渐进式迁移遗留单体:避免“大爆炸”重写的增量策略
大型遗留系统迁移往往在最终切换之前很久就已经失败了。
失败的根源通常在于把迁移当作一次性事件来做:搬应用、搬数据库、迁移所有用户、切换流量、关掉旧系统。
这就埋下了一个危险的假设:旧系统和新系统必须一次性完成交接。
实际上通常并不需要。
如果你已经了解遗留系统的行为,用特征测试(characterization tests)把它保护起来,建立便于迁移的边界,并能对比新旧实现,那么你还有另一种选择。
你可以每次只迁移一个能力。这会彻底改变问题的性质。
不是这样:
legacy monolith
↓
complete rewrite
↓
big-bang cutover
而是这样:
legacy monolith
↓
one capability extracted
↓
small percentage of traffic
↓
observe
↓
expand
↓
repeat
目标不是把迁移拖得更慢,而是让每一步变更更小、可观察、可回滚。
在本教程中,我将展示如何以渐进方式迁移遗留单体应用,内容包括:
选择安全的首个迁移切片
在新旧代码之间定义边界
在不同实现之间路由请求
使用 Strangler Fig 模式
按业务能力而非技术分层进行迁移
让新旧实现并行运行
逐步放量引流
在完全切换之前发现故障
设计回滚路径
谨慎处理数据所有权
移除已迁移的遗留代码
合理使用 AI,避免把渐进式迁移变成自动化重写
示例代码使用 TypeScript,但这套方法适用于大多数语言、运行时和架构。
目标很简单:把迁移变成一系列受控的变更,而不是一次不可逆的事件。
前置要求
要跟上本文内容,你需要熟悉:
TypeScript 或类似语言
API 与服务边界
集成测试
依赖注入
路由与反向代理
数据库事务
可观测性
渐进式重构
遗留系统现代化改造
你应该已经理解想要迁移的功能的行为。
理想情况下,你需要清楚:
它的输入
它的输出
它的重要业务规则
它的副作用
它的依赖项
它的外部契约
你将如何检测行为差异
如果尚未达到这一点,进行迁移可能为时过早。
目录
为何“大爆炸”式迁移风险极高
设想一个遗留的商务应用。
它包含:
customers
orders
payments
inventory
shipping
invoicing
notifications
reporting
现代化方案通常会写道:
replace the monolith
这听起来像是一个项目。
但在实际操作中,这可能意味着要同时更改:
runtime
framework
database
deployment model
API contracts
authentication
networking
observability
data model
business logic
external integrations
如果最终切换失败,可能的原因多不胜数。
例如:
Did pricing change?
Did the database migration lose data?
Is the payment provider failing?
Did authentication behave differently?
Did the new runtime change date handling?
Did a timeout become shorter?
Did an event stop being published?
Did the new deployment configuration fail?
这是“大爆炸”式迁移的核心问题之一:变量变更过多且集中发生。
增量迁移旨在减少每一步中变更的变量数量。
以迁移切片思考,而非以应用思考
不要问:
我们该如何迁移这个单体架构?
而要问:
我们能独立迁移的最小、但有意义的业务能力是什么?
举个例子:
计算订单总额
生成发票
发送订单确认
创建货运单
续订订阅
审批客户
一个迁移切片最好具备:
清晰的输入
清晰的输出
已知的副作用
明确的依赖关系
可观察的行为
可回滚的路径
这样你就有了一个具体可迁移的东西。
比如:
生成发票
可以拆解为:
输入:
orderId
行为:
加载订单
计算税费
生成发票号
创建发票
副作用:
存储发票
发布 invoice.created 事件
输出:
invoice
这比迁移下面这些要容易得多:
billing 模块
或者:
src/services/
业务能力比文件夹更适合作为迁移单元。
谨慎选择第一个迁移对象
第一个切片很关键。
我通常不建议从系统里最关键的能力入手。
你要找的是既能充分验证迁移方案、又不会因为出错就引发灾难性后果的对象。
合适的第一个切片通常具有:
中等流量
较少的外部依赖
行为清晰
测试覆盖良好
事务边界少
影响范围小
比如:
生成客户对账单
就比下面这个更适合作为首个迁移对象:
支付授权
第一次迁移在某种程度上是技术活,但更是一次学习演练。
你要验证的是:
路由
部署
可观测性
回滚
数据访问
测试
团队协作流程
验证通过后,再把这套模式应用到更关键的能力上。
在旧代码和新代码之间建立边界
假设遗留应用中有这样一个函数:
async function generateInvoice(
orderId: string
) {
// legacy implementation
}
迁移之前,先引入一个边界:
interface InvoiceGenerator {
generate(
orderId: string
): Promise<Invoice>;
}
遗留实现就变成了:
class LegacyInvoiceGenerator
implements InvoiceGenerator {
async generate(
orderId: string
): Promise<Invoice> {
// existing behavior
}
}
新的实现如下:
class NewInvoiceGenerator
implements InvoiceGenerator {
async generate(
orderId: string
): Promise<Invoice> {
// migrated behavior
}
}
调用方无需关心当前生效的是哪个实现。
这带来了一项关键能力:
replace implementation
without replacing caller
这也是渐进式迁移的基石之一。
采用绞杀榕模式
描述渐进式替换的一个常见方法就是绞杀榕(Strangler Fig)模式。
不是一次性替换整个应用,而是让新行为围绕旧系统逐渐生长。
概念上:
incoming request
│
↓
router
/ \
/ \
legacy path new path
初期:
legacy: 100%
new: 0%
随后:
legacy: 95%
new: 5%
接着:
legacy: 50%
new: 50%
最终:
legacy: 0%
new: 100%
此时,该功能对应的旧实现就可以移除了。
关键在于替换是逐步进行的。旧应用继续承载系统的一部分,而新实现接管其余部分。
按功能迁移,而非按技术层迁移
一种诱人的迁移策略是:
move database
then move services
then move APIs
then move UI
这会导致在很长一段时间内,每个功能都横跨新旧两种架构。
例如:
new API
↓
legacy service
↓
new database
↓
legacy event publisher
有时这种情况无法避免。
但只要可能,我倾向于采用垂直切片。
一个垂直切片可能是:
Generate Invoice
request
↓
application logic
↓
persistence
↓
events
↓
response
该功能可以作为一致的整体进行迁移。
然后:
Create Shipment
可以独立迁移。
接着:
Renew Subscription
以此类推。
这样能让你更早拥有可用的已迁移功能。同时,也能减少临时的跨系统依赖数量。
让旧版与新版实现并存
在增量迁移过程中,新旧系统并存是常态。
在一段时间内,你可能会同时拥有:
LegacyInvoiceGenerator
NewInvoiceGenerator
两者同时部署。
这不是无意的重复代码,而是迁移策略的一部分。
关键问题在于请求如何在两者之间做选择。
你可以使用以下因素:
feature flag
tenant
user group
request header
region
percentage rollout
specific account IDs
例如:
class InvoiceRouter {
constructor(
private readonly legacy:
InvoiceGenerator,
private readonly migrated:
InvoiceGenerator
) {}
async generate(
orderId: string,
useMigrated: boolean
) {
if (useMigrated) {
return this.migrated.generate(
orderId
);
}
return this.legacy.generate(
orderId
);
}
}
这个设计故意保持简单。关键在于路由逻辑是显式的,你知道每个请求是由哪个实现处理的。
显式路由流量
避免使用难以观察的迁移逻辑。
例如:
try {
return await newService.call();
} catch {
return legacyService.call();
}
这看似具有弹性,但可能掩盖故障。
假设新实现的失败率为 40%。如果所有失败都静默回退到旧版,用户可能察觉不到问题。但迁移过程并不健康。
问题在于,第一种写法将两个决策混为一谈:该由哪个实现接收请求 以及 该实现失败时该怎么办。由于回退逻辑隐藏在 catch 块中,已迁移的路径可能反复失败,却不会产生可量化的显式路由信号。
更好的方法是先做出路由决策,记录该决策,然后调用选定的实现。这样能将迁移策略与错误处理分离,并清晰记录实际上有多少流量进入了每条路径。
例如:
const route =
migrationPolicy.route(request);
metrics.increment(
`invoice.route.${route}`
);
if (route === "migrated") {
return migrated.generate(
request.orderId
);
}
return legacy.generate(
request.orderId
);
这样你就能度量:
路由到 legacy 的请求数
路由到 migrated 的请求数
迁移失败次数
回退次数
延迟
业务结果
迁移本身应该成为系统的一等行为,全程可观测。
从内部或低风险流量开始
在把大部分客户流量切到迁移后的路径之前,先从更安全的流量开始。
比如:
开发环境
测试环境
内部用户
员工账号
测试租户
特定的低风险客户
这样可以用更低的风险验证:
部署
路由
可观测性
数据访问
外部集成
故障处理
然后再逐步扩大范围,例如:
内部用户
↓
1% 生产流量
↓
5%
↓
10%
↓
25%
↓
50%
↓
100%
具体百分比不重要,重要的是原则。
每一步流量提升,都应该建立在前一阶段积累的足够证据之上。
逐步增加生产流量
假设你有:
10,000 次发票请求/天
不要一次性把所有请求从:
legacy → new
切换过去,而是先路由:
1%
这大约意味着:
100 个真实请求/天
走迁移后的路径。
此时持续监控:
错误率
延迟
输出差异
副作用
客户可见的故障
业务指标
系统表现正常就增加流量;表现异常就减少或停用迁移路由。
这样迁移就变成了一个可控的实验,和一次性切换完全是两回事。
在上线前后使用差分测试
本系列上一篇文章讲的就是差分测试,这一技术在迁移中尤其有用。
差分测试是指用相同的输入同时运行 legacy 和迁移后的实现,并对比它们的可观测行为。根据功能的不同,对比内容可能包括返回值、错误、状态变更和副作用。
目标并非证明两种实现在内部结构上完全一致,而是确保在行为差异波及所有生产流量之前将其发现。
在实际流量路由生效前,你可以进行如下比对:
same input
↓
legacy result
same input
↓
new result
在灰度发布阶段,只要安全条件允许,也可以抽样真实流量并对比其行为表现。
例如:
real request
│
├────→ active implementation
│
└────→ shadow implementation
随后对比以下维度:
output
errors
side effects
business state
这能为你在扩大流量范围前提供数据支撑。
进而,发布决策可基于以下指标:
divergence
error rate
latency
business outcomes
而不是模糊的判断,例如:
看起来没问题。
一个小型端到端发票迁移示例
看到各组件协同工作,理解起来会更容易。
下面是一个刻意保持极简的内存级示例,基于全文贯穿的发票功能。它不包含真实数据库、反向代理、消息队列或部署平台,旨在集中展示迁移的控制流。
首先定义共享契约:
type InvoiceInput = {
orderId: string;
subtotal: number;
};
type Invoice = {
orderId: string;
total: number;
};
interface InvoiceGenerator {
generate(
input: InvoiceInput
): Promise<Invoice>;
}
旧版实现计算发票总额的方式如下:
class LegacyInvoiceGenerator
implements InvoiceGenerator {
async generate(
input: InvoiceInput
): Promise<Invoice> {
return {
orderId: input.orderId,
total: input.subtotal * 1.21,
};
}
}
假设我们已将该功能迁移到新实现中:
class MigratedInvoiceGenerator
implements InvoiceGenerator {
async generate(
input: InvoiceInput
): Promise<Invoice> {
const tax =
input.subtotal * 0.21;
return {
orderId: input.orderId,
total: input.subtotal + tax,
};
}
}
代码写法虽有不同,但预期行为保持一致。
接着,定义一个确定性的流量切换函数。示例中,我们将每个 orderId 分配给 0 到 99 之间的某个桶,以确保同一订单始终遵循同一路径:
function bucketFor(
value: string
): number {
const sum = [...value].reduce(
(total, char) =>
total + char.charCodeAt(0),
0
);
return sum % 100;
}
function shouldUseMigrated(
orderId: string,
percentage: number
): boolean {
return (
bucketFor(orderId) < percentage
);
}
当 percentage 设置为 10 时,大约 10% 的 ID 将被分配给迁移后的路径。
现在添加几个简单的内存指标:
const metrics = {
legacyRequests: 0,
migratedRequests: 0,
mismatches: 0,
};
然后将旧版和新版的实现放在同一个感知迁移状态的入口点之后:
class IncrementalInvoiceService {
migratedEnabled = true;
rolloutPercentage = 10;
constructor(
private readonly legacy:
InvoiceGenerator,
private readonly migrated:
InvoiceGenerator
) {}
async generate(
input: InvoiceInput
): Promise<Invoice> {
const legacyResult =
await this.legacy.generate(
structuredClone(input)
);
const migratedResult =
await this.migrated.generate(
structuredClone(input)
);
if (
migratedResult.orderId !==
legacyResult.orderId ||
migratedResult.total !==
legacyResult.total
) {
metrics.mismatches += 1;
}
const useMigrated =
this.migratedEnabled &&
shouldUseMigrated(
input.orderId,
this.rolloutPercentage
);
if (useMigrated) {
metrics.migratedRequests += 1;
return migratedResult;
}
metrics.legacyRequests += 1;
return legacyResult;
}
}
这个小服务融合了文章中的几个概念。
首先,它使用相同的输入运行两种实现并比较结果。由于该示例完全在内存中运行且没有外部副作用,这样做是安全的。
其次,它将一定比例的请求路由到迁移后的结果。
第三,它记录了多少请求使用了每条路径,以及发生了多少次行为不匹配。
你可以用几个请求来测试它:
const service =
new IncrementalInvoiceService(
new LegacyInvoiceGenerator(),
new MigratedInvoiceGenerator()
);
for (let i = 1; i <= 100; i++) {
await service.generate({
orderId: `order-${i}`,
subtotal: 1000,
});
}
console.log(metrics);
你可能会看到类似这样的输出:
legacyRequests: 89
migratedRequests: 11
mismatches: 0
由于分桶函数刻意设计得很简单,仅有 100 个输入时,实际比例未必正好是 90/10。重点在于路由是确定性的、可度量的,并且由 rolloutPercentage 控制。
如果迁移后的实现开始出现差异,mismatch 计数器会给你一个可观察的信号。
而如果你决定暂停灰度,回滚方式也很明确:
service.migratedEnabled = false;
从这一刻起,所有返回的响应又都来自旧实现。
这只是一个刻意简化的示例。生产系统还需要更强的路由能力、真实的指标采集、错误处理、持久化状态,以及对副作用的小心处理。
尤其是,如果生成发票会发送邮件、写入两个生产数据库、向客户扣款,或发布对外可见的事件,就不能盲目地同时执行两套实现。在这些场景下,影子路径需要借助录制适配器、隔离的基础设施,或其他机制,让你在不重复产生真实副作用的前提下完成行为对比。
但控制回路是一样的:
相同输入
↓
对比新旧实现的行为
↓
路由一小部分流量
↓
观察
↓
扩大范围或回滚
这就是增量迁移最小但可用的形态。
回滚要在需要之前就设计好
回滚机制不应该等到出事故时才临时设计。在切换流量之前,就要想清楚:如果迁移后的路径出问题会怎样。
对于路由层面的迁移,回滚可能非常简单:
migration flag = false
流量就会回到:
legacy implementation
例如:
if (
featureFlags.useNewInvoices
) {
return migrated.generate(
orderId
);
}
return legacy.generate(orderId);
一旦迁移后的路径行为异常,只需:
useNewInvoices = false
回滚几乎即时生效。
不过,回滚操作在以下情况下会变得更加复杂:
数据格式变更
写入了新数据
事件不一致
外部系统已更新
旧代码无法读取新记录
在这些场景中,回滚可能不仅仅是切换一个功能开关那么简单。你可能需要向后兼容的模式(schema),以便两个版本都能读取相同记录;针对外部副作用的补偿操作;可重放的事件;对账任务;或者让旧系统在短时间内继续消费新路径写入的数据。
对于风险较高的迁移,提前定义回滚边界也有助于解决问题。例如:在新 schema 版本写入之前,或发出特定外部事件之后,流量可以回切到旧系统,此时恢复需要补偿措施而非简单回滚。关键在于明确何时回滚仍具可逆性,何时已需切换至不同的恢复策略。
因此,回滚设计必须在部署之前完成。
将数据迁移视为独立问题
应用迁移与数据迁移虽有关联,但并非同一问题。
假设旧系统存储的是:
{
"customer_type": "P",
"status": 2
}
而新系统存储的是:
{
"customerType": "PREMIUM",
"status": "APPROVED"
}
此时你需要明确回答:
哪个数据库是权威数据源?
两个系统能否读取相同数据?
是在读取时转换数据吗?
是否批量迁移记录?
是否复制变更?
数据所有权何时转移?
这些决策必须明确。否则,应用迁移看似成功,数据边界却依然模糊不清。
谨慎对待双写
一种常见的过渡策略是:
写入旧数据库
+
写入新数据库
这被称为双写(dual writing),看起来简单直接。
例如:
await legacyOrders.save(order);
await newOrders.save(order);
但如果发生以下情况:
旧系统写入成功
新系统写入失败
两个系统的数据就不一致了。
或者:
旧系统写入失败
新系统写入成功
问题依旧。
双写引入了分布式一致性问题。
如果使用双写,你需要考虑:
重试
幂等性
对账
顺序
部分失败
监控
有时候更稳妥的做法是:
单一权威写入
↓
变更事件
↓
复制
或者使用事务性 outbox。
没有放之四海而皆准的方案。关键在于不要把双写视为简单的迁移技术。
明确数据归属
在双系统共存期间,数据归属权容易变得模糊。
举个例子:
旧系统写入客户数据
新系统写入发票数据
两个系统都读取订单数据
这种安排可能完全合理,但必须记录下来。
对于每个迁移的功能,需要定义:
系统记录源
写入所有者
读取方
复制方向
一致性预期
例如:
发票
写入所有者:
新系统
数据真相源:
新数据库
旧系统访问:
只读适配器
复制:
新 → 旧报表存储
这样架构就有了明确的流向。
如果没有明确的归属规则,迁移往往会制造出永久性的同步难题。
关注业务行为,而非仅仅盯着基础设施
在灰度发布期间,团队通常监控的是:
CPU
内存
延迟
HTTP 500 错误
数据库连接数
这些指标确实重要,但还不够。
假设:
HTTP 200 成功率 = 99.99%
同时:
发票总额计算错误
基础设施监控显示:
正常
但业务系统其实并不健康。
迁移可观测性必须包含领域信号。
例如:
处理订单量
授权支付量
生成发票量
折扣分布
续费失败数
平均发票总额
发布事件数
如果你了解正常的业务表现,异常波动就能暴露出技术指标抓不到的迁移缺陷。
判断迁移切片何时算作完成
流量达到 100% 并不意味着功能已完全迁移。
在宣布完成之前,我会核对以下事项:
100% 流量走新路径
错误率在可接受范围内
延迟在可接受范围内
行为差异已解决
副作用已验证
数据归属权已确立
回滚窗口已结束
移除旧系统调用方
停止旧系统写入
可观测性就绪
然后自问:
旧的实现还在承担任何职责吗?
如果没有,就删掉它。
让两套实现长期共存,只会带来:
额外的维护成本
理解混乱
重复出现的 bug
职责归属不清
未来的迁移债务
增量迁移的最终目标应该是简化系统,而不是让它永久性地重复。
移除旧路径
这一步经常被一拖再拖。
团队把流量切了过去,却把旧路径留在原地,理由是:
以防万一
几个月之后:
没人知道它是否还在被使用
删除之前,先确认:
路由指标显示流量为零
没有调用方依赖它
数据依赖已经解除
回滚观察期已结束
运维文档已更新
然后移除:
旧的实现
旧的功能开关
旧的数据库访问
不再使用的集成代码
临时的兼容层
删除本身就是迁移的一部分。
只增加新架构、不清理旧架构的迁移,往往会增加而不是降低系统复杂度。
如何在增量迁移中使用 AI
在这个过程中,AI 能帮上很多忙。
比如,它可以分析遗留代码库,帮你回答:
哪些模块实现了这个能力?
哪些调用方依赖它?
它涉及哪些数据库表?
它调用了哪些外部服务?
会产生哪些副作用?
已经存在哪些功能开关?
哪些路径需要适配器?
一个实用的 prompt 可以这样写:
分析 Generate Invoice 这个能力。
识别以下内容:
1. 入口点
2. 业务规则
3. 持久化依赖
4. 外部集成
5. 副作用
6. 调用方
7. 数据归属
8. 可能的迁移切分点
不要重新设计系统。
对每个结论都要给出证据,包括文件路径
和相关代码引用。
AI 还能帮助对比迁移前后的变更。
例如:
对比旧实现和迁移后的实现。
找出以下方面可能存在的行为差异:
- 返回值
- 错误处理
- 副作用
- 持久化
- 事件顺序
- 重试逻辑
- 幂等性
- 事务边界
不要默认新实现是正确的。
这很有用,因为迁移涉及大量重复性的分析工作,而 AI 可以显著加快这类分析。
别让 AI 把迁移变成重写
这是一种常见的失败模式。
当你这样提问时:
帮我迁移这个遗留功能。
模型往往回应如下:
新架构
新领域模型
新 API
新事件模型
新数据库模式
新校验层
新框架
此时,你不再是在迁移某个功能,而是在重新设计它。
虽然有时重新设计确有必要,但这应当是有意为之的结果。
在增量迁移过程中,我倾向于使用带有明确约束的提示词。
例如:
在无意改变可观察行为的前提下,迁移此功能。
保留:
- 输入,
- 输出,
- 错误处理,
- 副作用,
- 相关顺序,
- 事务性行为。
仅引入在目标环境中运行该功能所需的最小结构性变更。
列出任何你无法确信保留的行为。
这样能将变换范围收窄。
AI 应当用于减少机械性工作量,而不是悄悄扩大项目范围。
实用的增量迁移流程
以下是我会采用的流程。
1. 理解功能特性
识别以下要素:
输入
输出
规则
副作用
依赖项
未知项
2. 表征现有行为
使用以下测试保护关键行为:
表征测试
集成测试
契约测试
3. 为迁移重构代码
创建以下结构,且有意不改变行为:
接缝
适配器
显式依赖
清晰编排
4. 构建新实现
在目标环境中实现该功能,保持其可观察契约清晰。
5. 对旧新实现进行差异测试
使用代表性案例对比:
输出
错误
副作用
业务状态
6. 引入显式路由
允许请求通过可观察的迁移策略选择:
遗留路径
或
迁移路径
7. 从安全流量开始
使用:
内部用户
测试租户
选定客户
8. 逐步增加流量
例如按以下比例递增:
1%
5%
10%
25%
50%
100%
仅当证据支持进入下一阶段时,才继续扩大比例。
9. 监控技术与业务指标
同时观察以下两方面:
系统健康度
业务行为
10. 保留回滚能力
确保能快速、清晰地切回传统路径。
11. 转移数据所有权
明确界定各系统负责的数据操作:
写入
读取
复制
12. 移除传统路径
在迁移稳定后:
删除旧实现
移除临时路由
移除过时依赖
然后选择下一个待处理的功能模块。
增量迁移解决不了的问题
增量迁移降低了风险,但并未消除复杂性。
你可能仍需处理:
分布式事务
共享数据库
旧数据库结构
紧密耦合
不支持的运行时环境
低测试覆盖率
组织归属不明
监管合规约束
此外,有些系统进行部分迁移极其困难。
例如:
强状态系统
强耦合的桌面应用
大型事务批处理系统
依赖共享全局状态的系统
有时,迁移的边界需要划定得更大。
但核心原则不变:
做出最小且可逆的变更,以产生有效的迁移进展。
增量不一定指微小,而是指可控。
完整的遗留系统现代化工作流
本文总结了本系列文章一直在构建的完整工作流。
我们从这样一个基础问题出发:
如何在现代改造遗留应用时,避免不小心将其变成一次重写项目?
第一步是理解。
遗留系统
↓
调查
↓
映射行为与依赖
接着是特征化测试。
观察到的行为
↓
测试用例
↓
行为安全网
然后是重构。
纠缠的功能模块
↓
接缝与边界
↓
利于迁移的结构
之后是差异化测试。
旧实现
+
新实现
↓
行为比对
最后执行增量迁移。
理解
↓
特征化
↓
重构
↓
迁移
↓
比对
↓
路由
↓
观测
↓
扩展
↓
移除遗留代码
顺序至关重要。
如果跳过理解环节,你可能会迁移错误的行为逻辑。
如果跳过特征测试,你可能察觉不到行为的变化。
如果跳过重构,迁移边界可能会一直太大。
如果跳过对比,差异就会被掩盖。
如果跳过灰度发布,问题只会在全面铺开后才暴露。
每一步都在化解一种不同的不确定性。
结语
现代化改造遗留应用,并不需要一次性全盘替换。
很多情况下,更稳妥的策略是设计一条路径,让新旧实现可以暂时共存。
迁移一个能力,然后对比验证。
导一小部分流量过去,观察运行情况。
有证据支撑就逐步加大流量,否则就回滚。
明确交接归属,然后移除旧路径。
如此循环往复。
完整的工作流如下:
理解
↓
特征测试
↓
重构
↓
增量迁移
↓
对比行为
↓
逐步导流
↓
观察
↓
移除旧代码
AI 可以加速每一个环节。
它能帮你梳理代码、识别依赖、生成适配器、对比实现、分析故障,以及审查迁移 diff。
但速度快不等于有把握。
关键决策仍然需要工程师的判断:
哪些行为是重要的?
哪些可以改变?
哪些必须保持兼容?
迁移边界在哪里?
多少证据才算够?
什么时候需要回滚?
什么时候可以移除旧路径?
这些问题不是靠生成代码就能回答的,它们属于迁移决策。
这也正是本系列文章想传达的核心。
AI 让写新代码越来越便宜,但遗留系统现代化并不会因此变得轻而易举——它只是让围绕代码做出的决策质量变得更加重要。
因为最安全的迁移,往往不是改动最多的那个方案,而是让你在改变系统的同时,始终清楚改了什么、为什么改、以及是否可以放心继续往前走。