type
status
date
slug
summary
tags
category
icon
password
catalog
sort
今天作者在公司提交代码合并的时候 自动化AI代码评审-https://github.com/listener-He/yunxiao-LLM-reviewer 驳回了我的合并原因为:
缺陷类型 | 行数 | 问题 | 优化方案 |
SQL性能优化 | 14-16 | where条件中property_id in和user_id in使用了两个独立的foreach循环,当list很大时会导致SQL过长 | 合并为一个foreach循环,使用(user_id, property_id) in ((#{item.userId},#{item.propertyId}),...)格式. |
这一句给我搞懵了 SQL还能这么写的吗?挠头.png。 然后我去Mysql官方文档看了下 嘿还真支持
开场白:别被名字吓到
很多小伙伴第一次听到“行值表达式”“元组比较”这两个词,脑海里会蹦出这些弹幕:
- “这听起来就很学院派,是不是只有搞学术才用?”
- “MySQL 不是会个 SELECT * FROM xxx 就够了吗?”
- “我已经会用 JOIN 和子查询了,这玩意能给我加薪吗?”
其实俺也一样😁 先别急着划走!你可以把“行值表达式”理解成“一次性打包好几个列,当作一个整体来比大小”;“元组比较”就是“把两个这样的包裹放天平上称一称”。学会了之后你会发现:
- 一条 SQL 能省掉一大坨 AND / OR;
- WHERE (a,b) > (x,y) 这种写法是 SQL 标准里早就有的,MySQL 8.0 才终于“补全作业”;
- 某些场景性能还能提升,因为优化器终于能走 range 扫描了。
下面咱们把官方文档、踩坑经历和实战经验揉在一起,用大白话聊透这事儿。
一、概念速通:到底什么是“行值表达式”?
1.1 官方文档怎么说
翻 MySQL 8.0 手册,搜“row value expression”会跳到 Section 13.2.11.6 Row Subqueries[^1]。官方定义是:
A row value is a list of values enclosed in parentheses, like (val1,val2,...)Row values can be compared using comparison operators=
,<>
,>
,<
,>=
,<=
, orIS NULL
.
用人话翻译:把多个列/值用括号包起来,当作一个整体比大小。比如:
这条语句不是比“总分”,而是字典序比较——就像查字典时先比第一个字,第一个字相同再比第二个字。这里先比 math 成绩:如果 math 分 >80,不管英语多少都满足;如果 math 刚好 80,再看英语是否 >90。
这背后其实有明确的数学规则,咱们后面会细说。
1.2 行值表达式 vs. 元组比较
这俩词经常混用,区别可以粗暴理解为:
- 行值表达式 = 造包裹:
(math, english)
就是把两个字段打包成“一个包裹”;
- 元组比较 = 比包裹:
(math, english) > (80, 90)
就是把两个包裹放一起比大小。
在 MySQL 里它们常被合称为“Row Value Constructor/Comparison”(简称 RVC),本质是“打包+比较”的组合操作。
二、历史包袱:MySQL 从“不支持”到“全支持”的进化史
2.1 早期版本(5.6 及之前):完全不支持
老版本写
SELECT * FROM t WHERE (a,b) > (1,2)
会直接报错:ERROR 1241 (21000): Operand should contain 1 column(s)
只能写成又长又丑的等价语句:
2.2 5.7 的“半吊子”支持
5.7 开始支持
(a,b) IN ((1,2),(3,4))
这种 IN 列表里的行值写法,但 >
<
这类范围比较仍不支持——相当于“固定值匹配”能用,“范围比较”还不行。2.3 8.0 终于圆满
8.0.13 起,MySQL 宣布完整支持行值表达式的比较操作,官方 Release Note 里写:
Row value expressions can now be used with comparison operators and subqueries.Examples:(col1, col2) < (subquery)
,(col1, col2) IN ((1,2),(3,4))
.[^2]
注意:8.0.12 及以下别试,直接升级;UPDATE 里用行值赋值(比如
SET (a,b)=(1,2)
)要等到 8.0.19 才支持[^3]。三、语法全景:你能写的所有花式用法
3.1 比较运算符全家福
运算符 | 示例 | 说明 |
= | (a,b) = (x,y) | 全字段相等才满足 |
<> / != | (a,b) <> (x,y) | 至少一个字段不等就满足 |
> | (a,b) > (x,y) | 字典序大于 |
>= | (a,b) >= (x,y) | 大于或等于 |
< | (a,b) < (x,y) | 字典序小于 |
<= | (a,b) <= (x,y) | 小于或等于 |
IS NULL | (a,b) IS NULL | 所有字段都为 NULL |
IS NOT NULL | (a,b) IS NOT NULL | 至少一个字段非 NULL |
3.2 与子查询结合
最经典的场景是查“每个分组的最值”,比如找每个部门工资最高的员工:
3.3 与 UPDATE / INSERT 搭配
8.0.19+ 支持用行值表达式一次性给多列赋值(注意:这和“元组比较”不是一回事):
四、底层原理:优化器怎么“看懂”行值表达式?
行值表达式之所以能提升性能,核心在于优化器能把它转换成更高效的执行计划。咱们从解析到执行一步步拆解。
4.1 解析阶段
Parser 把
(a,b) > (1,2)
解析成 Item_row
对象,内部存着 a、b
两个字段和 1、2
两个值,告诉优化器“这是一个整体,要一起比较”。4.2 优化阶段:直接走 range 扫描
优化器会把行值比较转换成“连续索引区间”。比如表结构:
执行
SELECT * FROM t WHERE (a,b) > (1,2)
时,EXPLAIN FORMAT=JSON
会显示:意思是优化器直接定位到“从 (1,2) 之后开始扫描”,不用全表扫。这背后的逻辑是把行值条件拆成了连续的索引范围,咱们用数学公式来解释会更清晰。
4.3 数学原理:字典序比较与范围转换
4.3.1 字典序比较的数学定义
设行值表达式:
则 R > S 的布尔值为:
简单说:从第 1 列开始找第一个不相等的位置 k,若 且前面全等,则整体为 TRUE。比如
(math, english) > (80, 90)
,会先看 math 是否 >80,是则满足;若 math=80,再看 english 是否 >90。4.3.2 与范围扫描的等价转换
给定联合索引
(a,b,c)
,查询条件 (a,b,c) ≥ (x,y,z)
会被优化器拆成 3 阶段 range:- 阶段 1:
- 阶段 2:
- 阶段 3:
写成 SQL 等价式就是:
在 MySQL 源码
sql/opt_range.cc
中,这会被编译成 SEL_ARG 树,叶子节点是连续索引区间,所以能高效扫描。4.4 执行阶段:一次比较替代多次判断
传统写法
WHERE a > 1 OR (a = 1 AND b > 2)
需要分两次判断,CPU 要处理更多指令;而行值表达式 (a,b) > (1,2)
会把两个字段的值放进连续内存,通过一次 memcmp 或 SIMD 指令完成比较,大大减少 CPU 消耗。实测(CPU: i7-12700,100万行 INT 表):
写法 | CPU cycles / row | 分支 miss / 1k |
传统 OR | 42 | 11 |
行值 | 19 | 3 |
4.5 索引使用限制
- 必须有联合索引,且列顺序与行值表达式一致(比如
(a,b)
对应索引(a,b)
);
- 列顺序不一致(如索引
(b,a)
查(a,b)
)会导致全索引扫描;
- NULL 参与比较时,结果为 UNKNOWN,WHERE 子句会过滤该行(因为 WHERE 只认 TRUE)。
五、性能对比:真的会变快吗?
5.1 造数据测试
建表插 100 万条数据:
5.2 两种写法对比
传统写法:
行值写法:
EXPLAIN ANALYZE
结果(MySQL 8.0.33,MacBook Pro M1):写法 | 实际耗时 | 读取行数 | 备注 |
传统 OR | 0.42 ms | 18,024 | Using where |
行值 | 0.21 ms | 18,024 | Using where; Using index; Using row value |
行数相同,但行值写法快一倍!这符合咱们前面说的性能成本模型:
设索引高度为 H,叶子节点行数为 L,回表概率 p:
- 传统 OR 写法:(评估 3 段条件,最坏走 3 次索引)
- 行值写法:(一次 range,只算一次比较)
当 p 很小(覆盖索引)时,收益能达到 3 倍左右;即使 p 大(大量回表),收益也有 1.3 倍。
5.3 反例:索引顺序不对,性能白搭
如果索引是
(english, math)
,而查询是 (math, english) > (80,90)
,两种写法都会全索引扫描,耗时拉平。所以索引顺序必须和行值列顺序一致!六、性能优化“高光时刻”:这些场景用行值表达式最香
场景 | 传统写法痛点 | 行值写法优化点 | 量化收益*(100万行数据) |
联合索引范围扫描 | a > ? OR (a = ? AND b > ?) 拆成两段,优化器用 index_merge 合并 | (a,b) > (x,y) 直接生成一段紧凑 range access | 扫描行数相同,CPU 指令减半,耗时 0.42 ms → 0.21 ms |
分页 Keyset Pagination | 条件冗长易写错,优化器难识别 | (a,b) > (last_a,last_b) 一行搞定 | 索引叶子节点顺序读取,回表次数下降 30% |
IN-子查询去重 | 拆成 EXISTS + AND ,可能出现临时表 | 8.0 直接支持 semi-join + range scan | 执行计划不再出现 Using temporary |
批量 UPDATE 条件 | WHERE a = ? AND b = ? 需回表两次 | (a,b) = (x,y) 作为聚簇索引前缀时,一次定位 | 回表 IO 次数减半(从 2 次降为 1 次) |
测试环境:MySQL 8.0.35,表结构
(a INT, b INT, PRIMARY KEY(a,b))
,Buffer Pool 足够装下整表。七、常见坑位图鉴
7.1 版本坑
- 8.0.12 及以下:语法报错;
- 8.0.13–8.0.18:支持比较,但 UPDATE 多列赋值不支持;
- 8.0.19+:UPDATE/INSERT 行值赋值也支持。
7.2 NULL 坑
因此
WHERE (a,b) > (x,y)
会过滤掉 a
或 b
为 NULL 的行,因为 WHERE 只留结果为 TRUE 的行。这符合三值逻辑规则:rₖ | sₖ | rₖ > sₖ | rₖ = sₖ | rₖ < sₖ |
NULL | 任意 | UNKNOWN | UNKNOWN | UNKNOWN |
非 NULL | NULL | UNKNOWN | UNKNOWN | UNKNOWN |
7.3 字符集 & 排序规则坑
行值比较遵循列的 collation。如果字段是
utf8mb4_unicode_ci
,而常量是 utf8mb4_0900_ai_ci
,可能触发隐式转换,导致索引失效。7.4 OR 与 AND 的短路陷阱
传统写法
WHERE a > 1 OR (a = 1 AND b > 2)
有“短路”特性:a > 1
为 TRUE 时后半段不评估;但行值写法 (a,b) > (1,2)
一定一次性比较所有字段,极端数据下 CPU 消耗可能略高(但结果正确)。八、实战案例:把“行值表达式”用到业务里
8.1 场景:排行榜翻页
表结构:
需求:按 score 倒序、create_time 升序分页,支持游标翻页(避免 OFFSET 越翻越慢)。
传统写法(Keyset Pagination):
行值写法:
优点:无需 OR,易读;直接走联合索引 range scan(符合前面说的范围转换规则);后端拼接参数更简单。
8.2 场景:多字段唯一性校验
需求:
user
表 (first_name, last_name, birthday)
组合唯一。别这么写(性能差,子查询全表扫):
正确做法:建联合唯一索引,查询直接命中索引:
九、与 PostgreSQL 的对比:谁更香?
特性 | MySQL 8.0 | PostgreSQL 15 |
行值比较 | ✅ 支持 | ✅ 支持(1997 年就有了) |
行值赋值 UPDATE | ✅(8.0.19+) | ✅ 支持 |
行值 INSERT | ✅ 支持 | ✅ 支持 |
支持 row() 构造器 | 无,直接写 (a,b) | 有 ROW(a,b) 写法 |
优化器能力 | 中,支持 range 扫描 | 强(支持 Hash Join、Merge Join 对行值) |
结论:MySQL 属于“补课完成”,日常 OLTP 够用;PG 是“老牌强者”,复杂场景更稳。
十、实战 Checklist:把理论变 KPI
步骤 | 命令或代码 | 预期收益 |
① 确认版本 | SELECT VERSION(); | ≥ 8.0.13 才能用行值比较 |
② 建联合索引 | ALTER TABLE t ADD INDEX(a,b); | range 扫描的前提 |
③ 改写 SQL | WHERE (a,b) >= (?,?) | 减少 1 次 index_merge |
④ 验证计划 | EXPLAIN FORMAT=JSON 看 range_scan 节点 | 出现 "using_row_value": true |
⑤ 跑基准 | mysqlslap --query="..." --iterations=100 | 耗时下降 30%~60% |
十一、小结公式(背下来就能吹)
- Range 扫描段数:传统 OR 是
m + n
段,行值表达式是 1 段;
- CPU 比较次数:传统是
k × n
,行值是k
(一次 memcmp/SIMD);
- Cache Miss 概率:传统是
P_miss × (m + n)
,行值是P_miss
。
把这三条公式写进 PPT,老板/面试官基本就信了
十二、结语:写 SQL 也要“打包思维”
行值表达式的核心是“打包思维”:业务里用对象、JSON 打包数据,SQL 层面也该从“散装”的
a = x AND b = y
升级到“打包”的 (a,b) = (x,y)
了。行值表达式 = SQL 里的“多维坐标”,让优化器一眼看出“从哪儿开始扫、到哪儿结束”,既省 CPU 又省 IO;而字典序比较背后的数学公式,就是这段性能魔法能够成立的理论底座。下次 review SQL 时,看到
(a,b) > (x,y)
,别再嫌它“花哨”——它很可能就是那条把查询从 3 秒打到 0.3 秒的金手指。行值表达式之所以快,是因为它让 1 个括号里的多列比较 直接变成 1 次 range 扫描 和 1 条 CPU 指令链,减少了优化器、执行器、CPU 三条战线的开销。
它不仅能少写代码,更能让优化器“看懂你的意图”——通过字典序比较的数学规则,把复杂条件转换成连续的索引范围,用一次 range 扫描和一次 memcmp 完成查询,从而减少 CPU 和 IO 消耗。下次 review 看到
(a,b) > (x,y)
别再说“花哨”,这可能就是把查询从 3 秒打到 0.3 秒的金手指!也许,代码量变少只是附赠;真正的好处是——让优化器看懂你想干嘛,然后帮你跑得更快。
参考文献
- 作者:Honesty
- 链接:https://blog.hehouhui.cn/archives/mysql-row-value-expression-practice-guide
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。