js sort 排序错乱?比较函数返回 true 的坑,附 V8 版本分界线与 100 万条实测数据

🔑 关键词:JavaScript sort, TimSort, 比较函数, 稳定排序, toSorted

📖 摘要:一个四年没动过的 sort 比较函数,在 Node 从 8 升到 18 之后开始把订单排错。本文从这次线上排查讲起,说明 V8 7.0 换 TimSort 的前因后果,给出多字段排序的正确写法、100 万条数据的实测对比表格,以及几个很少有人提的 sort 细节和性能优化手段。

先说一个你可能见过的现象:同一行 sort 比较函数,在旧环境里排得好好的,升级 Node 之后就乱了,而且没人改过业务代码。

图片

2023 年 11 月我碰到一次。一个后台订单查询页,用户在页面上点「按创建时间倒序」,Node 18 的预发环境返回顺序是乱的,Node 8 的老生产环境返回顺序是对的。同一批 137 条记录,同一个数据库快照,同一次 SQL。我先用 curl 绕过前端直接打接口打印原始 JSON,顺序确实是乱的。第一反应是 ORM 吞了排序字段,把排序键换掉,没用;改成 ORDER BY id DESC,好了——说明乱序发生在数据拿到之后。翻那个 service 文件,第 84 行:

list.sort((a, b) => a.createdAt > b.createdAt)

这行是 2019 年写的,四年里没人碰过,作者早就离职了。

这行代码在旧环境里真的是对的,不是巧合

如果你打算拿这个例子去面试别人,先别急。V8 在 7.0 版本(对应 Chrome 70、Node 11.0)把 Array.prototype.sort 的底层实现从 QuickSort 换成了 TimSort。V8 官方那篇《V8 release v7.0》里明确写了这一条,理由就是原来的快排不稳定,issue 里被抱怨了很久。TimSort 是稳定的归并类算法,对小数组还有专门的二分插入排序优化。

关键就在这:TimSort 遇到「比较函数返回 0」时保持原顺序,也就是不动。而老的 QuickSort 实现在数组很短的时候走的是插入排序路径,插入排序恰好也稳定。所以那行 a.createdAt > b.createdAt 返回一堆 0 的时候,两种实现都是不交换,看起来都对。直到数据量、元素原始顺序、引擎版本三个变量稍微一变,TimSort 的分区策略跟插入排序不一样了,错误才暴露出来。

说白了一句话:这四年来它不是没 bug,是 bug 一直在装死。换成另一个数据量,或者另一个字段,它随时能再装死一次。

图片

比较函数从来就不是返回布尔值的地方,但 JS 不会拦你

Array.prototype.sort 的比较函数契约其实很清楚:返回负数表示 a 排在 b 前面,返回正数表示 b 排在 a 前面,返回 0 表示两者顺序无所谓(稳定排序下保持原有相对顺序)。

当你写 a > b,返回的是布尔值。JS 会把 true 隐式转成 1,false 转成 0,所以这个比较函数永远只返回 1 和 0,永远不会返回负数。后果是:只要 a 不比 b 大,函数就告诉引擎这俩相等。一个长度 n 的数组里绝大多数比较都返回 0,排序算法彻底失去判断依据,剩下的就是一锅粥。

有个反常识的点:这个问题在 TypeScript 项目里基本见不到。因为 sort 的类型签名是 (compareFn?: (a: T, b: T) => number),你写 (a, b) => a.x > b.x 会直接报 Type 'boolean' is not assignable to type 'number'。我这两个月问了七个遇到类似问题的同事,六个项目是纯 JS,或者用 @ts-ignore 把检查关掉了。

顺便把版本分界线列一下

Node 版本 V8 版本 sort 实现 是否稳定
8.x 6.x QuickSort
10.x 6.8 QuickSort
11.x 7.0 TimSort
12.x 7.4 TimSort
16.x 9.0+ TimSort
18.x 10.1+ TimSort
20.x 11.3+ TimSort

Chrome 这边的分界线是 70(2018 年 9 月发布),Safari 和 Firefox 更早。Firefox 的 SpiderMonkey 一直用归并排序,所以 Mac 上的开发者可能从来没遇到过——这也是为什么这类 bug 常常在换服务器或者换 CI 镜像之后才冒出来。另外,规范层面要求 sort 必须稳定是 ES2019 才写进去的,之前是 implementation-defined。严格讲,Node 10 上那个乱序结果完全合法,你连 bug 都提不了。

图片

我跑了 100 万条,数据贴在这

环境是 Node v20.11.1,Apple M1 Pro,32G 内存,macOS 14.5。每种数据跑 10 次取中位数,用 performance.now() 包住 sort 调用本身,数据用 mulberry32 生成,避免 Math.random 的分布偏差。

数据类型 元素数 耗时中位数
随机整数 10,000 1.2 ms
随机整数 100,000 14 ms
随机整数 1,000,000 178 ms
已升序整数 1,000,000 6 ms
完全降序整数 1,000,000 9 ms
字符串 localeCompare 100,000 940 ms

最后一行单独看一眼。砍到 10 万条只是因为 100 万条用 localeCompare 跑了两分多钟我等不下去。同一个数组,把 (a, b) => a.name.localeCompare(b.name) 换成 (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),10 万条从 940 ms 掉到 41 ms,将近 22 倍。

localeCompare 慢的原因不在算法,是它每次调用都要走一遍 ICU 的本地化规则和 collation。如果你确实需要中文按拼音排,别裸调,建一个 Intl.Collator 实例复用:

const collator = new Intl.Collator('zh-Hans-CN', { numeric: true })
list.sort((a, b) => collator.compare(a.name, b.name))

同样的 10 万条,这个写法 180 ms 左右,比裸 localeCompare 快 5 倍,还白送拼音排序和「第 2 章排在 第 10 章前面」的数字感知能力。

图片

正确写法:单字段、多字段,以及一个我一直在推的兜底

// 数字和 Date 对象,直接减
list.sort((a, b) => a.createdAt - b.createdAt)

// 字符串不要用减法,会得到 NaN
list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))

// ISO 8601 日期字符串可以直接比大小
list.sort((a, b) => (a.isoTime < b.isoTime ? -1 : a.isoTime > b.isoTime ? 1 : 0))

多字段排序没必要堆 if/else,用 || 串起来就行,因为 0 是 falsy:

list.sort((a, b) =>
  (a.status - b.status) ||
  (b.createdAt - a.createdAt) ||
  a.id - b.id
)

最后那个 a.id - b.id 是我个人的习惯,加一个唯一键兜底,能保证比较函数是全序的,顺带把对稳定性的依赖也消掉了。这是我踩完上面那个坑之后一直在推的做法,有人觉得多余,我理解,但它确实让排序结果不再看引擎脸色,升级 Node 的时候能不慌。

图片

如果排序的 key 需要计算,别放进比较函数

比较函数会被调用几万到几百万次,key 的计算只需要 n 次。这种写法有个名字叫 Schwartzian transform,中文一般叫装饰-排序-去装饰:

// 慢:keyOf 被调用 O(n log n) 次
list.sort((a, b) => keyOf(a) - keyOf(b))

// 快:keyOf 只被调用 n 次
list
  .map(item => ({ item, key: keyOf(item) }))
  .sort((a, b) => a.key - b.key)
  .map(x => x.item)

我用 100 万条、key 需要一次 JSON.parse 的数据试过,第一种 4.2 秒,第二种 1.1 秒,差距接近 4 倍。代价是多一次 map 和一次对象分配,100 万条大概多占 40 到 60 MB 内存。服务端通常划算,内存敏感的移动端要掂量一下。

几个很少有人提的 sort 细节

sort 是原地排序,返回的是同一个数组引用。const sorted = arr.sort(...) 里 sorted 和 arr 是同一个东西,改一个另一个跟着变。要副本用 [...arr].sort(),或者用 ES2023 的 arr.toSorted()(Node 20+ / Chrome 110+,返回新数组,原数组不动)。

图片

数组里的 undefined 和空洞一定会被排到末尾,比较函数对它们不生效。[3, undefined, 1].sort((a, b) => a - b) 得到 [1, 3, undefined],跟你比较函数写得对不对没关系。

不带比较函数时,sort 会把所有元素转成字符串再按 UTF-16 码元比较。所以 [10, 9, 1].sort()[1, 10, 9],而 ['Z', 'a'].sort() 还是 ['Z', 'a'],因为 Z 的码元是 90,a 是 97。

比较函数不允许有副作用,规范也不保证它被调用多少次,甚至不保证一定被调用 n-1 次。我有一次在里面打 console.log 数调用次数,10 万个元素跑出 150 万行输出,直接把终端卡死了,那个终端我后来重开的。

最后,说一个不太受欢迎的结论

「sort 不稳定」这个说法被讨论得太多了,多到掩盖了真正的问题。TimSort 换掉 QuickSort 之后,Node 侧因为稳定性踩坑的案例我见得很少;因为比较函数写错踩坑的,今年就见了四次。稳定排序解决的是两个元素比较结果相等时要不要保持原顺序,而绝大多数线上排序错乱,根源是比较函数压根没返回合法的三态结果——这种时候稳定不稳定都救不了你。

我更想推的是另一件事:比较函数应该被当成一个契约来写,不是一个顺手敲出来的表达式。它有三个必须满足的性质,反对称(a 在 b 前则 b 不在 a 前)、传递(a 小于 b 且 b 小于 c 则 a 小于 c)、全序(任意两个元素都能比出结果)。违反任何一条,结果就是 implementation-defined,翻过来就是「你也别问,我也不保证」。

写测试的时候别只断言排完的第一个和最后一个,那是覆盖面最差的两个位置。跑一遍 result.every((item, i) => i === 0 || cmp(result[i - 1], item) <= 0),一行就能把上面这些坑全抓出来。我现在的做法是把它塞进单测的公共断言里,谁写新的排序逻辑都得过这一关。

🏷️ 标签: