☰
SQL行值比较:一行代码解决游标分页与复合排序难题
2026/10/19 14:33:39 网站建设 项目流程

写了五年 SQL,我一直觉得 WHERE 条件的玩法自己已经摸得差不多了。等值、范围、IN、EXISTS、模糊匹配,随手就能写。直到上周优化一个老项目的分页接口,在文档里翻到一行WHERE (create_date, order_no) > (...),第一反应是“这也能比大小?”仔细琢磨了一下才发现,这行看起来有点“非主流”的语法,居然能把我之前绕了一大圈的复杂 OR 条件整个干掉,而且查询性能稳得离谱。

这个语法叫行值比较,也常被叫做行构造器比较。核心就是把(a, b)当成一个整体,和另一个行(x, y)做比较。别看它写法简单,解决的是 SQL 里一类很硬核的问题:多列组合比较。最典型的落地场景就是游标分页、多列范围查询、复合排序取相邻记录。如果你也被 OFFSET 分页越翻越慢、手写 OR 条件绕到头晕这些问题困扰过,这篇文章应该能给你省下不少事。

1. 这行写法到底是什么——行值比较的语法与原理

1.1 语法长什么样

先直接看代码。假设有一张订单表,里面有create_date和order_no两个字段,我想找出“某个日期之后,且同日期下订单号更大”的所有订单。

传统写法是这样的:

WHERE create_date > '2024-01-01' OR (create_date = '2024-01-01' AND order_no > 'A10001')

用行值比较,可以直接写成:

WHERE (create_date, order_no) > ('2024-01-01', 'A10001')

是不是清爽很多?这里的(create_date, order_no)就是一个行构造器,它在语法上把两列打包成一个“复合值”,然后和右边的复合值做整体比较。比较规则是逐列进行的,第一列先比,相同时再比第二列,以此类推。

这个语法不是某个数据库私有的花活。主流关系型数据库里,PostgreSQL、MySQL、SQLite、Oracle 都支持,只是支持程度和索引利用的细节有差异,后面我会专门说。

1.2 字典序比较原理

行值比较的规则,本质上就是字典序。你查英文字典的时候,先看第一个字母,第一个字母相同再看第二个,对吧?(a, b) > (x, y)的意思就是:

  • 先比较第一列 a 和 x,如果 a 大于 x,整个表达式直接为真,不再看后面的列;
  • 如果 a 小于 x,整个表达式直接为假;
  • 如果 a 等于 x,再继续比较第二列 b 和 y,b 大于 y 才为真。

扩展到三列,规则一模一样:(a, b, c) > (x, y, z),先比 a,a 相等比 b,b 相等比 c。这就是为什么这个写法能表达“多条件组合排序”的需求——因为数据库的复合索引本就是按字典序组织数据的,这个语法和索引的组织方式天然匹配。

我常用一个生活类比帮助理解:拿两个人的身高和体重比较。规则是“先比身高,身高相同再比体重”。(180, 75) > (180, 70)因为身高相同,体重 75 大于 70,成立;(181, 60) > (180, 90)因为身高 181 大于 180,直接成立,根本不用管体重。这就是字典序的行值比较。

1.3 等价展开式,帮你吃透底层逻辑

行值比较看着像语法糖,但它不是凭空发明的东西,它有一个完全等价的展开式。以两列为例:

(a, b) > (x, y) 等价于 a > x OR (a = x AND b > y)

三列就是:

(a, b, c) > (x, y, z) 等价于 a > x OR (a = x AND b > y) OR (a = x AND b = y AND c > z)

这个展开式特别重要,因为两个原因。

第一,它能帮你快速验证自己写的行值比较是否符合业务意图。比如你订单分页要“日期大于游标日期,或者日期相等但订单号大于游标订单号”,展开式一写,逻辑对没对一目了然。

第二,它是很多不支持行值语法数据库的替代方案。你在 SQL Server 上写不了(a, b) > (x, y),但可以手写展开式,效果一样。所以理解了展开式,你就等于在任何一个数据库里都掌握了行值比较的能力。

不过这里有个容易搞错的地方:手写 OR 展开式在优化器眼里,不一定等同于行值比较。因为 OR 条件可能让优化器选择索引合并甚至全表扫描,而行值比较在很多数据库里会被优化成一个范围扫描。这个差异放到第 4 节详细讲。

2. 最香的落地场景:游标分页

