16.5 最终一致性
一句话
最终一致只承诺一件事:写入停止且副本能通信之后,所有副本最终会收敛到同一状态;它不给「多久」任何上界,也不保证中间过程读到的顺序——所以它必须配一套确定的冲突解决规则,否则「最终」永远不来。
定义:一个纯活性(liveness)承诺
形式化地说:对任意一个只有有限次写的执行,存在某个时刻 t,此后所有副本对同一数据项的读都返回相同的值。注意三个「没有」:
| 没有承诺 | 含义 | 后果 |
|---|---|---|
| 没有时间上界 | 「最终」可以是毫秒,也可以是几小时 | 不能当 SLA 写进合同 |
| 没有读新鲜度保证 | 读到旧值完全合法 | 用户可能看到自己刚写的数据消失 |
| 没有顺序保证 | 不同副本可以按不同顺序看到写 | 「评论先于帖子」这类因果气泡会出现 |
它只解决「最终会一致」,不解决「一致之前怎么办」。因此一个可用的最终一致系统必须再加两件事:
- 可靠的传播机制:反熵(anti-entropy)、提示移交(hinted handoff)、读修复(read repair),保证写最终能到达每个副本;
- 确定的冲突解决:并发写到达不同副本时,合并规则必须让所有副本算出同一个结果。
🔑 「最终一致」不是一种实现,而是一组实现的共同下限:Dynamo 风格的多副本、单主异步复制、CDN 缓存,都可以叫最终一致,但它们的冲突模型、丢失风险、收敛时间完全不同。
冲突解决:四种做法,四种代价
| 做法 | 规则 | 收敛结果 | 代价 / 风险 |
|---|---|---|---|
| LWW(last-write-wins) | 比较时间戳,取最大的 | 单值 | 依赖时钟;会静默丢写 |
| 多值 / siblings | 保留所有并发版本 | 版本集合 | 读时要合并,应用复杂度上升 |
| CRDT | 用可交换、结合、幂等的合并函数 | 单值或确定结构 | 受数据类型限制(计数器、集合、寄存器等) |
| 应用层合并 | 业务自定义(如购物车并集) | 业务决定 | 需要领域知识,难以通用 |
LWW 最诱人,也最容易出错:它把「谁更新」交给了时钟。时钟一偏,旧写就会赢。
算一笔账:LWW 在时钟偏移下错到什么程度
模拟设定:5 个副本、10 个 key、1000 次写,写随机落在某个副本,之后每轮随机选一对副本做一次反熵;每个副本的时钟有固定偏移(从 -skew 到 +skew 均匀分布),时间戳 = 写入步数 + 偏移。
package main
import (
"fmt"
"math/rand"
)
type entry struct {
val int
ts int64
rep int
}
type replica struct {
m map[string]entry
offset int64
}
func newer(a, b entry) bool {
if a.ts != b.ts {
return a.ts > b.ts
}
return a.rep > b.rep
}
func merge(dst, src *replica) {
for k, v := range src.m {
if cur, ok := dst.m[k]; !ok || newer(v, cur) {
dst.m[k] = v
}
}
}
func converged(rs []*replica) bool {
for i := 1; i < len(rs); i++ {
if len(rs[0].m) != len(rs[i].m) {
return false
}
for k, v := range rs[0].m {
if w, ok := rs[i].m[k]; !ok || v != w {
return false
}
}
}
return true
}
func trial(nrep int, skew int64, seed int64) (rounds, wrong int) {
rng := rand.New(rand.NewSource(seed))
rs := make([]*replica, nrep)
for i := range rs {
off := int64(0)
if nrep > 1 {
off = -skew + 2*skew*int64(i)/int64(nrep-1)
}
rs[i] = &replica{map[string]entry{}, off}
}
truth := map[string]int{}
for step := 0; step < 1000; step++ {
key := fmt.Sprintf("k%d", rng.Intn(10))
r := rng.Intn(nrep)
e := entry{val: step, ts: int64(step) + rs[r].offset, rep: r}
if cur, ok := rs[r].m[key]; !ok || newer(e, cur) {
rs[r].m[key] = e
}
truth[key] = step // 真实世界最后写入的值
}
for rounds = 1; rounds <= 500; rounds++ {
a, b := rng.Intn(nrep), rng.Intn(nrep)
for a == b {
b = rng.Intn(nrep)
}
merge(rs[a], rs[b])
merge(rs[b], rs[a])
if converged(rs) {
break
}
}
for k, want := range truth {
for _, r := range rs {
if r.m[k].val != want {
wrong++ // 收敛了,但收敛到错误的值
}
}
}
return rounds, wrong
}
func main() {
const nrep, trials, keys = 5, 100, 10
for _, skew := range []int64{0, 50, 500} {
var sumR, sumW int
for i := 0; i < trials; i++ {
r, w := trial(nrep, skew, int64(i)+1)
sumR += r
sumW += w
}
fmt.Printf("时钟偏移 ±%3dms:平均 %d 轮反熵收敛,最终值错误 %.1f%%\n",
skew, sumR/trials, float64(sumW)*100/float64(trials*keys*nrep))
}
}
输出(本机运行,Go 1.27,Apple M1):
时钟偏移 ± 0ms:平均 11 轮反熵收敛,最终值错误 0.0%
时钟偏移 ± 50ms:平均 10 轮反熵收敛,最终值错误 61.7%
时钟偏移 ±500ms:平均 8 轮反熵收敛,最终值错误 81.9%
读法:
- 没有时钟偏移时,LWW 与真实写入顺序一致,收敛即正确;
- 偏移 ±50ms、写入间隔 1ms 时,±50ms 足以让一个「落后 100 次写」的副本赢得合并,61.7% 的 (副本, key) 最终是错误但一致的值;
- 偏移越大,错误率越高,而且收敛速度看起来更快(错误值互相覆盖,反而更快一致)——这就是「收敛」骗人的地方:收敛不等于正确。
🔑 最终一致只保证「大家最后一样」,不保证「最后是对的」。正确性必须来自冲突解决规则,而不是来自「最终」。
CRDT:让合并天生收敛
CRDT(Conflict-free Replicated Data Type)的思路是把数据结构限制成「合并操作满足交换律、结合律、幂等性」,于是任何顺序、任何次数的合并都得到同一结果:
| 类型 | 合并函数 | 性质 | 典型用途 |
|---|---|---|---|
| G-Counter(只增计数) | 每副本一个分量,逐分量取 max | 交换/结合/幂等 | 播放量、点赞数 |
| PN-Counter | 两个 G-Counter 相减 | 同上 | 可增可减的计数 |
| G-Set / 2P-Set | 并集 / 带墓碑的并集 | 同上 | 已读标记、黑名单 |
| OR-Set | 带唯一标签的并集,删除只删自己见过的标签 | 同上 | 购物车、标签 |
| LWW-Register | 按 (时间戳, 副本 ID) 取最大 | 确定,但依赖时钟 | 用户资料字段 |
以 G-Counter 为例,合并就是逐分量取 max:
type GCounter struct{ n [5]uint64 } // 5 个副本各一个分量
func (c *GCounter) Inc(replica int) { c.n[replica]++ }
func (c *GCounter) Merge(o GCounter) {
for i := range c.n {
if o.n[i] > c.n[i] {
c.n[i] = o.n[i] // 取 max:交换、结合、幂等
}
}
}
func (c *GCounter) Value() uint64 {
var s uint64
for _, v := range c.n {
s += v
}
return s
}
代价是表达能力受限:不是所有业务都能写成 CRDT。把「转账」「库存扣减」硬套 CRDT,通常会得到一个语义错误但能收敛的系统——比不收敛更危险,因为它看起来「没问题」。
反熵:收敛靠什么推进
| 机制 | 做法 | 特点 |
|---|---|---|
| Gossip | 副本间随机互传全量/增量 | 简单、最终覆盖;带宽随副本数上升 |
| Merkle 树比对 | 先比哈希树,只传差异 | 省带宽,适合大表 |
| Hinted handoff | 目标副本不可用时先代存,恢复后补发 | 降低短期不可用导致的丢失 |
| Read repair | 读时发现版本不一致,顺手修复 | 只修复被读到的数据 |
| 全量校验 | 定期扫描比对 | 兜底,成本高 |
模拟里 5 个副本、每轮只做一次随机配对,平均 11 轮收敛;现实中反熵是并行的、带 Merkle 差异的,收敛时间由「反熵周期 × 轮数 × 数据量」决定,而不是一个固定常数。
「最终」到底多久:工程上怎么答
不要说「最终会一致」,要说清楚四个量:
| 问题 | 可度量的答案 |
|---|---|
| 反熵多久跑一次? | 周期 T(秒/分钟) |
| 一次能修多少? | 修复吞吐 R(MB/s 或 key/s) |
| 最坏要修多少? | 分区期间积累的写量 W |
| 收敛时间上界 | 约 T + W / R(还要加上重试与背压) |
监控上至少要有:副本间版本差(divergence)、反熵队列积压、读修复命中率、LWW 覆盖告警(同一 key 短时间内被不同副本覆盖)。
什么时候可以用
| 场景 | 是否适合 | 理由 |
|---|---|---|
| 点赞数、播放量、浏览量 | ✅ | 可交换计数(G-Counter)或允许近似 |
| 社交动态、评论 | ✅(配因果) | 允许延迟,但要防因果气泡(16.4) |
| 用户资料、偏好设置 | ✅ | LWW 可接受,冲突影响小 |
| 购物车 | ✅(有前提) | 需要 OR-Set 或应用合并,不能简单 LWW |
| DNS / CDN 配置 | ✅ | 传播延迟可接受,见 9.1 边缘缓存原理 |
| 账户余额、支付 | ❌ | 需要线性一致/事务,不能用最终一致 |
| 库存扣减、秒杀 | ❌ | 超卖不可接受 |
| 唯一性约束(用户名、订单号) | ❌ | 需要全局协调 |
| 预订/排座 | ❌ | 冲突必须立刻拒绝 |
反模式
| 反模式 | 后果 |
|---|---|
| 把「最终一致」写进 SLA(如「1 秒内一致」) | 没有机制能保证;故障时违约 |
| 用 LWW 处理金额/库存 | 时钟偏移或并发导致静默丢写、超卖 |
| 假设并发写不会冲突 | 冲突迟早发生,且往往在峰值时 |
| 没有反熵/读修复,只靠写传播 | 一次失败就永久丢失 |
| 把「异步复制」当「最终一致所以安全」 | 单主异步复制在切主时可能丢已提交写(18.5 故障检测与切换 |
| 只监控最终状态,不监控分歧 | 长期分歧被「平均值」掩盖,直到用户投诉 |
| 用 CRDT 硬套不合适的业务 | 收敛但语义错误,比不收敛更难发现 |
自检
Q1 最终一致承诺「最终收敛」,为什么模拟里会出现 61.7% 的最终值错误?
▸ 答案:因为它收敛到的是冲突解决规则算出的值,不是「真实世界最后一次写的值」。LWW 用时间戳判定谁赢,副本时钟有 ±50ms 偏移、写间隔只有 1ms 时,一个落后几十次写的副本可能因为时钟更快而获胜。收敛性(大家一样)与正确性(值对不对)是两件事,最终一致只保证前者。
Q2 一个系统「写停止后 1 秒内所有副本一致」,它还是最终一致吗?它比最终一致强在哪?
▸ 答案:仍是最终一致(它满足「最终收敛」),但额外给了收敛时间上界,属于「有界收敛/有界陈旧」的更强承诺。最终一致的定义不限制时间,所以「1 秒内」是对实现的额外约束,需要靠反熵周期、同步复制或 quorum 写来兑现;能不能兑现要用故障注入验证,不能只看正常路径。
Q3 为什么 CRDT 能保证收敛,而 LWW 不能保证正确?
▸ 答案:CRDT 把合并函数设计成满足交换律、结合律、幂等性,因此合并顺序与重复都不影响最终状态——收敛是数学性质,不依赖时钟。LWW 也收敛(按时间戳取最大是确定的),但它的「正确性」依赖时间戳能真实反映写顺序;时钟偏移时,收敛结果与真实顺序无关,所以会静默丢写。CRDT 牺牲的是表达能力,LWW 牺牲的是正确性。
Q4 单主异步复制算不算最终一致?它和 Dynamo 风格的多副本最终一致差在哪?
▸ 答案:算——停止写入后,从库最终会追上主库,满足最终一致。差别在冲突模型:单主只有一个写入者,不存在并发写冲突,所以不需要冲突解决;代价是切主时可能丢已提交但未复制的写(RPO > 0),且读可能长期陈旧。多副本最终一致要处理并发写冲突,因此需要 LWW/多值/CRDT,代价是元数据与合并逻辑。
Q5 业务说「我们接受最终一致」,你还要追问哪三个问题?
▸ 答案:① 冲突怎么解决(LWW 会丢写吗?业务能接受吗?);② 不一致窗口内用户看到什么(自己的写会消失吗?需要会话保证吗?见 16.6);③ 收敛时间的上界与监控(反熵周期、修复吞吐、分歧告警)。三个都答不上来,「接受最终一致」只是接受了「最终」两个字,没有接受工程后果。
跨篇相关:只约束因果顺序的更强模型见 16.4 因果一致性;会话级的新鲜度保证见 16.6 会话保证;比它强的顺序一致见 16.3 顺序一致性;冲突检测与解决策略见 25.3 冲突检测与解决策略;CRDT 的具体类型见 25.6 CRDT (计数 / 集合 / 寄存器);复制模型与异步复制的丢失风险见 18.1 复制模型:单主 / 多主 / 无主、18.2 同步、半同步、异步、18.5 故障检测与切换;CAP 与 BASE 的定位见 17.5 BASE 与柔性事务;缓存层的最终一致见 9.1 边缘缓存原理;完整谱系见 16.7 一致性模型谱系图。