微服务拆完才是开始:Saga、Outbox 与渐进迁移方案

📅 2026/8/3 1:49:38 👁️ 阅读次数 📝 编程学习
微服务拆完才是开始:Saga、Outbox 与渐进迁移方案

微服务拆完才是开始:Saga、Outbox 与渐进迁移方案

在上一篇《微服务边界别再凭感觉:用 DDD 识别业务边界》里,我们聊了如何通过事件风暴、限界上下文和适度拆分,从单体系统中识别并落地粗粒度微服务边界。但很多团队踩过的坑是:边界画完了、服务拆好了,真正的麻烦才刚刚开始。

跨服务事务怎么保证一致?消息发丢了怎么办?禁止跨库之后,列表联查怎么实现?怎么在不停业务的前提下完成迁移?服务越拆越多,如何避免重新变成分布式单体?

拆分只是微服务改造的起点,分布式一致性、消息可靠性、平滑迁移路径和长期架构治理,才是决定改造成败的硬骨头。本文承接上篇的电商商城案例,拆解微服务落地阶段的核心工程难题,分享行业通用的成熟方案与治理思路。

一、跨服务一致性:从本地事务到业务补偿的思维转变

在原共享数据库单体中,订单和库存等数据库写入可以放在同一个本地事务里,由 ACID 保证数据库范围内的一致性。但支付机构、物流系统、消息发送等外部副作用,本来就不属于数据库事务能够完整覆盖的范围。拆成微服务之后,每个服务拥有独立的数据所有权和本地事务边界,跨服务操作无法再依赖原来共享数据库中的同一个本地事务,这是所有团队拆分后遇到的第一个坎。

很多人会陷入两个极端:要么为了一致性强行把模块合并回去,退化成单体;要么随口一句 “最终一致性” 就把问题带过,最后线上出现超卖、重复下单、订单状态错乱等各种业务故障。

1. 先转变认知:不是不需要一致,是换一种方式一致

订单与库存拥有不同的生命周期和业务规则,适合拆分成独立服务,但拆分绝不意味着放弃一致性。我们不再追求跨服务的强一致,而是通过预占、状态机、补偿机制,保证业务层面的最终一致性。

为了说明这一思路,下面以 “同步预占库存、异步接收支付结果” 的编排方案为例。整条链路状态流转可控,是电商交易场景中常见的一种设计:

  1. 订单服务创建待确认订单,使用客户端请求 ID 保证提交幂等;
  2. 订单服务同步调用库存服务执行预占,预占失败则直接关闭订单,流程终止;
  3. 预占成功后,订单进入待支付状态,启动支付超时倒计时;
  4. 用户支付完成后,支付服务发布支付成功集成事件,代表支付机构已确认款项到账;
  5. 订单服务消费支付成功事件,更新订单状态为已确认,并发布订单已确认事件;
  6. 库存服务消费订单已确认事件,将临时预占转为正式占用。

在该方案中,订单服务承担流程协调者角色,维护订单状态机和各步骤执行结果;库存、支付等服务只处理自身本地事务,不直接控制整个交易流程。如果后续业务复杂度持续增长,可以考虑引入独立的流程管理器,但不建议把所有业务流程都集中到一个 “万能编排服务” 中,避免形成新的耦合瓶颈。

注:本案例中支付成功后确认库存占用,实际账面库存的扣减时点应由企业库存口径决定,可以发生在支付确认、履约分配或仓库出库阶段,不能把预占确认与实物出库扣减混为一谈。

2. Saga 模式:把大事务拆成可补偿的本地事务

Saga 是企业级长流程事务中常见的解决方案之一,核心思路是:把一个跨多个服务的业务流程,拆成一系列本地事务,每个事务对应一个补偿动作;当某一步失败时,按顺序执行前面步骤的补偿操作,让业务回到一致状态。

针对订单链路,常见的补偿设计如下:

  • 订单创建失败:流程终止,无副作用;
  • 库存预占失败:自动关闭订单,无需额外补偿;
  • 支付超时 / 失败:自动取消订单,异步通知库存释放预占;
  • 库存确认异常:先按业务策略执行有限重试、重新分配履约节点或转人工处理;确认无法履约后,再取消订单并发起退款;
  • 订单取消后收到延迟支付成功事件:不得直接重新确认订单,应根据订单当前状态进入自动退款、重新校验库存或人工处理流程。消费者必须同时校验事件和当前业务状态,不能只根据事件内容盲目执行状态迁移。

