【Redis】系列第3期:VibeLoop 的数据盾牌 --- 分布式锁完整演进
贯穿案例「VibeLoop」为虚拟的轻量级内容互动平台,仅用于技术演示,并非真实存在的产品。
上期速递:【Redis】缓存策略与三大经典问题【Redis】Redis 数据结构与 Spring Boot 集成
本文覆盖 setnx→Lua→WatchDog→Redlock 六个版本完整演进、Redisson 四件套实战、源码走读、3个 VibeLoop 实战场景、8道面试题、必背速查表。
目录
- 开篇场景:热门帖子的编辑灾难
- 理论速览:分布式锁的三要素
- [版本一:setnx 基础锁与三个致命漏洞](#版本一:setnx 基础锁与三个致命漏洞)
- [版本二:set EX NX 原子加锁](#版本二:set EX NX 原子加锁)
- [版本三:校验 Value 防误删](#版本三:校验 Value 防误删)
- [版本四:Lua 脚本原子释放](#版本四:Lua 脚本原子释放)
- [版本五:WatchDog 自动续期](#版本五:WatchDog 自动续期)
- [版本六:Redlock 多节点加锁](#版本六:Redlock 多节点加锁)
- [Redisson 四件套实战](#Redisson 四件套实战)
- [VibeLoop 三大实战场景](#VibeLoop 三大实战场景)
- 源码走读:面试关键路径
- [面试八连问 + 详解](#面试八连问 + 详解)
- 必背速查表
开篇场景:热门帖子的编辑灾难
VibeLoop 运营团队刚推出一篇"双十一战报",半小时浏览量破 50 万。
Alice 正在改数据错误,Bob 同时在调排版,Charlie 接到运营指令要把标题换成"V2.1 版本"。三个人几乎同时点了保存。
结果:DB 里只剩 Charlie 的版本。Alice 的数据修正没了,Bob 的排版也丢了。翻日志一看,三个 UPDATE 语句几乎同时打进去,后到的直接覆盖前一个。

这就是分布式锁要解决的事:互斥。同一时刻只能有一个客户端操作共享资源。
但"加个锁"三个字,落地到分布式环境里会踩一串坑。本文沿着 setnx → set EX NX → 校验 value → Lua 原子释放 → WatchDog 自动续期 → Redlock 多节点加锁的演进线,一个个拆。
面试官不会问"会不会用 Redis 加锁"------他会问"你的锁在什么场景下会失效"。
理论速览:分布式锁的三要素
一把能用的分布式锁必须同时满足三条:
| 要素 | 含义 | 面试关键词 |
|---|---|---|
| 互斥性 | 同一时刻只有一个客户端持有锁 | SET NX |
| 防死锁 | 持有者崩溃后锁自动释放 | EXPIRE / WatchDog |
| 解铃还须系铃人 | 只有加锁的客户端能释放 | UUID 校验 |

少一条,你的锁压力一上来就会露馅。
版本一:setnx 基础锁与三个致命漏洞
最原始的写法,不到 10 行:
java
// 版本一:setnx 基础锁
public void editPost(Long postId, String content) {
String lockKey = "lock:post:" + postId;
// 1. 尝试加锁
Boolean locked = jedis.setnx(lockKey, "1");
if (!locked) throw new RuntimeException("获取锁失败");
try {
// 2. 执行业务
postMapper.updateContent(postId, content);
} finally {
// 3. 释放锁
jedis.del(lockKey);
}
}
并发场景推演:三个致命漏洞
致命漏洞1:无过期时间 → 死锁
T1: Client A 执行 setnx → 成功,获取锁
T2: Client A 执行业务逻辑(还没执行到 finally)
T3: Client A 所在服务器突然宕机
结果: lock:post:10001 永不过期 → 所有后续请求全部失败
致命漏洞2:释放锁无归属校验
T1: Client A setnx 成功,加锁
T2: Client A 业务执行超时
T3: Client B 尝试加锁,因为锁还在,失败(此时业务正常)
T4: Client A 终于执行完,执行 del → 正常释放
这个版本还埋着一个"隐身"漏洞3:jedis.del(lockKey) 根本不管这锁是不是自己加的------后面会炸。
面试追问预答
问:setnx 做分布式锁有什么问题?
答:三个致命问题。一是没有过期时间,客户端崩溃导致死锁;二是锁没有持有者标识,A 的锁可能被 B 误删;三是加锁和设过期不是原子操作。后续版本逐个修。
版本二:set EX NX 原子加锁
Redis 2.6.12 开始支持 SET key value NX EX seconds,一条命令搞定加锁加过期:
java
// 版本二:set EX NX 原子加锁
String lockKey = "lock:post:" + postId;
String lockValue = UUID.randomUUID().toString(); // 持有者标识
Boolean locked = jedis.set(lockKey, lockValue,
SetParams.setParams().nx().ex(30)); // NX + 30s 过期
if (!locked) throw new RuntimeException("获取锁失败");
try {
postMapper.updateContent(postId, content);
} finally {
jedis.del(lockKey);
}
并发场景推演:锁被误删
T0: Client A 使用 "uuid-A" 加锁,TTL=30s
T1: Client A 执行业务,耗时 35s(超过 30s TTL)
T2: Redis 自动删除锁(锁已过期)
T3: Client B 使用 "uuid-B" 加锁成功,开始执行业务
T4: Client A 执行完业务,执行 jedis.del(lockKey)
结果: Client B 的锁被 Client A 删除了!Client C 此时可以加锁
→ 同一时刻 Client B 和 Client C 同时持有锁 → 互斥被打破
已修复 :死锁问题(有 TTL 了)、setnx+expire 两步非原子的问题。
仍存在:del 之前没判断持有者,锁仍然能被误删。
面试追问预答
问:为什么 SET NX 和 EXPIRE 要放在一条命令里?
答:分两步的话,setnx 成功后、expire 执行前客户端崩了,锁永不过期。
SET key value NX EX seconds一条命令完成,Redis 单线程保证原子性。
版本三:校验 Value 防误删
del 之前先比较 value,不是自己的锁不动它:
java
// 版本三:校验 value 再删除
String lockValue = UUID.randomUUID().toString();
Boolean locked = jedis.set(lockKey, lockValue,
SetParams.setParams().nx().ex(30));
try {
postMapper.updateContent(postId, content);
} finally {
// 先查后删:判断锁的持有者
if (lockValue.equals(jedis.get(lockKey))) {
jedis.del(lockKey);
}
}
并发场景推演:非原子操作的竞态窗口
T1: Client A 执行 jedis.get(lockKey) → 返回 "uuid-A",判断通过
T2: 就在此时,锁刚好过期,Redis 自动删除
T3: Client B 加锁成功,value="uuid-B"
T4: Client A 执行 jedis.del(lockKey) → 删除了 Client B 的锁!
问题本质 :get + del 之间存在竞态窗口。这个窗口极短但确实存在,高并发下概率触发。
面试追问预答
问:get 比较后再 del,不是已经解决问题了吗?
答:没解决。get 和 del 之间有一个极短的竞态窗口。锁在 get 之后、del 之前过期的话,Client A 照样删 Client B 的锁。这就是为什么需要 Lua 把判断和删除捏成一个原子操作。
版本四:Lua 脚本原子释放
Redis 单线程执行 Lua 脚本,执行期间其他命令插不进来:
java
// 版本四:Lua 脚本原子释放
String lockScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
String lockValue = UUID.randomUUID().toString();
Boolean locked = jedis.set(lockKey, lockValue,
SetParams.setParams().nx().ex(30));
try {
postMapper.updateContent(postId, content);
} finally {
// 原子判断 + 删除
jedis.eval(lockScript, Collections.singletonList(lockKey),
Collections.singletonList(lockValue));
}
为什么 Lua 是原子的

整个 Lua 脚本在 Redis 中被加载、编译、执行,中间不会有其他命令穿插进来。脚本内部的多个 Redis 命令是串行连续的。
面试追问预答
问:Lua 脚本为什么能保证原子性?Redis 6.0 引入 IO 多线程后还原子吗?
答:Redis 6.0 的 IO 多线程只处理网络读写,命令执行仍然是主线程单线程。Lua 脚本执行时不会被其他命令打断,原子性不受影响。IO 多线程不等于命令并行执行。
版本五:WatchDog 自动续期
版本四解决了误删问题,但新问题来了:TTL 设多少?
- 太短:业务没跑完锁就过期了,互斥失效
- 太长:客户端崩溃后,其他人得干等好久才能抢到锁
Redisson 的 WatchDog 就是解决这个的。默认 internalLockLeaseTime=30000ms,续期间隔 = 30000/3 = 10000ms。调用 lock() 或 lock(leaseTime, unit) 时,当且仅当 leaseTime=-1 才会触发看门狗自动续期。如果你显式传了 leaseTime=30000(看起来和默认值一样),看门狗不会启动------锁会在 30s 后静默过期,业务还没执行完的话直接并发裸奔。这是个非常容易踩的坑。
java
// 版本五:Redisson RLock + WatchDog
RLock lock = redissonClient.getLock("lock:post:" + postId);
try {
// leaseTime=-1 触发看门狗机制,默认 30s 自动续期
lock.lock();
postMapper.updateContent(postId, content);
} finally {
lock.unlock();
}

WatchDog 核心机制
加锁成功 → 启动定时任务(10s 后执行)
→ 10s 后 Lua 脚本 pexpire key 30000(重置 TTL 为 30s)
→ 再次注册 10s 后的定时任务
→ 循环,直到 unlock() 被调用
unlock() → 取消定时任务 → Lua 脚本校验并删除 key

底层基于 Netty 的 HashedWheelTimer 时间轮:128 个槽位,每 100ms 前进一格,每圈 12.8 秒。续期任务被分配到对应的槽位,到时自动触发。
面试追问预答
问:WatchDog 的原理是什么?续期间隔为什么是 10s?
答:基于 Netty HashedWheelTimer 时间轮实现。默认 internalLockLeaseTime=30s,续期间隔 = 30s/3 = 10s。这样即使一次续期网络超时,还有 20s 余量够重试。客户端崩了,30s 后锁自动释放,不会死锁。
版本六:Redlock 多节点加锁
版本五在单节点 Redis 上够用了。但 Redis 是单点的话,宕机了怎么办?
Redlock 由 Redis 作者 antirez 提出:在 N 个独立 Redis 节点上分别加锁,超过半数(N/2+1)成功才算获取锁。
java
// 版本六:RedissonRedLock 多节点加锁
RLock lock1 = redissonClient1.getLock("lock:post:" + postId);
RLock lock2 = redissonClient2.getLock("lock:post:" + postId);
RLock lock3 = redissonClient3.getLock("lock:post:" + postId);
RLock lock4 = redissonClient4.getLock("lock:post:" + postId);
RLock lock5 = redissonClient5.getLock("lock:post:" + postId);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
try {
// 尝试在 5 个节点上加锁,超时时间 100ms
boolean locked = redLock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
if (locked) {
postMapper.updateContent(postId, content);
}
} finally {
redLock.unlock();
}
Redlock 算法步骤
- 获取当前时间戳(毫秒)
- 依次向 N 个节点发起
SET key value NX PX ttl,设置短的超时时间(远小于锁 TTL) - 统计成功节点数。如果 >= N/2+1 且总耗时 < 锁有效期 → 获取锁成功
- 锁实际有效时间 = 锁 TTL - 总耗时
- 失败则向所有节点发送释放请求

NTP 时钟偏移风险
Redlock 最大的争议在这里。Martin Kleppmann 在 《How to do distributed locking》 中指出:如果某个 Redis 节点的 NTP 时钟突然向前跳了 30s,该节点上的锁提前过期,另一个客户端就能在这个节点上加锁------互斥被打破。
antirez 在 《Is Redlock safe?》 中回应:可以用 fencing token(单调递增令牌)在业务层防御。即使两个客户端同时拿到锁,资源层(如 DB)可以拒绝较小 token 的写入。
面试追问预答
问:Redlock 安全吗?有什么争议?
答:大多数场景下安全,但正确性要求极高的系统(金融交易级别)存在 NTP 时钟偏移的理论风险。可以配合 fencing token 在资源层兜底。VibeLoop 内容编辑场景用单节点 WatchDog 就够了,没必要上 Redlock 的复杂度。
Redisson 四件套实战
除了基础 RLock,Redisson 还提供三种高级锁。
1. 可重入锁(ReentrantLock)
同一个线程可以多次获取同一把锁,内部用 hset 维护重入计数:
java
RLock lock = redissonClient.getLock("lock:post:10001");
public void outerMethod() {
lock.lock();
try {
innerMethod(); // 同一线程再次获取同一把锁,计数器 +1
} finally {
lock.unlock(); // 计数器 -1
}
}
public void innerMethod() {
lock.lock(); // 重入,不阻塞
try {
// 业务
} finally {
lock.unlock();
}
}
底层用 Hash 结构:hset lock:post:10001 uuid:threadId 1,每次重入 hincrby +1,释放时 -1,计数归零后才真正 del。
2. 读写锁(RReadWriteLock)
读锁共享,写锁互斥:
java
RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:post:10001");
// 读操作:多个线程可以同时持有读锁
rwLock.readLock().lock();
Post post = postMapper.selectById(postId);
rwLock.readLock().unlock();
// 写操作:独占,等待所有读锁释放
rwLock.writeLock().lock();
postMapper.updateContent(postId, content);
rwLock.writeLock().unlock();
VibeLoop 场景:帖子浏览(读多)用读锁,帖子编辑(写少)用写锁。读读不互斥。
3. 联锁(MultiLock)
多把锁必须全部获取成功才算成功。和 Redlock 的多数派不同,联锁要求 100%:
java
RLock lock1 = redissonClient1.getLock("lock:resource-a");
RLock lock2 = redissonClient2.getLock("lock:resource-b");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2);
multiLock.lock(); // 两把锁都成功才算获取
适用场景:转账时需要同时锁住 A 账户和 B 账户。
4. 红锁(RedLock)
版本六已详解。用于对 Redis 单点故障零容忍的场景。
VibeLoop 三大实战场景
场景一:帖子编辑锁
java
@Service
public class PostEditService {
@Autowired
private RedissonClient redissonClient;
private static final long LEASE_TIME = 30000; // 30s
public boolean editPost(Long postId, Long userId, PostDTO dto) {
String lockKey = "lock:post:edit:" + postId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(3, LEASE_TIME, TimeUnit.MILLISECONDS)) {
postMapper.updateById(dto);
return true;
}
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
场景二:定时任务 Leader 选举
VibeLoop 有 3 个节点,每天凌晨生成热门帖子榜单。用分布式锁保证只有一个节点执行:
java
@Component
public class HotPostTask {
@Autowired
private RedissonClient redissonClient;
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2:00
public void generateHotPosts() {
RLock lock = redissonClient.getLock("lock:task:hot-posts");
if (!lock.tryLock()) {
return; // 已有节点在执行,直接返回
}
try {
hotPostService.generate();
} finally {
lock.unlock();
}
}
}
场景三:用户注册唯一性校验
用户注册时,用分布式锁防止同一用户名被并发注册:
java
public boolean register(String username, RegisterDTO dto) {
String lockKey = "lock:register:" + username;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
if (userMapper.countByUsername(username) > 0) {
return false; // 双重检查
}
userMapper.insert(dto.toUser());
return true;
}
return false;
} finally {
lock.unlock();
}
}
源码走读:面试关键路径
关键路径1:Redisson tryLock 加锁全流程
java
// RedissonBaseLock.tryLock()
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true; // 加锁成功
}
// 加锁失败 → 订阅释放事件 → 信号量阻塞等待 → 唤醒后重试
// ...
}
核心 Lua 脚本(加锁):
lua
-- KEYS[1] = lockKey, ARGV[1] = leaseTime, ARGV[2] = uuid:threadId
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁已存在,判断是否是同一线程(可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]); -- 返回剩余 TTL
关键路径2:WatchDog 续期触发
java
// RedissonBaseLock.scheduleExpirationRenewal()
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
// 每 internalLockLeaseTime/3 执行一次续期
entry.getTimeout().set(internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
// 注册到 Netty 时间轮
renewExpiration(threadId, entry);
}
续期 Lua:
lua
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]); -- 续期 30s
return 1;
end;
return 0;
关键路径3:Redlock 多数派判断
java
// RedissonRedLock.tryLock()
int successCount = 0;
long startTime = System.currentTimeMillis();
for (RLock lock : locks) {
if (lock.tryLock(remainTime, leaseTime, unit)) {
successCount++;
}
long elapsed = System.currentTimeMillis() - startTime;
if (elapsed > waitTime) break; // 总超时
}
if (successCount >= locks.size() / 2 + 1) {
return true;
} else {
unlockInner(locks); // 失败 → 释放所有已获得的锁
return false;
}
面试八连问 + 详解
Q1:Redis 分布式锁用在什么场景?和数据库锁有什么区别?
Redis 分布式锁适合跨 JVM、跨服务的互斥场景,比如防止定时任务重复执行、保证幂等。和 SELECT FOR UPDATE 的区别:DB 锁依赖事务和连接,粒度粗、性能差;Redis 锁是内存操作,微秒级响应,但一致性弱于 DB 锁。防并发用 Redis,防事务用 DB。
Q2:setnx 的三个致命漏洞是什么?
- 无过期时间,客户端崩溃导致死锁;2) 释放锁时没校验持有者,A 的锁被 B 误删;3) 加锁和设过期不是原子操作,两步之间崩溃仍会死锁。
Q3:为什么用 Lua 而不是 Java 判断后删除?
Java 判断和删除是两个 Redis 命令,中间有网络往返的竞态窗口。Lua 脚本在 Redis 单线程中执行,判断和删除是原子的。Redis 6.0 的 IO 多线程只处理网络读写,命令执行仍是单线程。
Q4:Redisson 的 WatchDog 怎么防止死锁?
WatchDog 每 10s 把 TTL 重置为 30s,直到客户端主动 unlock。客户端崩了,续期停止,30s 后锁自动过期。底层基于 Netty HashedWheelTimer 时间轮。
Q5:Redlock 和普通锁的核心区别?
普通锁依赖单个 Redis 节点,节点宕机锁就没了。Redlock 在 N 个独立节点上分别加锁,超过半数成功才算获取锁。即使少数节点宕机,剩余节点仍能提供服务。代价是网络开销和复杂度增加。
Q6:Redlock 安全吗?争议在哪里?
基本安全,但正确性要求极高(如金融)的场景存在 NTP 时钟偏移风险。某个节点时钟突然跳变,锁可能提前过期。antirez 建议配合 fencing token 在资源层兜底。
Q7:可重入锁怎么实现的?
底层用 hset lockKey uuid:threadId 1,field 存线程标识,value 存重入计数。每次加锁 hincrby +1,释放时 -1,归零后才 del。
Q8:分布式锁的过期时间设多长合适?
不用 WatchDog 的话,设为业务平均时间的 3~5 倍,加 10%~30% 随机抖动。用 WatchDog 的话,设 leaseTime=-1 交给它自动续期。
必背速查表
六版本演进速查
| 版本 | 核心命令 | 解决的问题 | 遗留问题 | 互斥 | 防死锁 | 归属 | 面试关键词 |
|---|---|---|---|---|---|---|---|
| v1 | SETNX |
基础互斥 | 无过期→死锁、无归属 | ✅ | ❌ | ❌ | 基础用法 |
| v2 | SET NX EX |
死锁、原子性 | 锁被误删 | ✅ | ✅ | ❌ | 原子命令 |
| v3 | GET + DEL |
误删(校验) | 非原子操作 | ✅ | ✅ | ⚠️ | UUID 标识 |
| v4 | EVAL Lua |
原子性 | 过期时间难评估 | ✅ | ✅ | ✅ | Lua 脚本 |
| v5 | WatchDog |
自动续期 | 单点故障 | ✅ | ✅ | ✅ | Netty 时间轮 |
| v6 | Redlock |
单点故障 | NTP 时钟偏移 | ✅ | ✅ | ✅ | 多数派 |
常用命令速查
| 命令 | 用途 | 时间复杂度 |
|---|---|---|
SET key val NX EX ttl |
原子加锁 | O(1) |
EVAL script numkeys key... arg... |
Lua 脚本原子操作 | 取决于脚本 |
PEXPIRE key ttl |
设置/更新过期时间 | O(1) |
DEL key |
删除锁 | O(1) |
HEXISTS key field |
检查可重入锁持有者 | O(1) |
Redisson 常用 API
java
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // 阻塞直到获取锁(带 WatchDog)
lock.tryLock(3, 30, TimeUnit.SECONDS); // 等待3s,持锁30s
lock.tryLock(); // 尝试一次,失败立即返回
lock.unlock(); // 释放
lock.isLocked(); // 是否被锁定
lock.isHeldByCurrentThread(); // 当前线程是否持有
选型决策树
单节点 Redis → RLock + WatchDog(leaseTime=-1) ← VibeLoop 内容编辑
多节点可用 → Redlock(N≥5,容忍少数节点宕机)
需要读写分离 → RReadWriteLock
需要锁定多个资源 → RedissonMultiLock
下期预告 --- 第4期「VibeLoop 的生存手册 --- 持久化与高可用架构」:RDB 快照原理、AOF 三种同步策略、主从复制 PSYNC、哨兵集群故障转移、Cluster 哈希槽分片。VibeLoop 从 3 主 3 从 + 3 哨兵到 6 节点 Cluster 的演进路线。