asopi tech
asopi techIndie Developer
コンセンサスアルゴリズム・アンダーグラウンド【第2回】 ― クラッシュ故障の系譜、PaxosとRaft

【2026年7月版】

コンセンサスアルゴリズム・アンダーグラウンド【第2回】 ― クラッシュ故障の系譜、PaxosとRaft

公開日: 2026/07/22
読了時間: 約 20分

前回は、停止と遅延を区別できないこと、過半数クォーラムがクラスタの外側を保護しないこと、ログと適用済み状態が別物であることを確認した。今回はその上に立つ最初の系譜、ノードは停止するが虚偽は送らないというクラッシュ故障モデルのアルゴリズムを扱う。

Paxos:多数派が交差することだけを使う

Paxos の対象は、クラッシュ故障が発生し得る複数ノードにおいて、一つの値を安全に決定することである。

提案には単調増加する番号を付与する。多数派がより大きい番号の提案を受理した後は、古い提案を拒否する。基本構造は Prepare/Promise と Accept/Accepted の二段階である。

class Acceptor:
    def __init__(self):
        self.promised = -1
        self.accepted_number = None
        self.accepted_value = None

    def prepare(self, number):
        if number <= self.promised:
            return None
        self.promised = number
        return self.accepted_number, self.accepted_value

    def accept(self, number, value):
        if number < self.promised:
            return False
        self.promised = number
        self.accepted_number = number
        self.accepted_value = value
        return True

提案者は多数派から Promise を得た後、過去に受理済みの値が存在すれば、その中で最大番号の値を引き継ぐ。ここが Paxos の核心である。提案者は自身の値を通すのではなく、すでに決定されている可能性のある値を優先する。

responses = [a.prepare(n) for a in acceptors]
responses = [r for r in responses if r is not None]

if len(responses) >= quorum:
    previously_accepted = [r for r in responses if r[0] is not None]
    if previously_accepted:
        value = max(previously_accepted)[1]

    accepted = sum(a.accept(n, value) for a in acceptors)
    decided = accepted >= quorum

安全性の根拠は単純である。Paxos が用いる性質は、多数派クォーラム同士が必ず交差することのみである。5ノードなら多数派は3であり、任意の3ノード集合を二つ選んでも、少なくとも1ノードは重複する。この重複したノードが、過去の決定候補を次の提案へ伝える。

Basic Paxos と Multi-Paxos

Basic Paxos は一つの値を決定するアルゴリズムである。データベースが必要とするのは継続的なログであるため、実用上は Multi-Paxos を用いる。安定したリーダーが存在する間は Prepare を省略し、各ログスロットについて Accept のみを反復する。

leader
 ├─ ACCEPT slot=101 value=A
 ├─ ACCEPT slot=102 value=B
 └─ ACCEPT slot=103 value=C

この最適化まで含めると、Multi-Paxos と Raft の実行形態は近接する。両者の主な差は、リーダー選出とログ整合性の規定方法にある。比較研究においても、Raft と Paxos の共通性は大きく、とくにリーダー選出方式が主要な差だと分析されている。

Google の Spanner や Chubby の系譜では、Paxos グループ単位でデータを複製する。ただし現代の実装は論文どおりの Basic Paxos ではなく、永続化、リーダーリース、再構成、スナップショット、バッチングを含む独自の Multi-Paxos である。

Raft:同じ安全性を、実装できる形に分解する

Raft の目的は、Paxos と同等のクラッシュ耐性を、実装者が理解できる形へ構造化することである。公式サイトも、Paxos と同等の耐障害性と性能を持ち、理解しやすさを目的に設計したアルゴリズムだと説明している。

Raft は問題を明確に分割した。リーダー選出、ログ複製、安全性、メンバーシップ変更、ログ圧縮の五つである。

from dataclasses import dataclass

@dataclass
class Entry:
    term: int
    command: bytes

class RaftNode:
    def __init__(self, node_id, peers):
        self.node_id = node_id
        self.peers = peers
        self.state = "follower"
        self.current_term = 0
        self.voted_for = None
        self.log = []
        self.commit_index = -1
        self.last_applied = -1

永続化が必要な最低限の状態は currentTermvotedForlog[] の三つである。これらをディスクへ確実に保存する前に応答すると、再起動後に同一 term で複数の候補へ投票する、あるいは受理したログを失うといった不整合が生じる。

リーダー選出

Follower は選挙タイムアウトを超過すると Candidate へ遷移する。

def start_election(self):
    self.state = "candidate"
    self.current_term += 1
    self.voted_for = self.node_id
    votes = 1

    for peer in self.peers:
        response = peer.request_vote(
            term=self.current_term,
            candidate_id=self.node_id,
            last_log_index=len(self.log) - 1,
            last_log_term=self.log[-1].term if self.log else 0,
        )
        if response.term > self.current_term:
            self.become_follower(response.term)
            return
        votes += int(response.granted)

    if votes >= self.quorum():
        self.become_leader()

投票側は、候補のログが自身と同等以上に新しいことを確認する。

def request_vote(self, term, candidate_id, last_log_index, last_log_term):
    if term < self.current_term:
        return VoteResponse(self.current_term, False)

    if term > self.current_term:
        self.become_follower(term)

    local_term = self.log[-1].term if self.log else 0
    local_index = len(self.log) - 1

    up_to_date = (
        last_log_term > local_term
        or (last_log_term == local_term and last_log_index >= local_index)
    )

    can_vote = self.voted_for in (None, candidate_id)
    granted = can_vote and up_to_date

    if granted:
        self.voted_for = candidate_id
        self.persist_term_and_vote()

    return VoteResponse(self.current_term, granted)

このログの新しさの判定により、コミット済みエントリを欠いたノードはリーダーになれない。Paxos が提案時に過去の値を回収するのに対し、Raft は選挙の資格そのものを制限する。

ログ複製

リーダーは、直前エントリの index と term を付与して AppendEntries を送信する。

def append_entries(self, term, prev_index, prev_term, entries, leader_commit):
    if term < self.current_term:
        return False

    if prev_index >= 0:
        if prev_index >= len(self.log):
            return False
        if self.log[prev_index].term != prev_term:
            return False

    insert_at = prev_index + 1
    for i, entry in enumerate(entries):
        pos = insert_at + i
        if pos < len(self.log) and self.log[pos].term != entry.term:
            self.log = self.log[:pos]
        if pos == len(self.log):
            self.log.append(entry)

    self.persist_log()

    if leader_commit > self.commit_index:
        self.commit_index = min(leader_commit, len(self.log) - 1)

    return True

不一致が検出された場合は、Follower 側の未確定な後続を切り捨て、リーダーのログへ揃える。

Raft が実際に解決したもの

Raft の価値は、新しい安全性を発明した点ではなく、実装に必要な境界を明示した点にある。

  • term が世代を表す
  • 一つの term で一票のみ投じる
  • ログが自身と同等以上に新しい候補にのみ投票する
  • prevLogIndexprevLogTerm で連続性を検査する
  • 現 term のエントリを多数派へ複製してからコミットを進める
  • 過去 term のエントリは、現 term のエントリとともに間接的に確定する

最後の二つは見落とされやすい規則である。過去 term のエントリが多数派に存在するという事実だけでは、コミット済みと判断できない。

Viewstamped Replication と Zab:同じ骨格の別名

Viewstamped Replication は、primary と backup による複製を view 番号で世代管理する。

view 10: primary=A
view 11: primary=C

Raft の term に近い構造である。操作は primary が順序付けし、backup のクォーラムへ複製される。primary 障害時には view change を実行し、クォーラムから最新のログを回収する。

Zab(ZooKeeper Atomic Broadcast)は、ZooKeeper 向けのリーダーベース原子ブロードキャストである。目的は値を一つずつ決定することではなく、更新履歴を全レプリカで同一順序にすることにある。トランザクションIDは概念的に世代と連番の組を持つ。

zxid = (epoch, counter)

リーダー交代時には、新リーダーが最新の履歴を保持することを確認し、Follower を同期させてから新規提案を受け付ける。Raft がログ整合性から選挙資格を制限するのに対し、Zab はリーダー起動時の履歴同期フェーズを明示的に置いている。

Raft、Multi-Paxos、Viewstamped Replication、Zab は、実装構造として見れば近接した系統である。

epoch / view / term
stable leader
ordered log
majority replication
leader replacement

差異は、安全性を説明する用語と、リーダー交代時に回収する状態の規則にある。

EPaxos:リーダーへのRTTを消す

