SQL Scale Lab
第一部 DB 基礎編 · 第 7 章 · 約 30 分

途中を見せない(トランザクション)

「注文の作成」と「在庫の減算」をひとつの安全な処理としてまとめる。2つのセッションを動かし、コミット前の変更が他から見えない仕組みを体験します。

この章で答える問い
  1. 2 つの文を「1 つの操作」にするには
  2. 途中の状態は、他の接続からどう見えるか
  3. 途中で失敗したら、どうなるか

2つのセッションを起動: 独立した接続環境

この章の実行環境では、A と B という 2 つの独立したデータベース接続を使用します(第 0 章の SHOW PROCESSLIST で確認した、サーバー側で並行して動作する 2 つのスレッドに相当します)。 ボタンをクリックして、用意された SQL 文を接続 A または接続 B に送信してみましょう。まずは以下の順序でボタンを押してみてください:A-1 → A-2 → A-3 → B-1 → B-2。

A で注文を入れて在庫を減らし、B から覗く
見る場所: B-1 と B-2 の結果。A-4 のあとで、もう一度 B-1 と B-2
セッション A
セッション B
この版は記録の再生。

接続 A では注文を 1 件追加し、在庫数を 1 減らしました。 しかし、別の接続 B から確認すると、注文数は 50 件のままで、在庫数も減っていません。 次に A-4(COMMIT) を押してトランザクションを確定させてから、再度 B-1 と B-2 を押してみてください。今度は注文件数が 51 件になり、在庫も 1 減っていることが確認できるはずです。

これがトランザクションの仕組みです。BEGIN から COMMIT までの複数の処理は「ひとまとまりの不可分な操作」として扱われます。 COMMIT されるまでは他の接続から変更途中の状態は見えず、COMMIT された瞬間にすべての変更が一度に確定・公開されます。

ROLLBACK: 処理を取り消して元に戻す

やめる
見る場所: A-3 では減って見えるが、A-4 のあとの A-5 で元に戻っている
セッション A
セッション B
この版は記録の再生。

A-1 → A-2 → A-3 → B-1 → A-4 → A-5 の順に実行してみましょう。 A-3 の時点では接続 A 自身には在庫が 0 に見えますが、B-1 の接続 B には元の在庫数のまま見えています。 そして A-4 で ROLLBACK を実行すると、接続 A 側の変更もすべて破棄され、A-5 では元の値に復元されます。 COMMIT せずにロールバックすれば、行われた変更は最初から「何もなかったこと」になります。 第 5 章で紹介した「本番の手作業メンテナンスは BEGIN で始める」というルールは、まさにこの安全弁を利用するためです。影響行数が想定と違っていれば、即座に ROLLBACK で安全に巻き戻せます。

処理が途中で失敗したときの挙動

在庫が不足している場合の注文処理を考えてみましょう。在庫がマイナス値にならないよう、あらかじめテーブルに制約を追加しておきます。

在庫は 0 以上、という制約
期待: エラーなく終わる
在庫不足の注文
見る場所: A-3 が CHECK 制約でエラーになる。A-4 で全部取り消す
セッション A
セッション B
この版は記録の再生。

A-1 → A-2 → A-3(制約エラー発生)→ A-4 → A-5 と進めてみます。 ここで注意すべきは、A-3 がエラーで失敗しても、A-2 で追加した注文レコードは自動的には消えないという点です(MySQL はクエリ単位でエラーになっても、トランザクション全体を自動でロールバックするわけではありません)。 そのため、アプリケーション側で明示的に ROLLBACK を発行し、途中で追加された注文を取り消す必要があります。 A-5 で注文件数が元の 50 件に戻っていることを確認してください。

// アプリケーションコードの基本形
begin()
try {
  insert(注文)
  update(在庫を減らす)     // ここで例外が発生した場合
  commit()
} catch (error) {
  rollback()               // 直前の注文もすべて巻き戻して整合性を保つ
}

