SQL 查询优化:从执行计划到吞吐调优

深入解析 SQL 查询优化:读懂执行计划(EXPLAIN/EXPLAIN ANALYZE)与代价模型、索引设计(B+树/覆盖索引/联合索引)、分区裁剪与分桶优化、谓词下推与投影下推、Join 策略(Hash/Merge/Nested Loop)与数据倾斜、聚合与窗口函数优化、大表扫描与列存优化、慢查询定位方法,以及数仓 SQL 调优的工程实践。

引言

一条跑 5 分钟的查询和一条跑 5 秒的查询,往往不是"机器差多少",而是执行计划差了量级。SQL 优化不是玄学:每个数据库都会给你一张"执行计划"——它把 SQL 翻译成具体的执行步骤,步骤的选择直接决定性能。会读执行计划的人,能准确说出"慢在哪、怎么改"。

SQL 优化的本质是"帮优化器做更好的决定":给足信息(统计、索引、分区),减少废话(全表扫、大 Join、重复聚合)。

本文从执行计划读法讲起,覆盖索引、分区、Join、聚合与倾斜五大优化面,最后给出一套"慢查询定位→优化→验证"的工程方法。示例以 PostgreSQL/Doris/ClickHouse 为主(SQL 语义通用)。


一、读懂执行计划

1.1 EXPLAIN 与 EXPLAIN ANALYZE

-- PostgreSQL
EXPLAIN ANALYZE
SELECT region, SUM(amount)
FROM orders
WHERE created_at >= '2026-09-01'
GROUP BY region;

执行计划从下往上读,每一行是一个算子:

Aggregate  (actual time=120.5..121.0 rows=8 loops=1)   -- 聚合
  ->  Seq Scan on orders  (actual time=0.1..95.2 rows=1.2M ...)
      Filter: (created_at >= '2026-09-01')
      Rows Removed by Filter: 800000                     -- 扫了全表

读计划三件事:

  1. rows:算子实际处理的行数,判断预估是否离谱(统计信息过时 → 计划差)。
  2. time:耗时分布,找"大头算子"。
  3. 操作类型:Seq Scan(全表扫)/ Index Scan / Hash Join / Nested Loop——操作类型决定量级。

1.2 代价模型与统计信息

优化器靠表统计(行数、分布、直方图)做选择。统计信息过旧,优化器就会"误判":

# 统计信息缺失/过旧的症状
# 明明有索引却走 Seq Scan(误判全表更便宜)
# Join 顺序离谱(把小表当驱动表)
# 修法: ANALYZE / 手动更新统计
# 数仓场景: 大表定期 ANALYZE,分区表按分区更新统计

二、索引优化

2.1 索引不是越多越好

索引类型加速场景代价
B+ 树索引等值 + 范围 + 排序写放大、占用空间
覆盖索引查询列全在索引内(避免回表)索引变宽
联合索引多列组合过滤/排序列顺序敏感
位图索引低基数列(数仓常用)写不友好

2.2 索引选择铁律

# 1) 高频过滤列(WHERE)优先建索引
# 2) 联合索引: 最左前缀 + 选择性高列放前
#    (region, created_at) 支持 region=? AND created_at BETWEEN...
# 3) 覆盖索引: SELECT 列全部纳入索引 → 免回表
# 4) 排序/分组列: ORDER BY / GROUP BY 列入索引,避免排序
# 5) 低选择性列(is_active 0/1)别单独建索引,走分区/位图
# 6) 写多读少的表少建索引(写放大)

2.3 数仓与 OLTP 的差异

  • OLTP(行存 + 索引):小范围点查,索引是主角。
  • OLAP(列存):查询几乎全扫,索引让位给分区裁剪与列裁剪——列存本身"每列一个索引"。

三、分区与分桶

3.1 分区裁剪(Partition Pruning)

分区表让查询只扫相关分区,是数仓第一提速手段:

-- 按日期分区
CREATE TABLE events (
  ...
) PARTITION BY RANGE (dt);

SELECT * FROM events WHERE dt = '2026-09-27';
-- 只扫描 2026-09-27 分区,其他分区直接跳过
# 分区裁剪生效条件
# 1) WHERE 里对分区键用等值/范围过滤(不能是函数包住: dt+1=...)
# 2) 分区键在计划里出现 "Partition Pruned: 90%" 即成功
# 3) 别对分区键做隐式类型转换(varchar vs date 会失效)

