上个月我在一个服务里搜了一下 TODO 和 FIXME,一共 89 条。我顺手跑了 git blame,按年份统计了一下:
- 2019 年:7 条
- 2020 年:15 条
- 2021 年:21 条
- 2022 年:18 条
- 2023 年:16 条
- 2024 年之后:12 条
七年前留下的临时方案,今天还在生产环境上跑。这篇就是这场代码考古的记录。
出土文物一:那个 2019 年的重试
原文是:// TODO: 临时加个重试,等上游修好了删掉。
我去查了一下,那个「上游」在 2021 年就已经下线了。也就是说这段重试逻辑现在重试的是一个根本不会失败的调用,纯粹浪费。
但我不敢直接删。因为那七年里,有人在重试的分支里加了埋点,另一个人在重试次数上做了配置化,第三个人在重试失败时加了告警。这段「临时代码」已经长出了三条依赖。
这就是临时方案的第一个宿命:它会被别人当成永久设施来使用。你留的是脚手架,别人在上面盖了房子。
出土文物二:写死的白名单
一个包含 6 个 ID 的数组,注释写着「临时白名单,下版本改成配置」。提交时间 2020 年 11 月。
我数了数,这个数组现在有 47 个元素。五年里,每次有新需求,就有人往里加一个。有三个人分别在不同时间加了注释说「这里应该改成配置」,但没人改。
为什么?因为往数组里加一个元素要 30 秒,改成配置要半天。每一次单独看,加一个元素都是理性选择。
出土文物三:那个 sleep
找到一行 sleep(500),注释:// 临时规避时序问题。
我花了两个小时想搞清楚它规避的到底是什么时序问题。翻了当时的提交记录、issue、聊天记录(还好聊天记录还在),最后拼出来了:那时候有个异步任务和主流程存在竞态,作者不知道怎么改,就 sleep 了一下。
而那个异步任务在 2022 年的一次重构中已经改成同步的了。这个 sleep 现在纯粹是在浪费每个请求 500 毫秒。
我算了一下,这个接口日均调用 12 万次。五年时间,这行代码累计浪费了大约 833 小时的墙上时间。
为什么临时方案会变成永久
考古完我想了几天,总结出三个机制:
机制一:没有痛感
临时方案之所以是临时方案,就是因为它能工作。能工作的东西不会有人来催你改。真正会被改的是那些每天出问题的东西。
换句话说,临时方案的存活恰恰因为它做得足够好。这有点讽刺。
机制二:知识随人流失
写下临时方案的人,脑子里有完整的上下文:为什么临时、正式方案该怎么做、什么条件下可以替换。这些东西大部分没写下来。
人一走,代码里就只剩下一个孤零零的 TODO,后来的人只能看着它发呆。上面那个 sleep 就是典型——我花两小时才拼出的上下文,写下它的人只需要三句话就能说清楚。
机制三:TODO 是免费的
写一个 TODO 的成本是零,而且它给人一种「我已经处理了这件事」的心理安慰。实际上什么都没发生。
我们组有个人说过一句很扎心的话:
TODO 是写给自己良心看的,不是写给项目看的。
我现在的做法
我没有解决这个问题,我怀疑它无解。但我调整了几个习惯,让代价小一点:
- 临时方案必须写清楚删除条件。不写「以后改」,写「当上游 X 服务支持 Y 接口后,删除本段」。这样后来的人有明确的判断依据。我在自己写的临时代码里全部这么做,效果明显。
- 能加过期告警的加过期告警。有一次我写了个临时逻辑,同时加了一行:如果当前日期超过某个时间点,启动时打印一条 WARN。半年后这条 WARN 真的出现了,我们就真的去改了。这个小技巧成功率意外地高。
- 临时方案要难用一点。这条听起来很反直觉。上面那个白名单数组,如果当初写成一个需要重新编译才能生效的常量,可能早就被改成配置了。让临时方案保持不便,才能维持改它的动力。
- 每季度做一次考古。就是我上个月做的这件事。花半天,跑一遍 TODO 统计,挑三条最老的处理掉。三条不多,但一年就是十二条。
最后
那次考古我最后动手清理了 5 条,包括那个 sleep。改完压测,P99 从 640ms 降到 130ms。
我在提交信息里写:「删除一行 2020 年的临时 sleep」。这大概是我今年性价比最高的一次提交——改动量一行,收益 500 毫秒。
然后我在别的地方加了两个新的 TODO。人就是这样,我也没有例外。