多线程或多事务同时读写同一份数据时,核心矛盾只有一个:如何保证"读—改—写"这一连串操作不被彼此打断,同时又不把系统拖死。乐观锁和悲观锁是应对这一矛盾的两套经典策略。它们不是两种"锁实现",而是两种对冲突发生概率的不同假设,由此推导出不同的同步方式、不同的性能特征,以及不同的适用边界。
1 两种锁在解决什么问题
1.1 悲观锁:假定冲突一定发生
悲观锁(Pessimistic Locking)的策略是:在访问共享资源之前,先把资源"占住",阻止其他竞争者同时修改。它的隐含假设是:冲突大概率会发生,所以不如提前加锁,把并发读写串行化或部分串行化。
典型心智模型:
线程 A:加锁 → 读 → 改 → 写 → 释放锁
线程 B:等待锁 → … → 加锁 → 读 → 改 → 写 → 释放锁
在数据库里,这对应 SELECT … FOR UPDATE;在 Java 里,对应 synchronized、ReentrantLock 等互斥机制。
1.2 乐观锁:假定冲突很少发生
乐观锁(Optimistic Locking)的策略相反:不加锁,直接读;写回时检查数据是否仍是自己读到的那个版本。如果期间被其他人改过,本次更新失败,由应用层决定重试或放弃。
典型心智模型:
线程 A:读(version=1) → 计算 → UPDATE … WHERE version=1 → 成功
线程 B:读(version=1) → 计算 → UPDATE … WHERE version=1 → 失败(version 已是 2)
它的隐含假设是:冲突是小概率事件,与其让所有读写都排队等锁,不如让大多数操作直接通过,只在冲突时付出重试成本。
2 在 Java 与 MySQL 中的实现方式
2.1 MySQL 中的悲观锁
MySQL(InnoDB)的悲观锁依托行级锁实现,常见手段如下。
显式加锁读
BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;
FOR UPDATE 会在满足条件的行上加排他锁(X Lock)。其他事务对同一行的 FOR UPDATE 或 UPDATE/DELETE 会被阻塞,直到当前事务提交或回滚。
隐式加锁写
普通 UPDATE / DELETE 在执行时也会自动对涉及行加 X 锁,本质上同样是悲观锁,只是不需要显式 SELECT … FOR UPDATE。
共享锁读(读锁)
SELECT balance FROM account WHERE id = 1 LOCK IN SHARE MODE;
-- 或 MySQL 8.0+
SELECT balance FROM account WHERE id = 1 FOR SHARE;
共享锁(S Lock)允许多个事务同时读,但阻止其他事务加写锁或修改。
悲观锁的代价是明确的:锁等待、潜在死锁、长事务持有锁导致吞吐下降。InnoDB 通过 MVCC 让普通 SELECT(快照读)不加锁,只有当前读和写操作才走锁路径,这是性能和一致性之间的折中。
2.2 MySQL 中的乐观锁
MySQL 本身没有名为"乐观锁"的内置语法。乐观锁是应用层协议,数据库提供的是带条件的原子更新作为底层支撑。
版本号方案
表结构增加 version 字段:
version INT NOT NULL DEFAULT 0
更新时:
UPDATE account
SET balance = 150, version = version + 1
WHERE id = 1 AND version = 3;
如果 affected rows = 0,说明 version 已变,更新被其他事务抢先完成,应用层应重试或报错。
状态字段方案
用业务状态(如 status = 'PENDING')做 CAS 条件,原理相同:
UPDATE orders
SET status = 'PAID'
WHERE id = 100 AND status = 'PENDING';
乐观锁也加锁,意义在哪
前面说过,InnoDB 执行带 version 条件的 UPDATE 时,引擎内部仍会短暂持有行 X 锁。既然两种方案都涉及锁,乐观锁相对悲观锁的价值就不在"完全不锁",而在锁的持有时机和时长。
悲观锁的典型路径是 SELECT … FOR UPDATE 加锁,再执行业务计算,最后 UPDATE 写回。从加锁到事务提交,锁一直占着——业务逻辑越复杂、事务越长,其他竞争者等待越久。读阶段用的是当前读,本身就要拿锁;多个事务争抢同一行时,大部分线程会在锁上阻塞。
乐观锁把冲突检测推迟到写回那一刻:读阶段走普通快照读(MVCC),不加锁;多个事务可以同时读到相同 version,各自做业务计算;真正竞争发生在执行 UPDATE … WHERE version = ? 的 instant,InnoDB 在这一条语句的执行窗口内加 X 锁、完成 Compare-Set,语句结束锁即释放。CAS 语义在这里体现为:Compare 和 Set 绑定在同一条原子 UPDATE 里,不需要为了"读 + 算"提前占锁。
对比两条路径的锁窗口:
悲观锁: [---- FOR UPDATE 加锁 ---- 业务计算 ---- UPDATE ---- COMMIT ----]
乐观锁: [---- 快照读(无锁)---- 业务计算 ----][UPDATE 短暂加锁][----]
因此,乐观锁最大的收益是:通过 CAS 做并发校验,读和算的阶段不阻塞他人;锁只在更新的那一瞬间出现,且粒度限于单条 UPDATE 的执行时间。悲观锁则要求你先 SELECT FOR UPDATE 占住行,锁的时长覆盖整个读改写周期,往往长得多。
冲突稀少时,乐观锁让绝大多数事务并行推进;冲突频繁时,大量 CAS 失败重试又会抵消这一优势——这正是第 4 节讨论选型时的核心权衡。
2.3 Java 中的悲观锁
Java 提供多种互斥机制,本质都是阻塞或自旋等待,直到获得独占访问权。
| 机制 | 说明 |
|---|---|
synchronized | 内置监视器锁,JVM 层面实现,支持锁升级(偏向锁 → 轻量级锁 → 重量级锁) |
ReentrantLock | JUC 显式锁,支持公平/非公平、可中断、超时、Condition |
ReadWriteLock | 读写分离:读读不互斥,读写、写写互斥 |
示例:
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
2.4 Java 中的乐观锁
原子类(CAS 封装)
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 内部是 CAS 循环
}
手动 CAS 循环
public void update(int expected, int newValue) {
int current;
do {
current = count.get();
if (current != expected) {
return; // 版本不匹配,放弃或重试
}
} while (!count.compareAndSet(current, newValue));
}
JUC 中的乐观锁工具
AtomicStampedReference:带版本戳,解决 ABA 问题LongAdder:高并发计数,分段降低 CAS 竞争StampedLock:乐观读模式,读时不加锁,写时验证 stamp
3 乐观锁的本质:Compare-And-Set 的原子性
乐观锁能否正确工作,不取决于"有没有加锁"这个表面形式,而取决于一件事:Compare(比较当前值是否仍为期望值)和 Set(写入新值)这两步,对外表现为一个不可分割的原子操作。如果 Compare 和 Set 之间可以被其他线程插入,就会出现 lost update。
下面分别看 MySQL 和 Java 如何保证这一点。
3.1 为什么必须原子
非原子的 Compare-Set 会导致如下竞态:
sequenceDiagram
participant DB as 数据库(version=3)
participant T1
participant T2
Note over T1,T2:初始数据库version=3
T1->>DB:读取version,得到3
T2->>DB:读取version,得到3
T1->>T1:校验version==3 ✔️
T2->>T2:校验version==3 ✔️
T1->>DB:更新数据,写入version=4
Note right of DB:DB现在version=4
T2->>DB:更新数据,写入version=4
Note right of T2:T1修改被覆盖,版本冲突漏检正确的乐观锁语义是:只有一个竞争者能在"version 仍为 3"的前提下完成写入。
3.2 MySQL 如何保证 Compare-Set 原子
MySQL InnoDB 把带条件的 UPDATE 实现为单条 SQL 语句在引擎层的一次原子操作,而不是应用层的"先 SELECT 再 UPDATE"两步。
执行
UPDATE account SET balance = 150, version = 4 WHERE id = 1 AND version = 3;
时,InnoDB 的大致过程如下:
sequenceDiagram
participant App as 应用
participant Server as MySQL Server
participant InnoDB as InnoDB 引擎
App->>Server: UPDATE … WHERE id=1 AND version=3
Server->>InnoDB: 解析为单行更新
InnoDB->>InnoDB: 对 id=1 行加 X 锁
InnoDB->>InnoDB: 读取当前行,比较 version==3
alt version 匹配
InnoDB->>InnoDB: 写入新 balance、version=4
InnoDB-->>Server: affected rows = 1
else version 不匹配
InnoDB-->>Server: affected rows = 0
end
InnoDB->>InnoDB: 释放锁(语句结束/事务提交)
Server-->>App: 返回影响行数关键机制:
- 行锁保护:即使 Compare 和 Set 在引擎内部分多步执行,X 锁保证同一行在同一时刻只有一个事务能执行写路径上的 Compare-Set。
- 单语句原子性:一条
UPDATE要么完整生效,要么完全不生效,不存在"Compare 成功但 Set 只完成一半"的中间态对外可见。 - 影响行数作为 CAS 返回值:
affected rows = 0等价于 CAS 失败,应用据此重试。
反模式是把 Compare 和 Set 拆成两条语句:
-- 错误示范:两步之间没有原子性保障
SELECT version FROM account WHERE id = 1; -- 应用层比较
UPDATE account SET balance = 150, version = 4 WHERE id = 1; -- 无条件更新
这样第二步会无条件覆盖,乐观锁失效。正确做法是把 Compare 条件写进 WHERE 子句,合并为一条 UPDATE。
在 REPEATABLE READ 下,InnoDB 还通过 next-key gap 锁处理幻读;对乐观锁的单行 version 更新,核心仍是行级 X 锁 + 单语句原子执行。
3.3 Java CAS 如何保证 Compare-Set 原子
Java 的 CAS(Compare-And-Swap)由 JVM 通过 CPU 硬件指令实现,常见底层指令包括 x86 的 CMPXCHG / LOCK CMPXCHG,以及 ARM 的 LDREX/STREX 等。
Unsafe.compareAndSwapInt(及 VarHandle、AtomicXxx 的上层封装)语义如下:
// 伪代码:硬件保证以下三步原子执行
boolean compareAndSet(int expected, int newValue) {
if (currentValue == expected) {
currentValue = newValue;
return true;
}
return false;
}
硬件层面的保证方式:
- 总线锁或缓存锁:
LOCK前缀指令锁定缓存行,保证读-比较-写在多核间原子完成。 - LL/SC(Load-Link / Store-Conditional):ARM 等平台通过"链接加载 + 条件存储"实现,存储仅在链接期间内存未被修改时成功。
AtomicInteger.incrementAndGet() 的典型实现是 CAS 自旋循环:
public final int incrementAndGet() {
int current, next;
do {
current = get(); // 读当前值
next = current + 1; // 计算新值
} while (!compareAndSet(current, next)); // CAS:仅当仍为 current 时写入 next
return next;
}
这与 MySQL 的 UPDATE … WHERE version = ? 是同一模式:Compare 和 Set 绑定在一次原子操作中;失败则重试或放弃。
3.4 ABA 问题
CAS 只比较值是否相等,不感知"中间变过又变回"。例如栈顶 A → B → A,线程可能误以为没有变化,Java 用 AtomicStampedReference(值 + 版本戳)或 AtomicMarkableReference 解决,数据库乐观锁用单调递增的 version 天然规避 ABA。
CAS 与 MySQL 乐观锁的对比
| 维度 | Java CAS | MySQL 乐观锁 UPDATE |
|---|---|---|
| 原子单元 | 单个内存字 / 对象字段 | 单行记录 |
| 底层机制 | CPU 指令 | InnoDB 行锁 + 单语句原子性 |
| 失败处理 | 自旋重试或业务决定 | 检查 affected rows,应用重试 |
| 适用粒度 | 内存中的计数器、引用 | 持久化数据的跨事务协调 |
4 适用场景与选型取舍
两种锁没有绝对的优劣,选型取决于冲突频率、操作耗时、一致性要求和失败代价。
4.1 适合悲观锁的场景
写多读少、冲突频繁
库存扣减、账户转账、座位预订等,多个请求几乎必然争抢同一行。此时乐观锁会导致大量重试,悲观锁直接排队更可控。
业务逻辑复杂、读改写跨度长
一次事务内需要多次读取、复杂校验、调用外部服务后再写回。若用乐观锁,从第一次读到最终写之间窗口很大,失败率极高。悲观锁把整段逻辑包在锁或事务内,避免反复重试。
必须避免"失败后重试"的业务
某些操作失败代价高(如已发短信、已调用支付),不适合"先试试,冲突了再重来"。悲观锁在一开始就独占资源,路径更确定。
需要防止幻读或范围一致性
SELECT … FOR UPDATE 配合 gap 锁,可以锁定范围,防止并发插入导致的数据不一致。乐观锁通常针对已知主键的单行更新,范围场景需要额外设计。
4.2 适合乐观锁的场景
读多写少、冲突稀少
用户资料、配置项等,绝大多数更新不会撞车。乐观锁避免读操作也参与锁竞争,吞吐更好。
短小的原子更新
UPDATE … WHERE id = ? AND version = ? 或 AtomicInteger.incrementAndGet(),Compare-Set 窗口极短,CAS 成功率高,几乎没有锁等待开销。
分布式 / 高并发下的可扩展性
悲观锁的锁等待会随并发度线性恶化;乐观锁把冲突检测推后,无冲突时各节点独立推进。Web 层无状态 + DB 层 version 字段,是常见的水平扩展模式。
对延迟敏感、可接受重试
CDN 统计、浏览计数等,偶尔丢失一次重试的成本低于全局加锁。注意:这需要业务容忍失败重试或近似计数(如 LongAdder)。
4.3 选型参考
flowchart TD
A[并发更新同一数据] --> B{冲突概率高吗?}
B -->|高| C{读改写窗口长吗?}
B -->|低| D[乐观锁]
C -->|长| E[悲观锁]
C -->|短| F{失败重试可接受吗?}
F -->|是| D
F -->|否| E| 考量因素 | 倾向悲观锁 | 倾向乐观锁 |
|---|---|---|
| 冲突频率 | 高 | 低 |
| 操作耗时 | 长(毫秒级以上) | 短 |
| 读比例 | 低 | 高 |
| 失败处理 | 不可重试 | 可重试 |
| 典型实现 | FOR UPDATE、ReentrantLock | version 字段、Atomic* |
4.4 常见误区
误区一:乐观锁完全不涉及锁
MySQL 的乐观锁 UPDATE 在引擎内部仍然短暂持有行 X 锁;只是锁的持有时间极短,且读阶段不加锁。Java CAS 则依赖 CPU 层面的缓存锁。乐观锁"乐观"的是冲突概率,不是"无锁"。
误区二:有了 MVCC 就不需要乐观锁
MVCC 保证读到的快照一致,不保证写不冲突。两个事务可以同时读到相同 version,仍需要 version 条件 UPDATE 或 CAS 检测写冲突。
误区三:悲观锁一定比乐观锁慢
低冲突下,悲观锁的加锁/唤醒开销可能高于一次成功的 CAS;高冲突下,乐观锁的重试风暴反而更糟。性能取决于 workload,不能一概而论。
误区四:应用层 SELECT + UPDATE 等同于乐观锁
缺少 WHERE 中的 version 条件,或者 Compare 和 Set 不在同一原子边界内,只是"无锁的 lost update",不是正确的乐观锁。
5 总结
悲观锁和乐观锁的区别,首先是对冲突概率的不同假设:悲观锁默认会撞车,先占资源再操作;乐观锁默认很少撞车,操作后再验证。
在实现层面,MySQL 悲观锁靠 InnoDB 行锁(FOR UPDATE、写锁);Java 悲观锁靠 synchronized、ReentrantLock 等互斥原语。MySQL 乐观锁靠带 version 条件的单条 UPDATE,Java 乐观锁靠 CAS 原子指令,本质都是 Compare-And-Set:MySQL 用行锁保证单语句内 Compare 与 Set 不可分割,Java 用 CPU 硬件指令保证内存上的 Compare 与 Set 不可分割。
选型上,冲突多、逻辑长、不能重试倾向悲观锁;冲突少、更新短、读多写少、可重试倾向乐观锁。实际系统里两者常并存:例如 ORM 乐观锁处理普通更新,关键金融操作单独走 FOR UPDATE 悲观路径。理解 CAS 原子的边界,比记住"乐观不加锁、悲观加锁"的 slogan 更有用。