news 2026/10/14 1:29:11

Redis分布式锁避坑指南:从SETNX到Redlock的演进与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis分布式锁避坑指南:从SETNX到Redlock的演进与实战

1. 从一次库存超卖事故说起:为什么你写的Redis锁可能根本锁不住

去年帮一个做社区团购的朋友排查线上问题,场景很典型:晚高峰时段,某款特价商品库存明明只有200份,最终却成交了237单,多出来的37单全部要平台自己贴钱发货。翻日志发现,扣减库存的接口在并发峰值时,多个请求同时读到了相同的库存值,各自判断"还有货"然后各自扣减。代码里其实加了锁——用Redis的SETNX加EXPIRE两步操作实现的分布式锁。问题就出在这两步上:SETNX成功之后、EXPIRE执行之前,如果服务实例恰好发生GC停顿或者网络抖动,这把锁就永远不会过期,后续所有请求全部阻塞;而如果为了避免死锁把过期时间设得太短,业务还没执行完锁就自动释放了,第二个线程趁虚而入,超卖就这么发生了。

这个案例几乎涵盖了Redis分布式锁在实际生产中最容易踩的几类坑:原子性缺失、锁误删、过期时间与业务耗时不匹配、可重入性缺失、以及集群环境下主从切换导致的锁失效。很多人以为分布式锁就是一句SETNX的事,但真正要把它写对、写稳,需要理解Redis的底层命令语义、Java客户端的API行为、以及分布式系统本身的一致性边界。

这篇内容面向的是已经用过Redis、准备或者正在项目里落地分布式锁的Java后端开发。我会从最朴素的实现讲起,一步步推到生产可用的方案,把每一步"为什么要这么改"讲透,最后给出可以直接抄进项目的代码骨架和一套压测验证方法。中间会穿插我自己踩过的坑和排查思路,尽量让你少走弯路。

2. 三种加锁写法的演进:从SETNX到SET NX EX的原子性之争

2.1 最原始的SETNX + EXPIRE:为什么它一定会出问题

早期网上流传最广的写法是这样的:

// 危险写法,不要在生产使用 Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, value); if (Boolean.TRUE.equals(ok)) { redisTemplate.expire(key, 30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { redisTemplate.delete(key); } }

这段代码的问题不在于逻辑,而在于两条命令之间存在时间窗口。setIfAbsent和expire是两次独立的网络往返,Redis服务端不保证它们原子执行。如果第一条命令返回成功后客户端崩溃、或者网络在两次命令之间断开,这个key就会永久存在,形成死锁。有人会说"我加个try-catch不就行了",但崩溃和断电是catch不住的。

从Redis 2.6.12版本开始,SET命令支持了NX和EX选项,可以一条命令同时完成"不存在则设置"和"设置过期时间"两个动作。这是官方推荐的唯一正确姿势,因为它把原子性交给了Redis服务端保证。

2.2 SET NX EX:一条命令解决原子性

改造后的写法:

String result = redisTemplate.execute((RedisCallback<String>) connection -> { return connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(30), RedisStringCommands.SetOption.ifAbsent()); });

或者用更简洁的StringRedisTemplate配合setIfAbsent的重载方法(Spring Data Redis 2.1+):

Boolean ok = stringRedisTemplate.opsForValue() .setIfAbsent(key, value, 30, TimeUnit.SECONDS);

这里有个细节值得展开:为什么value不能随便写,必须是一个唯一标识?因为释放锁的时候,你要确保删的是自己加的锁,而不是别人的。设想线程A加锁后业务执行超时,锁自动过期,线程B拿到锁开始执行;此时线程A执行完毕,直接DEL key,就把线程B的锁给删了。这就是经典的锁误删问题。解决办法是value存一个当前线程/请求的唯一ID(比如UUID+线程ID),删除前先比对。

2.3 释放锁的Lua脚本:比对与删除必须原子

比对和删除如果分成两步(先GET判断再DEL),同样存在时间窗口:判断通过之后、删除之前,锁刚好过期被别的线程拿到,你删的还是别人的锁。所以释放锁必须用Lua脚本,让Redis服务端一次性完成"读取-比对-删除":

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 key, String requestId) { Long result = stringRedisTemplate.execute( new DefaultRedisScript<>(UNLOCK_LUA, Long.class), Collections.singletonList(key), requestId); return result != null && result == 1L; }

