seate TCC模式案例

场景描述

var code = "610a4fab-5651-4d2c-977e-78b42bec7dfe"
  1. 用户下单时,需要创建订单并从用户账户中扣除相应的余额。
  2. 如果订单创建成功但余额划扣失败,则需要回滚订单创建操作。
  3. 使用 Seata 的 TCC 模式来保证分布式事务的一致性。

1. 项目结构

假设我们有两个微服务:

  • Order Service:负责创建订单。
  • Account Service:负责扣除用户余额。

此外,还需要一个 Seata Server 来协调分布式事务。


2. 数据库设计

Order 表
CREATE TABLE `orders` (
  `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
  `user_id` VARCHAR(32) NOT NULL,
  `product_id` VARCHAR(32) NOT NULL,
  `amount` DECIMAL(10, 2) NOT NULL,
  `status` VARCHAR(16) DEFAULT 'INIT' -- 状态:INIT(初始化)、CONFIRMED(确认)、CANCELLED(取消)
);
Account 表
CREATE TABLE `accounts` (
  `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
  `user_id` VARCHAR(32) NOT NULL,
  `balance` DECIMAL(10, 2) NOT NULL
);

3. Order Service

(1) 定义 TCC 接口

OrderService 中定义 Try、Confirm 和 Cancel 方法。

@LocalTCC
public interface OrderTccService {

    @TwoPhaseBusinessAction(name = "createOrder", commitMethod = "confirmOrder", rollbackMethod = "cancelOrder")
    boolean createOrder(BusinessActionContext context, String userId, String productId, BigDecimal amount);

    boolean confirmOrder(BusinessActionContext context);

    boolean cancelOrder(BusinessActionContext context);
}
(2) 实现 TCC 方法
@Service
public class OrderTccServiceImpl implements OrderTccService {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public boolean createOrder(BusinessActionContext context, String userId, String productId, BigDecimal amount) {
        // Try 阶段:创建订单,状态为 INIT
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setAmount(amount);
        order.setStatus("INIT");
        orderMapper.insert(order);

        // 将订单 ID 存入上下文,供 Confirm 和 Cancel 使用
        context.getActionContext().put("orderId", order.getId());
        return true;
    }

    @Override
    public boolean confirmOrder(BusinessActionContext context) {
        // Confirm 阶段:将订单状态更新为 CONFIRMED
        Long orderId = (Long) context.getActionContext("orderId");
        orderMapper.updateStatus(orderId, "CONFIRMED");
        return true;
    }

    @Override
    public boolean cancelOrder(BusinessActionContext context) {
        // Cancel 阶段:将订单状态更新为 CANCELLED
        Long orderId = (Long) context.getActionContext("orderId");
        orderMapper.updateStatus(orderId, "CANCELLED");
        return true;
    }
}
(3) Mapper 定义
@Mapper
public interface OrderMapper {
    void insert(Order order);
    void updateStatus(Long orderId, String status);
}

4. Account Service

(1) 定义 TCC 接口

AccountService 中定义 Try、Confirm 和 Cancel 方法。

@LocalTCC
public interface AccountTccService {

    @TwoPhaseBusinessAction(name = "deductBalance", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct")
    boolean deductBalance(BusinessActionContext context, String userId, BigDecimal amount);

    boolean confirmDeduct(BusinessActionContext context);

    boolean cancelDeduct(BusinessActionContext context);
}
(2) 实现 TCC 方法
@Service
public class AccountTccServiceImpl implements AccountTccService {

    @Autowired
    private AccountMapper accountMapper;

    @Override
    public boolean deductBalance(BusinessActionContext context, String userId, BigDecimal amount) {
        // Try 阶段:检查余额是否足够,并冻结相应金额
        Account account = accountMapper.findByUserId(userId);
        if (account.getBalance().compareTo(amount) < 0) {
            throw new RuntimeException("Insufficient balance");
        }
        accountMapper.freezeBalance(userId, amount);

        // 将冻结金额存入上下文,供 Confirm 和 Cancel 使用
        context.getActionContext().put("userId", userId);
        context.getActionContext().put("amount", amount);
        return true;
    }

