QuanZhou's Wiki

分布式基础:状态机、分片、路由与元数据

~/ 分布式#状态机#并发#元数据#KV Cache

背景

前面学习了两组基础:

第一组:操作系统、网络、并发与存储。
第二组:RPC 和远程调用。

这两组解决的是底层问题:系统为什么会慢、远程调用为什么会失败、为什么接口要考虑 timeoutretry 和幂等。

接下来这一组更贴近 KV Cache 系统本身:

第三组:并发状态和状态机。
第四组:分片、路由和元数据。

之所以把这两组放在一起,是因为它们在真实系统里经常不能分开看。

BlockGroup 为例,它不仅是一组 KV Cache block 的集合,还是一个有生命周期、有位置、有 owner、有版本的分布式对象。系统不仅要知道它是什么状态,还要知道它在哪里、谁能修改它、客户端看到的位置是不是最新的。

这一篇的目标是:能把一个分布式对象的生命周期讲清楚,并理解一个请求如何找到它需要的 KV Cache。

学习目标

这一组需要掌握:

状态机
状态转移
并发读写
CAS

版本号
epoch
引用计数
pin/unpin
哈希分片
一致性哈希
Range 分片
路由表
元数据服务
本地元数据缓存
元数据失效

放到 KV Cache 场景里,最核心的问题是:

这个 block group 当前在哪里?
它现在是什么状态?
谁拥有它?
谁能修改它?
它能不能读?
它能不能迁移?
它能不能淘汰?
客户端看到的位置是不是旧的?
迁移过程中路由怎么更新?

这些问题的答案,最终都会落到状态机和元数据设计上。

为什么要先学状态机?

分布式系统里很多 bug 都不是算法 bug,而是状态 bug。

例如:

一个 block group 正在迁移,另一个线程把它淘汰了。
一个请求正在读取 cache,后台任务释放了这块内存。
源节点认为迁移失败,目标节点认为迁移成功。
客户端拿着旧路由继续访问旧 owner。
旧 owner 恢复后继续写状态,覆盖了新 owner 的结果。

这些问题的共同点是:对象状态没有定义清楚,状态转移没有被保护。

所以学习分布式对象时,第一步不是先看它怎么存,而是先问:

这个对象有哪些状态?
状态之间怎么转移?
哪些状态允许读?
哪些状态允许写?
哪些状态允许迁移?
哪些状态允许淘汰?
失败后进入什么状态?
谁有权改变状态?

BlockGroup 是一个状态对象

在 KV Cache 系统里,Block 通常表示一段连续 token 的 K/V 数据,BlockGroup 则是一组访问模式相近、生命周期相近的 block。

可以简单理解为:

Block:KV Cache 的基本存储单元。
BlockGroup:调度、迁移、淘汰和生命周期管理的逻辑单位。

一个 BlockGroup 至少包含两类信息:

数据本身:一组 block 的 K/V 内容。
元数据:位置、状态、epoch、ref_count、pin_count、owner 等。

很多时候,KV 数据本身生成后不再修改,可以近似看作 immutable;但元数据会频繁变化。

例如:

block_group_id = bg_1
location = node_a / gpu_0 / HBM
state = LOCAL
epoch = 10
ref_count = 2
pin_count = 1
owner = worker_a

系统真正复杂的地方,往往在这些元数据状态上。

BlockGroup 的状态机

一个简化的 BlockGroup 状态机可以是:

CREATED
LOCAL
PINNED
MIGRATING_OUT
MIGRATING_IN
REMOTE
EVICTING
EVICTED
FAILED

这些状态的含义可以这样理解:

CREATED:元数据已创建,但数据可能还未完全写入。
LOCAL:数据在本地,可以正常访问。
PINNED:正在被请求使用,不能淘汰,通常也不能随意迁移。
MIGRATING_OUT:正在从本节点迁出。
MIGRATING_IN:正在迁入本节点。
REMOTE:数据不在本地,需要远程访问或拉取。
EVICTING:正在淘汰或释放。
EVICTED:已经被淘汰,不再可读。
FAILED:进入异常状态,需要后台修复或重新计算。

状态机的价值在于:它把系统行为变成明确规则。

例如:

