☰
SQL行值比较:告别OFFSET深分页,用Keyset Pagination提升查询性能
2026/10/20 14:49:10 网站建设 项目流程

“写了 5 年 SQL,才发现可以用(a, b) > (x, y)这种神仙写法!”这句话我是在一次代码评审里听到的。当时有个需求要“按登录时间倒序,滚动翻页拉取某用户的数据”,第一版写成了login_time < ? OR (login_time = ? AND id < ?)。逻辑没错,但读起来很绕,括号一层套一层。同事扫了一眼,改成(login_time, id) < (:time, :id),整个查询一下清爽了,而且执行计划比原来还稳。从那次开始,我才认真去补 SQL 里的行值比较(Row Value Comparison),越用越觉得,它不是炫技,而是很多复杂条件最自然的表达方式。这篇文章就围绕这种写法,把语义、场景、索引表现和容易踩的坑一次说清楚。

1. 先把这个“神仙写法”的语义彻底拆明白

1.1 它不是在表达“两个条件同时成立”

很多人第一次看到(a, b) > (x, y),下意识会理解成“a 大于 x,并且 b 大于 y”。这是最大的误解。

它的真实含义是:先比较第一列,如果第一列已经能判断大小,就直接出结果;只有第一列相等时,才继续比较第二列。这跟查字典一模一样:先看首字母,首字母大的词直接排在后面,首字母相同的才看第二个字母。

举例来说:

SELECT * FROM t WHERE (a, b) > (1, 2);

上面这条 SQL 等价于:

SELECT * FROM t WHERE a > 1 OR (a = 1 AND b > 2);

注意这里是OR,不是AND。所以(a,b) = (2, 0)也能命中,因为a=2已经大于1,后面的b根本不需要看。而(a,b) = (1, 3)也会命中,因为第一列相等,第二列3 > 2。

>是这样,<和>=、<=也都是同一套规则,只是方向反过来。如果用=就比较直观了:

WHERE (a, b) = (1, 2);

等价于:

WHERE a = 1 AND b = 2;

多列也同理,(a, b, c) > (x, y, z)就是先比 a,a 相等比 b,b 还相等再看 c。整个过程就是标准的多列字典序比较。

1.2 我为什么写了五年才注意到这个写法

说实话,不是这个语法太偏,而是日常环境里几乎没人主动提它。

第一,大部分入门教程只讲单列上的WHERE、ORDER BY,很少有一节课专门讲“多列元组比较”。第二,主流 ORM 和 Query Builder 一般也不暴露这类写法,很多人天天写应用代码,接触不到裸 SQL 的高级形态。第三,即便偶尔在文档里看到(a, b) > (x, y),第一反应也容易觉得是某个数据库的方言,不敢往生产代码里放。

但实际上,行值构造器(Row Value Constructor)是 SQL 标准里早就有的概念,PostgreSQL、MySQL 等主流数据库都支持。它不是冷门黑科技,纯粹是信息差。

明白了基本语义之后,接下来要聊的才是重点:这个写法到底能用在哪些场景,值不值得抛弃原来的 OR/AND 写法。

2. 落地场景首选:深分页查询彻底告别 OFFSET

2.1 传统 OFFSET 分页慢在哪、乱在哪

先看一个特别常见的业务:展示某个用户的登录记录,按登录时间倒序,每页 20 条。传统写法通常是:

SELECT id, login_time, login_ip FROM user_login_log WHERE user_id = ? ORDER BY login_time DESC, id DESC LIMIT 20 OFFSET 1980;

如果要看第 100 页,OFFSET就是 1980。数据库为了拿到这 20 条,理论上要先生成前 2000 条结果,再把前 1980 条丢掉。随着页码越来越深,扫描量越来越大。这个问题的本质是:OFFSET只告诉数据库“丢掉多少行”,没有告诉数据库“从哪个位置继续”。

更隐蔽的问题是分页漂移。翻页过程中如果老数据被删除,或者新数据被插进来,固定偏移量就会导致同一行在上一页和下一页重复出现,或者某一行被漏掉。在无限滚动、消息列表、Feed 流这几种场景里,这种问题特别容易被用户感知。