    @Override
    public boolean confirmDeduct(BusinessActionContext context) {
        // Confirm 阶段:扣除已冻结的金额
        String userId = (String) context.getActionContext("userId");
        BigDecimal amount = (BigDecimal) context.getActionContext("amount");
        accountMapper.confirmDeduct(userId, amount);
        return true;
    }

    @Override
    public boolean cancelDeduct(BusinessActionContext context) {
        // Cancel 阶段:释放已冻结的金额
        String userId = (String) context.getActionContext("userId");
        BigDecimal amount = (BigDecimal) context.getActionContext("amount");
        accountMapper.cancelDeduct(userId, amount);
        return true;
    }
}
(3) Mapper 定义
@Mapper
public interface AccountMapper {
    Account findByUserId(String userId);
    void freezeBalance(String userId, BigDecimal amount);
    void confirmDeduct(String userId, BigDecimal amount);
    void cancelDeduct(String userId, BigDecimal amount);
}

5. 调用方(API Gateway 或其他服务)

在调用方使用 @GlobalTransactional 注解开启全局事务。

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderTccService orderTccService;

    @Autowired
    private AccountTccService accountTccService;

    @PostMapping("/create")
    @GlobalTransactional
    public ResponseEntity<String> createOrder(@RequestBody CreateOrderRequest request) {
        try {
            // 创建订单
            orderTccService.createOrder(null, request.getUserId(), request.getProductId(), request.getAmount());

            // 扣除余额
            accountTccService.deductBalance(null, request.getUserId(), request.getAmount());

            return ResponseEntity.ok("Order created successfully");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to create order: " + e.getMessage());
        }
    }
}

6. 测试流程

  1. 启动 Seata Server。
  2. 启动 Order Service 和 Account Service。
  3. 发送请求到 /api/orders/create 接口,创建订单并扣除余额。
  4. 如果任意一个步骤失败,Seata 会自动触发回滚逻辑。

7. 关键点总结

  1. TCC 模式的核心

    • Try:预留资源。
    • Confirm:确认操作。
    • Cancel:补偿操作。
  2. Spring Cloud 集成

    • 使用 @LocalTCC 和 @TwoPhaseBusinessAction 注解定义 TCC 接口。
    • 使用 @GlobalTransactional 开启全局事务。
  3. 事务一致性

    • 如果任意一步失败,Seata 会自动调用 Cancel 方法进行回滚,确保数据一致。

TCC模式还会存在空回滚,幂等,悬挂等问题 

1. 空回滚

问题描述

  • 定义:在 TCC 模式中,如果 Try 阶段没有执行(例如由于网络超时或服务不可用),但 Cancel 阶段被调用了,则会导致空回滚。
  • 原因
    • Try 请求未到达服务端,或者未成功执行。
    • Seata Server 在协调事务时检测到失败,直接触发了 Cancel 阶段。

解决方案

  • 解决思路:在 Cancel 方法中判断是否需要执行回滚操作。
  • 实现方式
    • 在数据库中增加一个状态字段,用于标记资源是否已经被预留(Try 阶段是否执行过)。
    • 如果状态字段表明资源未被预留,则直接跳过 Cancel 操作。
示例代码
@Override
public boolean cancelDeduct(BusinessActionContext context) {
    String userId = (String) context.getActionContext("userId");
    Account account = accountMapper.findByUserId(userId);

    // 判断是否需要回滚(账户是否有冻结金额)
    if (account.getFrozenAmount().compareTo(BigDecimal.ZERO) == 0) {
        return true; // 跳过空回滚
    }

    // 执行取消逻辑
    accountMapper.cancelDeduct(userId, (BigDecimal) context.getActionContext("amount"));
    return true;
}

2. 幂等性

问题描述

  • 定义:TCC 的 Confirm 或 Cancel 方法可能因为网络重试等原因被多次调用,导致重复操作。
  • 原因
    • Seata Server 可能会多次尝试调用 Confirm 或 Cancel 方法。
    • 客户端或网络层可能引发重复请求。

解决方案

  • 解决思路:确保 Confirm 和 Cancel 方法是幂等的。
  • 实现方式
    • 使用数据库的状态字段来记录操作是否已经完成。
    • 如果某个操作已经完成,则直接返回成功,不再重复执行。
示例代码
@Override
public boolean confirmDeduct(BusinessActionContext context) {
    String userId = (String) context.getActionContext("userId");
    Account account = accountMapper.findByUserId(userId);

    // 判断是否已经确认
    if ("CONFIRMED".equals(account.getStatus())) {
        return true; // 已经确认,直接返回
    }

    // 执行确认逻辑
    accountMapper.confirmDeduct(userId, (BigDecimal) context.getActionContext("amount"));
    accountMapper.updateStatus(userId, "CONFIRMED");
    return true;
}

@Override
public boolean cancelDeduct(BusinessActionContext context) {
    String userId = (String) context.getActionContext("userId");
    Account account = accountMapper.findByUserId(userId);

    // 判断是否已经取消
    if ("CANCELLED".equals(account.getStatus())) {
        return true; // 已经取消,直接返回
    }

    // 执行取消逻辑
    accountMapper.cancelDeduct(userId, (BigDecimal) context.getActionContext("amount"));
    accountMapper.updateStatus(userId, "CANCELLED");
    return true;
}

3. 悬挂

问题描述

  • 定义:Confirm 或 Cancel 方法比 Try 方法先执行,导致业务逻辑异常。
  • 原因
    • Try 请求在网络传输中延迟,而 Seata Server 认为 Try 失败并提前触发了 Confirm 或 Cancel。
    • Try 请求最终到达服务端时,发现 Confirm 或 Cancel 已经执行。

解决方案

  • 解决思路:通过状态字段和事务上下文信息,避免悬挂问题。
  • 实现方式
    • 在数据库中记录事务的执行状态。
    • 在 Try 方法中检查是否存在对应的 Confirm 或 Cancel 操作。如果有,则直接跳过 Try 操作。
示例代码
@Override
public boolean deductBalance(BusinessActionContext context, String userId, BigDecimal amount) {
    Account account = accountMapper.findByUserId(userId);

    // 判断是否已经确认或取消
    if ("CONFIRMED".equals(account.getStatus()) || "CANCELLED".equals(account.getStatus())) {
        return true; // 悬挂处理:直接返回
    }

    // 执行 Try 逻辑
    if (account.getBalance().compareTo(amount) < 0) {
        throw new RuntimeException("Insufficient balance");
    }
    accountMapper.freezeBalance(userId, amount);
    return true;
}

4. 总结

问题原因解决方案
空回滚Try 未执行,但 Cancel 被调用在 Cancel 方法中检查 Try 是否已执行,未执行则跳过。
幂等性Confirm 或 Cancel 方法被多次调用使用状态字段记录操作是否已完成,避免重复执行。
悬挂Confirm 或 Cancel 比 Try 先执行在 Try 方法中检查 Confirm 或 Cancel 是否已执行,已执行则跳过 Try。

通过以上方法,可以有效解决 TCC 模式中的空回滚、幂等性和悬挂问题,从而保证分布式事务的一致性和可靠性。


用字段状态检测以上问题,程序并不健壮,如果在高并发情况下还会出现一些问题,为了程序健壮性,达到强一致,我们还需要引入令牌和分布式锁


1. 状态字段的作用

  • 状态字段 是最基础的幂等性保障方式。
  • 它通过记录操作的状态(如 INITCONFIRMEDCANCELLED)来判断某个操作是否已经完成。
  • 优点:简单直观,易于实现。
  • 缺点:在高并发场景下可能会出现竞争条件(race condition),导致状态更新不一致。

2. 引入令牌机制

为什么需要令牌?

  • 定义:令牌是一种唯一标识符,用于确保每个请求只被执行一次。
  • 在分布式系统中,网络重试可能导致同一个请求被多次发送到服务端。如果服务端无法区分这些重复请求,则会导致重复操作。
  • 适用场景
    • 请求可能因为网络问题被重复发送。
    • 需要严格避免重复操作的场景(如支付、扣款等)。

实现方式

  • 每个请求生成一个唯一的令牌(如 UUID)。
  • 服务端在接收到请求时,先检查该令牌是否已经被处理过。
  • 如果已处理过,则直接返回成功;否则执行业务逻辑并记录该令牌。
示例代码
@Override
public boolean confirmDeduct(BusinessActionContext context) {
    String token = (String) context.getActionContext("token");
    if (StringUtils.isEmpty(token)) {
        throw new RuntimeException("Token is missing");
    }

    // 检查令牌是否已经处理过
    if (deductTokenRepository.existsByToken(token)) {
        return true; // 幂等性处理:直接返回
    }

    // 执行确认逻辑
    String userId = (String) context.getActionContext("userId");
    BigDecimal amount = (BigDecimal) context.getActionContext("amount");
    accountMapper.confirmDeduct(userId, amount);

    // 记录令牌
    DeductToken deductToken = new DeductToken();
    deductToken.setToken(token);
    deductToken.setStatus("CONFIRMED");
    deductTokenRepository.save(deductToken);

    return true;
}
数据库表设计
CREATE TABLE `deduct_token` (
  `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
  `token` VARCHAR(64) NOT NULL UNIQUE,
  `status` VARCHAR(16) NOT NULL,
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

3. 引入分布式锁

为什么需要分布式锁?

  • 定义:分布式锁是一种协调机制,用于保证多个节点对共享资源的操作是互斥的。
  • 在高并发场景下,即使有状态字段或令牌机制,也可能因为多个线程同时访问同一资源而导致数据不一致。
  • 适用场景
    • 多个服务实例同时处理同一个请求。
    • 需要强一致性保障的场景。

实现方式

  • 使用 Redis 或 Zookeeper 实现分布式锁。
  • 在业务逻辑执行前获取锁,在业务逻辑完成后释放锁。
  • 如果无法获取锁,则等待或直接返回失败。
示例代码(基于 Redis)
@Autowired
private RedisTemplate<String, String> redisTemplate;

@Override
public boolean confirmDeduct(BusinessActionContext context) {
    String lockKey = "lock:confirmDeduct:" + context.getXid(); // XID 是全局事务 ID
    String userId = (String) context.getActionContext("userId");

    // 尝试获取分布式锁
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, userId, 10, TimeUnit.SECONDS);
    if (Boolean.FALSE.equals(locked)) {
        throw new RuntimeException("Failed to acquire lock");
    }

    try {
        // 检查状态字段
        Account account = accountMapper.findByUserId(userId);
        if ("CONFIRMED".equals(account.getStatus())) {
            return true; // 已经确认,直接返回
        }

        // 执行确认逻辑
        accountMapper.confirmDeduct(userId, (BigDecimal) context.getActionContext("amount"));
        accountMapper.updateStatus(userId, "CONFIRMED");
        return true;
    } finally {
        // 释放分布式锁
        redisTemplate.delete(lockKey);
    }
}

4. 综合解决方案

在实际项目中,通常会结合 状态字段令牌机制分布式锁 来实现全面的幂等性保障:

  1. 状态字段
    • 用来记录操作的状态,避免重复执行。
  2. 令牌机制
    • 为每个请求分配唯一标识符,确保每个请求只被执行一次。
  3. 分布式锁
    • 在高并发场景下,使用分布式锁保护共享资源,避免竞争条件。
示例流程
  1. 客户端生成令牌
    • 客户端在发送请求时生成一个唯一的令牌(如 UUID),并将令牌附加到请求中。
  2. 服务端校验令牌
    • 服务端接收到请求后,首先检查令牌是否存在。
    • 如果令牌已存在,则直接返回成功。
  3. 获取分布式锁
    • 如果令牌不存在,则尝试获取分布式锁。
    • 如果锁获取成功,则继续执行业务逻辑;否则返回失败或等待。
  4. 更新状态字段
    • 执行业务逻辑后,更新状态字段以标记操作已完成。
  5. 记录令牌
    • 将令牌保存到数据库中,以便后续重复请求可以直接跳过。

5. 总结

方法适用场景优缺点
状态字段基础的幂等性保障,适用于大多数场景。优点:简单易用;缺点:高并发下可能存在问题。
令牌机制适用于需要严格避免重复操作的场景(如支付、扣款)。优点:能有效防止重复请求;缺点:需要额外存储令牌信息。
分布式锁适用于高并发场景,需要强一致性保障的场景。优点:避免竞争条件;缺点:增加了系统复杂性和性能开销。

通过结合 状态字段令牌机制分布式锁,可以构建一个健壮的幂等性保障机制,从而更好地应对分布式事务中的各种挑战。

posted @ 2025-04-20 22:29  在线电影制作人  阅读(46)  评论(0)    收藏  举报  来源