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

发布时间:2026/8/3 1:40:52
微服务拆完才是开始:Saga、Outbox 与渐进迁移方案 微服务拆完才是开始Saga、Outbox 与渐进迁移方案在上一篇《微服务边界别再凭感觉用 DDD 识别业务边界》里我们聊了如何通过事件风暴、限界上下文和适度拆分从单体系统中识别并落地粗粒度微服务边界。但很多团队踩过的坑是边界画完了、服务拆好了真正的麻烦才刚刚开始。跨服务事务怎么保证一致消息发丢了怎么办禁止跨库之后列表联查怎么实现怎么在不停业务的前提下完成迁移服务越拆越多如何避免重新变成分布式单体拆分只是微服务改造的起点分布式一致性、消息可靠性、平滑迁移路径和长期架构治理才是决定改造成败的硬骨头。本文承接上篇的电商商城案例拆解微服务落地阶段的核心工程难题分享行业通用的成熟方案与治理思路。一、跨服务一致性从本地事务到业务补偿的思维转变在原共享数据库单体中订单和库存等数据库写入可以放在同一个本地事务里由 ACID 保证数据库范围内的一致性。但支付机构、物流系统、消息发送等外部副作用本来就不属于数据库事务能够完整覆盖的范围。拆成微服务之后每个服务拥有独立的数据所有权和本地事务边界跨服务操作无法再依赖原来共享数据库中的同一个本地事务这是所有团队拆分后遇到的第一个坎。很多人会陷入两个极端要么为了一致性强行把模块合并回去退化成单体要么随口一句 “最终一致性” 就把问题带过最后线上出现超卖、重复下单、订单状态错乱等各种业务故障。1. 先转变认知不是不需要一致是换一种方式一致订单与库存拥有不同的生命周期和业务规则适合拆分成独立服务但拆分绝不意味着放弃一致性。我们不再追求跨服务的强一致而是通过预占、状态机、补偿机制保证业务层面的最终一致性。为了说明这一思路下面以 “同步预占库存、异步接收支付结果” 的编排方案为例。整条链路状态流转可控是电商交易场景中常见的一种设计订单服务创建待确认订单使用客户端请求 ID 保证提交幂等订单服务同步调用库存服务执行预占预占失败则直接关闭订单流程终止预占成功后订单进入待支付状态启动支付超时倒计时用户支付完成后支付服务发布支付成功集成事件代表支付机构已确认款项到账订单服务消费支付成功事件更新订单状态为已确认并发布订单已确认事件库存服务消费订单已确认事件将临时预占转为正式占用。在该方案中订单服务承担流程协调者角色维护订单状态机和各步骤执行结果库存、支付等服务只处理自身本地事务不直接控制整个交易流程。如果后续业务复杂度持续增长可以考虑引入独立的流程管理器但不建议把所有业务流程都集中到一个 “万能编排服务” 中避免形成新的耦合瓶颈。注本案例中支付成功后确认库存占用实际账面库存的扣减时点应由企业库存口径决定可以发生在支付确认、履约分配或仓库出库阶段不能把预占确认与实物出库扣减混为一谈。2. Saga 模式把大事务拆成可补偿的本地事务Saga 是企业级长流程事务中常见的解决方案之一核心思路是把一个跨多个服务的业务流程拆成一系列本地事务每个事务对应一个补偿动作当某一步失败时按顺序执行前面步骤的补偿操作让业务回到一致状态。针对订单链路常见的补偿设计如下订单创建失败流程终止无副作用库存预占失败自动关闭订单无需额外补偿支付超时 / 失败自动取消订单异步通知库存释放预占库存确认异常先按业务策略执行有限重试、重新分配履约节点或转人工处理确认无法履约后再取消订单并发起退款订单取消后收到延迟支付成功事件不得直接重新确认订单应根据订单当前状态进入自动退款、重新校验库存或人工处理流程。消费者必须同时校验事件和当前业务状态不能只根据事件内容盲目执行状态迁移。必须明确的是Saga 不是分布式回滚。很多现实动作无法真正 “撤销”比如已发送的短信无法撤回已经出库的商品无法通过数据库回滚恢复到仓库已经完成的外部支付只能发起退款。Saga 的本质是业务补偿不是技术回滚无法自动补偿的场景必须设计人工兜底流程。3. 幂等设计每一步都要防重复分布式系统里网络超时、重试、消息重投都是常态不能假设每个请求只会到达一次。库存预占、支付、退款、履约生成等有副作用的写操作都必须设计独立的幂等规则不能用一个 “全局幂等键” 笼统概括创建订单使用客户端请求 ID 或提交令牌防止重复提交库存预占使用预占单号或 “订单号 操作类型” 作为幂等键支付与退款使用支付单号、退款单号作为幂等依据事件消费通过唯一 EventId 识别同一条消息的重复投递同时配合业务幂等键如支付单号、预占单号、退款单号识别同一个业务动作即使两条消息的 EventId 不同只要业务标识一致也不能重复执行对应操作。消费者记录 “该 EventId 已处理” 和修改本地业务数据最好放在同一个本地事务中。否则业务数据已经修改、幂等记录却写入失败消息重投时仍然可能重复执行。二、消息可靠投递解决双写难题避免事件失联事件驱动是微服务解耦的核心手段但很多团队的事件驱动最后做成了 “薛定谔的事件”数据库更新成功了消息却没发出去下游以为没收到上游以为已经通知了最后数据两边不一致。1. 最经典的分布式坑双写问题“先更新数据库再发消息” 看起来天经地义但两步之间任何一步失败都会出问题数据库提交成功消息发送失败下游永远不知道状态变了消息先发出去了数据库回滚了下游收到了无效的事件。这就是典型的双写问题 —— 两个独立的写操作无法通过本地事务保证原子性。2. Outbox 与 CDC不是二选一常是组合使用很多资料会把 Outbox 和 CDC 说成两种对立方案实际上二者解决的是不同层面的问题Outbox 解决的是业务数据和待发布事件如何在同一个本地事务中提交CDC 解决的是如何捕获数据库变更并可靠转发。企业中常见的做法正是 OutboxCDC Relay二者组合使用。主流的落地方式有三种Outbox 轮询投递业务数据和 Outbox 记录在同一个本地事务中提交由服务内部后台任务定时扫描 Outbox 表将待发送事件投递到消息中间件。实现路径直接、基础设施依赖相对较少适合中小规模场景。Outbox CDC Relay业务数据和 Outbox 记录仍然在同一个本地事务中提交但不再轮询 Outbox 表而是通过 Debezium 等 CDC 工具监听 Outbox 表的 Binlog将事件实时转发到消息中间件。相比轮询延迟更低、对业务库压力更小是事件量较大、对投递延迟要求较高场景中的常见选择。直接监听业务表 CDC不维护独立的 Outbox 表直接监听业务表的 Binlog 变更再转换成业务事件对外发布。这种方式对业务代码侵入极低适合某些遗留系统改造但风险也更高数据库字段变更容易影响下游表级变化不一定等于业务事实很难准确表达 “订单已确认” 这类领域语义容易让数据库模型成为公开契约。采用这种方式必须增加专门的事件转换层不能把原始表结构直接暴露给下游。3. 消息可靠性的真相避免丢失、容忍重复、控制局部顺序很多资料会宣称 “保证消息不丢、不重、不乱”但在分布式系统里这是不现实的。正确的认知是降低丢失风险通过 Outbox 或 CDC将业务提交与待发布事件关联起来并配合持续重试、监控和人工补偿显著降低数据库已提交但事件永久丢失的风险容忍重复投递消息中间件通常是 “至少一次投递” 语义系统不应假设消息绝不重复而是通过消费者幂等确保重复消息不会产生重复业务结果控制局部顺序全局顺序成本极高且没有必要对确有顺序要求的事件可按聚合 ID如订单号分区投递在生产端路由规则和消费并发策略一致的前提下尽量维持同一订单事件的局部顺序同时通过版本号或序列号识别乱序消息避免旧事件覆盖新状态。4. 消费失败与数据对账消费者失败不能无限重试。瞬时故障可采用有限重试和指数退避超过阈值后将消息转入死信队列或隔离队列并触发告警。处理人员修复数据或代码后再进行受控重放。同时订单、支付和库存等关键链路应建立定时对账任务主动发现 “支付成功但订单未确认”“订单取消但库存未释放” 等不一致状态。Outbox 解决消息发布的可靠性问题对账机制才是最终兜底。5. 守住契约领域事件不能直接当集成事件用领域事件的演进影响范围主要限制在当前限界上下文内部因此调整成本通常低于公开集成事件但仍需考虑内部处理器的兼容性。而跨越限界上下文的集成事件是跨服务的公开契约必须具备明确、可治理的版本策略并优先保持向后兼容。如果直接把内部领域对象扔出去当事件用后续领域模型一调整所有下游服务都会跟着崩。正确的做法是在服务的防腐层或应用层做一次转换把内部领域事件映射成稳定的集成事件再对外发布。三、跨服务查询禁止跨库之后联查场景怎么解“禁止服务直接访问其他服务的数据库” 是守住边界的铁律但这条规则落地后第一个现实问题就来了订单列表页要同时展示商品名称、价格、支付状态、履约进度总不能一次查十几个接口吧很多团队就是因为扛不住查询压力又偷偷开了跨库权限最后边界名存实亡。其实跨服务查询有成熟的解法不同场景选不同方案即可。1. 方案一API 聚合 / BFF 层拼接由上层 BFFBackend 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 划清业务边界到用工程手段解决分布式难题再到持续治理守护边界这是一套完整的闭环。边界划得越合理跨服务事务、同步调用和联合发布的数量通常就越可控治理跟得上微服务才不会越做越乱。希望这两篇文章能帮正在做微服务改造的你少踩几个坑多走几步稳路。