深入了解Spring事务

深入了解Spring事务

敲得码黛 834 2020-04-25

前言

在一个阳光明媚的周五,我正开心的敲着代码,突然看到一个技术交流群中正在火热的讨论着某个话题,好奇心驱使着我点开看了一下, 原来是某位同僚正在远程面试,面试官出了这样的一道题

我心里默默的笑了笑,这特喵的该不会是哪个crud仔自己排查不出来,所以找面试者来套方案的吧。

本想帮一帮这位同僚,奈何公司规定:上班时间不允许装逼。

ps:突然想到自己似乎也没有对这方面知识做过系统的学习,于是默默的拿出小本本记录了下来。

ps:先明确一点,要使用事务,首先你的数据库肯定得支持事务,你说你数据库都不支持事务,那就算神仙来了也没用的好吧。

事务不生效的场景

非事务方法内部调用不生效

Controller

/**
 * 用于验证事务不生效的场景
 * @author hcq
 * @date 2020/4/24 22:27
 */
@RestController
@RequestMapping("/student/transaction")
@AllArgsConstructor
public class TransactionController {

    private TransactionService transactionService;

    /**
     * 非事务方法内部调用 事务不生效
     */
    @RequestMapping("/innerCall")
    public String  innerCall(){
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.innerCall(stu);
        return "SUCCESS";
    }


}

Service

@Service
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    public void innerCall(Student student) {
        saveRecordAndThrowRuntimeException(student);
    }

    @Override
    @Transactional(rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student){
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

}

可以看到最后的结果是,数据库中成功保存了这个数据 (也就是事务没有生效)

然后我们再来分析一下为什么事务会不生效(这就很考验你的java功底喽)。

之前我也因为这个问题困扰过一段时间,后来所幸遇到高人指点,高人告诉我“ java中的方法调用,调用的只是代码片段”,也就说方法间的调用其实和你直接把另一个方法的代码复制过来是一样的效果。所以代码在执行时就变成了下面这个样子。

@Service
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    public void innerCall(Student student) {
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

    @Override
    @Transactional(rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student){
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

}

看到这里你应该已经明白了为什么事务没生效了吧。(因为压根就没有@Transaction注解啊 )

对此我还有另一种解释:Controller中所依赖的Service其实是IOC提供的一个代理对象,而这个代理对象在调用具体的方法时,会通过判断该方法上面是否包含@Transactional注解来决定是否要开启事务,而这个innerCall方法没有包含此注解,所以Spring代理对象会认为此方法不需要开启事务,在innerCall方法调用事务方法的过程中,其实方法的调用者已经由Spring代理对象转换为了这个类的原生对象(也就是this关键字)。而我们的这个原生对象是没有对@Transaction注解做任何处理的,所以事务自然也不会生效。

ps:就算看不懂也没关系哦,这个作者的脑洞有点大。哈哈

抛出检查异常事务不回滚(需指定要回滚的异常才会回滚)

Controller

@RestController
@RequestMapping("/student/transaction")
@AllArgsConstructor
public class TransactionController {

    private TransactionService transactionService;

    /**
     * 检查异常(Checked Exception)事务不回滚
     */
    @RequestMapping("/checkedException")
    public String  checkedException() throws IOException {
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.checkedException(stu);
        return "SUCCESS";
    }

    /**
     * 检查异常(Checked Exception)事务回滚
     */
    @RequestMapping("/checkedExceptionAndRollBack")
    public String  checkedExceptionAndRollBack() throws IOException {
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.checkedExceptionAndRollBack(stu);
        return "SUCCESS";
    }
}

Service

@Service
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(rollbackFor = {})
    public void checkedException(Student stu) throws IOException {
        studentMapper.insert(stu);
        throw new IOException("找不到XXX.xml");
    }

    /**
     * 指明IOException异常进行回滚
     * 此处需要注意的是不能在本方法中把IOException异常catch掉,否则也会导致事务无法回滚
     * @param stu 学生信息
     * @throws IOException IO异常
     */
    @Override
    @Transactional(rollbackFor = {IOException.class})
    public void checkedExceptionAndRollBack(Student stu) throws IOException {
        checkedException(stu);
    }
}

