纯 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 s2.67 s0.19 s
范围扫描 100 次0.199 s0.081 s0.021 s
count(*) WHERE age > 500.124 s0.059 s0.002 s
GROUP BY + 3 个聚合0.174 s0.093 s0.030 s
与 200 行无索引表连接17.2 s0.12 s0.010 s
CREATE INDEX0.70 s0.39 s0.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 ↗。