第六篇:《SQL 优化的艺术:从慢查询到毫秒级响应》

在上一篇文章中,我们学会了用 EXPLAIN ANALYZE 读懂执行计划。但"读懂"只是诊断的开始,真正的挑战在于改------为什么这条 SQL 明明有索引却不走?为什么 OFFSET 100000 越翻越慢?为什么加了窗口函数之后查询耗时从秒级变成分钟级?SQL 优化的本质,是理解数据库的执行模型,然后顺着它的规则去写查询,而不是和它对抗。 本文将系统讲解索引失效的典型场景、深度分页的正确解法、子查询与 JOIN 的取舍、以及窗口函数的性能边界,最后通过一个"5 秒到 50 毫秒"的完整优化实战,帮你建立从"能跑"到"跑得快"的工程能力。

一、索引失效:七种最常见的"建了也白建"

索引建好了,查询却依然全表扫描------这是 SQL 优化中最常见的挫败感。以下是 PostgreSQL 中最典型的索引失效场景。

1.1 对索引列做函数运算

sql 复制代码
-- ❌ 索引失效:YEAR() 包裹了索引列
SELECT * FROM users WHERE YEAR(created_at) = 2026;

-- ✅ 索引生效:改写为范围查询
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

原理:B-tree 索引存储的是 created_at 的原始值,而不是 YEAR(created_at) 的结果。优化器无法通过索引快速定位"年份等于 2026"的行。解决方案有两个:改写查询为范围条件,或者创建表达式索引 CREATE INDEX ... ON users (YEAR(created_at))。

1.2 隐式类型转换

sql 复制代码
-- user_id 是 integer,却用字符串比较
-- ❌ 索引可能失效(取决于转换方向)
SELECT * FROM orders WHERE user_id = '12345';

-- ✅ 类型匹配
SELECT * FROM orders WHERE user_id = 12345;

原理:PostgreSQL 的类型转换规则决定了比较操作符的选择。当索引列被转换为其他类型时,索引无法直接使用。排查方法:在 EXPLAIN 中观察是否出现了 Filter 而不是 Index Cond。

1.3 前导通配符的 LIKE

sql 复制代码
-- ❌ 索引失效
SELECT * FROM users WHERE name LIKE '%Alice';

-- ✅ 索引生效(锚定开头)
SELECT * FROM users WHERE name LIKE 'Alice%';

原理:B-tree 按字典序排序,'%Alice' 无法利用有序性定位起始点。如果业务确实需要后缀匹配,可以考虑 pg_trgm 扩展的 GIN 索引,或者将字符串反转后建索引。

1.4 复合索引的最左前缀被破坏

sql 复制代码
-- 索引:(user_id, status, created_at)
-- ❌ 跳过最左列
SELECT * FROM orders WHERE status = 'pending';

-- ❌ 跳过中间列(对 created_at 的索引能力受限)
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2026-01-01';

-- ✅ 完整使用最左前缀
SELECT * FROM orders WHERE user_id = 123 AND status = 'pending' AND created_at > '2026-01-01';

原理:复合 B-tree 索引先按第一列排序,再按第二列排序。跳过最左列意味着索引的整体有序性无法被利用。

1.5 否定条件(NOT、!=、<>)

sql 复制代码
-- ⚠️ 通常不走索引(除非返回行数极少)
SELECT * FROM orders WHERE status != 'completed';

原理:否定条件意味着"大部分行都满足",优化器会判断全表顺序扫描比"走索引 + 大量回表"更划算。如果确实需要优化,可以考虑把否定条件改写为 NOT EXISTS 配合索引,或者用部分索引只索引目标行。

1.6 OR 条件连接不同列

sql 复制代码
-- ⚠️ 可能不走索引
SELECT * FROM orders WHERE user_id = 123 OR status = 'pending';

原理:如果 OR 两侧的列没有各自的可用索引,优化器无法合并结果,只能全表扫描。如果两侧都有索引,PostgreSQL 可以使用 BitmapOr 合并两个索引的位图。

1.7 IS NULL / IS NOT NULL 的边界

B-tree 索引可以支持 IS NULL,但如果 NULL 值占比极高(如 99%),优化器可能仍然选择全表扫描。真正的问题是:在复合索引中,NULL 值的排序位置可能影响最左前缀的可用性。实践建议:如果 NULL 是业务中的高频查询条件,考虑用部分索引 WHERE column IS NULL 精确覆盖。

二、深度分页:OFFSET 的 O(n²) 陷阱与游标分页

2.1 为什么 OFFSET 越深越慢

sql 复制代码
-- ❌ 深度分页:OFFSET 100000 意味着读取并丢弃 100000 行
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;