2.2 用行值比较实现 Keyset 分页

行值比较解决的是“从哪个位置继续”的问题。思路是记住上一页最后一条记录的位置,下一页直接从那个位置之后开始取。这就是常说的 Keyset Pagination,也叫 Seek Method。

还是登录记录的例子。表结构大致是:

CREATE TABLE user_login_log ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, login_time DATETIME NOT NULL, login_ip VARCHAR(64) ); CREATE INDEX idx_user_time ON user_login_log(user_id, login_time, id);

第一页没有游标,正常查:

SELECT id, login_time, login_ip FROM user_login_log WHERE user_id = ? ORDER BY login_time DESC, id DESC LIMIT 20;

拿到第一页 20 条之后,记住最后一条的login_time和id。假设分别是2024-06-01 12:30:00和10086,第二页就这样查:

SELECT id, login_time, login_ip FROM user_login_log WHERE user_id = ? AND (login_time, id) < ('2024-06-01 12:30:00', 10086) ORDER BY login_time DESC, id DESC LIMIT 20;

注意这里用的是<而不是<=。因为上一页的最后一条已经展示过了,下一页必须排除它。

和 OFFSET 方案对比,差别非常明显:

对比项OFFSET 分页行值比较 / Keyset 分页
扫描方式每次从头部重新定位,再跳过 N 行从已知游标继续向后扫描
深页性能页码越深越慢,扫描量线性增长基本恒定,和页码深度无关
数据稳定性插入/删除会造成重复或漏行只受“游标之后新增数据”影响
适用场景随机跳页、小数据量管理后台无限滚动、Feed 流、按序加载

2.3 排序方向、游标位置和边界处理

用这个写法,第一反应容易搞错的是符号方向。我刚开始也栽过:ORDER BY 是DESC,游标比较却写了>,结果一页数据都查不出来。

规律其实很简单:排序方向决定比较方向。

  • ORDER BY login_time DESC, id DESC,当前页最后一条是“已返回数据里最小的”,下一页要更小,所以游标用<。
  • ORDER BY login_time ASC, id ASC,当前页最后一条是“已返回数据里最大的”,下一页要更大,所以游标用>。

还有个容易忽略的细节:排序字段必须要有一个唯一列来打破平局。login_time很可能在同一秒出现多条记录,如果只按它排序和比较,翻页时会丢掉整批相同时间的记录。所以我在 ORDER BY 和游标里都加了主键id,用(login_time, id)一起定位。这就是前面索引里写成(user_id, login_time, id)的原因。

另外,行值比较这种方式并不适合“点击第 87 页”这种随机跳页需求。用户没有上一页的游标,只有页码,只能老老实实用 OFFSET 或者先算出目标游标再跳。所以在实际项目里我一般这样分工:管理后台用 OFFSET,简单直接;C 端滚动加载用 Keyset 分页,稳定且经得起数据量增长。

3. 复合索引下的执行计划:为什么它更容易走索引,又会在哪失效

3.1 复合索引天然就是字典序

复合索引(a, b)的物理存储排序,本身就和行值比较的规则一致:先按 a 排,a 相同的按 b 排。所以WHERE (a, b) > (1, 2)这个条件,优化器很容易映射成“从某个索引位置开始连续扫描”。它表达的是一个连续区间,不是一个需要拼接多个离散区间的复杂谓词。

拿前面的登录记录例子来说,索引(user_id, login_time, id)加上条件:

WHERE user_id = ? AND (login_time, id) < ('2024-06-01 12:30:00', 10086)

执行计划会先用user_id定位到用户的记录区间,再用(login_time, id)定位到游标位置,然后向“更小”的方向顺序读 20 行。整个过程就是 B+Tree 上的一次定位加一次范围扫描。

对比一下传统的 OR 写法:

WHERE user_id = ? AND ( login_time < '2024-06-01 12:30:00' OR (login_time = '2024-06-01 12:30:00' AND id < 10086) )

逻辑上等价,但优化器需要理解“两个条件可以合并成一个范围区间”,不同版本、不同数据库对这类 OR 的识别能力不一致。行值比较直接把意图写在了语法里,优化器少做一层等价变换,执行计划通常更稳定。

3.2 失效条件与易混淆的“半失效”写法

语法能用,不等于索引就一定能用好。下面这几种情况会让行值比较失去索引优势。

  • 第一列包了函数。比如(DATE(login_time), id) < ('2024-06-01', 10086),因为login_time被DATE()包裹,索引里的原始值没法直接参与范围比较,数据库只能全量扫描后逐行算函数。
  • 隐式类型转换。比如(login_time, id) < ('2024-06-01 12:30:00', '10086'),如果id是 BIGINT 而游标里传的是字符串,部分数据库会放弃索引或者做额外转换。参数类型最好和列类型完全一致。
  • 列顺序和索引顺序不一致。索引是(login_time, id),条件却写(id, login_time) < (10086, '2024-06-01'),优化器很难直接复用原索引的序关系。

还有一个容易误判的场景。有人以为只要写了(a, b) > (x, y)就一定能提速,但如果 a 列本身没有等值条件,而是希望通过“先按 a 分组,再在组内按 b 翻页”,行值比较并不能直接解决。比如想实现“每个分类下的商品按 id 滚动”,正确姿势是先写category = ?的等值条件,再在(category, id)上做行值比较;如果 category 不固定,想表达“组内”就比较吃力,需要窗口函数或别的办法。

3.3 AND 独立条件场景千万别硬套

写 SQL 最怕的就是把一个语法套进所有场景。行值比较适合“多个列拼成一个排序位置”的语义,但完全不等于“多个列同时满足各自的范围条件”。

举个反例。想查“2024 年之后创建,并且金额大于 100 的订单”,如果图省事写成:

WHERE (created_at, amount) > ('2024-01-01', 100)

结果会错。因为第一列created_at只要大于2024-01-01,整行就满足条件,不管amount是多少。2024 年 3 月创建的 50 元订单会被查出来,而 2023 年 12 月 31 日创建的 1000 元订单反而被排除,因为第一关就过不了。

真正正确写法是:

WHERE created_at > '2024-01-01' AND amount > 100;

这个例子说明,行值比较表达的是“这一行在字典序里的位置”,不是“多个字段的独立筛选”。把这句话记住,基本就不会再乱用了。

4. 跨数据库兼容性:哪些库能写,哪些库要绕路

4.1 各库支持情况速查

行值比较虽然来自 SQL 标准,但数据库厂商的落地情况非常不一致。这也是很多老手只在某个特定数据库里见过它的客观原因。

数据库行值比较支持情况备注
PostgreSQL支持,使用广泛(a,b) > (x,y)是日常玩法,文档里讲得也清楚
MySQL 8.0 / 5.7支持较老版本执行计划可能不理想,需要 EXPLAIN 验证
MariaDB支持行为基本跟随 MySQL
SQLite3.15 及以上支持轻量场景下写起来很顺手
Oracle支持多列比较和元组 IN不同版本细节差异大,以实测为准
SQL Server原生不支持这种行值关系比较一般用 OR 展开,或用窗口函数实现同等效果

如果你主要在 SQL Server 环境工作,很可能根本没见过这个写法,因为写了会直接报语法错误。这解释了为什么网上一搜“SQL 神仙写法”,很多人第一反应是“什么数据库?我这边跑不通”。

4.2 同思路的元组 IN 与多列子查询

除了>、<,行值构造器还有几个兄弟写法,理解了之后会感觉整个思路都是通的。

复合主键批量匹配,用元组 IN 非常干净:

WHERE (user_id, role_id) IN ((1, 2), (1, 3), (2, 4));

等价于以前那种长长的:

WHERE (user_id = 1 AND role_id = 2) OR (user_id = 1 AND role_id = 3) OR (user_id = 2 AND role_id = 4);

还有多列子查询:

WHERE (user_id, login_time) IN ( SELECT user_id, MAX(login_time) FROM user_login_log GROUP BY user_id );

这条查询能直接表达“找出每个用户最近一次登录记录”的需求,比起先 GROUP BY 再 JOIN 的写法,语义上更贴近业务。不过要注意,多列子查询在某些数据库上的执行计划要仔细测,不一定永远比 JOIN 快。

4.3 兼容层落地建议

如果团队同时维护多个数据库,我的建议是:不要在业务代码里散落这种写法,封装一层 SQL 片段工具。比如统一提供一个“构建游标条件”的函数,目标库支持行值比较就输出(a,b) > (?,?),不支持就降级为a > ? OR (a = ? AND b > ?)。这样既享受了语法的简洁,也避免把项目锁死在单一数据库上。

代码评审时如果出现行值比较,我一般会顺手留一行注释,例如:

-- (login_time, id) < (:time, :id) 等价于: -- login_time < :time OR (login_time = :time AND id < :id)

因为不是每个人都熟悉这个语法。一行注释能省掉后续大量的“为什么这么写”的疑问。

5. 实操中容易踩的边界:NULL、隐式转换和排查方法

5.1 NULL 会把整个比较变成未知

SQL 里和 NULL 比较的结果不是 TRUE 也不是 FALSE,而是 UNKNOWN。行值比较也一样,任何一列碰到 NULL,结果都可能变成未知,最终导致该行被过滤掉。

最典型的坑是=场景:

WHERE (name, age) = ('张三', NULL);

这条 SQL 永远不会返回结果,因为age = NULL是 UNKNOWN。必须改写成name = '张三' AND age IS NULL。>和<同理,只要参与比较的列里含有 NULL,行为就和直觉不一样。

分页场景里,如果排序字段允许为空,游标会比较难维护。比如某条记录的login_time是 NULL,它到底排在最前还是最后,取决于数据库的 NULLS FIRST 还是 NULLS LAST 规则。游标里带着 NULL 去比较,下一批数据可能莫名其妙缺失。我的建议很简单:排序字段尽量设计成 NOT NULL,带默认值;如果历史数据已经存在 NULL,用COALESCE(login_time, '1970-01-01')统一兜底,并且这个兜底逻辑必须同时出现在 ORDER BY 和行值比较里,否则排序和分页会对不上。

5.2 隐式类型转换与排序规则

行值比较对类型一致性的要求比单列比较更高,因为它是多列打包比较,任何一列的隐式转换都可能影响整个谓词的索引使用。

比如(a, b) > (?, ?)中,b 列是整数,但应用层把 b 的参数拼成了字符串,数据库可能会对 b 列做隐式 CAST,索引失效,查询退化成全表扫描。排查这类问题时,我会看 EXPLAIN 里的type字段,如果明明有条件却显示ALL或者full index scan,第一件事就是查参数类型。

字符串列的排序规则也一样重要。('a', 1) > ('B', 0)这个比较,在大小写敏感的 collation 下是一个结果,在大小写不敏感下是另一个结果。如果数据库默认排序规则变更,或者做数据迁移,之前跑得好好分页逻辑可能突然错乱。

5.3 排查清单与替换决策

我在生产环境用这个写法之前,基本会按这套清单过一遍:

  • 先确认数据库版本和文档支持情况,SQL Server 直接放弃,老版本 MySQL 先测执行计划。
  • 用 EXPLAIN 看是否命中预期索引,关注type、rows、Extra几项。MySQL 里还可以在 EXPLAIN 后加SHOW WARNINGS看优化器改写成什么样。
  • 在测试环境构造大量重复排序字段的数据,专门验证游标会不会漏数据或重复数据。
  • 接口层把上一页游标两个值打日志,出问题时能快速还原。

如果发现行值比较在当前数据库版本上执行计划不理想,不要死磕。它不是一个“必须用”的语法,等价 OR 写法或者窗口函数同样能解决问题。工具是为人服务的,哪种写法在当前环境里稳定、可维护,就用哪种。

我在实际项目里最常用的还是 Keyset 分页这个场景。它最大的价值不是让 SQL 看起来更短,而是把“我在哪、下一步去哪”这个状态表达清楚了。只要你理解它是在描述多列打包后的字典序位置,并且注意 NULL、类型、排序方向这几个细节,基本就不会翻车。说到底,这不是什么神仙语法,只是行值比较这个标准能力离日常编码太远,一旦认识了它,很多以前靠 OR 硬撑的写法都会变得顺眼很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询