1、【Java面试】工作11年的粉丝去面试,被问到Mysql事务的实现原理?_哔哩哔哩_bilibili
# MySQL事务底层实现原理:ACID特性深度解析
## 引言
今天分享的这段面试非常有意思,去大厂面试的时候,**90%的可能性都会被问到**,但是真正能够去完整回答出来的同学非常少。
最近一个工作了11年的粉丝去面试就被问到这样一个问题:**MySQL事务的底层实现原理。**
另外,我花了一个多星期的时间把之前的所有高手的回答整理成了5万字的文档,想获取的小伙伴可以在我的主页去加微领取。
下面我们来看看普通人和高手对这一个问题的回答。
## 一、普通人的回答
**普通人的回答:**
MySQL的事务的实现原理,它里面就因为事务它有一个很关键的特性,我是这么理解的,就是它会有一个就是**原子性**嘛,就是它要保证我事务同时成功同时失败,所以它为了保证这个在一个回滚的机制上,所以它用到了一个**undo log**的这个表。就是说它在更改这个事务的之前,它会把这个数据的**快照**保存到undo log。嗯,然后,对。
**问题分析:**
普通人的回答主要存在以下问题:
- 只提到了原子性和undo log,理解不全面
- 没有提到ACID的其他特性(一致性、隔离性、持久性)
- 没有深入分析每个特性的实现机制
- 缺乏系统性的理解
## 二、高手的回答
### 2.1 总体思路
**高手的回答:**
好的,面试官,MySQL里面的事务满足**ACID**的特性,所以在我看来呢,MySQL的事务的原理啊,就是**InnoDB**是如何去保证ACID这四个特性的。
### 2.2 A - 原子性(Atomicity)
**首先呢,A表示Atomic原子性,也就是说需要保证多个DML操作的原子性,要么都成功,要么都失败。**
那么失败就意味着对原本执行成功的数据要进行回滚,所以InnoDB里面呢,设计一个**undo log**。
**Undo Log**在事务执行的过程中把修改之前的数据快照保存到Undo Log里面,一旦出现错误就直接从Undo Log里面去读取数据进行反向操作就行了。
**原子性实现原理:**
```
事务开始
↓
执行SQL操作
↓
将修改前的数据保存到Undo Log
↓
├─ 成功 → 提交事务
└─ 失败 → 从Undo Log恢复数据(回滚)
```
**Undo Log的作用:**
1. **回滚操作**:事务回滚时,从Undo Log读取原始数据恢复
2. **MVCC**:实现多版本并发控制,提供一致性读
3. **数据恢复**:数据库崩溃恢复时使用
**示例:**
```sql
BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;
UPDATE users SET balance = balance + 100 WHERE id = 2;
-- 如果第二条SQL失败,通过Undo Log回滚第一条SQL
ROLLBACK;
```
### 2.3 C - 一致性(Consistency)
**C表示一致性,也就是说数据的完整性约束没有被破坏。**
那么这个更多是依赖业务层面的一些保障,数据库本身也提供了一些,比如说像**主键**的唯一约束、字段长度和类型的一些保障等等。
**一致性实现原理:**
**数据库层面的保障:**
1. **主键约束**:保证唯一性
2. **外键约束**:保证引用完整性
3. **非空约束**:保证数据完整性
4. **数据类型约束**:保证数据格式正确
5. **检查约束**:保证数据范围正确
**业务层面的保障:**
1. **业务规则校验**:在应用层进行业务逻辑校验
2. **数据校验**:确保数据符合业务规则
3. **事务边界**:合理划分事务边界
**示例:**
```sql
-- 数据库层面:主键约束
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR(100) UNIQUE,
age INT CHECK (age > 0 AND age < 150)
);
-- 业务层面:转账业务的一致性
BEGIN;
-- 检查账户余额是否足够
SELECT balance FROM accounts WHERE id = 1;
-- 如果余额足够,执行转账
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
```
### 2.4 I - 隔离性(Isolation)
**I表示事务的隔离性,也就是说多个并行事务对同一个数据进行操作的时候,如何去避免多个事务的干扰导致数据混乱的一个问题。**
而InnoDB里面实现了**SQL92**的一个标准,提供了四种隔离级别的一个实现,分别是:
- **RU(Read Uncommitted)**:也就是说**读未提交**
- **RC(Read Committed)**:表示**读已提交**
- **RR(Repeatable Read)**:表示**可重复读**
- **Serializable**:表示**串行化**
**InnoDB默认采用的隔离级别是RR,也就是说可重复读。**
#### 隔离性实现机制
**1. MVCC机制**
然后使用了**MVCC机制**去解决了脏读和不可重复读的一个问题。
**MVCC(Multi-Version Concurrency Control,多版本并发控制):**
- 每行数据都有多个版本
- 事务读取时,只能看到已提交的版本
- 通过Undo Log实现版本管理
**MVCC实现原理:**
```
事务A开始(事务ID=100)
↓
读取数据(只能看到事务ID < 100的已提交数据)
↓
事务B修改数据(创建新版本,事务ID=101)
↓
事务A再次读取(仍然看到旧版本,保证可重复读)
```
**2. 行锁和表锁**
然后使用了**行锁**或者**表锁**的方式来解决了幻读的问题。
**锁机制:**
- **行锁(Record Lock)**:锁定单行数据
- **间隙锁(Gap Lock)**:锁定索引记录之间的间隙
- **临键锁(Next-Key Lock)**:行锁+间隙锁的组合
- **表锁(Table Lock)**:锁定整个表
**锁的作用:**
- **防止脏读**:读未提交的数据
- **防止不可重复读**:同一事务中多次读取结果不一致
- **防止幻读**:范围查询结果不一致
**示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 30; -- 使用MVCC读取
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('新用户', 35); -- 被Next-Key Lock阻塞
COMMIT;
-- 事务A
SELECT * FROM users WHERE age > 30; -- 仍然返回相同结果(防止幻读)
COMMIT;
```
### 2.5 D - 持久性(Durability)
**最后一个是D表示持久性,也就是说只要事务提交成功,那么对于这个数据的结果的影响一定是永久的,不能因为数据库宕机或者其他原因导致数据变更的一个失效。**
#### 持久性实现原理
**理论上来说,事务提交之后直接把数据持久化到磁盘里面就OK了。**
但是因为**随机磁盘IO**的效率确实很低,所以InnoDB里面设计了**Buffer Pool(缓冲池)**来进行优化。
也就是说数据发生变更的时候,先更新内存缓冲池,然后再合适的时间再持久化到磁盘里面。
**Buffer Pool机制:**
```
数据变更
↓
更新Buffer Pool(内存)
↓
异步刷新到磁盘
```
**问题:**
那么在这个机制里面,有可能出现在持久化的这个过程中,如果数据库**宕机**就会导致数据丢失,也就是说无法去满足持久化。
**解决方案:**
所以在InnoDB里面引入了**redo.log**这样一个文件,那么这个文件存储了数据库变更之后的一个值。
当我们通过事务进行数据更改的时候,除了修改内存缓冲池里面的数据以外呢,还会把**本次修改**的一个值追加到redo.log里面。
当事务**提交**的时候,直接把redo.log里面的**日志**刷新到**磁盘**里面进行持久化。
一旦数据库出现宕机,在MySQL重启的话,可以直接用redo.log里面保存的**重做日志**读取出来以后再去执行一遍,从而去保证数据的一个持久性。
**Redo Log机制:**
```
事务开始
↓
数据修改
↓
写入Redo Log Buffer(内存)
↓
事务提交
↓
刷新Redo Log到磁盘(顺序IO,性能好)
↓
数据库崩溃
↓
MySQL重启
↓
从Redo Log恢复数据
```
**Redo Log的特点:**
1. **顺序写入**:顺序IO,性能好
2. **持久化保证**:事务提交时强制刷新到磁盘
3. **崩溃恢复**:数据库重启时自动恢复
4. **WAL机制**:Write-Ahead Logging,先写日志再写数据
**示例:**
```sql
BEGIN;
UPDATE users SET balance = 1000 WHERE id = 1;
-- 此时:
-- 1. 数据修改写入Buffer Pool
-- 2. 修改记录写入Redo Log Buffer
COMMIT;
-- 此时:
-- 1. Redo Log刷新到磁盘(保证持久性)
-- 2. Buffer Pool异步刷新到磁盘
```
## 三、ACID特性实现机制总结
因此在我看来,事务的持久性原理核心本质就是如何去保证事务的ACID特性,而在实现中用到了:
- **MVCC**:多版本并发控制
- **行锁、表锁**:保证隔离性
- **undo.log**:保证原子性
- **redo.log**:保证持久性
等等,这样一些机制去保证这样一个特性。
### 3.1 实现机制对比
| ACID特性 | 实现机制 | 核心组件 |
|---------|---------|---------|
| 原子性(A) | Undo Log | 回滚段、Undo Log |
| 一致性(C) | 约束+业务逻辑 | 主键、外键、检查约束 |
| 隔离性(I) | MVCC + 锁 | Undo Log、行锁、间隙锁 |
| 持久性(D) | Redo Log | Redo Log Buffer、Redo Log文件 |
### 3.2 完整流程
```
事务开始
↓
执行SQL操作
↓
├─ 写入Undo Log(保证原子性)
├─ 写入Redo Log Buffer(保证持久性)
├─ 更新Buffer Pool(内存)
└─ 加锁(保证隔离性)
↓
事务提交
↓
├─ 刷新Redo Log到磁盘(保证持久性)
├─ 释放锁(保证隔离性)
└─ 清理Undo Log(不再需要回滚)
```
## 四、设计思想借鉴
InnoDB里面的事务实现原理有很多可以去值得借鉴的设计思想,比如说像:
### 4.1 乐观锁
- **思想**:假设冲突很少发生,先操作后检查
- **实现**:版本号、时间戳
- **应用**:高并发场景
### 4.2 以空间换时间
- **思想**:利用内存的缓存区的方式来以空间换时间
- **实现**:Buffer Pool缓存热点数据
- **应用**:减少磁盘IO,提升性能
### 4.3 优化磁盘IO
- **思想**:顺序IO比随机IO性能好
- **实现**:Redo Log顺序写入
- **应用**:提升写入性能
### 4.4 分布式事务框架借鉴
这些思想我认为还挺重要的。比如说在分布式事务框架,**Seata**的AT模式的数据回滚,就借鉴了InnoDB里面的**undo log**的一个设计思想。
**Seata AT模式:**
- 类似于Undo Log,保存数据修改前的快照
- 事务回滚时,从快照恢复数据
- 实现了分布式事务的原子性
## 五、总结
MySQL事务的底层实现原理:
### 5.1 ACID特性实现
1. **原子性(A)**:通过Undo Log实现回滚机制
2. **一致性(C)**:通过数据库约束和业务逻辑保证
3. **隔离性(I)**:通过MVCC和锁机制保证
4. **持久性(D)**:通过Redo Log保证数据持久化
### 5.2 核心机制
- **Undo Log**:保证原子性和MVCC
- **Redo Log**:保证持久性
- **MVCC**:保证隔离性(可重复读)
- **锁机制**:保证隔离性(防止幻读)
- **Buffer Pool**:优化性能
### 5.3 设计思想
- **乐观锁**:假设冲突少,先操作后检查
- **以空间换时间**:利用内存缓存提升性能
- **优化磁盘IO**:顺序IO比随机IO性能好
- **可借鉴性**:设计思想可以应用到分布式系统
**核心思想:**
- 事务的ACID特性是通过多种机制协同实现的
- 每个机制都有其特定的作用和优势
- 这些设计思想可以借鉴到其他系统中
2、【Java面试】6年开发去A里面试P6竟被Mysql难住了,说一下你对行锁、临键锁、间隙锁的理解_哔哩哔哩_bilibili
# MySQL行锁、临键锁和间隙锁深度解析
## 引言
一个工作了6年的程序员,最近去**阿里**里面试P6这样岗位。一会之前,信心满满的跟我说,这一次一定要拿下35k月薪的offer,然后在第一面的时候,被**MySQL**里面的一个问题给难倒了。
Hello大家好,我是Mike,一个没有才华只能靠颜值混饭吃的**Java程序员**。今天给大家分享一道阿里的面试题:**说一下你对于行锁、临键锁和间隙锁的一个理解。**
我把这样一个问题的回答整理到了一个10万字的面试文档里面,大家可以在我的主页去加v领取。
下面我们看看普通人和高手的回答。
## 一、普通人的回答
**普通人的回答:**
行锁是锁定这一条记录。这个是针对于比如说我们去针对**主键**的精准匹配的时候,比如说ID等于1这种。在这种匹配的时候,他会去加行锁。
然后**间隙锁**是锁定一个**索引**的区间。比如说我们针对于一个索引列的,或者唯一索引列的范围**查询**的时候,他会去加间隙锁。
然后**临键锁**应该是非唯一索引的时候。非唯一索引查找的时候,他会去针对非唯一索引的这一列的精准匹配的时候。他会去加上临键锁。
**问题分析:**
普通人的回答主要存在以下问题:
- 对三种锁的理解不够深入
- 没有说明三种锁之间的关系
- 没有提到锁的目的(解决幻读)
- 没有说明锁的锁定范围
- 缺乏系统性的理解
## 二、高手的回答
### 2.1 总体概述
**高手的回答:**
好的,面试官。行锁、临键锁和间隙锁都是**MySQL**里面,**InnoDB**引擎下解决事务隔离性的一系列排他锁。
下面我分别介绍一下这三种锁。
### 2.2 行锁(Record Lock)
**行锁也称为记录锁(Record Lock)。**
当我们针对**主键**或者**唯一索引**加锁的时候,MySQL**默认**会对查询的这一行数据增加行锁,避免其他事务对这一行数据进行一个修改。
**行锁的特点:**
- **锁定范围**:单行记录
- **触发条件**:主键或唯一索引的精准匹配
- **作用**:防止其他事务修改这一行数据
**示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 对id=1这一行加行锁
-- 事务B(在另一个会话)
BEGIN;
UPDATE users SET name = '新名字' WHERE id = 1; -- 被阻塞,等待事务A释放锁
COMMIT;
```
**行锁的锁定范围:**
```
索引: [1] [2] [3] [4] [5]
↑
行锁锁定id=1这一行
```
### 2.3 间隙锁(Gap Lock)
**间隙锁顾名思义就是锁定一个索引区间。**
在普通索引或者唯一索引的列上,由于索引是基于**B+树**的一个结构存储,所以默认会存在一个索引的一个区间。
而间隙锁就是某个事务对索引列加锁的时候,默认锁定对应索引的**左开右开区间**的一个范围。
在基于索引列的范围查询中,无论是否是唯一索引,都会自动触发一个**间隙锁**。
比如基于**BETWEEN**范围查询的过程中,就会产生一个左右开区间的一个间隙锁。
**间隙锁的特点:**
- **锁定范围**:索引记录之间的间隙(不包含记录本身)
- **触发条件**:范围查询(BETWEEN、>、<等)
- **作用**:防止其他事务在间隙中插入数据(解决幻读)
**示例:**
```sql
-- 假设users表有id: 1, 3, 5, 7, 9
-- 事务A
BEGIN;
SELECT * FROM users WHERE id BETWEEN 3 AND 7 FOR UPDATE;
-- 间隙锁锁定区间:(3, 7),即4, 5, 6这些位置
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (id, name) VALUES (4, '新用户'); -- 被阻塞,间隙被锁定
INSERT INTO users (id, name) VALUES (6, '新用户'); -- 被阻塞,间隙被锁定
INSERT INTO users (id, name) VALUES (8, '新用户'); -- 成功,不在锁定范围内
COMMIT;
```
**间隙锁的锁定范围:**
```
索引: [1] (2) [3] (4,5,6) [7] (8) [9]
↑ ↑
间隙锁锁定(3, 7)这个区间
注意:不包含3和7本身
```
**间隙锁的区间表示:**
- **(3, 7)**:左开右开区间,不包含3和7
- 锁定的是3和7之间的所有可能插入的位置
### 2.4 临键锁(Next-Key Lock)
**最后一个是临键锁,它相当于行锁加间隙锁的一个组合。**
也就是说它的锁定范围既包含了索引记录,也包含了索引的一个区间。它会锁定一个**左开右闭区间**。
**临键锁的特点:**
- **锁定范围**:索引记录 + 索引记录之前的间隙
- **触发条件**:非唯一索引的精准匹配或范围查询
- **作用**:防止幻读(既防止修改记录,也防止在间隙中插入)
**临键锁的组成:**
```
临键锁 = 行锁 + 间隙锁
```
**示例:**
```sql
-- 假设users表有age索引,age值: 20, 25, 30, 35, 40
-- 事务A(非唯一索引精准匹配)
BEGIN;
SELECT * FROM users WHERE age = 30 FOR UPDATE;
-- 临键锁锁定:(25, 30],即包含age=30的记录,以及(25, 30)的间隙
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('用户1', 27); -- 被阻塞,间隙被锁定
UPDATE users SET name = '新名字' WHERE age = 30; -- 被阻塞,记录被锁定
INSERT INTO users (name, age) VALUES ('用户2', 32); -- 成功,不在锁定范围内
COMMIT;
```
**临键锁的锁定范围:**
```
索引: [20] (21-24) [25] (26-29) [30] (31-34) [35]
↑ ↑
临键锁锁定(25, 30]这个区间
包含age=30的记录,以及(25, 30)的间隙
```
**临键锁的区间表示:**
- **(25, 30]**:左开右闭区间,不包含25,包含30
- 锁定的是25和30之间的间隙,以及age=30这条记录
### 2.5 三种锁的关系
**所以总的来说呢,行锁、临键锁和间隙锁,它只是表示锁的数据的一个范围。最终目的是为了去解决幻读的这个问题。**
而临键锁相当于行锁加间隙锁的一个组合,因此当我们使用**非唯一索引**进行精准匹配的时候,会默认加一个临键锁。
因此它需要去锁定匹配的这一行记录,还需要去锁定这一行数据,对应的**左开右闭区间**。
**三种锁的对比:**
| 锁类型 | 锁定范围 | 触发条件 | 组成 | 作用 |
|--------|---------|---------|------|------|
| 行锁 | 单行记录 | 主键/唯一索引精准匹配 | - | 防止修改记录 |
| 间隙锁 | 索引间隙 | 范围查询 | - | 防止在间隙中插入 |
| 临键锁 | 记录+间隙 | 非唯一索引精准匹配 | 行锁+间隙锁 | 防止幻读 |
**锁定范围图示:**
```
行锁: [记录]
间隙锁: (间隙)
临键锁: (间隙] [记录]
```
### 2.6 实际应用建议
**因此在实际应用过程中,尽可能就使用唯一索引进行一个数据查询,避免大面积的锁定造成性能的影响。**
**建议:**
1. **优先使用唯一索引**:
- 唯一索引使用行锁,锁定范围小
- 性能影响小
2. **避免非唯一索引的范围查询**:
- 非唯一索引会触发临键锁
- 锁定范围大,可能影响性能
3. **合理设计索引**:
- 根据查询模式设计索引
- 避免不必要的锁竞争
**示例对比:**
```sql
-- 使用唯一索引(主键)- 行锁,锁定范围小
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 只锁定id=1这一行
-- 使用非唯一索引 - 临键锁,锁定范围大
SELECT * FROM users WHERE age = 30 FOR UPDATE; -- 锁定(25, 30]区间
```
## 三、锁的详细分析
### 3.1 行锁详解
**行锁(Record Lock)的特点:**
- **精确锁定**:只锁定查询条件匹配的那一行
- **性能好**:锁定范围小,对性能影响小
- **适用场景**:主键查询、唯一索引查询
**行锁的加锁规则:**
```sql
-- 主键精准匹配 → 行锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 唯一索引精准匹配 → 行锁
SELECT * FROM users WHERE email = 'test@example.com' FOR UPDATE;
```
### 3.2 间隙锁详解
**间隙锁(Gap Lock)的特点:**
- **锁定间隙**:只锁定索引记录之间的间隙,不锁定记录本身
- **防止插入**:防止其他事务在间隙中插入数据
- **适用场景**:范围查询,防止幻读
**间隙锁的加锁规则:**
```sql
-- 范围查询 → 间隙锁
SELECT * FROM users WHERE id BETWEEN 3 AND 7 FOR UPDATE;
-- 锁定区间:(3, 7)
-- 大于查询 → 间隙锁
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 锁定区间:(5, +∞)
-- 小于查询 → 间隙锁
SELECT * FROM users WHERE id < 5 FOR UPDATE;
-- 锁定区间:(-∞, 5)
```
**间隙锁的区间表示:**
- **(a, b)**:左开右开区间
- 不包含a和b,只锁定a和b之间的间隙
- 可以插入a和b,但不能插入a和b之间的值
### 3.3 临键锁详解
**临键锁(Next-Key Lock)的特点:**
- **组合锁**:行锁 + 间隙锁的组合
- **左开右闭**:锁定区间是左开右闭的
- **防止幻读**:既防止修改记录,也防止在间隙中插入
**临键锁的加锁规则:**
```sql
-- 非唯一索引精准匹配 → 临键锁
SELECT * FROM users WHERE age = 30 FOR UPDATE;
-- 锁定区间:(前一个记录, 30]
-- 非唯一索引范围查询 → 临键锁
SELECT * FROM users WHERE age BETWEEN 25 AND 35 FOR UPDATE;
-- 锁定多个区间
```
**临键锁的区间表示:**
- **(a, b]**:左开右闭区间
- 不包含a,包含b
- 锁定a和b之间的间隙,以及b这条记录
## 四、锁与幻读的关系
### 4.1 幻读问题
**幻读的定义:**
在同一个事务中,多次执行相同的查询,结果集不一致。
**幻读示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 25; -- 返回3条记录
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('新用户', 30);
COMMIT;
-- 事务A
SELECT * FROM users WHERE age > 25; -- 返回4条记录(多了一条)
COMMIT;
```
### 4.2 锁如何解决幻读
**行锁:** 只能防止修改已存在的记录,不能防止插入新记录
**间隙锁:** 可以防止在间隙中插入新记录,但不能防止修改已存在的记录
**临键锁:** 既能防止修改记录,也能防止在间隙中插入,完美解决幻读
**解决幻读的机制:**
```
临键锁 = 行锁(防止修改)+ 间隙锁(防止插入)
= 完美解决幻读
```
## 五、实际应用场景
### 5.1 场景一:主键查询
```sql
-- 使用主键查询,加行锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 锁定范围:id=1这一行
-- 性能影响:小
```
### 5.2 场景二:唯一索引查询
```sql
-- 使用唯一索引查询,加行锁
SELECT * FROM users WHERE email = 'test@example.com' FOR UPDATE;
-- 锁定范围:email='test@example.com'这一行
-- 性能影响:小
```
### 5.3 场景三:非唯一索引查询
```sql
-- 使用非唯一索引查询,加临键锁
SELECT * FROM users WHERE age = 30 FOR UPDATE;
-- 锁定范围:(前一个age值, 30]区间
-- 性能影响:中等
```
### 5.4 场景四:范围查询
```sql
-- 范围查询,加间隙锁或临键锁
SELECT * FROM users WHERE age BETWEEN 25 AND 35 FOR UPDATE;
-- 锁定范围:多个区间
-- 性能影响:较大
```
## 六、性能优化建议
### 6.1 索引设计
1. **优先使用唯一索引**:
- 唯一索引使用行锁,性能好
- 锁定范围小,影响小
2. **避免非唯一索引的范围查询**:
- 会触发临键锁,锁定范围大
- 可能影响并发性能
3. **合理设计索引**:
- 根据查询模式设计
- 避免不必要的锁竞争
### 6.2 查询优化
1. **尽量使用精准匹配**:
- 精准匹配使用行锁
- 范围查询使用间隙锁或临键锁
2. **避免大范围查询**:
- 大范围查询锁定范围大
- 影响并发性能
3. **使用合适的隔离级别**:
- 根据业务需求选择隔离级别
- 平衡一致性和性能
## 七、总结
关于数据库里面的事务隔离级别,以及解决事务隔离级别的一些底层的实现,在面试过程中是非常常见的,像**MySQL**里面有很多**锁的类型**。
除了应对面试以外,我认为对于实际应用开发也有非常好的指导意义。也就是说如何在保证数据安全的情况下去平衡好性能。
### 7.1 三种锁的总结
1. **行锁(Record Lock)**:
- 锁定单行记录
- 主键/唯一索引精准匹配
- 性能好,锁定范围小
2. **间隙锁(Gap Lock)**:
- 锁定索引间隙
- 范围查询触发
- 防止在间隙中插入
3. **临键锁(Next-Key Lock)**:
- 行锁 + 间隙锁的组合
- 非唯一索引精准匹配
- 完美解决幻读问题
### 7.2 核心思想
- **锁的目的是解决幻读**:通过锁定不同的范围来防止幻读
- **临键锁是行锁和间隙锁的组合**:既能防止修改,也能防止插入
- **优先使用唯一索引**:减少锁定范围,提升性能
- **平衡一致性和性能**:在保证数据安全的前提下,优化性能
通过深入理解MySQL的锁机制,我们可以更好地设计并发程序,在保证数据一致性的同时,优化系统性能。
3、【Java面试】这应该是面试官最想听到的回答,Mysql如何解决幻读问题?_哔哩哔哩_bilibili
# MySQL如何解决幻读问题:MVCC和LBCC机制深度解析
## 引言
**MySQL如何解决幻读问题**,一个工作了四年的小伙伴去美团面试遇到了这样一个问题。
Hello大家好,我是Mike,一个工作了14年的**Java程序员**。最近我把所有的高手回答的一些面试题全部整理到了一个20万字的面试文档里面,大家可以在我的主页区领取。
关于这个问题,面试官想考察什么?我们应该如何去回答呢?
这个问题至少是考察三年以上开发经验的同学,**MySQL底层去解决并发事务的问题**,至少是要有一定的技术积累才能够去真正理解,否则只是理解一个皮毛。
而如果作为一个刚工作没有多久的程序员,必须要知道数据库的事务隔离级别的一些问题。
## 一、普通人的回答
**普通人的回答:**
所以**幻读**就是一个事务前后两次读取到的数据条数不一致。
在第一个事务里面,执行一个范围查询。这个时候满足条件的查询数据只有一条。接着,第二个事务里面,插入了一条数据并且提交了。然后,在第一个事务里面,再次执行查询的时候,发现有两条数据满足条件。
在**RR(Repeatable Read)**的事务隔离级别里面,引入了**MVCC**和**LBCC**这两种方式去解决幻读问题。
**MVCC类似于一种乐观锁的一个设计。**简单来说就是针对每个事务生成一个事务版本,然后通过这个版本去定义一个访问规则。
第一个,一个事务只能看到第一次查询之前已经提交的事务以及当前事务的修改。
第二个,一个事务不能看到当前事务第一次查询之后创建的事务以及未提交的事务的修改。
但是如果在一个事务里面存在当前读的情况下,MVCC还是会存在幻读的问题。因为当前读不是读的快照而是直接读的内存。
所以针对这样一个情况,可以使用**LBCC**,也就是基于锁的机制来解决。也就是我们常说的,**行锁**、表锁和**间隙锁**等等。
**问题分析:**
基于对这样一个知识的理解,如果没有对**MySQL**不同事务隔离级别的底层实现原理有一个清晰认识的同学,在回答这个问题的时候,要么就是很生硬,要么就是显得有点像是在背这样一个答案。
## 二、高手的回答
### 2.1 总体思路
**高手的回答:**
在**RR(Repeatable Read)**,也就是**可重复读**的一个事务隔离级别下呢,**InnoDB**采用了一个**MVCC**的机制来去解决幻读的问题。
### 2.2 MVCC机制
**MVCC就是一种乐观锁的一个机制。**
它是通过对于不同事务生成不同的**快照版本**,然后通过**Undo**的版本链来进行管理。
并且在MVCC里面它规定了**高版本能够看到低版本的一个事务版本,低版本看不到高版本的一个事务版本**。
从而去实现了不同事务之间的一个数据隔离,解决了幻读的问题。
#### MVCC实现原理
**1. 版本链管理**
```
事务ID=100 (开始时间最早)
↓
事务ID=101
↓
事务ID=102 (当前事务)
↓
Undo Log版本链
```
**2. 可见性规则**
- **高版本能看到低版本**:事务ID大的能看到事务ID小的已提交数据
- **低版本看不到高版本**:事务ID小的看不到事务ID大的数据
**3. 快照读**
```sql
-- 快照读:读取快照版本
SELECT * FROM users WHERE age > 25;
-- 读取的是事务开始时的快照,不会看到后续插入的数据
```
**MVCC解决幻读的机制:**
```
事务A开始(事务ID=100)
↓
执行范围查询:SELECT * FROM users WHERE age > 25
↓
读取快照版本(只能看到事务ID < 100的已提交数据)
↓
事务B插入数据(事务ID=101)并提交
↓
事务A再次执行范围查询
↓
仍然读取快照版本(看不到事务ID=101的数据)
↓
结果:两次查询结果一致,解决了幻读
```
### 2.3 当前读的问题
**但是如果在事务里面存在当前读的一个情况下,那么它是直接读取内存里面的数据,跳过快照版本。**
所以还是会出现幻读的一个问题。
#### 当前读 vs 快照读
**快照读(Snapshot Read):**
- 读取快照版本
- 使用MVCC机制
- 不会加锁
- 可以解决幻读
**当前读(Current Read):**
- 直接读取内存中的数据
- 不使用快照版本
- 需要加锁
- 可能产生幻读
**当前读的SQL语句:**
```sql
-- 当前读:直接读取内存数据
SELECT * FROM users WHERE age > 25 FOR UPDATE; -- 加锁,当前读
SELECT * FROM users WHERE age > 25 LOCK IN SHARE MODE; -- 加锁,当前读
UPDATE users SET name = '新名字' WHERE age > 25; -- 当前读
DELETE FROM users WHERE age > 25; -- 当前读
```
**当前读导致幻读的示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 25 FOR UPDATE; -- 当前读,返回3条记录
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('新用户', 30);
COMMIT;
-- 事务A
SELECT * FROM users WHERE age > 25 FOR UPDATE; -- 当前读,返回4条记录(幻读)
COMMIT;
```
### 2.4 解决方案
**我认为可以通过两种方式来解决。**
#### 方案一:避免当前读
**第一种是尽量去避免当前读的这样一个读的情况。**
**建议:**
- 尽量使用快照读(普通SELECT)
- 避免使用FOR UPDATE、LOCK IN SHARE MODE
- 如果必须使用当前读,考虑业务逻辑是否真的需要
**示例:**
```sql
-- 使用快照读(推荐)
SELECT * FROM users WHERE age > 25; -- 快照读,不会幻读
-- 避免使用当前读(除非必要)
SELECT * FROM users WHERE age > 25 FOR UPDATE; -- 当前读,可能幻读
```
#### 方案二:使用LBCC机制
**第二种是可以引入一个LBCC的方式来解决。**
**LBCC(Lock-Based Concurrency Control,基于锁的并发控制):**
- 使用锁机制来保证隔离性
- 包括行锁、表锁和间隙锁等
- 可以解决当前读的幻读问题
**LBCC解决幻读的机制:**
```
事务A开始
↓
执行范围查询:SELECT * FROM users WHERE age > 25 FOR UPDATE
↓
加临键锁(Next-Key Lock)
↓
锁定age > 25的范围(包括记录和间隙)
↓
事务B尝试插入age=30的数据
↓
被临键锁阻塞(无法插入)
↓
事务A提交,释放锁
↓
结果:事务B的插入被阻塞,解决了幻读
```
**临键锁的作用:**
- **行锁**:防止修改已存在的记录
- **间隙锁**:防止在间隙中插入新记录
- **临键锁 = 行锁 + 间隙锁**:完美解决幻读
**示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 25 FOR UPDATE;
-- 加临键锁,锁定age > 25的范围
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('新用户', 30);
-- 被临键锁阻塞,无法插入
COMMIT;
-- 事务A
COMMIT;
-- 释放锁,事务B的插入才能执行
```
## 三、MVCC和LBCC的对比
### 3.1 机制对比
| 机制 | 原理 | 适用场景 | 优点 | 缺点 |
|------|------|---------|------|------|
| MVCC | 多版本并发控制,快照读 | 快照读(普通SELECT) | 无锁,性能好 | 当前读无法解决幻读 |
| LBCC | 基于锁的并发控制 | 当前读(FOR UPDATE等) | 可以解决当前读的幻读 | 需要加锁,性能影响 |
### 3.2 使用场景
**MVCC适用场景:**
- 普通SELECT查询(快照读)
- 读多写少的场景
- 对一致性要求不是特别严格的场景
**LBCC适用场景:**
- FOR UPDATE、LOCK IN SHARE MODE(当前读)
- UPDATE、DELETE操作
- 对一致性要求严格的场景
### 3.3 组合使用
**InnoDB在RR隔离级别下的策略:**
- **快照读**:使用MVCC机制,无锁,解决幻读
- **当前读**:使用LBCC机制,加锁,解决幻读
**示例:**
```sql
-- 同一个事务中
BEGIN;
-- 快照读:使用MVCC,无锁
SELECT * FROM users WHERE age > 25; -- 快照读,MVCC解决幻读
-- 当前读:使用LBCC,加锁
SELECT * FROM users WHERE age > 25 FOR UPDATE; -- 当前读,临键锁解决幻读
COMMIT;
```
## 四、幻读问题详解
### 4.1 幻读的定义
**幻读(Phantom Read):**
在同一个事务中,多次执行相同的范围查询,结果集不一致。
**幻读示例:**
```sql
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 25; -- 返回3条记录
-- 事务B(在另一个会话)
BEGIN;
INSERT INTO users (name, age) VALUES ('新用户', 30);
COMMIT;
-- 事务A
SELECT * FROM users WHERE age > 25; -- 返回4条记录(多了一条)
COMMIT;
```
### 4.2 幻读与不可重复读的区别
| 问题 | 描述 | 影响范围 |
|------|------|---------|
| 不可重复读 | 同一事务中,多次读取同一行数据结果不一致 | 单行数据 |
| 幻读 | 同一事务中,多次范围查询结果集不一致 | 多行数据(结果集) |
### 4.3 幻读的危害
1. **数据不一致**:同一事务中数据不一致
2. **业务逻辑错误**:可能导致业务判断错误
3. **统计错误**:可能导致统计数据不准确
## 五、InnoDB的解决方案
### 5.1 RR隔离级别下的策略
**InnoDB在RR隔离级别下:**
1. **快照读**:使用MVCC机制
- 读取快照版本
- 不会看到后续插入的数据
- 解决幻读
2. **当前读**:使用LBCC机制
- 加临键锁
- 锁定范围,防止插入
- 解决幻读
### 5.2 实现细节
**MVCC实现:**
- **Undo Log版本链**:管理不同版本的数据
- **Read View**:定义可见性规则
- **事务ID**:标识事务版本
**LBCC实现:**
- **行锁(Record Lock)**:锁定单行记录
- **间隙锁(Gap Lock)**:锁定索引间隙
- **临键锁(Next-Key Lock)**:行锁+间隙锁的组合
### 5.3 完整流程
```
事务A开始(事务ID=100)
↓
执行范围查询
↓
├─ 快照读 → 使用MVCC → 读取快照版本 → 解决幻读
└─ 当前读 → 使用LBCC → 加临键锁 → 解决幻读
↓
事务B插入数据
↓
├─ MVCC:看不到新插入的数据
└─ LBCC:被临键锁阻塞,无法插入
↓
事务A提交
↓
结果:幻读问题解决
```
## 六、最佳实践
### 6.1 使用建议
1. **优先使用快照读**:
- 普通SELECT使用快照读
- 无锁,性能好
- MVCC自动解决幻读
2. **谨慎使用当前读**:
- 只在必要时使用FOR UPDATE
- 当前读会加锁,影响性能
- 使用临键锁解决幻读
3. **合理设计事务**:
- 尽量缩短事务时间
- 避免大范围查询
- 合理使用索引
### 6.2 性能优化
1. **索引优化**:
- 使用唯一索引减少锁定范围
- 避免非唯一索引的大范围查询
2. **查询优化**:
- 尽量使用精准匹配
- 避免大范围查询
3. **事务优化**:
- 缩短事务时间
- 减少锁持有时间
## 七、总结
MySQL如何解决幻读问题:
### 7.1 两种机制
1. **MVCC(多版本并发控制)**:
- 快照读使用MVCC
- 通过版本链和可见性规则解决幻读
- 无锁,性能好
2. **LBCC(基于锁的并发控制)**:
- 当前读使用LBCC
- 通过临键锁解决幻读
- 需要加锁,性能影响
### 7.2 核心思想
- **快照读使用MVCC**:无锁,解决幻读
- **当前读使用LBCC**:加锁,解决幻读
- **两种机制结合**:在RR隔离级别下完美解决幻读
- **根据场景选择**:快照读优先,当前读谨慎使用
### 7.3 面试要点
在回答这个问题时,需要说明:
1. **幻读的定义**:同一事务中范围查询结果不一致
2. **MVCC机制**:快照读如何解决幻读
3. **当前读的问题**:当前读可能产生幻读
4. **LBCC机制**:临键锁如何解决当前读的幻读
5. **两种机制的结合**:InnoDB如何同时使用两种机制
通过深入理解MVCC和LBCC机制,我们可以更好地理解MySQL如何解决幻读问题,在面试中给出让面试官满意的答案。