ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从synchronized到Redis分布式锁:原理、演进与工程实践

从synchronized到Redis分布式锁:原理、演进与工程实践

1. 一场超卖事故逼出的分布式锁

有一次我排查库存超卖问题时,发现罪魁祸首不是 SQL,而是 Java 里最基础的 synchronized。Redis 分布式锁就是在那次事故里被正式引入项目的,也是今天这篇内容的主角。当时限时抢购刚开始一分钟,订单量冲到每秒几百,库存却从 100 直接跌到了 -3。单看代码逻辑完全没有问题:扣减前判断 stock > 0,扣减时加了同步锁,为什么还是会超卖?

问题出在服务部署了三个实例,synchronized 只能锁住当前 JVM 内的线程。三台机器上的三个锁互相看不见,负载均衡把请求一分散,同一个时刻可能有两台实例同时对 stock=1 的记录做扣减,最后库存自然变成负数。

这种场景下,需要一把"所有实例都能看到"的锁。Redis 因为读写快、部署简单,成了最常用的选择。但分布式锁没有想象中那么简单:加锁、解锁、过期时间、锁续期、主从故障,每个环节都藏着坑。这篇文章我会从最基础的实现开始,逐步把每一层缺陷和对应的解法讲清楚,最后给出一份可以直接参照的排查清单。

1.1 synchronized 在集群环境里为什么会失灵

多实例部署是分布式锁存在的根本原因。单机时,JVM 里的 synchronized 保证同一时刻只有一个线程进入临界区,因为所有线程都共享同一个监视器锁。一旦服务拆成多个实例,每个实例各自持有一个 JVM,同一个键的请求被负载均衡分发到不同的机器上,A 实例的 synchronized 并不能阻止 B 实例的线程进入同一个方法。

举个具体数字:库存 100,两个实例同时收到扣减请求,各自执行"检查库存大于 0、扣减库存",两台机器都读到 1,都认为自己可以扣,最后数据库 update 语句执行两次,库存变成 -1。也就是说,如果同步锁只锁在自己所在的 JVM 里,在集群环境下就只是心理安慰,该超卖还是会超卖。

1.2 判断一把分布式锁是否合格的四个条件

经历过那次事故后,我给自己定了一个评估清单,任何分布式锁方案都要先过一遍:

第一,互斥性。任何时刻只能有一个客户端持有锁。这是分布式锁存在的意义,也是最难保证的一条,因为要同时考虑并发、网络、节点故障。

第二,防死锁。持有锁的客户端崩溃后,锁必须能够自动释放,否则会出现永久"卡死"的状态。最简单的手段就是给锁设置过期时间,但过期时间引入的新问题后面会详细说。

第三,可重入性。如果同一个线程已经持有锁,再次进入同一把锁时应该能直接通过。很多业务场景会嵌套调用同一个加锁方法,完全不支持重入的锁在工程上是很难用的。

第四,容错性。Redis 自身也可能故障,单节点挂了锁怎么办,主从切换后锁会不会丢。第四点在大多数内部系统里被忽视了,但它决定了方案在极端情况下的安全边界。

有了这四条标准,再去看市面上的各种实现,就不会被花哨的名字带偏。接下来按实现演进顺序,把代码一层层写出来。

2. 从 SETNX 到 Lua 脚本:锁实现的完整演进

2.1 用 SETNX 加锁,很多人的第一版代码长这样

网上能搜到的大量 Demo,第一版加锁代码基本是这样的:

// 第一版:看起来能用,实际埋了一堆雷 if (jedis.setnx(lockKey, "1") == 1) { jedis.expire(lockKey, 30); // 执行业务逻辑 jedis.del(lockKey); }

这段代码有三个问题。最致命的坑是 setnx 和 expire 是两次独立命令,如果 setnx 成功之后、expire 执行之前,当前进程突然崩溃或者被重启,锁就没有过期时间,会一直存在 Redis 里,后续所有请求都抢不到这把锁。业界管这叫"锁泄漏",比数据库连接泄漏更难排查,因为没有任何异常日志,只是请求全部卡住。

第二个坑是 value 写死成了 "1"。任何客户端都可以对这个 key 执行 del,也就是说 A 线程加的锁,B 线程随手就删掉了。这会导致两个线程同时进入临界区,分布式锁形同虚设。

