CONCURRENCY · LOCK SYSTEM

锁体系总览

从「进程内」到「数据库」再到「分布式」,一次性讲清乐观锁、悲观锁、分段锁的分类、定义、特点与作用。全文以图为主,逐层拆解。

3大层级
2条顶层思想
16+种具体锁
1张总览图
THE BIG PICTURE
一图总览:锁的分类树

无论哪种锁,底层只有两条并发控制思想——悲观(PCC)乐观(OCC),它们横切下面三个层级。先看整体结构,后面逐层展开。

🔒 锁体系 Lock System
🛡️ 悲观并发控制 PCC
先加锁,假设冲突频繁
⚡ 乐观并发控制 OCC
先操作,提交时校验,假设冲突少
↑ 两条思想横切以下全部三层

① 进程内锁

悲观锁(阻塞)
  • 互斥锁 Mutex
  • 可重入锁 ReentrantLock
  • 读写锁 RWMutex
  • 自旋锁 SpinLock
  • 信号量 Semaphore
  • synchronized(JVM 锁升级)
乐观锁(无锁 CAS)
  • 原子操作类 Atomic / sync.atomic
并发工具(配套)
  • 条件变量 Condition
  • 分段锁(容器优化)

② 数据库锁(InnoDB)

悲观锁(物理锁)
  • 共享锁 S / 排他锁 X
  • 粒度:表锁·行锁·间隙锁·临键锁
  • 辅助:意向锁·MDL·自增锁
乐观锁(业务实现)
  • version 版本号 / 时间戳

③ 分布式锁

悲观锁(跨进程)
  • Redis:NX+EX / 看门狗 / Redlock
  • ZooKeeper / etcd
  • 数据库实现
分布式乐观锁
  • 版本号 CAS(跨服务)
TOP-LEVEL MINDSET
顶层思想:悲观并发控制 vs 乐观并发控制

所有具体锁都能归到这两条思想之下。区别在于「何时处理冲突」——悲观者在进入临界区前就锁住,乐观者先放手去做、提交时再验证。

🛡️ 悲观并发控制 PCC

Pessimistic Concurrency Control —— 假设冲突一定会发生,先加锁把别人挡在门外再操作。
定义

访问数据前先获取锁;持有锁期间其他线程/事务被阻塞,直到释放。最常见的「互斥」「行锁」「分布式锁」都属此类。

先加锁后访问 冲突高时安全 吞吐偏低 可能死锁
① 请求加锁 ② 获得锁 ③ 临界区操作 ④ 释放锁 期间其他线程阻塞等待
悲观路径:进入临界区前必须先拿到锁

⚡ 乐观并发控制 OCC

Optimistic Concurrency Control —— 假设冲突很少,先直接操作,提交时再校验版本/条件。
定义

不加物理锁,而是读取时记录版本,写回时用「版本号/CAS」判断期间是否被改过;若冲突则重试或放弃。CAS、version 乐观锁、分布式乐观锁都属此类。

先操作后校验 冲突少时高吞吐 无死锁 高冲突会重试
① 读数据+版本 ② 本地操作 ③ CAS 校验版本 ④ 成功提交 重试 / 放弃 版本不符 无阻塞,冲突才重试
乐观路径:提交时才验证版本是否被改
💡
一句话区分:悲观锁「怕别人抢,所以先锁门」;乐观锁「觉得没人抢,进门干完活再确认门没被换」。前者靠保证安全,后者靠版本/条件保证正确。
LAYER 1 · IN-PROCESS
一、进程内锁(单进程 · 多线程)

同一进程内、多个线程争用共享资源时使用。语言/运行时原生提供(Java/Go/Python/PHP 均有对应实现),不跨进程、不跨机器,性能最高。

互斥锁 Mutex

最基础的悲观锁:同一时刻只允许一个线程进入临界区。
定义

Mutex = Mutual Exclusion。拿不到锁的线程会被挂起排队(阻塞),让出 CPU 直到锁释放。Go 的 sync.Mutex、Java 的 Lock、pthread 的 pthread_mutex 都是它。

