重构失败很少是因为设计能力不够,多数是因为一次改动太大:改到一半发现行为理解错了,此时进退两难——往前是未知,往后是几百个文件的 diff 已经无法撤销。让重构可控的核心不是技术,是把不可控的部分切小。
重构与改行为是两件事
重构的严格定义是:在不改变外部可观测行为的前提下,改变内部结构。一旦同一次改动里既改结构又改行为,就失去了判断依据——出了问题是结构改错了,还是新行为本来就有缺陷?无法分辨,也就无法回滚到某个已知正确的中间状态。
实操上的约束很朴素:
- 一个提交要么只改结构,要么只改行为,不同时做。
- 改结构时,所有现有测试必须保持绿,且不允许顺手”优化”任何判断条件。
- 需要改行为时,先做完结构重构并提交,再开一个提交改行为。
这条纪律最难守的地方在于,重构过程中总会看到”这里顺便改一下更好”的地方。正确做法是把它们记到待办清单里,等结构稳定后再处理,而不是当场动手。
先补测试,再动结构
没有测试的重构是盲改。顺序不能反:先为即将被重构的部分补上覆盖其当前行为的测试——注意是当前行为,不是”应该有的行为”。如果现有行为本身就是错的,那属于行为变更,是第二步的事。
补测试时优先覆盖:对外接口契约、边界条件、错误路径。对于没有测试的老模块,一种可行的做法是”特征化测试”:先写一批记录当前实际输出的用例(哪怕输出看起来不合理),把它们固定下来,然后重构,观察哪些用例的输出变了。变了的地方就是需要人工确认的点。
# 特征化测试:先锁住现状,不判断对错
def test_legacy_pricing_current_behavior():
assert legacy_price({"qty": 0, "unit": 10}) == 0 # 现状如此,先记录
assert legacy_price({"qty": -1, "unit": 10}) == -10 # 疑似缺陷,标记待确认
assert legacy_price({"qty": 100, "unit": 10}) == 900 # 折扣逻辑,需确认规则
提交粒度:可评审、可回滚
一个重构提交应该小到评审者能在十分钟内看懂,同时小到出问题可以直接 revert 而不带掉别的东西。判断标准是:这个提交单独 revert 之后,系统是否仍然处于一个可运行、行为一致的状态。
常见的一组拆分方式:
| 步骤 | 内容 | 是否改变行为 |
|---|---|---|
| 1 | 补特征化测试,锁住现状 | 否 |
| 2 | 提取函数/改名/消除重复 | 否 |
| 3 | 引入新接口,旧实现保留并转发 | 否 |
| 4 | 调用方逐个切到新接口 | 否 |
| 5 | 并行运行,对比新旧结果 | 否(只加观测) |
| 6 | 删除旧实现 | 否(若结果已一致) |
| 7 | 修复在对比中发现的真实缺陷 | 是 |
第 7 步与前面分开,正是为了让”结果变了是因为切换实现”和”结果变了是因为修了缺陷”成为两个可区分的事件。
绞杀者模式
对于无法一次性替换的旧模块(调用方多、边界不清、文档缺失),绞杀者模式的做法是让新旧并存,逐步把流量切过去,最后摘除旧的。
落地顺序:
- 在旧模块前面加一层薄代理(可以是函数调用,也可以是 HTTP 层的拦截),把所有入口收拢到一处。
- 写新实现,代理层默认走旧实现,行为零变化。
- 按调用方或按流量比例,逐个把入口切到新实现。
- 每个入口切换后观察一段时间,确认无异常。
- 所有入口切完后,删除旧实现和代理层。
代理层是关键:没有它,调用点散落在几十个文件里,切换就变成了大爆炸。收拢入口本身是一次纯结构改动,风险极低,收益极高。
并行运行与结果对比
新旧并存期间最有力的验证手段是并行运行(shadow run):请求同时走新旧两条路径,以旧路径的结果为准返回,把两条路径的结果差异记录到日志或指标里。
func (s *Service) Price(ctx context.Context, in Input) (Out, error) {
legacyOut, legacyErr := s.legacy.Price(ctx, in)
if s.shadowEnabled(in) {
go func() {
newOut, newErr := s.next.Price(ctx, in)
if !equal(legacyOut, newOut) || !sameErrKind(legacyErr, newErr) {
s.diffLog.Warn("pricing mismatch",
"input", in, "legacy", legacyOut, "next", newOut)
}
s.metrics.Incr("pricing.shadow.mismatch")
}()
}
return legacyOut, legacyErr
}
几个工程细节:影子调用必须异步或加超时,不能影响主路径耗时;差异日志要能按输入特征聚合,否则几百条日志看不出模式;要有”不匹配率”指标,用来判断是否可以进入切换阶段。
对比阶段的判断标准是:不匹配率降到零(或者每个剩余差异都有明确解释并确认为缺陷修复),才继续下一步。带着”少量差异说不清”就切换,等于把未诊断的问题带进生产。
特性开关的工程细节
特性开关是切换的控制器,它本身也需要工程约束:
- 默认关闭,显式开启。新代码路径默认不生效,避免开关本身成为故障源。
- 作用域要小。支持按用户、按租户、按请求比例开启,比全局开关好用得多——可以先在内测账号上验证。
- 可运行时调整。切换不该依赖重新部署,否则回滚一次要等一个发布周期。配置中心或数据库是常见选择,注意加上缓存失效时间。
- 失败要可预测。开关读取失败时应该走哪条路?通常是走旧的、保守的一侧。
- 开关状态要进日志。排查时最想知道的是”这个请求到底走的哪条路”。
开关的清理债务
开关最大的问题是它会积累。每个开关都是一份认知负担:读代码的人要不断判断”这条分支现在还活着吗”。一个长期存在的开关会让代码里出现无法删除的死路径,也会让测试组合爆炸。
管理方式是把开关当债务记账:创建时登记负责人、预期存活时间、清理条件;每次发布检查里列出超过期限的开关;清理动作本身作为独立提交完成,走正常评审。开关数量是一个值得公开的指标——它大致反映了团队偿还重构债务的速度。
两种常见失败
大爆炸式重写。冻结旧系统几个月,另起炉灶重写,上线时一次性切换。问题在于:需求在这期间继续演进,新系统要同时追上旧系统的既有行为和新需求;上线时两个系统的差异无法枚举;一旦新系统有问题,回退路径往往已经不可用(数据已经迁移)。绞杀者模式的价值正在于它避免了这个”一次性”时刻。
边重构边加功能。重构过程中顺带实现新需求,导致无法判断回归来自哪里,也让”何时算完成”变得模糊。功能变更应该排在重构之后,或完全独立地并行推进——如果必须并行,用主干开发加短期分支,避免长期分支与重构互相阻塞。
重构的安全感来自可回滚性,而可回滚性来自小粒度。每一步都能退回去,才敢往未知的方向走。
Comments (2)
这文章应该置顶,能少掉很多重复提问。
看到第三节我默默关掉了自己正在改的 PR,先去把注释补上。
Please Sign in to join the discussion.