读完这篇,你能做什么
能把一次慢请求的应用记录和数据库记录对应起来,判断应改 SQL、索引还是事务范围。
学习理由、准备要求与知识点
为什么学它
慢 SQL、锁等待和应用排队症状相似,按证据排查才能避免无效加索引或盲目扩容。
先读这些内容
知识点清单
- 请求时间窗口
- 慢查询日志
- 语句摘要
- 执行计划
- 实际行数
- 循环次数
- 锁等待
- 优化前后对比
运行前先确认MySQL 8.4;源码固定 Spring Framework 6.2.11。源码教学实现与区间分析程序均为 Java 17;SQL 计划与数据库采集步骤需在具备授权的练习实例按实际表结构执行。
01先弄懂它是什么
接口慢,不一定是 SQL 算得慢。一次请求可能先等工作线程,再等数据库连接,然后执行 SQL、传结果、转成对象,最后生成响应。数据库执行只用了十毫秒,不能解释接口为何用了两秒;应用记录的一次 JDBC 调用又可能包括网络和读取结果的时间。先给同一请求画出耗时分布,才能决定该查连接池、锁等待还是 SQL。
比较慢请求时,要记下接口、参数规模、追踪编号、数据量、并发量和采样时间。把同类 SQL 汇总比较,必要时保留少量脱敏后的参数样本。不要只看最慢的一次:冷缓存、后台任务或偶发锁等待都可能影响它。还要看多数请求和尾部请求的耗时(如 p50、p95、p99),并一起检查吞吐、错误率和超时率,避免把拒绝更多请求误认为变快了。
分页查询慢,可以先分成两个待验证的问题:它是读了太多记录,还是在等别人释放锁?读得太多时,检查筛选条件、联合索引的字段顺序、返回列和排序;等锁时,找出谁持有锁、事务为何迟迟不结束。即使执行计划的 key 有值,也只说明选用了索引。如果条件仍留下大部分数据,就可能继续扫描很多记录。先用证据分清原因,再决定是否加索引。
文中这几个词,先认一认
- 端到端延迟
- 从请求进入到响应完成一共过去多久,包括排队、网络和各个依赖的等待。
- 执行计划
- 数据库计划怎样查表和连接数据;计划里估计要读的行数不等于实际读到的行数。
- 等待证据
- 在指定时间段记录谁在等锁、等磁盘或等线程,帮助判断是计算太多还是被别人挡住。
02看看它怎么工作
用同一个请求编号,把追踪中各步骤的耗时记录(span)、连接池指标和数据库会话对应起来。若大部分时间花在借连接上,先看多少连接被占用、多少线程在等,以及连接被谁长时间拿着;直接扩大连接池,可能让数据库更拥挤。若耗时主要在 SQL,再从慢查询日志挑出代表性语句,用 Performance Schema 汇总同类语句及等待原因。采集多少记录、保留多久,也要考虑存储和运行开销。
先对练习表执行 EXPLAIN FORMAT=TREE SELECT id, amount FROM learn_order WHERE user_id = 7 AND status = 'PAID' ORDER BY id DESC LIMIT 20,核对过滤、排序和扫描方向。
需要真实行数与时间时再考虑 EXPLAIN ANALYZE:它会执行查询,应先评估数据规模和运行成本。不要把树中父节点和子节点的时间逐层相加,因为节点时间可能包含下游工作;循环次数和每次输出行数也需要结合解释。
分布式追踪中的并行子调用同样不能相加为请求延迟。两个数据库调用分别占据二十到六十毫秒、四十到八十毫秒,时间总和八十,但覆盖请求时间只有六十。下面的区间合并程序将这一原则转成可验证模型:按类别显示覆盖时间,另算全部已观测区间的并集;类别之间若有重叠,必须保留重叠事实,不能伪造互斥分摊。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["固定请求与负载样本"] --> B["关联应用及数据库时间线"]
B --> C["定位排队 扫描 或等待"]
C --> D["提出一个可证伪假设"]
D --> E["在同条件下修改与复测"]
E --> F["核对结果语义和代价"]按顺序看,每一步做了什么
- 记录慢请求的参数规模、追踪编号和延迟分布。
- 拆出连接获取、执行与结果处理边界,核对各层时钟来源。
- 在相同时间窗查阅语句摘要、执行计划和阻塞证据。
- 一次修改一个主要因素,对比扫描、等待、吞吐与正确性。
03对照实现代码
Spring Framework v6.2.11 · JdbcTemplate#execute(PreparedStatementCreator, PreparedStatementCallback, boolean);JdbcTemplate#query(PreparedStatementCreator, PreparedStatementSetter, ResultSetExtractor)
以下是原创 JDBC 教学实现,突出连接获取、执行和映射三个计时边界;不是 Spring 源码节选,也没有复现 Spring 的事务连接复用、异常翻译与资源代理。
import java.sql.*;
import javax.sql.DataSource;
import java.util.*;
class SourceModel {
record Sample(List<Long> ids, long acquireNs, long executeNs, long mapNs) {}
static Sample query(DataSource dataSource) throws SQLException {
long start = System.nanoTime();
try (Connection connection = dataSource.getConnection()) {
long acquired = System.nanoTime(); // 连接池等待计入获取阶段
try (PreparedStatement statement =
connection.prepareStatement("SELECT id FROM learn_order WHERE user_id = ?")) {
statement.setLong(1, 7L);
long beforeExecute = System.nanoTime();
try (ResultSet rows = statement.executeQuery()) {
long executed = System.nanoTime(); // 驱动可能仍在后续读取阶段接收数据
List<Long> ids = new ArrayList<>();
while (rows.next()) ids.add(rows.getLong(1)); // 这里同时包含读取与映射成本
long mapped = System.nanoTime();
return new Sample(List.copyOf(ids), acquired - start,
executed - beforeExecute, mapped - executed);
}
}
} // 自动关闭仅示范资源归还,计时没有覆盖关闭成本
}
}真实 execute 先调用 DataSourceUtils.getConnection,再创建 PreparedStatement 并应用设置,因此包围业务回调的计时可能漏掉连接获取。
真实 query 在 PreparedStatementCallback 中绑定参数、执行查询,并调用 ResultSetExtractor;回调耗时不是纯数据库执行时间。
异常路径会清理参数和资源、翻译 SQLException,finally 也负责释放;诊断代码不能为了计时破坏这些生命周期。
教学实现把准备语句、关闭资源等耗时放进未细分部分,提醒读者还有时间没分到具体步骤。真实监控要决定把它们计入哪个阶段,或单独记录,不能因为没量到就当作零。
代码先调用谁,再调用谁
JdbcTemplate.query- execute 获取连接与创建语句
PreparedStatementCallback绑定及执行ResultSetExtractor映射- 资源释放与异常翻译
04动手写一个例子
慢请求区间并集:识别覆盖时间与未观测时间
给定总时长和若干半开区间 [start,end),计算每类 span 的并集时长、所有 span 的并集与未观测时间。只接受落在请求范围内的区间;分类结果可以重叠,不宣称互斥归因。
- 输入 1
总长100;SQL[20,60)、SQL[40,80)、POOL[0,20)- 输出 1
SQL=60,POOL=20,总覆盖80,未观测20
- 输入 2
总长10;无span- 输出 2
总覆盖0,未观测10
将区间按起点再终点排序,保存当前合并区间;下一个起点不超过当前终点时扩展右端,否则结算当前长度。
对所有区间执行一次合并得到总覆盖,再对各类别分组分别合并。全部并集不能由类别时长直接相加得到。
零长度区间贡献零;逆序或超出请求边界的区间拒绝。使用 long 并限制端点在非负总时长内,使并集和不会超出总时长。
为什么这样写是对的每次结算后,已结算区间互不相交且精确覆盖已处理输入;当前未结算区间是最后一段连通覆盖。
全量排序一次,各组排序成本之和不超过 O(n log n);分组与排序副本保存 O(n) 个引用。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Span(String kind, long start, long end) {}
record Report(Map<String, Long> byKind, long covered, long unknown) {}
static long union(List<Span> spans) {
if (spans.isEmpty()) return 0;
List<Span> sorted = new ArrayList<>(spans);
sorted.sort(Comparator.comparingLong(Span::start).thenComparingLong(Span::end));
long left = sorted.get(0).start(), right = sorted.get(0).end(), total = 0;
for (int i = 1; i < sorted.size(); i++) {
Span s = sorted.get(i);
if (s.start() <= right) right = Math.max(right, s.end()); // 重叠或相邻可合并
else {
total += right - left; // 仅结算互不重叠的完整区间
left = s.start(); right = s.end();
}
}
return total + right - left;
}
static Report analyze(long duration, List<Span> spans) {
if (duration < 0) throw new IllegalArgumentException("duration");
Map<String, List<Span>> groups = new TreeMap<>();
for (Span s : spans) {
if (s.kind() == null || s.start() < 0 || s.end() < s.start() || s.end() > duration)
throw new IllegalArgumentException("span"); // 拒绝错误时钟与越界输入
groups.computeIfAbsent(s.kind(), k -> new ArrayList<>()).add(s);
}
Map<String, Long> totals = new TreeMap<>();
groups.forEach((kind, group) -> totals.put(kind, union(group)));
long covered = union(spans); // 类别之间可能重叠,必须单独算全量并集
return new Report(Map.copyOf(totals), covered, duration - covered);
}
static void check(boolean ok) { if (!ok) throw new AssertionError(); }
static void bad(Runnable action) {
try { action.run(); } catch (IllegalArgumentException expected) { return; }
throw new AssertionError("expected invalid input");
}
public static void main(String[] args) {
Report r = analyze(100, List.of(new Span("SQL",20,60),
new Span("SQL",40,80), new Span("POOL",0,20)));
check(r.byKind().get("SQL") == 60 && r.covered() == 80 && r.unknown() == 20);
check(analyze(10, List.of()).unknown() == 10);
r = analyze(10, List.of(new Span("A",0,10), new Span("B",2,8)));
check(r.covered() == 10 && r.byKind().get("B") == 6);
check(analyze(10, List.of(new Span("A",5,5))).covered() == 0);
check(analyze(10, List.of(new Span("A",5,10), new Span("A",0,5))).covered() == 10);
bad(() -> analyze(10, List.of(new Span("A",0,11))));
bad(() -> analyze(10, List.of(new Span("A",8,3))));
System.out.println("7 interval attribution checks passed");
}
}这些特殊情况也要试一试
- 并行子 span 重叠时,分类覆盖和可能大于端到端时间。
- 缺失 span 只代表未观测,不能自动认定为应用 CPU 时间。
- 跨机器时钟偏移需要追踪系统处理,不能直接拼接未经校正的绝对时间。
进阶选读 · 生产拓展:结合因果依赖计算关键路径
场景一个接口并行查询多个数据源,最长单个 SQL 不一定决定整体响应。
做法将经过校准的 span 和等待依赖构成有向无环图,在拓扑序上计算最长因果路径;区分等待子任务与本地执行,并保留缺失链路标记。
复杂度已构建 DAG 上的最长路径为 O(V+E);时间校准和 span 关联另计。
好处与代价获得关键路径需要可靠的传播与采样;不能把网络到达顺序直接当作因果顺序。
05项目里怎么用
诊断记录应连起症状、原查询、表大小、执行计划、等待记录、原因假设、改动和复测。对比时保持数据集与参数分布相近,并记录预热,不能拿第一次冷读和改完后的热读直接比较。如果结果顺序或分页含义变了,要先说明功能已改变,不能只报告变快。
治理慢日志和追踪时,按问题选择阈值、采样和保留期。把每条 SQL 的完整参数长期写入日志既增加开销,也扩大数据暴露面。高频普通查询的累计耗时可能大于少量慢查询,所以需要总调用量、平均成本和尾延迟共同帮助确定优先级。
修复应包含回退入口。新增索引会增加写入维护成本和空间;缩短事务可能改变一致性;提高连接池上限会增加数据库竞争。先在相近负载下观察,再渐进发布。若瓶颈是排队,应先处理限流、连接泄漏或慢持有者,而不是无限增加并发。
容易踩的坑
- 将所有嵌套或并行 span 的时间相加,得到超过真实请求时长的伪结论。
- 把 EXPLAIN ANALYZE 当成不执行 SQL 的静态命令。
- 看到索引名称便停止分析扫描行数、回表和排序。
- 忽略异常与超时请求,只比较成功请求延迟。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 慢查询日志 | 容易获得具体高耗时样本 | 受阈值与记录内容影响,不能覆盖应用排队 | 定位代表性慢 SQL |
| Performance Schema | 可关联语句与等待统计 | 需要配置采集范围并理解聚合口径 | 判断系统性等待与高累计成本 |
| 应用分布式追踪 | 能观察跨服务和连接获取边界 | 采样、传播与时钟质量影响完整性 | 解释端到端慢请求 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为何 JDBC 调用耗时不能直接称为 MySQL CPU 时间?
展开参考答案与推导
它可能包含连接、网络、结果读取和映射,数据库内部还可能等待锁或 I/O;需要明确计时边界。
02使用索引是否意味着查询高效?
展开参考答案与推导
不一定。索引仍可能扫描大量记录、回表或额外排序,要结合实际行数、过滤和负载判断。
03为何类别耗时之和可能超过请求总时长?
展开参考答案与推导
类别可能并行或嵌套,同一时段被多次计入;总覆盖应取区间并集。
换个情况,还会处理吗
01接口两秒,SQL 十毫秒,先查什么?
展开参考答案与推导
确认记录对应同一请求,然后查看线程排队、连接获取、远程依赖、结果处理与未观测区间。
02改索引后单次查询变快十倍,能宣称稳定提升吗?
展开参考答案与推导
不能。需要同数据和参数分布下重复比较,并控制缓存冷热、并发与错误率。
03数据库锁等待很多,扩大连接池会怎样?
展开参考答案与推导
可能增加等待者和竞争;应先找长事务、阻塞链和锁顺序,再评估并发上限。
写代码试一试
01合并 [0,4)、[2,6)、[8,10)。
- 输入 / 条件
三个区间- 输出 / 预期
总覆盖8
提示 先扩展第一段至6。
展开参考答案与推导
第一段并集为[0,6),第二段[8,10),总长6+2=8。
02A覆盖[0,10),B覆盖[3,7),总覆盖多少?
- 输入 / 条件
总时长10- 输出 / 预期
总覆盖10;分类和14
提示 分类统计不是互斥归因。
展开参考答案与推导
B完全包含在A中,不增加全量并集;程序需分别计算这两个口径。
03如何处理起点大于终点的 span?
- 输入 / 条件
[8,3)- 输出 / 预期
拒绝输入
提示 不要静默反转时间。
展开参考答案与推导
它可能来自测量错误或时钟问题,自动交换会掩盖问题;验证失败后定位采集来源。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能把一次慢请求的应用记录和数据库记录对应起来,判断应改 SQL、索引还是事务范围。
本篇重点加练
执行计划只是某次条件下的证据,优化效果必须在可比数据与参数分布下核验。
- 执行计划
- 优化前后对比
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01面试场景
用户称 14:02~14:05 页面缓慢,但应用与数据库日志时区不同。制定时间对齐和请求样本筛选规则,再说明慢日志阈值会漏掉什么。
- 提交内容
- 时间映射表与样本清单
- 怎样算完成
- 记录时区和时钟误差
- 区分未记录与未发生
练到的知识点请求时间窗口 · 慢查询日志
- 作业 02原理问答
两条 SQL 只有商户 ID 不同,但一条扫描量远高于另一条。比较语句摘要和单次执行计划各能提供的信息。
- 提交内容
- 证据对照表
- 怎样算完成
- 包含参数分布
- 不以摘要平均值代表所有实例
练到的知识点语句摘要 · 执行计划
- 作业 03原理问答
同一 SQL 在小商户与头部商户参数下表现不同。制定计划采样策略,说明一份 EXPLAIN 可以覆盖与不能覆盖的输入范围。
- 提交内容
- 参数分层与计划清单
- 怎样算完成
- 区分估计行数和实际观测
- 注明统计信息状态
练到的知识点执行计划
综合应用
- 作业 04代码推演
某关联查询外层 100 行,内层每次扫描 80 行,另一方案一次处理 5000 行。按自定计划树估算实际工作量并检查哪些耗时不能仅由行数推出。
- 提交内容
- 计划树和计算过程
- 怎样算完成
- 区分每循环行数与总行数
- 列出缺失的测量信息
练到的知识点实际行数 · 循环次数
- 作业 05设计评审
团队准备加索引解决慢请求,但慢样本都处于锁等待。设计前后对照,使扫描成本、等待时间和应用排队能够分别核对。
- 提交内容
- 实验指标与对照计划
- 怎样算完成
- 同负载及数据分布
- 设置索引变更后的正确性核验
练到的知识点锁等待 · 优化前后对比
- 作业 06排错实验
在本地小型
MySQL数据集分别制造索引选择差和会话持锁两类慢查询,收集执行计划与等待证据,撰写两份不同诊断。- 提交内容
- 建表脚本与证据报告
- 怎样算完成
- 数据量和锁持续时间有上限
- 明确 EXPLAIN 与实际执行的区别
练到的知识点请求时间窗口 · 慢查询日志 · 语句摘要 · 执行计划 · 实际行数 · 循环次数 · 锁等待 · 优化前后对比
- 作业 07设计评审
优化后测试集平均更快,但线上最慢的一组参数没有出现在测试数据中。设计前后对照的样本选择与分层报告。
- 提交内容
- 评估协议
- 怎样算完成
- 包含冷热数据及倾斜
- 预先声明成功标准
练到的知识点优化前后对比
- 作业 08编程实践
在本地小型
MySQL数据库生成均匀与倾斜两种分布,运行同一查询的两种候选实现,采集计划、结果摘要和多次耗时。- 提交内容
- 数据脚本与对照报告
- 怎样算完成
- 两实现结果按同一口径核对
- 不将缓存预热差异隐藏在结论中
练到的知识点执行计划 · 优化前后对比
进阶挑战 · 掌握前置后选做
- 作业 09编程实践
编写 Java 慢请求汇总器读取脱敏 CSV,按查询指纹输出次数、分位数和扫描分布;加入缺失数据、空窗口和极端耗时。
- 提交内容
- 源码、CSV 与报告
- 怎样算完成
- 写清分位数定义
- 保留样本数并避免只比较均值
练到的知识点请求时间窗口 · 语句摘要 · 实际行数 · 循环次数 · 优化前后对比
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 开放探究
研究“扫描行数更少的方案一定更快”是否成立,提出至少两个不同成本来源并设计本地可验证的反例实验。
- 提交内容
- 假设与实验计划
- 怎样算完成
- 明确随机 IO、排序或其他具体因素
- 避免只用一次时间差证明
练到的知识点执行计划 · 优化前后对比
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
MySQL 8.4:慢查询日志 (新标签页)↗
阅读记录条件与配置边界,用时间窗口收集高成本 SQL。
- 实践参考文档英文
MySQL 8.4:EXPLAIN (新标签页)↗
阅读计划输出及 EXPLAIN ANALYZE 的执行行为,用证据判断扫描代价。
- 深入教程英文
MySQL 8.4:Performance Schema 入门 (新标签页)↗
阅读语句与等待事件汇总,区分 SQL 执行时间和资源等待。
- 深入实践指南英文
MySQL 8.4:处理死锁 (新标签页)↗
阅读锁顺序、短事务与检测输出,设计可重试的事务边界。
- 实践实践指南中文
TiDB 执行计划入门:数据库准备怎样查数据 (新标签页)↗
先读“解读 EXPLAIN 的返回结果”。小练习:为同一个查询画出计划树,标出索引和估计行数;若已有
TiDB,再比较 EXPLAIN 与 EXPLAIN ANALYZE,并记录后者会真正执行查询。 文档公开;练习需自行准备TiDB,计划名称和格式不能直接套用到MySQL。 - 深入参考文档英文
Spring 6.2.11 JdbcTemplate 源码 (新标签页)↗
核对连接获取、回调、映射与资源释放边界。