场景描述
- 用户下单时,需要创建订单并从用户账户中扣除相应的余额。
- 如果订单创建成功但余额划扣失败,则需要回滚订单创建操作。
- 使用 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 {@Autowiredprivate OrderMapper orderMapper;@Overridepublic boolean createOrder(BusinessActionContext context, String userId, String productId, BigDecimal amount) {// Try 阶段:创建订单,状态为 INITOrder 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;}@Overridepublic boolean confirmOrder(BusinessActionContext context) {// Confirm 阶段:将订单状态更新为 CONFIRMEDLong orderId = (Long) context.getActionContext("orderId");orderMapper.updateStatus(orderId, "CONFIRMED");return true;}@Overridepublic boolean cancelOrder(BusinessActionContext context) {// Cancel 阶段:将订单状态更新为 CANCELLEDLong 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 {@Autowiredprivate AccountMapper accountMapper;@Overridepublic 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;}@Overridepublic boolean confirmDeduct(BusinessActionContext context) {// Confirm 阶段:扣除已冻结的金额String userId = (String) context.getActionContext("userId");BigDecimal amount = (BigDecimal) context.getActionContext("amount");accountMapper.confirmDeduct(userId, amount);return true;}@Overridepublic 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 {@Autowiredprivate OrderTccService orderTccService;@Autowiredprivate AccountTccService accountTccService;@PostMapping("/create")@GlobalTransactionalpublic 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. 测试流程
- 启动 Seata Server。
- 启动 Order Service 和 Account Service。
- 发送请求到
/api/orders/create
接口,创建订单并扣除余额。 - 如果任意一个步骤失败,Seata 会自动触发回滚逻辑。
7. 关键点总结
-
TCC 模式的核心:
- Try:预留资源。
- Confirm:确认操作。
- Cancel:补偿操作。
-
Spring Cloud 集成:
- 使用
@LocalTCC
和@TwoPhaseBusinessAction
注解定义 TCC 接口。 - 使用
@GlobalTransactional
开启全局事务。
- 使用
-
事务一致性:
- 如果任意一步失败,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. 状态字段的作用
- 状态字段 是最基础的幂等性保障方式。
- 它通过记录操作的状态(如
INIT
、CONFIRMED
、CANCELLED
)来判断某个操作是否已经完成。 - 优点:简单直观,易于实现。
- 缺点:在高并发场景下可能会出现竞争条件(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 是全局事务 IDString 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. 综合解决方案
在实际项目中,通常会结合 状态字段、令牌机制 和 分布式锁 来实现全面的幂等性保障:
- 状态字段:
- 用来记录操作的状态,避免重复执行。
- 令牌机制:
- 为每个请求分配唯一标识符,确保每个请求只被执行一次。
- 分布式锁:
- 在高并发场景下,使用分布式锁保护共享资源,避免竞争条件。
示例流程
- 客户端生成令牌:
- 客户端在发送请求时生成一个唯一的令牌(如 UUID),并将令牌附加到请求中。
- 服务端校验令牌:
- 服务端接收到请求后,首先检查令牌是否存在。
- 如果令牌已存在,则直接返回成功。
- 获取分布式锁:
- 如果令牌不存在,则尝试获取分布式锁。
- 如果锁获取成功,则继续执行业务逻辑;否则返回失败或等待。
- 更新状态字段:
- 执行业务逻辑后,更新状态字段以标记操作已完成。
- 记录令牌:
- 将令牌保存到数据库中,以便后续重复请求可以直接跳过。
5. 总结
方法 | 适用场景 | 优缺点 |
---|---|---|
状态字段 | 基础的幂等性保障,适用于大多数场景。 | 优点:简单易用;缺点:高并发下可能存在问题。 |
令牌机制 | 适用于需要严格避免重复操作的场景(如支付、扣款)。 | 优点:能有效防止重复请求;缺点:需要额外存储令牌信息。 |
分布式锁 | 适用于高并发场景,需要强一致性保障的场景。 | 优点:避免竞争条件;缺点:增加了系统复杂性和性能开销。 |
通过结合 状态字段、令牌机制 和 分布式锁,可以构建一个健壮的幂等性保障机制,从而更好地应对分布式事务中的各种挑战。