目录
前言
通过上一篇文章,我们知道了怎么获取数据库连接,怎么操作数据库。本篇文章的主要内容是事务,首先会讲一些概念性的东西,然后会讲 Spring 怎么使用事务,以及事务传播特性和隔离特性的案例分析。
什么是事务?
事务就是把一组操作当成一个操作来做,这一组操作要么全部成功,要么全部失败。从表现形式上看就像执行了一个操作一样,这就是事务的作用。
事务有哪几类?
- 编程式事务:什么是编程式事务呢?无非就是自己写代码实现事务的功能,比如你自己去拿数据库连接,想啥时候提交就啥时候提交,这样做的优点很明显,那就是控制的颗粒度很细。但是缺点也显而遇见,那就是太麻烦了,尤其是业务复杂的情况下要写一堆事务控制代码很痛苦。且事务代码和业务代码结合在一起,侵入性也很强。
- 声明式事务:声明式事务是无侵入的,所谓声明,就是你要指明哪个是事务,比如在方法上写了一个 @Transactional 注解,这就是一个声明,就代表要开启一个事务来处理这个方法。
事务的四大特性(ACID)
-
A 原子性:原子性指的是在一组业务操作下,要么都成功,要么都失败。也就是要么都成功提交,要么都回滚。
-
C 一致性:一致性是保证事务可以从一个有效的状态转移到另一个有效的状态,有效的状态只指任何数据库事务修改数据必须满足定义好的规则,包括数据完整性(约束)、级联回滚、触发器等。比如你设置了一个字段为varchar(10) 类型表示姓名的字段,那么执行事务之前,数据库中的字段长度都是小于等于10的,这就是一个有效的状态。那么你执行事务之后的状态也应该是保证数据库中的数据是一个有效的状态。比如不能插入一条长度为 20 的姓名。这就是从一个有效的状态转移到另一个有效的状态。
-
I 隔离性:在并发情况下,事务之间要相互隔离。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
-
D 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
隔离级别和不同级别可能会导致的问题
在并发情况下,不同事务对同一数据的操作可能会有造成一些问题,比如脏读,幻读和不可重复读。
- 脏读:当事务A对数据库中的数据修改之后到最终提交这个事件段内,事务B读取了刚刚事务 A 修改的数据去使用。但是事务A出于某种原因又执行了回滚操作,那么刚刚它修改的数据就又回到之前的状态。对于事务B来说,这就是脏读。可以通过设置 B 事务的隔离级别为 读已提交(READ_COMMITTED) 来避免脏读的情况,设置读已提交之后,就只会读取到别的事务提交之后的数据。
- 不可重复读:假如事务A需要多次读取某个字段的数据,但是在多次执行查询操作的时间间隔里,别的事务把这条数据给修改且提交了,就导致了同一个事务多次读取同一个数据的时候获取的结果却是不一样的,对于事务A来说,这就是不可重复读。可以通过事务A的事务隔离级别为 可重复读 (REPEATABLE_READ)来避免不可重复读的情况。原理就是加了行锁,即当前事务对该数据操作的时候,其他事务需要等待。
- 幻读:幻读跟不可重复读有类似的地方,都是指同一个事务里多次查询数据,但是出现不一致的情况。但是幻读是从整个数据表的维度来说的。有的时候需要对整张表的数据,比如说求和,求平均值等。所以这个时候对某一条数据加锁是没用的。其他的数据被修改了,对最终的结果也是有影响的。就会导致多次取到的数据不一致。如果想避免幻读,需要讲事务的隔离级别设置为 串行化(SERIALIZABLE),这样就会对整张表加锁。当前事务未结束的情况下,别的事务是没法对其进行修改操作的。显然,这样会影响效率。
注意:上面虽然说这是三个问题,但是具体是不是问题,还是要由你的业务所决定的。比如你就是需要读别的事务还没有提交的数据,那么脏读对你来说就不是问题,恰恰相反,如果读不到这个被修改的数据你的程序才是有 bug 的。所以说上面几种情况是不是问题得结合业务来定。并且需要根据业务来设置不同的事务隔离级别。
不同事务隔离级别可能会出现的问题如下所示
从线程安全的角度来说,这四个级别越往下越安全,但于此同时,效率也越来越低。还是那句话,根据实际需求来设置隔离级别,并不是越安全越好,或者越快越好。Mysql 默认的隔离级别是可重复读,但是也可以根据自己业务的不同进行更改。
Spring 事务的传播特性
Spring 事务的传播特性是为了应对多个事务在顺序执行的时候可能会遇到的一些问题,或者说通过一些传播特性更好的协调事务间的合作。比如当事务A先执行,又调用到事务B的方法,事务 B 的方法可以选择跟事务A融合成一个事务共进退,也可以自己开启一个新的事务自立门户,当然也可以直接报错,不让别的事务调用本身。
事务传播行为类型 | 外部不存在事务 | 外部存在事务 |
REQUIRED(默认) | 开启新的事务 | 融合到外部事务中 |
SUPPORT | 不开启新的事务 | 融合到外部事务中 |
REQUIRES_NEW | 开启新的事务 | 不融合在外部事务,创建自己的事务 |
NOT_SUPPORTED | 不开启新的事务 | 不用外部事务 |
NEVER | 不开启新的事务 | 抛异常 |
MANDATORY | 抛异常 | 融合到外部事务中 |
NESTED | 开启新的事务 | 融合到外部事务中,外层回滚会影响内层事务,但是内部事务回滚不会影响外部 |
Spring 事务如何使用?
- 首先引入需要的一些依赖
<dependencies>
<!-- https://mvnrepository.com/artifact/org.springframework/spring-core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.2.9.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework/spring-context -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.9.RELEASE</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>5.2.9.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.21</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.9.RELEASE</version>
</dependency>
</dependencies>
- 添加配置类
//这个注解表示开启事务
@EnableTransactionManagement
@ComponentScan("com.transaction.*")
public class TransactionConfig {
// 注册数据源,前篇文章已经讲过
@Bean
public DataSource dataSource() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUsername("root");
dataSource.setPassword("123456");
dataSource.setUrl("jdbc:mysql://localhost:3306/transaction?useSSL=false");
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setMinIdle(5);
dataSource.setMaxActive(20);
dataSource.setInitialSize(10);
return dataSource;
}
// 注册 JdbcTemplate 实例,用于操作数据库,前面文章也已经讲过
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
// 事务管理器,用于管理 Spring 事务,这是下一篇讲源码时的重点
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
- 接口和实现类
// 服务层接口
public interface DepositService {
/**
* Description: 存款
* Author: cison
* Date: 2021/9/12 11:06 下午
*
* @param userId
* @param money
* @return void
*/
public void deposit(Long userId, Integer money) throws Exception;
}
//服务层实现类
@Service
public class DepositServiceImpl implements DepositService {
@Autowired
private AccountDao accountDao;
@Override
@Transactional
public void deposit(Long userId, Integer money) {
try {
accountDao.updateAccountInfos(userId, money);
} catch (Exception e) {
System.out.println("第一个事务出错回滚");
}
//执行第二次更新方法,可以用来测试事务传播的一些特性
try {
accountDao.updateAccountInfos2(userId, money);
} catch (Exception e) {
System.out.println("第二个事务出错回滚");
}
//模拟事务出错
//int a = 1 / 0;
}
}
- 数据操作层
@Repository
public class AccountDao {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional
public boolean updateAccountInfos(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
@Transactional
public boolean updateAccountInfos2(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
}
- 测试类
public class TransactionMain { public static void main(String[] args) { AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(TransactionConfig.class); DepositService depositService = (DepositService) applicationContext.getBeanFactory().getBean("depositServiceImpl"); depositService.deposit(1L, 1); } }
可以看到有三个事务方法,一个是服务层的 deposit,另外两个是数据访问层的操作数据库的方法,因为 @Transactionl 什么属性都没有配置,那么采用的事务传播就是 Required ,那么这三个事务其实就会融合成为一个事务,任何一个事务出错,事务就会回滚,那么执行的这两个sql语句就都不会生效。大家可以自己去模拟测试一下,我就不再这里贴图举例了。
大家可以给数据访问层的两个事务方法都配置上传播特性自己玩一下看看,比如可以分别设置为 propagation = Propagation.REQUIRED 和 propagation = Propagation.REQUIRES_NEW,如下所示
@Transactional(propagation = Propagation.REQUIRED)
public boolean updateAccountInfos(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean updateAccountInfos2(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
当你再执行的时候,你会发现,mysql 死锁了。程序直接卡死了。我们分析一下为什么呢?因为第一个事务方法配置的是 REQUIRED, 也就是默认的传播策略,此时他会跟外层事务融合在一起,我们认为此时只有一个事务,并且因为他在操作这一条数据,而默认事务隔离策略会加上行锁。而第二个事务方法标记 REQUIRES_NEW 就代表要开启一个新的事务,那么当他再访问这一行数据进行的时候,是必须要等待的,而第一个事务也要等待第二个事务结束之后才回提交,那么就会导致两边都在等等等,就死锁了。如果第一个方法更新id = 1那一行的数据,第二个方法更新id=2的数据,就不会有这个问题了。
此外,还可以通过配置这两个方法的传播行为为 REQUIRED 和 NESTED 来验证内部事务的回滚不会影响外部的事务,而外部事务的回滚则会影响内部事务。如下
@Transactional(propagation = Propagation.REQUIRED)
public boolean updateAccountInfos(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
@Transactional(propagation = Propagation.NESTED)
public boolean updateAccountInfos2(Long userId, Integer money) {
String sql = "update AccountInfo set balance = balance + " + money + " where id = " + userId;
jdbcTemplate.update(sql);
// 模拟事务异常
//int a = 1 / 0;
return true;
}
如果是这样测试的话,如果只有 updateAccountInfos2 回滚,那么第一个方法更新数据库的语句就会生效,即内部事务回滚不会影响外部事务。但是如果deposit方法或者updateAccountInfos方法出错回滚了,那么这两条更新语句就一条也不会生效了,这也就是外部事务会影响内部事务。
此外还有很多事务传播的特性和事务隔离特性可以玩,大家有兴趣可以自己搞一下。