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

トランザクション

読了目安 約4分

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

30秒でつかむ 複数の更新を、全部成功か全部取消にする
複数の更新を、全部成功か全部取消にするトランザクション内の更新は、COMMITですべて確定するか、ROLLBACKですべて取り消されます。1COMMIT全部を確定2複数の更新ひとまとまり3ROLLBACK全部を取消
  1. COMMIT 全部を確定
  2. 複数の更新 ひとまとまり
  3. ROLLBACK 全部を取消
この章の目次

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

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

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

ACID 特性

トランザクションが守る性質は 4 つに整理されていて、それぞれに名前が付いています。

  • 原子性(Atomicity):全部やるか、全部やらないか。送金の 2 本の UPDATE を不可分にした性質です。
  • 一貫性(Consistency):トランザクションの前後で、データが守るべき決まりを壊れたままにしない性質です。ただし「残高の合計が変わらない」のような業務上の決まりまで DB が自動で守るわけではありません。UNIQUE や外部キーなど宣言した制約を守るのが DB の役目で、送金の 2 本の UPDATE を両方とも書くのはアプリの責任です。
  • 分離性(Isolation):同時に走るトランザクションを互いに隔てる性質です。在庫の例では、素の SELECT で足りないぶんを FOR UPDATE で強めました。
  • 永続性(Durability):COMMIT で確定した変更は、直後にサーバーが落ちても消えない性質です。

頭文字を並べて ACID 特性と総称します。 データベースの解説の多くはこの 4 文字を前提に書かれているので、名前ごと覚えておくと読める文章が増えます。