2609 字
13 分钟
CPU飘高排查-解析缓存篇

上一篇(深浅拷贝篇)把那六成多的占用收掉了一半多, 静止态从 64.5% 降到 40% 上下.

这一篇讲剩下那四成 —— 一件跟拷贝完全无关的事: 每轮都把同一份文件重新解析一遍.

1. 上一阶段留下的线头#

上一篇结尾我留了一句: 剩下那四成来自另一件独立的事. 那不是敷衍.

当时已经能看出来它跟深拷贝不是同一条路径: 深拷贝是”取数据的方式不对”, 这个是”数据根本没必要重新读”. 两件事都得改, 但改法毫无关系 —— 一个是改函数签名, 一个是加缓存. 混在一篇里讲, 读者会觉得是同一件事的延续, 其实是两条独立的线.

顺带说一句, 这一阶段我手上的数据比上一篇完整, 因为 perf 终于能跑了(上一篇为什么没用上, 那里写过原因).

2. 先花十分钟, 把三个很像答案的方案否掉#

在真正动手之前, 有三条路看起来都像答案, 我都过了一遍, 十分钟内全部否掉. 列出来是因为这三条最容易把人带偏.

换红黑树 / 换哈希表: 树只有 3 层, 节点数 25 个, log n 里的 log n 也就是 12. 就算把查找优化到 O(1), 省下的也就是一两次比较. 这种规模上换容器, 收益不可能是大的.

这就是那棵树真实的形状:

每层 2~5 个节点, 深 3~4 层

  root
   └─ "devices"         ← 每层一次 lower_bound
       └─ "weldtimer"     每次约 2 次字符串比较
           └─ "signals"
               └─ [i]

  一条链 ≈ 6~8 次比较

一个节点本身长这样:

_Rb_tree_node< pair<CZString, Value> >
┌──────────────────────────────┐
│ color   1B (+7B 对齐填充)    │
│ parent  ├─> 上层节点         │
│ left    ├─> 左子树           │
│ right   └─> 右子树           │
├──────────────────────────────┤
│ key     CZString             │
│ value   Value (union+type)   │
└──────────────────────────────┘

  ⇒ 光"树"就 32 字节起, key 和 value 另算

key 是 CZString(指针 + 长度 + 复制策略), value 是 Value(union 加类型枚举); 长一点的 key 还要额外堆分配一块.

看完这两张图大概就明白为什么不动它了: 收益上限是”每层省一次比较”, 成本是把这几万个节点连同指针关系整个换掉, 还要面对 rehash 之后的引用失效和迭代顺序变化. 而真正吃掉 CPU 的是”每轮把整棵树重建一遍”, 换容器一分钱都省不到.

上内存池: 池省的是 malloc 那一段. 而 perf 的样本里全在解析和拷贝构造上, 池根本碰不到病灶.

布隆过滤器: 它回答的是”一定不存在 / 可能存在”, 取不到值. 而我要的是值本身.

这三条否得快, 但很有必要 —— 见过太多人在这里停住, 换容器换两周, 最后收益个位数百分比.

3. perf 跑通之后看到的东西#

第一次真正录到数据, 三个硬指标很有信息量:

  • IPC 1.75 —— 每个周期执行 1.75 条指令, 不算低
  • 分支预测 miss 0.53% —— 几乎不误判
  • cache miss 约 6% —— 访存也不难受

这三条合起来只说一件事: 不是访存瓶颈, 不是分支瓶颈, 就是老老实实在执行大量指令.

这一步的意义在于把方向翻过来. 看到”解析 JSON”很容易往”优化数据布局、减少内存跳转”上想, 但那三个数说明访存根本不是矛盾所在; 真正的问题是每轮执行的指令总量太大.

4. 反汇编看每轮到底在干什么#

顺着栈去看那个刷新函数, 每轮都有一次完整的 JSON 解析:

  • ReadJsonFileToJson 打开文件、读内容、Json::Reader 递归下降
  • 反汇编里能看到每轮大约 1800~2500 个 Json::Value 对象被构造又析构
  • 解析完先把旧树 clear(), 再填新的

25 轮/秒 × 1800~2500, 也就是每秒四五万到六万个对象生灭:

1 秒 = 25 轮, 每轮做的是同一件事

  open / read / 递归下降  ─>  约 1800~2500 个 Json::Value 构造
  旧树 clear()            ─>  大致同样多的析构
  ────────────────────────────────────────────────────
  合计: 每秒四五万到六万个对象生灭, 而内容一个字都没变

而关键在于: 这些对象的内容每轮都一样. 那两份描述文件从部署那天起几乎没动过, 其中一份实测从 8 月 28 日之后 mtime 就没再变过.

一份几个月没变的文件, 每秒被解析 25 次.

5. 第一版想法是错的#

我最初想的是”缓存节点指针”: 解析一次, 把常用路径的 Json::Value* 存下来, 后面直接走指针, 省掉链式查找.

写完立刻发现不成立: 那棵树每轮都会 clear() 一次, 所有指针当轮就失效. 缓存只能在一轮之内有效, 而一轮之内的链式访问本来也没几次.

