跳到主内容

接手祖传代码三个月:从不敢改到敢重构的安全网搭建实录 ​

接手祖传代码,第一步不是重写 ​

很多人第一次接手祖传代码,都会经历相似的情绪曲线:第一天觉得“这也能跑”,第一周觉得“必须重写”,第一个需求上线前又变成“千万别动”。三个月后我最大的体会是:遗留系统并不可怕,可怕的是在没有反馈机制时凭感觉修改。

所谓祖传代码,通常不是某一段代码特别烂,而是业务规则、历史兼容、隐性依赖和人员流动叠加后的结果。它可能没有文档、测试稀缺、命名失真、分支里藏着十年前的客户补丁,但它每天都在生产环境产生真实价值。新人 onboarding 的目标,也不是立刻证明自己比前任强,而是尽快建立一张“改动—影响—验证”的安全网。

重构信心不是性格特质,而是工程条件。你能多安全地修改一个模块,取决于你是否知道它的输入输出、依赖边界、关键路径、异常场景和验证方式。下面这套路径,是我在三个月里把一个不敢碰的核心模块,逐步改到可以小步替换的实践复盘。

先判断模块类型,再决定理解深度 ​

接手初期最容易犯的错误,是从第一行代码开始顺序阅读。业务系统不是教材,调用链往往来回跳转,状态散落在数据库、缓存、配置中心和第三方接口里。线性读代码的投入产出比很低,还容易被实现细节带偏。

更有效的方法是先给模块分类:

  • 资金或交易核心模块:优先补齐测试和对账,任何重构都要有数据校验。
  • 流程编排模块:重点画清状态机、节点顺序和失败补偿。
  • 数据转换模块:用典型输入样本锁定输出,适合先做特征测试。
  • 外部适配模块:重点识别协议差异、超时重试和兼容性分支。
  • 低频管理后台模块:不必一开始追求完美抽象,先保证入口和权限清晰。

分类决定策略。比如我接手的模块表面上叫“订单同步服务”,实际同时承担状态转换、第三方推送、失败重试和人工补偿四件事。若把它当成单一同步逻辑,很容易低估风险;把它拆开看,才能找到第一处安全落点。

识别入口与场景谁调用、何时触发记录当前行为日志样本与输出补特征测试锁定既有结果小步重构一次只改一类依赖地图DB、缓存、接口观测与回滚指标、开关、对账

第一个月:不要急着抽象,先建立行为基线 ​

我的前两周没有改生产代码,主要做三件事:找入口、抓样本、问历史。

入口不只是 Controller 或定时任务,还包括消息消费者、批处理脚本、人工修复页面、失败补偿任务。很多祖传 bug 之所以诡异,是因为同一个状态可能被五个入口修改。可以用一张表记录:触发方式、请求参数、读写表、调用外部服务、成功结果、失败结果、是否可重试。

抓样本时,优先选择有代表性的真实数据:正常订单、边界金额、已取消订单、重复通知、第三方超时、历史兼容字段为空。注意不要只在本地造“干净数据”,遗留系统最稳定的部分往往是主流程,最危险的部分是异常和脏数据。

问历史也有技巧。不要问“这段代码为什么这么写”,这容易得到“以前就是这样”的回答。更好的问法是:

  • 这个字段曾经兼容过哪个客户端?
  • 哪种订单不能走新流程?
  • 过去线上事故通常从哪个监控发现?
  • 有没有人工修数脚本或临时开关还在使用?
  • 这个异常吞掉是刻意的,还是当年没补完?

这些问题能把代码里没有表达的约束挖出来。所谓行为基线,就是用文档、日志和样本回答:系统现在到底做了什么,而不是我们以为它应该做什么。

方案对比:重写、封装、绞杀式重构怎么选 ​

面对祖传代码,常见方案有三种。

全量重写听起来最痛快,实际风险最高。重写期间旧系统仍在变化,新系统很容易漏掉长期积累的边界规则;等新系统上线时,团队面对的是一次无法拆分的大爆炸切换。除非业务已停止演进、模块边界清晰、验收样本完整,否则不建议新人在 onboarding 阶段推动。

外围封装适合先止血。比如把难测的核心逻辑包在一个适配器后面,新增功能走新接口,旧接口继续保留。它的优点是短期风险低,缺点是如果不继续偿还债务,容易形成“新旧两套逻辑并存”的新混乱。

绞杀式重构是我最终采用的方式:先在外部加测试和监控,再选择一小块纯逻辑迁出,通过开关并行校验新旧结果;稳定后扩大范围,逐步让旧代码只剩很薄的兼容层。它不追求一次优雅,而是让每次变更都可验证、可回滚、可暂停。

三者的关键差异不是技术新旧,而是反馈周期。反馈越接近实时,重构胆子才越有依据。

第二个月:用特征测试给旧代码系安全带 ​

遗留代码大多不适合一上来补细粒度单元测试,因为依赖隐藏、函数过长、副作用太多。此时更适合从“特征测试”开始:不评价设计好坏,只记录当前输入下的输出和副作用。

例如订单状态转换函数里混着大量条件,先不要拆它,而是构造一组历史订单样本,把转换后的状态、通知类型、重试次数固定下来。下面是一段简化示例,重点是用测试锁住当前行为:

python
# 特征测试:先描述“现在的行为”,而不是理想行为
# 等测试稳定后,再逐步拆分状态判断、通知、重试逻辑

def test_legacy_order_sync_characterization():
    # 来自生产日志的脱敏样本:重复通知 + 第三方超时过一次
    sample_order = load_fixture("orders/retry_after_timeout.json")

    before = capture_side_effects()
    result = legacy_order_sync(sample_order, now="2024-05-01T10:00:00Z")
    after = capture_side_effects()

    # 这些断言来自旧系统实际结果,先保护行为,再讨论命名和结构
    assert result.status == "SYNCED"
    assert result.notify_type == "ORDER_PARTNER_V2"
    assert after.count_created_tasks(before) == 1
    assert after.external_calls == ["queryOrder", "pushOrder"]

写这种测试时要克制,不要顺手把觉得不合理的结果改成“应该如此”。如果发现行为疑似 bug,先标成待确认,用业务样本或监控占比判断:它可能确实是缺陷,也可能是某个客户依赖的历史兼容。

当纯逻辑有了保护后,再处理外部依赖。数据库、消息队列、第三方 HTTP 不必全部 mock 掉,否则测试会变成对实现细节的复制。更推荐分层:

  • 纯规则测试:输入订单,输出状态和动作,速度快、覆盖边界。
  • 仓储集成测试:使用独立测试库,验证 SQL、事务和唯一约束。
  • 契约测试:验证第三方请求字段和响应解析,不依赖对方实时环境。
  • 端到端冒烟测试:只覆盖主链路,用来确认装配没有断。

第三个月:小步重构,让每次变更都能回滚 ​

有了基线后,重构仍要遵守“一次只改变一种属性”。一次提交里既改结构、又改业务规则、又换框架,出了问题几乎无法定位。安全节奏通常是:先改名,再搬函数;先抽纯逻辑,再隔离 IO;先并行比对,再切流;先小流量,再全量。

我处理那段同步逻辑时,先把状态判断抽成无副作用函数,旧方法内部委托给新函数;随后把第三方推送拆到端口接口后面;再通过配置开关让 1% 流量同时执行新旧两套实现,但只以旧结果为准,新结果只记录差异。

伪代码如下:

python
def sync_order(order, *, feature_flags, metrics):
    # 旧逻辑仍作为兜底,避免一次性切换
    old_result = legacy_sync(order)

    if feature_flags.is_on("refactor.sync.parallel_compare", order.tenant_id):
        try:
            new_result = new_sync(order)
            if not result_semantically_equal(old_result, new_result):
                # 记录差异样本,不影响用户请求
                metrics.emit_diff("order_sync_diff", order.id, old_result, new_result)
        except Exception as exc:
            metrics.emit_error("order_sync_new_error", exc)

    return old_result

并行比对比普通测试更接近真实流量。它能发现测试样本里没有的地区、版本、历史脏数据和特殊租户。差异连续稳定为零后,再让新结果接管小流量;一旦核心指标异常,立刻通过开关回退。

此外,重构期间要补三类可观测信息:入口量级和成功率、关键分支命中率、新旧结果差异数。没有这些指标,“看起来没问题”只是心理安慰;有了它们,才能判断代码路径是否真的被执行,异常是否被吞掉,切换是否造成数据偏移。

避坑总结:这些坑会直接消耗重构信任 ​

1. 不要把“看不懂”误判成“可以删” ​

常见现象:看到某个奇怪字段、空判断或休眠逻辑,认为冗余后删除,结果特定老版本客户端立刻报错。修正方法是先查调用入口、历史提交、线上日志和监控命中率;没有证据前,用适配层隔离并加注释说明“待验证的兼容逻辑”,而不是直接删除。

2. 不要用大量 mock 伪造安全感 ​

常见现象:测试里把数据库、消息、HTTP 全 mock 一遍,覆盖率很高,但 SQL 别名改错、事务边界丢失、第三方字段大小写变化都测不出来。修正方法是测试金字塔分层:纯逻辑多用 mock 或假对象,SQL 和协议用集成测试与契约测试,主链路保留少量真实组件冒烟。

3. 不要在一次提交里同时重构和修需求 ​

常见现象:产品催一个小规则,顺手重命名、拆函数、改框架,上线后出问题却无法判断是需求变化还是重构引入。修正方法是把提交拆成“无行为变化”和“业务规则变化”两类;前者靠特征测试验证输出一致,后者单独评审、单独发布。

4. 不要忽略人工入口和定时任务 ​

常见现象:主接口测试通过,结果补偿任务、后台手动重放、离线脚本仍在写旧状态,造成数据来回跳转。修正方法是建立写入口清单,所有能修改核心状态的 Job、脚本、消息消费者都纳入依赖地图,并在监控里按入口统计成功率。

5. 不要把大开关当成永久架构 ​

常见现象:为了安全加了新旧双写和并行比对,上线稳定后开关长期保留,团队没人敢删,最后形成第三套复杂系统。修正方法是在切换计划里写明观测周期、退出条件和清理责任人;差异归零且小流量稳定后,按计划删除旧路径。

结语:重构信心来自可验证,而不是无所畏惧 ​

三个月后再看那个模块,它并不优雅,仍有历史包袱,但我已经能清楚说明每个入口的风险、每组样本的预期、每条核心链路的监控,以及每次改动失败时如何回滚。这时的“敢重构”不是胆子变大,而是不确定性被一点点转化成了可验证的事实。

新人 onboarding 最有价值的产出,也不只是完成了几个需求,而是把口口相传的恐惧,变成团队下一次修改时可以复用的安全网。好的遗留系统治理从来不是英雄式推倒重来,而是耐心地理解行为、锁住行为、替换行为,再把验证手段留给后来的人。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。