🔩 ねじき教室 Go と Web の教室

トランザクション

読了目安 約3分

複数の書き込みを「全部やるか、全部やらないか」にまとめるトランザクションを学びます。

この章の目次

途中で止まってはいけない処理

alice から bob へ 1000 円を送金する処理を考えます。 残高を持つ accounts テーブルで、UPDATE は 2 本です。

SQL
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 を作ります。

SQL
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);

引き落としだけ実行して残高を見ます。

SQL
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE name = 'alice';
SELECT * FROM accounts;
テキスト
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
|  1 | alice |    2000 |
|  2 | bob   |    1000 |
+----+-------+---------+

ここで取り消します。

SQL
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 を使います。

SQL
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 にクエリを発行します。

Go
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 は無害です。

ロックは他のリクエストを待たせるので、範囲を広げすぎるとロック待ちがそのまま応答時間になります。 囲むのは「まとめて成功か失敗かであるべき一連の読み書き」だけにとどめます。