LOCAL 可以读。
PINNED 可以读,但不能淘汰。
MIGRATING_OUT 是否可以读,要看系统策略。
MIGRATING_IN 通常不能直接读,除非迁入完成。
EVICTING 通常不能发起新读。
EVICTED 不能读,只能 miss、重算或从远端恢复。
FAILED 不能直接使用,需要修复。

状态转移

有了状态,还要定义状态之间允许怎么转移。

例如:

CREATED -> LOCAL
LOCAL -> PINNED
PINNED -> LOCAL
LOCAL -> MIGRATING_OUT
MIGRATING_OUT -> REMOTE
REMOTE -> MIGRATING_IN
MIGRATING_IN -> LOCAL
LOCAL -> EVICTING
EVICTING -> EVICTED
任意中间态 -> FAILED
FAILED -> LOCAL
FAILED -> EVICTED

不是所有转移都应该被允许。

例如:

PINNED -> EVICTED 通常不应该允许。
EVICTED -> LOCAL 需要重新加载或重新计算,不能直接改状态。
MIGRATING_OUT -> EVICTING 需要非常小心,否则可能迁移和释放冲突。
REMOTE -> EVICTED 要确认本地是否只是元数据引用,不能误删远端数据。

状态转移最好集中管理,而不是散落在各个函数里。

例如可以抽象成:

transition(block_group_id, from_state, to_state, epoch)

服务端检查:

当前 state 是否等于 from_state?
当前 epoch 是否匹配?
from_state -> to_state 是否被允许?
调用方是否是当前 owner?

如果检查失败,就拒绝更新。

哪些状态可以读?

读能力可以按状态定义:

CREATED:通常不能读,数据还不完整。
LOCAL:可以读。
PINNED:可以读。
MIGRATING_OUT:可选,可以读源,也可以要求刷新路由。
MIGRATING_IN:通常不能读,除非迁入完成。
REMOTE:不能本地读,需要远程读或拉取。
EVICTING:通常拒绝新读。
EVICTED:不能读。
FAILED:不能读,需要修复或重算。

这里没有唯一标准,取决于系统设计。

例如 MIGRATING_OUT 有两种策略:

策略一:迁移期间仍允许从源节点读,迁移完成后切换路由。
策略二:迁移期间拒绝新读,让客户端等待或刷新路由。

策略一可用性更好,但源节点释放旧副本要更谨慎。策略二状态更简单,但可能增加请求延迟。

哪些状态可以迁移?

迁移通常只应该从稳定状态发起。

比较安全的规则是:

LOCAL 且 pin_count == 0:可以迁移。
REMOTE:本地没有数据,不需要迁出,但可以发起拉取。
PINNED:不迁移,避免影响正在使用的请求。
EVICTING / EVICTED:不迁移。
FAILED:先修复,再决定是否迁移。

实际系统可能允许 pinned 数据迁移,但需要更复杂的协议,例如读写双路径、引用转移、迁移期间冻结状态等。

初学阶段可以先掌握保守设计:正在使用的数据不迁移,正在迁移的数据不淘汰。

哪些状态可以淘汰?

淘汰比迁移更危险,因为淘汰意味着释放数据。

基本规则可以是:

LOCAL 且 ref_count == 0 且 pin_count == 0:可以淘汰。
REMOTE:本地没有数据,可以释放本地占位或元数据缓存。
PINNED:不能淘汰。
MIGRATING_OUT / MIGRATING_IN:不能直接淘汰。
EVICTING:已经在淘汰中,不重复发起。
EVICTED:已经淘汰,重复 evict 应该返回成功。
FAILED:由修复任务决定释放还是恢复。

注意最后一点:EVICTED 再次收到 evict 请求时,最好返回成功。这是幂等设计。

引用计数 ref_count

ref_count 表示当前有多少使用者正在引用这个 block group。

例如:

decode 请求开始读取 block group:ref_count += 1
decode 请求读取结束:ref_count -= 1

规则通常是:

ref_count > 0:不能释放。
ref_count == 0:可以考虑淘汰,但还要看 pin_count 和状态。

ref_count 必须原子更新,否则可能出现读释放冲突。

典型错误:

线程 A 准备读取 bg_1。
线程 B 看到 ref_count == 0,开始释放 bg_1。
线程 A 还没来得及增加 ref_count,就读到了已释放的数据。

所以读请求通常要先通过一个受保护的流程获取引用:

1. 加锁或 CAS 检查 state。
2. 如果 state 允许读,ref_count += 1。
3. 解锁。
4. 执行读取。
5. 读取完成后 ref_count -= 1。

pin/unpin

pin 表示把某个 block group 固定住,防止它被淘汰,通常也防止它被迁移。

它适合这些场景:

一个请求即将进入 decode,需要保证相关 KV Cache 不被释放。
迁移任务需要确保源数据在复制完成前不被淘汰。
预取任务希望短时间保留一批热数据。

pin/unpin 不能简单设计成:

pin_count += 1
pin_count -= 1

因为 RPC 可能超时重试,重复执行会导致计数错误。

更好的方式是:

pin(block_group_id, request_id, epoch)
unpin(block_group_id, request_id, epoch)

服务端维护:

block_group_id -> pinned_request_set

这样同一个 request_id 重复 pin 只算一次,重复 unpin 也只释放一次。

CAS

CAS 是 Compare-And-Swap,比较并交换。

它的语义可以理解为:

如果当前值等于 expected,就把它改成 new_value。
否则修改失败。

在状态机里,CAS 很适合做条件更新。

例如:

CAS(state, LOCAL, MIGRATING_OUT)

含义是:

只有当前 state 仍然是 LOCAL 时,才能改成 MIGRATING_OUT。
如果已经被其他线程改成 PINNED 或 EVICTING,则迁移失败。

但分布式场景里,只比较 state 还不够,通常还要比较 epoch:

CAS((state, epoch), (LOCAL, 10), (MIGRATING_OUT, 11))

这样可以避免旧请求覆盖新状态。

版本号和 epoch

versionepoch 用来表示状态版本。

每次关键状态变化时,epoch 递增:

LOCAL(epoch=10)
-> MIGRATING_OUT(epoch=11)
-> REMOTE(epoch=12)

客户端发起写操作时必须携带自己看到的 epoch:

evict(block_group_id=bg_1, epoch=10)

服务端检查当前 epoch:

如果 current_epoch != request_epoch,拒绝请求。

这样可以防止旧客户端、旧 owner、旧迁移任务继续写入。

旧 owner 恢复后还能不能继续写?

不能直接继续写。

这是分布式系统里非常关键的问题。

例如:

1. worker A 是 bg_1 的 owner,epoch=10。
2. A 因为网络抖动和集群失联。
3. 系统把 bg_1 的 owner 转移给 worker B,epoch=11。
4. A 恢复后,以为自己还是 owner,继续写 bg_1 状态。

如果没有 epoch 或 fencing token,A 可能覆盖 B 的新状态。

正确做法是:

每次 owner 变化,epoch 递增。
所有写操作必须携带 epoch。
元数据服务只接受当前 epoch 的写入。
旧 epoch 的写入直接拒绝。

这就是 fencing 的基本思想。

迁移失败怎么办?

迁移是长操作,失败是常态。

例如迁移流程:

1. bg_1: LOCAL(epoch=10)
2. 源节点设置 MIGRATING_OUT(epoch=11)
3. 目标节点设置 MIGRATING_IN(epoch=11)
4. 拷贝数据
5. 校验数据
6. 更新 location
7. 源节点释放旧副本

失败可能发生在任何一步:

源节点设置状态后崩溃。
目标节点空间不足。
数据拷贝到一半网络断开。
目标校验失败。
元数据更新成功,但源节点释放失败。
客户端仍然持有旧路由。

一种保守处理方式是:

迁移开始前记录 operation_id。
迁移状态写入元数据。
源副本延迟删除。
目标校验成功后再更新 location。
客户端发现 epoch 不匹配时刷新元数据。
后台 repair 任务扫描 MIGRATING 状态并修复。

中间态不能无限停留。系统需要后台任务处理这些状态:

MIGRATING_OUT 超时:回滚到 LOCAL,或继续完成迁移。
MIGRATING_IN 超时:清理目标临时数据,或重新拉取。
元数据和实际数据不一致:以元数据 epoch 为准做修复。

淘汰过程中又被访问怎么办?

淘汰过程中被访问也很常见。

例如:

1. bg_1 ref_count=0,pin_count=0,被选中淘汰。
2. 状态从 LOCAL 变成 EVICTING。
3. 一个新的 decode 请求需要读取 bg_1。

这时有几种策略:

策略一:拒绝新读,让请求 miss 或重新计算。
策略二:取消淘汰,把状态改回 LOCAL。
策略三:等待淘汰完成,再从下一级存储拉回。

哪种策略更好,取决于系统目标。

在线推理通常更关注延迟,所以如果淘汰还没真正释放数据,取消淘汰可能更合适。但这要求淘汰状态机能支持回滚。

一个保守状态转移可以是:

LOCAL -> EVICTING
EVICTING -> LOCAL
EVICTING -> EVICTED

但前提是:

数据还没释放时才能回滚。
数据一旦释放,只能进入 EVICTED。
回滚和释放必须互斥。

为什么要学分片?

当数据放不下一台机器,就要分片。

分片要解决的问题是:

给定一个 key,应该去哪个节点找数据?

在 KV Cache 系统中,这个 key 可能是:

block_id
block_group_id
request_id
session_id
model_id
layer_id
token_range

分片策略决定了数据如何分布,也决定了负载、迁移成本和故障影响面。

哈希分片

最简单的方式是:

node = hash(key) % N

优点:

实现简单。
数据分布相对均匀。
查询路径清晰。

缺点:

节点数量 N 改变时,大量 key 会重新映射。
扩容或缩容会触发大量迁移。
不容易表达物理拓扑和负载差异。

如果用 block_group_id 做哈希分片:

node = hash(block_group_id) % N

系统可以快速找到某个 group 的默认归属节点。但如果节点扩容,很多 group 的位置都可能变化。

一致性哈希

一致性哈希把 key 和节点都映射到一个环上。key 顺时针找到的第一个节点,就是它的归属节点。

它的优点是:

增加或删除节点时,只影响环上的一小段 key。
比 hash(key) % N 更适合缓存系统扩缩容。
可以通过虚拟节点改善负载均衡。

它适合这些场景:

cache 节点会动态扩缩容。
允许少量数据迁移。
希望路由计算比较简单。

但一致性哈希也不是万能的。

在 KV Cache 场景中,如果某些 block group 特别大或特别热,单纯靠一致性哈希仍然可能产生热点。

所以还需要结合:

节点负载。
GPU HBM 余量。
网络拓扑。
请求局部性。
block group 热度。
迁移成本。

Range 分片

Range 分片按照 key 的范围切分。

例如:

[0, 1000)      -> node A
[1000, 2000)   -> node B
[2000, 3000)   -> node C

优点:

适合范围查询。
方便按时间、token range、sequence range 组织数据。
有利于顺序扫描和批量迁移。

缺点:

容易产生热点。
范围切分不均时,部分节点压力过大。
需要 split/merge 机制维护范围。

KV Cache 中,如果按 token range 组织 block group,Range 分片可以让连续 token 的 cache 更容易放在一起。

但如果某些长上下文请求特别多,相关 range 可能成为热点。

分片维度怎么选?

KV Cache 的分片维度不是固定的。

可以按:

按 request/session 分片:同一个请求的数据尽量放一起。
按 block_group_id 分片:实现简单,分布较均匀。
按 layer 分片:配合 pipeline parallelism 或模型层级调度。
按 model_id 分片:隔离不同模型。
按租户分片:做资源隔离。
按 token range 分片:保留连续访问局部性。

不同维度的取舍:

按请求分片:局部性好,但长请求可能很大。
按 block_group 分片:负载均衡较好,但一次请求可能跨多个节点。
按 layer 分片:适合模型并行,但路由更复杂。
按模型分片:隔离性好,但热门模型容易形成热点。
按 token range 分片:顺序访问友好,但需要处理长序列热点。

一个好的分片策略要同时考虑:

访问局部性。
负载均衡。
迁移成本。
故障影响面。
元数据复杂度。
调度策略。

路由表

分片之后,系统需要知道数据在哪里。这个信息通常通过路由表表达。

一个简化路由表可以是:

block_group_id -> node_id
block_group_id -> device_id
block_group_id -> memory_tier
block_group_id -> epoch
block_group_id -> state

例如:

bg_1 -> node_a / gpu_0 / HBM / epoch=10 / LOCAL
bg_2 -> node_b / cpu_memory / epoch=7 / REMOTE
bg_3 -> node_c / ssd / epoch=3 / EVICTED

客户端访问 bg_1 时,先查路由:

