一站式分布式事务 Seata 方案

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

引言

上一篇《如何选择分布式事务解决方案?》 综合比较了各种分布式事务方案的优缺点,并在技术选型上给出合理化的建议。考虑到企业应用的普遍性和适用性,今天重点聊聊分布式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配置繁琐,引入子系统会产生很多其它问题。应根据实际场景合理选择。
责任编辑:赵宁宁 来源: 码易有道
相关推荐

2022-06-27 08:21:05

Seata分布式事务微服务

2023-11-06 13:15:32

分布式事务Seata

2022-06-21 08:27:22

Seata分布式事务

2022-03-24 07:51:27

seata分布式事务Java

2017-05-04 21:30:32

前端异常监控捕获方案

2023-01-06 09:19:12

Seata分布式事务

2022-07-10 20:24:48

Seata分布式事务

2010-05-06 16:02:26

2023-02-04 18:24:10

SeataJava业务

2013-06-14 09:30:52

2013-10-20 13:30:07

华为一站式BYOD敏捷办公

2021-04-23 08:15:51

Seata XA AT

2024-10-09 14:14:07

2015-12-15 17:33:57

戴尔云计算

2013-12-12 15:34:00

Moneta移动支付一站式解决方案

2015-04-19 16:36:10

腾讯云

2009-10-23 09:42:24

2009-07-30 21:16:29

布线服务电缆架设

2022-09-16 11:27:46

建设微服务

2023-10-26 06:59:58

FinOps云原生
点赞
收藏

51CTO技术栈公众号