第三个坑相对隐蔽:解锁发生在业务逻辑之后,但如果业务执行时间超过了设定的 30 秒,锁已经自动过期并可能被其他线程抢到,此时 A 线程执行完再 del,等于删掉了别人刚拿到的锁。第一版代码在低并发下可能一直正常,一旦业务慢下来,问题就会集中爆发。

2.2 SET NX PX 一条命令完成加锁

正确的基础版加锁应该把 setnx 与 expire 合并成一条命令。Redis 从 2.6.12 版本开始支持 SET 命令同时携带 NX 和 PX 参数:

String requestId = UUID.randomUUID().toString(); String result = jedis.set(lockKey, requestId, SetParams.setParams().nx().px(30_000)); if ("OK".equals(result)) { // 拿到锁,执行互斥逻辑 }

NX 保证"只有 key 不存在时才写入",PX 30000 表示 30 秒后自动过期。这两个行为在 Redis 服务端是同一条命令完成的,不存在中间崩溃导致锁永久残留的问题。

这里的 requestId 千万不要省。它是一个随机标识,唯一的作用就是给锁标上"持有者身份"。后面解锁时,正是靠这个标识确认"这确实是我自己拿到的锁",而不是随手删掉别人的锁。直接用线程 id 或者 UUID 都可以,关键是每次加锁的值要尽可能唯一。

过期时间的选择也要说两句。设太短,业务稍微慢一点锁就没了;设太长,持有锁的客户端崩溃后,其他客户端要等很久才能拿到锁。我一般会根据业务耗时的 P99 来定,留出 2 到 3 倍的余量,同时不能低于几百毫秒,否则网络抖动都可能把锁抖掉。这个参数没有标准答案,需要压测后调。

2.3 解锁的 Lua 脚本:先确认是自己的房间再关门

解锁比加锁更容易写错。很多项目的解锁逻辑是:

if (requestId.equals(jedis.get(lockKey))) { jedis.del(lockKey); }

这段代码的问题在于 get 判断和 del 删除是两条命令。假设 A 线程执行完 get,确认锁是自己的,还没来得及 del,锁恰好过期了。B 线程这时抢到锁并写入自己的 requestId,A 线程继续执行 del,删掉的还是 B 刚刚拿到的锁。两个线程瞬间都认为自己持有锁,互斥性被破坏。

正确做法是把判断和删除放到同一个 Lua 脚本里,让 Redis 保证整个脚本的原子性:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

调用侧对应的 Java 代码大致是这样:

String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId));

之所以强调 Lua 脚本,是因为 Redis 在执行脚本时不会穿插其他命令。关门之前先看看门牌号是不是自己的房间,这两个动作是在服务端一口气完成的,别人没有机会在中间把锁抢走。

2.4 走到这一步,锁能直接上线吗

到这里,我们已经有了一把具备互斥性、防死锁、不会误删他人锁的基础锁。如果业务并发不高、容忍秒级锁等待,这个版本其实已经能应付大部分内部系统。

但它仍然有三个明显短板:第一,没有锁续期,业务执行时间超过过期时间后锁就自动失效;第二,没有重入机制,同一线程嵌套加锁时可能自己把自己挡住;第三,Redis 主从切换时锁可能丢失。后面两章就是围绕这三个短板展开的。

3. 业务没跑完锁先过期了:续期与看门狗机制

3.1 30 秒的锁,45 秒的业务:超时引发的连锁问题

我见过一个真实案例,锁的超时时间设在 30 秒,但某个接口因为上游服务偶发变慢,单次调用耗时飙到 45 秒左右。表面现象是:多个请求同时通过了加锁判断,进入同一个资源处理逻辑,产生重复数据。

原因链条很清楚:A 线程拿到锁后执行到第 30 秒,锁自动过期,Redis 里这个 key 被删除;B 线程看到 key 不存在,成功加锁并进入业务逻辑。两个线程在同一个时间段内同时操作了同一份数据,分布式锁变成了一次性的"限流标记"。

