分布式事务常见方案

作者:old wang 发布时间: 2025-07-21 阅读量:0 评论数:0

分布式事务是微服务项目里绕不开的问题。

但真正落到项目里的问题:

一个业务操作拆到了多个服务里,本地事务不再管用,数据开始出现不一致。

我们从一个常见的下单链路开始,复盘一次从本地事务到最终一致性方案的演进过程。

业务场景如下:

用户下单
  |
  |-- 创建订单
  |-- 预占库存
  |-- 预占优惠券
  |-- 创建支付单
  |-- 支付成功后加积分
  |-- 发送通知

系统拆分后,服务和数据库大概是这样:

order-service       -> order_db
stock-service       -> stock_db
coupon-service      -> coupon_db
payment-service     -> payment_db
points-service      -> points_db
notify-service      -> notify_db

早期系统里,这些表可能都在一个库中,一个 @Transactional 就能解决。

拆成微服务之后,问题就变了:

订单创建成功了,库存没扣
库存冻结成功了,优惠券使用失败
支付成功了,积分迟迟没到账
通知发送失败了,但主流程已经完成

这些问题本质上都是跨服务、跨数据库的一致性问题。

原来的方案

最开始系统还是单体应用。

订单、库存、优惠券都在一个 MySQL 库中。

代码大概是这样:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private CouponMapper couponMapper;

    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(CreateOrderRequest request) {
        // 1. 创建订单
        Order order = Order.create(request);
        orderMapper.insert(order);

        // 2. 扣减库存
        int stockRows = stockMapper.deductStock(
                request.getSkuId(),
                request.getQuantity()
        );

        if (stockRows <= 0) {
            throw new BizException("库存不足");
        }

        // 3. 使用优惠券
        if (request.getCouponId() != null) {
            int couponRows = couponMapper.useCoupon(
                    request.getUserId(),
                    request.getCouponId()
            );

            if (couponRows <= 0) {
                throw new BizException("优惠券不可用");
            }
        }

        return order.getId();
    }
}

这种写法在单体应用里没问题。

因为订单表、库存表、优惠券表都在同一个数据库里,Spring 本地事务可以保证这些操作一起提交或一起回滚。

后来系统拆成了微服务。

订单、库存、优惠券分别拆到了不同服务中。

代码变成了这样:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StockClient stockClient;

    @Autowired
    private CouponClient couponClient;

    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(CreateOrderRequest request) {
        // 1. 创建订单
        Order order = Order.create(request);
        orderMapper.insert(order);

        // 2. 远程扣减库存
        stockClient.deduct(request.getSkuId(), request.getQuantity());

        // 3. 远程使用优惠券
        if (request.getCouponId() != null) {
            couponClient.use(request.getUserId(), request.getCouponId());
        }

        return order.getId();
    }
}

这段代码看起来问题不大,但实际风险很高。

关键原因是:

@Transactional 只能管理当前服务的本地数据库事务,不能管理远程服务里的数据库事务。

也就是说:

order-service 的 @Transactional
只能回滚 order_db

不能回滚 stock-service 的 stock_db
也不能回滚 coupon-service 的 coupon_db

一旦远程调用中间某一步失败,就可能出现部分成功、部分失败。

问题在哪里

1. 本地事务和远程调用不是一个事务

假设下单流程是:

1. order_db 插入订单成功
2. stock-service 扣减库存成功
3. coupon-service 使用优惠券失败
4. order-service 抛异常,回滚本地订单

最终结果可能变成:

订单没了
库存扣了
优惠券没用

从订单服务看,本地事务回滚了。

但库存服务已经提交了自己的本地事务。

这就是典型的分布式一致性问题。

2. 远程调用超时,不代表对方没有执行

微服务调用里经常会遇到超时:

订单服务调用库存服务
  |
  |-- 请求已到达库存服务
  |-- 库存服务扣减成功
  |-- 响应返回时网络超时
  |
订单服务认为调用失败

调用方看到的是失败。

但被调用方可能已经执行成功。

如果订单服务这时候直接回滚本地订单,数据就不一致了。

3. 简单重试可能导致重复执行

很多人遇到远程调用失败,第一反应是重试。

但没有幂等的重试很危险。

例如:

第一次扣库存成功,但响应超时
订单服务发起第二次重试
库存再次扣减

这会导致库存重复扣减。

所以只要系统里存在远程调用、消息消费、定时补偿,就必须设计幂等。

常见幂等键包括:

requestNo
orderNo
payNo
eventId
第三方回调流水号

4. 长链路同步调用会影响用户体验

如果下单接口里同步完成所有动作:

创建订单
扣库存
用优惠券
创建支付单
加积分
发送短信
同步数据

那么链路越长,接口越慢。

其中任何一个服务抖动,都会影响用户下单。

对用户来说,表现就是:

下单按钮一直转圈
偶尔下单失败
重复点击产生重复订单

所以这不是单纯的事务问题,也是业务链路拆分问题。

优化思路

分布式事务不能一上来就套框架。

应该先区分业务动作的一致性要求。

在下单场景里,不同动作的重要程度并不一样:

创建订单        核心动作,必须可靠
预占库存        核心动作,不能超卖
预占优惠券      核心动作,不能重复使用
创建支付单      核心动作,需要和订单状态对齐
支付后加积分    可最终一致
发送通知        可弱一致
数据同步        可最终一致

所以更合理的做法是分层处理:

核心链路:同步处理,状态可恢复
后置动作:异步处理,最终一致
通知动作:最大努力通知

整体链路可以调整为:

用户下单
  |
  v
订单服务创建订单,状态 INIT
  |
  v
预占库存、预占优惠券
  |
  v
预占成功,订单状态 CREATED
  |
  v
用户支付
  |
  v
支付回调
  |
  v
更新支付单和订单状态
  |
  v
写入本地事件表
  |
  v
异步发布消息
  |
  |-- 积分服务加积分
  |-- 通知服务发通知
  |-- 其他系统同步数据

这里的重点不是追求一个“大而全”的全局事务。

而是把业务拆成几类:

必须同步保证的,放在核心链路
可以延迟完成的,放到异步事件
失败可以接受的,做最大努力通知
异常必须可恢复,依赖状态机和补偿任务

核心实现

方案演进

第一阶段:订单状态机 + 资源预占

第一阶段适合业务量不大、服务拆分刚开始的系统。

核心思路是:

1. 本地事务只处理本地数据
2. 远程调用接口必须幂等
3. 核心资源采用预占模型
4. 异常状态通过补偿任务恢复

订单状态可以设计为:

INIT            初始化
RESERVING       资源预占中
CREATED         订单创建成功,等待支付
RESERVE_FAILED  资源预占失败
PAID            已支付
CLOSED          已关闭

状态流转如下:

INIT
  |
  | 资源预占
  v
RESERVING
  |
  | 预占成功
  v
CREATED
  |
  | 支付成功
  v
PAID

RESERVING
  |
  | 预占失败
  v
RESERVE_FAILED / CLOSED

下单时先创建订单。

@Service
public class OrderAppService {

    @Autowired
    private OrderMapper orderMapper;

    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(CreateOrderRequest request) {
        // 1. 防重复下单
        Order exists = orderMapper.selectByRequestNo(request.getRequestNo());
        if (exists != null) {
            return exists.getId();
        }

        // 2. 创建订单,初始状态为 INIT
        Order order = Order.init(request);
        orderMapper.insert(order);

        return order.getId();
    }
}

这里建议对 request_no 加唯一索引。

ALTER TABLE orders
ADD UNIQUE KEY uk_request_no (request_no);

这样即使用户重复点击,或者接口被重试,也不会创建多笔订单。

接下来执行资源预占。

资源预占不要简单地先查订单状态再执行,因为多个线程可能同时处理同一笔订单。

更稳妥的做法是用状态抢占。

@Service
public class OrderReserveService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StockClient stockClient;

    @Autowired
    private CouponClient couponClient;

    public void reserveResource(Long orderId) {
        // 1. 抢占订单处理权:INIT -> RESERVING
        int rows = orderMapper.updateStatus(
                orderId,
                OrderStatus.INIT,
                OrderStatus.RESERVING
        );

        if (rows <= 0) {
            return;
        }

        Order order = orderMapper.selectById(orderId);

        boolean stockReserved = false;
        boolean couponReserved = false;

        try {
            // 2. 预占库存
            stockClient.reserve(new StockReserveRequest(
                    order.getOrderNo(),
                    order.getSkuId(),
                    order.getQuantity()
            ));
            stockReserved = true;

            // 3. 预占优惠券
            if (order.getCouponId() != null) {
                couponClient.reserve(new CouponReserveRequest(
                        order.getOrderNo(),
                        order.getUserId(),
                        order.getCouponId()
                ));
                couponReserved = true;
            }

            // 4. 预占全部成功,订单进入 CREATED
            orderMapper.updateStatus(
                    orderId,
                    OrderStatus.RESERVING,
                    OrderStatus.CREATED
            );
        } catch (Exception e) {
            // 5. 只要中间失败,需要释放已经预占成功的资源
            releaseReservedResource(order, stockReserved, couponReserved);

            orderMapper.updateStatus(
                    orderId,
                    OrderStatus.RESERVING,
                    OrderStatus.RESERVE_FAILED
            );

            throw e;
        }
    }

    private void releaseReservedResource(Order order,
                                         boolean stockReserved,
                                         boolean couponReserved) {
        if (couponReserved) {
            couponClient.release(new CouponReleaseRequest(
                    order.getOrderNo(),
                    order.getUserId(),
                    order.getCouponId()
            ));
        }

        if (stockReserved) {
            stockClient.release(new StockReleaseRequest(
                    order.getOrderNo(),
                    order.getSkuId()
            ));
        }
    }
}

