Seata XA 模式示例分析

790 阅读7分钟

@[toc]

2PC 的传统方案是在数据库层面实现的,为了减少不必要的对接成本,国际开放标准组织 Open Group 定义了分布式事务处理模型 DTP(Distributed Transaction Processing Reference Model)。

  • AP (Application Program):使用 DTP 分布式事务的应用程序,一般是业务工程。

  • RM (Resource Manager):资源管理器,可以理解为事务的参与者,一般指的是数据库实例,通过资源管理噐对该数据库进行控制,资源管理噐控制着分支事务 。

  • TM (Transaction Manager):事务管理器,负责协调和管理事务,事务管理噐控制着全局事务,管理事务生命周期,并协调各个 RM 。 全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一项工作,这项工作即是一个全局事务。

DTP 模型定义了 TM 和 RM 之间通讯的接口规范叫 XA ,基于数据库的 XA 协议来实现 2PC 又称为 XA 方案。

AP、RM 与 TM 角色之间的交互方式如下:

  1. TM 向 AP 提供应用程序编程接口, AP 通过 TM 提交和回滚事务。
  2. TM 通过 XA 接口来通知 RM 数据库事务的开始 、 结束提交和回滚等。

seata 的 xa 模型如下图所示:

在这里插入图片描述

1 下载示例

利用 git 命令把 Seata XA 模式示例下载到本地,下载地址:github.com/seata/seata…

2 示例结构

示例采用 spring-cloud 微服务架构,数据库采用 mysql,数据库连接池采用 druid,数据库 DAO 层采用 jdbcTemplate。

把 Seata XA 模式示例装载到 IDEA 中。

目录或文件说明
account-xa账户模块
business-xa业务模块
order-xa订单模块
sql初始化脚本所在文件夹
stock-xa库存模块
pom.xml父类 pom 配置

在这里插入图片描述

在这里插入图片描述

3 业务服务 business-xa

触发点在业务服务中,所以我们先从这里开始分析。

3.1 模块结构

模块是标准的 Maven 结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LuPgKWzQ-1638236325213)(C:\个人\写作\md 文章\分布式事务\Untitled.assets\image-20211129101345918.png)]

目录或文件说明
io/seata/sample/controller控制层
io/seata/sample/feignfeign客户端
BusinessXAApplicationSpring 启动类
BusinessXADataSourceConfigurationseata XA 模式配置类
TestDatas测试数据类

3.2 Controller 层

Controller 层定义了一个 BusinessController 类,里面只有一个 purchase “购买” 方法,入参为 rollback(是否强制回滚,默认为 false) 与 count(购买数量,默认为 30)。强制回滚指的是即使业务逻辑都执行成功,仍然主动进行回滚。

    @RequestMapping(value = "/purchase", method = RequestMethod.GET, produces = "application/json")
    public String purchase(Boolean rollback, Integer count) {
        int orderCount = 30;
        if (count != null) {
            orderCount = count;
        }
        try {
            businessService.purchase(TestDatas.USER_ID, TestDatas.COMMODITY_CODE, orderCount,
                rollback == null ? false : rollback.booleanValue());
        } catch (Exception exx) {
            return "Purchase Failed:" + exx.getMessage();
        }
        return SUCCESS;
    }

3.3 Service 层

Service 层定义了 BusinessService 类,该类有两个方法,它们分别是 purchase 与 initData。

(1)purchase 方法

purchase 方法定义了购买逻辑。

 @GlobalTransactional
    public void purchase(String userId, String commodityCode, int orderCount, boolean rollback) {
        String xid = RootContext.getXID();
        LOGGER.info("New Transaction Begins: " + xid);

        String result = stockFeignClient.deduct(commodityCode, orderCount);

        if (!SUCCESS.equals(result)) {
            throw new RuntimeException("库存服务调用失败,事务回滚!");
        }

        result = orderFeignClient.create(userId, commodityCode, orderCount);

        if (!SUCCESS.equals(result)) {
            throw new RuntimeException("订单服务调用失败,事务回滚!");
        }

        if (rollback) {
            throw new RuntimeException("Force rollback ... ");
        }
    }

具体步骤如下:

  1. purchase 方法标注了 @GlobalTransactional,表示开启全局事务。
  2. 利用 RootContext.getXID() 来获取事务 ID。
  3. 调用库存服务 stockFeignClient.deduct(commodityCode, orderCount),扣减商品库存。
  4. 调用订单服务 orderFeignClient.create(userId, commodityCode, orderCount),创建商品订单。
  5. 如果是强制回滚模式,就直接抛出 RuntimeException 异常。

(2)initData 方法

initData 方法用于初始化数据。

    @PostConstruct
    public void initData() {
        jdbcTemplate.update("delete from account_tbl");
        jdbcTemplate.update("delete from order_tbl");
        jdbcTemplate.update("delete from stock_tbl");
        jdbcTemplate.update("insert into account_tbl(user_id,money) values('" + TestDatas.USER_ID + "','10000') ");
        jdbcTemplate.update(
            "insert into stock_tbl(commodity_code,count) values('" + TestDatas.COMMODITY_CODE + "','100') ");
    }

该方法标注上了 @PostConstruct 注解。被@PostConstruct修饰的方法会在服务器加载 Servlet 的时候运行,并且只会被服务器执行一次1

初始化数据逻辑如下:

  1. 删除账户表、订单表与库存表中的数据。
  2. 插入一条账户信息,账户金额为 10000。
  3. 插入一条库存信息,库存量为 100。

3.4 stock Feign 客户端

stock Feign 客户端用于调用库存服务,配置的是本地服务。入参是商品代码(commodityCode)与数量(count)。

@FeignClient(name = "stock-xa", url = "127.0.0.1:8081")
public interface StockFeignClient {

    @GetMapping("/deduct")
    String deduct(@RequestParam("commodityCode") String commodityCode, @RequestParam("count") int count);

}

3.5 order Feign 客户端

order Feign 客户端用于订单服务,配置的也是本地服务。入参是用户ID(userId)、商品代码(commodityCode)与数量(count)。

@FeignClient(name = "order-xa", url = "127.0.0.1:8082")
public interface OrderFeignClient {

    @GetMapping("/create")
    String create(@RequestParam("userId") String userId, @RequestParam("commodityCode") String commodityCode,
                  @RequestParam("orderCount") int orderCount);

}

4 库存服务 stock-xa

库存模块的结构与业务模块大同小异,所以我们直接来看它的 service 层。

4.1 服务层 StockService

StockService 定义了 deduct 方法,用于扣减商品的库存数量。

public void deduct(String commodityCode, int count) {
        String xid = RootContext.getXID();
        LOGGER.info("deduct stock balance in transaction: " + xid);
        jdbcTemplate.update("update stock_tbl set count = count - ? where commodity_code = ?",
            new Object[] {count, commodityCode});
    }

具体步骤如下:

  1. 利用 RootContext.getXID() 来获取事务 ID。
  2. 扣减商品的库存数量。

4.2 数据源配置

使用了 DataSourceProxyXA 类代理了 DruidDataSource。

@Bean("dataSourceProxy")
    public DataSource dataSource(DruidDataSource druidDataSource) {
        // DataSourceProxy for AT mode
        // return new DataSourceProxy(druidDataSource);

        // DataSourceProxyXA for XA mode
        return new DataSourceProxyXA(druidDataSource);
    }

5 订单服务 order-xa

数据源配置与库存服务相同,也是使用 DataSourceProxyXA 类来代理 DruidDataSource。

5.1 服务层 OrderService

service 层只有一个 OrderService 类,该类定义了 create 方法,用于创建库存。

public void create(String userId, String commodityCode, Integer count) {
        String xid = RootContext.getXID();
        LOGGER.info("create order in transaction: " + xid);

        // 定单总价 = 订购数量(count) * 商品单价(100)
        int orderMoney = count * 100;
        // 生成订单
        jdbcTemplate.update("insert order_tbl(user_id,commodity_code,count,money) values(?,?,?,?)",
            new Object[] {userId, commodityCode, count, orderMoney});
        // 调用账户余额扣减
        String result = accountFeignClient.reduce(userId, orderMoney);
        if (!SUCCESS.equals(result)) {
            throw new RuntimeException("Failed to call Account Service. ");
        }

    }

具体步骤如下:

  1. 利用 RootContext.getXID() 来获取事务 ID。一般用于记录日志。
  2. 计算定单总价,公式为订购数量(count) * 商品单价(100)。这里的商品单价写死在代码中。
  3. 创建一条订单。
  4. 调用账户服务扣减账户余额。

5.2 account feign 客户端

account feign 客户端用于扣减账户余额,入参是用户ID(userId)与金额(money)。

@FeignClient(name = "account-xa", url = "127.0.0.1:8083")
public interface AccountFeignClient {
    @GetMapping("/reduce")
    String reduce(@RequestParam("userId") String userId, @RequestParam("money") int money);
}

6 账户服务 account-xa

数据源配置与库存服务相同,也是使用 DataSourceProxyXA 类来代理 DruidDataSource。

6.1 服务层 AccountService

我们来看 AccountService 类的 reduce 方法。

@Transactional
    public void reduce(String userId, int money) {
        String xid = RootContext.getXID();
        LOGGER.info("reduce account balance in transaction: " + xid);
        jdbcTemplate.update("update account_tbl set money = money - ? where user_id = ?", new Object[] {money, userId});
        int balance = jdbcTemplate.queryForObject("select money from account_tbl where user_id = ?",
            new Object[] {userId}, Integer.class);
        LOGGER.info("balance after transaction: " + balance);
        if (balance < 0) {
            throw new RuntimeException("Not Enough Money ...");
        }
    }

处理逻辑如下:

  1. 使用 @Transactional 来包裹该方法。
  2. 利用 RootContext.getXID() 来获取事务 ID。
  3. 扣减账户剩余金额。
  4. 查询当前账户剩余金额并返回结果。

以上四个服务(业务服务、库存服务、订单服务、账户服务),只有这个方法标注了 @Transactional,所以不确定在什么情况下进行标注。

7 测试

7.1 执行测试脚本

脚本放置在 \seata\seata-samples\seata-xa\sql\all_in_one.sql 中,用于创建库存、订单与账户表。执行该脚本,写入本地 mysql 数据库。

DROP TABLE IF EXISTS `stock_tbl`;
CREATE TABLE `stock_tbl`
(
    `id`             int(11) NOT NULL AUTO_INCREMENT,
    `commodity_code` varchar(255) DEFAULT NULL,
    `count`          int(11) DEFAULT 0,
    PRIMARY KEY (`id`),
    UNIQUE KEY (`commodity_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl`
(
    `id`             int(11) NOT NULL AUTO_INCREMENT,
    `user_id`        varchar(255) DEFAULT NULL,
    `commodity_code` varchar(255) DEFAULT NULL,
    `count`          int(11) DEFAULT 0,
    `money`          int(11) DEFAULT 0,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl`
(
    `id`      int(11) NOT NULL AUTO_INCREMENT,
    `user_id` varchar(255) DEFAULT NULL,
    `money`   int(11) DEFAULT 0,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

7.2 配置数据源

分别进入 Account 服务、Business 服务、Order 服务和 Stock 服务,修改 application.properties 中与数据库相关的配置。形如:

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/cloud?useSSL=false&serverTimezone=UTC
spring.datasource.username=cloud_user
spring.datasource.password=xxx

7.2 启动 seata-server 服务

seata 需要一个 seata-server 作为分布式事务服务端。

7.3 启动服务

启动 Account 服务、Business 服务、Order 服务和 Stock 服务:

启动成功后,会初始化以下数据。初始化逻辑定义在 business-xa 服务中。

账户(account_tbl):新建一个账户,余额为 10000。

在这里插入图片描述

库存(stock_tbl):新建一个商品,库存为 100。

在这里插入图片描述

以上这些服务启动成功后,会在 seata-server 中注册并打印出相关日志:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-huD6XDK2-1638236325218)(C:\个人\写作\md 文章\分布式事务\Seata XA 模式示例分析.assets\image-20211130091519028.png)]

7.4 触发购买流程

(1)正常执行

打开浏览器,访问 http://127.0.0.1:8084/purchase 触发购买流程。

利用以下语句来查询相关数据:

-- 账户
SELECT * FROM account_tbl;

-- 订单
SELECT * FROM order_tbl;

-- 库存
SELECT * FROM stock_tbl;

账户余额变为 7000(10000-3000):

订单的商品数量为 30 个,总金额为 3000:

在这里插入图片描述

库存量变为 70 个(100 -30):

在这里插入图片描述

这是正常执行的情况。

(2)异常执行

重启 businsess-xa 服务,重新初始化数据。

执行:http://127.0.0.1:8084/purchase?rollback=true,强制让 businsess-xa 服务方法抛出异常,这样所有的分布式事务全部回滚。

账户余额保持不变:

在这里插入图片描述

库存保持不变:

在这里插入图片描述

最终测试结果:

在这里插入图片描述

参考资料

github.com/seata/seata…

Footnotes

  1. blog.csdn.net/qq360694660…