这种问题的难点在于它不是每次必现,只有当业务耗时超过锁超时时间的那一刻才会触发。压测时如果只用正常耗时跑,完全发现不了。真等线上出现症状,往往已经造成了重复消息、重复下单或者数据不一致。

3.2 自己写定时续期:看起来简单,坑也不少

最简单的续期思路是拿到锁之后,启动一个定时任务,每隔一段时间执行一次 expire 续期,业务执行完再取消定时任务。大致逻辑是这样:

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() -> { if (requestId.equals(jedis.get(lockKey))) { jedis.expire(lockKey, 30, TimeUnit.SECONDS); } }, 10, 10, TimeUnit.SECONDS);

这个办法能工作,但工程上很别扭。业务的临界区逻辑和续期逻辑变成了两个独立的线程,它们之间没有任何协调。如果业务线程在临界区里做数据库操作时卡住了,续期线程可能还在放心地延长锁,这把锁实际上变成了"无期锁";反过来,如果业务执行结束后,续期线程刚好正在执行上一次续期命令,锁又被延长了,其他线程只能多等一个周期。

另外,这个定时任务本身也有生命周期问题:任务没有被正确关闭会造成线程泄漏;任务被错误取消后又会导致锁提前过期。自己实现续期,等于亲手引入了一系列并发和线程安全问题。这也是为什么大多数项目最终都会选择成熟的锁库,而不是自己造轮子。

3.3 Redisson 看门狗是怎么做的

很多项目选择 Redisson 来彻底解决续期问题。它的核心机制叫 Watch Dog(看门狗)。当使用lock.lock()且不显式指定 leaseTime 时,Redisson 默认给锁设置的过期时间是 30 秒,同时会在当前线程上启动一个后台任务,每 10 秒执行一次续期逻辑,把锁的有效期重新刷新成 30 秒。

这里有两个数字值得记住:30 秒是默认的锁租期,10 秒是续期间隔,正好是租期的三分之一。为什么选这个比例?因为即便某次续期操作因为网络波动失败了,锁也不会立刻过期,还有接近 20 秒的缓冲期让下一次续期补救。

看门狗解决的核心矛盾是:业务执行时间不可控,但锁又不能无限期存在。只要持有锁的进程还活着,后台任务就会持续续期;一旦进程崩溃,看门狗线程也就停了,锁最迟 30 秒后自动释放。这样既不会出现"业务没跑完锁被释放",也不会出现"进程死掉锁永远不释放"。

需要特别注意:只有在你没有显式指定锁租期时,看门狗才会生效。如果你写的是lock.lock(5, TimeUnit.SECONDS),Redisson 就认为你明确指定了租期为 5 秒,到期后锁直接释放,看门狗不会去续期。很多生产事故恰恰来自这里:开发者以为自己用了 Redisson 的看门狗,实际上一指定 leaseTime,看门狗就静默失效了。

3.4 看门狗也解决不了的 GC 停顿

看门狗不是万能的。最典型的反例是 Full GC。假设 JVM 在持锁期间发生了一次长时间 GC 停顿,整个应用进程的线程都会暂停执行,包括看门狗线程。停顿 15 秒甚至更久之后,锁已经到期并释放,另一个实例上的请求抢到了锁。等到 GC 恢复,原本的业务线程继续执行,还是会在临界区里操作同一份数据。

这个问题本质上是任何带过期时间的分布式锁都无法完全消除的,因为"进程是否存活"和"进程是否在正常执行业务"两件事,无法通过 Redis 里的一个 key 来准确表达。真正有效的应对方式是尽量缩短锁内代码的执行时间,不在锁内调用外部慢接口、不做大批量重试,从源头降低"锁持有时间远超预期"的概率。

4. 主从复制造成的锁丢失与 RedLock 争议

4.1 主从异步复制让锁凭空消失

单机 Redis 加锁,如果这个节点宕机,整个服务就不可用了。为了高可用,通常会部署主从结构,由主节点负责写,从节点同步数据,主节点出问题后自动提升从节点。

问题恰恰出在同步机制上。Redis 的主从复制是异步的,主节点上写入一条锁记录后,需要把这条写命令传播给从节点,但这个过程不是原子完成的。如果锁记录还没同步到从节点,主节点就宕机了,哨兵会把从节点提升为新的主节点,但新主节点里并没有这条锁记录。

