
Redis分布式锁是所有中间件方案里被讨论最多、也最容易在面试里和线上同时翻车的话题。单机时代做互斥很简单加个synchronized或者用数据库唯一约束就行了可一旦服务拆成多个实例同一份资源会被多个进程同时操作这时候必须有一把让所有实例都能看到的锁Redis分布式锁就是为此存在的。Redis能当锁的载体性能好只是一方面更重要的是它提供了一条原子性的加锁命令配合Lua脚本解锁能完整串起一整套互斥流程。很多团队第一版锁代码都能跑通但一旦流量上来、任务变慢、Redis抖动各种隐蔽问题就全暴露了。这篇文章不打算背八股我会按实际踩坑顺序把从最基础的SET NX EX写法、误删锁、过期时间、看门狗续期、Redisson的RLock一直到Redlock的争议全部过一遍。刚接触分布式锁的人可以按顺序阅读面试前冲刺的人重点关注第5节已经在用锁但线上出过并发问题的人建议直接看第3节和第6节。1. Redis分布式锁到底解决什么问题1.1 从超卖场景说起单机锁为什么失效电商秒杀是最好的切入点。库存只有100件后端部署了10个实例每个实例都能收到用户请求。如果每个实例只在自己内存里做synchronized那10个实例都会各自收到一批请求并且每个实例内存里看到的库存都是100。当10个实例同时判断剩余库存大于0时就可能卖出去10件甚至更多这就是典型的超卖。单机锁失效的本质是每个进程的内存空间相互隔离A实例加的锁B实例根本看不见。分布式锁要做的是把锁的状态放到一个所有进程都能访问的公共存储里。谁能在公共存储里成功写入唯一标记谁就获得进入临界区的资格。分布式锁需要满足几个基本要求互斥性同一时间只能有一个客户端持有锁安全性不能因为锁的异常导致多个客户端同时进入临界区可用性加锁和解锁不因某个节点出问题而整体瘫痪最后是死锁预防锁必须能自动释放不能因为持有者崩溃就永久卡死其他请求。我见过很多团队写分布式锁的时候只盯着互斥性其他几条全忽略。结果上线后锁确实能保证大部分时间只有一个客户端在跑但一旦发生锁过期、节点重启、网络分区就出现两个客户端同时进入临界区。这种偶发问题比单纯拿不到锁更可怕因为拿不到锁是显性报错同时进入临界区是隐性数据错误。1.2 为什么选Redis而不是数据库或ZooKeeper实现分布式锁的公共存储有好几个选择。数据库可以用一张锁表插入唯一键代表加锁删除该行代表解锁。这个方案最直观但它的问题也很明显每次抢锁都是一次磁盘IO事务成本高数据库连接是昂贵资源为了锁长期占用连接非常不划算而且数据库本身也有锁竞争并发高的时候可能把主库打挂。ZooKeeper是另一个常见选择。它用临时顺序节点配合监听机制客户端断开后节点自动消失天然解决死锁问题可靠性比Redis高不少。但ZooKeeper是CP系统为了保证一致性写操作的吞吐量远低于Redis部署运维也比Redis重。对大多数互联网业务来说为了一个互斥锁去引入一个协调服务性价比不太合适。Redis能在这种比较里胜出本质上是性能和模型简单性之间的平衡。Redis是纯内存操作单线程执行命令对某个key的写操作天然是原子的。更重要的是Redis在2.6.12版本以后把SETNX和EXPIRE合并成了一个原子操作也就是SET NX EX这让加锁并设置超时变成了一条命令的事不再需要两条命令拼凑。这种原生的原子性是数据库表方案很难给的。不过Redis也有短板。如果Redis节点挂了或者发生主从切换锁的可靠性就会受到挑战。这个问题后面讲Redlock方案时会详细分析。先把这一步想清楚后续代码才不会写偏。我的原则是能用Redis解决的不要引入额外中间件但必须了解Redis方案的风险边界。2. 最基础的Redis分布式锁长什么样2.1 SET NX EX为什么是黄金组合最简单的加锁命令长这样SET lock_key unique_value NX EX 30000NX表示只在key不存在时才设置成功EX表示过期时间30000是毫秒。unique_value是当前客户端的唯一标识。这条命令的含义是如果lock_key这个key不存在我就写入unique_value并设置30秒过期如果已经存在直接返回失败。为什么必须有过期时间因为持有锁的客户端可能在执行任务时崩溃如果锁没有过期时间这个key会永远留在Redis里后续所有客户端都拿不到锁系统等于整体停产。设置过期时间就是一种兜底最坏情况下持有锁的进程没来得及释放就挂了锁也会在若干秒后被Redis自动清除。有人会问为什么不用SETNX再单独执行一条EXPIRE很多老代码确实是这么写的但这里有一个非常经典的坑SETNX成功之后、EXPIRE执行之前如果进程崩溃了锁就永远不释放。虽然这个时间窗口极短但分布式场景里任何小概率事件在高峰流量下都会被放大成事故。所以一定要用合并后的SET NX EX这是一条原子命令不存在中间状态。2.2 可复制的第一版加锁/解锁代码我用Java和Spring Boot的写法示范一版最基础的实现。选Java是因为Spring生态里用Lettuce或Jedis的人最多但核心逻辑换成其他语言也一样。public class SimpleRedisLock { private final StringRedisTemplate redisTemplate; private final String lockKey; private final String lockValue; private final long expireMs; public boolean tryLock(String lockKey, String lockValue, long expireMs) { Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireMs)); 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; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue); } }加锁时setIfAbsent对应SET NX EX这个没什么好说的。重点在解锁为什么不能用最简单的delete因为存在一个非常经典的场景客户端A持有锁执行任务时锁到了过期时间被自动删除此时客户端B加锁成功拿着同一把锁开始处理业务A的任务终于执行完了如果它直接执行delete会顺手把B的锁删掉。删掉B的锁意味着B和后续客户端C可能同时进入临界区这是毁灭性的并发事故。所以在解锁前必须先比较value只有value是自己的才能删除。这种先比较再删除的操作必须放在Lua脚本里原子执行因为如果先GET比较、再单独执行DEL两个命令之间可能有其他客户端插入操作依然会出问题。2.3 写锁时最容易犯的三个基础错误第一版代码看着简单实际落地时还有几个容易忽略的点。第一个是setIfAbsent的返回值。Spring Data Redis的setIfAbsent返回的是Boolean包装类型在某些连接异常场景下可能返回null。如果你直接写成if (setIfAbsent(...))null会触发空指针。用Boolean.TRUE.equals()包一层再判断能同时规避null和自动拆箱的坑。第二个是锁的粒度。review代码时我最常提醒的就是锁key不能太粗。比如更新用户资料的接口你把锁key写成user_update_lock_global那所有用户都抢同一把锁接口吞吐直接被打成串行。更合理的做法是带业务维度写成user_update_lock_{userId}或者order_lock_{orderId}。锁key越细并发能力越强但锁数量会增多Redis内存开销也会上升。在互斥和并发之间取得平衡是分布式锁设计的基本功。第三个是序列化方式。如果你用了StringRedisTemplatevalue存的就是字符串比较起来没有歧义。但如果用了JDK默认序列化value会被序列化成二进制命令行里看就是乱码虽然锁逻辑不会出错但排查问题时非常迷惑。建议团队在锁相关的操作上统一使用StringRedisTemplate或者在RedisConfig里显式配置String序列化器。3. 那些坑过期时间、误删、可重入与续期3.1 锁过期导致两个线程同时进临界区最隐蔽的问题是锁的过期时间到底该怎么设。假设你设置的是10秒而业务代码在锁内要执行的操作需要15秒。时间一到Redis自动删除key客户端B加锁成功但客户端A还没执行完。结果就是两个客户端同时进入了临界区锁的安全性在这一瞬间完全失效。有人说那把过期时间设成120秒不就行了问题在于你很难预估业务最长耗时。业务依赖的下游可能抖动数据库可能突然出现慢查询一个平时3秒的任务偶尔跑3分钟也完全可能。过期时间设短了长任务抖动就出问题设长了一旦持有锁的进程真崩溃其他客户端要干等很久才能抢到锁。所以设置一个固定的合理过期时间只是权宜之计真正稳妥的做法是让锁的持有时间能够动态续期。这里我想强调一个容易被忽视的点锁的过期时间不是越小越好也不是越大越好它需要和你对业务的耗时预估绑定。如果你做的是缓存更新2秒就够如果是异步任务处理30秒起步。但无论设多少都必须有一个配套的兜底机制防止任务实际耗时超过预估。3.2 误删别人锁的经典事故是怎么发生的前半部分我提到解锁必须校验value这里再把误删场景完整讲一遍。假设客户端A拿到锁后执行任务任务在9秒时完成准备执行解锁但网络抖动了一下解锁请求10.5秒才到达Redis。锁在第10秒过期客户端B在10.1秒加锁成功。A的解锁请求到达后如果没有value校验会把B的锁直接删除。这个事故最麻烦的地方在于它不是必现的只在任务时间接近过期时间、且解锁网络有抖动时才会出现。很多团队上线了一两个月都没事某天大促流量上来任务压力变大才开始暴露出并发问题。排查时从日志看两组请求确实都进入了临界区但大家第一反应是怀疑缓存配置问题很难立刻联想到锁的value校验没做。我之前见过一个团队为了规避这个问题把解锁脚本改成如果值不等于自己就等5秒再重试以为这样能避开竞态。实际上这个方案更糟糕。延迟解锁会让任务整体变慢而且等待期间锁可能被别人反复抢走又释放重试时你根本无法保证当前持有者是谁。正确的做法只有一种Lua脚本里GET后DEL并且保证GET和DEL在同一个原子操作内执行。3.3 可重入与锁续期这样设计才稳妥可重入锁的意思是同一个线程在持有锁的情况下可以再次获取同一把锁。Redis的原生SET NX不支持可重入但业务里经常遇到递归调用或者嵌套调用同一个锁的场景。第一种方案是用ThreadLocal记录当前线程持有锁的次数加锁时如果发现锁的value是自己的线程标识就只累加计数不真正去调Redis解锁时递减计数减到0才真正删除key。这个方案能满足单线程重入缺点是锁的元信息和业务状态耦合代码会变复杂。第二种方案是把可重入逻辑整体交给Redisson它的RLock实现了和Java的ReentrantLock类似的语义后面会细说。这里先强调一个原则分布式锁的可重入不要做成无限可重入。业务里出现过因为锁内又调用了需要同一把锁的方法导致计数永远不为0、锁永远不会释放的案例。可重入逻辑必须明确层级上限同时配合过期时间和看门狗兜底。锁续期是另一个绕不开的话题。续期的本质是在锁快要过期时如果持有者还在执行业务就自动把过期时间延长。常见的设计是看门狗线程每隔过期时间的1/3去检测当前锁是否还由自己持有如果是就延长过期时间。关键前提是续期也必须先判断value是自己的否则就是在帮其他客户端续期那比不续更可怕。4. Redisson与Redlock从简单用到选型边界4.1 Redisson怎么把坑都封装掉Redisson是Java生态里使用最广泛的Redis客户端之一它提供的RLock把加锁、解锁、续期、可重入都封装得比较完整。用法上和JDK的Lock很像RLock lock redissonClient.getLock(order_lock: orderId); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { // 这里写需要互斥的业务逻辑 } } finally { if (locked) { lock.unlock(); } }tryLock的第一个参数是获取锁的等待时间第二个参数是锁的租约时间。如果传入leaseTimeRedisson不会启动看门狗锁在指定时间后自动释放。如果不传leaseTimeRedisson默认启动看门狗锁的有效期默认是30秒看门狗每隔10秒检查一次如果锁还在就把过期时间重置成30秒。这就解决了业务耗时超过固定过期时间的问题。看门狗的具体做法是加锁成功后在客户端本地启动一个后台任务延迟到leaseTime/3后执行执行时调用Lua脚本续期。如果持有锁的客户端宕机看门狗线程随之消亡锁最多在30秒后自动过期释放不会造成永久死锁。这个机制的本质是同时实现了业务没结束就续期和客户端崩了就自动释放两个目标。我的建议是不要一开始就手写续期逻辑。自己写看门狗会遇到线程安全、异常处理、Redis命令性能等一系列问题除非你们有非常特殊的需求否则直接用Redisson是最划算的选择。你真正需要关心的是Redisson版本和Redis服务端的兼容性以及Spring Boot集成时Bean的初始化方式。本地开发时常见的问题是Redis没有设置密码或者配置了Acl权限导致Redisson客户端连不上这个和锁本身无关但排查起来很浪费时间。4.2 Redlock的争议与适用边界Redlock是Redis官方提出的多节点锁方案核心思想是不依赖单点Redis而是向N个独立的Redis节点同时加锁当且仅当超过一半节点加锁成功时才认为持有锁。它的设计动机是解决单点故障如果一个Redis节点挂了其他节点还能继续提供服务。但Redlock在工业界有比较大的争论。《数据密集型应用系统设计》作者Martin Kleppmann写过一篇著名的反驳文章核心观点是Redlock并没有解决分布式锁最本质的网络延迟导致的锁异常问题。比如客户端A加锁成功后发生网络分区A以为自己持有锁同时客户端B在另一个分区里也成功加锁此时就会出现两个客户端同时进入临界区。Redis作者Salvatore Sanfilippo也有回应认为在延迟足够低、网络足够稳定的场景下Redlock是可用的。我的实际建议是对大多数互联网业务特别是请求量高但并发写冲突不频繁的场景单节点Redis配合Redisson已经够用。只有当你们对锁的可用性要求极高并且能接受Redlock带来的复杂度时才考虑多节点方案。如果要上Redlock建议部署奇数个节点比如5个Redis实例并且这些实例之间完全独立不共享持久化文件不能放在同一个机房机架里否则断电就全部失效。还要提醒一句不要把Redlock当成银弹。分布式锁的正确性最终依赖业务对什么情况下可以容忍两个客户端同时进入临界区的定义。如果你的业务是扣减库存这种强一致操作光靠锁并不够还需要配合幂等、版本号或者数据库乐观锁。选型时先问自己业务真的需要这么强的锁吗如果只是防止重复提交、防止超卖这种缓存级别的控制单实例锁足够了。5. 高频面试题与线上排查实录5.1 面试里最容易被追问的细节分布式锁是中间件面试的保留项目。常见的追问链路我整理成了一张速查表。面试问题期望的回答方向常见的错误回答为什么用SET NX EX而不是SETNX加EXPIRE两条命令非原子崩溃会导致死锁因为更快解锁为什么要用Lua脚本先GET比较再DEL必须原子执行防止误删为了提升性能锁的value里放什么客户端唯一标识比如UUID加线程ID随便一个字符串过期时间设置多少合适结合业务耗时10到30秒配合续期越大越好主从切换是否会丢锁会主节点加锁后没同步到从节点切换后锁丢失Redis是单线程不会丢如何实现锁续期看门狗每隔过期时间的三分之一续期续期前判断value定时任务全量续期面试官问这些问题的目的通常不是要一个标准答案而是想看你有没有踩过坑。我建议准备几个真实场景讲出来比如我曾经在解锁时没有校验value导致线上两个实例同时写数据后来改成了Lua脚本。真实的故障复盘远比背十个知识点更有说服力。5.2 一个让我改了三版锁的线上案例真实发生过一个订单状态更新的接口。业务逻辑是读订单、校验状态、更新数据库、发消息。第一版用RedissontryLock的等待时间设成了0意思是抢不到就立刻放弃。上线第一天就出现大量订单繁忙错误原因是同一订单并发请求多后面的请求全部快速失败。第二版优化是把等待时间调整成5秒请求会阻塞等待上一个请求释放锁。结果更糟Tomcat线程被占满阻塞在锁等待上的请求越积越多吞吐量反而下降了。后来我们换了一种思路把锁的粒度从整个订单改成订单状态机里需要互斥的状态转移。分析后发现真正需要互斥的只有从待支付到已支付那一步查询和写库本身可以并行。锁的时间窗大幅缩短等待时间自然不需要那么长。这个案例让我明白一个道理分布式锁的很多问题不是锁本身的问题而是锁的粒度设计问题。拿到锁之后做的事情越少、越快锁需要设置的过期时间就越短出问题的概率就越低。很多时候你优化的不是锁而是锁定临界区的代码范围。5.3 Lua脚本里的两个隐蔽坑排查过程中我还发现两个比较隐蔽的坑值得专门写出来。第一个是Lua脚本的value传参。锁的value如果含有特殊字符比如单引号而代码里不是通过ARGV传参而是直接拼接在脚本字符串里就会破坏Lua的语法结构。所以必须用参数化方式传value不能把用户输入直接嵌入脚本。第二个是Redis Cluster的CROSSSLOT限制。在集群模式下Lua脚本操作的key必须都在同一个slot否则会报CROSSSLOT错误。分布式锁的key一般只有一个解锁脚本也只操作一个key所以通常没问题。但如果你在解锁脚本里顺带删了另一个业务key就要特别注意slot策略。这个问题在单机Redis上永远不会出现迁移到集群时才会突然爆发。6. 一些值得坚持的实践规范6.1 代码评审时盯紧三个点评审锁相关的代码时我重点看三处。第一解锁必须放在finally里防止业务异常导致锁长期不释放。第二tryLock的返回值不能直接当boolean用要注意区分本次获取锁失败和异步任务获取锁超时的差异。第三不要在持有锁的代码里调用外部接口因为外部接口的耗时不可控很容易导致续期和过期之间的竞态这是线上翻车的高发区。除了这三个点我还会要求团队在锁的value里带上业务标识和线程ID。这样做不是为了什么花哨的命名规范而是出现问题时你能从Redis里看到当前锁是谁持有的、已经在位多长时间这对快速定位问题帮助很大。6.2 监控与告警比写锁本身更重要线上一定要对加锁失败率、锁等待时长、锁持有时长做监控。加锁失败率可以通过业务埋点统计锁等待时长可以在tryLock调用处记录耗时锁持有时长可以在解锁时打印耗时。如果锁持有时间长期超过过期时间的一半说明过期时间设置偏短或者业务在锁内做了太重的事需要尽快优化。有一点容易被忽略Redis的慢命令日志也要关注。如果锁相关的命令频繁出现在慢日志里说明value太大、key太多或者Redis实例本身有压力。分布式锁生成的请求量一般不大但如果key设计得太碎比如每秒创建几十万个锁key对Redis的内存和CPU也是不小的负担。把监控和日志一次性加上出了问题才能快速定位否则只能盲猜。6.3 最后分享一个维护细节分布式锁的key不应该混在普通业务缓存里一起管理。锁key过期后如果被LRU淘汰或者被缓存清理任务误删都会导致锁提前失效。建议给锁key加统一前缀例如lock:并且在Redis配置里对这类key设置单独的过期策略。另外还要注意锁时间与时钟的关系。Redisson的续期是基于客户端本地时钟的如果服务器时钟被NTP强制校准出现大幅跳变续期逻辑可能错乱。虽然这种情况很少见但生产环境的时钟一致性仍然值得关注。我在一个项目里就遇到过因为运维调整服务器时间导致看门狗判断异常、锁提前释放的问题后来通过统一使用Redis服务端时间作为判断基准才稳定下来。分布式锁没有绝对完美的方案关键是清楚知道自己用的是哪种方案、它存在什么短板、业务能不能容忍最坏情况。把这些想明白Redis分布式锁才能真正成为你手里的趁手工具而不是定时炸弹。