Explorar el Código

feat(analyzer): blob 链可达性 — 修复 44 页孤儿误报 + 取证/复现工具

根因: analyzer 可达页集合从不走 blob 链 (Type=9), 带 blob 列的库
(sys_logs.param 等) 全部 blob 链页被误报孤儿。DockerManager 实测库
44 页孤儿 = 44 页活 blob 链页, 真实泄漏 0 (orphanaudit 双口径对账)。

- analyzer/blobwalk.go: 从行存储 B+ 树根 BFS, 逐叶条目认领数据页,
  抽行内 BlobRef 走链 — 可达集合补上 blob 链; audit/web/pagemap 挂接
- analyzer/main.go: decodeFieldValue 补 FieldType_DateTime (19) 分支
- internal/orphancheck: 双口径取证 (analyzer 口径对账 + 引擎只读扫描)
- cmd/orphanaudit: 对任意副本跑双口径 (只读, 绝不写原件)
- cmd/mrepro: sqlite→VRTX 迁移±VACUUM 复现, 真实孤儿>0 时 exit 1
- migrate/orphan_pages_test.go: 迁移+VACUUM 后真实孤儿=0 回归

实测: 用户库 44→0; docker.db.sqlite 迁移 (±VACUUM) 真实孤儿均=0,
blob 链 38 条断裂 0; VACUUM 后 248MB→194MB 链完好
yl32940 hace 3 semanas
padre
commit
fa9c420a94

+ 11 - 1
analyzer/audit.go

@@ -8,6 +8,8 @@ package main
 //   堆链 / 数据链  table_page.go heapAllocBlock / DataPage 链 (偏移 12)
 //   schema 扩展链  schema_ext.go appendPage                (偏移 12)
 //   索引页         btree.CollectAllPageIDs / 各 handler 的 CollectPageIDs
+//   blob 链 (v10)  blob_read.go BlobFree 整链回收的同款走链 (Type=9, NextPage 串联,
+//                  根 = 行内 8B BlobRef, 见 blobwalk.go)
 //
 // 这样 analyzer 报出的"不可达页"才能和引擎 DropTable 时会漏收的页是同一批,
 // 否则这工具只是好看, 不能当证据用。
@@ -123,6 +125,8 @@ func pageTypeName(t int) string {
 		return "WAL"
 	case PG_SEXT:
 		return "SchemaExt"
+	case 9:
+		return "BlobIndex"
 	default:
 		return fmt.Sprintf("?%d", t)
 	}
@@ -670,6 +674,8 @@ type TableAudit struct {
 	HeapBytes    int64  `json:"heapBytes"`
 	IdxPages     int    `json:"idxPages"`
 	IdxDPs       int    `json:"idxDps"`
+	BlobChains   int    `json:"blobChains"` // 行内 BlobRef 走出的链数 (v10)
+	BlobPages    int    `json:"blobPages"`  // 这些链占的页数 (Type=9)
 	Verdict      string `json:"verdict"`
 	Detail       string `json:"detail"`
 }
@@ -870,6 +876,10 @@ func runAuditStructural() (*AuditResult, *walker, map[uint32]bool, []TInfo, bool
 			ta.IdxDPs += dp
 		}
 
+		// 2.8) v10 blob 链: 行内 8B BlobRef 指向的 Type=9 链页是行的外部持有页,
+		//      与堆块同类。不走这条链, 带 blob 数据的库会被整链误报成孤儿页。
+		ta.BlobChains, ta.BlobPages = walkTableBlobChains(w, t)
+
 		switch {
 		case ta.HeapActual == 0 && ta.HeapDeclared == 0:
 			ta.Verdict, ta.Detail = "OK", "无堆页"
@@ -1059,7 +1069,7 @@ func finalizeVerdict(R *AuditResult) {
 				R.Reach.Unaccounted, R.Reach.WalLeftover))
 		default:
 			notes = append(notes,
-				fmt.Sprintf("有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 可能来自 DropTable / 重建索引漏收, 也可能是本工具未覆盖的结构", R.Reach.Unaccounted))
+				fmt.Sprintf("有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 可能来自 DropTable / 重建索引漏收, 也可能是本工具未覆盖的结构 (v10 起的 blob 链已覆盖: Type=9 孤儿即真实泄漏)", R.Reach.Unaccounted))
 		}
 	case R.Free.Verdict != "OK":
 		R.Verdict = "FREE-CHAIN"

+ 214 - 0
analyzer/blobwalk.go

