VDBE
字节码执行 钉住的是 sqlite3_step
如何跑一段已经编译好的程序。这段程序从哪里来,是本篇要回答的问题:sqlite3_prepare_v2
接到一段 SQL
文本,要先弄清楚它的词法结构、语法结构、每个名字指向哪个表和列、该用哪条访问路径,最后才落成一串
VdbeOp。官方 Architecture of SQLite
把这条链路拆成 Tokenizer、Parser、Code Generator
三个源码组件,再加上第 5 篇的 Bytecode
Engine,一共四段。
常见误区是把”查询计划”想象成一个独立、可观察的中间阶段,或者以为 prepare 一次就终身有效。两者都不准确:查询规划在源码里和代码生成粘在一起,而 prepare 出的语句会在运行时按 schema cookie 自动过期重编译。本文按四件事推进:
prepare内部四段管线与对应源码文件,不臆造行号。- 名字解析、查询规划为什么在源码里没有独立文件边界。
- schema cookie 如何触发
sqlite3_step内部的自动 reprepare。 - 本机 SQLite 3.53.2 的
EXPLAIN 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 Statement(
sqlite3_prepare_v2C 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.c、parse.y、resolve.c、where*.c、select.c、prepare.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.c、build.c、delete.c、expr.c、insert.c、select.c、trigger.c、update.c、where.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"]
常见误解
“SQLite 没有独立的查询优化阶段。” 有,只是它不是一个单独的可插拔模块。官方文档直言:Code Generator 里”especially the logic in
where*.c和select.c,有时被称为 query planner”。它和代码生成是同一批文件、同一次遍历完成的,不是先出一份计划再翻译成 bytecode。“Parser 调用 Tokenizer 才是常规做法,SQLite 反过来是特例。” 这话只说对了一半:YACC/BISON 式的”parser 主动要 token”确实是主流教学写法,但 SQLite 选择”tokenizer 主动推 token 给 parser”是为了避免全局状态、便于重入,Lemon 生成的解析器同样支持这种调用方向。这是工程取舍,不是谁”违反规范”。
二、名字解析:语法树和表结构之间的粘合层
语法树刚从 Parser
出来时,SELECT name FROM t WHERE id=1 里的
name、t、id
都只是字符串。真正把它们和 sqlite_schema
里的表/列对象绑定起来的,是 resolve.c
里的名字解析:核心入口函数
sqlite3ResolveExprNames,对每个标识符调用内部的
lookupName 在当前 NameContext
链上查找匹配的表和列,绑定成功后把 Expr
节点的游标号、列号写回节点本身;查不到就在这一步报语法错误,不会拖到执行期才发现。
这一步紧接在 Parser 之后、Code Generator 真正生成指令之前,但官方架构文档没有把它单列成第四个组件——它被归在 Code Generator 的前置工作里。这是本篇要澄清的边界:源码里确实存在名字解析这一步,只是没有对应的架构图方框。
常见误解(续)
- “列名和表名的绑定是执行期发生的。”
不是。
resolve.c在 prepare 阶段就把标识符解析成内部的游标号 + 列号;执行期的Columnopcode(第 5 篇)拿到的已经是数字下标,不再看字符串列名。这也是为什么ALTER TABLE ... RENAME COLUMN之后旧的 prepared statement 不能直接复用列名字符串,而要靠 schema cookie 机制整体重编译(见第三节)。
三、查询规划藏在代码生成里,不是独立产物
官方文档把这句话说得很直接:查询规划器”是一个
AI,努力从数百万种可能算法里挑出最优的一种”(Architecture
of SQLite,Code Generator
一节原文)。这个”AI”没有独立的产物格式,它的输出就是
where.c 内部的 WhereInfo /
WhereLoop 结构,随即被同一遍遍历翻译成
OpenRead、SeekGE、IdxGT
之类的 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)
四、schema cookie 与自动 reprepare
官方 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
时悄悄触发一次完整重编译——这是查询延迟抖动里容易被忽略的一类成因。
常见误解(续)
- “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 |
OpenRead、SeekGE、Column
… |
| 用途 | 判断有没有走对索引(对接第 11 篇) | 判断执行细节(第 5 篇) |
| 稳定性 | 输出文本本身也不是稳定协议 | 同样不是稳定协议 |
对同一条点查
SELECT name FROM t WHERE id=1,EXPLAIN
展开的 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
篇讨论查询计划器与统计信息时,这条线会继续展开,本篇不预判答案。
七、小结
三句话小结
sqlite3_prepare_v2内部依次经过 Tokenizer(tokenize.c)、Parser(parse.y)、名字解析(resolve.c)、查询规划与代码生成(where*.c/select.c等),最终产出第 5 篇执行的VdbeOp数组;官方架构文档只把它们归成 Tokenizer/Parser/Code Generator 三个组件,规划与代码生成没有独立边界。- schema cookie(文件头偏移 40)变化会让
sqlite3_step依据是否用prepare_v2/v3建立的语句,选择自动 reprepare 或返回SQLITE_SCHEMA;这个检查发生在每次step,不是显式事件。 - 本机 3.53.2 上
id INT PRIMARY KEY与id INTEGER PRIMARY KEY对同一查询给出完全不同的EXPLAIN QUERY PLAN/EXPLAIN输出,查询规划算法本身(NN/N3 启发式及其已知短板)留给第 11 篇细拆。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, Architecture of SQLite(sqlite.org/arch.html)。
- SQLite C API, Compiling An SQL
Statement:
sqlite3_prepare、sqlite3_prepare_v2、sqlite3_prepare_v3(sqlite.org/c3ref/prepare.html)。 - SQLite Documentation, The SQLite Bytecode Engine(sqlite.org/opcode.html)。
- SQLite Documentation, File Format For SQLite Databases,§1.3.9 Schema cookie(sqlite.org/fileformat2.html)。
- SQLite Documentation, The Next Generation Query Planner(sqlite.org/queryplanner-ng.html)。
- SQLite Documentation, The SQLite Query Optimizer Overview(sqlite.org/optoverview.html)。
源码(A 级)
tokenize.c、parse.y、resolve.c(sqlite3ResolveExprNames、lookupName)、where.c/wherecode.c/whereexpr.c、select.c、prepare.c(schema cookie 校验、sqlite3ResetOneSchema)——sqlite/sqlite源码树,函数与文件名跨版本稳定,具体行号不作为引用依据。
实验
- 本机 SQLite 3.53.2:
EXPLAIN QUERY PLAN三种形态、INTvsINTEGER PRIMARY KEY对比、EXPLAIN字节码(输出见第五节;脚本reproduce/06-query-plans.sh)。
站内
上一篇:VDBE 字节码执行 下一篇:Rollback Journal 模式
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】查询计划器与统计:没有 ANALYZE 时的启发式与代价估计边界
钉住 EXPLAIN QUERY PLAN 的 SEARCH/SCAN 判定、ANALYZE 写入 sqlite_stat1/stat4 的具体语义,以及没有统计时 SQLite 用什么默认猜测(表大小猜一百万行、重复度猜 10);对照官方 N3 join 排序算法与 PG 式穷举代价优化器的复杂度边界,本机 3.53.2 实测覆盖两条 EQP 路径。
【SQLite 内核】VDBE 字节码执行:prepare、step 与寄存器机
钉住 SQLite 把 SQL 编译成 bytecode、由 sqlite3_step 驱动虚拟机执行的路径:指令格式、寄存器、B-Tree 游标与 ResultRow;用本机 3.53.2 EXPLAIN 走通一次点查,编译管线细节留给第 6 篇。
【SQLite 内核】索引与 covering scan:什么时候真的省掉了第二次查找
钉住 covering index 消除的具体开销:第 4 篇 index b-tree 只存 key+rowid,命中后仍要回表这一次二次查找;用本机 3.53.2 实测 SELECT 列是否全部落在索引内如何改变 EQP 输出,并区分语句级自动索引与 sqlite_autoindex_* 约束索引这两个常被混淆的概念。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。