这里有一个关键点:

库存预占成功、优惠券预占失败时,必须释放已经冻结的库存。

不能只依赖订单超时任务慢慢释放。

否则在高峰期可能出现大量库存被冻结,用户看到有库存但无法购买。

订单状态更新 SQL 可以写成 CAS 形式:

UPDATE orders
SET status = #{newStatus},
    update_time = NOW()
WHERE id = #{orderId}
  AND status = #{oldStatus};

这样可以避免并发状态覆盖。

库存服务如何实现预占

库存服务不要直接扣减最终库存。

更推荐使用“可用库存 + 冻结库存”的模型。

available_stock  可用库存
frozen_stock     冻结库存

预占库存时:

available_stock - quantity
frozen_stock + quantity

确认扣减时:

frozen_stock - quantity

释放库存时:

available_stock + quantity
frozen_stock - quantity

预占接口示例:

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private StockFreezeMapper stockFreezeMapper;

    @Transactional(rollbackFor = Exception.class)
    public void reserve(StockReserveRequest request) {
        // 1. 幂等判断
        StockFreeze exists = stockFreezeMapper.selectByBizNo(request.getOrderNo());
        if (exists != null) {
            return;
        }

        // 2. 扣减可用库存,增加冻结库存
        int rows = stockMapper.reserve(
                request.getSkuId(),
                request.getQuantity()
        );

        if (rows <= 0) {
            throw new BizException("库存不足");
        }

        // 3. 记录冻结明细
        stockFreezeMapper.insert(StockFreeze.reserved(
                request.getOrderNo(),
                request.getSkuId(),
                request.getQuantity()
        ));
    }
}

对应 SQL:

UPDATE product_stock
SET available_stock = available_stock - #{quantity},
    frozen_stock = frozen_stock + #{quantity},
    update_time = NOW()
WHERE sku_id = #{skuId}
  AND available_stock >= #{quantity};

冻结记录表:

CREATE TABLE stock_freeze (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_no VARCHAR(64) NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    status VARCHAR(32) NOT NULL,
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    UNIQUE KEY uk_biz_no (biz_no)
);

biz_no 可以使用订单号。

这个唯一索引是幂等的最后防线。

不要只依赖 Redis 防重。

Redis 可以做前置拦截,但数据库唯一索引才是最终兜底。

释放冻结库存:

@Transactional(rollbackFor = Exception.class)
public void release(StockReleaseRequest request) {
    StockFreeze freeze = stockFreezeMapper.selectByBizNo(request.getOrderNo());

    if (freeze == null) {
        return;
    }

    if (FreezeStatus.RELEASED.equals(freeze.getStatus())) {
        return;
    }

    if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
        throw new BizException("库存已经确认扣减,不能释放");
    }

    int rows = stockMapper.release(
            freeze.getSkuId(),
            freeze.getQuantity()
    );

    if (rows <= 0) {
        throw new BizException("释放库存失败");
    }

    stockFreezeMapper.updateStatus(
            freeze.getBizNo(),
            FreezeStatus.RELEASED
    );
}

释放库存 SQL:

UPDATE product_stock
SET available_stock = available_stock + #{quantity},
    frozen_stock = frozen_stock - #{quantity},
    update_time = NOW()
WHERE sku_id = #{skuId}
  AND frozen_stock >= #{quantity};

确认扣减库存:

@Transactional(rollbackFor = Exception.class)
public void confirm(StockConfirmRequest request) {
    StockFreeze freeze = stockFreezeMapper.selectByBizNo(request.getOrderNo());

    if (freeze == null) {
        throw new BizException("库存冻结记录不存在");
    }

    if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
        return;
    }

    if (FreezeStatus.RELEASED.equals(freeze.getStatus())) {
        throw new BizException("库存已释放,不能确认扣减");
    }

    int rows = stockMapper.confirm(
            freeze.getSkuId(),
            freeze.getQuantity()
    );

    if (rows <= 0) {
        throw new BizException("确认扣减库存失败");
    }

    stockFreezeMapper.updateStatus(
            freeze.getBizNo(),
            FreezeStatus.CONFIRMED
    );
}

确认扣减 SQL:

UPDATE product_stock
SET frozen_stock = frozen_stock - #{quantity},
    update_time = NOW()
WHERE sku_id = #{skuId}
  AND frozen_stock >= #{quantity};

这个模型不等于严格强一致。

它适合中小规模业务或系统演进初期,依赖的是:

状态机
幂等
补偿任务
冻结记录
异常可恢复

第二阶段:本地事件表 Outbox 保证最终一致

订单支付成功后,通常还要做很多后置动作:

加积分
发通知
同步搜索系统
同步数据仓库
触发营销任务

这些动作不应该阻塞支付回调主链路。

更合适的做法是使用本地事件表,也就是 Outbox Pattern。

核心思路:

业务数据和事件数据写在同一个本地事务里
后续由异步任务把事件投递到 MQ
消费端通过幂等保证重复消费不出问题

支付回调流程:

支付回调
  |
  v
本地事务:
  1. 更新支付单状态
  2. 更新订单状态为 PAID
  3. 写入 outbox_event
  |
  v
异步任务扫描 outbox_event
  |
  v
发送 RocketMQ 消息
  |
  v
积分服务、通知服务消费消息

事件表设计:

CREATE TABLE outbox_event (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
    event_id VARCHAR(64) NOT NULL COMMENT '事件唯一 ID,用于事件幂等和排查',
    event_type VARCHAR(64) NOT NULL COMMENT '事件类型,例如 ORDER_PAID、ORDER_CREATED',
    biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,例如订单号、支付单号',
    payload TEXT NOT NULL COMMENT '事件内容,通常存储 JSON 字符串',
    status VARCHAR(32) NOT NULL COMMENT '事件状态:PENDING=待投递,PROCESSING=投递中,RETRY=等待重试,SUCCESS=投递成功,FAILED=投递失败',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
    next_retry_time DATETIME NOT NULL COMMENT '下次可重试时间',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_event_id (event_id),
    KEY idx_status_retry_time (status, next_retry_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地事件表,用于保证业务操作与消息投递的最终一致性';

支付成功后写入事件:

@Service
public class PayCallbackService {

    @Autowired
    private PaymentMapper paymentMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OutboxEventMapper outboxEventMapper;

    @Transactional(rollbackFor = Exception.class)
    public void handlePaySuccess(PayCallbackRequest request) {
        Payment payment = paymentMapper.selectByPayNo(request.getPayNo());

        if (payment == null) {
            throw new BizException("支付单不存在");
        }

        // 支付回调幂等
        if (PaymentStatus.SUCCESS.equals(payment.getStatus())) {
            return;
        }

        paymentMapper.updateStatus(
                payment.getId(),
                PaymentStatus.SUCCESS
        );

        orderMapper.updateStatus(
                payment.getOrderId(),
                OrderStatus.CREATED,
                OrderStatus.PAID
        );

        OrderPaidEvent event = new OrderPaidEvent(
                payment.getOrderNo(),
                payment.getUserId(),
                payment.getPayAmount()
        );

        outboxEventMapper.insert(OutboxEvent.pending(
                IdGenerator.uuid(),
                "ORDER_PAID",
                payment.getOrderNo(),
                JsonUtils.toJson(event)
        ));
    }
}

这段逻辑保证了一件事:

订单支付成功,事件一定存在
订单支付失败,事件一定不存在

因为订单状态和事件表在同一个本地事务中。

Outbox 事件发布

事件发布任务负责扫描 PENDINGRETRY 状态的事件。

多实例部署时,不能简单查询出来就发送。

需要先抢占事件。

@Component
public class OutboxEventPublisher {

    @Autowired
    private OutboxEventMapper outboxEventMapper;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Scheduled(fixedDelay = 3000)
    public void publish() {
        List<OutboxEvent> events = outboxEventMapper.selectPendingEvents(100);

        for (OutboxEvent event : events) {
            int locked = outboxEventMapper.markProcessing(event.getId());

            if (locked <= 0) {
                continue;
            }

            try {
                rocketMQTemplate.convertAndSend(
                        event.getEventType(),
                        event.getPayload()
                );

                outboxEventMapper.markSuccess(event.getId());
            } catch (Exception e) {
                outboxEventMapper.markRetry(
                        event.getId(),
                        calculateNextRetryTime(event.getRetryCount() + 1)
                );
            }
        }
    }

    private LocalDateTime calculateNextRetryTime(int retryCount) {
        int[] intervals = {1, 5, 10, 30, 60, 120};
        int minutes = intervals[Math.min(retryCount - 1, intervals.length - 1)];
        return LocalDateTime.now().plusMinutes(minutes);
    }
}

抢占 SQL:

UPDATE outbox_event
SET status = 'PROCESSING',
    update_time = NOW()
WHERE id = #{id}
  AND status IN ('PENDING', 'RETRY')
  AND next_retry_time <= NOW();

发送成功:

UPDATE outbox_event
SET status = 'SUCCESS',
    update_time = NOW()
WHERE id = #{id}
  AND status = 'PROCESSING';

发送失败:

UPDATE outbox_event
SET status = 'RETRY',
    retry_count = retry_count + 1,
    next_retry_time = #{nextRetryTime},
    update_time = NOW()
WHERE id = #{id}
  AND status = 'PROCESSING';

这里还要补一个生产环境细节。

如果服务把事件抢占成 PROCESSING 后宕机,这条事件会一直卡住。

所以必须有超时恢复任务。

@Component
public class OutboxEventRecoverJob {

    @Autowired
    private OutboxEventMapper outboxEventMapper;

    @Scheduled(fixedDelay = 60000)
    public void recoverProcessingEvents() {
        outboxEventMapper.recoverTimeoutProcessingEvents(
                LocalDateTime.now().minusMinutes(5)
        );
    }
}

恢复 SQL:

UPDATE outbox_event
SET status = 'RETRY',
    next_retry_time = NOW(),
    update_time = NOW()
WHERE status = 'PROCESSING'
  AND update_time < #{timeoutTime};

这样 Outbox 方案才形成闭环:

业务表 + 事件表同事务写入
  |
  v
PENDING / RETRY 事件扫描
  |
  v
抢占为 PROCESSING
  |
  v
发送 MQ
  |
  |-- 成功:SUCCESS
  |-- 失败:RETRY
  |-- 宕机:PROCESSING 超时恢复
  |
  v
消费端幂等处理

注意,Outbox 不保证消息只发送一次。

比如 MQ 发送成功后,服务还没来得及把事件改成 SUCCESS 就宕机了。

恢复后可能会再次发送。

所以消费端必须幂等。

消费端幂等

以积分服务为例。

订单支付成功后,积分服务消费 ORDER_PAID 事件。

@Component
@RocketMQMessageListener(
        topic = "ORDER_PAID",
        consumerGroup = "points-service-order-paid-consumer"
)
public class OrderPaidConsumer implements RocketMQListener<String> {

    @Autowired
    private PointsService pointsService;

    @Override
    public void onMessage(String message) {
        OrderPaidEvent event = JsonUtils.fromJson(message, OrderPaidEvent.class);
        pointsService.addPoints(event);
    }
}

积分入账逻辑:

@Service
public class PointsService {

    @Autowired
    private PointsAccountMapper pointsAccountMapper;

    @Autowired
    private PointsRecordMapper pointsRecordMapper;

    @Transactional(rollbackFor = Exception.class)
    public void addPoints(OrderPaidEvent event) {
        // 1. 幂等判断
        PointsRecord exists = pointsRecordMapper.selectByBizNo(event.getOrderNo());
        if (exists != null) {
            return;
        }

        int points = calculatePoints(event.getPayAmount());

        // 2. 增加积分账户余额
        pointsAccountMapper.increase(
                event.getUserId(),
                points
        );

        // 3. 记录积分流水
        pointsRecordMapper.insert(PointsRecord.create(
                event.getOrderNo(),
                event.getUserId(),
                points
        ));
    }

    private int calculatePoints(BigDecimal payAmount) {
        return payAmount.intValue();
    }
}

积分流水表需要对业务单号加唯一索引:

CREATE TABLE points_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
    biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,用于幂等控制,例如订单号',
    user_id BIGINT NOT NULL COMMENT '用户 ID',
    points INT NOT NULL COMMENT '积分变动数量,正数表示增加积分,负数表示扣减积分',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    UNIQUE KEY uk_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分流水表,用于记录积分变动明细并保证消费幂等';

这样即使消息重复消费,也不会重复加积分。

这个方案的边界很清楚:

支付成功后,积分不一定立即到账
但只要事件没有丢,最终会到账
即使消息重复投递,消费端也不会重复加积分

这就是最终一致性。

常见分布式事务方案怎么选

原文里提到了很多方案,包括 2PC、3PC、TCC、事务消息、最大努力通知、Seata AT、本地事件表。

这些方案不是并列地随便选。

更重要的是看业务场景。

1. 2PC / XA:强一致,但性能和可用性成本高

2PC 是两阶段提交。

可以简化理解为:

第一阶段:Prepare
协调者询问所有参与者能否提交
参与者执行事务但不提交,并锁定资源

第二阶段:Commit / Rollback
所有参与者都准备成功,则统一提交
任一参与者失败,则统一回滚

流程:

Coordinator
    |
    | prepare
    v
Participant A  -> yes
Participant B  -> yes
Participant C  -> yes
    |
    | commit
    v
All Commit

优点是强一致。

缺点也明显:

同步阻塞
资源锁持有时间长
协调者故障会影响整体事务
不适合高并发长链路业务

它更适合低频、关键、强一致的内部系统。

普通互联网订单链路里,不建议默认使用 2PC。

2. 3PC:更多是理论方案,项目中较少直接落地

3PC 在 2PC 基础上增加了预提交阶段:

CanCommit
PreCommit
DoCommit

它试图降低 2PC 的阻塞问题。

但在真实 Java 后端项目里,3PC 很少作为常规方案直接落地。

原因是:

实现复杂
网络分区下仍然可能不一致
工程上通常会选择最终一致性或补偿方案

所以 3PC 可以作为理论知识了解,不建议在普通业务系统里强行引入。

3. TCC:适合核心资源预占,但业务侵入强

TCC 是业务层面的补偿事务。

它把一个业务动作拆成三个阶段:

Try      预留资源
Confirm  确认提交
Cancel   取消释放

以下单扣库存为例:

Try:冻结库存
Confirm:扣减冻结库存
Cancel:释放冻结库存

接口可以设计成:

public interface StockTccService {

    void tryReserve(String orderNo, Long skuId, Integer quantity);

    void confirm(String orderNo);

    void cancel(String orderNo);
}

TCC 必须有明确状态机:

RESERVED -> CONFIRMED
RESERVED -> CANCELED

CONFIRMED 不能再 CANCELED
CANCELED 不能再 CONFIRMED

Confirm 示例:

@Transactional(rollbackFor = Exception.class)
public void confirm(String orderNo) {
    StockFreeze freeze = stockFreezeMapper.selectByBizNo(orderNo);

    if (freeze == null) {
        throw new BizException("冻结记录不存在");
    }

    if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
        return;
    }

    if (FreezeStatus.CANCELED.equals(freeze.getStatus())) {
        throw new BizException("已取消的冻结记录不能确认");
    }

    stockMapper.confirmDeduct(
            freeze.getSkuId(),
            freeze.getQuantity()
    );

    stockFreezeMapper.updateStatus(
            orderNo,
            FreezeStatus.CONFIRMED
    );
}

Cancel 示例:

@Transactional(rollbackFor = Exception.class)
public void cancel(String orderNo) {
    StockFreeze freeze = stockFreezeMapper.selectByBizNo(orderNo);

    if (freeze == null) {
        // 空回滚:Cancel 先于 Try 到达
        stockFreezeMapper.insertCancelRecord(orderNo);
        return;
    }

    if (FreezeStatus.CANCELED.equals(freeze.getStatus())) {
        return;
    }

    if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
        throw new BizException("已确认扣减的库存不能取消");
    }

    stockMapper.release(
            freeze.getSkuId(),
            freeze.getQuantity()
    );

    stockFreezeMapper.updateStatus(
            orderNo,
            FreezeStatus.CANCELED
    );
}

