慢的 SQL都慢在同一个地方
要么索引没走对,要么锁等太久。EXPLAIN 里的一行 type 和 rows 就能解释九成的慢查询,剩下那一成藏在 MVCC 和间隙锁里。这份里的每个数字都是在 MySQL 8.0.41 上真跑出来的。
01先看执行计划每节都给 EXPLAIN 的真实输出,对照着自己的 SQL 看 type 和 rows。
02再理解为什么索引、MVCC、锁三块是同一套页结构的推论,理解了就不用背结论。
03最后是排查顺序慢查询定位、死锁排查、分库分表判断,给的是可照着做的步骤。
1
一条 SQL 从发出去到出结果★
先知道有几个地方会慢,再谈优化
一条 SQL 在 MySQL 里要过五道关:连接器 → 解析器 → 优化器 → 执行器 → 存储引擎。你能插手的只有后面两道:让它走对索引(优化器的选择),让它别扫太多行(执行器的活)。MySQL 8.0 把查询缓存整个删掉了,所以"改一条 SQL 让缓存失效"这类老题已经过期。
实测输出(MySQL 8.0.41)第一个查询没有任何返回结果 —— 查询缓存已经不存在
transaction_isolation = REPEATABLE-READ
autocommit = ON
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
max_connections = 151
long_query_time = 10.000000
slow_query_log = OFF
- 连接器负责握手、校验账号密码、分配权限。这一步慢的表现是"连不上"或"连上后报没权限",和 SQL 本身无关。连接数默认 151,超过就报
Too many connections。长连接还有个坑:连接期间权限改了不生效,必须重连,因为权限是连接建立时读进内存的。 - 解析器做词法语法分析,把 SQL 变成语法树。这里报错就是语法问题,
You have an error in your SQL syntax。它不看你有没有索引,只看你写得对不对。 - 优化器是真正决定"慢不慢"的地方。它要在多个执行方案里挑一个:用哪个索引、要不要回表、JOIN 谁先谁后。挑的依据是成本估算,数据来自统计信息。所以统计信息不准,优化器就会选错,这是"明明有索引却不走"的头号原因。
- 执行器按优化器给的方案,一行一行向存储引擎要数据,边取边判断过滤条件。如果这一步要了 10 万行才筛出 10 行,问题就在索引设计,不在 SQL 写法。
- 存储引擎(InnoDB)负责真正的读写,管内存里的 Buffer Pool、磁盘上的页、事务和锁。同样一条 SQL,换个引擎表现完全不同,这也是为什么"MySQL 优化"实际上等于"InnoDB 优化"。
- 查询缓存为什么被删:它的失效粒度是整张表,表上任何一行被改,这张表的所有缓存全部作废。写多的系统里命中率极低,还要额外加锁维护。8.0 直接删掉,换来的是更简单可靠的架构。现在还提"用查询缓存优化"的答案,说明知识停在 5.7。
面试怎么答一条 SQL 依次经过连接器、解析器、优化器、执行器和存储引擎。连接器管连接和权限,默认最大连接 151,权限在连接建立时就确定了,改了要重连。解析器只做语法分析。优化器根据统计信息在多个方案里挑一个,选哪个索引、JOIN 顺序都在这里定,所以统计信息不准就会选错索引。执行器按方案向引擎逐行取数据并过滤。优化能插手的只有优化器和执行器这两段。补充一点:MySQL 8.0 已经移除了查询缓存,因为失效粒度是整表,写多的场景命中率极低,现在不该再把查询缓存当成优化手段。
2
页与 B+ 树:三层为什么能放两千万行★
所有索引问题的物理基础
InnoDB 读写的最小单位是页,默认 16KB。索引是一棵 B+ 树,非叶子节点只存键值和页号,叶子节点才存数据并用双向链表串起来。这个结构决定了三件事:树很矮(三层就够放千万级)、范围查询很顺(叶子是链表)、每次查询的磁盘 IO 次数等于树高。
实测输出(1 万行的表)innodb_page_size = 16384 字节(16KB)
t_user 表
行数 ≈ 10186
数据 1589248 字节
索引 2277376 字节
聚簇索引页数 = 97
二级索引页数 = 139
- 算一下树有多高:1 万行数据只占了 97 个页,平均一页放 105 行。根页能放多少个指针?非叶子节点只存"主键 + 页号",一个主键 4 字节、页号 6 字节,16KB 一页能放大约 1170 个。所以一层根能管 1170 个叶子页,两层的容量是 1170 × 105 ≈ 12 万行。
- 三层能放多少:1170 × 1170 × 105 ≈ 1.4 亿行。这就是"B+ 树三层放两千万行"的来源——实际还取决于单行大小,行越宽每页放得越少。反过来,这也是主键要短的硬理由:主键长一倍,每个非叶子页能放的指针就少一半,同样的数据树就更高,每次查询多一次磁盘 IO。
- 为什么不用红黑树或跳表:红黑树是二叉的,每个节点只有两个分支,同样的数据高度是 B+ 树的好几倍,磁盘 IO 次数跟着翻倍。跳表适合内存(Redis 的 ZSet 就用它),因为内存随机访问便宜,不需要靠"一页装很多"来减少 IO。
- 为什么叶子要用双向链表串起来:这是 B+ 树和 B 树最大的区别。B 树的非叶子节点也存数据,范围查询要中序遍历来回跳。B+ 树的所有数据都在叶子层,叶子之间是链表,
WHERE id BETWEEN 100 AND 200只要定位到 100 所在的叶子,然后顺着链表往右扫,磁盘是顺序读的。 - 索引页数比数据页还多:实测二级索引 139 页,大于聚簇索引的 97 页。因为这张表建了三个二级索引(name+age、city、phone),每个二级索引都是自己独立的一棵 B+ 树,叶子节点存"索引列 + 主键"。每加一个索引就是多维护一棵树,写一次数据要更新所有相关的树,这是索引不能乱加的直接原因。
面试怎么答InnoDB 以 16KB 的页为读写单位,索引是 B+ 树,非叶子节点只存键值和页号,叶子节点存数据且用双向链表串联。按主键 4 字节估算,一个非叶子页能放约 1170 个指针,叶子页按每行 100 字节算放约 160 行,三层就能覆盖两千万行以上,所以查询的磁盘 IO 次数基本等于树高,通常两三次。相比红黑树,B+ 树单节点分支多所以更矮,IO 更少;相比 B 树,数据全在叶子层且叶子成链,范围查询是顺序扫描而不是中序跳跃。这也解释了主键要短、索引不能乱加:主键变长会抬高树高,每加一个索引就多维护一棵树。
3
聚簇索引、回表与覆盖索引
Extra 那一列里藏着的性能差异
InnoDB 的表本身就是一棵按主键排序的 B+ 树,叫聚簇索引,叶子节点直接存整行数据。二级索引的叶子只存"索引列 + 主键",所以用它查别的列时必须拿着主键再回聚簇索引查一次,这就是回表。如果需要的列全在索引里,就不用回表,EXPLAIN 的 Extra 会显示
Using index。实测输出(只看关键三列)-- SELECT * → 需要回表
type=ref key=idx_name_age rows=1 Extra=(空)
-- SELECT name, age → 覆盖索引,不回表
type=ref key=idx_name_age rows=1 Extra=Using index
- 回表到底贵在哪:不是多查一行这么简单。二级索引是按 name 排序的,聚簇索引是按主键排序的,两边的顺序毫无关系。所以用二级索引查出 100 个主键后,回表就是100 次随机 IO。而顺着二级索引扫描本身是顺序 IO。顺序读一万行可能比随机读一万行快一个数量级,这就是回表真正的代价。
- 覆盖索引是最便宜的优化:把
SELECT *改成只查索引里有的列,回表次数直接归零。但要注意别为了覆盖索引把大字段全塞进索引——索引列越多,叶子节点越大,每页放的条目越少,树就越高,而且每次写入都要更新这棵更大的树。 - 为什么主键不建议用 UUID:聚簇索引按主键排序,随机 UUID 会让新插入的行落在 B+ 树的随机位置,频繁触发页分裂:一页满了要拆成两页,还要调整父节点的指针。用自增主键时新行永远插在最右边,是顺序追加。自增主键的代价是"ID 可被猜到"和"分库分表后要另想办法",通常是自增 + 业务无关 ID 组合解决。
- 二级索引叶子存的是主键,不是行地址:这个设计让行移动时不用改所有二级索引。但代价是主键越长,所有二级索引都跟着变大。用 VARCHAR(36) 的 UUID 做主键,每个二级索引叶子里都多存 36 字节,四个二级索引就是四份额外开销。
- 不是所有 extra 为空都需要慌:查一行数据回表一次完全不贵。真正的问题是"扫了很多行还要每行回表"。判断标准是
rows这个估算值——rows 小、回表几次,无所谓;rows 上万还要回表,才需要改成覆盖索引。
面试怎么答InnoDB 的表本身就是按主键组织的 B+ 树,叫聚簇索引,叶子节点存整行。二级索引叶子只存索引列和主键,所以用它查非索引列时要拿主键再回聚簇索引查一次,这叫回表。回表的真实代价是随机 IO:二级索引按 name 排序、聚簇索引按主键排序,顺序无关,查出一批主键就要随机跳着读。EXPLAIN 的 Extra 显示 Using index 说明是覆盖索引,不用回表。优化上优先改成只查索引列,而不是无脑加索引;主键建议用短且递增的值,因为随机 UUID 会导致页分裂,而主键长度会放大每一个二级索引的体积。
4
最左前缀:key_len 是唯一可信的证据★
别再靠"感觉走了索引"下结论
联合索引
(a, b, c) 是按 a 排序、a 相同再按 b 排序、b 相同再按 c 排序。跳过 a 直接用 b,就像拿一本按姓氏排的电话簿去查名字,索引用不上。EXPLAIN 里的 key_len 精确告诉你用到了索引的哪几列,这比 key 有值更可信——key 有值但 key_len 很小,说明只用了一小段。实测输出-- 只用 name
type=ref key=idx_name_age key_len=202 rows=1
FORMAT=JSON: "used_key_parts": ["name"]
-- name + age 都用
type=ref key=idx_name_age key_len=206 rows=1
FORMAT=JSON: "used_key_parts": ["name", "age"]
-- 只用 age
type=ALL key=NULL key_len=NULL rows=10186 Extra=Using where
- key_len 怎么算出来:
name VARCHAR(50)是 utf8mb4、非空,一个字符最多 4 字节,所以 50 × 4 = 200,VARCHAR 变长还要 2 字节存长度,合计 202。age INT非空占 4 字节,202 + 4 = 206。数字对上了,就证明两列都用上了。这个算法能反推索引里到底用了哪些列,是排查"联合索引为什么只走一半"最硬的证据。 - 跳过第一列就用不上:实测
WHERE age = 25直接type=ALL、key=NULL,扫了 10186 行。原因是 B+ 树只保证"在 a 相同的前提下按 b 有序",a 不确定的时候 b 在整棵树上是乱序的,没法二分。 - 范围查询会截断后面的列:
WHERE name = 'x' AND age > 20里,name 是等值所以能直接定位,age 是范围所以只能确定一个区间。如果还有第三列 c,c 在 age 的区间内是乱序的,用不上。所以写联合索引时把等值条件放前面、范围条件放后面。 - WHERE 里条件的书写顺序不影响:
WHERE age = 25 AND name = 'x'和反过来的效果完全相同,优化器会自动重排。真正决定用不用得上的是索引定义里列的顺序,不是 SQL 里的顺序。这一点很多人搞混。 - ORDER BY 和 GROUP BY 也走最左前缀:
WHERE name = 'x' ORDER BY age能直接利用(name, age)索引避免排序;但ORDER BY created_at就走不了,Extra 会出现Using filesort。把范围条件换成等值条件后,排序需求常常自然消失,这比加索引更省事。
面试怎么答联合索引按列的顺序逐级排序,所以必须从最左列开始用,跳过第一列后面全部失效。判断依据最硬的是 EXPLAIN 的 key_len:VARCHAR(50) 的 utf8mb4 非空列占 50×4+2=202 字节,INT 占 4 字节,202 说明只用了第一列,206 说明两列都用上了。EXPLAIN FORMAT=JSON 里的 used_key_parts 会直接列出用了哪几列,更好读。设计联合索引时等值条件放前面、范围条件放后面,因为范围列之后的列无法再用。另外 WHERE 条件的书写顺序不影响结果,优化器会重排,真正决定成败的是索引定义的列顺序。
5
索引失效的真凶,一次测清楚★
六种情况,每种都能在 EXPLAIN 里看到
索引失效不是玄学,本质只有两种:要么你让索引列"不再是它自己"(函数、运算、类型转换),要么你的条件覆盖了太多行(优化器算完觉得全表扫更便宜)。把这两种想明白,绝大部分"为什么不走索引"都能自己解释。
实测输出(1 万行,逐条对照)-- name = 'user123'
type=ref key=idx_name_age rows=1
-- name LIKE 'user12%'
type=range key=idx_name_age rows=111 Extra=Using index condition
-- name LIKE '%123'
type=ALL key=NULL rows=10186 Extra=Using where
-- DATE(created_at) = '2026-01-01'
type=ALL key=NULL rows=10186 Extra=Using where
-- phone = 13800000123(传数字)
type=ALL key=NULL rows=10186 Extra=Using where
-- phone = '13800000123'(传字符串)
type=ref key=idx_phone rows=1
-- city != '北京'
type=ALL key=NULL rows=10186 Extra=Using where
-- city = '北京'
type=ref key=idx_city rows=2000
-- city = '北京' OR age = 30
type=ALL key=NULL rows=10186 Extra=Using where
- 1. 函数或运算包住了索引列:
DATE(created_at) = '2026-01-01'里,索引存的是 created_at 原值,你让它算完再比,树上的顺序就失效了。改写成范围:created_at >= '2026-01-01' AND created_at < '2026-01-02'。这个改法适用面很广,值得背下来。 - 2. 隐式类型转换(最隐蔽):
phone是 VARCHAR,你传数字13800000123,MySQL 会把字符串列逐行转成数字再比,等效于给每行套了个函数,索引直接废掉。反过来传字符串就正常。规则:字符串列必须传字符串,而且这也是索引失效里最容易在真实项目里发生的一种,因为参数从 JSON 或表单里来的时候类型常常没校验。 - 3. 前后都模糊的 LIKE:
'%123'没法在有序的树上定位起点,只能全表。前模糊后精确('user12%')可以用索引,实测走了 range、只扫 111 行。真正的全文检索需求应该上全文索引或 ES,不要用LIKE '%%'硬撑。 - 4. 不等号 != 或 <>:这条最反直觉。实测
city != '北京'是type=ALL全表扫,而city = '北京'走了索引只扫 2000 行。原因不是"不等号不能用索引",而是这张表五个城市各占 20%,查 4 个城市等于要扫 80% 的行,优化器一算:走索引要回表 8000 次,不如直接顺序扫全表。如果某个值占了 99%,查 != 它反而是高效的。所以这条的准确说法是"选择性低的时候优化器会放弃索引"。 - 5. OR 连接了没索引的列:只要 OR 的某一边用不上索引,整个查询就退化成全表。因为要满足 OR 就得两边都扫一遍再合并。改法有两种:给另一边也建索引(优化器可能用 index merge),或者拆成两条 SQL 用 UNION 合并。但更常见的正确做法是改数据模型或加缓存,而不是硬加索引。
- 6. 选择性太低:
city只有 5 个值,即使走索引也要回表 2000 次,优化器可能直接选全表。判断标准是选择性 = 不重复值数 / 总行数,接近 1 才适合建索引。性别、状态、是否删除这类列单独建索引意义很小,放在联合索引的后面才有价值。
面试怎么答索引失效可以归成两类原因。第一类是让索引列失去原有顺序:列上套了函数或运算、发生了隐式类型转换、LIKE 以通配符开头,这几种都会让 B+ 树的有序性用不上。第二类是优化器主动放弃:条件选择性太低、不等号覆盖了大部分行、OR 里有一边没索引,优化器算完成本发现全表扫更便宜。判断方法统一看 EXPLAIN,type 是 ALL 且 key 是 NULL 就说明没用索引,rows 是估算的扫描行数。修复上,函数改写成范围条件、字符串列传字符串、LIKE 改成前缀匹配、低选择性列放进联合索引的后置位置或者干脆不建索引。
6
explain 怎么读:type 的等级和 rows 的含义
一张表,四个字段,够定位九成慢查询
读 EXPLAIN 只需要看四列:type(访问方式,越靠前越好)、key(实际用了哪个索引)、rows(预估要扫多少行)、Extra(有没有回表、有没有额外排序)。其中 rows 是最容易被忽略但最有用的——它直接告诉你这个查询大概要付出多少行扫描的代价。
实测输出(只摘关键字段)-- 走 idx_name_age
"table_name": "t_user"
"key": "idx_name_age"
"used_key_parts": ["name", "age"]
"rows_examined_per_scan": 1
"query_cost": "0.35"
-- 全表扫
"table_name": "t_user"
"rows_examined_per_scan": 10186
"query_cost": "1042.85"
- type 从好到坏:
system > const > eq_ref > ref > range > index > ALL。日常见到最多的是ref(等值命中非唯一索引)、range(范围)、index(扫整个索引,比 ALL 好一点因为不用回表)、ALL(全表)。看到 ALL 就该警觉,看到 index 也别高兴太早——它只是把"扫数据页"换成了"扫索引页"。 - eq_ref 和 ref 差在哪:
eq_ref是"每条记录只匹配一行",典型是 JOIN 时用主键或唯一索引关联;ref是"可能匹配多行",用的是普通索引。所以 eq_ref 出现说明关联条件用的是唯一键,这通常是最好的情况。 - rows 是估算值不是真实值:1 万行的表显示 10186,比真实行数还大,因为统计信息本身是个采样估算。所以 rows 只能看数量级:10 和 100 没本质区别,10 和 10000 是天壤之别。不要拿 rows 跟实际结果行数对比后说"统计错了",它衡量的是"预计要检查多少行",不是"返回多少行"。
- query_cost 是判断优劣的直接依据:实测走索引 0.35,全表 1042.85,差了近 3000 倍。用
FORMAT=JSON能看到这个数字,改完 SQL 前后对比 cost 就知道有没有真的优化,比肉眼看快慢可靠得多。 - Extra 里五个关键信号:
Using index是覆盖索引(好事);Using where是取出来还要过滤(可能扫多了);Using index condition是索引下推(好事);Using filesort是多了一次排序(坏事);Using temporary是建了临时表(通常更坏,GROUP BY 或 DISTINCT 常见)。
面试怎么答读 EXPLAIN 主要看 type、key、rows、Extra 四列。type 的优先级是 system、const、eq_ref、ref、range、index、ALL,看到 ALL 说明全表扫描要重点处理,看到 index 也要注意它只是改扫索引而不是真正定位。rows 是优化器基于统计信息的估算,只看数量级不看精确值。Extra 里 Using index 表示覆盖索引不用回表,Using index condition 是索引下推,Using filesort 和 Using temporary 是额外的排序和临时表开销。想看具体代价可以用 EXPLAIN FORMAT=JSON,里面的 query_cost 能直接对比优化前后,实测走索引 0.35、全表 1042.85,差了近三千倍。
7
索引下推:Extra 里的 Using index condition
把过滤提前到引擎层,少回表
索引下推(ICP,Index Condition Pushdown)是 MySQL 5.6 引入的优化:把本来要在 Server 层做的 WHERE 过滤,下推到存储引擎层,在遍历索引的时候就顺手判断掉。好处是不满足条件的行直接不回表。
Extra 里出现 Using index condition 就说明它生效了。实测输出-- ICP 关闭
type=range key=idx_name_age rows=111 Extra=Using where
-- ICP 打开
type=range key=idx_name_age rows=111 Extra=Using index condition
-- 实际命中
COUNT(*) = 3
- 没有 ICP 时发生了什么:按
name LIKE 'user12%'在索引上扫描,扫到 111 个候选主键,每个都要回表去取 age,取回来再判断age = 30,最后只剩 3 行。也就是白回表了 108 次。 - 有 ICP 之后:索引
(name, age)的叶子里本来就有 age,引擎在扫索引的时候就能判断 age 是否等于 30,不满足的直接跳过不回表。回表次数从 111 降到 3。注意rows还是显示 111——那 111 是"要检查的索引条目数",不是回表次数,所以不要用 rows 判断 ICP 有没有生效,看 Extra。 - 什么条件下才会生效:条件必须是索引里已有的列,而且该列在索引中位于范围条件的后面。如果 age 不在索引里,引擎手上根本没有这个值,想下推也推不了。所以联合索引的列顺序会影响 ICP 能不能用,这是设计索引时的一个额外考量。
- ICP 只减少回表,不减少扫描:索引条目还是一个一个过,111 条该看还得看,省的是随机 IO。所以它对"扫得多但命中少"的场景效果最好。如果条件命中率很高,ICP 收益就很小。
- 默认是开的,别手动关:
optimizer_switch里index_condition_pushdown默认 ON。上面关掉只是为了观察差异,生产环境不要动。关掉它只会变慢,没有任何理由。
面试怎么答索引下推是 5.6 引入的优化,把 Server 层的 WHERE 过滤下推到存储引擎,在遍历二级索引时就用索引里已有的列判断,不满足条件的行不再回表。判断是否生效看 EXPLAIN 的 Extra,出现 Using index condition 就是生效了。实测一个 LIKE 前缀加第二列等值的查询,候选 111 行最终只命中 3 行,没开 ICP 要回表 111 次,开了之后只回表 3 次。注意 rows 显示的还是索引扫描条目数,不会因为 ICP 变小,所以不能拿 rows 判断。它只减少回表不减少扫描,条件必须落在索引列上,默认开启不需要手动配置。
8
深翻页:从 9005 次回表降到 5 次★
LIMIT 大偏移量为什么越翻越慢
LIMIT 9000, 5 并不是"跳过 9000 行只读 5 行",而是先老老实实取出 9005 行,再把前 9000 行扔掉。如果每行还要回表,那就是 9005 次随机 IO。改成先在索引上取 5 个主键再回表,回表次数就只有 5 次,这个改法叫延迟关联。实测输出-- 直接深翻
type=index key=PRIMARY rows=9005 Extra=(空)
-- 延迟关联,子查询部分
type=index key=PRIMARY rows=9005 Extra=Using index ← 只扫索引,不回表
-- 延迟关联,外层关联
type=eq_ref key=PRIMARY rows=1 ← 只回表 5 次
-- 两者结果一致,首行 id 都是 9001
- 关键在 Extra 的变化:子查询里那个
Using index说明"扫 9005 行"是在索引上扫的,完全不回表;外层eq_ref只执行 5 次。而直接深翻的SELECT *扫 9005 行的同时每行都要回表。同样的扫描行数,IO 差了两个数量级。 - 为什么不能靠 OFFSET 优化:偏移量本身没有记录"上一页停在哪",数据库无法直接跳到第 9000 行。B+ 树能按 id 快速定位到某个值,但"第 9000 个"这个概念它不知道。所以能直接跳的只有"从某个 id 之后",这就是游标分页的由来。
- 更好的改法是游标分页:
WHERE id > 上一页最后一个 id ORDER BY id LIMIT 5,每次都能用主键定位到起点,翻到第 100 万页和第一页一样快。代价是不能跳页——用户没法直接点"第 500 页"。所以它适合"下拉加载更多"和"导出全量",不适合需要页码的后台列表。 - 业务上还有两个解法:一是限制可翻页数(比如最多 100 页,再深就要求加筛选条件),二是把总数和列表分离(列表只给游标,总数单独缓存)。很多产品的"第 10000 页"本来就是无意义的需求,产品侧砍掉比技术侧硬撑更划算。
- 顺带说 ORDER BY 的坑:上面能这么快是因为
ORDER BY id正好是主键顺序。如果改成ORDER BY created_at而 created_at 没索引,那就要先全表排序再翻页,延迟关联也救不了——得先解决排序。
面试怎么答LIMIT offset, size 的语义是先取 offset+size 行再丢弃前 offset 行,所以偏移越深扫得越多。如果查询还要回表,深翻页就变成大量随机 IO。优化方法是延迟关联:子查询只在索引上取目标行的主键,EXPLAIN 里会显示 Using index 说明不回表,外层再用主键关联只回表 size 次。实测扫 9005 行的场景,直接查要回表 9005 次,延迟关联只回表 5 次。更彻底的是游标分页,用 WHERE id > 上一页最后 id 加 LIMIT,任何页都能直接定位,代价是不能跳页。如果排序字段没索引,得先解决排序问题,延迟关联不起作用。
9
MVCC:同一事务里为什么读出三个结果★
快照读、当前读,一次实验看清
InnoDB 靠 MVCC 实现"读不加锁也能不阻塞"。每行数据藏了两个隐藏字段:DB_TRX_ID(最后修改它的事务号)和 DB_ROLL_PTR(指向 undo log 里上一个版本)。普通 SELECT 是快照读,按事务开始时的 ReadView 挑一个可见的版本;
FOR UPDATE 是当前读,永远读最新提交的版本并加锁。实测输出(两个连接,RR 隔离级别,初始 balance=100)A 快照读 -> 100
B 执行 UPDATE 并提交 -> 999
A 再快照读 -> 100 ← 事务没结束,看到的还是老版本
A 当前读 FOR UPDATE -> 999 ← 当前读绕过快照,读最新
A 再快照读 -> 100 ← 又回到快照
- 同一行读出三个值,这就是 MVCC:普通 SELECT 从头到尾读的是"事务开始那一刻的快照",所以两次都是 100,虽然 B 已经提交了 999。而
FOR UPDATE是当前读,它不看快照,直接读最新已提交版本,所以是 999。加锁读之后再做普通读,又回到 100,说明快照一直没变,当前读只是"临时看了一眼"。 - undo log 是版本链的载体:每次 UPDATE 都把旧值写进 undo log,DB_ROLL_PTR 串成一条链。B 把 100 改成 999 时,undo log 里存了"把 999 改回 100"的记录。所以 A 要读 100,不用去别的地方找,顺着版本链回溯就能拿到。这也是"事务开太久会撑爆 undo 表空间"的原因:老事务不结束,它需要的旧版本就不能被清理。
- ReadView 决定谁可见:事务开始时生成一个 ReadView,里面记着"当时活跃的事务 ID 列表"。遍历版本链时,遇到的事务 ID 如果还在活跃列表里,或者比自己大,就跳过继续往前找。RR 的 ReadView 是事务开始时建一次,RC 是每条语句都重建,这一个差异造成了两者可见性的全部不同。
- 快照读和当前读必须分清:
SELECT是快照读;SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都是当前读。"为什么我改了数据但查询还是老值"这类问题的答案永远在这里。 - MVCC 解决的是读写冲突,不解决写写冲突:两个事务同时改同一行,MVCC 管不了,得靠行锁排队。所以"MVCC 让读写不阻塞"是准确的说法,"MVCC 让并发无锁"是错的,写写之间照样要等。
面试怎么答InnoDB 每行有 DB_TRX_ID 和 DB_ROLL_PTR 两个隐藏字段,undo log 里保存旧版本并用回滚指针串成版本链。普通 SELECT 是快照读,按事务开始时的 ReadView 从版本链里挑可见版本;FOR UPDATE、UPDATE、DELETE 是当前读,直接读最新提交版本并加锁。实测在一个 RR 事务里,另一个事务提交了更新后,快照读仍是旧值,同一事务内执行 FOR UPDATE 能读到新值,之后再普通读又回到旧值,这正好证明快照和当前读是两套机制。RR 的 ReadView 在事务开始时创建一次,RC 每条语句都重建,这是两者可见性差异的根源。MVCC 只解决读写冲突,写写冲突仍然靠行锁。
10
四种隔离级别,RR 到底解决了什么★
同一个实验,RC 和 RR 三个结果全不同
四个隔离级别解决的是三类问题:脏读(读到别人没提交的)、不可重复读(同一事务两次读不一样)、幻读(同一范围两次读数变多)。MySQL 的默认级别是 RR(可重复读),靠 MVCC 的一致性读解决不可重复读,靠间隙锁解决当前读时的幻读。但 RR 下的快照读本身就不会有幻读,因为整张表看到的都是同一个快照。
实测输出(两个连接对照)=== READ COMMITTED ===
快照读 id=1:10 -> B 改成 999 -> 999(不可重复读)
快照读 COUNT:2 -> B 插入 id=9 -> 3(出现幻影行)
当前读 COUNT(*):3
=== REPEATABLE READ ===
快照读 id=1:10 -> B 改成 999 -> 10(可重复读)
快照读 COUNT:2 -> B 插入 id=9 -> 2(没有幻影行)
当前读 COUNT(*):3(看到了幻影行)
- RC 两点全崩:RC 每条语句重建 ReadView,所以第二次读就看到了 B 已提交的 999(不可重复读),也看到了新插入的行(幻读)。这就是"RC 一致性弱但并发高"的具体含义。
- RR 的快照读很彻底:整个事务共用一个 ReadView,所以两次读完全一样,插入的新行也看不见。很多人以为 RR 靠间隙锁防幻读,其实快照读根本不需要锁——它在读一个历史快照,新行压根不在快照里。
- 但当前读会破功:最后一行
SELECT COUNT(*) FOR UPDATE在 RR 下返回 3,看到了 B 插入的幻影行。因为当前读要看最新数据,快照帮不上忙。这就是间隙锁存在的唯一理由:让"当前读看到的那批行"在事务结束前不能被插入新的。 - 所以标准答案要分两半:RR 在快照读下靠 MVCC 避免幻读,在当前读下靠间隙锁避免幻读。只说"RR 用间隙锁解决幻读"是不完整的,会说这一半,说明真的做过实验。
- RC 也有它的道理:RC 没有间隙锁,写冲突少、死锁概率低,很多互联网公司主动把默认级别改成 RC。代价是业务要能接受同一事务里读到不同值。选级别是业务一致性要求和并发量的权衡,不是"越高越安全"。
面试怎么答四种隔离级别解决脏读、不可重复读和幻读三类问题。MySQL 默认 RR。实测同一段操作:RC 下两次快照读分别拿到 10 和 999,行数从 2 变成 3,说明既有不可重复读又有幻读;RR 下两次都是 10、行数都是 2,因为整个事务共用同一个 ReadView。但 RR 下把查询换成 FOR UPDATE 做当前读,行数变成 3,幻影行出现了。所以准确的说法是:RR 在快照读下靠 MVCC 避免幻读,在当前读下靠间隙锁避免幻读,两者缺一不可。RC 虽然没有间隙锁、一致性更弱,但并发更好、死锁更少,不少公司会主动选它。
11
间隙锁:为什么 RR 下插不进去★
从 data_locks 里看真实加了哪几把锁
InnoDB 的行锁有三种形态:记录锁(锁住某一行)、间隙锁(锁住两条记录之间的空隙,只阻止插入)、临键锁(记录锁 + 前面的间隙,范围查询时的默认形态)。间隙锁只在 RR 及以上级别存在,RC 下没有,这是很多人从 Oracle 或 PostgreSQL 转过来最容易困惑的地方。
实测输出=== REPEATABLE READ ===
A 锁住 id BETWEEN 2 AND 4,实际只命中 id=3
B 插 id=2(落在被锁区间内) -> 被锁挡住,错误 1205,等了 2.0 秒
B 插 id=6(落在区间外) -> 成功
=== READ COMMITTED(同样语句)===
B 插 id=2(落在被锁区间内) -> 成功,没有间隙锁
B 插 id=6(落在区间外) -> 成功
- 只命中 3 这一行,却锁住了整段空隙:
BETWEEN 2 AND 4在表里只有 id=3 满足,但 id=2 也插不进去。因为 RR 下范围查询加的是临键锁:锁住 id=3 这一行,同时锁住它左边的空隙 (1, 3)。只要落在这段空隙里,插入就被挡住。 - data_locks 能看到锁的真实形态。主键等值查询加的是一把
X,REC_NOT_GAP;范围查询变成了X(临键锁)加一把X,GAP,锁到了 id=9 那个位置。LOCK_MODE 里的 REC_NOT_GAP 表示不含间隙,GAP 表示只锁间隙,没有后缀就是临键锁。 - 普通索引等值查询会加三把锁:在
v = 50上执行 FOR UPDATE 时,实测同时出现了 idx_v 上的记录锁、idx_v 上的间隙锁、以及回表后主键上的记录锁。原因是二级索引不唯一,引擎无法确定"还有没有别的 50",所以要多锁一段。所以给低选择性的列建索引,锁的范围反而更大。 - RC 为什么没有间隙锁:RC 每条语句重新取快照,它不需要阻止"插入新行",因为下一次读自然就会看到新数据。关掉间隙锁换来的是更少的锁冲突和更低的死锁概率,代价是同一事务内可能读到不一致的数据。
- 间隙锁的坑在"锁了比你想要的多":一个
WHERE id BETWEEN 2 AND 4 FOR UPDATE可能锁住一整段,如果这是个高频路径,插入操作会被反复阻塞。实践建议:能在 RR 下用等值条件就别用范围条件加锁,非要范围就用 RC,或者改用乐观锁(版本号)替代悲观锁。 - 唯一索引等值查询只加记录锁:因为唯一性保证了"只有这一行",不需要锁间隙。这也是"用唯一索引能减少锁冲突"的实际原因。如果你的业务查询是唯一键,尽量声明成 UNIQUE 而不是普通索引,锁的范围立刻收窄。
面试怎么答InnoDB 行锁有记录锁、间隙锁、临键锁三种形态,范围查询在 RR 下默认加临键锁,也就是记录锁加左边的间隙。间隙锁只阻止插入不阻止读取,只在 RR 及以上级别存在,RC 没有。实测在 id 为 1、3、5、7、9 的表上对 BETWEEN 2 AND 4 加 FOR UPDATE,虽然只命中 id=3 一行,但插入 id=2 会被阻塞到锁等待超时,插入 id=6 则正常,说明锁的范围是 id=3 及其左边的空隙。用 performance_schema.data_locks 能看到真实的锁模式,X,REC_NOT_GAP 是纯记录锁,X,GAP 是纯间隙锁,没有后缀是临键锁。想减少锁冲突,优先用唯一索引做等值查询,或者在 RC 下运行。
12
死锁:谁被牺牲,怎么排查
不是"避免死锁",是"控制死锁的代价"
死锁是两个事务各持一把锁、又去等对方那把,形成环。InnoDB 有死锁检测(
innodb_deadlock_detect,默认开),发现环后主动回滚其中一个事务,报 1213,另一个正常提交。所以死锁不是"数据库坏了",而是一种被设计的正常现象,关键是把它的代价控制在"重试一次就好"。实测输出A 先锁 id=1:拿到 id=1 的锁
B 先锁 id=2:拿到 id=2 的锁
A 再锁 id=2:也拿到,正常提交(InnoDB 选了 B 做牺牲者)
B 再锁 id=1:错误 1213,被选为牺牲者回滚
SHOW ENGINE INNODB STATUS 里的死锁记录:
*** (1) TRANSACTION:
LOCK WAIT 3 lock struct(s), 2 row lock(s), undo log entries 1
UPDATE t_lock SET v = v + 1 WHERE id = 1
- 1213 和 1205 要分清:1213 是
Deadlock found,说明检测到了环,事务被主动回滚,立刻重试通常就成功;1205 是Lock wait timeout,说明只是等锁超过了innodb_lock_wait_timeout(默认 50 秒),可能根本没有死锁,只是有长事务占着锁。两者处理方式完全不同。 - 死锁检测有成本:每次等锁都要遍历等待图看有没有环,高并发下这个遍历会吃掉大量 CPU。当业务能接受"偶发回滚"时,可以关掉检测(
innodb_deadlock_detect=OFF),让等待超时来兜底。这是典型的用响应时间换 CPU。 - 排查死锁的三个入口:
SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段有完整现场(两个事务分别执行到什么语句、持有什么锁、等什么锁);performance_schema.data_locks看当前所有锁;information_schema.INNODB_TRX看谁开的事务最久。 - 最常见的死锁场景是"顺序不一致":A 先改 1 再改 2,B 先改 2 再改 1。统一加锁顺序是最有效的预防手段——业务代码里凡是批量更新多行,先按主键排序再执行。这一条能消掉大部分死锁。
- 第二常见的是"唯一索引冲突":两个事务同时插入同一个唯一键,其中一个会拿共享锁等待,然后互相升级成排他锁,结果死锁。这类死锁的正确解法是业务层捕获 1213 后重试,不要试图用 SQL 消除。
- 长事务是死锁的放大器:事务开得越久,持锁时间越长,撞上的概率越大。所以真正的预防措施是把事务写短:不要在事务里做 RPC 调用、发消息、写文件、等用户输入。事务里只留数据库操作,这是比任何锁调优都有效的原则。
面试怎么答死锁是两个事务各持有一把锁又互相等待形成的环。InnoDB 默认开启死锁检测,发现环后会主动回滚其中一个事务并报 1213,另一个正常提交。要和 1205 区分开:1213 是死锁,立刻重试通常成功;1205 只是锁等待超时,默认 50 秒,往往是长事务占锁造成,不一定是死锁。排查用 SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK 段,里面有两个事务的完整现场。预防上最有效的是统一加锁顺序,批量更新前按主键排序;其次是缩短事务,不在事务里做 RPC、发消息这类耗时操作。高并发场景如果死锁检测消耗 CPU 明显,可以关掉检测让超时兜底。
13
三大日志与两阶段提交
redo、undo、binlog 各管一件事
三个日志分工完全不同:redo log 是 InnoDB 的物理日志,保证崩溃后已提交的数据不丢;undo log 是逻辑日志,用于事务回滚和 MVCC 的版本链;binlog 是 Server 层的逻辑日志,用于主从复制和数据归档。redo 和 binlog 必须保持一致,靠"两阶段提交"保证。
实测默认值(MySQL 8.0.41)innodb_flush_log_at_trx_commit 1
sync_binlog 1
binlog_format ROW
- redo log 解决"写一半断电":InnoDB 修改数据时先改内存里的 Buffer Pool(脏页),再往 redo log 里写一条"在哪个页的哪个位置改了什么"。事务提交时只要 redo 落盘就算成功,脏页可以慢慢刷。这就把"随机写磁盘"变成了"顺序写日志",也就是常说的 WAL(Write-Ahead Logging)。
- redo log 是固定大小的环形文件:写满一圈要覆盖旧记录,所以必须先保证对应的脏页已经刷盘(checkpoint)。这就是为什么"redo 写满"会拖慢写入——它被迫等刷脏页。
innodb_log_file_size太小是写入抖动的常见原因。 - undo log 是"逻辑"的逆操作:它记录的不是"把页 5 的偏移 20 改回 100",而是"把 id=1 的 balance 改回 100"。回滚时重新执行这条反向 SQL。同一个 undo 既用于回滚,也用于 MVCC 读旧版本,这是它能被复用的关键设计。
- binlog 属于 Server 层,不属于 InnoDB:所以换存储引擎它还在,而且它记录的是"逻辑变更",主从复制就是靠备库重放 binlog。
binlog_format=ROW记录每行改成了什么,比 STATEMENT(记 SQL 原文)更安全,因为避免了NOW()、RAND()这类函数在主备上执行结果不同的问题。 - 两阶段提交的流程:写 redo 并标记
prepare→ 写 binlog → 把 redo 改成commit。崩溃发生在不同阶段,恢复策略不同:如果 redo 是 prepare 但 binlog 已完整,就提交;如果 binlog 不完整,就回滚。这样 redo 和 binlog 要么都成功要么都失败。 - 为什么这个顺序不能反:如果先提交 redo 再写 binlog,中间崩溃就会出现"主库有数据但 binlog 没有",备库永远同步不到,主备数据不一致。"双 1"配置(两个开关都设为 1)保证每次提交都刷盘,代价是每次事务至少两次 fsync,对写入吞吐影响明显。要求极致性能时可以放宽到
sync_binlog=0或innodb_flush_log_at_trx_commit=2,但要接受崩溃时可能丢最后一批事务。
面试怎么答redo log 是 InnoDB 的物理日志,记录页级别的修改,用于崩溃恢复,采用 WAL 方式把随机写变成顺序写,所以事务提交只要 redo 落盘就算成功。undo log 是逻辑日志,记录反向操作,既用于事务回滚,也用于 MVCC 的版本链。binlog 是 Server 层的逻辑日志,用于主从复制和归档,格式推荐 ROW。redo 和 binlog 靠两阶段提交保持一致:先写 redo 并置为 prepare,再写 binlog,最后把 redo 改成 commit;崩溃恢复时如果 binlog 完整就提交,不完整就回滚。双 1 配置指 innodb_flush_log_at_trx_commit=1 和 sync_binlog=1,保证每次提交都刷盘,是数据安全性和吞吐量的权衡点。
14
count 与 NULL:四种写法差在哪
一个 5 行的表就能测出差异
COUNT(*) 和 COUNT(1) 在 InnoDB 里优化后完全等价,都统计行数;COUNT(列) 会跳过该列为 NULL 的行;COUNT(DISTINCT 列) 先对非 NULL 值去重再计数。所谓"count(1) 比 count(*) 快"是 MyISAM 时代的老说法,InnoDB 里不成立。实测输出(5 行数据,其中 2 行 city 为 NULL)COUNT(*) = 5
COUNT(1) = 5
COUNT(city) = 3
COUNT(DISTINCT city) = 2
- COUNT(*) 不是"把每列都读一遍":优化器会把它改写成"统计行数",选最小的那个索引来扫,而不是扫全表。如果表上有二级索引,通常扫二级索引就够了,因为二级索引页比数据页小。所以 count(*) 是四种写法里最优的,不是最差的。
- COUNT(列) 必须判断 NULL:既然跳过 NULL,就一定要把那一列读出来判断,没法用"只统计行数"的优化。所以它比 COUNT(*) 慢,而且语义也不同。业务上如果只是想数行数,绝不要写成 COUNT(某列)。
- 为什么 InnoDB 不能像 MyISAM 那样记住总行数:因为 MVCC。同一个时刻,不同事务看到的行数是不同的(有的行别人还没提交,有的行已删除但对当前快照还可见)。没有一个全局唯一的"总行数",所以只能现算。这是 MVCC 的必然代价。
- COUNT(*) 在千万级表上就是慢,这时该做的不是换写法,而是换方案:用单独的计数表(增删时更新)、用 Redis 计数器、或者接受近似值(
SHOW TABLE STATUS里的 rows 是估算)。后台列表页的"共 N 条"往往是性能杀手,很多产品会改成"加载更多"来回避它。 - NULL 的其他坑:
WHERE col != 'x'不会匹配 col 为 NULL 的行;NOT IN的子查询里如果有 NULL,整个结果会变成空;SUM/AVG也跳过 NULL,导致AVG的分母变小。所以设计表时尽量 NOT NULL + 给默认值,能省掉一整类问题。
面试怎么答InnoDB 里 COUNT(*) 和 COUNT(1) 优化后等价,都统计行数,优化器会选最小的索引来扫,所以这两种是最快的。COUNT(列) 要跳过该列为 NULL 的行,必须把列值读出来判断,没法用只统计行数的优化,而且语义不同。实测一张 5 行、其中 2 行 city 为 NULL 的表,COUNT(*) 和 COUNT(1) 都是 5,COUNT(city) 是 3,COUNT(DISTINCT city) 是 2。InnoDB 不能像 MyISAM 那样直接返回总行数,是因为 MVCC 下不同事务看到的行数不同,没有全局唯一的总数。千万级表上 COUNT(*) 本身就慢,应该改用计数表或缓存,而不是换写法。
15
慢查询怎么定位:从日志到执行计划
别一上来就改 SQL
定位慢查询有固定顺序:先开慢日志找到是哪条 SQL → 用 explain 看执行计划 → 确认是索引问题还是数据量问题 → 再动手改。跳过前两步直接"我觉得加个索引会快"是最常见的错误,因为很多慢查询根本不是索引问题,而是数据量、锁等待或返回值太大。
实测默认值slow_query_log OFF
long_query_time 10.000000
slow_query_log_file E:\...\data\DESKTOP-xxx-slow.log
- 慢日志默认是关的:实测
slow_query_log=OFF、long_query_time=10。所以"我们库没有慢查询"经常只是"没开日志"。线上建议设成 1 秒,并配合log_queries_not_using_indexes一起开(注意后者在写多的库上会产生大量日志)。 - 日志有了要看聚合,不是一条条看:
mysqldumpslow是自带的简单工具,pt-query-digest(Percona Toolkit)能按"总耗时"排序,找出"单次不算慢但调用量巨大"的 SQL。这类 SQL 往往比偶发的 10 秒慢查询更值得优化,因为它吃掉的是整体 CPU。 - explain 之后要分清三种慢:一是扫描行数多(rows 大),那是索引问题;二是扫描行数不多但很慢,那是锁等待或 IO 问题,用
SHOW PROCESSLIST看状态是不是Waiting for table metadata lock或Sending data;三是返回数据量大,那是查询设计问题,该分页或只取需要的列。 - MySQL 8.0 的 performance_schema 比慢日志更细:
events_statements_summary_by_digest表把 SQL 按"指纹"聚合,直接给出总耗时、平均耗时、扫描行数、返回行数,不用等慢日志,实时就能查。定位"哪类 SQL 最耗 CPU"用它最快。 - 改完之后一定要验证:用
EXPLAIN FORMAT=JSON对比改之前的query_cost,再看rows有没有真的降下来。肉眼看"快了一点"非常不可靠,因为 Buffer Pool 命中率、并发情况都会影响观感。
面试怎么答定位慢查询有固定顺序:先确认慢日志开了没有,默认是关闭且阈值 10 秒,所以很多库说没有慢查询其实只是没记录。开了之后用 pt-query-digest 按总耗时聚合,重点找单次不慢但调用量极大的 SQL。拿到具体 SQL 后跑 explain,看 type、key、rows、Extra 判断是不是索引问题。要注意慢查询有三种不同原因:扫描行数多属于索引问题,扫描不多但慢往往是锁等待,返回数据量大是查询设计问题,三者解法完全不同。MySQL 8.0 还可以直接查 performance_schema 的 events_statements_summary_by_digest,按 SQL 指纹聚合,比慢日志更实时。优化后要用 query_cost 和 rows 验证效果,不能只凭感觉。
16
分库分表:什么时候该分,分的代价是什么
这是最后手段,不是第一选择
单表千万级、磁盘或连接数成为瓶颈,才考虑分库分表。在那之前有一整串成本更低的方案:加索引、覆盖索引、读写分离、归档历史数据、加缓存、改查询设计。分库分表一旦做了就几乎回不去,因为它把"一个事务能保证的事"打散到了多个库,分布式事务、跨库 JOIN、全局排序、分页、唯一 ID 全部要重新解决。
- 先算清楚瓶颈在哪:单表 500 万到 1000 万行、磁盘 IO 撑不住、连接数打满(默认 151),这些才是拆的信号。"表大了就要分"是误解——如果查询都能走索引、每次只取几十行,一个表放几千万行完全正常。
- 成本更低的六步先走完:一是索引优化(覆盖索引、去掉冗余索引);二是读写分离(主从,解决读压力);三是把大字段拆出去(TEXT/BLOB 单独一张表,让主表行变小、每页放更多行);四是归档历史数据(只保留最近一年在线);五是加缓存(热点数据不进数据库);六是改查询(去掉
SELECT *、去掉深翻页)。 - 分片键选错是最贵的错误:按 user_id 分片,那"按订单号查"就要扫所有分片;分片键必须覆盖绝大多数查询条件。常见的补救是建"映射表"记录业务 ID 到分片的路由,但那又引入一次查询。所以分片键要看业务的主查询维度,不是看哪个字段好分。
- 五个必须重新解决的问题:分布式事务(跨库怎么保证原子性)、跨库 JOIN(只能冗余或分两次查)、跨库分页排序(各分片取前 N 再归并,越深越慢)、全局唯一 ID(自增不行了,用雪花算法或号段模式)、扩容迁移(一致性哈希或双写迁移)。每一条都是独立的技术债。
- 中间件能省一部分活,但省不了设计:ShardingSphere、MyCat 这类能把分片逻辑藏在代理层,让应用看起来还在用一个库。但跨库事务和跨库 JOIN 该有的限制一样有,只是换了个地方暴露。而且多一层代理就多一层故障点和延迟。
- 能不分就不分,非要分就先分表后分库:单库内分表成本最低(还能用本地事务),只有单库确实撑不住了才分库。分表后如果发现还是不够,再从"分表"升级到"分库",这个过渡比一步到位安全得多。
面试怎么答分库分表是最后手段,触发条件应该是单表数据量到千万级、磁盘 IO 或连接数成为实际瓶颈,而不是"表大了"这种模糊判断。在它之前有一段成本低得多的路:索引优化、读写分离、大字段拆表、历史数据归档、加缓存、改查询设计。分片键的选择最关键,必须覆盖大部分查询条件,否则按订单号查就要扫所有分片。分完之后有五个问题必须重新解决:跨库事务、跨库 JOIN、跨库分页排序、全局唯一 ID、扩容迁移。实践中建议先分表后分库,因为单库内分表还能用本地事务,成本最低。
17
MyBatis 的四个坑
用框架不等于不用想 SQL
MyBatis 层面最容易出问题的是四件事:
${} 会拼接字符串导致注入和索引失效、N+1 查询、二级缓存在多表关联下返回脏数据、批量操作没走 batch。这四个都是"框架帮你做了决定,但你没意识到"的类型。- ${} 直接拼进 SQL,两个后果:一是SQL 注入,参数里带引号就能改语义;二是没法预编译,每次参数不同都是新 SQL,数据库要重新解析,而且如果拼在 WHERE 里还会让索引失效(因为不是绑定变量)。只有表名、列名、排序方向这类"结构"才不得不用 ${},这时必须走白名单校验。
- N+1 是 ORM 最经典的问题:查 100 个用户,然后每个用户查一次部门,就是 1 + 100 = 101 次往返。每次往返都有网络延迟,101 次 × 1ms 就是 100ms,而一次 JOIN 可能只要 3ms。MyBatis 的
<collection>嵌套查询默认就是 N+1,要显式配fetchType="lazy"或者改成 JOIN +resultMap组装。 - 二级缓存默认是关的,开了要小心:它的作用域是 Mapper 命名空间,多个 SqlSession 共享。但同一张表被多个 Mapper 操作时,一个 Mapper 的更新不会清掉另一个 Mapper 的缓存,结果就是读到旧数据。所以只要存在多表关联写入,就不建议开二级缓存,用 Redis 做业务缓存更可控。
- 一级缓存也要注意:它是 SqlSession 级别的,默认开。同一个 SqlSession 里两次相同查询只发一次 SQL。在 Spring 环境下,如果不配事务,每次 Mapper 调用都是新 SqlSession,一级缓存等于没用;配了事务才会共享,这时候又可能读到会话内的旧值。所以"为什么我改了数据还是读到老值",一半的答案是这一级缓存。
- 批量插入别用循环单条 insert:1000 条循环插入是 1000 次往返。用
<foreach>拼成一条多值 INSERT,或者用ExecutorType.BATCH。注意一条 SQL 拼太长会超过max_allowed_packet,一般 500 到 1000 条一批。
面试怎么答MyBatis 有四个常见坑。第一是 ${} 做字符串拼接,既有 SQL 注入风险又无法预编译,拼在 WHERE 里还会导致索引失效,只有表名列名这类结构才能用,而且必须白名单校验,参数值一律用 #{}。第二是 N+1,嵌套 collection 查询默认每行发一次 SQL,应该改成 JOIN 加 resultMap 组装,或者批量 IN。第三是二级缓存作用域是 Mapper 命名空间,多表关联写入时一个 Mapper 的更新清不掉另一个的缓存,会读到脏数据,所以不建议开。第四是批量操作要用 foreach 拼多值 INSERT 或 ExecutorType.BATCH,不要循环单条插入。
18
速答清单
十九条,考前扫一遍
01MySQL 8.0 还有查询缓存吗?
没有,8.0 已彻底移除。失效粒度是整张表,写多的场景命中率极低,维护成本高于收益。
02一条 SQL 在 MySQL 里经过哪些阶段?
连接器、解析器、优化器、执行器、存储引擎。能优化的只有后两段,优化器决定用哪个索引。
03为什么用 B+ 树不用红黑树?
B+ 树单节点分支多,树更矮,磁盘 IO 次数等于树高。红黑树二叉,同样数据高度高好几倍。
04三层 B+ 树能放多少行?
按主键 4 字节、每页放 1170 个指针、叶子每页约 100 行算,约 1.3 亿行;实际受单行大小影响。
05主键为什么建议短且递增?
短能让非叶子页放更多指针、降低树高;递增让新行总在最右追加,避免随机插入导致的页分裂。
06什么是回表?
二级索引叶子只存索引列和主键,查非索引列时要拿主键再查一次聚簇索引,这次随机 IO 就是回表。
07怎么避免回表?
用覆盖索引,即 SELECT 的列全在索引里,EXPLAIN 的 Extra 会显示 Using index。
08key_len 怎么算?
VARCHAR(50) 的 utf8mb4 非空列 = 50×4+2 = 202 字节,INT 占 4 字节。用它反推联合索引用到了哪几列。
09联合索引为什么跳列就失效?
B+ 树只保证在 a 相同的前提下 b 有序,a 不确定时 b 在整棵树上是乱序的,无法二分定位。
10哪些情况索引会失效?
列上套函数或运算、隐式类型转换、LIKE 以通配符开头、选择性太低、不等于覆盖大部分行、OR 里有一边没索引。
11字符串列传数字为什么会全表扫?
MySQL 会把字符串列逐行转成数字比较,等效于每行套了函数,索引顺序失效。字符串列必须传字符串。
12type 字段的优先级?
system、const、eq_ref、ref、range、index、ALL。看到 ALL 要处理,看到 index 也只是改扫索引。
13rows 是真实行数吗?
不是,是优化器基于统计信息的估算值,只能看数量级。比较优化前后用 EXPLAIN FORMAT=JSON 的 query_cost。
14索引下推是什么?
把 WHERE 过滤下推到存储引擎,在遍历索引时就用索引列判断,不满足的不回表。Extra 显示 Using index condition。
15深翻页为什么慢?怎么优化?
LIMIT offset 是先取 offset+size 行再丢弃,偏移越深扫得越多,还要逐行回表。用延迟关联只取主键,或改游标分页。
16MVCC 靠什么实现?
行上的 DB_TRX_ID 和 DB_ROLL_PTR 隐藏字段,undo log 里保存旧版本串成版本链,ReadView 决定哪个版本可见。
17快照读和当前读有什么区别?
普通 SELECT 是快照读,按 ReadView 读历史版本;FOR UPDATE、UPDATE、DELETE 是当前读,读最新提交版本并加锁。
18RR 怎么解决幻读?
快照读靠 MVCC 一致性读,整个事务共用一个 ReadView;当前读靠间隙锁阻止其他事务插入新行。两者缺一不可。
19间隙锁什么时候存在?
只在 RR 及以上级别存在,RC 没有。范围查询加临键锁(记录锁加左边间隙),唯一索引等值查询只加记录锁。
201213 和 1205 有什么区别?
1213 是检测到死锁,事务被主动回滚,立刻重试通常成功;1205 是锁等待超时,默认 50 秒,往往是长事务占锁。
21死锁怎么预防?
统一加锁顺序(批量更新前按主键排序)、缩短事务(不在事务里做 RPC 或发消息)、用唯一索引减少锁范围。
22redo、undo、binlog 各管什么?
redo 是 InnoDB 物理日志管崩溃恢复,undo 是逻辑日志管回滚和 MVCC,binlog 是 Server 层逻辑日志管主从复制和归档。
23什么是两阶段提交?
写 redo 置 prepare,写 binlog,再把 redo 改 commit。崩溃恢复时 binlog 完整就提交,不完整就回滚,保证两日志一致。
24COUNT(*) 和 COUNT(1) 有区别吗?
InnoDB 里优化后等价,都统计行数并选最小索引扫。COUNT(列) 会跳过 NULL,语义不同且更慢。
25为什么 InnoDB 不能直接返回总行数?
MVCC 下不同事务看到的行数不同,没有全局唯一的总数,只能现算。MyISAM 没这问题所以能缓存行数。
26分库分表前该先做什么?
索引优化、读写分离、大字段拆表、历史数据归档、加缓存、改查询设计。分片键必须覆盖主要查询条件。
27#{ } 和 ${ } 的区别?
#{} 是参数绑定走 PreparedStatement,安全且能预编译;${} 是字符串拼接,有注入风险且可能导致索引失效。