前面几篇 Mooncake 统一内存池:AI Vibe Coding 与 Rust, Mooncake 统一内存池:从默认 Evict 到 Linux Reclaim 发出去之后, 收到了不少业界同学的交流和反馈, 线上线下都有不少同学来聊这条线. 所以这篇文章不再只讲某一个点, 而是想从 RL 这个具体场景出发, 系统梳理一下 Mooncake Store 这几轮演进: 为什么先做统一内存池, 为什么又必须做 local master, 为什么 softpin 和 evict 语义都得跟着重写, 以及后面为什么还要补上主动释放 lease 和 fault inject.
背景:先对齐几个关键概念
这篇文章默认读者对开源 Mooncake Store 有基本了解, 但我还是先补一个最小背景, 只讲和本文相关的几件事.
- 本地命中读路径里,
AcquireReplica / ReleaseReplica会保护本地 memory replica 的生命周期. 只要调用方还持有着BufferHandle, 对应 replica 的refcnt就不会归零, 因而不能被真正释放. - 远端读路径里, 调用方会先拿到 replica list, 同时拿到一段 lease. 这段 lease 的意思很朴素: 在 lease 有效期内, 提供这份 replica 的节点要保证这份数据仍然可读.
- 社区 Mooncake 里的
softpin=true, 更接近“这类对象不要驱逐”或者“至少 lease 内整体跳过它”. 这套语义放在 system prompt 一类对象上没什么问题. local master的基本做法我在 之前的总结 里提过: 把MasterService直接嵌进RealClient进程, 让节点本地就有一套控制面权威.unify memory pool的意思则是: 不再让global memory、local buffer、hot cache、以及每个 dummy client 各自的shm分头存在, 而是在节点本地只保留一块统一共享池G.
第一轮演进:统一内存池成为前提
我之前写统一内存池时, 更多还是从“减少本地中转”和“收拢数据面复杂度”的角度讲. 但从最近几次运行经验看, 它已经不只是一个更优雅的架构选择, 而几乎变成了前提条件.
原因很简单: 在我们最近支持的一类新环境里, 由于各种现实约束, 最终某些节点上真正能留给 Mooncake Store 的内存只有大约 30G. (当然我们正尝试把原本不归 Mooncake 管的一些大块本地 buffer 继续往同一资源视图里收=。=) 如果 Mooncake 自己可支配的只有这三十来个 G, 那就根本不可能再像过去那样切成:
过去:
global memory
hot cache
每个 dummy client 各自的 shm
现在:
G
这种切法真正的问题是:
- 每个池子都要预留 headroom, 最后很容易出现 A 池分不出来, B 池却还剩不少的情况.
- 同一个 key 在不同池子之间来回复制, 你看到的是“容量在减少”, 但实际上减少的是很多块彼此隔离的小岛.
- 读 miss、写入、evict、remove 分别盯着不同池子做决策, 根本谈不上统一的本地资源视图.
所以在 RL 下, unify memory pool 不是为了“让代码更好看”, 而是为了让 Mooncake 至少先拥有一块真正能统一调度的本地资源. 如果连本地内存都还是裂开的, 后面所有关于驱逐、回填、恢复、共享访问的讨论都会越来越别扭.
第二轮演进:统一池进一步推导出 local master
但事情走到这里还没完. 很容易产生一种错觉: “既然都统一成一块 G 了, 那不就是 allocator 的事么?” 其实不是.
统一池真正要统一的, 不只是字节本身, 还包括:
- 谁来分配这块内存;
- 谁来维护这块内存上对象的 metadata;
- 谁来决定一个对象现在是可见、不可见、可驱逐、还是只能迁走不能删除;
- 谁来对
refcnt、lease、remove stash 这些生命周期状态负责.
如果 client 侧内存仍然主要由外部 global master 视角管理, 那么统一池就只是在物理层上“长得像一块”, 语义上却仍然是裂开的: allocator 在本地, metadata 的真相在别处, evict 决策和实际内存所有权又不在一个地方. 这种结构下, 你很难让 PutStart、AcquireReplica、LocalRemove、evict、读 miss 回填这些动作共享同一套节点本地状态机.
所以 unify memory pool 的必要性, 实际上又进一步把我们推向了 local master. 只有把 MasterService 放回 RealClient 进程里, 让节点本地自己成为 G 的控制面权威, 统一池才不只是“共享了一块内存”, 而是真正共享了一套状态语义.
换句话说: 不做 local master, unify memory pool 很容易只统一了 bytes, 却没有统一 state.
第三轮演进:softpin 语义需要重写
接下来最关键的一步, 是 softpin 语义.
社区 Mooncake 里, softpin=true 很自然会被理解成“这个 key 不可驱逐”或者“至少当前节点先别碰它”. 这种语义在 system prompt、常驻热点对象这类场景下并没有什么问题. 因为它们本来就是高价值、低变更、希望一直尽量留在本地的对象.
但 RL 下我们用 Mooncake 的方式并不是这样. 在这里, Mooncake 更像一个数据中转站. 典型使用模式往往是:
put
-> get
-> remove
也就是说, 用户并不是希望“这个 key 永远待在当前节点”, 而是希望“在这段生命周期里, 这个 key 不要从系统里消失; 至于它是不是还待在当前节点, 反而没那么重要”.
所以到了 RL 场景, softpin=true 的语义就必须改成:
| 语义 | 社区直觉 | RL 下我们真正需要的 |
|---|---|---|
softpin=true |
当前节点尽量别驱逐它 | 生命周期由用户管理, 可以离开当前节点, 但必须仍然可读回 |
这一步非常关键. 因为只要接受了这个定义, 你就会立刻得到一个结论: softpin 本身不该再是“阻塞当前节点回收”的理由. 它真正阻止的是“让对象语义上消失”, 而不是“让对象离开当前节点内存”.
这也是为什么我后来越来越觉得, RL 里的 softpin 更像一种 put-time contract, 而不是 get/exists 时不断往后续的“热点豁免”. 既然用户自己管理生命周期, 那么系统的职责就应该是保证对象可恢复, 而不是一看到 softpin 就把它永远留在当前节点.
第四轮演进:evict 从“删 key”改成“移走对象”
我之前那篇 从默认 Evict 到 Linux Reclaim 主要讲的是 unified pool 下 evict 的整体设计, active/inactive list, 以及为什么要借 Linux reclaim 的职责划分. 那篇文章里我更强调“怎么做”. 这次我反而更想强调“为什么必须这么做”.
因为一旦 softpin 被重新解释成“可以迁走, 但不能消失”, evict 的准确定义就不再是“删 key”, 而变成:
把对象从当前节点的统一内存池 G 里移走.
沿着这个定义, 很多事情就清楚了:
softpin=true对象可以被从当前节点驱逐, 前提是先完成 remote swapout 或 offload, 确保之后还能读回.softpin=false对象继续保留 best-effort cache 语义. 某些次级副本在压力大时可以直接丢.- 真正阻塞“对象离开当前节点”的, 应该只剩下两个因素:
refcnt和 lease.
refcnt 很好理解. 只要仍有人持有着 BufferHandle, 当前这份本地 replica 就不能被真正释放.
lease 则是另一回事. 远端读恢复拿到 replica list 之后, 当前节点已经承诺“在这段时间里这份 replica 仍然可读”. 既然已经许诺了, 那它在 lease 过期前当然也不能被回收.
注意这里一个很微妙但很重要的变化: softpin 不再是阻塞当前节点驱逐的硬条件. 它决定的是“对象能不能语义上消失”, 而不是“对象是否必须永远待在这里”.
第五轮演进:读回填链路需要幂等化
RL 下还有一个额外麻烦: 并发读 miss 会非常凶.
如果还是沿用那种很朴素的思路:
AcquireReplica miss
-> 查远端
-> PutStart
-> Get
-> PutEnd
-> 再 Acquire 一次
那么中间窗口里其实会发生很多事. 比如另一个线程可能已经把同一个 key load 到本地了; 或者你这边刚 load 完, 另一个线程又因为内存压力把它驱逐走了(是的,我51第一天就因此被废了). 在 RL 这种并发度下, 这类窗口并不稀奇.
所以后面我们也顺手把读 miss 回填链路重新收拢了一下:
- 引入
LoadReplica(key), 把TryLocalCopy和 offload fallback 收成一个幂等入口. - 引入
PutStartOrAcquire, 把“本地不存在就创建, 已经存在就直接 acquire”收成一个状态机. - 引入
LocalPutEndAndAcquire, 让“写完之后立刻 acquire”也变成原子动作, 避免再走一遍容易漂移的二次查询窗口.
这些接口名字看上去有点工程味, 但它们背后的动机其实很朴素: RL 里的读恢复不能只靠“碰巧没并发”来成立, 它必须本身就是幂等的.
第六轮演进:lease 暴露成主要回收瓶颈
当 evict 语义理顺之后, 下一件事就是看它到底卡在哪里.
某次线上观测里, ShrinkInactiveList 一共跑了大约 1046 万次, 但真正出现 pre_evict > 0 的只有 4911 次, 占比只有 0.047%. 也就是说, 绝大多数时候 reclaim 线程其实都在空转.
再往下拆空转原因, 结果更直接:
lease_only占了大约98.3%ref_only只有大约0.95%lease + refcnt大约0.69%
另外 earliest_lease_expire_ms 的分布也很有意思: p50 ≈ 17.0s, p90 ≈ 26.4s, p99 ≈ 28.8s, 而默认 default_kv_lease_ttl 本身就是 30000ms.
这组数据其实已经把问题说得很清楚了: 在内存很紧的节点上, reclaim 大部分时间都在反复扫“还没过 lease 的对象”. 如果只是被动等 TTL 自然过期, 驱逐往往很难真正开始发生.
第七轮演进:主动释放 lease
这也是为什么我后来加了主动释放 lease.
先说结论: 这里的 ReleaseLease 不是为了正确性, 而是为了效率. 就算完全不调用它, 只要 TTL 最终会过期, 正确性仍然成立. 但在只有三十来个 G 可以腾挪的环境里, “最终会过期”和“现在能不能尽快回收出来”完全是两回事.
local master 下, 一次典型的远端读恢复大致是:
GetReplicaList(key) # 这里授予 lease, 并返回 lease_token
-> PutStartOrAcquire(key)
-> Get(remote -> local)
-> LocalPutEndAndAcquire(key)
问题在于, 这段 lease 的真实用途其实很窄. 它只是在保护这次 Get 所依赖的那份远端 replica. 一旦 Get 已经结束, 我们就已经知道本次尝试成功还是失败了. 这时候继续把 lease 白白拿满整个 TTL, 对读恢复本身已经没有任何帮助, 却会持续阻塞回收.
所以我们最后做的事情其实很直接:
GrantLease(ttl)除了更新时间窗口, 还返回一个lease_token.GetReplicaListResponse把这个lease_token带回给调用方.LocalCopy在一次Get尝试结束之后, 无论成功还是失败, 都尽快调用ReleaseLease(key, replica_id, lease_token).
实际代码里, 我们用的是一个很小的 ReleaseLeaseGuard, 效果大致相当于下面这个简化版伪代码:
auto remote = get_replica_list_remote(key); // 拿到 replica + lease_token
ReleaseLeaseGuard guard(key, remote.replica_id, remote.lease_token);
auto start = PutStartOrAcquire(key, size);
if (!start) {
return err;
}
auto get = Get(key, remote_replica, local_slices);
guard.Run(); // 这次传输结束后尽快释放 lease
if (!get) {
auto refreshed = get_replica_list_remote(key, preferred_source);
ReleaseLeaseGuard guard2(key, refreshed.replica_id, refreshed.lease_token);
auto retry = Get(key, refreshed_replica, local_slices);
guard2.Run();
}
return LocalPutEndAndAcquire(key, local_replica_id);
这里面有几个约束非常重要:
ReleaseLease只是 hint. 漏掉它, 最多是回收效率退化, 不能把它变成正确性的前提.- lease 一定要按
key + replica_id + lease_token去释放, 不能只看 key. 因为put -> remove -> put多代短暂共存是合法基线. - 这个操作必须是 at-most-once / no-retry. 如果同一份 grant 被重复 release,
lease_cnt就会被错误扣减. lease_timeout仍然是 evict / remove 的唯一硬判定.lease_now_ / lease_cnt_只是主动释放需要的 bookkeeping, 不能替代真正的 lease 语义.
这里还有一个很自然的担心: 既然每次 remote Get 尝试之后都多了一次 ReleaseLease, 会不会反而把热点读路径拖慢? 至少在我们的设计里, 这是刻意避免掉的. 主动释放 lease 虽然引入了额外 bookkeeping, 但锁内逻辑仍然保持在 O(1): 不需要扫描 holder, 不需要沿着对象集合做额外查找, 也不会把这条链路变成新的大临界区. 所以 ReleaseLease 本身并没有带来运行效率的下降; 相反, 正因为它能及时释放掉大量原本白白占着的 lease, 整个系统的实际运行效率反而是大幅提升的.
我觉得这一步最有意思的地方在于: 它并没有改变 Mooncake 对外承诺的正确性, 只是把“这次读恢复到底还需不需要继续占着 lease”这件事显式表达出来了. 但正是这种看上去很小的表达, 才让 reclaim 在紧内存节点上真正开始工作.
第八轮演进:Fault Inject 的工程化补强
最后再说下 fault inject. 这块我觉得很值得单独讲一节, 不是因为它实现多复杂, 而是因为它真的太好用了.
local master 路径下, 很多问题天然就是双进程时序问题:
- 当前 Python 进程里跑着
DummyClient这边的逻辑; - 另一个进程里跑着
RealClient + MasterService.
有些 fault 点发生在当前 client 进程, 有些 fault 点发生在 local master 进程. 如果没有一套 per-process 的 fault table, 这类问题基本就只能靠运气复现.
所以我们后来做的 fault inject 很克制:
- 每个进程各自维护自己的 fault table, 不共享.
- 只支持几种最实用的动作:
sleep、return_error、reset. - DEBUG 下生效, Release 下保持零负担.
它的接口也尽量保持简单:
store.inject_fault(...)
store.get_fault_status(...)
store.inject_fault_local_master(...)
store.get_fault_status_local_master(...)
选哪个入口, 不看“我连的是哪个 local master”, 只看 fault 点实际跑在哪个进程里.
例如 DummyClient.put_parts -> Client::DummyPut -> TransferWrite 这一类快路径, 实际上仍然跑在当前 client 进程, 那就该用 inject_fault. 而 get_buffer 的 local miss 恢复, 比如 TryLocalCopy / LocalCopy, 是跑在 local master 进程里的, 就该用 inject_fault_local_master.
一个很典型的例子, 就是复现并发 get_buffer 时“有人已经开始本地回填, 别人看到 REPLICA_IS_NOT_READY”这个窗口. 以前这种问题很吃时机, 现在只要在 local copy 完成 PutStart 之后故意 sleep 一下, 基本就能稳定复现:
fault_name = "ClientLocalCopyAfterPutStart"
ret = store.inject_fault_local_master(
fault_name,
"sleep",
3000, # 毫秒
1,
1,
)
assert ret == 0
buf = store.get_buffer(key)
assert buf is not None
print(store.get_fault_status_local_master(fault_name))
store.inject_fault_local_master(fault_name, "reset")
另一个我很喜欢的用法, 是直接给 TryLocalCopy 前面的远端查询路径注入错误, 然后验证 offload fallback 仍然能把数据读回来. 这类 case 如果没有 fault inject, 往往要等某个真实 RPC 抖一下, 才能碰巧撞到:
fault_name = "ClientLocalCopyBeforeRefreshReplicaListRemote"
ret = store.inject_fault_local_master(
fault_name,
"return_error",
-900, # RPC_FAIL
1,
1,
)
assert ret == 0
buf = store.get_buffer(key) # 假设这个 key 已经被预先写入 offload
assert bytes(buf) == expected
print(store.get_fault_status_local_master(fault_name))
store.inject_fault_local_master(fault_name, "reset")
我现在越来越喜欢这种足够朴素的 fault inject. 只要能把那些“平时只能靠运气撞到”的时序窗口, 变成可以稳定复现、稳定写 e2e 的东西, 就已经非常值了.
总结
很多时候, 系统设计里最难的部分并不是“想不到新点子”, 而是被真实场景逼着, 一层层把原来那些模糊但凑合能用的语义重新说清楚. 也正是在这种高压场景里, 那些平时不显山不露水的语义问题, 才会被一个个逼到台前.
P.S. 整个五一基本都在支持 RL, 头秃=。=每天 400 报销额度的 token 完全不太够用,头更秃=。=