“你六的太狠了!牛批!我真是躲得过初一,躲不过15啊!!!”
这句话这段时间经常出现在技术群的聊天记录里。第一次看到时,我还以为谁又在玩什么谐音梗。后来才反应过来,这是程序员之间一个精准得有点残忍的日常总结:某个临时方案,当时确实让你躲过了上线检查、验收测试、或者当天就要交付的 deadline,你以为这关过了就没事了。结果到了第十五天,它在一行日志里、一次重启之后、或者服务流量突然上来的时候,把之前所有的“侥幸”一次性连本带利地追了回来。
说“躲不过 15”,其实重点不是那个“15”具体指哪一天。它真正想说的是:在软件工程里,任何靠巧合、靠记忆、靠“先跑通再说”的方式掩盖起来的问题,都不会因为你的祈祷而消失。它只是在你最没有防备的时候换了副面孔重新出现。
这篇文章不想顺着段子继续玩下去。我想把它当成一个真实工程问题的入口来聊:为什么我们总在“躲初一”?躲过之后,那个“十五”到底藏在哪里?以及一个更重要的——从一个习惯性补救的人,变成一个习惯性预防的人,中间到底需要补上哪些具体能力。
1. 为什么“躲过初一”这件事本身,就是最大的风险信号
先说一个我自己的判断:在大部分真实项目里,真正造成线上事故的,常常不是一开始就写错的复杂逻辑,而是某个“临时躲一下”的小改动。
1.1 临时方案的本质是“推迟决策”
你回想一下最近那次临时改动是怎么发生的。大概率是这样一个流程:版本计划排好了,测试也在跑,突然发现一个边界情况没处理。直接重构?时间不够。完整补测试?流程太长。于是选择了最省事的那条路——加个 if 分支,注释掉一小段代码,或者把一个常量改成看起来“差不多够用”的值。为了让发布能过,你在代码旁边加了一行注释:“临时处理,后续再优化。”
这行注释就是“初一”的仪式感。你明知道这不是长期方案,但当下最紧迫的目标是让流程往前走。这个逻辑在单次场景里没有错,它错在后续没有人真正把它“后续”掉。
从工程角度看,临时方案本质上不是一个技术决策,而是一个延期决策。你把“什么时候彻底解决这个问题”的选择权,从一个自己可控的时间点,推迟到了一个完全未知的触发条件上。而这个触发条件不会提前通知你。
1.2 为什么反馈总是来得“恰逢其时”
有经验的人会告诉你,这种“十五”最可怕的一点,是它总会在你最忙的时候出现。听起来像玄学,但从系统运行规律来看,这完全可以用概率解释。
临时方案大概率跳过了一些正常流程:没有补充边界测试、没有写清楚异常处理、没有更新相关文档。这意味着它的稳定运行,依赖的是一系列恰好成立的外部条件。比如恰好没有人改动那个方法、恰好数据格式没变、恰好并发量还在预期范围内。在条件没有变化的时候,它表现得像一棵无毒无害的小草。一旦任何依赖条件产生偏移——上游接口加了字段、配置中心换了一台机器、某个定时任务延迟了 200 毫秒——“恰好成立”就会变成“恰好不成立”。
出事时间越晚,越说明这个方案在真实环境里存活了足够久,被足够多的流量“检查”过。等到它真的坏了,通常不是坏在一个小问题上,而是坏在条件偏移累积到某个临界点的时候。这就是为什么“十五”总是给人感觉“防不胜防”。
1.3 要警惕的不是哪一次失误,而是“每次都能躲过”的侥幸
我觉得真正值得警惕的,反而不是某一次事故,而是你开始习惯“躲过去”这个动作本身。
一旦尝到了“临时改一下就能过”的甜头,人的决策模式会慢慢变化:遇到问题,第一反应不再去想“怎么彻底解决”,而是想“怎么最快绕过去”。这种心态在个人项目里危害还不算大,因为你自己知道哪些地方埋着雷。但一旦放进团队协作、多人维护、长期迭代的环境里,每一个“绕过去”都会留下一段别人看不懂的隐形成本。改代码的人以为自己在解决业务问题,实际上是在给整个系统的未来埋了一张张没有期限的欠条。
2. 那些“躲不过的十五”,最常见的四种隐藏形式
“十五”不是某一行特定代码,而是临时方案长期存在的几种典型形态。我们把它们拆开看,确认很多事故背后都有相同的基本盘。
2.1 被注释掉的“历史包袱”
项目里最常见的“十五”,是一大段被注释掉的代码。
它可能是一个没人记得为什么废弃的函数、一段调不通的第三方请求逻辑、或者一个被替换掉的旧算法。注释掉它的那个人当时可能是想“先留着,万一以后要用呢”。问题是,一旦代码进入版本管理,历史版本早就替你保管好了一切,“注释保留”根本没有必要。
被注释代码最危险的地方,是它给了后来者一个错误的信号:这段逻辑曾经是关键的,现在虽然没用了,但可能还有价值。于是有人试图恢复它,有人试图在里面找可复用的片段,还有人怕删掉之后出问题就不敢动。一个本来应该清爽的文件,逐渐变成了考古现场。真正到最后引起冲突的,往往是某次重构时工具自动格式化了注释内容,或者恢复了一段早已不兼容的旧逻辑。
2.2 靠“补丁套补丁”维持的脆弱结构
如果说注释代码是静态的隐患,那么补丁套补丁就是动态的系统性风险。
一开始可能只是一处边界判断没写好,于是你在调用处加了一个 if,把特殊场景挡回去。过了一阵子,新需求又把原来的假设打破了,于是你又加了一个状态位,这次如果需要同时兼容旧状态和新状态,就再多套一层逻辑。每一次小修复单独拿出来看都成立,没有明显错误。但把五六个这样的修复叠在一起之后,整个分支结构已经复杂到没有人能一眼讲清楚“什么条件下走哪条路”。
这种结构真正的问题不是“丑”,而是测试覆盖跟不上。你修补的都是特定场景,写测试的时候也只针对当时的输入。可当输入组合出现你从未想到过的情况时,整个分支会走出一条谁都无法预期的执行路径。到那时,你再回头去找是哪一处补丁引发的错误,排查成本会高到让人崩溃。
2.3 永远在“下周”的异常处理
很多团队都有一种心照不宣的逃避:只处理主流程,不处理异常分支。
不是说完全没有 try-catch,而是 catch 住之后只打一行日志,或者直接 return null,根本不考虑上层拿到 null 之后会怎样。这种代码看起来“稳”,因为它在异常来临时不会崩溃。但它把崩溃延迟到了更高的调用层,由另一个毫不知情的模块去承担。
等到“十五”出现时,日志里看到的是某个接口返回了空对象,但真正的根源却在几层调用以下。你要顺着调用链一点点追溯,才发现那个空对象是两个月前某人为了“先让主流程跑通”而留下的默认值。异常处理看起来最不重要,实际上它决定了系统在极端情况下的行为。真正负责的写法,不光要想“正常时怎么走”,还要想“异常时给调用方一个什么样的结果,才不至于让问题以更隐蔽的方式扩散”。
2.4 没有更新状态的“流程跳过”
还有一种很难用代码审查发现的“十五”,是流程层面上的跳过。
比如某个配置文件的修改只在本机生效,没提交到仓库。比如某个接口新增了鉴权字段,A 服务更新了,B 服务还在用老格式。再比如一次发布手册里要求做数据校验,但因为“之前都正常”,就顺理成章地跳过了。这种问题不会立刻产生错误,甚至会在测试环境完全无法复现。它只在发布到特定环境、特定流量比例下,才突然冒出来。
这也是我认为最需要警惕的一类。因为它不靠代码质量取胜,而靠流程约束取胜。个人开发时还能靠记忆力维持,一旦到了团队协作,任何“口头确认”“大家心里有数”的环节,都是潜在的失控点。
3. 别再靠“赌运气”过关:一套可落地的检查顺序
前面分析了这么多,核心是想说明:“躲得过初一”不是运气问题,而是检查机制不够具体的问题。如果我们的目标是让问题在早期自己暴露出来,而不是在线上被用户发现,那就要把“检查”从一句口号变成一套可执行的顺序。
我在自己的工程实践里,总结了一个比较顺手的检查框架,叫“三层过滤法”。它不复杂,但能有效覆盖大多数“躲初一行为”的盲区。
3.1 第一层:改动发生前,先回答自己三个问题
很多“临时方案”之所以后来变成了事故,是因为改动发生的那一刻,思考是停下来的。手比脑子快,一看到问题就立刻搜代码、改逻辑、提交,根本没有给“为什么要改”留出反应时间。
在动手前,先强迫自己回答三个问题:
- 这个改动到底是为了让什么场景变得正确?
- 如果不做这个改动,系统现在会发生什么?
- 这个改动的副作用会影响哪些既有功能?
这三个问题回答不上来任何一个,就说明你对这个改动的理解还不足以支撑你把它提交进代码库。同理,如果在改动的时候写不清“为什么”,那这个改动大概率也不是一个清晰的改动。
3.2 第二层:从输入、环境、依赖三个方向做快速验证
单次能跑通,不等于在所有环境都能跑通。在提交前,按三个方向快速过一遍:
- 输入方向:数据格式、字段为空、编码类型、超长内容、极端数值。如果输入结构发生变化,代码能不能给出合理反馈?
- 环境方向:本机、测试环境、生产环境之间,配置、路径、系统用户、文件权限、端口是否一致?有没有只在本机成立的硬编码?
- 依赖方向:第三方服务是否可用、版本是否匹配、调用是否设置了合理的超时和重试策略、上游接口返回结构是否有兼容处理?
这个过程不需要写自动化测试,只需要在脑子里快速过一遍。但如果你发现其中任何一个方向需要“假设它没问题”才能通过,那恰恰说明这里就是最可能出问题的角落。
3.3 第三层:上线后 24 小时内,主动去“找”问题
等到问题被用户发现,就已经晚了。真正有效的习惯,是在上线后的极短时间窗口内,自己先去怀疑系统。
我一般会做三件事:
- 盯关键日志,看有没有新增的 WARN 和 ERROR。
- 跑一遍主要链路的主流程,确认核心功能没有被影响。
- 用一两条边界用例,专门去触发那个“临时方案”所覆盖的场景,确认行为符合预期。
这个 24 小时窗口非常宝贵。问题刚出现时,上下文还新鲜,代码还写得动。等过了一个星期再回头看,连你自己都不一定记得当初为什么那么写了。
注意:上线后的“主动排查”不是为了证明代码没问题,而是为了证明自己还没来得及制造问题。哪怕日志一切正常,也要警惕“暂时一切正常”和“真的正确”之间的区别。
4. 一个临时代码的完整治理框架
如果你手头已经积压了不少“躲过初一”的改动,现在最需要做的不是懊恼,而是给它们挂上号、排好期、逐一消化。
4.1 给临时改动建立“技术债台账”
我见过不少项目,代码里散落着各种 TODO、FIXME、临时处理,但没有人清楚这些标记到底对应多少工作量。真正要治理,先把它们全部收集起来,放进一个表格里。这个表格不需要很复杂,通常包含以下几列:
| 字段 | 作用 | 示例 |
|---|---|---|
| 临时改动位置 | 文件、函数、行号,方便快速定位 | order_service.py L128 |
| 临时原因 | 为什么当时选择绕开 | 上游接口还没就绪,先用默认值 |
| 影响范围 | 这个改动会牵连哪些模块 | 下单主流程、订单导出 |
| 触发条件 | 什么情况下会暴露问题 | 上游新版本上线后,字段为空 |
| 负责人 | 当前最了解这块逻辑的人 | 小王 |
| 到期日期 | 计划彻底下限的日期 | 下个版本迭代 |
| 正式方案 | 替代临时改动的方案是什么 | 接上游新字段,去掉默认值 |
| 状态 | 待处理 / 处理中 / 已下线 | 处理中 |
这个台账的价值在于,它把“欠债”从一种情绪压力变成了可见的任务列表。你不需要一下子全部还清,但每个月至少处理一两项。只要状态列里有“已下线”,就说明这套机制在起作用。
4.2 一次只处理一个“十五”,不要顺手重构一切
很多人面对一堆历史债,容易犯一个毛病:好不容易决定清理了,就想把相关问题一次性全部理顺。结果往往是越改越大,最后连正常功能都被影响了。
更稳的做法是:挑一个影响范围最明确、触发条件最清晰的“十五”,单独处理。处理完只跑相关模块的回归测试,确认行为没有变化,再把它从台账里划掉。不要顺手改掉旁边的代码结构,不要在同一个提交里既修 bug 又重构。任何和当前任务无关的优化,都单独记录,再安排时间。
4.3 每次十二指肠式的“躲”,都值得复盘一次
如果一次事故里,你发现自己或同事使用了“临时方案”,不要只把代码修掉就结束。多问一句“为什么会走到用临时方案这一步”,有时候比修代码本身更有价值。
常见的原因包括:需求没有讲清楚边界、排期没有留出测试时间、依赖接口的变更没有提前同步、代码 review 流于形式。这些问题单独看都不是大问题,但一旦反复出现,就会迫使每个人习惯性地“先躲为敬”。
5. 从“躲过十五”到“让问题无处可藏”
把话题从技术细节拉回到工作方式上。我觉得“躲得过初一,躲不过十五”这句流行语,本质上是在描述一种被动式的工作习惯。一个被动工作的开发者,总是在等系统用事故来告诉他哪里有问题。这种方式的成本太高,反馈周期太长,而且每次事故的代价都可能超出预期。
反过来看,主动工程能力的核心,不是“不会出问题”,而是“问题出现得更早、更小、更好定位”。要做到这一点,靠的不是某个高深的算法,也不是某套炫酷的架构,而是一堆小到容易被忽略的习惯:
- 每次提交前,先看一遍 diff 里有没有“临时”“稍后”“随便”这样的字样。
- 每次写完异常分支,问自己一句:如果这里出错了,日志能让人一眼看出原因吗?
- 每次发现一段没人能解释的注释代码,不是去猜,而是去看版本历史,找到它的出生原因。
- 每次流程跳过之前,先明确一点:这次跳过,靠什么来保证不会出问题?
这些习惯单独拿出来都不难,难的是不让“赶进度”带跑自己的判断。长期来看,一个系统的健康程度,不是由技术水平最高的那个开发者决定的,而是由所有人默认接受的最低代码标准决定的。如果你想让自己所在的项目不再经常“躲初一”,最有效的一步,就是把那个最低标准往上抬一点点。
“十五”终究会来。但如果我们提前把该检查的检查了、该记录的记录了、该修复的计划排好了,那它来的时候,就不再是事故,而只是一次普通的功能迭代。
下次再有人感慨“躲得过初一,躲不过 15”时,你至少可以有自己的答案:初一不该靠躲,十五也不是要靠运气去赌。真正值得练的能力,是让问题在它很小的时候,就无处可藏。