每日一讲技术题(7.29-30)
第一问
什么是锁?InnoDB 有哪些行级锁类型(共享锁、排他锁)?意向锁的作用是什么?
一、什么是锁?
锁是数据库用来控制并发访问的机制,目的是保证数据在多人同时读写时的正确性。
打个比方:你爸去取款机取钱,同时你妈也在手机银行转账,两个人的操作针对同一个账户。如果没有锁,可能余额扣了两次、或者明明余额不够却取出来了。
锁的本质就是一句话:让冲突的操作排队执行。
二、InnoDB 的行级锁类型
InnoDB 的三种基本行级锁:
- Record Lock(记录锁)
锁住索引记录本身(不是行,是索引记录)。
-- 对 id=5 的行加锁
SELECT * FROM user WHERE id = 5 FOR UPDATE;
- 如果
id是主键,锁的是主键索引上id=5的那条记录 - 如果
id是唯一索引,锁的也是唯一索引上的那条记录
- Gap Lock(间隙锁)
锁住两条索引记录之间的间隙,防止其他事务在这个间隙插入新记录。
索引值:1, 3, 5, 8, 10
间隙:(-∞, 1), (1, 3), (3, 5), (5, 8), (8, 10), (10, +∞)
-- 间隙锁作用:其他事务插入 id=4 会被阻塞
SELECT * FROM user WHERE id BETWEEN 3 AND 5 FOR UPDATE;
Gap Lock 的存在是 MySQL RR 隔离级别能防幻读的根本原因。
- Next-Key Lock(临键锁)
Record Lock + Gap Lock 的组合,锁住"当前记录"+"前面的间隙"。
索引值:1, 3, 5
Next-Key Lock 覆盖的区间:
(-∞, 1] → 锁住 1 及其前面的间隙
(1, 3] → 锁住 3 及其与 1 之间的间隙
(3, 5] → 锁住 5 及其与 3 之间的间隙
(5, +∞) → 锁住 5 之后的间隙
-- 加锁区间是 (3, 5]
-- 即:锁住 id=5 这行,同时锁住 3 到 5 之间的间隙
SELECT * FROM user WHERE id = 5 FOR UPDATE;
注意:如果 id=5 这条记录不存在,就只变成 Gap Lock,锁住间隙但不锁具体记录。
- Insert Intention Lock(插入意向锁)
一种特殊的 Gap Lock(间隙锁),用于 INSERT 操作。
- 多个事务可以同时持有一个间隙的插入意向锁(互相兼容)
- 但如果有事务在这个间隙有 Gap Lock,插入意向锁必须等
-- 事务A:锁住间隙 (3, 5)
SELECT * FROM user WHERE id = 4 FOR UPDATE;
-- 事务B:想插入 id=4,必须等事务A释放 → 被阻塞
INSERT INTO user(id) VALUES(4);
这样设计是为了在防幻读的同时,尽量让插入操作并行化(不冲突的插入可以同时进行)。
三、意向锁的作用
意向锁是表级别的锁,分两种:
| 锁 | 含义 |
|---|---|
| IS(意向共享锁) | 表示"这个事务准备在表里的某些行上加共享锁(S)" |
| IX(意向排他锁) | 表示"这个事务准备在表里的某些行上加排他锁(X)" |
核心作用:快速检测表级锁冲突
假设没有意向锁:
事务A:给 user 表的 id=1 这行加了 X 锁
事务B:想给整张 user 表加 LOCK TABLES WRITE
如果没有意向锁,事务B必须遍历整张表的每一行检查有没有行锁,才能确定能不能加表锁。一张千万级的大表,这判断就是灾难。
有了意向锁:
事务A 加行锁时,会在表上留下一把 IX 锁
事务B 想加表锁时,直接检查有没有 IX/IS 锁
→ 有 IX 锁 → 说明有行级排他锁 → 直接阻塞,不用遍历行
一句话:意向锁让表级锁和行级锁的冲突判断从 O(n) 变成 O(1)。
兼容矩阵
| 已持有 ↓ | 请求 → | IS | IX | S | X |
|---|---|---|---|---|---|
| IS | IS | 兼容 | 兼容 | 兼容 | 不兼容 |
| IX | IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
| S | S | 兼容 | 不兼容 | 兼容 | 不兼容 |
| X | X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
兼容 = 两个锁可以同时存在
不兼容 = 后请求的事务必须等待,直到先持有的锁释放
- IX 和 IX 之间兼容 — 两个事务可以分别锁不同的行,互不影响
- IX 和 S 之间不兼容 — 已经有行锁了,不能再加共享表锁
- IX 和 IS 之间兼容 — 都只是在"准备"阶段,不冲突
四、总结
| 概念 | 一句话 |
|---|---|
| 锁 | 让冲突操作排队,保证数据一致性 |
| Record Lock | 锁住某条记录本身 |
| Gap Lock | 锁住记录之间的空隙,防止插入 |
| Next-Key Lock | 记录+间隙组合锁,RR 级别防幻读 |
| Insert Intention Lock | 插入时用的特殊间隙锁,允许并行插入 |
| 意向锁(IS/IX) | 表级的"标记",快速判断能不能加表锁,避免扫全表检测行锁 |
第二问
什么是死锁?请描述一个MySQL中产生死锁的场景,以及如何避免和解决死锁。
一、什么是死锁
死锁 是指两个或多个事务互相持有对方需要的资源(锁),并且互相等待对方释放,导致谁都无法继续执行的情况。
MySQL 不会让它们永远等下去——它会自动检测死锁,选择一个开销较小的事务作为"牺牲品"回滚掉,让另一个事务正常执行。被回滚的事务会收到 Deadlock found when trying to get lock 错误。
二、典型场景
假设有两个事务 T1 和 T2:
| 时间 | T1 | T2 |
|---|---|---|
| 1 | UPDATE accounts SET balance = balance - 100 WHERE id = 1; 持有 id=1 的行锁 |
|
| 2 | UPDATE accounts SET balance = balance - 200 WHERE id = 2; 持有 id=2 的行锁 |
|
| 3 | UPDATE accounts SET balance = balance + 100 WHERE id = 2; 等待 T2 释放 id=2 的锁 — 阻塞 |
|
| 4 | UPDATE accounts SET balance = balance + 200 WHERE id = 1; 等待 T1 释放 id=1 的锁 — 阻塞 |
此时双方各执一锁、互等一锁,形成循环等待,死锁。
三、如何避免
-
统一资源访问顺序 — 不管是哪个事务,操作记录都按
id从小到大访问。上面的例子只要约定"先更新 id 小的,再更新 id 大的",死锁就不可能出现。 -
缩短事务时间 — 不要在事务里做外部 API 调用、用户输入等待这些操作,锁持的时间越短,冲突概率越低。
-
合理设置隔离级别 — 比如 READ COMMITTED 比 REPEATABLE READ 产生间隙锁(Gap Lock)的机会更少,死锁概率也相应降低。当然要结合业务需求来选。
-
使用低冲突索引 — 走全表扫描时 MySQL 会锁大量行甚至 Gap 锁,尽量让 UPDATE/DELETE 走精确的索引。
四、如何解决
- 捕获重试 — 在应用层捕获
ER_LOCK_DEADLOCK(错误码 1213),等一小段时间后重试整个事务。因为 MySQL 已经替你选了牺牲品回滚,重试通常就能成功。 - 查看 InnoDB 状态 — 用
SHOW ENGINE INNODB STATUS\G查看最近一次死锁的信息,分析是哪两个事务、什么 SQL、什么索引导致的死锁。 - 调整事务隔离级别 — 如果业务允许,考虑降到 READ COMMITTED 以减少 Gap Lock 竞争。
- 加上合理的索引 — 确保 WHERE 条件能精准定位少量记录。
总结:
死锁就是两个事务互相等锁。避免的关键是统一定好资源访问顺序,解决的关键是捕获 + 重试 + 分析原因加索引。
第三问
数据库的三范式是什么?请分别解释并举例。什么情况下会反范式设计?
一、数据库三范式
三范式是关系数据库设计中用来规范表结构的规则,目的就两个:减少数据冗余,避免增删改时的异常。
第一范式:列不可再分
每一列必须存原子值,也就是单个不可再分的值,不允许存列表、数组或逗号分隔的一串数据。
违反的例子:
| 学号 | 姓名 | 所选课程 |
|---|---|---|
| 001 | 张三 | 数学, 英语, 物理 |
"所选课程"里存了多个课程名。你想查选了数学的学生,得用模糊匹配或者字符串拆分,效率很低。
改造后:
| 学号 | 姓名 | 课程 |
|---|---|---|
| 001 | 张三 | 数学 |
| 001 | 张三 | 英语 |
| 001 | 张三 | 物理 |
每个单元格只有一个值,满足第一范式。但新的问题出现了:张三的姓名重复了三次,改名字要改三行。
第二范式:消除部分依赖
前提是满足第一范式。如果表的主键是联合主键(多列组成),那么每个非主键列必须完全依赖全部主键,不能只依赖其中一部分。
违反的例子:
主键是(学号, 课程)。
| 学号 | 课程 | 姓名 | 成绩 |
|---|---|---|---|
| 001 | 数学 | 张三 | 85 |
| 001 | 英语 | 张三 | 90 |
分析依赖关系:
- 成绩:需要同时知道哪个学生的哪门课,完全依赖全部主键。
- 姓名:只需要学号就能确定,不依赖课程。这是部分依赖。
后果:姓名重复,改名字要改多行,新增一个没选课的学生插不进去。
改造后:
拆成两张表。
学生表:(学号, 姓名)
选课表:(学号, 课程, 成绩)
姓名只存一份,改一次就行。满足第二范式。
第三范式:消除传递依赖
前提是满足第二范式。非主键列必须直接依赖于主键,不能通过其他非主键列间接依赖。
这个间接依赖叫传递依赖:主键决定 A 列,A 列决定 B 列,B 列就通过 A 列间接依赖主键。
违反的例子:
学生表里加了班级编号和班级名称。
| 学号 | 姓名 | 班级编号 | 班级名称 |
|---|---|---|---|
| 001 | 张三 | C01 | 计科一班 |
| 002 | 李四 | C01 | 计科一班 |
依赖关系:学号决定班级编号,班级编号决定班级名称。班级名称不直接依赖学号,而是通过班级编号传递过来的。
后果:计科一班的名称存了两遍。如果班级改名,得改两行。如果这个班的学生全毕业了,班级信息也跟着丢了。
改造后:
拆成两张表。
学生表:(学号, 姓名, 班级编号)
班级表:(班级编号, 班级名称)
班级名称只存一次,改一次就行。满足第三范式。
补充:BCNF(巴克斯范式)
第三范式的加强版。核心区别:第三范式允许非主键列决定另一个非主键列,但 BCNF 不允许——任何能决定其他列的列,本身必须是候选键。实际业务中大部分符合第三范式的表也自然符合 BCNF,知道有这个加强版就行。
二、什么时候反范式
反范式就是故意违反范式规则,在表里引入冗余字段。目的是用空间换时间,减少 JOIN,提高读取性能。
常见场景:
查询性能优化。订单列表要展示用户名、商品名、地址,按第三范式要 JOIN 五六张表,数据库压力大。在订单表里直接冗余存上用户名,一次查询搞定,省掉那次 JOIN。
高并发读。商品详情页每秒几千次请求,每次都 JOIN 扛不住。文章表里直接存作者名,排行榜表里直接存用户头像,这些在高并发系统中很常见。
分库分表后。订单数据和用户数据分到了不同数据库,没法跨库 JOIN。只能把用户昵称、手机号直接存到订单表里。
数据仓库。分析查询不关心写入一致性,追求的是聚合查询跑得快。宽表设计里大量字段冗余,这是正常的建模方式。
反范式的代价:
写操作变复杂。用户改了昵称,用户表要更新,订单表、评论表、消息表里冗余的昵称也得跟着更新。
数据可能不一致。程序有 bug 或者消息队列积压,新旧数据同时存在。
存储变大。不过现在磁盘便宜,这个代价通常可以接受。
实际做法:
先按第三范式设计核心业务表,保证数据一致性和写入的正确性。上线后用慢查询定位真正慢的查询,在关键路径上选择性加一两个冗余字段。同时用触发器或应用层的事件机制保证冗余字段同步更新。不提前优化,也不完全放弃范式,在写一致性和读性能之间找到适合自己的平衡点。
第四问
什么是数据库的隔离级别?MySQL的默认隔离级别是什么?它解决了哪些并发问题,又可能带来什么问题?
数据库隔离级别
隔离级别控制一个事务对数据的修改在什么时候、什么条件下能被其他事务看到。它是数据库事务四大特性里隔离性的具体实现。
SQL 标准定义了四个级别,从低到高:
第一是读未提交。一个事务可以读到另一个事务未提交的数据。性能最好,但所有并发问题都可能出现。
第二是读已提交。一个事务只能读到其他事务已提交的数据。解决了脏读,但不可重复读和幻读仍然可能。
第三是可重复读。同一个事务内,多次读取同一行数据结果一致。解决了脏读和不可重复读,但幻读在标准定义里仍然可能。
第四是串行化。事务完全串行执行,等价于排队。解决了所有并发问题,但性能最差。
三种并发问题
脏读:一个事务读到另一个事务还没提交的数据。如果对方回滚了,你读到的就是废弃的数据。
不可重复读:同一个事务里,两次读同一行数据,结果不一样,因为被其他事务修改并提交了。
幻读:同一个事务里,两次执行同一个范围查询,结果集的行数不一样,因为被其他事务插入了新行。
MySQL 的默认隔离级别
MySQL 的 InnoDB 引擎默认使用可重复读。
这里有个有意思的区别。Oracle 和 PostgreSQL 的默认级别都是读已提交,但 MySQL 选择了可重复读。而且 MySQL 在这个级别下通过两种机制做了一些超出标准定义的事情。
第一种是多版本并发控制,也就是 MVCC。每个事务看到的是数据在某个时间点的快照,同一个事务内多次读到的快照一致。
第二种是 Next Key Lock,也就是行锁加间隙锁。在范围查询时,不仅锁住匹配的行,还锁住这些行之间的间隙,阻止其他事务在这个范围内插入新数据。
这两个机制合在一起,让 MySQL 的可重复读实际上也解决了幻读问题。
它解决了哪些问题
脏读被解决了。MVCC 保证事务只能读到已提交的快照版本。
不可重复读被解决了。同一个事务内,快照一致,多次读同一行结果不变。
幻读也被解决了。Next Key Lock 在范围查询时锁住间隙,阻止其他事务插入满足条件的新行。
所以 MySQL 的可重复读提供了接近串行化的一致性保证,但并发性能比串行化好得多。
可能带来什么问题
第一个是间隙锁带来的死锁风险。这是双刃剑。范围查询时锁住不存在的行,并发插入时容易被阻塞或产生死锁。比如事务 A 锁住 id 大于 10 的所有行和间隙,事务 B 要插入 id 为 15 的行就会被阻塞。如果换成读已提交级别就不存在这个问题,因为读已提交只有行锁没有间隙锁。
第二个是并发写入性能下降。在高并发写入的场景下,间隙锁会成为瓶颈,尤其是自增列或者有唯一索引的范围更新操作。
第三个是锁的开销更大。相比读已提交,可重复读需要维护更多的锁信息,热点表上的锁争用更严重。
第四个是 Undo Log 膨胀。MVCC 需要保留 Undo Log 来构造历史快照。如果一个事务长时间不结束,这些 Undo Log 就不能被清理,导致 Undo 表空间膨胀,查询性能也可能下降,因为回滚段链变长了。
第五个是对主从复制的要求。可重复读级别下才能安全使用基于语句的 binlog 格式。因为行锁加间隙锁保证了从库执行相同 SQL 的结果和主库一致。如果降到读已提交级别,binlog 必须用行格式,否则主从数据会不一致。
总结
MySQL 默认的可重复读通过 MVCC 和 Next Key Lock 同时解决了脏读、不可重复读和幻读三个问题,比标准定义更强。代价是间隙锁带来的死锁风险和并发瓶颈。如果你的业务对幻读不敏感,写入并发又高,主动降到读已提交级别反而是更好的选择。
第五问
描述“脏读”、“不可重复读”和“幻读”的现象,并分别说明在哪种隔离级别下可以避免。
一、脏读(Dirty Read)
现象描述:
事务 A 执行 UPDATE users SET balance = 200 WHERE id = 1;,但还没 COMMIT。
事务 B 此时去查 SELECT balance FROM users WHERE id = 1;,读到了 200。
然后事务 A 发现不对劲,执行了 ROLLBACK,那行数据恢复成原来的值(比如 100)。
事务 B 手里拿着的 200 就是一笔"脏数据"——它从未真正存在于数据库中。这就是脏读。
为什么危险:
- 事务 B 基于这个错误的值做了后续操作(比如扣款、计费),结果引用了一个从未提交的中间状态。
- 银行、支付、库存这类场景,脏读是不能接受的。
哪个隔离级别能避免?
READ COMMITTED(读已提交) 及以上。实现方式:
- 在该级别下,事务只能读到已提交的数据。
- 具体实现上,PostgreSQL 直接用快照(Snapshot)实现,MySQL InnoDB 用 MVCC 的 Read View 来判断——只有已提交事务的版本才可见。
二、不可重复读(Non-repeatable Read)
现象描述:
事务 A 开始后做了两次相同的查询:
-- 第一次读
SELECT balance FROM users WHERE id = 1; -- 返回 100
-- 此时事务 B 更新了这条记录
UPDATE users SET balance = 150 WHERE id = 1; -- 并 COMMIT
-- 事务 A 第二次读同一行
SELECT balance FROM users WHERE id = 1; -- 返回 150,和第一次不一样!
简单说就是:同一事务内,同一条记录,两次读取结果不一致。
与脏读的区别:
- 脏读是读到了未提交的数据。
- 不可重复读是读到了其他事务已提交的修改——关键在于数据是合法的、已提交的,只是事务 A 觉得"我刚才看到不是这个数啊"。
哪个隔离级别能避免?
REPEATABLE READ(可重复读) 及以上。
实现方式:
- MVCC 快照读:事务首次读取时建立一个快照,之后所有读取都基于这个快照,其他事务的提交对其不可见。
- PostgreSQL 的 REPEATABLE READ 就是靠快照实现的,同一事务内所有查询看到的都是事务开始时的数据。
- MySQL InnoDB 也是靠 MVCC 实现,不过它把 REPEATABLE READ 作为默认级别。
三、幻读(Phantom Read)
现象描述:
事务 A 按范围查询了一次:
SELECT * FROM orders WHERE amount > 100; -- 返回 3 行:id=1,2,3
-- 此时事务 B 插入了一笔新订单
INSERT INTO orders (id, amount) VALUES (4, 200); -- 并 COMMIT
-- 事务 A 再次执行同样的范围查询
SELECT * FROM orders WHERE amount > 100; -- 返回 4 行!多出了一行 id=4
"幻"就幻在这条新出现的行就像幽灵一样凭空冒出来了。
与不可重复读的区别(很多人混淆):
| 不可重复读 | 幻读 | |
|---|---|---|
| 对象 | 已有行的值被修改/删除 | 新插入的行 |
| 场景 | 同一条数据两次取值不同 | 同一次查询多出行 |
| 感受 | "值变了" | "多了一条记录" |
哪个隔离级别能避免?
SERIALIZABLE(可串行化)。
实现方式:
- 强制事务串行执行(实际是通过锁或冲突检测实现),从根本上杜绝任何并发冲突。
- PostgreSQL 的 SERIALIZABLE 使用 SSI(Serializable Snapshot Isolation,可串行化快照隔离)技术,检测读写冲突后直接 abort 其中一个事务。
- 代价就是并发性能大幅下降。
补充:MySQL InnoDB 的特殊情况
MySQL InnoDB 的 REPEATABLE READ 虽然没有 SERIALIZABLE 那么严格,但通过 间隙锁(Gap Lock) + Next-Key Lock 也能阻止幻读:
-- 事务 A
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- InnoDB 不光锁了现有行,还在索引间隙加了锁,阻止其他事务插入新行
-- 所以事务 B 的 INSERT 会被阻塞
因此在实际的 MySQL 环境中,REPEATABLE READ 就已经做到了避免幻读。这和 SQL 标准定义不完全一致——MySQL 文档自己也提到了这一点,所以大家在面试时要注意区分"SQL 标准定义"和"MySQL 实际行为"。
汇总表
| 隔离级别 | 脏读 | 不可重复读 | 幻读(SQL 标准) |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE READ | 避免 | 避免 | 可能 |
| SERIALIZABLE | 避免 | 避免 | 避免 |
| 级别越高 | 一致性越强 | 并发性能越低 |
|---|
实际应用中大部分数据库的默认级别是 READ COMMITTED(PostgreSQL、Oracle、SQL Server)或 REPEATABLE READ(MySQL),选哪个看你业务对一致性和吞吐量的权衡。
第六问
为什么推荐使用自增主键?使用UUID作为主键的优缺点是什么?
为什么推荐自增主键
核心原因:性能优势
1. B+ 树写入特性
InnoDB使用聚簇索引(Clustered Index),数据按主键顺序物理存储。自增主键单调递增,新数据直接追加到 B+ 树末尾,不需要频繁的页分裂(Page Split)和节点重平衡(Rebalance)。
2. 空间利用率高
UUID(通用唯一识别码)主键比 BIGINT(8 字节整数)多占 3-4 倍存储空间。每张二级索引都会包含主键值,数据量上去后差距明显——16 字节的 UUID vs 8 字节的 BIGINT vs 4 字节的 INT。
3. 缓存友好
自增主键写入紧凑连续,磁盘和内存的缓存命中率更高。UUID 随机写入导致缓存频繁刷出刷入,LRU(最近最少使用)算法效率下降。
4. 简单可靠
不需要额外的生成逻辑,数据库内置机制保证唯一性和递增性。
UUID 主键的优缺点
缺点(通常不推荐)
| 问题 | 说明 |
|---|---|
| 随机写入 | UUID 无序,写入时频繁触发页分裂,插入性能下降 50%-80% |
| 存储膨胀 | 16 字节 vs BIGINT 8 字节;二级索引全部放大 |
| 索引碎片 | 随机写入导致 B+ 树(B+ Tree)碎片化严重,查询扫描范围变大 |
| WHERE 效率 | 范围查询(BETWEEN、分页)无法利用物理连续性 |
优点(适合场景)
- 分布式系统:多个节点独立生成 ID 无需中心协调,避免自增主键在分布式下的冲突问题
- 数据合并:多个分库的数据合并到中心库时,UUID 天然不会冲突
- 安全考虑:自增主键暴露业务规模(注册用户数、订单量等),UUID 不可预测
- 离线写入:离线端生成 ID,联网后同步不会冲突
实用建议
- 默认选自增主键(BIGINT UNSIGNED),绝大部分单体应用和微服务单库场景都足够
- 需要 UUID 时,用 UUID 的变体:
- MySQL 8.0+:
UUID_TO_BIN(UUID(), TRUE)生成时序 UUID,对 B+ 树友好 - 雪花算法(Snowflake ID):分布式友好、64 位数字、时序有序
- 或者用
BIGINT UNSIGNED+ 号段模式(Segment Mode)替代 UUID
- MySQL 8.0+:
总结:除非分布式场景或安全考虑是硬需求,否则自增主键在性能、存储、维护复杂度上都完胜 UUID。
第七问
什么是覆盖索引?它的优点是什么?
覆盖索引(Covering Index)
指一个索引包含了查询所需要的所有列,查询执行时只需要扫描索引本身就能获取全部结果,不需要回表到聚簇索引(主键索引)查找完整行记录。
举个例子:
-- 表
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
email VARCHAR(100)
);
-- 复合索引
CREATE INDEX idx_name_age ON user(name, age);
-- 这个查询的所有字段 (name, age) 都在索引中
SELECT name, age FROM user WHERE name = '张三';
MySQL 在 idx_name_age 索引的 B+ 树上遍历到 '张三' 后,直接拿到 name 和 age 返回,不再根据 id 回表去主键索引查其他字段。EXPLAIN 的 Extra 列会显示 Using index。
优点
- 减少磁盘 I/O -- 索引体积通常远小于表数据(B+ 树只存索引列和主键),扫描索引比扫描整表需要的磁盘页更少
- 避免回表 -- 省去了从二级索引到聚簇索引的二次 B+ 树查找,这是最重要的性能提升点
- 减少缓冲池占用 -- 同样大小的内存可以缓存更多索引页,数据页访问次数下降
- 利用索引排序 -- 索引本身有序,
ORDER BY和GROUP BY可以直接使用索引排序,避免文件排序(filesort) - 并发性能更好 -- 索引页更紧凑,减少了锁冲突和 I/O 竞争
需要注意的缺点:
- 覆盖索引的列越多,索引体积越大,写入时的维护成本也越高
- 不能把大字段(TEXT、BLOB 或超长的 VARCHAR)包含进来,否则索引臃肿反而拖慢查询
- 覆盖索引是为了特定查询优化的,不是越多越好,需要结合业务查询模式来设计
第八问
请解释“回表”的概念。
回表是 InnoDB 存储引擎中,在使用**非聚簇索引(二级索引)**查询时的一种额外操作。
为什么会有回表
InnoDB 表的数据和主键索引是存储在一起的(聚簇索引),叶子节点存的是完整行数据。而二级索引的叶子节点存的是索引列的值 + 主键值,不是完整行。
当用二级索引查数据时:首先,在二级索引 B+ 树中定位到符合条件的叶子节点,拿到主键值;然后,拿着主键值到聚簇索引中再查一次,获取完整行数据。这第 2 步就是回表。
举例说明
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
KEY idx_age (age)
);
SELECT * FROM user WHERE age = 25;
执行流程:
- 走
idx_age二级索引,找到 age=25 的叶子节点,拿到 id=10 - 走聚簇索引(主键),找到 id=10 的完整行
- 返回结果
第 2 步就是回表。一次回表 = 一次随机 I/O。
如何避免回表
- 覆盖索引:查询的列全部包含在二级索引中,就不需要回表
-- 不需要回表,因为 age 和 id 都在 idx_age 里
SELECT id, age FROM user WHERE age = 25;
- 索引下推(ICP):MySQL 5.6+ 的特性,在二级索引扫描时提前过滤,减少回表次数
回表不是 bug,是 InnoDB 的存储结构决定的。优化的方向是:要么建覆盖索引,要么让回表次数尽量少。