news 2026/9/13 18:36:30

多实例并发下的Redis分布式锁:从setnx到Redisson的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多实例并发下的Redis分布式锁:从setnx到Redisson的完整实践

去年接手过一个订单支付系统,流量一上来就出问题:同一笔订单被两个实例同时处理,结果产生了多次扣款,用户投诉直接炸群。排查到最后,根子就落在“多实例并发访问共享资源”这件事上——当时用的那套所谓分布式锁,实现太粗糙,既没有防误删,也没有处理锁过期的问题。后来我把整套方案从手写setnx升级到Redisson,才算真正稳住。这中间踩过的坑和最后掌握的方案,值得好好梳理一遍。

如果你也正面临多实例部署下需要互斥访问共享资源的场景,或者马上要准备面试想系统搞懂基于Redis的分布式锁,这篇文章可以帮你少走不少弯路。我会从最基础的setnx实现讲起,到Redisson的完整接入,再到生产环境和面试中的高频问题,一层层拆开讲清楚。

1. 先想明白:什么场景必须上分布式锁

1.1 单机锁管不到的另一台机器

很多同学第一次接触分布式锁,是因为面试题背过“分布式锁保证互斥”,但到了真实业务里,反而不知道怎么判断“该不该用”。

先看一个最典型的问题:JVM里的synchronized和ReentrantLock到底在锁什么?它们锁的是同一个JVM进程内的对象和线程。单个服务实例下,多线程同时扣减库存,用synchronized加在方法上就能保证不超卖。但一旦服务做了多实例部署,前面挂了负载均衡,同一个用户请求可能被分到实例A,另一个请求被分到实例B。这时候,实例A的锁和实例B的锁是两个完全独立的对象,谁也管不了谁。所以即使每个实例内部都加了synchronized,两个实例同时读到库存=1,又同时扣减成0,一样会超卖。

分布式锁解决的就是这个“跨进程互斥”的问题。多个服务实例去同一个地方(Redis、ZooKeeper、etcd等)抢占一个只有一匹马能占到的位置,谁抢到了谁才能往下执行,执行完再释放,让别人抢。这样就把原本分散在各实例内的“本地互斥”升级成了全局统一的“公共互斥”。

但注意,并不是所有多实例场景都需要分布式锁。如果共享资源本身有原子性保障,比如数据库行锁、Redis单命令的原子性(INCR、SETNX),直接用这些原子能力往往比引入分布式锁更简单可靠。分布式锁真正的价值场景,是“一段业务逻辑”需要整体串行化,比如“先查账户余额,再冻结,再扣减”这类多步操作,单靠一个数据库原子操作做不完整,才需要一把跨节点的锁把整段逻辑保护起来。

1.2 从订单支付场景看锁的边界

我用一个自己实际做过的订单支付场景说明。订单服务部署了3个实例,用户提交支付请求后,服务要做这几件事:

  1. 查询订单当前状态,确认是否未支付。
  2. 调用第三方支付渠道创建支付单。
  3. 支付回调到达后更新订单状态为已支付。

问题出在“查询订单状态”和“更新订单状态”之间。如果同一个订单的两个支付请求几乎同时到达,实例A查到订单未支付,实例B也查到订单未支付,两个实例都去调用支付渠道,那么用户可能被扣两次钱。用锁的话,应该以“订单号”作为锁的key,在“查询订单状态→创建支付单”这段逻辑外面加锁,保证同一时间只有一个实例能处理同一条订单。

锁的粒度也要注意。上面场景的key是order:pay:{orderId},也就是每笔订单一把锁。如果图省事用一个全局keyorder:pay:all,那所有订单的支付操作都会互相排队,性能会非常差。锁粒度越大,互斥范围越广,可用性越差。反之粒度太小,比如用userId做key,同一个用户的不同订单之间也会互相阻塞,但还能接受;如果用orderId,互斥范围就控制得刚刚好。

所以,判断要不要用分布式锁,基本看三件事:

  • 是否存在多个实例同时操作同一份共享资源。
  • 操作是否是多步骤组合,单靠原子指令无法保证完整。
  • 是否允许一定的锁等待和锁开销(Redis锁通常也就几毫秒的额外开销)。

如果三个条件都满足,就可以考虑上分布式锁了。

2. Redis分布式锁的两种主流写法:手写setnx与Redisson

2.1 setnx加锁配合Lua释放锁的基础实现

最早实现Redis分布式锁,大家用的是SETNX命令。SETNX全称是“SET if Not eXists”,只有key不存在时才能设置成功。放到锁场景里,key就是锁的名称,value就是持有者的标识。谁设置成功,谁就拿到了锁。

命令大概是这样的:

SET order:pay:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000

这条指令包含了几个关键参数:

  • NX:只有key不存在时才设置,保证互斥。
  • PX 30000:设置key的过期时间为30秒,防止持有锁的实例宕机后死锁。
  • value550e8400-...:一个随机字符串,用于释放锁时校验“这把锁是不是我的”。

释放锁不能直接用DEL,因为假如线程A的锁到期被Redis自动清掉了,线程B加锁成功,此时线程A执行完业务再去DEL,就会把线程B的锁删掉,造成锁失效。所以释放锁必须做校验:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段Lua脚本会先取key的值,跟传入的value比较,相等才删除。因为整个判断和删除在Lua脚本里是原子执行的,所以不存在中间被其他线程插队的可能。这里的关键是value必须是每个线程唯一的随机数,一般用UUID或者“UUID+线程ID”拼出来。

用Java的话,不引入第三方组件也能写出一个可用版:

public class RedisLock { private StringRedisTemplate redisTemplate; private String lockKey; private String lockValue; private long expireMillis; public boolean tryLock(String lockKey, String lockValue, long expireMillis) { Boolean success = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, expireMillis, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(success); } public void unlock(String lockKey, String lockValue) { 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), lockValue); } }

这套方案能解决基础互斥和防误删,但问题也不少。比如同一线程重入时要额外维护ThreadLocal计数;锁到期了业务还没执行完,Redis把锁删了,另一个线程就能进来,造成并发冲突;还有获取锁的线程如果只是简单轮询,在高竞争下会浪费大量请求。这也是为什么生产环境越来越多的人直接用Redisson。

2.2 Redisson如何解决重入、续期和阻塞等待

Redisson是Redis官方推荐的Java客户端之一,它把分布式锁封装成了类似JUC锁的接口,用过ReentrantLock的人几乎零成本上手。

先说它怎么解决“可重入”。Redisson的锁不是用普通String类型存储,而是用了Hash结构。key是锁名称,field是“UUID+线程ID”的唯一标识,value是这个线程获取锁的次数。每次重入,value加1;每次释放,value减1;减到0才真正删除key。这样同一个线程就可以在持有锁的情况下反复进入加锁的代码块,不会自己把自己锁死。

再说“自动续期”。默认情况下,Redisson获取锁后会给锁设置30秒的过期时间,但这不是一个固定值,而是通过一个定时任务来续期。这个定时任务叫watchdog,它会每隔10秒检查一次,如果锁还持有在当前线程手里,就刷新过期时间为30秒。换句话说,只要业务线程不结束,锁就不会因为超时被Redis主动删除。这个设计解决了“业务执行时间超过锁过期时间”的经典问题。

当我们不指定锁的leaseTime时,watchdog才生效;如果自己在tryLock方法里传了leaseTime,Redisson就不会额外续期,锁会在指定的时间后强制释放。这点后面参数调优时还会细说。

tryLock还支持等待时间。比如:

