qinghuan
← Java Web / 全部笔记

单元 70 / Java Web

P1 · 重点提升L2 · 进阶

MySQL 索引:用 EXPLAIN 验证查询假设

从订单筛选和排序方式设计索引,再用执行计划检查实际选择,避免把“用了索引”当成“一定更快”。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能基于查询提出索引假设,并用计划及真实成本说明它是否合适。

学习理由、准备要求与知识点

为什么学它

索引方案必须跟随查询形状和数据分布,执行计划帮助检验优化假设。

先读这些内容

知识点清单

  • 联合索引字段顺序
  • 筛选排序分页形状
  • 最左前缀
  • 范围扫描后续列
  • 覆盖与过滤
  • 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索引或数据设计"]

按顺序看,每一步做了什么

  1. 写清过滤条件、排序、分页与返回列。
  2. 检查现有索引,避免重复或高度重叠索引。
  3. 采集有代表性分布与数量的数据。
  4. 比较EXPLAIN估算和实际执行工作,注明ANALYZE执行成本。
  5. 同时评估写入、存储和分页正确性,保留基线。

03对照实现代码

TreeMap边界查找:理解有序定位的Java类比 (新标签页)↗

OpenJDK17u,jdk-17.0.16+8 · TreeMap#getCeilingEntry、#getLowerEntry

原创二叉查找树教学实现,非源码节选。只类比有序键边界;不模拟红黑树平衡,也不代表MySQL索引结构或优化器。

核心机制代码 · java
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类比只帮助理解有序边界,数据库性能结论仍来自官方机制与实际执行计划。

代码先调用谁,再调用谁

  1. TreeMap.ceilingEntry
  2. getCeilingEntry
  3. 比较目标与节点键
  4. 下降或回溯寻找边界
  5. 返回条目或空

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区分;固定快照中的连续翻页不重不漏,但真实数据更新排序字段仍需额外一致性策略。

为什么这样写是对的二分区间左侧均不满足目标边界,右侧均满足;输出严格降序、属于目标用户且严格小于游标。

时间 初始化O(N),每页O(log N+K)额外空间 索引快照O(N),单页O(K)

初次验证与复制线性;每页常数次二分加最多K项输出,不执行深偏移扫描。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 59 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 主要字段

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    订单查询按 tenant_id 精确筛选、created_at 范围筛选,再按 created_at 与 id 倒序分页。提出两种联合索引候选并说明每项字段安排服务哪个查询需求。

    提交内容
    查询形状、候选索引及比较维度。
    怎样算完成
    • 筛选排序投影分别考虑
    • 前缀条件表达明确

    练到的知识点联合索引字段顺序 · 筛选排序分页形状 · 最左前缀

  2. 作业 02原理问答

    同一联合索引遇到范围条件后,后续列可能在哪些执行环节发挥作用?设计需要读取或不需要读取额外列的两组查询,列出验证计划。

    提交内容
    查询对照和待观察证据。
    怎样算完成
    • 区分定位、过滤与覆盖
    • 结论依赖实际执行计划

    练到的知识点范围扫描后续列 · 覆盖与过滤

综合应用

  1. 作业 03排错实验

    在独立练习表生成最多两万行、可重建的数据,比较索引前后的 EXPLAIN;仅对确认有界的 SELECT 使用 EXPLAIN ANALYZE,记录版本、数据分布和执行次数。

    提交内容
    数据脚本、自写结果一致性检查、计划及测量记录。
    怎样算完成
    • 测量不触及业务数据
    • 解释关键字段而非只贴截图
    • 区分估算与实际执行

    练到的知识点EXPLAIN 主要字段 · EXPLAIN ANALYZE 执行边界

  2. 作业 04面试场景

    只按联合索引的非首列查询,某次测试仍出现索引访问。提出需要确认的版本、优化器设置、数据分布和查询投影信息,说明一次结果能支持的结论范围。

    提交内容
    证据请求表与结论限定。
    怎样算完成
    • 不套用绝对化索引口诀
    • 区分可选择计划与稳定性能保证

    练到的知识点最左前缀 · Skip Scan 与测量前提

  3. 作业 05编程实践

    编写本地分页验证程序,测试数据含多条相同 created_at 的记录;实现一种稳定分页合同,遍历全部页并对 ID 覆盖、重复和顺序进行自动检查。

    提交内容
    Java/JDBC 或 SQL 脚本、自写边界测试与运行证据。
    怎样算完成
    • 平价顺序明确
    • 整轮遍历有独立基准
    • 说明测试期间是否允许并发更新

    练到的知识点筛选排序分页形状 · EXPLAIN 主要字段

  4. 作业 06排错实验

    对同一练习查询分别固定 tenantId 与不固定 tenantId,数据分布保持一致;比较同一联合索引下的计划、读取列与返回行数。

    提交内容
    有界数据脚本、自写结果核验、两组计划证据。
    怎样算完成
    • 查询差异仅为受控条件
    • 解释实际计划而非猜测

    练到的知识点联合索引字段顺序 · EXPLAIN 主要字段

  5. 作业 07设计评审

    两个查询分别按用户查最近记录与按状态查全部记录,总表写入频繁。提出索引候选,说明不能由其中一个查询直接推断另一个的收益。

    提交内容
    查询矩阵与候选比较。
    怎样算完成
    • 字段顺序对应实际过滤和排序
    • 写入成本列入评审

    练到的知识点联合索引字段顺序

  6. 作业 08原理问答

    为 EXPLAIN 中的估算行数、访问方式和覆盖信息分别列出一个可能误读,并设计需要额外采集的证据确认真实瓶颈。

    提交内容
    字段解读表与补充验证计划。
    怎样算完成
    • 估算与实际执行证据区分
    • 每项补充证据回答具体假设

    练到的知识点EXPLAIN 主要字段

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成写入性能基础后:团队计划为五个相近查询各增加一个覆盖索引。设计评审方法,综合索引空间、写入成本、读取收益和数据变化后的重新验证。

    提交内容
    查询矩阵、成本预算与上线前验收计划。
    怎样算完成
    • 考虑索引重复与维护成本
    • 测量负载能代表目标业务

    练到的知识点联合索引字段顺序 · 筛选排序分页形状 · 覆盖与过滤 · Skip Scan 与测量前提

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    原来某租户占总记录的 1%,后来占 80%,SQL 文本没有变化。提出如何检验既有索引决策是否仍合适的实验,控制数据量与结果语义。

    提交内容
    分布变化实验设计和取舍标准。
    怎样算完成
    • 结论与数据分布关联
    • 不能只凭单次执行计划选型

    练到的知识点联合索引字段顺序 · EXPLAIN 主要字段

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