OFFSET 的实现方式是 "生成所有前面的行,然后把它们扔掉" ------数据库必须扫描并丢弃前 100000 行,才能返回第 100001 到 100020 行。页码越深,丢弃的行越多,总成本呈 O(n²) 增长。有测试显示,在百万级数据集上,游标分页相比 OFFSET 可以获得 17 倍的加速。

2.2 游标分页(Keyset Pagination)

游标分页不问"跳过多少行",而是问 "从上一页最后一行的位置之后开始" :

sql 复制代码
-- 第 1 页
SELECT id, name, created_at FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 第 2 页:用上一页最后一行的 (created_at, id) 作为游标
SELECT id, name, created_at FROM users
WHERE (created_at, id) < ('2026-05-20 14:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

关键设计:排序列必须是有索引的、稳定的。如果 ORDER BY 的列没有索引,每一页都需要全表排序,反而比 OFFSET 更慢。复合游标 (created_at, id) 解决了 created_at 相同值的并列问题,id 作为决胜列保证排序的唯一性。

2.3 混合策略:前几页用 OFFSET,深页用游标

游标分页的局限是不能"跳到第 N 页" 。如果产品需要页码跳转,可以采用混合策略:前 100 页用 OFFSET(成本可接受),深页切换到游标分页。

2.4 并发插入下的分页稳定性

OFFSET 分页在并发插入时会重复或跳过行------新行插入后,原来的第 2 页可能包含第 1 页已经出现过的行。游标分页天然稳定,因为它的锚点是"上一页的最后一行",而不是"跳过的行数"。

三、子查询 vs JOIN vs CTE:选择的原则

3.1 三种写法的语义差异

sql 复制代码
-- 子查询(IN)
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE status = 'active');

-- JOIN
SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id WHERE u.status = 'active';

-- CTE
WITH active_users AS (
    SELECT id FROM users WHERE status = 'active'
)
SELECT * FROM orders WHERE user_id IN (SELECT id FROM active_users);

性能原则:

IN + 子查询适合子查询结果集较小、且子查询列有索引的场景。PostgreSQL 可能会将 IN 优化为 Semi Join(半连接),性能与显式 JOIN 相当。

JOIN 在需要返回多表列、或者需要利用 Hash Join/Merge Join 时更合适。但如果 JOIN 产生了一对多放大(一个订单对多个用户),结果行数会膨胀。

CTE 在 PostgreSQL 12+ 中默认内联(inline)------除非显式标记 MATERIALIZED,否则 CTE 不会成为优化屏障。CTE 的价值在于可读性,而不是性能。

3.2 EXISTS vs IN:NULL 的陷阱

sql 复制代码
-- ❌ 如果 users.status 可能为 NULL,IN 的结果可能不符合预期
SELECT * FROM orders WHERE user_id NOT IN (SELECT id FROM users WHERE status = 'active');

-- ✅ NOT EXISTS 在 NULL 场景下语义更安全
SELECT * FROM orders o WHERE NOT EXISTS (
    SELECT 1 FROM users u WHERE u.id = o.user_id AND u.status = 'active'
);

核心原则:NOT IN 遇到 NULL 会返回空结果集(三值逻辑),而 NOT EXISTS 不会。当子查询可能返回 NULL 时,优先用 NOT EXISTS。

四、窗口函数的性能边界

窗口函数是 PostgreSQL 强大的分析能力,但它有明确的性能特征。

4.1 窗口函数的执行模型

窗口函数在 WindowAgg 节点中执行:先按 PARTITION BY 分组,再按 ORDER BY 排序,最后对每一行计算窗口函数。排序是窗口函数的主要成本------如果没有合适的索引支撑 PARTITION BY 和 ORDER BY,每一行都需要参与排序。

4.2 两个实用的优化方向

方向一:索引覆盖 PARTITION BY + ORDER BY

sql 复制代码
-- 查询:按部门分组,按薪资降序排名
SELECT dept_id, name, salary,
       ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employees;

-- ✅ 为 (dept_id, salary DESC) 建索引,排序可以被索引直接满足
CREATE INDEX idx_emp_dept_salary ON employees (dept_id, salary DESC);

方向二:利用 PostgreSQL 19 的 QUALIFY 下推优化

PostgreSQL 19 实现了 QUALIFY 谓词的优化:只引用 PARTITION BY 列、不涉及窗口函数的条件可以下推到 WHERE,在窗口计算之前过滤行。这意味着:

sql 复制代码
-- 如果 dept_id 的过滤条件不涉及窗口函数
-- 优化器可以在排序之前就过滤掉不需要的分区,减少排序数据量
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
    FROM employees
) t WHERE rn <= 3 AND dept_id = 10;

方向三:单调窗口函数的提前终止

对于 ROW_NUMBER() 配合 ORDER BY 和 LIMIT 的场景,如果窗口函数是单调的,PostgreSQL 19 支持run conditions,允许在满足条件后提前终止分区的处理,而不是计算完整分区再过滤。这直接减少了排序和计算的总量。

