当代码仓库不再描述生产环境
我们提供十六套文件模板。每一套都是一份模式——要抽取哪些字段、它们是什么类型、以及抽取它们的提示词——而每一套都以数据库表中的一行存在。
这些模式定义在代码仓库里。而事实证明,它们同时也定义在生产环境里,内容不同,并且已经这样好几个月了。其中一半彼此不一致,而且是同时向两个方向不一致,我们手上没有任何工具能告诉我们这件事。
这是一则案例研究:存放在数据库里的配置如何漂移、"仓库是唯一真相来源"为什么会悄悄地不再成立,以及那个把隐形问题变成十五行报告的小脚本。
通往同一批行的两条路径
模板模式通过两种方式进入数据库。
种子脚本定义全部十六套并整体写入。它在"仓库"这个意义上是真相来源:读它,你就知道一套模板应该是什么样。
定点迁移修正某一套模板的某一个字段——加一个占位符、去掉一个必填标志、重写一段提示词。
各自都合理。合在一起就是陷阱,而这个陷阱有个具体形状:种子脚本不能在生产环境运行。 它会重写全部十六行的每一个字段定义和每一段提示词,也就会抹掉迁移小心翼翼打上的那些定点修正。
于是种子脚本只在本地和测试里跑,迁移在生产里跑。这意味着:只落在种子脚本里的修正永远到不了生产——而只落在迁移里的修正,会在下一次重灌开发库时被抹掉。
在这样的安排里,漂移不是风险,它是默认状态。
同时朝两个方向
等我们终于去比对时,十六套模板中有八套发生了漂移,而有意思的是,它们漂移的方向是相反的。
生产环境有仓库从未见过的修改。 出生证明模板带着四个在任何提交里都不存在的字段定义:back_side、father_citizenship、mother_citizenship、seal。有人直接、并且正确地修好了什么,而仓库对此一无所知。更糟的是:我们自己的测试把这四个记成了被接受的"孤儿"——我们决定容忍的未映射字段。它们根本不是孤儿,它们就是那次修复。
仓库有生产环境从未见过的修改,而这个方向正在丢数据。韩国出口报关单的模式声明了十三个字段——运费、集装箱封志、许可证号、装运截止日期等等——而生产环境的提示词从未问过它们。抽取器把 JSON 键绑定到已声明的名字上,但一个提示词从不提及的字段,模型没有理由去填。于是这十三个字段在生产中一直返回空值,出现在每一份韩国报关单上,持续了两边分道扬镳的整段时间。
没有人报告过。一个空字段,看起来就像文件里本来没有的字段。
我们缺的那样东西
这件事拖了几个月,原因简单得令人难堪:没有办法去问。
要比对,就意味着读种子脚本、导出生产行、再用眼睛去比十六份嵌套的 JSON 结构。没有人会凭直觉去做这件事,也不存在任何告警,因为根本没有东西在看。
所以我们写了能起作用的最小的东西:一个脚本,读取种子脚本里的定义,读取数据库里同样那些行,打印出差异。只读,可以安全地对着生产运行,大约一百行。
第一次运行就产出了上面那份关于八套模板的报告。本文中的每一项发现,都来自它的第一次执行。
这才是真正的教训,而它与模板无关。活在数据库里的配置需要一个 diff,否则它会漂移,而且没人会发现。 代码自带这个能力:git status 持续且免费地回答这个问题。数据什么都没有,而这种缺失是看不见的——你不会注意到一项缺席的检查,你只会注意到它本可以抓住的那些失败,也就是说,你不会注意到。
我们在下一层又犯了同样的错
下面这部分值得一读,因为它发生在我们以为问题已经解决之后。
那个 diff 比较了两列:字段定义和抽取提示词。那就是模式。感觉上很完整。
但模板还带着路由配置——决定一份文件匹配哪套模板的关键词、把它排除掉的负向关键词、以及匹配阈值。同一张表、同样两条写入路径、同样暴露于漂移。而对一个只看两列的 diff 来说,它们是隐形的。
而它确实漂移了,并且有后果。我们的护照模板正在把个体工商户登记摘要收入囊中:在自己 0.3 的阈值上给出自信的 0.367,而那是一份六页、其中没有护照的文件。该模板没有任何一个标识性关键词命中。把它带到那里的是两个通用短语——"公民"和"俄罗斯联邦",它们几乎出现在每一份俄罗斯正式文件里——因为登记摘要会引用经营者的身份证件。
我们造出了让漂移可见的检查,然后又把它的范围收窄到让下一次漂移不可见。关掉一条通道、却任由旁边另一条敞着,是一个非常容易犯的错误,而且它看上去恰恰像是尽责。
修复只花了三行:把路由列加进查询。真正要紧的验证是确认这个 diff 确实看得见路由变化——我们把修复回滚,看着报告点名了那处差异,然后再滚回去。
如果有人把配置放在数据库里,我们会说什么
1. 默认它会漂移,在需要之前就把 diff 造好。 这是一天的工作量,回答的是一个你否则根本无法提出的问题。我们那个在第一次运行时就找到了八个问题。
2. 比对整行,而不只是你心目中的"模式"那部分。 你漏掉的那一列,正是下一个意外藏身之处。问问自己:这一行里还有什么会改变行为。
3. 让它能安全地在生产上运行。 只读、不写、无副作用。一项让人不敢运行的检查,就是不会被运行的检查。
4. 把无法解释的生产状态当作修复,而不是噪声。 我们的测试把四个合法的生产字段归类成了被容忍的孤儿。容忍清单,正是漂移的证据被送去遗忘的地方——去审一下你的那份。
5. 验证这项检查确实能发现它声称能发现的东西。 故意弄坏点什么,确认报告把它点了名。一个从没人见过它失败的 diff,还算不上一个 diff。
一般形态
"仓库是唯一真相来源"这句话描述的是一种愿望,而不是一种机制。只有当某样东西在执行它时,它才成立。对代码,执行它的是部署:运行的就是被提交的。
对数据库里的配置,默认没有任何东西在执行它。那一行就是最后一个写入者写下的样子,而这些写入者彼此并不一致。"数据即代码"是口号;它的运维含义是:数据需要代码白得的那样东西——一次持续的、廉价的、乏味的比较,比较你的本意与实际存在的东西。
我们那个跑四秒,全部一致时打印一行。这一行——16 templates match the seeder——如今是部署检查清单的一部分,也是这种事再次发生时我们唯一会察觉到的原因。
要点
- 两条写入路径指向同一批行,就保证会漂移,尤其当其中一条无法安全地在生产运行时。我们十六套模板里漂了八套。
- 漂移是双向的。 生产带着四个任何提交里都没有的字段;仓库带着十三个生产提示词从不索取的字段,在每一份文件上静默返回空值。
- 它持续数月的原因是:没有东西能提出这个问题。 一个一百行的只读 diff,在第一次运行时就找到了全部。
- 随后我们在下一层犯了同样的错,把 diff 的范围收窄到模式列,让路由列保持隐形——护照模板正是这样开始收走工商登记摘要的。
- 容忍清单是漂移的藏身处。 被我们测试当作孤儿接受的四个字段,实际上是一次没有记录在案的生产修复。
FAQ
什么是数据库中的配置漂移?
它指的是:你的代码所假定的配置,与实际存储的配置之间产生了分歧。只要有不止一条路径写同一批行——比如种子脚本加上迁移——而又没有东西持续比较两者,它就会发生。
为什么不能干脆在生产上跑种子脚本?
因为它会整体重写每一行,从而抹掉迁移打上的定点修正。这正是两条路径存在的理由,也正是它们分道扬镳的理由:在生产上安全的那条路径,并不是在仓库里定义真相的那一条。
怎样发现漂移?
写一个只读脚本,从你的仓库读取定义、从数据库读取同样那些行,然后打印差异。它很小、可以安全地对着生产运行,而且它是唯一能把隐形问题变成一份报告的东西。
diff 应该覆盖哪些列?
所有会改变行为的列,而不只是你心目中属于"模式"的那些。我们把范围限定在字段定义和提示词上,而路由列——关键词、排除词、匹配阈值——就一直隐形地漂着,直到护照模板开始收走工商登记摘要。
怎么知道 diff 是有效的?
故意弄坏它。回滚一次迁移、跑一遍报告、确认它点名了那处差异——然后再滚回去。一项从未被看到失败过的检查,只是被写出来了,还没有被测试过。
结语
这个故事里没有任何一处需要某个人犯错。每一次迁移都是对的,每一次种子脚本的改动都是对的,把种子脚本挡在生产之外也是对的。整个失效来自一次比较的缺席——一件造起来花一天、此后每次运行花四秒的东西。
如果你的配置活在数据库里,今天值得问的问题不是"它漂了吗",而是"如果它漂了,我会怎么发现"。如果答案里包含"把两样东西并排放着眯眼看",那你已经知道该造什么了。
如果你想要一套在每次部署时都会把模板模式与仓库核对的文件翻译,试试 KTTC。