Lua脚本在Redis中是单线程原子执行的,中间不会插入其他命令,这就彻底堵死了误删的窗口。我见过不少团队用redisTemplate.delete直接删,测试环境并发低看不出问题,一上生产就偶发数据错乱,排查起来非常费劲。

注意:Lua脚本里比对value时用的是==,Redis的Lua环境对字符串比较是安全的,但如果你value里存的是数字字符串,要小心类型转换带来的意外,建议统一用UUID这种纯字符串。

3. 过期时间这道坎:业务没跑完锁就没了怎么办

3.1 固定过期时间的根本矛盾

锁的过期时间设多长,是个两难:设短了,业务没执行完锁就释放,并发问题重现;设长了,万一持锁进程崩溃,其他线程要干等很久。更麻烦的是,业务耗时往往不是固定的——同一个接口,数据量小的时候50毫秒,数据量大的时候可能3秒,你没法用一个固定值覆盖所有情况。

我见过有团队把过期时间设成30秒,结果某个批量操作跑了35秒,锁提前释放,两个线程同时操作同一批数据,最后对账对了一整天。也见过设成5分钟的,结果一个实例OOM挂掉,那把锁5分钟内谁也拿不到,整个下单链路堵死。

3.2 看门狗续期机制:让锁跟着业务走

解决思路是自动续期:加锁成功后,启动一个后台定时任务,每隔一段时间(通常是过期时间的1/3)检查业务是否还在执行,如果在执行就把锁的过期时间重置。这样锁的生命周期就跟着业务走,业务没结束锁就不会过期,业务一结束(或进程崩溃)续期任务停止,锁在过期时间后自动释放。

Redisson这个客户端内置了这套机制,叫"看门狗"(watchdog)。默认情况下,如果你用lock()不加参数,它会用30秒作为默认过期时间,然后每10秒续期一次。核心逻辑简化后大概是这样:

// 伪代码,展示续期思路 private void scheduleRenewal(String key, String requestId, long leaseTime) { long renewInterval = leaseTime / 3; scheduler.scheduleAtFixedRate(() -> { if (isLockHeld(key, requestId)) { // 只有还持有锁才续期 stringRedisTemplate.expire(key, leaseTime, TimeUnit.MILLISECONDS); } }, renewInterval, renewInterval, TimeUnit.MILLISECONDS); }

这里有个关键点:续期前必须确认锁还在自己手里。如果锁已经被别人拿走了(比如发生了主从切换或者自己长时间GC),你还去续期,就会把别人的锁的过期时间改掉,造成更严重的混乱。Redisson的续期逻辑里会先判断当前锁的持有者是不是自己。

3.3 续期带来的新问题:GC停顿与时钟漂移

看门狗不是银弹。如果JVM发生长时间的Full GC(比如超过一个续期间隔),续期任务可能来不及执行,锁就过期了。这时候业务线程从GC中醒来,以为自己还持有锁,继续操作共享资源,而实际上锁已经被别人拿走了。这种情况在堆内存大、GC参数不合理的服务上并不罕见。

另一个容易被忽略的是时钟漂移。Redis的过期时间是按服务端时间算的,如果Redis所在机器和业务机器时间不同步,或者Redis主从之间时间不一致,过期判断就可能出现偏差。生产环境务必保证所有节点开启NTP时间同步,这是基础设施层面的要求,不是代码能解决的。

实操建议:对于耗时可能超过10秒的业务,不要依赖固定过期时间,要么用Redisson的看门狗,要么自己在业务代码里显式续期。同时监控JVM的GC停顿时间,如果频繁出现超过1秒的停顿,先解决GC问题再谈分布式锁。

4. 可重入与锁等待:同一线程重复加锁为什么会死锁

4.1 不可重入锁的尴尬场景

假设一个下单接口,外层方法加了锁,内层调用的某个公共服务方法也加了同一把锁。如果锁不可重入,内层方法会发现自己拿不到锁(因为外层已经持有了),于是要么阻塞等待(自己等自己,永久死锁),要么直接失败返回。这在业务代码分层调用时非常常见。

