分布式事务(二)

在分布式事务(一)中,我们介绍了XA规范下的2PC分布式事务,但是最后也介绍了其中存在的缺点,比如例如长时间锁定数据库资源,导致系统的响应不快,并发上不去等问题,那么我们该如何解决呢?


这里就有一个柔性事务的解决方案架构,柔性事务有两个特性:基本可用和柔性状态。 所谓基本可用是指分布式系统出现故障的时候允许损失一部分的可用性。柔性状态是指允许系统存在中间状态,这个中间状态不会影响系统整体的可用性,比如数据库读写分离的主从同步延迟等。柔性事务的一致性指的是最终一致性。


收到柔性事务就不得不提到BASE理论了,如果想要了解什么是BASE理论的话,可以了解下 分布式系统——CAP理论及BASE理论
分布式事务(二)_第1张图片


基于可靠消息的最终一致性,放弃了刚性事务ACID的强一致性要求,这里我们就来看看TCC事务补偿型方案,TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。 它分为三个阶段:

  • Try: 阶段主要是对业务系统做检测及资源预留
  • Confirm: 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  • Cancel: 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
    分布式事务(二)_第2张图片



这里我们就来看看在项目中如何具体来使用TCC事务,这里我们使用了是 tcc-transaction,这是一个开源的TCC补偿性分布式事务框架,我们可以直接在github上搜索tcc-transaction开源框架,其Git地址: https://github.com/changmingxie/tcc-transaction ,然后我们将其源码压缩包下载下来,使用开发工具打开,如下:
分布式事务(二)_第3张图片


然后这里我们直接使用Maven进行打包发布的话,是会报错的,这里我们需要将移除unit-test单元测试模块,如下:
分布式事务(二)_第4张图片


然后还需要注释掉maven插件中configuration模块,如下:
分布式事务(二)_第5张图片


然后Maven打包发布就能就不会有问题的,但是我们在其他项目中引入时,可能还是会出现相关的问题,主要修改的就是Spring和Dubbo的版本问题,这里我们在其他项目中使用的SpringBoot的版本为2.3.2,以及apache都dubbo版本2.7.6,然后这里经过实验调整了Spring版本为4.3.4,dubbo版本为2.7.6,如下:
分布式事务(二)_第6张图片


至于网上查询得知还需要调整org.quartz-scheduler的版本为2.2.1,并去除其中的c3p0的依赖,这里我并没有进行调整,也没有发生错误,这里也说明下,如果出现了问题可以进行尝试下,如下:
分布式事务(二)_第7张图片


不过虽然未设置这一步,我们将tcc-transaction项目打包后,别的项目中引入时,启动发现缺少相关的类,这里就又在项目添加了相关依赖,如下:
分布式事务(二)_第8张图片


这样我们就可以在IDEA中点击首先点击右上角maven,并且在根目录中点击 clean 清除项目,下一步点击 install 安装项目到本地maven仓库,如下:
分布式事务(二)_第9张图片




然后我们就可以来在别的项目中引入我们需要使用的tcc相关的依赖包了,这里我们创建了两个SpringBoot项目,然后来通过dubbo调用,这两个项目中需要引入tcc相关的依赖包,如下:
在这里插入图片描述

<dependency>
    <groupId>org.mengyungroupId>
    <artifactId>tcc-transaction-dubboartifactId>
    <version>1.2.12version>
dependency>
<dependency>
    <groupId>org.mengyungroupId>
    <artifactId>tcc-transaction-springartifactId>
   <version>1.2.12version>
dependency>

其中的版本号,肯定是从刚刚那个tcc-transaction项目中得知,如下:
分布式事务(二)_第10张图片



这里我们来大致介绍下上面两个项目,其中 tcc-two 项目是dubbo服务的提供方,我们先来看下其项目结构及配置文件application.yml,如下:
分布式事务(二)_第11张图片

server:
  port: 8081

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/test2?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: root

