Redis分布式锁全解析:从原理到高并发实战避坑
2026/9/10 20:28:47 网站建设 项目流程

1. 为什么单机锁到了分布式环境就失灵了

先从一个最日常的场景说起。你在写一个订单服务,扣减库存用的是 synchronized 或者 ReentrantLock,在单机部署的时候一切正常,请求再多也不会超卖。后来系统要支撑更高的并发,你把服务横向扩容到了三台机器,Nginx 做负载均衡,结果线上开始出现库存扣成负数的情况。问题几乎可以确定出在锁上——三台机器各自持有一把 JVM 内部的锁,互相之间完全隔离,A 机器锁住的代码段,B 机器和 C 机器照样可以同时进去执行。

这就是分布式锁要解决的第一个本质问题:在多个进程、多台机器之间,实现临界区资源的互斥访问。单机锁靠的是 JVM 内存中的监视器(Monitor),分布式环境下根本没有共享内存,必须引入一个所有进程都能访问到的"第三方"来协调。这个第三方可以是 Redis、ZooKeeper、etcd、数据库,核心职责都一样——提供一个全局唯一的、所有节点都能达成一致的"令牌"。

我在实际项目里见到过不少类似的错误设计,最常见的是用数据库唯一索引当锁。比如建一张锁表,插入一条记录表示拿到锁,释放锁就删掉记录。这个方案在小规模场景下能用,但性能天花板很低,每次加锁解锁都是一次磁盘 IO,而且还要自己处理连接池、超时、宕机恢复等一系列问题。真正在生产环境里用得最多的还是 Redis,原因很直接:性能好、实现简单、生态成熟。后面的内容我会以 Redis 为主线展开,同时把 ZooKeeper 和 etcd 的对比放在后面讲,方便你在做技术选型的时候心里有数。

这篇文章不打算只讲理论。我会先带你从零实现一个可用的 Redis 分布式锁,再逐步加细节,处理各种边界情况,最后聊一聊 Redlock 的争议、主从切换带来的坑,以及面试官最爱问的那几个问题。不管是刚接触分布式锁的初学者,还是准备面试的候选人,这篇文章都能给你一套完整的、可以直接落地的知识框架。

2. Redis 分布式锁的核心实现

2.1 从 SETNX 到 SET NX EX:一条命令解决原子性问题

Redis 分布式锁最基础的版本,大家可能都听过 SETNX 命令。SETNX 是 "SET if Not eXists" 的缩写,只有当 key 不存在的时候才能设置成功,正好可以用来模拟"抢占"的行为。最早的实现方式是这样的伪代码:

// 加锁 Long result = jedis.setnx("lock:order:123", "1"); if (result == 1) { // 拿到锁,执行业务逻辑 } else { // 没拿到锁,重试或返回失败 } // 释放锁 jedis.del("lock:order:123");

这个版本有一个致命问题:如果拿到锁的线程在执行过程中宕机了,或者代码抛异常没有走到 del 那一行,这个 key 就永远留在 Redis 里了,其他线程再也拿不到锁。这叫做死锁,在实际生产环境里一旦发生就是事故。解决办法是给锁加过期时间,让 Redis 在 key 过期后自动删除。

于是代码变成了这样:

