トランザクション 基礎

ハンドラが我々の curl https://api.example.com/user/42BEGIN; SELECT user; UPDATE last_seen; COMMIT; で包んだ。 これらのキーワードは自明に見える —— そうではない。COMMIT は 異なる分離レベル、異なるエンジン、レプリカをまたぐ、サービスをまたぐで、 異なることを意味する。4 セクションで全体像を組み立てる:ACID —— 各文字が実際に保証するものとそれを弱めるダイヤル;分離レベルとそれらが防ぐ異常、 在庫の最後の 1 個を競う 2 つのトランザクションの対話的ウォークスルー付き;MVCC —— Postgres が読み取りロックを取らずに読む方法、 そして vacuum が存在する理由;そして分散トランザクション —— 2PC、Saga、Raft / Paxos、そしてあなたが避けられない CAP / PACELC のトレードオフ。 最後にクイックリファレンス

01

ACID —— 各文字が実際に保証するもの

リクエストは BEGIN; SELECT user; UPDATE last_seen; COMMIT; になった。 4 つの文字がそれらのキーワードが守るべき契約を記述する。 どれも絶対ではない —— あらゆる本番システムは各部分をスループット、レイテンシ、 またはコストと引き換えにする。

ACID —— 原子性一貫性分離性耐久性 —— は 1983 年に Härder と Reuter が トランザクションのあるべき姿を記述するために造語した。「トランザクション」という 言葉はこれなしではほとんど何も意味しない。

A —— 原子性

BEGINCOMMIT の間のすべての文は、すべて効果を持つか、 どれも持たないかのいずれか。「半分のトランザクション」は存在しない。 2 つ目の UPDATE が失敗すれば、1 つ目はロールバックされる —— その一部がすでに WAL またはデータページに書かれていたとしても。

実装:エンジンは何かを変更する前にアンドゥ情報を書く (Postgres は古い行バージョンを保持;MySQL/InnoDB は別の undo log に書く)。ROLLBACK 時に undo は逆順で再生される。クラッシュ回復時、 回復コードは WAL の再適用(REDO)か undo の適用(UNDO)で進行中のトランザクションを 終わらせる —— Mohan の ARIES アルゴリズム(1992)が標準参照で、現代のエンジンの 大半はその変種。

C —— 一貫性

トランザクションはデータベースをある有効な状態から別の状態へ移す。ここで「有効」とは あなたのアプリケーションが言う通り。データベースはあなたが宣言したものだけを強制する:CHECK 制約、外部キー、UNIQUENOT NULL。 アプリケーション不変条件(「すべての注文には少なくとも 1 つの明細行がある」、 「総借方 = 総貸方」)はあなたの責任 —— ACID の C は最も弱い文字で、最も多くの人が過大評価する文字。

重要:ACID の C は CAP の C ではない。ACID-C は制約充足に関するもの; CAP-C はすべてのレプリカが同じ時点で同じ値を見ることに関するもの。同じ文字、まったく異なる概念。

I —— 分離性

並行トランザクションは 1 つずつ実行されたかのように振る舞う。これは最も高価な約束だから、 SQL はダイヤルを与える:READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE。 Section 02 で各レベルが実際に何を防ぎ、より重要なのは、まだ何を許すかを歩く。

デフォルトはほぼ絶対に SERIALIZABLE ではない。Postgres はREAD COMMITTED を出荷;MySQL InnoDB は REPEATABLE READ を出荷; Oracle は READ COMMITTED を出荷。 コードが明示的に SERIALIZABLE を要求していなければ、 教科書の「分離」の定義より弱い何かの上で動いている。

D —— 耐久性

COMMIT が成功を返したら、物理メディア喪失以外のクラッシュには変更が耐える。 実装:変更を先行書き込みログに書く、fsync を呼ぶ、戻る。fsync が荷重を担う syscall —— ページをカーネルページキャッシュから押し出し、 ジャーナルを通して、プラッタに(大半の SSD では、FUA / cache-flush コマンド経由でドライブの揮発キャッシュからも)。

現実のシステムはスループットのために D を常に弱める。Postgres にはsynchronous_commit = off がある —— COMMIT は fsync を待たずに戻る、 スループットは ~10 倍上がる、代償はクラッシュ時に最大 600 ms のコミット済みトランザクションを失うこと。 MySQL には innodb_flush_log_at_trx_commit = 0|1|2 がある、同じトレードオフ。 大半のクラウド管理データベースは安全な設定をデフォルトに; 大半のセルフマネージドは疲れた DBA によっていつか下げられる。

4 つすべてダイヤル、ブール値ではない

ノブ                                  弱める    なぜ回すか
─────────────────────────────────── ───────── ─────────────────────────────
fsync = off(Postgres)               A + D     ベンチマーク。本番では絶対に。
synchronous_commit = off            D         バルク書き込み;小さな喪失を許容。
innodb_flush_log_at_trx_commit = 0  D         同じ考え、MySQL。
READ UNCOMMITTED                    I         ホットテーブル上のレポートクエリ。
NOLOCK ヒント(SQL Server)           I         同。
非同期レプリケーション              D(マルチ) ack 済み未複製 → 失う可能性。
FK / CHECK 制約なし                 C         アプリケーションが強制、らしい。

要点。「ACID は契約: 原子性(全か無か、WAL + undo で実装)、 一貫性(宣言された制約はトランザクションを通じて保持される; アプリ不変条件はまだあなたのもの)、 分離性(READ UNCOMMITTED から SERIALIZABLE までのダイヤル; デフォルトは絶対に SERIALIZABLE ではない)、 耐久性(コミット済み = クラッシュに耐える、fsync で実装; synchronous_commit=off、非同期レプリケーション、その他多くのノブで弱められる)。 DB のダイヤルがどこにあるかを知ることがすべて。」

02

分離レベルとそれらが防ぐ異常

SQL 標準は 4 つの分離レベルを定義する。あらゆるデータベースは serializable より弱いデフォルトを選び、トランザクションコードパスで本番に出荷されたほぼすべての バグは、選ばれたレベルがまだ許す異常まで遡れる。

分離は、2 つのトランザクションが互いに影響する前にどれだけ重なれるかを制御するダイヤル。 強い = 驚き少、ブロックと中止多。弱い = スループットよし、 インシデントチャンネルで「待って、在庫が -1 になっているのはなぜ?」が多くなる。

4 つの標準レベル

レベル            ダーティ 非反復          ファントム ロスト    ライト
                  リード   リード          リード   アップデート スキュー
───────────────── ─────── ─────────────── ──────── ───────── ─────────
READ UNCOMMITTED  許可    許可            許可     許可      許可
READ COMMITTED    ブロック 許可            許可     許可*     許可
REPEATABLE READ   ブロック ブロック        場合に   ブロック  許可
                                          よる
SERIALIZABLE      ブロック ブロック        ブロック ブロック  ブロック

5 つの異なる危険があるように見えるので、定義しよう。それぞれは具体的なバグパターン、 専門用語ではない。

5 つの異常、例で定義

  1. ダーティリード。T1 は T2 が変更したが未コミットの行を読む。 T2 がロールバック;T1 は存在したことのない値を見た。READ COMMITTED 以上でブロック。
  2. 非反復リード。T1 は row 7 を読み、同じトランザクション内で後に row 7 を再度読む。T2 がその間に変更した。T1 は「同じ」行に対して 2 つの異なる値を得る。REPEATABLE READ 以上でブロック。
  3. ファントムリード。T1 が SELECT count(*) WHERE x>5 を 2 回実行する。T2 がその間に一致する行を挿入する。T1 は異なるカウントを見る。 標準では SERIALIZABLE がブロック;Postgres の REPEATABLE READ(実際にはスナップショット分離)もブロックする。
  4. ロストアップデート。T1 が X=1 を読み、T2 が X=1 を読み、両方が X=2 を書く。 一方が静かに上書きされる。これはアプリケーションコード内の古典的な read-modify-write 競合。行ロックを取る(SELECT ... FOR UPDATE)か、単一のアトミックなUPDATE 文で作業することで防ぐ。
  5. ライトスキュー。T1 が X と Y を読み、Y の値に基づいて X を更新することにする。 T2 が X と Y を読み、X の値に基づいて Y を更新することにする。互いに書き込みを上書きしない —— が、結合状態は両方がチェックしていた不変条件を破る。SERIALIZABLE のみが捕まえる。

実際のデータベースが実際にすること

SQL 標準はガイドライン;エンジンは分岐する:

  • Postgres。デフォルトは READ COMMITTED。 その REPEATABLE READ は実際には スナップショット分離 —— 最初の文でスナップショットを取り、そのスナップショットを全トランザクションで見る。 その SERIALIZABLESerializable Snapshot Isolation(SSI): REPEATABLE READ 上で動作し、シリアライゼーションサイクルを形成するトランザクションペアの 一方を中止する述語ロックトラッカーを加える。楽観的 —— 中止のみのコスト。
  • MySQL InnoDB。デフォルトは REPEATABLE READ。 読み取りには MVCC を使用するが、REPEATABLE READ 下では ファントムを防ぐためにギャップロックも使用する。これは INSERT 重のワークロードで 有名にデッドロックしやすい。
  • Oracle。デフォルトは READ COMMITTED。そのSERIALIZABLE もスナップショット分離 —— 名前にもかかわらずライトスキューを許す。 (SSI 以前の Postgres も同じ。)
  • SQL Server。デフォルトは悲観ロック付き READ COMMITTED; MVCC 挙動には READ_COMMITTED_SNAPSHOT に切り替えられる。

ロストアップデートの正しいツール

アプリケーションコード内で最も一般的なトランザクションバグパターン:

-- 悪い:READ COMMITTED 下のロストアップデート
BEGIN;
  SELECT balance FROM accounts WHERE id = 42;  -- 100 を返す
  -- アプリが balance - 30 = 70 を計算
  UPDATE accounts SET balance = 70 WHERE id = 42;
COMMIT;

-- 良い(オプション 1):アトミック、読み-書き競合なし
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 42;
COMMIT;

-- 良い(オプション 2):行ロックをコミットまで保持
BEGIN;
  SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
  -- 安全に読み;T2 はここでブロック
  UPDATE accounts SET balance = 70 WHERE id = 42;
COMMIT;

-- 良い(オプション 3):SERIALIZABLE + 再試行ループ
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  SELECT balance FROM accounts WHERE id = 42;
  UPDATE accounts SET balance = 70 WHERE id = 42;
COMMIT;  -- 40001 で失敗するかも;アプリ再試行

新しい値が古い値の純粋な関数であるときはオプション 1 がベスト。アプリが決定を する必要があるとき(例「balance < 30 なら拒否」)はオプション 2 がベスト。 多くの不変条件をチェックし、ロックを手で配置したくないときはオプション 3 がベスト; コストは時々の再試行。

2 つの並行トランザクション、4 つの分離レベルセットアップ —— 2 ユーザが在庫の最後の 1 個を同時に買う任意のレベルT1(ユーザ A)BEGIN;SELECT qty FROM items WHERE id = 1; -- 1 を読む-- 買うと決めるUPDATE items SET qty = qty - 1 WHERE id = 1;COMMIT;T2(ユーザ B)BEGIN;SELECT qty FROM items WHERE id = 1; -- 1 を読む-- 買うと決めるUPDATE items SET qty = qty - 1 WHERE id = 1;COMMIT;
在庫は qty=1 から始まる。2 つのトランザクションが交錯する。何が起こるかは分離レベルだけで決まる。
1 / 8
同じ SQL、同じ 2 つのトランザクション;分離レベルがどの異常を得るかを決める。 Postgres のデフォルトは READ COMMITTED;MySQL InnoDB は REPEATABLE READ;誰も SERIALIZABLE をデフォルトにしない —— 最も高価だから。下のロストアップデートとライトスキューのバグは、ショッピングカート、 オンコールスケジューラ、銀行送金コードで本番に出荷されたことが何度も何度もある。

なぜ誰も SERIALIZABLE をデフォルトにしないか

コスト。Postgres SSI は楽観的だが、読み取りごとにまだ簿記を加える;競合下では 中止再試行が支配的になり得る。2 相ロック SERIALIZABLE(オリジナルの教科書実装)は さらに悪い —— 長い保持ロックとデッドロック。大半のワークロードはREAD COMMITTED + クリティカルパスでの SELECT FOR UPDATE で 生きられ、CPU と競合で 2 倍少なく支払える。これがあらゆるエンジンが行った 人間工学的トレードオフ。

要点。「5 つの異常、4 つのレベル。ダーティリード、非反復リード、 ファントムリード、ロストアップデート、ライトスキュー —— それぞれ異なるレベルでブロック。 Postgres のデフォルトは READ COMMITTED;MySQL InnoDB は REPEATABLE READ; どれも SERIALIZABLE をデフォルトにしない。アプリケーションコード内の SELECT-then-UPDATE パターンは READ COMMITTED 下でアップデートを失う —— アトミック UPDATE、SELECT FOR UPDATE、または SERIALIZABLE + 再試行で修正。 ライトスキューは真の serializable(Postgres SSI)でのみ死に、ほぼ誰も デフォルトでその代金を払わない。」

03

MVCC —— Postgres が読み取りロックを取らずに読む方法

マルチバージョン同時実行制御は、読み手と書き手が互いを無視できるようにするトリック。 すべての行はそれを生んだトランザクションと殺したトランザクションの ID を持つ; すべての読み手は、それが始まったときにどのトランザクションが「コミット済み」と みなされたかを示すスナップショットを持つ。読み取り = 可視性のチェック、 ロックは関与しない。

悲観ロック(古典的アプローチ)はシンプル:読むには共有ロックを取り、書くには排他ロックを取る。 読み手は書き手をブロックし、その逆も。動作はするが、競合下でスループットが崩壊する。

MVCC は言う:行を所定の位置で削除または上書きしない。UPDATE は 「新しいバージョンを作り、古いものを死亡とマークする」を意味する。DELETE は「行を死亡とマークする」を意味する。 読み手は見るべきでない死バージョンを通り過ぎる;書き手は読み手をブロックしない。 代償は膨張(古いバージョンが蓄積する)と、それらをクリーンアップするバックグラウンドプロセス(vacuum)。

xmin と xmax —— あなたに見えない 2 つの列

すべての Postgres 行には 2 つの隠れた列がある:

  • xmin —— この行バージョンを作成したトランザクション ID。
  • xmax —— この行バージョンを削除または更新したトランザクション ID(まだ生きていれば 0)。

それだけ。可視性はこれら 2 つの数とあなたのスナップショットに対する計算。

-- 自分で見る
SELECT xmin, xmax, * FROM users WHERE id = 42;

 xmin  | xmax | id | name  | last_seen
-------+------+----+-------+-----------
 50001 |    0 | 42 | alice | 2026-05-26
 (1 row)

-- トランザクション 50100 による UPDATE last_seen の後:
 xmin  | xmax  | id | name  | last_seen
-------+-------+----+-------+-----------
 50001 | 50100 | 42 | alice | 2026-05-26  ← 古いバージョン
 50100 |     0 | 42 | alice | 2026-05-27  ← 新しいバージョン

スナップショットと可視性ルール

トランザクション(または READ COMMITTED では単一の文)が始まるとき、 エンジンはスナップショットを取る:大まかに(xmin, xmax, in_progress[])、「xmin 以下はすべてコミット済み; xmax 以上は何も始まっていない;in_progress は今動いているもの」を捕捉する。

行バージョンがあなたのトランザクションに可視なのは:

  1. その xmin がスナップショットの xmin より下で in_progress リストにない(あなたが始まる前にコミット済み)、かつ
  2. もしくは xmax = 0(まだ生きている)、または xmax がスナップショットの xmax 以上 / in_progress リスト内 / 中止されたトランザクションに属する(「削除」はまだあなたにはカウントされない)。

結果:読み取りにロックは関与しない。2 つの並行な読み手は互いを待たない; 書き手は読み手をブロックしない。書き手同士はまだ同じ行を更新する 2 人を防ぐために 行レベルの排他ロックを取り合う。

コスト:膨張と vacuum

死んだ行バージョンが積み上がる。激しく更新されるテーブルは、生きている行の 10 倍の 死んだ行を持つことがある —— 順次スキャンごとに死んだバイトを読み、 インデックスルックアップごとに墓石を通り過ぎる。これが膨張で、 静かにパフォーマンスを沈める。

VACUUM が GC:テーブルをスキャンし、xmax が 現在実行中の最古スナップショットより下の行(「もう誰もこの行を見られない」)を見つけ、 スペースを回収する。autovacuum がそれを自動的に実行する; 書き込み重のテーブルでは通常そのトリガー(autovacuum_vacuum_scale_factorautovacuum_naptime)をチューニングする必要がある、 デフォルトはあなたのものよりも穏やかなワークロードを想定しているから。

