一、为什么需要锁
在多用户并发操作同一条数据的场景下,如果不做控制,可能出现数据被重复修改、状态流转异常等问题。例如两个用户同时对同一条订单执行"开始处理"操作,可能导致订单被重复流转、生成重复的下游单据。
锁的核心作用是:保证同一份数据在同一时刻只能被一个事务修改。
二、悲观锁(Pessimistic Lock)
1. 核心思想
悲观锁假设并发冲突一定会发生,因此在操作数据前先加锁,其他事务必须等待当前事务完成后才能操作。
2. 实现方式
通过 SQL 的 FOR UPDATE 子句实现行级锁:
SELECT * FROM orders WHERE id = 123 FOR UPDATE;
在 MyBatis-Plus 中的写法:
Order order = baseMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, orderId)
.last("FOR UPDATE"));
3. 锁的释放时机
FOR UPDATE 加的行锁在事务提交(commit)或回滚(rollback)时自动释放,而非方法返回时释放。因此必须在事务内使用,否则每次查询自动提交,锁会立即释放,等于未加锁。
@Transactional(rollbackFor = Exception.class)
public void processOrder(Long orderId) {
// FOR UPDATE 在此事务内生效,事务提交后释放
Order order = baseMapper.selectOne(...last("FOR UPDATE"));
// 业务逻辑...
} // 方法结束,Spring 提交事务,锁释放
4. 执行流程
事务A 事务B
─────────────────────────────────────────────
SELECT ... FOR UPDATE (加锁成功)
执行业务逻辑... SELECT ... FOR UPDATE (阻塞等待)
UPDATE ...
COMMIT (释放锁) ← 获取锁,继续执行
但数据已被修改,校验失败
5. 优缺点
| 优点 | 缺点 |
|---|---|
| 数据一致性强,一定能防住并发 | 加锁期间其他事务阻塞等待,并发性能低 |
| 实现简单直观 | 业务逻辑耗时长会导致长时间等待 |
| 适合写多读少场景 | 处理不当可能引发死锁 |
三、乐观锁(Optimistic Lock)
1. 核心思想
乐观锁假设并发冲突概率较低,不主动加锁,而是在更新时校验数据是否已被其他事务修改。若已被修改,则本次更新失败,由调用方决定重试或提示用户。
2. 实现方式一:WHERE 条件携带旧值
更新时将查询时的旧值放入 WHERE 条件,若数据已被修改,WHERE 不匹配,影响行数为 0:
Order exist = baseMapper.selectById(orderId); // 查出旧状态
Order update = new Order();
update.setStatus(OrderStatusEnum.PROCESSING.getCode());
int updated = baseMapper.update(update,
Wrappers.<Order>lambdaUpdate()
.eq(Order::getId, orderId)
.eq(Order::getStatus, exist.getStatus())); // 旧状态作为条件
if (updated <= 0) {
throw new ServiceException("数据状态已变化,请刷新后重试");
}
生成的 SQL:
UPDATE orders
SET status = 'PROCESSING'
WHERE id = 123 AND status = 'PENDING';
3. 实现方式二:版本号字段(MyBatis-Plus @Version)
在实体类中添加 @Version 注解的版本号字段,MyBatis-Plus 自动在更新时校验并自增版本号:
public class Order {
private Long id;
private String status;
@Version
private Integer version;
}
更新时自动生成:
UPDATE orders SET status='PROCESSING', version=version+1
WHERE id=123 AND version=1;
4. 执行流程
事务A 事务B
─────────────────────────────────────────────
查出 status=PENDING
查出 status=PENDING
UPDATE WHERE status=PENDING
→ 成功,改为 PROCESSING
COMMIT
UPDATE WHERE status=PENDING
→ 不匹配,影响行数为 0
检测到冲突,抛异常
5. 优缺点
| 优点 | 缺点 |
|---|---|
| 不加锁,不阻塞其他事务,并发性能好 | 冲突时需手动处理(重试或报错),逻辑较复杂 |
| 不会死锁 | 冲突频繁时不断重试,可能比悲观锁更慢 |
| 适合读多写少场景 | 需要业务层判断更新结果 |
四、双重保险:悲观锁 + 乐观锁组合使用
在核心业务场景中,可同时使用两种锁,层层设防:
@Transactional(rollbackFor = Exception.class)
public Boolean processOrder(Long orderId) {
// 第一层:悲观锁,保证同一时刻只有一个事务操作
Order exist = baseMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, orderId)
.last("FOR UPDATE"));
if (exist == null) {
throw new ServiceException("订单不存在");
}
Order update = new Order();
update.setStatus(OrderStatusEnum.PROCESSING.getCode());
// 第二层:乐观锁,作为兜底校验
int updated = baseMapper.update(update,
Wrappers.<Order>lambdaUpdate()
.eq(Order::getId, orderId)
.eq(Order::getStatus, exist.getStatus()));
if (updated <= 0) {
throw new ServiceException("订单状态已变化,请刷新后重试");
}
return true;
}
这种写法属于防御性编程:即使悲观锁因事务未生效等原因失效,乐观锁仍能保证数据一致性。
五、选型建议
| 场景特征 | 推荐方案 |
|---|---|
| 状态流转、审批等写操作多、冲突概率高 | 悲观锁 |
| 库存扣减、计数器等读多写少、偶发冲突 | 乐观锁 + 重试 |
| 支付、转账等核心业务 | 悲观锁 + 乐观锁双重保险 |
| 并发量极大、冲突极少 | 乐观锁 |
六、常见坑点
- 悲观锁未在事务内使用:
FOR UPDATE必须在事务中才有效,否则锁立即释放 - 事务内调用外部接口:长耗时操作会导致锁长时间持有,阻塞其他请求
- 乐观锁未判断影响行数:更新后必须检查
updated是否为 0,否则无法感知冲突 - 分隔符冲突:复合键拼接时若字段值包含分隔符,会导致误判
七、小结
悲观锁与乐观锁是数据库并发控制的两种基础手段,并非互斥关系。理解两者的原理和适用场景,根据业务特点合理选择,是保障数据一致性的关键。在核心业务中组合使用两种锁,是工程实践中常见的防御性做法。

1424

被折叠的 条评论
为什么被折叠?



