主题
七、MySQL 数据库(18 题)
7.1 ★★★★MySQL 的存储引擎 InnoDB 和 MyISAM 的区别是什么?为什么 InnoDB 是默认引擎?
📖 点击查看答案
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 事务 | 支持 ACID | 不支持 |
| 锁粒度 | 行锁(默认)+ 表锁 | 仅表锁 |
| 外键 | 支持 | 不支持 |
| 索引结构 | B+ 树聚簇索引(数据和主键索引在一起) | B+ 树非聚簇(索引和数据分离) |
| 全文索引 | 5.6+ 支持 | 支持 |
| 崩溃恢复 | 支持(redo log) | 不支持 |
| MVCC | 支持 | 不支持 |
| 主键查询 | 快(直接定位数据) | 慢(需回表) |
| COUNT(*) | 需扫描(MVCC 下不能缓存) | 有缓存(存储总行数) |
| 适用场景 | OLTP、高并发读写 | 只读、统计(已弃用) |
InnoDB 成为默认引擎原因(MySQL 5.5+):
- 支持事务,满足 OLTP 业务需求
- 行锁高并发性能好
- 支持外键,保证数据完整性
- redo log 保证崩溃恢复,数据安全
- MVCC 实现非阻塞读,读写不冲突
- 聚簇索引对主键查询性能优秀
- MyISAM 表锁高并发差,崩溃易损坏
InnoDB 关键特性:
- 插入缓冲(Insert Buffer / Change Buffer):对非唯一二级索引的插入先缓冲,再合并
- 两次写(Double Write):防止页撕裂,保证数据完整性
- 自适应哈希索引(AHI):对热点数据自动建哈希索引
- 异步 IO(AIO):提高 I/O 性能
- 刷新邻接页(Flush Neighbor Page):顺序 I/O 优化
7.2 ★★★MySQL 为什么选择 B+ 树作为索引结构?对比 B 树、Hash、红黑树?
📖 点击查看答案
B+ 树优势:
- 非叶子节点不存数据,只存索引:每个节点可存更多索引,树更矮,磁盘 I/O 次数少(B+ 树 3-4 层即可存储千万级数据)
- 所有数据在叶子节点:查询稳定,都要走到叶子节点,时间复杂度稳定 O(logN)
- 叶子节点形成双向链表:范围查询高效(
WHERE id BETWEEN 1 AND 100只需定位起点遍历链表) - 节点大小等于页大小(16KB):一次 I/O 读取一页,充分利用局部性原理
对比 B 树:
- B 树非叶子节点也存数据,每个节点索引数少,树更高
- B 树范围查询需中序遍历,效率低
- B+ 树叶子链表支持高效范围扫描
对比 Hash 索引:
- Hash 查询 O(1),但不支持范围查询、排序、最左前缀匹配
- Hash 碰撞问题
- 仅适用于等值查询(如 Memory 引擎)
- InnoDB 自适应哈希索引(AHI)对热点数据自动建 Hash
对比红黑树(二叉平衡树):
- 二叉树,每个节点只有 2 个子节点,树高 log₂(N),千万数据约 23 层
- 树高过高,磁盘 I/O 次数多
- B+ 树是多叉(通常 1000+ 叉),3-4 层即可
对比 AVL 树:
- 同样是二叉树,树高问题
- 旋转调整成本高
对比跳表:
- Redis ZSet 用跳表(内存场景,实现简单)
- 磁盘场景 B+ 树更适合(页对齐、节点大)
总结:B+ 树在磁盘存储场景下综合性能最优,适合数据库的等值查询、范围查询、排序需求。
7.3 ★★★★聚簇索引和非聚簇索引的区别是什么?什么是回表查询?如何避免回表?
📖 点击查看答案
聚簇索引(Clustered Index):
- 数据行按主键顺序存储,数据和主键索引在同一棵 B+ 树中
- 叶子节点直接存储完整数据行
- 一张表只能有一个聚簇索引(通常是主键)
- InnoDB 的主键索引是聚簇索引
非聚簇索引(Secondary / Non-Clustered Index):
- 也叫二级索引、辅助索引
- 叶子节点存储的是主键值,而非完整数据行
- 一张表可有多个非聚簇索引
- InnoDB 的非主键索引都是非聚簇索引
MyISAM 索引:
- 全部是非聚簇索引,数据和索引分离存储
- 叶子节点存储数据行的物理地址
回表(Table Lookup):
- 通过非聚簇索引查询时,先在二级索引 B+ 树找到主键值
- 再到聚簇索引 B+ 树中查找完整数据行
- 两次 B+ 树查找,称为回表
sql
-- 假设有索引 idx_name(name)
SELECT * FROM users WHERE name = 'Tom';
-- 1. 在 idx_name 树找到 name='Tom' 对应的主键 id=5
-- 2. 回表到聚簇索引找到 id=5 的完整数据行如何避免回表:
覆盖索引(Covering Index):
- 索引包含查询所需的所有列,直接在索引中获取数据
sql-- 建联合索引 idx_name_age(name, age) SELECT name, age FROM users WHERE name = 'Tom'; -- 直接从 idx_name_age 获取,无需回表- Explain 中 Extra 显示
Using index表示用覆盖索引
避免
SELECT *:- 只查需要的列,配合覆盖索引
将常用字段加入联合索引:
- 但索引列不宜过多(索引膨胀)
主键设计优化:
- 主键用自增整型,插入顺序,减少页分裂
- 避免长字符串主键(二级索引存储主键值,空间浪费)
7.4 ★★★★什么是覆盖索引?什么是回表?什么场景下适合使用覆盖索引?
📖 点击查看答案
覆盖索引:
- 一个索引包含了查询所需的全部字段(
SELECT、WHERE、ORDER BY、GROUP BY涉及的列) - 查询可直接从索引 B+ 树获取数据,无需回表到聚簇索引
- Explain 中 Extra 显示
Using index
回表:
- 通过二级索引找到主键值后,再到聚簇索引查找完整数据行的过程
- 需要两次 B+ 树查找,性能较低
sql
-- 表结构
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50),
INDEX idx_name_age(name, age)
);
-- 1. 需要回表(SELECT 包含索引外的列 city)
SELECT * FROM users WHERE name = 'Tom';
-- Extra: NULL
-- 2. 覆盖索引(只查 name, age)
SELECT name, age FROM users WHERE name = 'Tom';
-- Extra: Using index
-- 3. 覆盖索引(WHERE + SELECT 都在索引内)
SELECT name, age FROM users WHERE name = 'Tom' AND age > 18;
-- Extra: Using index适合覆盖索引的场景:
高频查询的特定列组合:
- 如用户列表查询
SELECT id, name, age FROM users WHERE name = ? - 建
idx_name_age(name, age, id)或直接用主键 id(聚簇索引天然包含)
- 如用户列表查询
统计查询:
SELECT COUNT(*), city FROM users WHERE age > 18 GROUP BY city- 建
idx_age_city(age, city)避免Using temporary和Using filesort
排序分页:
SELECT name, age FROM users ORDER BY name LIMIT 100- 建
idx_name_age(name, age)利用索引有序性
避免 SELECT * 的场景:
- 明确只查某几列,将这几列加入联合索引
查询列少且频繁:
- 索引列不宜过多(每列都占空间),适合列数少的查询
注意事项:
- 覆盖索引会增加索引列数,占用更多存储空间
- 写操作(INSERT/UPDATE/DELETE)需维护多个索引,性能下降
- 应根据实际查询模式权衡,不是所有查询都需要覆盖索引
7.5 ★★★★联合索引的最左前缀匹配原则是什么?联合索引 (a, b, c) 在哪些查询条件下会失效?
📖 点击查看答案
最左前缀匹配原则:
- 联合索引
(a, b, c)按 a → b → c 的顺序建立 B+ 树 - 查询条件必须从最左列开始,按顺序匹配
- 遇到范围查询则停止匹配后续列
联合索引 (a, b, c) 的 B+ 树结构:
- 先按 a 排序,a 相同按 b 排序,b 相同按 c 排序
- 因此只能利用 a 开头、连续的列
有效使用索引的情况:
sql
-- 1. 全部使用
WHERE a = 1 AND b = 2 AND c = 3 -- 使用 abc
-- 2. 前缀使用
WHERE a = 1 -- 使用 a
WHERE a = 1 AND b = 2 -- 使用 ab
-- 3. 顺序无关(优化器会调整)
WHERE b = 2 AND a = 1 -- 使用 ab
WHERE c = 3 AND a = 1 AND b = 2 -- 使用 abc
-- 4. 范围查询后续列仍可用(MySQL 5.6+ ICP)
WHERE a = 1 AND b > 2 AND c = 3 -- 使用 ab,c 通过 ICP 过滤索引失效的情况:
跳过最左列:
sqlWHERE b = 2 -- 不使用索引(无 a) WHERE c = 3 -- 不使用索引(无 a) WHERE b = 2 AND c = 3 -- 不使用索引(无 a)范围查询后的列:
sqlWHERE a > 1 AND b = 2 -- 只使用 a,b 失效(a 是范围,b 无法利用索引有序性) WHERE a = 1 AND b > 2 AND c = 3 -- 使用 a、b,c 失效(但 ICP 可用)对索引列使用函数/计算:
sqlWHERE LEFT(a, 1) = '1' -- 失效 WHERE a + 1 = 2 -- 失效 WHERE a = 1 -- 正常 WHERE DATE(create_time) = '2024-01-01' -- 失效,改为 create_time >= '2024-01-01' AND create_time < '2024-01-02'类型隐式转换:
sql-- a 是 VARCHAR,但传数字 WHERE a = 1 -- 失效(隐式转换相当于 CAST(a AS INT)) WHERE a = '1' -- 正常LIKE 以通配符开头:
sqlWHERE a LIKE '%abc' -- 失效 WHERE a LIKE 'abc%' -- 正常(前缀匹配) WHERE a LIKE 'abc_def' -- 正常OR 条件:
sqlWHERE a = 1 OR b = 2 -- a 走索引,b 不走(除非 b 也有索引) WHERE a = 1 OR d = 2 -- 若 d 无索引则全表扫描NOT / != / NOT IN / NOT LIKE:
sqlWHERE a != 1 -- 通常失效(扫描大部分数据,优化器选择全表)ORDER BY 与索引方向不一致:
sqlORDER BY a DESC, b ASC -- b 无法利用索引(MySQL 8.0+ 支持降序索引)
7.6 ★★★什么是索引下推(ICP)?Explain 中哪些字段可以确认索引下推生效?
📖 点击查看答案
索引下推(Index Condition Pushdown,ICP):
- MySQL 5.6 引入,针对联合索引的优化
- 在存储引擎层(而非 Server 层)对索引列进行条件过滤,减少回表次数
传统流程(无 ICP):
- 存储引擎根据联合索引第一个条件找到匹配的主键
- 回表取完整数据行
- Server 层根据其他条件过滤
ICP 流程:
- 存储引擎根据联合索引遍历
- 在索引上直接判断其他条件(联合索引中的列),不符合则跳过
- 符合的才回表
- Server 层做剩余条件过滤(非索引列)
sql
-- 联合索引 idx_name_age(name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age > 18;
-- 无 ICP:
-- 1. 存储引擎找到所有 name LIKE '张%' 的记录(可能 1000 条)
-- 2. 回表 1000 次取完整数据
-- 3. Server 层过滤 age > 18(可能只剩 100 条)
-- 有 ICP:
-- 1. 存储引擎找到 name LIKE '张%' 的记录
-- 2. 在索引上判断 age > 18(索引中有 age),不符合跳过
-- 3. 只对符合的记录回表(100 次)
-- 减少 900 次回表Explain 中确认 ICP 生效的字段:
Extra列显示Using index condition:表示使用了索引下推- 对比:
Using index:覆盖索引,无需回表Using index condition:索引下推,先在索引过滤再回表Using where:Server 层过滤(回表后再过滤)
sql
EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND age > 18;
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+
-- | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+
-- | 1 | SIMPLE | users | NULL | range | idx_name_age | idx_name_age | 156 | NULL | 1000 | 33.33 | Using index condition |
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+ICP 适用条件:
- 联合索引
- 范围查询后的列仍可被利用
- WHERE 条件中包含索引列
- 不支持子查询、存储函数
ICP 局限:
- 只能下推 WHERE 条件,不能下推 JOIN ON 条件
- 不支持虚拟列上的条件
7.7 ★★★EXPLAIN 的各个字段含义是什么?如何根据 Explain 结果优化慢 SQL?type 列的各种值分别代表什么含义?
📖 点击查看答案
EXPLAIN 关键字段:
| 字段 | 含义 |
|---|---|
id | 查询序号,相同表示同一步骤,不同则从大到小执行 |
select_type | SIMPLE(简单查询)、PRIMARY(最外层)、SUBQUERY(子查询)、DERIVED(派生表)、UNION |
table | 表名 |
type | 访问类型,性能从好到差:system > const > eq_ref > ref > range > index > ALL |
possible_keys | 可能使用的索引 |
key | 实际使用的索引(NULL 表示未用索引) |
key_len | 索引使用的字节数,可判断联合索引用了几个列 |
ref | 索引比较的列或常量,如 const、db.t1.id |
rows | 预估扫描行数(越小越好) |
filtered | 过滤后剩余比例(百分比) |
Extra | 额外信息(重要) |
type 列值含义(性能从好到差):
system:表只有一行(系统表)const:通过主键或唯一索引等值查询,最多返回一行sqlWHERE id = 1 -- 主键等值eq_ref:JOIN 时被驱动表使用主键或唯一索引,最多匹配一行sqlJOIN b ON a.id = b.a_id -- b.a_id 是唯一索引ref:通过普通索引等值查询,可能返回多行sqlWHERE name = 'Tom' -- name 是普通索引range:索引范围扫描sqlWHERE id BETWEEN 1 AND 100 WHERE id IN (1, 2, 3) WHERE id > 10index:扫描整个索引树(不回表,比 ALL 快)sqlSELECT COUNT(*) FROM users; -- 扫描最小索引ALL:全表扫描(最差,需优化)
Extra 关键值:
Using index:覆盖索引,无需回表(好)Using index condition:索引下推(ICP)Using where:Server 层过滤(回表后过滤)Using temporary:使用临时表(需优化,常见于 GROUP BY、DISTINCT)Using filesort:文件排序(需优化,常见于 ORDER BY 未用索引)Using join buffer:使用 Join Buffer(被驱动表无索引)Impossible WHERE:WHERE 条件恒为假
优化慢 SQL 步骤:
看
type:避免ALL,至少range,最好ref/const看
key:是否走了预期索引,未走则调整 SQL 或加索引看
rows:扫描行数是否过大,过大需优化看
Extra:Using filesort:ORDER BY 列加索引或调整Using temporary:GROUP BY 列加索引Using where:考虑覆盖索引避免回表
看
key_len:判断联合索引用了几列key_len = 字段长度 + 是否可空(1) + 变长(2)- 如
INT NOT NULL= 4,VARCHAR(50) NOT NULL utf8mb4= 50*4 + 2 = 202
优化建议:
- 加合适索引(联合索引、覆盖索引)
- 避免
SELECT * - 避免函数操作索引列
- 大表分页用游标:
WHERE id > last_id LIMIT 100替代LIMIT 1000000, 100
7.8 ★★★★MySQL 事务的 ACID 特性是如何实现的?(MVCC + undo log、redo log、锁机制)
📖 点击查看答案
ACID 特性及实现机制:
1. 原子性(Atomicity):
- 通过 undo log(回滚日志) 实现
- 事务执行前先写 undo log,记录修改前的旧值
- 事务回滚时根据 undo log 恢复数据
- undo log 还用于 MVCC 读取历史版本
事务:UPDATE users SET name='Tom' WHERE id=1;
-- undo log 记录:id=1, name='old_value'
-- 回滚:根据 undo log 恢复 name='old_value'2. 持久性(Durability):
- 通过 redo log(重做日志) 实现
- 事务修改数据时,先写 redo log(WAL,Write-Ahead Logging),再修改 Buffer Pool 中的数据页
- redo log 是物理日志(记录页的修改),顺序写性能高
- 事务提交时 redo log 刷盘(
innodb_flush_log_at_trx_commit=1默认) - 崩溃恢复时重放 redo log,恢复已提交但未刷盘的数据
3. 隔离性(Isolation):
- 通过 锁机制 + MVCC 实现
- 写操作用锁保证互斥(行锁、间隙锁)
- 读操作用 MVCC(多版本并发控制)避免阻塞写
- 不同隔离级别对应不同的锁和 MVCC 策略
4. 一致性(Consistency):
- 由原子性 + 持久性 + 隔离性共同保证
- 应用层约束(如外键、约束、业务逻辑)也参与
- 事务执行前后数据满足完整性约束
redo log 工作流程(WAL):
1. 修改数据 → 先写 redo log buffer(内存)
2. 事务提交 → redo log buffer 刷到 redo log file(OS cache 或 fsync 到磁盘)
3. 异步将 Buffer Pool 脏页刷到数据文件(checkpoint)
4. 崩溃恢复 → 重放 redo log,恢复未刷盘的已提交数据undo log 工作流程:
1. 修改数据前 → 写 undo log 到 undo log segment
2. 通过回滚指针 `roll_pointer` 将数据行的历史版本串联成版本链
3. 回滚 → 沿版本链恢复
4. MVCC 读 → 根据 ReadView 沿版本链找到可见版本
5. undo log 在无事务引用时由 purge 线程清理MVCC(Multi-Version Concurrency Control):
- 每行数据有隐藏字段:
trx_id(最近修改的事务ID)、roll_pointer(指向 undo log 版本链) - 读操作根据 ReadView 判断版本可见性,实现非阻塞读
- RC 级别每次 SELECT 生成新 ReadView,RR 级别事务首次 SELECT 生成 ReadView
锁机制:
- 行锁:Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁)
- 表锁:表锁、MDL 锁、IS/IX 意向锁
- 不同隔离级别锁策略不同:RR 用 Next-Key Lock 防幻读,RC 用 Record Lock
7.9 ★★★★MySQL 事务隔离级别有哪些?InnoDB 的默认隔离级别是什么?为什么选择 RR(可重复读)?
📖 点击查看答案
四个隔离级别(从低到高):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现机制 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 | 无锁 |
| READ COMMITTED(读已提交,RC) | 避免 | 可能 | 可能 | MVCC(每次 SELECT 新 ReadView) |
| REPEATABLE READ(可重复读,RR) | 避免 | 避免 | 避免* | MVCC + Next-Key Lock |
| SERIALIZABLE(串行化) | 避免 | 避免 | 避免 | 所有读加共享锁 |
*InnoDB 的 RR 通过 Next-Key Lock 在一定程度上避免了幻读,但特定场景下仍有幻读。
InnoDB 默认隔离级别:RR(REPEATABLE READ)
为什么选择 RR 而不是 RC:
历史原因:早期 MySQL binlog 只有 statement 格式,RC 级别下主从复制会有数据不一致问题
- Statement 格式记录 SQL 语句,RC 下并发执行顺序不确定,导致从库数据与主库不同
- RR 配合
binlog_format=ROW才能保证一致性,但早期默认 statement
数据一致性:
- RR 在事务内多次读结果一致,业务逻辑更稳定
- 避免不可重复读带来的业务问题
主从复制安全:
- statement 格式下,RR 的顺序执行保证主从一致
- 现在用 ROW 格式后,RC 也可用于主从复制,但 RR 仍是默认
为什么不用 SERIALIZABLE:
- 性能极差,所有读加锁,并发性几乎为 0
RR 下幻读问题:
- Next-Key Lock(Record + Gap)防止其他事务在范围内插入,解决大部分幻读
- 但当前事务内自己执行 INSERT/UPDATE 后再 SELECT,可能看到新插入的行(快照读 vs 当前读混用)
sql
-- RR 下幻读示例
BEGIN;
SELECT * FROM users WHERE id > 10; -- 快照读,看不到 id=15
-- 另一事务 INSERT id=15 并提交
UPDATE users SET name='Tom' WHERE id = 15; -- 当前事务 UPDATE 触发当前读
SELECT * FROM users WHERE id > 10; -- 此时能看到 id=15(幻读)
COMMIT;查看/设置隔离级别:
sql
SELECT @@transaction_isolation; -- 查看当前隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置会话级别7.10 ★★★脏读、不可重复读和幻读分别是什么?每种隔离级别分别解决了什么问题?
📖 点击查看答案
三种读异常:
1. 脏读(Dirty Read):
- 事务 A 读到了事务 B 未提交的修改
- 事务 B 回滚后,事务 A 读到的数据是无效的(脏数据)
sql
-- 事务 A
BEGIN;
SELECT name FROM users WHERE id = 1; -- name='Tom'
-- 事务 B
BEGIN;
UPDATE users SET name='Jerry' WHERE id = 1; -- 未提交
-- 事务 A 再读
SELECT name FROM users WHERE id = 1; -- name='Jerry'(脏读,B 未提交)
-- 事务 B 回滚
ROLLBACK;2. 不可重复读(Non-Repeatable Read):
- 事务 A 两次读同一行,结果不同
- 中间被事务 B 修改或删除并提交
sql
-- 事务 A
BEGIN;
SELECT name FROM users WHERE id = 1; -- name='Tom'
-- 事务 B
BEGIN;
UPDATE users SET name='Jerry' WHERE id = 1;
COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1; -- name='Jerry'(不可重复读)3. 幻读(Phantom Read):
- 事务 A 两次执行同一范围查询,结果集行数不同
- 中间被事务 B 插入新行并提交
sql
-- 事务 A
BEGIN;
SELECT * FROM users WHERE age > 18; -- 10 条
-- 事务 B
BEGIN;
INSERT INTO users(name, age) VALUES('New', 20);
COMMIT;
-- 事务 A 再查
SELECT * FROM users WHERE age > 18; -- 11 条(幻读)不可重复读 vs 幻读:
- 不可重复读:同一行内容变化(UPDATE/DELETE)
- 幻读:结果集行数变化(INSERT)
各隔离级别解决的问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 不解决 | 不解决 | 不解决 |
| READ COMMITTED(RC) | 解决 | 不解决 | 不解决 |
| REPEATABLE READ(RR) | 解决 | 解决 | 解决*(InnoDB 通过 Next-Key Lock) |
| SERIALIZABLE | 解决 | 解决 | 解决 |
InnoDB RR 级别如何解决幻读:
- 快照读(普通 SELECT):通过 MVCC,事务内始终读同一个 ReadView 快照,看不到新插入的行
- 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE):通过 Next-Key Lock 锁住范围,阻止其他事务插入
sql
-- 当前读 + Next-Key Lock
SELECT * FROM users WHERE age > 18 FOR UPDATE;
-- 锁住 age > 18 的所有记录 + 间隙,其他事务无法 INSERTRR 下仍存在的幻读场景:
- 先快照读(无锁),再 UPDATE 触发当前读,可能看到新插入的行
- 需在首次查询用
FOR UPDATE加锁才能完全避免
7.11 ★★★★MVCC 的实现原理是什么?ReadView 是如何工作的?MVCC 在 RR 和 RC 级别下的行为有什么区别?
📖 点击查看答案
MVCC(Multi-Version Concurrency Control)实现原理:
1. 数据行的隐藏字段(InnoDB 每行数据有):
trx_id:最近修改该行的事务 ID(6 字节)roll_pointer:回滚指针,指向 undo log 中的上一版本(7 字节)- 通过
roll_pointer将历史版本串联成版本链(undo log 链表)
2. undo log 版本链:
当前数据:id=1, name='Tom', trx_id=100
↓ roll_pointer
undo log v3:id=1, name='Jerry', trx_id=90
↓
undo log v2:id=1, name='Old', trx_id=80
↓
undo log v1:插入时的原始数据3. ReadView 结构:
m_ids:生成 ReadView 时活跃(未提交)事务 ID 列表min_trx_id:m_ids 中最小的事务 IDmax_trx_id:下一个将分配的事务 ID(系统最大事务 ID + 1)creator_trx_id:生成 ReadView 的事务 ID
ReadView 可见性判断规则(沿版本链查找):
对于当前版本的 trx_id:
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:- 在
m_ids中:活跃事务,不可见 - 不在
m_ids中:已提交,可见
- 在
- 不可见则沿
roll_pointer找上一版本,重复判断
RC 和 RR 的区别:
| 特性 | RC(读已提交) | RR(可重复读) |
|---|---|---|
| ReadView 生成时机 | 每次 SELECT 都生成新 ReadView | 事务内首次 SELECT 生成 ReadView,后续复用 |
| 效果 | 同一事务内多次读可能不同(看到其他事务已提交的修改) | 同一事务内多次读结果一致(快照固定) |
| 不可重复读 | 存在 | 避免 |
| 幻读(快照读) | 存在 | 避免快照读的幻读 |
RC 级别示例:
sql
-- 事务 A(RC)
BEGIN;
SELECT name FROM users WHERE id = 1; -- ReadView1,name='Tom'
-- 事务 B
UPDATE users SET name='Jerry' WHERE id = 1; COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1; -- ReadView2,name='Jerry'(不可重复读)
COMMIT;RR 级别示例:
sql
-- 事务 A(RR)
BEGIN;
SELECT name FROM users WHERE id = 1; -- ReadView1,name='Tom'
-- 事务 B
UPDATE users SET name='Jerry' WHERE id = 1; COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1; -- 复用 ReadView1,name='Tom'(可重复读)
COMMIT;MVCC 局限性:
- 只解决快照读的并发问题,当前读(
FOR UPDATE)仍需锁 - 长事务会导致 undo log 版本链过长,影响性能
- 长事务持有旧 ReadView,导致 undo log 无法 purge,表空间膨胀
7.12 ★★★★MySQL 中有哪些锁类型?行锁、间隙锁、临键锁分别在什么场景下使用?
📖 点击查看答案
MySQL 锁分类:
按粒度:
- 全局锁:
FLUSH TABLES WITH READ LOCK,全库只读,用于备份 - 表锁:
- 表锁:
LOCK TABLES t READ/WRITE - 元数据锁(MDL):防止 DDL 和 DML 冲突
- 意向锁(IS/IX):快速判断表是否有行锁,避免全表扫描
- 表锁:
- 行锁(InnoDB 独有):
- Record Lock(记录锁):锁住索引上的一条记录
- Gap Lock(间隙锁):锁住索引记录之间的间隙,防止插入
- Next-Key Lock(临键锁):Record + Gap,锁住记录 + 前面的间隙
- Insert Intention Lock(插入意向锁):INSERT 时加,表示要在某间隙插入
按模式:
- 共享锁(S,读锁):
SELECT ... LOCK IN SHARE MODE - 排他锁(X,写锁):
SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT
Record Lock(记录锁):
- 锁住索引上的一条记录
- RC 隔离级别默认使用 Record Lock
- 示例:
SELECT * FROM users WHERE id = 1 FOR UPDATE锁住 id=1 的索引记录
Gap Lock(间隙锁):
- 锁住索引记录之间的间隙,不包括记录本身
- 防止其他事务在间隙内插入新记录
- RR 隔离级别下用于解决幻读
- 示例:假设 id 有 1, 5, 10,
WHERE id > 1 AND id < 5 FOR UPDATE锁住 (1, 5) 间隙- 其他事务无法 INSERT id=2, 3, 4
Next-Key Lock(临键锁):
- Record Lock + Gap Lock,锁住记录 + 前面的间隙
- RR 隔离级别下默认的行锁类型
- 示例:id 有 1, 5, 10,
SELECT * FROM users WHERE id = 5 FOR UPDATE- 锁住 (1, 5] 区间(间隙 (1,5) + 记录 5)
- 其他事务无法 INSERT id=2, 3, 4,也无法修改 id=5
使用场景:
| 场景 | 锁类型 | 说明 |
|---|---|---|
| RC + 等值查询 | Record Lock | 只锁匹配的记录 |
| RR + 等值查询唯一索引命中 | Record Lock | 退化为记录锁 |
| RR + 等值查询唯一索引未命中 | Gap Lock | 锁住间隙 |
| RR + 等值查询非唯一索引 | Next-Key Lock | 锁住记录 + 间隙 |
| RR + 范围查询 | Next-Key Lock | 锁住范围内所有记录 + 间隙 |
| INSERT | Insert Intention Lock | 插入意向锁,与 Gap Lock 冲突 |
示例:
sql
-- 表数据:id=1, 5, 10, 15
-- 事务 A(RR)
BEGIN;
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;
-- 锁住 (5, 10) 和 (10, 15) 的 Next-Key Lock
-- 即 (5, 15) 区间都被锁
-- 事务 B
INSERT INTO users VALUES(7, 'Tom'); -- 阻塞(7 在间隙内)
INSERT INTO users VALUES(20, 'Tom'); -- 成功(20 不在锁范围)意向锁作用:
- 表级锁,表示表中有行锁
- 加行锁前先加表意向锁,避免逐行检查
- IS 与 IS/IX 兼容,与表 X 锁冲突
7.13 ★★★★死锁在 MySQL 中是如何产生的?如何检测和处理死锁?如何从业务层面预防死锁?
📖 点击查看答案
死锁产生原因:
- 两个或多个事务相互等待对方持有的锁,形成循环依赖
- InnoDB 行锁 + 交叉更新是常见原因
死锁示例:
sql
-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 锁 id=1
-- 事务 B
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- 锁 id=2
-- 事务 A
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 等待 id=2 的锁
-- 事务 B
UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- 等待 id=1 的锁
-- 死锁!死锁检测:
- InnoDB 默认开启死锁检测(
innodb_deadlock_detect=ON) - 事务等待锁时,检测是否存在等待图(Wait-for Graph) 的环路
- 发现死锁后,回滚undo 量较小的事务(victim),让另一事务继续
- 报错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
死锁处理:
- 被回滚的事务收到 1213 错误
- 应用层捕获错误并重试事务
- 重试次数有限(如 3 次),避免无限循环
java
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
try {
accountMapper.deduct(from, amount);
accountMapper.add(to, amount);
} catch (DeadlockLoserDataAccessException e) {
// 重试
throw new RetryableException("死锁,请重试");
}
}死锁日志分析:
sql
SHOW ENGINE INNODB STATUS;
-- 查看 LATEST DETECTED DEADLOCK 部分
-- 包含两个事务的锁信息、SQL 语句业务层面预防死锁:
统一加锁顺序:
- 所有事务按相同顺序访问表和行(如按主键升序)
sql-- 转账时,先锁较小 id 的账户 if (from > to) { swap(from, to); }缩短事务:
- 事务尽量短,减少锁持有时间
- 避免在事务中执行慢操作(如 RPC 调用、文件 I/O)
降低隔离级别:
- RC 比 RR 锁范围小(无 Gap Lock),死锁概率低
- 业务允许时用 RC
使用合理的索引:
- 无索引的 UPDATE/DELETE 会锁全表(或大量行),易死锁
- 通过索引精准定位行,缩小锁范围
批量操作分批:
- 大批量 UPDATE 分小批,减少锁持有
避免事务嵌套:
- Spring
@Transactional默认传播 REQUIRED,嵌套调用共享事务
- Spring
设置锁超时:
sqlSET innodb_lock_wait_timeout = 10; -- 锁等待超时 10s- 超时后报错,避免长时间阻塞
乐观锁替代悲观锁:
- 用版本号
UPDATE ... SET version=version+1 WHERE id=? AND version=? - 无锁竞争,无死锁
- 用版本号
7.14 ★★★★redo log、undo log、binlog 三种日志各自的作用是什么?它们之间的关系是怎样的?两阶段提交机制是怎样的?
📖 点击查看答案
三种日志:
1. redo log(重做日志):
- InnoDB 引擎层日志,物理日志(记录页的修改)
- 作用:崩溃恢复,保证持久性(Durability)
- WAL(Write-Ahead Logging):先写 redo log,再修改数据页
- 大小固定,循环写(
innodb_log_files_in_group,默认 2 个文件) - 刷盘策略:
innodb_flush_log_at_trx_commit- 0:每秒刷盘(性能高,可能丢 1s 数据)
- 1:每次提交刷盘(默认,安全)
- 2:每次提交写 OS cache,每秒 fsync(折中)
2. undo log(回滚日志):
- InnoDB 引擎层日志,逻辑日志(记录反向操作)
- 作用:
- 事务回滚,保证原子性(Atomicity)
- MVCC,提供历史版本读取
- 记录修改前的旧值
- 事务提交后不立即删除,由 purge 线程清理(无活跃事务引用时)
- 存储在 undo tablespace 或系统表空间
3. binlog(归档日志):
- Server 层日志,逻辑日志(记录 SQL 语句或行变更)
- 作用:主从复制和数据恢复(PITR,Point-in-Time Recovery)
- 三种格式:
STATEMENT:记录 SQL 语句(小,但有些函数如NOW()主从不一致)ROW:记录行的变更(大,但准确,推荐)MIXED:混合(默认,自动选择)
三者关系:
| 日志 | 层级 | 作用 | 写入方式 |
|---|---|---|---|
| redo log | InnoDB | 崩溃恢复 | 循环写 |
| undo log | InnoDB | 回滚 + MVCC | 随机写,随事务清理 |
| binlog | Server | 主从复制 + 恢复 | 追加写 |
两阶段提交(2PC,Two-Phase Commit):
为什么需要:redo log 和 binlog 是两个独立的日志,若只写一个后崩溃,主从数据不一致。
流程(以 UPDATE 为例):
1. 执行器调用 InnoDB 引擎接口更新数据
2. InnoDB 写 undo log(回滚用)
3. InnoDB 更新 Buffer Pool 中的数据页
4. InnoDB 写 redo log(prepare 状态) ← 第一阶段
5. InnoDB 提交事务,返回给执行器
6. 执行器写 binlog
7. 执行器调用 InnoDB 提交事务
8. InnoDB 将 redo log 改为 commit 状态 ← 第二阶段崩溃恢复规则:
- redo log 是 prepare 状态:
- binlog 完整(有完整事务结束标记):提交事务(重放 redo log)
- binlog 不完整:回滚事务(用 undo log 回滚)
- redo log 是 commit 状态:事务已提交,重放 redo log
两阶段提交保证 redo log 和 binlog 的一致性:
- 若 redo log 写完但 binlog 未写崩溃:回滚,从库也不会有该事务
- 若 binlog 写完但 redo log 未 commit 崩溃:通过 binlog 判断提交,重放 redo log
7.15 ★★★★MySQL 主从复制的原理是什么?异步复制、半同步复制、GTID 复制的区别是什么?
📖 点击查看答案
主从复制原理:
三个线程:
- Binlog Dump 线程(主库):将 binlog 发送给从库
- IO 线程(从库):接收 binlog,写入 relay log(中继日志)
- SQL 线程(从库):读取 relay log,重放 SQL,更新数据
流程:
主库 → 写 binlog → Binlog Dump 线程 → 从库 IO 线程 → relay log → SQL 线程 → 从库数据复制方式:
1. 异步复制(默认):
- 主库执行完事务写 binlog 后立即返回客户端,不等从库确认
- 从库异步拉取 binlog
- 优点:主库性能高
- 缺点:主库崩溃可能丢数据,从库有延迟
2. 半同步复制(Semi-Sync):
- 主库执行完事务后,等待至少一个从库确认收到 binlog(写入 relay log)才返回
- 超时(
rpl_semi_sync_master_timeout,默认 10s)后降级为异步 - 优点:保证数据至少在一个从库
- 缺点:性能略降(多一次网络往返)
- MySQL 5.7+ 支持** Loss-Less** 半同步(在引擎提交前等待,避免幻读)
sql
-- 主库
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
-- 从库
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;3. 全同步复制:
- 主库等待所有从库执行完毕才返回
- 性能极差,少用
GTID 复制:
GTID(Global Transaction Identifier):
- 格式:
server_uuid:transaction_id,如3E11FA47-71CA-11E1-9E33-C80AA9429562:23 - 每个事务有全局唯一 ID
传统复制 vs GTID 复制:
| 特性 | 传统复制 | GTID 复制 |
|---|---|---|
| 定位 | 基于 binlog 文件名 + position | 基于 GTID |
| 主从切换 | 需手动计算 position,易错 | 自动找未同步的 GTID |
| 复制过滤 | 复杂 | 简单 |
| 错误恢复 | 难 | 自动跳过已执行的 GTID |
GTID 配置:
ini
# 主库
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON # 从库也要记录 binlog(级联复制)
# 从库
change master to master_auto_position=1;复制模式:
- 基于语句(STATEMENT):记录 SQL,主从可能不一致(函数如
NOW()) - 基于行(ROW):记录行变更,准确但日志大
- 混合(MIXED):默认,自动选择
多源复制(MySQL 5.7+):
- 一个从库可从多个主库复制
- 适用于数据汇聚场景
7.16 ★★★★主从延迟产生的原因是什么?有哪些解决方案?如何从业务层面规避?
📖 点击查看答案
主从延迟原因:
从库单线程重放(MySQL 5.6 之前):
- SQL 线程串行执行 relay log,主库并发写,从库追不上
- MySQL 5.7+ 支持并行复制(基于 group commit)
网络延迟:
- 主从跨机房、跨地域,网络 RTT 大
从库性能差:
- 从库硬件配置低,或承担读压力大
大事务:
- 单个大事务(如批量 DELETE 100 万行)执行慢,从库需同样时间重放
DDL 操作:
- DDL(如 ALTER TABLE)在从库执行期间阻塞其他事务
从库负载高:
- 从库承担报表查询、备份等,影响 SQL 线程执行
binlog 传输延迟:
- 大量 binlog 传输慢
解决方案:
1. 并行复制(MySQL 5.7+):
ini
slave_parallel_type = LOGICAL_CLOCK # 基于组提交的逻辑时钟
slave_parallel_workers = 16 # 并行工作线程数
slave_preserve_commit_order = ON # 保持提交顺序2. 半同步复制:保证 binlog 至少到一个从库,但仍有延迟
3. 从库优化:
- 升级硬件(CPU、SSD)
- 从库不承担重负载(如备份用单独的延迟从库)
4. 减少大事务:
- 大批量操作分批执行
- 避免长事务
5. 读写分离策略:
- 强制读主:写后立即读的场景,从主库读
- 延迟判断:延迟超过阈值时读主
业务层面规避:
1. 写后立即读强制走主库:
java
@Transactional
public void updateUser(Long id, String name) {
userMapper.update(id, name);
// 写后立即读,从主库读
User user = userMapper.selectByPrimaryKey(id); // 走主库数据源
}2. 关键业务读主:
- 账户余额、订单状态等强一致性场景直接读主库
3. 延迟感知路由:
java
// 检查从库延迟
long delay = replicationMonitor.getDelay(slave);
if (delay > 1000) { // 延迟超过 1s
return readFromMaster();
} else {
return readFromSlave();
}4. 会话保持:
- 用户写操作后,短时间内(如 3s)该用户读走主库
- 通过 Session 或 Token 记录
5. 最终一致性业务:
- 如商品列表、评论等,从库延迟可接受
6. 避免 SELECT SLEEP 等:
- 业务避免依赖立即读到最新数据
7. 分布式事务:
- 跨服务写后,通过消息队列通知,而非依赖从库读
7.17 ★★★★慢 SQL 优化的一般思路是什么?如何定位、分析、优化一条慢查询?
📖 点击查看答案
定位慢 SQL:
- 开启慢查询日志:
sql
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1s 记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未用索引的查询- 使用 Performance Schema / sys 库:
sql
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile;- 使用工具:
mysqldumpslow:汇总慢查询日志pt-query-digest(Percona Toolkit):详细分析慢日志- APM 工具:Datadog、SkyWalking
分析慢 SQL:
- EXPLAIN:
sql
EXPLAIN SELECT * FROM users WHERE name = 'Tom';- 关注
type、key、rows、Extra type=ALL:全表扫描,需加索引Extra=Using filesort:排序未用索引Extra=Using temporary:用了临时表
- SHOW PROFILE(详细耗时):
sql
SET profiling = 1;
SELECT ...;
SHOW PROFILE;
SHOW PROFILE FOR QUERY 1;- SHOW STATUS:
sql
SHOW STATUS LIKE 'Handler_read%'; -- 查看读取情况- Trace 优化器:
sql
SET optimizer_trace='enabled=on';
SELECT ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;优化策略:
1. 索引优化:
- 加合适索引:WHERE、JOIN ON、ORDER BY、GROUP BY 列
- 联合索引按区分度排序,遵循最左前缀
- 覆盖索引避免回表
- 避免索引失效场景(函数、隐式转换、LIKE %开头)
sql
-- 优化前
SELECT * FROM users WHERE DATE(create_time) = '2024-01-01';
-- 优化后
SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';2. SQL 改写:
sql
-- 避免 SELECT *
SELECT id, name, age FROM users WHERE ...;
-- 大分页优化
-- 优化前
SELECT * FROM users LIMIT 1000000, 10;
-- 优化后(游标分页)
SELECT * FROM users WHERE id > 1000000 LIMIT 10;
-- 或子查询
SELECT * FROM users WHERE id >= (SELECT id FROM users LIMIT 1000000, 1) LIMIT 10;
-- JOIN 优化:小表驱动大表
SELECT * FROM small_table s JOIN big_table b ON s.id = b.small_id;
-- 确保 b.small_id 有索引
-- OR 改 UNION
-- 优化前
SELECT * FROM users WHERE name = 'Tom' OR age = 18;
-- 优化后(age 和 name 分别有索引)
SELECT * FROM users WHERE name = 'Tom'
UNION
SELECT * FROM users WHERE age = 18;3. 表结构优化:
- 合适的数据类型(INT 代替 VARCHAR)
- 适度反范式化(冗余字段避免 JOIN)
- 大表分区、分表
4. 数据库配置:
innodb_buffer_pool_size:足够大(物理内存 60-70%)innodb_io_capacity:SSD 可调大- 连接池配置
5. 架构优化:
- 读写分离
- 缓存(Redis)
- 分库分表
- 异步化(消息队列)
优化流程总结:
定位慢 SQL → EXPLAIN 分析 → 识别问题(全表扫描/回表/排序/临时表)
→ 加索引/改写 SQL/调结构 → 验证效果(EXPLAIN + 耗时)7.18 ★★★★MySQL 分库分表方案有哪些?水平拆分和垂直拆分的区别?如何选择分片键?分库分表后如何处理跨库 JOIN 和分布式 ID?
📖 点击查看答案
分库分表类型:
1. 垂直拆分:
- 垂直分库:按业务模块拆分到不同数据库
- 如:用户库、订单库、商品库
- 解决单库表多、压力大问题
- 垂直分表:将一张大表按字段拆分为多张表
- 如:
user_base(id, name, age)+user_detail(id, bio, avatar) - 将热字段和冷字段分离,减少单表字段数
- 如:
2. 水平拆分:
- 水平分库:同一张表的数据分散到多个库
- 如:
db0.user、db1.user、db2.user
- 如:
- 水平分表:同一张表的数据分散到多个表
- 如:
user_0、user_1、user_2
- 如:
- 解决单表数据量过大问题
| 拆分方式 | 解决问题 | 特点 |
|---|---|---|
| 垂直分库 | 单库压力大、业务耦合 | 按业务拆分,库独立 |
| 垂直分表 | 单表字段多、热冷字段混合 | 拆字段,表数量不变 |
| 水平分库 | 单库数据量大、并发高 | 拆数据到多个库 |
| 水平分表 | 单表数据量大 | 拆数据到多个表 |
分片键选择:
原则:
- 高基数:值分布均匀,避免数据倾斜(如 user_id 而非 status)
- 查询频繁:常用查询条件作为分片键,避免跨库查询
- 不可变:值不频繁变更(如 user_id 而非 phone)
- 单调递增:可选,利于时间范围查询(如 create_time)
常见分片策略:
- 取模:
user_id % N,均匀分布,但扩容麻烦(需 rehash) - 范围:
id 0-1000万 在 db0,1000万-2000万 在 db1,利于范围查询,但易热点 - 一致性哈希:扩容时迁移数据少
- Hash + 范围结合:先 hash 分库,再范围分表
跨库 JOIN 解决:
绑定表(Binding Table):
- 将有关联关系的表用相同分片键和分片规则,如
user和user_detail都按user_id分片 - JOIN 时在同一库内完成
- 将有关联关系的表用相同分片键和分片规则,如
广播表(Broadcast Table):
- 小表(如字典表、配置表)在每个库都有全量数据
- JOIN 时本地查询,无需跨库
应用层 JOIN:
- 拆分为多次查询,应用层组装
javaList<Order> orders = orderMapper.selectByUserId(userId); List<Long> productIds = orders.stream().map(Order::getProductId).collect(toList()); List<Product> products = productMapper.selectByIds(productIds); // 应用层组装数据冗余:
- 订单表中冗余商品名称等字段,避免 JOIN
Elasticsearch:
- 宽表存入 ES,支持复杂查询和 JOIN
分布式 ID 方案:
UUID:
- 简单,但无序,B+ 树插入性能差
数据库自增 + 步长:
- 每个库设置不同起始值和步长
db0: 1,4,7,10...db1: 2,5,8,11...db2: 3,6,9,12...- 扩容麻烦
号段模式(如美团 Leaf):
- 一次从数据库获取一段 ID(如 1000 个),用完再取
- 减少数据库访问
Snowflake(雪花算法):
- 64 位:
1 符号位 + 41 时间戳 + 10 机器 ID + 12 序列号 - 趋势递增,性能高
- 需处理时钟回拨问题
- 64 位:
Redis INCR:
INCR id_seq,原子自增- 依赖 Redis 可用性
Zookeeper / etcd:
- 通过 ZNode 版本号生成 ID
- 性能较低
ShardingSphere 示例:
yaml
spring:
shardingsphere:
sharding:
tables:
t_order:
actual-data-nodes: ds${0..1}.t_order${0..1}
database-strategy:
inline:
sharding-column: user_id
algorithm-expression: ds${user_id % 2}
table-strategy:
inline:
sharding-column: order_id
algorithm-expression: t_order${order_id % 2}
key-generator:
column: order_id
type: SNOWFLAKE分库分表中间件:
- ShardingSphere(Sharding-JDBC / Sharding-Proxy):Apache 顶级项目
- MyCat:早期开源,基于代理
- TDDL(阿里):内部使用
- Vitess:YouTube 开源,CNCF