在软件开发中,事务确保一组操作要么全部成功,要么全部失败,这对于数据库操作尤为重要,因为任何单一操作的失败都可能导致数据不一致。Spring 事务管理通过 @Transactional
注解实现,能够轻松地在数据层和业务层维护数据的一致性和完整性。在操作失败时,系统会自动回滚,简化了复杂业务逻辑的处理。接下来,通过一个案例来说明事务的作用。
银行转账案例
在银行转账案例中,我们需要实现任意两个账户之间的转账操作。以账户 Alice 向账户 Bob 转账为例,基于 Spring 和 MyBatis 的整合环境,我们将数据层提供的基础操作用于处理账户余额:减钱(outMoney)和加钱(inMoney)。业务层的 transfer 方法将调用这两个操作,接受两个账号和转账金额作为参数,从而完成转账的模拟。
(1)Spring 整合 MyBatis 项目创建
要搭建基于 Spring 整合 MyBatis 环境,可参考《Spring 整合 MyBatis》,也可以直接使用以下代码。
- 项目目录
- com.it.config.JdbcConfig
package com.it.config;
import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
public class JdbcConfig {
@Value("${jdbc.driver}")
private String driver;
@Value("${jdbc.url}")
private String url;
@Value("${jdbc.username}")
private String userName;
@Value("${jdbc.password}")
private String password;
// 1. 定义一个方法获取要管理的对象
// 2. 添加 @Bean,表示当前方法的返回值是一个 Bean
@Bean("dataSource")
public DataSource dataSource () {
DruidDataSource ds = new DruidDataSource();
ds.setDriverClassName(driver);
ds.setUrl(url);
ds.setUsername(userName);
ds.setPassword(password);
return ds;
}
}
- com.it.config.MybatisConfig
package com.it.config;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
public class MybatisConfig {
@Bean
public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) {
SqlSessionFactoryBean ssfb = new SqlSessionFactoryBean();
ssfb.setDataSource(dataSource);
return ssfb;
}
@Bean
public MapperScannerConfigurer mapperScannerConfigurer() {
MapperScannerConfigurer msc = new MapperScannerConfigurer();
msc.setBasePackage("com.it.dao");
return msc;
}
}
- com.it.config.SpringConfig
package com.it.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.PropertySource;
@Configuration
@ComponentScan("com.it")
@PropertySource("classpath:jdbc.properties")
@Import({JdbcConfig.class, MybatisConfig.class})
public class SpringConfig {
}
- com.it.dao.AccountDao
package com.it.dao;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface AccountDao {
@Update("update accounts set money = money + #{money} where name = #{name}")
void inMoney(@Param("name") String name, @Param("money") Double Money);
@Update("update accounts set money = money - #{money} where name = #{name}")
void outMoney(@Param("name") String name, @Param("money") Double Money);
}
- com.it.domain.Account
package com.it.domain;
public class Account {
private Integer id;
private String name;
private Double money;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Double getMoney() {
return money;
}
public void setMoney(Double money) {
this.money = money;
}
@Override
public String toString() {
return "Account{" +
"id=" + id +
", name='" + name + '\'' +
", money=" + money +
'}';
}
}
- com.it.service.impl.AccountServiceImpl
package com.it.service.impl;
import com.it.dao.AccountDao;
import com.it.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountDao accountDao;
public void transfer(String out, String in, Double money) {
accountDao.outMoney(out, money);
accountDao.inMoney(in, money);
}
}
- com.it.service.AccountService
package com.it.service;
public interface AccountService {
/**
*
* @param out 转出方
* @param in 转入方
* @param money 金额
*/
public void transfer(String out, String in, Double money);
}
- resources/jdbc.properties
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql:///db?useSSL=false
jdbc.username=root
jdbc.password=123456
- test/java/com.it.service.AccountServiceTest
package com.it.service;
import com.it.config.SpringConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.io.IOException;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SpringConfig.class)
public class AccountServiceTest {
@Autowired
private AccountService accountService;
@Test
public void testTransfer() throws IOException {
accountService.transfer("Alice", "Bob", 100D);
}
}
- pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.it</groupId>
<artifactId>spring_tx</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- Spring 依赖 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.10.RELEASE</version>
</dependency>
<!-- MyBatis 依赖 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.5</version>
</dependency>
<!-- MySQL 依赖 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
</dependency>
<!-- Spring JDBC 依赖 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.10.RELEASE</version>
</dependency>
<!-- Spring 整合 MyBatis 依赖 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>1.3.0</version>
</dependency>
<!-- Druid 连接池依赖 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
<!-- junit 依赖 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<!-- Spring Test 依赖 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.2.10.RELEASE</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
(2)创建数据表
使用 MySQL 数据库,创建名为 db
的数据库。在 db
中创建 accounts
表,向表中添加 Alice
和 Bob
两个模拟账户,每个账户初始金额设置为 1000
:
create table accounts
(
id int auto_increment
primary key,
name varchar(100) not null,
money double default 0 null
);
INSERT INTO db.accounts (id, name, money) VALUES (1, 'Alice', 1000);
INSERT INTO db.accounts (id, name, money) VALUES (2, 'Bob', 1000);
(3)创建账户间的转账事务
下面这段代码实现了 Alice 向 Bob 转账 100 的操作,并且插入了 1/0
异常代码。在运行 AccountServiceTest 类中的 testTransfer 方法时,如果不存在 1/0
异常代码,则代码正常执行,Alice 的账户减少 100,Bob 的账户增加 100。然而,由于插入了 1/0
异常代码,导致整体业务失败。这个过程中,尽管异常前的操作成功,异常后的操作却失败,这意味着转账业务无法完成。
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountDao accountDao;
public void transfer(String out, String in, Double money) {
accountDao.outMoney(out, money);
int i = 1/0; // 异常处
accountDao.inMoney(in, money);
}
}
为了解决上述问题,我们需要在业务层实现事务的完整性,这要求开启 Spring 的事务管理。首先,在需要开启事务的方法上添加 @Transactional
注解,通常建议将其添加在接口上,而不是具体的实现类上:
public interface AccountService {
/**
*
* @param out 转出方
* @param in 转入方
* @param money 金额
*/
@Transactional
public void transfer(String out, String in, Double money);
}
接着,在 JdbcConfig 类中配置事务管理器:
@Bean
public PlatformTransactionManager transactionManager (DataSource dataSource) {
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
transactionManager.setDataSource(dataSource);
return transactionManager;
}
最后,在 SpringConfig 中告知 Spring 使用事务驱动:
@Configuration
@ComponentScan("com.it")
@PropertySource("classpath:jdbc.properties")
@Import({JdbcConfig.class, MybatisConfig.class})
@EnableTransactionManagement // 添加这一行注解
public class SpringConfig {
}
在完成事务开启后,AccountServiceImpl 类的 transfer 方法中的两个数据操作要么全部成功,要么全部失败。如果在两者之间发生异常,Spring 将会回滚事务,确保两个操作都失败,而不会出现一个成功、一个失败的情况。
(4)事务管理步骤小结
- 在业务层接口上添加 Spring 事务管理(
@Transactional
注解) - 设置事务管理器(PlatformTransactionManager)
- 开启注解式事务驱动(
@EnableTransactionManagement
注解)
Spring 注解式事务通常添加在业务层接口中而不会添加到业务层实现类中,降低耦合。另外,注解式事务可以添加到业务方法上表示当前方法开启事务,也可以添加到接口上表示当前接口所有方法开启事务。
事务角色
在银行转账案例中,业务层的 transfer 方法调用了数据层的两个操作:outMoney 和 inMoney。这两个操作独立执行,通常需要开启两个不同的事务,记为 T1 和 T2,因此它们之间的回滚互不影响。这就导致了在操作异常时,无法实现要么全部成功,要么全部失败的效果。
Spring 事务通过 @Transactional 注解来解决这个问题。当业务层方法开启事务 T 时,数据层的 T1 和 T2 被纳入事务 T 的管理范围。这样,代码中实际上只有一个完整的事务 T,从而确保两个数据层操作的同成功和同失败。在这个过程中,有两个事务角色:
- 事务管理员:发起事务的业务层方法,通常指代开启事务的地方
- 事务协调员:参与事务的数据层方法,负责执行具体操作
事务属性
Spring 事务的属性如下所示。其中,两个较为重要且难以理解的属性是 rollbackFor 和 propagation。
(1)rollbackFor 属性
rollbackFor 属性的作用是设置事务的回滚异常。在 Spring 的事务管理中,并不是所有异常的出现后,Spring 事务都会发生回滚。有些异常不会,比如 IOException 异常:
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountDao accountDao;
public void transfer(String out, String in, Double money) throws IOException{
accountDao.outMoney(out, money);
// int i = 1/0;
if (true) {throw new IOException();}
accountDao.inMoney(in, money);
}
}
因此,需要为这类事务单独设置异常回滚,在 rollbackFor 属性中指定 IOException :
public interface AccountService {
/**
*
* @param out 转出方
* @param in 转入方
* @param money 金额
*/
@Transactional(rollbackFor = {IOException.class})
public void transfer(String out, String in, Double money) throws IOException;
}
(2)propagation 属性
为了说明 propagation 属性的作用,在转账业务代码基础上添加日志记录功能。具体而言,无论转账是否成功,每次转账操作都应在数据库中留痕。为此,首先在数据库中添加 logs 表:
create table logs
(
id int auto_increment
primary key,
message varchar(512) not null,
create_date date null
);
接着,实现 LogDao 接口,提供向 logs 表添加记录的功能:
package com.it.dao;
import org.apache.ibatis.annotations.Insert;
public interface LogDao {
@Insert("insert into logs (message,create_date) values(#{message},now())")
void log(String message);
}
之后,实现日志模块的业务层:
package com.it.service;
import org.springframework.transaction.annotation.Transactional;
public interface LogService {
@Transactional
void log(String out, String in, Double money);
}
package com.it.service.impl;
import com.it.dao.LogDao;
import com.it.service.LogService;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class LogServiceImpl implements LogService {
@Autowired
private LogDao logDao;
@Override
public void log(String out, String in, Double money) {
logDao.log("From " + out + " to " + in + ", money: " + "money");
}
}
最后,在 AccountServiceImpl 的 transfer 方法中添加日志模块,将添加日志的操作放在 finally 代码块中,原因是希望无论 try 代码块中的代码是否正常执行,最终都添加日志记录。
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountDao accountDao;
@Autowired
private LogService logService;
public void transfer(String out, String in, Double money){
try {
accountDao.outMoney(out, money);
accountDao.inMoney(in, money);
} finally {
logService.log(out, in, money);
}
}
}
运行代码后,执行正常。现在在 try 代码块中添加异常,并再次执行:
try {
accountDao.outMoney(out, money);
int i = 1/0;
accountDao.inMoney(in, money);
} finally {
logService.log(out, in, money);
}
此时,accounts
表中的数据未发生变化,且 logs 表中也没有任何日志记录。这表明日志记录与转账操作属于同一个事务(如上图所示),结果要么成功,要么失败。显然,这不是我们预期的结果。我们希望无论转账操作是否失败,都能将日志记录到数据库中。为此,需要引入事务传播行为的概念:
事务传播行为:事务协调员对事务管理员所携带事务的处理态度。
分析可知,上述现象是由于日志模块的事务协调员选择加入转账操作的事务。为了解决这个问题,可以设置日志模块单独开启一个新事务,而不加入转账事务。这一需求可以通过配置 propagation 属性来实现:
public interface LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
void log(String out, String in, Double money);
}
代码中的 Propagation.REQUIRES_NEW
指定了需要一个新事务,从而满足了日志记录的需求。以下是事务传播行为及 propagation 属性的所有情况和取值: