分布式锁为什么经常用错?一次讲清 setnx、锁续期、误删锁与 Redisson 实战

张开发
2026/4/9 2:58:17 15 分钟阅读

分享文章

分布式锁为什么经常用错?一次讲清 setnx、锁续期、误删锁与 Redisson 实战
分布式锁为什么经常用错一次讲清 setnx、锁续期、误删锁与 Redisson 实战大家好我是一名有 4 年工作经验的 Java 后端开发。最近在系统整理高并发业务场景下的一些核心设计问题准备沉淀成一个系列。前面几篇我写了秒杀库存扣减、缓存一致性、MQ 幂等消费、支付超时库存回补、热点 Key 治理这一篇继续聊一个在面试和实际项目里都非常高频但也特别容易被误用的话题分布式锁。个人主页文章目录分布式锁为什么经常用错一次讲清 setnx、锁续期、误删锁与 Redisson 实战一、前言二、业务场景2.1 场景设定2.2 业务要求2.3 为什么这个场景典型三、问题现象3.1 没设置过期时间服务挂了锁就永远不释放3.2 过期时间和加锁不是原子操作3.3 锁过期了业务还没执行完3.4 线程 A 的锁被线程 B 删除了四、原理分析4.1 一个可用的分布式锁至少要满足什么条件4.2 为什么锁的 value 不能随便写成 14.3 为什么分布式锁不是银弹五、常见方案对比六、推荐方案设计6.1 核心原则6.2 为什么我不建议“只靠分布式锁保证不重复”七、落地代码7.1 正确的 Redis 原子加锁7.2 解锁时必须校验 value并使用 Lua 保证原子性7.3 业务代码示例领取优惠券7.4 数据库唯一索引做最终兜底7.5 长事务场景下的锁续期7.6 定时任务互斥执行怎么做八、为什么很多项目用了分布式锁还是会出问题8.1 只加锁不加数据库唯一约束8.2 解锁直接 delete没有校验持有者8.3 锁时间拍脑袋设置8.4 长事务没有续期8.5 锁粒度设计错误8.6 把所有问题都交给分布式锁九、压测与监控怎么写文章才更像做过项目的人写的9.1 压测场景示例9.2 压测结果示例9.3 线上建议重点监控哪些指标十、面试中怎么回答这个问题10.1 回答思路10.2 面试官更想听到什么十一、总结十二、后续准备继续写的内容十三、结尾一、前言很多人一提到并发问题第一反应就是加锁上 Redis用setnx看起来很合理。但真实线上场景里分布式锁经常不是“不会用”而是“以为用了其实没用对”。比如锁过期了业务还没执行完结果两个线程同时进来了线程 A 加的锁被线程 B 给删了用了分布式锁结果数据库唯一索引没加还是出现脏数据本来可以用唯一约束解决的问题硬是上了复杂锁方案锁没续期长事务执行到一半锁自动释放服务挂掉后锁没释放后续请求全部卡死所以分布式锁真正难的地方从来都不是“怎么写一条 Redis 命令”而是在分布式环境下怎么保证锁真正只被一个线程持有并且加锁、执行业务、解锁整个过程都可靠这篇文章就结合一个典型业务场景把分布式锁为什么经常用错、到底应该怎么设计系统讲透。二、业务场景先假设这样一个场景。2.1 场景设定系统里有一个“领取优惠券”的接口业务要求是同一个用户对同一批次优惠券只能领取一次活动开始时并发会非常高多台应用实例同时对外提供服务Redis 和数据库都是分布式部署接口大致流程如下用户点击领取优惠券系统校验活动是否有效校验库存是否充足校验用户是否已经领取过发放优惠券并扣减库存2.2 业务要求这个场景下一般要满足下面这些要求同一个用户不能重复领取高并发下不能超发多实例部署下要保证并发互斥服务异常退出后锁不能永久不释放锁释放时不能误删别人的锁整个方案要便于排查和监控2.3 为什么这个场景典型因为它很像真实业务而且分布式锁常常就出现在这类场景里一人一单一人一券重复提交任务互斥执行同一资源只能被一个请求修改这类问题如果处理不好后果都非常直接重复领券库存超发数据脏写任务重复执行三、问题现象很多项目里一开始的分布式锁写法通常是这样的publicvoidreceiveCoupon(LonguserId,LongcouponBatchId){StringlockKeylock:coupon:userId:couponBatchId;BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(lockKey,1);if(!Boolean.TRUE.equals(success)){thrownewRuntimeException(请勿重复领取);}try{doReceiveCoupon(userId,couponBatchId);}finally{stringRedisTemplate.delete(lockKey);}}看起来逻辑非常直观先setIfAbsent成功就说明拿到锁执行业务最后删除锁但这段代码在线上其实埋了很多坑。3.1 没设置过期时间服务挂了锁就永远不释放如果你只setnx不设置过期时间一旦服务在业务执行过程中宕机锁就会一直留在 Redis 里。结果就是后续请求再也拿不到锁业务长时间不可用3.2 过期时间和加锁不是原子操作有些人会改成这样BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(lockKey,1);if(Boolean.TRUE.equals(success)){stringRedisTemplate.expire(lockKey,Duration.ofSeconds(10));}这还是有问题。因为setnx成功还没来得及expire服务挂了结果还是变成死锁。也就是说加锁和设置过期时间必须是原子操作。3.3 锁过期了业务还没执行完假设你把锁超时时间设置为10 秒但某次数据库抖动业务执行了15 秒。这时候就会出现线程 A 拿到锁执行业务到第 10 秒时锁自动过期线程 B 又拿到同一个锁线程 A 和线程 B 同时修改同一份数据这时候分布式锁其实已经失效了。3.4 线程 A 的锁被线程 B 删除了这是最经典也是最危险的坑之一。假设流程如下线程 A 拿到锁valueA线程 A 执行业务超时锁过期线程 B 拿到同一个锁valueB线程 A 执行完毕直接delete(lockKey)结果就是线程 B 明明还持有锁却被线程 A 误删掉了后面线程 C 就又能进来了。所以锁释放时绝对不能直接删而要先校验“这把锁是不是我自己的”。四、原理分析分布式锁真正要解决的不是“看起来只有一个线程进来了”而是在多实例、多线程、异常退出、网络抖动的情况下仍然保证同一时刻只有一个持有者操作共享资源。4.1 一个可用的分布式锁至少要满足什么条件一个相对靠谱的分布式锁至少要满足下面几个条件加锁是原子操作必须有过期时间避免死锁锁的 value 要能标识持有者解锁时只能删自己的锁业务执行时间超过锁超时时要有续期机制少一个都可能出问题。4.2 为什么锁的 value 不能随便写成 1因为锁不只是“有没有”还要知道“是谁持有的”。所以更合理的 value 一般会是UUID线程 ID进程 ID 线程 ID这样在解锁时才能判断当前准备删锁的线程是不是当初真正加锁的线程。4.3 为什么分布式锁不是银弹这是面试里很容易拉开差距的一点。很多问题其实并不应该优先上分布式锁。比如一人一券更适合数据库唯一索引状态流转更适合条件更新幂等消费更适合唯一约束 消费记录库存扣减更适合条件更新或预扣减方案也就是说分布式锁适合控制并发但不适合作为业务正确性的唯一保障。真正线上可靠的做法通常是分布式锁控制并发入口数据库唯一约束/状态机保证最终正确性五、常见方案对比下面把几种常见的分布式锁方案放在一起看一下。方案思路优点缺点适用场景数据库悲观锁select ... for update简单直接事务内强约束性能一般扩展性差并发不高、事务型场景Redisset nx ex原子加锁带过期时间快常见需要自己处理解锁、续期、异常轻量并发控制Redis Lua 解锁校验 value 后原子解锁比直接 delete 更安全仍需自己处理续期常见基础方案Redisson 锁封装加锁、续期、解锁等细节开箱即用工程性好依赖框架需理解实现Java 项目强推荐ZooKeeper 锁临时顺序节点实现互斥一致性好成本高性能一般对一致性要求极高的场景如果是 Java 项目里的大多数业务我更推荐优先评估是否真的需要分布式锁如果需要优先使用 Redisson而不是手写一套不完整的 Redis 锁。六、推荐方案设计这里给一版更贴近线上落地的思路。6.1 核心原则以“用户领取优惠券”为例我更倾向于这样设计先用分布式锁限制同一用户并发领取业务落库时给user_id coupon_batch_id加唯一索引优惠券库存扣减使用数据库条件更新锁只负责减少并发冲突不负责兜底所有业务正确性也就是说锁是第一道防线数据库约束是最后一道底线。6.2 为什么我不建议“只靠分布式锁保证不重复”因为锁解决的是“同一时刻”的并发问题不是“永远不会重复”的业务语义问题。比如下面这些情况锁并不能单独兜住服务重启锁过期重试请求跨时间到来消息重复投递代码 bug 导致锁范围不对所以更稳妥的做法是锁减少并发竞争数据库唯一索引保证最终不会重复写入七、落地代码下面给一版比较贴近实际项目思路的代码。7.1 正确的 Redis 原子加锁Redis 加锁应该尽量做到一条命令原子完成publicbooleantryLock(StringlockKey,StringrequestId,longexpireSeconds){BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(lockKey,requestId,Duration.ofSeconds(expireSeconds));returnBoolean.TRUE.equals(success);}这里做对了两件事setIfAbsent同时设置过期时间这样至少避免了“先加锁成功再设置过期时间失败”的问题。7.2 解锁时必须校验 value并使用 Lua 保证原子性不能直接delete(lockKey)而是要判断当前锁是不是自己的。Lua 脚本如下ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])elsereturn0endJava 调用示例privatestaticfinalDefaultRedisScriptLongUNLOCK_SCRIPTnewDefaultRedisScript();static{UNLOCK_SCRIPT.setScriptText( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end );UNLOCK_SCRIPT.setResultType(Long.class);}publicvoidunlock(StringlockKey,StringrequestId){stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(lockKey),requestId);}这样可以避免误删别人的锁。7.3 业务代码示例领取优惠券publicvoidreceiveCoupon(LonguserId,LongcouponBatchId){StringlockKeylock:coupon:userId:couponBatchId;StringrequestIdUUID.randomUUID().toString();booleanlockedtryLock(lockKey,requestId,10);if(!locked){thrownewRuntimeException(操作过于频繁请稍后重试);}try{doReceiveCoupon(userId,couponBatchId);}finally{unlock(lockKey,requestId);}}7.4 数据库唯一索引做最终兜底如果你真的想保证“一人一券”数据库唯一索引一定不能少。CREATETABLEuser_coupon(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,coupon_batch_idBIGINTNOTNULL,created_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_user_coupon(user_id,coupon_batch_id));这样即使在极端情况下锁失效数据库仍然会拒绝重复写入。7.5 长事务场景下的锁续期如果业务执行时间可能超过锁超时时间那就必须考虑续期。这也是很多手写 Redis 锁最容易漏掉的地方。常见思路有两种自己起后台线程定时续期直接使用 Redisson 的 watchdog 自动续期机制如果是 Java 项目我更建议直接用 Redisson。示例代码RLocklockredissonClient.getLock(lock:coupon:userId:couponBatchId);booleanlockedlock.tryLock(0,10,TimeUnit.SECONDS);if(!locked){thrownewRuntimeException(操作过于频繁请稍后重试);}try{doReceiveCoupon(userId,couponBatchId);}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}如果你不显式指定固定租约时间Redisson 还能通过 watchdog 自动续期这对长事务场景很有价值。7.6 定时任务互斥执行怎么做除了业务接口分布式锁还常用于定时任务。比如订单超时扫描数据补偿任务每日结算任务这类场景里更适合使用“任务维度”的锁publicvoidexecuteCompensateJob(){StringlockKeylock:job:compensate;StringrequestIdUUID.randomUUID().toString();booleanlockedtryLock(lockKey,requestId,60);if(!locked){return;}try{doCompensate();}finally{unlock(lockKey,requestId);}}但同样要注意任务执行时间是否可能超过 60 秒是否需要续期是否需要防止重复补偿很多补偿任务看起来只是“互斥执行”实际上最终仍然需要幂等设计。八、为什么很多项目用了分布式锁还是会出问题这也是线上非常常见的情况。8.1 只加锁不加数据库唯一约束这是最典型的问题。很多人以为加了锁就万无一失了但锁一旦因为超时、误删、代码 bug 失效数据库层就完全没有兜底能力。8.2 解锁直接 delete没有校验持有者这会导致非常危险的误删锁问题。8.3 锁时间拍脑袋设置比如统一设置 5 秒、10 秒看起来简单其实很容易在慢 SQL、GC、网络抖动场景下失效。8.4 长事务没有续期业务执行时间超出锁 TTL 后锁会自动释放后续线程又能重新拿锁。8.5 锁粒度设计错误比如本来应该锁userId couponBatchId结果只锁了couponBatchId这会导致锁范围过大请求大量串行化系统吞吐明显下降。反过来如果锁范围过小也可能根本挡不住真正的并发冲突。8.6 把所有问题都交给分布式锁这其实是最根本的误区。分布式锁可以减少冲突但不能替代唯一索引幂等设计状态机条件更新补偿机制九、压测与监控怎么写文章才更像做过项目的人写的分布式锁这类文章如果只讲概念不讲监控和风险点很容易看起来像八股。所以建议补上压测和线上观测视角。9.1 压测场景示例这里给一个适合写进文章的测试场景场景配置优惠券领取峰值 QPS8000应用实例数4Redis主从数据库MySQL 主从用户并发集中在热点活动批次对比方案方案 A无锁 仅业务判断方案 B手写 Redis 锁 Lua 解锁方案 CRedisson 锁 唯一索引 条件更新9.2 压测结果示例指标无锁手写 Redis 锁Redisson DB 兜底平均 RT12ms20ms22ms重复领券数13440锁相关异常无偶发误删风险最低系统稳定性差中好工程维护成本低中中从结果上可以看出不加锁时并发冲突非常明显手写 Redis 锁能解决一部分问题但细节处理不到位时仍然有风险Redisson 配合数据库唯一索引整体更稳说明以上压测数据为示例写法实际结果需要结合锁粒度、业务执行时长、Redis 部署模式和数据库约束综合评估。9.3 线上建议重点监控哪些指标如果你准备真正落地分布式锁方案至少建议监控这些指标加锁成功率加锁失败次数锁等待耗时业务执行耗时与锁 TTL 的关系锁超时自动释放次数Redis 命令 RT分布式锁异常日志次数数据库唯一索引冲突次数任务重复执行次数业务侧重复写入告警这些指标一旦写进文章里会明显更像真实线上经验总结。十、面试中怎么回答这个问题如果面试官问你分布式锁为什么经常用错你一般怎么设计你可以这样回答。10.1 回答思路第一很多人会用 Redis 的setnx来实现分布式锁但如果没有同时设置过期时间就可能在服务异常退出后导致死锁所以加锁和过期时间必须是原子操作。第二锁的 value 不能随便写成固定值而应该是唯一请求标识比如 UUID。因为解锁时不能直接 delete而是要通过 Lua 脚本先校验 value再原子删除避免误删别人的锁。第三如果业务执行时间可能超过锁的过期时间就必须考虑续期机制。手写锁很容易漏掉这个细节所以在 Java 项目里我通常更倾向于使用 Redisson。第四分布式锁不应该作为业务正确性的唯一保障。像一人一券、一人一单这类场景我会同时在数据库层增加唯一索引或者通过条件更新、状态机做最终兜底。第五锁粒度也很关键粒度过大影响吞吐粒度过小又起不到互斥效果所以要基于具体资源维度来设计。10.2 面试官更想听到什么面试官真正想听的通常不是你会不会背“setnx expire”而是你有没有这些意识你知道加锁和过期时间必须原子执行你知道解锁不能直接 delete你知道锁续期是高频坑点你知道分布式锁不是业务兜底的全部你知道数据库唯一索引和状态机仍然很重要你知道 Redisson 适合工程化落地如果你能把这些点讲清楚面试官会明显觉得你不仅会背原理还做过真实并发治理。十一、总结分布式锁这个问题真正难的不是“怎么写个锁”而是如何在异常退出、超时、误删和多实例竞争下仍然让锁真正可靠。如果只记一句结论我觉得可以记住这句大多数 Java 项目里分布式锁更适合用 Redisson 做工程化实现同时配合数据库唯一索引或状态机做最终兜底。也就是说锁负责减少并发冲突数据库负责保证最终正确性这套思路通常比“只靠手写 Redis 锁”更稳。十二、后续准备继续写的内容如果这篇你觉得还可以后面这个系列我准备继续写本地消息表到底怎么设计才靠谱高并发系统的限流、降级、熔断怎么配合使用一次线上缓存击穿和热点 Key 故障排查复盘MySQL 慢 SQL 在高并发场景下怎么定位和治理JVM Full GC 问题在线上怎么排查如果你也在做高并发相关业务欢迎交流。十三、结尾如果你觉得这篇文章对你有帮助欢迎点赞、收藏、关注。后面我会继续输出一些偏实战的 Java 后端文章。我是一个正在持续沉淀高并发与后端工程实践的 Java 开发也欢迎大家一起讨论更好的实现方案。

更多文章