2.1 传统 OFFSET 分页的痛点

分页是后端开发逃不掉的需求。最朴素的实现是 OFFSET 分页:

SELECT * FROM articles ORDER BY view_count DESC, id ASC LIMIT 20 OFFSET 0; -- 第一页 SELECT * FROM articles ORDER BY view_count DESC, id ASC LIMIT 20 OFFSET 20; -- 第二页

前几十页还好,一旦数据量上去了,OFFSET 越大越慢。原因很简单:数据库要先把前 N 行全部查出来,再一笔一笔丢给 OFFSET 掉的 N 行丢弃掉,然后才返回后面的 20 行。翻到第 1000 页,可能实际扫描了几十万行才把你要的 20 行捞出来。

这种写法还有一个更隐蔽的问题:数据在翻页过程中发生变化时,会出现重复数据。比如用户看到第 1 页,某条记录因为浏览量上升排到了前面,他再翻第 2 页时可能又看到了第 1 页里的某条记录。对内容列表这种场景,体验很差。

游标分页就是来解决这两个问题的。它不是用页码去偏移,而是用“上一页最后一条数据的位置”作为起点,往后取。关键就在于如何表达“往后”这个位置关系——这正是行值比较的强项。

2.2 游标分页的核心写法

还是拿view_count DESC, id ASC这个排序举例,每页 20 条。第一页没有游标,正常查:

SELECT * FROM articles ORDER BY view_count DESC, id ASC LIMIT 20;

拿到第一页数据后,记录最后一条的view_count和id,假设是(100, 5000)。现在要查第二页,按排序规则,第二页应该是“排在 (100, 5000) 之后”的 20 条。

排序规则是view_count降序、id升序。那么排在后面意味着什么?两种情况:

  • view_count小于 100;
  • view_count等于 100 且id大于 5000。

这正是行值比较的逻辑,但注意方向:因为第一列是降序,第二列是升序,所以不能无脑套(view_count, id) > (100, 5000)。直接套的话,展开式是view_count > 100 OR (view_count = 100 AND id > 5000),第一列方向反了。

最稳妥的写法是对降序列取负,把排序方向统一成升序再比较:

SELECT * FROM articles WHERE (-view_count, id) > (-100, 5000) ORDER BY view_count DESC, id ASC LIMIT 20;

验证一下展开式:-view_count > -100等价于view_count < 100;-view_count = -100等价于view_count = 100,加上id > 5000。完全符合业务逻辑,完美。

如果排序是两个字段都升序,比如ORDER BY create_date ASC, id ASC,那就更简单了:

WHERE (create_date, id) > ('2024-01-01', 5000) ORDER BY create_date, id LIMIT 20;

两列都降序就整体反转:

WHERE (create_date, id) < ('2024-01-01', 5000) ORDER BY create_date DESC, id DESC LIMIT 20;

这里不要死记硬背公式,记住一个推导方法:先把你的排序规则翻译成“谁排在后面”的条件,然后用手写 OR 展开式去反推行值表达式的方向,准不会错。

2.3 混合排序方向的处理技巧

实际业务里,混合方向的排序太常见了。比如“热门文章按浏览量降序,浏览量相同的按发布时间升序”,再比如“先按权重降序,再按更新时间降序”。这类需求如果不用行值比较,手写游标条件的时候极其容易翻车,我见过不止一次线上分页出现重复数据,排查半天发现是 OR 条件里第二列的方向写反了。

混合方向的通用处理套路就是我在 2.2 里展示的:降序列取负,全部转成升序再比较。以三列排序为例,假设排序规则是a DESC, b ASC, c DESC,游标值为(va, vb, vc),那么下一页的查询条件就是:

WHERE (-a, b, -c) > (-va, vb, -vc) ORDER BY a DESC, b ASC, c DESC LIMIT 20;

这样写,每列比较方向都是升序,行值比较的字典序逻辑可以直接应用,不需要自己在脑子里绕“第一列降序时第二列怎么比”。我每次写混合排序都会用这个方法,写完之后还会拿上一页最后一条记录手工推演一遍,基本能杜绝方向错误。

不过有一个细节要提醒:MySQL 里如果降序列是无符号整数类型,直接取负会踩坑。无符号整数取负后,MySQL 会把它转成一个巨大的正数,比较结果直接错乱。遇到这种情况,要么在设计表时避免无符号整数,要么就不要用取负法,老实手写展开式。

