ARTICLE DETAIL

资讯详情

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

分布式锁四种主流实现方案:数据库、Redis、ZooKeeper、Etcd对比与选型

分布式锁四种主流实现方案:数据库、Redis、ZooKeeper、Etcd对比与选型 老读者应该记得去年我写过一遍单体应用里synchronized和ReentrantLock的对比当时评论区就有人问服务拆成多台机器部署之后同一个用户请求落在不同实例上JVM 锁还管用吗答案显然是不管用。跨进程、跨节点的互斥只能靠分布式锁。这两年我在订单防重、定时任务调度、缓存击穿治理这几个场景里把市面上主流的四类分布式锁方案都趟了一遍踩过的坑不算少今天一次性把数据库、Redis、ZooKeeper、Etcd 这 4 种主流实现方式掰开揉碎讲清楚。这篇东西既是给准备上手分布式锁的工程同学看的也是给那些正在准备分布式系统面试、想搞清楚 Redis 分布式锁与 ZK 分布式锁底层差异的同学准备的。1. 先搞清楚分布式锁到底锁住了什么1.1 为什么单机锁到分布式就失灵了单机环境下我们希望一段代码在同一个进程内只能被一个线程执行于是有了synchronized、ReentrantLock这些东西。它们的核心依赖是 JVM 内存里那份共享的锁状态线程 A 加锁线程 B 去读同一块内存发现锁被持有就只能阻塞等待。这个模型成立的前提是“所有线程共享同一块内存”一旦应用部署成多实例内存就各自独立了JVM 锁自然失效。多实例部署现在几乎是标配哪怕一个小服务为了保证可用性也会至少部署两台。一台机器扛不住流量、一台机器宕机不能影响整体服务这是最朴素的诉求。于是同一份请求可能被负载均衡分发到任意一台实例上同一行库存数据也可能被多个实例同时操作。这个时候如果不做跨进程互斥就会出现超卖、重复扣款、重复下发消息等一系列问题。分布式锁解决的就是这个层面的互斥问题。1.2 一把合格的分布式锁需要满足什么很多人一上来就写 Redis 命令其实先想清楚分布式锁的评判标准后面所有方案都能套这套标准去衡量。互斥性任意时刻只能有一个客户端持有锁。这是锁的底线失去了互斥性整个方案就没有存在意义。安全性锁必须能被安全释放。如果持有锁的客户端崩溃了锁不能一直挂着必须有超时或者类似机制兜底否则就是死锁。可用性加锁、解锁的链路不能频繁出故障。分布式锁的基础存储组件Redis、ZK、Etcd本身要可用同时锁服务不能因为少数节点故障就整体不可用。可重入性同一个线程在持有锁的情况下能否再次加锁。这在递归调用或者同一线程内多次获取同一个锁的场景里很关键具体业务里不一定会用到但实现上要考虑。性能加解锁延迟要低不能对业务主链路造成明显开销。后面讲四种方案时我会反复拿这套标准去比划你会发现没有银弹每种方案都是在这些维度之间做取舍。2. 基于数据库最朴素但常常够用2.1 唯一约束方案忘掉锁直接占用数据库做锁的思路最早也最直接。第一种玩法是利用唯一约束拿“插入成功即加锁成功删除记录即释放锁”这种思路。建一张锁表锁名做唯一索引CREATE TABLE distributed_lock ( id BIGINT NOT NULL AUTO_INCREMENT, lock_name VARCHAR(64) NOT NULL, owner VARCHAR(128) NOT NULL, expire_time DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uk_lock_name (lock_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;加锁就是尝试插入一条记录插进去就是拿到锁插不进去说明别人持有着。释放锁就是删掉这条记录。这个方案胜在极简不需要引入任何额外组件有一张表就能跑。但有个致命问题如果持有锁的线程崩溃了记录永远删不掉锁就死了。所以表里我还加了expire_time字段启动一个定时任务扫描超时记录并删除作为兜底。这个方案我很少在生产主线用但在一些内部工具、低频后台任务里反而合适。举个例子公司内部有个数据修复平台每天凌晨跑一批补偿任务任务本身要保证同一个业务维度不被两个运维同时操作频率极低用唯一约束表完全够了不值得为这种场景上 Redis。2.2 悲观锁方案SELECT FOR UPDATE第二种数据库方案是走事务 悲观锁。核心就一条 SQLSELECT * FROM distributed_lock WHERE lock_name order_123 FOR UPDATE;注意这张表里lock_name也要有唯一索引FOR UPDATE会锁定这一行直到事务提交或回滚。加锁流程是开启事务执行上面这条 SQL查到了就代表拿到锁执行业务逻辑提交事务自动释放锁。没查到就阻塞等待InnoDB 的行锁会让它排队。这个方案的好处是省去了自己管理锁记录释放逻辑跟着事务走一旦事务回滚或提交锁自然释放不会出现崩溃后锁残留的问题。缺点是性能受限于数据库连接数和锁等待时间高并发下SELECT FOR UPDATE会把连接长时间占住业务高峰期很容易打满连接池。数据库这种单点写入压力的方案只适合低并发、强事务一致性的内部系统。而且操作的是数据库如果数据库本身做了主从锁记录在主库所有请求都要打到主库上读写分离架构下这个方案会破坏原有的流量规划。2.3 数据库方案的优缺点和适用场景数据库方案的优点一句话总结不引入新组件、实现简单、事务天然保证一致性。没有额外维护 Redis、ZK 集群的成本对团队规模小、基础设施薄弱的项目来说很有吸引力。但它撑不住高并发加锁操作和业务操作耦合在同一个事务里时事务时长直接影响锁持有时间容易拖垮数据库连接。就我的经验数据库分布式锁适合用在并发量几百 QPS 以内的内部系统、后台管理任务、低频定时任务里。互联网高并发入口链路不要用数据库做锁扛不住。这个结论在我维护过的多个项目中反复被验证凡是拿 MySQL 做锁扛流量入口的最后都不得不迁移到 Redis 方案。3. 基于Redis性能最强但细节最险3.1 SET NX EX 一条命令搞定Redis 做分布式锁是现在最主流的方案因为它性能好、实现轻。公认的标准写法是SET lock_key unique_value NX PX 30000这条命令做了三件事NX保证只有键不存在时才能设置成功PX 30000给键加上 30 秒过期时间unique_value作为持有者标识。为什么必须一条命令因为如果先SETNX再单独EXPIRE两步之间进程崩溃的话锁会永久残留。这是最经典的坑网上被讲了无数次但我接手过的代码里仍然能看到两段式的写法看完只能说很心痛。释放锁的时候直接用DEL是危险的。考虑这个场景线程 A 拿到锁执行时间超过了 30 秒锁自动过期了线程 B 拿到同一把锁开始执行此时 A 终于跑完了顺手DEL把 B 的锁给删了后面 C 又进来了锁完全乱套。正确的释放必须校验持有者身份这个要求用 Lua 脚本实现原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endunique_value一般是 UUID 加上线程 ID保证唯一性。每次释放锁之前先 GET 校验再 DEL整个过程用 Lua 封装成原子操作防止校验和删除之间被别人插一脚。这一步是 Redis 分布式锁最容易忽略的细节绝不能用两步 Java 代码去实现“先判断再删除”。3.2 超时释放与续期Redisson看门狗刚才那个 30 秒过期时间其实是个天大的麻烦。业务执行得慢30 秒不够怎么办锁自动释放了别的线程进来了互斥被打破。把过期时间设得很长万一持有者真崩了锁要挂很久才能被回收可用性受损。怎么解决续期机制。这就是 Redisson 的价值所在。Redisson 是 Java 生态最成熟的 Redis 分布式锁客户端我用它可以做到这样RLock lock redissonClient.getLock(order:123); // 尝试加锁最多等3秒锁超时30秒 if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } }这里传入的leaseTime其实很少用。Redisson 默认还有一个看门狗机制Watchdog如果没有显式传超时时间Redisson 会默认给锁设置 30 秒过期然后启动一个后台定时任务每 10 秒检查一次如果锁还被当前线程持有就自动把过期时间重置回 30 秒。这个机制解决了“业务没跑完锁先断了”的问题。我实际项目里踩过一个坑锁内做外部接口调用对方响应超时设的 60 秒看门狗把锁续到 90 秒接口卡住后线程一直占着锁不释放打得 Redis 满满都是槽位被占的报错。后来我给所有外部调用的锁显式设置了leaseTime并保证这个值大于外部调用最大超时时间看门狗不再介入锁的时长由我控制。所以我的建议是业务代码不复杂就放心用看门狗复杂链路务必显式控制锁时间哪怕多留些余量也比无限续期好。3.3 RedLock与主从切换的坑Redis 分布式锁还有一个著名的争议点主从架构下的锁丢失问题。Redis 主从同步是异步的线程 A 给 master 写入了锁master 还没来得及把数据同步给 slave 就宕机了哨兵把 slave 提升为新 master此时新 master 上压根没有这把锁。另一个线程 B 来加锁直接成功了互斥失败。为了应对这个问题Redis 作者提出了 RedLock 算法。思路是部署多个互相独立的 Redis 节点客户端向超过半数的节点申请加锁只有超过半数成功才算加锁成功。理论上只要不是超过半数的节点同时宕机锁就安全。这个方案在分布式系统领域争议很大很多大牛发文反对核心论点在于它依赖了不可控的时钟和 GC 停顿。但就工程实践看当业务对安全要求极高、又不想引入 ZK 这种强一致组件时RedLock 仍然是一个值得了解的方案。我自己用过 Redisson 的RedissonRedLock写过一版实验代码过程并不复杂但部署成本高需要独立的 Redis 节点而且加锁时间随节点数量线性上升。我的观点是如果你的 Redis 本身就配置了正确的持久化AOF 合理 fsync 策略并且不是很极端的高并发场景普通 Redis 分布式锁已经够用。真正需要 RedLock 的场景大概率也到了该认真考虑 ZK 或 Etcd 的时候了。3.4 Redis方案的使用场景与注意点Redis 分布式锁最常用的场景是缓存击穿治理、秒杀库存扣减、幂等控制这类对性能和一致性都有要求的入口链路。拿秒杀场景举例用户请求量每秒上万库存扣减如果走数据库悲观锁数据库直接被打死走 Redis 锁先抢锁再扣库存抢不到的直接返回“已售罄”整体响应时间能控制在几十毫秒。用 Redis 做锁有几个注意点值得记下来过期时间必须远大于单次业务执行时间拿不准就设置一个相对大的值配合看门狗续期。锁的粒度要细。不要让多个业务共用一把锁锁的 key 要能标识唯一资源比如用户维度锁用user:123订单维度锁用order:123粒度越细并发度越高。Redis 本身的持久化策略不能省。关闭持久化的 Redis 重启即丢数据锁也会丢主从架构下更危险。生产环境至少开启 AOF建议appendfsync everysec兼顾性能与安全。4. 基于ZooKeeper一致性优先的可靠选择4.1 临时顺序节点Watch监听ZooKeeper 实现分布式锁依赖的是它底层的 ZAB 协议数据在集群节点间强一致同步不存在 Redis 那种主从切换丢数据的问题。ZK 的锁模型基于多层节点机制持久节点会话结束后依然存在。临时节点会话结束后自动删除。顺序节点创建时自动追加递增序号。ZK 分布式锁的经典实现是创建临时顺序节点。客户端 A 在/locks/order_123下创建临时顺序节点/locks/order_123/lock_0000001这时候它发现自己创建的节点序号最小就认为自己拿到锁了。客户端 B 同样创建节点发现序号不是最小于是对自己前面那个节点注册 Watch进入等待状态。当前一个节点被删除时B 被唤醒检查自己是否已经是序号最小的节点如果是就持有锁。这里的“前一个节点被删除”就是持有锁的客户端会话结束、临时节点自动消失。核心优势是持有锁的客户端如果宕机ZK 会检测到会话超时并自动清理临时节点锁自动释放不需要额外设置超时时间也避免了 Redis 那种“锁过期但业务没跑完”的纠结。临时节点的生命周期与会话绑定这是 ZK 锁最优雅的设计。4.2 Curator InterProcessMutex自己用 ZK 原生 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_123); if (lock.acquire(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.release(); } }Curator 的InterProcessMutex有几个特性值得了解。它是可重入的同一个线程可以多次acquire对应每次都要release。它基于临时顺序节点实现加锁过程会创建一个带UUID的临时顺序节点至于取哪个节点作为锁标识Curator 会在内部处理。它支持阻塞获取和限时获取超时返回 false给业务方灵活的选择空间。另外 Curator 还提供了InterProcessReadWriteLock读写锁在某些读多写少的场景里能进一步提升并发度。4.3 羊群效应与性能瓶颈ZK 分布式锁并非没有缺点。一个是性能ZK 加锁需要通过网络通信创建节点整个过程要走 ZAB 协议确认延迟通常在几毫秒到几十毫秒之间和 Redis 的亚毫秒级相比有明显差距。另一个是羊群效应这是 ZK 锁的经典问题。早期实现是让所有等待线程都监听锁持有者的节点锁一释放所有等待线程同时被唤醒大家一起抢锁最终只有一个成功其余线程继续等待。节点数量多时这种惊群会带来大量无谓的网络和内存开销。解决方案就是上面说的临时顺序节点 监听前一个节点把锁的等待变成链式传递每个客户端只监听它前面的那个节点释放时只通知一个等待者。Curator 的实现就是这样做的这也是为什么我强烈建议大家直接使用成熟客户端而不是自己写原生实现。ZK 方案最适合对一致性要求极高、但对性能要求没那么极端的场景。比如分布式任务调度中心的 leader 选举、配置中心的变更发布、金融系统里面的资金对账任务。在这些场景里锁的可靠性和自动释放能力比加锁延迟更重要。5. 基于Etcd云原生时代的后起之秀5.1 Lease Revision Watch机制Etcd 是云原生技术栈里的明星组件Kubernetes 内部大量使用它做数据存储其分布式锁能力也相当完善。Etcd 实现分布式锁依赖三个核心机制Lease租约类似于 Redis 的过期时间。客户端创建一个租约指定过期时间到期后租约自动失效关联的 key 会被自动删除。租约可以续期防止业务未完成时锁提前释放。Revision版本号Etcd 中每个 key 的每次修改都会产生一个全局递增的 revision分布式锁的竞争就通过比较 revision 的先后顺序来确定。Watch监听客户端可以 Watch 一个 key当 key 发生变化删除、修改时收到通知。Etcd 锁的实现思路和 ZK 的临时顺序节点非常像。客户端创建一个 key比如/locks/order_123/xxx其中xxx是客户端持有的租约 ID 加上唯一标识。在同一个锁前缀下所有客户端创建的 key 都有不同的 revisionrevision 最小的那个客户端就是持锁者其他客户端 Watch 前一个 key等它被删除。具体流程大致是客户端 A 创建 key发现它的 revision 是最小的直接持有锁客户端 B 创建 key发现前面还有一个 revision 更小的 key注册 Watch 到那个 key 上A 的租约到期或者 A 显式释放锁删除自己的 keyB 收到 Watch 事件再次检查自己是不是 revision 最小的如果是就拿到锁。整个机制和 ZK 版如出一辙但底层依赖的是一个更契合云原生场景的存储组件。5.2 etcd 分布式锁的代码示例Go 生态里etcd 官方提供concurrency包里面封装了分布式锁的实现。Java 生态里对应的客户端是jetcd它也有类似 Lock 接口。Go 的代码大致长这样cli, _ : clientv3.New(clientv3.Config{ Endpoints: []string{etcd1:2379, etcd2:2379, etcd3:2379}, }) session, _ : concurrency.NewSession(cli, concurrency.WithTTL(10)) mutex : concurrency.NewMutex(session, /locks/order_123) mutex.Lock(context.TODO()) defer mutex.Unlock()这里的关键点是concurrency.NewSession创建了一个带 TTL 的会话会话到期后自动释放锁这是 Etcd 版锁的兜底机制。NewMutex内部使用了一个租约把锁 key 关联到这个租约上。实际使用中留意两个问题一个 Session 可以服务于多个 Mutex但一把锁最好关联一个租约避免租约被意外的过期操作影响。Lock是阻塞获取的如果业务有超时控制需求使用TryLock或者通过 context 传递截止时间。Etcd 锁的可靠性和 ZK 相当因为 Etcd 的 Raft 协议同样保证了数据在集群中多数派节点的持久化和一致性。但 Etcd 的部署和维护成本比 Redis 高比 ZK 略低因为是云原生标配K8s 集群里往往已经有现成的 Etcd。如果公司已经在用 K8sEtcd 锁几乎是顺带可用的能力。5.3 Etcd 与 ZK 的对比把 ZK 和 Etcd 放一起比较是面试官非常爱问的话题。两者的一致性协议不同ZK 用的是 ZABEtcd 用的是 Raft但都提供线性的强一致读写锁语义的可靠性在同一水平。有几个实际差异值得关注运维成本Etcd 生态更现代和 K8s 天然集成云原生环境里基本免运维。ZK 是一个独立组件需要额外部署监控和维护心智负担更重。客户端成熟度ZK 的 Curator 在 Java 生态里非常成熟API 设计完善使用体验好。Etcd 的 Java 客户端jetcd相对年轻功能完整度和 Curator 还有差距但 Go 生态的concurrency包做得很好。性能两者都属于强一致组件的范畴加锁延迟在同一量级都比 Redis 慢。如果锁成为热点两者都会成为瓶颈。功能范围ZK 还提供命名服务、配置管理、服务发现Etcd 在 K8s 场景里主要负责配置存储。选哪个更多取决于你所在的技术栈里已经有什么。我的感觉是如果团队是 Java 技术栈、已有 ZK 运维经验继续用 ZK 没任何问题。如果是从零起步、基础设施以云原生为主选 Etcd 更合理一举两得不用多维护一套组件。6. 四方案对比与选型建议6.1 核心指标对比表四种方案的特点我汇总成一张表方便直观对比维度数据库RedisZooKeeperEtcd性能低高中中一致性强单库弱异步复制强强自动释放需定时任务兜底过期时间/看门狗会话超时租约到期锁丢失风险低主从切换时可能丢失低低可重入需自行实现Redisson 支持Curator 支持客户端支持引入成本零低中中适用并发量千级以下万级以上千到万级千到万级真要我给个一句话建议那就是图省事加性能项目里已有 Redis选 Redis要强一致且已有 ZK 运维设施选 ZK在云原生环境里从零选型选 Etcd只是给内部工具做简单互斥数据库就行。6.2 按业务场景选型的具体建议秒杀、抢购、库存扣减高并发、高吞吐首选 Redis 分布式锁注意配合 Lua 释放锁脚本和合理的过期时间。这类场景遇到锁冲突更好的做法是直接快速失败返回不要让线程阻塞排队。定时任务调度多实例部署时同一个定时任务只会执行一次。我会选 ZK 或 Etcd因为定时任务频率低对加锁性能不敏感但对可靠性要求高不能让同一批次的任务被两台机器重复执行否则可能出现重复对账、重复发券。缓存重建热点缓存失效后大量请求同时回源数据库。用 Redis 锁做互斥拿到锁的线程去查库回填缓存拿不到的线程快速返回旧缓存或者短暂等待再读取整体效果很好。配置更新与 leader 选举配置更新要求绝对一致不能出现两个节点同时执行变更。ZK 或 Etcd 的强一致模型在这里最合适Redis 方案在这种场景里我是不推荐的。内部管理平台数据订正、批量任务触发并发量小且允许线程短暂阻塞。数据库方案足够省掉一套组件的维护成本。6.3 从锁的粒度看选型差异除了场景和并发量锁的粒度也影响选型。比如分布式锁的 key 设计为细粒度时每个 key 的竞争频率低Redis 的极高性能优势能充分发挥。但如果锁粒度粗比如整个店铺的所有操作共用一把锁所有请求都串行化任何组件的性能优势都救不了这种设计。无论选哪种方案锁粒度设计永远是第一位的。这个观点我在代码评审里强调了无数次很多人写着写着就把所有业务共用一个锁前缀并发能力直接砍半然后怪锁的方案选错了。7. 高频面试追问与避坑实录7.1 关于分布式锁的高频追问分布式锁在面试里出现频率极高而且面试官往往不满足于“我知道有四种方案”而是层层深挖。我整理了十几个追问覆盖了不同深度为什么数据库唯一约束可以做锁它的失效场景有哪些SET NX EX和SETNX EXPIRE有什么区别为什么必须一条命令Redis 分布式锁释放时为什么要用 Lua 脚本不用会怎样Redis 锁的过期时间怎么设置业务没执行完锁过期了怎么办Redisson 看门狗的原理是什么续期是精确续还是每次都重置为什么说 Redis 主从模式下锁可能丢失如何解决RedLock 的加锁流程是什么它有争议吗ZK 分布式锁的底层实现原理是什么为什么用临时顺序节点什么是羊群效应ZK 锁如何避免ZK 锁如果客户端长时间阻塞锁会被自动释放吗为什么Etcd 分布式锁与 ZK 分布式锁的异同四种方案各自的优缺点和适用场景是什么你项目里实际上用过哪种方案遇到过什么问题其中“你项目里用 Redis 锁遇到过什么问题”这一题是区分候选人是真用过还是背概念最好的试金石。我自己真实遇到过的就是锁过期导致互斥失效和持有者身份校验没做好导致误删锁这两件事如果你在面试里能讲出当时的排查过程和处理方案面试官基本就认可你确实有实战经验了。7.2 实测中遇到的典型问题和排查思路分享几个真实项目中踩过的坑给大家做个参考。第一个是锁过期与业务耗时打架。排查线上问题时发现限时抢购的库存偶尔会超扣把日志拉出来一比对时间戳差了几百毫秒的两个线程同时进入了扣减逻辑。根因是业务里查了第三方库存接口单次耗时超过锁过期时间前一个线程的锁已经过期后一个线程加锁成功并进入。解决方案有两层第一层把锁过期时间从 10 秒调整到 30 秒给业务留足余量第二层在扣库存前二次校验当前操作是否仍然持有锁。这两层叠加才解决了超扣问题。更合理的做法是缩短锁内执行业务的链路把外部调用移出锁的范围。第二个是忘了释放锁。代码里tryLock成功之后业务抛了一个没有被捕获的异常finally块里没写unlock这把锁挂了接近一分钟直到过期。排查时 Redis 里看到order_4421这个 key 长时间存在TTL 一直在刷新明显是被某个线程反复续期但业务日志里并没有对应的成功记录。后面养成了条件反射加锁必带try { } finally { }代码评审也会重点检查这个点。看门狗能续期但不代表你可以不释放锁的释放和连接的关闭一样必须作为底线纪律。第三个是锁粒度太粗。业务里把一个用户的所有操作都放到了同一个 key 上用户同时下单和查询优惠券都必须抢同一把锁。结果 QPS 稍微上来Redis 锁超时重试的比例直线上升。排查后把锁 key 从user:123细化到了user:123:order_create和user:123:coupon_query并发能力瞬间恢复了。心态上要明确锁保护的是共享资源的写操作读操作如果能容忍短暂不一致完全不需要加锁。7.3 一点实践心得分布式锁这个东西方案本身难度不大真正的复杂度全藏在细节里。我历经这几个项目后最深的体会是不管你选哪套方案都要把锁的过期、续期、释放、校验这四个环节想清楚。很多线上问题不是锁“设计错”了而是这四件事里有一件没考虑到位。方案选型上优先用团队已经熟悉的组件不要为分布式锁单独引入一套新基础设施除非业务真的需要它的强一致特性。另外建议配置一个锁监控的指标把加锁耗时、持有时间、等待时间这几个数值收集起来方便在出问题时快速定位。分布式锁没有全能的解只有适合当下场景的选择而判断“适合”这件事靠的还是对业务场景和背后原理的深刻理解。
返回列表