拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大型Java项目为什么无人敢改?从风险识别到渐进式重构的破局之路

大型Java项目为什么无人敢改?从风险识别到渐进式重构的破局之路

凌晨两点四十七分,线上支付成功率掉了三个点。我盯着告警群里连番弹出的消息,打开 IDEA,跳进一个跑了快十年的 Spring 模块里,然后——卡住了。不是卡在编译,也不是卡在环境,而是卡在“到底要不要动它”这个念头上。那段四百多行的私有方法牵扯着五张表、三个外部系统,还有一段谁也说不清当年为什么这么写的历史逻辑。我瞟了一眼旁边同样被叫醒的同事,他也看着我。最后我们把超时参数从三秒调到五秒,加了行日志,互道晚安,各自睡去。

大型 Java 项目最隐蔽的工程危机,从来不是系统崩溃,而是上面那个场景的无限循环:系统能运行,业务在增长,但每一行代码背后都站着一排不知道什么时候会踩响的地雷。没人敢改,不是大家懒,而是风险高到任何一个正常人都会选择不动。这篇文章不打算给你打鸡血,只想把这种“无人敢改”的状态拆开,讲清楚它是怎么形成的、怎么识别、以及——如果你正好身处其中——有没有一条不那么激进的出路。

1. 系统能跑不等于系统健康:先拆掉“没坏就别动”的思维钢印

我见过太多团队把“能运行”当作系统的健康标准。仿佛只要线上没炸,架构就是好的,代码就是行的,一切都可以维持。但事实恰恰相反,绝大多数存量 Java 系统能撑到今天,靠的不是设计精良,而是无数层兜底在替它的缺陷买单。

1.1 运行中的系统靠的不是好设计,而是层层兜底

先想想一个典型的老单体应用:数据库连接池配置了最大 200 个连接,但实际并发只有 80,于是你感觉不到连接池大小是否合理;某个第三方接口偶尔超时,但底层有重试框架,失败了自动重试三次,于是业务方根本感知不到它有多脆弱;一段 O(n²) 的循环处理 100 条数据只要 30 毫秒,于是没人注意到它其实应该写成 Map 匹配。

JVM 本身就是一个巨大的容错机器。OutOfMemoryError 有兜底,线程池满了有拒绝策略,连接超时可以被重试掩盖,数据库死锁可以被自动回滚后再次提交勉强绕过。系统“还能运行”的真相是:无数个重试机制、超时设置、熔断兜底和运维人员的半夜手工抢救,把问题一层一层地摁在了水面以下。

但兜底是有限度的。流量翻倍、数据涨到某个临界值、某个上游接口突然变慢,这层伪装就会瞬间撕开。我在一个老项目里见过连接池被打满的经典惨案:表面上看系统一直正常,只是偶尔有请求变慢,四个监控项全是绿色。直到双十一流量来了,池子瞬间枯竭,所有线程阻塞,业务全线瘫痪。查到最后,发现根因是六个月前有人把一条 SQL 的索引弄丢了,而重试机制让这个错误一直没被暴露出来。

“运行中”和“健康”之间的距离,就是所有隐性债务全部到期之前的时间差。

1.2 老 Java 项目为什么特别容易“带病运行”

不是说 Java 不好,而是 Java 项目的生命周期普遍太长,长到有足够的时间积累出各种奇怪的“带病运行”状态。我见过不少项目的演进路径都是这样的:最早用 Struts2 + Spring 3,后来迁到 Spring Boot,再后来为了提效又引进了 MyBatis、Dubbo、消息队列、分布式任务调度平台。框架换了四五代,但代码里还留着大量当年 XML 配置时代的影子。

这种长期演进带来一个典型问题:配置漂移。同一个参数,可能同时存在本地 properties、Nacos 配置中心、数据库配置表三个来源,而且三处默认值还不一样。代码在本地能跑,是因为本地配置碰巧正确;在生产上能跑,是因为生产环境里的某次手工修改碰巧覆盖了错误默认值。你问大家这个配置最终以哪里为准,没人能拍着胸脯给出答案。

再加上 Java 生态里动态代理、反射、字节码增强这些机制——Spring AOP、MyBatis 的 Mapper 代理、CGLIB、javassist——静态分析工具很难穿透这些层看到真实调用链。IDE 里跳转到一个方法实现,结果跳到一个代理类里,星星点点全是 Tracer 日志,关键业务逻辑却藏在 XML 映射文件里。这种项目如果没人长期维护,几年下来,除了“还能运行”这一个事实,几乎所有的工程指标都在悄悄恶化。

1.3 “没坏别动”的真正代价:把决策权交给运气

团队一旦形成“没坏别动”的共识,短期看是规避风险,长期看其实是在累积一个更被动的选择:当系统必须得改的时候,你已经没有选择的余地了。这句话怎么理解?

我举一个特别常见的例子。订单状态机里有一段老代码,它同时负责“创建订单”“更新库存”“发送通知”三件事,其中还有一段专门处理某种历史遗留的异常状态。需求方提了个新需求:订单创建后要增加一个自动审核步骤。你评估了一下,发现必须动这段老代码,但它的逻辑牵扯到十几个调用方,而且没有任何测试覆盖。你改的时候非常小心,结果上线后库存数据还是出错了——那个异常状态处理逻辑其实是有人手工修过数据后留下的补丁,被你当成正常逻辑挪走了。

这个失误的成本有多大?业务层面是半天数据修复,工程层面是对这个模块的信任崩塌。下次再有人碰到这块代码,就会默认“这里有问题,能不动就不动”。这种不信任是会传染的:一个模块没人敢改,就会传染到整个服务,再传染到团队的新人——他们入职三个月,学会的第一件事不是怎么写代码,而是“哪里不能碰”。

所以,“没坏别动”真正的代价不是眼前少做了一个需求,而是把一个本该由工程决策解决的问题,彻底交给了运气。今天系统还能运行,是你运气好;明天它扛不住流量挂了,换个团队来收拾残局,就没有运气可言了。

2. 无人敢改的病根:风险不是玄学,是可以被定位的

“敢不敢改”看起来是团队情绪问题,本质却是一个工程问题。不敢改的人不是怂,而是在完成一次理性的风险评估后,得出了“风险大于收益”的结论。这个风险评估的输入,就是信息完整度、耦合的可见性、以及出事后止血的难易程度。

2.1 信息断层:代码还在,解释代码的人走了

大型 Java 项目,尤其是活了很多年的那种,几乎都经历过人员更替。第一代开发者写了核心模块,升职走人了;第二代开发者接手后加了一些功能,跳到别的公司;第三代开发者在上面打了几个补丁;现在轮到你看代码的时候,屏幕上这一坨逻辑里已经混了三代人的思路。

这个过程中最可怕的东西不是烂代码,而是“信息孤岛”。注释里写着“此处不能动,动了会出大问题”,但没有人知道当初到底发生了什么,也没有文档记录当时排查的过程。我遇到过一个真实的例子:一个物流项目的运费计算模块,里面有一个魔法数 0.97,注释是“按集团要求处理”。后来集团确实调整了运费折扣策略,新来的同事想动这个 0.97,但找不到当初的依据,只能照着老逻辑再抄一遍,继续保留注释,问题被完整地带到了下一代。

当你评估一个改动时,需要的信息包括:这段逻辑为什么存在、它的输入有哪些边界情况、所有调用方分别在哪、改动后的验证手段是什么。如果一个项目里这些信息只存在于某个离职员工的脑子里,那任何人评估改动风险时,都会得出同一个结论:我不知道会发生什么,所以我不敢改。

2.2 隐性耦合:你以为在改 A,实际上牵动 B、C、D

直观的耦合还可以靠代码审查发现,隐性耦合才是最阴的。Java 项目里最常见的隐性耦合是什么?静态工具类里的可变状态。