另外还有个实战小技巧:查LIMIT 20时,实际查询写LIMIT 21。多查出来的一条用来告诉你“还有下一页”。如果返回了 21 条,说明有下一页,显示“加载更多”按钮;如果只有 20 条或更少,说明到底了。这样省掉一次独立的 COUNT 查询,对性能是实打实的帮助。

3. 除了分页,这些场景同样能救场

3.1 多列范围查询

有一种查询在后台管理里很常见:按日期和流水号翻数据。比如“查 2024 年 1 月 1 日及之后、订单号大于某个值的订单”。业务上这是一个复合范围条件,不用行值语法就得写成:

WHERE create_date > '2024-01-01' OR (create_date = '2024-01-01' AND order_no > 'A10001')

这种写法的问题不仅是啰嗦,重要的是在部分数据库里,OR 条件会让优化器放弃走复合索引,转而做全表扫描或索引合并。数据量小的时候感觉不出来,到了千万级数据量,接口响应直接恶化。

用行值比较重写:

WHERE (create_date, order_no) > ('2024-01-01', 'A10001') ORDER BY create_date, order_no LIMIT 100;

如果还想加一个范围上界,比如“只查 2024 年 2 月之前的数据”,可以再配合一个普通范围条件:

WHERE (create_date, order_no) > ('2024-01-01', 'A10001') AND (create_date, order_no) < ('2024-02-01', '') ORDER BY create_date, order_no LIMIT 100;

这里('2024-02-01', '')作为上界的关键在于第二列用空字符串。因为字典序比较时,任何非空字符串都比空字符串大,所以create_date = '2024-02-01'的订单号必然比空串大,于是这个条件等于“日期小于 2024 年 2 月 1 日,或者日期等于该日但订单号仍需大于空串后小于下界”——结合order_no的语义,这通常意味着排除了该日期之后的数据。如果不需要按日期范围上界限制,只用在分页场景,这个写法非常顺手。

不过要注意,手工构造''作为第二列的边界值,需要确认业务上不存在负值或空串冲突。如果第二列可能为空字符串本身,那这个边界就不严谨了。更稳妥的做法是业务上给一个基准值,比如全零字符串'000000',或者直接用日期加一天的写法绕过这个小技巧。

3.2 复合排序中找下一条

还有一种场景,用行值比较特别优雅:给定排序规则,找“比某条记录大(小)的下一条记录”。

比如说排行榜,按score降序、win_rate升序排序,找到分数刚好低于某条记录的下一位选手。没见过行值写法的人,第一反应可能是子查询:

SELECT * FROM leaderboard WHERE score < 1000 OR (score = 1000 AND win_rate > 0.85) ORDER BY score DESC, win_rate ASC LIMIT 1;

或者用窗口函数 LAG,但窗口函数要把全部分区数据都加载进内存排序,代价不小。而行值比较的写法是:

SELECT * FROM leaderboard WHERE (score, win_rate) < (1000, 0.85) ORDER BY score DESC, win_rate ASC LIMIT 1;

注意方向:排序是score DESC, win_rate ASC,那么“下一条”是 score 更小,score 相同则 win_rate 更大。所以score列要反着比,第二列顺着比。这个逻辑你按展开式推一遍:WHERE score < 1000 OR (score = 1000 AND win_rate > 0.85),完全正确。这里因为第一列是降序,直接写<是符合逻辑的,因为score < 1000的反向排序方向正好匹配降序。如果觉得容易混,还是可以用取负法,统一转成升序再比较。

这种写法在“瀑布流加载更多”“排行榜下一位”“消息列表按时间与 ID 加载旧消息”这类场景里都适用,而且配合复合索引可以直接定位,不需要全表算窗口函数,性能优势很明显。

3.3 行值 IN 与批量查询

行构造器不仅能比较大小,还能配合 IN 做批量查询。比如我想查两个复合主键对应的订单:

传统写法:

WHERE (order_sn = 'A10001' AND item_id = 1) OR (order_sn = 'A10002' AND item_id = 2)

行值写法:

WHERE (order_sn, item_id) IN (('A10001', 1), ('A10002', 2))

这个写法有两个好处。一是可读性大幅提升,多条复合条件一目了然;二是和复合索引匹配度更高,优化器更容易走索引。

