Spring系列-实战篇(5)-数据库的事务和锁

1.前言

大学里面数据库课考试,事务和锁的相关知识绝对是要划的重点。数据库的事务要遵循ACID(原子性、一致性、隔离性、持久性)四要素,锁又有悲观锁和乐观锁的划分方式。那么今天咱们讲讲,如何基于SpringBoot+Mybatis的框架,进行有关事务和锁的代码开发。java

在实际应用中,两者密不可分。在业务系统开发过程当中,每每有一系列对数据库的操做是须要绑定在一个事务里的,要么一块儿提交,要么一块儿回滚。例如:A给B转100块钱,同时要执行 下面两个方法。mysql

(1)update account set money=money-100 where user='A';
(2)update account set money=money+100 where user='B' ;

这两个方法必须做为同一个事务提交,事务提交的结果,要么转帐成功,要么转帐失败。是绝对不可以存在A扣钱成功,B帐号没加钱;或A没扣钱,B的帐号却多了100块钱。sql

为了遵循事务的ACID原则,咱们会引用了锁的概念,若是是单纯基于某个数据库的事务,咱们可使用接下来要讲的悲观锁和乐观锁。固然有些特殊状况,咱们还须要考虑分布式事务锁的方案,那就说来话长了,本文就不作介绍了。数据库

2.事务

在使用事务以前,请先保证数据是手动提交事务的。oracle默认是手动提交事务的,可是mysql数据库一般默认都是自动提交事务的,下面是如何关闭mysql自动提交事务的设置。后端

--查看是否自动提交
show variables like '%autocommit%';
--0为关闭自动提交;1为开启自动提交
set global autocommit= 0

2.1.解决的问题

实际开发过程当中,咱们绝大部分的事务都是有并发状况。当多个事务并发运行,常常会操做相同的数据来完成各自的任务。在这种状况下可能会致使如下的问题:并发

  • 脏读—— 事务A读取了事务B更新的数据,而后B回滚操做,那么A读取到的数据是脏数据。
  • 不可重复读—— 事务 A 屡次读取同一数据,事务 B 在事务A屡次读取的过程当中,对数据做了更新并提交,致使事务A屡次读取同一数据时,结果不一致。
  • 幻读—— 系统管理员A将数据库中全部学生的成绩从具体分数改成ABCDE等级,可是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉同样,这就叫幻读。

不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住知足条件的行,解决幻读须要锁表。oracle

2.2.@Transactional

SpringBoot为事务管理提供了不少功能支持,目前最经常使用的就是经过声明式事务管理,基于@Transactional注解的方式来实现。实现原理是基于AOP,对方法先后进行拦截,而后在目标方法开始以前建立或者加入一个事务,在执行完目标方法以后根据执行状况提交或者回滚事务。app

@Transactional注解可做用于类、接口和方法上。框架

  1. 类:该类的全部 public 方法将都具备该类型的事务属性,同时,咱们也能够在方法级别使用该标注来覆盖类级别的定义。

2.接口:在使用基于该接口的代理时,事务属性才会生效。
3.方法:做为事务管理的最细粒度。值得注意的有,aop的本质决定该注解只能做用在public方法上,不然会被忽略,但不会报错。分布式

默认状况下,只有来自外部的方法调用才会被AOP代理捕获,也就是,类内部方法调用本类内部的其余方法并不会引发事务行为,即便被调用方法使用@Transactional注解进行修饰。

2.3.示例代码

开启基于@Transactional事务的方式很简单,先在启动类经过 @EnableTransactionManagement 注解开启事务管理。随后在对应的类、接口、方法加上 @Transactional 就能够了。

在某个示例Controller中的一个方法

@RequestMapping(path = "/updateNameNow",method = RequestMethod.GET)
    @Transactional
    public Response updateNameNow(@RequestParam("name")String name,@RequestParam("username")String username) throws Exception{
        //根据username,更新用户name
        userMapper.updateName(name,username); 
        throw new RuntimeException("发生了一个错误");
    }

该方法加了注解@Transactional,原方法做用是更新用户的姓名,可是在执行dao层的update操做后,抛出了一个运行时异常。最终的结果是update事务回滚了,数据库中没有更新成功。

值得注意的是咱们抛出的异常是RuntimeException运行时异常,@Transactional默认支持回滚的异常就是运行时异常。非运行时异常(JAVA编译器强制要求咱们必需对进行catch并处理的异常)并不会触发事务回滚,不过咱们能够在主键的属性中申明支持回滚的粒度,如:

@RequestMapping(path = "/updateNameNow",method = RequestMethod.GET)
    @Transactional(rollbackFor =Exception.class )
    public Response updateNameNow(@RequestParam("name")String name,@RequestParam("username")String username) throws Exception{
        //根据username,更新用户name
        userMapper.updateName(name,username); 
        throw new Exception("发生了一个错误");
    }

2.4.经常使用属性

刚刚咱们见识过@Transactional中的rollbackFor 属性,这里列一下经常使用的几种属性。

  • propagation: propagation用于指定事务的传播行为,就是若是@Transactional的方法调用了另一个@Transactional的方法,事务该如何传播。propagation有七种类型,默认值为 REQUIRED。

    属性 含义
    REQUIRED 若是当前没有事务,就新建一个事务,若是已经存在一个事务,则加入到这个事务中。这是最多见的选择。
    SUPPORTS 支持当前事务,若是当前没有事务,就以非事务方式执行。

|MANDATORY|表示该方法必须在事务中运行,若是当前事务不存在,则会抛出一个异常。|
|REQUIRES_NEW|表示当前方法必须运行在它本身的事务中。一个新的事务将被启动。若是存在当前事务,在该方法执行期间,当前事务会被挂起。|
|NOT_SUPPORTED|表示该方法不该该运行在事务中。若是当前存在事务,就把当前事务挂起。|
|NEVER|表示当前方法不该该运行在事务上下文中。若是当前正有一个事务在运行,则会抛出异常。|
|NESTED|若是当前存在事务,则在嵌套事务内执行。若是当前没有事务,则执行与PROPAGATION_REQUIRED相似的操做。|

  • isolation: isolation用于指定事务的隔离规则,默认值为DEFAULT,即便用后端数据库默认的隔离级别。
  • timeout:timeout用于设置事务的超时属性。
  • readOnly: readOnly用于设置事务是否只读属性,用于一次执行多条查询语句的场景。从这一点设置的时间点开始到这个事务结束的过程当中,其余事务所提交的数据,该事务将看不见。
  • rollbackFor、rollbackForClassName、noRollbackFor、noRollbackForClassName:rollbackFor、rollbackForClassName用于设置哪些异常须要回滚;noRollbackFor、noRollbackForClassName用于设置哪些异常不须要回滚。他们都是在设置事务的回滚规则。

3.锁

咱们这里回顾一下数据库中的两种锁,悲观锁和乐观锁。

悲观锁,顾名思义,就是对数据的冲突采起一种悲观的态度,也就是说假设数据确定会冲突,因此在数据开始读取的时候就把数据锁定住。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁,就是认为数据通常状况下不会形成冲突,因此在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测。若是发现冲突了,则让用户返回错误的信息,让用户决定如何去作。Java中有CAS就是乐观锁的实现方式。

加锁实际上会增长数据库资源的消耗,至于咱们该如何合理的选用锁,则取决于实际应用场景中事务冲突发生的频率。若是冲突的频率较高,建议选择悲观锁;若是冲突的频率较低,乐观锁显然更合适。

3.1.悲观锁

oracle和mysql数据库都支持行级锁,行级锁中又分共享锁(读锁)排他锁(写锁)。而悲观锁明显是排他锁,须要阻塞其余的写锁和读锁。

对应于数据库的经常使用操做中,共享锁对应的语言是DQL(select),排他锁对应的语言是DML(update,delete,insert)。咱们若是要保证DQL也遵循悲观锁的控制,能够经过 (select ... for update)来实现。咱们来看一个例子。

UserMapper.java

/**
     * 根据 username 查询 name
     * @param username
     * @return
     */
    @Select("select name from user where username=#{username} for update")
    String getNameByUsername(@Param("username") String username);

    /**
     * 更新 name
     * @param name
     * @param username
     * @return
     */
    @Update("update user set name=#{name} where username=#{username}")
    int updateName(@Param("name")String name,@Param("username")String username);

UserController.java

/**
     * 查询 name ,停10秒返回结果
     * @param username
     * @return
     */
    @RequestMapping(path = "/getNameByUsername", method = RequestMethod.GET)
    @Transactional
    public Response getNameByUsername(@RequestParam("username") String username) {
        String name = userMapper.getNameByUsername(username);
        try {
            Thread.sleep(10000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return Response.ok().data(name);
    }

    /**
     * 更新 name,马上返回
     *
     * @param name
     * @param username
     * @return
     * @throws Exception
     */
    @RequestMapping(path = "/updateNameNow", method = RequestMethod.GET)
    @Transactional
    public Response updateNameNow(@RequestParam("name") String name, @RequestParam("username") String username) throws Exception {
        int ret = userMapper.updateName(name, username);
        return Response.ok().data(ret);
    }

咱们经过这两个接口测试,UserMapper.getNameByUsername方法的查询sql有 "for update" ,说明使用了排他锁,而另外一个接口 UserMapper.updateName 明显也是排他锁。

  1. 先调用 /getNameByUsername 接口,接着立刻调用 /updateNameNow接口。 由于/getNameByUsername 接口的代码中有线程等待,在等待10秒钟后才会有返回结果。但咱们发现 /updateNameNow 接口也是要等待10秒钟,等/getNameByUsername 接口调用返回完成后,才会跟着有返回。说明悲观锁生效了,后者要等待前者的事务完成了才会执行。
  2. 咱们去掉UserMapper.getNameByUsername方法中的"for update",从新运行接口,重复刚才的操做。/getNameByUsername 接口继续是等待10秒钟有返回,可是 /updateNameNow 接口则不须要等待,立马就有返回。

3.2.乐观锁

乐观锁的控制权通常不在数据库层面,而在业务层面。并无任何排他锁的操做,而是在最后提交的时候,按照咱们自定义的规则比对一下数据,若是按照咱们的规则发现数据冲突了,则本身解决冲突。那么重点就在于这个自定义的规则。

我在咱们公司,早期是基于Oracle的ADF框架作开发的。建表后要在ADF中建Entity Object 作字段的映射,Entity Object 有5个基础字段:

  1. created on:建立时间
  2. created by:建立人
  3. modified on:最后修改时间
  4. modified by:最后修改人
  5. version number:版本号

前面4个字段咱们很好理解,最后一个version number 版本号,我以前一直以为不少余。实际上它是ADF中实现乐观锁的关键字段,包括Hibernate等 orm框架都是利用它来作数据比较。咱们看下面的例子:

UserMapper.java

/**
     * 根据 username 查询,返回 User对象
     * @param username
     * @return
     */
    @Select("select * from user where username=#{username}")
    User getUserByUsername(@Param("username") String username);

    /**
     * 根据版本号,更新User
     * @param user
     * @return
     */
    @Update("update user set name=#{user.name},object_version_number=object_version_number+1 " +
            "where username=#{user.username} and object_version_number=#{user.objectVersionNumber}")
    int updateUser(@Param("user") User user);

UserController.java

/**
     * 更新 User
     * @param user
     * @return
     * @throws Exception
     */
    @RequestMapping(path = "/updateUser", method = RequestMethod.POST)
    @Transactional(rollbackFor = Exception.class)
    public Response updateUser(@RequestBody User user) throws Exception {
        int ret = userMapper.updateUser(user);
        if (ret < 1) {
            throw new Exception("乐观锁致使保存失败");
        }
        return Response.ok();
    }

必需要保证全部的对表数据的更新操做,都要将版本号加1。在作DML操做时,须要带上当前拿到的版本号信息,放在DML语言的where条件中。

  1. 若是拿到的版本号和数据库中最新的版本号一致,则认为事务无冲突,提交成功,变量ret返回1。
  2. 若是拿到的版本号和数据库中最新的版本号不一致,事务冲突,则提交失败,变量ret返回0。结合@Transactional,在抛出异常后事务回滚。

这个例子中,咱们经过对表中的版本号字段的比较,就完成了乐观锁的实现,实现方式明显看起来要不悲观锁“友善”的多。咱们平时业务开发时,若是没有遇到事务冲突很是严重的场景,使用乐观锁基本就能达到目的。

相关文章
相关标签/搜索