可重入的实现思路是:在Redis里用Hash结构存储锁,field是持有者的唯一标识,value是重入次数。加锁时如果发现锁的持有者就是自己,就把重入次数加1;解锁时把次数减1,减到0才真正删除key。

// 可重入加锁的Lua脚本 private static final String REENTRANT_LOCK_LUA = "if redis.call('exists', KEYS[1]) == 0 then " + " redis.call('hincrby', 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";

对应的解锁脚本也要相应调整,判断field存在则减1,减到0再删key。这套逻辑Redisson已经封装好了,直接用RLock就行,没必要自己造轮子——除非你有特殊的定制需求。

4.2 锁等待:自旋还是订阅

拿不到锁的时候,是立刻返回失败,还是等待一段时间?这取决于业务语义。如果是"防止重复提交",拿不到锁直接返回"操作进行中"更合理;如果是"保证顺序执行",就需要等待。

简单的自旋等待(循环重试)实现容易,但会消耗CPU和Redis的QPS,高并发下可能把Redis打满。更好的方式是基于Redis的发布订阅:拿不到锁的线程订阅一个频道,持锁线程释放锁时往频道发消息,等待的线程收到通知后再去抢。Redisson的lock()默认就是这种机制,比自旋优雅得多。

不过订阅机制也有代价:每个等待的线程都要占用一个Redis连接(订阅会阻塞连接),如果等待的线程很多,连接数会暴涨。所以生产上通常会给等待设置一个超时时间,超时还没拿到就放弃,避免无限堆积。

4.3 tryLock的参数怎么设

Redisson的tryLock有三个参数:等待时间、持有时间、时间单位。我的经验是:

  • 等待时间:根据业务能容忍的延迟来定,一般设1到3秒。设太长会导致请求堆积,设太短会频繁失败。
  • 持有时间:如果设了,看门狗就不生效了(因为你知道业务最多跑多久)。如果业务耗时不确定,就不要传这个参数,让看门狗自动续期。
  • 不要用无参的lock():它会一直等待,一旦出现死锁就是灾难。永远用带超时的tryLock。
