1. 一开始我没打算深究直到那条SQL跑了390秒先交代一下背景。去年有次线上告警某核心查询接口从平均80ms直接飙到390秒监控图上几乎是一条直线。当时第一反应是锁表或者死锁可查了一轮wait_event全是CPU计算没有锁等待。顺着慢SQL日志找到那条查询光是看它执行计划的前两行我就知道自己遇到了什么——执行优化器选了一条离谱的路径。那条SQL形式其实很简单两张表关联加两个过滤条件走了嵌套循环Nested Loop做连接内外层加起来要扫几百万行能不慢吗。可问题在于这条SQL在半年前、一个月前、甚至一周前还跑得好好的为什么突然就“退化”了很多人这时候会去加索引、加资源、改SQL写法但我想先弄明白一个更根本的问题PostgreSQL到底凭什么决定用哪种执行方式它为什么会在某一天“变心”这就是我和执行优化器Planner / Optimizer开始较真的起点。它负责在你发出SQL之后、实际读取数据之前把所有可能的执行路径都算一遍代价然后挑一个它认为最省成本的计划。这个“算代价”的过程决定了你线上80%的性能问题到底是SQL问题还是环境问题。这篇文章不讲抽象概念就讲我踩过的坑、看懂执行计划的方法、以及怎么在关键时候帮优化器一把。不管你是DBA、后端开发还是刚接触PG的数据分析师只要你写过需要跑好几秒的查询这篇东西对你应该都有参考价值。2. 优化器的“内心算盘”它根本不是靠聪明而是靠一套代价公式很多人以为优化器是个“智能体”像人一样能“理解”你的业务含义所以才会在某些时候“变笨”。其实它远没有这么玄乎。PG的优化器就是一个彻头彻尾的“账房先生”它做的所有决策都基于一套代价模型Cost Model。2.1 代价单位PG眼里的一条记录扫描到底值多少钱PG内部用“代价单位cost unit”衡量一切操作的耗材。默认参数里顺序扫描一个数据页8KB的代价是1.0随机访问一个页面的代价是4.0处理一条记录的CPU代价是0.01处理一条索引记录的CPU代价是0.005。这些数字本身没有物理意义它们是相对比例用来让优化器对比不同路径到底谁更“贵”。换句话说优化器的世界里没有毫秒只有成本点。一个执行计划的总代价是25.3另一个是100.9它会毫不犹豫选25.3。这就能解释很多“反直觉”的现象明明加了索引可查询还是顺序扫全表很可能就是在优化器看来用索引要回表Random Access CPU Filter成本比全表顺序扫描还高尤其是在数据量小、或者过滤条件选择性太差的时候。我第一次理解这点时有点失落但也是从那时候起我看执行计划不再凭感觉而是开始自己估算“一笔账”。2.2 统计信息失真优化器眼里的世界是统计学重建的世界优化器算代价的前提是它先要知道每个表大概有多少行、每一列的数据分布长什么样。这些信息存在pg_statistic表里并通过视图pg_stats呈现。PG默认通过采样来估算不需要每行都读过一遍。但是采样比例直接决定了统计质量的“颗粒度”。PG的默认default_statistics_target是100也就是说对每个字段最多采样100个常见值 一个100等深的直方图。当你的数据分布比较“规整”比如用户ID接近均匀分布时这个粒度够用一旦数据高度倾斜比如某个状态值占了99.9%、另一个状态值只有几百条100个桶的直方图就很容易失真。我记得当时那条慢SQL里有一个过滤条件status 2。这个状态在业务上是一个“待重试”的中间态正常情况下只占全表的0.01%左右。但优化器按统计估算出的行数和实际行数能差出三个数量级。估算结果一偏连接顺序全乱最终选出来的计划当然就是灾难。2.3 相关性的隐藏影响字段之间不是独立的另一件优化器“看不见”的事是字段之间的相关性。很多SQL教程默认各个过滤条件是互相独立的PG也的确按独立假设来做联合估算。但真实数据往往不是这样比如某张订单表里status1已完成的行通常pay_time都比较早而status2的行pay_time普遍集中在最近几天。两个字段高度相关PG却当成互相独立来算概率结果就是选择率被乘到离谱的小行数估算被严重低估。这类问题很难通过调参数彻底解决。常规做法是给关键字段建多列统计信息Extended Statistics例如CREATE STATISTICS orders_status_paytime_stats (dependencies) ON status, pay_time FROM orders;建完再执行ANALYZE orders优化器就有条件把相关因素纳入考虑了。这句SQL不能保证所有查询都变快但确实在我的案例里修正了那个数量级的估算偏差。2.4 新鲜度问题为什么这个月还快下个月就慢了还有一类特别常见的“执行计划退化”根因只是统计信息过旧。PG的ANALYZE任务如果是手动跑或者autovacuum的触发阈值没调好大表的统计信息可能停留在很久之前。表结构被大量INSERT/UPDATE/DELETE之后统计信息严重偏离优化器还是按旧世界做决策性能就会突然崩盘。此后我定的一个巡检标准是大表每周至少做一次ANALYZE并且把autovacuum_analyze_threshold的基数调大让频繁更新的表能更快触发统计刷新。统计信息新鲜程度可能比SQL写法本身更能决定执行时间。3. 扫描与连接优化器手里的几张牌每张背后都是取舍执行优化器的核心问题其实就是两件事每一张表怎么扫以及多张表按什么顺序、用什么方式连。把这两点吃透执行计划基本就能看懂了。3.1 你以为“索引一定能加速”优化器却不这么看PostgreSQL里常见的单表扫描方式有四种顺序扫描Seq Scan把整张表的页面从头读到尾每次读一个页按页为单位处理。索引扫描Index Scan先走B-Tree定位到符合条件的索引条目再回表取完整行数据。索引只扫描Index Only Scan所需字段全部包含在索引里干脆不回表。位图扫描Bitmap Scan先用索引定位到一批相关的堆页面再按页面编号聚合后统一回表减少随机I/O次数。优化器选择哪种方式主要看选择率Selectivity——也就是“预计有多少行会被过滤后留下”。选择率高比如过滤后还剩30%的行那索引扫描的回表成本极高优化器宁可顺序扫一遍。选择率低比如过滤后只剩0.1%索引扫描就划算。很多人困扰的“为什么我加了索引却还是Seq Scan”往往不是因为PG傻而是因为你的过滤条件把“小概率查询”写成了“大概率查询”。比如对性别字段建索引过滤后还剩一半数据优化器当然走全表。3.2 连接方式的选择嵌套循环、哈希连接、归并连接这个基础大多数人都懂但我想强调它们的适用边界嵌套循环连接Nested Loop Join适合外层结果集很小、内层有高效索引的情况。注意它不一定慢慢的是外层被估算成“很大”时还要逐行去探测内层。哈希连接Hash Join适合两张大表等值连接先给其中一边建哈希表再按哈希值匹配。它是PG在OLAP查询里最常用的连接方式。归并连接Merge Join适合两边都已经按连接键排好序的情况比如连接键恰好是索引列PG可以直接拿着有序流做归并省去排序开销。在我排查那条慢SQL时优化器选的是Nested Loop但它估计的内层行数只有几十行实际却有数十万行。两边一失衡整个计划就从“合理的嵌套循环”变成“灾难式螺旋”。3.3 连接顺序优化器最花心思的地方多表连接时连接顺序会呈指数级增长。PG默认的搜索策略是动态规划通过join_collapse_limit控制连接重排的上限默认值8。也就是说超过8张表之后PG不再做全局最优搜索而是退化到启发式策略。这里有个实际经验当你的SQL超过10张表关联时不要指望优化器能自己找到最好路径。我给自己的规矩是要么把大SQL拆成临时表分步执行要么在SQL里调整JOIN顺序让过滤性最强、数据量最小的表先被处理干净。连接顺序的优化空间往往比单条索引还大。另外还有一个经常被忽略的开关from_collapse_limit。它决定子查询转成JOIN后能否参与连接重排。如果子查询被当作一个独立单元优化器的灵活性会下降。遇到复杂查询留意这两个参数必要时可以让优化器把子查询“拆开看”。3.4 遗传算法超过12张表时的无奈妥协当连接的表太多动态规划的时间成本会爆炸。PG为此内置了一个遗传查询优化器GEQO默认在geqo_threshold超过12时启用。GEQO不是去枚举所有连接顺序而是用一种随机搜索的思路去“碰”一个相对好的计划。听上去很聪明但代价是计划质量不稳定。同一个查询因为随机种子不一样可能这次选了A计划、下次选了B计划性能忽高忽低。生产环境里我有一次就遇到过某报表查询涉及16张表某天突然从8秒变成80秒重启数据库都没用。最后把geqooff强制关掉让PG硬着头皮做动态规划反而稳定了——当然代价是规划时间变长查询如果频繁执行就得多权衡。4. 看懂EXPLAIN输出的“语言”一张执行计划就是一份审计报告如果你只能从这篇文章带走一个技能我希望是“逐行读懂EXPLAIN ANALYZE的输出”。大部分人说“我看不懂执行计划”不是智商问题而是没人告诉你每一行到底在说什么。4.1 EXPLAIN家族的区别EXPLAIN SELECT ...; -- 只显示优化器估算的计划不真正执行 EXPLAIN ANALYZE SELECT ...; -- 真正执行这条SQL并输出实际执行时间和实际行数 EXPLAIN ANALYZE, BUFFERS SELECT ...; -- 额外输出每次操作读了多少缓冲页、有多少命中、多少磁盘读实际工作里我几乎只用后两种。EXPLAIN单独用只能告诉你优化器“脑子里怎么想”EXPLAIN ANALYZE才能告诉你“现实世界怎么跑”。两者之间的差距就是你需要排查的问题空间。4.2 从“缩进”看执行顺序执行计划是一个树状结构。很多新手看它的时候从上往下读其实应该从缩进最深的节点开始读因为那条线才是数据流的起点。每行会告诉你扫描/连接的类型优化器的估算行数rows和实际行数actual rows执行时间actual time第一组数字是启动时间第二组是完成时间循环次数loops实操中我有个习惯先扫一眼所有节点的“实际行数”看哪一层和“估算行数”差距最大。差距一旦超过一个数量级就把那行圈出来问题基本就出在这。4.3 一个虚拟案例逐步拆解假设有张订单表orders和用户表usersSQL长这样EXPLAIN (ANALYZE, BUFFERS) SELECT u.id, u.name, o.total_amount FROM orders o JOIN users u ON u.id o.user_id WHERE o.pay_status 2 AND o.pay_time now() - interval 7 days ORDER BY o.pay_time DESC;看到类似这样的输出Nested Loop (cost0.29..12345.20 rows445 width100) - Index Scan using orders_pay_time_idx on orders o (cost0.29..9823.10 rows4892 width12) Filter: (pay_status 2) - Index Scan using users_pkey on users u (cost0.29..0.52 rows1 width96)初看好像没问题先按时间扫订单再逐个回查用户。但是注意actual rows如果Nested Loop返回了445行而orders的Index Scan实际返回了54500行然后被Filter过滤成445行——这就是一个典型的开销倒挂优化器以为先在索引里过滤pay_status 2能省事实际上又不是这么回事。原因可能是pay_status和pay_time相关而优化器按独立条件去估算了联合选择性。这种场景下你建一个联合索引比如(pay_status, pay_time DESC)让过滤和排序都能走索引往往比给优化器讲道理更简单。输出里还有几个关键指标我得单独拎出来说actual time第一个值单个节点的启动开销。如果它很高说明这个操作在等待前一阶段产出。rows / loops如果一个节点的loops大于1意味着它被反复执行。超大loops往往是性能里最隐蔽的敌人。Buffersshared hit/readread高说明物理I/O多了如果系统内存足够而read持续很高就得思考work_mem配置或者扫描路径的选择问题。4.4 用EXPLAIN ANALYZE复盘那起线上事故回到开头那条慢SQL我最终用EXPLAIN ANALYZE抓到的问题链路是这样的嵌套循环外层是orders表的Seq Scan估算行数4000实际扫描了280万行。内层是users表的Index Scan估算每次循环只有1行实际也确实是1行——听起来没问题。问题是外层4000行估算错到280万行后循环次数被放大700倍整个查询的耗时全耗在反复查找用户的索引上。根因就是前面说的统计失真。我重新ANALYZE之后执行计划从Nested Loop换成了Hash Join耗时直接从390秒降到1.8秒。那次之后我给自己立了条规矩任何慢SQL排查第一件事不是加索引而是看“估算行数 vs 实际行数”的偏差。偏差才是根因索引只是手段。5. 主动干预优化器哪些开关该动哪些开关别乱动调整优化器是一门“把枪口抬高一寸”的艺术。PG暴露了很多enable_xxx参数但我不建议你上来就把哪个开关关掉。正确的打开方式是先用参数试验确认问题路径后再用SQL改写或者统计手段去根治。5.1 能改的参数以及它们的真实影响下面是几个我实际用过的参数按“调整频率从高到低”排一下参数名作用使用场景work_mem控制排序、哈希表等操作能用的内存上限哈希连接出现临时文件、排序落盘时调大effective_cache_size告诉优化器操作系统页面缓存大概有多大用于评估索引扫描成本系统内存大但优化器偏向顺序扫描时random_page_cost随机I/O代价倍数默认4.0SSD环境可以调低到1.5左右SSD环境下优化器过于厌恶索引扫描时join_collapse_limit控制JOIN重排上限多表连接时尝试扩大搜索空间enable_nestloop强制禁用嵌套循环一般不推荐长期开启临时验证“是不是嵌套循环在捣鬼”特别注意enable_nestloopoff这种开关适合做短期诊断。如果你关闭某个连接方式后查询变快了不代表这个方式没用而只说明优化器当前估算失真。把统计信息修好、索引建对然后再把开关恢复默认才是正道。5.2 用auto_explain捕捉生产环境的隐性问题很多慢SQL在测试环境怎么都复现不了一上生产就慢原因无非是数据分布不同、内存参数不同。这种情况下我推荐开启auto_explain扩展LOAD auto_explain; SET auto_explain.log_min_duration 2s; SET auto_explain.log_analyze true; SET auto_explain.log_buffers true;所有执行超过2秒的SQL都会自动在PostgreSQL日志里带上完整执行计划和缓冲信息。这比你手动从慢日志里捞SQL再手动EXPLAIN精准得多。我甚至会在压测阶段就把它开着把执行计划连同压测数据一起收集后面做回归对比时能省不少力气。5.3 警惕参数只加不减的“内存幻觉”很多人一看到慢查询就调work_mem从64MB直接拉到1GB结果排序确实快了但并发一高每个会话都需要1GB内存数据库整体内存立即告警。PG的work_mem是每次排序/哈希操作单独分配的一个查询里可能同时存在多个排序节点。合理做法是先用EXPLAIN ANALYZE看有没有“Sort Method: external merge Disk”字样确认排序是否落盘。根据实际峰值内存小步调优比如256MB起步观察一段时间再继续加。结合最大连接数算总账work_mem * 平均并发数 * 连接数别超过可用内存的70%。5.4 修改随机页代价后的体验前面提到random_page_cost默认4.0这是机械硬盘时代的遗产。我所在的环境全闪存后优化器总是觉得随机读很贵导致它经常放弃索引而选顺序扫描。我把random_page_cost调到1.5并配合执行计划对比发现一批中等选择率的查询得到了很明显的提升。但这个参数不是越低越好。如果调到1.1或者1.0优化器会开始“过度信任”索引遇到选择率高的查询反而大量回表性能比顺序扫描更差。我的建议是以默认4.0为起点每次降0.5跑一遍核心查询集对比取一个综合最优值。6. 关键经验复盘优化器需要的不是你教它做事而是给它正确的数据文章写到这里其实最核心的一句话已经浮出来了执行优化器本质上是个依赖统计数据的决策器它不需要你教它怎么写业务SQL它需要的是准确的统计信息、合理的代价参数以及足够直接的索引设计。回看那次390秒事故我真正的损失不是那几次告警而是排查的过程里浪费时间怀疑了不少“嫌疑犯”从数据库版本问题、到连接池被打满、甚至一度怀疑是网络带宽……最后用一张EXPLAIN ANALYZE锁定真相时我意识到多数性能问题的诊断方法其实有章可循而我之前缺的恰恰是系统化理解优化器的这套分析框架。最后再分享一个我现在一直在用的小习惯每次发完表结构变更加列、加索引、大批量更新数据之后我都会顺手执行一次ANALYZE并跑一遍核心SQL的EXPLAIN ANALYZE看一眼估算行数有没有失真。这套动作只要10分钟但能拦截掉绝大多数“SQL昨天还好好的今天就慢成狗”的诡异问题。另外一定要把EXPLAIN的能力教给你身边的同事。我见过太多同学遇到慢查询就直接把一个几百行的执行计划截图丢到群里然后所有人一起盯着看。实际上读懂执行计划只需要三件事知道树形结构的执行顺序关注估算与实际行数的偏差确认瓶颈点反复执行的次数。把这三点告诉团队比任何一个调参大招都管用。End.