我给你画个画像:一个名为GlobalCacheUtil的类,里面有一个static HashMap,谁都可以往里塞数据。从类名看它像缓存,度很高——但问题是它有 43 个调用方,分散在 11 个模块里,其中有几个模块在跑批任务时会向里面写数据,而另一些在线接口会读取。你只是想优化其中一个调用方的逻辑,把它从一个方法改成另一个方法,结果在一个低峰时段,跑批和在线请求对同一个 key 的并发写把缓存搞脏了,线上出现了一堆诡异的脏数据。

再比如,一个 Service 里直接new了另一个 Service 的实现类,绕开了 Spring 管理的依赖注入,导致 AOP 拦截、事务代理全部失效。你还以为走了事务,其实那个方法用的是裸 JDBC 连接,根本没参与 Spring 的事务管理。这种问题你只靠读代码很难看出来,因为它看起来完全正常。

还有一个被反复提及的重灾区是 JPA 实体的级联操作。清理一条主记录,结果连带把照片、操作日志、审批流全部清了。代码里根本没有显式调用 delete 的痕迹,全在实体注解和 ORM 框架的级联规则里。这类问题有一个共同点:它们都不在“调用链”这个常规观察维度上,所以常规的代码审查找不出来,只有到了生产环境才能暴露。

2.3 没有测试的悬崖:不是它完美,而是它“测试不起”

有一个反直觉的现象:越烂的系统越没有测试,而理由不是“它很完美不需要测试”,恰恰相反——它太烂了,写测试的成本高到让人望而却步。

模块 A 内部有 500 行逻辑、耦合了数据库、Redis、消息队列和三个外部 HTTP 接口。你给我写一个单元测试看看?必须先 mock 掉五六层依赖,然后还得处理各种静态方法、全局配置。Mockito 的when写了一个小时,跑起来还是抛 NullPointerException,因为某个静态块里初始化了一半就失败了。一轮折腾下来,正常功能没做,测试先写了两天,这让任何有正常时间观念的工程师都会退避三舍。

没有测试,就形成了一个死循环:因为没有测试,改动风险高;因为风险高,没人敢改;因为没人敢改,代码越来越烂;因为越来越烂,更没有人愿意补测试。打破这个循环的关键,不是指望某一天团队突然爆发激情把所有测试都补上,而是找到一个小小的切入口,从最安全、最独立、最值得保护的地方开始补,一点一点把这个恶性循环扭过来。

2.4 结构指标不会骗人:用依赖图和复杂度看烂在哪

如果你觉得前面的分析太主观,那我们从数据角度看。IDE 里直接看调用层次、依赖结构、圈复杂度,往往比任何经验都诚实。

我当时接手一个账务模块时,第一件事就是把它的类依赖图导出来。结果非常直观:有一个OrderService类,五百多行方法,十几个private方法,扇入三十多,扇出二十多。它同时被 Controller、定时任务、MQ 消费者、RPC provider 四类入口调用。这意味着你动它任何一个方法的签名,至少四个调用方会被影响;改其中一个私有方法的逻辑,可能影响十几个出口的行为。

工具方面,我建议你看看这三样:IDE 的 Call Hierarchy(快速看一个方法的调用关系)、SonarQube 的复杂度报告(找圈复杂度巨人)、ArchUnit(后面会详细说,它能把架构规则固化成测试)。把这些指标数据拉出来,你会发现“不敢改”不是一种感觉,而是一个可以被量化表达的事实:调用点太多、测试太少、复杂度太高、依赖太乱。只有把它们变成数据,才能反过来指导“先改哪里”。

3. 给存量项目做一次“工程体检”:用数据代替直觉

破局的第一步,是搞清楚自己的项目“病”到了什么程度。靠“我觉得代码很烂”这种直觉支撑不了任何决策,你需要的是可量化的体检指标。

3.1 最简单的指标:改一行代码要多久

我特别喜欢用一个简单到有点粗暴的指标来评估一个模块的健康度:从接到一个改动需求,到把代码改完并验证通过,需要多长时间。

具体测试方法很简单:随便挑一个核心方法,把其中一个参数名改掉,然后从“定位调用方”开始全程计时——IDE 全局搜索花了几秒、能跳转到的调用点有多少个、有没有测试能直接告诉你改坏了哪里、编译一次需要多久、跑一遍关联模块的回归需要多久。这个过程做完,你对这个模块的“改动成本”就有了一个非常直接的体感。

如果一个参数改名,你花了二十分钟还没确认所有调用方都改完了,那你对这个模块的一切改动其实都是在刀尖上跳舞。这个时间如果超过两小时,基本可以断定,这个模块是团队未来交付速度的最大瓶颈之一。

3.2 一份可复用的 Java 存量项目体检清单

我把自己在几个项目里用过的体检项整理成了一张表,你可以直接拿去跑。

体检项健康阈值(参考)说明
全量构建时间10 分钟内超过 20 分钟,CI 基本失去“快速反馈”的作用
单元测试行覆盖率关键模块 60% 以上低于 20% 的模块,改动前必须人工推算影响面
全量回归时长30 分钟内超过 1 小时,每次发版都是一次煎熬
核心类圈复杂度中位数10 以内超过 20 的方法,建议优先纳入重构观察名单
跨模块调用点数量单方法不超过 20 个调用方超过 30,任何签名变更都是伤筋动骨
模块循环依赖数量0存在循环依赖的模块,构建顺序和启动都可能埋雷
TODO / FIXME 数量控制在个位数超过 50,说明代码里充满了“未完成的手尾”
CR 平均耗时10 分钟内超过 30 分钟,往往意味着 diff 太大,评审已经失效
近三个月故障分布热度集中在少数模块如果所有模块雨露均沾,说明问题在整个系统的地基

你不用每一项都达标,重点是跑完一轮之后,就能挑出整个系统里最脆弱的那几个点。做工程体检的目的不是骂自己代码写得差,而是给后续的重构排一个优先级。

3.3 工具怎么选、从哪先跑起来

体检工具不一定要新装一堆。先把你已有的数据用好:Jenkins 的构建耗时、SonarQube 的代码分析、CI 里测试报告的历史趋势、线上监控里的故障热点,这些都能无成本地反映问题。

新工具里,我最想推荐的是 ArchUnit。它是一个用 JUnit 风格写架构规则的 Java 测试库,能把“禁止 Controller 直接调用 Repository”“Service 不允许相互 new”这类架构约束直接固化成测试。一旦有人违反规则,跑测试立刻爆红。

下面是一段我当时写过的很典型的 ArchUnit 规则,你可以感受一下它的用法:

import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; @AnalyzeClasses(packages = "com.example.legacy") public class ArchitectureRuleTest { @ArchTest static final ArchRule controller_should_not_access_repository = classes().that().resideInAPackage("..controller..") .should().onlyDependOnClassesThat() .resideInAnyPackage("..service..", "java..", "org.springframework..", "..dto..") .because("controller 层只应依赖 service 层接口,直接访问 repository 会破坏分层的意义"); @ArchTest static final ArchRule no_cycle_between_services = classes().that().resideInAPackage("..service..") .should().onlyHaveDependentClassesThat() .resideInAnyPackage("..service..", "java..", "org.springframework.."); }

注意一个细节:架构规则宁可先松后紧,不要一上来就卡死一切。我见过有的团队第一天就把几十条规则全部打开,结果 CI 全红,大家怨声载道,最后把规则全部注释掉了。正确做法是先跑一版“只报告不介入”的规则,让团队自己看清楚问题分布,再逐条收紧。

3.4 别被体检报告吓到:把问题分三档再动手

体检结果出来之后,你大概率会看到一堆触目惊心的数字。先别慌,也不是所有问题都值得立刻处理。我会把问题分成三档:

  • P0 级:可能引发生产事故的问题。比如并发写同一个全局缓存、事务边界失效、数据一致性遇到隐患、循环依赖导致启动失败。这些要优先处理,因为它们是炸弹。
  • P1 级:严重拖慢交付速度的问题。比如巨型 Service、深继承、重复代码、没有测试的关键模块。它们不一定会炸,但每个需求都要绕很远的路。
  • P2 级:纯粹难看的遗留问题。比如命名混乱、格式不统一、少量死代码。这类东西不影响运行,也不值得花宝贵的精力去清理,先放着就好。

