你有没有过这种经历:一条慢查询看了半天,明明索引都加了,分页还是很慢?(a, b) > (x, y) 这种写法,我写了五年 SQL 都没真正用过。直到某次处理一个后台交易流水深分页问题,有人在我旁边敲了一行 SQL:WHERE (created_at, id) < ('2025-06-01 10:00:00', 10086)。我愣了两秒,差点问“这能编译过吗”。他淡淡地说,这是行值比较。那之后我翻了不少文档才反应过来,原来多列条件可以直接放进括号里整体判断,而且这套逻辑和ORDER BY多列排序用的是同一套字典序。这篇文章就把行值比较的规则、适用场景,以及我踩过的 NULL 和索引相关的坑一次性说清楚。
1. 先弄懂它在比什么:不是元组相加,而是字典序
1.1 一次慢查询逼出来的发现
当时的业务是一个流水后台,页面固定展示 20 条,用户能往后翻几百页。翻到 300 页以后,接口经常超时,原始 SQL 长这样:
SELECT id, created_at, amount FROM t_receipt ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 5980;索引一开始是有的,(created_at, id)联合索引,单看执行计划也走了索引。问题是 OFFSET 越深,数据库越要往前扫描更多行才能丢掉。旁边的人把 SQL 改成:
SELECT id, created_at, amount FROM t_receipt WHERE (created_at, id) < ('2025-06-01 10:00:00', 10086) ORDER BY created_at DESC, id DESC LIMIT 20;我当时的第一个反应是:两个括号包着两列,怎么还能直接比大小?后来去查文档才知道,这在 SQL 里是标准支持的行值比较(row value comparison),并不是某个数据库私加的语法。
如果按我原来的习惯,这一段条件我会写成:
WHERE created_at < '2025-06-01 10:00:00' OR (created_at = '2025-06-01 10:00:00' AND id < 10086)两种写法本质等价,但前者把“按多列组合排序后的边界判断”压缩进了一个括号,后者要手动展开两层条件,列数越多越容易写错。
1.2 官方的比较规则:先比第一列,相等再看第二列
行值比较的规则,和查字典是一样的。假设要比较两行(a, b)和(x, y),比较器会先看第一对字段a和x;如果已经能分出大小,直接得出结果;如果a等于x,再看第二对b和y;以此类推。
用 SQL 展开就是这样:
-- 两列 (a, b) > (x, y) -- 等价于 a > x OR (a = x AND b > y) (a, b) >= (x, y) -- 等价于 a > x OR (a = x AND b >= y) -- 三列 (a, b, c) > (x, y, z) -- 等价于 a > x OR (a = x AND b > y) OR (a = x AND b = y AND c > z)注意,这里的“>”不是把两个元组相加、求长度、求哈希之后比大小,而是逐列递进判断。它和ORDER BY a, b的排序方向是一一对应的:ORDER BY a, b决定了记录之间的先后顺序,而(a, b) > (x, y)就是在问“如果按这个顺序排,前一条是否严格靠后”。这个对应关系是整个写法的灵魂。
1.3 10 秒钟的验证脚本
如果你在 MySQL、PostgreSQL 或 SQLite 环境里,可以直接运行下面几条 SQL 验证一下:
-- 第一列相等,第二列决定结果 SELECT (1, 2) > (1, 1) AS c1; -- true -- 第一列相等,第二列反方向 SELECT (1, 1) > (1, 2) AS c2; -- false -- 第一列不同,第二列直接不重要 SELECT (2, 0) > (1, 100) AS c3; -- true -- 有 NULL 时结果不再是普通 true/false,详见第 4 节 SELECT (1, NULL) < (2, 3) AS c4;跑完这三条,基本就能建立直觉:第一列权重最高,第二列只在第一列相等时才参与决定。遇到(2, 0)和(1, 100)这种例子,容易下意识去比 0 和 100,但这不符合字典序规则,第一列 2 已经大于 1 了,第二列根本轮不到比较。
1.4 支持这个写法的数据库一览
| 数据库 | 状态 | 备注 |
|---|---|---|
| PostgreSQL | 支持 | ROW(a,b)和(a,b)都可用,执行计划通常能识别为索引条件 |
| MySQL 5.7+ | 支持行构造器比较 | 语法没问题,但优化器不一定总走理想索引,必须 EXPLAIN 验证 |
| Oracle | 支持 | 行值表达式是老特性,平时很少有人用 |
| SQLite 3.15+ | 支持 | 轻量数据库也有完整的 row value 能力 |
| SQL Server | 不支持 | (a,b) > (x,y)这种写法直接报错,需要 OR / UNION ALL 绕路 |
看到这张表你就明白,为什么很多人写了多年 SQL 也没用过这个语法:它不是某个数据库一家的私有特性,而是标准里早就存在的东西,只是不同数据库的落地程度差异太大。
2. 为什么写了五年 SQL 也没发现:三个现实原因
2.1 单列过滤写顺了,思维会形成路径依赖
日常业务里,绝大多数 SQL 都是单列条件,比如status = 1 AND created_at > ?,要么就是 `id IN (...) %sql 这类等值过滤。ORM 框架生成的条件也基本是单列拼接,很少有机会把一个多列元组当成整体去比较。时间一长,人的条件反射就是:多列过滤只能靠 AND/OR 手动展开。
我后来反思过,自己长期觉得“多列排序只能是 ORDER BY 里逗号分隔”,但从来没想过,排序后的多列字段本身可以被放在括号里当成一个整体来比较。技术能力没问题,纯粹是思维路径依赖。打破它只需要一个契机:亲眼看到别人写出(created_at, id) < (...)。
2.2 数据库方言分裂,让这个特性处于“半公开”状态
如果你是从 SQL Server 入门的,大概率从来没见过这个语法,因为 SQL Server 不支持。换到 MySQL 或 PostgreSQL 之后,也很少有人专门告诉你“行值比较”这个词。官方文档里它的名字是 row value expression 或 row constructor,如果不主动搜索“行值比较”,很难在常见教程和示例里遇到。
所以这个特性一直处于“标准里有、文档里有、但圈子里没人用”的状态。再加上很多老教材停留在WHERE a=? AND b=?的写法,也就没什么人提起。
2.3 等价展开式会随列数增多越来越难维护
行值写法看起来“小众”,但它真正解决的是手写展开式的维护问题。两列时 OR 写法还勉强能接受,三列时已经有点绕:
(a, b, c) > (x, y, z) -- 等价于 a > x OR (a = x AND b > y) OR (a = x AND b = y AND c > z)四列、五列时,前面的“相等判断”要不断叠加,手写很容易漏掉某一条分支。行值写法无论多少列,永远是一行:
(a, b, c, d) > (x, y, z, w)这就是我在第 3 节做深分页游标时偏爱它的核心原因:边界由哪些列组成,一眼就能看出来,不需要在一堆括号和 OR 里反复核对。
3. 最值钱的用法:把深分页从 OFFSET 换成行值游标
3.1 为什么 OFFSET 越翻越慢
LIMIT 20 OFFSET 5980看起来像是“跳过 5980 条再取 20 条”,但大多数数据库的实现是先扫描到前 5980 条的位置,再丢弃它们,最后返回剩余部分。翻的页数越大,扫描和丢弃的基数越大,耗时自然线性上涨。
如果查询里还带 JOIN、GROUP BY、大字段排序,这个代价还会被进一步放大。最简单的类比是:一摞卡片要找到第 10001 张,你得先数过前 10000 张,而不是直接抽出来。
行值游标分页的思路完全不同:不再告诉数据库“跳过多少行”,而是告诉它“从哪一条记录之后继续”。数据库只要走到对应索引位置,直接从那里往下读固定数量即可,耗时与翻页深度基本无关。
3.2 Keyset 分页的完整落地写法
第一页不需要特殊处理:
SELECT id, created_at, amount FROM t_receipt ORDER BY created_at DESC, id DESC LIMIT 20;取返回结果里最后一条记录的created_at和id,作为下一页的游标。下一页查询写成:
SELECT id, created_at, amount FROM t_receipt WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;这里的操作符方向特别容易搞反。因为排序是DESC,上一页最后一条是当前可见范围内“位置最靠后”的记录,下一页要找的是比它更靠后的记录,也就是在字典序上更小的行,所以用<。如果排序是ASC,对应的游标条件就要用>。
3.3 为什么游标里非要带上主键
如果只用created_at一个字段做游标,很容易漏数据。假设同一秒里产生了 100 条流水,当前页最后一条落在这一秒的第 5 条,下一页如果只判断created_at < ?,那么同一秒内剩下的 95 条全都会被漏掉。
加上主键id之后,id的全局唯一性让游标能精确表达“上次看到的是哪一条”。这也是为什么 keyset 分页的标准姿势是把排序字段和主键绑在一起:
ORDER BY created_at DESC, id DESC对应索引直接建(created_at, id)。如果数据库支持 DESC 索引,也可以考虑(created_at DESC, id DESC),不过多数情况下普通升序索引也能通过反向扫描满足。
3.4 滚动清理历史数据
深分页之外,另一个高频场景是滚动批量清理历史数据。直接DELETE ... WHERE created_at < ? LIMIT 2000在某些数据库上语法不统一,更稳重的做法是先取 id,再删除:
# 伪代码示意 while True: ids = SELECT id FROM t_log WHERE (created_at, id) < (:batch_time, :batch_id) ORDER BY created_at ASC, id ASC LIMIT 2000 DELETE FROM t_log WHERE id IN (ids) if len(ids) < 2000: break这种方式不会每次全表扫一遍,也不会一条长事务锁住大量数据,很适合定时任务里滚动归档。
4. 我踩过的坑:NULL、列数不匹配、索引失效和排序规则
4.1 NULL 会让整个比较变成 UNKNOWN,分页直接缺行
这是行值比较最容易踩的坑。SQL 的三值逻辑里,只要参与比较的字段出现 NULL,比较结果可能是 UNKNOWN,而WHERE只保留 TRUE。也就是说:
SELECT (NULL, 1) < (1, 2);结果不是普通的 true/false,而是 UNKNOWN。放到分页查询里,带有 NULL 排序字段的记录会被悄悄过滤掉,表现为“某一页少了数据”或“游标翻不过去”。
解决方法是两层:建表时把排序字段设计成NOT NULL DEFAULT ...;如果历史数据已经有 NULL,查询时用COALESCE(created_at, '2000-01-01 00:00:00')兜底,并且ORDER BY里要用完全相同的 COALESCE 表达式,否则比较逻辑和排序逻辑各说各话,游标照样乱。
4.2 左右两边列数必须一致,ROW 关键字也不是哪都能用
写行值比较时,左右两边的列数必须完全一致。(a, b) > (x)这种写法,不管在哪个数据库都是语法错误。另外,PostgreSQL 里可以写ROW(a, b),但 MySQL 不支持ROW关键字,只能写(a, b)。跨数据库迁移时如果用了ROW(...),到了 MySQL 就会直接报错。
行构造器里的元素可以是列、常量、函数结果,但两边类型要能比较。比如日期列就不要和字符串常量直接比较,虽然数据库会做隐式转换,可一旦转换规则不直观,排查起来很被动。
4.3 优化器不一定用行值比较来扫索引,必须 EXPLAIN
行值写法很优雅,但优化器是不是买账,每个数据库、每个版本的表现都不一样。我的习惯是写完立刻跑:
EXPLAIN SELECT id, created_at, amount FROM t_receipt WHERE (created_at, id) < ('2025-06-01 10:00:00', 10086) ORDER BY created_at DESC, id DESC LIMIT 20;在 PostgreSQL 里,如果执行计划能看到类似Index Cond: (ROW(created_at, id) < ROW(...))的输出,说明它把行值比较直接映射成了索引条件,这是理想情况。MySQL 里则需要看type、rows、Extra字段,如果变成全表扫描或扫描行数很大,就要考虑改写。
我常用的改写方案有两个。第一个是展开成 OR 形式:
WHERE created_at < ? OR (created_at = ? AND id < ?)第二个是用 UNION ALL 分段查询,让每一段都走独立的索引范围:
SELECT * FROM ( SELECT id, created_at, amount FROM t_receipt WHERE created_at < ? UNION ALL SELECT id, created_at, amount FROM t_receipt WHERE created_at = ? AND id < ? ) AS u ORDER BY created_at DESC, id DESC LIMIT 20;为什么优先用 UNION ALL 而不是 OR?因为 OR 写法虽然可读性好,但优化器不一定把它转换成两个独立的索引范围合并;UNION ALL 是显式把一个游标分成“小于”和“等于且小于”两段,每一段都能稳定利用联合索引。这也是 MySQL 社区实现 keyset 分页最常见的落地写法。
4.4 字符串排序规则会影响“相等”,进而影响下一列比较
当第一列是字符串时,a = x的判断结果取决于数据库的排序规则。比如在utf8mb4_general_ci下,'A' = 'a'是成立的,那么('A', 1) > ('a', 2)的规则会变成:第一列相等,继续比较第二列,而不是提前分出大小。
这通常和ORDER BY的行为保持一致,但如果业务上期望严格区分大小写,就要统一使用utf8mb4_bin或指定COLLATE。核心原则是:比较和排序必须遵循同一套排序规则,否则你用一套规则排序,再用另一套规则判断边界,结果一定是错乱的。
4.5 参数类型别乱传,隐式转换会影响索引和结果
行值游标里经常传时间、数字、字符串,最怕的是 ORM 把Long类型序列化成字符串再塞进 SQL。列是BIGINT,参数是'10086',数据库通常会隐式转换,大部分场景没问题;但一旦碰上字符集、排序规则或奇怪的函数包装,隐式转换会让索引失效或结果不符合预期。传参时尽量保持类型与列类型一致,Date传Date,Long传Long,别图省事统一转字符串。
5. 和它同族的几个写法,也值得放进工具箱
5.1 多列 IN:批量过滤的升级版
行值比较不止有>和<,多列IN也属于同一族。比如:
WHERE (user_id, status) IN ((1001, 1), (1002, 2), (1003, 1))这比拼接一长串(user_id = 1001 AND status = 1) OR ...清晰得多。多列唯一键上的批量判断尤其好用,不过要注意元组里一旦有 NULL,IN的规则会变复杂,尽量保证列是 NOT NULL。
5.2 行子查询比较:断点续传的好帮手
行值比较也可以和子查询结合:
SELECT id, created_at FROM t_sync_log WHERE (version_no, id) > ( SELECT version_no, id FROM t_release WHERE release_id = :rid ) ORDER BY version_no, id;这种写法很适合“从上次处理到的地方继续”的同步任务。只要把上次的断点信息存到游标表里,下次拉起时直接比较元组即可。注意子查询必须恰好返回一行,如果可能多行,要先LIMIT 1。
5.3 别把行值比较和聚合最值混为一谈
还有一个非常常见的误解:
-- 想取“组合最大值”,但这样是错的 WHERE (a, b) = (SELECT MAX(a), MAX(b) FROM t)这里的MAX(a)可能来自一行,MAX(b)来自另一行,组合起来并不是任何一行真实存在的值。如果想找“按 a、b 排序后最后一条”,正确写法是:
ORDER BY a DESC, b DESC LIMIT 1;或者使用支持该语义的数据库特性。行值比较表达的是“两条记录之间的大小关系”,不是“整列聚合后的最值”,这两件事不要混在一起用。
我的建议很简单:别把行值比较当成炫技,日常 90% 的查询仍然用单列条件就够了,它解决的是“多列共同组成一个有序边界”这一类特定问题。但当你遇到深分页、断点续传、列表游标这些场景时,它比手工拼接 OR 稳得多。
把后台列表改造成 keyset 分页之后,我最直观的感受是心里有底了:无论翻到第几页,查询都能稳定使用索引,耗时不会因为页数变大而增长。最后再分享一个小技巧:把(created_at, id)这类游标封装成通用工具方法,前端只传lastCreatedAt和lastId两个参数,内部生成按字典序比较的 SQL。这样团队其他人不用背语法,也能自动享受这个写法带来的性能收益。