前言
在单体应用开发中,我们习惯了用 synchronized 或 ReentrantLock 来解决线程安全问题。但在分布式系统或多个人同时操作数据库的场景下,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 字段。
- 取数据: 查询出商品信息,同时获取当前版本号
version = 1。 - 改数据: 做业务逻辑。
- 存数据: 更新时,带上刚才查出来的版本号作为条件。
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。
- 实体类加注解:
Java
public class Goods { private Long id; private Integer stock; @Version // 标记这是版本号字段 private Integer version; }
- 配置拦截器:
Java
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加乐观锁插件 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }
- 业务代码:
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 开销(因为可能需要重试循环) |
| 安全性 | 高 | 中(需要业务层配合处理失败) |
| 推荐场景 | 强一致性、写冲突极高 | 互联网高并发、读多写少 |
一句话口诀:
冲突多,用悲观,加上排他保平安;
冲突少,用乐观,版本控制效率翻。