一站式分布式事务Seata方案

渔夫出海前并不知道鱼在哪,他们只是相信定会满载而归.                    ——稻盛和夫

引言

  上一篇《 如何选择分布式事务解决方案?》 综合比较了各种分布式事务方案的优缺点,并在技术选型上给出合理化的建议。考虑到企业应用的普遍性和适用性,今天重点聊聊分布式Seata方案实践和原理。

Seata 是什么?

  Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。默认AT模式

英文官网:https://seata.io/zh-cn/

中文官网:https://seata.io/zh-cn/

看官网这轻松而有趣的解释,心中默念:

为探究这个问题,我们尝试从问题中来,到问题中去。试想:

  1. 不使用分布式事务会产生怎样的结果?

  2. 为何选择Seata方案?

  3. 这种方案如何实现提交和回滚保证数据一致性的?

带着这些问题,根据实际应用场景逐步探究。。。

业务场景

  在电商交易系统中,最核心的业务场景:下订单 --> 减库存 --> 调支付,要求具备数据强一致性。

基本模型如下:

我们要求该场景应满足以下几个条件:

  1. 订单状态正常(0-异常,1-正常)

  2. 库存不能出现负数或者下单了没有扣库存

  3. 保证金额正常支付,不能出现扣款多或者扣款少的情况

业务实现

下面我们给出核心代码描述业务过程。

订单服务

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&money=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,读者可查阅我的另一篇文章《 手把手教会Seata环境配置

使用非常简单。只需要在核心业务方法上加一个注解@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、全局失败回滚流程

下面我们用代码验证此过程:

  1. 启动nacos;

  2. 启动seata;

  3. 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”,

  1. 解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”;

  2. 执行“业务 SQL”更新业务数据;

  3. 在业务数据更新之后,其保存成“after image”,最后生成行锁。

  以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。

图片来源于学习笔记

2、二阶段提交:

  因为“业务 SQL”在一阶段已经提交至数据库,所以Seata框架只需将一阶段保存的快照数据和行锁删掉(天空不留下鸟儿的痕迹,但它已经飞过),完成数据清理即可。

图片来源于学习笔记

3、二阶段回滚:

  二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据。

  1. 还原前要首先要校验脏写;

  2. 对比“数据库当前业务数据”和 “after image

  3. 如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。

图片来源于学习笔记

总结

  本文主要对分布式事务Seata的使用、原理做了介绍,同时在选择方案选择上给出如下建议:

  • 非必要不引入分布式事务的处理;

  • 分布式事务在分布式环境下使用,单体应用不必考虑;

  • 一般多用在远程调用三方外部平台之间,内部系统服务之间建议使用Spring事务

  • Seata分布式事务方案默认AT模式,代码无入侵,使用简单,在数据一致性要求比较高的系统中,是很好的分布式事务解决方案。常用于电商支付、金融转账类业务。

  • Seata配置繁琐,引入子系统会产生很多其它问题。应根据实际场景合理选择。

结尾

  感谢阅读到最后,文章内容主要来源于近期学习笔记,结合自身的理解做了整理。不足之处敬请批评指正。如果您觉得有帮助,也请点赞、转发加关注,一起做长期且正确的事情!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值