引言
上一篇《如何选择分布式事务解决方案?》 综合比较了各种分布式事务方案的优缺点,并在技术选型上给出合理化的建议。考虑到企业应用的普遍性和适用性,今天重点聊聊分布式Seata方案实践和原理。
Seata 是什么?
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。默认AT模式。
英文官网:https://seata.io/zh-cn/
中文官网:https://seata.io/zh-cn/
看官网这轻松而有趣的解释,心中默念:
为探究这个问题,我们尝试从问题中来,到问题中去。试想:
- 不使用分布式事务会产生怎样的结果?
- 为何选择Seata方案?
- 这种方案如何实现提交和回滚保证数据一致性的?
带着这些问题,根据实际应用场景逐步探究。。。
业务场景
在电商交易系统中,最核心的业务场景:下订单 --> 减库存 --> 调支付,要求具备数据强一致性。
基本模型如下:
我们要求该场景应满足以下几个条件:
- 订单状态正常(0-异常,1-正常)
- 库存不能出现负数或者下单了没有扣库存
- 保证金额正常支付,不能出现扣款多或者扣款少的情况
业务实现
下面我们给出核心代码描述业务过程。
订单服务:
public interface OrderService {
/**
* 创建订单
*/
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);
}
这里只给出核心业务逻辑说明问题。在订单业务作为核心逻辑,远程调用扣库存和支付。
public class OrderServiceImpl implements OrderService
{
@Resource
private OrderDao orderDao;
@Resource
private StorageService storageService;
@Resource
private AccountService accountService;
/**
* 创建订单->调用库存服务扣减库存->调用账户服务扣减账户余额->修改订单状态
* 简单说:下订单->扣库存->减余额->改状态
*/
@Override
//这里先注释掉,用于比较实用分布式事务前后的效果
//@GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order)
{
//创建订单
orderDao.create(order);
//2 扣减库存
storageService.deduct(order.getProductId(),order.getCount());
//3 扣减账户金额
accountService.debit(order.getUserId(),order.getMoney());
//4 修改订单状态,从零到1,1代表已经完成
orderDao.update(order.getUserId(),0);
}
}
未使用分布式事务前的场景
初始状态:
-- 库存商品: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
页面异常因为账户服务中扣减过程发生异常。我们再来观察数据库中的变化情况:
-- 库存商品: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)
观察分析,订单状态:0-异常。库存较少,但是余额没有扣减。商家容易哭死。这就使我们不得不做事务的控制。以达到数据一致性。
Seata方案引入
对此,我们使用分布式事务Seata的解决方案解决此问题。
图片来源于官网
为方便测试使用,这里准备了Seata的一套环境。并给出相应业务测试用例SQL。
使用非常简单。只需要在核心业务方法上加一个注解@GlobalTransactional即可。
@Override
@GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order)
{
//创建订单
orderDao.create(order);
//2 扣减库存
storageService.deduct(order.getProductId(),order.getCount());
//3 扣减账户金额
accountService.debit(order.getUserId(),order.getMoney());
//4 修改订单状态,从零到1,1代表已经完成
orderDao.update(order.getUserId(),0);
}
恢复数据初始状态,再次启动和重复上述测试步骤。观察在发生异常的情况下,数据库还是初始的状态(为出现订单异常和账户余额变动的问题)。显然:这个注解帮助我们:在分布式环境下,当有异常发生时进行全局回滚,以维持数据的一致状态。我们把这种全局意义上控制的事务成为全局事务。
Seata执行流程及原理
@GlobalTransactional用事实告诉我们:极简是一种美!
是不是很好奇?
下面究其背后的原理做深层次的探究。
(1) 全局事务中的角色
- TM 事务发起方,是业务方法上带有@GlobalTransactional注解的服务,如:本案例中订单服务。
- TC 事务协调者,可以理解为一个隐形的中间人,负责管理事务。
- TR 事务参与者:本案例当中:订单、库存、账户都是还是事务参与者
(2) 全局成功提交流程
(3) 全局失败回滚流程
下面我们用代码验证此过程:
- 启动nacos;
- 启动seata;
- debug启动订单、库存、账户服务,断点跟踪
查看数据库变化情况(取关键字段信息):
-- 全局事务
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 之间的对应关系,比对上图中成功提交流程.
我们观察业务库中的undo_log日志情况:(重点关注rollback_info字段信息)
格式化一下rollback_info信息:
当然,读者感兴趣可以查看下seata_account库和 seata_storage库中undo_log的情况,与此类似。
所以我们可以将Seata的执行原理归纳为:
(1) 在一阶段,Seata 会拦截“业务 SQL”,
- 解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”;
- 执行“业务 SQL”更新业务数据;
- 在业务数据更新之后,其保存成“after image”,最后生成行锁。
以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
图片来源于学习笔记
(2) 二阶段提交:
天空不留下鸟儿的痕迹,但它已经飞过),完成数据清理即可。
图片来源于学习笔记
(3) 二阶段回滚:
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据。
- 还原前要首先要校验脏写;
- 对比“数据库当前业务数据”和 “after image”
- 如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
图片来源于学习笔记
总结
本文主要对分布式事务Seata的使用、原理做了介绍,同时在选择方案选择上给出如下建议:
- 非必要不引入分布式事务的处理;
- 分布式事务在分布式环境下使用,单体应用不必考虑;
- 一般多用在远程调用三方外部平台之间,内部系统服务之间建议使用Spring事务
- Seata分布式事务方案默认AT模式,代码无入侵,使用简单,在数据一致性要求比较高的系统中,是很好的分布式事务解决方案。常用于电商支付、金融转账类业务。
- Seata配置繁琐,引入子系统会产生很多其它问题。应根据实际场景合理选择。