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

资讯详情

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

分布式锁从原理到实战:Redis、ZooKeeper与数据库方案全解析

分布式锁从原理到实战:Redis、ZooKeeper与数据库方案全解析 从表单重复提交到库存超卖分布式锁真是绕不开的一道坎。我最早接触这个概念是在一个秒杀系统里QPS一上来本地锁直接失灵数据库唯一索引扛不住写入压力接口响应飘红一条数据能被同时写进好几份。后来老老实实把Redis分布式锁落地到生产环境才把这堆麻烦按下去。这篇笔记我就把分布式锁的来龙去脉、三种主流实现、Redis方案的完整实操过程还有那些文档里不会写的踩坑经验一次性讲清楚。不管你是刚接触分布式开发的新手还是已经用过锁但被各种极端情况坑过的老手这篇应该都能给你点参考。1. 搞懂分布式锁要解决什么难题1.1 本地锁为何在分布式环境下失灵很多单体应用时代的老代码防并发就靠synchronized或者ReentrantLock。这两个东西的本质都是JVM层面的线程互斥同一个进程里的多个线程抢同一个锁对象线程调度器保证同时只有一个线程能拿到锁。这套机制在单机部署时完全够用。但一旦服务拆成多个实例或者同一个服务部署了多份副本情况立刻变了。用户A的请求打到实例1用户B的请求打到实例2两个实例各自持有一把本地的ReentrantLock这两把锁之间完全没有互通机制。结果就是两个线程同时进入了临界区同时去改库存、同时去扣余额、同时去插入同一条业务数据。这就是本地锁的盲区——它只能约束一个进程内部的并发约束不了进程之间的竞争。有个很典型的场景定时任务。很多系统用Scheduled做定时对账、定时推送、定时清理单机部署时定时任务只会跑一份。一旦服务水平扩展成两个实例同一个定时任务在每个实例上都会触发一次。如果没有分布式锁兜底对账任务就会重复执行轻则重复发消息重则把账对出双倍差异。我接手过一个线上事故就是凌晨的定时任务重复跑给用户发了三遍优惠券原因就是服务扩容后没处理分布式锁。1.2 分布式锁必须满足的四个硬性要求分布式锁本质上是一把“大家都能看见”的锁不管请求落在哪个实例上加锁和解锁都要对同一份共享存储操作这样才能跨进程互斥。实现方案五花八门但底层都绕不开几个硬性指标。第一是互斥性。任何时刻只能有一个客户端持有锁这是锁存在的根本意义。第二是防死锁。持有锁的客户端崩溃了、网络断了、GC卡死了锁必须能自动释放不然其他客户端会永久阻塞。第三是高性能和高可用。加锁解锁操作本身要快不能为了加一把锁引入一个超重的组件同时锁服务本身不能成为单点。第四是支持阻塞和非阻塞获取。业务需要立即返回失败还是等待锁释放两种模式都要能实现。在实际项目中光满足这四个基本要求还不够。后面我会专门讲到两个经常被忽视的细节一个是加锁时必须带上持有者标识防止误删别人的锁另一个是锁要有自动续期机制防止业务没执行完锁就过期了。这两个细节决定了分布式锁在极端情况下是“基本可用”还是“真正可靠”。2. 三种主流实现方案横向对比2.1 基于数据库的锁——最朴素也最少用数据库实现分布式锁有两种常见姿势。一种是基于唯一约束在表里插入一条记录记录里带上锁名和持有者标识谁插入成功谁就拿到锁释放时就删掉这条记录。另一种是基于乐观锁在业务表上加版本号字段更新时检查版本号是否匹配不匹配就重试或失败。数据库方案的优点是好理解、不用引入额外组件但缺点非常致命。性能差每次加锁解锁都是一次数据库读写高并发下数据库连接会被大量占用。可靠性也差如果持有锁的线程异常退出锁记录不会自动消失必须额外做超时清理。更麻烦的是数据库本身也是分布式系统主从切换、网络分区时同样有一致性问题。我在一些老项目里见过这种方案基本都是存量系统实在没条件引入Redis或ZooKeeper才凑合用。新项目直接用数据库做分布式锁我持保留意见——成本低但坑也多不推荐作为首选方案。2.2 基于ZooKeeper的锁——强一致但性能偏弱ZooKeeper实现分布式锁的经典方式是临时顺序节点。客户端在锁目录下创建一个临时顺序节点然后检查自己是不是序号最小的节点是则拿到锁否则监听前一个节点的删除事件。前一个节点被删除后当前客户端被唤醒再次检查自己是否是最小序号如此循环直到获得锁。这种方案的好处是ZooKeeper的ZAB协议保证了强一致性不存在Redis那种主从切换导致锁丢失的问题。临时节点也天然支持客户端崩溃自动清理不用手动设置过期时间死锁风险很低。同时监听机制天然支持锁的公平性——先到先得不会出现Redis那种非公平抢锁导致的饥饿问题。但ZooKeeper方案的性能上限一般。创建节点、监听事件都是RPC调用延迟明显高于Redis的内存操作高并发场景下单节点能支撑的加锁频率远低于Redis。另一个问题是ZooKeeper本身也是集群部署如果集群规模不大加锁的吞吐会成为瓶颈。我的经验是对一致性要求极高、对性能要求没那么苛刻的场景比如分布式任务调度、配置中心内部协调选ZooKeeper很稳对性能敏感的在线接口ZooKeeper不是最优解。2.3 基于Redis的锁——性能王者但需小心使用Redis实现分布式锁是当前互联网公司的主流方案。核心操作就是一条命令SET lock_key unique_value NX PX expire_time。NX保证只有键不存在时才能设置成功PX设置过期时间防止死锁unique_value用于释放锁时校验持有者身份。Redis是纯内存操作单次加锁耗时通常在毫秒以内性能碾压数据库和ZooKeeper方案。Redis方案的缺点在于一致性边界。标准部署是主从架构客户端在主节点上加锁成功但主节点还没来得及同步到从节点就宕机了从节点晋升为主节点后锁就丢了别的客户端就能再加一把同样的锁。这就引出了分布式锁领域著名的Redlock算法争论。Redlock的思想是向多个独立的Redis节点依次加锁超过半数节点成功才算加锁成功以此降低单点故障导致锁失效的概率。但Redlock并非银弹它依赖时钟同步复杂度也很高实际生产中用得反而不多。我在生产环境的经验是绝大多数业务场景下Redis单节点加上主从标配再配合看门狗续期和持有者校验已经能挡住99%的问题。真正需要Redlock级别的强一致锁说明业务本身在架构设计上就有问题——比如把分布式锁当作幂等性的唯一防线这本身就是危险的设计。2.4 对比表格——三个方案怎么选维度数据库锁ZooKeeper锁Redis锁性能低数据库IO开销大中RPC创建节点事件通知高纯内存操作可靠性低依赖连接和事务异常退出锁残留高临时节点自动清理中依赖过期时间主从切换有丢失风险一致性弱受隔离级别影响强ZAB协议保证弱主从异步复制实现复杂度低SQL即可实现中需理解节点模型和监听机制低一条SET命令搞定核心逻辑适用场景存量系统、并发极低强一致要求、任务调度在线高并发、接口幂等、性能敏感实际选型时我给团队的建议是优先Redis因为大多数互联网场景对性能敏感而且Redis已经普遍是基础设施的一部分不需要额外引入组件。如果对一致性要求极高且并发量不大选ZooKeeper或etcd。数据库方案建议直接跳过。3. Redis分布式锁实操全流程3.1 三步完成基础加锁与解锁先说最基础的实现。加锁用Redis的SET命令必须同时设置NX和PX参数这是关键中的关键。// 加锁key是业务锁名称value是请求唯一标识 public boolean tryLock(String lockKey, String requestId, long expireTimeMillis) { // jedis是Redis客户端示例 String result jedis.set(lockKey, requestId, NX, PX, expireTimeMillis); return OK.equals(result); }为什么要带上requestId因为解锁时要校验身份确保只能释放自己持有的锁。如果只凭lockKey解锁可能出现这种情况线程A加锁后执行很慢锁到期自动释放了线程B加锁成功此时线程A终于执行完了调用del(lockKey)结果把线程B的锁误删了。这种误删锁是分布式锁使用中最典型、破坏性最强的错误务必通过持有者标识规避。解锁操作必须用Lua脚本保证原子性。为什么要用Lua脚本因为“判断持有者删除键”是两个操作如果分开执行就有可能在判断完毕之后、删除之前锁刚好过期被其他线程抢走然后你删掉了别人刚加的锁。Lua脚本将这两个操作打包在Redis服务端原子执行彻底杜绝了这个竞态窗口。-- 解锁Lua脚本保证检查持有者与删除键的原子性 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end3.2 锁过期时间应该设置多长过期时间设置成多少是个经典难题。设置太短业务还没执行完锁就自动释放了另一个线程趁虚而入临界区被同时进入。设置太长如果持有锁的线程崩溃了其他线程要等很久才能重新获取锁。一个常见做法是根据业务最大执行时间估算。比如一次库存扣减操作经过压测确认99.9%的情况下执行时间都在2秒以内那把过期时间设置为5秒留出充足余量。但这种方法有个漏洞——很多业务的执行时间不是稳定可控的。数据库慢查询、下游接口超时、Full GC停顿任何一个因素都可能导致执行时间超过预设值。更靠谱的方案是引入自动续期机制。核心思路是启动一个守护线程定期检查锁是否还持有如果持有且业务还没执行完就自动续期重置过期时间。这个机制在Redisson中被称为“看门狗”。默认续期逻辑是每10秒检查一次如果锁还持有就重置为30秒确保锁在业务执行期间始终保持有效。3.3 看门狗续期的实现原理看门狗的本质是一个定时任务。Redisson的默认实现是加锁成功后启动一个后台任务每隔锁过期时间的1/3检查一次锁是否还在如果还在就把过期时间重置为初始值。以默认30秒过期时间为例watch dog会每隔10秒执行一次续期操作只要业务线程还活着锁就会一直有效。看门狗要解决的核心问题是如何判断“业务线程还活着”。Redisson的做法是每个锁对应一个独立的调度任务锁持有线程执行完毕后无论正常返回还是抛异常都会在finally块中释放锁并取消看门狗任务。如果持有线程所在的JVM直接宕机看门狗任务也随之消失锁在过期时间到达后自动释放不会形成死锁。手动实现看门狗也不难。在你自己的分布式锁工具类里加锁成功后启动一个ScheduledExecutorService定期执行续期Lua脚本释放锁时关闭定时任务。不过建议直接用Redisson成熟稳定不用重复造轮子。// 使用Redisson获取锁并执行业务 RLock lock redissonClient.getLock(inventory_lock); try { // waitTime为获取锁的最大等待时间leaseTime为-1时启用看门狗自动续期 boolean locked lock.tryLock(3, -1, TimeUnit.SECONDS); if (!locked) { return 系统繁忙稍后重试; } // 执行业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }重点说一下这里的leaseTime参数。传-1表示启用Redisson的看门狗机制锁不会因为业务执行时间长而提前失效。如果传了一个具体的值比如10表示锁的过期时间固定为10秒看门狗不生效。这个区别在实际使用中太容易踩坑了——我见过有人在业务可能要执行30秒的接口里给leaseTime传了10秒结果锁频繁提前释放并发控制完全失效。3.4 为什么不建议直接用SETNX加整个业务锁很多老文章教你用SETNX lock_key 1加锁用完DEL lock_key解锁。这种写法在并发不高的系统里可能看不出问题但高并发下全是隐患。一是没有设置过期时间一旦持有锁的线程崩溃锁就永久占住其他线程全部阻塞。二是没有持有者标识前面说的误删锁问题必然发生。三是没有续期机制多线程下锁的持有时间不可控。如果你是在面试里被问到或者要给团队写一个通用组件一定不要停留在“SETNXDEL”的玩具阶段。正确的做法是使用SET key value NX PX命令配合Lua脚本解锁再加上看门狗续期三层缺一不可。这也解释了为什么Redisson在Java生态里这么流行——它不仅实现了分布式锁的标准用法还把你容易遗漏的细节全部封装好了。4. 从会用到活用——锁的应用进阶4.1 锁粒度锁越细并发越高分布式锁的使用里锁粒度是非常影响系统吞吐量的设计点。对同一把锁的竞争越激烈系统串行化越严重吞吐量就越低。锁粒度做细核心思路是让不同维度、不同范围的业务互不影响。用库存扣减举例。假设系统里有100个商品锁粒度可以让每个商品ID作为锁的唯一标识也就是说不管请求量多大只要操作的是不同商品锁之间完全不冲突只有操作同一个商品的请求才会竞争同一把锁。实现时用lock_key_{productId}即可。这个设计让并发能力从“全系统只有一个入口”提升到“每个商品一条独立通道”。我经手过一个促销系统上线前商品详情页库存扣减接口的压测结果不到500 QPS把锁粒度从全局锁改成SKU级锁之后压测数据直接翻了好几倍系统平均RT还降了。再举个例子订单状态流转和库存扣减是两件完全独立的事就不应该共用同一把锁。拆成order_status_lock_{orderId}和inventory_lock_{skuId}各自管好各自的范围互不拖累。锁粒度设计的核心原则是能拆多细就拆多细但前提是拆分后依然能保证业务上的互斥需求。4.2 公平性Redis锁天生非公平Redis分布式锁天然是非公平锁。多个线程同时抢锁时谁的网络快、谁先到达Redis谁就能拿到锁。理论上是公平的毕竟先到先得。但在极低概率下线程自旋重试可能导致后来者“插队”——线程A释放锁线程B和线程C同时监听B先收到通知去抢锁此时C可能已经重试成功拿到了锁。这种现象叫惊群效应不过在Redis加锁场景下通常影响不大。真正要注意的是你把分布式锁用在什么场景。如果业务对公平性有要求比如取号服务要求先到先得Redis的非公平锁特性会导致极端情况下后到的请求先拿到锁。这个时候选ZooKeeper的临时顺序节点方案更合适它的监听机制天然保证公平性。生产环境我见过取号服务用Redis锁结果顺序乱掉的情况排查到最后发现是同事把库存模块的锁直接copy了过去压根没考虑公平性需求。4.3 Redlock争议为什么我没推荐它前面提到Redis主从切换可能导致锁丢失Redlock就是为解决这个问题提出的。Redlock要求部署多个相互独立的Redis主节点加锁时依次向每个节点发送SET命令超过半数成功才认为加锁成功。理论很美好但实践上有几个现实问题。第一是成本高至少需要5个Redis节点才能构建一个可用的Redlock。第二是性能差加锁的耗时取决于最慢的那个节点网络的任何一个抖动都会拖慢加锁速度。第三是它依然假设节点时钟一致但Redis节点如果时钟漂移锁的有效期就会失真。更重要的是Redlock解决的是分布式锁的安全极端情况但99%的业务并不需要这种级别的保障。用Redlock换来的强一致保证在很多场景下其实可以用其他手段替代。比如库存扣减就算Redis锁因为主从切换丢了只要数据库层面有幂等约束或乐观锁兜底就不会产生超卖。与其追求分布式锁绝对安全不如在业务层做好兜底设计——这句话我在很多团队分享里都强调过。4.4 锁失效后的兜底策略分布式锁再稳极端情况下还是会失效。主从切换锁丢失、网络分区导致锁无法访问、业务线程Block到锁自动过期……这些情况在真正的生产环境里都是可能发生的。成熟的系统设计一定不会把分布式锁当作唯一的防线。以库存为例除了Redis锁数据库层可以做乐观锁UPDATE inventory SET stock stock - 1 WHERE id #{id} AND stock 1保证即使锁失效也不会扣成负数。再配合数据库唯一索引做幂等同一笔订单的重复提交会被直接拒绝。这三层防线层层递进任何一层失效都有下一层兜底。分层防御的思想很朴素分布式锁保证并发下的互斥执行数据库约束保证数据层面的一致性接口幂等保证重复请求的安全。每层都有自己的责任边界不要指望一把锁解决所有问题。5. 生产环境常见问题与排查实录5.1 业务还没执行完锁就自动过期了表现接口偶发出现并发问题日志里能看到同一个业务Key被多个线程同时执行。排查思路先确认锁的过期时间设置再确认是否启动了看门狗续期。如果是手动实现重点检查定时续期线程是否正常执行。如果是Redisson检查leaseTime是否误传了固定值。最常见的原因是同事封装分布式锁工具类时为了省事把过期时间写成了固定值2秒但业务里有一段耗时不一定可控的下游接口调用一旦下游慢锁就提前释放了。解决办法是要么把过期时间调到业务耗时的3倍以上并按压测结果校准要么启用看门狗续期机制让锁的过期时间跟着业务执行时间自动延长。5.2 误删了别人的锁表现同一条业务数据被两个线程同时修改数据出现覆盖或被更新成旧值。排查思路看解锁逻辑是否校验了持有者标识。如果解锁代码是del(lockKey)这种不带条件判断的写法那误删锁几乎是必然发生的——线程A锁超时释放线程B加锁成功线程A执行完后直接删锁删掉的其实是B的锁。解决办法很简单加锁时存入一个全局唯一标识解锁时先执行Lua脚本校验标识匹配再删除。同时建议在日志中把加锁和解锁时的持有者标识打出来排查问题时能快速定位是谁删了谁的锁。5.3 加了分布式锁但接口还是被并发穿透表现加上锁之后压测时仍然能观察到并发请求同时进入临界区。排查思路这类问题通常不是锁本身的问题而是锁的使用姿势有问题。最容易犯的错是只锁了部分代码比如缓存更新时先查询数据库再写缓存但查询和更新之间没有放在同一个锁保护范围内或者加锁的位置在事务外层锁释放了但事务还没提交另一个线程读到了旧数据。另一个常见错误是分布式锁和事务的顺序颠倒。按照“先加锁再开启事务业务执行完先提交事务再释放锁”的顺序才是安全的——锁保护的是业务操作不是事务生命周期。如果先提交事务后释放锁其他线程就能在锁持有期间读到未提交的新数据造成脏读。5.4 大量线程等待锁接口响应变慢表现某个接口的平均响应时间从几十毫秒涨到几秒Tomcat线程池被占满。排查思路首先判断锁竞争是否激烈再看是否有线程持有锁时间异常长。可以通过Redis的TTL lock_key命令查看锁的剩余过期时间通过GET lock_key查看当前持有者标识再通过应用日志定位这个持有者是在执行什么业务。如果发现锁持有时间异常长大概率是持有线程在临界区内调用了慢接口或发生了长GC。处理思路一是优化临界区代码只把必须互斥的最小操作放在锁内二是对慢调用设置超时时间防止线程无限期等待下游服务三是评估是否可以通过细粒度锁减少竞争。5.5 常见问题速查表问题可能原因快速定位方式解决方案业务未执行完锁已释放过期时间设置过短无续期机制打印锁获取与释放日志比对业务耗时启用看门狗续期或按压测结果调大过期时间误删他人锁解锁未校验持有者检查解锁代码是否使用Lua脚本加锁带requestId解锁用Lua脚本校验并发穿透锁保护加锁范围不够锁与事务顺序错误检查临界区覆盖范围定位事务提交位置扩大锁范围至完整业务操作先提交事务再释放锁接口响应变慢锁竞争激烈临界区耗时过长Redis的TTL和GET命令查看持有情况细化锁粒度精简临界区代码设置下游超时主从切换锁丢失Redis主节点宕机锁未同步到从节点查看Redis复制状态查主从切换记录业务层兜底幂等设计极高一致性场景用ZooKeeper替换5.6 我的锁性能测试清单每次给团队交付一个分布式锁组件我都会跑一遍完整的性能测试清单。第一项是基本功能验证单线程加锁、解锁、重入是否正常。第二项是并发竞争测试多线程同时抢锁确认同一时刻只有一个线程进入临界区日志中的并发数始终保持为1。第三项是锁超时测试人为让业务线程睡眠超过锁过期时间确认锁能自动释放其他线程能重新获取。第四项是异常恢复测试模拟持有锁的线程抛出异常、JVM宕机确认锁清理符合预期。第五项是崩溃之前锁清理测试模拟进程被强杀重启后确认锁没有被永久占住。这套清单执行下来基本能把分布式锁的各种边界情况覆盖到。尤其推荐第三项和第四项这两项最容易暴露手动实现方案的漏洞。我见过不少团队上线一套手写分布式锁功能测试全过结果压测时业务线程一卡锁就提前释放并发问题立刻爆发——本质就是没做超时和异常恢复的验证。6. 写在最后的建议我在实际操作中体会最深的一点是分布式锁的难点从来不在“怎么加锁”而在“异常情况下锁还能不能守住底线”。Redis的SET NX PX一条命令就能实现基础互斥但面对过期续期、持有者校验、主从切换、业务兜底这些真实世界的复杂情况每一层都需要认真设计。如果你刚开始使用分布式锁建议先从Redisson上手把看门狗、公平锁、可重入这些内置能力用起来避免重复造轮子。等把底层原理吃透了再根据自己的业务场景定制也不是不行。最后再分享一个小技巧加锁前先想清楚一个问题——“这个锁到底在保护什么资源加锁失败后业务能不能优雅降级”答案想清楚了分布式锁的方案就成功了一大半。
返回列表