这时另一个客户端尝试加同一把锁,会发现对应的 key 不存在,加锁成功。两个客户端都认为自己持有了锁,前面 Redis 提供的互斥性就被打破了。这不是理论假设,主从切换期间确实可能发生,而且很难通过日志发现。

4.2 RedLock 的过半策略是怎么运作的

针对单点故障和主从切换导致的锁丢失,社区最著名的方案是 RedLock。它的设计思路不是依赖单一 Redis 节点,而是让客户端同时在多个相互独立的 Redis 节点上尝试加锁,只有超过半数的节点加锁成功,才算真正获得锁。

实现上通常至少准备 5 个独立部署的 Redis 节点(不复制、不共享数据)。加锁流程是这样的:客户端携带同一个随机 value,按顺序向每个节点执行 SET NX PX,记录每个节点返回的时间;统计成功节点数,同时统计这次加锁的总耗时。只有同时满足"成功节点数大于总节点数的一半"和"总耗时小于锁的租期"这两个条件时,加锁才算成功。加锁失败或总耗时超限时,需要向所有已经成功的节点发送 Lua 脚本释放锁,清理残余记录。

为什么要求总耗时小于租期?这是为了防止"拿锁花的时间比锁本身的有效期还长"这种尴尬情况。如果加锁一共花了 5 秒,而锁租期只有 3 秒,那这把锁刚拿到就已经过期了,没有任何互斥意义。

4.3 RedLock 的争议:理论不完美,但工程上需谨慎

RedLock 从提出开始就一直伴随争议。支持方说它解决了主从切换下的锁丢失:只要一半以上的节点还活着,锁就还在,不会因为某个单点故障而失效。

反方观点主要有三个:第一,分布式系统领域的专家专门分析过,如果客户端在加锁完成后发生长时间 GC 暂停,锁在暂停期间过期,其他客户端仍然可能拿到锁,RedLock 无法规避这个问题;第二,多节点部署成本高,而且每次加锁要访问多个节点,延迟明显高于单节点;第三,也是最尖锐的一点,RedLock 并没有从数学上严格证明互斥性,在极端场景下它提供的仍是一种"尽力而为"的保障。

我个人在实际项目中的选择是:普通内部系统,主从 Redis 加 SET NX PX 加看门狗已经足够;对一致性要求极高的资金类、强校验类场景,优先考虑用数据库行锁或者事务替代,而不是盲目引入 RedLock。因为 RedLock 增加了复杂度,却没有消灭所有极端风险。把"主从锁丢失"的风险直接靠数据库唯一约束兜底,往往比在 Redis 层做更多努力更可靠。

5. 锁粒度与性能:别把所有请求都堵在一把锁上

5.1 一把大锁堵死后端的现实场景

分布式锁实现正确之后,下一个常踩的坑是性能。锁的互斥性天然意味着同一时刻只能有一个线程进入临界区,如果所有请求都去抢同一把锁,整体吞吐量就会被锁的粒度和临界区耗时限制死。

最典型的是热点商品的库存扣减。所有用户都在抢一个商品,如果锁 key 直接写成 "lock:stock:1001",那么所有扣减请求都串行排队。哪怕 Redis 本身能支撑每秒几万次操作,业务接口也会被这把锁压到每秒几百甚至几十的吞吐。等到秒杀结束,Redis 负载看起来不高,但接口 RT 一直在涨,锁就是瓶颈本身。

5.2 分段锁:把一把大锁拆成多把小锁

应对这种热点锁竞争,常用的手段是分段锁。思路很简单:不要把整份共享资源看作一个整体,而是拆成多个段,每段一把锁。

以库存为例,总库存 1000,拆成 10 个段,每段代表 100 个库存。加锁时不锁整个商品,而是先根据请求参数或者随机数决定操作第几个段,例如取模 10 得到段号,然后只锁定对应段落的 key。代码框架大概是:

int segment = ThreadLocalRandom.current().nextInt(10); String lockKey = "lock:stock:1001:segment:" + segment; boolean locked = tryLock(lockKey, requestId, 3000); if (locked) { try { int stock = getSegmentStock(segment); if (stock > 0) { decreaseSegmentStock(segment, 1); } } finally { unlock(lockKey, requestId); } }

