SQL行值比较:告别深翻页卡顿的隐藏技巧
写了5年SQL我一直以为比较操作只能在单列上进行。直到有一次需要在一张上千万行的表里做稳定分页被LIMIT 100000, 10卡到怀疑人生才偶然发现SQL里还藏着(a, b) (x, y)这种行值比较写法。第一眼看到时我整个人是懵的元组还能直接比大小仔细查了文档、跑了测试之后我只有一个感觉——这5年SQL白写了。这个写法解决的不只是分页问题。它能让多条件排序、取每组前N条、定位相邻记录这些老顽固场景从几行复杂子查询变成一句干净的条件。这篇文章我会从语法原理开始讲到真实业务中怎么用、有哪些坑、怎么确认索引有没有生效。无论你写SQL是刚入门还是已多年这都属于“知道以后就再也回不去”的技巧。1. 行值比较是什么先搞懂这个语法的底层逻辑1.1 一段代码背后的词典序规则(a, b) (x, y)在绝大多数主流数据库里都遵循字典序lexicographical order比较规则。它等价于下面这段冗长的传统写法-- 行值比较 SELECT * FROM t WHERE (a, b) (x, y); -- 等价展开 SELECT * FROM t WHERE a x OR (a x AND b y);这个展开规则跟英文词典排序一模一样先比第一个字母相同才比第二个字母。换成数字也是一样道理先比a和xa大直接通过a相同才轮到b和y比。举个更直观的例子。假设有一张用户表要找出(score, id)排在(80, 1001)之后的用户SELECT * FROM user WHERE (score, id) (80, 1001) ORDER BY score, id;这个查询会返回score大于80的所有用户同时也会返回score等于80且id大于1001的用户。注意这里选择id作为第二个比较列不是随便选的它保证了排序结果全局唯一这正是后面稳定分页的关键。比较规则还可以扩展成三元组、四元组甚至更多列。比如(a, b, c) (x, y, z)就是先比a、再比b、最后比c。理论上元组里的元素个数没有硬性限制但实际使用中两到三个列已经覆盖绝大多数场景。1.2 为什么大多数人写了多年SQL都没注意到它我认真复盘过自己为什么5年都没发现这个写法原因其实很现实。第一主流教材里讲WHERE条件翻来覆去都是单列比较、AND/OR、IN、BETWEEN几乎不会提行值比较。第二ORM框架把SQL封装得严严实实写WHERE (a,b) (x,y)这种原生条件框架支持度参差不齐很多人压根没机会接触。第三就算偶尔在别人的SQL里看到这种写法第一反应也是“这能跑吗”而不是“这居然能跑”。但SQL标准里行值表达式row value expression比较一直是一等公民。MySQL、PostgreSQL、SQLite、MariaDB这些主流数据库都支持只不过官方文档一般把它放在比较运算的角落里不细翻根本注意不到。所以这个写法不是“黑魔法”而是正经的SQL标准语法。只是知道的人少写出来的代码就显得特别“神仙”。2. 用它解决真实问题三个最经典的应用场景2.1 优化OFFSET分页告别深翻页卡顿LIMIT 100000, 10这种深翻页问题做过几年后端的人应该都有体会。它真正糟糕的地方在于数据库要先扫过前面那100000行再丢弃它们最后才返回你要的10行。数据量越大越靠后的页越慢。行值比较提供了一种叫“游标分页”Keyset Pagination的解法。核心思路是不告诉数据库“跳过多少行”而是告诉它“从哪一条记录开始往后取”。-- 传统深翻页越到后面越慢 SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 100000; -- 游标分页稳定且快 SELECT * FROM orders WHERE (created_at, id) (2024-05-21 10:30:00, 100001) ORDER BY created_at DESC, id DESC LIMIT 10;第二句SQL的意思是找出所有排序在(2024-05-21 10:30:00, 100001)之前的记录按相同顺序取前10条。拿到这10条后记下最后一条记录的created_at和id作为下一页的查询条件继续传参。这样每次查询都只走索引、直接定位到起点不需要扫描那一大堆被“跳过”的行。在千万级数据表上原来可能耗时几百毫秒甚至几秒的深翻页优化后稳定在个位数毫秒级。我自己在一个业务表上实测第10000页的查询耗时从800ms直接降到3ms。2.2 轻松取每组前N条记录“每个用户最近3笔订单”“每个班级成绩前2名的学生”这类分组TopN需求很多人第一反应是窗口函数ROW_NUMBER()。窗口函数当然能做但如果你用的数据库版本比较老或者不想多包一层子查询行值比较也是一个值得掌握的替代方案。以“每个用户最近3笔订单”为例用相关子查询加行值比较可以这样写SELECT o1.* FROM orders o1 WHERE ( SELECT COUNT(*) FROM orders o2 WHERE o2.user_id o1.user_id AND (o2.created_at, o2.id) (o1.created_at, o1.id) ) 3 ORDER BY o1.user_id, o1.created_at DESC, o1.id DESC;这段SQL的逻辑是对每一笔订单统计同一个用户下“排在我前面的订单”有多少个。如果少于3个说明我属于这个用户的前3名。判断“谁排在我前面”用的正是行值比较比拿三个条件分开写简洁得多。窗口函数写法需要先算ROW_NUMBER()、再在外面包一层过滤行值比较写法则是“边查边比较”对老版本数据库兼容性更好。在数据量不大的分组场景下两者性能差距很小但行值比较写出来的逻辑更贴近自然语言直接数一数比我大的有几个。2.3 多字段状态序列的定位与相邻数据查找业务里经常会有“按状态优先级排序后定位到某条记录再取它前面或后面的一条”这种需求。比如工单系统里工单先按紧急程度分高、中、低紧急程度相同再按提交时间排序。行值比较可以很优雅地表达“某条记录在排序中的位置”-- 找出按(priority, submit_time)排序后位于(high, 2024-05-20 09:00:00)之后的第一条记录 SELECT * FROM tickets WHERE (CASE priority WHEN high THEN 1 WHEN medium THEN 2 ELSE 3 END, submit_time) (1, 2024-05-20 09:00:00) ORDER BY (CASE priority WHEN high THEN 1 WHEN medium THEN 2 ELSE 3 END), submit_time LIMIT 1;这种写法在“上一条/下一条”场景里非常干净。以前我需要先查当前记录、再拼两个条件现在一次性就能定位还能顺带保证排序和过滤条件完全一致不会出现“查出来的下一条和列表页顺序对不上”的尴尬情况。3. 实操要点索引、NULL值与各数据库兼容性3.1 复合索引匹配不是想用就能用行值比较虽然写着爽但性能好不好关键在于有没有对应的复合索引。数据库能高效执行(a, b) (x, y)前提是索引也以(a, b)为前缀建立。-- 推荐为行值比较建立匹配的复合索引 ALTER TABLE orders ADD INDEX idx_created_id (created_at, id); -- 不推荐只有单列索引行值比较大概率走全表扫描 ALTER TABLE orders ADD INDEX idx_created (created_at);这背后的原因是B树索引本身就是按字典序组织数据的。索引(created_at, id)天然就是先按created_at排序、相同再按id排序和行值比较的顺序完全一致所以数据库可以直接在索引上定位“大于某个键值的第一条记录”。如果索引顺序和行值比较顺序不一致比如索引是(id, created_at)但比较条件是(created_at, id)优化器通常就无法直接利用索引完成这个比较。还有一个容易踩坑的细节混合排序方向。行值比较本身只支持“全列同方向”的比较比如(a, b) (x, y)和ORDER BY a ASC, b ASC是匹配的。如果需要ORDER BY a ASC, b DESC这种混合方向排序行值比较就不能直接对号入座。-- 这种混合方向排序行值比较不能直接套 ORDER BY a ASC, b DESC -- 一个常见的绕过思路把b换成它的相反数保存或计算 ORDER BY a ASC, -b ASC这个技巧在特定场景下有用但会给查询增加计算成本。我的建议是优先把业务排序规则设计成统一方向实在不行再考虑改写不要强行硬套行值比较。3.2 NULL值比你想的更麻烦行值比较最大的坑是NULL。SQL里NULL参与比较时结果不是TRUE也不是FALSE而是UNKNOWN。这意味着如果a或b中有一个是NULL(a, b) (x, y)这个条件很可能直接不成立哪怕你直觉上觉得应该成立。-- 假设表里有这些记录 -- (score, id) (NULL, 5) -- (score, id) (90, 10) SELECT * FROM score_record WHERE (score, id) (80, 1);这条SQL很可能只会返回(90, 10)而(NULL, 5)会被过滤掉。在实际业务里这会造成“明明我什么都没干为什么数据不见了”的诡异问题。规避办法很简单要么在设计表结构时给这些列加上NOT NULL DEFAULT默认值要么在行值比较前用COALESCE把NULL替换成边界值。WHERE (COALESCE(score, 0), id) (80, 1)不过COALESCE会把查询变成非SARGable索引利用率会下降所以最稳妥的方案还是从源头保证列不为NULL。3.3 主流数据库支持程度速查数据库行值比较支持情况注意事项MySQL支持5.7及以后版本表现稳定8.0性能更佳PostgreSQL支持完全遵循SQL标准NULL语义也标准SQLite支持对NULL排序规则有特殊处理需区分NULLS FIRST/LAST场景MariaDB支持与MySQL行为基本一致SQL Server部分支持支持特定形式的行值比较但使用有限制建议先用普通写法验证SQL Server的情况比较特殊。它支持行构造器但官方文档和社区实践里的差异很大某些版本对行值比较的优化并不好。我的建议是如果团队主用SQL Server先在小数据量上验证执行计划确认走索引之后再推广如果用的是MySQL或PostgreSQL可以放心大胆用。4. 踩坑实录与调试技巧4.1 分页数据重复或缺失的问题用游标分页时最容易出现的bug是“数据跳页”和“重复数据”。这两种现象往往是同一个原因排序字段不够唯一。-- 错误示例created_at可能有大量重复 WHERE (created_at) (2024-05-21 10:30:00) ORDER BY created_at DESC LIMIT 10;如果两行记录的created_at相同它们谁在谁前完全由数据库内部物理顺序决定。当新数据插入、旧数据更新时这个顺序可能改变于是下一页查询就会漏掉或重复某些记录。解决办法就是给排序条件加一个绝对唯一的字段通常用主键id。所以(created_at, id)这种组合比单独一个created_at可靠得多。这一点我在经历过一次线上分页重复告警后才真正明白为什么所有游标分页教程都强调“必须包含唯一列”。4.2 行值比较与表达式混用容易看走眼行值比较写起来简洁但一旦在列上套函数整个条件就变味了。(DATE(created_at), id) (2024-05-21, 100)这类写法虽然语法对但DATE(created_at)意味着数据库无法直接使用created_at上的普通索引每一行都得先算一遍函数。我见过有人把行值比较当成万能药结果写出一堆“看起来很短、跑起来很慢”的SQL。判断标准很简单如果比较的第一列是索引列且没套函数通常没问题如果第一列上有任何函数运算就一定要用EXPLAIN确认是否全表扫描。-- 避免这样写 WHERE (YEAR(created_at), id) (2024, 100) -- 改写为范围条件能更好利用索引 WHERE created_at 2024-01-01 AND (created_at 2024-01-01 OR id 100)4.3 用EXPLAIN验证是否走索引不管在网上看到多少“神仙写法”落到自己业务里之前都应该用EXPLAIN看一眼执行计划。以MySQL为例EXPLAIN SELECT * FROM orders WHERE (created_at, id) (2024-05-21 10:30:00, 100001) ORDER BY created_at DESC, id DESC LIMIT 10;重点关注key字段是否显示你期望的复合索引以及rows字段估算的扫描行数是不是只有个位数。如果发现type是ALL全表扫描或者rows很大说明索引没匹配上得回去调整索引顺序或重写条件。我个人的使用习惯是先在测试库用一小段真实数据验证结果正确再跑EXPLAIN验证性能最后才上线。这套流程虽然多花几分钟但能挡掉大量线上事故。5. 写在最后一个小习惯帮我少写很多复杂SQL接触行值比较之后我养成了一个新习惯凡是遇到需要“按顺序找一条记录位置”的地方先想想能不能用行值比较表达。这个思维转换帮我砍掉了很多子查询和临时表。举个例子以前做消息中心的时候要“找到某条消息在用户消息列表中的后面几条”我的第一反应是ROW_NUMBER()加子查询或者先用LIMIT捞一大堆再在内存里过滤。现在直接写SELECT * FROM messages WHERE (user_id, send_time, id) (10086, 2024-05-21 10:00:00, 500012) ORDER BY user_id, send_time, id LIMIT 20;话再说回来行值比较也不是银弹。它最大价值集中在分页、TopN、相邻记录定位这几类场景。如果只是普通单列过滤老老实实写WHERE就行没必要为了炫技硬套元组语法。还有一点要提醒(a, b) (x, y)不等于a x AND b y它是“或”的关系不是“且”的关系。这俩一旦搞混SQL跑出来的结果会差出十万八千里。最后分享一个小技巧如果你觉得(a, b) (x, y)在团队里可读性一般可以在SQL注释里把等价的展开逻辑写清楚。比如“等价于 a x OR (a x AND b y)”这样后来接手的人不至于一头雾水。写代码的人是自己维护代码的人可能是别人把“神仙写法”变成“大家都看得懂的神仙写法”才真正值钱。