深入解析 Spring 事务与 MySQL 事务
昨天在进行组内CR时,针对同事写的关于数据库事务部分展开了充分友好交流。于是有感而发,经过查阅资料,写下这篇文章。
关于事务
事务这个问题老生常谈了,相信大家都能知道并能背出一大段相关定义:事务是逻辑上的一组操作,要么都执行,要么都不执行。
其实就是原子性操作的意思。
关于原子性 不仅在数据库中有提到比如我们很熟悉的事务特性:ACID原理。
如下:
原子性(Atomicity
):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
一致性(Consistency
):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;
隔离性(Isolation
):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
持久性(Durability
):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
原子性在并发中也有提及比如我们熟悉的并发问题的源头:可见性、原子性、有序性。
因为之前的话有在看并发相关的书籍,所以我们在这里简单提及一下。
关于并发的操作,在硬件层面操作系统角度来说其实是通过CPU
的时间片轮转调度。
关于时间片轮转(RR)调度算法的详解这里我们就不展开论述,只需要知道它是专门为分时系统设计的。
该算法中,将一个较小时间单元定义为时间量或时间片。时间片的大小通常为 10~100ms。就绪队列作为循环队列。CPU 调度程序循环整个就绪队列,为每个进程分配不超过一个时间片的 CPU。
原子性带来的问题:可能还没有执行完最后一步 发生了CPU的切片 导致并不是一个原子性操作(CPU层面原子 但是 高级语言程序层面并非原子性) 从而就会导致这个问题。
而JVM的编译优化又会带来有序性的问题,我们只需要知道JVM提供了一系列的方法如:volatile
、synchroized
、final
等解决了此相关问题。
而关于原子性,则是通过加锁等方式得到解决,当我们对临界资源进行加锁时,虽然此时发生了线程切换但是切换后线程并不持有操作资源的权限,从而保证了原子性的操作!
这也是我们说的为什么多线程、加锁等会带来额外开销,非必要并不对推荐多线程的处理方式!
而关于多线程,我们可能又会联想到线程安全、锁、ComplateFuture
、线程池、线程池的核心参数、IO线程池、CPU线程池...... 好了 打住!有点扯远了。
数据库事务
首先我们在Spring提到的事务@Transactional
其实质上是用来管理MySQL
数据库层面。因为我们日常开发中事务注解一般的应用场景就是于此。当然也有一种说法是JAVA
层面本身并不具备事务的特性!
事务并发可能出现的问题
引发问题 | 现象 | 可能什么状况下发生 |
---|---|---|
脏读 | 一个事务读到了另一个未提交事务修改过的数据 | 读未提交事务隔离级别 |
不可重复读 | 一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值 | 读未提交事务隔离级别、读已提交事务隔离级别 |
幻读 | 一个事务先根据某种条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务按照该条件查询时,能把另一个事务插入的记录也读出来 | 读未提交事务隔离级别、读已提交事务隔离级别、可重复读事务隔离级别 |
MySQL 中常见的事务隔离级别主要有以下四种,它们分别是:
- READ UNCOMMITTED(读取未提交内容)
- 在该隔离级别下,事务可以读取到其他事务尚未提交的数据变更,这被称为"脏读"(Dirty Read)。
- 这种隔离级别会引发很多问题,因为它允许读取到的数据可能会被其他事务回滚,从而导致数据不一致。
- 实际应用中很少使用这个隔离级别。
- READ COMMITTED(读取提交内容)
- 在该隔离级别下,一个事务只能读取到其他事务已经提交的数据变更。
- 它解决了脏读的问题,但仍然存在"不可重复读"(Non-Repeatable Read)的问题,即在一个事务内,多次读取同一数据集合时,可能会得到不同的结果集。
- 这是很多数据库系统的默认隔离级别,如 Oracle 和 SQL Server。
- REPEATABLE READ(可重复读)
- 这是 MySQL 默认的事务隔离级别。
- 在该隔离级别下,确保了一个事务在执行过程中可以多次读取同样的数据结果,即使其他事务对该数据进行了修改并提交,也不会影响当前事务的读取结果。
- 然而,它仍然存在"幻读"(Phantom Read)的问题,即在一个事务内,第一次查询某范围内记录后,其他事务又在该范围内插入了新的记录,当本事务再次查询该范围时,会产生幻行。
- SERIALIZABLE(可串行化)
- 这是最高的事务隔离级别,它通过强制事务串行执行,避免了前面提到的脏读、不可重复读和幻读问题。
- 在该隔离级别下,事务会被处理成类似顺序执行的效果,即一个事务在操作数据库时,其他事务必须等待该事务完成才能进行操作。
- 由于性能开销较大,通常只在非常需要确保数据一致性且并发量不高的情况下使用。
如何解决幻读?
需要强调的是可重读下虽然有效的避免了幻读的产生但是并没有完全解决幻读。其主要有如下方式。
- 针对快照读(普通
select
语句),是通过MVCC
方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对当前读(
select ... for update
等语句),采用了next-key lock
(记录锁+间隙锁)结合的锁机制方式解决了幻读,因为当执行select ... for update
语句的时候,会加上next-key lock
,如果有其他事务在next-key lock
锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
也就是说问题来了,当我们正常查询的时候,select
直接查询就好,并不需要担心幻读。因为有MVCC
机制。
那么select forupdate
的场景是什么呢?
当事务需要读取某些记录,并且打算随后更新这些记录时,使用 FOR UPDATE
可以确保这些记录在更新期间不会被其他事务修改。MVCC
主要解决了读取操作的幻读问题,但不保证在读取和更新之间数据不被其他事务修改。
如果直接使用update呢?
-
当您执行一个
UPDATE
语句时,MySQL
会自动处理以下步骤:- 读取记录:
MySQL
使用MVCC
来读取当前记录的值,确保读取的是事务开始时的快照。 - 计算新值:基于读取的值计算新值(例如,
val + 1
)。 - 更新记录:
MySQL
会尝试更新记录。如果记录在事务开始后已经被其他事务修改,则MySQL
会根据行版本号检测到冲突,并处理这种冲突。如果检测到冲突,通常会发生以下两种情况之一:- 乐观锁:如果使用的是乐观锁机制,更新可能会失败,并且事务可能会回滚或重试。
MVCC
:在REPEATABLE READ
隔离级别下,MySQL
会使用MVCC
的机制确保更新是在事务开始时的快照上进行的,即使其他事务已经修改了记录。
- 读取记录:
其实正常来说我认为MVCC保证了一部分这种版本号机制乐观锁的更新准确性。但是昨天为什么组里讨论说这种方式不可行,我有点忘记了!于是当时让使用for update
使用for update
的场景?
- 确保锁定:在更新之前,您可能想要显式地锁定记录,以防止其他事务在您的更新操作完成之前读取或修改这些记录。
- 复杂业务逻辑:在更新之前,可能需要进行一些复杂的业务逻辑验证,这需要确保在验证期间记录不被其他事务更改。
- 长事务:在长事务中,您可能想要更精确地控制锁的范围和持续时间,以减少对其他事务的影响。
所以个人认为?在大多数简单的更新场景中,直接使用 UPDATE 语句就足够了,MySQL 的 MVCC 机制会处理好并发控制。只有在需要更细粒度的锁控制时,才考虑使用 SELECT ... FOR UPDATE。
ps:关于这里这段知识,仅代表个人学术水平的认知,请合理消化吸收
@Transaction注解
源码解读:
java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
//可选的限定描述符,指定使用的事务管理器
String value() default "";
//可选的事务传播行为,具体看Propagation中定义的属性
Propagation propagation() default Propagation.REQUIRED;
//可选的事务隔离级别
Isolation isolation() default Isolation.DEFAULT;
//事务超时时间设置
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
//读写或只读事务,默认读写
boolean readOnly() default false;
//导致事务回滚的异常类数组,
//注意项:即使自定义异常,也必须继承自Throwable
Class<? extends Throwable>[] rollbackFor() default {};
//导致事务回滚的异常类名字数组
//类名数组,必须继承自Throwable
String[] rollbackForClassName() default {};
//不会导致事务回滚的异常类数组
//Class对象数组,必须继承自Throwable
Class<? extends Throwable>[] noRollbackFor() default {};
//不会导致事务回滚的异常类名字数组
//类名数组,必须继承自Throwable
String[] noRollbackForClassName() default {};
}
@Transactional
的常用配置参数总结:
propagation
事务的传播行为,默认值为REQUIRED
。isolation
事务的隔离级别,默认值采用DEFAULT
。timeout
事务的超时时间,默认值为-1
(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。readOnly
指定事务是否为只读事务,默认值为false
。rollbackFor
用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。
@Transactional
作为注解所以是基于AOP
实现的,而AOP
的实现基于了动态代理 。动态代理的实现方式有两种分别是:JDK
动态代理和CGLIB
动态代理。
好的,现在我们知道了:
如果一个类或者一个类中的 public 方法上被标注@Transactional
注解的话,Spring
容器就会在启动的时候为其创建一个代理类,在调用被@Transactional
注解的 public
方法的时候,实际调用的是,TransactionInterceptor
类中的 invoke()
方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。
我们常说的同一个类下A
调用B
事务失效 其实是说的B
如果被@Transaction
修饰的话,B
的事务失效,而非A
事务失效。如下:
java
@Service
public class MyService {
private void method1() {
method2();
//......
}
@Transactional
public void method2() {
//......
}
}
这是因为 Spring AOP
工作原理决定的。因为 Spring AOP
使用动态代理来实现事务的管理,它会在运行的时候为带有 @Transactional
注解的方法生成代理对象,并在方法调用的前后应用事物逻辑。如果该方法被其他类调用我们的代理对象就会拦截方法调用并处理事务。但是在一个类中的其他方法内部调用的时候,我们代理对象就无法拦截到这个内部调用,因此事务也就失效了。
解决方法:
SpringUtil.getBean()
- 注入
ApplicationContext
获取bean
@Autowired
引入自身Bean
通过这种方式 实际上是使用了 Spring 代理后的对象。这样,即使在内部方法调用中,也能保证调用的是代理对象,从而实现事务管理。
提一嘴 远程调用(HTTP
、RPC
)的时候会破坏事务的传播机制,从而导致事务失效,于是有了分布式事务比如Seata
其解决方案又包括:如两阶段提交(2PC
)、三阶段提交(3PC
)、TCC(Try-Confirm-Cancel)
模式等。