☰
Redis分布式锁从手写到Redisson:核心原理与生产实践
2026/10/3 9:08:57 网站建设 项目流程

1. 先搞清楚:分布式锁到底解决什么问题

分布式锁这个东西,只要你的系统一拆成多实例,几乎都会撞上它。最典型的例子就是秒杀场景里的库存扣减:三个应用实例同时收到下单请求,如果每个实例都在本地用一个 synchronized 或者 Lock 做并发控制,那只是锁住了当前进程里的线程,另外两台机器照常往里冲,库存最后必然超卖。这时候就需要一把所有实例都能看见、都能争取的锁,也就是分布式锁。

很多人会问,能不能用数据库的唯一索引或者悲观锁来实现?可以,但问题也很明显:数据库自身容易成为瓶颈,行锁竞争激烈时性能下滑很快,而且事务长时间未提交还会拖垮连接池。至于 ZooKeeper 或 etcd 这类协调服务,它们的强一致模型很诱人,但引入额外组件、运维成本和部署复杂度都要考虑。而 Redis 因为高性能、数据结构简单、落地成本低,成了目前最主流的分布式锁承载者。基本上你出去面试,只要是聊到缓存治理、并发控制、中间件落地,分布式锁都是绕不开的一环。

这篇内容适合两类读者:一是已经在项目里用过 Redis,但只是简单 get/set,想了解分布式锁原理和正确姿势的人;二是准备面试,想要一套能说清楚“为什么手写锁容易踩坑、Redisson 为什么更好”的逻辑链的开发者。我会从手写一把锁开始,逐步暴露问题再解决问题,最后落到 Redisson 的最佳实践,全程用 Java 代码示例,思路同样适用于其他语言。

2. 手写一把可用的 Redis 分布式锁:从踩坑到完善

手写分布式锁的过程,本质上就是不断发现分布式系统里那些“看似简单、实则坑深”的问题。我会带着你从最幼稚的版本开始,一步步演进到接近生产可用的形态。

2.1 第一版:SETNX 加过期时间,雏形有了但会死锁

最直觉的思路:用一个 Redis Key 表示锁,谁成功写入谁就拿到了锁。Redis 里恰好有SETNX命令,意思是“只有 Key 不存在时才设置成功”。于是很多人第一版锁长这样:

// 获取锁 Boolean success = redisTemplate.opsForValue().setIfAbsent("lock:order:123", "locked"); if (Boolean.TRUE.equals(success)) { try { // 执行业务逻辑 doSomething(); } finally { // 释放锁 redisTemplate.delete("lock:order:123"); } }

这个版本暴露出的第一个问题:如果业务逻辑抛异常,或者服务进程在释放锁之前突然宕机,Key 永远不会被删除,锁变成了死锁。之后所有请求都会卡在获取锁这一步,事故就这么发生了。

于是大家会条件反射地加一个过期时间:

redisTemplate.opsForValue().setIfAbsent("lock:order:123", "locked", 30, TimeUnit.SECONDS);

过期时间解决了死锁,但又引出两个新问题:释放锁时可能把别人刚拿到的锁删掉;加锁和设置过期时间如果分两步执行,中间进程宕机同样会导致死锁。第二个问题在早期 Redis 版本中确实存在,因为当时 SETNX 和 EXPIRE 是两条独立命令,后来 Redis 2.6.12 提供了SET key value NX EX seconds的原子写法,这个问题才算在命令层面被解决。但“误删别人的锁”这个问题,光靠原子加锁还不够。

2.2 第二版:用唯一标识防止误删锁

设想这样一个时间线:线程 A 拿到锁,设置了 30 秒过期;但 A 的业务执行了 35 秒,锁已经过期自动释放;线程 B 拿到锁开始执行;此时 A 终于执行完毕,在 finally 里执行 delete,删除的其实是 B 的锁。B 后面的并发请求又可能拿到锁,整个互斥就失效了。

正确的做法是:加锁时写入一个当前线程或请求的唯一标识,释放前先判断这个标识是否还属于自己,只有是自己的才删。这个过程用两条命令也能做,但判断和删除之间仍然有时间窗口,所以必须用 Lua 脚本保证原子性:

-- 加锁 if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 else return 0 end
-- 释放锁 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

Java 端用唯一标识可以取 UUID 拼接线程信息,或者用 Thread ID 加请求序号,只要保证在锁的维度上全局唯一即可。很多网上教程到这一步就结束了,说“这样就完美了”。但实际上如果业务执行时间超过锁过期时间,锁已经释放,后续线程照样可以进入临界区,所以还需要看门狗机制或者手动续期。手写这套逻辑很繁琐,这也正是我们后来转向 Redisson 的原因之一。

