各位同学,大家好!我是你们的老朋友,一个在代码世界里摸爬滚打多年的PHP资深工程师。
今天我们要聊的,是分布式系统中的“终极Boss”——分布式事务。
如果不谈分布式事务,你写代码就像是在风和日丽的晴天里划船,一切都很顺滑。一旦引入分布式,你就在台风眼里冲浪了。你面对的不是一台数据库,而是一堆数据库、一堆消息队列、一堆微服务,它们就像是一个吵吵闹闹的大家庭,谁都不听谁的。
我们今天要讲的主题是:在PHP的世界里,如何用“TCC”或者“Saga”模式,优雅地实现支持回滚的分布式事务最终一致性架构。
别被这些名词吓到了,咱们把这些概念嚼碎了,咽下去,让它成为你的肌肉记忆。
第一部分:CAP定理的“求而不得”
在深入代码之前,我们必须先聊聊CAP定理。这就像是你谈恋爱,你要么找一个人Consistency(一致性),要么找一个人Availability(可用性),要么找一个人Partition Tolerance(分区容错性)。
在分布式系统里,P(分区容错性)是刚需,毕竟网络这玩意儿,时不时就断个网,抽个风。所以,我们只能在A和C之间二选一。
传统的数据库事务,讲究ACID(原子性、一致性、隔离性、持久性)。在单机环境下,ACID那是王道,完美无缺。但一旦到了分布式,ACID就成了奢望。为了保证C(一致性),系统可能会挂起请求,这就牺牲了A(可用性);为了保证A(可用性),系统可能会返回一个模糊的错误,这就牺牲了C(一致性)。
所以我们只能妥协,退而求其次,追求BASE理论(Basically Available, Soft state, Eventually consistent)。
- B(基本可用):系统嘛,别动不动就挂,偶尔掉链子也没事。
- S(软状态):数据不用时刻强一致,允许中间有个状态。
- E(最终一致性):只要你别让我等太久,我保证迟早会一致。
今天我们实现的,就是这种最终一致性,并且要支持回滚。这听起来很矛盾,对吧?回滚不就是要把事情做回去吗?怎么还能是最终一致性?嘿,这正是分布式事务的奥妙所在。
第二部分:TCC(Try-Confirm-Cancel)——分三步走
TCC模式是解决分布式事务最常用的手段之一,尤其是对于那种需要强回滚能力的场景。它的核心思想是:把一个大事务拆解成三个阶段:Try(尝试)、Confirm(确认)、Cancel(取消)。
咱们打个比方,你去餐厅吃饭。
- Try阶段(扣款+扣库存):服务员先把你的钱“冻结”了,把菜“扣减”了(但还没上桌,也没收钱)。这时候钱还没真正扣,库存也没真正少,只是锁定了资源。
- Confirm阶段(结账+上菜):如果你决定吃,服务员就真正扣款,上菜。如果服务员算错了账,或者你临时不想吃了,这时候就进入Cancel阶段。
- Cancel阶段(退钱+恢复库存):服务员把冻结的钱退给你,把扣减的库存加回去。这就是完美的回滚。
TCC的三个核心接口
在代码里,TCC不是自动生成的,得你手写。
try(): 资源预留。检查参数,检查余额,锁定资源。注意,这时候不要真的扣钱! 锁住它就行。confirm(): 业务确认。资源预留成功后,如果一切正常,执行真正的业务逻辑。cancel(): 业务取消。如果中间出错了,或者超时了,执行补偿逻辑,把资源释放掉。
第三部分:Saga模式——长流程的“剧情反转”
TCC虽然好用,但有个大问题:它要求业务逻辑能拆解成一个个原子性的Try-Confirm-Cancel操作。
如果你的业务是“用户下单购买一张机票,然后自动生成一个行程单,然后通知客户”,这还好办。但如果是“跨国转账,涉及到汇率换算、税务扣除、合规审查、最终结算”,这就太长了。TCC要写几十个Confirm和Cancel接口,维护成本高得吓人。
这时候,Saga模式就出场了。
Saga模式认为,一个长事务不应该被一个统一的控制器强行锁住,而应该像写小说一样,由一个个小故事组成。如果中间某一段故事出错了,我们就让后面的故事回滚,或者执行一个“反向操作”。
Saga有两种模式:
- 编排者模式(Orchestrator):一个中央控制器,负责指挥谁先谁后,谁失败了怎么办。
- 参与者模式(Choreography):没有中央控制器,大家靠发消息沟通。A干完活发个MQ消息给B,B干完活发个消息给C。如果B挂了,A就发个消息告诉B:“兄弟,你挂了,咱们撤吧!”
咱们今天主要讲Saga编排者模式,因为它更适合PHP这种脚本语言的处理逻辑。
第四部分:代码实战——TCC + Saga 架构在PHP中的实现
好了,话不多说,咱们来点硬菜。假设我们有一个经典的电商场景:用户下单扣库存。
我们的系统有三个微服务:
- 订单服务:负责创建订单。
- 库存服务:负责扣减库存。
- 账户服务:负责扣款。
为了演示方便,我们假设我们用PHP原生代码(或者Swoole/Hyperf框架),直接上代码。
1. 数据库表设计
这是地基。不管架构多牛,地基得稳。
-- 订单表
CREATE TABLE `orders` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`product_id` bigint(20) NOT NULL COMMENT '商品ID',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0:待处理 1:成功 2:失败',
`transaction_id` varchar(64) DEFAULT NULL COMMENT '分布式事务ID',
`version` int(11) DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
-- 库存记录表(用于TCC)
CREATE TABLE `product_inventory` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`product_id` bigint(20) NOT NULL,
`total_count` int(11) NOT NULL COMMENT '总库存',
`reserved_count` int(11) NOT NULL DEFAULT '0' COMMENT '预留库存(冻结)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB;
2. 定义TCC接口
我们定义一个基类,所有TCC业务都继承它。这就像给每个操作都上了保险。
<?php
interface TCCService
{
/**
* Try阶段:资源预留
* @param array $params 上下文参数
* @return bool
*/
public function try($params);
/**
* Confirm阶段:确认执行
* @param array $params 上下文参数
* @return bool
*/
public function confirm($params);
/**
* Cancel阶段:回滚操作
* @param array $params 上下文参数
* @return bool
*/
public function cancel($params);
}
3. 库存服务的TCC实现
这是第一个关键点:Try阶段不要写UPDATE语句,要写UPDATE ... SET reserved = reserved + X。这是为了支持并发和回滚。
class InventoryService implements TCCService
{
private $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function try($params)
{
$productId = $params['product_id'];
$count = $params['count'];
$sql = "SELECT total_count FROM product_inventory WHERE product_id = ? FOR UPDATE";
$stmt = $this->db->prepare($sql);
$stmt->execute([$productId]);
$row = $stmt->fetch();
if (!$row || $row['total_count'] < $count) {
return false; // 库存不足,Try失败
}
// 核心TCC逻辑:只增加预留库存,不减少总库存
$sql = "UPDATE product_inventory SET reserved_count = reserved_count + ? WHERE product_id = ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute([$count, $productId]);
}
public function confirm($params)
{
$productId = $params['product_id'];
$count = $params['count'];
// Confirm阶段:真正的扣减
// 为了防止并发问题,必须加版本号或者状态判断
$sql = "UPDATE product_inventory
SET total_count = total_count - ?, reserved_count = reserved_count - ?
WHERE product_id = ? AND reserved_count >= ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute([$count, $count, $productId, $count]);
}
public function cancel($params)
{
$productId = $params['product_id'];
$count = $params['count'];
// Cancel阶段:释放预留库存
$sql = "UPDATE product_inventory SET reserved_count = reserved_count - ? WHERE product_id = ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute([$count, $productId]);
}
}
4. 订单服务的Saga编排器
这才是主角。Saga编排器负责串联起所有的TCC操作,并且管理事务状态。
class OrderSagaOrchestrator
{
private $inventoryService;
private $orderService;
private $db; // 事务记录表
public function __construct(InventoryService $inventoryService, OrderService $orderService, PDO $db)
{
$this->inventoryService = $inventoryService;
$this->orderService = $orderService;
$this->db = $db;
}
/**
* 开始分布式事务
*/
public function createOrderSaga($userId, $productId, $count)
{
$txId = uniqid('TX_', true); // 生成唯一事务ID
// 开启数据库本地事务,用于记录事务日志
$this->db->beginTransaction();
try {
// 1. 插入订单记录,状态为TRYING
$this->orderService->createOrder($userId, $productId, $count, 'TRYING', $txId);
// 2. 调用库存服务的Try
$inventoryParams = ['product_id' => $productId, 'count' => $count];
if (!$this->inventoryService->try($inventoryParams)) {
throw new Exception("库存Try失败");
}
// 3. 调用账户服务的Try (假设账户服务也有TCC)
// $accountService->try(...);
// 4. 修改订单状态为CONFIRMING (等待后续Confirm)
$this->orderService->updateOrderStatus($txId, 'CONFIRMING');
// 5. 提交本地事务日志
$this->db->commit();
// 6. 启动一个异步定时任务或者监听器,去执行Confirm
// 注意:这里的Confirm通常不在主线程里同步执行,以免阻塞
$this->scheduleConfirmTask($txId, $inventoryParams);
return ['success' => true, 'tx_id' => $txId];
} catch (Exception $e) {
// 事务失败,需要执行Cancel
$this->db->rollBack();
// 3.5 立即执行Cancel
$this->inventoryService->cancel($inventoryParams);
// 4.5 更新订单状态为CANCELLED
$this->orderService->updateOrderStatus($txId, 'CANCELLED');
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* 异步执行Confirm
*/
private function scheduleConfirmTask($txId, $params)
{
// 这里通常使用Redis List或者MQ
// 比如: Queue::push(new OrderConfirmJob($txId, $params));
// 简单演示,我们只是打印
echo ">>> 派发Confirm任务: {$txId} <br>";
}
}
第五部分:幂等性——TCC的免死金牌
看到上面代码的confirm方法了吗?我写了一句 WHERE reserved_count >= ?。
这就是幂等性处理。
在分布式系统中,网络是不可靠的。你发送了一个Confirm请求,对方没收到怎么办?重发!
如果你发了一百次Confirm,你的数据库就要被扣减一百次吗?那用户得哭晕在厕所。
什么是幂等性?
输入相同,输出就相同。不管你调用多少次,结果是一样的。
在TCC中:
- Try必须是幂等的。如果Try已经成功了,再Try一次,不能重复增加
reserved_count。 - Confirm必须是幂等的。如果Confirm成功了,再Confirm一次,不能重复扣减库存。
- Cancel必须是幂等的。如果Cancel成功了,再Cancel一次,不能重复减少库存。
在代码里,幂等性通常通过唯一ID(Transaction ID)和状态机来实现。Confirm方法执行前,先查数据库,如果状态已经是CONFIRMED,就直接返回成功。
第六部分:最终一致性——MQ的补位
看到上面的scheduleConfirmTask了吗?这其实是分布式一致性的核心点。
在createOrderSaga里,我们只是把Confirm任务扔到了队列里。这意味着,用户下单成功(返回了200 OK),但库存可能还没扣,钱可能还没转。
这就是最终一致性。在Confirm任务执行之前,系统处于一种“软状态”。
为什么这么做?
因为Confirm可能是同步的(比如调用外部API),速度很慢,会拖垮主流程。我们把这个耗时的动作放到异步队列里处理,保证了主流程的高可用和快速响应。
如果Confirm失败了怎么办?
这就轮到消息队列(MQ)登场了。MQ有重试机制。
- 订单服务把Confirm任务发到MQ。
- 库存服务消费MQ消息。
- 如果扣库存失败,MQ记录失败次数。
- 如果重试N次还是失败,消息进入死信队列。
- 运维同学看到死信队列报警,人工介入修复数据。
第七部分:Saga编排者的健壮性
上面的OrderSagaOrchestrator是基础版。但在生产环境中,它太脆弱了。
问题来了:如果try成功了,confirm执行过程中,或者执行完后,服务挂了呢?
- 情况A:
try成功,confirm还没执行,服务挂了。- 解决方案:需要定时任务扫描器。每隔几分钟扫描所有状态为
TRYING或CONFIRMING的订单,重新发起Confirm逻辑。如果Confirm一直失败,转为Cancel。
- 解决方案:需要定时任务扫描器。每隔几分钟扫描所有状态为
- 情况B:
confirm执行了,但数据库还没刷盘就挂了。- 解决方案:本地消息表。在执行Confirm时,先往本地数据库插一条“Confirm日志”,然后再执行业务逻辑。如果业务逻辑报错,就回滚日志。如果业务逻辑成功,但挂了,定时任务也能看到这条日志,继续执行。
第八部分:PHP生态下的最佳实践
如果你用的是PHP,有几个坑得注意。
- 进程长驻:PHP通常是脚本语言,请求结束进程就销毁了。在分布式事务里,我们建议使用 Swoole、OpenSwoole 或者 Workerman 来运行你的微服务。你需要一个长驻的进程来维持数据库连接,并且处理异步任务(MQ Consumer)。
- 协程:如果你的框架支持协程(比如Hyperf),可以利用协程的异步非阻塞特性,极大提高并发量。比如,同时向多个下游服务发起Try请求。
- 框架选择:不要重复造轮子。
- Seata:阿里的开源分布式事务框架,支持TCC和Saga模式,有PHP版本。
- EasySwoole:基于Swoole,支持分布式事务组件。
第九部分:架构全景图
让我们把这东西串起来。
- 用户发起请求。
- API网关转发到订单服务。
- 订单服务开启本地事务,写入订单记录(状态TRYING),记录TX_ID。
- 订单服务作为Saga编排者,通过RPC调用库存服务的
try()。 - 库存服务通过数据库
SELECT ... FOR UPDATE锁定库存,更新reserved_count。 - 库存服务返回成功。
- 订单服务将
confirm任务扔入Redis或MQ。 - 订单服务返回成功给用户。
- 定时任务或MQ消费者执行
confirm(),真正扣减库存。 - 如果哪一步挂了,定时任务扫描到异常状态,触发
cancel()回滚。
第十部分:陷阱与避坑指南
写分布式事务,就是和Bug赛跑。这里有三个大坑:
坑一:Cancel回滚失败
有时候,资源已经被其他事务占用了,或者逻辑写错了。Cancel抛了异常,但日志没打印全。
- 对策:所有的Try/Confirm/Cancel方法,返回值都要严格校验。如果Cancel失败,要记录详细的日志,甚至报警,通知运维去查。
坑二:脏读
在Try阶段,数据处于“软状态”,其他事务可能读取到这些未确认的数据。
- 对策:在业务层面做隔离。比如,库存Try阶段,只允许“减库存”操作,不允许“买库存”。
坑三:超时
分布式系统里,网络延迟是不可控的。如果Try超过了5秒还没收到响应,怎么办?
- 对策:设置全局超时时间。超过超时时间,直接触发Cancel,标记事务失败。
总结
好了,同学们,咱们今天的讲座也到了尾声。
回顾一下,我们在PHP中实现支持回滚的分布式事务最终一致性,核心思路是:
- 拒绝ACID,拥抱BASE。
- 使用TCC模式,将大事务拆解为Try-Confirm-Cancel三个原子操作。
- 利用Saga编排者串联业务流程,保证Try成功后,Confirm必定执行。
- 善用消息队列(MQ)处理Confirm的异步逻辑,保证主流程不阻塞。
- 死磕幂等性,防止重试导致的数据错误。
- 如果是长流程,或者不想手写TCC,可以研究Saga模式(编排者模式)。
PHP虽然常被诟病“跑得快但不耐操”,但在微服务架构下,配合Swoole、MQ和合理的TCC设计,一样能写出高性能、高可用的分布式系统。
分布式事务就像走钢丝,看着摇摇晃晃,心里发慌,但只要掌握了TCC和Saga这两根平衡杆,你就能在云端跳起优美的华尔兹。记住,一致性不是瞬间完成的,它是经过无数次的尝试、确认、补偿,最终达成的完美平衡。
希望今天的分享能帮大家解开这个结,下次写代码时,如果遇到分布式事务,别慌,深呼吸,先问问自己:我是用TCC还是Saga?
谢谢大家!