-- 膨張したテーブルを見つける
SELECT schemaname, relname,
       n_live_tup, n_dead_tup,
       round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 1) AS pct_dead
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;

-- 手動クリーンアップ(短時間ブロック)
VACUUM (VERBOSE, ANALYZE) users;

-- ディスクも回収(テーブルを書き換え;ACCESS EXCLUSIVE ロックを取る)
VACUUM FULL users;

VACUUM FREEZE とラップアラウンド緊急事態

Postgres トランザクション ID は 32 ビット(使用可能)。~20 億トランザクションで ラップする。行の xmin がトランザクション 12345 のもので、 現在のトランザクション ID がそれをラップして過ぎていたら、 突然「行は未来に作成された」—— 可視性が壊れる。

修正はフリージング:古い xmin 値を「永遠に全員に可視」 特殊マーカで書き換える。VACUUM は行が十分古いとき自動的にこれを行う (vacuum_freeze_min_age)。書き込み重のデータベースが十分速く凍結していないなら、 Postgres は最終的にデータ完全性を保護するため新しい書き込みの受け入れを拒否する —— これが有名なトランザクションラップアラウンド緊急事態で、主要サイト(Sentry、Mailchimp など)を ダウンさせた。datfrozenxid を監視し、autovacuum を無効にしない。

MVCC vs 他のエンジン

エンジン           どうやって                                    コスト
─────────────     ──────────────────────────────────────────── ─────────────
Postgres          xmin/xmax がタプル内;古いバージョンはテーブル内 膨張、vacuum
MySQL InnoDB      古いバージョンは別の undo log                  Undo 増大
Oracle            古いバージョンは UNDO テーブルスペース         UNDO 管理
SQL Server        古いバージョンは tempdb(snapshot モード)     Tempdb 増大

どこでも同じアイデア、死んだバイトの保管場所が違う。Postgres のテーブル内アプローチが、 「膨張」が Postgres 特有の用語である理由 —— 他のエンジンも同じ問題を持つが、 バイトは他の場所に住む。

なぜスナップショット分離はライトスキューを許すか

両方のトランザクションは BEGIN でスナップショットを取る;両方とも 同じ世界を見る。両方とも異なる行を更新する。どの単一行にも書き-書き競合がないので、 MVCC の可視性チェックは捕まえない。サイクルは各トランザクションが読んだ述語を 追跡するときだけ可視 —— それが Postgres SSI がスナップショット分離の上に加えるもの。 SSI は読み/書き依存のグラフを維持し、任意のサイクル内で 1 つのトランザクションを中止する。

要点。「MVCC:すべての行に xmin/xmax、すべてのトランザクションがスナップショットを取る、 可視性は計算 —— 読み取りロックなし。代償は死んだ行の蓄積(膨張)、VACUUM で清掃; vacuum 不足の書き込み重のテーブルは遅くなり、Postgres では最終的に txn-ID ラップアラウンド 緊急事態に当たる。スナップショット分離は単一行に書き-書き競合がないからライトスキューを許す; Postgres SSI はそれを捕まえるために上に述語ロックサイクル検出を加える。」

04

分散トランザクション —— 2PC、Saga、Raft、CAP の分岐

単一ノード ACID はほぼ解決した問題。トランザクションが 2 つのデータベース、 2 つのサービス、または 2 つのリージョンに及んだ瞬間、頼っていたあらゆる保証が 再びテーブルに戻る —— そして教科書の答え(2PC)は経験のあるシステム屋が 絶対に使うなと言う答え。

我々の curl は 1 つの API サービスに行き、それは 1 つのデータベースと 会話した。現実のシステムはそんなにラッキーではない。この user-update トランザクションは さらに必要:決済サービス経由でカードを請求、メールサービス経由で確認メール送信、 分析データベースに監査行を書く、user 行を別リージョンの読み取りレプリカに複製。 今や「コミット」は 1 つではなく 5 つの参加者にまたがる何かを意味する。

2PC —— 詰まるプロトコル

