Redis 分布式锁实战演进:从单机锁到 Redisson 核心原理
本文将基于本项目的 Git 提交记录,详细梳理一个分布式锁是如何一步步完善的。我们将看到每一次迭代背后的思考:遇到了什么问题?如何解决?引入了什么新问题?
V1.0 - V2.0:单机锁的局限性
阶段描述
最初的业务场景非常简单,在一个 InventoryService 中扣减库存。
- V1.0 (commit
3ba341a):没有任何锁,高并发下出现超卖。 - V2.0 (commit
62eefd9) :引入 Java 自带的锁(synchronized或ReentrantLock)。
代码实现
java
private Lock lock = new ReentrantLock();
public String sale() {
lock.lock();
try {
// 1. 查询库存
// 2. 扣减库存
} finally {
lock.unlock();
}
}
存在的问题
单机锁无法解决分布式问题 。
当项目部署了两个服务实例(如 8081 和 8082,commit a7c6adc)时,ReentrantLock 只能锁住当前 JVM 的线程。两个不同的 JVM 进程依然可以同时读取到相同的库存数量,导致超卖。
V3.1:简单的分布式锁(递归重试)
阶段描述 (commit 523ec3c)
为了解决分布式环境下的互斥问题,我们引入 Redis 的 SETNX (Set if Not Exists) 命令。
代码实现
java
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue);
if (!flag) {
// 获取失败,递归重试
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) {}
sale();
} else {
try {
// 业务逻辑
} finally {
stringRedisTemplate.delete(key);
}
}
存在的问题
栈溢出 (StackOverflowError) 。
在高并发场景下,如果大量线程抢不到锁并不断递归调用 sale(),会导致方法调用栈过深,最终抛出 StackOverflowError。
V3.2:优化重试策略(自旋锁)
阶段描述 (commit ae63c87)
为了解决递归导致的栈溢出,将递归调用改为循环等待(自旋)。
代码实现
java
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) {
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) {}
}
// 业务逻辑...
存在的问题
死锁风险 。
如果在 setIfAbsent 成功后,业务代码执行过程中服务宕机(或断电),finally 块中的 delete 永远无法执行。Redis 中的 key 一直存在,导致其他线程永远无法获取锁。
V4.0:引入过期时间
阶段描述 (commit 3105842)
为了解决死锁问题,必须给锁加一个过期时间。
代码实现
java
// 使用原子命令:SET key value NX EX 30
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue, 30L, TimeUnit.SECONDS)) {
// ...
}
注意:必须使用 SET 的原子命令,不能先 SETNX 再 EXPIRE,否则在两步之间宕机依然会导致死锁。
存在的问题
误删锁 。
假设线程 A 获取锁,设置 30s 过期。
- A 业务执行慢,用了 35s。
- 30s 时锁自动过期。
- 线程 B 获取到锁。
- A 执行完毕,执行
delete(key)。
此时 A 删除的是 B 的锁!B 的业务还在跑,失去了锁的保护,线程 C 又能进来了。
V5.0:防误删(UUID 校验)
阶段描述 (commit 75362b8)
为了解决误删问题,我们在删除前判断:这把锁是不是我的?
我们在加锁时存入 UUID + ThreadID,删除时检查值是否一致。
代码实现
java
String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();
// 加锁...
try {
// 业务...
} finally {
// 判断是否是自己的锁
if (stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)) {
stringRedisTemplate.delete(key);
}
}
存在的问题
原子性问题 。
get 判断和 delete 删除是两个独立的操作,不是原子的。
极端场景:
- A 判断锁是自己的(返回 true)。
- 此时锁恰好过期(或被 GC 停顿)。
- B 抢到新锁。
- A 恢复执行,继续执行
delete。
A 还是把 B 的锁删了。
V6.0:原子删除(Lua 脚本)
阶段描述 (commit 8e3012a)
为了保证"判断 + 删除"的原子性,引入 Lua 脚本。Redis 会将 Lua 脚本作为一个整体执行,中间不会被插入其他命令。
代码实现
java
String luaScript =
"if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), Arrays.asList(key), uuidValue);
存在的问题
不可重入 。
如果 sale() 方法中调用了另一个需要相同锁的方法 method2(),由于 SETNX 的特性,method2() 会因为锁已被自己持有(但无法识别)而阻塞,导致死锁。Java 的 ReentrantLock 是支持可重入的。
V7.0:可重入锁(Hash + Lua)
阶段描述 (commit 07035b9)
为了支持可重入,我们需要记录"是谁加的锁"以及"加了几次"。
数据结构从 String 变为 Hash:
- Key:
lockName - Field:
UUID:ThreadID - Value:
重入次数
代码实现
加锁 Lua 脚本:
lua
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[1], 1)
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0
end
解锁 Lua 脚本:
lua
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then
return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then
return redis.call('del', KEYS[1])
else
return 0
end
同时引入工厂模式 DistributedLockFactory 方便使用。
存在的问题
锁过期时间不够用(业务没跑完锁没了) 。
虽然解决了误删和可重入,但如果业务执行时间超过设置的过期时间(如 30s),锁依然会释放,导致并发安全问题。
V8.0:自动续期(看门狗机制)
阶段描述 (commit 21da498)
为了解决锁提前过期的问题,引入"看门狗"机制:开启一个后台线程(定时任务),每隔一段时间(如过期时间的 1/3)检查锁是否存在,如果存在则重置过期时间。
代码实现
java
// 加锁成功后启动定时任务
private void resetExpire() {
String script = "if redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else " +
"return 0 " +
"end";
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if (stringRedisTemplate.execute(...)) {
resetExpire(); // 递归调用,继续续期
}
}
}, (this.expireTime * 1000) / 3);
}
总结
至此,我们手写的分布式锁已经具备了 Redisson 的核心功能:
- 互斥性:SETNX / Lua
- 防死锁:TTL (Time To Live)
- 防误删:UUID 唯一标识
- 原子性:Lua 脚本
- 可重入:Hash 结构 + 计数器
- 自动续期:Timer 定时任务
这正是 Redisson 客户端底层的核心实现逻辑。
配套的代码在 https://github.com/yisuibandamowang/RedisDistributedLockDemo 喜欢可以点个 star