2.3 第三版:可重入与自旋等待

除了误删,还有两个高频需求:可重入和阻塞等待。

可重入是指同一个线程在持有锁的情况下再次获取同一把锁,应该能直接成功。比如一个方法加锁后内部调用了另一个同样加锁的方法。基于 Redis 的实现里,可重入最常见的做法是用 Hash 数据结构,把锁的信息存成field:value形式,field 存放线程标识,value 存放重入次数:

-- 加锁(可重入) if redis.call('exists', KEYS[1]) == 0 then redis.call('hset', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 end if 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 end return 0
-- 释放锁(每释放一次重入次数减一,减到 0 才删除) if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) if counter > 0 then return 1 else redis.call('del', KEYS[1]) return 1 end

阻塞等待则更像 JUC 里的 Lock 语义:拿不到锁的线程不会立刻返回失败,而是自旋重试。自旋需要控制频率,不能死循环空转打爆 Redis,我见过一种做法是让线程 sleep 50 到 200 毫秒再重试,同时设置最大等待时间。但这样既有延迟又浪费 CPU,Redisson 给出的答案是信号量机制加发布订阅,等锁被释放时通知等待线程,而不是无脑重试。

2.4 从手写走向 Redisson,不只是因为懒

写到这里你应该能感受到,一把生产可用的分布式锁,远不是一条 SETNX 那么简单。你需要处理原子性、唯一标识、可重入、续期、阻塞唤醒和异常兜底。如果这些你自己实现,意味着每行代码都要经过充分压测和 Review,还要考虑 Redis 连接池、序列化、超时配置等各种边界条件。与其重复造轮子,不如选择一个被广泛验证的成熟方案。

Redisson 是一个基于 Redis 的 Java 客户端,官方定位是分布式协调工具包,它的 RLock 就是对分布式锁的完整封装。而且 Redisson 内部用的是 Lua 脚本,把加锁、重入、续期、释放逻辑全部原子执行。接下来我们就看看它在项目里怎么落地。

3. Redisson 接入与生产实践

Redisson 不像 Jedis 和 Lettuce 那样把自己定位成纯连接客户端,它提供了一整套分布式数据结构:分布式锁、分布式集合、分布式队列、原子计数器等。它也支持 Spring Boot 自动装配,代码侵入性很小。

3.1 Redisson 快速接入与配置

Maven 工程里加依赖:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency>

在application.yml里写基础配置:

spring: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 password: null database: 0

如果你用的是 Spring Boot 2.x,也可以通过RedissonClient手动构造:

Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16); RedissonClient redisson = Redisson.create(config);

有个细节容易被忽略:Redisson 默认的序列化器是 Kryo 或 FST,而 Spring 容器里常配置的是 Jackson 序列化器。如果同一个 Redis 实例既给 Spring RedisTemplate 用,又给 Redisson 用,建议在 Config 里显式指定与业务一致的序列化方式,避免两种客户端读写同一个 Key 时发生反序列化异常。调试的时候我用 Another Redis Desktop Manager 或 RedisInsight 查看锁 Key 的结构,RLock默认会使用Hash结构存储线程信息和重入计数,一眼就能看出来是谁在持有锁。

3.2 一次最正规的加锁释放流程

我建议团队里所有加锁操作都走同一个封装好的工具类,而不是直接把 RLock 散落在业务代码里。规范流程长这样:

