tsdb-cj 排查笔记

最危险的不是崩溃,是「看起来在工作」

tsdb-cj 是基于 badger-cj 的嵌入式时序数据库,
也是忆时塔 分层记忆的存储底座。
本文不谈它的设计,只讲一次审计里挖出来的四类问题——它们的共同点是:
代码能跑、测试能过、日志无错,但功能根本没生效。

一、为什么这类问题最贵

程序崩溃是响亮的失败:栈trace 摆在那里,你只需要修。

静默失效是安静的失败:调用方传了参数、服务返回成功、测试全绿,但那个参数从未被使用。

它贵在三个地方:

  1. 发现得晚:没有触发条件,只有「某天你恰好需要那个功能」才会暴露
  2. 定位得慢:报错信息不会指向它,因为根本没有报错
  3. 会伪装成别的问题:TTL 不生效看起来像「过期依赖没触发」,实际是参数被丢了

下面四种,全都是这个形状。

二、失效一:TTL 形参被丢掉了

现象

bucket() 有个 ttl 参数,语义是「这个物化桶多久后自动过期」。调用方传了:

ts.bucket("chat", "1h", "w1",
    "summary", refs,
    ttl: Some(Duration.hour))

桶能建、能读、能下钻,一切正常。但它永远不会过期。

为什么会这样

代码里有两条分支:旧格式桶(windowStartMs == 0)和新格式桶(表式引用行)。新格式那条分支是后来加的,写的时候直接 txn.set(...),把 ttl 形参忘了。

签名上有 ttl,调用方传了 ttl,编译器不会报错(仓颉里具名参数在函数体内未被使用不一定是硬错误),测试也不会失败——因为没有测试覆盖「新格式桶 + TTL」的组合。

回归用例的注释写得很直白:

// 回归:新格式桶(引用走表式 k: 行)
// 必须同样尊重 ttl。
// 此前该分支直接 txn.set /
// 丢掉了 ttl 形参
// ——调用方传了 TTL,
//   却永远不过期(静默失效)。

为什么难发现

TTL 的验证需要时间流逝。单元测试通常不会真的等一小时;而如果不写「设一个 1 秒 TTL 然后 sleep 2 秒」这种用例,这条路径就永远是空白。

这类问题只能靠审计发现,或者靠生产环境里「数据一直在涨」的告警。

三、失效二:只清了一半

现象

clearSeries() 清空一个系列的全部物化桶。调用后你以为数据没了。

为什么会这样

新格式桶把「桶记录」和「引用行」存在两个独立的 key 前缀下:

b:<series>:<level>:<bucket>
  ← 桶记录(元信息)

k:<series>:<level>:<bucket>:<seq>
  ← 表式引用行(实际的 refs)

清理代码只扫了 b: 前缀。

于是 k: 前缀下的引用行全部留着——成了永远读不到的幽灵行:

  • 存储只增不减(清空系列后体积不降)
  • 引用计数持续虚高(refRowsCount 把孤儿行也算进去)
  • 更麻烦的是同 key 重新物化时:新旧引用行叠加,引用数翻倍、下钻返回幽灵引用

代码注释把这个坑写清楚了:

// 桶记录与引用行是两个独立 key 前缀:
// 只扫 b: 会留下永远读不到的幽灵行
// (存储只增不减、refRowsCount 持续虚高,
//  且同 key 重新物化时新旧行叠加
//  导致引用数虚高)。

教训

当你为一个实体设计多个 key 前缀时,「删除」必须覆盖全部前缀。 这不是靠记住,要靠一个函数集中管理前缀常量——清理逻辑只引用那个函数,而不是手写字符串。

四、失效三:一个冒号串了数据

这类失效最隐蔽:数据没丢,但查错了。

机制

标签索引的 key 这样拼:

__idx:<tag>:<value>:<series>:<ts>

(实际格式略有出入,关键是 tag 和 value 直接拼进 key。)

问题出现了:

传入 拼出的 key
tag="a", value="b:c" __idx:a:b:c:...
tag="a:b", value="c" __idx:a:b:c:...

两组不同的标签,拼出了完全相同的前缀。 于是 filter(tag="a", value="b:c") 会把 tag="a:b" 的数据也一起返回。

