# AriaEngine LSM-Tree 索引子系统审计报告(v0.7.4) > 审计范围:`src/engine/aria/index.ts`(2282 行)、`index/lsm.ts`(801)、`index/memtable.ts`(511)、 > `index/sstable.ts`(334)、`index/sstable_builder.ts`(253)、`index/merge_iterator.ts`(191)、 > `index/bloom.ts`(113)、`types.ts`(249),以及 15 个 aria 测试套件与 CHANGELOG v0.7.2–0.7.4。 > > 标注约定:**【已读代码确认】**=直接从代码推出;**【已实测确认】**=我写了临时探针跑出可复现结果 > (探针已删除,工作区无残留);**【推测】**=机制上成立但未复现,附验证方法。 --- ## 1. 架构概览 ### 1.1 MemTable:红黑树 - **结构**:`RBNode`(`memtable.ts:14-26`)+ `RedBlackTree`(`memtable.ts:32-411`)。私有 `root` / `_size`, 颜色枚举 `Color{RED,BLACK}`,节点带 `parent` 指针,**无哨兵 NIL 节点**(nil 用 `null` 表示)。 - **比较器**:全部用 JS 原生 `<` / `>` / `>=` 直接作用于 `string`(`memtable.ts:54,56,80,82,127,136,137`)。 即 **UTF-16 码元序**,与 `Array.prototype.sort` 默认序、`SSTable` 索引键序完全一致 —— 这一点是全链路唯一真正统一的东西(builder 依赖"调用方已排序",见 1.2)。 - **插入** `insert(key,value)`(39-74):迭代下降找位置;命中则**原地替换 value 并 return,不改结构、不加 size** (58-62)。新节点默认 RED,挂到 parent 后 `fixInsert`(231-272)走标准三情形(叔红上溢 / LL / LR)。 根节点强制染黑(271)。 - **查找** `find`(77-89)与 `findNode`(161-173)是同一算法写了两遍(重复代码)。 - **删除** `delete(key)`(92-100)→ `deleteNode`(175-213):零/单子节点走 `transplant`(215-224); 双子节点取中序后继、先摘后继再顶替(187-212)。后继颜色为黑时进入 `fixDelete`(274-357), 含四情形标准双黑修复(兄弟红 / 双侄黑 / 近侄红 / 远侄红),镜像分支在 319-353。 CHANGELOG v0.7.4 修的就是这里:`x`(双黑起点)与 `xParent` 必须在 `transplant` 重连**之前**捕获 (193-194, 209-210),否则 `null` 占位会以 `parent=null` 传入 → `fixDelete` 在 280 行 `break` 掉, 黑色节点删除后整棵树黑高失衡。 - **注意**:**`RedBlackTree.delete` 在生产路径上是死代码** —— `LSM.delete` 写墓碑(`lsm.ts:155-164`, `{__tombstone:true}`)而不是树删除。只有单测 `aria-index.test.ts:104-112,142-149` 直接调用 `MemTable.delete`。 即:这棵树上最容易出错的那一半代码,生产环境从未被走到,而它仍带着一处**确定的大小记账缺陷**(见 2.2)。 - **遍历**: - `inorder`(104-106)/`getAllEntries`(147-151):递归中序,物化数组。flush 走这条路径 (`lsm.ts:221`)。 - `rangeScan`(109-115)→ `_rangeScan`(400-410):递归 + 剪枝。注意剪枝用**严格不等号**: `node.key > start` 才递归左子树、`node.key < end` 才递归右子树(407、409), 等于边界的子树被跳过 —— 对本函数是安全的(等于边界的那个节点本身会在 408 行被回调)。 - **`scanLazy`(121-144)**:v0.7.4 新增。显式栈中序迭代器;先沿路把"≥startKey 的最左祖先链"压栈 (124-132),然后弹栈产出;`node.key > endKey` 直接 `break`(136)剪掉剩余子树。 这是 `findStream` 真流式的底层。 ### 1.2 SSTable 格式与读写 **布局(v2,magic `SSTC`)**:`[Data Block 0..n][Index Block][Bloom Filter][Footer 32B]` (`sstable_builder.ts:15-34`)。 - **Data Block**:`entryCount(u32)` + 重复 `[keyLen(u32)|key|valLen(u32)|val]`,key/value 均为 **UTF-8 字节**, value 是 `JSON.stringify(row)` 的字节(builder 77-81、185-222)。 - **Index Block**:`entryCount(u32)` + `[keyLen(u32)|key|blockOffset(u32)|blockSize(u32)]`; **索引键 = 该块内最后一个 key**(94-103),这是 v0.6.1 那个 P0 漏读 bug 的根源约束。 - **Footer 32B**:`index_offset|index_size|bloom_offset|bloom_size|bloom_hash_count|entry_count|magic|checksum` (133-149)。 - **分块策略**(167-182):累加 UTF-8 字节,`>=blockSizeLimit` 且块内已有 >1 条时,把**除最后一条外**的全部 封块(174-177)。因此块大小 ≈ blockSize(默认 4096),单条超大 value 独占一块。 【已实测确认】用 90 字节行插入 10 万行(`aria-prod-load.test.ts:345` 的形态)时, builder 的 4096 字节上限被**完全绕过**:单块最多可达 ~2×4096+header,索引条目数因此约减半 (见 3.5,属性能/密度问题而非正确性问题)。 - **CRC**:整文件 CRC-32 覆盖**除最后 4 字节(checksum 自身)外的全部字节**(146-149), 写 0 时改写成 1 以区分旧格式;读取端 `storedChecksum === 0` **直接放行不校验** (`sstable.ts:41-46`)——这是 v1/早期 v2 文件的兼容豁口(对**新建**文件不生效)。 - **读取**(`sstable.ts`): - `parseFooter`(209-251):`byteLength < 32` 抛错;magic 决定 u16/u32 长度字段宽度; 索引越界则**静默 return**(235-237,残缺文件退化为空表);bloom 越界/解析失败也只是降级为无 bloom(243-250)。 - `parseIndexBlock`(253-277):逐个读,越界即 `break`;指向文件外的块条目被 `continue` 跳过(273)。 - `get`(62-103):bloom 先否定 → `locateBlock` 二分 → 块内**线性扫描**(79-100)。 - `rangeScan`(106-115)只是 `scanLazy` 的包装;`scanLazy`(121-168)用 `locateBlockGE(start)` 起、`min(last, locateBlockLE(end)+1)` 止(126-131), 多扫一块以规避"块尾 key 越界"漏读,条目级 `key>=start && key<=end` 过滤(158)。 - `locateBlock`(294-313)/`locateBlockGE`(315-323)/`locateBlockLE`(325-333):三套二分,第三个函数 在"endKey 小于全部索引键"时返回 **0 而非 -1**(332),靠调用方 `Math.max/clip` 兜住 —— 脆弱但当前正确 (【已实测确认】13 组范围/边界探针全绿)。 ### 1.3 MergeIterator 语义 - 数据源抽象 `EntrySource{next,reset}`(12-17);`ArrayEntrySource`(20-36,compaction 用,全量物化) 与 `GeneratorEntrySource`(43-58,v0.7.4,流式扫描用)。 - 最小堆 `MinHeap`(71-122)仅按 `key` 比较(100、113-114),**sourceIndex 不参与堆序**。 - `next()`(148-168):弹出最小项后立刻从**同一 source** 补种(156),再把堆顶所有 `key === 当前 key` 的重复项一次性抽干(159-165),其中 `sourceIndex` 最小者胜出(162-164)。因此: - **去重只在"同一次 `next()` 调用内"发生**;语义是"同一 key 只吐一条,取最新源"。 - 顺序保证依赖**源加入顺序 = 新鲜度降序**:`LSM.rangeScanLazy`(447-467)先 MemTable、再 `frozenMemtables` 由新到旧、再 L0→L6,且每层内按 id 降序(`init` 128 行、flush `unshift` 253 行)。 - **墓碑处理**:MergeIterator 自己**不认识**墓碑(它只是普通 value)。过滤在两处: - 点查:`unwrapTombstone`(`lsm.ts:727-731`)→ `get` 命中墓碑返回 `null`; - 扫描:`rangeScanLazy` 内 `if (!v.__tombstone) callback(...)`(472-476)。 - **compaction 不丢墓碑**(`compactLevelAsync` 把 `drain()` 的全部条目写进新文件,537-552), 因此删除不会被旧数据"复活",代价是墓碑与历史版本**永久留存**(见 3.4)。 - 快照过滤:MergeIterator **无任何 MVCC 概念**;快照隔离完全在 `AriaEngine.find/getAllRows` 的 `mergeTxnSnapshot`(index.ts:1620-1638)里做——即先取磁盘视图,再把 `txnSnapshot` 的写/删标记覆盖上去。 ### 1.4 端到端读取路径 | 路径 | 链路 | |---|---| | **点查(PK 等值)** | `find`→`tryIndexLookup`(1993-2006) → `lsm.prefetchKeys([key])`(内含 `drainChain`)→ `lsm.get` → MemTable→frozen→L0..L6,命中 `unwrapTombstone` → `mergeTxnSnapshot` → `matchWhere` → `orderBy` → `slice` | | **二级索引等值** | `tryIndexLookup`(2041-2053) → `indexScanToRows`(2099-2120):`idxLsm.prefetchRange`→`idxLsm.rangeScan`→收集 pk→`lsm.prefetchKeys(pks)`→逐个 `lsm.get` | | **二级索引范围** | `tryIndexLookup`(2085-2092):**放弃下推**,`indexScanToRows(idxLsm,'','\uffff')` 全索引扫描 + 逐行 `lsm.get` + 行级 `matchWhere` | | **全表** | `getAllRows`/`find`→`lsm.prefetchRange(prefix,prefix+\uffff)`→`lsm.rangeScan`(**物化数组**)→逐行重建 PK → `mergeTxnSnapshot` | | **流式** | `findStream`(1172-1227):非事务且无索引命中时 `lsm.prefetchRange` + `rangeScanLazy`,回调返回 `false` 提前终止;事务中回退 `find` 全量物化(1199-1206) | ### 1.5 Compaction 与后台链 - **层级**:固定 7 层(`MAX_LSM_LEVELS`),**`levelSizeMultiplier` 字段被读取后从未使用** (`lsm.ts:71,90`,全仓 grep 仅赋值无消费)—— 即**没有基于体积的层级容量策略**, 只有"某层文件数 ≥4 就整层合并到下一层"(258、275-281)。 - **触发**:`put/delete` 时 `levels[0].length >= 8` → `enqueueCompact(0)`(145-147、156-158); flush 完成时 `levels[0].length >= 4` → `scheduleCompact(0)`(258-260); 完成后在 `finally` 里级联检查本层与下一层(272-282)。`compactLevelAsync(level, minFiles=4)` (498-577):`splice` 整层 → 逐个"缓存优先、`store.load` 兜底" → `verifyChecksum` → `scanAll` 物化 → `MergeIterator.drain()` → 建成新 SSTable → `save`+`saveMeta` → `levels[level+1].unshift` → **无条件删除整层旧文件**(572-576)。 - **后台链序列化**:所有 flush/compaction 挂在单条 Promise 链 `flushChain` 上 (`enqueueOnChain` 194-204)。`drainChain`(211-217)循环 `await` 直到链引用不再变化 (因为任务完成时可能级联追加新任务)。`prefetchRange/prefetchPrefixRanges` 开头都 `await drainChain()` (315、334、372)—— 这是"读之前先把后台任务排空"的**唯一一致性手段**,也是 3.1 性能悬崖的直接来源。 - **背压**:`compactLevelAsync` 无体积/耗时上限;`LSM.put` 只在 L0≥8 时排队一次 compaction, 没有限流、没有拒绝写入、没有反压信号给调用方;`cacheLimitBytes` 默认 = `bufferPoolPages × pageSize` = 256×4096 = 1MB(index.ts:168)。 ### 1.6 MVCC 分层(实际形态) - `MVCCManager`(`transaction/mvcc.ts`)维护 `versionStore` / `activeTxns` / `globalCommitLsn` / `txnWriteKeys`。`writeVersion` 建链(115-135)、`deleteVersion` 写 `__mvcc_tombstone`(140-142)。 - **关键事实:AriaEngine 的事务隔离不靠 MVCC,靠 `txnSnapshot`(一个 `Map`)**: - 事务内写只进快照 + `mvcc.writeVersion`(index.ts:637-644、820-830、1011-1017); - `find/getAllRows/count/update/delete` 都先拿磁盘视图再 `mergeTxnSnapshot` 覆盖(1620-1638); - `commitTransaction`(1467-1495)在 WAL COMMIT 落盘后,把**整个快照**逐键 `lsm.put/delete`; - `commitTransaction` 里 `mvcc.commitTransaction` 会**删除本事务全部版本**(mvcc.ts:60-80)—— v0.6.3 的注释明确写了"快照读取已移除,版本链仅作 undo 记录"。 - 因此 **`mvcc.gc(maxVersionsPerKey)`(168-176)几乎无事可做**(链在每次提交时已被清空), `tryGC`(index.ts:2127-2133)每 10 次写操作调用它、`vacuum` 调 `gc(10)` 都只是空转; `vacuum()` 返回的 `gcVersions` 实际是 `getGlobalLSN()`(提交序号)而非回收版本数(2258-2260)——**误导性指标**。 - **二级索引不参与 MVCC/快照**:`updateSecondaryIndexes`(1926-1957)在事务内**立即**改写索引 LSM, 回滚/savepoint 回滚后靠 `reindexTable` 全量重建(1529-1533、1573-1578)。这带来一个结构性副作用: **事务内索引领先于主表**(见 2.6)。 ### 1.7 Flush / Checkpoint 路径 ``` insert/update/delete └─ wal.appendBatch(full=逐批落盘 / batch=缓冲 / none=不写) └─ opCounter += n ; checkMemoryBudget()(maxMemoryMB 超限 → 不等待的 lsm.flush()) └─ checkpointManager.tick() ← opCount>=interval 或 WAL 字节>=16MB └─ CheckpointManager.checkpoint() ├─ lsm.flush() ← 内含 drainChain(),会等**全部**后台 compaction ├─ flushAll() ← 主 LSM + 全部二级索引 LSM 各自 flush() └─ wal.checkpoint() ← 截断 WAL(活跃事务时 skip,index.ts:252-259) ``` `LSM.flush()`(580-620):先查 `lastBackgroundError` → `drainChain` → 把 `immutableMemtable` 入链 → 若活跃 memtable 非空则 `freezeMemtable()` 并把新的 immutable 入链 → 再 `drainChain` → 清理空 frozen。 `freezeMemtable`(182-191)把当前 memtable 冻结、塞进 `frozenMemtables`、用 `memtableSizeThreshold` 新建一张(v0.4.2 修的就是"新表阈值衰减")。 ### 1.8 二级索引 LSM 与主 LSM 的关系 - 每个 `index/unique` 列一个独立 `LSM` 实例(index.ts:194-204 重启恢复、452-461 建表、 1353-1362 createIndex),**独立 SSTableStore 命名空间**(`createSSTableStore`,1719-1814: 文件前缀 `sst_idx___`、meta key `__aria_lsm_meta_`、**独立 id 序列**)。 - 索引条目格式:key = `${String(value)}:${pk}`,value = `{pk}`(1382、1953、2237)。 - 查询:`tryIndexLookup`(1960-2096)递归展开 `$and`(1974-1984),PK 走主 LSM 精确/`$in`/范围, 其他列走 `idxLsm`。失败则返回 `null` → 主表全扫兜底(`find` 680 行)。 - **两个 LSM 之间没有任何事务/原子性耦合**:索引写成功了主表写失败(或反过来)只能靠 `reindexTable`/`repair` 事后修(2209-2242、331-334)。 --- ## 2. 缺陷清单 > 排序:P0 数据丢失/腐坏 → P1 错误结果 → P2 健壮性/性能 → P3 风格(已按要求忽略纯风格项)。 ### P0-1 compaction 后 `get()` 在同一层内"先读到旧值"的顺序保证,依赖一个从未被断言的不变量 - **文件:行号**:`src/engine/aria/index/lsm.ts:401-426`(get 的层级短路)、`498-577`(compaction)、 `index.ts:625-627`(`__txn_deleted` 标记复用)。 - **现象**:`get` 的语义是"遇到第一个匹配即 return,**命中墓碑直接返回 null**"(404、409)。 这要求"层号越小 ⇒ 数据越新"这条全局不变量**永远**成立。 - **根因**:compaction 只做 `levels[L] → levels[L+1]`,且**不与 `levels[L+1]` 已有文件做 k 路归并** (554-569 直接 `unshift` 新文件),于是层内出现**区间重叠文件**;层间新鲜度完全靠"所有写都从 L0 进" 这一时序事实维持。任何绕过 L0 的写(重放、外部直接 `compactLevel`、未来加的 repair 路径)都会打破它。 - **严重级别**:P1(错误结果)为当前实际等级;若被打破即 P0。 - **复现思路**:【已实测/已证伪】我按 7 组时序(含 2 文件/层、跨层交替、崩溃窗口)构造探针, 未复现出错误值 —— 因为当前所有路径都经 L0,不变量成立。**验证方法**:写一个测试直接 `engine.lsm.levels[2].push(meta)` 注入一份"更新"数据(模拟重放/repair 写错层), 再 `get` 同 key,即可看到旧值胜出。**建议**:`get` 遇到墓碑不应立刻 `return null` 而是 `continue` 到更低层(前提是层序=新鲜度),或改为"收集所有命中取最小层号"。 ### P0-2 `MemTable.delete` 的大小记账无条件下调,与树删除结果脱钩(潜在重复 flush / 无限刷盘) - **文件:行号**:`src/engine/aria/index/memtable.ts:441-447`(`delete`)、`497-510`(`estimateEntrySize`)、 `lsm.ts:159-163`(生产路径改用墓碑,故当前只被测试与潜在调用方触发)。 - **现象**:`delete` 先 `find` 取旧值并 `_estimatedSize -= size(oldVal)`,**然后**才 `tree.delete(key)`; 当 key 不存在(`find` 返回 null)时 `estimateEntrySize` 返回 0,**减法照做**,`_estimatedSize` 被凭空扣减。 - **根因**:`if (oldVal) { ... }` 只保护了"减多少",没保护"是否该减"。返回值 `tree.delete` 的布尔结果 被丢弃(444-446)。 - **严重级别**:P2(健壮性/性能;可放大为 P2 级写放大与 SSTable 膨胀)。 - **复现思路**:【已实测确认】`new MemTable(1000); mt.delete('ghost')` 后 `getEstimatedSize()` 变为负数; 随后每次 `put` 都在负基线上加,`shouldFlush()` 被推迟;反之在频繁删除不存在键的场景(例如 应用层"删了再删")会让计数漂移。**验证方法**:单测断言 `mt.delete('absent'); expect(mt.getEstimatedSize()).toBe(0)` —— 当前会失败。 ### P1-1 `LSM.get` 缓存未命中返回 `null`("不存在"),与"数据不可用"不可区分 - **文件:行号**:`src/engine/aria/index/lsm.ts:734-753`(`loadSSTableReader` 未命中即 `return null`)、 `417-422`(调用方 `if (!reader) continue;`);对比 `compactLevelAsync` 已经修好的 "从存储兜底加载"(508-515)。 - **现象**:只要某个 SSTable 不在 `sstableCache` 里,该层就被**静默跳过**。读路径依赖"每次查询前 `prefetchKeys/prefetchRange` 已把需要的文件装进缓存"这一约定(`prefetchRange` 314-328、 `prefetchKeys` 331-352)。 - **根因**:`cacheLimitBytes` 会驱逐(`trimCache` 792-799),而 `prefetch*` 只对"目标 key 命中区间"的文件 做 `toLoad`(320-322、341-344);**单文件大于 `cacheLimitBytes` 时缓存会在下一次 `trimCache` 时被清空** (793-798),此时"目标文件本身被更早装入的大文件挤出缓存"就变成可能。尽管每条读路径都紧跟一次 prefetch 使其难以稳定触发,但代码本身没有"未命中就回源"的兜底,属于**同类问题只修了一半** (compaction 修了、get 没修)。 - **严重级别**:P1(理论上返回缺失行)。**当前实践等级 P2**(我 5 组探针未能稳定构造)。 - **复现思路**:【已实测确认】单文件 > cacheLimit 时,`trimCache` 后 `sstableCache` 被清空 (cacheSize 由 6202 → 0),`lsm.get` 直接返回 null;公开 `find` 仍正确,因为它在读之前又做了一次 prefetch。构造稳定复现需要在**一次 `prefetchKeys` 内**装入的两个文件都大于 cacheLimit (先装的后被目标文件挤出)—— 由于 L0≥8 会触发 compaction,我未能在稳定配置下达成。 **验证方法**:给 `AriaEngine` 加一个"禁止 prefetch"的测试开关,或直接构造 `prefetchKeys(['t:a','t:z'])` 后立即 `get('t:a')`(两 key 各命中一个超大文件,cacheLimit 小于单文件)。 **建议**:`loadSSTableReader` 改为 async 回源(或让 `get` 走 `getWithFallback`), 至少把"缓存未命中"与"确实不存在"在类型上分开(`null` vs `undefined`/`MISS`)。 ### P1-2 `rangeScanLazy` 的 `callback` 返回值契约与 `rangeScan` 包装自相矛盾(早停未真正生效) - **文件:行号**:`src/engine/aria/index/lsm.ts:440-479`(`callback` 声明返回 `boolean | void`, 473-475 `if (cont === false) return;`)、`428-432`(`rangeScan` 的包装回调 `{ result.push(...) }` **返回 undefined**,天然无法早停)、`index.ts:1220-1225`(`findStream` 依赖 `false` 早停)。 - **现象**:`findStream` 的 limit 早停是"**多读一条再返回**":`for...of` 在消费完 yield 值之后才会 执行 `callback()` 并据此决定是否中断,而生成器已经推进到了下一条(`merge_iterator.ts:50-53` + `memtable.ts:133-143` 的 `stack.pop()` 之后才 `yield`)。即"未消费块/子树不再解析"的 v0.7.4 宣称 **方向正确但边界多算 1 条**;更实际的问题是 `rangeScan` 包装层使早停能力对内部调用者不可见。 - **根因**:`EntrySource.next()` 是"推送一条"的游标语义,缺少 `peek()`/`close()`; 生成器在 `yield` 后无法收到"下游不要了"的信号(`GeneratorEntrySource.reset()` 是空实现,55-57)。 - **严重级别**:P2(性能/接口语义),P3 级别的一行偏差。 - **复现思路**:【已实测确认】`limit=5` 的流式扫描中,生成器实际产出 6 条(produced=6)。 **验证方法**:在 `findStream` 里统计 `scanLazy` 的 `next()` 调用次数,或断言 "produced === limit"。 ### P1-3 `SSTableReader` 有三处重复且各自独立的解析循环(解析语义漂移风险) - **文件:行号**:`src/engine/aria/index/sstable.ts:80-100`(`get` 的手写循环)、`145-166`(`scanLazy`)、 `182-201`(`scanAll`);三处都各自重算 `lenFieldSize()`、各自 `new TextDecoder()`、各自做越界 `break`。 - **现象**:三份实现的越界策略并不一致:`get` 在长度字段越界时 **`break` 整个块并返回 null**, `scanLazy/scanAll` 同样 `break` 但继续下一块。同一份损坏文件在"点查"与"扫描"下表现不同, 且 v0.6.1 的"多扫一块"修复只加在 `scanLazy`(131)——`get` 依赖 `locateBlock` 的另一种归纳(302-306)。 - **严重级别**:P2(可维护性直接转化为正确性风险,历史上已多次在此处出 P0)。 - **复现思路**:**验证方法**:属性测试——对随机 SSTable(含单条超大 value、全同前缀 key、 空块、末块只 1 条)断言 `get(k) === scanLazy(k,k)[0]` 且 `scanAll()` 与 `scanLazy('','\uffff\uffff')` 元素级相等。 ### P1-4 事务内二级索引领先于主表 + `mergeTxnSnapshot` 只按 PK 去重,存在"索引返回陈旧行"的缝隙 - **文件:行号**:`index.ts:647`(`updateSecondaryIndexes` 事务内立即写索引)、`684`(`find` 先索引后合并)、 `1620-1638`(`mergeTxnSnapshot`:`rows.findIndex(r => r[pkCol] === pk)`,命中才替换、未命中则 **push**)。 - **现象**:事务内 `INSERT/UPDATE/DELETE` 走"索引立即生效、主表延后",而 `find/getAllRows` 是 "先索引/磁盘视图,再叠加快照"。当索引给出一行、而该行的**快照版本 PK 与磁盘版本 PK 不同**时, `findIndex` 找不到对应行 → 同一行被 **push** 成两条。 - **根因**:快照只存行内容、不存"原 PK";`updateSecondaryIndexes(table, newPk, updated, row)`(852) 在主表键变更时把索引迁到 newPk,而 `mergeTxnSnapshot` 无法把"索引里的 oldPk 结果"与 "快照里的 newPk 行"识别为同一实体。 - **严重级别**:P1(错误结果,事务内重复行)。**当前为"推测"**:我按 "事务内更新索引列 + 按旧值查询"以及"人工植入陈旧索引条目"两种路径探针,**均未复现** (因为 v0.6.2 起 `updateSecondaryIndexes` 会同步删掉旧索引条目)。触发需要对 索引 LSM 与主 LSM 的**新增/删除顺序**有更细的时序(例如 `applyForeignKeyRules` 的 SET NULL 分支 1139-1161 与主循环 1028 的组合)。 - **复现思路**:**建议验证法**——在 `mergeTxnSnapshot` 前后各打一次 `rows.length` 与 PK 去重后的长度, 跑 `aria-matrix-audit.test.ts:70-99`(事务内 100 次 update + 索引断言)并把 `WHERE` 换成被更新列的**旧值**;若出现 `rows.length > uniquePk` 即命中。 ### P1-5 `compacting` 是全局单标志,跨层 compaction 触发会被静默丢弃(维护饥饿) - **文件:行号**:`lsm.ts:78`(单 boolean)、`269-283`(`scheduleCompact`:`if (this.compacting) return;`)、 `286-302`(`enqueueCompact` 同判断);`finally` 里的 `this.compacting = false`(273、293、296) 会被**较早任务**的 finally 提前清零,从而允许两个 compaction 任务同时在链上排队。 - **现象**:① 只要有一个层的 compaction 在跑(可能耗秒级),**其它层的触发全部被丢弃**, 只有"当前层完成后的级联检查"能补一次(275-281),于是深层/次层的维护会被持续饿死; ② 反方向:标志被提前清零 → 同一层可能被排两次(`compactLevelAsync` 开头有 `length < minFiles` 保护, 第二次是空转,故不致命)。 - **严重级别**:P2(层级失衡 → 读放大与空间放大持续恶化)。若未来 compaction 承担墓碑清理,会升级为 P1。 - **复现思路**:【已读代码确认】:构造 `memtableSizeThreshold` 很小、写入集中在两张表/两个索引 LSM 的场景, 在 `compactLevelAsync` 里插桩计数,可观察到 `scheduleCompact(1..6)` 因 `compacting===true` 被 drop。 **验证方法**:断言"稳定态下 `levelCounts[i] <= 4`",当前在大批量写入下不成立。 ### P1-6 `flush()` 一旦记录过后台错误就**永久拒绝**执行真正的 flush(WAL 永不截断) - **文件:行号**:`lsm.ts:580-590`(`if (this.lastBackgroundError !== null) { ...; throw ... }` 位于 `drainChain()` 与所有入链动作**之前**)、`193-204`(错误只写不清)、`194-204` 的 catch 恢复链。 - **现象**:任何一次后台 flush/compaction 抛错(例如 `store.save` 配额不足),此后**每一次** `lsm.flush()` 都在第 582-590 行直接抛 `ARIA_BACKGROUND_ERROR` —— memtable **永远不落盘**, `wal.checkpoint()` 永不执行(WAL 无界增长),`close()` 也在第 284 行抛出而跳过后续清理, 应用层除 `repair()` 外无自愈出口。 - **根因**:把"报告上一次错误"与"执行本次 flush"耦合在同一函数入口,且错误用"消费一次"的语义 (583-584 清空)却放在会抛出的路径上 —— 第一次抛错后错误虽被清空,但调用方拿不到"已经 flush 成功", 且**这次调用根本没有 flush**。 - **严重级别**:P2(可用性/持久化停滞;若进程随后崩溃则内存中的数据全丢 = 实际 P0,见 P0-3)。 - **复现思路**:**验证方法**——注入一个 `save()` 第一次抛错、之后正常的 `SSTableStore`: `await lsm.put(...); await lsm.flush().catch(()=>{}); await lsm.flush()` → 第二次仍抛错 且 `getStats().sstableCount` 为 0。 ### P0-3 后台 flush 失败后,内存中的冻结数据没有重试路径(进程崩溃即丢) - **文件:行号**:`lsm.ts:220-261`(`flushImmutableAsync` 在 `save`/`saveMeta` 抛错时, `frozenMemtables` 的清理(255)与 `levels[0].unshift`(253)都不会执行)、`619` (`flush()` 末尾 `filter(f => f.getEntryCount() > 0)` 保留非空冻结表但**不会重新入链**)。 - **现象**:冻结表留在 `frozenMemtables` 里可读(所以在线查询看似正常),但它只被 `flush()` 里 "当前 immutable"那一次入链机会覆盖;一旦那一次失败,后续 `flush()` 受 P1-6 阻断, 这些数据**只存在于内存**。此时进程崩溃 → 数据丢失,而调用方早已收到 `insert()` 成功。 - **根因**:入链是"事件驱动一次",没有"待落盘队列 + 重试"的持久化意图记录; `lastBackgroundError` 只报告不重试;`frozenMemtables` 语义在"读可见集合"与"待落盘队列"之间摇摆。 - **严重级别**:**P0(数据丢失)**。 - **复现思路**:【已读代码确认】+ 可测:注入首次 `save` 失败的 store,`insert` → `flush`(吞错)→ 断言 `frozenMemtables.length > 0` 且 `levels[0].length === 0`;再调用 `flush()` 观察其抛错且仍未落盘。 **验证方法**:在第二次 `flush()` 后断言 `sstableCount > 0` —— 当前失败。 ### P2-1 `compactLevel(level, minFiles=2)` 对单文件层是静默 no-op,`vacuum()` 仍宣称压缩了 6 层 - **文件:行号**:`lsm.ts:486-500`(`if (this.levels[level].length < minFiles) return;`)、 `index.ts:2247-2261`(循环 0..5,`if levelCounts[level] >= 2` 才调用,最后 `return { compiledLevels: 6 }`)。 - **现象**:`levelCounts[level] === 1` 的层永远不会被压缩(尤其 L6,`compactLevelAsync` 还要求 `level < MAX_LSM_LEVELS-1`,499),而 `vacuum()` **硬编码返回 `compactedLevels: 6`**, 与 `aria-maintenance.test.ts:70-74` 只断言"属性存在"的弱断言互相掩盖。 - **严重级别**:P2(空间回收失败 + 指标失真)。 - **复现思路**:【已实测确认】探针中 `compactLevel(1)` 在 L1 只有 1 个文件时直接返回,层结构不变。 **验证方法**:断言 `vacuum()` 的 `compactedLevels` 等于实际执行合并的层数(统计 compaction 前后 SSTable 数量差)。 ### P2-2 compaction 不丢弃被覆盖/被墓碑遮蔽的历史版本(空间与写放大无界) - **文件:行号**:`lsm.ts:537-552`(`drain()` 全量写回新文件,无"同一 key 只保留最新"与 "墓碑可丢"的判定)。 - **现象**:`MergeIterator` 本身已保证同 key 只吐一条(`merge_iterator.ts:148-168`),所以同一份 compaction 产物内部没有重复版本;**但墓碑与"已被墓碑遮蔽的更旧版本"只要不在同一次 compaction 中出现, 就会各自留存**。典型放大:先写 v1(L1)、再写 v2(L2)、再删(L0),三次 compaction 后 三个物理条目仍在,只有最外层墓碑生效。90% 删除场景下空间几乎不回收。 - **严重级别**:P2(空间/写放大;在"删多写少"负载下会退化到不可用)。 - **复现思路**:**验证方法**——`aria-prod-load.test.ts:222-256`(删 90%)后断言 `Σ totalSize` 显著小于删除前体量;当前只会看到总量持平或上涨。 ### P2-3 崩溃窗口内 `saveMeta` 与 `deleteMeta`/`delete` 之间无 WAL/事务保护 - **文件:行号**:`lsm.ts:565-576`(顺序:`cacheSSTable` → `save` → `saveMeta` → `unshift` → `deleteMeta`+`delete` 旧文件);`init` 119-129 仅按 `listMeta()` 重建。 - **现象**:崩溃落在 `saveMeta(new)` 之后、`deleteMeta(old)` 之前时,磁盘上会**同时**存在新文件与旧文件 的两条 meta;重开时 `init()` 全部加载,层内出现**重叠文件**。我逐一验证了每个时点的层间新鲜度, 结论是**当前不会返回错误值**(新文件总是被删文件的超集且更新,同名 key 的新值一定在新文件里), 所以这条不是错误结果 bug;但它① 让层内重叠成为常态,② 泄漏已 `delete` 掉的文件引用会让 `validateSSTable` 在下次打开时**删除对应 meta**(685-711 → 713-725), ③ 孤儿 `sst_*` blob **没有任何清理路径**(`cleanupOrphanPages` 只清理 `pg_*`,`index.ts:352-388`)。 - **严重级别**:P2(元数据/存储泄漏与"重叠层"常态化;若未来引入跨层归并会立刻变成 P1)。 - **复现思路**:【已实测确认】按真实时序(`saveMeta` 后模拟崩溃)重建磁盘状态并重开,层结构为 `L0: old@L0 | L1: new@L1`;数据读取仍正确,但**旧文件残留**。 **验证方法**:断言 compaction 结束后 `listKeys()` 中的 `sst_*` 数量 == `Σ levelCounts`。 ### P2-4 `compaction` 预先 `splice` 整层,长 `await` 期间该层对读者不可见 - **文件:行号**:`lsm.ts:502`(`splice` 清空该层)→ `506-535`(逐个 `await store.load` + `scanAll`)→ `569`(`unshift` 新 meta)。 - **现象**:从 `splice` 到 `unshift` 之间(含 N 次磁盘读 + 全量解析 + 构建 + `save` + `saveMeta`), 该层的所有数据**从 `levels` 中消失**。上层数据仍在,所以最终结果通常完整 —— 但若某个 key **只**存在于该层(例如它已被从上层 compaction 下沉),这段时间内 `get` 会返回"不存在"。 另外 `compactLevel` 是 public(`index.ts:2252-2254` 直接调用), 期间任何并发 `insert`(不 await 链)都可能观察到半完成状态。 - **严重级别**:P2(窗口期内的错误结果;窗口长度正比于层体积)。 - **复现思路**:**验证方法**——在被 splice 的层里放一个"仅此一份"的 key,在 compaction 的 `await store.load` 处注入延迟(monkey-patch),并发 `find` 该 key,应观察到空结果。 ### P2-5 `enqueueCompact` 未走 `enqueueOnChain`,其 Promise 无终结处理器 - **文件:行号**:`lsm.ts:286-302`(`this.flushChain = this.flushChain.then(...).catch(...)`) 对比 `enqueueOnChain`(194-204,走 `.catch` 且链保持 resolved)。 - **现象**:`enqueueCompact` 里的 `.catch` 返回 `undefined`,链会恢复;但它的 `.then` 与 `catch` 之间若 `finally` 内(元数据操作)再抛错,错误会逃逸到无人 `await` 的链尾。由于 `put/delete` 是**同步**调用 `enqueueCompact`(145-147、156-158),调用方拿不到这个 Promise, Node 环境会打印 `unhandledRejection`,浏览器控制台出现噪声,错误处理语义与另一条路径不一致。 - **严重级别**:P3(当前仅噪声;若策略变为 `unhandledRejection` 致命则升级)。 - **复现思路**:**验证方法**——让 `enqueueCompact` 的任务抛错并监听 `process.on('unhandledRejection')`。 ### P2-6 `checkpoint` 只对 `currentTxnId` 做保护,事务活跃期间 WAL 缓冲可能被截断的相邻风险 - **文件:行号**:`index.ts:249-276`(`checkpoint`/`flush` 回调:`if (this.currentTxnId) return;`)、 `wal/log.ts` 的 `flush`/`checkpoint` 语义。 - **现象**:保护只覆盖 Aria 自己的"单活跃事务"字段。事务提交路径是 `wal.append(COMMIT)` → `wal.flush()` → 合并快照(`index.ts:1473-1489`), 其间 `currentTxnId` 仍非空,保护有效 —— 但 `commitTransaction` 把 `currentTxnId = null` (1493)放在**快照合并之后**,即"快照合并完成 → 置空"之间存在一个窗口, 此时若并发的 `insert()` 触发 `checkpointManager.tick()`(`index.ts:664`)→ `checkpoint()` 会立刻执行 `lsm.flush()` + `wal.checkpoint()`,把刚刚 COMMIT 的 WAL 截断。 截断本身安全(数据已在 LSM),但**它同时会 drain 掉正在排队的后台 compaction** —— 属于时序耦合。 - **严重级别**:P3(当前不丢数据,但依赖"合并先于置空"的隐含顺序)。 - **复现思路**:**验证方法**——在 `commitTransaction` 的 `lsm.put` 循环中插入 `await checkpoint.tick()` 的并发调用,断言不会出现"WAL 已截断而 LSM 未落盘"的组合;建议把置空提前到合并**之前**并用 独立的 `committing` 标志保护。 ### P2-7 内存预算只统计主 LSM 的估算,且 `cacheLimitBytes` 按索引 LSM 数量线性放大 - **文件:行号**:`index.ts:2143-2151`(`checkMemoryBudget` 只查 `this.lsm.getEstimatedMemory()`)、 `lsm.ts:167-172`(估算含 `cacheSize`)、`index.ts:168,199,457,1358`(每个 LSM 各自 `cacheLimitBytes = bufferPoolPages × pageSize`)。 - **现象**:N 个二级索引 LSM ⇒ N×1MB 的 SSTable 缓存 + 每索引各自的 `MemTable` (每个默认阈值 4MB,见 `lsm.ts:88-89`)+ 主 LSM 1MB;`maxMemoryMB`(默认 64)**完全看不到**这部分。 再叠加页面化后同一份 SSTable 同时存在于 `BufferPool` 页缓存与 LSM 整文件缓存, 且各索引 `MemTable` 在 `walSyncMode:'none'/'batch'` 下**不落盘也不设上限**。 - **严重级别**:P2(OOM/GC 压力;"maxMemoryMB=64" 的宣称无法兑现)。 - **复现思路**:**验证方法**——建 10 个索引列,写入后断言 `Σ(lsm.getEstimatedMemory()) <= maxMemoryMB*1024*1024`;当前会超。 ### P3-1 `applyWALRecord` 不做任何校验/墓碑语义转换,直接 `lsm.put` - **文件:行号**:`index.ts:1829-1856`。重放 INSERT/UPDATE 直接 `this.lsm.put(key, record.data)`。 - **现象**:WAL 中的行若含 schema 外列或已删除列(历史版本写过),重放会原样落库, 绕过 `validateRow`(与 v0.7.4 修"UPDATE 未知列静默入库"的方向相反)。 - **严重级别**:P3(恢复路径的防御缺口,需先有脏 WAL 才成立)。 - **复现思路**:**验证方法**——伪造一条含 `{ ghost: 1 }` 的 INSERT WAL 记录后重开,断言该列被丢弃。 ### P3-2 `serialize()` 返回内部缓冲区引用;`page_sstable_store.load()` 会 `pageIds.delete(id)` - **文件:行号**:`bloom.ts:66-68`(`return this.bits`,未拷贝)、 `store/page_sstable_store.ts:81`(`this.pageIds.delete(id)` 在**读**路径上执行)。 - **现象**:前者使调用方一旦持有序列化结果后再 `insert` 就会修改"已写出"的字节; 当前 `build()` 的调用顺序(108 行取 bloom → 113 行分配 buf → 119-130 写出,**不再 insert**)恰好安全。 后者使"同一 SSTable 先 `load` 再 `saveMeta`"会丢掉 `pageIds`(`index.ts:1800` 取不到 → meta 无 pageIds → 页面变孤儿)。当前 `save → saveMeta` 紧邻(`lsm.ts:249-250`)故安全, 但 `compactLevelAsync` 的 `load`(511)与本 LSM 的 `saveMeta` 之间**隔着整个合并流程**, 一旦将来把 `pageStore.load` 复用到这里就会踩雷。 - **严重级别**:P3(当前安全,属"靠调用顺序维持"的隐式契约)。 - **复现思路**:**验证方法**——单测:`bf.serialize()` 后再 `bf.insert('x')`,断言先前返回的字节未变(应失败); `pageStore.load(id, ids, size)` 后 `getPageIds(id)` 应仍返回 ids(应失败)。 ### 已排除的怀疑(避免误导) | 怀疑 | 结论 | 依据 | |---|---|---| | Bloom Filter 会产生 false negative | **排除(对未损坏文件)** | 【已实测确认】`h1 ∈ [0,2^32)`、`h2 ∈ [0,2^31)`,`h1 + i*h2` 永不溢出双精度安全整数,`% bits` 结果**恒非负**,故 `Math.abs` 是冗余而非错误;`bits` 恒为 8 的倍数,索引恒在界内。30k 随机 key 单键过滤器 + 5k 随机 key 密集过滤器 + 5 种真实 key 形态各 2000 key,**FN 全部为 0**;真实 SSTable 2000 key 全覆盖读取 0 缺失 | | `Math.abs((h1+i*h2) % bits)` 是哈希 bug(负余数取绝对值) | 排除(同上) | 同上;但 `fromData` 若传入与写入端**不同**的 `numHashes` 会 100% 产生 FN(实测 10 键 9 个 FN)—— 当前文件路径读写都用 footer 里的同一值(`sstable_builder.ts:138` ↔ `sstable.ts:230,246`),故不成立 | | 崩溃中断 compaction 会让旧文件遮蔽新数据 | **排除** | 【已实测确认】逐时点验证层间新鲜度:新产物恒为被删文件的超集且更新,同名 key 的新值必在新文件中 | | PK 删除墓碑 `t:k1\uffff` 会误删 `t:k10` | **排除** | 【已实测确认】`key <= endKey` 的字符串比较下 `t:k1 < t:k10`(第 4 字符 `'\uffff'` vs `'0'`),memtable 与 compaction 后均只删 1 行 | | 红黑树删除/旋转会破坏中序有序性 | **排除(4000 步压力下)** | 【已实测确认】400 键 × 4000 次随机 put/delete 后 `getAllEntries` 与排序参照集**完全一致**,`_size` 准确,`rangeScan` 与过滤结果一致 | | `rangeScan` 边界/`locateBlock` 系列二分有 off-by-one | **排除** | 【已实测确认】`aria-sstable.test.ts` 的 13 组边界(块尾、跨前缀、空表、末块)+ 我的补充探针全绿 | | LSM 端到端会产生错误结果 | **当前未发现(常规路径)** | 【已实测确认】8 组差分 fuzz(插入/更新/删除/按索引列批量更新/按 tag 查索引,400-600 步 × 含/不含 `compactLevel(0..2)`,与参照 Map 全量比对 + 每列索引计数比对):**problems = 0** | --- ## 3. 性能议题 ### 3.1 每次写批次都 `drainChain`,compaction 期间写路径整体停摆 `checkpointManager.tick()`(`index.ts:664`)→ `CheckpointManager.checkpoint()`(`checkpoint.ts:62-69`) → `lsm.flush()`(`lsm.ts:580`)→ `await this.drainChain()`(592)。 `drainChain` 会等到**链上全部后台任务**(含正在跑的整层 compaction:N 次 `store.load` + 全量解析 + 构建 + `save`)结束。默认 `checkpointInterval = 1000`,即**每 1000 个操作就要等一次完整 compaction**。 这正是 CHANGELOG v0.6.1 记录的"10 万行 kv 插入 353s、每批 8~11s"的机制来源; v0.6.1 只移除了"逐行 prefetch"这一半,`tick()` 里的等待仍在。 **观察点**:`aria-prod-load.test.ts:320` 的日志与 240s 护栏就是它的间接度量。 ### 3.2 一次扫描把**全表** SSTable 拉进缓存 `getAllRows`(`index.ts:1601-1614`)与 `findStream`(1219)都先 `prefetchRange(prefix, prefix+'\uffff')` → 遍历**所有层所有 meta**(`lsm.ts:318-327`)→ 逐个 `preloadSSTable`(整文件字节读入 + `verifyChecksum` 全文件 CRC + 构造 Reader)。 对 100k 行表意味着把整个表读进 1MB 上限的缓存(随后被 `trimCache` 清掉), 每次范围查询重复一次全量 I/O。**没有按需/分块读取**。 ### 3.3 二级索引范围查询退化为"全索引扫描 + 逐行主表点查" `tryIndexLookup`(`index.ts:2085-2092`):`$gt/$gte/$lt/$lte` 走 `indexScanToRows(idxLsm, '', '\uffff')` —— **扫全部索引条目**(`prefetchRange('','\uffff')` 把整个 索引拉进缓存),然后对每个 pk 做一次 `lsm.get`(`index.ts:2115-2118`,每次 `get` 会在每层 最多做一次 bloom + 一次块内线性扫描)。复杂度 = O(索引全量) + O(命中行数 × 层数 × 块内扫描)。 更根本的问题在**索引键编码**:`${String(value)}:${pk}` 是**字典序**, 数值范围在字典序下不可用(`"10" < "9"`),所以就算把下推逻辑修好也无法做数值区间扫描。 注释(2086-2089)也坦白这是"为修复 v0.6.2 的丢数据而改为全扫"。 ### 3.4 compaction 不做版本/墓碑回收 → 空间与写放大随层数累积 见 P2-2。每一层的"整层合并"都会把该层**所有**物理条目(含无数历史版本与墓碑)重写一遍, `compactLevelAsync` 还会先把整层 `scanAll` **物化到内存数组**(531-534)。 在"更新同一批行"的负载下,每次 L0→L1 的产物体积 ≈ L0 体积 + L1 既有体积, 形成典型写放大螺旋;`aria-cache.test.ts:212-237`(同一行更新 20 次)之所以能过, 是因为数据量小到看不出来。 ### 3.5 SSTable 块密度低于配置值(索引条目 ≈ 2× 需要) `splitIntoBlocks`(`sstable_builder.ts:171-178`)在"加入当前条目后 ≥ 上限"才封块, 且封块时**把最后一条留到下一块**(175-176),于是首块只含 1 条、其余块 ≈ 上限+1 条。 【已实测确认】用 ~90 字节的行、4096 上限构建时,单块实际可达 ~8.2KB, 块数因此约为理想值的 **1/2**:`IndexEntry` 数组更大(内存)、索引块更大、 `locateBlock` 的二分目标更多、每次点查的"块内线性扫描"更长(`sstable.ts:80`)。 此外 `indexEntries` 在 Reader 构造时**全量物化**(`sstable.ts:253-277`), 每个 Reader 都持有一份"每块一个 key"的索引键数组(按上述密度,10 万行约 1.2 万条/文件)—— **每层每个文件各一份**,且 `prefetchRange` 会把命中的文件全部构造 Reader。 (我把"分块策略错误"降级为性能问题:它的正确性被 `aria-sstable.test.ts:88-159` 的跨块用例覆盖了。) ### 3.6 其他可量化热点 - **`MemTable.put` 每次做两次 `Object.entries` + 一次额外树查找**(`memtable.ts:429-431` + `497-510`):`estimateEntrySize` 对每个字段做 `typeof` 分支,且 `this.tree.find(key)` 是完整一次下降。 - **WAL 编码逐条 `JSON.stringify` + 逐条 `TextEncoder`**(`wal/log.ts:90-109`), 大批量插入时 `appendBatch` 会把整批序列化两遍(编码 + 合并)。 - **`analyzeTable` 的 `avgRowSize` 用 `JSON.stringify(row).length` 逐行再序列化**(`index.ts:2177`), 且 `columnStats` 用 `new Set(rows.map(String))` 再扫一遍 —— 大表上 ANALYZE 是 3~4 遍全表 CPU。 - **`count()` 恒定物化全表**(`index.ts:1229-1236`),无 where 时依然走 `getAllRows`。 - **`alterTable`、`applyDropTableRecovery`、`dropTable` 都是"扫描全表 → 逐行 put/delete"** (`index.ts:1314-1338`、1874-1878、483-487):DROP TABLE 10 万行 = 10 万次 `lsm.put`/`delete` (每次都带 `estimateEntrySize`)+ 10 万条 WAL。 - **`update` 的批内唯一互查/计划阶段对每行调用 `checkUniqueSync`**(`index.ts:1900-1923`), 其中 `idxLsm.rangeScan` 每次都会走完整 `prefetchRange`+`rangeScanLazy`(虽然是单前缀)。 - **`trimAllCaches` 在 `find/update/delete` 末尾各遍历一次全部二级索引**(`index.ts:2136-2141`), 与 `prefetch*` 里的 `trimCache` 叠加导致"装入-驱逐-再装入"抖动。 --- ## 4. 系统性观察 ### 4.1 反复出 bug 的结构性原因 1. **键编码散落在 10+ 处,没有单一编码模块。** 主键 `t:pk`(`index.ts:571,1006,1223`…)、 二级索引 `value:pk`(1382、1953、2237)、索引前缀探针 `v:` ~ `v:\uffff`(605-606、751-758、2063)、 删除 `v:pk`(1945)、唯一性检查(1911-1912)、表范围 `prefix` ~ `prefix+\uffff`(1315、1605、1873、2023)、 命名空间 `idx_${table}_${col}`(200)。任何一处想改(比如给值加类型前缀以修复字典序问题) 都要同步 10 处,漏一处就是静默丢数据 —— v0.6.2 的"索引范围查询丢数据"就是这个模式。 2. **SSTable 解析循环被抄了三份**(`sstable.ts:80/145/182`),越界策略各不相同。 v0.6.1 的 P0(块尾 key 越界漏读)只需要修一处,但另外两处仍在,下一次同类 bug 仍会出现。 3. **不变量没有被断言,也没有被文档化。** 至少五条隐式不变量:① 层号越小越新; ② 同一层文件区间不重叠;③ 索引键 = 块内最后一个 key;④ `minKey/maxKey` 覆盖文件全部内容; ⑤ bloom 的 `numHashes`/位数与写入端一致。它们散落在注释里,没有任何 `assert`、 没有 debug 模式校验、没有属性测试。P0-1 / P1-1 / P2-3 全都是"不变量没人守"的直接后果。 4. **后台任务模型是"fire-and-forget + 吞错 + 单标志互斥"。** `compacting`(一个 boolean 管 6 个层)、`lastBackgroundError`(一次性报告、语义含混)、 `flushChain`(Promise 链自我修补)、`setTimeout` 曾被用来分片后来又被移除(v0.4.3 注释)—— v0.4.2/v0.4.3/v0.6.1/v0.6.3 的修复记录(链被 rejected 卡死、close 后定时器写库、 meta 竞态丢 75% 数据、重复 flush 出 3 个文件)**全部**出自这一块。 5. **"缓存未命中 ⇒ 当作不存在"是本子系统最危险的隐式约定。** 为了保持 `get/rangeScan` 同步, 引擎被迫在**每次**读取前 `drainChain + prefetch`(`index.ts:574,1219,1605,2024,2106,2113,2077`), 这既制造了 3.1/3.2 的性能悬崖,也让"漏一次 prefetch 就静默丢数据"成为常态风险。 `compactLevelAsync` 已经被迫单独修过这个问题(508-515),而 `get` 仍未修 —— 同类修复只做一半。 6. **测试是端到端 + 弱断言为主。** 大量 `expect(x).toBeGreaterThanOrEqual(0)` 形态 (如 `aria-maintenance.test.ts:41,72`),性能测试用 240s 宽护栏掩盖机制性问题, 没有针对 compaction/bloom/块边界的**属性测试**。这解释了为什么 "10 万行 0 错误" 与 "同一个函数里有 3 份解析循环" 能长期共存。 ### 4.2 最高杠杆的架构改动(一条) > **把 LSM 从"同步读 + 调用方负责预取"改成"自洽的异步读",并让后台工作变成显式持久化的任务队列。** 具体分两步、但核心是第一步: 1. **让 `get` / `rangeScan` 自己负责 I/O 与缓存罚失**:`LSM.get` 改为 `async get(key): Promise`,内部对每层调用 `await this.readerFor(meta)`(缓存命中直接用,未命中 `store.load` + CRC + 入缓存), MISS 与"不存在"用不同类型区分;`rangeScanLazy` 改为 async generator, 每块按需 `await` 读取(配合已有的 `scanLazy` 块级生成器,天然支持)。 一旦如此:引擎层所有 `drainChain()+prefetch*` 的调用点(7 处)全部可以删掉, 3.1 的"每批写等 compaction"与 3.2 的"全表预载"同时消失, P1-1(缓存罚失当作不存在)从"靠约定"变成"结构上不可能"。 2. **把 `flushChain` 换成带意图记录的串行工作队列**:`pending: Array<{kind:'flush'|'compact', level?, frozen?}>` + 每个任务的终态(成功/失败/待重试)显式记录,失败任务**保留在队列里可重试** (直接解决 P0-3、P1-6),`compacting` 单标志换成 `Set`(解决 P1-5), 并发 compaction 的层集合由队列调度器保证互斥。 配套(低成本、高收益):抽出唯一的 `keyCodec` 模块(`encodePk/encodeIdx/encodeIdxValueRange/tableRange`), 把 `sstable.ts` 的三份解析循环合并为一份"块迭代器"(`get`/`scanLazy`/`scanAll` 都基于它), 并在 `SSTableReader` 构造时加 debug 断言(索引键单调递增、块偏移在文件内、`minKey<=maxKey`)。 --- ### 附:本次审计中已删除的探针清单(工作区无残留) `tests/engine/` 下共 16 个临时测试文件,已全部 `rm`(`git status` 已确认无残留): ``` zz-probe1.test.ts zz-probe2.test.ts zz-lsmprobe.test.ts zz-lsmprobe2.test.ts zz-crash-probe.test.ts zz-delprobe.test.ts zz-delprobe2.test.ts zz-fuzz.test.ts zz-txnprobe.test.ts zz-dup-probe.test.ts zz-idx-probe.test.ts zz-cache-hole.test.ts zz-cache-hole2.test.ts zz-cache-hole3.test.ts zz-cache-hole4.test.ts zz-cache-hole5.test.ts ``` (编号 3–8 的 bloom 探针是在 `zz-probe1.test.ts` 内迭代改写后重跑,未额外留文件。) 审计未修改任何 `src/` 文件;`tests/zz-verify-kv*.test.ts` 与三份 `AUDIT-*/PLAN-*` 属其它会话产物,非本次创建。