建设厅网站突然显示不全,建手机端网站,厦门帮忙建设网站,网页图片加载不出来兄弟们#xff5e; 最近是不是又在搞分布式系统#xff1f;不管是秒杀、库存扣减#xff0c;还是订单防重复提交#xff0c;只要涉及 “多实例抢同一资源”#xff0c;十有八九会想到 Redis 分布式锁。毕竟 Redis 快啊、部署也方便#xff0c;一行 setnx #xff08;现在…兄弟们 最近是不是又在搞分布式系统不管是秒杀、库存扣减还是订单防重复提交只要涉及 “多实例抢同一资源”十有八九会想到 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 里的 “order1001” 这个 key 就永远躺在那了 —— 后面所有想抢这个锁的请求都会被 setIfAbsent 挡在外面直接造成 “死锁”我之前就遇到过一次线上秒杀活动刚开服 5 分钟就有用户反馈 “下单按钮点了没反应”。查日志发现大量请求卡在 “获取分布式锁” 那一步再查 Redis发现有个锁 key 已经存在 10 多分钟了对应的服务实例早就因为内存溢出挂了。最后只能手动删 key 救急那叫一个狼狈。怎么爬坑加过期时间但别瞎加解决办法很简单给锁加个 “过期时间”就算业务执行崩了Redis 也会自动删掉锁 key避免死锁。现在 Redis 推荐用 set key value nx ex 过期时间 这个命令因为它是 “原子操作”—— 要么加锁成功且设置过期要么失败不会出现 “加锁成功但过期没设上” 的中间状态之前有人分开写 setnx 再 expire这两步不是原子的也会有坑。改成这样就安全多了// 加锁key订单锁value用户标识nx不存在才加锁ex30秒过期 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 order1001 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传lockKeyARGV传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 ClusterRedlock红锁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 为当前线程count1并设置过期时间存在的话加锁失败。Lua 脚本-- KEYS[1] 锁的keyARGV[1] 线程IDARGV[2] 过期时间秒 if redis.call(hexists, KEYS[1], threadId) 1then -- 重入count1重置过期时间 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] 锁的keyARGV[1] 线程IDARGV[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 这种成熟框架别自己造轮子除非你对底层原理吃得很透不然很容易踩坑。