一起叒来看分布式事务

2,895 阅读18分钟

事务是指将所有涉及到的操作放到一个不可分割的执行单元内. 一个事务内的所有操作, 要么全部都执行, 要么全部都不执行. 这就是事务的通俗理解.

一般来说, 事务都是针对数据库而言, 但是其实并不是,一些消息队列例如RocketMq, kafka等也会涉及到事务. 这些组件有个专门的术语, 叫资源管理器(Resource Manager, 即RM)

分布式事务,是随着分布式系统应用越来越广泛的过程中衍生出来的一个新概念,一般是指RM在不同的节点上.在微服务大行其道的今天, 分布式事务越来越值得被重视.

本地事务

本文是想介绍分布式事务的, 但是要说分布式事务, 本地事务又是一个绕不开的话题. 所以我们这里迅速过一下本地事务的相关概念.

ACID

ACID是事务必须具备的四个特性.其中分别是:

1. A是指原子性. 是指一个操作必须是一个不可分割的单元, 要么执行要么不执行, 不能存在指执行了一半,另外一半没执行的状态
2. C是指一致性. 是指事务执行前后,系统都处在一个一致性的状态.
3. I是指隔离性. 隔离性是指不同事务之间应该互相隔离,不受影响
4. D是持久性, 表示事务的执行应该是永久性的, 不能因为系统重启或奔溃就丢失

一般地,ACID中的I又会引出另外一组概念: 可见性问题和隔离等级.

可见性问题是指由于一个事务内的操作在另外一个事务中的可见性而带来的问题. 一般来说,可见性越高, 带来问题的可能性就越大.

可见性从高到低, 问题有以下几种:

1. 读未提交. 一个事务能读到另外一个事务未提交的更改.这是最严重的问题的,未提交的数据都是脏数据.

2. 不可重复读. 所谓的不可重复读是指一个事务第一次读某条记录
和第二次读同一条记录时会读到不一样的内容.
原因是该事务在这两次读之间, 有另外一个事务更新了这条记录,并且提交了.

3. 幻读. 所谓幻读是指一个事务在两次读同一份数
据时, 第一次和第二次读到的数量不一样. 原因是该事务在这两次读之间,
有另外一个事务新增/删除了记录, 并提交了.


可以看到, 其实不可重复读和幻读都是由于另外一个事务更改了数据造成的.
两者的差别是另外一个事务的操作是update还是insert/delete

隔离等级和可见性问题是遥相呼应的, 每个隔离等级的存在都是为了解决掉可见性问题

隔离等级有以下几种:

1. 读未提交. 这是最低级别的隔离等级, 很明显,什么可见性问题都没解决.
2. 读已提交. 解决了"读未提交"的问题
3. 可重复读. 解决"不可重复读"的问题
4. 串行化, 解决了"幻读"的问题.

当然, 越高的隔离等级意味着越低的处理数据的吞吐量.

mysql中的事务

在mysql中, 可以用 begin, commit, rollback三个指令来实现事务.

1. begin用来开始一个事务.
2. commit 用来提交一个事务
3. rollback用来回滚一个事务

mysql的事务是默认自动提交的.可以使用

set autocommit = 0
或
set autocommit = 1

来关闭/开启自动提交.

值得一提的是, mysql默认的隔离等级是"可重复读", 但是高版本的innodb(mysl5.7)其实是通过间隙锁达到了"串行化"的标准了的.

spring中的事务

spring是支持事务操作的话, 但是spring中的事务操作其实都只是个代理, 最终都是依赖数据库的begin, commit, rollback实现的.

编程式事务

编程式事务是指通过transactionTemplate和TransactionManager来手动控制commit和rollback的事务.

编程式事务相对于声明式事务而言, 灵活度更高, 例如可以针对某个代码段提交或回滚.

声明式事务(代理)

声明式事务通俗来说就是注解事务, 通过把spring的@Transactional注解添加到方法或类上来声明一个事务, 因此得名"声明式事务".

@Transactional常用的参数有:

1. propagation, 指定事务的传播等级
2. isolation, 指定隔离等级
3. norollback for, 指定不回滚事务的异常
4. rollback for, 指定需要回滚事务的异常
5. timeout, 指定事务的超时时间

