場景描述
- 用戶下單時,需要創建訂單并從用戶賬戶中扣除相應的余額。
- 如果訂單創建成功但余額劃扣失敗,則需要回滾訂單創建操作。
- 使用 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. 總結
方法 | 適用場景 | 優缺點 |
---|---|---|
狀態字段 | 基礎的冪等性保障,適用于大多數場景。 | 優點:簡單易用;缺點:高并發下可能存在問題。 |
令牌機制 | 適用于需要嚴格避免重復操作的場景(如支付、扣款)。 | 優點:能有效防止重復請求;缺點:需要額外存儲令牌信息。 |
分布式鎖 | 適用于高并發場景,需要強一致性保障的場景。 | 優點:避免競爭條件;缺點:增加了系統復雜性和性能開銷。 |
通過結合 狀態字段、令牌機制 和 分布式鎖,可以構建一個健壯的冪等性保障機制,從而更好地應對分布式事務中的各種挑戰。