PHP如何实现支持回滚的分布式事务最终一致性架构

各位同学,大家好!我是你们的老朋友,一个在代码世界里摸爬滚打多年的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(取消)。

咱们打个比方,你去餐厅吃饭。

  1. Try阶段(扣款+扣库存):服务员先把你的钱“冻结”了,把菜“扣减”了(但还没上桌,也没收钱)。这时候钱还没真正扣,库存也没真正少,只是锁定了资源。
  2. Confirm阶段(结账+上菜):如果你决定吃,服务员就真正扣款,上菜。如果服务员算错了账,或者你临时不想吃了,这时候就进入Cancel阶段。
  3. Cancel阶段(退钱+恢复库存):服务员把冻结的钱退给你,把扣减的库存加回去。这就是完美的回滚。

TCC的三个核心接口

在代码里,TCC不是自动生成的,得你手写。

  1. try(): 资源预留。检查参数,检查余额,锁定资源。注意,这时候不要真的扣钱! 锁住它就行。
  2. confirm(): 业务确认。资源预留成功后,如果一切正常,执行真正的业务逻辑。
  3. cancel(): 业务取消。如果中间出错了,或者超时了,执行补偿逻辑,把资源释放掉。

第三部分:Saga模式——长流程的“剧情反转”

TCC虽然好用,但有个大问题:它要求业务逻辑能拆解成一个个原子性的Try-Confirm-Cancel操作。

如果你的业务是“用户下单购买一张机票,然后自动生成一个行程单,然后通知客户”,这还好办。但如果是“跨国转账,涉及到汇率换算、税务扣除、合规审查、最终结算”,这就太长了。TCC要写几十个Confirm和Cancel接口,维护成本高得吓人。

这时候,Saga模式就出场了。

Saga模式认为,一个长事务不应该被一个统一的控制器强行锁住,而应该像写小说一样,由一个个小故事组成。如果中间某一段故事出错了,我们就让后面的故事回滚,或者执行一个“反向操作”。

Saga有两种模式:

  1. 编排者模式(Orchestrator):一个中央控制器,负责指挥谁先谁后,谁失败了怎么办。
  2. 参与者模式(Choreography):没有中央控制器,大家靠发消息沟通。A干完活发个MQ消息给B,B干完活发个消息给C。如果B挂了,A就发个消息告诉B:“兄弟,你挂了,咱们撤吧!”

咱们今天主要讲Saga编排者模式,因为它更适合PHP这种脚本语言的处理逻辑。

第四部分:代码实战——TCC + Saga 架构在PHP中的实现

好了,话不多说,咱们来点硬菜。假设我们有一个经典的电商场景:用户下单扣库存

我们的系统有三个微服务:

  1. 订单服务:负责创建订单。
  2. 库存服务:负责扣减库存。
  3. 账户服务:负责扣款。

为了演示方便,我们假设我们用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有重试机制。

  1. 订单服务把Confirm任务发到MQ。
  2. 库存服务消费MQ消息。
  3. 如果扣库存失败,MQ记录失败次数。
  4. 如果重试N次还是失败,消息进入死信队列
  5. 运维同学看到死信队列报警,人工介入修复数据。

第七部分:Saga编排者的健壮性

上面的OrderSagaOrchestrator是基础版。但在生产环境中,它太脆弱了。

问题来了:如果try成功了,confirm执行过程中,或者执行完后,服务挂了呢?

  • 情况Atry成功,confirm还没执行,服务挂了。
    • 解决方案:需要定时任务扫描器。每隔几分钟扫描所有状态为TRYINGCONFIRMING的订单,重新发起Confirm逻辑。如果Confirm一直失败,转为Cancel。
  • 情况Bconfirm执行了,但数据库还没刷盘就挂了。
    • 解决方案:本地消息表。在执行Confirm时,先往本地数据库插一条“Confirm日志”,然后再执行业务逻辑。如果业务逻辑报错,就回滚日志。如果业务逻辑成功,但挂了,定时任务也能看到这条日志,继续执行。

第八部分:PHP生态下的最佳实践

如果你用的是PHP,有几个坑得注意。

  1. 进程长驻:PHP通常是脚本语言,请求结束进程就销毁了。在分布式事务里,我们建议使用 SwooleOpenSwoole 或者 Workerman 来运行你的微服务。你需要一个长驻的进程来维持数据库连接,并且处理异步任务(MQ Consumer)。
  2. 协程:如果你的框架支持协程(比如Hyperf),可以利用协程的异步非阻塞特性,极大提高并发量。比如,同时向多个下游服务发起Try请求。
  3. 框架选择:不要重复造轮子。
    • Seata:阿里的开源分布式事务框架,支持TCC和Saga模式,有PHP版本。
    • EasySwoole:基于Swoole,支持分布式事务组件。

第九部分:架构全景图

让我们把这东西串起来。

  1. 用户发起请求。
  2. API网关转发到订单服务
  3. 订单服务开启本地事务,写入订单记录(状态TRYING),记录TX_ID。
  4. 订单服务作为Saga编排者,通过RPC调用库存服务try()
  5. 库存服务通过数据库SELECT ... FOR UPDATE锁定库存,更新reserved_count
  6. 库存服务返回成功。
  7. 订单服务confirm任务扔入RedisMQ
  8. 订单服务返回成功给用户。
  9. 定时任务MQ消费者执行confirm(),真正扣减库存。
  10. 如果哪一步挂了,定时任务扫描到异常状态,触发cancel()回滚。

第十部分:陷阱与避坑指南

写分布式事务,就是和Bug赛跑。这里有三个大坑:

坑一:Cancel回滚失败
有时候,资源已经被其他事务占用了,或者逻辑写错了。Cancel抛了异常,但日志没打印全。

  • 对策:所有的Try/Confirm/Cancel方法,返回值都要严格校验。如果Cancel失败,要记录详细的日志,甚至报警,通知运维去查。

坑二:脏读
在Try阶段,数据处于“软状态”,其他事务可能读取到这些未确认的数据。

  • 对策:在业务层面做隔离。比如,库存Try阶段,只允许“减库存”操作,不允许“买库存”。

坑三:超时
分布式系统里,网络延迟是不可控的。如果Try超过了5秒还没收到响应,怎么办?

  • 对策:设置全局超时时间。超过超时时间,直接触发Cancel,标记事务失败。

总结

好了,同学们,咱们今天的讲座也到了尾声。

回顾一下,我们在PHP中实现支持回滚的分布式事务最终一致性,核心思路是:

  1. 拒绝ACID,拥抱BASE
  2. 使用TCC模式,将大事务拆解为Try-Confirm-Cancel三个原子操作。
  3. 利用Saga编排者串联业务流程,保证Try成功后,Confirm必定执行。
  4. 善用消息队列(MQ)处理Confirm的异步逻辑,保证主流程不阻塞。
  5. 死磕幂等性,防止重试导致的数据错误。
  6. 如果是长流程,或者不想手写TCC,可以研究Saga模式(编排者模式)。

PHP虽然常被诟病“跑得快但不耐操”,但在微服务架构下,配合Swoole、MQ和合理的TCC设计,一样能写出高性能、高可用的分布式系统。

分布式事务就像走钢丝,看着摇摇晃晃,心里发慌,但只要掌握了TCC和Saga这两根平衡杆,你就能在云端跳起优美的华尔兹。记住,一致性不是瞬间完成的,它是经过无数次的尝试、确认、补偿,最终达成的完美平衡。

希望今天的分享能帮大家解开这个结,下次写代码时,如果遇到分布式事务,别慌,深呼吸,先问问自己:我是用TCC还是Saga?

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注