Просмотр исходного кода

写操作提交栅栏 + 孤儿 WAL 自愈 + 审计器 WAL 重放

根因 (DockerManager 生产事故): 插入一行要脏"数据页+主键页+二级索引页",
脏页阈值触发的中途提交会把同一操作切成两笔 WAL; kill 落在切点时
"数据页已提交、索引页未提交"被烧成正式状态 —— 行按主键在、按二级索引
永远查不到 (monitor_metrics 2 行 + 1665 个裂页残留 child 指针)。
显式事务本就被 currentTxn 挡住自动提交, 普通写入路径没有这个保护。

- DB: opDepth/beginOp 提交栅栏, 挂到插/改/删/批量删重建/建索引共 7 个
  多页写路径; 操作期间阈值与防抖提交一律抑制, 收尾按 DirtyOverThreshold
  条件补提交 (阈值被越过立即提交, 否则重新武装防抖)
- DB: 孤儿 WAL 链启动自愈 healOrphanWALPages (空闲链自洽才动手,
  只回收不重放 —— 已提交事务的回写必先于指针清零落盘)
- DB: 头页仅在有真实变更 (分配游标移动) 时随 WAL 落盘
- analyzer: 检测到待恢复 WAL (FirstWALPage!=0) 时先拷临时副本重放
  再审计, 不再把"未重放"误报成 CORRUPT
- demo/idx_crash_chaos: 二级索引断电混沌 (阈值=1 + 随机强杀 + 逐行反查);
  禁用栅栏第 1 轮即复现幽灵行, 启用后 360 轮全过

验证: wal_crash_chaos 120 轮 / txn_crash_chaos 60 轮 / DB、DB/core、
migrate、analyzer 全量测试无回归
yl32940 3 недель назад
Родитель
Сommit
5d0e429ee6

+ 45 - 6
DB/core/db.go

@@ -60,6 +60,12 @@ type DB struct {
 	// "单写入者模型"约束 (同一时刻只允许一个 goroutine 跑写入路径)。
 	currentTxn atomic.Pointer[Transaction]
 
+	// opDepth 行级多页写操作的嵌套深度 (InsertRow/UpdateRow/DeleteRow/批量/建索引/批量删)。
+	// >0 时阈值/防抖提交一律不触发: 否则 Commit 会把同一操作的脏页切成两笔 WAL,
+	// kill 后烧出半成品 (DockerManager 实测: 数据页已提交、索引页未提交, 行按主键
+	// 在、按二级索引永远查不到)。深度归零时由收尾闭包补一次提交检查。
+	opDepth atomic.Int32
+
 	// pendingMu 保护 pendingFree / pendingFreeChain: 写入路径登记、提交路径出队,
 	// 两条路径并发 (写线程插入/删除 + 后台提交线程) 时必须串行化, 否则切片并发读写
 	// 会丢登记项 / 触发数据竞争。
@@ -292,6 +298,14 @@ func OpenWithOptions(path string, opt OpenOptions) (*DB, error) {
 			_ = ff.f.Close() // 恢复失败必须释放文件句柄, 否则 Windows 上文件被占用
 			return nil, err
 		}
+		// 孤儿 WAL 链自愈: kill 恰落在"WAL 页已写盘、头页指针未发布(或已被后续
+		// 提交清掉)"的窗口时, 盘上会留下 FirstWALPage 找不到的 WAL 链页, 永不
+		// 回收。这里把它们整条捡回空闲链 (不重放: 已提交事务的回写必先于指针
+		// 清零落盘, 重放只是幂等; 未提交事务的镜像本就该丢弃 —— 回收即丢弃)。
+		if err := healOrphanWALPages(ff, header); err != nil {
+			_ = ff.f.Close()
+			return nil, err
+		}
 	}
 
 	// 恢复完成后再载入表元数据: 上面重放过的表页此刻才是最新状态
@@ -736,11 +750,16 @@ func (db *DB) Commit() error {
 		wal.RecordPage(d.PageID, d.Data)
 	}
 