修法

在两个入口(append 和 appendBatch)统一校验:含 : 的 tag 名或值直接拒绝。

且校验位置很讲究——注释里写:

// 批内标签校验与 append 保持一致。
// 校验写在取锁与事务之前:
// 失败不得落半个批次、也不占写锁。

两条纪律:入口校验要一致(否则单条能进、批量不能进,或反之),校验要在取锁之前(失败不该占着锁)。

为什么这类最危险

TTL 不生效你知道「没生效」;幽灵行你知道「体积不对」。而标签串数据是静默的错误结果——查询返回了数据,看起来合理,只是混进了不该有的东西。

如果你的下游在做聚合统计,这种错误会直接体现在数字里,而你会先怀疑聚合逻辑。

五、失效四:一个差 96 小时后完全落空的窗口

这个不算「失效」,但它是同一个病根:以为差不多,其实差得刚好全错。

现象

按周总结时要算「本周的起点」。代码用了一个常量:

周一 = 当天零点 - 3 天

这个公式只在当天恰好是周四时成立。周四减 3 天确实是周一;周一减 3 天会跑到上周五,差 96 小时。

为什么没被立刻发现

因为是手工测试时「今天恰好是周四」——公式对上了,验证通过。

为什么后果这么严重

摘要取回用的是窗口全等匹配(windowFrom / windowTo 必须完全相等),不是区间重叠:

查询:周窗口 [10-19 00:00, 10-26 00:00)
已存:[10-15 00:00, 10-22 00:00)
      ← 差 96 小时

结果:不是「近似命中」,
      而是直接落空 → 返回「无摘要」

「差一点」在区间匹配里等于「完全错误」。 而且错误形态是「查不到」,会让人以为是总结没跑,往另一个方向查。

修法

不用常量推算,直接用 Timeline 的日历对齐能力(它内部按 ISO 周一起算)。

这条的教训不在代码,而在验证方法:只在一个时刻验证过的时间逻辑,等于没验证。 涉及周/月对齐的代码,至少要覆盖 7 天里每一天的输入。

六、这四种有什么共同点

回头看,它们各自的表象不同,但生成机制高度一致:

# 失效 为什么
二 TTL 被丢 新分支复刻时漏了参数
三 只清一半 多 key 前缀,删除只覆盖了一个
四 冒号串数据 拼接式 key 缺少边界校验
五 差 96 小时 用常量推算日历,且只在一天验证过

可以归纳成三条可执行的纪律:

1. 新分支必须复刻旧分支的全部形参

写「新格式」分支时,把旧分支的形参列表拿出来逐条对照。编译器通常不会救你(参数未被使用未必是硬错误),测试也不会(除非你为新分支补齐同样的用例矩阵)。

2. 多前缀实体,删除必须集中管理

如果 b: 和 k: 是同一个逻辑实体的两半,那么**「清空」必须同时清两半**。把前缀定义收敛到一处,清理函数只依赖那处——而不是在两个地方各写一遍字符串。

3. 拼接式 key 要有边界守卫

任何 a + ":" + b 形式的 key,都要问一句:a 或 b 里出现分隔符会怎样? 要么转义,要么在入口拒绝。不要假设调用方不会传。

七、还有一个方法论

这次审计里最有用的一条,其实不是上面三条纪律,而是:

写完一个功能,先用「参数真的生效了吗」的方式验一遍,而不是「函数被调用了吗」。

举例:

  • 设 TTL → 等它过期,确认读不到(不是「确认 set 成功」)
  • 清空系列 → 查存储体积 / 引用计数,确认归零(不是「确认函数返回成功」)
  • 写标签 → 用构造出的歧义输入(带冒号的)反向验证不会串

「调用成功」和「生效」是两件事,而后者才是你真正需要的。


附:这些问题的数量级

tsdb-cj 目前 114 个测试用例。这次审计一次性修掉 4 处静默失效 + 1 处窗口对齐错误,改动 8 个文件、390 行新增。

值得说的是:这 4 处都不是新引入的 bug,而是从早期版本就存在——它们陪着代码跑了几十个版本、通过了每次全量测试。这大概就是「静默」二字最贴切的注脚。