トランザクション 基礎

COMMIT は分離レベルごと、エンジンごと、ネットワークをまたぐかで別のことを意味する。30 の図、3 つの主張 ——ACID のどの文字もダイヤルで、すでに誰かが下げている;ぶつかる異常は出荷時のレベルが決める;ネットワークをまたげば契約全体が往復で交渉し直される。

01

COMMIT が実際に約束するもの

4 つの文字が BEGIN と COMMIT で何が買えるかを説明する。4 つとも約束ではない。どれもダイヤルで、どのデータベースも出荷時にいくつかを下げている。

原子性と永続性は同じ仕組みだ。データページに触る前に、エンジンは変更を先行書き込みログに追記する。ログがトランザクションそのもので、ページはそのキャッシュにすぎない。

下では 2 つのトランザクションが 1 本のログ上で交錯する。いま追記されているレコードが時計の位置で、T1 の COMMIT レコードが着地した瞬間、その手前にある T1 のすべてが永続する。再生してみる:

ログ上の 1 / 7 レコード目

注目してほしい: データページはディスクに届かない。メモリ上で変わるだけだ。永続性は COMMIT レコードでだけ到来する —— 1 回のシーケンシャルな fsync がトランザクション全体を生き延びさせる。だからログは追記専用で、ページはそうでなくていい。T2 は同じログに 2 レコード持つが COMMIT レコードがないので、復旧にとって T2 は起きなかったことになる。

これが復旧の走る不変条件だ: トランザクションが効いたのは、その commit レコードがディスク上にあるとき、かつそのときに限る。クラッシュ印をログ上で戻し、REDO 集合とUNDO 集合が組み替わるのを見る:

7 レコード書いた後にクラッシュ —— REDO 2、UNDO 2. クラッシュ印をログ上でドラッグ。矢印キーで 1 レコードずつ、Home で元に戻る
7 レコード書いた後にクラッシュ —— REDO 2、UNDO 2

の時点では T1 の更新 2 件がディスク上にあり COMMIT はない。だから生き残った更新 3 件はすべて UNDO され、確認応答を受け取っていないクライアントが「何も起きなかった」と考えるのは正しい。なら同じ 2 件が今度は REDO される。ARIES(Mohan、1992)はこの規則に、復旧自身が落ちても再開できる記録を足したものだ。

永続性の代金はコミットごとに 1 回の fsync で、その fsync がどこまで届く必要があるかが上限を決める。下の各段は 1 つ上の段に、バイトが届かねばならない場所をもう 1 つ足したもので、すでに払った段は点いたままになる。降りてみる:

0.10 µs —— 1 接続で毎秒 10,000,000 コミット

覚える価値があるのはからだ。ローカル NVMe の fsync が 50 µs、ネットワークディスクなら 700 µs で、その下の安い段はどちらにも含まれている。同じコード、同じクエリで、1 接続がコミットできる量が 14 倍違う —— 毎秒 20,000 対 1,429。その段から上はデータベースの問題ではなくストレージの問題で、SQL では調整できない。

そこでダイヤルが回される。synchronous_commit = off はフラッシュ前に COMMIT から戻り、PostgreSQL は最大でwal_writer_delay 3 サイクル遅れてフラッシュする —— 既定なら 600 ms。コミット速度を上げて窓が埋まるのを見る:

100/s —— 窓の中に確認済みコミット 60 件

では、窓の中にアプリケーションが「コミット済み」と告げられた3,000 件が入っている。これらは黙って失敗する。エラーもロールバックもログ行もなく、再起動後に行が単に存在しない。クラッシュとは別の失敗で、そして顧客まで届くのはこちらだ。

C は最も過大評価される文字だ。データベースが強制するのはあなたが宣言したもの —— CHECK、外部キー、UNIQUE、NOT NULL —— だけである。送金額を 120 より大きくして宣言済み制約を発火させ、次に片脚だけ書く設定に切り替える:

0 動かす —— a = 120

2 つの失敗は似ていない。CHECKは文を拒否する。エラーコード、ロールバックされたトランザクション、誰かが読むスタック。壊れた和は何も上げない。a + b = 200 は誰にも宣言されていないからだ。ACID の C はあなたの不変条件がトランザクションをまたいで成り立つことで、エンジンが手伝うのは書き下したぶんだけである。

これで 4 文字がそろった。下のつまみはどれも、そのうち 1 つをスループット・レイテンシ・手間と交換する。つまみを歩いて、それぞれが下げる文字を見る:

fsync=off — クラッシュで消失も半端な適用もありうる

