项目代号定成xxx10的那天,我做的第一件事不是打开编辑器加功能,而是把代码仓库锁了三天。听起来有点夸张,但这是我做到第十个大版本之后学会的条件反射:一个项目能活到第十版,说明它确实在被使用;也说明它身上叠了九层历史包袱,每一层当时都有理由,现在都可能变成障碍。xxx10这个代号里的“10”,本质上是它第十次把整套东西重新梳理一遍的迭代节点,它考验的是还能不能安全地继续往前走,而不是还能加多少新东西。
这篇内容想聊的就是这件事:当一个项目迭代到第十版,你该按什么顺序动手、哪些地方一行代码都别碰、v9 那些没写进文档的隐性约定怎么挖出来、发布了怎么退回去。适合已经带过几轮迭代、正被“越改越怕”困住的人看;也适合刚接手一个祖传项目、需要快速摸清底细的人。文中的具体参数和节奏,是我按照常见工程实践补出来的经验值,你可以照着改,但别当成硬性标准。
1. 第十版的难点在于面对九版欠账,而不是加功能
1.1 为什么做到第十版的人总想推倒重来
我见过太多项目在第八版到第十版之间选择重写,理由高度一致:代码太乱、结构不合理、命名不规范。这个判断本身没错,但结论往往是错的。因为一个跑到第十版的项目,它的价值早就不在代码里了,而在那九版过程中被一点点试出来的边界处理上:哪些字段实际可能是空的、哪两个操作必须按固定顺序来、哪个接口的返回在某些情况下会多一层包装、哪个定时任务在整点会互相挤。
这些知识没有任何一份文档会完整记录,它们散落在提交记录、注释、甚至某个离职同事的聊天记录里。你重写的时候,等于把这些全部清零,然后靠上线后的报错一个个捡回来。我之前跟过的一个小工具线,第九版决定重写核心调度模块,代码确实清爽了,但上线头三天补了二十多个只有老代码里才存在的判断分支。那三天里团队做的所有事情,本质上是花着线上故障的成本,去重新学习自己九年前就学过的东西。
所以第十版的正确姿势不是“重新开始”,而是“把已有的东西拆开、看清、再决定动哪里”。推倒重来是最后手段,不是首选方案。
1.2 三笔最容易被低估的历史欠账
在xxx10动手之前,我习惯先把欠账分类。经验上最难缠的是下面这三类,它们共同的特点是:平时完全看不出来,一出问题就是大问题。
| 欠账类型 | 典型表现 | 爆发时机 | 处理代价 |
|---|---|---|---|
| 配置与默认值漂移 | 代码里的默认值早就不是实际生效值,真实值藏在环境变量或配置文件第 40 行 | 换环境、新机器部署、配置迁移 | 中,但排查极慢 |
| 时序与并发假设 | 依赖“A 一定在 B 之前完成”,但从没写进任何地方 | 加机器、改异步、引入重试 | 高,会复现失败 |
| 错误处理的口头约定 | 某类异常约定“吞掉就行”,没人知道为什么 | 重构异常处理、接入监控 | 高,容易误报淹没告警 |
第一类最典型。你打开代码看到超时时间是 3000 毫秒,以为改这里就生效了,结果线上跑的是配置文件里的 8000,而配置文件又是从另一个模板生成的。这种多层覆盖的默认值,是第十版最容易踩的坑,因为重构时你一定会想“顺手统一一下”,一统一就把某个环境的真实值覆盖掉了。
第二类更隐蔽。九次迭代里,一定有人为了让某个功能“先跑起来”,把顺序依赖硬编码进了调用链。当时没问题,因为单机、单线程、数据量小。等到第十版你想引入并发或者拆服务,这些假设会一个个碎掉,而且碎的时候报的错往往和真正的原因隔了十万八千里。
1.3 先把“版本账本”补出来,再谈别的
我的做法是先花一到两天,补一张版本账本。数据来源就三个:提交记录、变更日志、还有历史工单的归档。按版本号排成一张表,四列就够:版本号、主要改动、当时的动机、有没有留下后遗症。
这张表的价值在于,它会强迫你把“当时的动机”写出来。很多现在看起来莫名其妙的代码,动机写出来之后你会突然理解:哦,是因为当时那个上游接口每天凌晨会返回空数据,所以加了这个判断。理解之后你才知道能不能删。没有动机记录的改动,一律按“暂不动”处理。
补账本的过程里,我建议顺手标一个东西:每个版本上线后一周内出现的故障。你会发现故障分布非常不均匀,往往集中在两三个版本上,而那两三个版本对应的模块,就是xxx10最该优先处理的地方。这比凭感觉判断“哪块代码最丑”靠谱得多,因为丑不等于危险,出过事的才危险。
2. 重构边界怎么划:该动的地方动手,不该动的地方坚决不碰
2.1 用“改动频率 × 故障成本”给模块排队
第十版最容易犯的错,是把“重构”当成一件全局的事,恨不得每个文件都过一遍。真实情况是,一个跑了九版的项目里,百分之七十的代码在过去两年几乎没被改过,它们稳定、丑、但没风险。对这些代码动手,收益接近零,风险却是实打实的。
我用的排序方法是两个维度打分。横轴是改动频率,看这个模块在过去十二个月里被改了多少次;纵轴是故障成本,看它一旦出问题影响多大。落进四个象限之后,处理策略完全不同:
- 高频高危区:改得勤又容易出事,这是
xxx10的主战场,值得投入最多时间做结构梳理和测试补齐。 - 高频低危区:改得勤但影响小,优先做接口收口和命名统一,别做大手术,因为它的价值就在于随时能改。
- 低频高危区:很少动但一出事就是大事,比如计费、对账、权限校验这类。这里的原则是“加护栏不加改造”,补监控、补断言、补回归样本,代码结构一个字都别动。
- 低频低危区:直接冻结,连注释都别改,改了反而要重新走一遍评审。
这个排序做完,你会发现真正需要动手的地方通常只占整个项目的两到三成。剩下的部分,你的任务是“保证它不被碰到”,而不是“让它变好”。
2.2 冻结清单必须写成文档,不能只记在脑子里
我踩过这个坑。第八版的时候,我心里清楚哪几个模块不能碰,也跟团队口头说过。三个月后有个新同事做优化,顺手把其中一个模块的日志格式改了,结果下游一个靠日志做统计的脚本全挂了,排查了两个小时才定位到。
从那以后xxx10一定带一份冻结清单,文件放在仓库根目录,名字直白一点,就叫FROZEN.md。里面每个冻结项写四件事:模块路径、冻结原因、解冻条件、负责人。举个格式:
## modules/legacy-scheduler - 原因:内含 2019 年至今未记录的时序依赖,涉及 3 个上游任务的触发顺序 - 解冻条件:完成影子跑批比对,差异项全部归零 - 负责人:调度方向负责人 - 最后复核:xxx10 启动前关键是“解冻条件”这一栏。没有它,冻结就变成永久封存,几年后没人知道为什么冻着,也没人敢解。有了它,冻结就是一个有出口的状态,而不是一块飞地。
2.3 一次性大重构和分十次小重构,我选后者
这两条路我都走过。一次性大重构的问题不在于技术难度,而在于它把风险集中到了一个时间点上。你会有两到三周的时间处在“新旧代码都不完整”的状态,这期间任何线上问题都很难判断是新逻辑引起的还是老逻辑本来就有的。而且大重构的评审几乎不可能做细,几千行 diff 摆在那里,评审人只能看个大概。
分次小重构的问题是慢,一次只动一个模块,中间还要保证每一步都能独立上线。但它的好处是每一步都能验证,出问题能精确定位到是哪一步。在xxx10这种已经承载真实使用的项目上,我倾向于后者,哪怕总时间更长。
具体拆分的方式是按“可独立上线”来切,而不是按“代码归属”来切。这两者的区别很大:按代码归属切,你会得到“重构数据访问层”这样一个大块,它没法单独上线;按可独立上线切,你会得到“把用户查询的超时从硬编码改成配置”这样一个小动作,它可以单独发、单独验证、单独回滚。
3. 挖出 v9 的隐性约定:xxx10 最耗时的准备工作
3.1 隐性约定通常藏在四个地方
隐性约定是第十版最大的敌人,因为它们不出现在任何文档里,但线上系统每天都在依赖它们。按我的经验,它们高度集中在下面四个位置,排查的时候按这个顺序走,效率最高。
第一个是配置文件和默认值的多层覆盖。代码默认值、配置文件、环境变量、启动参数,四层覆盖下来,实际生效的往往是最不显眼的那一层。排查方法是把实际生效值打印出来,而不是读代码猜。加一行启动日志把所有关键配置的实际值输出,跑一次就知道。
第二个是被外部依赖的日志文案。这一点很多人想不到。跑了九年的系统,几乎一定有监控脚本、统计脚本、甚至人工排查手册在靠日志里的某段文字做匹配。你把日志格式改了,那边就静默失效,而且不会报错,只是数据慢慢变少。所以xxx10里凡是要改日志的地方,我的原则是“加字段不改旧字段”,宁可日志冗余一点。
第三个是接口里那些“宽松”的字段。比如某个字段声明是字符串,实际可能是数字、可能是 null、可能是空对象。九次迭代下来,调用方早就把这些情况都处理了,但代码里没有任何地方写明。重构时如果你加一个类型校验,看起来是提升了质量,实际是把一批正在正常工作的调用打挂了。
第四个是时间和时区。这个坑几乎每个跑到十版的项目都有。存储用的是什么时区、展示用的是什么时区、定时任务按哪个时区触发、跨月跨年的边界怎么算,这四个问题如果没有明确答案,第十版千万别碰时间相关的逻辑。
3.2 影子跑批:用同一批输入并行跑新旧两版
光靠读代码是挖不干净的,必须靠跑。我的做法是影子跑批:准备一批真实样本输入(脱敏后),让旧版本和新版本同时处理,比对输出差异。
具体落地有几个要点。样本要覆盖“正常流量”和“边角流量”,后者更重要,比如空值、超长字符串、跨越时间边界的数据、重复提交。比对不能只比最终结果,中间的关键输出也要比,否则你只知道结果不对,不知道错在哪一步。差异不能靠人眼看,写个脚本做结构化 diff,输出成一张表。
我一般会把样本量控制在几百到一两千条,太多了看不过来,太少了覆盖不到边角。跑完之后会得到一份差异清单,这份清单是xxx10最有价值的产出之一,因为它把隐性约定变成了显性条目。
3.3 差异清单要分级,不能一刀切
拿到差异之后,最忌讳的是“所有差异都要消除”。有些差异是修正了历史 bug,本来就该不一样。所以我会分三级:
| 级别 | 判定标准 | 处理方式 |
|---|---|---|
| A 类 | 影响业务结果,或下游会因此出错 | 必须消除,消除不了就回退该处改动 |
| B 类 | 不影响结果,但输出格式或顺序变了 | 评估下游是否依赖,依赖则消除,不依赖则记录 |
| C 类 | 纯内部表现差异,如日志措辞、内部字段顺序 | 记录备查,不处理 |
分级的意义在于控制工作量。实际跑下来,A 类差异通常只占很小一部分,但它们是真正会引发故障的。把精力集中在 A 类,B 类做记录和确认,C 类直接放过,整个比对环节就能在合理时间内收口。反过来,如果坚持零差异,你会发现有些差异永远消不掉,因为新版本确实修正了老版本的错误。
4. 发布与退出:第十版的退路比进路更重要
4.1 回滚不是一句版本回退就能解决的
很多人对回滚的理解停留在代码层面:出问题就把版本退回去。但一个跑到第十版的项目,回滚的难点根本不在代码,而在状态。代码可以一秒退回去,数据退不回去。
具体来说有四类状态是回滚时最容易漏的。一是已经写入的数据,新版本按新结构写的数据,老版本读不懂,或者读出来是错的。二是缓存,新版本写入的缓存格式老版本不认,回滚后会出现一批诡异的解析错误。三是已经发出去的消息,如果系统里有异步消息,新版本投递的消息老版本消费不了,会积压。四是用户已经产生的操作,用户在灰度期间做了一些新版本才有的操作,回滚后这些操作记录成了孤儿数据。
所以xxx10的发布设计里,我要求每一条改动都回答一个问题:如果现在回滚,哪些状态需要一并处理?处理不了的就不要在这个版本上线,或者改成双写、读旧写新这类兼容模式。
4.2 灰度节奏和观察窗口,我给一组经验值
灰度不是走形式,它是用最小代价换取真实反馈。我常用的节奏是五档:1%、5%、20%、50%、100%。每档之间不是等时间,而是等信号。
1% 这档主要看的是“会不会立刻炸”,观察窗口短一点,十几分钟到半小时,重点看错误率和关键接口的成功率有没有跳变。5% 到 20% 这两档看的是“长尾问题”,比如特定数据组合、特定地区、特定客户端版本才会触发的问题,观察窗口要拉长到几个小时,重点看那些平时不太动的指标,比如重试次数、超时比例、队列积压。
20% 往上主要看容量和依赖。这时候流量已经足够大,能暴露出资源层面的问题:连接数够不够、下游扛不扛得住、缓存命中率有没有掉。到了 50% 基本就是全量的预演,除了极端峰值,该暴露的都暴露了。
每一档我要求有三个明确的数字:进入条件、退出条件、熔断条件。熔断条件尤其重要,它必须是可自动判断的,比如“核心接口错误率连续五分钟超过 1% 自动回退到上一档”。靠人盯着看再决定,中间那几分钟往往就是事故扩大的时间。
4.3 把新逻辑藏在开关后面,而不是藏在分支里
第十版必然有一批改动是“新旧行为不一样”的。对这类改动,我的原则是全部用配置开关控制,而不是靠代码里的 if 判断版本。开关的好处是可以在不改代码、不重新发布的情况下切换,回退成本几乎为零。
开关设计有两个细节值得注意。一是开关的默认值必须是“旧行为”,这样即使配置没下发、开关读取失败,系统也还是跑在老逻辑上。二是开关要能被观测,也就是每次切换要记录一条日志,包含切换时间、切换人、当前状态,否则事后根本说不清某个时间点跑的是哪套逻辑。
另外要提前想好开关的下线计划。开关是好东西,但留太多就变成新的技术债。我通常会在开关上加一个“计划移除版本”的注释,到了下一版强制清理,不然三年后你会发现系统里躺着几十个状态不明的开关,没人敢删。
5. 做到第十版才想明白的几件事
5.1 有三个习惯,我后悔没在第三版就建立
第一个是变更日志。不是那种从提交记录自动生成的东西,而是人写的、一句话说清“为什么改”的日志。第十版补账本的时候我才意识到,最贵的信息不是“改了什么”,而是“当时为什么这么改”。这个习惯建立成本极低,每次改动写两行,但它在几年后能省下几十个小时的考古时间。
第二个是可回放的输入样本。每次线上出问题,修完之后把触发问题的那批输入存下来,加进回归样本库。这个库到第十版会变成一笔巨大的资产,因为它是唯一一份“真实世界长什么样”的记录。影子跑批能跑起来,靠的就是这个库。
第三个是每个版本开工前先写“退出条件”。也就是这个版本做到什么程度算完成、什么情况下允许放弃。听起来很形式,但它的作用是防止版本范围无限膨胀。第九版我们就是因为没有退出条件,从计划的两周做到六周,最后砍掉一半功能草草收尾,留下的半成品成了第十版的负担。
5.2 下一轮开工前,我会先过一遍这张清单
这份清单是xxx10收尾时整理的,现在每次开新版本前都会走一遍,大概半小时,但能提前发现大部分结构性风险。
- 版本账本是否补齐,最近三个版本的动机是否都有记录
- 冻结清单是否更新,解冻条件是否还成立
- 改动频率和故障成本的四象限排序是否重算过
- 影子跑批的样本库是否包含最近半年的故障输入
- 每一条改动是否都回答了“回滚时状态怎么处理”
- 灰度各档的进入、退出、熔断条件是否写成了可判断的数字
- 新加的开关是否都有默认值、日志和计划移除版本
- 有没有哪个模块是“只有一个人懂”的,是否需要先做知识交接
最后一条是我后来加的,因为吃过亏。某个模块一直是某位同事在维护,逻辑复杂但没有文档,他休假那两周刚好出了问题,其他人只能靠猜。第十版之后我的做法是,凡是进入高频高危区的模块,至少要有两个人能独立改,做不到就先补文档和注释,再谈重构。
5.3 关于“第十版”这个心理关口
做到第十版,人会不自觉有一种“该收尾了”的心态,想在这个版本把所有问题一次解决,做成一个漂亮的终版。我试过一次,结果是范围失控,四分之三的计划没完成,反而新增了一堆半成品。
后来我的想法变了:第十版不是终点,它只是第十次把东西重新梳理一遍。真正值得追求的,是让第十一版的启动成本比第十版更低——账本是现成的、样本库是现成的、冻结清单是现成的、谁懂哪块是清楚的。这些看起来不像“技术成果”,但它们决定了这个项目还能不能健康地活下去。
现在我评估一个版本做得好不好,看的是下一个版本开工时团队有多慌,而不是这个版本发了多少功能。慌的程度低,说明这一版的底子打对了;要是每次开工都像重新认识一个陌生项目,那前面那些迭代基本等于白干。