开篇:一份被称为"核反应堆"的代码
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 代码不是你的敌人——它只是比你更早出现在这个世界上。
面对它的时候,不需要害怕,不需要愤怒。你需要的是:
- 围栏模式——不改旧代码之前,先加保护层
- 测试先行——不知道它怎么工作,就先记录它怎么工作
- 渐进替换——不重写,替代
"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。