2 相コミットが古典的な答え:

  1. フェーズ 1(PREPARE)。コーディネータがすべての参加者に尋ねる: 「コミットできるか?」各参加者は変更を自分の WAL に耐久に書くがコミットしない; 影響する行をロックする;「はい」または「いいえ」と返す。
  2. フェーズ 2(COMMIT または ABORT)。全員がはいと言ったら、 コーディネータは「コミット」を耐久に記録し、全員にコミットを伝える。 1 人でもいいえと言った(またはタイムアウトした)なら、コーディネータは中止と言う。

参加者の問題:はいに投票した後、コーディネータの決定を聞く前まで、イン-ダウト状態 —— ロックを保持、コミットできず、中止もできず。 コーディネータがちょうどそのとき死んだら、参加者は永遠に待つ。 それらの行を欲しがる他のトランザクションが積み重なる。本番が止まる。

XA(X/Open 分散トランザクション標準)はタキシードを着た 2PC。 Java EE には組み込みサポート;Postgres には PREPARE TRANSACTION がある。 新規システムでクロスサービストランザクションに XA を使うものはほぼない、 詰まる挙動が運用的にひどく、コミットごとに 2 ラウンドトリップのレイテンシが 負荷下で押しつぶすから。

Saga —— 現代の答え

Saga は長期実行のクロスサービストランザクションを、 ローカルトランザクションのシーケンスと各々の補償アクションとしてモデル化する:

ステップ 1:カード請求         補償:カード返金
ステップ 2:在庫予約           補償:在庫解放
ステップ 3:確認送信           補償:キャンセル送信
ステップ 4:監査行を書く       補償:監査反転行を書く

ステップ 3 が失敗したら、ステップ 2 と 1 の補償を逆順で実行。
各ステップはローカルに ACID;saga は最終的に整合。

分散ロックなし、イン-ダウト状態なし、コーディネータの詰まりなし。 代償:補償はあなたが書く必要、各ステップは冪等でなければならない (再試行が起こる)、全体は外部観察者の視点からは原子的でない —— カードが請求されたが在庫が予約されていないという窓がある。

2 種類のフレーバ:オーケストレーション(中央 saga エグゼキュータが各ステップを順番に呼ぶ —— 推論しやすい、 デバッグしやすい、単一障害点)とコレオグラフィ(各サービスがイベントを公開し、他のサービスが反応する —— より緩い結合、 全体像が見えにくい、イベントループでデッドロックしやすい)。 大半のチームはオーケストレーションから始め、後悔しない。

Raft と Paxos —— コンセンサスコア

Saga はクロスサービスワークフローについて。コンセンサスプロトコルは クロスレプリカ状態について。同じ問題ファミリ、まったく異なるレイヤ。

Raft(Ongaro & Ousterhout 2014)が今日の選択肢;Paxos(Lamport 1989)はそれが等価な古い形式化。 両方とも追加専用ログを N ノード間で複製する:

  1. 1 つのノードがリーダ、過半数投票で選ばれる。
  2. リーダが書き込みを受け、自分のローカルログに追加し、エントリをフォロワに複製する。
  3. エントリは過半数(クォーラム)のノードがそれを耐久に ack した後でコミット済みとみなされる。
  4. 各レプリカの状態マシンがコミット済みエントリをログ順に適用する。
  5. リーダが死んだら、フォロワがタイムアウトを検出し、新しいリーダを選ぶ。

< N/2 障害に耐える:3 ノードは 1 障害、5 は 2、7 は 3。 コストはレイテンシ —— すべての書き込みはクォーラムラウンドトリップが必要。 etcd、Consul、CockroachDB、TiKV、Kafka の新しい KRaft モードはすべて Raft。 Spanner は Paxos を使う。ZooKeeper は Zab(Paxos バリアント)を使う。

CAP と PACELC —— 避けられない分岐

CAP 定理(Brewer 2000;Gilbert & Lynch 2002): ネットワークパーティションがあるとき、一貫性(すべてのレプリカが同じ値を返す)と可用性(すべてのレプリカが応答する)の間で選ぶ必要がある。パーティション中は両方は持てない。 パーティション治癒後、両方を再び持てる。

実際には、ほぼすべてのシステムは大体 CP のように振る舞う —— レプリカが分岐する リスクを冒すよりも、パーティション中に書き込みの受け入れを停止することを好む。 Cassandra のような「AP」システムはどこでも書き込みを受け、後で last-write-wins または CRDT で和解する;コストは 2 人の読み手が和解までは異なる値を見ることがある。