4.3 窗口函数的反模式

不要把窗口函数当作"万能的 GROUP BY 替代" 。如果你只需要聚合结果(如每个部门的总薪资),用 GROUP BY 而不是 SUM() OVER (PARTITION BY ...)------GROUP BY 的输出行数更少,后续处理成本更低。窗口函数的正确场景是"在保留明细行的同时附加聚合信息"。

五、实战:从 5 秒到 50 毫秒的完整优化

场景:以下查询在千万级订单表上执行 5.2 秒:

sql 复制代码
SELECT o.id, o.total_amount, u.name,
       ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.created_at DESC) AS rn
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
  AND DATE(o.created_at) = '2026-10-01'
ORDER BY o.created_at DESC
LIMIT 100;

第一步:EXPLAIN (ANALYZE, BUFFERS) 诊断

发现:

Seq Scan on orders,Rows Removed by Filter: 9800000------函数 DATE(created_at) 导致索引失效。

WindowAgg 节点耗时 3.8 秒------在过滤前对全表计算了窗口函数。

Nested Loop 连接 users,但 users 被驱动了数百万次。

第二步:逐项修复

修复 1:消除函数包裹

sql 复制代码
-- 改写 DATE(o.created_at) = '2026-10-01'
o.created_at >= '2026-10-01' AND o.created_at < '2026-10-02'
修复 2:用部分索引精准覆盖

sql
CREATE INDEX idx_orders_pending_created
ON orders (user_id, created_at DESC)
WHERE status = 'pending';

修复 3:把窗口函数的计算推迟到过滤之后

sql 复制代码
-- 先在子查询中完成过滤和 LIMIT,再计算窗口函数
SELECT id, total_amount, user_id, created_at,
       ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM (
    SELECT o.id, o.total_amount, o.user_id, o.created_at
    FROM orders o
    WHERE o.status = 'pending'
      AND o.created_at >= '2026-10-01'
      AND o.created_at < '2026-10-02'
    ORDER BY o.created_at DESC
    LIMIT 100
) filtered
ORDER BY created_at DESC;

修复后的执行计划:

Index Scan using idx_orders_pending_created,Rows Removed by Filter: 0。

WindowAgg 只对 100 行计算,耗时 12 毫秒。

整体查询耗时从 5.2 秒降至 48 毫秒。

六、小结

索引失效的七种场景:函数包裹、隐式类型转换、前导通配符 LIKE、最左前缀被破坏、否定条件、OR 连接不同列、NULL 边界。排查方法是在 EXPLAIN 中确认是否出现 Index Cond。

深度分页:OFFSET 的 O(n²) 成本不可接受,游标分页用"上一页最后一行"作为锚点,实现 O(1) 的稳定性能。游标分页要求排序列有索引,且并发插入下天然稳定。

子查询 vs JOIN vs CTE:IN 适合小子查询,JOIN 适合需要多表列,CTE 的价值在可读性而非性能。NOT IN 遇 NULL 返回空,NOT EXISTS 更安全。

窗口函数:排序是主要成本,索引覆盖 PARTITION BY + ORDER BY 是关键;PostgreSQL 19 的 QUALIFY 下推和单调函数提前终止进一步降低了计算量。

优化实战:消除函数包裹 → 部分索引精准覆盖 → 把窗口函数推迟到过滤之后,5.2 秒降至 48 毫秒。

相关推荐
ShineWinsu2 小时前
对于Qt:QWidget通用属性的解析—上
数据库·qt·microsoft·面试·gui·属性·qwidget
程序员塔塔喵2 小时前
【MySQL篇|第十篇】深分页问题
数据库·oracle·深分页问题
JavaPub-rodert2 小时前
王仕宇分享:把 MySQL 排障做成 AI Agent,慢 SQL、锁等待、连接数和主从异常都能自动查
人工智能·sql·mysql
ClouGence3 小时前
SQL 效率天花板!开源 CloudDM,自带一站式 AI SQL 助手
数据库·sql·开源
qq_452396233 小时前
第七篇:《事务与并发:MVCC、隔离级别与锁机制》
sql
Decay__3 小时前
附-01 · MySQL 压测与容量规划 —— sysbench 基准测试、容量评估与参数基线
运维·数据库·sql·mysql·架构
这个DBA有点耶3 小时前
主从切换报成功,业务却写不进去——数据层脑裂与应用层连接残留的两类假成功
数据库·架构·dba
qq_452396233 小时前
第八篇:《VACUUM、自动清理与膨胀治理》
jvm·数据库·oracle
SelectDB技术团队3 小时前
ClickHouse 支持存算分离吗?Doris 社区版原生存算分离怎么落地
数据库·clickhouse·云原生·开源·apache doris·技术选型·存算分层