dubbo:
  application:
    name: tcc-two       #服务名称
  registry:
    address: 127.0.0.1:2181    #注册中心服务地址
    port: 2181                 #注册中心缺省端口,当address没有带端口时使用此端口作为缺省值
    protocol: zookeeper        #注册中心地址协议
    timeout: 10000             #注册中心请求超时时间
    check: false               #注册中心不存在时,是否报错
    subscribe: false           #是否向此注册中心订阅服务,如果设为false,将只注册,不订阅
  protocol:
    name: dubbo         #协议名称
  provider:
    timeout: 3000       #远程服务调用超时时间(毫秒)

然后就是一个服务的接口及其实现,其业务很简单,就是我们在分布式事务(一)中做的事,即 tcc-two 是向数据库 test2 中的 user 表,来插入一条指定主键 id 的数据,如下:

public interface Test2UserService {
    @Compensable
    void createUser(Long userId);
}
@Service
public class Test2UserServiceImpl implements Test2UserService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Compensable(confirmMethod = "confirm", cancelMethod = "cancel")
    @Transactional
    @Override
    public void createUser(Long userId) {
        String sql = "INSERT INTO user(id) VALUES (?)";
        //添加test2库的user用户
        jdbcTemplate.update(sql,userId);
    }

    public void confirm(Long userId) {
        //确认,donothing
    }

    public void cancel(Long userId) {      //补偿性
        String sql = "DELETE FROM user where id = ?";
        jdbcTemplate.update(sql, userId);
    }
}

至于上述为什么说TCC是一种补偿性事务,看上述就可以得知,我们在 try 方法加上了@Compensable注解,来指定了其成功或失败后做的事,如上成功插入后,如果其他数据库失败了,这里会调用cancel方法来删除插入的数据,从而保证了最终一致性。


最后我们在 tcc-two 的 resource 目录下还看到了一个 tcc-xml 文件,这个是tcc配置数据源,可以是mysql或其他nosql ,这里我们使用了c3p0,当然你也可以使用其他的如dbcp,druid之类的,如下:


<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:util="http://www.springframework.org/schema/util"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd">
    <import resource="classpath:tcc-transaction.xml"/>
    <bean class="org.mengyun.tcctransaction.spring.recover.DefaultRecoverConfig">
        <property name="maxRetryCount" value="30"/>
        <property name="recoverDuration" value="60"/>
        <property name="cronExpression" value="0/30 * * * * ?"/>
        <property name="delayCancelExceptions">
            <util:set>
                <value>org.apache.dubbo.remoting.TimeoutExceptionvalue>
            util:set>
        property>
    bean>

    <bean id="tccDataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"/>
        <property name="user" value="root"/>
        <property name="password" value="root"/>
        <property name="jdbcUrl" value="jdbc:mysql://127.0.0.1:3306/tcc?useUnicode=true&characterEncoding=utf8"/>
    bean>

    <bean id="transactionRepository" class="org.mengyun.tcctransaction.spring.repository.SpringJdbcTransactionRepository">
        <property name="dataSource" ref="tccDataSource"/>
        <property name="domain" value="SAAS"/>
        <property name="tbSuffix" value="_ASSET"/>
    bean>

beans>

上述需要指定的是,在Git上的1.2.x项目指南中,其实引入的是alibaba的dubbo中的TimeoutException,这里我们使用了Apache的dubbo,并且看到tcc-transaction中也是引入的apache的,加之报错,这里我们就直接将其换成了apache的TimeoutException
分布式事务(二)_第12张图片


然后我们在 tcc-two 的Application 启动类上将其加入,如下:
分布式事务(二)_第13张图片


这样 tcc-two 模块就全部介绍完了,上述最后一个介绍 tcc 配置数据源,在 tcc-one 模块中也是一样的,同样的步骤即配置。




然后再来看一看 tcc-one 的模块,这个在dubbo中是一个消费者,会去调用 tcc-two 的接口,用来向数据库 test2 中插入数据,这里 tcc-one 模块就会操作数据库 test1 ,来向其中插入数据。


其目录结构和相关依赖如下,我们肯定也是要引入 tcc 相关的依赖包,另外还有其他用到的主要的依赖,还有配置文件application.yml
分布式事务(二)_第14张图片

<dependency>
    <groupId>org.mengyungroupId>
    <artifactId>tcc-transaction-dubboartifactId>
    <version>1.2.12version>
dependency>
<dependency>
    <groupId>org.mengyungroupId>
    <artifactId>tcc-transaction-springartifactId>
   <version>1.2.12version>
dependency>

这里依赖在 tcc-two 中也是同样需要的,上述没有提到,和这里一直,可能还需要一些zookeeper、curator等等,如果报错,自行引入。

<dependency>
    <groupId>org.apache.dubbogroupId>
    <artifactId>dubbo-spring-boot-starterartifactId>
    <version>2.7.6version>
dependency>

<dependency>
    <groupId>mysqlgroupId>
    <artifactId>mysql-connector-javaartifactId>
    <version>5.1.38version>
dependency>

<dependency>
    <groupId>com.mchangegroupId>
    <artifactId>c3p0artifactId>
    <version>0.9.5-pre10version>
dependency>

server:
  port: 8080

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/test1?useUnicode=true&characterEncoding=utf8&useSSL=false
    username: root
    password: root

dubbo:
  application:
    name: tcc-one       #服务名称
  registry:
    address: 127.0.0.1:2181    #注册中心服务地址
    port: 2181                 #注册中心缺省端口,当address没有带端口时使用此端口作为缺省值
    protocol: zookeeper        #注册中心地址协议
    timeout: 10000             #注册中心请求超时时间
    check: false               #注册中心不存在时,是否报错
    register: false            #是否向此注册中心注册服务,如果设为false,将只订阅,不注册
  consumer:
    check: false		#启动时检查提供者是否存在,true报错,false忽略
    timeout: 3000		#远程服务调用超时时间(毫秒)


然后我们来看看Test2UserService接口,其实这个接口在 tcc-two 中已经有了,这里我们就是将其拷贝了一份在tcc-one中,用于dubbo的调用(因为我们没有建立公共的引入模块),这里只需要注意下,该接口的名称、包路径名称等和tcc-two模块中保持一致即可。

public interface Test2UserService {
    @Compensable
    void createUser(Long userId);
}

然后就是我们tcc-one模块中,来操作 test1 数据库的部分了,如下:

public interface Test1UserService {
    @Compensable
    void createUser(Long userId);
}
@Service
public class Test1UserServiceImpl implements Test1UserService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Reference
    private Test2UserService test2UserService;

    @Compensable(confirmMethod = "confirm", cancelMethod = "cancel")
    @Transactional
    @Override
    public void createUser(Long userId) {
        //添加test2库的user用户
        test2UserService.createUser(userId);

        //添加test1库的user用户
        String sql = "INSERT INTO user (id) VALUES (?)";
        jdbcTemplate.update(sql, userId);
    }

    public void confirm(Long userId) {
    	//确认,donothing
    }

    public void cancel(Long userId) {      //补偿性
        String sql = "DELETE FROM user where id = ?";
        jdbcTemplate.update(sql, userId);
    }
}

这里同样的我们需要多出两个额外的方法,以用来tcc事务的确认即补偿。另外还需要注意的是在 tcc-two 中,我们使用了@Service 注解时dubbo的,这里我们使用的 @Service 注解其实是 Spring 的。


因为在tcc-one模块的启动类上我们就没有使用 @EnableDubbo 注解,使用dubbo的肯定扫描不到了,我们在测试类中注入就会失败,所以这里使用了Spring的,让Spring自己去扫描,那么至于为什么不使用dubbo的@Service,然后用@EnableDubbo去扫描,一是因为没必要,这里我们是测试,这个只是测试用下,也不用暴露出去,二是使用了dubbo的我们就需要改20880的端口,所以这里需要注意下。
分布式事务(二)_第15张图片


