写了 5 年 SQL,我一直有个习惯:遇到分页就写LIMIT/OFFSET,遇到多条件就拼一长串OR。直到有次在一份代码评审里,看到有人用WHERE (created_at, id) < (?, ?)代替了我那条写了三年的created_at < ? OR (created_at = ? AND id < ?),我才意识到,原来行值比较这种语法就活在标准 SQL 里,只是大多数教程从没把它当重点讲。
今天就把这个“神仙写法”掰开揉碎。它到底怎么算、等价于什么、凭什么能优化分页、有哪些藏着掖着的边界条件,我都会用手把手的例子讲清楚。文章不是给数据库专家看的,而是给所有写过 SQL、被深分页坑过、或者只是想把查询写得更清爽的开发者。就算你从没听过“行值比较”这个词,照着下面的例子也能复现。
1. 行值比较的完整语义:先比第一列,相等才比第二列
我第一次看到(a, b) > (x, y)时,第一反应是“这不就是要求 a > x 并且 b > y 吗”。这是最大的误解。行值比较不是“同时大于”,而是“逐列比较、先到先得”的字典序。
1.1 三个例子让你彻底转过弯
先直接看计算结果:
(2, 100) > (3, 1)结果是false。因为第一列 2 < 3 就已经决定了结果,第二列 100 > 1 根本不会再参与比较。(3, 0) < (3, 1)结果是true。因为第一列 3 = 3 相等,才轮到第二列比较,0 < 1 成立。(3, 1) >= (3, 1)结果是true。两列都相等,条件成立。
所以(a, b) > (x, y)严格翻译成人话是:先看 a 和 x,如果 a 已经比 x 大,那整体就大;如果 a 等于 x,再看 b 是否比 y 大;如果 a 比 x 小,那整体就小,无论 b 多大都没用。
用逻辑表达式展开,就是:
(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 OR (a = x AND b <= y) |
(a, b) = (x, y) | a = x AND b = y |
这个展开关系是理解所有后续内容的地基。你完全可以在不理解字典序的情况下记住这张表,但我建议你花一分钟把字典序想明白,因为只有理解了它,后面排序方向、索引匹配那些坑你才能一眼看穿。
1.2 它和 ORDER BY 天生是一套“字典序”
SQL 里ORDER BY a, b的排序规则,是从左到右逐列排序:先按 a 排,a 相同的行再按 b 排。而行值比较(a, b) > (x, y)也是从左到右逐列比较。两者用的是同一套规则。
打个比方,这就和查字典一样。比较两个单词谁靠后,先看第一个字母,如果相同再看第二个字母;如果所有字母都相同,这俩单词就是同一个词,不存在谁大于谁。ORDER BY ...是在给单词表排顺序,(a, b) > (x, y)是在判断两个单词的相对位置,它们天然配套。
这个特性极其重要,因为它直接决定了游标分页的可行性:一个有序结果集的最后一条记录,天生可以作为“书签”构造出下一页的查询条件。你不需要额外计算任何东西,只要把上一页最后一条记录的排序字段值原样填进行值比较里就行。
也正因为如此,行值比较条件可以直接命中复合索引。B-tree 复合索引本身就按字典序组织数据,(a, b) > (x, y)这个条件在索引里就是一个范围定位,不需要把整张表捞出来逐行判断。
1.3 各数据库到底支持不支持
我最早也是抱着“是不是只有某个数据库才支持”的心态去查的。实际情况是,行值比较是标准 SQL 里的内容,主流数据库基本都认:
| 数据库 | 支持情况 | 备注 |
|---|---|---|
| MySQL 8.0+ | 支持 | 对(a,b) > (x,y)这类条件能做 range 优化 |
| MySQL 5.7 及更早 | 语法能用,优化器行为不稳定 | 务必用 EXPLAIN 验证,别直接照搬 |
| PostgreSQL | 原生支持 | 和 B-tree 索引结合得很好 |
| SQLite 3.15+ | 支持 | 行为符合字典序 |
| Oracle | 支持 | 也有行值比较的概念 |
MySQL 官方文档里管这个叫“行构造函数比较”,标准 SQL 写法是ROW(a, b) > ROW(x, y),但 MySQL、PostgreSQL 里直接写(a, b) > (x, y)更简洁。在 SQLite 里早期写法是(a, b) > (x, y)也可以。所以别被“ROW 关键字”吓到,实际项目里用括号形式就够了。
2. 行值比较真正发光的场景:游标分页、Feed 流、批量任务
语法本身很简单,难点在于知道什么时候用它。我不打算抽象地讲“适用场景”,直接给你三个我实际重构过的业务场景,每个都带 SQL,你拿去就能改。
2.1 深分页的痛:OFFSET 把扫过的行又读了一遍
几乎所有后台列表第一版都会写成LIMIT 20 OFFSET 10080。数据量几千条时毫无感觉,等表到了几百万行,你会发现第 1000 页慢得离谱。原因很简单:数据库执行LIMIT 10000, 20时,并不是“直接跳到第 10000 条”,而是从索引或表头往前数 10020 行,再丢掉前 10000 行,把最后 20 行返回给你。页码越深,扫描的行越多。
游标分页就是换一种思路:不跳,而是把“上一页最后一条记录”当作书签,下一页只找书签之后的 20 条。因为数据是有序的,书签之后的记录天然就是下一页的内容。
行值比较正是实现书签最顺手的写法。比如订单表按created_at DESC, id DESC排序,第一页取完后,客户端记住了最后一条订单的created_at和id,下一页就可以:
SELECT * FROM orders WHERE (created_at, id) < ('2024-06-01 12:00:00.123', 98765) ORDER BY created_at DESC, id DESC LIMIT 20;加不加索引,性能差距是数量级的:OFFSET 方案在第 1000 页可能要扫上万行才能返回 20 行;keyset 方案无论翻到第几页,都只扫 20 行。这个收益是实打实的,不是玄学。
2.2 Feed 流“加载更多”:方向别搞反
消息中心、动态流、评论列表这类“加载更多”的业务,本质也是一种分页,但它不太像传统页码表,更像“往下滚”。这种场景用行值比较特别自然。
假设表结构是messages(id, push_time, msg_id),按push_time DESC, msg_id DESC排序展示。第一次加载 10 条:
SELECT * FROM messages ORDER BY push_time DESC, msg_id DESC LIMIT 10;拿到这批数据后,客户端把最后一条的push_time和msg_id传回来,下一页:
SELECT * FROM messages WHERE (push_time, msg_id) < ('2024-06-02 10:00:00.000', 555) ORDER BY push_time DESC, msg_id DESC LIMIT 10;这里要特别提醒:排序是 DESC 时,“下一页”是往“更小”的方向走,所以用<;排序是 ASC 时才用>。我见过不少朋友把方向记反,结果第二页查出来空结果,或者和第一页重复。后面 4.2 节我会专门给一张对照表,这里先留个印象。
2.3 批量任务游标:对账、日志补偿类场景
比前端分页更需要行值比较的,其实是后台的定时任务。比如对账任务要扫描一整张操作日志表,一次处理 1000 条,处理完一批再取下一批。很多人写的是:
SELECT * FROM operation_log ORDER BY log_time ASC, log_id ASC LIMIT 1000 OFFSET 0;然后下一批OFFSET 1000,再下一批OFFSET 2000。这个写法有两个隐患:一是 OFFSET 越深越慢;二是如果你的任务一边扫一边还有新数据写入,或者前一批处理失败有回滚,OFFSET 会让某些行被重复扫到,或者漏掉。
用行值比较写批次游标就稳定得多:
-- 第一轮 SELECT * FROM operation_log ORDER BY log_time ASC, log_id ASC LIMIT 1000; -- 记录本轮最后一条的 log_time 和 log_id -- 第二轮 SELECT * FROM operation_log WHERE (log_time, log_id) > ('2024-06-01 10:00:00.123', 456) ORDER BY log_time ASC, log_id ASC LIMIT 1000;因为条件是“严格大于书签”,所以无论如何都不会重复取到上一批最后一条;因为是基于大小的定位,而不是数行数,所以插入新数据也不会让后面的批次错乱。这个稳定性的提升,在数据量上来之后比性能提升更值钱。
3. 从旧写法到新写法:一次订单分页改造的完整过程
光讲理论和场景还不够,我带你完整走一遍改造过程:造一张表、灌数据、写新旧两版 SQL、看 EXPLAIN 和执行时间。环境是 MySQL 8.0,你在本地照着敲一遍就能复现。
3.1 建一张带复合索引的订单表,灌 5 万行测试数据
先建表。游标分页的核心索引是(created_at, id)。为什么要把 id 放第二列?因为created_at可能重复,而id一般是主键、全局唯一。两列组合起来,才能保证排序的绝对稳定。
CREATE TABLE orders ( id bigint unsigned NOT NULL AUTO_INCREMENT, user_id bigint unsigned NOT NULL, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL, PRIMARY KEY (id), KEY idx_user_created (user_id, created_at), KEY idx_created_id (created_at, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;灌数据的 SQL 用递归 CTE,MySQL 8.0 可以直接跑。我生成 5 万行,created_at从 2024-01-01 开始每分钟一条,id自增,这样两条排序字段都有序但又不完全成比例,更接近真实数据:
INSERT INTO orders (user_id, amount, status, created_at) WITH RECURSIVE seq AS ( SELECT 1 AS i UNION ALL SELECT i + 1 FROM seq WHERE i < 50000 ) SELECT MOD(i, 1000) + 1, ROUND(MOD(i * 37, 500), 2), IF(MOD(i, 10) = 0, 2, 1), DATE_ADD('2024-01-01', INTERVAL i MINUTE) FROM seq;3.2 旧写法:OR 展开的游标条件看着就累
第一页很简单:
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;假设最后一行的值是created_at = '2024-01-20 06:20:00.000', id = 20020。传统写法里,下一页要这么写:
SELECT * FROM orders WHERE created_at < '2024-01-20 06:20:00.000' OR (created_at = '2024-01-20 06:20:00.000' AND id < 20020) ORDER BY created_at DESC, id DESC LIMIT 20;逻辑完全正确,但它有毛病:一是条件里出现了一个OR,优化器要判断是把两个范围拆开合并,还是转成别的形式;二是代码可读性差,后面接手的同事要盯着看半天才知道你在干嘛。更麻烦的是,在不同 MySQL 版本上,这条 OR 语句的执行计划可能不一样,今天走 range,明天可能就给你整个 index_merge 出来。
3.3 新写法:一行 WHERE 完成同样语义
用行值比较,同样的逻辑只有一行:
SELECT * FROM orders WHERE (created_at, id) < ('2024-01-20 06:20:00.000', 20020) ORDER BY created_at DESC, id DESC LIMIT 20;和执行原 SQL 时在应用层传参一样,这里也是两个占位符,不是把两列拼成一个值。在编程语言里写的时候,就是where (created_at, id) < (?, ?),传两个参数进去。
为什么这里用<?因为排序是created_at DESC, id DESC,我们正在往“更早、更小 id”的方向翻页,所以当前记录的排序位置必须小于上一页的最后一个书签。这个细节我在 2.2 已经提过,但值得反复强调,因为它是行值比较最容易翻车的地方。
3.4 EXPLAIN 对比:优化器到底干了什么
直接跑EXPLAIN看新旧两种写法的执行计划。在我的测试环境里:
| 写法 | type | key | extra |
|---|---|---|---|
| 旧写法 OR 展开 | range | idx_created_id | Using index condition;某些版本可能出现 Using union(index merge) |
| 新写法行值比较 | range | idx_created_id | Using index condition |
两者的 key 都是idx_created_id,都能走到索引范围扫描。但旧写法存在版本相关的优化器选择问题,尤其当 OR 两个分支涉及同一索引的不同范围时,MySQL 可能把它变成 index_merge 的 union,也可能在数据分布变化时选择不同的访问路径。而行值写法从一开始就直接告诉优化器:这是一个复合范围条件,可选择的计划更少,行为更稳定。
更直白的性能差异,可以用一个简单对比感受一下:假设有 100 万行数据,翻到第 500 页。OFFSET 写法大概需要扫描“前 50000 行再丢弃”,而行值写法因为索引直接定位到书签,扫描行数接近 20。这两个数字不在一个量级,所以“换一种写法”不是微调,而是把深分页从不可用变成可用。
4. 行值比较的四个隐藏边界:列顺序、反向排序、NULL 与类型
行值比较好用,但它不是银弹。下面这四个边界条件,每一个都是我或者身边的人真实踩过的坑,比题目本身更有价值。
4.1 列顺序必须和复合索引的顺序一致
这是最容易踩的坑。假设索引是(created_at, id),你出于习惯写了:
WHERE (id, created_at) < (20020, '2024-01-20 06:20:00.000')逻辑上看着没毛病,但实际上第一列是id,它不是这个复合索引的第一列。优化器没法直接用这个索引去定位“id 小于 20020 且 created_at 小于某个时间”的范围。这时候要么走全表扫描,要么横向匹配另一个单列索引,性能直接崩掉。
索引的匹配规则是从左前缀开始的:索引第一列必须出现在行值比较的第一列。所以游标条件的列顺序,必须和ORDER BY的列顺序、复合索引的列顺序三者完全一致。顺序对不上,索引就是个摆设。
4.2 排序方向与比较符号的对照表
我见过不少人今天用了<,明天同样的表结构排序改成 ASC 后忘了换,结果第一页正常、第二页直接空。把这四种情况列出来对照着看就清楚了:
| 排序方式 | 游标条件 | 原因 |
|---|---|---|
ORDER BY a ASC, b ASC | (a, b) > (last_a, last_b) | 下一页是更大的记录 |
ORDER BY a DESC, b DESC | (a, b) < (last_a, last_b) | 下一页是更小的记录 |
ORDER BY a ASC, b DESC | 不建议直接使用行值比较 | 混合方向不是字典序 |
ORDER BY a DESC, b ASC | 不建议直接使用行值比较 | 混合方向不是字典序 |
为什么混合方向不建议直接用?因为ORDER BY a ASC, b DESC的排序规则,等价于“先按 a 升序,a 相同时按 b 降序”,这已经不是严格的从左到右字典序了。行值比较(a, b) > (x, y)永远假设两列按照同一方向参与比较。遇到混合方向,要么改排序,要么给升降序列建对应的降序索引(MySQL 8.0 支持降序索引),否则硬套行值会得到错误结果。
4.3 NULL 会把游标“卡住”
行值比较遇到 NULL 时,遵循 SQL 的三值逻辑:结果为 UNKNOWN,行会被过滤掉。这是最隐蔽的坑。
举个例子,WHERE (created_at, id) < ('2024-01-01', 100)这个条件,如果某行created_at是 NULL,那这行根本不会出现在结果里,无论 id 是几。问题在于,如果你扫表做批量任务,而这张表的created_at里恰好有一批历史脏数据是 NULL,游标条件会永远跳过它们,你的任务会一直扫不到这批数据,仿佛它们从不存在。
我在一个旧系统迁移时踩过这个坑:日志表里有个字段允许 NULL,游标跑了几个小时,最后总有一部分数据没处理到,排查了很久才发现是 NULL 被游标条件“静默过滤”了。解决方案是:如果这一列要参与排序和游标,就要保证它是 NOT NULL;实在不行,就在查询前WHERE created_at IS NOT NULL先过滤掉,或者用COALESCE(created_at, '1900-01-01')兜底。但要注意,对列做函数处理会影响索引使用,需要根据实际执行计划权衡。
4.4 隐式类型转换和 collation:顺序对不上就白搭
行值比较能不能走索引,还取决于两边的数据类型是否一致。比如id列是varchar(20),你传一个数字20020,MySQL 会做隐式类型转换,结果就是索引列被包了一层函数,索引失效。这种事情平时写单列条件时也会遇到,但行值比较的隐蔽性更强,因为你可能只注意到了第一列,没注意第二列。
字符串排序规则同理。如果两个列的字符集或 collation 不同,排序顺序可能和你的预期不一致,游标就会出现“跳数据”的诡异现象。我的建议是:游标列尽量用数值或 datetime 类型,避免用字符串;如果必须用字符串,确认这一列的 collation 是统一的,并且 EXPLAIN 里能看到 range 而不是 full scan。
5. 不止 WHERE:行值比较的组合写法与放弃时机
行值比较不止能在 WHERE 子句里做大小判断,它还有很多变体写法,能大幅简化代码。当然,也要知道什么时候别用它。
5.1 多列 IN:批量拉取指定组合
业务里常有“一次性按联合条件查多条”的需求。以前你会写:
SELECT * FROM orders WHERE (user_id = 1 AND status = 1) OR (user_id = 2 AND status = 2) OR (user_id = 3 AND status = 2);用行值 IN 写法,一行搞定:
SELECT * FROM orders WHERE (user_id, status) IN ((1, 1), (2, 2), (3, 2));这个写法逻辑更紧凑,也更容易看出意图——它本质上是在枚举一组“复合键”。对开发来说,这种批量组合条件的查询场景太常见了,值得记下来。
5.2 多列子查询与存在性判断
行值比较也可以跟子查询配合。比如判断两张表里哪些记录在另一张表里不存在,以前要用两列分别 OR,现在可以直接:
SELECT * FROM t1 WHERE NOT EXISTS ( SELECT 1 FROM t2 WHERE (t2.uid, t2.pid) = (t1.uid, t1.pid) );或者把行值放进IN子查询里:
SELECT * FROM t1 WHERE (col_a, col_b) IN ( SELECT col_x, col_y FROM t2 WHERE col_y > 100 );这些写法在逻辑上就是一个“多列等于”的语法糖,但阅读体验好很多。你不用再小心翼翼地给每一列配对AND和OR,出错的概率也小。
5.3 什么时候明智地放弃行值比较
行值比较不是万能的。我从实际项目里总结出四个应该放弃的场景:
第一,需要随机跳页。用户想直接点“第 500 页”或者分享“第 10 页”的链接时,你还是得用 OFFSET,因为 keyset 只知道前后,不知道具体位置。单纯从“快速跳页”的角度,行值比较没法替代页码。
第二,排序字段本身会变化。比如按“点赞数”排序的排行榜,点赞数一直在变,那上一页末尾那条记录的点赞数,下一页开始时可能已经被刷过,书签就不准了。这种场景游标不稳定,要么用 OFFSET,要么接受数据的小幅漂移。
第三,多表 JOIN 后的排序。如果ORDER BY的字段来自被驱动表,行值比较只能优化驱动表的扫描,不能保证整个 JOIN 结果的排序稳定。硬要用会让 SQL 变得很别扭,不如回到 OFFSET 或者换一种结果组织方式。
第四,老版本数据库或者没建索引。MySQL 5.7 对行值比较的优化器支持不完整,写完一定要 EXPLAIN;另外,如果游标列上没有复合索引,行值比较无法利用索引做范围扫描,效果和普通全表扫描差不多。索引永远是前提。
最后分享一个我现在的个人习惯:只要是做游标分页,我会先写ORDER BY,然后看结果集的最后一条记录,把排序字段值原样抄进WHERE (col1, col2) < (?, ?)里。这套流程走多了,你会发现(a, b) > (x, y)并不是什么炫技,它只是把“从上一页末尾继续”这句话,翻译成了数据库最听得懂的方言。别神化它,但它值得放进你每一套常用 SQL 工具箱里。