外观
02 - 分布式锁
知识图谱
分布式锁
┌──────────────┼──────────────┐
▼ ▼ ▼
SimpleRedisLock Redisson ZooKeeper
(手写, 教学用) (生产级) (强一致)
│ │
▼ ▼
SET NX EX + Lua 看门狗自动续期
UUID-ThreadId 可重入/公平锁
unlock.lua Pub/Sub 通知
│
▼
┌───────────────────────────┐
│ 演进路线(面试必讲): │
│ 1. SETNX → 无过期, 死锁 │
│ 2. +EX → 非原子 │
│ 3. SET NX EX → 误删 │
│ 4. +UUID → GET/DEL非原子 │
│ 5. +Lua → 最终方案 │
└───────────────────────────┘核心代码走读
SimpleRedisLock
文件: src/main/java/com/hmdp/utils/SimpleRedisLock.java
java
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
// 静态 UUID: 每个 JVM 实例唯一
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
// Lua 脚本在类加载时初始化(只加载一次)
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 锁值 = UUID(区分实例) + ThreadId(区分线程)
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// Lua 脚本: 原子的 compare-and-delete
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}unlock.lua — 原子释放
文件: src/main/resources/unlock.lua
lua
-- KEYS[1] = 锁的 key
-- ARGV[1] = 当前线程的标识(UUID-ThreadId)
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 值匹配 → 是自己的锁 → 删除
return redis.call('del', KEYS[1])
end
-- 值不匹配 → 不是自己的锁 → 不删除
return 0被注释掉的非原子版本(经典面试题)
java
/*
// ⚠️ 有 Bug 的版本: GET 和 DELETE 不是原子操作
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if(threadId.equals(id)) {
// ← 这里可能发生 GC 暂停、线程切换
// ← 此时锁可能已过期, 被其他线程获取
stringRedisTemplate.delete(KEY_PREFIX + name);
// ← 删除了别人的锁!
}
}
*/CacheClient 中的简化版锁(对比)
文件: src/main/java/com/hmdp/utils/CacheClient.java (line 169-176)
java
// ⚠️ 无 owner 校验, 任何线程都能删除
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key); // 直接删除!
}Redisson 在项目中的使用
配置: src/main/java/com/hmdp/config/RedissonConfig.java
java
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.76.129:6379")
.setPassword("123321");
return Redisson.create(config);
}使用: VoucherOrderServiceImpl.createVoucherOrder()
java
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = redisLock.tryLock(); // 非阻塞获取
if (!isLock) {
log.error("获取用户ID锁失败!用户正在下单...");
return;
}
try {
// 业务逻辑: 查订单 → 扣库存 → 保存订单
} catch (Exception e) {
// 发送回滚消息到 Kafka
} finally {
redisLock.unlock();
}面试 Q&A
Q1: 讲讲你是怎么实现分布式锁的?
项目中有两种实现:
SimpleRedisLock(手写):基于 Redis 的
SET key value NX EX命令,key 是lock:{业务名},value 是UUID-ThreadId。释放锁时使用 Lua 脚本做原子的 compare-and-delete,确保只释放自己的锁。Redisson(生产级):用于秒杀下单的用户级锁
lock:order:{userId}。Redisson 提供了看门狗自动续期、可重入、公平锁等高级特性。
追问:为什么锁的 value 要用 UUID + ThreadId?
防止误删别人的锁。
- UUID:区分不同 JVM 实例(分布式场景下可能有多个服务实例)
- ThreadId:区分同一实例内的不同线程
释放锁时先 GET 比对 value,只有匹配才 DELETE。如果不记录 owner,线程 A 的锁过期后被线程 B 获取,线程 A 业务完成后 DELETE 就会删掉线程 B 的锁。
再追问:UUID 是 static final 的,为什么?
static final意味着每个 JVM 实例生成一个 UUID,所有SimpleRedisLock对象共享。这是正确的设计——同一 JVM 内靠 ThreadId 区分,不同 JVM 靠 UUID 区分。如果每次 new 都生成新 UUID,同一线程先后创建两个锁对象会被视为不同 owner。
Q2: 为什么释放锁要用 Lua 脚本?
因为 GET + 判断 + DELETE 不是原子操作。两步之间可能发生:
时间线: T1: 线程A GET lock → 返回 "A-1" (匹配) T2: 线程A 的锁过期, 自动删除 T3: 线程B SET lock "B-2" NX EX → 成功获取锁 T4: 线程A DELETE lock → 删除了线程B的锁!Lua 脚本在 Redis 中是原子执行的(单线程模型保证),GET 和 DELETE 之间不会被其他命令插入。
追问:Redis 的 MULTI/EXEC 事务能不能解决这个问题?
不太适合。Redis 事务需要配合 WATCH 实现乐观锁:
WATCH lock_key GET lock_key → 检查值 MULTI DEL lock_key EXEC如果 WATCH 的 key 在 EXEC 前被修改,事务会被丢弃。但这样的问题是:
- 需要两次网络往返(WATCH+GET 和 MULTI+DEL+EXEC)
- 事务失败后需要重试逻辑
- 代码复杂度高于 Lua
Lua 脚本一次网络往返就搞定,更简洁高效。
Q3: Redisson 的看门狗机制是什么?
Redisson 的
tryLock()如果不指定 leaseTime,默认 30 秒过期。同时启动一个看门狗(Watchdog)线程,每隔leaseTime / 3(10秒)检查锁是否仍被持有,如果是则续期到 30 秒。这解决了 SimpleRedisLock 的一个痛点:业务执行时间超过锁 TTL 时锁提前释放。
追问:看门狗会不会导致锁永远不释放?
不会。看门狗是在持有锁的线程所在的 JVM 中运行的:
- 如果 JVM 正常运行但业务卡住,看门狗续期 → 需要设置最大等待时间或业务超时
- 如果 JVM 宕机,看门狗也挂了 → 锁在 30 秒后自动释放
所以看门狗保证了"活着就续、死了就释"。
Q4: SimpleRedisLock 支持可重入吗?
不支持。同一线程再次 tryLock 时,
SET NX会失败(key 已存在),即使是同一个 owner。Redisson 支持可重入:内部使用 Hash 结构
lock_key → {owner: count},每次加锁 count++,每次解锁 count--,count 为 0 时才真正释放。
Q5: 三种锁实现的对比
| 维度 | SimpleRedisLock | Redisson | ZooKeeper |
|---|---|---|---|
| 实现方式 | SET NX EX + Lua | Hash + Pub/Sub + Watchdog | 临时顺序节点 + Watch |
| 可重入 | 不支持 | 支持 | 支持 |
| 自动续期 | 不支持 | 看门狗 | 临时节点自动(会话机制) |
| 公平性 | 非公平 | 支持公平锁 | 天然公平(顺序节点) |
| 锁释放通知 | 轮询 | Pub/Sub 通知 | Watch 事件通知 |
| 一致性 | AP(Redis 主从异步复制可能丢锁) | AP | CP(强一致) |
| 性能 | 高 | 高 | 中 |
| 复杂度 | 低 | 中 | 高 |
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| 非原子释放锁 | 被注释的代码是经典反面教材 | 原子性理解 |
| CacheClient 的 unlock | 直接 DELETE 无校验,与 SimpleRedisLock 矛盾 | 代码一致性 |
| Redis 主从切换丢锁 | 主节点宕机,从节点晋升,锁可能丢失 | Redis 集群下的锁可靠性 |
tryLock() 无参调用 | Redisson 的 tryLock() 不指定 waitTime 时立即返回 | API 理解 |
| 锁粒度设计 | lock:order:{userId} vs lock:order:{voucherId} | 锁粒度对并发的影响 |
加分回答
- 提到 RedLock 算法:Redisson 支持 RedLock,在多个独立 Redis 实例上加锁,解决主从切换丢锁问题,但有争议(Martin Kleppmann vs Antirez 的辩论)
- 提到 锁粒度选择:项目中秒杀用
lock:order:{userId}(用户级),而限购券用lock:voucher:{voucherId}{userId}(用户+券级),粒度越细并发越高 - 分析
SimpleRedisLock的 Lua 脚本只需要一次网络往返,而被注释的版本需要两次(GET + DELETE),性能更好 - 提到 Redisson 源码中
tryLockInnerAsync使用的是 Lua 脚本操作 Hash 结构实现可重入
关联文档
- 01-Redis缓存设计 — CacheClient 内部锁的问题
- 03-秒杀系统 — Redisson 在秒杀消费端的使用
- 11-已知问题与优化方向 — CacheClient unlock Bug