数据库存:架构师复盘框架:系统重构如何定位查询速度慢
系统重构后,接口从几十毫秒变成数秒,最容易出现的判断是:“数据库有慢查询,赶紧加索引。”但我在性能复盘中反复遇到一个相反结论:真正拖慢接口的,往往不是某一条 SQL 突然变差,而是查询次数、事务边界、连接池等待和数据返回量发生了变化。如果只盯着慢查询日志,可能会把一个调用链问题误判成索引问题,最后索引加了,写入变慢,接口尾延迟却没有改善。
本文不把慢查询定位简化成“查看日志、执行 EXPLAIN、添加索引”三步,而是从系统重构复盘的角度,建立一条完整证据链:先确认接口究竟慢在哪里,再把请求关联到 SQL,区分执行慢、等待慢和调用次数过多,最后通过对照实验验证修复是否真的有效。
一个接口的总耗时,通常由多个阶段组成:网关转发、应用线程排队、获取数据库连接、SQL 执行、锁等待、结果传输、ORM 映射以及业务计算。数据库监控看到的 SQL 执行时间,只覆盖其中一部分。
例如,一个接口总耗时为 2.4 秒,数据库中某条 SQL 的执行时间只有 80 毫秒,但应用线程在连接池中等待了 1.7 秒,那么“优化 SQL”并不是第一优先级。相反,如果接口只执行了一次 SQL,但扫描了数百万行,才应该优先研究访问路径和索引。
我的第一条判断原则是:先拆分总耗时,再决定是否进入 SQL 优化。没有链路拆分之前,任何“数据库就是根因”的结论都只能算猜测。
| 耗时阶段 | 常见表现 | 首要证据 | 优先动作 |
|---|---|---|---|
| 应用线程排队 | 请求数上升时接口突然变慢,数据库负载不一定高 | 线程池队列长度、线程状态、应用 P95 | 检查线程池、下游阻塞和同步调用 |
| 连接池等待 | 数据库连接数接近上限,SQL 本身执行时间正常 | 连接获取耗时、活跃连接数、等待连接数 | 排查连接泄漏、事务过长和连接池配置 |
| SQL 执行 | 单条查询扫描行数大、CPU 或 IO 升高 | 慢查询、执行计划、实际扫描行数 | 优化谓词、索引、连接和返回列 |
| 锁等待 | 高峰期更明显,偶发超时,数据库 CPU 可能并不高 | 锁等待、阻塞事务、事务持续时间 | 缩小事务范围,统一更新顺序 |
| 结果传输与映射 | 数据库执行不慢,但接口返回数据大、应用 CPU 高 | 返回字节数、结果集行数、序列化耗时 | 分页、字段裁剪、聚合和压缩 |

慢查询排查中,一个很容易被忽略的指标是调用次数。一条单次耗时 1 秒、每小时执行 10 次的报表 SQL,和一条单次耗时 40 毫秒、每秒执行 2000 次的查询,对在线系统的影响完全不同。
我通常会把 SQL 按“总耗时贡献”重新排序,而不是简单按照单次平均耗时排序。一个 SQL 模板的总耗时可以粗略理解为:单次执行耗时乘以执行次数。对于高并发接口,还要额外关注 P95、P99 和超时次数,因为平均值会掩盖极端参数和锁等待。
| SQL模板 | 单次平均耗时 | 每秒调用次数 | 每秒累计耗时 | 优先级判断 |
|---|---|---|---|---|
| 订单详情查询 | 900毫秒 | 2次 | 1.8秒 | 检查扫描量和执行计划 |
| 用户标签查询 | 35毫秒 | 180次 | 6.3秒 | 优先排查N+1或重复查询 |
| 库存状态查询 | 80毫秒 | 40次 | 3.2秒 | 检查批量化和缓存命中率 |
| 运营报表聚合 | 2.8秒 | 0.2次 | 0.56秒 | 与在线接口隔离,未必优先改索引 |
如果只看“最慢的一条 SQL”,很可能会错过真正消耗数据库资源最多的查询。这也是为什么我在复盘表中会单独记录 SQL 模板、调用次数、总耗时和尾延迟,而不是只记录一条慢查询样本。
执行计划可以告诉我们优化器打算如何访问数据,但它不能直接说明线上实际发生了什么。一个计划显示使用了索引,并不代表扫描行数足够少;一个计划显示全表扫描,也不一定代表有问题,因为小表全表扫描可能比走低选择性索引更快。
我会把执行计划放在四类信息中一起看:估算行数、实际扫描行数、实际返回行数和等待状态。若估算行数与实际行数相差很大,统计信息、数据分布或参数敏感性就值得怀疑;若执行时间增长主要来自锁等待,继续改索引通常不会解决根因。
系统重构常常从代码层面开始:拆分服务、替换 ORM、重写数据访问层、调整缓存、扩展查询字段或重新设计事务边界。开发人员看到的是模块变得更清晰,数据库看到的却可能是查询次数增加、连接持有时间变长和返回数据量扩大。
例如,重构前的订单接口可能先查订单,再通过一次关联查询获得用户和商品信息;重构后为了“职责分离”,订单、用户和商品被拆成三个 Repository。业务代码在循环中分别调用它们,最终形成一条订单查询加几十条关联查询。
单条关联 SQL 可能只有十几毫秒,但当一次请求返回 50 条订单时,数据库往返次数会快速放大。高并发下,连接池、网络和对象映射同时承压,接口 P99 就可能从几十毫秒滑向数秒。
以九数云这类面向企业数据分析、报表和多源数据整合的平台为例,查询场景和典型交易系统不完全相同。用户可能同时筛选时间、组织、渠道、产品和客户等维度,系统还要完成多表关联、聚合、排序和图表数据返回。
在这种场景中,不能简单套用“给查询字段加索引”的交易型思路。一个分析查询变慢,可能是筛选范围扩大、聚合层级增加、数据源刷新、宽表字段增多,或者多个图表在同一次页面加载中分别发起查询。
以下案例是我按照这类平台常见架构整理的情景复盘,数据为模拟值,用来展示定位方法,不代表九数云内部系统或真实客户的统计结果。案例的价值不在于某个品牌,而在于说明:当数据分析页面变慢时,应该如何把页面体验拆成可验证的技术证据。
| 观察项 | 重构前 | 重构后 | 初步含义 |
|---|---|---|---|
| 首屏接口数量 | 4个 | 11个 | 前端组件拆分后,查询并发数增加 |
| 单次请求SQL数量 | 6条 | 24条 | 可能出现重复查询或查询粒度过细 |
| 查询返回行数 | 1.8万行 | 7.4万行 | 聚合前数据被过早传输到应用层 |
| 页面P95 | 1.2秒 | 4.6秒 | 用户感知明显变差 |
| 数据库CPU利用率 | 58% | 67% | 资源升高,但没有高到足以直接证明CPU是唯一根因 |
| 连接池等待占比 | 3% | 29% | 连接竞争可能比单条SQL变慢更值得优先处理 |

数据库 CPU 没有达到 90%,并不能排除数据库相关问题。锁等待、连接池耗尽、磁盘延迟、网络传输和事务持有时间,都可能让接口变慢,却不会让 CPU 持续打满。
相反,数据库 CPU 很高,也不意味着应该立即扩容。高 CPU 可能由重复查询、错误的分页方式、低选择性索引或某个批处理任务引起。扩容只能把问题推迟,不能修复访问模式。
我在复盘时会把“资源是否高”和“资源是否被错误使用”分开记录。前者是容量问题,后者是设计问题。两者都需要处理,但处理顺序不同。
接口延迟是用户看到的结果,不是根因。一个请求可能在应用线程池中排队 500 毫秒,获取连接等待 800 毫秒,真正的 SQL 执行只用了 60 毫秒。此时即使把 SQL 优化到 20 毫秒,接口也不会从 1.36 秒恢复到几十毫秒。
正确做法是先建立时间预算。把一个请求的耗时记录成多个区间,并在同一条 Trace 中关联请求、连接获取、SQL 执行和结果处理。没有时间区间,就没有根因定位。
加索引是有效工具,但不是默认答案。索引是否有收益,至少取决于过滤条件的选择性、联合索引顺序、排序方式、返回列数量、数据分布和写入比例。
例如,对状态字段建立单列索引,在“成功”记录占总数据 98% 的表中,可能无法有效减少扫描量。索引还会增加插入、更新和删除成本。如果业务是高频写入系统,盲目新增多个索引,可能让写延迟和存储成本一起上升。
| 现象 | 直接加索引的风险 | 应该先验证什么 |
|---|---|---|
| 过滤字段选择性低 | 索引扫描范围仍然很大,收益有限 | 不同取值的分布、实际扫描行数 |
| 排序和分页慢 | 索引方向或列顺序不匹配 | 过滤、排序、分页的组合关系 |
| 查询被锁阻塞 | 索引变化无法消除锁等待 | 阻塞事务、锁类型、事务持续时间 |
| 写入占比高 | 索引维护增加写放大 | 写入QPS、更新列、索引维护成本 |
| 参数分布差异大 | 优化器可能为不同参数选择不同计划 | 小范围与大范围参数下的实际计划 |
平均值适合观察整体趋势,但不适合判断线上体验。一次接口平均耗时从 900 毫秒降到 300 毫秒,看起来改善明显;如果 P99 仍然是 8 秒,用户依旧会在高峰期遇到卡顿。
我更关注 P95、P99、超时率和最差参数分布。尤其是多租户、报表和搜索场景,不同租户的数据量可能相差几十倍。小租户的平均值很漂亮,并不能代表大租户的真实体验。
测试环境通常缺少线上数据规模、真实参数分布和并发竞争。一个只有 10 万行的测试表,可能让全表扫描看起来毫无问题;到了生产环境,数据量达到数亿行,同样的 SQL 就会出现明显延迟。
此外,测试环境往往只有少数请求,线上却存在缓存失效、锁竞争、连接池排队和后台任务争抢资源。性能验证必须尽量复制线上数据规模和参数分布,而不是只在客户端点几次执行。
“走索引”只说明访问路径的一部分。索引可能返回大量候选记录,之后仍需要回表、排序和过滤;也可能因为覆盖列不足,产生大量随机 IO。
我会重点比较估算行数与实际行数。如果优化器估算返回 100 行,实际扫描了 500 万行,计划中的“使用索引”就不具有足够的解释力。此时应该进一步检查统计信息、数据倾斜和参数敏感性。
临时改 SQL 可以恢复服务,但不能保证同类问题不再出现。系统重构后如果没有建立 SQL 数量、慢查询比例、连接池等待和大结果集监控,下一次代码变更仍可能重复引入问题。
一次完整复盘至少要留下三类成果:可回滚的修复、可量化的验证结果、能提前发现问题的监控或规范。只有这样,排查经验才会从个人记忆变成团队能力。

没有基线,就无法判断“变慢”到底发生在何时、哪个版本和哪类请求。基线不应只记录平均响应时间,至少要包括吞吐量、P50、P95、P99、超时率、数据库 QPS、连接池等待和资源使用率。
如果重构前没有完整数据,可以从访问日志、监控平台、数据库审计日志和用户反馈中拼接出近似基线,并明确标注数据可信度。复盘不要求所有历史数据完美,但必须区分“观测事实”和“团队推断”。
| 基线维度 | 建议记录内容 | 为什么重要 |
|---|---|---|
| 用户体验 | P50、P95、P99、超时率 | 识别普遍变慢还是少数极端请求变慢 |
| 请求规模 | QPS、并发数、峰值时段 | 判断是否由流量或并发变化触发 |
| 数据库访问 | SQL QPS、慢查询数量、平均耗时 | 判断是查询质量变差还是访问量增加 |
| 连接资源 | 活跃连接、空闲连接、等待连接时间 | 识别连接池和事务持有问题 |
| 资源压力 | CPU、IOPS、磁盘延迟、内存 | 区分容量瓶颈和访问模式问题 |
基线的价值不只是记录数字,而是帮助我们建立可比较的实验条件。如果重构前后的流量、缓存状态和数据规模完全不同,单纯比较两个响应时间没有意义。
我会在应用链路中至少增加三个时间点:开始获取连接、成功获取连接、数据库执行完成、结果处理完成。这样可以把“数据库慢”拆成连接等待、SQL 执行和应用处理三个部分。
对于已有链路追踪的系统,可以将数据库调用作为 Span,并记录 SQL 指纹、数据库实例、表或业务模块、返回行数和错误类型。生产环境不建议默认记录完整参数,应该对敏感字段脱敏,并采用采样策略控制日志成本。
请求总耗时
├── 应用排队耗时
├── 获取数据库连接耗时
├── SQL执行耗时
│ ├── 排队等待
│ ├── 锁等待
│ └── 实际执行
├── 结果集传输耗时
└── 业务处理与序列化耗时
如果暂时没有完整链路,也可以先通过应用日志记录 SQL 模板、执行开始时间、执行结束时间和请求标识,再与数据库慢查询日志按时间窗口和 SQL 指纹进行关联。这个方法不如分布式追踪精确,但通常足以发现查询次数暴增和连接池等待。

这是系统重构中最有价值的一步。很多 N+1 查询问题不会让某一条 SQL 看起来特别慢,因为每条 SQL 都能在几十毫秒内完成;真正的问题是一次请求执行了几十甚至几百条 SQL。
我会为每个请求计算三个值:SQL 总数、不同 SQL 模板数、重复模板比例。若一次请求执行 80 条 SQL,其中 70 条属于同一个模板,只是参数不同,就应该优先考虑批量查询、预加载或缓存,而不是先为每个字段添加索引。
| 请求类型 | SQL总数 | 重复模板比例 | 更可能的根因 | 优先方案 |
|---|---|---|---|---|
| 订单列表 | 3条 | 0% | 单条聚合或分页查询慢 | 执行计划、索引和分页策略 |
| 订单详情批量页 | 86条 | 81% | N+1或循环加载 | 批量查询和关联数据预加载 |
| 经营分析首页 | 24条 | 17% | 多个图表并发查询 | 查询合并、缓存和预聚合 |
| 导出任务 | 7条 | 14% | 大范围扫描和结果集过大 | 异步任务、分片读取和专用读模型 |
我会把数据库相关问题分为三类。第一类是执行慢,表现为扫描数据量大、排序聚合成本高、磁盘读取多;第二类是等待慢,表现为锁等待、连接等待、线程资源不足;第三类是返回慢,表现为 SQL 已经执行完成,但结果集太大,传输和序列化占用了大量时间。
三类问题的修复方向完全不同。执行慢可能需要改变 SQL 或索引;等待慢可能需要缩短事务和调整并发;返回慢则需要减少字段、限制行数或改变数据接口。把三类问题混在一起,是优化无效的常见原因。

以 MySQL 为例,常规执行计划可以使用如下命令查看访问路径;如果版本支持运行时分析,还应尽量结合实际执行信息,而不是只看静态估算。
EXPLAIN SELECT order_id, customer_id, order_status, created_at FROM orders WHERE tenant_id = 1024 AND order_status = 'PAID' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 50;
我通常重点看以下内容:访问类型、候选索引、实际使用索引、估算行数、过滤比例、连接顺序、额外排序以及临时表。如果是 PostgreSQL,则应关注 EXPLAIN ANALYZE 中的实际行数、计划行数、节点耗时和 Buffers;不同数据库的字段名称不同,但判断逻辑相近。
执行计划分析不能脱离参数。对于同一条 SQL,查询一个小租户和查询一个大型租户,可能得到完全不同的执行成本。建议至少选择小数据量、中位数据量和大数据量三类参数进行对比。
系统重构经常扩大事务范围。原来只有一次写入的事务,可能因为服务编排而包住多个查询、远程调用和业务计算。数据库连接在整个事务结束前都无法归还连接池,最终表现为连接池等待和接口尾延迟上升。
判断事务问题时,我会同时看事务持续时间、连接占用时间、锁等待和回滚率。如果事务平均只执行 30 毫秒,但连接占用时间达到 600 毫秒,说明连接可能在 SQL 执行外被长时间持有。
连接池也不能简单通过“把最大连接数调大”解决。连接数扩大后,数据库并发执行压力可能更高,锁竞争和上下文切换反而加剧。连接池配置必须和数据库承载能力、应用实例数量及 SQL 并发模型一起评估。
假设某企业经营分析页面接入订单、客户、商品和渠道等数据。重构前,页面由服务端一次性准备主要数据;重构后,为了让组件独立加载,首屏拆成多个图表接口,每个图表分别查询自己的数据。
这种设计在代码结构上更灵活,却带来一个容易忽视的变化:页面打开一次,数据库不再面对少量聚合查询,而是面对多个并发查询。部分图表还会根据筛选条件再次刷新,用户改变一次日期范围,可能触发十几条查询。
为了便于说明,下面数据采用情景模拟。实际排查时,应使用应用链路、数据库监控和真实流量压测结果替换这些数字。
| 阶段 | 页面请求数 | 数据库查询数 | 平均查询耗时 | 页面P95 |
|---|---|---|---|---|
| 重构前 | 4个 | 6条 | 48毫秒 | 1.2秒 |
| 重构后初版 | 11个 | 24条 | 71毫秒 | 4.6秒 |
| 批量查询后 | 8个 | 12条 | 54毫秒 | 2.1秒 |
| 预聚合与缓存后 | 6个 | 7条 | 39毫秒 | 1.3秒 |
这里最值得注意的不是单条查询平均耗时,而是页面请求数、数据库查询数和页面尾延迟同步变化。重构后平均查询耗时只从 48 毫秒升到 71 毫秒,增长并不夸张;但查询数量从 6 条增加到 24 条,连接竞争和结果处理成本被放大,最终让用户感知到明显卡顿。

排查初期,团队从慢查询日志中找到几条平均耗时 70 毫秒左右的查询。按照常见阈值,这些 SQL 不一定会被认定为严重慢查询,因此最初没有引起足够重视。
但将请求日志与 SQL 指纹关联后发现,页面一次加载会重复执行同一类查询。某个图表需要客户标签,另一个图表也需要客户标签,两个模块各自查询,没有共享结果;筛选器变化时,部分查询又被重复触发。
最终,问题从“某条 SQL 太慢”变成了“同一请求中 SQL 调用过多且重复率过高”。这类问题的关键指标不是慢查询阈值,而是每请求 SQL 数量、重复模板比例和数据库往返次数。
重构后的部分接口先从数据库取出较多明细数据,再在应用层进行分组和汇总。这样做可以复用一套业务代码,但会把数据库本来擅长完成的过滤、聚合和排序转移到应用层。
在模拟数据中,数据库返回行数从 1.8 万行增加到 7.4 万行,最终页面实际只展示几十个聚合结果。应用需要接收、解析、转换和序列化大量中间数据,数据库连接也会被占用更长时间。
我的判断是:如果用户只需要汇总结果,却把明细数据跨网络传到应用层,性能问题往往不是索引缺失,而是计算位置不合理。这时应考虑在数据库侧聚合、建立汇总表、构建读模型或使用适合分析场景的预计算机制。

当首屏同时发起多个接口时,每个接口都可能从连接池申请连接。如果应用实例数量较多,即使单实例连接数没有超过上限,整个数据库实例也可能出现连接数堆积。
案例中,连接池等待占比从 3% 上升到 29%。这意味着部分请求甚至还没有开始执行 SQL,就已经在等待可用连接。此时直接增加数据库索引不会减少连接等待,盲目扩大连接池也可能让数据库同时处理更多竞争请求。
更合理的顺序是:先统计应用实例总连接上限,再观察实际活跃连接、事务连接占用时间和 SQL 并发,之后决定是减少查询数量、缩短事务、限制并发,还是调整连接池上限。
批量查询和预聚合让页面 P95 恢复,并不代表所有问题都结束了。批量查询可能增加单条 SQL 的返回结果,预聚合可能增加数据刷新成本,缓存还可能引入数据时效性和一致性问题。
因此,我会同时观察读取性能、写入性能、刷新耗时、缓存命中率、数据库资源和数据准确性。尤其是分析平台,用户更关心的不只是“快”,还关心筛选后的数字是否及时、口径是否一致。
| 验证维度 | 优化前 | 优化后 | 验收判断 |
|---|---|---|---|
| 页面P95 | 4.6秒 | 1.3秒 | 用户体验显著改善 |
| 每请求SQL数量 | 24条 | 7条 | 调用方式得到优化 |
| 连接池等待占比 | 29% | 5% | 连接竞争基本缓解 |
| 数据刷新耗时 | 18分钟 | 24分钟 | 读取提速伴随刷新成本上升,需要继续评估 |
| 缓存命中率 | 41% | 78% | 重复计算得到复用 |
| 数据口径异常数 | 0次 | 2次 | 缓存和预聚合上线前必须补充一致性校验 |

当单条 SQL 占据主要耗时,且实际扫描行数、排序量或聚合量明显偏高,可以按以下顺序排查。
如果查询属于在线交易路径,优先控制扫描行数和返回行数;如果属于报表或分析路径,不要只追求单条 SQL 极限速度,还要评估刷新、并发和资源隔离。
当单条 SQL 耗时正常,但每请求 SQL 数量异常,优先检查业务代码和数据访问层,而不是索引。
批量查询并不意味着一次性把所有数据取出来。批量接口仍需设置数量上限、超时、分页和参数校验,否则只是把很多小问题合并成一条超大查询。
连接池等待通常与三个问题有关:连接数量不足、连接归还不及时、数据库执行时间过长。排查时不要只看最大连接数,而要看连接生命周期。
如果连接占用时间远大于 SQL 执行时间,优先缩短连接持有范围;如果 SQL 执行本身很慢,则需要先处理数据库访问路径,否则单纯增加连接只会放大资源争抢。
锁等待问题通常表现为高峰期突发、平均值不一定明显、P99 和超时率却快速恶化。数据库 CPU 可能正常,甚至偏低,因为大量请求处于等待状态。
索引有时可以减少锁定范围,但不能替代事务设计。特别是更新条件不准确、事务范围过大时,建立新索引只是降低部分扫描成本,根因仍然存在。
当数据库执行时间不高,但返回行数、响应体和应用 CPU 同时上升,应把注意力转移到数据接口设计。
对于九数云这类多维分析、报表和数据整合场景,建议把问题拆成四条链:数据刷新链、查询计算链、页面请求链和结果呈现链。每条链都有自己的瓶颈,不能仅通过数据库慢查询日志覆盖全部过程。
| 链路 | 重点指标 | 常见问题 | 可选方案 |
|---|---|---|---|
| 数据刷新链 | 刷新耗时、失败率、增量比例 | 全量刷新、重复计算、源端限流 | 增量同步、分层存储、任务拆分 |
| 查询计算链 | 扫描行数、聚合耗时、并发查询数 | 宽表扫描、聚合过晚、查询重复 | 预聚合、汇总表、查询合并 |
| 页面请求链 | 接口数量、并发请求、P95/P99 | 图表各自取数、重复刷新、瀑布式请求 | 接口编排、请求合并、结果复用 |
| 结果呈现链 | 响应字节数、渲染耗时、浏览器内存 | 返回明细过多、前端重复计算 | 服务端聚合、分页、虚拟化渲染 |

加索引通常上线快、回滚相对直接,适合访问条件明确、数据选择性较高、写入压力可控的场景。但索引越多,写入维护成本越高,表结构变更和空间管理也更复杂。
改数据模型、建立汇总表或读模型,往往能获得更稳定的读取性能,但会增加数据同步、刷新、校验和运维成本。它适合读取模式稳定、查询聚合复杂、在线访问量较大的场景。
| 方案 | 短期收益 | 长期成本 | 适用边界 |
|---|---|---|---|
| 新增索引 | 实施快,单查询可能明显提速 | 增加写入、存储和维护成本 | 过滤选择性高、查询模式稳定 |
| 改写SQL | 可能快速减少扫描和返回数据 | 需要回归多个参数和业务分支 | SQL逻辑存在明显冗余或返回过宽 |
| 批量查询 | 减少数据库往返和N+1 | 需要处理参数数量和结果映射 | 关联数据可一次性获取 |
| 预聚合 | 分析查询延迟稳定 | 增加刷新、口径和一致性治理 | 指标口径稳定、读取频率高 |
| 缓存 | 降低重复读取压力 | 需要处理失效、穿透和数据时效 | 数据变化频率低、重复访问高 |
| 专用读模型 | 可针对查询模式深度优化 | 增加同步链路和存储成本 | 读写模型差异大、查询复杂 |
缓存可以显著降低重复查询,但它不是免费的性能。缓存命中时速度很快,缓存失效时可能产生并发回源;缓存中的数据如果没有明确刷新策略,还可能导致用户看到旧数据。
对于经营分析页面,应先确认指标的时效要求。实时库存、实时支付状态和风险控制通常不能简单使用长时间缓存;日报、周报和经营趋势则更适合采用预计算或短周期缓存。
我建议在方案评审中明确写出三个数字:允许的数据延迟、缓存有效期和缓存失效后的最大回源并发。只写“增加缓存”而不写这三个约束,后续几乎一定会出现一致性或缓存击穿问题。
数据库侧聚合能够减少网络传输和应用内存使用,通常适合结构化的分组、求和、计数和筛选。但复杂业务规则如果全部塞进 SQL,可能降低可读性和可测试性。
应用侧聚合更容易复用业务代码,也方便处理复杂规则,但在数据量大时会产生传输和内存成本。我的判断标准不是“聚合必须放在哪里”,而是比较三项成本:数据移动成本、计算成本和一致性维护成本。
如果用户点击一次就触发数亿行扫描,即使优化索引也可能无法满足交互体验。此时应把问题从“如何让在线请求更快”改成“哪些计算应该提前完成”。
在线接口适合处理范围明确、响应数据有限、延迟目标严格的查询;大范围导出、复杂经营分析和跨周期聚合,更适合异步任务、预计算或专用分析链路。把所有工作都塞进同步接口,是架构层面的性能债务。

