学习目标
读完本章后,你将能够:
- 说出分布式一致性的定义,区分强一致、弱一致与最终一致三种级别,并能判断常见系统属于哪一种。
- 用 CAP 定理分析一个分布式系统属于 CP 还是 AP,并解释 BASE 理论如何在工程实践中平衡 CAP 的取舍。
- 画出 Raft 的三种节点状态(Follower / Candidate / Leader)转换图,说明超时选举的触发条件。
- 描述 Raft 选主与日志复制的完整流程:从 Follower 超时变 Candidate,到获多数票变 Leader,再到客户端写入、日志 commit 并 apply。
- 说出 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 的延伸:
| 字母 | 全称 | 含义 |
|---|---|---|
| BA | Basically Available | 基本可用:允许响应时间增加或功能降级 |
| S | Soft State | 软状态:允许数据存在中间状态 |
| E | Eventually 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 | 处理客户端请求,复制日志到所有 Follower | Candidate 获得多数票 |
选主流程的详细步骤:
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: 心跳 AppendEntriespackage 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 日志复制流程
日志复制的完整步骤:
- Leader 收到客户端写请求。
- Leader 将日志条目追加到本地日志(暂未 commit)。
- Leader 并行发送
AppendEntriesRPC 给所有 Follower。 - Follower 收到后,检查
prevLogIndex和prevLogTerm是否匹配。 - 匹配则追加日志,回复
success=true;不匹配则回复success=false。 - Leader 收到多数 Follower 的
success=true后,将该日志标记为committed。 - Leader 回复客户端"成功”。
- Leader 在下一次心跳中将
commitIndex通知 Follower。 - 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 通过比较 lastLogIndex 和 lastLogTerm 来判断谁的日志更新。“多数派防脑裂"对应 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
| 对比维度 | Raft | Paxos |
|---|---|---|
| 设计目标 | 可理解性优先 | 通用性和理论完备性 |
| 结构分解 | 选主 + 日志复制 + 安全,三个子模块 | 单一协议,不显式分解 |
| Leader 角色 | 强 Leader 模型,所有请求经过 Leader | 可选 Leader(Multi-Paxos),不是必须 |
| 日志管理 | 日志连续,只能追加,无空洞 | 允许日志空洞,更灵活但更复杂 |
| 工程实现 | etcd、Consul、TiKV、CockroachDB | Chubby(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 四种实现对比
| 实现 | 核心机制 | 优点 | 缺点 |
|---|---|---|---|
| Redis | SET 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 工程要点
| 维度 | ZooKeeper | Redis |
|---|---|---|
| 数据模型 | 层级 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 工程要点
| 框架 | 基于 | 特性 |
|---|---|---|
| Redisson | Redis | 最流行;提供 RLock、自动看门狗续期、RedLock 支持、可重入 |
| Curator | ZooKeeper | Apache 顶级项目;InterProcessMutex 封装了临时有序节点锁,开箱即用 |
| etcd clientv3 | etcd | concurrency 包提供 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 은 버려지고 시스템은 다수파 분할 상태로 돌아간다.
제출 조건: 하나의 로그 항목이 대다수 노드에 복제된 후에만 Leader 는 클라이언트에게 제출을 확인할 수 있습니다.
Leader 충돌 시나리오:
① Leader 가 대다수에게 복제하기 전에 충돌 → 해당 로그는 제출되지 않았으며 새 Leader 는 이를 보유하지 않을 것입니다
② Leader 가 대다수에게 복제했지만 아직 클라이언트에게 응답하지 않고 충돌 → 새 Leader 는 반드시 이 로그를 보유합니다(대다수가 포함하기 때문).
핵심 속성: 특정 인덱스에서 로그 항목이 제출된 경우, 미래에 선출되는 모든 Leader 는 이 항목과 그 이전의 모든 항목을 포함하게 됩니다. 이는 “로그가 더 새로운 후보만이 당선될 수 있다"는 규칙을 통해 보장됩니다.
면접 추가 포인트: “로그 일치 속성” - 동일한 인덱스와 Term 을 가진 두 로그 항목은 같은 명령어를 저장하며, 이후 로그 항목들도 동일합니다. 이것은 로그의 일관성과 안전성을 보장합니다.