KV存储笔记:版本、并发写与冲突解决(LWW / vector clock)

1 分钟阅读

发布于:

复制 + quorum 的系统一旦允许在故障/分区下继续接受写,就必须回答:当同一 key 出现并发写时,系统如何定义“最终值”?

这里的“并发”不是多线程意义,而是分布式因果意义:两个写互相不可比较(没有 happens-before)。

本文聚焦三种常见策略:

  • LWW(Last-Write-Wins):用时间戳强行线性化
  • vector clock + siblings:保留多个并发版本,把合并交给上层
  • 单主/租约:通过领导者把写线性化(本质是避免并发)

1. 为什么会出现并发写:分区 + 可用性选择

以 N=3 为例,若系统选择 W=1(更可用):

  • 分区时两侧都可能分别写入不同副本集合
  • 当分区恢复,这些写会在修复中“相遇”,形成并发版本

即使 W=2,也可能出现:

  • coordinator 超时重试导致重复写
  • 不同 coordinator 在配置切换时对同一 key 接受写(epoch 设计不严谨)

工程结论:只要系统不是严格单主线性化,并发版本就是常态风险。


2. 版本的基本要求:可比较、可判定并发

一个版本系统至少要支持:

  • 比较新旧(A 是否更新于 B)
  • 判定并发(A 与 B 是否并发)

常见版本类型:

  • 单调序列号(需要单主或全局序列源)
  • 时间戳(需要时钟假设)
  • 逻辑时钟(Lamport:可比较但无法判定并发)
  • 向量时钟(vector clock:可判定并发)

3. LWW:简单,但把风险转移到时钟与重试

3.1 LWW 的定义

为每次写附带一个 timestamp,取 timestamp 最大的版本作为 winner:

winner = argmax(version.timestamp)

3.2 LWW 的工程陷阱

  • 时钟漂移:不同节点时间不同,会导致“较旧写被认为较新”
  • 回拨:NTP 校时造成时间跳变
  • 客户端时间戳:风险更大,且与客户端重试耦合

若仍要用 LWW,至少要明确:

  • timestamp 由谁生成(服务端更常见)
  • 单调性如何保证(逻辑时钟 + 物理时钟混合)
  • 时钟异常时的降级策略(拒写/强制走单主)

3.3 LWW 适用边界

适用于:

  • value 本身可被覆盖,业务只关心“最新写”
  • 能容忍小概率错误覆盖(或有上层补偿)

不适用于:

  • 计数器、集合更新等需要“合并语义”的场景

4. Vector Clock:保留并发版本,明确冲突存在

4.1 向量时钟的直觉

每个 key 的版本携带一个向量:

vc = { nodeA: 5, nodeB: 2, nodeC: 7 }

当某节点写入时,递增自己的分量并携带整个向量。

两个版本 vc1 与 vc2 的关系:

  • 若对所有节点 (vc1[i] <= vc2[i]),且至少一个严格小于,则 vc2 “更新于” vc1
  • 若既不满足 vc1<=vc2 也不满足 vc2<=vc1,则并发

4.2 siblings:并发版本集合

当系统检测到并发,会把多个版本都保留:

key -> [ (value1, vc1), (value2, vc2), ... ]

读可能返回多个值(siblings),由上层合并并写回“合并后的新版本”。

4.3 代价与工程问题

  • 元数据膨胀:向量长度随节点数增长;工程上常做裁剪/只保留相关节点
  • 并发版本爆炸:若上层不及时合并,siblings 会持续累积
  • 读写路径复杂:读要合并、修复要传播多个版本

因此常见工程约束:

  • siblings 上限(超过则强制 LWW 或拒写)
  • 自动合并策略(对可自动合并的数据类型)
  • 只在“需要并发语义”的表启用 vector clock

5. 另一条路:避免并发(单主/租约/共识)

如果系统目标是更强的一致性与更简单的读写语义,通常会选择:

  • 单主复制(primary-backup)
  • leader lease(租约)限制并发 leader
  • Raft/Paxos 把写线性化

代价是:

  • 可用性在分区下下降(写需要 leader/quorum)
  • 时延与实现复杂度上升

工程上常见分层:

  • 强一致关键路径:走共识/单主
  • 大吞吐弱一致路径:走多主 + 修复

6. 冲突解决策略(上层视角)

当读到 siblings,需要一个 merge 函数:

  • 集合:取并集(加 tombstone 处理删除)
  • 计数器:用 CRDT(PN-Counter 等)
  • 最后写:回到 LWW(但明确语义)
  • 业务合并:例如购物车、点赞等

关键是:merge 必须是幂等的,且在重试/重复执行下不会扩大错误。


7. 小结

冲突解决没有银弹,只有明确的语义选择:

  • LWW 简单但依赖时钟与容错假设
  • vector clock 能表达并发,但需要控制元数据与 siblings 增长
  • 单主/共识能避免并发,但牺牲分区下写可用性与实现复杂度

下一篇笔记会把“删除语义”补齐:tombstone、TTL 与 compaction/修复的交互,避免“删了又回来”。