在上一章我们用说对于超卖问题和一人一单可以用悲观锁/乐观锁来解决,但这些都是单机锁,在分布式环境下是完全失效的。我们需要一种跨越多个 JVM、跨越多个进程的全局锁机制——分布式锁

一、Lua 脚本与Lua脚本的问题

我们的项目在之前的超卖问题里,采用乐观锁的方式解决了单机环境下的并发安全问题,但在分布式环境下,单机锁(无论是悲观锁还是乐观锁)都无法保证全局的互斥访问。此外的一人一单问题,目前是用 synchronized 锁住了 userId,这在单机环境下是可行的,但在分布式环境下同样会失效。因为不同 JVM 的线程无法共享同一个锁对象。所以我们需要一个分布式锁,来保证在分布式环境下的并发安全。最初我们的方案是setnx命令,这个命令很简单,给redis设置一个key,如果这个key不存在就设置成功,存在就失败。我们可以利用这个命令来实现分布式锁的功能:

boolean tryLock(String key, String value, long expireTime) {
1
2
3
4
	// SETNX 命令:如果 key 不存在则设置,存在则返回 false
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}

但是问题是,这个方案存在一些严重的缺陷:

  1. 死锁风险:如果持有锁的线程在业务执行过程中崩溃了,那么这个锁就永远不会被释放,导致其他线程无法获取锁,形成死锁。
  2. 续期问题:如果业务执行时间超过了锁的过期时间,那么锁就会被自动释放,其他线程可能会获取到这个锁,导致并发安全问题。
  3. 性能问题:在高并发场景下,频繁的 setnxdel 操作会对 Redis 造成很大的压力,可能会成为系统的性能瓶颈。

所以我们考虑一步步来升级,先看lua,lua脚本是redis提供的一种功能,可以让我们在redis服务器端执行一段lua脚本,这段脚本可以包含多个redis命令,并且这些命令会被原子性地执行。我们可以利用lua脚本来实现一个更安全的分布式锁,解决上面提到的死锁和续期问题,当然lua本身是一种语言,只不过redis可以执行它,所以我们需要写一段lua脚本来实现分布式锁的功能:
我们可以用lua来做到以下几点:

  1. 原子性:通过lua脚本,我们可以把获取锁和设置过期时间的操作放在一起执行,保证它们的原子性,避免死锁问题。
  2. 续期机制:我们可以在lua脚本中实现一个续期机制,当业务执行时间超过锁的过期时间时,自动续期,保证锁不会被意外释放。
  3. 性能优化:通过lua脚本,我们可以减少与redis的交互次数,提升性能。
    不过主要是可以用来先做一人一单的改造,锁住userId,保证同一用户只能买一单。
    或者通过redis的set结构搭配lua脚本来实现一个分布式的布隆过滤器,来判断用户是否已经购买过了,从而实现一人一单的功能。
    这个项目结束的时候,我们采用的便是Redis+Lua做预拦截,也就是所谓的快速响应和处理,把判断资格和扣库存的逻辑都放在lua脚本里执行,保证原子性和性能。具体落库我们可以用RabbitMQ来异步处理,保证秒杀系统的高性能和高可用。不过不代表这就完全安全了,还是需要分布式锁,来保证在分布式环境下的并发安全。因为在分布式环境下,单机锁(无论是悲观锁还是乐观锁)都无法保证全局的互斥访问。
    这里Lua脚本被我们用来做一人一单而不做最终的分布式锁,主要是因为还是有这些问题:
  4. 复杂性:编写和维护lua脚本相对较复杂,尤其是在处理错误和异常情况时,可能会增加开发和调试的难度。
  5. 可读性:对于不熟悉lua的开发者来说,lua脚本可能不太直观,降低了代码的可读性和可维护性。
  6. 功能限制:虽然lua脚本可以实现分布式锁的基本功能,但它缺乏一些高级特性,如自动续期、看门狗机制等,这些特性对于高并发场景下的分布式锁来说是非常重要的。
    因此,我们最终选择了Redisson这个第三方库来实现分布式锁,Redisson提供了丰富的功能和更好的性能,能够满足我们在秒杀系统中对分布式锁的需求。

二、Redisson 是什么?我们如何改造

Redisson 是一个基于 Redis 的 Java 分布式对象与工具库,提供了分布式锁、队列、布隆过滤器、延迟队列等高级特性。它把“锁”从一个 Lua 脚本,升级为一整套完整协议:可重入、可重试、自动续期、看门狗、订阅通知、故障保护。

项目接入非常简单,我们只需要配置 RedissonClient,通过 getLock 取得 RLock,再像本地锁一样调用 lock/unlock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 配置
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("yourPassword");

RedissonClient redissonClient = Redisson.create(config);

// 2. 使用
RLock lock = redissonClient.getLock("lock:order:" + userId);
try {
lock.lock();
// 核心业务逻辑
} finally {
lock.unlock();
}

