SYSTEM DESIGN GUIDE — PART 3 / 7
データを保存する
B-Tree と LSM-Tree の中身から、トランザクション、レプリケーション、シャーディング、CAP、分散合意まで。システムデザインで最も深く掘られる領域です。
- ① 導入と土台
- ② ネットワークと通信
- ③ データを保存する
- ④ システムの部品箱
- ⑤ 壊れないシステム
- ⑥ アーキテクチャの型
- ⑦ 面接を突破する
この部がガイドの心臓部です。システムデザイン面接の議論の 6 割はデータ層の話になります。
このページの内容
- CH 11ストレージエンジンの中身(B-Tree と LSM-Tree)
- CH 12リレーショナルデータベースとインデックス
- CH 13トランザクションと分離レベル
- CH 14NoSQL の地図
- CH 15レプリケーション
- CH 16シャーディング(パーティショニング)
- CH 17CAP 定理と一貫性モデル
- CH 18分散トランザクションと冪等性
- CH 19分散合意(Paxos / Raft)とリース
CHAPTER 11ストレージエンジンの中身(B-Tree と LSM-Tree)
データベースが「ディスクにどう書いているか」を説明できる。ここを知っていると面接での深掘りに強くなる。
11.1最も単純なデータベース
db_set() { echo "$1,$2" >> db.txt; } # 追記するだけ
db_get() { grep "^$1," db.txt | tail -1; } # 最後の行が最新
これは実は書き込みが猛烈に速い(追記=シーケンシャル書き込み)。しかし読み取りが O(n) で、ファイルが無限に伸びます。この 2 つの問題(読み取りの高速化、容量の回収)を解決したのが、実際のストレージエンジンです。
11.2B-Tree(B+Tree)
MySQL(InnoDB), PostgreSQL, Oracle, SQL Server など、リレーショナル DB の標準。
┌───────────────┐
│ [30] [70] │ ← 内部ノード(キーとポインタのみ)
└──┬────┬────┬──┘
┌────────┘ │ └────────┐
┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│10,20,25 │→ │35,50,60 │→ │75,90,99 │ ← 葉ノード(実データ)
└─────────┘ └──────────┘ └──────────┘
葉ノードは連結リストで繋がる(範囲検索が速い)
- 各ノードは1 ページ(4〜16 KB)。1 ノードに数百のキーが入る
- 分岐数が数百 → 10 億件でも深さ 3〜4。つまりディスクアクセス 3〜4 回で到達
- 更新は「その場書き換え(in-place update)」
深さの計算:分岐数 500、深さ 4 → 500^4 = 625 億件。上位ノードはメモリにキャッシュされるので、実際のディスクアクセスは 1〜2 回。
クラッシュ耐性。ページの途中で電源が落ちるとデータが壊れる。そのため WAL(Write-Ahead Log / redo ログ)に先に書いてから本体を更新します。
① WAL に「この変更をする」と追記して fsync ② 実際のページを(後で、まとめて)更新 ③ クラッシュ時は WAL を読み直して復旧
これを WAL 原則と言います。「ログを先に書く」は分散システム全体を貫く思想です(Raft も Kafka も、本質は同じ)。
11.3LSM-Tree(Log-Structured Merge Tree)
Cassandra, RocksDB, LevelDB, HBase, Bigtable, ScyllaDB, TiKV などが採用。
書き込み
│
├─► [WAL に追記](クラッシュ復旧用)
│
└─► [MemTable] メモリ上のソート済み構造(スキップリスト等)
│ 一定サイズで凍結してディスクへフラッシュ
▼
[SSTable L0] ソート済み・イミュータブルなファイル
│ コンパクション(マージ)
▼
[SSTable L1] → [L2] → [L3] ... (下位ほど大きく、古い)
読み取り:MemTable → L0 → L1 → … と順に探す。遅くなりがちなので Bloom フィルタで「このファイルには絶対無い」を高速判定します。
削除:「削除しました」というマーカー(トゥームストーン)を書く。実際の削除はコンパクション時に行われます。
11.4B-Tree vs LSM-Tree(面接で頻出)
| B-Tree | LSM-Tree | |
|---|---|---|
| 書き込み | ランダム I/O、ページ単位で書き換え | シーケンシャル追記のみ → 速い |
| 読み取り | 安定して速い(深さ分のアクセス) | 複数の層を探す可能性がある(Bloom で緩和) |
| 書き込み増幅 | 中(WAL + ページ書き込み、ページ分割) | 高(コンパクションで何度も書き直す)※設定で調整可 |
| 空間増幅 | 断片化で 30% 程度の無駄 | 圧縮が効きやすい(連続してソート済み) |
| 圧縮 | ページ単位で効きにくい | ブロック単位で効きやすい |
| レイテンシの安定性 | 安定 | コンパクション中にスパイクが出る |
| トランザクション | 実装しやすい(ロックが素直) | やや複雑 |
選び方の一言
- 読み取り中心・強いトランザクション → B-Tree(PostgreSQL / MySQL)
- 書き込み中心・時系列・大量データ・圧縮したい → LSM-Tree(Cassandra / RocksDB)
深掘りの一例
面接官「なぜ時系列データに Cassandra を選ぶのですか?」 「書き込みが 99% を占め、更新がほぼ無く、追記中心だからです。LSM-Tree は 書き込みを WAL と MemTable のシーケンシャル書き込みに変換するため、 B-Tree のランダム I/O を避けられます。またソート済みで格納されるため 時間範囲のスキャンが高速で、圧縮率も高くなります。 代償としてコンパクション時に I/O とレイテンシのスパイクが出るので、 コンパクション戦略を TimeWindowCompactionStrategy にして TTL 期限切れの SSTable をまるごと落とす形にします。」
11.5その他の重要概念
- 書き込み増幅 (Write Amplification):アプリが 1 KB 書いたとき、実際にディスクへ書かれる量。SSD の寿命に直結する
- 読み取り増幅 (Read Amplification):1 回の読み取りで発生する実 I/O 回数
- RUM 予想:Read・Update・Memory の 3 つは同時に最適化できない(2 つまで)
- fsync:OS のページキャッシュから物理ディスクへ強制書き出し。これをしないと電源断でデータが消える。非常に遅い(1〜10 ms)ので、グループコミット(複数トランザクションの fsync をまとめる)が使われる
- チェックポイント:WAL が無限に伸びないよう、定期的に本体へ反映して WAL を切り詰める
第 11 章 一問一答
- WAL の目的は。
- クラッシュ時の復旧。変更を先に順次書き込みで記録し、後から本体に反映する。
- LSM-Tree の読み取りが遅くなりがちな理由と対策は。
- 複数の SSTable を探すため。Bloom フィルタとコンパクションで緩和。
- 10 億行のテーブルで B-Tree インデックスを引くと何回ディスクアクセスするか。
- 深さ 3〜4 回、上位はキャッシュされるので実質 1〜2 回。
CHAPTER 12リレーショナルデータベースとインデックス
遅いクエリを見て「どのインデックスを作れば良いか」を即答できる。
12.1正規化と非正規化
正規化(データを重複なく分割する)
users(id, name, city_id) cities(id, name, country)
- ✅ 更新が 1 箇所で済む、一貫性が保たれる、容量が小さい
- ❌ 読むたびに JOIN が必要
非正規化(あえて重複させる)
users(id, name, city_id, city_name, country) ← 都市名をコピー
- ✅ JOIN なしで読める → 読み取りが速い
- ❌ 都市名が変わったら全ユーザーを更新する必要がある(=書き込みコストと不整合リスク)
鉄則:正規化から始め、実測して遅い箇所だけ非正規化する。そして非正規化した瞬間から「更新をどう伝播するか」が設計課題になる(第 20 章のキャッシュ無効化と同じ問題)。
大規模分散 DB(Cassandra など)ではクエリ駆動設計が普通です。「どんなクエリをするか」を先に決め、そのクエリ専用のテーブルを重複して作ります。JOIN が無い世界では非正規化が前提になります。
12.2インデックスの本質
インデックスは「本の索引」です。索引がなければ全ページをめくる(フルスキャン)ことになります。
-- インデックスなし: 1000 万行を全部読む(数秒) SELECT * FROM users WHERE email = 'a@example.com'; -- email にインデックスがある: B-Tree を 3 段辿る(1 ms 未満) CREATE INDEX idx_users_email ON users(email);
代償:インデックスは
- 書き込みを遅くする(INSERT/UPDATE のたびに全インデックスを更新)
- 容量を食う(テーブル本体と同程度になることも)
インデックスの本数に普遍的な上限はありません。各インデックスの更新コスト、サイズ、クエリの重要度、書き込み量を測定し、使われていないものを削除します。
12.3複合インデックスと「左端一致の原則」
CREATE INDEX idx ON orders(user_id, status, created_at);
このインデックスが使えるクエリ
WHERE user_id = 1 ✅ WHERE user_id = 1 AND status = 'paid' ✅ WHERE user_id = 1 AND status = 'paid' AND created_at > '2026-01-01' ✅ WHERE user_id = 1 AND created_at > '2026-01-01' △(user_id だけ使える)
使えないクエリ
WHERE status = 'paid' ❌ 左端が欠けている WHERE created_at > '2026-01-01' ❌
電話帳は「姓 → 名」でソートされています。「姓が佐藤の人」は探せますが、「名が太郎の人」は全部めくらないと見つかりません。
- 主要クエリの等価条件、範囲条件、ORDER BY、GROUP BY を並べて評価する
- 等価条件を先に置くことが多いが、常に正解とは限らない
- 範囲条件の後ろでも、DB によってはインデックス条件プッシュダウンやソートに使える
- カーディナリティだけで決めず、実行計画と実測で検証する
カバリングインデックス:必要な列が全部インデックスに含まれていれば、テーブル本体を読む必要がありません(Index-Only Scan)。
CREATE INDEX idx ON orders(user_id, status) INCLUDE (total_amount); -- SELECT total_amount FROM orders WHERE user_id=1 AND status='paid' -- → インデックスだけで完結。劇的に速い
12.4インデックスの種類
| 種類 | 用途 |
|---|---|
| B-Tree | 汎用。等価・範囲・ソート・前方一致 |
| Hash | 等価のみ。範囲不可。メモリ上では速い |
| GIN / 転置 | 配列・JSON・全文検索(1 行に複数の値がある場合) |
| GiST / R-Tree | 地理空間、範囲型 |
| BRIN | 巨大で物理順序と相関の強い列(時系列のタイムスタンプ)。極小サイズ |
| 部分インデックス | WHERE deleted_at IS NULL の行だけ。サイズを大幅削減 |
| 式インデックス | LOWER(email) などの計算結果に張る |
クラスタ化インデックス(重要)
- InnoDB (MySQL):主キーがクラスタ化インデックス = 行データそのものが主キー順に格納される。セカンダリインデックスは「主キーの値」を保持するので、主キー → 行の 2 段引きになる
- PostgreSQL:ヒープ(無秩序)+ インデックスは物理位置(ctid)を指す
InnoDB での帰結:主キーは単調増加(AUTO_INCREMENT や時刻順 ID)にすべき。ランダムな UUID を主キーにすると B-Tree の挿入位置がバラバラになり、ページ分割が多発して性能が大幅に落ちます(第 25 章の ID 設計につながる)。
12.5実行計画を読む
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 AND status = 'paid';
| 表示 | 意味 | 判断 |
|---|---|---|
Seq Scan / ALL |
フルスキャン | 大テーブルなら要インデックス |
Index Scan / ref |
インデックス使用 | ✅ |
Index Only Scan |
カバリング | ヒープアクセスを省ける場合に有利 |
Nested Loop |
1 行ずつ相手を引く | 外側が小さいなら OK |
Hash Join |
片方をハッシュ表に | 大きい表同士に向く |
Merge Join |
ソート済み同士 | 入力順序、コスト、統計情報に依存 |
rows= の推定と実測の乖離 |
統計情報が古い | ANALYZE を実行 |
Sort + external merge Disk |
メモリに乗らずディスクソート | work_mem 不足 |
12.6よくある性能問題
| 症状 | 原因 | 対策 |
|---|---|---|
| N+1 クエリ | ループの中でクエリ | JOIN、または IN (...) でまとめて取得 |
| インデックスが使われない | 列に関数を適用 WHERE YEAR(d)=2026 |
WHERE d >= '2026-01-01' AND d < '2027-01-01' |
| 深いページングが遅い | OFFSET | カーソルページネーション |
SELECT * |
不要な列で I/O 増、カバリング不可 | 必要な列だけ |
| ロック待ちが多い | 長いトランザクション | トランザクションを短く、外部 API 呼び出しを中に入れない |
COUNT(*) が遅い |
全件走査 | 近似値、集計テーブル、キャッシュ |
「このクエリは user_id で絞って created_at 降順に並べるので、 (user_id, created_at DESC) の複合インデックスを張ります。 表示に必要な列を INCLUDE すればカバリングインデックスになり、 ヒープアクセスも消せます。」
第 12 章 一問一答
(a, b, c)のインデックスでWHERE b = 1は使えるか。- 使えない(左端一致の原則)。
- カバリングインデックスの利点は。
- テーブル本体を読まずインデックスだけで完結し、I/O が激減する。
- InnoDB で UUID を主キーにすべきでない理由は。
- クラスタ化インデックスへのランダム挿入でページ分割が多発するから。
- N+1 問題とは。
- 1 回の一覧取得の後、各行ごとに追加クエリを発行してしまう問題。JOIN やバッチ取得で解消。
CHAPTER 13トランザクションと分離レベル
ACID を正確に説明し、分離レベルごとに起きる異常を挙げられる。
13.1ACID
| 意味 | 実現手段 | |
|---|---|---|
| A 原子性 (Atomicity) | 全部やるか、全部やらないか | undo ログ / ロールバック |
| C 一貫性 (Consistency) | トランザクション後もドメイン不変条件が保たれる | アプリが意味を定義し、DB の制約・トランザクション・型が支援する |
| I 分離性 (Isolation) | 同時実行しても、順番にやったように見える | ロック / MVCC |
| D 永続性 (Durability) | 定義した障害モデルの範囲でコミット結果を失わない | WAL + fsync、レプリケーション、バックアップ、復旧手順 |
「C」はドメインの不変条件を指しますが、アプリケーションだけの責任ではありません(「残高は負にならない」など)をアプリが定義し、DB の CHECK、UNIQUE、外部キー、分離レベル、原子的更新などで守ります。
fsync してもディスクやサイト全体が壊れれば消えます。複数ノード・複数 AZ へのレプリケーションに加え、誤削除や相関障害にはバックアップと復旧訓練が必要です。
13.2同時実行で起きる異常
| 異常 | 内容 |
|---|---|
| ダーティリード | 他のトランザクションの未コミットの値を読む |
| ノンリピータブルリード | 同じ行を 2 回読むと値が違う(間に他がコミット) |
| ファントムリード | 同じ条件で 2 回検索すると行数が違う |
| ロストアップデート | read-modify-write が衝突し、片方の更新が消える |
| 書き込みスキュー | 各々は正しいのに、組み合わせると不変条件が壊れる |
書き込みスキューの具体例(面接頻出)
医師のオンコール当番制度。「最低 1 人は当番でなければならない」という制約がある。A 医師と B 医師が同時に「今、当番は 2 人いるな。じゃあ自分は休もう」と判断し、両方が自分を外す。結果、当番が 0 人になる。どちらのトランザクションも単独では正しいが、同時に走ると不変条件が壊れる。
13.3分離レベル
| レベル | ダーティリード | ノンリピータブル | ファントム | 書き込みスキュー |
|---|---|---|---|---|
| READ UNCOMMITTED | 起きる | 起きる | 起きる | 起きる |
| READ COMMITTED | 防ぐ | 起きる | 起きる | 起きる |
| REPEATABLE READ | 実装依存 | 実装依存 | 実装依存 | 実装依存 |
| SERIALIZABLE | 防ぐ | 防ぐ | 防ぐ | 防ぐ |
※ PostgreSQL と MySQL InnoDB でも、同じ名前の分離レベルが同じ挙動になるとは限りません。通常の読み取りとロック読み取り、MVCC、ギャップロック、SSI などを製品・操作ごとに確認します。
PostgreSQL = READ COMMITTED、MySQL(InnoDB) = REPEATABLE READ、Oracle = READ COMMITTED、SQL Server = READ COMMITTED。
既定の分離レベルは、あなたが期待するほど強くありません。「残高チェック → 引き落とし」を READ COMMITTED でやると、二重引き落としが起きます。
13.4対策の 3 つの道具
① 悲観ロック(SELECT FOR UPDATE)
BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 行をロック UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;
- 確実。競合が多い場面向き
- デッドロックに注意(必ず同じ順序でロックを取る)
② 楽観ロック(バージョン番号)
UPDATE items SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 5; -- 更新行数が 0 なら誰かに先を越された → 再読み込みしてリトライ(409 Conflict を返す)
- ロックを取らないので競合が少ない場面で高速
- API の
If-Match: "etag"はまさにこれ(412 Precondition Failed)
③ 原子的な操作にする
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100; -- 読み取ってから書くのではなく、1 文で完結
最も安全で速い。可能ならこれを選ぶ。
13.5MVCC(マルチバージョン並行性制御)
現代の DB のほとんどが採用している仕組みです。
行 id=1 の履歴 ├ version1: balance=1000, xmin=100, xmax=105 (tx100 が作り、tx105 が消した) └ version2: balance=900, xmin=105, xmax=null (tx105 が作った、現行) トランザクション 103 から見ると → version1(balance=1000)が見える トランザクション 110 から見ると → version2(balance=900)が見える
MVCC の利点:多くの通常読み取りと書き込みの競合を減らしますが、書き込み同士の競合、ロック読み取り、DDL、VACUUM、インデックス更新などはブロックし得ます。
- 古いバージョンが溜まる → VACUUM / パージが必要
- PostgreSQL では長時間のトランザクションが VACUUM を妨げ、テーブル肥大化(bloat)を起こす
- 長い分析クエリを本番 DB で走らせてはいけない典型的理由
SSI (Serializable Snapshot Isolation):PostgreSQL の SERIALIZABLE の実装。スナップショット分離で走らせつつ、危険な依存パターンを検出したら片方を中断(could not serialize access)します。ロックしないので速いが、アプリ側にリトライロジックが必須です。
13.6デッドロック
tx1: A をロック → B を待つ tx2: B をロック → A を待つ → 永遠に進まない
- DB は待機グラフの循環を検出して、片方を強制ロールバックします
- 予防策:① 常に同じ順序でロックを取る ② トランザクションを短くする ③ ロックの粒度を上げすぎない ④ アプリ側でリトライする
第 13 章 一問一答
- ACID の C は誰の責任か。
- アプリがドメイン不変条件を定義し、DB の制約・トランザクション・分離レベルで共同して守る。
- 書き込みスキューとは。
- 個々のトランザクションは正しいのに、同時実行で不変条件が壊れる異常。SERIALIZABLE または明示ロックで防ぐ。
- 楽観ロックが向く場面は。
- 競合頻度が低い場合。ロックコストがなく、競合時のみリトライすればよい。
- MVCC の代償は。
- 古いバージョンの蓄積。VACUUM が必要で、長時間トランザクションが肥大化を招く。
CHAPTER 14NoSQL の地図
「どの DB を使いますか」に、要件から論理的に答えられる。
14.1分類と代表例
| 種類 | データモデル | 代表 | 得意 |
|---|---|---|---|
| Key-Value | key → value(不透明) | Redis, DynamoDB, Memcached | キャッシュ、セッション、超低レイテンシ |
| ドキュメント | JSON 文書 | MongoDB, Couchbase, Firestore | スキーマが流動的、階層データ |
| ワイドカラム | 行キー + 列ファミリ | Cassandra, Bigtable, HBase, ScyllaDB | 巨大な書き込み、時系列、スパースな列 |
| グラフ | ノードと辺 | Neo4j, JanusGraph | 関係の多段探索(友達の友達、不正検知) |
| 時系列 | 時刻 + 値 + タグ | InfluxDB, TimescaleDB, Prometheus, Monarch | メトリクス、IoT |
| 検索 | 転置インデックス | Elasticsearch, OpenSearch | 全文検索、ログ分析、ファセット |
| NewSQL / 分散 SQL | リレーショナル | Spanner, CockroachDB, TiDB, YugabyteDB | SQL + 水平スケール + 強い一貫性 |
14.2選択のフローチャート
強いトランザクション(複数行 ACID)が必要か?
├─ YES
│ └─ データ量、IOPS、ワーキングセット、可用性要件は単一ノードに収まるか?
│ ├─ YES → PostgreSQL / MySQL(+リードレプリカ) ← まずここを疑え
│ └─ NO → Spanner / CockroachDB / TiDB(分散 SQL)
│ or アプリ側でシャーディング(Vitess 等)
└─ NO
├─ アクセスは常に単一キー? 低レイテンシが最優先?
│ └─ DynamoDB / Redis / Cassandra
├─ 書き込みが極端に多い? 時系列?
│ └─ Cassandra / Bigtable / 時系列 DB
├─ 全文検索・ファセット・スコアリング?
│ └─ Elasticsearch(※ 真実の情報源には使わない)
└─ 多段の関係を辿る?
└─ グラフ DB
「まず PostgreSQL で始めます。見積もりでは 5 年で 2 TB、ピーク 3,000 QPS なので、 リードレプリカ 2 台で十分に収まります。 単一ノードの IOPS、容量、ワーキングセット、障害復旧要件を超えるようなら、 アクセスパターンが常に user_id で絞られるので DynamoDB か Cassandra への移行が自然です。 最初から分散 DB を入れると、トランザクション・JOIN・運用の複雑さを 得るものに見合わないコストで払うことになります。」
「とりあえず NoSQL」も「とりあえず RDB」も減点です。理由が要ります。
14.3Cassandra / DynamoDB のデータモデリング(重要)
これらは(特に Cassandra では)クエリ駆動設計が重要です。DynamoDB にも似た考え方がありますが、容量制限、トランザクション、インデックス、整合性レベル、マネージドサービスとしての仕様は別に確認します。
プライマリキー = パーティションキー + クラスタリングキー 例: ユーザーのタイムラインを新しい順に取得したい CREATE TABLE timeline ( user_id uuid, -- パーティションキー: どのノードに置くかを決める post_time timestamp, -- クラスタリングキー: パーティション内のソート順 post_id uuid, content text, PRIMARY KEY ((user_id), post_time) ) WITH CLUSTERING ORDER BY (post_time DESC); → SELECT * FROM timeline WHERE user_id=? LIMIT 20; ← 1 ノードへの 1 回のシークで完了
- 主要クエリでパーティションキーを絞れるようにする(全体スキャンを避ける)
- パーティションのサイズとアクセス頻度に上限を置く(100 MB は Cassandra で使われる目安の一例で、普遍値ではない)
- JOIN が制限されるため、必要な形で重複して持つ
- セカンダリインデックスは製品・データ分布・クエリに応じて評価する(全ノードへの散布になる場合がある)
パーティションキーに country = 'JP' のような偏った値を選ぶと、1 ノードに負荷が集中して全体が死にます。→ 複合キー(user_id、device_id#日付 など高カーディナリティ)を選ぶ。
14.4Polyglot Persistence — 複数 DB の使い分け
実際の大規模サービスは 1 種類の DB では作られません。
ユーザー・注文・決済 → PostgreSQL(トランザクションが要る) セッション・カウンタ → Redis(低レイテンシ) 商品検索 → Elasticsearch(全文検索) イベントログ → Kafka → S3 / BigQuery(分析) 画像・動画 → S3(オブジェクトストレージ) レコメンド用の関係 → グラフ DB or 事前計算テーブル
「どれが真実の情報源 (source of truth) か」が曖昧になり、データ同期の問題が生まれます。→ 真実の情報源を 1 つに決め、そこから CDC(変更データキャプチャ)で派生先へ流すのが定石です(第 37 章)。
第 14 章 一問一答
- Cassandra でパーティションキーの選び方の鉄則は。
- 高カーディナリティかつ均等に分散し、クエリで必ず等価指定できる列。
- Elasticsearch を派生インデックスとして扱うことが多い理由は。
- 業務トランザクションの正規化・制約・更新モデルと検索インデックスの責務が異なるため。設定した耐久性が十分な用途では、単独のシステム・オブ・レコードになる場合もある。
- 分散 SQL(Spanner 等)の価値は。
- SQL とトランザクションを維持したまま水平スケールできること。代償はレイテンシとコスト。
CHAPTER 15レプリケーション
複製方式の違いと、それぞれで起きる問題を説明できる。
15.1なぜ複製するのか
- 可用性:1 台落ちても止まらない
- 読み取りスケール:読みを複数台に分散
- レイテンシ:ユーザーの近くにコピーを置く
- 耐久性:ディスクが壊れてもデータが残る
複製はバックアップではありません。DELETE FROM users; は全レプリカに即座に伝播します。バックアップ(時点復旧 PITR)は別途必要です。この指摘は面接で加点されます。
15.2シングルリーダー(マスター・スレーブ)
書き込み
│
▼
┌───────────┐ レプリケーションログ
│ リーダー │────┬────────┬────────┐
└───────────┘ ▼ ▼ ▼
[フォロワ1][フォロワ2][フォロワ3]
│ │ │
└────── 読み取り ──┘
| 同期レプリケーション | 非同期レプリケーション | |
|---|---|---|
| コミットの条件 | フォロワの ACK を待つ | 待たない |
| データ損失 | ACK を返した範囲は、定義した障害モデル内で失わない | リーダー故障時に未転送分が失われ得る |
| 書き込みレイテンシ | 遅い(+RTT) | 速い |
| 可用性 | フォロワが遅いと書き込みが止まる | フォロワが遅くても影響なし |
準同期(semi-synchronous)は一つの選択肢です。何台の ACK を待つか、相関障害、フェイルオーバー時にどのログを正とするかを、RPO/RTO と併せて決めます。
15.3レプリケーションラグが引き起こす 3 つの問題
これは面接で頻出です。必ず 3 つとも言えるようにしてください。
① Read-your-own-writes(自分の書き込みが読めない)
ユーザーがプロフィールを更新 → 直後にページを再読み込み → 古い情報が表示される
対策:
- 自分が書いたデータはリーダーから読む(更新後 N 秒間はリーダー固定)
- クライアントが最終書き込みのタイムスタンプ/LSN を持ち、それ以上に進んだレプリカのみ使う
② Monotonic reads(時間が巻き戻る)
1 回目の読み取り → レプリカ A(新しい)→ 「コメントが 5 件」 2 回目の読み取り → レプリカ B(古い) → 「コメントが 3 件」← 消えた?!
対策:同じユーザーは常に同じレプリカへ(ユーザー ID のハッシュで固定)
③ Consistent prefix reads(因果が逆転する)
実際: A「今何時?」→ B「3 時です」 表示: B「3 時です」→ A「今何時?」 ← 意味不明
対策:因果関係のある書き込みを同じパーティションに置く、または論理クロックで順序付け
「リードレプリカを使うと結果整合になるので、 自分の投稿直後の読み取りだけリーダーへルーティングします。 クライアントが保持する LSN やサーバー側の読み取り期限を使い、 その時点まで追いついたレプリカだけを選びます。 固定の秒数はレプリケーション遅延と SLO を測って決めます。 タイムラインの他人の投稿は数秒遅れても実害がないので、レプリカで構いません。」
15.4フェイルオーバー
1. リーダーの故障を検知(ハートビート途絶、通常 10〜30 秒) 2. 新しいリーダーを選出(最も進んだフォロワ、または合意アルゴリズム) 3. ルーティングを切り替える
- データ損失:非同期レプリケーションだと未転送の書き込みが消える
- スプリットブレイン:旧リーダーが生き返って 2 台がリーダーを名乗る → フェンシング(STONITH)やリース + エポック番号で防ぐ
- タイムアウトの設定が難しい:短すぎると誤検知(一時的な負荷でフェイルオーバー)、長すぎるとダウンタイムが伸びる
- ID の衝突:AUTO_INCREMENT を使っていると新旧で番号が重複
Raft / Paxos はログの合意を支援しますが、クライアントの再試行、ID、フェンシング、データ移行、運用者の誤操作まで自動的に解決するものではありません。
15.5マルチリーダー
複数の拠点それぞれに書き込み可能なリーダーを置く方式。
- ✅ 各リージョンでローカル書き込み → 低レイテンシ、リージョン障害に強い
- ❌ 書き込み競合が必ず起きる
| 競合解決の方法 | 説明 | 問題 |
|---|---|---|
| LWW (Last Write Wins) | タイムスタンプが新しい方を採用 | データが黙って消える。時計ずれで誤動作 |
| バージョンベクトル | 因果関係を追跡し、真の並行更新のみ検出 | 実装が複雑 |
| アプリで解決 | 両方を保存してユーザーに選ばせる | UX 次第 |
| CRDT | 数学的に必ず収束するデータ型 | 表現できる型が限られる |
CRDT (Conflict-free Replicated Data Type):G-Counter(増加のみのカウンタ)、OR-Set(追加・削除できる集合)、RGA/Yjs(テキスト)など。Figma・Google Docs 系の共同編集で使われます(Google Docs は歴史的には OT = Operational Transformation)。
15.6リーダーレス(Dynamo スタイル)
Cassandra, DynamoDB, Riak が採用。
N 個のレプリカ、W 個に書けたら成功、R 個から読む。
W + R > N なら、読み取りは必ず最新を含む(強い一貫性に近い) 例: N=3, W=2, R=2 → 2+2 > 3 ✅ 例: N=3, W=1, R=1 → 1+1 < 3 ❌ 古い値を読む可能性がある(が、超高速)
チューニングの意味
W=N, R=1:読み取り最速。書き込みは 1 台落ちると失敗W=1, R=N:書き込み最速。読み取りが重いW=R=(N+1)/2:バランス型(一般的)
修復メカニズム
- リードリペア:読み取り時に古いレプリカを見つけたら直す
- アンチエントロピー / Merkle ツリー:バックグラウンドで差分を検出して同期
- ヒンテッドハンドオフ:落ちているノードへの書き込みを別ノードが一時預かりする
W+R>N でも
- 書き込み中にノードが落ちて別ノードへスライドすると保証が崩れる
- 並行書き込みの順序は決まらない
- 部分的に失敗した書き込みはロールバックされない
→ 「クォーラム=線形化可能」ではありません。これは面接で差がつく指摘です。
第 15 章 一問一答
- レプリケーションラグの 3 つの異常と対策は。
- Read-your-writes(リーダーから読む)、Monotonic reads(同じレプリカに固定)、Consistent prefix(因果順序の保存)。
- レプリケーションはバックアップの代わりになるか。
- ならない。誤削除も複製されるため PITR バックアップが別途必要。
- N=5 で強い整合性に近づけるクォーラムは。
- 例えば W=3, R=3(W+R>N を満たす)。
- LWW の危険性は。
- 並行更新の一方が黙って失われ、時計ずれで誤った方を採用する可能性がある。
CHAPTER 16シャーディング(パーティショニング)
シャードキーを選べ、その選択の結果を予測できる。
16.1なぜシャーディングするのか
レプリケーションは読み取りをスケールさせますが、
- 書き込みは全レプリカが同じ量を処理するのでスケールしません
- データ量も 1 台に収まらなくなります
→ データを分割して別々のノードに置くのがシャーディングです。
シャーディングは最後の手段です。導入すると:
- クロスシャードの JOIN ができない
- クロスシャードのトランザクションが困難(2PC が必要)
- スキーマ変更が全シャードに必要
- 運用(バックアップ、リバランス)が複雑化
先にやること:インデックス最適化 → キャッシュ → リードレプリカ → 垂直分割(テーブル単位で別 DB へ)→ アーカイブ(古いデータを別ストレージへ)→ それでもダメならシャーディング
16.2分割方式
① レンジパーティショニング
シャード1: id 1〜1000万 / 日付 2024年 シャード2: id 1000万〜2000万 / 日付 2025年
- ✅ 範囲クエリが効率的(
WHERE date BETWEEN ...が 1 シャードで済む) - ❌ ホットスポット:最新データに書き込みが集中する(時系列の宿命)
② ハッシュパーティショニング
shard = hash(user_id) % N
- ✅ 均等に分散する
- ❌ 範囲クエリが全シャードに散る(scatter-gather)
- ❌ N が変わると全データが移動する(下記コンシステントハッシュで解決)
③ ディレクトリ(ルックアップ)方式
lookup_table: tenant_id → shard_id (別途保存)
- ✅ 柔軟。特定テナントだけ別シャードへ移せる(大口顧客の分離に有効)
- ❌ ルックアップ自体が単一障害点・ボトルネックになりうる(キャッシュ必須)
④ 地理パーティショニング
EU のユーザーデータは EU リージョンに(GDPR のデータ主権要件)
16.3コンシステントハッシュ(面接頻出・必修)
問題:hash(key) % N は N が 4→5 になると約 80% のキーが別ノードへ移動します。キャッシュなら全ミス、DB なら全データ移行です。
解決:ハッシュ空間を円(0〜2^32)に見立て、ノードもキーも円上に配置。キーは時計回りで最初に出会ったノードに属する。
0 / 2^32
│
NodeC ●──┼──● NodeA
╱ │ ╲
│ (円) │ ← key1 は時計回りで NodeA へ
│ │
╲ ╱
key2 ●────● NodeB
ノードを 1 台追加/削除しても、移動するのは K/N 個のキーだけ(K=全キー数)。
ノードが少ないと円上の配置が偏り、負荷が不均等になる。→ 仮想ノード(virtual node / vnode):1 台の物理ノードを 100〜256 個の点として円上に配置する。これで分散が均等になり、ノードごとの性能差にも重み付けで対応できます。
さらに進んだ手法
- Rendezvous Hashing (HRW):各ノードについて
hash(key, node)を計算し、最大値のノードを選ぶ。実装が単純で分散も良い - Jump Consistent Hash (Google):メモリ不要・高速。ただしノードの削除は末尾のみ
- Maglev Hashing (Google):ルックアップテーブルを事前生成。極めて高速で、ロードバランサ用に最小限の中断で済むよう設計されている
16.4シャードキーの選び方(最重要)
- カーディナリティが高い(値の種類が多い)
- 分布が均等(特定の値に偏らない)
- クエリの大半がそのキーで絞れる(さもないと全シャードスキャン)
| 例 | 評価 |
|---|---|
user_id |
⭕ 多くの SNS/EC で最適。ユーザー単位のクエリが大半だから |
tenant_id(B2B SaaS) |
⭕ ただし巨大テナントが偏りを生む → 別シャードへ隔離 |
country |
❌ カーディナリティが低く、偏る |
created_at |
△ 時系列には自然だが、最新シャードに書き込みが集中する |
hash(user_id) |
⭕ 均等分散。ただし範囲クエリ不可 |
- キーのソルティング:
hash(user_id) % 10 + "#" + timestampのように分散させ、読み取り時は 10 個を並列に読む - 有名人問題(celebrity problem):フォロワー 1 億人のアカウントは、そのユーザー専用の扱い(ファンアウトしない = pull 型)にする(第 47 章の Twitter 設計参照)
16.5リバランス(再分散)
hash % N で N を変えること(全データ移動)。
| 方式 | 説明 |
|---|---|
| 固定パーティション数 | 最初から 1024 パーティションを作り、ノードに割り当てる。ノード追加時はパーティション単位で移す。Elasticsearch, Riak が採用 |
| 動的パーティショニング | パーティションが大きくなったら分割、小さければ結合。HBase, MongoDB, Bigtable が採用 |
| ノード数比例 | ノードあたり固定数のパーティション。Cassandra |
固定パーティション数の目安:将来のノード数の 10〜100 倍を最初に作っておく。
16.6シャーディング後の困りごとと対処
| 問題 | 対処 |
|---|---|
| クロスシャード JOIN | 非正規化して片側に持つ / アプリで結合 / 小テーブルは全シャードに複製 |
| クロスシャードトランザクション | Saga パターン、または 2PC(重い)、または設計で回避 |
| グローバルな一意 ID | Snowflake 等の分散 ID(第 25 章) |
| グローバルなソート/集計 | 各シャードで部分集計 → マージ(scatter-gather)。上位 K なら各シャード K 件取得 |
| セカンダリインデックス | ローカルインデックス(各シャード内・scatter-gather 読み取り)or グローバルインデックス(別シャードに保持・書き込みが分散トランザクション化) |
| スキーマ変更 | オンライン DDL、expand-contract パターン(第 33 章) |
模範解答
「シャードキーは user_id にします。理由は、① 数億の値があるので分散が均等、 ② タイムライン取得・プロフィール取得など主要クエリの 9 割が user_id で絞れるためです。 ただし『ハッシュタグでの検索』は全シャードに散るので、 それは別途 Elasticsearch に転置インデックスを持たせます。 リバランスに備えて最初から 1024 個の論理シャードを作り、 物理ノードに複数の論理シャードを割り当てる構成にします。」
第 16 章 一問一答
- コンシステントハッシュの利点は。
- ノード増減時の移動キー数が K/N に抑えられる。
- 仮想ノードが必要な理由は。
- 物理ノードが少ないとハッシュ円上の分布が偏るため。
- 良いシャードキーの条件を 3 つ。
- 高カーディナリティ、均等分布、主要クエリで絞れること。
- シャード数を後から変えやすくする方法は。
- 論理シャードを多め(1024 等)に固定し、物理ノードへの割り当てだけを変える。
CHAPTER 17CAP 定理と一貫性モデル
CAP を正確に(=よくある誤解なしに)説明し、PACELC まで語れる。
17.1CAP 定理の正しい理解
ネットワーク分断 (P) が起きたとき、一貫性 (C) と可用性 (A) の両方は満たせない。
ネットワーク分断が発生!
[ノードA] ✕✕✕ [ノードB]
│ │
書き込み要求 読み取り要求
CP を選ぶ: B は「今は答えられません」とエラーを返す(可用性を捨てる)
AP を選ぶ: B は古いデータを返す(一貫性を捨てる)
- ❌「どんな分散システムでも CA を選べる」→ 分断をモデルに含め、分断中も全要求に応答するという意味では両立しません。単一ノードや、分断を許容しないシステムを CA と呼ぶ場合とは区別します。
- ❌「MongoDB は CP、Cassandra は AP と固定」→ 設定、操作、バージョン、障害モデルで変わります。クォーラム設定だけで線形化可能性が自動的に得られるわけでもありません。
- ❌「CAP の C は ACID の C と同じ」→ 違います。CAP の C は、各操作が実時間順序を守る線形化可能性 (linearizability) を指す定義で説明されることが多い性質です。
CAP の C = 線形化可能性:「全ての操作が、ある 1 つの時系列上に並んでいるように見え、書き込みが完了した瞬間から、全ての読み取りがその値以降を返す」。つまり「レプリカが 1 台しかないように振る舞う」ということです。
17.2PACELC — CAP より実用的
Partition が起きたら A か C か、Else(平常時は)Latency か Consistency か。
分断時: A or C 平常時: L or C
重要なのは後半です。平常時ですら、強い一貫性を取るとレイテンシが増えます(複数ノードの合意を待つ必要があるため)。実際の設計判断の 99% は「分断時」ではなく「平常時のレイテンシ vs 一貫性」です。
| システム | PACELC |
|---|---|
| DynamoDB / Cassandra | 構成、読み取り種別、整合性レベルで異なる。製品名だけで固定しない |
| Spanner | 外部一貫性を提供する構成があるが、レイテンシ・リージョン構成とのトレードオフを明記する |
| MongoDB | write/read concern と read preference に依存する |
| MySQL(非同期レプリカ) | レプリカ読み取りではラグを許容し得る。リーダー読み取りなら別の性質になる |
17.3一貫性モデルは一列の階段ではない
線形化可能性と直列化可能性は、単純な強弱関係ではありません。前者は操作の実時間順序、後者はトランザクションの結果がある直列実行と等価かどうかを扱います。
| 観点 | 問い | 代表例 |
|---|---|---|
| 操作の実時間順序 | 完了した書き込みを後続の読み取りが必ず追い越さないか | 線形化可能性 |
| トランザクションの競合 | 複数トランザクションの結果が、ある直列実行と等価か | 直列化可能性 |
| 操作間の因果関係 | 原因を観測した後に結果を観測できるか | 因果一貫性 |
| セッション内の保証 | 自分の書き込み、単調な読み取りなどを保つか | セッション保証 |
| 収束 | 書き込みが止まったとき、レプリカが同じ値に収束するか | 結果整合性 |
線形化可能性と直列化可能性の両方を満たし、さらに実時間順序も保つ性質を Strict Serializability(外部一貫性)と呼びます。因果一貫性や結果整合性にも、実装ごとの遅延・競合解決・可用性の保証があります。
線形化可能性と直列化可能性の違いは面接で高難度の質問として出ます。
| 線形化可能性 | 直列化可能性 | |
|---|---|---|
| 対象 | 単一オブジェクトの単一操作 | 複数オブジェクトの複数操作(トランザクション) |
| 保証 | 実時間の順序を守る | ある論理的順序と等価であればよい |
| 例 | 「書き込み完了後の読み取りは必ず新値」 | 「T1→T2 か T2→T1 のどちらかと同じ結果」 |
直列化可能だが線形化可能でない実装では、トランザクション全体の結果はある直列順序と等価でも、完了済み操作の後続読み取りが古い値を見ることがあります。実時間順序を保証するかどうかを別に確認します。
17.4「強い一貫性は本当に必要か」を問う
高評価の視点
「機能ごとに必要な一貫性レベルを分けます。 ・残高・在庫の引き当て : 線形化可能性が必要。単一ノードのトランザクション or Spanner ・いいね数の表示 : 結果整合で十分。多少ズレても実害がない ・自分の投稿の表示 : read-your-writes だけあれば良い ・タイムライン : 因果一貫性があれば十分(返信が元投稿より先に見えなければよい) 全体を強い一貫性にするとレイテンシとコストが跳ね上がるので、 必要な箇所だけ強くするのが正しい設計だと考えます。」
これが言えると、CAP を「暗記した人」ではなく「使える人」として評価されます。
第 17 章 一問一答
- CAP の CA はどう理解するか。
- 分断を含む分散モデルで、分断中の一貫性と可用性を同時には保証できない。単一ノードなどを CA と呼ぶ場合とは区別する。
- PACELC の “ELC” が重要な理由は。
- 分断は稀だが、平常時のレイテンシと一貫性のトレードオフは常に発生するから。
- 線形化可能性と直列化可能性の違いは。
- 前者は操作の実時間順序、後者はトランザクション結果と直列実行との等価性を扱う。単純な強弱関係ではない。
- 結果整合性の弱点は。
- 「いつ収束するか」の保証がなく、単独では read-your-writes すら保証しない。
CHAPTER 18分散トランザクションと冪等性
複数サービスにまたがる操作の整合性を保つ手段を選べる。
18.1なぜ難しいのか
注文サービス: 注文を作成 ✅ 決済サービス: 課金 ✅ 在庫サービス: 在庫を減らす ❌(ここで失敗) → 課金だけ済んで商品が確保されていない。どうする?
単一 DB なら ROLLBACK で終わりですが、別々の DB を跨ぐとロールバックできません。
18.2二相コミット (2PC)
コーディネータ
① Prepare(準備できる?)
├──► 参加者A: 「はい」(ロックを取って待機)
├──► 参加者B: 「はい」
└──► 参加者C: 「はい」
② Commit(全員 Yes なら)
├──► A: コミット
├──► B: コミット
└──► C: コミット
- ブロッキング:Prepare 後にコーディネータが落ちると、参加者はロックを握ったまま永久に待つ(イン・ダウト状態)
- コーディネータが単一障害点にならないよう、ログの永続化・複製・リカバリを設計する必要がある
- 全参加者が生きている必要があり、独立故障などの単純化した仮定では可用性が全参加者の積になる(99.9%^5 = 99.5%)。実際は共有依存、再試行、フェイルオーバー、タイムアウトを含めて評価する
- 遅い(2 往復 + ディスク書き込み)
マイクロサービス間で 2PC はほぼ使いません。使われるのは単一 DB クラスタ内部(Spanner の Paxos ベース 2PC など、コーディネータ自体が合意アルゴリズムで冗長化されている場合)に限られます。
18.3Saga パターン(実務の主流)
長いトランザクションを、ローカルトランザクションの連鎖 + 補償処理に分解する。
【正常系】 注文作成 → 課金 → 在庫引当 → 配送手配 【在庫引当で失敗したら(補償トランザクション)】 注文作成 ← 注文キャンセル 課金 ← 返金 在庫引当 ✕
| コレオグラフィ(振付) | オーケストレーション(指揮) | |
|---|---|---|
| 仕組み | 各サービスがイベントを購読し反応する | 中央のオーケストレーターが順に指示 |
| 利点 | 疎結合、中央のボトルネックなし | フローが 1 箇所に見える、デバッグしやすい |
| 欠点 | 全体像がどこにも書いていない、循環しやすい | オーケストレーターが複雑化・単一障害点 |
| 向き | 小さく単純なフロー | 参加者が多い、分岐・再試行・補償が複雑なフロー |
- 分離性がない (No Isolation)。途中状態が他から見える → 「支払い済みだが未確定」のような中間状態をドメインモデルに持たせる
- 補償が常に可能とは限らない(メール送信は取り消せない)→ 「取り消せない操作は最後に置く」のが原則
- 意味的ロック:処理中のレコードに
PENDINGフラグを立て、他からの操作を防ぐ
18.4Outbox パターン(必修)
問題:「DB を更新して、かつイベントを Kafka に publish する」を原子的にやりたい。
❌ 危険な実装: db.save(order) ← 成功 kafka.send(event) ← ここで落ちる → イベントが失われる ❌ 逆順も危険: kafka.send(event) ← 成功 db.save(order) ← ここで落ちる → 存在しない注文のイベントが流れる
【同一トランザクション内】
BEGIN;
INSERT INTO orders (...);
INSERT INTO outbox (event_type, payload, created_at); ← 同じ DB なので原子的
COMMIT;
【別プロセス】
outbox テーブルをポーリング or CDC(Debezium 等で WAL を読む)
→ Kafka へ publish → 送信済みマーク
これで、同一 DB のコミットについて outbox レコードが失われないことを保証できます。リレーは再試行、送信済み管理、poison message、保持期間、監視を持ち、少なくとも 1 回配信を目指します。
逆方向の Inbox パターン:受信側で 処理済みメッセージ ID を記録し、重複メッセージを捨てる(=冪等な消費)。
18.5「Exactly-once」の真実
「何を対象に、どの障害まで」exactly-once を保証するかを明示しない説明は不十分です。ネットワーク越しの配信では、送信側が ACK を受け取れず、相手が未受信なのか処理済みなのか判別できない場合があります。そのため実務では、at-least-once 配信 + 冪等な処理、または同一トランザクション内の重複排除で、限定した範囲の副作用を一度きりにします。
配信保証の 3 段階: at-most-once : 送って忘れる。失うかもしれない at-least-once : ACK が来るまで再送。重複するかもしれない ← 実用の基本 exactly-once : 保証範囲を限定すれば実現可能。外部副作用まで含むかを明記する
| 冪等にする手法 | 例 |
|---|---|
| 一意キー + 制約 | INSERT ... ON CONFLICT DO NOTHING、処理済み ID テーブル |
| UPSERT / 絶対値の代入 | SET status = 'paid'(+1 ではなく) |
| 条件付き更新 | WHERE status = 'pending'(状態機械) |
| バージョン/シーケンス番号 | 古い番号のメッセージは無視 |
| 冪等性キー | クライアント生成の UUID(第 9 章) |
Kafka の “exactly-once semantics”:これは冪等プロデューサ(シーケンス番号による重複排除)+ トランザクション(consume-transform-produce をアトミックに)の組み合わせで、Kafka の内部で完結する場合のみ成立します。外部システム(DB や決済 API)への副作用は含まれません。この区別は重要です。
18.6まとめ:整合性を保つ設計の優先順位
1. そもそも 1 つのサービス/DB のトランザクションに収まらないか?(境界の再設計) ↓ 無理なら 2. Outbox + イベント駆動 + 冪等な消費(結果整合) ↓ 補償が必要なら 3. Saga(オーケストレーション型) ↓ どうしても同期的な強整合が必要なら 4. 分散 SQL(Spanner / CockroachDB)に任せる ↓ 最後の手段 5. 2PC
第 18 章 一問一答
- 2PC の最大の欠点は。
- コーディネータ障害時に参加者がロックを保持したままブロックすること。
- Saga に無い ACID の性質は。
- 分離性 (Isolation)。中間状態が外から見える。
- Outbox パターンが解決する問題は。
- DB 更新とイベント発行の原子性(デュアルライト問題)。
- exactly-once は実現可能か。
- 保証範囲を限定すれば可能。一般の外部副作用まで含む配送は、at-least-once + 冪等処理、重複排除、トランザクションで効果を構成する。
CHAPTER 19分散合意(Paxos / Raft)とリース
「誰がリーダーか」を安全に決める仕組みを説明でき、分散ロックの落とし穴を指摘できる。
19.1なぜ合意が必要か
分散システムで「全員が同じことに同意する」必要がある場面:
- 誰がリーダーか(フェイルオーバー)
- どのノードがクラスタのメンバーか
- 分散ロックを誰が持っているか
- ログの何番目に何が書かれたか
非同期ネットワーク(メッセージ遅延に上限がない)で 1 台でも故障しうるなら、必ず合意に達するアルゴリズムは存在しない。→ 実際のアルゴリズムは「安全性は絶対に守る、進行性は通常時に保証する」という妥協をします。
19.2Raft(理解しやすい合意アルゴリズム)
3 つの状態:Follower / Candidate / Leader
すべてのノードは Follower として起動 │ 一定時間リーダーからの心拍がない │ (150〜300ms は論文の例。RTT、処理時間、障害検知要件で調整) ▼ Candidate になり、term(任期番号)を +1 して投票を求める │ 過半数の票を得たら ▼ Leader になり、定期的に心拍を送る ※ タイムアウトをランダムにすることで、同時立候補(票の分裂)を防ぐ
クライアント → Leader Leader: 自分のログに追記 Leader → 全 Follower に AppendEntries 過半数が ACK → コミット確定 → クライアントに成功を返す (残りには後から伝わる)
- 5 ノードなら 3 ノードの合意で決定 → 2 台まで故障に耐える
- 分断が起きても、過半数を持つ側だけが動ける → スプリットブレインが原理的に起きない
- 奇数ノードが基本(4 ノードは 3 必要なので、5 ノードより耐障害性が低い)
| ノード数 | クォーラム | 耐えられる故障数 |
|---|---|---|
| 3 | 2 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
Raft の 3 つの安全性保証
- 選挙の安全性:1 つの term で最大 1 人のリーダー
- ログの一致:同じ index と term のエントリは同じ内容
- リーダー完全性:コミット済みエントリを持つノードだけがリーダーになれる
Paxos との違い:Paxos は理論的に先行し(Lamport, 1998)、より一般的ですが理解が難しい。Multi-Paxos が実用形で、Google の Chubby / Spanner が採用。Raft は「理解しやすさ」を設計目標にした後発で、etcd / Consul / TiKV / CockroachDB が採用。面接では Raft で説明すれば十分です。
19.3合意システムの実用
| システム | 用途 |
|---|---|
| ZooKeeper (ZAB) | Kafka の旧メタデータ管理、HBase、分散ロック |
| etcd (Raft) | Kubernetes の全状態を保存 |
| Consul (Raft) | サービスディスカバリ、設定 |
| Chubby (Paxos, Google) | ロックサービス。GFS/Bigtable のマスター選出 |
これらは「少量のメタデータ」用です。秒間数万の書き込みには耐えません。「重要だが小さいデータ」を置く場所と覚えてください。
19.4リース (Lease) — 実用的な近似
リース = 有効期限付きのロック。
ノード A: 「10 秒間リーダーです」というリースを取得 → 10 秒間は他の誰もリーダーになれない → A が落ちても、10 秒経てば自動的に他が取れる(デッドロックしない) → A は 10 秒経つ前に更新(renew)し続ける
時計に依存する。
A がリースを取得 → GC で 15 秒停止 → 目覚めた A は「まだ自分がリーダーだ」と思っている その間に B がリーダーになっている → 2 人のリーダーが存在!
✅ 解決策:フェンシングトークン (fencing token)
ロックを取るたびに単調増加する番号を発行する A がロック取得 → token=33 A が停止 B がロック取得 → token=34 A が復活して書き込み: 「token=33 で書きます」→ ストレージ側が「34 を見たので拒否」
ストレージ側がトークンを検証することが必須です。これがないと分散ロックは安全になりません。
加点シーン
面接官「Redis で分散ロックを実装すればいいのでは?」 「SET key value NX PX 30000 で単純なロックは作れますが、2 つ問題があります。 ① Redis がシングルインスタンスなら単一障害点、レプリカがあると フェイルオーバー時に非同期複製のせいでロックが二重に取得されうる。 ② クライアントの GC 停止や長いネットワーク遅延で、 リース期限後も自分がロックを持っていると誤認する。 ですので、保護対象のリソース側でフェンシングトークンを検証する設計にします。 厳密さが必要なら、Redis ではなく etcd / ZooKeeper のような 合意ベースのシステムを使います。」
19.5論理時計 — 順序を決める別の方法
物理時計には障害時の上限を保証できないため、因果関係の順序を単独で決める用途には使えません。
| 手法 | 説明 |
|---|---|
| Lamport クロック | 各ノードがカウンタを持ち、メッセージ送受信で同期。「a→b なら L(a)<L(b)」だが逆は言えない |
| ベクタークロック | 各ノードのカウンタの配列。並行かどうかを判定できる |
| ハイブリッド論理クロック (HLC) | 物理時刻 + 論理カウンタ。CockroachDB が採用 |
| TrueTime (Google Spanner) | GPS + 原子時計で時刻の不確実性区間 ε を明示。コミット時に ε だけ待つ(commit-wait)ことで、外部一貫性(strict serializability)をグローバルに実現 |
Spanner の TrueTime は面接で語ると強いトピックです。「時計の誤差を無くす」のではなく「誤差の上限を保証し、その分だけ待つ」という発想の転換が本質です(典型的な ε は数 ms)。
第 19 章 一問一答
- なぜ合意ノードは奇数にするか。
- 偶数だとクォーラムに必要な数が増え、耐障害数が同じか悪化するため。
- Raft がスプリットブレインを防げる理由は。
- 過半数の合意を必須とするため、分断された両側が同時に過半数を持つことはありえない。
- 分散ロックを安全にする仕組みは。
- フェンシングトークン(単調増加番号)をリソース側で検証する。
- Spanner の TrueTime の役割は。
- 時刻の不確実性上限を保証し、コミット時にその分待つことで全球的な外部一貫性を得る。


