浅谈 Transaction 事务
1. 事务的 ACID 原则
事务的四个基本原则 (ACID) :
- Atomicity 原子性
- Consistency 一致性
- Isolation 隔离性
- Durability 持久性
原子性,指的是事务内的操作具有原子性,不可分割,不可被打断;
一致性,保证数据的一致性;
隔离性,指的是事务与事务之间应该隔离,而不能相互影响。所以,数据库的设计者设计了四种隔离级别;
持久性,一旦提交的事务操作要持久性的保存到数据库中。
事务就是让数据正确无误地持久化到数据库中。
2. 数据库并发操作产生的问题
数据库操作肯定不是串行的,同步的执行,在大多数场合都是并发地处理操作,所以,这就会引发一些数据不一致问题。例如,两个人同时操作相同的数据,还有人在此时读取这条数据,怎么保证修改后的数据是正确的,读取的数据也是正确的?
2.1 产生的问题
先来说说数据库并发操作产生的问题:
- Dirty Read 脏读
- Unrepeatable Read 不可重复读
- Phantom Read 幻读
问题 | 原因 | 允许 |
---|---|---|
脏读 | 事务 A 读取了事务 B 未提交的数据,并在这个基础上又做了其他操作。 | 不被允许 |
不可重复读 | 事务 A 读取了事务 B 已提交的更改数据。 | 大多数场景允许 |
幻读 | 事务 A 读取了事务 B 已提交的新增数据。 | 大多数场景允许 |
脏读是肯定不被允许的,这是由于事务之间没有隔离,并发操作下可能会导致数据不一致的问题。
不可重复度 和 幻读 一般是被允许的,因为你已经提交事务了,我在操作或者读取一般都是没有问题的,这符合一般情况,只有极端使用场景需要避免这种情况。
那如何解决这三种并发问题呢?数据库设计者搞了事务的隔离级别,这也是保证 ACID 原则中的隔离性,目的是为了保证数据一致性。
2.2 事务的隔离级别
四种隔离级别:
- READ_UNCOMMITTED 读取未提交
- READ_COMMITTED 读取已提交
- REPEATABLE_READ 可重复读
- SERIALIZABLE 串行化
越往下,级别越高,并发性越差,安全性越高。
每种隔离级别能解决的问题:
事物隔离级别 | 脏读 | 不可重复度 | 幻读 |
---|---|---|---|
READ_UNCOMMITTED | 允许 | 允许 | 允许 |
READ_COMMITTED | 禁止 | 允许 | 允许 |
REPEATABLE_READ | 禁止 | 禁止 | 允许 |
SERIALIZABLE | 禁止 | 禁止 | 禁止 |
一般隔离级别可以设置为 READ_COMMITTED
,解决脏读问题。
3. Spring 事务传播行为
除了事务的隔离级别外,Spring 框架还提供了 7 种事务传播行为,来控制方法调用之间的事务行为。
Spring 提供了 7 种事务传播行为:(这几个单词的直译就能体现它们的作用)
- Propagation.REQUIRED 当前方法必须以事务方式执行
- Propagation.SUPPORTS 当前方法支持其它方法的事务(本身没有事务)
- Propagation.MANDATORY 当前方法强制其它方法必须以事务方式执行
- Propagation.REQUIRES_NEW 当前方法需要以一个新的事务执行
- Propagation.NOT_SUPPORTED 当前方法不支持事务方式执行
- Propagation.NEVER 当前方法绝不可能以事务方式执行,也不允许其它方法以事务方式执行
- Propagation.NESTED 当前方法作为一个子事务嵌套在其他事务中,提交还是回滚受其它事务影响
网上介绍事务传播行为的文章有很多,我就不多做介绍了。
这里介绍下 Spring 中事务的执行流程,传播行为在日常开发中使用不多,多数场景使用默认配置,所以了解即可。如果想进一步理解,可以读读代码,看下 Spring 是怎么实现的。
我画了一个草图,简述下 SpringBoot 对事务的处理流程(也是事务传播行为的实现):
(Tip: 键盘 Shift + 鼠标滑轮 可以横向滚动页面)
在 SpringBoot 项目中,通常使用 @Transactional
注解为方法添加事务,通过注解的 propagation
属性(如:@Transactional(propagation = Propagation.REQUIRED) )可以设置事务的传播行为。
这个流程示例是 UserService.saveUser() 方法向 user 表中插入用户信息,并调用 UserHobbiesService.saveUserHobby() 方法向 user_hobbies 中插入用户爱好信息。
- 使用 CGLib 动态代理创建 UserService 代理对象并调用方法 saveUser() 的代理方法,代理的作用是方法拦截,让方法以事务方式执行。(这一步就是给 saveUser() 加了 AOP 的环绕增强,具体是否以事务执行,还要看其设置的传播行为);
- 代理方法执行会调用事务拦截器的 invoke() 方法,再调用
TransactionAspectSupport.invokeWithinTransaction()
方法,这一步是拿到事务的基本属性,再根据当前的事务管理器类型(我这里是 JdbcTransactionManager),去做相应的处理逻辑; - 再调用
TransactionAspectSupport.createTransactionIfNecessary()
方法,这一步处理了一下事务名称(如果没有事务名称就把方法名作为事务名称),然后通过事务管理器对象调用getTransaction()
方法; - 在
getTransaction()
方法中,会先判断当前是否已经存在事务。saveUser() 作为调用者,第一次执行到这里,所以当前还没有创建事务。然后根据 saveUser() 设置的事务传播行为判断是否要创建事务,如果是 Propagation.REQUIRED 就创建并开始一个新事务,如果是 Propagation.NOT_SUPPORTED 就不会创建事务,如果是 Propagation.MANDATORY 会抛出异常等等; getTransaction()
执行结束后返回事务状态,再往上一层 (TransactionAspectSupport) 返回事务信息,再调用proceedWithInvocation()
方法,执行 saveUser() 中的代码,执行到 saveUserHobby() 时继续重复上面的处理逻辑,唯一不同的可能是到 4 步时,判断可能已经存在事务了,这时就会调用handleExistingTransaction()
方法,根据 saveUserHobby() 设置的事务传播行为再做处理。- 当整个调用链执行结束后,在
TransactionAspectSupport.invokeWithinTransaction()
方法中清除事务信息,没有发生异常就提交事务,发生异常回滚事务。
事务的传播行为是作用于两个类的方法之间的,为什么是两个类之间?同一个类中的两个方法不行?
Spring 中的事务是通过代理来实现事务行为的(同 AOP),方法被同类中的方法调用,不会走方法拦截,而是被当前对象 (this) 调用的,所以被调用的方法不会走上面事务处理流程,导致不符合预期的结果。
例如:同类中,调用者是 Propagation.REQUIRED,被调用者是 Propagation.NEVER,那么这两个方法也是在同一个事务中执行,而不会因为被调用者是 Propagation.NEVER 导致异常。
额外说下 @Transactional
的使用事项:
- 被注解的方法必须是 public 修饰;
- 该注解默认是 RuntimeException 异常才回滚,一般可以通过
@Transactional(rollbackFor = Exception.class)
指定回滚的异常; - 方法中不能使用 try-catch 捕获异常,否则不会正常回滚,必须抛出异常;
End.