重构策略选择
你确认了优先级,现在要开始还债了。但怎么还和什么时候还,和「还多少」同样重要。
很多团队在债务治理的第一步就犯了错:看到一段烂代码,直接上手重写。结果写到一半发现逻辑太复杂、项目进度被拖、项目延期、新功能被锁死。一年后回头看,系统比原来更乱——因为新旧代码混在一起,没人分得清哪些是新写的、哪些是遗留的。
这一篇讲的重构策略选择,就是帮你回答:什么时候该重构、用什么方式重构、如何控制重构的风险。
重构的时机:债务利息超过承受阈值
重构不是想重构就重构的。重构有成本:时间、风险、机会成本。在债务利息没有超过承受阈值之前,不值得重构。
债务利息的承受阈值因团队而异。一个 startup 可能可以承受 30% 的开发效率损失,一个金融系统可能 5% 就受不了。
判断是否值得重构的简单公式:
重构价值 = 重构减少的年利息成本 - 重构投入成本 - 重构风险成本
如果重构价值 > 0,重构是值得的。如果重构价值 < 0,重构是亏本的。
flowchart TD
A["开始"] --> B{"债务利息 > 承受阈值?"}
B -->|否| C["暂不重构\n继续观察"]
B -->|是| D{"重构价值 > 0?"}
D -->|否| E["不重构\n寻找替代方案"]
D -->|是| F["开始重构"]
C --> G["监控债务变化"]
G --> B
替代重构的方案
不是所有债务都需要通过重构来还。有些债务可以通过其他方式「止损」:加缓存绕过性能问题、加熔断器降低故障影响、加监控提前发现问题、增加人力投入来对抗效率损失。选择哪种方式,取决于成本对比。
重构策略对比:大爆炸 vs 渐进式
重构策略分为两大类:大爆炸重构(Big Bang Refactoring)和渐进式重构(Incremental Refactoring)。
大爆炸重构
大爆炸重构指的是在短时间内一次性替换整个系统或整个模块。这种方式的特点是:
- 优点:速度快、可以彻底解决债务、新旧代码不共存
- 缺点:风险极高、需要长时间 lock codebase、团队压力大、难以回退
大爆炸重构的本质是「破旧立新」——先摧毁旧系统,再建立新系统。这听起来很痛快,但实践中问题重重。
大爆炸重构失败的根本原因:新系统在设计时无法完全预见旧系统的所有边界条件和隐藏逻辑。旧系统之所以能运行这么多年,是因为它已经「长」出了大量针对特定场景的处理逻辑——这些逻辑没有文档,可能连写代码的人都不记得了。重写时,这些「隐性知识」往往会丢失。
真实案例:某社交平台的重写悲剧
2010 年左右,某知名社交平台决定用新架构重写整个平台。他们花了 18 个月,投入 30 人的团队,终于完成了「更现代化」的版本。上线第一周,服务器崩溃了。上线第一个月,陆续发现了 47 个「旧版本有但新版本没有」的功能。半年后,这个重写项目被放弃,团队回归旧版本,又花了 6 个月修复重写期间引入的问题。
这个案例后来成为「不要大爆炸重写」的标准反面教材。
渐进式重构
渐进式重构指的是将重构工作分散到多个阶段,每次只改一小部分。这种方式的特点是:
- 优点:风险可控、可以持续验证、随时可以回退、不影响业务连续性
- 缺点:速度较慢、新旧代码需要共存较长时间、可能有技术债叠加
渐进式重构的本质是「边还债边做生意」——不追求一步到位,而是将大目标拆解成小步骤,逐步推进。
两种策略的权衡矩阵:
大爆炸重构的适用条件
大爆炸重构只在极少数情况下才合理:系统是全新项目(没有旧系统需要兼容)、债务极轻(不需要继承旧逻辑)、团队有完整的系统知识(知道所有边界条件)、有充足的时间和资源、愿意接受失败风险。如果有任何一条不满足,不要选择大爆炸。
绞杀者模式:渐进式重构的核心武器
绞杀者模式(Strangler Pattern)是渐进式重构的核心策略。其核心理念是:不要直接改旧系统,而是在旧系统外面包裹新系统,逐步将流量从旧系统迁移到新系统,直到旧系统被「绞杀」取代。
flowchart LR
subgraph 阶段1["阶段 1:新旧并行"]
A["用户请求"] --> B["API 网关"]
B --> C["旧系统"]
B --> D["新系统"]
C --> F["旧数据"]
D --> G["新数据"]
end
subgraph 阶段2["阶段 2:流量迁移"]
A2["用户请求"] --> B2["API 网关"]
B2 --> C2["旧系统\n(部分流量)"]
B2 --> D2["新系统\n(部分流量)"]
end
subgraph 阶段3["阶段 3:旧系统退出"]
A3["用户请求"] --> B3["API 网关"]
B3 --> D3["新系统"]
D3 --> G3["新数据"]
C3["旧系统\n(已废弃)"]
end
绞杀者模式的三种实现方式
方式一:代理模式
通过 API 网关或反向代理,将请求路由到新系统或旧系统。适合 API 层面的重构。
Nginx
location /api/v2/ {
# 新系统
proxy_pass http://new-service:8080;
}
location /api/v1/ {
# 旧系统,逐步迁移
# 新增功能走新系统,旧功能暂时走旧系统
proxy_pass http://legacy-service:8080;
}
方式二:功能开关模式
通过配置开关,决定每个请求走新代码还是旧代码。适合代码层面的重构。
功能开关控制重构切换
@Service
public class OrderService {
private final OrderServiceLegacy legacyService;
private final OrderServiceNew newService;
private final FeatureToggleService featureToggle;
public Order createOrder(OrderRequest request) {
// 通过功能开关决定走哪套实现
if (featureToggle.isEnabled("order-service-v2")) {
return newService.createOrder(request);
} else {
return legacyService.createOrder(request);
}
}
}
方式三:数据库切分模式
通过数据库层面的切分(新旧系统使用不同的数据库表),逐步将数据迁移到新数据库。适合数据层面的重构。
sequenceDiagram
participant Client as 客户端
participant Gateway as API 网关
participant Legacy as 旧系统
participant New as 新系统
participant DB_L as 旧数据库
participant DB_N as 新数据库
Note over Legacy,DB_L: 阶段1:数据写入旧库
Client->>Gateway: 创建订单
Gateway->>Legacy: 转发请求
Legacy->>DB_L: 写入旧库
DB_L-->>Legacy: 确认
Legacy-->>Client: 响应
Note over New,DB_N: 阶段2:双写阶段
Client->>Gateway: 创建订单
Gateway->>Legacy: 转发请求
Legacy->>DB_L: 写入旧库
Legacy->>New: 同步写入
New->>DB_N: 写入新库
DB_L-->>Legacy: 确认
Legacy-->>Client: 响应
Note over Legacy,DB_N: 阶段3:流量逐步迁移
Client->>Gateway: 创建订单
Gateway->>New: 新流量走新系统
New->>DB_N: 写入新库
DB_N-->>New: 确认
New-->>Client: 响应
测试驱动重构(TDD)
重构最大的风险是「改坏了不知道」。测试驱动重构是控制这个风险的核心方法。
测试驱动重构的核心流程:
- 写测试:在改动之前,为要重构的代码编写测试用例。这些测试用例是「保护网」——确保重构后的行为与重构前一致。
- 运行测试:确保测试用例通过,证明你理解了代码的行为。
- 重构代码:在测试通过的前提下,重构代码。
- 再次运行测试:确保重构后的代码仍然通过所有测试。
测试驱动重构示例
// 第一步:识别要重构的方法
public class OrderService {
public Order processOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
if (order == null) {
throw new OrderNotFoundException();
}
if (order.getStatus() != OrderStatus.PENDING) {
throw new OrderStatusException("Order is not pending");
}
if (order.getAmount() <= 0) {
throw new IllegalArgumentException("Invalid amount");
}
// 业务逻辑...
return order;
}
}
// 第二步:编写测试用例
@Test
public void testProcessOrder_success() {
Order order = new Order();
order.setId(1L);
order.setStatus(OrderStatus.PENDING);
order.setAmount(new BigDecimal("100.00"));
when(orderRepository.findById(1L)).thenReturn(order);
Order result = orderService.processOrder(1L);
assertNotNull(result);
assertEquals(OrderStatus.PROCESSING, result.getStatus());
}
@Test(expected = OrderNotFoundException.class)
public void testProcessOrder_notFound() {
when(orderRepository.findById(1L)).thenReturn(null);
orderService.processOrder(1L);
}
@Test(expected = OrderStatusException.class)
public void testProcessOrder_invalidStatus() {
Order order = new Order();
order.setId(1L);
order.setStatus(OrderStatus.COMPLETED);
when(orderRepository.findById(1L)).thenReturn(order);
orderService.processOrder(1L);
}
测试覆盖率的要求
如果你准备重构一个方法,这个方法的测试覆盖率至少要达到 80%,最好能覆盖所有边界条件。如果一个方法连测试都没有,那在重构之前,应该先补充测试。补充测试的过程本身就是理解代码的过程。
功能开关:小范围验证的艺术
功能开关(Feature Toggle)是在重构过程中控制风险的核心工具。它的核心理念是:任何改动都可以随时回退。
功能开关的使用场景:
场景一:控制新代码是否生效
功能开关控制逻辑
@Service
public class ProductService {
private final FeatureToggle featureToggle;
public List<Product> searchProducts(String keyword) {
if (featureToggle.isEnabled("product-search-v2")) {
return searchProductsV2(keyword);
} else {
return searchProductsV1(keyword);
}
}
private List<Product> searchProductsV2(String keyword) {
// 新实现:使用 Elasticsearch
return elasticsearchService.search(keyword);
}
private List<Product> searchProductsV1(String keyword) {
// 旧实现:使用 SQL LIKE
return jdbcTemplate.query(
"SELECT * FROM products WHERE name LIKE ?",
keyword
);
}
}
场景二:A/B 测试验证
通过功能开关,可以将用户分成两组,一组走新代码,一组走旧代码,从而对比效果。
灰度流量控制
@Service
public class UserService {
private final FeatureToggle featureToggle;
public UserVO getUserInfo(Long userId) {
// 灰度 10% 的用户
if (featureToggle.isEnabled("user-service-v2", userId, 0.1)) {
return getUserInfoV2(userId);
} else {
return getUserInfoV1(userId);
}
}
}
场景三:紧急回退
当新代码出现问题时,不需要回滚代码,只需要关闭开关,将流量切回旧代码。
紧急回退操作示例
# 一行命令关闭功能开关,流量立即切回旧代码
$ feature-toggle disable product-search-v2
# 观察监控,确认无异常
# 然后再安排时间修复问题
功能开关的技术债务
功能开关本身会产生技术债务——如果你在每个服务里都塞满 if (toggle.isEnabled(...)) 的逻辑,代码会变得难以维护。建议在功能稳定后,尽快「删除开关,保留新代码」,不要让开关成为永久的架构负担。一个功能开关的理想生命周期是:开启 → 稳定 → 移除(删除旧代码分支)。
风险控制:重构的边界条件与回退方案
重构是高风险活动,必须有充分的风险控制措施。
重构前必须明确的边界条件
在开始重构之前,必须明确回答以下问题:
- 这个模块的所有输入是什么? 包括正常输入、异常输入、边界值
- 这个模块的所有输出是什么? 包括返回值、副作用、对外部系统的调用
- 这个模块与其他模块的交互是什么? 哪些模块依赖它?它依赖哪些模块?
- 这个模块有哪些已知的 bug 或 workaround? 这些 bug 在重构后是否应该被修复?
- 这个模块的性能要求是什么? 重构后是否需要保持性能不变?
回退方案
每次重构都必须有回退方案。回退方案应该包括:
回退触发条件:什么情况下应该回退?
- 新代码故障率高于旧代码
- 性能指标下降超过阈值(如 P99 延迟上升 20%)
- 用户投诉数量上升
回退执行步骤:如何快速切回旧代码?
回退操作清单
1. 关闭功能开关 → 流量切回旧代码(约 5 分钟)
2. 如果是代码级别的改动 → 回滚代码版本(约 15 分钟)
3. 如果是数据库改动 → 执行回滚 SQL(约 10 分钟)
4. 确认监控指标恢复正常(约 10 分钟)
5. 发出故障通告,通知相关方(约 5 分钟)
总计理论回退时间:约 45 分钟
回退后的处理:回退不是终点,是���续改进的起点。
反模式:为什么不应该「边开发边重构」
最后一个话题,聊聊最常见的重构反模式:「边开发边重构」。
「边开发边重构」的典型场景是:团队在开发新功能的过程中,看到烂代码就顺手重构一下,结果导致项目延期。
这个反模式的本质是混淆了两件本质不同的事情:
- 重构:保持外部行为不变,改变内部实现
- 开发:增加新功能,改变外部行为
当你同时做这两件事时,你既无法专注于重构(因为要理解新功能需求),也无法专注于开发(因为要处理重构的风险)。结果是:重构没做好(因为被新功能打断),新功能也没做好(因为被重构拖累)。
正确做法:将重构和新功能开发严格分开。
flowchart TD
subgraph 错误做法["错误做法"]
A1["边开发边重构"] --> B1["重构风险 + 新功能需求叠加"]
B1 --> C1["项目延期,质量下降"]
end
subgraph 正确做法["正确做法"]
A2["重构阶段"] --> B2["补充测试,改好代码"]
B2 --> C2["验证通过"]
C2 --> D2["开发阶段"]
D2 --> E2["基于干净的代���开发新功能"]
end
具体来说:
- 如果重构是开发新功能的前提,那就先重构,再开发
- 如果新功能需要改到某个烂模块,那就先把那个模块重构干净,再开发新功能
- 如果重构和新功能是独立的,那就放在不同的 Sprint 中处理
「顺手重构」的陷阱
「顺手重构」听起来很美好——既完成了工作,又改善了代码。但实践中,「顺手重构」往往变成「顺手改坏」——因为重构是在时间压力下进行的,风险控制往往被省略(反正已经 deadline 了,哪有时间写测试)。建议将「顺手重构」改为「记录债务,计划重构」。
思考题
问题1:一个遗留模块债务很重,但团队对它的了解几乎为零——没有文档、没有测试、没人敢动。这种情况下,应该选择大爆炸重构还是渐进式重构?为什么?
参考答案
应该选择渐进式重构,甚至在开始重构之前需要先做「考古工作」。大爆炸重构的前提是团队对系统有完整的理解(包括所有边界条件和隐藏逻辑),而「没有文档、没有测试、没人敢动」说明这个前提不成立。正确的做法是:1)先花时间理解代码(通过代码考古、运行调试、查阅 git 历史);2)补充测试用例,建立保护网;3)从小范围开始,逐步重构。如果连理解都不理解就直接重写,很可能会丢失重要的业务逻辑。
问题2:在绞杀者模式中,新旧系统需要「双写」数据。这种设计会带来什么问题?如何解决?
参考答案
双写的问题有两个:1)数据一致性问题——新旧系统的数据写入可能因为性能差异、事务边界不同等原因导致不一致;2)性能开销——双写意味着每个操作都要执行两次。解决方案:1)异步双写而非同步双写——新系统写入后,通过消息队列异步同步到旧系统;2)数据校验——定期比对新旧系统的数据,发现不一致时告警;3)最终逐步切流——先让小部分流量走新系统,确认稳定后再逐步增加比例,直到旧系统可以下线。
问题3:功能开关在重构完成后应该如何处理?
参考答案
功能开关在重构稳定后必须「关闭」——即删除旧代码分支,只保留新代码。处理步骤:1)确认新代码稳定运行至少 1-2 个 Sprint;2)通过灰度逐步将流量 100% 切换到新代码;3)监控��键指标(错误率、延迟、错误日志),确认一切正常;4)「删除开关」——在代码中移除 if (toggle.isEnabled(...)) 的逻辑,只保留新代码分支;5)删除开关配置和相关的监控告警。功能开关应该被视为临时工具,而不是永久架构。一个系统里如果积累了太多「已经稳定但还开着开关」的功能,说明代码质量在持续下降。