对 SQL 这个东西我一直觉得自己算是有经验的。写了五年多的业务查询窗口函数、CTE、JSON 操作都玩过可是直到前几个月处理一个深分页的问题我才偶然在一篇老帖子的评论里看到有人写出(a, b) (x, y)这种比较条件。当时愣了一下——这玩意儿居然能直接写在 WHERE 里查了一圈资料才发现原来这是 SQL 标准里早就存在的一种比较方式行值比较Row Value Comparison也叫多列组合比较。它的逻辑很像字典排序先比第一个字段如果相等再比第二个字段以此类推。(a, b) (x, y)本质上就是“先比 a 和 xa 大就直接赢a 相等再比 b 和 y”。如果你能理解版本号1.2.0 1.1.9是怎么比较的那你已经理解了这个写法的一半。这篇文章我会把行值比较这件事讲透。不讲虚的直接拆等价逻辑、给实战 SQL、说清哪些数据库支持、并且把深分页和版本号判断这两个最常见的应用场景完整落一遍。不管你是刚写 SQL 不久的新人还是被大表 OFFSET 折磨过的老手看完应该都能直接用上。1. 行值比较是什么为什么大多数人没用过1.1 一句话看懂(a, b) (x, y)的语义先上最常见的例子。假设有一张订单表你想找出“下单时间和订单号”都比某个游标更晚的记录以前你会怎么写WHERE order_time 2024-06-14 20:30:00 OR (order_time 2024-06-14 20:30:00 AND order_id 10086)用行值比较上面整段条件可以缩成一行WHERE (order_time, order_id) (2024-06-14 20:30:00, 10086)这个条件读起来非常直白从某一天某一秒开始订单号继续往后取。它不需要你手动去描述“时间相同再看订单号”这种分叉逻辑数据库自己会按从左到右的顺序逐列比较。这种多列组合比较不是某个数据库私有的黑魔法它出自 SQL 标准底层的比较规则是明确的字典序先比较左边第一列order_time和右边第一列2024-06-14 20:30:00。如果第一列已经分出大小整个表达式直接出结果后面的列不再参与比较。如果第一列相等继续比较第二列order_id和10086。如果所有列都相等则整个表达式为相等不成立。所以(a, b) (x, y)在无 NULL 的常规情况下严格等价于a x OR (a x AND b y)这就是它的展开式。别小看这个展开式后面讲索引优化时要靠它理解数据库为什么能走范围扫描。1.2 一个更新感知为什么我们写了五年都没发现说真的SQL 的很多特性是“知道的人觉得理所当然不知道的人根本不会去搜”。行值比较之所以冷门主要是三个原因第一大多数教程和入门书都只教单列比较。从WHERE age 18开始学遇到多条件就是 AND 拼接思维被固化在单列维度。第二ORM 框架把它屏蔽了。现在写业务查询基本都靠 MyBatis、Hibernate、Entity Framework 这类框架生成 SQL多列条件被映射为若干个谓词拼接很少有人去看底层 SQL 能不能合并。第三部分数据库和版本支持得不好。比如 SQL Server 至今不支持这种多列缩写比较MySQL 5.7 能用但优化不稳定这导致很多人尝试一次失败后就放弃了。但正因为大多数人没用过它反而成了实际工作中的“信息差”。尤其在深分页、版本判断、字典序范围查询这类场景写法和性能都会有明显改善。1.3 为什么说它“神仙”核心价值在于“可读性 索引友好”单纯说省几行代码还不至于用“神仙”来形容。这个写法真正的价值在于两点一是把业务意图直接写进条件里。你要查“从这个点之后的记录”代码里就真的是一个大于号而不是一长串 OR 和 AND 的拼盘。后续维护的人看到(order_time, order_id) (?, ?)能立刻知道这是一个复合游标而不是在一堆括号里慢慢推理。二是它和复合索引的匹配度非常高。如果在(order_time, order_id)上建了组合索引那么(order_time, order_id) (2024-06-14 20:30:00, 10086)可以被优化器转换为索引条件下推或范围扫描效果比先索引回表再过滤更好。这个我们放到第 3 部分用真实执行计划拆。2. 语法细节与等价展开别只看表面2.1 六种比较运算符的完整等价形式行值比较不只支持、、、、都支持。为了让你心里有底我把它们的等价展开式一次列全。假设比较左边(a, b)和右边(x, y)且不考虑 NULL行值写法等价展开(a, b) (x, y)a x AND b y(a, b) (x, y)a x OR b y(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 OR (a x AND b y)(a, b) (x, y)a x OR (a x AND b y)这个表格建议你先记住和两行百分之八十的业务场景都只用到这两类。剩下几个都是同一逻辑换了个方向。有个容易被忽略的点的展开式里用的是 AND这意味着只要有一列为 NULL整个(a, b) (x, y)就不是 false而是 NULL也就是“不确定”。从数据库层面看这个条件既不会命中也不会报错很多人排查半天才发现是 NULL 把数据吞了。2.2 NULL 参与比较时行为和三值逻辑一致行值比较在遇到 NULL 时不会像 Python 元组那样直接做一个奇怪的比较结果而是遵循 SQL 的三值逻辑真、假、未知。举个例子。假设某张表的remark_order列允许为 NULL现在执行WHERE (order_time, remark_order) (2024-06-14 20:30:00, A001)如果某一行(order_time 2024-06-15 08:00:00, remark_order NULL)那么由于第一列order_time已经大于右边数据库会直接判定该行满足条件不会再去看remark_order。也就是说只要先比较的列已经决定了大小后面的 NULL 不会影响结果。但反过来的情况就要小心。如果第一列相等第二列是 NULL比如(order_time 2024-06-14 20:30:00, remark_order NULL)那么第二列比较NULL A001的结果是 NULL整个条件变成未知这一行会被过滤掉。所以实际使用中有一个重要原则如果列允许 NULL最好不要直接写进行值比较要么先 COALESCE要么先排除 NULL 行。例如WHERE (order_time, COALESCE(remark_order, )) (2024-06-14 20:30:00, A001)但这里要敲个警钟加了 COALESCE 之后索引匹配就可能失效因为对列做了函数处理。更稳妥的做法是业务上先把 NULL 数据剔除或者为排序字段建默认值而不是在查询里硬扛。2.3 不同数据库的支持情况与版本差异先直接给一张我自己整理的支持情况表数据库行值比较支持实际使用备注PostgreSQL支持支持得很彻底、、BETWEEN 都可以NULL 排序可用 NULLS FIRST/LAST 配合MySQL 8.0支持支持行构造器比较优化器对大表范围查询友好MySQL 5.7基本支持可以写但某些写法和索引优化不稳定必须 EXPLAIN 验证Oracle支持对多列条件比较支持很早DBA 圈子里用得比较多SQLite3.15 支持支持行值适合轻量数据场景SQL Server不支持不支持多列缩写比较只能手动展开成 OR/AND重点说一下 SQL Server。它并不是“不支持多条件查询”而是不支持(a, b) (x, y)这种行构造器写法。遇到这种情况老老实实展开WHERE a x OR (a x AND b y)但展开式的性能表现取决于索引和统计信息有时候还不如把两个条件分开写更稳。关于不同数据库的坑后文第 4 部分还会补充。3. 实战三个能立刻用上的场景3.1 深分页优化用游标代替 OFFSET这是行值比较最有价值的场景没有之一。互联网业务里最常见的分页写法是SELECT * FROM play_log WHERE user_id 88 ORDER BY play_time DESC, song_id DESC LIMIT 10 OFFSET 50000;OFFSET 的问题在于数据库必须把前 50000 行都读出来、排序、然后再扔掉越到后面的页越慢。我优化过的一个内容系统第 100 页之后接口延迟从 80ms 涨到 2 秒多问题就在这里。用行值比较做 keyset 分页逻辑换成记住上一页最后一条记录的游标下一页直接取“游标之后的数据”完全不需要跳过前面的行。假设上一页最后一条是play_time 2024-06-14 21:35:12, song_id 1001下一页 SQL 就是SELECT user_id, play_time, song_id, duration_sec FROM play_log WHERE user_id 88 AND (play_time, song_id) (2024-06-14 21:35:12, 1001) ORDER BY play_time DESC, song_id DESC LIMIT 10;注意这里我用了因为本次查询是倒序取“更早”的记录。如果正序翻页就用。这个写法的优势很直观不需要 OFFSET数据库只需要走索引找到游标位置再往后扫 10 条。翻页过程中有人插入了新数据也不会导致当前页数据重复或漏跳。条件里的比较顺序与索引(user_id, play_time, song_id)高度匹配执行计划能稳定走 range。表结构建议这样建CREATE TABLE play_log ( user_id INT NOT NULL, play_time DATETIME NOT NULL, song_id INT NOT NULL, duration_sec INT, PRIMARY KEY (user_id, play_time, song_id) );复合主键本身就是索引所以不需要额外建索引。真正生产环境里如果把song_id作为排序兜底字段这个设计天然就够用。3.2 版本号比较三列版本号秒变元组行值比较的第二个经典场景是版本判断。假设一张软件发布表版本号被拆成主版本、次版本、补丁版本三列CREATE TABLE app_release ( app_name VARCHAR(50), major INT, minor INT, patch INT, release_time DATETIME );现在要找出所有“高于 2.1.0 版本”的记录。用传统写法你得这样SELECT app_name, major, minor, patch FROM app_release WHERE major 2 OR (major 2 AND minor 1) OR (major 2 AND minor 1 AND patch 0);三行 OR 条件第一眼很难读懂而且稍有不慎就把边界判断写错。用行值比较一行搞定SELECT app_name, major, minor, patch FROM app_release WHERE (major, minor, patch) (2, 1, 0);这个写法还可以配合 BETWEEN 使用。比如查“2.0.0 到 3.0.0 之间但不超过 3.0.0”的所有版本SELECT app_name, major, minor, patch FROM app_release WHERE (major, minor, patch) (2, 0, 0) AND (major, minor, patch) (3, 0, 0);这一下就把版本区间的判断变成了“直接比较元组”和人类对版本号的理解完全对齐。我当时把代码里一堆版本判断从三行 OR 改成这个写法代码评审的同事直接说“这写得太舒服了”。3.3 多列配对查找IN、EXISTS 的简洁化除了比较大小行值比较还支持和IN的子查询配对。比如有个成绩表exam_score(student_id, exam_time, score)你想查出每位考生最近一次考试的记录SELECT s.student_id, s.exam_time, s.score FROM exam_score s WHERE (s.student_id, s.exam_time) IN ( SELECT student_id, MAX(exam_time) FROM exam_score GROUP BY student_id );以前遇到这种“两列同时匹配子查询”的需求要么写 EXISTS 关联要么把两列拼成一个字符串再 IN又别扭又有风险。用行值比较(s.student_id, s.exam_time) IN (...)语义非常清晰找一对键。不过我要提醒一句这种 IN 多列子查询的写法在 MySQL 里要关注优化器能不能把子查询转成高效的 semi join。我实测在 MySQL 8.0 里通常表现不错但在 5.7 的某些版本上可能退化成逐行判断生产环境用之前一定 EXPLAIN。3.4 完整的模拟演示建表、造数、看执行计划为了让你对性能有体感我用一个 30 万行的模拟场景走一遍。-- 建表 CREATE TABLE play_log ( user_id INT NOT NULL, play_time DATETIME NOT NULL, song_id INT NOT NULL, duration_sec INT, PRIMARY KEY (user_id, play_time, song_id) ); -- 写入 30 万行模拟数据略 -- 目标查询 user_id88 在某个游标之前的 10 条记录 EXPLAIN SELECT user_id, play_time, song_id, duration_sec FROM play_log WHERE user_id 88 AND (play_time, song_id) (2024-06-14 21:35:12, 1001) ORDER BY play_time DESC, song_id DESC LIMIT 10;在 MySQL 8.0 里执行计划的type一栏大概率是rangekey显示的是主键PRIMARYrows预估只需要扫描游标附近的少量行数。这个效果和 OFFSET 深分页那种type index、扫描几万行的执行计划是完全不同的量级。注意如果你的执行计划出现Using filesort说明 WHERE 里的比较顺序和索引列顺序对不上或者 ORDER BY 方向与索引不一致。优先检查这两点而不是去怀疑行值比较本身。4. 常见问题与排查实录4.1 为什么我的比较结果和预期不一致这类问题里大部分是下面三种原因。原因一左右两侧的列数量不一致。行值比较要求两边列数相同(a, b) (x)这种写法在 MySQL 里直接报Operand should contain 2 column(s)。这种报错反而是好事因为问题立刻暴露了怕的是两边数量碰巧一致但语义搞错那才难查。原因二列顺序写反。(play_time, song_id) (2024-06-14 21:35:12, 1001)和(song_id, play_time) (1001, 2024-06-14 21:35:12)完全是两回事。前者先比时间再比歌曲 ID后者先比歌曲 ID 再比时间。如果你的 ORDER BY 是play_time DESC, song_id DESC而 WHERE 里写的是(song_id, play_time) (1001, ...)那么翻页会错位结果重复。原因三NULL 参与比较导致行被吞。前文已经说过避免方法有两个一是业务上把排序字段设为 NOT NULL二是在查询里用 COALESCE 兜底但注意 COALESCE 会影响索引。4.2 为什么这条 SQL 没有走索引很多人在网上看到行值比较就说“一定走索引”实际上不一定。索引能否生效受几个条件制约左边第一个比较列必须是索引的最左前缀。如果索引是(a, b, c)那么(a, b) (x, y)大概率能走但(b, c) (y, z)以 b 开头如果查询里只有这个条件可能就退化成全表扫描。比较方向要和索引方向匹配。MySQL 8.0 对倒序索引支持得不错但老版本里 DESC 排序与升序索引配合时执行计划可能不会用最优 range。不要在列上套函数。这一点和普通查询一样(DATE(play_time), song_id) ...会让索引失效。排查方法很简单EXPLAIN SELECT ...重点看type和key两列。如果type是ALL或index说明优化器没有走到理想的范围扫描你需要调整索引定义或改写条件。4.3 不同数据库的坑位盘点按我实际踩过的经验整理一份速查表场景数据库坑点建议深分页MySQL 5.7行值比较优化不稳定偶发全表扫描升级 8.0或用联合索引 EXPLAIN 验证NULL 排序PostgreSQL和默认把 NULL 排到末尾和业务期望可能相反用 NULLS FIRST/LAST 或 COALESCE明确排序方向不支持行值SQL Server直接写会语法报错手动展开为 OR 结构并检查执行计划子查询 INMySQL 8.0正常5.7 可能半连接优化不理想确认优化器版本后再上线字面量比较所有数据库(字符串, 数字)类型不一致时可能隐式转换两侧类型保持完全一致其中 PostgreSQL 的 NULL 排序值得多说一句。PostgreSQL 在 ORDER BY 里默认NULLS LAST升序时但行值比较的处理 NULL 时依然走三值逻辑不会自动套用 NULLS LAST 的语义。所以如果你排序字段有 NULL最稳妥的做法是先过滤WHERE play_time IS NOT NULL AND (play_time, song_id) (2024-06-14 21:35:12, 1001)这样既避免 NULL 干扰也不会因为加函数影响索引。4.4 几个被反复问到的细节问题问(a, b) (x, y)和(a, b) (x, y)有什么区别区别就是边界。不包含等于边界包含。如果你的游标来自上一页最后一条记录下一页应该用或而不是或否则会重复取出游标那一行。问我可以把(major, minor, patch) BETWEEN (1, 0, 0) AND (1, 2, 9)写在一条语句里吗MySQL、PostgreSQL、SQLite 都支持但边界语义要小心。BETWEEN 是闭区间AND后面的边界会被包含。如果你不想包含(1, 2, 9)就用AND (major, minor, patch) (1, 2, 9)。问这种写法能用在 UPDATE 或 DELETE 里吗可以。比如批量删除某个复合游标之前的日志DELETE FROM play_log WHERE user_id 88 AND (play_time, song_id) (2024-05-01 00:00:00, 0);但删除和更新操作最好加 LIMIT 分批执行避免长事务锁行。这一点和普通删除一样。4.5 实际心得三个让我“终于会用”的经验最后分享几个我自己用下来的经验。第一写 keyset 分页时游标里一定要带一个唯一列。如果排序字段本身不唯一比如只按play_time排序那么分页边界可能撞上多条相同时间的记录。我处理过的一个真实问题是用户翻到第 3 页时发现有一条记录重复出现而第 4 页又少了一条。原因就是play_time有大量相同值游标无法唯一定位。解决办法是在游标末尾追加主键列song_id这也是我在 3.1 节里把(play_time, song_id)作为游标的原因。第二对比 OR 展开式行值比较不只是在卖弄语法。a x OR (a x AND b y)这种写法在优化器眼里可能被拆成两个分支去扫描也就是a x扫一段范围a x AND b y又扫一段范围最后做合并。而行值比较直接表达“完整游标之后的区间”更贴合复合索引的有序特性。因此同样的业务逻辑行值写法通常更容易生成单一范围扫描的执行计划。第三不要迷信“一定更快”。我在 MySQL 5.7 上确实遇到过极端情况表数据很少、索引统计信息过期、优化器选择全表扫描反而比 OFFSET 还慢。所以任何优化都必须以 EXPLAIN 为依据线上环境拿真实数据量验证一遍再上线。写在最后如果你和我一样第一次看到(a, b) (x, y)会觉得有点叛逆但用顺了之后会发现它其实就是“把多列排序条件当成一个整体游标”的自然表达。现在我在设计 SQL 时只要遇到复合主键、版本号判断、多列翻页第一反应就是能不能用行值比较把条件写清楚。说个小技巧给刚开始用的人你完全可以在不建表的情况下用SELECT (1, 2, 3) (1, 2, 4);这种字面量语句在数据库里快速验证语法和支持情况。跑通一条简单的再迁移到真实场景思路会清晰很多。行值比较不是什么高深算法它只是把“字典序”这个直觉还给了 SQL仅此而已。