【分布式事务】Seata AT实战

目录

[Seata 介绍](#Seata 介绍)

[Seata 术语](#Seata 术语)

[Seata AT 模式](#Seata AT 模式)

介绍

实战(nacos注册中心,db存储)

[部署 Seata](#部署 Seata)

[实现 RM](#实现 RM)

[实现 TM](#实现 TM)

可能遇到的问题

[1. Seata 部署成功,服务启动成功,全局事务不生效](#1. Seata 部署成功,服务启动成功,全局事务不生效)

[2. 服务启动报错 can not get cluster name in registry config 'service.vgroupMapping.xx', please make sure registry](#2. 服务启动报错 can not get cluster name in registry config ‘service.vgroupMapping.xx‘, please make sure registry)

[debug 调试](#debug 调试)

undo_log

branch_table

global_table

lock_table

原理

[RM & TM 如何与 TC 建立连接](#RM & TM 如何与 TC 建立连接)

一阶段步骤

二阶段步骤

[AT 模式如何避免脏写和脏读](#AT 模式如何避免脏写和脏读)

总结


项目地址:GitHub - chenyukang1/mall

本文实战基于如下版本:

JDK 8

Spring Boot 2.6.11

Spring Cloud 2021.0.4

Spring Cloud Alibaba 2021.0.4.0

Seata 1.5.2

Seata 介绍

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

  • 对业务无侵入:即减少技术架构上的微服务化所带来的分布式事务问题对业务的侵入

  • 高性能:减少分布式事务解决方案所带来的性能消耗

源码:https://github.com/seata/seata(opens new window)

文档:Apache Seata

Seata 术语

TC (Transaction Coordinator) 事务协调者 :维护全局和分支事务的状态,驱动全局事务提交或回滚

TM (Transaction Manager) 事务管理器:开始全局事务、提交或回滚全局事务

RM (Resource Manager) 资源管理器:管理分支事务处理的资源,与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚

Seata AT 模式

介绍

AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等

在 AT 模式下,用户只需关注自己的业务SQL,用户的业务SQL 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作

实战(nacos注册中心,db存储)

部署 Seata

完成 db 建表,nacos 发布 seataServer.properties 配置,最后启动 seata,参考:Docker compose部署 | Apache Seata

实现 RM
  1. 创建订单和库存服务的 DB 和表

    -- 库存服务DB执行
    CREATE TABLE tab_storage (
    id bigint(11) NOT NULL AUTO_INCREMENT,
    product_id bigint(11) DEFAULT NULL COMMENT '产品id',
    total int(11) DEFAULT NULL COMMENT '总库存',
    used int(11) DEFAULT NULL COMMENT '已用库存',
    PRIMARY KEY (id)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
    INSERT INTO tab_storage (product_id, total,used)VALUES ('1', '96', '4');
    INSERT INTO tab_storage (product_id, total,used)VALUES ('2', '100','0');

    -- 订单服务DB执行
    CREATE TABLE tab_order (
    id bigint(11) NOT NULL AUTO_INCREMENT,
    user_id bigint(11) DEFAULT NULL COMMENT '用户id',
    product_id bigint(11) DEFAULT NULL COMMENT '产品id',
    count int(11) DEFAULT NULL COMMENT '数量',
    money decimal(11,0) DEFAULT NULL COMMENT '金额',
    status int(1) DEFAULT NULL COMMENT '订单状态:0:创建中;1:已完成',
    PRIMARY KEY (id)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

  2. 各数据库加入undo_log

    CREATE TABLE undo_log (
    id bigint(20) NOT NULL AUTO_INCREMENT,
    branch_id bigint(20) NOT NULL,
    xid varchar(100) NOT NULL,
    context varchar(128) NOT NULL,
    rollback_info longblob NOT NULL,
    log_status int(11) NOT NULL,
    log_created datetime NOT NULL,
    log_modified datetime NOT NULL,
    ext varchar(100) DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY ux_undo_log (xid,branch_id)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

  3. 编写业务代码

实现创建订单 & 锁库存逻辑

    public boolean save(long userId, long productId) {
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setUserId(userId);
        orderEntity.setProductId(productId);
        orderEntity.setCount(1);
        orderEntity.setMoney(BigDecimal.valueOf(80));
        orderEntity.setStatus(0);

        return save(orderEntity);
    }

    public boolean lockStock(long productId, long used) {
        return storageDao.lockStock(productId, used) > 0;
    }

    <update id="lockStock">
        UPDATE tab_storage
        SET used = used + #{num}
        WHERE
            product_id = #{productId}
          AND total - used >= #{num}
    </update>
实现 TM

TM 作为事务全局管理者,也是全局事务的发起者,通过远程调用订单和库存服务,开启全局事务

  1. 编写 Feign 远程调用

  2. 开启全局事务

     @GlobalTransactional(timeoutMills = 300000)
     @GetMapping("/submitOrder")
     public R submitOrder(long userId, long productId, long used) {
         businessService.submitOrder(userId, productId, used);
    
         return R.ok().put("res", "success");
     }
    
     @Override
     public void submitOrder(long userId, long productId, long used) {
         log.info("submitOrder begin ... xid: {}", RootContext.getXID());
         R lockStock = storageFeignService.lockStock(productId, used);
         boolean lockRes = (boolean) lockStock.get("res");
         if (!lockRes) {
             throw new RuntimeException("lock stock fail");
         }
         R save = orderFeignService.save(userId, productId);
         boolean saveRes = (boolean) save.get("res");
         if (!saveRes) {
             throw new RuntimeException("save order fail");
         }
     }
    
可能遇到的问题
1. Seata 部署成功,服务启动成功,全局事务不生效

首先检查是否有全局事务 ID xid,请求的时候会通过请求头传递到下游服务,没有这个一切白搭。可以直接在全局事务的入口打印出来看看,代码示例:

log.info("submitOrder begin ... xid: {}", RootContext.getXID()); 

如果全局事务 ID 为 null,可能的原因有:

  • 版本问题:如果选用较低版本的 Seata(比如v1.5.2),适当降低 Spring Boot、Spring Cloud、Spring Cloud Alibaba 的配套版本,实在不确定可以参考文章开头我的版本配置
2. 服务启动报错 can not get cluster name in registry config 'service.vgroupMapping.xx', please make sure registry

这个报错是因为 TM/RM 的 service.vgroupMapping.xx 配置与 Seata Server 的不一致,可以按如下方式排查:

  1. TM/RM 配置指定了事务群组
  1. 服务端有对应的配置(以 nacos 为例)
  1. TM/RM 的 nacos 注册中心必须和 Seata 在同一 namespace、同一 group(默认是 SEATA_GROUP) 下

  2. Seata 使用 nacos 部署,它读的配置默认是 seataServer.properties,而 TM/RM 的配置要通过官方提供的脚本发布到 nacos 与 Seata 同一命名空间下 ,推荐阅读:Nacos 配置中心 | Apache Seata

debug 调试

我们在全局事务开启后,结束前打断点,看看数据库发生了什么

undo_log

发现 RM 的 undo_log 表中都生成了一条记录,以库存表为例,字段的数据如下

{"@class":"io.seata.rm.datasource.undo.BranchUndoLog","xid":"116.198.200.0:8091:45570721124696075","branchId":45570721124696077,"sqlUndoLogs":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"tab_storage","beforeImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"tab_storage","rows":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Field","name":"id","keyType":"PRIMARY_KEY","type":-5,"value":["java.lang.Long",2]},{"@class":"io.seata.rm.datasource.sql.struct.Field","name":"used","keyType":"NULL","type":4,"value":44}]]}]]},"afterImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"tab_storage","rows":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Field","name":"id","keyType":"PRIMARY_KEY","type":-5,"value":["java.lang.Long",2]},{"@class":"io.seata.rm.datasource.sql.struct.Field","name":"used","keyType":"NULL","type":4,"value":46}]]}]]}}]]}
  • xid:全局事务 id
  • branchId:分支事务 id
  • beforeImage:事务前快照
  • afterImage:事务后快照
