接手一个代号叫 t3code 的旧项目,是我这几年做过最耗时,也最有收获的一件事。项目名里的 t3,其实是当初团队里一位老前辈随手起的内部代号——取的是 "Third Try" 的意思,也就是第三次推倒重来的版本。可讽刺的是,等我真正开始梳理这套代码的时候,它已经历了至少四次局部重写,代码库里同时躺着三套风格完全不同的命名规范,核心业务链路里还有一份写了五年却没人敢动的"祖传"配置模块。这篇文章不是什么理论教学,而是把 t3code 从病入膏肓到恢复正常迭代速度的完整过程记录下来的,里面包含了我踩过的坑、用过的工具、以及最终沉淀下来的判断标准。如果你手头也有一坨不敢碰的老代码,或者正准备对现有系统做一次大规模重构,这篇文章值得你花点时间读完。
先说结论:t3code 这个项目最终没有选择推到重写,而是走了一条"渐进式重构 + 局部重写"的混合路线。整个过程耗时大约四个月,核心模块的接口响应时间从平均 1200ms 降到了 380ms,线上故障率降低了大约 70%,更重要的是,团队里新来的两个同事可以在两周内独立完成从前需要老员工带着看一个月才能搞定的需求改动。下面我会按照实际操作顺序,把从代码体检、风险判断、分阶段重构到最终沉淀检查清单的完整链路逐步拆开来讲。
1. t3code 项目起因:攒了三四年的技术债,到了不得不还的时候
1.1 为什么这个代号叫 t3——它到底经历了什么
t3code 最早是一个企业内部的数据统计系统,最初是一个工程师用一周时间写出来的临时脚本集合,用来每天跑几张报表。后来业务扩张,脚本变成了 Web 服务,服务拆出了微服务,微服务又引入了消息队列。等到我接手的时候,这个系统的代码量已经膨胀到将近 80 万行,其中有大约 30% 的代码逻辑属于"看着有用,但没人说得清是否还有调用方"。
说"病入膏肓"一点不过分。那段时间系统几乎每周都会出一次线上事故,要么是某个定时任务突然卡死,要么是某个接口偶发超时。每次排查到最后,问题都指向同一批代码——那些由不同时期不同人写下的、相互交织的模块。最折磨人的不是代码本身的复杂度,而是那份"薛定谔的文档":文档上写了某个模块负责 A 功能,但实际代码里它还悄悄改了 B 表的数据,而这个副作用只有 2019 年某个深夜加班的同事知道,那位同事在留言里写了一句"不要再动这段逻辑"之后就离职了。
促使我正式把 t3code 作为重构项目立项的导火索,是一次持续了六个小时的线上故障。凌晨两点,某个核心报表的数据出现大面积偏差,我们花了四个小时定位到原因——一个看似人畜无害的配置项,在六年前被某个已离职的同事写死在了代码里,而前一周另一位同事"顺手"给配置中心加了个默认值,于是新逻辑和旧逻辑同时生效,数据被叠加计算了一遍。那一次之后,领导和团队达成了一致:这笔技术债不能再拖了。
1.2 项目启动前,我先整理了这样一份问题清单
接到重构任务之后,我没有立刻打开编辑器开始改代码。相反,我用三天时间做了一次系统性的问题盘点,最后输出了一份让整个团队都沉默的清单。
这份清单分成四类:
- 死代码与不可达逻辑:通过静态分析和运行时日志交叉比对,确认大约有 12% 的代码在过去一年里从未被执行过。这些代码不算致命,但它们在每次全局搜索和代码审查中都会消耗注意力。
- 共享可变状态:有多个模块通过同一个全局缓存实例读写数据,而且没有统一的版本控制,任何一边改了数据结构,另一边都会在毫无提示的情况下拿到损坏的数据。
- 隐式耦合:一个最典型的例子是 A 模块的日志里会顺带打印 B 模块的错误信息,导致日志监控系统把 B 的故障归因到 A 上,每次排查都会走弯路。
- 不可测试的上帝函数:某个核心计算函数有六百多行,内部直接操作数据库连接、缓存、消息队列和外部 HTTP 调用,参数有十一个,其中五个是布尔标记位。这个函数是整个系统最脆弱的部分,因为没人敢给它写单元测试。
这份清单的价值不在于"发现了多少问题",而在于让我们明确了重构的边界。不可能一次解决所有问题,但如果不知道问题在哪,任何所谓的技术方案都是在赌运气。后来团队内部形成了一个共识:任何重构项目的前两周,只做诊断和盘点,不允许动业务代码。谁动了,谁请全组喝咖啡。
2. 代码体检:先看清全局,再动手开刀
2.1 绘制模块依赖图,找到真正的核心链路
正式动手之前,我给团队立了一个规矩:没有依赖图的项目重构,就是闭着眼在雷区里跳舞。t3code 的模块关系已经乱到无法靠人脑梳理,所以我们借助工具先给系统画了一张完整的依赖拓扑图。
我用的工具组合是jdepend配合Structure101,前者负责做包级别的依赖分析,输出每个模块之间的依赖关系;后者负责把依赖关系可视化成图,并且标出"环"的存在——多个模块相互依赖形成的循环引用。这一步做完之后,整个系统的病灶就像 X 光片一样清晰了。
依赖图让我看清了三件用眼睛看代码根本发现不了的事:
- 有一个"审计日志模块"顺着依赖箭头查下来,居然间接依赖了几乎所有的业务模块。也就是说,每一次写审计日志的操作,都可能触发一长串初始化逻辑,这就是为什么某些写操作莫名其妙地慢。
- 名义上的三层架构(表现层、服务层、数据层)早就名存实亡,服务层里有大量直接操作数据库表的代码,数据层里反而藏着业务判断逻辑。
- 核心链路其实只有一条,但为了照顾十几种边缘场景,代码里塞了二十多个 if 分支,其中有六个分支从未被任何已知请求触发过。
依赖图不光帮我们找到了问题,更重要的是帮我们定下了重构的顺序。规则很简单:从依赖关系图的底层开始改,先改那些被依赖最多、但自身不依赖别人的模块。这样做的好处是,每次改动的影响半径是可控的,不会出现改了一个底层工具类,结果三天后才发现远端的某个服务被牵连的情况。
这里我想多提一句:很多人觉得画依赖图是浪费时间,有这个功夫不如直接开改。但我的亲身体会是,依赖图上花掉的一周时间,在后期至少省出了三周以上的联调时间。尤其是当你需要向老板或者甲方解释"为什么重构需要四个月"的时候,一张清晰的依赖图比任何 PPT 都有说服力。
2.2 用静态分析工具量化"坏味道",让问题变得可以讨论
画完依赖图之后,下一步是对每个模块的代码质量做量化评估。这个阶段我用的是SonarQube加eslint(针对项目里的 JavaScript 部分)和pylint(针对 Python 部分)。t3code 是典型的多语言混杂项目,核心服务用 Java,有个辅助脚本模块用 Python,前端管理后台混着老旧的 jQuery 和新版的 React。
量化指标我只关注四个:
- 圈复杂度:函数的圈复杂度超过 15 的,一律列入重构观察名单。圈复杂度超过 30 的,直接标红,列为最高优先级。这个指标能在一定程度上代表代码的可理解成本——复杂度越高,越没人敢动。
- 重复代码率:用 SonarQube 自带的重复代码检测跑了一遍,结果触目惊心,有将近 18% 的代码属于重复片段。最离谱的是同一个"日期区间校验"的逻辑,在项目里被复制粘贴了十一份,其中三份有 Bug,三份又是修过的版本,还有五份完全一致。这种重复带来的问题不只是维护代价高,而是你压根不知道哪个副本是对的。
- 注释与代码的匹配度:这个需要人工抽查。我随机抽了 50 个函数,发现有 12 个函数的注释描述的内容和实际逻辑是矛盾的。这种"会撒谎的注释"比没有注释更危险,因为后人会相信注释,然后用错误的理解去改正确的代码。
- 测试覆盖率:整个项目的核心模块测试覆盖率不到 25%,而且很多测试是"光跑不断言"的空壳测试。这种情况下的覆盖率数字完全没有参考价值,它只代表了"代码被执行过",不代表"代码被验证过"。
我把这些量化结果整理成了一份报告,并且按模块给出了红黄绿三个等级的评分。这份报告在项目组里引起了不少讨论——有些同事一开始觉得评分不公平,但当他们看到自己的模块被标红的具体原因时,大多还是认账的。量化的意义在于把"我觉得这里写得差"变成了"这里有 37 处重复代码段和 14 处圈复杂度超标的函数",后者才是能推动决策的语言。
2.3 确定边界:哪些该重构,哪些该重写,哪些该原地冻结
代码体检做完之后,团队内部开了一次整整一天的评审会,主题只有一个:对每一块代码分类,决定是重构、重写、还是冻结。
当时我们定的分类标准是这样的:
| 分类 | 判断标准 | t3code 里的实际占比 |
|---|---|---|
| 保留并重构 | 逻辑仍在活跃使用,结构问题可控 | 约 45% |
| 整体重写 | 逻辑复杂、使用频率高、且被判定为不可维护 | 约 20% |
| 原地冻结 | 仍有调用方但极少变更,风险高 | 约 18% |
| 删除 | 已无调用方,确认死亡代码 | 约 12% |
| 待进一步确认 | 调用方不明确,需要运行时追踪 | 约 5% |
这个分类最大的作用,是让团队停止"在垃圾堆里盖新房"的无意义努力。之前总有人试图在某个乱成一团的函数内部继续加功能,现在分类明确后,那些代码要么等重写,要么被冻结,谁都不许再往里加逻辑。新增需求一律走新的替代模块,这就是后来我们常说的"新旧隔离"策略。
3. 重构执行的三个关键阶段:安全网、主干替换、数据迁移
3.1 第一阶段:先织一张不会让人摔死的安全网
重构任何遗留代码之前,最忌讳的事情就是直接动手改代码。你根本不知道现有逻辑里哪些行为是"特性"还是"Bug"——很多业务依赖的,恰恰是那套错误的行为。比如 t3code 里有一个对账模块,逻辑上要求金额精确到分,但旧代码里有个浮点数直接比较的 Bug,导致有些小数位后四位的偏差被"正确"地吞掉了。如果重构时把这个 Bug 修掉,反而会打破下游对账系统的预期。
所以第一阶段的任务,是给核心链路建立"特征测试"(Characterization Test)。这个想法来自 Michael Feathers 的那本《修改代码的艺术》,核心做法很朴素:用代码把当前系统的行为原原本本地固定下来,不管它是对的还是错的。
我们选了一个数据流最复杂的核心模块——订单汇总计算链,针对它的外部输入输出写了几百个特征用例。每一个用例的做法是:
- 从生产环境的流量录制工具(我们用的
GoReplay)抓取真实的请求和响应。 - 把请求-响应对作为一条测试用例固化到自动化测试里。
- 这些用例的意义不是"验证功能正确",而是"确保重构过程中任何行为偏差都能立刻被发现"。
也就是说,重构前和重构后,同一个请求必须得到完全相同的响应,哪怕这个响应在你看来是不合理的。如果有差异,测试就会红,我们就得停下来分析为什么。这套安全网在一开始跑的时候,每天都会红一片,因为很多依赖的初始化顺序是隐性的,稍微调整一下模块加载顺序,某些数据的计算结果就变了。但正是这些"红",让我们知道哪些改动是有副作用的。
我强烈建议所有做重构项目的朋友,把"特征测试"放在第一步。哪怕你手头的时间再紧,这个步骤不能省。它就像高空作业时系在身上的那根安全绳——你会希望它永远用不上,但没有它你根本不敢往下跳。
3.2 第二阶段:按照依赖图优先级,逐层替换主干模块
安全网铺好之后,我们开始按依赖图的优先级,从底层往上层逐层替换。
这个阶段的顺序非常关键。我们的原则是每一轮替换都只动一个模块,替换完成后跑全量特征测试,同时灰度发布给一部分真实流量,观察对比新旧逻辑的输出差异,确认一致后再进行下一轮。
拿最典型的订单汇总模块来说,替换过程分为四步:
第一步,在新代码目录里建立同名模块框架,先把旧的输入输出接口原样拷贝过来,保持签名完全一致。这一步的目的不是为了实现新逻辑,而是为了让新模块能"插拔"到旧系统里而不破坏编译和启动。
第二步,把旧模块里那六百多行的上帝函数,按职责拆成五个逻辑片段:参数解析、数据查询、业务计算、组装结果、记录日志。每一段都用独立的函数封装,并且在此期间不改变任何计算逻辑,只做结构拆分。
第三步,把拆分后的新模块接入主干,跑特征测试。这一步通常会暴露很多问题,比如旧代码里有些"副作用"发生在函数中间,而拆分后这些副作用的位置变了,导致后续逻辑读数异常。遇到这种情况,要回到旧代码里精确定位副作用的触发时机,然后在新的拆分结构里用显式调用来模拟,而不是靠巧合。
第四步,灰度对比。我们在生产环境把 5% 的流量切到新模块,同时把新旧模块处理同一批请求的结果做字段级对比,持续观察三天。直到对比结果零差异,才把流量逐步提升到 50%、100%。
这个阶段最需要耐心。平均下来,我们每替换一个核心模块,需要一到两周的时间。中间也会遇到让人抓狂的问题,比如新旧模块的并发行为不一致——旧模块是单线程按顺序处理,新模块用线程池并发处理,结果某些共享数据被重复初始化,导致偶发错乱。这类问题只有灰度流量放大到足够大时才能暴露出来,所以每一轮的灰度观察期都不能压缩。
3.3 第三阶段:数据层迁移与双写校验,这是最容易翻车的一步
前端模块替换得七七八八之后,最硬核的部分来了:数据层迁移。t3code 的旧库用了大量存储过程完成业务逻辑,有些表的结构设计根本不符合当下业务的需求,比如把标签直接拼在人名字段里,用逗号分隔。这种设计在查询特定标签的用户时,只能靠LIKE '%tag%'去扫全表。
数据迁移我们采用了比较保守的"双写"方案:
- 新表结构设计完成后,在迁移窗口开启前,建立新旧两张表之间的同步管道。业务写入操作同时写旧表和新表,读取操作暂时仍全部走旧表。
- 同步管道跑一周,确保新表的数据和旧表完全一致。这一周我们每天跑对账任务,对比关键字段的数量和校验和,有差异就立即暂停双写并排查原因。
- 确认数据一致后,把读取操作切换到新表,观察一周。此时旧表仍然在写入,但没人再读了。
- 最后,将写入操作切到新表,旧表彻底降级为只读备份,再保留两个月后下线。
双写阶段看起来简单,实际上处处是坑。我印象最深的是两张表的字段映射关系里有一个枚举值含义发生了变化——旧表里status=2表示"已取消",但在新表里status=2表示"已退款"。这个映射错误在数据一致性对账中被抓住了,但如果不是提前设计了"字段级校验"而不是"全表行数校验",这个问题会在切换读操作之后才暴露,那时候排查成本会翻好几倍。
数据迁移给我的教训是:永远不要相信人眼比对数据。哪怕你觉得自己看得很仔细,几百条数据或许还能看出来,到了几十万条量级,必须依靠脚本做自动化逐字段对比。我们用 Python 写了一个简单的对账脚本,跑一次只需要几分钟,但它成了整个迁移过程中最让人安心的工具。
4. 重构过程中真实踩过的坑,以及对应的规避方案
4.1 坑一:过度抽象导致的"第二次泥潭"
重构进行了大约六周之后,我们犯了一个当时觉得挺合理、事后追悔莫及的错误。有一位同事提出,既然这么多模块都要做类似的日志记录和权限校验,不如抽一个"通用基础组件"出来,统一管理这些横切逻辑。听起来很对,对吧?我同意了。
结果这个"通用基础组件"在两个星期之内收到了来自七个不同模块的定制化需求,每个模块都要求它支持自己特有的配置项。为了兼容这些需求,组件内部塞满了 if-else 和可配置开关,接口参数从一开始的三个膨胀到九个。到了第三周,这个组件的复杂度已经超过了它想取代的那些分散代码的总和。
后来我们痛定思痛,把这个组件直接拆掉,退回每个模块自带轻量实现。拆掉之后,系统反而更清爽了。
这个教训我记到今天:重构中的抽象必须源于至少三个以上的真实调用场景,而不是基于"将来可能有需要"的预判。在代码里预防未来,大多数时候只是在制造现在的复杂度。与其用一个过度设计的通用组件,不如允许暂时的重复,等真正看到重复的规律时再做抽象。
4.2 坑二:并行发布时新旧逻辑的偶发错乱
第二坑发生在数据迁移接近尾声的时候。由于新旧两套逻辑在线上并行运行,我们需要在代码里通过配置开关来控制流量切换。有一天晚上配置中心推送了一个新配置,同一个服务实例里新旧两个模块同时收到了配置更新事件,结果其中一个模块按照新配置走了新逻辑,另一个模块还没来得及更新,仍在跑旧逻辑——两套逻辑同时对同一批数据动了手,险些造成线上数据污染。
排查原因后发现,这不是配置中心的问题,而是我们自己的代码在切换逻辑时没有做原子化处理。正确的做法应该是在每次请求进来的时候,根据请求的唯一 ID 计算一个 hash,确定它走旧逻辑还是新逻辑,并且在整个请求生命周期内保持一致。我们最初用的是"全局开关",配额的切换无法保证同一请求的前半段和后半段读到一致的开关状态。
这个问题的规避方案其实不复杂:任何灰度切换都必须做到"请求级一致性",而不是"配置级一致性"。一个请求一旦被标记为走新逻辑,那么它内部涉及的所有子调用、数据库读写、消息投递,都必须遵循新逻辑的规则,不允许在中间某个环节因为配置更新而跳到旧逻辑去。
4.3 坑三:人员交接期知识断层如何弥补
重构进行到一半的时候,团队里最熟悉旧系统的两位老同事之一提出了离职。这件事一度让项目陷入停滞,因为很多旧逻辑的知识只存在他的脑子里,而文档和代码都没有完整记录。
这件事让我意识到,重构项目里最脆弱的资产不是代码,而是"活的知识"。从那之后,我们强制推行了两条制度:
第一,任何模块的重构都必须由两个人共同负责,一个熟悉旧逻辑,一个擅长新代码。两个人的组合不能是一个纯新手配一个纯专家,必须有交叉。这样即使走了一个人,另一个人也能在两周内把关键知识传给接手者。
第二,每周必须开一次"行为差异评审会",在评审会上,负责每个模块的人要用大白话讲解这个模块重构前和重构后的行为差异,包括那些没有在代码里体现的隐性规则。这些讲解被录音并整理成笔记,存到了项目知识库里。
这套机制没办法让知识 100% 传承,但它把"依赖某个人的记忆"变成了"依赖团队的过程"。后来项目过了危机期,我们回头看,这可能是比技术重构本身更有价值的制度建设。
5. 从 t3code 沉淀出的可复用清单,留给以后的自己
5.1 重构成本评估表:什么时候该重构,什么时候该重写
经历 t3code 之后,我最大的收获是:不是所有烂代码都值得重构。有些代码烂到了骨头里,重构的成本比推倒重写还要高。为了快速做这个判断,我整理出了下面这张评估表,后续每遇到类似项目都会先过一遍:
| 评估维度 | 倾向重构 | 倾向重写 |
|---|---|---|
| 代码调用范围 | 核心链路的一部分被使用 | 几乎所有模块都在依赖它 |
| 行为确定性 | 有明确的输入输出规格 | 行为模糊,没人说清所有副作用 |
| 测试基础 | 已有部分可用测试或能快速补充 | 几乎没有测试且代码不可测试 |
| 数据结构复杂度 | 数据结构稳定,字段少有变化 | 表结构疾病缠身,字段语义混乱 |
| 业务价值 | 业务逻辑仍然清晰且活跃 | 业务已被边缘化,逻辑无人理解 |
如果评估结果是"重写",那就不要犹豫,不要尝试在旧代码上面修修补补。如果评估结果是"重构",那就严格按照安全网、依赖图、分层替换的节奏来推进。最怕的就是既不重构也不重写,只靠打补丁度日——那是真正的慢性死亡。
5.2 团队协作层面的节奏控制
重构项目的失败,很多时候不是因为技术难,而是因为节奏失控。t3code 项目里我们定了几条纪律,每条都来源于一次或多次真实的教训:
- 每两周必须有一个可演示的成果。不管这个小成果是拆分了一个函数、减少了一个隐式耦合,还是新建了一个能跑的模块。可演示的成果能让团队保持信心,也能让管理层看到进展。
- 每一次代码评审,重构项目必须使用比平时更严格的审查标准。不是只是"能跑就行",而是要求每个新函数不超过 40 行、每个模块不允许出现循环依赖、每个公共接口必须有真实调用方。
- 允许犯错的预算要提前划定。我们当时规定,重构期间允许出现最多两次需要回滚的严重事故,超过这个次数就必须暂停,重新评估进度和方案。给意外留出预期,意外真的来了才不会把团队心理防线击穿。
5.3 重构完成之后,观测体系才算真正的验收
很多人以为重构上线就算完了,其实恰恰相反,重构上线的那一刻,监控和观测才真正变得重要。t3code 重构完成后的前两个月,我们搭建了一套完整的观测体系,用来持续验证重构的成果。
核心指标有三类:
- 稳定性指标:接口错误率、超时率、熔断次数。重构后的系统如果稳定性不升反降,说明还有隐藏问题。
- 性能指标:TP50、TP95、TP99 响应时间。重构不能只是"代码好看了",必须体现在关键业务链路的响应速度上。
- 交付效率指标:从需求到上线平均耗时、线上缺陷数。这个指标最能反映开发体验的真实改善——如果重构只是代码结构变好但团队交付速度没变,那这个重构的商业价值就需要重新审视。
我们还专门搭了一个重构前后对比看板,把上述指标按时间轴放在一起展示。在重构完第一个月的时候,TP99 还有一些抖动,因为某些旧的定时任务仍然在跑,和新的数据管道产生了资源竞争。我们顺着看板上的异常点往下排查,最终清掉了几条已经不再需要的定时任务,系统才真正稳定下来。这个看板直到项目结束后的半年都还在持续运行,因为它已经变成了团队日常巡检的默认工具。
关于 t3code 这个项目,我能写出来的经验大概就是这些。最后再补充一句:事后复盘的时候,团队里很多人都在感慨,如果当时没有坚持先做诊断、再定边界、然后织安全网这三步,而是直接拿着 IDE 冲进代码里开改,结局很可能是把一套还能勉强运转的系统搅成一锅更稠的粥。重构这门手艺,慢就是快、稳就是快,这是我在这四个月里最深的体会。