【重写SpringFramework】事务概述(chapter 4-2)

149 阅读11分钟

1. 前言

我们经常需要在某个业务逻辑中执行多个 SQL 操作,并且这些操作拥有一定的逻辑关系,我们希望它们同时成功或者同时失败。比如在注册用户时,需要向用户表 t_user 和账户表 t_account 各插入一条数据。任何一个操作的失败都应回滚所有的操作,避免出现一张表有数据,另一张表没有数据的情况。为此,我们可以使用数据库事务来确保多个 SQL 操作同步执行。

2. JDBC 事务

JDBC 规范提供了事务的相关实现,具体来说,Connection 接口定义了与事务有关的方法,简单介绍如下:

  • setAutoCommit 方法:关闭自动提交,也可以看作是开启事务的标记。如果不关闭自动提交,每执行一个 SQL 语句就会提交一次,也就无所谓事务了。
  • commit 方法:提交事务,统一执行多个 SQL 操作。
  • rollback 方法:回滚事务,将多个 SQL 操作还原到初始状态。
public interface Connection {
    void setAutoCommit(boolean autoCommit) throws SQLException;
    void commit() throws SQLException;
    void rollback() throws SQLException;
}

JDBC 规范通过数据库连接实现了事务的相关方法,以下是一个典型的事务操作。在示例代码中,首先关闭自动提交,然后在 try...catch 块中执行业务方法。如果出现异常,执行回滚逻辑,并抛出异常;否则提交事务,确保 SQL 语句正常执行。

//示例代码,模拟事务操作
public void register(Connection conn) {
    conn.setAutoCommit(false);

    try {
        this.userDao.createUser(conn);        	//保存用户信息
        this.accountDao.createAccount(conn);     //保存账户信息
    } catch(Exception e) {
        conn.rollback();
        throw e;
    }
    conn.commit();
}

对于单个方法来说,事务操作是通过 Connection 对象完成的。那么扩展到多个方法,如果要确保它们属于同一个事务,前提是所有方法拿到的是同一个 Connection 对象。

此外,在多线程环境下,同一时刻可能有多个事务执行,事务之间不能互相影响,也就是说要确保每个事务使用不同的 Connection。总而言之,事务需要对内统一,对外有别。为了兼顾内外,我们可以使用 Java 提供的线程封闭技术。

3. 事务资源

3.1 概述

ThreadLocal 是指将任一对象作为当前线程的本地变量临时存储,在该线程执行的任何位置都可以获取指定的对象。如果一个事务中有多个方法,为了使事务生效,需要保证所有方法使用同一个数据库连接。一般来说,一个事务内的方法都是在同一个线程中执行,因此我们可以使用线程封闭的技术,将 Connection 绑定到 ThreadLocal 上。

2.1 线程绑定的Connection.png

如图所示,同一个事务的多个方法运行在一个线程中,因此 ThreadLocal 变量中保存的 Connection 是同一个,各个业务方法拿到的相同的数据库连接,从而确保事务可以正常运行。

3.2 TransactionSynchronizationManager

TransactionSynchronizationManager 是一个工具类,作用对线程绑定的事务资源进行统一的管理。狭义的事务资源仅指 resources 字段,广义的资源则包括其他字段。简单介绍如下:

  • resources:事务资源集合,以数据库连接为例,key 是 DataSource,对应的资源是 ConnectionHolder
  • synchronizations:事务同步集合
  • currentTransactionName:当前事务的名称
  • currentTransactionReadOnly:是否为只读事务
  • currentTransactionIsolationLevel:当前事务的隔离级别
  • actualTransactionActive:当前事务是否已启用
public class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("事务资源");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("事务同步集合");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("事务名称");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal<>("readOnly属性");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal<>("隔离级别");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal<>("事务活跃标记");
}

TransactionSynchronizationManager 是如何管理资源的,这里以 bindResource 方法进行说明。对于数据库连接来说,key 的类型是 DataSourcevalue 的类型是 Connection。首先创建一个 Map 并设置为当前线程的本地变量,然后将 Connection 添加到 Map 中。这样一来,数据库连接与线程绑定到了一起,因此只要在同一个线程内,得到的总是相同的 Connection

public static void bindResource(Object key, Object value){
    Map<Object, Object> map = resources.get();
    if (map == null) {
        map = new HashMap<>();
        //将资源集合绑定到线程上
        resources.set(map);
    }

    //将资源添加到资源集合缓存中
    Object oldValue = map.put(key, value);

    if (oldValue instanceof ResourceHolder && ((ResourceHolder) oldValue).isVoid()) {
        oldValue = null;
    }
}

3.3 ResourceHolder

TransactionSynchronizationManagerresources 字段存储的不仅仅是数据库连接,还可以是其他资源。Spring 使用 ResourceHolder 接口来描述事务相关资源,类图中列举了三种常见的资源,目前只关心 ConnectionHolder,其余资源仅了解即可。

  • ConnectionHolder:持有一个 JDBC 连接对象
  • SqlSessionHolder:mybatis 框架使用的资源
  • RedisConnectionHolder:持有一个 Redis 连接对象,缓存与数据库事务关系密切

2.2 ResourceHolder类图.png

ResourceHolder 接口可以看做是一个标记接口,ResourceHolderSupport 作为抽象子类,表示资源的持有者。

  • rollbackOnly 字段表示是否回滚,SmartTransactionObject 接口的 isRollbackOnly 方法实际上检查的是该字段。
  • deadline 字段表示事务超时的最后期限,可以用于保存事务注解的 timeout 属性。
public abstract class ResourceHolderSupport implements ResourceHolder{
    private boolean rollbackOnly = false;
    private Date deadline;
}

ConnectionHolder 持有一个数据库连接,上节已介绍。对于事务来说,是非常重要的资源,因此继承了 ResourceHolderSupport

public class ConnectionHolder extends ResourceHolderSupport {
    private Connection connection;
}

3.4 TransactionSynchronization

在事务执行的过程中,需要经历若干阶段,比较重要的节点包括开启事务、提交、回滚,以及最后的清理工作。一般来说,事务不是孤立运行的,如果需要与其他框架打交道,我们需要一种类似事件或生命周期的机制,在适当的时候发出通知。TransactionSynchronization 接口在事务执行的特定阶段进行回调,其作用类似 BeanPostProcessor 组件。(事务同步仅了解,我们不进行详细展开)

  • suspend:在挂起事务时触发,需要解除绑定的资源
  • resume:在恢复事务时触发,将资源绑定到线程上
  • beforeCommit:在事务提交前触发(beforeCompletion 前)
  • beforeCompletion:在事务提交或回滚前触发(beforeCommit 后),主要用于清理资源
  • afterCommit:在事务提交后触发
  • afterCompletion:在事务提交或回滚后触发
public interface TransactionSynchronization extends Flushable {
    void suspend();
    void resume();
    void beforeCommit(boolean readOnly);
    void beforeCompletion();
    void afterCommit();
    void afterCompletion(int status);
}

4. 共享的数据库连接

4.1 获取连接

为了确保 SQL 的操作与事务操作使用同一个 Connection,DataSourceUtils 工具类的获取和释放数据连接的方法也要进行调整。具体来说,getConnection 方法优先使用绑定在 ThreadLocal 上的连接,如果 ConnectionHolder 不为空,说明事务存在。否则,从数据源中获取新连接,并在之后(开启事务时)绑定到当前线程上。

public static Connection getConnection(DataSource dataSource) throws SQLException {
    //尝试获取线程绑定的连接
    ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
    if(conHolder != null && conHolder.getConnection() != null){
        return conHolder.getConnection();
    }

    //从数据源中获取连接
    return dataSource.getConnection();
}

