Files
MetonaSqlark/AUDIT-aria-lsm-v0.7.4.md
T
thzxx c8b59bd16f docs: v0.7.4 全量深度审计 + v0.8.0 根治性迭代方案
- 全源码逐行通读(src 16079 行 / tests 20223 行 / 文档 4349 行),7 个子系统并行深度审计
- PLAN-v0.7.5.md:8 大根因分析 + 3 条并行工作流 + 55 项缺陷总账 + 6 道硬门禁
- AUDIT-aria-lsm / AUDIT-query-layer / AUDIT-storage-engines:逐条 file:line 证据
- 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
2026-09-14 20:37:44 +08:00

559 lines
49 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.20.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 格式与读写
**布局(v2magic `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-36compaction 用,全量物化)
`GeneratorEntrySource`43-58v0.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 = 1MBindex.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<key,row>`**
- 事务内写只进快照 + `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.appendBatchfull=逐批落盘 / 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(活跃事务时 skipindex.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_<table>_<col>_`、meta key `__aria_lsm_meta_<ns>`、**独立 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'` 下**不落盘也不设上限**。
- **严重级别**P2OOM/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` 会等到**链上全部后台任务**(含正在跑的整层 compactionN 次 `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<Entry | MISS>`,内部对每层调用
`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<level>`(解决 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-*` 属其它会话产物,非本次创建。