RLock lock = redissonClient.getLock("order:pay:" + orderId); if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 拿锁成功 } else { // 3秒没抢到,走降级 }

第一个参数是获取锁的等待时间,最多等3秒;第二个参数是锁的自动释放时间,10秒后自动释放。如果传(-1, 10, TimeUnit.SECONDS)或者不传等待时间,会进入阻塞等待模式。这里有个隐藏的细节:只有传了leaseTime时,代码执行时间超过10秒锁就会自动释放;如果你希望业务能灵活跑完,就不传leaseTime,让watchdog兜底。

3. 代码能跑不等于能上生产:这些坑我逐个踩过

3.1 value随机串与防误删的Lua脚本

我最早手写的那版锁,value直接写死一个字符串"1",然后释放锁的时候也直接DEL,当时没出事纯粹是运气好。后来在一个抢券场景就翻车了:线程A处理超时,锁到期自动释放;线程B拿到锁开始处理;这时候线程A终于执行完,一个DEL把线程B的锁删了;线程C又趁机拿到锁,三个线程同时跑同一段逻辑,券的库存直接变成负数。

这个问题的根因就是两个,一是value不唯一,二是删锁没校验。正确做法前面已经说了,value必须用UUID + 线程ID这样的全局唯一字符串;删锁必须用Lua脚本先比较后删除,不能用DEL一步到位。

还有一个容易忽略的点:Lua脚本本身也要求每次传的KEYSARGV正确。释放锁时KEYS[1]必须是锁key,ARGV[1]必须是加锁时存入的value。如果value拼错了,脚本会返回0,锁删不掉,最终只能等超时释放。所以加锁时和释放时一定要从同一个地方取值,不要把value放在方法参数里随手传一个。

3.2 看门狗自动续期:你以为的锁超时其实不是超时

Redisson的watchdog默认续期逻辑能解决业务超时问题,但它本身也有坑。先说工作机制:当不指定leaseTime时,Redisson会给锁设置一个默认的lockWatchdogTimeout,默认30秒。后台会有一个Netty定时任务,每过lockWatchdogTimeout / 3也就是10秒,检查一次锁是否还被当前线程持有,如果持有就重新设置过期时间为30秒。只要线程没结束,锁就会一直续期。

但这带来两个问题。第一,如果你在业务代码里显式指定了leaseTime,watchdog就完全不生效。很多人在lock.tryLock(3, 30, TimeUnit.SECONDS)里写了leaseTime=30秒,结果数据库慢查询导致业务跑了50秒,锁在第30秒就已经被Redis删除,另一个线程进来了。这时候还以为是锁丢了,其实是自己把watchdog关掉了。所以,如果业务执行时间不能精确预估,就别传leaseTime,让watchdog扛着。

第二,watchdog续期是通过后台线程异步执行的,如果持有锁的线程发生长时间GC(Full GC造成STW),后台线程也会停,锁可能会在GC期间到期被释放,然后被其他线程抢到。这个问题在JVM场景下非常隐蔽。Redisson的续期检测和业务代码在同一个JVM里,JVM都停了也没法续期。真实场景中概率不高,但如果你做的是金融支付这类强一致业务,需要认真考虑是否接受这个风险。

3.3 主从切换锁丢失:没有银弹

Redis主从架构下,数据同步是异步的。客户端A在主节点上写入了锁key,主节点还没把数据同步给从节点就发生故障,哨兵把从节点提升为主节点。此时客户端B可以去新的主节点上尝试加锁,因为锁数据根本没同步过来,B会加锁成功。结果就是A和B同时拿到了同一把锁,分布式锁的互斥性被打破。

社区里对这个问题的经典回应是RedLock算法:向多个独立的Redis节点依次加锁,只有超过半数节点加锁成功才算真正拿到锁。但RedLock本身也有争议,主要在于它依赖所有节点的时间推进一致,并且客户端在某节点加锁成功后如果阻塞太久,锁也会提前失效。实际部署RedLock也不简单,需要至少5个独立的Redis实例。

我的经验是,先看清业务对一致性的要求。如果是缓存、防重这类允许极小概率冲突的业务,单机Redis + Redisson锁完全够用,不必上RedLock。如果是金融支付、库存强扣减这类绝对不允许并发覆盖的场景,与其硬用Redis锁,不如直接用数据库唯一索引 + 乐观锁,或者用ZooKeeper/etcd这类CP模型组件做分布式锁。锁方案的选型本质上是一次“可用性”和“一致性”的取舍,不存在一个方案在所有场景下都是最优的。

4. Spring Boot接入Redisson的完整步骤与参数调优

4.1 依赖引入和可复制的配置类

如果你的项目是Spring Boot,最省事的方案是用redisson-spring-boot-starter。以Spring Boot 2.x为例,Maven引入:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency>

然后在application.yml里配置连接信息:

spring: data: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 password: null database: 0 threads: 16 nettyThreads: 32

如果你更习惯手动创建RedissonClient,也可以写一个配置类:

@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0) .setConnectionPoolSize(16) .setConnectionMinimumIdleSize(8); return Redisson.create(config); } }