必须明确的是:Saga 不是分布式回滚。很多现实动作无法真正 “撤销”,比如已发送的短信无法撤回,已经出库的商品无法通过数据库回滚恢复到仓库,已经完成的外部支付只能发起退款。Saga 的本质是业务补偿,不是技术回滚,无法自动补偿的场景必须设计人工兜底流程。

3. 幂等设计:每一步都要防重复

分布式系统里,网络超时、重试、消息重投都是常态,不能假设每个请求只会到达一次。库存预占、支付、退款、履约生成等有副作用的写操作,都必须设计独立的幂等规则,不能用一个 “全局幂等键” 笼统概括:

  • 创建订单:使用客户端请求 ID 或提交令牌,防止重复提交;
  • 库存预占:使用预占单号或 “订单号 + 操作类型” 作为幂等键;
  • 支付与退款:使用支付单号、退款单号作为幂等依据;
  • 事件消费:通过唯一 EventId 识别同一条消息的重复投递;同时配合业务幂等键(如支付单号、预占单号、退款单号)识别同一个业务动作,即使两条消息的 EventId 不同,只要业务标识一致,也不能重复执行对应操作。

消费者记录 “该 EventId 已处理” 和修改本地业务数据,最好放在同一个本地事务中。否则业务数据已经修改、幂等记录却写入失败,消息重投时仍然可能重复执行。

二、消息可靠投递:解决双写难题,避免事件失联

事件驱动是微服务解耦的核心手段,但很多团队的事件驱动最后做成了 “薛定谔的事件”:数据库更新成功了,消息却没发出去;下游以为没收到,上游以为已经通知了,最后数据两边不一致。

1. 最经典的分布式坑:双写问题

“先更新数据库,再发消息” 看起来天经地义,但两步之间任何一步失败都会出问题:

  • 数据库提交成功,消息发送失败:下游永远不知道状态变了;
  • 消息先发出去了,数据库回滚了:下游收到了无效的事件。

这就是典型的双写问题 —— 两个独立的写操作,无法通过本地事务保证原子性。

2. Outbox 与 CDC:不是二选一,常是组合使用

很多资料会把 Outbox 和 CDC 说成两种对立方案,实际上二者解决的是不同层面的问题:Outbox 解决的是业务数据和待发布事件如何在同一个本地事务中提交;CDC 解决的是如何捕获数据库变更并可靠转发。企业中常见的做法正是 Outbox+CDC Relay,二者组合使用。

主流的落地方式有三种:

  1. Outbox + 轮询投递业务数据和 Outbox 记录在同一个本地事务中提交,由服务内部后台任务定时扫描 Outbox 表,将待发送事件投递到消息中间件。实现路径直接、基础设施依赖相对较少,适合中小规模场景。

  2. Outbox + CDC Relay业务数据和 Outbox 记录仍然在同一个本地事务中提交,但不再轮询 Outbox 表,而是通过 Debezium 等 CDC 工具监听 Outbox 表的 Binlog,将事件实时转发到消息中间件。相比轮询,延迟更低、对业务库压力更小,是事件量较大、对投递延迟要求较高场景中的常见选择。

  3. 直接监听业务表 CDC不维护独立的 Outbox 表,直接监听业务表的 Binlog 变更,再转换成业务事件对外发布。这种方式对业务代码侵入极低,适合某些遗留系统改造,但风险也更高:数据库字段变更容易影响下游,表级变化不一定等于业务事实,很难准确表达 “订单已确认” 这类领域语义,容易让数据库模型成为公开契约。采用这种方式必须增加专门的事件转换层,不能把原始表结构直接暴露给下游。

3. 消息可靠性的真相:避免丢失、容忍重复、控制局部顺序

