PHP分布式事务实战:Saga模式落地与避坑指南

发布时间:2026/9/26 23:00:17
PHP分布式事务实战:Saga模式落地与避坑指南 做PHP这一行以前聊分布式事务总觉得有点“高攀”。大多数PHPer的日常工作半径就是LNMP环境下几个服务、一个MySQL、一个Redis事务靠数据库自带的那套begin/commit/rollback就够了。但业务一旦拆成多个服务比如订单服务、库存服务、支付服务、积分服务各占一个库这时候跨库跨服务的数据一致性就绕不开了。Saga模式就是在这种背景下被反复提到的方案。我最初接触Saga是几年前搞一个电商订单重构遇到最头疼的事用户下单时扣库存成功了、创建订单成功了但支付超时结果库存扣了、钱没到账、订单还挂在那边。这种不一致靠单库事务完全没法解决。后来调研了2PC两阶段提交、本地消息表、事务消息、Saga几种方案最终在PHP项目里落地了Saga模式也就是用一系列本地事务配合补偿动作来达到最终一致性。这篇文章就把我在这条路上踩过的坑、验证过的写法、以及为什么在PHP生态里Saga比2PC更顺手一次性说清楚。文章适合谁适合那些系统已经拆分成多个服务、开始为数据一致性发愁的PHP开发者尤其是订单、支付、库存、营销这类强事务场景。文章会聊Saga的核心原理、编排方式选型、PHP落地需要的基建选型、完整的流程实现以及几个必须避开的深坑。1. 先搞清楚Saga模式到底在解决什么问题1.1 分布式事务的痛点在哪里——从一次扣库存事故说起上月我把一个商城系统的下单链路拆成了三个服务订单服务负责写订单、库存服务负责扣减库存、支付服务负责调第三方支付。上线第一周就出问题用户提交订单后订单服务创建订单成功库存服务扣减成功但支付服务返回“超时”实际上用户在第三方支付页面上已经付款成功了。于是数据库里的状态是订单存在、库存减少、支付记录缺失。用户钱付了但订单显示未支付客服工单一天炸出几十条。这事的根源在于单体应用里可以用一个数据库事务把订单表和库存表一起提交要么全成功要么全失败。但拆成三个服务每个服务有自己的数据库跨库操作就没有一个统一的“事务管理器”能协调了。MySQL不支持跨服务分布式事务就算支持2PC的资源锁定成本也很高。1.2 为什么PHP场景下特别需要Saga很多人觉得PHP天生就是做Web页面的和分布式事务关系不大。但事实是PHP在中小型电商、ERP、SaaS系统里的占比相当高这些业务一增长就面临服务拆分。拆分后PHP服务通常是无状态的靠Redis、数据库来做状态存储这就决定了它不太适合做2PC那样需要全局锁的长事务。2PC在PHPMySQL场景下有两个硬伤一是协调者和参与者之间要维持长时间的锁资源PHP的进程模型是“请求结束即释放”一个长时间占用的全局锁会让数据库连接池很快耗尽二是2PC要求每个参与者都要实现prepare、commit、rollback三阶段接口MySQL默认的XA事务支持在PHP的PDO连接下用起来很别扭而且参与者宕机后全局状态恢复非常困难。Saga的思路完全不同。它把一个大事务拆成多个本地事务每个本地事务都有对应的补偿动作。比如“扣库存”对应的补偿是“回补库存”“调支付”对应的补偿是“发起退款”。每个步骤都是独立的执行完就提交不存在长时间持有数据库锁的问题。这正好匹配PHP无状态、请求短、进程快的特性。1.3 Saga的两个核心角色正向事务与补偿事务写Saga之前必须把两个词刻在脑子里正向事务Forward Operation和补偿事务Compensating Operation。正向事务就是业务主流程上每一步要执行的本地操作比如创建订单、扣库存、扣款、加积分。补偿事务是为正向事务准备的“撤销”操作注意是“补偿”不是“回滚”——数据库里的已提交记录已经生效了你要做的是用另一笔业务操作去抵消它的影响比如库存扣多了就给加回去、钱扣错了就退款回去。Saga能成立的前提是每个正向事务都对应一个明确可执行的补偿事务。如果某个操作压根没办法补偿比如“发送了一个不可撤回的短信通知”这个操作就不适合放进Saga流程里或者说你要额外评估业务容忍度。这点在后文设计流程时会专门展开。2. 编排方式选型编舞式还是指挥式2.1 编舞式Choreography事件驱动各服务自行监听Saga有两种主流实现方式。第一种是编舞式核心思想是“没有中央协调者”每个服务执行完自己的正向事务后发布一个事件由下一个服务监听该事件并继续执行。拿下单流程举例订单服务创建订单后发布order.created事件库存服务监听到order.created后扣库存发布inventory.deducted支付服务监听到inventory.deducted后发起扣款任何一个环节失败就发布失败事件由前面相关服务监听后做补偿。编舞式的优点是完全解耦服务之间不直接调用只通过事件通信新增一个参与者只需订阅相关事件不需要改动协调逻辑。缺点是流程分散在各处一个全局的订单状态变化被拆成多个事件出问题时排查链路非常费劲而且参与者之间通过事件耦合很容易形成“事件风暴”。2.2 指挥式Orchestration中央协调器统一调度第二种是指挥式也是我实际落地采用的方式。它引入一个Saga协调器Orchestrator由协调器记录整个业务流程当前执行到哪一步、下一步该调用哪个服务、某个服务失败后要调哪些补偿。协调器本质上是一个状态机。每次驱动一个服务执行正向操作收到成功响应后推进状态收到失败响应后反向触发之前已完成步骤的补偿操作。我在PHP项目里把协调器做成了一个独立的队列消费者进程它读取命令消息、查Saga状态表、决定下一步动作然后向目标服务发送RPC请求或投递消息。2.3 我的选型建议与理由两个方案各有适合的场景。编舞式适合业务流程简单、参与者稳定、团队对事件驱动很熟悉的情况。指挥式适合业务流程复杂、参与者经常增减、需要强流程控制的情况。我在这里直接给结论PHP项目里做Saga优先选指挥式。理由有三第一业务链路通常要精确控制。下单流程中失败后的补偿顺序不能乱比如支付失败了你希望先取消库存再取消订单有先后关系编舞式里实现这种顺序约束很绕。第二可观测性。协调器把每次状态推进都记录在Saga状态表里任何一个环节失败你打开状态表就能看到卡在哪一步反向补偿路径也很直观。第三PHP技术栈做编舞式需要每个服务都具备完善的事件发布和监听能力很多老项目改造起来成本高而指挥式只需要一个服务中心往外发命令现有服务暴露几个接口即可接入。3. PHP实现Saga的基建选型3.1 消息中间件选型RabbitMQ、Kafka还是Redis StreamSaga模式天然依赖异步消息所以先得选定消息中间件。我把三套在PHP生态里用得最多的方案做一个对比中间件可靠性吞吐量PHP客户端成熟度运维成本适用场景Redis Stream中受持久化配置影响高高phpredis原生支持极低小型项目、已有Redis、消息量不大RabbitMQ高ACK持久化中高php-amqplib中大部分业务系统追求可靠投递Kafka高极高中php-rdkafka高海量消息、日志流水、事件溯源多数PHP业务用RabbitMQ是一个比较平衡的选择。它对消息ACK、重试、死信队列的支持非常完善正好匹配Saga里的“命令投递、失败重试、死信人工介入”这些环节。如果你的项目里已经稳跑着Redis消息量一天几千条这种规模用Redis Stream起步也够用我早期验证方案时就是用它搭的demo。3.2 状态存储为什么必须有一个Saga状态库你一定要想明白一个事Saga协调器自己是不能只活在内存里的。协调器所在进程随时可能重启如果重启后不记得当前流程走到第几步那整个分布式事务就断了。所以必须要有一个持久化的状态存储记录每个Saga实例的完整生命周期。我用的是MySQL的单表原因很简单PHP项目基本都搭了MySQL运维成本低而且状态表的数据量不大一条Saga实例一行记录不需要单独引一个新存储。字段设计在下文第4部分给出完整方案。这里再强调一点状态存储里的记录可以支持协调器做到**至少一次At Least Once**的消息投递语义协调器先把“推进到下一步”的状态写入本地库再向服务发命令如果发命令后进程崩溃重启后能从状态表里找到“已推进但未发命令”的待办项继续补发。这个设计让消息丢失的可能性降到很低。3.3 幂等与重试Saga的地基写Saga代码之前我建议你先想清楚幂等方案否则后面八成会出线上事故。什么叫幂等“同一个操作执行一次和执行一百次结果完全一样”就是幂等。举个例子支付服务收到“扣款100元”的命令如果协调器因为网络超时重发了三次支付服务若每次都真实扣款用户就被扣了300问题就大了。正确的做法是每个Saga实例在发布命令时带上一个全局唯一的命令ID服务消费命令时先查本地记录确认这笔命令ID是否已处理过处理过就直接返回成功不再重复扣款。实现上有两种常见方式一是消费端建一张processed_command表用命令ID做唯一索引插入成功才执行业务逻辑二是利用业务数据天然的唯一性比如“扣款流水号”“退款单号”直接设置唯一索引重复插入会直接失败。我实际项目里两者结合用核心资金操作一定要有业务唯一索引做兜底。4. 一个可落地的PHP订单Saga流程实现4.1 场景定义下单——库存——支付——积分我拿一个最常见的电商场景来演示完整的实现用户下一个订单涉及4个服务、4个正向操作和3个补偿操作。步骤1订单服务创建订单状态待支付步骤2库存服务扣减库存补偿回补库存步骤3支付服务预扣款补偿发起退款步骤4积分服务增加积分补偿扣回积分正常情况下顺序执行最终订单变“已完成”。任何一个环节失败比如步骤3支付失败则按逆序补偿步骤2回补库存、步骤1取消订单。注意步骤1的“创建订单”一般不设计独立的补偿SQL而是把状态改成“已取消”这就是一个业务上的补偿操作。4.2 状态机设计与代码骨架协调器的核心是一张状态表加一个状态机引擎。我先给出表的定义CREATE TABLE saga_instance ( saga_id varchar(64) NOT NULL COMMENT Saga实例ID, saga_type varchar(32) NOT NULL COMMENT Saga类型如ORDER_CREATE, current_step tinyint NOT NULL COMMENT 当前正向步骤序号, status tinyint NOT NULL COMMENT 0执行中 1成功 2补偿中 3补偿完成 4失败待人工, payload json DEFAULT NULL COMMENT 业务参数快照, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (saga_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTSaga实例状态表;协调器的主循环代码骨架如下我用的是队列消费者方式。每个Saga实例的推进逻辑都在process方法里。class OrderCreateSaga { private SagaStateStore $store; private MessageBus $bus; public function start(array $payload): string { $sagaId $this-generateSagaId($payload); $this-store-create($sagaId, $payload, 1); // 步骤1创建订单 $this-bus-send(order.create, [ saga_id $sagaId, command_id $this-generator-commandId(), payload $payload, ]); return $sagaId; } public function onStepSuccess(string $sagaId, int $step): void { // 用事务保护状态推进避免并发重复推进 $this-store-transaction(function () use ($sagaId, $step) { $instance $this-store-lock($sagaId); // check当前步骤是否匹配防御乱序响应 if ($instance-currentStep() ! $step) { return; } if ($step 4) { $instance-markSuccess(); return; } $instance-advanceTo($step 1); $this-dispatchStep($instance, $step 1); }); } public function onStepFailed(string $sagaId, int $step, string $reason): void { $this-store-transaction(function () use ($sagaId, $step, $reason) { $instance $this-store-lock($sagaId); $instance-markCompensating($reason); // 从当前失败步骤的上一正向步骤开始逆序触发补偿 for ($i $step - 1; $i 1; $i--) { $this-bus-send($this-compensationCommand($i), [ saga_id $sagaId, command_id $this-generator-commandId(), payload $instance-payload(), ]); } $instance-markCompensated(); }); } }每个服务消费端接收到命令后执行本地事务然后向协调器回发CommandSucceeded或CommandFailed事件。协调器监听两套事件后分别走onStepSuccess和onStepFailed分支。4.3 补偿流程的实际触发条件刚才的代码里有个细节我刻意把补偿命令在onStepFailed里一次性发出去而不是等每个补偿步骤都成功了再发下一个。这种做法叫“平行补偿”。原因是补偿步骤之间通常不存在必须严格串行的依赖比如回补库存和取消订单可以同时进行没必要排队。但有一种情况要改成串行补偿后一个补偿的前置条件依赖前一个补偿完成。比如退款前必须先确认订单已经取消否则用户发起投诉时会看到订单还在“待支付”状态。遇到这种依赖协调器就要在每个补偿命令的回执回调里推进状态码。我的建议是业务上尽量解耦补偿之间的依赖这样可以大幅简化协调器实现实在解耦不了的再单独做一个“补偿状态机”。串行补偿的状态记录建议额外加一张compensation_log表CREATE TABLE saga_compensation_log ( id bigint NOT NULL AUTO_INCREMENT, saga_id varchar(64) NOT NULL, step tinyint NOT NULL, status tinyint NOT NULL COMMENT 0待执行 1成功 2失败, attempts tinyint NOT NULL DEFAULT 0 COMMENT 重试次数, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_saga_step (saga_id,step) ) ENGINEInnoDB;每个补偿步骤都要先写一条待执行记录等对应的补偿命令返回成功后才置为成功。协调器重启后扫描状态为“待执行”的补偿记录继续补发即可。4.4 幂等键与消息的“唯一执行”保障前面反复提到幂等这里给出正式的设计手段。每条命令消息附加一个command_id消费端在执行业务逻辑前先落一条记录CREATE TABLE processed_command ( command_id varchar(64) NOT NULL, processed_at datetime NOT NULL, PRIMARY KEY (command_id) ) ENGINEInnoDB;消费端伪代码public function consume(array $message): void { $commandId $message[command_id]; $inserted $this-db-insertIgnore(processed_command, [ command_id $commandId, ]); // 之前已处理过直接确认消息避免重复执行 if ($inserted-affectedRows 0) { $this-mq-ack($message); return; } try { $this-bizService-deductStock($message[payload]); $this-mq-ack($message); } catch (Throwable $e) { // 本地库回滚连带删除processed_command记录允许重试 $this-db-rollback(); $this-mq-nack($message, requeue: true); } }这里有个容易踩的坑insertIgnore和业务操作必须放在同一个数据库事务里。如果你先insertIgnore成功提交了再执行业务操作业务操作失败了processed_command里已经有一条“已处理”的记录重试时就会被幂等拦截真实业务永远没有机会执行第二次。所以正确顺序是开启事务 -insertIgnore- 执行业务 - 提交事务失败则整个回滚。5. 实操避坑我踩过的Saga深坑5.1 空补偿与悬挂事务问题这是Saga领域最经典的两个深坑我具体解释下。空补偿协调器给支付服务发了“扣款”命令支付服务一直没回响应比如网络断了协调器判定步骤失败开始补偿给支付服务发了“退款”命令。但事实上支付服务压根没收到过扣款命令没有扣款记录退款命令就落空了。这个时候退款服务返回“无记录可退”协调器应该怎么处理我的做法是给支付服务增加一个“按业务订单号查询支付状态”的接口。补偿命令到达后如果发现没有扣款记录就把这条补偿记录标记为“空补偿成功”直接跳过。但注意支付状态是会发生延迟变更的——核心指令可能正在第三方支付网关排队下一秒才真正扣款成功。所以空补偿要配合“检查状态”来做不能只查本地库就完事。悬挂事务刚才的场景反过来。协调器判定“扣款超时”并开始补偿结果扣款命令在补偿之后才真正到达支付服务并执行成功。这时候补偿的退款命令已经执行过了或者正在执行就会出现“扣款成功了但退款也发生了”的双重资金操作。解决悬挂问题的标准做法是在消费端引入“预占检查”。扣款命令里带上command_id和saga_id消费端执行扣款前先检查是否已经存在该command_id的补偿记录如果发现补偿已经发生过直接丢弃这个迟到的正向命令。需要把正向命令的执行记录和补偿命令的执行记录放在同一个数据源里查询逻辑才可靠。5.2 重试风暴与超时放大很多Saga项目栽在一个表面上很合理的设计调用某个服务失败了就马上重试重试失败继续重试直到把队列里的消息堆满。这种重试策略在跨服务通信里极容易引发“重试风暴”——下游服务已经故障了上游还在疯狂投递下游恢复后会发现积压了海量消息直接被打挂。我采用的策略是三级重试第一级消息队列自带的ACK重试延迟1秒、5秒、15秒最多3次。第二级超过3次后进入独立的延迟队列延迟1分钟后再投递最多持续10次。第三级仍然失败就投递到死信队列由定时任务扫描死信并通知人工介入。这里的关键不是重试次数的绝对值而是每次重试之间的间隔必须逐步扩大且最终必须有一个失败出口。没有失败出口的重试设计就是失控的死信队列就是那个出口。超时设置也有讲究。协调器给下游服务发命令后不能无限等待响应。我习惯给每个服务命令设置独立的超时时间数据库操作给5秒第三方HTTP调用给10到15秒。超时后不直接判失败而是先发送一个“查询当前状态”的请求确认对方到底执行没有再决定是重试正向命令还是走补偿。这样能把网络抖动造成的误判降到最低。5.3 死信与人工介入通道Saga模式落地后你要接受一个现实有些事务最终靠代码是补不完的必须让人去处理。比如支付服务连续宕机24小时期间所有下单Saga实例都堆积在补偿失败状态单靠自动重试没有意义。这时候死信队列就是“人工介入通道”的入口。我在项目里给死信队列配了一个简单的后台页面列出所有死信消息的Saga ID、失败原因、重试次数、最近一次错误信息。页面提供一个手动按钮——管理员可以在确认下游服务恢复后手动把死信消息重新投递到正常队列。这个功能我们内部叫“运维放行单”。死信消息重新投递时还要注意保留原有的command_id和processing_state不能重新生成否则幂等语义就断了守护程序无法判断这条消息之前执行过哪些操作会产生重复扣款、重复退款的蹊跷事故。5.4 日志与追踪分布式排查的基本功最后聊一下排查问题的基础设施。Saga跨服务之后一个Saga实例的执行轨迹散落在多个服务日志里单靠grep各服务日志做拼图太痛苦了。我的做法是统一日志字段规范。每条与Saga相关的日志都要带上saga_id、step、command_id、action_type四个字段。日志格式统一JSON举例{ saga_id: sag_20250115_8f3a2d, step: 3, command_id: cmd_20250115_9e2b41, action_type: compensate, message: refund command sent }这样在日志平台上直接按saga_id搜索就能把一次分布式事务的完整生命周期串起来哪个步骤发了命令、哪个步骤响成功、哪个步骤触发了补偿、补偿有没有成功。没有这套统一日志规范之前我只能一个个服务看日志效率极低。如果项目规模允许可以给每个服务接一套APM链路追踪工具但我个人经验是先把统一日志字段做好再用SQL去状态表和日志表里查已经能覆盖90%的Saga排查场景不必一开始就上重型调用链系统。最后再分享一个实用习惯每次上线Saga相关代码前我会先做一轮“故障演练”手动模拟几个关键服务宕机或响应超时观察Saga能不能自动进入补偿流程、补偿命令是否幂等、死信是否正确捕获。这套演练脚本一跑能提前暴露很多代码里看不出来的问题。Saga不是银弹它解决的是“最终一致性”问题不是“强一致”问题。在设计业务时你需要和产品确认某笔失败的单子允许延迟多久补账用户看到的状态是不是可以一时半会儿“悬着”只要这些问题的答案是肯定的Saga方案在PHP里就能给你一套比2PC稳妥得多、也简单得多的选择。