纯 Python 的数据库注定比 C 写的 SQLite 慢一两个数量级。但“慢多少”差别很大:阶段 17 之前,10 万行表和一张 200 行无索引小表的等值连接要 17 秒,是 SQLite 的 1500 倍。这篇讲阶段 17 的几项优化,以及每一项怎么保证语义不变。
所有数字来自 tests/benchmark.py(10 万行,同一台机器,前后对比)。
最大的一刀:算法,而不是常数#
17 秒的连接是嵌套循环:外层 10 万行,内层每行把 200 行扫一遍,2000 万次比较。SQLite 遇到这种情况会临时建一个“自动索引”。MiniDB 的对应物是 HashLookup:内层表只能全表扫描、但有“本表列 = 已连接表的表达式”时,第一次探测前把内层表按该列哈希一次,之后按 key 取行。
细节在于“相等”的定义。SQL 的 = 要先做比较亲和性转换('5' 和 5 在某些列上相等,在另一些列上不等),所以哈希键是转换之后的排序键,转换规则和索引查找完全相同;列那一侧会被转换的情况(索引不能用的情况)哈希也不用。
17.2 s → 0.12 s。
闭包链 → 生成源码#
阶段 11 起,表达式被编译成一层层闭包:a + b > 3 是 gt(add(col_a, col_b), const_3) 这样的嵌套调用,每行每个运算符一次 Python 函数调用。改成把一个表达式拼成一段 Python 源码,compile() 成一个 lambda:
- 整数的
+ - *在 64 位范围内直接用 Python 运算,溢出时才调用原来的values.add(溢出转 REAL 的语义不变); - 两侧都是数值的比较直接用 Python 比较;
- AND / OR 用海象运算符保持短路和三值逻辑。
函数、CASE、子查询这些复杂节点仍是闭包,作为辅助函数被生成的代码调用,它们的子表达式照样走代码生成。同样的手法用在结果列元组、ORDER BY 键、分组聚合循环,以及只有内连接时的嵌套 for 循环(每张表一层,过滤条件内联)。
两个容易忽略的点:
- 常量不写进源码,放进环境字典。源码只取决于表达式的形状,按源码文本缓存 code 对象——只差一个常量的点查只编译一次。第一版把整数常量直接内联,结果每条只差一个数字的语句都要重新
compile()一次。 - 嵌套深度。表达式太深时子树退回闭包;连接则是 sqllogictest 发现的:21 张表的连接生成 21 层
for,超过了Python 编译器 20 层静态嵌套块的限制(SyntaxError: too many statically nested blocks)。超过 16 张表改用通用循环。
批量建索引#
CREATE INDEX 原来是逐行插入 B+ 树。改成先算出所有 key 排序(UNIQUE 直接在排好序的 key 上找相邻重复),再自底向上装满节点(每层末尾两个节点平衡一下,保证最小填充率)。0.70 s → 0.39 s,文件还因为索引页装满而变小(15.7 → 14.8 MB)。同一个 bulk_load 后来被 VACUUM 复用。
tokenizer 与解析器#
tokenizer 改成一个大正则匹配常见的词、整数、简单字符串、运算符和空白,其余情况(带引号的标识符、BLOB、注释、参数……)走原来的逻辑。验证方式是差分:新旧 tokenizer 对 40 万个随机片段输出必须相同。唯一的差异是旧实现遇到 ² 这样的非 ASCII 数字会崩溃,新的和 SQLite 一样把它当作标识符字符。
解析器给“单个字面量或列”(VALUES 行里最常见)开了快路径,跳过十几层优先级函数;同样用 5.5 万条 fuzz 语句做新旧差分,语法树和报错完全一致。
结果#
| 操作(10 万行) | 优化前 | 优化后 | sqlite3 |
|---|---|---|---|
| 逐条 INSERT(一个事务) | 4.36 s | 2.67 s | 0.19 s |
| 范围扫描 100 次 | 0.199 s | 0.081 s | 0.021 s |
count(*) WHERE age > 50 | 0.124 s | 0.059 s | 0.002 s |
| GROUP BY + 3 个聚合 | 0.174 s | 0.093 s | 0.030 s |
| 与 200 行无索引表连接 | 17.2 s | 0.12 s | 0.010 s |
| CREATE INDEX | 0.70 s | 0.39 s | 0.017 s |
扫描、聚合、连接大约快了一倍,最慢的一项从 SQLite 的 1500 倍降到 12 倍。没变快的也要写下来:每条 SQL 文本都不同的点查只快了约 9%(主要时间在解析和规划,纯 Python),参数化插入只快了约 7%(瓶颈在 B+ 树和记录编码)。
每一项都要证明“没改语义”#
性能改动最怕悄悄改了结果。每一项都在改动前后跑全部对照测试、600 个种子的差分 fuzz、变形测试和 sqllogictest 全量;tokenizer 和解析器另外做了新旧实现的差分。21 层 for 的问题就是这样在提交前被抓到的。
对应的决策#
D55(top-k 与索引顺序免排序)、D56(覆盖索引)、D58(记录格式)、D89(哈希连接)、D90(生成源码)、D97(BETWEEN 走索引)。
代码、测试与文中引用的设计决策(D 编号,见 DECISIONS.md):github.com/ayaya114514/MiniDB ↗;浏览器里直接试:MiniDB Playground ↗。