コミットによる変更の確定と、ロールバックによる取り消しのタイムラインを図で確認してみましょう。

BEGININSERT 注文UPDATE 在庫 −1COMMITB から見える値: 注文数 50 / 在庫 55(変更前のまま)51 / 54 が一度にUPDATE が失敗ROLLBACK注文は入ったまま → ROLLBACK で注文も消え、50 / 55 に戻るサーバーが落ちたときも、COMMIT 前の変更は同じく残らない。
BEGIN から COMMIT までの間は他の接続から見えず、エラー発生時に ROLLBACK すれば(あるいはサーバーがクラッシュしても)すべての変更が安全に巻き戻されます。

読み取りの一貫性: 読んでいる間、世界は止まって見える

B が読んでいる間に A が変える
見る場所: B-2 と B-3 は同じ値。B-4 のあとの B-2 で初めて変わる
セッション A
セッション B
この版は記録の再生。

B-1 → B-2 → A-1 → B-3(まだ古い値のまま)→ B-4 → B-2(最新の値に更新)という順序で実行してみます。 トランザクションを開始した接続 B からは、そのトランザクション内で最初にデータを読み取った時点のスナップショットが一貫して見え続けます。 途中で別の接続 A がデータを更新してコミットしても、接続 B の集計処理に矛盾が生じることはありません。 これは MySQL のデフォルトの動作(REPEATABLE READ 分離レベル)による保護です。

AUPDATE → 777(BEGIN なし = 即確定)BBEGINSELECT → 37SELECT → 37COMMITSELECT → 777表の今の値37777(A-1 の時点で確定)B に見える値37 のまま(最初の SELECT の時点)777(COMMIT の後)
接続 B のトランザクション内では開始時点のスナップショットを読み続けるため、接続 A が更新を確定させても、B が COMMIT して新しいトランザクションを始めるまで更新値は見えません。

ACID 特性(信頼性の 4 原則)

  • 原子性(Atomicity): すべて成功するか、すべて取り消されるかのどちらかであり、中途半端な状態を残さない。
  • 一貫性(Consistency): NOT NULL や CHECK 制約などの整合性ルールを破った不正な状態では処理を終了しない。
  • 分離性(Isolation): 処理の途中の状態を他の接続から見せない(独立性を保つ)。
  • 永続性(Durability): 一度 COMMIT されたデータは、その直後にシステム障害や停電が発生しても確実に失われない。

これら 4 つの頭文字を取って ACID 特性 と呼びます。単語を丸暗記するよりも、先ほどの 2 つの接続での検証体験と結びつけて理解しておくことが重要です。

後始末

ラボの「両方 ROLLBACK して切断」を押し、npm run seed:basics で作り直す。

BEGIN から COMMIT までは 1 つの操作。途中は他人に見えず、失敗すれば ROLLBACK で全部なかったことになる。

確認

BEGIN から COMMIT までの間、他の接続からは何が見えるか。
何も起きていないように見える。ひとまとまりの操作として扱われ、COMMIT した瞬間に全部が一度に見えるようになる。
1 文がエラーになったとき、アプリのコードから ROLLBACK を出す必要があるのはなぜか。
MySQL は 1 文が失敗してもトランザクションを自動では取り消さないから。先に成功した文がそのまま残るので、どれか 1 文が失敗したら必ず ROLLBACK と書く。
トランザクションの中で、外部への呼び出しや人の入力を待ってはいけないのはなぜか。
待っている間もトランザクションは開いたままで、触った行を他の人が触れなくなるから。BEGIN から COMMIT までの時間は短く保つ。

次の章へ

第 0 章で触れた「スプレッドシートでは破綻する課題」のうち、2 つ(同時の更新衝突、処理途中のクラッシュ)がこの章で解決しました。 残る1つの課題「データ行数が増加すると検索が遅くなる問題」に、次の章でいよいよ切り込みます。インデックスの仕組みとその正体を実際に確かめていきましょう。