独占阻塞排队保护共享变量
临界区 T1 运行 T2 等待 T3 等待 T4 等待 拿不到锁 → 进入等待队列,不占 CPU 时间片
Mutex:独占 + 阻塞排队

读写锁 RWMutex

把「读」和「写」分开:读共享、写独占,专治「读多写少」。
定义

Read-Write Mutex。多个读线程可同时持有读锁;一旦有写线程,则读写都互斥。Go 的 sync.RWMutex、Java 的 ReentrantReadWriteLock

读读并行读写互斥写写互斥
共享数据 读1 读2 ✓ 并行 ✗ 独占
RWMutex:读可并行,写独占

自旋锁 SpinLock

拿不到锁时不睡觉,而是「空转」循环重试——换 CPU 时间换低延迟。
定义

Spin Lock。线程在 while(!cas(lock)) 上空转,不触发上下文切换。适合临界区极短、多核场景;否则空转浪费 CPU。内核、JVM 轻量级锁底层常用。

忙等无上下文切换占 CPU临界区要极短
自旋锁 Spin 互斥锁 Block 循环重试 ↻ ↻ ↻ 挂起 → 排队 立即拿到锁 被唤醒拿到锁 空耗 CPU 发生上下文切换
自旋 vs 阻塞:延迟与 CPU 的取舍

信号量 Semaphore

不是「锁一把」,而是「限 N 个」——控制同时访问的线程数量。
定义

维护一个许可计数器 permit。acquire 减 1、release 加 1;为 0 时阻塞。当 N=1 时退化为互斥锁。常用于连接池、限流。Java Semaphore、Go chan 限流。

许可计数可设 N 个可跨线程释放
permits=2 许可池 T1 T2 T3 T3 等待 许可用完 → 多余线程阻塞(连接池即此模型)
Semaphore:计数许可,N 个并发

可重入锁 ReentrantLock

同一线程可多次加同一把锁而不死锁——靠「持有者+计数」实现。
定义

Reentrant = 可重入。内部记录 owner 线程与 holdCount,同一线程重复 lock 只增计数,解锁到 0 才真正释放。避免递归/嵌套调用自锁。比 synchronized 更灵活:可公平、可中断、可超时、可绑定多个 Condition。

防自死锁可公平/中断多条件变量

synchronized(JVM 锁升级)

Java 关键字级互斥,锁会按竞争强度「自动升级」以省开销。
定义

JVM 对 synchronized 的优化:无竞争时用偏向锁(记录线程 ID),轻度竞争升级为轻量级锁(CAS 自旋),竞争激烈才膨胀为重量级锁(OS 互斥、阻塞)。即「无锁→偏向→轻量→重量」四态。

自动升级无锁→偏向→轻量→重量
无锁 偏向锁 轻量级 重量级 竞争逐步加剧 → 锁逐步膨胀 只升不降:偏向≈CAS无名将,重量级才真正 OS 阻塞
synchronized 锁升级路径

乐观锁:原子操作类(CAS)

无锁(lock-free)方案:靠 CPU 原子指令 Compare-And-Swap 实现线程安全。
定义

AtomicInteger / sync/atomic 等。核心是一条硬件指令:cas(addr, expect, new) —— 仅当内存值等于 expect 才写入 new,返回是否成功。失败则循环重试(自旋)。无阻塞、无死锁。

无锁CPU 原子指令ABA 问题高冲突自旋
内存值 = 5 expect=5, new=6 T1 成功 T2 重试 5==5 ✓ 写入6 读到5但已被改 ✗ T2 重新读取最新值再试
CAS:期望值匹配才更新,否则重试

并发工具:条件变量 Condition

和互斥锁配合,实现「条件不满足就等,满足了再醒」的精确等待。
定义

Condition = 等待队列。线程先持有互斥锁,条件不满足时 await() 释放锁并挂起;另一线程 signal() 唤醒它,它重新抢锁。典型用于生产者-消费者、阻塞队列。是「锁 + 等待」的组合件。

