Seata实战-分布式事务简介及demo上手

Seata简介

Seata(Simple Extensible Autonomous Transaction Architecture) 是 阿里巴巴开源的分布式事务中间件,以高效并且对业务 0 侵入的方式,解决微服务场景下面临的分布式事务问题。

附上项目github链接

https://github.com/seata

目前Seata还处于不断开源升级中,并不建议在线上使用,生产环境可以考虑使用阿里云商用的GTS,附上Seata目前的升级计划,可以考虑在V1.0,即服务端HA集群版本进行线上使用
Seata实战-分布式事务简介及demo上手

先来看下为什么会产生分布式事务问题

分布式事务产生背景

讲到事务,又得搬出经典的银行转账问题了,下面以实例说明

假设银行(bank)中有两个客户(name)张三和李四
我们需要将张三的1000元存款(sal)转到李四的账户上

目标就是张三账户减1000,李四账户加1000,不能出现中间步骤(张三减1000,李四没加)
Seata实战-分布式事务简介及demo上手

假设dao层代码如下

public interface BankMapper {
    /**
     * @param userName 用户名
     * @param changeSal 余额变动值
     */
    public void updateSal(String userName,int changeSal);
}

对应xml中sql如下

<update id="updateSal">
        update bank SET sal = sal+#{changeSal}  WHERE name = #{userName}
 </update>

如果两个用户对应的银行存款数据在一个数据源中,即一个数据库中,那么service层代码可以如下编写

/**
     * @param fromUserName 转账人
     * @param toUserName 被转账人
     * @param changeSal 转账额度
     */
    @Transactional(rollbackFor = Exception.class)
    public void changeSal(String fromUserName,String toUserName,int changeSal) {
        bankMapper.updateSal(fromUserName, -1 * changeSal);
        bankMapper.updateSal(toUserName, changeSal);
    }

通过spring框架下的@Transactional注解来保证单一数据源增删改查的一致性

但是随着业务的不断扩大,用户数在不断变多,几百万几千万用户时数据可以存一个库甚至一个表里,假设有10个亿的用户?

数据库的水平分割

为了解决数据库上的瓶颈,分库是很常见的解决方案,不同用户就可能落在不同的数据库里,原来一个库里的事务操作,现在变成了跨数据库的事务操作。
Seata实战-分布式事务简介及demo上手
此时@Transactional注解就失效了,这就是跨数据库分布式事务问题

微服务化

当然,更多的情形是随着业务不断增长,将业务中不同模块服务拆分成微服务后,同时调用多个微服务所产生的

微服务化的银行转账情景往往是这样的

  1. 调用交易系统服务创建交易订单;
  2. 调用支付系统记录支付明细;
  3. 调用账务系统执行 A 扣钱;
  4. 调用账务系统执行 B 加钱;

Seata实战-分布式事务简介及demo上手

如图所示,每个系统都对应一个独立的数据源,且可能位于不同机房,同时调用多个系统的服务很难保证同时成功,这就是跨服务分布式事务问题

分布式事务理论基础

两阶段提交(2pc)

两阶段提交协议(Two Phase Commitment Protocol)中,涉及到两种角色

一个事务协调者(coordinator):负责协调多个参与者进行事务投票及提交(回滚)
多个事务参与者(participants):即本地事务执行者

总共处理步骤有两个
(1)投票阶段(voting phase):协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功,但未提交)或取消(本地事务执行故障);
(2)提交阶段(commit phase):收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚;

如果所示 1-2为第一阶段,2-3为第二阶段

如果任一资源管理器在第一阶段返回准备失败,那么事务管理器会要求所有资源管理器在第二阶段执行回滚操作。通过事务管理器的两阶段协调,最终所有资源管理器要么全部提交,要么全部回滚,最终状态都是一致的

Seata实战-分布式事务简介及demo上手
图片来自蚂蚁金服公众号

TCC

TCC 将事务提交分为 Try - Confirm - Cancel 3个操作。其和两阶段提交有点类似,Try为第一阶段,Confirm - Cancel为第二阶段,是一种应用层面侵入业务的两阶段提交。

Try:预留业务资源/数据效验
Confirm:确认执行业务操作
Cancel:取消执行业务操作

其核心在乎try步骤中的冻结/预留业务资源中,还是以银行转账例子来说明
假设用户user表中有两个字段:可用余额(available_money)、冻结余额(frozen_money)

A给B转账1000块的操作就会变成

