ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SQL行值比较:从复合索引到深分页优化的利器

2026/9/27 0:08:57 拓冰建站 浏览量
SQL行值比较:从复合索引到深分页优化的利器 写 SQL 写了五年从子查询到窗口函数从递归 CTE 到各种奇葩优化我自认为已经玩得挺熟了。但第一次看到(a, b) (x, y)这种写法时我盯着屏幕愣了好一会儿——等等SQL 里还能这么比较这不是 Python 元组比较的玩法吗怀着半信半疑的心态跑了一下结果完全正确那一刻真的有“相见恨晚”的感觉。这套语法叫行值比较Row Value ComparisonSQL 标准里很早就有了但绝大多数教材、面试题和业务代码里都没人提。它看着像一个语法糖实际却是深度绑定复合索引、解决深分页慢查询的一把利器。这篇文章会把这套语法彻底拆开从原理讲到实战从坑点讲到优化效果。不管你是刚开始学 SQL 想查漏补缺还是写了好几年 SQL、被 OFFSET 深分页和复杂多条件比较折磨过的老手看完这篇都能有收获。1. 先用大白话拆解(a, b) (x, y) 到底是什么1.1 语法本质行值构造器在 SQL 标准里(a, b)并不只是两个列名被括号包住它叫“行值构造器”row value constructor。它的意思是把 a 和 b 当作一个整体来看待相当于在一个二维坐标里定位一个点。你比较的不再是“某个数和某个数”而是“整个点和另一个点”的关系。SELECT * FROM users WHERE (age, score) (30, 1000);这条语句的真实语义和查字典一模一样先比较第一位的 ageage 大就大如果 age 相等再比较第二位的 score。也就是说上面这句等价于下面这串 OR 写法WHERE age 30 OR (age 30 AND score 1000)用生活化的例子来解释字典里的单词排序就是典型的字典序。apple排在apply前面因为前三个字母 a、p、p 都一样第四个字符 l 小于 y。SQL 行值比较的规则完全相同逐位比较一旦分出大小就停止如果所有位都相等那就等于。这种“按位比较”的思路和ORDER BY age, score是同一个方向的逻辑。所以你可以把它理解成“排序键的化身”排序怎么写行值比较就怎么用。1.2 和 AND 条件不是一回事这是新手最容易翻车的地方。很多人看到(age, score) (30, 1000)脑子里自动翻译成“年龄大于 30 且分数大于 1000”实际上完全不是。我列个对比你看完就彻底明白了查询写法实际语义选人逻辑WHERE age 30 AND score 1000两个条件同时满足只有 31 岁且 1001 分以上的人WHERE (age, score) (30, 1000)字典序逐位比较31 岁以上任何人或者 30 岁但分数大于 1000 的人WHERE age 30 OR (age 30 AND score 1000)和第 2 条完全等价同上如果只套用 AND 的思维你会以为行值比较“要求更严格”但恰恰相反它把条件放宽了只要第一列已经大于第二列根本不用管。这种语义在多列排序、分页、排名中非常自然因为它描述的是“位置先后”而不是“同时满足”。画个重点如果你要表达“两个条件同时满足”必须用AND别用行值比较。行值比较永远是“先看第一列再看第二列”的字典序。这是两个完全不同维度的问题。1.3 为什么教材里很少有人讲接触这个语法之后我专门问过身边几个写了七八年 SQL 的朋友一半人说不知道另一半人说“好像在文档里见过但没实际用过”。原因有三点一是不少团队用的是 SQL Server而 T-SQL 恰恰不支持这个语法所以很多人没见过也不奇怪二是大部分业务查询只要单列条件就能完成多列比较写得少自然遇不到这个需求三是教材和面试题喜欢考子查询、连接和窗口函数行值比较这种“冷门标准语法”基本不进大纲。但这恰恰是面试和实际优化里拉开差距的地方。遇到深分页慢查询时知道行值比较的人能给出一个优雅、高效、让面试官眼前一亮的方案不知道的人只能继续用 OFFSET 硬扛。1.4 数据库支持情况与 SQL Server 的替代方案这里要先泼一盆冷水SQL ServerT-SQL不支持(a, b) (x, y)这种写法。不管你是 SQL Server 2019 还是 2022直接写都会报语法错误。SQL Server 里的“行值”只体现在表值构造函数VALUES上主要用于 INSERT不能参与 WHERE 里的关系比较。所以如果你在 SQL Server 下干活请老老实实把行值比较展开成 OR 写法或者用接下来讲到的 keyset 分页思路改造 SQL效果一样只是代码没那么优雅。完整支持行值比较的常见数据库如下数据库支持情况补充说明MySQL5.7 已支持8.0 更完整优化器对行值范围扫描支持得好PostgreSQL完整支持分页、LATERAL 里经常配合使用SQLite3.15 支持轻量级数据库里也能用Oracle支持语法规格和标准一致SQL Server不支持需要展开 OR或用其他方案替代这也是网上关于行值比较的内容相对少的原因如果你的主力数据库是 SQL Server很容易觉得这只是 MySQL 或某家数据库的私有语法。实际上它是标准 SQL 的一部分换数据库时几乎不用改逻辑。2. 四个高频场景行值比较到底能解决什么问题2.1 keyset 分页是慢 SQL 优化的第一板斧行值比较最实用、最經典的场景就是 keyset 分页也叫游标分页或基于键的 seek 分页。传统分页长这样SELECT * FROM orders ORDER BY created_at, id LIMIT 20 OFFSET 100000;这条 SQL 的执行逻辑是先扫出一共 100020 条记录再丢掉前面十万条最后返回后面的 20 条。这就像翻一本 1000 页的书每看一页都要从第一页重新翻起越往后越慢。订单量上了百万之后翻到第 5000 页数据库可能需要扫过上百万行才能给你返回 20 条慢 SQL 就是这么来的。keyset 分页则完全不同。它把“跳过多少条”改成“从上次最后一条的位置继续取”“位置”正好可以用行值比较表达-- 第一页 SELECT * FROM orders WHERE (created_at, id) (2025-06-01 00:00:00, 0) ORDER BY created_at, id LIMIT 20; -- 第二页以上一页最后一条的 created_at、id 作为游标 SELECT * FROM orders WHERE (created_at, id) (2025-06-01 00:30:00, 123456) ORDER BY created_at, id LIMIT 20;如果订单表上有(created_at, id)复合索引数据库定位第一条符合条件的记录后直接顺序往后扫 20 条一条多余的数据都不碰。这就是为什么 keyset 分页和 OFFSET 在数据量大的情况下性能能差出几个数量级。我在一张 500 万行的订单表上做过对比OFTSET 翻到第 5000 页查询耗时 30 秒以上keyset 分页无论翻到多深基本稳定在 50 毫秒以内。这个优化不需要改表结构只需要换一个 WHERE 条件加一个复合索引属于性价比极高的慢 SQL 优化手段。这里有个关键前提排序键必须唯一。如果只按created_at排序很多数据的创建时间可能相同翻页时会出现漏数据或重复数据。所以最稳妥的 keyset 写法一定是ORDER BY created_at, id后面垫一个主键或唯一键确保位置唯一。这也是为什么复合索引里最后一定要带主键不是随便加的。2.2 榜单排名先比主指标再比小分行值比较的字典序逻辑天生适合“总分相同再拼小分”的排名场景。比如一个积分榜规则是先比总分总分相同就比累计时长想找出“排在某个选手之后的下一位”用行值比较非常顺滑SELECT * FROM rank_list WHERE (total_score, cumulative_time) (980, 3600) ORDER BY total_score DESC, cumulative_time DESC LIMIT 20;注意这里榜单是降序排列的所以“下一批更靠后的记录”要比上一个位置“小”要把换成-- 找上一页取上一批的最小组合 SELECT * FROM rank_list WHERE (total_score, cumulative_time) (980, 3600) ORDER BY total_score DESC, cumulative_time DESC LIMIT 20;只要榜单排序是(total_score DESC, cumulative_time DESC)行值比较就能沿着同一个复合索引去定位边界之后的记录。相比窗口函数先算全量排名再翻页这种方式的扫描范围小得多内存压力也低。排行榜这类实时性要求很高、数据量又不小的场景keyset 分页配合行值比较往往比ROW_NUMBER()更扛压。2.3 乐观锁与复合唯一匹配行值比较不只是、也是。比如订单状态机里的并发更新最常见的写法是UPDATE orders SET status PAID WHERE order_id 123 AND status PENDING AND version 3;这个写法是对的但条件写起来有点啰嗦。用行值比较可以打包UPDATE orders SET status PAID, version version 1 WHERE (status, version) (PENDING, 3);这条语句的含义是把“状态为 PENDING 且版本号为 3”的那一行更新掉。乐观锁的核心理念是更新前先检查当前版本号两个并发请求同时进来时只有先执行成功的那个能匹配(status, version)后到的执行 0 行自然就放弃更新了。相比单独写两个条件行值写法更紧凑也能减少“落了括号或漏了条件”这类手误。2.4 找相邻记录和边界区间在历史记录、审计日志、状态机流转表里经常要查“某条记录的下一条”或者“某条记录的上一条”。如果每条记录有业务序号和更新时间直接用行值比较就能定位-- 选中某个工单状态机里位于 (seq, updated_at) 之后的下一条 SELECT * FROM workflow_log WHERE (seq, updated_at) (12, 2025-06-01 09:00:00) AND order_id 1001 ORDER BY seq, updated_at LIMIT 1;反过来找上一条把换成排序方向换一下即可。配合(order_id, seq, updated_at)这种复合索引数据库能直接跳到目标附近性能很好。同样地当你需要表达“从某个复合键开始的一段区间”时也可以把行值比较当作区间的左右边界SELECT * FROM events WHERE (event_time, event_id) (2025-05-01 00:00:00, 0) AND (event_time, event_id) (2025-06-01 00:00:00, 0) ORDER BY event_time, event_id;这个写法在按时间ID 做复合区间查询时非常自然读起来也像“从五月开始的第一个事件到六月前的最后一个事件”语义一目了然。3. 避坑指南NULL、索引方向与数据库差异3.1 NULL 会让结果当场“叛变”行值比较最大的坑就是 NULL。只要参与比较的列里有任意一个是 NULL整个比较结果很可能变成 NULL而 WHERE 里的 NULL 会被当成“条件不成立”这一行直接过滤掉。举例下面这条查询-- age 为 NULLscore 为 2000 的人会被过滤掉 SELECT * FROM users WHERE (age, score) (30, 1000);直觉上你会觉得score 已经 2000 了年龄 NULL 也不影响吧。但结果恰恰相反这一行会被丢弃。原因很简单NULL 30的结果是未知行值比较的结果也变成未知WHERE 不会保留未知的行。这不是数据库的 BUG而是 SQL 三值逻辑的必然结果。对策有两种如果业务上不允许 NULL那么查询结果本身就是正确过滤如果业务允许 NULL你必须先显式排除或者用 COALESCE 给默认值SELECT * FROM users WHERE age IS NOT NULL AND score IS NOT NULL AND (age, score) (30, 1000);SELECT * FROM users WHERE (COALESCE(age, 0), COALESCE(score, 0)) (30, 1000);第二种方式有个隐患对列做了函数包裹后很多数据库的索引会失效。除非你建了函数索引否则尽量用第一种“排除 NULL”的方案。3.2 复合索引顺序是生死线行值比较不是语法糖神器它只有在有序结构上才能发挥性能。要让(a, b) (x, y)高效执行必须满足两个条件WHERE 里的行值列和 ORDER BY 里的排序键保持一致表上有相同顺序的复合索引。看一个高效示例-- 索引 (status, created_at, id) SELECT * FROM orders WHERE status PAID AND (created_at, id) (2025-06-01, 0) ORDER BY created_at, id LIMIT 10;这里status是等值前缀先通过等值定位到一堆 PAID 订单再通过(created_at, id)范围定位第一条位置然后顺序往下扫。数据库不需要额外排序也不需要回看已翻过的记录。反过来如果索引顺序不对比如索引是(id, created_at)查询却是WHERE (id, created_at) (0, 2025-06-01) ORDER BY created_at, id索引先比较 id排序却是先按 created_at二者方向完全拧了数据库大概率先把全量数据拉出来再排序复杂度瞬间爆炸。这种问题在慢 SQL 优化里最隐蔽因为它语法没错错在执行计划。3.3 排序方向混搭与降序索引另一个容易忽略的细节是行值比较里每一列的“自然排序方向”和 ORDER BY 的方向必须匹配。如果你要按score DESC, id ASC排序那行值没法直接写成(score, id) (98, 123)因为第一列是降序、第二列是升序字典序默认按列的自然升序逐位比较不能在某一位上单独指定方向。常见解法有两个用降序索引。MySQL 8.0 支持降序索引直接建(score DESC, id ASC)复合索引行值比较就能正常走索引。没有降序索引时把升序的那一列取负号WHERE (score, -id) (98, -123) ORDER BY score DESC, -id ASC负号技巧我在生产环境用过简单直接。缺点是多了一个表达式计算优化器是否能完全命中索引还得实测。有降序索引的版本优先考虑降序索引代码更干净也更符合直觉。3.4 多列类型一致性行值比较要求参与比较的列和右侧常量类型匹配。常见翻车现场表里字段是 VARCHAR右侧写了个数字数据库做了隐式转换后排序和匹配结果和预期完全不同。比如(order_no, user_id)比较时order_no是字符串右侧如果写成整数常量某些数据库会把字符串转成数字再比较一旦出现非纯数字字符串结果直接乱掉。如果你在调试时发现“数据明明存在却查不到”“跑出来的顺序很怪”先把左右两侧的类型用 CAST 对齐再打开执行计划看一眼。这个排查步骤能省下不少半夜抓头发的时间。4. 进阶玩法配合索引、窗口函数做慢 SQL 优化4.1 深分页改造实录与对比数据深分页在慢 SQL 优化里属于“高频出现但不容易根治”的问题。传统 OFFSET 分页的接口写起来确实方便但数据一多每翻一页就要让数据库从头扫一次磁盘 IO 和 CPU 全浪费在已经看过的数据上。我在一个排行榜项目里做过一次完整改造。改造前SELECT user_id, total_score FROM rank_table ORDER BY total_score DESC, user_id ASC LIMIT 50 OFFSET 5000;这条 SQL 走了 filesort每次翻页都要对几万行数据重新排序高峰期 CPU 被打得很满。改造后SELECT user_id, total_score FROM rank_table WHERE (total_score, user_id) (980, 31415) ORDER BY total_score DESC, user_id ASC LIMIT 50;这里的游标 (980, 31415)来自上一页的最后一条记录。因为排序是total_score DESC, user_id ASC所以“更靠后”的位置就是比这个组合“更小”的位置用表达。配合(total_score DESC, user_id ASC)复合索引数据库直接从目标位置开始取不再需要全量排序。这个查询耗时从 800ms 掉到 20ms 左右而且翻得越深差距越大。核心原因很简单OFFSET 分页的扫描量和“翻页深度”成正比keyset 分页的扫描量大约只和“每页大小”成正比总偏移量根本不参与计算。4.2 窗口函数和行值比较怎么配合窗口函数确实能优雅地实现“分组取 Top N”但它的问题是要把整个分区的数据算一遍临时表压力和内存占用都不低。对于一次性报表这个代价可以接受对于持续翻页、边翻边加载的场景行值比较更轻盈。一个典型的配合思路是第一次查询用窗口函数算出每个分组的游标之后每一页都用行值比较继续拉取。例如打卡记录表每个员工有多条记录界面要“按员工分组每次加载一页”-- 第一次用窗口函数给每个员工排序拿到游标 SELECT *, ROW_NUMBER() OVER (PARTITION BY emp_id ORDER BY punch_time DESC) rn FROM attendance; -- 后续翻页按某个员工的游标继续取 SELECT * FROM attendance WHERE emp_id E1024 AND (emp_id, punch_time) (E1024, 2025-06-01 09:30:00) ORDER BY emp_id, punch_time DESC LIMIT 20;严格说这不是窗口函数和行值比较的强耦合但组合起来确实能让复杂报表的分页加载变轻。我的建议是一次性全局 Top N直接用窗口函数持续分页加载用行值比较。两者不是对立关系而是各管一摊。4.3 区间定位、去重与合并行值比较还有一些零散的玩法价值同样不小。比如同时定位多条边界找出“总成绩高于某个学生组合”的一批人用行值比较可以省掉一长串 OR 拼接。又比如配合 DISTINCT按复合键找“第一个索引位置”思路和查找后继一致。再比如两张大表做范围 join如果 join 条件是“时间在双方各自的时间窗内有交集”行值比较写区间边界会比逐列拼接更清晰。举一个实际点的例子。假设你要把一批用户的连续登录记录按“日期登录次数”的复合键去重只保留“比某条记录更早”的记录SELECT DISTINCT (login_date, login_seq) FROM login_log WHERE (login_date, login_seq) (2025-06-15, 100) ORDER BY login_date, login_seq;严格来说DISTINCT 后面跟行值构造器可能在不同数据库有细节差异但思路是对的当你把多列当成一个整体去“定位”和“切割”时代码会变得简洁很多。4.4 什么情况下别用行值比较我在代码评审时总结过三条线建议你也这么判断如果条件里只有单列范围完全不需要行值比较普通或就够如果条件是多列“并且”关系也就是全部要同时满足不要用行值比较用AND才是正确语义如果条件是多列“字典序先后”的关系或者在基于排序键做翻页才考虑行值比较。这三条可以避免把“看起来像元组的小把戏”到处乱用。行值比较不是万能语法它是一个精确表达“复合排序位置”的工具。用对了它是神器用错了就是难排查的语义 BUG。5. 五年 SQL 经验浓缩成这几点5.1 先看执行计划再谈优化任何涉及行值比较的优化我都不建议“感觉快了”。打开 EXPLAIN 或实际执行计划重点看三件事是不是走了预期复合索引Extra 里还有没有 Using filesort预估扫描行数是不是明显小于 OFFSET 方案。以 MySQL 为例EXPLAIN SELECT * FROM orders WHERE (created_at, id) (2025-06-01, 0) ORDER BY created_at, id LIMIT 20;如果 type 显示range或refExtra 里没有 filesort基本可以确认优化到位。如果看到ALL或者Using filesort说明索引顺序不对赶紧回去调整复合索引。SQL Server 用户虽然没有行值比较语法但同一套 keyset 思路一样成立把(a, b) (x, y)换成a x OR (a x AND b y)即可。一法通用殊途同归。5.2 游标分页的稳定性与安全原则keyset 分页的游标通常会作为参数出现在 API 请求里。这里有两个原则不能破游标属于“上一页最后一条”的位置信息不要拿它当业务主键去公开尽量单独封装并做权限校验任何有用户输入的 SQL 一律参数化。行值比较再精巧也不能和“拼接字符串”共存。SQL 注入的第一道防线永远是参数绑定而不是任何语法技巧。另外keyset 分页对数据的增删很友好删除已经翻过的数据不影响下一页新增在游标之后的数据会自动出现在后续结果里不会像 OFFSET 那样出现“页码错乱”。这也是生产环境选择 keyset 的隐形优势。5.3 团队规范比个人炫技重要想把行值比较引入团队我的建议是只允许在分页和复合排序场景中使用游标列必须包含唯一键每个使用行值比较的查询注释里写清楚它等价于哪段 OR 写法方便团队里不熟的人理解统一用(create_time, id)这类稳定字段避免使用容易变化的业务列。我见过太多“为了把语法用出来而用”的写法。明明应该用 AND 的查询硬套行值比较结果语义完全变了线上出 BUG 还特别难查。这种问题一旦在团队蔓延比不用这个特性更危险。所以团队规范和个人炫技之间后者永远要让位。5.4 写到最后说一点个人体会写 SQL 五年我越来越觉得SQL 的深度不在于你多背了几个函数而在于能不能把排序、索引和比较逻辑串成一条线。行值比较在我眼里就是“排序键的代言人”。你什么时候能写明白ORDER BY a, b什么时候就会需要(a, b) (x, y)。弄懂它之后很多深分页、慢 SQL、多条件判断的问题都能一眼看穿本质。以前我优化慢查询第一反应是加索引、改 join、拆子查询现在遇到深分页我会先想到行值比较。这个转变就是这篇内容想带给你的价值。如果你手头正有一个被 OFFSET 拖慢的分页接口别急着改架构先建好复合索引再把行值比较放进去打开执行计划对比一下那两条 SQL。你会回来感谢这个语法的。