一站式分布式事務 Seata 方案
引言
上一篇《如何選擇分布式事務解決方案?》 綜合比較了各種分布式事務方案的優(yōu)缺點,并在技術選型上給出合理化的建議??紤]到企業(yè)應用的普遍性和適用性,今天重點聊聊分布式Seata方案實踐和原理。
Seata 是什么?
Seata 是一款開源的分布式事務解決方案,致力于提供高性能和簡單易用的分布式事務服務。Seata 將為用戶提供了 AT、TCC、SAGA 和 XA 事務模式,為用戶打造一站式的分布式解決方案。默認AT模式。
英文官網(wǎng):https://seata.io/zh-cn/
中文官網(wǎng):https://seata.io/zh-cn/
看官網(wǎng)這輕松而有趣的解釋,心中默念:
為探究這個問題,我們嘗試從問題中來,到問題中去。試想:
- 不使用分布式事務會產(chǎn)生怎樣的結果?
- 為何選擇Seata方案?
- 這種方案如何實現(xiàn)提交和回滾保證數(shù)據(jù)一致性的?
帶著這些問題,根據(jù)實際應用場景逐步探究。。。
業(yè)務場景
在電商交易系統(tǒng)中,最核心的業(yè)務場景:下訂單 --> 減庫存 --> 調(diào)支付,要求具備數(shù)據(jù)強一致性。
基本模型如下:
我們要求該場景應滿足以下幾個條件:
- 訂單狀態(tài)正常(0-異常,1-正常)
- 庫存不能出現(xiàn)負數(shù)或者下單了沒有扣庫存
- 保證金額正常支付,不能出現(xiàn)扣款多或者扣款少的情況
業(yè)務實現(xiàn)
下面我們給出核心代碼描述業(yè)務過程。
訂單服務:
public interface OrderService {
/**
* 創(chuàng)建訂單
*/
void create(Order order);
}
倉儲服務:
public interface StorageService {
/**
* 扣減庫存
*/
void deduct(Long productId, Integer count);
}
帳戶服務:
public interface AccountService {
/**
* 扣減賬戶余額
*
* @param userId 用戶id
* @param money 金額
*/
void debit(@RequestParam("userId") Long userId, @RequestParam("money") BigDecimal money);
}
這里只給出核心業(yè)務邏輯說明問題。在訂單業(yè)務作為核心邏輯,遠程調(diào)用扣庫存和支付。
public class OrderServiceImpl implements OrderService
{
@Resource
private OrderDao orderDao;
@Resource
private StorageService storageService;
@Resource
private AccountService accountService;
/**
* 創(chuàng)建訂單->調(diào)用庫存服務扣減庫存->調(diào)用賬戶服務扣減賬戶余額->修改訂單狀態(tài)
* 簡單說:下訂單->扣庫存->減余額->改狀態(tài)
*/
@Override
//這里先注釋掉,用于比較實用分布式事務前后的效果
//@GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order)
{
//創(chuàng)建訂單
orderDao.create(order);
//2 扣減庫存
storageService.deduct(order.getProductId(),order.getCount());
//3 扣減賬戶金額
accountService.debit(order.getUserId(),order.getMoney());
//4 修改訂單狀態(tài),從零到1,1代表已經(jīng)完成
orderDao.update(order.getUserId(),0);
}
}
未使用分布式事務前的場景
初始狀態(tài):
-- 庫存商品:100
mysql> select * from t_storage;
+----+------------+-------+------+---------+
| id | product_id | total | used | residue |
+----+------------+-------+------+---------+
| 1 | 1 | 100 | 0 | 100 |
+----+------------+-------+------+---------+
1 row in set (0.00 sec)
--賬戶余額:1000
mysql> select * from t_account;
+----+---------+-------+------+---------+
| id | user_id | total | used | residue |
+----+---------+-------+------+---------+
| 1 | 1 | 1000 | 0 | 1000 |
+----+---------+-------+------+---------+
1 row in set (0.00 sec)
-- 訂單為空
mysql> select * from t_order;
Empty set (0.00 sec)
下面模擬一個賬戶扣減異常(因為OpenFeign的默認分別是連接超時時間10秒.這處設置20秒就是為了挑事兒),然后分別啟動訂單、庫存、賬戶服務。
/**
* 扣減賬戶余額
*/
@Override
public void debit(Long userId, BigDecimal money) {
LOGGER.info("------->account-service中扣減賬戶余額開始");
//模擬超時異常,全局事務回滾
//暫停幾秒鐘線程
try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
accountDao.debit(userId,money);
LOGGER.info("------->account-service中扣減賬戶余額結束");
}
模擬下訂單過程:
http://localhost:2001/order/create?userId=1&productId=1&count=10&mnotallow=100
頁面異常因為賬戶服務中扣減過程發(fā)生異常。我們再來觀察數(shù)據(jù)庫中的變化情況:
-- 庫存商品:100
mysql> select * from t_storage;
+----+------------+-------+------+---------+
| id | product_id | total | used | residue |
+----+------------+-------+------+---------+
| 1 | 1 | 100 | 10 | 90 |
+----+------------+-------+------+---------+
1 row in set (0.00 sec)
--賬戶余額:1000
mysql> select * from t_account;
+----+---------+-------+------+---------+
| id | user_id | total | used | residue |
+----+---------+-------+------+---------+
| 1 | 1 | 1000 | 0 | 1000 |
+----+---------+-------+------+---------+
1 row in set (0.00 sec)
-- 訂單為空
mysql> select * from t_order;
+----+---------+------------+-------+-------+--------+
| id | user_id | product_id | count | money | status |
+----+---------+------------+-------+-------+--------+
| 3 | 1 | 1 | 10 | 100 | 0 |
+----+---------+------------+-------+-------+--------+
1 row in set (0.00 sec)
觀察分析,訂單狀態(tài):0-異常。庫存較少,但是余額沒有扣減。商家容易哭死。這就使我們不得不做事務的控制。以達到數(shù)據(jù)一致性。
Seata方案引入
對此,我們使用分布式事務Seata的解決方案解決此問題。
圖片來源于官網(wǎng)
為方便測試使用,這里準備了Seata的一套環(huán)境。并給出相應業(yè)務測試用例SQL。
使用非常簡單。只需要在核心業(yè)務方法上加一個注解@GlobalTransactional即可。
@Override
@GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order)
{
//創(chuàng)建訂單
orderDao.create(order);
//2 扣減庫存
storageService.deduct(order.getProductId(),order.getCount());
//3 扣減賬戶金額
accountService.debit(order.getUserId(),order.getMoney());
//4 修改訂單狀態(tài),從零到1,1代表已經(jīng)完成
orderDao.update(order.getUserId(),0);
}
恢復數(shù)據(jù)初始狀態(tài),再次啟動和重復上述測試步驟。觀察在發(fā)生異常的情況下,數(shù)據(jù)庫還是初始的狀態(tài)(為出現(xiàn)訂單異常和賬戶余額變動的問題)。顯然:這個注解幫助我們:在分布式環(huán)境下,當有異常發(fā)生時進行全局回滾,以維持數(shù)據(jù)的一致狀態(tài)。我們把這種全局意義上控制的事務成為全局事務。
Seata執(zhí)行流程及原理
@GlobalTransactional用事實告訴我們:極簡是一種美!
是不是很好奇?
下面究其背后的原理做深層次的探究。
(1) 全局事務中的角色
- TM 事務發(fā)起方,是業(yè)務方法上帶有@GlobalTransactional注解的服務,如:本案例中訂單服務。
- TC 事務協(xié)調(diào)者,可以理解為一個隱形的中間人,負責管理事務。
- TR 事務參與者:本案例當中:訂單、庫存、賬戶都是還是事務參與者
(2) 全局成功提交流程
(3) 全局失敗回滾流程
下面我們用代碼驗證此過程:
- 啟動nacos;
- 啟動seata;
- debug啟動訂單、庫存、賬戶服務,斷點跟蹤
查看數(shù)據(jù)庫變化情況(取關鍵字段信息):
-- 全局事務
mysql> select xid,transaction_id,application_id,transaction_service_group,transaction_name from global_table ;
+-------------------------------+----------------+---------------------+---------------------------+------------------+
| xid | transaction_id | application_id | transaction_service_group | transaction_name |
+-------------------------------+----------------+---------------------+---------------------------+------------------+
| 192.168.0.101:8091:2155601919 | 2155601919 | seata-order-service | fsp_tx_group | fsp-create-order |
+-------------------------------+----------------+---------------------+---------------------------+------------------+
1 row in set (0.01 sec)
-- 分支事務
mysql> select xid,transaction_id,branch_id,client_id,branch_type from branch_table;
+-------------------------------+----------------+------------+-------------------------------------------+-------------+
| xid | transaction_id | branch_id | client_id | branch_type |
+-------------------------------+----------------+------------+-------------------------------------------+-------------+
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601922 | seata-order-service:192.168.0.101:51952 | AT |
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601927 | seata-storage-service:192.168.0.101:52459 | AT |
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601930 | seata-account-service:192.168.0.101:52498 | AT |
+-------------------------------+----------------+------------+-------------------------------------------+-------------+
3 rows in set (0.00 sec)
-- 鎖
mysql> select xid,transaction_id,branch_id,table_name,resource_id from lock_table;
+-------------------------------+----------------+------------+------------+-----------------------------------------------+
| xid | transaction_id | branch_id | table_name | resource_id |
+-------------------------------+----------------+------------+------------+-----------------------------------------------+
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601930 | t_account | jdbc:mysql://114.116.10.56:3306/seata_account |
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601922 | t_order | jdbc:mysql://114.116.10.56:3306/seata_order |
| 192.168.0.101:8091:2155601919 | 2155601919 | 2155601927 | t_storage | jdbc:mysql://114.116.10.56:3306/seata_storage |
+-------------------------------+----------------+------------+------------+-----------------------------------------------+
3 rows in set (0.00 sec)
注意:全局事務xid 和分支事務branch_id 之間的對應關系,比對上圖中成功提交流程.
我們觀察業(yè)務庫中的undo_log日志情況:(重點關注rollback_info字段信息)
格式化一下rollback_info信息:
當然,讀者感興趣可以查看下seata_account庫和 seata_storage庫中undo_log的情況,與此類似。
所以我們可以將Seata的執(zhí)行原理歸納為:
(1) 在一階段,Seata 會攔截“業(yè)務 SQL”,
- 解析 SQL 語義,找到“業(yè)務 SQL”要更新的業(yè)務數(shù)據(jù),在業(yè)務數(shù)據(jù)被更新前,將其保存成“before image”;
- 執(zhí)行“業(yè)務 SQL”更新業(yè)務數(shù)據(jù);
- 在業(yè)務數(shù)據(jù)更新之后,其保存成“after image”,最后生成行鎖。
以上操作全部在一個數(shù)據(jù)庫事務內(nèi)完成,這樣保證了一階段操作的原子性。
圖片來源于學習筆記
(2) 二階段提交:
天空不留下鳥兒的痕跡,但它已經(jīng)飛過),完成數(shù)據(jù)清理即可。
圖片來源于學習筆記
(3) 二階段回滾:
二階段如果是回滾的話,Seata 就需要回滾一階段已經(jīng)執(zhí)行的“業(yè)務 SQL”,還原業(yè)務數(shù)據(jù)?;貪L方式便是用“before image”還原業(yè)務數(shù)據(jù)。
- 還原前要首先要校驗臟寫;
- 對比“數(shù)據(jù)庫當前業(yè)務數(shù)據(jù)”和 “after image”
- 如果兩份數(shù)據(jù)完全一致就說明沒有臟寫,可以還原業(yè)務數(shù)據(jù),如果不一致就說明有臟寫,出現(xiàn)臟寫就需要轉人工處理。
圖片來源于學習筆記
總結
本文主要對分布式事務Seata的使用、原理做了介紹,同時在選擇方案選擇上給出如下建議:
- 非必要不引入分布式事務的處理;
- 分布式事務在分布式環(huán)境下使用,單體應用不必考慮;
- 一般多用在遠程調(diào)用三方外部平臺之間,內(nèi)部系統(tǒng)服務之間建議使用Spring事務
- Seata分布式事務方案默認AT模式,代碼無入侵,使用簡單,在數(shù)據(jù)一致性要求比較高的系統(tǒng)中,是很好的分布式事務解決方案。常用于電商支付、金融轉賬類業(yè)務。
- Seata配置繁瑣,引入子系統(tǒng)會產(chǎn)生很多其它問題。應根據(jù)實際場景合理選擇。