中级程序员常用的 SQL 性能排查方法

运营教程23小时前发布 jdkjadmin
0 00
中级程序员常用的 SQL 性能排查方法

中级程序员常用的 SQL 性能排查方法

SQL 变慢,未必是“少建了一个索引”。查询可能扫描了过多数据,也可能估算行数失准、排序溢出、锁等待,或被应用重复执行。只盯着 SQL 文本加索引,既容易无效,也可能拖慢写入。

一套可靠的排查过程应当从可观测证据出发:先确认慢在哪里,再检查实际执行计划,最后用可重复的对照测试验证改动。本文以 PostgreSQL 为主讲解,部分原则适用于其他关系型数据库;涉及命令或执行计划格式时,会标明数据库差异。

一、先界定问题:慢的是哪一段

用户感知到的“查询慢”,可能发生在数据库之外。请求耗时通常包含连接池排队、网络往返、数据库执行、结果传输,以及应用端反序列化和后续处理。数据库监控显示查询耗时正常,但接口仍然很慢时,应同时检查应用链路,而不是只改 SQL。

先收集这几项信息:

  • 具体 SQL、参数类型和参数范围。敏感数据应脱敏,不要把密码、令牌或个人信息写进排查记录。
  • 调用频率和并发量。单次耗时 50 毫秒、每秒调用一万次,可能比偶发的两秒查询更值得优先处理。
  • 受影响的时间窗口,以及当时的数据量、并发写入、部署和批处理情况。
  • 数据库 CPU、磁盘 I/O、连接池等待、锁等待和缓存命中等指标。
  • 查询返回行数和实际消费方式。应用只取 20 行,不代表数据库只处理了 20 行。

不要只凭一次手工执行判断问题。冷缓存、并发波动、参数分布和网络状况都会影响耗时。优先比较同一查询在相似参数、数据规模和负载下的多次结果,并保留基线。

二、从慢查询日志和聚合指标找到目标

慢查询日志适合发现超过阈值的单次查询;数据库扩展或监控平台的聚合统计,则适合找出调用频繁、累计耗时高的查询。两者互补:最慢的一次未必最值得先修,频繁执行的小查询也可能占用大量总资源。

在 PostgreSQL 中,pg_stat_statements 扩展可以按规范化后的查询统计调用次数和耗时。启用它通常需要调整实例配置中的 shared_preload_libraries 并重启数据库,然后在目标数据库中执行 CREATE EXTENSION;是否允许启用取决于实例权限和托管服务配置。

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
    queryid,
    calls,
    round(total_exec_time::numeric, 2) AS total_ms,
    round(mean_exec_time::numeric, 2) AS mean_ms,
    rows,
    left(query, 300) AS sample_query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

以上列名适用于较新的 PostgreSQL 版本;旧版本的统计列名称可能不同,应按所用版本文档核对。统计视图记录的是聚合数据,不是每一次调用的完整参数。不同参数值的执行差异也可能被平均值掩盖,因此还要结合日志、应用追踪和真实参数范围分析。

记录参数时要注意安全:优先记录参数类型、取值分布或经过脱敏的样本。日志是否会记录绑定参数,受数据库、驱动和配置影响;不要为了排查而默认打开包含所有敏感参数的详细日志,也不要把生产日志直接复制到公开工单。

三、读懂执行计划:估算行数与实际行数

执行计划回答的是数据库打算怎样执行查询:扫描哪些表或索引、如何连接、是否排序,以及估算要处理多少行。在 PostgreSQL 中,可先使用:

EXPLAIN
SELECT o.id, o.created_at
FROM orders AS o
WHERE o.customer_id = 42
  AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;

EXPLAIN 只展示计划和估算,不执行查询。为了检查实际行数和耗时,可以在可控环境中运行:

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at
FROM orders AS o
WHERE o.customer_id = 42
  AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;

重要边界:EXPLAIN ANALYZE 会真正执行语句。对 SELECT 来说,它可能消耗大量 CPU、I/O,并产生与查询相关的锁等待;对 INSERT、UPDATE、DELETE 等写语句,执行计划分析会实际产生写入效果。不要在生产环境随意对写语句加 ANALYZE。即使在事务中随后回滚,触发器、外部副作用和执行资源消耗也不能一概视为无害。

计划中优先关注以下内容:

  • Seq Scan:顺序扫描本身不是错误。表很小、过滤条件命中大量行时,顺序扫描往往更便宜。要看扫描行数、过滤行数和耗时,不要见到它就立刻建索引。
  • Index Scan、Bitmap Index Scan:说明使用了索引,但不代表一定高效。若仍需回表读取大量行,成本可能不低;索引扫描也可能因为匹配行太多而不划算。
  • rows 与 actual rows:前者是估算,后者是实际执行结果。估算偏差很大时,优化器可能选错连接顺序或扫描方式。
  • loops:节点执行次数。嵌套循环中的内层节点如果被重复执行很多次,即使单次很快,总耗时也可能很高。实际总工作量要结合行数和循环次数理解。
  • Buffers:反映访问的共享缓冲区读写情况。shared read 是从操作系统或存储读取,不必然等于物理磁盘读取;操作系统缓存也可能参与。
  • Sort、Hash:留意排序或哈希是否使用临时文件。数据量、并发和内存配置都会影响结果,不能仅凭一个节点名称判断性能。

