文章目录
一、 核心概念分类梳理与解释
1. 读的类型 (Read Type)
概念 | 解释 |
---|---|
快照读 (Snapshot Read) | 读取的是记录在某个时间点的快照(通常是事务开始时的版本),而不是最新的数据。这种读不加锁,因此是非阻塞的。普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下就是快照读。 |
当前读 (Current Read) | 读取的是记录的最新版本,并且会为读取到的数据加锁,保证其他事务无法并发修改这些数据。SELECT ... FOR UPDATE 、SELECT ... LOCK IN SHARE MODE 、UPDATE 、DELETE 、INSERT 等操作都属于当前读。 |
2. 并发问题 (Concurrency Problems)
概念 | 解释 |
---|---|
脏读 (Dirty Read) | 一个事务读到了另一个未提交事务修改的数据。如果那个事务回滚,那么第一个事务读到的数据就是无效的(“脏”数据)。 |
不可重复读 (Non-Repeatable Read) | 在同一个事务中,两次执行相同的查询,读到了不同的数据。这通常是因为在两次查询之间,另一个已提交的事务修改或删除了该数据。 |
幻读 (Phantom Read) | 在同一个事务中,两次执行相同的范围查询,看到了不同数量的行。这通常是因为在两次查询之间,另一个已提交的事务插入了新的、符合该查询条件的数据。注意:不可重复读针对的是“同一行”数据的值变化,幻读针对的是“多行”数据的存在性变化。 |
3. 锁的类型 (Lock Type)
概念 | 解释 |
---|---|
共享锁 (Shared Lock, S Lock) | 又称为读锁。事务读取数据时加的锁。多个事务可以同时持有同一数据的共享锁(允许并发读),但不能有任何事务持有该数据的排它锁。 |
排它锁 (Exclusive Lock, X Lock) | 又称为写锁。事务修改数据时加的锁。一个事务持有某数据的排它锁后,其他事务不能为该数据加任何锁(共享锁或排它锁),直到该锁被释放。 |
意向共享锁 (Intention Shared Lock, IS Lock) | 表级锁。表示事务打算给表中的某些行加共享锁(S Lock)。 |
意向排它锁 (Intention Exclusive Lock, IX Lock) | 表级锁。表示事务打算给表中的某些行加排它锁(X Lock)。 |
行锁 (Record Lock) | 锁住索引记录。例如,SELECT * FROM t WHERE id = 1 FOR UPDATE; 会在 id=1 的索引记录上加一个行锁,防止其他事务修改或加锁。 |
间隙锁 (Gap Lock) | 锁住索引记录之间的间隙,防止其他事务在间隙中插入新数据,从而有效防止幻读。例如,SELECT * FROM t WHERE id BETWEEN 5 AND 10 FOR UPDATE; 不仅会锁住 id=5 和 10 的记录,还会锁住 (5, 10) 这个区间,防止插入 id=6,7,8,9 的新记录。 |
临键锁 (Next-Key Lock) | 行锁 + 间隙锁 的组合。它既锁住记录本身,也锁住该记录之前的间隙。InnoDB 默认使用临键锁来保证 REPEATABLE READ 隔离级别下的可重复读和防止幻读。例如,如果一个表有 id=10, 20, 30 的记录,那么临键锁可能锁定的区间是:(-∞, 10] , (10, 20] , (20, 30] , (30, +∞) 。 |
意向锁作用:为了更高效地协调行锁和表锁之间的关系。例如,事务A想给某行加排他锁(X),它会先申请表级的意向排他锁(IX)。如果另一个事务B已经持有了表锁(如 LOCK TABLES ... WRITE
),它就能通过查看表上是否有意向锁来判断是否可以立即授予表锁,而无需逐行检查。
二、 事务隔离级别详解与InnoDB解决方案
SQL标准定义了4个隔离级别,隔离级别越低,并发性能越高,但可能出现的并发问题越多。下表详细说明了InnoDB的实现机制和各操作下的加锁行为。
隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 的实现方式、加锁行为与存在的问题 |
---|---|---|---|---|
读未提交 (Read Uncommitted) |
可能 | 可能 | 可能 | 实现:直接读取数据的最新版本,无MVCC隔离。 加锁行为:写操作(INSERT/UPDATE/DELETE)仍会加排他锁(X Lock)。快照读 不加锁,直接读最新版本(可能脏读)。当前读 加锁。 问题:所有并发问题都可能发生。性能极差,几乎不使用。 |
读已提交 (Read Committed, RC) |
不可能 | 可能 | 可能 | 实现:通过MVCC实现。每个快照读(普通SELECT)都会生成一个新的ReadView,所以每次读到的都是最新已提交的数据版本。 加锁行为: - 快照读:不加锁,利用MVCC。 - 当前读 (SELECT FOR UPDATE/UPDATE/DELETE):只加行锁,不加间隙锁。这导致其他事务可以插入新的数据,从而可能发生幻读。 - INSERT:插入意向锁(一种特殊的间隙锁),但通常不会阻塞其他插入。 问题:解决了脏读,但不可重复读和幻读仍然可能发生。 |
可重复读 (Repeatable Read, RR) |
不可能 | 不可能 | InnoDB下不可能 | 实现:通过MVCC + 临键锁(Next-Key Locking)实现。 1. MVCC:事务中的第一个快照读会创建一个ReadView,后续所有快照读都复用这个ReadView,保证数据视图一致。 2. 临键锁:在进行当前读时,使用临键锁来锁住扫描到的索引范围和记录。 加锁行为: - 快照读:不加锁,利用MVCC。 - 当前读 (SELECT FOR UPDATE/UPDATE/DELETE):加临键锁(Next-Key Lock),即行锁+间隙锁,防止其他事务修改或插入,从而解决幻读。 - INSERT:检查插入意向锁,如果目标间隙被其他事务加了间隙锁,则阻塞等待。 问题:由于引入了间隙锁,锁的范围更大,锁冲突和死锁的概率比RC级别更高。 |
串行化 (Serializable) |
不可能 | 不可能 | 不可能 | 实现:将所有普通的SELECT 语句都隐式转换为SELECT ... LOCK IN SHARE MODE ,即所有读操作都加共享锁。加锁行为: - 快照读 失效,所有读都变为当前读并加共享锁(S Lock)。读写、写写严重互斥。 问题:完全牺牲并发性能,如同单线程执行,几乎不使用。 |
总结:InnoDB如何解决并发问题?
- 解决脏读:MVCC机制。事务只能读到已提交的数据版本。
- 解决不可重复读:MVCC机制。RR级别下,一个事务内使用同一个ReadView,保证看到的数据一致性。
- 解决幻读:临键锁(Next-Key Lock)。RR级别下,当前读操作通过间隙锁和行锁的组合,锁住可能被插入新数据的范围,从根本上杜绝了幻读。
三、 MVCC 与 Undo Log 原理
1. 核心组件
Undo Log (回滚日志):记录了数据被修改之前的值(旧版本)。主要用于:
- 事务回滚:当事务需要回滚时,可以用Undo Log将数据恢复到修改前的状态。
- 实现MVCC:当需要读取旧版本数据时,通过Undo Log可以构建出该数据的历史版本。
Read View (读视图):事务进行快照读时产生的一致性视图。它决定了当前事务能看到哪个版本的数据。关键属性包括:
m_ids
:生成ReadView时,系统中活跃(未提交)的事务ID列表。min_trx_id
:m_ids
中的最小值。max_trx_id
:生成ReadView时,系统应该分配给下一个事务的ID。creator_trx_id
:创建该ReadView的事务ID。
2. MVCC 工作原理 (以RR级别为例)
InnoDB每行数据包含一些隐藏字段:
DB_TRX_ID
(6字节):最近一次修改该行数据的事务ID。DB_ROLL_PTR
(7字节):回滚指针,指向该行数据在Undo Log中的上一个历史版本。
数据版本链:通过DB_ROLL_PTR
,一行数据的所有历史版本可以被连接成一个链表。
版本可见性规则:
当一行数据被访问时,MVCC会根据当前事务的ReadView,沿着数据版本链从最新版本开始逐个判断:
- 如果版本
trx_id
==creator_trx_id
,说明是该事务自己修改的,可见。 - 如果版本
trx_id
<min_trx_id
,说明该版本在ReadView创建前已提交,可见。 - 如果版本
trx_id
>=max_trx_id
,说明该版本在ReadView创建后才开启,不可见。 - 如果版本
trx_id
在m_ids
中,说明该版本是由ReadView创建时还活跃的事务修改的,不可见。 - 如果不可见,则通过
DB_ROLL_PTR
找到上一个历史版本,重新执行判断规则,直到找到可见的版本或到达链尾。
RR vs RC在MVCC下的区别:
- RR:事务开始时第一次快照读生成一个ReadView,之后一直复用这个ReadView。
- RC:每次快照读都会重新生成一个新的ReadView。
四、 可重复读 (RR) 隔离级别下的 SQL 开发规范
为了在享受RR级别高一致性的同时,避免潜在的锁竞争和性能问题,请遵循以下规范:
规范类别 | 具体建议 | 原因说明 |
---|---|---|
事务设计 | 1. 保持事务短小精悍。尽快提交事务。 | 长事务会长时间持有锁和ReadView,阻塞其他操作,并导致Undo Log膨胀,影响性能。 |
2. 避免在事务中执行用户交互或外部调用。 | 这会使事务保持打开状态很长时间,是长事务的罪魁祸首。 | |
查询操作 | 3. 明确查询意图。如果业务需要最新的数据,使用当前读(SELECT ... FOR UPDATE );如果不需要,使用默认的快照读。 |
滥用FOR UPDATE 会导致不必要的锁竞争,降低并发性。 |
4. 为查询条件建立合适的索引。尤其重要! | 特别是范围查询,如果没有索引,临键锁会退化为锁住整个表的所有记录和间隙,极易导致死锁和性能骤降。 | |
更新/删除操作 | 5. 按索引操作。UPDATE 和DELETE 语句的WHERE 条件必须使用索引列。 |
否则会进行全表扫描,不仅性能差,还会给所有记录及其间隙加锁,相当于锁表,风险极高。 |
6. 批量操作分批次进行。 | 大批量操作会持有大量锁,占用大量Undo Log。分批次(如每次处理1000条)提交可以缓解问题。 | |
锁竞争与死锁 | 7. 以固定的顺序访问多个资源。 | 例如,多个事务都需要更新A和B两条记录,都约定先更新A再更新B,可以避免循环等待带来的死锁。 |
8. 监控和处理死锁。应用程序需要对 Deadlock 错误进行重试。 |
RR级别下间隙锁的引入使得死锁更易发生,应用层必须有容错机制。 | |
9. 尽量使用等值查询而非范围查询。 | 范围查询的锁范围更大,锁冲突概率更高。在业务允许的情况下,优先使用等值查询。 |
核心思想:理解快照读和当前读的区别,善用索引,并极力避免长事务。RR级别下的锁机制更为复杂,规范的目的是在利用其强一致性优势的同时,尽量减少其带来的锁开销和死锁风险。