好的复盘开头应包含接口、时间、影响范围和用户表现。例如:“某经营分析页面在版本发布后,高峰期 P95 从 1.2 秒升至 4.6 秒,部分大租户出现超过 10 秒的加载延迟;订单写入成功率正常,主要影响查询和页面加载。”
这种描述比“数据库性能下降”更有价值,因为它明确了影响边界,也避免把尚未验证的根因写成事实。
时间线的作用是区分相关性和因果性。版本发布后变慢,不代表版本中的某条 SQL 一定是根因,也可能是发布同时改变了缓存预热、数据刷新或实例数量。
如果判断是索引问题,应提供扫描行数、过滤比例和执行计划变化;如果判断是锁等待,应提供阻塞事务和等待时长;如果判断是 N+1,应提供单请求 SQL 数量和重复模板比例。
同时还要记录反证。例如数据库 CPU 没有持续升高、单条 SQL 执行时间正常,就说明“CPU不足”或“某一条 SQL 极慢”可能不是主要根因。反证能够防止团队在错误方向上继续投入。
| 根因假设 | 支持证据 | 可能反证 | 验证实验 |
|---|---|---|---|
| 缺少联合索引 | 扫描行数大,新增索引后计划和耗时改善 | 锁等待占主要耗时 | 同参数、同并发对比执行计划 |
| N+1查询 | 每请求SQL数量暴增,重复模板比例高 | 只有一条SQL且结果集很大 | 批量查询替换后比较SQL数量和P99 |
| 连接池不足 | 获取连接耗时高,活跃连接接近上限 | 连接等待低但SQL执行很慢 | 缩短事务或限制并发后观察等待占比 |
| 锁等待 | 阻塞链清晰,事务持续时间长 | 低峰期同样执行慢且无阻塞 | 缩小事务范围并对比锁等待时长 |
| 结果集过大 | 返回行数和响应体明显增加 | 数据库扫描量本身异常大 | 裁剪字段和服务端聚合后比较传输耗时 |
一次有效的性能实验,至少要固定数据库版本、数据规模、查询参数、缓存状态和并发模型。若只能在生产环境验证,应采用影子流量、小比例灰度或只读副本,避免直接把未经验证的索引和 SQL 改动推向全部用户。
优化前后的对比也要覆盖副作用。新增索引可能降低写入性能,预聚合可能延长数据刷新,缓存可能带来短暂旧数据,批量查询可能增加单次返回量。只有把收益和代价放在同一张表中,决策才完整。

如果接口已经大面积超时,第一目标是恢复服务,而不是当场完成架构优化。可以根据业务影响采取限流、降级、关闭非核心图表、缩小默认查询范围、暂停重型报表任务或临时切换只读数据源等措施。
止血期间要保留证据。至少记录故障开始时间、版本、请求量、SQL模板、连接池状态、锁等待和资源曲线。没有证据的紧急操作,事后很容易只能凭记忆争论。
恢复服务后,不应立即把临时措施当成最终方案。建议在较短时间内完成版本差异、调用次数、耗时分布、数据库状态和执行计划的对照。
如果团队没有足够的观测数据,应把“缺少哪些数据”本身列为复盘结论。例如没有记录连接获取时间,就无法确认连接池是否是瓶颈;没有 SQL 指纹,就无法判断查询次数是否暴增。
修复方案应尽量拆成小变更。先解决最确定、收益最大的访问方式问题,再评估索引、缓存和数据模型调整。每次变更只改变一个主要变量,才能知道收益来自哪里。
系统重构评审不能只审模块边界和代码可维护性,还要审数据访问行为。每次拆分服务或 Repository,都应回答:一次请求会新增多少数据库往返?事务范围是否变化?查询结果集是否扩大?缓存和数据刷新是否需要重新设计?
对于高频接口,可以建立查询预算。例如规定一次列表请求默认不超过若干条 SQL、默认返回行数不超过某个范围、单接口连接等待不能超过总体延迟预算的一定比例。预算不是绝对规则,但能让性能风险在上线前被讨论。
慢查询日志适合发现执行时间超过阈值的 SQL,但阈值必须结合业务。在线接口可能需要几十毫秒级监控,后台报表可以接受更高阈值。不要把一个固定阈值当成所有业务的“慢”定义。
日志分析时,建议按 SQL 指纹聚合,而不是逐行阅读。重点排序维度包括平均耗时、P95、调用次数、总耗时贡献和错误次数。
执行计划适合验证访问路径,但要结合实际参数和运行时状态。对于复杂查询,应保存优化前后的计划文本,并标注数据库版本、统计信息更新时间和测试参数,否则后续无法判断计划变化的原因。
数据库监控能够告诉你 SQL 慢,却未必能告诉你哪个接口、哪个代码路径和哪个用户动作触发了它。链路追踪把 SQL 与请求关联起来,特别适合定位重构后的重复调用、跨服务查询和页面多接口并发。
线程池、连接池、GC、序列化和网络指标,是判断接口慢因的重要补充。若数据库执行时间只占总耗时很小一部分,就不应该继续把排查资源全部投入数据库。
| 工具或数据 | 能回答的问题 | 不能单独回答的问题 |
|---|---|---|
| 慢查询日志 | 哪些SQL超过阈值 | 来自哪个接口,是否因调用次数增加 |
| 执行计划 | 访问路径和估算成本 | 真实锁等待和高并发尾延迟 |
| 链路追踪 | 请求与SQL的调用关系 | 索引是否适合所有参数分布 |
| 连接池监控 | 连接是否等待、占用多久 | SQL本身的访问路径是否合理 |
| 数据库资源监控 | CPU、IO、连接和锁状态 | 哪段业务代码制造了重复查询 |

