上周帮人排查一份跑批结果,对方拿着两份导出的 CSV 问我:明明写的是同一个排序,为什么换了个引擎跑,顺序就散了?我打开脚本一看,里面写的是sort by,而他对比的那一版写的是order by。这两个词长得像近义词,实际上一个是 SQL 标准里的"全局契约",一个是分布式引擎自造的"局部动作",中间隔着一整套执行模型的差异。更麻烦的是,"sort by" 这个词在代码、接口、命令行、甚至表格列头的排序按钮里都在出现,每一处的含义还不完全一样。
这篇想聊的就是sort by和order by的区别,但我不会停在"一个是全局排序、一个是分区内排序"这种一句话结论上。我会把 Hive、Spark 这类分布式 SQL 引擎里的子句差异、执行计划的读法、一次真实对照实验的数字、JS 和 pandas 里sortBy的命名陷阱,以及排序"看起来没生效"时的完整排查链路,一层层拆开。适合正在写离线数仓 SQL、调 Spark 作业、或者被分页重复数据折磨过的朋友看,新手可以拿来建立概念,老手可以对着排查清单查漏补缺。
1. 两个词根本不在同一个语义层级
1.1 ORDER BY 是有标准背书的全局契约
在 MySQL、PostgreSQL 这类常规关系库里写order by,你买到的是一个明确的承诺:整个结果集按指定的键排好序。SQL 标准里ORDER BY是查询表达式的组成部分,逻辑执行顺序排在SELECT、WHERE、GROUP BY、HAVING、窗口计算之后,是结果交付前的最后一环(LIMIT再往后)。
这个"全局"两个字含金量很高。它意味着不管你底下有多少个存储分片、多少个执行线程,最终拿到的那一坨行是有确定次序的。代价当然也有:引擎必须把所有候选行收拢起来做过比较,才能确定谁在前谁在后。数据量小的时候你感觉不到,数据量一大,"收拢"这个动作就成了瓶颈。
还有一个经常被忽略的点:ORDER BY排序键不唯一的时候,它只保证"按这个键有序",不保证相同键之间的相对次序。这是很多分页事故的根源,后面第五节会专门讲。
1.2 SORT BY 是特定引擎自造的局部动作
如果你在 MySQL 里写一句select * from t sort by id,会直接报语法错误——SORT BY不属于 SQL 标准。它只活在 Hive、Spark SQL 这类分布式查询引擎里,语义是:在 shuffle 之后,每个分区(reducer)内部对数据排序,不保证跨分区有序。
打个比方,一副扑克分给八个人,每个人把自己手上那摞从小到大理好。八个人各自手上都是有序的,但你把八摞牌按 1 号到 8 号的顺序摞成一叠,整体并不是有序的。SORT BY干的就是"各自理牌"这件事。
它存在的理由也很实际。分布式环境里做真正的全局排序,要么把数据全部收敛到一个节点(单点、慢、容易崩),要么得先采样切分范围再分发(实现复杂)。而大量业务场景其实只需要"相同的 key 挨在一起"——为后续的聚合、去重、窗口计算服务——局部排序就够了,成本还低一个数量级。这是典型的用语义强度换执行效率的取舍。
1.3 界面和接口里的 sort by 只是个动词短语
还有一层语境需要先剥离掉:表格列头上的 "Sort by"、命令行里的sort -k2、前端配置里的orderBy、文档里写的 "sort by date",这些地方的 sort by 就是个英文动词短语,意思是"按某字段排序",跟 SQL 子句没有血缘关系。这里的命名差异纯粹是风格问题,不是语义差异。
但这一层里有它自己的坑,而且坑还挺深:有的实现原地修改原数组,有的返回新数组;有的默认按字符串比较,有的按数值;有的稳定,有的不稳定;有的支持多字段混合升降序,有的只能全升或全降。第四节会把这些逐个拆开。
2. Hive 与 Spark 里 SORT BY 排的到底是什么
2.1 分区内有序,而且"有序"的边界取决于 reducer 数量
Hive 的执行流程是 map → shuffle → reduce。SORT BY作用在 reduce 侧:每个 reducer 拿到自己那份数据后,在本地做一次排序再输出。reducer 的数量由几个参数共同决定,常见的是hive.exec.reducers.bytes.per.reducer(默认 256MB 量级)、hive.exec.reducers.max,以及显式指定mapreduce.job.reduces。
这里有个特别阴险的坑:数据量小的时候只会起一个 reducer,此时SORT BY和ORDER BY的表现一模一样。于是很多人在测试环境拿几万行数据验证,"没问题啊,顺序对的",然后上了生产,数据量涨到几十 GB,reducer 变成 8 个、16 个,输出顺序立刻散架。我之前遇到过一回,下游报表按导出文件的行序做折线图,测试环境一路正常,上线后曲线开始锯齿状抖动,查了两天才定位到这句sort by。
第二个坑是SORT BY配LIMIT。ORDER BY ... LIMIT 10是全局 TopN,语义清晰。SORT BY ... LIMIT 10则是每个 reducer 各自取前 10 条,8 个 reducer 就吐出 80 条,而且合起来根本不是全局前 10。这个东西不报错、不告警,就是静悄悄给你一个错的答案。
2.2 ORDER BY 在分布式引擎里的两种实现路径
Hive 的ORDER BY默认会把数据收敛到单个 reducer 做全局排序,这就是它慢、且容易在数据倾斜时被单点拖死的根本原因。数据量一大,那个 reducer 既是计算瓶颈也是内存风险点。有些版本对ORDER BY ... LIMIT做了 TopN 优化,可以让多个 reducer 各算局部 TopN 再合并,但这是特例,别把它当成默认行为,版本之间差异也不小,用EXPLAIN看实际计划比翻文档靠谱。
Spark 的路径不一样,这点很多人搞混。Spark SQL 的ORDER BY不是简单退化成单分区,它走的是采样 + 范围分区:先抽样估算数据分布,按分位点切出若干个范围,把行按范围重新分发,再在每个分区内排序。这样输出的一批文件之间是按范围首尾衔接的,顺序读下来就是全局有序。所以"ORDER BY 一定只有一个 task"这句话在 Spark 上不成立,把 Hive 的经验硬套过来会得出错误结论。
顺带提醒一个语义上的边界:窗口函数里的ORDER BY(比如row_number() over (order by ts))定义的是窗口内部的行序和帧范围,它不会让最终输出结果集变有序。想让结果有序,外层还得再写一次ORDER BY。这条误解我在代码评审里见过不止一次。
2.3 DISTRIBUTE BY、SORT BY、CLUSTER BY 三兄弟的分工
搞清楚这三个的分工,SORT BY的定位就彻底清楚了。
| 子句 | 控制行去哪个分区 | 是否排序 | 输出是否全局有序 | 典型用途 |
|---|---|---|---|---|
DISTRIBUTE BY | 按列哈希分发 | 否 | 否 | 把相同 key 聚到同一分区,为后续操作准备 |
SORT BY | 不控制 | 分区内排序 | 否 | 分区内有序,配合DISTRIBUTE BY使用 |
CLUSTER BY | 按列哈希分发 | 分区内排序 | 否(但按分发键全局聚集) | 上面两个的简写,只能升序 |
ORDER BY | 全局收敛/范围分区 | 全局排序 | 是 | 最终结果交付、取全局 TopN |
CLUSTER BY本质是DISTRIBUTE BY和SORT BY用同一组列的语法糖,代价是不能指定ASC/DESC,只能升序加默认的 NULL 处理。要倒序就得老老实实把两个子句拆开写。
还有一个名字陷阱:建表 DDL 里的CLUSTERED BY (uid) SORTED BY (ts) INTO 32 BUCKETS是分桶声明,说的是"写入时按 uid 分 32 个桶,桶内按 ts 有序",跟查询里的SORT BY完全是两回事。文档里这俩词挨得近,第一次看很容易串。
3. 一次 3000 万行的对照实验
3.1 实验怎么搭的
光讲原理没体感,我用一份 3000 万行的模拟订单明细做过对照。表结构大致是(order_id string, uid string, amount double, dt string),其中 uid 做了轻度倾斜——大约 3% 的 uid 占了 20% 左右的行,这是业务数据的常态,也是排序类作业最容易翻车的地方。
集群参数没有特殊调优,hive.exec.reducers.bytes.per.reducer用的是默认量级。三条对照 SQL:
-- A:全局排序 select order_id, uid, amount from ods_order_detail order by amount desc limit 1000; -- B:分区内排序 select order_id, uid, amount from ods_order_detail sort by amount desc limit 1000; -- C:按 uid 聚集 + 分区内排序 select order_id, uid, amount from ods_order_detail distribute by uid sort by uid, amount desc;3.2 数字对照
| 方案 | reducer 数 | 输出文件数 | 耗时(相对) | 结果是否全局有序 | 风险 |
|---|---|---|---|---|---|
AORDER BY+LIMIT | 单点收敛或 TopN 并行 | 1 到少数几个 | 基准的 4 倍左右 | 是 | 单点内存、倾斜时雪崩 |
A 去掉LIMIT | 1 | 1 | 基准的 6 倍以上 | 是 | 极易 OOM |
BSORT BY+LIMIT | 8 | 8 | 基准的 1 倍 | 否 | TopN 结果错误、易误判 |
CDISTRIBUTE BY+SORT BY | 8 | 8 | 基准的 1.1 倍 | 按 uid 全局有序,按 amount 仅桶内有序 | 写法稍复杂 |
这里的具体耗时绝对值没有参考价值,集群规格、数据分布、序列化格式都会影响它。真正值得记住的是数量级关系:全局排序的代价通常是局部排序的 3 到 6 倍,而且数据倾斜越严重,差距越大。所以判断标准不该是"哪个更规范",而是"我到底需不需要全局有序"。
3.3 从执行计划里读出真相
判断一句 SQL 到底走了哪条路径,最直接的办法是EXPLAIN。
explain select order_id, uid, amount from ods_order_detail sort by amount desc;看Reduce Operator Tree里的sort order节点,以及Statistics里的NumReduceTasks。如果 reducer 数是 1,你这句SORT BY在小数据集上会伪装成全局排序;如果是 8,输出就是 8 个各自有序的片段。
Spark 侧看的东西不一样,重点盯Sort节点的global标志,以及它下面的Exchange类型:出现Exchange SinglePartition意味着数据被收拢到一处,出现Exchange rangepartitioning意味着走了范围分区,后者才是并行全局排序。
还有个很常见的误判场景:输出目录里多个 part 文件,每个文件内部有序。有人用cat part-*把文件拼起来看,觉得"这不是挺有序的吗",其实这个"有序"完全取决于文件名排序和分片顺序,纯属巧合。真要验证,取每个文件的首行和尾行做边界比对,一眼就能看出范围是否衔接。
4. 代码里的 sortBy 和 orderBy:命名差异背后的语义坑
4.1 JavaScript 里 sort 原地改、sortBy 通常返回新数组
前端这块最常见的翻车是"我明明排了序,原来那个数组怎么也被改了"。
const rows = [{ n: 'b', v: 2 }, { n: 'a', v: 2 }, { n: 'c', v: 1 }]; // 原地修改,返回值只是同一个数组的引用 rows.sort((x, y) => x.v - y.v); // rows 本身变了Array.prototype.sort是原地操作,而且不传比较器时默认按字符串比较——[10, 9, 1].sort()得到的是[1, 10, 9],因为'10' < '9'。ES2019 之后它保证稳定排序,相同键的相对次序会保留,但很多老环境的 polyfill 不保证。
lodash的sortBy是另一套风格:返回新数组、支持传多个排序键、稳定。代价是不能直接指定降序,得靠取负值或者包一层取反。
import { sortBy } from 'lodash'; const sorted = sortBy(rows, ['v', 'n']); // 升序,多键 const byVDesc = sortBy(rows, r => -r.v); // 数值降序多键混合升降序,原生sort反而更清晰,把比较逻辑写全就不会有歧义:
const cmp = (a, b) => a.v !== b.v ? a.v - b.v : b.n.localeCompare(a.n); // v 升序,n 降序 const result = [...rows].sort(cmp); // 先拷贝,别污染原数组React 或状态管理里尤其要注意这一条。直接对 state 里的数组调sort,引用没变,某些浅比较逻辑就检测不到更新,页面不刷新;或者反过来,你只是想展示一个排序视图,结果把共享的数据源改了,别的组件跟着乱。习惯做法就是先展开再排。
4.2 pandas 和 PySpark:同一个词,两种分区语义
pandas里叫sort_values,参数是by,看起来像sort by的直译:
df.sort_values( by=['uid', 'ts'], ascending=[True, False], na_position='last', kind='mergesort' # 稳定排序,需要保持相同键的原始次序时用 )默认的quicksort不稳定,相同键的相对次序可能变。做多阶段排序、或者后面还要靠原次序做二次判断的场景,明确指定mergesort是省事的做法。
PySpark 这边,两套 API 的对应关系几乎是教科书级的:
# 等价于 ORDER BY:全局有序 df.orderBy(F.col('ts').desc()) # 等价于 SORT BY:仅在分区内部有序 df.sortWithinPartitions(F.col('ts').desc()) # 想清楚分区边界:先按 uid 重分区,再分区内排序 df.repartition(8, 'uid').sortWithinPartitions('uid', 'ts')坑在于toPandas()和写文件这两个出口。sortWithinPartitions之后collect()到本地,多分区结果的拼接顺序取决于任务完成顺序,很可能不是你以为的那个顺序。写 parquet 目录同理,多个 part 文件各自有序,读目录的时候如果只读了前几个文件做预览,很容易得出"排序生效了"的错误结论。
4.3 ORM 和接口层:orderBy 的参数形态决定成败
ORM 层的orderBy看着简单,细节不少。Prisma 用数组表达多字段顺序:
await prisma.order.findMany({ orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], });数组形式的好处是顺序明确。用对象形式({ createdAt: 'desc', id: 'asc' })的时候,字段顺序依赖对象键的遍历顺序,虽然现代规范对字符串键的遍历顺序有保证,但只要中间经过一次序列化、反序列化、或者某个中间层重建了对象,顺序就可能变。多字段排序我一律用数组,省得给自己埋雷。
另一个常被忽略的点:排序字段没有索引。ORDER BY在数据库里如果没有可用索引,就得把所有候选行捞出来现场排(filesort 之类),这时候加个复合索引(status, created_at)往往比改 SQL 有效得多。索引的列顺序要和ORDER BY的列顺序、方向配合,混着升降序的复合索引在某些库里用不上,这点值得单独查一下所用数据库的行为。
5. 顺序"看起来没生效"时的排查链路
5.1 第一步:先确认你的假设是不是错的
排序出问题,九成以上不是引擎的锅,是对语义的假设错了。我习惯先问三个问题:
- 你验证的时候,数据量有多大?分区数是多少?
- 你是看整个结果集,还是只看了某个文件、某一页?
- 这个顺序是必须全局有序,还是只要"相同 key 挨在一起"就够?
前面那个跑批的例子就是典型:测试环境数据小,reducer 只有 1 个,SORT BY表现和ORDER BY一致,验证通过纯属巧合。把分区数显式调成 8 再跑一遍,问题立刻复现。能稳定复现的 bug 才好修,所以第一步往往是把偶发变成必现。
5.2 第二步:NULL、类型、排序规则三连
顺序不对但既不报错也不稳定,先查这三样。
| 问题 | 表现 | 处理方式 |
|---|---|---|
| NULL 位置 | 空值全跑到最前或最后,和预期相反 | 显式写NULLS FIRST/NULLS LAST,或COALESCE兜底 |
| 类型不匹配 | '10'排在'9'前面 | 排序前做类型转换,别拿字符串存数字 |
| 排序规则 | 大小写混排位置怪、中文按拼音还是笔划不一致 | 明确指定 collation,或统一转小写后排序 |
NULL 的行为在各库之间差异很大:MySQL 升序时 NULL 在最前,PostgreSQL 升序时 NULL 在最后,另一类商业数据库默认也是 NULL 排在最后。跨库迁移或者多数据源汇总的时候,这一段不显式写清楚,结果一定对不上。
类型问题更隐蔽,因为很多引擎会做隐式转换,转得对不对得看运气。字段设计阶段就别用字符串存金额、日期、编号,能省掉后面一大半的对账工作。
排序规则这条在中文场景下尤其明显。同一个字段,按拼音排和按笔划排出来的顺序完全不同,大小写敏感和不敏感也会让Apple和apple的位置变。做榜单、做通讯录、做字母索引用得上的场景,把 collation 定死并且写进文档,比事后解释省事。
5.3 第三步:分页加非唯一排序键的经典事故
这个必须单独说,因为它太常见了。ORDER BY create_time DESC LIMIT 10 OFFSET 20,如果create_time有大量重复值(同一秒创建的批量数据),数据库对相同值的行返回顺序是不确定的,翻页时就会出现同一条数据在两页里都出现,或者某条数据一页都没出现。
修法很简单:加一个唯一键作为补充排序键,把全序补完整。
select id, uid, create_time from t order by create_time desc, id desc limit 10 offset 20;深分页还有性能问题,OFFSET越大越慢,因为前面那些行还是要被扫描和丢弃。数据量上到百万级、翻到几百页的时候,换成游标分页(keyset pagination)收益非常明显:
select id, uid, create_time from t where (create_time, id) < (:last_time, :last_id) order by create_time desc, id desc limit 10;前提是(create_time, id)上有合适的索引,并且排序方向要和索引方向对齐。这套写法在无限滚动的列表接口里几乎是标配,比OFFSET稳得多。
6. 选型:什么场景该用哪个
6.1 按目的分三类,答案自然浮现
| 你的目的 | 该用什么 | 理由 |
|---|---|---|
| 交付给人看的报表、全局 TopN、严格有序的最终结果 | ORDER BY,尽量配LIMIT | 语义确定,代价可控 |
| 为后续聚合、去重、窗口计算准备数据,只需同 key 聚集 | DISTRIBUTE BY+SORT BY | 成本低一个数量级,且能控制分区边界 |
| 接口返回列表、前端渲染 | 数据库ORDER BY+ 索引,或内存sortBy | 数据量小,可读性优先 |
判断的核心其实就一句:这个顺序是要给别人看的,还是要给下一步计算用的。给人看的,要全局有序;给计算用的,同 key 聚集通常就够了。
6.2 DISTRIBUTE BY 加 SORT BY 的正确写法
这个组合有个容易写错的地方:SORT BY的列必须以DISTRIBUTE BY的列为前缀,否则分区内还是可能乱。
-- 正确:同 uid 进同一分区,分区内按 uid, ts 排 distribute by uid sort by uid, ts -- 错误:uid 相同进同一分区,但分区内只按 ts 排 distribute by uid sort by ts第二种写法下,分区内确实按 ts 有序了,但不同 uid 的行交错在一起,你想按 uid 顺序读取就做不到。第一种写法能保证"按 uid 全局有序",而 ts 只在同一个 uid 内部有序——这通常正是时间序列类数据想要的形态。
CLUSTER BY就是第一种写法的简写版,代价是不能指定升降序。要按 ts 倒序,就必须老老实实拆成两个子句。
6.3 参数层面的经验值
调过几轮之后,我形成了几个相对固定的习惯,供参考:
hive.exec.reducers.bytes.per.reducer从默认值往下调到 128MB 左右,reducer 数变多,单个任务压力小,SORT BY的并行度也上来了,但小文件会变多,得配合下游的合并策略。- 单 reducer 承载的数据量控制在一个能扛住的量级。
ORDER BY之前先把无关列裁掉、把明细预聚合成更粗的粒度,往往比换引擎、加机器都有效。 - Spark 侧
spark.sql.shuffle.partitions直接决定了sortWithinPartitions之后的分区数,默认值 200 在中小数据量下明显偏大,空任务一堆,调到和数据规模匹配的量级能省不少时间。 - 输出小文件问题:
SORT BY之后的文件数等于 reducer 数,如果下游是按目录扫描的,先做分区裁剪再合并,不然读放大会很难看。
我自己现在写这类 SQL 的顺序是:先问清楚下游到底要不要全局有序,如果只是要"同一个用户的行为按时间挨着",直接distribute by uid sort by uid, ts,比order by省出来的时间通常够再跑一遍数据校验;只有确实要出榜单、出 TopN 的时候,才上ORDER BY并且一定带LIMIT。这么做还有一个额外好处——单点任务少了,夜里被 OOM 告警叫醒的次数明显下降。