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 注解可作用于类、接口和方法上。
- 类:该类的所有 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 明显也是排他锁。
- 先调用 /getNameByUsername 接口,接着马上调用 /updateNameNow 接口。因为 /getNameByUsername 接口的代码中有线程等待,在等待 10 秒钟后才会有返回结果。但我们发现 /updateNameNow 接口也是要等待 10 秒钟,等 /getNameByUsername 接口调用返回完成后,才会跟着有返回。说明悲观锁生效了,后者要等待前者的事务完成了才会执行。
- 我们去掉 UserMapper.getNameByUsername 方法中的 ”for update”,重新运行接口,重复刚才的操作。/getNameByUsername 接口继续是等待 10 秒钟有返回,但是 /updateNameNow 接口则不需要等待,立马就有返回。
3.2. 乐观锁
乐观锁的控制权一般不在数据库层面,而在业务层面。并没有任何排他锁的操作,而是在最后提交的时候,按照我们自定义的规则比对一下数据,如果按照我们的规则发现数据冲突了,则自己解决冲突。那么重点就在于这个自定义的规则。
我在我们公司,早期是基于 Oracle 的 ADF 框架做开发的。建表后要在 ADF 中建 Entity Object 做字段的映射,Entity Object 有 5 个基础字段:
- created on:创建时间
- created by:创建人
- modified on:最后修改时间
- modified by:最后修改人
- 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 条件中。
- 如果拿到的版本号和数据库中最新的版本号一致,则认为事务无冲突,提交成功,变量 ret 返回 1。
- 如果拿到的版本号和数据库中最新的版本号不一致,事务冲突,则提交失败,变量 ret 返回 0。结合 @Transactional,在抛出异常后事务回滚。
这个例子中,我们通过对表中的版本号字段的比较,就完成了乐观锁的实现,实现方式明显看起来要不悲观锁“友善”的多。我们平时业务开发时,如果没有遇到事务冲突非常严重的场景,使用乐观锁基本就能达到目的。