尧图精选

手写Redis分布式锁:原理、常见坑与工程实践选型指南

🕒 发布时间:2026/10/1 10:47:40 📁 来源:尧图网络
“手写Redis分布式锁”这几个字放在招聘JD里是常规操作放在面试题里是必考题放在我实际写的代码里却是一段反复推翻重来的血泪史。我最早接触分布式锁还停留在SETNX一把梭的时代后来被线上事故教育了几次才发现这门手艺远不是一行命令那么简单。这篇内容不是教科书也不是 Redisson 的源码分析而是我自己踩过坑之后的整理想聊清楚三件事分布式锁到底在锁什么手写过程中会踩到哪些细节坑以及最后我如何在手写和现成框架之间做选择。如果你正准备自己实现一把 Redis 分布式锁或者想在面试里把这个问题说透这篇文章可以直接拿来当参考。1. 为什么我要手写一把Redis分布式锁1.1 分布式环境下锁到底锁的是什么先从一个最基础的问题说起。单体应用时代多个线程并发操作共享变量我们直接上synchronized或者ReentrantLockJVM 会帮我们保证同一时刻只有一个线程进入临界区。但一旦应用从单机变成多实例部署每个实例都有自己的 JVM 锁所谓“互斥”就只在一个进程内部成立。用户请求负载均衡到三台机器上三台机器同时执行同一段扣库存代码JVM 锁谁也拦不住谁这就是分布式锁要解决的核心问题跨进程、跨实例的互斥。这里很多人有个误区以为分布式锁锁的是某段代码。其实严格来说锁的是“资源标识”。扣库存锁的是商品 ID防重提交锁的是订单号定时任务锁的是任务名。不同业务共用同一个锁 key 没有意义真正要做的是让所有操作同一个资源的实例在 Redis 里对同一个 key 达成互斥。记住这一点后面看锁的设计思路会清晰很多。1.2 哪些场景必须用分布式锁我列一下实际项目里最常见的四类场景基本覆盖了绝大部分用法。第一类是库存扣减。秒杀、抢购场景下库存是共享资源减库存操作必须互斥。很多人会拿原子自增INCR说事但真实业务往往不是简单减一而是要先校验库存充足、再记录扣减流水、最后更新库存这个完整流程必须一把锁包住。第二类是定时任务。比如每天晚上做数据对账如果部署了三台实例三个定时任务都会触发不加锁就会重复处理。第三类是接口防重。用户双击提交订单、支付回调重复推送这类场景如果下游接口没有幂等逻辑就需要锁来保证同一笔业务只处理一次。第四类是缓存重建。热点 key 缓存过期后大量请求同时打到数据库用分布式锁保证只有一个请求去查数据库并重建缓存其余请求等待或者直接返回旧值。1.3 为什么偏偏是Redis数据库锁不香吗有人会问数据库不是也能实现分布式锁吗SELECT ... FOR UPDATE锁行、version字段做乐观锁这些都是方案。数据库锁的问题在于成本和耦合。悲观锁依赖数据库连接和事务一个锁操作要占住一条连接锁等待超时会直接影响数据库连接池在高并发下容易拖垮数据库本身。乐观锁虽然轻量但冲突多的时候要反复重试CPU 和磁盘开销都不小。而且数据库不是所有团队都愿意拿出来做协调器毕竟它还承担着核心数据存储的职责。Redis 的优势在于纯内存操作单次加锁的延迟通常在毫秒级别性能远高于数据库。再加上 Redis 本身的可用性架构绝大多数业务场景下它都能把锁玩得很稳。更重要的是SET key value NX EX seconds一条命令就能完成加锁加上 Lua 脚本可以完成原子释放整个实现逻辑很直观这也是我后来坚持手写一版的原因——与其背 Redisson 的源码不如先把它背后的原理捋明白。2. 手写第一版看似简单其实处处是坑2.1 天真版本setnx加expire两步走我第一次写分布式锁代码大概长这样Boolean locked jedis.setnx(lock:order:123, 1); if (locked) { jedis.expire(lock:order:123, 30); // 业务逻辑 } else { // 获取锁失败 }当时觉得逻辑没毛病setnx 只有 key 不存在时才能设置成功成功就加锁接着设个过期时间防止死锁。直到后来我仔细想了一个问题如果setnx执行成功但expire还没执行进程突然崩溃、或者 Redis 连接断开了这个 key 就会永远留在 Redis 里锁再也释放不了。这不是理论上的假设Redis 客户端超时、JVM 发生OOM、发布过程中机器被 kill都可能让两条命令中间断掉。后来有人用setnx加锁之后用一个独立线程去补expire甚至用定时任务扫描“没有过期时间的锁 key”再补上过期时间。这些方案都能解决一部分问题但都是补偿思路治标不治本而且代码复杂度会越写越高。2.2 加锁必须原子化SET key value NX EX正确的姿势是用 Redis 从 2.6.12 版本开始支持的SET命令扩展参数把加锁和设置过期时间合并成一个原子操作String lockKey order:lock:123456; String requestId UUID.randomUUID().toString(); String result jedis.set(lockKey, requestId, NX, EX, 30); if (OK.equals(result)) { // 加锁成功执行业务 try { // doSomething } finally { // 释放锁 } }这里有两个重点。第一NX表示只有 key 不存在时才写入EX 30表示自动过期时间 30 秒两个参数配合设置Redis 内部是同一个命令、同一个原子操作从根本上消除了两步操作之间的窗口期。第二value 绝对不能写死成1必须是一个全局唯一的requestId这个标识会在释放锁时用来确认“这把锁是不是我的”。这一点我当时没当回事结果后面踩了一个很大的误删锁的坑下面细说。2.3 释放锁要校验唯一标识不能无条件del如果释放锁只是简单执行jedis.del(lockKey)会有一个非常隐蔽的问题线程 A 拿到锁设置了 30 秒过期时间业务却跑了 40 秒。30 秒时锁自动过期线程 B 趁虚而入拿到了锁。线程 A 跑完业务后执行del删掉的其实是线程 B 的锁。这时候线程 C 可能已经拿到锁了B 的业务还在执行互斥彻底失效。解决办法是释放锁之前先比较 value 是否等于自己当初写入的requestId相等才删除String value jedis.get(lockKey); if (requestId.equals(value)) { jedis.del(lockKey); }但这里又有一个新问题get和del是两个独立命令中间依然存在时间窗口。假如线程 A 刚get完确认 value 是自己的线程 B 也get完发现锁还是 A 的此时锁过期了B 加锁成功然后 A 再执行del照样把 B 的锁删了。所以校验和删除也必须做成原子操作这就是 Lua 脚本出场的原因。2.4 为什么释放锁必须用Lua脚本Redis 的 Lua 脚本功能允许我们把多条 Redis 命令打包成一个脚本交给服务端执行整个脚本执行期间不会插入其他命令天然具备原子性。释放锁的脚本写起来非常简单if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用 Jedis 调用的伪代码String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId));这段脚本的意思很简单如果 key 里的 value 等于我手里的requestId才执行删除否则什么都不做。因为get和del在脚本内部执行中间不会被任何其他客户端命令打断误删锁的问题就算从根本上解决了。注意手写锁时加锁用SET NX EX释放锁用 Lua 脚本这是一对不可拆分的组合。前半段不原子会死锁后半段不原子会误删两个坑踩一次就够了。3. 给锁加上续期机制避免业务没跑完锁就过期3.1 过期时间设多长才算合理写到这里锁好像能用了但另一个问题浮现了过期时间到底该设多大设 10 秒业务稍微慢一点锁就自动释放了另一个线程进来对共享资源的互斥保护形同虚设。设 100 秒如果持有锁的进程真的挂了其他所有线程都得在原地干等 100 秒这个等待时间用户根本受不了。本质上过期时间是一个兜底保障它应该大于业务正常执行时间又要尽量小以缩短故障恢复时间。业务正常执行时间其实很难拍脑袋定。一次库存扣减要查库存、写流水、更新缓存正常 50 毫秒GC 一停顿可能就到 5 秒。更极端的情况是 JVM 发生长Full GC整个应用冻结十几秒如果过期时间短锁就会在业务还没结束的时候自动释放。所以单纯把过期时间调大不是办法更务实的做法是设一个“相对合理但偏短”的默认值比如 30 秒然后给锁配一个续期机制让持有锁的线程主动延长过期时间一直续到业务跑完为止。3.2 一个简单的看门狗怎么实现续期机制在 Redisson 里叫 WatchDog直译过来就是看门狗。原理不复杂加锁成功后启动一个守护线程每隔一段时间比如每 10 秒去 Redis 里把锁的过期时间重置为初始值比如重新设为 30 秒。只要业务还在跑看门狗就一直续期业务结束后在finally里释放锁同时把看门狗停掉。一个最简的 Java 实现思路如下public class RedisLock { private static final long DEFAULT_EXPIRE 30_000L; private static final long WATCHDOG_INTERVAL 10_000L; private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); private volatile boolean running false; public void lock(String lockKey, String requestId) { String result jedis.set(lockKey, requestId, NX, EX, DEFAULT_EXPIRE / 1000); if (OK.equals(result)) { running true; scheduler.scheduleAtFixedRate(() - renew(lockKey, requestId), WATCHDOG_INTERVAL, WATCHDOG_INTERVAL, TimeUnit.MILLISECONDS); } } private void renew(String lockKey, String requestId) { // 续期的时候也必须先校验锁还是不是自己的 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end; jedis.eval(lua, Collections.singletonList(lockKey), Arrays.asList(requestId, String.valueOf(DEFAULT_EXPIRE))); } public void unlock(String lockKey, String requestId) { running false; String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; jedis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } }这段代码有几个细节值得注意。第一续期的间隔不能太大一般取过期时间的三分之一过期 30 秒就每 10 秒续一次这样即使某一次续期失败锁也不会立刻过期。第二看门狗线程必须在释放锁的时候停掉否则业务结束了守护线程还在不停续期锁可能永远不释放。第三renew也必须用 Lua 脚本校验持有者如果在续期瞬间锁已经不是自己的了不能把别人的锁错误续期。3.3 进程卡顿、GC停顿、网络分区续期也救不了的情况看门狗能解决业务执行时间长的问题但有几个场景是它也无能为力的。第一个是 JVM 长 GC。看门狗线程和应用业务线程跑在同一个 JVM 里JVM 全局停顿的时候业务线程停了看门狗线程同样也停了续期全部卡住锁到期自动释放。业务恢复后继续操作共享资源而此时锁可能已经被别人拿走了这是分布式锁和应用自身的宿命矛盾。第二个是网络分区。客户端和 Redis 之间的网络断开看门狗自然续不了期锁自动释放。如果业务进程还在正常运行它就会在“没有锁”的状态下继续操作共享资源。第三个是运维层面的问题比如 Redis 主节点宕机触发主从切换新的主节点可能没有从旧主节点同步到锁 key锁瞬间就“消失”了。这些问题说明一个事实任何基于 Redis 的分布式锁都不是绝对安全的它只是把出问题的概率降到了一个业务可接受的范围。是否需要为了让极端场景更安全而引入 RedLock 或者 ZooKeeper 这类强一致方案要结合业务的重要程度来判断不能一刀切。4. 更进一步可重入锁与阻塞等待4.1 可重入到底解决什么问题到目前为止的锁是“不可重入”的同一个线程如果已经持有了锁再次调用加锁会被当成一个陌生线程请求而失败。实际业务里这种情况不少见。比如一个服务方法内部调用了另一个同样需要加锁的方法或者一个递归结构在每一层都尝试加锁一旦走到第二次加锁就必然失败。如果加锁失败的策略是阻塞等待那就会变成自己等自己释放锁直接死锁如果策略是快速失败则业务直接异常。JDK 的ReentrantLock用同一个线程持有锁的数量来做可重入判断这个思路完全可以平移过来。区别在于单机锁的状态保存在 JVM 内存里分布式锁的状态保存在 Redis 里所以计数器也得放到 Redis 里。4.2 用哈希结构设计可重入锁可重入锁的经典做法是用 Redis 哈希结构来保存持有者信息和重入次数。key 是锁资源标识field 是持有者的requestIdvalue 是重入计数。加锁时先判断 key 是否存在不存在就写入fieldrequestId, value1存在则判断 field 是否为自己的requestId是的话对 value 做INCR否则加锁失败。释放锁时先DECR计数减到 0 才删除 key没减到 0 说明还有外层锁存在不能删。这里有个容易搞混的点用哈希结构之后原来那个SET NX EX的直接写法就不能用了得借助一段更复杂的 Lua 脚本来实现。因为加锁过程包含“判断 key 是否存在”和“写入 field/value”两步只有 Lua 脚本能保证它们是原子的。写出来的脚本大概长这样-- 加锁 if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, 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(pexpire, KEYS[1], ARGV[2]) return 1 end return 0-- 释放锁 if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return 0 end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then redis.call(pexpire, KEYS[1], ARGV[2]) return count end redis.call(del, KEYS[1]) return 0我在实际项目里其实很少用到可重入因为业务上会刻意规避同一个线程重复加锁的设计。但面试中这块几乎是必考点而且理解了哈希结构的计数逻辑对理解 Redisson 的底层也有帮助。4.3 拿不到锁的时候自旋等待还是订阅通知手写锁到现在加锁失败的线程怎么处理还没讨论清楚。最简单的策略是快速失败拿不到锁直接返回失败由上层业务决定是重试还是换一种处理方式。但很多业务场景需要“排队等待”比如扣库存接口用户等了 100 毫秒拿不到锁就直接报错体验很差。这时候就要引入阻塞等待机制。阻塞等待的粗暴实现是自旋用一个循环不断尝试加锁每次失败后Thread.sleep(50)再试。自旋的问题在于浪费 CPU。尤其当几十个线程同时等同一把锁时每个线程都在高频度地请求 Redis对 Redis 的访问压力成倍增加即使它们大部分请求都是徒劳的。更关键的是锁一旦释放所有等待线程在下一个时间点同时冲上去抢锁形成“惊群效应”瞬间加锁压力飙升。更好的做法是让等待线程先“睡”等锁释放时收到通知再醒来抢锁。Redis 的 keyspace 通知机制可以做到这一点给 Redis 开启notify-keyspace-events参数让它监听某个 key 的删除事件锁释放时向频道发送一条消息等待锁的线程订阅这个频道收到消息后再尝试加锁。这种模型的CPU开销远小于自旋线程间也有天然的错峰效果。不过手写这套发布订阅逻辑并不轻松要考虑消息丢失、频道命名、连接管理等问题这也是很多团队最终选择 Redisson 的原因——Redisson 把发布订阅、信号量这些机制都封装好了。4.4 自旋锁的坑等待超时和CPU飙升如果在代码里直接写一个while (true)去尝试加锁很快你就会发现四个问题。第一没有等待超时限制锁永远不被释放时线程会无限循环连接池被耗尽整个应用线程阻塞。第二Thread.sleep(50)看似无害但大量线程并发自旋时Redis 客户端的请求量会暴涨有时你没把业务压力打垮先被自己的自旋把 Redis 打到了高延迟。第三锁刚释放的一瞬间多个线程同时加锁只有一个能成功其他线程白忙一轮CPU 空转非常严重。第四自旋间隔设置不合理太短会加剧压力太长则加锁等待时间不可控。我给自旋加了两个约束一个是用long endTime System.currentTimeMillis() acquireTimeout控制整个获取锁的时限超时就放弃不再无限等下去另一个是退避时间从一个随机区间取值而不是固定值比如 50 到 150 毫秒之间随机让各个线程错开尝试时间缓解惊群。这套办法在并发量不高的时候够用但并发一上去还是得靠发布订阅模型才能把问题解决好。5. 常见问题排查实录手写锁上线之后的排查和维护才是这门手艺真正考验人的地方。我把实际过程中遇到过的、以及给团队排查过的问题整理成一张速查表再挑几个典型展开说一下。现象根因解决方案锁被误删多个线程同时进入临界区释放锁没有校验持有者唯一标识释放锁走 Lua 脚本先比较 value 再删除业务执行完之前锁过期并发请求穿墙过期时间设置过短且没有续期机制增加看门狗线程自动续期主从切换后锁丢失互斥失效Redis 主从复制是异步的新主节点可能没有锁 key对一致性要求高的场景考虑 RedLock 或改用 ZooKeeper同一线程嵌套加锁直接死锁普通锁不可重入用哈希结构记录线程持有次数锁等待导致接口响应超时自旋等待时间过长或锁未释放设置获取锁超时时间完善释放锁的 finallyRedis 连接池被占满自旋循环中高频创建连接或等待线程过多复用连接池改用发布订阅模型5.1 误删别人刚获取的锁这个问题我在 2.3 里讲过原理这里说说排查过程。当时线上监控发现偶尔有两条请求同时对同一个订单做写操作加了锁还互斥失效。排查第一步是看 Redis 里锁 key 的 value 变化。我们当时用MONITOR命令跟踪了某个 key 的写入和删除记录发现删除操作来自一个已经超时结束的请求。到这里原因就清楚了我们释放锁的代码写的是无条件del没有校验 value。修复方式就是改成 Lua 脚本这也让我彻底理解了为什么锁的 value 必须唯一。这里提醒一句线上慎用MONITOR它会显著增加 Redis 的负载量大的时候可能把 Redis 压到告警。5.2 业务执行时间超过锁过期时间这个问题有两个典型表现。一种是锁自动过期第二个线程进来了第一个线程日志里却看不出任何异常只在最终数据上发现被写入了两次。另一种是看门狗线程写的续期逻辑有 bug把过期时间设置成了固定值而不是“当前时间过期时长”导致锁的实际 TTL 越续越小最终很快过期。我排查这类问题的方法比较简单粗暴在获取锁和释放锁的地方打日志记录拿到锁的时间、释放锁的时间以及看门狗每次续期的 Redis 时间把这些日志对齐到按请求维度去查就能看出锁的生命周期到底发生了什么。一般业务流程稍微复杂一点的系统锁自带续期是必须的否则任何一次慢 SQL 或外部调用超时都会成为定时炸弹。5.3 主从切换对锁的影响很多团队用 Redis 主从架构保证高可用但主从之间的复制是异步的。客户端在主节点上写入锁 key如果数据还没复制到从节点主节点就宕机了哨兵会发现并从节点提升为主节点这时候新的主节点上根本没有锁 key。所有客户端都能成功加锁互斥失效。这个问题的本质是 Redis 的复制机制不保证强一致任何基于普通 Redis 的分布式锁都有这个盲区。要解决它业界最知名的方案是 RedLock 算法同时向多个独立的 Redis 节点加锁超过一半成功才算加锁成功。但 RedLock 也有争议连 Redis 官方都曾经承认它在极端时钟漂移下并不绝对安全。我在实际业务里的态度是先去问这个锁守护的数据到底重要到什么程度。如果只是防重复提交、限流、保护缓存重建普通 Redis 锁加看门狗就够了如果涉及资金类强一致操作建议直接用 ZooKeeper 或者 etcd 这类本身就是为一致性设计的协调服务而不是在 Redis 上硬凑。5.4 嵌套调用导致自己等自己有一个印象很深的排查案例一个方法加了分布式锁方法内部又调用了另一个同样加锁的方法。因为当时用的是不可重入锁第二次加锁直接失败代码又没有做失败处理结果业务抛异常了。排查时翻日志只看到“lock acquire failed”完全没有方向。最后逐层打了断点才发现问题出在内部调用上。这类问题在代码设计上最好直接规避。优先避免在同一个线程里重复拿同一把锁比如把需要锁保护的公共逻辑拆成独立的内部方法外层统一加锁。如果确实有可重入需求就按 4.2 里的哈希结构来做不要心存侥幸。5.5 获取锁超时和接口响应超时还有一类常见问题是接口整体的响应时间变长。打开日志发现很多请求卡在“获取锁”这一步。这种情况通常是持锁线程执行时间过长或者持锁线程异常退出看门狗没有正常停止却把锁续期了。排查时先看锁的 TTL 是否被异常拉长再看业务线程是否长时间占用。我建议所有手写锁都提供两个基础参数获取锁超时时间和锁自动过期时间并且默认值要合理。获取锁超时设 3 到 5 秒过期时间设 30 秒大部分业务是够用的。6. 手写锁 vs Redisson以及我的最终建议6.1 Redisson 比我手写多做了哪些事情我不止一次想过既然 Redisson 功能全面为什么还要自己写答案是为了搞懂原理但搞懂之后不代表生产环境一定要手写。Redisson 的锁默认提供 30 秒的看门狗续期每 10 秒续一次锁释放后自动关闭续期线程它内部用发布订阅模型实现等待唤醒而不是靠 CPU 自旋它还实现了可重入锁、公平锁等多种模式。我手写的这些方案Redisson 基本都内置了而且工程化程度更高容错处理更完善。比如 Redisson 的解锁逻辑里考虑到了“解锁时如果看门狗还在续期要先取消续期再解锁”这种细节手写代码时很容易漏掉。再比如 Redisson 对 Redis 连接断开的处理、对命令超时的重试策略都是一整套成熟的设计。单纯从代码数量上看Redisson 的锁模块就有上万行这是经过大量生产环境检验的代码靠手写很难在短期内达到同等质量。6.2 什么时候手写什么时候必须用框架我现在的决策原则是分层处理。小项目、内部系统、并发量不高、锁守护的逻辑很简单手写一把 30 行的锁完全够用毕竟少引一个依赖维护成本更低。中大型项目特别是对锁的可靠性有要求、业务链路又长又复杂的场景直接用 Redisson 这类成熟框架不要自己造轮子。我在团队里一直强调手写锁最大的价值不是替代 Redisson而是让你在排查线上问题、评估框架源码时有底气。你见过SET NX EX就不会把 Redisson 当黑盒你写过 Lua 释放脚本遇到锁误删的问题就能第一时间想到 value 校验。6.3 我最后留下的几个习惯聊到收尾分享几个我坚持了很久的实操习惯。第一锁 key 的命名一定要规范我常用的格式是业务域:lock:资源ID比如order:lock:123456。命名规范不仅方便排查问题也方便在 Redis 里通过SCAN去统计锁的使用情况。第二requestId用UUID.randomUUID().toString()或者“IP线程ID时间戳”的组合保证全局唯一尽量不要复用。第三释放锁必须放在finally块里任何异常路径都不能绕过。第四锁的过期时间、续期间隔、获取锁超时时间三个参数都要显式配置不要写死魔法数。还有一个我每次写锁都会想起的教训锁不是一个孤立的技术点它和你对业务并发模型的理解深度绑定。你越清楚临界区里到底在保护什么就越明白过期时间、续期、可重入这些机制为什么存在。如果只是照着网上的代码背下来线上迟早会用一个故障帮你复习一遍。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →