接手一个代号叫 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%去扫全表。数据迁移我们采用了比较保守的双写方案新表结构设计完成后在迁移窗口开启前建立新旧两张表之间的同步管道。业务写入操作同时写旧表和新表读取操作暂时仍全部走旧表。同步管道跑一周确保新表的数据和旧表完全一致。这一周我们每天跑对账任务对比关键字段的数量和校验和有差异就立即暂停双写并排查原因。确认数据一致后把读取操作切换到新表观察一周。此时旧表仍然在写入但没人再读了。最后将写入操作切到新表旧表彻底降级为只读备份再保留两个月后下线。双写阶段看起来简单实际上处处是坑。我印象最深的是两张表的字段映射关系里有一个枚举值含义发生了变化——旧表里status2表示已取消但在新表里status2表示已退款。这个映射错误在数据一致性对账中被抓住了但如果不是提前设计了字段级校验而不是全表行数校验这个问题会在切换读操作之后才暴露那时候排查成本会翻好几倍。数据迁移给我的教训是永远不要相信人眼比对数据。哪怕你觉得自己看得很仔细几百条数据或许还能看出来到了几十万条量级必须依靠脚本做自动化逐字段对比。我们用 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 冲进代码里开改结局很可能是把一套还能勉强运转的系统搅成一锅更稠的粥。重构这门手艺慢就是快、稳就是快这是我在这四个月里最深的体会。