接手一个十几年没大动过的老系统,圈子里管这叫“接盘”,前面还得加两个字:慎重。最近刷到一条讨论挺有代表性:某款上线十二年、代码结构已经乱成一团的产品,年营收还在一个亿以上的量级,原团队要撤,招人来接。底下评论区清一色的“赶紧退退退”。我盯着这条讨论看了很久,因为这个场景我太熟了。祖传代码、屎山、遗留系统、技术债,这几个词几乎是每个做业务开发的人迟早要撞上的墙。这篇文章不站队,既不说“烂代码一定死”,也不说“给钱就得干”。我想聊的是:这种“还能赚钱但代码已经烂透”的遗留系统到底是怎么形成的,接之前该怎么尽调,真接下来了又该怎么止血和渐进式改造,以及哪些红线碰都不能碰。适合正在犹豫要不要接手老项目的工程师、被临时点将去救火的技术负责人,也适合自己维护着一个多年前写的老产品、想动又不敢动的独立开发者。全篇都是我自己踩过和看别人踩过的坑,能抄的部分我尽量写成可以直接照做的步骤。
1. 先搞清楚这类“能赚钱的老系统”到底是什么东西
1.1 烂代码活过十二年的真正原因
很多人第一反应是“代码烂成这样早就该重写了”。这个判断在纯技术视角下成立,在商业视角下往往不成立。一个能连续十几年产生正向现金流的产品,它的价值从来不在代码质量上,而在它已经解决了某个细分场景下的具体问题,并且积累了一批离不开它的用户。
我见过太多这种系统:页面丑、前端还是十几年前的模板引擎、接口命名毫无章法、一个方法三千行、数据库字段名用拼音首字母缩写、注释里写着“临时方案,下周改”。可它就是每天稳定跑,每天稳定收钱。原因很朴素——业务需求在早期被快速满足了,而后续十几年里,用户要的东西没有本质变化。
代码质量的评价维度是“维护成本”,产品的评价维度是“是否解决问题”。这两条曲线在早期是同向的,到后期经常分叉。一个系统只要还在被使用、还有人在付费,它就在创造价值;而只要改动频率足够低,维护成本就不会立刻爆发。所以“屎山”能活得久,不是因为它优秀,而是因为它被使用得足够稳定,而稳定又反过来压制了改动的动机。
理解这一点很重要,因为它决定了你的心态。你接手的不是一个“技术灾难”,而是一台“还能转但已经没法修的机器”。你的任务不是证明它有多烂,而是判断这台机器还能转多久、修的成本和收益是否匹配。
1.2 为什么营收不错的团队反而更不敢重构
这里有个反直觉的现象:越是赚钱的老系统,越没人敢动。我总结下来是三层锁。
第一层是业务锁。产品每天都在产生收入,任何一次重构都意味着风险窗口。哪怕重构期间可用性只下降百分之一,折算成钱也是真金白银。管理层算的是这笔账,工程师算的是代码整洁度,两套账本很难对上。
第二层是知识锁。十二年的代码,前面可能换过四五拨人。真正懂某段逻辑的人早就走了,剩下的人只能靠猜。有些分支逻辑看起来毫无意义,实际上是当年为了某个大客户临时加的,删了第二天就投诉。这类“隐性需求”没有文档,只存在于线上行为和客户合同里。
第三层是心理锁。老系统的维护者往往形成了一种防御性心态:能不动就不动,改了出事要担责,不改出事是“历史遗留”。这种心态一旦蔓延,团队就只剩下修修补补的能力,重构的意愿被彻底磨掉。
我个人的判断是:当一个系统的改动成本已经高到“改动前先开三次会”的程度,它就已经进入维护末期了。这时候要么接受它慢慢腐烂直到被替换,要么就下定决心做外科手术。中间状态最难受,既不敢大改又想持续加需求,最后只能往上堆补丁,把山堆得更高。
1.3 什么人适合接,什么人趁早婉拒
不是所有人都适合接这种盘子。我给一个粗糙但实用的判断标准。
适合接的人:有至少三五年业务开发经验、见过线上事故、能接受“代码质量和业务价值不挂钩”这个现实、并且有耐心做长期工程的人。尤其是做过一两次渐进式替换的人,知道怎么在不影响业务的前提下慢慢挪。
趁早婉拒的人:刚工作一两年想练技术的、对代码整洁度有强洁癖的、以及把“重写”当成唯一答案的。重写一个跑了十二年、承载着隐性需求的系统,失败率高得吓人。我在行业里看到的多数重写案例,结局是两边并行、老系统关不掉、新系统带病上线,最后维护两套的人比原来更痛苦。
还有一种情况要特别警惕:对方只谈薪资不谈交接。如果原团队已经走空、没有交接期、没有原维护者可以随时问,那你要独自面对十二年的黑盒。这种盘子即使给的数字很好看,也要打一个大问号。合理的做法是要求至少留一到两个熟悉核心链路的人做三个月顾问,把隐性知识挖出来。
2. 接手前的尽调:别急着读代码,先看账本和日志
2.1 三层尽调清单:业务、数据、工程
真正该做的第一件事不是打开仓库,而是拿一份尽调清单去问人。我把清单压成三层,按顺序过。
业务层要问的:这个产品现在有哪些付费客户、收入集中在几个大客户身上、近三年的续费率是多少、有没有正在谈的强制合规改造要求。如果收入高度集中在两三个客户,那你的技术决策会被这两个客户绑架,重构空间极小。
数据层要问的:核心表有多少张、单表最大多少行、日增数据量、有没有分库分表、备份策略是什么、能不能拿到脱敏快照。这一层是决定你后面能不能在本地把系统跑起来的关键,也是最容易被忽略的。
工程层要问的:构建方式(有没有能一键构建的脚本)、依赖版本(有没有已经停止维护的框架)、发布流程(手动上传还是有流水线)、监控覆盖情况、以及最关键的——最近一年的线上事故清单和原因。
这张清单的价值在于,它能在一周内告诉你“这活是三个月能理顺,还是三年都理不顺”。我用一张表把它固化下来,方便对照打分。
| 维度 | 关键问题 | 红旗信号 |
|---|---|---|
| 业务 | 收入是否集中在少数客户 | 前三大客户占比超过 60% |
| 业务 | 是否有强制性的外部时限 | 有明确截止日期且不可延期 |
| 数据 | 单表数据量级 | 单表过亿且无分区 |
| 数据 | 备份可恢复性 | 从未做过恢复演练 |
| 工程 | 能否本地一键启动 | 依赖已下线的内部服务 |
| 工程 | 发布方式 | 手动改文件且无版本记录 |
| 工程 | 事故记录 | 近一年有重复出现的同一类故障 |
三条以上红旗,就说明这盘子的隐性成本远超预期,报价得重新谈。
2.2 用数据量化这堆代码的“负债率”
“代码很烂”是主观判断,谈判和决策都需要客观数字。我习惯用几个能快速采集的指标做粗量化,不需要完整的静态扫描,一个人两三天就能跑出来。
第一个是改动频率与改动规模。拉取版本库最近两年的提交记录,统计每个文件的修改次数。修改次数最多、同时又最长的那些文件,就是风险最集中的地方。这类文件通常叫“上帝类”或者“巨型脚本”,一个文件几千行,谁都往里加东西。
第二个是单次改动的返工率。统计最近一年的发布记录里,有多少次是“修上一次发布引入的问题”。这个比例超过 15%,基本可以确认代码的可测试性和可理解性都很差。
第三个是构建时长与失败率。构建要二十分钟以上、或者经常因为环境问题失败的,说明依赖管理已经失控。
第四个是测试覆盖率。老系统往往覆盖率接近零,这时候不要看百分比,直接问:上线前有没有任何自动化验证?如果答案是“靠人工点”,那就意味着你后面任何改动都得靠人肉回归,成本极高。
提示:这四个数字不要自己闷头算,一定要在尽调阶段跟对方一起算。数字摆到桌面上,很多“能不能重构”的争论会自动变成“重构预算多少”的讨论。
2.3 判断“能不能救”的五个硬指标
算完数字之后,可以做一个粗略的可行性判断。我总结成五个硬指标,满足三条以上,才值得投入长期改造。
第一,核心链路是否收敛。也就是说,真正产生收入的路径是不是只有几条。如果核心链路清晰,哪怕外围代码很乱,你也可以先给核心链路套上保护壳,慢慢替换。反过来,如果收入分散在几十个互相纠缠的功能点上,改造难度会成倍上升。
第二,数据是否可迁移。核心数据模型能不能被理解、能不能被导出、迁移过程中能不能保证一致性。数据模型是真正的资产,代码可以推倒重来,数据不行。
第三,是否有可观测性基础。哪怕只有基本的日志和少量监控,也比完全没有强。完全没有可观测性的系统,任何改动都是盲飞。
第四,业务侧是否愿意给你时间窗。改造不是纯技术活,它需要业务侧配合降速、配合灰度、配合接受短期功能冻结。业务侧不配合,技术上再漂亮也推不动。
第五,是否存在可以并行替换的边界。这一点最关键。如果系统内部耦合到“改一个字段要动八个模块”,那第一步应该是解耦而不是替换。边界清晰的系统才适合绞杀者模式,边界模糊的系统只能先做内部整理。
3. 落地实操:给祖传系统做一次体检与止血
3.1 第一步,把系统在本地跑起来
这一步听起来最基础,实际最难,而且往往要花掉第一周的全部时间。十二年没动的项目,依赖的运行时版本、数据库版本、甚至操作系统补丁都可能是老古董。我的一般做法是“先用容器锁版本,再逐层剥离”。
先确认三个版本:语言运行时版本、数据库版本、以及关键中间件版本。这三个不确定,后面的工作都是白费。老项目常见的情况是依赖某个已经停止维护的框架版本,这个版本又只兼容特定区间的运行时。不要试图升级,先原样复现。
# 用一个容器把老版本的运行时和数据库一起拉起来 docker run -d --name legacy-runtime \ -p 8080:8080 \ -v "$PWD/app:/opt/app" \ -e TZ=Asia/Shanghai \ legacy-base-image:1.0 docker run -d --name legacy-db \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=localdev \ -v "$PWD/data:/var/lib/mysql" \ mysql:5.7数据库优先用脱敏后的结构快照,不要用生产全量数据。表结构加少量样例数据就够跑通流程,全量数据动辄几百 G,本地根本跑不动,还涉及数据安全问题。
跑起来之后立刻做一件事:记录启动步骤。把每一步命令、每一个环境变量、每一个坑都写进一个RUNBOOK.md。这份文档的价值在半年后会体现得非常明显,因为下一个接手的人(可能就是三个月后的你自己)不用再重新踩一遍。
注意:不要在这一步顺手“清理代码”。本地能跑起来本身就是阶段性成果,任何顺手改动都会让你分不清“跑不起来是因为环境还是因为我改了代码”。
3.2 第二步,用日志画出真实的调用地图
代码静态阅读在老系统上效率极低,因为真正的执行路径和你读到的完全不是一回事。更快的办法是从运行时的痕迹反推结构。
具体做法:在入口处(HTTP 层或者消息消费层)打一个全局的请求标识,然后在数据访问层打上同一个标识,把一次请求经过的所有 SQL 和外部调用串起来。跑一段时间真实流量或者回放流量,你就得到了一张真实的调用图。
# 极简的请求链标识注入,够用就行 import uuid, logging, contextvars trace_id = contextvars.ContextVar("trace_id", default="-") class TraceFilter(logging.Filter): def filter(self, record): record.trace_id = trace_id.get() return True def with_trace(func): def wrapper(*args, **kwargs): token = trace_id.set(uuid.uuid4().hex[:12]) try: return func(*args, **kwargs) finally: trace_id.reset(token) return wrapper有了这张图,你能立刻看出几件事:哪些接口是真正被调用的(很多接口可能三年没人访问)、哪些表是热点、哪些外部依赖是单点。我做过一个项目,统计下来 40% 的接口近半年零调用,直接下线,维护面积瞬间小了一圈。
这一步还有一个副产品:慢查询清单。把执行时间超过阈值的 SQL 按频次排序,前十名基本就是性能瓶颈所在,也是后面优化的第一批目标。
-- 从慢日志汇总表里捞出高频慢查询的模式 SELECT LEFT(digest_text, 80) AS sql_pattern, COUNT(*) AS exec_count, ROUND(AVG(avg_timer_wait)/1e9, 2) AS avg_ms, SUM_rows_examined AS total_rows FROM performance_schema.events_statements_summary_by_digest WHERE avg_timer_wait > 500000000 ORDER BY exec_count DESC LIMIT 20;3.3 第三步,给核心链路加安全带
在动任何业务代码之前,必须先建立“改动可验证”的能力。老系统通常没有测试,这时候不要想着补全单元测试,性价比太低。我推荐两条路线并行。
一条是特征测试,也叫刻画测试。思路很直白:不判断输出对不对,只把当前行为录下来,之后每次改动都对比行为有没有变化。录制的对象是核心接口的请求和响应,用真实请求采样一批,存成基线文件。
# 特征测试的最小实现:录制 + 回放比对 import json, hashlib from pathlib import Path BASELINE = Path("baselines") def record(case_id, request, response): payload = {"request": request, "response": response} digest = hashlib.sha256( json.dumps(payload, sort_keys=True, ensure_ascii=False).encode() ).hexdigest()[:16] (BASELINE / f"{case_id}.json").write_text( json.dumps({"payload": payload, "digest": digest}, ensure_ascii=False, indent=2) ) def replay(case_id, new_response): saved = json.loads((BASELINE / f"{case_id}.json").read_text()) old = saved["payload"]["response"] if old != new_response: return f"行为差异: {case_id}" return None另一条是影子流量。把生产流量复制一份打到新版本上,只记录不返回给用户,然后比对两边的输出。这一步的价值在于能覆盖到你自己想不到的边界情况。
# 用反向代理把流量镜像到影子服务,主链路不受影响 location /api/ { proxy_pass http://legacy_backend; mirror /mirror; mirror_request_body on; } location = /mirror { internal; proxy_pass http://candidate_backend$request_uri; }注意:影子流量会产生真实的写操作。一定要在影子服务上把写库、发消息、调用外部支付接口这些动作全部关掉或者打到沙箱,否则你会凭空多出一批订单。
3.4 第四步,用绞杀者模式做渐进替换
真正开始替换时,我建议从外围功能入手,不要一上来就动核心。绞杀者模式的核心是:在新旧两套实现外面套一层路由,按流量比例或者按用户维度逐步切换,任何时刻都能一键切回。
# 极简的路由开关,按用户维度灰度 import hashlib def route(user_id: str, percent: int) -> str: """percent 表示走新实现的百分比,0 到 100""" if percent <= 0: return "legacy" if percent >= 100: return "modern" bucket = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100 return "modern" if bucket < percent else "legacy" def handle(user_id, payload): target = route(user_id, percent=5) if target == "modern": return modern_handler(payload) return legacy_handler(payload)关键在顺序。我的经验顺序是:先替换纯读接口(风险最低,出错也不影响数据)→ 再替换独立的写接口(比如日志、通知这类可以容忍重复的)→ 然后是带状态的核心写接口 → 最后才是涉及资金和结算的部分。
每一步都要有明确的回滚条件,写在发布单上,比如“新实现错误率超过旧实现的 1.5 倍,十分钟内回滚”。回滚不是失败,是正常操作。把回滚当成常规动作,团队才敢做灰度。
4. 重构的节奏与红线:什么能动,什么打死不能动
4.1 改造优先级怎么排
改造清单拉出来往往有几十条,全做不现实。我用的排序逻辑是“风险 × 频次 ÷ 成本”。风险指改动出错后的影响面,频次指这块代码被改动的频率,成本指改造需要的人天。分数高的先做。
| 类型 | 典型场景 | 优先级 | 理由 |
|---|---|---|---|
| 高频高风险的上帝类 | 订单主流程、结算逻辑 | 最高 | 每天都在改,出错代价大 |
| 高频低风险的外围功能 | 后台管理页面、报表导出 | 高 | 改动成本低,容易出成果 |
| 低频高风险的陈年逻辑 | 年结、历史数据修复 | 中 | 改一次要验证很久,但改完收益大 |
| 低频低风险的死代码 | 已下线功能的残留 | 最低 | 直接删除,不需要重构 |
这里有个容易被忽略的策略:先删后改。很多“需要重构”的代码其实是根本没人用的死代码。先做一轮清理,把零调用的接口、无引用的类、废弃的开关分支删掉,剩下的改造工作量可能直接砍掉三分之一。删代码是最便宜的优化。
4.2 数据库改造的坑最深
应用代码可以灰度、可以回滚,数据库改动一旦上线,回滚成本高一个数量级。我在数据层定了几条死规矩。
第一条,结构变更和代码变更分离。加字段、加索引、改字段类型,单独一批上线,不和应用代码混在一起。这样出问题时能立刻定位是哪一类改动引起的。
第二条,只做向前兼容的变更。加字段允许,删字段不允许直接删,先标记废弃、观察一个版本周期、确认没有引用再删。改类型要新增字段做双写,而不是原地改。
第三条,大表操作必须评估锁表时间。给一张几千万行的表加索引,不同数据库版本的行为差别很大,有的会锁表几分钟,直接导致线上雪崩。上线前一定要在同等数据量的环境里跑一遍,记录耗时。
-- 双写过渡:新增字段并逐步填充,不做原地修改 ALTER TABLE orders ADD COLUMN amount_cents BIGINT NULL; -- 分批回填,每批控制行数,避免长事务 UPDATE orders SET amount_cents = ROUND(amount * 100) WHERE amount_cents IS NULL LIMIT 5000;提示:分批回填一定要有循环和限速,别写成一条没有 WHERE 的全表更新。这种语句在小表上测试毫无问题,在生产上会直接把数据库拖死。
4.3 上线与回滚机制
老系统的发布往往是“传个包、重启服务”,这种方式在改造期非常危险。至少要补上三件事:版本可追溯、变更可灰度、故障可回滚。
版本可追溯:每次发布的代码版本、数据库变更脚本、配置变更,都要有记录,能回答“三天前那次发布到底改了什么”。
变更可灰度:哪怕是单体应用,也能按机器分批发布。先发一台、观察十分钟、再发剩下的。
故障可回滚:回滚脚本必须提前写好并演练过。很多团队的回滚方案只存在于脑子里,真出事的时候没人敢操作。
我一般会把这三件事做成一张检查表,贴在发布流程里,每次发布逐条打勾。听起来很笨,但确实能把事故率压下来。
5. 常见问题与排查实录
5.1 高频问题速查表
这些年在这类系统上遇到的问题,翻来覆去就那么几类。整理成表,遇到时先对照。
| 现象 | 常见根因 | 排查切入点 |
|---|---|---|
| 本地起不来,日志无报错 | 依赖的内部服务已下线 | 检查配置文件里的外部地址 |
| 偶发超时,重启后恢复 | 连接池泄漏或慢查询堆积 | 看连接数曲线和慢日志 |
| 数据对不上,重算就正常 | 并发写没有幂等控制 | 查唯一键和事务边界 |
| 改了配置不生效 | 缓存未失效或多处配置源 | 全量搜索配置项引用 |
| 灰度后新版本报错,旧版本正常 | 数据结构不兼容 | 检查新老版本共用的字段 |
| 某个客户反馈异常,其他人正常 | 定制化分支逻辑 | 搜索客户标识相关的条件判断 |
5.2 文档里不会写的避坑经验
第一,永远不要相信注释。老系统里的注释写的是“临时方案”,但二十年了还在跑。判断一段逻辑是否有效,看线上行为,不看注释。
第二,改动前先找到“知情人”。哪怕对方已经离职,也想办法联系上,问半小时能省你两周。这里的关键是问对问题:不要问“这段代码干什么”,要问“这段代码当初是为了解决什么问题”。前者对方答不上来,后者往往能唤起记忆。
第三,保留所有开关的默认值。改开关逻辑时,新开关的默认值必须等于改动前的行为。这一条能挡掉大部分灰度事故。
第四,别在周五下午发布。这条听着像玩笑,实际上是血泪。老系统的问题往往要几天才暴露,周五发版意味着你要带着问题过周末。
第五,给自己留一份“改动日志”。每天记三行:今天改了什么、为什么改、验证方式是什么。三个月后你会感谢自己。
6. 一点个人体会
我前后接过三个类似的老系统,结局各不相同。一个被彻底替换掉了,花了十四个月;一个只做了外围解耦,核心逻辑原封不动地又跑了三年;还有一个最后被业务方主动下线了,因为客户需求变了,产品本身没价值了。这三个案例里,技术难度最高的那个反而最顺利,因为业务侧给了明确的时间窗和预算;最难的是第二个,技术不难,但没人愿意拍板,所有改动都在灰色地带里打转。
所以我现在判断一个“接盘”机会,第一眼看的不是代码,是有没有人愿意为改造买单。买单的不一定是钱,也可以是时间窗口、是业务侧的配合承诺、是明确的责任划分。没有这些,代码再烂也别碰;有了这些,代码烂到什么样都能一点点理顺。至于那种“年营收很高所以随便改”的说法,我是不太信的,营收高只能说明这台机器还值钱,不代表它好修。真要上手,先把尽调清单跑一遍,把本地环境跑起来,把核心链路的调用图打出来,这三件事做完,你心里自然有答案。