RLock lock = redissonClient.getLock("order:lock:" + orderId); boolean acquired = false; try { acquired = lock.tryLock(2, TimeUnit.SECONDS); if (!acquired) { throw new BizException("操作太频繁,请稍后重试"); } // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } }

注意finally里的isHeldByCurrentThread()判断,这是防止解锁时抛IllegalMonitorStateException的关键。如果锁已经因为超时被自动释放,你再去unlock就会报错。

5. 集群与主从切换:Redlock到底该不该用

5.1 主从架构下的锁失效

单机Redis的锁是可靠的,但生产环境Redis通常是主从+哨兵或者Cluster模式。问题来了:客户端往主节点写锁,主节点返回成功,但在锁同步到从节点之前主节点宕机了,哨兵把从节点提升为新主节点。新主节点上没有这把锁,另一个客户端就能成功加锁,于是两个客户端同时持有锁。

这个问题的本质是Redis的异步复制。主节点写成功不代表从节点收到了,主从切换会丢失最近写入的数据。对于分布式锁这种要求强一致性的场景,这是致命的。

5.2 Redlock算法及其争议

Redis作者antirez提出了Redlock算法:部署N个独立的Redis主节点(注意是独立的,不是主从关系),客户端依次向所有节点申请锁,如果在多数节点(N/2+1)上成功且总耗时小于锁的有效期,就认为加锁成功。释放时向所有节点发送释放请求。

这个算法看起来解决了单点问题,但分布式系统领域的学者Martin Kleppmann提出了尖锐质疑:Redlock依赖各节点的时间假设,如果某个节点发生时钟跳跃,或者GC停顿导致请求延迟,安全性就无法保证。而且Redlock没有 fencing token 机制,即使锁本身没问题,持有锁的客户端在暂停后恢复,也可能在锁已过期的情况下继续操作资源。

我的实际建议是:大多数业务场景不需要Redlock。如果你的业务对一致性要求极高(比如金融交易),应该用带 fencing token 的方案,或者直接用ZooKeeper/etcd这类基于共识协议的协调服务。如果只是一般的防重复、防并发,单主Redis加合理的过期时间就够了,主从切换导致的锁失效概率极低,且影响可控。

5.3 不同方案的选型对照

方案一致性保证性能实现复杂度适用场景
单机Redis SET NX EX单点可靠,主从切换有风险极高低一般业务防并发
Redisson看门狗同单机,自动续期高低业务耗时不确定
Redlock多节点较高,但有时钟依赖中高对一致性要求高的场景
ZooKeeper临时节点强一致(ZAB协议)中中金融级、配置管理
etcd lease强一致(Raft协议)中中云原生、K8s生态

选型的核心是问自己:锁失效的后果有多严重?如果只是多卖几单、多发几条消息,单机Redis足够;如果涉及资金、库存的精确扣减,要么上强一致方案,要么在业务层做幂等和对账兜底。

6. 生产落地的完整代码骨架与压测验证

6.1 一个可直接复用的工具类

把前面的要点整合起来,给出一个基于Spring Data Redis的工具类骨架(不依赖Redisson,方便理解原理):

@Component public class RedisDistributedLock { private final StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX = "lock:"; private static final long DEFAULT_LEASE_MS = 30_000L; public RedisDistributedLock(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public String tryLock(String bizKey, long waitMs, long leaseMs) { String key = LOCK_PREFIX + bizKey; String requestId = UUID.randomUUID().toString(); long deadline = System.currentTimeMillis() + waitMs; do { Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, requestId, leaseMs, TimeUnit.MILLISECONDS); if (Boolean.TRUE.equals(ok)) { return requestId; } try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } while (System.currentTimeMillis() < deadline); return null; } public boolean unlock(String bizKey, String requestId) { String key = LOCK_PREFIX + bizKey; Long result = redisTemplate.execute( new DefaultRedisScript<>(UNLOCK_LUA, Long.class), Collections.singletonList(key), requestId); return result != null && result == 1L; } }

使用时的标准姿势:

String requestId = lock.tryLock("stock:" + skuId, 2000, 30000); if (requestId == null) { throw new BizException("系统繁忙,请重试"); } try { // 扣减库存等业务逻辑 } finally { lock.unlock("stock:" + skuId, requestId); }

6.2 压测验证:怎么确认锁真的生效了

写完代码不代表就对了,必须压测验证。我的做法是写一个并发测试:启动200个线程,同时对一个共享计数器做加锁自增,最后检查结果是否等于200。如果不等于,说明锁没生效。

@Test public void testLockConcurrency() throws Exception { int threads = 200; AtomicInteger counter = new AtomicInteger(0); CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { String rid = lock.tryLock("test:counter", 5000, 10000); if (rid != null) { try { int v = counter.get(); Thread.sleep(1); // 模拟业务耗时 counter.set(v + 1); } finally { lock.unlock("test:counter", rid); } } } catch (Exception ignored) { } finally { latch.countDown(); } }); } latch.await(60, TimeUnit.SECONDS); pool.shutdown(); assertEquals(threads, counter.get()); }

这个测试能跑通,说明基本的互斥性没问题。但还要测锁误删:故意让一个线程持有锁超过过期时间,看它解锁时会不会删掉别人的锁。以及测主从切换:手动kill主节点,观察锁的行为是否符合预期。

6.3 监控与告警:锁的持有时间和等待队列

生产环境必须监控两个指标:锁的平均持有时间和锁的等待超时率。如果平均持有时间接近过期时间,说明业务耗时在增长,需要调整过期时间或者优化业务;如果等待超时率突然升高,说明有热点key竞争激烈,或者有线程持锁不释放。

我通常会在加锁和解锁的地方埋点,把耗时上报到监控系统。另外,Redis的INFO命令里能看到连接数、命令执行数等指标,如果发现EVALSHA(Lua脚本执行)的QPS异常高,可能是自旋等待太频繁,要考虑换成订阅机制。

一个容易被忽略的点:解锁失败(返回0)也要告警。正常情况下解锁应该返回1,如果频繁返回0,说明锁已经过期被别人拿走了,业务可能存在并发风险,需要立即排查。

7. 几个我踩过的坑和对应的排查思路

第一个坑是序列化方式不一致。用RedisTemplate默认的JDK序列化存key,用StringRedisTemplate去读,读出来是乱码,比对永远不相等,锁永远删不掉。解决办法是统一用StringRedisTemplate,或者显式配置StringRedisSerializer。这个问题在测试环境因为key少不容易发现,上线后key一多就暴露了。

第二个坑是Lua脚本的KEYS和ARGV传参。Spring Data Redis的execute方法,脚本里用KEYS[1]取的是Collections.singletonList(key)里的值,ARGV[1]取的是后面可变参数的值。如果传参顺序搞反了,脚本逻辑就全乱了。我建议在脚本里加注释标明每个参数的含义,团队协作时不容易出错。

第三个坑是锁的粒度。一开始我把锁加在整个方法上,粒度太粗,导致不同商品的库存扣减互相阻塞,QPS上不去。后来改成按商品ID加锁,并发度立刻提升。锁的粒度要尽可能细,只锁真正需要互斥的资源,不要图省事锁一个大范围。

第四个坑是异常处理。业务代码抛异常时,如果finally块里解锁又抛异常,会掩盖原始异常。正确的做法是在finally里用try-catch包住解锁逻辑,记录日志但不抛出,保证原始异常能正常向上传播。

排查锁相关问题的通用思路是:先看日志里加锁和解锁的requestId是否配对,再看Redis里key的实际过期时间,最后用MONITOR命令(仅限测试环境)观察实际的命令序列。大部分问题都能通过这三步定位。

8. 写在最后的一点个人体会

分布式锁这个东西,看起来简单,用起来处处是细节。我的经验是:能用业务幂等解决的,就不要用分布式锁。比如扣减库存,与其加锁串行化,不如用UPDATE stock SET count = count - 1 WHERE count >= 1这种数据库层面的原子操作,性能更好,也没有锁失效的风险。分布式锁应该用在那些确实无法用单条原子操作表达、且并发冲突概率不高的场景。

如果一定要用,优先选Redisson这种成熟客户端,不要自己手写。自己写的锁,测试环境跑得再好,生产环境的网络抖动、GC停顿、主从切换这些边界情况,很难全部覆盖到。Redisson经过大量生产验证,看门狗、可重入、订阅等待这些机制都帮你处理好了,省下来的时间用来优化业务逻辑更划算。

最后提醒一句:任何分布式锁方案都不能保证100%的安全,网络分区、时钟问题这些分布式系统的固有难题,不是加个锁就能绕过去的。关键业务一定要有对账和补偿机制作为最后一道防线。锁是提高并发正确性的手段,不是保证绝对正确的银弹。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:28:59

高仿麻将小程序源码拆解:胡牌算法与洗牌逻辑实战

简介&#xff1a;这是一套高仿麻将游戏的小程序源码&#xff0c;附带一份通俗易懂的详细教程&#xff0c;主要面向微信小程序开发者、前端初学者和游戏编程爱好者&#xff0c;目标是帮助读者从零掌握麻将游戏小程序的完整构建方法&#xff0c;包括项目结构规划、界面搭建、规则…

作者头像 李华
网站建设 2026/10/14 1:28:43

磁盘调度算法实验:FCFS、SSTF、SCAN原理与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:28:39

树莓派Qt工程DHT11温湿度采集实战源码与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:28:25

长寿科学:健康寿命延长的生物学机制与生活方式

1. 长寿的本质&#xff1a;从科学认知到生活实践《超越百岁&#xff1a;长寿的科学与艺术》这本书彻底颠覆了我对"长寿"的认知。过去我以为长寿就是活得久&#xff0c;但书中揭示了一个更深刻的真相&#xff1a;真正的长寿是健康寿命的延长&#xff0c;而非单纯的时间…

作者头像 李华
网站建设 2026/10/14 1:28:24

地宫取宝题解:记忆化搜索与动态规划的经典结合

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:28:21

碳化硅全栈生态:从衬底到应用,跨越硅基时代的裂谷

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华