Long result = jedis.setnx("lock:order:123", "1"); if (result == 1) { jedis.expire("lock:order:123", 10); // 执行业务逻辑 }

但是这里又引入了一个新的原子性问题:setnx 和 expire 是两条独立的命令,如果 setnx 执行成功之后、expire 执行之前进程崩溃了,key 依然没有过期时间,死锁问题照样存在。这个问题的根源在于多步操作不具备原子性,在并发场景下任何两步之间都可能被意外打断。

正确的做法是使用 Redis 2.6.12 版本之后提供的 SET 命令扩展参数,把加锁和设置过期时间合成一条命令:

String result = jedis.set("lock:order:123", "1", "NX", "EX", 10); if ("OK".equals(result)) { // 拿到锁 }

SET key value NX EX seconds 的含义是:当 key 不存在时设置成功,同时设置过期时间为 seconds 秒。这两件事在 Redis 内部是原子完成的,从根本上杜绝了"设置锁成功但没设置过期时间"的窗口期。这是 Redis 分布式锁的第一个核心要点,也是面试中最高频的考点之一,必须把为什么要用一条命令的原因讲清楚。

2.2 释放锁为什么要用 Lua 脚本

锁加上了,过期时间也设置好了,但释放锁这一步同样有坑。先看下面这段代码:

// 业务逻辑执行完毕 jedis.del("lock:order:123");

问题在于:线程 A 拿到锁后执行了较长时间,锁已经因为超时被 Redis 自动释放了。这时候线程 B 拿到了锁,开始执行业务逻辑。突然线程 A 执行完毕,执行 del 命令把锁删掉了。线程 B 的锁被"误删",紧接着线程 C 也能拿到锁,临界区同时进入了两个线程,互斥彻底失效。

解决办法是释放锁时校验 value 是否为当前线程持有锁时写入的标识。这个标识可以用 UUID 生成,加锁时作为 value 写入,释放锁时先 GET 判断是不是自己的值,是才能删。于是释放锁的伪代码变成了:

String token = UUID.randomUUID().toString(); jedis.set("lock:order:123", token, "NX", "EX", 10); // 业务逻辑... // 释放锁 if (token.equals(jedis.get("lock:order:123"))) { jedis.del("lock:order:123"); }

如果学过并发编程,你会发现这里犯了和前面类似的错误——GET 和 DEL 是两步操作,中间存在时间窗。线程 A 判断 value 是自己的,还没执行 DEL,锁恰好过期了,线程 B 立刻加锁成功。此时线程 A 执行 DEL,还是会把 B 的锁删掉。所以判断和删除之间依然需要原子性。

Redis 官方给出的标准答案是使用 Lua 脚本,把"比较 value + 删除 key"放进一个脚本里,Redis 会保证脚本整体原子执行:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(script, Collections.singletonList("lock:order:123"), Collections.singletonList(token));

这段脚本的逻辑很简单:先获取锁对应的 value,如果等于当前线程的 token,就删除 key,返回 1;否则返回 0。因为 Redis 是单线程执行命令的,eval 执行期间不会有其他命令插入,所以这个比较和删除是不可分割的。

到这里,一个可用的 Redis 分布式锁已经成型:加锁用 SET NX EX 一条命令,释放锁用 Lua 脚本保证原子性,value 用 UUID 标识持有者身份防止误删。这是 Redis 分布式锁的基础版本,也是很多开源框架(比如 Redisson)的底层雏形。我建议你在写简历项目的时候,至少要把这个演进过程想明白,从 SETNX 到 SET NX EX、从直接 DEL 到 Lua 脚本,每一步解决什么问题都要能讲清楚。

2.3 一个可以抄作业的 Java 实现

光讲理论不够,我写一个完整的 Java 实现,基于 Jedis,可以直接拿来跑实验。生产环境建议用 Redisson 或者 Lettuce,但理解原理用 Jedis 最直观。

public class RedisDistributedLock { private static final String LOCK_SUCCESS = "OK"; private static final Long RELEASE_SUCCESS = 1L; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "EX"; private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; private Jedis jedis; private String lockKey; private String lockValue; private int expireTime; public RedisDistributedLock(Jedis jedis, String lockKey, int expireTime) { this.jedis = jedis; this.lockKey = lockKey; this.expireTime = expireTime; this.lockValue = UUID.randomUUID().toString(); } /** * 加锁,获取不到直接返回 false */ public boolean tryLock() { String result = jedis.set(lockKey, lockValue, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); return LOCK_SUCCESS.equals(result); } /** * 加锁,带重试机制,在指定时间内不断尝试 */ public boolean tryLock(long waitTime) throws InterruptedException { long endTime = System.currentTimeMillis() + waitTime; while (System.currentTimeMillis() < endTime) { if (tryLock()) { return true; } Thread.sleep(50); } return false; } /** * 释放锁,使用 Lua 脚本保证原子性 */ public boolean unlock() { Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); return RELEASE_SUCCESS.equals(result); } }

使用的时候要注意几点。第一,expireTime 的设置非常讲究,不能太短也不能太长。太短可能导致业务还没执行完锁就过期了,另一线程进来并发执行;太长的话如果持有锁的线程宕机了,其他线程要等很久才能拿到锁。一个相对通用的做法是根据业务的最大执行耗时来估算,再留 2 到 3 倍的余量。第二,tryLock 的重试间隔不要设置成固定值,可以用随机值避免多个线程同时重试造成羊群效应。第三,业务代码里要在 finally 块中释放锁,确保任何异常路径下锁都能被正确释放,否则异常线程会一直持有锁直到过期。

还需要强调一点:这个实现里的 expireTime 是"死值",锁不会自动续期。如果业务执行时间不可控,建议后续接入看门狗机制自动续期,这个在 Redisson 里已经实现了,后面会详细说。

3. 分布式锁的四个经典翻车现场

3.1 锁过期引发的羊群效应和并发穿透

锁过期是分布式锁最让人头疼的问题。线程 A 拿到锁,正常情况应该 200 毫秒执行完,但你设的过期时间是 1 秒。结果那一次业务特别慢,GC 停顿加上下游接口超时,整个花费了 1.2 秒。此时锁已经过期被 Redis 删除,线程 B 拿到锁也进来执行了,两个线程同时操作同一份资源,互斥被破坏。

这个问题的根因是:持有锁的时间超过了预设的过期时间,而我们的实现无法感知这一点。有的同学说"我把过期时间设大一点不就行了吗",设大只能降低概率,不能根除。你永远无法准确预测业务在最坏情况下的执行时长。

业界对这个问题的解法是"看门狗"机制,也就是自动续期。Redisson 的实现方式是:加锁成功后会启动一个后台定时任务,默认每 10 秒执行一次,如果锁还被当前线程持有,就把锁的过期时间重置为 30 秒。这样业务执行多久,锁就能续多久,只有等到业务主动释放或者线程宕机,锁才会真正失效。

如果你是自己实现,也可以做一个简化版的续期逻辑。加锁后启动一个定时任务,每隔 expireTime / 3 秒检查一次锁是否还是自己的,如果是就重新执行 expire 命令,业务结束后取消定时任务。这是一个加分项,面试官听到这里通常会眼前一亮。

3.2 主从切换导致锁丢失:为什么 RedLock 存在争议

Redis 的高可用架构通常是主从复制加哨兵。客户端向主节点写入锁,主节点异步复制到从节点。问题就在于"异步"两个字:如果客户端刚在主节点写入锁,主节点还没把数据复制给从节点就宕机了,哨兵会选择一个从节点提升为新的主节点。新的主节点上没有这条锁记录,其他客户端就可以成功加锁,原来那个"持有锁"的客户端在逻辑上就失效了。

这是一个非常经典的分布式系统问题:Redis 的主从复制是异步的,所以无法保证锁的持久性。对于大多数业务场景,这个风险可以接受,因为锁丢失的前提是主节点恰好在这个极短的窗口内宕机。但在金融支付等对一致性要求极高的场景,这就是不可接受的。

针对这个问题,Redis 的作者 Antirez 提出了 RedLock 算法。核心思想是:部署 N 个互相独立的 Redis 节点(通常 N=5),客户端需要向所有节点发起加锁请求,只有成功锁住超过半数的节点(N/2+1),才算真正拿到锁。释放锁时向所有节点发送释放请求。这样即使少数节点宕机,只要大多数节点还保留着锁记录,整个锁依然有效。

RedLock 在理论界引发了大量讨论,尤其是分布式系统领域的权威专家 Martin Kleppmann(《Designing Data-Intensive Applications》的作者)专门写过一篇长文批评 RedLock,认为它存在不少逻辑漏洞。主要争议点包括:客户端加锁耗时过长导致锁在不同节点上的有效时间不一致、GC 停顿会让锁在客户端"不知情"的情况下过期、时钟跳跃会导致过期时间计算失准等。这些问题的本质是:RedLock 仍然依赖时间和时钟,而分布式系统里"时间"本身就是一个不可靠的变量。

我和大多数同行的态度是:RedLock 有理论瑕疵,但不是不能用的方案。如果你的系统既需要 Redis 的性能又对锁的可靠性有较高要求,可以用 RedLock,但心里要清楚它并不能做到 100% 安全。如果需求是绝对可靠,更好的选择是 ZooKeeper 或者 etcd,它们用"临时顺序节点 + 会话"或者"租约 + 版本号"的机制,从底层设计上规避了时间依赖问题。这个对比我在后面第 5 节详细展开。

3.3 可重入性:同一个线程重复加锁怎么办

先想一个问题:如果你的业务代码里,方法 A 加锁后调用了方法 B,方法 B 内部也要加同一把锁,你的线程会被自己挡住吗?

在单机 ReentrantLock 里当然不会,因为 ReentrantLock 支持可重入,内部用 holdCount 记录当前线程持有锁的次数。但上面我们实现的 Redis 分布式锁压根不认识"线程",同一个线程第二次执行 SET NX EX 时,发现 key 已经存在,就会返回失败。如果策略是失败就重试,那就死锁了——线程自己等自己释放锁。

解决思路是在锁的 value 里记录持有者的标识和重入次数。Redis 官方推荐的方式是把 value 设置为一个 Hash,field 存持有者标识,field 对应的值存重入计数。Redisson 就是这么做的,它内部用一个 Hash 结构存储锁信息,加锁时记录线程 ID 和计数,解锁时计数减一,减到零才真正删除 key。

虽然 Redisson 的底层实现比较复杂,但使用它的 API 时你感知不到这层复杂性,它就是天然支持可重入的。如果你是自己写底层实现,可重入逻辑可以做得简单一点:加锁时如果发现 key 已存在,就 GET 一下 value,如果是当前线程的标识,就允许进入,并把这个线程的重入次数加一。释放锁时重入次数减一,减到零才执行 DEL。

这里有一个工程上的小陷阱:可重入计数存在 Redis 里,如果锁过期了,计数也就没了。这种情况下即使线程还在执行,它后续的重入加锁也会失败。所以做可重入锁时一定要配合看门狗续期,让锁的生命周期覆盖整个执行过程。

3.4 锁粒度与热点 key:别把整条街的锁挂在同一根柱子上

还有一个容易被忽略但非常重要的问题——锁的粒度。很多初学者喜欢用一个全局锁 key,比如 lock:inventory,所有库存操作都靠这一把锁串行执行。这样确实简单,但并发能力被压到极低,所有请求都排队等着同一把锁。

正确做法是把锁的粒度细化为业务维度,比如 lock:inventory:sku_123456,每个商品一个锁,不同商品的锁互不干扰。再进一步,如果某个商品被秒杀,单把锁依然是热点,这时候就需要用分段锁的思路。把库存拆成多个段,比如 100 个库存拆成 10 段,每段 10 个库存,每段一把锁,用户请求到达时先对用户 ID 做哈希取模,路由到固定的段,只在段内竞争锁。这是典型的"分而治之"思想,在秒杀系统里很常见。

我在一个真实项目里见过一个极端的例子:所有订单操作都加同一把锁,压测时 QPS 一到 200 就开始大量超时,后来把锁 key 从 lock:order 改成 lock:order:{orderId},QPS 直接到 2000 以上都没压力。锁的粒度对性能的影响就是这么显著。

不过要提醒一句:锁粒度越细,实现复杂度越高。分段锁要考虑数据一致性,不同段之间的数据如果有关联操作,需要仔细设计路由规则,不能让一个事务跨多个段。高并发场景下,简单的全局锁往往是最不容易出错的,性能瓶颈可以通过其他方式优化,比如异步化、削峰填谷。

4. 生产级方案:Redisson 与看门狗机制

4.1 为什么建议直接用 Redisson

看到这里,你应该已经意识到,从零写一个生产可用的分布式锁,需要考虑的问题非常多:原子性、过期时间、可重入、自动续期、主从切换、重试策略……每一个都暗藏深坑。除非你有特殊需求,否则我的建议是直接用 Redisson,它是目前 Java 生态里最成熟的 Redis 分布式锁实现。

Redisson 是 Redis 官方推荐的 Java 客户端框架,它对分布式锁的支持非常完整。你只需要引入依赖,写几行代码就能用上功能完善的分布式锁:

Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("lock:order:123"); try { // 尝试加锁,最多等待 10 秒,加锁成功后 30 秒自动过期 if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

这段代码背后,Redisson 替我们做了哪些事情?我列一下关键点,方便你在面试的时候展开讲。

第一,可重入。Redisson 的 RLock 支持可重入,底层是用 Redis Hash 结构实现计数,同一个线程可以多次加锁,多次解锁后才真正释放锁。

第二,自动续期。默认情况下,加锁成功后锁的有效期是 30 秒。如果业务逻辑在锁的有效期内还没执行完,Redisson 的后台线程会每 10 秒检查一次锁的状态,只要锁还在且由当前线程持有,就自动把过期时间重置为 30 秒。这就是所谓的"看门狗"机制,解决了锁过期但业务未完成的问题。

第三,等待锁的机制。tryLock 支持传入 waitTime,获取不到锁会阻塞等待,而不是立即返回失败。超时后会返回 false,你需要根据返回值决定是否走降级逻辑。

第四,公平锁和多锁支持。Redisson 还提供了 FairLock(公平锁)和 MultiLock(多节点锁),可以应对一些特殊场景。公平锁内部基于 Redis 的 List 和 Hash 结构实现了一个等待队列,保证先到的线程先获取锁,适合对公平性有要求的场景。

4.2 看门狗的参数真相:30 秒和 10 秒是怎么来的

Redisson 的看门狗机制网上讲得很多,但不少人知其然不知其所以然。默认配置的 lockWatchdogTimeout 是 30000 毫秒,也就是 30 秒。后台续期的定时任务执行时间是 lockWatchdogTimeout / 3,也就是 10 秒。

为什么是 30 秒和 10 秒?这个比例不是凭空设计的,它的逻辑是这样的:续期任务必须在锁真正过期前执行,否则锁过期了再去续期没有意义。如果每隔 10 秒执行一次续期,而锁的有效期是 30 秒,那么即使某一次续期因为网络抖动失败了,还有机会在下一次续期时补救,因为整个过期周期内最多可能丢失两次续期机会。网络抖动在分布式环境下是常态,这个设计相当于给锁的有效期做了一个"兜底",让它能容忍短时间的网络瞬断。

你可能会问,为什么不定时 1 秒续一次?那样更安全。这是性能与安全性的权衡。续期操作本身就是一次 Redis 请求,每秒执行一次会极大地增加 Redis 的压力。每 10 秒一次,在保证安全性的前提下把 Redis 请求量降到了相对合理的水平。

还有一个常见的坑:如果你手动调用 lock.tryLock(waitTime, leaseTime, unit),传入了 leaseTime(锁的持有时间),Redisson 就不会启动看门狗。因为你自己已经明确了锁的有效期,框架会认为你不需要自动续期。如果你想用看门狗,就不要传 leaseTime,或者传 -1。这个细节在官方文档里有说明,但很容易被忽略。我见过有同事在调用 tryLock 时传了 leaseTime,业务执行超过该时间后锁被强制释放,出现并发问题,排查了半天才发现是这里的问题。

4.3 集群模式下 Redisson 的锁有什么不同

Redisson 支持多种 Redis 部署模式,包括单节点、主从、哨兵和集群。需要注意的是,在不同模式下锁的表现不太一样。

单节点模式下,锁的可靠性完全依赖于这一个节点,节点宕机锁就丢了,应用层可能完全感知不到。主从模式下,锁写入主节点后异步复制,如果主节点故障且数据未同步,锁会丢失,这是我们前面提到的经典问题。哨兵模式只是增加了故障自动切换能力,不能解决复制延迟带来的锁丢失问题。

Redisson 针对集群模式提供了一种"红锁"的实现,也就是 MultiLock。你可以在多个独立的 Redis 节点上加同一把锁,只有当大多数节点都成功加锁时才返回成功。但说实话,在真正的 Redis Cluster 模式(数据分片模式)下,Redisson 加锁时锁 key 会被哈希到固定的槽位,锁本身只存在于某一个分片节点上。Cluster 的分片机制并不为锁提供跨节点容错。

所以如果你真的需要高可靠的分布式锁,不要指望 Redis Cluster 特性本身能帮你。要么接受单点风险,要么手动在多个独立 Redis 实例上部署 Redisson MultiLock,要么换 ZooKeeper/etcd。我在企业里看到的普遍做法是:业务不涉及资金,用 Redis 单主从加哨兵,接受极低概率的锁丢失;涉及资金对账等敏感操作,直接用 ZooKeeper 或者数据库事务保证强一致。

5. 进阶对比:ZooKeeper 与 etcd 的分布式锁

5.1 ZooKeeper 临时顺序节点是怎么实现锁的

Redis 分布式锁的性能和简单性是优势,但它基于过期时间做兜底,本质上是一个"尽力而为"的方案。如果你的业务对强一致有硬性要求,目前业界最可靠的方案是 ZooKeeper 或 etcd。

ZooKeeper 实现分布式锁的原理,核心是两类节点:临时节点和顺序节点。临时节点在客户端会话结束时自动删除,即使客户端宕机也不会出现死锁。顺序节点则自动获得一个全局递增的序号,客户端可以通过序号判断自己在等待队列中的位置。

加锁的流程是这样的:客户端在指定锁路径下创建一个临时顺序节点,比如 /locks/lock_0000000001。然后获取该路径下的所有子节点,对节点序号排序。如果自己创建的节点序号最小,说明自己获得了锁;如果不是最小的,就监听序号比自己小的前一个节点,等待它被删除。

释放锁的流程更简单:客户端主动删除自己创建的节点,或者会话超时由 ZooKeeper 自动删除。一旦锁节点被删除,监听它的下一个客户端就会被唤醒,重新检查自己是否是最小序号节点。

这个方案有两个明显优点。第一,不需要设置过期时间,极端情况下也不会出现锁长期占用的问题,因为会话超时兜底。第二,加锁和解锁是严格按照顺序进行的,先来后到,不存在 Redis 那种"锁过期了另一线程突然插队"的情况。缺点是性能不如 Redis,ZooKeeper 的写操作要走 ZAB 协议,延迟和吞吐量都不在一个量级。另外 ZooKeeper 的会话超时机制会导致一种特殊问题:客户端没有宕机,但因为 GC 停顿或网络分区导致心跳丢失,ZooKeeper 认为会话失效删除临时节点,其他客户端就能拿到锁,而原客户端还在继续执行。

5.2 etcd 的租约和版本号机制

etcd 是 Kubernetes 生态中的核心组件,实现分布式锁用到了两个关键特性:租约(Lease)和版本号(Revision)。比 ZooKeeper 更现代的一点是,etcd 提供了 TTL 机制,客户端可以创建租约,并持续续约,这比 ZooKeeper 的心跳机制更可控。

etcd 加锁的原理和 ZooKeeper 类似,也是在同一个前缀下创建 key,每个 key 会有一个全局递增的 Revision。客户端创建自己的 key 后,查询前缀下的所有 key,看看自己的 Revision 是否最小。如果最小就获得锁,否则监听比自己小一个 Revision 的 key 的删除事件。

etcd 的租约机制解决了两个问题。第一,客户端崩溃后锁能自动释放,因为租约到期后关联的 key 会自动删除。第二,客户端可以主动续约,只要业务没有结束,锁就不会被释放,避免了 Redis 那种"锁过期但业务还在执行"的问题。

相比 ZooKeeper,etcd 在运维便捷性和社区活跃度上有优势,而且和云原生生态的结合更紧密。如果你的系统已经在 Kubernetes 上运行,直接使用 etcd 做分布式锁几乎是零额外成本的。但如果你只是在一个传统的 Java 服务里需要一个分布式锁,引入一套新的 etcd 集群作为协调者,通常有点重,Redis 往往够用了。

5.3 三种方案横向对比与选型建议

我把 Redis、ZooKeeper、etcd 三种方案放在一张表里对比,方便你对照选择。

维度RedisZooKeeperetcd
性能高,微秒级延迟中,毫秒级延迟,ZAB 协议写开销大中,Raft 协议写开销大
可靠性依赖过期时间和主从复制,极端情况可能丢锁临时节点 + 会话机制,可靠性高租约 + Revision,可靠性高
实现复杂度低,SET NX 即可,框架支持完善中,需要理解节点类型和 Watcher中,需要理解租约和 Revision
自动续期需要自己实现或使用 Redisson 看门狗会话本身就包含心跳,无需额外处理租约自动续期机制
公平性无,抢占式有,按顺序节点排序有,按 Revision 排序
典型场景高并发缓存场景、秒杀、幂等控制配置推送、分布式协调、强一致场景云原生环境、Kubernetes 生态

选型没有绝对的好坏,只有合不合适。我给你的建议是:如果你的应用是互联网业务、QPS 高、对锁的要求是"基本可靠",直接用 Redis 加 Redisson,这是性价比最高的方案。如果你的应用涉及资金、对账、库存等强一致场景,且 QPS 不高,用 ZooKeeper 或者 etcd 更稳。如果你想在云原生架构里做协调服务,etcd 是最自然的选择。

6. 分布式锁面试题高频考点拆解

6.1 面试官最爱问的 7 个问题

分布式锁是 Java 后端面试的高频考点,几乎每一面都会遇到。我整理了 7 个出现频率最高的问题,每个都附带答题思路,你可以照着梳理自己的回答逻辑。

第一个问题:你们项目里为什么用分布式锁,不用它行不行?回答时要说清楚背景,比如服务多实例部署后单机锁失效,资源并发访问出现数据错乱。如果再延伸一点,可以说明为什么不能用 synchronized 或者数据库乐观锁替代。这个问题的核心是考察你有没有真实的高并发场景经验。

第二个问题:Redis 分布式锁怎么实现?从 SET NX EX 命令说起,解释原子性,然后补充 Lua 脚本释放锁,最后提 value 用 UUID 防误删。如果能画出完整的时序图,加分。

第三个问题:锁过期了业务没执行完怎么办?标准答案是看门狗自动续期,讲 Redisson 的实现细节,包括 30 秒过期、10 秒续期、续期失败的处理。

第四个问题:主从切换会导致锁丢失,怎么解决?先说清问题本质是异步复制,然后提 RedLock 的多数派思想,再说 RedLock 的争议点,最后指出更可靠的替代方案是 ZooKeeper 或者 etcd。

第五个问题:分布式锁如何保证可重入?说明用 Hash 结构记录线程标识和重入计数,来源可以提 Redisson 的实现。

第六个问题:锁的粒度怎么设计?举一个全局锁和分段锁对比的例子,说明锁粒度对性能的影响,然后延伸到热点 key 场景。

第七个问题:ZooKeeper 实现分布式锁和 Redis 有什么区别?从三个维度回答:实现原理、可靠性、性能。如果能讲清楚 ZooKeeper 临时顺序节点的原理,并且对比 Redis 的过期时间机制,面试官对你的评价会明显上一个档次。

6.2 从"会背题"到"会讲题"的三层递进

我发现很多候选人对分布式锁的知识有一个共同问题:能说出 SET NX EX、Lua 脚本、RedLock 这些名词,但一旦被追问细节就露馅。比如你问"为什么释放锁要用 Lua 脚本",他会背答案是"为了保证原子性",但你继续问"为什么 GET 和 DEL 两条命令不能保证原子性",他就愣住了。

面试官真正想考察的其实不是你是否知道答案,而是你有没有真正理解背后的逻辑。建议你按照"场景 -> 问题 -> 方案 -> 原理"这个路径组织回答,不要一上来就甩名词。比如问你 Redis 分布式锁的实现,不要只说命令,要先把场景还原:多个服务实例并发操作同一资源,需要互斥;单机锁做不到跨进程互斥;Redis 作为共享存储可以充当协调者;进一步思考宕机怎么办,所以加过期时间;再进一步想释放时判断和删除要原子,所以用 Lua 脚本。这样层层递进地回答,面试官会知道你是在真正思考,而不是背诵答案。

还有一个很容易被忽略的加分项:主动讲出方案的局限性和适用场景。比如你答完 Redis 锁的实现后,自己主动说"但是这个方案在极端场景下会丢锁,所以如果对一致性要求极高,我会考虑 ZooKeeper",这会给面试官留下一个"候选人有大局观、真正理解技术决策背后的 trade-off"的印象。

6.3 一个完整的回答模板

最后我给你一个可以直接套用的回答模板,以"Redis 分布式锁怎么实现"为例:

我们项目里使用 Redisson 框架实现分布式锁。核心原理是使用 Redis 的 SET NX EX 命令,保证加锁和设置过期时间的原子性。锁的 value 存放当前请求的唯一标识,防止误删。释放锁时使用 Lua 脚本,先校验 value 再删除 key,整体原子执行。Redisson 在底层用 Hash 结构支持可重入,同时通过看门狗机制自动续期,解决业务执行时间超过锁过期时间的问题。这个方案的优点是性能好、接入简单,缺点是 Redis 主从复制是异步的,极端场景下主节点宕机可能导致锁丢失,所以如果业务对一致性要求极高,我会选择 ZooKeeper 或 etcd 实现。

这段回答覆盖了命令细节、原子性、防误删、可重入、看门狗、可靠性边界和替代方案,层次清晰,信息密度高,足够支撑起一次深度的面试追问。如果你能把这里的每一句话都展开讲清楚,分布式锁这道题基本就过关了。

7. 分布式锁实战经验与避坑建议

最后分享一些我在真实项目中积累的经验,有些是用线上故障换来的教训,希望能帮你少走弯路。

第一,业务代码里必须用 finally 释放锁。这一点看起来是老生常谈,但我知道至少有三分之一的人犯过这个错误。一旦业务逻辑抛出异常,锁没有释放,其他线程只能等着锁过期。如果过期时间设置得比较长,比如 30 秒,而每次请求都会触发异常,那这台服务的错误请求就像滚雪球一样把并发能力耗尽。在写分布式锁代码的时候,请把 try-finally 当成一种肌肉记忆。

第二,锁的过期时间不能拍脑袋定。我见过不少项目把过期时间统一设成 10 秒,理由是"够用了"。但实际上不同的业务操作耗时差异很大,一个简单的缓存更新可能 50 毫秒就结束,一个涉及外部接口调用的业务可能要好几秒。正确的做法是针对每个业务场景评估最坏执行时间,然后在这个基础上乘一个安全系数。如果业务耗时无法预估,就一定要用看门狗自动续期,不要再依赖固定过期时间。

第三,监控锁的获取耗时和等待耗时。分布式锁是一个典型的"隐性依赖",出了问题不会第一时间暴露,而是表现为接口变慢、超时率上升。建议对所有加锁操作做埋点,统计加锁等待时间、持锁时间两个指标。一旦持锁时间接近过期时间,要立刻报警。我之前的团队就靠这个监控在锁的过期时间设置不合理时提前发现,避免了上线后的事故。

第四,不要在持锁期间做耗时操作。锁保护的应该是"操作共享资源的临界区",而不是整个业务流程。很多人习惯把加锁写在接口入口处,然后拿着锁去查数据库、调外部接口、做复杂计算,这些操作动辄几百毫秒甚至几秒。这把锁的粒度放大了十倍不止,严重拖垮了系统的并发能力。正确的做法是:先在锁外面做好所有能提前做的准备,只把真正需要互斥的那一小段代码放进锁里。

第五,加锁失败后的降级策略要想清楚。拿到锁执行正常逻辑,拿不到锁怎么办?有的系统是直接返回失败,有的系统是重试等待,有的系统是走降级路径。不同的策略对用户体验和系统负载的影响完全不一样。如果你的锁冲突率较高,建议用"等待重试 + 超时降级"的组合策略,不要让所有请求同时打向 Redis。

分布式锁本质上不是银弹。它能解决跨进程互斥的问题,但无法自动解决业务逻辑设计上的缺陷。如果你发现系统频繁出现锁冲突、锁等待超时,先把业务代码里锁保护的代码段反复看几遍,也许更好的解法是把锁去掉,调整数据结构或者业务逻辑,而不是换一种更复杂的锁实现。

我在实际项目里体会最深的一件事是:分布式锁的方案演进,本质上是业务需求和技术风险之间的博弈。你妥协了某一部分可靠性,就要在其他地方设计补偿机制。比如用 Redis 锁丢了一个订单的幂等控制,就要靠数据库的唯一索引兜底。以我个人的经验,把分布式锁的底层原理吃透,再结合实际业务做权衡,远比背下来的 command 和代码更有价值。希望这份拆解能帮你在自己的项目里正确、安全地用上它。

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

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

立即咨询