@Autowired private RedissonClient redissonClient; private static final String LOCK_PREFIX = "biz:lock:"; public void withLock(String lockName, long waitTime, long leaseTime, TimeUnit unit, Runnable action) { RLock lock = redissonClient.getLock(LOCK_PREFIX + lockName); boolean locked = false; try { locked = lock.tryLock(waitTime, leaseTime, unit); if (!locked) { throw new RuntimeException("获取分布式锁超时, lockKey=" + lockName); } action.run(); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

这里有两个关键点。

第一,tryLock(waitTime, leaseTime, unit)的语义是:最多等waitTime毫秒,如果拿到锁,锁自动在leaseTime后过期。如果leaseTime不传,Redisson 会启用看门狗自动续期。这引出了常见的面试题“Redisson 的锁会不会因为业务没执行完而提前释放”,标准答案是:如果你没指定 leaseTime,看门狗会默认每隔 10 秒给锁续期到 30 秒,直到线程主动释放;如果你指定了 leaseTime,看门狗就不会启动,锁在指定时间后必然释放。

第二,释放锁前用isHeldByCurrentThread()判断是有意义的,虽然 Redisson 内部通过线程标识保证了不会误删其他线程的锁,但显式判断可以让日志更清晰。我在项目里遇到过一种情况:业务方法在持有锁期间主动删除了当前锁 Key,然后 finally 里又调用 unlock,这时候 Redisson 会抛出IllegalMonitorStateException,控制不了锁的持有者。排查这种问题最快的办法就是去看锁 Key 的 type 和 ttl,确认是不是被外部命令误删了。

3.3 看门狗机制:锁过期了还在执行业务怎么办

看门狗是 Redisson 最值得拿出来讲的设计。它的原理说起来不复杂:加锁成功后,如果锁没有设置过期时间(即 leaseTime 为 -1),Redisson 会在客户端启动一个定时任务,默认每 10 秒执行一次,检查锁是否还存在,如果存在就把锁的过期时间重置为 30 秒。这相当于一个后台线程持续为当前线程的锁续命,直到线程释放锁或进程崩溃。

默认参数是通过lockWatchdogTimeout配置的,默认值 30000 毫秒,续期周期是它的三分之一。如果你的业务平均执行时间很长,想调大这个值,我建议基于实际压测结果设置,而不是随意放大。因为看门狗只能续期持有者自己的锁,如果锁因为 Redis 主从切换、网络分区等原因丢失,看门狗也无法挽回。

另一个实际经验:不要在持有锁期间做太重的 IO 操作,比如远程调用第三方接口、批量写数据库,因为这些操作的耗时不可控,一旦超过看门狗能覆盖的范围,锁被自动释放后其他线程进入临界区,逻辑就会出问题。我见过一个案例,业务里嵌了一个外部短信接口,某段时间对方响应变慢,单次调用耗时四五秒,一次完整业务超过 30 秒,结果锁提前失效,多个线程并发扣减,最终账实不符。后来我们把所有远程调用移到加锁前,或者给远程调用单独设超时时间,才把问题压下去。

3.4 主从切换与 Redlock 的争议

面试官非常喜欢问一个问题:Redis 主从架构里,客户端在 Master 上加了锁,但数据还没同步到 Slave,Master 宕机后 Slave 被提升为新的 Master,锁丢了怎么办?

Redisson 的默认单机模式确实无法完全规避这个问题,因为它只和单个 Redis 节点交互。Redis 的作者 Antirez 提出了 Redlock 算法:在多个独立部署的 Redis 节点上依次加锁,只有当超过半数节点加锁成功且总耗时小于锁有效时间,才认为锁真正获取成功。Redisson 提供了RedissonMultiLock来支持这种多节点模式。

但这里必须说清楚,Redlock 在业界一直有争议,包括分布式系统专家 Martin Kleppmann 和 Antirez 之间的经典论战。核心矛盾在于:即使加了 Redlock,客户端 GC 停顿仍然可能导致进程暂停期间锁过期而其他客户端拿到锁。Redlock 只能降低概率,做不到绝对互斥。所以我的建议是:先从业务层面容忍极低概率的重复执行(比如扣减前查一次库存、生成幂等号),再决定是否值得引入 Redlock。大多数业务系统的 Redis 主从切换频率很低,加上锁超时时间设置合理,单机 Redisson 锁已经够用;只有到了资金类、强一致类业务,才需要更复杂的设计。

4. 实战中的常见问题与面试高频考点

这一部分我用速查表的方式整理我在排查分布式锁问题时最常遇到的情况,以及对应的解决方案。

4.1 常见故障排查速查表

现象可能原因排查方法解决建议
获取锁一直失败,日志报超时锁未释放,看门狗失效Redis 里TTL lockKey查看剩余时间,HGETALL lockKey查看持有线程确认业务是否长时间阻塞;排查是否手动删除过锁
锁提前释放,并发进入临界区业务耗时超过 leaseTime;主从切换锁丢失加日志记录锁获取与释放耗时;监控 Redis 主从切换事件调大 leaseTime 或依赖看门狗;远程调用移出临界区
释放锁时报 IllegalMonitorStateException当前线程不是锁持有者打印调用链;确认是否有异步线程或代理类导致的线程标识变化用isHeldByCurrentThread()判断后再 unlock
锁 Key 存在但业务没在执行进程崩溃前持有锁,锁未过期看锁的 TTL 和创建时间锁过期时间不能太长,建议 30 秒内;配合看门狗
压测时 TPS 突然下降锁竞争激烈,大量线程自旋等待Redis 的INFO commandstats观察 Lua 调用次数;应用侧打印等待时间缩小锁粒度;用 tryLock 设置合理等待时间

排查分布式锁问题时,我通常会先开一个 Redis 客户端工具盯住锁的 Key。这里顺带提一句,很多人纠结 Redis 可视化工具该选哪个,我现在常用 RedisInsight 和 Another Redis Desktop Manager,前者官方维护、功能全,后者轻量、适合快速看 Key。排查锁问题看 Key 的type、ttl、hgetall三项就够了,不需要很重的监控面板。

4.2 分布式锁面试题答题模板

面试题问来问去其实就那么几类,我整理了一个答题思路,比死记硬套强。

问“分布式锁有哪些实现方案”,先分层:数据库锁、Redis 锁、ZooKeeper 锁、etcd 锁。然后说各自适用场景:数据库锁简单但性能差;Redis 性能好但存在主从切换的极端情况;ZK 和 etcd 强一致但引入额外组件。千万别一上来就只说 Redis,要让面试官觉得你有全局视野。

问“Redis 分布式锁怎么实现”,从 SETNX 讲起,主动说出三个坑:死锁、误删、原子性。然后讲解决方案:过期时间、唯一标识、Lua 脚本。再讲 Redisson 的可重入 Hash 结构和看门狗机制。

问“Redisson 锁的原理”,要能说出几个关键词:Lua 脚本、Hash 数据结构、看门狗、发布订阅。如果面试官追问,可以画一条时间线:线程 A 加锁成功,线程 B 通过 Redis 的 Channel 订阅锁释放消息,A 释放后 B 收到通知再去竞争锁。Redisson 底层用了 Netty 的发布订阅能力来实现阻塞等待,所以它的等待不是无脑轮询。

问“锁失效怎么办”,先分两种:业务未执行完锁过期,由看门狗续期解决;主从切换导致锁丢失,可以聊 Redlock 的取舍。这里我建议说实话,说明 Redlock 并非万能,最终还是要靠业务幂等来兜底,这种回答反而显得更有实战经验。

4.3 使用中的一些额外建议

锁粒度控制是最容易被忽略的。不要把整个用户 ID 加锁,尽量缩小到订单号、库存维度。比如处理用户下单时,用订单号做锁 Key 而不是用户 ID,因为同一个用户可能并行下不同订单,锁在用户维度会平白阻塞其他订单。

合理设计锁超时时间。业务预期最长执行时间是 2 秒,锁超时就设 5 秒,给余量但别给太多。如果业务里确实有耗时不可控的操作,优先优化业务,而不是无限调大过期时间。

规范统一加锁入口。团队里每个人都按自己理解写一遍加锁逻辑,后续排查成本会非常高。我现在的做法是提供一个DistributedLockUtil,所有业务只传锁名、等待时间、执行业务逻辑,锁内部统一做日志、监控上报和异常转换。这样出了问题只需要改一个类。

别忘了加监控。锁获取等待时间、锁持有时间、获取失败次数这三个指标非常关键。我们曾经上线过一个新功能,锁竞争突然加剧,就是因为监控里看到“锁获取等待时间 p99 从 5ms 涨到 200ms”,才第一时间发现了一个大 key 扫描导致的 Redis 变慢问题。

5. 后续还能怎么扩展

如果你用的是 Redisson,其实不止 RLock 一种选择。它提供的RSemaphore、RCountDownLatch、RReadWriteLock也都很有用,尤其是读多写少的场景,用RReadWriteLock可以允许并发读,只在写时互斥,吞吐量比普通锁高不少。这是我在实际项目里体验很深的点。

另外一个方向是把分布式锁和本地锁结合,形成双层锁:本地加锁挡住同 JVM 内的大部分线程,只有极少数的线程才需要争抢 Redis 锁。这种方式能显著降低 Redis 的压力,在很多高并发项目里都是一种经典优化手段。

最后再分享一个小技巧:在测试环境压测分布式锁时,不要只看功能正常,要故意制造锁过期、线程崩溃、Redis 主从切换这类故障,看看业务是否会产生重复数据。我在公司做过一次混沌演练,故意在持锁期间把线程 sleep 到超过锁超时时间,结果发现有一个报表任务被重复跑了三次,从此团队对锁超时的认识深刻了很多。这个教训比看十篇文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询