TiDB 検証ライフ(10-分散トランザクションの検証2)

2021年は、皆様にとってどのような年だったでしょうか。
自分にとっては、殻を破るため冒険に出た激動の1年でした。

NewSQLのTiDBに触れ始め、ワクワクさせられながら1ヶ月経ちました。
今回は今年の最終回で、並行トランザクションの同時実行制御(デッドロック含)を検証してみます。
関連して、TiDBトランザクション分離レベルも少しみてみます。

検証環境は、前回[TiDB 検証ライフ(9-分散トランザクションの検証1)](https://qiita.com/MingchunZhao/items/777337ce02659f612cc7)の環境を続けて使用することにします。

## 同時実行制御の検証

同時実行制御って大事ですね、TiDBがどこまでやってくれるか気になります。
悩まず、とにかくやってみます。

### 検証シナリオ

僕がA銀行からB銀行へ、
妻がB銀行からA銀行へ、
同時に振替ボタンを押した、といったシナリオです。
![スクリーンショット 2021-12-31 11.17.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61975/c3149095-f21a-09f1-0fe2-b33d48ed4f4a.png)
(妻が怒っているように見えますが、フィクションです。とても仲よいです)

### 検証スタート(悲観的トランザクション)

前回と比べ、端末2、端末3の用途が変わります。

端末2:　僕の振替用セッション
端末3:　妻の振替用セッション

#### 端末2のセッションから、僕が操作開始

##### A、B銀行の口座残高を確認します

```
select * from a_bank.account;
 name |  balance  
------+-----------
 foo  | 400000.00 
(1 row)

select * from b_bank.account;
 name |  balance  
------+-----------
 foo  | 100000.00 
(1 row)
```

##### トランザクションモードを確認

```
select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 pessimistic 
(1 row)
```

デフォルトの悲観的モードになっています。

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### A銀行から10万円引き出す

```
update a_bank.account set balance=balance-100000 where name='foo';
UPDATE 1
```

##### B銀行に10万円振り込む

```
update b_bank.account set balance=balance+100000 where name='foo';
UPDATE 1
```

#### ここで、端末3のセッションから、妻が操作開始

##### トランザクションモードを確認

```
select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 pessimistic 
(1 row)
```

デフォルトの悲観的モードになっています。

##### A、B銀行の口座残高を確認します

```
select * from a_bank.account;
 name |  balance  
------+-----------
 foo  | 400000.00 
(1 row)

select * from b_bank.account;
 name |  balance  
------+-----------
 foo  | 100000.00 
(1 row)
```

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### B銀行から10万円引き出す

```
update b_bank.account set balance=balance-100000 where name='foo';
（待ち状態。。。。。。）
error: mysql: 1205: Lock wait timeout exceeded; try restarting transaction
```

すると、`UPDATE`がロック待ちに入り、タイムアウトで失敗しました。
悲観的モードなので、相手の更新中は、ロック待ちに陥ってしまいますね。

じゃ、楽観的トランザクションではどうなるでしょうか。

### 検証やり直し(楽観的トランザクション)

#### 端末2のセッションから、僕が操作開始

##### 一旦、今までの処理をロールバック

```
rollback;
ROLLBACK
```

##### 楽観的モードに切り替え

```
set @@tidb_txn_mode='optimistic';
SET

select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 optimistic 
(1 row)
```

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### A銀行から10万円引き出す

```
update a_bank.account set balance=balance-100000 where name='foo';
UPDATE 1
```

##### B銀行に10万円振り込む

```
update b_bank.account set balance=balance+100000 where name='foo';
UPDATE 1
```

#### ここで、端末3のセッションから、妻が操作開始

##### 一旦、今までの処理をロールバック

```
rollback;
ROLLBACK
```

##### 楽観的モードに切り替え

```
set @@tidb_txn_mode='optimistic';
SET

select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 optimistic 
(1 row)
```

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### B銀行から10万円引き出す

```
update b_bank.account set balance=balance-100000 where name='foo';
UPDATE 1
```

`UPDATE`がロック待機せず成功。
悲観的モードとは違う結果だ。

##### A銀行に10万円振り込む

```
update a_bank.account set balance=balance+100000 where name='foo';
UPDATE 1
```

ここも通った。

#### 端末2から、トランザクションのステータスを確認

```
select * from information_schema.tidb_trx\G
*************************** 1. row ***************************
ID: 430159871769051137
START_TIME: 2021-12-31T14:48:06.619+09:00
CURRENT_SQL_DIGEST: 
CURRENT_SQL_DIGEST_TEXT: 
STATE: Idle
WAITING_START_TIME: 
MEM_BUFFER_KEYS: 4
MEM_BUFFER_BYTES: 158
SESSION_ID: 7
USER: root
DB: b_bank
ALL_SQL_DIGESTS: ["f53534242526f5f17dd442bf4429823244510563047efdcc0679741ca5833ea6","df9f53eb4cb1672927cb6374dd27fa80a8124ec0186f390ea41cac113d8428ac","dc5f0aff8b925c386a79b6e33f64e79e814e2310a12f0d793fe9ab4c9c8c0eee","d7815f6adab7ceebe67882038b7d29c081675c0e94751110ff74213523b6998e"]
```

Idel状態になっている。

#### 端末3から、トランザクションのステータスを確認

```
select * from information_schema.tidb_trx\G
*************************** 1. row ***************************
ID: 430159928314822659
START_TIME: 2021-12-31T14:51:42.324+09:00
CURRENT_SQL_DIGEST: 
CURRENT_SQL_DIGEST_TEXT: 
STATE: Idle
WAITING_START_TIME: 
MEM_BUFFER_KEYS: 4
MEM_BUFFER_BYTES: 158
SESSION_ID: 7
USER: root
DB: 
ALL_SQL_DIGESTS: ["f53534242526f5f17dd442bf4429823244510563047efdcc0679741ca5833ea6","8e412fff994ffcdc77c6516ab298cc58851c07c354eb766a283a16bc997845ab","05704c1de71e0adc626cf220a7fbae005553d5ef74ed6bf44dec053cd656d474","d7815f6adab7ceebe67882038b7d29c081675c0e94751110ff74213523b6998e"]
```

端末2同様、Idle状態。

#### 端末2から、トランザクションをコミット

```
commit;
COMMIT
```

コミット成功し、A、B銀行の口座残高も正常に更新されている。

```
select * from a_bank.account;
 name |  balance  
------+-----------
 foo  | 300000.00 
(1 row)

select * from b_bank.account;
 name |  balance  
------+-----------
 foo  | 200000.00 
(1 row)
```

#### 端末3から、トランザクションをコミット

```
commit;
error: mysql: 9007: Write conflict, txnStartTS=430159928314822659, conflictStartTS=430159871769051137, conflictCommitTS=430160050056593410, key={tableID=66, handle=1} primary={tableID=66, handle=1} [try again later]
```

書き込みで競合発生し失敗。
楽観的モードで、コミット時に競合チェックするのである意味当然か。

この楽観的モードは、大規模な分散データベースとして、TiDBが採用しているストレージエンジン[RocksDB](https://rocksdb.org/)が提供している機能らしいです。

### トランザクションの分離レベル

並行トランザクション競合時の挙動は、理論上トランザクション分離レベルに依存するようです。
分離レベルは、同時「読み取り/書き込み」で競合発生時、どこまでデータ整合性を担保できるかを示してくれます。

#### ANSI SQL-92標準で定義されて四つの分離レベルです

- Read Uncommitted
- Read Committed
- Repeatable Read
- Serializable

#### TiDBの分離レベルは何よ

MySQLとの互換性のため`Repeatable Read`として知られている`Snapshot Isolation(SI)`レベルの整合性を実装しているようです。これは、ANSI [Repeatable Read](https://docs.pingcap.com/tidb/stable/transaction-isolation-levels#difference-between-tidb-and-ansi-repeatable-read)レベルとも、MySQL [Repeatable Read](https://docs.pingcap.com/tidb/stable/transaction-isolation-levels#difference-between-tidb-and-mysql-repeatable-read)レベルとも、異なるようでご注意を。

※ PingCAP社のマニュアルから引用: https://docs.pingcap.com/tidb/stable/transaction-isolation-levels

## デッドロックの検証

トランザクション間でデッドロックが起きたら、厄介ですね。
通常は外部からロック解除してあげないといけませんが、TiDBはどう対処するでしょうか。

### 検証シナリオ

僕と妻が、同じA銀行口座に対し、以下のように交互に行レベル操作を行う、といったシナリオです。

1. 僕がfooレコードから1万円引く
2. 妻がbarレコードから1万円引く
3. 僕がbarレコードに1万円足す
4. 妻がfooレコードに1万円足す

操作の肝は、

- 異なるトランザクションから
- 同じテーブルの複数行に対し
- 交互に同じ行の値を更新

で、金額を引いたか足したかは、どうでもよいです。

### 検証スタート

#### 1. 端末2のセッションから、僕が操作開始

##### 一旦、今までの処理をロールバック

```
rollback;
ROLLBACK
```

##### 悲観的モードに切り替え

```
set @@tidb_txn_mode='pessimistic';
SET

select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 pessimistic 
(1 row)
```

##### A銀行口座にbarレコードを追加

```
insert into a_bank.account values('bar',800000);
INSERT 1

select * from a_bank.account;
 name |  balance  
------+-----------
 bar  | 800000.00 
 foo  | 300000.00 
(2 rows)
```

これで、A銀行口座に、foo、barの2レコード存在します。

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### A銀行口座のfooから1万円引く

```
update a_bank.account set balance=balance-10000 where name='foo';
UPDATE 1
```

#### 2. 端末3のセッションから、妻が操作開始

##### 一旦、今までの処理をロールバック

```
rollback;
ROLLBACK
```

##### 悲観的モードに切り替え

```
set @@tidb_txn_mode='pessimistic';
SET

select @@tidb_txn_mode;
 @@tidb_txn_mode 
-----------------
 pessimistic 
(1 row)
```

##### トランザクション開始

```
start transaction;
START TRANSACTION
```

##### A銀行口座のbarから1万円引く

```
update a_bank.account set balance=balance-10000 where name='bar';
UPDATE 1
```

#### 3. 端末2のセッションから、僕が操作

##### A銀行口座のbarに1万円足す

```
update a_bank.account set balance=balance+10000 where name='bar';
UPDATE 1
```

#### 4. 端末3のセッションから、妻が操作

##### A銀行口座のfooに1万円足す

```
update a_bank.account set balance=balance+10000 where name='foo';
error: mysql: 1213: Deadlock found when trying to get lock; try restarting transaction
```

ここで、すぐデッドロックが検知され、処理中断されました。
延々にロック待ちに陥るようなことはなさそうです、すごい!

##### DEADLOCKS表から詳細情報を確認

```
select * from information_schema.deadlocks\G
*************************** 1. row ***************************
DEADLOCK_ID: 1
OCCUR_TIME: 2021-12-31T16:48:06.575191+09:00
RETRYABLE: 0
TRY_LOCK_TRX_ID: 430161722650394625
CURRENT_SQL_DIGEST: 05704c1de71e0adc626cf220a7fbae005553d5ef74ed6bf44dec053cd656d474
CURRENT_SQL_DIGEST_TEXT: update `a_bank` . `account` set `balance` = `balance` + ? where `name` = ? ;
KEY: 7480000000000000455F698000000000000001016261720000000000FA
KEY_INFO: {"db_id":57,"db_name":"a_bank","table_id":69,"table_name":"account","index_id":1,"index_name":"PRIMARY","index_values":["bar"]}
TRX_HOLDING_LOCK: 430161741511655425
*************************** 2. row ***************************
DEADLOCK_ID: 1
OCCUR_TIME: 2021-12-31T16:48:06.575191+09:00
RETRYABLE: 0
TRY_LOCK_TRX_ID: 430161741511655425
CURRENT_SQL_DIGEST: 05704c1de71e0adc626cf220a7fbae005553d5ef74ed6bf44dec053cd656d474
CURRENT_SQL_DIGEST_TEXT: update `a_bank` . `account` set `balance` = `balance` + ? where `name` = ? ;
KEY: 7480000000000000455F69800000000000000101666F6F0000000000FA
KEY_INFO: {"db_id":57,"db_name":"a_bank","table_id":69,"table_name":"account","index_id":1,"index_name":"PRIMARY","index_values":["foo"]}
TRX_HOLDING_LOCK: 430161722650394625
```

デッドロックの詳細情報を確認できます。

- 発生時間
- ロック(待たせる側、待たされる側)
- SQL文
- レコードのキー

これで、原因調査を行い、再発防止策を立てられそうです。

## 終わりに

なんとか年越す前に、トランザクション検証を終えることができました。
TiDBの魅力に徐々に引き込まれていきそうです。

2022年は、技術とメンタル両面で自分を磨き上げ強くなりたい、
と心に決めながら、今年の検証ライフを締めさせていただきます。

ご多用中お付き合いいただき、ありがとうございました。
良いお年をお迎えください、来年もどうぞよろしくお願いいたします。