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

1.前言

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

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

(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块钱。

为了遵循事务的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改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

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

2.2.@Transactional

SpringBoot为事务管理提供了很多功能支持,目前最常用的就是通过声明式事务管理,基于@Transactional注解的方式来实现。实现原理是基于AOP,对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

@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,在抛出异常后事务回滚。

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

你可能感兴趣的:(springboot,事务,乐观锁,悲观锁)