ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分布式锁全解析:Redis、ZooKeeper、etcd实现与避坑指南

分布式锁全解析:Redis、ZooKeeper、etcd实现与避坑指南 最近排查线上问题的时候我发现一个特别有意思的现象不少团队嘴上说已经给服务加了分布式锁实际上代码里用的还是最早期那套setnx expire的写法。这类代码平时看着没什么问题一旦遇到节点宕机、主从切换或者业务超时锁会静默失效紧接着就是重复执行、数据错乱。分布式锁这个组件看起来只有加锁、解锁两件事但从底层原理到工程落地每一步的偏差都可能酿成线上事故。这篇文章我会把分布式锁从单机锁失效的本质、Redis实现演进、主从切换带来的丢锁问题、Redlock的争议、ZooKeeper/etcd 强一致方案的原理一直讲到常见缺陷和最佳实践最后把面试高频考点整理出来。目标读者是后端开发、架构师特别是正在准备分布式锁面试的同学。1. 单机锁失效的地方分布式锁从哪里接手1.1 从一次定时任务重复执行说起先讲一个我实际排查过的故障场景。某业务每天早上要定时给用户生成账单服务做了 3 节点部署值班同事在项目里用 JDK 自带的ScheduledExecutorService配合本地锁来控制任务执行。结果某天早上十几条账单被重复生成用户收到了两条一模一样的扣款消息。问题出在哪synchronized和ReentrantLock只是JVM 进程内的线程锁3 个节点各自运行各自的 JVM每台机器都有自己的本地锁。节点 A 的锁拦住的是节点 A 内部的线程节点 B、C 上的线程跟它毫无关系。定时任务在凌晨三点同时触发了 3 个节点的执行逻辑每个节点都觉得我已经拿到锁了我来跑任务然后一起操作同一批数据。这就是分布式锁要解决的第一个本质问题让多个进程之间对同一份资源的访问形成互斥。单机锁锁的是线程分布式锁锁的是进程/服务实例。两者的目标一致但作用域完全不同。1.2 分布式锁必须具备的四个特性从上面的故障出发一个合格的分布式锁至少要满足四个条件互斥性任意时刻只能有一个客户端持有锁这是锁存在的全部意义。防死锁持有锁的客户端崩溃或网络异常后锁必须能在有限时间内自动释放否则其他客户端永远拿不到锁。可重入同一个客户端在持有锁之后再次获取同一把锁应该能成功否则一个方法内部调用另一个加锁方法就会把自己卡死。高可用锁服务本身不能成为单点锁的获取和释放过程要足够快不能拖垮业务主链路。前两个是硬性要求后两个在工程上是强烈建议。很多自研的简单锁只满足了互斥和防死锁可重入和高可用做得一塌糊涂上线后问题频出。1.3 上锁之前先问问自己是否真的需要锁我在聊方案的时候经常劝人能不用分布式锁的地方尽量别用。这听起来反直觉但分布式锁本质上是在分布式环境下退而求其次的并发控制手段它给系统引入了一个额外的中间件依赖和故障点。很多场景其实可以用更底层的方案替代数据库唯一索引比如订单流水号、用户手机号、活动参与记录直接建唯一约束重复插入直接报错不需要加锁。乐观锁在数据表上加版本号字段更新时UPDATE table SET ... WHERE id? AND version?影响行数为 0 就重试内存型 KV 也可以用 CAS 操作实现。幂等键请求进来先查幂等表同一个业务幂等键只处理一次用数据库事务保证查 写的原子性。我的经验是能用数据层语义解决的问题优先别引入分布式锁。锁不是万能的它只是把并发冲突的概率降到了极低但没有彻底消除。真正扛底线的还得靠业务本身的数据约束。2. Redis分布式锁实现从SETNX到原子化演进2.1 早期的 SETNX EXPIRE死锁是怎么来的Redis 做分布式锁是市面上最常见的方案原因很简单Redis 性能高、部署广泛、运维成本低。最早期的写法是这样的// 早期实现setnx expire boolean locked redis.setnx(order:lock, 1); if (locked) { redis.expire(order:lock, 30); try { // 业务逻辑 } finally { redis.del(order:lock); } }这段代码有一个臭名昭著的坑setnx和expire是两条独立的命令不是原子的。假设客户端刚执行完setnx还没来及执行expire进程突然崩溃或者 Redis 所在的机器宕机这个 key 就会永久留在 Redis 里。key 不删除后续所有客户端都获取不到锁系统直接进入假死状态。我遇到过不止一次这种事故某个应用用的还是这套老代码某天晚上一个节点的进程被 kill 掉结果凌晨的任务全部跳过第二天早上业务方一堆电话打过来。2.2 原子化加锁SET key value NX PX正确做法是把加锁和设置过期时间合并成一条命令SET order:lock client-token NX PX 30000这条命令的含义是仅当 key 不存在时写入该 key并设置 30 秒过期时间。由于 Redis 是单线程执行命令NX 和 PX 在一条命令里保证了原子性要么同时生效要么都不生效不存在中间状态。这里的 value 推荐使用全局唯一的客户端标识比如 UUID 或服务名 IP 线程ID 随机数的组合。后续释放锁时要用它校验身份。从这条命令开始Redis 分布式锁才算有了一个正确的地基。2.3 释放锁为什么不能直接 DEL很多初学者会问释放锁不就是把 key 删掉吗我 GET 一下判断是自己的 key 再 DEL 不行吗不行。问题在于先 GET 再 DEL是两步操作中间存在时间窗口。举个例子客户端 A 获取锁业务执行超过 30 秒key 自动过期。客户端 B 获取同一把锁开始执行业务。客户端 A 终于执行完了执行 DEL 删除锁结果把 B 刚拿到的锁删掉了。客户端 C 看到锁没了也获取成功于是 B 和 C 同时进入临界区数据写坏。正确的释放逻辑必须用 Lua 脚本保证对比 删除的原子性-- 释放锁脚本 -- KEYS[1] 是锁的 keyARGV[1] 是客户端唯一标识 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedis 的 Lua 脚本在执行期间不会被其他命令打断从根本上避免了错删他人锁的问题。这也是为什么所有成熟的 Redis 锁客户端在释放锁时都走 Lua。2.4 Redisson 的看门狗到底在续什么直接用SET NX PX原生命令实现锁有一个很难受的痛点锁的超时时间怎么定。定小了业务没跑完锁就没了定大了客户端异常后锁要很久才能被其他线程获取。Redisson 的看门狗机制就是为这个问题生的。它的核心逻辑是客户端获取锁时默认设置 30 秒过期时间。后台启动一个定时任务每隔 10 秒检查一次这把锁是否仍然被当前客户端持有如果持有就把过期时间重置为 30 秒。客户端进程一旦宕机定时任务也跟着停了锁最多在 30 秒后自动释放不会造成死锁。这个续期机制把锁超时导致业务没跑完的概率降到了极低。Redisson 的锁底层是一个 hash 结构field 存的是客户端线程标识value 存的是重入计数。同一个线程在持有锁之后再次调用lock()计数器加 1释放时计数器减 1减到 0 才真正删除锁。这让分布式锁具备了和 JDKReentrantLock类似的可重入能力。3. 主从切换丢锁问题与Redlock的争议现场3.1 为什么 Redis 主从架构下互斥会失效前面讲的 Redis 分布式锁默认只针对单实例 Redis。如果 Redis 使用了主从模式 哨兵情况就变了。Redis 主从复制默认是异步的。客户端往 Master 写入锁 keyMaster 还没把这个 key 复制给 Slave 就宕机了哨兵把某个 Slave 提升为新 Master但新 Master 上没有这把锁。此时另一个客户端就能在新 Master 上获取到同一把锁两个锁持有者同时存在互斥被打破。这个窗口虽然很短但在某些对数据一致性要求极高的场景下是不可接受的。于是就有了 Redlock 算法。3.2 Redlock 算法的核心流程Redlock 的初衷是解决单实例 Redis 的主从切换丢锁问题。它的思路对多个相互独立的 Redis 实例同时加锁只要大多数实例成功就认为是加锁成功。算法流程如下准备 N 个互不关联的 Redis 实例通常是 5 个且不使用主从复制。客户端用同一个 key 和同一个随机 value依次向 N 个实例发送SET key value NX PX ttl命令。计算成功加锁的实例数如果大于 N/2即过半且总耗时小于锁有效时间的一半则加锁成功。加锁失败时向所有实例发送 Lua 脚本释放锁。这里为什么要限定总耗时小于 ttl 的一半因为客户端在收集各实例响应时本身要花时间如果不做限制可能出现第 4 个实例刚加锁成功第 1 个实例的锁已经过期的情况这样锁就没有实际意义了。3.3 Martin 与 Antirez 的争论理想模型 vs 现实工程Redlock 发布后分布式领域两位大佬吵了一架。这场争论是分布式锁学习绕不开的内容。Martin Kleppmann《数据密集型应用系统设计》作者认为 Redlock 存在致命问题分布式系统的时钟并不可靠。比如JVM 发生长时间的 GCStop-The-World客户端线程完全暂停这个时间段内所有程序逻辑都不执行包括续期线程。等 GC 结束锁早就过期了另一个客户端拿走了锁。NTP 时间同步导致系统时钟突然回拨Redis 计算过期时间的基准发生变化锁可能提前失效。Martin 的观点是任何一种依赖于本地时钟 租约时间的锁方案在真实的暂停、时钟跳跃面前都没有强互斥保证。他甚至认为 Redlock 是一个建立在错误假设上的算法。AntirezRedis 作者则回应Redlock 的目标本来就不是数学意义上的绝对互斥而是把分布式锁的失效概率降到工程可接受的范围。他承认在遇见极端 GC 或时钟错误时互斥会被打破但这类问题不是 Redlock 独有的ZooKeeper、etcd 一样会遇到会话超时与业务执行时间不同步的问题。用大白话讲这场争论的本质是你要为理论上的绝对正确买单还是为工程上的高可靠买单。我个人的倾向是 Antirez 这边——分布式环境下不存在绝对的互斥关键是系统的兜底设计能不能承受锁失效的损失。3.4 什么场景下我才推荐 RedlockRedlock 的实现复杂度和运维成本都比单实例锁高出不少需要部署 5 个独立 Redis还要处理成批加锁、失败重试、资源清理。我在实际项目里很少直接上 Redlock判断标准很简单如果业务可以容忍极低概率的重复执行比如定时推送、数据补发、缓存重建单实例 Redis 锁 看门狗 幂等表就够了。如果锁的互斥性直接关系到资金、库存这类强一致性数据我宁可把方案换成 ZooKeeper 或 etcd也不会为了保留 Redis 而上 Redlock。4. ZooKeeper与etcd强一致锁方案的实现逻辑4.1 ZooKeeper临时顺序节点 顺序竞争ZooKeeper 实现分布式锁的原理基于它的两个特性临时节点EPHEMERAL和顺序节点SEQUENTIAL。整体思路是所有客户端在同一个锁目录下创建临时顺序子节点比如/locks/mylock/lock_000000001。客户端检查自己创建的节点序号是否在全部子节点中最小。如果最小说明成功获取锁。如果不是最小则对序号比自己小一号的节点注册 Watch 监听等待它被删除。前一个节点被删除后当前客户端重新检查自己的序号是否已变为最小。临时节点最大的价值在于客户端会话断开时ZooKeeper 服务端会自动删除它创建的临时节点因此客户端崩溃后锁会自动释放不会死锁。顺序节点保证了锁的公平性先来后到而不是靠 Redis 那套谁抢到算谁的争抢逻辑。Watch 机制还有一个精妙之处每个客户端只监听前一个节点而不是监听整个目录。如果所有客户端都监听目录下所有节点前一个客户端释放锁时会触发大量客户端的通知这就是著名的惊群效应。4.2 Curator 封装后的锁用起来长什么样直接用 ZooKeeper 原生 API 写锁非常琐碎现实工程中基本都用 Curator 的InterProcessMutex。它内部封装了创建顺序节点、监听前序节点、会话重连后自动重试等逻辑还支持线程维度可重入。基本用法如下CuratorFramework client CuratorFrameworkFactory.newClient( zk1:2181,zk2:2181,zk3:2181, new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/order); if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.release(); } }Curator 把锁的 get 操作变成了一场排队 唤醒的协作而不是 Redis 那种独占后设置过期时间的强制隔离。这也决定了 ZooKeeper 锁在并发量上万时吞吐低于 Redis但在一致性上远胜后者。4.3 etcd租约 Revision 号实现锁etcd 是 Raft 协议实现的强一致 KV 存储它的分布式锁通过concurrency包提供原理上可以概括为三步客户端为锁 key 创建带租约的键值并持续通过心跳续租。租约到期后即使客户端没有主动释放锁也会自动删除避免死锁。每个获取锁的客户端都会拿到一个全局单调递增的 Revision 号谁的 Revision 小谁先拿到锁。客户端不断尝试把当前 key 的 Revision 与锁目录下最小 Revision 做比较直到自己是最小的那个锁获取成功。用 Go 代码简单示意cli, _ : clientv3.New(clientv3.Config{Endpoints: []string{etcd:2379}}) session, _ : concurrency.NewSession(cli, concurrency.WithTTL(30)) m, _ : concurrency.NewMutex(session, /locks/order) m.Lock(context.TODO()) // 业务逻辑 m.Unlock(context.TODO())etcd 锁和 ZooKeeper 锁在提供的语义上高度相似都是强一致、可续租、自动释放。差异主要在技术底层ZooKeeper 由 ZAB 协议保证一致性etcd 由 Raft 保证一致性。两者都能在大多数节点存活时提供可靠锁服务。4.4 Redis、ZooKeeper、etcd 三方案对比维度RedisZooKeeperetcd一致性模型AP主从异步复制有丢锁窗口CPZAB 协议CPRaft 协议锁失效机制过期时间 看门狗续期会话超时 临时节点删除租约 TTL 续租吞吐能力最高单实例十万级 QPS较低通常千级中等万级左右可重入客户端库实现Curator 内置需自行封装运维复杂度低现有组件即可中需独立集群中需独立集群典型风险主从切换丢锁、锁过期网络分区导致节点删除租约续期失败导致锁丢失我在做技术选型时通常遵循一个原则性能瓶颈在锁服务本身的系统说明业务并发已经很高了更值得考虑拆锁或去掉锁真正需要强互斥的数据链路则优先考虑 CP 系组件。5. 缺陷清单过期、GC、时钟与误删锁的边界场景5.1 锁过期但业务没执行完这是分布式锁最经典的失效场景。业务执行时间是不可预测的可能因为数据库慢查询、外部接口超时、大批量数据处理而超出锁的预设过期时间。锁提前释放后其他客户端进入临界区两个客户端同时写同一份数据。规避手段有两个方向一是尽量缩小锁内业务的范围不要在持锁期间做耗时的 RPC 调用和事务提交二是用看门狗机制持续续期。但续期不是万能的如果业务本身设计不良锁内跑大任务拿再多的续期也没意义。5.2 GC 停顿看门狗也救不了你Redisson 的看门狗靠的是客户端进程内的定时线程但这个线程和业务线程处于同一个 JVM。一旦发生长时间的 Stop-The-World GC所有线程包括看门狗全部暂停。GC 结束之后锁可能已经过期了另一个客户端已经获取锁。这种现象在堆内存大、对象多、GC 配置不当的服务里并不罕见。应对思路比较朴素尽量避免在锁内创建大对象和超大数据结构控制 JVM 堆的大小配置更合理的 GC 策略。另外要意识到无论如何优化锁服务都无法覆盖业务线程整体暂停超过锁过期时间的极端情况。5.3 时钟跳跃会提前杀掉还有效的锁Redis 的过期时间基于系统时钟判断。如果服务器发生 NTP 校时时钟向前跳了几十秒一个本应还有 20 秒生命周期的锁可能在一瞬间就被判定为过期。ZooKeeper 的会话超时也有类似问题它依赖客户端和服务端之间的心跳窗口窗口计算也受系统时钟影响。规避方式尽量使用稳定时钟的服务环境关闭不必要的 NTP 频率调整。再者不要把所有的一致性期望都寄托在锁的时间维度上业务层最好有状态校验作为二次确认。5.4 误删他人锁经典的并发踩坑这是我在代码评审时经常反复强调的点。错误的释放锁实现如下if (redis.get(key).equals(token)) { redis.del(key); }前面已经分析过这个时间窗口的成因这里补充一个工程建议释放锁的代码一定要跟获取锁的实现配套。如果使用的是 Redisson它的unlock()内部已经处理好持有者校验 计数减一 原子删除千万不要自己再写一套get del逻辑覆盖上去。5.5 网络分区下锁自动释放的双刃剑ZooKeeper 和 etcd 的锁会在客户端会话断开后自动释放这是为了避免客户端崩溃导致死锁。但它同时带来另一个问题如果客户端只是网络闪断进程本身还活着、业务还在执行锁却被释放了。此时另一个客户端获取锁进入同一临界区两个活着的客户端并发操作数据。这个缺陷没有完美的解决方案只能通过调优会话超时时间和添加业务幂等来缓解。例如 ZooKeeper 的会话超时设置得过短如 5 秒一个网络抖动就会触发锁丢失设置的过长客户端真正崩溃后锁要很久才释放。需要根据网络环境和业务容忍度来平衡。5.6 把 Redis Cluster 当成 Redlock 用最后一个常见误区用了 Redis Cluster 或者哨兵集群就以为分布式锁天然高可用了。实际上 Redis Cluster 的故障转移和主从异步复制一样存在丢锁窗口cluster 化不提供 Redlock 的多实例冗余语义。它们的生产环境可靠性来自数据高可用而不是分布式锁的抗并发失效。6. 工程化落地的取舍逻辑与面试高频考点6.1 锁粒度与持有时间能细就不要粗锁的粒度直接决定了系统的并发瓶颈。我见过一个订单系统为了保障一件商品的库存一致性直接把整个订单创建流程都塞进一把全局锁里。结果是并发被锁到一个极低的水平压测怎么都上不去。正确的粒度是按业务主键加锁比如按用户ID、订单ID、商品IDlockKey stock:lock: skuId而不是lockKey stock:lock:all锁的范围越小临界区越短系统的并发能力越高。锁内只做必须互斥的操作其他可并行的计算都放到锁外。6.2 超时时间怎么定如果不用看门狗锁超时时间需要自己拍脑袋时我一般用这个经验公式锁超时时间 业务预计最大执行时间 × 5 ~ 10 倍比如预计业务最多执行 2 秒设置 10~20 秒相对安全。太短会频繁过期导致并发问题太长会在客户端崩溃时让其他客户端等待过久。有看门狗的话超时时间设置可以宽松一些兜底意义更大。获取锁时还要设置一个获取超时时间比如 3~5 秒。拿不到锁就快速失败不要无限阻塞等待。工程上长时间的锁等待往往意味着业务链路已经出了严重问题更快失败反而能加速告警触发。6.3 兜底永远比锁本身更重要分布式锁只能把并发冲突的概率降到很低它无法保证 100% 绝对互斥。我在关键系统里都会加一道数据层的最终防线常见兜底手段数据库唯一索引重复插入直接报错。乐观锁版本号更新影响行数为 0 时重试。状态机校验订单状态从待支付到已支付只能单向流转重复通知在状态校验时被拦截。幂等表每个业务请求带唯一幂等键处理前查询、处理后写入。同时做好监控锁等待时间、锁持有时间、获取失败率是三个基础指标。如果锁持有时间频繁接近超时时间说明业务在锁内耗时过长需要告警。6.4 面试高频考点与速答要点结合我面试和做技术评审的经验分布式锁这块最常见的问题基本以下面几种形式出现这里一并整理出回答要点问题 1单机锁为什么不能用于分布式环境因为单机锁的互斥作用域只在进程内不同进程之间不存在共享的锁状态。分布式环境的多个服务实例各自执行各自的 JVM本地锁无法阻止跨节点的并发访问。问题 2SETNX EXPIRE 为什么不安全两条命令不是原子的中间一旦崩溃就会留下永不过期的 key造成死锁。解决方式是 SET NX PX 单命令原子加锁。问题 3释放锁为什么要用 Lua 脚本需要校验持有者并删除 key整个校验 删除必须是原子的。Lua 脚本在 Redis 单线程事件循环内执行不会被其他命令打断。问题 4Redisson 的看门狗是怎么工作的获取锁时默认 30 秒过期后台线程每 10 秒将过期时间重置为 30 秒。客户端宕机后线程停止锁在最多 30 秒后自动释放。问题 5Redlock 解决了什么问题有什么争议解决单实例主从切换时锁丢失的问题核心是多数节点成功加锁才认为获得锁。争议在于时钟不可靠和 GC 停顿可能导致锁提前失效理论上的强互斥无法被严格保证。问题 6ZooKeeper 和 Redis 锁的区别ZooKeeper 基于临时顺序节点 Watch 实现公平锁可靠性高但吞吐低Redis 基于 NX PX Lua 实现性能高但一致性弱。强一致优先考虑 ZK/etcd性能优先考虑 Redis。问题 7分布式锁失效怎么办靠兜底数据库唯一索引、乐观锁、幂等表、状态机校验。分布式锁是前置防线兜底是最终防线。最后聊一下我个人的取舍逻辑。做了几年分布式系统我对锁的态度越来越保守能用幂等解决的不上锁能用数据库约束解决的不上锁非得用锁不可时优先选团队维护成本最低的方案——大多数团队运维 Redis 最熟练那就用单实例 Redis 看门狗同时把幂等表建好。等业务真的发展到需要强一致锁的那一天ZooKeeper 和 etcd 也是现成的替换选项。分布式锁从来不是架构的终点它只是并发控制链路上的一环真正的安全感来自你对兜底方案的自信。
返回列表