分布式一致性与 Raft 协议

2022-02-11T10:00:00+08:00 | 33分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

学习目标

读完本章后,你将能够:

  1. 说出分布式一致性的定义,区分强一致、弱一致与最终一致三种级别,并能判断常见系统属于哪一种。
  2. 用 CAP 定理分析一个分布式系统属于 CP 还是 AP,并解释 BASE 理论如何在工程实践中平衡 CAP 的取舍。
  3. 画出 Raft 的三种节点状态(Follower / Candidate / Leader)转换图,说明超时选举的触发条件。
  4. 描述 Raft 选主与日志复制的完整流程:从 Follower 超时变 Candidate,到获多数票变 Leader,再到客户端写入、日志 commit 并 apply。
  5. 说出 Raft 的三项安全性保证(选举限制、提交限制、Leader 完整性),并解释多数派如何防止脑裂。

前置知识: 了解分布式系统的基本概念(多节点、网络通信、故障),熟悉 Go 语言基本语法,了解 HTTP/RPC 通信基础。

动手做 3 件事:

  • 用 Go 搭建一个三节点 Raft 集群(使用 hashicorp/raft 库),观察 Leader 选举日志。
  • 手动 kill 掉 Leader 节点,观察剩余节点如何选出新 Leader。
  • 在 Leader 上写入一条数据,验证 Follower 节点是否同步一致。

一、分布式一致性概述

1.1 用生活类比先建立直觉

想象三个会计在不同城市同时为同一家公司记账。如果 A 在北京记了一笔"收入 1000 元",B 在上海记了一笔"支出 500 元",C 在广州记了一笔"收入 300 元",那么一天结束时,三个人手里的账本必须完全一样——这就是"一致性"。

但现实中网络可能延迟、节点可能宕机。如果 A 写入后还没来得及通知 B 和 C 就宕机了,那 B 和 C 手里的账本就是旧的。如何保证多个节点在同一时刻看到的数据是一致的?这就是分布式一致性要解决的核心问题。

graph TD
    A[分布式一致性] --> B[强一致性
Strong Consistency] A --> C[弱一致性
Weak Consistency] A --> D[最终一致性
Eventual Consistency] B --> B1[读到的数据总是最新
如 Raft / Paxos] C --> C1[读到的可能是旧数据
允许短暂不一致] D --> D1[一段时间后最终一致
如 DNS / Cassandra]

桥接: “三个会计对账"对应分布式系统中多个节点对同一数据达成一致。强一致就像每次记账后必须等三个人都确认才算完成;弱一致允许有的人暂时看到旧账本;最终一致则是"虽然现在不一致,但过一会儿就一致了”。

1.2 工程要点

知识点 1:分布式一致性是什么 & 强一致 / 弱一致 / 最终一致

分布式一致性是指:多个节点对同一数据达成一致——任意时刻从任意节点读取,得到的结果都是相同的。

三种一致性级别对比:

一致性级别定义读到的数据适用场景典型系统
强一致性写操作完成后,后续任何读都能读到最新值总是最新金融交易、库存扣减etcd、ZooKeeper、Raft
弱一致性写操作完成后,不保证后续读能读到最新值可能是旧值社交媒体点赞、日志收集大多数 NoSQL
最终一致性弱一致性的特例:保证最终会一致,但不保证何时短暂不一致后一致DNS、CDN、购物车Cassandra、DynamoDB

⚠️ 新手必踩的坑: “最终一致性"不是说数据会自动变一致,而是说在没有新的写入的情况下,经过足够长的时间(通常是毫秒到秒级),所有副本最终会收敛到同一个值。如果你在读数据时没有等待同步完成,就可能读到旧值。


二、CAP 定理与 BASE 理论

2.1 用生活类比先建立直觉

想象一家连锁银行有三个网点(三个节点),它们之间通过电话线(网络)同步账本:

  • 一致性(C):你去任何网点存钱,存完后在所有网点查到的余额都一样。
  • 可用性(A):无论什么时候去哪个网点,都能办理业务(不会关门)。
  • 分区容错性(P):电话线断了(网络分区),网点仍然能营业。

问题来了:如果电话线断了(P 发生),网点 A 收到一笔存款,但无法通知网点 B 和 C。此时要么:

  • 选择 C:网点 A 拒绝这笔存款(不可用),等电话线修好再说——这就是 CP
  • 选择 A:网点 A 先记下这笔存款(可能不一致),等电话线修好再同步——这就是 AP

你不能同时拥有三个。这就是 CAP 定理。

graph TD
    A[CAP定理] --> B[一致性 C
所有节点同一时刻数据一致] A --> C[可用性 A
每次请求都能收到响应] A --> D[分区容错 P
网络分区时系统仍能运行] D --> E[分布式系统必选P] B --> F[CP:选择一致性
如 etcd / ZooKeeper] C --> G[AP:选择可用性
如 Eureka / Cassandra]

桥接: “电话线断了"对应网络分区——在分布式系统中,网络分区是不可避免的,所以 P 必选。剩下的选择就是在 C 和 A 之间权衡:CP 系统宁可暂时拒绝服务也要保证数据一致;AP 系统宁可暂时数据不一致也要保证服务可用。

2.2 工程要点

知识点 2:CAP 定理 & BASE 理论

CAP 定理(Brewer 定理)指出:一个分布式系统最多同时满足以下三个属性中的两个:

属性含义说明
C(Consistency)一致性所有节点在同一时刻看到相同的数据
A(Availability)可用性每个请求都能收到非错误响应(不保证是最新数据)
P(Partition Tolerance)分区容错性网络分区时系统仍能运行

由于网络分区在分布式系统中不可避免,P 是必选的。因此实际选择是:

组合含义行为典型系统
CP一致性 + 分区容错分区时拒绝写入,保证一致etcd、ZooKeeper、Redis Sentinel
AP可用性 + 分区容错分区时继续服务,允许暂时不一致Eureka、Cassandra、DynamoDB

BASE 理论是 CAP 在工程实践中的妥协方案,是 AP 的延伸:

字母全称含义
BABasically Available基本可用:允许响应时间增加或功能降级
SSoft State软状态:允许数据存在中间状态
EEventually Consistent最终一致:保证数据最终达到一致

⚠️ 新手必踩的坑: 不要把"一致性"和"事务的 ACID 中的 C"混淆。ACID 的 C 是指事务前后数据约束不变(如外键约束、唯一约束);CAP 的 C 是指多个副本之间数据一致。两者是完全不同的概念。


三、Raft 节点状态与选主流程

3.1 用生活类比先建立直觉

想象一个班级要选班长:

  • 平时大家都安静地做自己的事(Follower,跟随者)。
  • 如果一直没收到班长的心跳消息(班长"失联"了),某个积极的同学就会站起来说:“我来当班长!"(变成 Candidate,候选人)。
  • 候选人发起投票,如果获得超过半数同学的投票,就成为 Leader(领导者)。
  • 当上班长后,定期给大家发"我还活着"的消息(心跳),防止其他人发起选举。

如果某个同学发现有一个比自己更高届的班长(term 更大),就乖乖回去当 Follower。

graph TD
    A[Follower
跟随者] -->| 选举超时 | B[Candidate
候选人] B -->| 获得多数票 | C[Leader
领导者] B -->| 收到更高term的Leader | A B -->| 选举超时重试 | B C -->| 发现更高term | A C -->| 定期发送心跳 | A

桥接: “班长选举"对应 Raft 的选主流程。Follower 是默认状态,等待 Leader 心跳;超时后变 Candidate 发起选举;获多数票变 Leader。关键概念是 term(任期)——每发起一次选举 term 加 1,类似"第几届班长”。term 是一个全局递增的整数,保证了"越新的 Leader 越有权”。

3.2 工程要点

知识点 3 & 4:Raft 三种节点状态 & 选主流程

Raft 中每个节点在任意时刻处于三种状态之一:

状态职责触发转换的条件
Follower被动接收 Leader 的请求,响应投票和日志复制初始状态;收到更高 term
Candidate主动发起选举,争取选票Follower 选举超时
Leader处理客户端请求,复制日志到所有 FollowerCandidate 获得多数票

选主流程的详细步骤:

sequenceDiagram
    participant F1 as Follower-1
    participant F2 as Follower-2
    participant F3 as Follower-3
    F1->>F1: 选举超时 变为Candidate
    F1->>F1: term自增 投票给自己
    F1->>F2: RequestVote term=2
    F1->>F3: RequestVote term=2
    F2-->>F1: 同意投票
    F3-->>F1: 同意投票
    F1->>F1: 获3票 超过半数 变为Leader
    F1->>F2: 心跳 AppendEntries
    F1->>F3: 心跳 AppendEntries
package raft

import (
    "sync"
    "time"
)

// 步骤1:定义节点角色类型
type Role int

const (
    Follower Role = iota
    Candidate
    Leader
)

// 步骤2:定义日志条目
type LogEntry struct {
    Term    int
    Index   int
    Command interface{}
}

// 步骤3:定义Raft节点结构
type RaftNode struct {
    mu            sync.Mutex
    id            string
    role          Role
    currentTerm   int
    votedFor      string
    log           []LogEntry
    commitIndex   int
    lastApplied   int
    peers         []string
    lastHeartbeat time.Time
}

// 步骤4:Follower选举超时后启动选举
func (n *RaftNode) startElection() {
    n.mu.Lock()
    n.role = Candidate
    n.currentTerm++
    n.votedFor = n.id
    term := n.currentTerm
    lastLogIndex := len(n.log) - 1
    lastLogTerm := 0
    if lastLogIndex >= 0 {
        lastLogTerm = n.log[lastLogIndex].Term
    }
    n.mu.Unlock()

    // 步骤5:并行向所有节点发送RequestVote
    votes := 1 // 自己的一票
    var voteMu sync.Mutex
    var wg sync.WaitGroup

    for _, peer := range n.peers {
        if peer == n.id {
            continue
        }
        wg.Add(1)
        go func(p string) {
            defer wg.Done()
            resp := n.sendRequestVote(p, term, lastLogIndex, lastLogTerm)
            voteMu.Lock()
            if resp.VoteGranted {
                votes++
                // 步骤6:获得多数票后成为Leader
                if votes > len(n.peers)/2 && n.role == Candidate {
                    n.becomeLeader()
                }
            }
            voteMu.Unlock()
        }(peer)
    }
    wg.Wait()
}

// 步骤7:成为Leader后开始发送心跳
func (n *RaftNode) becomeLeader() {
    n.mu.Lock()
    defer n.mu.Unlock()
    n.role = Leader
    go n.sendHeartbeats()
}

⚠️ 新手必踩的坑: 选举超时时间必须设置为随机值(通常 150-300ms)。如果所有节点的超时时间相同,它们会同时变成 Candidate 同时发起选举,导致谁也拿不到多数票,选举失败。Raft 通过随机化超时时间来打破这种"活锁”。


四、Raft 日志复制

4.1 用生活类比先建立直觉

想象一条工厂流水线:

  • 车间主任(Leader)接到一份订单(客户端写请求)。
  • 主任先在自己的工单本上记一笔(追加本地日志)。
  • 然后把工单复印件分发给所有工人(Follower),让他们也记一笔。
  • 超过半数的工人确认"记好了”,主任就在自己的工单上盖"已完成"章(commit)。
  • 主任回复客户"订单已完成"。
  • 最后通知所有工人"可以执行这份工单了"(apply 到状态机)。

关键在于:只要超过半数的节点确认了,这条日志就算"安全"了,即使少数节点宕机也不会丢失。

flowchart TD
    A[客户端发送写请求] --> B[Leader追加日志到本地]
    B --> C[Leader并行发送AppendEntries]
    C --> D[各Follower追加日志]
    D --> E{多数Follower确认?}
    E -->| 是 | F[Leader标记日志为committed]
    E -->| 否 | G[等待重试]
    F --> H[Leader回复客户端成功]
    F --> I[Leader通过心跳通知Follower commit]
    I --> J[Follower apply日志到状态机]
    G --> C

桥接: “流水线确认"对应 Raft 的日志复制。Leader 先自己记(本地追加),再让 Follower 记(AppendEntries),多数确认后 commit(盖"已完成"章)。commit 的日志是安全的——即使 Leader 宕机,新选出的 Leader 也一定包含已 commit 的日志。

4.2 工程要点

知识点 5:Raft 日志复制流程

日志复制的完整步骤:

  1. Leader 收到客户端写请求。
  2. Leader 将日志条目追加到本地日志(暂未 commit)。
  3. Leader 并行发送 AppendEntries RPC 给所有 Follower。
  4. Follower 收到后,检查 prevLogIndexprevLogTerm 是否匹配。
  5. 匹配则追加日志,回复 success=true;不匹配则回复 success=false
  6. Leader 收到多数 Follower 的 success=true 后,将该日志标记为 committed
  7. Leader 回复客户端"成功”。
  8. Leader 在下一次心跳中将 commitIndex 通知 Follower。
  9. Follower 收到后,将日志 apply 到状态机。
package raft

import "time"

// 步骤1:定义AppendEntries请求
type AppendEntriesRequest struct {
    Term         int        // Leader的当前term
    LeaderId     string     // Leader的ID
    PrevLogIndex int        // 紧接新日志条目之前的日志索引
    PrevLogTerm  int        // PrevLogIndex对应的term
    Entries      []LogEntry // 待复制的日志条目
    LeaderCommit int        // Leader的commitIndex
}

// 步骤2:定义AppendEntries响应
type AppendEntriesResponse struct {
    Term    int  // 响应者的当前term
    Success bool // 是否追加成功
}

// 步骤3:Follower处理AppendEntries请求
func (n *RaftNode) HandleAppendEntries(req *AppendEntriesRequest) *AppendEntriesResponse {
    n.mu.Lock()
    defer n.mu.Unlock()

    // 步骤4:term过小,拒绝(说明发请求的不是合法Leader)
    if req.Term < n.currentTerm {
        return &AppendEntriesResponse{Term: n.currentTerm, Success: false}
    }

    // 步骤5:重置选举超时计时器(收到合法Leader消息)
    n.lastHeartbeat = time.Now()

    // 步骤6:如果term更大,更新自己的term
    if req.Term > n.currentTerm {
        n.currentTerm = req.Term
        n.votedFor = ""
    }
    n.role = Follower

    // 步骤7:检查prevLogIndex和prevLogTerm是否匹配
    if req.PrevLogIndex >= 0 {
        if req.PrevLogIndex >= len(n.log) {
            return &AppendEntriesResponse{Term: n.currentTerm, Success: false}
        }
        if n.log[req.PrevLogIndex].Term != req.PrevLogTerm {
            return &AppendEntriesResponse{Term: n.currentTerm, Success: false}
        }
    }

    // 步骤8:追加新日志条目(处理冲突和追加)
    for i, entry := range req.Entries {
        idx := req.PrevLogIndex + 1 + i
        if idx < len(n.log) {
            // 步骤9:索引已存在,检查term是否冲突
            if n.log[idx].Term != entry.Term {
                // 冲突:删除从这里开始的所有日志,追加新条目
                n.log = n.log[:idx]
                n.log = append(n.log, entry)
            }
        } else {
            // 步骤10:索引不存在,直接追加
            n.log = append(n.log, entry)
        }
    }

    // 步骤11:更新commitIndex
    if req.LeaderCommit > n.commitIndex {
        newCommit := req.LeaderCommit
        if lastEntry := len(n.log) - 1; newCommit > lastEntry {
            newCommit = lastEntry
        }
        n.commitIndex = newCommit
        n.applyLogs()
    }

    return &AppendEntriesResponse{Term: n.currentTerm, Success: true}
}

// 步骤12:将已commit的日志应用到状态机
func (n *RaftNode) applyLogs() {
    for n.lastApplied < n.commitIndex {
        n.lastApplied++
        // 将 n.log[n.lastApplied].Command 应用到状态机
    }
}

⚠️ 新手必踩的坑: 日志冲突处理是 Raft 最容易出错的地方。当 Follower 在某个 index 上的 term 与 Leader 不一致时,必须删除该 index 及之后的所有日志,然后用 Leader 的日志覆盖。不要只删除冲突的那一条——后面的日志也全部作废,因为 Leader 的日志才是权威的。


五、Raft 安全性保证与脑裂问题

5.1 用生活类比先建立直觉

想象班级选班长的规则:

  • 选举限制:只有"成绩最好"(日志最新)的同学才有资格当班长。如果 A 的作业进度落后于 B,B 不会给 A 投票。这保证了当上班长的人一定拥有最完整的作业记录。
  • 提交限制:班长只能在"自己任期"内盖"已完成"章。前任班长盖了一半的章,新班长不能直接帮他盖完——必须重新确认。
  • Leader 完整性:一旦某份作业被盖了"已完成"章(commit),之后所有班长手里一定都有这份作业。

脑裂问题:如果班级被一道墙隔成两半(网络分区),墙两边可能各选出一个班长。怎么办?Raft 的答案是多数派——5 个人的班级,被隔成 2+3 两组:3 个人那组能选出班长(超过半数),2 个人那组选不出(不到半数),所以只有一边能正常工作。

graph TD
    A[原集群 5节点
Leader + 4 Follower] --> B[网络分区] B --> C[分区A 2节点
保留旧Leader
无法获多数票
无法commit] B --> D[分区B 3节点
选出新Leader
获多数票
可以commit] C --> E[分区恢复后
旧Leader发现更高term
降级为Follower] D --> E

桥接: “成绩最好才能当班长"对应选举限制——Raft 通过比较 lastLogIndexlastLogTerm 来判断谁的日志更新。“多数派防脑裂"对应 Raft 的核心设计:任何决策都需要超过半数节点同意,所以网络分区时少数派那组无法 commit 数据。

5.2 工程要点

知识点 6 & 8:安全性保证 & 脑裂问题

Raft 通过以下三个安全性规则保证正确性:

安全性保证规则作用
选举限制候选人的日志必须至少和投票者一样新(比较 lastLogTerm 和 lastLogIndex)保证日志最新的节点才能当选 Leader
提交限制Leader 只能提交当前 term 的日志,不能直接提交旧 term 的日志防止已复制但未提交的旧 term 日志被错误提交
Leader 完整性如果一条日志被 commit,那么后续所有 Leader 的日志中都包含这条日志保证已提交的数据不会丢失

