【Redis合集-06】生产级Redis分布式锁深度解析:Redisson原理、看门狗与Lua源码实战及ZooKeeper选型对比
目录
一、我们为什么需要分布式锁?
二、从简单的 setnx 开始,看看坑在哪
三、Redisson——生产级方案
1. 可重入加锁原理(基于 Hash + Lua 脚本)
2. 看门狗(Watchdog)自动续期机制
3. 释放锁的 Lua 脚本(可重入递减)
4. Java 代码快速上手
四、高并发下的进阶——锁竞争与红锁
1. 主从切换导致锁丢失
2. 红锁的争议和大厂实际选择
五、实战:模拟秒杀扣库存(JMeter 压测)
六、关键注意点总结
七、Redis 与 ZooKeeper 分布式锁的区别和选型比较
1. 实现原理对比
2. 核心特性对比
3. 优劣势分析
4. 实际选型(个人想法)
一、我们为什么需要分布式锁?
在单机 Java 里,可以用 synchronized 或者 ReentrantLock 来保证多线程对共享资源的安全访问。但到了分布式环境,应用部署在多台机器上,这些 JVM 级别的锁就管不住了,因为它们只对自己进程内的线程可见。
比如“商品秒杀扣库存”,三个实例同时去查库存并减库存,很可能超卖。这时候就必须有一个跨 JVM 的协调者,Redis、Zookeeper 都可以充当这个角色。
二、从简单的 setnx 开始,看看坑在哪
最简单的实现:
// 加锁:setnx + expire 不能保证原子性(早期做法)
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock_key", "1");
redisTemplate.expire("lock_key", 30, TimeUnit.SECONDS);
问题1:这两步不是原子操作。如果刚 setIfAbsent 成功,还没执行 expire 服务器就挂了,这个锁就永远不会释放,造成死锁。
改进:用一条原子命令 SET key value NX EX 30。
// 正确原子加锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock_key", "client_id", 30, TimeUnit.SECONDS);
释放锁时不能直接 del,因为如果线程A执行超时,锁过期自动释放了,线程B拿到了锁,此时线程A再去 del 就会把 B 的锁误删。所以 value 必须设一个唯一标识(比如 UUID+线程ID),释放时要判断 value 是否是自己,并且判断+删除要原子执行,用 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Java 调用脚本释放。
这样看起来已经挺好了,但还有致命问题:
-
不可重入:同一个线程再申请同一把锁会把自己堵死。
-
锁过期时间不好设:业务执行时间可能超过 expire 时间,锁自动释放后别的线程进来,造成并发问题。
-
主从切换锁丢失:如果 Redis 是主从架构,主节点加了锁还没同步到从节点就宕机,从升级为主后锁丢失,其他实例可以再次获取锁。
这就是为什么需要 Redisson 这种封装。
三、Redisson——生产级方案
Redisson 内部用了一套基于 Redis 的分布式锁实现,解决了可重入、自动续期、异步回调等问题。下面直接贴上我从源码里扒出来的核心实现。
1. 可重入加锁原理(基于 Hash + Lua 脚本)
Redisson 锁在 Redis 里存的是一个 Hash,key 是锁名,field 是 UUID:线程ID,value 是重入次数。
以下是 RedissonLock 中 加锁的核心 Lua 脚本:
-- Redisson 加锁脚本 tryLockInnerAsync
-- KEYS[1] 锁名称
-- ARGV[1] 锁自动过期时间(默认30秒)
-- ARGV[2] 当前客户端的唯一标识(UUID:threadId)
-- 1. 如果锁不存在,直接 hset 并设置过期时间
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; -- nil 代表加锁成功
-- 2. 如果锁存在,且是当前线程持有(field 存在),则重入次数 +1,并续期
elseif (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; -- nil 也代表加锁成功(可重入)
-- 3. 否则锁已被其他客户端持有,返回锁的剩余时间(毫秒),代表加锁失败
else
return redis.call('pttl', KEYS[1]);
end
这个脚本保证了:
-
首次加锁:创建 Hash,field=当前线程,计数器=1。
-
可重入:同一个 field 再次加锁,计数器加 1,并重置过期时间。
-
互斥:不同 field 尝试加锁时,只返回剩余 TTL,不会覆盖锁。
2. 看门狗(Watchdog)自动续期机制
加锁成功后会启动一个定时任务,每隔 internalLockLeaseTime / 3 的时间(默认 30秒/3 = 10秒),执行一段 Lua 脚本来刷新 key 的过期时间。
续期脚本如下(源码中 scheduleExpirationRenewal 方法里用到的):
-- KEYS[1] 锁名称
-- ARGV[1] 当前客户端标识
-- ARGV[2] 要设置的过期时间(默认30秒)
-- 只有当锁的 field 仍然是当前线程时才续期
if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 1;
end
return 0;
对应的 Java 调度代码简化版(基于 RedissonBaseLock.scheduleExpirationRenewal):
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
// 已经存在的续期任务会直接返回,避免重复创建
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
return;
}
entry.addThreadId(threadId);
Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> {
// 检查当前线程是否还持有锁(通过本地标志)
if (entry.getFirstThreadId() == threadId && hasLock()) {
// 执行续期 Lua 脚本
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null || res == null || !res) {
// 续期失败,取消任务,清理
cancelExpirationRenewal(threadId);
return;
}
// 续期成功,继续调度下一次
scheduleExpirationRenewal(threadId);
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
entry.setTimeout(task);
}
关键点:
-
renewExpirationAsync内部就是执行上面的 Lua 脚本。 -
只要客户端进程存活且线程还持有锁,看门狗就会一直续期,锁的有效期始终保持在 30 秒左右。
-
如果客户端宕机,看门狗任务停止,30 秒后锁自动删除,不会死锁。
3. 释放锁的 Lua 脚本(可重入递减)
解锁时不能直接 del,必须原子地判断身份、递减计数器,计数器归零才删除锁并通知等待者。源码中的解锁脚本:
-- KEYS[1] 锁名称
-- KEYS[2] 通知频道(Redisson 内部使用的 Pub/Sub channel)
-- ARGV[1] 解锁消息(0 或 1 代表是否释放成功)
-- ARGV[2] 锁过期时间(看门狗默认30秒)
-- ARGV[3] 当前客户端标识(UUID:threadId)
-- 如果锁不存在,直接广播消息并返回
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil; -- 当前线程未持有锁,不能释放
end
-- 锁存在且是自己持有,计数器 -1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
-- 计数器 >0 说明有重入,只续期不删除锁
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 计数器归零,删除锁并发布释放消息
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end
结合 Java 中的 unlockInnerAsync 调用,可以清楚看到:如果重入了两次,第一次 unlock 只会减到 1 并续期,第二次 unlock 才会真正删除锁并通知其他等待的线程。
4. Java 代码快速上手
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.19.0</version>
</dependency>
配置单节点或集群:
spring:
redis:
redisson:
file: classpath:redisson.yaml
或者直接写配置类:
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
使用分布式锁:
@Service
public class StockService {
@Autowired
private RedissonClient redissonClient;
public void deductStock(String productId) {
String lockKey = "stock:lock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,等待10秒,锁自动过期默认30秒(看门狗)
// tryLock(10, TimeUnit.SECONDS) 等价于 tryLock(10, -1, SECONDS)
// leaseTime = -1 → 启用默认 30s 过期 + 看门狗自动续期
if (lock.tryLock(10, TimeUnit.SECONDS)) {
// 执行业务,查询库存、扣减库存
int stock = getStockFromDB(productId);
if (stock > 0) {
updateStock(productId, stock - 1);
System.out.println("扣减成功,剩余库存:" + (stock - 1));
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放锁前检查,只有当前线程持有的锁才释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
踩坑记录:
unlock一定要放在 finally 中,并且用isHeldByCurrentThread()判断后再释放。否则如果加锁失败还去 unlock,会抛IllegalMonitorStateException。
四、高并发下的进阶——锁竞争与红锁
1. 主从切换导致锁丢失
假设我们用了一主一从哨兵模式,线程A在主节点获取了锁,此时主节点挂了,数据还没同步到从节点,从升主后锁丢失,线程B就能再次获取同一把锁,出现安全性问题。
Redis 作者 Antirez 提出 Redlock 算法:在 N 个独立的 Redis 主节点上(通常是5个)依次尝试获取锁,只有当在大多数节点(N/2+1)上成功加锁,且总耗时小于锁的有效时间,才算真正获取锁。释放时向所有节点发送释放命令。
Redisson 封装了 RedissonRedLock:
RLock lock1 = redissonClient.getLock("lock1");
RLock lock2 = redissonClient.getLock("lock2");
RLock lock3 = redissonClient.getLock("lock3");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 业务逻辑
} finally {
redLock.unlock();
}
2. 红锁的争议和大厂实际选择
在某些博客中提到了业界对红锁的质疑(Martin Kleppmann 等人的分析),认为它不能保证绝对安全(依赖时钟同步、网络超时等)。所以在很多国内大厂,如果 Redis 只作为缓存(允许少量不一致),更倾向于使用 Redis Cluster 下的单节点锁 + 客户端的重试逻辑,配合业务幂等去最终兜底;或者直接用 Zookeeper / etcd 来做强一致性的锁。
个人学习认为:
-
对一致性要求极高的场景(如金额操作),考虑 ZooKeeper。
-
对于性能要求高、可容忍极小概率错误的场景(如防重复提交、活动库存扣减),Redisson 单节点锁看门狗方案就足够,简单可靠。
五、实战:模拟秒杀扣库存(JMeter 压测)
我用 Spring Boot + Redisson 搭了个简单 demo,核心代码如下,启动 100 线程并发扣库存,最终库存为 0,没有超卖。
@RestController
public class SeckillController {
@Autowired
private RedissonClient redissonClient;
// 模拟商品库存
private static int totalStock = 500;
@GetMapping("/seckill")
public String seckill() {
String lockKey = "seckill_lock";
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试快速加锁,不等待,失败直接返回
// leaseTime=3:锁最多持有 3 秒,过期自动释放,无看门狗
// 秒杀、快速校验等对耗时敏感的操作,不希望业务被阻塞,也不希望锁长时间占用。同时,由于业务本身执行很快(比如只做库存判断+扣减),3 秒足够,即使业务偶尔超时释放,也可以通过幂等机制兜底,所以不需要看门狗的续期。
if (lock.tryLock(0, 3, TimeUnit.SECONDS)) {
if (totalStock > 0) {
totalStock--;
System.out.println(Thread.currentThread().getName() + " 抢购成功,剩余:" + totalStock);
return "success, remain " + totalStock;
} else {
return "卖完了";
}
} else {
return "系统繁忙,请重试";
}
} catch (Exception e) {
return "error";
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
JMeter 设置 100 线程循环 5 次,最终库存正好减到 0,没出现负数。过程中观察 Redis 里的 key,发现每次锁的 TTL 在 10~30 秒之间被看门狗刷新,很稳定。
六、关键注意点总结
-
锁粒度尽量细:
lockKey要带业务标识如商品 ID,不要用全局大锁,否则性能极差。 -
加锁、解锁必须成对,而且加锁的线程和解锁的线程要一致,用
isHeldByCurrentThread判断。 -
避免长事务,在锁内只做必要的共享资源操作,比如查库和写库,不要调用外部 RPC 导致超时。
-
可重入的陷阱:如果你在锁内部又调用了同样加锁的方法,没问题,Redisson 支持。但别在递归里无限加锁。
-
集群环境下时间同步要求:如果你用红锁,务必保证节点之间 NTP 时钟偏差不大,否则可能出现锁提前释放(接口幂等后续保证)。
七、Redis 与 ZooKeeper 分布式锁的区别和选型比较
这里我结合自己的理解和实际项目中的选择,系统整理一下两者的核心差异。
1. 实现原理对比
Redis 锁(以 Redisson 为例)
-
本质上是「主动轮询 + 自动续期」:客户端通过 SET NX EX 争抢一个 key,谁抢到谁就持有锁。为了防止业务超时锁被清掉,引入了看门狗定时续期。
-
加锁是去 Redis 节点上竞争一个字符串 key,解锁是通过 Lua 脚本判断身份后删除 key。
-
锁信息存在单个 Redis 节点(或通过红锁分布在多个节点),节点只负责存储和简单的过期删除,不维护客户端的连接状态。
ZooKeeper 锁
-
基于 临时顺序节点 + Watch 机制 实现。客户端在指定锁路径(如
/lock/resource)下创建一个临时顺序节点(如lock-0000000001),然后获取路径下所有子节点,判断自己创建的节点是否是序号最小的那个。-
如果是最小,获取锁成功。
-
如果不是,则对前一个节点注册 Watcher 监听,当前一个节点删除时,自己再重新判断是否成为最小节点,以此类推。
-
-
锁的节点是「临时」的,依靠客户端与 ZK 的 Session 心跳维持。一旦客户端宕机或网络中断,Session 超时后临时节点自动删除,锁随之释放。
-
ZK 的写操作必须在集群中过半节点(Leader 和多数 Follower)完成才返回,因此锁的获取和释放具备 强一致性,不会出现主从切换锁丢失的问题。
2. 核心特性对比
| 特性 | Redis(Redisson) | ZooKeeper(Curator) |
|---|---|---|
| 一致性模型 | 最终一致性 / AP 模型(主从异步复制可能丢锁;红锁试图解决但仍有争议) | CP 模型(基于 ZAB 协议,写需过半确认,不会丢锁) |
| 性能(吞吐量) | 极高,纯内存操作,单节点可支撑 10w+ QPS | 相对较低,写操作需集群同步,受限于磁盘和网络,适合读多写少的协调场景 |
| 锁自动释放 | 依赖 key 的 TTL 过期 + 看门狗续期;客户端宕机后最多 30 秒释放 | 依赖临时节点 + Session 心跳;客户端宕机后 Session 超时(一般几秒到几十秒)立即释放 |
| 可重入支持 | Redisson 通过 Hash 计数器实现 | Curator 的 InterProcessMutex 内部用本地计数器 + 同一线程重入判断实现 |
| 阻塞等待 | 通过 Redis 的 Pub/Sub 通知 + 自旋重试,非公平锁 | 利用 Watcher 监听前一个节点,公平锁(按创建顺序排队) |
| 客户端复杂度 | 简单,依赖 Redisson 封装即可 | 稍复杂,需引入 Curator 并理解 Session 和 Watcher 机制,但 Curator 也已封装得很好 |
| 运维依赖 | 已有 Redis 集群即可复用,无需额外组件 | 需要单独部署和维护 ZooKeeper 集群(3~5 台),增加运维成本 |
3. 优劣势分析
Redis 分布式锁的优势
-
性能高,并发大的场景下吞吐量远超 ZK。
-
大部分公司 Redis 已经是基础设施,直接复用,零额外成本。
-
Redisson 提供的看门狗和自动续期,使得开发人员不需要操心底层细节,上手快。
-
对网络闪断有一定容忍度:短暂断连后如果重连成功,看门狗会继续工作;ZK 则可能直接 Session 超时释放锁。
Redis 分布式锁的劣势
-
主从架构下存在锁丢失的可能(异步复制导致),不适合绝对严格的互斥场景。
-
即使使用红锁,也依赖客户端时钟同步,且有理论上的安全边界争议。
-
续期机制要求客户端进程必须健康,否则 30 秒后锁自动释放,这期间可能被其他客户端拿到锁。
ZooKeeper 分布式锁的优势
-
绝对的强一致性保证,不会因为主从切换丢失锁,适合金融、交易等对一致性要求极高的场景。
-
公平锁实现天然优雅,按照申请顺序排队,避免“惊群效应”(Watcher 只监听前一个节点,不会所有等待客户端同时唤醒)。
-
临时节点与 Session 绑定,客户端宕机锁立即释放,时间可控(通过 session timeout 设置),通常比 Redis 30 秒更短。
ZooKeeper 分布式锁的劣势
-
性能较低,ZAB 协议写操作需要落盘和广播,吞吐量远不如 Redis。
-
需要单独搭建和维护 ZK 集群,增加运维复杂度和硬件成本(虽然也可以用现有的 ZK 注册中心复用,但耦合度提高)。
-
客户端 Session 超时时间需要权衡:设得太短可能因为 GC 或网络短暂抖动导致锁误释放;设得太长则故障释放变慢。
-
在高并发激烈竞争下,频繁创建/删除节点和 Watcher 触发会给 ZK 带来较大压力,稳定性不如 Redis。
4. 实际选型(个人想法)
-
秒杀、活动库存扣减、防重复提交这类场景:数据量极大、一致性容忍微小误差(比如实际库存-1偶尔多扣了一次但能被后续业务拦截),直接用 Redisson + Redis 单节点/Cluster 是最佳实践,性能扛得住,开发简单。
-
金融转账、账户余额操作、分布式定时任务严格单次执行:强一致性是刚需,哪怕性能下降也必须保证不出错,建议用 Curator + ZooKeeper。
-
如果公司已有 ZK 集群,并且对性能要求不极端,可以考虑直接用 ZK 锁统一方案,减少技术栈。
-
混合使用也常见:核心资产用 ZK 锁,读多写少的配置变更用 ZK;海量 C 端业务用 Redis 锁,两者共存毫无问题。
总之,没有银弹,选型看业务对一致性和性能的权衡。理解它们的原理,就能在设计时做出最合适的取舍。
以上笔记源自视频学习与个人理解,部分原理和源码结合了 Redisson 官方源码分析,若有疏漏欢迎指正。