Skip to content
Go back

乐观锁与悲观锁——读多写少与写多的两种世界观

乐观锁与悲观锁:读多写少与写多的两种世界观

一句话结论(30s)

乐观锁和悲观锁是解决「多个人同时改同一份数据」的两种世界观:悲观锁假设冲突必然发生、先加锁再做(synchronized、SELECT…FOR UPDATE),乐观锁假设冲突很少、先做再检查(CAS、version 字段)。它们的关键区别不在「谁更好」而在冲突率——因为冲突率低时乐观锁的 CAS 几乎一次成功、无锁开销为零,而冲突率高时 CAS 大量自旋会让 CPU 飙升、比阻塞等锁还贵。核心判断公式:冲突率 <5% 用乐观锁,>20% 用悲观锁,5%~20% 用「乐观重试 N 次后降级悲观锁」的混合策略。

核心原理(2min)

主流程:乐观锁先读旧值、算出新值、用 CAS/版本号条件更新,失败就重试;悲观锁先拿到锁再操作,保证同一时刻只有一个线程修改。关键机制两点:一是 MySQL 乐观锁里 WHERE version=5SET version=version+1 必须放在同一条 UPDATE 里、由 InnoDB 行锁保证原子,拆成「读 → 判断 → 写」就没有保护;二是乐观锁的 ABA 问题——值被改成 Z 又改回 X 后 CAS 仍成功,数值运算通常无害,但链表/栈等引用结构必须用 AtomicStampedReference 加版本号。理解它本质是在「CPU 自旋」和「线程阻塞唤醒」两种开销之间做取舍。

底层深入(5-10min)

从银行转账说起

两个操作员同时给同一个账户转账:

操作员 A:余额 1000 → 转入 500 → 新余额 1500
操作员 B:余额 1000 → 转入 300 → 新余额 1300

如果没有并发控制,A 和 B 同时读到余额 1000,分别算出 1500 和 1300。最后写的那个会覆盖先写的结果——要么少了 500,要么少了 300。

想一想:为什么”最后写的人”会覆盖”先写的人”?因为 A、B 都基于同一个旧值 1000 计算,没有并发控制时后提交的 UPDATE 直接盖掉先提交的结果,先提交的那笔钱就凭空丢了。

乐观锁和悲观锁,就是解决”多个人同时改同一份数据”的两种哲学。

悲观锁:假设冲突必然发生

悲观锁的底层逻辑是:“外面有 100 个线程都在盯着这条数据,我操作之前必须先把门锁死,不让别人看。”

Java 中的悲观锁:synchronized

public class BankAccount {
    private int balance = 1000;

    public synchronized void transfer(int amount) {
        // 同一时刻只有一个线程能执行这个方法
        int newBalance = balance + amount;
        balance = newBalance;
    }
}

MySQL 中的悲观锁:SELECT … FOR UPDATE

BEGIN;
-- 对 id=1 的行加排他锁,其他事务的 SELECT ... FOR UPDATE 会阻塞
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 读到 balance = 1000,放心修改,没有其他事务能插进来
UPDATE account SET balance = 1500 WHERE id = 1;
COMMIT;

FOR UPDATE 在读取时就加了排他锁——读操作本身就会阻塞其他写操作。这条记录被锁住期间,任何其他事务想 FOR UPDATE 同一行,只能排队等。

悲观锁的代价

100 个线程抢同一行数据 → 99 个线程在等锁 → CPU 虽然空闲,但请求在积压。悲观锁在竞争激烈时吞吐量急剧下降,但不会出错——得到锁的线程一定能正确完成操作。

乐观锁:假设冲突很少发生

乐观锁的底层逻辑是:“我先做,做完了检查一下有没有别人也做了。如果有,就重来。”

Java 中的乐观锁:CAS(Compare and Swap)

public class OptimisticBankAccount {
    private AtomicInteger balance = new AtomicInteger(1000);

    public void transfer(int amount) {
        int oldValue, newValue;
        do {
            oldValue = balance.get();          // 读当前值
            newValue = oldValue + amount;       // 计算新值
            // CAS:如果 balance 还是 oldValue,就改成 newValue
            // 如果被别人改过(balance != oldValue),循环重试
        } while (!balance.compareAndSet(oldValue, newValue));
    }
}

compareAndSet 是 CPU 级别的原子指令(CMPXCHG)——“比较并交换”一步完成,硬件保证不可中断。

想一想:为什么 CAS 能”无锁”还保证正确?因为比较和交换是 CPU 的一条 CMPXCHG 原子指令、一步完成,硬件保证中间不可被其他线程插入;于是”读到改”的检查是原子的,冲突时能发现值变了、就重试。

MySQL 中的乐观锁:version 字段

-- 表结构
CREATE TABLE account (
    id      BIGINT PRIMARY KEY,
    balance INT,
    version INT NOT NULL DEFAULT 0   -- 版本号,每次更新 +1
);

-- 乐观锁更新逻辑
-- 1. 先读出版本号
SELECT balance, version FROM account WHERE id = 1;
-- → balance=1000, version=5

-- 2. 更新时带上版本号条件
UPDATE account
SET balance = 1500, version = version + 1
WHERE id = 1 AND version = 5;  -- 版本号匹配才更新

-- 3. 检查受影响行数
-- affectedRows = 1 → 更新成功
-- affectedRows = 0 → 版本号已变(被别人改过),重试
// Spring Data JPA 的实现
@Entity
public class Account {
    @Id
    private Long id;
    private Integer balance;

    @Version       // JPA 自动管理版本号
    private Integer version;
}

// 调用方
@Transactional
public void transfer(Long accountId, int amount) {
    Account account = accountRepo.findById(accountId).get();
    account.setBalance(account.getBalance() + amount);
    // JPA 自动生成:UPDATE ... SET balance=?, version=version+1
    //              WHERE id=? AND version=?
    // 如果 version 不匹配 → 抛 OptimisticLockException
}

JPA 的 @Version 注解让乐观锁完全透明——开发者只管写业务逻辑,框架在 SQL 层自动加上版本号校验。

两种锁的数学边界

判断用哪种锁,不是靠”感觉”,而是算一笔账:

乐观锁的开销

乐观锁总开销 = 冲突概率 × 重试成本 + (1 - 冲突概率) × 0

其中:
- 冲突概率 = 并发线程数 / 操作窗口内的尝试次数
- 重试成本 = CPU 空转 n 次 CAS 循环的时间

当冲突概率 < 5%:99% 的 CAS 一次成功 → 无锁的开销为 0,悲观锁每次都要加锁 → 乐观锁完胜。

当冲突概率 > 20%:CAS 大量自旋重试 → CPU 飙升 → 5 次重试比 1 次锁等待更贵 → 悲观锁反超。

想一想:为什么冲突高时”无锁”的乐观锁反而更贵?因为 CAS 失败会自旋重试,5 次 CPU 空转比 1 次阻塞唤醒还贵;阻塞虽然要切内核态,但线程让出 CPU、不空转,高竞争下反而更划算。

量化判断标准

场景特征读:写比推荐锁原因
商品浏览数更新100:1乐观锁读多写少,冲突极低
秒杀库存扣减1:1000悲观锁写极度密集,CAS 自旋废掉 CPU
用户信息编辑1000:1乐观锁极少同时编辑同一个用户
银行账户转账10:1悲观锁金额敏感,宁可等也不能出错

乐观锁的”ABA 问题”

CAS 的经典缺陷:

1. 线程 A 读到值 X → 计算出新值 Y
2. 线程 B 把 X 改成 Z,然后又改回 X
3. 线程 A 的 CAS 比较:还是 X!→ 成功改写
    但 A 不知道数据"曾经变成 Z 又变回来的"

对于数值运算(余额加减),ABA 通常不是问题——余额从 100 变成 90 再变回 100,数值没变,结果正确。

对于链表/栈这类引用结构,ABA 可能是致命的——指针指向的节点可能已经被回收并重新分配了。Java 的 AtomicStampedReference 通过”版本号 + 值”双重 CAS 解决 ABA:不仅比较引用,还比较版本号是否一致。

MySQL 乐观锁的一个关键细节

UPDATE account SET balance = 1500, version = version + 1
WHERE id = 1 AND version = 5;

这条 SQL 的 WHERE version = 5SET version = version + 1 在 MySQL 的同一个行锁保护下执行——读 version 和更新 version 是原子的。不要把”读 version → Java 判断 → 写 version”拆开——这样在”读”和”写”之间没有保护,并发时必然出错。

MySQL 行锁(InnoDB 的 Record Lock)本身是悲观锁——乐观锁的”乐观”体现在应用层的 CAS 逻辑,底层仍依赖于数据库的行锁来保证 WHERE + UPDATE 的原子性。

想一想:为什么说”乐观锁底层还是悲观锁”?因为 WHERE version=5 AND SET version=version+1 里,读 version 和更新 version 必须在 InnoDB 行锁保护下原子完成;应用层的”乐观”只是逻辑上的无锁,物理上仍靠数据库的行锁兜底。

混合策略:乐观读 + 悲观写

实际项目中,最优解往往不是二选一:

public void deductStock(Long productId, int quantity) {
    // 第一步:乐观锁尝试(大部分情况一次成功)
    int retryCount = 0;
    while (retryCount < 3) {
        int affected = stockDao.deductWithVersion(productId, quantity);
        if (affected > 0) return;  // 成功
        retryCount++;
    }
    // 第二步:乐观锁重试 3 次都失败 → 竞争激烈 → 降级为悲观锁
    stockDao.deductWithLock(productId, quantity);  // SELECT ... FOR UPDATE
}

先用乐观锁快速通过(低竞争场景占 95%),重试多次失败后自动降级为悲观锁(高竞争场景兜底)。不是”选哪个”,而是”在什么条件下切换”。

总结

维度乐观锁悲观锁
哲学先做,做完检查先锁,锁了再做
适用场景读多写少,冲突率低写多,冲突率高
CPU 开销冲突时自旋重试线程阻塞/唤醒
实现方式CAS、version 字段synchronized、FOR UPDATE
吞吐量低冲突时极高高冲突时稳定
死锁风险有(加锁顺序不当)

核心判断公式:冲突率 < 5% → 乐观锁;冲突率 > 20% → 悲观锁;5%-20% → 混合策略。

乐观锁和悲观锁不是”谁更好”的问题——是”你的数据在什么竞争强度下活着”的问题。选错了,不是功能不对,是性能死在并发数上了。

章末提问

追问 1:什么时候用乐观锁、什么时候用悲观锁?给个量化标准。

回答思路:结论先行——看冲突率:<5% 用乐观锁、>20% 用悲观锁、5%~20% 用”乐观重试 N 次后降级悲观锁”的混合策略。因为低冲突时乐观锁的 CAS 几乎一次成功、无锁开销为零;高冲突时 CAS 大量自旋让 CPU 飙升、比阻塞等锁还贵,所以在临界区间用混合策略自动切换。

追问 2:乐观锁的 ABA 问题是什么?什么时候致命?

回答思路:结论先行——值被改成 Z 又改回 X 后 CAS 仍成功、检测不到中间变化;数值运算通常无害,链表/栈等引用结构致命。因为余额 100→90→100 数值没变、结果仍正确;但引用结构里指针指向的节点可能已被回收并重新分配,CAS 比较引用相同却语义已变,必须用 AtomicStampedReference 加版本号双重 CAS 解决。

追问 3:MySQL 乐观锁的 version 更新为什么不能拆成”读→判断→写”?

回答思路:结论先行——因为拆分后读和写之间没有保护,并发必然出错;WHERE version=5 AND SET version=version+1 必须在同一条 UPDATE 里由 InnoDB 行锁保证原子。因为行锁本身是悲观锁,乐观锁的”乐观”体现在应用层 CAS 逻辑,底层仍靠行锁兜底才能做到条件判断与更新原子完成。


Share this post on:

Previous Post
圈复杂度——McCabe公式与重构的数学依据
Next Post
Segmented分段锁——从单锁串行到10倍并发的演进