在SQLite的查询优化流程里,子查询展平属于逻辑优化阶段的一个关键动作。它并不是简单的语法替换,而是一套带有严格前置条件的等价变换。优化器会检查子查询的结构和位置,判断能否消除嵌套层次,把内层查询合并到外层查询的FROM子句或者WHERE条件里。这样做最大的好处是让优化器获得更完整的统计信息,有机会使用索引和调整连接顺序。本文围绕展平的触发规则、相关子查询的去相关化处理,以及展平失败后的排查方向展开。

一、子查询展平到底做对了什么
子查询展平(Subquery Flattening)的直观理解,就是把嵌套在FROM子句中的子查询,或者出现在IN、EXISTS中的子查询,转换成一个普通的连接操作。例如,FROM (SELECT ...) AS t 这种匿名派生表,优化器会尝试把内层的FROM表直接合并到外层,把内层WHERE条件上拉到外层WHERE,同时把内外层表的连接条件合并到ON子句。这种变换在没有改变语义的前提下,给了优化器更多选择。
但是展平不是无条件发生的。SQLite会拒绝展平包含聚合函数、GROUP BY、HAVING、DISTINCT、LIMIT/OFFSET、UNION/INTERSECT/EXCEPT的子查询,因为这些结构会影响行数和分组语义。如果子查询出现在SELECT列表中,并且外层查询本身包含聚合,也会因为标量上下文而无法直接展平。此外,复合SELECT语句的右侧子查询、带有ORDER BY且没有LIMIT的子查询也可能被排除。具体判断逻辑在SQLite源码的flattenSubquery函数中可以看到,核心是保证变换前后的结果集完全一致。
以一个简单的派生表查询为例:
-- 原始查询 SELECT e.name, d.dept_name FROM employees e JOIN ( SELECT id, dept_name FROM departments WHERE active = 1 ) d ON e.dept_id = d.id;
展平之后,优化器可以等价地把它看作:
SELECT e.name, d.dept_name FROM employees e JOIN departments d ON e.dept_id = d.id WHERE d.active = 1;
区别在于后者的departments表直接参与连接,如果departments的active列上有索引,SQLite可以更早过滤行。而原始写法可能会先物化子查询结果,再和外层连接,缺少全局优化空间。当然,SQLite优化器是否真的物化取决于版本和统计信息,但展平至少打开了一条更优路径。
二、相关子查询的去相关化改写
相关子查询的特点是内层查询引用了外层查询的列,比如WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)。如果没有优化,SQLite需要对users表的每一行都执行一次orders表的查询,复杂度接近两个表大小的乘积。SQLite优化器会尝试把这种存在性检查改造成半连接(semi join),从而将相关条件转成连接条件。
对于上面这个EXISTS例子,优化器可以等价地改写为对users和orders做JOIN,再对users侧去重。逻辑表达式大致是:SELECT u.name FROM users u JOIN orders o ON o.user_id = u.id WHERE o.amount > 100 GROUP BY u.id,但实际计划中SQLite更倾向于使用自动去重的半连接算法,并不一定生成GROUP BY。核心是把外层引用u.id替换成表之间的等值条件,让连接器可以使用索引。
-- 原始EXISTS写法 SELECT u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 100 ); -- SQLite内部可能将它改造成半连接的形式 SELECT u.name FROM users u JOIN orders o ON o.user_id = u.id WHERE o.amount > 100 GROUP BY u.id, u.name;
NOT EXISTS的情况则对应反半连接,通常会改写成LEFT JOIN加IS NULL判断。示例:
-- 原始NOT EXISTS写法 SELECT u.name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM banned b WHERE b.user_id = u.id ); -- 等价的反连接写法 SELECT u.name FROM users u LEFT JOIN banned b ON b.user_id = u.id WHERE b.id IS NULL;
标量子查询(在SELECT列表或者WHERE中使用返回单值的相关子查询)的改写要复杂一些。SQLite可能先尝试缓存相同外层行的子查询结果,如果外层行很多但相关列基数很小,这种缓存能减少重复计算。对于可以转换为LEFT JOIN的场景,例如SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users u,优化器未必总能自动展平,需要手动改成LEFT JOIN加GROUP BY。理解这些改写路径,能帮助我们写出更容易被优化的SQL。
三、展平失败的信号与手动优化方向
当子查询因为某些限制无法展平时,SQLite会退回到逐行执行策略。对于相关子查询,执行计划中通常会看到类似CORRELATED SCALAR SUBQUERY或者依赖外层循环的SCAN标识。此时可以用EXPLAIN QUERY PLAN命令观察扫描次数。例如:
EXPLAIN QUERY PLAN SELECT u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 100 );
如果计划中先SCAN users,然后对每一行再SEARCH orders,说明半连接优化可能没有生效,或者缺少orders表在user_id列上的索引。这种情况下,给orders.user_id和orders.amount创建复合索引可以显著降低内层查询的开销。即使优化器做了展平,索引仍然会影响连接顺序和过滤效率。
对于派生表无法展平的情况,常见原因有子查询包含LIMIT或DISTINCT。此时可以手动把逻辑改成显式JOIN,或者把子查询结果写入临时表并建立索引。SQLite 3.34以后支持物化子查询的提示,但更通用的做法是重写SQL结构。举一个需要手动改写的例子:原始查询为SELECT * FROM (SELECT user_id FROM orders ORDER BY created_at DESC LIMIT 10) t JOIN users u ON u.id = t.user_id,由于LIMIT阻止展平,可以先提取为WITH recent AS (SELECT user_id FROM orders ORDER BY created_at DESC LIMIT 10) SELECT * FROM recent JOIN users u ON u.id = recent.user_id,让优化器更清楚临时结果集的大小。
还需要注意,SQLite对IN子查询的处理有时会自动改写为EXISTS或者半连接。例如SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100) 通常会被展平为对users和orders的半连接。但如果IN子查询包含GROUP BY,展平可能失败,这时改成EXISTS加关联条件往往能恢复优化空间。总之,遇到慢查询时先看执行计划,再判断是展平失败、索引缺失还是统计信息不准,比盲目调整SQL结构更有效。