SQL行值比较:(a,b) > (x,y) 的原理、高效场景与避坑指南
前阵子排查一个翻页慢的接口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。这两者差别巨大。-- 假设数据a10, b0游标是 (x5, y5) -- 行值比较(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) ) ENGINEInnoDB;展示顺序是“按创建时间倒序”而且同一秒内可能有多条消息所以排序键必须是(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这时候要查排在我score10086, achieved_at2025-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 LOCKEDPostgreSQL 下可以直接写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 中用SQLite3.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 里被过滤掉。-- 假设有记录 (a1, bNULL)游标是 (x1, y2) -- 行值比较 (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 条件折叠起来适合的时候用是效率翻倍不适合的时候硬用就是事故现场——判断标准上面都写清楚了照着排查就行。