sql.js WASM 深度解析
本文面向:想用纯 WASM 版 SQLite 避免原生编译的 Node.js / Electron 开发者。
预计阅读时间:10 分钟
最终效果:理解 sql.js 的内存模型、自动保存、嵌套事务、增量迁移,以及只读打开 VS Code state.vscdb 的方案。
为什么不用 better-sqlite3
Node.js 生态中最流行的 SQLite 绑定是 better-sqlite3,它性能好、API 简洁、同步调用不需要 async/await。但 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()中:
exportasyncfunctioninitDatabase():Promise<Database>{constsqlJsOptions=process.env.ELECTRON_PACKAGED?{locateFile:()=>join(process.resourcesPath,'sql-wasm.wasm')}:undefined;constSQL=awaitinitSqlJs(sqlJsOptions);if(existsSync(DB_PATH)){constbuffer=readFileSync(DB_PATH);db=newSQL.Database(buffer);}else{db=newSQL.Database();}db.run('PRAGMA journal_mode = WAL;');db.run('PRAGMA foreign_keys = ON;');applySchemaMigrations(db);saveDatabase();returndb;}关键步骤:
WASM 定位:打包后的 Electron 应用中,WASM 文件通过
electron-builder.yml的extraResources配置复制到resources/目录。locateFile回调告诉 sql.js 去哪找这个文件。开发环境下使用默认路径(node_modules 内)。数据库加载:如果数据库文件已存在,用
readFileSync读取整个文件到Buffer,传给new SQL.Database(buffer)。这是 sql.js 的核心特性——它在内存中操作数据库,初始化时需要把整个文件加载进内存。PRAGMA 设置:启用 WAL(Write-Ahead Logging)模式和外键约束。WAL 模式在 sql.js 中的意义有限(因为没有真正的并发写入),但保持与原生 SQLite 的一致性。
Schema 迁移:
applySchemaMigrations()执行建表 SQL 和增量迁移。
内存模型:全量加载,手动持久化
sql.js 的数据库完全在内存中。这意味着:
- 所有读写操作都在内存中完成,速度很快
- 但修改不会自动写入磁盘
- 需要手动调用
db.export()获取数据库的二进制表示,然后写入文件
saveDatabase()函数负责持久化:
exportfunctionsaveDatabase():void{if(!db)return;constdata=exportDatabasePreservingForeignKeys(db);constbuffer=Buffer.from(data);writeFileSync(DB_PATH,buffer);}exportDatabasePreservingForeignKeys()是一个包装函数,处理 sql.js 的一个陷阱:
exportfunctionexportDatabasePreservingForeignKeys(activeDb:Database):Uint8Array{try{returnactiveDb.export();}finally{activeDb.run('PRAGMA foreign_keys = ON;');}}db.export()会重置数据库连接的所有 PRAGMA 设置,包括foreign_keys。所以在 export 之后必须重新启用外键约束。这个 bug 在 sql.js 的 issue 中有记录,ChatCrystal 用 wrapper 函数统一处理。
自动保存机制
手动保存容易遗漏,所以 ChatCrystal 实现了定时自动保存:
letsaveInterval:ReturnType<typeofsetInterval>|null=null;exportfunctionstartAutoSave(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 提供了一个工具函数:
exportfunctionresultToObjects(result:{columns:string[];values:unknown[][]}[],):Record<string,unknown>[]{if(!result.length)return[];const{columns,values}=result[0];returnvalues.map((row)=>{constobj:Record<string,unknown>={};columns.forEach((col,i)=>{obj[col]=row[i];});returnobj;});}把[{columns: ["id", "name"], values: [["1", "foo"]]}]转成[{id: "1", name: "foo"}]。在路由处理中广泛使用,让代码更可读。
事务支持:嵌套 SAVEPOINT
server/src/db/transaction.ts实现了支持嵌套的事务包装器:
constdepthMap=newWeakMap<Database,number>();functionsetDepth(db:Database,depth:number):void{if(depth===0)depthMap.delete(db);elsedepthMap.set(db,depth);}exportfunctionwithTransaction<T>(db:Database,fn:()=>T):T{constdepth=depthMap.get(db)??0;constisNested=depth>0;constsavepointName=`sp_${depth}`;if(isNested){db.run(`SAVEPOINT${savepointName}`);}else{db.run('BEGIN');}setDepth(db,depth+1);try{constresult=fn();if(isNested){db.run(`RELEASE${savepointName}`);}else{db.run('COMMIT');}setDepth(db,depth);returnresult;}catch(error){if(isNested){db.run(`ROLLBACK TO${savepointName}`);db.run(`RELEASE${savepointName}`);}else{db.run('ROLLBACK');}setDepth(db,depth);throwerror;}}用WeakMap<Database, number>跟踪每个数据库实例的事务嵌套深度。顶层事务用BEGIN/COMMIT/ROLLBACK,嵌套事务用SAVEPOINT/RELEASE/ROLLBACK TO。这保证了导入服务中的事务是原子的——如果一条对话的解析或入库失败,整个对话的写入都会回滚,不会留下半成品数据。
Schema 迁移:无 ORM 的增量方案
没有 ORM 意味着 schema 迁移要手动管理。ChatCrystal 的策略是:
SCHEMA_SQL包含所有CREATE TABLE IF NOT EXISTS和CREATE INDEX IF NOT EXISTS——幂等执行,不会重复创建。applySchemaMigrations()中的ensureColumn()函数处理增量列迁移:
functionensureColumn(db:Database,table:string,column:string,sql:string){constinfo=db.exec(`PRAGMA table_info(${table})`);constcolumns=info[0]?.values.map((row)=>String(row[1]))??[];if(!columns.includes(column)){db.run(sql);}}用PRAGMA table_info检查列是否存在,不存在就执行ALTER TABLE ADD COLUMN。这种模式比版本号迁移更简单,适合单机应用的场景。
ensureIndexColumns()处理索引的增量更新——如果索引的列定义变了,先删后建:
functionensureIndexColumns(db,indexName,expectedColumns,createSql){constinfo=db.exec(`PRAGMA index_info('${indexName}')`);constcolumns=info[0]?.values.map((row)=>String(row[2]))??[];constisCurrent=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 以只读方式打开:
exportasyncfunctionopenVscdb(dbPath:string):Promise<Database|null>{try{constSQL=awaitgetSqlJs();constbuf=readFileSync(dbPath);returnnewSQL.Database(buf);}catch{awaitnewPromise((r)=>setTimeout(r,500));try{constSQL=awaitgetSqlJs();constbuf=readFileSync(dbPath);returnnewSQL.Database(buf);}catch{returnnull;}}}由于 sql.js 把整个文件读入内存再创建数据库实例,它不持有文件句柄——读完就可以释放。这天然避免了与 VS Code 进程的文件锁冲突。如果读取时文件被锁(VS Code 正在写入),等待 500ms 重试一次。
sql.js 实例通过模块级单例复用:
letsqlJsInstance:Awaited<ReturnType<typeofinitSqlJs>>|null=null;asyncfunctiongetSqlJs(){if(!sqlJsInstance)sqlJsInstance=awaitinitSqlJs();returnsqlJsInstance;}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
项目地址:github.com/ZengLiangYi/ChatCrystal
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。
