PostgreSQL分区表排序优化:从Append Sort到Merge Append
在PostgreSQL分区表上做排序优化最典型的一次性能跳变是把执行计划里的Append Sort换成Merge Append。前阵子我处理过一个线上慢查询订单表按月分成了12个区业务只是取最近100条订单SQL一眼看上去没有毛病。可执行计划一出来人就不太好了——Append把12个分区的结果全部拍平下面跟着一个巨大的Sort节点Sort Method那行写着external merge Disk。一条本该毫秒级返回的查询硬生生跑了好几秒。这篇就把这整件事讲透Append Sort和Merge Append两种执行计划差异在哪PostgreSQL优化器什么条件下才肯生成Merge Append实际通过索引和参数怎么把计划改过来以及我在这类问题里踩过的一些坑。想给分区表上的ORDER BY提速或者单纯想弄懂执行计划为什么会这么走的人都可以看看。1. 先看执行计划Append Sort 和 Merge Append 到底差在哪1.1 慢查询的典型症状Sort 挂在 Append 下面当时那条SQL简化下来就是这样按时间倒序取最近100条订单SELECT * FROM orders WHERE order_ts 2024-01-01 00:00:0008 AND order_ts 2024-07-01 00:00:0008 ORDER BY order_ts DESC LIMIT 100;执行计划大致长这样Limit - Sort Sort Key: order_ts DESC Sort Method: external merge Disk - Append - Seq Scan on orders_p202401 - Seq Scan on orders_p202402 - Seq Scan on orders_p202403 ...同样的SQL等索引和参数到位之后执行计划会变成这样Limit - Merge Append - Index Scan Backward using orders_p202401_order_ts_idx on orders_p202401 - Index Scan Backward using orders_p202402_order_ts_idx on orders_p202402 - Index Scan Backward using orders_p202403_order_ts_idx on orders_p202403 ...第一种形态我习惯叫它Append SortAppend把各个分区的结果物理地拼成一个整体然后再做一次全量排序。第二种形态就是标题里的Merge AppendAppend节点的位置还在但上面挂的不是Sort而是Merge Append节点。两种计划最终返回的行序完全一样但走的路径天差地别。1.2 全量排序的代价账为什么数据一多就失控先说Append Sort为什么慢。假设总数据量是N分区数是K。AppendSort做的事情是先把所有分区的行收集齐形成一个N行的数据集然后对这个完整数据集做排序。排序复杂度是O(N log N)。N到千万级的时候比较次数大概是N乘23.25也就是2.3亿次左右的比较。这个数字本身已经很吓人了更关键的是排序过程中所有数据都要参与work_mem一旦放不下PostgreSQL就会把中间结果写到临时文件于是计划里出现Sort Method: external merge Disk。一旦落盘IO开销比排序本身更致命。更要命的是哪怕LIMIT只取100行AppendSort也必须等全部数据排完才能输出前100行。你只需要100个结果但数据库把所有行都整理了一遍这就是典型的“为了拿前100名把整个年级的成绩单重新排了一遍”。Merge Append的逻辑完全不同。Merge Append做的是多路归并每个分区只要自己内部保持有序执行器就可以把这K路有序流同时推进每次从K个流的头部挑出最小/最大值输出然后从对应的流补充下一行。对LIMIT 100这种Top-N查询它只需要从K路里挑100次就够了根本不需要等所有数据排完。对比项Append SortMerge Append数据组织先拼接成整体再全量排序多路有序输入流并行归并主要开销N log N 次排序比较可能外排归并比较输入流有序性靠索引或局部排序保证Top-N行为必须全部排完才输出流式输出取够N行就停内存压力大容易触发临时文件小各路数据各自维护不需要整体入内存典型前提没有任何有序子路径每个分区都能提供有序子路径1.3 别被名字骗了Merge Append 不是不排序很多人看到Merge Append以为它优化掉了排序。不是的。Merge Append只是把“一次大排序”换成了“多路小排序归并”本质上是归并排序里的merge阶段。学过归并排序的都知道归并排序最后一步就是把两个或多个已经有序的序列合并成一个有序序列。Append Sort时代Append负责拼数据Sort负责排序两者是先后关系。Merge Append则是把“多路已经有序的输入流”同时合并成一个全局有序流输入流的有序性靠每个分区自己的索引或局部排序来保证。最终结果顺序和Append Sort完全一致差别在于怎么达成有序。理解了这一层后面看优化器行为就顺了。2. 优化器什么时候才肯选 Merge Append2.1 生成条件拆解五个条件缺一个都白搭PostgreSQL不会因为你建了分区表就自动给你Merge Append。它生成Merge Append路径需要同时满足几个条件查询有明确的排序需求最常见的就是ORDER BY。分区裁剪之后实际参与扫描的分区数大于1。如果只剩一个分区优化器直接做单表有序扫描根本不需要Append这一层自然也没有Merge Append。每个参与分区的子路径都能按同一个排序键提供有序输出。这些有序子路径的排序方向必须一致要么都是ASC要么都是DESC。不能一半升序一半降序。代价模型算下来Merge Append路径比AppendSort路径更优。这一步是最终裁决前面条件都满足cost不占优计划里照样不会出现Merge Append。第3条是分区表排序优化里最容易出问题的地方。PostgreSQL的路径生成过程会先做分区裁剪然后为每个分区找最优路径再尝试把这些路径组合成Append。如果优化器在给每个分区寻找“能按排序键有序输出”的路径时发现某个分区没有索引支撑这条路就断了。第5条也经常被忽略。我见过不少开发者建了索引也确认分区有索引但执行计划还是AppendSort。为什么因为统计信息没更新优化器估算出来的行数偏差太大导致代价计算失真。插了大批量数据后不ANALYZE很多“怪计划”都会冒出来。2.2 排序键和分区键不乱配是最快的在所有触发条件里排序键和分区键的关系最重要。最理想的情况是ORDER BY的列正好是分区键比如分区键是order_ts查询也ORDER BY order_ts。为什么快因为分区边界天然把数据切成了连续区间上一个分区的所有order_ts都严格小于下一个分区的所有order_ts。也就是说分区之间的整体顺序是“白送”的Merge Append只需要在每个分区内部拿到有序结果流然后按照分区边界顺序归并就行。如果非要按customer_id排序而customer_id跟分区键order_ts没有关系事情就麻烦很多。每个分区都得提供一个customer_id有序流优化器要逐个分区确认路径代价模型也很容易算不过全量排序。就算理论上能生成Merge Append也未必每次都能生成。我在很多生产环境里观察到的规律是排序键包含分区键生成Merge Append基本是水到渠成排序键和分区键完全不搭硬靠全局索引撑出来的Merge Append不稳定可遇不可求。所以做表结构设计时如果预见到某个排序需求是高频率的最好让分区键往排序键上靠。2.3 索引、参数和统计信息一个都不能少想让Merge Append稳定出现需要三方配合。首先是索引。在分区表上建索引直接用父表建就行CREATE INDEX idx_orders_ts ON orders (order_ts DESC);这条语句会让所有已有分区都递归创建一个对应的索引。优化器就有了在每个分区上走Index Scan的能力。其次是参数。PostgreSQL有一个专门的控制开关叫enable_mergeappend默认是on。一般不需要改但排查问题时可以用它做A/B对比。最后是统计信息。优化器做代价估算依赖统计信息批量导入数据后没ANALYZE行数估算可能差出几个数量级。遇到“我觉得该走Merge Append但优化器就是不走”的情况先跑一下ANALYZE orders;这一条经常比调参管用。3. 实测把一条慢 SQL 从 Append Sort 改成 Merge Append3.1 准备一个测试环境为了把这套流程说清楚我准备了一张按order_ts RANGE分区的订单表12个月12个分区插了300万行模拟数据。CREATE TABLE orders ( id bigserial NOT NULL, customer_id bigint NOT NULL, order_ts timestamptz NOT NULL, total_amount numeric(12,2) NOT NULL, status smallint NOT NULL DEFAULT 0 ) PARTITION BY RANGE (order_ts);创建分区的脚本用了一个DO块循环生成12个月的子表DO $$ DECLARE d timestamp : 2024-01-01 00:00:00; BEGIN FOR i IN 1..12 LOOP EXECUTE format( CREATE TABLE orders_%s PARTITION OF orders FOR VALUES FROM (%L) TO (%L), to_char(d, YYYYMM), d, d interval 1 month ); d : d interval 1 month; END LOOP; END $$;插入数据INSERT INTO orders (customer_id, order_ts, total_amount, status) SELECT (random() * 100000)::bigint 1, 2024-01-01 00:00:0008::timestamptz (random() * 365)::int * interval 1 day (random() * 86400) * interval 1 second, round((random() * 5000 10)::numeric, 2), (random() * 4)::int FROM generate_series(1, 3000000) g; ANALYZE orders;生产环境的分区边界建议带明确时区我这里为了示例直观简化了处理实际项目中不要省略时区细节。3.2 基线计划缺少索引时的 Append Sort接下来跑那条慢SQL并打开执行计划分析EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id, customer_id, order_ts, total_amount FROM orders WHERE order_ts 2024-01-01 00:00:0008 AND order_ts 2024-07-01 00:00:0008 ORDER BY order_ts DESC LIMIT 100;这个WHERE条件会把范围裁剪到2024年1月到6月这6个分区。但因为任何分区上都没有索引优化器找不到“每个分区都能有序输出”的子路径只能退而求其次Limit - Sort Sort Key: order_ts DESC Sort Method: external merge Disk - Append - Seq Scan on orders_p202401 - Seq Scan on orders_p202402 ...注意这里排序键和分区键其实是一致的但Merge Append仍然没有出现。原因很直接每个分区内部连一个能支撑order_ts有序输出的路径都没有Merge Append拿什么去归并没有有序输入流它就无从谈起。量化看6个分区共约150万行数据走全量排序Sort Method落到external merge Disk执行时间在几百毫秒到几秒之间。这在生产环境里就是典型的“明明两个字段很顺却慢得离谱”。3.3 加上索引后Sort 消失Merge Append 出现在父表上建索引CREATE INDEX idx_orders_ts ON orders (order_ts DESC);再跑一次EXPLAINLimit - Merge Append - Index Scan Backward using orders_p202401_order_ts_idx on orders_p202401 - Index Scan Backward using orders_p202402_order_ts_idx on orders_p202402 ...Sort节点直接消失了取而代之的是每个分区上的Index Scan。因为每个分区的Index Scan都按order_ts倒序输出Merge Append只需要做K路归并。配合LIMIT 100执行器从6路输入流的头部挑选最大的100行很可能每个分区只扫了前几页数据就停了。实测对比里同样的数据量AppendSort跑几百毫秒甚至上秒改成Merge Append后掉到个位数毫秒。不同机器差异很大但计划形态转变带来的收益方向是一致的尤其是数据量越大、LIMIT越小差距越夸张。有一点要注意索引方向要和ORDER BY方向匹配。ORDER BY order_ts DESC可以建(order_ts DESC)索引也可以建(order_ts)索引但走Backward Scan。关键是最终每个分区的子路径确实能按目标方向产出有序流方向不匹配计划还是起不来。3.4 业务分页联合索引把 Merge Append 用得更彻底实际业务里排序往往不只有一个字段。比如订单列表页要按(order_ts, id)倒序分页用来处理同一时间戳下的多个订单。这种SQL常常配合keyset分页而不是传统的OFFSETSELECT id, customer_id, order_ts, total_amount FROM orders WHERE order_ts 2024-01-01 00:00:0008 AND order_ts 2024-07-01 00:00:0008 AND (order_ts, id) (2024-03-15 10:00:0008, 1000) ORDER BY order_ts DESC, id DESC LIMIT 100;这种查询需要联合索引来支撑完整排序键CREATE INDEX idx_orders_ts_id ON orders (order_ts DESC, id DESC);执行计划里每个分区会走Index Scan using idx_orders_ts_id路径键是(order_ts DESC, id DESC)Merge Append按相同路径键归并后返回结果就是正确的全局有序。毕竟单列order_ts索引只能保证order_ts列有序同一个order_ts内部的id顺序它管不了所以需要联合索引把第二排序键也带上。3.5 开关实验用 enable_mergeappend 做 A/B为了确认性能差异真的来自Merge Append我习惯在单个会话里做一次A/B实验。关掉enable_mergeappend强制优化器回到AppendSortSET enable_mergeappend off; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id, customer_id, order_ts, total_amount FROM orders WHERE order_ts 2024-01-01 00:00:0008 AND order_ts 2024-07-01 00:00:0008 ORDER BY order_ts DESC LIMIT 100; RESET enable_mergeappend;果然执行计划又变回了Append - Sort - Limit耗时也随之上升。这一步能非常直观地证明问题就出在排序路径选择上。注意这个实验只改当前会话不要在生产库全局改。4. 我在这条路上踩过的坑以及怎么绕开4.1 一个分区没有索引Merge Append 直接“整车掉级”Merge Append有一个“全有或全无”的特征所有参与扫描的分区都必须能提供有序子路径任何一个分区不行整个计划就退回AppendSort。我吃过大亏的场景是分区表创建索引后后续新挂载的分区没有同步建索引。可能是迁移脚本只建了表结构也可能是手工ALTER TABLE ATTACH PARTITION时没走标准流程。结果就是表里有30个分区其中29个走得是Index Scan唯独1个分区在Seq Scan整个查询的排序路径就废了。排查方法很简单。打开EXPLAIN逐行看Append下面的子节点只要发现某个分区不是Index Scan而落到Seq Scan优先查索引。用\d orders查看父表索引再对具体分区用\d orders_p202406对比很快就能定位。修复就是给缺索引的分区补上同名索引然后重新EXPLAIN验证。4.2 排序键和分区键不搭别指望全局索引一劳永逸分区键是order_tsSQL却要ORDER BY customer_id这种跨分区全局排序是最容易让人白费功夫的场景。就算给customer_id建了索引优化器也未必生成Merge Append。因为每个分区都要提供customer_id的有序流这些流归并后还得保证全局有序路由成本、归并成本加起来优化器的代价模型通常会认为一次全量排序更“划算”。另外这样的SQL往往WHERE条件里也不带分区键分区裁剪失效所有分区全量参与扫描。即便能生成Merge Append规划时间也会因为分区数太多而变长。我的建议很直接如果这个排序需求是核心路径优先考虑调整分区键设计让高频排序键贴近分区键如果只是低频报表能接受几秒延迟就别硬抠执行计划。为了一个低频查询把全局参数改掉得不偿失。4.3 GROUP BY ORDER BY 组合的误判分组排序是另一个话题。看到Append - HashAggregate - Sort这种结构不要以为建个索引就能让Merge Append蹦出来。分组把数据行粒度改变了排序键变成了聚合结果列和原始分区的索引路径已经对不上。比如SELECT customer_id, count(*) FROM orders GROUP BY customer_id ORDER BY count(*) DESC;这种SQL的执行计划里Sort排序的是聚合结果不是分区表原始行。想让它也走Merge Append需要的是分区内预聚合、结果归并这类更复杂的路径设计和本文说的“分区表行级排序”不是一回事。遇到GROUP BYORDER BY先分清楚排序对象是谁别在排序索引上浪费时间。4.4 别拿 enable_sort 当万能钥匙遇到AppendSort很多人第一反应是关掉enable_sort试试。我见过有人在生产库上全局SET enable_sort off结果一堆依赖排序算子的join、unique、聚合查询全被影响了第二天就有人来找麻烦。参数默认值作用注意事项enable_mergeappendon控制Merge Append路径是否参与竞争单会话A/B诊断用极少需要长期关闭enable_sorton控制Sort节点是否启用影响面极大不要全局关enable_incremental_sorton控制增量排序路径对分区表辅助有效但也不是万能正确做法是把参数实验限制在最小范围。用单个会话跑EXPLAIN做完实验立刻RESET。或者用事务BEGIN; SET LOCAL enable_sort off; EXPLAIN ... ROLLBACK;参数实验的目标只是确认问题路径不是让生产库长期处于“半残状态”。4.5 外排临时文件是执行计划问题的探针如果EXPLAIN ANALYZE里Sort节点下方出现Sort Method: external merge Disk这基本就是全量排序溢出到临时文件的信号。线上排查时pg_stat_statements里有temp_bytes字段专门记录查询写临时文件的情况。定期扫一遍temp_bytes高的SQL配合执行计划看是不是AppendSort能很精准地捞出这类问题。这个探针比单纯看耗时更可靠。有时候查询看起来只慢了一点点但临时文件已经写了几百MB这对磁盘IO和共享缓冲都是压力迟早会爆发。5. 给同样被分区表排序折磨的人一份排查清单5.1 EXPLAIN 五步读法拿到一条分区表慢查询我基本按这个顺序读执行计划找Sort节点看它的父节点是不是Append。如果是AppendSort看Sort Method是不是external merge Disk。看Append下面每个分区是Index Scan还是Seq Scan。只要有一个Seq Scan就是索引或裁剪出问题了。如果出现Merge Append确认它的有序子路径方向和ORDER BY一致。最后看LIMIT在哪一层Merge Append配合LIMIT才是流式取数的完整形态。命令上我惯用EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)TIMING OFF可以减少一些分析开销生产库上偶尔跑一次不会有什么压力。5.2 设计排序键时的两条铁律第一高频排序键尽量贴近分区键。分区键天然给数据划分了区间排序也顺势而为这是成本最低的优化路径。第二父表索引要跟排序键建齐并且给“新分区自动/手动补索引”留一套机制。索引不缺Merge Append才有落地的地基。5.3 还搞不定的排查顺序如果你按前面步骤改完了Merge Append还是没出现我建议按这个顺序往下走确认索引覆盖了所有参与扫描的分区。确认统计信息是新的必要时重新ANALYZE。确认WHERE条件有没有触发分区裁剪。没有裁剪时参与分区太多规划时间和代价模型都可能偏移。确认排序键是不是真的和分区键有天然契合。完全不搭的情况下硬求Merge Append不一定值得。用enable_mergeappend开关做一次A/B证明计划路径差异确实存在。以上都做完了还不行基本要回到SQL写法和表结构设计层面重新评估。最后说一个我自己的判断技巧。如果你发现某个分区单独查询很快但整张分区表一查就慢大概率就是Append Sort掉级了。拿这个现象当信号比瞎调work_mem高效得多。PostgreSQL的分区表不是拆完就高枕无忧执行计划、索引、统计信息这三件套任何时候缺一个查询都能给你颜色看。