数据库存:数据库管理员复盘框架:性能优化如何定位查询速度慢
查询从 80 毫秒变成 2.4 秒,最容易出现的错误不是不会写 SQL,而是第一时间把问题归咎于“索引没建好”。在我参与数据库性能排查时,遇到过多次这样的情况:执行计划没有明显恶化,数据库 CPU 也只有 45%,真正拖慢请求的原因却是连接池等待、长事务锁阻塞,或者接口一次性取回了几十万行数据。定位查询速度慢,第一步不是改 SQL,而是先证明时间究竟花在了哪里。
一套可靠的 DBA 性能复盘框架,应该把“现象、证据、假设、验证、修复、回归”串成一条链路。只有这样,团队才不会在没有保存基线的情况下贸然加索引,也不会因为一次测试变快,就误以为线上问题已经解决。本文将以通用数据库场景为主,结合 MySQL、PostgreSQL 等常见引擎的诊断思路,说明如何从接口变慢定位到真正根因,并把一次慢查询事故沉淀为可复用的排查流程。
用户说“页面打开很慢”,并不等于数据库执行某条 SQL 很慢。一个请求从进入网关到返回页面,可能经历网络传输、线程池排队、连接池获取、事务等待、数据库执行、结果传输、对象映射和页面渲染等多个阶段。
如果接口总耗时是 2 秒,而数据库真正执行只用了 120 毫秒,那么继续修改 SQL 通常不会解决用户感知。相反,如果数据库端显示执行时间 1.8 秒,而应用端只记录了 1.9 秒,才有充分理由把排查重点放到执行计划、扫描行数、排序、连接和资源瓶颈上。
我通常会先把一次请求拆成下面几段,而不是直接打开 SQL 编辑器:
平均耗时只能告诉你整体水平,不能告诉你最糟糕的用户体验。假设一条查询 99% 的请求都在 50 毫秒内完成,但 1% 的请求因为特殊参数需要扫描数百万行,那么平均值可能仍然只有 100 毫秒,P99 却已经超过 5 秒。
因此,慢查询复盘至少要记录平均耗时、P95、P99、最大耗时、超时率和执行次数。对线上接口而言,P99 常常比平均值更接近用户在高峰期遇到的真实问题;对批处理任务而言,则需要同时观察总执行时长、每批耗时和资源峰值。
“SQL 看起来更简洁了”不是性能结果,“执行计划显示使用了索引”也不是性能结果。真正能证明优化有效的证据,至少应该包括:相同参数范围下的耗时变化、扫描行数变化、返回行数变化、锁等待变化,以及高并发或真实流量下的 P95、P99 和超时率变化。
如果一次改动没有留下优化前基线,就很难证明优化后的提升来自哪里;如果没有观察上线后的分位数,也很难证明实验室中的提升可以复制到生产环境。

很多慢查询事故看起来像是某个时间点突然爆发,实际上根因可能已经积累了数周甚至数月。例如,订单表从 300 万行增长到 8000 万行,原本可以接受的时间范围查询逐步扫描更多数据;某个联合索引的区分度随着业务状态变化下降;报表任务从每天一次变成每小时一次,逐渐挤占了在线请求的缓存和 IO。
事故真正被发现,往往是因为某个外部条件触发了阈值:大促流量上升、数据归档延迟、一次发布改变了查询参数、统计信息没有及时更新,或者连接池从 100 个扩大到 300 个之后,数据库出现了更严重的并发争抢。
下面是一组用于说明排查方法的情景模拟数据,不代表某家公司的生产记录。某订单列表接口在工作日下午出现超时,应用监控显示平均耗时从 180 毫秒升到 640 毫秒,P99 从 1.1 秒升到 6.8 秒。团队第一判断是“订单表数据量增长导致索引失效”。
| 观察对象 | 异常前 | 异常时 | 第一判断 |
|---|---|---|---|
| 接口平均耗时 | 180 毫秒 | 640 毫秒 | 整体变慢 |
| 接口 P99 | 1.1 秒 | 6.8 秒 | 尾部请求恶化明显 |
| 数据库 CPU | 32% | 46% | 不像 CPU 饱和 |
| 数据库执行耗时 | 90 毫秒 | 105 毫秒 | SQL 本身变化有限 |
| 连接池获取连接耗时 | 8 毫秒 | 410 毫秒 | 连接池等待疑似主因 |
如果只看“接口变慢”和“订单表变大”,很容易直接去改索引。但把链路拆开后可以发现,数据库执行时间只增加了 15 毫秒,真正增长的是连接池获取时间。此时优化 SQL 只能解决次要问题,应该先检查连接没有及时归还、慢事务占用连接、连接池配置和数据库最大连接数。
连接池等待、锁等待和网络抖动通常不会影响每一条请求,而是集中影响高并发时段或特定业务参数。于是平均耗时可能只是从 180 毫秒涨到 250 毫秒,但 P99 已经从 1 秒涨到 8 秒。
这也是我在复盘时坚持先看时间分布的原因:尾部慢请求往往比平均慢请求更能揭示并发、锁、连接和参数分布问题。如果只拿一条“执行最快”的 SQL 在客户端运行,很可能得到一个与线上完全不同的结论。

这是最常见也最危险的简化。接口可能在等待连接,数据库可能在等待锁,应用也可能在等待下游服务。即使数据库监控显示一条 SQL 的平均执行时间很低,应用端仍然可能因为连接池排队而出现高延迟。
排查时应同时拿到应用侧和数据库侧的时间戳。应用日志需要记录请求开始、获取连接、发送 SQL、收到结果和返回响应的时间;数据库侧需要记录 SQL 执行时间、等待事件、锁等待和会话状态。两边时间口径对不上时,不能直接把差值归因于数据库。
“使用索引”只是执行计划中的一个事实,不是性能结论。一条查询可能使用了索引,却扫描了 500 万条索引记录;也可能先通过低选择性字段定位出大量数据,再回表读取大量列;还可能因为排序和聚合产生临时结果。
判断索引是否有效,应该关注索引筛选后的数据规模、估算行数和实际行数、回表数量、排序成本,以及查询返回结果占总数据的比例。对于只返回大部分表数据的查询,全表扫描有时反而比低效索引访问更合理。
假设一张只有几千行的小表,查询需要返回其中 70% 的记录,优化器选择顺序扫描并不奇怪。强行添加索引或通过提示改变访问路径,可能增加维护成本,却没有带来实际收益。
全表扫描真正值得警惕的场景,是大表、低返回比例、频繁执行、并发较高,且查询没有合理利用可用访问路径。不能脱离表规模、返回比例和执行频率,单独评价全表扫描好或坏。
同一个 SQL 模板,参数不同可能对应完全不同的数据分布。查询热门用户可能返回几十行,查询默认用户可能返回数百万行;查询最近一天可能命中局部索引,查询三年历史数据则可能更适合分区扫描或批量读取。
因此,基准测试至少要覆盖小结果集、大结果集、空结果集、边界日期、高频参数和低选择性参数。只用“看起来最快”的参数测试,会把最危险的执行路径隐藏起来。
如果同时改 SQL、加索引、调整数据库参数、扩大连接池并升级机器,最终即使耗时下降,也无法判断哪一个动作真正有效,更无法评估哪个改动带来了副作用。
更稳妥的做法是把动作拆成可验证的实验。先保存原始执行计划和指标,再选择一个主要变量进行修改;验证后再进入下一步。生产事故中可以采用低风险动作快速止血,但复盘阶段仍然要补齐因果验证。
数据库 CPU 不高,并不意味着数据库没有问题。锁等待、磁盘 IO、缓存未命中、连接数耗尽、日志刷盘和临时空间不足,都可能让查询变慢,而 CPU 仍处于中低水平。
我会把 CPU 看成资源证据的一部分,而不是数据库健康度的总分。任何关于“资源不足”的判断,都应该至少结合 CPU、磁盘延迟、IOPS、内存、活跃会话、等待事件和连接数共同确认。

执行慢是数据库真正花时间读取、连接、排序、聚合或返回数据;等待慢则是查询逻辑本身未必变化,但它在等待锁、连接、IO、并发资源或网络。两者的处理方向完全不同。
| 表现 | 更可能的方向 | 优先证据 | 不应先做的动作 |
|---|---|---|---|
| 执行计划改变,扫描行数暴增 | 计划、统计信息、索引或参数问题 | 优化前后执行计划、估算与实际行数 | 直接扩大数据库机器 |
| 执行时间稳定,但接口 P99 上升 | 连接池、网络或应用排队 | 链路追踪、连接获取时间、线程池指标 | 盲目改写 SQL |
| CPU 不高,活动会话大量等待 | 锁、IO 或其他等待事件 | 锁等待、事务状态、磁盘延迟 | 只查看 CPU 使用率 |
| 特定参数耗时异常 | 数据倾斜、参数敏感或返回量过大 | 参数分组耗时、返回行数、执行计划 | 只用平均耗时判断 |
如果相同 SQL、相同参数在低并发下依然变慢,优先检查数据量、执行计划、索引和统计信息;如果低并发很快,高并发才变慢,则要重点检查锁竞争、连接池、CPU、IO 和事务范围。
这里有一个经常被忽略的区别:查询单次耗时变慢,和查询排队时间变长,不一定是同一个问题。前者通常反映执行路径,后者通常反映并发资源。监控如果只记录一个总耗时,就会把两类问题混在一起。
我判断一条查询是否值得优化,通常会先比较“扫描行数”和“返回行数”。如果查询只返回 20 行,却扫描了 300 万行,说明过滤效率存在明显问题;如果查询需要返回 200 万行,那么即使使用了索引,也可能受限于数据传输和应用处理。
扫描行数与返回行数的比例不是绝对标准,但它是非常有用的第一轮信号。比例越高,越应该检查过滤条件、联合索引顺序、隐式转换、函数计算和数据分布。
执行计划中的估算行数与实际行数差距很大时,优化器可能基于错误的数据分布选择了不合适的访问路径。例如,它以为某个条件只会返回几十行,实际却返回几十万行;它以为某个连接很小,实际连接结果发生了数量级膨胀。
此时不要急着用提示强制指定执行计划。应先确认统计信息是否及时、数据是否存在明显倾斜、字段关联性是否被准确反映,以及参数是否导致计划选择不稳定。强制计划可以作为临时止血措施,但不一定是长期解决方案。
数据库性能问题往往和变更有关,但“变更后发生”不等于“变更就是根因”。需要把 SQL、索引、应用版本、数据库参数、数据归档、批处理任务和流量变化放在同一条时间线上。
如果问题刚好出现在应用发布后,应对比发布前后的 SQL 模板、参数、调用次数和执行计划;如果没有应用发布,却出现数据量、批任务或统计信息更新时间变化,也要纳入调查范围。

先明确异常开始时间、结束时间和影响范围。例如,不能只记录“下午变慢”,而应记录 14:05 开始 P99 上升,14:18 达到峰值,14:37 发布回滚后逐步恢复。
时间窗口一旦固定,就可以把应用发布、数据库参数变更、批处理任务、备份、归档、流量峰值和锁等待事件放到同一张时间线上。没有时间窗口,后续查询日志和监控指标很容易变成无关数据的堆积。
性能事故处理中,重启应用、杀掉会话、清空连接池虽然可能快速恢复服务,却也可能破坏最有价值的现场证据。执行计划、活动会话、长事务和等待事件都可能在操作后消失。
如果业务必须止血,应在允许的时间内先完成最小化取证:保存异常 SQL 模板、关键参数、活动会话、等待类型、锁关系、资源曲线和连接池状态。然后再执行回滚、限流或终止阻塞会话等动作。
慢查询日志和性能监控通常会按 SQL 模板聚合,但模板聚合可能掩盖不同参数的差异。复盘时应保留脱敏后的参数范围、执行时间、执行次数、返回行数、扫描行数和发生时间。
如果系统只记录完整 SQL 文本而没有记录参数,后续很难复现参数敏感问题;如果只记录平均耗时而没有记录 P99,也很难识别偶发极慢请求。
不同数据库引擎的命令不同,不能把一种数据库的语法直接套到另一种数据库。以常见 SQL 诊断为例,示意写法如下,具体选项应结合数据库版本和测试环境确认:
-- 仅示意:先查看预估执行计划 EXPLAIN SELECT order_id, user_id, created_at, amount FROM orders WHERE tenant_id = 1001 AND status = 'PAID' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 50; -- 仅示意:在支持该能力的数据库中查看实际执行信息 EXPLAIN ANALYZE SELECT order_id, user_id, created_at, amount FROM orders WHERE tenant_id = 1001 AND status = 'PAID' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 50;
生产环境执行实际分析命令需要谨慎。有些数据库会真实执行 SQL,并增加额外开销;对更新、删除或带副作用的语句,更不能直接照搬示例。必要时应在脱敏副本、只读环境或受控流量下验证。
不要只截一张执行计划图片就结束。更有价值的记录方式,是把计划节点、估算行数、实际行数和耗时放在一起,并标记出第一次出现数量级膨胀的位置。
如果执行计划没有明显变化,但请求在某个时间段突然变慢,锁等待是必须排除的方向。尤其是订单状态更新、库存扣减、账户余额变更等高并发业务,查询和更新可能因为事务范围过大而互相阻塞。
排查时需要确认阻塞者和被阻塞者、事务开始时间、最后一次操作、锁对象、等待持续时间及隔离级别。不同数据库的锁诊断视图差异较大,文章可以统一讲判断逻辑,但执行命令必须按数据库类型和版本分别验证。
当数据库 CPU 不高但接口依然慢时,要把注意力从执行计划扩展到连接和 IO。重点观察数据库活跃连接数、等待连接数、连接创建频率、连接归还时间、磁盘读写延迟、缓存命中率和临时空间。
连接池过小会导致应用排队,连接池过大则可能把数据库推入更严重的并发争抢。连接池配置不是越大越好,它必须和数据库最大连接数、单条 SQL 平均耗时、应用实例数量以及数据库可承受并发共同计算。
修复后不能只在客户端点击一次运行。应该使用相同或相近的参数、数据规模和并发条件,对比优化前后的执行耗时、扫描量、锁等待、资源占用和错误率。
如果是线上问题,还需要观察发布后的至少一个业务高峰周期。对于每天只在月底运行的报表查询,仅观察普通工作日并不能证明优化成功。

下面是一个脱敏后的示例场景。业务需要查询某租户最近一段时间内已支付订单,并按创建时间倒序返回前 50 条。初始 SQL 如下:
SELECT * FROM orders WHERE tenant_id = 1001 AND status = 'PAID' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 50;
这条 SQL 在数据量较小时运行很快,开发环境只有 20 万行订单,平均耗时约 25 毫秒。上线一年后,订单表增长到 1.2 亿行,线上某些租户的查询耗时开始超过 2 秒,P99 在月底甚至达到 9 秒。
团队的第一反应是给 status 和 created_at 分别增加索引。但这并不是一个充分的方案,因为查询同时包含租户过滤、状态过滤、时间范围、排序和结果限制,单列索引未必能让数据库快速得到目标的前 50 行。
| 指标 | 普通租户 | 大租户 | 判断 |
|---|---|---|---|
| 平均耗时 | 42 毫秒 | 680 毫秒 | 存在明显租户数据倾斜 |
| P99 | 210 毫秒 | 4.8 秒 | 尾部请求集中在大租户 |
| 返回行数 | 50 行 | 50 行 | 结果集大小不是主要差异 |
| 扫描行数 | 1,800 行 | 2,900,000 行 | 过滤效率随租户变化显著下降 |
| 排序耗时 | 3 毫秒 | 420 毫秒 | 排序和候选集规模是重要成本 |
这个结果说明,问题不是“所有查询都变慢”,而是数据分布发生了倾斜。普通租户只拥有少量订单,大租户则占据了总订单量的很大比例。对所有租户使用同一条访问路径,导致优化器在不同参数下可能做出不同选择。
第一种方案是增加联合索引,例如将租户、状态和创建时间放在同一个访问路径中。它可能显著降低在线查询扫描量,但会增加索引存储空间,并提高订单写入、状态更新和索引维护成本。
第二种方案是改写查询,减少 SELECT *,只返回页面所需字段。它可以降低回表和网络传输成本,但如果主要瓶颈是候选记录筛选和排序,单纯减少字段不会解决根因。
第三种方案是采用基于游标或最后一条时间标记的分页方式,避免深分页。它对翻页较深的场景很有效,但需要稳定排序字段,且前端和接口协议要支持游标。
第四种方案是将高频列表查询转移到面向查询的汇总表或搜索服务。这样可以把在线数据库从复杂筛选中释放出来,但会引入数据同步延迟、一致性约束和额外运维成本,不能只因为 SQL 慢就立即上复杂架构。
假设团队先建立了联合索引,并将返回字段从 18 个减少到 7 个,再针对深分页接口改为基于 created_at 和 order_id 的游标分页。以下数据是用于展示验证方法的情景模拟,不是未经来源证明的生产数据。
| 指标 | 优化前 | 联合索引后 | 游标分页后 |
|---|---|---|---|
| 大租户平均耗时 | 680 毫秒 | 95 毫秒 | 52 毫秒 |
| 大租户 P99 | 4.8 秒 | 620 毫秒 | 180 毫秒 |
| 单次扫描行数 | 290 万 | 1,200 | 420 |
| 深分页第 1000 页耗时 | 3.6 秒 | 2.1 秒 | 68 毫秒 |
| 订单写入额外索引维护 | 0 | 约增加 8% CPU | 约增加 8% CPU |
这个案例最值得注意的不是“加索引后快了多少”,而是每个改动解决了不同环节:联合索引减少筛选和排序前的候选集,减少返回字段降低传输与回表成本,游标分页则解决深分页的偏移扫描。性能优化应把一个大问题拆成多个成本来源,而不是期待一个索引解决所有问题。

如果异常时间点与执行计划变化高度重合,优先保存新旧计划,并确认数据量、统计信息和参数是否发生变化。不要先通过删除索引或强制计划进行不可逆操作,先判断新计划为什么被优化器选中。
这通常指向过滤条件、索引设计、数据类型或查询写法问题。先检查是否对索引列做了函数处理、发生隐式类型转换、使用了无法有效利用索引的前置模糊匹配,或者联合索引顺序与高频查询不匹配。
但不要只看 SQL 结构。还要检查业务数据是否严重倾斜:某个租户、某个状态或某个时间段可能占据绝大多数记录。数据分布改变后,原来合理的索引也可能失去选择性。
锁等待场景的第一目标是恢复业务,不是立即重写所有 SQL。应先找到阻塞事务,确认它为什么没有提交,以及终止或回滚它是否会带来更大风险。
资源瓶颈下不能只优化最慢的一条 SQL。需要先做负载画像,确认是少数重查询占用资源,还是大量中等查询共同叠加。如果是少数重查询,应优先优化高资源消耗语句;如果是调用次数过多,则需要减少重复查询、增加缓存或调整调用策略。
扩大机器可以快速获得缓冲,但它不是免费的永久方案。硬件扩容适合业务增长已经被确认、优化空间有限且需要快速恢复容量的场景;如果根因是无效查询、批任务与在线业务争抢资源,扩容可能只会延后下一次故障。
连接池等待通常需要同时检查应用代码和数据库端会话。重点确认连接是否在异常分支中没有及时归还、事务是否包住了不必要的远程调用、连接池最大值是否被多个应用实例叠加放大,以及数据库端是否有足够资源承受当前连接数。
扩容连接池只能在数据库仍有余量时使用。如果数据库已经因为并发查询变慢,继续增加连接可能让上下文切换、锁竞争和 IO 等待更严重。连接池参数应该根据实例数量、单实例并发、SQL 平均执行时间和数据库可承受并发进行压测,而不能凭经验设置一个很大的数字。
如果查询本身执行不慢,但结果传输、序列化和应用处理耗时很高,应限制返回字段、限制单页数量,并检查是否存在不必要的全量导出。对于深分页,可以考虑游标分页、按主键范围分页或离线生成文件。
游标分页的代价是接口状态和排序规则更复杂,用户不能随意跳到任意页;离线导出的代价是结果存在延迟,不适合强实时场景。因此,方案选择要服从业务体验,而不是只看数据库耗时。

