跳到主要内容

创建日期:2026-09-17 | 最近更新:2026-09-17 本文所有数字均为本机实测(macOS / Darwin 24.6.0,Node v24.14.1):node:sqlite 对应 SQLite 3.51.2better-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——原因很简单:

SQLiteMySQL / Postgres
进程边界没有——库直接编进你的进程客户端进程 → 网络 → 服务端进程
一次查询的成本函数调用 + 内存/页缓存查找序列化 + 网络往返 + 协议解析 + 服务端调度
部署一个文件一个服务 + 连接池 + 运维
并发写单写者(这是它真正的限制)多写者

一句话:SQLite 省掉的不是「数据库能力」,而是**「客户端/服务端之间的那一段」。这也是为什么它敢把 API 设计成同步**的(node:sqlitebetter-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:sqlitebetter-sqlite3不会帮你调这些。默认值就是 SQLite 的默认值(实测 better-sqlite3journal_mode=deletesynchronous=2(FULL)),所以生产环境必须自己设 PRAGMA

3. 本文的支点:写入成本 = 提交次数 × fsync

这是全文最值钱的一段。SQLite 写性能的核心事实是:

一次「提交」(commit)至少要一次 fsync。而 fsync 是毫秒级的。

默认 synchronous = FULL 时,SQLite 为了「断电不丢已提交数据」,必须把数据真正刷到磁盘才敢返回。所以:

  • 每条 INSERT 单独提交(autocommit,也就是不写事务)→ N 行 = N 次 fsync
  • 用一个事务包住 N 条 INSERT → N 行 = 1 次 fsync

实测(5000 行,两个同步驱动,同一台机器):

写法better-sqlite3node:sqlite
A 逐条 insert(无事务)3650.7 ms(730.1 µs/行3304.2 ms(660.8 µs/行)
B 显式事务,但每次重新 prepare110.5 ms(22.1 µs/行)38.1 ms(7.6 µs/行)
C 显式事务 + 复用 prepared statement13.0 ms(2.6 µs/行)14.3 ms(2.9 µs/行)
D better-sqlite3db.transaction() 包装14.8 ms(3.0 µs/行)—(无此 API)

A → C 是 280 倍。 分解一下这 280 倍来自两处:

  1. 事务:A → B,把 N 次 fsync 变成 1 次(这一步贡献了绝大部分,约 33~87 倍);
  2. 复用 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_modesynchronous3000 行逐条 insert
deleteFULL(默认)1780 ms
deleteNORMAL1841 ms
deleteOFF1224 ms
walFULL327 ms
walNORMAL83 ms
walOFF65 ms

三个可迁移的结论:

  1. WAL 把 autocommit 写提升了 5~21 倍(1780 → 327 / 83 ms)。因为它把「随机写回原文件」换成了顺序追加到 -wal 文件
  2. synchronous=NORMAL 只在 WAL 模式下才划算——注意 delete + NORMAL(1841ms)和 delete + FULL(1780ms)几乎一样慢。这是个很常见的误解:在回滚日志模式下,NORMAL 省不下那次关键 fsync;
  3. 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_size4096新建库时可设 4096(对齐文件系统块);已建库改需 VACUUMIO 最小单位
cache_size-2000(2MB)按可用内存设,如 -64000(64MB)页缓存大小
mmap_size0读多写少可设 256MB用内存映射读文件
journal_modedeleteWAL日志模式(见 §4)
synchronousFULLWAL 下用 NORMALfsync 强度
foreign_keysOFF建议 ON外键约束常被默认关掉
temp_store0复杂查询可用 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 写对,再谈别的。

关联

自测

  1. 为什么 A 逐条 insertC 事务+复用 prepare 能差 280 倍?这 280 倍分别来自哪两处?
  2. 为什么说「synchronous=NORMAL 只在 WAL 模式下才划算」?实测里哪两个数字支持这个结论?
  3. WAL 相比 delete 模式,两个真正的价值是什么?对「已用事务的批量写」,实测增益大概多少?
  4. 为什么大结果集下 all() 可能比 iterate() 更快?那什么时候必须用 iterate()
  5. foreign_keys 的默认值是什么?不显式开会发生什么?
  6. 为什么索引不是越多越好?怎么用 EXPLAIN QUERY PLAN 判断该不该建?

参考

  • 实测环境:macOS(Darwin 24.6.0)/ Node v24.14.1node: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