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