Skip to content

レプリケーション

リーダーの書き込みログを複数のレプリカへ流し、同じ状態を持たせる。確定と見なすのに何台の複製を待つか(async / quorum / sync)で、速さと無損失が入れ替わる。複製には遅れがあり、遅れたレプリカから読むと自分の書き込みがまだ見えない(stale read)。遅れたレプリカを昇格させると確定済みの書き込みが消える。この 3 つを、切断できるデモで壊しながら確かめる。

この章で作るもの

WAL で作った「変更をまずログに書く」仕組みは、1台の中でクラッシュに耐えるためのものだった。その同じログを別のマシンへ送れば、1台まるごと壊れても状態が生き残る。これがレプリケーションだ。題材は最も一般的な**単一リーダー(primary-backup)**方式で、PostgreSQL・MySQL・MongoDB・Redis などがこの形を採る。

順に見ていく。

  1. 耐久性ポリシー: リーダーは書き込みをレプリカへ流す。何台の複製を待って「確定(クライアントに OK を返す)」とするか。その選択肢が async(待たない)/ quorum(過半数)/ sync(全台)で、そのまま速さと無損失のトレードオフになる
  2. 複製ラグと stale read: 複製は一瞬では終わらない。遅れているレプリカから読むと、ついさっき書いた値がまだ来ていない(古い読み)。読み分散の代償
  3. フェイルオーバーのデータ損失窓: リーダーが落ちたらレプリカを昇格させる。だが昇格先が遅れていると、確定済みだった書き込みが消える。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 │  (最新)
                                              └───┴───┴───┴───┘
単一リーダーのログシッピング。書き込みはリーダーのログに追記され、offset(LSN)の順にレプリカへ流れる。レプリカは受け取った位置まで自分のログを伸ばして適用する。どこまで届いたか(applied)がレプリカごとに違い、その差がラグになる

耐久性ポリシー: 速さと無損失の交換

中心の問いは「リーダーが何台の複製を待って確定とするか」だ。確定(committed)とは「クライアントに『書けたよ』と返してよい、もう失われない」という状態のこと。必要な台数はポリシーで決まる(リーダー自身も1台に数える):

go
// 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台必要)
同じ1件の書き込みが、ポリシーで確定タイミングが変わる。3ノード(リーダー+レプリカ2)で1台が切れているとき、async は即確定、quorum はリーダー+生存レプリカの2台で確定、sync は全台を待つので確定できず止まる

動かす

下はリーダー + レプリカ2台。耐久性ポリシーを切り替えて「書き込み」を押すと、確定(緑)まで何台待つかが変わる。各レプリカの「切断」でラグを開くと、async は切れていても即確定、quorum はリーダー+1台で確定、sync は切れた1台を待って確定が止まる。バッジの「確定 n/m」が確定境界。

デモレプリケーション(リーダー + 2レプリカ)書き込みなし
asyncquorumsync
リーダーLeader
値 —
レプリカ 1追従中
値 — ・ ラグ 0
レプリカ 2追従中
値 — ・ ラグ 0

耐久性ポリシーを選んで書き込んでみる。レプリカを切ると挙動が変わる

確定(耐久性条件を満たした)複製済みだが未確定セル内・値の数字は書き込みの通し番号(w1, w2, …)

確定していないセル(青枠)は「複製はされたが、まだクライアントに OK を返していない」状態。sync で1台切ると、書いてもいつまでも青のまま。これが「遅い1台が全体を止める」の正体。

複製ラグと stale read

複製は瞬時ではない。レプリカが遅れている間、そこから読むとさっき書いた値がまだ来ていない。読み負荷をレプリカに逃がす(read replica)と必ずこの問題が出る。

デモでレプリカを1台切ってから何度か書き、その切れたレプリカの「値」を見ると、リーダーの最新値とずれる。これが stale read。対策は用途で変わる:

  • read-your-writes が要る画面(自分の投稿直後の表示など)はリーダーから読む、あるいは「自分が書いた offset まで追いついたレプリカ」からだけ読む
  • 多少古くてよい集計・タイムラインはレプリカへ流してスループットを稼ぐ

「どこから読むか」を用途ごとに決めるのが、レプリケーションを使う側の設計の中心になる。

フェイルオーバーとデータ損失窓

リーダーが落ちたら、レプリカのどれかを新リーダーに昇格(promote)させて再開する。ここで昇格先が遅れていると、そのレプリカが受け取っていなかったぶんは、確定済みでもこの世から消える。消えた確定済み書き込みの数がデータ損失窓:

go
// 昇格したノードが持たない 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
async のデータ損失窓。確定を返した w4 がまだレプリカに届く前にリーダーが死ぬ。遅れたレプリカ(w3まで)を昇格させると、w4 は誰も持っておらず消える。quorum なら w4 は過半数に載ってから確定するので、最新を持つレプリカを昇格でき損失ゼロ

だから実務のデフォルトは 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