# MyBatis-Plus教程 - 13 悲观锁和乐观锁

在讲解悲观锁和乐观锁之前,先说一下可能遇到的问题。

# 13.1 并发问题

以商品减库存为例,假设有一件 ID 为 1 的商品,库存为 100,事务A事务B 同时购买 10 件库存。

购买时,事务A 先查询商品的库存为 100,在 100 的基础上减10 ,更新库存为 90;事务B 同时查询到库存为 100,也在 100 的基础上减10,更新库存为 90。事务A事务B 先后提交(A先B后),导致最终库存值变为 90,而不是 80,而 事务A 的更新被覆盖,库存减少 10 的操作丢失了。


你可能会想,通过如下的 SQL 操作是否可以解决:

UPDATE tb_prod SET prod_count = prod_count - 10 WHERE id = 1 AND prod_count > 0;
1

prod_count = prod_count - 10 是直接在数据库中执行的,不存在中间读取数据的步骤,因此可以避免在应用层读取库存后再减库存带来的并发问题。

但是数据库的事务隔离级别仍然可能导致并发问题,因为数据库的隔离级别可能设置的不是串行化的,如果数据库的隔离级别设置为串行化的,那么可以解决这个问题,但这种方式可能会导致性能下降。

如果数据库隔离级别不是串行化的,那么两个事务同时执行这条语句的时候,会先读取到 prod_count 的值,那么它们读取到的值将都是100,并判断 prod_count > 0 成立,那么后面再同时执行 UPDATE 语句,都设置 prod_count = 100 - 10,还是出现了问题。

虽然 UPDATE 操作是原子性的,数据库会确保 事务Aprod_count = prod_count - 10 操作不会被其他事务中断,但它不能解决并发事务中的竞争问题,也就是说 事务A事务B 在更新操作之前,都需要读取相同的初始值 prod_count = 100,这部分读取过程并不受原子性保障,因此两个事务都可以基于相同的初始值来进行更新计算。所以出问题了。

怎么解决呢,下面说一下悲观锁和乐观锁。

# 13.2 悲观锁和乐观锁

# 1 悲观锁

悲观锁就是很悲观,总有刁民想害朕,认为在我修改的时候,肯定有别人也在修改,所以在查询数据的时候就会对数据进行加锁,此时别人是无法查询修改数据的,必须等我修改完成,别人才能查询和修改数据。

在使用悲观锁的时候,查询和修改数据是一致的,在查询到的数据的基础上修改不会出问题。但是悲观锁会降低系统的并发性能,如果对数据一致性要求非常高,并且可以接受一定的性能损失,那么可以使用悲观锁。

# 2 乐观锁

乐观锁相对于乐观一些,在查询数据的时候是不会上锁的,在执行更新的时候判断数据是否被修改过,如果没有修改过就修改,如果被修改过了,就返回修改失败。

实现的思路一般是在数据库的表中添加一个 version 的字段,在要修改数据之前查询到 version 字段的值,然后在修改的时候,判断 version 的值是否和查询到的一致,如果一致,那么就可以修改数据,同时将 version 的值 +1 ,如果不一致,说明数据被修改了,则不执行更新。

对应执行的 SQL 大概是:

updateset 字段=新值, version = version + 1 where version = 查询到的verison
1

乐观锁不会影响数据的查询,并发效率高。存在的问题是可能看到的数据不是真实的数据,例如看到有库存,然后购买,购买的时候提示没有库存了。

# 13.3 MyBatis-Plus实现乐观锁

下面看一下在 MyBatis-Plus 中,如何实现乐观锁。

# 1 修改数据库表

在数据库表中添加一个 version 字段(自定义的):

ALTER TABLE tb_user
ADD COLUMN version int DEFAULT 1;
1
2

默认值可以设置为 1。

# 2 修改pojo

修改实体类,添加 version 属性与数据库对应。

User.java:

@Data
@TableName("tb_user")
public class User {
    // ... 其他属性

    @Version
    private Integer version;
}
1
2
3
4
5
6
7
8

需要添加 @Version 注解,标识该属性是用来做乐观锁版本控制的。

# 3 插件配置

MyBatis-Plus 中的乐观锁也是通过拦截器来实现的,拦截执行的 SQL,添加版本控制。

和之前的分页插件配置是一样的,在自定义的 MyBatis-Plus 配置类中进行配置:

package com.foooor.helloplus.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {
    /**
     * 添加分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 乐观锁配置
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

        // 分页插件,如果配置多个插件, 切记分页最后添加
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

        return interceptor;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

添加乐观锁配置。

# 4 测试

编写测试方法进行测试。

首先查询用户,然后修改用户信息:

@Test
void testUpdate() {

    // 1.先查询用户
    User user = userMapper.selectById(2);
    log.info("查询结果:{}", user);

    // 2.执行更新操作
    user.setUsername("我最牛逼");
    int result = userMapper.updateById(user);
    log.info("更新结果:{}", result);

}
1
2
3
4
5
6
7
8
9
10
11
12
13

查询出 user 后,里面包含了 version 信息,然后更新的时候,会比较 user 中的 version 信息。

生成的 SQL 如下:

UPDATE tb_user SET username=?, password=?, age=?, email=?, create_time=?, update_time=?, version=? WHERE id=? AND version=?
1

所以在查询后,不要手动修改 version 的值,例如下面手动修改了version 的值,那么更新的结果就是 0,因为更新时候的 version 和查询出来的不一样:

@Test
void testUpdate() {

    // 1.先查询用户
    User user = userMapper.selectById(2);
    log.info("查询结果:{}", user);

    // 2.执行更新操作
    user.setUsername("我最牛逼");
    user.setVersion(1);    // 修改了version的值,和查询出的不一样
    int result = userMapper.updateById(user);
    log.info("更新结果:{}", result);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14