悲观锁与乐观锁的区别?分别什么场景使用?(SELECT FOR UPDATE vs version 字段)
Differences between pessimistic and optimistic locking, and their respective use cases (SELECT FOR UPDATE vs. version column
考察要点
这道题面试官想看你是否深刻理解数据库并发控制的两种核心思想,以及它们在工程实践中的权衡。具体来说,考察点包括:
- 并发控制哲学:是否理解悲观与乐观的本质区别,即“先加锁再访问” vs “先访问,提交时检查冲突”。
- SQL 实现能力:是否知道
SELECT ... FOR UPDATE的正确用法(必须在事务中),以及如何通过version字段在应用层实现乐观锁。 - 场景判断能力:能否根据业务场景(如:读写比、冲突概率、性能要求)选择合适的锁策略。
核心概念
悲观锁(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 表结构如下:
1CREATE TABLE products (2 id INT PRIMARY KEY,3 name VARCHAR(100),4 stock INT NOT NULL5) ENGINE=InnoDB;67INSERT INTO products VALUES (1, 'iPhone 15 Pro', 10);
操作流程:
1-- Transaction A (T_A)2BEGIN;34-- 1. 查询并锁定库存为 10 的商品,此时其他事务无法修改 id=1 的行5SELECT stock FROM products WHERE id = 1 FOR UPDATE;6-- (Application logic: check if stock > 0)78-- 2. 模拟业务处理耗时9-- DO_SOME_WORK();1011-- 3. 执行更新12UPDATE products SET stock = stock - 1 WHERE id = 1;1314COMMIT;
在 T_A COMMIT 之前,任何其他尝试 UPDATE products ... WHERE id = 1 或 SELECT ... FROM products WHERE id = 1 FOR UPDATE 的事务都会被阻塞。
2. 乐观锁 (Optimistic Locking)
乐观锁假定并发冲突的概率很低。它不会在数据读取时加锁,而是在数据提交更新时,通过一种机制来检查在此期间数据是否被其他事务修改过。如果被修改过,则更新失败,由应用层决定如何处理(如:重试、报错)。
核心思想:默认不冲突,提交时才检查。
实现方式:通常不是数据库原生的锁机制,而是一种应用层设计模式。
- 版本号(Version)字段:最常用。
- 时间戳(Timestamp)字段:原理类似。
数据示例:使用版本号扣减库存
首先,修改表结构,增加 version 字段。
1CREATE TABLE products (2 id INT PRIMARY KEY,3 name VARCHAR(100),4 stock INT NOT NULL,5 version INT NOT NULL DEFAULT 06) ENGINE=InnoDB;78INSERT INTO products VALUES (1, 'iPhone 15 Pro', 10, 0);
操作流程:
1-- Transaction A (T_A)2-- 1. 读取数据和版本号3-- SELECT id, stock, version FROM products WHERE id = 1;4-- 假设读到 stock=10, version=056-- (Application logic: check if stock > 0, calculate new_stock=9)78-- 2. 提交更新时,检查版本号是否匹配9UPDATE products10SET stock = 9, version = version + 111WHERE id = 1 AND version = 0; -- 关键的 CAS (Compare-And-Set) 操作1213-- 3. 检查更新影响的行数14-- 如果 affected_rows = 1,说明更新成功。15-- 如果 affected_rows = 0,说明在 T_A 读取数据后,有其他事务已修改了该行(version 不再是 0),本次更新失败。应用层需要进行重试(重新读取、计算、更新)或向用户报错。
对比总结
| 特性 | 悲观锁 (SELECT ... FOR UPDATE) | 乐观锁 (Version 字段) |
|---|---|---|
| 数据一致性 | 强一致性。在事务持有锁期间,数据状态绝对安全。 | 最终一致性。提交时才发现冲突,可能需要重试才能达到一致。 |
| 并发性能 | 差。锁定了数据行,其他写事务必须等待,降低了系统的吞吐量。 | 高。无锁读取,只有在更新时才进行一次原子性检查,适合读多写少的场景。 |
| 实现复杂度 | 数据库层面,实现简单,依赖数据库的锁机制。 | 应用层面,需要应用自行实现版本号管理、冲突检测和重试逻辑。 |
| 系统开销 | 数据库开销大。锁的获取、检查、释放都涉及系统资源。易产生死锁。 | 应用开销大。冲突频繁时,应用层反复重试会消耗大量 CPU。 |
| 适用场景 | 写多、冲突概率高。如秒杀、抢票、金融交易等,保证数据绝对正确是第一位的。 | 读多、写少、冲突概率低。如编辑文章、修改用户信息等,可以容忍偶尔的更新失败。 |
常见低分回答
SELECT ... FOR UPDATE不在事务中:SELECT ... FOR UPDATE如果没有被BEGIN/COMMIT包裹,会因为 MySQL 默认的autocommit=1模式导致语句执行完后锁立即释放,完全起不到并发控制的作用。- 混淆锁的粒度:声称
SELECT ... FOR UPDATE会锁表。在InnoDB中,如果WHERE条件命中了索引,它会施加行级锁(Row Lock);如果WHERE条件没有命中索引或索引选择性差,导致全表扫描,才会退化为表级锁,这是性能灾难。 - 对乐观锁重试机制含糊其辞:只说“乐观锁失败了就重试”,但没有说明重试的具体逻辑,如:是否需要重新读取数据、最大重试次数、重试的退避策略(如指数退避),这体现了候选人缺乏实际的工程经验。
- 认为乐观锁一定比悲观锁性能好:这是一个常见的误区。在写冲突非常严重的场景下,乐观锁会导致大量失败和重试,CPU 开销剧增,性能反而可能低于悲观锁。悲观锁虽然阻塞,但是一次性成功,总耗时可能更短。
- 忽略
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),版本号已经不同。如果使用时间戳,也基本能避免。
常见追问与延伸题目
-
追问:
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锁住,防止它们在我计算期间被修改,从而保证计算结果的准确性。
- 锁类型不同:
- 回答要点:
-
追问:你刚才提到了
MVCC,它和我们讨论的锁机制是什么关系?- 回答要点:
MVCC(多版本并发控制) 是InnoDB引擎为实现高并发读写性能而设计的核心机制,主要服务于非锁定读(普通的SELECT语句)。它通过为每行数据维护多个版本(通过undo log)来实现“读写不阻塞”。在READ COMMITTED和REPEATABLE READ隔离级别下,普通的SELECT读到的是一个数据快照,而不是最新的数据,因此不需要加锁。- 关系:
MVCC是默认的、乐观的并发策略,它让“读”操作变得非常快。而SELECT ... FOR UPDATE/SHARE是一种显式加锁的手段,它暂时覆盖了MVCC的非锁定读行为,强制从“快照读”切换为“当前读”,并施加悲观锁。可以说,MVCC解决了“读-写”冲突,而我们讨论的锁机制主要用于解决“写-写”冲突。
- 回答要点:
-
追问:如果让你设计一个秒杀系统,你会选择悲观锁还是乐观锁?为什么?
- 回答要点:
- 首选悲观锁,但需要非常谨慎地使用。秒杀场景是典型的写多、冲突概率极高的场景。
- 为什么不用乐观锁:在秒杀开始的瞬间,大量请求涌入,如果使用乐观锁,绝大多数更新都会因为
version冲突而失败。应用层会进行大量无效的重试,这会急剧消耗应用服务器的 CPU,并对数据库造成大量无效的UPDATE尝试,性能会非常差,甚至拖垮整个系统。 - 悲观锁方案:
- 库存预热:将库存加载到 Redis 中,利用 Redis 的原子操作(如
DECR)来处理绝大部分请求,这是业界主流方案,避免直接冲击数据库。 - 数据库层兜底:如果必须在数据库层面控制,也应该使用悲观锁。
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0;这是一个原子操作,其本身就利用了数据库的行锁,是一种隐式的悲观锁。如果业务逻辑复杂,需要先SELECT再UPDATE,则必须使用SELECT ... FOR UPDATE。
- 库存预热:将库存加载到 Redis 中,利用 Redis 的原子操作(如
- 总结:秒杀场景下,乐观锁的重试成本远大于悲观锁的等待成本。最佳实践是利用缓存(如 Redis)在数据库上层挡住大部分流量,数据库层面作为最终一致性的保障,并采用悲观锁策略。
- 回答要点: