トランザクション
読了目安 約4分
複数の書き込みを「全部やるか、全部やらないか」にまとめるトランザクションを学びます。
- COMMIT 全部を確定
- 複数の更新 ひとまとまり
- ROLLBACK 全部を取消
この章の目次
途中で止まってはいけない処理
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 は無害です。
ロックは他のリクエストを待たせるので、範囲を広げすぎるとロック待ちがそのまま応答時間になります。 囲むのは「まとめて成功か失敗かであるべき一連の読み書き」だけにとどめます。
ACID 特性
トランザクションが守る性質は 4 つに整理されていて、それぞれに名前が付いています。
- 原子性(Atomicity):全部やるか、全部やらないか。送金の 2 本の UPDATE を不可分にした性質です。
- 一貫性(Consistency):トランザクションの前後で、データが守るべき決まりを壊れたままにしない性質です。ただし「残高の合計が変わらない」のような業務上の決まりまで DB が自動で守るわけではありません。UNIQUE や外部キーなど宣言した制約を守るのが DB の役目で、送金の 2 本の UPDATE を両方とも書くのはアプリの責任です。
- 分離性(Isolation):同時に走るトランザクションを互いに隔てる性質です。在庫の例では、素の SELECT で足りないぶんを FOR UPDATE で強めました。
- 永続性(Durability):COMMIT で確定した変更は、直後にサーバーが落ちても消えない性質です。
頭文字を並べて ACID 特性と総称します。 データベースの解説の多くはこの 4 文字を前提に書かれているので、名前ごと覚えておくと読める文章が増えます。