レプリケーション
リーダーの書き込みログを複数のレプリカへ流し、同じ状態を持たせる。確定と見なすのに何台の複製を待つか(async / quorum / sync)で、速さと無損失が入れ替わる。複製には遅れがあり、遅れたレプリカから読むと自分の書き込みがまだ見えない(stale read)。遅れたレプリカを昇格させると確定済みの書き込みが消える。この 3 つを、切断できるデモで壊しながら確かめる。
この章で作るもの
WAL で作った「変更をまずログに書く」仕組みは、1台の中でクラッシュに耐えるためのものだった。その同じログを別のマシンへ送れば、1台まるごと壊れても状態が生き残る。これがレプリケーションだ。題材は最も一般的な**単一リーダー(primary-backup)**方式で、PostgreSQL・MySQL・MongoDB・Redis などがこの形を採る。
順に見ていく。
- 耐久性ポリシー: リーダーは書き込みをレプリカへ流す。何台の複製を待って「確定(クライアントに OK を返す)」とするか。その選択肢が
async(待たない)/quorum(過半数)/sync(全台)で、そのまま速さと無損失のトレードオフになる - 複製ラグと stale read: 複製は一瞬では終わらない。遅れているレプリカから読むと、ついさっき書いた値がまだ来ていない(古い読み)。読み分散の代償
- フェイルオーバーのデータ損失窓: リーダーが落ちたらレプリカを昇格させる。だが昇格先が遅れていると、確定済みだった書き込みが消える。async はこの窓が開く
Raft が「1つのログを全員で合意する」話だったのに対し、この章は「1つのログを配って冗長化する」話。合意はしない代わりに、ポリシー次第で速くも安全にもできる。両者の違いは章末で整理する。
単一リーダーとべき等なレコード
構成はシンプル。書き込みを受け付けるのはリーダー1台だけ。リーダーは各書き込みをログに追記し、その末尾をレプリカへ順番に流す(ログシッピング)。レコードは WAL と同じくべき等で、「+100する」ではなく「値をVにする」と絶対値で持つ。だからレプリカは届いた順に上書きするだけでよく、途中で取りこぼしても再送で必ず追いつける。
書き込み リーダーのログ(正本) レプリカ
┌────────┐ ┌───┬───┬───┬───┐ R1 ┌───┬───┬───┐ applied=3
│ w4 を書く│──▶│ 1 │ 2 │ 3 │ 4 │───流す──▶ │ 1 │ 2 │ 3 │ (w4 未着=ラグ1)
└────────┘ └───┴───┴───┴───┘ R2 ┌───┬───┬───┬───┐ applied=4
offset(LSN) │ 1 │ 2 │ 3 │ 4 │ (最新)
└───┴───┴───┴───┘耐久性ポリシー: 速さと無損失の交換
中心の問いは「リーダーが何台の複製を待って確定とするか」だ。確定(committed)とは「クライアントに『書けたよ』と返してよい、もう失われない」という状態のこと。必要な台数はポリシーで決まる(リーダー自身も1台に数える):
// need は現在の耐久性ポリシーで確定に必要な「そのレコードを持つノード数」(リーダー込み)。
func (c *Cluster) need() int {
switch c.durability {
case Async:
return 1 // リーダーだけでよい
case Quorum:
return c.nodes()/2 + 1
case Sync:
return c.nodes()
default:
return c.nodes()
}
}- async:
1。リーダーのログに載った瞬間に確定。レプリカの返事を待たない。速いが、まだ流れていないぶんはリーダーが死ぬと消える - quorum(準同期):
過半数。3ノードなら2台。少数の遅い/落ちたレプリカを許容しつつ、後述のフェイルオーバーでも損しにくい妥協点 - sync:
全台。1件も取りこぼさないが、1台でも詰まると全書き込みが止まる(可用性を犠牲に)
リーダー レプリカ1(切断) レプリカ2 確定?
async : ✔ ✗(未着) ✔ ✔ 即(1台でよい)
quorum : ✔ ✗(未着) ✔ ✔ (2/3が持つ)
sync : ✔ ✗(未着) ✔ ✗ 止まる(3台必要)動かす
下はリーダー + レプリカ2台。耐久性ポリシーを切り替えて「書き込み」を押すと、確定(緑)まで何台待つかが変わる。各レプリカの「切断」でラグを開くと、async は切れていても即確定、quorum はリーダー+1台で確定、sync は切れた1台を待って確定が止まる。バッジの「確定 n/m」が確定境界。
耐久性ポリシーを選んで書き込んでみる。レプリカを切ると挙動が変わる
確定していないセル(青枠)は「複製はされたが、まだクライアントに OK を返していない」状態。sync で1台切ると、書いてもいつまでも青のまま。これが「遅い1台が全体を止める」の正体。
複製ラグと stale read
複製は瞬時ではない。レプリカが遅れている間、そこから読むとさっき書いた値がまだ来ていない。読み負荷をレプリカに逃がす(read replica)と必ずこの問題が出る。
デモでレプリカを1台切ってから何度か書き、その切れたレプリカの「値」を見ると、リーダーの最新値とずれる。これが stale read。対策は用途で変わる:
- read-your-writes が要る画面(自分の投稿直後の表示など)はリーダーから読む、あるいは「自分が書いた offset まで追いついたレプリカ」からだけ読む
- 多少古くてよい集計・タイムラインはレプリカへ流してスループットを稼ぐ
「どこから読むか」を用途ごとに決めるのが、レプリケーションを使う側の設計の中心になる。
フェイルオーバーとデータ損失窓
リーダーが落ちたら、レプリカのどれかを新リーダーに昇格(promote)させて再開する。ここで昇格先が遅れていると、そのレプリカが受け取っていなかったぶんは、確定済みでもこの世から消える。消えた確定済み書き込みの数がデータ損失窓:
// 昇格したノードが持たない offset は消える。確定済みだったぶんの数がデータ損失窓。
lost := c.committed - rp.applied()
if lost < 0 {
lost = 0
}async の怖さはここ。「確定した」とクライアントに返したのに、リーダーが未複製のまま死に、遅れたレプリカが昇格すると、その約束が破られる。デモで async にして、レプリカ1を切断 → 書き込み(即確定してしまう)→ その遅れたレプリカ1を「昇格」すると、確定済みだった書き込みが失われ、損失件数が赤字で出る。
リーダー: [1][2][3][4]✔確定 → 💥故障
レプリカ: [1][2][3] ← これを昇格
▲
w4 は誰も持たない → 確定していたのに消失(損失窓=1)
quorum なら: w4 は「レプリカにも載ってから」確定する
→ 最新を持つレプリカが必ず居る → それを昇格すれば損失0だから実務のデフォルトは quorum(準同期) に寄る。全台を待つ sync ほど脆くなく(1台落ちても過半数で回る)、async のように確定の約束を破らない。「過半数に書けてから確定、昇格も最新を持つ過半数の中から」。これは Raft の commit 規則とまったく同じ発想で、単一リーダー複製と合意はここで地続きになる。
設計の観点: レプリカ構成の見積もり
「読み 100k QPS / 書き 5k QPS のサービス。DB をどう構成する?」。レプリケーションはこの問いへの標準解。ざっくり順序で見積もる:
- 読み: 1レプリカが ~20k QPS 捌けるなら、100k / 20k = 5レプリカに読みを分散。リーダーは書きに専念させる
- 書き: 書きは分散できない(リーダー1点)。5k QPS が1台の上限に近いなら、レプリケーションでは足りずシャーディング(次章 コンシステントハッシュ)で書き先を分ける必要が出る
- 耐久性: 決済など「確定の約束を破れない」系は quorum 必須。データ損失窓を SLA に落とすと async は選べない
- ラグの許容: read-your-writes が要る導線だけリーダー読みに回し、残りはレプリカへ。ラグの p99 を監視項目に置く
メリット・デメリットと実例
| 方式 | 速さ | 無損失 | 1台故障時 | 代表例 |
|---|---|---|---|---|
| async | 即確定(最速) | 損失窓あり | 書きは止まらない | Redis レプリカ、MySQL 既定、PostgreSQL 既定 |
| quorum(準同期) | 過半数を待つ | 昇格先が最新なら失わない | 過半数で継続 | MySQL 準同期、MongoDB w:majority、Kafka acks=all+min.insync.replicas |
| sync(全台) | 最も遅い1台に律速 | 完全 | 止まる | PostgreSQL synchronous_commit(全同期スタンバイ) |
裏どり:
- PostgreSQL: WAL をストリーミングでスタンバイへ送る。既定は async、
synchronous_standby_namesで同期スタンバイを指定でき、quorum指定(ANY 2 (...))で準同期にできる - MySQL: 既定は async レプリケーション。準同期プラグイン(
rpl_semi_sync)で「1台の ACK を待ってから確定」にできる - Kafka:
acks=all+min.insync.replicas=2が実質 quorum。ISR(In-Sync Replicas)に入っているぶんだけを確定に数える発想は、この章のacksForと同じ - MongoDB:
writeConcern: { w: "majority" }で過半数複製を待つ。まさに quorum
いずれも「何台の ACK を待つか」を書き込みごと/設定で選べるのが共通点。この章の Durability は、その1ノブを最小化したもの。
簡略化したこと
- 複製は「モデル上は同期配送」で、到達可能なら書き込みの中で末尾まで流す。実物の非同期ストリーミング(遅延・順序保証・バックプレッシャ・再送)は持たない。ラグは切断で表現している
- 永続化・ネットワーク・スナップショット転送は無し(状態は全てメモリ)
- リーダー選出はしない(昇格は手動)。誰を・いつ昇格するかを安全に合意する話が Raft。実務のフェイルオーバーは両者の組み合わせ(合意で選ぶ + ログを複製)
- 分岐したログの解決は「新リーダーのログが正、長い側は切り詰め」に単純化(Raft と同じ発想)
参考資料
- Kleppmann, Designing Data-Intensive Applications 5章(Replication)。単一リーダー/マルチリーダー/リーダーレスの全体像
- PostgreSQL ドキュメント: Streaming Replication / Synchronous Replication
- Kafka ドキュメント: Replication と
acks/min.insync.replicas - 実装: distributed/replication