凌晨一点半,值班群弹出一条告警。负责核心对账的老系统挂了,而真正敢碰这块代码的人,要么在休假,要么已经离职,剩下的我们对着一屏十年前写下的代码面面相觑。那一刻我意识到,所谓“没人敢动的遗留系统”,正在 2026 年变成越来越多团队的日常。
这里的“没人敢动”不是夸张。它通常意味着:没有测试,没有文档,注释和业务早就对不上;唯一的部署脚本存在某个同事的笔记本里;改一行日志都可能触发连锁故障。于是整个组织达成了一种默契——别碰它。但业务不会因为代码老就停止变化:合规要加字段,渠道要接新接口,数据要换口径。总有人会被推去碰那块禁区。
这篇文章不是来教你“优雅地重写一切”的。恰恰相反,这几年我亲手维护过好几个这样的老骨头,最深的体会是:先学会怕,再学会动。怕,是指对未知依赖的敬畏;动,是指用一套可验证的小步法,把地雷区切成能走的路。下面这些内容适合两类人:刚被安排接手老模块、正在焦虑的程序员,以及想带团队走出存量泥潭的技术负责人。我会从恐惧的来源讲起,再讲侦察方法、改造打法、实操坑点,最后聊聊怎么让“维护”这件事在团队里长期存活。
1. 先承认吧:不敢动,是因为还没摸清它的脾气
很多团队把遗留系统直接等同于“烂代码”,但我觉得这个判断太偷懒了。烂,顶多说明读起来难受;真正让人不敢动的,是未知。你不知道它依赖什么、被谁依赖、跑在什么隐性假设上,所以每次改动都像蒙眼下楼梯,脚下悬空。
1.1 一碰就炸的三个典型现场
先说我亲眼见过的三种“一碰就炸”场景,它们能解释为什么大家会对老代码形成条件反射式的恐惧。
第一种是隐式全局状态。有个老系统启动时按固定顺序加载配置,同一个配置项先被默认值覆盖、再从环境变量里读,找不到就悄悄 fallback 到降级路径。某次为了“加一个似乎无用的开关”,有人多写了一行配置,结果所有请求走了降级模式,线上成功率直接掉了两个点。问题不在那行配置,而在于加载顺序这个全局状态,从来没有被任何人写进文档。
第二种是共享底层数据。某个跑批任务一直复用另一个模块的临时表当数据源,两个模块之间没有任何声明过的依赖关系。后来有人觉得“这个索引没人引用”,顺手删了,结果月底批处理从两小时变成八小时,吓得当场回滚。你以为你只改了自己的模块,其实你动的是别人的地基。
第三种是逻辑藏在魔法里。老系统的计算顺序依赖字符串排序,日期处理隐式依赖服务器时区,半夜部署一次系统时钟校准,第二天对账结果全不对。这类问题最恐怖的地方在于:代码看起来很正常,你压根不知道业务正确性建立在一个“谁也不记得的假设”上。
1.2 为什么坏代码能活到 2026 年
从 2026 年回头看,这些代码能活到今天一点都不意外。当年它们上线时,业务压力远大于工程质量压力,考核指标是“功能按时交付”,不是“代码容易维护”。一批人走了,下一批人只敢在边上打补丁,第三批人连看都懒得看。维护老系统没有独立的预算口径,也没有度量指标,所以它永远排不进迭代计划。
更要命的是,重写的成本被严重高估了,但也确实高。老系统里往往沉淀着大量业务规则、踩坑经验和隐性约束,这些知识大部分不在代码里,而在活人的脑子里。人走了,知识就断了。于是每届团队都评估过“要不重写吧”,每届团队都在评估完后退缩。拖着拖着,系统就活到了 2026 年。
1.3 “越没人碰,越没人敢碰”的自我强化
在遗留系统上待久了,你会发现一个有点反直觉的现象:没人碰的时候,它反而很稳定。所有改动都发生在系统边缘,大家用外部脚本、额外配置、临时表在外面绕来绕去;核心代码一两年不动一次,自然也就不出故障。这种“脆弱但稳定”的平衡,反过来强化了所有人的信念——不能碰。
真正的危险时刻,恰恰是“第一次必须动它”的时候。需求绕不过去了,合规必须加字段了,某个人终于要打开那块封存已久的代码了。因为没有测试、没有文档、没有演练,第一次改动大概率要出事。出事之后,团队更不敢碰,平衡进一步加固。我听太多人说过“这块代码谁碰谁倒霉”,其实他们说的不是代码,而是团队对未知的集体回避。
2. 接手的第一个月:不写代码,先把地雷图画出来
我每次接手老系统,前两周基本不写业务代码,只做一件事:利用现有的监控、告警、故障记录,反推出这个系统真正要保护的路径。读代码当然重要,但盲读十年前的代码效率太低,还不如先让“历史事故”告诉你去哪读。
2.1 从故障史和告警史里找“疼痛度排行”
具体操作不复杂,我一般按五步走:
- 导出过去 12 到 24 个月的生产告警和故障复盘记录。
- 按模块聚合,统计每块出故障的次数和平均恢复时长。
- 按“频率 × 影响面”排序,得到一张疼痛度排行。
- 挑出排行前十的核心链路,去代码里定位入口。
- 再把每条链路对应的系统负责人、文档、测试现状标出来。
这个方法的逻辑很简单:告警和故障是最诚实的数据,它们不会骗人。系统哪些地方真正脆弱、哪些地方只是看起来老,事故记录里写得明明白白。我之前维护过一个对账模块,全系统 70% 的故障都发生在那里,可在文档里它反而被认为“很稳定”——因为没人敢动所以稳定,一旦有需求就炸。有了疼痛度排行,我就能理直气壮地把有限的精力集中在这 30% 的高危路径上。
2.2 为最痛的路径画“测试缺口地图”
先说明一点,我不追求“把所有模块补满单元测试”,那不现实也没必要。我更建议画一张测试缺口地图:对每一条关键链路,记录四个问题的答案。
- 有没有自动化测试?有的话覆盖什么场景?
- 有没有文档?文档和当前行为一致吗?
- 有没有人知道谁会调用它?调用方改动的联动影响是什么?
- 有没有可执行的手工操作手册?还是全靠某个老师傅口口相传?
把答案填完,你手里就有了一张真正可决策的地图:哪里需要先兜底,哪里可以先不动,哪里连手工演练都没有必须补上。这张图不是给别人看的,是给你自己后续动刀时用的。我习惯把它挂在项目主页最显眼的位置,每补一项测试就勾掉一项,非常有成就感,也方便向团队同步进度。
2.3 AI 助手负责提假设,人负责验证
2026 年的代码理解工具已经非常强了,读老代码这件事,现在基本可以让大模型打前站。我常用的方式是让 AI 干三件事:总结某个函数的完整调用链、列出函数对全局变量和外部存储的所有读取、指出代码里可疑的隐式假设。这些工作过去要花一个下午,现在十分钟就能拿到一份草稿。
但请你记住一句话:AI 是侦察兵,不是指挥官。它给的是假设,不是结论,尤其对于没人敢动的老代码,幻觉的代价太高了。我的做法是拿到 AI 总结后,再通过运行时日志和实际调用去核实关键结论。比如它会说“这个函数只在深夜跑批时被调用”,我会去查一下告警记录和时间窗口,确认真的没有其他入口。验证过的假设才算是地雷图上的信息,没验证的只能算猜。
3. 真正动刀的三板斧:特征测试、防腐层、绞杀者
侦察完成之后,就要面对真正的课题:怎么改才能既解决问题、又不把系统炸了。我实践下来,最有用的三招是特征测试、防腐层和绞杀者模式。它们分别解决行为锁定、边界隔离、逐步替换三个问题。
3.1 特征测试围剿法:先锁定现状再谈优化
特征测试这个概念可能有人不熟,它有时候也被称为“黄金测试”(Golden Master)。核心思想非常朴素:我不先假设系统应该怎么样,我只记录它现在怎么样,然后把“现在”固化成基线,以后每次改动都拿基线对比,行为变化了就得有人出来解释。
具体做法分四步。第一步,选定目标函数或链路;第二步,从生产数据里抽取真实的输入(注意脱敏);第三步,执行并把输出保存成基线文件;第四步,把整个过程写成回归测试,以后代码一改就跑。下面是一个简化示例:
# 特征测试示例:不写业务断言,只记录当前行为 def test_reconcile_snapshot(snapshot): inputs = load_real_case("20260105_active_orders.csv") result = legacy_reconcile(inputs) snapshot.assert_match(result)第一次运行生成基线,后面每次运行都会自动对比。如果业务行为是故意改变的,就由人来确认后更新基线;如果行为意外变了,测试立刻报警。写这种测试时需要注意三点:先把时间戳和非确定性字段剔除;处理随机数和自增 ID,不能每次运行结果都不一样;小心测试之间的互相污染,老系统常常内部共享状态,跑测试必须隔离进程或按顺序执行。
这套方法的奇妙之处在于,它完全不要求你先读懂代码。你只要把行为锁住,就有了第一次安全改动的资格。我见过太多人一上来就重构,结果把系统行为改坏了都不知道,因为他们缺少这个基线。
3.2 防腐层:在新旧之间砌一堵墙
第二个问题,是老系统边界太混。新功能想复用旧能力,直接去摸老 DAO、旧存储过程,系统间的依赖像蜘蛛网一样。这时候需要的不是勇气,而是一堵墙:防腐层(Anti-Corruption Layer)。
我之前在一个重构项目里接手过一个订单查询模块,所有下游都直接连老库表。我们做的第一件事,不是让下游迁移到新系统,而是定义了一个只属于新系统的接口OrderPort,然后写一个防腐层实现,让它内部去访问老库表,并把老数据转换成新系统需要的模型。下游只依赖OrderPort,完全不知道背后是老的还是新的。
这一招有什么好处?好处是混乱被关进了笼子。老系统的各种怪癖和格式问题,只发生在防腐层里;新系统和下游可以保持干净整洁的模型。后面真要替换老模块时,只需要换掉防腐层背后的实现,接口不变,下游一行都不用改。唯一要警惕的是,防腐层不能变成垃圾场。所有过时接口要定期清理,不然几年之后,这堵墙本身又成了没人敢动的代码。
3.3 绞杀者模式:流量切分与逐步夺权
面对一个整体替换风险太高的系统,绞杀者模式(Strangler Fig)是更现实的选择。它的思路不是推倒重来,而是像藤蔓慢慢绞杀老树一样,把一个功能一个功能地从老系统里剥离出来,最后老树自然枯萎。
举一个我做过的小例子。有个日报生成任务,整体重构风险太大,我们把它拆成“读取数据、计算指标、写入结果”三段。第一轮只把“读取数据”换成新数据源,计算和写入暂时继续用原逻辑,保证输出格式不变。然后用开关控制流量,先从某个低峰时段或小租户开始,逐步放大到全部流量;每放一批,都对比新旧输出和延迟,出问题就立刻回切。
每一步的要点是可回滚。我给自己定的规矩是:永远不要在一版发布里同时换数据源和换计算逻辑。两个不确定因素叠加,出了问题你根本不知道是哪一个造成的。按比例切流时,我习惯先把灰度从 1% 开始,确认没有差异再 5%、20%、50%,最后一口气放满。每次切流都在白天可控时段做,尽量避免在业务高峰期玩心跳。
4. 改造时最容易翻车的细节:提交粒度、双跑与“顺手”
打法想清楚之后,真正决定成败的其实是细节。我见过太多人死在自己忽略的小动作上,所以这章聊三个最容易翻车的点。
4.1 按“可回滚边界”切分提交
刚接手老系统的人最喜欢做的事,是把一次改动做得大而全:改配置文件、动数据库表、重构函数、顺手修个 bug,一次性提交。这种做法的隐患在于,出问题时你根本没办法干净地回滚。我把线上改动按类型分了四类,规则是:一次线上操作只跨越一种边界。
| 类型 | 典型例子 | 回滚方式 |
|---|---|---|
| 代码逻辑 | 修改计算规则 | 重新发布旧版本 |
| 配置项 | 切换流量比例、开关注册 | 改配置立刻回切 |
| 数据修复 | 修正脏数据、补跑任务 | 备份恢复或补偿脚本 |
| 结构变更 | 加字段、加索引 | 前向兼容迁移 + 逆迁移 |
老话讲“把大象装进冰箱要分三步”,老系统改动比这个更讲究。同一迭代里,结构变更尽量先行,并且要保证旧代码在加字段后依然能跑;代码逻辑改动再跟上;配置和数据修复最后单独做。这样每一步出了问题,都可以只回滚自己这一层,不影响其他部分。
4.2 双跑校验:新旧实现同时跑,结果说话
前面讲到绞杀者模式时提到了输出对比,这里展开讲一下。双跑校验的意思是,在正式切流量之前,让新旧两套逻辑同时接受同一个输入,把各自的结果放到一张对比表里,不一致就告警。
很多人以为双跑必须搭一套完整的影子系统,其实未必。如果只是在函数级做替换,完全可以在老代码旁边加一个“旁路”:同一个请求进来,老逻辑照常返回给用户,新逻辑在后台算一遍,把结果只写到日志或对比表里,不影响线上。跑几天,看看偏差率。如果偏差完全可控,再把入口切换到新逻辑。
双跑最需要注意的是对齐问题。两个实现必须吃同一份输入快照,否则会因为读取时间不同而产生差异,导致误报。我见过有团队因为忽略了这一点,每天收到几百条差异告警,最后每个人都疲了,真正的问题反而被淹没在噪音里。差异率同样要保守:刚开始偏差超过 0.5% 就应该停下来,查清楚再放量,不要为了赶进度把告警阈值调大。阈值一旦调大,你等于是在看不见的状态里切流量。
4.3 “顺手”是事故之源:重构绝不混排
遗留系统改造里翻车概率最高的一句话,我几乎每次复盘都会听到:“我反正要改这里,就顺手把那块看不懂的代码也整理了一下。”这句话在正常系统里最多算风格问题,在没人敢动的老系统里就是事故源头。
原因很简单:修 bug 和重构的验收标准不同。修 bug,验证的是“本次问题有没有解决”;重构,验证的是“行为有没有任何变化”。两者混在一个改动里,测试结果根本没法解读。真出问题了,代码评审的人看不懂哪些是修 bug 带来的变化、哪些是重构带来的变化,回滚时要么把 bug 修复也带没了,要么留下一个半重构的状态。
我自己定过一条死规矩:提交信息里必须写清楚改动类型。比如refactor(legacy-reconcile): extract validation into function, no behavior change,说明这次只做重构、不动行为;如果里面混了 bug 修复,我会直接打回。另外要提醒一句,别迷信自动重构工具的“百分之百安全”。老系统里有太多隐式依赖,工具的静态分析根本发现不了,改完还是要靠特征测试和双跑来兜底。
5. 让“维护”成为团队里的正经事:度量、交接与轮值
改代码改得再好,如果整个团队和业务方始终把遗留系统当成一个烂摊子、靠一两个英雄死扛,下一次换人,局面还是会回到原点。让“维护”被系统化地接受,是比技术更棘手的一关。
5.1 把技术债翻译成管理层听得懂的风险
我很少直接对管理者说“我们要重构”或者“代码质量太差”,因为对方很难评估这句话的优先级。能推动决策的,是风险语言:这个模块一年内出过几次故障、每次恢复多长时间、如果故障发生在月底结算会损失什么。我常用这样一张表去沟通:
| 指标 | 当前值 | 加固后目标 |
|---|---|---|
| 关键模块年度故障次数 | 8 次 | ≤ 2 次 |
| 平均恢复时间 | 3.5 小时 | ≤ 1 小时 |
| 核心链路有回归测试覆盖 | 0 条 | 12 条 |
| 了解核心链路的团队人数 | 1 人 | ≥ 3 人 |
带着数字去谈,比“代码太烂必须修”有说服力得多。你会发现,管理者不是不愿意投钱,而是根本不知道风险有多大。把风险翻译成他们的语言,这笔投入就变得可衡量了。
5.2 可运行示例优先于文档
老系统最缺的是知识,但光写文档救不了知识断层。十年前的文档大概率早就不准了,写完的文档过半年也容易过期。我现在更相信可运行的示例:每个核心路径都要有一份能一键执行的“手术清单”,包括触发什么脚本、备份什么数据、上线顺序、止损操作。再配一个复现典型故障的排查脚本,新人照着走一遍,比读三十页文档有用得多。
交接也不只是吃顿饭聊聊天。我习惯让接手的人先在测试环境里重演一遍以往的真实故障,老手在旁边只看不动手。能顺利把故障处理完,说明任务真的交出去了;处理不了,就再走一遍流程。这个环节不能省,因为它暴露的是“能不能做”,而不是“记住了多少”。
5.3 轮值审视:别让系统成为一个人的领地
最后一点,也是我吃过亏之后学到的:遗留系统不能有唯一的守护神。总有某个同事特别聪明、特别熟悉老系统,所有人都离不开他;然后他休假一周,大家就慌了。这不是荣誉,这是风险。
为了对抗这种局面,我建议把维护责任做成轮值制:每个月换一个人负责遗留系统的问题响应与改动评审,老手当后备,但不兜底所有决定。每次改动都要留下一条变更记录,像写考古日志一样写清楚“改了什么、为什么改、验证了什么”。这听起来麻烦,但几个月后你会看到明显的效果——团队的“知道分子”从一个人变成了好几个人,系统不再是某个人的私有领地,而是大家都能看懂的东西。
6. 收尾的话:与老代码和解
这一年多维护老系统下来,我最大的变化是心态。以前看到“没人敢动的代码”,第一反应是嫌弃,觉得这些是历史包袱;现在反而多了一层敬意。那些看着别扭的写法,很可能在当时的技术条件、业务压力和团队认知下,是当时的自己能做的最好选择。老代码不是敌人,它只是把我们没经历过的历史,原封不动地传了下来。
我也越来越相信,改造遗留系统真正值得追求的目标,不是把它变得漂亮,而是让下一个接手的人少一点恐惧。每次改完,我都会在变更记录末尾给未来的人留一句话,写清我自己踩过的坑,提醒他们哪个开关不能乱动、哪个输入要注意。技术债可以慢慢还,这种“有人在你前面走过这条路”的安全感,才是让一个团队敢动起来的最宝贵资产。