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 上。
如图所示,同一个事务的多个方法运行在一个线程中,因此 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 的类型是 DataSource,value 的类型是 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
TransactionSynchronizationManager 的 resources 字段存储的不仅仅是数据库连接,还可以是其他资源。Spring 使用 ResourceHolder 接口来描述事务相关资源,类图中列举了三种常见的资源,目前只关心 ConnectionHolder,其余资源仅了解即可。
ConnectionHolder:持有一个 JDBC 连接对象SqlSessionHolder:mybatis 框架使用的资源RedisConnectionHolder:持有一个 Redis 连接对象,缓存与数据库事务关系密切
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 数据源。
除了直观的图示说明,相应地,也可以在代码上体现出来。大致步骤也分为四步:
- 获取数据库连接
- 将
Connection绑定到当前线程上 - JDBC 事务的基本流程
- 最后需要释放数据库连接,解除与线程的绑定
//示例代码
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编程探微】,加群一起讨论。
原创不易,觉得内容不错请关注、点赞、收藏。