赞
踩
SpringAOP的约定会把我们的代码织入约定的流程中。同样地,使用AOP的思维后,执行SQL的代码就可以织入Spring约定的数据库事务的流程中。所以首先需要掌握这个约定。
在讲解SpringAOP时,只要我们遵循约定,就可以把自己开发的代码织入约定的流程中。为了“擦除”令人厌烦的try..catch..finally.语句,减少那些数据库连接开闭和事务回滚提交的代码,Spring利用其AOP为我们提供了一个数据库事务的约定流程。通过这个约定流程就可以减少大量的冗余代码和一些没有必要的try..catch..finally..语句,让开发者能够更加集中于业务的开发,而不是数据库连接资源和事务的功能开发,这样开发的代码可读性就更高,也更好维护。
对于事务,需要通过标注告诉Spring在什么地方启用数据库事务功能。对于声明式事务,是使用@Transactional进行标注的。这个注解可以标注在类或者方法上,当它标注在类上时,代表这个类所有公共(public)非静态的方法都将启用事务功能。在@Transactional中,还允许配置许多的属性,如事务的隔离级别和传播行为,这是本章的核心内容;又如异常类型,从而确定方法发生什么异常下回滚事务或者发生什么异常下不回滚事务等。这些配置内容,是在Spring IoC容器在加载时就会将这些配置信息解析出来,然后把这些信息存到事务定义器(TransactionDefinition接口的实现类)里,并且记录哪些类或者方法需要启动事务功能,采取什么策略去执行事务。这个过程中,我们所需要做的只是给需要事务的类或者方法标注@Transactional和配置其属性而已,并不是很复杂。
有了@Transactional的配置,Spring就会知道在哪里启动事务机制,其约定流程如图所示。
因为这个约定非常重要,所以这里做进一步的讨论。
当Spring的上下文开始调用被@Transactional 标注的类或者方法时,Spring就会产生AOP的功能。
请注意事务的底层需要启用AOP功能,这是Spring事务的底层实现,后面我们会看到一些陷阱。那么当它启动事务时,就会根据事务定义器内的配置去设置事务,首先是根据传播行为去确定事务的策略。有关传播行为后面我们会再谈,这里暂且放下。然后是隔离级别、超时时间、只读等内容的设置,只是这步设置事务并不需要开发者完成,而是Spring 事务拦截器根据@Transactional配置的内容来完成的。在上述场景中,Spring通过对注解@Transactional属性配置去设置数据库事务,跟着Spring就会开始调用开发者编写的业务代码。执行开发者的业务代码,可能发生异常,也可能不发生异常。在Spring数据库事务的流程中,它会根据是否发生异常采取不同的策略。
如果都没有发生异常,Spring数据库拦截器就会帮助我们提交事务,这点也并不需要我们干预。
如果发生异常,就要判断一次事务定义器内的配置,如果事务定义器已经约定了该类型的异常不回滚事务就提交事务,如果没有任何配置或者不是配置不回滚事务的异常,则会回滚事务,并且将异常抛出,这步也是由事务拦截器完成的。
无论发生异常与否,Spring都会释放事务资源,这样就可以保证数据库连接池正常可用了,这也是由Spring事务拦截器完成的内容。
在上述场景中,我们还有一个重要的事务配置属性没有讨论,那就是传播行为。它是属于事务方法之间调用的行为,后面我们会对其做更为详细的讨论。但是无论怎么样,从流程中我们可以看到开发者在整个流程中只需要完成业务逻辑即可,其他的使用Spring事务机制和其配置即可,这样就可以把try..catch..finally?、数据库连接管理和事务提交回滚的代码交由Spring拦截器完成,而只需要完成业务代码即可,所以你可以经常看到代码清单所示的简洁代码。
代码清单 使用Spring数据库事务机制
- public class UserServiceImpl implements UserService {
- @Autowired
- private UserDao userDao = null;
-
- @Override
- @Transactional
- public int insertUser(User user) {
- return userDao.insertUser(user);
- }
- }
这里仅仅是使用一个@Transactional注解,标识insertUser 方法需要启动事务机制,那么Spring就会按照那样,把insertUser方法织入约定的流程中,这样对于数据库连接的闭合、事务提交与回滚都不再需要我们编写任何代码了,可见这是十分便利的。从代码中,可以看到只需要完成对应的业务逻辑便可以了,这样就可以大幅减少代码,同时代码也具备更高的可读性和可维护性。
@Transactional的配置项
数据库事务属性都可以由@Transactional来配置,先来探讨它的源码,如代码清单所示。
代码清单 @Transactional 源码分析
- package org.springframework.transaction.annotation;
-
- import java.lang.annotation.Documented;
- import java.lang.annotation.ElementType;
- import java.lang.annotation.Inherited;
- import java.lang.annotation.Retention;
- import java.lang.annotation.RetentionPolicy;
- import java.lang.annotation.Target;
- import org.springframework.core.annotation.AliasFor;
- import org.springframework.transaction.TransactionDefinition;
-
- @Target({ElementType.TYPE, ElementType.METHOD})
- @Retention(RetentionPolicy.RUNTIME)
- @Inherited
- @Documented
- public @interface Transactional {
-
- //通过bean name 指定事务管理器
- @AliasFor("transactionManager")
- String value() default "";
-
- //同value属性
- @AliasFor("value")
- String transactionManager() default "";
-
- //指定传播行为
- Propagation propagation() default Propagation.REQUIRED;
-
- //指定隔离级别
- Isolation isolation() default Isolation.DEFAULT;
-
- //指定超时时间(单位秒)
- int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
-
- //是否只读事务
- boolean readOnly() default false;
-
- //方法在发生指定异常时回滚,默认 所有异常都回滚
- Class<? extends Throwable>[] rollbackFor() default {};
-
- //方法 发生指定异常名称时回滚 默认是所有异常都回滚
- String[] rollbackForClassName() default {};
-
- //方法在发生指定异常时不回滚 默认是所有异常都回滚
- Class<? extends Throwable>[] noRollbackFor() default {};
-
- //方法在发生指定异常名称时不回滚,默认是所有异常都回滚
- String[] noRollbackForClassName() default {};
- }
value和transactionManager属性是配置一个Spring的事务管理器,关于它后面会进行详细讨论;timeout 是事务可以允许存在的时间戳,单位为秒;readOnly属性定义的是事务是否是只读事务;rollbackFor、rollbackForClassName、noRollbackFor和noRolbackForClassName都是指定异常,我们从流程中可以看到在带有事务的方法时,可能发生异常,通过这些属性的设置可以指定在什么异常的情况下依旧提交事务,在什么异常的情况下回滚事务,这些可以根据自己的需要进行指定。以上这些都比较好理解,真正麻烦的是propagation和isolation这两个属性。propagation指的是传播行为,isolation则是隔离级别,它需要了解数据库的特性才能使用,而这两个麻烦的东西,就是本章的核心内容,也是互联网企业最为关心的内容之一,因此值得我们后面花较大篇幅去讲解它们的内容和使用方法。由于这里使用到了事务管理器,所以我们接下来先讨论一下Spring的事务管理器。
关于注解@Transactional值得注意的是它可以放在接口上,也可以放在实现类上。但是Spring团队推荐放在实现类上,因为放在接口上将使得你的类基于接口的代理时它才生效。我们知道在Spring可以使用JDK动态代理,也可以使用CGLIG动态代理。如果使用接口,那么你将不能切换为CGLIB动态代理,而只能允许你使用JDK动态代理,并且使用对应的接口去代理你的类,这样才能驱动这个注解,这将大大地限制你的使用,因此在实现类上使用@Transactional注解才是最佳的方式,本书也是将它放置在实现类上的。
上述的事务流程中,事务的打开、回滚和提交是由事务管理器来完成的。在Spring中,事务管理器的顶层接口为Platform TransactionManager,Spring还为此定义了一些列的接口和类,如图所示。
当我们引入其他框架时,还会有其他的事务管理器的类,比方说我们引入Hibernate,那么Spring orm包还会提供Hibernate TransactionManager与之对应并给我们使用。因为本书会以MyBatis框架去讨论Spring数据库事务方面的问题,最常用到的事务管理器是DataSource TransactionManager。从图可以看到它也是一个实现了接口Platform TransactionManager的类,为此可以看到Platform Transaction Manager接口的源码,如代码清单所示。
代码清单 Platform TransactionManager 源码分析
- package org.springfrarnework.transaction;
-
- public interface PlatforrnTransactionManager {
- //获取事务 它还会设置数据属性
- TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;
-
- //提交事务
- void cornrnit(TransactionStatus status) throws TransactionException;
-
- //回滚事务
- void rollback(TransactionStatus status) throws TransactionException;
-
- }
显然这些方法并不难理解,只需要简单地介绍一下它们便可以了。Spring在事务管理时,就是将这些方法按照约定织入对应的流程中的,其中getTransaction方法的参数是一个事务定义器(TransactionDefinition),它是依赖于我们配置的@Transactional的配置项生成的,于是通过它就能够设置事务的属性了,而提交和回滚事务也就可以通过commit和rollback方法来执行。
在Spring Boot中,当你依赖于mybatis-spring-boot-starter之后,它会自动创建一个DataSource-
TransactionManager 对象,作为事务管理器,如果依赖于spring-boot-starter-data-jpa,则它会自动创建Jpa TransactionManager对象作为事务管理器,所以我们一般不需要自己创建事务管理器而直接使用它们即可。
上面我们只是简单地使用事务,这里将讨论Spring事务机制中最重要的两个配置项,即隔离级别和传播行为。毫无疑问本节内容是本章的核心内容,也是互联网企业最为关注的内容之一,因此它们十分重要,值得花上大篇幅去讨论。我们从这两个配置项的大概含义谈起。
首先是隔离级别,因为互联网应用时刻面对着高并发的环境,如商品库存,时刻都是多个线程共享的数据,这样就会在多线程的环境中扣减商品库存。对于数据库而言,就会出现多个事务同时访问同一记录的情况,这样引起数据出现不一致的情况,便是数据库的丢失更新(Lost Update)问题。应该说,隔离级别是数据库的概念,有些难度,所以在使用它之前应该先了解数据库的相关知识。
数据库事务具有以下4个基本特征,也就是著名的ACID。
Atomic(原子性):事务中包含的操作被看作一个整体的业务单元,这个业务单元中的操作要么全部成功,要么全部失败,不会出现部分失败、部分成功的场景。
Consistency(一致性):事务在完成时,必须使所有的数据都保持一致状态,在数据库中所有的修改都基于事务,保证了数据的完整性。
Isolation(隔离性):这是我们讨论的核心内容,正如上述,可能多个应用程序线程同时访问同一数据,这样数据库同样的数据就会在各个不同的事务中被访问,这样会产生丢失更新。为了压制丢失更新的产生,数据库定义了隔离级别的概念,通过它的选择,可以在不同程度上压制丢失更新的发生。因为互联网的应用常常面对高并发的场景,所以隔离性是需要掌握的重点内容。
Durability(持久性):事务结束后,所有的数据会固化到一个地方,如保存到磁盘当中,即使断电重启后也可以提供给应用程序访问。
这4个特性,除了隔离性,都还是比较好理解的,所以这里会更为深入地讨论隔离性。在多个事务同时操作数据的情况下,会引发丢失更新的场景,例如,电商有一种商品,在疯狂抢购中,会出现多个事务同时访问商品库存的场景,这样就会产生丢失更新。一般而言,存在两种类型的丢失更新,让我们了解下它们。下面假设一种商品的库存数量还有100,每次抢购都只能抢购1件商品,那么在抢购中就可能出现如表所示的场景。
可以看到,T5时刻事务1回滚,导致原本库存为99的变为了100,显然事务2的结果就丢失了,这就是一个错误的值。类似地,对于这样一个事务回滚另外一个事务提交而引发的数据不一致的情况,我们称为第一类丢失更新。然而它却没有讨论的价值,因为目前大部分数据库已经克服了第一类丢失更新的问题,也就是现今数据库系统已经不会再出现表的情况了。所以对于这样的场景不再深入讨论,而是讨论第二类丢失更新,也就是多个事务都提交的场景。
如果是多个事务并发提交,会出现怎么样的不一致的场景呢?例如可能发生如表所示的场景。
注意T5时刻提交的事务。因为在事务1中,无法感知事务2的操作,这样它就不知道事务2已经修改过了数据,因此它依旧认为只是发生了一笔业务,所以库存变为了99,而这个结果又是一个错误的结果。这样,T5时刻事务1提交的事务,就会引发事务2提交结果的丢失,我们把这样的多个事务都提交引发的丢失更新称为第二类丢失更新。这是我们互联网系统需要关注的重点内容。为了克服这些问题,数据库提出了事务之间的隔离级别的概念,这就是本章的重点内容之一。
上面我们讨论了第二类丢失更新。为了压制丢失更新,数据库标准提出了4类隔离级别,在不同的程度上压制丢失更新,这4类隔离级别是未提交读、读写提交、可重复读和串行化,它们会在不同的程度上压制丢失更新的情景。
也许你会有一个疑问,都全部消除丢失更新不就好了吗,为什么只是在不同的程度上压制丢失更新呢?其实这个问题是从两个角度去看的,一个是数据的一致性,另一个是性能。数据库现有的技术完全可以避免丢失更新,但是这样做的代价,就是付出锁的代价,在互联网中,系统不单单要考虑数据的一致性,还要考虑系统的性能。试想,在互联网中使用过多的锁,一旦出现商品抢购这样的场景,必然会导致大量的线程被挂起和恢复,因为使用了锁之后,一个时刻只能有一个线程访问数据,这样整个系统就会十分缓慢,当系统被数千甚至数万用户同时访问时,过多的锁就会引发宕机,大部分用户线程被挂起,等待持有锁事务的完成,这样用户体验就会十分糟糕。因为用户等待的时间会十分漫长,一般而言,互联网系统响应超过5秒,就会让用户觉得很不友好,进而引发用户忠诚度下降的问题。所以选择隔离级别的时候,既需要考虑数据的一致性避免脏数据,又要考虑系统性能的问题。因此数据库的规范就提出了4种隔离级别来在不同的程度上压制丢失更新。下面我们通过商品抢购的场景来讲述这4种隔离级别的区别。
数据库隔离级别源码:
- package org.springframework.transaction.annotation;
- import org.springframework.transaction.TransactionDefinition;
-
- public enum Isolation {
- //默认隔离级别
- DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),
- //读未提交
- READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),
- //读已提交
- READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),
- //可重复读
- REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ),
- //串行化
- SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);
-
- private final int value;
-
- Isolation(int value) {
- this.value = value;
- }
-
- public int value() {
- return this.value;
- }
- }
未提交读(read uncommitted)是最低的隔离级别,其含义是允许一个事务读取另外一个事务没有提交的数据。未提交读是一种危险的隔离级别,所以一般在我们实际的开发中应用不广,但是它的优点在于并发能力高,适合那些对数据一致性没有要求而追求高并发的场景,它的最大坏处是出现脏读。让我们看看可能发生的脏读场景,如表所示。
表中的T3时刻,因为采用未提交读,所以事务2可以读取事务1未提交的库存数据为1,这里当它扣减库存后则数据为0,然后它提交了事务,库存就变为了0,而事务1在T5时刻回滚事务,因为第一类丢失更新已经被克服,所以它不会将库存回滚到2,那么最后的结果就变为了0,这样就出现了错误。
脏读一般是比较危险的隔离级别,在我们实际应用中采用得不多。为了克服脏读的问题,数据库隔离级别还提供了读写提交(read commited)的级别,下面我们讨论它。
读写提交(read committed)隔离级别,是指一个事务只能读取另外一个事务已经提交的数据,不能读取未提交的数据。例如,表的场景在限制为读写提交后,就变为表描述的场景了。
在T3时刻,由于采用了读写提交的隔离级别,因此事务2不能读取到事务1中未提交的库存1,所以扣减库存的结果依旧为1,然后它提交事务,则库存在T4时刻就变为了1。T5时刻,事务1回滚,因为第一类丢失更新已经克服,所以最后结果库存为1,这是一个正确的结果。但是读写提交也会产生下面的问题,如表所描述的场景。
在T3时刻事务2读取库存的时候,因为事务1未提交事务,所以读出的库存为1,于是事务2认为当前可扣减库存;在T4时刻,事务1已经提交事务,所以在T5时刻,它扣减库存的时候就发现库存为0,于是就无法扣减库存了。这里的问题在于事务2之前认为可以扣减,而到扣减那一步却发现已经不可以扣减,于是库存对于事务2而言是一个可变化的值,这样的现象我们称为不可重复读,这就是读写提交的一个不足。为了克服这个不足,数据库的隔离级别还提出了可重复读的隔离级别,它能够消除不可重读的问题。
可重复读的目标是克服读写提交中出现的不可重复读的现象,因为在读写提交的时候,可能出现一些值的变化,影响当前事务的执行,如上述的库存是个变化的值,这个时候数据库提出了可重复读的隔离级别。这样就能够克服不可重复读的现象如表所示。
可以看到,事务2在T3时刻尝试读取库存,但是此时这个库存已经被事务1事先读取,所以这个时候数据库就阻塞它的读取,直至事务1提交,事务2才能读取库存的值。此时已经是T5时刻,而读取到的值为0,这时就已经无法扣减了,显然在读写提交中出现的不可重复读的场景被消除了。但是这样也会引发新的问题的出现,这就是幻读。假设现在商品交易正在进行中,而后台有人也在进行查询分析和打印的业务,让我们看看如表所示可能发生的场景。
这便是幻读现象。可重复读和幻读,是读者比较难以理解的内容,这里稍微论述一下。首先这里的笔数不是数据库存储的值,而是一个统计值,商品库存则是数据库存储的值,这一点是要注意的。也就是幻读不是针对一条数据库记录而言,而是多条记录,例如,这51笔交易笔数就是多条数据库记录统计出来的。而可重复读是针对数据库的单一条记录,例如,商品的库存是以数据库里面的一条记录存储的,它可以产生可重复读,而不能产生幻读。
串行化(Serializable)是数据库最高的隔离级别,它会要求所有的SQL都会按照顺序执行,这样就可以克服上述隔离级别出现的各种问题,所以它能够完全保证数据的一致性。
通过上面的讲述,读者应该对隔离级别有了更多的认识,使用它能够在不同程度上压制丢失更新,于是可以总结成如表所示的一张表。
作为互联网开发人员,在开发高并发业务时需要时刻记住隔离级别可能发生的各种概念和相关的现象,这是数据库事务的核心内容之一,也是互联网企业关注的重要内容之一。追求更高的隔离级别,它能更好地保证了数据的一致性,但是也要付出锁的代价。有了锁,就意味着性能的丢失,而且隔离级别越高,性能就越是直线地下降。所以我们在选择隔离级别时,要考虑的不单单是数据一致性的问题,还要考虑系统性能的问题。例如,一个高并发抢购的场景,如果采用串行化隔离级别,能够有效避免数据的不一致性,但是这样会使得并发的各个线程挂起,因为只有一个线程可以操作数据,这样就会出现大量的线程挂起和恢复,导致系统缓慢。而后续的用户要得到系统响应就需要等待很长的时间,最终因为响应缓慢,而影响他们的忠诚度。
所以在现实中一般而言,选择隔离级别会以读写提交为主,它能够防止脏读,而不能避免不可重复读和幻读。为了克服数据不一致和性能问题,程序开发者还设计了乐观锁,甚至不再使用数据库而使用其他的手段。例如,使用Redis作为数据载体,这些内容我们会在后续章节谈及。对于隔离级别,不同的数据库的支持也是不一样的。例如,Oracle只能支持读写提交和串行化,而MySQL则能够支持4种,对于Oracle默认的隔离级别为读写提交,MySQL则是可重复读,这些需要根据具体数据库来决定。
只要掌握了隔离级别的含义,使用隔离级别就很简单,只需要在@Transactional配置对应即可,如代码清单所示。
代码清单 使用隔离级别
- @Transactional(isolation = Isolation.SERIALIZABLE)
- public int insertUser(User user) {
- return userDao.insertUser(user);
- }
上面的代码中我们使用了序列化的隔离级别来保证数据的一致性,这使它将阻塞其他的事务进行并发,所以它只能运用在那些低并发而又需要保证数据一致性的场景下。对于高并发下又要保证数据一致性的场景,则需要另行处理了。
当然,有时候一个个地指定隔离级别会很不方便,因此Spring Boot可以通过配置文件指定默认的隔离级别。例如,当我们需要把隔离级别设置为读写提交时,可以在application.properties文件加入默认的配置,如代码清单所示。
代码清单 配置默认的隔离级别
#隔离级别数字配置的含义:#-1数据库默认隔离级别
#1未提交读#2读写提交#4可重复读
#8串行化
#tomcat数据源默认隔离级别
spring.datasource.tomcat.default-transaction-isolation=2
#dbcp2数据库连接池默认隔离级别
#spring.datasource.dbcp2.default-transaction-isolation=2代码中配置了tomcat数据源的默认隔离级别,而注释的代码则是配置了DBCP2数据源的隔离级别,注释中已经说明了数字所代表的隔离级别,相信读者也有了比较清晰的认识,这里配置为2,说明将数据库的隔离级别默认为读写提交。
文章参考:深入浅出Spring Boot 2.x 杨开振著
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。