慢 SQL 很少只是“少建了一个索引”这么简单。相同的查询在数据量增长、统计信息过期、参数分布变化或并发上升后,可能从索引扫描变成全表扫描,也可能因为排序、锁等待或连接池排队而表现为响应时间变长。
因此,调优的目标不是让某条 SQL 在一次测试中变快,而是建立一条可复核的证据链:
- 确认慢在哪里,是数据库执行慢、等待慢,还是应用排队慢。
- 保存问题发生时的 SQL、参数、执行计划和资源指标。
- 只修改一个主要变量,并在接近真实数据分布的环境验证。
- 观察修改后的稳定性,准备明确的回滚方案。
本文使用订单查询作为例子。SQL 和配置以 PostgreSQL 风格为主;如果实际使用的是 KingbaseES、MySQL 或其他兼容数据库,EXPLAIN 选项、统计视图、索引类型和参数名称可能不同,执行前应核对目标版本文档。
先定位瓶颈
一次请求的耗时可以粗略拆成应用排队、网络传输、连接获取、锁等待和 SQL 执行。只看接口总耗时,无法直接证明数据库执行计划有问题。
在应用侧至少记录以下字段:
- SQL 的归一化指纹,而不是把完整敏感参数写入日志。
- 请求开始时间、数据库执行时间、连接池等待时间。
- 返回行数、异常类型和事务标识。
- trace id 或 request id,便于关联数据库日志。
数据库侧则要关注调用次数、平均耗时、尾延迟、扫描行数、临时文件、锁等待和缓存命中情况。平均耗时下降而 P99 上升,通常不能视为调优成功。
在 PostgreSQL 中,可以使用统计扩展观察聚合后的 SQL。以下查询依赖 pg_stat_statements,必须先按环境规范启用扩展;没有权限或未启用时,不应直接把这段语句当作通用能力:
SELECT
calls,
total_exec_time,
mean_exec_time,
rows,
shared_blks_read,
shared_blks_hit,
query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY total_exec_time DESC
LIMIT 20;
这一步只能帮助筛选候选 SQL。统计视图中的数据是聚合结果,不能替代某次具体请求的执行计划,也不能单凭总耗时判断单次调用是否异常。
用执行计划验证假设
先使用不实际执行的计划检查器,确认优化器预计采用什么路径:
EXPLAIN (COSTS, VERBOSE)
SELECT order_id, customer_id, created_at, amount
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
AND created_at >= TIMESTAMP '2026-08-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;
当确认语句可以在受控环境执行后,再使用实际计划:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT order_id, customer_id, created_at, amount
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
AND created_at >= TIMESTAMP '2026-08-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;
ANALYZE 会真正执行 SQL。对于 SELECT 可以在只读事务中操作,但仍应注意函数、副作用和资源消耗;对 UPDATE、DELETE 不要直接在生产环境随意使用。必要时先在脱敏的生产规模副本上验证。
阅读计划时重点比较三组数字:
cost是优化器估算成本,不是毫秒数,适合比较计划路径,不能直接当作性能承诺。rows与实际返回行数的差异反映估算偏差。偏差很大时,索引选择、连接顺序和内存分配都可能失真。actual time、循环次数和BUFFERS用于判断时间消耗在哪里。大量磁盘读、排序落盘或某个节点循环次数异常,通常比整张计划树的表面复杂度更值得关注。
如果是连接查询,要从最耗时且实际处理行数最多的节点向上追踪。不要只盯着“Seq Scan”这个词:小表顺序扫描可能是合理选择;反过来,存在索引也不代表优化器一定应该使用它。
索引设计要围绕访问路径
针对上面的过滤和排序,可以考虑复合索引:
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);
这里的列顺序来自访问模式:先按租户和状态缩小范围,再利用时间列完成范围过滤或排序。这个设计不是所有查询的通用答案,是否有效取决于选择性、数据分布、查询比例和写入成本。
创建索引前要检查:
- 等值条件、范围条件和排序条件分别是什么。
- 查询是否经常只按
tenant_id查询,是否需要兼顾其他路径。 - 表的写入量、索引维护成本和磁盘空间是否可接受。
- 现有索引是否已经覆盖相同前缀,避免重复建设。
- 线上创建索引的并发行为、失败处理和锁影响是否符合目标数据库版本。
CREATE INDEX CONCURRENTLY 是 PostgreSQL 的特定语法,不能未经修改地套用到其他数据库。即使数据库支持在线建索引,也要先确认失败后是否会留下需要清理的无效对象,并在低峰期观察锁和资源。
索引也可能因为表达式不匹配而失效。例如:
-- 可能导致索引难以直接利用,具体取决于列类型和优化器
WHERE CAST(customer_id AS TEXT) = '10001'
更稳妥的做法是让参数类型与列类型一致:
WHERE customer_id = 10001
如果业务确实需要按表达式检索,可以评估表达式索引,但必须用真实查询计划验证,而不是根据语句表面推断。
处理分页、排序和返回列
OFFSET 分页在页码变大后,数据库往往仍需扫描并丢弃前面的记录:
SELECT order_id, created_at, amount
FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC, order_id DESC
LIMIT 50 OFFSET 50000;
对于按时间顺序浏览的场景,可以改为基于游标的分页。游标包含上一页最后一条记录的排序键:
SELECT order_id, created_at, amount
FROM orders
WHERE tenant_id = 42
AND (created_at, order_id) < (TIMESTAMP '2026-08-08 12:00:00', 987654)
ORDER BY created_at DESC, order_id DESC
LIMIT 50;
这要求排序键能够稳定确定顺序,并且客户端正确保存游标。若 created_at 可能重复,只使用时间列会出现重复或漏数据,因此示例增加了唯一性更强的 order_id。
另一个常见问题是无边界返回列。生产查询不应习惯性使用 SELECT *,因为新增列会扩大网络传输和回表成本,也会让覆盖索引更难维护。只选择页面实际需要的字段,通常更容易控制资源消耗。
统计信息与参数分布
优化器依赖统计信息估算过滤结果。如果表经历大量插入、删除或批量更新,统计信息可能不能代表当前分布。更新统计信息前,应先确认目标数据库的维护机制和锁影响;以 PostgreSQL 为例,可以在受控条件下执行:
ANALYZE orders;
若某个列的数据分布非常倾斜,例如少数租户占据绝大多数订单,默认统计粒度可能不足。可以针对列调整统计目标,但这会增加分析和计划成本,不能把数值越大越好当作规律:
ALTER TABLE orders
ALTER COLUMN tenant_id SET STATISTICS 500;
ANALYZE orders;
参数化查询还可能遇到“不同参数适合不同计划”的问题。小租户和大租户的数据量差异明显时,一个固定计划未必同时适合两者。此时应结合数据库版本、预编译策略和实际计划进行判断,不能仅凭一次参数测试下结论。可行的工程方案包括拆分查询路径、限制参数范围、调整统计信息,或在确认风险后改变预编译策略。
建立可回滚的调优流程
推荐把一次调优拆成以下步骤:
- 记录基线:保存归一化 SQL、典型参数分位数、调用量、平均耗时、P95/P99 和资源指标。
- 复现问题:使用与线上接近的数据规模和参数分布,避免只用空表或极小样本。
- 提出单一假设:例如“排序落盘导致尾延迟上升”,而不是同时修改索引、内存和连接池。
- 做计划对比:保存修改前后的执行计划,核对实际行数、扫描块、排序和连接节点。
- 小范围发布:先选择低风险实例、只读副本或少量流量,设定停止条件。
- 观察稳定性:覆盖高峰和低峰,观察至少一个完整业务周期,具体时长由流量周期决定。
- 记录回滚:索引名称、配置原值、变更时间、负责人和撤销命令都应可追溯。
例如,可以把索引变更写成迁移脚本,并将验证和回滚动作放在变更记录中:
-- 变更前确认:检查是否存在同名对象及近似索引
-- 变更动作:按目标数据库支持的在线方式创建
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);
-- 回滚示例,仅在确认该索引由本次变更创建且无其他依赖时执行
-- DROP INDEX CONCURRENTLY IF EXISTS idx_orders_tenant_status_created;
不要为了让计划“看起来更好”直接关闭顺序扫描、强制索引或大幅调整全局内存参数。这样的改动可能只对一个查询有效,却损害其他 SQL;若必须使用会话级实验参数,也应限定在测试连接中,并保留前后计划。
常见问题
有索引,为什么还是全表扫描?
可能是表很小、过滤条件选择性低、统计信息过期、表达式或类型转换阻断索引使用,也可能是全表扫描的成本确实更低。应查看实际计划和数据分布,而不是只检查索引目录。
EXPLAIN 显示成本降低,就一定变快吗?
不一定。成本是优化器内部估算值,依赖统计信息和成本参数。最终应比较实际耗时、尾延迟、磁盘读、缓存命中和并发下的资源争用。
建了复合索引后写入变慢怎么办?
每个索引都会增加写入维护、存储和缓存压力。应检查索引使用率和重复索引,结合读写比例决定保留、重建或删除;删除前要确认没有其他业务依赖,并设置观察窗口。
只在测试环境快,线上仍然慢怎么办?
优先检查数据规模、参数分布、统计信息、并发度、缓存状态和锁等待是否一致。测试环境没有模拟这些条件时,结果只能证明语句在测试条件下可行。
总结
慢 SQL 调优的核心是证据而不是经验口诀。先把总耗时拆成数据库执行、等待和应用排队,再通过实际执行计划定位高成本节点;索引设计要匹配过滤、排序和数据分布;分页、类型转换、统计信息和参数差异同样可能决定最终效果。
一次合格的优化还应包含基线、单变量验证、小范围发布和可执行回滚。只有当性能收益在真实参数与并发条件下持续存在,同时没有引入不可接受的写入、存储和运维成本,才能把“某次查询变快”认定为一次可维护的性能改进。