複製により、論理的に同一のデータが複数の場所に存在するようになる。フォロワーは、フェイルオーバーの受け皿、ローカルからの読み取り、負荷分散の役割を担うことができるが、データを複数保持するだけでは自動的に強い一貫性が得られるわけではない。誰が書き込みの順序を決定し、成功とみなすにはどの程度を待機し、読み取りでどのような状態を許容し、障害発生後に誰が書き込みを継続する権利を持つかを定義する必要がある。
4つの独立した次元に分解する
| 次元 | 回答すべき問い |
| トポロジとエントリポイント | リーダーが1つのみか、複数の書き込み者があるか、それともコーディネーターが複数のフォロワーと連携するか? |
| 確認条件 | ローカルでの永続化を待つのか、リモートでの受信を待つのか、リモートでのディスク書き込みを待つのか、それともアプリケーションでの適用を待つのか? |
| 読み取りセマンティクス | 古い値を許容するか、ライト・ユア・ライツ(自身の書き込みの読み取り)を要求するか、線形一貫性を要求するか? |
| 障害とフェイルオーバー | どのような障害を許容するか、確認済みの書き込みをどのように保持し、旧リーダーをどのように隔離するか? |
「同期複製」は主に確認待ちの条件を記述するものであり、「シングルリーダー」は主に書き込みの組織化方法を記述するものである。これらの用語のいずれかだけで、システム全体の保証を推測してはならない。
シングルリーダー:複製グループ内で順序を集中管理する
クライアントは書き込みをリーダーに渡し、リーダーはログの順序を形成し、変更内容をフォロワーに複製する。フォロワーはプロトコルに従ってログを適用し、クエリ可能な状態を形成する。ここで言うリーダーは、通常、シャードまたは複製グループのリーダーであり、必ずしもクラスタ全体で唯一の書き込みノードであるとは限らない。異なるシャードは、異なるマシンがリーダーを務めることができる。
同期と非同期:成功レスポンスはどの境界で発生するか
非同期複製では、リーダーはリモートフォロワーからの待機なしに成功を返すことができる。その後、リーダーが恒久的に失われ、かつ代替者がこれらの更新をまだ受信していない場合、クライアントがすでに成功した書き込みを受け取っていても、データが消失する可能性がある。単なる一時的なダウンでローカルの永続化ログが残っている場合は、データは後に復旧する可能性があるため、障害モデルを区別する必要がある。
同期複製では、指定されたリモート確認条件を満たすまで成功とみなさないが、「同期」が必ずしも「少なくとも1つのフォロワーがディスクに書き込んだこと」を意味するわけではない。プロトコルは、指定された数のノード、特定のメンバー、またはクォラムの待機を要求する場合もあれば、異なる進捗段階の待機を要求する場合もある。書き込みの遅延には、ログ処理、ネットワーク往復、キュー待ち、ストレージのオーバーヘッドが含まれ、fsyncの最大値2つだけで概括することはできない。
例えば、PostgreSQLはリモート受信、永続化、適用などの待機セマンティクスを区別しており、remote_applyを選択して初めて、待機がスタンバイ側の適用段階まで進められる。具体的な動作は設定と組み合わせて判断すべきであり、「同期」という言葉だけで任意のスタンバイ側が読み取り可能であると仮定してはならない。PostgreSQL 複製ドキュメント
1つのコミットレコードに対して、3つのフォロワーがそれぞれ異なる段階に達する可能性がある
ログ位置を簡略化した整数で表し、確認待ちのコミットレコードを108とする。現在のスタンバイ側の進捗は以下の通りであり、writeはOSファイル層への書き込み、flushは永続化、applyはリプレイ済みを示す:
| フォロワー | write | flush | apply | 位置108に対する状態 |
| A | 110 | 108 | 104 | 永続化済み、適用待ち |
| B | 110 | 106 | 106 | ファイル層に書き込まれたが、108への永続化は未了 |
| C | 107 | 107 | 107 | 108への書き込みは未了 |
progress = {
"A": {"write": 110, "flush": 108, "apply": 104},
"B": {"write": 110, "flush": 106, "apply": 106},
"C": {"write": 107, "flush": 107, "apply": 107},
}
def reached(stage, position):
return {node for node, stages in progress.items() if stages[stage] >= position}
assert reached("write", 108) == {"A", "B"}
assert reached("flush", 108) == {"A"}
assert reached("apply", 108) == set()
progress["A"]["apply"] = 108
assert reached("apply", 108) == {"A"}
assert "B" not in reached("apply", 108)
PostgreSQLにおいて、有効な同期スタンバイが設定されている場合、remote_writeは遠端のファイルシステムへの書き込みを待機するものであり、単にネットワークパケットが届いたことだけを意味するものではない。onは対応するスタンバイ側の永続化を待機し、remote_applyはさらに適用段階まで進める。remote_writeは、スタンバイデータベースプロセスのクラッシュとスタンバイ側のOSクラッシュに対して異なる保護を提供する。後者の場合、まだ永続化されていないデータが消失する可能性がある。synchronous_commit の定義
このモデルは、各段階でどのフォロワーに到達したかのみを示しており、FIRST/ANYのスタンバイ選択や完全なコミット条件の実装は含まれていない。実際に返却できるかどうかは、どのスタンバイがカウント対象となるか、いくつの確認が必要か、そしてローカルでの永続化が完了しているかどうかに依存する。Aの適用確認が現在の設定を満たす場合でも、それに基づいてまだ遅れているBへの即時読み取りをルーティングしてはならない。
フェイルオーバーは、任意のフォロワーをリネームしてリーダーにするものではない
安全なフェイルオーバーでは、プロトコルが要求するコミット済み履歴を保持し、資格のある候補者を選択し、旧リーダーが回復した後に成功した書き込みを継続しないようにする。確認済みのデータを持つフォロワーが存在する場合でも、遅れているフォロワーを代替として選択すれば、単に「以前は同期複製を使用していた」というだけでは、データが消失しないことを証明できない。
コンセンサスプロトコルは、選挙の資格、ログの整合性、コミットルールを組み合わせる。通常のマスター-スタンバイ複製では、フェイルオーバーメカニズムがこれらの保証を提供しているかどうかを確認する必要がある。旧リーダーの喪失を疑うことと、新しい書き込み権限を得ることは別の問題であり、詳細は障害検出を参照のこと。
フォロワーでの読み取りがライト・ユア・ライツを破綻させる理由
ユーザーがニックネームを変更してすぐにページを更新した場合、新しい読み取りがまだそのログを適用していないフォロワーに割り当てられると、古いニックネームが表示される可能性がある。関連する読み取りを、十分な新鮮さを保証するエントリポイントを経由させるか、書き込みレスポンスでバージョントークンを返し、読み取り側が自身の進捗を少なくともそのトークンまで待機させることができる。
トークンには、明確なスコープと順序関係が必要である。1つのシャードのログ位置を、他のシャードと無関係に比較することはできない。また、リーダーの任期を跨ぐ場合も、有効な履歴識別子が必要である。「書き込み後5秒間はメインノードを読む」という固定の戦略は、経験則として機能するかもしれないが、すべての遅延ケースにおいてライト・ユア・ライツを証明するものではない。
新鮮さトークンは、位置、履歴、読み取りスナップショットのすべてを制約しなければならない
ある書き込みが (orders, history-1, 108) を返すと仮定する。これは注文シャードの特定のログ履歴に属し、読み取りでは少なくとも位置108を含める必要がある。在庫シャードが999まで進んでいても、注文の読み取りを引き受けることはできない。また、互換性のない履歴の位置200を、108と直接比較することもできない。
from dataclasses import dataclass
@dataclass(frozen=True)
class Token:
shard: str
history: str
position: int
def can_read(applied, required, snapshot_position=None):
if (applied.shard, applied.history) != (required.shard, required.history):
return False
visible = applied.position
if snapshot_position is not None:
visible = min(visible, snapshot_position)
return visible >= required.position
required = Token("orders", "history-1", 108)
assert not can_read(Token("orders", "history-1", 106), required)
assert can_read(Token("orders", "history-1", 108), required)
assert not can_read(Token("inventory", "history-1", 999), required)
assert not can_read(Token("orders", "other-history", 200), required)
assert not can_read(Token("orders", "history-1", 110), required, 100)
最後の assert は見落としがちな境界条件である。フォロワーは110まで適用済みだが、現在のトランザクションは100の履歴スナップショットに固定されており、適用を待ってもこのスナップショットは自動的に更新されない。読み取りで先ほどの書き込みを反映させる必要がある場合、許容される分離セマンティクス内で、十分に新しい読み取りビューを構築する必要がある。
これは「読み取りビューがログのプレフィックスに対応する」という抽象モデルであり、PostgreSQLの実際のMVCCスナップショット形式ではない。実際のシステムでは、トークンから読み取り条件への信頼できるマッピングを実装する必要がある。異なる履歴間でプロトコルによって継承関係が証明できる場合は、変換して検証できるが、例のようにすべて拒否してはならない。クライアントがトークンを独自に作成して認証資格として使用することもできない。
特定の書き込みが適用されるのを待つことは、その依存関係が要求する新鮮さのみを提供するものであり、すべてのクライアントの最新の書き込みを読み取れることを意味しない。線形一貫性読み取りを保証するには、現在のリーダー権限または対応する読み取りプロトコルが必要である。ライト・ユア・ライツとグローバルなリアルタイム順序は、異なる保証である。
受信、永続化、適用は、3つの異なる線である
待機戦略を比較する際は、まず「どのフォロワーを待っているのか」を固定し、次にその段階を比較する。ここでは同期スタンバイを1つだけ選び、ローカルの条件が満たされていると仮定する。write、flush、applyの3つの境界は順序を保つ。待機条件をflushからapplyに変更しても、それは今回の成功の約束を変更するだけであり、フォロワーに対してリプレイを強制的に実行するものではない。
apply≤flush≤write
図を準備しています
受信、永続化、適用は、3つの異なる線であるapply≤flush≤write。遠端ファイル層への書き込み、永続化、適用確認は異なる保証を提供する。成功条件は、任意のフォロワーがすぐに読み取り可能であることを意味しない。
{"id":"replica-progress-gates","title":"受信、永続化、適用は、3つの異なる線である","summary":"apply≤flush≤write。遠端ファイル層への書き込み、永続化、適用確認は異なる保証を提供する。成功条件は、任意のフォロワーがすぐに読み取り可能であることを意味しない。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"write\" data-i18n=\"write\"></button><button data-mode=\"flush\" data-i18n=\"flush\"></button><button data-mode=\"apply\" data-i18n=\"apply\"></button></div><div class=\"control\"><label for=\"write\" data-i18n=\"write\"></label><output for=\"write\" id=\"write-value\"></output><input id=\"write\" type=\"range\" min=\"104\" max=\"110\" value=\"110\" step=\"1\"></div><div class=\"control\"><label for=\"flush\" data-i18n=\"flush\"></label><output for=\"flush\" id=\"flush-value\"></output><input id=\"flush\" type=\"range\" min=\"104\" max=\"110\" value=\"108\" step=\"1\"></div><div class=\"control\"><label for=\"apply\" data-i18n=\"apply\"></label><output for=\"apply\" id=\"apply-value\"></output><input id=\"apply\" type=\"range\" min=\"104\" max=\"110\" value=\"104\" step=\"1\"></div><div id=\"bars\" class=\"plot\"></div><div class=\"metrics\"><div><span data-i18n=\"visible\"></span><strong id=\"visible\"></strong></div></div><p class=\"status\" id=\"outcome\" role=\"status\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='flush';function draw(changed){let w=+$('#write').value,f=+$('#flush').value,a=+$('#apply').value;if(changed==='write'){f=Math.min(f,w);a=Math.min(a,f);}if(changed==='flush'){f=Math.min(f,w);a=Math.min(a,f);}if(changed==='apply')a=Math.min(a,f);const vals={write:w,flush:f,apply:a};for(const k in vals){$('#'+k).value=vals[k];set(k+'-value',vals[k]);}$('#bars').replaceChildren(...Object.entries(vals).map(([k,v])=>{const e=document.createElement('div');e.className='plot-row';const i=document.createElement('i');i.style.width=(v-104)/6*100+'%';const b=document.createElement('b');b.textContent=k+' '+v;e.append(i,b);return e;}));set('visible',t(a>=108?'yes':'no'));set('outcome',t(vals[mode]>=108?'ready':'wait'));$('#outcome').dataset.ready=String(vals[mode]>=108);pressed(mode);}document.querySelectorAll('input').forEach(e=>e.oninput=()=>draw(e.id));document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;draw();});draw();","audio":false,"strings":{"apply":"apply","flush":"flush","heading":"受信、永続化、適用は、3つの異なる線である","intro":"コミットレコードは108にある。スタンバイ側の write、flush、apply をそれぞれ進め、待機条件を選択する。","mode":"どの段階を待つか","no":"いいえ","ready":"選択された遠端条件を満たす","reset":"リセット","scope":"選択された単一の同期スタンバイ。ローカルのコミット条件は満たされている。モデルでは連続する整数位置をLSNの代わりに使用し、アップストリームの進捗が後退すると、ダウンストリームも同時に収縮し、新しい独立したスナップショットの選択を示す。","scopeLabel":"モデルの範囲と規約","visible":"フォロワーが108を読み取れる","wait":"コミットレコード108に到達していない","write":"write","yes":"はい"}}
マルチリーダー:複数の場所で独立した変更を許可する
地理的に分散した独立した書き込みは、ローカルの書き込みレイテンシを低減し、一部のネットワーク分断時にもリクエストの処理を継続できる。その代償として、異なる場所では、お互いの更新をまだ見ていない状態で同一オブジェクトを変更する可能性がある。例えば、AとBが同時に連絡先住所を変更した場合、同期が回復した後に、これらのバージョンの保持、選択、またはマージを行う必要がある。
これは「2つのノードが書き込み可能である」というだけでは必ずしも衝突が発生するわけではない。テナントごとに書き込みサイトを固定するか、操作をマージ可能なタイプに制限することで、衝突を減らすことができる。ただし、サイト間で書き込み権限を移行する場合、オフライン編集、および並列作成については、明確なルールが必要である。成功前にサイト間で一意性を調整する必要がある場合、すべての書き込みを完全に独立したローカル操作として扱うことはできない。
リーダーレス:プロトコルがないのではなく、複数のフォロワーを調整する
Dynamoスタイルの複製では、リクエストコーディネーターがキーの複数のフォロワーと連携することを許可する。コーディネーターは必ずしもそのキーのフォロワーを持っているわけではなく、実際に確認条件を満たすフォロワーのみがWにカウントされる。機械的に「W-1の返信を受け取ったら、自身を加える」という処理をしてはならない。
固定されたフォロワー集合のサイズをNとし、1回の書き込みでW個のフォロワーの確認を得、1回の読み取りでR個のフォロワーを待機するとする。R+W>Nの場合、2つの集合は必ず交差する。しかし、交差することは、読み取りが確認済みの書き込みが残した情報に接触する可能性があることを示すだけであり、バージョンの選択、並行処理、未完成の書き込み、読み取り修復の処理は、依然としてプロトコルの一部である。
例えば、N=3、W=2、R=2の場合、書き込み1がAにしか到達せず、書き込みリクエストがタイムアウトしたとする。1回目の読み取りでAとBは1を見るが、2回目の読み取りでBとCは0を見る。プロトコルが「今回の最大バージョンを選択して返す」だけの場合、必要な伝播や読み取りによる書き込みの完了が行われていなければ、読み取られる値が後退する可能性がある。クォラムの数だけでは、線形一貫性を証明できない。
一部のリーダーレスのレジスタアルゴリズムでは、クォラムにバージョン情報と読み取りによる書き込みを組み合わせることで、線形一貫性を実現できる。したがって、リーダーレストポロジが弱い一貫性しか提供できないわけではない。完全なアルゴリズムを判断する必要がある。
障害時の一時保存と修復
Hinted handoffは、本来あるべきフォロワーに送信されるはずだった更新を一時保存し、復旧後に補送するために使用される。これは、ヒントの永続性、保持期限、および後続の修復に依存しており、任意のフォロワーが恒久的に失われた後でも必ず復旧可能であるという保証ではない。
Sloppy quorumは、一時的な代替ノードを受信に参加させることを許可し、書き込み可能性を向上させる可能性がある。しかし、読み書きで使用する候補集合が異なる場合、同じN上の交差証明をそのまま適用することはできない。詳細なパスは分散読み書きパスを参照のこと。
選択時にはまずビジネス要件を明確にする
| 要件 | さらに確認すべき点 |
| 成功した書き込みが、許容範囲内の障害によって消失しないこと | 永続化、確認集合、フェイルオーバーの資格、障害ドメイン |
| 書き込み直後に自身の修正を読み取れること | ルーティング、適用進捗、セッショントークン |
| 複数の地域でネットワークが切断されても変更可能であること | 並行衝突のセマンティクス、およびローカルで検証できない制約 |
| 総書き込みスループットの拡張 | シャードとリーダーの分散、単なるフォロワーの数ではない |
次の読み取り:競合解決と分散トランザクション。
参考文献
Keywords: replication, leader, follower, quorum, synchronous replication, read-your-writes, failover