执行计划中的成本数字是优化器用于比较方案的估算单位,不是毫秒。判断时应结合实际耗时、实际行数、缓冲区访问和应用侧延迟。计划也可能随参数值、统计信息、数据库版本和系统负载变化。

四、检查过滤条件与索引是否匹配

索引的效果取决于查询条件、数据分布和访问模式。以下查询可以考虑建立复合索引:

CREATE INDEX CONCURRENTLY idx_orders_customer_status_created
ON orders (customer_id, status, created_at DESC);

索引通常有助于按前导列过滤,并在列顺序与查询排序条件合适时减少排序工作。复合索引不是“包含这些列就都能用”:列顺序会影响适用条件;具体能否利用排序、范围条件或索引覆盖,也取决于数据库实现和查询计划。应通过实际计划验证,而不是只套用口诀。

PostgreSQL 的 CREATE INDEX CONCURRENTLY 可以减少建索引期间对普通写入的阻塞,但它并非零影响:仍会消耗 I/O 和资源,在高负载下可能拖慢业务;并且不能放在显式事务块中执行。创建失败时还可能留下无效索引,需要检查并处理。其他数据库的在线建索引语法、限制和锁行为不同,不能直接照搬。

下面这种写法常见于按日期查询:

SELECT id, created_at
FROM orders
WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00'
  AND created_at <  TIMESTAMP '2025-02-01 00:00:00';

将列与边界值直接比较,通常比对列套函数更容易利用普通索引。例如,WHERE date(created_at) = ... 可能无法直接使用仅建在 created_at 上的索引。若确实需要按表达式查询,可以在 PostgreSQL 中评估表达式索引,或将时间范围改写为半开区间;要注意时区和日期边界的业务定义。

参数类型也很关键。若应用把时间、数字或主键以不匹配的类型传入,数据库可能发生隐式转换,影响索引使用或计划选择。检查驱动绑定的类型,以及计划中的条件表达式,不要只看参数显示出来的文本值。

低基数列并非绝对不能建索引,高基数列也不保证索引一定有用。关键是过滤后剩余多少行、数据分布是否倾斜、是否还需要排序或回表,以及查询出现频率。索引会占空间,并增加插入、更新和删除的维护成本;写密集表上盲目加索引可能使整体性能更差。

五、统计信息失准:计划为什么会选错

优化器依赖统计信息估算结果行数。表大量变更、数据分布偏斜,或多个条件之间存在相关性时,估算可能不准确。PostgreSQL 可先更新统计信息:

ANALYZE orders;

自动统计信息收集通常会工作,但它有触发阈值和调度时机;刚导入大量数据后,及时执行 ANALYZE 有助于计划获得较新的统计信息。提高某列统计目标可能让分布估算更细致,但会增加统计信息采集和存储开销,不应对所有列一律调高。

ALTER TABLE orders
ALTER COLUMN customer_id SET STATISTICS 500;

ANALYZE orders;

如果误差主要来自多个条件的相关性,可评估 PostgreSQL 扩展统计信息,例如依赖关系或多列最常见值统计。扩展统计信息并不能覆盖任意复杂关联,也不是所有计划估算误差的通用修复;需要针对具体查询和数据验证。

参数敏感也值得关注:同一条参数化 SQL,热门客户可能对应几十万行,普通客户可能只有几行,适合的计划未必相同。数据库的预备语句机制和计划缓存策略会影响参数化查询的计划行为。出现“某些参数快、某些参数慢”时,应分参数分布查看实际计划,并记录驱动是否使用预备语句;不要用一组参数得出的计划代表全部情况。

六、连接、聚合与排序:检查工作量如何放大

关联查询变慢时,先确认连接条件是否完整、关联列类型是否一致,以及两侧行数估算是否合理。缺少连接条件可能意外产生笛卡尔积;一对多关系若在聚合前放大行数,也会造成大量无效计算或重复结果。

SELECT c.id, COUNT(o.id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id
 AND o.created_at >= TIMESTAMP '2025-01-01 00:00:00'
GROUP BY c.id;

过滤条件放在 ON 还是 WHERE,对外连接语义可能不同。把右表的过滤条件放入 WHERE,常会过滤掉右表为空的行,使结果不再保留所有左表记录。性能改写不能以悄悄改变结果为代价。

排序和聚合的成本与输入行数密切相关。可以检查是否先处理了远超最终需要的数据,是否有不必要的 DISTINCT、重复排序或宽行传递。只选择业务需要的列,通常比 SELECT * 更有利于减少网络传输和应用处理;但它是否改变数据库内部访问路径,还需看索引和执行计划。

深分页是另一类常见问题:

SELECT id, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 500000;

OFFSET 很大时,数据库通常仍需找到并跳过大量结果。若业务允许,可以改用基于上一页最后一条记录的游标分页,并建立与排序顺序相匹配的索引:

SELECT id, created_at
FROM orders
WHERE (created_at, id) < (TIMESTAMP '2025-01-10 12:00:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 50;

游标分页适合顺序翻页,不适合直接跳到任意页。示例依赖排序列和主键组成稳定的全序,且该元组比较写法适用于 PostgreSQL;跨数据库迁移时需核对语法。若排序列可为空,还应明确空值排序规则。数据并发变化时,分页结果是否重复或遗漏也取决于事务隔离和业务对一致性的要求。

七、SQL 没有跑很久,但请求仍然慢:检查锁与连接

查询耗时可能包括等待锁,而不只是执行计算。PostgreSQL 中可结合 pg_stat_activity 和锁视图观察活动会话、等待事件及阻塞关系。查看时要考虑权限:普通用户可能看不到其他会话的完整信息,生产环境应由有权限的值班人员按规范操作。

SELECT
    pid,
    state,
    wait_event_type,
    wait_event,
    now() - query_start AS elapsed,
    left(query, 200) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY query_start;

query_start 表示当前查询或当前事务内语句的起始时间,具体解释需结合 state。会话处于 idle in transaction 时,可能持有未提交事务和锁;它不等于数据库正在执行一条慢 SQL。不要看到“运行时间长”就直接终止会话,先查阻塞链和事务归属,再按变更流程处理。

如果数据库语句耗时正常,但接口等待时间高,检查连接池是否耗尽、连接获取是否排队,以及应用是否对每行结果做额外查询。典型 N+1 问题是先查询一批订单,再逐条查询订单明细;单条 SQL 都不慢,但调用次数和网络往返成倍增加。可考虑批量查询、合适的关联查询,或用应用追踪统计一次请求实际发出的 SQL 数量。

把多个 SQL 合成一个大查询不一定总是更好:它可能增加结果集重复、锁持有时间或数据库端计算。优化目标是减少总工作量和不必要往返,同时保持语义清晰,并用真实负载测量。

八、验证改动:一次只改变一个主要因素

排查需要可复现的对照。可以按以下顺序进行:

  1. 保存原始 SQL、脱敏参数、数据规模、执行计划和耗时基线。
  2. 提出一个可检验的判断,例如“过滤后扫描了大量无关行”或“计划估算明显低于实际行数”。
  3. 只改一个主要变量:更新统计信息、改写条件或增加索引,不要同时改 SQL、索引和数据库参数。
  4. 确认结果集正确,再以相近参数、数据规模和并发条件对比多次执行。
  5. 观察吞吐量、尾延迟、CPU、I/O、锁等待、写入性能和存储增长,而不只看单次查询时间。
  6. 记录回滚方案。新索引、SQL 改写和配置变更都应有明确的撤回方式。

基准测试应尽量接近实际负载。单用户下的毫秒级改善,可能在高并发时变成连接争用或 I/O 压力;反之,计划显示成本升高,也不一定意味着真实请求变慢。测试环境与生产的数据量、硬件、统计信息和参数分布不同,测试结果不能无条件外推。

九、常见错误与边界总结

  • 看到顺序扫描就加索引:小表、低选择性条件或大量结果集都可能让顺序扫描更合算。
  • 只看平均耗时:平均值会掩盖少数极慢请求和参数敏感问题,应结合分位数、调用次数及参数分布。
  • 把计划成本当成毫秒:成本是优化器估算值,不能直接与接口耗时换算。
  • 在生产随意执行 EXPLAIN ANALYZE:它会真实运行语句,写语句尤其可能造成实际数据变更;应先确认语句类型、环境和影响范围。
  • 认为索引越多越好:索引占存储,也会增加写入维护成本。每个索引都应对应明确的查询收益。
  • 用函数包住列后期待普通索引必然生效:表达式索引、查询改写和索引匹配都要结合具体数据库验证。
  • 为了提速改变结果语义:外连接过滤、空值、重复行、时区和排序稳定性都可能影响结果正确性。
  • 忽略数据库版本与参数限制:SQL 语法、计划格式、在线建索引能力和绑定参数上限因产品、版本及驱动而异。不要把某个数据库的限制当成通用 SQL 规则。以 PostgreSQL 为例,协议层绑定参数数量存在实现上限,但驱动、协议模式和版本会影响实际可用边界;大批量条件更适合评估临时表、批量写入或分批处理,而非假定任意长度的 IN 列表都能安全运行。
  • 拼接用户输入构造 SQL:动态值应通过驱动提供的参数绑定传入;参数绑定用于避免把输入当作 SQL 语法执行,但不能代替权限控制、输入校验和业务授权。表名、列名等标识符通常不能用普通值参数绑定,应使用白名单映射或数据库驱动提供的安全标识符引用方式。

SQL 性能排查不是背诵“索引最左匹配”或寻找某个万能配置,而是把应用延迟、数据库计划、数据分布和并发行为串起来验证。先定位主要工作量,再用执行计划和监控找到证据,最后在结果正确、负载相近的条件下比较改动,通常比凭感觉重写查询更快,也更容易避免引入新的问题。

© 版权声明

相关文章