聊聊数据库的事务

友情提示:为了将知识点讲清楚,本篇博客文字性内容可能会多,建立分段阅读,切勿烦躁,重要文字小编已使用不同格式标注出来了。

事务的介绍

事务是什么?

事务(Transaction)是并发控制的基本单位。所谓的事务,它是一个 操作序列,这些操作要么都执行,要么都不执行,它是一个 不可分割 的工作单位。

事务有哪些特征(ACDI)?

  • 原子性(Atomicity):事务是一个不可分割的最小单位,事务中的操作要么都执行成功,要么都执行失败。举例银行转账,转账人扣款和收款人收款,这两个步骤要么同时成功,要么同时失败,不能只发生其中一个步骤。
  • 一致性(Consistency):事务执行前后,数据库数据的完整性必须保持一致。举例:银行转账前后双方的金额总数保持一致。
  • 隔离性(Isolation):多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。举例多个用户操纵,防止数据干扰,就要为每个客户开启一个自己的事务。
  • 持久性(Durability):一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障(如断电重启)也不应该对其有任何影响。 举例:如果使用 commit 将事务提交后,无论发生什么都 都不会影响到我提交的数据。

事务有什么用?

举个例子:李明在超市买了100元商品,使用微信支付给超市支付100元,这里其实有两步操作。

  • 第一步:李明微信余额扣款100元。
  • 第二步:超市账户余额收款100元。

假如第一步刚执行完,超市就停电了,李明微信余额被扣了100元,而超市账户这边却没收到钱,这不就出大问题了吗,不得打架了吗?

为了解决数据库数据一致性问题,数据库事务应运而生。

在JDBC中使用事务

不过无论如何都应该先配置数据库的信息,所以我们先在 application.properties 中进行如下代码配置。

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring_boot_chapter6
spring.datasource.username=root
spring.datasource.password=123456
#最大等待连接中的数量,设0为没有限制
spring.datasource.tomcat.max-idle=10
#最大连接活动数
spring.datasource.tomcat.max-active=50
#最大等待毫秒数,单位为ms,超过时间会出错误信息
spring.datasource.tomcat.max-wait=10000
#数据库连接池初始化连接数
spring.datasource.tomcat.initial-size=5

#日志配置
logging.level.root=debug
logging.level.org.springframework=debug
logging.level.org.mybatis=debug

通过这样的配置就己经在项目中定义好了数据库连接池,这样就可以使用数据库了,本篇文章后面的内容就可以使用它了。在 Spring 数据库事务中可以使用 编程式事务,也可以使用 声明式事务。大部分的情况下,会使用声明式事务。编程式事务这种比较底层的方式已经基本被淘汰了,SpringBoot 也不推荐我们使用,因此这里不再讨论编程式事务。这里将日志降低为 DEBUG 级别,这样就可以看到很详细的日志了,这样有助于观察 Spring 数据库事务机制的运行。由于目前 MyBatis 已经被广泛地使用在持久层中,因此本篇文章将以 MyBatis 框架作为持久层进行论述。

扩展:声明式事务和编程式事务的区别?

  • 声明式事务:通过AOP(面向切面)方式在方法前使用编程式事务的方法开启事务,在方法后提交或回滚。用配置文件的方法或注解方法(如:@Transactional)控制事务。
  • 编程式事务:手动开启、提交、回滚事务。

为了让读者有更加直观的认识,我们先从JDBC的代码入手,这里采用的就是 编程式事务。首先是一段熟悉的JDBC进行插入用户的测试:

package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.service.JdbcService;
import org.apache.ibatis.session.TransactionIsolationLevel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

@Service
public class JdbcServiceImpl implements JdbcService {

    @Autowired
    private DataSource dataSource;


    @Override
    public int insertUser(String userName, String note) {
        Connection conn = null;
        int result = 0;
        try {
            // 获取连接
            conn = dataSource.getConnection();
            // 开启事务
            conn.setAutoCommit(false);
            // 设置隔离级别
            conn.setTransactionIsolation(TransactionIsolationLevel.READ_COMMITTED.getLevel()); // 读已提交
            // ---------- 业务代码 ----------
            // 执行SQL
            PreparedStatement ps = conn.prepareStatement("insert into t_user(user_name,note) values(?,?)");
            ps.setString(1,userName);
            ps.setString(2,note);
            result = ps.executeUpdate();
            // -----------------------------
            // 提交事务
            conn.commit();
        }catch (Exception e){
            // 回滚事务
            if (conn != null) {
                try {
                    conn.rollback();
                } catch (SQLException el) {
                    el.printStackTrace();
                }
            }
            e.printStackTrace();
        } finally {
            // 关闭数据库连接
            try {
                if (conn != null && conn.isClosed()) {
                    conn.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        return result;
    }
}

阅读代码可以发现,业务代码只有那么一丢丢,其他的都是有关JDBC的功能代码,我们看到了数据库连接的获取和关闭以及事务的提交和回滚、大量的 try... catch... finally... 语句。要知道,我们只是执行一条SQL而己,如果执行多条SQL,这代码显然是更加难以控制的。

于是人们就开始不断地优化,使用 Hibernate、MyBatis 都可以减少这些代码,但是依旧不能减少开闭数据库连接和事务控制的代码,而 AOP 给这些带来了福音。知道 AOP 的小伙伴们,可以知道 AOP 允许我们把那些公共的代码抽取出来,单独实现,为了更好地论述,下面画出上面代码执行SQL的流程图,如【图6-1】所示。
在这里插入图片描述
这个流程与我们 AOP 约定流程十分接近,而在图中,有业务逻辑的部分也只是执行SQL那一步骤,其他的步骤都是比较固定的,按照 AOP 的设计思想,就可以把除执行SQL这步之外的步骤抽取出来单独实现,这便是 Spring 数据库事务编程的思想。

Spring声明式事务的使用

我们要使用Spring为我们提供的声明式事务之前,要先掌握以下约定。

Spring声明式数据库事务约定

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

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

有了 @Transactional 的配置,Spring 就会知道在哪里启动事务机制,其约定流程如【图6-2】所示。
在这里插入图片描述
因为这个约定非常重要,所以这里做进一步的讨论。

当 Spring 的上下文开始调用被 @Transactional 标注的类或者方法时,Spring 就会产生 AOP 的功能。请注意事务的底层需要启用 AOP 功能,这是Spring事务的底层实现,后面我们会看到一些陷阱。那么当它启动事务时,就会根据 事务定义器 内的配置去设置事务,首先是根据 传播行为 去确定事务的策略。有关传播行为后面我们会再谈,这里暂且放下。然后是 隔离级别、超时时间、只读 等内容的设置,只是这步设置事务并不需要开发者完成,而是 Spring事务拦截器 根据 @Transactional 配置的内容来完成的。

在上述场景中,Spring通过对注解 @Transactional 属性配置去设置数据库事务,跟着 Spring 就会开始调用开发者编写的业务代码。执行开发者的业务代码,可能发生异常,也可能不发生异常。在 Spring 数据库事务的流程中,它会根据是否发生异常采取不同的策略。

如果都没有发生异常,Spring 数据库拦截器就会帮助我们提交事务,这点也并不需要我们干预。如果发生异常,就要判断一次事务定义器内的配置,如果事务定义器己经约定了该类型的异常不回滚事务就提交事务,如果没有任何配置或者不是配置不回滚事务的异常,则会回滚事务,并且将异常抛出,这步也是由事务拦截器完成的。

无论发生异常与否,Spring 都会释放事务资源,这样就可以保证数据库连接池正常可用了,这也是由 Spring 事务拦截器完成的内容。

在上述场景中,我们还有一个重要的事务配置属性没有讨论,那就是 传播行为。它是属于事务方法之间调用的行为,后面我们会对其做更为详细的讨论。但是无论怎么样,从流程中我们可以看到开发者在整个流程中只需要完成业务逻辑即可,其他的使用Spring事务机制和其配置即可,这样就可以把 try... catch... finally... 、数据库连接管理和事务提交回滚的代码交由 Spring 拦截器完成,而只需要完成业务代码即可,所以你可以经常看到下面代码所示的简洁代码。

package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.dao.UserDao;
import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;

    @Override
    @Transactional // 声明式事务注解
    public int insertUser(User user) {
        return userDao.insertUser(user);
    }
}

这里仅仅是使用一个 @Transactional 注解,标识 insertUser 方法需要启动事务机制,那么 Spring 就会按照【图6-2】那样,把 insertUser 方法织入约定的流程中,这样对于数据库连接的闭合、事务提交与回滚都不再需要我们编写任何代码了,可见这是十分便利的。从代码中,可以看到只需要完成对应的业务逻辑便可以了,这样就可以大幅减少代码,同时代码也具备更高的可读性和可维护性。

@Transactional 的配置项

注解 @Transactional 的源码分析:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

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;

@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 -1;

	// 是否只读事务
    boolean readOnly() default false;

	// 方法在发生指定异常时回滚,默认是所有异常都回滚
    Class<? extends Throwable>[] rollbackFor() default {};

	// 方法在发生指定异常名称时回滚,默认是所有异常都回滚
    String[] rollbackForClassName() default {};

	// 方法在发生指定异常时不回滚,默认是所有异常都回滚
    Class<? extends Throwable>[] noRollbackFor() default {};

	// 方法在发生指定异常名称时不回滚,默认是所有异常都回滚
    String[] noRollbackForClassName() default {};
}

valuetransactionManager 属性是配置一个 Spring的事务管理器,关于它后面会进行详细讨论;timeout 是事务可以允许存在的时间戳,单位为秒;readOnly 属性定义的是事务是否是只读事务;rollbackForrollbackForClassNamenoRollbackFornoRollbackForClassName 都是指定异常,我们从流程中可以看到在带有事务的方法时,可能发生异常,通过这些属性的设置可以指定在什么异常的情况下依旧提交事务,在什么异常的情况下回滚事务,这些可以根据自己的需要进行指定。以上这些都比较好理解,真正麻烦的是 propagationisolation 这两个属性。propagation 指的是 传播行为isolation 则是 隔离级别,它需要了解数据库的特性才能使用,而这两个麻烦的东西,就是本篇文章的核心内容,也是互联网企业最为关心的内容之一,因此值得我们后面花较大篇幅去讲解它们的内容和使用方法。由于这里使用到了事务管理器,所以我们接下来先讨论一下Spring的事务管理器。

关于注解 @Transactional 值得注意的是它可以放在接口上,也可以放在实现类上。但是 Spring 团队推荐放在实现类上,因为放在接口上将使得你的类基于接口的代理时它才生效。学习过 AOP 的小伙伴们,就能知道在 Spring 可以使用 JDK动态代理,也可以使用 CGLIG动态代理。如果使用接口,那么你将不能切换为CGLIB动态代理,而只能允许你使用JDK动态代理,并且使用对应的接口去代理你的类,这样才能驱动这个注解,这将大大地限制你的使用,因此在实现类上使用 @Transactional 注解才是最佳的方式。

Spring 事务管理器

上述的事务流程中,事务的 打开、回滚和提交 是由 事务管理器 来完成的。在Spring中,事务管理器的顶层接口为 PlatformTransactionManager,Spring 还为此定义了一些列的接口和类,如【图6-3】所示。
在这里插入图片描述
当我们引入其他框架时,还会有其他的事务管理器的类,比方说我们引入 Hibernate,那么 Spring orm 包还会提供 HibernateTransactionManager 与之对应并给我们使用。因为本篇文章会以 MyBatis 框架去讨论 Spring 数据库事务方面的问题,最常用到的事务管理器是 DataSourceTransactionManager。从【图6-3】可以看到它也是一个实现了接口 PlatfonnTransactionManager 的类,为此可以看到 PlatformTransactionManager接口的源码,如一下代码所示:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface PlatformTransactionManager extends TransactionManager {
	// 获取事务,它还会设置数据属性
    TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException;

	// 提交事务
    void commit(TransactionStatus var1) throws TransactionException;

	// 回滚事务
    void rollback(TransactionStatus var1) throws TransactionException;
}

显然这些方法并不难理解,只需要简单地介绍一下它们便可以了。Spring 在事务管理时,就是将这些方法按照约定织入对应的流程中的,其中 getTransaction 方法的参数是一个 事务定义器TransactionDefinition),它是依赖于我们配置的 @Transactional 的配置项生成的,于是通过它就能够设置事务的属性了,而提交和回滚事务也就可以通过 commit 和 rollback 方法来执行。在 SpringBoot 中,当你依赖于 mybatis-spring-boot-starter 之后,它会自动创建一个 DataSource­TransactionManager 对象,作为事务管理器,如果依赖于 spring-boot-starter-data-jpa,则它会自动创建 JpaTransactionManager 对象作为事务管理器,所以我们一般不需要自己创建事务管理器而直接使用它们即可。

测试数据库事务

首先我们来创建一张表,SQL语句如下:

-- 创建用户表
create table t_user (
	id int(12) auto_increment,
	user_name varchar(60) not null,
	note varchar(512),
	primary key(id)
)

为了与它映射起来,需要使用一个POJO,代码如下:

package com.springboot.chapter6.pojo;

public class User {
    private Long id;
    private String userName;
    private String note;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public String getNote() {
        return note;
    }

    public void setNote(String note) {
        this.note = note;
    }
}

再给出一个MyBatis接口:

package com.springboot.chapter6.dao;

import com.springboot.chapter6.pojo.User;
import org.springframework.stereotype.Repository;

@Repository
public interface UserDao {
	// 获取用户信息
    User getUser(Long id);
    // 新增用户
    int insertUser(User user);
}

接着是与这个MyBatis接口文件对应的一个映射文件,代码如下,它提供SQL与相关POJO的映射规则。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.springboot.chapter6.dao.UserDao">
    <select id="getUser" parameterType="long" resultType="com.springboot.chapter6.pojo.User">
        select id, user_name as userName, note from t_user where id= #{id}
    </select>
    
    <insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
        insert into t_user(user_name,note) value(#{userName},#{note})
    </insert>
</mapper>

<insert> 元素定义的属性 useGeneratedKeyskeyProperty,则表示在插入之后使用数据库生成机制回填对象的主键。

接着需要创建一个 UserService 和它的实现类 UserServicelmpl,然后通过 @Transactioanal 启用 Spring数据库事务机制,代码如下:

package com.springboot.chapter6.service;

import com.springboot.chapter6.pojo.User;

public interface UserService {
    // 获取用户信息
    User getUser(Long id);
    // 新增用户
    int insertUser(User user);
}
package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.dao.UserDao;
import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,timeout = 1)
    public User getUser(Long id) {
        return userDao.getUser(id);
    }

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,timeout = 1)
    public int insertUser(User user) {
        return userDao.insertUser(user);
    }
}

代码中的方法上标注了注解 @Transactional,意味着这两个方法将启用 Spring数据库事务机制。在事务配置中,采用了 读写提交 的隔离级别,后面我们将讨论隔离级别的含义,这里的代码还会限制超时时间为 1s。然后可以写一个控制器,用来测试事务的启用情况,代码所下:

package com.springboot.chapter6.controller;

import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.HashMap;
import java.util.Map;

@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    // 测试获取用户
    @RequestMapping("/getUser")
    @ResponseBody
    public User getUser(Long id){
        return userService.getUser(id);
    }

    // 测试插入用户
    @RequestMapping("/insertUser")
    @ResponseBody
    public Map<String,Object> insertUser(String userName,String note){
        User user = new User();
        user.setUserName(userName);
        user.setNote(note);
        // 结果会回填主键,返回插入条数
        int update = userService.insertUser(user);
        Map<String,Object> result = new HashMap<>();
        result.put("success",update == 1);
        result.put("user",user);
        return result;
    }
}

有了这个控制器,我们还需要给 SpringBoot 配置 MyBatis 框架的内容,于是需要在配置文件 application.properties 中加入以下代码:

#MyBatis映射文件通配
mybatis.mapper-locations=classpath:mapper/*.xml

这样 MyBatis 框架就配置完了。依赖于 mybatis-spring-boot-starter 之后,SpringBoot 会自动创建事务管理器、MyBatis 的 SqlSessionFactory 和 SqlSessionTemplate等内容。下面我们需要配置 SpringBoot 的运行文件,以达到测试的目的,并且查看 SpringBoot 自动为我们创建的事务管理器、SqlSessionFactory 和 SqISessionTemplate 信息。启动文件如下代码:

package com.springboot.chapter6;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.transaction.PlatformTransactionManager;

import javax.annotation.PostConstruct;

@SpringBootApplication
@MapperScan(basePackages = "com.springboot.chapter6.dao")
public class Chapter6Application {

    public static void main(String[] args) {
        SpringApplication.run(Chapter6Application.class, args);
    }

    // 注入事务管理器,它由SpringBoot自动生成
    @Autowired
    PlatformTransactionManager transactionManager;

    // 使用后初始化方法,观察自动生成的事务管理器
    @PostConstruct
    public void viewTransactionManager(){
        // 启动前加入断点观测
        System.out.println(transactionManager.getClass().getName());
    }
}

首先这里使用了 @MapperScan 扫描对应的包,这样就可以把 MyBatis 对应的接口文件扫描到 SpringIoC 容器中了。这里通过注解 @Autowired 直接注入了事务管理器,它是通过 SpringBoot 的机制自动生成的,并不需要我们去关心;而在 viewTransactionManager 方法中,加入了注解 @PostConstruct,所以在这个类对象被初始化后,会调用这个方法,在这个方法中,因为先前已经将 IoC 容器注入进来,所以可以通过 IoC 容器获取对应的 Bean 以监控它们,并且在控制台打印的地方加入断点,这样我们以 debug 的方式启动它就可以进入断点了。下图就是我在启动时监控得到的内容。
在这里插入图片描述
从图中可以看到,SpringBoot 已经生成了事务管理器,这便是 SpringBoot 的魅力,允许我们以最小的配置代价运行 Spring 的项目。那么按照之前的约定使用注解 @Transactional 标注类和方法后,Spring 的事务拦截器就会同时使用事务管理器的方法开启事务,然后将代码织入 Spring 数据库事务的流程中,如果发生异常,就会回滚事务,如果不发生异常,那么就会提交事务,这样就我们从大量的冗余代码中解放出来了,所以我们可以看到在服务类(Service)中代码是比较简单明了的。下面我们打开浏览器,在地址栏中输入http://localhost:8080/user/insertUser?userName=zhangsan&note=zs,就能看到日志打印出来:
在这里插入图片描述
从日志中,我们可以看到 Spring 获取了数据库连接,并且修改了隔离级别,然后执行SQL,在最后会自动地关闭和提交数据库事务,因为我们对方法标注了 @Transactional,所以 Spring 会把对应方法的代码织入约定的事务流程中。

隔离级别

上面我们只是简单地使用事务,这里将讨论 Spring事务机制中最重要的两个配置项,即 隔离级别传播行为。毫无疑问这两部分内容是本篇文章的核心内容,也是互联网企业最为关注的内容之一,因此它们十分重要,值得花上大篇幅去讨论。我们从这两个配置项的大概含义谈起。首先是隔离级别,因为互联网应用时刻面对着高并发的环境,如商品库存,时刻都是多个线程共享的数据,这样就会在多线程的环境中扣减商品库存。对于数据库而言,就会出现多个事务同时访问同一记录的情况,这样引起数据出现不一致的情况,便是数据库的 丢失更新(LostUpdate)问题。

应该说,隔离级别是数据库的概念,有些难度,所以在使用它之前应该先了解数据库的相关知识,不太了解的小伙伴们可以回头看看文章开头对数据库事务的介绍。

Isolation (隔离性):这是我们讨论的核心内容,正如上述,可能多个应用程序线程同时访问同一数据,这样数据库同样的数据就会在各个不同的事务中被访问,这样会产生丢失更新。为了压制丢失更新的产生,数据库定义了隔离级别的概念,通过它的选择,可以在不同程度上压制丢失更新的发生。因为互联网的应用常常面对高并发的场景,所以隔离性是需要掌握的重点内容。

多个事务同时操作数据的情况下,会引发丢失更新的场景,例如,电商有一种商品,在疯狂抢购中,会出现多个事务同时访问商品库存的场景,这样就会产生丢失更新。一般而言,存在两种类型的丢失更新,让我们了解下它们。下面假设一种商品的库存数量还有 100,每次抢购都只能抢购 1 件商品,那么在抢购中就可能出现如【表6-1】所示的场景。
在这里插入图片描述
可以看到,T5 时刻事务 1 回滚,导致原本库存为99 的变为了 100,显然事务 2 的结果就丢失了,这就是一个错误的值。类似地,对于这样一个事务回滚另外一个事务提交而引发的数据不一致的情况,我们称为 第一类丢失更新。然而它却没有讨论的价值,因为目前大部分数据库已经克服了第一类丢失更新的问题,也就是现今数据库系统已经不会再出现【表6-1】的情况了。所以对于这样的场景不再深入讨论,而是讨论第二类丢失更新,也就是多个事务都提交的场景。

如果是多个事务并发提交,会出现怎么样的不一致的场景呢?例如可能发生如【表6-2】所示的场景。
在这里插入图片描述
注意T5 时刻提交的事务。因为在事务 1 中,无法感知事务 2 的操作,这样它就不知道事务 2 已经修改过了数据,因此它依旧认为只是发生了一笔业务,所以库存变为了 99,而这个结果又是一个错误的结果。这样,T5 时刻事务 1 提交的事务,就会引发事务 2 提交结果的丢失,我们把这样的多个事务都提交引发的丢失更新称为 第二类丢失更新。这是我们互联网系统需要关注的重点内容。为了克服这些问题,数据库提出了事务之间的隔离级别的概念,这就是本篇文章的重点内容之一。

详解隔离级别:

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

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

1、未提交读

未提交读(readuncommitted)是 最低 的隔离级别,其含义是允许一个事务读取另外一个事务没有提交的数据。未提交读是一种 危险 的隔离级别,所以一般在我们实际的开发中应用不广,但是它的优点在于并发能力高,适合那些对数据一致性没有要求而追求高并发的场景,它的最大坏处是出现 脏读。让我们看看可能发生的脏读场景,如【表6-3】所示。
在这里插入图片描述
【表6-3】中的T3 时刻,因为采用未提交读,所以事务 2 可以读取事务 1 未提交的库存数据为 1,这里当它扣减库存后则数据为 0,然后它提交了事务,库存就变为了 0,而事务 1 在T5 时刻回滚事务,因为第一类丢失更新已经被克服,所以它不会将库存回滚到 2,那么最后的结果就变为了 0,这样就出现了错误。脏读一般是比较危险的隔离级别,在我们实际应用中采用得不多。为了克服脏读的问题,数据库隔离级别还提供了读写提交(readcommited)的级别,下面我们就讨论它。

2、读写提交

读写提交(readcommitted)隔离级别,是指一个事务只能读取另外一个事务已经提交的数据,不能读取未提交的数据。例如,【表6-3】的场景在限制为读写提交后,就变为【表6-4】描述的场景了。
在这里插入图片描述
在T3 时刻,由于采用了读写提交的隔离级别,因此事务2 不能读取到事务 1 中未提交的库存 1,所以扣减库存的结果依旧为 1,然后它提交事务,则库存在T4 时刻就变为了 1。T5时刻,事务1 回滚,因为第一类丢失更新己经克服,所以最后结果库存为 1,这是一个正确的结果。但是读写提交也会产生下面的问题,如【表6-5】所描述的场景。
在这里插入图片描述
在T3 时刻事务 2 读取库存的时候,因为事务 1 未提交事务,所以读出的库存为 1,于是事务 2 认为当前可扣减库存;在T4 时刻,事务 1 己经提交事务,所以在T5 时刻,它扣减库存的时候就发现库存为 0,于是就无法扣减库存了。这里的问题在于事务 2 之前认为可以扣减,而到扣减那一步却发现已经不可以扣减,于是库存对于事务 2 而言是一个可变化的值,这样的现象我们称为 不可重复读,这就是读写提交的一个不足。为了克服这个不足,数据库的隔离级别还提出了可重复读的隔离级别,它能够消除不可重读的问题。

3、可重复读

可重复读的目标是克服读写提交中出现的不可重复读的现象,因为在读写提交的时候,可能出现一些值的变化,影响当前事务的执行,如上述的库存是个变化的值,这个时候数据库提出了可重复读的隔离级别。这样就能够克服不可重复读的现象如【表6-6】所示。
在这里插入图片描述
可以看到,事务 2 在T3 时刻尝试读取库存,但是此时这个库存已经被事务 1 事先读取,所以这个时候数据库就阻塞它的读取,直至事务 1 提交,事务 2 才能读取库存的值。此时已经是T5 时刻,而读取到的值为 0,这时就已经无法扣减了,显然在读写提交中出现的不可重复读的场景被消除了。但是这样也会引发新的问题的出现,这就是 幻读。假设现在商品交易正在进行中,而后台有人也在进行查询分析和打印的业务,让我们看看如【表6-7】所示可能发生的场景。
在这里插入图片描述

4、串行化

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

使用合理的隔离级别

通过上面的讲述,读者应该对隔离级别有了更多的认识,使用它能够在不同程度上压制丢失更新,于是可以总结成如【表6-8】所示的一张表。
在这里插入图片描述
作为互联网开发人员,在开发高并发业务时需要时刻记住隔离级别可能发生的各种概念和相关的现象,这是数据库事务的核心内容之一,也是互联网企业关注的重要内容之一。追求更高的隔离级别,它能更好地保证了数据的一致性,但是也要付出锁的代价。有了锁,就意味着性能的丢失,而且 隔离级别越高,性能就越是直线地下降。所以我们在选择隔离级别时,要考虑的不单单是数据一致性的问题,还要考虑系统性能的问题。例如,一个高并发抢购的场景,如果采用串行化隔离级别,能够有效避免数据的不一致性,但是这样会使得并发的各个线程挂起,因为只有一个线程可以操作数据,这样就会出现大量的线程挂起和恢复,导致系统缓慢。而后续的用户要得到系统响应就需要等待很长的时间,最终因为响应缓慢,而影响他们的忠诚度。所以在现实中一般而言,选择隔离级别会以读写提交为主,它能够防止脏读,而不能避免不可重复读和幻读。为了克服数据不一致和性能问题,程序开发者还设计了乐观锁,甚至不再使用数据库而使用其他的手段。例如,使用 Redis 作为数据载体,这些内容我们会在后续章节谈及。对于隔离级别,不同的数据库的支持也是不一样的。例如,Oracle 只能支持读写提交和串行化,而 MySQ L则能够支持4种,对于 Oracle 默认的隔离级别为 读写提交,MySQL则是 可重复读,这些需要根据具体数据库来决定。只要掌握了隔离级别的含义,使用隔离级别就很简单,只需要在 @Transactional 配置对应即可,如下代码所示。

@Transactional(isolation = Isolation.SERIALIZABLE)
public int insertUser(User user) {
    return userDao.insertUser(user);
}

上面的代码中我们使用了序列化的隔离级别来保证数据的一致性,这使它将阻塞其他的事务进行并发,所以它只能运用在那些低井发而又需要保证数据一致性的场景下。对于高并发下又要保证数据一致性的场景,则需要另行处理了。

当然,有时候一个个地指定隔离级别会很不方便,因此 SpringBoot 可以通过配置文件指定默认的隔离级别。例如,当我们需要把隔离级别设置为读写提交时,可以在 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,说明将数据库的隔离级别默认为读写提交。

传播行为

传播行为是 方法之间调用事务采取的策略问题。在绝大部分的情况下,我们会认为数据库事务要么全部成功,要么全部失败。但现实中也许会有特殊的情况。例如,执行一个批量程序,它会处理很多的交易,绝大部分交易是可以顺利完成的,但是也有极少数的交易因为特殊原因不能完成而发生异常,这时我们不应该因为极少数的交易不能完成而回滚批量任务调用的其他交易,使得那些本能完成的交易也变为不能完成了。此时,我们真实的需求是,在一个批量任务执行的过程中,调用多个交易时,如果有一些交易发生异常,只是回滚那些出现异常的交易,而不是整个批量任务,这样就能够使得那些没有问题的交易可以顺利完成,而有问题的交易则不做任何事情,如【图6-5】所示。
在这里插入图片描述
在 Spring 中,当一个方法调用另外一个方法时,可以让事务采取不同的策略工作,如新建事务或者挂起当前事务等,这便是事务的传播行为。这样讲还是有点抽象,我们再回到【图6-5】中。图中,批量任务 我们称之为 当前方法,那么 批量事务 就称为 当前事务,当它调用单个交易时,称单个交易为 子方法,当前方法调用子方法的时候,让每一个子方法不在当前事务中执行,而是创建一个新的事务去执行子方法,我们就说当前方法调用子方法的传播行为为 新建事务。此外,还可能让子方法在 无事务独立事务 中执行,这些完全取决于你的业务需求。

传播行为的定义

在 Spring 事务机制中对数据库存在 7 种传播行为,它是通过枚举类 Propagation 定义的。下面先来研究它的源码,如下代码所示。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

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;
    }
}

以上代码中加入中文注释解释了每一种传播行为的含义。传播行为一共分为 7 种,但是常用的只有上面代码标注的 3 种,其他的使用率比较低。基于实用的原则,本篇文章只讨论这 3 种传播行为。下面的小节将对这 3 种传播行为进行测试。

测试传播行为

本节中我们继续沿用上个章节的代码来测试 RQUIREDREQUIRES_NEWNESTED 这 3 种最常用的传播行为。这里让我们新建服务接口 UserBatchService 和它的实现类 UserBatchServiceimpl 。它是一个批量应用,用来批量新增用户。代码如下所示。

package com.springboot.chapter6.service;

import com.springboot.chapter6.pojo.User;

import java.util.List;

public interface UserBatchService {
	// 批量新增用户
    int insertUsers(List<User> userList);
}
package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserBatchService;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserBatchServiceImpl implements UserBatchService {

    @Autowired
    private UserService userService;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int insertUsers(List<User> userList) {
        int count = 0;
        for (User user : userList) {
            // 调用子方法,将使用@Transactional定义的传播行为
            count += userService.insertUser(user); // 标记
        }
        return count;
    }
}

注意标记的代码。这里它将调用上节代码的 insertUser方法,只是 insertUser 方法中没有定义传播行为。按照我们之前的论述,它会采用 RQUIRED,也就是沿用当前的事务,所以它将与 insertUsers 方法使用同一个事务。下面我们在上节的用户控制器(UserController)的基础上新增一个方法测试它,代码如下所示。

@Autowired
private UserBatchService userBatchService;

@RequestMapping("/insertUsers")
@ResponseBody
public Map<String,Object> insertUsers(String userName1,String note1,
                                      String userName2,String note2){
    User user1 = new User();
    user1.setUserName(userName1);
    user1.setNote(note1);

    User user2 = new User();
    user2.setUserName(userName2);
    user2.setNote(note2);

    List<User> userList = new ArrayList<>();
    userList.add(user1);
    userList.add(user2);
    // 结果会回填主键,返回插入条数
    int inserts = userBatchService.insertUsers(userList);
    Map<String,Object> result = new HashMap<>();
    result.put("success",inserts > 0);
    result.put("user",userList);
    return result;
}

这样我们就可以通过请求这个方法来测试用户批量插入了。在浏览器地址栏中输入请求 http://localhost:8080/user/insertUsers?userName1=usemame_1&note1=note_1&userName2=usemame_2&note2=note_2,就可以观察后台日志。我的日志如下:
在这里插入图片描述

通过加粗的日志部分,我们可以看到都是在沿用已经存在的当前事务。接着我们把上面代码中调用的 insertUser 方法的注解给修改一下,代码如下所示。

@Override
@Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRES_NEW)
public int insertUser(User user) {
    return userDao.insertUser(user);
}

再进行测试,可以得到如下日志:
在这里插入图片描述
在日志中,为了更好地让读者理解,加粗的文字是我加进去的。从日志中可以看到,它启用了新的数据库事务去运行每一个 insertUser 方法,并且独立提交,这样就完全脱离了原有事务的管控,每一个事务都可以拥有自己独立的隔离级别和锁。

最后,我们再测试 NESTED 隔离级别。它是一个如果子方法回滚而当前事务不回滚的方法,于是我们再把上面的代码修改为如下代码,进行测试。

@Override
@Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.NESTED)
public int insertUser(User user) {
    return userDao.insertUser(user);
}

再次运行程序,可以看到如下日志:
在这里插入图片描述
在大部分的数据库中,一段SQL语句中可以设置一个标志位,然后后面的代码执行时如果有异常,只是回滚到这个标志位的数据状态,而不会让这个标志位之前的代码也回滚。这个标志位,在数据库的概念中被称为 保存点(savepoint) 。从加粗日志部分可以看到,Spring为我们生成了 nested 事务,而从其日志信息中可以看到保存点的释放,可见Spring也是使用保存点技术来完成让子事务回滚而不致使当前事务回滚的工作。注意,并不是所有的数据库都支持保存点技术,因此 Spring 内部有这样的规则:当数据库支持保存点技术时,就启用保存点技术;如果不能支持,就新建一个事务去运行你的代码,即等价于 REQUIRES_NEW 传播行为。NESTED 传播行为和 RQUIRES_NEW 还是有区别的。NESTED 传播行为会沿用当前事务的隔离级别和锁等特性,而 RQUIRES_NEW 则可以拥有自己独立的隔离级别和锁等特性,这是在应用中需要注意的地方。

@Transactional 自调用失效问题

@Transactional 在某些场景下会失效,这是要注意的问题。在上节中,我们测试传播行为,是使用了一个 UserBactchServicelmpl 类去调用 UserServiceImpl 类的方法,那么如果我们不创建UserBactchServicelmpl 类,而只是使用 UserServicelmpl 类进行批量插入用户会怎么样呢?下面我们改造 UserServicelmpl,代码如下所示。

package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.dao.UserDao;
import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;


    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int insertUsers(List<User> userList) {
        int count = 0;
        for (User user : userList) {
            // 调用自己类自身的方法,产生自调用方法
            count += insertUser(user);
        }
        return count;
    }

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRES_NEW)
    public int insertUser(User user) {
        return userDao.insertUser(user);
    }
}

代码中新增了方法 insertUsers,对应的接口也需要改造,这步比较简单就不再演示了。对于 insertUser方法,我们把传播行为修改为 REQUIRES_NEW,也就是每次调用产生新的事务,而 insertUsers 方法就调用了这个方法。这是一个类自身方法之间的调用,我们称之为自调用。那么它能够成功地每次调用都产生新的事务吗?

UserController 只需要将 userBatchService.insertUsers(userList); 改成 userService.insertUsers(userList); 即可,浏览器输入http://localhost:8080/user/insertUsers?userName1=usemame_1&note1=note_1&userName2=usemame_2&note2=note_2 进行测试,下面是我的测试日志:
在这里插入图片描述
过日志可以看到,Spring 在运行中并没有创建任何新的事务独立地运行 insertUser 方法。换句话说,我们的注解 @Transactional 失效了,为什么会这样呢?在上节,我们谈过 Spring 数据库事务的约定,其实现原理是 AOP,而 AOP 的原理是动态代理,在自调用的过程中,是类自身的调用,而不是代理对象去调用,那么就不会产生 AOP,这样 Spring 就不能把你的代码织入到约定的流程中,于是就产生了现在看到的失败场景。为了克服这个问题,我们可以像上节那样,用一个 Service 去调用另一个 Service,这样就是代理对象的调用,Spring 才会将你的代码织入事务流程。当然也可以从 SpringIoC 容器中获取代理对象去启用 AOP,例如,我们再次对 UserServicelmpl 进行改造,代码如下所示。

package com.springboot.chapter6.service.impl;

import com.springboot.chapter6.dao.UserDao;
import com.springboot.chapter6.pojo.User;
import com.springboot.chapter6.service.UserService;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserServiceImpl implements UserService, ApplicationContextAware {

    @Autowired
    private UserDao userDao;

    private ApplicationContext applicationContext;

    // 实现生命周期方法,设置IOC容器
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int insertUsers(List<User> userList) {
        int count = 0;
        // 从IOC容器中取出代理对象
        UserService userService = applicationContext.getBean(UserService.class);
        for (User user : userList) {
            // 使用代理对象调用方法插入用户,此时会织入Spring数据库事务流程中
            count += userService.insertUser(user);
        }
        return count;
    }

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRES_NEW)
    public int insertUser(User user) {
        return userDao.insertUser(user);
    }
}

从代码中我们实现了 ApplicationContextAware 接口的 setApplicationContext 方法,这样便能够把 IoC 容器设置到这个类中来。于是在 insertUsers 方法中,我们通过 IoC 容器获取了 UserService 的接口对象。但是请注意,这将是一个代理对象,并且使用它调用了传播行为为 REQUIRES_NEW 的 insertUser方法,这样才可以运行成功。我还监控了获取的 UserService 对象,如下图所示。
在这里插入图片描述
从代码中我们可以看到,从 IoC 容器取出的对象是一个代理对象,通过它能够克服自调用的问题。下面是运行这段代码的日志:
在这里插入图片描述
从标记的日志部分可以看出,Spring 已经为我们的方法创建了新的事务,这样自调用的问题就克服了,只是这样代码需要依赖于 Spring 的 API,这样会造成代码的侵入。使用上节中的一个类调用另外一个类的方法则不会有依赖,只是相对麻烦一些。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值