读完这篇,你能做什么
能基于查询提出索引假设,并用计划及真实成本说明它是否合适。
学习理由、准备要求与知识点
为什么学它
索引方案必须跟随查询形状和数据分布,执行计划帮助检验优化假设。
先读这些内容
知识点清单
- 联合索引字段顺序
- 筛选排序分页形状
- 最左前缀
- 范围扫描后续列
- 覆盖与过滤
- EXPLAIN 主要字段
- EXPLAIN ANALYZE 执行边界
- Skip Scan 与测量前提
运行前先确认MySQL8.4、InnoDB与Java17。源码使用OpenJDK17u TreeMap作有序边界查找类比;TreeMap是红黑树,不是MySQL的B+树或优化器实现。算法使用固定内存快照。
01先弄懂它是什么
建索引先看查询怎么找、怎么排。例如订单按 user_id 筛选,再按 create_time 和 id 倒序展示,可评估 (user_id,create_time,id) 联合索引:先把同一用户的数据放在一段,再按时间和编号排列。是否值得建立,还要看能筛掉多少数据、需要哪些列、写入频率和其他查询,不能只看哪个字段出现得多。
InnoDB 的聚簇索引叶子保存行数据,二级索引通常带主键用于找到那一行。如果查询要的列不在二级索引中,可能还得按主键再取数据,这叫回表;索引已包含所需数据时可以减少这一步。但索引越宽,保存、缓存和维护它的成本也越高,不必把所有返回字段都塞进去。
EXPLAIN 展示优化器打算怎样执行,rows 等数字通常是估算;EXPLAIN ANALYZE 则会真的执行,并记录运行情况,所以先要清楚目标语句会做多少工作。看到 key 有值,只能说明计划选了某个索引。若表本来很小、条件仍留下大部分行,或需要反复回表取数据,走索引未必更快,全表扫描也可能是合理选择。
文中这几个词,先认一认
- 最左前缀
- 理解联合索引查找范围时,先从最左字段开始看连续约束,类似按姓再按名排序后查找。
- 覆盖索引
- 查询需要的信息可以直接从所选索引中取得,减少再去读取完整行的次数。
- 游标分页
- 用上一页最后一条记录的稳定排序值查下一段数据,不靠越来越大的 offset 跳过。
02看看它怎么工作
这个联合索引先按用户排列,同一用户里再按时间排,时间相同再按 id 排。user_id 等值条件能找到该用户连续的一段,时间范围再从这一段中缩小范围。后面的字段能否继续缩小扫描区间,要看具体条件;即使不能,也可能帮助过滤、直接提供所需列或满足排序。缺少最左列时,也不能一口断言索引完全不会被访问,特定条件还可能采用 Skip Scan 等方式。
在隔离学习库确认orders字段和已有索引后,评估CREATE INDEX idx_orders_user_time_id ON orders(user_id,create_time,id)。
对SELECT id,number,status,create_time FROM orders WHERE user_id=42 ORDER BY create_time DESC,id DESC LIMIT 20执行EXPLAIN,记录key、访问方式、估算行数和Extra,再在可承受数据上用ANALYZE对比实际工作。
多条记录可能有相同的 create_time,所以还需要 id 这样的稳定次排序键,否则翻页边界可能重复或漏项。倒序查下一页时,取时间比上一页末条更早的记录;时间相同,则取 id 更小的记录,并继续按同样顺序排列。这些条件怎样使用索引,仍要看实际计划。游标可以避免按大 offset 跳过数据,也能固定排序边界,但不能自动让多次请求都看到同一份数据库快照。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["查询过滤排序与返回列"] --> B["候选联合索引"]
B --> C["EXPLAIN检查计划"]
C --> D["代表性数据实际测量"]
D --> E["比较扫描回表排序与耗时"]
E --> F{"读收益覆盖写入成本"}
F -->|"是"| G["记录适用查询与回归指标"]
F -->|"否"| H["调整SQL索引或数据设计"]按顺序看,每一步做了什么
- 写清过滤条件、排序、分页与返回列。
- 检查现有索引,避免重复或高度重叠索引。
- 采集有代表性分布与数量的数据。
- 比较EXPLAIN估算和实际执行工作,注明ANALYZE执行成本。
- 同时评估写入、存储和分页正确性,保留基线。
03对照实现代码
OpenJDK17u,jdk-17.0.16+8 · TreeMap#getCeilingEntry、#getLowerEntry
原创二叉查找树教学实现,非源码节选。只类比有序键边界;不模拟红黑树平衡,也不代表MySQL索引结构或优化器。
class SourceModel {
static class Node {
long key; Node left,right;
Node(long key) { this.key=key; }
}
static Long ceiling(Node root,long target) {
Long candidate=null;
for (Node node=root;node!=null;) {
if (node.key>=target) {
candidate=node.key; // 当前节点可行,但左侧可能更接近。
node=node.left;
} else {
node=node.right; // 当前与左侧都太小,只能向右找。
}
}
return candidate; // 没有满足下界的键时返回空。
}
}TreeMap真实方法通过比较器在树中寻找不小于目标的最小键,并在必要时利用父指针回溯。
ceiling与lower等接口区分包含边界和严格边界,这与分页条件使用小于还是小于等于的差别直接相关。
教学模型保存候选而非沿父指针回溯,逻辑相同但不是原代码;未维护平衡时不能承诺对数高度。
MySQL采用不同存储结构、页访问和成本选择。Java类比只帮助理解有序边界,数据库性能结论仍来自官方机制与实际执行计划。
代码先调用谁,再调用谁
TreeMap.ceilingEntrygetCeilingEntry- 比较目标与节点键
- 下降或回溯寻找边界
- 返回条目或空
04动手写一个例子
联合键边界与稳定倒序游标分页
输入按(user,time,id)严格升序的固定快照,查询一个用户的倒序页;可给上一页末项作为排他游标。相同时间按id排序,游标不能属于其他用户,页大小1至100。查询不得扫描其他用户。
- 输入 1
用户1有(10,1)、(10,2)、(20,3),首屏2条- 输出 1
id=[3,2]
- 输入 2
上一页末项(用户1,10,2)- 输出 2
下一页id=[1]
先验证输入严格有序并复制快照。查询通过二分找用户区间的左右边界,无需构造user+1哨兵,从而避免最大整数溢出。
若存在游标,再二分寻找第一个大于等于该完整联合键的位置;从它前一项反向读取,天然排除游标本身。游标键可以已不在快照中,边界仍按排序值有效。
反向读取最多limit项且不越过用户左边界。因为联合键严格唯一,相同时间由id区分;固定快照中的连续翻页不重不漏,但真实数据更新排序字段仍需额外一致性策略。
为什么这样写是对的二分区间左侧均不满足目标边界,右侧均满足;输出严格降序、属于目标用户且严格小于游标。
初次验证与复制线性;每页常数次二分加最多K项输出,不执行深偏移扫描。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
import java.util.function.Predicate;
public class Main {
record Key(long user,long time,long id) implements Comparable<Key> {
public int compareTo(Key b) {
int c=Long.compare(user,b.user);
if (c==0) c=Long.compare(time,b.time);
return c==0?Long.compare(id,b.id):c;
}
}
static class Index {
final List<Key> rows;
Index(List<Key> input) {
rows=List.copyOf(input);
// 先验证完整联合键严格有序且无重复。
for (int i=1;i<rows.size();i++)
if (rows.get(i-1).compareTo(rows.get(i))>=0)
throw new IllegalArgumentException("order");
}
int first(Predicate<Key> condition) {
int low=0,high=rows.size();
while (low<high) {
int mid=low+(high-low)/2;
if (condition.test(rows.get(mid))) high=mid;
else low=mid+1;
}
return low;
}
List<Long> page(long user,Key cursor,int limit) {
if (limit<1||limit>100||(cursor!=null&&cursor.user()!=user))
throw new IllegalArgumentException("page");
// 直接比较用户边界,避免用user+1构造溢出哨兵。
int start=first(k->k.user()>=user);
int end=first(k->k.user()>user);
if (cursor!=null) end=Math.min(end,first(k->k.compareTo(cursor)>=0));
List<Long> ids=new ArrayList<>();
// 排他游标从end-1开始,且始终不跨越用户左边界。
for (int i=end-1;i>=start&&ids.size()<limit;i--) ids.add(rows.get(i).id());
return List.copyOf(ids);
}
}
static void eq(Object expected,Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
Index index=new Index(List.of(new Key(1,10,1),new Key(1,10,2),
new Key(1,20,3),new Key(2,30,4)));
eq(List.of(3L,2L),index.page(1,null,2));
eq(List.of(1L),index.page(1,new Key(1,10,2),2));
eq(List.of(),index.page(3,null,2));
eq(List.of(2L,1L),index.page(1,new Key(1,15,99),2));
eq(List.of(),new Index(List.of()).page(1,null,1));
eq(List.of(7L),new Index(List.of(new Key(Long.MAX_VALUE,0,7)))
.page(Long.MAX_VALUE,null,1));
try { index.page(1,new Key(2,30,4),2); throw new AssertionError("cursor"); }
catch (IllegalArgumentException expected) {}
System.out.println("PASS 7 composite cursor tests");
}
}这些特殊情况也要试一试
- 空快照或不存在用户返回空页;游标不必恰好对应当前记录。
- 相同时间靠唯一id稳定排序,完整联合键重复拒绝。
- 用户最大
long值不会溢出,比较使用Long.compare而非减法。
进阶选读 · 分页过程中的快照与排序字段变更
场景用户连续翻页期间订单时间被修改或新订单插入,需要定义不重复和可见性契约。
做法尽量使用不可变排序键,并可在首屏固定查询上界或业务快照标识;更严格导出采用一致性快照或版本化读取,同时控制长事务成本。
复杂度索引定位可保持对数加输出成本,快照保留和数据版本维护增加存储与资源占用。
好处与代价实时新数据与固定快照完整遍历存在取舍,接口应明确新插入、删除和排序修改的可见规则。
05项目里怎么用
测量需要代表性数据和可比较条件。记录表行数、用户分布、返回比例、索引大小与缓存冷热状态;多次执行观察波动。仅几行数据的计划不能代表大规模系统,缓存命中下的微小延迟差也不能证明磁盘读取成本相同。
查询优化还要看用了多少资源。大量扫描、回表、排序和临时结果,在单次测试可能不明显,多个请求一起运行时却会争抢资源。看 EXPLAIN ANALYZE 时,要分清执行循环次数和每次处理多少行,再结合整体监控判断;一个 key 字段不能代替这些检查。
索引增加写入维护和存储成本,更新索引字段可能产生更多页工作。新增索引应考虑执行方式、锁与资源影响,并持续观察业务查询是否仍使用。排序规则、类型转换和函数条件也会影响可用性,索引命中口诀必须回到当前版本和SQL验证。
容易踩的坑
- key非空就认定扫描少且性能已优化。
- 把最左前缀口诀绝对化,忽略过滤、覆盖与特殊访问方式。
- 分页仅按非唯一时间排序,导致边界不稳定。
- 把Java树的复杂度直接当作
MySQL磁盘执行耗时。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 联合索引 | 贴合一组过滤与排序。 | 列序限制复用,增加写维护。 | 高频稳定查询形状。 |
| 覆盖索引 | 减少额外行访问。 | 条目更宽、存储与写成本更高。 | 返回字段有限且收益明确的查询。 |
| 游标分页 | 避免深偏移并提供稳定边界。 | 不便任意跳页,需定义并发可见性。 | 连续滚动列表和分批处理。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01用了索引一定比全表扫描快吗?
展开参考答案与推导
不一定,返回比例、回表成本、表大小与缓存状态都会影响选择。
02范围列之后的字段一定完全没用吗?
展开参考答案与推导
不一定,可能参与过滤、覆盖或排序,是否收紧区间需具体分析。
03TreeMap与InnoDB索引结构相同吗?
展开参考答案与推导
不同,前者是Java红黑树;这里只类比有序边界查找。
换个情况,还会处理吗
01两页同一时间记录重复怎么办?
展开参考答案与推导
加入稳定唯一次排序键并用完整联合键作排他游标。
02小数据测试不用新索引怎么办?
展开参考答案与推导
先检查查询和数据分布,优化器可能认为扫描更便宜;用代表性数据验证。
03ANALYZE在大查询上能随意执行吗?
展开参考答案与推导
不能,它会实际运行,应了解成本并在合适环境与预算内执行。
写代码试一试
01为何游标边界从end-1读取?
- 输入 / 条件
游标对应id2- 输出 / 预期
不再次输出id2
提示 end是第一个大于等于游标的位置。
展开参考答案与推导
前一项才严格小于游标,满足下一页排他规则。
02用户是最大long时如何找右边界?
- 输入 / 条件
Long.MAX_VALUE- 输出 / 预期
该用户记录仍可查询
提示 不要计算user+1。
展开参考答案与推导
二分谓词直接使用row.user>user,没有加法哨兵。
03如何测试完整翻页不重不漏?
- 输入 / 条件
固定快照、每页2条- 输出 / 预期
拼接等于同用户完整降序列表
提示 上一页末项作为下一游标。
展开参考答案与推导
循环直到空页,比较拼接顺序、长度与唯一性;该结论限定固定快照。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能基于查询提出索引假设,并用计划及真实成本说明它是否合适。
本篇重点加练
重点检验索引设计与执行计划证据,避免把索引名称当成性能结论。
- 联合索引字段顺序
- EXPLAIN 主要字段
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
订单查询按
tenant_id精确筛选、created_at范围筛选,再按created_at与 id 倒序分页。提出两种联合索引候选并说明每项字段安排服务哪个查询需求。- 提交内容
- 查询形状、候选索引及比较维度。
- 怎样算完成
- 筛选排序投影分别考虑
- 前缀条件表达明确
练到的知识点联合索引字段顺序 · 筛选排序分页形状 · 最左前缀
- 作业 02原理问答
同一联合索引遇到范围条件后,后续列可能在哪些执行环节发挥作用?设计需要读取或不需要读取额外列的两组查询,列出验证计划。
- 提交内容
- 查询对照和待观察证据。
- 怎样算完成
- 区分定位、过滤与覆盖
- 结论依赖实际执行计划
练到的知识点范围扫描后续列 · 覆盖与过滤
综合应用
- 作业 03排错实验
在独立练习表生成最多两万行、可重建的数据,比较索引前后的 EXPLAIN;仅对确认有界的 SELECT 使用 EXPLAIN ANALYZE,记录版本、数据分布和执行次数。
- 提交内容
- 数据脚本、自写结果一致性检查、计划及测量记录。
- 怎样算完成
- 测量不触及业务数据
- 解释关键字段而非只贴截图
- 区分估算与实际执行
练到的知识点EXPLAIN 主要字段 · EXPLAIN ANALYZE 执行边界
- 作业 04面试场景
只按联合索引的非首列查询,某次测试仍出现索引访问。提出需要确认的版本、优化器设置、数据分布和查询投影信息,说明一次结果能支持的结论范围。
- 提交内容
- 证据请求表与结论限定。
- 怎样算完成
- 不套用绝对化索引口诀
- 区分可选择计划与稳定性能保证
练到的知识点最左前缀 · Skip Scan 与测量前提
- 作业 05编程实践
编写本地分页验证程序,测试数据含多条相同
created_at的记录;实现一种稳定分页合同,遍历全部页并对 ID 覆盖、重复和顺序进行自动检查。- 提交内容
- Java
/JDBC或 SQL 脚本、自写边界测试与运行证据。 - 怎样算完成
- 平价顺序明确
- 整轮遍历有独立基准
- 说明测试期间是否允许并发更新
练到的知识点筛选排序分页形状 · EXPLAIN 主要字段
- 作业 06排错实验
对同一练习查询分别固定
tenantId与不固定tenantId,数据分布保持一致;比较同一联合索引下的计划、读取列与返回行数。- 提交内容
- 有界数据脚本、自写结果核验、两组计划证据。
- 怎样算完成
- 查询差异仅为受控条件
- 解释实际计划而非猜测
练到的知识点联合索引字段顺序 · EXPLAIN 主要字段
- 作业 07设计评审
两个查询分别按用户查最近记录与按状态查全部记录,总表写入频繁。提出索引候选,说明不能由其中一个查询直接推断另一个的收益。
- 提交内容
- 查询矩阵与候选比较。
- 怎样算完成
- 字段顺序对应实际过滤和排序
- 写入成本列入评审
练到的知识点联合索引字段顺序
- 作业 08原理问答
为 EXPLAIN 中的估算行数、访问方式和覆盖信息分别列出一个可能误读,并设计需要额外采集的证据确认真实瓶颈。
- 提交内容
- 字段解读表与补充验证计划。
- 怎样算完成
- 估算与实际执行证据区分
- 每项补充证据回答具体假设
练到的知识点EXPLAIN 主要字段
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成写入性能基础后:团队计划为五个相近查询各增加一个覆盖索引。设计评审方法,综合索引空间、写入成本、读取收益和数据变化后的重新验证。
- 提交内容
- 查询矩阵、成本预算与上线前验收计划。
- 怎样算完成
- 考虑索引重复与维护成本
- 测量负载能代表目标业务
练到的知识点联合索引字段顺序 · 筛选排序分页形状 · 覆盖与过滤 · Skip Scan 与测量前提
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
原来某租户占总记录的 1%,后来占 80%,SQL 文本没有变化。提出如何检验既有索引决策是否仍合适的实验,控制数据量与结果语义。
- 提交内容
- 分布变化实验设计和取舍标准。
- 怎样算完成
- 结论与数据分布关联
- 不能只凭单次执行计划选型
练到的知识点联合索引字段顺序 · EXPLAIN 主要字段
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MySQL 8.4:联合索引 (新标签页)↗
阅读联合索引的使用条件,以实际查询的过滤、排序和返回列设计索引。
- 实践参考文档英文
MySQL 8.4:范围优化 (新标签页)↗
阅读范围扫描、多个索引条件和 Skip Scan,理解口诀的适用边界。
- 深入参考文档英文
MySQL 8.4:创建表 (新标签页)↗
阅读列类型、非空、主键、唯一键与存储引擎,建立数据合法性约束。
- 实践实践指南中文
TiDB 执行计划入门:数据库准备怎样查数据 (新标签页)↗
先读“解读 EXPLAIN 的返回结果”。小练习:为同一个查询画出计划树,标出索引和估计行数;若已有
TiDB,再比较 EXPLAIN 与 EXPLAIN ANALYZE,并记录后者会真正执行查询。 文档公开;练习需自行准备TiDB,计划名称和格式不能直接套用到MySQL。 - 深入参考文档英文
MySQL8.4 EXPLAIN (新标签页)↗
区分估算计划与ANALYZE实际执行。
- 深入参考文档英文
OpenJDK17 TreeMap源码 (新标签页)↗
学习ceiling与lower的包含和排他边界,明确Java类比范围。