ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分布式锁原理与Redis实现全解析:从互斥到看门狗

分布式锁原理与Redis实现全解析:从互斥到看门狗 “面试官竟然问我怎么实现分布式锁幸亏我总结了全套八股文”这个标题估计不少朋友都看着眼熟。分布式锁在面试里几乎是 Redis 方向必考的一道题但很多人背了一堆概念真被问到“你项目里到底怎么实现的”“锁过期了怎么办”“主从切换锁丢了怎么处理”的时候还是会卡壳。这篇我把分布式锁从需求到实现、从坑点到答法完整捋一遍既照顾准备面试的朋友也让真正要做技术选型的人能落地参考。先说清楚一件事分布式锁不是某一个中间件的专属功能而是分布式系统里一种跨进程互斥的手段。面试官问你这个问题表面是考 Redis 命令和实现细节背后其实想看你对“分布式环境下的一致性问题”有没有成体系的认知。所以这篇文章我不会只给你一段 SETNX 代码就结束而是会把方案选型、实现细节、生产环境坑点、面试追问思路全部串起来讲。1. 分布式锁到底解决什么问题1.1 从“抢购超卖”说起先抛开面试官的角度用一个最常见的业务场景来理解秒杀。假设你有一个商品库存只有 10 件高并发下 1000 个用户同时发起下单请求。如果代码里只是简单地做“查库存 - 减库存 - 更新库存”在高并发下一定会出现超卖。为什么因为多个请求同时读到库存还是 10然后都执行了减一操作最后数据库里的库存可能变成负数。单机环境下这个问题好解决JVM 里的synchronized或者ReentrantLock就能保证同一时刻只有一个线程执行减库存逻辑。可一旦系统拆成多个服务实例每个实例都有自己独立的 JVM单机的锁就失效了。A 实例的线程在锁内执行B 实例的线程根本不知道这把锁存在照样能进去执行。这时候就需要一把“大家都能看到的锁”让所有实例的线程都遵守同一个互斥规则——这就是分布式锁的核心价值。说白了分布式锁解决的是“多个进程之间”的互斥问题而不是“单个进程内多个线程之间”的并发问题。这个概念是后续所有讨论的基础。面试时如果能先用 30 秒把这个问题讲清楚天然就给面试官留下一个“这个人是真做过分布式”的好印象。1.2 一把合格的分布式锁要满足哪些条件很多初学者以为分布式锁就是“加锁 - 释放锁”两个操作实际上真要往上加条件能列出一长串。面试考的就是你对这些条件的理解深度。我整理了一个比较完整的清单条件说明典型问题互斥性同一时刻只有一个客户端能持有锁这是最基本要求做不到就谈不上锁安全性锁不会被别的线程误删释放锁时没有校验持有者导致 A 线程把 B 线程的锁删了可用性锁服务本身不能成为单点Redis 主节点挂掉后锁服务还能不能正常对外提供死锁避免客户端崩溃后锁能自动释放没有设置过期时间持有锁的进程挂了锁永远不释放可重入性同一线程可以重复获取同一把锁业务方法嵌套调用时内部再次尝试获取同一把锁高性能加锁/解锁延迟低、开销小每次锁操作都要走一次磁盘 IO 显然不可接受这里我特别想强调“安全性”和“死锁避免”这两项。很多生产事故就是这两个条件没满足导致的。比如某个服务实例在释放锁的时候没有校验这把锁是不是自己持有的结果把别的实例刚拿到的锁给释放了两个请求同时进入临界区数据就乱了。这种问题在面试里经常被包装成“如何防止锁被误删”后面我会详细讲。2. 主流实现方案选型从数据库到 Redis2.1 数据库锁实现简单但天花板明显先看一种最古老的方案基于数据库实现分布式锁。主流做法有两种一种是利用数据库唯一索引的约束特性插入一条锁记录成功插入表示拿到锁删除记录表示释放锁另一种是使用SELECT ... FOR UPDATE行级锁让数据库帮忙做互斥。这个方案的优势是“零额外依赖”。如果你的项目本来就有数据库不需要再引入 Redis、ZooKeeper 这些组件实现起来也很直观。但它的问题同样突出第一数据库单点故障时整个锁服务不可用第二没有内置的锁过期机制客户端挂了之后锁记录永远留在表里其他客户端永远拿不到锁必须额外起一个定时任务去清理第三FOR UPDATE在高并发下会对数据库造成比较大的压力性能和 Redis 完全不是一个量级。所以数据库锁更适合“并发量低、对锁的可靠性要求不那么苛刻、团队没有额外中间件运维能力”的场景。比如某些低频后台任务并发量本来就低直接用数据库锁完全够用。但如果你面对的是秒杀这类高并发场景数据库锁基本就是给自己挖坑。2.2 ZooKeeper 临时顺序节点可靠但重ZooKeeper 实现分布式锁的思路是利用“临时顺序节点”加“监听机制”。客户端在锁目录下创建一个临时顺序节点然后检查自己是不是序号最小的节点是表示获取锁成功不是则监听自己前一个节点的删除事件前一个节点被删除后再重新检查。整个过程依靠 ZK 的节点唯一性和 Watcher 机制实现分布式协同。这个方案从一致性角度来说比 Redis 要强。因为 ZooKeeper 本身基于 ZAB 协议写请求需要多数派确认才返回成功所以正常情况下不会出现“锁已经写入但主节点挂了导致锁丢失”的问题。临时节点还自带“会话过期自动删除”的能力客户端崩溃后锁会自动释放不需要额外的过期时间。但它的缺点也很明显性能偏低、部署和运维成本高。ZK 的每一次写操作都要走一次 Leader 选举相关的协议延迟比 Redis 高不少而且 ZooKeeper 集群的规模通常比 Redis 大维护起来更重。还有一个常被提到的“羊群效应”——大量客户端同时监听同一个节点节点删除时会惊动大量客户端造成不必要的网络流量和重选压力。面试中如果你能说出来“ZooKeeper 方案更可靠但更重、Redis 方案性能好但有窗口期”面试官就知道你对这两种方案都有过实际思考而不是只会背一种。2.3 为什么面试重点考 Redis 分布式锁前面铺垫了这么多回到正题为什么面试官偏偏爱问 Redis 分布式锁我觉得有三个原因。第一Redis 是很多互联网公司的标配基础设施面试官默认你有 Redis 使用经验问分布式锁是在考察你“会不会用 Redis 解决实际问题”而不是考理论。第二Redis 分布式锁本身覆盖了很多关键知识点——单线程模型、原子命令、Lua 脚本、过期时间、主从复制、持久化策略、RedLock 算法问一道分布式锁几乎可以串起 Redis 的整个知识体系。第三这个方案如今在工程界的实践已经非常成熟Redisson 框架把大部分复杂度都封装好了面试官可以直接问你“Redisson 的看门狗是怎么实现的”“锁续期失败会怎样”问题可以层层深入。我个人的建议是不要只把 Redis 当成唯一答案。面试时先答 Redis 方案再补充一句“如果对一致性要求极高或者公司已经有 ZooKeeper 基础设施我也会考虑用 ZK 实现不过我们当前场景对性能要求比较高所以选了 Redis 方案”。这样既给出方案又展示了选型思考比单纯背答案强得多。3. Redis 分布式锁的核心细节与常见坑3.1 第一版实现SETNX EXPIRE 隐藏的原子性问题现在进入正题看看 Redis 分布式锁怎么一步步写对。很多人最早接触的版本是这样的# 加锁 SETNX lock_key unique_value # 解锁 DEL lock_key这个版本的问题一眼就能看出来如果加锁后程序还没执行完就崩溃了锁永远不会释放其他客户端全部阻塞。于是有人想到加一个过期时间# 加锁 SETNX lock_key unique_value EXPIRE lock_key 30 # 解锁 DEL lock_key看起来解决了死锁问题但这里藏着一个非常经典的坑SETNX和EXPIRE是两条独立命令不是原子操作。如果执行完SETNX后、执行EXPIRE之前进程突然崩溃或者 Redis 连接异常断开过期时间就没有设置成功锁依然会永久存在。这个 bug 在面试里几乎必问就是考你有没有意识到“多条命令组合在一起时一定要考虑原子性”。正确的做法是用 Redis 提供的原子命令一次完成两个操作SET lock_key unique_value NX EX 30这条命令的含义是只有当lock_key不存在时才设置 value同时设置 30 秒的过期时间。NX保证互斥EX保证自动过期两个操作合二为一从根上解决了原子性问题。从 Redis 2.6.12 版本开始支持这个写法所以实际项目中不需要再用SETNX EXPIRE组合。3.2 释放锁的坑千万不要直接 DEL很多人在释放锁的时候简单地执行DEL lock_key。这样做隐藏着一个严重的误删问题客户端 A 拿到锁后因为某些原因执行时间超过了过期时间锁已经被 Redis 自动释放了。此时客户端 B 拿到了同一把锁开始执行自己的业务。A 执行完后执行DEL lock_key直接把 B 的锁给删了。这时候 B 还在临界区执行同时 C 又进来拿锁三个客户端同时执行分布式锁彻底失效。解决办法是释放锁之前先校验 value 是不是自己写入的值只有是自己的才删除。为了保证“判断 value”和“删除 key”这两个操作是原子的需要借助 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用 Java 客户端的伪代码表示加锁解锁流程// 加锁设置随机值 过期时间原子操作 String lockKey order:123:lock; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30)); // 业务逻辑 if (Boolean.TRUE.equals(locked)) { try { // do something } finally { // 解锁Lua 脚本校验 value 后删除 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId); } }这里的requestId必须是全局唯一的随机值不能写死。它在锁释放时扮演“身份凭证”的角色相当于你给这把锁盖了一个专属章只有盖同一个章的人才能打开锁。这个细节面试官很喜欢追问你主动说出来“我们的 value 是用 UUID 生成的确保每次加锁的客户端身份唯一”是很加分的。3.3 锁超时与业务执行时间超过过期时间怎么办如果业务逻辑执行时间超过了锁的过期时间锁被自动释放新的客户端就能拿到锁同样会引发并发问题。这是一个两难的事情过期时间设短了业务没跑完锁就丢了设长了一旦持有锁的客户端挂了其他客户端要等很久。业界最常见的方案是“续期”也就是 Redisson 里的看门狗Watchdog机制。默认情况下 Redisson 加锁时会设置 30 秒的锁超时时间同时启动一个后台定时任务每 10 秒执行一次续期逻辑。只要锁还在持有中就自动把过期时间重置为 30 秒。如果进程崩溃看门狗线程跟着销毁锁自然在 30 秒后过期释放不会造成死锁。这就是“程序挂了锁能自动释放程序活着锁不会提前过期”的关键机制。Redisson 的客户端代码非常简洁RLock lock redissonClient.getLock(order:123:lock); try { boolean locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (locked) { // 业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }调用tryLock时如果传了 leaseTimeRedisson 会按照你指定的时间执行不启动看门狗如果不传 leaseTime则启用默认的看门狗续期逻辑。这一点很多人在实际使用中会忽略自己在代码里写死了 leaseTime然后抱怨 Redisson 的续期不生效其实是没有触发默认续期路径。当然续期方案也不是万能的。如果 Redis 主节点挂了锁在主从切换过程中丢失续期再勤快也救不了。这个问题的本质是在“Redis 主从复制是异步”的前提下锁的持久化天然存在窗口期。3.4 可重入锁同线程重复加锁怎么处理有时候业务逻辑是嵌套的外部方法加锁后内部方法又尝试获取同一把锁。如果锁不支持可重入内部方法会把自己阻塞死——明明已经持有锁了又去等同一把锁释放而释放锁的代码在外层方法等不到。Redis 分布式锁实现可重入的核心思路是value 里记录持有者的身份和重入次数。每次加锁时先检查 key 是否存在如果存在且 value 是当前线程的标识就把重入次数加一否则加锁失败。每次释放锁时把重入次数减一减到零才真正删除 key。Redisson 内部就是这么实现的它的 value 是一个ThreadLocal维护的计数器加线程标识加锁和解锁都会在 Lua 脚本里做原子判断。这个知识点面试中不算高频但被问到时如果能答出“重入技术是在 value 里维护线程标识和计数器而不是简单地再 SET 一次”说明你对锁的底层实现有真正的理解。Redisson 的源码里有一个getLockName内部是lockName threadId和一个重入次数的哈希结构有兴趣的话可以翻一翻源码面试时聊起来会很有底气。4. 分布式锁面经高频问题与回答思路4.1 从“怎么实现”到“为什么这样实现”面试官不会只问一层他会顺着你的答案往下追。我收集了一线面试中最常出现的几组问题整理成了一张速查表问题回答要点分布式锁和单机锁有什么区别单机锁基于 JVM 内存只在进程内生效分布式锁基于公共存储跨进程协调SETNX 和 SET NX EX 的区别SETNX 本身没有过期时间参数需要再调 EXPIRE两条命令不原子SET NX EX 一步完成为什么释放锁要用 Lua 脚本GET 判断和 DEL 删除两个操作必须原子执行否则会误删别人的锁value 为什么要用随机值释放锁时校验持有者身份防止客户端 A 删掉客户端 B 的锁业务执行时间超过锁过期时间怎么办看门狗定时续期或者设置合理的过期时间并配合续期机制Redis 主从切换时锁丢失怎么办该场景下 Redis 方案存在窗口期对一致性要求极高的场景可考虑 ZK 或 RedLockRedisson 看门狗续期原理是什么后台定时任务每 10 秒检查一次锁仍持有就重置过期时间为 30 秒锁的可重入性怎么实现value 中记录线程标识和重入计数加锁时计数加一解锁时减到零才删面试时有一个非常重要的原则你答到哪一层面试官就会从那一层往下问。所以尽量不要把话说得太满。比如你说“我们用 Redis 分布式锁解决了超卖问题”面试官一定会追问“Redis 锁过期了怎么办”“主从切换锁丢了怎么办”。与其被动等他一个个问不如自己主动把边界条件讲出来“我们当时评估过Redis 锁存在主从切换的丢锁窗口期但结合我们业务的可容忍度这个风险是可控的如果后续业务对一致性要求提高会考虑引入 ZooKeeper 或做锁的可靠持久化。”这样一句话既展示了风险意识也体现了架构视野。4.2 RedLock 算法银弹还是陷阱聊到 Redis 分布式锁绕不开 RedLock 算法。它的思路是在多个独立的 Redis 节点通常 5 个上依次加锁如果能够在大多数节点比如 3 个以上上加锁成功并且整个加锁过程消耗的时间小于锁的过期时间就算拿到了锁。释放锁时向所有节点广播释放命令。RedLock 在 Redis 官方文档中被作为“防主从切换丢锁”的推荐方案提出但业内对它的争议很大。最大的质疑来自分布式系统领域的大牛 Martin Kleppmann他写过一篇著名的文章《How to do distributed locking》指出 RedLock 本质上是一个“异步复制 依赖时间假设”的方案在发生 GC 暂停、时钟跳跃、网络分区时依然会有问题。Redis 作者 Salvatore Sanfilippo 也做了回应讨论非常精彩。我的观点是面试时你可以客观评价 RedLock但不要把它吹成万能神药。如果你能说出来“RedLock 的设计目标是提供一种不依赖单点 Redis 的容错方案但它依赖多个节点的时间一致性和客户端的严格实现复杂度高、性能损失大在工程实践中很少有人真正落地我们当时的场景通过主从高可用加锁过期时间已经满足了业务要求所以没有采用”这个回答的层次会明显高于只知道 RedLock 概念的人。4.3 锁的粒度与业务场景没人会锁一张表很多刚接触分布式锁的人会把锁的 key 设置成类似order:lock这种全局粒度结果整个订单模块的所有操作都串行化了性能惨不忍睹。好的做法是尽量缩小锁粒度让不同业务的锁互不干扰。比如真正需要互斥的是一张订单的同一笔操作那锁的 key 就应该设计成order:123:lock其中 123 是订单 ID。这样不同订单之间的操作可以并行执行只有同一笔订单的操作才会互斥。这个思想在面试中也很重要。面试官问“你怎么设计锁的 key”其实是在考察你对并发冲突范围的判断。你不能总是锁全局也不能为了提升并发性能把锁拆得太碎。判断标准很简单什么样的数据之间存在真正的竞争关系就把这些数据的操作放在同一把锁下面。5. 实操踩坑记录与排查技巧5.1 我在生产环境遇到过的三个坑第一个坑锁误删导致并发执行。那次是早期代码直接DEL锁没有校验持有者。我们的定时任务在同一时刻部署了多个实例实例 A 持有锁后执行时间超过了锁过期时间锁被 Redis 自动释放实例 B 拿到锁进来了。A 执行完顺手DEL把 B 的锁删了实例 C 又进来。结果等于二重执行线上出现了一批重复数据。后来我们把释放逻辑改成 Lua 脚本校验 value 后才删除同时把业务执行时间做了拆分尽量避免单次任务超过锁过期时间。第二个坑主从切换丢锁。这是 Redis 主从架构固有的问题。主节点上刚写入锁还没同步给从节点主节点就挂了哨兵把从节点提升为新的主节点此时新的主节点没有这把锁别的客户端就能直接拿到锁。这个窗口期虽然很短但在强一致要求的业务里是不可接受的。我们当时的应对是把这类业务对一致性的要求降级——能接受极小概率的重复执行才继续用 Redis 锁如果不能接受那就要换方案。没有一种技术方案是“既要高性能又要强一致”的面试时最忌讳的就是描述出一个不存在的完美方案。第三个坑续期线程泄漏。Redisson 的看门狗逻辑本身没问题但如果你的业务线程池没有被正确关闭或者高并发下创建了大量锁对象而没有释放后台续期线程会长期堆积最终拖垮 JVM。这个问题的排查难度很高因为续期线程是异步的不会直接报错只能通过线程转储Thread Dump观察到大量RedissonLockEntry相关线程。后来我们在封装锁工具类时做了严格的生命周期管理每次调用结束后确保释放锁并关闭对应的RLock对象。5.2 锁问题排查思路从监控到链路追踪分布式锁的问题往往不是立刻爆发的而是持续一段时间后才在数据上露出端倪。我常用的排查路径是这样的看监控先用 Redis 监控面板看锁相关 key 的访问量、平均延迟、慢查询记录。如果锁过期时间设置得过长Redis 中会堆积大量过期 key导致内存持续上涨。看日志和链路追踪给加锁、解锁、续期埋点把锁的获取时间、持有时间、释放方式记录到日志里。用链路追踪系统查同一把锁在哪个节点上被获取、持有多久、是否走了 Lua 脚本释放。看线程转储怀疑有锁泄漏时抓几份 JVM 线程转储看有没有大量工作线程阻塞在锁等待上以及后台续期线程是否异常增多。代码复查重点看释放锁的代码是否在finally块里、是否所有异常路径都能走到。整个过程最怕的是没有日志。所以我的建议是在封装锁工具类的时候就把打点逻辑做进去别等到出了问题再用临时日志去查。比如一个tryLock方法成功与否、等待多久、持有多久、释放成功与否这些信息都打到同一个日志文件里。排查时只要按照链路 ID 一搜基本都能定位到问题节点。5.3 监控指标如何判断锁服务是健康的最后补充几个我们团队自己定义的锁健康指标你也可以参考指标含义异常参考值锁获取成功率成功获取锁次数 / 尝试获取锁次数长期低于 90% 说明锁竞争过于激烈或持有时间过长锁等待时长从请求加锁到获取成功的耗时P99 超过 1 秒需要关注说明锁粒度可能过大锁持有时长业务持有锁到释放的耗时超过设置的过期时间说明可能存在未续期或业务异常解锁失败次数Lua 脚本返回 0 的次数持续上升说明可能存在锁被提前过期、其他客户端持有锁锁 key 数量当前 Redis 中残留锁 key 数量长期持续增长说明可能存在锁泄漏这些指标不需要额外开发复杂的监控系统用 Redis 的慢日志、INFO 命令、以及对锁工具类埋点日志的统计就能得到。重要的是有这套意识别等到线上出了重复数据才开始怀疑锁的问题。6. 给面试者的最后一套锦囊6.1 答题框架总分总 分层递进面试答“怎么实现分布式锁”这种开放题最忌讳上来就甩一段代码。一个好的答题结构是先讲业务背景说明为什么需要分布式锁再讲方案选型为什么选 Redis然后讲具体实现展示关键代码和边界处理最后讲风险与兜底主动说明这个方案在什么情况下会失效以及你的应对策略。我举个例子你感受一下这个节奏“我们做秒杀系统时遇到库存超卖问题单机锁跨实例后失效所以需要分布式锁。当时对比了数据库锁、ZooKeeper 和 Redis考虑到性能和维护成本我们选了 Redis。加锁用SET key value NX EX原子命令value 是 UUID释放锁用 Lua 脚本先校验再删除。业务可能会在锁过期前没跑完所以用 Redisson 的看门狗做了续期。这个方案存在的问题是主从切换时锁可能丢失不过我们的业务允许极小概率的重复执行配合接口幂等性设计可以兜底。”整个过程不超过两分钟但覆盖了所有关键点面试官想深挖任意一环都能顺势往下问。6.2 亮点答法多谈取舍与边界面试中真正的加分项不是“我会用 Redisson”而是你能说出“为什么这样选型”“方案的边界在哪里”。比如你提到用数据库锁作为降级方案那就要想清楚什么情况下会把流量切到数据库锁切过去之后原来高并发的场景数据库能不能扛住这些细节比背一个 RedLock 概念有价值得多。另外一个容易被忽略的点是时钟跳跃。Redis 锁的过期时间依赖服务器本地时钟如果服务器时钟出现大幅跳跃锁也可能提前或延后过期。Redisson 的看门狗机制里的时间计算同样遇到这个问题。这属于“知道的人少、但说出来很加分”的知识点面试时可以主动提一句“分布式锁对服务器时间同步也有要求需要在运维层面做好 NTP 配置”。6.3 最后聊一句实战心态分布式锁在面试里再热也只是一道技术题。真正到了生产环境你会遇到比面试题复杂得多的组合问题——Redis、业务、网络、JVM、数据库全部掺和在一起。所以我建议你准备这道题的时候不只是背结论而是真的在本地 Redis 里写一遍加锁解锁逻辑模拟一下锁过期、误删、重入这些场景再对照 Redisson 源码看看它怎么做的。把原理变成肌肉记忆面试时才能答得自信、答得自然。我个人在实际面试中最喜欢听到的回答是那种能主动把方案的“不完美之处”说出来的候选人。技术选型没有银弹分布式锁更是如此。愿意正视和讨论取舍的人才是真正能处理线上问题的人。
返回列表