Docker × MySQL で学ぶ、トランザクションとデッドロック体験

# Docker × MySQL で学ぶ、 トランザクションとデッドロック体験

**トランザクション**や**ロック**の仕組みは、データベースを扱う上で欠かせない重要な概念との事で学ぶ。  
特に、複数の処理が同時に動く環境では、**デッドロック**という相互待ちの問題が発生することがあります。  
本記事では、MySQL を Docker コンテナで手軽に立ち上げ、**超シンプルな銀行システム**を例にして「デッドロックをわざと起こしてみる → 対策してみる」流れを一通り体験します。
恩師にローカルは無闇に汚すなと教育されてきたので、今回は MySQL はインストールせず Docker Hub から取得する形で手軽に環境構築をします！

---

## 目次

1. [トランザクションとは](#トランザクションとは)  
2. [ロックとは](#ロックとは)  
3. [デッドロックとは](#デッドロックとは)  
4. [Docker × MySQL で環境を作る](#docker--mysql-で環境を作る)  
   1. [MySQL イメージを取得](#mysql-イメージを取得)  
   2. [コンテナを起動](#コンテナを起動)  
   3. [コンテナ内で MySQL を使う](#コンテナ内で-mysql-を使う)  
5. [デッドロックを体験する（銀行システム例）](#デッドロックを体験する銀行システム例)  
   1. [テーブル準備](#テーブル準備)  
   2. [デッドロックを引き起こす状況を用意](#デッドロックを引き起こす状況を用意)  
   3. [デッドロックを起こす](#デッドロック発生)  
6. [デッドロックの対策](#デッドロックの対策)  
   1. [リトライする方法](#対策方法1)  
   2. [更新順序を統一する方法](#対策方法2)  

---

## トランザクションとは

**トランザクション**とは、データベースにおける「一連の操作」をひとまとまりとして扱う仕組みです。

典型的には以下のような**ACID特性**を満たします。

- **Atomicity（原子性）**  
  成功ならすべて反映、失敗ならすべて取り消す
- **Consistency（一貫性）**  
  トランザクション実行前後でデータの整合性を保つ
- **Isolation（分離性）**  
  複数のトランザクションが干渉し合わないようにする
- **Durability（永続性）**  
  コミットが完了したら障害が起きてもデータが消えない

例えば、**銀行システム**で「口座Aからお金を引き出し(Aの残高をマイナス)、口座Bへ振り込む(Bの残高をプラス)」という複数の処理で成り立つ一連の操作を途中で止めずに確実に行うために、トランザクションが使われます。

---

## ロックとは
データベースでは、複数のトランザクションが同時に同じデータを操作するとき、データの不整合や競合を防ぐために「ロック」を使います。ロックを取得することで、あるトランザクションがデータを操作している最中、他のトランザクションに操作を制限する仕組みを実現します。


### 1. 共有ロック（Shared Lock）

今回は共有ロックは体験しませんが、紹介です。
#### 目的
- **読み取り専用ロック**とも呼ばれます。  
- データを“読んでいる”間、他のトランザクションも同じデータを読み取ることはできますが、書き込み（更新・削除）は行えません。  
- 複数の「読み取り専用トランザクション」が、同時に同じデータに対して共有ロックを取得できます。

#### どんなタイミングで使われるか
- **データを読み取りつつ、他からの書き込みをブロックしたい**場合に使われます。  
- たとえば、MySQL での `SELECT ... LOCK IN SHARE MODE`（古い構文）や `SELECT ... FOR SHARE`（MySQL 8.0 / PostgreSQL）を利用すると、共有ロックがかかります。

### 2. 排他ロック（Exclusive Lock）
今回体験するのがこちらです。

#### 目的

- **書き込み（更新）操作を独占**するためのロックです。
- 該当データを操作する間、**他のトランザクションが同じデータに対して読み書きを行えない**ように制限します。
- 同じデータに対して同時に排他ロックを取得できるのは**1つのトランザクションだけ**です。

#### どんなタイミングで使われるか

- トランザクション内で**更新系クエリ (UPDATE/DELETE/INSERT) を実行するとき**

### ロック解除のタイミング
- 共有ロック、排他ロックどちらも`COMMIT;`や`ROLLBACK;`でトランザクションが終了した時にロックが解除されます。
- トランザクションが続く限りロックは保持されるため、長時間ロックしていると他の処理をブロックしてしまう可能性があります。

---

## デッドロックとは

**デッドロック**は、複数のトランザクションが互いに相手のロックを待ち合う状態で、どちらも先に進めなくなる問題です。  
例えば、**口座1** → **口座2** の順で更新しているトランザクションAと、**口座2** → **口座1** の順で更新しているトランザクションBが同時に走ると、お互いが相手のロック解放を待ち合って永遠に進めなくなります。

ちなみに
共有ロックと排他ロックで起きるデッドロックを"**変換デッドロック**"、
排他ロックと排他ロックで起きるデッドロックを"**サイクルデッドロック**"
というそうです。

多くのデータベースでは、**デッドロックを検知すると、片方のトランザクションを強制的にロールバックして解消**します。MySQL(InnoDB)の場合は、以下のようなエラーが返ります。
`ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction`

文章で説明してもいまいち良くわからないので、実際にMySQLでいじってみます！
今回はサイクルデッドロックを体験しようと思います！

---

## Docker × MySQL環境を作る

### MySQL イメージを取得
Docker Desktop(もしくはOrbStack)のインストールはされている前提で進ませてください。

ターミナルにて
```bash
docker pull mysql:8.0
```
でMySQL 8.0 系のイメージを Docker Hub から取得します。
バージョンを固定したい場合は mysql:5.7 のように指定可能です。

### コンテナを起動
```bash
docker run --name my-mysql \
  -e MYSQL_ROOT_PASSWORD=my-secret-pw \
  -p 3306:3306 \
  -d mysql:8.0
```
これでホストの localhost:3306 にアクセスすると、コンテナ内の MySQL に繋げます。

### コンテナ内で MySQL を使う
```bash
docker exec -it my-mysql bash
mysql -u root -p
```
パスワード入力を求められたら、先ほど MYSQL_ROOT_PASSWORD に指定した my-secret-pw を入力してください。
これで MySQL クライアントを使えるようになります。

---

## デッドロックを体験する（銀行システム例）
ここでは、超シンプルな銀行口座テーブルを作って、2つのトランザクションで同時に口座残高を更新し合い、デッドロックを起こしてみます。以下の流れで行きます。
1. 2つのターミナルで MySQL コンテナに入る
2. 片方のターミナルで簡単な銀行テーブルを作成し、初期データを用意
3. 2つのセッションから同時にトランザクションを開始して更新を試みる
4. デッドロックを発生させる

### テーブル準備
```sql
-- ターミナル1
CREATE DATABASE IF NOT EXISTS bank_demo;
USE bank_demo;

-- 銀行口座テーブル
CREATE TABLE bank_accounts (
  account_id INT PRIMARY KEY,
  account_name VARCHAR(50),
  balance INT
) ENGINE=InnoDB;

-- 初期データを投入
INSERT INTO bank_accounts (account_id, account_name, balance) VALUES
(1, 'OHTANI', 1000),
(2, 'MIZUHARA', 1000);

-- テーブルの確認
SELECT * FROM bank_accounts;
+------------+--------------+---------+
| account_id | account_name | balance |
+------------+--------------+---------+
|          1 | OHTANI       |    1000 |
|          2 | MIZUHARA     |    1000 |
+------------+--------------+---------+
2 rows in set (0.00 sec)
```
ちなみにbalanceは口座残高という意味。

### デッドロックを引き起こす状況を用意
**シナリオ概要**
・大谷 から 水原 へ100を振り込むトランザクション1 (ターミナル1)
・水原 から 大谷 へ100を振り込むトランザクション2 (ターミナル2)

```sql
-- ターミナル1
USE bank_demo;

START TRANSACTION;

/* Aliceの口座から100引き落とす */
UPDATE bank_accounts
   SET balance = balance - 100
 WHERE account_id = 1;  -- 大谷
 
 -- ここでまだ COMMIT や ROLLBACK をせず、待機（トランザクションをそのまま）
```
これにより、bank_accounts テーブルの id=1 (大谷) の行に対して排他ロックが取得されている状態です。なぜならトランザクション内でUPDATE文を書くことが、排他ロックの発動条件だったからです。

次に同じMySQLコンテナに入った別タブのターミナルで
```sql
-- ターミナル2
USE bank_demo;

START TRANSACTION;

/* Bobの口座から100引き落とす */
UPDATE bank_accounts
   SET balance = balance - 100
 WHERE account_id = 2;  -- 水原

-- ここでまだ COMMIT や ROLLBACK はしない
```
これで水原の行にも排他ロックがかかりました。

### デッドロック発生
まずはトランザクション1の続きで、大谷から水原の振込を完了したいので、水原のレコードを更新しようとします。
```sql
-- ターミナル1 (大谷 -> 水原 へ入金する処理を続行)
UPDATE bank_accounts
   SET balance = balance + 100
 WHERE account_id = 2;
```
ここで、トランザクション2 がすでに id=2(水原の行) をロックしているため、このクエリは待ち状態（ロック解除待ち）になるので何も出力されないはずです。水原の行をロックしている他のトランザクションが終了すれば待ち状態は解放されて、処理を進むことができます。

そして次は、トランザクション2で水原から大谷の振り込みを完了したいので、大谷のレコードを更新しようとします。
```sql
-- ターミナル2 (水原 -> 大谷 へ入金処理を続行)
UPDATE bank_accounts
   SET balance = balance + 100
 WHERE account_id = 1;
```
トランザクション1 が id=1 をロック中のため、こちらも待ち状態になります。

**トランザクション1はトランザクション2が終了しないと処理を進められない。
トランザクション2はトランザクション1が終了しないと処理を進められない。
どちらものトランザクションも永遠に進めない！これがデッドロック状態です。**

しかしInnoDBはデッドロックを検出すると,どちらか一方のトランザクションを**強制ロールバック**します。そして、そのトランザクションには下記のようなエラーが返されると思います。
```bash
#ターミナル2
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
```

ここでターミナル１を確認すると、トランザクション1はエラーが出ていません。
試しに`COMMIT;`でトランザクションを終了して、テーブルの中身を確認します。
```sql
-- ターミナル1
SELECT * FROM bank_accounts;

+------------+--------------+---------+
| account_id | account_name | balance |
+------------+--------------+---------+
|          1 | OHTANI       |     900 |
|          2 | MIZUHARA     |    1100 |
+------------+--------------+---------+
2 rows in set (0.00 sec)
```
トランザクション1の処理だけは問題なく完了していることがわかりますね。

---

## デッドロックを対策してみる
### 対策方法1

step1. **トランザクションを短く保つ**
トランザクション時に画面入力待ちなどを挟まず、必要最小限の更新処理だけを行って即コミットする。
step2. **デッドロック (エラー1213) が起きたら、ロールバック後に再実行**
DBMSがデッドロックを感知してくれることを信じれば、片方は成功しているので失敗した側のトランザクションを再実行すれば良い。

この対策法は「デッドロックは起こり得る」という前提で、発生時に再度トランザクションをやり直す方法です。

今回はstep2についてしか体験できませんが、先ほどエラーが出たターミナル2の方で、再度同じSQL文を叩いて`COMMIT;`してテーブルを確認すると、OHTANI 1000, MIZUHARA 1000という予定通りの結果となっているのが確認できます。結構当たり前です。

### 対策方法2
**更新順序を統一する**

デッドロックは、異なる順序で同じリソース(今回は行)をロックしようとすると発生しやすいです。
そこで、「常に同じ順序でロックを取得」 するルールを設計段階で決めておくと、相互待ちを起こしにくくなります。

**銀行口座の場合**：
口座ID が小さい方 → 大きい方の順で必ず UPDATE など

**テーブルが複数ある場合**：
必ずテーブルA → テーブルB の順で UPDATE など

さっきの簡単な銀行システムの例で試してみます。
```sql
-- ターミナル1
START TRANSACTION;

UPDATE bank_accounts SET balance = balance - 100 WHERE account_id = 1;  -- 先に1を更新
UPDATE bank_accounts SET balance = balance + 100 WHERE account_id = 2;  -- 次に2を更新
-- ここでまだ COMMIT しないでおく
```

次にトランザクション2ではid=2 → id=1への送金だが、更新する行の順序を1→2とする。
```sql
-- ターミナル2
START TRANSACTION;

UPDATE bank_accounts SET balance = balance + 100 WHERE account_id = 1;   -- 先に1を更新
UPDATE bank_accounts SET balance = balance - 100 WHERE account_id = 2;   -- 次に2を更新
```
この時ターミナル2では一つ目の更新処理がロック解除待ちとなっていることが確認できます。なぜならトランザクション1ではまだ`COMMIT;`していないのでid=1の行はロック解除されていないからです。そこでターミナル1に戻り`COMMIT;`をしてトランザクション終了をして、またターミナル2に戻ると処理が正常に進んでいることが確認できます！トランザクション2も`COMMIT;`してテーブルを確認します。
```sql
SELECT * FROM bank_accounts;
+------------+--------------+---------+
| account_id | account_name | balance |
+------------+--------------+---------+
|          1 | OHTANI       |    1000 |
|          2 | MIZUHARA     |    1000 |
+------------+--------------+---------+
2 rows in set (0.00 sec)
```
上手くいってますね！
これでデッドロックせず、処理後のデータの整合性が取れている状態となりました！

---

## まとめ
今回は超簡単な例としてデッドロックを体験してみましたが、実務レベルでどこまでトランザクションを意識しているのかも気になるし、デッドロックを対策するために実装の際はどんなロジックを書けば良いのかなども今後記事として出せたらなと思います。