在 MySQL(特别是 InnoDB 引擎)中,锁定读(Locking Read)和非锁定读(Non-Locking Read)是处理并发事务时两种截然不同的读取策略。它们的核心区别在于读取数据时是否会对行或索引加锁,从而影响其他事务的写入操作。
一、非锁定读(Non-Locking Read)
这是 MySQL 默认的读取行为,也是大多数 SELECT 语句的执行方式。
1. 核心机制:MVCC
非锁定读依赖于 多版本并发控制(MVCC, Multi-Version Concurrency Control)。
当事务执行普通 SELECT 时,InnoDB 不会给数据行加任何锁。
它通过读取数据的历史版本快照(Snapshot)来保证一致性。
具体读取哪个版本取决于当前的事务隔离级别:
读已提交(RC):读取该行最新已提交的版本。
可重复读(RR):读取事务启动时刻生成的快照版本。
2. 特点
高并发:读操作不会阻塞写操作,写操作也不会阻塞读操作(除非涉及DDL等元数据锁)。
无等待:不需要等待其他事务释放锁。
潜在问题:在 RC 级别下可能出现"不可重复读";在 RR 级别下虽然解决了不可重复读,但在特定场景下(如范围查询插入新数据)仍可能遇到幻读(尽管 InnoDB 通过 Next-Key Lock 尽量规避,但普通 SELECT 本身不加锁,无法阻止其他事务插入新行,只是自身看不到而已)。
3. 适用场景
绝大多数业务查询场景。
对数据实时性要求不高,或者能容忍短暂不一致的场景。
高并发读取场景。
二、锁定读(Locking Read)
锁定读是指显式地在 SELECT 语句中加入锁提示,强制数据库在读取数据时获取锁,从而阻塞其他事务的修改或读取。
1. 两种主要类型
| 语法 | 锁类型 | 作用 | 阻塞情况 |
|---|---|---|---|
| SELECT ... FOR SHARE (MySQL 8.0+) LOCK IN SHARE MODE (旧版) | 共享锁 (S锁) | 允许其他事务读取该行,但禁止其他事务修改或删除该行。 | 其他事务的写操作会被阻塞,直到当前事务提交。 |
| SELECT ... FOR UPDATE | 排他锁 (X锁) | 禁止其他事务读取(锁定读)、修改或删除该行。 | 其他事务的读写操作(锁定读)都会被阻塞。 |
2. 核心机制
锁定读会绕过 MVCC 的快照读取,直接读取最新已提交的数据,并立即对该数据行加上相应的锁。
如果其他事务已经持有了冲突的锁,当前事务会进入等待状态,直到锁释放或超时。
3. 适用场景
数据一致性要求极高:需要确保在读取后、更新前,数据不被其他事务修改。
业务逻辑依赖当前值:例如"检查库存是否充足,若充足则扣减",必须使用 FOR UPDATE 防止超卖。
外键约束检查:InnoDB 在检查外键约束时,会自动对引用的父表行加共享锁。
去重插入:在插入前先查询是否存在,若存在则更新,需加锁防止并发插入重复数据。
三、对比总结
| 维度 | 非锁定读 (普通 SELECT) | 锁定读 (FOR SHARE / FOR UPDATE) |
|---|---|---|
| 是否加锁 | 否 | 是 (S锁或X锁) |
| 底层技术 | MVCC (快照读) | 当前读 (Current Read) + 锁机制 |
| 并发性能 | 高,读写互不阻塞 | 低,存在锁竞争和等待 |
| 数据实时性 | 可能读到旧版本 (取决于隔离级别) | 总是读到最新已提交数据 |
| 阻塞其他事务 | 不阻塞 | 阻塞其他事务的写 (S/X) 或读写 (X) |
| 典型用途 | 日常查询、报表统计 | 转账、扣库存、唯一性校验 |
四、实战示例
1. 非锁定读(默认)
java
-- 事务 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 此时事务 B 可以修改 id=1 的 balance,事务 A 不受影响,且看到的是快照数据
COMMIT;
2. 锁定读(排他锁)
java
-- 事务 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 此时事务 B 如果想执行 UPDATE accounts SET balance=... WHERE id=1;
-- 或者 SELECT ... FOR UPDATE,事务 B 会被阻塞,直到事务 A 提交或回滚
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
3,举例说明:
1、场景设定
假设有一张商品表 products,其中 id=1 的商品库存 stock 为 10。
事务 A:查询库存并准备扣减。
事务 B:试图修改同一商品的库存。
2、非锁定读示例(普通 SELECT)
2.1执行流程
| 时间 | 事务 A (隔离级别 RR) | 事务 B | 状态说明 |
|---|---|---|---|
| T1 | START TRANSACTION; | 事务 A 开启 | |
| T2 | SELECT stock FROM products WHERE id=1; | 非锁定读:读取快照,不加锁 | |
| T3 | UPDATE products SET stock=5 WHERE id=1; | 成功:因为 A 没加锁,B 可以直接修改并提交 | |
| T4 | COMMIT; | 事务 B 提交,库存变为 5 | |
| T5 | SELECT stock FROM products WHERE id=1; | 仍返回 10:RR 级别下,A 看到的是 T1 时刻的快照 | |
| T6 | UPDATE products SET stock=stock-1 WHERE id=1; | 成功:A 基于快照值 10 计算,最终库存变为 9 | |
| 2.2. 结果分析 | |||
| 问题:发生了丢失更新。事务 B 将库存改为 5,但事务 A unaware,基于旧值 10 扣减后变成 9,导致 B 的修改被覆盖。 | |||
| 特点:读写互不阻塞,并发高,但数据一致性需应用层保证(如乐观锁)。 |
3、锁定读示例(SELECT ... FOR UPDATE)
3.1. 执行流程
| 时间 | 事务 A (隔离级别 RR) | 事务 B | 状态说明 |
|---|---|---|---|
| T1 | START TRANSACTION; | 事务 A 开启 | |
| T2 | SELECT stock FROM products WHERE id=1 FOR UPDATE; | 锁定读:读取最新值 10,并加排他锁 (X锁) | |
| T3 | UPDATE products SET stock=5 WHERE id=1; | 阻塞:B 试图获取 X 锁,但被 A 持有,进入等待 | |
| T4 | UPDATE products SET stock=stock-1 WHERE id=1; | 成功:A 基于当前值 10 扣减,库存变为 9 | |
| T5 | COMMIT; | 事务 A 提交,释放锁 | |
| T6 | (解除阻塞) UPDATE ... 执行 | 成功:B 获取锁,基于 A 提交后的值 9 继续操作 |
3.2. 结果分析
优势:保证了串行化效果。事务 B 必须等待 A 完成,确保了 A 读取和写入期间数据不被干扰。
特点:数据强一致,但并发性能低,存在锁等待风险。
4、核心区别总结
| 维度 | 非锁定读 (Snapshot Read) | 锁定读 (Current Read) |
|---|---|---|
| SQL 语句 | SELECT ... | SELECT ... FOR UPDATE / FOR SHARE |
| 是否加锁 | 否,依赖 MVCC 快照 | 是,直接对索引行加锁 |
| 读取内容 | 历史版本快照 (可能非最新) | 数据库最新已提交数据 |
| 并发影响 | 不阻塞其他事务的写操作 | 阻塞其他事务的写 (或读) 操作 |
| 典型后果 | 可能出现不可重复读或丢失更新 | 确保数据在读写期间的一致性 |
| 适用建议 | 大多数查询场景 | 涉及资金、库存等关键资源的读写序列 |
5、避坑指南
索引必须命中:如果 SELECT ... FOR UPDATE 没有走索引(如 WHERE name='xxx' 且无索引),InnoDB 会退化为全表锁,导致整个表无法被其他事务修改,严重拖垮系统。
死锁预防:多个事务以不同顺序锁定多行数据时极易死锁。建议业务层固定加锁顺序,或设置合理的超时时间。
RR 级别的间隙锁:在可重复读级别下,锁定读不仅锁住行,还可能锁定间隙(即防止其他事务插入新数据),这在处理范围查询时需特别注意,可能意外阻塞正常的插入操作
五、注意事项
死锁风险:锁定读容易引发死锁。例如,事务 A 锁定了行 1 等待行 2,事务 B 锁定了行 2 等待行 1。务必保持加锁顺序一致,或设置合理的 innodb_lock_wait_timeout。
索引的重要性:锁定读必须通过索引来定位行。如果查询没有走索引(全表扫描),InnoDB 可能会锁定整个表的所有行(或大量间隙锁),导致严重的性能下降甚至系统瘫痪。
隔离级别影响:
在 RC 级别下,FOR UPDATE 只锁住满足条件的行。
在 RR 级别下,FOR UPDATE 除了锁住行,还可能加上间隙锁(Gap Lock)或Next-Key Lock,以防止幻读。这意味着即使某些行不存在,也可能被锁定,导致其他事务无法插入这些数据。
MySQL 8.0 变化:推荐使用 FOR SHARE 替代旧的 LOCK IN SHARE MODE,语义更清晰,且符合 SQL 标准。