配合 Mutex等待/通知生产者消费者
条件 + 互斥锁 消费者 await 生产者 signal 条件不满足→释放锁等待 生产后 signal 唤醒
Condition:wait / signal 的协作
LAYER 2 · DATABASE (InnoDB)
二、数据库锁(以 InnoDB 为例)

在数据库引擎层实现的锁,作用于「行/表」,由事务持有。悲观锁是引擎的物理锁,乐观锁是业务层用版本号模拟。

共享锁 S / 排他锁 X

读锁与写锁的兼容性,决定事务能否并发。
定义

S 共享锁(读):SELECT ... LOCK IN SHARE MODE,大家都能读、都不能写。X 排他锁(写):SELECT ... FOR UPDATE / UPDATE/DELETE/INSERT,独占,别人既不能读锁也不能写。

请求 \ 已持有SX
S 共享兼容 ✓冲突 ✗
X 排他冲突 ✗冲突 ✗
S-S 可读并发,其余相互阻塞

锁粒度:表 / 行 / 间隙 / 临键

InnoDB 在「索引」上加锁,粒度从小到大,临键锁本质是「分段」思想的体现。
定义

表锁:锁整张表,开销小但并发差。
行锁:锁索引上的某行(Record Lock)。
间隙锁 Gap:锁两条记录之间的「空隙」,防止插入——解决幻读。
临键锁 Next-Key = 行锁 + 向前间隙锁(左开右闭),是 InnoDB 默认行锁算法,可看作对索引区间的「分段加锁」。

记录5 记录10 记录15 间隙 间隙 间隙 间隙 临键锁 = 锁住某记录 + 其左侧间隙(左开右闭) 把索引「分段」加锁,既防修改也防插入 → 天然防幻读
临键锁:记录 + 间隙 = 分段锁定索引区间

辅助锁:意向 / MDL / 自增

为了「快速判断要不要加表锁」「保护元数据」「高效自增」而存在的配套锁。
定义

意向锁 IS/IX:事务给行加 S/X 前,先给表加意向锁,让表级锁能快速判断是否有行级冲突,不必逐行扫。
MDL 元数据锁:对表结构(DDL)加锁,防止读写时表结构被改。
自增锁 AUTO-INC:保证 AUTO_INCREMENT 插入时序号连续不冲突。

请求 \ 已持有ISIXSX
IS
IX
意向锁之间兼容,与表级 S/X 才冲突

乐观锁:version 版本号

不加物理锁,用「版本号/时间戳」在 UPDATE 时做条件校验。
定义

表里加 version 字段。更新时:UPDATE t SET val=?, version=version+1 WHERE id=? AND version=#{old}。若返回 0 行说明期间被改,需重试。避免长事务长时间持有行锁。

无物理锁版本号/时间戳防丢失更新适合读多写少
读 id=1, v=1 T1 与 T2 同时读到 v=1 T1: UPDATE ... WHERE v=1 → 成功, v=2 影响 1 行 T2: UPDATE ... WHERE v=1 → 0 行(已被改), 重试 都基于 v=1 提交
version 乐观锁:后提交者因版本不符而失败重试
LAYER 3 · DISTRIBUTED
三、分布式锁(跨机器 · 跨服务)

当资源被多个进程/节点共享,进程内锁失效,需要借助外部协调服务(Redis / ZK / etcd / DB)来实现互斥。

Redis 分布式锁:NX + EX / 看门狗 / Redlock

最常用方案:用 SET key val NX EX 原子加锁,靠过期时间防死锁,靠「看门狗」自动续期。
基础加锁

SET lock:order uuid NX EX 30。仅当 key 不存在才设置成功 → 拿到锁;带过期时间避免客户端宕机后锁永不释放。释放须用 Lua 脚本保证「判断 uuid + 删除」原子,防止误删别人的锁。

看门狗 Watchdog

业务没执行完锁快过期时,后台定时线程自动续期(如 Redisson 每 1/3 TTL 续一次),直到业务释放。解决「锁提前过期」问题。

客户端A Redis lock = A TTL 30s SET NX EX OK Lua 释放 看门狗:业务未完则定时续期
Redis 单节点加锁 / 释放
Redlock(多节点容错)

为降低单点 Redis 宕机丢锁的风险:向 N 个独立 Redis 主节点依次加锁,当在 多数(≥ N/2+1) 节点上成功且总耗时小于锁有效期,才算真正拿到锁。属于 AP 系统的折中强一致方案,争议较大、需谨慎评估时钟与运维成本。

客户端 节点1节点2节点3 节点4节点5节点6 向 N 个节点加锁,多数成功即视为获得锁
Redlock:多数派原则

ZooKeeper / etcd 分布式锁

基于「临时顺序节点 + 监听前驱」实现,CP 系统,天然防羊群效应。
定义

在锁目录下创建临时顺序(ephemeral_sequential)节点;序号最小的节点获得锁。其他节点只监听「比自己小一号」的前驱,前驱释放(会话断开/删除)才被唤醒去抢。会话断开临时节点自动消失 → 不会死锁。

CP 强一致无羊群效应自动释放性能略低于 Redis
/lock 目录 seq-001 ✓ seq-002 seq-003 序号最小者持锁;其余只监听前驱
ZK/etcd:临时顺序节点 + 监听前驱

数据库 / 分布式乐观锁

兜底方案:用 DB 唯一键或版本号实现跨服务互斥。
DB 实现(悲观)

建一张锁表,INSERT 唯一键(如 method_name)抢锁,释放即 DELETE;或用 SELECT ... FOR UPDATE 行锁。简单但强依赖 DB,并发与可用受限,仅适合低频场景。

分布式乐观锁

与单库 version 思路一致,但跨多个服务/节点更新同一资源时带版本条件:UPDATE ... WHERE id=? AND version=?。失败时由业务层重试,不持有连接锁,扩展性更好。

DB 唯一键抢锁跨服务 version CAS
KEY IDEA · SEGMENTED LOCK
分段锁:把「一把大锁」拆成「多把小锁」

分段锁本身是一种降低锁粒度的设计思想,不是某种具体锁,而是悲观锁在高并发容器上的工程化运用。核心:把数据分桶,每个桶一把独立锁,线程只锁自己要用的那段。

原理:分而治之

定义

把共享数据结构(如哈希表)分成 N 个段(Segment),每段持有一把独立锁。线程操作 key 时,只锁该 key 所属的分段,其他分段仍可并发。JDK 7 的 ConcurrentHashMap 就是 16 个 Segment;JDK 8 后改为更细的「数组 + CAS + 节点锁(synchronized)」。

降低锁粒度提升并发度JDK7 ConcurrentHashMap

与临键锁的呼应

思想同源

分段思想一脉相承:InnoDB 的临键锁把索引「分段」加锁防幻读;分布式用分片锁 / 分库分表锁降低冲突;容器用分段锁提升并发。本质都是「缩小锁保护范围,让更多操作并行」。

ConcurrentHashMap(分段锁)示意 整表一把锁 🔒 全局锁 任一写 → 全表阻塞 拆分 16 个分段,各持一把锁 Seg0 🔒 Seg1 空闲 Seg2 🔒 Seg3 … 写 Seg0 不阻塞写 Seg2 → 并发度 ≈ 段数
分段锁:锁范围从「整表」缩小到「单段」
HOW TO CHOOSE
如何选型:一张决策图

先判断「冲突范围」在哪一層,再决定用悲观还是乐观,最后挑具体实现。

资源在单进程内? 多线程争用 否(跨机) 进程内锁 冲突多 → Mutex/RWLock 冲突少 → Atomic/CAS 分布式锁 高一致 → ZK/etcd 高性能 → Redis/NX 跨服务更新 → version 争用的是 DB 数据? 与事务绑定 强一致 → FOR UPDATE / 行锁 读多写少 → version 否→走进程内/分布式 判断 悲观 乐观
⚠️
常见坑:① 乐观锁务必用「版本号/条件」而非常量,否则失效;② Redis 锁释放必须用 Lua,否则可能删掉别人的锁;③ 锁的粒度越粗并发越低,能用分段/行锁就别用表锁;④ 悲观锁要记得加超时与重试,避免死锁拖垮系统。