遗留系统改造实战——一个运行了8年的老系统,我们是怎么重构的
遗留系统改造实战——一个运行了8年的老系统,我们是怎么重构的
适读人群:面临遗留系统改造难题的工程师、技术 TL | 阅读时长:约20分钟 | 核心价值:遗留系统改造最难的不是技术,是在不停业的前提下一边飞翔一边换发动机
这个系统有多"遗留"
2022 年,我加入了一家做制造业 ERP 的公司,负责主要业务模块的重构工作。
这个系统运行了 8 年。8 年意味着什么?
- 技术栈:SSH(Struts2 + Spring3 + Hibernate3),JDK 1.7
- 代码量:约 150 万行,注释为零,几乎没有单元测试
- 数据库:Oracle 11g,500 张表,大量存储过程(约 200 个)
- 团队:原始开发团队 3 年前全部离职,没有人完全理解整个系统
- 文档:只有一份 2015 年写的需求文档,已经严重过时
更关键的是:这个系统在生产上运行着 50 多家客户的数据,不能停机,不能出 Bug。
为什么要重构,而不是"忍着用"
这是我加入后第一个要回答的问题。不少人(包括老板)觉得"能跑就行,不要动"。
我做了一次系统评估,给出了不能不动的几个理由:
维护成本极高:加一个功能,平均需要 3 周,因为要先看懂相关的 3000 行代码,弄清楚哪些地方要改,哪些地方不能改。同样的功能,新系统可能 3 天完成。
稳定性隐患:JDK 1.7 已经停止安全更新,Struts2 有多个高危漏洞(包括 CVE-2018-11776,CVSS 10.0),随时可能被利用。
扩展性瓶颈:客户在增长,但系统无法水平扩展,单机性能到达上限,某些重要客户在月末结账时系统会卡顿。
人才问题:没有工程师愿意在 2022 年写 Struts2,招人只能找 "能看得懂 Struts2 代码的程序员",薪资倒挂严重。
老板听完后,批了预算。但条件是:不能影响现有客户的使用,不能有生产事故。
这两个条件,决定了我们的改造方式。
改造策略:绞杀者模式(Strangler Fig Pattern)
你不可能把一个 150 万行的系统推翻重写——这是一个公认的工程失败模式("第二系统效应")。
我们采用的是绞杀者模式:就像热带雨林里的绞杀榕一样,新系统逐渐包裹住老系统,把老系统的功能一块一块地替换掉,最终老系统被完全"绞杀",由新系统全面接管。
整体思路:
关键是那个"路由代理"——它根据请求的功能模块,决定转发到老系统还是新系统。两个系统在过渡期共享同一个数据库,确保数据一致。
第一步:搞清楚老系统在做什么
改造前,最重要的工作不是写新代码,而是理解老代码。
我们花了 6 周时间做系统梳理:
- 整理功能清单:把所有功能模块列出来,按业务价值和使用频率排优先级
- 绘制数据流:找出核心数据的流转路径(比如一个采购单从创建到归档经过哪些模块)
- 识别高风险代码:找出"改了这里,不知道会影响哪里"的危险区域
这个过程里,我们有一个工具:在老系统里加一层日志,把所有的函数调用链都记录下来(AOP 实现)。运行一段时间后,通过日志分析哪些代码被调用了,哪些死代码从没被执行。
// AOP 日志追踪(只在分析阶段开启)
@Aspect
@Component
public class LegacyCodeTracingAspect {
@Around("execution(* com.example.legacy..*(..))")
public Object trace(ProceedingJoinPoint pjp) throws Throwable {
String className = pjp.getTarget().getClass().getName();
String methodName = pjp.getSignature().getName();
MDC.put("call_chain", className + "." + methodName);
legacyCallLogger.info("CALL: {}.{}()", className, methodName);
return pjp.proceed();
}
}分析结果出乎意料:150 万行代码里,实际被执行的不到 30 万行。大量的历史代码(按注释推测是 2015-2017 年的旧功能)从来没有被调用,只是占着空间。
第二步:建立测试网(防止回归)
改造的最大风险是:改了 A 功能,不知道影响了 B 功能。
老系统几乎没有自动化测试,这意味着每次改动都需要人工测试,而且人工测试永远不可能覆盖所有场景。
我们的解法:录制-回放测试(Record and Replay)
在老系统入口录制真实的请求和响应:
// 记录器(生产环境开启,只在改造阶段)
@Aspect
public class RequestRecorder {
@Around("@annotation(RecordForReplay)")
public Object record(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
Object result = pjp.proceed();
// 异步保存到测试数据库
replayStore.save(RecordedInteraction.of(
pjp.getSignature().toLongString(),
args,
result,
LocalDateTime.now()
));
return result;
}
}在新系统里回放这些请求,对比结果:
@Test
public void replayRecordedInteractions() {
List<RecordedInteraction> interactions = replayStore.findAll();
for (RecordedInteraction interaction : interactions) {
Object newResult = newSystem.invoke(interaction.getMethod(), interaction.getArgs());
Assertions.assertEquals(
interaction.getExpectedResult(),
newResult,
"新系统结果与老系统不符:" + interaction.getMethod()
);
}
}这个测试套件不需要我们手写测试数据,直接用真实的生产数据(脱敏后)驱动,覆盖率比任何手写测试都高。
第三步:选择第一个切入点
不能一上来就改最复杂的模块。第一个切入点要满足:
- 业务上相对独立(和其他模块耦合少)
- 有清晰的边界
- 改错了影响范围可控
我们选择了"报表模块"作为第一个切入点。
报表模块的特点:只读,不写数据,就算输出不对也不会改变任何状态,风险极低。而且这个模块原来的性能很差(大量存储过程,有的报表要跑 5 分钟),用新技术(ClickHouse)重做可以快速展示收益。
第四步:核心业务的替换策略
报表模块成功后,开始向核心业务模块推进。核心业务的替换策略更复杂:
双写验证阶段:
// 过渡期:同时写老系统和新系统,对比结果
public PurchaseOrder createPurchaseOrder(CreatePORequest request) {
// 1. 写老系统(主)
PurchaseOrder legacyResult = legacyPOService.create(request);
// 2. 异步写新系统(影子)
CompletableFuture.runAsync(() -> {
try {
PurchaseOrder newResult = newPOService.create(request);
// 对比结果是否一致
if (!legacyResult.equals(newResult)) {
shadowTestLogger.warn("双写结果不一致!老: {}, 新: {}", legacyResult, newResult);
}
} catch (Exception e) {
shadowTestLogger.error("新系统双写失败", e);
}
});
// 3. 返回老系统结果(新系统只是验证,不影响主流程)
return legacyResult;
}双写验证阶段可以跑 2-4 周,通过日志分析新老系统的结果差异,修复新系统的 Bug,直到差异率降到 0。
然后切流:先把 5% 的流量切到新系统,观察 1 周,没问题再切到 20%,再到 50%,最终 100%。
踩坑记录
踩坑一:数据库存储过程是"潘多拉魔盒"
我们最头疼的是 Oracle 存储过程。200 个存储过程,有些长达 2000 行,包含大量的业务逻辑。
最大的问题:存储过程之间有复杂的调用关系,改一个过程可能影响另外 10 个过程的行为,而这些依赖关系在任何文档里都没有记录。
我们有一次改了一个采购入库的存储过程,上线后发现影响了财务模块的成本核算,已经有两批订单的成本算错了。回滚花了 4 小时,加上修数据又花了 1 天。
教训: 存储过程的改造必须是最后一步,而且需要最全面的回归测试。我们最终的策略是把存储过程的逻辑"搬运"到新系统的 Java 代码里,不改原存储过程,等新系统全量切流后再一次性废弃。
踩坑二:数据库共用带来了数据模型兼容性问题
过渡期新老系统共用数据库。有次我们在新系统里加了一个新字段(nullable),老系统的 Hibernate 开始抛出警告(字段不在映射里),但一段时间后老系统开始报空指针异常——因为 Hibernate 3 有一个 Bug,某些情况下不能正确忽略未知字段。
教训: 过渡期数据库表结构变更要极其谨慎,新增字段必须用 NULL 或有默认值,且需要验证老系统能否正常处理。
踩坑三:一开始范围估计严重不足
我们估计整个改造要 12 个月,实际花了 26 个月。
低估的原因:
- 没有意识到理解老代码会花那么多时间(估计 2 个月,实际花了 6 个月)
- 存储过程比预想的复杂 3 倍
- 业务方在改造过程中提出了大量新需求,改造和新需求交织在一起
教训: 遗留系统改造估算时,在第一次估算结果上乘以 2.5,会更接近真实情况。
改造完成后的收益
两年多后,改造基本完成(存储过程还剩 20% 没有搬完)。收益:
- 平均功能交付时间:从 3 周缩短到 4 天
- 月末结账性能:从 45 分钟缩短到 8 分钟
- 招人:可以正常在市场上招 Spring Boot / Spring Cloud 工程师了
- Struts2 漏洞:彻底消除了安全隐患
- 系统稳定性:年可用率从 99.2% 提升到 99.85%
代价:2 年多的时间,约 6 名工程师的精力,大量的夜晚和周末。
值不值?对这个公司来说是值的,因为不改的话,再过 2 年系统可能真的撑不住了。
给正在做遗留系统改造的朋友
几点我认为最重要的经验:
- 先理解,再动手:花足够的时间理解老系统在做什么,是最贵的时间投入,也是最值得的
- 小步快跑,不要大爆炸:每次只改一个模块,上线,验证,再继续
- 测试网是命根子:没有充分的自动化测试,不要开始改造
- 保持老系统可回滚:任何时刻都要能把流量切回老系统
- 和业务方对齐预期:改造期间的新需求要严格管控,否则永远改不完
遗留系统改造是一场马拉松,不是百米冲刺。跑完需要的是韧性,不是速度。