大多数团队犯的错误,是在 P2 上花了很多时间自我感动,比如大规模重命名、统一代码格式,而 P0 的问题依然在线上埋着。体检报告的正确用法,是先解决 P0,再盯着 P1,P2 可以在日常维护中顺手清理。

4. 破局实操:从“不敢动”到“敢动手”的渐进式配方

体检完了,问题定位了,接下来才是关键:到底怎么让一个无人敢动的系统变得“可以动、敢动、动得起”。这里我想给你一套我亲测有效的渐进式路线,每一步都不激进,但每一步都在增加团队的系统掌控力。

4.1 第一步不是重构代码,而是先立“红线测试”

很多人一上来就想“我要重构这个模块”。停。在没有护栏的前提下直接重构大模块,跟裸奔无异。第一步应该先把红线画出来——让你和团队都知道,哪些架构约束是雷池,谁踩谁爆。

红线测试的核心思想,就是把你最关心的架构底线转换成自动化的测试。对这类的 Java 项目,ArchUnit 是极其顺手的选择。它可以检查分层依赖、禁止循环依赖、禁止不安全的调用方式,而且集成过程非常轻:

  1. 引入archunit-junit5依赖;
  2. 新建一个测试类,用@AnalyzeClasses指定要扫描的包;
  3. 写上几条最重要的规则,先以“报告模式”跑一轮,看看系统里违规情况如何;
  4. 确认规则不误伤后,改成强校验模式,纳入 CI。

这套东西的价值不在于它发现了多少违规,而在于它建立了一个“改坏了会立刻报错”的反馈闭环。没有这个闭环,所有人改代码都是在赌运气;有了它,哪怕还是没人敢改,至少大家知道了“改到什么程度会踩红线”,风险从不可见变成了可见。

4.2 挑软柿子:用低风险模块重建团队的修改信心

红线立好之后,先别碰危险地区。找个影响面最小、逻辑最独立的模块开刀,可以是某个纯工具类、某个无状态的验证逻辑、某个只依赖入参和出参的计算函数。

我当时挑的是一个运费计算的纯函数。它入参只有订单毛重、配送区域、运费模板三个值,出参是运费。唯一的问题是这个函数是一个两百行的私有方法,塞在订单服务里,还掺了一段过期的优惠逻辑。我花了一天时间把它提成独立的FreightCalculator类,把所有分支用表格场景写成了 12 个单元测试,再用 ArchUnit 加了一条“运费计算必须走 FreightCalculator”的守护规则。

这个改动没有任何线上风险,但它对团队心理的意义非常大:大家第一次看到,在“无人敢动”的系统里,有人能花一天完成一次干净利落的修改,而且测试是绿的。这种“原来我也能改”的信心一旦建立,比任何技术方案都更能带动后续的改动。

4.3 接口先行:拆依赖得先立合同,再动实现

处理真正的硬骨头模块时,我的原则是:接口先行,实现后改。先给老代码外面裹一层稳定的接口,让调用方全部面向接口编程,然后再慢慢替换内部实现。这个阶段的每一步,都要保证对外的行为完全不变。

举个具体例子。报表模块每天凌晨跑批,历史原因它没走消息队列,而是直接调用了订单服务的内部方法OrderServiceImpl#listByCondition。这个方法的签名很“脏”,传一个 Map 进去,里面塞各种条件。现在你想把报表模块改成通过 MQ 订阅订单变更事件来同步数据,如果直接改调用方,风险巨大;如果直接动订单服务,影响更大。

正确做法是:先抽一个OrderQueryService接口,定义一个干净的方法,比如List<OrderSnapshot> queryChangedOrders(OffsetDateTime since),然后把老逻辑包在适配器实现里,暂时仍然调用原来那套查询实现。报表模块切到新接口后,接口行为完全一致,适配器内部继续跑老逻辑。等哪天真正把报表模块改成订阅消息了,适配器才被真正替换成新实现。

