分布式锁基础入门:单机锁为什么在集群失效?三大实现方案总览
一、前言很多 Java 后端同学在学习并发的时候最先接触的锁都是 JVM 层面锁synchronized、ReentrantLock。在单体应用时代这些锁足够解决绝大多数并发竞争问题。 但是现在企业项目几乎都是微服务、集群多实例部署。同一个服务在 3 台、5 台服务器上同时启动多份每一份都拥有独立的 JVMJVM 之间内存隔离互不感知。 这个时候本地锁的短板就暴露出来了synchronized 只能锁住当前 JVM 内部的线程其他服务器上的线程不受任何限制。举一个互联网最经典的超卖场景 商品 A 库存剩余 1 件服务集群部署 2 台实例。用户发起两个下单请求负载均衡把请求分发到 Server1、Server2 两台机器。Server1 线程读取库存 stock 1Server2 线程读取库存 stock 1Server1 执行扣减stock 更新为 0Server2 执行扣减stock 更新为 0最终结果库存变成 0但是生成了 2 笔订单也就是超卖。 如果我们只在代码里加上synchronized只能保证单台机器内部不会并发扣减跨机器的并发冲突完全拦不住。 想要跨进程、跨服务器保证同一时刻只有一个线程操作共享资源就必须引入分布式锁。分布式锁定义在分布式集群环境下借助一个外部独立的共享存储组件实现跨节点互斥保证同一时间只有一个客户端持有锁操作临界资源。一个核心认知分布式锁和本地锁本质不一样。本地锁是 JVM 内部机制稳定可靠分布式锁依赖网络和第三方中间件天然存在网络延迟、丢包、节点宕机、时钟不一致等各类异常不存在绝对完美的锁所有方案都是在 CAP 之间做取舍。二、分布式锁必须满足的核心特性重中之重网上很多总结只写 4 点这里我把基础必选特性 扩展增强特性分开整理面试可以逐层递进回答拉开差距。基础必选特性缺一不可1. 互斥性最核心任意时刻同一个锁资源只能有一个客户端成功获取锁。这是锁的本职工作。误区很多新手以为 Redis 加锁成功就一定互斥但是如果主从切换、数据没同步就会出现锁失效破坏互斥性。怎么理解这个误区呢这个现象根源在于Redis主从架构的异步复制机制它决定了“加锁成功”这个确认只在主节点的语境下成立并不代表锁的状态已经获得了集群级别的共识。根源在于异步复制与“提前的”成功确认在Redis主从架构中主节点处理写命令如加锁的SET resource value NX PX ...时遵循的是AP原则可用性优先。它的行为是先在主节点内存中执行命令并立即向客户端返回“成功”之后才异步地将命令传播给从节点。这意味着从客户端收到“加锁成功”的那一刻到锁数据真正抵达从节点之间存在一个时间窗口。在这个窗口内锁状态仅存在于主节点从节点对它一无所知。那失效是如何发生的一个典型的竞态时间线当上述时间窗口与主节点故障重叠时互斥性就被破坏了。过程如下1. 客户端 A 加锁A 向主节点请求获取锁lock:resource主节点写入成功并立即回复 A “加锁成功”。A 开始执行受锁保护的业务逻辑。2. 主节点故障就在主节点将lock:resource这个键异步复制到从节点之前主节点宕机了。3. 从节点晋升哨兵Sentinel机制检测到主节点故障将一个从未收到过锁lock:resource的从节点提升为新的主节点。4. 客户端 B 加锁此时客户端 B 向新的主节点请求获取同一把锁lock:resource。由于新主节点上根本没有这个键B 的加锁请求会成功。此刻客户端 A 和客户端 B 都认为自己持有锁互斥性被彻底打破。A 的锁在旧主节点上随着旧主节点失联而“丢失”了而新主节点对 A 的锁一无所知自然就会批准 B 的请求。这是架构权衡的结果而非单纯的Bug这个问题的本质是Redis在主从架构下选择了 AP可用性 分区容忍性而没有选择 CP一致性 分区容忍性。为了保证高可用和低延迟它无法像 ZooKeeperCP系统那样在向客户端确认前先等待锁数据在多数节点上完成同步。所以异步复制带来的锁丢失风险是这种设计取向下的固有代价。如何应对这一层面的风险要解决这个问题需要让“锁的成立”不再依赖于单个节点的本地状态。1、Redlock红锁算法其核心思想是放弃主从复制改用多个通常5个完全独立的Redis主节点。客户端必须在超过半数N/21的独立节点上都加锁成功才算真正持有锁。这样即使个别节点故障只要多数节点存活锁的状态就不会凭空消失从而避免主从切换导致的锁丢失。2、Fencing Token栅栏令牌这是一种更根本的兜底思路。既然锁本身可能因故障切换而不可靠就由被保护的资源来做最终防线。每次成功加锁时让Redis生成一个单调递增的令牌例如通过INCR客户端携带该令牌访问资源。资源端会记录已处理过的最大令牌并拒绝任何令牌小于等于该值的请求。这样即使客户端 A 的锁“丢失”了当它带着旧令牌去操作时也会被资源端挡下从而在最终效果上守住互斥性。总的来说“Redis加锁成功”是一个局部性的确认它只保证在执行加锁命令的那个节点上此刻不存在同名锁。一旦这个节点消失且数据未能同步这个确认就失去了全局效力互斥性也随之瓦解。2. 避免死锁锁自动释放能力客户端拿到锁之后还没执行完业务代码直接宕机、进程崩溃、网络断开。 如果锁永远保留其他所有客户端永远拿不到锁业务直接卡死也就是死锁。这里说的“死锁”不是 Redis 内部线程互相等待而是业务层面的“锁泄漏”——锁的持有者已经不可能再释放锁而其他客户端又必须拿到这把锁才能继续于是业务被永久卡住。设想一个场景客户端 A 执行SET lock:order NX PX 30000假设这里没有设置过期时间或者过期时间极长A 加锁成功后开始执行业务。此时发生A 所在机器宕机或 A 进程崩溃或 A 与 Redis 网络断开且一直恢复不了。A 已经无法执行“释放锁”的逻辑DEL lock:order但 Redis 里的lock:order还在。其他客户端 B、C 再来加锁SET lock:order NX PX 30000因为键已存在NX失败B、C 一直拿不到锁。如果业务必须持有这把锁才能推进那么这部分业务就永久卡死。这就是业务级死锁。解决思路Redis给锁设置过期 TTLRedisson 额外提供看门狗自动续期ZK/Etcd基于会话机制客户端会话断开临时节点自动删除MySQL依靠事务超时、连接断开自动回滚释放锁3. 锁归属校验只能持有者释放锁经典坑锁超时自动释放之后B 线程拿到锁此时 A 线程业务执行完毕直接执行解锁把 B 线程的锁删掉。也就是误删锁。可以这样理解误删锁的根源是“解锁”这个动作只认“锁的键”不认“锁的主人”。A 的锁虽然已经过期但 A 自己并不知道它仍然按原计划执行DEL lock:order而这个命令会无差别地删掉当前存在于这个键上的任何锁——包括 B 刚刚加上的那把。解决加锁的时候存入唯一标识UUID / 客户端 ID 线程 ID解锁前先判断标识匹配匹配成功才允许删除锁。这个坑非常高频手写 Redis 锁的时候 90% 的人第一版都会踩。4. 高可用提供锁服务的中间件不能是单点。如果锁服务挂了整个依赖分布式锁的业务全部瘫痪。Redis主从、哨兵、集群模式提升可用性ZK本身就是集群过半节点可用就能正常工作MySQL主从架构但是主库挂掉切换有成本增强可选特性工业级锁会实现可重入性同一个客户端同一个线程可以多次获取同一把锁不会自己把自己锁住。ReentrantLock 天然可重入原生SETNX不可重入Redisson、ZK 锁支持可重入。适用场景方法嵌套调用外层加锁内层还要加同一把锁。阻塞 / 非阻塞获取非阻塞抢锁失败直接返回 false业务自行重试或者放弃阻塞抢不到锁等待一段时间持续尝试获取类似 ReentrantLock 的 lock ()读写锁分离读共享、写互斥。大量读请求可以并发写请求互斥提升吞吐量。Redisson、ZK 都支持读写锁。公平锁 / 非公平锁公平锁按照请求顺序排队拿锁非公平锁抢占式拿锁性能更高。锁超时续期业务执行时间不确定锁快要到期但是业务还没跑完自动延长锁时间Redisson 看门狗。锁可中断等待锁的过程中可以主动中断等待避免无限阻塞。三、synchronized 和 ReentrantLock 原理与局限很多面试官喜欢连环问synchronized 底层是什么为什么集群环境失效1. synchronized底层对象头 Mark Word Monitor 监视器锁。对象头 Mark Word每个 Java 对象在堆里都有一块头部区域其中 Mark Word 用来存哈希码、GC 分代年龄以及锁状态标记无锁、偏向、轻量级、重量级和指向锁记录的指针。Monitor可以理解为 JVM 在底层HotSpot 中是 C 的ObjectMonitor维护的一个监视器里面有 owner、entry list、wait set 等结构真正负责线程的阻塞与唤醒。所以“给对象加锁”本质是修改这个对象 Mark Word 里的锁标记并关联到对应的 Monitor。锁信息跟着对象走对象在哪个 JVM 堆里锁就在哪个 JVM 里。锁的升级偏向锁 → 轻量级锁 → 重量级锁。这是 JVM 为了减少无竞争和轻竞争场景下的开销做的优化不是三种不同的锁 API而是同一把锁在不同竞争程度下的状态演化偏向锁只有一个线程反复进入同步块时Mark Word 直接记录这个线程 ID后续该线程进入不需要任何 CAS相当于“认人”。注意JDK 15 后偏向锁已默认禁用并逐步移除但概念仍常被提及。轻量级锁出现另一个线程竞争时偏向锁撤销升级为轻量级锁。线程通过 CAS 把 Mark Word 指向自己栈帧里的 Lock Record竞争失败就自旋重试避免直接陷入内核阻塞。重量级锁自旋也拿不到锁时升级为重量级锁未抢到的线程进入 Monitor 的等待队列由操作系统挂起涉及用户态/内核态切换开销最大。升级方向是单向的一般不会降级目的是在低竞争时尽量少用系统调用高竞争时才真正阻塞。生效范围同一个 JVM 进程内多个线程竞争同一个对象监视器。因为锁状态写在当前 JVM 堆中的对象头里Monitor 也是当前 JVM 进程内的 C 对象。所以synchronized(obj)能互斥的前提是多个线程访问的是同一个 JVM 里的同一个对象实例。两个不同的 JVM 进程各自堆里各有一份“同名对象”它们的 Mark Word 互不相干Monitor 也互不相干。A 进程锁住了自己堆里的objB 进程根本看不到这个obj自然不会阻塞。局限锁信息保存在当前 JVM 的对象头里其他 JVM 看不到这个对象自然无法感知锁状态不可手动释放代码执行完成或者异常自动释放不支持超时、非阻塞。核心就一句话它的锁状态只存在于当前 JVM 的堆内存里出了这个进程没人知道这把锁的存在。synchronized是一把“长在对象头上”的进程内锁它的所有优点自动释放、JVM 保证和所有局限跨不了进程、不能超时都源于这个设计定位。单机多线程用它没问题一旦需要跨 JVM 互斥就必须把锁状态外移到所有进程都能访问的共享存储上这就是分布式锁要解决的问题。2. ReentrantLock底层AQSAbstractQueuedSynchronizer双向队列 CAS。ReentrantLock内部持有一个Sync对象Sync继承自AQSAbstractQueuedSynchronizer。AQS 的核心就三样东西一个volatile int state表示锁的持有状态。state 0表示无人持有state 0表示已被持有数值就是重入次数。所有对state的修改都通过CAS完成保证原子性。一个 CLH 变体的双向队列抢锁失败的线程被包装成 Node尾插进这个队列然后LockSupport.park()挂起锁释放时从队头唤醒后继节点。双向队列是为了方便取消节点、处理超时和中断时从队列中摘除。CAStryAcquire里用compareAndSetState(0, 1)抢锁抢不到就走入队逻辑入队本身也用 CAS 保证尾指针安全。所以加锁的本质是CAS 把state从 0 改成 1成功即持锁失败则入队阻塞。锁的状态就是一个 JVM 堆里的int字段。生效范围同一个 JVM 内。和synchronized同理state是ReentrantLock对象的一个字段对象在哪个 JVM 的堆里锁就在哪个 JVM 里。AQS 队列也是当前 JVM 内的 Java 对象链表LockSupport.park/unpark也只能作用于本进程内的线程。两个 JVM 各自 new 一个ReentrantLock各自有各自的state和队列互不可见。A 进程把 state CAS 成 1B 进程的 state 还是 0B 的线程照样能抢到锁。跨进程互斥ReentrantLock做不到。优势可重入、支持公平 / 非公平、支持超时获取、支持中断。局限锁状态 state 变量存在当前 JVM 内存集群多实例下完全无效。ReentrantLock把锁从对象头搬到了堆里的一个 int 字段用 CAS 抢、用 AQS 队列排队因此能显式地插入公平判断、超时和中断逻辑能力比synchronized强但state和队列终究只存在于当前 JVM 的内存里跨进程、跨实例依然完全无效。要在集群里互斥必须把锁状态外移到所有实例都能访问的共享存储——这就是 Redis / ZooKeeper 分布式锁的立足点。3.和 synchronized 对照维度synchronizedReentrantLock锁状态位置对象头 Mark Word MonitorAQS 的volatile int state等待管理Monitor 内部队列JVM 黑盒AQS 双向队列Java 代码可见可重入支持支持state 计数公平性只有非公平可选公平/非公平超时不支持tryLock(timeout)中断不可中断lockInterruptibly()手动释放不需要自动必须finally里unlock()生效范围单 JVM单 JVM跨进程无效无效注意ReentrantLock多了一条新风险必须手动释放。忘了在finally里unlock()state 永远不归零队列里的线程永久挂起就是进程内死锁。这是它换来灵活性的代价。本地锁 VS 分布式锁对比表锁类型底层依赖作用范围代表实现优点缺点synchronizedMarkWord、Monitor单 JVM 内Java 内置使用简单、无需手动释放集群失效、功能弱ReentrantLockAQS CAS单 JVM 内JUC灵活、可重入、支持超时集群失效MySQL 分布式锁数据库行锁 / 表锁、版本字段跨进程跨机器select for update、乐观锁无需额外中间件性能差数据库压力大Redis 分布式锁Redis 字符串、Lua 脚本跨进程跨机器SETNX、Redisson性能高QPS 支撑强存在主从异步复制锁丢失风险ZK/Etcd 分布式锁临时有序节点、Raft 协议跨进程跨机器Curator InterProcessLock一致性强无时钟问题性能低于 Redis维护成本高四、分布式锁三大主流方案深度拆解方案 1基于 MySQL 实现分布式锁细分 3 种实现方式悲观锁select ... for update、乐观锁version 版本号、唯一索引锁。1. 悲观锁SELECT ... FOR UPDATE底层InnoDB 的行锁。FOR UPDATE是当前读会对扫描到的索引记录加排他锁X 锁。锁加在索引记录上锁结构由 InnoDB 在服务端内存里维护。生效范围所有连到同一个 MySQL 主库的事务。A 实例的事务锁住id1这行B 实例的事务再来FOR UPDATE同一行就会阻塞——不管 A、B 在哪个 JVM、哪台机器。关键细节也是坑必须在事务里。行锁的生命周期绑定事务COMMIT/ROLLBACK才释放。如果autocommit1且没显式开事务FOR UPDATE语句一结束锁就释放了等于没锁。必须走索引。如果WHERE条件没命中索引InnoDB 会扫描并锁住大量行甚至退化成近似表锁。这是线上最常见的性能事故来源。只阻塞写和当前读。普通SELECT是 MVCC 快照读不加锁、不阻塞只有FOR UPDATE、UPDATE、DELETE这类当前读才会被阻塞。有锁等待超时innodb_lock_wait_timeout默认 50 秒超时抛错。有死锁风险两个事务交叉加锁顺序不同InnoDB 会检测死锁并回滚其中一个。2. 乐观锁版本号 CAS底层不加显式锁靠一条UPDATE语句的WHERE version oldVersion做 CAS。UPDATE本身会加行锁但那是语句级的语句执行完autocommit 下立即提交就释放不会跨事务持有。生效范围同样跨进程因为版本号存在共享的数据库行里。核心逻辑UPDATE stock SET num num - 1, version version 1 WHERE id xxx AND version oldVersion;影响行数为 1 → 更新成功为 0 → 版本不匹配或记录不存在说明期间被别人改过业务层决定重试还是失败。一个需要澄清的细节例子里num num - 1是相对更新它在数据库里是原子减法本身就不会丢失更新。版本号真正防的是另一类场景业务先SELECT读出num10在应用层做完判断比如“够扣才扣”再UPDATE ... SET num 9绝对赋值——如果没有版本号这期间别人把 num 改成 5你的SET num 9就会把别人的修改覆盖掉。版本号的作用是检测“我读到的快照是否已经过期”让基于旧快照的决策作废。优点无阻塞读多写少时吞吐高不占长事务和连接单条UPDATE基本无死锁。缺点冲突高时大量更新失败、大量重试重试本身又加重冲突业务层必须处理失败路径影响行数为 0 时要注意区分“版本冲突”和“记录不存在”。3. 唯一索引锁底层唯一索引约束。插入一条带唯一业务标识的记录成功 抢锁成功触发Duplicate entry唯一冲突 锁已被占用删除这条记录 释放锁。生效范围跨进程因为锁记录持久化在共享的表里。这是三种方式里最像Redis SETNX的一种——锁状态是一条真实的数据行而不是事务期间的内存锁结构。和悲观锁的关键差异INSERT在 autocommit 下提交后数据库连接和事务都释放了但锁记录还在表里。所以它不占长事务、不占连接适合临界区可能较长的场景。代价是持有者崩溃后记录不会自动消失。没有事务回滚来兜底锁就永久残留其他客户端永远插不进去——这就是数据库版的“业务死锁”。所以必须配TTL 字段 定时清理或插入时判断过期思路和 Redis 锁的PX完全一致。并发下DELETE再INSERT同一唯一键可能涉及间隙锁和竞态实现时要小心标准做法是直接INSERT并捕获唯一冲突异常而不是“先查再插”。4. 三者对比维度悲观锁FOR UPDATE乐观锁版本号唯一索引锁锁状态形式InnoDB 行锁内存锁结构无显式锁版本字段表里的唯一记录持锁时长整个事务仅UPDATE语句瞬间从INSERT到DELETE是否占数据库连接事务期间一直占短短冲突时行为阻塞等待有超时更新失败业务重试插入立即报唯一冲突崩溃后事务回滚自动释放无锁可释放记录残留需 TTL 清理死锁风险有交叉加锁基本无有并发删插的间隙锁适用场景冲突多、临界区短、强一致读多写少、冲突低、可重试需要不占长事务、能接受清理机制优点项目本身就有 MySQL不需要额外部署中间件上手成本最低。缺点MySQL 行锁粒度大并发高的时候大量阻塞吞吐量很低悲观锁依赖事务长事务会长期占用行锁容易引发数据库死锁数据库本身容易成为单点瓶颈主从切换场景下如果读从库判断锁状态会有延迟。 适合场景并发量低、内部后台系统、任务防重复执行不适合高并发秒杀、订单扣减场景。方案 2基于 Redis 实现分布式锁企业最常用演进路线SETNX 简单加锁 → SET NX EX 原子命令 → Lua 脚本解锁校验 UUID → 看门狗续期 → Redisson 完整封装 → Redlock 红锁。原理简述利用 Redis 单命令原子性抢锁就是往 Redis 写入一个特定 keykey 存在代表锁被占用key 不存在代表可以加锁。加锁SET lock_key unique_id NX EX 30解锁Lua 脚本先判断 value 等于当前客户端唯一 ID再删除 key保证判断 删除原子性。优点Redis 内存数据库读写性能极强能够支撑很高 QPSAPI 简单生态成熟Redisson 封装完善。缺点Redis 主从同步是异步的主节点拿到锁还没同步给从节点就宕机新主节点没有锁记录出现两个客户端同时拿到锁锁失效依赖 TTL 超时机制存在时钟漂移风险Redlock 红锁算法争议很大CAP 理论下很难落地。适合场景绝大多数互联网业务库存扣减、定时任务防重复、幂等控制、接口防重是面试最高频的方案。方案 3ZooKeeper / Etcd 分布式锁ZK 原理ZK 的节点类型分为持久节点、临时节点、有序节点。分布式锁实现思路创建临时有序节点最小序号节点获得锁其他节点监听前一个节点释放锁之后通知下一个节点竞争。 客户端会话断开临时节点自动删除天然解决死锁问题不需要设置过期时间也就不存在时钟漂移带来的锁提前失效问题。Etcd 原理基于 Raft 一致性算法强一致性自带租约Lease机制和 ZK 思路接近云原生场景用得多。优点一致性强天然支持可重入没有 TTL 误释放的经典坑缺点性能弱于 RedisZK 集群部署运维复杂网络分区时 ZK 会进入不可用状态CP。适合场景一致性要求极高的场景分布式选主、核心资金链路、分布式任务调度不适合超高并发场景。五、分布式锁的常见误区误区 1一遇到并发就上分布式锁分布式锁本质是串行化会把并发请求排队直接压低系统吞吐量。 优先思考能不能不用锁幂等方案唯一流水号、订单号数据库唯一约束拒绝重复请求队列削峰高并发秒杀用消息队列排队异步处理而不是分布式锁本地限流、分段锁、库存预扣减方案替代锁锁是兜底手段不是首选方案。误区 2Redis 锁设置过期时间就万事大吉过期时间只能解决宕机死锁但是会带来新问题业务执行时间超过锁过期时间锁提前释放并发问题重现。这就是看门狗要解决的场景。误区 3解锁直接用 DEL 命令DEL lock_key是致命错误没有校验锁持有者 ID会出现 A 释放 B 的锁也就是误删锁。解锁必须 Lua 脚本原子校验 删除。误区 4Redlock 是工业级标准方案Redlock 是 Redis 作者提出但是分布式领域很多大佬质疑它时钟漂移问题很难规避绝大多数公司生产环境并不会使用 Redlock主从 Redisson 普通锁足够。误区 5ZK 锁完全没有缺点ZK 强一致的代价是可用性网络分区过半节点失联ZK 直接拒绝所有请求并且 Watcher 监听机制如果大量抢锁容易出现惊群效应。六、CAP 理论和分布式锁选型关系CAP一致性 C、可用性 A、分区容错 P分布式系统 P 无法避免只能在 C 和 A 之间选择。Redis 锁偏向 AP优先可用性牺牲强一致性主从异步复制存在锁丢失风险适合绝大多数业务ZK/Etcd 锁偏向 CP优先一致性网络分区时牺牲可用性适合一致性敏感场景MySQL 锁偏向 CP事务机制保证一致性但是性能差可用性依赖数据库主从。面试回答选型的时候带上 CAP面试官会觉得理解深度远超普通候选人。七、面试自测思考题带思考方向分布式锁需要具备哪些特性基础 4 点 扩展特性synchronized 为什么不能作为分布式锁底层原理是什么Redis 分布式锁和 ZK 分布式锁各自优缺点生产上怎么选型CAP 角度怎么理解解锁直接执行 DEL key 会有什么问题怎么解决为什么说分布式锁会降低系统吞吐量什么场景尽量避免使用分布式锁八、小结分布式锁是分布式面试里的重中之重很多人只会背一句 SETNX 加锁但是各种异常场景一问就卡壳。 分布式锁的难点从来不是一行加锁代码而是各种异常边界进程宕机、网络抖动、网络丢包、主从切换、时钟不一致、业务执行超时。 后面文章我们逐个方案落地手写可运行代码复现各类坑点把每一种异常场景的解决方案讲清楚。