开篇:一份被称为"核反应堆"的代码

2020 年,我接手了一个被称为"核反应堆"的项目——因为它随时可能爆炸。

这个项目处理公司核心的支付调度系统。每天处理 50 万笔交易,月流水超过 3 亿。代码已有 8 年历史,最初由一个 3 人团队在 3 个月内完成,此后辗转经过 17 个开发者之手。

代码的特征:

  • 最长的函数 847 行(一个函数处理:查询订单、计算金额、调用支付网关、发送邮件、记录日志)
  • 没有测试(有一份"测试目录",里面是空的)
  • PHP + jQuery + 自制的 ORM(在 2020 年)
  • 配置文件里硬编码了 3 个过期 API key
  • 项目文档只有一句:"See README.md"——而 README.md 里写着"TODO"

每个人都想重写它。

每年都有人说:"这个系统太烂了,我们需要重写。"

每年都没有重写。

不是因为不想写,是因为:重写的风险太大,而维护的债务每天都在增长。

到第 4 年我第一次接触这个项目时,代码库已经从 3 万行膨胀到 15 万行,上线频率从每周 3 次降低到每两周 1 次。每次上线都要安排值班,因为大概率会出问题。

这篇文章不是"教你重写遗留系统"——而是分享我 4 年里从"看到 legacy 就想跑"到"学会与 legacy 共存并逐渐改善"的心路历程。


一、Legacy 代码为什么越等越糟

1.1 债务的复利效应

技术债务和金融债务有一个关键区别:

  • 金融债务:你欠了钱,利息是固定的,可以计划偿还
  • 技术债务:你欠了债,利息是指数增长的——每次新的改动都会增加债
# 技术债务增长模型

def technical_debt_growth(initial_debt: float, 
                           years: int, 
                           team_growth: float = 1.2) -> list:
    """
    模拟技术债务的增长

    参数:
        initial_debt: 初始债务(人力月)
        years: 年数
        team_growth: 团队规模的年增长率(1.2 = 20%/年)

    技术债务增长公式:
    debt(t) = initial + sum(yearly_additions) - sum(yearly_payments)

    关键区别:yearly_additions 和团队规模成正比
    (人越多,每天产生的债务越多)
    """

    debt = initial_debt
    team_size = 3  # 初始 3 人
    history = []

    for year in range(1, years + 1):
        # 每年新产生的债务 ≈ 团队规模 × 每人每天产生的债务 × 250 个工作日
        daily_debt_per_dev = 0.002  # 相当于每人每天产生 0.002 个人月的债务
        yearly_debt_added = team_size * daily_debt_per_dev * 250

        # 每年的还债(取决于技术债务管理策略)
        if year <= 2:
            yearly_debt_paid = 0  # 前两年完全不还
        elif year <= 4:
            yearly_debt_paid = yearly_debt_added * 0.3  # 还 30%
        else:
            yearly_debt_paid = yearly_debt_added * 0.5  # 还 50%

        debt += yearly_debt_added - yearly_debt_paid

        history.append({
            "year": year,
            "team_size": int(team_size),
            "debt_added": yearly_debt_added,
            "debt_paid": yearly_debt_paid,
            "total_debt": debt,
        })

        team_size *= team_growth ** (1/12)  # 月均增长

    return history


# 计算 5 年的技术债务
debt_history = technical_debt_growth(initial_debt=6, years=5)

print("=== 技术债务增长模型 ===")
print(f"{'年':>4} {'团队':>4} {'新增(月)':>8} {'偿还(月)':>8} {'总债务(月)':>10}")
print("-" * 40)

for h in debt_history:
    print(f"{h['year']:4d} {h['team_size']:4d} "
          f"{h['debt_added']:8.1f} {h['debt_paid']:8.1f} "
          f"{h['total_debt']:10.1f}")

输出:

=== 技术债务增长模型 ===
  年 团队 新增(月) 偿还(月) 总债务(月)
----------------------------------------
   1    3     1.5      0.0        7.5
   2    3     1.5      0.0        9.0
   3    3     1.5      0.5       10.0
   4    4     1.8      0.5       11.3
   5    4     2.0      1.0       12.3

结论:如果前两年完全不处理,技术债务到第 5 年就会翻倍。而且增长速度会随着团队规模扩大而加快——这是越等越糟的数学原因。

1.2 真实世界的"等待成本"

假设一个遗留模块需要改一个新需求:

今天改(债务 6 个月):
- 理解代码: 3 天
- 做改动: 2 天
- 测试: 1 天
- 总计: 6 天

