一条分区规则,静默算错了六个租户
前一篇讲判定层怎么不腐烂,那里所有的判定都能写成确定函数,能自测。这一篇讲剩下的那一半:有一类判断写不成确定函数,因为它需要的知识不在数据里。
一条规则,静默算错六个租户
一次从 ClickHouse 到 Doris 的迁移,十三个租户,同一张表要在目标库重建分区。分区键取源表的一个时间列,看上去是一条规则能办完的事。
实际情况是这个时间列逐租户不同。七个租户用的是告警产生时间,另外六个用的是入库时间。两个列之间差着一整条链路的延迟。
按一条规则处理的后果:那六个租户的数据落进错误的月份分区。而分区列是 UNIQUE KEY 的一部分,月份错,key 就错;key 错,该合并的行不合并,去重结果是错的。全程没有任何一条报错。导入成功,查询有结果,数字是错的。
这个失败有三个性质,值得单独拎出来:判错的原因是实体异构而规则同构;错误静默;发现它需要同时看源库的列清单、目标库的 DDL、以及每个租户各自的历史。
这是一类问题,不止一个案例
同一段时间里攒下的其他几条:
| 声明的那份 | 实际生效的那份 | 断裂时的表现 |
|---|---|---|
SHOW INDEX 里这条索引存在 |
588 个 tablet 中 566 个从未回填 | 查询走不到索引,只是慢 |
PVC 状态是 Bound |
没有任何工作负载引用它 | 持续计费 |
| values 里配了这个环境变量 | 代码读的是另一个带前缀的名字 | 静默取默认值 |
--set foo.enabled=false |
这个 key 在整个仓库里不存在 | flag 无效 |
values 写 redundancy_mode: triple |
subchart 的 helper 硬编码了 double | 生产门禁要求 triple,实际是 double |
| chart 里成员副本数声明为 0 | 这些成员正在服役 | 一次 upgrade 会把它们一起停掉 |
排在一起,形状是同一个:声明的那份和实际生效的那份对不上,而两侧各自都合法,所以系统没有理由报错。 索引确实声明过,tablet 确实是 fail-open;PVC 确实是 Bound,确实没人用;变量确实配了,代码确实读的是另一个名字,每一侧单独看都通过校验,只有把两侧摆在一起才看得见。
判别特征是 fail-open:断裂的时候,系统继续正常运转。
为什么这类活一直排在最后
三个门槛,前两个是技术的,第三个是经济的,而第三个才是决定性的。
判据是逐实例的:一条对七个租户成立的规则,在另外六个上会给出一个看起来很正常的错误答案,要正确就得逐个判,逐个判在过去意味着逐个花人力。
证据跨介质:判断一个环境变量有没有生效,要读源码里它是怎么被拼出来的;判断一个卷能不能回收,要读停机记录和聊天记录里人的意图,这两类知识都不在任何结构化状态里。
单次收益太低:每一件单独看都是二三十分钟的交叉核对,没有交付物,可以无限推迟。合起来收益很高,单独看每一件都不值得现在做。
第三条在过去无解,逐实例判断的成本是人力,现在降到了几分钱 token 的量级。这类工作总量一直很大、单件价值一直很低,成本结构变了,它才第一次值得做。
policy-as-code 到这里就停了
OPA、Kyverno、Conftest、Steampipe 解决的是同一个问题的另一半。它们要求两件事:事实是结构化的,判据能写成可判定的布尔表达式。
前面那几条正好卡在这两个要求之外。判断一个 key 配了有没有生效,需要知道代码里它是怎么被拼出来的,这份知识在源码里。判断一个卷是孤儿还是暂停待复活,需要知道人的意图,这份知识在变更单和聊天记录里。判断分区源列该取哪个,需要知道每个租户这张表的历史。
所以分界线不在工具好不好,在判据能不能写成函数。能写成函数的那一半交给脚本,那是上一篇讲的探针。剩下的那一半才需要别的东西。
判据是资产,裁决是消耗品
过去这类活的人力全花在裁决上:十三个租户,每个查二十分钟,判据活在做这件事的人脑子里,做完就散了,下一次迁移,同样的二十分钟乘以新的租户数,再来一遍。
反过来安排:人只产出判据和判据的反例,取证和逐实例裁决交给 agent,判据写一次,裁决跑 N 次。
这个分工的价值不在省掉的那几个小时,在于把一次性的劳动换成了可累积的东西:判据会随着使用变准,裁决结果用完就扔。
反例是判据的一部分
上一篇里,探针的防腐机制是 --selftest 加脚本哈希加 --schema 对账。探针是确定函数,所以它能自测,改了能被发现。
写不成函数的判定没有这套机制。它的对应物只能是反例。
一条判据至少带两个样本。must_not_flag 是一个看起来违例但其实合规的实体,它防假阳性;must_flag 是一个已知违例的实体,它防假阴性。前者更要紧,因为它正好是写死规则会踩的那个坑。
拿孤儿卷举例。判据的朴素版本是「状态 Bound 但没有任何工作负载引用」。这个判据会误杀一个基准测试环境:它确实零工作负载,但它是十几个租户迁移的唯一数据源,属于暂停不是废弃。一个只看「有没有在跑」的规则会把它当垃圾清理掉。
所以这条判据的 must_not_flag 就写它:
1 | name: orphan_volume |
规则因此很简单:写不出 must_not_flag 的判据,说明还不知道它会在哪里错,禁止投入运行。
检查器自己也在转述
这批案例里最值得说的一条,主角是检查工具本身。
有一个做迁移前 schema 对照的脚本,三方比对源库快照、目标 DDL、各租户列清单,用的是一个默认快照路径。快照过期之后,脚本照常运行,照常输出「零漂移」。
检查器和它要检查的东西栽在同一个失败模式上:声明的那份(我检查过了)和实际生效的那份(我检查的是三周前的数据)脱节,而且静默。
对策是把新鲜度做成 fail-closed 的前置检查。每个证据源先打印取得时间,任一超过阈值直接终止,不许带着过期输入往下跑。
这是一条更一般的原则的实例:安全机制要接受和它保护的代码同等的怀疑。
三态里最要紧的是 unknown
沿用探针的返回契约,verdict 三态:violated、clean、unknown。
unknown 在这里的分量比在探针里还重。探针的 unknown 多半意味着数据缺失,判据的 unknown 常常意味着人的意图那一层没查到。
规则是:查不到意图记录时判 unknown,禁止判 violated。因为 violated 会变成一条删除建议,而误删和漏删的代价完全不对称。
报告里 unknown 单独成节。它标出的是可观测性缺口:这些实体判不了,是因为某一类证据在这个组织里根本没有沉淀的地方。这份清单比违例清单更有长期价值。
为什么落成 skill 而不是一个 repo
这套东西最初的设想是一个仓库。想清楚之后改成一份 skill,放进自己的 workspace,名字叫「声明态 vs 生效态审计」。
理由是判据的复利来自被调用。跑过一轮之后,新发现的判错样本回填进 counterexamples,判据才会变准。一份没有调用入口的判据集合,结局可以预测。
有现成的证据。手上有一份写于八月初的孤儿资源清单,markdown 格式,列了资源、容量、月成本,命令都写好了。文件里有一句原话:命令都写出来了但一条都没执行,全部待人工审。这份清单现在躺在 _archive/ 目录,而那些资源还在计费,量级是每月数千美元。
判断做过了,判据没有留下来,执行入口也没有。这正是要避免的结局,skill 的形式解决的是入口那一半:它在真实工作里被调用,调用的时候反例会被回填。
这篇同样是设计,不是复盘。三条种子判据里,只有孤儿卷那条有完整的真实数据支撑,另外两条的反例来自单次观察,还没经过第二次验证;反例回归目前是规范,不是代码,判据改了而反例没跟上的检测,对应探针那边的脚本哈希自检,这里还没有实现。还有一个问题没解法:上一篇里探针库的垃圾回收靠调用记录,用得少的淘汰掉,这个机制在判据上不成立,巡检判据按周期跑,调用次数和它有没有用没有关系,判据数量膨胀之后怎么收敛,目前只有「同一类超过五条先合并再新增」这种手工约束,不够硬。