数据库事务的隔离级别用于控制并发事务之间的相互影响,不同的隔离级别可以防止不同的并发问题。图片中的表格清晰地展示了四种隔离级别与三个常见并发问题(脏读、不可重复读、幻读)之间的关系。下面我将详细解释这些概念以及表格的含义。
一、三个并发问题
- 脏读(Dirty Read)
- 发生场景:一个事务读取了另一个事务尚未提交的数据。如果那个事务后来回滚,那么第一个事务读取到的数据就是无效的、脏的。
- 示例:事务A将某行数据从1改为2,但尚未提交;事务B读取到该行数据为2。随后事务A回滚,数据恢复为1,但事务B已经使用了错误的2。
- 不可重复读(Non-repeatable Read)
- 发生场景:在一个事务内,两次读取同一行数据,却得到了不同的结果。这是因为在两次读取之间,另一个事务修改了该行数据并提交了。
- 示例:事务A第一次读取某行数据为1;事务B将该行修改为2并提交;事务A再次读取同一行,得到2,导致两次读取不一致。
- 幻读(Phantom Read)
- 发生场景:在一个事务内,两次执行相同的范围查询(如
SELECT * FROM table WHERE condition),第二次查询返回了第一次没有看到的行(或少了行)。这是因为在两次查询之间,另一个事务插入或删除了符合条件的数据并提交。 - 示例:事务A查询所有年龄大于20的员工,得到10条记录;事务B插入一条年龄为25的新员工并提交;事务A再次执行相同查询,得到11条记录,仿佛出现了"幻影"行。
- 发生场景:在一个事务内,两次执行相同的范围查询(如
二、四种隔离级别
SQL标准定义了四种隔离级别,级别越高,并发控制越严格,但并发性能也越差。
- 读未提交(Read Uncommitted)
- 特点:允许一个事务读取到其他事务尚未提交的数据。这是最宽松的级别,几乎不做任何隔离。
- 可能的问题:脏读、不可重复读、幻读都可能发生。
- 表格对应:该级别下三个问题均为"可能"。
- 读已提交(Read Committed)
- 特点:一个事务只能读取到其他事务已经提交的数据。这避免了脏读,因为未提交的数据不会被读到。
- 可能的问题:由于只关注提交的数据,但其他事务可以在两次读取之间修改并提交数据,因此不可重复读和幻读仍然可能发生。
- 表格对应:脏读"不可能",不可重复读和幻读"可能"。
- 可重复读(Repeatable Read)
- 特点:在一个事务内,多次读取同一数据会得到相同的结果,即禁止了不可重复读。通常通过锁定读取的行或使用多版本并发控制(MVCC)来实现。
- 注意:根据SQL标准,可重复读仍然允许幻读,因为它只锁定读取的行,而不锁定范围,所以其他事务可以插入新行导致范围查询结果变化。
- 表格对应:脏读和不可重复读"不可能",幻读"可能"。
- 补充:某些数据库(如MySQL的InnoDB引擎)在可重复读级别下通过间隙锁(gap lock)也防止了幻读,但标准定义中幻读仍可能发生。
- 串行化(Serializable)
- 特点:强制事务串行执行,即一个事务一个接一个地运行,完全避免了并发冲突。通常通过锁表或使用更严格的锁机制实现。
- 可能的问题:所有三个问题都不会发生,因为事务之间没有并发干扰。
- 表格对应:三个问题均为"不可能"。
- 实际应用:由于串行化极大地限制了并发性能,几乎不会在生产环境中使用,除非对数据一致性要求极高且并发量极低。
三、表格解读
表格的行是隔离级别(从上到下越来越严格),列是三个问题,单元格表示在该隔离级别下问题是否可能发生:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不可能 | 可能 | 可能 |
| 可重复读 | 不可能 | 不可能 | 可能 |
| 串行化 | 不可能 | 不可能 | 不可能 |
- 读未提交:所有问题都可能,因为事务可以读到未提交数据,且其他事务的修改和插入都会影响当前事务。
- 读已提交:脏读被防止,但其他事务的提交修改会导致不可重复读,插入新行会导致幻读。
- 可重复读:通过保证同一行数据的一致性,防止了不可重复读,但范围查询仍可能因新行插入而出现幻读。
- 串行化:所有问题都被防止,但代价是并发性能极低。
总结:
- 隔离级别与问题关系:隔离级别越高,能防止的问题越多,数据一致性越好,但并发性能越差。实际开发中需要根据业务需求权衡一致性和性能。
- 常见选择:大多数数据库默认使用读已提交(如Oracle、PostgreSQL)或可重复读(如MySQL)。串行化几乎不用,因为太慢。
- 数据库实现差异:不同数据库对隔离级别的实现可能略有不同,例如MySQL的InnoDB在可重复读下通过间隙锁避免了幻读,但标准定义中幻读仍然存在。理解标准有助于跨数据库设计。
解释"可重复读"隔离级别的实现机制,并通过具体的例子帮助你理解为什么它只能防止不可重复读,而不能防止幻读(标准定义下),以及MySQL的InnoDB是如何通过间隙锁来额外防止幻读的。
可重复读的实现方式:锁定行 与 MVCC
可重复读隔离级别的核心目标是:保证在一个事务内,多次读取同一行数据,得到的结果始终一致 。
它通常通过两种方式实现:
-
基于锁的实现
- 事务在读取某行时,会对该行加共享锁(S锁),这样其他事务就不能修改这行(直到当前事务结束),从而保证后续读取相同行时值不变。
- 如果事务要修改数据,则加排他锁(X锁)。
-
基于多版本并发控制(MVCC)的实现
- 数据库为每一行维护多个历史版本。每个事务在开始时会看到一个快照(snapshot),即该时刻所有已提交数据的版本。事务内的所有读取操作都基于这个快照,因此多次读取同一行会看到相同的内容,即使其他事务修改了数据并提交,也不会影响当前事务的快照。
- MVCC 避免了加锁带来的性能开销,是许多现代数据库(如Oracle、PostgreSQL、MySQL InnoDB)的默认实现方式。
另外一提什么叫共享锁和排它锁
共享锁(Shared Lock,简称 S 锁)和排它锁(Exclusive Lock,简称 X 锁)是数据库管理系统中两种最基本的锁类型,用于控制并发事务对同一数据的访问,保证数据的一致性和隔离性。
1. 共享锁(S 锁)
- 含义:共享锁又称为"读锁"。当事务对某条数据加上共享锁后,该事务可以读取这条数据,但不能修改它。
- 特性 :
- 多个事务可以同时在同一数据上持有共享锁。也就是说,共享锁之间是兼容的。
- 只要有至少一个共享锁存在,其他事务就不能对该数据加排它锁(即不能修改),但可以继续加共享锁。
- 用途:主要用于读取操作,确保在读取过程中数据不会被其他事务修改,但允许其他事务同时读取。
例子:想象一本公共图书馆的书。多个读者可以同时阅读同一本书(共享锁),彼此不干扰,但谁也不能在书上涂改(排它锁被阻止)。
2. 排它锁(X 锁)
- 含义 :排它锁又称为"写锁"。当事务对某条数据加上排它锁后,该事务可以读取 和修改这条数据。
- 特性 :
- 排它锁与其他任何锁(包括共享锁和其他排它锁)都不兼容。
- 一旦一个事务获得排它锁,其他事务不能再对该数据加任何类型的锁(既不能读也不能写),直到排它锁被释放。
- 用途:用于修改操作(INSERT、UPDATE、DELETE),保证在修改过程中数据不会被其他事务干扰。
例子:假设一位作家正在修改图书馆里的一本书(排它锁),此时其他读者既不能阅读这本书(不能加共享锁),其他作家也不能同时修改(不能加排它锁)。只有等这位作家合上书离开,别人才可以访问。
3. 锁的兼容性矩阵
| 锁类型 | 共享锁(S) | 排它锁(X) |
|---|---|---|
| 共享锁(S) | ✅ 兼容 | ❌ 冲突 |
| 排它锁(X) | ❌ 冲突 | ❌ 冲突 |
- 共享锁之间:多个共享锁可以同时存在,因为只读不写,不会破坏数据一致性。
- 排它锁与任何锁:排它锁必须独占资源,因此与任何其他锁都冲突。
举例说明可重复读如何防止不可重复读
场景:可重复读(基于锁的实现)如何工作
假设事务 A 在可重复读级别下执行:
sql
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 第一次读取,对 id=1 的行加共享锁,并保持
-- 此时事务 B 试图执行:UPDATE account SET balance = 200 WHERE id = 1;
-- 事务 B 会被阻塞,因为 id=1 的行已被事务 A 加共享锁,事务 B 需要排它锁,但无法获得
SELECT balance FROM account WHERE id = 1; -- 第二次读取,仍然是 100
COMMIT; -- 事务 A 提交,释放所有锁
-- 事务 B 的 UPDATE 现在可以继续执行(如果还没超时)
在事务 A 的整个生命周期内,id=1 的行一直被共享锁保护,其他事务无法修改,因此多次读取结果一致。这就是可重复读。
如果事务 A 在第一次读取后就释放了共享锁,那么事务 B 就能立即修改,导致不可重复读------这实际上是读已提交。
场景:可重复读级别下,使用 MVCC 防止不可重复读
假设有一张表 account,包含以下数据:
| id | balance |
|---|---|
| 1 | 100 |
| 2 | 200 |
现在有两个并发事务:事务A和事务B。
-
事务A开始 (假设获取快照,看到 balance(1)=100, balance(2)=200)
-
事务A 第一次查询 id=1 的余额:
sql
SELECT balance FROM account WHERE id = 1; -- 结果 100 -
此时事务B开始并修改 id=1 的余额为 150,并提交:
sql
UPDATE account SET balance = 150 WHERE id = 1; COMMIT; -
事务A 第二次查询 id=1 的余额:
sql
SELECT balance FROM account WHERE id = 1; -- 结果仍然是 100(基于快照)
结果 :事务A两次读取同一行得到相同结果(100),不可重复读被防止了。
关键:MVCC 让事务A始终看到自己开始时的快照,不受事务B提交的影响。
如果用行锁实现:事务A第一次读取时对 id=1 的行加共享锁,事务B要修改这行会被阻塞,直到事务A结束,这样也能保证两次读取一致。
为什么可重复读仍然允许幻读(标准定义)
幻读发生在范围查询中,而不是单行查询。例如:
- 事务A 执行范围查询:
SELECT * FROM account WHERE balance > 100;得到两行(id=1,2)。 - 此时事务B 插入一条新记录 id=3,balance=300,并提交。
- 事务A 再次执行相同的范围查询,得到三行(id=1,2,3),出现了"幻影行"。
在标准的可重复读隔离级别下,MVCC 只保证已存在行的快照一致性,但无法阻止新行的插入,因为新行不在事务开始时的快照中,第二次查询时快照里本来没有这行,但新行在提交后是可见的(取决于快照的实现机制)。
- 使用 MVCC 时,事务的快照通常只包含事务开始前已提交的数据,以及本事务自己修改的数据。新插入的行如果在事务开始后由其他事务提交,在快照模式下可能不可见 ,但具体行为依赖于数据库实现。
- 在 PostgreSQL 的可重复读(快照隔离)中,事务A第二次范围查询不会看到事务B插入的新行,因为快照隔离严格基于事务开始时的快照,这实际上防止了幻读!
- 但 SQL 标准定义的可重复读允许幻读,因为标准并未强制要求使用快照隔离,而是允许基于锁的实现。在基于行锁的可重复读中,范围查询没有锁定间隙,其他事务可以插入新行,导致幻读。
为了符合标准定义,我们说"可重复读可能发生幻读"。但实际数据库实现可能不同,比如 PostgreSQL 的可重复读实际上达到了可串行化的快照隔离效果(防止幻读),而 MySQL InnoDB 在可重复读下默认也能防止幻读(通过间隙锁),但这属于扩展实现。
MySQL InnoDB 存储引擎在可重复读(Repeatable Read)隔离级别下,通过一种名为 Next-Key Lock 的机制来防止幻读(Phantom Read)。Next-Key Lock 实际上是**行锁(Record Lock)和间隙锁(Gap Lock)**的组合。下面我们详细拆解并举例说明。
MySQL InnoDB 如何通过间隙锁防止幻读
MySQL InnoDB 在可重复读级别下,不仅使用 MVCC,还引入了间隙锁(gap lock) 和 next-key lock (记录锁+间隙锁)来防止幻读。
一、场景预设
为了清楚地演示,我们假设有一张名为 student 的表,id 是主键索引,表中有如下数据行:
| id (PK) | name |
|---|---|
| 5 | Alice |
| 10 | Bob |
| 15 | Charlie |
注意,数据行之间存在着间隙(Gap),例如 (5, 10) 之间、(10, 15) 之间,以及 15 之后到正无穷的区间。
三种锁的详细定义
- 记录锁(Record Lock)
- 含义 :锁住具体的某一条索引记录。
- 作用:阻止其他事务修改或删除这条被锁定的记录。
- 示例 :对
id = 10的记录加锁,其他事务无法操作id = 10这一行。
- 间隙锁(Gap Lock)
- 含义 :锁住两个索引记录之间的空隙,或者锁住第一条记录之前的空隙,以及最后一条记录之后的空隙。
- 作用 :阻止其他事务在这个空隙中插入新记录。
- 示例 :锁定 (5, 10) 这个区间,那么其他事务无法插入
id = 6、id = 8等值在 5 和 10 之间的记录。 - 重要特性 :间隙锁之间是兼容的,也就是说,两个事务可以同时对同一个间隙加锁,但目的都是为了阻止插入,而不是互斥。
- Next-Key Lock
- 含义 :是 记录锁 + 间隙锁 的组合。
- 锁定范围 :它锁定的是一个左开右闭 的区间
(前一个值, 当前值]。 - 作用 :既锁住了记录本身(防止修改/删除),也锁住了记录之前的间隙(防止插入),从而在范围查询中保证数据的一致性,防止幻读。
注意:共享锁(S 锁)和排他锁(X 锁)都可以是 Next-Key Lock,Gap Lock或者Record Lock,在 MySQL InnoDB 中,共享锁(S 锁)和排他锁(X 锁)是锁的模式,而记录锁(Record Lock)、间隙锁(Gap Lock)和 Next-Key Lock 是锁的类型。这两种锁可以从不同维度组合:S 锁和 X 锁都可以表现为记录锁、间隙锁或 Next-Key Lock,具体取决于查询条件、索引类型和隔离级别。
三种锁的区别对比
| 锁类型 | 锁定对象 | 防止修改/删除 | 防止插入 | 锁定区间示例(基于上述数据) |
|---|---|---|---|---|
| 记录锁 | 具体的某行 | ✅ | ❌ | id = 10 |
| 间隙锁 | 记录之间的空隙 | ❌ | ✅ | (5, 10) |
| Next-Key锁 | 记录 + 前一个间隙 | ✅ | ✅ | (5, 10] |
注意:
(5, 10]表示大于5且小于等于10的范围。它包含了id=10这条记录本身,以及id在5和10之间(比如6,7,8,9)的空隙。
举例说明 Next-Key Lock 如何防止幻读
假设有一个事务 A 要执行以下操作:
sql
-- 事务 A
BEGIN;
SELECT * FROM student WHERE id > 7 FOR UPDATE;
这条语句是一个当前读 (因为使用了 FOR UPDATE),它需要锁定所有 id > 7 的记录,以防止其他事务插入或修改影响当前事务的结果。
- 锁定过程分析
InnoDB 会扫描索引,并对扫描过程中触及的区间加上 Next-Key Lock。基于我们已有的数据 (5, 10, 15),扫描 id > 7 的过程如下:
- 第一条记录 :扫描到
id = 10,因为它满足id > 7。此时,InnoDB 会对id = 10所在的区间加 Next-Key Lock,即锁定(5, 10]。 - 继续扫描 :继续向后扫描,找到
id = 15,同样满足条件。对id = 15所在的区间加 Next-Key Lock,即锁定(10, 15]。 - 扫描结束 :直到扫描到索引的最大值(Supremum),InnoDB 还会对最后一个值之后的间隙加 Next-Key Lock,即锁定
(15, +∞)(实际是(15, supremum])。
- 最终锁定的范围
事务 A 实际上锁定了以下几个区间,它们是连续的,覆盖了所有 id > 7 的可能范围:
(5, 10](10, 15](15, +∞)
这意味着,任何试图在 id > 7 的范围内插入新记录的操作都会被阻塞,直到事务 A 提交或回滚。
- 验证插入被阻塞
现在,假设另一个事务 B 尝试插入一条新记录:
sql
-- 事务 B
BEGIN;
INSERT INTO student (id, name) VALUES (12, 'David'); -- id=12 在区间 (10, 15] 内
COMMIT;
结果 :事务 B 会被阻塞 ,无法立即执行,直到事务 A 释放锁。因为 id = 12 落在了事务 A 持有的 Next-Key Lock 区间 (10, 15] 内。
注意 :插入 id≤5 的数据(比如 id=3)不会被阻塞(因为锁的起始是 5);
修改 / 删除已存在的 id=10、15 会被锁,但读取(不加锁的 select)不会被阻塞
为什么这样可以防止幻读?
有了 Next-Key Lock,InnoDB 不仅锁住了现有的行,还锁住了这些行之间的间隙 。它通过这种方式告诉数据库:在这个事务结束前,不允许任何新记录插入到我扫描过的这个范围内。这就从根本上杜绝了其他事务通过插入新记录来改变结果集的可能性,从而确保了当前读的可重复性和一致性
MVCC基于快照的读取确实会导致"不一致",为什么MVCC允许这种"不一致"?
简单直接地回答问题:
是为了在保证"可重复读"的同时,最大化并发性能。
假设有两个事务:
- 事务A (长报表查询):
SELECT SUM(balance) FROM account;需要扫描100万行,耗时10秒。 - 事务B :
UPDATE account SET balance = balance + 100 WHERE id = 1;并提交。
如果没有MVCC(比如用锁实现可重复读):
- 事务A必须锁住整张表或所有行,防止事务B修改,否则会导致不可重复读。这样事务B就被阻塞10秒,并发性能极差。
有了MVCC:
- 事务A基于自己开始时的快照读数据,事务B直接修改并提交,互不阻塞。
- 事务A最终算出的总和,是事务A开始那一刻的余额总和,不包含事务B的+100。
- 从"绝对最新"的角度看,事务A的结果是"过时的",但从"事务内部一致性"角度看,它是逻辑一致的(因为事务A从未见过事务B的修改)。
这种"不一致"会导致业务问题吗?
取决于业务需求。
- 对于报表查询:通常可以接受稍微旧一点的数据(比如生成上个月的报表),只要报表内部是自洽的。
- 对于转账操作:不行!必须看到最新数据。
所以,MVCC提供了两种读取模式:**
| 读取模式 | 实现方式 | 看到的数据 | 适用场景 |
|---|---|---|---|
| 快照读(Snapshot Read) | 普通的 SELECT |
事务开始时的快照 | 报表、长查询、历史数据分析 |
| 当前读(Current Read) | SELECT ... FOR UPDATE 或 UPDATE、DELETE |
已经提交的最新数据(加锁) | 修改操作、需要最新数据的场景 |
系统地总结一下当前读(Current Read) 以及与之相关的 SELECT ... FOR UPDATE、UPDATE、DELETE 操作。这些是数据库并发控制中的重要概念,尤其是在 MVCC(多版本并发控制)环境下,它们与普通的快照读(Snapshot Read)形成对比。
当前读 是指读取数据时总是读取数据的最新已提交版本,并且通常会对读取的数据加锁(共享锁或排它锁),以防止其他事务同时修改。当前读的目的是为了进行数据修改(如更新、删除)或确保后续操作基于最新数据,避免丢失更新或写偏斜等问题。
与当前读相对的是快照读 (普通的 SELECT),它基于事务开始时的快照,不加锁,读取的是历史版本。
常见的当前读操作:
SELECT ... FOR UPDATE(加排它锁)SELECT ... LOCK IN SHARE MODE(加共享锁,MySQL 语法)UPDATE(修改数据前需要先读取最新数据)DELETE(删除数据前需要先读取最新数据)INSERT(在某些情况下也可能涉及当前读,如插入时检测唯一键冲突)
初始数据表 account 结构如下:
CREATE TABLE account (
id INT PRIMARY KEY,
balance INT
);
INSERT INTO account VALUES (1, 100), (2, 200);
SELECT ... FOR UPDATE
作用 :对查询结果集中的每一行加排它锁(X 锁) 。加锁后,当前事务可以读取和修改这些行,而其他事务不能对这些行加任何锁(既不能加共享锁,也不能加排它锁),因此无法修改或通过 FOR UPDATE / LOCK IN SHARE MODE 读取。但是,其他事务的普通 SELECT(快照读)不受影响,可以读取历史版本。
示例:
sql
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- ① 加排它锁,读取最新数据 100
-- 此时事务B尝试执行(在另一个会话中):
-- UPDATE account SET balance = balance + 100 WHERE id = 1; -- 想增加100,变成200
-- 事务B会被阻塞,因为事务A持有排它锁,事务B需要排它锁但无法获得。
-- 事务A继续:
UPDATE account SET balance = balance + 50 WHERE id = 1; -- ② 基于当前值100,更新为150
COMMIT; -- ③ 事务A提交,释放锁
-- 事务B的UPDATE现在获得锁,它读取到最新值150,然后加100,变成250
-- 事务B提交
COMMIT;
结果 :最终 id=1 的余额为 200。事务A的 FOR UPDATE 保证了在更新前没有其他事务干扰。
SELECT ... LOCK IN SHARE MODE
作用 :对查询结果集中的每一行加共享锁(S 锁)。多个事务可以同时对同一行加共享锁,但都不能修改该行(因为修改需要排它锁)。这通常用于确保在读取期间数据不会被其他事务修改,但允许多个事务并发读取。
示例:
sql
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE; -- 对 id=1 加共享锁,读取 100
-- 事务B
BEGIN;
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE; -- 也可以加共享锁,立即成功,读取 100
-- 事务C尝试修改
BEGIN;
UPDATE account SET balance = 150 WHERE id = 1; -- 需要排它锁,但共享锁存在,因此被阻塞
-- 事务A提交
COMMIT; -- 事务A释放共享锁,但事务B仍持有共享锁,事务C依然被阻塞
-- 事务B提交
COMMIT; -- 所有共享锁释放,事务C获得排它锁,执行更新
说明 :LOCK IN SHARE MODE 常用于需要读取当前最新数据,但又不想阻塞其他读操作的场景(例如生成报表时防止数据被修改)。
UPDATE
作用 :UPDATE 操作本身就是一个当前读。它首先会定位到要更新的行(使用索引扫描),对扫描到的行加排它锁(X 锁),然后读取这些行的最新已提交数据,最后执行更新。加锁期间,其他事务无法修改这些行。
示例:
sql
-- 事务A
BEGIN;
UPDATE account SET balance = balance + 50 WHERE id = 1; -- 对 id=1 加排它锁,读取 100,更新为 150
-- 此时事务B尝试修改同一行
BEGIN;
UPDATE account SET balance = balance + 100 WHERE id = 1; -- 被阻塞,等待事务A释放锁
-- 事务A提交
COMMIT;
-- 事务B获得锁,读取最新值 150,更新为 250,提交
注意 :UPDATE 的 WHERE 条件如果使用了索引,则只锁住索引覆盖的行;如果没有索引,可能会锁住全表(全表扫描)。此外,在可重复读级别下,UPDATE 还会对扫描过程中遇到的间隙加间隙锁,以防止幻读(但如果 WHERE 条件是唯一索引等值查询,间隙锁可能降级)。
DELETE
作用 :与 UPDATE 类似,DELETE 也是当前读。它先定位到要删除的行,对这些行加排它锁,然后删除。其他事务无法同时修改或删除这些行。
示例:
sql
- 事务A
BEGIN;
DELETE FROM account WHERE id = 1; -- id=1 存在,加记录锁(排它锁)
-- 事务B(并发)
BEGIN;
INSERT INTO account (id, balance) VALUES (1, 300); -- 被事务A 的记录锁阻塞,等待
-- 事务A 提交
COMMIT; -- id=1 被永久删除,锁释放
-- 事务B 获得锁,重新检查唯一性,发现 id=1 已不存在,插入成功
补充 :DELETE 的加锁行为与 UPDATE 类似,除了删除行本身,还可能锁定间隙以防止幻读。
INSERT(涉及唯一键冲突时的当前读)
作用 :普通的 INSERT 操作本身并不是当前读,但在插入过程中,如果表上有唯一索引(包括主键),InnoDB 需要检查是否违反唯一性约束。这个检查过程会进行当前读:它会读取索引页,判断是否存在重复键值。如果发现重复键,INSERT 会失败并可能引发锁等待(取决于是否存在冲突的事务)。
更具体地说:
- 当插入一行时,InnoDB 会对插入的间隙加插入意向锁(Insert Intention Lock) ,这是一种特殊的间隙锁,表示该事务打算在某个间隙插入记录。多个事务可以在同一个间隙上同时持有插入意向锁,因为它们只是声明"我要在这个间隙里插数据",而实际插入的位置可能不同(比如间隙是 (10, 20),一个插 id=12,一个插 id=15)或者还有其他事务也想插入相同的id也是兼容的(比如都插6)。 可以同时插。这就是为什么 MySQL 插入性能高的原因。
- 但是,如果已经有其他事务在该间隙上加了普通的间隙锁(Gap Lock)或 Next-Key Lock(包含间隙锁),那么插入意向锁就必须等待,因为间隙锁的目的就是阻止其他事务在该间隙中插入任何新记录。
- 如果插入导致唯一键冲突,并且冲突的键被其他事务持有锁(例如其他事务正在修改或删除该键),则当前事务会被阻塞,直到冲突事务释放锁。此时,插入操作实际上需要等待获取锁,这涉及了当前读(读取最新索引状态),如果冲突事务只是持有锁(例如 SELECT ... FOR UPDATE)而没有修改或删除该记录,那么当它提交后,该记录依然存在,插入会因唯一键冲突而失败。但如果冲突事务最终删除了该记录,或者将该记录的唯一键值修改为其他值(从而不再冲突),那么插入就可能成功。
示例 :
锁类型:记录锁
阻塞原因:事务B 需要访问被事务A 锁住的 id=1 索引记录以检查唯一性。
sql
-- 假设 account 表 id 是主键
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 2 FOR UPDATE; -- 对 id=2 加排它锁
-- 事务B
BEGIN;
INSERT INTO account (id, balance) VALUES (2, 500); -- 插入 id=2,但 id=2 已被事务A锁住
-- 此时事务B会被阻塞,因为需要检查主键唯一性,发现 id=2 存在且被事务A锁定
-- 事务B等待事务A释放锁
-- 事务A提交
COMMIT;
-- 事务B获得锁,发现 id=2 仍然存在(事务A只是查询,没有删除),插入失败,返回重复键错误
锁类型:Next-Key 锁(包含间隙锁),锁住的范围包括 id=1 的记录锁和 (-∞, 1) 的间隙锁。
阻塞原因:事务B 试图插入到被间隙锁保护的范围内。
-- 事务A
BEGIN;
DELETE FROM account WHERE id < 2; -- 这会锁住 id<2 的范围,包括 id=1 以及它之前的间隙
-- 事务B(并发)
BEGIN;
INSERT INTO account (id, balance) VALUES (0, 300); -- id=0 在间隙(负无穷, 1)内,被间隙锁阻塞,等待
-- 事务A 提交
COMMIT; -- 间隙锁释放
-- 事务B 获得插入意向锁,成功插入 id=0
注意:INSERT 冲突时要去申请 S 锁(共享锁) :申请 S 锁的意思是 "我发现这里有人了(或者有人正在申请),我现在要盯着(S 锁)这行记录。我不需要改它,我只需要知道它最后到底是留下来(A 提交)还是消失(A 回滚)。"因为所有INSERT 的最终母的就是想把S锁升级到X,进行插入。
场景设定: 表 t1 有唯一主键 id。
| 时间点 | 事务 A | 事务 B | 事务 C |
|---|---|---|---|
| T1 | INSERT (id=1) (成功,持有 X 锁) | ||
| T2 | INSERT (id=1) (冲突,请求 S 锁,阻塞等待) | ||
| T3 | INSERT (id=1) (冲突,请求 S 锁,阻塞等待) | ||
| T4 | ROLLBACK (回滚) |
关键爆发点(T4):
- 事务 A 回滚,它持有的 X 锁释放。
- 锁授予 :由于 S 锁之间是兼容的,事务 B 和 事务 C 同时获得了这一行的 S 锁。
- 冲突发生 :
- 事务 B 醒来,想要继续完成 INSERT。为了写数据,它必须把手里的 S 锁升级为 X 锁。
- 事务 C 醒来,也想要继续完成 INSERT。它也必须把手里的 S 锁升级为 X 锁。
- 死锁达成 :
- 事务 B 想拿 X 锁,但事务 C 正持有着 S 锁(S 和 X 冲突),B 等待 C 释放。
- 事务 C 想拿 X 锁,但事务 B 正持有着 S 锁,C 等待 B 释放。
- 结果:两人互相持有 S,互相请求 X,死锁!
- INSERT 冲突时的那个"S 锁"是数据库为了稳妥起见给你的。但 INSERT 最终的目的是写,写就需要"锁升级"到 X。
悖论:如果只有一个人在等,升级很顺利。如果有两个及以上的人都在等,大家就陷入了"因为你看着,所以我不能动;因为我看着,所以你也不能动"的僵局。
如果事务 A 提交(Commit)------【没有死锁,只有报错】
T1-T3:A 占了坑(X锁),B 和 C 在旁边戴着袖章监视(申请 S锁)。
T4(A 提交):A 说:"这个座我占定了!"
由于 A 提交了,id=1 这行记录正式永久进入数据库。
B 和 C 醒来:
B 和 C 发现 A 已经把座占死了,而且证据确凿(已提交)。
结果:数据库直接给 B 和 C 弹出一个 "Duplicate key error" (主键重复错误)。
结局:B 和 C 灰溜溜地结束了事务。大家都解脱了,没有死锁。
另一个常见场景是插入时使用 ON DUPLICATE KEY UPDATE ,这时也会涉及当前读来检查唯一键,并根据情况执行更新。
INSERT ... ON DUPLICATE KEY UPDATE 是 MySQL 中一种特殊的插入语法,它的作用是:**当插入的数据导致唯一键冲突(比如主键或唯一索引重复)时,自动转为执行 UPDATE 操作,而不是直接报错。**也通常被称为 Upsert是一个非常强大的语法。简单来说就是"如果不存在就插入,如果存在就更新。"
为什么说它涉及"当前读"?
在执行这个语句时,数据库需要完成以下逻辑:
尝试插入:首先尝试插入新记录。
检测唯一键冲突:如果插入发现违反唯一约束(例如主键 id 已存在),InnoDB 会读取该唯一键对应的现有记录的最新数据,以判断冲突的具体情况。这个读取操作是当前读,因为它需要看到已经提交的最新数据,并且可能会对记录加锁(取决于隔离级别),以便后续执行更新。
执行更新:根据冲突记录的值和 UPDATE 子句中的表达式,对原有记录进行修改。
因此,整个过程涉及了对冲突记录的当前读,以确保更新基于最新的数据,并且保证并发安全。
为什么需要当前读?
- 保证数据一致性:在修改数据前,确保读取到的是最新值,避免基于过时数据做出错误更新。
- 防止丢失更新:如果两个事务同时读取同一行,然后都基于旧值计算新值并更新,后一个提交可能会覆盖前一个,造成丢失更新。通过当前读加锁,可以强制串行化修改。
- 实现复杂业务逻辑 :例如,在库存系统中,先检查库存是否足够(
SELECT ... FOR UPDATE),再扣减库存,可以防止超卖。
MVCC最后再对两种读举例
假设初始余额:id=1: 100, id=2: 200
快照读:
sql
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 1; -- 看到 100(快照)
-- 此时事务B执行并提交:UPDATE account SET balance = 150 WHERE id = 1;
SELECT * FROM account WHERE id = 1; -- 仍然看到 100(快照,可重复读)
COMMIT;
当前读:
sql
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- ① 加排它锁,读取最新数据(100)
-- 此时事务B尝试执行:
-- UPDATE account SET balance = balance + 100 WHERE id = 1; -- 想增加100,变成200
-- 事务B会被阻塞,因为事务A持有排它锁,事务B需要排它锁但无法获得。
-- 事务A继续:
UPDATE account SET balance = balance + 50 WHERE id = 1; -- ② 基于当前值100,更新为150
COMMIT; -- ③ 事务A提交,释放锁
-- 事务B的UPDATE现在可以继续,它获得锁,读取到最新值150,然后加100,变成250
-- 最终余额为250
那会不会真的出现逻辑错误?
会的,有一种经典的异常叫做 "写偏斜"(Write Skew) ,在MVCC的快照隔离下可能发生,需要上升到可串行化隔离级别才能彻底解决。
写偏斜例子:
- 规则:至少有一名医生值班。当前有医生A(值班)、医生B(值班)。
- 事务1(医生A请假):检查值班人数是否>1(看到2人),准备把自己设为不值。
- 事务2(医生B请假):检查值班人数是否>1(看到2人),准备把自己设为不值。
- 两个事务都基于快照看到2人,都认为自己请假后还剩1人,于是都提交。
- 结果:值班人数变成0,违反规则。
这种问题在可重复读 下可能发生,是由于快照读的局限性:事务 1 和事务 2 在执行 SELECT COUNT(*) 时,利用的是 MVCC 快照读。它们都看到了数据库在那一瞬间的状态(2 人值班)
首先,我们定义一张医生表 doctors。
-
on_call 字段:代表值班状态。1 表示正在值班,0 表示不在值班(请假了)。
-
业务规则 :医院要求至少要有一名医生值班(即 on_call=1 的记录数必须 ≥1 )。
SQL
-- 创建医生表
CREATE TABLE doctors (
id INT PRIMARY KEY,
name VARCHAR(50),
on_call TINYINT, -- 1: 值班中, 0: 已请假
) ENGINE=InnoDB;
-- 初始数据:两名医生都在值班
INSERT INTO doctors (id, name, on_call, version) VALUES (1, '医生A', 1);
INSERT INTO doctors (id, name, on_call, version) VALUES (2, '医生B', 1);
方案一:显式加锁(使用 FOR UPDATE)+
原理 :在读取数据时就告诉数据库:"这些行我要用,并且我待会可能要改,别人不准动。"
具体步骤:
- 事务1 :执行
SELECT COUNT(*) FROM doctors WHERE on_call = 1 FOR UPDATE;- 底层动作 :数据库会给 id=1 和 id=2 的行加上X 型 Next-Key Lock。
- 事务2 :尝试执行相同的 SELECT ... FOR UPDATE。
- 底层动作 :由于事务1已经锁定了这两行,事务2会直接卡住(进入锁等待)。
- 事务1 :执行
UPDATE ... SET on_call = 0 WHERE id = 1; 并提交。 - 事务2 :事务1提交后,事务2苏醒,重新读取数据。此时它发现 on_call=1 的人只剩 1 个了。
- 结果:事务2发现不符合"剩下一人"的条件,放弃修改。
- 优点:最标准、最直观,利用行锁解决。
- 缺点:如果值班医生很多,锁定的行数会很多,影响并发。
方案二:提升隔离级别(串行化 Serializable)
原理:不需要改 SQL 语句,直接把数据库的防御等级提到最高。
具体步骤:
- 修改级别 :执行
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; - 事务1 :执行普通查询
SELECT COUNT(*)...。- 底层动作 :数据库自动将其转化为 LOCK IN SHARE MODE,给 A 和 B 加共享锁(S锁)。
- 事务2 :执行相同查询。也给 A 和 B 加共享锁(S锁与S锁兼容,都能看)。
- 死锁发生 :
- 事务1想 UPDATE A,需要 X 锁,但事务2持有着 A 的 S 锁,事务1等待。
- 事务2想 UPDATE B,需要 X 锁,但事务1持有着 B 的 S 锁,事务2等待。
- 数据库介入 :检测到死锁,强制回滚其中一个事务(报错),剩下的那个执行成功。
- 优点:不用改 SQL 代码。
- 缺点:频繁报错(死锁),用户体验差,并发性能极低。
方案三:改进版乐观锁(引入共享资源)
注意:简单的行级版本号无法解决此问题。我们必须建立一个"共享哨兵"。
-
额外建表 :
SQLCREATE TABLE global_config ( id INT PRIMARY KEY, config_name VARCHAR(50), version INT ); INSERT INTO global_config VALUES (1, '值班校验', 500);
具体步骤 :
步骤演示:
医生A (事务1) 开始:
先查询一共几个人在值班
SELECT COUNT(*) FROM doctors WHERE on_call = 1; -> 得到 2。
查询版本
SELECT version FROM global_config WHERE id = 1; -> 得到 500。
医生A心想:有2个人,版本是500,我可以请假。
医生B (事务2) 开始(并发进行):
也查询一共几个人在值班
SELECT COUNT(*) FROM doctors WHERE on_call = 1; -> 得到 2。
查询版本
SELECT version FROM global_config WHERE id = 1; -> 得到 500。
医生B心想:也有2个人,版本也是500,我也能请假。
医生A 提交更新:
先更新版本号
UPDATE global_config SET version = 501 WHERE id = 1 AND version = 500;
再请假
UPDATE doctors SET on_call = 0 WHERE id = 1;
结果:成功(影响1行)。医生A成功把版本占领到了 501。
医生B 提交更新:
想更新版本号的时候一看,版本不是500了。
关键一步:UPDATE global_config SET version = 501 WHERE id = 1 AND version = 500;
就不能执行UPDATE doctors SET on_call = 0 WHERE id = 2;
结果:失败!事务回滚(影响0行)。因为现在 global_config 里的 version 已经是 501 了,它匹配不到 version = 500 的行。
方案四:实体化冲突(Materializing Conflicts)
核心思路:人为制造一个争抢点。
具体操作:
创建一个名为 on_call_quota(值班配额)的表,只有一行数据,代表当前值班人数。
任何医生想请假,必须先 UPDATE on_call_quota SET count = count - 1 WHERE count > 1。
分析:
通过对同一行数据的竞争,强行把两个事务拉到同一个排队序列中。这样写偏斜就变成了标准的"写冲突",数据库的行锁机制能完美处理。
- 值班配额表(仅一行)
sql
CREATE TABLE on_call_quota (
id INT PRIMARY KEY,
current_count INT NOT NULL -- 当前值班人数
) ENGINE=InnoDB;
步骤演示:
医生A (事务1) 开始:
- 抢占配额并加锁 :
UPDATE on_call_quota SET current_count = current_count - 1 WHERE id = 1 AND current_count > 1;
-> 结果:成功(影响1行) 。
-> 底层: 数据库给配额表 id=1 这行加了 X锁(排他锁),事务1拿到了修改权。医生A心想:配额我占到了,别人现在动不了这行。
医生B (事务2) 开始(并发进行):
- 尝试抢占配额 :
UPDATE on_call_quota SET current_count = current_count - 1 WHERE id = 1 AND current_count > 1;
-> 结果:卡住(阻塞中) 。
-> 底层: 因为事务1还没提交,锁没释放,医生B的这条语句必须在门口排队等待。医生B心想:怎么半天没反应?(其实是在等医生A)。
医生A (事务1) 继续:
- 正式请假:
sql
UPDATE doctors SET on_call = 0 WHERE id = 1;
-> 结果:成功。
- 提交事务 :
COMMIT;
-> 结果: 此时配额表 current_count 真正变成 1 ,并且释放了on_call_quota的 id=1 这一行的锁。
注意:在 InnoDB 事务中,锁的释放遵循一个原则:锁是随用随加的,但释放必须统一在最后。
当你执行第一句 UPDATE on_call_quota 时,锁就加上了。
即便你这句执行完了,去执行后面的 UPDATE doctors 了,前面的锁也绝对不会释放。
只有当你下达 COMMIT(提交)或 ROLLBACK(回滚)指令的那一刻,数据库才会把这个事务里抓着的所有锁全部撒开。
- 医生B (事务2) 苏醒(继续执行)拿到锁并尝试执行刚才卡住的语句:
sql
UPDATE on_call_quota SET current_count = current_count - 1 WHERE id = 1 AND current_count > 1;
-> 结果:失败(影响0行) 。
-> 底层: 因为事务A已经把 current_count 改成了 1 ,此时 WHERE 1 > 1 条件不成立。医生B心想:卧槽,锁拿到了,但怎么更新失败了?影响行数居然是0?
- 逻辑判断与回滚 :
医生B通过代码判断 UPDATE 影响行数为 0,意识到人数已经不够了。
ROLLBACK;
-> 结果: 医生B请假失败,事务回滚。
方案三(改进版乐观锁)vs 方案四(实体化冲突)对比表
| 维度 | 方案三:改进版乐观锁 | 方案四:实体化冲突 |
|---|---|---|
| 底层核心 | 逻辑校验 (WHERE version = 500) | 物理排队 (利用 UPDATE 产生的行锁) |
| 对待竞争的态度 | 乐观:认为冲突少,大家先各忙各的,最后提交时才"撞一下"。 | 悲观:认为肯定会打架,一上来就强制排队,一个一个过。 |
| 性能表现 | 高并发下更顺滑:只要不进入最后提交阶段,大家互不阻塞。 | 并发受限:所有人必须在同一个"独木桥"上排队等待。 |
| 失败处理 | 代码判断:如果更新行数为 0,程序需要回滚并告诉用户重试。 | 阻塞等待:事务 B 会卡住,等事务 A 做完才醒来发现没名额了。 |
| 适用场景 | 读多写少、分布式系统、长事务。 | 写操作频繁、对实时性要求极高、逻辑简单。 |
| 深入理解两者的本质差异 |
- 方案三(乐观锁):像"抢票"
过程:大家都在页面上选座、填表。填完表点击"提交"的那一刻,系统检查:"你刚才领的那张验证码(Version 500)还生效吗?"
结果:如果有人比你早了一毫秒提交,验证码就失效了。
优点:在你填表的时候,别人也能填表,大家互不干扰。数据库连接和锁被占用的时间极短。 - 方案四(实体化冲突):像"排队买票"
过程:不管你想买哪张票,必须先去窗口排队,拿到了"准购证"才能去选座。
结果:第一个人拿到了准购证,后面的人只能在窗口等着,直到第一个人付完钱走人。
优点:顺序性极强。排在后面的人醒来时,看到的就是前面人买完之后的真实库存。