Redisson分布式锁原理、实战与避坑指南:从秒杀到缓存击穿的完整解决方案

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结构的好处是:

  1. 可重入性 :通过累加 UUID:threadId 对应的Value值,轻松实现同一个线程多次加锁(重入)。
  2. 锁归属清晰 :Value中包含了客户端UUID和线程ID,在释放锁时可以精确判断当前释放锁的线程是否是锁的持有者,防止误删其他客户端的锁。
  3. 高效 :检查锁状态、重入计数、释放锁等操作都可以通过Redis的 HINCRBY HEXISTS 等原子命令完成。

3.2 看门狗(Watchdog)机制:告别锁超时烦恼

这是Redisson最核心的“黑科技”之一,也是它比手动实现强大得多的地方。手动实现分布式锁时,你必须在加锁时设置一个过期时间(TTL),以防止客户端崩溃导致锁永远无法释放(死锁)。但这引出了另一个问题: 业务逻辑的执行时间可能超过TTL

假设你设置锁超时时间为30秒,但某个业务操作因为GC、网络延迟或复杂计算,执行了35秒。在第30秒时,Redis中的锁自动过期释放了。此时,另一个客户端成功获取了该锁。一秒钟后,最初的客户端终于执行完了,并开始执行释放锁的逻辑——这会导致它错误地释放了第二个客户端刚刚创建的锁!数据一致性被彻底破坏。

Redisson的看门狗机制就是为了解决这个问题。 当你使用 lock.lock() 不加超时参数,或者使用 lock.lock(10, TimeUnit.SECONDS) 但未指定 leaseTime 参数时,看门狗就会自动启用。

它的工作原理如下:

  1. 客户端A成功加锁,锁的默认过期时间设置为30秒(可配置 lockWatchdogTimeout )。
  2. 同时,在客户端A内部,会启动一个定时任务(看门狗),这个任务每隔 过期时间的1/3 (即10秒)执行一次。
  3. 每次看门狗任务执行时,它会检查客户端A是否还持有这把锁(通过检查Redis中对应Hash的字段是否存在)。如果还持有,它就通过 pexpire 命令将锁的过期时间 重新刷新为30秒
  4. 只要客户端A的业务线程没有执行完,这个续期操作就会一直进行,直到业务线程主动调用 unlock()
  5. 当业务线程调用 unlock() 释放锁后,看门狗任务也会被取消。

这样一来,只要持有锁的客户端还“活着”,锁就不会因为超时而自动释放,完美避免了上述的锁失效问题。只有当客户端进程崩溃,看门狗线程也随之停止,锁才会在最后一次续期后的30秒自动过期。

实操心得 :看门狗机制虽好,但不能滥用。如果你能明确知道业务逻辑的最大执行时间, 强烈建议使用带有明确 leaseTime 参数的加锁方法 ,例如 lock.lock(10, TimeUnit.SECONDS) 。这会告诉Redisson不使用看门狗,锁在10秒后自动释放。这能避免在客户端异常崩溃时,锁因为看门狗的续期而长时间不释放(尽管有超时,但比预期长)。对于定时任务、已知耗时的操作,指定 leaseTime 是更优选择。

3.3 可重入性(Reentrancy)实现

可重入锁意味着同一个线程可以多次获取同一把锁,而不会把自己阻塞死。在单机JVM中, ReentrantLock 通过一个计数器实现。在Redisson的分布式锁中,这个计数器就存储在之前提到的Redis Hash结构里。

加锁流程(简化):

  1. 客户端A线程T1尝试获取锁“myLock”。
  2. Redisson执行Lua脚本,原子性地检查Redis中“myLock”这个Hash是否存在。
  3. 如果不存在,则创建Hash,设置字段 UUID_A:threadId_T1 的值为1(首次获取),并设置过期时间。加锁成功。
  4. 如果存在,且字段 UUID_A:threadId_T1 也存在,则通过 HINCRBY 命令将其值加1(重入次数+1),并重置过期时间。加锁成功。
  5. 如果存在,但字段不是 UUID_A:threadId_T1 ,说明锁被其他客户端或线程持有,则加锁失败。

解锁流程(简化):

  1. 客户端A线程T1调用 unlock()
  2. Redisson执行Lua脚本,原子性地获取字段 UUID_A:threadId_T1 的值(重入次数)。
  3. 如果值减1后大于0,说明还有重入层数,则更新该值,并重置过期时间。
  4. 如果值减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 生产级配置与最佳实践

  1. 锁命名要有业务意义 :锁的Key是全局资源,命名应包含业务前缀和资源标识,避免冲突。如 order:pay:lock:{orderId} product:stock:lock:{skuId}
  2. 务必在finally块中释放锁 :这是铁律,确保任何情况下(包括异常)锁都能被释放。
  3. 释放前检查锁持有者 :使用 lock.isHeldByCurrentThread() 判断,防止误释放其他线程的锁(在复杂线程池或异步回调中可能发生)。
  4. 合理设置等待时间和持有时间
    • waitTime (获取锁的最大等待时间):根据系统容忍的排队时间设置。设置太短可能导致大量失败,太长则线程堆积。
    • leaseTime (锁自动释放时间):如果你能预估业务最大耗时,就设置一个略大于该值的 leaseTime ,并禁用看门狗。如果无法预估,则使用看门狗(不传 leaseTime ),但要监控业务执行时间,防止异常长耗时任务。
  5. 避免在锁内执行耗时操作或远程调用 :锁的持有时间应尽可能短,只包裹真正需要互斥的核心资源操作。将数据准备、日志记录等非竞争操作移到锁外。
  6. 做好降级和熔断 :获取锁失败是常态,不是异常。必须有相应的降级策略,比如返回“系统繁忙,请重试”的提示,或者使用本地缓存返回旧数据,保证系统整体可用性。
  7. 监控与告警 :监控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. 检查是否重入 :在锁内部又调用了另一个需要同一把锁的方法,导致重入次数大于1。一次 unlock() 只减少一次计数,锁并未完全释放。确保加锁和解锁次数匹配。
  2. 检查线程一致性 :在异步回调或线程池场景下,执行 unlock() 的线程可能不是当初 lock() 的线程。Redisson的锁是与 线程ID 绑定的。确保锁的获取和释放在同一线程上下文。可以使用 lock.unlockAsync() 配合回调,或者在异步任务开始时传递锁引用并在同一异步线程中释放。
  3. 检查异常 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线程池),会导致线程池迅速被占满,整个服务无法响应其他请求,引发雪崩。

排查与解决

  1. 监控锁等待时间 :在 tryLock 前后记录时间,统计平均等待时间和最大等待时间。如果等待时间持续增长,说明竞争激烈。
  2. 缩小锁粒度 :这是最有效的办法。检查锁的Key是否过于宽泛。能否从“商品锁”细化到“商品SKU锁”?甚至结合库存分段。
  3. 引入排队与限流 :在锁的外层,使用更高效的限流器(如Redisson的 RRateLimiter )或消息队列进行流量削峰,控制进入锁竞争环节的请求数量。
  4. 使用独立线程池 :对于特别耗时的锁竞争操作,将其提交到专用的、有界线程池中执行,避免影响主业务线程池。
  5. 设置合理的等待超时 :不要设置过长的 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();
    }
}

问题 :如果事务提交失败,会发生什么?锁已经释放了,但数据修改回滚了。其他线程拿到锁后,读到的还是旧数据,然后基于旧数据做修改,导致数据错误。

解决方案 锁的范围必须包含整个事务 。有两种方式:

  1. 编程式事务 :将事务控制放在锁的代码块内部。
  2. 声明式事务调整 :将 @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倍。当然,这增加了系统的复杂性,需要维护分段库存与总库存的一致性。这属于一种“空间换时间”和“最终一致性”的权衡,适用于对极限性能有要求,且能容忍短暂库存不一致(如秒杀场景)的业务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值