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.tsinitDatabase() 中:

typescript 复制代码
export async function initDatabase(): Promise<Database> {
  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.ymlextraResources 配置复制到 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() 函数负责持久化:

typescript 复制代码
export function saveDatabase(): void {
  if (!db) return;
  const data = exportDatabasePreservingForeignKeys(db);
  const buffer = Buffer.from(data);
  writeFileSync(DB_PATH, buffer);
}

exportDatabasePreservingForeignKeys() 是一个包装函数,处理 sql.js 的一个陷阱:

typescript 复制代码
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 实现了定时自动保存:

typescript 复制代码
let saveInterval: ReturnType<typeof setInterval> | 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 提供了一个工具函数:

typescript 复制代码
export function resultToObjects(
  result: { columns: string[]; values: unknown[][] }[],
): Record<string, unknown>[] {
  if (!result.length) return [];
  const { columns, values } = result[0];
  return values.map((row) => {
    const obj: Record<string, unknown> = {};
    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 实现了支持嵌套的事务包装器:

typescript 复制代码
const depthMap = new WeakMap<Database, number>();

function setDepth(db: Database, depth: number): void {
  if (depth === 0) depthMap.delete(db);
  else depthMap.set(db, depth);
}

export function withTransaction<T>(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<Database, number> 跟踪每个数据库实例的事务嵌套深度。顶层事务用 BEGIN/COMMIT/ROLLBACK,嵌套事务用 SAVEPOINT/RELEASE/ROLLBACK TO。这保证了导入服务中的事务是原子的------如果一条对话的解析或入库失败,整个对话的写入都会回滚,不会留下半成品数据。

Schema 迁移:无 ORM 的增量方案

没有 ORM 意味着 schema 迁移要手动管理。ChatCrystal 的策略是:

  1. SCHEMA_SQL 包含所有 CREATE TABLE IF NOT EXISTSCREATE INDEX IF NOT EXISTS------幂等执行,不会重复创建。

  2. applySchemaMigrations() 中的 ensureColumn() 函数处理增量列迁移:

typescript 复制代码
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() 处理索引的增量更新------如果索引的列定义变了,先删后建:

typescript 复制代码
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 以只读方式打开:

typescript 复制代码
export async function openVscdb(dbPath: string): Promise<Database | null> {
  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 实例通过模块级单例复用:

typescript 复制代码
let sqlJsInstance: Awaited<ReturnType<typeof initSqlJs>> | 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

项目地址:github.com/ZengLiangYi/ChatCrystal

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
青春之我_XP1 小时前
深度解析 SQL 经典面试题:如何优雅地计算连续登录天数?
数据库·sql·mysql
l1t1 小时前
DeepSeek总结的PostgreSQL 19 中的 SQL/PGQ:无需图数据库的图查询
数据库·sql·postgresql
书中枫叶1 小时前
生活缴费充值系统
前端·javascript·经验分享·mongodb·node.js
一个博客2 小时前
pdf-viewer 实现预览pdf文件
开发语言·javascript·pdf
wuxia211810 小时前
微信小程序单击元素切换元素的显示和隐藏
javascript·微信小程序·setdata
一起学开源11 小时前
一文读懂 ReAct 范式:让 AI Agent 真正学会“思考+行动“
java·javascript·react.js·ecmascript·react·alibaba·智能体开发
暴躁小师兄数据学院12 小时前
【AI大数据工程师特训笔记】第05讲:关联查询
数据库·sql·oracle
lzhdim12 小时前
SQL 入门 17:MySQL 数据类型:从字符串到 JSON 的全面解析
数据库·sql·mysql·json
游九尘13 小时前
JavaScript 实现三段式版本号对比函数(app升级用)
javascript·uni-app