声明式事务的原理是动态代理和AOP, 简单来说就是在具体的方法执行前后加上begin, commit, rollback的逻辑.

声明式事务最大的好处就是简单, 对代码侵入性低.对应的缺点就是粒度不好控制, 最小的粒度也是要加到方法上.

事务的传播(嵌套事务)

事务的传播通俗地来讲, 就是多个方法的调用链中, 如果涉及到事务的嵌套, spring应该如何处理.

这个概念也是由声明式事务的原理而引申出来的. 声明式事务的原理就是由动态代理在方法的前后加上开启事务和提交事务的逻辑.

假设存在以下的一种场景:

@Transational
public void A(){
    B();
    // do something
}
@Transational
public void B(){
    //dosomething
    int a = 1/0
}

上面两个方法都声明开启事务, 很明显B是会抛出异常的, B的事务会被回滚. 那么A会不会也被回滚呢. 这就需要用事务的传播机制来解决了.

spring的事务传播机制一共有7种:

1. propagation_require. 默认的传播类型. 表示当前方法需要再一个事务中执行, 如果没有开启事务, 则需要开启一个

2. propagation_support. 表示当前方法不需要事务, 但是如果事务存在,则在事务中执行

3. propagation_mandatory. 表示当前方法必须要在事务中执行, 如果不存在,则抛出异常.

4. propagation_requireNew. 表示当前方法需要在新的事务中执行.当前方法执行时, 如果已经存在一个事务, 则先挂起该事务

5. propagation_not_support. 表示当前方法不支持事务, 如果已经存在事务, 那就先挂起该事务.

6. propagation_never. 表示当前方法不应该在事务中执行, 如果存在事务, 则抛异常.

7. propagation_nested. 如果存在嵌套的事务, 那么各个方法在各自独立的方法里面提交和回滚.

分布式事务

DTP, XA和JTA

DTP模型

DTP(Distributed Transaction Processing)是x/open组织提出来的分布式事务的模型.

一个DTP模型至少包含以下三个元素:

1. AP, 应用程序,用于定义事务开始和结束的边界. 说人话就是我们开启事务的代码所以的应用.
2. RM, 资源管理器. 理论上一切支持持久化的数据库资源都可以是一个资源管理器.
3. TM, 事务管理器, 负责对事务进行协调,监控. 并负责事务的提交和回滚.

XA规范

XA是x/open提出来的分布式事务的规范, 它是跟语言无关的.

XA规范定义了RM和TM交互的接口. 例如TM可以通过以下接口对RM进行管理:

  1. xa_open和xa_close, 用于跟RM建立连接
  2. xa_star和xa_end, 开始和结束一个事务
  3. xa_prepare, xa_commit和xa_rollback, 用于预提交, 提交和回滚一个事务
  3. xa_recover 用于回滚一个预提交的事务
JTA规范

JTA规范是可以认为是XA规范java语言实现版的规范.

JTA定义了一系列分布式事务相关的接口:

1. javax.transaction.Status: 定义了事务的状态,例如prepare, commit rollback等等等
2. javax.transaction.Synchronization:同步
3. javax.transaction.Transaction:事务
4. javax.transaction.TransactionManager:事务管理器
5. javax.transaction.UserTransaction:用于声明一个分布式事务
6. javax.transaction.TransactionSynchronizationRegistry:事务同步注册
7. javax.transaction.xa.XAResource:定义RM提供给TM操作的接口
8. javax.transaction.xa.Xid:事务id

以上不同的接口由不同的角色(RM, RM等)来实现.

二阶段提交(2PC)

二阶段提交是最简单的分布式事务解决方案. 它把一个事务分成request commit和commit/rollback两个阶段组成.

第一阶段是请求阶段, 由协调者向所以的RM询问事务是否可以提交. 如果可以提交则回复YES,否则回复NO.

第二阶段是提交阶段, 协调者根据所有的RM的响应来决定该分布式事务是否可以提交. 如果所有的RM都回复了YES, 则可以提交,否则回滚该事务.

二阶段提交思想虽然简单, 但是它存在非常多的问题.

  1. 协调者单点问题
  2. 第一阶段的阻塞问题
  3. 第二阶段由于网络问题, RM没有收到commit/rollback指令而导致数据不一致的问题.

三阶段提交(3PC)

三阶段提交是为了解决二阶段算法存在的问题而提出来的.它把事务的提交分成3个阶段:

 1. cancommit阶段, 和2PC中的请求阶段类似
 2. precommit阶段. 如果cancommit阶段不是全部响应YES或者有RM超时, 那么回滚整个事务. 
 否则, 发送precommit指令, 让各个RM执行事务操作,执行完后响应ACK.
 3. docommit阶段.如果precommit阶段由RM没有响应ACK或者超时, 那么回滚整个事务.
 否则发送docommit指令, 让各个RM真正提交事务.

TCC

TCC是指try-comfirm-cancel.是这些年来大火的一种柔性分布式事务解决方案.

所谓"柔性", 是针对2PC和3PC等"刚性事务"而言的. 柔性事务不再一味追求强一致性, 只要求最终一致性.

TCC把一个分布式事务拆成以下三个步骤:

1. try阶段. 各个事务参与者检查业务一致性, 预留系统资源.例如锁定库存
2. comfirm阶段. 事务参与者使用try阶段预留的资源,执行业务操作.
3. cancel阶段. 如果try阶段任意一个事务参与者try失败, 则做cancel操作. cancel包括释放资源和反向补偿

其实仔细一看, T-C-C是刚好跟2PC中的request-commit-rollback一一一对应的.从这点上看, TCC本质上也是一个2PC思想的解决方案.

在TCC中还有两个概念, 主业务服务和从业务服务.

主业务服务可以通俗地理解成发起事务的那个服务.例如一个购买的服务, 它分别调用库存服务和订单服务. 那么购买服务就可以看做是主业务服务.

对应地, 上面所说的"库存服务"和"订单服务"就是从业务服务.

为什么要先区分这两种服务呢? 因为它们的职责是不一样的:

1. 从业务服务必须要提供try, comfirm, cancel方法.
2. 主业务服务需要记录事务日志, 并在事务管理器的协调下, 适当地调用从业务服务的tcc三个方法.

TCC的模型如下图所示:

image

图片来自www.tianshouzhi.com/api/tutoria…, 同时也极力推荐这个博客, 受益匪浅.

消息队列

利用消息队列来实现最终一致性是另外一种柔性分布式事务的思想. 它的主要思想通过消息队列异步完成一个分布式事务, 结合定时任务做重试和补偿, 必要的时候需要人工介入.

总结地来说, 一共有"尽最大努力通知", "本地消息表"和"MQ事务消息"三种思想.

尽最大努力通知

尽最大努力通知就是主动通知方会尽最大努力把处理结果通知到接收方, 如果通知失败,会做最多X次重试.如果最终还是失败, 主动方提供了查询的接口, 可以由接收方主动查询.

这种思想是最简单的, 其实应用的也是比较多的.典型的有:

1. 运营商短信发送状态回传
2. 微信和支付宝支付状态回传
本地消息表

顾名思义, 本地消息表就是利用一个本地数据库维护事务完成的中间状态. 在分布式事务执行的过程中,各方事务参与者完成操作后更新消息表的状态,逐步完成一个整体的事务.

对于异常的情况, 由定时调度定时检测消息表中未完成的事务, 发起重试. 定时调度的解决方案见java中执行定时任务的6种姿势

如果最终还是有一方未能完成事务操作,则由人工介入进行补偿.

image

如有上面一张图:

  1. 生产者先写本地消息表和业务数据, 用本地事务保证成功.再发送MQ消息.
  
  2. 消费者消费数据,同样是执行本地事务. 成功后更新本地消息表的状态. 失败怎么办呢? 可以发送消息给生产者进行回滚, 但是那样复杂度
  就高了(要求生产者也要实现TCC, 那就还不如用TCC了). 所以更现实的方案是重现+人工补偿
  
 3. 生产者可能会写业务数据成功, 但是发送MQ消息失败, 这个时候本地消息表还是会有对应未完成的事务, 那么定时任务会扫描出来, 重试.最终还是能完成整个分布事务.
 

当然, 上图也不是百分百完善的 但是本地消息表更多的只是一种思想, 具体实现可能会有所不同,也要结合具体的业务场景和业务要求来实现.

MQ事务消息