这里有两个参数值得解释一下:connectionPoolSize是连接池最大连接数,connectionMinimumIdleSize是最小空闲连接数。分布式锁在高并发下对Redis连接占用比较高,如果最小空闲连接设太小,热点key竞争时会频繁创建连接,增加额外延迟。一般业务规模,最小空闲8、最大32是个稳妥起步值。

4.2 实际业务里的加锁参数表

配置好客户端后,业务里的锁调用我习惯封装成一个工具类,避免每个地方都写一长串try-finally。下面是一个典型的支付防重加锁写法:

RLock lock = redissonClient.getLock("order:pay:" + orderId); try { boolean locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new BizException("当前订单正在处理中,请勿重复提交"); } // 查询订单、调支付渠道、更新状态 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

注意这里的tryLock(3, TimeUnit.SECONDS)只传了等待时间,没有传leaseTime,所以watchdog会生效。如果你想用固定leaseTime,就要明确业务执行时间的上限。

参数怎么调优,我给一个参考表:

场景waitTime(等待锁时间)leaseTime(锁自动释放)说明
短事务,如订单状态更新3s不传(watchdog)等待时间不宜过长,避免用户接口长时间挂起
长事务,如对账批量任务5s不传(watchdog)用watchdog兜底,防止超长任务锁过期
明确耗时场景,如定时导出2s120s业务罕见超时,可设固定leaseTime
超高竞争场景0ms(非阻塞)30s抢不到立刻返回,快速失败降级

这里面最忌讳的是waitTime设得很大,比如等30秒,但后面的业务处理只需要几十毫秒。因为分布式锁的底层是通过Redis的subscribe订阅锁释放消息来实现阻塞唤醒的,等锁线程并不是空转轮询,但大量线程同时等待同一把锁,仍然会带来不小的Redis pub/sub压力。高竞争场景最好设置一个较短的waitTime,抢不到就快速返回失败提示,让用户重试。

4.3 锁获取失败后的降级策略

锁获取失败时,最直接的做法是给用户返回“操作太频繁,请稍后再试”。但有些场景不能简单失败,需要更优雅的降级。

我常用的降级方案有三种:

  1. 快速失败 + 前端重试:适合用户点击类操作,接口返回“处理中”状态码,前端自动轮询订单状态,后端保证同订单不会并发处理。
  2. 异步队列兜底:锁获取失败后,把请求体丢到MQ,由消费者串行处理。这个适合对实时性不高的批量场景。
  3. 加本地互斥标记:如果同一实例内大量请求打在同一条订单上,可以在JVM内部先用一个ConcurrentHashMap做一层挡板,只有本地没在处理该订单时才去抢Redis锁,减少对Redis的无效请求。但要注意及时清理map,防止内存泄露。

降级逻辑的核心是:不能因为拿不到锁就把整个业务流程停掉,也不能放着不管直接往下执行。前者影响可用性,后者破坏一致性。好的设计应该是在两者之间找一个业务可接受的折中。

5. 面试和评审中绕不开的高频考点与答题思路

5.1 判断一个分布式锁好不好的三条标准

面试里几乎必问的一个问题是“你设计分布式锁要考虑哪些点”。这个问题不用背固定答案,只要抓住三条主线就可以:

  • 互斥性:任意时刻只能有一个客户端持有锁。
  • 死锁防护:客户端宕机后锁会自动释放,不会造成永久等待。
  • 可重入性(大多数场景需要):同一个客户端可以重复获取同一把锁。

围绕这三点去回答,就能把setnx的基本实现、Lua脚本释放、过期时间设置全部串起来。比如谈到互斥性,你要说SET NX保证了互斥;谈到死锁防护,你要说过期时间和watchdog;谈到可重入性,你要说Redisson的Hash结构计数。这样回答有层次,不会像背题。

