如何逐步迁移传统的单体应用系统,而无需进行大规模的重新开发
大多数传统的迁移项目在最终切换完成之前就会失败。 这种失败通常源于将迁移过程视为一个单一的步骤来执行:迁移应用程序、数据库,转移所有用户数据,调整流量分配,然后关闭旧系统。 这种做法隐含了一个危险的假设:即旧系统和新系统必须同时被替换掉。 但实际上,这种情况很少会发生。 如果你已经了解了旧系统的运行机制,可以通过编写测试用例来保护这些现有功能,设定适合迁移的边界条件,并对比新旧系统的实现方式,那么你就有了另一种选择。 你可以一次只迁移一个功能模块。这样就能彻底改变原有的迁移方案。 旧式单体系统 ↓ 全面重构 ↓ 一次性切换完成 而你现在可以选择的做法是: 旧式单体系统 ↓ 提取出一个功能模块进
大多数传统的迁移项目在最终切换完成之前就会失败。
这种失败通常源于将迁移过程视为一个单一的步骤来执行:迁移应用程序、数据库,转移所有用户数据,调整流量分配,然后关闭旧系统。
这种做法隐含了一个危险的假设:即旧系统和新系统必须同时被替换掉。
但实际上,这种情况很少会发生。
如果你已经了解了旧系统的运行机制,可以通过编写测试用例来保护这些现有功能,设定适合迁移的边界条件,并对比新旧系统的实现方式,那么你就有了另一种选择。
你可以一次只迁移一个功能模块。这样就能彻底改变原有的迁移方案。
旧式单体系统
↓
全面重构
↓
一次性切换完成
而你现在可以选择的做法是:
旧式单体系统
↓
提取出一个功能模块进行迁移
↓
只转移一小部分流量
↓>观察迁移效果
↓>逐步扩展迁移范围
↓>重复上述步骤
我们的目标并不是让迁移过程变得更慢,而是要让每一次变更都变得可控、可观察,并且可以随时逆转。
在本教程中,我将向你展示如何通过以下步骤逐步迁移旧式单体系统:
选择第一个适合进行迁移的功能模块
明确旧代码与新代码之间的边界
设计请求路由规则,使请求在新旧系统之间正确传递
采用“逐步替换”策略来推进迁移工作
按照业务功能而非技术层次来进行迁移
逐步增加迁移流量
在完全切换之前及时发现并处理可能出现的故障
设计好回退方案
TypeScript或类似的语言
API接口及服务边界定义
集成测试的相关知识
依赖注入机制
路由规则与反向代理技术
数据库事务处理
逐步重构的开发方法
旧系统的现代化改造流程
该功能模块的输入参数是什么
它的输出结果有哪些
它所遵循的重要业务规则是什么
它依赖于哪些其他系统或组件
让旧系统和新系统同时运行,以便进行观察和调试
谨慎处理数据的所有权问题
彻底移除那些已经迁移但仍然存在的旧系统功能
合理运用人工智能技术,但不要让逐步迁移的过程变成自动化的全面重构
这些示例中使用了TypeScript语言,但这种迁移方法适用于大多数编程语言、运行环境以及架构。
我们的目标很简单:就是把迁移过程变成一系列可控的变更步骤,而不是一个不可逆的操作。
先决条件
要想顺利完成这些迁移工作,你需要掌握以下内容:
系统可观测性的相关概念
此外,你还需要充分了解自己想要迁移的那个功能模块的运行机制。
理想情况下,你应该知道以下内容:
它可能会产生哪些副作用
它与其他外部系统之间有什么接口约定
同时,你也需要知道如何检测这些功能模块在迁移前后行为上的变化。
如果你还没有达到那个阶段,那么现在进行迁移可能还为时过早。
目录
为什么大规模迁移会如此危险
想象一个传统的商业应用程序。
它包含以下组件:
客户信息
订单管理
支付系统
库存管理
运输流程
开票功能
通知机制
报告系统
现代化改造计划要求:
替换这个单体应用程序
这听起来像是一个简单的项目,但实际上,在操作层面,可能意味着需要同时更改以下内容:
运行环境
开发框架
数据库
部署模型
API接口
认证机制
网络连接
监控系统
数据模型
业务逻辑
外部集成模块
如果最终迁移失败,导致问题的原因可能会非常多。
例如:
价格设置是否发生了变化?
数据库迁移过程中是否丢失了数据?
支付服务是否出现了故障?
认证机制是否发生了异常?
新的运行环境是否改变了日期处理方式?
超时时间是否变短了?
某些事件是否不再被正常记录?
新的部署配置是否出了问题?
这就是大规模迁移所面临的核心问题:太多变量会同时发生变化。
渐进式迁移的方法旨在减少每一步中需要更改的变量数量。
应该按“迁移片段”来考虑迁移方案,而不是整个应用程序
不要问:“我们该如何迁移这个单体应用程序?”
我们应该先思考:
哪些是最基本、最独立的功能模块,可以首先进行迁移?
例如:
计算订单总金额 生成发票 发送订单确认信息 创建运输安排 续订服务 审核客户信息一个理想的“迁移片段”应该具备以下特点:
明确的输入参数 明确的输出结果 已知的副作用 明确的依赖关系 可观察的行为表现 可行的回退方案这样,我们就能明确知道哪些内容是可以进行迁移的。
例如:
输入参数: 订单编号 处理流程: 加载订单信息 计算税费 生成发票编号 创建发票文件 副作用: 将生成的发票保存到数据库 发布发票创建成功的通知 输出结果: 发票文件这样的拆分方式显然比直接迁移“计费模块”或“src/services/”这类抽象目录要容易得多。
业务功能模块才是更合适的迁移单位,而不是文件夹结构。
谨慎选择第一个要迁移的功能模块
第一个迁移片段至关重要。
通常情况下,我会避免从系统中最关键的功能模块开始进行迁移。
选择一个既有实际意义、又不会因为迁移错误而导致严重后果的功能模块,才是明智的选择。
一个合适的初始迁移目标通常具备以下特点:
流量适中 外部依赖较少 行为稳定 测试覆盖度良好 事务边界较少 影响范围有限例如:
生成客户对账单可能比以下功能更适合作为初始迁移目标:
路由机制 部署流程 可观测性 回滚机制 数据访问方式 测试流程 团队协作效率只有在这些方面都确认无误后,才能将这种迁移模式应用到更关键的功能上。
在旧系统与新系统之间建立界限
假设某个旧应用程序具有以下结构:
async function generateInvoice( orderId: string ) { // 旧版本的实现代码 }在进行迁移之前,需要为这个功能建立一个界限:
interface InvoiceGenerator { generate( orderId: string ): Promise<Invoice>; }这样一来,旧版本的实现代码就会变成这样:
class LegacyInvoiceGenerator implements InvoiceGenerator { async generate( orderId: string ): Promise<Invoice>> { // 保留原有的功能实现 } }而新版本的实现代码则如下:
class NewInvoiceGenerator implements InvoiceGenerator { async generate( orderId: string ): Promise<Invoice>> { // 新增的功能实现 } }现在,调用者无需关心究竟是哪个版本在运行。
这种设计方式实现了一个非常重要的功能:
使用“绞杀者图模式”进行渐进式替换描述渐进式替换的一种常用方法是“绞杀者图模式”。
与其一次性替换整个应用程序,不如让新功能逐渐在旧系统的基础上发展起来。
从概念上来说:
接收到的请求 │ ↓ 路由器 / \ / \ 旧系统路径 新系统路径最初时:
旧系统占比:100% 新系统占比:0%后来:
旧系统占比:95% 新系统占比:5%再后来:
旧系统占比:50% 新系统占比:50%最终:
旧系统占比:0% 新系统占比:100%此时,就可以删除旧系统的相应功能实现了。
关键在于替换过程必须循序渐进。在新的功能逐渐取代旧功能的过程中,旧系统依然可以继续为系统的部分功能提供支持。
迁移功能,而非技术层
一种常见的迁移策略是:
先迁移数据库, 再迁移服务,然后是API,最后才是用户界面。这种做法会导致在一段时间内,所有功能同时存在于旧架构和新架构中。
例如:
新API ↓ 旧版服务 ↓ 新数据库 ↓ 旧版事件发布机制有时这种情况是不可避免的。
但只要有可能,我更倾向于采用“垂直切分”的方法。
所谓“垂直切分”,就是将某个功能作为一个完整的单元进行迁移。
比如,“生成发票”这个功能就可以作为一个独立的单元进行迁移;而“创建发货单”这个功能则可以单独迁移;同样,“续订订阅”也可以单独处理。
这样就能让新的功能更快地投入使用,同时也能减少临时出现的跨系统依赖关系。
让旧版本和新版本同时运行
在渐进式迁移过程中,新旧版本共存是正常的。
在一段时间内,你可能会遇到这样的情况:
旧版发票生成器 新版发票生成器这两个版本会同时被部署出来。
这种设计并非偶然,而是迁移策略的一部分。
关键的问题在于:如何让请求在旧版本和新版本之间进行选择。
你可以使用以下方法来实现这一目标:
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 legacygenerate( request.orderId );现在你可以对这些指标进行测量了:通过旧路径处理的请求数量 通过新路径处理的请求数量 迁移过程中出现的失败案例 备用方案的使用次数 延迟情况 业务运营结果迁移过程应该被视作一种系统级行为,因此必须能够对其进行监控。先从内部流量或低风险流量开始测试
在将大量客户流量转向新路径之前,可以先从那些风险较低的流量开始进行测试。例如:开发环境 测试环境 内部用户 员工账户 测试租户 特定的低风险客户群体这样就可以在较低的风险范围内验证以下内容:系统部署情况 路由机制的正确性 系统的可监控性 数据访问功能 外部接口的集成效果 错误处理机制的表现完成这些验证后,再逐步扩大测试范围。例如:内部用户 ↓ 1% 的生产环境流量 ↓ 5% ↓ 10% ↓ 25% ↓ 50% ↓ 100%具体的比例并不重要,关键是要遵循这种逐步扩展的原则。每次扩大测试范围时,都应当确保之前的测试已经提供了足够的证据来支持这一决策。逐步增加生产环境流量
假设你每天有10,000笔发票处理请求。不要一次性将所有请求都切换到新路径,而应该先只让1%的请求通过新路径进行处理。这样每天实际上只有100笔请求会经过新路径。接下来就可以对这些指标进行监控了:在部署前后使用差异测试本系列文章中的上一篇文章专门讨论了差异测试技术,而在本次迁移过程中,这种技术就显得尤为重要。差异测试就是使用相同的输入数据来运行旧版本和新版本的系统,然后比较它们产生的输出结果。根据需要,这些输出结果可以包括返回值、错误信息、状态变化以及各种副作用等。目标并不是要证明这些实现方式在内部是完全相同的,而是要在这些差异影响到所有生产环境中的流量之前,发现它们所导致的实际行为差异。
在正式开始路由测试之前,你可以进行如下对比:
相同输入 ↓ 旧版本的结果 相同输入 ↓ 新版本的结果在推广新版本的过程中,你也可以选取部分真实流量来进行对比分析,只要这样做不会带来风险即可。
例如:
真实请求 │ ├────→ 新版本实现方式 │ └────→ 旧版本实现方式然后进行如下对比:
输出结果 错误情况 副作用 业务状态这样,在增加流量之前,你就能获得这些数据作为参考依据。
据此,你可以做出是否继续推广新版本的决策:
一个简单的端到端发票迁移示例当这些组件被组合在一起运行时,它们各自的功能就更容易被理解了。
下面是一个故意设计得比较简单的内存演示示例,它基于我们在整篇文章中一直在使用的发票处理功能。这个示例没有包含真实的数据库、反向代理、队列或部署平台,其目的只是为了集中展示整个迁移流程。
首先来看一个共同的接口定义:
type InvoiceInput = { orderId: string; subtotal: number; }; type Invoice = { orderId: string; total: number; }; interface InvoiceGenerator { generate( input: InvoiceInput ): Promise; } 旧版本的实现方式是这样计算发票总金额的:
class LegacyInvoiceGenerator implements InvoiceGenerator { async generate( input: InvoiceInput ): Promise{ return { orderId: input.orderId, total: input.subtotal * 1.21, }; } } 现在假设我们已将这一功能迁移到了新版本中:
class MigratedInvoiceGenerator implements InvoiceGenerator { async generate( input: InvoiceInput ): Promise{ 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现在,让我们添加一些用于记录内存中数据变化的小型指标: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{ 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来控制。如果新的实现方式开始产生差异,那么“不匹配次数”这个指标就能及时反映出这一问题。而且,如果你决定停止这种逐步推广的方式,也可以直接进行回退操作:service.migratedEnabled = false;从那一刻起,所有返回的响应都将再次来自旧的实现方式。<这个例子被刻意简化了。在实际的生产系统中,需要更完善的路由机制、真实的性能指标、错误处理机制、持久化的状态管理机制,以及针对各种副作用的妥善处理措施。特别是,在生成发票、发送电子邮件、写入两个生产数据库、向客户收费或发布外部可见的事件时,你不应该盲目地同时执行这两种实现方式。在这些情况下,需要使用记录适配器、隔离的基础设施或其他机制来比较两种方案的行为,而无需重复产生实际的效果。
但控制流程是相同的:
相同输入 ↓ 比较旧版本与新版本的行为 ↓ 将少量流量路由到新版本 ↓>观察结果 ↓>根据需要扩展或回滚到旧版本这就是最小化风险、逐步推进的迁移方案。
在真正需要之前就设计好回滚机制
在出现问题时才去设计回滚方案是不对的。在将流量切换到新版本之前,应该先考虑如果新版本出现故障会带来什么后果。
对于仅在路由层面进行的迁移来说,回滚操作可能非常简单:
将迁移标志设置为false这样流量就会自动返回到旧版本:
使用旧版本的实现方式例如:
if ( featureFlags.useNewInvoices ) { return migrated.generate(orderId); } return legacy.generate(orderId);如果新版本出现故障,只需将迁移标志重新设置为false,流量就会立即返回到旧版本。
但是,当出现以下情况时,回滚操作会变得复杂得多:
数据格式发生变化 有新的数据被写入数据库 事件的处理方式有所不同 外部系统也发生了变化 旧版本的代码无法读取新版本生成的记录在这些情况下,回滚可能不仅仅涉及简单地切换一个标志。你可能需要设计出向后兼容的数据结构,以确保两个版本都能读取相同的记录;同时还需要采取补偿措施来处理外部系统的副作用,或者设计可重放的事件处理流程、数据同步机制,甚至需要让旧系统在一段时间内仍能继续接收新版本生成的数据。
对于风险较高的迁移项目来说,提前确定回滚的边界也是非常重要的。例如,可以规定在新的数据结构版本编写完成之前,或者在某个外部事件发生之后,流量才会切换到新版本;在这种情况下,恢复数据可能需要采取复杂的补偿措施,而不仅仅是简单的回滚操作。关键在于要清楚知道何时还可以进行回滚,以及何时必须采用其他的恢复策略。
因此,回滚机制的设计必须在部署之前就完成。
将数据迁移视为一个独立的问题来处理
应用程序的迁移与数据的迁移虽然密切相关,但它们实际上属于不同的问题。
假设旧系统存储的数据结构如下:
{ "customer_type": "P", "status": 2 }而新系统存储的数据结构则是:
{ "customerType": "PREMIUM", "status": "APPROVED" }那么现在你需要考虑的是:如何才能使这两个系统之间进行顺利的数据迁移……
哪个数据库才是权威数据源? 这两个系统能否读取相同的数据? 在读取数据时是否需要对数据进行转换处理? 是否需要分批迁移记录? 是否需要复制所做的更改? 所有权的变更究竟发生在什么时候?这些决策必须明确无误。否则,虽然应用程序迁移看起来似乎成功了,但数据的一致性问题依然存在。
谨慎使用双重写入机制
一种常见的迁移策略是:
先向旧数据库写入数据 + 再向新数据库写入数据这种做法被称为“双重写入”,看起来很简单。
例如:
await legacyOrders.save(order); await newOrders.save(order);但是,如果出现以下情况会怎样呢?
旧数据库的写入操作成功了 新数据库的写入操作失败了此时,两个系统之间的数据就会不一致。
或者:
旧数据库的写入操作失败了 新数据库的写入操作成功了结果仍然是一样的问题。
双重写入机制会引发分布式一致性问题。
如果使用这种机制,就需要考虑以下因素:
重试机制 幂等性 数据协调 数据写入顺序 部分失败情况 监控机制有时候,更安全的方法是:
明确数据的归属权在系统共存期间,数据的归属问题很容易引发混淆。
举个例子:
数据记录系统 数据写入者 数据读取者 数据复制方向 对数据一致性的要求例如:
数据复制方向: 从新数据库复制到旧数据库的报表存储系统中这样,整个架构就有了明确的方向。
如果没有明确的归属规则,迁移操作往往会引发长期存在的数据同步问题。
关注业务行为,而不仅仅是基础设施状况
在系统部署过程中,开发团队通常会监控以下指标:
HTTP 200响应成功率达到了99.99%然而:
已处理的订单数量 已授权的付款金额 生成的发票总数 折扣分配情况 未成功的续费操作 平均每张发票的金额 发布的各类事件信息如果你了解正常的业务运行模式,那么那些异常的变化就能揭示出技术指标所无法发现的迁移缺陷。
了解迁移阶段何时完成
仅仅因为流量达到了100%,并不意味着某个功能已经完全完成了迁移。
在宣布迁移完成之前,我通常会进行以下验证:
新路径上的流量是否达到100%? 错误率是否在可接受范围内? 延迟是否在可接受范围内? 各种行为差异是否已经得到解决? 是否存在任何副作用? 数据所有权问题是否已经明确? 回滚机制是否已经准备就绪? 旧的调用代码是否已经被移除? 旧的写入操作是否已经停止? 系统的可观测性是否已经得到保障?完成这些验证后,还需要进一步思考:
旧的实现方式是否仍然具有实际用途?
如果没有用途,就应该将其移除。
如果让两种实现方式同时处于活跃状态,将会导致以下问题:
维护成本增加 造成混乱 出现重复的错误 数据所有权不明确 未来还会带来更多的迁移难题增量迁移的目的应该是简化系统结构,而不是让系统变得更加复杂。
移除旧的路径
这个步骤往往会被推迟。
开发团队虽然将流量切换到了新路径上,但仍然保留着旧路径:
以防万一……几个月后:
路由指标显示该路径上没有流量 没有任何代码依赖于它 数据依赖关系已经被清除 回滚机制已经准备就绪 操作文档也已经更新确认无误后,就可以将其删除:
在增量迁移过程中如何利用人工智能人工智能可以帮助完成这个过程中的许多环节。
例如,它可以分析旧的代码库,并帮助回答以下问题:
分析“生成发票”这一功能。 需要确定以下内容: 1. 入口点 2. 业务规则 3> 数据持久化相关依赖关系 4> 外部集成接口 5> 可能产生的副作用 6> 相关调用代码 7> 数据所有权问题 8> 需要处理的迁移难点 请不要重新设计整个系统。 对于每一项发现的结果,都需要用文件路径和相关代码引用作为证据。人工智能还可以帮助比较迁移过程中所做的各种变更。
例如……
比较旧版本与迁移后的新实现之间的差异。 需要重点关注以下方面可能存在的行为差异: - 返回值; - 错误处理方式; - 副作用; - 数据持久化机制; - 事件执行顺序; - 重试策略; - 函数的幂等性; - 事务边界的处理方式。 切勿假设新实现一定是正确的。这一点非常有用,因为迁移过程往往涉及大量的重复性分析工作,而人工智能能够显著加快这些分析的效率。
不要让人工智能把迁移过程变成一次彻底的重设计
有一种常见的错误模式会发生在这种过程中。
当你提出这样的请求时:
“请帮我将这个旧功能迁移到新系统中。”
模型给出的回应往往是:
新的架构 新的领域模型 新的API 新的事件处理机制 新的数据库结构 新的验证流程 新的开发框架到了这个阶段,你其实已经不是在迁移一个功能,而是在对它进行彻底的重设计。
有时候重设计确实是必要的,但这种重设计必须是经过深思熟虑后做出的。
在进行渐进式迁移时,我更倾向于使用那些带有明确约束条件的提示语句。
在迁移这个功能的过程中,千万不要故意改变它的可观察行为。 必须保留以下内容: - 输入数据 - 输出结果 - 错误信息 - 任何副作用 - 在适用的情况下,保持原有的执行顺序 - 事务处理机制 只引入那些为了使该功能能在目标环境中正常运行而必需的结构变更。 如果有些行为无法被保留下来,请明确列出。这样的要求能够确保迁移过程的范围始终保持在可控的范围内。
人工智能应该用来帮助减少人工工作量,而不是悄悄地扩大项目规模。
实用的渐进式迁移工作流程
以下是我会采用的工作流程。
1. 了解该功能的具体需求
需要明确以下几点:
输入数据 输出结果 规则设定 可能产生的副作用 依赖关系 尚未明确的因素2. 分析现有功能的行为特征
为了保护那些重要的行为特性,需要采取以下措施:
编写特征测试用例 进行集成测试 执行契约测试3. 为迁移做准备并进行代码重构
需要创建以下内容:
过渡方案 适配器模块 明确的依赖关系说明 清晰的工作流程安排在整个重构过程中,绝对不能故意改变该功能的行为特性。4. 构建新的实现方案
在目标环境中实现该功能,同时确保其可观察行为仍然符合预期要求。
5>对新旧版本进行差异测试
通过一些典型的案例来比较新旧版本之间的差异:
输出结果 错误信息 副作用 系统状态6>引入明确的路由机制
让用户能够根据需求选择使用旧版本还是新版本:
旧版本 或 新版本通过明确的迁移策略来引导用户的选择。7>从小规模测试开始
可以先让以下用户群使用新系统进行测试:
8>逐步增加使用规模例如:
1% 5% 10% 25% 50% 100%只有当有足够的证据支持下一步行动时,才能继续进行。
9. 监控技术指标与业务指标
需要同时关注以下两个方面:
系统健康状况 业务运行表现10. 确保可回滚功能可用
必须确保能够快速且顺利地恢复到旧的系统状态。
11. 明确数据的所有权归属
需要明确指定哪些系统拥有以下权限:
12. 删除旧的系统路径在迁移过程稳定后,需要:
增量迁移无法解决的问题增量迁移确实可以降低风险,但它并不能消除系统的复杂性。
你可能仍然需要处理以下问题:
状态信息复杂的系统 耦合度很高的桌面应用程序 大型事务处理系统 具有全局共享状态的系统有时,迁移的范围需要进一步扩大。
但基本原则始终不变:
进行最小范围的、可逆的修改,从而取得实际的迁移进展。
“增量”并不一定意味着修改量很小,而是指修改过程是受控的。
完整的旧系统现代化工作流程
本文总结了我们在这一系列文章中讨论的工作流程。
我们最初面临的是这样一个基本问题:
如何对旧系统进行现代化改造,同时又避免让项目变成彻底的重写工作?
第一步是深入了解系统的现状。
观察系统的实际运行表现 ↓ 进行各种测试 ↓>确认系统的稳定性然后对系统代码进行重构。
旧系统实现代码 + 新系统实现代码 ↓ 比较两者的运行行为最后就是进行增量迁移了。
结论对旧应用程序进行现代化改造并不需要一次性替换所有内容。
在很多情况下,更安全的策略是创建一条路径,让旧版本和新版本的代码能够暂时共存。
先实现一项功能,然后再进行对比测试。
先引导少量流量进行测试,观察系统表现如何。
当有足够的数据支持时再增加流量;如果发现问题,则立即回退到旧版本。
明确划分责任范围,之后再逐步淘汰旧版本代码。
重复这个过程即可。
理解 → 特性分析 → 代码重构 → 渐进式迁移 → 行为对比 → 逐步增加流量 → 观察结果 → 删除旧版本代码人工智能可以让每个阶段的工作效率都得到提升。
它可以帮助绘制代码结构图、识别依赖关系、生成适配器、比较不同实现方案、分析故障原因,并检查迁移过程中产生的差异。
但速度并不等同于可靠性。
重要的决策仍然需要依靠人类的工程判断力:
哪些行为是关键?哪些部分可以修改?哪些内容必须保持兼容性?迁移的边界应该如何确定?什么样的证据才足够?何时需要回退到旧版本?何时可以彻底淘汰旧代码?这些都不是通过代码生成就能解决的问题,而是需要人为做出的迁移决策。
而这正是整个系列内容所要传达的核心思想。
虽然人工智能让编写新代码的成本大大降低,但这也并不意味着对旧系统的现代化改造会变得简单易行。相反,这使得在代码相关决策中的质量变得更加重要。
因为最安全的迁移方案往往不是那些会对系统造成最大改变的方案,而是那些能够在确保系统稳定性的前提下,让你逐步推进改革过程的方案。
相关文章
在迁移之前如何对现有的旧应用程序进行重构
一旦一个团队决定迁移现有的旧应用程序,通常就会面临立即开始迁移代码的压力。 需要迁移数据库、API或用户界面,或者将整个应用程序迁移到新的框架、运行环境、云服务提供商或架构中。 这听起来似乎合情合理,但实际上存在一个问题。 如果当前的系统将业务规则、数据持久化机制、基础设施、外部集成功能以及各种协调逻辑都混合在同一个模块中,那么迁移工作就会变得比实际情况要困难得多。 你并不是在仅仅迁移软件本身,而是在试图迁移那些在多年开发过程中逐渐纠缠在一起的各种组件和功能。 正因如此,我通常更倾向于在迁移之前先对代码进行重构。这样做的目的并不是为了让旧系统看起来更加美观,也不是要彻底重新设计整个系统,更不是
阅读全文
文章:构建安全且可扩展的面部识别系统
当有三千名员工同时进行身份验证时,同步API调用会导致系统崩溃。本文介绍了一种适用于大规模人脸识别系统的四层架构:客户端过滤功能可使云服务成本降低30%;检测与验证过程的分离使得系统扩展能力提升10倍;基于风险的动态阈值设置有助于更好地控制系统性能;而零信任隐私保护机制,结合用户同意机制及自动化数据清理功能,能够有效遵守GDPR和HIPAA等法规要求。 作者:Praveen Kumar Gopalakrishnan
阅读全文
如何利用功能标志来实现安全、渐进式的功能推出
功能开关是团队在部署过程中最强大的工具之一。它们将 部署 与 发布 分离开来,这意味着你的持续集成/持续交付流程可以在每次代码合并时都将更新推送到生产服务器上,但只有当你明确启用这些功能开关时,用户才会看到新的变化。 “部署”是一个技术性操作,而“发布”则是一项产品决策。正是这种分离机制,使得本文中讨论的诸多内容成为可能。 然而,如果实现不当,功能开关反而会带来技术债务、测试难题以及运行时的复杂性。在这篇文章中,你将学习到实施功能开关的核心方法——从简单的布尔值切换,到基于百分比的比例化部署方案,再到针对不同用户群体的功能启用策略,同时还会了解相关的生命周期管理方法及应避免的错误做法。以下就是
阅读全文
在旧系统迁移过程中如何使用差异测试方法
在旧系统迁移过程中,最危险的时刻并不一定是在开始编写新实现代码的时候,而是在新实现看起来已经完成的时候。 代码编译通过了,测试也通过了,架构也更加清晰了,新的服务响应速度也更快了…… 然后所有人都会开始问同一个问题: 我们现在可以把流量切换过来吗? 这时,信心就会变得难以建立。 一个新的实现虽然能够通过自己的测试套件,但其行为仍可能与它所要替代的系统有所不同。 也许数值的舍入规则发生了变化,或者对空值的处理方式不同了,又或许某个错误现在被当作成功的响应来处理了…… 也许记录的排序方式改变了,某些副作用出现的顺序也变了,又或许在迁移过程中,一些从未被记录下来的业务规则丢失了…… 正因如此,在进行
阅读全文