1. bg_1 在 node_a。
2. 数据在 gpu_0 HBM。
3. 当前 epoch=10。
4. 当前状态 LOCAL,可以读。

然后客户端发起读取:

get_block_group(bg_1, epoch=10)

如果服务端发现当前 epoch 已经不是 10,就说明客户端路由过期,需要刷新元数据。

元数据服务

元数据服务维护系统的事实来源。

在 KV Cache 中,元数据可能包括:

block_id -> block_group_id
block_group_id -> node_id
block_group_id -> device_id
block_group_id -> memory_tier
block_group_id -> state
block_group_id -> epoch
block_group_id -> owner
block_group_id -> ref_count
block_group_id -> pin_count
request_id -> block_group_list

它回答的问题是:

数据在哪里?
数据现在是什么状态?
谁是 owner?
当前版本是多少?
这个请求需要哪些 block group?

元数据服务需要更强的一致性,因为它决定系统行为。

例如:

block group 当前 owner 是谁。
block group 当前 epoch 是多少。
block group 是否可以被淘汰。
block group 是否正在迁移。

这些状态如果错了,可能导致读错位置、重复迁移、误删 cache 或旧 owner 写入。

本地元数据缓存

每次访问都查元数据服务会很慢,所以客户端通常会缓存元数据。

例如 worker 本地缓存:

bg_1 -> node_a / gpu_0 / HBM / epoch=10

这样下一次访问 bg_1 时,不用再远程查询 metadata server。

但本地缓存会带来一个问题:

缓存可能过期。

例如:

1. worker 本地缓存 bg_1 在 node_a,epoch=10。
2. bg_1 被迁移到 node_b,epoch=11。
3. worker 继续向 node_a 读取 bg_1。

如果没有校验,worker 就会访问旧位置。

所以本地元数据缓存必须配合:

epoch 校验。
TTL。
主动失效通知。
访问失败后刷新。

元数据失效

元数据失效有几种方式。

TTL

给缓存一个过期时间。

bg_1 route cache TTL = 100ms

优点:

实现简单。
不需要服务端主动通知。

缺点:

TTL 内可能读到旧路由。
TTL 太短会增加 metadata server 压力。
TTL 太长会增加过期风险。

主动失效

元数据变化时,服务端通知客户端删除旧缓存。

例如:

bg_1 location changed: node_a -> node_b
notify clients invalidate bg_1

优点:

旧缓存失效更及时。
可以降低错误路由概率。

缺点:

客户端列表难维护。
通知可能丢失。
通知顺序可能乱。
实现复杂度更高。

版本校验

每次请求都带上 epoch:

get_block_group(bg_1, epoch=10)

服务端检查当前 epoch:

如果 current_epoch == 10,正常处理。
如果 current_epoch > 10,返回 STALE_METADATA。

客户端收到 STALE_METADATA 后重新查元数据。

版本校验是非常关键的兜底机制。即使 TTL 或主动失效没有及时生效,也能防止旧路由继续写状态。

迁移过程中路由怎么更新?

迁移是状态机和元数据最容易出问题的地方。

一种简化流程:

1. bg_1 当前在 node_a,state=LOCAL,epoch=10。
2. 调度器决定迁移到 node_b。
3. 元数据更新为 state=MIGRATING_OUT,epoch=11。
4. node_b 创建临时状态 MIGRATING_IN,epoch=11。
5. node_a 将数据复制到 node_b。
6. node_b 校验数据成功。
7. 元数据更新 location=node_b,state=LOCAL,epoch=12。
8. node_a 延迟释放旧副本。

迁移期间,客户端可能看到不同状态。

如果客户端拿着旧路由访问 node_a:

get_block_group(bg_1, epoch=10)

node_a 可以返回:

STALE_METADATA(current_epoch=11)

客户端重新查询元数据后,发现正在迁移,可以选择:

等待迁移完成。
继续从源节点读。
转向目标节点。
重新计算。

不同系统会有不同策略,但核心原则是:

路由更新必须和 epoch 绑定。
客户端必须能识别旧路由。
源副本不要过早删除。
中间态必须能被后台修复。

数据在哪里?

这是路由和元数据要回答的第一个问题。

一个 block group 可能在:

本地 GPU HBM。
本地 CPU Memory。
本地 SSD。
远端 GPU HBM。
远端 CPU Memory。
远端 SSD。
已经被淘汰。
正在迁移。