这样一来,10 个段可以并行扣减,理论上的并发能力提升接近一个数量级。代价是代码复杂了不少:单个段库存不足时,要循环尝试其他还有余量的段;每个段还需要独立的库存字段,对账逻辑也要相应调整。分段数也不是越大越好,段太多会增加库存分布的碎片化,反而可能造成"总库存还有,但每个段都空"的假象。

5.3 锁内代码越短越好,这算一条铁律

不管用哪种分布式锁,有一条原则应该刻在脑子里:锁内代码要尽量短。锁的互斥范围只包含真正需要串行执行的临界操作,不要把无关业务都塞进去。

我最常看到的反例是,在持有锁的时间里调用远程服务、发送消息、做批量数据组装。这样一来,锁的持有时间等于整段业务逻辑的耗时,锁的竞争就会变得异常激烈,甚至超过锁本身的保护需求。正确做法是把锁内代码压缩到几个关键操作,外部调用移到锁外。如果外部调用必须在锁内完成,那就需要认真考虑这个外部调用的超时时间,并做好降级,避免一个慢接口拖垮整个锁的释放节奏。

6. 选型建议与故障排查清单:上线前再检查一遍

6.1 几种常见方案的对比

到了真正要落地的时候,选型往往比实现更让人纠结。我把遇到的几种方案放在一个表里,方便对照:

方案实现成本可靠性性能典型场景
单机 Redis:SET NX PX + Lua 解锁低一般,节点宕机即失效高内部系统、低并发保护
Redisson 看门狗锁中较好,能解决续期问题高大部分业务系统的默认选择
RedLock 多节点加锁高理论上更稳,但仍非绝对中对锁丢失容忍度极低的场景
数据库行锁 / 唯一约束中高低强一致性兜底、资金类操作

这个表格不是评分标准,更多是帮你明确"性价比"。选型前先问自己两个问题:业务能不能接受偶尔的锁失效?如果锁失效,造成的损失是什么级别?如果是超卖几件库存、产生一条重复消息,可以用 Redis 锁;如果涉及资金流向,建议在 Redis 锁之外再加一道数据库层兜底。

6.2 高频故障现象与排查路径

我已经记不清多少次在群里被人问"分布式锁到底哪里出问题了"。这里把高频故障和排查路径整理成了一份可用清单:

锁 key 一直存在但不释放。优先检查持锁进程是不是发生了长时间阻塞,再看解锁 Lua 脚本是否真的执行到了,最后检查是不是手动设置了 leaseTime 导致看门狗没有生效。

偶发出现两个请求同时拿到锁。先看锁的 value 校验和解锁脚本是否完整,再看 Redis 是否发生过主从切换。如果主从切换时间点与事故时间吻合,基本可以断定是锁丢失。

业务接口 RT 飙高但 Redis 负载很低。大概率是锁竞争太激烈,要么锁粒度太粗,要么锁内代码执行时间太长。此时可以上线一个埋点,统计每次抢锁等待时间和锁持有时间,哪项异常就优化哪项。

6.3 压测时需要盯住的三个指标

最后说几个我在压测时固定会看的指标。第一个是加锁等待时间,也就是从发起加锁到成功拿到锁的间隔。如果这个值随并发上升呈线性增长,说明锁竞争已经影响了接口耗时。第二个是锁持有时间,正常业务的锁持有时间应该远小于锁租期,一旦接近甚至超过租期,就要考虑是不是锁内代码过慢。第三个是单位时间内成功加锁的次数,这个值能直观反映锁的吞吐上限。

这三个指标可以通过在锁封装里加一行耗时记录来采集,不需要引入额外监控系统。等积累几轮压测数据后,再回头调过期时间、看门狗开关、分段数,判断会客观很多。

最后分享一个我踩了几次坑之后养成的习惯:所有使用分布式锁的代码,我都会在注释里写明三样东西——锁保护的是什么资源、锁内最多执行哪些操作、锁的过期时间依据是什么。下一次有人接手这段代码,不需要翻文档就能知道这把锁的边界在哪。分布式锁本身并不复杂,复杂的是使用边界;把边界划清楚,锁才真正可靠。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进