書き込み直後に読み取りレプリカへ問い合わせると、なぜ古いデータが返るのか?
プロフィール更新の UPDATE を投げた直後にリロードしたら、更新前の値が表示された——そんなバグ報告を見たことはないだろうか。書き込み用と読み取り用で DB を分けている構成では珍しくない現象だ。あの一瞬のズレの間、DB の内部では何が起きているのか説明できるだろうか。
- DB はデータファイルを直接書き換える前に、変更内容を WAL(Write-Ahead Log)として先にディスクへ確定させる。レプリケーションはこの WAL をレプリカへ転送し再生する仕組みだ
- ストリーミングレプリケーションは既定で非同期。プライマリは WAL をレプリカへ送るのを待たずにコミットを完了させるため、送信〜受信〜再生の間だけレプリカは古い状態になる。これが「レプリケーションラグ」だ
- ラグをゼロにしたければ同期レプリケーションでコミットをレプリカの確認待ちにできるが、レプリカが詰まった瞬間に書き込み全体が止まるという可用性とのトレードオフを引き受けることになる
DB はなぜデータファイルを直接書き換えないのか — WAL という前提
テーブルの行は最終的にディスク上のデータファイルに格納される。素朴に考えれば UPDATE のたびにそのファイルを直接書き換えればよさそうだが、実際の DB エンジンはそうしない。かわりに「このページのこの値をこう変える」という変更内容をWAL(Write-Ahead Log、先行書き込みログ)という追記専用のログファイルに先に書き、それをディスクへ fsync してからコミットを完了する。データファイル本体への反映は後回しにしてよい。
この順序には理由がある。データファイルはテーブルのあちこちに散らばったページをランダムな位置で書き換える必要があるが、WAL は末尾に追記していくだけなのでディスクへの書き込みが逐次アクセスになり高速だ。さらに、プロセスが途中でクラッシュしてもデータファイルへの反映が済んでいない変更は WAL に残っているので、再起動時に WAL を先頭から読み直して未反映の変更を再生(REDO)すれば、コミット済みのはずのデータが消えている事態を防げる。「コミット成功を返した以上そのデータは失われない」という約束は、WAL がディスクに確定していることによって成り立っている。
レプリケーションの正体 — WAL をコピーして再生するだけ
レプリカ(読み取り専用の複製サーバー)は、まっさらな DB にプライマリの WAL を延々と受け取って再生し続けているだけの存在だ。PostgreSQL のストリーミングレプリケーションでは、プライマリが生成した WAL をレプリカへ継続的に送信し、レプリカ側がそれを自分のデータファイルに適用していく。SQL 文をもう一度実行するのではなく、プライマリで確定した変更の記録をそのまま複製するので、プライマリとレプリカで実行計画が食い違って結果がズレる心配がない。
┌─ プライマリ ──────────────┐
│ UPDATE 実行 │
│ → WAL に追記 → fsync │
│ → クライアントへ COMMIT 応答 │
└──────────┬────────────┘
│ WAL をストリーミング送信
▼
┌─ レプリカ ────────────────┐
│ WAL 受信 → 順に再生(REDO) │
│ → ここでようやく反映 │
└────────────────────────┘
ここで見落としやすいのは、プライマリが「クライアントへ COMMIT 応答を返すタイミング」と「レプリカが WAL を受け取って再生し終えるタイミング」が別物だという点だ。両者の間に生じる時間差がレプリケーションラグで、PostgreSQL ではプライマリ側の pg_current_wal_lsn() とレプリカ側の pg_last_wal_replay_lsn() が指す WAL の位置(LSN)の差として観測できる。
WAL は「作業日誌」に近い。倉庫の棚(データファイル)を並べ替える前に、まず「棚 A の3段目を棚 B の1段目に移す」と日誌に書き込んでから作業する。日誌さえ残っていれば、途中で停電しても日誌を読み返して続きの作業をやり直せる。レプリケーションは、この日誌のコピーを支店(レプリカ)に届けて同じ順番で作業させているだけであり、支店には日誌が届いて実際に棚を並べ替え終わるまでのタイムラグが必ず生じる。
なぜ既定で「非同期」なのか — 同期にするとどうなるか
PostgreSQL のストリーミングレプリケーションは既定で非同期だ。プライマリは WAL を自分のディスクに fsync した時点でクライアントにコミット完了を返し、レプリカへの転送や再生の完了は待たない。この設計だとプライマリが直後にクラッシュした場合、レプリカへ届く前の直近のコミットがレプリカ昇格後に失われる可能性がある一方で、レプリカの応答を待たない分コミットのレイテンシは低く保てる。
これを変えるのが synchronous_commit と synchronous_standby_names の組み合わせだ。synchronous_standby_names に同期対象のレプリカ名を指定し synchronous_commit = on(既定値)にすると、コミットは指定したレプリカが WAL をディスクへ書き込んだと確認するまで待つようになる。さらに remote_apply まで上げると、レプリカが WAL を受け取って再生を完了するまで待つため、直後に同じレプリカへ読みに行っても必ず最新のコミット結果が返る。ただしこの場合、同期対象のレプリカが落ちたり詰まったりすると、プライマリへの書き込みそのものがブロックされる。ラグをゼロに近づけることと、レプリカ障害時の可用性を両立させることはできない。
# postgresql.conf 側の抜粋(プライマリ) synchronous_standby_names = 'replica1' synchronous_commit = on # replica1 の WAL fsync を待ってコミット完了 # remote_apply にすると replica1 での再生完了まで待つ(ラグはほぼゼロになる代わりに書き込みが遅くなる)
ラグを前提にした設計 — read-your-writes をどう守るか
非同期レプリケーションを使う以上、「自分が書いた直後の値を自分で読む」(read-your-writes)が読み取りレプリカ経由では保証されないという前提でアプリケーションを組む必要がある。よくある対処は、書き込み直後の確認読み取りだけプライマリへ向ける、あるいは書き込みから一定時間はキャッシュやセッション内の値をそのまま使い DB を再度読みに行かないという方法だ。レプリカのラグをメトリクスとして監視し、一定秒数を超えたレプリカを読み取り対象から一時的に外すロードバランサー設定を組むケースもある。
読み取りをレプリカに逃がす主な動機はプライマリの読み取り負荷の分散であって、ラグをゼロにすることではない。「レプリカを増やせば増やすほど整合性の保証が弱くなる」という感覚を持っておくと、read-your-writes が壊れるバグに遭遇したときにレプリケーション構成を疑う勘が働くようになる。
マネージド DB(RDS・Cloud SQL など)で「読み取りレプリカ」を立てて読み取り負荷を分散する構成は広く使われている。フォーム送信直後のリダイレクト先で更新前のデータが一瞬表示される、バッチ処理がプライマリへ大量書き込みをした直後だけレプリカのラグが伸びてダッシュボードの数値が遅延する、といった形でこの仕組みに出会う。障害復旧の場面でもレプリカをプライマリへ昇格させる際に「未反映の WAL がどこまで届いていたか」が失われるデータの範囲を決める。
ローカルの Docker で PostgreSQL を 1 台立て、WAL がコミットのたびに実際に進んでいくことを pg_current_wal_lsn() で確認する。レプリカを別途組むことはしないが、コミットのたびに WAL の位置(LSN)が前進していく様子——レプリケーションが転送しているのはまさにこの差分だ——を体感できる。
docker run -d --name pg-wal -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16 sleep 3 docker exec -it pg-wal psql -U postgres -c "SELECT pg_current_wal_lsn();" docker exec -it pg-wal psql -U postgres -c "CREATE TABLE t(id int); INSERT INTO t VALUES (1);" docker exec -it pg-wal psql -U postgres -c "SELECT pg_current_wal_lsn();" docker rm -f pg-wal
2 回の pg_current_wal_lsn() の値を比べると、CREATE TABLE と INSERT の分だけ LSN(例: 0/1650F80 のような 16 進表記)が増えているはずだ。レプリケーションはこの LSN の差分にあたる WAL のバイト列をそのままレプリカへ流している。
- レプリカはプライマリの SQL 文をもう一度実行して複製している — 実際は WAL に記録された変更(バイト単位の差分)をそのまま再生しているだけで、SQL の再実行ではない。実行計画のズレによる不整合が起きない設計になっている。
- レプリケーションは常に同期的で、コミットが返った時点でレプリカも最新になっている — PostgreSQL のストリーミングレプリケーションは既定で非同期。同期にするには
synchronous_standby_namesとsynchronous_commitを明示的に設定する必要があり、その代わりレプリカ障害時に書き込みがブロックされ得る。 - レプリケーションラグはネットワークが遅い時だけ起きる特殊な障害だ — WAL の送信・受信・再生という 3 段の処理はネットワークが健全でも一定の時間がかかる。プライマリの書き込み量が急増したりレプリカの I/O が追いつかなかったりすれば、ネットワークに問題が無くてもラグは常に発生し得る。
- WAL(Write-Ahead Log)
- データファイルへの反映前に変更内容を先に確定させる追記専用のログ。クラッシュリカバリとレプリケーションの両方の基盤になる。
- ストリーミングレプリケーション
- プライマリが生成した WAL を継続的にレプリカへ送信し、レプリカ側で再生することでデータを複製する仕組み。
- レプリケーションラグ
- プライマリでコミットが完了してから、その変更がレプリカで再生され読み取り可能になるまでの時間差。
- synchronous_commit
- コミットがどの段階(ローカル fsync/同期レプリカの fsync/再生完了)まで待ってからクライアントに応答するかを決める PostgreSQL のパラメータ。
- read-your-writes
- 自分が行った書き込みの直後に、自分がその結果を読み取れることを保証する整合性の性質。非同期レプリカ読み取りでは保証されない。
- 28.3. Write-Ahead Logging (WAL) — PostgreSQL Documentation — WAL がなぜクラッシュリカバリと性能を両立できるのかの一次ソース
- 26.2. Log-Shipping Standby Servers — PostgreSQL Documentation — ストリーミングレプリケーションの仕組みと構成方法
- 19.6. Replication — PostgreSQL Documentation — synchronous_commit の各設定値と同期レプリケーションの挙動