ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

多实例并发下的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个实例用户提交支付请求后服务要做这几件事查询订单当前状态确认是否未支付。调用第三方支付渠道创建支付单。支付回调到达后更新订单状态为已支付。问题出在“查询订单状态”和“更新订单状态”之间。如果同一个订单的两个支付请求几乎同时到达实例A查到订单未支付实例B也查到订单未支付两个实例都去调用支付渠道那么用户可能被扣两次钱。用锁的话应该以“订单号”作为锁的key在“查询订单状态→创建支付单”这段逻辑外面加锁保证同一时间只有一个实例能处理同一条订单。锁的粒度也要注意。上面场景的key是order:pay:{orderId}也就是每笔订单一把锁。如果图省事用一个全局keyorder:pay:all那所有订单的支付操作都会互相排队性能会非常差。锁粒度越大互斥范围越广可用性越差。反之粒度太小比如用userId做key同一个用户的不同订单之间也会互相阻塞但还能接受如果用orderId互斥范围就控制得刚刚好。所以判断要不要用分布式锁基本看三件事是否存在多个实例同时操作同一份共享资源。操作是否是多步骤组合单靠原子指令无法保证完整。是否允许一定的锁等待和锁开销Redis锁通常也就几毫秒的额外开销。如果三个条件都满足就可以考虑上分布式锁了。2. Redis分布式锁的两种主流写法手写setnx与Redisson2.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方法里传了leaseTimeRedisson就不会额外续期锁会在指定的时间后强制释放。这点后面参数调优时还会细说。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脚本本身也要求每次传的KEYS和ARGV正确。释放锁时KEYS[1]必须是锁keyARGV[1]必须是加锁时存入的value。如果value拼错了脚本会返回0锁删不掉最终只能等超时释放。所以加锁时和释放时一定要从同一个地方取值不要把value放在方法参数里随手传一个。3.2 看门狗自动续期你以为的锁超时其实不是超时Redisson的watchdog默认续期逻辑能解决业务超时问题但它本身也有坑。先说工作机制当不指定leaseTime时Redisson会给锁设置一个默认的lockWatchdogTimeout默认30秒。后台会有一个Netty定时任务每过lockWatchdogTimeout / 3也就是10秒检查一次锁是否还被当前线程持有如果持有就重新设置过期时间为30秒。只要线程没结束锁就会一直续期。但这带来两个问题。第一如果你在业务代码里显式指定了leaseTimewatchdog就完全不生效。很多人在lock.tryLock(3, 30, TimeUnit.SECONDS)里写了leaseTime30秒结果数据库慢查询导致业务跑了50秒锁在第30秒就已经被Redis删除另一个线程进来了。这时候还以为是锁丢了其实是自己把watchdog关掉了。所以如果业务执行时间不能精确预估就别传leaseTime让watchdog扛着。第二watchdog续期是通过后台线程异步执行的如果持有锁的线程发生长时间GCFull 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 groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.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 锁获取失败后的降级策略锁获取失败时最直接的做法是给用户返回“操作太频繁请稍后再试”。但有些场景不能简单失败需要更优雅的降级。我常用的降级方案有三种快速失败 前端重试适合用户点击类操作接口返回“处理中”状态码前端自动轮询订单状态后端保证同订单不会并发处理。异步队列兜底锁获取失败后把请求体丢到MQ由消费者串行处理。这个适合对实时性不高的批量场景。加本地互斥标记如果同一实例内大量请求打在同一条订单上可以在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本身不支持可重入因为你第二次执行setnxkey已经存在会直接失败。解决方案是在客户端维护一个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里有大量subscribe和pubsub相关连接堆积大概率是等锁线程过多。这时候不要盲目加连接数而是应该缩短waitTime、细化锁key从源头降低竞争。连接池调大只能缓解症状解决不了锁竞争的结构性问题。
返回列表