代码重构:如何与原有代码兼容(企业落地版)
目标:在不影响线上稳定性的前提下,让新旧实现可共存、可灰度、可回滚,并逐步把流量/调用迁移到新代码上。
1. 先把“兼容”说清楚:你要兼容什么?
重构时的“兼容”通常包含 5 类:
- API 兼容:请求/响应字段、语义、错误码、幂等等不变
- 行为兼容:边界条件、默认值、排序规则、精度/舍入、超时重试等不变
- 数据兼容:数据库结构、历史数据、索引、唯一约束、数据校验规则不破坏
- 依赖兼容:对外部系统(MQ/缓存/第三方)的交互协议、topic/key、数据格式不变
- 运维兼容:监控指标、日志结构、告警规则、SLA 口径尽量不变(否则排障会很痛)
一句话:别人怎么调用你、怎么查你、怎么排障你,都不要被重构吓到。
2. 最核心的落地策略:让“新旧实现共存”
2.1 外观层/门面(Facade)——保持入口不变
保留原有的 Controller / Service 接口,把内部实现替换为可切换的“门面”。
- 优点:调用方 0 改动
- 缺点:门面层要做好路由、监控、兜底
示例(Spring):
publicinterfaceOrderQueryService{OrderDTOquery(LongorderId);}@ServicepublicclassOrderQueryFacadeimplementsOrderQueryService{privatefinalOldOrderQueryServiceoldImpl;privatefinalNewOrderQueryServicenewImpl;privatefinalFeatureSwitchfeatureSwitch;@OverridepublicOrderDTOquery(LongorderId){if(featureSwitch.isNewQueryEnabled(orderId)){returnnewImpl.query(orderId);}returnoldImpl.query(orderId);}}关键点:
- 入口不动,只换内部实现
- 切换策略建议“按用户/店铺/订单号取模”实现稳定灰度
2.2 适配器(Adapter)——新实现适配旧接口/旧数据
当新代码模型更合理,但旧接口/旧库结构短期改不了,用适配器做“翻译”。
publicclassNewToOldOrderAdapter{publicOldOrderDTOadapt(NewOrdernewOrder){OldOrderDTOdto=newOldOrderDTO();dto.setId(newOrder.getOrderId());dto.setAmount(newOrder.getPayAmount().toPlainString());// ...returndto;}}适配器的价值:把“脏兼容逻辑”集中隔离,避免污染核心域模型。
2.3 绞杀者模式(Strangler Fig)——按能力/场景逐步替换
不要“一次性重写”。按“最容易切、收益最大、风险最小”的路径拆:
- 先把纯查询迁到新实现(无副作用,最安全)
- 再迁弱一致写(可补偿)
- 最后迁强一致核心写路径(下单、扣减、支付)
迁移顺序常用优先级:
- 只读接口
- 异步链路
- 低频写接口
- 核心写接口(最后)
3. API 兼容:接口别随便动(真要动也要“可并存”)
3.1 兼容性改动 vs 破坏性改动
- 兼容性改动:
- 新增字段(响应新增字段一般 OK)
- 新增枚举值(注意客户端是否做了严格校验)
- 扩展错误码(不改变老错误码语义)
- 破坏性改动:
- 删除/改名字段
- 改变字段类型(string→number)
- 改变默认值/排序/分页语义
- 改动幂等语义
3.2 版本化(推荐对外 API 必做)
- URL 版本:
/api/v1/orders、/api/v2/orders - Header 版本:
Accept: application/vnd.xxx.v2+json
落地建议:
- 新旧版本并存一段时间
- 明确下线窗口,配合监控“谁还在用 v1”
4. 数据兼容:数据库怎么改才不会炸?
4.1 最稳的三步:先加、再写、最后删
绝对不要先删字段/改类型。
Step 1:扩展(不影响老代码)
- 新增列(允许 NULL / 给默认值)
- 新增索引(注意加索引锁表风险:线上要用在线 DDL)
- 新增表(旁路表)
Step 2:双写/回填
- 写入时新旧字段都写(dual write)
- 异步回填历史数据(job + 校验)
Step 3:切读 + 清理
- 流量切到新字段/新表读
- 观察稳定后,再删旧字段/旧表(一定要留足回滚窗口)
4.2 双写的坑(以及怎么补)
双写最常见的问题:
- 新旧写成功/失败不一致(部分失败)
- 时序问题导致读到“半迁移态”
- 回滚时数据不完整
实战建议:
- 双写时以旧为主,新为影子(先保证旧链路稳定)
- 新写失败:打点+告警+补偿队列(别在主链路硬失败)
- 用 MQ/CDC 做异步同步会更稳(尤其跨服务)
5. 行为兼容:最容易被忽略,但最容易出事故
5.1 建“行为合同”(Contract)
写清楚旧实现的行为:
- 空值/缺失字段怎么处理
- 金额精度/舍入方式
- 排序规则
- 分页:page 从 0 还是 1?size 最大多少?
- 错误码:哪些场景返回哪个 code
- 幂等:重复请求返回什么
然后对新实现做契约测试(Contract Test):
- 用同一套输入跑新旧实现
- 对比输出差异(允许白名单差异)
- 把对比报告做成 CI gate(不通过不准合并)
5.2 “影子流量 / 回放”是神器
- 线上主请求走旧实现
- 同步/异步把同请求喂给新实现(不影响用户)
- 对比响应差异,积累差异样本
注意:
- 脱敏/合规(别把用户隐私乱写日志)
- 新实现要做限流,避免被影子流量拖死
6. 发布与切流:让风险变成“可控的小步”
6.1 开关(Feature Flag)是重构的安全带
常用开关粒度:
- 全局开关:一键全开/全关
- 按用户/店铺/订单取模灰度
- 按接口/能力点开关(最推荐)
开关必须满足:
- 实时可切(配置中心)
- 可回滚(秒级切回旧实现)
- 有审计(谁什么时候切的)
6.2 灰度策略建议(从保守到激进)
- 白名单(内部账号)
- 1% / 5% / 10% 按哈希灰度
- 分城市/分机房
- 全量
每一步至少观察:
- P99 延迟
- 错误率(5xx/业务失败)
- 核心业务指标(下单成功率、支付成功率)
7. 回滚策略:别把自己逼进死角
7.1 回滚必须“预设计”
能回滚的前提是:
- 旧实现没删
- 数据仍能被旧实现读取/解释
- 新字段写失败不会让旧实现崩
7.2 常见回滚套路
- 开关回滚:最优(秒级)
- 版本回滚:次优(分钟级,依赖发布系统)
- 数据回滚:最差(成本高、风险大)
所以:别让“数据结构大改”成为唯一方案。
8. 常见场景的“兼容打法”
8.1 重构 Service 层但 Controller 不动
- Controller 调用 Facade
- Facade 内路由新旧 Service
- 输出 DTO 保持旧结构,内部用适配器
8.2 拆微服务(单体 → 服务化)
- 先抽出“查询”服务(最简单)
- 对外仍由原服务提供 API,内部转 RPC(BFF/Facade)
- 逐步把写链路迁走
- 期间用 MQ 保持数据同步
8.3 引入新缓存/新索引
- 先“写入新缓存但不读”(shadow write)
- 后“读新缓存失败再回源旧逻辑”(read fallback)
- 命中率稳定后再切主读
9. 最小可行的兼容落地清单(拿去当检查表)
9.1 设计阶段
- 兼容范围定义清楚(API/行为/数据/依赖/运维)
- 新旧共存方案(Facade/Adapter/Strangler)
- 灰度策略 & 开关设计
- 回滚路径明确(至少开关回滚)
9.2 开发阶段
- 旧行为合同文档(默认值、错误码、排序、幂等)
- 契约测试(新旧对比)
- 影子流量/回放对比(如适用)
- 日志/指标埋点一致(或做映射)
9.3 上线阶段
- 白名单灰度 → 小流量 → 全量
- 观测看板:P99/错误率/核心业务指标
- 告警联动:新实现失败率阈值触发自动回滚(可选)
- 回滚演练(至少预发演练一次)
10. 一个“很现实”的经验:别追求 100% 行为一致
有些差异是合理的,比如:
- 更严格的参数校验
- 更标准的错误码
- 性能优化导致的超时口径变化
做法:
- 对差异建立白名单,并在契约测试里显式声明
- 让差异“可见、可解释、可追踪”,不要偷偷改
11. 结尾:你真正要追求的是“可控变化”
重构不是把代码写得更优雅,而是把系统从:
- “改一点就炸”
变成 - “小步迭代、随时回滚、指标可见”。
只要你做到新旧共存 + 可灰度 + 可回滚 + 有契约测试,大部分重构都能稳着落地。