Interview Lab / MySQL 索引三部曲 · 2/3

MySQL 索引设计:从一条 SQL 推导联合索引

SQL(Structured Query Language,结构化查询语言)是向数据库描述查询的语言;联合索引是把多列按声明顺序拼成一个有序键的索引。索引设计负责把真实 SQL 的过滤、排序和返回列,映射成一条成本可控的访问路径。

本文固定使用一张订单表和一组查询逐步推导,不从孤立口诀开场。读完应能回答四个问题:扫描段在哪里、是否还要读取完整行、是否额外排序、读收益能否抵过写成本。

索引列序查询范围读写权衡MySQL 8.0 / 8.4
前置回顾:B+Tree(B Plus Tree,B 加树)是一种把有序数据分层存放的多叉树,它的叶子按索引键有序。设计索引,本质是安排这种顺序,使目标数据尽量成为短而连续的一段。不了解内部页与叶子页时,可先读结构篇。
版本与边界:本文面向 MySQL 8.0 / 8.4 的 InnoDB;InnoDB 是 MySQL 默认的事务型存储引擎,负责保存行与索引。示例讨论普通 B+Tree 索引,不覆盖 FULLTEXT(全文索引)等专用结构。优化器是负责比较候选路径并选择方案的组件;执行计划则是它最终选定的查询步骤,两者需要在目标实例中验证。
一、先固定查询:不要脱离业务设计索引

商户 7 的订单列表只展示已支付订单,按创建时间和主键倒序,每页 20 条:

SELECT id, created_at
FROM orders
WHERE tenant_id = 7 AND status = 'PAID'
ORDER BY created_at DESC, id DESC
LIMIT 20;

tenant_id 是商户编号,status 是订单状态,created_at 是创建时间,id 是主键。设计前先写清:

过滤

先只保留 tenant=7、status=PAID 的记录。

排序

在这个分组里按 created_at、id 倒序。

返回

只需要 id、created_at,有机会不读取完整行。

频率与代价

高频列表查询值得优化,但每个新索引都会增加写入维护和空间。

先写出“过滤 + 排序 + 返回 + 频率”,再讨论索引列序。
二、联合索引列序:让候选一步步缩成连续区间

Composite Index(联合索引,也叫多列索引)把多个列按声明顺序拼成有序键。(tenant_id,status,created_at) 先比较商户,商户相同再比较状态,前两项相同才比较时间;这种逐列比较叫字典序。

联合索引由十条候选逐步收缩到三条连续记录完整展示下标零到九的商户、状态、时间和主键四行数据;查询先把十条候选按商户七缩到下标二至七,再按已支付缩到三至六,最后按九点之后缩到四至六。键顺序:(tenant_id, status, created_at),共 10 条下标从 0 开始;查询:tenant=7、status=PAID、time≥09:00。下标tenantstatustimeid01234567896677777788NEWPAIDNEWPAIDPAIDPAIDPAIDREFUNDNEWPAID09:3010:1010:0008:1009:2010:0511:4009:0008:3012:009199120101205309412490501520候选收缩过程① 全集下标 0–9,共 10 条② tenant=7 → 下标 2–7,共 6 条连续记录③ 再固定 status=PAID → 下标 3–6,共 4 条连续记录④ 再取 time≥09:00 → 下标 4–6,共 3 条:id 205、309、412若只给 status=PAID:命中下标 1、3–6、9,散落在多个 tenant 分组,不能由已知左前缀直接锁成一段。
最应记住:最左前缀不是 SQL 书写顺序口诀,而是字典序怎样把候选压成连续区间;常见误读是“WHERE 里的条件必须按索引顺序书写”。
本查询的基础索引:(tenant_id,status,created_at)。前两列用等值条件固定分组,第三列在组内承担范围或顺序读取。不是因为“等值永远放前面”,而是它恰好对应这组高频查询的访问路径。
三、条件边界:哪些写法能直接确定扫描段

Sargable(Search Argument Able,可用搜索参数定位)描述一种条件:数据库能把它转换成索引起点、终点或若干明确区间,而不是逐行计算后才知道是否满足。

条件定位能力原因或改写
id=205一个位置形成精确键值
created_at>=a AND created_at<b连续范围联合索引前置列需已被合适约束,或时间是首列
status IN ('PAID','REFUND')多个已知区间最终是否采用由成本决定
name LIKE 'ann%'通常是前缀范围左侧前缀已知,排序规则也会影响边界
name LIKE '%ann'普通 B+Tree 难定位开头未知,候选散落
DATE(created_at)='2026-09-01'普通列索引通常难直接定位改为原列的左闭右开范围
-- 保留 created_at 自己的有序性,并覆盖全天
WHERE created_at >= '2026-09-01 00:00:00'
  AND created_at <  '2026-09-02 00:00:00'
