我见过太多团队在接手老系统时栽跟头,也见过一些代码烂到让人头皮发麻、但商业上活得相当滋润的产品。最近圈子里又在传那个“年收入 1.4 亿、代码堆了 12 年”的老故事,评论区一水儿的“快跑”“别接盘”。说句实在话,这种反应很正常,但光喊“退退退”解决不了问题——真到了你面前,该接还是得接。这篇就聊聊我这些年做遗留系统接手和技术债治理的经验:屎山代码为什么能活这么久、接之前该查什么、怎么给技术债算账、真动手时从哪儿下刀、哪些坑一定会踩。不管你是刚被安排去维护老项目的初中级开发,还是准备做技术决策的负责人,看完至少能少走半年弯路。
1. 年入过亿的“屎山”:商业成功和技术质量是两套评价体系
1.1 先看清一个事实:代码烂和赚钱不冲突
很多人下意识觉得,一个产品能做出过亿年收入,代码必然优雅。现实恰恰相反——商业成功只证明“有人愿意为它付钱”,从来不证明“它的实现方式合理”。我接触过的老系统里,有相当一部分属于这种类型:数据库里字段叫flag1、flag2、status_a,业务逻辑散落在几百个存储过程和定时脚本里,前端页面还停留在十年前的模板引擎,但它每天处理着几十万笔真实交易,客户续费率稳得吓人。
为什么会这样?因为用户买的是功能,不是代码。只要登录能进、下单能成、报表能对、账单能开,代码内部再怎么纠缠,对客户来说都是黑盒。这就好比一家开了十几年的老饭馆,后厨动线混乱、锅碗瓢盆乱放,但招牌菜的味道一直稳定,门口照样排队。你在后厨待一天会崩溃,可食客不会关心。
所以接手这类系统,第一件事就是把心态摆正:它的商业价值是真实的,它的技术债也是真实的,这两件事同时成立。你要做的不是否定前者,也不是无视后者,而是搞清楚“这套系统靠什么在赚钱”和“这套系统靠什么还没崩”这两个问题。前者决定它值不值得投入,后者决定你还有多少时间窗口。
我见过最典型的误判,是新团队一进来就宣布“这代码没法维护,必须重写”。结果重写周期拖了十八个月,老系统还在同步改需求,两边功能逐渐分叉,最后新系统上线时业务方一句“怎么少了三个报表”,项目直接停摆。教训就是:在没有完全理解业务之前,任何“推倒重来”的冲动都是危险的。
1.2 屎山是怎么长出来的:三条最常见的养成路径
聊到这儿得说清楚,“屎山”不是某个人一夜之间写出来的,它几乎都是时间、人员和需求三方长期作用的产物。我复盘过几个典型项目,成因基本能归到三类。
第一类是需求驱动的“先上线再说”。创业初期为了抢市场,什么架构设计、代码规范、测试覆盖,统统让位给交付速度。功能一个接一个往上叠,每叠一次就多一条分支判断,多一个特判字段。三年之后,一个订单状态判断能写出二十个if-else,谁也说不清哪条路径对应哪类历史客户。这不是某个人水平差,而是当时的环境下,“能跑”就是最高优先级。
第二类是人员流动带来的知识断层。老系统最贵的不是代码,是代码背后的隐性知识。某个字段为什么要在凌晨两点被某个脚本重置、某段逻辑为什么对某个渠道单独跳过,这些答案往往只存在于三五个老员工的脑子里。人一走,代码就变成了“能跑但不敢碰”的黑箱。新来的人看不懂,就只能在旁边再加一层补丁,补丁叠补丁,最后没人敢动核心路径。
第三类是外包交付与多版本并行。定制化项目做多了,同一套系统会分裂出多个客户分支,每个分支都有独立的小改动。久而久之主干和分支彻底分家,合并成本高到没人愿意合,于是每个客户一份代码,修一个 bug 要改五遍。这种结构下的技术债,本质上是组织问题,光靠几个技术高手是还不清的。
提示:判断一座屎山能不能治,先看它的债是“技术性的”还是“组织性的”。前者靠工程手段能缓解,后者需要业务方一起坐下来定规则。
1.3 这类系统的护城河到底在哪
既然代码这么烂,为什么后来者不做一个更漂亮的替代品把它干掉?这是接手前必须想明白的问题。我的观察是,这类老系统的护城河通常不在技术,而在三个地方。
一是业务规则的沉淀。十多年积累下来的计费规则、结算周期、权限模型、异常处理流程,很多都是踩过坑之后补进去的。新系统从零开始,往往要花一两年才能把这些“脏但必要”的规则补齐。而这两年,老系统还在跑。
二是客户迁移成本。企业客户换系统不是换手机壳,涉及数据迁移、人员培训、流程再造、历史对账。只要老系统不崩,客户没有强动力换。这就给了老系统一个很长的生存窗口。
三是数据资产本身。十几年的交易数据、行为数据、对账数据沉在里面,这些数据的结构虽然混乱,但价值是真的。新系统就算功能更强,也得先把这些数据洗干净迁过去,而这往往是最难的一步。
所以我的结论是:接手这类系统,你接的不是代码,是一套正在运转的商业机器。机器旧、噪音大、零件锈,但它还在产出。你要做的是边保养边换零件,而不是停下来大修——因为停下来那天,收入就停了。
2. 接手之前:先做一次不带感情的技术尽调
2.1 尽调清单:12 个问题问完,基本心里有数
很多“接盘”悲剧的根源,是接手前只看了几眼代码就拍板,没做系统性的尽调。我一般会用下面这份清单去问,问完基本能判断这活儿是“能做”还是“该跑”。这份清单不针对某个具体产品,任何遗留系统都适用。
| 序号 | 问题 | 为什么问 |
|---|---|---|
| 1 | 收入构成中,前三大客户占比多少 | 客户集中度过高,任何改动都可能触发大客户流失 |
| 2 | 核心链路的日订单/日请求量级 | 决定你后续改造能承受多大的停机窗口 |
| 3 | 代码总行数、模块数、单文件最大行数 | 粗估阅读成本和拆分难度 |
| 4 | 有没有自动化测试,覆盖率大概多少 | 没有测试的系统,改一行都是赌博 |
| 5 | 一次完整发布需要多久,回滚要多久 | 发布效率直接决定改造节奏 |
| 6 | 近一年线上事故记录和平均恢复时间 | 事故分布能暴露最脆弱的模块 |
| 7 | 依赖的第三方库/中间件是否还在维护 | 停止维护的依赖是定时炸弹 |
| 8 | 有没有人能完整讲清计费和结算逻辑 | 隐性知识是否还在组织内 |
| 9 | 数据库体量、表数量、最大单表行数 | 决定数据迁移和拆分方案 |
| 10 | 部署方式是手工还是自动化 | 手工部署意味着每次上线都是高风险操作 |
| 11 | 有没有接口文档、数据字典、架构图 | 文档缺失程度决定前期摸底时间 |
| 12 | 新需求的平均交付周期 | 这是技术债最直观的“利息”表现 |
这十二个问题里,我最看重的是第 4、6、12 三个。测试覆盖率决定你敢不敢动,事故记录告诉你哪里最疼,需求交付周期则直接反映了技术债的利息有多高。如果一个二十人的团队,一个中等需求要排六周,那基本可以确定:大部分时间不是花在写代码,而是花在理解代码和防止改坏上。
2.2 给技术债算个粗账:五个可量化指标
“技术债”这个词太虚,开会时人人都在说,但没人说得清欠了多少。我习惯把它拆成五个能算的指标,做一张简单的表,跟业务方沟通时特别管用。
- 缺陷密度:每千行代码的线上缺陷数。行业里没有一个绝对标准,但可以和自己团队的历史数据比。如果老系统是团队平均值的五倍以上,说明代码本身的问题已经影响到交付质量。
- 发布频率与回滚率:一个月发几次、回滚几次。发布越少、回滚越多,说明变更风险越高,团队越不敢动。
- 平均修复时间(MTTR):从告警到恢复的平均耗时。老系统常见的现象是“排查两小时,改代码五分钟”,时间全耗在定位上。
- 需求交付周期:从需求评审到上线的平均天数。这是业务方最能感知的指标,也是推动改造最有力的论据。
- 变更影响面:改一个模块平均要回归测试多少个关联模块。这个数字超过十个,基本说明系统耦合已经失控。
把这五个指标做成季度趋势图,你会发现一个规律:技术债不是线性增长的,而是到了某个点之后加速恶化。早期加个字段没人管,中期加个分支还能忍,后期改一行要拉五个人开会。等到“改一行要开会”这个阶段,治理成本已经很高了,但还没到不可救药——真正不可救药的是“没人敢改”。
注意:算指标时一定要用系统里的真实数据,不要拍脑袋。数据不准的账,业务方不会认,治理预算也批不下来。
2.3 三条路的取舍:重写、渐进改造、只维护
摸完底之后,摆在面前的基本是三条路。我把它们的适用条件、成本、风险列成表,方便对照。
| 方案 | 适用条件 | 周期 | 主要风险 |
|---|---|---|---|
| 全量重写 | 业务规则相对稳定、有完整文档、原团队可支持 | 12~24 个月 | 功能对不齐、周期失控、双线维护拖垮团队 |
| 渐进改造 | 系统仍在产生现金流、不能停机、规则复杂 | 6~18 个月分批 | 改造不彻底,半新半旧长期共存 |
| 只维护不改 | 产品进入衰退期、客户在流失、无新增投入 | 持续 | 技术债继续恶化,人才留不住 |
我的经验是,绝大多数年收入过亿的老系统,都不适合全量重写。原因很简单:它的价值在于“稳定产出”,而重写意味着在一两年内,团队要同时维护老系统、开发新系统、还要保证功能一致。这个三线作战的模式,对组织和预算的要求极高,失败率也很高。
渐进改造才是更现实的选择,但它有个前提——你得先把系统“围起来”,也就是在不改变行为的前提下,加上测试、监控和灰度能力。没有这层护栏,渐进改造和全量重写一样危险,只是把风险摊薄到了每一天。
至于“只维护不改”,听起来最省事,实际上是最贵的选择。因为需求不会停,客户不会等你,最后还是会逼着你在最烂的代码上做最急的改动。除非这个产品确实进入生命周期尾声,否则我不建议选这条路。
3. 摸清家底:代码、架构、数据三层拆解
3.1 代码层:静态扫描加考古式阅读
接手第一周,我不会急着改代码,而是先做两件事:跑静态扫描和读提交历史。这两件事能在一周内给你一张粗略的地形图。
静态扫描主要看四个指标:圈复杂度、重复代码率、依赖环、超大文件。我用通用工具扫一遍,重点看那些“复杂度高 + 变更频繁”的文件,这类文件通常就是核心痛点所在。重复代码率高不可怕,可怕的是重复的那段逻辑里藏着一个特判——你改了一处,忘了另外三处,线上就出问题。
# 以常见静态扫描工具为例,先扫出复杂度最高的前 50 个文件 scan --report --threshold complexity=20 --top 50 ./src # 单独统计重复代码块,超过 100 行的重复要重点标记 scan --report --duplication --min-lines 100 ./src比静态扫描更有价值的是读 git 历史。我的做法是按文件统计提交频次和作者数量,把“高频修改 + 多作者”的文件列出来。这些文件往往就是业务核心,也是最容易出问题的地方。然后再看这些提交的 message,如果大量是“修复”“临时处理”“紧急上线”,基本可以确认这块逻辑没有清晰的设计。
考古式阅读也有技巧。不要从第一个文件开始顺序读,而是从入口往下追:先找到定时任务的注册点、HTTP 接口的路由表、消息消费者的订阅关系,从这些入口反推核心链路。读的时候顺手记三样东西:这个函数做什么、依赖哪些表、有没有副作用。记完一条链路,你对系统的理解会比读十篇文档都深。
提示:读老代码时,注释的可信度要打折。我见过太多注释写着“临时方案,后续优化”,结果这个“临时”跑了八年。以代码实际行为为准,注释只作参考。
3.2 架构层:画出真实的调用关系
老系统最要命的问题之一,是文档里的架构图和实际运行结构完全对不上。文档上画的是分层架构,实际上是通过数据库表互相调;文档上说模块解耦,实际上一个存储过程牵动八张表。所以架构摸底不能看文档,要看真实调用。
我的做法是在关键入口加日志埋点,跑一周,把调用链数据捞出来,画一张真实的依赖图。没有链路追踪条件的,就从数据库的访问日志和中间件的消费日志反推。重点看三件事:哪些模块被依赖最多、有没有循环依赖、哪些调用是跨层的。
被依赖最多的模块,就是改造时最不能动的模块,得最后动。循环依赖的地方,往往是逻辑最纠缠的地方,也是重构时最先要拆的。跨层调用(比如前端直接拼 SQL、业务层直接操作别的模块的表)则是隐性的耦合点,改一处崩一片。
还有一个容易被忽略的点:定时任务和批处理。很多老系统的核心逻辑不在接口里,而在凌晨跑的几十个脚本里。这些脚本没有版本管理、没有监控、没有重试,跑失败了没人知道,第二天对账才发现。接手时一定要把所有定时任务列出来,标清楚执行时间、依赖关系、失败处理方式。这张表做出来,你会对系统的“隐性复杂度”有全新认识。
3.3 数据层:最容易被低估的风险源
如果让我给老系统的风险排个序,数据层的风险排第一,代码层排第二。原因很简单:代码改错了可以回滚,数据改错了可能回不来。
接手时我必查的几项:备份策略是否可恢复(不是“有没有备份”,而是“真的恢复过一次吗”)、金额字段用什么类型存、时间字段有没有时区问题、软删除字段是否被统计逻辑正确排除、有没有历史脏数据。
金额用浮点数存是老系统的高发问题。0.1 + 0.2算出来是0.30000000000000004,单笔看不出来,跑一年对账就是几十块的差异,查起来能查死人。正确做法是用整数存最小货币单位(比如分),或者用定点小数类型。如果老系统已经是浮点,改造时至少要在读写层做一次统一转换,别让误差继续扩散。
时区问题也很常见。服务器用一套时区、数据库用另一套、前端展示再用第三套,跨天统计的时候数据就对不上。我遇到过最离谱的一次,是某个报表在月末最后一天总是少几个小时的数据,查了三天才发现是某个脚本用了本地时间做日期截断。这类问题的排查成本极高,但预防成本很低——统一用 UTC 存储,展示层再转本地时区,这条规则越早定越好。
还有软删除。很多老系统用is_deleted标记删除,但统计 SQL 里有的加了过滤、有的没加,导致报表数字忽大忽小。接手时要把所有涉及统计的 SQL 过一遍,确认过滤条件一致。这个活儿很枯燥,但不做的话,后面每一个数据问题都会被怀疑到你头上。
-- 接手老系统时,先跑一遍这类检查,找出潜在的数据不一致 -- 1. 金额字段是否存在浮点类型 SELECT table_name, column_name, data_type FROM information_schema.columns WHERE data_type IN ('float', 'double', 'real') AND column_name LIKE '%amount%'; -- 2. 统计类 SQL 是否遗漏软删除过滤(人工核对为主) SELECT COUNT(*) FROM orders WHERE is_deleted = 0; SELECT COUNT(*) FROM orders;4. 动手术:在不影响业务的前提下改造老系统
4.1 第一步不是重构,是加护栏
我见过太多团队,一接手就迫不及待地开始“优化代码”,结果第一周就改出线上故障,从此业务方对技术团队失去信任,后续任何改造提案都批不下来。改造老系统的第一优先级不是让它变好,而是让它变得“可安全地变”。
护栏分三层。第一层是特征测试:不追求覆盖所有分支,而是针对核心链路,把当前的输入输出录下来做成断言。比如计费接口,拿一百条真实请求跑一遍,把结果存成基准,之后每次改动都跑一遍比对。这种测试不关心代码怎么写,只关心行为有没有变,特别适合没有文档的老系统。
第二层是监控告警:核心接口的成功率、耗时、错误码分布,关键定时任务的执行结果,数据库的连接数和慢查询。老系统最怕的是“悄悄坏掉”,有了监控,至少能在用户投诉之前发现。
第三层是灰度与开关:新逻辑上线先走开关,按客户、按比例放量,出问题一键回退。开关本身要简单可靠,别搞成一套复杂的配置系统——那是另一个屎山的开始。
// 特征测试的简化示例:录下老逻辑的输出,作为新逻辑的比对基准 const cases = loadRecordedCases('billing_cases.json'); for (const c of cases) { const actual = newBillingLogic(c.input); if (actual !== c.expected) { throw new Error(`行为不一致: ${c.id}, 期望 ${c.expected}, 实际 ${actual}`); } }这三层护栏做完,通常要一到两个月。很多团队嫌慢,但我的经验是:这两个月省下来,后面要花六个月还债。而且护栏做完之后,你会发现改造的信心完全不一样了——以前改代码靠祈祷,现在改代码靠比对。
4.2 绞杀者模式落地:新老双跑怎么比
渐进改造最常用的套路是绞杀者模式:在老系统外面包一层路由,新逻辑一点点替换老逻辑,替换完一块就砍掉一块。听起来简单,落地时最难的是新老结果不一致怎么办。
我的做法是双跑比对:同一个请求,同时走新老两套逻辑,老逻辑的结果照常返回给用户,新逻辑的结果只记日志。跑一段时间,把差异捞出来逐条分析。差异分三类:新逻辑错了、老逻辑错了、两边都对但规则不同。第一类改新逻辑,第二类记下来作为老系统的已知缺陷,第三类要找业务方确认口径。
# 双跑比对的伪代码:老逻辑照常返回,新逻辑只记录差异 def handle(request): old_result = legacy_calc(request) try: new_result = new_calc(request) if new_result != old_result: log_diff(request, old_result, new_result) except Exception as e: log_error(request, e) return old_result双跑的关键是差异要有归因。如果每天产生几千条差异但没人分析,双跑就失去了意义。我一般要求差异率降到千分之一以下,才允许新逻辑接管流量。接管也要按比例来,先百分之一,再百分之十,再一半,最后全量。每一档至少观察一周,重点看错误率、耗时、用户投诉。
注意:双跑会带来额外的计算和存储开销,老系统本身就很吃力的话,要控制双跑的采样比例,别把老系统压垮了。
4.3 节奏和人力配置:一份可参考的排期
改造老系统最怕的是“大干快上”,正确姿势是分阶段、小步走。我按经验给一份参考排期,具体还要看系统规模。
| 阶段 | 时长 | 主要工作 | 产出 |
|---|---|---|---|
| 摸底 | 1~2 个月 | 尽调、静态扫描、读历史、画调用图 | 系统地形图、风险清单 |
| 护栏 | 1~2 个月 | 特征测试、监控告警、灰度开关 | 可安全变更的基础设施 |
| 拆解 | 2~4 个月 | 按业务域拆分模块,先拆边缘再拆核心 | 模块边界、接口契约 |
| 替换 | 4~8 个月 | 绞杀者模式逐块替换,双跑验证 | 新模块上线、老模块下线 |
| 收尾 | 1~2 个月 | 清理死代码、补文档、交接 | 可维护的系统 |
人力上,我的建议是不要抽走全部老员工。老系统里的隐性知识,是新团队最缺的东西。比较理想的配置是:两三个熟悉老系统的骨干做顾问,负责答疑和评审;五六个新成员做主力,负责改造落地;再加一个能拍板的业务负责人,负责口径确认。这个配置下,改造速度不会最快,但翻车概率最低。
还有一个反直觉的经验:改造初期不要碰核心计费和结算逻辑。这两块业务规则最复杂、容错最低、对账要求最高,一旦出问题就是真金白银的损失。先把周边的报表、查询、通知这类只读模块换掉,积累信心和经验,再动核心。
5. 踩坑实录与问题速查
5.1 几个真实场景的翻车记录
说几个我亲身经历或近距离观察过的翻车场景,都是那种“事前觉得不可能,事后觉得早该想到”的类型。
第一个是缓存与数据库不一致。老系统里有个缓存,更新时先删缓存再写库,高并发下读请求把旧值又写回缓存,导致某些用户看到的价格是旧的。这个问题在低峰期复现不了,一到促销就冒出来。最后改成“先写库、再删缓存、加延迟双删”,并且给缓存加了版本号,才算稳住。教训是:老系统的缓存往往没有统一的失效策略,接手时要把所有缓存点列出来,逐个确认更新顺序。
第二个是定时任务重复执行。系统做了主备部署,但定时任务没有加分布式锁,主备同时跑,同一批订单被处理了两次。财务对账时发现多了一批退款,查了一整天才定位到。这类问题在老系统里非常普遍,因为早期是单机部署,后来加了集群,脚本没跟着改。接手时一定要确认:所有定时任务是否有幂等保护,是否只在一个节点执行。
第三个是字符集和排序规则不一致。两个表关联查询,一个用utf8,一个用utf8mb4,关联字段的排序规则不同,导致索引失效,一个原本几百毫秒的查询跑了十几秒。这种问题不会报错,只会变慢,特别隐蔽。我的建议是接手时统一跑一遍表和字段的字符集检查,不一致的地方尽早统一。
第四个是日志把磁盘写满。老系统的日志没有滚动策略,某次异常导致日志暴增,磁盘满了之后数据库写不进去,整个系统挂掉。这种事故听起来很低级,但在缺乏运维规范的老系统里非常常见。接手时检查日志目录、临时目录、备份目录的空间占用和清理策略,是性价比很高的一件事。
5.2 常见问题速查表
下面这张表是我这些年攒下来的,遇到问题时可以先按图索骥,能省不少时间。
| 现象 | 可能原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| 报表数字忽大忽小 | 软删除过滤不一致 | 对比统计 SQL 的 where 条件 | 统一封装统计视图,禁止裸写 SQL |
| 金额对不上,差几分钱 | 浮点数存储与计算 | 检查字段类型和运算逻辑 | 改为整数分存储,读写层统一转换 |
| 跨天统计少数据 | 时区不统一 | 检查服务器、数据库、脚本时区 | 统一 UTC 存储,展示层转换 |
| 定时任务重复执行 | 集群下无分布式锁 | 查看任务调度配置 | 加锁并保证任务幂等 |
| 查询突然变慢 | 字符集/排序规则不一致 | 检查关联字段的字符集 | 统一字符集和排序规则 |
| 发布后部分用户报错 | 缓存未失效或灰度不彻底 | 检查缓存版本和流量切分 | 加缓存版本号,完善灰度策略 |
| 日志暴涨导致宕机 | 无日志滚动和清理 | 检查磁盘占用和日志配置 | 配置滚动策略和磁盘告警 |
| 修改一处影响多处 | 隐式耦合、共享表 | 查调用链和表依赖 | 先加测试,再逐步解耦 |
这张表不是万能的,但它覆盖了老系统里八成的常见故障。我建议接手团队把它打印出来贴在墙上,遇到问题先对照一遍,能避免很多重复劳动。
5.3 给接手者的几条硬建议
最后说几条掏心窝子的建议,都是踩过坑之后总结的,不一定好听,但实用。
第一,别信“这代码很简单,你看看就懂”这句话。说这话的人往往自己也没完全懂。老系统的复杂度不在于代码行数,而在于那些没有写下来的业务规则。遇到说不清的地方,一定要找到真正懂业务的人问清楚,问不到就先别动。
第二,先保命,再治病。接手头三个月,目标不是让系统变好,而是让它别在你手上出事。备份、监控、告警、回滚预案,这四样配齐之前,任何“优化”都是冒险。
第三,把隐性知识写下来。每搞懂一条链路、一个字段、一段特殊逻辑,就写进文档。哪怕只是几句话,也比留在某个人脑子里强。这件事短期看不到收益,但半年后新人入职时,你会感谢自己。
第四,小步提交,频繁发布。老系统最怕大改动。每次只改一小块,改完立刻验证、立刻发布、立刻观察。出了问题时,小改动容易定位、容易回滚。大改动一旦出事,排查范围大得吓人。
第五,接受“不完美”。老系统不可能被你改造成教科书式的架构,也没必要。你的目标是让它可维护、可预测、可安全变更,而不是让它变得优雅。很多时候,加一层清晰的防腐层,比推倒重来更划算。
我个人在实际操作中的体会是,接手老系统这件事,技术能力只占一半,另一半是耐心和沟通。你得耐着性子读那些没人愿意读的代码,得跟业务方一遍遍确认口径,得在“想重构”和“不能出事”之间反复权衡。这个过程不痛快,但每拆掉一块硬骨头,你对系统的掌控感就会强一分。等到某天你发现自己能闭着眼睛说出核心链路的走向,那这座屎山,基本就算是你的了。