资讯详情

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

📅 2026/10/24 1:28:53 | 华诺云谱 👁 阅读
从synchronized到Redis分布式锁:原理、演进与工程实践
1. 一场超卖事故逼出的分布式锁有一次我排查库存超卖问题时发现罪魁祸首不是 SQL而是 Java 里最基础的 synchronized。Redis 分布式锁就是在那次事故里被正式引入项目的也是今天这篇内容的主角。当时限时抢购刚开始一分钟订单量冲到每秒几百库存却从 100 直接跌到了 -3。单看代码逻辑完全没有问题扣减前判断 stock 0扣减时加了同步锁为什么还是会超卖问题出在服务部署了三个实例synchronized 只能锁住当前 JVM 内的线程。三台机器上的三个锁互相看不见负载均衡把请求一分散同一个时刻可能有两台实例同时对 stock1 的记录做扣减最后库存自然变成负数。这种场景下需要一把所有实例都能看到的锁。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 线程这时抢到锁并写入自己的 requestIdA 线程继续执行 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 几种常见方案的对比到了真正要落地的时候选型往往比实现更让人纠结。我把遇到的几种方案放在一个表里方便对照方案实现成本可靠性性能典型场景单机 RedisSET NX PX Lua 解锁低一般节点宕机即失效高内部系统、低并发保护Redisson 看门狗锁中较好能解决续期问题高大部分业务系统的默认选择RedLock 多节点加锁高理论上更稳但仍非绝对中对锁丢失容忍度极低的场景数据库行锁 / 唯一约束中高低强一致性兜底、资金类操作这个表格不是评分标准更多是帮你明确性价比。选型前先问自己两个问题业务能不能接受偶尔的锁失效如果锁失效造成的损失是什么级别如果是超卖几件库存、产生一条重复消息可以用 Redis 锁如果涉及资金流向建议在 Redis 锁之外再加一道数据库层兜底。6.2 高频故障现象与排查路径我已经记不清多少次在群里被人问分布式锁到底哪里出问题了。这里把高频故障和排查路径整理成了一份可用清单锁 key 一直存在但不释放。优先检查持锁进程是不是发生了长时间阻塞再看解锁 Lua 脚本是否真的执行到了最后检查是不是手动设置了 leaseTime 导致看门狗没有生效。偶发出现两个请求同时拿到锁。先看锁的 value 校验和解锁脚本是否完整再看 Redis 是否发生过主从切换。如果主从切换时间点与事故时间吻合基本可以断定是锁丢失。业务接口 RT 飙高但 Redis 负载很低。大概率是锁竞争太激烈要么锁粒度太粗要么锁内代码执行时间太长。此时可以上线一个埋点统计每次抢锁等待时间和锁持有时间哪项异常就优化哪项。6.3 压测时需要盯住的三个指标最后说几个我在压测时固定会看的指标。第一个是加锁等待时间也就是从发起加锁到成功拿到锁的间隔。如果这个值随并发上升呈线性增长说明锁竞争已经影响了接口耗时。第二个是锁持有时间正常业务的锁持有时间应该远小于锁租期一旦接近甚至超过租期就要考虑是不是锁内代码过慢。第三个是单位时间内成功加锁的次数这个值能直观反映锁的吞吐上限。这三个指标可以通过在锁封装里加一行耗时记录来采集不需要引入额外监控系统。等积累几轮压测数据后再回头调过期时间、看门狗开关、分段数判断会客观很多。最后分享一个我踩了几次坑之后养成的习惯所有使用分布式锁的代码我都会在注释里写明三样东西——锁保护的是什么资源、锁内最多执行哪些操作、锁的过期时间依据是什么。下一次有人接手这段代码不需要翻文档就能知道这把锁的边界在哪。分布式锁本身并不复杂复杂的是使用边界把边界划清楚锁才真正可靠。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