MySQL常见面试题
MySQL常见面试题


如何定位慢查询
解决方案
使用成熟的开源工具:
- 调试工具:Arthas
- 运维工具:Prometheus 、Skywalking
MySQL自带的慢日志(调试阶段开启,生产阶段关闭)
-
慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志
-
如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置
# 开启MySQL的慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句的执行时间超过2秒,就会视作为慢查询,记录慢查询日志
long_query_time=2
分析SQL
- 方法:可以采用EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息
- 信息:
- possible_key 当前sql可能会使用到的索引
- key 当前sql实际命中的索引
- key_len 索引占用的大小
- Extra 额外的优化建议
- type 这条sql的连接的类型,性能由好到差为NULL、system、const、eq_ref、ref、range、 index、all
- system:查询系统中的表
- const:根据主键查询
- eq_ref:主键索引查询或唯一索引查询
- ref:索引查询
- range:范围查询
- index:索引树扫描
- all:全盘扫描

索引
概念:索引(index)是帮助MySQL高效获取数据的数据结构(有序)。在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。
数据结构比较:
在数据库(尤其是 MySQL 的 InnoDB 引擎)的设计中,核心目标是在海量数据中实现极速的查找,并尽量减少磁盘 I/O 操作。注意:磁盘IO次数是首要条件!!
为什么不选二叉树(BST / AVL / 红黑树)?
无论是普通的二叉搜索树(BST),还是自平衡的 AVL 树或红黑树,它们在数据库索引场景中都有一个致命伤:“树太高了”。
- 磁盘 I/O 瓶颈: 磁盘寻道是非常耗时的操作。在数据库索引中,每个节点通常对应一个磁盘页(Page,InnoDB 默认为 16KB)。
- 高度失控: 二叉树的每个节点只能有两个子节点(阶数为 2)。如果存储 1000 万行数据,二叉树的高度大约为 log_2(10^7) \approx 24。这意味着查找一条数据可能需要进行 24 次磁盘 I/O。
- 逻辑: 这种“瘦高”的结构在内存中运行很快,但在涉及磁盘读写时,过多的 I/O 会导致性能崩塌。
为什么不选 B 树(B-Tree)?
B 树是对二叉树的改良,它变成了“多叉树”,大大降低了树的高度。但相比 B+ 树,它有三个主要劣势:
一、内部节点存储数据导致“扇出”降低
- B 树: 每个节点(无论是根节点还是叶子节点)都存储完整的
Data。 - B+ 树: 非叶子节点(索引节点)只存储
Key和Pointer,不存储具体的Data。 - 结论: 同样是 16KB 的磁盘页,B+ 树的非叶子节点能存储更多的索引项,这意味着它的**扇出(Fan-out)**更大,树的高度更低(通常 3 到 4 层即可支撑千万级数据)。
二、范围查询(Range Query)性能差
- B 树: 如果要查 ID 在 10 到 100 之间的数据,B 树需要进行中序遍历,不断在不同层级的父子节点间“反复横跳”。
- B+ 树: 所有数据都存在叶子节点,并且叶子节点之间用双向链表连接。查找范围时,只需先找到起始点,然后在叶子节点层进行顺序扫描即可。
三、查询稳定性
- B 树: 查找性能不稳定,运气好在根节点就找到了,运气差要到叶子节点。
- B+ 树: 任何查找都要最终落到叶子节点,查询路径长度固定,性能非常稳定。
为啥选择了B+树?
B+ 树之所以成为 InnoDB 的首选,主要得益于以下设计:
| 特性 | B+ 树的表现 | 给数据库带来的好处 |
|---|---|---|
| 矮胖结构 | 阶数通常几百上千,高度极低 | 即使亿级数据,磁盘 I/O 也在 3-4 次以内 |
| 非叶子节点去 Data 化 | 索引页能装更多索引 | 内存缓存效率更高,命中率高 |
| 叶子节点双向链表 | 物理存储有序,逻辑链表连接 | 完美支持 ORDER BY、GROUP BY 和范围查询 |
| 预读性 | 节点大小与操作系统的页对齐 | 充分利用局部性原理,一次读取整页数据 |
索引类型
聚集索引(聚簇索引)
将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据。必须有,且只有一个。
聚簇索引并不是一种单独的索引类型,而是一种数据存储方式。
- 核心逻辑: 将数据行与索引 B+ 树的叶子节点存放在一起。也就是说,索引即表,表即索引。
- 物理存储: 数据的物理存储顺序与索引的逻辑顺序完全一致。
- 唯一性: 因为数据行只能按照一种物理顺序存储,所以一个表只能有一个聚簇索引。
二级索引(非聚集索引)
将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键。可以存在多个。
非聚簇索引也叫辅助索引(Secondary Index)或者二级索引。
- 核心逻辑: 索引结构与数据行分开存储。B+ 树的叶子节点不存储真实的整行数据。
- 叶子节点存什么:
- 在 InnoDB 中:叶子节点存储的是该索引列的值 + 对应的主键值。
- 在 MyISAM 中:叶子节点存储的是该索引列的值 + 数据的磁盘地址(物理指针)。
- 数量: 一个表可以拥有多个非聚簇索引(如对
name、age分别建索引)。

回表查询
通过二级索引找到对应的主键值,到聚集索引中查找整行数据,这个过程就是回表。再实际开发中尽可能要避免回表查询。
比如我上面的表结构,聚簇索引是主键ID,非聚簇索引是name,那么看如下SQL:
select *
from t_user
where name = 'Kit';
该SQL会先走非聚簇索引,拿到主键ID,然后由于我是select的所有字段,所以,还会拿着这个主键ID去走一遍聚簇索引,拿到剩余字段。
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t_user | null | ref | t_user_name_index | t_user_name_index | 82 | const | 1 | 100 | null |
注意这里的Extra是null,所以肯定是回表了的。
覆盖索引
是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到 。
-- 第一句
select * from tb_user where id = 1; 覆盖索引
-- 第二句
select id, name from tb_user where name = 'Arm'; 覆盖索引
-- 第三句
select id, name, gender from tb_user where name = 'Arm'; 非覆盖索引需要回表
覆盖索引处理MySQL超大分页问题
- 问题:当在进行分页查询时,如果执行 limit 9000000,10 ,此时需要MySQL排序前9000010 记录,仅仅返回 9000000 - 9000010 的记录,其他记录丢弃,查询排序的代价非常大 。
- 优化思路: 一般分页查询时,通过创建 覆盖索引 能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化
select *
from tb_sku t,
(select id from tb_sku order by id limit 9000000,10) a
where t.id = a.id;
这段代码展示了 MySQL 大分页查询优化的一种常见技巧(延迟关联):
- 子查询部分:
(select id from tb_sku order by id limit 9000000,10)。这步只查询id字段,可以利用覆盖索引快速定位到第 9,000,000 行开始的 10 个 ID,避免了全表扫描和大量的回表操作。 - 主查询部分:通过
where t.id = a.id将这 10 个 ID 与原表关联,从而获取这 10 条记录的所有字段信息。
相比于直接执行 select * from tb_sku limit 9000000, 10,这种方式能显著提升超大偏移量分页的查询性能。
索引创建原则
- 针对于数据量较大,且查询比较频繁的表建立索引。
- 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
- 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
- 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
- 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
- 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。
- 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。
索引失效
假设我们有一张用户表 tb_user,并针对 profession (职业)、age (年龄)、status (状态) 三个字段建立了联合索引: CREATE INDEX idx_user_pro_age_sta ON tb_user(profession, age, status);
索引失效的情况:
1. 违反最左前缀法则
规则:查询必须从索引的最左列开始,且不能跳过中间列。
-
完全失效:跳过了最左列
profession。-- 失效:直接从 age 开始,没有 profession select * from tb_user where age = 31 and status = '0'; -
部分失效:跳过了中间列
age。-- 只有 profession 走索引,status 不走索引(中间断开了) select * from tb_user where profession = 'Software Engineering' and status = '0';
2. 范围查询右边的列失效
规则:联合索引中,出现范围查询(>、<)后的列索引失效。
-
例子:
-- profession 和 age 走索引,但 status 索引失效 -- 因为 age 使用了范围查询 '>' select * from tb_user where profession = 'Software Engineering' and age > 30 and status = '0';提示:在业务允许的情况下,使用
>=或<=通常可以规避部分失效问题。
3. 在索引列上进行运算操作
规则:对索引字段使用函数或数学计算,MySQL 将无法利用索引。
-
例子:
-- 失效:对 phone 字段进行了截取操作 select * from tb_user where substring(phone, 10, 2) = '15'; -- 失效:对 age 进行了数学运算 select * from tb_user where age + 1 = 30;
4. 字符串不加单引号(隐式类型转换)
规则:如果字段类型是字符串,但查询时未加引号,MySQL 会进行隐式转换,导致索引失效。
-
例子:
-- 假设 phone 是 varchar 类型 -- 失效:未加单引号,发生了类型转换 select * from tb_user where phone = 17612345678;
5. Like 模糊查询以 % 开头
规则:头部模糊匹配会导致全表扫描。
-
失效例子(头部模糊):
-- 失效:百分号在前面 select * from tb_user where profession like '%Engineering'; -
有效例子(尾部模糊):
-- 走索引:百分号只在后面 select * from tb_user where profession like 'Software%';
sql优化
-
表的设计优化
比如设置合适的数值(tinyint int bigint),要根据实际情况选择
比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低
-
索引优化(参考优化创建原则和索引失效)
-
SQL语句优化
- SELECT语句务必指明字段名称(避免直接使用select * )
- SQL语句要避免造成索引失效的写法
- 尽量用union all代替union,union all会展示重复的数据,而union会多一次过滤,效率低
- 避免在where子句中对字段进行表达式操作(可能会导致索引失效)
- Join优化 能用innerjoin 就不用left join right join,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序
-
主从复制、读写分离
如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。读写分离解决的是,数据库的写入,影响了查询的效率。
-
分库分表
事务隔离级别
在数据库的并发控制中,事务隔离级别(Transaction Isolation Levels) 是为了平衡数据一致性与系统并发性能而设计的规范。
MySQL 的 InnoDB 存储引擎完全支持 SQL:1992 标准定义的四个隔离级别。
一、 并发事务带来的三大问题
在聊隔离级别之前,我们需要先了解如果不进行隔离,并发事务同时操作同一行数据会发生什么“惨案”:
- 脏读 (Dirty Read): 事务 A 读取了事务 B 还没提交的数据。如果事务 B 随后回滚,事务 A 读到的就是非法数据。
- 不可重复读 (Non-repeatable Read): 事务 A 在同一个事务内多次读取同一条数据,结果不一致。这是因为在两次读取之间,事务 B 修改并提交了该数据。
- 幻读 (Phantom Read): 事务 A 按某个范围查询数据,发现多了或少了几行。这是因为在查询期间,事务 B 插入或删除了符合条件的行并提交了。

