トランザクション
読了目安 約3分
複数の書き込みを「全部やるか、全部やらないか」にまとめるトランザクションを学びます。
この章の目次
途中で止まってはいけない処理
alice から bob へ 1000 円を送金する処理を考えます。 残高を持つ accounts テーブルで、UPDATE は 2 本です。
UPDATE accounts SET balance = balance - 1000 WHERE name = 'alice';
UPDATE accounts SET balance = balance + 1000 WHERE name = 'bob';1 本目と 2 本目の間でサーバーが落ちると、引き落とされた 1000 円が入金されないまま消えます。 許されるのは「両方成功した」か「何も起きなかった」かだけです。
BEGIN、COMMIT、ROLLBACK
この要求に応えるのがトランザクションです。 複数のクエリを 1 つの不可分な単位にまとめ、「全部やるか、全部やらないか」を DB が保証します。 この性質を原子性と呼びます。
- BEGIN:トランザクションを開始する。
- COMMIT:ここまでの変更をまとめて確定する。
- ROLLBACK:ここまでの変更をまとめて取り消す。
mysql クライアントで accounts を作ります。
CREATE TABLE accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
balance INT NOT NULL
);
INSERT INTO accounts (name, balance) VALUES ('alice', 3000), ('bob', 1000);引き落としだけ実行して残高を見ます。
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE name = 'alice';
SELECT * FROM accounts;+----+-------+---------+
| id | name | balance |
+----+-------+---------+
| 1 | alice | 2000 |
| 2 | bob | 1000 |
+----+-------+---------+ここで取り消します。
ROLLBACK;
SELECT * FROM accounts;+----+-------+---------+
| id | name | balance |
+----+-------+---------+
| 1 | alice | 3000 |
| 2 | bob | 1000 |
+----+-------+---------+前章の「UPDATE に元に戻すボタンはない」は、正確には「COMMIT 後はもう戻せない」です。
BEGIN と COMMIT の間で落ちた未確定の変更も、MySQL が自動で捨てます。 送金の 2 本を挟んでおけば、片方だけ実行された結果はどう落ちても残りません。
同時アクセスで壊れる例
在庫 1 個の商品に 2 人がほぼ同時に購入ボタンを押すと、「在庫を SELECT で確認してから減らして注文を作る」処理はこうなりえます。
時刻 A さんのリクエスト B さんのリクエスト
t1 SELECT stock → 1
t2 SELECT stock → 1
t3 在庫ありと判断、stock を 0 に更新
t4 注文を作成
t5 在庫ありと判断、stock を 0 に更新
t6 注文を作成(在庫はもうない)両方の SELECT が更新前の「1」を見て、在庫 1 個に注文が 2 件成立します。
素の SELECT は他の読み取りを止めないので、BEGIN で囲むだけでは防げません。
割り込まれたくない読み取りには SELECT ... FOR UPDATE を使います。
BEGIN;
SELECT stock FROM items WHERE id = 1 FOR UPDATE;
-- stock が 1 以上なら
UPDATE items SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders (item_id, user_id) VALUES (1, 2);
COMMIT;FOR UPDATE 付きで読んだ行にはロック(他のトランザクションを待たせる鍵)がかかります。 B さんの FOR UPDATE は A さんの COMMIT まで待たされ、更新後の在庫 0 を読んで「在庫切れ」にできます。 items と orders は仮のテーブルです。
Go から使う
Go では db.BeginTx で開始し、返ってきた tx にクエリを発行します。
func transfer(ctx context.Context, db *sql.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - 1000 WHERE name = ?", "alice"); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + 1000 WHERE name = ?", "bob"); err != nil {
return err
}
return tx.Commit()
}defer tx.Rollback() を開始直後に書くのがイディオムです。
どの return から抜けても未 COMMIT の変更が捨てられ、Commit 後の Rollback は無害です。
ロックは他のリクエストを待たせるので、範囲を広げすぎるとロック待ちがそのまま応答時間になります。 囲むのは「まとめて成功か失敗かであるべき一連の読み書き」だけにとどめます。