このうち 2 つは決定ではなく既定値だ。PostgreSQL はREAD COMMITTED、InnoDB は REPEATABLE READで出荷される。コードが明示的に要求していない限りACID の I はすでに下がっており、永続性のつまみと違って誰も意図していない。

02

5 つの異常と、それぞれを止めるレベル

分離レベルは、並行する 2 つのトランザクションが互いをどれだけ見てよいかを決める。壊れうるものは 5 つ、どれも具体的なバグで、どれも別の設定で死ぬ。

5 つとも形は同じだ。2 つのトランザクション、1 つの時計、見えてはいけなかった何か。図はすべてその絵で、2 本のレーンが操作を運び、トラックで時計を歩かせる。後ろではすでに起きたことが点いたままだ。

まず一番安いもの。T1 が、T2 が書いてまだコミットしていない行を読み、そのあと T2 がロールバックする。再生して 1 歩ずつ進める:

T2 が stock = 0 を書く、未コミット

注目したいのは、T1 が読んだのが古い値ではないことだ。一度も存在しなかった値で、データベースのどのコミット済み状態にもない —— 前にも後にも、それが真だった瞬間はない。READ UNCOMMITTED より上はすべてこれを止めるし、 PostgreSQL は READ UNCOMMITTED を頼まれても止める。そんなレベルを持っておらず、黙って READ COMMITTEDを渡すからだ。

READ COMMITTED が買えるのはちょうどそこまでだ。文ごとに新しいスナップショットを取るので、1 つのトランザクション内で同じ行を 2 回読むと別々の値が返りうる:

T1 が残高を読む:100

注目したいのは、エラーが出ないことだ。T1 は1 行についての矛盾する 2 つの読み値で計算するだけだ。レポートの合計行が自分の明細と食い違うのも、すでに動いた残高に対して残高チェックが通ってしまうのも、これである。REPEATABLE READ は文ごとではなくトランザクション全体で 1 つのスナップショットを取って直す。

同じ問題には第 2 の形があり、標準はそれを別の危険として数える。変わった行ではなく、T1 がすでに数え終えた範囲に現れた行だ。時計を歩かせる:

T1 が一致する行を数える:3

標準の REPEATABLE READ はファントムを許す。読み取りロックはすでに存在する行にしか掛けられないからだ。スナップショットにその制限はない —— だから PostgreSQL のREPEATABLE READ(実体はスナップショット分離)は、名前が許すと言っているファントムを止める。InnoDB は索引区間へのギャップロックという別の道で同じ場所に着く。

次は実際に本番へ出るもので、これは SQL ではなくアプリケーションコードに住む。 2 つのトランザクションが同じ残高を読み、読んだ値から引き、結果を書き戻す:

T1 が 100 を読む

注目してほしい:どちらのコミットも成功し、どちらの呼び出し元も引き出し成功と告げられた。行は 90 を保持し、本来は 60で、消えた 30 はどこでもエラーを上げない。これが更新の消失で、アプリケーションコードで最も多いトランザクションのバグであり、オブジェクトを読み・フィールドを変え・保存する ORM の書き方から最も入りやすいものだ。

最後の 1 つは、スナップショット分離を丸ごと通り抜けるほど微妙だ。2 つのトランザクションが同じ 2 行を読み、それぞれ自分の書き込みは安全だと判断し、それぞれ別の行に書く:

T1 が当直の医師を数える:2

別々の行に書いたので、MVCC が捕まえられる書き込み競合が存在しない。各書き込みは単体では合法で、不変条件はこの 2 つが合わさって初めて壊れる。それを見るには各トランザクションが何を読んだかを追う必要があり、それこそが直列化可能スナップショット分離が上に足すものであり、それより弱いものには決してできないことだ。

5 つの異常、4 つのレベル、そしてレベルの意味について食い違う 3 つのエンジン。レベルを歩き、その下でエンジンを切り替える:

READ UNCOMMITTED · SQL 標準

食い違いこそが要だ。で PostgreSQL は更新の消失を止めず、SQLSTATE 40001 でアボートさせ、リトライループを期待する。InnoDB は同じレベル・同じ異常で、その書き込みを黙って通す。ロック読み取りはスナップショットではなく最新のコミット済み行を取るからだ。片方は大きな声で、片方は静かに失敗し、アプリケーションコードは同一である。

だから直し方はレベルの選択ではなく書き方の選択になる。次の 4 つはどれも同じ 100 から 30 と 10 を引く。エンジンも、いま使っているレベルも同じだ。引き落としを片方失うのは最初の 1 つだけである。

-- 1  読んでから書く。残高 → 90
T1: SELECT bal FROM acct WHERE id = 1;   -- 100
T2: SELECT bal FROM acct WHERE id = 1;   -- 100
T1: UPDATE acct SET bal = 70 WHERE id = 1;
T2: UPDATE acct SET bal = 90 WHERE id = 1;

-- 2  アトミック UPDATE。残高 → 60
T1: UPDATE acct SET bal = bal - 30 WHERE id = 1;
T2: UPDATE acct SET bal = bal - 10 WHERE id = 1;

-- 3  FOR UPDATE。残高 → 60
T1: SELECT bal FROM acct WHERE id = 1 FOR UPDATE;
T2: SELECT bal FROM acct WHERE id = 1 FOR UPDATE;
--  T2 は T1 の COMMIT を待ち、70 を読む

-- 4  SERIALIZABLE。残高 → 60
BEGIN ISOLATION LEVEL SERIALIZABLE;
  SELECT bal FROM acct WHERE id = 1;
  UPDATE acct SET bal = 70 WHERE id = 1;
COMMIT;  -- 敗者は 40001、ブロックごと再試行

違いは引き算をどこでやるかにある。書き方 1 はアプリケーションで引き、リテラルを送る。残る 3 つは引き算そのものを送るか、2 人目の読み手を待たせるか、2 人目のコミットを失敗させる。切り替えて、それぞれが残す残高を読む:

100 から 30 と 10 を引き出す → 90

注目してほしい: 間違っているのは最初の 1 つだけで、しかもレビューを通り抜ける形で間違っている —— 読み、アプリケーションで計算し、リテラルを書く。残る 3 つは 2 つのトランザクションに同じ出発値から計算させない。1 つは読まないことで、1 つは行ロックをコミットまで握ることで、1 つは負けたほうをアボートして再実行する。

その最後の 1 つは無料ではない。コミットが確率 pで競合するなら試行は幾何分布に従い、1 件のコミットを着地させるのに1/(1−p) 回かかる。競合確率を上げる:

p = 0.00 → コミット 1 件あたり 1.00 回の試行

ではコミットごとに 2 回の試行がかかり、有効な仕事の半分が捨てられる。 では 10 回、90% だ。 Ports と Grittner の読み中心ベンチマークでは、SSI は素のスナップショット分離と数パーセントしか違わない —— 高いのは記録ではなくアボートで、その率はワークロードの性質だ。

03

読み取りがロックを取らない仕組み

悲観ロックは単純で、競合下で崩れる。読み手が書き手を止め、書き手が読み手を止める。MVCC はロックを算術に置き換える。

規則は「行を上書きしない」だ。UPDATE は新しいバージョンを作り古いものに死んだ印を付ける。各バージョンは隠し列を 2 つ持つ —— 作った xmin と殺した xmax、生きているあいだ後者は 0 だ。

トランザクション id 軸の上に描くと、バージョンは区間になる。xmin から xmax まで存在する。あなたのスナップショットは縦線で、それが横切るバージョンが読まれるものだ。横に引いてみる:

スナップショット 50,120 → バージョン 2. 左右にドラッグ。矢印キーで 1 つずつ、Home で元に戻る
スナップショット 50,120 → バージョン 2

注目してほしいのは仕事量の少なさだ。ロックも待機も、書き手との調整もない —— 読み取りはバージョンごとに整数比較 2 回で、異なるスナップショットで同じ行を読む 2 つのトランザクションは、互いの存在を知らないまま別のバージョンに着地する。

この 2 回の比較が規則のすべてで、ループ表明として書ける。バージョンが見えるのは、作った側がスナップショット前にコミットしていて殺した側がしていないとき。スナップショットを動かし、書いたトランザクションの結末を変える:

スナップショット 50,140. 左右にドラッグ。矢印キーで 1 つずつ、Home で元に戻る
スナップショット 50,140 — 見える

書き手を実行中にすると、xminがスナップショットより小さくてもバージョンは消える。効くのはコミットであって書き込みではない。アボートにすると永久に消える —— ロールバックしたトランザクションが、現在も未来もどのスナップショットにも見る権利のないバージョンをディスクに残すのはこのためだ。

誰も二度と見ないのに、そこにある。それが MVCC の請求書だ。更新 1 回につき死んだバージョン 1 つが、生きているものと同じ 8 KiB ページに入る。更新回数を上げてページを埋める:

更新 0 回 → 死んだバージョン 0 個

注目すべきは、シーケンシャルスキャンがそのすべてを読むことだ。請求は書き込みだけでなく全クエリに乗る。では、テーブルは生きた行 1 つと死んだ 27 個を抱え、スキャンは同じ答えに 28 倍のバイトを払う。これが肥大化で、スループットを連続的に落としながら決してエラーを出さない。だからログではなくレイテンシのグラフで見つかる。