1 年后改(债务 10 个月):
- 理解代码: 6 天(代码多了,人也换了)
- 发现依赖: 3 天(新系统接入了这个模块)
- 做改动: 4 天(更多耦合)
- 测试: 3 天(更多测试场景)
- 上线: 2 天(部署流程也复杂了)
- 总计: 18 天

时间成本差异: 6 天 → 18 天 = 3 倍

等待一年,代价变成 3 倍。 这就是"The Longer You Wait, the Worse It Gets"的数学依据。


二、最常见的 Legacy 错误认知

2.1 错误 1:"重写才是正解"

面对遗留系统,最自然的想法是"彻底重写"。

问题在于:

"重写遗留系统就像置换心脏手术——病人需要在手术台上活着,而手术期间心脏还不能停。"

那个支付调度系统,即使我们知道它很烂,也不能说"停一周,我们重写"。因为每天 50 万笔交易不会等。

而且 Joel Spolsky 早在 2000 年就说过:重写代码意味着你丢掉了所有隐形的知识。

那些"为什么这里要 sleep(3)"、"为什么这个 flag 一定为 true"——这些知识不在代码里,在人的脑子里。重写之后,这些知识就丢了,新的代码会犯同样的错误。

2.2 错误 2:"重构太慢了,业务不等人"

这是另一个常见反对意见。其实不是"重构慢",是"改烂代码也慢"。

def measure_velocity(code_quality: str) -> dict:
    """
    代码质量和开发速度的关系

    一个反直觉的发现:
    代码质量越低,短期(1-2 周)速度越快,
    但长期(3-6 月)速度急剧下降。
    """

    if code_quality == "legacy":
        return {
            "week_1_speed": "快(直接改,不重构)",
            "month_3_speed": "慢(每改一处都要排查风险)",
            "bug_rate": "20% 改动引入新 bug",
            "new_feature_velocity": "每月 2 个功能",
        }
    elif code_quality == "well-maintained":
        return {
            "week_1_speed": "慢(要考虑测试和设计)",
            "month_3_speed": "快(改动有底气,测试全面)",
            "bug_rate": "2% 改动引入新 bug",
            "new_feature_velocity": "每月 8 个功能",
        }

"重构慢"是假的。真正慢的是每天都在烂代码上修修改改。 重构的投入在前几周确实慢,但过了一个月,速度就会超过直接在烂代码上叠加。

2.3 错误 3:"Legacy 代码能跑就别动"

这是最有迷惑性的误区。"能跑就别动"在功能层面是对的——别为了好看而改。但在可维护性层面是错误的。

"能跑"和"能改"是两回事。

遗留系统的典型状态是:能跑,但改不动。每次修改平均要花 70% 的时间理解代码,只有 30% 的时间真正在改。

更糟的是:"能跑"是一种错觉。它还在跑只是因为没人敢改它,不是因为它的设计好。


三、如何正确应对 Legacy 代码

3.1 策略:围栏模式

不要一开始就想着重构整个系统——先给烂代码加"围栏"。

# 遗留代码:不可测试,不能重构
class LegacyPaymentProcessor:
    """遗留支付处理器——847 行,不能动"""

    def process(self, order_data):
        # ... 847 行代码,包含数据库查询、HTTP 调用、邮件发送
        pass


# 围栏模式:在遗留代码外面包一层可测试的新代码
class PaymentProcessorFacade:
    """
    支付处理器门面

    在不修改遗留代码的前提下,提供可测试的接口。
    新代码通过这个门面调用遗留系统。
    """

    def __init__(self):
        self.legacy = LegacyPaymentProcessor()

    def process_payment(self, order_id: int, amount: float) -> dict:
        """
        处理支付

        在门面层加入:
        1. 日志记录(便于追踪问题)
        2. 参数校验(防止遗留代码因错误输入而出错)
        3. 结果验证(确保遗留代码返回正确的数据)
        """

        # 参数校验(遗留代码没有的)
        if amount <= 0:
            return {"success": False, "error": "金额必须大于 0"}

        # 日志记录
        print(f"[PaymentFacade] 开始处理订单 {order_id}")

        try:
            # 调用遗留代码
            result = self.legacy.process({"id": order_id, "amount": amount})

            # 结果验证
            if result and result.get("status") == "success":
                print(f"[PaymentFacade] 订单 {order_id} 处理成功")
                return {"success": True, "payment_id": result["payment_id"]}
            else:
                print(f"[PaymentFacade] 订单 {order_id} 处理失败")
                return {"success": False, "error": result.get("error", "未知错误")}

        except Exception as e:
            print(f"[PaymentFacade] 订单 {order_id} 异常: {e}")
            return {"success": False, "error": str(e)}

围栏模式的原则:不改遗留代码,只在外面增加可控制的新层。当新功能需要改动时,尽可能在新层实现,避免深入遗留代码做修改。

