16.5 最终一致性

一句话

最终一致只承诺一件事:写入停止且副本能通信之后,所有副本最终会收敛到同一状态;它不给「多久」任何上界,也不保证中间过程读到的顺序——所以它必须配一套确定的冲突解决规则,否则「最终」永远不来。

定义:一个纯活性(liveness)承诺

形式化地说:对任意一个只有有限次写的执行,存在某个时刻 t,此后所有副本对同一数据项的读都返回相同的值。注意三个「没有」:

没有承诺 含义 后果
没有时间上界 「最终」可以是毫秒,也可以是几小时 不能当 SLA 写进合同
没有读新鲜度保证 读到旧值完全合法 用户可能看到自己刚写的数据消失
没有顺序保证 不同副本可以按不同顺序看到写 「评论先于帖子」这类因果气泡会出现

它只解决「最终会一致」,不解决「一致之前怎么办」。因此一个可用的最终一致系统必须再加两件事:

  1. 可靠的传播机制:反熵(anti-entropy)、提示移交(hinted handoff)、读修复(read repair),保证写最终能到达每个副本;
  2. 确定的冲突解决:并发写到达不同副本时,合并规则必须让所有副本算出同一个结果。

🔑 「最终一致」不是一种实现,而是一组实现的共同下限: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 一致性模型谱系图。