我用过一个场景:从消息队列拿到一批业务实体的复合键,需要批量去数据库查完整记录。之前是一条条查,后来改成行值 IN,一条 SQL 批量查回来,再在内存里做映射,接口耗时降了一个数量级。当然要注意 IN 列表的长度,不要一下子塞几千个值,建议分批,比如每批 500 个,否则 SQL 报文太大容易触发数据库的 max_allowed_packet 限制。

4. 性能到底强在哪:索引匹配与实测对比

4.1 复合索引的匹配逻辑

在聊性能之前,先理解复合索引的本质。复合索引(a, b)在底层 B+ 树里,键值是一对(a, b)元组,节点之间的顺序就是按字典序排的:先按 a 排,a 相同再按 b 排。

所以当查询条件写成(a, b) > (x, y)时,数据库可以直接在索引里定位到元组(x, y)的位置,然后从这个位置往后顺序扫描,直到取够需要的行数。这是索引最擅长的事情:范围扫描。

而等你把条件手写成 OR 展开式,比如a > x OR (a = x AND b > y),优化器面对的是一个包含 OR 的过滤条件。在部分数据库版本里,优化器可能会认为 OR 会让索引失效,转而走全表扫描;即便优化器聪明一点,能识别这个 OR 等价于一个范围条件,也未必每个版本都能稳定做到。MySQL 8.0 对这类写法的优化已经不错了,但 5.7 和更早版本的表现差异很大,这也是为什么很多人说“行值比较在 MySQL 8.0 才真正香”。

PostgreSQL 对行值比较的支持更彻底,官方文档直接就说了这种写法可以匹配复合索引。老实讲,我第一次在 PostgreSQL 里用行值比较做游标分页的时候,看到执行计划里干干净净的Index Range Scan,心里是真的服气。

4.2 EXPLAIN 里看到的差异

拿 MySQL 8.0 举例。假设表上有复合索引(create_date, order_no),我用 EXPLAIN 观察两种写法。

OFFSET 分页到很深的页码时,执行计划通常是这样的:

type: ALL 或 index key: 可能用到索引也可能没用上 rows: 30 万+ Extra: Using filesort

因为 OFFSET 需要扫描并丢弃前面 30 万行,而且排序如果没完全走索引,还会有 filesort。而游标分页用行值比较的写法:

type: range key: idx_create_date_order_no rows: 20 Extra: Using index condition

type 从 ALL 变成 range,rows 从 30 万变成 20,这个差距在数据量大的时候就是几十毫秒和几百毫秒、甚至秒级别的差距。当然不同数据库版本、不同统计信息下执行计划会有差异,但大方向是稳的:行值比较能让优化器明确走索引范围扫描。

我之前在某个模拟项目里测过一组数据:表里 500 万行记录,按(create_date, order_no)排序做分页。OFFSET 翻到第 3 万行时,单次查询耗时约 380ms,用的还是 SQL 里直接 LIMIT OFFSET;同样环境下,游标写法稳定在 3-5ms,扫描行数从数十万降到了 20 行左右。这个数据不一定在你的机器上复现,但趋势是明确的。

4.3 手写 OR 条件何时可以替代

这里要说明一个容易误读的点:我并不是说行值比较在所有数据库里都一定更快。在优化器足够聪明的数据库里,手写 OR 展开式可能也会被优化成同样的执行计划。比如 MySQL 8.0 里,a > x OR (a = x AND b > y)有时候会被优化器识别并转换成和行值比较差不多的效果。

那为什么还推荐行值写法?两个原因。

第一,你无法保证目标数据库、目标版本、以及线上数据和统计信息都让优化器选择同样的最优计划。与其赌优化器的识别能力,不如用更明确的语法表达业务意图,让优化器少绕弯路。

第二,可维护性。三列、四列的 OR 展开式写出来又长又容易错,行值写法一眼能看懂。代码是写给人看的,能少一行是一行。

5. 踩坑记录:写行值比较最容易翻车的几个点

5.1 方向搞反,查出来的数据错位

这是我在实际开发里踩过最痛的一个坑。有一次做消息列表分页,排序是msg_time DESC, id DESC,我写游标条件的时候想当然写成了(msg_time, id) > (last_msg_time, last_id),结果查出来的数据不断重复,翻页混乱。

