本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
事务与并发(ACID / 隔离级别 / 锁 / MVCC) · 锁机制

悲观锁与乐观锁的区别?分别什么场景使用?(SELECT FOR UPDATE vs version 字段)

Differences between pessimistic and optimistic locking, and their respective use cases (SELECT FOR UPDATE vs. version column

答案语言

考察要点

这道题面试官想看你是否深刻理解数据库并发控制的两种核心思想,以及它们在工程实践中的权衡。具体来说,考察点包括:

  1. 并发控制哲学:是否理解悲观与乐观的本质区别,即“先加锁再访问” vs “先访问,提交时检查冲突”。
  2. SQL 实现能力:是否知道 SELECT ... FOR UPDATE 的正确用法(必须在事务中),以及如何通过 version 字段在应用层实现乐观锁。
  3. 场景判断能力:能否根据业务场景(如:读写比、冲突概率、性能要求)选择合适的锁策略。

核心概念

悲观锁(Pessimistic Locking)和乐观锁(Optimistic Locking)是并发控制中两种截然不同的策略,用于解决“丢失更新”(Lost Update)等并发问题。


1. 悲观锁 (Pessimistic Locking)

悲观锁假定并发冲突的概率很高,因此在对数据进行任何修改之前,都会先获取一个排他锁,以阻止其他事务对该数据进行修改,直到当前事务完成。

核心思想:数据在被修改时一定会被锁定,其他人必须等待。

MySQL/InnoDB 实现:主要通过 SELECT ... FOR UPDATE 实现。

  • 机制:在一个事务中,当执行 SELECT ... FOR UPDATE 时,InnoDB 会对查询结果集中的每一行记录添加一个排他锁(X-Lock)。其他任何事务如果想修改这些行(UPDATE, DELETE)或对它们再次加排他锁(SELECT ... FOR UPDATE),都将被阻塞,直到持有锁的事务提交(COMMIT)或回滚(ROLLBACK)。
  • 注意:该语句必须在事务块 (BEGIN/START TRANSACTION ... COMMIT/ROLLBACK) 中使用,否则锁会立即释放,失去意义。

数据示例:扣减商品库存

假设 products 表结构如下:

sql
1CREATE TABLE products (
2 id INT PRIMARY KEY,
3 name VARCHAR(100),
4 stock INT NOT NULL
5) ENGINE=InnoDB;
6
7INSERT INTO products VALUES (1, 'iPhone 15 Pro', 10);

操作流程:

sql
1-- Transaction A (T_A)
2BEGIN;
3
4-- 1. 查询并锁定库存为 10 的商品,此时其他事务无法修改 id=1 的行
5SELECT stock FROM products WHERE id = 1 FOR UPDATE;
6-- (Application logic: check if stock > 0)
7
8-- 2. 模拟业务处理耗时
9-- DO_SOME_WORK();
10
11-- 3. 执行更新
12UPDATE products SET stock = stock - 1 WHERE id = 1;
13
14COMMIT;

在 T_A COMMIT 之前,任何其他尝试 UPDATE products ... WHERE id = 1 或 SELECT ... FROM products WHERE id = 1 FOR UPDATE 的事务都会被阻塞。


2. 乐观锁 (Optimistic Locking)

乐观锁假定并发冲突的概率很低。它不会在数据读取时加锁,而是在数据提交更新时,通过一种机制来检查在此期间数据是否被其他事务修改过。如果被修改过,则更新失败,由应用层决定如何处理(如:重试、报错)。

核心思想:默认不冲突,提交时才检查。

实现方式:通常不是数据库原生的锁机制,而是一种应用层设计模式。

  1. 版本号(Version)字段:最常用。
  2. 时间戳(Timestamp)字段:原理类似。

数据示例:使用版本号扣减库存

首先,修改表结构,增加 version 字段。

sql
1CREATE TABLE products (
2 id INT PRIMARY KEY,
3 name VARCHAR(100),
4 stock INT NOT NULL,
5 version INT NOT NULL DEFAULT 0
6) ENGINE=InnoDB;
7
8INSERT INTO products VALUES (1, 'iPhone 15 Pro', 10, 0);

操作流程:

sql
1-- Transaction A (T_A)
2-- 1. 读取数据和版本号
3-- SELECT id, stock, version FROM products WHERE id = 1;
4-- 假设读到 stock=10, version=0
5
6-- (Application logic: check if stock > 0, calculate new_stock=9)
7
8-- 2. 提交更新时,检查版本号是否匹配
9UPDATE products
10SET stock = 9, version = version + 1
11WHERE id = 1 AND version = 0; -- 关键的 CAS (Compare-And-Set) 操作
12
13-- 3. 检查更新影响的行数
14-- 如果 affected_rows = 1,说明更新成功。
15-- 如果 affected_rows = 0,说明在 T_A 读取数据后,有其他事务已修改了该行(version 不再是 0),本次更新失败。应用层需要进行重试(重新读取、计算、更新)或向用户报错。

对比总结

特性悲观锁 (SELECT ... FOR UPDATE)乐观锁 (Version 字段)
数据一致性强一致性。在事务持有锁期间,数据状态绝对安全。最终一致性。提交时才发现冲突,可能需要重试才能达到一致。
并发性能差。锁定了数据行,其他写事务必须等待,降低了系统的吞吐量。高。无锁读取,只有在更新时才进行一次原子性检查,适合读多写少的场景。
实现复杂度数据库层面,实现简单,依赖数据库的锁机制。应用层面,需要应用自行实现版本号管理、冲突检测和重试逻辑。
系统开销数据库开销大。锁的获取、检查、释放都涉及系统资源。易产生死锁。应用开销大。冲突频繁时,应用层反复重试会消耗大量 CPU。
适用场景写多、冲突概率高。如秒杀、抢票、金融交易等,保证数据绝对正确是第一位的。读多、写少、冲突概率低。如编辑文章、修改用户信息等,可以容忍偶尔的更新失败。

常见低分回答

  1. SELECT ... FOR UPDATE 不在事务中:SELECT ... FOR UPDATE 如果没有被 BEGIN/COMMIT 包裹,会因为 MySQL 默认的 autocommit=1 模式导致语句执行完后锁立即释放,完全起不到并发控制的作用。
  2. 混淆锁的粒度:声称 SELECT ... FOR UPDATE 会锁表。在 InnoDB 中,如果 WHERE 条件命中了索引,它会施加行级锁(Row Lock);如果 WHERE 条件没有命中索引或索引选择性差,导致全表扫描,才会退化为表级锁,这是性能灾难。
  3. 对乐观锁重试机制含糊其辞:只说“乐观锁失败了就重试”,但没有说明重试的具体逻辑,如:是否需要重新读取数据、最大重试次数、重试的退避策略(如指数退避),这体现了候选人缺乏实际的工程经验。
  4. 认为乐观锁一定比悲观锁性能好:这是一个常见的误区。在写冲突非常严重的场景下,乐观锁会导致大量失败和重试,CPU 开销剧增,性能反而可能低于悲观锁。悲观锁虽然阻塞,但是一次性成功,总耗时可能更短。
  5. 忽略 SELECT ... FOR SHARE:只知道 FOR UPDATE(排他锁),不知道还有 FOR SHARE(共享锁)。后者允许多个事务同时读取并锁定同一行,但不允许任何事务修改它,适用于“读取数据并确保其不被修改,但允许其他事务读取”的场景。

工程实践与优化

  • 悲观锁的死锁问题:当多个事务以不同顺序请求锁资源时,容易产生死锁。

    • 解决方案:约定所有业务逻辑都以相同的顺序来获取锁。例如,更新多个商品库存时,统一按商品 ID 从小到大的顺序加锁。
    • 监控:通过 SHOW ENGINE INNODB STATUS 查看最新的死锁日志。InnoDB 有死锁检测机制,会自动回滚一个事务来解决死锁,但这会导致业务失败。
  • FOR UPDATE 与索引:执行 SELECT ... FOR UPDATE 时,务必通过 EXPLAIN 确认 WHERE 条件走了索引。type 字段应为 ref, eq_ref, range 等,而不是 ALL。Extra 字段不应出现 Using temporary 或 Using filesort。

  • 乐观锁的重试策略:

    • 次数限制:必须设置最大重试次数,防止无限重试导致死循环。
    • 失败回退:重试失败后,应有明确的业务逻辑,如提示用户“操作繁忙,请稍后再试”。
    • 指数退避:为避免多个失败的事务同时重试再次造成冲突,可以在每次重试前增加一个随机的等待时间(如 10ms, 20ms, 40ms...)。
  • ABA 问题:乐观锁可能存在 ABA 问题。即一个值从 A 变为 B,又变回 A。版本号机制可以天然避免此问题,因为 version 只会单向递增(A, v=0 -> B, v=1 -> A, v=2),版本号已经不同。如果使用时间戳,也基本能避免。

常见追问与延伸题目

  1. 追问:SELECT ... FOR SHARE 和 SELECT ... FOR UPDATE 有什么区别?分别用在什么场景?

    • 回答要点:
      • 锁类型不同:FOR SHARE 获取的是共享锁(S-Lock),FOR UPDATE 获取的是排他锁(X-Lock)。
      • 兼容性不同:多个事务可以同时持有同一行的 S-Lock,但只要有一个事务持有了 X-Lock,其他任何事务都不能再获取 S-Lock 或 X-Lock。S-Lock 与 X-Lock 互斥。
      • 场景:
        • FOR UPDATE:我要读取这个数据,并且接下来要修改它,在我修改完成前,不许任何人读(加锁读)或写。场景:典型的扣减库存。
        • FOR SHARE:我要读取这个数据,并确保在我事务结束前它不会被修改,但我允许别人也来读。场景:一个复杂的业务,需要先读取多个关联表的数据(如账户余额、用户等级),基于这些数据做计算,最后才写入一个结果表。在读取余额和等级时,可以用 FOR SHARE 锁住,防止它们在我计算期间被修改,从而保证计算结果的准确性。
  2. 追问:你刚才提到了 MVCC,它和我们讨论的锁机制是什么关系?

    • 回答要点:
      • MVCC (多版本并发控制) 是 InnoDB 引擎为实现高并发读写性能而设计的核心机制,主要服务于非锁定读(普通的 SELECT 语句)。它通过为每行数据维护多个版本(通过 undo log)来实现“读写不阻塞”。在 READ COMMITTED 和 REPEATABLE READ 隔离级别下,普通的 SELECT 读到的是一个数据快照,而不是最新的数据,因此不需要加锁。
      • 关系:MVCC 是默认的、乐观的并发策略,它让“读”操作变得非常快。而 SELECT ... FOR UPDATE/SHARE 是一种显式加锁的手段,它暂时覆盖了 MVCC 的非锁定读行为,强制从“快照读”切换为“当前读”,并施加悲观锁。可以说,MVCC 解决了“读-写”冲突,而我们讨论的锁机制主要用于解决“写-写”冲突。
  3. 追问:如果让你设计一个秒杀系统,你会选择悲观锁还是乐观锁?为什么?

    • 回答要点:
      • 首选悲观锁,但需要非常谨慎地使用。秒杀场景是典型的写多、冲突概率极高的场景。
      • 为什么不用乐观锁:在秒杀开始的瞬间,大量请求涌入,如果使用乐观锁,绝大多数更新都会因为 version 冲突而失败。应用层会进行大量无效的重试,这会急剧消耗应用服务器的 CPU,并对数据库造成大量无效的 UPDATE 尝试,性能会非常差,甚至拖垮整个系统。
      • 悲观锁方案:
        1. 库存预热:将库存加载到 Redis 中,利用 Redis 的原子操作(如 DECR)来处理绝大部分请求,这是业界主流方案,避免直接冲击数据库。
        2. 数据库层兜底:如果必须在数据库层面控制,也应该使用悲观锁。UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0; 这是一个原子操作,其本身就利用了数据库的行锁,是一种隐式的悲观锁。如果业务逻辑复杂,需要先 SELECT 再 UPDATE,则必须使用 SELECT ... FOR UPDATE。
      • 总结:秒杀场景下,乐观锁的重试成本远大于悲观锁的等待成本。最佳实践是利用缓存(如 Redis)在数据库上层挡住大部分流量,数据库层面作为最终一致性的保障,并采用悲观锁策略。