在某能源央企做了十年工控架构,我打交道最多的不是新技术,而是那些已经跑了五六年甚至更久的系统。风电场的监控系统就是典型——最初为了赶工期快速搭建,后来不断打补丁,三年后变成了一座代码迷宫。没有人敢动它,也没有人完全理解它。

每次新人入职,看到代码库里那些两千行的Controller、没有注释的SQL拼接、硬编码的IP地址列表,都会问我同一个问题:”为什么不推倒重来?”

我的回答总是:因为推倒重来的失败率比你想象的高得多。

遗留系统的三座大山

在和几个类似系统的改造打交道之后,我总结出遗留系统难以改造的三个核心问题,姑且叫它们”三座大山”。

技术债是最显性的。过时的框架版本、缺失的依赖管理、到处复制粘贴的工具类、没有统一规范的接口协议。我们有一个监控模块还在用Spring 4.x,升级意味着改一堆被废弃的API,而这些API散落在上百个文件里。

知识债是最隐性的,也是最危险的。写了那段代码的人早就离职了,没有人知道为什么某个接口的返回值要加一个看似多余的字段,直到你删掉它之后才发现——下游有个五年前写的报表服务在依赖这个字段做条件判断。这种隐含的依赖关系没有任何文档记录,只在运行时的报错里才会浮出水面。

测试债是最致命的。没有单元测试,没有集成测试,甚至连个像样的接口文档都没有。你改了一行代码,不知道会影响什么。我们曾经有个同事信心十足地说”这个改动很简单”,结果上线后三个厂区的数据采集全部中断了两个小时。

三座大山叠加在一起,就形成了一个可怕的现状:没人敢改,也没人知道怎么改是安全的

绞杀者模式:用新服务逐步替代

Martin Fowler提出的绞杀者模式(Strangler Fig Pattern)是处理遗留系统的经典方法。核心思路很简单:在老系统外面套一层新系统,逐步把流量从老系统迁移到新系统,最终”绞杀”掉老系统。

听起来很美好,实战中有很多细节需要处理。

我们的监控系统改造就是这样开始的。最初的做法是在Nginx层做流量分流:新功能走新服务,旧功能继续走老系统。Nginx配置大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
upstream legacy_backend {
server 10.0.1.100:8080;
}

upstream new_backend {
server 10.0.2.200:8080;
}

server {
listen 80;

# 新开发的告警模块走新服务
location /api/v2/alerts {
proxy_pass http://new_backend;
}

# 实时数据查询已迁移到新服务
location /api/v2/realtime {
proxy_pass http://new_backend;
}

# 其余请求走老系统
location / {
proxy_pass http://legacy_backend;
}
}

这个方案的好处是风险可控——每次只迁移一小块功能,出问题只需回滚Nginx配置,老系统完全不受影响。

但实际操作中我们踩了一个坑:老系统的接口缺乏统一的版本管理。有些接口URL是 /api/data,有些是 /data/query,还有些是 /query.do(很早期的风格)。这意味着你不能简单地按URL前缀分流,需要逐个接口梳理。

我们最终的解决方案是维护一个路由映射表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class RoutingConfig {

private final RouteRegistry routeRegistry;

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// 已迁移到新服务的接口
.route("new_alerts", r -> r
.path("/api/v2/alerts/**")
.uri("lb://new-monitor-service"))
.route("new_realtime", r -> r
.path("/api/v2/realtime/**")
.uri("lb://new-monitor-service"))
// 老系统兜底
.route("legacy", r -> r
.path("/**")
.uri("lb://legacy-monitor-service"))
.build();
}
}

用Spring Cloud Gateway替代Nginx做分流,好处是路由规则可以放在配置中心动态调整,不需要重启服务。

门脸模式:不改调用方,只换实现

绞杀者模式解决的是外部流量的迁移问题,但系统内部模块之间的改造怎么办?这就是门脸模式(Facade Pattern)发挥作用的地方。

我们的时序数据处理模块原本是直接操作IoTDB的原生API,代码散落在二十多个类里。要把它迁移到新的数据访问层,如果直接改这二十个类,工作量巨大且风险极高。

门脸模式的思路是:定义一个统一的接口层,让所有调用方都通过这个接口访问,然后在接口内部逐步替换实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 第一步:定义统一的门脸接口
public interface TimeSeriesFacade {
/**
* 写入时序数据点
*/
void insert(String deviceId, String measurement,
long timestamp, Object value);

/**
* 查询时间范围内的数据
*/
List<DataPoint> query(String deviceId, String measurement,
long startTime, long endTime);

/**
* 聚合查询(新增能力)
*/
AggregateResult aggregate(String deviceId, String measurement,
long startTime, long endTime,
AggregationType type);
}

// 第二步:门脸实现内部委托给老模块
@Service
public class TimeSeriesFacadeImpl implements TimeSeriesFacade {

// 先用老实现
private final LegacyIoTDBClient legacyClient;

@Override
public void insert(String deviceId, String measurement,
long timestamp, Object value) {
// 暂时委托给老实现
legacyClient.insertRecord(deviceId, measurement,
timestamp, value);
}

@Override
public List<DataPoint> query(String deviceId, String measurement,
long startTime, long endTime) {
// 暂时委托给老实现
return legacyClient.queryRange(deviceId, measurement,
startTime, endTime);
}

@Override
public AggregateResult aggregate(String deviceId, String measurement,
long startTime, long endTime,
AggregationType type) {
// 新功能,直接用新实现
return newAggregationEngine.aggregate(
deviceId, measurement, startTime, endTime, type);
}
}

