创建日期:2026-09-17 | 最近更新:2026-09-17 本文所有数字均为本机实测(macOS / Darwin 24.6.0,Node v24.14.1):
node:sqlite对应 SQLite 3.51.2,better-sqlite3@13.0.3对应 SQLite 3.53.4。实测脚本见文末「参考」,可直接复现。 结论标注分两类:实测=本机跑出来的数字;原理=来自 SQLite 文档/架构的通识解释。
高性能 SQLite 理论分析入门
第 0 篇想解决一个很具体的问题:为什么你写的 SQLite 插入那么慢? 大多数人的第一反应是「SQLite 性能不行,换 Postgres」——但实测下来,同一份代码只加一行事务,插入速度能从 730 µs/行 变成 2.6 µs/行,快 280 倍。
也就是说:慢的通常不是 SQLite,是写法。 这篇先讲清它为什么快、瓶颈到底在哪,再用实测把「优化顺序」排出来。第 1 篇再横向对比各驱动的写法差异。
1. 先破一个误解
SQLite 常被当成「玩具数据库」,因为它没有服务器进程。但在单机、读多写少、数据量在 GB 级以内的场景里,它的性能往往超过你连的 MySQL/Postgres——原因很简单:
| SQLite | MySQL / Postgres | |
|---|---|---|
| 进程边界 | 没有——库直接编进你的进程 | 客户端进程 → 网络 → 服务端进程 |
| 一次查询的成本 | 函数调用 + 内存/页缓存查找 | 序列化 + 网络往返 + 协议解析 + 服务端调度 |
| 部署 | 一个文件 | 一个服务 + 连接池 + 运维 |
| 并发写 | 单写者(这是它真正的限制) | 多写者 |
一句话:SQLite 省掉的不是「数据库能力」,而是**「客户端/服务端之间的那一段」。这也是为什么它敢把 API 设计成同步**的(node:sqlite、better-sqlite3 都是同步 API)——进程内的函数调用本来就不需要异步。
反过来说:SQLite 不该用的地方也非常明确——多机共享、高并发写、需要细粒度权限与在线扩容。这篇和下一篇都只讨论「该用它的时候,怎么用对」。
2. 它为什么快:三个层级
理解 SQLite 的性能,只需要盯住三层:
① 页(page)—— IO 的最小单位,默认 4096 字节
↓
② B-tree —— 表和索引都是 B-tree;查询 = 从根走到叶子
↓
③ 页缓存(page cache)—— SQLite 自己在内存里缓存页,默认只有 2MB
2.1 页:一切的计量单位
数据库文件被切成固定大小的页(默认 page_size = 4096)。读一行数据,实际发生的是「读一页」;写一行,实际是「改一页」。所以:
- page_size 与文件系统块对齐时分外划算(4096 是大多数系统的默认块大小,这也是 SQLite 默认值);
- 一行跨页存储会带来额外 IO;
- 缓存的是页,不是行——这解释了很多反直觉现象(见 §7)。
2.2 B-tree:为什么「有没有索引」差很多
实测(10 万行,WHERE cat = 42):
无索引 -> SCAN t 4.72 ms
建索引后 -> SEARCH t USING INDEX idx_cat (cat=?) 1.15 ms (4x)
SCAN 是扫全部页,SEARCH ... USING INDEX 是从 B-tree 根走到叶子。4 倍差距看着不吓人,是因为 10 万行还小、且全在页缓存里;数据量一大或缓存不够时,差距是数量级的。
一个立刻能用的技能:用 EXPLAIN QUERY PLAN 看它是 SCAN 还是 SEARCH。
EXPLAIN QUERY PLAN SELECT * FROM t WHERE cat = 42;
-- 看到 SCAN → 该考虑索引
-- 看到 SEARCH → 走索引了
还有个更划算的形态叫覆盖索引——查询要的列全在索引里,连表都不用回:
CREATE INDEX idx_cat_name ON t(cat, name);
EXPLAIN QUERY PLAN SELECT name FROM t WHERE cat = 42;
-> SEARCH t USING COVERING INDEX idx_cat_name (cat=?)
2.3 页缓存:默认值小得离谱
SQLite 的页缓存默认 cache_size = -2000,即 2MB。用它扛一本几十万行的表,等于每查一次都在做磁盘 IO。
PRAGMA cache_size = -64000; -- 负号 = 单位是 KiB,这里 = 64MB
PRAGMA mmap_size = 268435456; -- 256MB,让读走内存映射
注意:
node:sqlite与better-sqlite3都不会帮你调这些。默认值就是 SQLite 的默认值(实测better-sqlite3的journal_mode=delete、synchronous=2(FULL)),所以生产环境必须自己设 PRAGMA。
3. 本文的支点:写入成本 = 提交次数 × fsync
这是全文最值钱的一段。SQLite 写性能的核心事实是:
一次「提交」(commit)至少要一次
fsync。而fsync是毫秒级的。
默认 synchronous = FULL 时,SQLite 为了「断电不丢已提交数据」,必须把数据真正刷到磁盘才敢返回。所以:
- 每条 INSERT 单独提交(autocommit,也就是不写事务)→ N 行 = N 次 fsync;
- 用一个事务包住 N 条 INSERT → N 行 = 1 次 fsync。
实测(5000 行,两个同步驱动,同一台机器):
| 写法 | better-sqlite3 | node:sqlite |
|---|---|---|
| A 逐条 insert(无事务) | 3650.7 ms(730.1 µs/行) | 3304.2 ms(660.8 µs/行) |
B 显式事务,但每次重新 prepare | 110.5 ms(22.1 µs/行) | 38.1 ms(7.6 µs/行) |
| C 显式事务 + 复用 prepared statement | 13.0 ms(2.6 µs/行) | 14.3 ms(2.9 µs/行) |
D better-sqlite3 的 db.transaction() 包装 | 14.8 ms(3.0 µs/行) | —(无此 API) |
A → C 是 280 倍。 分解一下这 280 倍来自两处:
- 事务:A → B,把 N 次 fsync 变成 1 次(这一步贡献了绝大部分,约 33~87 倍);
- 复用 prepared statement:B → C。注意 B 里每次循环都
db.prepare(...)——编译 SQL 语句本身有成本,复用能再快 3~8 倍。
所以优化顺序是确定的:① 开事务 → ② 复用 prepared statement → ③ 调 PRAGMA → ④ 加索引。 顺序反了就是白费功夫:你先去调
page_size,但每条 insert 还在单独 fsync,收益基本为零。
3.1 一个反直觉的发现:批量写时 WAL 的增益没你想的大
WAL 常被当成「SQLite 提速银弹」。但实测(20 万行,已经用了显式事务,synchronous=NORMAL):
better-sqlite3 journal=delete 586 ms 341k 行/秒
better-sqlite3 journal=wal 531 ms 377k 行/秒 ← 只快约 10%
结论(实测):当「事务 + NORMAL」已经就位时,WAL 对批量写的增益只有约 10%——因为这时的瓶颈已经不是 fsync 次数了。WAL 的真正价值在下面两个场景(见 §4)。
4. journal_mode 与 synchronous:真正的收益在哪
单独把这两个参数拎出来测(3000 行逐条 insert,无事务——故意制造最坏情况):
| journal_mode | synchronous | 3000 行逐条 insert |
|---|---|---|
| delete | FULL(默认) | 1780 ms |
| delete | NORMAL | 1841 ms |
| delete | OFF | 1224 ms |
| wal | FULL | 327 ms |
| wal | NORMAL | 83 ms |
| wal | OFF | 65 ms |
三个可迁移的结论:
- WAL 把 autocommit 写提升了 5~21 倍(1780 → 327 / 83 ms)。因为它把「随机写回原文件」换成了顺序追加到
-wal文件; synchronous=NORMAL只在 WAL 模式下才划算——注意delete + NORMAL(1841ms)和delete + FULL(1780ms)几乎一样慢。这是个很常见的误解:在回滚日志模式下,NORMAL 省不下那次关键 fsync;synchronous=OFF最快(65ms),但断电/崩溃可能损坏数据库——只在「数据可由源重建」(如缓存、临时分析库)时考虑。
WAL 真正的两个价值(原理):
- 读写不互斥:
delete模式下写会锁住整库、读者被挡;WAL 模式下写者与读者可以同时进行(单写多读)。这才是 WAL 的主要意义。 - autocommit 场景的大幅提升(上表)。
WAL 的代价(原理):多出 -wal 和 -shm 两个文件;需要定期 checkpoint 把 WAL 合并回主库(默认自动);WAL 文件在长事务下会持续增长。
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
5. 读取:游标不是银弹
「大结果集要用游标迭代,别一次 all()」——这条建议在 SQLite 上不一定成立。实测(20 万行):
| 方式 | 耗时 | 说明 |
|---|---|---|
all() 一次性物化 | 136.4 ms | 全部读进内存(进程 RSS 峰值 227 MB) |
iterate() 游标逐行 | 192.5 ms | 只保留当前行 |
all() 反而更快(实测)。原因(原理):游标迭代要为每行做一次 JS↔SQLite 的往返与对象构造,而 all() 是一次性批量转换、路径更短。
所以选择依据不是「谁快」,而是内存:
- 结果集小/中,且能放进内存 →
all(); - 结果集大到不能物化(或你只想读前几行就停)→
iterate(),用时间换内存。
顺带:
iterate()还有一个「提前退出」的用法——查出想要的就可break,不必读完,这在「找一条匹配记录」时比all()更省。
6. 页与缓存调参速查
| PRAGMA | 默认 | 建议 | 管什么 |
|---|---|---|---|
page_size | 4096 | 新建库时可设 4096(对齐文件系统块);已建库改需 VACUUM | IO 最小单位 |
cache_size | -2000(2MB) | 按可用内存设,如 -64000(64MB) | 页缓存大小 |
mmap_size | 0 | 读多写少可设 256MB | 用内存映射读文件 |
journal_mode | delete | WAL | 日志模式(见 §4) |
synchronous | FULL | WAL 下用 NORMAL | fsync 强度 |
foreign_keys | OFF | 建议 ON | 外键约束常被默认关掉 |
temp_store | 0 | 复杂查询可用 MEMORY | 临时表放哪 |
几个容易踩的点:
foreign_keys默认是关的——你以为写了外键就有约束,其实没有,必须每个连接显式PRAGMA foreign_keys = ON;page_size改不了已有库(除非VACUUM重建),所以建库时定好;- 删数据不会缩小文件——
DELETE只是把页标记为空闲,文件大小不变。要真正回收得VACUUM(会重建整个文件,代价大且会锁库); ANALYZE会让查询规划更准——尤其当数据分布倾斜、或你建了多个索引时。
7. 什么时候「不该」加索引
索引不是越多越好(原理):
- 每个索引都是一棵额外的 B-tree:每次 INSERT/UPDATE/DELETE 都要维护所有索引——写放大。一张有 6 个索引的表,写入慢几倍很正常;
- 选择性低的列(如只有 true/false 的布尔列)建索引收益很小,SQLite 可能干脆不用它;
- 多列查询要匹配索引的列顺序(最左前缀)——
INDEX(cat, name)能服务WHERE cat=?,但服务不了WHERE name=?。
判断方法还是 EXPLAIN QUERY PLAN:先看有没有 SCAN,再决定要不要建;建完再看确实变成了 SEARCH。 不要凭感觉加。
8. 一页速查:推荐的初始化
PRAGMA journal_mode = WAL; -- 并发读 + autocommit 提速
PRAGMA synchronous = NORMAL; -- WAL 下性价比最高
PRAGMA foreign_keys = ON; -- 默认关,务必手动开
PRAGMA cache_size = -64000; -- 64MB 页缓存
PRAGMA mmap_size = 268435456; -- 256MB 内存映射(读多写少)
PRAGMA busy_timeout = 5000; -- 遇到锁时等 5 秒,而不是立刻报 SQLITE_BUSY
写入侧的铁律(三行就够):
// ① 用一个事务包住所有写
db.exec('BEGIN');
// ② prepared statement 在循环外 prepare 一次
const stmt = db.prepare('INSERT INTO t (a, b) VALUES (?, ?)');
for (const [a, b] of rows) stmt.run(a, b);
db.exec('COMMIT');
9. 收尾:SQLite 的边界
该用:桌面/移动 App 本地存储、单机服务、CLI 工具、嵌入式设备、离线优先应用、分析型单机数据处理、测试替身(比内存 mock 真实得多)。
别用:多机共享同一份数据、高并发写(它是单写者——同时只有一个写事务)、需要用户级权限/审计、需要在线水平扩容。
记住全文那一句就够:SQLite 的写瓶颈是「提交次数 × fsync」,不是 CPU。 先把事务和 prepared statement 写对,再谈别的。
关联
- 下一篇:SQLite 驱动写法横向对比——
node:sqlite/better-sqlite3/@libsql/client/sql.js同一任务四种写法,含大整数静默丢精度等五个会咬人的差异 - 同类入门(服务端数据库):MySQL 系列(连接/索引与 EXPLAIN/事务隔离)
- 本站其他「先测再写」的笔记:InkOS 为什么不用 LangGraph、LangGraph 图解
自测
- 为什么
A 逐条 insert和C 事务+复用 prepare能差 280 倍?这 280 倍分别来自哪两处? - 为什么说「
synchronous=NORMAL只在 WAL 模式下才划算」?实测里哪两个数字支持这个结论? - WAL 相比
delete模式,两个真正的价值是什么?对「已用事务的批量写」,实测增益大概多少? - 为什么大结果集下
all()可能比iterate()更快?那什么时候必须用iterate()? foreign_keys的默认值是什么?不显式开会发生什么?- 为什么索引不是越多越好?怎么用
EXPLAIN QUERY PLAN判断该不该建?
参考
- 实测环境:macOS(Darwin 24.6.0)/ Node v24.14.1;
node:sqlite→ SQLite 3.51.2,better-sqlite3@13.0.3→ SQLite 3.53.4 - 本文指标脚本(随仓库留档,可复现):
source/sqlite-bench/bench.mjs(三种写入写法 + journal 对比 + all/iterate)、source/sqlite-bench/details.mjs(PRAGMA 组合矩阵 + EXPLAIN QUERY PLAN);依赖与跑法见同目录README.md - SQLite 官方文档:sqlite.org/docs.html(PRAGMA 各参数的权威定义)
- SQLite 架构说明:sqlite.org/arch.html(B-tree / 页 / 页缓存的原始描述)
- WAL 说明:sqlite.org/wal.html