Raft や Multi-Paxos では、すべての書き込みが単一リーダーを経由する。地理分散環境では、これがそのまま遅延となる。

Tokyo client → US leader → Tokyo replica

東京のクライアントが東京のレプリカへ書き込む場合でも、米国のリーダーまでの往復が必要となる。

EPaxos は固定リーダーを排除し、任意のレプリカが要求を提案できるようにする。すべての操作を直ちに一つのグローバル順序へ並べるのではなく、競合する操作間の依存関係を合意する。

@dataclass
class Command:
    id: str
    reads: set[str]
    writes: set[str]

def conflicts(a: Command, b: Command) -> bool:
    return bool(
        a.writes & b.writes
        or a.writes & b.reads
        or b.writes & a.reads
    )

def dependencies(command, known_commands):
    return {
        other.id for other in known_commands
        if conflicts(command, other)
    }

異なるキーへの操作のように相互に交換可能な要求は、高速パスで決定できる。

代償は実装の複雑さである。依存グラフの管理、サイクルの解決、fast quorum、リカバリプロトコル、実行可能順序の計算が必要となり、性能は競合率に左右される。低競合ワークロードでは有効だが、すべての操作が同一のキーやメタデータを更新する場合、依存関係が集中し、高速パスの利点は縮小する。

Flexible Paxos:クォーラムは同じである必要がない

通常の Paxos では、Phase 1 と Phase 2 の双方で多数派を用いる。しかし安全性に必要なのは、同種のクォーラム同士が交差することではない。

Phase-1 quorum ∩ Phase-2 quorum ≠ ∅

たとえば9ノードであれば、Phase 1 を7、Phase 2 を3とする設計も成立する。通常運転では Phase 2 が支配的であるため、安定リーダー下の書き込みを小さいクォーラムで処理できる。その代わり、リーダー交代には大きなクォーラムが必要となる。

これはコンセンサス設計における重要な視点である。すべての操作を同一のクォーラムで処理する必要はなく、頻繁な経路を軽く、稀な復旧経路を重く設計できる。ただし可用性の故障パターンは変化するため、クォーラムを縮小すれば高速化するという単純な結論にはならない。

Multi-Raft:合意の単位を小さくする

単一の巨大な Raft グループでは、すべての書き込みが一つのリーダーへ集中する。そこでデータを多数の範囲へ分割し、範囲ごとに Raft グループを構成する。

range 1: A B C   leader=A
range 2: B C D   leader=C
range 3: C D E   leader=E

CockroachDB はデータを Range へ分割し、各 Range を独立した Raft グループとして複製する。書き込みは Range の Raft リーダーへ送られ、過半数のログへ複製された後にコミットされる。TiKV も Rust 実装のトランザクショナルKVストアとして、データ範囲ごとの Raft 複製を行う。

ただし Multi-Raft は問題を除去するのではなく、粒度を縮小するにすぎない。代わりに次の問題が発生する。

  • 数十万 Raft グループの tick 管理
  • ハートビートの増加
  • Range split とメンバーシップ変更の競合
  • ホット Range へのリーダー集中
  • 複数 Range をまたぐトランザクション
  • スナップショット転送によるI/O飽和

論文に書かれていないもの

ここからは、アルゴリズム論文だけでは不足する部分である。学習用の Raft は数百行で実装できるが、本番で必要となるのはその周辺である。

永続化の順序

incorrect:            correct:
  send success          write WAL
  write WAL             fsync
                        send vote or success

実際には、WAL とメタデータ、データページ、スナップショットの間の順序まで設計する必要がある。

バッチングと backpressure

一件ごとに fsync すると低速であるため、複数の提案をまとめる。バッチサイズを拡大するとスループットは増加するが、待ち時間も増加する。

batch = []
deadline = now() + MAX_DELAY

while len(batch) < MAX_BATCH and now() < deadline:
    command = try_receive()
    if command:
        batch.append(command)

append_and_fsync(batch)
replicate(batch)

Follower が遅延している場合、無制限に AppendEntries を送信するとメモリと送信キューが膨張する。

if peer.inflight_bytes > MAX_INFLIGHT:
    pause_replication(peer)
else:
    send_append_entries(peer)

etcd の Raft ライブラリには、optimistic pipelining、フロー制御、メッセージとログのバッチング、クォーラム喪失時のログ無制限増加の防止などが実装されている。

スナップショットとログ圧縮

ログを永久保存すると増加を続ける。

if applied_index - snapshot_index > SNAPSHOT_THRESHOLD:
    snapshot = state_machine.serialize()
    persist_snapshot(snapshot, applied_index)
    truncate_log(before=applied_index)

順序を誤ると復旧不能となる。安全な基本順序は次のとおりである。

1. state machine snapshot を生成
2. snapshot metadata を永続化
3. snapshot が確実に読めることを確認
4. 対応するログを削除

メンバーシップ変更

3ノード構成から5ノード構成へ一度に切り替えると、旧構成と新構成で相互に交差しない多数派が同時に成立し得る。そのため Raft では Joint Consensus を用いる。

C_old → C_old,new → C_new

移行中は、旧構成と新構成の双方でクォーラムを要求する。

線形化読み取り

リーダーであっても、無条件に最新値を返してよいわけではない。ネットワーク分断後の古いリーダーである可能性があるためである。方式は主に三つある。

方式内容特性
Read through log読み取り要求もログへ追加する安全だが低速
ReadIndexハートビートで現在もクォーラムを保持していることを確認してから返す実用的な既定
Leader lease一定時間は自身がリーダーであると仮定する高速だが時計への依存が強い

etcd の Raft は ReadIndex 型と lease 型の線形化読み取りを実装している。

アルゴリズムをI/Oから分離する

etcd の Raft ライブラリは、Raft を決定的な状態機械としてモデル化している。入力はローカルの tick かネットワークメッセージであり、出力は送信すべきメッセージ、永続化すべきログ、状態変化である。ネットワーク、ディスク、状態機械への適用は利用側へ分離されている。

for {
    select {
    case <-ticker.C:
        node.Tick()

    case msg := <-network:
        node.Step(ctx, msg)

    case ready := <-node.Ready():
        wal.Save(ready.HardState, ready.Entries)
        transport.Send(ready.Messages)
        applySnapshot(ready.Snapshot)
        applyCommitted(ready.CommittedEntries)
        node.Advance()
    }
}

この構造は設計上の選好ではない。Raft 本体にソケット通信やディスクI/Oを直接埋め込むと、障害注入、決定的テスト、リプレイ検証のいずれも困難となる。etcd 公式の raftexample も、Raft サーバー、コミット済み更新を適用するKVストア、REST API を分離している。

本番の Raft 実装が備える周辺は、おおよそ次のとおりである。

Raft core
 ├─ durable WAL
 ├─ snapshot manager
 ├─ transport / TLS and identity
 ├─ message deduplication
 ├─ batching / backpressure
 ├─ membership changes
 ├─ read index / leader lease
 ├─ clock assumptions
 ├─ disk failure handling / corruption detection
 ├─ metrics / tracing / fault injection
 └─ rolling upgrade / state-machine versioning

したがって、新規のデータベースや分散ストレージを実装する際、コンセンサスアルゴリズムを一から記述する判断は慎重を要する。とくにクラッシュ故障モデルであれば、etcd/raft や TiKV の raft-rs のように状態機械として分離された実績のあるライブラリを用い、ストレージ・通信・適用層を自前で構築する方が現実的である。

まとめ

  • Paxos が用いる道具は、多数派クォーラム同士が必ず交差することのみである。提案者は自身の値ではなく、すでに決定されている可能性のある値を伝播させる。
  • Raft は同一の安全性を、選挙資格の制限とログ連続性の検査という実装可能な規則へ分解した。Multi-Paxos、VR、Zab とは実装構造として近接する。
  • EPaxos は固定リーダーを排除して地理分散の往復を削減し、Flexible Paxos は頻繁な経路と稀な復旧経路でクォーラムを変更できることを示した。
  • Multi-Raft は合意の単位を縮小する設計であり、リーダーレス化ではない。tick 管理やホット Range という別の問題が発生する。
  • 本番実装の大半は、永続化順序・バッチング・backpressure・スナップショット・メンバーシップ変更・線形化読み取りという、論文の外側にある。

次回は故障モデルを一段引き上げる。ノードが停止するだけでなく、署名付きで矛盾した情報を送る Byzantine 故障を扱い、PBFT から HotStuff、およびデータ配布と順序決定を分離した DAG-BFT までを整理する。