ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

分布式锁基础入门:单机锁为什么在集群失效?三大实现方案总览

分布式锁基础入门:单机锁为什么在集群失效?三大实现方案总览

一、前言

很多 Java 后端同学在学习并发的时候,最先接触的锁都是 JVM 层面锁:synchronized、ReentrantLock。在单体应用时代,这些锁足够解决绝大多数并发竞争问题。 但是现在企业项目几乎都是微服务、集群多实例部署。同一个服务,在 3 台、5 台服务器上同时启动多份,每一份都拥有独立的 JVM,JVM 之间内存隔离,互不感知。 这个时候,本地锁的短板就暴露出来了:synchronized 只能锁住当前 JVM 内部的线程,其他服务器上的线程不受任何限制。

举一个互联网最经典的超卖场景: 商品 A 库存剩余 1 件,服务集群部署 2 台实例。用户发起两个下单请求,负载均衡把请求分发到 Server1、Server2 两台机器。

  1. Server1 线程读取库存 stock = 1
  2. Server2 线程读取库存 stock = 1
  3. Server1 执行扣减,stock 更新为 0
  4. Server2 执行扣减,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(一致性 + 分区容忍性)。为了保证高可用和低延迟,它无法像 ZooKeeper(CP系统)那样,在向客户端确认前先等待锁数据在多数节点上完成同步。所以,异步复制带来的锁丢失风险,是这种设计取向下的固有代价。

如何应对这一层面的风险

要解决这个问题,需要让“锁的成立”不再依赖于单个节点的本地状态。

1、Redlock(红锁)算法:其核心思想是放弃主从复制,改用多个(通常5个)完全独立的Redis主节点。客户端必须在超过半数(N/2+1)的独立节点上都加锁成功,才算真正持有锁。这样即使个别节点故障,只要多数节点存活,锁的状态就不会凭空消失,从而避免主从切换导致的锁丢失。
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:给锁设置过期 TTL;Redisson 额外提供看门狗自动续期
  • ZK/Etcd:基于会话机制,客户端会话断开,临时节点自动删除
  • MySQL:依靠事务超时、连接断开自动回滚释放锁
3. 锁归属校验:只能持有者释放锁

经典坑:锁超时自动释放之后,B 线程拿到锁;此时 A 线程业务执行完毕,直接执行解锁,把 B 线程的锁删掉。也就是误删锁。

可以这样理解:误删锁的根源是“解锁”这个动作只认“锁的键”,不认“锁的主人”。A 的锁虽然已经过期,但 A 自己并不知道,它仍然按原计划执行DEL lock:order,而这个命令会无差别地删掉当前存在于这个键上的任何锁——包括 B 刚刚加上的那把。

解决:加锁的时候存入唯一标识(UUID / 客户端 ID + 线程 ID),解锁前先判断标识匹配,匹配成功才允许删除锁。

这个坑非常高频,手写 Redis 锁的时候 90% 的人第一版都会踩。

4. 高可用

提供锁服务的中间件不能是单点。如果锁服务挂了,整个依赖分布式锁的业务全部瘫痪。

  • Redis:主从、哨兵、集群模式提升可用性
  • ZK:本身就是集群,过半节点可用就能正常工作
  • MySQL:主从架构,但是主库挂掉切换有成本

增强可选特性(工业级锁会实现)

  1. 可重入性:同一个客户端同一个线程,可以多次获取同一把锁,不会自己把自己锁住。
    • ReentrantLock 天然可重入;原生SETNX不可重入;Redisson、ZK 锁支持可重入。
    • 适用场景:方法嵌套调用,外层加锁,内层还要加同一把锁。
  2. 阻塞 / 非阻塞获取
    • 非阻塞:抢锁失败直接返回 false,业务自行重试或者放弃
    • 阻塞:抢不到锁,等待一段时间持续尝试获取(类似 ReentrantLock 的 lock ())
  3. 读写锁分离:读共享、写互斥。大量读请求可以并发,写请求互斥,提升吞吐量。Redisson、ZK 都支持读写锁。
  4. 公平锁 / 非公平锁:公平锁按照请求顺序排队拿锁;非公平锁抢占式拿锁,性能更高。
  5. 锁超时续期:业务执行时间不确定,锁快要到期但是业务还没跑完,自动延长锁时间(Redisson 看门狗)。
  6. 锁可中断:等待锁的过程中,可以主动中断等待,避免无限阻塞。

