【并发实战】拒绝数据乱套!乐观锁与悲观锁的落地应用指南

简介: 在分布式场景下,Java锁无法解决数据并发问题。本文详解悲观锁(SELECT FOR UPDATE)与乐观锁(版本号机制),结合MySQL与MyBatis-Plus实战,助你应对秒杀超卖等高并发难题,提升系统一致性与性能。

前言

在单体应用开发中,我们习惯了用 synchronizedReentrantLock 来解决线程安全问题。但在分布式系统或多个人同时操作数据库的场景下,Java 级别的锁就鞭长莫及了。

最经典的场景就是**“秒杀扣库存”**:

假设库存只有 1 件。

  • 用户 A 读取库存:1
  • 用户 B 读取库存:1
  • 用户 A 扣减库存:0,写入数据库。
  • 用户 B 扣减库存:0,写入数据库。
  • 结果: 卖出了 2 件商品,库存变成 0(甚至 -1),这就是严重的超卖事故。

为了解决这个问题,我们需要借助数据库层面的锁机制:悲观锁乐观锁

1. 悲观锁 (Pessimistic Lock)

心态: “总有刁民想害朕”。

悲观锁总是假设最坏的情况,认为自己在使用数据的时候,一定会有别人来修改数据。所以,在获取数据时就直接加锁,直到我用完提交事务,别人才能用。

实现方式:SELECT ... FOR UPDATE

在 MySQL InnoDB 引擎中,我们可以使用 FOR UPDATE 来触发行锁。

SQL 示例:

SQL

-- 开启事务
BEGIN;
-- 1. 查询并锁定这条记录(注意:必须在事务中才生效)
-- 此时,其他事务如果想修改这条 id=1 的数据,会被阻塞等待
SELECT stock FROM goods WHERE id = 1 FOR UPDATE;
-- 2. 判断库存 > 0,然后执行扣减
UPDATE goods SET stock = stock - 1 WHERE id = 1;
-- 3. 提交事务,释放锁
COMMIT;

优缺点分析

  • 优点: 简单粗暴,数据安全性极高,完全杜绝了并发冲突。
  • 缺点: 性能较差。因为是排他锁,并发高时会造成大量线程阻塞(Waiting),甚至引发死锁。
  • 适用场景: 写多读少,且并发量不是特别大的强一致性场景(如银行转账)。

2. 乐观锁 (Optimistic Lock)

心态: “世界充满爱”。

乐观锁假设数据一般情况下不会造成冲突,所以查询时不上锁。只在更新数据的那一刻,去检查一下在此期间有没有人修改过这条数据。

实现方式:版本号机制 (Version)

这是最通用的实现方案。我们在表中增加一个 version 字段。

  1. 取数据: 查询出商品信息,同时获取当前版本号 version = 1
  2. 改数据: 做业务逻辑。
  3. 存数据: 更新时,带上刚才查出来的版本号作为条件。

SQL 示例:

SQL

-- 1. 查询库存和版本号
SELECT stock, version FROM goods WHERE id = 1;
-- 假设查出来 version = 1
-- 2. 更新时,检查 version 是否还是 1
-- 如果这期间没人修改,version 还是 1,更新成功,version 变 2
-- 如果有人修改过,version 变成了 2,条件不满足,更新失败 (受影响行数 0)
UPDATE goods SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = 1;

Java 实战(MyBatis-Plus)

在 Java 开发中,MyBatis-Plus 提供了开箱即用的乐观锁插件,甚至不需要你写 SQL。

  1. 实体类加注解:
    Java
public class Goods {
    private Long id;
    private Integer stock;
    @Version // 标记这是版本号字段
    private Integer version;
}
  1. 配置拦截器:
    Java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 添加乐观锁插件
    interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
    return interceptor;
}
  1. 业务代码:
    Java
// 1. 先查
Goods goods = goodsMapper.selectById(1L);
// 2. 修改
goods.setStock(goods.getStock() - 1);
// 3. 更新 (MP会自动带上 version 条件并自增)
int result = goodsMapper.updateById(goods);
if (result == 0) {
    // 更新失败,说明被人抢先了,可以抛异常或重试
    throw new RuntimeException("手慢了,请重试!");
}

优缺点分析

  • 优点: 吞吐量高,不需要数据库加锁,不会死锁。
  • 缺点: 存在 ABA 问题(不过用 Version 机制可解);在高并发写的时候,失败率高,需要业务代码实现“重试机制”。
  • 适用场景: 读多写少的场景(如抢优惠券、编辑个人信息)。

3. 总结与选型

维度 悲观锁 乐观锁
侧重点 宁可排队,也不能错 只要没人跟我抢,我就能跑得快
开销 数据库锁开销,高并发易阻塞 CPU 开销(因为可能需要重试循环)
安全性 中(需要业务层配合处理失败)
推荐场景 强一致性、写冲突极高 互联网高并发、读多写少

一句话口诀:

冲突多,用悲观,加上排他保平安;

冲突少,用乐观,版本控制效率翻。

相关文章
|
JSON 数据格式
Nestjs(三)接收参数 @Query @Body @Param(post、get 、put、delete ...)
Nestjs(三)接收参数 @Query @Body @Param(post、get 、put、delete ...)
1061 4
|
移动开发
钉钉H5微应用配置IP,应用首页地址报错:app url exceeds max length limit,这个怎么处理?
钉钉H5微应用配置IP,应用首页地址报错:app url exceeds max length limit,这个怎么处理?
1367 0
|
Java 微服务 Spring
FeignClient GET请求方式无法解析对象参数
FeignClient GET请求方式无法解析对象参数,报java.lang.IllegalArgumentException: method GET must not have a request body
1388 0
|
算法 数据库 开发者
[软件工程导论(第六版)]第3章 需求分析(复习笔记)
[软件工程导论(第六版)]第3章 需求分析(复习笔记)
【JAVA学习之路 | 进阶篇】Collection中常用方法
【JAVA学习之路 | 进阶篇】Collection中常用方法
|
Web App开发 编解码 vr&ar
使用FFmpeg从音视频处理到流媒体技术的探索和实战应用
使用FFmpeg从音视频处理到流媒体技术的探索和实战应用
785 2
|
设计模式 算法 Java
谈谈springboot的模板方法模式
【4月更文挑战第15天】模板方法模式是一种在软件工程中广泛使用的设计模式,它定义了一个操作中的骨架,将某些步骤延迟到子类中实现。这样可以在不改变算法结构的情况下重新定义算法的某些特定步骤。Spring Boot利用这种模式提供了一种非常灵活的方式来扩展和定制标准功能。
373 2
|
设计模式 算法 Java
【设计模式】springboot3项目整合模板方法深入理解设计模式之模板方法(Template Method)
【设计模式】springboot3项目整合模板方法深入理解设计模式之模板方法(Template Method)
|
存储 消息中间件 缓存
并发编程 - 通过 Disruptor 来实现无锁无阻塞的并发编程
并发编程 - 通过 Disruptor 来实现无锁无阻塞的并发编程
1260 1