所以 location 不应该只是一个简单的 node_id

更完整的 location 可以包括:

node_id
device_id
memory_tier
offset
size_bytes
replica_role

例如:

node_id = node_a
device_id = gpu_0
memory_tier = HBM
offset = 1024MB
size_bytes = 256MB
replica_role = primary

谁说了算?

分布式系统里必须明确事实来源。

对于 BlockGroup 元数据,通常应该有一个权威来源:

metadata server

worker 本地缓存、scheduler 缓存、cache manager 本地索引都只能是副本或缓存。

如果出现冲突,例如:

metadata server 说 bg_1 在 node_b,epoch=12。
node_a 本地还认为 bg_1 在自己这里,epoch=10。

应该以 metadata server 的高版本状态为准。

这也是为什么 epoch 很重要:它让系统能够比较哪个状态更新。

客户端看到的是不是旧位置?

客户端几乎一定可能看到旧位置。

原因包括:

本地缓存未失效。
迁移刚刚完成。
失效通知丢失。
网络延迟导致消息乱序。
客户端长时间没有刷新路由。

所以系统不能假设客户端永远拿到最新位置。

更现实的设计是:

允许客户端缓存路由。
请求携带 epoch。
服务端拒绝旧 epoch 请求。
客户端收到 STALE_METADATA 后刷新。

也就是说,缓存可以过期,但过期缓存不能破坏正确性。

一个请求如何找到 KV Cache?

假设 worker 需要读取 bg_1

路径可能是:

1. worker 查询本地路由缓存。
2. 如果命中,拿到 node_id、device_id、memory_tier、epoch。
3. worker 向对应 cache manager 发起 get_block_group(bg_1, epoch)。
4. cache manager 检查 state 和 epoch。
5. 如果可以读,返回数据或数据通道信息。
6. 如果 epoch 过期,返回 STALE_METADATA。
7. worker 重新查 metadata server。
8. metadata server 返回新 location、state、epoch。
9. worker 根据新状态决定读取、等待、拉取或重算。

这条路径串起了:

本地元数据缓存。
路由表。
元数据服务。
epoch 校验。
状态机。
RPC。
数据读取。

所以一个请求“找到 KV Cache”不是简单 map 查询,而是一套带版本、状态和失败处理的流程。

一个简化的 BlockGroup 元数据结构

可以用下面的结构帮助理解:

BlockGroupMetadata {
    block_group_id
    model_id
    request_id
    layer_id
    token_range
    block_list

    node_id
    device_id
    memory_tier
    offset
    size_bytes

    state
    epoch
    owner
    ref_count
    pin_count

    last_access_time
    access_count
    priority
}

这里可以分成三类:

身份信息:block_group_id、model_id、request_id、layer_id、token_range。
位置信息:node_id、device_id、memory_tier、offset、size_bytes。
状态信息:state、epoch、owner、ref_count、pin_count。

其中状态信息最需要并发保护,位置信息最容易因为迁移而过期。

常见错误设计

状态没有中间态

错误做法:

LOCAL -> REMOTE

中间没有 MIGRATING_OUTMIGRATING_IN

问题是迁移过程中系统不知道数据处于什么阶段。

更好的方式:

LOCAL -> MIGRATING_OUT -> REMOTE
REMOTE -> MIGRATING_IN -> LOCAL

写状态不带 epoch

错误做法:

set_state(bg_1, EVICTED)

问题是旧 owner 也可以写。

更好的方式:

set_state(bg_1, from_state=EVICTING, to_state=EVICTED, epoch=12)

ref_count 和 pin_count 不是原子更新

错误做法:

if ref_count == 0:
    free(block_group)

但没有锁或 CAS 保护。

问题是另一个线程可能正在增加引用。

更好的方式:

在同一个临界区里检查 state、ref_count、pin_count,并更新状态。

本地缓存路由但不校验 epoch

错误做法:

route = local_cache[bg_1]
send_request(route.node)

问题是路由可能过期。

更好的方式:

route = local_cache[bg_1]
send_request(route.node, bg_1, route.epoch)

服务端校验 epoch,过期则返回 STALE_METADATA

迁移完成后立刻删除源副本

错误做法:

更新元数据到 node_b 后,node_a 立即删除数据。

