ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL技术面复盘:慢查询、索引失效与主从复制延迟解析

MySQL技术面复盘:慢查询、索引失效与主从复制延迟解析

某大厂OD技术面第三轮,MySQL这一块占了将近一半时间。面试官没有让我背八股文,而是直接贴了一条线上真实出现过的慢查询,从执行计划开始问,一路追到优化器为什么放弃索引、MVCC的ReadView到底怎么生成、从库延迟到底卡在哪一环。说实话,平时写SQL、建索引我也算熟练,但被这样一层层剥开问,还是有点招架不住。这篇文章把我在这一轮里印象最深的几道题、当时的回答思路、以及面完查资料补上的细节一起复盘出来,希望能帮同样在准备OD或中级后端岗位面试的朋友,把MySQL知识从“会用”提升到“能讲清楚为什么”。

先说结论:OD技术面的MySQL题目一般不会让你手写特别复杂的存储过程,更看重你能否快速定位慢查询、说清事务隔离与锁、解释主从复制中的延迟问题。这些知识点单独拎出来都不算难,难的是面试官不断追问“为什么”的时候,能不能一直往下讲而不自相矛盾。下面每一章都尽量还原面试现场,也标注了我当时没答好的地方。

1. 一条慢查询把索引知识问到底:EXPLAIN只是起点

1.1 题目原样:几十万行的表,这条SQL为什么慢

面试官在共享屏幕上贴了一张表结构,名字很普通,就是一张用户订单表。

CREATE TABLE user_order ( id bigint PRIMARY KEY AUTO_INCREMENT, user_id bigint NOT NULL, order_no varchar(32) NOT NULL, status tinyint NOT NULL DEFAULT 0, created_at datetime NOT NULL, KEY idx_user_id (user_id), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

慢查询是:

SELECT * FROM user_order WHERE status = 1 ORDER BY created_at DESC LIMIT 20;

他问我:这张表有几十万行,这条查询耗时在1.2秒左右,你怎么优化?我第一反应不是“加索引”,而是先确认status这个条件到底能不能筛选出足够小的数据集合。因为从表结构看,status只做了普通二级索引,而这类状态字段的取值通常就那么几个,区分度很低。如果一个状态下有80%的行,优化器完全可能认为走索引回表比全表扫描更贵。

我当时回答了两步:先看数据分布,再决定索引方案。数据分布可以用一条分组SQL确认:

SELECT status, COUNT(*) AS cnt FROM user_order GROUP BY status;

如果status=1占绝大多数,那JOIN或查询本身是低选择性的,单纯在status上建索引很难生效;如果status=1只占很小一部分,在(status, created_at)上建复合索引,既能让where快速过滤,又能让order by走索引避免文件排序。

1.2 现场分析EXPLAIN:type、key、rows、Extra怎么串起来

面试官让我把这条SQL的EXPLAIN输出画出来,意思很明显:想确认我是真用过EXPLAIN,还是只背过几个字段名。这条查询做一个最简单的EXPLAIN,正常会看到类似下面的结果:

+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----------------+ | 1 | SIMPLE | user_order | NULL | ALL | idx_created_at| NULL | NULL | NULL | 456789 | 33.33 | Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----------------+

type为ALL说明全表扫描,possible_keys里虽然有idx_created_at,但优化器最终没选择它,因为where条件里没有可用的过滤列,走created_at索引只能用来排序,回表代价太高。rows显示40多万行估算,filtered只有33%左右,意味着大量行需要读取和过滤。

我当时分析的重点放在Extra这一列。看到Using filesort,说明排序没有走索引;如果强制走idx_created_at,EXPLAIN里会出现Backward index scan,表示MySQL在反向扫描索引树,取到created_at较大的行后,还需要回表去拿整行,再判断status是否为1。和全表扫相比,哪个代价更大,优化器心里有一本账。

1.3 面试官追问:加上(status, created_at)复合索引就够了吗

我提出复合索引方案后,面试官没有停,紧接着问:是不是加了复合索引就一定能走?这里有个很关键的判断:如果status=1占比非常高,即使有(status, created_at),优化器也可能走全表扫描。因为扫描所有status=1的主键并进行回表,可能比一次全表顺序扫描更慢。全表扫描可以利用预读,按物理顺序读盘,回表则是随机IO,IO成本完全不是一个量级。

正确的优化思路是尽量让查询变成覆盖索引。比如把SQL改成只查必要字段,然后建一个包含排序字段和查询条件字段的索引:

ALTER TABLE user_order ADD INDEX idx_status_created_at_id (status, created_at, id); SELECT id, user_id, order_no, status, created_at FROM user_order WHERE status = 1 ORDER BY created_at DESC LIMIT 20;

如果查询的所有字段都在二级索引中,就不需要回表取完整行。这样即使status=1占比高,扫描二级索引的代价也比“索引扫描+回表”低很多,优化器更愿意选择这个索引。注意,覆盖索引并不能把status=1的所有行都变成一条,它只是减少了回表这个大头,让limit 20在拿到20条后就能提前终止。

面试官这时候又追问了一句:既然只要20条,为什么全表扫描也要扫完40万行?因为全表扫描没有地方可以“提前停止”,它不知道哪些行会有status=1,只能一路判断。而覆盖索引扫描可以在拿到20行后立刻停止,这是覆盖索引在低区分度场景下的真正价值。

1.4 一个加分项:打开optimizer trace看优化器的成本账

回答到“优化器觉得回表更贵”时,面试官问我:你怎么知道优化器是这么想的?当时我有点卡住,后来复盘想到的正确做法是打开optimizer trace,让优化器把成本计算过程全部列出来。

SET optimizer_trace="enabled=on"; SELECT * FROM user_order WHERE status = 1 ORDER BY created_at DESC LIMIT 20; SELECT * FROM information_schema.OPTIMIZER_TRACE;

在输出的JSON里,要重点看considered_execution_plans和cost相关字段,可以看到每个候选执行计划的扫描行数、回表代价、排序代价。只有从这个层面分析,才算真正理解了优化器。面试官没有要求我把JSON背下来,但他显然希望我知道这个工具的存在,而不是只会背“type=ALL不好”。

2. 事务隔离级别:现场画ReadView,再讲间隙锁

2.1 MySQL为什么默认用可重复读而不是读已提交

面试官从慢查询切到了事务,问得很直接:MySQL默认隔离级别是什么?为什么是它?这个我能答:默认是REPEATABLE READ,也就是可重复读。至于为什么,背景是早期binlog格式默认是statement,也就是记录SQL语句本身。在READ COMMITTED下,主库一个事务中删除和插入多条记录时,binlog里记录的语句顺序可能导致从库回放结果不一致,所以MySQL用可重复读配合间隙锁来规避这个麻烦。

后来binlog已经是ROW格式,记录的是每行的变更,其实主从一致性对隔离级别的要求没那么苛刻,所以很多互联网公司会把隔离级别改成READ COMMITTED来减少锁竞争。但是注意,MySQL的RR和标准SQL里的RR并不是完全等价的,它通过MVCC和Next-Key Lock解决了大部分幻读,但仍然有边界场景。这是我后面才补全的理解。

2.2 MVCC现场推演:两个事务并发读同一个行版本

面试官让我在白板上画一下:事务A和事务B并发执行,事务A第一次查询看到的是旧值,事务B更新并提交,事务A第二次查询为什么还是旧值。这本质是考MVCC的快照读实现。

假设表t有一行id=1,name='A',当前事务版本号trx_id=100。事务A在版本号101开始,执行普通select;事务B在版本号102执行了update,把name改成'B'。在InnoDB里,update不会直接覆盖旧行,而是生成一个新版本,旧版本通过undo log链被保留。事务A第一次select时,会创建一个ReadView,记录当前活跃事务列表,也就是还没提交的事务集合。因为事务B的102在活跃列表里,A会认为102修改的新版本不可见,于是顺着undo log链找到旧版本,读到'A'。

事务B提交后,事务A再次select,在RR隔离级别下会复用第一次创建的ReadView,而不是重新生成。活跃列表中仍然有102,所以A依然看不到B的修改,这就是所谓可重复读。这里有个容易困惑的点:如果事务A的select是SELECT * FROM t WHERE id=1 FOR UPDATE,那属于当前读,读的是最新已提交版本,不走ReadView可见性判断,而是走加锁读取。面试官看我把这两者混在一起,专门纠正了一下。

2.3 幻读到底靠谁解决:MVCC和Next-Key Lock的边界

面试官标准追问来了:RR不是能解决幻读吗?那为什么还要有间隙锁?这个问题很多面经讲得含糊。我在现场梳理成两条线:普通快照读,比如不带for update的select,靠MVCC保证同一事务多次查询结果一致,相当于看不到别的事务新插入的行;当前读,比如带for update或在update语句里,需要锁定真实的数据范围,这时MVCC就没法阻止别人插入满足条件的新行,必须靠锁。

InnoDB使用的隔离方案是Next-Key Lock,也就是记录锁加间隙锁的组合。假设事务A执行SELECT * FROM user_order WHERE user_id=100 FOR UPDATE,在RR下,InnoDB会给扫描到的user_id=100的索引记录加记录锁,同时给这些记录之间的间隙加间隙锁。间隙锁锁的不是某一行,而是不允许别的事务在这个范围内插入新行。这样的话,事务B想插入一条user_id=100的新订单时,会被阻塞,直到事务A提交或回滚。

面试官还追问了一个特殊情况:如果user_id=100的记录在当前表里不存在,事务A的for update会把整个间隙锁住吗?答案是会的,这也是RR下很容易产生死锁的原因。我在实际项目里见过因为一个不存在主键的for update把全表某段区间锁住,导致后续大量插入阻塞。这类问题不是理论,而是生产事故。

2.4 如果把隔离级别改成RC,会发生什么

面试官问我:既然RR有间隙锁、死锁概率高,那改成RC有什么代价?我回答:RC下每次普通select都会生成新的ReadView,所以同一事务里两次查询可能看到不同版本,产生不可重复读。RC也没有间隙锁,只有记录锁,插入并发能力更强,降低死锁概率。代价是如果一个事务先查了一条记录,另一事务更新并提交,再查可能读到新值,某些业务逻辑不能被接受。

但需要注意,RC下依然有当前读和快照读的区别,只是快照读每次重新生成快照。互联网高并发场景选RC往往是因为它更贴近“读已提交”的直觉,配合ROW格式binlog也足够安全。我给出的结论是:没有绝对的谁好谁坏,关键看业务是否能容忍同一事务内读到不同的值。这个回答让面试官点头了。

3. 三道“看起来简单”的索引失效题:函数、隐式转换、LIKE

3.1 字符串列与整型比较,索引为什么失效

面试官出了第一道SQL:表user里user_no是varchar(32),有唯一索引,查询条件是WHERE user_no = 12345678,问索引会不会生效。我一眼看出坑:MySQL在比较会隐式把字符串转成浮点或整型,字符串列作为索引列,等值比较时会先对列做类型转换,相当于对索引列套了一层函数,结果就是索引失效。

很多同学容易记反:如果条件是WHERE id = '123',id是整型,索引仍然有效,因为MySQL是把右边的字符串转成整型,而不是对id列做函数。真正失效的是索引列被隐式转换的那个方向。我在现场把这个区别说了一遍,面试官明显对此很满意,因为很多答案只说了“字符串列与数字比较会导致索引失效”,但没有说清为什么以及方向性。

3.2 JOIN时字符集不一致:一个隐蔽的慢查询原因

第二道题是一个真实业务里容易踩的坑。面试官说有两张表order_info和pay_info,order_no字段都是varchar(32),但一张表是utf8mb4,另一张是utf8mb3,JOIN条件用order_no关联,问为什么特别慢。慢的原因不是索引没建,而是JOIN比较时,MySQL会把utf8mb3自动转换为utf8mb4,因为utf8mb4是utf8mb3的超集。这个转换如果发生在表的某列上,索引就无法正常用于关联。

验证方法很简单,还是EXPLAIN。正常情况下,被驱动表应该显示type为ref,慢查询时却显示ALL,同时能看到连接字符集相关的提示。优化方向是统一字符集:把两张表的字段都改成utf8mb4,或者都改成相同的排序规则,避免隐式转换。这里有个实操提醒:直接在大表上执行ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4可能非常耗时,后续版本支持INPLACE,但线上操作还是要评估磁盘空间和IO压力,最好放在低峰期。

3.3 函数调用和LIKE前缀:索引优化的边界在哪

面试官给了三条SQL,问哪条可能走索引:

SELECT * FROM t WHERE DATE(created_at) = '2024-01-01'; SELECT * FROM t WHERE mobile LIKE '138%'; SELECT * FROM t WHERE name LIKE '%三%';

第一条是典型的函数包裹索引列,DATE(created_at)导致普通created_at索引失效,正确写法是范围查询:created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00',这样可以直接走索引B+树的范围扫描。第二条LIKE '138%'是前缀匹配,MySQL可以用索引的B+树特性从13800000001开始连续扫描,属于范围查询,可以走索引。第三条LIKE中间包含%,无法利用索引树定位,基本只能全表扫描,更多时候需要考虑全文索引或搜索引擎。

我当时答完三条SQL后,面试官追加了一句:MySQL 8.0的函数索引能解决第一条吗?我说能,语法类似ALTER TABLE t ADD INDEX idx_created_date ((DATE(created_at)));,但我不建议为了这个问题去建函数索引,因为范围查询写法对优化器更友好,也能兼容老版本,函数索引只适合确实无法改写SQL的场景。

3.4 为什么“有条件才建索引”比“无脑加索引”更重要

面试官总结了一个点:索引不是越多越好,每一次插入和更新都要维护所有二级索引。低区分度列上的索引,空间占用不小,写入代价却不小,还可能让优化器选择错误。我补充了一个度量指标:索引选择性,也就是COUNT(DISTINCT col) / COUNT(*)。如果选择性接近1,适合当过滤条件;如果非常低,就要谨慎。

在面试里能把这层讲出来,会让面试官觉得你有真实调优经验,而不是只会背“索引失效的原因”。我后来在自测时,遇到慢SQL第一反应永远是三个问题:条件列的选择性怎么样?查询需要回表吗?排序和分组能走索引吗?这三个问题基本能覆盖大多数慢查询场景。

4. 主从复制延迟:从监控指标一路问到并行复制

4.1 Seconds_Behind_Master看起来很大,未必真是延迟

面试官切换到主从场景,给了一个监控截图:从库的Seconds_Behind_Master数值非常高,让我排查。我第一反应是提醒他注意这个指标的局限性。它是由从库SQL线程执行binlog事件的时间戳和当前系统时间对比出来的,如果从库系统时间被调过,或者主从机器时间不一致,这个数值本身就有误导性。

更准确的办法,是对比主库当前binlog文件名和位置,与从库SHOW SLAVE STATUS\G里的相关坐标。要看Master_Log_File和Read_Master_Log_Pos,这两个表示IO线程已经读取到主库binlog的哪个位置;再看Relay_Log_File和Exec_Master_Log_Pos,这表示SQL线程已经回放到哪个位置。如果IO线程坐标落后很多,可能是网络或主库本身在刷盘;如果IO线程坐标已经追上,但Exec位置落后很多,那问题就在SQL线程回放。

4.2 三步定位:进程状态、复制状态、性能监控

我当时说了一个排查顺序,面试官听了比较认可。第一步看从库SHOW PROCESSLIST,关注SQL线程是否处于Waiting for dependent transaction to commit或者System lock状态。这个状态经常意味着有大事务在反复执行,SQL线程被卡住。第二步看二进制日志坐标,判断是IO延迟还是SQL回放延迟。第三步,如果MySQL版本支持,查performance_schema里的复制相关表:

SELECT * FROM performance_schema.replication_applier_status_by_worker;

这张表能看出每个并行回放worker的剩余工作量。如果worker积压了很多事务,说明并行复制没完全发挥作用,通常是有大事务在其中拖慢了整体进度。

第三步能问出来,说明不是只会读监控面板,而是真正接触过复制链路。面试官接着问,大事务为什么可怕,我举了一个真实场景:一条UPDATE一次修改了50万行,主库执行可能只花几十秒,但binlog里会记录每一行的变更事件,从库要一个新的一个应用,整个过程非常长。期间这个表上的查询都被拖慢,延迟迅速累积。

4.3 优化方向:并行复制、分批写、DDL工具

面试官问最终怎么解决,我给出三个方向。第一,开启并行复制,也就是MTS。对MySQL 5.7以上版本,设置slave_parallel_type=LOGICAL_CLOCK,并把slave_parallel_workers调成4或8。并行复制对顺序没有要求的事务可以并发回放,能明显提升从库吞吐。但要注意,不能简单认为worker越多越好,每个worker也是独立线程,过多时会增加上下文切换和锁竞争。

第二,避免大事务。写代码时把大规模UPDATE、DELETE拆成批,比如每次只更新1000行,中间sleep一下。这不一定是为了从库,主库的锁和undo膨胀也会更小。第三,DDL要小心。大表的DDL在从库执行也会产生延迟,甚至卡住复制。更好的方案是使用在线DDL工具,比如gh-ost或类似方案,通过binlog追数据完成变更,尽量减少元数据锁的影响。

4.4 一个容易答反的点:半同步复制解决的是丢数据,不是延迟

面试官最后抛了个陷阱:那我是不是把主从改成半同步复制,延迟就没了?这里要分清楚,半同步复制是为了减少数据丢失窗口的。它要求主库在提交事务时,等到至少一个从库收到binlog并写入relay log,才向客户端返回成功。这减少的是主库宕机时binlog还没传到从库的风险,并不能保证从库SQL线程已经把事务回放完。收到binlog和回放完是两码事,延迟的瓶颈往往在回放侧。

我之前确实见过有人把半同步和同步复制搞混。半同步协议里,从库只需要ACK“我收到binlog”,不需要ACK“我执行完了”。所以半同步解决的是“主库挂了会丢多少数据”,不解决“从库读不到最新数据”。能把这一点讲明白,面试官会认为你对复制原理有系统性理解。

5. 复盘:OD技术面MySQL环节的答题节奏与翻车清单

5.1 我习惯用的五步回答框架

面完这轮之后,我给自己总结了一个通用答题框架,遇到任何MySQL问题都可以套。第一步,先复述场景,确认表数据量、隔离级别、MySQL版本,这些变量会影响答案。第二步,用EXPLAIN或optimizer trace获取客观信息,不凭感觉说“应该走索引”。第三步,区分是数据量问题、索引问题、锁问题,还是从库回放问题,不要一上来就说加缓存。

第四步,给出最小改动方案,比如先改SQL写法,再考虑加索引或换隔离级别。第五步,说出这个方案的局限性和验证方式,比如加索引后需要看EXPLAIN和真实耗时,而不是只改完就扔。这套框架不一定每次都能答对,但能让思路看起来有条理。

5.2 这一轮最容易翻车的细节

我整理了一个翻车清单,都是用血泪换来的。第一,把MySQL的RR当成完全解决幻读,忽略了当前读场景下依然存在幻读可能。第二,看到慢SQL就加索引,没有先看列的选择性和执行计划。第三,忽略隐式类型转换,导致索引白建。第四,从库延迟只看Seconds_Behind_Master,不会看binlog坐标和并行复制信息。第五,把半同步复制当成延迟解决方案,概念混淆。

还有一个非常容易被问倒的细节:为什么ORDER BY created_at DESC LIMIT 20在有个单列索引created_at时,有时候反而不如全表扫描?因为排序走索引可以减少filesort,但回表次数可能很高。这里要回到“覆盖索引”和“选择性”两个关键词。我跟面试官拆解之后,他补了一句:能说出覆盖索引,说明你踩过这类坑。

5.3 面完后的自测与巩固

这轮面试结束后,我搭了一个本地的MySQL环境,用几条典型的慢SQL重新做了实验。我用sysbench往测试表里写了20万行数据,分别测了status低区分度、created_at范围查询、字符串隐式转换、JOIN字符集不一致这几种场景,每一步都打开EXPLAIN和optimizer trace记录数据。这个过程比单纯刷面经有用得多,因为很多结论只有亲手验证过,才敢在面试时肯定地说出来。

我还给自己留了一个小练习:写一个事务并发脚本,分别验证RR和RC下的不可重复读现象。具体做法是开两个会话,事务A先select,事务B更新提交,事务A再select。在RR下结果不变,在RC下结果会变。亲手跑一遍,比我背十遍MVCC理论都深刻。如果你也在准备这类面试,建议也做一遍这个实验,成本很低,收获很大。

面到最后一轮,其实最深的体会不是哪个知识点难,而是能不能把一条线串起来。索引、事务、锁、主从复制这些点,单独背都能背出来,但要应对追问,必须在动手实践里真正理解它们的因果关系。把这篇文章里的几道真题逐个跑一遍,回头再看那些八股文,你会觉得它们不再是孤立的问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进