基础索引和 EXPLAIN 只是入门。在千万级数据的中后台系统中,深度分页、JOIN 连表和 COUNT 统计这三个场景才是真正的性能杀手。理解回表与 LIMIT 的底层交互、NLJ/BNL 的算法差异,以及 COUNT 的引擎优化,是高级后端开发的必备功底。
目录
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 → 10SELECT t1.* FROM user t1INNER 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 oJOIN users u ON o.user_id = u.idWHERE 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:三个维度作答:
- Redis 缓存 ID 列表:将主键 ID 存入 ZSET,分页走 Redis 的
ZRANGE,再用WHERE id IN (...)回源 - ES 承担检索:通过 Binlog 同步数据到 ES,让搜索引擎处理复杂筛选 + 分页
- 业务妥协:推动产品限制最大翻页数,或强制用户使用更精确的搜索条件——业界通用做法
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 index | EXPLAIN <SQL> |
| JOIN 是否走索引 | EXPLAIN 的 type 和 key | EXPLAIN <SQL> |
| JOIN 驱动表是否合理 | EXPLAIN 的 rows 估算 + filtered | EXPLAIN <SQL> |
| 是否可以用覆盖索引优化 | 检查 SELECT 列是否都在索引中 | 对比 EXPLAIN 与实际索引定义 |
6.4 一句口诀
分页深时先取 ID,小表驱动记心里;COUNT 无脑用星号,架构业务双兜底。
相关链接
- MySQL 索引底层与优化 — 回表、覆盖索引、最左前缀
- MySQL 事务、MVCC 与锁
- MySQL Using filesort 调优
- MySQL Buffer Pool
本文基于 MySQL 8.0 / InnoDB 引擎编写,深度分页问题在所有版本中均存在,延迟关联与游标分页为通用解决方案。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 141 天
部分内容可能已过时