二、 MySQL 的四大隔离级别
为了解决上述问题,隔离级别从低到高分为四层,级别越高,数据越安全,但性能开销也越大。
1. 读未提交 (Read Uncommitted)
- 特性: 一个事务可以读取到另一个未提交事务修改的数据。
- 存在问题: 脏读、不可重复读、幻读。
- 评价: 性能最高,但几乎没人用,安全性太低。
2. 读已提交 (Read Committed, RC)
- 特性: 只能读取到已经提交的数据。
- 解决问题: 解决了脏读。
- 存在问题: 不可重复读、幻读。
- 应用场景: 许多主流数据库(如 Oracle, SQL Server)的默认隔离级别。
3. 可重复读 (Repeatable Read, RR) — MySQL 默认级别
- 特性: 在同一个事务中,多次读取同一记录的结果是一致的。
- 解决问题: 解决了脏读、不可重复读。
- 特殊点: MySQL 的 InnoDB 通过 MVCC(多版本并发控制)和 Next-Key Locks 很大程度上解决了幻读问题。 这是它相比于标准 SQL 规范的强大之处。
4. 串行化 (Serializable)
- 特性: 所有的事务都按顺序执行。它会对读取的每一行数据都加锁(读写冲突)。
- 解决问题: 解决所有并发问题。
- 评价: 性能最差,通常只在对数据绝对敏感且并发量极低的场景下使用。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 备注 |
|---|---|---|---|---|
| Read Uncommitted | ❌ (存在) | ❌ (存在) | ❌ (存在) | 极少使用 |
| Read Committed | ✅ (解决) | ❌ (存在) | ❌ (存在) | Oracle/PostgreSQL 默认 |
| Repeatable Read | ✅ (解决) | ✅ (解决) | ⚠️ (InnoDB 优化) | MySQL 默认 |
| Serializable | ✅ (解决) | ✅ (解决) | ✅ (解决) | 性能极低 |
虽然 MySQL InnoDB 在 可重复读 (RR) 级别下通过 MVCC (多版本并发控制) 和 Next-Key Locks (临键锁) 已经解决了绝大多数场景下的幻读,但在一些特殊的并发操作序列下,幻读依然会“现身”。
我们可以把 InnoDB 解决幻读的方案分成两套“拳法”:
- 快照读 (Snapshot Read): 普通的
SELECT语句。靠 MVCC 解决,它让你看到的是事务开始时的快照,别人新插的数据你看不到。 - 当前读 (Current Read):
SELECT ... FOR UPDATE、UPDATE、DELETE等。靠 Gap Lock (间隙锁) 解决,它会在范围内加锁,不准别人插新数据。
但当这两套拳法“混用”或者顺序不对时,幻读就钻了空子。
幻读依然发生的两个典型场景:
一、“后发制人”的更新操作 (最经典)
假设表 user 中原本没有 id=5 的记录。
- 事务 A: 先执行
SELECT * FROM user WHERE id=5。由于是快照读,结果是空。 - 事务 B: 插入一条
id=5的数据并 提交。 - 事务 A: 此时执行
UPDATE user SET name='new' WHERE id=5。- 关键点:
UPDATE是当前读。它会去磁盘读最新的数据,结果它竟然发现并成功更新了事务 B 刚插进去的那行!
- 关键点:
- 事务 A: 再次执行
SELECT * FROM user WHERE id=5。- 炸裂点: 根据 MVCC 的规则,一个事务能看到自己修改过的数据。因为事务 A 刚才更新了这行,它现在的版本号属于事务 A 了。于是,事务 A 的第二次查询竟然查出了这行数据!
结论: 在事务 A 看来,就像凭空变出来的一样,这就是幻读。
二、快照读切换到当前读
- 事务 A: 执行普通
SELECT查询某个范围(快照读),没发现新行。 - 事务 B: 插入新行并 提交。
- 事务 A: 突然想给这些行加锁,执行了
SELECT ... FOR UPDATE(当前读)。- 结果: 由于当前读会读取数据库中最新的记录,事务 A 这一次查询会立刻看到事务 B 插入的数据。
结论: 同一个事务内,查询结果集变了。
什么是快照读和当前读?
简单来说,快照读(Snapshot Read)让你看到的是“过去”,而当前读(Current Read)让你看到的是“现在”。
在 MySQL InnoDB 引擎中,为了同时兼顾高并发性能和数据一致性,设计了这两套不同的读取机制。
一、 快照读 (Snapshot Read)
快照读就是普通的 SELECT 查询。它不需要加锁,而是通过 MVCC(多版本并发控制) 来实现。
-
核心逻辑: 它读取的是数据的“快照版本”。即便当前有其他事务在修改这些数据,快照读也会去 Undo Log(回滚日志) 里找之前的老版本,从而实现非阻塞的并发读。
-
适用场景: 普通的、对实时性要求不是极高的查询。
-
代表语句:
SELECT * FROM table WHERE id = 1; -
特点:
- 不加锁: 性能极高,不会被其他事务的写操作阻塞。
- 可见性受隔离级别影响:
- RC(读已提交): 每次执行
SELECT都会生成一个新的快照,所以能读到别人刚提交的修改。 - RR(可重复读): 整个事务只在第一次
SELECT时生成快照,后面再查都用同一个,所以能保证“可重复读”。
- RC(读已提交): 每次执行
二、 当前读 (Current Read)
当前读是指读取数据库中最新、最真实的数据,并且为了防止在读取过程中数据被别人改掉,它会对读取的行进行加锁。
- 核心逻辑: 必须读取到磁盘上已经提交的最新的那一行记录,不能读回滚日志里的老版本。
- 适用场景: 涉及修改操作(更新、删除、插入)或者需要保证绝对数据实时的查询。
- 代表语句:
SELECT ... FOR UPDATE(加排他锁/X锁)SELECT ... LOCK IN SHARE MODE(加共享锁/S锁)INSERTUPDATEDELETE
- 特点:
- 加锁: 会阻塞其他事务对这些行的修改(或获取锁)。
- 保证一致性: 强制读最新,不会因为版本快照而产生数据延迟。
三大log文件
在 MySQL(尤其是使用 InnoDB 存储引擎时)中,redolog、undolog 和 binlog 是保证数据库事务 ACID 特性、崩溃恢复和主备复制的核心机制。
三大日志概览
| 特性 | Redo Log (重做日志) | Undo Log (回滚日志) | Binlog (归档日志) |
|---|---|---|---|
| 所属层级 | InnoDB 存储引擎层 | InnoDB 存储引擎层 | MySQL Server 层 |
| 文件格式 | 物理日志(记录数据页的变化) | 逻辑日志(记录相反的操作) | 逻辑日志(记录 SQL 或数据行) |
| 记录内容 | “在某个数据页上做了什么修改” | “这条记录修改前的值是多少” | “更新了哪一行” 或 “执行了某条 SQL” |
| 主要作用 | 崩溃恢复(保证持久性) | 事务回滚 & MVCC(原子性、隔离性) | 主从复制 & 数据恢复 |
| 写入方式 | 循环写(空间固定,会覆盖) | 逻辑写入(存储在回滚段中) | 追加写(文件满了切下一个) |
Redo Log:保证“不丢数据”的硬汉
作用: Redo Log 是为了实现事务的持久性(Durability)。当数据库宕机时,内存中尚未刷到磁盘的数据(脏页)会丢失,重启后通过 Redo Log 重新执行操作,确保数据不丢失。这就是所谓的 WAL(Write-Ahead Logging,预写日志) 技术。
- 产生时机: 只要有数据修改,就会先写到
redo log buffer,然后根据策略(如innodb_flush_log_at_trx_commit)刷到磁盘文件。 - 清除时机: Redo Log 的大小是固定的(通常是几个文件组成的环形结构)。当日志对应的“脏页”已经被真正刷新到磁盘数据文件(
.ibd)后,这部分日志就失效了,空间可以被覆盖重用。
Undo Log:后悔药与多版本管理
作用: Undo Log 主要负责原子性(Atomicity)和 MVCC(多版本并发控制)。
- 回滚: 如果事务失败或执行了
ROLLBACK,MySQL 利用 Undo Log 将数据改回原来的样子(记录相反的操作:比如你执行了INSERT,它记录一个DELETE)。 - MVCC: 当一个事务在读取数据时,如果有另一个事务正在修改,它可以通过 Undo Log 读取到之前的版本。
- 产生时机: 在事务开始修改数据之前,会先记录该行的原始状态到 Undo Log 中。
- 清除时机: 事务提交后,Undo Log 不会立刻删除。它需要等待 purge 线程 确认没有其他事务再需要这个版本(用于 MVCC)时,才会异步地进行清理。
Binlog:集群复制与数据溯源
作用: Binlog 记录了所有修改数据库结构的 DDL 和 DML 语句,属于 MySQL Server 层,与引擎无关。
- 主从复制: Master 将 Binlog 发送给 Slave,Slave 重放实现数据同步。
- 数据恢复: 如果数据库被误删,可以结合全备和 Binlog 进行“增量恢复”到指定时间点。
- 产生时机: 事务提交时,一次性将整个事务的日志写入 Binlog 文件。
- 清除时机: 通过配置项
expire_logs_days(或新版本的binlog_expire_logs_seconds)设置保存天数。超过时间后,系统会自动删除旧文件。
MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 MySQL InnoDB 存储引擎实现隔离级别(主要是 RC 读已提交 和 RR 可重复读)的核心机制。
串行化和读未提交这两个事务级别没有用到MVCC
简单来说,它的目的是:让数据库在处理读写冲突时,不用加锁也能保证数据的安全性,从而大幅提升并发性能。
MVCC 的核心实现原理
MVCC 的实现依赖于三个关键组件:隐藏列、Undo Log(回滚日志) 和 ReadView(一致性视图)。
隐藏列
InnoDB 在每行数据后面都会默默添加几个隐藏字段:
DB_TRX_ID:最近修改这条数据的事务 ID。DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本(存放在 Undo Log 中)。DB_ROW_ID:如果表没有主键,InnoDB 会自动生成的隐藏主键。
Undo Log(版本链)
当一个事务修改数据时,InnoDB 不会直接覆盖旧数据,而是先把旧数据写入 Undo Log。 通过隐藏列中的 DB_ROLL_PTR 指针,将这些旧版本的数据串联起来,形成一个版本链。
ReadView(一致性视图)
ReadView 是事务在进行“快照读”时产生的一个结构,它定义了哪些数据版本对当前事务是可见的。它包含四个核心字段:
m_ids:生成 ReadView 时,系统中当前正在活跃(未提交)的事务 ID 列表。min_trx_id:活跃事务中最小的 ID。max_trx_id:系统即将分配给下一个事务的 ID 值。creator_trx_id:创建这个 ReadView 的事务 ID。
可见性判断规则
当事务想读某行数据时,会拿着该行当前的 trx_id 与 ReadView 进行比对:
trx_id==creator_trx_id:这个版本是我自己改的,可见。trx_id<min_trx_id:说明这个事务在 ReadView 创建前已经提交了,可见。trx_id>=max_trx_id:说明这个事务在 ReadView 创建后才启动,不可见。min_trx_id<=trx_id<max_trx_id:- 如果
trx_id在m_ids列表中:说明事务还没提交,不可见。 - 如果不在:说明事务已提交,可见。
- 如果
如果当前版本不可见,就顺着版本链(Undo Log)往回找,直到找到可见的版本。
图示:
下面我结合图片来解释这一个过程,图片主要参考的是黑马程序员的MySQL相关课程:
假设当前我有五个事务,他们同时开始,但是事务提交的时间不一致,如下图表格所示:
| 事务2 | 事务3 | 事务4 | 事务5 |
|---|---|---|---|
| 开始事务 | 开始事务 | 开始事务 | 开始事务 |
| 修改id为30记录,age改为3 | 查询id为30的记录 | ||
| 提交事务 | |||
| 修改id为30记录,name改为A3 | |||
| 查询id为30的记录 | |||
| 提交事务 | |||
| 修改id为30记录,age改为10 | |||
| 查询id为30的记录 | |||
| 查询id为30的记录 | |||
| 提交事务 |
我们先来看一下Undo Log版本链的产生过程:
最开始我们的记录如图所示:

