兄弟们~ 最近是不是又在搞分布式系统?不管是秒杀、库存扣减,还是订单防重复提交,只要涉及 “多实例抢同一资源”,十有八九会想到 Redis 分布式锁。
毕竟 Redis 快啊、部署也方便,一行 setnx (现在是 set key value nx ex)就能搞个 “锁” 出来,看起来门槛低得很。但我敢说,不少同学刚写完代码还没来得及喝口咖啡,线上就炸了!要么死锁了,要么锁不住导致数据乱了,要么 debug 到半夜才发现 “哦!原来这里踩坑了!”
今天就跟大家扒一扒 Redis 分布式锁里那 5 个 “又大又深” 的坑,每个坑都给你讲清楚 “怎么踩进去的”“为什么会炸”“怎么爬出来”,全程大白话,带代码,保证你看完直呼 “原来之前我栽在这了!”
坑 1:忘了加过期时间,锁变成 “老赖” 占着茅坑不拉屎
这绝对是新手最常踩的第一个坑,没有之一!
我见过不少同学写分布式锁,上来就这么搞:
// 伪代码:加锁(只做了nx,没加过期) Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent("order:lock:1001", "user123"); if (lockSuccess) { try { // 搞业务:扣库存、创建订单 doBusiness(); } finally { // 解锁 redisTemplate.delete("order:lock:1001"); } }看起来没毛病吧?加锁、执行业务、解锁,逻辑很顺。但问题就出在 “没给锁加过期时间” 上!你想啊:如果 doBusiness() 执行到一半,服务器突然宕机了、或者线程被 kill 了,那 finally 里的 delete 压根没机会执行!这时候 Redis 里的 “order🔒1001” 这个 key 就永远躺在那了 —— 后面所有想抢这个锁的请求,都会被 setIfAbsent 挡在外面,直接造成 “死锁”!
我之前就遇到过一次:线上秒杀活动,刚开服 5 分钟,就有用户反馈 “下单按钮点了没反应”。查日志发现,大量请求卡在 “获取分布式锁” 那一步;再查 Redis,发现有个锁 key 已经存在 10 多分钟了,对应的服务实例早就因为内存溢出挂了。最后只能手动删 key 救急,那叫一个狼狈。
怎么爬坑?加过期时间!但别瞎加!
解决办法很简单:给锁加个 “过期时间”,就算业务执行崩了,Redis 也会自动删掉锁 key,避免死锁。
现在 Redis 推荐用 set key value nx ex 过期时间 这个命令,因为它是 “原子操作”—— 要么加锁成功且设置过期,要么失败,不会出现 “加锁成功但过期没设上” 的中间状态(之前有人分开写 setnx 再 expire,这两步不是原子的,也会有坑)。
改成这样就安全多了:
// 加锁:key=订单锁,value=用户标识,nx=不存在才加锁,ex=30秒过期 Boolean lockSuccess = redisTemplate.opsForValue() .setIfAbsent("order:lock:1001", "user123", 30, TimeUnit.SECONDS); if (lockSuccess) { try { doBusiness(); // 业务逻辑,比如扣减库存 } finally { // 解锁:后面会讲这里还有坑,先这么写着 redisTemplate.delete("order:lock:1001"); } }这里要提醒一句:过期时间别瞎设!设短了不行(后面坑 2 会讲),设太长也不行 —— 比如你设个 24 小时,万一锁没正常释放,那这个资源 24 小时内都被锁住,影响业务。一般建议根据 “业务最大执行时间” 来设,比如你的 doBusiness() 最多跑 5 秒,那过期时间设 10-30 秒就够了,留个缓冲。
坑 2:过期时间设短了,锁 “提前跑路” 导致并发问题
刚解决了死锁问题,又有同学踩进下一个坑:过期时间设太短,业务还没执行完,锁就被 Redis 自动删了!
举个例子:你给锁设了 5 秒过期,结果某次业务因为数据库慢、或者调用的第三方接口卡了,doBusiness() 跑了 6 秒才结束。这时候尴尬了 —— 锁在第 5 秒就被 Redis 删了,而你的业务还在执行;这时候另一个请求过来,发现 “锁没了”,就直接加锁成功,也开始执行同样的业务。
两个请求同时操作同一资源,后果就是:库存超卖、订单重复创建、数据不一致…… 我之前见过最离谱的一次,某电商平台因为这个问题,同一个订单号被创建了 3 次,用户收到 3 条发货通知,客服电话被打爆。
怎么爬坑?给锁 “续命”!或者用 “看门狗”
核心思路:让锁的过期时间 “跟着业务走”—— 只要业务还在执行,就自动把锁的过期时间延长,避免锁提前失效。
有两种常见方案:
方案 1:自己写 “续命” 线程
在加锁成功后,启动一个后台线程,每隔一段时间(比如过期时间的 1/3)就去检查 “当前锁还是不是自己的”,如果是,就把过期时间重置为初始值。
比如你锁过期时间是 30 秒,后台线程每隔 10 秒检查一次,只要业务没结束,就执行 expire "order🔒1001" 30,相当于给锁 “续杯”。
伪代码大概长这样:
Boolean lockSuccess = redisTemplate.opsForValue() .setIfAbsent("order:lock:1001", "user123", 30, TimeUnit.SECONDS); if (lockSuccess) { // 启动续命线程 Thread renewThread = new Thread(() -> { while (业务还在执行中) { // 检查锁是不是自己的(value等于user123) String currentValue = redisTemplate.opsForValue().get("order:lock:1001"); if ("user123".equals(currentValue)) { // 续命:重置为30秒过期 redisTemplate.expire("order:lock:1001", 30, TimeUnit.SECONDS); } // 每隔10秒检查一次 try { Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); renewThread.start(); try { doBusiness(); // 执行业务 } finally { // 业务结束,停止续命线程,删除锁 renewThread.interrupt(); redisTemplate.delete("order:lock:1001"); } }方案 2:用成熟框架的 “看门狗”
自己写续命线程容易有 bug(比如线程没停干净、检查逻辑有问题),其实像 Redisson 这种 Redis 客户端,已经内置了 “看门狗” 机制。
只要你用 Redisson 创建分布式锁,它就会自动启动一个看门狗线程,每隔 30 秒(默认)就给锁续一次期,直到业务执行完、手动释放锁。代码也特别简单:
// 获取Redisson客户端(提前配置好) RedissonClient redissonClient = Redisson.create(config); // 获取分布式锁 RLock lock = redissonClient.getLock("order:lock:1001"); try { // 加锁:默认30秒过期,看门狗自动续命 lock.lock(); doBusiness(); // 执行业务 } finally { // 解锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }是不是省了很多事?所以建议大家生产环境别自己瞎写,优先用 Redisson 这种成熟的框架,坑都帮你填好了。
坑 3:解锁不校验所有者,把别人的锁删了
这个坑也很隐蔽,很多同学觉得 “加锁、执行业务、解锁” 流程对了就行,没考虑过 “解锁时,锁可能已经不是自己的了”。
比如这样的代码:
// 加锁:30秒过期 Boolean lockSuccess = redisTemplate.opsForValue() .setIfAbsent("order:lock:1001", "user123", 30, TimeUnit.SECONDS); if (lockSuccess) { try { doBusiness(); // 假设这里执行了35秒,锁已经过期被自动删了 } finally { // 直接解锁,没校验是不是自己的锁! redisTemplate.delete("order:lock:1001"); } }你品,你细品:业务执行了 35 秒,超过了锁的 30 秒过期时间,Redis 已经把这个锁删了。这时候另一个用户(比如 user456)过来,成功加了新的锁;结果你这边业务执行完,直接把 user456 的锁给删了!接下来就乱了:user456 以为自己还拿着锁,在执行业务;而其他请求又能加锁了,多个请求同时操作,数据直接就乱了。
这种情况 debug 起来特别费劲,因为你会发现 “锁加了、也解了”,但就是有并发问题,直到你盯着日志看时间线,才会发现 “哦!原来我删了别人的锁!”
怎么爬坑?解锁前先校验 “锁是不是自己的”
核心原则:谁加的锁,谁才能解。所以解锁前,必须先检查 “当前锁的 value 是不是自己加锁时设的值”,只有是自己的,才能删。
但这里有个关键点:“检查 value” 和 “删除锁” 这两步,必须是原子操作!不能分开写(先 get 再 delete),因为中间可能有时间差,还是会出问题。
比如你这么写,还是有坑:
// 错误示例:先get再delete,非原子操作 String currentValue = redisTemplate.opsForValue().get("order:lock:1001"); if ("user123".equals(currentValue)) { // 这里有时间差!可能刚判断完,锁就过期被别人加了 redisTemplate.delete("order:lock:1001"); }那怎么保证原子性呢?答案是用Lua 脚本。因为 Redis 执行 Lua 脚本时,会把脚本里的所有命令当作一个整体执行,中间不会被其他请求打断,完美保证原子性。
用 Lua 脚本解锁
我们可以写一个这样的 Lua 脚本:先判断锁的 value 是不是自己的,如果是,就删除;如果不是,就不做任何操作。
Lua 脚本内容:
-- 第一个参数是锁的key,第二个参数是自己的标识(value) if redis.call('get', KEYS[1]) == ARGV[1] then -- 是自己的锁,删除 return redis.call('del', KEYS[1]) else -- 不是自己的锁,不操作 return 0 end然后在 Java 代码里调用这个脚本:
// 加锁 String lockKey = "order:lock:1001"; String lockValue = "user123"; // 用唯一标识,比如UUID+线程ID,避免同一台机器不同线程冲突 Boolean lockSuccess = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS); if (lockSuccess) { try { doBusiness(); } finally { // 调用Lua脚本解锁 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // 执行脚本:KEYS传lockKey,ARGV传lockValue redisTemplate.execute( new DefaultRedisScript<>(luaScript, Integer.class), Collections.singletonList(lockKey), lockValue ); } }这样就安全了:不管锁有没有过期,只有当 value 是自己的标识时,才会删除,绝不会删别人的锁。顺便说一句,Redisson 也帮你处理了这个问题 —— 它的 unlock() 方法会自动校验当前线程是不是锁的持有者,不是的话会抛异常,避免误删。所以用框架真的能少踩很多坑。
坑 4:主从切换时,锁 “丢了”
前面讲的坑,都是基于 “单机 Redis” 的场景。但生产环境为了高可用,Redis 一般都是主从架构(主节点写,从节点读,主挂了从节点顶上)。
可主从架构里,藏着一个更隐蔽的坑:主从同步延迟导致锁丢失。
流程是这样的:
你在主节点上成功加了锁(set key value nx ex),但主节点还没来得及把这个 “加锁命令” 同步到从节点;
突然主节点宕机了(比如硬件故障、网络断了);
Redis 的哨兵(Sentinel)发现主节点挂了,就把某个从节点升级成新的主节点;
新的主节点上,压根没有你之前加的那个锁(因为没同步过来);
其他请求过来,就能在新主节点上成功加锁,导致多个请求同时执行业务。
这个坑有多坑?它不是代码的问题,是 Redis 主从架构的特性导致的,你代码写得再完美,遇到主从切换也可能中招。我之前帮朋友排查过一个问题,就是因为主从切换丢了锁,导致秒杀活动中出现了超卖,最后只能走退款流程,损失了不少用户信任。
怎么爬坑?方案有 3 种,各有优劣
方案 1:接受风险,用 “主从 + 哨兵”,配合业务补偿
这是最常用的方案,因为实现简单,性能也没问题。核心思路是:
Redis 用主从 + 哨兵架构,保证高可用;
承认 “主从切换时可能丢锁”,但通过 “业务补偿” 来解决后果(比如超卖了,用定时任务对账,发现超卖就退款、发通知);
配合前面讲的 “加过期时间、校验解锁者”,把丢锁的概率降到最低。
这种方案适合 “对数据一致性要求不是 100% 严格,能接受少量异常并通过补偿解决” 的场景(比如电商秒杀、普通订单),毕竟主从切换不是高频事件,丢锁的概率很低。
方案 2:用 Redis Cluster+Redlock(红锁)
Redis 作者提出了一个 “红锁”(Redlock)方案,专门解决主从切换丢锁的问题。原理很简单:
部署多个独立的 Redis 节点(至少 3 个,奇数个),这些节点之间没有主从关系,都是独立的;
加锁时,要在超过半数的节点上成功加锁(比如 3 个节点,至少 2 个成功),才算整体加锁成功;
解锁时,要在所有节点上都删除锁。
这样即使某个节点宕机了,只要还有超过半数的节点正常,锁依然有效。Redisson 也实现了红锁,代码大概这样:
// 创建多个Redis节点的客户端 Config config1 = new Config(); config1.useSingleServer().setAddress("redis://192.168.1.101:6379"); RedissonClient client1 = Redisson.create(config1); Config config2 = new Config(); config2.useSingleServer().setAddress("redis://192.168.1.102:6379"); RedissonClient client2 = Redisson.create(config2); Config config3 = new Config(); config3.useSingleServer().setAddress("redis://192.168.1.103:6379"); RedissonClient client3 = Redisson.create(config3); // 构造红锁 RLock lock1 = client1.getLock("order:lock:1001"); RLock lock2 = client2.getLock("order:lock:1001"); RLock lock3 = client3.getLock("order:lock:1001"); RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); try { // 加锁:在超过半数节点(至少2个)加锁成功,才算成功 redLock.lock(30, TimeUnit.SECONDS); doBusiness(); } finally { redLock.unlock(); }但红锁也有争议:比如网络分区时可能出现 “脑裂”,而且需要部署多个独立 Redis 节点,运维成本高、性能也比单机差(要连多个节点)。所以红锁适合 “对数据一致性要求极高,能接受运维成本和性能损耗” 的场景(比如金融交易)。
方案 3:不用 Redis,换 ZooKeeper 分布式锁
如果实在担心 Redis 主从切换的问题,也可以换个技术栈 —— 用 ZooKeeper 实现分布式锁。
ZooKeeper 的特性是 “强一致性”:数据写入时,会同步到所有节点,只有所有节点都写成功,才算成功。所以不存在 Redis 主从同步延迟的问题,锁的可靠性更高。
但 ZooKeeper 也有缺点:性能比 Redis 差(毕竟要同步所有节点),而且部署和维护更复杂(需要集群,至少 3 个节点)。所以要不要换,得根据你的业务场景权衡。
坑 5:没考虑 “可重入”,自己把自己锁死了
最后一个坑,是 “可重入” 问题。所谓 “可重入”,就是同一个线程可以多次获取同一把锁,不会自己挡住自己。
比如这样的场景:你的业务代码里有个递归调用,或者一个方法调用另一个方法,两个方法都需要获取同一把锁:
// 方法A需要加锁 public void methodA() { Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent("lock", "value", 30, TimeUnit.SECONDS); if (lockSuccess) { try { System.out.println("进入方法A"); methodB(); // 调用方法B,方法B也需要这把锁 } finally { // 解锁(这里省略校验,方便举例) redisTemplate.delete("lock"); } } } // 方法B也需要加锁 public void methodB() { Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent("lock", "value", 30, TimeUnit.SECONDS); if (lockSuccess) { try { System.out.println("进入方法B"); } finally { redisTemplate.delete("lock"); } } else { System.out.println("方法B获取锁失败,被挡住了!"); } }当线程执行 methodA 时,成功加了锁;然后调用 methodB,这时候 setIfAbsent 发现锁已经存在了,就返回 false,方法 B 获取锁失败 —— 这就是 “自己把自己锁死了”。这种情况在复杂业务里很常见,比如订单处理流程中,“创建订单” 和 “扣减库存” 都需要同一把锁,如果你没考虑可重入,就会出现这种尴尬的情况。
怎么爬坑?实现 “可重入锁”
要实现可重入锁,核心是要记录 “哪个线程持有锁” 以及 “持有了多少次”,解锁时次数减 1,直到次数为 0 才真正删除锁。
具体怎么实现呢?可以用 Redis 的 Hash 数据结构,把锁的 key 作为 Hash 的 key,然后在 Hash 里存两个字段:
threadId:持有锁的线程 ID(比如 “12345”);
count:重入次数(比如 1、2、3)。
加锁和解锁的逻辑如下:
加锁逻辑(Lua 脚本实现原子性)
检查 Hash 里的 threadId 是不是当前线程 ID:
如果是,说明是重入,把 count 加 1,同时重置锁的过期时间;
如果不是,检查 Hash 是否存在:不存在的话,创建 Hash,设置 threadId 为当前线程,count=1,并设置过期时间;存在的话,加锁失败。
Lua 脚本:
-- KEYS[1] = 锁的key,ARGV[1] = 线程ID,ARGV[2] = 过期时间(秒) if redis.call('hexists', KEYS[1], 'threadId') == 1then -- 重入:count+1,重置过期时间 redis.call('hincrby', KEYS[1], 'count', 1); redis.call('expire', KEYS[1], ARGV[2]); return1; -- 加锁成功 else -- 不是当前线程的锁,检查是否存在 if redis.call('exists', KEYS[1]) == 0then -- 不存在,创建Hash,设置threadId和count redis.call('hset', KEYS[1], 'threadId', ARGV[1]); redis.call('hset', KEYS[1], 'count', 1); redis.call('expire', KEYS[1], ARGV[2]); return1; -- 加锁成功 else return0; -- 加锁失败 end end解锁逻辑(Lua 脚本)
检查 Hash 里的 threadId 是不是当前线程 ID:
如果不是,直接返回 0(不是自己的锁,不解锁);
如果是,把 count 减 1:如果减到 0,就删除整个 Hash(释放锁);如果没到 0,就重置过期时间。
Lua 脚本:
-- KEYS[1] = 锁的key,ARGV[1] = 线程ID,ARGV[2] = 过期时间(秒) if redis.call('hexists', KEYS[1], 'threadId') ~= 1then return0; -- 不是自己的锁,不解锁 end -- count减1 local count = redis.call('hincrby', KEYS[1], 'count', -1); if count == 0then -- count到0,删除锁 redis.call('del', KEYS[1]); return1; else -- count没到0,重置过期时间 redis.call('expire', KEYS[1], ARGV[2]); return1; endJava 代码调用
把上面的 Lua 脚本集成到 Java 代码里,就能实现可重入锁了:
// 加锁方法 privateboolean reentrantLock(String lockKey, String threadId, int expireSeconds) { String luaScript = "if redis.call('hexists', KEYS[1], 'threadId') == 1 then redis.call('hincrby', KEYS[1], 'count', 1); redis.call('expire', KEYS[1], ARGV[2]); return 1; else if redis.call('exists', KEYS[1]) == 0 then redis.call('hset', KEYS[1], 'threadId', ARGV[1]); redis.call('hset', KEYS[1], 'count', 1); redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end"; Integer result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Integer.class), Collections.singletonList(lockKey), threadId, String.valueOf(expireSeconds) ); return result != null && result == 1; } // 解锁方法 privateboolean reentrantUnlock(String lockKey, String threadId, int expireSeconds) { String luaScript = "if redis.call('hexists', KEYS[1], 'threadId') ~= 1 then return 0; end local count = redis.call('hincrby', KEYS[1], 'count', -1); if count == 0 then redis.call('del', KEYS[1]); return 1; else redis.call('expire', KEYS[1], ARGV[2]); return 1; end"; Integer result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Integer.class), Collections.singletonList(lockKey), threadId, String.valueOf(expireSeconds) ); return result != null && result == 1; } // 测试可重入 publicvoid testReentrant() { String lockKey = "reentrant:lock"; String threadId = Thread.currentThread().getId() + ""; // 当前线程ID int expireSeconds = 30; // 第一次加锁 if (reentrantLock(lockKey, threadId, expireSeconds)) { try { System.out.println("第一次加锁成功"); // 第二次加锁(重入) if (reentrantLock(lockKey, threadId, expireSeconds)) { try { System.out.println("第二次加锁成功(重入)"); } finally { reentrantUnlock(lockKey, threadId, expireSeconds); System.out.println("第二次解锁"); } } } finally { reentrantUnlock(lockKey, threadId, expireSeconds); System.out.println("第一次解锁"); } } }运行这段代码,会输出:
第一次加锁成功 第二次加锁成功(重入) 第二次解锁 第一次解锁完美实现了可重入!当然,如果你用 Redisson,它也内置了可重入锁,不用自己写这么多代码 ——RLock 本身就是可重入的,之前的例子里已经体现了。
总结:Redis 分布式锁避坑指南
讲完了 5 个坑,最后给大家总结一下避坑要点,方便你收藏备用:
坑 1:忘加过期时间→死锁
避坑:加锁时必须用 set key value nx ex 原子命令,设置合理过期时间(比业务最大执行时间长)。
坑 2:过期时间太短→锁提前释放
避坑:用 “续命线程” 或 Redisson 看门狗,业务没结束就自动续期。
坑 3:解锁不校验所有者→删别人的锁
避坑:解锁前用 Lua 脚本校验锁的 value(比如线程 ID),原子性删除。
坑 4:主从切换→锁丢失
避坑:普通场景用 “主从 + 哨兵 + 业务补偿”;高一致性场景用 Redlock 或 ZooKeeper。
坑 5:不支持可重入→自己锁自己
避坑:用 Hash 结构记录线程 ID 和重入次数,或直接用 Redisson 可重入锁。
最后再啰嗦一句:Redis 分布式锁不是 “银弹”,没有完美的方案,只有适合自己业务的方案。生产环境优先用 Redisson 这种成熟框架,别自己造轮子,除非你对底层原理吃得很透,不然很容易踩坑。