注意,我们的“改造”并没有在业务层复杂化,核心只是替换锁实现。但真正的价值不在接口层,而在 Redisson 内部的实现机制。
为什么我们选Redisson?因为它提供了自动续期看门狗机制,解决了分布式锁的死锁和续期问题,同时它的性能也经过了大量的优化,能够满足我们秒杀系统的高并发需求。Redisson在项目里用于秒杀系统的分布式锁,主要是为了保证在分布式环境下的并发安全,防止超卖和一人多单的问题。通过Redisson,我们可以轻松地实现分布式锁的功能,同时也能享受到它提供的高级特性,如自动续期和看门狗机制,进一步提升系统的稳定性和可靠性。在Redis+Lua快速拦截后,Reddison再解决下单的落库等问题,保证分布式环境下的并发安全。

三、Redisson 原理与源码实现(重点)

以下是一些了解到的信息,主要可以用来做为面试题的素材:
也就是常见的项目Redisson八股,选取了几个比较有代表性的:

3.1 可重入锁原理:同一线程可多次获得锁

Redisson 的可重入锁实现并不是简单的“锁重入计数”,而是把锁的状态存成一个 Redis Hash,用 “线程粒度计数” 来表达“谁持有锁、持有了几层”。它把 lockKey 对应的 value 设计为一个 Hash:

  • key:锁名,例如 lock:order:123
  • field:uuid:threadId(Redisson 客户端实例唯一标识 + 当前线程 id)
  • value:重入次数(同一线程第几次进入)

把这个结构想象成下面这样会更直观:

1
2
3
lock:order:123  (Hash)
├─ 9c1f...:41 -> 2
└─ (没有其他 field)

这表示:只有一个线程持有这把锁9c1f...:41),而且它已经重入了 2 次。

如果你在 Redis 里执行 HGETALL lock:order:123,看到的就是「field-value」对。换句话说,Redis 并不是“把线程放进锁里排队”,它只保存“当前持有者是谁 + 重入次数是多少”。其他线程在抢锁失败时不会写入 Hash(它们只会拿到一个 TTL 然后去等待/订阅)。

加锁时会走一个 Lua 脚本,核心逻辑如下:

  1. 如果锁不存在,设置 hash 并写入重入次数为 1。
  2. 如果锁存在且 field 是当前线程,重入次数 +1。
  3. 否则返回失败。

源码路径大致为:RedissonLock.java -> tryLockInnerAsync,内部通过 EVAL 执行一段 Lua 脚本来保证原子性。

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
-- 参数约定(Redisson 内部传入)
-- KEYS[1] = 锁的 key,例如:lock:order:123
-- ARGV[1] = 锁的租期(毫秒),例如:30000
-- ARGV[2] = 当前线程标识 field,例如:{uuid}:{threadId}

-- 1) 这把锁 key 不存在:说明“无人持有”
if (redis.call('exists', KEYS[1]) == 0) then
-- 用 Hash 记录持有者:field={uuid:threadId},value=重入次数(1)
redis.call('hset', KEYS[1], ARGV[2], 1)
-- 设置过期时间(毫秒级),防止死锁
redis.call('pexpire', KEYS[1], ARGV[1])
-- 返回 nil 表示加锁成功(Redisson 用 nil 代表 acquired)
return nil
end

-- 2) 锁存在,但持有者就是“我”:允许重入
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1)
-- 重入也会刷新 TTL,避免业务深层调用导致过期
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end

-- 3) 锁存在且持有者不是我:返回剩余 TTL(毫秒)
-- pttl = “还剩多久过期”,调用方可据此决定等待/订阅超时时间
return redis.call('pttl', KEYS[1])

可重入的关键,是“同一线程再次获取锁时并不阻塞,而是把自己的 field 的 value 递增”。释放时则相反:每次 unlock 会把 value -1,只有当计数减到 0,才会真正删除 lockKey 并通知其他线程。

你可以把解锁想象为这两步(逻辑上):

  1. HINCRBY lockKey field -1;如果结果 > 0,说明还有重入层,锁仍归我。
  2. 结果 == 0,删除 key(DEL lockKey)并 PUBLISH 唤醒等待者。
1
2
3
4
5
6
7
8
flowchart TD
A[请求加锁] --> B{锁是否存在?}
B -- 否 --> C[HSET field=threadId, value=1]
C --> D[PEXPIRE 设置过期]
B -- 是 --> E{field是否为当前线程?}
E -- 是 --> F[HINCRBY 计数+1]
F --> D
E -- 否 --> G[返回失败/等待]

这解决了 Lua 锁的第一个致命缺陷:可重入。

3.2 锁重试与 WatchDog 机制:安全与性能的双保险

Lua 方案失败就只能 sleep 重试,而 Redisson 把“等待”做成了两件事:该阻塞就阻塞(但不忙等)该续期就续期(但只给真正持有锁的线程续期)

锁重试机制(关键:不忙等)