TCC 还要处理几个问题:

幂等:Try / Confirm / Cancel 都可能重复调用
空回滚:Cancel 可能先于 Try 到达
悬挂:Cancel 执行后,Try 又到达
状态流转:不能出现已确认后又取消

所以 TCC 适合库存、优惠券、账户余额这类核心资源预占场景。

不适合积分、通知、数据同步这类后置动作。

4. 可靠消息最终一致性:适合主流程成功后的后置动作

可靠消息最终一致性适合:

支付成功后加积分
订单完成后发通知
发货后同步物流状态
订单变更后同步搜索系统

核心原则是:

消息不能丢
消费可以重试
消费端必须幂等

常见实现方式有两种:

本地事件表 Outbox
RocketMQ 事务消息

RocketMQ 事务消息的基本流程:

发送 half message
  |
  v
executeLocalTransaction 执行本地事务
  |
  |-- 本地事务成功:COMMIT
  |-- 本地事务失败:ROLLBACK
  |-- 本地事务未知:UNKNOWN
  |
  v
MQ 对 UNKNOWN 状态进行事务回查

需要注意:

RocketMQ 事务消息解决的是“本地事务和消息发送的一致性”,不是消费端业务一定成功。

消费者仍然要自己保证:

幂等
重试
失败告警
人工补偿

