分布式锁与事务配合:为什么锁要在事务提交后释放
分布式锁与事务配合:为什么锁要在事务提交后释放
一、问题引入
在分布式系统中,多个实例可能同时处理同一条数据。为了防止并发冲突,我们用分布式锁来保证同一时刻只有一个线程在操作某条数据。
但一个常见的错误是:在事务提交之前就释放了锁,导致其他线程读到了"未提交"的中间状态。
二、错误示例:锁在事务内释放
@TransactionalpublicvoidupdateInventory(IntegerskuId,Integerquantity){// 1. 加锁DistributedLocklock=lockProvider.getLock("inventory-"+skuId);lock.tryLock();try{// 2. 查询当前库存Inventoryinventory=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,丢失了一次扣减!三、正确做法:事务提交后再释放锁
@TransactionalpublicvoidupdateInventory(IntegerskuId,Integerquantity){// 1. 加锁DistributedLocklock=lockProvider.getLock("inventory-"+skuId,TimeUnit.MINUTES,2);if(!lock.tryLock(TimeUnit.SECONDS,30)){thrownewRuntimeException("获取锁超时");}// 2. 注册事务完成后释放锁(无论提交还是回滚都释放)AfterTransactionActionCollectorcollector=newAfterTransactionActionCollector();collector.addCommitSyncAction(lock::unlock);collector.addRollbackSyncAction(lock::unlock);TransactionSynchronizationManager.registerSynchronization(collector);// 3. 执行业务逻辑Inventoryinventory=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 为什么回滚时也要释放锁
collector.addCommitSyncAction(lock::unlock);// 提交后释放collector.addRollbackSyncAction(lock::unlock);// 回滚后也释放如果事务回滚了但不释放锁,这把锁就会一直被持有,直到超时自动释放。在超时之前,其他线程都无法获取锁,造成业务阻塞。
五、分布式锁基础知识
5.1 什么是分布式锁
在单机环境中,Java 的synchronized或ReentrantLock可以保证线程安全。但在分布式环境(多个服务实例)中,这些本地锁无效,需要一个所有实例都能访问的"中央锁服务"。
常见实现:
| 实现方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis | SETNX + 过期时间 | 性能高,使用广泛 | 主从切换时可能丢锁 |
| ZooKeeper | 临时有序节点 | 强一致性 | 性能较低 |
| 数据库 | 唯一索引/行锁 | 无需额外中间件 | 性能差,不推荐 |
5.2 分布式锁的核心API
publicinterfaceDistributedLock{/** * 尝试加锁,等待指定时间. * @return true=加锁成功,false=超时未获取到 */booleantryLock(TimeUnitunit,longtimeout);/** * 释放锁. */voidunlock();}publicinterfaceDistributedLockProvider{/** * 获取一把锁. * @param key 锁的唯一标识 * @param unit 锁的最大持有时间单位 * @param duration 锁的最大持有时间(防止死锁的兜底) */DistributedLockgetLock(Stringkey,TimeUnitunit,longduration);}5.3 锁的超时时间
// 锁最多持有2分钟,超时自动释放(防止死锁)DistributedLocklock=lockProvider.getLock("order-process-"+orderId,TimeUnit.MINUTES,2);超时时间的设置原则:
- 必须大于业务方法的最大执行时间
- 不能太长,否则异常退出时其他线程等待时间过久
- 一般设置为业务耗时的 2-3 倍
六、完整示例:防止订单重复处理
6.1 业务场景
MQ 消费者可能重复收到同一条消息(网络重试、消费者重启等),需要保证同一订单不会被并发处理。
6.2 完整代码
@ServicepublicclassOrderProcessServiceImplimplementsOrderProcessService{@ResourceprivateDistributedLockProviderdistributedLockProvider;@ResourceprivateOrderRepositoryorderRepository;@ResourceprivateStockServicestockService;@ResourceprivatePaymentServicepaymentService;/** * 处理订单(MQ消费者调用). * 使用分布式锁防止同一订单被并发处理. */@Transactional(rollbackFor=Exception.class)publicvoidprocessOrder(IntegerorderId){// ====== 第一步:加锁 ======StringlockKey="order-process-"+orderId;DistributedLocklock=distributedLockProvider.getLock(lockKey,TimeUnit.MINUTES,2);// 等待锁,最多等30秒if(!lock.tryLock(TimeUnit.SECONDS,30)){log.warn("获取订单处理锁超时, orderId:{}",orderId);thrownewRuntimeException("订单正在处理中,请稍后重试");}// ====== 第二步:注册事务完成后释放锁 ======AfterTransactionActionCollectorcollector=newAfterTransactionActionCollector();collector.addCommitSyncAction(lock::unlock);collector.addRollbackSyncAction(lock::unlock);TransactionSynchronizationManager.registerSynchronization(collector);// ====== 第三步:执行业务逻辑 ======// 查询订单Orderorder=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 决定了并发控制的范围
// 粗粒度:按会员维度加锁(同一会员的所有操作串行)StringlockKey="member-"+memberId;// 细粒度:按订单维度加锁(只有同一订单的操作串行)StringlockKey="order-"+orderId;// 更细粒度:按SKU维度加锁(只有同一商品的库存操作串行)StringlockKey="stock-"+skuId;| 粒度 | 并发度 | 安全性 | 适用场景 |
|---|---|---|---|
| 粗(会员级) | 低 | 高 | 涉及会员多个资源的操作 |
| 中(订单级) | 中 | 中 | 订单状态变更 |
| 细(SKU级) | 高 | 需要额外保证 | 库存扣减 |
7.2 锁 Key 的命名规范
// 推荐格式:业务域-操作-唯一标识"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 加锁在事务外面
// 错误:锁在事务外加,事务内释放publicvoidouterMethod(IntegerorderId){DistributedLocklock=lockProvider.getLock("order-"+orderId);lock.tryLock();try{innerTransactionalMethod(orderId);// @Transactional}finally{lock.unlock();// 此时事务可能还没提交!}}这种情况下,innerTransactionalMethod的事务可能还没提交,锁就被释放了。正确做法是把锁的释放放到事务同步回调中。
8.3 忘记释放锁
@TransactionalpublicvoidprocessOrder(IntegerorderId){DistributedLocklock=lockProvider.getLock("order-"+orderId);lock.tryLock();// 如果这里抛异常,锁永远不会释放(直到超时)Orderorder=orderRepository.findById(orderId).orElseThrow();// ...}解决方案:使用事务同步回调,无论提交还是回滚都释放锁。
collector.addCommitSyncAction(lock::unlock);collector.addRollbackSyncAction(lock::unlock);8.4 可重入性问题
@TransactionalpublicvoidmethodA(IntegerorderId){DistributedLocklock=lockProvider.getLock("order-"+orderId);lock.tryLock();// ...methodB(orderId);// 内部也尝试加同一把锁}@TransactionalpublicvoidmethodB(IntegerorderId){DistributedLocklock=lockProvider.getLock("order-"+orderId);lock.tryLock();// 如果锁不支持可重入,这里会死锁!}解决方案:使用支持可重入的分布式锁实现(如 Redisson 的 RLock)。
九、与本地锁的对比
// 本地锁(只在单个JVM内有效)privatefinalReentrantLocklocalLock=newReentrantLock();publicvoidlocalMethod(){localLock.lock();try{// 业务逻辑}finally{localLock.unlock();}}// 分布式锁(跨多个JVM实例有效)publicvoiddistributedMethod(){DistributedLocklock=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 在事务提交前释放,事务回调在提交后释放 |