至于上图启动类引入的tcc.xml ,之前也说过和tcc-two中的是一模一样的,这里就不看了,直接来看其测试类

@RunWith(SpringRunner.class)
@SpringBootTest(classes = TccOneApplication.class)
class TccOneApplicationTest {

	@Autowired
	private Test1UserService test1UserService;

	@Test
	void testTest1UserService() {
		test1UserService.createUser(1L);
	}
}

在测试之前,我们还需要建立相关的数据库,其实一共有3个数据库,有关tcc.xml配置数据源需要注意的点:

  1. 数据源必须配置新的,不能使用之前项目存在的dataSource的bean,也不能在同一库中,不然会导致tcc表数据与本地事务一起回滚,从而无法保存异常事务日志;
  2. 注意domain、tbSuffix的配置,这两项文档中并没有配置,但源码demo中配置了,用于数据库的表名称等,推荐配置;
  3. 最后的DefaultRecoverConfig项是可选的,用于恢复与重试,具体作用参考1.2.x项目指南
  4. defaultAutoCommit必须为true(默认为true)



这个数据库如下,其中test1、test2没什么好说的,主要我们在其中新建一张 user 表,其中字段 id 设置为主键,用来保证其不重复即可(其余字段可有可无,若有保证可以为null即可)
在这里插入图片描述

然后这里我们需要使用建表语句在 tcc 数据库中建立一张用户存储tcc事务日志的表,如下:

CREATE TABLE `TCC_TRANSACTION_ASSET` (
  `TRANSACTION_ID` INT(11) NOT NULL AUTO_INCREMENT,
  `DOMAIN` VARCHAR(100) DEFAULT NULL,
  `GLOBAL_TX_ID` VARBINARY(32) NOT NULL,
  `BRANCH_QUALIFIER` VARBINARY(32) NOT NULL,
  `CONTENT` VARBINARY(8000) DEFAULT NULL,
  `STATUS` INT(11) DEFAULT NULL,
  `TRANSACTION_TYPE` INT(11) DEFAULT NULL,
  `RETRIED_COUNT` INT(11) DEFAULT NULL,
  `CREATE_TIME` DATETIME DEFAULT NULL,
  `LAST_UPDATE_TIME` DATETIME DEFAULT NULL,
  `VERSION` INT(11) DEFAULT NULL,
  `IS_DELETE` TINYINT(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`TRANSACTION_ID`),
  UNIQUE KEY `UX_TX_BQ` (`GLOBAL_TX_ID`,`BRANCH_QUALIFIER`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;

其中表名是和tcc.xml配置文件中 tbSufifix 配置有关的,如下:
分布式事务(二)_第16张图片


上述虽然看着挺多挺复杂,其实理清楚逻辑后,还是比较简单的,上述全部完成后,我们就可以来进行测试了,我们将test1数据库的user表事先插入一条id=1的数据,运行测试类,打断点发现会先调用dubbo,在test2数据库中插入了一条信息,但是后来在test1数据库中插入,因为已存在,主键冲突,然后tcc就会发现补偿性回滚事务,其实就是由将其删除啦。




另外上述的TCC事务在一些业务场景下还是可以优化的, 比如在旅游网站上进行购买,需要通过购买机票和预定酒店,这里要求要不全部购买成功,要不全部失败,如有其中一种失败了,那么就需要回滚。


清楚了上述的逻辑,这里我们就来使用TCC事务实现,按照上述我们介绍了,我们肯定就直接在 try 方法里进行购买,confirm 方法中也无需进行操作,只需要在 cancel 方法中失败会把扣除的票数给加回去。上述我们已经详细的介绍了TCC补偿性事务了,这里我们就直接上伪代码了


首先我们在上伪代码之前,我们先来介绍了数据库大致设计,我们肯定会有两个数据库,其中分别有 plane_tickethotel ,其中除了一些必要的信息数据字段外,我们主要要有 status 、userId,其中status表示是否售出(0:未售出、1:已售出),还有就是userId,表示购买人ID

@Service
public class PlaneTicketOrderService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Compensable(confirmMethod = "confirmOrder", cancelMethod = "cancelOrder")
    @Transactional
    public void tryOrder(Long userId) {  //try方法

        //购买飞机票
        jdbcTemplate.update("UPDATE plane_ticket SET userId = ?, status = 1 WHERE status = 0 LIMIT 1", userId);

        //预定酒店
        hotelOrderService.tryOder(userId);
    }

    public void confirmOrder(Long userId) {
        //确认,donothing
    }

    public void cancelOrder(Long userId) { //补偿性
        jdbcTemplate.update("UPDATE plane_ticket SET status = 0 WHERE userId = ?", userId);
    }
}

@Service
public class HotelOrderService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Compensable(confirmMethod = "confirmOrder", cancelMethod = "cancelOrder")
    @Transactional
    public void tryOrder(Long userId) {  //try方法

        //预定酒店
        jdbcTemplate.update("UPDATE hotel SET userId = ?, status = 1 WHERE status = 0 LIMIT 1", userId);

    }

    public void confirmOrder(Long userId) {
        //确认,donothing
    }

    public void cancelOrder(Long userId) { //补偿性
        //本语句是幂等性的
        jdbcTemplate.update("UPDATE hotel SET status = 0 WHERE userId = ?", userId);
    }
}

