一个跑了七八年的核心系统,代码里并排躺着三套订单状态机,注释写着"临时方案,下个版本删"。你接手后想加一个退款原因字段,打开代码看了半小时才发现:这个字段要穿过四个服务、两张宽表和一个定时任务,中间还夹着一句if (status == 3 && userType != 2),没人说得清userType为什么不能等于 2。这就是典型的该重构信号——不是代码丑,而是改不动、不敢改。而这两件事一旦出现在高可用系统里,迟早会以故障的形式还回来。
我参与过几次大型系统的代码重构,踩过的坑比写过的设计文档多。回头看,真正决定成败的不是用了什么设计模式,而是几条很朴素的核心策略:怎么切流、怎么造接缝、怎么验证、怎么回滚。下面把这几条拆开讲,包括每一步背后的判断依据、具体参数怎么定、以及那些文档里不会写的坑,适合正在维护老系统、准备动刀又怕动刀的人参考。
1. 重构前的判断:这套代码该不该动,动到什么程度
1.1 三个前置信号:从"改不动"到"不敢改"
判断要不要重构,我一般看三个信号,比看代码质量报告靠谱得多。
第一个信号是需求交付周期在变长。同一个团队,去年做一个类似需求要三天,今年要两周,而且延期原因集中在"改这里会影响那里"。这说明模块之间的耦合已经高到让变更成本指数上升了。
第二个信号是故障定位时间在变长。线上出问题,从告警到定位根因超过半小时,日志里全是互相嵌套的调用,链路追踪断在某个老服务里。高可用系统讲究的是快速止血,定位慢就意味着 MTTR 下不来。
第三个信号是没人敢删代码。你 grep 一个方法,发现只有一处调用,但那处调用包在try-catch里,catch 块是空的,注释写着"历史原因"。删掉它怕出事,不删它又挡着新逻辑。这种"僵尸代码"积累到一定密度,整个模块就没法动了。
这三个信号有一个共同点:它们都是可观测的,不依赖个人审美。代码风格难看不是重构的理由,改不动才是。
1.2 用可观测数据给老系统做一次体检
决定动刀之前,我会先拉一份数据出来,把"感觉"变成"证据"。常看的几类指标:
| 观测维度 | 具体指标 | 为什么看它 |
|---|---|---|
| 变更频率 | 单文件近半年提交次数 | 高频改动区通常耦合最重,重构收益最大 |
| 缺陷密度 | 单模块线上缺陷数 / 代码行数 | 找出真正拖后腿的模块,而不是均匀用力 |
| 调用深度 | 核心接口的平均跨服务调用数 | 超过 5 跳的链路,任何重构都要格外小心 |
| 依赖广度 | 单模块被多少个其他模块引用 | 被引用越多,改接口的代价越大,越要先造接缝 |
| 测试现状 | 核心路径的自动化用例数 | 决定你能改多快,没测试就只能小步慢走 |
我见过一个团队,凭直觉认为"支付模块最烂",投入两个月重构,结果线上故障率没降。后来拉数据才发现,真正的故障源是订单查询和库存对账这两个模块,支付模块只是代码写得难看但很稳定。重构要跟着数据走,不跟着情绪走。
还有一点,体检数据要留档。重构前后各跑一次,用同样的口径对比,这是你后面向团队和业务方证明"这三个月没白干"的唯一凭据。
1.3 重构和重写的分界线:先看状态在哪
这两件事经常被混为一谈,但风险差了一个数量级。
重构是在不改变外部行为的前提下调整内部结构,理论上每一步都可以回滚,用户无感。重写是用新实现替换旧实现,行为可能有细微差异,切换那一刻风险最高。
我的判断标准是:数据状态有多复杂。如果这套系统的核心资产是那几张表里的历史数据,而且数据模型里塞满了业务妥协(比如用status字段的某几个值表示不同的业务分支),那重写的成本会远超预期,因为你很难保证新系统对历史数据的解释和旧系统完全一致。
反过来,如果系统本身是无状态的,或者状态可以完整导出重建,重写的可行性就高很多。
大多数情况下我的选择是混合:对状态简单的边缘模块直接重写,对状态复杂的核心链路走渐进式重构。这个判断要在动手前做完,中途改主意是最伤的。
2. 策略一:绞杀者切流,把大爆炸式替换挡在门外
2.1 一次性替换为什么在高可用系统里基本等于事故
大爆炸式替换的画面是这样的:新版本开发三个月,测试环境跑通,选个低峰期上线,凌晨两点切流量,然后所有人盯着监控。这种做法的失败率高得惊人,原因不在技术,在于三个无法消除的变量。
一是生产数据分布无法模拟。测试库里的数据是造的,字段长度、空值比例、异常组合都和生产不一样。新代码在测试环境跑了十万条没问题,生产上第一个小时就撞上一条三年前的脏数据。
二是并发行为无法模拟。老代码里那些没写注释的锁、缓存、重试,实际承担着削峰的作用。新实现逻辑更"干净",反而可能把压力原样传导到下游。
三是回滚窗口太长。切换是全量的,一旦出问题,回滚意味着把流量整体切回去,中间这几分钟产生的数据还得处理。凌晨两点做这种决策,压力极大。
2.2 从边缘切第一刀:入口层是最安全的起手
绞杀者模式的核心思路很朴素:新旧两套实现在线上并存,用一个路由层决定每个请求走哪边,逐步把流量从旧的一侧搬到新的一侧,直到旧的一侧流量归零再下线。
第一刀切在哪里很关键。我的经验是优先选这三类:
- 只读接口,尤其是报表、查询、详情类。读写分离的读侧切换,出问题最多是数据展示错误,不会污染数据。
- 旁路功能,比如消息通知、埋点上报、导出任务。这些失败了不影响主流程。
- 低频入口,比如某个后台管理页面。用户少,出问题影响面小,正好用来验证整套切流机制。
绝对不要第一刀切核心写入链路。写入一旦出错,数据修复的成本是把重构省下来的时间全赔进去还不够。
路由层的实现可以很轻,不一定非要上服务网格。一个网关规则、一个功能开关,甚至应用内的一个路由器就够了:
// 按用户维度稳定分流,同一用户始终命中同一版本 public class GrayRouter { private final Set<String> whiteList; // 白名单用户,强制走新版 private final int percentage; // 灰度比例,0-100 public boolean routeToNew(String userId) { if (whiteList.contains(userId)) { return true; } // 用哈希而不是随机数,保证同一用户多次请求结果一致 int bucket = Math.floorMod(userId.hashCode(), 100); return bucket < percentage; } }这里有个细节值得多说一句:必须用哈希分桶,不能用Math.random()。原因是一旦同一个用户的请求被随机分到两个版本,而两个版本共享同一份会话状态或写入同一张表,就会出现"刚在新版下了单,刷新跑到旧版查不到"的情况。这类问题在灰度期间极难定位,因为它是概率性的。
2.3 双跑、影子流量与灰度开关的实际操作
切流之前,我一般会先做一轮影子流量:把生产请求复制一份发给新实现,新实现的响应直接丢弃,只记录日志和耗时,不产生任何副作用。这一步的价值在于用真实的请求分布验证新代码,同时又完全不承担风险。
影子流量的实现有几个必须处理的问题:
- 下游副作用要屏蔽。新实现在处理影子请求时,所有写库、发消息、调外部接口的操作都要 mock 掉或者打到影子库。否则你等于把生产数据写了两遍。
- 时间戳和随机数要固定。新老实现的输出对比时,这些字段天然不一致,必须在 diff 时忽略。
- 采样率控制。一开始 1% 就够,观察日志里有没有异常堆栈,稳定了再提到 10%。
影子流量跑通之后,才开始真正的灰度切流。灰度比例我一般按这个节奏走:
| 阶段 | 流量比例 | 观察窗口 | 关注点 |
|---|---|---|---|
| 内部验证 | 白名单用户 | 1-2 天 | 功能正确性,异常日志 |
| 小流量 | 1% | 30 分钟 | 错误率、P99 延迟是否劣化 |
| 扩量 | 5% → 20% | 每档 1 小时 | 下游压力、数据库连接池 |
| 过半 | 50% | 半天 | 长尾请求、定时任务兼容性 |
| 全量 | 100% | 观察 3 天 | 旧代码保留,随时可切回 |
每一档的观察窗口不建议压缩。有些问题只在特定时间点触发,比如每小时的第 5 分钟有定时任务跑,你 30 分钟的观察窗口可能正好错过。我吃过这个亏:灰度到 20% 时一切正常,全量之后第二天凌晨对账任务跑起来才发现新旧表的字段语义不一致,对账结果全错。
灰度开关本身也要设计好。开关的粒度不要太细,几十个开关叠在一起,出问题时你会搞不清当前生效的是哪一套组合。我的做法是一个功能一个总开关,加少量按维度的子开关(用户、租户、地区),总数控制在可控范围。
3. 策略二:先造接缝,再动内部——接口隔离与防腐层
3.1 没有接缝的重构,改到第二周就走不下去
接缝这个词来自遗留代码处理领域的经典说法,意思是在不修改行为的前提下,能够替换实现的位置。翻译成人话:你得先有一道缝,才能把旧东西从里面掏出来。
大部分老系统的问题就是没有缝。业务逻辑、数据访问、外部调用全揉在一个方法里,你想替换其中一块,就得把整个方法重写。改到第二周你会发现,每改一处都要牵动十几处,PR 越来越大,review 越来越难,最后要么放弃,要么变成事实上的重写。
造接缝的第一步往往是"什么都不改,只包一层"。举例来说,一个方法直接在里面查数据库:
public BigDecimal calcPrice(Long orderId) { Order order = jdbcTemplate.queryForObject("select * from t_order where id = ?", ...); // 200 行业务逻辑,中间还查了两次用户表 return price; }第一步不是拆这 200 行,而是把数据获取抽出去:
public interface OrderRepository { Order findById(Long orderId); } public BigDecimal calcPrice(Long orderId) { Order order = orderRepository.findById(orderId); // 200 行逻辑原样保留 return price; }这一步的行为完全没变,测试都不用重写,但它给了你一个缝:以后可以把OrderRepository的实现换成新的数据源,而计算逻辑一行不动。重构的第一步永远是让代码变得可改,而不是直接改。
3.2 防腐层怎么落地:新旧模型不共享同一套实体
新旧代码共存期间,最大的隐患是旧模型的污染。老系统里的 Entity 往往背着几十个字段,其中一半是历史遗留,另一半是给某个报表用的。新代码如果直接复用这个 Entity,等于把老系统的所有妥协都继承过来了,重构的意义就没了。
防腐层的做法是:新代码只认自己的领域模型,老系统的一切都通过适配器转换进来。
// 新代码使用的领域模型,字段精简,语义明确 public record OrderView(String orderNo, OrderStatus status, Money amount) {} // 适配器负责把老系统的数据翻译成新模型 public class LegacyOrderAdapter { private final LegacyOrderDao legacyDao; public OrderView load(String orderNo) { LegacyOrderEntity entity = legacyDao.selectByNo(orderNo); if (entity == null) { return null; } return new OrderView( entity.getOrderNo(), mapStatus(entity.getStatus(), entity.getUserType()), // 状态映射集中在这里 Money.of(entity.getAmountCent(), "CNY") ); } // 老系统的状态码语义混乱,映射逻辑单独抽出来,方便加测试 private OrderStatus mapStatus(Integer status, Integer userType) { if (status == null) return OrderStatus.UNKNOWN; if (status == 3 && userType != null && userType == 2) { return OrderStatus.REFUNDED_PARTIAL; } return switch (status) { case 1 -> OrderStatus.CREATED; case 2 -> OrderStatus.PAID; case 3 -> OrderStatus.REFUNDED; default -> OrderStatus.UNKNOWN; }; } }这段代码的关键不在写法,在于把那些"没人说得清"的逻辑集中到一个地方。老系统里status == 3 && userType != 2这种判断可能散落在二十个文件里,你永远不知道它们的语义是否一致。集中到适配器之后,至少有了一个可以写测试、可以讨论、可以逐步修正的入口。
防腐层的另一个作用是控制爆炸半径。老系统改了什么字段,只需要改适配器一处,新代码不受影响。这在新旧并行半年以上的项目里价值极大。
3.3 数据层的接缝最难:分库、双写与读切换的顺序
应用层的接缝相对好造,数据层才是硬骨头。因为数据一旦写进去,就有了持久化的承诺。
如果要换表结构或换存储,我的操作顺序固定是四步:
- 加新表,双写。老代码继续写老表,同时写一份到新表。双写放在业务代码里做,不要试图用数据库触发器,触发器难以调试且容易在分库场景失效。
- 双读比对。读的时候两边都读,以老表结果为准返回,同时把两份结果做 diff 并打日志。这一步跑一到两周,能发现绝大部分转换逻辑的 bug。
- 切读。把读的主路径切到新表,老表读降级为兜底。此时双写还在继续。
- 停老写,下线老表。观察稳定后再停。
这个顺序里最容易出问题的是第 1 步。双写不是事务性的,写老表成功、写新表失败的情况一定会发生。解决办法是两条:一是双写失败不能影响主流程(新表写入包 try-catch,失败只记日志告警),二是必须有对账任务,按时间窗口扫描两边的差异并补偿。
对账任务的 SQL 大致长这样:
-- 找出时间窗口内只存在于老表、不存在于新表的记录 SELECT o.id, o.update_time FROM t_order_old o LEFT JOIN t_order_new n ON o.id = n.id WHERE o.update_time >= :startTime AND o.update_time < :endTime AND n.id IS NULL LIMIT 1000;对账的窗口不要太大,按小时跑,单次扫描量控制在几万条以内。曾经有团队用一条全表 JOIN 做对账,直接把从库拖垮,反而造成了一次故障。
还有一点要提前想清楚:切换读的那一刻,历史数据怎么办。老表里有三年前的数据,新表是空的。要么做一次全量迁移(注意分批,别锁表),要么在新表查询逻辑里做"查不到回源老表"的兼容。我倾向前者,因为回源逻辑会长期存在,成为新的技术债。
4. 策略三:用契约测试和流量回放,兜住那些没人记得的隐式行为
4.1 覆盖率在这类项目里是最容易骗人的指标
重构进行到一半,团队里总会有人问:测试覆盖率多少了?这个问题在老系统上意义有限。
原因很直接:遗留代码的测试往往是"能跑通就算过"。一个方法被 200 行的测试覆盖了,但断言只有一句assertNotNull(result),这种覆盖率是 100%,保护力是 0。你改了内部逻辑,测试照样绿,线上照样炸。
真正需要验证的不是"代码被执行了",而是"行为没变"。行为包括返回值、异常类型、副作用(写了哪张表、发了什么消息)、调用顺序。老系统里那些没人记得的隐式行为,恰恰是最容易在重构中丢掉的。
4.2 契约测试的落地:把"调用方假设"写成可执行断言
跨服务重构时,我最推荐的验证手段是契约测试。它的思路是把调用方对被调方的假设固化成测试用例,任何一方违反契约都会在 CI 阶段失败。
具体做法是每个消费方声明自己用到了提供方的哪些字段、什么格式、什么边界条件。比如订单服务调用用户服务拿用户等级:
# 消费方声明的契约片段 consumer: order-service provider: user-service interaction: request: method: GET path: /api/user/level query: userId: "10086" response: status: 200 body: level: 3 # 必须存在,整数 levelName: "GOLD" # 必须存在,字符串 expireAt: null # 允许为 null这份契约的价值在于,重构用户服务的时候,你不用去翻订单服务的代码看它到底依赖了什么,契约就是答案。而且它比接口文档可靠,因为它是可执行的。
我在一个项目里靠这个抓出过一个坑:用户服务重构时,把level字段从整数改成了字符串,类型转换在内部做掉了,接口文档也更新了,但订单服务里有个地方做了level > 2的比较,字符串比较的结果完全不同。契约测试在合并前就报错了。
契约测试要注意的是不要覆盖太广。全量接口都写契约,维护成本会压垮团队。优先覆盖三类:跨团队调用的核心接口、历史上出过故障的接口、字段语义含糊的接口。
4.3 流量回放:用生产真实请求做回归基线
有一类问题靠单测和契约测试都抓不到,就是真实的参数组合和边界数据。这类问题只能靠流量回放。
流程是四步:录制、脱敏、回放、比对。
录制阶段从网关或应用入口把请求抓下来,存成结构化文件。脱敏是必须的,手机号、身份证、地址这些字段要做替换,但替换不能改变数据形态,比如手机号还是 11 位数字,否则就测不出真实的边界问题。
回放阶段的关键是隔离。新实现处理回放请求时,所有下游依赖都要 mock 或者打到影子环境,绝对不能碰生产库。这一点如果做不好,流量回放就成了一次小规模的生产事故。
比对阶段我一般写个脚本做结构化 diff:
import json IGNORE_KEYS = {"timestamp", "traceId", "requestId", "serverTime"} def normalize(obj): """递归清理噪声字段,统一数字格式""" if isinstance(obj, dict): return {k: normalize(v) for k, v in obj.items() if k not in IGNORE_KEYS} if isinstance(obj, list): return [normalize(i) for i in obj] if isinstance(obj, float): return round(obj, 2) return obj def diff(old_resp, new_resp, path=""): diffs = [] o, n = normalize(old_resp), normalize(new_resp) if type(o) != type(n): diffs.append(f"{path}: 类型不一致 {type(o).__name__} -> {type(n).__name__}") return diffs if isinstance(o, dict): for k in set(o) | set(n): if k not in o: diffs.append(f"{path}.{k}: 新增字段") elif k not in n: diffs.append(f"{path}.{k}: 字段丢失") else: diffs.extend(diff(o[k], n[k], f"{path}.{k}")) elif isinstance(o, list): if len(o) != len(n): diffs.append(f"{path}: 数组长度 {len(o)} -> {len(n)}") for i, (a, b) in enumerate(zip(o, n)): diffs.extend(diff(a, b, f"{path}[{i}]")) elif o != n: diffs.append(f"{path}: {o!r} -> {n!r}") return diffs跑完之后,diff 结果要分三类处理:真问题(逻辑不一致,必修)、预期差异(新实现有意调整的行为,记录在案)、噪声(时间戳、排序不稳定,加进忽略列表)。
噪声的处理要克制。忽略列表加得太随意,等于把真问题也一起忽略了。我的原则是:每加一个忽略项,都要有人在 review 里说清楚为什么它可以忽略。
回放样本量不需要太大,几千条覆盖主要路径就够了。但如果你的系统里有大量靠参数组合区分的分支,样本要按业务分布加权采样,不能随机抽。
5. 策略四:小批量变更、可秒回滚、带护栏上线
5.1 变更粒度怎么切:一天多次小改 vs 一周一次大改
重构期间最反直觉的一条经验是:上线频率要提上去,不是降下来。
一个 3000 行的 PR,review 的人看不过来,测试的人测不完,出问题时git bisect要跑半天。反过来,一个 200 行的 PR,半小时能看完,CI 十分钟出结果,出问题直接回滚影响面很小。
我自己定的粒度标准是:一个 PR 只做一件事,且这件事能用一句话说清楚。"把订单查询的数据访问抽成 Repository"是一件事;"抽 Repository 顺便把状态码枚举改了"是两件事,必须拆开。
特别是纯重构和功能变更绝对不能混在一个 PR 里。混在一起,出问题时你没法判断是重构引入的还是新功能引入的,回滚也会把新功能一起带走。这条规则我在任何项目里都会写进团队约定,因为它省下的排查时间是以天计的。
5.2 回滚要能在三分钟内完成,才算真的能回滚
很多团队说"我们支持回滚",实际演练一次要四十分钟,还得几个人配合。那不叫能回滚,那叫理论上存在回滚的可能性。
回滚手段按速度排,从快到慢:
| 手段 | 生效时间 | 适用场景 | 局限 |
|---|---|---|---|
| 功能开关关闭 | 秒级 | 新逻辑包在开关里 | 要求代码从一开始就设计开关 |
| 灰度路由切回 | 秒级到分钟级 | 绞杀者模式下的流量切换 | 老实现必须还在线 |
| 镜像版本回退 | 分钟级 | 常规发布 | 容器拉起需要时间 |
| 数据补偿 | 小时级 | 数据被写坏 | 只能做逻辑补偿,难以完全还原 |
这里面最值得投入的是功能开关。它把"回滚"从运维动作变成了配置动作,不需要发版,不需要等容器,改个配置就生效。
但开关也有代价:代码里全是if (switchOn) { ... } else { ... },可读性下降,而且开关的生命周期管理很容易失控。我的做法是每个开关在创建时就写清楚预期下线时间,一般不超过两个月,到期没下线就强制清理。没有这个约定,三年后你会面对一个没人敢关的开关列表。
5.3 上线观察窗口与自动熔断阈值
灰度期间看什么指标,要提前定好,不能等到出问题才临时找。我常用的看板有这么几组:
- 错误维度:接口错误率、异常数量、下游调用失败率。错误率相对基线的涨幅超过 0.1 个百分点就告警。
- 延迟维度:P99 和 P999。平均值没意义,重构引入的性能问题通常体现在长尾上。
- 资源维度:数据库连接池使用率、慢查询数、GC 时间、线程池队列长度。新实现可能逻辑更"正确"但更耗资源。
- 业务维度:核心转化漏斗的每一步成功率。这个最能反映用户真实感受。
阈值设置有个技巧:不要设绝对值,设相对基线的倍数。比如"P99 延迟超过灰度前 7 天均值的 1.5 倍"就告警,比"P99 超过 500ms"更适应业务波动。
自动熔断的逻辑可以写得很简单:
# 灰度熔断规则示意 rule: window: 5m metrics: - name: error_rate threshold: 0.005 # 错误率超过 0.5% action: rollback - name: p99_latency threshold: 1.5x_baseline # 超过基线 1.5 倍 action: pause_gray # 暂停扩量,不自动回滚 - name: db_slow_query threshold: 10 # 5 分钟内慢查询超过 10 条 action: alert错误率直接触发回滚,延迟只暂停扩量,这个区别是有意设计的。延迟劣化可能是下游抖动,自动回滚会带来不必要的震荡;错误率飙升通常是代码问题,快速回滚才是对的。
6. 那些真正让人翻车的地方
6.1 "顺手改一下"是最贵的一行代码
重构进行到第三周,人的心态会发生变化,开始觉得"反正都在改这个文件,顺手把这几个命名也规范一下"。这个念头出现的时候,一定要按住。
顺手改的问题不在于改得对不对,而在于它让 diff 失去了可解释性。一个 PR 里既有逻辑调整又有格式重排,review 的人看不出哪些是行为变更。更要命的是,如果这个 PR 引发了故障,你无法判断是逻辑调整引起的还是格式重排引起的(虽然概率低,但排查时你必须排除这种可能,这就多花了时间)。
还有一个更隐蔽的坑:顺手升级依赖。重构期间升级框架版本,引入了新的默认行为,问题会以极其诡异的方式出现。我见过一次,升级某个序列化库之后,null 字段的默认处理变了,导致下游解析失败,排查了两天才定位到。
我的规矩是:重构期间不升级任何依赖,不调整任何格式规则,不修任何不相关的 bug。这些事都记在待办里,重构完成、系统稳定一个月之后再单独排期。
6.2 双写阶段的数据不一致,往往在切换那天才暴露
双写期间,读的还是老表,所以数据不一致不会影响用户。这就形成了一个危险的假象:双写跑了两个月,看起来一切正常,于是团队放心地切换读,然后问题集中爆发。
原因是双写失败的记录一直在积累,只是没人看。等到切读之后,用户读的是新表,才发现有些记录压根没同步过来,或者字段值是错的。
对策很明确:双写上线第一天就要把对账跑起来,并且每天看差异报告。差异率应该是从高到低收敛的曲线,如果两个月后还有稳定的日增差异,说明双写逻辑有问题,不能切读。
差异处理的优先级也要分清:
- 数量级差异:转换逻辑有 bug,必须修。
- 零星差异:通常是并发写入或超时导致的,靠补偿任务解决。
- 系统性差异:某个字段的映射规则理解错了,最严重,因为影响面是全局的。
6.3 节奏和预期的管理:重构不是一个人的事
技术上的问题都有解法,难的是让人理解为什么半年没有新功能。
我的办法是把重构的收益翻译成业务方听得懂的语言,并且在过程中就用数据说话,而不是等结束之后总结。比如重构进行到第二个月,公布一组数据:核心需求的平均交付周期从 12 天降到 8 天,线上故障平均恢复时间从 45 分钟降到 18 分钟。这些数字比"我们提升了代码质量"有说服力得多。
另一个经验是预留缓冲。我在估算重构工期时,通常会在技术评估的基础上乘 1.5。不是因为技术不确定,而是因为重构过程中一定会插入紧急需求、一定会遇到比预期更乱的代码、一定会有几次回滚重来。按理想状态排期,最后一定是延期,而延期会动摇整个团队对这件事的信心。
最后分享一个我在实际项目里反复验证有效的做法:重构的每个阶段都要有一个可以停下来的点。也就是说,如果业务突然要求全力投入新功能,你能在一个干净的、稳定的状态下暂停重构,随时可以继续。这就要求每个阶段结束时,新旧两条路径都是可用的、数据的双写和比对是完整跑通的。做不到这一点,重构就会变成"要么全做完,要么前功尽弃",那种项目十有八九会失败。