-	// 4. HeaderPage 也纳入 (pageID=0)
-	//    marshalHeader 要读整张头页 (含提交路径正在改的 WAL 链指针), 必须在 headerMu 内拍快照。
-	db.file.withHeader(func() {
-		wal.RecordPage(0, marshalHeader(db.header))
-	})
+	// 4. HeaderPage 只在有【真实】变更时纳入 (pageID=0): allocPage 推进分配游标
+	//    会置 headerDirty, 新页号必须随本笔 WAL 落盘 —— 否则崩溃重放后盘上旧游标
+	//    会把这批新页二次分配。纯改写已有页的提交不动头页, 省掉 4K 镜像与回写;
+	//    NextTxnID / ModifyTime 这类诊断字段只随内存游标前进, 不强制头页落盘。
+	if db.file.IsHeaderDirty() {
+		//    marshalHeader 要读整张头页 (含提交路径正在改的 WAL 链指针), 必须在 headerMu 内拍快照。
+		db.file.withHeader(func() {
+			wal.RecordPage(0, marshalHeader(db.header))
+		})
+	}
 
 	// 5. 原子提交
 	if err := wal.Commit(); err != nil {
@@ -801,12 +820,32 @@ func (db *DB) NotifyWrite() {
 	}
 }
 
+// beginOp 标记一个行级多页写操作的开始, 返回闭包在操作结束时调用 (defer 形态,
+// 嵌套安全: 深度计数, 最外层收尾)。收尾只做一件事: 重新武装防抖提交 —— 提交
+// 时机仍由防抖/阈值策略决定, 这里保证的是"任何提交都不早于操作结束"。
+func (db *DB) beginOp() func() {
+	db.opDepth.Add(1)
+	return func() {
+		if db.opDepth.Add(-1) == 0 {
+			if db.file.DirtyOverThreshold() {
+				db.autoCommit() // 阈值在操作期间被越过 (被栅栏压住): 立即补提交
+			} else {
+				db.NotifyWrite() // 否则重新武装防抖定时器
+			}
+		}
+	}
+}
+
 // autoCommit 自动提交入口 (防抖定时器 / 脏页阈值共用)
-// 有活跃事务时直接跳过, 避免把事务从中途截断提交
+// 有活跃事务时直接跳过, 避免把事务从中途截断提交; 行级写操作中途同样跳过
+// (opDepth>0), 等操作收尾时由 beginOp 的闭包统一补提交。
 func (db *DB) autoCommit() {
 	if db.currentTxn.Load() != nil {
 		return
 	}
+	if db.opDepth.Load() > 0 {
+		return
+	}
 	_ = db.Commit()
 }
 

+ 1 - 0
DB/core/deleter.go

