悲观锁和乐观锁

一、 基本概念

悲观锁和乐观锁是并发编程中两种不同的并发控制策略,它们基于对数据竞争的不同假设:

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。

posted @ 2026-10-29 16:28  Tauarz  阅读(7)  评论(0)    收藏  举报