三、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 进程锁住了自己堆里的obj,B 进程根本看不到这个obj,自然不会阻塞。

局限:

  • 锁信息保存在当前 JVM 的对象头里,其他 JVM 看不到这个对象,自然无法感知锁状态;
  • 不可手动释放,代码执行完成或者异常自动释放;
  • 不支持超时、非阻塞。

核心就一句话:它的锁状态只存在于当前 JVM 的堆内存里,出了这个进程,没人知道这把锁的存在。

synchronized是一把“长在对象头上”的进程内锁,它的所有优点(自动释放、JVM 保证)和所有局限(跨不了进程、不能超时)都源于这个设计定位。单机多线程用它没问题;一旦需要跨 JVM 互斥,就必须把锁状态外移到所有进程都能访问的共享存储上,这就是分布式锁要解决的问题。

2. ReentrantLock

底层:AQS(AbstractQueuedSynchronizer)双向队列 + CAS。

ReentrantLock内部持有一个Sync对象,Sync继承自AQS(AbstractQueuedSynchronizer)。AQS 的核心就三样东西:

  • 一个volatile int state:表示锁的持有状态。state == 0表示无人持有;state > 0表示已被持有,数值就是重入次数。所有对state的修改都通过CAS完成,保证原子性。

  • 一个 CLH 变体的双向队列:抢锁失败的线程被包装成 Node,尾插进这个队列,然后LockSupport.park()挂起;锁释放时从队头唤醒后继节点。双向队列是为了方便取消节点、处理超时和中断时从队列中摘除。

  • CAS:tryAcquire里用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 成 1,B 进程的 state 还是 0,B 的线程照样能抢到"锁"。跨进程互斥,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 实例的事务锁住id=1这行,B 实例的事务再来FOR UPDATE同一行,就会阻塞——不管 A、B 在哪个 JVM、哪台机器。

关键细节,也是坑:

  • 必须在事务里。行锁的生命周期绑定事务,COMMIT/ROLLBACK才释放。如果autocommit=1且没显式开事务,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读出num=10,在应用层做完判断(比如“够扣才扣”),再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,不需要额外部署中间件,上手成本最低。

缺点:

  1. MySQL 行锁粒度大,并发高的时候大量阻塞,吞吐量很低;
  2. 悲观锁依赖事务,长事务会长期占用行锁,容易引发数据库死锁;
  3. 数据库本身容易成为单点瓶颈;
  4. 主从切换场景下,如果读从库判断锁状态会有延迟。 👉 适合场景:并发量低、内部后台系统、任务防重复执行;不适合高并发秒杀、订单扣减场景。

方案 2:基于 Redis 实现分布式锁(企业最常用)

演进路线:SETNX 简单加锁 → SET NX EX 原子命令 → Lua 脚本解锁(校验 UUID) → 看门狗续期 → Redisson 完整封装 → Redlock 红锁。

原理简述

利用 Redis 单命令原子性,抢锁就是往 Redis 写入一个特定 key;key 存在代表锁被占用,key 不存在代表可以加锁。

加锁:SET lock_key unique_id NX EX 30解锁:Lua 脚本,先判断 value 等于当前客户端唯一 ID,再删除 key,保证判断 + 删除原子性。

优点:Redis 内存数据库,读写性能极强,能够支撑很高 QPS;API 简单,生态成熟,Redisson 封装完善。

缺点:

  1. Redis 主从同步是异步的:主节点拿到锁,还没同步给从节点就宕机,新主节点没有锁记录,出现两个客户端同时拿到锁,锁失效;
  2. 依赖 TTL 超时机制,存在时钟漂移风险;
  3. Redlock 红锁算法争议很大,CAP 理论下很难落地。

适合场景:绝大多数互联网业务,库存扣减、定时任务防重复、幂等控制、接口防重,是面试最高频的方案。

方案 3:ZooKeeper / Etcd 分布式锁

ZK 原理

ZK 的节点类型分为持久节点、临时节点、有序节点。分布式锁实现思路:创建临时有序节点,最小序号节点获得锁;其他节点监听前一个节点,释放锁之后通知下一个节点竞争。 客户端会话断开,临时节点自动删除,天然解决死锁问题,不需要设置过期时间,也就不存在时钟漂移带来的锁提前失效问题。

Etcd 原理:基于 Raft 一致性算法,强一致性,自带租约(Lease)机制,和 ZK 思路接近,云原生场景用得多。

优点:一致性强,天然支持可重入,没有 TTL 误释放的经典坑;

缺点:性能弱于 Redis;ZK 集群部署运维复杂;网络分区时 ZK 会进入不可用状态(CP)。

适合场景:一致性要求极高的场景,分布式选主、核心资金链路、分布式任务调度;不适合超高并发场景。

五、分布式锁的常见误区

误区 1:一遇到并发就上分布式锁

分布式锁本质是串行化,会把并发请求排队,直接压低系统吞吐量。 优先思考:能不能不用锁?

  1. 幂等方案:唯一流水号、订单号,数据库唯一约束,拒绝重复请求;
  2. 队列削峰:高并发秒杀用消息队列排队异步处理,而不是分布式锁;
  3. 本地限流、分段锁、库存预扣减方案替代锁;

锁是兜底手段,不是首选方案。

误区 2:Redis 锁设置过期时间,就万事大吉

过期时间只能解决宕机死锁,但是会带来新问题:业务执行时间超过锁过期时间,锁提前释放,并发问题重现。这就是看门狗要解决的场景。

误区 3:解锁直接用 DEL 命令

DEL lock_key是致命错误!没有校验锁持有者 ID,会出现 A 释放 B 的锁,也就是误删锁。解锁必须 Lua 脚本原子校验 + 删除。

误区 4:Redlock 是工业级标准方案

Redlock 是 Redis 作者提出,但是分布式领域很多大佬质疑它,时钟漂移问题很难规避,绝大多数公司生产环境并不会使用 Redlock,主从 + Redisson 普通锁足够。

误区 5:ZK 锁完全没有缺点

ZK 强一致的代价是可用性,网络分区过半节点失联,ZK 直接拒绝所有请求;并且 Watcher 监听机制如果大量抢锁,容易出现惊群效应。

六、CAP 理论和分布式锁选型关系

CAP:一致性 C、可用性 A、分区容错 P,分布式系统 P 无法避免,只能在 C 和 A 之间选择。

  • Redis 锁:偏向 AP,优先可用性,牺牲强一致性(主从异步复制存在锁丢失风险);适合绝大多数业务;
  • ZK/Etcd 锁:偏向 CP,优先一致性,网络分区时牺牲可用性;适合一致性敏感场景;
  • MySQL 锁:偏向 CP,事务机制保证一致性,但是性能差,可用性依赖数据库主从。

面试回答选型的时候带上 CAP,面试官会觉得理解深度远超普通候选人。

七、面试自测思考题(带思考方向)

  1. 分布式锁需要具备哪些特性?(基础 4 点 + 扩展特性)
  2. synchronized 为什么不能作为分布式锁?底层原理是什么?
  3. Redis 分布式锁和 ZK 分布式锁各自优缺点,生产上怎么选型?CAP 角度怎么理解?
  4. 解锁直接执行 DEL key 会有什么问题?怎么解决?
  5. 为什么说分布式锁会降低系统吞吐量?什么场景尽量避免使用分布式锁?

八、小结

分布式锁是分布式面试里的重中之重,很多人只会背一句 SETNX 加锁,但是各种异常场景一问就卡壳。 分布式锁的难点从来不是一行加锁代码,而是各种异常边界:进程宕机、网络抖动、网络丢包、主从切换、时钟不一致、业务执行超时。 后面文章我们逐个方案落地,手写可运行代码,复现各类坑点,把每一种异常场景的解决方案讲清楚。

RELATED READING

延伸阅读

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