Skip to content

W+R>N 之后还缺什么:版本、并发写、时钟与会话 ​

三副本的存储,写入要求两个副本确认(W=2),读取从两个副本读(R=2)。W+R=4>3,读写集合一定相交,所以「一定能读到最新值」。可是用户刚改完昵称,刷新页面看到的还是旧的;两个人同时编辑同一条记录,其中一个人的修改悄无声息地消失了。

W+R>N 是多副本系统里最常被引用的条件:写入成功的 W 个副本和读取的 R 个副本至少有一个是同一个,所以读取总能「接触到」最新的写入。这个推理是对的,但它只证明了相交,没有回答:读到几个值之后怎样知道哪个是最新的?写入只成功了一部分算什么?两个客户端同时写怎么办?读写集合是不是总从同一组副本里选?

本文用一个 N=3 的确定性模拟,把这些问题逐个跑一遍。这是对规则的模拟,不是某个具体存储系统的实测;具体系统的行为以各自的文档为准。实验见文末。

W=2 写 v2 到 A、B;R=2 读 C、BAv2Bv2Cv1比较版本 → v2取最先返回的 C → v1写 v3 只有 A 确认:客户端收到失败Av3Bv1Cv1读 A、C → v3读 B、C → v1并发写(两个客户端都基于同一版本)最后写入获胜 + 时钟慢 10后发生的写被静默丢弃版本向量 {A:2} 与 {A:1,B:1}保留两个值,读的一方合并宽松 quorum:A、B 不可达,写入落在 C 和备用 DAv1Bv1Cv4Dv4A、B 恢复后读 A、B → v1:W+R=4>3,集合却不相交读自己的写:会话记住写入的版本 5,副本 C 只有版本 1 → 拒绝并改读 A,得到 v5
图 1 · 模拟(JDK 25.0.4.1,确定性):W=2、R=2 时读集合一定与写集合相交,但只有比较版本才能选出新值。一次只收到 1 个确认的「失败」写入,会被包含 A 的读看到;并发写入要么被最后写入获胜静默丢弃,要么被版本向量保留下来交给应用合并;宽松 quorum 把写入放到备用节点,W+R=4>3 的读写集合也可能不相交。

一、相交之后,还要比较版本 ​

text
W=2 写入 v2 到 A、B(C 仍是 v1);R=2 从 C、B 读,比较版本取最新:v2
同样从 C、B 读,取最先返回的 C:v1
W=1 写入 A,R=1 从 C 读:v1

读集合 {C, B} 与写集合 {A, B} 相交于 B,所以 v2 一定在读到的结果里。但读到的是两个值,必须有办法判断哪个更新:每个值要带版本(时间戳、序号或版本向量),客户端或协调者比较版本后取最新的。如果实现只是「取最先返回的那个」,相交就没有意义了。

W+R≤N 时读写集合可以不相交,读到旧值是预期的结果。这样的配置不是错误,而是选择了更低的延迟和更高的可用性,前提是业务能接受。

二、「失败」的写入 ​

text
W=2 写 v3,只有 A 确认(1 个),客户端收到失败;随后 R=2 从 A、C 读:v3;从 B、C 读:v1

客户端要求两个确认,只收到一个,于是认为写入失败。但 v3 已经写在了 A 上,多副本存储通常不会因为凑不够确认就回滚已经写下的副本。之后的读取,如果读集合包含 A,就会读到这个「失败」的写入;不包含 A 就读不到。同一个值,有时存在、有时不存在。读修复(读时发现副本不一致就把新值写回)还可能把它扩散到所有副本。

客户端收到写入失败时,正确的理解是「不确定」:可能没写,可能写了一部分,之后可能被读到。处理方式与超时相同:带上幂等的标识重试,直到确认成功,而不是假定它没有发生。

三、最后写入获胜与时钟 ​

