我第一次在同事的代码里看到WHERE (user_id, order_no) (U1001, ORD20240001)这种写法的时候脑子里只有一个想法这语法是不是写错了写了五六年 SQL我习惯的条件过滤永远是a x AND b y这种逐列比较突然来一个用括号包起来的元组比较第一反应确实有点懵。后来自己跑了一遍结果完全正确又专门查了文档才发现这其实是关系数据库里一个很基础但很少被挂在嘴边的能力——行值比较。今天就把这个写法的原理、适用场景、性能和坑一次讲清楚尤其是最后一个部分值得先看。1. 一眼看不懂的写法元组比较的语义和兼容边界1.1 初见 (a, b) (x, y)这不是语法错误而是行值比较先说结论(a, b) (x, y)是标准的行构造器比较在 SQL 标准里有明确的位置。它比较的不再是“两个独立列的值”而是把(a, b)和(x, y)当成两个整体来比大小。这种“整体”就叫行值或者元组。我第一次理解这个语义是靠查字典打比方。一本英文字典里“apple”排在“app”后面因为先比较第一个字符a相同再比较第二个字符p和p相同接着比较第五个字符l和空字符空字符更小。行比较也是这个逻辑先比较第一列第一列相等时再比较第二列如果第二列还相等继续往后比较第三列、第四列。整个过程就是字典序。举个最直观的例子(apple, banana) (apple, cherry) -- 结果是 TRUE第一列apple等于apple所以不能直接判大小继续比第二列banana小于cherry最终结果是 TRUE。如果第一列本身就不相等比如(grape, a) (apple, z)那么直接由第一列决定第二列连被比较的机会都没有。这种规则和ORDER BY user_id, order_no的排序规则完全一致。也可以说行比较就是“判断一个元组在按照多列排序之后位于另一个元组之前还是之后”。1.2 哪些数据库能用哪些数据库直接报错很多第一次接触这个写法的人最大的困惑不是语义而是“我用的数据库支不支持”。这里先说结论支持和不支持的差距非常大。数据库行值比较支持情况说明MySQL 5.0支持WHERE 条件里可用8.0 优化器对行构造器的处理更完善PostgreSQL支持官方文档专门有 row comparison 说明语义严谨SQLite 3.15支持较新版本可用老版本会报语法错误Oracle部分场景可用写法接受度有限实际编码中更常写展开式SQL Server不支持WHERE (a,b) (x,y)基本都是语法错误这里面最需要注意的是 SQL Server。很多团队在数据库选型时用了 SQL Server然后从 MySQL 的教程里抄到这种写法一执行就直接报错。T-SQL 里虽然可以用(VALUES (1,2))这类表值构造器但语法上并没有把(a,b)当作一个可比较的行值对象来对待。如果你的项目跑在 SQL Server 上看到这行语法时别急着找优化器问题先确认是不是写法本身不被支持。1.3 和窗口函数、去重这些“熟悉又陌生”的 SQL 能力对标其实这类“知道的人不多但非常好用”的 SQL 特性远不止行值比较一个。像窗口函数里的ROW_NUMBER() OVER (PARTITION BY ...)很多人也是写了三四年 SQL 才第一次用上DISTINCT ON在 PostgreSQL 里的去重能力同样能颠覆“先 GROUP BY 再 MAX”的老套路。行值比较和它们是同一类东西不是新功能而是一直存在于 SQL 标准里、却被日常业务查询忽略的能力。2. 为什么它能替代繁琐的 AND / OR等价格式和字典序原理2.1 (a,b) (x,y) 并不等于 a x AND b y这一点最容易踩坑也最容易让人误会。WHERE a x AND b y表达的是“两个条件同时满足”它描述的是一个矩形区域。而WHERE (a,b) (x,y)表达的是“元组在字典序中位于 (x,y) 之后”它描述的是排序后的连续区间。来看一个反例a 100, b 1x 99, y 99(100, 1) (99, 99)的结果是 TRUE因为第一列100 99直接决定了结果第二列根本不用看。但如果写成a x AND b y也就是100 99 AND 1 99结果是 FALSE。所以两者完全不是一回事。如果你想要的本来就是一个“freenet 矩形区域”那用行比较就错了如果你想要的是“排序之后跳过某个位置、从这里继续往后取”行比较恰好精准命中。2.2 等价展开式一张表看懂所有运算符行值比较的每种运算符在语义上都可以展开成逐列条件。下面这张表建议收藏遇到要给别人讲代码逻辑的时候直接拿它解释。写法等价展开式(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)(a,b) (x,y)a x AND b y(a,b) (x,y)a x OR b y大家常用的大于号展开之后是a x OR (a x AND b y)。这个 OR 结构在优化器眼里往往不如“直接对复合键游标做范围扫描”来得干净这正是行比较写法在特定场景下能提升性能的原因之一。2.3 可读性当你需要描述“位置”而不是“条件”时更好用有些查询的语义用自然语言描述是“从这条记录之后开始取 20 条”“取排序后第 100 到 120 条”这种永远带着“位置感”的需求写展开式总感觉隔了一层WHERE user_id USER_004321 OR (user_id USER_004321 AND order_no ORD_000001234)而用行比较WHERE (user_id, order_no) (USER_004321, ORD_000001234)第一眼可能不习惯但看多了之后会发现后者和“跳到这个位置继续读”的业务含义一一对应反而更容易维护。当然前提是团队里所有人都知道这个语义否则第一次看代码的人还是会愣住。3. 真正该用它的地方复合主键游标、排序定位、区间表达3.1 Keyset 分页把上一页最后一行当作游标先说最值得推荐的场景深分页优化。传统分页常用LIMIT 20 OFFSET 100000让数据库先数过前面十万行再返回后面的 20 行。数据量小的时候无所谓数据量上了几百万OFFSET 越大扫描成本越高页响应时间会越来越慢。Keyset 分页的思路完全不同不用页码而是记住“上一页最后一条记录”的位置下次查询直接从这个位置往后取。-- 第一页没有游标直接取前 20 条 SELECT user_id, order_no, amount FROM user_orders ORDER BY user_id, order_no LIMIT 20; -- 第二页把上一页最后一条的 (user_id, order_no) 作为起始位置 SELECT user_id, order_no, amount FROM user_orders WHERE (user_id, order_no) (USER_004321, ORD_000001234) ORDER BY user_id, order_no LIMIT 20;这里(a,b) (x,y)的价值就出来了。它在语义上等价于“从排序后的游标处开始截取”直接命中复合主键的字典序规则优化器很容易把它转化成一次精准的索引范围扫描。这种分页方式还有一个隐藏优势当表有新数据插入时OFFSET 分页可能出现“下一页和上一页重复一条”“翻页漏掉新增记录”的问题而 Keyset 分页因为基于位置游标结果集合稳定得多。3.2 排序定位流式拉取和增量同步的通用写法Keyset 分页不止能用于用户端翻页后端服务之间的数据同步、消息拉取、日志导出也经常用。比如我们需要从订单表里持续导出某个用户之后的全部数据SELECT * FROM user_orders WHERE (user_id, order_no) (last_user_id, last_order_no) ORDER BY user_id, order_no LIMIT 5000;每次导出结束后更新last_user_id和last_order_no为最后一条记录的值下次继续。这种模式在数据量很大的情况下比“记录 ID 递增数值型条件”更通用因为复合游标可以表达任意多列的排序位置。3.3 用行比较构造“区间”但必须清楚这是字典序区间除了大于小于行比较还可以组合成“区间”表达。比如查一年内的月份数据SELECT * FROM monthly_metrics WHERE (year, month) (2023, 1) AND (year, month) (2024, 12) ORDER BY year, month;这里把BETWEEN拆成了两个行比较因为有些数据库对“行构造器 BETWEEN”的支持不完全一致用和组合最保险。如果你用的库里能直接写(year, month) BETWEEN (2023, 1) AND (2024, 12)效果也一样。但这个区间是字典序区间不是二维坐标矩形这个区别很关键。后面专门讲。4. 用之前必须搞懂的边界情况NULL、类型、BETWEEN 陷阱4.1 NULL 参与行比较结果是 UNKNOWNSQL 里只要任何值参与比较出现 NULL结果就很微妙。行值比较也一样不是简单地把“某一列 null”当作最小或者最大而是整个比较结果变成 SQL 三值逻辑里的 UNKNOWN。看这个查询SELECT * FROM user_orders WHERE (user_id, order_no) (NULL, ORD_000001234);由于user_id的第一列要跟NULL比较整个(a,b) (NULL, ...)的结果在绝大多数数据库里是 UNKNOWN最终被 WHERE 过滤掉不会返回任何行。这不是数据库 bug而是 SQL 对“未知值比较”的保守处理。所以实际业务里如果某列允许 NULL用行比较之前最好先决定好 NULL 的归属。通常做法是先用COALESCE给它一个边界值WHERE (COALESCE(user_id, ), order_no) (USER_004321, ORD_000001234)但注意COALESCE会破坏索引使用如果这列大量为 NULL这个方案要重新评估。最稳妥的方法还是从建模开始就让游标列NOT NULL复合主键本身也要求所有列NOT NULL。4.2 隐式类型转换数字语义别硬塞进 varchar行比较在类型处理上是“逐列比较”每一列都有自己的类型和排序规则。这就会引出另一个问题字符串的字典序和数字的大小序经常不一致。假设order_no是VARCHAR装的是ORD_000001234这种定长字符串字典序没问题。但如果你存的是1000000这种变长数字字符串那就惨了ORD_999999和ORD_1000000逐字符串比较的时候9大于1所以ORD_999999会排在ORD_1000000后面和你直觉里的数值顺序正好相反。用行比较时尤其要注意参与比较的每一列都保持类型稳定。如果字段本身有数字语义就建数字类型如果是字符串但需要按数值顺序考虑补齐位数或者干脆不要在业务里存这种字段。4.3 行比较 BETWEEN 的“字典序区间”和“二维矩形”不是一回事前面说(year, month) BETWEEN (2023,1) AND (2024,12)很多人的第一反应是它代表了“2023 年 1 月到 2024 年 12 月”这个矩形区域。实际上它代表的是字典序上的一段连续区间。我举个例子WHERE (year, month) BETWEEN (2023, 1) AND (2024, 12)这个条件会命中(2024, 0)——如果数据里存在month 0这样的脏数据的话。为什么比较规则是先看year2024 2023已经大于下限再看上限2024 2024继续比month0 12所以在区间内。但如果我们主观上想要“2023 年和 2024 年中月份在 1 到 12 之间”的矩形区域(2024, 0)明显不应该被包含。这就是字典序区间和二维矩形区间的本质区别前者是排序后连续的一段后者是按坐标轴框出来的正方形。如果你需要矩形区域正确的写法仍然是year BETWEEN 2023 AND 2024 AND month BETWEEN 1 AND 12千万别图省事套行比较。5. 一次真实场景下的性能实测行比较 vs 展开式 vs OFFSET5.1 测试环境与表结构为了验证行比较的实际表现我在本地 MySQL 8.0 环境里建了一张测试表插了两百万行数据CREATE TABLE user_orders ( user_id VARCHAR(32) NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, order_no) ) ENGINEInnoDB;主键是(user_id, order_no)天然就是一个适合行比较的复合键。测试机配置一般但数据量足够让执行计划的差异暴露出来。5.2 三种写法的执行计划对比第一种行比较写法EXPLAIN SELECT * FROM user_orders WHERE (user_id, order_no) (USER_004321, ORD_000001234) ORDER BY user_id, order_no LIMIT 20;执行计划里type range走的正是主键索引rows预估只有 20 左右Extra 里有Using index condition。这才是让人惊喜的地方行比较被优化器理解成了“对复合索引做范围扫描”而不是“先全表过滤再排序”。第二种等价的展开式EXPLAIN SELECT * FROM user_orders WHERE user_id USER_004321 OR (user_id USER_004321 AND order_no ORD_000001234) ORDER BY user_id, order_no LIMIT 20;在我这个测试环境里优化器用了index_merge/union来合并两个条件也能返回正确结果但执行计划的复杂度明显比第一种高。某些数据分布下OR 展开式还可能让优化器误判产生更差的执行计划。行比较写法相当于把一个整体语义原样交给了优化器。第三种常见的 OFFSET 深分页EXPLAIN SELECT * FROM user_orders ORDER BY user_id, order_no LIMIT 20 OFFSET 100000;计划里同样是走索引但rows预估到了十万以上意味着数据库要把排序后的前十万行都数一遍再丢给你这中间白白做了很多扫描。数据量再涨几个量级响应时间会非常难看。5.3 实测里的结论与保留意见在我这次实验里三者的耗时排序是行比较 展开式 OFFSET尤其是 OFFSET 翻到十万之后差距已经拉到几十倍。不过有一点我得诚实说这个结果不能无脑推广到所有数据库和所有数据分布。如果你的复合索引顺序、列类型、数据分布和这张测试表不一样行比较和展开式各自的执行计划都可能发生变化。所以我每次写这种东西之前都会先EXPLAIN一把确认优化器真的走了想要的索引。经验可以积累但执行计划不能靠脑补。6. 不能支持行比较的数据库怎么办SQL Server 下的替代方案6.1 SQL Server 为什么不能直接写SQL Server 的 T-SQL 里(a, b)这种括号结构在语法解析的时候更多被当作“多个列表达式”或“表值构造器”而不是一个可以参与比较运算的行值对象。所以直接写SELECT * FROM user_orders WHERE (user_id, order_no) (USER_004321, ORD_000001234);大概率会得到一个语法错误可读性还很差容易让维护者误以为这里在做一个子查询。6.2 替代方案一老老实实写展开式SQL Server 下最直接的办法就是把它展开成OR条件SELECT TOP 20 * FROM user_orders WHERE user_id USER_004321 OR (user_id USER_004321 AND order_no ORD_000001234) ORDER BY user_id, order_no;虽然写起来长一些但语义完全一致。如果查询量很大配合(user_id, order_no)上的复合索引也能做到合理的范围扫描。不过要注意SQL Server 对OR条件里跨多个键的复合索引匹配有时不如UNION ALL来得干净。6.3 替代方案二用窗口函数或自连接如果我要在 SQL Server 里实现“每组前 N 条”或“找到每个游标后的记录”展开式不够优雅时可以考虑窗口函数WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_no) AS rn FROM user_orders ) SELECT * FROM ranked WHERE rn 20;这是纯 ANSI 标准写法几乎所有数据库都能跑。行值比较能搞定的场景窗口函数基本也都能替代代价是中间结果集内存开销更大尤其在大表上要谨慎。6.4 给数据库选型的一个提醒行值比较这类语法最大的痛点就是数据库方言差异。如果你在团队里推广这个写法先确认线上数据库是什么。MySQL、PostgreSQL、SQLite 走起无压力SQL Server 需要展开式Oracle 虽然部分版本能解析但遇到复杂执行计划时也不一定比展开式聪明。最好在团队规范里明确“哪些数据库允许写行比较”而不是单一依赖某一个成员的记忆。7. 代码规范角度什么时候推广、什么时候封杀7.1 适合推广的场景复合索引游标、排序定位、流式分页我的经验是只要业务里出现“记住位置然后接着取”这种需求行比较就值得用。它和复合索引的逻辑天然匹配又比 OR 展开式更含蓄地表达游标语义。代码 review 的时候看到WHERE (a,b) (x,y)只需要问一句“这个 x,y 是不是上一页的最后一条记录”比盯着十行展开式去猜业务意图要快得多。7.2 不适合硬套的场景矩形区域、可空字段、类型混乱反过来如果业务条件是“a 在某个范围之间且 b 在某个范围之间”千万别用行比较。前面已经演示过(year, month) BETWEEN的字典序陷阱。另外游标列存在大量 NULL 或者类型混乱的表也别为了炫技把简单问题复杂化。逐列条件老老实实写执行计划更可控。7.3 无论怎么写参数化绑定这条底线不能破最后说一个和安全相关的重点。行比较写法再优雅也不代表它比普通条件更“抗注入”。只要你是把用户输入直接拼进 SQL 字符串不管是单条件还是元组比较都会给恶意输入留口子。正确的姿势永远是参数绑定。比如 Java 里用 JDBCPreparedStatement ps conn.prepareStatement( SELECT * FROM user_orders WHERE (user_id, order_no) (?, ?) ORDER BY user_id, order_no LIMIT 20 ); ps.setString(1, lastUserId); ps.setString(2, lastOrderNo);Go 的database/sql里用占位符?Python 的psycopg2用%s道理都一样。SQL 写得再巧妙只要数据是用户可控的输入就必须走参数绑定把值和语法分开。这一条在引入任何新语法时都排在最高优先级。说回开头那个“神仙写法”我后来在好几个项目里用它处理过订单同步、日志游标、深分页优化也踩过字典序 BETWEEN 和 NULL 的坑。如果你读完觉得掌握了先别急着在所有查询里铺开找一张有复合索引的测试表跑几条 EXPLAIN 对比一下再决定要不要让它进入团队的 SQL 规范。数据库这块纸上的推演永远替代不了实际执行计划。