- 全源码逐行通读(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 证据 - 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
49 KiB
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 降序(init128 行、flushunshift253 行)。
- 去重只在"同一次
- 墓碑处理: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上 (enqueueOnChain194-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→ 主表全扫兜底(find680 行)。 - 两个 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已把需要的文件装进缓存"这一约定(prefetchRange314-328、prefetchKeys331-352)。 - 根因:
cacheLimitBytes会驱逐(trimCache792-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), 至少把"缓存未命中"与"确实不存在"在类型上分开(nullvsundefined/MISS)。
P1-2 rangeScanLazy 的 callback 返回值契约与 rangeScan 包装自相矛盾(早停未真正生效)
- 文件:行号:
src/engine/aria/index/lsm.ts:440-479(callback声明返回boolean | void, 473-475if (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旧文件);init119-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 的结构性原因
- 键编码散落在 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 的"索引范围查询丢数据"就是这个模式。 - SSTable 解析循环被抄了三份(
sstable.ts:80/145/182),越界策略各不相同。 v0.6.1 的 P0(块尾 key 越界漏读)只需要修一处,但另外两处仍在,下一次同类 bug 仍会出现。 - 不变量没有被断言,也没有被文档化。 至少五条隐式不变量:① 层号越小越新;
② 同一层文件区间不重叠;③ 索引键 = 块内最后一个 key;④
minKey/maxKey覆盖文件全部内容; ⑤ bloom 的numHashes/位数与写入端一致。它们散落在注释里,没有任何assert、 没有 debug 模式校验、没有属性测试。P0-1 / P1-1 / P2-3 全都是"不变量没人守"的直接后果。 - 后台任务模型是"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 个文件)全部出自这一块。 - "缓存未命中 ⇒ 当作不存在"是本子系统最危险的隐式约定。 为了保持
get/rangeScan同步, 引擎被迫在每次读取前drainChain + prefetch(index.ts:574,1219,1605,2024,2106,2113,2077), 这既制造了 3.1/3.2 的性能悬崖,也让"漏一次 prefetch 就静默丢数据"成为常态风险。compactLevelAsync已经被迫单独修过这个问题(508-515),而get仍未修 —— 同类修复只做一半。 - 测试是端到端 + 弱断言为主。 大量
expect(x).toBeGreaterThanOrEqual(0)形态 (如aria-maintenance.test.ts:41,72),性能测试用 240s 宽护栏掩盖机制性问题, 没有针对 compaction/bloom/块边界的属性测试。这解释了为什么 "10 万行 0 错误" 与 "同一个函数里有 3 份解析循环" 能长期共存。
4.2 最高杠杆的架构改动(一条)
把 LSM 从"同步读 + 调用方负责预取"改成"自洽的异步读",并让后台工作变成显式持久化的任务队列。
具体分两步、但核心是第一步:
- 让
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(缓存罚失当作不存在)从"靠约定"变成"结构上不可能"。 - 把
flushChain换成带意图记录的串行工作队列:pending: Array<{kind:'flush'|'compact', level?, frozen?}>- 每个任务的终态(成功/失败/待重试)显式记录,失败任务保留在队列里可重试
(直接解决 P0-3、P1-6),
compacting单标志换成Set<level>(解决 P1-5), 并发 compaction 的层集合由队列调度器保证互斥。
- 每个任务的终态(成功/失败/待重试)显式记录,失败任务保留在队列里可重试
(直接解决 P0-3、P1-6),
配套(低成本、高收益):抽出唯一的 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-* 属其它会话产物,非本次创建。