1. 项目概述:为什么分布式锁是微服务时代的“刚需”?
如果你正在开发一个微服务架构的系统,或者一个用户量稍大的单体应用,那么“库存超卖”、“优惠券重复发放”、“用户余额扣减出错”这些问题,大概率已经或者即将成为你夜不能寐的根源。表面上看,这些是业务逻辑Bug,但往深了挖,核心往往出在并发控制上——多个线程、多个进程,甚至多个服务实例,在同一时刻争抢着修改同一份数据。在单机时代,我们靠
synchronized
或
ReentrantLock
这类JVM级别的锁就能高枕无忧。但一旦服务做了集群部署,这些锁就瞬间失效了,因为它们只能锁住自己JVM内的线程,管不了其他机器上的进程。
这时候,分布式锁就登场了。它本质上是一个所有服务实例都能看见和认可的“全局标志位”。当一个实例想要执行一段临界区代码(比如扣减库存)时,它必须先到这个公共区域(通常是Redis、ZooKeeper等中间件)去申请一把锁。只有申请成功的实例才能执行操作,执行完毕后释放锁,其他实例才能继续争抢。这样,就把并发控制从单机扩展到了整个分布式系统。
而在众多实现分布式锁的方案中,
Redisson
提供的分布式锁,可以说是Java开发者手中的“瑞士军刀”。它不仅仅是一个简单的
setnx
命令封装,而是一个提供了可重入、公平锁、联锁、红锁等多种高级特性,并且自动处理了锁续期、看门狗等复杂机制的成熟客户端。直接使用Redis命令手写分布式锁,你可能会陷入锁超时设置、锁误释放、死锁等一大堆坑里。而Redisson帮你填平了这些坑,让你能更专注于业务逻辑本身。所以,“从头开始学Redisson分布式锁”,不是一个简单的工具学习,而是掌握在分布式环境下构建健壮、可靠系统的核心技能之一。
2. 核心需求解析:从业务场景看分布式锁的“用武之地”
在动手写代码之前,我们必须搞清楚:到底哪些场景非用分布式锁不可?理解了场景,才能理解Redisson每个设计背后的用意。这里我结合几个最常见的“事故高发区”来聊聊。
2.1 秒杀与库存扣减
这是最经典的场景。假设商品库存只剩1件,同时有10万个请求涌进来。如果没有分布式锁,每个服务实例在查询数据库时都看到库存为1,然后都执行了
库存-1
的操作,最终导致库存变成-9,这就是恐怖的“超卖”。分布式锁在这里的作用,就是确保“查询库存”和“更新库存”这两个操作组成的临界区,同一时间只有一个线程能执行。整个流程变成:抢锁 -> 查询库存 -> 判断是否大于0 -> 扣减库存 -> 释放锁。这样,无论多少请求,都只能排队串行处理,从根本上杜绝超卖。
注意 :这里锁的粒度是关键。如果你把整个秒杀流程都锁住,性能会急剧下降。更优的做法是 以商品ID为维度进行加锁 ,例如
lock = redisson.getLock("product_stock_lock:" + productId)。这样,不同商品之间的秒杀就不会相互阻塞,只有同一商品的请求才会串行化,这在电商大促中至关重要。
2.2 分布式环境下的重复任务调度
假设你有一个定时任务,每天凌晨统计前一天的报表。这个任务在单机部署时,用
@Scheduled
注解很简单。但当你部署了多个服务实例时,可怕的事情发生了:每个实例的定时任务都会在同一时间触发,同一份报表被重复计算了多次。这时,你需要用分布式锁让这个任务在集群中“幂等”地执行。任务开始时,所有实例都尝试获取一个唯一的锁(比如
lock = redisson.getLock("daily_report_job_lock")
),只有一个实例能成功,执行任务,其他实例获取锁失败,直接跳过。这样就实现了集群环境下任务的精确调度。
2.3 防止缓存击穿下的重复数据库查询
缓存击穿是指一个热点Key突然过期,此时有大量并发请求这个Key,这些请求发现缓存不存在,都会同时去查询数据库,给数据库带来瞬间的巨大压力。一个常见的解决方案是使用分布式锁进行“互斥重建”。当第一个请求发现缓存失效时,它成功获取锁,然后去数据库查询数据并回填缓存。在这期间,其他并发请求也尝试获取锁,但都会失败,于是它们可以选择短暂休眠后重试读取缓存,或者直接返回默认值。这样就避免了大量请求穿透到数据库。
public String getData(String key) {
String data = redisTemplate.opsForValue().get(key);
if (data == null) {
// 缓存未命中,尝试获取锁
RLock lock = redisson.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 等待3秒,锁持有10秒
// 再次检查缓存,防止在等待锁期间已有其他线程更新了缓存
data = redisTemplate.opsForValue().get(key);
if (data == null) {
// 查询数据库
data = queryFromDB(key);
// 写入缓存
redisTemplate.opsForValue().set(key, data, 1, TimeUnit.HOURS);
}
} else {
// 获取锁失败,说明已有其他线程在重建缓存
// 方案1:休眠后重试
Thread.sleep(100);
return getData(key);
// 方案2:返回兜底数据
// return getDefaultData();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
return data;
}
2.4 用户余额或积分等敏感数据的并发更新
用户钱包扣款、积分增减,这些操作必须保证绝对准确。并发下可能出现的经典问题是:余额100元,两个请求同时扣款60元。两个线程都读到余额为100,都认为足够扣款,然后分别执行
100-60
,结果余额变成了40元,而实际应该只扣一次60元,余额应为40元,这里用户被多扣了60元。通过分布式锁(例如以用户ID为维度:
lock = redisson.getLock("user_balance_lock:" + userId)
),可以强制这些更新操作串行执行,避免数据错乱。
3. Redisson分布式锁的核心原理深度拆解
Redisson的锁之所以强大且省心,是因为它在简单的Redis命令之上,构建了一套完整的锁管理机制。理解这套机制,是你正确使用和排查问题的前提。
3.1 锁的存储结构:不仅仅是String
很多人以为分布式锁就是在Redis里设置一个Key。Redisson做得更精细。当你调用
redisson.getLock("myLock")
时,它在Redis中主要使用的数据结构是
Hash
。
锁Key(例如
myLock
)对应的不是一个简单的字符串值,而是一个Hash表。这个Hash表里至少会包含以下字段:
-
UUID:threadId: 这个字段的Name是<客户端UUID>:<当前线程ID>,它的Value是一个数字,代表 重入次数 。这是实现可重入锁的关键。例如,8743c9c0-0795-4710-be57-8a3b4d17b8c7:1->1,表示这个客户端(UUID)的这个线程(ID为1)持有该锁1次。 - 还可能包含一些系统字段,用于支持看门狗、锁模式等。
使用Hash结构的好处是:
-
可重入性
:通过累加
UUID:threadId对应的Value值,轻松实现同一个线程多次加锁(重入)。 - 锁归属清晰 :Value中包含了客户端UUID和线程ID,在释放锁时可以精确判断当前释放锁的线程是否是锁的持有者,防止误删其他客户端的锁。
-
高效
:检查锁状态、重入计数、释放锁等操作都可以通过Redis的
HINCRBY、HEXISTS等原子命令完成。
3.2 看门狗(Watchdog)机制:告别锁超时烦恼
这是Redisson最核心的“黑科技”之一,也是它比手动实现强大得多的地方。手动实现分布式锁时,你必须在加锁时设置一个过期时间(TTL),以防止客户端崩溃导致锁永远无法释放(死锁)。但这引出了另一个问题: 业务逻辑的执行时间可能超过TTL 。
假设你设置锁超时时间为30秒,但某个业务操作因为GC、网络延迟或复杂计算,执行了35秒。在第30秒时,Redis中的锁自动过期释放了。此时,另一个客户端成功获取了该锁。一秒钟后,最初的客户端终于执行完了,并开始执行释放锁的逻辑——这会导致它错误地释放了第二个客户端刚刚创建的锁!数据一致性被彻底破坏。
Redisson的看门狗机制就是为了解决这个问题。
当你使用
lock.lock()
不加超时参数,或者使用
lock.lock(10, TimeUnit.SECONDS)
但未指定
leaseTime
参数时,看门狗就会自动启用。
它的工作原理如下:
-
客户端A成功加锁,锁的默认过期时间设置为30秒(可配置
lockWatchdogTimeout)。 - 同时,在客户端A内部,会启动一个定时任务(看门狗),这个任务每隔 过期时间的1/3 (即10秒)执行一次。
-
每次看门狗任务执行时,它会检查客户端A是否还持有这把锁(通过检查Redis中对应Hash的字段是否存在)。如果还持有,它就通过
pexpire命令将锁的过期时间 重新刷新为30秒 。 -
只要客户端A的业务线程没有执行完,这个续期操作就会一直进行,直到业务线程主动调用
unlock()。 -
当业务线程调用
unlock()释放锁后,看门狗任务也会被取消。
这样一来,只要持有锁的客户端还“活着”,锁就不会因为超时而自动释放,完美避免了上述的锁失效问题。只有当客户端进程崩溃,看门狗线程也随之停止,锁才会在最后一次续期后的30秒自动过期。
实操心得 :看门狗机制虽好,但不能滥用。如果你能明确知道业务逻辑的最大执行时间, 强烈建议使用带有明确
leaseTime参数的加锁方法 ,例如lock.lock(10, TimeUnit.SECONDS)。这会告诉Redisson不使用看门狗,锁在10秒后自动释放。这能避免在客户端异常崩溃时,锁因为看门狗的续期而长时间不释放(尽管有超时,但比预期长)。对于定时任务、已知耗时的操作,指定leaseTime是更优选择。
3.3 可重入性(Reentrancy)实现
可重入锁意味着同一个线程可以多次获取同一把锁,而不会把自己阻塞死。在单机JVM中,
ReentrantLock
通过一个计数器实现。在Redisson的分布式锁中,这个计数器就存储在之前提到的Redis Hash结构里。
加锁流程(简化):
- 客户端A线程T1尝试获取锁“myLock”。
- Redisson执行Lua脚本,原子性地检查Redis中“myLock”这个Hash是否存在。
-
如果不存在,则创建Hash,设置字段
UUID_A:threadId_T1的值为1(首次获取),并设置过期时间。加锁成功。 -
如果存在,且字段
UUID_A:threadId_T1也存在,则通过HINCRBY命令将其值加1(重入次数+1),并重置过期时间。加锁成功。 -
如果存在,但字段不是
UUID_A:threadId_T1,说明锁被其他客户端或线程持有,则加锁失败。
解锁流程(简化):
-
客户端A线程T1调用
unlock()。 -
Redisson执行Lua脚本,原子性地获取字段
UUID_A:threadId_T1的值(重入次数)。 - 如果值减1后大于0,说明还有重入层数,则更新该值,并重置过期时间。
- 如果值减1后等于0,说明这是最外层锁,则直接删除这个Hash键,彻底释放锁。
整个流程通过Lua脚本保证原子性,确保了在高并发下重入计数的绝对准确。
3.4 公平锁与非公平锁
Redisson也实现了公平锁(
RFairLock
)。公平锁的核心是
排队
。它内部使用Redis的List数据结构作为一个等待队列。
-
非公平锁(默认的
RLock) :所有尝试加锁的客户端一起争抢,谁抢到算谁的。可能产生“饥饿”现象,即某些客户端永远抢不到锁。 -
公平锁(
RFairLock) :客户端加锁时,如果锁已被占用,它不会立即返回失败,而是将自己放入一个等待队列(Redis List)的末尾。当锁被释放时,等待时间最长的客户端(队首)将获得锁。这保证了获取锁的顺序与申请锁的顺序一致。
公平锁避免了饥饿,但性能通常低于非公平锁,因为它需要维护队列。在绝大多数业务场景中,非公平锁的吞吐量更高,是默认推荐的选择。只有在严格要求顺序性的特殊场景下,才考虑使用公平锁。
4. 从入门到精通:Redisson分布式锁的完整实操指南
理论讲透了,我们进入实战环节。我会从环境搭建、基础用法,一直讲到高级特性和生产级配置。
4.1 环境准备与基础集成
首先,在你的Spring Boot项目中引入Redisson依赖。我推荐使用Spring Boot Starter,它能很好地处理配置和Bean的注入。
Maven依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.0</version> <!-- 请使用最新稳定版本 -->
</dependency>
基础配置(application.yml):
spring:
redis:
# 单节点模式
host: 127.0.0.1
port: 6379
password: yourpassword # 如果没有密码,可省略
database: 0
# Redisson特定配置(可选,通常默认即可)
redisson:
config: |
singleServerConfig:
idleConnectionTimeout: 10000
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
# 看门狗默认超时时间,单位毫秒
lockWatchdogTimeout: 30000
配置完成后,Redisson客户端
RedissonClient
会自动注入到Spring容器中,你可以直接
@Autowired
使用。
4.2 基础加锁与解锁的四种姿势
姿势一:最常用——阻塞式加锁(带看门狗)
@Autowired
private RedissonClient redissonClient;
public void doSomething() {
RLock lock = redissonClient.getLock("myBusinessLock");
try {
// 1. 阻塞等待,直到获取锁。默认启用看门狗,锁超时时间为30秒(可配置)
lock.lock();
// 2. 执行业务逻辑
businessHandle();
} finally {
// 3. 必须在finally块中释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这是最傻瓜式的用法,但也是隐患最多的一种。如果
businessHandle()
方法执行时间过长或发生死循环,看门狗会不断续期,锁可能永远不会释放,导致其他线程永远等待。
姿势二:阻塞式加锁(指定自动释放时间)
public void doSomethingWithLeaseTime() {
RLock lock = redissonClient.getLock("myBusinessLock");
try {
// 等待获取锁,最多等待10秒。获取后,锁在5秒后自动释放,不启用看门狗。
boolean isLocked = lock.tryLock(10, 5, TimeUnit.SECONDS);
if (isLocked) {
businessHandle(); // 必须确保业务在5秒内完成!
} else {
log.warn("获取锁失败,处理降级逻辑");
handleFallback();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("锁等待被中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这种方式明确指定了
leaseTime
(5秒),Redisson不会启用看门狗。适用于你能准确预估业务耗时的场景。
务必确保业务执行时间小于
leaseTime
。
姿势三:非阻塞式加锁(立即返回)
public void doSomethingTryLock() {
RLock lock = redissonClient.getLock("myBusinessLock");
if (lock.tryLock()) { // 立即尝试,获取不到直接返回false
try {
businessHandle();
} finally {
lock.unlock();
}
} else {
// 获取锁失败,快速失败或重试
log.info("系统繁忙,请稍后再试");
}
}
适用于不希望线程等待的场景,比如高并发下的快速失败,配合前端提示“系统繁忙”用户体验更好。
姿势四:异步加锁
public void doSomethingAsync() {
RLock lock = redissonClient.getLock("myBusinessLock");
RFuture<Boolean> future = lock.tryLockAsync(10, 5, TimeUnit.SECONDS);
future.onComplete((isLocked, throwable) -> {
if (throwable != null) {
log.error("异步加锁异常", throwable);
return;
}
if (isLocked) {
try {
businessHandle();
} finally {
lock.unlockAsync();
}
} else {
handleFallback();
}
});
}
异步API适用于反应式编程或不想阻塞当前线程的场景。注意,异步操作的回调执行线程可能不是原线程,需要处理好线程上下文(如ThreadLocal)。
4.3 高级特性实战:联锁、红锁与读写锁
联锁(MultiLock) 当你需要同时锁定多个资源,并且要求要么全部锁定,要么一个都不锁定时,就需要联锁。例如,转账操作需要同时锁定A账户和B账户。
public void transfer(String fromAccount, String toAccount) {
RLock lock1 = redissonClient.getLock("account:" + fromAccount);
RLock lock2 = redissonClient.getLock("account:" + toAccount);
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
multiLock.lock();
try {
// 执行转账业务:扣减fromAccount,增加toAccount
doTransfer(fromAccount, toAccount);
} finally {
multiLock.unlock();
}
}
Redisson的联锁会按顺序依次尝试获取所有传入的锁,全部成功才算加锁成功。释放时也会按顺序释放。这能有效预防死锁(因为锁的获取顺序是固定的)。
红锁(RedLock) 这是一个基于Redis集群的分布式锁算法,旨在解决在Redis单点故障或主从异步复制下可能出现的锁失效问题。其核心思想是: 向多个(奇数个,通常5个)相互独立的Redis主节点申请锁,当从超过半数的节点(N/2 + 1)成功获取锁时,才算真正持有锁。
重要提示 :Redis官方后来发表了一篇文章,指出了RedLock算法在特定极端场景下(如系统时钟跳跃)依然存在争议,并且实现复杂。对于绝大多数业务场景,使用带有主从复制和故障转移的Redis Sentinel或Redis Cluster,并结合合理的锁超时时间,其可靠性已经足够。Redisson虽然提供了
RedissonRedLock实现,但除非你对一致性有极端要求且能接受其复杂性和性能损耗,否则不建议轻易使用。
读写锁(ReadWriteLock)
RReadWriteLock
实现了
java.util.concurrent.locks.ReadWriteLock
接口,允许多个读锁同时持有,但写锁是排他的。
RReadWriteLock rwLock = redissonClient.getReadWriteLock("myReadWriteLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();
// 读操作
readLock.lock();
try {
// 多个线程可以同时进入这里执行读操作
readData();
} finally {
readLock.unlock();
}
// 写操作
writeLock.lock();
try {
// 只有一个线程可以进入这里执行写操作
writeData();
} finally {
writeLock.unlock();
}
这在“读多写少”的场景下能极大提升并发性能,例如缓存数据的更新。
4.4 生产级配置与最佳实践
-
锁命名要有业务意义
:锁的Key是全局资源,命名应包含业务前缀和资源标识,避免冲突。如
order:pay:lock:{orderId},product:stock:lock:{skuId}。 - 务必在finally块中释放锁 :这是铁律,确保任何情况下(包括异常)锁都能被释放。
-
释放前检查锁持有者
:使用
lock.isHeldByCurrentThread()判断,防止误释放其他线程的锁(在复杂线程池或异步回调中可能发生)。 -
合理设置等待时间和持有时间
:
-
waitTime(获取锁的最大等待时间):根据系统容忍的排队时间设置。设置太短可能导致大量失败,太长则线程堆积。 -
leaseTime(锁自动释放时间):如果你能预估业务最大耗时,就设置一个略大于该值的leaseTime,并禁用看门狗。如果无法预估,则使用看门狗(不传leaseTime),但要监控业务执行时间,防止异常长耗时任务。
-
- 避免在锁内执行耗时操作或远程调用 :锁的持有时间应尽可能短,只包裹真正需要互斥的核心资源操作。将数据准备、日志记录等非竞争操作移到锁外。
- 做好降级和熔断 :获取锁失败是常态,不是异常。必须有相应的降级策略,比如返回“系统繁忙,请重试”的提示,或者使用本地缓存返回旧数据,保证系统整体可用性。
- 监控与告警 :监控Redis中锁Key的数量、锁等待时间、获取锁失败率等指标。设置告警,当锁平均持有时间异常增长或失败率飙升时,及时介入排查。
5. 避坑指南:Redisson分布式锁的典型问题与排查实录
即使理解了原理,实践中依然会踩坑。下面是我和团队遇到过的一些典型问题及解决方案。
5.1 锁超时与看门狗的那些事儿
问题现象
:业务逻辑执行时间不确定,有时会超过设置的
leaseTime
,导致锁提前释放,数据出错。
根因分析
:使用了
tryLock(waitTime, leaseTime, unit)
并设置了较短的
leaseTime
,且业务执行时间超过了它。
解决方案 :
-
方案A(推荐)
:如果业务耗时波动大,
不要设置
leaseTime,直接使用lock()或tryLock(waitTime, unit),依赖看门狗自动续期。同时,你需要优化业务代码,确保其平均耗时在可控范围内,并设置监控告警。 -
方案B
:如果必须设置
leaseTime,则需要将其设置为 业务可能的最大耗时 + 安全余量 。例如,业务99.9%的情况在2秒内完成,但极端情况可能到10秒,那么leaseTime可以设为15-20秒。这需要你对业务有深入的了解。
5.2 “我明明释放了锁,为什么日志显示还在持有?”
问题现象
:在finally块中调用了
unlock()
,但监控发现锁的TTL依然在刷新,似乎没释放。
排查步骤 :
-
检查是否重入
:在锁内部又调用了另一个需要同一把锁的方法,导致重入次数大于1。一次
unlock()只减少一次计数,锁并未完全释放。确保加锁和解锁次数匹配。 -
检查线程一致性
:在异步回调或线程池场景下,执行
unlock()的线程可能不是当初lock()的线程。Redisson的锁是与线程ID绑定的。确保锁的获取和释放在同一线程上下文。可以使用lock.unlockAsync()配合回调,或者在异步任务开始时传递锁引用并在同一异步线程中释放。 -
检查异常
:
unlock()方法本身也可能抛出异常(如网络中断),导致释放失败。可以在finally块中加入更健壮的释放逻辑:finally { if (lock.isHeldByCurrentThread()) { try { lock.unlock(); } catch (Exception e) { log.error("释放锁异常,锁Key: {}", lock.getName(), e); // 可以考虑发送告警,这是一个危险信号 } } }
5.3 Redis主从切换与锁失效
问题现象 :在Redis Sentinel或Cluster模式下,主节点宕机,从节点升级为主节点,但客户端A持有的锁信息可能因异步复制而未同步到新的主节点,导致客户端B也能获取到同一把锁。
根因分析 :这是Redis异步复制机制带来的固有风险。Redisson默认的锁在单节点或主从架构下无法完全规避此问题。
解决方案与选择 :
- 接受风险 :对于绝大多数业务,主从切换是低概率事件,且锁通常有TTL,短暂的数据不一致在可接受范围内。这是成本与收益的权衡。
- 使用RedLock :如前所述,通过多个独立Redis实例来降低风险,但带来复杂性和性能下降。
- 升级架构 :对于金融级强一致性场景,考虑使用ZooKeeper、etcd等CP模型的协调服务来实现分布式锁,它们通过ZAB或Raft协议保证强一致性,但性能通常低于Redis。
5.4 锁等待导致的线程池耗尽与链式雪崩
问题现象
:某个热点资源(如某个明星商品的库存)锁竞争激烈,大量线程阻塞在
lock.tryLock(10, SECONDS)
上。如果这些线程来自Web容器的公共线程池(如Tomcat的HTTP线程池),会导致线程池迅速被占满,整个服务无法响应其他请求,引发雪崩。
排查与解决 :
-
监控锁等待时间
:在
tryLock前后记录时间,统计平均等待时间和最大等待时间。如果等待时间持续增长,说明竞争激烈。 - 缩小锁粒度 :这是最有效的办法。检查锁的Key是否过于宽泛。能否从“商品锁”细化到“商品SKU锁”?甚至结合库存分段。
-
引入排队与限流
:在锁的外层,使用更高效的限流器(如Redisson的
RRateLimiter)或消息队列进行流量削峰,控制进入锁竞争环节的请求数量。 - 使用独立线程池 :对于特别耗时的锁竞争操作,将其提交到专用的、有界线程池中执行,避免影响主业务线程池。
-
设置合理的等待超时
:不要设置过长的
waitTime。对于非核心操作,快速失败并返回用户“请稍后重试”,好过让用户长时间等待。
5.5 性能调优与监控指标
为了让分布式锁稳定高效,你需要关注以下指标:
| 监控指标 | 说明 | 异常排查方向 |
|---|---|---|
| 锁获取成功率 |
成功获取锁次数 / 尝试获取锁总次数
| 成功率持续下降,说明竞争激烈或Redis服务异常。 |
| 平均锁持有时间 | 从获取到释放锁的平均耗时 | 时间过长,说明业务逻辑在锁内执行太慢,需要优化或拆分。 |
| 平均锁等待时间 | 尝试获取锁时,平均需要等待多久 | 等待时间过长,说明资源成为瓶颈,需要细化锁粒度或限流。 |
| Redis命令延迟 |
执行
hset
,
pexpire
等锁相关命令的耗时
| 延迟增高,可能Redis负载过高或网络有问题。 |
| 看门狗续期次数 | 看门狗线程执行续期的频率 | 异常增高,可能业务执行时间不稳定,频繁触及续期。 |
可以在加锁/解锁的关键节点通过AOP或手动埋点,将日志发送到监控系统(如Prometheus + Grafana)进行可视化。Redisson自身也提供了丰富的监控指标,可以通过JMX或Micrometer集成暴露出来。
6. 超越基础:分布式锁在复杂场景下的设计思考
掌握了基本用法和避坑技巧后,我们来看一些更复杂的场景,思考如何用分布式锁及其变种来优雅地解决问题。
6.1 分布式锁与数据库事务的协同
这是一个常见陷阱:在方法上加了
@Transactional
,同时在方法内部使用了分布式锁。
@Transactional
public void business() {
RLock lock = redisson.getLock("lock");
lock.lock();
try {
// 1. 查询数据
// 2. 修改数据
// 3. 保存到数据库
} finally {
lock.unlock();
}
}
问题 :如果事务提交失败,会发生什么?锁已经释放了,但数据修改回滚了。其他线程拿到锁后,读到的还是旧数据,然后基于旧数据做修改,导致数据错误。
解决方案 : 锁的范围必须包含整个事务 。有两种方式:
- 编程式事务 :将事务控制放在锁的代码块内部。
-
声明式事务调整
:将
@Transactional注解加在调用business()方法的上层方法上,确保锁在事务范围内。更稳妥的做法是,将“获取锁 -> 执行业务 -> 释放锁”这一整体放在一个没有事务的方法里,然后在这个方法内部,在锁的保护下,再调用带有事务的Service方法。
6.2 结合Spring Retry实现锁竞争的重试机制
对于可重试的失败(如获取锁失败),可以使用Spring Retry框架实现优雅的重试,而不是简单的
while(true)
循环。
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private RetryTemplate retryTemplate;
public void createOrder(String orderId) {
retryTemplate.execute(context -> {
RLock lock = redissonClient.getLock("order_create:" + orderId);
// 非阻塞尝试,快速失败进入重试逻辑
if (lock.tryLock()) {
try {
// 核心下单逻辑
return doCreateOrder(orderId);
} finally {
lock.unlock();
}
} else {
// 获取锁失败,抛出异常触发重试
throw new LockAcquisitionFailedException("获取订单创建锁失败, orderId: " + orderId);
}
});
}
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
// 指数退避策略:第一次等待100ms,第二次200ms,第三次400ms...
ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(100);
backOff.setMultiplier(2);
backOff.setMaxInterval(3000); // 最大等待3秒
template.setBackOffPolicy(backOff);
// 最多重试5次
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(5);
template.setRetryPolicy(retryPolicy);
return template;
}
}
这样设计,既避免了无限制重试,又通过指数退避减轻了竞争压力。
6.3 锁的细粒度化与分段锁实践
对于超级热点资源,如“全民疯抢的茅台酒库存”,即使锁粒度到了SKU级别,可能依然有数十万请求竞争一把锁。这时可以考虑 分段锁(Striped Lock) 的思路。
假设茅台酒库存有10000瓶。我们可以创建10把锁,每把锁管理1000瓶库存。
public boolean seckill(Long productId, Integer quantity) {
// 1. 基于用户ID或请求ID等,决定使用哪一把分段锁 (0-9)
int segment = (int) (Thread.currentThread().getId() % 10); // 简单示例
RLock segmentLock = redisson.getLock("product_stock_segment:" + productId + ":" + segment);
segmentLock.lock();
try {
// 2. 查询和扣减该分段对应的“虚拟库存”
Integer segmentStock = getSegmentStockFromRedis(productId, segment);
if (segmentStock >= quantity) {
decrSegmentStockInRedis(productId, segment, quantity);
// 3. 异步或批量同步到数据库总库存
asyncUpdateTotalStockInDB(productId, quantity);
return true;
}
return false;
} finally {
segmentLock.unlock();
}
}
这样,就将竞争一把锁的流量,分散到了10把锁上,理论上可以将并发处理能力提升近10倍。当然,这增加了系统的复杂性,需要维护分段库存与总库存的一致性。这属于一种“空间换时间”和“最终一致性”的权衡,适用于对极限性能有要求,且能容忍短暂库存不一致(如秒杀场景)的业务。

286

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



