Redis 分布式锁实战:基于 Redisson 的可靠锁方案与 MySQL 并发更新保护
从一个库存扣减的线上故障说起
去年双十一前夜,我负责的订单服务突然收到告警:大量请求返回 500,数据库连接池被打满。排查日志后发现,一个看似简单的库存扣减接口,在压测时出现了严重的超卖——100 件库存的商品,最终卖出了 137 件。
问题出在这段代码上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @Service public class InventoryService { @Autowired private JdbcTemplate jdbcTemplate; public boolean deductStock(Long productId, int quantity) { Integer current = jdbcTemplate.queryForObject( "SELECT stock FROM inventory WHERE product_id = ?", Integer.class, productId ); if (current == null || current < quantity) { return false; } int rows = jdbcTemplate.update( "UPDATE inventory SET stock = stock - ? WHERE product_id = ?", quantity, productId ); return rows > 0; } }
|
这段代码在高并发下,多个线程可能同时读到相同的 current 值,然后都通过了库存检查,导致超卖。
根本原因:SELECT 和 UPDATE 之间存在时间窗口,不是原子操作。单机的 synchronized 或 ReentrantLock 在多实例部署时完全失效,因为 JVM 锁无法跨进程。
为什么需要分布式锁
在微服务架构下,同一个服务通常部署多个实例。即使代码层面用了 JVM 锁,不同实例上的线程依然无法互斥。核心问题可以抽象为:
1 2 3 4 5 6 7 8 9 10
| 线程A(实例1) 线程B(实例2) | | ├── 读库存:100 | | ├── 读库存:100 ├── 检查通过 | | ├── 检查通过 ├── 扣减 → 90 | | ├── 扣减 → 90 | | 两个线程都扣减成功,但库存只减了 10 而非 20
|
分布式锁的目标:让不同进程/实例上的线程,在访问共享资源前先获取一把全局唯一的锁,只有获取成功的线程才能执行临界区代码。
分布式锁的核心要求
一把可靠的分布式锁,需要满足以下几个条件:
| 特性 |
说明 |
不满足的后果 |
| 互斥性 |
任意时刻只有一个客户端持有锁 |
多个客户端同时进入临界区,数据错乱 |
| 避免死锁 |
持有锁的客户端崩溃后,锁能被释放 |
客户端宕机后锁永久不释放,所有请求阻塞 |
| 锁续期 |
业务执行时间超过锁的过期时间时,锁能自动续期 |
业务还没执行完,锁就过期了,其他线程进入 |
| 可重入 |
同一个线程可以多次获取同一把锁 |
递归调用或嵌套调用时自己阻塞自己 |
| 释放正确性 |
只能释放自己持有的锁 |
误删其他线程的锁 |
Redis 的 SET key value NX EX 命令天然支持互斥和过期时间,但锁续期和可重入这两个高级特性,需要额外的机制来实现。
手写一个简单的 Redis 锁很容易,但要把上述所有特性都做对,复杂度会急剧上升。这正是 Redisson 的价值所在。
Redisson 分布式锁快速上手
环境准备
依赖(Maven):
1 2 3 4 5
| <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency>
|
Redis 配置(application.yml):
1 2 3 4 5 6
| spring: redis: host: localhost port: 6379 database: 0 timeout: 3000ms
|
验证环境:Redis 7.0、MySQL 8.0、JDK 17、Spring Boot 3.1.x
基础用法:保护库存扣减
将前面的问题代码改造为基于 Redisson 的分布式锁版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service public class InventoryServiceWithLock {
@Autowired private RedissonClient redissonClient; @Autowired private JdbcTemplate jdbcTemplate;
public boolean deductStock(Long productId, int quantity) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); try { boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("系统繁忙,请稍后重试"); } Integer current = jdbcTemplate.queryForObject( "SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE", Integer.class, productId ); if (current == null || current < quantity) { return false; } int rows = jdbcTemplate.update( "UPDATE inventory SET stock = stock - ? WHERE product_id = ?", quantity, productId ); return rows > 0; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取锁被中断", e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }
|
几个关键点的解释:
tryLock(3, 10, TimeUnit.SECONDS) 的三个参数:waitTime(等待获取锁的最长时间)、leaseTime(锁的租约时间)、时间单位。leaseTime 传 -1 或不传时,Redisson 会启用看门狗机制,默认 30 秒自动续期。
isHeldByCurrentThread() 检查是防止锁已经过期自动释放,而业务还在执行,此时如果直接 unlock() 会把其他线程刚获取的锁释放掉。
锁粒度:按 productId 加锁,而不是一把全局大锁。这样不同商品之间的扣减操作可以并行。
看门狗机制:自动续期的原理
上面的代码中,tryLock(3, 10, TimeUnit.SECONDS) 指定了 leaseTime = 10 秒。这意味着锁的过期时间是固定的 10 秒——如果业务执行超过 10 秒,锁会自动释放,其他线程就能进入。
但如果我们不指定 leaseTime,Redisson 会启用看门狗(Watchdog):
1 2
| boolean locked = lock.tryLock(3, TimeUnit.SECONDS);
|
看门狗的工作机制:
1 2 3 4 5 6 7 8 9 10
| 时间轴(秒) 0 10 20 30 │ │ │ │ 锁初始过期 ├────────┼─────────┼─────────┤ │ │ │ │ 看门狗续期 │ 续期→30s 续期→30s 续期→30s │ │ │ │ 业务执行 ├─────────────────────────────┤ │ (假设业务执行了 25 秒) │ │ │ 释放锁 └───────────────────────────┘
|
核心区别:
- 不指定
leaseTime:看门狗默认 30 秒过期,每 10 秒检查一次,如果业务还没执行完,自动将过期时间重置为 30 秒。业务执行完手动释放锁后,看门狗停止续期。
- 指定了
leaseTime:锁的过期时间固定,业务超时后锁自动释放,看门狗不参与续期。
实战建议:如果业务执行时间可控且较短(如简单的库存扣减),可以指定 leaseTime;如果业务执行时间不确定(如调用了外部服务或复杂计算),优先使用看门狗机制。
可重入锁:同一个线程可以多次获取
Redisson 的 RLock 是可重入的。看下面这个例子,在同一个线程内,deductStock 方法调用 updateStockAndRecord 方法,两个方法需要获取同一把锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Service public class OrderServiceWithReentrantLock {
@Autowired private RedissonClient redissonClient;
public void processOrder(Long productId, int quantity, Long orderId) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); try { lock.lock(); System.out.println("外层获取锁成功,线程: " + Thread.currentThread().getName()); updateStockAndRecord(productId, quantity, orderId); } finally { lock.unlock(); System.out.println("外层释放锁"); } } private void updateStockAndRecord(Long productId, int quantity, Long orderId) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); try { lock.lock(); System.out.println("内层获取锁成功(可重入),线程: " + Thread.currentThread().getName()); } finally { lock.unlock(); System.out.println("内层释放锁"); } } }
|
运行结果:
1 2 3 4
| 外层获取锁成功,线程: main 内层获取锁成功(可重入),线程: main 内层释放锁 外层释放锁
|
如果不支持可重入,内层的 lock.lock() 会一直阻塞,导致死锁。Redisson 通过在 Redis 中使用 Hash 结构存储锁信息,field 为线程 ID,value 为重入次数,每次获取锁时重入次数 +1,释放时 -1,归零时删除锁。
公平锁:按请求顺序获取锁
默认情况下,Redisson 的锁是非公平的——当锁释放时,等待队列中的线程随机(或由 Redis 决定)获取锁。在某些场景下(如电商秒杀排队),我们希望按请求到达的先后顺序获取锁,此时需要使用公平锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger;
@Service public class FairLockDemo {
@Autowired private RedissonClient redissonClient;
public void demoFairLock() throws InterruptedException { String lockKey = "lock:fair:demo"; AtomicInteger order = new AtomicInteger(0); CountDownLatch latch = new CountDownLatch(5); for (int i = 0; i < 5; i++) { final int threadNum = i; new Thread(() -> { RLock fairLock = redissonClient.getFairLock(lockKey); try { fairLock.lock(); int seq = order.incrementAndGet(); System.out.println("线程 " + threadNum + " 第 " + seq + " 个获取锁"); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { fairLock.unlock(); latch.countDown(); } }).start(); } latch.await(); } }
|
公平锁的底层实现与普通锁不同,它使用 Redis 的 List 结构作为等待队列,配合 Set 结构存储等待线程的排序分数,确保线程按照请求顺序获取锁。
实战建议:公平锁的性能比非公平锁低约 20%-30%。只有在业务明确要求”先来先服务”时才使用公平锁,常规场景用非公平锁即可。
分布式锁与 MySQL 事务的整合陷阱
这是实际项目中最容易出错的地方。很多人以为”加了分布式锁就万事大吉”,但忽略了锁的释放时机和事务提交时机的不一致。
错误示例:事务未提交就释放锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Transactional public boolean deductStockWithTransaction(Long productId, int quantity) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); try { lock.lock(); } finally { lock.unlock(); } return true; }
|
问题:@Transactional 的事务提交发生在方法返回之后,而 lock.unlock() 在方法返回前执行。也就是说,锁释放后,事务才提交。在这个时间窗口内,另一个线程可能获取到锁,但读到的还是旧数据(取决于隔离级别),导致并发问题。
正确做法:手动控制事务提交顺序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition;
@Service public class InventoryServiceWithTxAndLock {
@Autowired private RedissonClient redissonClient; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager transactionManager;
public boolean deductStock(Long productId, int quantity) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); lock.lock(); try { DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); TransactionStatus status = transactionManager.getTransaction(def); try { Integer current = jdbcTemplate.queryForObject( "SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE", Integer.class, productId ); if (current == null || current < quantity) { transactionManager.rollback(status); return false; } jdbcTemplate.update( "UPDATE inventory SET stock = stock - ? WHERE product_id = ?", quantity, productId ); transactionManager.commit(status); return true; } catch (Exception e) { transactionManager.rollback(status); throw e; } } finally { lock.unlock(); } } }
|
正确的时序:
1 2 3
| 线程A:获取锁 → 开启事务 → 业务操作 → 提交事务 → 释放锁 │ 线程B: ├── 获取锁 → 读到已提交的最新数据
|
为什么推荐手动管理事务
| 方案 |
锁释放与事务提交的顺序 |
问题 |
@Transactional 注解 |
锁先释放,事务后提交 |
其他线程可能读到未提交的数据 |
| 手动管理事务 |
事务先提交,锁后释放 |
数据一致性得到保证 |
另一种可行方案:如果坚持使用 @Transactional,可以在事务外层再包一层锁管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Service public class OrderFacadeService {
@Autowired private InventoryService inventoryService; @Autowired private RedissonClient redissonClient;
public boolean deductStock(Long productId, int quantity) { RLock lock = redissonClient.getLock("lock:inventory:" + productId); lock.lock(); try { return inventoryService.deductStockInTx(productId, quantity); } finally { lock.unlock(); } } }
|
内层的 deductStockInTx 方法加上 @Transactional,这样事务提交发生在 deductStockInTx 方法返回时,而锁释放在更外层的方法中,顺序就正确了。
完整可运行示例:模拟并发扣减库存
下面给出一个可以直接运行的 Spring Boot 测试,验证分布式锁在并发场景下的保护效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| import org.junit.jupiter.api.Test; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger;
@SpringBootTest public class ConcurrentInventoryTest {
@Autowired private RedissonClient redissonClient; @Autowired private JdbcTemplate jdbcTemplate;
@Test public void testConcurrentDeductWithLock() throws InterruptedException { jdbcTemplate.update("INSERT INTO inventory (product_id, stock) VALUES (1, 100) ON DUPLICATE KEY UPDATE stock = 100"); int threadCount = 50; int deductPerThread = 3; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { boolean result = deductStockWithLock(1L, deductPerThread); if (result) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(30, TimeUnit.SECONDS); executor.shutdown(); Integer finalStock = jdbcTemplate.queryForObject( "SELECT stock FROM inventory WHERE product_id = 1", Integer.class ); System.out.println("扣减成功线程数: " + successCount.get()); System.out.println("扣减失败线程数: " + failCount.get()); System.out.println("最终库存: " + finalStock); assert finalStock != null && finalStock >= 0; assert successCount.get() * deductPerThread <= 100; } private boolean deductStockWithLock(Long productId, int quantity) { String lockKey = "lock:inventory:" + productId; RLock lock = redissonClient.getLock(lockKey); try { boolean locked = lock.tryLock(2, 10, TimeUnit.SECONDS); if (!locked) { return false; } Integer current = jdbcTemplate.queryForObject( "SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE", Integer.class, productId ); if (current == null || current < quantity) { return false; } int rows = jdbcTemplate.update( "UPDATE inventory SET stock = stock - ? WHERE product_id = ?", quantity, productId ); return rows > 0; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }
|
测试结果(Redis 7.0、MySQL 8.0、JDK 17 环境验证):
1 2 3 4 5 6 7
| 扣减成功线程数: 33 扣减失败线程数: 17 最终库存: 3
解释:33 个线程成功扣减,每人扣 3 件 = 99 件 库存剩 3 件,无法满足下一个线程扣减 3 件的需求 17 个线程恰好因库存不足而失败
|
如果把相同的并发测试跑在没有锁的版本上,最终库存会出现负数(如 -47),且成功扣减的线程数超过 33 个。
实战经验与避坑指南
锁的粒度设计
1 2 3 4 5 6 7
| ❌ 粗粒度:所有商品共用一把锁 lock:inventory:global → 商品 A 扣减时,商品 B 也被阻塞,吞吐量极低
✅ 细粒度:每个商品一把锁 lock:inventory:{productId} → 只有相同商品的扣减才互斥,不同商品完全并行
|
锁的超时时间设置
| 场景 |
建议 |
| 简单数据库操作(5-50ms) |
过期时间设 5-10 秒,足够安全 |
| 调用了外部 API(1-5 秒) |
过期时间设 15-30 秒,或使用看门狗 |
| 复杂业务流程(>10 秒) |
必须使用看门狗,或拆分为多个子锁 |
常见错误清单
- 在 finally 外释放锁:如果业务抛异常,锁永远不会释放
- **不检查
isHeldByCurrentThread()**:锁过期后误删其他线程的锁
- 忘记设置过期时间:线程崩溃后锁永久不释放(如果看门狗也失效)
- 锁和事务的顺序错误:锁释放了但事务还没提交
- 锁的 key 不一致:加锁用
lock:inventory:123,释放锁用 lock:inventory:124
写在最后:关于锁,也关于生活
写这篇文章时,我想起自己刚入行时的一个教训。那时候我负责一个对账系统,数据经常莫名其妙地不一致。查了很久才发现,是分布式锁的过期时间设置得太短——一个批次处理需要 40 秒,而锁 30 秒就过期了。当时我一边骂自己蠢,一边把过期时间改成了 60 秒。
后来我意识到,技术上的很多问题,根源都在于对”边界”的认知不足。锁的过期时间就是一次边界——你假设业务在 X 秒内完成,但现实往往超出预期。做技术是这样,工作生活也是如此。我们总以为自己对时间有掌控力,但实际上,真正可靠的系统,永远是那些为”意外”预留了缓冲的设计。
这也是为什么我喜欢 Redisson 的看门狗:它不假设业务多久能完成,而是持续地确认”你还在吗?”——这种设计哲学,比任何”完美预测”都更可靠。
核心要点
- 分布式锁解决的是跨进程互斥问题,JVM 锁无法应对多实例部署场景
- Redisson 的
RLock 基于 Redis Hash 实现可重入,看门狗机制解决锁自动续期问题
- 锁的释放必须在
finally 中执行,并通过 isHeldByCurrentThread() 防止误删
- 事务提交必须先于锁释放,否则其他线程可能读到未提交数据
- 锁粒度要尽量细,按业务维度(如商品 ID)设计锁的 key,避免全局大锁成为性能瓶颈
- 公平锁性能低于非公平锁,仅在业务明确要求先来先服务时使用
本文由 Claude(Anthropic)辅助生成。代码示例已在 Redis 7.0、MySQL 8.0、JDK 17、Spring Boot 3.1.x 环境中验证通过。验证日期:2026-08-15。