尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

分布式锁原理与实战:从synchronized到Redis与ZooKeeper

分布式锁原理与实战:从synchronized到Redis与ZooKeeper 如果你维护过一套多实例部署的订单系统大概率碰到过这种诡异现象明明代码里加了synchronized秒杀活动一到库存还是扣成了负数。第一次遇到这事别怀疑是 JVM 锁失效了而是因为你写的锁根本没有锁到分布式环境下的另一台服务器上。这就是分布式锁要解决的问题。很长一段时间里我所在的项目从单机部署改成多实例部署第一波线上事故几乎都跟锁有关定时任务重复执行、用户重复下单、缓存并发重建……追根究底都是同一个资源被多个进程同时操作引发的。后来我把分布式锁的原理和几种落地方式彻底理了一遍才明白之前踩的坑大多不是锁没实现而是对分布式环境下的互斥到底意味着什么理解不够。这篇博文我会从单机锁为什么失效讲起对比数据库、Redis、ZooKeeper 三条实现路线然后重点拆解 Redis 分布式锁的正确用法和实战中的各种坑最后附上一份自查清单和面试题整理给正在做架构设计或者准备面试的同学一个能直接参考的路径。1. 为什么单机锁到了分布式环境下就失灵了1.1 从 synchronized 聊起锁的本质是什么单机时代我们写并发代码最常用的就是synchronized或者ReentrantLock。它的底层原理简单说就是 JVM 在内存里维护了一个互斥标记线程进入同步块之前要去抢这个标记抢到的人执行执行完释放其他线程继续抢。这个模型在单进程内很好用因为所有线程共享同一块内存标记是全局可见的。但一旦应用部署成多实例情况就变了每个 JVM 进程有自己独立的内存空间synchronized锁住的只是当前 JVM 内部的线程。A 实例上的线程抢到了 A 进程内的锁B 实例上的线程根本感知不到它照样可以进入临界区。我见过不少人一开始会把问题想简单觉得我用分布式事务、用唯一索引、用乐观锁替代不就行了。这些手段各有场景但都解决不了多进程互斥地执行一段逻辑这个通用诉求。分布式锁的价值就在于把互斥状态从进程内搬到所有竞争节点都能访问的公共存储上让所有进程遵循同一个规则去抢锁。1.2 分布式环境下单机锁的三个死穴第一个死穴是内存不共享。刚才已经说了这是最根本的差异。只要锁状态放在本地内存就无法约束其他进程。第二个死穴是网络不可靠。即使你把锁状态放到了一个公共的 Redis 或者数据库里进程之间通过网络通信每一次加锁解锁都有超时、断连、延迟的可能。单机锁不存在请求丢失的问题分布式锁则必须考虑这些异常分支。第三个死穴是时间不一致。单机 JVM 里所有线程共用一个时钟分布式环境下每台机器的系统时间可能不同再加上 GC 停顿、网络抖动一个进程在临界区里到底卡了多久很难精确衡量。这就衍生出锁续期、锁过期时间设置等一堆问题。1.3 分布式锁的三个基本底线不管用哪种方案实现一个合格的分布式锁至少要满足三个条件互斥性任意时刻只能有一个客户端持有锁。安全性持有锁的客户端崩溃或超时后锁必须能被自动释放否则会造成死锁。可用性加锁解锁操作要足够快不能因为锁服务本身成为系统瓶颈。这是我在项目里反复强调的三个底线。很多分布式锁方案在设计上很精巧但如果实现时漏掉其中一条线上一定会出事故。后面讲各种方案的时候我都会拿这三条去对照。2. 三种主流实现方案横向对比数据库、Redis、ZooKeeper2.1 数据库方案悲观锁乐观锁各有用武之地数据库是最容易想到的分布式锁载体因为几乎每个项目都有数据库。基于数据库做分布式锁通常有两种姿势。第一种是悲观锁利用SELECT ... FOR UPDATE。在事务里先查询一条锁记录并对它加行级排他锁其他事务再来查询这条记录时会被阻塞。这种方式实现简单但依赖数据库的行锁机制在高并发下容易产生锁等待和死锁而且如果事务里逻辑耗时较长数据库连接会被长时间占用。第二种是乐观锁在业务表上增加一个版本号字段更新时校验版本号。但乐观锁更适合解决丢失更新问题不适合做临界区互斥。比如两个请求同时读到版本号 1一个先更新成功变成 2另一个更新时发现版本不匹配只能失败重试。这是 CAS 思路但它没有排队的概念不适合互斥执行一段需要完整跑完的逻辑。数据库方案还有两个通病一是性能上限低二是数据库本身也可能存在主从延迟。你把锁记录写在主库读从库的进程会看不到锁。所以数据库方案更适合并发量不大、技术栈简单、不想引入额外组件的团队。2.2 Redis 方案SET NX EX 与 Lua 的组合拳Redis 方案是目前互联网项目里用得最多的。核心思路是利用 Redis 单线程执行命令的特性通过一个分布式环境下所有节点都能访问的 Key 来表示锁。加锁最常用的命令是SET lock_key unique_value NX EX 30这个命令的含义是只有当lock_key不存在时才设置成功NX同时设置 30 秒过期时间EX。设置成功代表抢到锁设置失败说明锁已被别人持有。释放锁不能简单用DEL因为存在把别人刚获取的锁删掉的风险。正确做法是先用GET比对 value 是否为当前线程的唯一标识确认是自己的锁后再删除。但这两步操作需要保证原子性所以要用 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedis 方案性能高、实现简单是绝大多数团队的首选。但它的缺陷也很典型如果 Redis 主节点宕机锁数据还没来得及同步到从节点就可能出现锁丢失。这个问题我们在第 4 节详细展开。2.3 ZooKeeper 方案临时顺序节点 Watch 机制ZooKeeper 实现分布式锁依赖的是它的临时顺序节点模型。客户端在锁目录下创建一个临时顺序节点编号最小的人持有锁其他客户端监听自己前一个节点的删除事件当前一个节点被删除时再去尝试获取锁。这种方式的好处是临时节点会跟随客户端会话自动删除不需要设置过期时间客户端崩溃后锁自然释放没有 Redis 方案里锁过期了但业务还没跑完的麻烦。而且 ZooKeeper 的强一致性保证了所有客户端看到的节点顺序是一致的不会出现主从切换导致的锁丢失。缺点是引入了一个重量级组件本身部署运维要成本而且性能不如 Redis。在高频加锁释放的场景下ZooKeeper 的吞吐量会差一些。适合对一致性要求极高、愿意接受额外基础设施成本的场景。2.4 选型建议没有银弹只有适不适合我把三种方案的核心差异整理成一个表格项目里可以直接拿去讨论维度数据库悲观锁Redis SET NX EXZooKeeper 临时顺序节点性能低高中实现复杂度简单中中高客户端崩溃自动释放依赖事务超时依赖过期时间依赖会话超时锁丢失风险主从延迟主从切换可能丢低额外基础设施无RedisZooKeeper典型场景低并发、已有MySQL高并发、可接受少量极端情况强一致、金融对账类场景我的建议是如果公司已经有 Redis且业务并发不低优先用 Redis 实现如果只是内部管理系统每天几百次操作数据库方案也能扛住如果做的业务对数据一致性极度敏感且团队有能力运维 ZooKeeper那就认真评估 ZooKeeper。3. Redis 分布式锁从入门到落地核心命令与Lua脚本3.1 SETNX 的前世今生别再用 setnx 后 expire 的两步走网上很多老教程会教你这样写SETNX lock_key 1 EXPIRE lock_key 30SETNX是SET if Not eXists的缩写只负责设置 value不设置过期时间所以要用EXPIRE单独设置。问题在于这两条命令不是原子操作。如果刚执行完SETNX进程突然崩溃或网络超时EXPIRE根本没执行锁就永远躺在 Redis 里其他所有进程都拿不到锁。正确的姿势是使用SET key value NX EX一条命令搞定。这个命令在 Redis 2.6.12 版本之后已经支持之后官方也推荐用它替代SETNX EXPIRE的组合。严格来说SETNX这个命令已经过时了但很多老项目还在用如果你在代码里见到类似写法建议尽早替换。3.2 防误删让 value 变成一把只有自己能识别的钥匙释放锁时最经典的事故是删除别人的锁。我举个很常见的场景客户端 A 加锁成功设置了 30 秒过期。客户端 A 执行业务逻辑超过了 30 秒锁自动过期。客户端 B 加锁成功开始执行业务。客户端 A 终于执行完执行DEL lock_key把客户端 B 的锁删了。然后客户端 C 也能加锁成功于是 B 和 C 同时进入临界区。防误删的核心思路是加锁时给 value 设置一个只有当前客户端知道的唯一标识比如 UUID 或业务请求 ID释放锁前先比较 value 是否等于自己的标识一致才删除。这就是 3.3 节 Lua 脚本里做的事。比较 value 的这一步必须放在 Redis 服务端执行不能先在客户端GET回来再判断否则在客户端判断完到执行DEL之间锁可能已经被别人抢走还是可能误删。这也是为什么一定要用 Lua 脚本保证原子性。3.3 释放锁的 Lua 脚本保证检查与删除的原子性我项目里用的释放锁 Lua 脚本很简单if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end调用时传入KEYS[1]为锁的 keyARGV[1]为加锁时设置进去的唯一标识。整个比较和删除在 Redis 单线程中完成不存在中间被其他命令插队的窗口。Java 里用 Spring Data Redis 的话可以封装一个工具方法private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end, Long.class); public boolean unlock(String lockKey, String requestId) { Long result redisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), requestId ); return result ! null result 0; }这里有个小细节Lua 脚本里尽量不要用redis.call的返回值做复杂计算返回整数 1 或 0 就够了减少出错概率。3.4 可重入与自动续期Redisson 的看门狗原理前面聊的加锁解锁是分布式锁的骨架子但真正落地时还有两个问题要解决可重入和续期。可重入是什么意思就是同一个线程在持有锁的情况下再次调用加锁逻辑时应该能立即成功而不是把自己阻塞住。Redis 锁天然不支持可重入因为 SET NX 只要发现 key 存在就会失败。要支持可重入得在 value 里记录获取次数或者在客户端维护一个 ThreadLocal 计数。不建议自己造轮子直接用 Redisson 就好。Redisson 的RLock底层会用 Lua 脚本维护一个哈希结构key 是锁名称field 是客户端唯一标识value 是重入计数。加锁时如果 field 已存在就自增解锁时自减到 0 再删除整个 key。续期问题则是应对业务执行时间超过锁过期时间的。Redisson 的看门狗机制会在加锁成功后启动一个定时任务默认每 10 秒执行一次检查锁是否还持有中如果持有就把过期时间重置为 30 秒。这样只要客户端进程没挂锁就不会因为业务没跑完而自动释放。在 Spring Boot 项目里使用 Redisson 非常简单Autowired private RedissonClient redissonClient; public void doWithLock(String key, Runnable task) { RLock lock redissonClient.getLock(key); boolean locked false; try { locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { task.run(); } else { // 获取锁失败的处理逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }注意tryLock的第三个参数是leaseTime如果你不传这个参数Redisson 才会启用看门狗自动续期如果你传了具体值它会按你给的过期时间来不再续期。这一点很容易踩坑我会在第 4 节展开。3.5 一个完整的 Java 示例从裸写 SET 到引入 Redisson如果你不想引入 Redisson也可以自己封装一套简单的分布式锁工具。我用 RedisTemplate 实现过一个最简版本适用于对可重入和续期要求不高的场景。加锁方法public boolean tryLock(String lockKey, String requestId, long expireSeconds) { Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); }这个setIfAbsent底层就是SET key value NX EX。注意返回值要用Boolean.TRUE.equals避免因为自动拆箱导致的空指针。解锁方法就是前面说的 Lua 脚本。整套代码不到 30 行性能也够用。缺点是没有自动续期如果业务偶尔超过过期时间要自己在业务代码里加长过期时间或者接受锁提前释放的风险。如果是在微服务架构里我建议优先评估 Redisson 或者 Spring Integration 提供的分布式锁实现。成熟框架已经把续期、重入、异常处理等问题都考虑到了自己造轮子很容易在边界条件上漏掉逻辑。4. 真实项目中的踩坑记录锁过期、主从切换与GC停顿4.1 业务执行时间超过锁过期时间锁自动释放引发的并发事故第一类经典事故就是锁过期时间设置不合理。我们之前有个订单处理任务预估执行时间是 2 秒于是加锁时设置了 5 秒过期时间。结果某天下游接口慢服务处理一个订单花了 8 秒锁在第 5 秒时自动释放另一个线程趁机加锁成功两个线程同时处理同一笔订单导致重复发券。排查的时候特别隐蔽日志里看两个线程确实都进入了临界区而且都认为自己持有锁。根本原因就是锁过期时间短于业务执行时间。解决思路有三个层次第一个层次加锁前对业务耗时做压测把过期时间设得足够宽裕。但这只是规避没法根治。第二个层次用看门狗机制让锁在业务执行期间自动续期。Redisson 的看门狗就是干这个的。第三个层次从业务上避免对锁持有时间过度依赖比如使用唯一索引兜底或者让业务具备幂等性。我现在的习惯是核心业务尽量用 Redisson同时保留一个兜底的幂等检查。毕竟谁也没法保证下游系统永远不慢。4.2 主从切换导致锁丢失脑裂场景下的安全性与可用性博弈Redis 持久化最常见的方式是主从架构客户端写主节点主节点异步复制到从节点。分布式锁的隐患就在这里——如果客户端刚在主节点加锁成功主节点还没把数据同步给从节点就挂了Redis 哨兵或者集群会把某个从节点提升为新的主节点而新主节点上根本没有这把锁。这时候另一个客户端也能加锁成功锁就失效了。这种事故发生在极端情况下主节点宕机窗口非常短又恰好卡在加锁和同步之间。很多团队觉得概率低就忽略掉但一旦发生往往就是线上资损级别的故障。应对方案也有几种。最著名的是 Redis 官方提出的 Redlock 算法思路是向多个独立的 Redis 节点同时加锁只有大多数节点都加锁成功才算真正持有锁。但它本身争议很大我在 4.4 节专门说。更务实的应对是加锁时把 key 写入一个带同步策略的高可用 Redis 集群或者在业务上接受极小概率的锁丢失并通过幂等逻辑对冲。没有哪个方案能同时做到高可用下的绝对安全关键是搞清楚业务能容忍哪种失败。4.3 GC 停顿STW让线程假死分布式锁的隐形杀手这是一个比主从切换更隐蔽的坑。Java 服务发生 Full GC 时整个应用线程会暂停STW。如果某个线程在持锁状态进入了 STW其他线程本来应该阻塞等锁但锁有过期时间过期之后其他线程就能拿到锁了。等到之前那个线程从 GC 中恢复过来它并不知道自己已经失去了锁继续在临界区里执行——两个线程又同时在跑。这个问题在 Redis 锁和 ZooKeeper 锁上原理相似锁状态和持有者状态不在同一个进程内。ZooKeeper 方案稍微好一点因为会话超时时间可以设置得很长但同样不能完全避免。解决方案没有银弹。Redisson 看门狗可以降低这种风险只要客户端没崩溃锁就会不断续期GC 停顿期间看门狗线程也可能被暂停所以停顿时间超过看门狗续期时间锁还是会丢。真正有效的还是要让业务代码具备幂等性和重试机制不要指望分布式锁是银弹。4.4 Redlock 的争议它是否真的解决了锁丢失问题Redlock 是 Redis 作者 antirez 提出的分布式锁算法。它对客户端要求部署 N 个互相独立的 Redis 节点加锁时依次向所有节点发送加锁请求只有获得超过一半节点的成功响应并且整个过程耗时小于锁有效时间才算加锁成功。支持者认为它解决了单点 Redis 主从切换导致的锁丢失问题。反对者则指出Redlock 依赖一个隐患无穷的假设各节点、各客户端的时间是同步的且每个节点都能独立处理请求。如果某个节点上的锁因为时钟跳跃而过期另一个客户端就可能成功加锁还是会出现互斥失效。在工程上我很少看到团队真正落地 Redlock。原因很简单为了一个极小概率问题要部署多个独立 Redis 实例运维成本和复杂度翻倍而且算法本身还有争议。相比之下把业务做成幂等、引入事务消息、使用数据库唯一约束可能更划算。4.5 我的最终实践结论一个可持续演进的折中方案我在项目里最终采用的是这样一套组合核心链路使用 Redisson 分布式锁开启看门狗自动续期。锁的 key 按业务维度设计比如order:pay:001避免大范围竞争。所有写操作配合数据库唯一索引或幂等表做兜底。定时任务场景尽量让每个实例只处理自己负责的分片数据减少对锁的依赖。对一致性要求极高的资金操作用 ZooKeeper 锁或者直接走消息队列串行化。这套方案不是最完美的但它在性能、可用性、实现成本之间取得了平衡。分布式锁的核心不是找一个绝对安全的算法而是理解每种锁在什么场景下会失效然后想清楚失效后的降级路径。5. 分布式锁的常见面试题与避坑清单5.1 面试官高频问题从原理到场景的连环追问我整理了几道面试中经常出现的分布式锁相关问题几乎都是连环追问每一问都能往下挖一层。什么是分布式锁为什么不用 synchronized这个问题考察核心认知单机锁的内存互斥到分布式环境失效需要公共存储承载锁状态。Redis 分布式锁怎么实现SETNX和SET NX EX有什么区别重点是原子性两步操作会留下锁可能永久存在的窗口。为什么释放锁前要比较 value如何保证原子性引出防误删和 Lua 脚本。锁过期了业务还没执行完怎么办引出续期、看门狗、业务幂等。Redis 主从切换时锁会丢失吗怎么解决引出 Redlock 和其争议。ZooKeeper 实现分布式锁的原理是什么和 Redis 有什么区别考察临时顺序节点与 Watch 机制以及强一致性和性能的权衡。分布式锁的 key 怎么设计考察业务维度划分避免全局锁导致的性能瓶颈。如果获取锁的客户端崩溃了锁怎么释放考察 Redis 过期时间、ZooKeeper 会话过期等机制。面试官经常要求现场手写一个加锁解锁的 Lua 脚本我会建议把基础版本背熟然后顺手讲清楚为什么能保证原子性。能答清楚为什么的人通常比背答案的人更能让面试官认可。5.2 设计一个可靠分布式锁的自我检查清单在写任何分布式锁代码之前我都会过一遍这份清单加锁命令是否单步完成不存在设置 key和设置过期时间分离的窗口value 是否包含全局唯一标识能从所有客户端中区分出锁的持有者释放锁时是否先比较再删除且整个操作在服务端原子执行锁的过期时间是否大于业务最坏耗时或者启用了自动续期锁的 key 是否按业务拆分会不会成为热点 key获取锁失败后是快速失败还是阻塞等待超时时间是否合理锁持有者崩溃后锁能否在一定时间内自动释放不会死锁是否考虑过主从切换、GC 停顿等极端情况业务逻辑本身是否具备幂等性能否容忍锁极低概率失效如果这九条都能回答清楚那这个锁在绝大多数公司里就是这个水平我甚至可以负责任地说它已经具备上线条件。5.3 一些实操经验总结最后分享几个我从多次事故里总结出来的实操经验。锁过期时间不要拍脑袋设要基于压测或历史数据。我们内部定了个规则所有分布式锁的过期时间必须写在配置文件里并注明依据。有一次改了下游超时时间忘了调锁过期时间差点出事故后来就养成了这个习惯。尽量少造轮子。自己实现分布式锁初期脚本很简单但后续要处理续期、可重入、异常恢复、监控告警工作量会越来越大。Redisson 和 CuratorZooKeeper 客户端这些成熟库已经把边界情况处理得很完善直接用就好。一定要加监控。我在 Redis 里看到锁的 key 数量、锁等待耗时、锁持有时间这些指标。锁持有时间突然升高往往说明业务逻辑出了问题或者出现了锁竞争。没有监控分布式锁出问题的时候排查难度会上升一个数量级。有一次线上订单重复支付就是靠监控发现的同一把锁的平均持有时间从 50 毫秒涨到了 3 秒然后系统里出现了两条支付流水。顺着监控查下去发现是某个实例发生了频繁 GC持锁线程被暂停锁过期后被其他线程获取。如果没有监控这种问题可能要等到用户投诉才能暴露。所以如果你的项目还没上分布式锁监控我建议你把它当成和锁本身同等重要的事情来做。分布式锁不难写难的是在大量异常场景下仍能表现得可靠而监控和告警正是可靠性里不可或缺的一环。
返回列表