sql.js是SQLite的纯WASM编译版本,无原生依赖,安装即用。数据库完全在内存中操作,需手动调用export()持久化至文件。支持定时自动保存、嵌套事务、增量schema迁移,适合单用户本地数据库场景,性能较原生版本低约20-30%。
sql.js的WASM深度解析
作者:技术拾光
时间:2026-06-04
浏览:119
文章目录
在Node.js生态中,提到SQLite绑定,很多人首先想到better-sqlite3——性能出色、API简洁、同步调用省心。但它有一个硬伤:C++原生编译。这意味着安装时需要配置Python和C++编译工具链,Electron打包时要针对目标平台重新编译,不同Node版本还需更换预编译二进制……令人烦恼。
- 安装时需要Python + C++编译工具链
- Electron打包时需要针对目标平台重新编译
- 不同Node.js/Electron版本需要不同的预编译二进制
sql.js是SQLite的纯WASM编译版本,通过Emscripten将SQLite的C源码编译成WebAssembly。它没有原生依赖,只需
源码参考:db/index.ts · db/schema.ts · db/transaction.ts · db/utils.ts · parser/vscdb.ts
为什么不用 better-sqlite3
better-sqlite3是原生C++ addon,依赖node-gyp编译,由此带来几个问题:
- 安装时需要Python + C++编译工具链
- Electron打包时需要针对目标平台重新编译
- 不同Node.js/Electron版本需要不同的预编译二进制
sql.js是SQLite的纯WASM编译版本,通过Emscripten将SQLite的C源码编译成WebAssembly。它没有原生依赖,只需npm install sql.js即可使用,无需编译。代价是性能略低于原生版本(约慢20-30%),但对于ChatCrystal这类场景(单用户、本地数据库、非高频写入)完全够用。
初始化流程
数据库初始化在server/src/db/index.ts的initDatabase()中:
export async function initDatabase(): Promise {
const sqlJsOptions = process.env.ELECTRON_PACKAGED
? { locateFile: () => join(process.resourcesPath, 'sql-wasm.wasm') }
: undefined;
const SQL = await initSqlJs(sqlJsOptions);
if (existsSync(DB_PATH)) {
const buffer = readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = WAL;');
db.run('PRAGMA foreign_keys = ON;');
applySchemaMigrations(db);
saveDatabase();
return db;
}
关键步骤如下:
1. **WASM定位:** 打包后的Electron应用中,WASM文件通过electron-builder.yml的extraResources配置复制到resources/目录。locateFile回调告诉sql.js去哪查找该文件。开发环境下使用默认路径(node_modules内)。
2. **数据库加载:** 如果数据库文件已存在,用readFileSync读取整个文件到Buffer,传给new SQL.Database(buffer)。这是sql.js的核心特性——它在内存中操作数据库,初始化时需要把整个文件加载进内存。
3. **PRAGMA设置:** 启用WAL(Write-Ahead Logging)模式和外键约束。WAL模式在sql.js中意义有限(因为没有真正的并发写入),但保持与原生SQLite的一致性。
4. **Schema迁移:** applySchemaMigrations()执行建表SQL和增量迁移。
内存模型:全量加载,手动持久化
sql.js的数据库完全在内存中,这意味着: - 所有读写操作都在内存中完成,速度很快 - 但修改不会自动写入磁盘 - 需要手动调用db.export()获取数据库的二进制表示,然后写入文件
saveDatabase()函数负责持久化:
export function saveDatabase(): void {
if (!db) return;
const data = exportDatabasePreservingForeignKeys(db);
const buffer = Buffer.from(data);
writeFileSync(DB_PATH, buffer);
}
exportDatabasePreservingForeignKeys()是一个包装函数,专门处理sql.js的一个隐藏陷阱:
export function exportDatabasePreservingForeignKeys(activeDb: Database): Uint8Array {
try {
return activeDb.export();
} finally {
activeDb.run('PRAGMA foreign_keys = ON;');
}
}
db.export()会重置数据库连接的所有PRAGMA设置,包括foreign_keys。因此export之后必须重新启用外键约束。这个bug在sql.js的issue中有记录,ChatCrystal用wrapper函数统一处理。
自动保存机制
手动保存容易遗漏,因此ChatCrystal实现了定时自动保存:let saveInterval: ReturnType | null = null;
export function startAutoSave(intervalMs = 30_000): void {
if (saveInterval) return;
saveInterval = setInterval(() => saveDatabase(), intervalMs);
}
默认每30秒保存一次。为什么选这个间隔?这里有一个权衡:
- 太频繁:db.export()需要序列化整个数据库到内存,频繁调用会增加内存压力
- 太稀疏:进程崩溃时可能丢失最近30秒的数据
- 30秒是一个合理的折中
除定时保存外,关键操作后也会主动保存,例如导入完成后立即调用saveDatabase(),确保新导入的数据不会因意外退出而丢失。
resultToObjects:查询结果的标准化
sql.js的db.exec()返回格式是[{ columns: string[], values: unknown[][] }]——列名数组 + 二维值数组。这种格式不够直观,ChatCrystal提供了一个工具函数:
export function resultToObjects(
result: { columns: string[]; values: unknown[][] }[],
): Record[] {
if (!result.length) return [];
const { columns, values } = result[0];
return values.map((row) => {
const obj: Record = {};
columns.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
}
它将[{columns: ["id", "name"], values: [["1", "foo"]]}]转换为[{id: "1", name: "foo"}]。在路由处理中广泛使用,使代码更易读。
事务支持:嵌套SAVEPOINT
server/src/db/transaction.ts实现了支持嵌套的事务包装器:
const depthMap = new WeakMap();
function setDepth(db: Database, depth: number): void {
if (depth === 0) depthMap.delete(db);
else depthMap.set(db, depth);
}
export function withTransaction(db: Database, fn: () => T): T {
const depth = depthMap.get(db) ?? 0;
const isNested = depth > 0;
const savepointName = `sp_${depth}`;
if (isNested) {
db.run(`SAVEPOINT ${savepointName}`);
} else {
db.run('BEGIN');
}
setDepth(db, depth + 1);
try {
const result = fn();
if (isNested) {
db.run(`RELEASE ${savepointName}`);
} else {
db.run('COMMIT');
}
setDepth(db, depth);
return result;
} catch (error) {
if (isNested) {
db.run(`ROLLBACK TO ${savepointName}`);
db.run(`RELEASE ${savepointName}`);
} else {
db.run('ROLLBACK');
}
setDepth(db, depth);
throw error;
}
}
使用WeakMap跟踪每个数据库实例的事务嵌套深度。顶层事务用BEGIN/COMMIT/ROLLBACK,嵌套事务用SAVEPOINT/RELEASE/ROLLBACK TO。这保证了导入服务中的事务是原子的——如果一条对话的解析或入库失败,整个对话的写入都会回滚,不会留下半成品数据。
Schema迁移:无ORM的增量方案
没有ORM意味着schema迁移需要手动管理。ChatCrystal的策略如下: 1.SCHEMA_SQL包含所有CREATE TABLE IF NOT EXISTS和CREATE INDEX IF NOT EXISTS,幂等执行,不会重复创建。
2. applySchemaMigrations()中的ensureColumn()函数处理增量列迁移:
function ensureColumn(db: Database, table: string, column: string, sql: string) {
const info = db.exec(`PRAGMA table_info(${table})`);
const columns = info[0]?.values.map((row) => String(row[1])) [];
if (!columns.includes(column)) {
db.run(sql);
}
}
用PRAGMA table_info检查列是否存在,不存在就执行ALTER TABLE ADD COLUMN。这种模式比版本号迁移更简单,适合单机应用场景。
ensureIndexColumns()处理索引的增量更新——如果索引的列定义变了,先删后建:
function ensureIndexColumns(db, indexName, expectedColumns, createSql) {
const info = db.exec(`PRAGMA index_info('${indexName}')`);
const columns = info[0]?.values.map((row) => String(row[2])) ?? [];
const isCurrent = columns.length === expectedColumns.length &&
columns.every((column, index) => column === expectedColumns[index]);
if (!isCurrent) {
db.run(`DROP INDEX IF EXISTS ${indexName}`);
db.run(createSql);
}
}
与VSCDB的复用
Cursor和Trae的适配器需要读取VS Code的state.vscdb文件。这些文件也是SQLite,但由VS Code进程持有锁。ChatCrystal的openVscdb()函数用sql.js以只读方式打开:
export async function openVscdb(dbPath: string): Promise {
try {
const SQL = await getSqlJs();
const buf = readFileSync(dbPath);
return new SQL.Database(buf);
} catch {
await new Promise((r) => setTimeout(r, 500));
try {
const SQL = await getSqlJs();
const buf = readFileSync(dbPath);
return new SQL.Database(buf);
} catch {
return null;
}
}
}
由于sql.js把整个文件读入内存再创建数据库实例,它不持有文件句柄——读完即可释放。这天然避免了与VS Code进程的文件锁冲突。如果读取时文件被锁(VS Code正在写入),等待500ms重试一次。
sql.js实例通过模块级单例复用:
let sqlJsInstance: Awaited> | null = null;
async function getSqlJs() {
if (!sqlJsInstance) sqlJsInstance = await initSqlJs();
return sqlJsInstance;
}
WASM模块只需初始化一次,后续所有数据库实例共享同一个WASM运行时。
性能与限制
sql.js的主要限制: - 内存占用:整个数据库加载到内存。ChatCrystal的典型数据库大小在几MB到几十MB,完全在可接受范围内。 - 并发:单线程操作,没有真正的并发写入。但ChatCrystal的写入场景(导入、摘要生成)本身就是串行的(p-queue并发度为1),因此这不是问题。 - export开销:db.export()需要序列化整个数据库。30秒自动保存一次,对于10MB的数据库,序列化耗时在毫秒级。
- 无WAL支持:sql.js的WAL模式是模拟的,没有真正的checkpoint机制。但对于单进程应用,这不影响数据安全。
总结
sql.js使ChatCrystal避免了原生编译的麻烦,同时提供了完整的SQLite功能。内存模型虽然限制了数据库大小的上限,但对于本地知识库应用完全够用。自动保存 + 事务支持 + 增量迁移,三个机制组合起来保证了数据的持久性和一致性。openVscdb()的只读内存打开方式,巧妙地解决了与VS Code进程的文件锁冲突问题。
源码参考:db/index.ts · db/schema.ts · db/transaction.ts · db/utils.ts · parser/vscdb.ts
实用工具推荐
电子包浆
使用 ›绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
图片压缩
使用 ›选择画质和输出格式,预览压缩前后的大小。
相关文章