株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
このシリーズについて
「知ってはいる。けど、人に説明しろと言われると詰まる」——そんな歯がゆいDB用語を、1記事1用語・図解中心で解消してきたシリーズも、今回で最終回です。
- 第1回:ACIDの「C(一貫性)」、説明できますか?
- 第2回:トランザクション分離レベル ―「READ COMMITTED」で結局なにが読めるの?
- 第3回:ロックとデッドロック ―「誰が」「何を」「どこまで」ロックしている?
- 第4回:「スキーマ」って結局なに? ― 同じ単語が文脈で別物を指している
- 第5回:正規化 ―「第3正規形まで」の「まで」って何?
- 第6回:インデックス ― 貼ったのに効かないのはなぜ?
- 第7回:実行計画 ― EXPLAINの出力、どこを見る?
- 第8回:N+1問題 ― なぜ「1回」で済まないのか
- 第9回:コネクションプール ― プールサイズは何を基準に決める?
- 第10回:ビューとマテリアライズドビュー ― どっちをいつ使う?
まず自己診断
こんなバグ報告を受けたことはありませんか。
「プロフィールを保存したら『保存しました』と出たのに、画面が切り替わったら前の内容のままでした。リロードしたら直りました」
再現しません。ログにもエラーはありません。データも正しく保存されています。でもユーザーの言っていることは本当です。
この現象を説明できますか。答えは「バグ」ではなく、レプリケーションのラグです。
レプリケーションとは
まず前提から。DBを1台で運用していると、読み取りが増えたときに詰まりますし、その1台が壊れたら終わりです。そこで同じ内容のコピーを別のサーバに持つのがレプリケーションです。
書き込みはプライマリ1台だけ、読み取りは複数のレプリカに分散。これで読み取り性能が上がり、プライマリが壊れてもレプリカを昇格させて復旧できます。
レプリカが本当に読み取り専用なのかは、実際に書き込もうとすると分かります。
ERROR: cannot execute INSERT in a read-only transaction
以下の実測はすべて、PostgreSQL 16.14のコンテナ2台(プライマリ+スタンバイ)でストリーミングレプリケーションを実際に構成して取得したものです。
ラグの正体
さて本題。プライマリの変更がレプリカに届くまでには、必ず時間差があります。これがレプリケーションラグです。
冒頭のバグの正体はこれです。
書き込みは主へ、直後の読み取りはレプリカへ。 レプリカにはまだ届いていないので、古い値が返る——これが read-your-writes問題(自分が書いたものを自分で読めない)です。
リロードで直るのは、その頃には複製が完了しているからです。だから開発者が手元で再現しようとしても再現しません。
実測:ラグを故意に起こす
自然に待っていてもラグは一瞬で消えてしまうので、スタンバイ側のWAL適用を一時停止して再現しました。
-- スタンバイ側で複製の適用を止める
SELECT pg_wal_replay_pause();
この状態でプライマリの残高を更新します。
-- プライマリ側
UPDATE accounts SET balance = 9999 WHERE name='sato';
同じ瞬間に両方を読むと、こうなりました。
| 読みに行った先 | sato の残高 |
|---|---|
| プライマリ | 9999 |
| レプリカ | 1000 ← 古いまま |
書き込みは成功しています。データも壊れていません。ただレプリカにまだ届いていないだけです。ラグはプライマリ側から観測できます。
SELECT state, sent_lsn, replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS lag_bytes
FROM pg_stat_replication;
state | streaming
sent_lsn | 0/303BC10
replay_lsn | 0/303BB08
lag_bytes | 264
送った位置(sent_lsn)と適用済みの位置(replay_lsn)がズレている——これがラグの正体です。適用を再開すると追いつきました。
SELECT pg_wal_replay_resume();
レプリカの残高は 9999 になり、lag_bytes は 0 に戻りました。
計測の落とし穴:ラグを秒で測ろうと
now() - pg_last_xact_replay_timestamp()を使うと、完全に追いついている状態でも数字が増え続けます(実測でもlag_bytes = 0なのに「12.3秒」と出ました)。これは「最後に適用したトランザクションからの経過時間」であり、書き込みが無い間は当然伸びるためです。遅れているかどうかの判断にはlag_bytes系の指標を使ってください。
「同期にすればラグは消える」——その代償
ここで当然こう考えます。「レプリカへの反映を待ってからコミットすればいいのでは?」 できます。それが同期レプリケーションです。
重要:「同期」には"どこまで待つか"の段階がある
ここで多くの人が誤解します。同期にすれば自動的にラグが消える、わけではありません。 PostgreSQLの synchronous_commit には、どこまで待つかの段階があります。
| 設定 | どこまで待つか | 保証されること |
|---|---|---|
on(既定) |
スタンバイがWALを書き込むまで | データが失われない(耐久性) |
remote_apply |
スタンバイが適用し終えるまで | 上記+レプリカの読み取りに反映済み |
公式ドキュメントによれば、既定の on が保証するのは「プライマリとすべての同期スタンバイが同時に壊れない限りトランザクションは失われない」こと。つまり目的は障害時のデータ喪失防止であって、レプリカで読めるかどうかは保証していません。読み取りへの反映まで保証するのは remote_apply だけで、これはWALの適用完了を待つぶん最も遅くなります。
先ほどのラグ再現デモが、まさにこれを示しています。 pg_wal_replay_pause() で止まるのは「適用」であって「書き込み」は進むため、on の同期コミットは完了します。同期にしていても、レプリカからは古い値が読めるわけです。
つまり冒頭のバグ(read-your-writes問題)は、on の同期レプリケーションでは解決しません。
代償の実測
同じ書き込みワークロード(pgbench、4クライアント・15秒)を両モードで比較しました。同期側は synchronous_commit = on(既定) での測定です。
| モード | スループット | 平均レイテンシ |
|---|---|---|
| 非同期 | 305.8 tps | 13.079 ms |
同期(on) |
203.9 tps | 19.622 ms |
| 非同期(再測定) | 294.9 tps | 13.565 ms |
スループットは約3分の2に、レイテンシは約1.5倍になりました(非同期を2回測って再現性も確認しています)。しかもこれは読み取りの鮮度を保証しない on での数字で、remote_apply にすればさらに遅くなります。
さらに深刻なのが可用性です。同期モードのままスタンバイを停止し、プライマリに書き込んでみました。
(INSERTを実行 → 12秒経過しても完了しない)
プライマリは生きているのに、書き込みが返ってきません。 レプリカの受領を待ち続けるからです。スタンバイを復旧させた瞬間、INSERTは完了しました。
つまり同期レプリケーションでは、レプリカの障害がプライマリの書き込み停止に直結します。「ラグをゼロにする」は無料ではない——しかも既定の on では、そもそも読み取りのラグはゼロになっていない、ということです。
実務ではどうするか
現実的には、非同期のまま「ラグがあっても困らない設計」にするのが基本方針です。
| 場面 | 方針 |
|---|---|
| 保存直後の確認画面 | プライマリから読む |
| 一覧・検索・ダッシュボード | レプリカでよい |
| 残高・在庫など正確さが要る値 | プライマリから読む |
| 更新を含むセッション中 | 一定時間プライマリに固定する |
冒頭のバグの正しい対処は「保存処理の直後の読み取りだけプライマリに向ける」であって、同期レプリケーションに切り替えることではありません。多くのフレームワークやプロキシには、この振り分けを行う仕組みが用意されています。
そもそも前節のとおり、既定の on で同期化してもこの問題は直りません。直すなら remote_apply まで上げることになりますが、それは読み取り先を切り替えるだけで済む話に、書き込み全体の性能と可用性を差し出す選択です。
前回との共通点:コピーはいつも少し古い
第10回のマテリアライズドビューを思い出してください。構造がまったく同じです。
| 何のコピーか | 追従の仕方 | 古さの制御 | |
|---|---|---|---|
| マテリアライズドビュー | 集計結果 | 手動(REFRESH) | REFRESH間隔で決まる |
| レプリカ | DB全体 | 自動(常時追従) | ラグの大きさで決まる |
どちらも「速さ・分散のためにコピーを持つと、そのコピーは必ず少し古い」という同じ原理の上にあります。コピーを作った瞬間に、鮮度という宿題が発生する。 これはDBに限らず、キャッシュでもCDNでも同じです。
「書いた直後に読めない」は、その宿題がいちばん目に見える形で表れた瞬間にすぎません。
まとめ:1行で説明するなら
「レプリケーションは書き込み用のプライマリから読み取り用のレプリカへ変更を複製する仕組みです。既定の非同期複製では反映に時間差(ラグ)があるため、書き込みは主・直後の読み取りはレプリカに行くと、まだ届いておらず古い値が返ります。これがread-your-writes問題。同期複製にしても、既定の
synchronous_commit = onが待つのはスタンバイがWALを書き込むところまで——保証されるのはデータが失われないことであって、レプリカで読めることではありません。読み取りへの反映まで保証するのはremote_applyだけで、そのぶん最も遅くなります。しかも同期にするとレプリカが落ちたとき書き込み自体が止まります。だから普通は非同期のまま、自分が書いた直後の読み取りだけプライマリに向けます」
シリーズ完結にあたって
全11回、おつかれさまでした。
このシリーズは「知ってはいるけど説明できない」という、地味に長く続く歯がゆさを1つずつ潰す目的で書きました。扱った11個には、実は共通する構造がいくつもありました。
- ACIDのCもスキーマレスも、「DBが守るのは宣言した制約だけ」という同じ話
- 分離レベルも同期レプリケーションも、「安全性と速度のダイヤル」という同じ形
- マテビューもレプリカも、「コピーは少し古い」という同じ原理
- インデックスが効かないもN+1も、「EXPLAINが答えを教えてくれる/くれない」の対
用語を単体で暗記すると忘れますが、構造で捉えると忘れません。そして何より、手元で動かすと腹に落ちます。この11回で扱った実測は、ほぼすべてDockerのDB1つあれば再現できるものばかりです。ぜひ自分の手で確かめてみてください。
「それ、説明できないな」と思った用語こそ、次に学ぶべき用語です。
参考
- PostgreSQL 16 Documentation - High Availability, Load Balancing, and Replication
- PostgreSQL 16 Documentation - Log-Shipping Standby Servers(ストリーミングレプリケーション)
- PostgreSQL 16 Documentation - Replication(synchronous_standby_names)
- PostgreSQL 16 Documentation - Monitoring Statistics(pg_stat_replication)
- PostgreSQL 16 Documentation - System Administration Functions(pg_wal_replay_pause)
- MySQL 8.0 Reference Manual - Replication
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!