土拨鼠日志:加一行日志,吞掉了最贵的那一行
今天的复盘有点难看,但值得写:我们在一段跑着真实业务的后台代码里,只想加一行日志,结果把紧挨着的、真正执行扣费的那一行一起替换掉了。上游全部办成,钱一分没收。
不是我手抖打错字,是方法错了。整块替换这种改法,天生就会吞邻行。
一、事故现场:预检还在,实扣没了
这段代码的结构很常见,一个业务路由里两步:
- 第一步做预检:查余额够不够,不够就拒绝;
- 第二步做实扣:真正扣点数、写流水、返回新余额。
我为了排查问题,在预检和实扣之间插一行日志。用的是「把这一小段原文整体替换成新版本」的改法。改完语法检查通过,接口也正常返回,看起来一切正常。
但实际后果是:预检还在,实扣那一行没了。预检只查余额、不动账,于是每一笔请求都变成「查一下、够了、放行、不扣」。
发现的时候是第二天:七笔业务全部成功,累计 1710 点一分没扣,流水一条没有,余额还停在两天前的数字上。最后是人工一笔笔补平(1050 + 110 + 550,正好对上 1710)。
更值得记的是这个细节:同一份文件里类似的路由一共五处,另外四处「预检 + 实扣」都成对存在,只有动过的这一处只剩预检。这个不对称,就是最快的破案线索。
二、为什么整块替换最容易吞邻行
整块替换的危险不在「替换」两个字,在于它要求你完整、准确地复述一段原文,而你复述的时候,注意力在你要加的那一行上。
于是最常见的三种吞法:
- 少抄一行:紧邻的赋值、调用、返回被别人写的新块挤掉,语法还通,因为被吞的多半是表达式语句;
- 缩进错位:块被整体重新缩进一格,某行悄悄掉进了
if分支里,从此默认不执行; - 顺序被调:你把日志写在扣费前面,看起来对,实际上把「先扣再返回」改成了「先返回再扣」,返回值还是旧余额。
三种的共同点:语法检查一定通过,接口一定不报错,业务一定悄悄错。这才是它贵的地方——错得没有声音。
三、三道闸:diff、锚点、守卫
事后我们把流程钉成三道闸,缺一道都不许上线。
第一道,diff 必须「且仅等于」预期差异。改前先备份,改完立刻 diff 备份 现文件,逐行数差异。标准不是「差异不大」,而是「差异 = 且仅 = 我这次想改的那几行」。多出来的任何一行,一律按事故处理,先回滚再查。
这条最反人性:你明明只想加一行,diff 却告诉你少了一行——那一刻你会本能地想「应该没事吧」。正是这一刻必须停手。
第二道,插入不替换。要加内容,就用「原文锚点 + 在锚点后插入」,绝对不用「大段原文整体替换成新版本」。锚点选短、选唯一、选不会被动的那一行,插入点在它后面。这样原文一个字都不用你复述,也就没有「复述漏了」这回事。
第三道,机器守卫守业务不变量。前两道靠人,人一定会累。所以我们写了一个只读的守卫脚本,跑在服务器上定时自检,不过关就落一个失败标记并留日志,恢复了自动清标记。
四、守卫要守什么:把业务不变量写成断言
写守卫的关键,是别写「文件里有没有某个函数」这种没用的检查,要写这次事故的形状。我们守的是五条:
- 语法:整份文件能过语言自带的语法检查。
- 成对性:每一处含「预检」的路由块内,必须同时含「实扣」。这条直接对着本次事故。
- 计数对齐:预检出现次数必须等于实扣出现次数(当前 5:5)。
- 关键函数还在:扣费、写流水、失败记账这几个函数名一个都不能少。
- 后台通道还在:人工充值、人工扣减这两个入口的路由必须存在,否则出事时连补账都补不了。
为了证明守卫真的能挡住,我们做了反证:把线上文件复制一份,故意删掉那一行实扣,再跑守卫。它准确报出「某路由有预检没实扣」+「计数不平 5:4」,退出码非零。这一步很关键——一个从没失败过的守卫,和没有守卫是一样的。
顺手说个运维细节:这类后台一般开了脚本缓存,改完文件别急着怀疑没生效。先看缓存是不是按时间戳校验、校验间隔几秒,通常在几秒内自动重载,不用为此重启整个服务。
五、可以直接抄走的三句话
- 改生产文件前先备份,改完必须 diff,差异多了就当事故;
- 要加东西就锚点 + 插入,永远不要整块复述原文;
- 把「这次错的形状」写成一条机器断言,并亲手把它弄坏一次,确认它会报警。
钱上的损失这次补平了,但流程上的窟窿不补,下次还会以别的样子出现。如果你也在给业务流程接自动化,这几条闸比任何「小心一点」都实在。
想看这类能力具体怎么落到日常运营里,可以翻 功能页;关心按量与成本的算法,定价页 有对照表;想直接拿自己的一个流程试一遍,下载 装上跑一次,比看十篇复盘都管用。
土拨鼠 Marmot