很多资料会宣称 “保证消息不丢、不重、不乱”,但在分布式系统里,这是不现实的。正确的认知是:

  • 降低丢失风险:通过 Outbox 或 CDC,将业务提交与待发布事件关联起来,并配合持续重试、监控和人工补偿,显著降低数据库已提交但事件永久丢失的风险;
  • 容忍重复投递:消息中间件通常是 “至少一次投递” 语义,系统不应假设消息绝不重复,而是通过消费者幂等确保重复消息不会产生重复业务结果;
  • 控制局部顺序:全局顺序成本极高且没有必要,对确有顺序要求的事件,可按聚合 ID(如订单号)分区投递,在生产端路由规则和消费并发策略一致的前提下,尽量维持同一订单事件的局部顺序;同时通过版本号或序列号识别乱序消息,避免旧事件覆盖新状态。

4. 消费失败与数据对账

消费者失败不能无限重试。瞬时故障可采用有限重试和指数退避;超过阈值后,将消息转入死信队列或隔离队列,并触发告警。处理人员修复数据或代码后,再进行受控重放。

同时,订单、支付和库存等关键链路应建立定时对账任务,主动发现 “支付成功但订单未确认”“订单取消但库存未释放” 等不一致状态。Outbox 解决消息发布的可靠性问题,对账机制才是最终兜底。

5. 守住契约:领域事件不能直接当集成事件用

领域事件的演进影响范围主要限制在当前限界上下文内部,因此调整成本通常低于公开集成事件,但仍需考虑内部处理器的兼容性。

而跨越限界上下文的集成事件,是跨服务的公开契约,必须具备明确、可治理的版本策略,并优先保持向后兼容。如果直接把内部领域对象扔出去当事件用,后续领域模型一调整,所有下游服务都会跟着崩。正确的做法是:在服务的防腐层或应用层做一次转换,把内部领域事件映射成稳定的集成事件再对外发布。

三、跨服务查询:禁止跨库之后,联查场景怎么解

“禁止服务直接访问其他服务的数据库” 是守住边界的铁律,但这条规则落地后,第一个现实问题就来了:订单列表页要同时展示商品名称、价格、支付状态、履约进度,总不能一次查十几个接口吧?

很多团队就是因为扛不住查询压力,又偷偷开了跨库权限,最后边界名存实亡。其实跨服务查询有成熟的解法,不同场景选不同方案即可。

1. 方案一:API 聚合 / BFF 层拼接

由上层 BFF(Backend for Frontend,面向前端的后端)或查询聚合服务,分别调用多个下游服务的查询接口,在内存里拼接成前端需要的数据结构返回。

  • 适用场景:实时性要求高、数据量小、调用链路短的场景,比如单条订单详情的少量补充信息;
  • 缺点:调用链路过长会拖慢响应速度,下游服务故障会直接影响查询结果,不适合大列表、多维度筛选的场景。

2. 方案二:CQRS 只读副本(事件投影)

通过订阅集成事件,把需要联查的字段异步同步到本地的只读表中,查询时直接查本地数据,不需要跨服务调用。

  • 适用场景:列表查询、多条件筛选、对实时性要求在秒级的场景,是电商列表和多条件查询场景中的常见方案;
  • 核心原则:只读副本不接受外部业务写命令,只能由事件投影程序更新;原始数据所有权仍归源服务,并接受一定程度的最终一致性延迟。

比如订单服务可以订阅支付、履约的状态变更事件,在本地维护一份订单宽表,订单列表查询直接查本地宽表,不需要每次都调用支付和履约服务。

3. 电商必做:业务快照与动态状态分离

很多人会忽略一个关键设计:不是所有数据都需要实时查上游。对于有历史语义的数据,必须在业务发生时就生成快照,归当前服务所有。

最典型的就是订单数据:

  • 下单时的商品名称、SKU 描述、成交单价、优惠明细、收货地址、服务承诺,必须作为快照存在订单服务里;
  • 因为后续商品可能改名、改价、下架,用户地址可能修改,但订单的历史语义不能变;
  • 只有支付进度、履约进度这类动态变化的状态,才需要通过事件同步到订单查询模型。

快照设计不仅能解决查询性能问题,更重要的是保证了业务数据的历史完整性,是电商系统的基础设计。

4. 报表与搜索:走专用通道