tryLock 失败时,上面 Lua 会返回一个 TTL(pttl),这对 Redisson 很重要:它让等待变得“有边界”。Redisson 的典型策略是:

  1. 先执行一次 tryAcquire(Lua)。
  2. 如果返回 nil,说明拿到锁,直接进入业务。
  3. 如果返回 TTL(比如 17842ms),说明被别人占着。
  4. 这时不会 while(true){sleep(50)} 轮询,而是:
    • 订阅解锁通知频道(内部是 redisson_lock__channel:{lockKey} 这类命名)
    • 在本地用一个“信号量/门闩”阻塞等待
    • 等到两种事件之一发生:
      • 收到 unlockPUBLISH 通知:立刻醒来,马上重试
      • TTL 时间到了还没通知:也会醒来重试(避免漏通知/网络抖动导致永久睡死)

这样做的结果是:等待线程几乎不消耗 CPU,并且在锁释放的那一刻能快速响应。

WatchDog 机制(关键:只给“仍然持有锁”的线程续期)

手写 Lua 锁通常会写死一个过期时间:比如 10 秒。业务一旦超过 10 秒,锁就“自然死亡”,并发安全直接失效。Redisson 的处理更贴近生产:

  • 如果你调用的是 lock()(不指定 leaseTime),Redisson 默认开启 WatchDog。
  • 默认租期通常是 30s(由 lockWatchdogTimeout 控制)。
  • WatchDog 会启动一个定时续期任务,典型续期间隔约为租期的 1/3(例如 30s 的租期,每 10s 续一次)。

续期时并不是无脑 PEXPIRE lockKey 30000,而是会先确认“锁还归我”。内部同样靠 Lua 保证原子性,大意是:

  1. HEXISTS lockKey field 必须为 1(我仍是持有者)
  2. 才会 PEXPIRE 刷新 TTL
  3. 否则不续期(说明锁已释放/已转移,WatchDog 立刻停止)

这让 WatchDog 成为一个非常克制的机制:只要业务线程还在、且锁还在自己名下,锁就不会因为“固定 TTL”而误释放;一旦业务线程崩溃或执行结束,续期自然停止,锁也会在 TTL 到期后释放,避免死锁。

还有一个容易忽略的点:如果你显式调用 lock(leaseTime, unit)tryLock(waitTime, leaseTime, unit) 指定了租期,Redisson 通常不会启用 WatchDog(因为你已经明确声明了租期)。这能避免无限续期带来的资源占用,同时把超时语义交给业务。

源码中关键点(便于你带着目的去看源码):

  • 竞争锁:RedissonLock.lock()lockInterruptibly()tryAcquire()/tryAcquireAsync()
  • 订阅与唤醒:内部 LockPubSub(订阅 channel,收到消息释放本地门闩)
  • WatchDog 启动:scheduleExpirationRenewal()
  • WatchDog 续期:renewExpiration()(Lua 校验持有者后再 PEXPIRE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sequenceDiagram
participant T as 业务线程
participant R as Redis
participant W as WatchDog
T->>R: tryLock (Lua)
alt 成功
T->>W: scheduleExpirationRenewal
loop 每10s左右
W->>R: PEXPIRE续期
end
else 失败
T->>R: SUBSCRIBE lockChannel
R-->>T: 收到unlock通知
T->>R: tryLock 重试
end

这一套机制让“重试”不再靠 sleep,性能更优;也让“超时释放”不再由固定 TTL 决定,而是与业务线程生命周期绑定,极大提升了安全性。

3.3 MultiLock 机制:多节点一致性保证

在 Redis 主从切换问题上,单点锁有概率失效。Redisson 提供 MultiLock(也叫联锁),它会将同一把锁分布在多个独立 Redis 实例上,只有当全部实例都加锁成功时,才认为整体加锁成功。否则会释放已加的锁并重试。

核心实现位于 RedissonMultiLock.java,逻辑类似分布式事务中的“两阶段尝试”:

  1. 依次尝试获取所有子锁。
  2. 若有任意子锁失败,则释放已获取的锁并等待重试。
1
2
3
4
5
6
flowchart TD
A[请求MultiLock] --> B[依次获取N个子锁]
B --> C{是否全部成功?}
C -- 是 --> D[返回加锁成功]
C -- 否 --> E[释放已成功的子锁]
E --> F[等待/重试]

它的优势在于“抗单点故障”,代价是性能下降与复杂度增加,因此只用于高价值场景(如资金、订单扣减)。在企业落地中,一般先用单实例 RedissonLock,极端场景再升级到 MultiLock 或 RedLock。

四、总结:别再手写锁,选择合适框架更重要

Lua 分布式锁让我们理解了锁的本质,但它解决不了工程化落地的全套问题。Redisson 把“锁”做成了一个体系:可重入、可重试、自动续期、锁通知、联锁保障,真正适用于生产环境。Lua最终和Redis一起负责快速拦截和处理,而Redisson则负责下单的落库等问题。不过这个项目的视频看完后毕竟只是了解,个人感觉如果真的用于项目开发,还是需要多加了解。