VACUUM が回収係で、破れない規則を 1 つ持つ。バージョンを消してよいのは実行中のどのスナップショットももう欲しがらないときだけだ。地平線を戻して、回収可能な集合を空にする:

地平線 50,300 → 回収可能 3 個、固定 0 個。地平線を左右にドラッグ。矢印キーで動かし、Home で元に戻る
地平線 50,300 → 回収可能 3 個、固定 0 個

地平線は最も古い実行中スナップショットなので、idle in transaction のまま座っている接続が 1 つあれば、その後ろの死んだバージョンがクラスタ全体で固定される。autovacuum は走り続け、解放を許されるものを見つけられず、テーブルは育つ。idle_in_transaction_session_timeout が存在する理由であり、トランザクションを閉じ忘れるアプリケーションが接続リークではなくストレージ障害である理由でもある。

時計はもう 1 つあり、そちらのほうが厄介だ。トランザクション id は 32 ビットで、各 id は自分の後ろ 231 個しか見えないので、使える空間は有限である。使い切ってみる:

0 使用 —— 残り 2.15 B、5,000 tps なら 119 時間

注目すべきは、最後の 2 つのしきい値がほぼ同時に来ることだ。PostgreSQL はautovacuum_freeze_max_age(既定 2 億)で autovacuum を強制し、から警告を出し、残り 300 万でクラスタを停止させる —— 一周させるくらいなら止める、ということだ。毎秒 5,000 の書き込みトランザクションなら 4,000 万 id は 2.2 時間 —— 警告の窓はスプリント 1 本ではなく、オンコールの引き継ぎ 1 回ぶんだ。

どの MVCC エンジンも同じ請求書を積み、違うのは死んだバージョンを生きた行の隣に置くか外に出すかだけである。4 つを歩く:

テーブルそのものの中 —— 肥大化し、vacuum が要る

PostgreSQL はそれらをテーブルに置く。だから「肥大化」は PostgreSQL の語彙だ。InnoDB と Oracle は undo ログに置き、症状は undo 領域の増大と ORA-01555 snapshot too oldに移る。SQL Server は tempdb。壊すものは同じで、開きっぱなしの読み手だ。

04

トランザクションがネットワークをまたぐとき

単一ノードの ACID はほぼ解決済みだ。2 つのデータベース、 2 つのサービス、2 つのリージョンにまたがった瞬間、保証はすべて交渉し直しになる —— そして教科書の答えは、運用者が絶対に入れるなと言うものだ。

今回のリクエストはカードに請求し、在庫を確保し、メールを送り、監査行を書く。4 つのシステム、1 つの「コミット」。1 つのコーディネータが全員に先に尋ね、あとで告げる —— fsync 1 回の代わりに往復 2 回だ。

2 つの相がある。PREPAREで各参加者が変更を永続化して投票し、そのあと決定が配られる。再生する:

3 つとも開始を告げられる

注目してほしい:賛成票が何を要求するかだ。投票した参加者は変更を自分のログに書き終え、必要なロックをすべて取っており、自分ではコミットもアボートもできない —— 1 通のメッセージを待っている。PostgreSQL ではこの状態を prepared transaction と呼び、行だけでなく §03 の vacuum 地平線も固定する。

この窓こそが反対理由のすべてだ。コーディネータのクラッシュをプロトコル上でドラッグし、どの参加者も自力で復旧できない区間を見つける:

BEGIN で死ぬ —— まだ誰も投票していない、安全にアボートできる。クラッシュ印をプロトコル上でドラッグ。矢印キーで 1 ステップずつ、Home で元に戻る
BEGIN で死ぬ —— まだ誰も投票していない、安全にアボートできる

誰も投票していないうちはタイムアウトが安全だ。何も永続していないので全員がアボートできる。決定が永続したあとは、復旧がそれを再生する。とのあいだには安全な単独判断が存在せず、参加者は運用者が介入するまでロックを握る —— そしてその行を欲しがるトランザクションが全部その後ろに並ぶ。

そこで現代のシステムはサービスをまたぐ原子性を諦め、補償で買い戻す。各ステップはローカルトランザクションで、それぞれに逆操作がある。どのステップが失敗するかを選ぶ:

全ステップ成功 —— 補償は不要

分散ロックも未決状態もない —— 原子性もない。請求とその補償のあいだには、外の世界が「払ったのに何も得ていない顧客」を見られる実際の区間がある。saga はその窓を消しはしない。短くし、あなたの責任にする。そして補償は業務ロジックであり、前向きの経路と同時に書かなければならない。

