ARTICLE DETAIL

资讯详情

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

分布式锁原理与实战:Redis、ZooKeeper与数据库方案对比及避坑指南

分布式锁原理与实战:Redis、ZooKeeper与数据库方案对比及避坑指南 分布式锁听起来像个老生常谈的技术点但面试挂在这上面的人一抓一大把。早几年我做电商订单系统的时候库存扣减和防重复支付两件事就把我折腾得不轻服务从单体拆成多实例部署之后原来顺手好用的synchronized和ReentrantLock突然全部失灵超卖、重复回调该出现还是出现。后来把分布式锁的原理彻底吃透我才意识到大多数人翻车不是因为不会用某一种方案而是根本没理解分布式环境下锁到底缺了什么、补什么才真正可靠。这篇文章就从原理到实战把分布式锁从头到尾扒一遍适合正在做微服务改造的开发者也适合准备面试想把这题答得有深度的同学。1. 为什么要写分布式锁——场景与痛点拆解1.1 从单体到分布式的并发困局先回到最初的问题单体应用里多个线程要争抢同一个资源比如扣库存、领优惠券我们怎么做很简单加一把JVM锁像synchronized或者ReentrantLock。因为所有线程都跑在同一个进程里共享同一块内存锁状态放在内存中就够了大家都能看见、都能遵守。但是微服务化之后情况变了。同一个服务可能部署了3个实例每个实例都有自己的JVM和内存。线程A在实例1里拿到了内存锁线程B在实例2里根本看不到这把锁照样冲进来执行临界区代码。说白了JVM锁的作用域是一个进程而分布式场景需要锁的作用域跨越多个进程、多台机器。这时候就需要一个所有实例都能访问的第三方裁判所有实例都到这个裁判那里登记我先来我占了你们等着。这个裁判就是Redis、ZooKeeper、数据库这类具备全局可见能力的中间件。分布式锁的本质就是把内存中的锁状态挪到一个大家都能访问的外部存储上。1.2 分布式锁的典型使用场景很多人觉得分布式锁就是面试题实际项目里用不上这是误解。但凡你的系统是集群部署又存在跨实例的共享资源写入就逃不掉分布式锁。几个最常见的场景库存扣减与秒杀。订单系统最典型的场景。用户同时下单要扣减同一个商品的库存。如果每个服务实例各自扣各自的库存数据必然对不上。用分布式锁保证同一时刻只有一个请求在扣减某个商品的库存其他请求排队。防止重复支付与幂等控制。支付回调往往不保证只调用一次同一个订单的回调可能到达多次。用分布式锁保证同一个订单的支付处理逻辑同一时刻只执行一次。这个场景对锁的要求还特别高宁可锁多等一会不能出现并发处理同一笔订单。定时任务的集群互斥执行。集群里的每个实例都会启动定时任务如果不同步同一个报表会被生成三份同一个奖励会被发放多次。给任务名加一把分布式锁谁抢到锁谁执行。分布式缓存重建。缓存过期瞬间大量请求同时回源查数据库这就是缓存击穿。用分布式锁让只有一个请求去重建缓存其他请求等待或者直接返回旧值。分布式事务中的资源锁定。跨服务调用链路上需要对某个业务实体先加锁再做一系列操作防止其他链路同时修改。可见分布式锁解决的是分布式系统里多个执行体抢同一份资源的同步问题是保证数据一致性的底层基础组件。你在面试里能把场景说到这个粒度再结合自己项目里的具体例子说服力是远大于背定义的。2. 分布式锁的设计模型三要素与两条铁律2.1 锁的本质——互斥、可重入与自动释放不管用什么中间件实现分布式锁在功能上都要满足几个基本性质。首先是互斥性。这是锁存在的唯一理由任意时刻只能有一个客户端持有锁。这句话说起来简单实现的时候反而是最容易出问题的。比如Redis主从切换导致锁丢失就可能出现两个客户端同时持有锁互斥性被破坏。其次是可重入性。同一个客户端在已经持有锁的情况下再次请求同一把锁应该直接成功。这个在业务代码里很常见A方法加了锁内部又调用了B方法B方法也加了同一把锁。如果锁不支持可重入就会把自己锁死。Redis的分布式锁默认不直接支持可重入需要在value里记录持有者信息和重入次数或者用ThreadLocal记录大家经常忽略这个细节。然后是自动释放。持有锁的客户端崩溃了锁也必须能释放掉否则其他客户端永远等下去。这就引出分布式锁设计中最重要的一个机制——超时时间。不管是Redis的EXPIRE过期时间、ZooKeeper的会话超时还是数据库的事务超时本质上都是给锁加上保底释放的能力。2.2 分布式锁必须满足的可靠性红线基础性质之上工程上还有几条可靠性要求面试里能不能拿高分就看对这些线的理解。死锁不可出现。只要客户端崩溃锁必须自动释放。这里要特别注意的是不能把释放动作完全寄托在客户端代码上——你代码里写了finally里释放锁可如果Redis连接断了呢如果进程被强制kill了呢所以必须有中间件侧的兜底过期机制。互斥性尽量不被破坏。这是最难的一条。Redis主从切换、ZooKeeper会话假超时都可能让两个客户端同时认为自己是锁的主人。严格来说任何分布式系统里都存在这种不确定性的窗口分布式锁只能尽量缩小这个窗口不能绝对消除。比如Redlock算法就是为了缩小这个窗口而设计的但代价是复杂度上升。高可用与低延迟。锁服务本身不能成为系统的单点。Redis挂了锁服务就不可用那就需要主从、哨兵、集群。延迟方面锁的获取与释放必须足够快不然就是抢锁5毫秒、业务执行50毫秒整体接口性能就被拖垮了。2.3 三类实现方案的横向对比目前主流的分布式锁实现方案就三套Redis方案、ZooKeeper方案、数据库方案。每种方案各有侧重没有银弹关键看业务场景更在意什么。方案性能可靠性复杂度典型实现Redis极高纯内存操作中等主从切换可能丢锁低接入简单SETNX Lua、RedlockZooKeeper中等磁盘与ZAB协议开销高顺序节点天然可靠中需引入ZK组件临时顺序节点数据库低行锁与事务开销大高依赖数据库ACID低不用引入新组件SELECT FOR UPDATE、乐观锁version单从性能看Redis是压倒性优势所以实际落地中Redis方案用得最多。ZooKeeper胜在可靠性适合对一致性要求极其严苛的场景。数据库方案最大的价值是不需要额外引中间件在架构比较轻、不想引入新组件的小项目里可以临时顶一顶但性能瓶颈在那里摆着并发大一点就撑不住。3. Redis分布式锁从入门到实战3.1 最简版本SETNX EXPIRE 的经典组合与隐患很多人学Redis分布式锁第一条代码就是SETNX配合EXPIRE。逻辑上很简单SETNX表示如果key不存在才设置成功谁设置成功谁就拿到锁拿到锁之后再用EXPIRE给key加一个过期时间防止持有者崩溃导致死锁。伪代码如下# 获取锁 SETNX lock_key client_id EXPIRE lock_key 30 # 执行业务 # 释放锁 DELETE lock_key这个版本看着能用问题却致命SETNX和EXPIRE是两条命令非原子操作。如果SETNX执行成功了客户端刚准备执行EXPIRE时突然宕机key就没有过期时间这把锁永远不会释放其他客户端全部卡死。这个bug在真实环境里出现得相当频繁尤其是网络抖动或进程被kill的时候。另一个坑是释放锁时直接用DELETE。假设客户端A的锁到期没执行完客户端B抢到了锁这时A终于执行完了一个DELETE就把B的锁删了C又趁机进来……锁的互斥性完全失效。所以这个最简版本只适合在单机Redis且业务极短的测试环境里玩玩千万别上生产。3.2 标准版本SET 原子命令 Lua 释放锁正确打法是把获取锁的原子性交给Redis官方建议的SET key value NX EX命令一条命令同时完成不存在才设置和设置过期时间两个动作SET lock_key client_id NX EX 30NX保证只有key不存在时才设置成功EX 30指定30秒过期。这样一来获取锁是原子的不存在半路崩溃留死锁的问题。这里有个很容易被忽略的细节value必须是全局唯一的客户端标识一般用UUID或者业务ID 线程ID拼接。为什么要唯一因为释放锁的时候要校验这把锁是不是我持有的。释放锁的逻辑不能直接用DELETE要先比对value是否等于自己的client_id等于才删。两个操作必须原子执行于是要借助Lua脚本-- 释放锁的Lua脚本 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endJava里用Redisson调用的话大致长这样// 获取锁 RLock lock redisson.getLock(order: orderId); // 尝试加锁最多等待10秒锁30秒自动释放 boolean locked lock.tryLock(10, 30, TimeUnit.SECONDS); try { if (locked) { // 业务处理 } } finally { if (locked) { lock.unlock(); } }这套方案相比最简版本把获取锁和释放锁两个关键路径都做成了原子操作已经可以应对绝大多数业务场景。我做过不少订单系统的防重复处理直接落地这套方案稳定性是够用的。3.3 Redlock 多节点锁的原理与争议标准版本还有一个隐藏问题它只依赖单个Redis主节点。如果这个节点发生主从切换从节点还没同步锁数据就被提升为主节点另一个客户端过来加锁就会成功于是两个客户端同时持有锁。这在很多场景下不可接受。Redis作者Antirez提出了Redlock算法来解决这个问题。思路是不依赖单个Redis实例而是部署N个独立的Redis节点官方建议N5客户端依次向所有节点执行加锁操作只有当超过半数N/21节点加锁成功并且加锁总耗时小于锁的有效期才算加锁成功。释放锁时向所有节点发起释放。Redlock的核心思想是少数节点不可用不影响整体锁的判定。比如5个节点里有2个宕机你还能在3个节点上加锁成功这个锁依然是有效的。即使某个节点出了bug因为多数节点正常两个客户端同时拿到锁的概率被大幅压低。但Redlock并不完美业界争论很大。著名分布式系统专家Martin Kleppmann专门写过文章质疑核心问题有两个一是GC停顿。Java客户端的垃圾回收会造成长时间停顿比如暂停了30秒。客户端在GC前申请到了锁GC期间锁过期了另一个客户端拿到锁执行完并释放然后第一个客户端才苏醒继续执行临界区代码此时它以为自己还拿着锁其实锁已经换主人了。Redlock无法解决这个问题。二是时钟漂移。Redlock假设各个Redis节点的时钟是一致的但服务器时钟可能被NTP校准出现时间跳跃。一个节点上的锁提前过期了其他节点还没过期导致客户端觉得自己在多数节点上拿到了锁实际上锁的有效期已经不足。Antirez和Martin来回辩论了好几轮结论是满足一定条件下节点时钟步调基本一致、没有长时间GCRedlock能提供很强的互斥性但不能在绝对意义上保证。我在生产环境一般用Redisson的RedissonLock它的底层就是改良版的Redlock思路。但在设计业务的时候我会额外留一手即使锁出现极端的双持有情况业务侧的数据幂等校验也能兜住。比如防重复支付在支付回调里先查订单当前状态已经是已支付就直接返回这层防护和分布式锁是互补关系而不是把安全完全押在锁上。3.4 续约机制看门狗是怎么解决锁过期的锁过期问题比想象中更隐蔽。假设你设置锁30秒过期但某次业务执行需要40秒——执行到25秒时锁自动过期了另一个客户端B拿到锁进来两个客户端同时处理同一笔业务。这是标准的锁提前过期事故。解决办法是续约watchdog。Redisson的实现思路是加锁成功后启动一个后台定时任务每隔锁有效期三分之一的时长默认锁30秒就每10秒续一次检查锁是否还被当前线程持有持有则把过期时间重新设置为30秒。业务执行完毕显式释放锁后台任务随之取消。这个机制保证了只要客户端进程还活着、业务还在跑锁就不会因为时间到期而提前释放。只有当进程崩溃心跳停止watchdog不再续约锁才会在一定时间后自动释放。这正好命中分布式锁既要自动释放防止死锁又不能提前释放破坏互斥的两个要求。不过续约也不是万能的。如果业务线程发生了长时间GC暂停watchdog线程本身也可能被暂停无法及时续约锁照样过期。这也是任何分布式锁方案里都无法绝对消除的窗口遇到这种极端情况还是得靠业务层的幂等逻辑兜底。4. ZooKeeper 与数据库实现方案深挖4.1 ZooKeeper 临时顺序节点的实现思路ZooKeeper方案在可靠性上通常被认为优于Redis。核心是借助ZK的两种节点特性临时节点Ephemeral和顺序节点Sequential。具体做法是这样的客户端在锁路径下创建一个临时顺序节点比如/locks/order_1001/下面创建子节点ZK会自动编号_c_0000000001、_c_0000000002。客户端获取当前路径下所有子节点按序号排序判断自己创建的节点是不是序号最小的那个。如果序号最小说明自己抢到了锁否则监听前一个序号节点的删除事件然后进入等待。持有锁的客户端处理完业务删除自己的临时节点或者客户端崩溃ZK检测到会话超时自动删除临时节点。监听到前一个节点被删除的客户端被唤醒再次确认自己是不是序号最小如果是就获得锁。这个方案的可靠性来自ZK的ZAB协议所有节点的数据是强一致的同一个路径下子节点的创建顺序全局唯一不会出现Redis那种主从切换丢锁的问题。临时节点和会话绑定客户端挂了会话结束节点消失锁自动释放不需要额外设置超时时间。性能上ZK方案肯定比Redis慢因为写节点要经过ZAB协议的半数确认涉及磁盘持久化。但在对一致性要求极高、并发量不算大的场景比如分布式任务调度、配置管理ZK依然是很合适的方案。4.2 数据库悲观锁方案SELECT FOR UPDATE数据库方案最大的好处是不引入新组件用已有的业务数据库就能实现。最典型的悲观锁实现就是SELECT ... FOR UPDATE-- 在事务里执行 BEGIN; SELECT * FROM order_lock WHERE biz_key order:1001 FOR UPDATE; -- 执行业务逻辑 -- 提交事务行锁自动释放 COMMIT;原理是FOR UPDATE会对命中的行加排他锁其他事务想再对这一行加锁时会被阻塞直到当前事务提交或回滚。这实际上就是分布式锁——所有服务实例都通过同一个数据库的行锁来互斥。优点是实现极其简单还天然支持可重入同一个事务里多次SELECT FOR UPDATE不会锁死自己。缺点是性能天花板很低。数据库的行锁竞争、事务间的阻塞等待、连接长时间占用这些都是瓶颈。另外还要小心事务超时和死锁的问题——FOR UPDATE的事务执行时间一旦超过数据库的innodb_lock_wait_timeout锁等待会被中断抛异常。这个方案适合一小类场景业务表本身就在数据库里并发量不高比如后台管理系统的操作锁、低频的批处理任务互斥。如果是为了电商秒杀这种高并发场景数据库锁基本顶不住。4.3 数据库乐观锁方案version 字段悲观锁之外数据库还有一条更轻量级的路线——乐观锁。核心思想是不加锁更新时校验版本号对不对不对就放弃。实现方式是给数据表加一个version字段UPDATE inventory SET stock stock - 1, version version 1 WHERE product_id 1001 AND version 3;UPDATE影响行数为1说明version匹配成功库存扣减生效影响行数为0说明version已经被其他事务改掉了当前更新失败需要重试。乐观锁的本质是利用数据库行更新的原子性来保证读取-判断-写入的原子性它没有显式的锁等待不会阻塞其他事务性能比悲观锁好不少。但它只适合单个资源更新这种简单场景。一旦临界区是多条记录的复杂操作或者必须保证读操作的绝对一致性乐观锁就会显得力不从心。我自己的经验是乐观锁最适合库存扣减这种单行更新的场景尤其适合并发冲突概率低的业务。冲突频繁时大量更新失败重试反而加大了数据库压力反而不如悲观锁或者Redis锁。面试里提到乐观锁能说出适合读多写少、冲突少这个边界比单纯报答案有用得多。4.4 三类方案选型的关键判断依据聊这么多最后落到选型上个人建议按三个维度来判断。第一个维度并发量。每秒几千以上的抢锁请求直接选Redis。ZK和数据库方案在高压下要么性能不足要么连接被占满。第二个维度一致性要求。如果锁被重复持有会产生资金损失、数据严重错乱那就往ZooKeeper方向靠。如果只是防止重复操作偶尔的极小概率并发可通过幂等兜底Redis已经完全够用。第三个维度基础设施现状。项目里已经有Redis就优先RedisZooKeeper也已经是集群的一部分那就用ZK啥中间件都不想加并且并发低数据库方案最省事。没有绝对的最优只有结合场景的最合适。5. 高频面试题与线上故障排查实录5.1 分布式锁高频面试题与答题框架分布式锁是面试高频题问法通常从浅到深我把几个典型问题和答题框架整理出来。分布式锁有哪些实现方式列出Redis、ZooKeeper、数据库三种分别说一句核心原理和优缺点。注意顺序先说Redis因为它最常用再说ZK突出可靠性最后补数据库说明适用低并发场景。Redis分布式锁怎么防止死锁关键答出两点加锁时设置过期时间并且加锁动作要原子SET key value NX EX释放锁用Lua脚本校验value后删除。再补一句生产环境用Redisson的watchdog自动续约防止业务未结束锁先过期。为什么释放锁之前要判断value核心是为了防止误删别人的锁。场景复述线程A的锁到期线程B拿到锁A执行完后直接DELETE会把B的锁删掉。所以value必须是全局唯一标识释放时先比对再删除并且整个过程用Lua保证原子。锁过期了线程A还在执行怎么办答题框架第一层watchdog定时续约第二层业务上加幂等校验兜底第三层尽量缩短业务执行时间把锁粒度放细。Redlock解决了什么问题有什么缺点答出多节点半数以上加锁成功的思路再说GC停顿和时钟漂移两个缺陷。能提一句Redlock提供的是概率意义上的强互斥就更有深度。ZK实现的分布式锁和Redisson有什么区别核心差异在锁的释放机制和一致性ZK基于临时顺序节点客户端会话自动释放锁一致性由ZAB协议保证Redis基于过期时间和Lua释放一致性在主从切换时可能被破坏。性能上Redis快一个数量级。分布式锁的数据结构怎么设计value里放什么Redis的value放客户端唯一标识可以扩展成业务标识:线程标识:重入次数来支持可重入key一般按业务维度命名如lock:stock:1001。怎么测试分布式锁是否有效写并发测试脚本模拟多线程同时抢锁统计是否所有临界区代码都被串行执行再模拟持有锁的实例宕机确认锁能否在过期时间后自动释放还可以做一次主从切换测试观察是否出现双持有。这个实操思路面试亮出来很加分。5.2 一次线上故障排查实录锁被误删之前带团队做过一个优惠券发放系统上线后监控到偶发的同一用户重复领券。查日志发现两位用户在同一毫秒级各自领了同一张券明显是分布式锁失效。排查过程值得分享。第一层排查先看Redis里锁的存活状态发现锁存在且有过期时间初步排除死锁。第二层排查把加锁和释放锁的日志打上时间戳发现线程A释放锁的时刻晚于线程B获取锁的时刻——这说明A释放锁时删掉的其实是B的锁。再往深看A加锁时设置过期时间10秒业务里还嵌了一个第三方接口调用偶尔响应超过10秒锁到期了业务没结束B就拿到锁了。根因确认锁过期 未校验value直接DELETE。修复做了三件事升级Redisson开启watchdog续约释放锁脚本加上value校验给第三方接口设置超时熔断阻断响应过长拖垮业务。这个案例的核心经验是锁的每个环节都要闭环测试不能只在正常路径上模拟。线上真正出问题的几乎都是异常路径网络抖动、接口超时、GC停顿、节点故障这些在开发和测试环境很难触发但在生产环境永远存在。5.3 避坑清单与最后一点心得最后把我这几年踩过的坑整理成一个速查清单方便大家对照自查坑点问题描述规避措施SETNX与EXPIRE分离非原子半路宕机导致死锁使用SET key value NX EX原子命令释放不校验value误删他人锁互斥失效Lua脚本比对value后再DEL锁过期业务未结束两个线程同时执行临界区Redisson watchdog自动续约 业务幂等兜底主从切换丢锁从节点未同步锁数据即提升为主多节点Redlock方案锁粒度太大高并发下大量请求排队等待吞吐暴跌按业务维度拆细key如按商品ID、用户ID不支持可重入同线程加锁后递归调用自己死锁value记录线程标识与重入次数或使用可重入封装监听ZK羊群效应大量客户端同时监听同一节点释放时惊群只监听前一个顺序节点分布式锁这东西接口就那么几个方法原理也不复杂难点全在极端场景下的可靠性。我的体会是设计阶段多花十分钟想清楚如果这个锁失效业务会发生什么比上线后排查几个小时有价值得多。后续如果你想深入可以再看看Redisson源码里watchdog的实现方式以及ZK客户端的会话重连机制把底层原理啃透面试和实战都会从容很多。
返回列表