从表单重复提交到库存超卖,分布式锁真是绕不开的一道坎。我最早接触这个概念是在一个秒杀系统里,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解锁。这种写法在并发不高的系统里可能看不出问题,但高并发下全是隐患。
一是没有设置过期时间,一旦持有锁的线程崩溃,锁就永久占住,其他线程全部阻塞。二是没有持有者标识,前面说的误删锁问题必然发生。三是没有续期机制,多线程下锁的持有时间不可控。
如果你是在面试里被问到,或者要给团队写一个通用组件,一定不要停留在“SETNX+DEL”的玩具阶段。正确的做法是使用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上手,把看门狗、公平锁、可重入这些内置能力用起来,避免重复造轮子。等把底层原理吃透了,再根据自己的业务场景定制也不是不行。最后再分享一个小技巧:加锁前先想清楚一个问题——“这个锁到底在保护什么资源,加锁失败后业务能不能优雅降级”,答案想清楚了,分布式锁的方案就成功了一大半。