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,在抛出异常后事务回滚。
这个例子中,我们通过对表中的版本号字段的比较,就完成了乐观锁的实现,实现方式明显看起来要不悲观锁“友善”的多。我们平时业务开发时,如果没有遇到事务冲突非常严重的场景,使用乐观锁基本就能达到目的。
但是谨记使用乐观锁时,要保证事务隔离级别是小于“可重复读”,可以是“提交读”或“未提交读”。像mysql的隔离级别默认是“可重复读”,是没办法直接使用乐观锁的。
4.事务补充
Spring提供下面两个方式控制事务:
- 命令式事务控制方式. 使用 TransactionTemplate 类. 特点: 个人觉得 JdbcTemplate + TransactionTemplate 非常搭配, 都是轻量级, 都是命令式. 另外 TransactionTemplate 因为是写代码形式, 事务控制做到更细粒度.
- 声明式事务控制方式 (@Transactional) 将DB访问封装到 @Service/@Component 类中, 并将具体访问过程放到一个 public 方法中, 并加上 @Transactional 注解. 优点: 代码很简洁, 不仅适用于 JdbcTemplate, 而且适用于 Jpa/MyBatis 等数据库访问技术. 缺点: 事务控制粒度较粗, 只能做到函数粒度的事务控制, 无法做到代码块级的事务控制, 另外需要理解其背后是通过 AOP + proxy 方式实现的, 使用有比较多的讲究
Spring 控制方式基础是 PlatformTransactionManager 接口, 它为各种数据访问技术提供了统一的事务支持接口, 不同的数据技术都有自己的实现:
- Spring JDBC 技术: DataSourceTransactionManager
- JPA 技术: JpaTransactionManager
- Hibernate 技术: HibernateTransactionManager
- JDO 技术: JdoTransactionManager
- 分布式事务: JtaTransactionManager
Spring Boot 项目中, 引入了 spring-boot-starter-jdbc 之后, 会自动注入一个 DataSourceTransactionManager 类型 bean 对象, 这个对象有两个名称, 分别为 transactionManager 和 platformTransactionManager . 引入了 spring-boot-starter-data-jpa 依赖后, 会自动注入一个 JpaTransactionManager 类型 bean 对象, 这个对象有两个名称, 分别为 transactionManager 和 platformTransactionManager.
如果我们项目有多个数据源, 或者既引入了 spring-boot-starter-jdbc, 又引入了 spring-boot-starter-data-jpa 依赖, 自动注入事务控制器就会混乱, 所以需要创建一个 TransactionManager configuration 类, 手动为不同数据源建立对应的 PlatformTransactionManager bean.
4.1.@Transactional
如果使用 @Transactional 注解控制事务, 需要指定对应的事务控制器, 比如 @Transactional(value="txManager1") 。
@EnableTransactionManagement
public class TransactionManagerConfig {
@Bean
@Autowired
public PlatformTransactionManager txManager1(DataSource dataSource1) {
return new DataSourceTransactionManager(dataSource1);
}
@Bean
@Autowired
public PlatformTransactionManager txManager2(DataSource dataSource2) {
return new DataSourceTransactionManager(dataSource2);
}
}
4.2.TransactionTemplate
生成 TransactionTemplate 对象时, 需要指定一个 Spring PlatformTransactionManager 接口的实现类. 因为我们使用的是 JdbcTemplate, 所以创建 TransactionTemplate 对象要传入 DataSourceTransactionManager 参数. 使用 TransactionTemplate 类控制事务, 我们只需要将数据访问代码封装成一个callback对象, 然后将callback对象传值给TransactionTemplate.execute()方法, 事务控制由TransactionTemplate.execute()完成.
TransactionTemplate.execute() 函数的主要代码:
public <T> T execute(TransactionCallback<T> action) throws TransactionException {
TransactionStatus status = this.transactionManager.getTransaction(this);
T result;
try {
result = action.doInTransaction(status);
} catch (RuntimeException | Error ex) {
// Transactional code threw application exception -> rollback
rollbackOnException(status, ex);
throw ex;
} catch (Throwable ex) {
// Transactional code threw unexpected exception -> rollback
rollbackOnException(status, ex);
throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception");
}
this.transactionManager.commit(status);
return result;
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。