在复杂的高并发后端业务场景中,数据库的并发控制是绕不开的核心命题。很多开发者对 MySQL 的事务隔离和锁机制停留在“背诵概念”的阶段,一旦遇到线上死锁或脏数据问题便无从下手。
本文将从宏观的隔离级别切入,一路深挖到 MVCC 和锁的底层原理,把 MySQL InnoDB 引擎处理并发的逻辑主线彻底串联起来。
一、四大隔离级别:从低到高的权衡
MySQL 通过设置不同的隔离级别,在“数据安全性”与“并发性能”之间做交易。从低到高依次为:
- RU(读未提交 - Read Uncommitted)
- 表现:能读到其他事务尚未提交的数据。
- 问题:存在严重的“脏读”问题。
- 底层:完全不使用 MVCC。
- RC(读已提交 - Read Committed)
- 表现:只能读到已经提交的数据,解决了脏读。
- 问题:存在“不可重复读”(同一个事务内两次查询,结果集的值可能被别人修改)。
- 底层:使用 MVCC。核心特征是每次执行 SQL 都会生成一个新的 Read View。
- RR(可重复读 - Repeatable Read)
- 表现:MySQL InnoDB 的默认级别。保证同一个事务内,多次读取到的数据状态是一致的。
- 问题:解决了脏读和不可重复读,并且在很大程度上防止了幻读。
- 底层:使用 MVCC + 间隙锁 / Next-Key Lock。核心特征是事务开始时生成一个 Read View,全程保持不变。
- Serial(串行化 - Serializable)
- 表现:完全串行执行,读写全部加互斥锁,没有任何并发可言。
- 问题:解决了所有读现象问题,但性能极差,生产环境几乎不使用。
- 底层:退化为纯锁并发,不使用 MVCC。
二、并发利器:MVCC(多版本并发控制)
在 RR 和 RC 级别下,MySQL 实现并发的核心机制就是 MVCC。
- 核心作用:读写分离。让“读”操作不加锁,读和写互不阻塞,极大提升并发性能。
- 底层基石:
- Undo Log(回滚日志):记录数据的历史版本,形成一条版本链。
- Read View(读视图):一个可见性判断的规则集合。通过对比当前事务 ID 与 Undo Log 版本链中的事务 ID,来决定当前事务能看到哪个历史版本的数据。
- Read View 本质上是事务运行时在内存里生成的一个快照结构,不会持久化到磁盘,事务结束就销毁。
三、查询的两副面孔:快照读 vs 当前读
同样是 SELECT,在 MySQL 中的执行逻辑可能完全不同,这取决于你用的是快照读还是当前读:
- 快照读(Snapshot Read)
- 场景:普通的
SELECT语句。 - 逻辑:读取 MVCC 版本链中的历史数据,完全不加锁。
- 注意:在 RR 级别下,快照读只能看到事务启动时的快照,即使其他事务提交了新数据也看不见(这就是可重复读的保证)。
- 场景:普通的
- 当前读(Current Read)
- 场景:
UPDATE/DELETE/INSERT,以及加锁查询SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE。 - 逻辑:强制读取磁盘上最新已提交的数据,并且必须加锁。
- 场景:
四、经典易混淆:不可重复读 vs 幻读
这两个概念极易混淆,核心区别在于数据变化的类型:
- 不可重复读:侧重于修改(UPDATE)。同一个事务内,同一行数据前后的值不一样。
- 幻读:侧重于新增或删除(INSERT / DELETE)。同一个事务内,按相同条件查询,前后的行数不一样(多了或少了行)。
- 注:幻读的本质问题在于,你无法锁住那些还不存在的行,因此单纯的行锁无法完全根除幻读,必须引入间隙锁。
五、InnoDB 的锁体系架构
为了在当前读下保证数据一致性并防止幻读,InnoDB 设计了细粒度的锁机制:
- 行级锁家族(锁的具体范围):
- 行锁(Record Lock):精准锁住索引上的某一行。
- 间隙锁(Gap Lock):锁住两个索引记录之间的“空白区间”,严禁其他事务往这个区间插入新数据。
- Next-Key Lock:行锁 + 间隙锁的组合体。锁住某一行以及它前面的间隙。这是 RR 级别下防幻读的默认武器。
- 读写锁分类(锁的互斥性):
- 共享锁(S锁 / 读锁):允许多个事务同时读取,但全都不能修改。
- 排他锁(X锁 / 写锁):独占锁。只要加上 X 锁,别人既不能读(当前读)也不能写。
FOR UPDATE就是典型的加 X 锁。
- 表级辅助锁:
- 意向锁(IS / IX):用于协调表锁和行锁的冲突。当要在某行加锁前,必须先在表上加意向锁,这样如果有人想锁整张表,就不需要逐行去扫描了。
- 插入意向锁:一种特殊的间隙锁,专门在插入数据前使用,多个事务向同一个间隙的不同位置插入数据时,互相不阻塞。
- 自增锁(Auto-inc Locks):保证自增主键 ID 的连续性和唯一性。
补充:为什么“不走索引”更容易锁表、死锁?
一句话先记住:InnoDB 锁的是索引,不是数据行。查不到精确索引,就会退化为范围锁(Gap Lock / Next-Key Lock)。
- 走主键/唯一索引:可以精确定位记录,通常只锁命中行,锁冲突范围小。
- 不走有效索引:无法精确定位,容易扩大为间隙锁或临键锁,甚至看起来像“锁了一大片”,阻塞与死锁概率都会上升。
- 和 S 锁死锁的关系:当多个事务在大范围上持有兼容的 S 锁后,再尝试升级到 X 锁,等待环更容易形成。
这也是线上排查锁等待时最常见的第一检查项:加锁 SQL 的 WHERE 条件是否命中有效索引。
六、FOR UPDATE 的真实面目
当我们执行 SELECT ... FOR UPDATE 时,到底发生了什么?
- 它是一个典型的当前读,直接越过 MVCC 读取最新提交的数据。
- 它会给命中的记录加上排他锁(X锁)。
- 在 RR 隔离级别下,它不仅锁住命中行,还会触发间隙锁(Gap Lock)或 Next-Key Lock,防止其他事务插入干扰数据。
- 视觉错觉:在 RR 级别下执行
FOR UPDATE,你会发现它居然能读到别的事务刚提交的新数据——这看起来像是降级成了 RC,但这其实只是“当前读”的正常表现,代价是持有沉重的锁。
七、开发频率真相:X 锁很常用,S 锁很少用
很多人误以为 X 锁只在手写 FOR UPDATE 时才会出现。实际上,日常开发里最常用的锁就是 X 锁,且大部分由数据库自动加。
- 自动加 X 锁(最高频)
UPDATE/DELETE/INSERT/REPLACE在执行时,InnoDB 会自动对相关记录加排他锁。- 结论:只要有增删改,就一定在使用 X 锁。
- 手动加 X 锁(核心业务高频)
- 语句:
SELECT ... FOR UPDATE。 - 典型场景:库存扣减、余额扣款、订单状态机流转、任何“先读判断再更新”的逻辑。
- 目的:把并发检查和后续更新串成一个原子流程,避免超卖、超扣与脏覆盖。
- 语句:
- S 锁(共享锁)在业务中较少直接使用
LOCK IN SHARE MODE在真实项目里远少于FOR UPDATE。- 原因:更容易形成阻塞链或升级死锁,很多场景可由 MVCC 快照读或
FOR UPDATE替代。
一条实用边界:只读展示、允许读到历史一致快照,用普通 SELECT;只要后续要基于这次读取结果做更新决策,就用 FOR UPDATE。
八、实战抉择:RC 还是 RR?
面试经常被问到:“既然 MySQL 默认是 RR,为什么阿里、美团等互联网大厂通常会把默认隔离级别改成 RC?”
答案就藏在前面的机制里:
- RR 的代价:事务级别的 Read View 导致长事务可能拖垮库;为了防幻读引入了大量间隙锁,极易引发死锁和高并发下的严重阻塞。
- RC 的优势:语句级的 Read View 保证了数据的实时性(数据新);基本没有间隙锁(只有在极少数特定约束检查时才会用),大大降低了死锁概率,并发性能显著提升。
在绝大多数互联网业务场景中,我们更看重并发吞吐量,而“不可重复读”的问题完全可以在业务代码中通过乐观锁(如 CAS 版本号机制)来解决。
Leave a comment