“范围后面的列全失效”并不准确:后续列往往不能继续缩短这一段的左右边界,但仍可能在索引记录层过滤候选,或成为查询所需的返回列。要区分“不能继续定位”和“完全不参与执行”。
四、回表、覆盖与索引条件下推:三种路径不要混说

Clustered Index(聚簇索引)是叶子直接包含完整行的索引;Secondary Index(二级索引)是叶子保存索引列和主键的其他索引。回表就是二级索引命中后,再按主键去聚簇索引取得完整行。Covering Index(覆盖索引)是某个查询的筛选列与返回列都能从同一索引得到,因此回表次数为 0;它不是一种新的树类型。

Index Condition Pushdown(ICP,索引条件下推)是存储引擎读取二级索引记录时,先检查索引中已有的附加条件,只让通过者回表。它减少回表,但没有保证回表为 0。

路径发生什么常见计划提示
普通二级索引 + 回表命中 N 条,可能按 N 个主键补取完整行结合返回列与执行节点判断
覆盖索引所需列都在索引,直接返回Using index
索引条件下推索引层先过滤,再对剩余项回表Using index condition
普通回表、索引条件下推和覆盖索引的三条互斥路径同一批四个候选主键和金额先完整画出,再分成普通回表、索引条件下推和覆盖查询三条路径;普通路径回表四次后筛出两条,下推路径先筛金额后回表两次,覆盖查询不回表直接返回四条摘要。同一批候选:四个主键,金额条件 amount>50下标从 0 开始;金额依次为 18、88、39、120,能从图中数出命中下标 1、3。二级索引候选集合(共 4 条)下标 0:id=101 / amount=18  下标 1:id=205 / amount=88  下标 2:id=309 / amount=39  下标 3:id=412 / amount=120查询所需列和可判断条件在哪里?amount 不在索引amount 在索引,返回列不全所需列全在索引A. 普通回表(本例 4 次)① 按 101、205、309、412 逐一取得四条完整行② 再判断 amount>50③ 返回 id=205、412,共 2 条代价:4 次逻辑回表B. ICP(本例 2 次)① 索引层检查四个金额② 排除下标 0、2③ 只按 id=205、412 回表④ 返回完整行,共 2 条代价:扫 4 条摘要 + 2 次回表C. 覆盖查询(0 次回表)查询只要 id、created_at所需值均在二级索引直接返回四条摘要不进入聚簇索引代价:只读 4 条索引记录三条出口可验证:普通路径回表 4 次;ICP 先过滤后回表 2 次;覆盖路径回表 0 次。摘要命中不等于完整行命中;ICP 减少回表,覆盖索引才消除回表。
最应记住:覆盖索引与索引条件下推都能少读完整行,但机制不同;常见误读是看到 Using index condition 就认为已经完全不回表。
不要为了覆盖无限加列:索引越宽,单页记录越少,占用的内存和磁盘越多,插入与更新也越贵。只覆盖稳定、高频、回表量大的查询。
五、排序与分页:让同一段索引顺序继续服务输出

ORDER BY 要求结果有序。若索引扫描顺序与结果顺序一致,MySQL 可能边读边返回;否则通常需要 filesort。filesort 是 MySQL 对额外排序阶段的名称,不表示一定写磁盘。

InnoDB 会把主键追加到二级索引记录中。因此 (tenant_id,status,created_at) 对本查询可利用附带的 id,并可能反向扫描来满足 created_at DESC,id DESC。不要先凭感觉创建显式降序索引,先由执行计划确认。

深分页为什么慢

-- 返回 20 条,但要先经过并丢弃前 100000 条
LIMIT 100000, 20;

Keyset Pagination(游标分页)记住上一页最后一条的排序键,从边界继续读取:

WHERE tenant_id=7 AND status='PAID'
  AND (created_at,id) < ('2026-09-01 10:05:00',309)
ORDER BY created_at DESC, id DESC LIMIT 20;
偏移分页与游标分页取得同一批二十条结果的代价对比完整列出从零到十万零十九的候选下标;偏移分页从零开始扫描并丢弃十万条,再返回二十条,游标分页由上一页末尾键定位到十万下标,只继续读取二十条。目标:取得第 100001~100020 条,共返回 20 条候选下标从 0 开始:0、1、2、…、99,999、100,000、…、100,019总数至少 100,020;口述“第 100001 条”对应下标 100,000。A. LIMIT 100000,20① 起点:候选下标 0② 扫描并丢弃 0~99,999 共丢弃 100,000 条③ 读取 100,000~100,019合计经过 100,020 条B. 游标条件 + LIMIT 20① 用上一页末尾 (created_at,id) 定位到下标 100,000 的边界② 从边界下一条继续③ 连续读取 20 条后停止合计:一次定位 + 20 条扫描返回20 条
最应记住:两种写法都返回 20 条,但偏移分页仍需经过并丢弃前 100,000 条;常见误读是“LIMIT 20 就只读 20 条”。
稳定排序键:时间可能重复,因此再加唯一 id 决定先后。游标分页适合上一页、下一页和无限滚动,不适合任意跳到第 5001 页。
六、设计闭环:读得更少,也要付得起写成本
  1. 收集高频查询族、真实参数与延迟目标,不为一条偶发 SQL 定制索引。
  2. 用等值、范围和排序条件推导候选区间,明确索引的每一列负责什么。
  3. 估算扫描记录、回表次数、额外排序与返回数据量。
  4. 检查新索引宽度、写入频率、磁盘和缓冲池占用。
  5. 用执行计划比较改动前后;确认新联合索引是否覆盖旧索引用途后,才评估移除重复索引。
