dsh-coldread-cache
Manifest validDSH plugin: keep a session's parsed event log resident so a local append extends the resident parse instead of invalidating it - a long session is parsed once per host process, not once per read.
dsh-coldread-cache
English | 简体中文
把正在写入的会话日志常驻在内存里解析,长会话不再越用越卡。
这是一个 DeepSeek Harness(DSH)插件。 它不修改 DSH 安装目录里的任何文件:挂载时在运行中的持久化后端实例上写入自有属性, 卸载时还原。
症状与根因
会话开得越久、轮次越多,界面就越卡:切换会话变慢、发一条消息要等、磁盘一直有读写。 关掉重开就好了——过一会儿又慢慢卡起来。
根因不是内存泄漏,是整份冷读:
- DSH 把每个会话存成
session.v4.jsonl.zstd:可独立解码的 zstd 帧拼接,每帧一个 JSONL 批次,首帧是头部。 - JSONL 后端会缓存已解析的日志,但缓存上限只有两条,且以 stat 推导出的
revision作为键。 - 每次追加都会改变这个 revision,所以正在写入的会话几乎每次读都缓存未命中。
- 未命中就是一次整份冷读:整个文件
readFile→ 逐帧 zstd 解码 → 逐行JSON.parse。
本机真实日志实测:
| 文件 | 事件数 | 整份冷读 |
|---|---|---|
| 16 MB | 15 958 | 576 ms |
| 73 MB | 约 39 700 | 约 2 400 ms |
开销几乎全在 JSON 层,不在解压也不在磁盘:73 MB 的文件里 JSON.parse 约
1 750 ms、JSON.stringify 约 2 470 ms。
抓到的现场:一个开着长会话的宿主进程 30 秒读了 846 MB,峰值 61.6 MB/s, 30 秒内 37 477 次读操作;而刚启动的宿主基本是 0 MB/s。
这个插件做了什么
为每个会话保留一个常驻解码器,用的是后端同一套已发布的格式解码器,只做增量扩展、 不重建:
- 文件没变 → 一次
stat之后直接从常驻结果返回; - 文件变长了 → 只读新增的那段字节,解码后喂给常驻解码器追加;
- 其它任何情况 → 原样交给后端自身的读取逻辑,不猜、不凑。
实测:把真实追加产生的帧喂给一个已有的 16k 事件解码器,耗时不到 1 ms, 而同一份日志整份冷读是 576 ms,且扩展结果与整份解析深度相等。 已经发出去的事件对象在增长后仍是同一批对象,调用方不会看到抖动。
一个踩过的坑
后端在返回结果前会调 freezeStoredEvents,它原地冻死解码器持有的那个事件数组。
所以插件必须让常驻解码器保持未冻结,每次读发一份冻结副本出去。
否则第二次追加会直接炸:
Cannot add property 15953, object is not extensible
正确性
只有在能够证明「复用等价于整份解析」时才复用常驻结果:
- 同一个文件——device + inode 相同,且路径相同;
- 文件只增长,且头部帧的字节未变;
- 常驻长度之后的每个字节都是完整、可独立解码的 zstd 帧(尾部撕裂就回退);
- 把这些帧喂给常驻解码器只会新增行,且格式解码器自身的关系约束仍然成立;
- 新增行通过与后端读取逻辑相同的 v4 行准入、存储事件校验与关系断言, 并且上报的是实际观测到的 revision。
任何一条无法证明就回退。所以最坏情况只是「和没装一样慢」,不会更慢,也不会给出 一份和整份解析不同的答案。
安装
插件由 profile 加载,并自带插入行:
// <profile>/cordis.patch.yml
- insert:
- id: coldread-cache
name: 'dsh-coldread-cache'
推荐用 dsh 自带的插件命令装:
dsh plugin --profile <name> add dsh-coldread-cache
手工挂载需要同时改三处,缺一处启动就会失败:
| 文件 | 需要什么 |
|---|---|
<profile>/package.json | dependencies 里有本包 |
<profile>/package.json | dsh.profile.bundles 里有 dsh-coldread-cache |
<profile>/cordis.patch.yml | 上面那条 insert 行 |
挂载顺序不用管:插件先试 ctx.get("sessionPersistence"),服务还没起来就注册
ctx.on("internal/service", …) 等它出现。所以宿主启动时自动生效,不需要人工干预。
配置
| 键 | 默认值 | 含义 |
|---|---|---|
enabled | true | 允许从常驻结果直接返回 |
maxEntries | 8 | 本进程常驻的会话数 |
maxResidentBytes | 134217728 | 常驻源字节总量 |
statusFile | "" | 可选的实时诊断文件路径 |
前两项是进程的内存策略。后端自己那份两条的缓存原样不动。
这个预算是按压缩后的源字节算的,但你付的是堆。实测一份解析出来的日志约等于
源字节的 8 倍(202 MB 的会话在堆上约 1207 MB):所以默认 128 MB 预算大约值 1 GB 堆。
statusFile 里的 resident.heapEstimateBytes / heapRatio 就是这份实时估算——比例由真实
冷读采样得到,不是写死的常数。内存宽裕的机器可以调大它。
**这个默认值试过调小,实测后撤回了。**直觉是「预算小一点省内存、行为不变」,但用真实会话 量了一遍(1 个会话在写、4 个在读):
| 预算 | 常驻 | 常驻命中率 |
|---|---|---|
| 16 MB | 35 MB | 14% |
| 32 MB | 35 MB | 14% |
| 64 MB | 35 MB | 14% |
| 128 MB | 99 MB | 53% |
| 256 MB | 167 MB | 93% |
原因:真正解决卡顿的那条路径(把「追加后立刻读」交给常驻解析扩展)16 MB 就够——因为 被写入的会话排在只被读的会话前面。预算再往上买的是纯读会话的常驻:翻历史、会话列表、 来回切会话。所以 128 MB 是「能服务一个真实工作集」的最小值;调小是拿一个实测到的功能退化 去换一个不是瓶颈的内存节省。
什么时候扩展次数才会涨
extensions 这个计数器只在一种情况下会涨:宿主对某个会话追加之后,又去读同一个会话。
把三种流量形状都量了一遍(各 8 轮,真实会话,fixture 共 254 MB):
| 流量形状 | 扩展 | 冷读 |
|---|---|---|
| 追加后读同一个会话(32 MB 预算) | 8 | 0 |
| 追加后读同一个会话(128 MB 预算) | 8 | 0 |
| 追加后先读 4 个别的会话(32 MB 预算) | 8 | 32 |
| 追加后先读 4 个别的会话(128 MB 预算) | 8 | 32 |
| 只读、不追加 | 0 | 15 |
所以「extensions: 0」本身不是故障:如果宿主那段时间只是在翻会话、没有在某个会话里连续
跑轮次,扩展就该是 0,而 coldParses 会涨。判断插件有没有在工作,看的是追加之后那次读
有没有走扩展,而不是看扩展的绝对次数。另外两条也顺手排除了:写句柄那条分支(宿主自己的
「追加→读回」循环会走快速路径)和预算大小(32 MB 下带旁观流量照样 8/8 扩展)。
为什么会在长时间使用后变卡(已修)
扩展路径每轮都会深冻结整条会话的事件图:Object.freeze 要递归走到每个对象。
这本身是对的(冻结是为了和后端行为一致),但它在扩展时被套在了全部事件上,而不是
只套在新追加的那几个上。于是每轮都要重新走一遍整条会话——成本随会话变长而线性增长。
在 6 万事件的日志上实测:
| 每轮扩展的动作 | 耗时 |
|---|---|
| 冻结结果里的每一个事件(改前) | 191.7 / 199.5 ms(两次) |
| 只冻结新追加的事件(改后) | 0.4 / 1.8 ms(两次) |
concat 本身(无法省) | 0.3 ms |
一个 100 轮的会话大约要为此花 20 秒纯冻结——这就是「用得越久越卡」的一个真实来源,
而且是插件自己引入的。修法有两条,互为保险:① 扩展时只冻结新事件;② freezeStoredEvent
遇到已经冻结的根就直接返回(已冻结的根意味着整棵图都冻结了,因为冻结永远是整图一次
完成的)。第 ② 条让第 ① 条即使漏掉某条路径也不会退化。门禁里有一条断言遍历扩展后的真实
结果、确认每个事件都深冻结,并且用一个故意留开口的对象证明这条断言能真的报出问题。
修完后的 A/B 中位数:131 ms(改前约 500 ms)。
淘汰顺序
常驻满了先淘汰谁,是按读的最近性吗?不是——那样在真实宿主上会失效。旁观会话的读取
本身就是一次冷读,冷读会让那条会话变成「最近使用」,于是正在被写入的那条会话反而被挤
出去,而「追加后立刻读」恰恰是本插件唯一的目标场景。实测:宿主连续跑 85 分钟、405 次读由
常驻服务、扩展次数 0,回退原因全是 no-entry。
所以规则改成:被写入过的会话优先于只被读过的会话。同级之间按最近读取淘汰,最后一条永
不淘汰。门禁里有一条专门的回归断言,并且用故障注入证明它能变红(把写入排名关掉,同一条
断言必须失败)。resident.hot 是当前被标记为「写入中」的会话数。
statusFile 是排查用的:写一份实时文档(心跳刷新),说明这份进程实际装上了哪些钩子、
命中了多少次、回退的原因分布。一个静默失效的插件比一个从没装上的插件更糟,所以留了这个
口子。
验证
pnpm run gate # 验收门禁,跑真实 vendored 后端
pnpm run fault # 故障注入:证明上面的门禁真的能变红
pnpm run perf # 真实会话日志上的「追加后读取」A/B
pnpm test # 依赖与安装机制探针
pnpm run compat # 对每一棵装了后端的 DSH 树跑完整回路(多版本兼容)
pnpm run probe:contract # 契约能变红:摘掉一个 helper 必须被拒绝
pnpm run probe:freeze # 每轮全量冻结 vs 只冻结新事件(回归断言)
pnpm run probe:evict # 旁观流量下常驻能否存活(回归断言)
pnpm run probe:budget # 各预算档的常驻命中率(默认值就是靠它定的)
pnpm run probe:heap # 常驻的堆开销(源字节 → 堆的换算比例)
pnpm run probe:segments # 校验能不能分段(证明剩下的开销是下限)
pnpm run probe:breakdown # 一次扩展的逐阶段耗时
pnpm run probe:artifact # reader.artifact() 的代价(证明显式校验不是多余的)
probe:* 这几条是把 README 里的数字量出来的脚本,不是装饰:probe:freeze 和
probe:evict 都带断言,改坏了会 exit 1。它们需要真实会话:默认读 $DSH_HOME\sessions
(未设则 $DSH_HOME 必填),也可以用 DSH_SESSIONS 直接指到会话目录。
门禁
GATE_GREEN checks=93 failures=0(约 3.5 分钟)。门禁把真实的 vendored 后端跑两遍同一批
真实会话副本(一遍原始、一遍打上插件),深度比对结果,覆盖:语料、追加路径、尾部撕裂文件、
同长度改写、卸载还原、关闭常驻的对照组、旁观流量下常驻的存活,并包含一次故意注入
错误答案的故障注入——用来证明这个比对本身能失败。没有故障注入的绿色不算绿色。
pnpm run fault 单独跑 7 项故障注入,证明门禁的关键断言都能真的变红:
翻转一个字节让 vendored 摘要检查失败、少一个事件 / 多一个字段让读取比对失败、
关掉常驻后命中数必须为 0(否则性能断言就是自证)。同时门禁脚本自带互斥锁——
两实例同跑会污染同一份 fixture,锁让第二次运行直接拒绝而不是悄悄给出错误结论。
两棵 vendored 目录始终保持与出厂 SHA-256 字节一致
(027803DDE72AAC7038253F0A8FF9FDCDBF0EB8CAD53ED26EFA3EC610F01587E7),
门禁在每次运行的开头和结尾都断言这一点。
性能 A/B
在真实的约 90 MB 会话日志上,用后端自己的写入路径追加一个真实批次,再计量紧随其后 的那次读(也就是宿主每轮都要做的那次读),各跑 5 轮取中位数:
三次独立运行的中位数(同一份 ~90 MB 真实会话,每次 5 轮):
| 追加后的一次读 | 运行 1 | 运行 2 | 运行 3 | 运行 4 |
|---|---|---|---|---|
| 原始后端 | 2 928 ms | 4 232 ms | 5 972 ms | 2 930 ms |
| 挂插件但关闭常驻(对照组) | 5 244 ms | 6 436 ms | 6 493 ms | 5 744 ms |
| 挂插件 | 474 ms | 410 ms | 477 ms | 509 ms |
即 5.8×–12.5×。对照组就是这个断言自己的证明:同一段循环、同一个 后端类、同一个文件,只关掉常驻——它一点加速都没有,说明这个断言能真的变红。
去掉每轮全量冻结之后
上面那批数字是在扩展路径还每轮深冻结整条会话时测的。修掉之后(见「为什么会在 长时间使用后变卡」),同一份 fixture 又跑了两次:
| 追加后的一次读 | 运行 A | 运行 B |
|---|---|---|
| 原始后端 | 8 417 ms | 2 893 ms |
| 挂插件但关闭常驻(对照组) | 6 959 ms | 4 682 ms |
| 挂插件 | 131 ms | 88 ms |
即 22×–64×。绝对值随机器负载浮动(原始后端自己两次就差 3 倍),所以结论看的是 倍数,而对照组是让这个倍数可信的东西。
注意:这里比的不是「同一个文件读两遍」。后端自己那份两条缓存已经能让重复读很快, 那样比什么都证明不了。真正会卡的是读一份刚刚变长的日志,所以 A/B 测的就是这个。
实机
单独冷启一份 profile 到另一个端口(不动正在用的宿主),什么参数都不给:
{
"backend": "session-persistence-jsonl",
"installed": ["readStoredLog","decodeStoredLog","persistBatch","persistHeader","truncateTornTail"],
"failures": {}
}
启动日志里零条插件错误。然后在界面上纯 UI 操作来回切换真实会话:
hits=4 ext=0 cold=1 ← 第一次打开
hits=5 ext=0 cold=2 ← 打开另一个会话
hits=9 ext=1 cold=3 ← 回到已打开的会话:靠「扩展常驻解析」服务,没有重新冷读
hits=11 ext=2 cold=4 ← 再一轮:又两次扩展
关键在最后两行:extensions 涨了,coldParses 没跟着涨。
真实宿主上发现的缺陷(已修)
上面这段是短时间、来回切同一个会话的情形,它掩盖了一个只在长时间使用下才暴露的问题。 在一台连续跑了 85 分钟、真实使用中的宿主上,状态文件是这样:
"stats": { "hits": 405, "extensions": 0, "coldParses": 369,
"fallbacks": 369, "reasons": { "no-entry": 369 } }
405 次读由常驻服务,扩展 0 次,369 次回退全部是 no-entry。注意:extensions: 0
本身不足以判定故障(见上面「什么时候扩展次数才会涨」)。把这条观测钉成缺陷的是一次
受控实验:固定 2 条常驻、让旁观会话读 64 次,然后看「追加后读同一会话」还能不能扩展——
保留写入排名时扩展(1→2),把写入排名去掉后不扩展(1→1)。同一段代码、同一个文件,
唯一的差别就是排名,所以机制成立。
结论:淘汰顺序确实是一个真实缺陷(按读的最近性淘汰,会被旁观会话的冷读把正在写入的会话 挤出去),实机那 85 分钟的计数器与它一致。这条是看实机计数器发现的,不是靠测试发现的; 修完才有「淘汰顺序」那节、以及门禁里那条带故障注入的回归断言。
多版本兼容
插件不是改源码,而是挂在一个已发布的后端上:它静态 import 七个 @deepseek-ai/* 包,
并在 dsh-session-persistence-jsonl 的实例上替换五个方法。所以「兼容」必须意味着
换一棵我没在上面开发的 DSH 树也能跑,而不是「在我这台机器上能跑」。
声明的接口面
插件对后端的依赖分成两类,分开列是因为失败方式不一样:
| 类别 | 成员 | 缺了会怎样 |
|---|---|---|
| 被替换的 | readStoredLog、decodeStoredLog、persistBatch、persistHeader、truncateTornTail | 不安装——一个都不写 |
| 被调用的 | assertStoredIdentity、locate、memoizeStoredLog | 不安装,并给出可读原因 |
第一类缺了不能安装;第二类缺了更麻烦——替换进去的代码会在别人的读路径里面抛异常, 那时候已经退无可退。两类都在写任何东西之前探测,所以版本搬家只会退化成原始后端, 不会装一半。
怎么测的
pnpm run compat # 对机器上每一棵装了后端的 DSH 树跑一遍完整回路
pnpm run probe:contract # 契约本身能变红:摘掉一个 helper 必须被拒绝
compat 会自己在标准位置找树(也可以用 DSH_TREES 指定),对每棵树:建一份用那棵树自己写的
会话日志,挂上插件,真追加一个批次,再真读回来,断言这次读是扩展常驻解析而不是重新冷读。
本机 3 棵树、2 个版本(0.2.0-rc.2 与 0.2.1-alpha.2)全部 COMPAT_GREEN checks=16。
测试还用一个 loader hook 把插件自己的 @deepseek-ai import 也重定向到被测的那棵树上。
没有这一步,测的其实是「A 树的后端 + B 树的校验模块」——两棵树一样时看不出来,
而那种情况恰恰最不容易被发现。
COMPAT_ALL_GREEN trees=3 failed=0
exit=0 COMPAT_GREEN checks=16 failures=0 0.2.0-rc.2
exit=0 COMPAT_GREEN checks=16 failures=0 0.2.1-alpha.2 (nightly)
exit=0 COMPAT_GREEN checks=16 failures=0 0.2.1-alpha.2 (stable)
顺带发现的两件事
- 两个版本的差异只有 17 行旧/21 行新,全在迁移校验的退出处理与
readGenerationHeader改名(header→metadata),读写路径一行没动。 - 老树(
0.2.0-rc.2)读不了新 harness 写的会话:它把working-directory/change当成未知且不可忽略的事件类型直接拒绝。这是数据层的不兼容,跟插件无关, 但足以说明为什么测试 fixture 必须由被测的那棵树自己生成。
历史加载:两个真实的回归与修复
上面那些优化都是针对「追加后读取」。历史加载是另一条路径,之前从没量过,一量就发现 插件在它上面比原始后端更慢——这正是「越用越卡、历史加载也慢」的机械原因。
先量清楚两条路径
| 路径 | 耗时 | 说明 |
|---|---|---|
list()(会话列表) | 839 ms | 遍历 548 个会话,每个都要开文件读头部 |
| 冷读一个 62 MB 会话 | 3892 ms | 用户从历史里点开一个会话 |
冷读拆开看:解压 16665 个帧 3006 ms、JSON.parse 40478 条 2007 ms。而帧非常小 (中位数 1298 B),单帧解压 0.195 ms——几乎全是每次调用的固定开销,不是按字节的工作量。
回归 1:插件用了慢的解码器
上游的后端不用一次性 API 逐帧解码:它的 createZstdFrameDecoder() 是
return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();
即优先用一个复用同一份原生上下文的私有解码器,探针失败才退回公开 API。插件的移植 只抄了公开那一半,于是冷读每一帧都新建一次原生上下文。
受控测量(交替轮次、每轮之间强制 gc、取中位数):
| 中位数 | |
|---|---|
| 公开一次性 API 逐帧 | 906 ms |
| 复用上下文 | 439 ms — 2.07x |
这里差点犯错:另一次测量说复用上下文是 0.72x(更慢)。两者不可能都对。差别在于那次 测量之前已经分配了 277 MB 明文,GC 状态被污染了。加上「每轮强制 gc、交替执行」之后结论 稳定为 2.07x。一个测量与另一个测量冲突时,先查环境差异,别挑顺眼的那个信。
回归 2:一次把所有明文都展开
插件原先先把 16665 个帧全部解压收集(约 278 MB 明文同时存活),再交给 reader。 而 reader 本来就能处理跨帧的记录,先收集毫无收益,只是白付分配与 GC。
修复后的效果
| 修复前 | 修复后 | |
|---|---|---|
| 冷读 62 MB 会话(对比原始后端) | 慢 1.48x(4227 ms vs 2855 ms) | 快 1.11x(2667 ms vs 2974 ms) |
会话列表:头部缓存
list() 会为每个会话开一次文件、解码第一帧来拿头部,即使那个文件的头部根本没变。
这占了枚举历史的大部分时间。
插件新增第 6 个钩子 readGenerationHeader,把后端自己的返回值按文件 identity 缓存
(和它自己那份 coldLogMemo 用同一个 stat 派生键)。缓存与常驻是两回事,必须分开:
| 常驻(residency) | 头部缓存 | |
|---|---|---|
| 存什么 | 解析后的事件 | 解析后的头部 |
| 大小 | 几百 MB | 每个约 500 B |
| 预算方式 | 按字节,128 MB | 按条数,4096 条(约 2 MB) |
分开才可能「历史枚举变快」而不动那个精心调过的内存预算。
| 阶段 | 原始 | 挂插件 | |
|---|---|---|---|
listGenerations(目录遍历) | 786 ms | 556 ms | 没被缓存覆盖 |
listArtifacts(逐个头读取) | 2964 ms | 701 ms | 4.23x |
list() 合计 | 1499 ms | 762 ms | 1.97x |
计数器实测:548 条缓存、1092 次命中、552 次未命中——命中是主要来源,不是靠运气。
两个假绿门禁(都已被抓住并修掉)
这一轮写门禁时,连续两次写出了「看着绿、其实没验到」的检查。两次都是靠故障注入 发现的,不是靠读代码:
- 第一版比对「选中的解码器」和「公开 API」的速度。把选择强制成公开路径后, 两边是同一份代码,自然一样快——检查照样绿。而且 400 帧的样本里,同一份代码两次 测量就差了 20%(66 vs 55 ms),这个检查本身就在噪声带里。
- 改成直接报告「选中了哪个解码器」后,注入依然绿——因为那个报告函数绕过了
被测的
createZstdFrameDecoder(),直接调底层构造函数,报的是「有没有」而不是「选了哪个」。
修法是让选择可观测:每个解码器自带 kind,报告函数必须穿过选择逻辑。
现在注入会红,而且理由是可诊断的:
FAIL the decoder selection matches what this runtime supports
:: runtime v26.0.0 privateUsable=true selected=public
注意同一轮里那条速度检查仍然是绿的——这正好说明:只看时间差不等于验到了机制。
第三个门禁缺陷:一次「假红」
三轮复跑里有一次撕裂尾部检查变红:the recovered event sets differ at 1 positions。五次里有
一次失败是最难处理的一种——不可按需复现,而且消息无法诊断:它不说是有事件丢了、多了,
还是某个字段不同。
所以先做了三件事,而不是先去改插件:
- 让它可诊断:把「差 N 处」换成多元集差,直接打印多出来/少掉的那个事件。
- 隔离复现:把第三节的场景按原样重跑(含它前面那次真实 append),交替 10–12 轮。 结果每轮都完全相同,而且可重复。
- 排除文件本身在动:在比较的每个步骤给文件做哈希。两侧读到的是同一批字节, 谁都没有改写它。
于是怀疑转向检查本身。门禁用的是 JSON.stringify 逐事件比对,而字符串对键的顺序敏感,
键顺序却不是会话事件语义的一部分——后端自己的判据是 isDeepStrictEqual,它不看键顺序。
probe:keyorder 把这个机制证出来了:两个只差键顺序的事件,deep 判等说相同、stringify 说不同。
改法是把比较收紧而不是放宽:撕裂尾部时插件根本不做恢复,它拒绝快路、把同一批字节交给 同一个原始读取器,所以两侧应当逐字节相同,不只是「作为集合相同」。现在用的是严格判等, 并额外固定住输入哈希——这样将来再红,一定是因为答案不同,而不是因为文件动了。
| 检查 | 改前 | 改后 |
|---|---|---|
| 比较方式 | JSON.stringify 多元集(对键顺序敏感) | 严格深判等(与后端同一判据) |
| 输入固定 | 无 | 两侧读取前后各一次 SHA256 |
| 失败信息 | 「差 1 处」 | 多出/少掉的具体事件 |
| 复现性 | 不检查 | 同一批字节读两次,答案必须一致 |
诚实的边界:我没有捕获到那次失败当场的内存状态,所以不能证明键顺序就是它的原因。 能证明的是:那个检查会因为与被测对象无关的理由变红,而收紧之后连跑 8 次全绿、 12 轮受控复现逐字节相同。这是证据支持的解释,不是已证实的结论。
顺带记下这条的通用形态:门禁有三种坏法——假绿(看着通过其实没验到)、假红 (因为无关理由失败,让人去改本来没坏的代码)、无法诊断(红了也查不动)。这一轮三种都遇到过了。
还能不能再优化:剩下的时间不归插件管
头部缓存之后,list() 里剩下的大头是 listGenerations——目录遍历。插件没有替换它:
六个钩子(readStoredLog / decodeStoredLog / persistBatch / persistHeader /
truncateTornTail / readGenerationHeader)里没有它。它是后端自己的代码。
那能不能缓存整份目录列表?probe:walk 量了这件事的两面:
content touched in place -> dir mtime UNCHANGED (合法:内容变了,路径列表没变)
file created -> dir mtime CHANGED
file removed -> dir mtime CHANGED
=> 但省不下东西:校验需要 575 次 stat(135 ms)来跳过 575 次 readdir(168 ms)
也就是说,按目录 mtime 做的列表缓存是安全的(内容变化不影响路径列表),但判断它是否还有效 要花的代价和它想省掉的代价是同一量级——都是每个会话一次系统调用。这不是「不容易做」,是不划算。
一个必须写下来的测量失败:我一度想算出遍历的「系统调用底价」,结果量出
listGenerations比它自己的底价还快(152 ms vs 254 ms)。这不可能,说明那个「底价」的算法错了——逐个 await 计时 的开销本身污染了单次操作成本。同一函数在不同轮次里也从 152 ms 变到 1692 ms(一个数量级)。所以这里不给精确数字。能站住的只有两条:目录遍历是剩余大头(它在
list()中占比最高), 而它不在插件手里。不做没有依据的优化,也不把测不准的数字包装成结论。
综合起来,这一轮的停手理由是分层的:
| 层 | 结论 | 依据 |
|---|---|---|
| 冷读解码器 | 已修(2.07x 的私有多路复用器) | 受控 A/B,且故障注入会变红 |
| 冷读明文展开 | 已修(流式喂帧替代 278 MB 全量展开) | 冷读从慢 1.48x 到快 1.11x |
| 会话列表头部 | 已修(4.23x on listArtifacts) | 计数器 548 条 / 1092 命中 |
| 目录遍历 | 不归插件管,且缓存不划算 | probe:walk 两面测量 |
| 已发布校验器 | 占了扩展的 90%,且不能分段 | probe:segments 状态机拒绝 |
优化到哪里为止
这一轮的目标是「在确认无法继续优化的时候停下」。结论是:剩下的 57 ms 是已发布校验器的价钱, 插件不该也不能替它省。
一条 55 MB / 58523 事件的会话,一次扩展共 63.2 ms,拆开看:
| 阶段 | 耗时 | 占比 |
|---|---|---|
assertReleasedV4Relationships(遍历全部事件) | 56.91 ms | 90% |
| 整表扫描(O(n) 对照) | 2.01 ms | 3% |
concat(O(n) 数组拷贝) | 0.31 ms | 0.5% |
| 其余 | ~3.9 ms | 6% |
想过把它分段(只校验新追加的事件),probe:segments 直接否掉了这个方向:
全量遍历 58523 个事件 80.6 ms 通过
只校验最后 4 个事件 0.07 ms
只校验尾部的结果:tool/ptc-dispatch is outside an open turn
从 turn/end 之后切一段:turn/start does not open the expected turn
**校验是一个顺序状态机,一个事件能不能被接受取决于它前面的事件。**尾部单独校验会被拒绝,
所以「从检查点续跑」在这里不成立。要做的话得重新实现 Relationships 那套状态机
(dsh-session-format-v3-to-v4 里 539–857 行),而它是模块私有的、没有导出——
那等于在已发布的校验逻辑上另起一套,比之前的冻结修复风险大得多,不值得。
另外两点也确认过了:
entry.reader.artifact()是 0.0 ms(它只检查版本号就返回),所以插件显式调用校验器 不是多余的——它是这条路径上唯一的全量校验。- CPU profile 显示开销主要来自冷解析的第一遍(
#consume22.5%、processChunkSync13.2%、 GC 9.0%),不是校验循环本身。
限制
- 快速路径只覆盖「按整帧增长」和「完全没变」。尾部撕裂、改写、截断、帧表重建都会回退。
- 常驻是进程级的,宿主重启后失效;插件会在挂载时重新安装。
- 第一次读永远是冷的(那一刻还没有常驻结果可用)。
- 后端自己那份两条的缓存不受影响,插件另有自己的字节数与条目数预算。
许可
MIT
Comments
Loading…