广告位 · 页头(后台-模块管理-新建模块后替换此区域)

SQLite在WebAssembly中为何内存只增不减?

SQLite 在 WebAssembly 环境中,它的内存行为与本地原生运行有显著差别。WebAssembly 规范只提供线性内存,这就像一个从 0 开始连续寻址的字节数组,所有堆分配、栈分配、页缓存都要在同一个线性空间内完成。SQLite 本身有一层内存分配抽象,它通过 sqlite3_malloc、sqlite3_free 之类的接口向宿主申请内存,而在 WebAssembly 中这些接口最终会落到 Emscripten 提供的 malloc 实现上。理解这一点是管理内存的第一步,因为一旦线性内存从浏览器申请到新的页,即使 SQLite 内部执行了 free,整个 WebAssembly 线性内存的高水位也不会自动收缩。

SQLite在WebAssembly中为何内存只增不减?

一、WebAssembly 线性内存与 SQLite 分配器的关系

WebAssembly 模块的内存由连续的页组成,每页固定为 64 KiB。当模块需要更多内存时,通过 memory.grow 指令申请新增页。这个过程一旦发生,浏览器为模块分配的 ArrayBuffer 会整体变化,典型实现中旧的 ArrayBuffer 会被丢弃,新 ArrayBuffer 承载更大的空间。对于 Emscripten 来说,内存被分成静态数据区、栈区和堆区,其中堆由 malloc/free 管理。SQLite 很少直接调用操作系统的 brk 或 mmap,而是通过自己的内存方法表向宿主请求内存。在 WASM 构建中,这些方法表默认绑定到 Emscripten 的分配器上,因此 SQLite 申请页缓存、语句结构体、VDBE 临时寄存器时,最终会改变堆的空闲块结构。

这就引出一个容易误判的问题:SQLite 执行 DROP TABLE 或 DELETE 后,数据文件对应的页缓存可能被释放,SQLite 会把这些内存归还给 Emscripten 的 free,但 Emscripten 的分配器不一定会把空闲内存归还给 WebAssembly 线性内存,更不会让浏览器回收已经增长的页。所以在浏览器里持续观察时,会发现 WASM 内存只增不减。用 Module.HEAPU8.byteLength 可以看到线性内存总字节数,它只会随 memory.grow 增加,不能反映真实空闲堆大小。

// 查看 sql.js 或 Emscripten 运行时当前的线性内存总量
console.log('WASM内存字节数:', Module.HEAPU8.byteLength);
console.log('WASM内存页数:', Module.HEAPU8.byteLength / 65536);

上面的代码适合在浏览器控制台快速判断当前 WASM 模块已经增长到了多少页。如果它达到了几百 MB,而 JS 堆仍然很小,基本可以确定问题出在 SQLite 内部对象未释放,或线性内存高水位被反复拉高。需要注意的是,这种增长不是浏览器内存泄漏,而是平台内存模型决定的特征,管理思路要围绕复用和提前释放展开,而不是希望调用某个 API 让内存缩回去。

二、常见的内存增长场景与泄漏排查

在实战项目中,最容易把 WASM 内存推高的操作是大批量写入和忘记释放预编译语句。批量写入时,SQLite 会为事务内的脏页、排序缓存、临时 B 树等结构分配内存。如果没有显式调用 BEGIN 和 COMMIT,SQLite 默认每条语句自动提交,但仍可能产生大量页缓存。开启事务可以减少 I/O 次数,但也会让更多脏页驻留在内存中。对比测试可以看到,插入 10 万行日志数据后,WASM 线性内存从 16 MB 涨到 80 MB 以上,即使关闭数据库,Module.HEAPU8.byteLength 也不会下降到初始值。

const SQL = await initSqlJs({ locateFile: file => `/wasm/${file}` });
const db = new SQL.Database();
db.run('CREATE TABLE logs (id INTEGER PRIMARY KEY, msg TEXT)');
db.run('BEGIN');
for (let i = 0; i < 50000; i++) {
  db.run('INSERT INTO logs (msg) VALUES (?)', ['message-' + i]);
}
db.run('COMMIT');
console.log('写入后 WASM 字节数:', Module.HEAPU8.byteLength);

另一个场景是预编译语句未释放。SQLite 的 prepare 会返回一个语句对象,底层对应 sqlite3_stmt 结构体。sql.js 封装后,只有调用 stmt.free() 才会触发底层 sqlite3_finalize,真正释放 SQLite 内部结构。如果只让 JS 对象失去引用,V8 的 GC 只能回收 JS 侧的包装对象,无法感知 WASM 堆里的 C 结构体。所以某些分页查询会越跑越大:

for (let i = 0; i < 20000; i++) {
  const stmt = db.prepare('SELECT * FROM logs WHERE id = ?');
  stmt.bind([i]);
  stmt.step();
  // 这里忘记 stmt.free(),WASM 内存会持续增长
}

