Lecture10-乐观锁

文章目录

    [toc]

    单词词性音标含义
    Optimisticadj./ˌɒptɪˈmɪstɪk/乐观的(乐观锁假设并发冲突很少,更新时才校验)
    Lockn./lɒk/锁(乐观锁通过版本号机制避免并发更新互相覆盖)
    Versionn./ˈvɜːʃn/版本号(@Version 注解的字段,每次更新 +1,用来判断数据是否被改过)
    Concurrentadj./kənˈkʌrənt/并发的(多个请求同时操作同一条数据)

    Lecture10-乐观锁

    一、问题:并发更新会丢数据

    假设两个管理员同时改同一条员工数据的工资:

    • 张三查到工资 8000,准备改成 9000
    • 李四同时查到工资 8000,准备改成 10000
    • 李四先提交成功(工资变成 10000),张三后提交(工资变成 9000)

    结果:张三的提交覆盖了李四的修改,李四的更新"丢了"。这种问题叫丢失更新。

    二、乐观锁的思路

    基本原则:任何人都必须基于最新版修改数据

    乐观锁不锁数据,而是在表里加一个 version(版本号)字段,更新时校验:

    1. 更新前,把读到的 version 一起带上:WHERE id=? AND version=旧值
    2. 更新成功后,version 自动 +1
    3. 如果更新时 version 已经变了(当前版本落后,说明被别人改过),影响行数为 0,本次更新失败,由业务决定是否重试

    乐观锁并发更新流程


    三、配置乐观锁

    1、准备:给表加 version 字段

    ALTER TABLE t_emp ADD COLUMN version INT NOT NULL DEFAULT 1 COMMENT '乐观锁版本号';
    

    2、实体类加 @Version

    @Data
    @TableName("t_emp")
    public class Employee {
    
        @TableId(type = IdType.AUTO)
        private Integer empId;
    
        // ...其他字段
    
        @Version                     // 乐观锁版本号字段
        private Integer version;
    
    }
    

    3、配置类中添加乐观锁插件

    在第 7 章的分页配置类中追加一行:

    @Configuration
    public class MybatisPlusConfig {
    
        @Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));          // 分页插件
            interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());                 // 乐观锁插件
            return interceptor;
        }
    
    }
    

    注意导包:com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor


    四、使用示例

    1、正常更新(version 自动 +1)

    @Test
    public void testOptimisticLock() {
        // 1. 查出来(拿到当前 version)
        Employee employee = employeeMapper.selectById(1);
        System.out.println("当前版本 version = " + employee.getVersion());
    
        // 2. 修改工资
        employee.setEmpSalary(new BigDecimal("9000.00"));
    
        // 3. 更新:框架自动带上 version 条件,成功后 version+1
        int rows = employeeMapper.updateById(employee);
        System.out.println("rows = " + rows);
    }
    
    ==>  Preparing: UPDATE t_emp SET emp_no=?,emp_name=?,emp_salary=?,...,version=? WHERE emp_id=? AND version=?
    ==> Parameters: ..., 2(Integer), 1(Integer), 1(Integer)
    <==    Updates: 1
    

    观察 SQL:SET ... version=2 + WHERE emp_id=? AND version=1。框架自动做了三件事:

    • SET 部分:version 自动 +1
    • WHERE 部分:自动带上「version = 读到的旧值」
    • 只有影响行数为 1 才算更新成功

    2、模拟并发:第二次更新失败

    @Test
    public void testOptimisticLockConflict() {
        // 模拟两个事务同时读到了同一条数据(version 都是 1)
        Employee employeeA = employeeMapper.selectById(1);   // version=1
        Employee employeeB = employeeMapper.selectById(1);   // version=1
    
        // A 先更新成功:version 变成 2
        employeeA.setEmpSalary(new BigDecimal("9500.00"));
        int rowsA = employeeMapper.updateById(employeeA);
        System.out.println("A 更新影响行数 = " + rowsA);      // 1
    
        // B 再用旧 version 更新:条件 version=1 已经不满足
        employeeB.setEmpSalary(new BigDecimal("10000.00"));
        int rowsB = employeeMapper.updateById(employeeB);
        System.out.println("B 更新影响行数 = " + rowsB);      // 0,更新失败!
    }
    
    ==>  Preparing: UPDATE t_emp SET ...,version=? WHERE emp_id=? AND version=?
    ==> Parameters: ..., 2(Integer), 1(Integer), 1(Integer)   ← A 的更新,version 1→2
    <==    Updates: 1
    
    ==>  Preparing: UPDATE t_emp SET ...,version=? WHERE emp_id=? AND version=?
    ==> Parameters: ..., 2(Integer), 1(Integer), 1(Integer)   ← B 的更新,version 已变成 2,条件不满足
    <==    Updates: 0                                         ← B 失败
    

    3、失败后的处理:重试

    业务上检测到影响行数为 0,重新读取最新数据再试一次:

    @Test
    public void testOptimisticLockRetry() {
        Employee employeeA = employeeMapper.selectById(1);
        Employee employeeB = employeeMapper.selectById(1);
    
        // A 先更新成功
        employeeA.setEmpSalary(new BigDecimal("9500.00"));
        employeeMapper.updateById(employeeA);
    
        // B 更新失败
        employeeB.setEmpSalary(new BigDecimal("10000.00"));
        int rowsB = employeeMapper.updateById(employeeB);
    
        if (rowsB == 0) {
            System.out.println("B 更新失败,重新读取最新数据再试一次...");
            // 重新查询:拿到最新的 version=2
            Employee latest = employeeMapper.selectById(1);
            latest.setEmpSalary(new BigDecimal("10000.00"));
            int rowsRetry = employeeMapper.updateById(latest);
            System.out.println("重试结果 rows = " + rowsRetry);   // 1,成功
        }
    }
    

    现实项目里,重试逻辑通常配合「提示用户:数据已被他人修改,请刷新后再试」一起用。


    五、注意事项

    1. @Version 字段类型推荐 Integer / Long / Date / Timestamp
    2. 乐观锁只对 updateById(entity) 和 update(entity, wrapper) 生效 共同点是都需要传入 entity 实体类对象,而关键就是实体类对象中包含 version 值
    3. 逻辑删除字段 is_deleted 与乐观锁 version 互不干扰,可以共存
    4. 每次更新都会带上 version 条件,必须保证 version 有值(查出再改);直接 new 一个对象 updateById 时,version 为 null 会导致条件异常,要注意

    六、本章小结

    • 丢失更新:并发下后提交的覆盖先提交的
    • 乐观锁三步:加 version 列 → 实体加 @Version → 配置类加乐观锁插件
    • 更新时自动带上 version 条件,失败影响行数为 0,业务自己处理重试
    • 几乎零成本接入,是并发安全的基础配置

    下一章学习 Service 层:IService 与链式操作,正式进入三层架构开发。

    end