PACELC(Abadi 2012)はこれを拡張する: 「If Partition, then Availability vs Consistency; Else, then Latency vs Consistency」。 パーティションなしでもトレードオフ —— あらゆる読み手に可視になりたい書き込みは リーダにヒットするか、同期レプリケーションを待つ必要がある。 非同期レプリケーションを使用する読み取りレプリカは古いデータを提供できる。

線形化可能性 vs 直列化可能性

これら 2 つの用語は似ているが、異なることを意味し、ほぼ全員が混同する:

  • 直列化可能性トランザクションについて: スケジュールはその効果がトランザクションのある直列順序と等価であれば直列化可能。 時間は重要でない —— 順序のみが重要。
  • 線形化可能性は分散システムにまたがる操作について: 操作はその開始と終了の間の単一時点で効果を持つように見え、その時点は 実時間順序を尊重する。op A が完了した後、すべての後続 op B は A の効果を見る。

一方が他方なしでありうる。Postgres SSI は直列化可能だが、単一ノード Postgres は 分散の意味で「線形化可能」ではない —— 1 ノードしかない。 Google Spanner は両方:レプリカ間で線形化可能性のための Paxos、内部に SSI;TrueTime(GPS + 原子時計)を使って、すべてのコミットに惑星全体で 実時間順序を尊重するタイムスタンプを与える。これは外部一貫性と呼ばれ、 ゴールドスタンダード。

これがあなたのコードに何を意味するか

クロスサービスワークフローはあなたが実際に持つネットワーク、希望するネットワークではなく、のために設計する必要がある:

  • 冪等キー。すべてのクロスサービスリクエストは ID を取得; 受信者は重複排除する。再試行は安全になる。
  • 補償アクション。すべてを原子的にコミットできないなら、 既にやったことを取り消せる必要がある。補償は一級のビジネスロジック —— 順方向パスと同時に書く。
  • read-your-writes 一貫性。ユーザが書いてすぐ読むなら、 読み取りをリーダにルーティングするか、彼らの最後の書き込みタイムスタンプを 知っているセッションにピン留めする。さもないと彼らは古いデータを見てあなたを責める。
  • 外部呼び出しをまたいで DB トランザクションを保持しない。行ロックはネットワークラウンドトリップになる;1 つの遅いダウンストリームサービスが データベースを詰まらせ得る。

要点。「2PC は紙の上で動き、本番で詰まる —— コーディネータの障害が参加者を無期限ロック状態に放置する。Saga(ローカルトランザクション + 補償 + 冪等性)が現代のクロスサービスの答え。Raft/Paxos はリーダ + クォーラムで複製状態を解き、 < N/2 障害に耐える。CAP はパーティション中に選択を強いる;PACELC はパーティション なしでもレイテンシと一貫性のトレードオフがあると指摘。線形化可能性はシステム全体での 実時間操作順序について;直列化可能性はトランザクション順序について —— 両者は直交し、 Spanner は TrueTime で両方を達成する。」

05

クイックリファレンス

冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。

ACID を歩いて —— 各文字は実際に何を保証するか?

原子性:BEGIN と COMMIT の間のすべての文が効果を持つかどれも持たないか —— WAL + undo で実装、回復時に再生(ARIES)。一貫性:宣言された制約(CHECK、FK、UNIQUE)はトランザクションを通じて保持される; アプリケーション不変条件はまだあなたの問題。分離性: 並行トランザクションは直列であるかのように振る舞う —— READ UNCOMMITTED から SERIALIZABLE までのダイヤル、デフォルトは絶対に SERIALIZABLE ではない。耐久性:COMMIT はクラッシュに耐える —— fsync で実装、 synchronous_commit=off と非同期レプリケーションで弱められる。

Postgres の REPEATABLE READ と SQL 標準の REPEATABLE READ の違いは?

SQL 標準は REPEATABLE READ にダーティリードと非反復リードを防ぐことのみ要求する —— ファントムはまだ許される。Postgres の REPEATABLE READ は実際にはスナップショット分離:最初の文でスナップショットを取り、すべての読み取りを それから提供する、よってファントムもブロックされる。命名は歴史的で全員を混乱させる。 重要:スナップショット分離はまだライトスキューを許す —— Postgres SERIALIZABLE(その上に SSI を加える)のみが捕まえる。

例でライトスキューを説明し、どう防ぐか?

2 人のオンコール医師、不変条件「少なくとも 1 人はオンコールでなければならない」。 T1 が count = 2 を読み、降りても安全と判断、Alice をオフコールに UPDATE。 T2 が count = 2 を読み(自分のスナップショット)、降りても安全と判断、Bob をオフコールに UPDATE。 両方コミット;今やゼロオンコール。互いに書き込みを上書きしなかった —— MVCC 可視性競合なし。 予防:決定のために読まれる行に SELECT FOR UPDATE、または SERIALIZABLE を使う (Postgres SSI は述語ロックサイクル検出でそれを捕まえる)。

MVCC は Postgres が読み取りロックを避けるのをどう可能にし、コストは何?

すべての行に隠れた列 xmin(作成トランザクション ID)と xmax(削除トランザクション ID)がある。 すべてのトランザクションがスナップショットを取る:(xmin, xmax, in_progress[])。 行は、その xmin があなたの前にコミット済みで、xmax がゼロ、あなたのスナップショットの後、 または中止済みなら可視。読み取りは決して書き込みをブロックしない;書き込みは決して読み取りをブロックしない。 コスト:死んだ行バージョンの蓄積(「膨張」)、VACUUM で清掃。 vacuum 不足の書き込み重のテーブルは遅くなる;Postgres では、十分長く放置すると DB が書き込みを拒否する txn-ID ラップアラウンド緊急事態に当たる。

現代の分散システムで 2PC はなぜ危険と見なされるか?

ブロッキング障害モード:フェーズ 1 で「はい」と投票した後、フェーズ 2 で commit/abort 決定を聞く前まで、すべての参加者はイン-ダウト状態 —— 行ロックを保持、進行不能。 コーディネータがちょうどそのときクラッシュしたら、参加者は無期限に待つ; それらの行に触れる他のトランザクションが積み重なる;スループットがゼロに行く。 コミットごとに 2 ラウンドトリップのレイテンシと、詰まったトランザクションの回復の 運用的苦痛を加えれば、代わりに saga になる:各サービスのローカルトランザクション + 補償アクション、すべてのステップで冪等再試行。

線形化可能性と直列化可能性の違いは?

直交。直列化可能性はトランザクションについて:スケジュールは その効果がトランザクションのある直列順序と等しいなら直列化可能 —— 時間は入らない。線形化可能性は分散システムにまたがる操作について:各 op は その呼び出しと戻りの間の点で原子的に効果を持つように見え、その点は実時間順序を尊重する。 単一ノード Postgres は直列化可能でありうるが、分散の意味で「線形化可能」ではない。 Google Spanner は両方を達成する(外部一貫性と呼ばれる)、 Paxos レプリケーションと、グローバルに順序付けされたコミットタイムスタンプのための TrueTime(GPS + 原子時計)を通じて。

コードレビューの赤旗

  • SELECT FOR UPDATE もアトミック UPDATE もないアプリケーションコードの SELECT-then-UPDATE。READ COMMITTED 下の古典的ロストアップデート。UPDATE accounts SET balance = balance - 30 を使うか、 明示的に行ロックを取る。
  • 計測なしに「パフォーマンス」のために READ UNCOMMITTED を設定。スループット利得は通常想像上のもの;ダーティリードバグはそうではない。 レポートクエリのために本当に古い読みが必要なら、コードレビューでそう名付ける。
  • 補償アクションが実装されていない Saga。順方向パスは出荷、補償は「TODO」。最初の部分障害インシデントが 3 つのサービスで 同時にデータを破損し、オンコールは元に戻す手段を持たない。
  • 2PC / XA 経由のクロスサービストランザクション。現代のマイクロサービスは冪等ステップのある saga であるべき。XA は通常必要のない 保証のためにコーディネータの詰まり、2 ラウンドトリップレイテンシ、運用悪夢を持ち込む。
  • 外部 HTTP 呼び出しをまたいでロックを保持する長期実行トランザクション。行ロックは今やサードパーティの p99 レイテンシと同じ長さ続く。 1 つの遅いダウンストリームサービスがデータベースをダウンさせ得る —— 「DB が遅い」のように見えて実際は「DB が他の何かを待っている」インシデントの一般的原因。