text
Y 在真实时间 95 写入,X 在真实时间 100 写入但时钟慢 10:最后写入获胜保留的是「Y 写的 2」,X 后发生的写入被静默丢弃

用客户端或节点的时间戳作为版本、时间戳大的覆盖小的(最后写入获胜,LWW),实现简单,但它依赖各机器的时钟一致。时钟慢了 10 的客户端,后发生的写入带着更小的时间戳,被当成了旧值丢弃。没有报错,没有冲突,数据就是没了。

NTP 能把时钟误差控制在毫秒级,但做不到零,并且偶尔会跳变。LWW 适合那些「丢一次写入可以接受」的数据(最后在线时间、缓存),不适合用户编辑的内容。

四、版本向量:发现并发,而不是假装没有 ​

text
两个客户端都基于 {A:1} 写入,版本向量 {A:2} 与 {A:1,B:1} 互不包含:副本保留 [x=1, x=2],交给读的一方合并
读到两个值、合并为 x=3 并以 {A:3,B:1} 写回:副本保留 [x=3]

版本向量记录每个副本上发生过的写入次数。两个版本,如果一个的每一项都不大于另一个,就是「先后」关系,新的覆盖旧的;如果互不包含,就是并发写入,谁也不该覆盖谁。存储保留两个值(兄弟值),交给读取的一方按业务规则合并,合并后的版本包含两者,写回之后兄弟值消失。

它把冲突暴露给了应用,而不是悄悄选一个。代价是应用必须会合并:购物车可以取并集,计数器可以用专门的数据类型(CRDT),用户编辑的文本可能需要人来决定。

五、宽松 quorum:W+R>N,集合却不相交 ​

text
首选副本 A、B、C;A、B 不可达时写入落在 C 和备用节点 D(确认 2 个);
A、B 恢复后、提示移交完成前,R=2 从 A、B 读:v1:W+R=4>3,读写集合却不相交

为了在部分节点故障时仍然可写,一些系统允许把写入暂时放到「备用」节点上凑够 W,等原来的节点恢复后再移交过去(提示移交)。这提高了写的可用性,但 W 个确认来自的不再是固定的 N 个副本,W+R>N 的相交推理就失效了:读取从恢复的 A、B 读,而新值在 C 和 D 上。

使用这类系统时,要清楚 W 和 R 的确认是否允许来自首选列表之外的节点;需要强一致的读写,应关闭宽松 quorum。

六、读自己的写 ​

text
会话写入 v5(W=2 到 A、B),下一次 R=1 被路由到 C:v1
会话记住自己写入的版本 5,读到更旧的版本就换副本:拒绝(版本 1 < 5)→ 改读 A:v5

开头那个「刚改完昵称刷新还是旧的」就是这个场景:写入成功了,但下一次读被负载均衡到了落后的副本。W+R>N 能避免它,但很多读请求为了延迟使用 R=1。

会话保证是更轻量的解法:客户端(或网关)记住自己写入时得到的版本,读取时带上这个版本,副本发现自己的数据比它旧,就拒绝或者等待追上,或者由客户端改读其他副本。类似的保证还有「单调读」(不会先读到新值再读到旧值)、「先写后读」等。它们不要求全局最新,只要求对同一个会话不倒退,代价小得多。数据库主从延迟下的同类问题,见 MySQL 复制与延迟:一笔事务怎样抵达副本。

七、检查清单 ​

  • 读到多个副本的值时,按版本选择最新值,而不是取最先返回的。
  • 写入失败当作「不确定」处理:幂等重试,不假定它没有发生。
  • 用时间戳做版本(LWW)时,接受「时钟偏差会丢写入」;用户编辑的数据用版本向量或条件写入。
  • 并发写入要么由版本向量暴露给应用合并,要么用条件更新让其中一个失败。
  • 了解系统是否使用宽松 quorum;需要相交保证时关闭它。
  • 用户刚写入的数据,用会话版本号保证读自己的写。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权