对于后台报表、全文搜索、多维度筛选这类场景,不要试图用在线业务服务满足,应该走专用通道:

  • 全文检索:同步到 Elasticsearch 等搜索引擎,支持多维度模糊查询;
  • 数据分析报表:同步到数据仓库或数据湖,通过离线计算产出报表,不占用在线业务资源。

四、平稳迁移:用绞杀者模式告别 “大爆炸式” 重构

很多团队做微服务改造,一上来就想推翻重写,定一个 “三个月全量上线” 的大目标,最后往往是工期失控、bug 满天飞,业务还得停摆。

对于不能停服、无法一次性重写的企业系统,绞杀者模式通常是更稳妥的迁移路径之一:新旧系统长期共存,从边缘到核心逐步替换,像藤蔓缠绕大树一样,慢慢把旧系统的功能 “绞杀” 掉。

结合行业通用实践,类似的单体改造项目通常可以按以下阶段平稳推进:

阶段 1:单体内部模块化梳理

先不着急拆服务,在现有单体内部按领域划分模块边界,梳理模块间的依赖关系。先做到代码层面不跨模块乱调用、不跨模块直连数据表,完成逻辑上的解耦。这一步成本最低,收益却很明显。

阶段 2:选择改造切入点

不要一上来就动订单、交易这类核心链路。从变化频繁、依赖可控、收益明确的模块开始,比如价格促销、会员模块,先验证改造路径、技术栈、发布流程,跑通之后再推进核心域。

阶段 3:增设防腐层与路由层

在网关层或单体边缘增加路由层与防腐层,将对应业务的流量逐步引导到新服务。在保持接口契约兼容的前提下,尽量让外部调用方对后端迁移过程无感。

阶段 4:新服务基础功能验证

新服务开发完成后,先通过测试用例、抽样数据、离线回放和无状态计算验证核心逻辑正确性,排查基础功能缺陷。此时尚未完成全量历史数据迁移,不直接接入真实生产流量。

阶段 5:历史数据回填与增量同步

在新服务接管写入之前,先完成历史数据回填,并通过增量同步保持新旧数据一致。切流前对记录数量、金额汇总、状态分布及关键业务指标进行核对,差异超过阈值时暂停切换。对于订单、库存和支付等核心数据,还应准备可重复执行的数据修复脚本。

增量同步期间必须明确唯一写入主方,避免新旧系统长期双向写入。双向同步不仅容易形成循环更新,也会让数据冲突难以判断以哪一方为准。

阶段 6:影子流量与双跑比对

完成数据回填与增量同步后,再引入真实影子流量进行新旧结果比对:

  • 对查询类、无副作用的接口,直接复制真实请求转发给新服务,对比新旧返回结果的一致性;
  • 对订单、库存、支付等有副作用的写操作,采用只计算不提交的 Dry Run 模式,避免产生重复业务数据。

阶段 7:灰度切流

双跑验证通过后,按流量比例、用户群体、业务场景逐步放大新服务的流量,全程监控错误率、耗时、业务指标。

对写请求进行灰度时,应基于租户、用户或业务主键进行稳定路由,确保同一业务对象始终只有一个写入主方,避免新旧系统同时修改同一条数据。路由流量可以快速切回,但涉及数据写入的回滚必须提前设计反向同步和数据校验机制,不能把应用流量切回等同于业务状态已经回滚。

阶段 8:数据所有权移交

新服务稳定运行一段时间后,停止旧模块的写入权限,将数据所有权正式移交新服务,完成物理边界的闭合。这一步是服务拆分真正完成的标志。

阶段 9:清理下线

确认无问题后,删除单体中的旧代码、临时适配层和路由规则,完成该模块的完整迁移。然后进入下一个模块的改造循环。

五、长期治理:避免微服务重回分布式单体

微服务不是拆完就一劳永逸了。随着业务迭代,边界会自然偏移,如果缺乏治理,用不了多久就会重新出现跨库调用、循环依赖、服务职责混乱的问题,变成 “分布式单体”。

我们在项目中已经落地了基础治理机制:独立的数据访问边界、禁止跨库直连、API 与事件双模式协作、防腐层隔离外部系统、接口版本管理、契约测试、统一日志与 TraceId、定期架构评审与调用链巡检。

而随着服务规模和业务复杂度增长,还可以从以下几个维度逐步完善治理体系。