@@ -0,0 +1,214 @@
+package main
+
+// ===== Blob 链走查 (v10 Type=9) =====
+//
+// v10 起 Blob 列行内是 8B BlobRef [TotalLen BE 4][FirstPageID BE 4], 链页由
+// PageHdr.NextPage 串联 (DB/core/page/base_page.go BlobIndex)。行是链的唯一主人:
+// 引擎删行 / DropTable 都靠行内引用还链 (table_page.go releaseBlobRefsInDataPage)。
+//
+// 早期 analyzer 的可达根不含 blob 链 —— 任何带 blob 数据的库, 全部活链页都会被
+// 报成"既不属于任何表/索引, 也不在空闲链上"的孤儿 (DockerManager 实测 44 页的主因)。
+// 这里把行内引用抽出来走链整链认领, 与引擎 DropTable 的回收口径同源。
+
+import (
+	"encoding/binary"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// blobRefsOfRow 从一行 (不含 pkID 前缀的 rowData) 抽出所有非空 blob 链首页。
+// 行布局解析与引擎 rowBlobRefs (DB/core/blob_read.go) 逐条对照:
+// 版本不符 / 偏移表不可信时返回 nil (与引擎"跳过该行"同义, 不让坏行毁掉整批)。
+func blobRefsOfRow(t *TInfo, rowData []byte) []uint32 {
+	if len(rowData) < 1 {
+		return nil
+	}
+	var sv *VInfo
+	for i := range t.Versions {
+		if t.Versions[i].Ver == rowData[0] {
+			sv = &t.Versions[i]
+			break
+		}
+	}
+	if sv == nil || len(sv.Indices) == 0 {
+		return nil
+	}
+	n := len(sv.Indices)
+	col0Idx := sv.Indices[0]
+	if col0Idx >= len(t.MasterFields) || t.MasterFields[col0Idx].Sz <= 0 {
+		return nil
+	}
+	col0Width := t.MasterFields[col0Idx].Sz
+	// v9 行布局: [schemaVer 1B][列0 W0 B][列结束偏移表 2N][列内容...][NULL 位图]
+	tblBase := 1 + col0Width
+	header := tblBase + 2*n
+	if header > len(rowData) {
+		return nil
+	}
+	// 表项 0 必须等于变长内容区起点 (被零填充/冲掉都会在这里暴露)。
+	if int(binary.BigEndian.Uint16(rowData[tblBase:tblBase+2])) != header {
+		return nil
+	}
+	bitmapLen := (n + 7) / 8
+	ends := make([]int, n)
+	prev := header
+	for i := 0; i < n; i++ {
+		off := int(binary.BigEndian.Uint16(rowData[tblBase+2*i:tblBase+2*i+2])) & rowVarOffMask
+		if off < prev || off > len(rowData) {
+			return nil
+		}
+		ends[i] = off
+		prev = off
+	}
+	if prev+bitmapLen > len(rowData) {
+		return nil
+	}
+	bitmap := rowData[prev : prev+bitmapLen]
+
+	var out []uint32
+	for i, idx := range sv.Indices {
+		if idx >= len(t.MasterFields) {
+			return nil
+		}
+		f := t.MasterFields[idx]
+		if f.Dropped || f.Type != byte(types.FieldType_Blob) {
+			continue
+		}
+		if bitmap[i>>3]&(1<<uint(i&7)) != 0 {
+			continue // NULL 列不占内容字节
+		}
+		start := 1
+		if i > 0 {
+			start = ends[i-1]
+		}
+		end := tblBase
+		if i > 0 {
+			end = ends[i]
+		}
+		if end-start < 8 || start+8 > len(rowData) {
+			continue
+		}
+		if ref := binary.BigEndian.Uint32(rowData[start+4 : start+8]); ref != 0 {
+			out = append(out, ref)
+		}
+	}
+	return out
+}
+
+// hasBlobColumn 该表是否有未删除的 blob 列 (没有就跳过链走查)。
+func hasBlobColumn(t *TInfo) bool {
+	for i := range t.MasterFields {
+		f := &t.MasterFields[i]
+		if !f.Dropped && f.Type == byte(types.FieldType_Blob) {
+			return true
+		}
+	}
+	return false
+}
+
+// walkTableBlobChains 扫一张表数据页里每行的 blob 引用, 把链页 (Type=9) 整链认领。
+// 返回 (链数, 链页数)。
+//
+// ⭐ 数据页集合来自【行存储 B+ 树叶条目】的 dpID 引用 —— btree 行存储的数据页
+// 由叶条目 (页号+槽位) 引用, FirstData 链是空的 (不再维护); 走 FirstData 链
+// 会扫不到任何行, 活链全被误报孤儿。
+//
+// 数据页迭代复用 eachRow (indexcheck.go): 它自带 PG_DATA 类型校验, 时序表的
+// TimeDataPage (布局不同) 会被整体跳过 —— 时序表 blob 列的链页因此仍会被报孤儿,
+// 与修复前一致, 不产生新的误报 (时序表是否允许 blob 列, 引擎未禁止, 属于已知缺口)。
+func walkTableBlobChains(w *walker, t *TInfo) (chains, pages int) {
+	if !hasBlobColumn(t) {
+		return 0, 0
+	}
+	// 行存储 = 主键 B+ 树 (时序表的 TimeDataPage 布局不同, 维持已知缺口)。
+	var rowStore *IInfo
+	for i := range t.Idxs {
+		if isRowStore(t.Idxs[i].Type) && isBTreeIdx(t.Idxs[i].Type) {
+			rowStore = &t.Idxs[i]
+			break
+		}
+	}
+	if rowStore == nil {
+		return 0, 0
+	}
+	dps := map[uint32]bool{}
+	pb := newPageBuf()
+	queue := []uint32{rowStore.EP}
+	seen := map[uint32]bool{}
+	for len(queue) > 0 {
+		pid := queue[0]
+		queue = queue[1:]
+		if pid == 0 || seen[pid] {
+			continue
+		}
+		seen[pid] = true
+		if pid < 2 || int(pid) > w.total {
+			continue
+		}
+		b, err := pb.page(pid)
+		if err != nil || b[0] != PG_IDATA {
+			continue
+		}
+		cn := int(le32(b[IX_CNT : IX_CNT+4]))
+		el := int(le16(b[IX_ELEN : IX_ELEN+2]))
+		if el <= 4 {
+			el = 12
+		}
+		if b[IX_LEAF] == 1 {
+			refOff := rowStoreRefOff(rowStore.Type, el)
+			if refOff >= 0 && refOff+4 <= el {
+				collect := func(nb []byte) {
+					n := int(le32(nb[IX_CNT : IX_CNT+4]))
+					for i := 0; i < n; i++ {
+						o := IDX_HDR + i*el
+						if o+el > len(nb) {
+							break
+						}
+						dps[be32(nb[o+refOff:o+refOff+4])] = true
+					}
+				}
+				collect(b)
+				// 叶同层链: 与 indexPages 一致顺链扫完
+				for nx := le32(b[PH_NEXT : PH_NEXT+4]); nx != 0; {
+					if seen[nx] || nx < 2 || int(nx) > w.total {
+						break
+					}
+					seen[nx] = true
+					nb, err := pb.page(nx)
+					if err != nil || nb[0] != PG_IDATA {
+						break
+					}
+					collect(nb)
+					nx = le32(nb[PH_NEXT : PH_NEXT+4])
+				}
+			}
+			continue
+		}
+		for i := 0; i < cn; i++ {
+			o := IDX_HDR + i*el
+			if o+el > len(b) {
+				break
+			}
+			child := be32(b[o+el-4 : o+el])
+			if child != 0 {
+				queue = append(queue, child)
+			}
+		}
+	}
+	for dp := range dps {
+		if dp < 2 || int(dp) > w.total {
+			continue
+		}
+		b, err := pb.page(dp)
+		if err != nil || b[0] != PG_DATA {
+			continue
+		}
+		eachRow(b, func(_ uint64, off, ln int) {
+			for _, first := range blobRefsOfRow(t, b[off:off+ln]) {
+				chains++
+				pages += w.chain(first, "blob", t.Name)
+			}
+		})
+	}
+	return chains, pages
+}

+ 108 - 0
analyzer/blobwalk_test.go

@@ -0,0 +1,108 @@
+package main
+
+// 回归: 带 blob 列的库, 结构审计不得把活 blob 链页报成孤儿页。
+//
+// 背景 (DockerManager 实测): 迁移后的库 analyzer 报"44 页既不属于任何表/索引,
+// 也不在空闲链上" —— 那 44 页是行内 BlobRef 指向的活链页 (Type=9), 早期
+// analyzer 的可达根不含 blob 链, 整链误报。本测试建一张带 blob 列的表
+// (含跨页大 blob), 跑 runAuditStructural, 要求 Unaccounted == 0。
+
+import (
+	"bytes"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+func seedBlobDB(t *testing.T) string {
+	t.Helper()
+	path := t.TempDir() + "/blob.db"
+	db, err := core.Open(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	db.SetAutoCommitDelay(0)
+	tbl, err := db.CreateTable("docs", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Blob, FieldName: []byte("body")},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := tbl.CreateIndex("pk_docs", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatal(err)
+	}
+	// 小 blob (单页链) + 大 blob (跨页链, 确保多页链也被认领)。
+	big := bytes.Repeat([]byte("0123456789abcdef"), 8*1024) // 128KB ≈ 8 个链页
+	rows := [][]byte{
+		[]byte("hello blob"),
+		big,
+		bytes.Repeat([]byte("z"), 20000), // 略超单页数据区, 强制跨页
+	}
+	for i, body := range rows {
+		if _, err := tbl.InsertRowWithID(uint64(i+1), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i + 1)},
+			{FieldName: []byte("body"), Value: body},
+		}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return path
+}
+
+func TestAuditBlobChainsReachable(t *testing.T) {
+	path := seedBlobDB(t)
+
+	if err := useDB(path); err != nil {
+		t.Fatalf("useDB: %v", err)
+	}
+	invalidateAudit()
+	mu.Lock()
+	fp = path
+	rHdr()
+	tabs = pTabs()
+	mu.Unlock()
+
+	R, _, _, _, _ := runAuditStructural()
+	if R.Reach.Wal > 0 {
+		t.Skipf("库带着未恢复 WAL (%d 页), 页归属不完整, 本断言不适用", R.Reach.Wal)
+	}
+	if R.Reach.Unaccounted != 0 {
+		for i := range R.Tables { t.Logf("表 %s BlobChains=%d BlobPages=%d", R.Tables[i].Name, R.Tables[i].BlobChains, R.Tables[i].BlobPages) }
+		t.Fatalf("活 blob 链页被误报成孤儿: Unaccounted=%d byType=%v ids=%v",
+			R.Reach.Unaccounted, R.Reach.UnaccountedByType, R.Reach.UnaccountedIDs)
+	}
+	if got := R.Reach.ByRole["blob"]; got <= 0 {
+		t.Fatalf("ByRole[blob] = %d, 期望 > 0 —— blob 链根本没被走查", got)
+	}
+	var ta *TableAudit
+	for i := range R.Tables {
+		if R.Tables[i].Name == "docs" {
+			ta = &R.Tables[i]
+		}
+	}
+	if ta == nil {
+		t.Fatal("审计结果里没有 docs 表")
+	}
+	if ta.BlobChains != 3 {
+		t.Fatalf("BlobChains = %d, 期望 3 (每行一条链)", ta.BlobChains)
+	}
+	if ta.BlobPages < 10 {
+		t.Fatalf("BlobPages = %d, 期望 >= 10 (128KB 大 blob 单独就 ~8 页)", ta.BlobPages)
+	}
+	// 释放全局句柄, 否则 Windows 下 t.TempDir 清理删不掉文件
+	fileMu.Lock()
+	if dbf != nil {
+		dbf.Close()
+		dbf = nil
+	}
+	fileMu.Unlock()
+}

+ 3 - 0
analyzer/main.go

@@ -884,6 +884,9 @@ func decodeFieldValue(buf []byte, typ byte) string {
 	// ===== 8 字节 =====
 	case types.FieldType_Uint64:
 		return fmt.Sprintf("%d", binary.BigEndian.Uint64(buf))
+	case types.FieldType_DateTime:
+		// 行内固定 8 字节 BE uint64, 存 Unix 毫秒时间戳 (types.go FieldType_DateTime)
+		return fmt.Sprintf("%d", binary.BigEndian.Uint64(buf))
 	case types.FieldType_Int64:
 		return fmt.Sprintf("%d", int64(binary.BigEndian.Uint64(buf)^0x8000000000000000))
 	case types.FieldType_Float64:

+ 2 - 2
analyzer/pagemap.go

@@ -48,7 +48,7 @@ var (
 
 var pmTypeNames = map[int]string{
 	0: "free", 1: "table", 2: "data", 3: "heap", 4: "imeta",
-	5: "idata", 6: "overflow", 7: "wal", 8: "sext",
+	5: "idata", 6: "overflow", 7: "wal", 8: "sext", 9: "blob",
 }
 
 // pmKind 归一化页类型: 返回 (给前端着色的类型值, counts 用的键名)。
@@ -73,7 +73,7 @@ func pmKind(pt int) (int, string) {
 func pmNewCounts() map[string]int {
 	return map[string]int{
 		"hdr": 0, "table": 0, "data": 0, "heap": 0, "imeta": 0, "idata": 0,
-		"overflow": 0, "wal": 0, "sext": 0, "free": 0, "never": 0,
+		"overflow": 0, "wal": 0, "sext": 0, "blob": 0, "free": 0, "never": 0,
 		"reserved": 0, "orphan": 0, "other": 0,
 	}
 }

+ 3 - 3
analyzer/web/index.html

@@ -127,9 +127,9 @@ let auTok=0; // Audit 渲染令牌: 索引内容增量补全期间轮询重绘
 //   1) 后端 /api/pagemap 按块返回 (每块 2 万页; 类型 1 字节 + 使用率 1 字节, base64);
 //   2) 前端分块拉取, 拉到哪块画哪块, 顶部进度条实时推进 (先有多少显示多少);
 //   3) 只画视口内的格子 —— 全库 19.5 万页也只有一块画布, DOM 里永远是 1 个 canvas。
-const PMT={0:'Free',1:'Table',2:'Data',3:'Heap',4:'IdxMeta',5:'IdxData',6:'Overflow(deprecated)',7:'WAL',8:'SchemaExt',[-1]:'Never used',[-2]:'Reserved',[-3]:'Orphan (leaked)',[-4]:'Unknown',[-9]:'\u52a0\u8f7d\u4e2d\u2026'};
-const PMC={0:'#1a1a2e',1:'#f7b731',2:'#5b9bd5',3:'#4ecdc4',4:'#a78bfa',5:'#74b9ff',6:'#b8a04a',7:'#ff9f43',8:'#e879f9',[-1]:'#101018',[-2]:'#40305a',[-3]:'#7a2626',[-4]:'#555555',[-9]:'#151520'};
-const KT={free:0,table:1,data:2,heap:3,imeta:4,idata:5,overflow:6,wal:7,sext:8,never:-1,reserved:-2,orphan:-3,other:-4};
+const PMT={0:'Free',1:'Table',2:'Data',3:'Heap',4:'IdxMeta',5:'IdxData',6:'Overflow(deprecated)',7:'WAL',8:'SchemaExt',9:'Blob',[-1]:'Never used',[-2]:'Reserved',[-3]:'Orphan (leaked)',[-4]:'Unknown',[-9]:'\u52a0\u8f7d\u4e2d\u2026'};
+const PMC={0:'#1a1a2e',1:'#f7b731',2:'#5b9bd5',3:'#4ecdc4',4:'#a78bfa',5:'#74b9ff',6:'#b8a04a',7:'#ff9f43',8:'#e879f9',9:'#2dd4bf',[-1]:'#101018',[-2]:'#40305a',[-3]:'#7a2626',[-4]:'#555555',[-9]:'#151520'};
+const KT={free:0,table:1,data:2,heap:3,imeta:4,idata:5,overflow:6,wal:7,sext:8,blob:9,never:-1,reserved:-2,orphan:-3,other:-4};
 const PMCELL=14,PMPITCH=PMCELL+2,PMCHUNK=20000;
 let PM=null,pmTok=0;
 const PMINFO=new Map(); // 单页归属缓存: 悬停时按需拉一页, 不再随页图整体传 19.5 万条

+ 145 - 0
cmd/mrepro/main.go

@@ -0,0 +1,145 @@
+// mrepro — 迁移孤儿页复现工具。
+//
+//	mrepro -src <sqlite 源库> [-workdir 目录] [-vacuum] [-keep]
+//
+// 流程: 把源库 cp 进工作目录 (绝不碰原件) → 官方 migrate.Run(sqlite→VRTX) →
+// (可选) VACUUM → orphancheck 取证 → 打印两套口径孤儿数。
+// 退出码: 真实孤儿页 > 0 时 exit 1 (供 CI/脚本判定复现)。
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/internal/orphancheck"
+	"git.ycl.pub/yl32940/TsDB2/migrate"
+)
+
+func main() {
+	src := flag.String("src", "", "sqlite 源库路径 (会先复制再迁, 不直接打开原件)")
+	workdir := flag.String("workdir", "", "工作目录 (默认自动创建临时目录)")
+	vac := flag.Bool("vacuum", false, "迁移后跑一次 VACUUM")
+	keep := flag.Bool("keep", false, "保留工作目录 (默认结束后删除)")
+	flag.Parse()
+
+	if *src == "" {
+		fmt.Fprintln(os.Stderr, "用法: mrepro -src <sqlite 源库> [-workdir 目录] [-vacuum] [-keep]")
+		os.Exit(2)
+	}
+
+	wd := *workdir
+	var err error
+	if wd == "" {
+		wd, err = os.MkdirTemp("", "mrepro-")
+		if err != nil {
+			fatal(err)
+		}
+	} else if err := os.MkdirAll(wd, 0o755); err != nil {
+		fatal(err)
+	}
+	if !*keep {
+		defer os.RemoveAll(wd)
+	}
+	fmt.Println("工作目录:", wd)
+
+	// 1. 复制源库 —— 只迁副本
+	srcCopy := filepath.Join(wd, "src.db")
+	if err := copyFile(srcCopy, *src); err != nil {
+		fatal(err)
+	}
+
+	// 2. 官方 migrate: sqlite → VRTX
+	sqliteSrc, err := migrate.OpenSQLite(srcCopy, nil)
+	if err != nil {
+		fatal(fmt.Errorf("打开 sqlite 副本: %w", err))
+	}
+	outDB := filepath.Join(wd, "out.db")
+	vrtxDst, err := migrate.OpenVRTX(outDB, nil)
+	if err != nil {
+		fatal(fmt.Errorf("打开 VRTX 目标: %w", err))
+	}
+	rep, runErr := migrate.Run(sqliteSrc, vrtxDst, migrate.DefaultOptions())
+	_ = vrtxDst.Close()
+	_ = sqliteSrc.Close()
+	if runErr != nil {
+		fmt.Fprintln(os.Stderr, "迁移报错 (继续取证):", runErr)
+	}
+	for _, tr := range rep.Tables {
+		note := ""
+		if tr.Error != "" {
+			note = "  ERROR: " + tr.Error
+		}
+		fmt.Printf("  表 %-32s %8d 行 %s\n", tr.Table, tr.Rows, note)
+	}
+
+	// 3. (可选) VACUUM
+	if *vac {
+		db, err := core.Open(outDB)
+		if err != nil {
+			fatal(fmt.Errorf("打开迁移产物: %w", err))
+		}
+		vres, err := db.VACUUM()
+		_ = db.Close()
+		if err != nil {
+			fatal(fmt.Errorf("VACUUM: %w", err))
+		}
+		fmt.Printf("VACUUM: %d → %d 字节 (省 %d), 复制 %d 行\n",
+			vres.OldFileSize, vres.NewFileSize, vres.SavedBytes, vres.RowsCopied)
+	}
+
+	// 4. 取证
+	R, err := orphancheck.Run(outDB, orphancheck.Options{EngineBlobScan: true, SamplesPerType: 3})
+	if err != nil {
+		fatal(err)
+	}
+	fmt.Printf("\n== 取证: %s\n", outDB)
+	fmt.Printf("总页数=%d  空闲链=%d  结构可达=%d  blob 链页=%d\n",
+		R.TotalPages, R.FreeCount, R.ReachableNoBlob, R.BlobPages)
+	fmt.Printf("analyzer 口径孤儿 = %d (按类型 %v)\n", len(R.AnalyzerOrphans), R.AnalyzerByType)
+	fmt.Printf("blob 感知真实孤儿 = %d (按类型 %v)\n", len(R.Orphans), R.ByType)
+	for _, n := range R.Notes {
+		fmt.Println("备注:", n)
+	}
+	for _, tn := range orphancheck.SortedTypes(R.ByType) {
+		for _, p := range R.Samples[tn] {
+			fmt.Printf("-- 样例 页 %d 类型 %s UseSize=%d NextPage=%d\n", p.ID, tn, p.UseSize, p.Next)
+			fmt.Print(orphancheck.SampleHex(p))
+		}
+	}
+
+	if len(R.Orphans) > 0 {
+		fmt.Printf("\n复现成功: %d 页真实孤儿。\n", len(R.Orphans))
+		os.Exit(1)
+	}
+	extra := ""
+	if d := len(R.AnalyzerOrphans) - len(R.Orphans); d > 0 {
+		extra = fmt.Sprintf(" (analyzer 口径的 %d 页全部是活 blob 链页 — analyzer 误报)", d)
+	}
+	fmt.Printf("\n纯净: 真实孤儿页 = 0%s\n", extra)
+}
+
+func copyFile(dst, src string) error { //nolint:revive // 参数序与 io.Copy 对齐习惯
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	if _, err := io.Copy(out, in); err != nil {
+		_ = out.Close()
+		return err
+	}
+	return out.Close()
+}
+
+func fatal(err error) {
+	fmt.Fprintln(os.Stderr, "错误:", err)
+	os.Exit(2)
+}

+ 146 - 0
cmd/orphanaudit/main.go

@@ -0,0 +1,146 @@
+// orphanaudit — VRTX 孤儿页取证工具。
+//
+//	orphanaudit -db <副本路径> [-samples 3] [-noengine]
+//
+// 输出两套口径并排:
+//
+//   - analyzer 口径: 与 analyzer/audit.go 完全同批可达根 (不走 blob 链)。
+//     与 analyzer 报告对账用 —— 两个数字必须相等, 不等说明本工具移植有 bug。
+//   - blob 感知口径: 再走行内 BlobRef → blob 链 (Type=9)。剩下的才是真实泄漏。
+//
+// 绝不直接对原件跑: 先用 cp 复制数据库文件, 只对副本运行。
+// 引擎只读打开 (O_RDONLY + 共享锁, 跳过 WAL 恢复), 不会写盘。
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"os"
+
+	"git.ycl.pub/yl32940/TsDB2/internal/orphancheck"
+)
+
+func main() {
+	db := flag.String("db", "", "VRTX 文件路径 (必须是副本!)")
+	samples := flag.Int("samples", 3, "每种类型的样例页数")
+	noEngine := flag.Bool("noengine", false, "不用引擎只读扫描 blob 链 (纯 analyzer 口径)")
+	jsonOut := flag.Bool("json", false, "JSON 输出")
+	flag.Parse()
+
+	if *db == "" {
+		fmt.Fprintln(os.Stderr, "用法: orphanaudit -db <副本路径> [-samples N] [-noengine] [-json]")
+		os.Exit(2)
+	}
+
+	R, err := orphancheck.Run(*db, orphancheck.Options{
+		EngineBlobScan: !*noEngine,
+		SamplesPerType: *samples,
+	})
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "取证失败:", err)
+		os.Exit(2)
+	}
+
+	if *jsonOut {
+		enc := map[string]any{
+			"file": R.File, "totalPages": R.TotalPages, "nextFreePage": R.NextFreePage,
+			"freeCount": R.FreeCount, "freeDeclared": R.FreeDeclared, "freeVerdict": R.FreeVerdict,
+			"walPending": R.WALPending, "reachableNoBlob": R.ReachableNoBlob, "blobPages": R.BlobPages,
+			"analyzerOrphans": len(R.AnalyzerOrphans), "analyzerByType": R.AnalyzerByType,
+			"analyzerWalLeftover": R.AnalyzerWalLeft,
+			"orphans": len(R.Orphans), "byType": R.ByType, "walLeftover": R.WalLeft,
+			"brokenChains": R.BrokenChains, "notes": R.Notes,
+			"orphanIds": ids(R.Orphans), "analyzerOrphanIds": ids(R.AnalyzerOrphans),
+			"samples": hexSamples(R),
+		}
+		writeJSON(enc)
+		return
+	}
+
+	printReport(R)
+}
+
+func writeJSON(v any) {
+	enc := json.NewEncoder(os.Stdout)
+	enc.SetIndent("", "  ")
+	_ = enc.Encode(v)
+}
+
+func ids(ps []orphancheck.PageInfo) []uint32 {
+	out := make([]uint32, len(ps))
+	for i, p := range ps {
+		out[i] = p.ID
+	}
+	return out
+}
+
+func printReport(R *orphancheck.Result) {
+	fmt.Printf("== 文件: %s\n", R.File)
+	fmt.Printf("总页数=%d  NextFreePage=%d  空闲链=%d 页 (header 声明 %d, 判定: %s)\n",
+		R.TotalPages, R.NextFreePage, R.FreeCount, R.FreeDeclared, R.FreeVerdict)
+	if R.WALPending > 0 {
+		fmt.Printf("未恢复 WAL 链: %d 页 (只读打开跳过恢复)\n", R.WALPending)
+	}
+	fmt.Printf("结构可达 (analyzer 口径, 含页 1)=%d 页;  引擎扫描认领 blob 链页=%d\n\n",
+		R.ReachableNoBlob, R.BlobPages)
+
+	fmt.Printf("-- analyzer 口径孤儿页 = %d\n", len(R.AnalyzerOrphans))
+	printHist(R.AnalyzerByType, R.AnalyzerWalLeft)
+	fmt.Printf("-- blob 感知口径真正孤儿页 = %d\n", len(R.Orphans))
+	printHist(R.ByType, R.WalLeft)
+
+	if len(R.Chains) > 0 {
+		fmt.Printf("\nblob 链: 共 %d 条, 断裂 %d 条\n", len(R.Chains), R.BrokenChains)
+	}
+	for _, n := range R.Notes {
+		fmt.Println("备注:", n)
+	}
+
+	if len(R.Orphans) > 0 && len(R.Samples) > 0 {
+		fmt.Println("\n== 孤儿页样例 (头 64B hex) ==")
+		for _, tn := range orphancheck.SortedTypes(R.ByType) {
+			for _, p := range R.Samples[tn] {
+				fmt.Printf("-- 页 %d  类型 %s  UseSize=%d  NextPage=%d\n", p.ID, tn, p.UseSize, p.Next)
+				fmt.Print(orphancheck.SampleHex(p))
+			}
+		}
+	}
+
+	if d := len(R.AnalyzerOrphans) - len(R.Orphans); d > 0 {
+		fmt.Printf("\n结论: analyzer 口径有 %d 页『孤儿』, 其中 %d 页是【活 blob 链页】"+
+			" —— analyzer 不走 blob 链造成的误报; 真正泄漏 %d 页。\n", len(R.AnalyzerOrphans), d, len(R.Orphans))
+	} else if len(R.Orphans) > 0 {
+		fmt.Printf("\n结论: %d 页真实泄漏 (blob 走查后仍不可达), 按上方类型直方图指认路径。\n", len(R.Orphans))
+	} else {
+		fmt.Println("\n结论: 无真实孤儿页。")
+	}
+}
+
+func printHist(m map[string]int, walLeft int) {
+	if len(m) == 0 {
+		fmt.Println("   (无)")
+		return
+	}
+	for _, tn := range orphancheck.SortedTypes(m) {
+		fmt.Printf("   %-24s %d\n", tn, m[tn])
+	}
+	if walLeft > 0 {
+		fmt.Printf("   其中 WAL(7) 页 = 已提交 WAL 链未回收 (引擎泄漏)\n")
+	}
+}
+
+func hexSamples(R *orphancheck.Result) map[string]any {
+	out := map[string]any{}
+	for tn, ps := range R.Samples {
+		var rows []map[string]any
+		for _, p := range ps {
+			rows = append(rows, map[string]any{
+				"id": p.ID, "useSize": p.UseSize, "next": p.Next,
+				"head64": orphancheck.SampleHex(p),
+			})
+		}
+		out[tn] = rows
+	}
+	return out
+}

+ 724 - 0
internal/orphancheck/orphancheck.go

@@ -0,0 +1,724 @@
+// Package orphancheck 取证工具: 找出 VRTX 文件里"既不在空闲链、也不被任何结构引用"的孤儿页,
+// 并按页类型出直方图 + 样例 hex。
+//
+// 两套口径并排输出:
+//
+//   - analyzer 口径: 与 analyzer/audit.go 的 runAuditStructural 同一批可达根
+//     (表页链 / schema 扩展链 / 未恢复 WAL 链 / 每表 data-heap-index 链),
+//     不含 blob 链 —— 它就是 analyzer 报的 "44 页既不属于任何表/索引, 也不在空闲链上" 的数字。
+//   - blob 感知口径: 在上面基础上, 用引擎只读打开库、全表扫出行内 BlobRef,
+//     把 blob 链页 (Type=9) 也计入可达 —— 剩余才是真正的泄漏页。
+//
+// 两口径的差集 = "analyzer 误报的孤儿" (活 blob 页)。若 blob 感知口径仍有孤儿,
+// 按类型直方图指认泄漏路径。
+//
+// 所有页布局常量与 analyzer/main.go 逐一对照 (那边由 layout_assert.go 编译期钉在
+// DB/core 的真实结构体上); 本包不 import analyzer (它是 package main), 常量独立维护。
+package orphancheck
+
+import (
+	"encoding/binary"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+const (
+	pgsz  = 16384
+	hdrsz = 4096
+	phdr  = 20
+
+	pgHead  = 0
+	pgTable = 1
+	pgData  = 2
+	pgHeap  = 3
+	pgIMeta = 4
+	pgIData = 5
+	pgOver  = 6
+	pgWAL   = 7
+	pgSExt  = 8
+	pgBlob  = 9
+
+	// PageHdr 字段偏移
+	phNext = 12
+	phUse  = 16
+
+	// 头页字段偏移 (core/header_page.go v11 布局, 编译期断言钉死)
+	hdrNextFree  = 12
+	hdrTablePage = 48
+	hdrFreeStart = 56
+	hdrFreeEnd   = 60
+	hdrFreeCount = 64
+	hdrFirstWAL  = 68
+
+	// 表页/表记录 (TableInfo=104B, TableNameSize=48)
+	tinfoSZ  = 104
+	tiFlag   = 48
+	tiHPCnt  = 52
+	tiFHeap  = 64
+	tiFData  = 72
+	tiAlloc  = 82
+	tiFIdx   = 88
+	sextHdr  = 4
+	recOff   = phdr + sextHdr
+	flagTomb = 0x80
+
+	// IndexMeta 记录 (100B, IndexNameSize=64)
+	metaSZ      = 100
+	imNameSz    = 64
+	imType      = 64
+	imFcnt      = 65
+	imFlds      = 66
+	imEP        = 84
+	imFlag      = 96
+	imFlagDel   = 0x80
+	imFlagUniq  = 0x01
+	idxHdr      = 32
+	ixTyp       = 20
+	ixELen      = 22
+	ixCnt       = 24
+	ixLeaf      = 28
+
+	// 索引类型 (page/base_page.go)
+	itTime    = 1
+	itPrimary = 2
+	itStr     = 3
+	itCompose = 4
+	itBTree   = 5
+	itHash    = 6
+
+	// Hash 索引元数据/桶页偏移 (analyzer/audit.go)
+	hmMagic   = 32
+	hmDir     = 48
+	hashMagic = 0x54484833
+	hashSlot  = 12
+	hashBktHd = 28
+)
+
+func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) }
+func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) }
+func be32(b []byte) uint32 { return binary.BigEndian.Uint32(b) }
+
+// PageInfo 一个孤儿页的取证信息。
+type PageInfo struct {
+	ID       uint32
+	TypeByte byte
+	UseSize  uint32
+	Next     uint32
+	Head64   []byte // 页头 64 字节样例
+}
+
+// ChainInfo 一条 blob 链的取证信息。
+type ChainInfo struct {
+	Table     string
+	PKID      uint64
+	FirstPage uint32
+	Pages     int
+	BrokenAt  uint32 // 链中途断掉/成环/越界时的页号 (0=完整)
+}
+
+// Result 取证结果。
+type Result struct {
+	File          string
+	TotalPages    int
+	NextFreePage  uint32
+	FreeCount     int
+	FreeDeclared  uint32
+	FreeVerdict   string
+	WALPending    int // 头页 FirstWALPage != 0 时的未恢复 WAL 链页数 (只读打开跳过恢复)
+	Notes         []string
+
+	ReachableNoBlob int // analyzer 口径可达页数 (含页 1 / 空闲页不计)
+	BlobPages       int // blob 链页总数 (含与结构页重叠数)
+
+	// AnalyzerOrphans: 与 analyzer 报数完全同口径的孤儿 (不含 blob 链可达性)
+	AnalyzerOrphans    []PageInfo
+	AnalyzerByType     map[string]int
+	AnalyzerWalLeft    int
+
+	// Orphans: blob 感知口径的真正孤儿
+	Orphans    []PageInfo
+	ByType     map[string]int
+	WalLeft    int
+	Samples    map[string][]PageInfo // 每类型最多 N 个样例 (头 64B)
+	Chains     []ChainInfo
+	BrokenChains int
+}
+
+// TypeName 页类型字节 -> 名字 (9=BlobIndex 是 analyzer 不认识的, 这里显式命名)。
+func TypeName(t byte) string {
+	switch t {
+	case pgHead:
+		return "Free(0)"
+	case pgTable:
+		return "Table(1)"
+	case pgData:
+		return "Data(2)"
+	case pgHeap:
+		return "Heap(3)"
+	case pgIMeta:
+		return "IdxMeta(4)"
+	case pgIData:
+		return "IdxData(5)"
+	case pgOver:
+		return "Overflow(6,deprecated)"
+	case pgWAL:
+		return "WAL(7)"
+	case pgSExt:
+		return "SchemaExt(8)"
+	case pgBlob:
+		return "BlobIndex(9)"
+	}
+	return fmt.Sprintf("?(%d)", t)
+}
+
+type walker struct {
+	f      *os.File
+	total  int
+	claim  map[uint32]string // pid -> role
+	cross  [][2]string       // 重复认领记录 (诊断)
+}
+
+// page 读一页。每次新分配 16KB —— 调用方拿到的切片在下一次 page() 之前有效。
+// 取证工具是 one-shot, 不需要 analyzer 那种复用缓冲 (那里要在热循环里省 GC);
+// 换来的正确性: 链 walk 里随时再读子页/元页, 复用单缓冲会被覆盖 (aliasing bug)。
+func (w *walker) page(pid uint32) ([]byte, error) {
+	b := make([]byte, pgsz)
+	if _, err := w.f.ReadAt(b, hdrsz+int64(pid-1)*pgsz); err != nil {
+		return nil, err
+	}
+	return b, nil
+}
+
+// take 认领一页; 重复认领记入 cross (同角色不算)。
+func (w *walker) take(pid uint32, role string) bool {
+	if pid == 0 {
+		return false
+	}
+	if r, ok := w.claim[pid]; ok {
+		if r != role {
+			w.cross = append(w.cross, [2]string{r, role})
+		}
+		return false
+	}
+	w.claim[pid] = role
+	return true
+}
+
+// chain 沿 NextPage 走链并认领 (带越界/成环保护)。
+func (w *walker) chain(start uint32, role string) int {
+	n := 0
+	seen := map[uint32]bool{}
+	for pid := start; pid != 0; {
+		if pid < 2 || int(pid) > w.total || seen[pid] {
+			return n
+		}
+		seen[pid] = true
+		if !w.take(pid, role) {
+			return n
+		}
+		n++
+		b, err := w.page(pid)
+		if err != nil {
+			return n
+		}
+		pid = le32(b[phNext : phNext+4])
+	}
+	return n
+}
+
+// idxDataPages 收下一页行存储索引叶条目引用的 DataPageID (与 analyzer 同逻辑)。
+func (w *walker) idxDataPages(b []byte, it byte) int {
+	if b[0] != pgIData {
+		return 0
+	}
+	cn := int(le32(b[ixCnt : ixCnt+4]))
+	el := int(le16(b[ixELen : ixELen+2]))
+	if el <= 4 {
+		el = 12
+	}
+	refOff := el - 4
+	if it == itPrimary {
+		refOff = 8
+	}
+	if refOff < 0 || refOff+4 > el {
+		return 0
+	}
+	n := 0
+	for i := 0; i < cn; i++ {
+		o := idxHdr + i*el
+		if o+el > len(b) {
+			break
+		}
+		if p := be32(b[o+refOff : o+refOff+4]); p != 0 {
+			if w.take(p, "data(idx)") {
+				n++
+			}
+		}
+	}
+	return n
+}
+
+func isRowStore(it byte) bool { return it == itTime || it == itPrimary }
+func isBTreeIdx(it byte) bool { return it == itPrimary || it == itBTree || it == itStr || it == itCompose }
+
+// indexPages 枚举一个索引占用的全部页 (移植 analyzer/audit.go indexPages)。
+func (w *walker) indexPages(it byte, entry uint32) (n, dp int) {
+	if entry == 0 || int(entry) > w.total {
+		return 0, 0
+	}
+	switch {
+	case it == itHash:
+		meta, err := w.page(entry)
+		if err != nil || le32(meta[hmMagic:hmMagic+4]) != hashMagic {
+			return 0, 0
+		}
+		w.take(entry, "index-meta")
+		n++
+		limit := phdr + ((pgsz-phdr)/hashSlot)*hashSlot
+		for dir := le32(meta[hmDir : hmDir+4]); dir != 0; {
+			if dir < 2 || int(dir) > w.total {
+				break
+			}
+			if !w.take(dir, "index-dir") {
+				break
+			}
+			n++
+			b, err := w.page(dir)
+			if err != nil {
+				break
+			}
+			for o := phdr; o+hashSlot <= limit; o += hashSlot {
+				if head := le32(b[o : o+4]); head != 0 {
+					n += w.chain(head, "index-bucket")
+				}
+			}
+			dir = le32(b[phNext : phNext+4])
+		}
+		return n, 0
+
+	case isBTreeIdx(it):
+		queue := []uint32{entry}
+		seen := map[uint32]bool{}
+		for len(queue) > 0 {
+			pid := queue[0]
+			queue = queue[1:]
+			if seen[pid] {
+				continue
+			}
+			seen[pid] = true
+			if pid < 2 || int(pid) > w.total {
+				continue
+			}
+			if !w.take(pid, "index-node") {
+				continue
+			}
+			n++
+			b, err := w.page(pid)
+			if err != nil || b[0] != pgIData {
+				continue
+			}
+			if b[ixLeaf] == 1 {
+				if isRowStore(it) {
+					dp += w.idxDataPages(b, it)
+				}
+				for nx := le32(b[phNext : phNext+4]); nx != 0; {
+					if nx < 2 || int(nx) > w.total || seen[nx] {
+						break
+					}
+					seen[nx] = true
+					if !w.take(nx, "index-node") {
+						break
+					}
+					n++
+					nb, err := w.page(nx)
+					if err != nil {
+						break
+					}
+					if isRowStore(it) {
+						dp += w.idxDataPages(nb, it)
+					}
+					nx = le32(nb[phNext : phNext+4])
+				}
+				continue
+			}
+			cn := int(le32(b[ixCnt : ixCnt+4]))
+			el := int(le16(b[ixELen : ixELen+2]))
+			if el <= 4 {
+				el = 12
+			}
+			for i := 0; i < cn; i++ {
+				o := idxHdr + i*el
+				if o+el > len(b) {
+					break
+				}
+				if child := be32(b[o+el-4 : o+el]); child != 0 {
+					queue = append(queue, child)
+				}
+			}
+		}
+		return n, dp
+
+	default:
+		n = w.chain(entry, "index-chain")
+		return n, 0
+	}
+}
+
+// tableInfo 结构走查所需的最小表信息 (不含 schema)。
+type tableInfo struct {
+	name  string
+	dp0   uint32
+	hp0   uint32
+	ip0   uint32
+	index []idxInfo
+}
+
+type idxInfo struct {
+	typ byte
+	ep  uint32
+}
+
+// parseTables 移植 analyzer pTabs 的表链/表记录/索引元数据解析 (跳过 schema 重建)。
+func (w *walker) parseTables() []tableInfo {
+	var out []tableInfo
+	hdr := make([]byte, hdrsz)
+	if _, err := w.f.ReadAt(hdr, 0); err != nil {
+		return out
+	}
+	for pid := le32(hdr[hdrTablePage : hdrTablePage+4]); pid != 0; {
+		if pid < 2 || int(pid) > w.total {
+			break
+		}
+		b, err := w.page(pid)
+		if err != nil || b[0] != pgTable {
+			break
+		}
+		us := le32(b[phUse : phUse+4])
+		if us > pgsz {
+			us = pgsz
+		}
+		for off := uint32(recOff); off+tinfoSZ <= us && off+tinfoSZ <= pgsz; {
+			tb := b[off : off+tinfoSZ]
+			alloc := uint32(le16(tb[tiAlloc : tiAlloc+2]))
+			if tb[tiFlag]&flagTomb != 0 {
+				off += tinfoSZ + alloc
+				continue
+			}
+			t := tableInfo{
+				name: strings.TrimRight(string(tb[:48]), "\x00"),
+				dp0:  le32(tb[tiFData : tiFData+4]),
+				hp0:  le32(tb[tiFHeap : tiFHeap+4]),
+				ip0:  le32(tb[tiFIdx : tiFIdx+4]),
+			}
+			// 索引元数据链
+			for ip := t.ip0; ip != 0; {
+				if ip < 2 || int(ip) > w.total {
+					break
+				}
+				ib, err := w.page(ip)
+				if err != nil || ib[0] != pgIMeta {
+					break
+				}
+				iu := le32(ib[phUse : phUse+4])
+				if iu > pgsz {
+					iu = pgsz
+				}
+				for io := uint32(phdr); io+metaSZ <= iu && io+metaSZ <= pgsz; io += metaSZ {
+					im := ib[io : io+metaSZ]
+					if im[imFlag]&imFlagDel != 0 {
+						continue
+					}
+					ep := le32(im[imEP : imEP+4])
+					if ep != 0 {
+						if pb, err := w.page(ep); err != nil || pb[0] != pgIData {
+							ep = 0
+						}
+					}
+					if ep != 0 {
+						t.index = append(t.index, idxInfo{typ: im[imType], ep: ep})
+					}
+				}
+				ip = le32(ib[phNext : phNext+4])
+			}
+			out = append(out, t)
+			off += tinfoSZ + alloc
+		}
+		nx := le32(b[phNext : phNext+4])
+		if nx == 0 || nx == pid {
+			break
+		}
+		pid = nx
+	}
+	return out
+}
+
+// Options 取证选项。
+type Options struct {
+	// EngineBlobScan 用引擎只读打开库扫行内 BlobRef (推荐; 只在副本上跑)。
+	EngineBlobScan bool
+	// SamplesPerType 每种类型的样例页数 (头 64B hex)。
+	SamplesPerType int
+}
+
+// Run 对 dbPath 执行取证。调用方必须保证 dbPath 是副本 (本函数只读打开,
+// 但"只读打开"本身不适合直接在唯一原件上做)。
+func Run(dbPath string, opt Options) (*Result, error) {
+	if opt.SamplesPerType <= 0 {
+		opt.SamplesPerType = 3
+	}
+	f, err := os.Open(dbPath)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	st, err := f.Stat()
+	if err != nil {
+		return nil, err
+	}
+	R := &Result{
+		File:            dbPath,
+		TotalPages:      int((st.Size() - hdrsz) / pgsz),
+		ByType:          map[string]int{},
+		AnalyzerByType:  map[string]int{},
+		Samples:         map[string][]PageInfo{},
+	}
+	if R.TotalPages < 0 {
+		return nil, fmt.Errorf("文件 %s 不足 4KB 头页", dbPath)
+	}
+
+	hdr := make([]byte, hdrsz)
+	if _, err := f.ReadAt(hdr, 0); err != nil {
+		return nil, err
+	}
+	nf := le32(hdr[hdrNextFree : hdrNextFree+4])
+	R.NextFreePage = nf
+	freeHead := le32(hdr[hdrFreeStart : hdrFreeStart+4])
+	R.FreeDeclared = le32(hdr[hdrFreeCount : hdrFreeCount+4])
+	freeEnd := le32(hdr[hdrFreeEnd : hdrFreeEnd+4])
+
+	w := &walker{f: f, total: R.TotalPages, claim: map[uint32]string{}}
+
+	// 1) 空闲链
+	freeSet := map[uint32]bool{}
+	{
+		seen := map[uint32]bool{}
+		bad := 0
+		var tail uint32
+		for pid := freeHead; pid != 0; {
+			if int(pid) > w.total || seen[pid] {
+				bad++
+				break
+			}
+			seen[pid] = true
+			freeSet[pid] = true
+			tail = pid
+			b, err := w.page(pid)
+			if err != nil {
+				bad++
+				break
+			}
+			pid = le32(b[phNext : phNext+4])
+		}
+		R.FreeCount = len(freeSet)
+		switch {
+		case bad > 0:
+			R.FreeVerdict = "损坏 (成环/越界)"
+		case uint32(R.FreeCount) != R.FreeDeclared:
+			R.FreeVerdict = fmt.Sprintf("计数不符 (实走 %d, header 声明 %d)", R.FreeCount, R.FreeDeclared)
+		case freeHead != 0 && freeEnd != tail:
+			R.FreeVerdict = fmt.Sprintf("尾指针不符 (实走尾 %d, header 声明 %d)", tail, freeEnd)
+		default:
+			R.FreeVerdict = "OK"
+		}
+	}
+
+	// 2) 结构可达 (analyzer 同口径)
+	w.take(1, "reserved")
+	w.chain(le32(hdr[hdrTablePage:hdrTablePage+4]), "table-page")
+	// schema 扩展链 (表页第 1 页 PHDR 之后, 大端)
+	if tp0 := le32(hdr[hdrTablePage : hdrTablePage+4]); tp0 != 0 {
+		if b, err := w.page(tp0); err == nil && len(b) >= phdr+4 {
+			w.chain(be32(b[phdr:phdr+4]), "schema-ext")
+		}
+	}
+	// 未恢复 WAL 链 (只读打开引擎不会重放, 链页仍是活的可达根)
+	if w0 := le32(hdr[hdrFirstWAL : hdrFirstWAL+4]); w0 != 0 {
+		R.WALPending = w.chain(w0, "wal-pending")
+		R.Notes = append(R.Notes, fmt.Sprintf("头页 FirstWALPage=%d (%d 页): 上次未干净关闭或只读跳过恢复, 页归属可能不完整", w0, R.WALPending))
+	}
+
+	tables := w.parseTables()
+	for i := range tables {
+		t := &tables[i]
+		w.chain(t.dp0, "data:"+t.name)
+		w.chain(t.hp0, "heap:"+t.name)
+		w.chain(t.ip0, "imeta:"+t.name)
+		for _, idx := range t.index {
+			w.indexPages(idx.typ, idx.ep)
+		}
+	}
+	R.ReachableNoBlob = len(w.claim)
+
+	// 3) 孤儿: analyzer 口径 (此时 claim 里还没有 blob 链)
+	snapNoBlob := make(map[uint32]string, len(w.claim))
+	for pid, role := range w.claim {
+		snapNoBlob[pid] = role
+	}
+	R.AnalyzerOrphans, R.AnalyzerByType, R.AnalyzerWalLeft = collectOrphans(f, w, nf, freeSet, snapNoBlob)
+
+	// 4) blob 链可达 (引擎只读扫描行内 BlobRef)
+	blobClaimed := 0
+	if opt.EngineBlobScan {
+		chains, notes, claimed := scanBlobChains(dbPath, tables, w)
+		R.Chains = chains
+		R.Notes = append(R.Notes, notes...)
+		blobClaimed = claimed
+	}
+
+	// 5) 孤儿: blob 感知口径 (在结构可达上叠加 blob 链), 并按类型留样
+	R.Orphans, R.ByType, R.WalLeft = collectOrphans(f, w, nf, freeSet, w.claim)
+	R.BlobPages = blobClaimed
+	for _, ci := range R.Chains {
+		if ci.BrokenAt != 0 {
+			R.BrokenChains++
+		}
+	}
+	perType := map[string]int{}
+	for _, p := range R.Orphans {
+		tn := TypeName(p.TypeByte)
+		if perType[tn] < opt.SamplesPerType {
+			R.Samples[tn] = append(R.Samples[tn], p)
+			perType[tn]++
+		}
+	}
+
+	if len(w.cross) > 0 {
+		R.Notes = append(R.Notes, fmt.Sprintf("有 %d 次跨角色重复认领 (一页多个主人), 详见交叉引用", len(w.cross)))
+	}
+	return R, nil
+}
+
+// collectOrphans 算 [2, NextFreePage) 内不在空闲链、不在 claim 集合里的页,
+// 返回 (全部孤儿, 按类型直方图, 其中 WAL 类型页数)。
+func collectOrphans(f *os.File, w *walker, nf uint32, freeSet map[uint32]bool,
+	claim map[uint32]string) ([]PageInfo, map[string]int, int) {
+	var out []PageInfo
+	byType := map[string]int{}
+	walLeft := 0
+	buf := make([]byte, pgsz)
+	for pid := uint32(2); int(pid) < int(nf) && int(pid) <= w.total; pid++ {
+		if _, ok := claim[pid]; ok {
+			continue
+		}
+		if freeSet[pid] {
+			continue
+		}
+		pi := PageInfo{ID: pid}
+		if _, err := f.ReadAt(buf, hdrsz+int64(pid-1)*pgsz); err == nil {
+			pi.TypeByte = buf[0]
+			pi.UseSize = le32(buf[phUse : phUse+4])
+			pi.Next = le32(buf[phNext : phNext+4])
+			pi.Head64 = append([]byte(nil), buf[:64]...)
+		}
+		out = append(out, pi)
+		byType[TypeName(pi.TypeByte)]++
+		if pi.TypeByte == pgWAL {
+			walLeft++
+		}
+	}
+	return out, byType, walLeft
+}
+
+// scanBlobChains 引擎只读打开库, 全表扫出行内 BlobRef, 走链认领 blob 页。
+// 返回 (链清单, 备注, 认领页数)。任何引擎错误都不致命 —— 降级为"未走 blob 链"。
+func scanBlobChains(dbPath string, tables []tableInfo, w *walker) ([]ChainInfo, []string, int) {
+	var notes []string
+	db, err := core.OpenWithOptions(dbPath, core.OpenOptions{ReadOnly: true})
+	if err != nil {
+		return nil, []string{fmt.Sprintf("引擎只读打开失败, 跳过 blob 链走查: %v", err)}, 0
+	}
+	defer db.Close()
+
+	before := len(w.claim)
+	var chains []ChainInfo
+	broken := 0
+	refs := 0
+	for i := range tables {
+		t := &tables[i]
+		tbl, ok := db.FindTable(t.name)
+		if !ok {
+			notes = append(notes, fmt.Sprintf("表 %s: 引擎 FindTable 失败, 该表 blob 列未走查", t.name))
+			continue
+		}
+		perPK := map[uint64][]types.BlobRef{}
+		scanErr := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
+			for _, fv := range vals {
+				if ref, ok := fv.Value.(types.BlobRef); ok && !ref.Empty() {
+					perPK[pkID] = append(perPK[pkID], ref)
+				}
+			}
+			return true
+		})
+		if scanErr != nil {
+			notes = append(notes, fmt.Sprintf("表 %s: 扫描失败 (%v), blob 列可能漏走", t.name, scanErr))
+		}
+		for pkID, rs := range perPK {
+			for _, ref := range rs {
+				refs++
+				ci := ChainInfo{Table: t.name, PKID: pkID, FirstPage: ref.FirstPageID}
+				seen := map[uint32]bool{}
+				for p := ref.FirstPageID; p != 0; {
+					if p < 2 || int(p) > w.total || seen[p] {
+						ci.BrokenAt = p
+						broken++
+						break
+					}
+					seen[p] = true
+					ci.Pages++
+					w.take(p, "blob:"+t.name)
+					b, err := w.page(p)
+					if err != nil {
+						ci.BrokenAt = p
+						broken++
+						break
+					}
+					if b[0] != pgBlob {
+						ci.BrokenAt = p
+						broken++
+						break
+					}
+					p = le32(b[phNext : phNext+4])
+				}
+				chains = append(chains, ci)
+			}
+		}
+	}
+	if refs == 0 {
+		notes = append(notes, "全库行内没有任何非空 BlobRef (无 blob 列或无 blob 数据)")
+	}
+	if broken > 0 {
+		notes = append(notes, fmt.Sprintf("有 %d 条 blob 链中途断裂/类型不符/越界 —— 链页泄漏或行内引用损坏", broken))
+	}
+	return chains, notes, len(w.claim) - before
+}
+
+// SampleHex 把样例页的头 64B 格式化成 hex 行。
+func SampleHex(p PageInfo) string {
+	return hex.Dump(p.Head64)
+}
+
+// SortedTypes 直方图按类型名排序输出。
+func SortedTypes(m map[string]int) []string {
+	ks := make([]string, 0, len(m))
+	for k := range m {
+		ks = append(ks, k)
+	}
+	sort.Strings(ks)
+	return ks
+}