Try:
交易系统创建交易订单,状态为特殊状态(待交易)
账单系统生成交易明细,状态待交易
先检查 A 账户余额是否足够,再将A的可用余额-1000,冻结余额+1000

Confirm:
订单状态变成交易完成
交易明细状态变为交易完成
A冻结余额-1000
B可用余额+1000

Cancle
交易订单状态变为交易失败
交易明细状态变为交易失败
A可用余额+1000,冻结余额-1000

引用网上一张TCC原理的参考图片
Seata实战-分布式事务简介及demo上手

使用TCC时要注意Try - Confirm - Cancel 3个操作的幂等控制,网络原因,或者重试操作都有可能导致这三个操作的重复执行

Seata解决方案

解决分布式事务问题,有两个设计初衷

  1. 对业务无侵入:即减少技术架构上的微服务化所带来的分布式事务问题对业务的侵入
  2. 高性能:减少分布式事务解决方案所带来的性能消耗

那么Seata是如何解决分布式事务问题的呢,Seata是基于XA事务演进而来的一个分布式事务中间件,XA是一个基于数据库实现的分布式事务协议,本质上和两阶段提交一样,需要数据库支持,Mysql5.6以上版本支持XA协议,其他数据库如Oracle,DB2也实现了XA接口

Seata中角色如下
Seata实战-分布式事务简介及demo上手

  1. Transaction Coordinator (TC): 事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚
  2. Transaction Manager ™: 控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议
  3. Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚

基本处理逻辑如下
Seata实战-分布式事务简介及demo上手
Branch就是指的分布式事务中每个独立的本地局部事务

第一阶段

Seata 的 JDBC 数据源代理通过对业务 SQL 的解析,把业务数据在更新前后的数据镜像组织成回滚日志,利用 本地事务 的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个 本地事务 中提交。

这样,可以保证:任何提交的业务数据的更新一定有相应的回滚日志存在
Seata实战-分布式事务简介及demo上手
基于这样的机制,分支的本地事务便可以在全局事务的第一阶段提交,并马上释放本地事务锁定的资源

这也是Seata和XA事务的不同之处,两阶段提交往往对资源的锁定需要持续到第二阶段实际的提交或者回滚操作,而有了回滚日志之后,可以在第一阶段释放对资源的锁定,降低了锁范围,提高效率,即使第二阶段发生异常需要回滚,只需找对undolog中对应数据并反解析成sql来达到回滚目的

同时Seata通过代理数据源将业务sql的执行解析成undolog来与业务数据的更新同时入库,达到了对业务无侵入的效果

第二阶段

如果决议是全局提交,此时分支事务此时已经完成提交,不需要同步协调处理(只需要异步清理回滚日志),Phase2 可以非常快速地完成
Seata实战-分布式事务简介及demo上手

如果决议是全局回滚,RM 收到协调器发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚
Seata实战-分布式事务简介及demo上手

Demo上手-Dubbo集成Seata

Demo的github项目名称是fescar-example,链接如下

https://github.com/fescar-group/fescar-samples

要跑demo例子,首先需要下载上述链接官方demo,我下面以IDEA为例子演示demo中dubbo的分布式事务例子,另外还需要一个fescar-server,我下的版本是0.4.1,链接如下

https://github.com/seata/seata/releases

先看下fescar-example的项目结构
Seata实战-分布式事务简介及demo上手

其中dubbo模块就是dubbo的demo
模块中代码结构如下
Seata实战-分布式事务简介及demo上手

配置修改

由于我本地启动了Zookeeper服务端做dubbo注册中心,所以我修改了4个dubbo配置文件中的注册中心为zk,官方默认的是用的广播,没有Zk用广播或者Redis做注册中心都可以
Seata实战-分布式事务简介及demo上手
数据库地址,由于我们是针对Rpc远程服务做分布式事务测试,所以数据库用一个也能达到测试效果,本地启动Mysql服务,并新建名为fescar的数据库
Seata实战-分布式事务简介及demo上手
同时修改jdbc.properties中url地址

jdbc.account.url=jdbc:mysql://localhost:3306/fescar
jdbc.account.username=root
jdbc.account.password=123456
jdbc.account.driver=com.mysql.jdbc.Driver
# storage db config
jdbc.storage.url=jdbc:mysql://localhost:3306/fescar
jdbc.storage.username=root
jdbc.storage.password=123456
jdbc.storage.driver=com.mysql.jdbc.Driver
# order db config
jdbc.order.url=jdbc:mysql://localhost:3306/fescar
jdbc.order.username=root
jdbc.order.password=123456
jdbc.order.driver=com.mysql.jdbc.Driver

