7AA7 博客

代码重构的安全手法:小步、可回滚、有测试

Cache 孙 UID:133
2026-10-18 09:42
66 2 1

重构失败很少是因为设计能力不够,多数是因为一次改动太大:改到一半发现行为理解错了,此时进退两难——往前是未知,往后是几百个文件的 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 步与前面分开,正是为了让”结果变了是因为切换实现”和”结果变了是因为修了缺陷”成为两个可区分的事件。

绞杀者模式

对于无法一次性替换的旧模块(调用方多、边界不清、文档缺失),绞杀者模式的做法是让新旧并存,逐步把流量切过去,最后摘除旧的。

落地顺序:

  1. 在旧模块前面加一层薄代理(可以是函数调用,也可以是 HTTP 层的拦截),把所有入口收拢到一处。
  2. 写新实现,代理层默认走旧实现,行为零变化。
  3. 按调用方或按流量比例,逐个把入口切到新实现。
  4. 每个入口切换后观察一段时间,确认无异常。
  5. 所有入口切完后,删除旧实现和代理层。

代理层是关键:没有它,调用点散落在几十个文件里,切换就变成了大爆炸。收拢入口本身是一次纯结构改动,风险极低,收益极高。

并行运行与结果对比

新旧并存期间最有力的验证手段是并行运行(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
}

几个工程细节:影子调用必须异步或加超时,不能影响主路径耗时;差异日志要能按输入特征聚合,否则几百条日志看不出模式;要有”不匹配率”指标,用来判断是否可以进入切换阶段。

对比阶段的判断标准是:不匹配率降到零(或者每个剩余差异都有明确解释并确认为缺陷修复),才继续下一步。带着”少量差异说不清”就切换,等于把未诊断的问题带进生产。

特性开关的工程细节

特性开关是切换的控制器,它本身也需要工程约束:

  • 默认关闭,显式开启。新代码路径默认不生效,避免开关本身成为故障源。
  • 作用域要小。支持按用户、按租户、按请求比例开启,比全局开关好用得多——可以先在内测账号上验证。
  • 可运行时调整。切换不该依赖重新部署,否则回滚一次要等一个发布周期。配置中心或数据库是常见选择,注意加上缓存失效时间。
  • 失败要可预测。开关读取失败时应该走哪条路?通常是走旧的、保守的一侧。
  • 开关状态要进日志。排查时最想知道的是”这个请求到底走的哪条路”。

开关的清理债务

开关最大的问题是它会积累。每个开关都是一份认知负担:读代码的人要不断判断”这条分支现在还活着吗”。一个长期存在的开关会让代码里出现无法删除的死路径,也会让测试组合爆炸。

管理方式是把开关当债务记账:创建时登记负责人、预期存活时间、清理条件;每次发布检查里列出超过期限的开关;清理动作本身作为独立提交完成,走正常评审。开关数量是一个值得公开的指标——它大致反映了团队偿还重构债务的速度。

两种常见失败

大爆炸式重写。冻结旧系统几个月,另起炉灶重写,上线时一次性切换。问题在于:需求在这期间继续演进,新系统要同时追上旧系统的既有行为和新需求;上线时两个系统的差异无法枚举;一旦新系统有问题,回退路径往往已经不可用(数据已经迁移)。绞杀者模式的价值正在于它避免了这个”一次性”时刻。

边重构边加功能。重构过程中顺带实现新需求,导致无法判断回归来自哪里,也让”何时算完成”变得模糊。功能变更应该排在重构之后,或完全独立地并行推进——如果必须并行,用主干开发加短期分支,避免长期分支与重构互相阻塞。

重构的安全感来自可回滚性,而可回滚性来自小粒度。每一步都能退回去,才敢往未知的方向走。

1.5k浏览量
66赞
2评论
2参与用户
8 分钟阅读时间

评论(2)

  • Kernighan 林 · 2026-10-18 12:24

    这文章应该置顶,能少掉很多重复提问。

    回复
  • 顾清池 · 2026-10-18 20:51

    看到第三节我默默关掉了自己正在改的 PR,先去把注释补上。

    回复