然后逐个把二十多个调用类从直接调用IoTDB API改为调用 TimeSeriesFacade。这个过程不需要改任何业务逻辑,只是把 legacyClient.queryRange(...) 改成 timeSeriesFacade.query(...),功能完全一致。

等所有调用方都切换完毕后,就可以安心地修改 TimeSeriesFacadeImpl 的内部实现,把 legacyClient 替换成新的数据访问层,调用方完全无感知。

这个过程我们花了三个月,但如果直接改那二十多个类,估计六个月都不一定能改完,而且中间必然出故障。

踩坑:一次”小改”引发的连锁故障

说到故障,我必须分享一个真实的教训。

在改造的第三个月,我们准备把告警模块从老系统迁移到新服务。按照绞杀者模式,在网关层加了路由规则,把 /api/v2/alerts 指向新服务。测试环境验证通过,万事大吉。

上线后不到二十分钟,值班群里开始炸锅:三个风电场的实时数据面板全部空白,数据采集服务报连接超时。

排查过程是这样的:

  1. 新的告警服务确实正常工作,没有问题
  2. 但是老系统里有个定时任务,每5分钟调用 /api/v2/alerts/statistics 接口来统计告警数量,用于仪表盘展示
  3. 这个接口没有在新服务里实现(因为文档里没记录,也没人知道它的存在)
  4. 老系统的定时任务收到404后,触发了异常处理逻辑,往数据库里写了一条错误日志
  5. 错误日志的写入触发了一个数据库触发器(是的,数据库里居然有触发器),这个触发器会锁住一张被实时数据采集服务频繁写入的表
  6. 表被锁住 → 采集服务写入超时 → 面板无数据

从”一个接口没实现”到”三个风电场数据中断”,中间经过了四层我们完全不知道的依赖关系。

这次事故教会我一件事:在遗留系统改造中,监控比重构更紧急。

我们后来在改造前加了一道流程:先在网关层对所有老系统的接口做流量录制,跑一周,看看有哪些”隐藏接口”被调用。这个简单的动作后来帮我们发现了至少十几个不在文档里的接口调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 流量录制过滤器
@Component
public class TrafficRecordingFilter implements Filter {

private final TrafficLogService trafficLogService;

@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;

// 记录所有请求路径和方法
trafficLogService.record(
httpRequest.getRequestURI(),
httpRequest.getMethod(),
System.currentTimeMillis()
);

chain.doFilter(request, response);
}
}

ROI思维:学会接受”够用就好”

改造进行到第五个月的时候,领导问我一个很现实的问题:那些完全没人维护的报表模块怎么办?

报表模块用的是十年前的JasperReports,生成的报表格式老旧,导出Excel还有乱码问题。但要重写它,估算是三个月工作量。

我问了三个问题:

  1. 这个报表模块有多少人在用?——大约5个人,每月用一次。
  2. 用的人有提过改进需求吗?——没有,他们已经习惯了导出后自己调整格式。
  3. 如果不改,会影响其他模块的改造吗?——不会,它是完全独立的。

三个问题答完,结论就很清楚了:不值得改

不是所有旧代码都值得动。遗留系统改造是一个资源分配问题,不是技术洁癖问题。我后来养成了一个习惯,在决定是否改造某个模块之前,先做一个简单的ROI评估:

  • 高ROI改造:被频繁调用的核心接口、性能瓶颈模块、安全漏洞
  • 中ROI改造:需要频繁维护的模块、新人入职后要花大量时间理解的模块
  • 低ROI改造:使用频率低、运行稳定、不阻碍其他改造的模块

低ROI的模块就让它跑着。给它们加好监控和日志,确保出问题能快速定位,这就够了。你的时间和精力应该花在高ROI的改造上。

我们最终用了八个月完成了监控系统的核心模块改造,而不是原计划推倒重来的”六个月”。看起来慢了,但过程中没有出现过一次P1级别的事故(那次连锁故障算P2),系统一直在正常运行。

推倒重来的方案看起来快,但它的高风险往往会导致项目在中途因为事故而被叫停,最终可能两年都没完成。渐进式改造慢而稳,但每一步都是安全的、可回滚的、可验证的。


最后说一个技术选择之外的事。在改造过程中,我发现最大的阻力其实不是技术问题,而是人的问题。老系统的维护者会觉得你在否定他的工作,新来的人会觉得改别人的旧代码没有成就感。

花时间和团队里每一个人聊清楚”为什么要改”,比写一百行代码都重要。遗留系统的改造,技术方案占三成,团队沟通占七成。

改造完成后,我在团队复盘会上说了一句大实话:我们不是在消灭技术债,我们是在管理技术债。 每个系统都会有技术债,区别在于你是被它拖着走,还是有计划地一点点还。

本文由AI辅助生成框架,技术细节来自真实项目经验。