4.2 释放连接

releaseConnection 方法的特点是在真正关闭 Connection 之前,尝试获取线程绑定的连接。如果线程绑定的连接存在,且与传入的连接是同一个对象,说明事务尚未关闭,不应当释放连接。举个例子,一个事务中存在多个事务方法,内层方法执行完毕并不会真正释放连接,只有当最外层方法执行完毕,即整个事务结束时才会释放连接。

public static void releaseConnection(Connection con, DataSource dataSource) {
    if (con == null) {
        return;
    }

    if (dataSource != null) {
        ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
        if(conHolder != null){
            Connection connection = conHolder.getConnection();
            if(connection != null && (connection == con || connection.equals(con))){
                //待释放的连接与线程绑定的连接是同一个,说明事务尚未关闭,此时不应当释放连接
                return;
            }
        }
    }

    con.close();    //关闭数据库连接
}

4.3 演示说明

我们发现,数据库连接在 DataSource、TransactionSynchronizationManager 以及 SQL 操作中流转。下图展示了详细的流转过程,可以分为四个阶段:

  • 第一步,需要获取 Connection 对象,从数据源中拿到一个新的数据库连接。
  • 第二步,将数据库连接绑定到当前线程上。
  • 第三步,若干 SQL 操作共享同一个数据库连接,这是实现事务操作的前提。
  • 第四步,解除数据库连接与线程的绑定,并将 Connection 返还给 DataSource 数据源。

2.3 共享的数据库连接示意图.png

除了直观的图示说明,相应地,也可以在代码上体现出来。大致步骤也分为四步:

  1. 获取数据库连接
  2. Connection 绑定到当前线程上
  3. JDBC 事务的基本流程
  4. 最后需要释放数据库连接,解除与线程的绑定
//示例代码
public void foo() {
    //1.获取数据库连接
    Connection connection = DataSourceUtils.getConnection(this.dataSource);
    //2.将数据库连接绑定到线程上
    TransactionSynchronizationManager.bindResource(this.dataSource, connection);

    //3.JDBC事务操作
    connection.setAutoCommit(false);
    try{
        ...;
    } catch(Exception e){
        connecton.rollback();
    }
    connection.commit();

    //4.释放数据库连接
    TransactionSynchronizationManager.unbindResource(dataSource);
}

5. 测试

5.1 准备工作

测试代码模拟这样一种场景,即当用户注册时,除了添加一条用户信息,还要添加一条账户信息。因此首先需要创建一张账户表,建表语句如下:

CREATE TABLE `t_account`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `phone` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `balance` decimal(10, 2) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `idx_unique_phone`(`phone`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compat;

t_account 表使用 phone 作为唯一索引,由于 t_user 表里已存在两条数据,因此向 t_account 表中手动添加数据如下:

INSERT INTO t_account (phone, balance) VALUES ('12305', 0);
INSERT INTO t_account (phone, balance) VALUES ('12306', 0);

Account 类表示账户信息,phone 为用户手机号,balance 表示账户余额。

//测试类
public class Account {
    private int id;
    private String phone;
    private BigDecimal balance;
}

AccountDao 定义了 save 方法用于新增一条账户记录。

//测试类
@Repository
public class AccountDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    public void save(String phone){
        String sql = "INSERT INTO t_account (phone, balance) VALUES (?, ?)";
        jdbcTemplate.update(sql, phone, 0);
    }
}

最后是 UserService 类,定义了业务方法 register,模拟注册操作,包括添加一条用户信息和账户信息。

//测试类
@Service
public class UserService {
    @Autowired
    private UserDao userDao;
    @Autowired
    private AccountDao accountDao;

    public void register(String name, String phone){
        this.userDao.save(name, phone);
        this.accountDao.save(phone);
    }
}

5.2 测试方法

测试方法分为四步,第一步,准备工作,创建 Spring 容器。

第二步,开启事务的操作,又可以分为三步:

  • 调用 DataSourceUtils 工具类的 getConnection 方法获取数据库连接,首次操作 TransactionSynchronizationManager 并没有绑定数据库连接,将会从数据源中获取一个新连接。
  • 拿到 Connection 对象之后,调用其 setAutoCommit 方法关闭自动提交
  • 将数据库连接包装成 ConnectionHolder,并绑定到当前线程上

第三步是业务逻辑,获取 UserService 实例并调用 register 方法,如果报错执行回滚操作,否则执行提交操作。

第四步,无论执行成功还是失败,都要释放资源。

//测试方法
@Test
public void testJdbcTransaction() throws Exception {
    //1. 准备工作
    AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DataSourceConfig.class);
    DataSource dataSource = context.getBean(DataSource.class);

    try{
        //2. 获取Connection,并绑定到当前线程上
        Connection connection = DataSourceUtils.getConnection(dataSource);
        connection.setAutoCommit(false);
        TransactionSynchronizationManager.bindResource(dataSource, new ConnectionHolder(connection));

        //3. 业务方法
        UserService userService = context.getBean(UserService.class);
        try {
            userService.register("Stimd", "12307");
        }catch (Exception e) {
            connection.rollback();
            throw e;
        }
        connection.commit();
    }finally {
        //4. 释放资源
        TransactionSynchronizationManager.unbindResource(dataSource);
    }
}

为了模拟事务回滚的效果,我们事先在 t_account 表中新增一条记录,特意将 phone 字段设置成将要插入的手机号。由于 phone 字段设置了唯一索引,可以预见插入账户表的操作会失败。

INSERT INTO t_account (phone, balance) VALUES ('12307', 0);

从测试结果可以看到,register 方法在执行的过程中报错,控制台打印的异常信息可知,错误原因是 t_account 表中的唯一索引重复。然后观察 t_user 表,没有出现新的记录,说明事务起了作用。

java.lang.RuntimeException: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '12307' for key 't_account.idx_unique_phone'

6. 总结

本节介绍了传统 JDBC 事务的原理和实现方式。事务操作是在 Connection 对象上进行的。首先需要关闭自动提交,然后执行业务代码。如果抛出异常,执行回滚操作,否则执行提交操作。

对于多个业务方法,如果想让它们处于同一个事务,那么先要确保它们拿到的 Connection 是相同的。为此,TransactionSynchronizationManager 工具类负责将 Connection 绑定到线程上。一般来说,一个事务是在单个线程内执行的,这样就确保了一个 Connection 可以被多个业务方法共享。以 JDBC 事务为基础,下一节探讨 Spring 是如何对事务进行深度改造的。

7. 项目信息

新增修改一览,新增(9),修改(2)。

tx
└─ src
   ├─ main
   │  └─ java
   │     └─ cn.stimd.spring
   │        ├─ jdbc
   │        │  └─ datasource
   │        │     ├─ ConnectionHolder.java (*)
   │        │     └─ DataSourceUtils.java (*)
   │        └─ transaction
   │           ├─ support
   │           │   ├─ ResourceHolder.java (+)
   │           │   ├─ ResourceHolderSupport.java (+)
   │           │   ├─ TransactionSynchronization.java (+)
   │           │   └─ TransactionSynchronizationManager.java (+)
   │           └─ TransactionException.java (+)
   └─ test
      └─ java
         └─ tx
            ├─ common
            │  ├─ Account.java (+)
            │  ├─ AccountDao.java (+)
            │  └─ UserService.java (+)
            └─ transaction
               └─TransactionTest.java (+)

注:+号表示新增、*表示修改

注:项目的 master 分支会跟随教程的进度不断更新,如果想查看某一节的代码,请选择对应小节的分支代码。


欢迎关注公众号【Java编程探微】,加群一起讨论。

原创不易,觉得内容不错请关注、点赞、收藏。