0x00 前言
时隔近两年,回头看了一下之前写的《MySQL Order By 注入的小技巧》,结尾留了一句:
至于 MySQL 的行为为何如此奇怪,本人并没有了解清楚,有待研究。
最近在 Claude Code 的帮助下终于把这个坑填上了。本文基于 MySQL 8.4 源码(tag mysql-8.4.0)、官方 Bug Tracker,以及 Docker 里跑 mysql:8.4 抓到的 optimizer_trace,把这套行为彻底捋清楚。
结论先抛在前:当年那个「直接写 sleep / GTID_SUBSET 没效果、包成子查询就能跑」并不是优化器的三条独立优化路径叠加的结果:
JOIN::remove_const在plan_is_const()为真时,把整段ORDER BY直接砍掉。- 同一函数在逐项扫描时,会把
used_tables() == 0的常量项静默丢弃,完全不调用val_int。 - 子查询里的 derived table(派生表)会在优化阶段被独立物化,与外层
ORDER BY的命运无关 —— 这才是 POC 写法稳定可用的根本原因。
Oracle 工程师 Roy Lyseng 在 Bug #67632 已经明确表态,这种「能算就提前算」的激进求值是** by design**。
0x01 重新审视现象
用 10 行整数表 rows10 和 MySQL 内置的 dual(行为上等价于一行的常量表)作为两种外层 FROM,跑下面 5 类 ORDER BY 表达式:
-- A. 直接函数调用
ORDER BY SLEEP(N)
ORDER BY GTID_SUBSET(2, 2)
-- B. 裸标量子查询包装
ORDER BY '', (SELECT SLEEP(N))
-- C. 派生表包装(即当年 POC 写法)
ORDER BY '', (SELECT(1) FROM (SELECT(SLEEP(N)) WHERE(1=1)) test)
ORDER BY '', (SELECT(1) FROM (SELECT(GTID_SUBSET(2,2)) WHERE(1=1)) test)
完整的可执行 SQL(包含建表、optimizer_trace 设置和全部 9 个测试查询)见 test.sql。下表测试时 A 类统一用 SLEEP(2);B、C 类的 N 在 dual 列取 5、rows10 列取 2,让两侧耗时差异都能明显观察。
图例:以「
ORDER BY <expr>会对结果集中每一行求值一次」作为朴素 SQL 预期。 ✅ 表示与预期相符;❌ 表示不相符,是本文要解释的现象。
| ORDER BY 表达式 | FROM dual(1 行 const plan) |
FROM rows10(10 行真表) |
|---|---|---|
A. 直接 SLEEP(2) |
❌ 0.075 s(无 sleep;预期 ≈ 2 s) | ✅ 20.13 s ≈ 10×2 s |
A. 直接 GTID_SUBSET(2,2) |
❌ 无错误(预期:ERROR 1772) | ❌ 无错误(预期:ERROR 1772) |
B. 裸子查询 (SELECT SLEEP(N)) |
❌ 0.087 s(无 sleep;预期 ≈ 5 s) | ✅ 20.10 s ≈ 10×2 s |
| C. 派生包装 SLEEP | ✅ 5.10 s ≈ 1×5 s | ❌ 2.10 s(仅 sleep 一次;预期 ≈ 10×2 s) |
| C. 派生包装 GTID_SUBSET | ✅ ERROR 1772 | ✅ ERROR 1772 |
把两列对照着看,能直接读出三条事实:
- A、B 类:
dual时副作用根本没触发,真表时每行触发一次。当年 POC 在from dual上看不到 SLEEP 效果,并不代表 SLEEP 永远被砍 —— 是from dual这一侧特殊。 - A 类的
GTID_SUBSET是例外:两种 FROM 下都不报错,函数从未被求值。 - C 类(派生表包装):两种 FROM 下副作用都只触发一次。这才是 POC 稳定生效的关键。
也就是说,「子查询包装」并不是任意写法都行,关键在那一层 derived table;而且它把副作用稳定收敛到「优化阶段执行一次」上。对延时注入而言这是个二值预言(slept / didn’t sleep),拿不到「时间随行数线性增长」的信号;报错注入则不受影响,触发一次就够。
0x02 第一条路径:plan_is_const() 整段消除
所有 ORDER BY 消除都集中在 JOIN::remove_const 里。第一条最简单粗暴:
// sql/sql_optimizer.cc:10193-10195
if (plan_is_const())
return change ? nullptr : first_order; // No need to sort
当 FROM 子句里所有表都是 const table(空表、单行表、dual,或被索引/WHERE 证明只有 0~1 行)时,整段 ORDER BY 直接被丢弃,里面写了什么、是不是常量、有没有副作用,全都不看。
这就是 A、B 类在 dual 上一律不触发副作用的根本原因 —— 根本没进入 sort 阶段,也没进入下面要讲的逐项扫描循环。
0x03 第二条路径:常量项被静默丢弃(兼论 SLEEP 为何幸存)
plan_is_const() 不成立时(FROM 里有真实多行表),remove_const 进入逐项扫描循环:
// sql/sql_optimizer.cc:10226-10256(节选)
const table_map not_const_tables = ~const_table_map;
for (ORDER *order = first_order; order; order = order->next) {
const table_map order_tables = order->item[0]->used_tables();
if (... aggregate / window function ...) {
*simple_order = false;
} else if ((order_tables & not_const_tables) == 0 &&
evaluate_during_optimization(order->item[0], query_block)) {
if (order->item[0]->has_subquery()) {
if (!thd->lex->is_explain()) {
const Opt_trace_array trace_subselect(trace, "subselect_evaluation");
String str;
order->item[0]->val_str(&str); // ← 这里会真的执行子查询
}
order->item[0]->mark_subqueries_optimized_away();
}
trace_one_item.add("uses_only_constant_tables", true);
continue; // ← 把这一项扔掉
}
// ...
}
判断只看两点:
- 表达式的
used_tables()是不是「全是 const table」?是 → 进入消除分支。 - 进入消除分支后,如果还含子查询(
has_subquery()),先把子查询执行一遍再扔。
const_table_map 初值是 INNER_TABLE_BIT(sql/sql_optimizer.cc:174),后续靠 mark_const_table 累加,不包含 RAND_TABLE_BIT。
GTID_SUBSET(2,2) 为何被静默丢弃
Item_func_gtid_subset 没重写任何「常量性」相关方法,基类 Item_func::get_initial_pseudo_tables() 返回 0。两个字面量参数下:
used_tables() == 0→const_item() == true(0 & not_const_tables) == 0恒为真- 没有
has_subquery(),直接continue; val_int()根本没被调用
而错误判断在 Item_func_gtid_subset::val_int 里 —— 字面量 2 解析为 GTID set 必然失败,但既然函数从未被调用,错误也就不会触发。这解释了 A 类 GTID_SUBSET 即使在 rows10 上也不报错。
SLEEP(2) 为何反而被保留
Item_func_sleep 主动声明自己「非确定」:
class Item_func_sleep final : public Item_int_func {
public:
table_map get_initial_pseudo_tables() const override {
return RAND_TABLE_BIT;
}
// ...
};
Item_func::update_used_tables 会把这个 bit OR 进 used_tables_cache,所以 SLEEP(2) 的 used_tables() == RAND_TABLE_BIT。RAND_TABLE_BIT 不在 const_table_map 里,(RAND_TABLE_BIT & not_const_tables) != 0,根本不进消除分支。这一项被原封不动保留,filesort 阶段每行调一次 val_int,于是 A 类在 rows10 上跑出 20.13 秒(10 × 2s)。
GTID_SUBSET 没覆盖 get_initial_pseudo_tables、SLEEP 有,一个被消除一个被保留,差别就在这一处。
0x04 第三条路径:派生表物化 —— POC 的真正秘密
理解了 0x02 和 0x03 后,下面这个问题就很尴尬了:C 类派生包装 SLEEP 在 dual 上也 sleep 了一次 5 秒。但 plan_is_const() 对 dual 返回 true,按 0x02,remove_const 应该直接 return,根本没机会进入 0x03 的 val_str 分支。那 SLEEP 是哪儿来的?
把 C 类的 optimizer_trace 抓出来:里面没有 simplifying_order_by、没有 subselect_evaluation(因为 remove_const 早就 return 了),但有 creating_tmp_table 和两次 join_execution,对应 (SELECT (SLEEP(5))) test derived table 的物化过程:
$ grep -oE '"(simplifying_order_by|subselect_evaluation|creating_tmp_table|join_execution|derived)"' trace.txt | sort | uniq -c
1 "creating_tmp_table"
1 "derived"
2 "join_execution"
也就是说:哪怕外层 ORDER BY 被整段砍掉,内层 derived table 仍然会在优化阶段被独立物化,物化的过程就执行了 SLEEP。这是一条独立于 remove_const 的代码路径,属于 MySQL 「能算的常量子查询就提前算掉」的总策略(Bug #67632 里 Roy Lyseng 的明确表态):
If MySQL can evaluate an expression, also subqueries, it will do that, in order to simplify queries as much as possible and potentially reduce join order calculation as much as possible.
B 类裸子查询 vs C 类 derived 包装:差在哪里
这才是 POC 之所以非得写成 (SELECT(1)FROM(SELECT(SLEEP(...))WHERE(1=1))test) 这一团嵌套的真正原因:
- B 类裸子查询
(SELECT SLEEP(N)):没有 derived table,没有可独立物化的对象。在dual外层下随plan_is_const()一起被丢弃。在真表外层下,SLEEP把所在 query block 标记为UNCACHEABLE_SIDEEFFECT(do_itemize),让Item_subselect::update_used_tables的INNER_TABLE_BIT收敛分支走不到,scalar subquery 不被识别为常量,每行重新求值一次。 - C 类 derived 包装
(SELECT 1 FROM (SELECT SLEEP(N)) test):内层(SELECT SLEEP(N))是 derived table,独立物化一次;外层SELECT 1 FROM <derived>看不到 SLEEP 的副作用标记,照常被识别为常量子查询。结果就是两种 FROM 下都只 sleep 一次:dual外层:remove_const早早 return,derived table 物化照样发生 → 5 秒。rows10外层:进入 0x03 的逐项循环,命中val_str分支,把整个嵌套子查询执行一次 → 2 秒(不是 20 秒)。
GTID_SUBSET 的 derived 包装走同一路径:物化时调用一次 val_int,触发一次 ERROR 1772。
一个安全相关的细节:EXPLAIN 也会触发
实测 EXPLAIN SELECT 1 FROM dual ORDER BY '',(SELECT(1)FROM(SELECT(SLEEP(5))WHERE(1=1))test) 同样卡 5 秒。remove_const 里的 val_str 调用有 if (!thd->lex->is_explain()) 守卫,但 derived table 的物化路径没有这层守卫 —— EXPLAIN 本身就足够把 SLEEP / GTID_SUBSET 触发一次。这也是 Bug #67632 当年报告人最关心的安全语义问题。
0xFF 小结
- 「
ORDER BY sleep()没效果」? 仅在from dual/ 单行表场景成立,由plan_is_const()整段消除引起;换成多行真表,SLEEP 每行执行一次。 - 「
ORDER BY GTID_SUBSET(2,2)不报错」? 永远成立。Item_func_gtid_subset没声明非确定,常量参数下used_tables() == 0,被remove_const静默丢弃,val_int从未调用。 - 「子查询包装能解决」? 真,但秘诀是那一层 derived table 在优化阶段被独立物化,而不是「让函数按行执行」。它把副作用稳定收敛到「优化阶段执行一次」,对报错注入完美适用;对延时注入只能当二值预言。
- 副作用甚至在
EXPLAIN阶段就会触发。Oracle 已明确认定为 Not a Bug,未来很长一段时间内都将持续可用。
参考
- MySQL 源码 (
mysql-8.4.0):sql/sql_optimizer.cc、sql/item_func.h、sql/item_gtid_func.h、sql/item_subselect.cc - MySQL 开发者文档:
simple_remove_const/eq_ref_table - Bug Tracker:#67632 — Not a Bug、#12689 — SLEEP() 在 query cache 中的旧问题
- 上一篇:《MySQL Order By 注入的小技巧》