最终的执行结果是:

  • checkedException:成功的添加了一条记录(事务没有回滚)
  • checkedExceptionAndRollBack:没有添加记录,事务回滚

“ 抛出检查异常事务不会回滚 ” 和 “ 抛出检查异常事务不会生效 ” ,这是两个不同的概念,事务有没有生效是由IOC代理对象有没有捕获到异常决定的,而事务捕获到检查异常时要不要回滚,则应该是由你来告诉这个代理对象。

相关特性

传播机制

传播机制是Spring中定义的一个概念,在mysql中并不存在这一概念,它规定了多个事务之间应该以何种方式进行传播。

Spring为此定义了7种事务的传播途径

传播途径 描述/结论
Propagation.REQUIRED(必要的) Spring默认的传播机制,若存在事务则在原事务中运行,若不存在事务则创建一个事务
Propagation.SUPPORTS(支持) 支持当前事务,如果不存在事务,则以非事务方式执行。
Propagation.MANDATORY(强制的) 强势要求使用事务,若当前环境不存在事务则抛出异常
Propagation.REQUIRES_NEW(新事务) 把当前事务挂起(暂停),创建一个新的事务去执行,执行完毕后恢复原事务
Propagation.NOT_SUPPORTED(不支持) 把当前事务挂起(暂停),以非事务的方式去运行,运行完毕后恢复原事务
Propagation.NEVER(决不使用) 绝不使用事务,如果当前环境存在事务则抛出异常
Propagation.NESTED(嵌套的) 若当前环境存在事务则嵌套在当前事务中执行(类似于REQUIRED)

Propagation.REQUIRED

Spring默认的传播机制,若存在事务则在原事务中运行,若不存在事务则创建一个事务

Controller

/**
 * 用于验证事务Propagation.REQUIRED级别的传播机制
 *  Propagation.REQUIRED 是默认的传播机制
 * @author hcq
 * @date 2020/4/24 22:34
 */
@RestController
@RequestMapping("/student/propagation/required")
@AllArgsConstructor
@Slf4j
public class RequiredPropagationController {

    private StudentMapper studentMapper;
    private RequiredPropagationService requiredPropagationService;

    /**
     * 结论1:不存在事务则创建一个事务 (经常被使用到的场景)
     */
    @RequestMapping("/noExistTransaction")
    public String noExistTransaction(){
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        requiredPropagationService.saveRecordAndThrowRuntimeException(stu);
        return "SUCCESS";
    }

    /**
     * 结论2:若存在事务则在原事务中运行 (经常被使用到的场景)
     *  通过观察test001是否被回滚?可以验证Controller中与Service中的是否是同一个事务
     *  
     *  若test001被回滚则说明是同一个事务
     *  若test001未回滚则表示不是同一个事务
     */
    @RequestMapping("/existTransaction")
    @Transactional(rollbackFor = {})
    public String  existTransaction(){
        studentMapper.insert(new Student("test001","小红",22,"女"));
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        try {
            requiredPropagationService.saveRecordAndThrowRuntimeException(stu);
        }catch (Exception e){
           log.info("捕获异常,防止其影响观察结果!");
        }
        return "SUCCESS";
    }
}

Service

@Service
@AllArgsConstructor
public class RequiredPropagationServiceImpl implements RequiredPropagationService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student) {
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }
}

执行结果:

  • 场景1:事务回滚,数据库中无数据,说明确实存在事务,结论1正确
  • 场景2:事务回滚,数据库中无数据。且后台打印了UnexpectedRollbackException异常日志,提示:”Transaction rolled back because it has been marked as rollback-only“,我们来分析一下这个过程,首先service抛出异常后将事务标记为仅回滚状态,然后controller中将service抛出的异常catch掉后想要提交事务,但是发现此事务已经被标记为仅回滚,所以又抛出了UnexpectedRollbackException异常。由此可得出结论2正确