还有一个延伸考点是性能。分布式锁的QPS很大程度上取决于Redis实例配置,Redis单实例的OPS大概在10万级别,但加了锁的客户端和Redis之间还有网络往返,实际业务能感受到的延迟通常在1~3ms。如果锁竞争激烈,还会有排队等待时间。所以面试里谈到性能优化,可以从降低锁粒度、缩短持锁时间、减少锁等待这几个方面回答。

5.2 容易被追问的细节:可重入、watchdog、主从切换

这部分是区分“背过面经”和“真实用过”的关键。

可重入的追问点:为什么setnx本身不支持可重入?因为你第二次执行setnx,key已经存在,会直接失败。解决方案是在客户端维护一个ThreadLocal计数,重入时+1,释放时-1,减到0才删除key。Redisson则是用Hash结构天然实现了计数,底层还用了getLock时通过getEntry判断当前线程是否持有锁,逻辑上更完备。

watchdog的追问点通常是:默认续期多久?判断依据是什么?答案是默认30秒,每10秒刷新一次,前提是锁还被当前线程持有。面试官可能会继续问“如果业务方法抛异常退出,watchdog还会续期吗”,这里要注意看门狗不是无限续的,它依赖锁被“线程持有”这个状态。执行完unlock()后锁被释放,watchdog自然停止。如果方法抛异常没有执行到unlock,锁会在30秒后过期,不会死锁。

主从切换的追问点是最容易被问倒的:Redis分布式锁在哨兵模式下,主机宕机后会不会丢锁?会。为什么?因为主从复制是异步的。这时候面试官会问“怎么解决”,你光说RedLock还不够,最好能说出RedLock的缺陷,比如需要锁定多个实例、性能下降、还存在时钟漂移问题,并且说明在强一致场景下会考虑ZooKeeper或etcd。这一层回答能让面试官知道你不仅会用一个工具,还知道方案的边界。

5.3 线上压测时我实际看到的数据

最后分享一组我手上的真实数据,方便大家做容量评估。一个标准订单服务实例,连接Redis 6.x部署在同样机房的容器里,网络延迟在0.5ms左右。用Redisson的tryLock获取锁并立即释放,单次加解锁大概耗时1.2~1.8ms。同订单并发200个请求抢一把锁,95%的请求都能在500ms内拿到锁并完成业务,另外5%因为等待时间超过3秒走了快速失败降级。整体接口TP99比没有锁时高大约15ms,对用户基本无感。

这个数据的前提是锁key分散得足够开。如果所有请求都抢同一个全局key,比如用一个不加业务维度的global_lock,竞争会非常惨烈,接口RT会成倍上升。所以线上压测时不要只测“加了锁能不能跑”,要切到真实业务维度去观察“每个key的竞争强度”。我曾在一个活动接口里发现,某个热点userId在一分钟内被同时请求了上万次,锁一直落在同一个key上,导致大量线程排队。后来把锁key细化到userId + 场景业务类型,竞争立刻降下来,这条经验在面试时讲出来也很有说服力。

还有一点,如果压测时发现Redis的INFO里有大量subscribepubsub相关连接堆积,大概率是等锁线程过多。这时候不要盲目加连接数,而是应该缩短waitTime、细化锁key,从源头降低竞争。连接池调大只能缓解症状,解决不了锁竞争的结构性问题。

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

Lima 博客与技术文章索引:从社区博客到 v2.x 里程碑的完整脉络

Lima 博客与技术文章索引&#xff1a;从社区博客到 v2.x 里程碑的完整脉络 【免费下载链接】lima Linux virtual machines, with a focus on running containers 项目地址: https://gitcode.com/GitHub_Trending/lim/lima Lima 是一个专注于运行容器的 Linux 虚拟机&…

作者头像 李华
网站建设 2026/9/13 18:31:10

嵌入式校招实战指南:汽车电子与AIoT岗位技术拆解

1. 这份校招日报不是“通知”&#xff0c;而是嵌入式应届生的战术地图你点开这条标题&#xff0c;第一反应可能是&#xff1a;“哦&#xff0c;又一家公司开了校招。”但如果你是正在准备2026届秋招的嵌入式方向本科生或硕士生——尤其是主修单片机、RTOS、Linux驱动、汽车电子…

作者头像 李华