问题是客户端可能还拿着旧路由访问 node_a。

更保守的方式:

源副本延迟删除。
旧 epoch 请求返回 STALE_METADATA。
一段时间后或确认没有旧引用后再释放。

如何排查状态和路由问题?

状态和路由问题通常需要同时看日志、指标和元数据快照。

日志字段

日志里最好带上:

request_id
operation_id
block_group_id
old_state
new_state
epoch
old_location
new_location
owner
ref_count
pin_count
error_code

否则排查迁移、淘汰和旧路由问题会很困难。

指标

可以关注:

metadata_query_latency
metadata_update_latency
metadata_stale_count
route_cache_hit_rate
route_cache_stale_count
state_transition_failure_count
cas_failure_count
block_group_migrating_count
block_group_evicting_count
stale_epoch_reject_count
repair_task_count

常见排查问题

遇到一次读失败,可以问:

客户端看到的 epoch 是多少?
服务端当前 epoch 是多少?
block group 当前 state 是什么?
客户端访问的是不是旧 location?
是否正在迁移?
ref_count 和 pin_count 是否异常?
是否有旧 owner 写入被拒绝?
是否有后台 repair 任务处理过这个 group?

面向 KV Cache 的检查清单

看组内代码或设计文档时,可以用下面的问题检查:

1. BlockGroup 有哪些状态?
2. 每个状态是否有明确含义?
3. 哪些状态可以读?
4. 哪些状态可以迁移?
5. 哪些状态可以淘汰?
6. 状态转移是否合法校验?
7. 状态更新是否带 epoch?
8. owner 变化时 epoch 是否递增?
9. 旧 epoch 写入是否会被拒绝?
10. ref_count 是否原子更新?
11. pin/unpin 是否按 request_id 幂等?
12. 迁移失败是否有中间态恢复机制?
13. 淘汰过程中被访问是否有明确策略?
14. 分片 key 是什么?
15. 路由表是否包含 state 和 epoch?
16. 本地元数据缓存如何失效?
17. 客户端访问旧位置时如何刷新路由?
18. 元数据服务是不是事实来源?

推荐学习顺序

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

1. 状态机:理解对象生命周期。
2. 状态转移:定义哪些变化合法。
3. 锁和 CAS:保护并发状态更新。
4. ref_count 和 pin/unpin:保护正在使用的数据。
5. version/epoch:防止旧请求和旧 owner 写入。
6. BlockGroup 迁移状态机:理解中间态和失败恢复。
7. BlockGroup 淘汰状态机:理解释放和回滚。
8. 哈希分片:理解最基础的数据分布。
9. 一致性哈希:理解扩缩容时减少迁移。
10. Range 分片:理解范围访问和热点。
11. 路由表:理解请求如何找到数据。
12. 元数据服务:理解谁是事实来源。
13. 本地元数据缓存:理解性能和过期风险。
14. 元数据失效:理解 TTL、主动通知和 epoch 校验。

最后要把这些内容串成一条链路:

BlockGroup 创建
-> 写入元数据
-> 分片决定归属
-> 路由表记录位置
-> 客户端缓存路由
-> 请求携带 epoch 读取
-> 状态机判断是否可读
-> 迁移时更新 state/location/epoch
-> 客户端发现旧路由后刷新
-> ref_count/pin_count 归零后淘汰

如果能把这条链路讲清楚,就说明已经初步理解了分布式对象的生命周期。

总结

并发状态、状态机、分片、路由和元数据,是 KV Cache 系统里非常核心的一组基础。

状态机回答:

这个对象现在能做什么?

元数据回答:

这个对象在哪里?谁拥有它?当前版本是多少?

分片和路由回答:

给定一个 key,应该去哪里找?

对 BlockGroup 来说,最重要的是建立下面几个意识:

1. BlockGroup 是有生命周期的状态对象。
2. 状态转移必须被保护,不能随意修改。
3. ref_count 和 pin_count 决定数据是否能释放或迁移。
4. epoch 用来防止旧 owner 和旧客户端写入。
5. 路由可以缓存,但必须能识别过期。
6. 元数据服务应该是事实来源。
7. 迁移和淘汰都必须有中间态和失败恢复机制。

掌握这些之后,再学习一致性、副本、故障恢复和调度,会更容易理解为什么分布式系统设计总是围绕状态、版本和所有权展开。