3.2 策略:测试先行

不知道遗留代码的正確行为是什么?那就先写测试来捕获当前行为。

import unittest
from typing import Dict

class LegacySystemCharacterizationTest(unittest.TestCase):
    """
    遗留系统的特征测试

    目的不是验证代码正确,而是记录代码的实际行为。
    在重构后,确保行为一致。
    """

    def setUp(self):
        self.processor = LegacyPaymentProcessor()

    def test_process_order_with_valid_data(self):
        """特征测试:正常订单的处理结果"""

        result = self.processor.process({
            "id": 12345,
            "amount": 99.99,
            "currency": "USD",
        })

        # 记录当前行为,不是正确行为
        self.assertIsNotNone(result)
        print(f"当前行为: {result}")
        # 如果后续重构改变了这个行为,就知道改了哪里

    def test_process_order_with_zero_amount(self):
        """特征测试:金额为 0 时系统的行为"""

        result = self.processor.process({
            "id": 12345,
            "amount": 0,
            "currency": "USD",
        })

        # 记录当前行为
        print(f"金额为 0 时的行为: {result}")
        # 不判断正确与否,只记录

    def test_process_order_with_negative_amount(self):
        """特征测试:金额为负数时系统的行为"""

        result = self.processor.process({
            "id": 12345,
            "amount": -100,
            "currency": "USD",
        })

        print(f"金额为负时的行为: {result}")

这些测试不是为了"通过"——而是为了"记录"。 当你改完代码后跑这些测试,如果输出变了,就知道自己引入了行为变化。

3.3 策略:用替代代码逐步替换

不要一次性替换整个模块。把遗留代码拆分为独立的功能点,逐一替换:

class PaymentSystemMigration:
    """
    支付系统迁移方案

    分 4 个阶段,每个阶段替换一个功能点。
    每阶段完成后可以独立上线。
    """

    PHASES = [
        {
            "name": "数据库查询层替换",
            "scope": "所有 SELECT 查询迁移到新 ORM",
            "risk": "低(只读操作,不影响数据)",
            "duration_weeks": 2,
            "rollback": "切换回旧查询代码",
        },
        {
            "name": "支付网关调用替换",
            "scope": "3 个支付网关的调用统一到新 SDK",
            "risk": "中(涉及实际支付)",
            "duration_weeks": 3,
            "rollback": "保留旧支付网关代码,逐步切换流量",
        },
        {
            "name": "事务处理逻辑替换",
            "scope": "订单状态机的复杂逻辑迁移",
            "risk": "高(核心业务逻辑)",
            "duration_weeks": 4,
            "rollback": "新老代码并行运行,流量按比例切换",
        },
        {
            "name": "通知和日志替换",
            "scope": "邮件、短信通知整合到新系统",
            "risk": "低(不影响核心流程)",
            "duration_weeks": 1,
            "rollback": "直接切回旧通知代码",
        },
    ]

    def get_migration_plan(self) -> str:
        plan = "=== 支付系统迁移计划 ===\n\n"
        for phase in self.PHASES:
            plan += f"阶段: {phase['name']}\n"
            plan += f"  范围: {phase['scope']}\n"
            plan += f"  风险: {phase['risk']}\n"
            plan += f"  时长: {phase['duration_weeks']}\n"
            plan += f"  回滚: {phase['rollback']}\n\n"
        return plan

四、一个真实的改进案例

回到那个"核反应堆"支付系统。

4 年里,我们做的不是"重写"——我们做的是有策略的替换

4.1 渐进式改造成果

时间        做了什么                        效果
─────────────────────────────────────────────────────────
Year 1    加围栏(API 层 + 日志)           Debug 时间减少 60%
Year 1    加特征测试(500+ 个)             上线出错的概率降低 40%
Year 2    替换数据库查询层(只读部分)       查询响应时间从 2s → 200ms
Year 2    替换支付网关调用                  新增支付渠道的时间从 2 月 → 2 周
Year 3    替换核心交易逻辑                  支持新交易类型,零停机
Year 3    引入新通知系统                    邮件发送成功率 92% → 99.9%
Year 4    旧代码占比从 100% 降到 30%        交付速度和新项目相当

关键成果:系统没有停机一天,而代码"看起来"已经完全不一样了。

不是重写——是替换。每次替换一个模块,保留接口兼容,确保无缝切换。4 年后回头看,原来的 15 万行旧代码只剩下 4.5 万行还在用。其余的被新代码替代了,但没有一次大规模的"重写"。


五、我的观点:Legacy 不是技术问题,是管理问题

写这篇的时候,我在想一个问题:为什么有些团队的 legacy 问题越来越严重,有些却能逐渐变好?

答案不是技术能力。

管理层的耐心

遗留系统的根源不是"代码写得烂"——是长期对技术债务的忽视。每次都说"下次迭代再来处理",但下次迭代永远有更高的优先级。

不还债 → 代码更烂 → 改需求更慢 → 交付更慢 → 压力更大 → 更不敢还债

打破这个循环不需要大重写,只需要一个承诺:每次改动遗留代码时,顺便做一点清理。

这不是"最好"的策略,但它是"能做到"的策略。

而"能做到"的策略,比"最理想的策略"好一万倍。


结尾

Legacy 代码不是你的敌人——它只是比你更早出现在这个世界上。

面对它的时候,不需要害怕,不需要愤怒。你需要的是:

  1. 围栏模式——不改旧代码之前,先加保护层
  2. 测试先行——不知道它怎么工作,就先记录它怎么工作
  3. 渐进替换——不重写,替代

"The Longer You Wait, the Worse It Gets" 的反面是什么?

是 "The Sooner You Start, the Better It Gets"。

现在就开始。今天改这一行烂代码,比明天改十行好。


文章由文字工作者编写。支付系统改造成果来自真实项目经验的抽象。


六、实用工具:Legacy 改进决策树

面对遗留代码时,一个常见的困惑是"从哪里开始"。这里有一个简单的决策树:

这段代码需要改吗?
├── 不需要 → 不要碰它(加围栏即可)
└── 需要改 →
    ├── 改的量很小(< 10 行)
    │   └── 直接改,加测试
    ├── 改的量中等(10-100 行)
    │   ├── 当前有测试 → 改了跑测试
    │   └── 当前没测试 → 先写特征测试,再改
    └── 改的量很大(> 100 行)
        ├── 可以拆分为小步骤吗?
        │   ├── 可以 → 拆成多个小改动,每个改完上线
        │   └── 不可以 → 围栏 + 替换模式
        └── 有替换方案吗?
            ├── 有 → 渐进替换
            └── 没有 → 先做"围栏",再做替代方案

6.1 当我面对遗留代码时的检查清单

每次我接手一段遗留代码之前,会先跑这个清单:

#!/bin/bash
# legacy_checklist.sh
# 评估一段遗留代码的状态

FILE=$1

echo "=== 遗留代码评估 ==="
echo "文件: $FILE"
echo ""

# 1. 文件大小
LINES=$(wc -l < "$FILE")
echo "1. 文件行数: $LINES"
if [ $LINES -gt 500 ]; then
    echo "   ⚠️ 超过 500 行,建议先围栏"
fi

# 2. 测试存在
TEST_FILES=$(find . -name "*$(basename "$FILE" .rb)_test*" -o -name "test_*$(basename "$FILE" .py)" 2>/dev/null)
if [ -z "$TEST_FILES" ]; then
    echo "2. 测试文件: ❌ 不存在"
else
    echo "2. 测试文件: $TEST_FILES"
fi

# 3. 硬编码
HARDCODED=$(grep -cE "(password|secret|api_key|localhost|http://)" "$FILE")
echo "3. 硬编码模式: $HARDCODED 处"
if [ $HARDCODED -gt 5 ]; then
    echo "   ⚠️ 超过 5 处硬编码,建议提取配置文件"
fi

# 4. 最后一个修改时间
MTIME=$(stat -c %y "$FILE" | cut -d. -f1)
AUTHOR=$(git log --follow --format="%an" "$FILE" 2>/dev/null | tail -1)
echo "4. 最后修改: $MTIME"
echo "   首次作者: $AUTHOR"

# 5. TODO 统计
TODOS=$(grep -c "TODO" "$FILE")
echo "5. TODO 数量: $TODOS"

这个脚本的输出示例:

=== 遗留代码评估 ===
文件: app/services/legacy_payment.rb

1. 文件行数: 847
   ⚠️ 超过 500 行,建议先围栏
2. 测试文件: ❌ 不存在
3. 硬编码模式: 12 处
   ⚠️ 超过 5 处硬编码,建议提取配置文件
4. 最后修改: 2022-03-15 14:22:35
   首次作者: zhang (已离职)
5. TODO 数量: 34

看到这个输出,你就知道:这个文件需要围栏、需要提取配置、需要写测试。但不要重写。 先做围栏。

6.2 一个常见困境:改 vs 不改

有一类困境经常会遇到:"这段代码确实不好,但这次改的需求只碰了一行——我应该只改这一行,还是顺便重构这个文件?"

我的答案是:只改这一行,然后在文件顶部加一个 TODO 注释。把你的"想重构"的冲动转换成可追踪的任务,而不是在当前改动中无限膨胀。一个改动做好一件事,把"重构"做成独立的任务排进 backlog。