写MySQL八股文这个东西,这几年被很多人吐槽过,说它“背了没用”“纸上谈兵”。但说句公道话,如果把八股文当成纯粹的背诵题,那确实没什么意义;可如果你把它当成一份“问题清单”,顺着这些问题去把底层的机制捋清楚,它反而是效率极高的复习路径。我自己当年准备面试的时候就有这种体会——很多知识点平时写代码根本不会碰,比如隔离级别的实现细节、redo log和binlog的配合方式,都是靠着“背八股”的契机才真正搞明白的。这篇是第一篇,我准备把MySQL里最常考、也最核心的几块地基先梳理一遍:索引的底层结构、事务的隔离级别、锁机制和日志系统。这四个东西是理解MySQL的支柱,后面的优化、主从复制、分库分表,全都在它们之上长起来的。
1. 索引:B+树为什么是MySQL的默认答案
索引这一块,面试基本是从“为什么用B+树”开始的。这个问题看着简单,但能把B树、B+树、哈希索引、跳表这几样东西的差异说清楚的人,其实不多。
1.1 B树和B+树的差异,以及为什么InnoDB选后者
先明确一点:MySQL的InnoDB引擎用的是B+树,不是B树。这两个名字长得像,结构上也有血缘关系,但关键差别在三个地方。
第一,B+树的非叶子节点不存数据,只存索引键。这意味着每一页能放下的键数量会多很多。InnoDB默认的页大小是16KB,假设主键是8字节的bigint,再算上指针和页头页尾的开销,一棵高度为3的B+树大概能存2000多万行数据。而B树因为非叶子节点也要存数据,同样高度下能承载的数据量会小一两个数量级。说白了,B+树让“矮胖”成为可能,而树越矮,查询时访问磁盘的次数就越少。
第二,B+树的数据都集中在叶子节点,而且叶子节点之间用链表串了起来。这对范围查询是决定性的优势。比如你要查“id在100到200之间”的所有记录,B+树只要找到id=100那条,顺着叶子链表往后扫就行了。B树则不然,数据分散在所有节点上,范围查询可能要反复在树的不同层级之间跳跃,IO次数不可控。
第三,B+树每次查询的路径长度是固定的——都是从根节点走到叶子节点。B树的查询在命中非叶子节点时就能提前结束,听起来好像更快,但实际上这带来一个问题:查询耗时的波动比较大,不稳定。对于数据库这种需要稳定延迟的服务来说,路径固定反而更容易控制性能。
当年有人问过我一个问题:为什么不用跳表或者哈希?跳表在Redis里用得多,但那是内存数据库,不需要考虑磁盘IO的局部性;哈希索引只能做等值匹配,一旦涉及范围查询就全废了。所以站在磁盘存储的角度,B+树的综合排序是合理的,这也是它成为关系型数据库索引默认选择的原因。
1.2 聚簇索引、二级索引、回表与覆盖索引
InnoDB的索引设计有个核心特征:数据本身就是按主键顺序组织的。这个按主键建的索引树,叫聚簇索引,它的叶子节点存的是整行的数据。而其他索引,也就是二级索引,叶子节点存的是主键的值。
这里就引出了“回表”这个概念。你给name字段建了一个普通索引,查询WHERE name = '张三'的时候,MySQL先在二级索引的B+树里找到“张三”对应的主键值,再拿着这个主键值到聚簇索引里查一次,才能拿到完整的数据行。这两步操作就叫回表。
回表不是必须的。如果查询所需的列已经在二级索引里全部包含了,MySQL就可以直接返回二级索引中的数据,不碰聚簇索引。这种情况叫覆盖索引。我写过一条很典型的慢查询,表里有几十万条记录,查询条件用的字段上有索引,但因为SELECT *把整行都查出来了,每条记录都要回表,结果慢得离谱。后来把SELECT *改成了只查索引里有的那两个字段,直接从几秒降到了几十毫秒。这个优化思路,就是覆盖索引。
1.3 最左前缀原则,以及它背后的优化器逻辑
联合索引的最左前缀原则,是面试里出现频率极高的问题。很多人能背出结论:联合索引(a, b, c)能匹配(a)、(a, b)、(a, b, c),但用不上(b)或者(c)。但“为什么”才是关键。
联合索引在B+树里的排序规则是:先按第一个字段排,第一个字段相同的再按第二个字段排,依此类推。所以当你跳过第一个字段,直接拿第二个字段去查,B+树的排序规则就帮不上忙了——因为你无法利用树的顺序性去快速定位,只能全表扫描。这就好比你有一本按“姓氏+名字”排序的电话簿,想找所有名字叫“伟”的人,是没有办法用目录直接定位的,因为目录只按姓氏组织。
有一个实际工作中的易错点:WHERE a = 1 AND c = 3这样的查询,虽然用到了联合索引的a,但c那一维是无法走索引下推的(除非用了MySQL 5.6引入的索引下推优化,这个后面细说)。很多新手以为“只要查询条件里有索引的第一个字段,整个查询就能用到索引”,这是不对的。准确的说法是:能用到索引中连续的前缀部分。
还有一点需要特别提一句——把范围查询的字段放在联合索引的哪个位置,对索引的可用性影响很大。WHERE a > 1 AND b = 2这种情况,a走索引没问题,但b就没办法参与索引匹配了,因为a的范围条件打断了b的排序连续性。所以建联合索引的时候,要把等值查询的字段放在前面,范围查询的字段放在后面。这个经验,面试官也爱问,工作中能省很多事。
2. 事务四大特性与隔离级别:最容易背串的模块
事务这块的八股文密度很高,ACID四个字母谁都能说出来,但“这四个特性到底是由哪个机制保证的”,大部分人模棱两可。
2.1 ACID,每个字母分别靠什么支撑
原子性(Atomicity)靠的是undo log。事务执行过程中,所有对数据的修改都会记录反向操作到undo log里。如果事务中途失败或者你手动回滚,MySQL就根据undo log把数据恢复成执行前的样子。注意,这个恢复过程不是简单的“改回去”,而是通过逻辑日志反向执行,细节后面聊日志的时候再展开。
一致性(Consistency)在MySQL里是一个“结果属性”,不是某个单一机制能搞定的。它需要原子性、隔离性、持久性共同配合,再加上数据库自身的约束(比如主键唯一、外键、非空),最后从应用层保证业务逻辑正确。所以才有人说,一致性不是数据库“做出来的”,而是数据库“保证其他特性之后自然达到的”。
隔离性(Isolation)由两套机制协同:锁和MVCC。锁负责保证并发写之间的互斥,MVCC负责在读多写少的场景下降低锁竞争。这块是重点,后面单独开一节细聊。
持久性(Durability)靠的是redo log。事务提交时,数据页可能还没来得及刷到磁盘,但redo log一定已经写成功了。这样即使数据库瞬间宕机,重启后也能根据redo log把已提交的事务恢复出来,做到不丢数据。
2.2 四种隔离级别,以及它们各自拦住了什么
SQL标准定义了四个隔离级别:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。它们分别解决或遗留三个并发问题:脏读、不可重复读、幻读。
- 脏读:事务A读到了事务B还没提交的数据。如果B回滚了,A读到的就是“不存在”的数据。
- 不可重复读:同一个事务里,两次相同的查询返回了不同的值。原因是另一个事务在两次查询之间做了提交。
- 幻读:同一个事务里,两次范围查询返回了不同数量的行。原因是另一个事务插入了新行。
把这四个隔离级别和三个问题放在一起看,会清晰很多:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能(InnoDB的RR已规避) |
| 串行化 | 不会 | 不会 | 不会 |
MySQL的InnoDB引擎默认隔离级别是可重复读,这本身就和SQL标准有点差异。标准里RR是允许幻读的,但InnoDB通过间隙锁和MVCC把RR级别下的幻读问题也解决了。很多人在这里会把“标准定义”和“MySQL实现”搞混,面试时建议先按标准讲,然后补一句“在InnoDB的RR隔离级别下,幻读基本不会发生”,这样既严谨又能体现你区分了通用标准和具体实现。
2.3 可重复读为什么在InnoDB里能防住幻读,以及生产环境的真实选择
MVCC是理解隔离级别的钥匙。InnoDB里的每行记录其实有两个隐藏字段:事务ID和回滚指针。每次事务开始时会生成一个读视图(ReadView),里面记录了当前活跃的事务ID列表。在可重复读级别下,这个ReadView是事务第一次查询时建立的,之后整个事务的所有普通查询都复用同一个ReadView。所以其他事务后面提交了新数据,当前事务也看不见,这就保证了可重复读。
那幻读是怎么被防住的?分两种情况:快照读(普通的SELECT)依靠ReadView,天然看不到新插入的行;当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)则走锁机制,在RR级别下会对扫描范围加上间隙锁,把其他事务的插入操作直接阻塞掉。两条路径各有各的方案,这也是为什么很多有经验的DBA对“RR是否防幻读”这件事会回答“要看读的方式”。
生产环境的选择上,很多团队会主动把隔离级别改成读已提交。原因之一是RC级别下间隙锁基本不生效,死锁概率会降低;原因之二是binlog在statement格式下,RC配合row格式更稳妥。但这不意味着RC比RR好——如果你的业务需要长时间运行的一致性读(比如账务核对),RR的快照读特征是很有价值的。关键是理解两者的机制,而不是盲目照搬别人的配置。
3. 锁机制:全局锁、表锁、行锁,以及真正难以察觉的间隙锁
锁是MySQL并发控制的执行层,八股文里最常考的是“锁的粒度”和“死锁的处理”,但实际工作中,很多人会栽在“当前读到底加了哪些锁”这个问题上。
3.1 从全局锁到行锁,MySQL的锁分了三层防线
全局锁是最狠的,一条FLUSH TABLES WITH READ LOCK就把整个库变成只读状态。日常运维里,全库逻辑备份需要一致性视图,以前会用到这个;但现在有了可重复读隔离级别加持,单机备份可以用mysqldump --single-transaction,在线DDL工具也能避免长时间锁全表。所以全局锁现在更多出现在“极端维护窗口”里,而不是常规操作。
表级锁分两种:表锁和元数据锁(MDL)。表锁是显式锁,LOCK TABLES ... READ/WRITE,现在业务代码里基本没人用。MDL则不一样,它是MySQL自动加的——任何一条SQL在执行前,都会自动对涉及的表加MDL读锁;DDL语句则需要MDL写锁。MDL写锁和读锁互斥,所以一条ALTER TABLE可能把后续所有普通查询都堵住,这就是经典的生产事故“DDL堵死读流量”的根源。我见过最典型的场景:半夜有人在业务高峰期跑了一个大表的ALTER TABLE,结果一连串的查询全部堆积在“Waiting for table metadata lock”,看起来像数据库挂了,其实是被一把看不见的“元数据锁”卡住了。
行锁是InnoDB的看家本领,又分为共享锁(S锁)和排他锁(X锁)。S锁之间可以共存,X锁与任何锁都互斥。另外还有意向锁——它不锁具体的行,而是标记“这张表上有没有行被加锁”,目的是让表锁和行锁在检查时能快速冲突判断,不用逐行遍历。
3.2 行锁的加锁规则,间隙锁和死锁的现场排查
行锁在InnoDB里并不是只锁“命中的那一行”。在RR隔离级别下,一个UPDATE语句扫描过的范围,如果范围里有间隙,MySQL就会加上间隙锁,目的是防止幻读。间隙锁锁的是“两条记录之间的空隙”,不是记录本身。比如表里有id为1和5两条记录,你执行WHERE id BETWEEN 2 AND 4 FOR UPDATE,这区间没有实际记录,但依然会被锁住,其他事务想插入id=3就会阻塞。
这里有一个非常有意思的坑:间隙锁可能让两个事务互相等待,造成死锁。
我举个具体场景:事务A执行SELECT * FROM t WHERE id = 5 FOR UPDATE,如果id=5的记录不存在,它会锁住5附近的间隙;事务B也想插入id=5,它也需要先获取插入意向锁,需要和A的间隙锁互斥。假设两个事务分别锁了不同的间隙,然后互相想往对方的间隙里插入数据,死锁就出现了。遇到死锁时,InnoDB会自动检测并回滚其中一个代价较小的事务,但那个被回滚的客户端会收到“Deadlock found when trying to get lock”的错误。处理思路有三个:让加锁顺序全局一致,缩小事务范围,以及把隔离级别从RR调成RC来减少间隙锁的出现。
3.3 一条UPDATE到底锁了多少行,用数据说话
有一次我们排查线上慢事务,发现一条简单的UPDATE ... WHERE status = 0把大量插入操作全堵住了。原因就是status字段没有索引,这条UPDATE需要全表扫描,InnoDB在RR级别下会对扫描过程中碰到的主键记录加上锁,扫描过的间隙也会被间隙锁覆盖。也就是说,一条看起来只改了几行的UPDATE,实际可能把整个表的写入都掐住了。
结论很简单也很反直觉:UPDATE的锁范围取决于扫描范围,而不是命中范围。只要扫描路径上碰到一行,这一行以及它旁边的间隙都会被锁。所以给WHERE条件涉及的字段建合适的索引,不只是为了查询快,更是为了把锁的范围缩到最小。这也是为什么DBA总是强调“生产库的UPDATE/DELETE必须先看执行计划”。
4. 日志系统:redo log、undo log、binlog,以及两阶段提交的巧劲
日志是MySQL里最不像“八股”的八股。你理解了日志,就理解了MySQL为什么在宕机之后还能不丢数据,也理解了主从复制为什么能工作。
4.1 redo log的WAL机制:先写日志,再改数据
redo log的存在逻辑是:直接改磁盘数据页太慢了。如果每次事务提交都强制把对应的数据页刷到磁盘,性能会低到没法用。于是MySQL采用了WAL(Write-Ahead Logging)策略:事务提交时,先把修改记录写到redo log里,并保证redo log落盘,而数据页的修改可以留在内存缓冲池中,之后再慢慢刷回磁盘。
这个思路的本质是“把随机IO变成顺序IO”。数据页的修改是分散在各个位置的,属于随机写,很慢;而redo log是追加写入的,属于顺序写,很快。万一数据库突然宕机,内存里的数据页还没来得及刷盘,redo log里已经记录了一切。重启后MySQL会扫描redo log,把那些还没刷盘的操作重新执行一遍,这就是崩溃恢复(Crash Recovery)。
redo log不是无限的,它采用循环写的方式,空间用完后就从头覆盖。这里有一个“checkpoint”的概念:系统会在合适时机把内存中的脏页主动刷盘,并把redo log的检查点位置向前推进。如果redo log满了且脏页还没刷完,MySQL会强制执行刷脏,这时候更新操作会被阻塞。这个行为平时不会触发,但如果你遇到过“更新突然卡住”,可以去查innodb_io_capacity和刷脏策略的配置。
4.2 undo log与一致性读:为什么你删了数据,快照还能读到旧值
undo log在事务部分已经提过,它的核心职责是保存“数据在被修改之前的样子”,因此可以做两件事:事务回滚和MVCC快照读。
回滚很好理解,就不展开了。重点说MVCC:一行记录被修改多次后,会形成一个版本链,每个版本的“以前的样子”都存在undo log里。事务执行快照读时,会根据事务ID判断哪个版本是“自己可见的”,然后沿着版本链找到那个版本。这就是为什么其他事务删掉了一行,你的事务还能通过快照读到它——你读的不是当前的物理数据,而是undo log里保留的旧版本。
有一个实际运维里的常见坑:一个超长事务迟迟不结束,会导致它最早创建的ReadView一直存活,这时历史版本无法被purge线程清理,undo log会不断膨胀,表空间可能被撑大。所以线上要留意长事务,不只是因为它会积累锁,还因为它会让undo log无法回收。
4.3 binlog和redo log的区别,以及两阶段提交为什么是必要的
binlog和redo log容易混,这里用一张表讲清楚:
| 对比项 | redo log | binlog |
|---|---|---|
| 所属引擎 | InnoDB存储引擎层 | MySQL Server层 |
| 记录内容 | 物理修改(“第几页第几行改成什么”) | 逻辑修改(“执行了什么SQL/行了什么变更”) |
| 写入时机 | 事务执行过程中持续写 | 事务提交时一次性写 |
| 用途 | 崩溃恢复、保证持久性 | 主从复制、数据恢复 |
两个日志如果不做协调,崩溃恢复就可能出问题:事务先写了binlog但redo log没提交,或反过来,主库恢复后的数据和从库不一致。为了解决这个问题,MySQL引入两阶段提交:事务提交时先写redo log并处于prepare状态,然后写binlog,最后把redo log改为commit状态。如果崩溃恰好发生在两步之间,恢复时MySQL会检查binlog里有没有完整的事务记录——有,则提交;没有,则回滚。这样就保证了主从数据的一致。
两阶段提交在八股文里常被问到“为什么不能只用一个日志”,答案简练一点说就是:redo log只管InnoDB的数据持久化,不负责主从间的逻辑传输;binlog管逻辑复制,但没法做崩溃恢复时对数据页的物理重放。两者各管一段,配合起来才完整。
5. 执行计划与索引失效:把八股文变成线上排查的实弹
八股文的最终价值,是你在EXPLAIN输出面前能一眼看出问题。所以这最后一章,我按实际排查的顺序,把执行计划里最值得看的几列和索引失效的常见场景串一遍。
5.1 EXPLAIN里的key、type和Extra,怎么看才高效
一张表的数据量到几十万之后,一条业务查询哪怕只慢0.5秒,用户都能感觉到。拿到慢查询日志后,第一件事就是EXPLAIN。
在我的经验里,优先看三列:type、key、Extra。
type列描述的是访问类型,按性能从好到差排列,常见顺序是:system > const > eq_ref > ref > range > index > ALL。其中ALL是全表扫描,一定要避免;index是扫描了整棵索引树,虽然比ALL好一点,但仍不是理想状态;range是范围扫描,可以接受;ref和const是典型的“有索引且正确使用”的样子。线上遇到ALL的查询,基本可以直接往索引方向上查。
key列展示的是实际用到的索引,这里有一个常见误区:有时候SQL的WHERE条件里写了索引字段,但key却是空的,说明索引没被用上。这时候就要看Extra列。
Extra列里有几个信息量很大的值:Using index表示覆盖索引;Using where表示在存储引擎层拿到数据后还要再过滤,要注意是不是发生了“索引下推失效”;Using index condition是索引下推(ICP)触发的标志;Using filesort是一件需要警惕的事,它代表MySQL不得不额外做一次排序操作,常见于ORDER BY字段没有按要求走索引的场景。
5.2 索引失效的六种场景,以及背后的共同原因
下面这六种场景,几乎涵盖了线上90%的索引失效问题:
- 对索引列做了函数运算或表达式计算,比如
WHERE DATE(create_time) = '2024-01-01'。原因是索引里存的是原始值,MySQL无法用B+树的顺序来加速一个被函数转换后的值。 - 隐式类型转换,比如索引字段是varchar,但查询条件传了数字。MySQL会把字段隐式转成数字再比较,实际上相当于对索引列加了函数操作。
- LIKE前置百分号,比如
LIKE '%关键词'。B+树的有序性依赖前缀相同,从中间开始匹配自然用不上索引。 - OR条件中有一个字段没索引。优化器为了保证结果的完备性,可能直接放弃使用索引,改为全表扫描。
!=或者NOT IN,很多时候优化器会认为全表扫描比索引查找代价更小。- 联合索引不满足最左前缀。
这六种场景背后有一个统一的判断逻辑:优化器在决定“走索引”还是“全表扫描”时,有一个预估代价的模型。很多所谓“索引失效”的案例,准确说不是“不能走”,而是“走了也不划算”。理解了这一点,你在面对“这SQL为什么没走索引”时,就不会只局限于背诵列表,而是会去算一算回表成本、扫描行数这些变量。
5.3 一次压测调优的完整排查链路
最后分享一个我实际做过的调优案例,把前面的内容串起来。
某项目的列表页接口压测到一定并发后,响应时间从平均20ms飙升到1.2秒。第一反应是查慢查询日志,抓到一条按用户ID和时间范围查询订单的SQL,形态是:
SELECT order_id, amount, status FROM orders WHERE user_id = 123 AND created_at BETWEEN '2024-01-01' AND '2024-01-31' ORDER BY created_at DESC LIMIT 20
EXPLAIN的结果是:type=ALL,rows预估扫了全表差不多70万行。orders表有联合索引(user_id, created_at),为什么没走?继续往下看,发现WHERE条件里对created_at做了BETWEEN,这确实能走索引,但问题在于user_id字段的隐式转换。表里user_id是varchar类型,而传入的参数在应用层被拼成了整数,于是MySQL对user_id做了类型转换,索引失效。
修复方案很简单:应用层把参数改成严格传字符串,同时给联合索引加上排序字段需要的覆盖能力。改完再压测,同样的并发下响应时间回到20ms以内,EXPLAIN显示type=ref,Extra里也出现了Using index。这次调优让我印象很深的地方在于:问题不是出在索引缺失,而是出在“有索引但被隐式转换废掉了”。这类问题的排查能力,不是靠背八股能完全覆盖的,但八股文里的索引失效场景恰恰给了你快速定位的指南针。
MySQL的基础知识远不止这一篇能写完的。索引和事务是地基,日志和锁是支撑,后面继续聊执行计划优化、主从复制、InnoDB的内存结构这些话题的时候,你会发现今天这些内容都是第一块多米诺骨牌。我自己复习下来的体会是:不要只背结论,每背一个结论都问一句“底层靠什么实现的”,然后回来画一遍图,或者写个小实验验证一下。能在脑子里把流程跑通一遍,比背十遍都管用。