
分布式锁这个话题我写过好几轮代码也在生产环境里踩过不少坑。每次聊到它总有人把“用 SETNX 就能实现”挂在嘴边但真上了生产就会发现事情没那么简单。今天这篇就把 Redis 分布式锁从原理到落地完整梳理一遍包括很多人没搞清楚的原子性、续期机制、RedLock 争议最后再用 Docker 镜像搭一套主从环境实测一把。文中的代码和步骤都可以直接参考希望能给正在做技术选型或者准备面试的朋友一些帮助。1. 分布式锁解决的是什么问题1.1 一个典型的并发冲突场景假设你有一个商品库存服务库存只剩 1 件这时候来了 10 个用户同时下单。每个请求进来都会执行“查库存、扣库存、写回数据库”这组操作。如果这 10 个请求被同一个进程处理单机锁就能挡住大问题。但现代服务为了保证可用性几乎都是多实例部署请求会负载均衡到不同机器上这时单机锁就失效了。用 MySQL 的行锁也能处理扣库存但行锁有性能瓶颈而且会把数据库的负载顶上去。很多团队会选择把这类高频、短时效的互斥操作放到 Redis 上Redis 的原子命令和内存性能天然适合做锁的载体。1.2 为什么单机锁不够用JVM 里的 synchronized 和 ReentrantLock 都属于进程内锁它们的底层机制是操作系统提供的线程同步原语锁状态记录在本地内存里。一旦请求分散到多个 JVM 实例A 机器上的锁对 B 机器完全不透明B 机器根本不知道 A 机器已经持锁了于是两个机器上的线程可以同时进入临界区。分布式锁就是把“互斥判断”放到所有实例都能访问的公共组件上。谁先在 Redis 里成功写入了锁 key谁就拿到锁其他实例只能等待。这个设计思路本质上就是把锁状态的存储位置从进程内存搬到了中心化存储里。1.3 一把合格的分布式锁必须具备的三个条件第一是互斥性任意时刻只能有一个客户端持有锁这是最基本的。第二是防死锁如果持有锁的客户端崩溃或者网络中断锁必须能自动释放不能永远卡住其他请求。第三是高性能和高可用加锁和解锁的耗时必须可控Redis 自身也要稳定。除了这三个硬性条件实际工程里还会考虑锁的可重入性、锁等待策略、续期机制、以及主从切换时的安全性。这些细节直接决定了一套方案是玩具还是真能上生产。1.4 主流实现路线为什么选了 Redis业界做分布式锁的主流路线有三种数据库、ZooKeeper、Redis。数据库方案最简单直观建一张锁表或者用SELECT ... FOR UPDATE实现可靠但性能差而且数据库单点风险也不小。ZooKeeper 通过临时顺序节点实现锁可靠性好但需要额外维护一套 ZooKeeper 集群运维成本高客户端复杂度也大。Redis 的方案胜在性能高、接入成本低很多项目本身就依赖 Redis 做缓存顺手就把锁也做了。不过它也有自己的弱点也就是后面要重点聊的主从切换和持久化问题。2. Redis 分布式锁的原子性演进2.1 从 SETNX 到 SET NX EX 的必然选择早期实现分布式锁最常用的是SETNX命令全称是SET if Not eXists只有 key 不存在的时候才能设置成功。用法很简单SETNX lock_key 1执行成功返回 1说明拿到锁返回 0说明锁被别人持有。但单独用SETNX有个致命问题如果客户端拿到锁以后崩溃了没有机会执行释放锁的命令这个 key 就会永远留在 Redis 里形成死锁。给锁 key 加过期时间能解决崩溃死锁的问题但是SETNX和EXPIRE分成两步执行会引入新的风险。如果第一步成功、第二步失败比如 Redis 命令超时锁依然没有过期时间死锁问题照样存在。正确的做法是使用 Redis 2.6.12 之后提供的SET命令扩展参数把加锁和设置过期时间合并成一个原子操作SET lock_key unique_value NX EX 30NX表示只有 key 不存在时才生效EX 30表示锁的过期时间是 30 秒unique_value是客户端生成的唯一标识这个命令本身就是原子的不管客户端在执行过程中遇到什么异常Redis 端要么同时完成设置 key 和过期时间要么什么都不做不存在中间状态。这是整个 Redis 分布式锁实现里最基础也最重要的一环。2.2 释放锁为什么必须用 Lua 脚本加锁要原子性释放锁同样要原子性。网上很多教程里释放锁的写法是GET lock_key # 如果 value 是自己再执行 DEL DEL lock_key这个流程看起来没问题但GET和DEL是两个独立命令中间一旦有延迟就会产生严重的安全隐患。举个例子线程 A 拿到锁执行业务时发生 Full GC 或者其他原因导致卡顿锁超时自动释放了线程 B 随后拿到同一个锁线程 A 恢复过来执行GET发现 key 存在返回的还是自己的 value于是执行DEL把线程 B 的锁删掉了。这就是经典的误删问题。解决办法是把检查和删除合并成一条 Lua 脚本让 Redis 端保证判断和删除的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的含义是先获取锁 key 的 value只有 value 和当前客户端持有的一致时才对 key 执行删除。整个过程在 Redis 服务端一次完成执行期间不会插入其他命令从根本上杜绝了删错锁的可能。2.3 锁的 value 到底应该存什么很多初学者加锁时 value 随便填比如直接填 1。这么做的隐患是所有客户端写入的 value 都一样释放锁时根本无法区分锁是不是自己持有的一旦发生时间线交叉误删的概率极高。生产环境里 value 一般使用 UUID 或者“业务标识 UUID”的组合确保每个客户端、每次加锁生成的 value 都是唯一的。这样在释放锁时通过 Lua 脚本做 value 比对才真正具备“只有持有者才能释放”的语义。2.4 过期时间设置的几个教训过期时间不能拍脑袋定。定太短业务还没执行完锁就自动释放别的线程提前进入临界区互斥性被破坏定太长持有锁的客户端万一真的崩溃其他线程要等很久才能抢到锁相当于把故障恢复时间拉长了。一个比较靠谱的思路是先根据业务耗时峰值设定一个基础过期时间比如压测得到接口 P99 耗时是 800 毫秒那初始过期时间可以设为 5~10 秒留足余量。然后配合续期机制在业务执行过程中动态延长过期时间。后续会讲到 Redisson 的看门狗就是干这个事的。如果没有续期机制宁可把过期时间设长一点也不要设太短导致并发穿透那样会引发更严重的资源竞争问题。3. 从手写 Redis 锁到使用 Redisson3.1 手写锁到底差在哪手写SET NX EX Lua 脚本这套方案能把分布式锁的最基础功能做出来也能应付一些简单场景。但真正放到生产环境里你会发现还有一堆问题没有解决。第一个问题是阻塞等待。一个线程没抢到锁后是立刻返回失败还是继续等待如果业务要求排队执行就需要自己实现自旋重试逻辑。第二个问题是续期。你设置锁过期时间 30 秒但业务执行了 60 秒这期间谁来负责延长锁的生命周期如果锁提前过期临界区就可能同时进入多个线程。第三个问题是可重入。同一个线程多次调用加锁逻辑怎么保证第二次加锁不会阻塞自己手写需要维护一个线程身份的计数映射。第四个问题是异常处理比如 Redis 连接断开、命令超时等边界情况。这些问题的解决方案不是没有但每一样都要自己写代码写完之后还要测试、压测、排查并发问题成本非常高。Redisson 这个客户端库把这些问题都封装好了所以生产环境我强烈建议直接用它。3.2 Redisson 快速上手Redisson 是 Java 生态里一个功能非常全面的 Redis 客户端分布式锁只是它的一个子功能。引入依赖很简单如果是 Maven 项目dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.4/version /dependency基本用法如下Autowired private RedissonClient redissonClient; public void handleOrder(Long orderId) { RLock lock redissonClient.getLock(order:lock: orderId); try { // 尝试加锁最多等待 3 秒加锁后 10 秒自动释放 boolean isLocked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!isLocked) { throw new RuntimeException(系统繁忙请稍后重试); } // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(加锁过程被中断, e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }tryLock方法的第一个参数是获取锁的最长等待时间第二个参数是锁的自动过期时间。如果在等待时间内拿不到锁就直接返回 false不会一直阻塞线程。3.3 看门狗机制是怎么实现的看门狗是 Redisson 分布式锁里最核心的机制之一。当你使用lock()或tryLock(waitTime, leaseTime)且没传 leaseTime或者传 -1时Redisson 会启动一个后台定时任务默认每隔 10 秒检查一次锁是否还持有如果还持有就把锁的过期时间重置为 30 秒。这个 30 秒是 Redisson 的默认 lockWatchdogTimeout 参数。看门狗的核心价值在于锁不会因为业务执行时间过长而提前自动释放极大地降低了并发穿透的风险。同时如果持有锁的客户端实例宕机看门狗也会随之停止锁到达过期时间后依然会被 Redis 自动清理不会死锁。这里有一个细节容易被忽略如果你在tryLock里明确指定了 leaseTime比如tryLock(3, 10, TimeUnit.SECONDS)Redisson 就不会启动看门狗因为应用已经明确告诉它锁的生命周期就是 10 秒。这个设计是合理的但很多人没注意导致设置了一个过短的 leaseTime 后业务还没跑完锁就没了。3.4 Redisson 的锁家族不止一种Redisson 提供了多种锁类型除了最常用的RLock可重入锁还支持公平锁FairLock、读写锁ReadWriteLock、信号量Semaphore和闭锁CountDownLatch。公平锁RedissonFairLock按照加锁请求的顺序分配锁适合需要严格排队执行的场景。读写锁适合读多写少、读读不互斥、读写互斥的业务比如配置中心和缓存刷新。信号量适合控制并发流量比如限制某个接口同时最多只能有 20 个请求在处理。锁家族看起来很丰富但生产环境里实际最常用的还是普通可重入锁。3.5 tryLock 参数设置的实用建议等待时间waitTime不能设太大否则大量线程都阻塞在获取锁上Redis 的连接池会被耗尽。一般压测时观察业务的 TP99 耗时把 waitTime 设置为这个值的 2~3 倍比较合适。leaseTime 如果设置了就一定要评估业务最大耗时留足 3 倍以上的余量。如果业务耗时不可控就直接用默认的看门狗续期不要设 leaseTime。另外一个建议是锁 key 的粒度要尽量细。能用订单号、用户 ID 做维度就不要用全局字符串。锁的粒度越粗并发竞争越激烈系统吞吐量越低。举个例子库存扣减用stock:lock:{skuId}就比全局stock:lock合理得多。4. RedLock 高可用方案的争议与实践4.1 主从切换会导致锁失效Redis 生产环境大多采用主从加哨兵的部署架构主节点负责写从节点负责同步复制数据。这里的关键问题是Redis 的主从复制默认是异步的主节点写入一条数据后不会等从节点确认就返回给客户端。假设线程 A 在主节点上成功写入了一把锁这时候主节点宕机。哨兵检测到主节点不可用后会触发故障转移把一个从节点提升为新的主节点。但问题在于旧主节点上的锁数据可能还没有同步到这个新的主节点上线程 A 的锁在故障转移后凭空消失了。此时线程 B 也来加锁新主节点上不存在这把锁的 key于是加锁成功。线程 A 和线程 B 同时获得了同一把锁分布式锁的互斥性就被破坏了。4.2 RedLock 的设计思路为了解决多副本场景下锁丢失的问题Redis 作者 antirez 提出了 RedLock 算法。核心思想是对锁进行多节点存储让锁不依赖于某一个 Redis 节点。RedLock 的做法是准备 N 个相互独立的 Redis 节点官方建议 N 取 5而且这 5 个节点要完全独立不能相互复制加锁时向所有节点发送SET NX EX命令。只要在锁的有效时间内超过一半的节点比如 5 个节点中至少 3 个加锁成功就认为整体加锁成功。释放锁时向所有节点发送释放锁的 Lua 脚本。这个设计的假设是即使少数节点发生了故障或者数据丢失只要大多数节点还保留着锁的 key锁就不会整体失效。同时为了避免客户端花太多时间在故障节点上加锁RedLock 要求加锁总耗时必须小于锁的过期时间否则加锁失败。举例来说锁的有效期是 10 秒向 5 个节点加锁的过程已经花了 11 秒那这次加锁就是无效的。4.3 业界对 RedLock 的评价RedLock 提出后分布式系统领域知名专家 Martin Kleppmann 发表过一篇《How to do distributed locking》对 RedLock 提出了不少质疑主要集中在几点时钟跳跃可能导致锁提前过期通过日志或 GC 停顿可以让持有锁的客户端“意识不到”锁已经过期从而执行了本不该执行的操作锁的可靠性依赖物理时钟这在分布式系统里是很危险的假设。antirez 随后也做了回应表示 RedLock 面向的场景是“偶发的锁失效可接受但概率要极低”不是绝对严格的安全锁。两位大神的争论至今没有完全定论但这说明了一个事实RedLock 也不是银弹它解决主从切换问题的思路是合理的但引入了更多的复杂性和新的假设使用时必须结合具体业务场景来判断。4.4 生产环境到底怎么选从我自己的实践经验来看大部分互联网业务场景其实不需要上 RedLock。原因有二一是 RedLock 需要至少 5 个独立 Redis 节点成本和运维复杂度都不低二是大多数业务并发写冲突的代价是可控的比如多扣一单库存可以通过对账修正并没有达到金融级那种绝对不可容忍的程度。如果对锁的可靠性要求很高比如涉及资金操作、抽奖库存等场景我更推荐的做法还是尽量缩短锁内业务耗时并让 Redis 主从切换期间对业务做柔性降级比如直接返回系统繁忙。有些团队也会选择用 ZooKeeper 或者 etcd 来实现分布式锁这类强一致性的组件天然比 Redis 更能抵抗主从切换时的锁丢失问题。Redis 官方对 RedLock 的实现也保持了保守态度在很多客户端库里没有把它作为默认方案。使用它的团队基本都是业务上明确评估过风险并且愿意为额外的复杂性买单。5. 常见问题排查与避坑清单5.1 锁误删的经典场景锁误删最常见的原因就是前面提到的 GET DEL 两步操作不具备原子性。另一个高发场景是业务把锁的 value 设置为固定字符串比如1所有线程都能通过 value 校验这时候删除操作形同虚设。排查时先看代码里释放锁的逻辑是否用了 Lua 脚本是否对 value 做了唯一性比对。排查分布式锁问题最好的入口就是 Redis 的慢查询日志和命令监控看看EVAL和SET命令的调用频率和耗时。如果EVAL命令执行耗时异常通常是返回的脚本体过大或者 Redis 实例 CPU 过高需要结合监控一起看。5.2 锁超时引发并发穿透怎么处理锁超时是一个无法完全避免的问题因为任何分布式系统都有故障概率。Redisson 的看门狗能把锁提前过期的时间窗口压缩到很小但一旦客户端进程发生长时间的 STWStop The World垃圾回收停顿看门狗本身也会停摆锁照样会过期。应对策略是尽可能缩短锁内执行耗时不做远程 RPC、不查大表、不循环遍历集合如果条件允许在临界区增加版本号或者状态比对配合数据库乐观锁做兜底对于资金类的强一致操作可以把并发控制下沉到数据库用带条件的 update 语句保证最终一致性。5.3 锁 key 冲突的问题锁 key 的命名规范看起来很基础但很容易被忽略。例如两个团队各自开发了类似功能都没有注意全局命名统一一个用了order:lock另一个用了order:pay:lock结果功能上线后互相干扰锁的粒度完全错乱。建议团队成员约定统一的 key 规范比如以业务线为前缀用冒号分隔层级同时把 key 的语义写清楚。5.4 Redis 连接吃紧和慢命令问题分布式锁是高频操作每次加锁和解锁都会占用一个 Redis 连接。如果应用的 Redis 连接池设置得太小高并发下会出现大量线程等待获取连接加锁耗时直线上升。遇到这类问题先把连接池的最大连接数调大同时检查是否有连接泄漏比如流程异常时没有正确归还连接。Redisson 的配置项里可以针对锁操作设置独立的连接池大小避免与普通缓存读写互相争抢连接资源。5.5 分布式锁面试高频追问整理结合我自己被问过以及面试别人时爱问的问题整理了一张速查表方便对照自检问题核心答案要点Redis 分布式锁的实现原理SET NX EX 原子命令 Lua 脚本释放锁释放锁为什么用 Lua 脚本保证判断 value 和删除 key 的原子性防止误删锁的过期时间怎么定结合业务耗时估算留足余量尽量使用看门狗续期可重入是怎么实现的Redisson 内部通过 threadId 和计数器实现主从切换会不会丢锁会异步复制存在数据丢失窗口RedLock 解决了什么问题通过多个独立节点半数加锁降低单点故障带来的锁丢失概率看门狗原理是什么默认每 10 秒将锁过期时间重置为 30 秒锁失效了怎么办业务幂等设计 数据库兜底 降级策略6. 用 Docker 镜像快速搭建 Redis 主从环境实测6.1 准备环境与拉取镜像纸上谈兵没用我直接把 Redis 主从环境用 Docker 镜像拉起来实测一遍锁的行为。我的机器上用的是 Ubuntu Docker如果你用 macOS 或者 Windows 的 Docker Desktop操作基本一样。先拉取官方 Redis 镜像这里我选择 7.2 版本的镜像稳定性和 Redis 命令都不缺docker pull redis:7.2启动第一个主节点命名为 redis-master映射宿主机的 6379 端口docker run -d --name redis-master -p 6379:6379 redis:7.2再启动从节点映射宿主机的 6380 端口并且用--slaveof参数把它指向主节点docker run -d --name redis-slave -p 6380:6379 --link redis-master redis:7.2 redis-server --slaveof redis-master 6379这里有个启动参数的坑要提醒一下如果用的是 Redis 5 及以上的版本--slaveof会被解析成启动参数需要确认镜像里的 Redis 版本支持如果失效可以进入容器后用SLAVEOF命令手动设置主从关系。6.2 验证主从复制等待几秒钟让主从建立连接在宿主机上执行docker exec -it redis-master redis-cli INFO replication输出里能看到role:master并且有connected_slaves:1这样的信息说明从节点已经连接上了。再在从节点上确认docker exec -it redis-slave redis-cli INFO replication能看到role:slave、master_link_status:up。此时在主节点写入一条测试数据docker exec -it redis-master redis-cli SET test_key hello在从节点读取docker exec -it redis-slave redis-cli GET test_key能返回hello说明主从同步正常。这是最直观的验证方式。6.3 模拟主节点宕机观察锁丢失现在我们在主节点上模拟一把分布式锁docker exec -it redis-master redis-cli SET business:lock client-A NX EX 60从节点查这把锁大概率也能查到因为 Redis 复制会把这把锁的 key 同步过去。但这恰恰体现了问题所在如果锁还没同步到从节点主节点就宕机了锁就丢了。为了模拟这个极端场景可以在写锁后立即把主节点容器停掉docker stop redis-master然后检查从节点的数据docker exec -it redis-slave redis-cli EXISTS business:lock如果返回 0说明锁确实丢了如果返回 1说明同步赶上了但这是运气问题。真正的风险在于同步永远存在一个时间窗口这个窗口内主节点故障锁就必然丢失。这也解释了为什么单独的 Redis 主从架构并不能百分之百保证分布式锁的可靠性。6.4 结合 Redis Desktop Manager 查看锁状态如果不想一直敲命令可以用 Redis Desktop Manager 可视化查看。在 RDM 里新建连接填写宿主机的 IP 和端口 6379连接上 Redis 主节点以后可以看到 key 列表也可以直接查看business:lock这个锁 key 的剩余过期时间。对于排查锁是否误删、value 是否一致这类问题可视化工具可以直接看到每个 key 的 value 和 TTL非常方便。6.5 实际测试给我带来的几点启发通过这次本地实测我有几个感受Redis 主从架构下的分布式锁确实存在数据丢失窗口这是架构层面无法回避的问题手工设置过期时间需要十分谨慎几十秒的锁和几秒的锁在复杂业务下的表现差异巨大可视化工具至少能帮你快速确认锁的 key 是否还在、TTL 是否符合预期。最后别忘了清理测试环境把容器停掉并删除避免端口占用docker stop redis-master redis-slave docker rm redis-master redis-slave7. 最后的经验把锁当成最后一道防线折腾分布式锁这些年我最大的体会是锁永远不应该成为保障数据一致性的唯一手段。Redisson 再稳、Lua 脚本再严谨也顶不住业务代码在持锁状态下执行了不可控的远程调用或者数据库主从延迟带来的脏读。一个成熟的系统设计应该把幂等、乐观锁、状态机校验放在更靠前的位置分布式锁只作为兜底手段防止真正并发的边缘情况而不是把所有希望都压在锁上。另外一个小技巧业务代码里释放锁的finally块务必先判断isHeldByCurrentThread()再执行unlock()否则在 Redisson 的看门狗场景下可能会因为锁已经自动过期并重新被别人获取导致unlock抛异常。这个细节我见过不止一次线上出现异常告警基本都是这个原因。希望这篇内容能帮你把 Redis 分布式锁讲透彻。有任何不同观点欢迎多交流毕竟分布式场景下的方案选型没有绝对的对错只有合适与否。