本地消息表的方案就在MQ普遍都还没有实现事务消息的时候提出的. 但是现在不管是kafka还是rocketMQ都开始支持事务消息了.

有了事务消息, 其实本地表和定时任务的工作就由MQ的事务机制来完成了.

例如www.tianshouzhi.com/api/tutoria… 里面介绍的方案.

分布式事务框架

在实际的应用中, 分布式事务出现的场景可以总结为两种. 还是以一个购买服务为例, 那么这两种分布式事务的场景可能是:

  1. 第一种, 同一个服务中对多个RM进行操作

image

  1. 第二种, 一个服务通过RPC调用多个服务, 间接操作了多个RM

image

在微服务化大行其道的今天,按业务分库应该是大多公司搭建架构的一个基本准则了. 所以这样来看, 貌似是第二种场景更符合实际了.

当然第一种场景肯定也还是有存在的. 例如上面"本地消息表"的解决方案中, 就有需要再同一个服务中跟多个RM交互.

分布式事务开源框架其实市面上也挺多的,例如tcc-transactio等等, 这里我们来看看atomikos和seata这两个

atomikos

atomikos是一个非常有名的分布式事务开源框架. 它有JTA/XA规范的实现, 也有TCC机制的实现方案, 前者是免费开源的, 后者是商业付费版的.

这里介绍一下JTA/XA规范的实现.

上面JTA规范那一小节说到JTA定义了一系列的接口,那些接口是由不同的角色去实现的. atomikos的角色是一个事务管理器, 它实现的接口主要有:

 1. javax.transaction.UserTransaction
   对应的实现是com.atomikos.icatch.jta.UserTransactionImp,用户只需要直接操作这个类就是实现一个JTA分布式事务

 2. javax.transaction.TransactionManager
   对应的实现是com.atomikos.icatch.jta.UserTransactionManager, atomikos使用这个实现类来对事务进行管理

 3. javax.transaction.Transaction
    对应的实现是com.atomikos.icatch.jta.TransactionImp

应用atomikos的简单实例(还是来自www.tianshouzhi.com/api/tutoria…):

  1. 引入依赖
<dependency>
   <groupId>com.atomikos</groupId>
   <artifactId>transactions-jdbc</artifactId>
   <version>4.0.6</version>
</dependency>
<dependency>
   <groupId>mysql</groupId>
   <artifactId>mysql-connector-java</artifactId>
   <version>5.1.39</version>
</dependency>
  1. demo实例
import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.jdbc.AtomikosDataSourceBean;

import javax.transaction.SystemException;
import javax.transaction.UserTransaction;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Properties;

public class AtomikosExample {

  private static AtomikosDataSourceBean createAtomikosDataSourceBean(String dbName) {
     // 连接池基本属性
     Properties p = new Properties();
     p.setProperty("url", "jdbc:mysql://localhost:3306/" + dbName);
     p.setProperty("user", "root");
     p.setProperty("password", "your password");

     // 使用AtomikosDataSourceBean封装com.mysql.jdbc.jdbc2.optional.MysqlXADataSource
     AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
     //atomikos要求为每个AtomikosDataSourceBean名称,为了方便记忆,这里设置为和dbName相同
     ds.setUniqueResourceName(dbName);
     ds.setXaDataSourceClassName("com.mysql.jdbc.jdbc2.optional.MysqlXADataSource");
     ds.setXaProperties(p);
     return ds;
  }

  public static void main(String[] args) {

     AtomikosDataSourceBean ds1 = createAtomikosDataSourceBean("db_user");
     AtomikosDataSourceBean ds2 = createAtomikosDataSourceBean("db_account");

     Connection conn1 = null;
     Connection conn2 = null;
     PreparedStatement ps1 = null;
     PreparedStatement ps2 = null;

     UserTransaction userTransaction = new UserTransactionImp();
     try {
        // 开启事务
        userTransaction.begin();

        // 执行db1上的sql
        conn1 = ds1.getConnection();
        ps1 = conn1.prepareStatement("INSERT into user(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS);
        ps1.setString(1, "tianshouzhi");
        ps1.executeUpdate();
        ResultSet generatedKeys = ps1.getGeneratedKeys();
        int userId = -1;
        while (generatedKeys.next()) {
           userId = generatedKeys.getInt(1);// 获得自动生成的userId
        }

        // 模拟异常 ,直接进入catch代码块,2个都不会提交
//        int i=1/0;

        // 执行db2上的sql
        conn2 = ds2.getConnection();
        ps2 = conn2.prepareStatement("INSERT into account(user_id,money) VALUES (?,?)");
        ps2.setInt(1, userId);
        ps2.setDouble(2, 10000000);
        ps2.executeUpdate();

        // 两阶段提交
        userTransaction.commit();
     } catch (Exception e) {
        try {
           e.printStackTrace();
           userTransaction.rollback();
        } catch (SystemException e1) {
           e1.printStackTrace();
        }
     } finally {
        try {
           ps1.close();
           ps2.close();
           conn1.close();
           conn2.close();
           ds1.close();
           ds2.close();
        } catch (Exception ignore) {
        }
     }
  }
}

很明显, 这个例子是属于场景1的分布式事务. 所以如果有场景1的分布式事务的话, 直接使用atomikos就可以了, 简单直接高效.

但是话又说回来了, 实际场景的分布式事务更多的还是属于场景2的. 很明显简单的JTA事务是处理不了场景2的分布式事务的.场景2下的分布式事务, 还得需要像TCC或消息队列柔性事务等解决方案去实现.

seata

seata就是Fescar(TXC/GTC/FESCAR)和tcc-transaction整合后开源的一个分布式事务落地解决方案框架,实现了AT, TCC, SAGA三种模式, 大有一统江湖的意思.

官网地址是seata.io/zh-cn/docs/…, 文档方面相对来说还不够完善, 但是作为了解还是足够了. 这里也是简单地介绍一下.

术语

TC - 事务协调者 维护全局和分支事务的状态,驱动全局事务提交或回滚。

TM - 事务管理器 定义全局事务的范围:开始全局事务、提交或回滚全局事务。

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

AT模式

AT 即Automatic Transaction, 所谓AUTO, 表示是这种模式是对业务无侵入的, 不需要业务改造.但是对业务有要求:

1. 基于支持本地 ACID 事务的关系型数据库。
2. Java 应用,通过 JDBC 访问数据库。

AT模式总体逻辑如下图:

image

AT模式采用的也是2PC的思想, 加入了补偿的机制, 补偿的机制跟innodb里面的undo日志类似.

undo日志其实就是一个反向补偿, 例如insert的语句, 事务回滚时,会执行一个对应的delete语句

用大白话翻译了一下模式(我的理解)就是:

1. 第1阶段, 先生成undo日志, undo日志和业务的操作在本地事务中一并提交
2. 第2阶段, 在TC的协调下, 如果可以提交则迅速提交. 需要回滚时根据回滚日志做反向补偿.

当然具体应用没有那么简单, 更多的参考官网

TCC模式

TCC模式就是上面介绍的TCC的思想, SEATA的tcc模式如下图:

image

TCC模式其实跟AT模式也是类似的, 也是一个2PC的演化版, 在事务协调器(TC)的协调下, 进行多个子事务的提交和回滚.

不同的是, AT模式回滚是在数据库资源层面的补偿(执行回滚日志), 而TCC是调用自定义的逻辑进行回滚(执行回滚代码逻辑).

SAGA模式

saga是一种长事务解决方案. 在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现

image

saga的思想虽然在1987年提出来了, 但是seata的saga模式是今年的8月份才正式支持的.我对它的理解也不够深入,所以也不在班门弄斧了.了解一下即可

总结

分布式系统从来就不是一个简单的概念, 分布式系统中的分布式事务更是如此.

也许分布式事务的思想算是比较简单, 但是实现起来的确有很多的细节和困难需要我们去注意和克服.因而大多数据公司企业都会有根据自己的业务实际去做不同的实践, 而不是完完全全地照搬思想.

这一点体现出来的另外一面就是, 现在市面上确实也没有一个完善的分布式解决方案, 能让我们照搬就可以了.阿里的seata开源也不久, 希望有一天, 它真的能一统江湖, 真正的可以一次性一站式地解决分布式事务的问题

引用

www.tianshouzhi.com/api/tutoria…

seata.io/zh-cn/docs/…