原因很简单:两列都是降序,排在前面的反而是“大”的行。所以游标往后翻,应该找“更小”的行,条件方向应该是<,而不是>。我后来总结了一个自查方法:拿到当前页最后一条记录,手写它的展开式条件,看下一页应该满足什么条件,再反推行值表达式的符号。每写一处游标分页,都做一遍这个推演,上线前再拿线上数据抽查一页,基本不会再犯方向错误。

5.2 NULL 值导致的神秘丢数据

行值比较遇到 NULL,结果是 UNKNOWN,这条记录会被过滤掉。这意味着如果第一列或第二列有 NULL,某些行可能永远查不出来,表现为“总数对不上”“分页少数据”。

比如一个表里score列允许 NULL,某个用户分数是 NULL,(score, win_rate) < (1000, 0.85)这个条件在他身上永远不成立,他就不会出现在结果里。

解决方法是设计上尽量避免可空列作为排序键。如果实在避免不了,查询时要么加WHERE score IS NOT NULL,要么在游标字段上做 COALESCE 处理,比如排序时统一用COALESCE(score, 0)。但要注意,如果用了 COALESCE,索引通常就用不上了,这是一个需要权衡的地方。我个人建议:作为分页或排序的字段,设计表时直接设 NOT NULL 加默认值,比写一堆防御代码省心得多。

5.3 数据库兼容性并不统一

行值比较虽然语法简单,但不同数据库的支持程度差异很大。我做了一个简表,方便你判断自己手上的库用不用得上:

数据库行值比较支持复合索引匹配备注
PostgreSQL支持优秀官方文档明确支持,推荐放心用
MySQL支持8.0 较好,5.7 一般8.0 下用起来体验很好
SQLite支持视版本而定3.15 版本后支持行值语法
Oracle支持支持语法兼容,具体看执行计划
SQL Server不支持不支持需手写 OR 展开,或用其他方式

如果你的项目跑在 SQL Server 上,那这篇里的很多写法无法直接用,需要把行值展开成 OR 条件。展开之后逻辑依然成立,只是代码会长一点,索引利用要靠优化器自己识别。

5.4 列类型不一致导致的隐式转换

行值比较对列的类型敏感。如果(a, b) > (x, y)里 a 列是 varchar,x 是数字,数据库可能会做隐式类型转换。一旦发生隐式转换,索引基本就废了,而且比较结果可能和你想的不一样。

这个坑在游标分页里尤其常见:上一页传回来的游标值,在应用层被 JSON 序列化后又反序列化,类型可能从字符串变成了数字,或者反过来。我处理过的一个情况是id字段在接口里被前端传成字符串,数据库隐式转成数字后虽然结果碰巧对,但执行计划已经从 range 变成了全表扫,好几天没人发现。所以游标值从接口传参到 SQL 执行,全程要保持字段类型一致,这个细节一定要盯住。

另一个相关细节:不同字符集和排序规则也会影响比较结果。比如大小写敏感和大小写不敏感的排序规则下,同一个字符串的比较结果可能不同。做分页排序的时候,最好在测试环境确认一次排序规则和业务预期一致,否则排序键相同但实际顺序不一样,游标定位就会漂。

一些实操后的真心话

我自己最开始看到(a, b) > (x, y)这种写法时,也觉得它像是某种“语法糖炫技”,实际用不上。但真的在游标分页和复合排序需求里用过几次之后,才意识到它不是炫技,而是特别贴合数据库索引组织方式的一种表达——复合索引本质上就是按字典序排列的元组序列,行值比较恰好就是索引最擅长处理的区间定位逻辑。

这些年我踩过最多坑的还是排序方向写反。哪怕我在这篇文章里反复强调,实际操作中依然会时不时怀疑自己。我的习惯是写完游标条件后,拿当前页最后一条数据手工推演一遍,确认下一页的条件符合排序逻辑,再打包上线。这个习惯帮我挡住了很多次线上事故。

最后再分享一个小技巧:游标分页初始游标值可以借用业务语义来简化条件。比如日期加订单号的组合,第一页游标可以传('2024-01-01', ''),空串作为第二列的初始下界,一行条件就把“该日期及之后全部数据”表达干净了。当然,前提是确认业务上不存在比空串更小的业务值。如果你也经常被复杂分页和排序折磨,建议找一天不忙的时候,拿自己的核心表把行值比较的几个典型场景各写一遍,跑一下 EXPLAIN,看到扫描行数从几十万掉到几十行的那一刻,你会回来感谢这个写法的。

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

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

立即咨询