做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在PHP+MySQL场景下有两个硬伤:一是协调者和参与者之间要维持长时间的锁资源,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 Stream
Saga模式天然依赖异步消息,所以先得选定消息中间件。我把三套在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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Saga实例状态表';协调器的主循环代码骨架如下,我用的是队列消费者方式。每个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`) ) ENGINE=InnoDB;每个补偿步骤都要先写一条待执行记录,等对应的补偿命令返回成功后才置为成功。协调器重启后扫描状态为“待执行”的补偿记录,继续补发即可。
4.4 幂等键与消息的“唯一执行”保障
前面反复提到幂等,这里给出正式的设计手段。每条命令消息附加一个command_id,消费端在执行业务逻辑前先落一条记录:
CREATE TABLE `processed_command` ( `command_id` varchar(64) NOT NULL, `processed_at` datetime NOT NULL, PRIMARY KEY (`command_id`) ) ENGINE=InnoDB;消费端伪代码:
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稳妥得多、也简单得多的选择。