尧图精选

手写Redis分布式锁:从SETNX到看门狗续期的完整实践

🕒 发布时间:2026/10/1 17:30:50 📁 来源:尧图网络
1. 项目概述为什么我要手写一把Redis分布式锁先交代背景。我负责的一个订单服务早期是单机部署用synchronized锁一个本地Map就能防住并发重复下单。后来业务量上来服务扩到三台锁立刻失效——三个JVM各自持有一把本地锁谁也拦不住谁。那种“线上突然出现一单卖了两份库存”的故障排查起来特别刺激。当时最直接的方案就是上分布式锁而市面上最顺手的载体就是Redis。所谓手写Redis分布式锁指的是不引入Redisson这类现成库自己基于Redis命令实现一套可靠的分布式互斥机制。网上铺天盖地的SETNX加过期时间那种写法严格来说只是“能用”距离“好用”还有距离。我花了一整天从最原始的版本一步步重构捋清了分布式锁的几个核心矛盾也踩了不少坑。这篇文章把我的完整推导过程、可落地的代码范式、以及排查问题的思路全部整理出来。适合哪些人看一种是面试前突击分布式锁原理的开发者特别是被问到“Redis分布式锁到底靠不靠谱”这类问题容易卡壳的另一种是生产环境已经用了分布式锁但遇到锁失效、锁误删、锁续期问题的同学。如果你正准备自己封装一个公共组件这篇文章可以直接作为设计蓝本。需要说明的是我的实现基于Spring Boot RedisTemplate命令层面的思路对任何语言都一样看的时候不必拘泥于Java。2. 从零推导让锁“立得住”的三个关键节点2.1 错误的开始只用SETNX最开始我的想法很简单Redis有个SETNX命令Key不存在时才设置成功。那给某条业务数据的关键字设一个Key谁设置成功了谁就拿到锁处理完业务再删掉Key释放锁天然互斥。Boolean success redisTemplate.opsForValue().setIfAbsent(lockKey, 1); if (Boolean.TRUE.equals(success)) { try { // 业务逻辑 } finally { redisTemplate.delete(lockKey); } }这段代码跑起来单测看着没问题但压测场景下很快暴露隐患如果业务逻辑执行途中服务宕机或者网络抖动导致删除锁的操作没能执行Redis里的Key一直存在这把锁就永远释放不了。后续所有请求全部阻塞到超时整个服务被一把锁拖死。这个问题在分布式环境里不是小概率事件而是必然会发生的事件。根源在于加锁和解锁之间缺少兜底机制。锁必须能在异常场景下自动失效不可能依赖业务代码“一定执行完毕”。2.2 原子性SETNX加EXPIRE两步走为什么不行解决上述问题最直观的思路是加过期时间。很多人会这么写Boolean success redisTemplate.opsForValue().setIfAbsent(lockKey, 1); if (Boolean.TRUE.equals(success)) { redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); // 业务逻辑 }这个版本的问题在于SETNX和EXPIRE是两次独立的Redis操作它们之间如果发生宕机还是会出现“锁设置了但没设过期时间”的经典故障。用生活场景类比你进厕所把门锁上了但挂上去的“有人”提示牌还没来得及翻转就停电了外面的人永远不知道里面有人。Redis官方其实早就给出了标准答案把SETNX和过期时间合成一条命令也就是SET key value NX EX timeout。这是分布式锁整个体系里最重要的一个原子性节点。没有原子性后续所有花里胡哨的设计都是空中楼阁。Boolean success redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS);Spring Data Redis的setIfAbsent重载方法底层就对应着SET NX EX语义一条命令搞定加锁和设过期时间。这是整个手写锁的基座。2.3 安全释放随机值加Lua校验不让锁被别人删掉继续想下一步上面代码里释放锁用的redisTemplate.delete(lockKey)是绝对安全的吗考虑一个时序线程A拿到锁执行任务。线程A执行时间较长锁在30秒后自动过期了。线程B此时拿到锁开始执行任务。线程A终于执行完执行delete(lockKey)把线程B持有的锁删掉了。结果就是线程B的锁凭空消失线程C也能进来互斥彻底失效。这个场景叫“误删别人的锁”是分布式锁事故的重灾区。正确的做法是给锁设置一个唯一的标识比如UUID释放前先比对当前锁的值是不是自己当初写进去的值只有匹配才删除。这个流程涉及“先查询再删除”两步如果分开执行中间又可能出现并发问题——查完了锁刚好过期另一线程拿了锁此时删除就会误删。因此“比对加删除”必须是一个原子操作Redis里用Lua脚本可以完美实现if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end进入else分支说明锁已经不属于自己或者已经过期不执行删除就对了。这招直接堵死了误删锁的漏洞也让我第一次意识到Redis的Lua脚本在分布式场景下的真正价值它把非原子的多步操作压缩成了Redis内部的一次原子执行。做到这里一个“能立得住”的分布式锁雏形已经有了原子加锁、过期兜底、安全释放。但还有一个大问题摆在我面前锁的过期时间到底设多长如果业务执行时间超过过期时间锁自动没了照样并发进来了。这个问题直接催生了后面的看门狗方案。3. 完整实现一个可上生产环境的锁3.1 设计概览与核心参数先定义清楚一个合格的分布式锁要满足哪些要求要求说明实现手段互斥性同一时刻只有一个线程持有锁Redis单Key原子写入原子性加锁、解锁操作不可拆分SET NX EX 和 Lua脚本安全性不误删他人锁随机唯一值校验容错性持有锁的线程崩溃后锁自动释放过期时间兜底续期能力业务未完成时自动延长锁有效期看门狗定时续期可重入性同一线程可多次获取锁ThreadLocal计数我在代码里定义了一个RedisLock工具类核心字段只有三个一个RedisTemplate、一个锁的Key、一个保持在线程局部变量里的唯一标识。设计上不做太多抽象锁本身就是个轻量对象每次获取锁时new一个实例把锁的Key和唯一值传进去即可。这里要特别提一下锁的Key命名规范。全局业务锁建议按lock:{业务模块}:{资源ID}的格式组织。比如库存扣减的每次操作锁住某个具体的商品ID而不是锁一个全局的lock:stock。锁粒度直接决定并发能力锁全局虽然安全但性能完全没法看锁到具体资源才能兼顾并发和正确性。这一条是我后来做压测才悟出来的靠锁范围大保平安等于把分布式锁用成了分布式串行化。3.2 加锁逻辑与失败重试加锁方法我命名为tryLock带一个超时时间和业务执行时长两个参数返回一个带锁标识的对象或null表示加锁失败。核心逻辑就一段public String tryLock(String lockKey, String requestId, long expireTime, TimeUnit unit) { Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireTime, unit); if (Boolean.TRUE.equals(success)) { return requestId; } return null; }requestId在这里相当重要它必须全局唯一。我直接用UUID.randomUUID().toString()也可以用“IP线程ID时间戳”拼接目的只有一个确保每个线程写入的锁值互不相同这样释放锁时才能靠它确认归属权。有人图省事直接传一个固定字符串等于把锁的归属逻辑废掉了后患无穷。加锁失败后的处理逻辑取决于业务形态。如果是一个定时任务竞争不到锁直接放弃本轮执行就行如果是高并发的秒杀扣减通常需要自旋重试。自旋要有上限不能无限循环。我见过一个项目里的写法是while(true)死等结果Redis抖动期间所有线程全部占满CPU服务整体卡死。合理的方式是封装一个带重试次数和重试间隔的获取方法public String tryLockWithRetry(String lockKey, String requestId, long expireTime, TimeUnit unit, int maxRetryTimes, long retryIntervalMs) { String token tryLock(lockKey, requestId, expireTime, unit); int retryCount 0; while (token null retryCount maxRetryTimes) { try { Thread.sleep(retryIntervalMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } retryCount; token tryLock(lockKey, requestId, expireTime, unit); } return token; }重试间隔需要考虑业务平均耗时。比如业务平均执行80毫秒重试间隔设在50到100毫秒比较合适既能快速抢占锁又不会对Redis造成频繁的写压力。毫无间隔的空转自旋是最差的选择。3.3 释放锁与看门狗续期释放锁的代码就是Lua脚本的Java封装private static final String UNLOCK_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public boolean unlock(String lockKey, String requestId) { Long result redisTemplate.execute( new DefaultRedisScript(UNLOCK_LUA, Long.class), Collections.singletonList(lockKey), requestId ); return Long.valueOf(1).equals(result); }关于续期这个问题先明确一点写过期时间的时候不要写一个“感觉差不多的值”。锁的过期时间应该大于业务预估最大耗时通常取平均耗时的3到5倍。但业务耗时本身就是个变量遇到慢SQL、上游接口超时执行时间照样会超过锁的过期时间。所以真正可靠的方案是动态续期也就是看门狗机制。看门狗的思路是拿到锁之后启动一个守护线程在锁快过期但业务没结束时定期给锁续期。初始过期时间设30秒守护线程每10秒执行一次续期把锁的有效期重置回30秒。业务执行完释放锁时把守护线程停掉。我用一个ScheduledExecutorService就能实现private ScheduledExecutorService watchdogExecutor; public void startWatchdog(String lockKey, String requestId, long expireTime, TimeUnit unit) { watchdogExecutor Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, redis-lock-watchdog- lockKey); t.setDaemon(true); return t; }); long initialDelay expireTime * 1000 / 3; long period expireTime * 1000 / 3; watchdogExecutor.scheduleAtFixedRate(() - { // 续期重新设置过期时间 redisTemplate.expire(lockKey, expireTime, unit); }, initialDelay, period, TimeUnit.MILLISECONDS); } public void stopWatchdog() { if (watchdogExecutor ! null) { watchdogExecutor.shutdownNow(); } }续期间隔取过期时间的三分之一是为了保证在锁过期前至少续期两次留足缓冲。比如30秒过期每10秒续一次最坏情况下锁过期前10秒也会得到一次续期。这里有一个关键设计点看门狗必须与锁的持有生命周期严格对应。获取锁成功后启动释放锁之前停止。如果续期的线程在业务执行完毕后还在跑会导致锁被无限续期引发新的死锁。我在封装时把看门狗的生命周期绑定在AutoCloseable接口上try-with-resources语法天然保证释放逻辑执行完后看门狗一定被关闭避免手动管理泄漏。try (RedisLock ignored RedisLock.acquire(redisTemplate, lockKey, expireTime)) { // 业务代码 }RedisLock.acquire内部完成加锁、启动看门狗close方法里完成释放锁、停止看门狗。这个模式在实际使用中最可靠也最不容易被误用。3.4 可重入性与ThreadLocal计数分布式锁的可重入性常被忽视。简单说同一个线程在持有锁的期间又执行到需要获取同一把锁的代码路径时如果不能重入就会自己死锁自己。我在实现里用了一个ThreadLocalHolder来记录线程当前持有的锁Key和重入次数private static final ThreadLocalMapString, Integer HOLD_LOCK_COUNTS new ThreadLocal(); public boolean reentrantTryLock(String lockKey, String requestId, long expireTime, TimeUnit unit) { MapString, Integer counts HOLD_LOCK_COUNTS.get(); if (counts ! null counts.containsKey(lockKey)) { counts.put(lockKey, counts.get(lockKey) 1); return true; } String token tryLock(lockKey, requestId, expireTime, unit); if (token ! null) { if (counts null) { counts new HashMap(); HOLD_LOCK_COUNTS.set(counts); } counts.put(lockKey, 1); return true; } return false; }释放时对应地递减计数减到零才真正执行解锁脚本。这个机制让锁支持同一个线程的嵌套调用同时又不会因为一次内层释放就把锁整个删掉。如果你的业务代码里存在A方法调用B方法、两个方法都获取同一把锁的情况可重入是必须实现的否则线上必然出现自己锁死自己的事故。4. 实战踩坑与排查实录4.1 过期时间拍脑袋的后果我第一次上生产时给锁设了10秒过期业务里有一个外部接口偶尔会走到15秒。结果那个月出现了两次并发异常排查下来都是锁提前过期第二个线程进来自动扣款库存变负数客诉电话直接打到技术部。事后我把锁的过期时间调到了30秒并且加了看门狗续期这才真正解决问题。这个教训我总结成一句话锁的过期时间不是用来约束业务执行时长的而是作为极端情况下的最终兜底。正常情况下应该靠看门狗续期保证锁不失效过期时间只负责“万一持有者彻底挂了锁也能自动消散”这个保底任务。所以过期时间设置反而不宜过短一般30秒起步。4.2 Redis连接池配置导致锁获取超时另一个坑出现在高并发场景。业务峰值时每秒有几千个请求同时尝试获取分布式锁而Redis连接池的maxTotal默认只有8。所有请求都在等待连接锁获取操作耗时从零点几毫秒劣化到几百毫秒连带业务超时。排查时Redis监控显示命令执行时间正常但调用端耗时曲线拉满显然是连接池瓶颈。调整方式不复杂把maxTotal提升到50到100maxIdle设置成30minIdle保持10以上同时开启testOnBorrow和testWhileIdle保证连接可用性。但也要结合Redis实例的CPU承受能力连接数动辄上百对单机Redis也是压力。这块需要压测验证不要盲目调大。4.3 Redis主从切换和时钟跳跃的边界业界关于Redis分布式锁最大的争议点是主从架构的锁丢失问题。场景是这样的线程A在主节点写入锁主节点还没来得及同步给从节点就发生宕机哨兵把从节点提升为主节点。新主节点里没有线程A的锁线程B就可以轻松获取同一把锁。著名的RedLock方案就是为了解决这个问题设计的但它需要向5个独立Redis实例同时加锁成本高且本身也有争议。我的看法是对于绝大多数业务场景Redis的可用性已经足够保障分布式锁的正确性。加锁操作走集群、主从同步开启wait 2这种参数后锁丢失的概率降到极低。如果业务对一致性要求极其苛刻比如涉及资金扣减不要依赖纯Redis锁改用数据库唯一索引或者ZooKeeper这类强一致组件更稳妥。设计系统时要把最坏情况想清楚而不是指望某个中间件全知全能。时钟跳跃对锁的影响主要在于过期时间计算。如果Redis服务器时钟向前跳变较大会导致当前时间超过Key的过期时间点锁立即失效。这个情况运维层面可以规避不做粗暴的时钟同步用NTP平滑校准。Redis节点也不建议自动校时到秒级精度这个属于基础设施细节但确实会直接影响锁的稳定性。4.4 从手写锁到Redisson的正确姿势我折腾完这套手写锁后其实又花了一晚上读过Redisson的源码。Redisson的分布式锁核心设计和手写版本思路高度一致同样是SETNX加过期时间同样用Lua脚本保证原子性不同之处在于它的看门狗机制更加完善还支持可重入、公平锁、红锁等多种模式。生产环境如果想少踩坑直接引入Redisson肯定是更稳妥的选择。那手写还有没有价值有。Redisson的看门狗续期默认30秒刷新一次如果你不理解续期的原理遇到锁不释放的问题依然无从下手。面试时被问“分布式锁的实现原理”如果你只能答出“Redisson封装了Redis的SETNX命令”和能画出一整套自研锁的设计图谱深度完全不同。手写一遍锁最大的收获是你终于能读懂中间件的设计意图排查问题也更有底气。5. 写在最后再分享一点实践经验有人可能会问既然Redisson都这么成熟了为什么团队里还有人要手写锁我的理解是锁这个组件太底层越底层的东西越不能完全黑盒使用。你至少得知道它的失败模式什么时候会失效失效之后会对业务造成什么影响。手写的过程不是为了生产环境里替换Redisson而是把分布式锁的优缺点彻底搞清楚。一点实践建议如果你决定在团队里自研分布式锁一定不要把锁的逻辑散落在业务代码里。抽成公共组件、提供统一的注解或模板方法、加好监控埋点比手写本身重要得多。日志里要有加锁耗时、释放耗时、锁等待次数这些指标否则出了问题只能靠猜。最后说一个细节技巧释放锁时无论业务成功还是异常都要在finally里执行。这点看起来简单但我在无数生产事故里见过try块写了一段业务、锁的释放放在try块最后一行的情况业务一旦抛异常直接跳过释放轻则锁等到过期重则连锁引发雪崩。try-with-resources或者finally释放这条是硬性铁律没有任何例外。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →