我让 AI 写了两版 Electron 缓存层,JSON 文件比 SQLite 快 4 倍——但最后一行代码我没敢合
先别急着骂标题党。这个结论有前提,而且前提比结论本身有意思得多。
你大概听过无数遍"Electron 本地数据用 SQLite 就对了"这种说法。我之前也信。直到上周,我让 Cursor 用两种方案各写了一版缓存层,跑了个 benchmark,数字直接把我干沉默了——JSON 文件方案在小数据量读取场景下比 SQLite 快了将近 4 倍。但这个"4 倍"后面藏着三个我自己差点踩进去的坑,一个比一个阴。
下面把整个对比过程摊开讲,包括两版 AI 生成代码、benchmark 数据、以及我最后为什么没选那个"更快"的方案。
事情的起因
我手上有个 Electron 桌面应用,需要在本地缓存一批用户最近浏览的记录——每条大概 200 字节的 JSON,总量在 200-2000 条之间浮动。读写频率不高,但每次冷启动要一次性拉全量数据渲染列表,用户对首屏速度很敏感。
原来这层缓存是裸写的 JSON 文件,fs.readFileSync一把梭。功能没毛病,但代码丑,还没做任何并发保护。我想着让 AI 帮我重构成"正经"一点的方案,就给了 Cursor 一个 prompt:
“为这个 Electron 应用实现一个本地缓存层,要求支持全量读取、单条写入、单条删除。优先用 SQLite,如果性能不合适再降级到 JSON 文件。给出完整实现和 benchmark 脚本。”
Cursor 干活很利索,十分钟不到吐了两版代码。SQLite 版用的是better-sqlite3,JSON 版用的是fs.readFileSync/fs.writeFileSync+ 一个简单的写队列。两版代码都能直接跑,编译没报错。
然后我写了段 benchmark,分别测 100/500/2000 条数据下的全量读取和单条写入。
两版代码长什么样
SQLite 版,AI 生成的核心逻辑:
// sqlite-cache.tsimportDatabasefrom'better-sqlite3'import{app}from'electron'importpathfrom'path'constdbPath=path.join(app.getPath('userData'),'cache.db')constdb=newDatabase(dbPath)db.exec(`CREATE TABLE IF NOT EXISTS cache_items ( id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at INTEGER NOT NULL )`)exportfunctiongetAll():CacheItem[]{constrows=db.prepare('SELECT id, data, updated_at FROM cache_items ORDER BY updated_at DESC')returnrows.all().map((r:any)=>({id:r.id,...JSON.parse(r.data),updatedAt:r.updated_at}))}exportfunctioninsert(item:CacheItem):void{conststmt=db.prepare('INSERT OR REPLACE INTO cache_items (id, data, updated_at) VALUES (?, ?, ?)')stmt.run(item.id,JSON.stringify(item),Date.now())}exportfunctionremove(id:string):void{db.prepare('DELETE FROM cache_items WHERE id = ?').run(id)}JSON 文件版:
// json-cache.tsimport{app}from'electron'importfsfrom'fs'importpathfrom'path'constcachePath=path.join(app.getPath('userData'),'cache.json')typeCacheStore=Record<string,CacheItem>functionreadStore():CacheStore{try{constraw=fs.readFileSync(cachePath,'utf-8')returnJSON.parse(raw)}catch{return{}}}functionwriteStore(store:CacheStore):void{fs.writeFileSync(cachePath,JSON.stringify(store))}exportfunctiongetAll():CacheItem[]{conststore=readStore()returnObject.values(store).sort((a,b)=>b.updatedAt-a.updatedAt)}exportfunctioninsert(item:CacheItem):void{conststore=readStore()store[item.id]={...item,updatedAt:Date.now()}writeStore(store)}exportfunctionremove(id:string):void{conststore=readStore()deletestore[id]writeStore(store)}两段代码看着都挺干净,对吧?AI 写的注释和类型声明甚至比我手写的还规范。但性能差异大到离谱。
benchmark 结果
测试环境:Electron 28,Node 18,Windows 11,NVMe SSD。每项跑 100 次取中位数。
| 数据量 | 操作 | SQLite 版 | JSON 版 | 倍数 |
|---|---|---|---|---|
| 100 条 | 全量读取 | 3.2ms | 0.8ms | JSON 快 4x |
| 100 条 | 单条写入 | 0.5ms | 1.1ms | SQLite 快 2x |
| 500 条 | 全量读取 | 8.7ms | 2.3ms | JSON 快 3.8x |
| 500 条 | 单条写入 | 0.6ms | 5.4ms | SQLite 快 9x |
| 2000 条 | 全量读取 | 22.1ms | 8.9ms | JSON 快 2.5x |
| 2000 条 | 单条写入 | 0.7ms | 21.3ms | SQLite 快 30x |
数字摆出来,你可能跟我一样第一反应是:JSON 全量读取怎么这么快?
答案其实很无聊——readFileSync在小文件(2000 条 × 200 字节 ≈ 400KB)场景下,本质是一次mmap+ 一次JSON.parse,V8 对小 JSON 的解析做了高度优化,底层的 JSON parser 直接走的是 SIMD 批量解码,快得离谱。而 SQLite 每次查询要走prepare → bind → step → finalize的完整链路,还要反序列化 TEXT 字段再JSON.parse,链路长了一大截。我之前读 better-sqlite3 的文档时看到作者说它是"同步 API 里最快的 SQLite binding",这话没错,但再快也快不过一次裸 mmap。
但你注意到单条写入那列了吗?500 条数据时 JSON 版已经慢了 9 倍,2000 条时直接 30 倍。因为 JSON 版每次写入都要readFileSync全量 +writeFileSync全量,数据量越大越惨。你想想,写一条记录要把整个文件序列化一遍再落盘,这操作本质上就是 O(n) 的全量重写。SQLite 是原地更新一行,快得多。
等一下,这里我漏说一个前提。
上面的 benchmark 是纯同步操作,没有并发。但真实场景里,用户可能在列表滚动时触发写入(标记已读),同时冷启动在做全量读取。JSON 版的writeFileSync会阻塞主进程,2000 条数据写一次卡 21ms,掉帧肉眼可见。SQLite 的better-sqlite3也是同步 API,但单行写入 0.7ms,基本无感。
我当时差点就拍板用 JSON 版了,毕竟我的场景读取远多于写入。但跑到并发测试时,JSON 版直接暴露了第二个问题——写覆盖。
第二个坑:写覆盖
JSON 版的insert函数是readStore → 修改 → writeStore,经典的 read-modify-write。两个异步操作同时进来,后写的会覆盖先写的。
我写了个并发测试验证:
// concurrency-test.tsimport*ascachefrom'./json-cache'// 模拟并发写入 100 条数据constitems=Array.from({length:100},(_,i)=>({id:`item-${i}`,title:`测试条目${i}`,content:'x'.repeat(200),updatedAt:0}))// 并发触发awaitPromise.all(items.map(item=>newPromise(resolve=>{setImmediate(()=>{cache.insert(item)resolve(undefined)})})}))constall=cache.getAll()console.log(`写入 100 条,实际存了${all.length}条`)// 输出:写入 100 条,实际存了 3 条100 条写进去只剩 3 条。因为 97 条都被并发写覆盖了。我当时盯着这个数字看了好一会儿,心想这要是上线了,用户存了半天的浏览记录刷新一下全没了,工单系统能给我打爆。SQLite 版不存在这个问题,better-sqlite3的同步写入天然串行化,不需要额外加锁。
当然,这不是 JSON 方案本身的锅,是 AI 生成代码时没考虑并发。你给 JSON 版加个写队列就行:
// json-cache-fixed.tsletwriteQueue:Promise<void>=Promise.resolve()functionwriteStoreQueued(store:CacheStore):Promise<void>{writeQueue=writeQueue.then(()=>{writeStore(store)returnPromise.resolve()})returnwriteQueue}exportasyncfunctioninsertAsync(item:CacheItem):Promise<void>{conststore=readStore()store[item.id]={...item,updatedAt:Date.now()}awaitwriteStoreQueued(store)}但加完队列之后,单条写入的延迟从 21ms 涨到了 24ms(队列等待开销),而 SQLite 还是 0.7ms。这时候选谁已经很明显了。
第三个坑:数据膨胀后的读取性能衰减
这个坑 benchmark 没测出来,是我后来手动拿 10000 条数据跑的。
10000 条数据,JSON 文件体积约 2MB。JSON.parse一个 2MB 的字符串大概要 45ms,加上readFileSync本身的 I/O,全量读取接近 60ms。SQLite 查 10000 行带排序,22ms。
数据量到这个级别,JSON 版的读取优势完全消失了。而且 JSON 版有个隐藏问题——内存占用。每次getAll都要把全量数据 parse 成 JS 对象,10000 条直接吃掉 15MB 堆内存。SQLite 是游标式遍历,可以分批limit/offset,内存可控。
说白了,JSON 文件方案的性能优势只存在于一个很窄的区间:数据量小于 2000 条、读取远多于写入、且不需要并发写入。出了这个区间,SQLite 全面碾压。
我最后选了什么
结论可能你已经猜到了——SQLite。
虽然 JSON 版在我当前的数据量下读取更快,但我没法保证数据量永远不超过 2000 条。一旦用户是重度使用者,累积个几千条浏览记录很正常。与其等将来踩坑再迁移,不如一开始就用稳的方案。那 3ms 的读取差异,用户根本感知不到。
倒是有一个意外收获。我在对比两版代码时发现,AI 生成的 SQLite 版有个小问题:它给updated_at建了普通索引,但排序查询用的是ORDER BY updated_at DESC,没利用到索引(因为 SQLite 的索引默认是 ASC)。手动加了个CREATE INDEX ... ON cache_items(updated_at DESC)之后,全量读取从 22ms 降到了 16ms。
你看,AI 写的代码能跑,但不代表跑得好。它知道要建索引,但不知道方向得跟查询一致。这种细节,你不亲自跑一遍 benchmark 是发现不了的。
顺便一提,我做的 App 叫雷达鸭,这个缓存层就是给它加的,华为应用市场能搜到。
你要是也在纠结 Electron 本地缓存用什么方案,别光听别人说"SQLite 万能"。拿你自己的数据量和读写比例跑一遍,数字比任何人的经验都靠谱。反正我以后写缓存层之前,第一件事就是先写 benchmark——这习惯是被 AI 搞出来的,因为你不测,就不知道它给你写的代码到底行不行。
你有没有遇到过类似的选择?两个方案都能跑,纸面数据一边倒,实际用起来完全是另一回事。欢迎留言聊聊。
个人介绍
老三,10+ 年软件开发经验,软件设计师 / 人工智能应用工程师。专注鸿蒙 ArkTS 北向开发与 Web 前端,业余折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术笔记。
本文遵循 MIT 协议,转载请注明出处。
