土法炼钢兴趣小组的算法知识备份

【SQLite 内核】SQL 编译管线:从文本到 VDBE 程序

文章导航

分类入口
databasestorage
标签入口
#sqlite#prepare#tokenizer#parser#resolve#query-planner#explain-query-plan#schema-cookie#ngqp

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

VDBE 字节码执行 钉住的是 sqlite3_step 如何跑一段已经编译好的程序。这段程序从哪里来,是本篇要回答的问题:sqlite3_prepare_v2 接到一段 SQL 文本,要先弄清楚它的词法结构、语法结构、每个名字指向哪个表和列、该用哪条访问路径,最后才落成一串 VdbeOp。官方 Architecture of SQLite 把这条链路拆成 Tokenizer、Parser、Code Generator 三个源码组件,再加上第 5 篇的 Bytecode Engine,一共四段。

常见误区是把”查询计划”想象成一个独立、可观察的中间阶段,或者以为 prepare 一次就终身有效。两者都不准确:查询规划在源码里和代码生成粘在一起,而 prepare 出的语句会在运行时按 schema cookie 自动过期重编译。本文按四件事推进:

  1. prepare 内部四段管线与对应源码文件,不臆造行号。
  2. 名字解析、查询规划为什么在源码里没有独立文件边界。
  3. schema cookie 如何触发 sqlite3_step 内部的自动 reprepare。
  4. 本机 SQLite 3.53.2EXPLAIN QUERY PLAN 钉住三种访问路径,和第 5 篇的 EXPLAIN 字节码对照。

本文是「SQLite 内核」系列第 6 篇(共 17 篇)。→ 系列目录

篇目 核心内容
第 5 篇 · VDBE 字节码执行 prepare / step、寄存器、EXPLAIN 点查
第 6 篇 · SQL 编译管线 词法语法、名字解析、查询规划、schema cookie
第 7 篇 · Rollback Journal 模式 DELETE/TRUNCATE/PERSIST、崩溃恢复

版本锚定:官方 Architecture of SQLite(sqlite.org/arch.html);Compiling An SQL Statementsqlite3_prepare_v2 C API 文档);The SQLite Bytecode Engine(sqlite.org/opcode.html);File Format For SQLite Databases §1.3.9(schema cookie);The Next Generation Query Planner(sqlite.org/queryplanner-ng.html)。源码引用 sqlite/sqlite 主线 tokenize.cparse.yresolve.cwhere*.cselect.cprepare.c,只标文件与函数名,不标行号——这些文件随版本演进,行号对不齐没有意义。本文实测锚定本机 SQLite 3.53.2


一、prepare 内部的四段管线

官方架构图把 SQL 编译器拆成三个组件,sqlite3_prepare_v2 是它们共同的入口:

组件 源码文件 做什么
Tokenizer tokenize.c 手写词法分析,把 SQL 文本切成 token 流,边切边喂给 Parser
Parser parse.y(Lemon 生成) 用 Lemon 生成的 LALR 语法把 token 组装成语法树;Lemon 生成的解析器可重入、线程安全
Code Generator attach.cbuild.cdelete.cexpr.cinsert.cselect.ctrigger.cupdate.cwhere.c/wherecode.c/whereexpr.c 遍历语法树,做名字解析、选访问路径、生成 bytecode

官方文档特别点名:tokenizer 调用 parser,不是常见的”parser 调用 tokenizer”(YACC/BISON 习惯)——这样做是为了线程安全和速度。

flowchart LR
  sql["SQL text"] --> tok["Tokenizer\ntokenize.c"]
  tok -->|"tokens"| par["Parser (Lemon)\nparse.y"]
  par -->|"parse tree"| res["Name resolution\nresolve.c"]
  res -->|"resolved tree"| plan["Query planner\nwhere*.c / select.c"]
  plan -->|"WhereInfo / WhereLoop"| gen["Code generator\nexpr.c / select.c / insert.c ..."]
  gen -->|"VdbeOp[]"| stmt["Prepared statement\nsqlite3_stmt"]

常见误解

  1. “SQLite 没有独立的查询优化阶段。” 有,只是它不是一个单独的可插拔模块。官方文档直言:Code Generator 里”especially the logic in where*.cselect.c,有时被称为 query planner”。它和代码生成是同一批文件、同一次遍历完成的,不是先出一份计划再翻译成 bytecode。

  2. “Parser 调用 Tokenizer 才是常规做法,SQLite 反过来是特例。” 这话只说对了一半:YACC/BISON 式的”parser 主动要 token”确实是主流教学写法,但 SQLite 选择”tokenizer 主动推 token 给 parser”是为了避免全局状态、便于重入,Lemon 生成的解析器同样支持这种调用方向。这是工程取舍,不是谁”违反规范”。


二、名字解析:语法树和表结构之间的粘合层

语法树刚从 Parser 出来时,SELECT name FROM t WHERE id=1 里的 nametid 都只是字符串。真正把它们和 sqlite_schema 里的表/列对象绑定起来的,是 resolve.c 里的名字解析:核心入口函数 sqlite3ResolveExprNames,对每个标识符调用内部的 lookupName 在当前 NameContext 链上查找匹配的表和列,绑定成功后把 Expr 节点的游标号、列号写回节点本身;查不到就在这一步报语法错误,不会拖到执行期才发现。

这一步紧接在 Parser 之后、Code Generator 真正生成指令之前,但官方架构文档没有把它单列成第四个组件——它被归在 Code Generator 的前置工作里。这是本篇要澄清的边界:源码里确实存在名字解析这一步,只是没有对应的架构图方框

常见误解(续)

  1. “列名和表名的绑定是执行期发生的。” 不是。resolve.c 在 prepare 阶段就把标识符解析成内部的游标号 + 列号;执行期的 Column opcode(第 5 篇)拿到的已经是数字下标,不再看字符串列名。这也是为什么 ALTER TABLE ... RENAME COLUMN 之后旧的 prepared statement 不能直接复用列名字符串,而要靠 schema cookie 机制整体重编译(见第三节)。

三、查询规划藏在代码生成里,不是独立产物

官方文档把这句话说得很直接:查询规划器”是一个 AI,努力从数百万种可能算法里挑出最优的一种”(Architecture of SQLite,Code Generator 一节原文)。这个”AI”没有独立的产物格式,它的输出就是 where.c 内部的 WhereInfo / WhereLoop 结构,随即被同一遍遍历翻译成 OpenReadSeekGEIdxGT 之类的 opcode——第 5 篇 EXPLAIN 看到的指令序列,就是规划结果和代码生成结果叠在一起之后的样子。

The Next Generation Query Planner(NGQP,3.8.0 引入)文档交代了这里的算法选择:3.8.0 之前用”Nearest Neighbor”(NN)启发式,每步只贪心选当前代价最低的边;3.8.0 之后换成”N Nearest Neighbors”(N3),同时跟踪 N 条候选路径。两者都不是穷举或动态规划,换来的是”64-way join 也能很快出计划”,代价是不保证全局最优——文档明确写道,其他做更完整搜索的数据库引擎,join 表数超过 10 到 15 就容易变慢,SQLite 选择用启发式换速度。查询规划的算法细节和索引选择留给第 11 篇,这里只钉住一件事:规划不是可以单独抽出来验证正确性的模块,它是代码生成流程内部的搜索过程

sequenceDiagram
  participant App
  participant Prep as sqlite3_prepare_v2
  participant Res as resolve.c
  participant Plan as where.c / select.c
  participant Vdbe as Code generator
  App->>Prep: SQL text
  Prep->>Res: parse tree
  Res->>Res: bind identifiers to table/column
  Res->>Plan: resolved tree
  Plan->>Plan: search WhereLoop candidates (NN / N3 heuristic)
  Plan->>Vdbe: chosen access path
  Vdbe-->>App: sqlite3_stmt (VdbeOp array)

官方 File Format For SQLite Databases §1.3.9 定义了数据库头部偏移 40 处的 4 字节大端整数——schema cookie,每次 schema 变化都会递增。一条 prepared statement 是针对某个具体 schema 版本编译出来的;运行时先核对当前 schema cookie 是否和编译时一致,不一致就要重编译。

sqlite3_prepare_v2 的 C API 文档强调了这条差异:新版(“vX”)接口会在 sqlite3_stmt 里保留原始 SQL 文本,这带来一个直接后果——sqlite3_step 可以在 schema 改变后自动用保存的文本重新 prepare 并重跑,不需要应用层介入;旧版 sqlite3_prepare(无 v2/v3)不保留文本,遇到同样情况只会返回 SQLITE_SCHEMA 错误,逼应用自己重来。源码里的判定逻辑在 prepare.c:读出 BTREE_SCHEMA_VERSION 元数据和内存里缓存的 schema_cookie 比较,不一致时如果 schema 已经加载过,就把 Parse.rc 置成 SQLITE_SCHEMA 并调用 sqlite3ResetOneSchema 清空内存 schema,逼一次重新读取。The SQLite Bytecode Engine 文档把这个行为称为”expired statement”:过期的语句如果是用 prepare_v2 建的会自动 reprepare 重跑,否则直接失败。

flowchart TB
  step["sqlite3_step()"] --> check["compare schema cookie\n(header offset 40) vs cached value"]
  check -->|"match"| run["run existing bytecode"]
  check -->|"mismatch"| retext{"created via\nprepare_v2/v3?"}
  retext -->|"yes"| reprep["reprepare from saved SQL text\n(prepare.c)"]
  retext -->|"no (legacy prepare)"| err["return SQLITE_SCHEMA"]
  reprep --> run

会触发 schema cookie 变化的典型操作:CREATE/DROP/ALTER TABLE、创建或删除索引、ANALYZE 写回 sqlite_stat* 后的部分路径。日常应用里这个开销通常不显眼,但如果一个长连接的服务在高频路径上反复 ALTER TABLE ADD COLUMN 或建临时索引,会让本该复用的 prepared statement 在下一次 step 时悄悄触发一次完整重编译——这是查询延迟抖动里容易被忽略的一类成因。

常见误解(续)

  1. “prepare 一次,之后永远是那份 bytecode,不会变。” 只要没有 schema 变化确实如此。但 schema cookie 机制意味着 bytecode 在语句生命周期内可能被整体替换,应用侧看到的只是 sqlite3_step 多花了一点时间,而不是一个显式的”重新编译”事件。

五、实测:EXPLAIN QUERY PLAN 钉住三种访问路径

环境:本机 sqlite3 3.53.2;表 t(id INT PRIMARY KEY, name TEXT)——注意是 INT 不是 INTEGER,SQLite 只把精确写作 INTEGER PRIMARY KEY 的列当 rowid 别名(第 5 篇用的是这种),INT PRIMARY KEY 会被当成普通 UNIQUE 约束,落地成一个自动索引。复现脚本:reproduce/06-query-plans.sh

sqlite3 sk-eqp.db "EXPLAIN QUERY PLAN SELECT name FROM t WHERE id=1;"
QUERY PLAN
`--SEARCH t USING INDEX sqlite_autoindex_t_1 (id=?)

同一张表换一个不落在任何索引上的谓词:

sqlite3 sk-eqp.db "EXPLAIN QUERY PLAN SELECT id FROM t WHERE name='a';"
QUERY PLAN
`--SCAN t

再加一个 ORDER BY 到未建索引的列:

sqlite3 sk-eqp.db "EXPLAIN QUERY PLAN SELECT * FROM t ORDER BY name;"
QUERY PLAN
|--SCAN t
`--USE TEMP B-TREE FOR ORDER BY

5.1 EXPLAIN QUERY PLAN 与 EXPLAIN 的分工

维度 EXPLAIN QUERY PLAN EXPLAIN
层次 高层访问路径描述 逐条 VDBE opcode
典型输出 SEARCH / SCAN / USE TEMP B-TREE OpenReadSeekGEColumn
用途 判断有没有走对索引(对接第 11 篇) 判断执行细节(第 5 篇)
稳定性 输出文本本身也不是稳定协议 同样不是稳定协议

对同一条点查 SELECT name FROM t WHERE id=1EXPLAIN 展开的 bytecode 是:

addr  opcode         p1    p2    p3    p4             p5  comment
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     10    0                    0   Start at 10
1     OpenRead       0     2     0     2              0   root=2 iDb=0; t
2     OpenRead       1     3     0     k(2,,)         2   root=3 iDb=0; sqlite_autoindex_t_1
3     Integer        1     1     0                    0   r[1]=1
4     SeekGE         1     9     1     1              0   key=r[1]
5     IdxGT          1     9     1     1              0   key=r[1]
6     DeferredSeek   1     0     0                    0   Move 0 to 1.rowid if needed
7     Column         0     1     2                    0   r[2]= cursor 0 column 1
8     ResultRow      2     1     0                    0   output=r[2]
9     Halt           0     0     0                    0
10    Transaction    0     0     1     0              1   usesStmtJournal=0
11    Goto           0     1     0                    0

对照第 5 篇 id INTEGER PRIMARY KEY(rowid 别名)的点查只用一个游标、SeekRowid 一步到位;这里 id INT PRIMARY KEY 走的是普通索引,OpenRead 打开了两个游标(表 + 自动索引),先在索引上 SeekGE/IdxGT 定位,再 DeferredSeek 回表取 name 列。同一条 SQL 文本,因为一个关键字的差异(INT vs INTEGER),EXPLAIN QUERY PLAN 与 EXPLAIN 的输出都完全不同——这是”计划依赖具体 schema”最直观的证据。

复现脚本与完整输出见 reproduce/06-query-plans.sh;换 SQLite 小版本后,SEARCH/SCAN 的具体文案可能微调,请以本机输出为准。


六、学术谱系、工程间隙与开放问题

谱系:把 SQL 编译成中间形式再执行,是 System R 以降数据库系统的经典分工,第 5 篇已经点过这条线。本篇要补的是查询规划算法本身的谱系分叉:教科书里最常见的是 Selinger 式动态规划(IBM System R, 1979)——对 join 顺序做穷举/动态规划搜索,保证局部最优但搜索空间随表数指数增长。SQLite 没有走这条路:NGQP 文档明确说 3.8.0 之前用 Nearest Neighbor 贪心、之后用 N Nearest Neighbors(N3),核心动机是”64-way join 也能快速出计划”,代价是不保证找到全局最优解。这是嵌入式场景下”够用就好”和服务器数据库”尽量最优”两种设计哲学的直接分岔点。

工程间隙:NGQP 文档专门记录了 N3 在 star-schema(一个大事实表 + 多个小维度表)场景下的已知短板——要修正这个问题所需的候选集规模 N 在维度表数量上是指数级的,普通设置下 N3 会倾向于先塞满几个维度表的全表扫描候选,把真正该优先命中事实表的方案挤出候选集之外。文档同时写明近年版本已经加入针对 star-query 的专项启发式,但没有承诺”问题已完全解决”。Query Planning 文档里还有一条更朴素的工程间隙提示:如果开发者发现 join 顺序不理想,官方建议先跑 PRAGMA optimize 补统计信息,而不是手工改写查询——这说明 SQLite 团队把”启发式偶尔失手”当成需要持续打磨、而非彻底避免的常态。

开放问题:贪心/近似搜索与穷举式最优搜索之间的取舍,在嵌入式单进程场景下是否还有第三条路(例如运行时反馈驱动的自适应重规划)——SQLite 现有 schema cookie 机制只解决”schema 变了要不要重编译”,不解决”同一 schema 下统计分布变化后要不要重规划”;PRAGMA optimize 是手动/周期性的缓解手段,不是自动反馈闭环。第 11 篇讨论查询计划器与统计信息时,这条线会继续展开,本篇不预判答案。


七、小结

三句话小结

  1. sqlite3_prepare_v2 内部依次经过 Tokenizer(tokenize.c)、Parser(parse.y)、名字解析(resolve.c)、查询规划与代码生成(where*.c/select.c 等),最终产出第 5 篇执行的 VdbeOp 数组;官方架构文档只把它们归成 Tokenizer/Parser/Code Generator 三个组件,规划与代码生成没有独立边界。
  2. schema cookie(文件头偏移 40)变化会让 sqlite3_step 依据是否用 prepare_v2/v3 建立的语句,选择自动 reprepare 或返回 SQLITE_SCHEMA;这个检查发生在每次 step,不是显式事件。
  3. 本机 3.53.2 上 id INT PRIMARY KEYid INTEGER PRIMARY KEY 对同一查询给出完全不同的 EXPLAIN QUERY PLAN/EXPLAIN 输出,查询规划算法本身(NN/N3 启发式及其已知短板)留给第 11 篇细拆。

参考资料

规范与官方文档(A 级)

  1. SQLite Documentation, Architecture of SQLite(sqlite.org/arch.html)。
  2. SQLite C API, Compiling An SQL Statementsqlite3_preparesqlite3_prepare_v2sqlite3_prepare_v3(sqlite.org/c3ref/prepare.html)。
  3. SQLite Documentation, The SQLite Bytecode Engine(sqlite.org/opcode.html)。
  4. SQLite Documentation, File Format For SQLite Databases,§1.3.9 Schema cookie(sqlite.org/fileformat2.html)。
  5. SQLite Documentation, The Next Generation Query Planner(sqlite.org/queryplanner-ng.html)。
  6. SQLite Documentation, The SQLite Query Optimizer Overview(sqlite.org/optoverview.html)。

源码(A 级)

  1. tokenize.cparse.yresolve.csqlite3ResolveExprNameslookupName)、where.c/wherecode.c/whereexpr.cselect.cprepare.c(schema cookie 校验、sqlite3ResetOneSchema)——sqlite/sqlite 源码树,函数与文件名跨版本稳定,具体行号不作为引用依据。

实验

  1. 本机 SQLite 3.53.2:EXPLAIN QUERY PLAN 三种形态、INT vs INTEGER PRIMARY KEY 对比、EXPLAIN 字节码(输出见第五节;脚本 reproduce/06-query-plans.sh)。

站内

  1. VDBE 字节码执行B-Tree 遍历与分裂
  2. 本系列 indexPLAN.md

上一篇VDBE 字节码执行 下一篇Rollback Journal 模式

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-18 · database / storage

【SQLite 内核】索引与 covering scan:什么时候真的省掉了第二次查找

钉住 covering index 消除的具体开销:第 4 篇 index b-tree 只存 key+rowid,命中后仍要回表这一次二次查找;用本机 3.53.2 实测 SELECT 列是否全部落在索引内如何改变 EQP 输出,并区分语句级自动索引与 sqlite_autoindex_* 约束索引这两个常被混淆的概念。


By .