接口先行的意义在于:你先把变化点隔离在一个小盒子里,让外界先依赖稳定的契约,再去动盒子内部。这样即便内部重构翻车,影响面也只会局限在适配器这一个点上。

4.4 数据一致性:大改涉及数据迁移时最容易翻车

几乎所有大重构的翻车现场,最后都会集中出现在数据一致性上,尤其是涉及库表拆分、字段调整、异步化改造的时候。这也是很多 Java 程序员面试时被问到“怎么保证数据一致性”时会紧张的原因——因为这个问题在存量系统里,实在是太真实了。

我先说单体时代最简单的做法:@Transactional。只要所有写操作都在同一个 Spring 事务里,数据库本地事务就能兜住一致性。但这个方案在重构中会遇到两个问题:第一,事务边界如果被人为放大(比如在事务里调外部 HTTP、发 MQ),本地事务会变成一场灾难;第二,当你把一个逻辑拆成多个步骤、甚至多个服务后,本地事务就覆盖不了了。

拆库拆服务之后,我推荐的方案是最终一致性模式,最常见的落地是本地消息表和 Outbox 模式。核心思路:业务操作和“要发的事件”写入同一个数据库事务,然后由后台任务把事件可靠地投递出去。伪代码大概是:

@Transactional public void createOrder(Order order) { orderDao.insert(order); outboxDao.insert(new OutboxEvent("ORDER_CREATED", order.getId())); } @Scheduled(fixedDelay = 1000L) public void publishOutboxEvents() { List<OutboxEvent> events = outboxDao.selectUnpublished(); for (OutboxEvent event : events) { mqSender.send(event.getType(), event.getPayload()); outboxDao.markPublished(event.getId()); } }

很多团队下意识排斥最终一致性,觉得“还是强一致好”。但现实是,分布式系统里根本没有全局的强一致。我的经验是:宁可先接受最终一致,把对账任务做好,也别假装自己有强一致能力。做分布式改造的时候,所有改动上线前都要回答一个问题:如果消息丢了,下游怎么发现,上游怎么补偿?答不上来,就不要动那一块。

4.5 可回滚是敢改的前提:上线只是实验的开始

很多人不敢改老代码,核心焦虑是“万一改砸了怎么办”。这个问题不应该靠“更加小心”来解决,而应该靠“即使砸了也能快速恢复”来解决。可回滚能力,才是“敢改”的底气来源。

具体到 Java 项目,我做三件事会极大提升这种底气:

  • 特性开关:用 Togglz、FF4J 或者自研配置中心的开关,把新逻辑和老逻辑藏在一个布尔开关后面。上线先关着,灰度打开,出问题马上关掉回退旧逻辑,不需要重新发版。
  • 全链路 traceId:用 MDC 把 traceId 串到所有日志里。改了一个老模块之后,你必须有办法在日志里快速过滤出“这个请求走了新逻辑还是老逻辑”,否则出了问题连在哪查都不知道。
  • 快速回滚:任何大改动,上线前都要确认一件事——如果今天线上出问题,运维能不能在五分钟内把版本回滚到上一个稳定版。不能,那就别上。

我自己的经验是,当一个团队真正确认了“改坏了能退回去”,大家改代码的心理负担会显著降低。反过来,没有回滚能力的团队,连一次关开关都要战战兢兢,那就谈不上什么重构了。

5. 比代码更难改的是人:制度设计要跟着松绑

最后这部分,我想聊聊代码之外的东西。很多时候系统里的代码烂归烂,最让工程师绝望的其实是协作方式本身也在阻止任何改变。

5.1 代码评审是风险分摊,不是找茬

以前我们组 review 老代码的改动,气氛极其紧张。提交者心里没底,因为改动本身风险高;评审者心里也没底,因为他对这块代码同样不熟。结果评审变成了互相找茬:你改了个变量名,他说不行应该用另一个;你加了个日志,他说会影响性能。

后来我们调整了策略:老代码区域的改动,评审的核心目标不是“审查代码对不对”,而是“确认这段改动的风险是否被充分理解”。评审时问的问题变成了“你知道这个方法的其他调用方在哪里吗”“你的测试覆盖了哪些分支”“如果上线出问题,回滚方案是什么”。这样一来,评审不再是单挑,而是四个人一起分担一个改动的认知负担。风险被摊薄了,改的人心里也踏实多了。

5.2 维护者交接:不写文档,写“运行地图”

很多团队出了事故之后才想起来要写文档,结果写出来的东西没人看。我后来用过一个很有效的替代方案:给系统画一张“运行地图”,控制在两页 A4 纸以内。

地图上只需要写清楚几件事:系统有哪些核心模块、模块之间的调用链是什么、关键数据的流向是怎样的、哪些地方有测试、哪些地方是测试盲区、历史上踩过哪些大坑、以及每个核心模块的应急联系人是谁。

这张地图的价值在于,任何新同学入职第一周,只需要花半天读明白地图,就能对系统的整体风险有一个基本认知。而维护者画地图的过程,本身也是一次极好的知识梳理。你会发现,很多你以为很懂的系统,真要把它画清楚,还是需要查半天代码。

5.3 把技术债翻译成业务指标

技术债这个概念在技术圈内部很热血,但到了业务方那里,往往只会换来一句“那你们赶快改”。要让管理层真正支持你动老系统,必须把技术债翻译成他们关心的业务语言。

我一般给管理层看三个数:第一个是“平均一个功能需求从提需求到上线的周期”,如果这个数从两周涨到了两个月,业务方自己就急了;第二个是“线上故障的平均恢复时间”,这是客户体验的直接体现;第三个是“每周因为历史老逻辑而引入的返工比例”,这直接影响研发成本和团队士气。

把这些数字摆出来,管理层就会明白:重构不是 IT 部门想“玩技术”,而是“再不改,新功能永远出不来,线上故障永远摁不完”。从这个角度看,技术债就不再是研发内部的问题,而是产品竞争力的核心问题。

5.4 定期维护日,防止破窗效应

还记得我们说的“没坏别动”是怎么形成的吗?就是破窗效应。一间屋子有一扇窗破了没人修,很快其他窗也会被砸。代码也一样:如果团队默认“这里乱一点没关系”,那它会一直乱下去,直到彻底失控。

我们的做法是每两周留一个固定的“卫生日”,不排新需求,专门做低风险维护:清掉一批编译警告、删掉确认无用的死代码、把 TODO 里能快速解决的解决掉、给某个核心方法补一个测试。不做大重构,只做小扫除,但坚持做。

这个机制的效果非常神奇。实践两个月后,代码审查的噪音明显变少了,新人看代码的恐惧感也减轻了。更重要的是,它向全团队传递了一个信号:代码的整洁度不是某个人的执念,而是整个团队持续在做的日常工作。

写在最后:几条留给自己的老代码改动红线

如果要给这篇经验做个收尾,我不想做什么提纲挈领的总结,只想分享几条我后来写给自己、也写给团队的“老代码改动红线”,每一条都是用真金白银的教训换来的:

  • 超过三百行的函数,第一次动手只加测试、不动逻辑。先让它被测试罩住,第二次修改时才有基本的安全感。
  • 改动面横跨两个及以上模块的,先找到模块负责人,当面把变更思路讲一遍。不要以为自己读过代码就懂了全部,当面聊十分钟,往往能发现你漏掉的调用方。
  • 线上数据不一致时,第一优先永远是最小化止血,而不是立刻根治。先保证业务不继续出错,再回来说根因。
  • 任何一次改动,在写代码之前先问自己一句:如果明天上线有问题,五分钟内能不能回滚到上一个版本?不能,就先把回滚方案做完再动手。

我在接手第一份大型 Java 项目时,同样被“能跑但没人敢改”的局面困了整整半年。后来想明白一件事:破局的关键从来不在于某个人的技术有多强,而在于能不能一步步把不可见的风险变成可见的规则、把单点的知识变成共享的地图、把“改坏了怎么办”变成“改坏了能退回去”。

当团队里不再有人需要用“我不敢”来表达对系统的恐惧,这个系统才真正开始健康起来。

返回列表