+ 149 - 0
migrate/orphan_pages_test.go

@@ -0,0 +1,149 @@
+package migrate
+
+// 回归: sqlite → VRTX 迁移 (含 VACUUM) 之后, blob 感知口径的孤儿页必须为 0。
+//
+// 样本刻意覆盖 DockerManager 案的要素:
+//   - payload TEXT 列里有一行 >16360 字节 (动态列单值上限) —— 按三态分流落成
+//     Blob 链式列, 迁移产生 Type=9 blob 链页;
+//   - 一个带 UNIQUE 索引的表 + 普通表。
+//
+// 判据: orphancheck 的 analyzer 口径 (不走 blob 链) 会报出这些活链页 —— 这正是
+// analyzer 用户看到的"孤儿页"; 而 blob 感知口径 (走行内 BlobRef → 链) 必须为 0,
+// 非 0 即迁移/引擎在成功路径上泄漏了页。
+
+import (
+	"bytes"
+	"database/sql"
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/internal/orphancheck"
+)
+
+// buildSQLiteSource 造一个 sqlite 源库, 返回路径。
+func buildSQLiteSource(t *testing.T) string {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "src.db")
+	db, err := sql.Open("sqlite", path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+
+	stmts := []string{
+		`CREATE TABLE configs (id INTEGER PRIMARY KEY, name VARCHAR(64), payload TEXT)`,
+		`CREATE TABLE svc (
+			id INTEGER PRIMARY KEY,
+			host VARCHAR(128),
+			port INTEGER,
+			UNIQUE(host)
+		)`,
+	}
+	for _, s := range stmts {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("建源表: %v", err)
+		}
+	}
+	// 大 payload: 40KB > 16360 单值上限 → 迁移时落 Blob 链式列。
+	big := bytes.Repeat([]byte("0123456789abcdef"), 2560) // 40960 字节
+	for i := 1; i <= 20; i++ {
+		var payload any
+		if i == 1 {
+			payload = big
+		} else {
+			payload = fmt.Sprintf(`{"i":%d,"s":"small"}`, i)
+		}
+		if _, err := db.Exec(`INSERT INTO configs (id, name, payload) VALUES (?, ?, ?)`,
+			i, fmt.Sprintf("cfg-%d", i), payload); err != nil {
+			t.Fatal(err)
+		}
+	}
+	for i := 1; i <= 50; i++ {
+		if _, err := db.Exec(`INSERT INTO svc (id, host, port) VALUES (?, ?, ?)`,
+			i, fmt.Sprintf("host-%d.example", i), 10000+i); err != nil {
+			t.Fatal(err)
+		}
+	}
+	return path
+}
+
+// migrateToVRTX 跑一次官方迁移, 返回目标 VRTX 路径。
+func migrateToVRTX(t *testing.T, srcPath string) string {
+	t.Helper()
+	dstPath := filepath.Join(t.TempDir(), "out.db")
+	src, err := OpenSQLite(srcPath, nil)
+	if err != nil {
+		t.Fatalf("打开 sqlite 源: %v", err)
+	}
+	dst, err := OpenVRTX(dstPath, nil)
+	if err != nil {
+		t.Fatalf("打开 VRTX 目标: %v", err)
+	}
+	if _, err := Run(src, dst, quiet()); err != nil {
+		t.Fatalf("迁移: %v", err)
+	}
+	if err := dst.Close(); err != nil {
+		t.Fatalf("关闭目标: %v", err)
+	}
+	if err := src.Close(); err != nil {
+		t.Fatalf("关闭源: %v", err)
+	}
+	return dstPath
+}
+
+func checkOrphans(t *testing.T, dbPath string) *orphancheck.Result {
+	t.Helper()
+	R, err := orphancheck.Run(dbPath, orphancheck.Options{EngineBlobScan: true, SamplesPerType: 3})
+	if err != nil {
+		t.Fatalf("取证 %s: %v", dbPath, err)
+	}
+	return R
+}
+
+func TestMigrateLeavesNoOrphanPages(t *testing.T) {
+	src := buildSQLiteSource(t)
+	dst := migrateToVRTX(t, src)
+
+	R := checkOrphans(t, dst)
+	t.Logf("analyzer 口径孤儿=%d %v; blob 感知真实孤儿=%d %v",
+		len(R.AnalyzerOrphans), R.AnalyzerByType, len(R.Orphans), R.ByType)
+
+	if len(R.AnalyzerOrphans) == 0 {
+		t.Log("注意: 样本未产生 analyzer 口径孤儿 (blob 链页), 误报对照组失效 —— 加大样本")
+	}
+	if len(R.Orphans) > 0 {
+		var ids []uint32
+		for _, p := range R.Orphans {
+			ids = append(ids, p.ID)
+		}
+		t.Fatalf("迁移后存在 %d 页真实孤儿 %v (按类型 %v) —— 成功路径泄漏页",
+			len(R.Orphans), ids, R.ByType)
+	}
+}
+
+func TestMigrateVacuumLeavesNoOrphanPages(t *testing.T) {
+	src := buildSQLiteSource(t)
+	dst := migrateToVRTX(t, src)
+
+	db, err := core.Open(dst)
+	if err != nil {
+		t.Fatalf("打开迁移产物: %v", err)
+	}
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+	if err := db.Close(); err != nil {
+		t.Fatalf("关闭: %v", err)
+	}
+
+	R := checkOrphans(t, dst)
+	if len(R.Orphans) > 0 {
+		var ids []uint32
+		for _, p := range R.Orphans {
+			ids = append(ids, p.ID)
+		}
+		t.Fatalf("迁移+VACUUM 后存在 %d 页真实孤儿 %v (按类型 %v)", len(R.Orphans), ids, R.ByType)
+	}
+}