选举限制的代码实现:

package raft

import "time"

// 步骤1:定义RequestVote请求
type RequestVoteRequest struct {
    Term         int    // 候选人的term
    CandidateId  string // 候选人ID
    LastLogIndex int    // 候选人最后一条日志的索引
    LastLogTerm  int    // 候选人最后一条日志的term
}

// 步骤2:定义RequestVote响应
type RequestVoteResponse struct {
    Term        int  // 响应者的当前term
    VoteGranted bool // 是否同意投票
}

// 步骤3:处理RequestVote请求
func (n *RaftNode) HandleRequestVote(req *RequestVoteRequest) *RequestVoteResponse {
    n.mu.Lock()
    defer n.mu.Unlock()

    // 步骤4:请求的term小于当前term,直接拒绝
    if req.Term < n.currentTerm {
        return &RequestVoteResponse{Term: n.currentTerm, VoteGranted: false}
    }

    // 步骤5:请求的term大于当前term,更新term并转为Follower
    if req.Term > n.currentTerm {
        n.currentTerm = req.Term
        n.role = Follower
        n.votedFor = ""
    }

    // 步骤6:检查是否可以投票
    // 条件1:当前term还没有投过票,或者已经投给了该候选人
    canVote := n.votedFor == "" || n.votedFor == req.CandidateId

    // 步骤7:检查候选人的日志是否至少和自己一样新
    logUpToDate := isLogUpToDate(n.log, req.LastLogIndex, req.LastLogTerm)

    if canVote && logUpToDate {
        n.votedFor = req.CandidateId
        n.lastHeartbeat = time.Now()
        return &RequestVoteResponse{Term: n.currentTerm, VoteGranted: true}
    }

    return &RequestVoteResponse{Term: n.currentTerm, VoteGranted: false}
}

// 步骤8:判断候选人的日志是否至少和自己一样新
func isLogUpToDate(localLog []LogEntry, candidateLastLogIndex int, candidateLastLogTerm int) bool {
    localLastIndex := len(localLog) - 1
    localLastTerm := 0
    if localLastIndex >= 0 {
        localLastTerm = localLog[localLastIndex].Term
    }

    // 步骤9:先比较最后一条日志的term,term大的更新
    if candidateLastLogTerm != localLastTerm {
        return candidateLastLogTerm > localLastTerm
    }
    // 步骤10:term相同则比较index,index大的更新
    return candidateLastLogIndex >= localLastIndex
}

脑裂问题分析:

脑裂是指网络分区导致集群中出现两个 Leader 的情况。Raft 通过多数派机制解决此问题:

分区情况节点数能否选出 Leader能否 commit行为
多数派分区大于等于 n/2+1正常服务
少数派分区小于 n/2+1不能不能拒绝服务(保证一致性)

⚠️ 新手必踩的坑: 在 5 节点集群中,如果网络分区为 2+3,少数派那 2 个节点上的旧 Leader 可能还会接受客户端请求,但它无法 commit(因为需要至少 3 个节点确认)。客户端会一直收不到成功响应,直到网络恢复或连接到多数派分区的新 Leader。务必在客户端实现超时重试机制。


六、Raft vs Paxos

6.1 用生活类比先建立直觉

想象两种开会做决策的方式:

  • Paxos 方式:每个人都可以发起提案,大家通过多轮消息交换达成共识。理论上非常通用,但流程复杂,开一次会要发很多消息,大家容易搞混。
  • Raft 方式:先选一个主持人(Leader),所有提案都交给主持人,主持人统一收集意见并宣布结果。流程清晰,容易理解,但前提是主持人必须存在。

Raft 的设计哲学就是"为了可理解性而设计”——把一致性问题分解为三个相对独立的子问题(选主、日志复制、安全),每个子问题都可以单独理解和实现。

桥接: Paxos 是理论基础,Raft 是工程优化。Paxos 证明了一致性是可实现的,但实现起来太复杂;Raft 用"强 Leader"模型简化了流程,牺牲了一点通用性,换来了巨大的可理解性和工程可行性。

6.2 工程要点

知识点 7:Raft vs Paxos

对比维度RaftPaxos
设计目标可理解性优先通用性和理论完备性
结构分解选主 + 日志复制 + 安全,三个子模块单一协议,不显式分解
Leader 角色强 Leader 模型,所有请求经过 Leader可选 Leader(Multi-Paxos),不是必须
日志管理日志连续,只能追加,无空洞允许日志空洞,更灵活但更复杂
工程实现etcd、Consul、TiKV、CockroachDBChubby(Google)、Spanner
学习曲线较低,论文配有详细示例较高,论文抽象,需要深入理解
适用场景需要强一致性的分布式存储 / 协调服务理论研究、需要极端通用性的场景

⚠️ 新手必踩的坑: Raft 不是 Paxos 的"替代品”,而是 Paxos 的"工程简化版"。Raft 的强 Leader 模型在 Leader 切换时会有短暂不可用(选主期间无法处理写请求),而 Paxos 理论上可以做到任何时候都能达成共识。在选择时,如果你的系统需要极端的可用性,可能需要考虑 Multi-Paxos;如果追求可维护性和可理解性,Raft 是更好的选择。


七、分布式与集群的区别

7.1 用生活类比先建立直觉

类比:火锅店生意太好,老板做了两件事。其一,又雇了两个一模一样的厨师,三个人都做同样的锅底、同样的菜——这叫集群,目的是"多几个人一起扛客流、某个厨师请假也不停业"。其二,把"切菜、熬汤、装盘、上菜"拆给不同的人,每个人只干自己那段——这叫分布式,目的是"一个人干不完,把一件事拆开并行做"。

桥接:集群是"同样的活多个人一起干",提升的是容量与可用性;分布式是"一件大事拆成小任务分给不同人",突破的是单机算力 / 存储上限。现实中两者常叠加:一个分布式系统里,每一个角色往往又是一个集群。

graph TD
    A[单机系统] --> B[集群 Cluster
多节点提供相同服务] A --> C[分布式 Distributed
一个任务拆成子任务分给不同节点] B --> B1[目标: 高可用 + 横向扩容] C --> C1[目标: 突破单机算力/存储上限] B --> D[典型: Nginx 多实例、Redis 主从] C --> E[典型: 微服务、Hadoop、MapReduce] B -. 常作为 .-> C2[分布式系统中的每个角色
本身又是一个集群]

7.2 工程要点

维度集群(Cluster)分布式(Distributed)
核心思想多节点做相同的事一个系统拆成不同的子系统/模块
目标高可用、负载均衡、扩容突破单机限制、解耦、并行计算
数据通常共享/复制同一份数据各节点持有不同分区的数据
失败影响挂一个,其他照常服务某一模块挂了,整体链路受影响
例子多台 Tomcat 扛流量、MySQL 主从微服务架构、HDFS(存算分离)

⚠️ 新手必踩的坑: 面试别把两者对立。一个"分布式系统"往往由多个"集群"组成——比如微服务里订单服务是一个集群、库存服务又是一个集群,它们合起来才是分布式系统。考点总结:集群重"副本与高可用",分布式重"拆分与协作";二者目标不同但常常共存。


八、分布式服务接口的幂等性设计

8.1 用生活类比先建立直觉

类比:你在自助售货机连按两次"买可乐",机器不该吐出两瓶——第一次扣款成功后,第二次应该被识别为"重复操作"而直接忽略。接口的幂等性就是:同一个请求无论发 1 次还是 10 次,系统产生的最终效果都一样。

为什么分布式里幂等如此重要?因为网络会超时、客户端会重试、消息队列会"至少一次"投递——同一条请求可能真的被处理多次。如果扣款、下单这类写操作不幂等,重试一次就可能重复扣钱。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: 下单请求 (带 requestId=abc)
    S->>S: 查防重表: abc 已处理?
    S-->>C: 首次: 处理 + 返回结果
    C->>S: 网络超时 客户端重试
    C->>S: 下单请求 (requestId=abc)
    S->>S: 查防重表: abc 已处理!
    S-->>C: 直接返回首次的结果(不重复处理)

8.2 工程要点:四种主流方案

方案 1:Token 机制(防重提交)。下单前先向服务端申请一个一次性 token,提交时带上;服务端用「token 是否存在」做原子校验,用过即删。

// 步骤1:用唯一 token 保证同一笔提交只处理一次
func SubmitOrder(ctx context.Context, token, req string) error {
    // 步骤2:SETNX 原子操作——token 不存在才插入成功(返回1)
    ok, _ := rdb.SetNX(ctx, "order:token:"+token, 1, time.Minute).Result()
    if !ok {
        return errors.New("重复提交或 token 已失效") // 已处理过,直接拒绝
    }
    // 步骤3:正常业务处理(扣库存、创建订单…)
    return doCreateOrder(req)
}

方案 2:数据库唯一索引。对"订单号"“业务唯一键"建唯一索引,重复插入直接报 DuplicateKey,捕获异常即视为重复。

方案 3:状态机约束。订单状态按 待支付 → 已支付 → 已发货 单向流转,重复支付时因状态已变更而拒绝(用 UPDATE ... WHERE status='待支付' 受影响行数为 0 判断)。

方案 4:防重表。单独建一张 processed_log(request_id PK, ...),处理前先 INSERT,靠主键冲突拦截重复;或配合 SELECT ... FOR UPDATE 加行锁。

⚠️ 新手必踩的坑: 幂等校验和业务处理必须放在同一个事务/原子操作里。先查"没处理过"再处理,中间若没加锁,并发两个请求都会查到"没处理过"然后都处理了——经典竞态。用唯一索引或 Redis 原子 SETNX 才能杜绝。考点总结:幂等的核心是为"同一请求"找一个全局唯一标识并做原子去重;四种方案按"是否需要提前交互、是否依赖数据库"取舍。


九、分布式系统中的接口调用顺序性

9.1 用生活类比先建立直觉

类比:客服中心把客户的三个诉求(报案→核实→理赔)放进一个"按编号排队的工单池”,同一个客户的工单永远交给同一个坐席按顺序处理,绝不会让理赔跑在报案前面。分布式里保证"顺序性",就是要让同一业务的多条消息按发生次序被处理。

9.2 工程要点:三种手段

手段 1:序号 / 序列号。每条消息带 sequence业务 key,消费者维护"已处理的最大序号",只处理 seq == last+1 的,小于的丢弃(重复),大于的暂存等待(补洞)。

手段 2:消息队列单分区 / 单队列。Kafka 的同一 partition、RabbitMQ 的同一队列天然 FIFO,把需要保序的业务 key 路由到同一分区即可(生产者按 key 取模选分区)。

手段 3:一致性哈希。对 业务 key 做一致性哈希,映射到固定节点/队列,保证同一 key 的所有请求落到同一处理者,从而按接收顺序处理。

flowchart LR
    A[同一业务key的多条消息] --> B{一致性哈希
或 key 取模} B --> C[固定分区/固定处理节点] C --> D[单分区内 FIFO] D --> E[按 sequence 校验] E --> F[顺序消费]

⚠️ 新手必踩的坑: 顺序性常以"吞吐下降"为代价——单分区意味着无法并行。实际做法是"局部顺序":只在需要保序的 key 维度串行,不同 key 之间仍可并行。考点总结:顺序性靠"同一 key 落到同一处理通道 + 序号校验"实现;全局顺序代价高,应做到 key 级别有序即可。


十、ZooKeeper 的常见使用场景

10.1 用生活类比先建立直觉

类比:ZK 像一个"公司公告栏 + 传达室"。① 公司把规章制度贴在公告栏,全员随时来看——这是配置中心;② 传达室登记了每个人的工位号,外人问"张三在哪"一查便知——这是命名服务;③ 只有抢到"红章"的人才能进金库——这是分布式锁;④ 部门要选负责人,大家投票,公告栏只承认唯一当选者——这是选主(Master Election)

10.2 工程要点

