跳到主要内容

创建日期:2026-09-17 | 最近更新:2026-09-17 全部代码与输出均为本机实测(Node v24.14.1):node:sqlite(内置,SQLite 3.51.2)、better-sqlite3 13.0.3(SQLite 3.53.4)、@libsql/client 0.18.0sql.js 1.14.2。四个驱动装了同一台机器、跑同一个任务,报错原文与返回形状都是真实输出。

SQLite 驱动写法横向对比:同一件事的四种写法

第 0 篇讲的是「怎么让 SQLite 快」;这篇讲**「选哪个驱动、各怎么写」。同一句 INSERT ... VALUES (?, ?),四个库的写法、返回形状、错误码、大整数处理全都不一样**——而其中有一条差异会静默丢数据

1. 先看四个选手

node:sqlitebetter-sqlite3@libsql/clientsql.js
来源Node 内置npm 原生扩展npm(Turso)npm(WASM)
依赖原生模块(有预编译包)原生 + 可选远程WASM 文件
SQLite 版本3.51.23.53.4内置1.14.2 对应版本
API 形态同步同步异步同步
持久化文件文件文件 / 远程纯内存,需手动导出
状态ExperimentalWarning生产验证充分生产可用生产可用

三点先说明白:

  1. node:sqlite 在 Node 24 已可不加 flag 使用(实测 Node v24.14.1 直接 import 即可),但仍会打印 ExperimentalWarning: SQLite is an experimental feature and might change at any time。它是唯一的零依赖选项,也是唯一 API 可能变动的选项。
  2. SQLite 引擎版本并不一致better-sqlite3 带的是 3.53.4,node:sqlite 是 3.51.2。如果你需要某个较新的 SQLite 特性,得先确认内置版本有没有。
  3. 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:sqlitebetter-sqlite3@libsql/clientsql.js
打开new DatabaseSync(p)new Database(p)await createClient({url})new SQL.Database()
执行 DDLdb.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 读回Uint8ArrayBufferArrayBuffer/Uint8ArrayUint8Array
异步❌ 全同步❌ 全同步
依赖0原生模块原生 + 可选远程wasm 文件

4. 五个会咬人的差异(都是实测)

4.1 ★ 大整数:一个炸,一个静默丢精度

这是全文最危险的一条。SQLite 的 INTEGER 是 64 位,而 JS 的 Number 只能安全表示到 2^53-1。往表里塞 9007199254740993MAX_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 开了之后连小整数都变 BigIntJSON.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].nameundefined,正确写法是 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-sqlite3db.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 存,要么全开无损 + 统一序列化
}

三条落地原则:

  1. prepared statement 一定缓存复用(第 0 篇实测:光这一步就是 3~8 倍);
  2. 取数一律转成对象数组再进业务,别让 res.rows[0][0] 这种写法漏到业务层;
  3. 大整数提前定策略——这是唯一一个「不处理就会静默错」的差异,值得在项目初期就钉死。

关联

自测

  1. 四个驱动里,唯一零依赖的是哪个?它现在还带什么警告?
  2. better-sqlite3 默认读大整数会发生什么?为什么说这比 node:sqlite 抛错更危险?
  3. @libsql/client 查询返回的 rows 是什么形状?写 res.rows[0].name 会得到什么?
  4. 同一个唯一约束冲突,三个驱动给出的 code 分别是什么?哪个驱动把细粒度码藏在 cause 里?
  5. 同步驱动最大的架构风险是什么?实测里哪个数字说明了这个风险?
  6. 为什么建议把驱动差异收进适配层?至少说出两类必须归一化的差异。

参考