3.2 分桶与聚簇

在分区内按业务键分桶(Hash/Key),让 Join/聚合局部化:

# 分桶 Join: 两表按同键分桶,桶对桶 Join,避免全网 shuffle
# 分桶聚合: 聚合先在桶内局部完成,再合并
# 聚簇表(数据按某列物理有序): 范围查询连续 IO

3.3 分区粒度的取舍

# 粒度太细(按小时): 分区数量爆炸,元数据开销大
# 粒度太粗(按年): 裁剪失效,扫描大
# 经验: 按查询最小范围选,多数场景按日

四、谓词下推与投影下推

4.1 谓词下推(Predicate Pushdown)

把 WHERE 过滤"尽量往数据源头推",让数据在读取/Join 前就变少:

# 优化器自动做的
# Join 前先各自过滤 → 少 Join 少扫描
# 分区裁剪本质也是谓词下推(推到存储层)
# 对数仓外部表/视图
# 让过滤条件"穿透"到源表(如 Doris 推给 Hive)

4.2 投影下推(Projection Pushdown)

只取需要的列,别 SELECT *:

# 列存场景
# SELECT * → 读出所有列(列存 IO 浪费)
# 只选需要的列 → 列裁剪,IO 大减
# 警惕: 隐式全列(SELECT a, b 但后面 join 带整表)

4.3 下推失效的信号

  • 过滤在子查询外层(WHERE 作用在 join 结果上)。
  • 对列套函数(WHERE UPPER(name)='X')——索引与裁剪失效。
  • 视图/CTE 中过滤被"物化"后再过滤(未下推)。

五、Join 策略与数据倾斜

5.1 三大 Join 策略

策略原理适用陷阱
Nested Loop逐行匹配小表驱动小结果大表笛卡尔爆炸
Hash Join建哈希表匹配大表等值 Join内存不足时溢出
Merge Join排序后归并两侧已有序排序开销
# 驱动表(小表)选择
# 优化器按统计选,但你能干预:
# 1) 先过滤缩小表再 Join(子查询/CTE)
# 2) 大表 Join 前确保过滤/裁剪生效
# 3) 数仓里 Hash Join 主导,驱动表越小越好

5.2 数据倾斜(Skew)

某些 Join/聚合键值过于集中(如"默认分组"、头部用户),导致单个节点扛全部:

# 倾斜症状
# 计划里某算子处理行数远超其他(rows 图有"长尾巴")
# 实际: 大部分任务秒级完成,个别任务卡死
# 解法
# 1) 倾斜键加盐: key || '_' || random(n) 摊平后再聚合
# 2) 过滤大热点: 大热点单独算,再 union 回来
# 3) 广播小表: 小表广播到各节点,免 shuffle
# 4) 参数: 倾斜检测 + 自动加盐(数仓内置)

5.3 Join 的工程检查清单

# [ ] 等值 Join(Hash 友好),避免不等式 Join
# [ ] 先过滤再 Join(缩小输入)
# [ ] 小表放 Join 右侧/驱动位
# [ ] 数据量级差大的 Join 用广播
# [ ] 确认没有因谓词无法下推导致的笛卡尔积

六、聚合与窗口函数优化

6.1 聚合下推与预聚合

# 1) 聚合下推: 子查询内先聚合再 Join(减少 Join 输入)
# 2) 预聚合/物化: 高频聚合结果物化(物化视图/明细表)
# 3) 两阶段聚合: 数仓内部先局部聚合再全局(自动,但注意倾斜)
# 4) 去重优化: COUNT(DISTINCT) 极耗资源,多维度可改 approx 估算

6.2 窗口函数重排

窗口函数(ROW_NUMBER/PARTITION BY)常常是计划中的"大头",优化点:

# 1) 分区列尽量与物理排序一致(减少 sort)
# 2) ROW_NUMBER() 取 top-N 场景: 用 limit within group 或先过滤
# 3) 避免窗口套窗口(先算一层,再套一层)
# 4) 大排序窗口: 确认排序列有索引/分区顺序支撑