场景利用的 ZK 特性说明
配置中心节点数据 + Watch 监听配置写进 ZNode,客户端 watch,变更即时推送
命名服务层级 ZNode 路径用路径做服务注册与发现(如 /services/order/10.0.0.1:8080
分布式锁临时有序节点 + 最小序号获锁创建 /lock/seq-0001 等临时节点,序号最小者持锁
选主临时节点 + 唯一性谁成功创建 /master 临时节点谁就是 Master,宕机节点消失触发重新选主

ZK 的核心是**临时节点(EPHEMERAL)**和 Watch 机制:临时节点随会话断开自动删除,天然适合做"存活探测 + 锁释放 + 选主失效"。

⚠️ 新手必踩的坑: ZK 的 Watch 是一次性的——触发一次后需重新注册,否则会漏掉后续变更。写监听逻辑时务必"收到通知→处理→再次注册 watch"。考点总结:ZK 四大场景本质都建立在"临时节点自动失效 + Watch 主动通知"之上,理解这两点即可推导所有用法。


十一、分布式 Session 方案

11.1 用生活类比先建立直觉

类比:你办了张连锁健身房会员卡。方案 A:每次去哪家分店,前台都当场查总部数据库确认你身份——集中存储(Redis);方案 B:系统记住"你上次去的是 3 号店",下次总把你路由到 3 号店——粘性会话;方案 C:会员卡本身印了你的全部信息和防伪签名,任何分店刷一下卡就能验真,无需查总部——JWT(无状态令牌)

11.2 工程要点

方案原理优点缺点
Redis 集中存储Session 存入 Redis,所有节点共享平滑扩缩容、无状态化依赖 Redis 可用性
粘性会话(Nginx ip_hash)同一 IP 总落到同一节点实现简单、零额外存储节点宕机 Session 丢失、负载不均
JWT 令牌用户信息签名进 token,客户端携带服务端无状态、易跨域令牌难即时吊销、体积大
flowchart LR
    U[用户] --> N[Nginx]
    N -->|粘性: 同IP同节点| S1[节点1 本地Session]
    N -->|无状态: 携带JWT| S2[任意节点 验签即可]
    N -->|共享: 查Redis| R[(Redis Session存储)]
    S1 -. 宕机丢失 .-> X[需重登录]
    R -. 统一来源 .-> Y[任意节点可用]

⚠️ 新手必踩的坑: JWT 一旦签发无法主动失效(除非维护黑名单),所以敏感操作(改密码、登出)要配合短过期时间 + 刷新令牌机制。考点总结:Session 方案三选一——要无状态选 JWT,要简单选粘性,要一致性与可扩展选 Redis 集中存储。


十二、分布式事务

12.1 用生活类比先建立直觉

类比:你和朋友合伙点外卖,要"付款成功"且"商家接单"同时成立,否则两边都不该发生。但支付系统和商家系统是两个独立服务,没法用数据库的本地事务一把锁住——这就是分布式事务要解决的问题。

12.2 两阶段提交(2PC,对应大纲 #14)

协调者(Coordinator)先问所有参与者"能不能提交"(Prepare),大家都说能,再发"正式提交"(Commit)。任一说不能,则全体回滚。

sequenceDiagram
    participant C as 协调者
    participant A as 参与者A(扣库存)
    participant B as 参与者B(创建订单)
    C->>A: 阶段1: Prepare?
    C->>B: 阶段1: Prepare?
    A-->>C: 就绪(冻结资源)
    B-->>C: 就绪(冻结资源)
    C->>C: 都就绪?
    C->>A: 阶段2: Commit
    C->>B: 阶段2: Commit
    A-->>C: 完成
    B-->>C: 完成

缺点:第二阶段协调者挂了会阻塞(参与者一直持有锁等待);协调者是单点;同步阻塞性能差。强一致但代价高,多用于数据库层(如 XA)。

12.3 TCC 协议(对应大纲 #15)

TCC = Try / Confirm / Cancel,是业务层面的两阶段,不依赖数据库锁:

  • Try:预留资源(如冻结 100 元额度,而非真扣)。
  • Confirm:真正提交(扣掉冻结的 100 元),必须幂等。
  • Cancel:释放预留(解冻额度),必须幂等。
// 步骤1:Try 阶段只冻结资源,不真正扣减
func (s *OrderSvc) Try(ctx context.Context, uid int64, amt int) error {
    return s.freeze(ctx, uid, amt) // 余额表加"冻结字段"
}
// 步骤2:Confirm 阶段真正扣减(幂等:用事务ID去重)
func (s *OrderSvc) Confirm(ctx context.Context, txID string, uid int64, amt int) error {
    if s.done(txID) { return nil } // 已确认过则直接返回
    return s.debit(ctx, uid, amt)  // 扣减并记 txID
}
// 步骤3:Cancel 阶段释放冻结(幂等)
func (s *OrderSvc) Cancel(ctx context.Context, txID string, uid int64, amt int) error {
    if s.done(txID) { return nil }
    return s.unfreeze(ctx, uid, amt)
}

12.4 其他两种方案

方案思路一致性适用
Saga长事务拆成一系列本地事务,某步失败则反向补偿最终一致跨多服务、长流程
本地消息表本地事务写业务 + 消息表,后台任务轮询发送,消费方幂等最终一致对一致性要求不极端的异步场景

⚠️ 新手必踩的坑: TCC 的 Confirm/Cancel 必须幂等——网络重试可能多次调用,重复 Confirm 不能重复扣钱。补偿(Cancel)也可能被重试,同样要幂等。考点总结:2PC 强一致但同步阻塞、有单点;TCC/Saga/本地消息表是最终一致方案,用"预留+补偿"或"异步+幂等"换可用性,是互联网主流选择。


十三、分布式锁解决方案总览

13.1 用生活类比先建立直觉

类比:公共卫生间只有一个坑位,谁能进?方案 A:门口挂个电子牌,谁用 Redis 抢到"使用中"标记谁进——Redis 锁;方案 B:谁在登记本上拿到最小排队号谁进——ZK 锁;方案 C:谁先在公告栏贴上自己名字谁进——etcd 锁;方案 D:谁先抢到那张唯一的"钥匙表格"行谁进——数据库锁

13.2 四种实现对比

实现核心机制优点缺点
RedisSET key value NX EX性能极高、简单主从切换可能丢锁(需 Redlock)
ZooKeeper临时有序节点,最小序号获锁失效自动释放、公平、可监听性能弱于 Redis
etcd租约 Lease + 事务 CAS高可用、自动过期、强一致需部署 etcd 集群
数据库唯一索引 / SELECT FOR UPDATE无需额外中间件性能差、连接占用
graph TD
    A[获取锁请求] --> B{Redis SET NX}
    A --> C{ZK 临时有序节点}
    A --> D{etcd Lease+CAS}
    A --> E{数据库唯一索引}
    B --> F[快但需防主从丢锁]
    C --> G[稳但性能一般]
    D --> H[稳且一致]
    E --> I[简单但慢]

⚠️ 新手必踩的坑:SET NX EX 设了 30 秒过期,但业务执行了 60 秒——锁提前过期,别的线程进来了,两个线程同时持锁。解决:用"锁续期"(看门狗 watchdog)在业务未完成时自动延长过期。 考点总结:选锁看"性能 vs 可靠性"——高并发选 Redis(配看门狗/Redlock),强一致选 ZK/etcd。


十四、ZooKeeper 与 Redis 的区别及优缺点

14.1 用生活类比先建立直觉

类比:ZK 像一个"严谨的档案室管理员"——凡事留痕、顺序严格、谁拿了钥匙都有记录,慢但稳;Redis 像一个"手脚麻利的前台"——响应飞快、能存各种花样数据,但偶尔(主从切换瞬间)可能记错一笔。

14.2 工程要点

维度ZooKeeperRedis
数据模型层级 ZNode 树Key-Value(多种结构)
一致性强一致(ZAB 协议,顺序一致)最终一致(异步复制,主从可能丢写)
性能较低(写需过半节点)极高(内存操作)
典型用途协调、选主、配置、锁缓存、计数器、简单锁、Session
优势可靠、Watch 精准、无单点脑裂快、生态广、功能多
劣势慢、运维复杂、不适合存大量数据锁在主从切换时可能失效

一句话:要"稳、准、协调"用 ZK;要"快、多、扛量"用 Redis。分布式锁若对正确性极度敏感(如金融扣款)优先考虑 ZK/etcd。


十五、MySQL 如何做分布式锁

15.1 用生活类比先建立直觉

类比:公司只有一张"会议室使用表"。方案 A:谁先在该表里插进自己名字那一行(唯一约束),谁就占用了会议室——唯一索引;方案 B:谁先对那一行加"排他锁"(FOR UPDATE),谁就能独占操作——悲观锁;方案 C:进门时看一眼"当前人数 < 容量"才进,出错了就重试——乐观锁(版本号)

15.2 工程要点

乐观锁:表加 version 字段,UPDATE ... SET stock=stock-1, version=version+1 WHERE id=? AND version=旧值,受影响行数为 0 表示被别人改过,重试。

-- 步骤1:带版本号更新,旧版本匹配才成功
UPDATE items SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
-- 步骤2:若影响行数=0,说明并发已被改,重试或失败

悲观锁SELECT ... FOR UPDATE 在事务内加行锁,提交才释放,适合冲突频繁场景。

BEGIN;
SELECT * FROM items WHERE id = 100 FOR UPDATE; -- 锁住该行
UPDATE items SET stock = stock - 1 WHERE id = 100;
COMMIT;

唯一索引:用一张 lock_table(key UNIQUE),谁 INSERT 成功谁持锁,提交/断开即释放(依赖连接断开回滚)。

⚠️ 新手必踩的坑: SELECT ... FOR UPDATE 必须命中索引否则会锁全表;且锁在事务提交后才释放,事务务必短小,否则拖累并发。考点总结:MySQL 分布式锁三种路——乐观锁(版本号,无锁高并发)、悲观锁(FOR UPDATE,冲突多时用)、唯一索引(最简但依赖连接)。性能都不如 Redis/ZK,仅适合低并发或复用现有库。


十六、业界常见分布式锁框架

16.1 工程要点

框架基于特性
RedissonRedis最流行;提供 RLock、自动看门狗续期、RedLock 支持、可重入
CuratorZooKeeperApache 顶级项目;InterProcessMutex 封装了临时有序节点锁,开箱即用
etcd clientv3etcdconcurrency 包提供 NewMutex,基于 Lease + 事务
Spring Integration多后端统一抽象,可切换 Redis/ZK 等锁实现
graph LR
    A[业务代码] --> B[Redisson
Redis] A --> C[Curator
ZooKeeper] A --> D[etcd concurrency
etcd] B --> E[看门狗自动续期] C --> F[公平锁+临时节点] D --> G[Lease租约过期]

⚠️ 新手必踩的坑: 千万别自己用 SETNX 裸写锁——漏了过期时间会死锁,漏了看门狗业务超时锁会丢,漏了唯一 value 会误删别人的锁。直接用 Redisson/Curator 这类成熟框架,它们已处理好续期、可重入、误删等问题。考点总结:面试常问"你们用哪个锁框架"——Java 系基本是 Redisson(Curator),Go 系多用 etcd/clientv3 或自研基于 Redis 原子命令的锁。


十七、自测题与动手练习

自测题

1. CAP 定理中,为什么分布式系统必须选择 P(分区容错性)?

查看答案

因为网络分区在分布式系统中是不可避免的——交换机故障、网卡问题、网络拥塞都可能导致分区。如果不选择 P,意味着系统在网络分区时直接不可用,这在工程上不可接受。因此 P 是必选的,实际的选择是在 C 和 A 之间权衡:CP 还是 AP。

2. Raft 中一个 5 节点集群,最多可以容忍多少个节点宕机?3 节点集群呢?

查看答案

5 节点集群最多容忍 2 个节点宕机(需要 3 个存活节点超过半数)。3 节点集群最多容忍 1 个节点宕机(需要 2 个存活节点超过半数)。公式:容忍数 = (n-1)/2。

3. Raft 选主时,为什么选举超时时间要设置成随机值?

查看答案

如果所有节点的选举超时时间相同,它们会在 Leader 宕机后同时变成 Candidate,同时发起选举,互相分票,导致没有任何一个 Candidate 能获得多数票。这种"活锁"会反复发生。随机化超时时间(通常 150-300ms)使得某个节点先超时先发起选举,大概率在其他人超时之前就获得多数票成为 Leader。

4. Raft 日志复制中,Follower 收到 AppendEntries 时,发现 prevLogIndex 处的 term 与 prevLogTerm 不匹配,应该怎么处理?

查看答案

返回 success=false,不追加任何日志。Leader 收到失败响应后,会减小 nextIndex(回退一个位置),重新发送 AppendEntries,直到找到 Follower 和 Leader 日志匹配的点,然后从这个点开始覆盖后续日志。这种"回退重试"机制保证了 Follower 的日志最终会与 Leader 完全一致。

5. 在 Raft 中,为什么 Leader 不能直接提交前任 term 的日志?

查看答案

因为存在一种场景:前任 Leader 复制了某条日志到少数节点后宕机,新 Leader(更高 term)上任后如果直接提交这条旧 term 日志,可能会违反 Leader 完整性——如果此时又发生一次 Leader 切换,新 Leader 可能不包含这条日志。Raft 的解决方案是:Leader 只能通过提交当前 term 的新日志来"间接"提交之前的日志。因为当前 term 的日志被提交时,它之前的所有日志也一并被提交。

动手练习

练习 1: 使用 hashicorp/raft 库搭建一个 3 节点 Raft 集群。启动后查看哪个节点成为 Leader,然后用 raft.Apply() 写入一条数据,验证其他节点是否同步。

练习 2: 在练习 1 的基础上,kill 掉 Leader 节点的进程。观察剩余两个节点需要多长时间选出新 Leader,以及在新 Leader 选举期间写入请求的行为。

练习 3: 模拟网络分区:用 iptables 或防火墙规则将 3 节点集群中的 1 个节点隔离。观察被隔离节点和剩余 2 节点各自的行为。恢复网络后,观察被隔离节点如何重新同步数据。


十八、本章小结

本章围绕分布式一致性与 Raft 协议展开,核心要点如下:

  • 分布式一致性:多个节点对同一数据达成一致。强一致保证读到的总是最新值(etcd/ZooKeeper),弱一致允许短暂不一致,最终一致保证最终收敛(Cassandra/DNS)。
  • CAP 定理:一致性、可用性、分区容错性三选二。分布式系统必选 P,所以实际是 CP(如 etcd)还是 AP(如 Eureka)。BASE 理论是 AP 的工程实践:基本可用、软状态、最终一致。
  • Raft 三种状态:Follower(默认状态,等待 Leader 心跳)到 Candidate(选举超时后发起选举)到 Leader(获得多数票后处理客户端请求)。term(任期)是全局递增的,保证越新的 Leader 越有权。
  • 选主流程:Follower 超时变 Candidate,term 加 1 投票给自己,发送 RequestVote,获多数票变 Leader,发送心跳维持。随机化选举超时避免活锁。
  • 日志复制:Leader 追加本地日志,AppendEntries 复制给 Follower,多数确认后 commit,回复客户端,通知 Follower apply。冲突时 Follower 删除不一致日志,用 Leader 日志覆盖。
  • 安全性保证:选举限制(日志最新的才能当选)、提交限制(只提交当前 term 的日志)、Leader 完整性(已 commit 的日志不会丢)。
  • 脑裂问题:网络分区可能导致多个 Leader,但少数派分区无法获得多数票,无法 commit。分区恢复后旧 Leader 降级为 Follower。
  • Raft vs Paxos:Raft 以可理解性为核心目标,分解为选主/日志复制/安全三个子问题;Paxos 更通用但更复杂。etcd、Consul、TiKV 都使用 Raft。

掌握这些知识后,你不仅能理解 etcd、Consul 等系统的底层机制,还能在面试中准确回答关于 CAP、Raft 选主、日志复制的核心问题。下一章我们将深入微服务架构、CI/CD 流水线与限流器实现。

复习提示:
  • CAP 定理核心:분산 시스템은 일관성(C), 가용성(A), 분할 허용성(P)을 동시에 만족할 수 없으며, 실제로는 P가 필수이므로 CP 또는 AP 중 선택한다.
  • Raft 선거 랜덤 타임아웃:Follower 의 선거 타임아웃은 무작위(150-300ms)로 설정되어 여러 Follower 가 동시에 선거를 시작하는 것을 방지한다.
  • 로그 복제 안전 보장:Leader 는 대다수 노드에 로그를 복제한 후에만 클라이언트에게 제출confirm 한다.
  • 뇌분열 복구:네트워크 분할이 복구된 후, 소수파 분할에서 생성된 commit 은 버려지고 시스템은 다수파 분할 상태로 돌아간다.
面试官
Raft 는 이미 제출된 로그가 손실되지 않도록 어떻게 보장합니까? 만약 Leader 가 제출 후에 충돌하면 어떻게 됩니까?
候选人
Raft 의 제출 보장 메커니즘:

제출 조건: 하나의 로그 항목이 대다수 노드에 복제된 후에만 Leader 는 클라이언트에게 제출을 확인할 수 있습니다.

Leader 충돌 시나리오:
① Leader 가 대다수에게 복제하기 전에 충돌 → 해당 로그는 제출되지 않았으며 새 Leader 는 이를 보유하지 않을 것입니다
② Leader 가 대다수에게 복제했지만 아직 클라이언트에게 응답하지 않고 충돌 → 새 Leader 는 반드시 이 로그를 보유합니다(대다수가 포함하기 때문).

핵심 속성: 특정 인덱스에서 로그 항목이 제출된 경우, 미래에 선출되는 모든 Leader 는 이 항목과 그 이전의 모든 항목을 포함하게 됩니다. 이는 “로그가 더 새로운 후보만이 당선될 수 있다"는 규칙을 통해 보장됩니다.

면접 추가 포인트: “로그 일치 속성” - 동일한 인덱스와 Term 을 가진 두 로그 항목은 같은 명령어를 저장하며, 이후 로그 항목들도 동일합니다. 이것은 로그의 일관성과 안전성을 보장합니다.
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!