branch_table

TC 的 branch_table 新增两条记录,表示开启两个分支事务

global_table

global_table 新增一条记录,表示开启一个全局事务

lock_table

lock_table 新增两条记录,表示两个 RM 一阶段开启了事务,但事务未提交,都持有行锁

原理

RM & TM 如何与 TC 建立连接

在启动阶段,RM/TM 会在控制台打出注册信息,即与 TC 建立了连接

NettyPool create channel to transactionRole:TMROLE,address:116.198.200.0:8091,msg:< RegisterTMRequest{applicationId='seata-server', transactionServiceGroup='business-tx-service-group'} >

不难看出,它们之间的通信基于 Netty,Netty 作为一款高性能的 RPC 通信框架,保证了 TC 与 RM 之间的高效通信

但它又是怎么区分 RM 还是 TM 的?毕竟配置文件都一样。答案是 @GlobalTransactional 注解,这个注解表示开启全局事务,Seata 认为标注这个注解的客户端就是 TM,这类注解都是基于 Spring AOP 机制,对使用了注解的 Bean 方法分配对应的拦截器进行增强,来完成对应的处理逻辑。而 GlobalTransactionScanner 这个 Spring Bean,就承载着为各个注解分配对应的拦截器的职责

推荐阅读:Seata应用侧启动过程剖析------RM & TM如何与TC建立连接 | Apache Seata

一阶段步骤
  1. RM 写表的过程,Seata 会拦截业务 SQL,首先解析 SQL 语义
  2. 在业务数据被更新前,做一次快照,生成 beforeImage
  3. 执行业务 SQL
  4. 在业务数据更新之后,做一次快照,生成 afterImage,最后生成行锁

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

二阶段步骤

因为业务 SQL 在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可

  • 正常:TM 执行成功,通知 TC 全局提交,TC 此时通知所有的 RM 提交成功,删除 undo_log 回滚日志
  • 异常:TM 执行失败,通知 TC 全局回滚,TC 此时通知所有的 RM 进行回滚,根据 undo_log 反向操作,使用 beforeImage 还原业务数据,删除 undo_log。但在还原前要首先要校验脏写,对比 "数据库当前业务数据" 和 "afterImage",如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写会按配置的策略处理
AT 模式如何避免脏写和脏读

推荐阅读:Seata AT 模式 | Apache Seata

总结

优点:

  • 无侵入性:本质上是通过代理数据源实现 2PC 模式,对业务无侵入性,开发成本低

缺点:

  • 不适合高并发场景:AT 模式的实现依赖数据库锁机制,本地事务依赖行锁来实现读写隔离,以电商中常见的提交订单业务为例,提交订单的业务流程涉及到创建订单,锁库存等等,订单是用户维度的数据,并发度不高;但库存记录是 sku 级别的,加行锁很容易让后续的读写请求都阻塞
相关推荐
MZWeiei6 分钟前
Zookeeper的选举机制
大数据·分布式·zookeeper
学计算机的睿智大学生7 分钟前
Hadoop集群搭建
大数据·hadoop·分布式
一路狂飙的猪7 分钟前
RabbitMQ的工作模型
分布式·rabbitmq
miss writer1 小时前
Redis分布式锁释放锁是否必须用lua脚本?
redis·分布式·lua
m0_748254881 小时前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl
字节程序员2 小时前
Jmeter分布式压力测试
分布式·jmeter·压力测试
ProtonBase2 小时前
如何从 0 到 1 ,打造全新一代分布式数据架构
java·网络·数据库·数据仓库·分布式·云原生·架构
时时刻刻看着自己的心2 小时前
clickhouse分布式表插入数据不用带ON CLUSTER
分布式·clickhouse
Data跳动11 小时前
Spark内存都消耗在哪里了?
大数据·分布式·spark
Java程序之猿13 小时前
微服务分布式(一、项目初始化)
分布式·微服务·架构