新增索引最直观,也最容易被接受,但索引会占用磁盘空间,并在插入、更新和删除时增加维护工作。对于写入密集型表,索引越多,写入放大越明显;对于低选择性字段,索引还可能无法带来足够的读取收益。
我建议用“收益除以代价”评估索引,而不是问“能不能建”。收益包括执行次数、扫描量下降、P99 改善和超时减少;代价包括索引空间、写入延迟、发布风险、备份时间和未来维护成本。
减少字段、改写分页、拆分复杂查询,往往比盲目加索引更有长期价值,但 SQL 改写可能改变空值处理、排序稳定性、重复行处理和分页边界。尤其是报表和财务类查询,性能变快不能牺牲结果正确性。
每次改写都应该建立结果集对照:选择代表性参数,比较记录数量、主键集合、排序顺序和边界日期。对于无法完全对照的异步任务,则需要通过业务抽样和总额校验确认结果。
缓存可以降低数据库读取压力,适合读多写少、允许短时间延迟的场景。但库存、余额、订单状态等数据对实时性要求较高,缓存失效、并发更新和回源风暴都会引入新的问题。
如果决定使用缓存,复盘中必须记录缓存命中率、失效策略、回源并发、数据最大允许延迟和故障降级方式。只写“增加缓存”而不说明一致性边界,等于把数据库问题转移成了业务数据问题。
读写分离可以把部分查询流量转移到只读节点,但读请求可能读到延迟数据。对于刚刚写入后必须立即读取的流程,需要明确读主库、等待复制追平,或者接受短暂不一致。
读写分离还可能让排查变得复杂:应用端看到的是从库耗时,数据库管理员检查的却是主库指标。如果没有在日志中记录实际访问实例,慢查询会被错误归类。
扩容适合容量不足和增长趋势明确的场景,能够快速缓解 CPU、内存或 IO 压力;优化 SQL 适合资源浪费明显、执行路径不合理的场景,长期收益通常更稳定。
在真实事故中,两者经常需要并行:先扩容或限流止血,再通过执行计划和调用画像完成根因治理。重要的是在复盘中区分“临时缓解动作”和“永久修复动作”,避免把扩容写成根因解决方案。
| 方案 | 短期收益 | 长期代价 | 更适合的场景 |
|---|---|---|---|
| 增加索引 | 读取路径可能快速改善 | 写入放大、空间增加、变更风险 | 高频读取、过滤选择性高、计划证据充分 |
| SQL 改写 | 减少扫描、排序或返回数据 | 需要验证结果正确性和兼容性 | 查询结构存在明显浪费 |
| 扩大连接池 | 缓解应用侧排队 | 可能放大数据库并发压力 | 数据库有余量且连接池确实是瓶颈 |
| 数据库扩容 | 快速增加资源缓冲 | 成本增加,无法修复无效查询 | 业务增长和资源瓶颈已被证实 |
| 缓存或读写分离 | 降低主库读取压力 | 一致性、同步和故障处理复杂 | 读多写少且允许明确的数据延迟 |

MySQL 排查中通常会关注执行计划中的访问类型、候选索引、实际使用索引、估算行数、过滤比例和额外操作。对于支持实际执行分析的版本,还应观察真实执行行数与计划估算之间的差异。
慢查询日志、性能摘要、活动连接、锁等待和事务状态需要结合使用。不能只依赖慢查询日志,因为慢查询日志可以告诉你“哪些语句超过阈值”,却不一定告诉你“它为什么在当时变慢”。
PostgreSQL 的执行计划通常需要关注顺序扫描、索引扫描、位图扫描、连接方式、估算行数、实际行数、排序和并行执行。统计信息、表膨胀、自动清理和版本特性也可能影响计划选择。
对 PostgreSQL 而言,执行计划中的估算与实际差异非常值得关注。一个估算几十行、实际几百万行的节点,往往会导致后续连接方式和内存分配出现连锁错误。
不同数据库在执行计划、锁模型、统计信息、计划缓存、等待事件和监控视图上都有自己的实现。通用方法可以保持一致:确认现象、采集样本、分析计划、排除等待、验证修复;但具体命令、字段含义和风险必须由对应引擎的官方文档与版本说明确认。
跨数据库迁移或混合数据库环境中,最容易犯的错误是把“索引、事务、隔离级别、全表扫描”当成完全相同的概念。即使 SQL 语法接近,优化器行为和锁等待表现也可能不同。
面向团队分享诊断脚本时,我会在脚本开头写明数据库类型、版本、只读要求和执行风险。这样做看似保守,却能避免新人把测试命令直接复制到生产环境,尤其是那些会真实执行 SQL 或扫描大表的命令。
— 示例记录模板,不代表所有数据库均支持相同字段
SELECT
query_id,
query_text,
execution_count,
total_duration_ms,
average_duration_ms,
rows_returned,
rows_scanned,
first_seen_at,
last_seen_at
FROM query_performance_summary
WHERE last_seen_at >= '2026-09-01'
ORDER BY total_duration_ms DESC;上面的查询是抽象化示例,实际系统中的监控表、字段名和统计口径需要按数据库产品或监控平台调整。文章中的代码可以帮助读者理解需要采集什么,但不能替代版本相关的诊断手册。
复盘时间线不是简单罗列“发现问题、修复问题”。每个节点都应该绑定监控、日志或操作记录,例如 10:05 P99 开始上升,10:12 发现连接池等待,10:25 定位到某批处理占用连接,10:40 回滚任务,10:48 指标恢复。
时间线的价值在于判断因果关系。如果数据库指标在应用发布前已经异常,那么发布可能只是放大因素;如果发布后 SQL 调用次数突然增加,应用变更的嫌疑就更高。
根因是直接造成性能异常的机制,例如某 SQL 在大租户参数下扫描大量数据;诱因可能是数据规模增长、统计信息未更新或新功能上线;放大因素则可能是没有慢查询告警、连接池配置过大或没有限流。
把三者分开写,能够避免复盘只停留在“加了索引,所以解决了”。真正成熟的结论应该说明:为什么这条 SQL 在现在变慢,为什么过去没有暴露,为什么监控没有提前发现,以及怎样防止同类问题再次出现。
| 复盘维度 | 必须回答的问题 | 建议证据 |
|---|---|---|
| 用户影响 | 哪些接口、任务或用户受到影响 | 请求量、错误率、超时率、业务订单数 |
| 性能变化 | 优化前后是否真正变快 | 平均耗时、P95、P99、最大耗时 |
| 执行变化 | 数据库少做了哪些工作 | 扫描行数、排序量、回表次数、执行计划 |
| 资源变化 | 是否把压力转移到其他环节 | CPU、IO、连接数、锁等待、缓存命中率 |
| 长期治理 | 怎样提前发现下一次问题 | 告警、基线、压测、评审规则和巡检记录 |
好的复盘不是为了证明某个工程师做得很快,而是为了让下一位值班同事可以沿着同样的路径找到问题。建议最终沉淀出慢查询样本、执行计划对比、判断分支、修复动作、回滚方案和验证结果。
如果复盘文档只有一句“增加索引后恢复”,它对团队的帮助非常有限;如果文档能说明“连接池等待占总耗时 62%,数据库执行只占 16%,最终通过修复连接未归还问题恢复”,后续遇到相似症状时,团队就能少走很多弯路。

先保护现场并确认影响范围,记录接口 P95、P99、超时率、请求量和数据库实例。随后拆分连接获取、SQL 执行、锁等待和结果处理耗时。若数据库执行时间没有同步上升,优先排查连接池、网络和应用线程池。
先比较历史执行计划和当前执行计划,再观察扫描行数、估算行数、返回行数和排序操作。如果表数据量或数据分布变化明显,检查统计信息和联合索引选择性。
如果 SQL 本身无法继续优化,应考虑预聚合、分区、异步计算或查询副本,但需要把数据延迟、一致性、存储和运维成本写入方案评估。
建立参数分组画像,至少区分小租户和大租户、短时间范围和长时间范围、热门状态和低频状态。对每组参数分别采集执行计划和耗时,避免用整体平均值掩盖数据倾斜。
参数敏感问题可能需要查询改写、分支执行、计划治理、数据拆分或不同的索引策略。不要因为某一组参数很快,就认定整个 SQL 模板健康。
高峰期问题通常需要将执行路径和并发资源放在一起看。除了慢查询,还要检查锁等待、活跃会话、连接池、CPU、IO、缓存和批处理任务。
如果低并发测试始终正常,单纯修改 SQL 可能无法解决并发争抢。此时应通过压测复现,确认系统在什么并发水平出现排队,并决定采用限流、分片、读写分离、批任务错峰或资源隔离。
报表查询通常扫描范围大、排序和聚合成本高,不适合与核心交易请求共享同一资源池。优先考虑只读副本、汇总表、离线数仓、预计算或错峰调度。
如果业务必须实时查询,则需要限制查询范围、限制并发、设置超时和资源上限。报表“必须实时”不代表可以无限制扫描在线交易表。
升级和发布都要做前后对照,不能只确认功能测试通过。需要比较高频 SQL 模板、调用次数、参数分布、执行计划、连接池、事务范围和数据库参数。
对于关键查询,应在发布前保存基线,并准备回滚或计划固定方案。升级后的性能验证应覆盖真实数据规模和代表性参数,而不是只用小数据量测试库。
第一类是时间基线,包括平均耗时、P95、P99 和超时率;第二类是工作量基线,包括执行次数、扫描行数、返回行数和排序量;第三类是资源基线,包括 CPU、IO、缓存和连接;第四类是计划基线,包括关键 SQL 的访问路径和估算规模。
没有基线时,团队只能在告警后凭感觉判断“是不是变慢了”。有了基线,就可以发现耗时尚未超时但已经持续恶化的趋势。
一条后台报表查询超过 2 秒可能很正常,一条支付确认查询超过 500 毫秒却可能已经影响业务。因此,阈值应该结合 SQL 类型、调用场景、执行频率和业务优先级设置。
建议同时设置绝对阈值和相对基线阈值。例如,某高频接口 P99 超过 800 毫秒,或者较过去七天同一时段上升超过 100%,都可以触发进一步调查。
只有一个“慢查询数量”指标不够。监控至少要能够按照 SQL 模板、实例、参数范围、时间段和业务接口进行聚合,并尽可能关联锁等待、连接池、资源和发布事件。
如果监控系统无法关联应用请求和数据库会话,排查时就会在“接口慢”和“SQL 慢”之间来回猜测。可观测性建设的目标不是采集更多指标,而是减少从现象到判断所需要的时间。

用一句话说明发生了什么,例如:“9 月 12 日 14:05 至 14:37,订单列表接口 P99 从 1.1 秒升至 6.8 秒,主要影响大租户查询,超时率最高达到 3.2%。”
事件描述必须同时包含时间、对象、指标和影响范围,避免只写“数据库性能异常”这种无法用于后续检索的表述。
这样的证据链比“优化连接池后恢复”更可靠,因为它解释了判断过程,并且能够让其他人复核结论。
根因应该描述机制,而不是描述动作。 “没有索引”是表象,“过滤条件无法有效缩小候选集,导致高频查询在大表上扫描大量记录”才是更完整的根因。 “连接池太小”也不够准确,应该进一步说明是连接归还不及时、事务范围过长,还是并发模型与池大小不匹配。
修复项包括已经完成的动作,例如 SQL 改写、索引调整、事务缩短、连接释放修复和任务错峰;预防项则包括慢查询告警、执行计划基线、长事务监控、发布前压测和索引评审。
每一项预防措施都要有负责人、完成时间和验收指标,否则它仍然只是复盘文档中的愿望,而不是团队流程的一部分。
定位查询速度慢,最值得建立的能力不是记住更多索引技巧,而是能够在信息不完整时保持判断纪律:先固定时间窗口,先拆分链路耗时,先保存现场,再用执行计划、扫描量、等待事件和资源指标建立证据。
我对慢查询复盘的核心判断可以浓缩为三句话:
下一步可以从团队里最常见的一条高频 SQL 开始,建立一份最小化基线:记录 SQL 模板、典型参数、平均耗时、P95、P99、扫描行数、返回行数、执行计划和资源占用。然后每次发布、数据增长或索引变更后,重新对比这些指标。
当团队能够回答“它什么时候开始慢、慢在执行还是等待、哪些参数最危险、改动减少了哪一部分成本、上线后是否在高峰期仍然稳定”,数据库性能优化就不再依赖某个 DBA 的临场经验,而会变成一套可以复用、验证和持续改进的工程流程。
我以前遇到过一个订单接口从平均 180 毫秒升到 1.8 秒的情况,团队一开始直接盯着 SQL 和索引,连续改了两版都没有明显改善。后来我把接口耗时拆开,才发现数据库真正执行 SQL 只用了 240 毫秒,剩下的时间主要耗在连接池等待和锁等待上。遇到类似问题时,我应该怎样判断慢点到底在哪里?
我的判断是:不要一看到接口变慢就打开执行计划,先把一次请求的完整耗时拆开。所谓“查询慢”,可能指数据库执行慢,也可能指等待数据库连接、等待锁、网络传输或应用处理结果慢。如果没有先确认时间花在哪里,后面的 SQL 优化很容易变成无效劳动。
我通常会先建立一条最小化的耗时链路,至少记录连接池获取连接、SQL 发出、数据库返回、结果集读取和业务处理这几个时间点。
一次接口请求可以拆成下面几类耗时: 耗时环节典型表现优先检查对象 连接池等待数据库 CPU 不高,但接口并发上升后整体变慢连接池大小、活跃连接数、连接获取耗时 锁等待SQL 执行计划没有明显变化,部分请求突然卡住长事务、未提交事务、阻塞会话 SQL 执行数据库执行耗时本身明显升高执行计划、扫描行数、排序和连接操作 结果传输返回数据量大,数据库执行结束后接口仍然缓慢返回字段、结果集大小、网络和序列化耗时 在一次脱敏复盘中,接口监控显示 P95 从 420 毫秒升到 2.1 秒,但数据库慢查询日志中的 SQL 平均执行时间只有 300 多毫秒。
继续查看链路后发现,连接池最大连接数为 50,高峰期活跃请求超过 200 个,连接获取等待一度达到 1.3 秒。此时增加索引并不能解决根因,反而可能增加数据库写入压力。我会用下面这个判断顺序缩小范围: 先对比应用端接口耗时和数据库端 SQL 执行耗时。
如果二者差距明显,优先检查连接池、锁等待、网络和结果处理。如果数据库执行耗时确实占主要部分,再查看执行计划和扫描规模。如果只有 P99 变慢而平均值稳定,重点排查锁、资源争用和特定参数,而不是立即重写所有 SQL。
一个实用的经验是:数据库 CPU 高、扫描行数大、SQL 执行时间长,才更像执行路径问题;数据库 CPU 不高但请求大量排队,则更像等待问题。先做耗时归因,再做 SQL 优化,通常比“看到慢就加索引”更快找到真正原因。
我曾经优化过一条带有用户编号和时间范围条件的订单查询,执行计划显示已经使用联合索引,但线上 P95 仍然超过 1 秒。团队当时认为“用了索引就说明索引没问题”,后来我对比了估算行数、实际扫描行数和排序过程,才发现索引虽然被使用,却扫描了大量低选择性数据。
分析执行计划时,哪些字段比“是否使用索引”更重要?
“使用索引”只是一个访问路径描述,不是性能结论。真正需要判断的是:索引过滤掉了多少数据、为了得到最终结果扫描了多少行、是否发生了大量回表、排序和临时处理。一个被使用但选择性很差的索引,可能只是比全表扫描稍微好一点,并不代表查询已经高效。
我看执行计划时,通常不会只盯着一个 type、access method 或 index 字段,而是按“估算规模,实际规模,额外操作”的顺序检查: 观察项需要问的问题风险信号 访问路径查询是否从正确的表和索引开始?大表被放在不合理的连接起点 估算行数优化器预计要处理多少行?
估算值明显偏小或偏大 实际扫描行数为了返回少量结果,实际检查了多少行?扫描 100 万行只返回几十行 排序与临时结果排序能否利用索引顺序完成?出现大范围排序或临时表 回表与覆盖情况过滤后是否还要读取大量数据页?
索引命中但随机 IO 很高 例如,一条演示查询如下:
SELECT order_id, status, created_at FROM orders WHERE tenant_id = 1001 AND status = 1 ORDER BY created_at DESC LIMIT 20;假设索引已经包含 tenant_id、status 和 created_at,但 tenant_id 下有 80% 的订单都是 status 为 1,索引的过滤能力就可能并不理想。
如果执行计划显示估算扫描 300 行,实际扫描却达到 120 万行,问题可能不在“有没有索引”,而在统计信息失真、数据分布变化、索引顺序不匹配或排序无法被索引消化。我做过的一次对比中,优化前查询确实显示使用索引,但扫描约 120 万行,P95 为 1.6 秒;
调整查询条件和索引顺序后,扫描行数降到约 320 行,P95 降到 90 毫秒。这里的示例数据用于说明诊断方法,不能直接套用到所有数据库。
指标调整前调整后 平均耗时850 ms45 ms P951.6 s90 ms 扫描行数约 120 万约 320 返回行数2020 因此,我建议把“用了索引吗”改成三个更有价值的问题:索引是否减少了扫描规模,索引顺序是否符合过滤和排序需求,执行计划中的估算是否接近实际。
只有这三个问题都得到合理答案,才有理由认为索引真正发挥了作用。
我排查过一类很容易误判的问题:同一条 SQL、同一套索引,执行计划前后几乎完全一致,但业务方反馈高峰期偶尔超时。最初大家怀疑缓存失效,后来通过等待事件和事务监控发现,真正原因是一个批量更新事务长时间未提交。像这种“计划没变但查询变慢”的情况,应该如何建立排查顺序?
如果执行计划稳定,但耗时只在某些时间段或某些请求上突然升高,我会把锁等待、资源争用和并发排队放在索引问题之前。因为执行计划只能说明数据库打算怎样执行,不能说明执行过程中是否需要等待其他事务,也不能说明当时 CPU、磁盘或连接池是否已经拥堵。这类问题最容易被平均值掩盖。
比如一条 SQL 平时执行 30 毫秒,99% 的请求都正常,但少量请求在等待锁时达到 8 秒,平均耗时可能仍然只有 110 毫秒。只看平均值,就会误以为这条 SQL 只是偶尔有网络抖动。
现象更值得优先怀疑的原因验证方向 CPU 不高,单次请求突然卡住锁等待或长事务阻塞会话、事务开始时间、等待事件 高峰期所有接口都变慢连接池、CPU、IO 或数据库并发限制活跃连接、队列长度、系统资源 只有大范围查询变慢磁盘 IO、排序、临时空间IO 延迟、临时文件、排序量 特定参数偶发变慢数据分布差异或参数敏感不同参数下的实际执行统计 我通常按四步排查。
第一步,确认慢请求是否集中在同一时间窗口;第二步,查看数据库当时是否存在长事务、锁等待或阻塞链;第三步,对比 CPU、磁盘 IO、内存、连接数和临时空间;第四步,检查应用连接池是否出现获取连接排队。一个典型场景是批量任务先更新大量订单,再进行复杂查询。
批量事务虽然没有改变查询的执行计划,但它持有的锁会让查询在执行过程中等待。此时如果直接重写 SQL,可能只会让查询在拿到锁之后执行得更快,却无法解决前面的等待时间。我会把总耗时拆成“等待耗时 + 实际执行耗时”。
如果实际执行只有 40 毫秒,等待却有 2 秒,就不应该把主要精力放在索引和 SQL 语法上。修复方向可能是缩短事务、拆分批量操作、调整提交策略、减少锁覆盖范围,或者改变读写任务的调度时间。
需要注意的是,不同数据库引擎查看锁和等待的命令、视图以及字段名称并不相同,不能把某一种数据库的诊断语句直接复制到另一种数据库。通用框架可以统一,但具体命令必须结合数据库类型和版本核对。
我以前见过一次“优化成功”的复盘:开发环境中查询从 700 毫秒降到 40 毫秒,但上线后高峰期 P99 仍然超过 1 秒。后来发现测试只执行了一次,数据量、并发量和缓存状态都与生产差异很大。数据库查询优化应该比较哪些指标,才能避免只凭一次执行时间下结论?
性能优化的完成标准不是“这次执行变快了”,而是相同业务条件下,耗时分布、扫描规模、资源消耗和错误率都得到可解释的改善。单次执行特别容易受到缓存、并发、数据分布和后台任务影响,因此只能作为观察,不应作为最终结论。我会先保存优化前的基线,再进行单变量修改。
基线至少包括平均耗时、P95、P99、扫描行数、返回行数、执行次数、CPU、IO、锁等待和超时率。对比时尽量使用相同的参数范围、相近的数据量和相同的并发条件。
指标为什么要看常见误判 平均耗时观察整体水平掩盖少量极慢请求 P95/P99观察用户实际感受到的尾部延迟采样量太少导致分位数不稳定 扫描行数判断执行路径是否真正缩小了处理规模只看耗时,不看数据处理量 CPU 与 IO确认性能改善是否转移了资源成本查询变快但数据库整体负载升高 错误率与超时率确认业务体验是否改善只验证数据库,不验证接口结果 我常用的验证过程是:先在相同参数下执行多轮测试,再更换小、中、大三类数据范围,最后放到接近真实并发的环境观察。
第一轮主要排除明显的语法和结果问题,第二轮观察数据分布变化,第三轮确认高峰期是否出现锁、连接池或资源瓶颈。例如,一条查询优化前平均耗时 850 毫秒,P95 为 1.6 秒,扫描约 120 万行;优化后平均耗时 45 毫秒,P95 为 90 毫秒,扫描行数降到 320 行。
如果只是平均耗时下降,但 P99 仍然很高,就说明尾部问题可能还在,例如锁等待、特定参数或高峰期资源争用。除了性能指标,还必须验证业务结果。涉及分页、排序、时间范围和状态过滤的 SQL,不能因为执行更快就忽略数据一致性。
尤其是把 offset 分页改成游标分页后,要检查是否出现重复记录、漏记录和排序不稳定。最后,我会把优化结果写进复盘表:问题表现、根因证据、修改内容、回滚方案、优化前后指标和后续监控。这样下一次查询再次变慢时,团队可以判断是数据量增长、执行计划变化,还是锁和资源问题,而不必重新从“先加索引”开始猜。


读者评论
文章把“接口慢”和“SQL慢”区分开很重要,连接池等待、锁阻塞和结果处理确实容易被忽略。用链路耗时拆分来定位,比较适合线上排查。
关于平均值与P95、P99的对比很有参考价值,尤其是高并发场景。不同参数集的测试也提醒了不能只拿一条“快查询”判断整体性能。
复盘流程从现象、证据到回归验证,逻辑比较完整。不过实际落地还需要统一日志时间口径,并提前确定监控和基线采集方式。