GaussDB中UNION结果顺序不稳定?执行计划拆解与稳定排序方案
发布时间:2026/10/22 11:06:24 锦皓数字建站

去年年中我接了一个数据对账的活儿两张业务表要按主键合并出全量清单。当时图省事直接写了一句SELECT id FROM t1 UNION SELECT id FROM t2。第一次跑出来结果整整齐齐从小到大排列看着特别科学。我也就没多想在下游脚本里默认它就是这个顺序。结果两周后数据源做了一次迁移同一条 SQL 再跑顺序全换了我的比对脚本当场翻车。也就是从那天起我开始认真研究 GaussDB 里 UNION 结果顺序这件事。这篇文章把我验证过的结论、复现过程、执行计划层面的原因以及现在在工程里使用的稳定化方案都整理出来。不管你是做数据迁移、报表开发还是日常写 SQL只要在 GaussDB包括单机和分布式形态里用过UNION/UNION ALL都建议花几分钟看完——尤其是如果你正在依赖默认顺序做分页、导出或比对那更要看。1. 先说结论UNION没有保序契约看起来有序只是执行计划的影子SQL 标准对 UNION 结果集的定义是一个集合集合本身就是无序的只有最外层的ORDER BY才被要求返回确定顺序。GaussDB 内核源于 PostgreSQL语义上同样遵循这一点。所以从规范上讲任何一个不带ORDER BY的 UNION 查询返回顺序都是未定义。这不是数据库的缺陷而是标准行为。那为什么日常里我们经常看到 UNION 的结果刚好有序因为优化器是按成本选计划的不是按让你看起来有序选的。我把常见的物理实现整理成一张表写法常见物理实现你能看到什么是否可靠UNION ALLAppend 拼接各分支结果多数时候先出第一个分支的行再出第二个分支不可靠UNION去重HashAggregate 去重类似 hash 桶序无明显规律不可靠UNION去重Sort Unique常常是升序输出看起来很整齐不可靠整个查询带 ORDER BY显式排序节点按排序键输出可靠需排序键为全序注意最后一行还有个前提排序键必须是全序也就是能区分出每一行。如果ORDER BY的列本身有重复值重复组内部的顺序依然不受任何保证。打个比方UNION 的结果就像把两袋扑克牌倒进一个盒子倒出来的瞬间看着好像有点规律但盒子根本不负责保持这种规律。换一个盒子、换一个倒法、甚至换个人来倒规律就没了。UNION 的有序观感完全取决于执行计划这个盒子长什么样而盒子的形状由成本估算决定数据量、统计信息、内存参数一变盒子就变。2. 复现实验同一句SQL顺序为什么说变就变空口无凭我把复现过程完整记录一下。测试环境是一套基于 GaussDB 内核的实例外加一套三节点的分布式形态两张测试表结构如下CREATE TABLE t1 (id int PRIMARY KEY, tag text, val int); CREATE TABLE t2 (id int PRIMARY KEY, tag text, val int); INSERT INTO t1 SELECT g, A, g * 10 FROM generate_series(1, 50) AS g; INSERT INTO t2 SELECT g 10, B, g * 100 FROM generate_series(1, 50) AS g;t1 的 id 是 1~50t2 的 id 是 11~60重叠区间 11~50正好用来观察去重效果。查询本身很简单SELECT id FROM t1 UNION SELECT id FROM t2;第一次执行前我顺手做了ANALYZE默认参数下执行计划大概是下面这个形态我裁掉了无关细节只留关键节点EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM t1 UNION SELECT id FROM t2;计划示意Unique Sort (Sort Key: t1.id) Append Seq Scan on t1 Seq Scan on t2输出是 1 到 60 的升序看起来非常正常。然后我做了第一个改动SET enable_sort off;注意这个参数是会话级 GUC开发环境里验证行为没问题现网不要随便动它。同一句 SQL 再跑计划变成了HashAggregate Append Seq Scan on t1 Seq Scan on t2输出顺序立刻散了第一行可能是 37、12、9、25……完全没有任何规律。也就是说同一句 SQL、同一份数据仅仅因为优化器从排序去重换成了哈希去重顺序就从有序变成了无序。第二个实验更有意思。我在 UNION 的每个分支里显式写了 ORDER BYSELECT id FROM (SELECT id FROM t1 ORDER BY id) x UNION ALL SELECT id FROM (SELECT id FROM t2 ORDER BY id) y;很多人的直觉是我都排序了结果肯定有序。但看一下执行计划就会发现两个子查询里的 ORDER BY 大概率直接被优化器丢掉了。原因后面细说这里先记住结论没有 LIMIT 的子查询 ORDER BY在优化器眼里价值很低被丢弃是常态。第三个实验在分布式环境里做。同样的UNION ALL数据分散在三个数据节点上CN 端通过流式节点汇总。我连续跑了 10 次出现了 3 种不同的行序组合。这在分布式形态下尤其明显各节点执行速度、网络返回时机都会影响到达顺序哪怕每个节点内部的数据是有序的全局也可能被拼得乱七八糟。实验关键计划节点观察到的输出顺序顺序稳定吗默认参数 UNIONSort Uniqueid 升序同环境内稳定换参数/数据即变enable_sortoffHashAggregatehash 桶序无规律不稳定分支子查询带 ORDER BY子查询排序被丢弃与物理扫描序相关不可靠分布式 UNION ALLAppend Stream随节点返回时机变化多次执行可能不同做完这三个实验我再也不会用一次执行结果去给 UNION 的行为定性了。3. 执行计划拆解三种顺序来源与触发条件实验只能证明现象真正要理解的是现象背后的算子。UNION 结果的顺序来源本质上就三种Append 的拼接序、HashAggregate 的桶内序、Sort 的排序副产品。搞懂这三条很多顺序漂移都能提前预判。3.1 Append最自然的拼接最不牢靠的秩序UNION ALL不需要去重物理上最直接的做法就是把两个查询结果首尾相接这就是 Append 节点。在单机形态下它通常先输出第一个分支的全部行再输出第二个分支的全部行看起来就像顺序保留了。但 Append 不构成任何契约。优化器可能调整子查询的连接顺序可能做谓词下推可能把某些分支改成其他访问路径。到了分布式环境Append 的输出还要经过 Stream 节点往 CN 汇聚哪个节点先返回、哪段数据先到达都会改变你看到的拼接结果。所以UNION ALL 保序这个说法顶多在小数据量单机场景里凑巧成立。3.2 HashAggregate完全没有规律的来源UNION要去重HashAggregate 是常见实现。它的工作原理是把每一行按哈希函数丢进一个桶里相同值落在同一桶输出时遍历所有桶。这个遍历顺序由哈希函数、桶的数量、插入的先后共同决定和你的业务顺序没有任何关系。更麻烦的是桶的数量会受work_mem等参数影响。同一批数据内存充裕时可能用 1024 个桶内存紧张时可能扩张成 2048 个桶遍历顺序随之改变。这就是为什么第一次跑好像有点规律二次跑就没了——不是数据变了是桶的结构变了。3.3 Sort Unique唯一一种碰巧有序的来源当优化器判断排序比哈希便宜时会先用 Sort 把数据排好再用 Unique 去掉相邻的重复值。这种情况下你确实会看到升序结果但这是为了去重顺便排了序不是去重语义的一部分。什么时候排序便宜数据量小、排序键上已经存在索引、work_mem足够让排序在内存里完成、且enable_sort没有被关闭。这些条件任何一个变化优化器都可能转向 HashAggregate顺序就没了。CBO 的成本模型里统计信息、分布情况、并行度、enable_hashagg、enable_sort、work_mem全都会影响最终决策。所以我才说顺序是执行计划在某个具体配置下的影子计划一变影子就没了。3.4 分布式形态流式合并让问题更复杂分布式 GaussDB 和多节点形态有个本质区别数据不在一个节点上执行计划会多出 Stream 一类节点CN 从各 DN 拉取结果。这时即便每个 DN 内部做了排序CN 端如果没有对应的归并排序节点也只是把各个有序片段简单地拼接或交错全局顺序完全取决于片段的到达时机。数据倾斜会放大这个问题。某个 DN 数据量大、跑得慢它的结果最后才到那么全局结果的前半段永远是快节点的数据后半段才是慢节点的数据。这种顺序既跟业务无关也跟查询语义无关纯粹是物理执行造成的偶发现象。想在分布式形态下拿稳定顺序唯一的正道是在 CN 端最外层写 ORDER BY且排序键如果和分布列一致还往往能省掉一次重分布。3.5 子查询 ORDER BY 的假秩序最后说说最容易误伤人的一种情况在 UNION 的每个分支子查询里写 ORDER BY。GaussDB 的优化器会做子查询展开flatten外层查询一旦把子查询吸收进来内层的 ORDER BY 没有 LIMIT 时既不影响正确性也无法被外层引用优化器丢掉它几乎没有成本。-- 看着像有序实际顺序不受保护 SELECT id FROM (SELECT id FROM t1 ORDER BY id) x UNION ALL SELECT id FROM (SELECT id FROM t2 ORDER BY id) y;如果你 EXPLAIN 一下大概率看不到 Sort 节点。就算偶尔看到了那也只是优化器觉得顺手保留的产物换一个数据分布就没了。把这种假秩序当保序用是很多分页重复、导出乱序事故的根源。4. 稳定拿顺序的五个方案与一条红线研究清楚原理之后接下来是真正干活的部分如果需要稳定顺序应该怎么写。我从项目里沉淀了五个方案按推荐程度排序。4.1 方案A给整个 UNION 结果排一次序这是最正统、也最不容易出错的写法SELECT id FROM t1 UNION SELECT id FROM t2 ORDER BY id;注意ORDER BY的位置在整个语句最后它作用的对象是 UNION 产生的结果集而不是某个分支。ORDER BY后面可以写列名也可以写序号比如ORDER BY 1代表第一列。如果用了UNION ALL且列组里有重复排序键必须能区分业务上每一行。只按 id 排id 重复时那些行彼此的顺序依然不确定翻页或比对时照样出幺蛾子。所以要么排序键包含一个唯一列要么用多列组合SELECT id, tag FROM t1 UNION ALL SELECT id, tag FROM t2 ORDER BY id, tag;4.2 方案B把 UNION 包进派生表再在外面排如果后续还要 LIMIT、OFFSET、聚合或者窗口函数建议先把 UNION 结果包成一张派生表SELECT * FROM ( SELECT id, tag FROM t1 UNION ALL SELECT id, tag FROM t2 ) u ORDER BY id, tag;这么写的好处是逻辑清晰内层负责合并外层负责排序和截断谁也不会误解 ORDER BY 属于哪一段。唯一的红线是不要以为内层已经排过序了外层就可以省略 ORDER BY。内层有没有序优化器说了算不是你的肉眼说了算。4.3 方案C用显式分支序号定义我要的顺序有时候你要的不是按某个字段排序而是先出第一张表的数据再出第二张表的数据这种来源优先级。这时候与其赌 Append 的拼接行为不如把这个优先级变成业务字段SELECT src, id, tag FROM ( SELECT 1 AS src, id, tag FROM t1 UNION ALL SELECT 2 AS src, id, tag FROM t2 ) u ORDER BY src, id;src一旦成为输出列顺序就是自己定义出来的和物理计划无关。团队评审时看这个 SQL一眼就能明白来源顺序优先的业务意图测试也能针对性地写断言。这是我个人最喜欢的一种写法因为它把不可言说的物理行为翻译成了可维护的业务语义。4.4 方案D用 ROW_NUMBER 精确控制组内顺序如果需求更细比如每个来源内部按 val 从大到小标一个序号再把所有来源合并后按这个序号排序就用窗口函数SELECT id, tag, seq FROM ( SELECT id, tag, ROW_NUMBER() OVER (ORDER BY val DESC) AS seq FROM t1 ) x UNION ALL SELECT id, tag, seq FROM ( SELECT id, tag, ROW_NUMBER() OVER (ORDER BY val DESC) AS seq FROM t2 ) y ORDER BY seq, id;窗口函数的计算结果在分支内是确定的seq就是这个确定性的载体。合并之后再用seq作为第一排序键整个结果就完全可复现了。报表里常见的取每个来源前 N 条再合并配合WHERE seq N就是标准解法。4.5 红线这三件事千万别做方案讲完再立几条红线都是踩过的坑换来的依赖无 LIMIT 的子查询 ORDER BY。优化器随时可能把它丢掉丢了就是全表扫描序。无 ORDER BY 的 LIMIT/OFFSET 分页。LIMIT 只是截断前 N 行前 N 行是哪 N 行根本没定义翻页会出现重复行和漏行。把 UNION 的碰巧顺序用于增量、对账、导出断点。计划一变、节点一重启、数据分布一调整下游就吃脏数据。5. 工程落地分页、导出、对账里的顺序稳定化写法原理和方法论都有了再说几个真实的业务场景。这三类场景对顺序的要求不太一样但共通点是顺序必须有业务含义不能是物理偶发。5.1 分页用 keyset 代替 OFFSETOFFSET 分页的问题不只是慢而是没有 ORDER BY 时LIMIT 截断的行本身就不确定。正确姿势是 keyset 分页每次带着上一页最后一条的标记往下取SELECT id, tag FROM ( SELECT id, tag FROM t1 UNION ALL SELECT id, tag FROM t2 ) u WHERE id :last_id ORDER BY id, tag LIMIT 20;:last_id是上一页最后一条数据的 id。这种写法天然稳定排序键唯一时每次翻页结果都是确定性的。id 不唯一就再加一个唯一列作为第二排序键比如ORDER BY id, tag。keyset 在数据量大时比 OFFSET 快得多因为它能走索引不扫描前面的行。5.2 导出给每一行生成逻辑序号断点续传才有根基批量导出场景里最怕导出任务跑一半失败重跑时顺序跟上次不一样断点续传就断了。解决办法是让导出的每一行带一个可复现的逻辑序号SELECT id, tag, ROW_NUMBER() OVER (ORDER BY id, tag) AS row_seq FROM ( SELECT id, tag FROM t1 UNION ALL SELECT id, tag FROM t2 ) u;这里row_seq是由排序键唯一决定的只要数据没变重跑一遍序号完全一致。下游记录上次导到 row_seq 12345下次从WHERE row_seq 12345继续即可。若担心大导出时窗口函数的性能可以先把结果落成临时表再基于临时表做分批。5.3 对账不要用 UNION 去重用 UNION ALL 聚合这是我翻车事故的另一个来源。很多人拿UNION把两张表的主键合并后比数量但 UNION 自带去重会把真正的重复脏数据掩盖掉对账结果看起来一致实际不一致。对账场景应该用 UNION ALL GROUP BY 把每个来源的出现次数拆出来SELECT id, COUNT(*) FILTER (WHERE src t1) AS c1, COUNT(*) FILTER (WHERE src t2) AS c2 FROM ( SELECT id, t1 AS src FROM t1 UNION ALL SELECT id, t2 AS src FROM t2 ) x GROUP BY id HAVING COUNT(*) FILTER (WHERE src t1) COUNT(*) FILTER (WHERE src t2);这条 SQL 会把在 t1 和 t2 中出现次数不一致的 id 全部列出来。比如 t1 里 id5 出现 2 次t2 里只有 1 次HAVING 条件立刻就能发现。如果你担心 FILTER 语法在团队环境里的兼容性用SUM(CASE WHEN src t1 THEN 1 ELSE 0 END)也是等价的。这里顺序本身并不重要HAVING 条件保证了结果的语义正确最后只为了可读性加ORDER BY id。5.4 性能提醒不要为不存在的需求付排序的钱稳定顺序和性能之间要平衡。如果下游根本不关心顺序就不要画蛇添足加 ORDER BY尤其大结果集上的全局排序可能带来额外内存和临时文件。反过来如果既要稳定的顺序又在意性能可以让排序键贴合已有索引或者让排序键和表的分布列一致这样分布式下可以在 DN 本地排序后由 CN 归并省掉一次重分布。拿不准的时候EXPLAIN ANALYZE看一遍确认排序列上有没有额外的 Sort 节点或重分布开销。6. 最后说点实在的调优参数与踩坑心得写到这里我知道有些同事第一反应是那我直接SET enable_sort on是不是就能保住顺序这里必须劝一句千万别。enable_sort、enable_hashagg 这类参数是给优化器调成本用的不是给应用层保序用的。靠 GUC 硬掰执行计划等于把顺序契约寄托在参数配置上换环境、换版本、换统计信息都会失效。而且全局关掉哈希聚合大表去重的性能损失肉眼可见代价太大。遇到任何UNION 结果顺序不对的反馈我现在的排查顺序是固定的先看执行计划里有没有排序/去重节点再看最外层有没有 ORDER BY再看排序键是不是真的能区分业务行最后才考虑是不是参数或统计信息把计划带偏了。大部分问题死在前三步。个人最大的一个坑就是文章开头说的那次分页重复。当时我写的是SELECT ... FROM (SELECT ... ORDER BY id) UNION ALL ... LIMIT 20第一页和第二页之间出现了重复行。EXPLAIN 一看内层 ORDER BY 早就被优化器丢掉了实际输出是物理扫描序。那次之后我定了一个团队约定凡是依赖行序的 SQL必须有最外层 ORDER BY凡是分页必须用 keyset凡是评审都先问一句这个顺序是从哪来的。现在我再写 UNION第一反应不是问数据库会返回什么顺序而是问自己我真的需要这个顺序吗如果需要我准备用什么业务键把顺序说出来。顺序不是数据库欠你的而是你自己写出来的。这句话算是踩了一串坑之后最值钱的体会。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。