1. 拆分与合并的可观察信号

“先粗后细” 不能靠感觉拍板,要建立可观察的判断信号,用数据驱动架构调整。

适合进一步拆分的信号

  • 两个模块经常独立变化,发布互相阻塞;
  • 资源消耗模式差异明显,有明确的独立扩容需求;
  • 已由不同团队分别负责,具备端到端交付能力;
  • 某模块需要更高的 SLO、更强的故障隔离或安全合规隔离。

应当暂缓拆分或考虑合并的信号

  • 两个服务几乎每个需求都要同步修改,联合发布占比居高不下;
  • 存在大量双向同步调用,多数业务事务跨越两个服务;
  • 单次请求调用链路过深,性能损耗超过拆分收益;
  • 两个服务始终由同一个小团队维护,拆分没有组织收益。

可以定期统计以下量化指标,阈值依据企业自身基线确定,不必照搬固定数字:

  • 近 3 个月联合发布次数占比;
  • 同一需求跨服务修改的比例;
  • 核心链路跨服务同步调用次数;
  • P95 调用链深度;
  • 故障跨服务传播次数;
  • 峰值资源消耗差异倍数。

2. 数据边界守护:所有权优先于物理隔离

很多人对 “数据隔离” 有误解,觉得每个服务必须配一个独立的物理数据库才叫微服务。其实数据所有权的优先级远高于物理实例。

改造初期完全可以共享同一个数据库集群,通过独立 Schema + 独立账号 + 禁止跨 Schema 写入 + 独立迁移脚本实现逻辑隔离。后续根据性能要求、合规要求、故障隔离要求,再逐步推进物理拆库。

边界的核心是 “谁的数据谁负责,别人只能通过接口访问”,而不是简单的物理分离。

3. 接口与契约治理

  • API 和事件契约优先采用向后兼容的增量演进,新增字段通常不需要升级大版本;
  • 禁止随意修改字段语义,确实无法兼容时再引入新版本,并明确旧版本的废弃、迁移和下线周期;
  • 契约测试可以有效验证接口兼容性,但不能替代完整的集成测试与端到端测试。

4. 韧性与可观测:从单服务日志到全链路可追溯

单体中的进程内方法调用本身不会遇到网络分区和远程超时,但单体仍可能依赖数据库、缓存和外部系统。微服务进一步把大量进程内调用转换成网络调用,因此部分失败成为更普遍的问题,必须配套完整的服务韧性机制:

  • 所有同步调用明确设置连接超时与响应超时,禁止无限等待;
  • 有副作用的写操作只有在具备幂等键时才能安全重试,支付、扣库存等请求不能因为超时就盲目重复调用;
  • 核心链路接入熔断与隔离舱机制,下游持续故障时快速失败,避免资源被耗尽拖垮整条链路;
  • 查询、推荐等非核心能力可以降级返回,但订单提交、支付确认等核心写操作不能返回虚假成功,必须明确失败并允许后续恢复。

在可观测性上,仅靠传统 HTTP 的 TraceId 不足以覆盖异步流程。完整的可观测体系应当覆盖日志、指标、调用链、消息轨迹四个维度:

  • 同步请求用 TraceId 串联调用链路;
  • 异步流程用 EventId、CorrelationId、CausationId 串联完整业务生命周期;
  • 业务链路日志应关联订单号、支付单号、履约单号等业务标识,支持从业务视角定位问题。

写在最后

很多团队对微服务的想象是 “拆完就解脱了”,但真实情况是:拆完只是万里长征第一步。

从单体到微服务,本质上是用分布式的复杂度,换取业务的敏捷性、可扩展性和组织协同效率。你省掉了单体的耦合成本,就要面对一致性、可靠性、运维复杂度的新挑战。没有完美的架构,只有权衡之下适合当前阶段的架构。

从 DDD 划清业务边界,到用工程手段解决分布式难题,再到持续治理守护边界,这是一套完整的闭环。边界划得越合理,跨服务事务、同步调用和联合发布的数量通常就越可控;治理跟得上,微服务才不会越做越乱。

希望这两篇文章,能帮正在做微服务改造的你,少踩几个坑,多走几步稳路。