我从一个很常见的需求说起查下一页数据条件是“比当前记录更大的一条”。以前我会写WHERE amount 100 OR (amount 100 AND create_time 2025-01-01 10:00:00)直到某天偶然发现SQL 标准里其实早就准备好了更优雅的写法——(amount, create_time) (100, 2025-01-01 10:00:00)。这个看起来像“神仙语法”的东西其实叫行值比较row value comparison。它能把多条 AND/OR 条件压缩成一个元组表达式可读性、性能都有机会同步提升非常适合经常写复杂筛选、分页、排序、复合主键操作的开发者。今天这篇我会把这个写法的原理、应用场景、兼容性坑和性能表现一次讲透。无论你刚接触 SQL还是已经写了好几年查询只要受够了又臭又长的条件组合都值得花几分钟看完。1. 行值比较的本质一条 OR 链条的“降维打击”1.1 先看你大概率写过的“经典长条件”不信的话回忆一下自己处理过的需求查某个复合条件中的下一条记录或者查“积分更高积分相同时净胜球更多”的球队。绝大多数人的第一反应是逐字段拆开用 AND 和 OR 拼起来。比如在订单表里按(amount, create_time)做游标分页-- 传统写法 WHERE (amount 1000) OR (amount 1000 AND create_time 2025-03-01 12:00:00) ORDER BY amount ASC, create_time ASC LIMIT 10;这个写法逻辑上没错但一旦字段从 2 个变成 3 个、4 个条件组合会以指数级膨胀。例如再加一个id作为最终排序键就要变成三组括号嵌套WHERE amount 1000 OR (amount 1000 AND create_time 2025-03-01 12:00:00) OR (amount 1000 AND create_time 2025-03-01 12:00:00 AND id 2001)写的时候提心吊胆漏一个括号、少一个“等于”查出来的数据就是错的。代码评审时同事看到这种条件也头疼得拿着笔在草稿纸上推演半天。1.2 行值比较的逐列规则像查字典一样比较行值比较做的就是把上面那一整段逻辑替换成WHERE (amount, create_time) (1000, 2025-03-01 12:00:00)它的本质是“字典序比较”先比较第一列如果第一列能分出大小就不再往后看如果第一列相等才比较第二列。也就是说(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) (x, y)等价于a x AND b y(a, b) (x, y)则对应(a x) OR (a x AND b y)。提示这里的“第一列优先”和字典排序一模一样。查单词时先比首字母首字母相同再看第二个字母SQL 行值比较就是这套逻辑。理解了这个类比后面所有用法都不会再混淆。这个从 OR 链条到元组表达的转换看起来只是换了个语法糖但对人的思维负担是实打实的降低。我写 SQL 五年后才认真用起来最大的感受是复杂比较逻辑能写得像“把一整行数据作为比较单元”心里想什么代码里就写什么。1.3 为什么叫“神仙写法”不只是一个语法糖有人可能会说这不就是简化了写法吗功能上有什么变化其实不止。第一可读性大幅提升。多字段游标分页、跨字段区间查询用行值比较后语义更加接近业务描述。代码评审时一眼就能看出“哦这里是在按两个字段组成的序找下一条”。第二更容易激发优化器正确选择执行计划。很多数据库的查询优化器能够识别出(a, b) (x, y)是一个复合索引上的范围扫描条件从而高效定位。但如果是a 100 OR (a 100 AND b ...)有些优化器会犹豫是否要用索引极端情况下甚至退化成全表扫描。这一点我在第 4 节会详细展开。第三表达力覆盖了更多场景。不只比较(a, b)还可以直接用IN做多列集合匹配可以和子查询返回的多列结果比较这都不是用 AND/OR 简简单单写几句的事。2. 值得替换的四个高频场景分页、排行、区间、匹配2.1 场景一游标分页keyset pagination替代 OFFSET 分页OFFSET 分页大家都很熟但一旦数据量涨到几十万、上百万LIMIT 100000, 20这种写法会越来越慢因为数据库要扫描再丢弃前面十万行。游标分页也叫 keyset pagination就是记住“上一页最后一条记录”的排序字段值下一页直接从这个值之后取。行值比较在这里几乎是量身定做的表达方式。假设有一张虚拟的交易流水表trade_log按(user_id, trade_time)排序-- 第一页 SELECT id, user_id, trade_time, amount FROM trade_log ORDER BY user_id ASC, trade_time ASC LIMIT 20;拿到第一页最后一条记录的user_id 117、trade_time 2025-04-02 09:30:15后第二页就可以写SELECT id, user_id, trade_time, amount FROM trade_log WHERE (user_id, trade_time) (117, 2025-04-02 09:30:15) ORDER BY user_id ASC, trade_time ASC LIMIT 20;这比“user_id 117 OR (user_id 117 AND trade_time ...)”简洁太多而且执行计划通常能稳定走(user_id, trade_time)复合索引。不仅是页面上点击“下一页”像微博信息流、后台日志滚动加载只要游标能向前移动这个模式都适用。2.2 场景二排行榜中查出“紧随其后的一条记录”排行榜常见需求是“查排名在我之前/之后的人”。比如球队积分榜按points DESC, goal_diff DESC, goals DESC排序需要查出某支球队下一名的数据。传统写法要拼三层条件WHERE points 70 OR (points 70 AND goal_diff 20) OR (points 70 AND goal_diff 20 AND goals 45) ORDER BY points DESC, goal_diff DESC, goals DESC LIMIT 1;改写之后当前球队的值为(70, 20, 45)WHERE (points, goal_diff, goals) (70, 20, 45) ORDER BY points DESC, goal_diff DESC, goals DESC LIMIT 1;注意这里用因为要查的是“比当前记录排序更靠后”的一条。我实际做过一个类似的内部竞赛系统原来在存储过程里写了四层嵌套条件改成行值比较后整个人都轻松了后续加一个排序列也只需要在元组里多加一个字段。2.3 场景三多个字段联合区间的数据筛选有些查询不是单字段区间而是“第一字段在某个范围且第二字段跟随第一字段才有效”。例如用户行为分析里想查“注册时间晚于某天且该天之后首次活跃时间晚于某时刻”的用户WHERE (register_date, first_active_time) (2025-03-01, 2025-03-01 08:00:00)这个语义等价于要么注册日期就晚了要么注册日期相同但首次活跃时间晚。很多开发者第一次看到这个写法会有点不习惯但它实际上完美匹配了“两个字段作为一个有序整体”的筛选诉求。对比把两个字段拆开写 OR 和 AND行值比较能避免写出自相矛盾的条件——比如忘记在 OR 分支里重写第一字段的等值条件。2.4 场景四多列等值匹配与复合主键操作行值比较不是只有大于小于等值匹配同样好用。最常见的就是多列INSELECT * FROM order_detail WHERE (customer_id, order_no) IN ((1001, A001), (1002, B002), (1003, C003));这在按复合主键批量查询时非常省事。对比原来的写法WHERE (customer_id 1001 AND order_no A001) OR (customer_id 1002 AND order_no B002) OR (customer_id 1003 AND order_no C003);另外(a, b) (x, y)还可以配合子查询比如找出“每个分组里最新一条记录对应的主键”SELECT * FROM inventory_log WHERE (device_id, record_time) ( SELECT device_id, MAX(record_time) FROM inventory_log GROUP BY device_id );这种写法在“取分组内最新记录”的场景里特别自然而且逻辑一目了然。3. 实操细节兼容性、列数、类型与 NULL 的坑3.1 主流数据库支持情况对照看到这个语法先别急着到处用。不同数据库对行值比较的支持程度是不一样的。我整理了一份兼容性速查表基于我实际使用和查阅文档的结果数据库支持情况使用感受MySQL支持(a, b) (x, y)和IN多列都可用日常最顺手PostgreSQL支持完全支持还支持行构造器ROW(a, b)SQLite支持支持常规行值比较IN多列也能用Oracle部分支持子查询中可以用多列比较直接写(a, b) (x, y)有场景限制SQL Server不支持原生行值比较需要写传统 OR/AND或用其他方式替代如果你负责的系统是 MySQL 或 PostgreSQL那基本不需要犹豫直接上手。SQL Server 默认情况下写(a, b) (x, y)会报语法错误需要绕道实现。Oracle 虽然支持多列子查询比较但直接用在 WHERE 里的元组比较需要确认当前版本和上下文我建议在测试环境先跑一下。3.2 列数必须一致类型要能比较行值比较最基础的规则是两边列数必须一致。(a, b) (x, y)是两列(a, b, c) (x, y)会直接报错这个错误比较明显数据库会提示列数不匹配。但也存在一个隐蔽问题若两边类型不同会触发隐式转换。例如(user_id, order_time) (105, 2025-01-01)如果user_id是整数列数据库通常会把字符串转成整数再比较问题不大。但反过来如果拿数字列和字符串列混合比较比如(status, remark) (1, 202)那就要小心status是数字、remark是字符串时两边是否都按预期规则比较。关键是每列各自比较任务简单但如果同一列类型不一致结果是不可预期的。注意比较操作是在“对应列”之间进行的不是把整行转成某个字符串再比较。所以(amount, name)不会把两个字段拼成一个字符串而是先比amount再比name。3.3 NULL 参与比较时的行为千万别忽略这是行值比较最容易踩的暗坑。NULL参与比较时结果往往不是TRUE也不是FALSE而是UNKNOWN。举个例子SELECT (1, NULL) (0, 100);先比较第一列1 0已经能确定结果为真因此整行比较返回真。但如果是SELECT (NULL, 1) (0, 100);第一列NULL 0结果是UNKNOWN无法分出胜负数据库继续比较第二列第二列是1 100为假整体就变成假。如果第二列也是 NULL那整体就是 UNKNOWN在 WHERE 里等同于过滤掉该行。所以当你的比较列里存在NULL值时不要指望行值比较会按“NULL 最小”之类的直觉处理。不同的数据库对NULL排序规则都不一样比如 MySQL 默认 NULL 最小PostgreSQL 默认 NULL 最大行值比较种的处理方式也会受此影响。在实际生产环境我更推荐先确保参与比较的字段是NOT NULL或者在 SQL 里用COALESCE把NULL替换成边界值否则很容易出现“看起来该出来的记录没出来”的诡异问题。4. 性能分析为什么它有可能比长条件更快4.1 复合索引如何配合元组比较很多人担心(a, b) (x, y)这种写法会不会让索引失效答案恰恰相反很多时候它能更好地利用复合索引。以 B-tree 索引为例复合索引idx_a_b(a, b)的叶子节点本身就是按a、再按b的顺序排列的。查询(a, b) (100, 2025-01-01)时数据库可以直接在索引树中定位到第一个满足a 100或者a 100 AND b 2025-01-01的叶子节点然后一直向后扫描这本质上是一个有序区间扫描。而传统写法a 100 OR (a 100 AND b 2025-01-01)虽然逻辑等价但优化器看到 OR 时可能心里发怵——它需要判断这个 OR 条件能否合并成一个索引范围。有些优化器做不到会退化成对两个条件分别计算再合并甚至干脆全表扫描。我拿一个 100 万行的本地测试表做过对比写法是否走idx_a_b耗时约为(a, b) (100, 2025-01-01)是索引范围扫描25 msa 100 OR (a 100 AND b 2025-01-01)偶尔走索引有时全表扫描60 ms ~ 300 ms这个结果不是绝对规律不同版本优化器会有差异但趋势很明显行值写法在不增加任何额外索引的情况下更容易让优化器做出最佳选择。4.2 想充分发挥性能复合索引要选对列顺序既然依赖复合索引索引里列的顺序就非常重要。查询条件是(a, b) (x, y)那么索引列的顺序应该和行值比较中的列顺序一致即先a后b。如果索引是(b, a)那对(a, b)的元组比较基本无能为力。一个实际的参考做法游标分页的ORDER BY a ASC, b ASC索引最好就是(a, b)。如果同时还有单字段a ?的查询需求(a, b)也能覆盖。如果业务经常按b单独查询那额外再加一个(b)索引不要指望(a, b)能服务好b单列查询。在 SQL Server 这类不支持行值比较的数据库里如果还想获得类似收益就得考虑把元组比较手动转换成多条件或者使用窗口函数等替代方案效果和维护成本要自行权衡。4.3 使用行值比较时的执行计划观察方法引入一个新写法后别光看“能查出正确结果”就完事。我通常会在测试环境先看执行计划确认预期走的是索引范围扫描。以 MySQL 为例在查询前加EXPLAINEXPLAIN SELECT * FROM trade_log WHERE (user_id, trade_time) (117, 2025-04-02 09:30:15) ORDER BY user_id ASC, trade_time ASC LIMIT 20;重点看type字段是range还是ALL以及key字段是否用到了复合索引。如果出现ALL说明写法和索引顺序可能有不匹配需要回查条件顺序与索引定义。提示EXPLAIN只能看到大概的访问方式如果想更精确地确认扫描行数可以开启 profiling 或借助系统自带的性能视图。但日常判断“有没有走索引”看type和key基本够了。5. 常见错误与排查实录我踩过的那些坑5.1 比较顺序写反看似没错结果全错行值比较最需要牢记的一点它是“从左到右、逐列比较”不是“把每列分别比较后再组合”。有时候业务想表达的是“a 字段大于某值或者 b 字段大于某值”用行值比较却写成了(a, b) (x, y)这里语义完全变了。举个例子-- 想找 order_time 大于某时间 或 amount 大于某金额 的记录 -- 错误直觉 (order_time, amount) (...)实际上这个查询先比较order_time只有当order_time相等时才会去看amount。如果order_time已经大了就不管 amount 是否满足如果order_time小了就算 amount 再大也不会返回。这种错误在测试数据不多时很难发现因为几条数据碰巧结果是对的线上数据一多就会漏数据。排查方法问自己“业务语义到底想表达什么”。如果是“任意字段满足即可”行值比较根本不适用应该用 OR。只有“第一字段优先相等时再看第二字段”的语义才适合行值比较。5.2 类型隐式转换和字符集带来的隐患这也是一个无声的坑。某次我把(user_code, create_time)和外部传入的(00123, ...)做比较user_code是字符串类型。按常理字符串比较是按字典序走但因为我表里的字符集和排序规则设置特殊查询结果死活不对。排查后发现字符串里既有数字又有字母排序规则导致比较顺序和业务预期不同。建议比较列尽量保持数据类型一致避免隐式转换。字符串列比较前确认排序规则collation不要默认它是“按 ASCII 或按数字大小”走。涉及用户输入时提前做类型转换和格式化别让数据库在 WHERE 里临时调用函数。比如CAST(user_code AS CHAR)写在条件里很容易破坏索引使用。5.3 多列 IN 与单列 IN 的混淆有些数据库对于(a, b) IN ((1, 2), (3, 4))的支持很直接但有人会写成WHERE a IN (1, 3) AND b IN (2, 4)这个错误很经典。逻辑上(a, b) IN ((1, 2), (3, 4))要求的是“成对匹配”a1 且 b2或者a3 且 b4。而a IN (1, 3) AND b IN (2, 4)会错误地返回(1, 4)、(3, 2)这些本来不该出现的组合。如果你在排查数据莫名多出来的问题可以先检查是不是把多列 IN 拆成了单列 IN 的笛卡尔组合。正确写法永远是WHERE (a, b) IN ((1, 2), (3, 4))如果数据库不支持多列 IN就得老老实实写 OR 组合。5.4 分页场景中游标值包含 NULL在游标分页项目里我碰到过一次连续翻页时数据重复/漏掉的情况。最后定位到原因排序列中存在NULL。因为(user_id, trade_time) (117, NULL)这种比较结果不可控有NULL值的那行可能在后续页中反复出现或者消失。解决思路如果业务允许给排序列加NOT NULL约束。如果历史数据已经存在NULL用COALESCE转成一个永远排在边界之前或之后的哨兵值比如COALESCE(trade_time, 1970-01-01)。永远不要依赖“数据库默认 NULL 排序规则”来承载核心分页逻辑因为不同数据库、不同版本的默认行为可能很不一样。结尾真正上手前先记住这三句话从我个人的使用体会来说行值比较不是银弹但绝对是 SQL 工具箱里被严重低估的一件利器。它的核心价值是把“逐列判断、条件叠加”的思维转换成“整行有序比较”的思维让人和优化器都更轻松。最后分享三个建议第一先在自己最熟悉的数据库版本里跑一跑(a, b) (x, y)和(a, b) IN ((...),(...))确认语法被支持再上生产。第二优先用在游标分页、复合主键匹配、分组最新记录这类本来就适合“成组比较”的场景不要强行把不相干字段塞进一个元组里。第三写完后顺手看一眼执行计划确认复合索引真的被用上。很多时候不是语法不生效而是索引顺序和元组顺序不一致把原本的优势浪费掉了。以后再遇到那些“又长又绕”的多条件查询不妨先停下来想想它是不是本质上就在比一个有序元组如果是大胆写成(a, b) (x, y)的样子。这种让代码变短、让逻辑变清晰的感觉真的很上瘾。