Propagation.SUPPORTS

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.MANDATORY

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.REQUIRES_NEW

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

需要注意的是在此级别下可能会由于代码原因从而导致数据库死锁。

Propagation.NOT_SUPPORTED

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

需要注意的是在此级别下可能会由于代码原因从而导致数据库死锁。

Propagation.NEVER

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.NESTED

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣话同学可以通过文章末尾的github链接去下载源码

此级别类似于REQUIRES_NEW,但是通过内部事务可以读到外部事务数据的特性,避免了REQUIRES_NEW这个级别下的死锁现象

隔离级别

Spring、mysql都有这一概念,它规定了事务之间的数据是否应该对其他事务可见。

这句话多少还是带有一点歧义,再具体一点来说应该是规定了一个事务是否可以访问到其他事务已经提交的或则是未提交的这部分数据

Spring与Mysql中都分别定义以下4种隔离级别,此外Spring还定义了一种DEFAULT的隔离级别,表示默认采用数据库隔离级别

隔离级别 描述信息
READ_UNCOMMITTED(读未提交) 可以读到其他事务未未提交的数据,此隔离级别下会出现脏读、幻读、不可重复读等问题
READ_COMMITTED(读已提交) 只能读到其他事务已经提交的数据,避免了脏读的现象,但是依然会出现幻读与不可重复读的问题
REPEATABLE_READ(可重复读) 通过MVCC解决了不可重复读的问题,然后又通过行锁加间隙锁来避免了幻读的现象,此隔离级别可以避免脏读、幻读与不可重复读等问题。同时这也是INNODB默认的隔离级别
SERIALIZABLE(序列化读) 最严格的隔离级别,规定每个事务必须按照顺序进行读取(也就是不允许并发)

READ_UNCOMMITTED(读未提交)

在这个隔离级别下,我们首先来了解一下什么是脏读

脏读

脏读又称无效数据的读出,是指在数据库访问中,事务T1将某一值修改,然后事务T2读取该值,此后T1因为某种原因撤销对该值的修改,这就导致了T2所读取到的数据是无效的。

ps:摘自百度百科

OK,下面我们通过代码来模拟这个现象,首先先看代码

Controller

/**
 * 用于模拟读未提交(READ_UNCOMMITTED)隔离级别下的脏读现象
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadUnCommitController {

    private ReadUnCommitService readUnCommitService;

    /**
     * 模拟脏读的现象
     */
    @RequestMapping("/readUnCommit")
    public String readUnCommit(){
        // 从数据库中查找id等于1943b39d5d684c60895614e3d4f7357f的学生信息
        String stuId="1943b39d5d684c60895614e3d4f7357f";
        Student student = readUnCommitService.getStudentById(stuId);
        return JSONObject.toJSONString(student);
    }
}

Service

@Service
@AllArgsConstructor
public class ReadUnCommitServiceImpl implements ReadUnCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional( isolation = Isolation.READ_UNCOMMITTED,rollbackFor = {})
    public Student getStudentById(String stuId) {
        return  studentMapper.selectById(stuId);
    }

}

然后我在数据库手动开启了一个事务,并添加了一条学生信息(注意此时我并没有提交这个事务,所以在数据库的表中是查不到这个记录的)。

然后通过接口进行查询,可以看到这条数据已经被查了出来,然后我再把数据库中添加数据的事务给回滚掉(一定要记得回滚或者是提交,否则的话这个事务就会一直占用着这把锁)。这样用户就查到了一条不存在的记录,这也就是所谓的脏读现象。

READ_COMMITTED(读已提交)

同样的套路:在这个隔离级别下,我们先来了解一下什么是不可重复读

不可重复读

不可重复读,是指在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。

ps:也是摘自百度百科。真香。。

Controller

/**
 * 用于模拟读已提交(READ_COMMITTED)隔离级别下的不可重复读、幻读现象
 * 此隔离级别下会出现的问题:幻读、不可重复读
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadCommitController {

    private ReadCommitService readCommitService;

    /**
     * 模拟不可重复读的现象
     */
    @RequestMapping("/readCommit")
    public String readUnCommit(){
        // 从数据库中查找id等于1943b39d5d684c60895614e3d4f7357f的学生信息
        String stuId="1943b39d5d684c60895614e3d4f7357f";
        Student student = readCommitService.getStudentById(stuId);
        return JSONObject.toJSONString(student);
    }


}

Service

@Service
@AllArgsConstructor
@Slf4j
public class ReadCommitServiceImpl implements ReadCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,rollbackFor = {})
    public Student getStudentById(String stuId) {
        Student stu=studentMapper.selectById(stuId);
        log.info("第一次读到的数据为:{}", JSONObject.toJSONString(stu));

        // 别问我为啥要休眠30秒,因为我要在数据库改数据提交事务呀
        try {
            Thread.sleep(30*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 别问我为啥不直接selectById,还不是因为这令人难受的mybatis一级缓存
        stu=studentMapper.selectList(new QueryWrapper<>()).get(0);
        log.info("第二次读到的数据为:{}",JSONObject.toJSONString(stu));
        return  stu;
    }

}

执行结果:同一个事务内两次查询同一条记录,却返回了不一样的数据(第二条数据是我在数据库手动修改的)

幻读

幻读是指当事务不是独立执行时发生的一种现象。事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。

ps:还是摘自百度百科。

Controller

/**
 * 用于模拟读已提交(READ_COMMITTED)隔离级别下的不可重复读、幻读现象
 * 此隔离级别下会出现的问题:幻读、不可重复读
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadCommitController {

    private ReadCommitService readCommitService;

    /**
     * 模拟幻读
     */
    @RequestMapping("/phantomRead")
    public List<Student> phantom(){
        Student stu=new Student("1943b39d5d684c60895614e3d4f7357f","小王",22,"男");
        return readCommitService.modifyAllStudentAndQueryAll(stu);
    }

}

Service

@Service
@AllArgsConstructor
@Slf4j
public class ReadCommitServiceImpl implements ReadCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = {})
    public List<Student> modifyAllStudentAndQueryAll(Student stu) {
        // 更改表中所有的表数据
        studentMapper.update(stu, new UpdateWrapper<>());
        // 休眠30秒,因为我要在数据库添加数据提交事务
        try {
            Thread.sleep(30 * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 返回表中的所有数据
        return studentMapper.selectList(new QueryWrapper<>());
    }

}

执行结果:可以看到在同一个事务中执行全表更新时只更新了4条记录,然后查询全表数据时查出了7条数据。并且有3条数据没有被更新,这些没有没更新的行一般被称为幻影行

REPEATABLE_READ(可重复读)

可重复读隔离级别的验证流程跟读已提交的验证流程是一模一样的,只需要修改对应的隔离级别然后观察幻读与不可重复读现象即可。

感兴趣的小伙伴可以下载源码自行翻阅

SERIALIZABLE(序列化读)

时间原因、这个先行略过,后期再补上

事务超时时间

Spring中的概念,默认永不超时,数据库中没有事务超时时间(数据库超时一般指的是连接超时或则是锁等待超时)。在Spring中如果一个事务超时了,那么这个事务内就无法执行任何sql语句,否则将会抛出异常。但是如果此事务内所有的语句都有已经执行完成了,那么这个超时的事务还是可以被提交的。

感兴趣的同学可以通过文章末尾的github链接去下载源码

事务的ACID

  • 原子性(A)是指一个事务内所有的操作在逻辑上都表现为一个操作
  • 一致性©是指一个事务只有两种状态:已执行与未执行,不会存在执行到一半的情况。(执行到一半服务器宕机,这个事务会进行回滚,也就相当于是未执行状态)
  • 隔离性(I)描述了多个事务之间的数据是否应该对其他事务的可见性
  • 持久性(D)是指一个事务一旦执行完被提交后,所做的更改就会被永久保存

既然用到了框架,就应该明白框架帮自己做了什么事情,这样才能够在出现问题的时候,不至于束手无策。


# Spring事务 # @Transaction详解