QuanZhou's Wiki

分布式基础:副本和一致性

~/ 分布式#副本#一致性#KV Cache

背景

前面已经学习了几组基础:

第一组:操作系统、网络、并发与存储。
第二组:RPC 和远程调用。
第三组:状态机、分片、路由与元数据。

这些内容解决了几个问题:

系统为什么会慢?
远程调用为什么会失败?
BlockGroup 有哪些状态?
数据如何分片?
请求如何通过路由和元数据找到 KV Cache?

接下来要学习的是副本和一致性。

在分布式系统里,数据通常不会只放一份。只放一份会有明显问题:

节点挂了,数据就不可用了。
单个节点读压力太大。
迁移或维护时没有备用数据。
网络抖动时请求容易失败。

所以系统会把数据复制到多个节点,这就是副本。

但副本一旦出现,就会带来另一个问题:

多个副本之间如何保持一致?

对 KV Cache 和 BlockGroup 来说,这个问题尤其重要。因为 KV 数据本身、位置信息、状态信息、引用计数、pin 状态,对一致性的要求并不一样。

学习目标

这一组需要掌握:

副本是什么
为什么需要副本
主副本和从副本
读副本和写副本
同步复制
异步复制
强一致性
最终一致性
读写一致性
Quorum
脏读、旧读、写冲突
KV Cache 中数据和元数据的一致性区别

这一组的目标是:能够判断哪些数据必须强一致,哪些数据可以最终一致,以及副本读写会带来什么风险。

为什么需要副本?

副本的目的主要有三个。

高可用

如果数据只有一份,节点挂了就无法访问。

例如:

bg_1 只在 node_a。
node_a 宕机。
worker 无法读取 bg_1。
请求失败或只能重新计算。

如果有副本:

bg_1 primary 在 node_a。
bg_1 replica 在 node_b。
node_a 宕机后,可以从 node_b 读取或提升 node_b 为新的 primary。

这能提高系统可用性。

读扩展

如果很多请求都读取同一份数据,单个节点可能成为热点。

例如:

某个系统 prompt 的 KV Cache 被大量请求复用。
所有请求都去 node_a 读取。
node_a 网络和内存带宽被打满。

如果有多个只读副本,就可以分担读压力:

node_a
node_b
node_c

不同请求可以读不同副本。

故障恢复和迁移缓冲

副本还可以用于节点维护、迁移和故障恢复。

例如:

node_a 即将下线。
先把重要 block group 复制到 node_b。
确认副本完整后,再切换路由。
最后释放 node_a 上的数据。

这样可以减少迁移期间的数据不可用时间。

副本带来的问题

副本不是免费午餐。

它会带来几个问题:

1. 写入要同步到哪些副本?
2. 读请求可以读任意副本吗?
3. 如果副本之间数据不一样,谁是对的?
4. 主副本挂了,谁能接管?
5. 旧主恢复后还能不能继续写?
6. 副本落后时,能不能对外提供读取?
7. 副本修复期间,路由和元数据如何更新?

这些问题合起来,就是一致性问题。

KV 数据和元数据要分开看

在 KV Cache 系统里,首先要区分两类数据:

KV 数据本身
BlockGroup 元数据

KV 数据本身通常是:

某个 layer、某段 token range 的 Key/Value tensor。
生成后很少修改。
可以近似看作 immutable block。

BlockGroup 元数据通常包括:

location
state
epoch
owner
ref_count
pin_count
replica_list
primary_replica

这些元数据会频繁变化。

这个区别非常关键:

KV 数据本身可以更偏缓存语义。
元数据状态必须更严格。

例如,一个已经生成好的 KV block,如果多个副本内容完全一样,那么读哪个副本都可以。

ownerepochstateref_count 这类元数据如果不一致,就可能造成严重问题:

旧 owner 继续写。
正在使用的 cache 被淘汰。
迁移过程被重复执行。
客户端读到旧位置。
两个节点都认为自己是 primary。

所以学习一致性时,要先问:这份数据是内容数据,还是控制状态?

主从副本

最常见的副本模型是主从副本。

Primary:主副本,负责处理写入。
Replica:从副本,复制 primary 的数据。

写入路径:

client -> primary -> replicas

读取路径可以有两种:

client -> primary
client -> replica

如果所有读都走 primary,一致性更容易保证,但 primary 压力更大。

如果读可以走 replica,读扩展能力更好,但可能读到旧数据。

KV Cache 中的主从副本

对于 BlockGroup,可以这样建模:

block_group_id = bg_1
primary = node_a
replicas = [node_b, node_c]
epoch = 10
state = LOCAL

如果要修改状态,例如迁移或淘汰,应该由 primary 或元数据服务协调。

例如:

evict(bg_1, epoch=10)

这个请求不能随便发给任意 replica 执行,否则可能出现:

node_b 删除了副本。
node_a 还认为副本存在。
metadata server 还把 node_b 写在 replica_list 里。

所以对元数据更新,要有明确的写入 owner。

同步复制

同步复制是指写入必须等待副本确认,才算成功。

例如:

client -> primary
primary -> replica_1
primary -> replica_2
replica_1 ack
replica_2 ack
primary -> client success

优点:

副本更一致。
primary 故障后,副本更可能拥有最新数据。
读副本更安全。

缺点:

写延迟更高。
可用性更差,只要副本慢就会拖慢写入。
吞吐更低。

在 KV Cache 场景里,同步复制适合关键元数据,比如:

owner
epoch
state
primary_replica

这些状态一旦错了,可能影响正确性。

异步复制

异步复制是指 primary 写入成功后先返回,再后台复制到其他副本。

例如:

client -> primary
primary local write success
primary -> client success
primary later -> replica_1
primary later -> replica_2

优点:

写延迟低。
吞吐更高。
对慢副本不敏感。

缺点:

副本可能落后。
primary 故障时可能丢失最新写入。
读 replica 可能读到旧数据。

在 KV Cache 场景里,异步复制适合:

非关键 cache 副本。
预热数据。
可重算数据。
统计指标。
访问热度。

如果某个 KV block 可以重新计算,那么它对一致性的要求可以低一些。

强一致性

强一致性要求系统表现得像只有一份数据。

更直观地说:

一次写入成功后,后续读一定能读到这次写入。

例如:

metadata server 更新 bg_1 location = node_b,epoch=11。
之后任何客户端查询 bg_1,都应该看到 node_b 和 epoch=11。

强一致性的优点是语义简单,系统更容易推理。

缺点是性能和可用性成本更高。

强一致性适合:

owner
epoch
state
primary_replica
ref_count
pin_count
资源配额
任务归属

这些状态如果读到旧值,可能导致错误行为。

最终一致性

最终一致性允许短时间内不同副本不一致,但如果没有新的写入,最终会收敛到相同状态。

例如:

node_a 记录 bg_1 access_count = 100。
node_b 暂时记录 access_count = 90。
后台同步后,两个节点最终合并成一致结果。

最终一致性适合:

访问统计。
热度估计。
监控指标。
非关键副本。
可以重算的 cache。
异步预热数据。

最终一致性的问题是:应用层必须能接受短时间不一致。

例如访问热度晚一点同步,通常没问题;但 epoch 晚一点同步,就可能出大问题。

读写一致性

除了强一致和最终一致,还有一些更细的读写语义。

Read Your Writes

自己写入后,自己后续一定能读到。

例如:

worker pin(bg_1, request_id=req_1) 成功。
worker 后续查询 bg_1,必须看到 req_1 已经 pin 住。

如果做不到,worker 可能刚 pin 完,下一步又看到 pin_count=0,导致错误淘汰。

Monotonic Reads

同一个客户端不会先读到新值,再读到旧值。

例如:

第一次读到 bg_1 epoch=11。
第二次不应该又读到 epoch=10。

否则客户端状态会倒退。

Causal Consistency

有因果关系的操作,要按因果顺序可见。

例如:

先完成迁移 bg_1: node_a -> node_b。
再释放 node_a 旧副本。

客户端不能先看到旧副本释放,再看到迁移完成,否则会出现短时间不可读。

Quorum

Quorum 是一种常见的多副本读写方式。

假设有 N 个副本:

W:写入需要成功的副本数。
R:读取需要读取的副本数。

如果满足:

R + W > N

那么读集合和写集合一定有交集,理论上可以读到最新写入。

例如:

N = 3
W = 2
R = 2
R + W = 4 > 3

写入至少写 2 个副本,读取至少读 2 个副本,这两个集合一定重叠。

Quorum 的代价

Quorum 能提高一致性,但也有代价:

读写延迟更高。
需要处理多个副本返回不同版本。
慢副本会影响尾延迟。
实现复杂度更高。

在 KV Cache 场景中,Quorum 更适合元数据或关键状态,不一定适合大块 KV 数据。

因为 KV 数据很大,如果每次读取都从多个副本读并比较,成本太高。

旧读、脏读和写冲突

副本系统常见问题包括旧读、脏读和写冲突。

旧读

旧读是指读到了过期数据。

例如:

bg_1 已经迁移到 node_b,epoch=11。
客户端从 replica 读到旧 location=node_a,epoch=10。

解决方式:

读请求携带 epoch。
服务端拒绝旧 epoch。
客户端收到 STALE_METADATA 后刷新。

脏读

脏读是指读到了未提交或未完成的数据。

例如:

bg_1 正在 MIGRATING_IN。
目标节点数据只复制了一半。
客户端直接从目标节点读取。

这会读到不完整数据。

解决方式:

中间态不可读。
目标节点校验完成后再切换状态。
读取前检查 state。

写冲突

写冲突是指多个节点同时修改同一份状态。

例如:

scheduler A 发起迁移 bg_1。
scheduler B 同时发起淘汰 bg_1。

解决方式:

状态机限制合法转移。
CAS 检查 state 和 epoch。
只有 owner 可以写。
元数据服务集中处理关键状态更新。

KV Cache 中哪些需要强一致?

在 KV Cache 系统里,下面这些状态通常需要强一致或接近强一致:

block_group_id -> owner
block_group_id -> epoch
block_group_id -> state
block_group_id -> primary_replica
block_group_id -> location
block_group_id -> ref_count
block_group_id -> pin_count
request_id -> active_block_group_list

原因是这些状态会影响正确性。

例如:

owner 错了:旧 owner 可能继续写。
epoch 错了:旧请求可能覆盖新状态。
state 错了:正在迁移的数据可能被读或被删。
location 错了:客户端访问旧位置。
ref_count 错了:正在使用的数据可能被释放。
pin_count 错了:正在使用的数据可能被淘汰。

这些状态不适合随便最终一致。

哪些可以最终一致?

下面这些数据可以更偏最终一致:

access_count
last_access_time
cache_hit_count
cache_miss_count
eviction_count
migration_bytes
replica_health_score
预热副本进度
非关键只读副本列表

这些数据即使短时间不准确,通常也不会破坏请求正确性。

例如:

access_count 晚一点同步,最多影响淘汰策略。
cache_hit_count 晚一点同步,最多影响监控展示。
预热副本进度短暂不一致,最多影响是否提前使用副本。

但要注意:最终一致的数据也可能影响策略。如果策略过度依赖不准确数据,也会导致性能问题。

BlockGroup 副本设计示例

一个 BlockGroup 的副本元数据可以设计成:

BlockGroupReplicaMetadata {
    block_group_id
    epoch
    state
    primary_replica
    replica_list
    committed_version
    replica_versions
    checksum
}

例如:

block_group_id = bg_1
epoch = 12
state = LOCAL
primary_replica = node_b
replica_list = [node_b, node_c, node_d]
committed_version = 8
replica_versions = {
    node_b: 8,
    node_c: 8,
    node_d: 7
}
checksum = xxx

这里可以看到:

node_b 和 node_c 已经是最新版本。
node_d 落后一版。

如果读请求要求强一致,就不能直接读 node_d

如果读请求只是访问可重算的非关键 cache,可能允许读 node_d,但要做好版本校验。

副本读写路径

写路径

以更新 BlockGroup location 为例:

1. client 发起 update_location(bg_1, node_b, epoch=12)。
2. metadata primary 检查 epoch 和 owner。
3. metadata primary 写本地日志。
4. metadata primary 同步到多数派副本。
5. 多数派确认后提交。
6. 返回 client success。

这条路径更偏强一致。

读路径

读元数据可以有不同策略:

强一致读:读 primary 或读 quorum。
快速读:读本地缓存或 follower。
带校验读:读缓存,但请求实际数据时带 epoch。

在性能敏感系统里,常见做法是:

读路径尽量快。
写路径保证关键状态正确。
读请求携带 epoch 做兜底校验。

这样可以在性能和正确性之间取得平衡。

副本落后怎么办?

副本落后是常态。

原因可能包括:

网络抖动。
副本节点负载高。
复制队列堆积。
节点短暂不可用。
磁盘或远端存储变慢。

系统需要维护副本状态:

HEALTHY
LAGGING
REBUILDING
UNAVAILABLE

如果副本落后:

不把它作为强一致读对象。
不把它提升为 primary。
后台补齐缺失数据。
必要时从 replica_list 中移除。

对于 KV Cache 数据副本,还要校验:

version
size_bytes
checksum
token_range
layer_id

否则可能读到错的数据。

Primary 故障怎么办?

如果 primary 挂了,系统需要选出新的 primary。

关键问题是:

谁有资格成为新的 primary?
新的 primary 是否拥有最新数据?
旧 primary 恢复后如何处理?

一个保守规则是:

只有拥有最新 committed_version 的副本才能成为 primary。
primary 切换时 epoch 必须递增。
旧 primary 恢复后,必须先向 metadata server 确认当前 epoch。
旧 epoch 的写入必须被拒绝。

例如:

node_a 是 primary,epoch=10。
node_a 故障。
node_b 被提升为 primary,epoch=11。
node_a 恢复。
node_a 携带 epoch=10 写入,被拒绝。

这和前面学习的 fencing 是同一个思想。

副本和迁移的关系

迁移和副本很像,但目标不同。

副本:

为了高可用、读扩展和容错,保留多份数据。

迁移:

为了改变数据位置,通常最终只保留目标位置或新的副本集合。

但迁移过程中经常会临时产生副本。

例如:

bg_1 从 node_a 迁移到 node_b。
复制完成但路由未切换前,node_a 和 node_b 都有数据。
路由切换后,node_b 成为 primary。
node_a 延迟释放旧副本。

这段时间内,系统必须明确:

谁是 primary?
哪个 epoch 最新?
读请求应该读哪里?
旧副本什么时候能删?

否则迁移很容易变成副本一致性问题。

副本和淘汰的关系

如果有副本,淘汰也不能只看本地。

例如:

node_a 想淘汰 bg_1。
metadata 里记录 bg_1 有 3 个副本。
node_a 是 primary。

如果 node_a 直接删除,可能导致 primary 消失。

淘汰前应该检查:

这个副本是不是 primary?
删除后副本数是否仍满足要求?
是否还有健康副本?
metadata 是否先更新?
是否有请求正在读取?

如果要删除 primary,通常需要先切主:

node_a primary -> node_b primary
epoch += 1
确认 node_b 可用
再删除 node_a 副本

常见错误设计

把所有副本都当成一样

错误做法:

replica_list = [node_a, node_b, node_c]
任意节点都可以读写。

问题是写冲突很难处理。

更好的方式:

明确 primary。
只有 primary 或元数据服务能处理关键写入。
replica 只负责读或复制。

从落后副本读取关键状态

错误做法:

从任意 replica 读取 bg_1 epoch。

问题是可能读到旧 epoch。

更好的方式:

关键状态读 primary、quorum,或者读后做 epoch 校验。

元数据最终一致

错误做法:

owner、epoch、state 通过异步同步,短时间不一致也接受。

问题是这些状态影响正确性。

更好的方式:

关键元数据强一致。
非关键统计指标最终一致。

副本修复不校验版本

错误做法:

发现副本缺失,直接从任意节点拷贝一份。

问题是可能拷贝旧版本。

更好的方式:

修复时校验 epoch、version、checksum 和 committed_version。

primary 切换不递增 epoch

错误做法:

node_a primary -> node_b primary,但 epoch 不变。

问题是旧 primary 恢复后无法识别自己已经过期。

更好的方式:

每次 primary/owner 变化,epoch 必须递增。

如何排查副本和一致性问题?

日志字段

日志中最好带上:

request_id
operation_id
block_group_id
epoch
version
primary_replica
replica_id
replica_state
committed_version
read_version
write_version
checksum
error_code

指标

可以关注:

replica_lag
replica_lag_bytes
replica_lag_seconds
replica_rebuild_count
replica_rebuild_latency
primary_failover_count
stale_read_count
stale_epoch_reject_count
quorum_read_latency
quorum_write_latency
replication_queue_length
replication_error_count

排查问题

遇到读错、读不到、迁移异常时,可以问:

客户端读的是 primary 还是 replica?
这个 replica 的 version 是多少?
metadata 的 committed_version 是多少?
客户端携带的 epoch 是多少?
服务端当前 epoch 是多少?
副本是否处于 LAGGING 或 REBUILDING?
primary 是否发生过切换?
旧 primary 是否有写入被拒绝?
读取的数据 checksum 是否匹配?

面向 KV Cache 的检查清单

看组内代码或设计文档时,可以检查:

1. 哪些数据有副本?
2. 副本是为了高可用、读扩展,还是迁移缓冲?
3. 是否区分 KV 数据和元数据?
4. 哪些元数据必须强一致?
5. 哪些指标或统计可以最终一致?
6. 是否有 primary_replica?
7. 只有谁能写关键状态?
8. replica 落后时是否还能对外读?
9. 读 replica 时是否校验 epoch/version?
10. primary 故障后如何选新 primary?
11. primary 切换是否递增 epoch?
12. 旧 primary 恢复后写入是否会被拒绝?
13. 迁移过程中的临时副本什么时候释放?
14. 淘汰副本前是否检查副本数和 primary 身份?
15. 副本修复是否校验 checksum?

推荐学习顺序

这一组可以按下面顺序学:

1. 副本的目的:高可用、读扩展、故障恢复。
2. 主从副本:理解 primary 和 replica。
3. 同步复制:理解一致性和写延迟。
4. 异步复制:理解性能和落后副本。
5. 强一致性:理解关键状态为什么不能错。
6. 最终一致性:理解统计和非关键副本为什么可以放松。
7. 读写一致性:理解 Read Your Writes、Monotonic Reads。
8. Quorum:理解 R/W/N 的基本关系。
9. 旧读、脏读、写冲突:理解副本系统常见错误。
10. KV Cache 中区分数据和元数据。
11. BlockGroup 副本元数据设计。
12. primary 故障和 epoch/fencing。
13. 副本、迁移和淘汰之间的关系。

最后要能回答下面的问题:

这个字段如果读到旧值,会不会影响正确性?
这个副本落后时,还能不能提供读取?
这个写操作应该写几个副本才算成功?
primary 挂了,哪个副本可以接管?
旧 primary 恢复后,如何防止它继续写?

总结

副本解决的是可用性、读扩展和故障恢复问题;一致性解决的是多个副本之间如何保持正确语义的问题。

对 KV Cache 和 BlockGroup 来说,最重要的是区分:

KV 数据本身:很多时候是不可变、可重算、可异步复制的。
元数据状态:owner、epoch、state、location、ref_count、pin_count 通常必须更严格。

核心判断标准是:

如果这个字段短时间不一致,会不会导致读错、写错、误删或旧 owner 写入?

如果会,就应该偏强一致;如果只是影响统计、热度或策略精度,可以考虑最终一致。

掌握副本和一致性之后,再继续学习故障处理、调度和负载均衡,会更容易理解系统为什么要设计 heartbeat、failure detector、failover、repair task 和降级策略。