然后事务二开始了,它需要把id为30的数据的age改为3:

此时,InnoDB 会将旧版本数据写入 Undo Log,并让当前记录的 DB_ROLL_PTR 指向该快照,形成版本链。
接着事务三来了,它把id为30的数据的name改为A3,同样需要在UndoLog里产生一个快照记录。并调整指针的指向,最终如下图所示:

同样的道理,当事务四也修改了记录,所以也会有一个“快照记录”,我这里就直接给出最后的图:

不同的是事务或相同事务对同一条数据进行刘修改,会导致该记录的undolog生成一条记录版本的链表,链表的头部是最新的旧记录,链表的尾部是最早的旧记录。
到这里我觉得你肯定已经明白这个Undo Log版本链是如何产生的了,那么接下来我们来看看事务五的两个查询究竟会返回哪条记录!
回顾一下前面说的一致性视图(ReadView)有的地方也叫读视图,它是快照读SQL执行的时,MVCC提取数据的依据,记录并维护当前系统活跃的事务(未提交)的id。这个视图和隔离级别有一定的关系,比如在RC(Read Committed)级别下:每次select都会生成一个快照读。而在RR(Repeatable Read)开启事务后的第一个select语句才是产生快照读的地方。
ReadView维护了四个字段:
| 字段 | 含义 |
|---|---|
| m_ids | 当前活跃的事务ID集合,只有未提交(Active)的事务才会进入 m_ids |
| min_trx_id | 最小活跃事务ID |
| max_trx_id | ReadView生成时,系统即将分配给下一个事务的 ID |
| creator_trx_id | ReadView创建者的事务ID |
这里我来再说一下这个活跃的事务ID是啥意思,以我开始的那个表格为例:当我的事务五的第一个查询语句时刻,那个时刻,活跃的事务ID一共有3,4,5。因为事务一已经commit了。
我现在先以RC隔离级别来说一下这个ReadView是如何生成的:

在RC模式下,每次select都会有一个属于自己的ReadView。
然后获取记录,需要按照如下的规则,并结合开始版本链来完成,先看规则,规则如下:
| 序号 | 判断条件 | 结论 | 详细说明 |
|---|---|---|---|
| ① | trx_id == creator_trx_id | 可以访问该版本 | 说明数据是当前这个事务自己更改的。 |
| ② | trx_id < min_trx_id | 可以访问该版本 | 说明该事务在 ReadView 生成前已经提交了。 |
| ③ | trx_id > max_trx_id | 不可以访问该版本 | 说明该版本的事务在当前 ReadView 生成后才启动,因此不可见。 |
| ④ | min_trx_id <= trx_id <= max_trx_id | 视情况而定 | 如果 trx_id 不在 m_ids 中,可以访问。说明该事务在 ReadView 生成前已提交。 |
现在我针对第一个读视图来,看一下它获取的是哪一条数据:

目前版本链中有 4 条记录(1 条当前记录,3 条在 Undo Log 中)。
ReadView 状态: m\_ids: \{3, 4, 5\},min\_trx\_id: 3,max\_trx\_id: 6,creator\_trx\_id: 5。
我们从最新版本(当前记录)开始按顺序往后找:
- 分析当前记录(trx\_id = 4)
- 规则①(是否是自己): trx\_id(4) \neq creator\_trx\_id(5),不是当前事务修改的。
- 规则②(是否已提交): trx\_id(4) \nless min\_trx\_id(3),说明该版本在 ReadView 生成时可能还未提交。
- 规则③(是否太新): trx\_id(4) < max\_trx\_id(6),说明该事务不是在 ReadView 生成后才开启的。
- 规则④(活跃事务判断): 虽然 trx\_id 在 3 和 6 之间,但 4 \in m\_ids。结论: 说明 ReadView 生成时,事务 4 还处于“活跃未提交”状态,不可见。
- 分析 Undo Log 版本链
- trx\_id = 3 的记录: 同理,虽然 3 < 6,但 3 \in m\_ids,说明事务 3 当时也未提交,不可见。
- trx\_id = 2 的记录: 此时 trx\_id(2) < min\_trx\_id(3)。
- 结论: 根据规则 ②,该版本在 ReadView 生成前就已经提交,可见。
最终结果:第一个 ReadView 最终获取的是 事务 2 提交的数据。
其实这里我觉得去套这个公式有点蠢了,你自己看你的事务隔离级别,你事务隔离级别不是读已提交吗,那么ReadView读的肯定就是最新提交的数据呀。一眼就能判断出读取的是哪个数据。RC就是找事务比自己先开启的,并且已经提交的,就可以读。
同理:如果是在RR级别下,由于会复用第一次select产生的ReadView,所以无论你读多少次,都以第一次select到的数据为准。
RC 级别: 本质上就是“取最新已提交的版本”。每次 SELECT 都重新生成 ReadView,所以它能看到别人刚提交的修改。
RR 级别: 本质上是“静态快照”。只在第一次 SELECT 时生成 ReadView,后续无论别人怎么改,它都只认第一次看到的世界。
数据库主从同步
MySQL 的主从同步(Replication)是实现高可用、负载均衡和读写分离的核心机制。其核心原理可以概括为:主库(Master)记录变更日志,从库(Slave)读取并重放这些日志。
日志文件:
- Binary Log (Binlog):存在于主库,记录了所有修改数据库结构的 DDL 和修改数据的 DML 操作。
- Relay Log (中继日志):存在于从库,它是从主库拿过来的 Binlog 的临时“中转站”。
线程:
- Binlog Dump Thread (主库):当从库连接时,主库会创建该线程,负责发送 Binlog 内容给从库。
- I/O Thread (从库):负责连接主库,请求 Binlog 内容,并将其写入本地的 Relay Log。
- SQL Thread (从库):负责读取 Relay Log 中的内容,并在从库本地执行,从而保持数据一致。
三种同步方式
| 模式 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 异步复制 (Asynchronous) | 主库写完 Binlog 后立即返回客户端,不关心从库是否收到。 | 性能最高,延迟最低。 | 如果主库宕机且日志未传到从库,可能丢失数据。 |
| 半同步复制 (Semi-Synchronous) | 主库至少等待一个从库确认收到 Relay Log 后才返回。 | 数据安全性较高。 | 会增加主库写操作的响应时间(RT)。 |
| 全同步复制 (Fully Synchronous) | 必须等所有从库都执行完并反馈后才返回。 | 数据最安全。 | 性能损耗巨大,通常由集群方案(如 MGR)实现。 |
分库分表
当单表数据量达到千万级,或者单库并发请求过高时,MySQL 的性能会显著下降。这时候就需要考虑分库分表。
分库分表的核心思想是:化整为零,分而治之。
1. 拆分的两个维度
分库分表通常分为“垂直拆分”和“水平拆分”两种方式:
垂直拆分(Vertical Splitting)
- 垂直分库:按业务模块将表拆分到不同的数据库中。例如:将用户表、订单表、商品表分别放在
user_db、order_db、product_db。 - 垂直分表:将一张表中的字段拆分到多张表中。通常将 高频访问列 和 低频/大字段列(如
text类型的备注、详情)分开,以减少单行数据的体积。
水平拆分(Horizontal Splitting)
- 水平分表:表的结构不变,按某种规则将数据行存入不同的表中(如
order_0,order_1)。 - 水平分库:将不同的数据行存入不同的物理数据库实例中,主要解决单机高并发和磁盘 IO 的瓶颈。
2. 数据的分片策略(Sharding Strategy)
如何决定一条数据该去哪个表?常见的策略有:
- 取模(Hash):
- 原理:
user_id % 4,将数据均匀分布在 4 个分片。 - 优点:数据分布均匀,不易产生热点。
- 缺点:扩容极其麻烦(需要全量数据迁移)。
- 原理:
- 范围(Range):
- 原理:按时间(如每月一张表)或按自增 ID 范围。
- 优点:扩容简单,无需迁移历史数据。
- 缺点:容易产生热点问题(比如最近一个月的数据会被疯狂读写,而老的表却很闲)。
- 查找表(Map):
- 原理:在元数据库中记录每条数据对应的分片位置。虽然灵活,但维护成本高且有性能损耗。
3. 分库分表带来的技术挑战
这是面试和实际架构中最难的部分,因为拆分后会破坏原生数据库的特性:
- 分布式事务问题:原本一个事务就能解决的写操作,现在跨了库。通常需要引入 Seata 这种分布式事务中间件,或者使用 TCC、可靠消息最终一致性 等方案。
- 跨节点 Join 问题:无法直接在 SQL 里 Join 不同库的表。
- 对策:字段冗余(空间换时间)、全局表(每个库都存一份小表)、在应用层(Java 代码)多次查询后组装。
- 分页与排序(Max/Count/Order By):
- 对策:需要在每个分片执行后,在中间件层面进行归并排序。如果偏移量(Offset)很大,性能会急剧下降。
- 全局唯一 ID:单表自增 ID 失效。
- 对策:使用 雪花算法(Snowflake) 或分布式 ID 生成器。
4. 常见的中间件解决方案
目前主流的方案分为两类:
| 类型 | 代表产品 | 优点 | 缺点 |
|---|---|---|---|
| 客户端代理 (SDK) | ShardingSphere-JDBC | 性能高,无需独立部署,支持 Spring Boot。 | 对代码有侵入性,升级需重启应用。 |
| 服务端代理 (Proxy) | MyCat, ShardingSphere-Proxy | 对代码透明(像连 MySQL 一样),跨语言。 | 增加了一层网络消耗,需要运维中间件。 |
5. 什么时候该分库分表?
不要为了“高大上”而分。阿里巴巴开发手册建议:单表行数超过 500 万行或者单表容量超过 2GB 时才考虑。
在动手之前,应优先考虑以下顺序:
- SQL 优化与索引优化。
- 读写分离(利用主从架构缓解读压力)。
- 缓存(Redis)。
- 冷热数据分离(将历史旧数据归档)。
- 最后才是分库分表