第 3 篇:「多窗口并发的噩梦」— Writer/Reader 架构
开场:一个看起来简单的场景想象一个场景:你的电脑屏幕: ┌─────────────────────────────────────────┐ │ VSCode 窗口 A │ │ 项目:/project │ │ 正在编辑 user.ts │ │ ↓ │ │ 等等,这个函数名改了... │ │ 按 Ctrl+S 保存 │ │ ↓ │ │ 【更新索引】 │ └─────────────────────────────────────────┘ 屏幕的另一边 ┌─────────────────────────────────────────┐ │ VSCode 窗口 B │ │ 项目:/project (同一个!) │ │ 打开搜索框 │ │ ↓ │ │ "我要搜 getUser 函数" │ │ 输入搜索框,按 Enter │ │ ↓ │ │ 【查询索引】 │ │ ↓ │ │ ??? 要等窗口 A 写完吗? │ │ 还是直接读取? │ │ 会不会读到破损的数据? │ └─────────────────────────────────────────┘这就是并发问题的真实场景。看似简单,实则暗藏杀机。SQLite 的并发限制:冷酷的事实SQLite 号称支持多进程访问。但有一个硬性限制:同一时刻,只能有一个写操作。时序图:冲突场景时间轴 窗口 A 窗口 B ───────────────────────────────────────────── t=0ms 用户编辑 user.ts └─ 改函数名 t=50ms 【获得 SQLite 写锁】 (EXCLUSIVE 锁) t=100ms 开始写: DELETE old symbols INSERT new symbols t=150ms 写入中... (50% 完成) │ ├─ 用户搜索 ├─ 发起查询 ├─ 【等待写锁释放】 ├─ ⏳ ⏳ ⏳ t=200ms 窗口 A 写入 100% 完成 │ 【释放写锁】 │ ├─ 获得读锁 ├─ 查询成功 └─ 返回结果 ✓ t=210ms 完成 完成问题:如果窗口 A 的写操作耗时很长(比如改了 100 个符号),窗口 B 就要等待。用户会感受到搜索延迟。更糟的是,如果写操作 30 秒都没完成,读操作会超时。并发问题的三个恐怖场景场景 1:脏读(Dirty Read)窗口 A(写): BEGIN TRANSACTION DELETE FROM code_index WHERE file = 'user.ts' INSERT INTO code_index (...) VALUES (...) ← 只插入了一半 ← 还没 COMMIT 窗口 B(读): SELECT * FROM code_index WHERE content MATCH 'getUser' 结果:搜索看到"被删除后但还没插入完"的中间状态 → 返回部分结果、重复结果或错误结果 ❌场景 2:写锁争用(Write Lock Contention)窗口 A、B、C、D 全都打开了同一个项目 用户在每个窗口都进行搜索和编辑: A: 编辑 → 改索引 B: 搜索 → 查索引(等 A 的写锁)⏳ C: 编辑 → 改索引(等 B 或 A)⏳ D: 搜索 → 查索引(等所有人)⏳⏳⏳ 结果:所有操作都慢下来,甚至超时场景 3:索引损坏(Corruption)极端情况:写到一半,进程崩溃 窗口 A 正在写: BEGIN TRANSACTION DELETE FROM code_index WHERE file = 'user.ts' INSERT INTO code_index (1000 条记录) ← VSCode 意外退出(断电、蓝屏) 结果:SQLite 日志文件残留 → 下次打开时,"我是否应该回滚这个事务?" → 如果判断错误 → 数据库损坏 → 索引全部无法使用 ❌LoopAgent 的解决方案:Writer/Reader 角色分工核心思想:不让多个窗口竞争写权限。只有一个窗口拥有写权限(Writer),其他窗口只能读(Reader)。架构图启动时间轴: t=0 VSCode 窗口 A 启动 ├─ 检查:有 .loopagent/code-index.sqlite 吗? │ └─ 没有 ├─ "我是第一个!" └─ 角色:Writer ✓ (可以读+写) 索引文件锁:A t=1s VSCode 窗口 B 启动 ├─ 检查:有 .loopagent/code-index.sqlite 吗? │ └─ 有(窗口 A 创建的) ├─ "我是第二个" └─ 角色:Reader ✗ (只能读) 索引文件锁:仍是 A t=2s VSCode 窗口 C 启动 ├─ 检查:... └─ 角色:Reader ✗ 索引文件锁:仍是 A ┌─ Writer (A) ──────────┐ │ 编辑代码、更新索引 │ │ 权限:READ + WRITE │ │ 锁:EXCLUSIVE │ └───────────────────────┘ ┌─ Reader (B) ──────────┐ │ 搜索、查询索引 │ │ 权限:READ ONLY │ │ 如果写锁被占用 → 降级 │ └───────────────────────┘ ┌─ Reader (C) ──────────┐ │ 同上 │ └───────────────────────┘实现细节// 每个 SQLite 索引存储都有一个"角色"文件// .loopagent/// ├─ code-index.sqlite (数据库)// ├─ writer.lock (写锁标记)// │ └─ 内容:"windowId:abc-def-123, pid:12345"// └─ readers.log (读者列表)// └─ 内容:每行一个 Reader// "windowId:xyz-789, pid:67890"// "windowId:qwe-456, pid:11111"classSQLiteIndexStore{privatewindowId:string;privateisWriter:boolean;asyncinitialize():Promisevoid{// 第一步:读取现有的锁文件constwriterInfo=awaitthis.readWriterLock();if(!writerInfo){// 没有 writer → 我是第一个!this.isWriter=true;awaitthis.claimWriterRole();console.log('✓ 我是 Writer');}else{// 已经有 writer → 我只能读