Spring声明式事务

Spring声明式数据库事务约定

为了省去令人厌烦的try···catch···finally语句,减少那些数据库连接开闭和事务回滚提交的代码,Spring利用其AOP为我们提供了一个数据库事务的约定流程,通过这个约定流程就可以减少大量冗余代码和一些没必要的try···catch···finally语句,让开发者能够更加集中于业务的开发,而不是数据库连接资源和事务的功能开发,这样开发的代码可读性更高,也更好维护。

对于事务,需要通过标注告诉Spring在什么地方启用数据库事务功能。对于声明式事务,是使用@Transactional进行标注的。这个注解可以标注在类上或者方法上,当标注在类上时,代表这个类所有公共非静态的方法都将启用事务功能。在@Transactional中,还允许配置许多的属性, 事务的隔离级别和传播行为:又如异常类型 ,从而确定方法发生什么异常 下回滚事务或者发生什么异常下不回滚事务等。这些配置内容,是在 Spring IoC 容器在加载时就会将这些配置信息解析出来,然后把这些信息存到事务定义器( TransactionDefinition 接口的实现类) 并且记录哪些类或者方法需要启动事务功能,采取什么策略去执行事务。这个过程中,我们所需要做的只是给需要事务的类或者方法标注@Transactional 和配置其属性而己,并不是很复杂。

隔离级别

因为互联网应用时刻面对着高并发的环境 ,如商品库存,时刻都是多个线程共 享的数据,这样就会在多线程的环境中扣减商品库存。对于数据库而言 就会出现多个事务同时访问同一记录的情况, 这样引起数据出现不一致的情况,便是数据库的丢失更新( Lost Update)问 题。应该说,隔离级别是数据库 的概念,有些难度 ,所以在使用它之前应该先了解数据库的相关知识。

数据库事务的知识

数据库事务具有以下4个基本特征 也就是著名的 ACID 。

  • Atomic (原子性):事务中包含的操作被看作一个整体的业务单元, 这个业务单元中的操作 要么全部成功,要么全部失败,不会出现部分失败、部分成功的场景。
  • Consistency (一致性):事务在完成时,必须使所有的数据都保持一致状态,在数据库中所有的修改都基于事务,保证了数据的完整性。
  • Isolation (隔离性) 这是我们讨论的核心内容,正如上述,可能多个应用程序线程同时访问同一数据,这样数据库同样的数据就会在各个不同的事务中被访问,这样会产生丢失更新。 为了压制丢失更新的产生,数据库定义了隔离级别的概念,通过它的选择,可以在不同程度 上压制丢失更新的发生。因为互联网的应用常常面对高并发的场景,所以隔离性是需要掌握 的重点内容。
  • Durability (持久性):事务结束后,所有的数据会固化到一个地方,如保存到磁盘当中,即 使断电重启后也可以提供给应用程序访问。

这4个特性,除了隔离性,都还是比较好理解的,所以这里会更为深入地讨论隔离性。在多个事务同时操作数据的情况下,会引发丢失更新的场景,例如,电商有一种商品,在疯狂抢购中,会出现多个事务同时访问商品库存的场景,这样就会产生丢失更新。下面假设一种商品的库存数量还有 100, 每次抢购都只能抢购1件商品, 那么在抢购中就可能出现下面这种场景。

时刻 事务1 事务2
T1 初始库存100 初始库存100
T2 扣减库存,余99
T3 扣减库存,余99
T4 提交事务,库存变为99
T5 提交事务,库存变为99

注意T5时刻提交的事务。因为在事务1中,无法感知事务2的操作,这样它就不知道事务2已经修改过了数据 ,因为它依旧认为只是发生了一笔业务,所以库存变为了 99 ,而这个结果又是一个 错误的结果。这样, T5时刻事务1提交的事务,就会引发事务2提交结果的丢失 ,为了克服这些问题,数据库提出了事务之间的隔离级别概念。

为了压制丢失问题,数据库提出了4类隔离级别,在不同程度上压制丢失更新,这4类隔离级别是未提交读、读写提交、可重复读和串行化,它们会在不通程度上压制丢失更新的情景。

也许你会有一个疑问,都全部消除丢失更新不就好了吗,为什么只是在不同的程度上压制丢失更新呢?其实这个问题是从两个角度去看的,一个是数据的一致性,另一个是性能。数据库现有的技术完全可以避免丢失更新,但是这样做的代价,就是付出锁的代价,在互联网中,系统不单单要考虑数据的一致性,还要考虑系统的性能。试想,在互联网中使用过多的锁,一旦出现商品抢购这样的场景必然会导致大量的线程被挂起和恢复,因为使用了锁之后,一个时刻只能有一个线程访问数据,这样整个系统就会十分缓慢,当系统被数千甚至数万用户同时访问时,过多的锁就会引发宕机,大部分用户线程被挂起,等待持有锁事务的完成,这样用户体验就会十分糟糕。因为用户等待的时间会十分漫长,一般而言,互联网系统响应超过5秒,就会让用户觉得很不友好,进而引发用户忠诚度下降的问题。所以选择隔离级别的时候,既需要考虑数据的一致性避免脏数据,又要考虑系统性能的问题。因此数据库的规范就提出了4种隔离级别来在不同的程度上压制丢失更新。下面我们通过商品抢购的场景来讲述这4种隔离级别的区别。

  1. 未提交读

    未提交读(read uncommitted)是最低的隔离级别,其含义是允许一个事务读取另外一个事务没 有提交的数据。未提交读是一种危险的隔离级别,所以一般在我们实际的开发中应用不广,但是它 的优点在于并发能力高,适合那些对数据一致性没有要求而追求高并发的场景,它的最大坏处是出现脏读。让我们看看可能发生的脏读场景。

    时刻 事务1 事务2 备注
    T0 ······ ······ 商品库存初始化为2
    T1 读取库存为2
    T2 扣减库存 库存为1
    T3 扣减库存 库存为0,读取事务1未提交的库存数据
    T4 提交事务 库存保存为0
    T5 回滚事务 库存为0,结果错误

    脏读一般是比较危险的隔离级别,在我们实际应用中采用的不多,为了克服脏读问题,数据库隔离级别还提供了读写提交(read commited)的级别。

2.读写提交

读写提交(read commited)隔离级别,是指一个事务只能读取另外一个事务已经提交的数据,不能读取未提交的数据。脏读的场景限制为读写提交之后,就变成下面这种场景。

时刻 事务1 事务2 备注
T0 ······ ······ 商品库存初始化为2
T1 读取库存为2
T2 扣减库存 库存为1
T3 扣减库存 库存为1,读取不到事务1未提交的库存数据
T4 提交事务 库存保存为1
T5 回滚事务 库存为1,结果正确

在T3时刻,由于采用读写提交的隔离级别,因此事务2不能读取事务1中未提交的库存1,所以扣减库存的结果依旧为1,然后它提交事务,则库存在T4时刻变为了1。T5时刻,事务1回滚,结果库存仍为1,这是一个正确结果。但是读写提交会产生不可重复读场景。

时刻 事务1 事务2 备注
T0 ······ ······ 商品库存初始化为1
T1 读取库存为1
T2 扣减库存 事务为提交
T3 读取库存为1 认为可扣减
T4 提交事务 库存变为0
T5 扣减失败 失败,因为此时库存为0,无法扣减

在T3时刻事务2读取库存的时候,因为事务1未提交事务,所以读出的库存为1,于是事务2 认为当前可扣减库存;在T4时刻,事务1己经提交事务,所以在T5时刻,它扣减库存的时候就发 现库存为0,于是就无法扣减库存了。这里的问题在于事务2之前认为可以扣减,而到扣减那一步却 发现己经不可以扣减,于是库存对于事务2而言是一个可变化的值,这样的现象我们称为不可重复读,这就是读写提交的一个不足。为了克服这个不足,数据库的隔离级别还提出了可重复读的隔离级别,它能够消除不可重读的问题。

3.可重复读

可重复读的目标是克服读写提交中出现的不可重复读的现象,因为在读写提交的时候,可能出现一些值的变化,影响当前事务的执行,如上述的库存是个变化的值,这个时候数据库提出了可重 复读的隔离级别 。这样就能够克服不可重复读的现象。

时刻 事务1 事务2 备注
T0 ······ ······ 商品库存初始化为1
T1 读取库存为1
T2 扣减库存 事务未提交
T3 尝试读取库存 不允许读取,等待事务1提交
T4 提交事务 库存变为0
T5 读取库存 库存为0,无法扣减

