本节摘要:执行引擎把计划树翻译成算子的流水作业:扫描算子取数、连接算子配对、聚合排序算子归并。用实际执行数据把"计划声称的代价"换成"真实发生的耗时",调优就从猜谜变成了记账。
上一节的 EXPLAIN 只看估算,本节引入实际执行——让计划真跑一遍(或采样跑一遍),每个节点多出三列账单数据:实际耗时、实际行数、循环次数。估算与实际的偏差是调优的金矿:估算一百行、实际八万行,说明统计或条件选择性判断失真,优化器在下一次还会犯同样的错。先记住执行模型的两个基调:树的自下而上流水(每个算子按批次向上游要数据,不是一次性搬完);以及大多数算子是"生产线"而不是"仓库"——它们边收边吐,内存占用取决于批次大小而非总数据量,例外是哈希与排序这类需要物化的算子,它们的内存账单请回看第 2 章 2.2 节。
| 家族 | 代表算子 | 什么时候出现 | 调优关注点 |
|---|---|---|---|
| 扫描 | Seq Scan、Index Scan、Bitmap Heap Scan、Index Only Scan | 每条查询的源头 | 全扫合不合理、索引是否只读索引不回表 |
| 连接 | Nested Loop、Hash Join、Merge Join | 多表关联 | 驱动侧大小、内层是否可探测 |
| 聚合 | HashAgg、GroupAgg | 分组统计 | 内存溢盘、估算行数偏差 |
| 排序 | Sort、Top-N Sort | 排序与分页 | 内存配额、是否用了索引序 |
| 数据搬运 | Subquery Scan、Materialize、CTE 扫描 | 子查询与物化 | 是否被意外物化成大结果 |
逐个说清最容易误读的三个。Nested Loop(嵌套循环):外层每行探一次内层,外层小、内层有索引时快如闪电,外层估算失真变大时灾难加倍——看到它先核对估算行数。Hash Join:内表整体建哈希再逐批探测,大小表连接的主力,哈希表放不进内存就分批落盘,账单里表现为哈希节点耗时异常。Index Only Scan:查询列全在索引里,完全不回表,是分页与覆盖查询的最快形态;如果计划显示它退化成回表,检查可见性判断是否需要回主表——又是第 3 章 MVCC 的回响。
EXPLAIN ANALYZE SELECT branch, sum(amount) FROM trades WHERE biz_date >= '2024-06-01' GROUP BY branch ORDER BY sum(amount) DESC LIMIT 10; -- 账单要点(示意): -- Limit (actual time=2318.4..2318.5 rows=10) -- -> Sort (actual time=2318.2..2318.3 rows=10) Sort Method: external merge Disk: 512kB -- -> HashAggregate (actual time=2300.1..2310.7 rows=860) -- -> Seq Scan on trades (actual time=0.4..1870.2 rows=4100000)
读账单四步走。第一步找最贵叶子:Seq Scan 实际耗时一千八百毫秒、扫了四百一十万行——条件只有一个月的范围,若表里历史数据跨五年,命中占比仅两成,全扫不合算。第二步看物化算子:Sort 的账单写着 external merge、磁盘五百 KB,排序溢盘了,属于内存配额问题。第三步核对估算与实际:假设估算行数写的是四十万、实际四百一十万,偏差十倍,统计信息嫌疑最大。第四步开处方:补 date 列索引把范围扫描换成索引扫描、跑批后刷新统计、调大排序内存配额。三招落地后同一条查询回到两百毫秒内——注意全程没有改一个字的 SQL。
开发同学最爱问的分页问题,用算子视角一讲就透。LIMIT 十页以内的浅翻页,Top-N Sort 在内存里维护十个名额的小堆,代价可控;翻到一万页时,Top-N 的堆没变贵,贵的是它上游必须先产出前一百万行——任何引擎都救不了"跳过一百万行取十行"的物理必然。工程解法是游标分页:WHERE id greater than 上一页最大值 ORDER BY id LIMIT 十,配合 id 上的索引,每一页都是一次 Index Only Scan,代价与页深无关。把这条写进开发规范,能消灭一大类上线后期的性能工单。
现代执行引擎支持算子级并行,但并行不是免费的:数据要在线程间重新分发,汇总要等最慢的分片。什么时候并行有收益?扫描与聚合类的大数据量作业,单个算子的活足够重,拆给多线程的调度开销可以忽略——这类作业并行往往近线性加速。什么时候并行反而慢?点查询、小表连接这类轻作业,并行调度的开销比活本身还贵。还有一类要特别小心:系统已经资源紧张时,大查询并行会加剧争抢,把本来只是慢的问题放大成拥堵。实践建议:对报表类作业按资源组控制并行度,交易链路的查询保持低调并行甚至串行;容量规划时给并行作业的内存与 IO 预留专门份额,别让它们与交易负载抢灶台。看懂并行的边界,才不会陷入"明明开了并行怎么反而更慢"的困惑。
数据导出类需求(对账文件、报表导出)在算子视角下有明显的坑。整表导出本质是无界的扫描加序列化,跑在交易高峰等于一场 IO 风暴;带 ORDER BY 的大导出会触发大排序,溢盘概率极高。工程化的做法按优先级排列:导出任务限速并错峰(复用 7.4 案例二的教训);大导出走备机或专用分析库(复用 2.3 的形态分工);游标式分批拉取代替一次性物化(内存占用可控,失败可续传);导出期间拉高该会话的语句超时但限定资源组(防止误伤配置)。这四条拼起来,就是一个"导出作业规范"——比"导出好慢怎么办"的临时救火体面得多,也稳定得多。
读路径讲得多,写路径的算子账也值得算。批量插入的执行画像:逐行插入经过的解析、约束检查、索引维护层层叠加,单行插入的循环开销在大批量下成为主要矛盾。提速的抓手按效力排序:批处理接口(一次网络往返携带多行数据,砍掉往返开销)、批量装载路径(绕过常规 SQL 层的专用加载通道,砍掉解析开销)、延后约束与索引(先装数据后建索引,砍掉逐行维护开销,适合一次性初始化)。三个抓手叠加,百万行的装载可以从小时级压到分钟级。但要记住第 3 章的联动提醒:大批量装载之后的统计刷新与清理,是这条流水线的固定收尾动作,砍不得。写路径的优化与读路径共享同一条方法论:看算子、找大头、按效力排序动手。
执行引擎阶段还有一类被忽视的知识:报错分诊。执行期报错与解析期不同,它们大多与数据和时间相关。三类高频报错的分诊路径:资源类(内存不足、临时空间耗尽)——查触发它的语句规模与当时的并发,解法在内存参数与 SQL 改写之间选;冲突类(唯一约束冲突、检查约束失败)——报错信息带着约束名,顺藤摸瓜到业务逻辑,通常是数据或并发插入的重复键;锁与等待类(等待超时、死锁牺牲)——回到 3.4 的方法处理。分诊的价值在响应速度:值班看到报错先分类,资源类看监控、冲突类看数据、锁类看等待链,三步内归因。把这份分诊表贴在值放手册里,与 7.2 的响应路径形成接力。
SQL 引擎三站走完,内核地基全部打好。第 5 章回到交付最关心的主题:怎么让这套系统在高故障面前不丢数据、不停服务。