索引存在不等于一定被使用:条件命中比例很高时,“二级索引定位 + 大量回表”可能比全表扫描更贵。最终选择属于执行计划篇的验收范围。
七、面试追问:怎样从查询推导,而不是背口诀
联合索引为什么有最左前缀?
因为键按声明列做字典序排列。只有先固定左边分组,右边列才容易形成可直接定位的连续区间。SQL 中等值条件的书写顺序通常不重要,索引物理顺序才重要。
等值列、范围列、排序列应该怎样排?
没有脱离查询族的万能顺序。要看哪些列能稳定固定业务分区、每一步缩小多少候选、排序能否复用,以及写成本。常见路径是先固定租户和状态,再在组内按时间范围或顺序读取,但它是推导结果,不是死规则。
覆盖索引越多越好吗?
不是。覆盖能消除回表,但更宽的索引降低单页密度、占用缓存并增加写放大。只对高频且回表成本明确的查询使用。
八、可运行实验:只改变时间条件的写法

下面从建表开始准备同一批数据。两条查询都用 FORCE INDEX 固定选择 idx_created_at;这个提示只用于控制实验变量,不是生产优化的默认写法。

DROP TABLE IF EXISTS orders_index_demo;

CREATE TABLE orders_index_demo (
  id BIGINT PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at DATETIME NOT NULL,
  amount DECIMAL(12,2) NOT NULL,
  INDEX idx_created_at (created_at)
) ENGINE=InnoDB;

INSERT INTO orders_index_demo VALUES
(101,7,'PAID','2026-08-31 23:50:00',18.00),
(205,7,'PAID','2026-09-01 09:20:00',88.00),
(309,7,'PAID','2026-09-01 10:05:00',39.00),
(412,7,'PAID','2026-09-01 11:40:00',120.00),
(490,7,'REFUND','2026-09-01 15:00:00',55.00),
(501,8,'NEW','2026-09-01 18:30:00',65.00),
(520,8,'PAID','2026-09-02 00:00:00',75.00),
(601,8,'PAID','2026-09-02 12:00:00',99.00);

-- A:先扫描索引记录,再逐条计算 DATE()
EXPLAIN ANALYZE SELECT id
FROM orders_index_demo FORCE INDEX (idx_created_at)
WHERE DATE(created_at)='2026-09-01';

-- B:直接形成左闭右开的时间范围
EXPLAIN ANALYZE SELECT id
FROM orders_index_demo FORCE INDEX (idx_created_at)
WHERE created_at>='2026-09-01 00:00:00'
  AND created_at<'2026-09-02 00:00:00';

预期现象:A 无法由普通列索引直接算出日期范围,通常要检查整棵指定索引中的 8 条记录;B 可以把扫描边界锁定在当天的 5 条记录。小数据用于验证计划形态,不代表生产耗时。

九、30 秒总结话术
让扫描量接近返回量,让索引顺序同时服务过滤与排序,再用真实计划确认收益。

面试时可以这样答:“我会先固定高频查询的过滤、排序和返回列,再按联合索引的字典序推导候选怎样一步步缩小。等值列是否放前面要看它能否稳定固定业务分组;范围后的列不一定完全无效,还可能参与索引层过滤或覆盖。最后要比较扫描、回表和排序是否下降,并确认新增索引的写入与空间成本可接受。”

下一步:进入《MySQL 执行计划:10 分钟看懂 EXPLAIN》,学习从普通 EXPLAIN 输出判断索引访问、预计扫描量与额外操作。

十、应用场景:什么时候适合,什么时候别硬加

租户内订单列表

怎么做:先用租户和状态固定分组,再按时间读取。为什么适合:目标通常是短而连续的索引段。注意:用唯一 id 保证稳定排序。

高频摘要查询

怎么做:只在收益明确时覆盖少量返回列。为什么适合:可消除大量回表。注意:不要把宽文本塞入在线事务索引。

连续翻页

怎么做:使用稳定排序键的游标分页。为什么适合:从上页边界继续,不必丢弃巨大偏移量。注意:不适合任意跳页。

不该硬用

前置通配符、复杂全文搜索和大范围聚合通常应评估全文索引、搜索系统或分析存储,而不是继续堆宽索引。

十一、资料依据