悲观锁和乐观锁
一、 基本概念
悲观锁和乐观锁是并发编程中两种不同的并发控制策略,它们基于对数据竞争的不同假设:
1.1悲观锁(Pessimistic Locking)
悲观锁(Pessimistic Locking) 的核心假设是:并发冲突大概率会发生。因此在操作数据之前,先把资源锁住,阻止其他事务访问,直到自己操作完成、事务结束才释放。
它的思路可以概括为“先下手为强”:我读的时候就不允许别人改,我改的时候更不允许别人碰。整个临界区被锁保护起来,串行执行。
悲观锁不是数据库独有的概念。Java 中的 synchronized、ReentrantLock 本质上是同一思想在 JVM 层面的体现——进入临界区前先获取锁,拿不到就阻塞等待。数据库层面的悲观锁则是通过行锁、表锁来实现的。
1.2 乐观锁 (Optimistic Locking)
乐观锁(Optimistic Locking) 的核心假设正好相反:并发冲突大概率不会发生。因此读取数据时不加任何锁,只在写入提交的那一刻检查数据在此期间有没有被别人修改过。如果没被改过,写入成功;如果被改过,说明发生了冲突,本次操作失败,由业务层决定重试还是放弃。
乐观锁严格来说并不是“锁”,它没有阻塞、没有等待队列,只是一种冲突检测机制——用“事后校验”替代“事前加锁”。这也是它读操作完全无阻塞的原因。
二、实现原理与机制
2.1 悲观锁的实现机制
数据库层:SELECT ... FOR UPDATE
MySQL InnoDB 实现悲观锁的标准方式是事务中的加锁读:
BEGIN;
SELECT stock FROM goods WHERE id = 1 FOR UPDATE; -- 对该行加排他锁
UPDATE goods SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 事务提交后锁释放
FOR UPDATE 执行时对命中行加排他锁(X 锁),其他事务对同一行的加锁读和写操作都会被阻塞,直到当前事务提交或回滚。
InnoDB 支持多种锁模式,按需选择:
| 锁模式 | 行为 | 适用场景 |
|---|---|---|
FOR UPDATE |
排他锁,阻塞其他加锁读与写 | 确定要修改该行 |
FOR SHARE |
共享锁,允许其他共享读,阻塞写 | 只读但防止被改 |
FOR UPDATE NOWAIT |
拿不到锁立即报错 | 快速失败,避免排队 |
FOR UPDATE SKIP LOCKED |
跳过已锁定的行 | 多 worker 消费任务队列 |
一个关键机制细节:InnoDB 的行锁是加在索引上的。如果 WHERE 条件没有走索引,行锁会退化为表级锁,并发度骤降。所以使用悲观锁前必须先确认执行计划。
应用层:Java 锁
// synchronized:JVM 内置监视器锁
synchronized (lock) {
// 临界区
}
// ReentrantLock:可中断、可超时、可公平
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
synchronized 依赖对象头 Mark Word 和 Monitor 实现,JDK 6 之后有偏向锁、轻量级锁、重量级锁的升级机制;ReentrantLock 基于 AQS(AbstractQueuedSynchronizer)的 CLH 队列实现,支持更灵活的锁控制。
Redis 层:分布式锁
跨进程场景下,悲观锁思想体现为分布式锁,例如基于 Redis 的 SET key value NX PX 30000(不存在才设置 + 过期时间),配合 Lua 脚本保证解锁的原子性。这本质上是“抢占一个全局标记”,与数据库行锁是同一思路的不同载体。
2.2 乐观锁的实现机制
版本号机制
在表中增加 version 字段,读取时一并取出,更新时作为条件:
-- 读取
SELECT stock, version FROM goods WHERE id = 1; -- 假设 version = 3
-- 更新:版本号必须仍为 3 才执行
UPDATE goods
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 3;
如果影响行数为 0,说明版本号已被别人改过,冲突发生。检查与更新必须在同一条 SQL 内完成,否则校验就失去意义。
时间戳机制
原理与版本号一致,只是用 update_time 等时间戳字段代替自增版本号。精度需注意:秒级时间戳在短时间内多次修改时可能无法区分。
CAS(Compare-and-Swap)
CAS 是乐观锁在硬件/内存层面的实现,包含三个操作数:内存位置 V、预期旧值 A、新值 B。仅当 V 的当前值等于 A 时,才把 V 更新为 B,否则不做任何操作。Java 的 AtomicInteger、AtomicReference 等原子类底层就是 CAS 指令。
AtomicInteger counter = new AtomicInteger(0);
counter.compareAndSet(0, 1); // 当前值为 0 才更新为 1
CAS 有一个经典缺陷——ABA 问题:值从 A 变 B 又变回 A,CAS 检查时认为“没变过”,但中间状态已发生。解决办法是引入版本号,把“A→B→A”变成“1A→2B→3A”,比较时同时校验版本。数据库的版本号机制,本质就是应用层的带版本 CAS。
重试机制:乐观锁不可省略的另一半
乐观锁冲突后必然要处理失败。没有重试的乐观锁只完成了一半。一个可用的策略包含:最大重试次数、退避等待、失败兜底。推荐带抖动的指数退避——每次失败后等待时间翻倍并加随机抖动,避免大量失败请求同时重试形成“重试风暴”。重试耗尽后,业务层要有明确出口(提示重试、转异步补偿等)。
一个常被忽略的变体:原子条件 UPDATE
UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0;
这条语句把“判断”和“更新”压缩进数据库的原子操作,数据库执行时短暂加锁,但无需维护 version 字段,冲突请求因条件不满足直接快速失败。它在单行单字段的条件扣减场景中,并发表现通常优于版本号方案。但适用范围有限:涉及多字段复杂计算或基于旧值做业务判断时,仍需版本号或悲观锁。
三、两者的特点
3.1 悲观锁的特点
优势:
- 强一致性有保障:临界区内数据不会被他人修改,逻辑简单直接;
- 冲突处理确定:拿不到锁就阻塞等待,不存在“执行到一半发现冲突要回退”的情况;
- 适合长事务中的关键资源保护。
劣势:
- 并发度低:锁持有期间其他请求排队,事务越长吞吐越低;
- 有死锁风险:多个事务以不同顺序加锁时可能互相等待;
- 锁粒度需谨慎:走不到索引会退化为表锁;
- 分布式场景实现成本高(需引入 Redis/ZooKeeper 等)。
3.2 乐观锁的特点
优势:
- 读操作完全无阻塞,高并发读场景吞吐高;
- 不占用数据库锁资源,不会产生锁等待和死锁;
- 适合跨服务、跨库场景,只需比对版本字段即可。
劣势:
- 冲突时需重试,高冲突下重试风暴会放大数据库压力;
- 一致性较弱:冲突检测在提交时,期间可能读到中间态数据;
- 需要额外的版本字段或业务改造;
- 重试逻辑增加代码复杂度。
3.3 对比速览
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 冲突检测时机 | 读取/操作前 | 提交写入时 |
| 是否阻塞 | 阻塞等待 | 不阻塞,失败即返回 |
| 并发性能 | 低(串行化) | 高(无锁读) |
| 一致性 | 强 | 最终/提交时校验 |
| 冲突代价 | 等待时间 | 重试成本 |
| 死锁风险 | 有 | 无 |
| 实现复杂度 | 数据库层简单,分布式复杂 | 需版本字段 + 重试逻辑 |
| 典型实现 | FOR UPDATE、synchronized、Redis 分布式锁 |
版本号、CAS、时间戳 |
四、使用场景分析
选型的核心变量是冲突概率和冲突的代价,而非“哪个更先进”。
4.1 适合悲观锁的场景
银行转账、账户扣款:数据错误的代价远高于吞吐损失,强一致优先。用 FOR UPDATE 锁住双方账户,事务内完成余额校验和扣减。
库存预占、座位锁定:下单时需锁住库存行防止超卖,且后续还有订单创建等操作依赖这个状态,用悲观锁保证临界区完整。
后台批处理、对账任务:并发量低但要求绝对准确,加锁串行化执行最省心。
分布式任务调度:多个节点争抢同一任务,用 Redis 分布式锁保证只有一个节点执行。
4.2 适合乐观锁的场景
用户资料更新、文章编辑:读多写少,绝大多数请求不会冲突;即使冲突,让用户重新提交即可,代价极低。
商品详情页、配置读取:几乎全是读操作,用乐观锁(或不加锁 + 版本校验)保证读性能。
低冲突的计数操作:如文章浏览量、点赞数,用 CAS 或原子 UPDATE 即可。
跨服务的状态同步:微服务间传递版本号,各服务独立处理冲突,无需全局锁。
4.3 混合策略:真实系统的常态
生产系统很少一刀切。常见组合:
- 秒杀链路:Redis 原子操作或数据库原子条件 UPDATE 承接高并发预扣,订单落库后再用悲观锁做最终一致性确认;
- 分阶段加锁:读阶段用乐观锁或 MVCC 快照,写阶段对关键行用悲观锁;
- 按数据热度切换:热数据用悲观锁避免重试风暴,冷数据用乐观锁提升吞吐。
4.4 判断门槛
可以借助监控数据做量化判断:
- 悲观锁的锁等待时间占总执行时间超过 20%,说明写冲突严重,考虑换成乐观锁或降低锁粒度;
- 乐观锁的重试成功率低于 90%,说明冲突过于频繁,重试成本已超过悲观锁的等待成本,应考虑切换或改用原子 UPDATE。
五、总结
悲观锁是“先锁后做”,用阻塞换确定性;乐观锁是“先做后验”,用重试换吞吐。数据库的 FOR UPDATE、Java 的 synchronized、Redis 分布式锁是悲观思想的三种载体;版本号、CAS、时间戳是乐观思想的三种载体。选型时先问两个问题:这个场景冲突有多频繁?一次冲突的代价有多高?想清楚这两点,答案基本就确定了。
一句话记忆:
冲突多、错不起 → 悲观锁;
冲突少、重试廉 → 乐观锁;
单行条件扣减 → 优先原子 UPDATE。

浙公网安备 33010602011771号