前阵子排查一个翻页慢的接口,SQL 从LIMIT/OFFSET改成游标分页,我正要写WHERE created_at <= ? AND id <= ?这种双条件,同事在旁边说了句:“你直接写(created_at, id) < (?, ?)不行吗?”我愣了一下,跑了把EXPLAIN,索引命中得明明白白——这个行值比较(row value comparison)语法,我写了五年 SQL 居然一次都没用过。
后来我专门把复合排序、排行榜相邻记录、任务队列调度这些场景都翻出来试了一遍,才意识到这语法在特定条件下能省掉一大坨OR和子查询,但用不好也会踩坑。这篇文章就把我对(a, b) > (x, y)这类写法的理解完整写下来:它到底是什么、哪些场景值得用、数据库兼容性怎么样,以及哪些情况下我劝你还是老实地展开写。
1. 先弄明白:行值比较到底在比什么
很多教程讲到多条件查询就直接教AND/OR,很少提 SQL 标准里还有个“行构造器”的概念。我第一次看到(a, b) > (x, y)也是一脸懵:这不就是把两个比较用逗号硬凑到一起吗?实际上它背后有一套非常严谨的规则,并不复杂。
1.1 字典序:把多列拼成一个“虚拟键”
行值比较的规则就是按字典序(lexicographic order)逐列比较,跟你在字典里查单词、在 Excel 里按两列组合排序是一个道理:先比第一列,第一列能分出大小就结束;第一列相同,才继续比第二列;第二列还相同,就比第三列,依此类推。
看几个直观的例子:
-- PG / SQLite 写法 SELECT ROW(1, 2) > ROW(0, 99); -- true,第一列 1 > 0,胜负已定 SELECT ROW(1, 2) > ROW(1, 1); -- true,第一列相等,第二列 2 > 1 SELECT ROW(1, 2) > ROW(1, 2); -- false,逐列全部相等 SELECT ROW(1, 2) > ROW(2, 0); -- false,第一列 1 < 2,后面不用看了类似地,MySQL 里可以写成SELECT (1, 2) > (0, 99);,SQLite 和 PostgreSQL 也支持逗号包裹的行值写法。注意 MySQL 的SELECT (1, 2) > (0, 99)其实是把1, 2当成了单行表达式?MySQL 中(1,2)会被解析为 row constructor 吗?实测时最好显式加ROW(),不同方言略有区别。在 PostgreSQL 中我习惯用ROW(1,2) > ROW(0,99),SQLite 则支持(1,2) > (0,99)。
这个“先比第一列,平局再比下一列”的规则,本质上就是复合索引在 B-tree 里的搜索逻辑——所以行值比较和复合索引天然契合,这一点后面性能部分还会重点讲。
1.2 等价展开:它就是 OR 条件链的简写
如果有人觉得行值比较是“魔法”,那把它翻译成普通条件就一切都明白了:
(a, b) > (x, y) 等价于 a > x OR (a = x AND b > y)为什么?因为比较先看a:如果a > x,那不管第二列什么值,整体都大;如果a < x,整体直接小;只有当a = x时,才轮到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)所以它不是什么新运算符,而是把一串有规律的OR条件折叠成了更短的语法。你以前手写的WHERE a > ? OR (a = ? AND b > ?),本质就是行值比较的展开式。搞清楚这一点,就不会再觉得它神秘。
1.3 一个必须避开的误解:它不是“多个条件同时满足”
最容易犯的错是把(a, b) > (x, y)理解成a > x AND b > y。这两者差别巨大。
-- 假设数据:a=10, b=0,游标是 (x=5, y=5) -- 行值比较:(10, 0) > (5, 5) => true,因为第一列 10 > 5 已经赢了 -- AND 方式:10 > 5 AND 0 > 5 => false如果业务本意确实是“a 要大于 x 且 b 也要大于 y”,那请老老实实写AND。行值比较对应的是“组合键排序后的相对位置”,而不是“每列都超过某个阈值”。这个坑我在页面上踩过一次:那时候想把“成员等级大于某个值且积分大于某个值”当作下一批拉取条件,直接用(level, points) > (3, 1000),结果把等级 10、积分 0 的人全捞出来了,因为他们等级更大,行值比较根本不看积分。数据量大时这类错误特别难发现。
2. 哪些实战场景值得换成行值比较
场景比语法更重要。我不建议为了秀语法而滥用,但这几个需求几乎就是为行值比较量身定做的。
2.1 消息流游标翻页:替代 LIMIT/OFFSET 的经典用法
做过信息流、聊天记录、订单列表分页的人应该都有同感:LIMIT/OFFSET在翻到第 50 页以后明显变慢,因为数据库要把前 50 页的数据全扫一遍再抛弃。更好的方案是 keyset pagination,也就是“游标分页”:记住本页最后一条记录的排序键,下一页直接查“排在它后面”的记录。
假设我有一张用户消息表:
CREATE TABLE my_feed ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, created_at DATETIME NOT NULL, content VARCHAR(500), PRIMARY KEY (id), KEY idx_user_time (user_id, created_at, id) ) ENGINE=InnoDB;展示顺序是“按创建时间倒序”,而且同一秒内可能有多条消息,所以排序键必须是(created_at, id)这个组合,id在这里兼任 tie-breaker,保证顺序稳定。第一页正常查:
SELECT id, created_at, content FROM my_feed WHERE user_id = 1024 ORDER BY created_at DESC, id DESC LIMIT 20;拿到这一页最后一条的created_at = '2025-03-01 20:30:00', id = 98765,第二页就可以直接写:
SELECT id, created_at, content FROM my_feed WHERE user_id = 1024 AND (created_at, id) < ('2025-03-01 20:30:00', 98765) ORDER BY created_at DESC, id DESC LIMIT 20;这个写法的优雅之处在于:两个排序维度被放进同一个括号里,语义就是“比这个游标更旧的下一条”。在 PostgreSQL 和 SQLite 里,优化器能直接把这个条件转换成复合索引的扫描区间,完全不用 OFFSET 去“跳过”前面每一行。而如果你用created_at < ? AND id < ?这种写法,会把“更早时间但更大 id”的合法记录漏掉,用created_at <= ? AND id <= ?又可能重复读到上一条。只有展开成created_at < ? OR (created_at = ? AND id < ?)才和行值比较完全等价——那为什么不直接写括号版本呢?
2.2 排行榜:查出“压我一头”的那条记录
排行榜需求里经常要查“我前面一名是谁”。如果榜单的排序规则是分数从高到低、先达到分数的人排前面,也就是:
ORDER BY score DESC, achieved_at ASC这时候要查排在我(score=10086, achieved_at='2025-02-01 12:00:00')前面的那条,条件其实是“分数比我高,或者分数相同但达成时间比我早”:
SELECT user_id, score, achieved_at FROM high_scores WHERE score > 10086 OR (score = 10086 AND achieved_at < '2025-02-01 12:00:00') ORDER BY score DESC, achieved_at ASC LIMIT 1;显然,这里不能直接写(score, achieved_at) > (10086, ...),因为行值比较默认两列都是升序,而实际排序中第一列是降序、第二列是升序,方向不一致,语义就反了。那有没有办法继续用行值比较?有,就是让两列方向统一:如果业务接受“同分时后达成者排前面”,那ORDER BY score DESC, achieved_at DESC,就可以写(score, achieved_at) < (10086, ...)。但业务方向不对,就别硬拗语法。这个细节后面第 4 章还会展开。
2.3 任务队列调度:取下一个更紧急的任务
任务表经常会维护一个优先级队列,比如priority数字越小越紧急,queue_id递增标识入队顺序。消费端每次想取“比当前任务更紧急的下一个”,同时还要支持并发跳过已锁行(SKIP LOCKED),PostgreSQL 下可以直接写:
SELECT id, priority, payload FROM task_queue WHERE status = 'pending' AND (priority, queue_id) > (3, 100) ORDER BY priority ASC, queue_id ASC LIMIT 1 FOR UPDATE SKIP LOCKED;这里的(priority, queue_id) > (3, 100)就是“比当前游标更大/更靠后”的组合键条件。配合FOR UPDATE SKIP LOCKED,多个消费者可以安全地轮询任务,谁抢到谁处理,不会重复消费。我在实际项目里用它做过一个简单的延迟队列轮询,效果比把游标拆成两个字段再写一长串 OR 条件清爽得多。
2.4 归档旧数据:范围删除也能用
定期清理日志/归档表是 DBA 常干的事。如果归档边界是按(server_id, log_time)来定的——比如“server_id 小于 10,或者 server_id 等于 10 但日志时间早于 2025-01-01 的记录都可以删”——那展开写会很长:
DELETE FROM server_logs WHERE server_id < 10 OR (server_id = 10 AND log_time < '2025-01-01') LIMIT 1000;换成行值比较:
DELETE FROM server_logs WHERE (server_id, log_time) < (10, '2025-01-01') LIMIT 1000;语义完全一致,而且范围一目了然:删除的是“从最早到 (10, '2025-01-01') 之间”的所有组合键记录。这种写法在 PostgreSQL 配合(server_id, log_time)索引非常高效;在 MySQL 里因为范围比较的索引利用问题,我建议先测试执行计划再决定。
3. 性能视角:它到底怎么吃复合索引
语法再帅,查询计划不配合就是白搭。我把几个主流数据库的实测情况和我自己的经验整理了一下,这部分是重点中的重点,也是网上讲得最少的地方。
3.1 PostgreSQL:规划器能直接消化 ROW 条件
PostgreSQL 的 B-tree 索引天然支持多列范围扫描,所以(a, b) > (x, y)这种条件可以直接变成索引的扫描边界。我曾在(status, priority, queue_id)复合索引上跑过任务队列查询:
EXPLAIN SELECT id FROM task_queue WHERE status = 'pending' AND (priority, queue_id) > (3, 100) ORDER BY priority, queue_id LIMIT 1;执行计划里能看到类似这样的输出:
Index Scan using idx_status_priority_queue on task_queue Index Cond: ((status = 'pending'::text) AND (ROW(priority, queue_id) > ROW(3, 100)))也就是说规划器把行值比较翻译成了索引条件,而不是全表扫描后过滤。这也是为什么我在 PostgreSQL 上放心大胆地到处用它的原因——写起来短,走索引也顺,两全其美。
3.2 MySQL:不等值行比较的尴尬
MySQL 的情况就微妙多了。MySQL 5.7+ 支持行构造器语法,但官方文档在行构造器表达式那节明确提醒过:不等值比较中不建议使用行构造器,因为它们通常无法高效利用索引。我自己的实测也印证了这一点:在 MySQL 8.0 上写WHERE (created_at, id) < ('2025-03-01 20:30:00', 98765),有时候能走 range,有时候直接被优化器忽略掉行值部分,扫出来全表搞 filesort,非常看具体索引和版本。
但等值比较和 IN 这类用法在 MySQL 里是没问题的,比如:
SELECT * FROM my_feed WHERE (user_id, is_read) IN ((1024, 0), (1024, 1));这种写法 MySQL 处理得很好,也能正常命中复合索引。所以我给 MySQL 用户的建议是:行值比较优先用于等值和集合判断;如果是范围游标分页,写成展开式更稳:
SELECT id, created_at, content FROM my_feed WHERE user_id = 1024 AND (created_at < '2025-03-01 20:30:00' OR (created_at = '2025-03-01 20:30:00' AND id < 98765)) ORDER BY created_at DESC, id DESC LIMIT 20;如果 OR 形式让你担心优化器合并区间表现不佳,还可以拆成两个查询用UNION ALL合并,不过这会多一次访问,实在不行再用。大多数时候展开式加复合索引,执行计划已经足够漂亮。
3.3 索引设计上的配合经验
不管哪个数据库,想用好行值比较,索引列顺序非常关键。我的经验是:等值条件列放最前面,之后才是排序/范围参与行值比较的列,且顺序要与比较方向一致。
比如消息流查询,条件是user_id = 1024,排序是created_at DESC, id DESC,索引建立为(user_id, created_at, id)就是最优的:先固定user_id,再让(created_at, id)这个组合键在索引里形成一段连续区间供行值比较扫描。如果你只建了(created_at, id)全表索引,user_id不能直接定位,可能就白费了。
另一个容易被忽略的是排序稳定列。凡是时间/分数这类可能重复的排序键,务必把主键或某个唯一列放进组合键尾部,同时放进索引和游标里,否则同一秒的数据会在翻页时重复出现或莫名其妙少一条。这个坑和行值比较关系不大,但只要换成组合游标就一定会碰到,提前设计好能省很多事。
4. 兼容性和几个容易翻车的细节
行值比较虽然来自 SQL 标准,但每个数据库的支持度和行为细节差异很大。这一章我把速查表和最容易踩的坑都列出来,建议收藏备用。
4.1 各数据库支持度速查表
| 数据库 | 语法支持 | 范围比较吃复合索引 | 说明 |
|---|---|---|---|
| PostgreSQL | 支持 | 通常能,规划器理解 ROW 条件 | 最推荐,可在范围、等值、IN 中用 |
| SQLite | 3.15+ 支持 | 通常能 | 官方文档专门展示过行值翻页 |
| MySQL 5.7+ | 支持 | 不等值比较一般不太行 | 等值/IN 可用;范围建议展开 |
| Oracle | 支持 | 视条件和优化器而定 | 小心 NULL、隐式转换 |
| SQL Server | 不支持 | — | 直接写 OR 展开,别折腾 |
这个表是结合我多年跨库开发的经验总结的。SQL Server 不支持行值谓词比较,我在 SQL Server 上写这种逻辑都是老老实实展开成 OR,或者使用ROW_NUMBER()配子查询。别指望一套语法吃遍所有库。
4.2 NULL 会让整行比较直接失效
行值比较碰上 NULL 是最隐蔽的翻车点。SQL 的三值逻辑下,NULL参与任何比较结果都是UNKNOWN,而行值比较是逐列做等值和大小判断,任何一层出现 NULL,整行的比较结果都会变成 UNKNOWN,于是该行在 WHERE 里被过滤掉。
-- 假设有记录 (a=1, b=NULL),游标是 (x=1, y=2) -- 行值比较 (1, NULL) > (1, 2) -- 第一列相等,第二列 NULL vs 2 结果是 UNKNOWN,整体也是 UNKNOWN -- 结果:这条记录不会被返回实际问题在于:业务上你可能想让它“排到最后”,或者“按 NULL 当作最小值处理”,但行值比较做不到这种自定义。我的建议是对可能为 NULL 的列先做COALESCE兜底,比如把b列映射成COALESCE(b, 0),并且配合索引使用。不过这样一来等价展开式和索引设计又会复杂一点,所以更务实的方案是:排序/游标字段尽量设计成NOT NULL,从源头消除这个坑。我参与过的几个项目里,所有分页排序键最后都统一改成了NOT NULL DEFAULT,不然每次性能调优都得跟 NULL 搏斗。
4.3 排序方向不一致时,上面第一列等于白给
再强调一遍:行值比较的两个维度都按“升序”语义工作。如果你排序需求是一列升序、一列降序,直接套行值比较会得到完全错误的顺序。
回到排行榜的例子:ORDER BY score DESC, achieved_at ASC表示“高分靠前,同分先达成者靠前”。我想找“分数比我高或者同分但比我早”的记录,应该展开成:
WHERE score > 10086 OR (score = 10086 AND achieved_at < '2025-02-01 12:00:00')而不是(score, achieved_at) > (10086, ...)。因为后者会先按分数升序比较,score比 10086 大的记录在行值比较里确实也算大,这一点碰巧对了;但achieved_at的语义反了。麻烦的是,你的查询可能只有 10% 的数据碰巧因为第一维分出了胜负而返回正确,剩下同分记录会被错误过滤/错误排序,这类 bug 非常难定位。所以碰到混合方向,直接展开,别犹豫。
4.4 字符集排序规则和隐式类型转换
字符串列参与行值比较时,比较顺序完全取决于数据库的排序规则(collation)。比如 MySQL 的utf8mb4_general_ci大小写不敏感,而utf8mb4_0900_ai_ci对某些重音字符的处理又不同,同一个(name, score) > ('John', 100)在不同连接 collation 下结果可能不一致。跨环境迁移时这种隐性差异容易让游标分页出现重复或漏读,建议测试环境、生产环境的 collation 保持一致。
隐式类型转换也是老话题。行值比较里如果一边是数字列、一边传了字符串,数据库可能把整列转成字符串再比较,结果就是数字 10 排在 '9' 前面这种反直觉局面。我见过一个同事把日期参数写成'2025-03-01',跟DATETIME列做行值比较,因为隐式转换导致索引失效,接口响应从 50ms 涨到 900ms。处理办法很简单:参数类型严格对齐字段类型,不要偷懒传裸字符串。
5. 写在最后:什么时候我劝你别用这招
行值比较确实称得上少有的“短小但威力巨大”的 SQL 特性,但它不是银弹。我的使用准则是这样几条:
一,优先看排序方向是否一致。信息流ORDER BY created_at DESC, id DESC这种全降序场景,行值比较完美;排行榜这种混合方向,就老老实实展开成 OR 条件。二,排序列上不要有 NULL,有就需要COALESCE兜底,否则结果可能悄悄变空。三,MySQL 的范围比较记得先看 EXPLAIN,发现不走索引就展开,不要跟优化器较劲;PostgreSQL 和 SQLite 那边可以放心用。四,跨库迁移时先查兼容性表,尤其 SQL Server 不支持这个语法,别写完才发现跑不了。
最后再分享一个我现在的习惯:游标分页的参数我会起一个直观的名字,比如last_seen_key,并在注释里写明排序方向,这样半年后再看这段 SQL 也能一眼明白(created_at, id) < (?, ?)为什么是<而不是>。语法糖再香,可维护性永远是第一位的。行值比较本质上就是把一堆有规律的 OR 条件折叠起来,适合的时候用是效率翻倍,不适合的时候硬用就是事故现场——判断标准上面都写清楚了,照着排查就行。