可以看到,事务 2在T3 时刻尝试读取库存,但是此时这个库存己经被事务1事先读取,所以这 个时候数据库就阻塞它的读取, 直至事务1提交,事务2才能读取库存的值 此时己经 是T5 时刻, 而读取到的值为0,这时就已经无法扣减了,显然在读写提交中出现的不可重复读的场景被消除了,但是这样也会引发新的问题的出现,这就是幻读。假设现在商品交易正在进行中,而后台有人也在 进行查询分析和打印的业务,让我们看看幻读的场景。

时刻 事务1 事务2 备注
T1 读取库存50件 商品库存初始化为100,现在已经销售50笔,库存50件
T2 查询交易记录,50笔
T3 扣减库存
T4 插入一笔交易记录
T5 提交事务 库存49件,交易记录51笔
T6 打印交易记录,51笔 这里与查询的不一致,在事务2看来有一笔是幻读的,与之前查询的不一致。

这便是幻读现象。可重复读和幻读,是比较难以理解的内容,这里稍微论述一下。首先这里的笔数不是数据库存储的值,而是一个统计值,商品库存则是数据库存储的值,这一点是要注意的。也就是幻读不是针对一条数据库记录而言,而是多条记录,例如,这51 笔交易笔数就是多条数据库记录统计出来的,而可重复读是针对数据库的单一条记录,例如,商品的库存是以数据库里面的一条记录存储的,它可以产生可重复读,而不能产生幻读

4.串行化

串行化( Serializable)是数据库最高的隔离级别,它会要求所有的 SQL 都会按照顺序执行,这 样就可以克服上述隔离级别出现的各种问题,所以它能够完全保证数据的一致性。

  1. 使用合理的隔离级别

    隔离级别和可能发生的现象

    隔离级别 脏读 不可重复读 幻读
    未提交读
    读写提交 ×
    可重复读 × ×
    串行化 × × ×

    在现实中一般而言,选择隔离级别会以读写提交为主,它能够防止脏读,而不能避免不可 重复读和幻读。为了克服数据不一致和性能问题,程序开发者还设计了乐观锁,甚至不再使用数据 库而使用其他的手段。例如,使用 Redis作为数据载体。对于隔离 级别,不同的数据库的支持也是不一样的。例如, Oracle 只能支持读写提交和串行化,而 MySQL则能够支持4种,对于 Oracle 默认的隔离级别为读写提交, MySQL 则是可重复读,这些需要根据具体 数据库来决定。

    只要掌握了隔离级别的含义,使用隔离级别就很简单,只需要在@Transactiona 配置对应即可

    @Transactional(isolation = Isolation.SERIALIZABLE)
    

    上面的代码中我们使用了序列化的隔离级别来保证数据的一致性,这使它将阻塞其他的事务进 行并发,所以它只能运用在那些低井发而又需要保证数据一致性的场景下。对于高井发下又要保证 数据一致性的场景,则需要另行处理了。 当然,有时候一个个地指定隔离级别会很不方便,因此 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, 说明将数据库的隔离级别默认为读写提交。

传播行为

package org.springframework.transaction.annotation;

public enum Propagation {
    /**
     * 需要事务,它是默认传播行为,如果当前存在事务,就沿用当前事务,
     * 否则新建一个事务运行子方法
     */
    REQUIRED(0),
     /**
     * 支持事务,如果当前存在事务,就沿用当前事务,
     * 如果不存在,则继续采用无事务的方式运行子方法
     */
    SUPPORTS(1),
     /**
     * 必须使用事务,如果当前没有事务,则会抛异常,
     * 如果存在当前事务,就沿用当前事务
     */
    MANDATORY(2),
     /**
     * 无论当前事务是否存在,都会创建新的事务运行方法,
     * 这样新事务就可以拥有新的锁和隔离级别等特性,与当前事务相互独立
     */
    REQUIRES_NEW(3),
     /**
     * 不支持事务,当前存在事务时,将挂起事务,运行方法
     */
    NOT_SUPPORTED(4),
     /**
     * 不支持事务,如果当前方法存在事务,则抛出异常,否则继续使用无事务机制运行
     */
    NEVER(5),
     /**
     * 在当前方法调用子方法时,如果子方法发生异常,
     * 只回滚子方法执行过的SQL,而不回滚当前方法的事务
     */
    NESTED(6);

    private final int value;

    private Propagation(int value) {
        this.value = value;
    }

    public int value() {
        return this.value;
    }
}