分散データベースコンセンサスプロトコル:Raftを理論からプロダクションまでの6つのコアモジュール
分散コンセンサスは分散データベースの礎である——TiDB、CockroachDB、etcd、Consulといった著名なプロダクトはすべてコンセンサスプロトコルに依存してデータの一貫性を保証している。しかし、Paxosの論文は難解で、エンジニアリング実装はさらに困難を極める;スプリットブレイン問題は多くのチームを深夜の対応に追いやる;リーダー選挙の揺らぎはサービスの断続的な利用不可を引き起こす;ログレプリケーションの遅延はクロスリージョンデプロイを形骸化させる;線形化可能性の保証は開発者を悩ませ続ける。Raftプロトコルは「理解しやすさ」を設計の中核とし、コンセンサス問題をリーダー選挙、ログレプリケーション、安全性の3つのサブ問題に分解し、2026年の分散データベースにおけるデファクトスタンダードなコンセンサスプロトコルとなっている。
コア概念一覧
| 概念 | 説明 |
|---|---|
| Raft | 理解しやすい分散コンセンサスプロトコル。コンセンサスを選挙・レプリケーション・安全性の3つのサブ問題に分解 |
| リーダー選挙 | Followerがタイムアウト後にCandidateに遷移し選挙を開始、多数票を獲得してLeaderになる |
| ログレプリケーション | Leaderがクライアントリクエストをログエントリとして全Followerに複製 |
| ハートビートタイムアウト | Leaderが定期的にハートビートを送信、Followerがタイムアウトすると選挙をトリガー |
| 任期Term | Raftの論理クロック。選挙ごとに増分され、古い情報の検出に使用 |
| コミットインデックス | 過半数のノードで確認されたログインデックス。このインデックス以前のログはコミット済みとみなされる |
| スナップショット | コミット済みログを状態スナップショットに圧縮し、ログの無限増長を防止 |
| メンバーシップ変更 | クラスタノードの動的な追加・削除。変更中に2つのLeaderが発生しないことを保証 |
| 線形化可能性 | 読み取り操作が最新の書き込み結果を観測。RaftはReadIndexまたはLease Readで実現 |
5つのコアチャレンジ
プロダクション環境でのRaftコンセンサスは「Leaderを選んでログを複製する」だけでは済まない。以下の5つのコアチャレンジに直面する:
1. リーダー選挙の安定性 — ネットワークジッタが頻繁な選挙を引き起こし、Leader切り替えの隙間にクラスタが利用不可になる。選挙ストームをどう防ぐか?適切なタイムアウトパラメータをどう設定するか?
2. ログレプリケーションの一貫性 — ネットワークパーティション後、ログが分岐する可能性がある。リカバリ後に競合ログをどう切り詰めるか?スローノードが全体のスループットに影響せずにどう追いつくか?
3. ネットワークパーティション処理 — マイノリティパーティションは選挙に失敗し続け、マジョリティパーティションは正常にサービスを提供。パーティションリカバリ後にどう安全にマージするか?
4. メンバーシップ変更の安全性 — ノードの追加・削除時に、2段階変更の間に設定切り替えウィンドウが存在すると、2つのLeaderが発生する可能性がある。安全なメンバーシップ変更をどう実装するか?
5. スナップショットとログ圧縮 — ログの無限増長がディスクとメモリを枯渇させる。いつスナップショットをトリガーするか?スナップショット転送が通常のレプリケーションに影響しないようにするには?
モジュール1:Raftステートマシンとリーダー選挙
Raftノードには3つの状態がある:Follower、Candidate、Leader。Followerはハートビートを受信しないとタイムアウトし、Candidateに遷移して選挙を開始する。
type NodeState int
const (
Follower NodeState = iota
Candidate
Leader
)
type RaftNode struct {
mu sync.Mutex
id int
state NodeState
currentTerm int
votedFor int
log []LogEntry
commitIndex int
lastApplied int
nextIndex map[int]int
matchIndex map[int]int
heartbeatCh chan struct{}
electionTimer *time.Timer
peers []string
}
func (rn *RaftNode) startElection() {
rn.mu.Lock()
rn.state = Candidate
rn.currentTerm++
rn.votedFor = rn.id
term := rn.currentTerm
lastLogIndex := len(rn.log) - 1
lastLogTerm := 0
if lastLogIndex >= 0 {
lastLogTerm = rn.log[lastLogIndex].Term
}
rn.mu.Unlock()
votesReceived := 1
voteCh := make(chan bool, len(rn.peers))
for i, peer := range rn.peers {
if i == rn.id {
continue
}
go func(peerAddr string) {
resp := rn.requestVote(peerAddr, &RequestVoteArgs{
Term: term,
CandidateID: rn.id,
LastLogIndex: lastLogIndex,
LastLogTerm: lastLogTerm,
})
voteCh <- resp.VoteGranted
}(peer)
}
for i := 0; i < len(rn.peers)-1; i++ {
if <-voteCh {
votesReceived++
}
}
rn.mu.Lock()
defer rn.mu.Unlock()
if votesReceived > len(rn.peers)/2 && rn.currentTerm == term {
rn.state = Leader
rn.nextIndex = make(map[int]int)
rn.matchIndex = make(map[int]int)
for i := range rn.peers {
rn.nextIndex[i] = len(rn.log)
rn.matchIndex[i] = 0
}
go rn.heartbeatLoop()
}
}
重要ポイント:選挙タイムアウトはランダム化すべき(150-300ms)。全ノードが同時にタイムアウトして票が分散するのを防ぐ。Candidateは過半数の票を獲得して初めてLeaderになれる。
モジュール2:ログレプリケーションと一貫性
Leaderはクライアントリクエストを受信すると、操作をローカルログに追加し、全Followerに複製する。過半数が確認したらコミットされる。
type LogEntry struct {
Term int
Index int
Command interface{}
}
func (rn *RaftNode) appendEntries(args *AppendEntriesArgs) *AppendEntriesReply {
rn.mu.Lock()
defer rn.mu.Unlock()
reply := &AppendEntriesReply{Term: rn.currentTerm}
if args.Term < rn.currentTerm {
reply.Success = false
return reply
}
if args.PrevLogIndex >= 0 {
if args.PrevLogIndex >= len(rn.log) {
reply.Success = false
return reply
}
if rn.log[args.PrevLogIndex].Term != args.PrevLogTerm {
rn.log = rn.log[:args.PrevLogIndex]
reply.Success = false
return reply
}
}
for i, entry := range args.Entries {
idx := args.PrevLogIndex + 1 + i
if idx < len(rn.log) {
if rn.log[idx].Term != entry.Term {
rn.log = rn.log[:idx]
rn.log = append(rn.log, entry)
}
} else {
rn.log = append(rn.log, entry)
}
}
if args.LeaderCommit > rn.commitIndex {
lastNewIdx := args.PrevLogIndex + len(args.Entries)
if args.LeaderCommit < lastNewIdx {
rn.commitIndex = args.LeaderCommit
} else {
rn.commitIndex = lastNewIdx
}
}
rn.currentTerm = max(rn.currentTerm, args.Term)
reply.Term = rn.currentTerm
reply.Success = true
return reply
}
func (rn *RaftNode) replicateLog() {
rn.mu.Lock()
defer rn.mu.Unlock()
if rn.state != Leader {
return
}
for i, peer := range rn.peers {
if i == rn.id {
continue
}
prevIdx := rn.nextIndex[i] - 1
prevTerm := 0
if prevIdx >= 0 && prevIdx < len(rn.log) {
prevTerm = rn.log[prevIdx].Term
}
entries := rn.log[rn.nextIndex[i]:]
go func(peerAddr string, peerID int) {
resp := rn.sendAppendEntries(peerAddr, &AppendEntriesArgs{
Term: rn.currentTerm,
LeaderID: rn.id,
PrevLogIndex: prevIdx,
PrevLogTerm: prevTerm,
Entries: entries,
LeaderCommit: rn.commitIndex,
})
rn.mu.Lock()
if resp.Success {
rn.nextIndex[peerID] = prevIdx + len(entries) + 1
rn.matchIndex[peerID] = rn.nextIndex[peerID] - 1
} else {
rn.nextIndex[peerID] = max(1, rn.nextIndex[peerID]-1)
}
rn.mu.Unlock()
}(peer, i)
}
}
重要ポイント:ログ一貫性チェックはPrevLogIndexとPrevLogTermで実装。FollowerのログがLeaderと不一致の場合、Leaderは一致点が見つかるまでnextIndexを段階的に戻す。
モジュール3:ハートビートとタイムアウトメカニズム
ハートビートはRaft稼働の中核——Leaderはハートビートで権威を維持し、FollowerはハートビートでLeaderの存続を検出する。
func (rn *RaftNode) heartbeatLoop() {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
<-ticker.C
rn.mu.Lock()
if rn.state != Leader {
rn.mu.Unlock()
return
}
rn.mu.Unlock()
rn.replicateLog()
}
}
func (rn *RaftNode) electionTimeoutLoop() {
for {
rn.resetElectionTimer()
select {
case <-rn.electionTimer.C:
rn.mu.Lock()
if rn.state != Leader {
rn.mu.Unlock()
rn.startElection()
continue
}
rn.mu.Unlock()
case <-rn.heartbeatCh:
continue
}
}
}
func (rn *RaftNode) resetElectionTimer() {
timeout := time.Duration(150+rand.Intn(150)) * time.Millisecond
if rn.electionTimer != nil {
rn.electionTimer.Stop()
}
rn.electionTimer = time.NewTimer(timeout)
}
重要ポイント:ハートビート間隔は選挙タイムアウトより十分に小さくすべき(通常1/5〜1/10)。Leaderのハートビートが到着する前にFollowerがタイムアウトするのを防ぐ。クロスリージョンデプロイではRTTに基づいてタイムアウトを調整。
モジュール4:スナップショットとログ圧縮
ログの無限増長はリソースを枯渇させる。スナップショット機構はコミット済みログをステートマシンスナップショットに圧縮し、スナップショット以降のログのみを保持する。
type Snapshot struct {
LastIncludedIndex int
LastIncludedTerm int
Data []byte
}
func (rn *RaftNode) takeSnapshot() {
rn.mu.Lock()
defer rn.mu.Unlock()
if rn.commitIndex <= 0 {
return
}
snapshotIdx := rn.commitIndex
snapshotTerm := rn.log[snapshotIdx].Term
stateData := rn.stateMachine.Serialize()
snap := Snapshot{
LastIncludedIndex: snapshotIdx,
LastIncludedTerm: snapshotTerm,
Data: stateData,
}
rn.log = rn.log[snapshotIdx+1:]
rn.lastApplied = snapshotIdx
rn.persistSnapshot(snap)
}
func (rn *RaftNode) installSnapshot(args *InstallSnapshotArgs) *InstallSnapshotReply {
rn.mu.Lock()
defer rn.mu.Unlock()
reply := &InstallSnapshotReply{Term: rn.currentTerm}
if args.Term < rn.currentTerm {
return reply
}
if args.LastIncludedIndex > rn.commitIndex {
rn.log = rn.log[args.LastIncludedIndex-rn.lastApplied:]
rn.commitIndex = args.LastIncludedIndex
rn.lastApplied = args.LastIncludedIndex
}
rn.stateMachine.Deserialize(args.Data)
rn.currentTerm = max(rn.currentTerm, args.Term)
reply.Term = rn.currentTerm
return reply
}
重要ポイント:スナップショットサイズは10-100MBに制御すべき。スナップショット転送中は通常のログレプリケーションをブロックしてはならず、独立したRPCチャネルを使用する。
モジュール5:メンバーシップ変更と安全性
旧設定から新設定に直接切り替えるとスプリットブレインが発生する可能性がある。RaftはJoint Consensusで安全な変更を実現する。
type ConfigChange struct {
Type string // "add" or "remove"
NodeID int
Address string
}
func (rn *RaftNode) proposeConfigChange(change ConfigChange) error {
rn.mu.Lock()
if rn.state != Leader {
rn.mu.Unlock()
return fmt.Errorf("not leader")
}
rn.mu.Unlock()
entry := LogEntry{
Term: rn.currentTerm,
Command: change,
}
rn.mu.Lock()
rn.log = append(rn.log, entry)
rn.mu.Unlock()
rn.replicateLog()
return nil
}
func (rn *RaftNode) applyConfigChange(change ConfigChange) {
rn.mu.Lock()
defer rn.mu.Unlock()
switch change.Type {
case "add":
if !rn.containsPeer(change.NodeID) {
rn.peers = append(rn.peers, change.Address)
rn.nextIndex[change.NodeID] = len(rn.log)
rn.matchIndex[change.NodeID] = 0
}
case "remove":
newPeers := make([]string, 0)
for i, p := range rn.peers {
if i != change.NodeID {
newPeers = append(newPeers, p)
}
}
rn.peers = newPeers
delete(rn.nextIndex, change.NodeID)
delete(rn.matchIndex, change.NodeID)
}
}
重要ポイント:プロダクション環境では単一ノード変更(一度に1ノードのみ追加/削除)を推奨。Joint Consensusの複雑さを回避。ノード削除前にクラスタが過半数を維持していることを確認。
モジュール6:クライアント読み書きと線形化可能性
Raftの読み取り操作がLeaderを経由しない場合、古いデータが返される可能性がある。線形化可能読み取りにはReadIndexまたはLease Readが必要。
func (rn *RaftNode) linearizableRead() (interface{}, error) {
rn.mu.Lock()
if rn.state != Leader {
leaderID := rn.currentLeader
rn.mu.Unlock()
return nil, fmt.Errorf("not leader, redirect to %d", leaderID)
}
readIndex := rn.commitIndex
term := rn.currentTerm
rn.mu.Unlock()
confirmCh := make(chan bool, len(rn.peers))
confirmed := 1
for i, peer := range rn.peers {
if i == rn.id {
continue
}
go func(addr string) {
resp := rn.sendHeartbeat(addr, term)
confirmCh <- resp.Success
}(peer)
}
for i := 0; i < len(rn.peers)-1; i++ {
if <-confirmCh {
confirmed++
}
}
if confirmed <= len(rn.peers)/2 {
return nil, fmt.Errorf("lost leadership")
}
rn.mu.Lock()
for rn.lastApplied < readIndex {
rn.mu.Unlock()
time.Sleep(time.Millisecond)
rn.mu.Lock()
}
result := rn.stateMachine.Read()
rn.mu.Unlock()
return result, nil
}
func (rn *RaftNode) leaseRead() (interface{}, error) {
rn.mu.Lock()
defer rn.mu.Unlock()
if rn.state != Leader {
return nil, fmt.Errorf("not leader")
}
if time.Since(rn.leaseStart) > rn.leaseDuration {
return nil, fmt.Errorf("lease expired, fallback to ReadIndex")
}
return rn.stateMachine.Read(), nil
}
重要ポイント:ReadIndexは線形化可能性を保証するが、1回のRPC往復が必要。Lease Readは時計の仮定に依存し、パフォーマンスは良いがリスクがある。プロダクションではReadIndex + 1秒Lease最適化を推奨。
5つのよくある落とし穴
| # | 落とし穴 | 結果 | 正しいアプローチ |
|---|---|---|---|
| 1 | ❌ 選挙タイムアウトが固定 | 複数ノードが同時タイムアウトし票が分散、選挙失敗 | ✅ 選挙タイムアウトをランダム化(150-300ms)、票の分散を防止 |
| 2 | ❌ Followerが直接読み取りに応答 | 古いデータを返し、線形化可能性に違反 | ✅ 読み取りリクエストをLeaderに転送、ReadIndexで一貫性を保証 |
| 3 | ❌ メンバーシップ変更を一括実行 | 変更中に2つのLeaderが共存(スプリットブレイン) | ✅ 単一ノード変更またはJoint Consensus2段階変更 |
| 4 | ❌ スナップショット中にログレプリケーションをブロック | スナップショット転送が遅いとクラスタ書き込みが停止 | ✅ 独立したRPCチャネルでスナップショットを転送、AppendEntriesをブロックしない |
| 5 | ❌ currentTermとvotedForを永続化しない | ノード再起動後に重複投票、選挙安全性を破壊 | ✅ Term/Vote更新のたびに安定ストレージに同期的に永続化 |
10のエラートラブルシューティング
| # | エラー現象 | 可能な原因 | トラブルシューティング方法 |
|---|---|---|---|
| 1 | 頻繁なLeader切り替え | 選挙タイムアウトが短すぎる、またはネットワーク遅延が高い | 選挙タイムアウトを増加、ハートビート間隔<選挙タイムアウト/5を確保 |
| 2 | ログレプリケーション遅延が大きい | スローノードが全体のコミットを遅らせる | 非同期レプリケーションを有効化、maxInflightバッチサイズ制限を設定 |
| 3 | term mismatch |
ネットワークパーティション後の旧Leaderが書き込みを試行 | Leaderが最新のTermを保持しているか確認、パーティションリカバリ後に旧Leaderは自動降格 |
| 4 | スナップショット転送OOM | スナップショットが大きすぎて一度にメモリにロード | スナップショットをチャンクで転送、1チャンク1-4MB |
| 5 | メンバーシップ変更後クラスタ利用不可 | 変更後に過半数を喪失 | 変更後もノード数が過半数を満たすことを確認、奇数ノードでデプロイ |
| 6 | 古いデータの読み取り | FollowerがLeaderを経由せず直接読み取りに応答 | ReadIndexまたはLease Readを有効化、線形化可能読み取りを確保 |
| 7 | ノード再起動後にログ消失 | ログが安定ストレージに永続化されていない | ログ追加後に毎回fsync、WALで永続性を保証 |
| 8 | commitIndexが進まない |
マイノリティのノードがダウン、過半数の確認に到達できない | 存続ノード数が過半数を満たすか確認、必要に応じて故障ノードを削除 |
| 9 | 選挙ストーム | ハートビート間隔と選挙タイムアウトの比率が不適切 | ハートビート間隔を選挙タイムアウトの1/10に設定、PreVoteフェーズを追加 |
| 10 | スナップショット後の状態不一致 | スナップショット書き込みとログ切り詰めの原子性の問題 | スナップショット書き込みとログ切り詰めを同じトランザクションで完了 |
高度な最適化
1. PreVoteフェーズで選挙ストームを防止 — 正式なRequestVoteの前にPreVoteフェーズを追加。ログが十分に新しい場合のみ選挙を開始し、ネットワークパーティションリカバリ後の選挙ストームを防止。
2. バッチログレプリケーションでスループット向上 — Leaderが複数のログエントリを1回のAppendEntries RPCにマージし、ネットワーク往復を削減。etcdのデフォルトバッチ上限は1024エントリ。
3. 非同期スナップショット転送 — スナップショット転送に独立したストリーミングRPCを使用し、通常のログレプリケーションチャネルをブロックしない。レートリミットで帯域幅の占有を防止。
4. Learnerノードで変更リスクを低減 — 新ノードはまずLearnerとして参加し、ログの追いつきが完了してからVoterに変換。新ノードの参加によるコミットブロックを防止。
5. ReadIndexキャッシュ最適化 — Leaderはハートビート確認後に最新のReadIndexをキャッシュ。後続の読み取りリクエストはキャッシュ値を直接使用し、ハートビート確認頻度を削減。
比較分析:Raft vs Multi-Paxos vs EPaxos vs ZAB
| 次元 | Raft | Multi-Paxos | EPaxos | ZAB |
|---|---|---|---|---|
| 理解しやすさ | ✅ 明確さを目指して設計 | ❌ 論文が難解、実装が複雑 | ⚠️ 中程度、依存グラフに依存 | ⚠️ 中程度、Raftに類似 |
| Leader依存 | ✅ 強Leaderモデル | ✅ Leaderありだが最適化可能 | ❌ Leaderless、任意のレプリカが提案可能 | ✅ 強Leaderモデル |
| クロスリージョン遅延 | ❌ 書き込みにLeader確認が必要 | ⚠️ 最適化可能だが複雑 | ✅ Leaderless、最寄りレプリカに書き込み | ❌ 書き込みにLeader確認が必要 |
| ログ順序 | ✅ 強い順序付け、推論が容易 | ⚠️ 順序外を許可、実装が複雑 | ❌ 依存グラフで順序を決定 | ✅ 強い順序付け |
| メンバーシップ変更 | ✅ 単一ノード変更がシンプル | ❌ 実装が複雑 | ⚠️ 中程度 | ⚠️ 中程度 |
| エコシステム | ✅ etcd/Consul/TiKV | ⚠️ Chubby/Megastore | ❌ 限定的なエコシステム | ✅ ZooKeeper |
| パフォーマンス | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
選定ガイド:単一リージョンデプロイ → Raft(成熟したエコシステム、理解しやすい);クロスリージョンアクティブアクティブ → EPaxos(最寄り書き込みで低遅延);ZooKeeperエコシステム → ZAB;Paxosはレガシー制約がある場合のみ。
まとめと展望
Raftプロトコルは2026年、分散データベースコンセンサスのデファクトスタンダードとなっている——強Leaderモデルはログ管理を簡素化し、ランダム化された選挙タイムアウトは票の分散を防止し、スナップショット機構はログ膨張を解決する。しかしプロダクション導入には5つのコアチャレンジを乗り越える必要がある:リーダー選挙の安定性、ログレプリケーションの一貫性、ネットワークパーティション処理、メンバーシップ変更の安全性、スナップショットとログ圧縮。本記事で紹介した6つのコアモジュール——ステートマシンと選挙、ログレプリケーション、ハートビートタイムアウト、スナップショット圧縮、メンバーシップ変更、線形化可能読み取り——はRaftを理論からプロダクションまでカバーする完全なチェーンを構成する。覚えておこう:コンセンサスプロトコルはLeaderを選ぶだけではない——選挙の安全性から線形化可能性までの完全なエンジニアリングシステムである。
オンラインツール推奨
- JSONフォーマッター — Raftログと設定JSONをフォーマットし、クラスタ状態を素早くデバッグ
- ハッシュ計算ツール — スナップショットとログのチェックサムフィンガープリントを生成し、データの完全性を保証
- cURL→コード変換ツール — etcd/Consul APIのcURLコマンドをGoクライアントコードに変換
- Base64エンコーダー — Raft RPC内のバイナリスナップショットデータをエンコードして転送
ブラウザローカルツールを無料で試す →