打开网易新闻 查看精彩图片

SQLite处理聚合查询时,会先造一张临时表。这张表不存原始数据,只存每个分组的中间计算结果——COUNT累加到哪了,SUM算到多少了。等所有行扫完,再一次性输出最终结果。两阶段执行,避免重复扫描,这是数据库工程师二十年前的老手艺。

但子查询是另一回事。你写了个嵌套SELECT,数据库默认会怎么做?先执行内层,结果扔进临时表,外层再接着跑。问题在于:这张临时表没索引。外层想过滤、想关联,都得全表扫。数据被读两遍,IO翻倍。

SQLite的解法叫「子查询拍扁」(Subquery Flattening)。把嵌套结构拆平,合并成单层查询,一次扫描解决战斗。

举个例子。原查询:

SELECT a FROM (SELECT x + y AS a FROM t1 WHERE z < 100) WHERE a > 5;

拍扁后变成:

SELECT x + y AS a FROM t1 WHERE z < 100 AND a > 5;

打开网易新闻 查看精彩图片

条件直接下推,索引能用上了,临时表也不用建了。性能提升多少?取决于数据量和原查询复杂度,但少一次全表扫描是确定的收益。

拍扁不是万能药:SQLite的严格门槛

拍扁不是万能药:SQLite的严格门槛

SQLite不会无脑拍扁。它列了一堆条件,满足才动手。比如:子查询不能用GROUP BY或DISTINCT,不能带LIMIT,不能是聚合查询,外层不能是LEFT JOIN的右表……规则很细,本质是在「优化收益」和「语义正确」之间走钢丝。

为什么LEFT JOIN的右表特殊?拍扁可能改变结果集的行数,LEFT JOIN对NULL的处理很敏感,乱优化会出Bug。SQLite选择保守:宁可不优化,不能算错。

视图(View)是子查询拍扁的最大受益者。你定义一个视图,每次引用它,底层都转成子查询。没有拍扁优化,视图就成了性能陷阱——多层嵌套,层层建临时表。拍扁之后,视图只是语法糖,执行计划跟手写JOIN没区别。

MIN/MAX的捷径:索引直接定位

MIN/MAX的捷径:索引直接定位

聚合查询里,MIN和MAX是异类。SQLite对它们有特殊照顾:如果字段有索引,直接跳到最后一条(或第一条)记录,返回结果。不用扫全表,不用建临时表,O(1)复杂度。

打开网易新闻 查看精彩图片

这个优化依赖索引的物理有序性。B-Tree索引天然按值排序,MIN就是最左节点,MAX就是最右。SQLite的查询优化器会检查:有没有可用索引?查询是不是纯粹的MIN/MAX,没带GROUP BY或其他聚合?条件满足,直接走捷径。

但加个GROUP BY就失效。比如SELECT department, MIN(age) FROM employees GROUP BY department,必须按部门分组再求最小值,索引帮不上忙,老老实实走两阶段聚合。

从查询到字节码:前端最后一块拼图

从查询到字节码:前端最后一块拼图

到目前为止,SQLite的前端完成了:解析SQL文本,生成语法树,分析名字和类型,选择索引,优化JOIN顺序,拍扁子查询,规划聚合执行方式。下一步是生成字节码(Bytecode),交给虚拟机执行。

字节码是SQLite的IR(中间表示,Intermediate Representation)。跟LLVM的IR类似,但更简单、更贴近数据库操作——打开表、定位索引、比较值、跳转标签。前端做的所有优化,最终都体现在字节码的指令序列里。

作者Maneshwar在系列前文已经讲过索引选择和排序优化。这篇补上了聚合和子查询,SQLite查询引擎的前端逻辑基本完整。对开发者来说,理解这些机制有个实际好处:写SQL时,你能预判哪些写法会被优化,哪些会踩坑。

比如知道拍扁规则,就不会在视图里塞LIMIT还指望性能;知道MIN/MAX走索引,就会给高频查询字段加索引而不是全表缓存。数据库是黑箱,但箱子上有些缝隙,透进光来。

Maneshwar正在做git-lrc,一个基于AI的代码审查工具,每次提交自动跑。项目开源在GitHub,免费无限制。他写这个SQLite系列,部分动机是梳理自己工具里的SQL优化逻辑——审查代码时,也能指出潜在的数据库性能问题。

如果你用过类似工具,或者在自己项目里踩过子查询的性能坑,他的实现思路值得一看:把数据库知识编码成规则,让AI当 reviewer 的副驾驶。