当前位置: 首页 > news >正文

我让 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.2ms0.8msJSON 快 4x
100 条单条写入0.5ms1.1msSQLite 快 2x
500 条全量读取8.7ms2.3msJSON 快 3.8x
500 条单条写入0.6ms5.4msSQLite 快 9x
2000 条全量读取22.1ms8.9msJSON 快 2.5x
2000 条单条写入0.7ms21.3msSQLite 快 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 协议,转载请注明出处。

http://www.jsqmd.com/news/1102613/

相关文章:

  • AI时代来临:企业如何拥抱人工智能转型
  • 紧急!线上偶发Bug无法复现?用IDEA条件断点实现“只在特定线程+特定参数+第1001次调用”精准捕获
  • LV3296与dsPIC30F3014在嵌入式数据采集中的高效应用
  • 类型系统的图灵完备:TypeScript 高级类型体操的底层逻辑与工程边界
  • Zotero-Better-Notes的Markdown导入功能:实现学术笔记无缝迁移的完整指南
  • 主流脑信号采集方式:EEG、fNIRS、ECoG、颅内电极
  • Selenium SSL握手失败:从原理到实战的完整解决方案
  • 如何快速修复损坏视频:untrunc终极完整修复指南
  • 文献综述秒生成,但导师一眼识破?——ChatGPT写论文的3层伪装机制与反检测实战策略
  • 3步实现Markdown笔记完美迁移:Zotero-Better-Notes导入功能终极指南
  • STM32F745ZG驱动WS2812B灯带开发指南
  • 基于TPAFE0808与STM32F469II的多通道信号采集系统设计
  • Si4732与PIC18F86K90在广播接收系统中的应用与优化
  • 优雅退出控制:基于 Go 信号捕获与 Context 超时的微服务无损下线
  • 工业4-20mA电流环设计:XTR116与PIC18F86K90实战解析
  • 13DOF传感器与PIC18LF47K42实现高精度定位导航方案
  • B站成分检测器终极指南:如何快速识别评论区用户真实身份
  • 当GPT-5.5 成为技术中台核心:企业智能化升级的机遇与陷阱
  • 终局不是 GUI,而是 CLI、TUI 和 GUI 的重新分工
  • Rust 异步 IO:从 epoll 到 io_uring
  • TC78H660FTG与PIC18F87J11组合的直流电机驱动方案
  • 指纹浏览器的数据加密技术哪家强?—从AES-256到环境绑定加密的技术深度拆解
  • MuleSoft+LangChain企业级AI编排实战:让大模型走进生产流水线
  • LV3296与PIC18F87J50在嵌入式数据采集中的优化实践
  • Windows本地语音识别终极指南:TMSpeech让你的电脑自动记录一切对话
  • spring,有哪些常见场景会导致@Transactional失效
  • Spring AI 框架实战:Java 后端集成大模型的架构设计与工程落地
  • 掌控AMD Ryzen性能密钥:SMUDebugTool深度调优完全手册
  • Microsoft Agent Framework 1.0 GA深度剖析:AutoGen与Semantic Kernel合体后的编程模型
  • 3分钟上手:用Python轻松下载B站大会员4K高清视频