修复方式是在每次循环结束前调用 stmt.free(),或者使用 db.exec 一次性执行不需要返回结果的查询。如果业务确实需要逐行读取,可以用 try...finally 保证异常路径也能释放。这个错误在 JS 堆监控中几乎看不到,因为每次包装对象都很小,但 WASM 堆会以每个语句几百字节到几 KB 的速度累积,最终导致页面卡顿甚至崩溃。

三、可复用的内存控制策略

既然线性内存高水位不会下降,工程项目就应该把重点放在减少峰值和复用已有空间上。最直接的策略是避免重复初始化 SQLite 运行时。initSqlJs 会加载 WASM 文件并创建运行时,频繁调用会反复申请大块内存。更好的做法是在应用启动时初始化一次,在页面生命周期内共享同一个 SQL 实例。数据库实例可以创建和关闭,但运行时不要反复初始化。

如果需要把数据导出为二进制并持久化到 IndexedDB,db.export() 会把整个数据库文件复制到 JS 侧,形成一份 Uint8Array。这个动作会让内存峰值接近翻倍,因为 WASM 中已经有一份数据库页缓存,JS 堆中又出现一份完整文件字节。处理大库时,可以通过分块写入 IndexedDB 减少同时持有的数据量,或者在导出前关闭连接并尽快将数据交给事务。例如:

const data = db.export();
db.close();
// 尽早将 data 写入 IndexedDB,写入完成后不再保留引用
await persistToIndexedDB(data);
reader = null;

上面的代码中,db.close() 会释放 SQLite 持有的数据库结构、页缓存和事务日志,这些空间会被 Emscripten 分配器标记为空闲块。下一次再创建新的 SQL.Database 时,它会优先复用这些空闲块,而不是继续触发 memory.grow。因此,对于批量导入导出流程,一个较好的节奏是:导入到数据库时用事务控制,导出后关闭数据库实例,把二进制数据立即持久化,再根据下一批任务创建新实例。这样可以让 WASM 堆里的内存得到回收复用,避免持续向上增长。

如果项目允许深度定制 SQLite 编译参数,可以在 C 编译阶段预设页缓存。sqlite3_config(SQLITE_CONFIG_PAGECACHE, ...) 允许指定一块静态内存作为页缓存,减少后续大块分配。虽然 sql.js 构建产物没有暴露这个配置,但自己编译 SQLite 到 WASM 时,可以把页缓存放入 Module.HEAPU8 的静态区域,或者使用 emmalloc 这类对小块分配更友好的分配器。下面是一个 C 侧配置示例:

static unsigned char pageCache[4096 * 128];
sqlite3_config(SQLITE_CONFIG_PAGECACHE, pageCache, 4096, 128);
sqlite3_initialize();

这样 SQLite 会优先从这段连续内存中分配页,减少运行时向 malloc 申请堆块的频率。需要注意的是,页缓存设置过大会占用静态内存,设置过小则会在高负载时退化为普通堆分配,因此需要根据实际单批写入量和查询频率调整。

四、从 SQLite 内部统计与浏览器工具定位高水位

要判断内存高水位究竟由哪部分业务造成,一个有效方法是结合 SQLite 的 PRAGMA 语句和浏览器 Performance 面板。PRAGMA 相关语句可以查看数据库文件的页数、页大小和空闲页数。比如连续执行 PRAGMA page_count;、PRAGMA page_size; 和 PRAGMA freelist_count;,可以了解数据库文件里有多少页、哪些是空闲页。虽然这不是 WASM 堆内存的完整画像,但能帮助判断是否因为表数据膨胀或大量删除产生了内部碎片。

const result = db.exec('PRAGMA page_count; PRAGMA page_size; PRAGMA freelist_count;');
console.log(result);

如果 freelist_count 很高,说明数据库文件内部有大量空闲页。对于内存紧张的场景,不推荐立即执行 VACUUM,因为它会重建数据库文件,需要额外的临时空间,可能让 WASM 内存瞬间翻倍。更好的做法是等待合适的时机,比如页面空闲时,先导出并关闭数据库,再在新建实例时用 VACUUM 处理。这样虽然导出仍有一份副本,但可以主动控制峰值出现的时间。

浏览器侧可以结合 performance.memory.usedJSHeapSize 观察 JS 堆,再通过 Module.HEAPU8.byteLength 观察 WASM 线性内存。两者同时上升,说明 JS 侧存在大对象或二进制数据累积;若 WASM 涨而 JS 不涨,问题大概率在 SQLite 内部未释放语句、缓存或建表操作。定位到具体调用后,再应用第三节的复用与释放策略,往往能让内存曲线保持稳定。最终目标是让 SQLite 在 WebAssembly 这个受限环境中,既发挥本地查询的优势,又不被线性内存的高水位特性拖累。

广告位 · 文章底部(后台-模块管理替换此区域)