@@ -35,6 +35,7 @@ func (d *Deleter) Where(cond Condition) *Deleter {
 // 避免逐行级联删除 B+ 树的高昂开销
 func (d *Deleter) Exec() (int64, error) {
 	defer d.table.db.lockOp()() // 批量删除全程串行; 内部 scanWithPKID/重建索引同 goroutine 重入
+	defer d.table.db.beginOp()() // 软删 + 全索引重建是一个操作, 中途阈值提交会切成半成品
 	// 找到主键索引
 	var pkMeta *IndexMeta
 	for i := range d.table.indexMeta.metas {

+ 14 - 0
DB/core/file.go

@@ -104,6 +104,12 @@ func (ff *File) SetDirtyThreshold(threshold int, onThreshold func()) {
 	}
 }
 
+// DirtyOverThreshold 脏页数是否已达到阈值 (无缓冲池时 false)。
+// beginOp 收尾用它判断"阈值在写操作期间被越过"并补提交。
+func (ff *File) DirtyOverThreshold() bool {
+	return ff.bp != nil && ff.bp.OverThreshold()
+}
+
 // SetOnPageWriteCallback 设置页写入回调(用于事务登记被修改的页)
 func (ff *File) SetOnPageWriteCallback(callback func(pageID uint32)) {
 	if ff.bp != nil {
@@ -318,6 +324,14 @@ func (ff *File) FlushHeader(h *HeaderPage) error {
 	return ff.writeHeaderLocked(h)
 }
 
+// IsHeaderDirty 头页自上次落盘以来是否有未持久化的真实变更 (当前即分配游标移动)。
+// db.Commit 据此决定要不要把头页镜像纳入本笔 WAL —— 无分配的纯改写提交不动头页。
+func (ff *File) IsHeaderDirty() bool {
+	ff.headerMu.Lock()
+	defer ff.headerMu.Unlock()
+	return ff.headerDirty
+}
+
 // allocPage 线程安全地分配一个页号: 在 headerMu 内摘空闲链 / 递增游标并推进头页,
 // 随后把头页落盘。
 //

+ 9 - 0
DB/core/pool/buffer_pool.go

@@ -80,6 +80,15 @@ func (bp *BufferPool) SetDirtyThreshold(threshold int, onThreshold func()) {
 	bp.onThreshold = onThreshold
 }
 
+// OverThreshold 当前脏页数是否已达到阈值 (threshold<=0 表示不限, 恒 false)。
+// 与 WritePage 里的触发条件 (dirtyCount >= dirtyThreshold) 严格同一条, 供 DB 层
+// 在写操作收尾时补"操作期间被提交栅栏抑制的阈值提交"。
+func (bp *BufferPool) OverThreshold() bool {
+	bp.mu.Lock()
+	defer bp.mu.Unlock()
+	return bp.dirtyThreshold > 0 && bp.dirtyCount >= bp.dirtyThreshold
+}
+
 // LockWrites 锁定写入(Commit/Flush 期间调用)
 func (bp *BufferPool) LockWrites() {
 	bp.mu.Lock()

+ 4 - 0
DB/core/table_crud.go

@@ -34,6 +34,7 @@ func (table *Table) InsertRowWithID(pkID uint64, values []types.FieldValue) (uin
 		return 0, err
 	}
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 插入 = 数据页+各级索引的多页写, 中途禁止阈值提交截断
 	if pkID == 0 {
 		pkID = table.AllocPKID()
 	}
@@ -125,6 +126,7 @@ func (table *Table) InsertRowsReserved(pkIDs []uint64, values [][]types.FieldVal
 }
 
 func (table *Table) insertRows(pkIDs []uint64, values [][]types.FieldValue, allReserved bool) ([]uint64, error) {
+	defer table.db.beginOp()() // 整批是一个操作: 批内任何一点都不许阈值提交
 	n := len(values)
 	if n == 0 {
 		return nil, nil
@@ -235,6 +237,7 @@ func (table *Table) DeleteRowByPK(pkID uint64) error {
 		return err
 	}
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 删除 = 数据页+全部索引的多页写, 中途禁止阈值提交
 	// 一次回表: 既拿到行数据用于释放动态字段的堆块, 也下传给索引层定位 pkID。
 	storeRow, found := table.indexMeta.ResolveByPK(pkID)
 	if !found || len(storeRow) < 8 {
@@ -263,6 +266,7 @@ func (table *Table) UpdateRowByPK(pkID uint64, values []types.FieldValue) error
 		return err
 	}
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 更新 = 数据页+全部索引的多页写, 中途禁止阈值提交
 	// 旧行既用于复用动态字段堆块, 也用于让索引层提取"旧键" (时序键变了要搬分段)。
 	storeRow, found := table.indexMeta.ResolveByPK(pkID)
 	if !found || len(storeRow) < 8 {

+ 3 - 0
DB/core/table_index.go

@@ -34,6 +34,7 @@ func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byt
 		return nil, errDDLInTransaction("CreateIndex")
 	}
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 对存量行建索引是多页写, 中途阈值提交会切成半成品索引
 	for i, fi := range fields {
 		if int(fi) >= len(table.Field) {
 			return nil, fmt.Errorf("%w: 索引 %q 的第 %d 个分量", ErrFieldNotFound, idxName, i+1)
@@ -263,6 +264,7 @@ func (table *Table) SearchByIndex(idxName string, key []byte) ([]types.FieldValu
 // DeleteRow 通过索引删除一行
 func (table *Table) DeleteRow(idxName string, key []byte) error {
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 删除 = 数据页+全部索引的多页写, 中途禁止阈值提交
 	// 一次定位: 既用于释放该行的堆页引用, 也下传给索引层删除 —— 避免同一行被查两次树
 	storeRow, found := table.indexMeta.SearchByIndex(idxName, key)
 	if !found || len(storeRow) < 8 {
@@ -306,6 +308,7 @@ func (table *Table) checkUniqueUpdate(values []types.FieldValue, oldRowData []by
 // UpdateRow 通过索引更新一行
 func (table *Table) UpdateRow(idxName string, key []byte, values []types.FieldValue) error {
 	defer table.db.lockOp()()
+	defer table.db.beginOp()() // 更新 = 删旧键+插新键+数据页的多页写, 中途禁止阈值提交
 	// 先取旧行: 动态字段可复用旧行已占用的堆块, 避免每次更新都产生堆垃圾。
 	// 该行同时下传给 UpdateByKey, 免去索引层再次回表。
 	storeRow, found := table.indexMeta.SearchByIndex(idxName, key)

+ 99 - 0
DB/core/wal_recovery.go

@@ -296,3 +296,102 @@ func recoverHeader(ff *File, data []byte) error {
 	_, err := ff.f.WriteAt(data[:PageSize4K], 0)
 	return err
 }
+
+// healOrphanWALPages 孤儿 WAL 链自愈。
+//
+// 背景: 正常提交里 WAL 页写入与头页指针发布是同句柄程序序, kill 拷出的文件
+// 要么指针指向完整链 (RecoverWAL 重放), 要么链根本没写过 —— 但直写路径/旧版本
+// 引擎可能留下"头页说没有 WAL、分配区间里却躺着类型=WALIndex 的链页"的中间态,
+// 这些页不在空闲链上, 只能永久泄漏 (表现为 Analyzer 的 unaccounted/WAL 残留)。
+//
+// 判定刻意保守, 宁漏勿错:
+//   - 只在 FirstWALPage==0 (没有待恢复事务) 时动手;
+//   - 先实走空闲链, 必须与 header.FreePageCount 完全一致 (不成环、不越界、计数
+//     相符) —— 这保证"不在空闲链上"这个判定本身可信, 不会把"其实在空闲链上、
+//     只是头页版本错位走不到"的页二次回收 (二次回收会弄断空闲链);
+//   - 链首 = TotalEntries>0 的 WAL 页 (续页该字段恒 0), 整链必须全部由
+//     WALIndex 页组成、且不与空闲链/分配区间相交, 否则整条放弃;
+//   - 回收不重放镜像: 若链属已提交事务, 其数据页回写 (wal.Commit 步骤 3) 先于
+//     指针清零 (步骤 5) 落盘, 镜像已在盘上, 重放只是幂等覆盖; 若属未提交事务,
+//     丢弃才是正确语义。统一 recycleChainWithCache 整条还链。
+func healOrphanWALPages(ff *File, header *HeaderPage) error {
+	if header.FirstWALPage != 0 {
+		return nil // 有待恢复事务, 归 RecoverWAL 全权处理
+	}
+	// 空闲链自洽性: 实走长度必须与声明值一致
+	freeSet := make(map[uint32]bool, header.FreePageCount)
+	for pid := header.FreePageStart; pid != 0; {
+		if freeSet[pid] {
+			return nil // 成环: 空闲链已不可信, 放弃自愈
+		}
+		freeSet[pid] = true
+		buf := make([]byte, PageSize)
+		if err := ff.readPageDisk(pid, buf); err != nil {
+			return nil // 链中途读失败: 同样放弃
+		}
+		pid = page.HdrOf(buf).NextPage
+	}
+	if uint32(len(freeSet)) != header.FreePageCount {
+		return nil // 计数不符: 头页与链内容版本错位, freeSet 不可信
+	}
+	// 分配区间 = [2, NextFreePage), 与文件实际页数取交集兜底
+	fi, err := ff.f.Stat()
+	if err != nil {
+		return nil
+	}
+	total := (fi.Size() - int64(PageSize4K)) / int64(PageSize)
+	limit := int64(header.NextFreePage)
+	if limit <= 2 || limit > total+1 {
+		limit = total + 1 // 游标不可信时用文件大小
+	}
+	buf := make([]byte, PageSize)
+	for p := uint32(2); int64(p) < limit; p++ {
+		if freeSet[p] {
+			continue
+		}
+		if err := ff.readPageDisk(p, buf); err != nil {
+			continue
+		}
+		if page.HdrOf(buf).Type != page.WALIndex {
+			continue
+		}
+		if page.WalHdrOf(buf).TotalEntries == 0 {
+			continue // 续页/游离页, 跟着链首一并回收
+		}
+		// 找到一个链首: 先走完整链, 校验全部由 WAL 页组成且不越界
+		// (cnt 上限防循环链: 链页互不相交, 正常不会到达 limit)
+		head, tail, cnt := p, p, uint32(0)
+		ok := true
+		for q := head; q != 0; {
+			if freeSet[q] || int64(q) >= limit || int64(cnt) >= limit {
+				ok = false // 与空闲链/分配区间相交, 或链成环: 不是完整孤儿链
+				break
+			}
+			tail = q
+			if err := ff.readPageDisk(q, buf); err != nil {
+				ok = false
+				break
+			}
+			if page.HdrOf(buf).Type != page.WALIndex {
+				ok = false
+				break
+			}
+			cnt++
+			q = page.HdrOf(buf).NextPage
+		}
+		if !ok || cnt == 0 {
+			continue
+		}
+		var recErr error
+		ff.withHeader(func() {
+			recErr = recycleChainWithCache(ff, header, head, tail, cnt)
+			if recErr == nil {
+				recErr = ff.writeHeaderLocked(header)
+			}
+		})
+		if recErr != nil {
+			return fmt.Errorf("孤儿 WAL 链回收失败 (head=%d, %d 页): %w", head, cnt, recErr)
+		}
+	}
+	return ff.sync()
+}

+ 1 - 1
Makefile

@@ -123,7 +123,7 @@ DEMO_DIRS := $(patsubst demo/%/main.go,%,$(wildcard demo/*/main.go))
 # 重型 demo: 跑得久或吃资源 (千万行压测 / 极端配置), 不进默认的 test-demos。
 # 它们同样必须能跑通, 只是要在需要时显式点名: make test-demos-heavy
 # 顺序从轻到重, 先看到多数结果, 最慢的 bench_10m 放最后。
-HEAVY_DEMOS := hash_bench stress_small_config verify_stress stress_extreme_config bench_10m
+HEAVY_DEMOS := hash_bench stress_small_config verify_stress stress_extreme_config bench_10m wal_crash_chaos txn_crash_chaos idx_crash_chaos
 
 # 单独跑一个 demo: make demo-verify_unique_index
 demo-%:

+ 73 - 1
analyzer/main.go

@@ -1,9 +1,12 @@
 package main
 
 import (
+	"crypto/sha1"
 	"encoding/binary"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
+	"io"
 	"log"
 	"math"
 	"math/big"
@@ -21,6 +24,7 @@ import (
 
 	// analyzer 的 go.mod 有 replace TsDB2 => ../, 可以直接读 core 的类型表。
 	// 列型的宽度/名字改为投影自 typeRegistry, 见 fSz / fName 的注释。
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 )
 
@@ -1388,6 +1392,17 @@ func openH(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 	}
+	// 待恢复 WAL 的库: 先拷到临时副本、用引擎打开一次 (触发 WAL 重放 + 孤儿 WAL
+	// 自愈), 再分析副本。本工具是只读审计, 自己从不重放 —— 直接分析裸字节会把
+	// "未重放"误报成一堆 CORRUPT (页版本错位 / free-live / missing-entry)。
+	orig := q.P
+	replayNote := ""
+	if rp, err := replayWALToTemp(q.P); err != nil {
+		replayNote = "wal replay failed: " + err.Error()
+	} else if rp != "" {
+		q.P = rp
+		replayNote = "WAL 已在临时副本上重放, 审计对象为副本"
+	}
 	// 切库: 换掉共享句柄, 作废审计缓存
 	if e := useDB(q.P); e != nil {
 		jw(w, map[string]any{"ok": false, "e": "open failed: " + e.Error()})
@@ -1407,7 +1422,64 @@ func openH(w http.ResponseWriter, r *http.Request) {
 	startAuditAsync()
 	// 页映射索引同样在后台建好 (排在审计之后), 免得用户点开概览时再等一次全库扫描。
 	startPagemapAsync()
-	jw(w, map[string]any{"ok": true, "s": st(fi)})
+	m := map[string]any{"ok": true, "s": st(fi)}
+	if orig != q.P {
+		m["orig"] = orig
+	}
+	if replayNote != "" {
+		m["note"] = replayNote
+	}
+	jw(w, m)
+}
+
+// replayWALToTemp 库头里挂着未恢复 WAL (FirstWALPage != 0) 时, 把文件拷到临时
+// 副本并用引擎打开一次 —— Open 会自动重放 WAL (连同孤儿 WAL 链自愈), Close 后
+// 副本即是重放后的最终状态, 审计它才不会把"未重放"误报成一堆 CORRUPT。
+// 审计只读、永不写原库: 重放发生在【副本】上, 原文件一个字节不动。
+// 无需重放返回 (""); 副本路径按 源路径+大小+mtime 命名, 同一份库反复打开时复用。
+func replayWALToTemp(src string) (string, error) {
+	f, err := os.Open(src)
+	if err != nil {
+		return "", err
+	}
+	hb := make([]byte, 4096)
+	_, rerr := f.ReadAt(hb, 0)
+	f.Close()
+	if rerr != nil {
+		return "", rerr
+	}
+	if binary.LittleEndian.Uint32(hb[68:72]) == 0 {
+		return "", nil // 无待恢复 WAL
+	}
+	fi, _ := os.Stat(src)
+	sum := sha1.Sum([]byte(fmt.Sprintf("%s|%d|%d", src, fi.Size(), fi.ModTime().UnixNano())))
+	dst := filepath.Join(os.TempDir(), "tsdb2-analyzer-replay-"+hex.EncodeToString(sum[:8])+".db")
+	if !fE(dst) {
+		in, err := os.Open(src)
+		if err != nil {
+			return "", err
+		}
+		out, err := os.Create(dst)
+		if err != nil {
+			in.Close()
+			return "", err
+		}
+		_, cerr := io.Copy(out, in)
+		in.Close()
+		out.Close()
+		if cerr != nil {
+			return "", cerr
+		}
+	}
+	// 引擎打开副本: RecoverWAL + 孤儿 WAL 自愈在这里跑完, Close 落盘。
+	db, err := core.OpenWithOptions(dst, core.OpenOptions{})
+	if err != nil {
+		return "", err
+	}
+	if err := db.Close(); err != nil {
+		return "", err
+	}
+	return dst, nil
 }
 func st(fi os.FileInfo) map[string]any {
 	s := int64(0)

+ 232 - 0
demo/idx_crash_chaos/main.go

@@ -0,0 +1,232 @@
+package main
+
+// =============================================================================
+// idx_crash_chaos — 二级索引一致性断电混沌
+//
+// 针对生产事故 (DockerManager): kill 后出现"行在盘上、二级索引永远查不到"的
+// 半操作固化。根因是脏页阈值触发的中途提交把"插入一行 (数据页+索引页)"切成
+// 两笔 WAL。本压测就是那个事故形态的故障注入:
+//
+// 子进程: 建带二级索引的表, 关掉防抖、脏页阈值调到 1 (每写一页都顶爆阈值,
+//         提交压力拉满), 然后不停单行插入 —— 不开事务、不手动 Commit。
+// 父进程: 随机 20~250ms 强杀 → 重开 (WAL 恢复) → 逐行校验【反向一致性】:
+//         全表扫到的每一行, 用它自己的索引列值走二级索引等值查, 必须命中自己。
+//         少一条 = 事故复现 = 失败。
+//
+// 默认 60 轮 (TSDB_IDX_ROUNDS), 种子固定可复现 (TSDB_IDX_SEED)。
+// =============================================================================
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"time"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+const (
+	envChild  = "TSDB_IDX_CHAOS_CHILD"
+	envDB     = "TSDB_IDX_DB"
+	envRounds = "TSDB_IDX_ROUNDS"
+	envSeed   = "TSDB_IDX_SEED"
+	envWindow = "TSDB_IDX_WINDOW" // 子进程随机存活上限 ms (默认 250)
+	envThresh = "TSDB_IDX_THRESH" // 脏页阈值 (默认 1, 每页都触发)
+)
+
+func main() {
+	if os.Getenv(envChild) == "1" {
+		runChild()
+		return
+	}
+	runParent()
+}
+
+// =============================================================================
+// 子进程: 建库建表, 关防抖 + 阈值 1, 无限单行插入直到被强杀
+// =============================================================================
+
+func runChild() {
+	path := os.Getenv(envDB)
+	db, err := core.Open(path)
+	if err != nil {
+		os.Exit(2)
+	}
+	db.SetAutoCommitDelay(0)                        // 防抖关掉: 提交完全由阈值驱动
+	db.SetDirtyThreshold(envInt(envThresh, 1))      // 阈值 1: 每个写操作都在顶爆阈值
+	db.SetBufferPoolCapacity(4096)
+
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_String, Flag: types.WidthCodeFor(16), FieldName: []byte("svc")},
+	})
+	if err != nil {
+		os.Exit(2)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		os.Exit(2)
+	}
+	if _, err := tbl.CreateIndex("idx_svc", page.IndexTypeBTree, []byte{1}); err != nil {
+		os.Exit(2)
+	}
+
+	for i := uint64(1); ; i++ {
+		svc := fmt.Sprintf("s%013d", i) // 每行一个唯一值, 反查才精确
+		if _, err := tbl.InsertRowWithID(i, []types.FieldValue{
+			{FieldName: []byte("id"), Value: i},
+			{FieldName: []byte("svc"), Value: svc},
+		}); err != nil {
+			os.Exit(2)
+		}
+	}
+}
+
+// =============================================================================
+// 父进程: 循环 起子进程 → 随机强杀 → 重开 → 反向一致性校验
+// =============================================================================
+
+func runParent() {
+	rounds := envInt(envRounds, 60)
+	baseSeed := int64(envInt(envSeed, 1))
+	rng := rand.New(rand.NewSource(baseSeed))
+	killWindow := envInt(envWindow, 250)
+
+	fmt.Printf("二级索引断电混沌: %d 轮, 种子 %d (复现: %s=%d %s=轮数 go run main.go)\n",
+		rounds, baseSeed, envSeed, baseSeed, envRounds)
+	for r := 1; r <= rounds; r++ {
+		if err := oneRound(rng, killWindow); err != nil {
+			fmt.Printf("❌ 第 %d 轮失败 (种子 %d): %v\n", r, baseSeed, err)
+			fmt.Printf("   复现: %s=%d %s=%d go run main.go\n", envSeed, baseSeed, envRounds, r)
+			os.Exit(1)
+		}
+		if r%10 == 0 {
+			fmt.Printf("  进度 %d/%d 通过\n", r, rounds)
+		}
+	}
+	fmt.Printf("✅ 二级索引断电混沌通过: %d 轮, 每行随机时刻强杀后 —— 存活行全部可被二级索引命中 (无半操作固化)\n", rounds)
+}
+
+func oneRound(rng *rand.Rand, killWindow int) error {
+	dir, err := os.MkdirTemp("", "tsdb-idx-chaos-")
+	if err != nil {
+		return err
+	}
+	path := filepath.Join(dir, "chaos.db")
+
+	exe, err := os.Executable()
+	if err != nil {
+		os.RemoveAll(dir)
+		return err
+	}
+	cmd := exec.Command(exe)
+	cmd.Env = append(os.Environ(), envChild+"=1", envDB+"="+path,
+		envThresh+"="+os.Getenv(envThresh))
+	if err := cmd.Start(); err != nil {
+		os.RemoveAll(dir)
+		return err
+	}
+	// 杀点均匀覆盖: 提交前 / WAL 写链中 / 页回写中 / 两 fsync 之间 / 操作页写入中途
+	time.Sleep(time.Duration(20+rng.Intn(killWindow-20)) * time.Millisecond)
+	_ = cmd.Process.Kill()
+	_ = cmd.Wait() // 回收句柄, 等文件锁释放
+
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		os.RemoveAll(dir)
+		return nil // 建文件前被杀, 无可验对象
+	}
+	defer os.RemoveAll(dir)
+	return verify(path, dir)
+}
+
+// =============================================================================
+// 反向一致性校验: 全表扫到的每一行, 用它自己的 svc 值走二级索引等值查,
+// 必须命中自己。少一条即"行在盘上、索引未命中"—— 事故复现。
+// =============================================================================
+
+func verify(path, dir string) error {
+	db, err := core.Open(path) // RecoverWAL + 孤儿 WAL 自愈在这里跑
+	if err != nil {
+		return fmt.Errorf("崩溃后重开失败: %w (现场: %s)", err, dir)
+	}
+	defer db.Close()
+
+	tbl, ok := db.FindTable("t")
+	if !ok {
+		return nil // 建表窗口内被杀
+	}
+
+	// 1) 全表扫 (主键路径) 收集全部存活行 id → svc
+	rows := map[uint64]string{}
+	if err := tbl.Query().Scan(func(vals []types.FieldValue) bool {
+		var id uint64
+		var svc string
+		for _, fv := range vals {
+			switch string(fv.FieldName) {
+			case "id":
+				id, _ = fv.Value.(uint64)
+			case "svc":
+				switch v := fv.Value.(type) {
+				case string:
+					svc = v
+				case []byte:
+					svc = string(v)
+				}
+			}
+		}
+		rows[id] = svc
+		return true
+	}); err != nil {
+		return fmt.Errorf("全表扫失败: %w (现场: %s)", err, dir)
+	}
+	if total := tbl.TotalRows(); total != uint64(len(rows)) {
+		return fmt.Errorf("TotalRows=%d 但实际扫到 %d (幽灵行/洞行) (现场: %s)", total, len(rows), dir)
+	}
+	if len(rows) == 0 {
+		return nil
+	}
+
+	// 2) 逐行反查: 用 svc 值走二级索引等值查, 结果集必须包含自己
+	bad := 0
+	for id, svc := range rows {
+		found := false
+		err := tbl.Query().Where(core.Eq("svc", svc, types.FieldType(0))).
+			Scan(func(vals []types.FieldValue) bool {
+				for _, fv := range vals {
+					if string(fv.FieldName) == "id" {
+						if rid, _ := fv.Value.(uint64); rid == id {
+							found = true
+							return false
+						}
+					}
+				}
+				return true
+			})
+		if err != nil {
+			return fmt.Errorf("二级索引等值查 %q 报错: %w (现场: %s)", svc, err, dir)
+		}
+		if !found {
+			bad++
+			if bad <= 5 {
+				fmt.Printf("   ✗ 行 pkID=%d svc=%q 按二级索引查不到 (现场: %s)\n", id, svc, dir)
+			}
+		}
+	}
+	if bad > 0 {
+		return fmt.Errorf("%d 行存活但二级索引未命中 —— 半操作已固化 (现场保留: %s)", bad, dir)
+	}
+	return nil
+}
+
+func envInt(key string, def int) int {
+	if v := os.Getenv(key); v != "" {
+		var n int
+		if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
+			return n
+		}
+	}
+	return def
+}