如果项目已经使用本地事件表,而且排查和补偿都比较方便,不一定非要换成 RocketMQ 事务消息。

5. 最大努力通知:适合短信、邮件、第三方回调

最大努力通知适合一致性要求较低的场景:

短信通知
邮件通知
站内信
第三方回调
运营消息

它的特点是:

主流程已经完成
通知失败不影响主流程
失败后按策略重试
多次失败后记录异常或人工处理

不要在业务线程里 Thread.sleep 重试。

错误示例:

public void notify(String event) throws InterruptedException {
    for (int i = 0; i < 5; i++) {
        boolean success = send(event);
        if (success) {
            return;
        }

        Thread.sleep(60_000);
    }
}

这种写法会长时间占用业务线程。

更合理的方式是记录通知任务,然后异步重试。

CREATE TABLE notify_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
    biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,例如订单号、支付单号',
    notify_type VARCHAR(64) NOT NULL COMMENT '通知类型,例如 SMS=短信,EMAIL=邮件,SITE_MESSAGE=站内信,WEBHOOK=第三方回调',
    payload TEXT NOT NULL COMMENT '通知内容,通常存储 JSON 字符串',
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT '通知状态:PENDING=待通知,PROCESSING=通知中,RETRY=等待重试,SUCCESS=通知成功,FAILED=通知失败',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
    next_retry_time DATETIME NOT NULL COMMENT '下次可重试时间',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_biz_no_type (biz_no, notify_type),
    KEY idx_status_retry_time (status, next_retry_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知任务表,用于异步通知、失败重试和最大努力通知';

重试任务:

@Component
public class NotifyRetryJob {

    @Autowired
    private NotifyTaskMapper notifyTaskMapper;

    @Autowired
    private NotifySender notifySender;

    @Scheduled(fixedDelay = 10000)
    public void retry() {
        List<NotifyTask> tasks = notifyTaskMapper.selectRetryTasks(100);

        for (NotifyTask task : tasks) {
            try {
                notifySender.send(task);
                notifyTaskMapper.markSuccess(task.getId());
            } catch (Exception e) {
                int nextRetryCount = task.getRetryCount() + 1;

                if (nextRetryCount >= 8) {
                    notifyTaskMapper.markFailed(task.getId());
                    continue;
                }

                notifyTaskMapper.markRetry(
                        task.getId(),
                        nextRetryCount,
                        calculateNextRetryTime(nextRetryCount)
                );
            }
        }
    }

    private LocalDateTime calculateNextRetryTime(int retryCount) {
        int[] intervals = {1, 5, 10, 30, 60, 120, 240, 480};
        int minutes = intervals[Math.min(retryCount - 1, intervals.length - 1)];
        return LocalDateTime.now().plusMinutes(minutes);
    }
}

这个方案不保证马上通知成功。

它保证的是:

失败可查
可以重试
可以告警
可以人工处理

6. Seata AT:侵入较低,但不是银弹

Seata AT 不是自动化 TCC。

TCC 需要业务自己实现 Try、Confirm、Cancel。

Seata AT 的核心是:

代理数据源
解析业务 SQL
记录 undo_log
一阶段提交本地事务
二阶段根据全局事务结果删除 undo_log 或执行反向补偿

可以简化理解为:

业务 SQL 执行前,记录 before image
业务 SQL 执行后,记录 after image
如果全局事务回滚,根据 undo_log 反向恢复

例如业务 SQL:

UPDATE product_stock
SET available_stock = available_stock - 10
WHERE sku_id = 1001;

Seata 会记录类似信息:

before image: available_stock = 100
after image : available_stock = 90

如果需要回滚,再根据回滚日志恢复。

Seata AT 的优点:

业务侵入较低
对常规数据库更新场景比较友好
开发成本低于手写 TCC

但它也有边界:

依赖数据库本地事务
复杂 SQL 支持有限
热点数据冲突会影响性能
undo_log 需要正确维护
不适合所有高并发核心链路

所以不要看到分布式事务就直接上 Seata。

大致可以这样选:

低频后台跨库操作
  -> 可以考虑 Seata AT

核心高并发资源扣减
  -> 优先考虑业务冻结模型或 TCC 思路

支付成功后加积分
  -> 更适合可靠消息最终一致性

短信、邮件、通知
  -> 最大努力通知即可

生产环境注意事项

1. 先做一致性分级

不要把所有动作都放进一个分布式事务里。

可以按业务重要性拆分:

库存、优惠券:尽量同步处理,避免超卖和重复使用
支付状态:必须可靠,状态流转要可恢复
积分、数据同步:最终一致
短信、邮件:最大努力通知

一致性要求不同,方案就应该不同。

2. 幂等必须落到数据库

只要有重试,就必须有幂等。

常见幂等键:

requestNo
orderNo
payNo
eventId
callbackNo

建议在数据库层面加唯一索引。

Redis 可以做前置防重,但不能替代数据库唯一约束。

因为 Redis 可能过期、丢失、被误删,数据库唯一索引才是最后防线。

3. 不要在本地事务里做大量远程调用

下面这种写法要谨慎:

@Transactional(rollbackFor = Exception.class)
public void createOrder() {
    orderMapper.insert(order);
    stockClient.reserve();
    couponClient.reserve();
    paymentClient.create();
}

问题是:

本地事务持有时间变长
数据库连接占用时间变长
远程服务抖动会拖垮当前服务
本地事务回滚不了远程服务的数据

更好的方式是缩短本地事务范围,让远程调用通过状态机和补偿机制变得可恢复。

4. 冻结资源必须有超时释放

库存冻结、优惠券冻结不能无限期占用。

例如订单 30 分钟未支付,需要关闭订单并释放资源。

订单超时关闭
  |
  |-- 释放冻结库存
  |-- 释放冻结优惠券
  |-- 更新订单状态 CLOSED

释放动作必须幂等。

因为这些动作可能同时发生:

用户主动取消订单
订单超时关闭任务
支付回调延迟到达
人工后台关闭订单

没有状态机和幂等,很容易出现重复释放或错误释放。

5. Outbox 表要考虑积压和归档

本地事件表不是写完就结束。

生产环境里至少要关注:

PENDING 数量
RETRY 数量
PROCESSING 超时数量
最大重试次数
最老未成功事件时间

如果事件量很大,还要考虑:

按月归档
按业务类型分表
定期清理 SUCCESS 历史数据
失败事件告警
后台手动重推

否则事件表会越来越大,后续查询和补偿都会变慢。

6. 补偿任务必须可观测

分布式事务最终一定会遇到异常。

不要只写补偿任务,还要能看见补偿任务的状态。

后台最好能查到:

业务单号
当前状态
失败原因
最近一次重试时间
下一次重试时间
重试次数
处理实例

否则线上排查会很痛苦。

7. 不要过度设计

不是所有跨服务操作都需要分布式事务框架。

可以简单按下面规则判断:

单库操作
  -> 本地事务足够

低频后台操作
  -> 本地事务 + 人工补偿,或考虑 Seata AT

核心交易资源预占
  -> 业务冻结模型或 TCC 思路

主流程成功后的后置动作
  -> Outbox 或事务消息

通知类动作
  -> 最大努力通知

技术方案应该服务业务主线,而不是为了显得架构复杂。

总结

分布式事务真正难的地方,不是知道多少种方案。

而是能不能把业务动作拆清楚:

哪些必须同步成功
哪些可以最终一致
哪些可以失败重试
哪些允许人工介入
哪些根本不需要分布式事务

在本文这个下单场景里,更合理的处理方式是:

下单核心链路:
  订单状态机 + 库存预占 + 优惠券预占

支付成功后:
  本地事件表 Outbox 保证事件不丢

积分和通知:
  异步消费 + 消费端幂等

异常场景:
  补偿任务 + 超时恢复 + 告警 + 人工处理

不要把 2PC、3PC、TCC、Seata、MQ 全部硬塞进一个系统。

对于大多数 Java 后端项目来说,真正能落地的分布式一致性设计,往往不是某个框架本身,而是下面这些基础能力:

清晰的状态机
可靠的幂等设计
可重试的补偿机制
可观测的异常任务
合理的一致性分级

这些做好了,系统才有恢复能力。

否则即使引入再多分布式事务框架,线上出问题时,依然很难排查和兜底。

评论