再执行dubbo_biz.sql中的建表语句,创建storage_tbl,order_tbl,account_tbl 3个业务表
再执行undo_log.sql 创建seata所需记录undolog的回滚日志表

启动测试

先启动fescar-server,启动方式

sh fescar-server.sh $LISTEN_PORT $PATH_FOR_PERSISTENT_DATA

参数有两个,LISTEN_PORT代表端口号,PATH_FOR_PERSISTENT_DATA表示Seata持久化数据存放路径

将安装包解压后cd到bin目录下,我这里指定端口号为8091,data路径为我自己创建的一个目录

sh fescar-server.sh 8091 /Users/chenyin/fescar/data

启动成功效果图如下
Seata实战-分布式事务简介及demo上手

回到项目代码中

先启动DubboAccountServiceStarter,其初始化时向account_tbl表中插入一个用户编号为
U100001的用户,初始金额为999

再启动DubboOrderServiceStarterDubboStorageServiceStarter,DubboStorageServiceStarter中默认初始化一个商品编号为C00321的商品,初始库存100

看下BusinessService业务处理类,里面调用了库存类(StorageService)扣减库存,调用订单类(OrderService)下单,其中手动抛出RuntimeException模拟分布式事务中的异常情况

@GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx")
    public void purchase(String userId, String commodityCode, int orderCount) {
        LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
        storageService.deduct(commodityCode, orderCount);
        orderService.create(userId, commodityCode, orderCount);
        throw new RuntimeException("xxx");

    }

如果没有throw new RuntimeException(“xxx”); 正确的业务操作结果是用户账户余额减400变成599,库存减2变成98,具体为什么是余额-400,库存-2大家看下demo中具体业务类的代码就知道,不多说。异常的情况,也就是分布式事务回滚的情况,应该是余额还是999,库存还是100

先看下有抛出异常的情况,启动业务类,DubboBusinessTester,执行结果如下
Seata实战-分布式事务简介及demo上手
检查下数据库中数据是否正确,有没发生数据未回滚的情况
Seata实战-分布式事务简介及demo上手
Seata实战-分布式事务简介及demo上手
数据正确无误,证明数据正确的回滚了。上面介绍过了,Seata是根据undolog中记录来回滚的,但是异常回滚后undolog表却为空?怎么回事,这是因为undolog日志被删除了,想要看到undolog表中记录,我们打断点来看,在异常还没抛出时打断点,看下数据库undolog表中数据情况
Seata实战-分布式事务简介及demo上手

断点处触发后,查看undolog表,可以看到3条记录,3个branch_id对应3个rpc分支事务,也就对应3个业务表的回滚日志,一个xid标识这3个分支事务处于一个全局分布式事务中
Seata实战-分布式事务简介及demo上手

其中rollbackinfo字段是bytes类型,看不到具体数据,怎么办?我们把数据导成txt格式
Seata实战-分布式事务简介及demo上手
数据内容如下,可以看到是BASE64加密过的,进行解密
Seata实战-分布式事务简介及demo上手
最终内容如下,我只贴出第一行数据中的rollback_info,可以看到其中记录了数据操作前后的镜像数据beforeImage,afterImage,如果发生回滚,可以通过xid,branchid定位到undolog中的rollback_info,并将beforeImage中内容反解析成sql来达到回滚目的的

{
  "branchId": 2008522332,
  "sqlUndoLogs": [
    {
      "afterImage": {
        "rows": [
          {
            "fields": [
              {
                "keyType": "PrimaryKey",
                "name": "id",
                "type": 4,
                "value": 3
              },
              {
                "keyType": "NULL",
                "name": "count",
                "type": 4,
                "value": 98
              }
            ]
          }
        ],
        "tableName": "storage_tbl"
      },
      "beforeImage": {
        "rows": [
          {
            "fields": [
              {
                "keyType": "PrimaryKey",
                "name": "id",
                "type": 4,
                "value": 3
              },
              {
                "keyType": "NULL",
                "name": "count",
                "type": 4,
                "value": 100
              }
            ]
          }
        ],
        "tableName": "storage_tbl"
      },
      "sqlType": "UPDATE",
      "tableName": "storage_tbl"
    }
  ],
  "xid": "192.168.202.197:8091:2008522331"
}

至于不抛出异常的情况,这里就不多做演示了,注释掉抛出异常的代码,重新运行一下就行

fescar-samples中也集成了tcc的分布式事务demo,下次文章再做介绍