6.3 去重的代价认识

# COUNT(DISTINCT user_id) 在大基数上极贵(要全局去重)
# 替代:
#   近实时看板 → HyperLogLog 近似(误差 ~1-2%)
#   精确但要快 → 预聚合去重明细
#   业务可接受近似 → 优先近似,别追求完美

七、慢查询定位与工程方法

7.1 慢查询发现

# 1) 数据库慢查询日志/网关记录: 按耗时排序找"头号杀手"
# 2) 监控: 每查询耗时、扫描行数、返回行数埋点
# 3) 大盘: 高频慢查询 TOP 榜(按耗时×频次=总成本排序)
# 4) 资源监控: CPU/IO/内存与慢查询关联

7.2 优化工作流

# 定位慢 SQL → EXPLAIN ANALYZE → 找大头算子
# → 判定原因(全表扫/坏 Join/倾斜/统计旧)
# → 对症修改(索引/分区/重写/加盐)
# → EXPLAIN 对比验证(rows/time 下降)
# → 回归: 确认结果一致(语义不变)

7.3 查询重写的常见模式

问题重写思路收益
大表自 Join 算同期对比窗口函数替代省一次大 Join
多层嵌套子查询CTE + 先过滤再关联减小中间集
三表大 Join 无过滤先各自过滤/裁剪输入骤减
逐行关联查字典预 Join 字典或广播免 Nested Loop
深分页 ORDER BY … OFFSET 大游标 / 键集分页免重复排序

八、数仓 SQL 优化实践清单

8.1 分层优化优先级

# 第一优先(收益最大): 分区裁剪 + 谓词下推(少扫 90%)
# 第二优先: 投影下推(少读列)+ 缩小 Join 输入
# 第三优先: 索引/排序对齐(免排序)
# 最后: 算子级微调(倾斜、资源参数)
# 教训: 先解决"扫了多少"再抠"每行多快"

8.2 性能基线与回归

  • 为关键查询建立性能基线(耗时 + 扫描量),每次模型变更后跑回归。
  • 数据量增长时主动复查:今天快的查询,3 个月后数据翻倍可能就慢了。
  • 用查询历史复盘:哪些查询总在 TOP 榜?是口径问题还是性能问题?

8.3 常见误区

  • 迷信索引:数仓 OLAP 索引经常不如分区裁剪有效,先确认裁剪再建索引。
  • 忽视统计:统计信息过期,优化器做的任何"聪明决定"都可能跑偏。
  • 只看总耗时:要分"扫描时间 vs 计算时间 vs 网络时间",别把锅全甩给 SQL。
  • 重写不改语义:任何"优化重写"必须回归验证结果一致,聚合去重最容易悄悄变口径。

总结

优化面核心手段量级收益
执行计划EXPLAIN ANALYZE 定位精准对症
索引覆盖/联合索引点查量级提升
分区裁剪 + 分桶少扫 90%
下推谓词 + 投影下推输入骤减
Join驱动表 + 广播 + 加盐消除量级爆炸
聚合预聚合 + 两阶段大聚合提速
工程基线 + 回归 + 监控可持续

SQL 优化是"读计划、找大头、对症改"的工程方法,不是背技巧。核心原则:先让数据库少干活(裁剪/下推/索引),再让活干得稳(倾斜/排序/统计)。 每次优化都要用执行计划验证、用回归守护语义——快不是目标,又对又快才是。


参考与延伸阅读

  • PostgreSQL 官方文档:EXPLAIN 与查询优化器
  • Doris/StarRocks 官方文档:分区裁剪、分桶与 Join 策略
  • ClickHouse 官方文档:列存优化与聚合下推
  • 高性能 MySQL / The Art of SQL —— 查询优化的经典参考
  • ClickHouse 分析引擎 — 列存与向量化执行
  • Doris 与 StarRocks — 分布式数仓优化器
  • 数据仓库建模 — 建模影响查询性能

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「data-engineering」更多文章

  1. 流批一体:从 Lambda/Kappa 架构到统一计算层
  2. 数据平台成本与 FinOps:存储、计算、弹性与降本实践
  3. 数据网格 Data Mesh:领域数据产品、自助平台与联邦治理