于是这条路自己把自己否了. 收益主体从来不是”查找”, 是”解析” —— 我把”哪里访问得多”当成了”哪里算得多”, 这是同一类错误的另一种形态.

上一版文档里”指针缓存是收益主体”那段结论, 就这么作废了. 留着当记录.

6. 真正该省的是: 没变的文件, 不要解析第二次#

方案最后压成一句话: 每轮先看文件变了没, 没变就直接用上一轮的结果.

每轮一次

  stat(path) 取戳记
      │
      ├── 没变 ──> return(复用上一轮的树, 不读文件)
      │
      └── 变了 / 被标脏 / stat 失败 ──> 解析 ──> 存戳记

判断”变没变”用 stat 的两个字段: st_mtim(秒 + 纳秒)和 st_size. 只用秒级 mtime 会漏掉同一秒内的两次重写, 所以纳秒也要比.

骨架大概是这样:

void refresh(const char *path, Json::Value &root) {
    struct stat st;
    const bool have = (stat(path, &st) == 0);      // ① 先取戳记
    if (!dirty && have && !differs(st)) return;    // 没变就用上一轮的
    dirty = false;
    ReadJsonFileToJson(path, root);                // ② 再解析
    if (have) save_stamp(st); else stamp.valid = false;  // ③ 存的是①那次的结果
}

7. 戳记写在解析之后, 会丢数据#

这是实打实踩到的坑, 也是后来追加一个提交的原因.

如果顺序反过来 —— 先解析, 再 stat: 解析期间(几十毫秒)外部刚好写了文件, 那么内存里是旧内容, 而戳记记的是新 mtime, 下一轮判定”没变化”, 那次修改就永久丢了, 而且不报任何错.

改成解析前取戳记, 这个窗口就没了.

一句话: 戳记和内容必须来自同一次读取. 这一点在别处(比如配置文件热加载)同样适用.

8. 缓存最难的不是命中, 是失效#

改之前我把”谁会写这个文件”查了一遍, 一共三类, 处理方式完全不同:

  1. 别的线程或协议处理(同进程写). 写完置一个脏标志, 下一轮强制重解析. 不能等文件系统 —— 那边是异步的, 等它会有延迟.
  2. 自己(每 N 轮把内存内容写回文件). 写完要立刻刷新戳记, 做自写豁免. 不做的话, 每 N 轮必被自己触发一次重解析, 缓存等于白做.
  3. 外部进程、人工 scp、脚本. 靠每轮 stat 兜底.

只做一类必翻车: 只做 mtime, 会被自己的写入触发; 只做脏标志, 会漏掉外部修改.

还有一个方向问题: 所有失败分支都往”多解析一次”倒. stat 失败、戳记无效、拿到空指针, 一律当”文件变了”处理. 宁可多算一轮, 不可少算一轮 —— 少算的那一轮, 可能就是现场调了参数却没生效.

9. 结果#

基线改后
整机 CPU(单核百分比)40~42%17~22%
热点线程占比31%9%
每轮刷新的工作量7.2 ms1.6 ms
指令数—−72.8%

“缓存真的生效了”这件事, 我没有用 CPU 下降来证明 —— CPU 降了可能有别的原因. 用的是计数:

  • strace -c -e trace=openat,read 五秒: 改前和循环频率同量级, 改后归零
  • 同一时间 stat 的调用次数仍然等于循环频率 —— 说明它还在看文件, 只是不再读内容
  • 手动改一次文件, 一个周期内生效; 连跑 3N 轮以上, openat 依然是 0(说明自写豁免是有效的)

另外留了编译开关, 现场可以用参数切回旧行为做 A/B, 不用改代码 —— 回退这件事最好在写代码时就留好, 而不是出事时再想.

10. 两个数不能混着说#

有个很容易说错的地方: 每轮工作量降了 78%, 而 CPU 只降了 55%. 这不矛盾, 但也不能拿一个去推另一个.

每轮工作量说的是”这一轮干了多少活”; CPU 说的是”这个线程在这段时间里占了多少比例”. 中间还夹着调度、睡眠、别的线程的干扰. 更要紧的是, 这两个数取自不同采集时段的两个指标, 摆在同一张表里做比值本身就是错的.

同样, 上一阶段末尾我预估过”再加上解析缓存, 应该能到 8% 以下”, 实际停在 17~22%. 预估被推翻就写清楚为什么: 那个 8% 的前提是”除了解析没有别的开销”, 而实际上 IO 映射本身还有一块固定成本. 数字不改, 把偏差的原因写上去.

最后#

两条可以搬走的规矩.

一是先削重复, 再谈结构. 同一个不变的输入, 算第二遍的时候就已经亏了; 在这之前做的任何结构优化, 都是在一个本来不该发生的动作上做微调.

二是缓存类改动的风险不在命中率, 在失效路径. 谁是写入方、写完怎么让缓存失效、stat 失败往哪边倒 —— 这三件事说不清楚就别上缓存. 因为漏掉一次外部修改的代价, 比 CPU 高几个百分点严重得多.

CPU飘高排查-解析缓存篇
https://alongnice.github.io/posts/io-parse-cache/
作者
Along Nice
发布于
2026-04-15
许可协议
CC BY-NC-SA 4.0