saga は各ステップを再試行可能にもする。つまり各ステップは冪等でなければならない。受信側が重複排除に使えるキーの有無を切り替えて、請求を再試行する:

1 回請求

キーがなければ、タイムアウト後の再試行は新しいリクエストと区別が付かず、顧客は試行ごとに請求される。キーは最適化ではない —— at-least-once 配送の上に物を建てられるようにする前提であり、API の最初のバージョンから入れるべきものだ。あとから足すというのは、キーなしで発生した請求を突き合わせるという意味になる。

saga はサービスを、レプリケーションはコピーを調整し、そこでの答えは多数決だ。グループの大きさと停止している台数を選ぶ:

3 ノード → 定足数 2、1 台まで許容 · 0 台停止

定足数は ⌊n/2⌋+1 なので、は2 台の故障を、は 3 台を許容する。偶数は何も買わない。6 ノードも許容は 2 台で、書き込みごとにレプリカ 1 台ぶん余計に払う。野生のクラスタ台数が奇数なのはそれが理由だ。

代償はレイテンシで、決めるのはコードではなく地理である。遠いレプリカを動かし、2 番目に近いものからコミット時間を読む:

コミット 0.2 ms —— 直列で毎秒 5,000 コミット

注目したいのは、近いフォロワーが 1 つあっても定足数にはならないことだ —— リーダーは 2 つ目の賛成を必要とするので、払うのは2 番目に近い往復で、定足数の外にいるものはまったく費用にならない。遠いレプリカがにあると、コミットごとに 70 ms、直列の書き手は毎秒 14 コミットで頭打ちになる。ディスクはどこにも関与していない。Spanner はこれを払ったうえで約 2ε ——— おおよそ 10 ms ——— の commit wait を足し、タイムスタンプを地球規模で順序付ける。

そしてネットワークが割れると、多数決があなたの代わりに決める。分断をグループ上でドラッグし、少数派の側に何を許すかを切り替える:

5 | 0 —— 左が多数派。左右にドラッグ。矢印キーで 1 つずつ、Home で元に戻る
5 | 0 —— 左が多数派

これが CAP で、しかも少数派の側だけについての選択だ。 では片側はまだコミットでき、もう片側は書き込みを拒むか分岐するかを選ぶ。 PACELC は皆が忘れる半分を足す —— それ以外のとき、分断がまったくなくても、読み取りが非同期レプリカへ行くことを許すたびにレイテンシと一貫性を交換している。どちらの取引もたいてい設定ファイルの中で、 p99 を最適化していた誰かによって行われる。

05

クイックリファレンス

そらで答える価値のある 3 つの問いと、レビューで探す 5 つのもの。

直列化可能性と線形化可能性の違いは何か。

この 2 つは直交している。スケジュールを固定し、どちらを問うかだけを切り替えて、2 つの判定を読む:

ある直列順序と等価で、その順序は時計を尊重する

直列化可能性はトランザクションの性質だ。スケジュールがある直列順序と一致すること。時間は出てこない。線形化可能性は個々の操作の性質で、各操作が呼び出しと返却のあいだの 1 点で効き、その点が実時間を守る。古いレプリカは直列化可能だが線形化可能ではない。書き込みスキューはどちらでもない。 Spanner は両方を買う —— レプリカ間 Paxos、内側は SSI、コミットの順序付けに TrueTime —— そしてそれを外部一貫性と呼ぶ。

このコードはどの分離レベルを使うべきか。

まず答えにならない。決めるのはコードが何をしているかで、下の 4 つのうち3 つはレベルの変更を要さない:

数を読み、変えて、書き戻す → 1 つのアトミック UPDATE

SERIALIZABLE が要るのは最後の 1 つ —— 個別にロックできない行にまたがる不変条件 —— だけで、リトライループとセットで要る。

  • FOR UPDATE なしの SELECT のあとのUPDATE。 更新の消失。InnoDB は無音、PostgreSQL は 40001。
  • HTTP 呼び出しをまたいで開いたままのトランザクション。 行ロックが第三者の p99 と同じ長さになり、vacuum 地平線も固定する。
  • 補償が TODO の saga。 最初の部分失敗で 3 サービスが割れ、戻す手段がない。
  • XA によるサービス横断トランザクション。 コーディネータが 1 回落ちれば全参加者が固まる。ロックは握ったまま。
  • 「性能のため」の READ UNCOMMITTED。 PostgreSQL では無効だ。

では 1 回のコミットは端から端でいくらか。

どこまで運ばせるか次第だ。下の図が足すのは §01 のディスクと §04 の定足数:

コミットあたり 50 µs —— 直列で毎秒 20,000 回