我更愿意把数据库性能问题定义为:一次业务请求为了完成目标,迫使系统完成了多少无效或重复的数据库工作。这比单纯比较某条 SQL 的执行时间更接近架构问题。
如果一条 SQL 从 100 毫秒优化到 50 毫秒,但每个请求仍然执行 100 次,系统的总体压力依然很大。相反,将 20 次 30 毫秒的查询合并为一次 80 毫秒的批量查询,通常更能改善整体吞吐和尾延迟。
模块拆分、接口拆分和 Repository 拆分可以改善代码组织,但它们也可能增加数据库往返、网络跳数和事务协作。架构设计不能只看“代码是否解耦”,还要看“数据是否被重复读取”。
我建议每次涉及数据访问的重构,都补充一张前后对照表,至少记录每请求 SQL 数量、最大返回行数、事务持续时间、连接占用时间和高峰期 P99。它不需要很复杂,但必须在设计阶段就出现,而不是故障后才补。
一次优化把 P99 从 8 秒降到 2 秒,当然是收益;但如果团队仍不知道为什么变慢、哪些参数最危险、什么时候会触发连接池耗尽,那么系统只是暂时恢复,并没有真正获得稳定性。
真正成熟的结果应该是:任何一次查询速度异常,都能沿着固定路径找到请求、SQL、参数、执行计划、等待状态和版本差异;任何一次修复,都能用相同指标进行对照;任何一次重构,都能提前评估数据库工作量变化。
如果你现在正面对一个变慢的接口,不要从加索引开始。先完成下面四件事:
如果结果显示是单条 SQL 扫描过多,再讨论索引和 SQL 改写;如果结果显示是查询次数暴增,先处理批量化和调用方式;如果结果显示是连接或锁等待,就回到事务、并发和资源治理;如果结果显示是结果集过大,就重新设计接口和计算位置。
系统重构后的慢查询定位,核心不是找到一条“最慢的 SQL”,而是解释一次请求为什么让数据库做了过多、过久或不必要的工作。这条判断一旦建立起来,排查就不再依赖个人经验,优化也不再停留在“试着加个索引看看”。
我在一次订单查询服务重构后遇到过类似问题:接口平均耗时只从180毫秒升到240毫秒,但P99却从620毫秒飙到4.1秒。团队一开始都认为是数据库索引失效,后来发现真正的问题是连接池等待和重复查询叠加。我想知道,面对这种情况,怎样建立一套不靠猜测的定位顺序?
第一步不要直接打开执行计划,而是先确认“慢”发生在哪个时间分段。接口耗时是一个结果,不是根因;它可能包含网关转发、应用线程排队、获取数据库连接、锁等待、SQL执行、结果传输和对象映射等多个阶段。
我通常先把一次请求拆成下面这条链路,并要求每一段都能拿到耗时数据: 阶段需要观察的指标典型异常 应用线程线程池队列、活跃线程数线程排队但数据库并不忙 连接池获取连接耗时、等待线程数连接池耗尽或连接未及时归还 数据库等待锁等待、IO等待、活跃会话SQL尚未执行就已经排队 SQL执行执行耗时、扫描行数、返回行数访问路径不合理或数据量过大 结果处理网络传输、反序列化、对象映射查询本身不慢,但返回数据过宽 然后再看P50、P95和P99,而不是只看平均耗时。
平均值很容易掩盖尾部请求:少数大租户、宽时间范围或锁冲突请求,往往正是用户感知最强、系统最容易超时的部分。我的判断顺序是“先分层,再关联,再归因”。只有确认数据库阶段占据了主要耗时,才进入慢查询、SQL指纹和执行计划分析;否则直接加索引,往往是在修复一个并不存在的数据库问题。
我曾经排查过一个重构后的详情接口,监控里没有特别突出的慢SQL,单条查询大多只有20到40毫秒,但接口在高峰期仍然经常超过2秒。后来我发现一次请求触发了几十条结构相似的SQL,怀疑是数据访问层引入了N+1查询。请问应该用哪些证据确认这个判断?
判断方法不是盯着最慢的一条SQL,而是比较“每次请求的SQL数量、单次耗时和总耗时贡献”。一条每分钟执行两次、耗时2秒的报表SQL,和一条每次只耗时30毫秒、但每秒执行数百次的明细SQL,对线上系统的影响完全不同。我会先在链路追踪或应用数据库埋点中记录SQL模板,而不是直接记录完整参数。
对每个请求统计SQL数量、去重后的模板数量、总数据库耗时和重复调用次数,示例结果如下: 指标重构前重构后判断 单次请求SQL数348明显出现调用放大 单条SQL平均耗时32毫秒29毫秒并非单条SQL显著变慢 请求总数据库耗时96毫秒1392毫秒总耗时由调用次数贡献 重复SQL模板数1至2个40个以上符合循环查询特征 如果订单列表查询后,应用针对每一条订单再次查询用户、商品或状态信息,基本就形成了典型的N+1模式。
它在测试环境中经常不明显,因为测试数据量小、并发低;一旦线上列表页返回几十条记录,数据库往返次数和连接占用会同时放大。修复时优先考虑批量查询、一次性预加载或在数据库侧完成合理关联,而不是先给每一条重复SQL加索引。索引能降低单次成本,却不能消除几十次网络往返和连接调度成本。
验证时应同时看每请求SQL数量、接口P95/P99、数据库QPS和连接池等待时间。
我遇到过一个分页查询,执行计划显示命中了联合索引,开发同事据此认为索引没有问题。但线上大租户查询仍然需要1到3秒,小租户却只有几十毫秒,而且扫描行数远高于最终返回行数。我想知道,看到“使用索引”后还应该继续检查哪些细节?
“使用了索引”只说明数据库选择了一条索引访问路径,不代表扫描范围足够小,也不代表后续排序、回表、锁等待和结果传输没有成本。实际排查中,索引是否存在只是起点,关键是它过滤了多少数据,以及过滤之后还要做多少工作。我通常重点对比估算行数、实际扫描行数、最终返回行数和执行时间。
下面是一组用于说明判断方法的示例数据: 参数场景扫描行数返回行数耗时风险判断 小租户,近7天42008046毫秒访问范围可接受 大租户,近7天6800001001.4秒选择性不足或数据倾斜 大租户,近180天92000001003.2秒时间范围放大扫描成本 接下来要检查联合索引的列顺序是否匹配过滤条件和排序条件,是否存在对索引列做函数处理、隐式类型转换或前导通配符,是否因为返回字段过多而产生大量回表,以及分页是否使用了大OFFSET。
低基数字段即使建立索引,也可能无法有效缩小扫描范围。还要对比不同参数下的执行计划。大租户、热门用户和超长时间范围可能导致数据分布严重倾斜,使同一SQL模板在不同参数下表现完全不同。我的经验是,遇到这种问题,先用实际参数验证扫描量,再决定调整联合索引、改用游标分页、拆分冷热数据,还是限制查询范围;
不要因为计划中出现索引名称就提前结束排查。
我以前做过一次索引优化,测试环境里单次SQL从900毫秒降到70毫秒,上线后接口P99却几乎没有改善,甚至写入延迟略有上升。复盘时才发现测试数据量、参数分布、并发量和缓存状态都与线上不同。我想建立一套更可靠的优化验证标准,避免被单次执行时间误导。
优化验证必须做成对照实验,而不是执行一次SQL后看客户端显示的耗时。至少要控制数据规模、参数分布、并发量、缓存状态、数据库版本和事务隔离级别;否则前后结果不可比,所谓优化可能只是缓存命中或测试数据过小带来的假象。我建议将验证拆成三个阶段。
第一阶段是SQL级验证,确认扫描行数、执行计划、排序和临时空间是否改善;第二阶段是接口级压测,观察P50、P95、P99和超时率;第三阶段是生产灰度,检查数据库资源和其他业务是否受到副作用影响。
指标优化前优化后是否足够 接口P952.8秒420毫秒说明尾延迟改善 接口P995.1秒680毫秒更接近真实用户体验 每请求SQL数364确认调用放大被消除 数据库CPU82%55%资源压力下降 写入延迟35毫秒61毫秒需要评估索引副作用 索引优化尤其要关注写入成本、存储增长和其他查询的计划变化。
只看读查询变快,可能会忽略插入、更新变慢,或者导致优化器在另一类参数下选择了更差的路径。因此灰度时应设置回滚阈值,例如P99持续恶化、写入延迟超过基线、锁等待增加或连接池使用率接近上限。
我最终会把结果沉淀成一张复盘表:故障现象、影响接口、重构变化、SQL证据、数据库状态、根因判断、修复动作、验证数据和长期治理措施。真正有效的优化,不只是让一条SQL变快,而是能在真实流量下稳定降低尾延迟,并且没有把问题转移到写入、锁竞争或连接池。


读者评论
文章把“接口慢”和“SQL慢”区分开来很有价值,尤其是连接池等待、锁等待和应用排队这些环节,确实容易在排查时被忽略。
按单次耗时、调用次数和总耗时贡献排序,比只盯着最慢SQL更符合线上场景。N+1查询在服务拆分后尤其值得重点检查。
关于执行计划的说明比较客观,使用索引不等于一定高效,还要结合实际扫描行数、返回行数和数据分布判断。
分析型页面的案例有参考意义,不过文中数据是情景模拟,实际落地时仍需要依赖链路追踪、数据库监控和压测结果验证。
文章对盲目加索引的风险解释得比较清楚。后续如果能补充不同数据库的监控指标或具体排查命令,操作性会更强。