- 全源码逐行通读(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 证据 - 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
559 lines
49 KiB
Markdown
559 lines
49 KiB
Markdown
# 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<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.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_<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'` 下**不落盘也不设上限**。
|
||
- **严重级别**: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<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-*` 属其它会话产物,非本次创建。
|