日志复制与安全性
共识算法的核心不是「值的一致」,而是「日志的顺序」。
这句话听起来有点反直觉——我们不是说好要让所有节点对「某个值」达成一致吗?为什么变成日志了?
因为:只要所有节点以相同顺序应用相同的日志条目,最终状态必然一致。值只是日志执行后的「结果」,日志才是「因」。
这是一个重要的思维方式转变:分布式系统不纠结于「如何同步最终状态」,而是确保「所有节点看到的操作历史完全相同」。这个思路,比试图直接同步状态,要简单得多。
日志条目结构
在 Raft 中,每个日志条目包含三个核心字段:
Term 的作用:Term 就像日志的「时间戳」,用于判断哪条日志「更新」。结合 index,可以精确定位任意一条日志。
复制流程:两阶段提交
Leader 接收客户端请求后,通过两阶段提交将日志复制到所有节点。
代码实现:Leader 端
三个关键索引
理解日志复制,需要理解三个核心索引:
安全性保证
不变式 1:Leader 不会覆盖已提交日志
这是通过投票规则保证的:只有日志比本地更新的 Candidate 才能获得投票。
不变式 2:日志匹配特性
这是通过 AppendEntries 的一致性检查保证的。
日志回退是必要的。当 Follower 的日志比 Leader 更长(但有冲突)时,Follower 必须回退自己的日志来匹配 Leader。这是 Raft 保证一致性的关键。
Leader 故障后的恢复
Leader 故障时,Follower 可能处于不同的日志状态。新 Leader 上任后,需要同步所有 Follower 的日志。
日志压缩:如何避免日志无限增长
理论上,日志可以无限增长。但在实际系统中,存储是有限的。
Raft 采用快照(Snapshot)+ 日志保留的方式解决:
快照时机:通常在日志大小达到某个阈值(如 64MB)或快照间隔(如每 10000 条日志)时触发。快照太频繁会消耗 CPU;日志太长会占用过多磁盘。
权衡矩阵
常见问题
问题 1:客户端请求重复
网络问题可能导致客户端没有收到 Leader 的响应,客户端重试。
幂等性的重要性:共识算法本身保证「日志顺序一致」,但不保证「请求只执行一次」。应用层需要实现幂等性,比如使用唯一请求 ID。
问题 2:Follower 落后太多
如果 Follower 落后太多(超过一个快照的距离),Leader 需要发送整个快照。
问题 3:网络分区导致日志不一致
网络分区时,分区两侧的日志可能不一致。恢复网络后,少数派分区的日志会被多数派覆盖。
这意味着少数派分区上的「未提交」数据可能丢失。这是共识算法的设计选择——为了保证一致性,牺牲了分区期间的可用性。
术语表
延伸思考
日志复制看似机械,但有几个深层问题值得关注。
问题一:时钟与逻辑时间。Raft 使用物理时钟(Term)标记日志时代,但物理时钟可能出现回拨。etcd 使用 Hybrid Logical Clock(HLC)来解决这个问题——如果当前 Term 已足够大,优先使用逻辑时钟,避免时钟回拨导致的问题。
问题二:持久化策略。日志必须写入磁盘才能算「已接受」吗?不一定。ZAB 允许 Follower 内存中预写,收到 COMMIT 再刷盘——这提升了性能,但牺牲了故障恢复时的数据安全性。Raft 通常选择写盘后才 ACK,安全性更高。
问题三:读写性能。强一致读必须经过 Leader,但 Leader 可能成为瓶颈。一个常见的优化是读写分离:Follower 提供只读请求(通过 Lease 机制保证不过期),只有写请求经过 Leader。
回到核心问题:理论已经清晰,工程实践如何?