这里我们如何进行改进呢?这里我们需要在两个表中加入一个冻结字段——frozen ,然后我们在 try 方法中就不会直接进行对票数的扣减了,而是回去先冻结一张票,然后等待两边都能够冻结完成后,在一起在 confirm 方法中进购买,反之失败的话,在 cancel 方法中进行解冻即可。


另外我们还在其中每一步都进行了幂等处理,如下:

@Service
public class PlaneTicketOrderService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Compensable(confirmMethod = "confirmOrder", cancelMethod = "cancelOrder")
    @Transactional
    public void tryOrder(Long userId) {  //try方法
        //幂等性要求,如果已经执行过,则直接返回
        Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM plane_ticket WHERE userId = ?", Integer.class, userId);
        if (count != null && count > 0) {
            return;
        }

        //锁定飞机票
        jdbcTemplate.update("UPDATE plane_ticket SET frozen = 1, userId = ? WHERE status = 0 AND frozen = 0 LIMIT 1", userId);

        //预定酒店
        hotelOrderService.tryOder(userId);
    }

    public void confirmOrder(Long userId) {
        //本语句是幂等性的
        jdbcTemplate.update("UPDATE plane_ticket SET frozen = 0, status = 1 WHERE userId = ?", userId);
    }

    public void cancelOrder(Long userId) {   //补偿性
        //本语句是幂等性的
        jdbcTemplate.update("UPDATE plane_ticket SET frozen = 0 WHERE userId = ?", userId);
    }
}

@Service
public class HotelOrderService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Compensable(confirmMethod = "confirmOrder", cancelMethod = "cancelOrder")
    @Transactional
    public void tryOrder(Long userId) {  //try方法
        //幂等性要求,如果已经执行过,则直接返回
        Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM hotel WHERE userId = ?", Integer.class, userId);
        if (count != null && count > 0) {
            return;
        }

        //锁定酒店
        jdbcTemplate.update("UPDATE hotel SET frozen = 1, userId = ? WHERE status = 0 AND frozen = 0 LIMIT 1", userId);
    }

    public void confirmOrder(Long userId) {
        //本语句是幂等性的
        jdbcTemplate.update("UPDATE hotel SET frozen = 0, status = 1 WHERE userId = ?", userId);
    }

    public void cancelOrder(Long userId) {   //补偿性
        //本语句是幂等性的
        jdbcTemplate.update("UPDATE hotel SET frozen = 0 WHERE userId = ?", userId);
    }
}

你可能感兴趣的:(分布式架构)