3758 字
19 分钟
MySQL 复杂 SQL 调优

基础索引和 EXPLAIN 只是入门。在千万级数据的中后台系统中,深度分页、JOIN 连表和 COUNT 统计这三个场景才是真正的性能杀手。理解回表与 LIMIT 的底层交互、NLJ/BNL 的算法差异,以及 COUNT 的引擎优化,是高级后端开发的必备功底。


目录#

  1. 深度分页灾难:LIMIT offset, count
  2. JOIN 的底层博弈:小表驱动大表
  3. COUNT 悬案:到底用哪个
  4. 实战进阶:任意跳页的深度分页
  5. 常见面试题
  6. 总结

1. 深度分页灾难:LIMIT offset, count#

这是中后台分页列表最容易出现的 P0 级线上故障。

1.1 底层原理:为什么慢#

当你执行 SELECT * FROM user WHERE age = 20 LIMIT 100000, 10; 时,MySQL 的底层逻辑并不是直接跳到第 10 万行。它必须:

执行过程(以 idx_age 二级索引为例):
① 遍历 idx_age 的 B+ 树 → 找出所有 age=20 的主键 ID(共 100010 个)
│
② 拿着 100010 个主键 ID → 逐行回表到聚簇索引 → 查出 100010 行完整数据
│ ↑
│ ← 100010 次随机磁盘 I/O!← 性能灾难
│
③ 丢弃前 100000 行 → 只返回最后 10 行给客户端
核心问题:前 10 万行的回表全是无用功,纯属 I/O 浪费。
┌─────────────────────────────────────────────────────────────┐
│ 深度分页 = 大量无用回表 │
│ │
│ idx_age 二级索引 聚簇索引(主键) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ age=20, id=1 │──回表──→ │ id=1, 完整行数据 │ ← 丢弃 │
│ │ age=20, id=7 │──回表──→ │ id=7, 完整行数据 │ ← 丢弃 │
│ │ age=20, id=12 │──回表──→ │ id=12, 完整行数据 │ ← 丢弃 │
│ │ ... ×100000 次 │ ... │ ... │ ← 丢弃 │
│ │ age=20, id=99901 │──回表──→ │ id=99901, 完整行 │ ✅ 返回 │
│ │ age=20, id=99902 │──回表──→ │ id=99902, 完整行 │ ✅ 返回 │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ 100010 次回表 → 100000 次白做 → 只有最后 10 次有意义 │
└─────────────────────────────────────────────────────────────┘

1.2 优化方案 1:延迟关联(子查询法)#

核心思路:利用覆盖索引先查出目标页的 10 个 ID,再用这 10 个 ID 去跟原表做 JOIN 查完整数据。

-- ❌ 原始写法:10 万次无用回表
SELECT * FROM user WHERE age = 20 LIMIT 100000, 10;
-- ✅ 延迟关联:回表次数从 100000 → 10
SELECT t1.* FROM user t1
INNER JOIN (
SELECT id FROM user WHERE age = 20 LIMIT 100000, 10
) t2 ON t1.id = t2.id;

执行过程对比:

延迟关联的执行路径:
① 子查询:遍历 idx_age → 找到 100010 个主键 id
→ 但只取了最后 10 个 id(不需要回表,id 就在索引叶子节点里!)
→ Extra: Using index(覆盖索引)✨
② 外层查询:拿着 10 个 id → 回表 10 次 → 查出完整行
回表次数:100010 → 10,性能提升上万倍

1.3 优化方案 2:游标分页#

如果是类似 APP 的”下拉加载更多”功能(不支持跳页),可以在业务层记住上一页最后一条记录的 ID。

-- 第一页
SELECT * FROM user WHERE age = 20 ORDER BY id LIMIT 10;
-- 假设最后一行的 id = 100
-- 第二页(用上次的 id 作为游标)
SELECT * FROM user WHERE age = 20 AND id > 100 ORDER BY id LIMIT 10;

优点:直接把查询起点锁定,每次只扫 10 行,时间复杂度 O(1)。

局限:不支持任意跳页(如直接点击第 5000 页),因为无法获取前一页的最大 ID。


2. JOIN 的底层博弈:小表驱动大表#

面试官会问:在使用 JOIN 连表查询时,底层是怎么工作的?为什么要”小表驱动大表”?

2.1 NLJ(嵌套循环连接)#

触发条件:被驱动表的关联字段有索引。

NLJ 执行过程:
驱动表 A(外层循环) 被驱动表 B(内层循环)
┌──────────────────┐ ┌──────────────────────┐
│ 遍历 A 的每一行 │ │ 利用 B 的索引树 │
│ row₁ ────────────┼──────→ │ 精确查找匹配行 │
│ row₂ ────────────┼──────→ │ 精确查找匹配行 │
│ row₃ ────────────┼──────→ │ 精确查找匹配行 │
│ ... │ │ ... │
└──────────────────┘ └──────────────────────┘
复杂度:O(A_rows × log B_rows) —— 因为内层走索引,每次是 B+ 树查找

关键:驱动表越小,外层循环次数越少。被驱动表关联字段的索引决定了内层是 O(log N) 还是 O(N)。

2.2 BNL(块嵌套循环连接)#

触发条件:被驱动表的关联字段没有索引。

BNL 执行过程:
① 把驱动表 A 的数据分批加载进 join_buffer(内存)
② 全表扫描被驱动表 B,在内存中与 join_buffer 批量比对
③ 重复直到 A 的所有数据都比对完
复杂度:O(A_rows × B_rows) —— 但比对发生在内存中,比磁盘随机 I/O 快
NLJ vs BNL 对比:
NLJ(有索引):
驱动表 ──→ 每次拿一行 → 去被驱动表的索引树精确定位
→ 适合 OLTP 的点查场景
BNL(无索引):
驱动表 ──→ 整批加载进 join_buffer → 全表扫描被驱动表 → 内存比对
→ 全表扫描不可避免,但内存比对比逐行回表快

2.3 什么是真正的”小表”#

面试经典陷阱:“A 表 100 行,B 表 10000 行,谁是小表?”

答案:不是看表的总行数,而是看经过 WHERE 条件过滤后的结果集谁的数据量更小。

SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid' AND u.vip_level = 5;
判断驱动表的选择:
orders 表 (100 万行)
→ WHERE status='paid' → 过滤后只剩 500 行 ✅ 小表!
users 表 (10 万行)
→ WHERE vip_level=5 → 过滤后 8000 行
→ 优化器应该选择 orders 作为驱动表(500 行 < 8000 行)

核心原则:用小结果集做驱动表,可以显著减少外层循环的次数,或减小加载到 join_buffer 中的内存开销。


3. COUNT 悬案:到底用哪个#

关于 COUNT(*)、COUNT(1)、COUNT(字段) 谁最快的争议,是 MySQL 界的日经题。

语法底层处理逻辑性能排名
COUNT(*)MySQL 专门做了优化:自动找到最小的二级索引树遍历统计,不提取任何字段值最快(并列)
COUNT(1)遍历二级索引,每遍历一行在 Server 层放一个数字 “1”,然后累加。与 COUNT(*) 性能基本无差异最快(并列)
COUNT(主键 id)遍历索引,提取 ID 值给 Server 层,判断不为 NULL 后累加。多了解析 ID 的步骤第二
COUNT(字段)若有索引走索引,提取字段值,必须严格判断是否为 NULL(NULL 不计入);若无索引则全表扫描最慢
COUNT(*) 的底层优化:
InnoDB 表有多个二级索引时:
→ MySQL 优化器自动选择"树最小"的那个二级索引
→ 树小 = 页少 = 磁盘 I/O 少
→ 不会傻傻地去扫聚簇索引(整行数据太大)或大的二级索引

结论:日常开发,无脑用 COUNT(*),不要自作聪明去写 COUNT(1) 或 COUNT(id)。


4. 实战进阶:任意跳页的深度分页#

场景:电商订单列表,产品要求用户可以任意点击页码跳转(如直接点击第 5000 页),游标分页法失效。

当面对”允许任意跳页的深度分页”这种需求时,MySQL 本身已经没有完美的纯 SQL 解法。需要跳出数据库单体,从架构、组件、业务三个维度寻找破局思路。

4.1 方案 1:Redis 缓存 ID 列表#

适用于查询条件相对固定、更新频率可控的列表(如热门商品列表)。

架构图:
用户请求第 5000 页
│
↓
┌─────────────────┐
│ 业务服务 │
│ 计算 offset │
│ = 5000 × 10 │
└────────┬────────┘
│ ZRANGE key 50000 50010
↓
┌─────────────────┐
│ Redis ZSET │ ← 主键 ID 按创建时间 Score 排序
│ [id1, id2, ...]│
└────────┬────────┘
│ 返回 10 个主键 ID
↓
┌─────────────────┐
│ MySQL │
│ SELECT * FROM │ ← 只用 IN 查询,不走 LIMIT
│ table WHERE │
│ id IN (...) │
└─────────────────┘
  • 实现细节:将符合条件的主键 ID 存入 Redis 的 ZSET(有序集合,按创建时间作为 Score 排序)
  • 如何分页:业务层通过 ZRANGE 命令极速拿到目标页的 10 个主键 ID
  • 回源查询:用这 10 个 ID 去 MySQL 执行 SELECT * FROM table WHERE id IN (...),彻底绕开 LIMIT 扫表机制

4.2 方案 2:Elasticsearch 承担搜索与分页#

在真实的电商系统中,订单列表往往伴随着极其复杂的动态筛选条件(组合筛选、模糊搜索),此时连 Redis 也扛不住了。

架构图:
┌──────────┐ Binlog 准实时同步 ┌─────────────────┐
│ MySQL │ ──────────────────────→ │ Elasticsearch │
│ (写入) │ (Canal 等组件) │ (检索 + 分页) │
└──────────┘ └────────┬────────┘
│
用户搜索/分页请求
│
↓
返回目标页结果
  • 实现细节:通过监听 MySQL 的 Binlog(如使用 Canal 组件),将数据准实时同步到 Elasticsearch 中
  • 核心优势:ES 底层基于倒排索引,对于多条件组合查询和分页的支持远强于 MySQL;将计算压力从 MySQL 剥离到专门的搜索引擎

4.3 方案 3:业务侧妥协——砍掉伪需求#

很多时候,最高级的技术优化就是”干掉需求”。

维度分析
用户行为一个正常用户,真的会一页一页翻到第 5000 页吗?
用户意图如果他真的要找很久以前的数据,为什么不直接用”搜索条件(时间、关键字)”?
业界标杆百度/谷歌搜索哪怕搜出几千万条结果,底部最多只允许翻几十页
ES 默认限制Elasticsearch 默认直接限制最多只能查前 10000 条数据

核心策略:推动产品经理限制最大查询页数,或者强制用户输入更精确的搜索条件,才是彻底解决深度分页的终极之道。


5. 常见面试题#

Q1:LIMIT 100000, 10 为什么慢?如何优化?#

A1:

原因:MySQL 需要先扫描 100000 + 10 行(包括回表获取完整数据),然后丢弃前 100000 行,只返回最后 10 行。前 10 万次回表全是无用 I/O。

优化方案:

方案做法适用场景
延迟关联子查询用覆盖索引先取 10 个 ID,再 JOIN 回原表取完整数据支持任意跳页
游标分页WHERE id > last_max_id LIMIT 10只支持”上一页/下一页”
架构侧Redis ZSET 缓存 ID 列表、ES 承担分页高并发、复杂筛选
业务侧限制最大查询页数、强制搜索条件根治方案

Q2:什么是”小表驱动大表”?#

A2:

  • NLJ 场景:驱动表越小 → 外层循环次数越少 → 内层索引查找次数越少
  • BNL 场景:驱动表越小 → join_buffer 占用内存越少 → 可能减少分批次数
  • “小表”不是总行数少的表,而是经过 WHERE 过滤后结果集行数更少的那个

Q3:COUNT(*) 和 COUNT(1) 哪个快?为什么?#

A3:一样快。MySQL 对 COUNT(*) 做了专门优化——自动选择最小的二级索引树遍历统计,不提取任何字段值。COUNT(1) 也是遍历二级索引,每行放个 “1” 然后累加,Server 层的开销微乎其微,两者在实际场景中性能无差别。

Q4:COUNT(字段) 和 COUNT(*) 的区别?#

A4:

  • COUNT(*) 统计所有行(包括 NULL 列的行),MySQL 专门优化,选最小索引扫描
  • COUNT(字段) 统计该字段不为 NULL 的行数,需要提取字段值并判断 NULL

日常开发无脑用 COUNT(*)。

Q5:面对”任意跳页的深度分页”,架构上如何解决?#

A5:三个维度作答:

  1. Redis 缓存 ID 列表:将主键 ID 存入 ZSET,分页走 Redis 的 ZRANGE,再用 WHERE id IN (...) 回源
  2. ES 承担检索:通过 Binlog 同步数据到 ES,让搜索引擎处理复杂筛选 + 分页
  3. 业务妥协:推动产品限制最大翻页数,或强制用户使用更精确的搜索条件——业界通用做法

6. 总结#

6.1 三大场景全景#

┌──────────────────────────────────────────────────────────────┐
│ 复杂 SQL 调优全景 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ① 深度分页 │
│ ┌─────────────────────────────────────────────┐ │
│ │ LIMIT offset, count │ │
│ │ → 前 offset 行的回表 = 纯浪费 │ │
│ │ → 延迟关联:子查询覆盖索引取 ID → JOIN 回表 │ │
│ │ → 游标分页:AND id > last_max_id │ │
│ │ → 架构兜底:Redis ZSET / ES │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ② JOIN 连表 │
│ ┌─────────────────────────────────────────────┐ │
│ │ 小表驱动大表 │ │
│ │ → NLJ:被驱动表关联字段有索引 → 内层走 B+ 树 │ │
│ │ → BNL:无索引 → join_buffer 全表比对 │ │
│ │ → "小表" = 过滤后的结果集,非原始表行数 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ③ COUNT 统计 │
│ ┌─────────────────────────────────────────────┐ │
│ │ COUNT(*) = COUNT(1) >> COUNT(id) > COUNT(字段)│ │
│ │ → MySQL 对 COUNT(*) 有专门优化 │ │
│ │ → 自动选最小二级索引扫描 │ │
│ │ → COUNT(字段) 排除 NULL,且需提取字段值 │ │
│ └─────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘

6.2 关键设计原则#

原则说明
深度分页 = 回表浪费offset 越大,无用回表越多;核心思路是”先拿 ID,再回表”
覆盖索引取 ID延迟关联的核心——子查询走覆盖索引,绕过回表直接拿到目标主键
游标分页跳页受限游标分页是 O(1),但不支持任意跳页——这是本质取舍
小表 = 过滤后结果集不是原始表的行数,优化器基于过滤后的估算行数选择驱动表
COUNT(*) 永远正确MySQL 专门优化 + 自动选最小索引,日常无脑用
架构 > 纯 SQL任意跳页的深度分页,纯 MySQL 无完美解法 → Redis / ES / 业务妥协

6.3 快速排查清单#

排查点看什么工具/命令
分页 SQL 是否深度分页LIMIT 的 offset 值慢查询日志
是否发生了大量回表EXPLAIN 的 Extra 是否有 Using indexEXPLAIN <SQL>
JOIN 是否走索引EXPLAIN 的 type 和 keyEXPLAIN <SQL>
JOIN 驱动表是否合理EXPLAIN 的 rows 估算 + filteredEXPLAIN <SQL>
是否可以用覆盖索引优化检查 SELECT 列是否都在索引中对比 EXPLAIN 与实际索引定义

6.4 一句口诀#

分页深时先取 ID,小表驱动记心里;COUNT 无脑用星号,架构业务双兜底。


相关链接#


本文基于 MySQL 8.0 / InnoDB 引擎编写,深度分页问题在所有版本中均存在,延迟关联与游标分页为通用解决方案。

MySQL 复杂 SQL 调优
https://shirone.mysqil.com/posts/mysql/mysql-复杂-sql-调优/
作者
HikariTish
发布于
2026-05-27
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

最后更新于 ,距今已过 141 天

部分内容可能已过时