SQL 性能优化体系总结:从索引到执行计划的完整知识地图

SQL 性能优化体系总结:从索引到执行计划的完整知识地图
SQL 性能优化体系总结从索引到执行计划的完整知识地图大家好我是朱大喜。写 SQL 最怕什么不是写不出来是写出来了跑不动。一条 SQL 跑了 40 分钟还在转圈看着监控面板上红彤彤的资源占用那感觉懂的都懂。今天用一张知识地图把 SQL 性能优化这件事讲透。一、性能优化的三层金字塔SQL 优化不是一条命令能解决的它是一个分层体系。我把它总结为三层金字塔第一层是SQL 语句本身的优化见效最快也是日常工作中最常用的。第二层是数据模型层面的优化投入产出比最高。第三层是架构层面的优化一旦做好后续维护成本大幅降低。为什么三层金字塔的层级不是随意排列的它映射了优化工作的见效速度和天花板高度。第一层 SQL 优化见效最快——一个子查询改写可以让耗时从 10 分钟降到 30 秒当天改当天上线——但天花板也最低因为无论 SQL 写得多优雅扫描 10 亿行数据终究需要时间。第三层架构优化正好相反——换成 ClickHouse 引擎可能让查询从一开始就快 50 倍——但落地周期可能是两个月。真正的经验法则是生产环境出问题时先用第一层救火当天然后在迭代中推进第二层月度最终在技术规划中落地第三层季度。三层不是选一个而是分节奏全部做。我见过太多团队永远在第一层打转——每次作业慢了就改 SQL改完快了两个月又慢了再加条件——从没想过第二层建张汇总表。他们不是懒是不知道三层金字塔的节奏分工。二、第一层SQL 语句优化 —— 每天都要用的基本功索引最多人忽略的加速器索引不是 MySQL 的专利Hive 也有 ORC 索引、ClickHouse 有跳数索引。但很多人建表时根本不考虑-- ❌ 错误示范没有针对查询条件建索引 CREATE TABLE user_orders ( user_id BIGINT, order_id BIGINT, pay_time STRING, amount DECIMAL(16,2) ); -- ✅ 正确做法根据高频查询模式设计索引/排序键 -- ClickHouse 中 ORDER BY 等价于索引设计 CREATE TABLE user_orders ( user_id UInt64 COMMENT 用户ID, order_id UInt64 COMMENT 订单ID, pay_time DateTime COMMENT 支付时间精确到秒, amount Decimal(16,2) COMMENT 订单金额 ) ENGINE MergeTree() -- 排序键设计原则把等值查询和范围查询的字段放在前面 -- 这里假设最常见的查询是按 user_id 查 按 pay_time 范围过滤 ORDER BY (user_id, pay_time) -- 二级跳数索引加速 amount 的聚合查询 SETTINGS index_granularity 8192; -- 粒度设为8192行一个索引标记JOIN 顺序CBO 不总是靠谱的大表 JOIN 小表还是小表 JOIN 大表理论上 CBO基于成本的优化器会帮你决策但实际中优化器的统计信息可能过期-- 查看表的统计信息是否过期 -- Hive 中可以这样检查 ANALYZE TABLE dwd.user_behavior_di COMPUTE STATISTICS; ANALYZE TABLE dwd.user_behavior_di COMPUTE STATISTICS FOR COLUMNS; -- 如果统计信息缺失手动指定 JOIN 顺序MapJoin 提示 SELECT /* MAPJOIN(small_dim) */ -- 明确告诉引擎small_dim 是小表放内存 large_fact.user_id, small_dim.user_name, -- 维度表的字段 SUM(large_fact.order_amount) AS total_amount FROM dwd.order_fact_di large_fact -- 事实表大表 JOIN dim.user_info_df small_dim -- 维度表小表可装入内存 ON large_fact.user_id small_dim.user_id WHERE large_fact.ds 20260728 GROUP BY large_fact.user_id, small_dim.user_name;子查询改写用 JOIN 替代 IN/NOT IN这是最常见的性能坑。IN 子查询在大数据量下会退化成笛卡尔积-- ❌ 性能杀手NOT IN 子查询 —— 每条外层记录都要扫描内层全表 SELECT user_id, user_name FROM dim.user_info WHERE user_id NOT IN ( SELECT DISTINCT user_id FROM dwd.order_fact_di WHERE ds 20260728 ); -- ✅ 改写成 LEFT JOIN IS NULL —— 只需一次 JOIN 操作 SELECT a.user_id, a.user_name FROM dim.user_info a LEFT JOIN ( SELECT DISTINCT user_id FROM dwd.order_fact_di WHERE ds 20260728 ) b ON a.user_id b.user_id WHERE b.user_id IS NULL; -- 关联不上的就是不在里面的为什么NOT IN退化成笛卡尔积的过程在 SQL 引擎中是逻辑上的渐进而不是物理上的直接。当子查询返回的结果集很大时比如有几千万个 user_id优化器可能放弃构建哈希表内存不够转而采用循环探测策略——对外层每一行去子查询结果里做查找。如果外层 500 万行、子查询 3000 万行这就是 500 万 × 3000 万次比较——约等于 15 万亿次任何硬件都扛不住。LEFT JOIN IS NULL之所以快是因为 JOIN 阶段会构建哈希表来加速匹配复杂度从 O(n×m) 降到 O(nm)。这不是语法糖是算法复杂度的质变。数据倾斜分布式计算的头号杀手某个 key 的数据量远超其他 key导致某个 Task 成为瓶颈整个作业卡住不动。-- 场景按用户ID分组计算但某些大客户占了80%的订单 -- 数据倾斜的判断方法先查一下数据分布 SELECT user_id, COUNT(1) AS cnt -- 统计每个user_id的订单数 FROM dwd.order_fact_di WHERE ds 20260728 GROUP BY user_id ORDER BY cnt DESC LIMIT 20; -- 如果前几个占了30%以上就需要处理倾斜 -- 解法一加盐打散 —— 给倾斜的key加随机后缀 SELECT -- 拆分后的虚拟用户ID比如 user_123_hash0、user_123_hash1 CONCAT(if(cnt 10000, user_id, CAST(rand() AS STRING)), _hash, CAST(rand() * 10 AS INT)) AS salted_key, SUM(order_amount) AS total_amount FROM ( SELECT a.user_id, a.order_amount, b.cnt FROM dwd.order_fact_di a JOIN ( SELECT user_id, COUNT(1) AS cnt FROM dwd.order_fact_di WHERE ds 20260728 GROUP BY user_id ) b ON a.user_id b.user_id WHERE a.ds 20260728 ) GROUP BY salted_key; -- 先局部聚合 -- 然后再做二次聚合把同一个user_id的数据加起来为什么数据倾斜的本质是按 key 的哈希分配工作遇到了key 的分布极不均匀这一矛盾。默认的分区策略是hash(key) % numPartitions当某个 key比如大客户的 user_id999出现在 500 万条数据中这 500 万条数据都会被分到同一个分区——而这个分区其他 key 的数据可能只有几千条。这个分区所在的 Executor 就成了整个作业的瓶颈。加盐打散的思路不是在数据上做手脚而是在哈希算法上做手脚——通过给倾斜 key 加上随机后缀变成999_hash0、999_hash1...999_hash9人为增加了倾斜 key 的哈希多样性让原本挤在一个分区的数据均匀散开到多个分区。这背后的哲学是打不过分布就改变哈希而不是改变数据。三、第二层模型优化 —— 投入产出比最高的优化很多时候SQL 写得再漂亮也跑不快是因为底层模型有问题。反范式化用空间换时间-- 范式化设计订单表只存user_id用的时候再JOIN -- 问题每次查订单列表都要 JOIN 用户表高频查询场景下开销大 -- ✅ 反范式化在订单 DWD 表中冗余常用维度字段 CREATE TABLE dwd.order_fact_wide_di ( order_id BIGINT COMMENT 订单ID, user_id BIGINT COMMENT 用户ID, user_name STRING COMMENT 用户昵称冗余字段来自dim_user, user_level STRING COMMENT 会员等级冗余字段减少JOIN, order_amount DECIMAL(16,2) COMMENT 订单金额, pay_time STRING COMMENT 支付时间, ds STRING COMMENT 数据日期分区 ) COMMENT 订单宽表 —— 冗余常用维度少做JOIN; -- 代价存储空间增加约15%~20% -- 收益高频订单查询场景的 JOIN 次数减少60%DWS 预聚合层把计算前移-- 场景每天无数人要查各等级用户的周消费汇总 -- 如果每次都从 DWD 实时算SQL 至少跑 5 分钟 -- ✅ 建一张 DWS 层的预聚合表每天凌晨跑一次 CREATE TABLE dws.user_level_weekly_summary_wi ( week_start STRING COMMENT 周起始日期, user_level STRING COMMENT 会员等级, user_cnt BIGINT COMMENT 用户数, order_cnt BIGINT COMMENT 订单数, total_amount DECIMAL(18,2) COMMENT 消费总额, avg_amount DECIMAL(16,2) COMMENT 人均消费, ds STRING COMMENT 最新数据日期 ) COMMENT 用户等级周汇总表 —— DWS 预聚合层; -- 每天凌晨执行一次后续查询直接读这张表从 5 分钟降到 0.5 秒 INSERT OVERWRITE TABLE dws.user_level_weekly_summary_wi SELECT DATE_FORMAT(date_sub(pay_time, PMOD(DATEDIFF(pay_time, 2026-01-05), 7)), yyyy-MM-dd) AS week_start, a.user_level, COUNT(DISTINCT a.user_id) AS user_cnt, COUNT(1) AS order_cnt, SUM(a.order_amount) AS total_amount, ROUND(SUM(a.order_amount) / COUNT(DISTINCT a.user_id), 2) AS avg_amount, ${yesterday} AS ds FROM dwd.order_fact_wide_di a WHERE a.ds DATE_SUB(${yesterday}, 6) -- 取最近7天数据 GROUP BY week_start, a.user_level;为什么DWS 层的价值不只是查询加速它还解决了重复计算带来的口径漂移问题。假设没有预聚合表每个分析师每天早上手动跑一次各等级用户周消费的 SQL。一个月后你会发现财务部的数字和运营部的数字差 2%——不是谁算错了而是他们取了不同时间点的数据快照或者一个人COUNT(user_id)、另一个人用了COUNT(DISTINCT user_id)。预聚合表的本质是把计算动作变成数据资产让所有下游消费者面对同一份已经算好的——也是唯一的——结果。它省的不是 5 分钟查询时间是 5 个部门对同一个指标的口径对齐成本。四、看懂执行计划优化的导航仪不管用什么引擎执行计划都是你判断优化方向的依据-- Hive/Spark 中查看执行计划 EXPLAIN EXTENDED SELECT a.user_level, SUM(b.order_amount) FROM dim.user_info a JOIN dwd.order_fact_di b ON a.user_id b.user_id WHERE b.ds 20260728 GROUP BY a.user_level;执行计划里要重点关注四个信号四个常见问题及解法Stage 过多说明 JOIN 层数太深优先考虑反范式化或建设 DWS 层CROSS JOIN十有八九是 JOIN 条件写漏了赶紧查单 Task 数据量过大数据倾斜加盐打散或两阶段聚合Shuffle 写出量大于输入量JOIN 时对上了多条记录导致膨胀检查关联键的唯一性为什么执行计划里 Shuffle 写出量大于输入量这个信号特别容易被忽视却是隐形成本的典型来源。假设一个 10GB 的事实表和 100MB 的维度表做 JOIN事实表的每条记录都只匹配维度表中的 1 条——那 Shuffle 写出量应该约等于 10GB。但如果维度表的关联键不是唯一的——一条事实记录匹配了 3 条维度记录——那 Shuffle 写出量就变成了 30GB。这 3 倍膨胀不仅让后续聚合多扫了 3 倍数据还在 shuffle 阶段消耗了 3 倍的网络带宽。修复这个问题通常不需要改 SQL——只需要在维度表上做一次去重确保关联键唯一。但如果你不看执行计划你永远不知道一条看似没问题的 SQL在默默浪费着 3 倍的集群资源。踩坑提醒先看执行计划再看运行日志太多人 SQL 慢直接翻日志看报错——日志只能告诉你出错了/跑不完执行计划能告诉你的为什么跑不完。养成习惯上线前 EXPLAIN 一次就是给每条重要 SQL 做一次体检。CBO 的统计信息每月更新一次数据每周都在变上个月的统计信息可能已经严重失真。一个表从 100 万行涨到 5000 万行优化器还以为它是 100 万行的小表做出的执行计划自然不合理。定时重跑ANALYZE TABLE是维护任务不是一次性配置。数据倾斜不要一上来就用加盐加盐方案会引入二次聚合增加代码复杂度和维护成本。先检查倾斜是否因为数据质量比如大量 NULL 值、默认值如果是在 ETL 阶段清洗掉比在查询中加盐更优雅。结论SQL 性能优化是一个体系活光会改写查询语句远远不够。三层金字塔的思路要刻在脑子里第一层SQL 层面索引、JOIN 顺序、子查询改写、倾斜处理 —— 每天都能用上第二层模型层面反范式化、预聚合 —— 投入一次长期受益第三层架构层面分区策略、引擎选型、生命周期管理 —— 决定天花板还有一个最重要的习惯上线前必看执行计划。执行计划不会骗人数据量、Stage 数、Shuffle 大小一目了然比你凭感觉猜优化方向靠谱一万倍。