分布式锁与事务配合:为什么锁要在事务提交后释放
一、问题引入
在分布式系统中,多个实例可能同时处理同一条数据。为了防止并发冲突,我们用分布式锁来保证同一时刻只有一个线程在操作某条数据。
但一个常见的错误是:在事务提交之前就释放了锁,导致其他线程读到了"未提交"的中间状态。
二、错误示例:锁在事务内释放
java
@Transactional
public void updateInventory(Integer skuId, Integer quantity) {
// 1. 加锁
DistributedLock lock = lockProvider.getLock("inventory-" + skuId);
lock.tryLock();
try {
// 2. 查询当前库存
Inventory inventory = inventoryRepository.findBySkuId(skuId);
// 3. 扣减库存
inventory.setQuantity(inventory.getQuantity() - quantity);
inventoryRepository.save(inventory);
} finally {
// 4. 释放锁 ← 问题在这里!
lock.unlock();
}
// 5. 方法结束,Spring 才会提交事务
}
时序问题:
线程A 线程B
│ │
├── 加锁成功 │
├── 查库存=100 │
├── 扣减为90 │
├── save(未提交!) │
├── 释放锁 ←─────────────────── 此时事务还没提交!
│ ├── 加锁成功
│ ├── 查库存=100 ← 读到了旧值!
│ ├── 扣减为90
│ ├── save
├── 事务提交(库存=90) ├── 事务提交(库存=90)
│ │
结果:扣了两次,但库存只减了10,丢失了一次扣减!
三、正确做法:事务提交后再释放锁
java
@Transactional
public void updateInventory(Integer skuId, Integer quantity) {
// 1. 加锁
DistributedLock lock = lockProvider.getLock(
"inventory-" + skuId, TimeUnit.MINUTES, 2);
if (!lock.tryLock(TimeUnit.SECONDS, 30)) {
throw new RuntimeException("获取锁超时");
}
// 2. 注册事务完成后释放锁(无论提交还是回滚都释放)
AfterTransactionActionCollector collector =
new AfterTransactionActionCollector();
collector.addCommitSyncAction(lock::unlock);
collector.addRollbackSyncAction(lock::unlock);
TransactionSynchronizationManager.registerSynchronization(collector);
// 3. 执行业务逻辑
Inventory inventory = inventoryRepository.findBySkuId(skuId);
inventory.setQuantity(inventory.getQuantity() - quantity);
inventoryRepository.save(inventory);
}
正确的时序:
线程A 线程B
│ │
├── 加锁成功 │
├── 查库存=100 │
├── 扣减为90 │
├── save │
├── 事务提交(库存=90) │
├── afterCommit → 释放锁 ─────── 此时数据已经持久化
│ ├── 加锁成功
│ ├── 查库存=90 ← 读到了正确的值
│ ├── 扣减为80
│ ├── save
│ ├── 事务提交(库存=80)
│ ├── 释放锁
│ │
结果:两次扣减都正确生效,库存从100→90→80
四、核心原理
4.1 事务隔离级别与可见性
在 MySQL 默认的 REPEATABLE READ 隔离级别下:
- 事务内的修改,在 COMMIT 之前,其他事务是看不到的
- 只有 COMMIT 之后,修改才对其他事务可见
所以如果锁在 COMMIT 之前释放,其他线程拿到锁后读到的还是旧数据。
4.2 锁的持有时间 = 事务的完整生命周期
加锁 ──────────────────────────────────────── 释放锁
│ │
│ ┌─── 事务开始 ───────── 事务提交 ───┐ │
│ │ │ │
│ │ 查询 → 计算 → 写入 │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
└──────────── 锁必须覆盖整个事务 ──────────────┘
4.3 为什么回滚时也要释放锁
java
collector.addCommitSyncAction(lock::unlock); // 提交后释放
collector.addRollbackSyncAction(lock::unlock); // 回滚后也释放
如果事务回滚了但不释放锁,这把锁就会一直被持有,直到超时自动释放。在超时之前,其他线程都无法获取锁,造成业务阻塞。
五、分布式锁基础知识
5.1 什么是分布式锁
在单机环境中,Java 的 synchronized 或 ReentrantLock 可以保证线程安全。但在分布式环境(多个服务实例)中,这些本地锁无效,需要一个所有实例都能访问的"中央锁服务"。
常见实现:
| 实现方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis | SETNX + 过期时间 | 性能高,使用广泛 | 主从切换时可能丢锁 |
| ZooKeeper | 临时有序节点 | 强一致性 | 性能较低 |
| 数据库 | 唯一索引/行锁 | 无需额外中间件 | 性能差,不推荐 |
5.2 分布式锁的核心API
java
public interface DistributedLock {
/**
* 尝试加锁,等待指定时间.
* @return true=加锁成功,false=超时未获取到
*/
boolean tryLock(TimeUnit unit, long timeout);
/**
* 释放锁.
*/
void unlock();
}
public interface DistributedLockProvider {
/**
* 获取一把锁.
* @param key 锁的唯一标识
* @param unit 锁的最大持有时间单位
* @param duration 锁的最大持有时间(防止死锁的兜底)
*/
DistributedLock getLock(String key, TimeUnit unit, long duration);
}
5.3 锁的超时时间
java
// 锁最多持有2分钟,超时自动释放(防止死锁)
DistributedLock lock = lockProvider.getLock(
"order-process-" + orderId, TimeUnit.MINUTES, 2);
超时时间的设置原则:
- 必须大于业务方法的最大执行时间
- 不能太长,否则异常退出时其他线程等待时间过久
- 一般设置为业务耗时的 2-3 倍
六、完整示例:防止订单重复处理
6.1 业务场景
MQ 消费者可能重复收到同一条消息(网络重试、消费者重启等),需要保证同一订单不会被并发处理。
6.2 完整代码
java
@Service
public class OrderProcessServiceImpl implements OrderProcessService {
@Resource
private DistributedLockProvider distributedLockProvider;
@Resource
private OrderRepository orderRepository;
@Resource
private StockService stockService;
@Resource
private PaymentService paymentService;
/**
* 处理订单(MQ消费者调用).
* 使用分布式锁防止同一订单被并发处理.
*/
@Transactional(rollbackFor = Exception.class)
public void processOrder(Integer orderId) {
// ====== 第一步:加锁 ======
String lockKey = "order-process-" + orderId;
DistributedLock lock = distributedLockProvider.getLock(
lockKey, TimeUnit.MINUTES, 2);
// 等待锁,最多等30秒
if (!lock.tryLock(TimeUnit.SECONDS, 30)) {
log.warn("获取订单处理锁超时, orderId:{}", orderId);
throw new RuntimeException("订单正在处理中,请稍后重试");
}
// ====== 第二步:注册事务完成后释放锁 ======
AfterTransactionActionCollector collector =
new AfterTransactionActionCollector();
collector.addCommitSyncAction(lock::unlock);
collector.addRollbackSyncAction(lock::unlock);
TransactionSynchronizationManager
.registerSynchronization(collector);
// ====== 第三步:执行业务逻辑 ======
// 查询订单
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) {
log.warn("订单不存在, orderId:{}", orderId);
return;
}
// 幂等检查:已处理的订单直接跳过
if ("PROCESSED".equals(order.getStatus())) {
log.info("订单已处理,跳过, orderId:{}", orderId);
return;
}
// 扣减库存
stockService.deductStock(order.getSkuId(), order.getQuantity());
// 扣款
paymentService.charge(order.getUserId(), order.getAmount());
// 更新订单状态
order.setStatus("PROCESSED");
orderRepository.save(order);
log.info("订单处理完成, orderId:{}", orderId);
}
}
6.3 执行流程图
MQ消费者收到消息(orderId=123)
│
▼
加锁:order-process-123
│
├── 加锁成功
│ │
│ ▼
│ 注册事务后释放锁的回调
│ │
│ ▼
│ 查询订单 → 幂等检查 → 扣库存 → 扣款 → 更新状态
│ │
│ ▼
│ 事务提交(所有数据库操作生效)
│ │
│ ▼
│ afterCommit → 释放锁
│
└── 加锁失败(超时)
│
▼
抛异常 → MQ稍后重试
七、锁的粒度设计
7.1 锁的 Key 决定了并发控制的范围
java
// 粗粒度:按会员维度加锁(同一会员的所有操作串行)
String lockKey = "member-" + memberId;
// 细粒度:按订单维度加锁(只有同一订单的操作串行)
String lockKey = "order-" + orderId;
// 更细粒度:按SKU维度加锁(只有同一商品的库存操作串行)
String lockKey = "stock-" + skuId;
| 粒度 | 并发度 | 安全性 | 适用场景 |
|---|---|---|---|
| 粗(会员级) | 低 | 高 | 涉及会员多个资源的操作 |
| 中(订单级) | 中 | 中 | 订单状态变更 |
| 细(SKU级) | 高 | 需要额外保证 | 库存扣减 |
7.2 锁 Key 的命名规范
java
// 推荐格式:业务域-操作-唯一标识
"inventory-deduct-" + skuId
"order-process-" + orderId
"delivery-cancel-" + deliveryCode
"cs-outbound-flow-to-ylh-" + memberId
八、常见陷阱
8.1 锁超时但事务还没结束
线程A 加锁(超时2分钟)
│
├── 开始处理(业务耗时3分钟)
│
├── 2分钟后:锁自动释放!
│ 线程B 加锁成功
│ 线程B 开始处理同一数据
│
├── 3分钟后:线程A事务提交
│ 线程B 事务提交
│
结果:数据被覆盖,出现并发问题
解决方案:
- 锁的超时时间要大于业务最大耗时
- 使用看门狗机制自动续期(如 Redisson 的 watchdog)
8.2 加锁在事务外面
java
// 错误:锁在事务外加,事务内释放
public void outerMethod(Integer orderId) {
DistributedLock lock = lockProvider.getLock("order-" + orderId);
lock.tryLock();
try {
innerTransactionalMethod(orderId); // @Transactional
} finally {
lock.unlock(); // 此时事务可能还没提交!
}
}
这种情况下,innerTransactionalMethod 的事务可能还没提交,锁就被释放了。正确做法是把锁的释放放到事务同步回调中。
8.3 忘记释放锁
java
@Transactional
public void processOrder(Integer orderId) {
DistributedLock lock = lockProvider.getLock("order-" + orderId);
lock.tryLock();
// 如果这里抛异常,锁永远不会释放(直到超时)
Order order = orderRepository.findById(orderId).orElseThrow();
// ...
}
解决方案:使用事务同步回调,无论提交还是回滚都释放锁。
java
collector.addCommitSyncAction(lock::unlock);
collector.addRollbackSyncAction(lock::unlock);
8.4 可重入性问题
java
@Transactional
public void methodA(Integer orderId) {
DistributedLock lock = lockProvider.getLock("order-" + orderId);
lock.tryLock();
// ...
methodB(orderId); // 内部也尝试加同一把锁
}
@Transactional
public void methodB(Integer orderId) {
DistributedLock lock = lockProvider.getLock("order-" + orderId);
lock.tryLock(); // 如果锁不支持可重入,这里会死锁!
}
解决方案:使用支持可重入的分布式锁实现(如 Redisson 的 RLock)。
九、与本地锁的对比
java
// 本地锁(只在单个JVM内有效)
private final ReentrantLock localLock = new ReentrantLock();
public void localMethod() {
localLock.lock();
try {
// 业务逻辑
} finally {
localLock.unlock();
}
}
// 分布式锁(跨多个JVM实例有效)
public void distributedMethod() {
DistributedLock lock = lockProvider.getLock("key");
lock.tryLock();
// 注册事务后释放
collector.addCommitSyncAction(lock::unlock);
collector.addRollbackSyncAction(lock::unlock);
// 业务逻辑
}
| 维度 | 本地锁 | 分布式锁 |
|---|---|---|
| 作用范围 | 单个JVM进程 | 跨多个服务实例 |
| 实现方式 | synchronized/ReentrantLock | Redis/ZooKeeper |
| 性能 | 纳秒级 | 毫秒级(网络IO) |
| 可靠性 | 进程崩溃自动释放 | 需要超时机制兜底 |
| 适用场景 | 单机部署 | 集群/微服务部署 |
十、总结
| 问题 | 答案 |
|---|---|
| 为什么锁不能在事务内释放? | 释放锁后其他线程可能读到未提交的数据 |
| 为什么回滚时也要释放锁? | 避免锁被永久持有导致其他线程阻塞 |
| 锁的超时时间怎么设? | 业务最大耗时的 2-3 倍 |
| 锁的 Key 怎么设计? | 业务域-操作-唯一标识,粒度越细并发度越高 |
| 和 try-finally 释放有什么区别? | try-finally 在事务提交前释放,事务回调在提交后释放 |