トランザクション
Transaction ・ とらんざくしょん
複数の更新を「全部確定するか、全部なかったことにするか」の一塊として扱う仕組み。
✦ 30秒で試す — 航海データを書き終えていても、「確定」の一言が無ければ無かったことになる再起動したあと、AとBの残高の合計は?予想してみる概要
トランザクションは、複数のデータベース操作を「すべて確定するか、すべてなかったことにするか」のどちらかしかない一塊として扱う仕組みです。定番の例は銀行の振込で、「A の口座から 30 引く」と「B の口座に 30 足す」は別々の更新ですが、片方だけが残った状態は許されません。2つの更新をトランザクションで囲めば、途中でエラーや停電が起きても、中途半端な状態はデータベースに残りません。
RDBMS を使う開発では、EC サイトの注文確定(在庫を減らす・注文の行を作る・ポイントを付ける)のように、「まとめて意味を持つ複数の更新」が至るところに現れます。トランザクションは、そうした更新の組を障害と同時アクセスの両方から守る、業務システムの土台です。トランザクションが守る約束は、原子性・一貫性・分離性・永続性の頭文字をとって ACID と呼ばれます。
なぜ生まれたか
データベースの更新は、いつでも途中で止まりえます。プログラムのバグ、ネットワークの切断、サーバの電源断。複数の更新のうち一部だけがディスクに書かれた瞬間に止まれば、データは「引いたのに足されていない」矛盾した状態で固定されます。初期のシステムでは、こうした事故のあとで、人がログや帳票を突き合わせて直すしかありませんでした。
もう一つの問題は同時実行です。複数の利用者が同じデータを同時に読み書きすると、互いの途中経過が混ざり、単独なら正しい処理同士でも結果が壊れます。1970年代の IBM の System R などの研究を経て、ジム・グレイが1981年の論文「The Transaction Concept」で、障害からの回復と同時実行の制御を「トランザクション」という1つの単位で保証する考え方を整理しました。アプリケーションは「ここからここまでを一塊にする」と宣言するだけで、あとはデータベースが、止まった処理の後始末と、同時に走る処理の交通整理を引き受けます。この保証の中身を4つの性質にまとめたのが、1983年に名付けられた ACID です。
詳細
BEGIN・COMMIT・ROLLBACK
SQL では、BEGIN(または START TRANSACTION)でトランザクションを始め、一連の更新を行ったあと、確定するなら COMMIT、取り消すなら ROLLBACK を発行します。COMMIT が成功を返した時点で変更は確定し、ほかの接続からも見えるようになります。途中でエラーが起きたとき、明示的に ROLLBACK したとき、そして COMMIT の前にサーバが落ちたときは、開始前の状態にまるごと戻ります。
トランザクションの一生は、BEGIN で始まり、COMMIT か ROLLBACK のどちらかで必ず終わる状態遷移です。
大事なのは、確定の境界が COMMIT の一点にあることです。BEGIN と COMMIT のあいだで何文実行していても、COMMIT が済むまでは「まだ何も起きていない」扱いになり、COMMIT が済んだら全部が一度に起きたことになります。
ここでよくある落とし穴が**自動コミット(autocommit)**です。多くのデータベースやドライバは、BEGIN を書かないと1文ごとに暗黙のトランザクションを張り、文が終わるたびに COMMIT します。振込の2つの UPDATE を BEGIN なしで順に投げると、1文目が終わった瞬間に引き落としが確定し、2文目の前に落ちれば、お金が消えたまま残ります。
停電の位置を自分で動かして確かめてください。上の ⚡ を処理の列の隙間に落とすと、そこで電源が切れ、再起動した後の口座が下に出ます。「1文ごとに自動コミット」と「BEGIN〜COMMIT で囲む」を切り替えて、口座の合計が崩れる位置と保たれる位置を探してみてください。
上の ⚡ をつかんで、処理の列の好きな隙間へ落としてください。そこで停電し、再起動した後の口座が下に出ます。いまは「1文ごとに自動コミット」です。
口座の数字は単位を省いています。実際のデータベースは、停電の後の再起動で WAL(ログ)を読み、COMMIT の記録があるトランザクションだけを残します。
停電の後に何が起きるか
「COMMIT 済みは残し、COMMIT 前は消す」を、電源が突然切れても実現しているのが WAL(Write-Ahead Logging、先行書き込みログ)です。データベースは、データ本体を書き換える前に「どの行をどう変えるか」をログへ追記し、COMMIT のときにはそのログ(COMMIT の記録を含む)がディスクに届いたことを確かめてから成功を返します。データ本体への反映は後回しでかまいません。
再起動したデータベースは、まずログを読み直します。COMMIT の記録があるトランザクションの変更は、データ本体に届いていなくてもログから再適用します(REDO)。COMMIT の記録がないトランザクションの変更は、途中までデータ本体に書かれていても取り消します(UNDO)。PostgreSQL のように、取り消しの代わりに「その版は無効」と印を付けるだけの実装もありますが、外から見える結果は同じです。
図の T1 は、A の引き落としも B への入金もログに書いています。それでも COMMIT の記録がないので、再起動の後には何も残りません。「ログに書いたか」ではなく「COMMIT の記録がディスクに届いたか」で運命が決まります。
同時に走るトランザクション
トランザクションのもう一つの役割は、同時に走るほかのトランザクションの途中経過から守ることです。ただ「1本ずつ順番に実行したのと同じ結果」を常に保証すると並行性が落ちるため、SQL 標準はどこまで干渉を許すかを4段階のトランザクション分離レベルとして定めています。PostgreSQL の既定は READ COMMITTED、MySQL(InnoDB)の既定は REPEATABLE READ で、どちらも「読むたびに値が変わる」などの現象の一部を許しています。レベルごとに何が見え、何が見えないかは、分離レベルの記事で行の版を動かして確かめられます。
実務でとくに問題になるのが、「残高を読んでから書き戻す」あいだに別の更新が割り込み、先の更新が消えるロストアップデートです。既定のレベルでは防げないことが多く、SELECT ... FOR UPDATE で読む時点で行をロックする悲観ロックか、バージョン列で書く時点に競合を見つける楽観ロックを明示的に使います(楽観ロックと悲観ロック)。
実務での落とし穴
いちばん多い失敗は、トランザクションを長く張ることです。トランザクションの途中で外部の API を呼んだり、ユーザーの入力を待ったりすると、そのあいだ行のロックが握られたままになり、ほかの処理が軒並み待たされます。トランザクションは「短く、データベースの操作だけ」が原則です。また、2本のトランザクションが互いのロックを待ち合うデッドロックは、ロックの取り方をそろえても完全には避けられません。RDBMS は検出して片方を強制的に ROLLBACK するので、アプリケーションには「失敗したらトランザクションごとやり直す」実装が要ります。
見落とされがちなのが、COMMIT の応答が届かなかった場合です。COMMIT を送った直後にネットワークが切れると、クライアントには確定したのかどうかが分かりません。やみくもに再送すると送金が二重になるので、注文番号のような一意なキーで重複を弾くなど、再実行しても結果が変わらない冪等性を持たせておきます。
トランザクションの境界と分散
ここまでの保証は、1つのデータベースの中に閉じた話です。マイクロサービスのようにデータが複数のサービスとデータベースに分かれると、1つの COMMIT で全部を確定させることはできません。複数のデータベースに「全員準備できたら一斉に確定」を指示する2相コミットという方式もありますが、調整役が止まると全員が待ち続けるなど、可用性と性能の代償が大きく、サービス間ではあまり使われません。代わりに、各ステップを個別に確定し、失敗したら打ち消しの処理を流すサーガや、メッセージキューで変更を伝えて最終的に揃える結果整合性の設計を選びます。「どこでトランザクションの境界を切るか」は、サービスの分け方そのものを左右する問いです。
試問
2本の送金 T1(A→B に30)と T2(C→D に20)が、処理のブロックを交互に並べて実行されます。ブロックはドラッグで並べ替えられ(同じ送金の中の順番は変わらない)、上の ⚡ を列の隙間へ落とすとそこで停電し、再起動した後の口座が下に出ます。送金の書き方は「1文ごとに自動コミット」と「BEGIN〜COMMIT で囲む」から選べます。
上の ⚡ をつかんで、処理の列の好きな隙間へ落としてください。そこで停電し、再起動した後の口座が下に出ます。いまは「1文ごとに自動コミット」です。
口座の数字は単位を省いています。実際のデータベースは、停電の後の再起動で WAL(ログ)を読み、COMMIT の記録があるトランザクションだけを残します。
