Mooncake Store 状态管理:副本生命周期、租约和本地热缓存

沿着副本状态机、元数据租约和本地热缓存的校验令牌,说明 Mooncake Store 如何维护读写可见性并防止旧写覆盖。

Code walkthroughsMooncake and HiCache internalsLLM servingKV cacheMooncakeDistributed systems

Mooncake Store 的主路径是 Put/Get,但真正容易出问题的是状态管理。大对象缓存系统最怕的不是“没有数据”,而是“读到一份看起来存在但其实不该读的数据”。

这篇只看三个状态边界:

text
副本生命周期
元数据租约
本地热缓存的世代号或令牌

副本的生命周期

副本不是一创建就可读,而是要经过一套状态机:

text
INITIALIZED
  -> PROCESSING
       -> COMPLETE
       -> REMOVED / FAILED

state flow

副本状态决定读路径能不能使用这份数据

Transfer Engine 传输成功只是数据传输结果;Store 读路径只应使用已经提交为 COMPLETE 的副本。

INITIALIZED

Master 分配元数据位置,此时尚未写入数据

PutStart

PROCESSING

Client 正在写入数据,Get 不能读取

PutEnd

COMPLETE

元数据已经提交,可以进入 GetReplicaList

淘汰或移除

REMOVED / FAILED

不再参与全局可读事实

写入时,Master 先为对象分配副本。此时副本只有位置,数据尚未写完。Client 完成数据面传输后,PutEnd 才把副本提交为 COMPLETE

读路径只应该使用处于 COMPLETE 状态的副本。这条规则看起来简单,却是 Store 保证读取正确性的核心。

PROCESSING 的意义

如果没有 PROCESSING 状态,PutStart 后元数据中会立刻出现新副本,Get 就可能读到尚未写完的数据。

PROCESSING 把写入拆成两步:

text
控制面:
  我预留了一个位置

数据面:
  我把数据写过去了

提交点:
  我确认这份副本可读

这个中间状态允许 Store 处理部分失败。例如批量写入 100 个对象,其中 3 个远端传输失败,Master 就不应该把这 3 个对象也标记为 COMPLETE。

租约的作用

Master 返回副本列表时,会给 Client 一份租约。租约表示这份元数据视图只能在有限时间内使用。

租约不是为了提升性能而添加的装饰字段,它限制了 Client 复用旧元数据的时间。

没有租约,Client 可能一直沿用很早以前查到的副本地址,即使 Segment 已经卸载、副本已经删除或 Master 已经完成故障切换。这类问题很难复现,在分布式缓存里却非常危险。

因此读路径应该理解成:

text
GetReplicaList
  -> COMPLETE 副本和租约
  -> Client 在租约有效期内执行数据面读取
  -> 租约过期后重新查询

本地热缓存的边界

本地热缓存是 Client 侧的加速层,可以把常用对象保留在本地内存中,减少远端读取。

但它不能绕过 Master 元数据。本地热缓存的准确定位是:

text
全局可读性:
  Master + 副本状态 + 租约

本地加速:
  热缓存

如果一个对象仍在本地热缓存中,但 Master 已经认为它不可读,就不能仅凭本地缓存命中直接返回。热缓存只能加速已经确认可读的数据路径。

写入令牌防止旧写覆盖新写

本地热缓存里常见的风险是异步写入乱序。

想象两个 put 几乎同时发生:

text
Put A 获取数据块,准备写入 key=x
Put B 随后也写入 key=x
Put B 先完成
Put A 后完成

如果没有写入令牌或版本号保护,A 可能把旧内容覆盖到热缓存中,导致后续读取命中过期数据。

Mooncake 的热缓存会使用类似写入令牌的机制:写入前先获取令牌,真正放入缓存时再检查它是否仍然有效。这样,过期写入就不会覆盖新状态。

缓存淘汰与可见性

Store 中的对象可能被淘汰或移除。对于远端缓存,淘汰不只是释放内存,还会影响元数据可见性。

应该区分:

text
数据内容:
  某段 Segment 上的内容

元数据:
  对象 -> 副本的可读关系

本地缓存:
  Client 侧的加速副本

只有元数据承认的 COMPLETE 副本才是全局读取路径的一部分。字节仍然留在某块内存中,并不代表读取方可以使用。

和 HiCache 的关系

SGLang HiCache 只需要知道某个逻辑页是否可用。Mooncake Store 则要在内部保证这一页对应的所有对象都满足可读条件。

例如,一个 MHA 页对应 K/V 两个对象:

text
page_k:COMPLETE + 租约有效 + 传输成功
page_v:COMPLETE + 租约有效 + 传输成功

只有两个对象都成功,HiCache 才能把这个逻辑页视为命中。

这就是 Mooncake 后端需要按缓存页汇总各对象结果的原因。上层不应该看到“半页命中”。

排障时看什么

如果怀疑 Mooncake Store 读到了旧数据或出现异常未命中,可以先按状态分层排查:

  • PutStart 是否成功但 PutEnd 没有成功。
  • 副本是否仍处于 PROCESSING。
  • GetReplicaList 返回的副本是否为 COMPLETE。
  • 租约是否过期。
  • 本地热缓存是否命中过期内容。
  • K/V 对象是否只有部分存在。
  • 如果使用的 Mooncake 包支持 group_ids,再看分组语义是否让一组对象拥有一致的管理边界。

不要一上来只查传输层。传输成功只能证明数据搬过了,不能证明这份数据应该被读取。

排查可见性时看哪里

  • PROCESSINGCOMPLETE 的差别为什么是可见性边界?
  • 为什么不能省略租约?
  • 本地热缓存为什么不能绕过 Master 元数据?
  • 写入令牌防止的是哪类乱序?
  • 为什么 HiCache 缓存页命中要求多个 Store 对象都成功?