创建日期:2026-09-17 | 最近更新:2026-09-17 全部代码与输出均为本机实测(Node v24.14.1):
node:sqlite(内置,SQLite 3.51.2)、better-sqlite313.0.3(SQLite 3.53.4)、@libsql/client0.18.0、sql.js1.14.2。四个驱动装了同一台机器、跑同一个任务,报错原文与返回形状都是真实输出。
SQLite 驱动写法横向对比:同一件事的四种写法
第 0 篇讲的是「怎么让 SQLite 快」;这篇讲**「选哪个驱动、各怎么写」。同一句
INSERT ... VALUES (?, ?),四个库的写法、返回形状、错误码、大整数处理全都不一样**——而其中有一条差异会静默丢数据。
1. 先看四个选手
node:sqlite | better-sqlite3 | @libsql/client | sql.js | |
|---|---|---|---|---|
| 来源 | Node 内置 | npm 原生扩展 | npm(Turso) | npm(WASM) |
| 依赖 | 零 | 原生模块(有预编译包) | 原生 + 可选远程 | WASM 文件 |
| SQLite 版本 | 3.51.2 | 3.53.4 | 内置 | 1.14.2 对应版本 |
| API 形态 | 同步 | 同步 | 异步 | 同步 |
| 持久化 | 文件 | 文件 | 文件 / 远程 | 纯内存,需手动导出 |
| 状态 | ExperimentalWarning | 生产验证充分 | 生产可用 | 生产可用 |
三点先说明白:
node:sqlite在 Node 24 已可不加 flag 使用(实测 Node v24.14.1 直接import即可),但仍会打印ExperimentalWarning: SQLite is an experimental feature and might change at any time。它是唯一的零依赖选项,也是唯一 API 可能变动的选项。- SQLite 引擎版本并不一致:
better-sqlite3带的是 3.53.4,node:sqlite是 3.51.2。如果你需要某个较新的 SQLite 特性,得先确认内置版本有没有。 sql.js是 WASM、纯内存——这不是缺点而是定位:它适合浏览器/沙箱,但「存到磁盘」得你自己export()+ 写文件。
2. 同一个任务,四种写法
任务固定:建表 → 用命名参数插一行 → 查询 → 触发唯一约束冲突 → 事务回滚。
2.1 node:sqlite(内置,同步)
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('./app.db');
db.exec(`CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, score REAL)`);
// 命名参数:$name / :name / @name 都行,用对象传入
const ins = db.prepare('INSERT INTO user (name, score) VALUES ($n, $s)');
console.log(ins.run({ n: 'alice', s: 9.5 }));
// -> { changes: 1, lastInsertRowid: 1 }
console.log(db.prepare('SELECT * FROM user WHERE name = ?').get('alice'));
// -> { id: 1, name: 'alice', score: 9.5 }
// 事务:必须手写
db.exec('BEGIN');
db.prepare('INSERT INTO user (name) VALUES (?)').run('carol');
db.exec('ROLLBACK');
要点:run() 返回 { changes, lastInsertRowid };事务没有封装,靠 db.exec('BEGIN'/'COMMIT'/'ROLLBACK')。
2.2 better-sqlite3(同步,生态最成熟)
import Database from 'better-sqlite3';
const db = new Database('./app.db');
db.exec(`CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, score REAL)`);
const ins = db.prepare('INSERT INTO user (name, score) VALUES (@n, @s)');
console.log(ins.run({ n: 'alice', s: 9.5 }));
// -> { changes: 1, lastInsertRowid: 1 }
console.log(db.prepare('SELECT * FROM user WHERE name = ?').pluck().get('alice'));
// -> 'alice' ← pluck():直接拿单列,不包对象
// 事务:有封装,抛错自动回滚,嵌套用 SAVEPOINT
const insertMany = db.transaction((names) => {
for (const n of names) db.prepare('INSERT INTO user (name) VALUES (?)').run(n);
});
try { insertMany(['carol', 'dave', 'alice']); } // 'alice' 重复 -> 抛错
catch (e) { console.log(e.code); } // SQLITE_CONSTRAINT_UNIQUE
// 实测:抛错后行数仍是 2 —— 自动回滚生效了
要点:db.transaction() 是它最被低估的功能——自动 BEGIN/成功 COMMIT/抛错 ROLLBACK,还支持嵌套(内部用 SAVEPOINT,实测嵌套后 ['alice','bob','erin','frank'] 都在)。另有 pluck()(取单值)、raw()(取数组)等取数修饰器。这些是它相对内置库最实际的领先。
2.3 @libsql/client(异步)
import { createClient } from '@libsql/client';
const db = createClient({ url: 'file:./app.db' }); // 也可指向远程
await db.execute(`CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, score REAL)`);
const r = await db.execute({
sql: 'INSERT INTO user (name, score) VALUES (?, ?)',
args: ['alice', 9.5],
});
console.log(r.rowsAffected, String(r.lastInsertRowid)); // -> 1 '1'
注意返回形状——这是最容易踩的地方:
await db.execute('SELECT name FROM user');
// -> { columns: ['name'], columnTypes: ['TEXT'], rows: [ ['alice'] ], rowsAffected: 0, lastInsertRowid: null }
// ^^^^^^ 是数组的数组,不是对象数组!
事务用对象式:
const tx = await db.transaction('write');
await tx.execute("INSERT INTO user (name) VALUES ('carol')");
await tx.rollback();
命名参数实测也支持(@n / :nm / $p 三种都取到了同一行)。
2.4 sql.js(WASM,纯内存)
import initSqlJs from 'sql.js';
const SQL = await initSqlJs(); // 先加载 wasm
const db = new SQL.Database(); // 注意:无文件参数
db.run(`CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, score REAL)`);
const stmt = db.prepare('INSERT INTO user (name, score) VALUES ($n, $s)');
stmt.run({ $n: 'alice', $s: 9.5 });
stmt.free(); // ★ 必须手动释放
db.exec('SELECT name, score FROM user');
// -> [ { columns: ['name','score'], values: [ ['alice', 9.5] ] } ]
// 又是另一种形状:{columns, values}
// 想持久化?自己导出
import { writeFileSync } from 'node:fs';
writeFileSync('./app.db', db.export()); // 实测这个空库 12288 字节
要点:exec() 返回 {columns, values};prepare() 出来的 statement 必须手动 free()(否则内存泄漏);持久化完全靠你自己 export() 写文件。
3. 横向对比矩阵
| 维度 | node:sqlite | better-sqlite3 | @libsql/client | sql.js |
|---|---|---|---|---|
| 打开 | new DatabaseSync(p) | new Database(p) | await createClient({url}) | new SQL.Database() |
| 执行 DDL | db.exec(sql) | db.exec(sql) | await db.execute(sql) | db.run(sql) |
| 插入 | stmt.run(v) | stmt.run(v) | await db.execute({sql,args}) | stmt.run(v) + free() |
| 取一行 | stmt.get() | stmt.get() | res.rows[0](数组) | stmt.getAsObject() |
| 取全部 | stmt.all() | stmt.all() | res.rows(数组的数组) | {columns, values} |
| 游标 | stmt.iterate() | stmt.iterate() | 无(需分页/stream) | step() |
| 命名参数 | $n/:n/@n + 对象 | @n/:n/$n + 对象 | ? + 数组;@/:/$ 亦可 | $n + 对象 |
| 事务封装 | ❌ 手写 | ✅ db.transaction() | ✅ db.transaction() | ❌ 手写 |
| 嵌套事务 | 手写 SAVEPOINT | ✅ 自动 SAVEPOINT | ✅ | 手写 |
| 批量 | 手写循环 | 手写循环 / transaction() | ✅ db.batch() | 手写循环 |
| 返回值 | {changes,lastInsertRowid} | {changes,lastInsertRowid} | {rowsAffected,lastInsertRowid} | 无返回 |
| 关联查询 | stmt.columns() | .pluck() / .raw() / .expand() | 无修饰器 | 无 |
| BLOB 读回 | Uint8Array | Buffer | ArrayBuffer/Uint8Array | Uint8Array |
| 异步 | ❌ 全同步 | ❌ 全同步 | ✅ | ❌ |
| 依赖 | 0 | 原生模块 | 原生 + 可选远程 | wasm 文件 |
4. 五个会咬人的差异(都是实测)
4.1 ★ 大整数:一个炸,一个静默丢精度
这是全文最危险的一条。SQLite 的 INTEGER 是 64 位,而 JS 的 Number 只能安全表示到 2^53-1。往表里塞 9007199254740993(MAX_SAFE_INTEGER + 2)再读回来:
node:sqlite 默认 -> 抛错!RangeError: Value is too large to be represented as a
JavaScript number: 9007199254740993 (code = ERR_OUT_OF_RANGE)
node:sqlite setReadBigInts(true) -> 9007199254740993n ✓ 无损
better-sqlite3 默认 -> 9007199254740992 ✗ 静默丢精度!少了 1
better-sqlite3 defaultSafeIntegers(true) -> 9007199254740993n ✓ 无损
两种失败模式,哪个更糟?
node:sqlite当场抛错——吵,但你立刻知道有问题;better-sqlite3默认静默返回错的数——它不报错,只是把你的 ID 悄悄改了 1。如果你的主键/雪花 ID/金额超了 2^53,这是无声的数据损坏。
两个驱动都可以开无损模式,代价与差异:
// node:sqlite:按 statement 开(实测:小整数仍是 number)
const st = db.prepare('SELECT big FROM t');
st.setReadBigInts(true);
// better-sqlite3:按数据库开(实测:连 42 都变成 BigInt(42))
db.defaultSafeIntegers(true);
实践建议:大 ID 一律存成 TEXT,从根上绕开这个问题。真要存 64 位整数,就显式开无损模式,并且记住
better-sqlite3开了之后连小整数都变 BigInt,JSON.stringify会直接抛错(BigInt 不可序列化)——需要JSON.stringify(v, (k, x) => typeof x === 'bigint' ? x.toString() : x)。
4.2 返回形状:三种不同的「查完了」
node:sqlite / better-sqlite3 [ { name: 'alice' } ] 对象数组
@libsql/client { columns:['name'], rows: [['alice']] } 数组的数组
sql.js [ { columns:['name'], values:[['alice']] } ]
这四个形状互不兼容——所以任何「换个驱动试试」的迁移,取数那一层必然要改。@libsql/client 尤其容易写错:res.rows[0].name 是 undefined,正确写法是 res.rows[0][0]。
4.3 错误码:三套命名
同一个「唯一约束冲突」,三个库给出三种 code:
node:sqlite Error: UNIQUE constraint failed: user.name
e.code = ERR_SQLITE_ERROR | e.errcode = 2067 | e.errstr = 'constraint failed'
better-sqlite3 SqliteError: UNIQUE constraint failed: user.name
e.code = SQLITE_CONSTRAINT_UNIQUE
@libsql/client LibsqlError: SQLITE_CONSTRAINT: UNIQUE constraint failed: user.name
e.code = SQLITE_CONSTRAINT | e.cause.code = SQLITE_CONSTRAINT_UNIQUE
唯一稳定的抓手是 e.message 里的 UNIQUE constraint failed 文本;想按 code 分支就得为每个驱动写一层映射。@libsql/client 把细粒度码藏在 e.cause 里,别只看 e.code。
4.4 参数绑定失败:只有内置库会明确报错
往里塞一个对象当参数值(stmt.run('dave', { nested: true })):
node:sqlite -> TypeError: Provided value cannot be bound to SQLite parameter 2.
node:sqlite 抛的是清晰的 TypeError,直接告诉你第几个参数不合法——这条体验比另外几个驱动好。
4.5 异步性决定了你的架构
node:sqlite / better-sqlite3 是同步的,意味着:
- ✅ 代码简单、没有
await传染、事务边界清晰、天然顺序一致; - ❌ 会阻塞事件循环。单条查询是微秒级,无所谓;但扫 20 万行(实测 136 ms)会卡住整个 Node 进程。
// 同步驱动跑重查询:整个事件循环停住
const rows = db.prepare('SELECT * FROM big').all(); // 136ms 内什么都干不了
对策(原理):
- 重查询分页/加 LIMIT,别一次物化;
- 用
worker_threads把重活挪出主线程(better-sqlite3文档也是这个建议); - 或者选异步驱动(
@libsql/client),但异步并不等于并行——它只是把等待交给了别的线程/连接,你仍然要面对事务边界与竞态。
这是选型里最根本的一条差异:不是「谁快」,而是**「你愿意让数据库操作占住你的进程吗」**。
5. 选型表
| 场景 | 建议 |
|---|---|
| Node 服务 / CLI 工具,想要零依赖 | node:sqlite(接受 ExperimentalWarning 与 API 可能变动) |
| 生产项目、要成熟生态与事务封装 | better-sqlite3(db.transaction()、pluck()、用户量大) |
| 需要远程库 / 边缘部署 / 多端同步 | @libsql/client(Turso) |
| 浏览器 / 沙箱 / 无原生模块环境 | sql.js(记得手动持久化) |
| 想要异步不阻塞 | @libsql/client;或同步驱动 + worker_threads |
一个常被问的组合:
node:sqlite+ 手写一层薄封装。零依赖 + 自己包一个transaction()助手(几十行),就能拿到better-sqlite3八成的开发体验,代价是没有pluck/raw/expand那些取数修饰器。
6. 写法建议:把差异关进适配层
既然四个驱动的取数形状、错误码、事务 API 都不同,别让业务代码直接依赖某个驱动。把差异收在一个薄适配层里,业务只认统一接口:
// 目标接口:统一「rows 是对象数组」「事务是函数」「错误码归一」
export function createDb(url) {
// 内部按驱动实现 adapt():
// 查完统一 rows -> Record<string, unknown>[]
// (libsql 要把 rows[0][0] 映射回 columns)
// 事务统一 db.tx(fn):内部 BEGIN/COMMIT/ROLLBACK,
// 或直接委托 better-sqlite3 的 db.transaction(fn)
// 错误统一成 { kind: 'unique' | 'foreign-key' | ..., raw: e }
// (映射 ERR_SQLITE_ERROR+2067 / SQLITE_CONSTRAINT_UNIQUE / e.cause.code)
// 大整数统一策略:要么全 TEXT 存,要么全开无损 + 统一序列化
}
三条落地原则:
- prepared statement 一定缓存复用(第 0 篇实测:光这一步就是 3~8 倍);
- 取数一律转成对象数组再进业务,别让
res.rows[0][0]这种写法漏到业务层; - 大整数提前定策略——这是唯一一个「不处理就会静默错」的差异,值得在项目初期就钉死。
关联
- 前置:高性能 SQLite 理论分析入门——为什么事务和 prepared statement 能差 280 倍、PRAGMA 怎么调
- 同类入门(服务端数据库):MySQL 系列
- 另一个「同一任务、两套框架各写一遍」的对照:LangChain 还是 pi-agent?
- 结构同源的架构对照:InkOS 为什么不用 LangGraph
自测
- 四个驱动里,唯一零依赖的是哪个?它现在还带什么警告?
better-sqlite3默认读大整数会发生什么?为什么说这比node:sqlite抛错更危险?@libsql/client查询返回的rows是什么形状?写res.rows[0].name会得到什么?- 同一个唯一约束冲突,三个驱动给出的
code分别是什么?哪个驱动把细粒度码藏在cause里? - 同步驱动最大的架构风险是什么?实测里哪个数字说明了这个风险?
- 为什么建议把驱动差异收进适配层?至少说出两类必须归一化的差异。
参考
- 实测环境:macOS(Darwin 24.6.0)/ Node v24.14.1;
node:sqlite→ SQLite 3.51.2,better-sqlite313.0.3 → SQLite 3.53.4,@libsql/client0.18.0,sql.js1.14.2 - 本文脚本(随仓库留档,可复现):
source/sqlite-bench/api-compare.mjs(四驱动同任务写法与报错原文)、source/sqlite-bench/details.mjs(大整数/BLOB/命名参数/EXPLAIN 探针);依赖与跑法见同目录README.md - Node 官方文档:nodejs.org/api/sqlite.html
- better-sqlite3:github.com/WiseLibs/better-sqlite3
- libSQL / Turso:github.com/tursodatabase/libsql-client-ts
- sql.js:github.com/sql-js/sql.js