为什么资金问题其实并不是真正的资金问题
2021 年 5 月,我和我在 Paycor(一家人力资本管理平台,现已被 Paychex 收购)的团队做了一个决定,那个决定最终塑造了我们平台未来五年的发展方向。我们停止了对单体架构的改动。
当时共有三个单体应用:规模庞大、支持多租户,并且与我们负责的人力资本管理(HCM)产品的业务边界深度交织。我们早就想把它们拆分了。我们研究过扼杀者模式,也起草过一份为期多年的迁移计划。
但这些计划始终未能获得资金支持。每个季度,迁移提案都要与能带来收入的功能需求竞争,结果总是败下阵来。
于是我们尝试了另一种做法。不再单独做项目,而是制定了一条简单的规则:每个新功能、每个 Bug 修复、每次功能增强——任何原本会涉及单体应用的改动,现在都要拆分出来,创建一个新的领域服务。
这种迁移将作为常规路线图工作的副产品自然发生,所需经费与其他所有工作共用同一预算。这是一种基于拉动机制的迁移。
成本是切实存在的。在单体架构中原本只需要四个小时的变更,现在需要六个小时。我们将这部分成本分摊到数百个用户故事中,而不是试图将其打包成一项极有可能无法获批的资本支出申请。
目前,我们在 Azure 上运行着 120 多个领域微服务,整个迁移过程实现了零停机。那三个单体应用仍然在运行,仅承载着无人需要改动的极小一部分功能,其余所有业务都已经完成迁移。
本文将详细介绍该方法的实际运转情况:支撑这一过程的平台投资、我们被迫做出的架构选择、确保成本可控的成本优化措施,以及如果今天重新开始,我会采取哪些不同的做法。
解锁三大平台,让基于拉动的迁移成为可能
基于拉动的迁移——即每次产品驱动的代码变更都会独立拆分出一个服务,而不是让某个独立的程序按照自己的时间表进行迁移——从原理上讲似乎很简单。但在实践中,这取决于三项平台投资。因为我们刚开始时并不具备这三项条件,所以在最初大约一个季度的时间里,迁移过程相当艰难。一旦这三项条件都到位,迁移工作便变得顺理成章。
Azure 上的按域订阅
首次投资是结构性的。我们采用了“每个产品领域一个订阅”的模式。考勤系统有一个独立的订阅,薪资系统有一个独立的订阅,报表、排班、集成等功能也各自拥有独立的订阅。
这在纸面上听起来有些繁琐。但在实际应用中,它为我们带来了三项超出预期的关键收益。
首先是成本归属。考勤订阅的账单每月都会精确地显示该领域在云资源上的具体支出。我们不会将共享云成本在不同的领域之间进行分摊。每个团队都能看到自己的账单,并对此负责。
这种架构还实现了影响范围隔离。无论是配置错误、脚本失控,还是过于严格的 IAM 新策略,所有问题都局限在该域的订阅范围内。在过去的五年中,我们从未发生过因配置变更导致的多域服务中断事件。
此外,还有第三个较为隐性的效果:团队责任感。当工程师能够看到自己的资源消耗和平台占用情况时,他们在代码审查中会做出不同的决策。虽然这是一个隐性的结果,但已经在数据中体现出来了。
是部署模板让订阅模式真正变得可用。当 DevOps 团队部署新服务时,该模板包含 Azure Kubernetes Service(AKS) 命名空间、数据库、Service Bus 和 Event Hub 配置、Key Vault、可观测性钩子以及网络配置。过去需要通过工单进行处理的、耗时两到四周的部署工作,如今已缩短为几分钟的自助服务。
这是此次迁移带来的最大的行为变化。如果搭建新服务需要三周时间,工程师们就绝不会为此支付 50% 的前期成本;但如果新服务只需十分钟就能上线,他们就会愿意支付这笔钱。
APIM 作为路由衔接层
第二项投资是运维层面的。我们在预计要迁移的每个端点前都部署了 API 管理(APIM)。
单体架构的端点和新的域服务都可以通过同一路径向 APIM 注册。流量分流由部署配置决定,而非客户端变更。我们可以先将 1% 的流量导向新服务,观察一天,然后再逐步增加比例。
客户端完全不知道正在进行的切换。移动应用毫不知情,合作伙伴的公共 API 调用者也毫不知情。APIM 负责管理接口规范,新服务则实现了该规范。流量在我们指定的时间点顺利完成了切换。
通过 Azure 应用配置实现功能开关,并使用 Redis 缓存封装器
第三项投资属于战术性投资。我们需要一个生产环境可以信赖的功能开关层。
Azure App Configuration 具备满足此需求的语义机制。但在我们的流量规模下,每次请求都调用该功能会带来高昂的成本。因此,我们构建了一个轻量级封装层,将值缓存到 Redis 中,并设置了比较短的 TTL 以及显式的失效触发机制。从应用程序的角度来看,功能开关检查等同于一次 Redis 查询,这几乎不产生任何成本。
该封装层主要承担两项任务。第一项是渐进式发布:先为 1% 的租户开启某个功能开关,观察指标,再逐步扩展。第二项是近乎瞬时的回滚。如果某个版本出现异常,在 App Configuration 中切换该功能开关,几秒内即可传播到每个节点。
渐进式发布是大多数工程师关注的部分。而近乎瞬时的回滚,则曾不止一次地拯救了我们。

图 1:解锁三大平台,使基于拉动的迁移成为可能(图片由作者制作)
HCM 服务水平目标(SLO)与考勤系统数据采集路径
在人力资源管理(HCM)领域,有两个服务水平目标(SLO)几乎影响着我们做出的每一项架构决策。
第一个是针对考勤系统的。当按小时计薪的员工打卡上下班时,我们绝不能丢失该条记录。对于本就入不敷出的员工来说,一次打卡记录的丢失就意味着一笔工资的损失。这方面绝不容许有任何误差。
第二个是关于薪资系统的。一旦到了薪资处理时间,系统就必须正常运行。在薪资处理窗口期内发生系统停机,绝非单纯的不便,而是涉及合规报告的重大事件。
这两项 SLO 推动我们做出了一系列具体的架构承诺。
十个摄入来源,一份持久记录
该考勤系统必须支持从大量设备和渠道接收打卡数据。目前,我们从以下十个来源采集数据:
员工自助服务门户
工作现场的实体考勤机
休息室的自助终端
面向零售和餐饮服务客户的销售点系统
现场运营中的 iPad 应用程序
iOS 和 Android 平台上的移动应用
集成所使用的合作伙伴公共 API
基于电话的系统
管理员在管理界面中手动录入的考勤记录
针对特定垂直领域的边缘设备
每个数据源都有其独特之处。远程站点的物理时钟连接不稳定。合作伙伴 API 设有我们无法控制的速率限制。POS 系统会在业务淡季批量提交数据。移动应用会在离线时将数据放入队列,并在恢复连接后进行重放。
无论通过何种渠道,每个数据源的首要任务都是将打卡数据写入 Service Bus 队列。随后,该打卡数据会从队列写入表存储,成为持久化记录。只有在持久化写入成功后,我们才会向数据源发出确认。
这一流程(进入队列、写入持久化记录、确认)是 SLO 的核心。一旦数据拥有了持久化记录,我们就能从任何下游故障中恢复。在拥有持久化记录之前,我们无法确认该数据已经被接收。
Event Hub 扇出
根据持久化记录, Azure Event Hub 会向四个下游消费者广播数据:
打卡处理:应用策略并计算工资事件。
记录数据库:作为打卡数据的权威来源。
报表:用于分析和合规性检查。
最近打卡列表:用户打卡后在 UI 中立即看到的列表。
每个消费者都从各自的消费者组中读取数据。它们独立扩展,独立发生故障,而且彼此之间不会相互阻塞。
从对用户感知的影响来看,最近打卡列表是最重要的消费者。当员工在手机上点击“打卡”时,用户界面会在不到一秒钟的时间内确认接受或拒绝。该确认由持久化写入驱动,而非由下游操作的完成情况决定。手机不会等待工资计算完成。
确定性回填
下游消费者会出现故障。虽然不常见,但确实会发生。当它们发生故障时,我们会运行一个确定性的回填流,从持久化记录开始按顺序向前重放数据,将其写入正在恢复的任何下游系统。
在消费端,重放操作具有幂等性:每个下游系统都维护着自己的水印,并且会跳过已经处理过的事件。
其结果是,下游系统中断绝不会导致事件丢失。从事件被确认的那一刻起,它就已经保存在持久化记录中。无论下游发生什么情况,都能恢复。
这正是将每个事件持久化存储所带来的实际收益。
薪资系统
薪资系统在美国的两个 Azure 区域(东部和西部)之间采用多区域 Active-Active 架构运行。这两个区域属于同一监管范围,区域间延迟较低,而且由同一运维团队负责。审计日志在两个区域中均得到了完整的保留。每个季度都会通过计划性的区域数据迁移测试故障转移功能。
我们刻意没有将其他组件部署为多区域架构。哪些组件采用多区域部署、哪些不采用,这是一个预算层面的抉择。大多数组件完全能够适应单区域运行,而过度复制所产生的成本将超过其节省的成本。

图 2:考勤系统数据摄取路径——十个数据源将数据发送到 Service Bus 和 Azure 表存储,成为持久化记录;Event Hub 将数据分发给四个下游消费者,并提供一个用于系统恢复的确定性回填流(图片由作者制作)
如何度过上午 8、9 点的早高峰
该考勤系统的日流量模式决定了其成本结构。当地时间上午 8 点至 9 点之间,在首班员工集中打卡的时段,各区域的打卡量高达数百万次,实际的流量峰值被压缩在约五分钟的时间窗内。
这种流量形态既有好处也有坏处。好处在于它具有可预测性:我们确切地知道峰值何时到来以及规模有多大。坏处在于,若仅根据这种形态简单地配置云资源,成本会迅速攀升。该峰值还呈现出周模式:工作日流量模式相似,周末则明显降低(零售和酒店业仍有打卡需求,但不足以改变总体趋势)。我们的调度规则同时采用了日曲线和周曲线。
如果我们将常驻容量按峰值进行配置,那么在 24 小时中,计算资源的利用率将有 23 个小时仅为 5%,而我们却要支付全天的全额费用。此外,在全年大部分时间里,员工工作模式稳定时,我们也会支付过高的费用。
我们不会这样做。与根据峰值配置常驻容量相比,我们目前采用的综合方案将突发时段的支出削减了约 70%。其运作原理如下。
定时扩展
我们有一条定时规则,会在每个区域预计达到峰值前 15 分钟预热容量。该规则了解该区域一天里的时间曲线。不同区域的情况各不相同,而且北美东海岸和西海岸达到峰值的绝对时间也不同。
预热机制的存在仅出于一个原因:对于五分钟的峰值而言,仅靠反应式自动扩展速度太慢。等到反应式自动扩展器检测到负载、决定添加节点、安排调度并启动容器时,峰值早已经过去。通过定时规则进行的预热为我们提供了基线,而反应式自动扩展则负责吸收在此基础上出现的波动。
反应式自动扩展
这种差异确实存在。不同客户的班次开始时间各不相同。有些员工准时在 8:00 开始工作,有些则在 8:15 或 8:30 开始。节假日会打乱所有安排。每年两次,夏令时转换会使预定规则偏离一小时。
面对所有这些变化,基于 CPU 和队列深度的反应式自动扩展可以处理长尾需求。预热机制为我们提供了冗余空间,而反应式扩展器则在此范围内进行更精细地调整。
无服务器扇出
Event Hub 消费者不是运行在 AKS 上。它们是 Azure Functions ,在消费计划中运行,按调用次数计费。
扇出型工作负载具有突发性和无状态性。当没有事件需要处理时,我们不希望为节点付费。函数可以在数秒内完成横向扩展以应对突发的流量高峰,并在高峰过后迅速缩减至零。对于运行五分钟后即进入空闲状态的工作负载,按使用量计费的方案,成本远低于持续运行的同等计算资源。该方案对每个函数应用的并发实例数设有上限,我们在高峰期间触及了该上限。解决方案是将扇出工作负载拆分为多个与下游消费者对齐的函数应用,使每个函数应用拥有独立的并发配额。其故障模式表现为队列的无声增长,而非明显的错误——请关注 Event Hub 的延迟。冷启动的痛点比预期要小,因为预热后的 AKS 层吸收了最初几百毫秒的开销。
缓存
缓存机制在每一层上都至关重要。最重要的缓存包括:
位于应用配置前端的 Redis,用于存储功能开关和租户级设置。
租户配置:指定哪些客户端使用哪些功能,以及相应的限制。
考勤查询:用于根据考勤记录与考勤政策进行匹配的排班数据。
政策数据:用于判定考勤记录是否被接受、是否迟到、是否早到、是否加班等的规则。
我们之前低估了功能开关缓存。在我们的请求量下,应用配置调用会不断累积。如果没有前端的 Redis 层,高峰期我们将不得不向应用配置发起数百万次的跨网络调用。通过设置短 TTL Redis 缓存,这一数值降至数万次。
我们确实曾经因为一次缓存踩踏事件而吃了苦头。一次计划中的数据更新使一个被大量共享的缓存键失效,随后来自数千个进程的下一个请求同时试图从“权威数据源”重新填充缓存。这差点导致“权威数据源”崩溃。为此,我们在重新填充路径上添加了抖动 TTL 机制,并采用了单次调用模式。
APIM 和 Traffic Manager
在区域边界处,APIM 和 Traffic Manager 会平滑地处理流量饱和情况。如果某个区域在高峰期接近了容量上限,流量就会转移到另一个区域。我们没有时刻对考勤系统运行 Active-Active 负载均衡。但在需要时,故障转移和溢出容量始终可用。
综合来看,针对已知突发流量设计的工作负载,其成本远低于针对“始终最坏情况”设计的工作负载。
重复了 100 多次的切换模式
平台搭建完成后,迁移流程便实现了可重复性。在过去的五年里,我们已经按照这个模式执行了超过 100 次切换:
构建域服务。这是一个基于标准配置模板的新服务,属于新的域订阅。它实现了与即将被替换的单体应用端点相同的契约。
通过功能开关实现双路运行。应用程序代码会检查该开关的状态。若开关开启,则调用新服务;若开关关闭,则继续沿用单体架构路径。起初,在生产环境中,该开关对所有用户均处于关闭状态。
为内部流量开启该开关。包括我们自己的质量保证流量、内部测试账户以及负载测试。对比响应结果以确保一致性,并针对任何差异进行修复。
调整 APIM 权重。一旦响应一致性得到保证,便通过 APIM 权重将一小部分生产环境流量切换至新服务。密切监控 SLO。
逐步增加权重。如果 SLO 达标,每隔几天便将权重提高一个数量级:1%、10%、50%、100%。
停用单体架构路径。当新服务承载 100% 的流量时间足够长且运行稳定后,即可移除单体架构路径。
“迁移完成”指标
我们通过一个明确的指标来判定端点是否已经迁移完成。该指标为二元指标:要么该端点的生产流量已经 100% 由新服务处理至少 30 天,且服务水平目标(SLO)未受影响;要么该端点尚未完成迁移。不存在中间状态。
这听起来可能有些吹毛求疵,但至关重要。如果没有对“迁移完成”的明确定义,团队就会无休止地对新服务进行微调,并依靠单体应用作为后备方案来规避风险,从而永远无法完全投入到新服务中。
回滚
大多数切换过程都很顺利,但也有例外。以下是一些典型的故障案例。
某项新服务对一个单体数据库字段存在隐式依赖,而我们此前并未意识到该字段会被读取。通过回滚功能开关,我们在一秒钟内恢复了原状,但修复问题却花了两天时间。
将 APIM 权重调整至 50% 时,新服务中出现了连接池耗尽的问题,而此前在 10% 时并未显现。通过回滚功能开关,新服务的 APIM 权重立即恢复到了 0%。我们对连接池进行了调优,并重新执行了切换操作。
有一次计划中的负载测试在切换窗口期间运行了,导致 SLO 数据出现偏差。由此,我们学会了将测试计划与切换窗口进行协调。
在所有情况下,回滚机制都是功能开关层。开关切换在几秒内即可传播,这正是生产环境故障处理所需的响应速度。APIM 权重调整速度较慢(以分钟计,而非秒),主要用于流量整形,而非紧急回滚。这两层机制结合起来,完全满足了我们的需求。
如果重来我会怎么做,以及一份为期 90 天的计划
五年来,实践“拉动迁移”的经验让我明白了这种方法的适用场景及其局限性。
何时无效
基于拉动的方法之所以有效,是因为每次代码修改都是进行迁移的机会。但另一方面,那些无人修改的代码则不会被迁移。单体应用中仍然存在“冷角”:多年前发布、运行正常、一年来无人触碰且客户依赖的功能。
我们曾经以为,这个问题会自行解决,但事实并非如此。五年过去了,这些“冷角”依然“冷”。它们不会自动迁移。
基于拉动的方法大约能处理系统中 80% 的部分,剩下的 20% 需要采取不同的方法:要么是有计划的一次性项目,要么是愿意让其永远留在单体应用中。目前,我们正在同时采取这两种方法。
在提供第二项服务之前应标准化哪些事项
如果今天让我从头开始,在以下五项准备就绪之前,我不会允许创建第二个域服务:
完全自助式的“按域订阅”配置模板。
功能开关层,即带有缓存封装器的应用配置,已经在预发布环境中经过测试。
用于路由接口的 APIM 契约。
可观测性套件,其中指标、追踪和日志流向已知的目标。
针对任何高风险数据摄取的持久化模式:Service Bus、持久化记录、Event Hub 扇出、确定性回填。
如果忽略其中任何一项,第二项服务都将需要返工,第三个服务也将需要返工。先将这些标准化,后续每个服务的开发成本就会很低。
组织层面的关键举措
有三项非技术举措对迁移的贡献,甚至超过了大部分平台层面的工作。
第一项是代码审查门禁,其中明确提出:“此变更是否开辟了一个新的领域服务,还是扩展了单体架构?”如果扩展了单体架构,审查者就会追问原因。大多数情况都有充分的理由。如果没有,该变更就会在审查过程中被重新调整。
第二项是针对“架构接缝映射”(即哪些端点应归属于哪个领域)的架构审查,我们尽早开展了这项工作,并每六个月重新审视一次。接缝映射在两次审查之间会发生偏移,而审查过程恰好能捕捉到这种偏移。
第三项是跟踪一项关于“前期成本”的工程师体验指标。对于针对单体架构变更构建新领域服务的用户故事,我们观察了周期时间。只要差距保持在预期的 4 小时至 6 小时范围内,我们就继续推进。如果差距扩大,我们会暂停并查找导致新路径成本高昂的原因。该指标是检验平台是否兑现其承诺的依据。
一份为期 90 天的计划
如果你是一位身处单体架构中且没有迁移预算的工程负责人,以下计划能帮助你判断这种方法是否适合你:
第 1-30 天。选择一个领域。构建配置模板。在某个端点前部署 APIM。搭建功能开关层。
第 31-60 天。拆分第一个领域服务。将某个端点迁移至新架构。衡量前期成本。
第 61-90 天。拆分第二个服务。将所学经验标准化。为下一个领域编写接缝映射文档。
到第 90 天时,你将知道这种方法是否适合你的组织。你还将知道你的团队是否具备组织层面的纪律性,可以长期持续地承担这样的前期成本。这才是更难的问题。





