package main // ============================================================================ // 页级审计 —— 回答"每个已分配的页现在归谁"。 // // 这里所有枚举规则都逐条对照引擎自己的实现写死, 不做任何推测: // 空闲链 next header_page.go recyclePage / recycleChain (页头 NextPage, 偏移 12) // 堆链 / 数据链 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 时会漏收的页是同一批, // 否则这工具只是好看, 不能当证据用。 // ============================================================================ import ( "cmp" "encoding/binary" "fmt" "log" "os" "runtime/debug" "slices" "strings" "sync" "sync/atomic" "git.ycl.pub/yl32940/TsDB2/DB/core" ) // ---- HeaderPage 额外字段 ---- // v11 起头页重排: FreePageStart(56) 与 FreePageEnd(60) 紧邻, 尾指针不再排在 Comment 之后。 const HDR_FREE_END = 60 // HDR_CRC HeaderCrc 字段偏移 (84): 整页 XXH32, 覆盖除该字段自身外的 4096 字节。 // 与 core/header_page.go 的编译期断言互相独立地钉住同一布局 —— 任一侧改了都编译失败。 const HDR_CRC = 84 // checkHeaderCrc 校验头页整页 XXH32。返回 (结论, 说明): // - v11 且 CRC==0: 旧库无校验, ("跳过", "旧 v11 库, 首次写头页自动生成 CRC") // - 匹配: ("OK", "整页 XXH32 与 HeaderCrc 一致") // - 失配: ("CRC 不符", "期望/实际值 —— 头页写坏或文件非 VRTX") // // b 必须是完整的头页字节 (>= 4096); 计算在副本上进行, 不改动调用方数据。 // 算法直接复用 core.XXH32, 与引擎写入侧 (PrepareWrite) 同源, 不存在两套实现漂移。 func checkHeaderCrc(b []byte, version uint32) (string, string) { stored := le32(b[HDR_CRC : HDR_CRC+4]) if version == 11 && stored == 0 { return "跳过", "旧 v11 库 (HeaderCrc=0 为 ExtConfig 预留区), 首次写头页自动生成 CRC" } page := make([]byte, 4096) copy(page, b[:4096]) for i := HDR_CRC; i < HDR_CRC+4; i++ { page[i] = 0 // CRC 覆盖范围: 除自身外的整页 } if got := core.XXH32(page); got != stored { return "CRC 不符", fmt.Sprintf("HeaderCrc=%08x, 实算=%08x —— 头页损坏或文件非 VRTX", stored, got) } return "OK", "整页 XXH32 与 HeaderCrc 一致" } // ---- IndexHead (索引页头) 字段偏移 ---- const ( IX_TYP = 20 // IndexType IX_ELEN = 22 // EntryLen IX_CNT = 24 // Count IX_LEAF = 28 // IsLeaf ) // ---- Hash 索引元数据 (IndexHead 之后) ---- const ( HM_MAGIC = 32 // uint32 HashMagic HM_TOTAL = 44 // 条目总数 HM_DIR = 48 // 桶目录链头 HM_DIRPG = 52 // 目录页数 HM_KEYLEN = 56 // 二级键长度 (hash_handler: hashMetaFixedOff+24) HM_DIRTAIL = 60 // 目录链尾 HASH_MAGIC = 0x54484833 HASH_SLOT = 12 // 每个目录槽 [head][tail][count] // 桶页: [PageHdr(20)][BucketID(4)][Count(4)] 之后就是条目 —— 按 IndexHead 解析会把 // BucketID / NextPage 当成 EntryLen / IsLeaf, 所以单独给一个头长。 HASH_BUCKET_HDR = 28 ) // 索引类型 (page/base_page.go) const ( IT_TIME = 1 IT_PRIMARY = 2 IT_STR = 3 IT_COMPOSE = 4 IT_BTREE = 5 IT_HASH = 6 ) // isRowStore 叶子条目最后 4 字节 = DataPageID 的索引 (即 index.RowStoreHandler 的实现者) func isRowStore(it byte) bool { return it == IT_TIME || it == IT_PRIMARY } // isBTreeIdx 走 B+ 树页格式的索引类型 (TablePage.isBTreePageIndex)。 // Compose 的处理器底层即 SBT 二级 B+ 树, 页格式与 BTree 相同。 func isBTreeIdx(it byte) bool { return it == IT_PRIMARY || it == IT_BTREE || it == IT_STR || it == IT_COMPOSE } 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) } func pageTypeName(t int) string { switch t { case PG_HEAD: return "Free" case PG_TABLE: return "Table" case PG_DATA: return "Data" case PG_HEAP: return "Heap" case PG_IMETA: return "IdxMeta" case PG_IDATA: return "IdxData" case PG_OVER: return "DataOverflow(deprecated)" case PG_WAL: return "WAL" case PG_SEXT: return "SchemaExt" case 9: return "BlobIndex" default: return fmt.Sprintf("?%d", t) } } // ===== 共享文件句柄 ===== // 全库审计要读十几万页。这里刻意**不做整文件映射**: 把整个库映射进地址空间后, // 扫过的页会全部变成进程常驻页 —— bench_10m (2.7G) 光映射就吃掉 2.7G 工作集, // 库再大一点等于把整库读进内存, 正是要避免的事。 // // 改成按页 ReadAt: 页落在系统文件缓存里 (可回收, 不计进程工作集), 进程自己只留 // 几只 16KB 的复用缓冲。逐页扫的热循环用 pageBuf 复用自己的缓冲, 避免"每页一次 // 16KB 分配"在上千万次读里制造 GB 级垃圾把 GC 拖垮; 冷路径走 readPage。 var ( fileMu sync.RWMutex dbf *os.File // 换库时不能立刻关掉旧句柄: 可能还有读操作正拿着它的数据。 // 旧句柄先进 retired, 等"在飞任务数"归零后再关 (见 gcRetired)。 retiredMu sync.Mutex retired []*os.File inflight atomic.Int64 // 在飞的长读任务数: HTTP 请求 + 后台审计/建索引 ) // pgOff 第 pid 页在文件中的偏移: 头部固定 4096 字节, 之后每页 16KB。 func pgOff(pid uint32) int64 { return int64(HDRSZ) + int64(pid-1)*int64(PGSZ) } // gcRetired 关闭在飞的旧句柄。只有在没有任何读任务在跑时才是安全的, // 否则留着等下一次机会 (换库时不常有, 泄漏一个句柄无所谓, 崩掉不行)。 func gcRetired() { if inflight.Load() != 0 { return } retiredMu.Lock() old := retired retired = nil retiredMu.Unlock() for _, f := range old { if f != nil { f.Close() } } } // useDB 切换当前库文件并接管句柄。 func useDB(p string) error { f, e := os.Open(p) if e != nil { return e } fileMu.Lock() old := dbf dbf = f fileMu.Unlock() if old != nil { retiredMu.Lock() retired = append(retired, old) retiredMu.Unlock() } gcRetired() return nil } // dbFile 取当前句柄 (未打开则惰性打开)。 func dbFile() (*os.File, error) { fileMu.RLock() f := dbf fileMu.RUnlock() if f != nil { return f, nil } fileMu.Lock() defer fileMu.Unlock() if dbf != nil { return dbf, nil } if fp == "" { return nil, os.ErrNotExist } f, e := os.Open(fp) if e != nil { return nil, e } dbf = f return f, nil } // readPage 读第 pid 页 (页号从 1 起), 每次新分配一块 16KB 缓冲。 // 逐页顺序扫的热循环请改用 pageBuf 复用缓冲, 否则千万次分配会把 GC 拖垮。 func readPage(pid uint32) ([]byte, error) { b := make([]byte, PGSZ) return readPageInto(pid, b) } // readPageInto 把第 pid 页读进 buf 并返回 buf[:PGSZ]。buf 长度须 >= PGSZ。 // 返回的切片就是 buf —— 用同一只 buf 读下一页会覆盖它, 调用方不得留存。 func readPageInto(pid uint32, buf []byte) ([]byte, error) { if pid == 0 { return nil, os.ErrInvalid } f, e := dbFile() if e != nil { return nil, e } b := buf[:PGSZ] if _, e := f.ReadAt(b, pgOff(pid)); e != nil { return nil, e } return b, nil } // pageBuf 一只可复用的页缓冲 (16KB)。逐页扫盘的热循环自己持有一只, // 避免"每页一次 16KB 分配"在百万次读里制造 GB 级垃圾。 type pageBuf struct{ b []byte } func newPageBuf() *pageBuf { return &pageBuf{b: make([]byte, PGSZ)} } // page 读第 pid 页到本缓冲并返回。返回的切片随下次调用失效, 不得留存。 func (p *pageBuf) page(pid uint32) ([]byte, error) { return readPageInto(pid, p.b) } // enterRead / exitRead 把一段长读工作登记为在飞任务, 使换库时不会 // 把仍在使用的旧文件句柄提前关掉 (见 gcRetired)。 func enterRead() { inflight.Add(1) } func exitRead() { inflight.Add(-1); gcRetired() } // totalPages 数据页总数。 // 文件 = 4096 字节头 + N×16KB, 所以 N = (size-4096)/16KB。 // 注意旧实现用的是 size/PGSZ-1, 会把最后一页整页漏掉。 func totalPages() int { fi, e := os.Stat(fp) if e != nil { return 0 } n := int((fi.Size() - HDRSZ) / PGSZ) if n < 0 { return 0 } return n } // ===== 空闲链 ===== type FreeChainInfo struct { Pages []uint32 `json:"pages"` Total int `json:"total"` Head uint32 `json:"head"` Tail uint32 `json:"tail"` Declared uint32 `json:"declared"` // header.FreePageCount DeclEnd uint32 `json:"declEnd"` // header.FreePageEnd BadPtr int `json:"badPtr"` Bytes int64 `json:"bytes"` Verdict string `json:"verdict"` Detail string `json:"detail"` } // rFreeChain 实走空闲链, 并与 header 里的三个声明值 (start/count/end) 互校。 // 空闲链指针写在页头 NextPage (偏移 12): header_page.go recyclePage 注释明确 // "不再占用偏移 0 的 Type/RefCount 字段"。读偏移 0 会得到 0, 链在第 1 页即断。 func rFreeChain() *FreeChainInfo { fi := &FreeChainInfo{ Head: le32(hdr[56:60]), // FreePageStart (v11 起偏移 56) DeclEnd: le32(hdr[HDR_FREE_END : HDR_FREE_END+4]), Declared: le32(hdr[64:68]), // FreePageCount (v11 起偏移 64) } total := totalPages() seen := make(map[uint32]bool) pb := newPageBuf() for pid := fi.Head; pid != 0; { if int(pid) > total { fi.BadPtr++ // 越界: 指到文件外 break } if seen[pid] { fi.BadPtr++ // 成环 break } seen[pid] = true fi.Pages = append(fi.Pages, pid) b, e := pb.page(pid) if e != nil { fi.BadPtr++ break } pid = le32(b[PH_NEXT : PH_NEXT+4]) } fi.Total = len(fi.Pages) if fi.Total > 0 { fi.Tail = fi.Pages[fi.Total-1] } fi.Bytes = int64(fi.Total) * PGSZ switch { case fi.BadPtr > 0: fi.Verdict, fi.Detail = "损坏", "空闲链出现成环或越界指针" case uint32(fi.Total) != fi.Declared: fi.Verdict, fi.Detail = "计数不符", "实走链长与 header.FreePageCount 不一致" case fi.DeclEnd != 0 && fi.Tail != fi.DeclEnd: fi.Verdict, fi.Detail = "尾指针不符", "实走链尾与 header.FreePageEnd 不一致" case fi.Head == 0 && fi.DeclEnd != 0: fi.Verdict, fi.Detail = "头尾不符", "空闲链为空却仍存在尾指针" case fi.Head != 0 && fi.DeclEnd == 0: // v11 起 FreePageEnd 紧挨 FreePageStart, 引擎的所有写路径 (recyclePage / // recycleChain / newPageWithReader) 都维护这对指针同生同灭 —— 链非空而尾指针 // 为 0 违反该不变量, 只可能是头页写坏或引擎漏写尾指针 (旧库已被版本闸门拒绝, // 不存在"字段引入前生成的库"这种情况)。 fi.Verdict, fi.Detail = "尾指针不符", "空闲链非空但 header.FreePageEnd=0 —— 违反头尾同生同灭不变量 (v11 起引擎全路径维护尾指针)" default: fi.Verdict, fi.Detail = "OK", "链长 / 尾指针 / 头尾三者自洽" } return fi } // ===== 页归属遍历 ===== // 光有"这页归谁"还不够 —— 单靠 role/owner 表看不出"一页被两个主人同时认领", // 因为第二次认领会被静默丢弃。这里把重复认领、非法指针都收集下来, 供完整性交叉校验 // (对照 SQLite 的 PRAGMA integrity_check / PG 的 amcheck 思路)。 type claim struct{ role, owner string } // CrossLink 一页被两个不同主人认领 (已剔除"同一张表内部的正常 fan-in")。 type CrossLink struct { Page uint32 `json:"page"` Type string `json:"type"` Claims []string `json:"claims"` // "role@owner" } // BadRef 链 / 索引里出现非法页指针: 指向文件外, 或指向引擎保留页 1。 type BadRef struct { From uint32 `json:"from"` // 指向它的页 (0=直接来自 header 根) To uint32 `json:"to"` Role string `json:"role"` Owner string `json:"owner"` Kind string `json:"kind"` } type walker struct { role map[uint32]string owner map[uint32]string multi map[uint32][]claim badRefs []BadRef total int pb *pageBuf // 走链时的复用页缓冲 (避免千万次 16KB 分配制造垃圾) } func newWalker() *walker { return &walker{ role: map[uint32]string{}, owner: map[uint32]string{}, multi: map[uint32][]claim{}, pb: newPageBuf(), } } // ptrOK 校验一个待跳转页号: 0 是链终止符; 1 是引擎保留页; 合法目标只能是 2 .. total。 // 非法就记一条 BadRef —— 链条里的坏指针必须报出来, 不能吞掉。 func (w *walker) ptrOK(from, to uint32, role, owner string) bool { if to >= 2 && int(to) <= w.total { return true } kind := "out-of-range" if to == 1 { kind = "reserved-page" } w.badRefs = append(w.badRefs, BadRef{From: from, To: to, Role: role, Owner: owner, Kind: kind}) return false } // take 认领一页; 已被认领返回 false (成环 / 交叉引用), 并把重复认领记入 multi。 // // multi 里按 (role, owner) 去重: 行存储索引的每个条目都会引用自己那行的数据页, // 一页的数据会被同一个索引叶反复认领 —— 千万行下这曾是 1000 万条 claim (约 400MB), // 而交叉引用判定只关心"有哪些不同的主人", 重复的同一主人没有意义。 func (w *walker) take(pid uint32, role, owner string) bool { if pid == 0 { return false } r0, ok := w.role[pid] if !ok { w.role[pid] = role w.owner[pid] = owner return true } if r0 == role && w.owner[pid] == owner { return false // 同一主人重复认领同一页 } cs := w.multi[pid] if cs == nil { cs = []claim{{r0, w.owner[pid]}} } for _, c := range cs { if c.role == role && c.owner == owner { w.multi[pid] = cs return false // 该主人之前已记过 (distinct 主人数不变) } } w.multi[pid] = append(cs, claim{role, owner}) return false } // chain 沿页头 NextPage 走链并整链认领。 func (w *walker) chain(start uint32, role, owner string) int { n := 0 from := uint32(0) seen := map[uint32]bool{} for pid := start; pid != 0; { if !w.ptrOK(from, pid, role, owner) { break } if seen[pid] { // 同一条链里又见回同一页 —— 链内成环 (区别于跨链交叉引用)。 w.badRefs = append(w.badRefs, BadRef{From: from, To: pid, Role: role, Owner: owner, Kind: "cycle"}) break } if !w.take(pid, role, owner) { break } seen[pid] = true n++ b, e := w.pb.page(pid) if e != nil { w.badRefs = append(w.badRefs, BadRef{From: pid, To: pid, Role: role, Owner: owner, Kind: "read-error"}) break } from = pid pid = le32(b[PH_NEXT : PH_NEXT+4]) } return n } // idxDataPages 收下一张行存储索引叶页上所有条目引用的 DataPageID。 // src = 当前页号, 用于定位越界引用来自哪一页; it = 索引类型, 决定取引用字段的偏移。 // // 【不要再用"末 4 字节"】v8 起主键叶条目是 [key(8)][dpID(4)][slot(2)] = 14B, // dpID 固定在偏移 8..12 (见 indexcheck.go:rowStoreRefOff)。若按末 4 字节读, // 得到的是 [dpID 低 2 字节][slot 2 字节]: 实测健康的 emp.pk_emp 会被读成 // 0x000C0001 = 786433, 一个远超文件页数的"页号" → analyzer 满屏 not-data-page / // unaccounted 并把好库判成 CORRUPT。引用偏移一律由 rowStoreRefOff 给出。 func (w *walker) idxDataPages(b []byte, src uint32, it byte, owner string) int { if b[0] != PG_IDATA { return 0 } cn := int(le32(b[IX_CNT : IX_CNT+4])) el := int(le16(b[IX_ELEN : IX_ELEN+2])) if el <= 4 { el = 12 } refOff := rowStoreRefOff(it, el) if refOff < 0 || refOff+4 > el { return 0 } n := 0 for i := 0; i < cn; i++ { o := IDX_HDR + i*el if o+el > len(b) { break } if p := be32(b[o+refOff : o+refOff+4]); p != 0 { if !w.ptrOK(src, p, "data", owner) { continue } if w.take(p, "data", owner) { n++ } } } return n } // ownerTable 取 owner 的表名部分 (owner 形如 "表名" 或 "表名.索引名"; 括号开头的是引擎保留结构)。 func ownerTable(o string) string { if strings.HasPrefix(o, "(") { return o } if k := strings.IndexByte(o, '.'); k >= 0 { return o[:k] } return o } // pageTypeByte 读某页的类型字节 (用于把交叉引用页标注类型)。 func pageTypeByte(pid uint32) byte { if b, e := readPage(pid); e == nil && len(b) > 0 { return b[0] } return 255 } // indexPages 枚举一个索引占用的全部页, 返回 (索引页数, 它引用的 DataPage 数)。 // 对照: btree.CollectAllPageIDs / TimeIndexHandler.CollectPageIDs / // ComposeChainHandler.CollectPageIDs / HashHandler.CollectPageIDs。 func (w *walker) indexPages(it byte, entry uint32, owner string) (n, dp int) { if entry == 0 { return 0, 0 } // 索引根页来自表结构, 也可能被写坏 —— 先做一次指针合法性校验。 if !w.ptrOK(0, entry, "index-root", owner) { return 0, 0 } switch { case it == IT_HASH: pb := newPageBuf() meta, e := pb.page(entry) if e != nil || le32(meta[HM_MAGIC:HM_MAGIC+4]) != HASH_MAGIC { return 0, 0 // magic 对不上就不猜 —— 与引擎"宁可泄漏也不乱收"一致 } w.take(entry, "index-meta", owner) n++ limit := PHDR + ((PGSZ-PHDR)/HASH_SLOT)*HASH_SLOT dir, from := le32(meta[HM_DIR:HM_DIR+4]), entry for dir != 0 { if !w.ptrOK(from, dir, "index-dir", owner) { break } if !w.take(dir, "index-dir", owner) { break } n++ b, e := pb.page(dir) if e != nil { break } for o := PHDR; o+HASH_SLOT <= limit; o += HASH_SLOT { head := le32(b[o : o+4]) tail := le32(b[o+4 : o+8]) cnt := le32(b[o+8 : o+12]) if head == 0 && tail == 0 && cnt == 0 { continue } if head != 0 { n += w.chain(head, "index-bucket", owner) } } from = dir dir = le32(b[PH_NEXT : PH_NEXT+4]) } return n, 0 case isBTreeIdx(it): // B+ 树: 内部条目的末 4B 是子页号 (主键 [key(8)][childPID(4)] 与二级 // [key(keyLen)][childPID(4)] 都成立); 叶子条目里, 只有行存储索引 (主键/时序) // 才携带 DataPage 引用, 且偏移随类型不同 —— 必须走 rowStoreRefOff, 不能一律取末 4B。 pb := newPageBuf() 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 !w.take(pid, "index-node", owner) { continue } n++ b, e := pb.page(pid) if e != nil || b[0] != PG_IDATA { continue } if b[IX_LEAF] == 1 { if isRowStore(it) { dp += w.idxDataPages(b, pid, it, owner) } // 叶子同层链: GetDataPageIDs 就是顺着它扫完所有叶子的 from := pid for nx := le32(b[PH_NEXT : PH_NEXT+4]); nx != 0; { if !w.ptrOK(from, nx, "index-node", owner) { break } if !w.take(nx, "index-node", owner) { break } n++ nb, e := pb.page(nx) if e != nil { break } if isRowStore(it) { dp += w.idxDataPages(nb, nx, it, owner) } from = nx nx = le32(nb[PH_NEXT : PH_NEXT+4]) } continue } cn := int(le32(b[IX_CNT : IX_CNT+4])) el := int(le16(b[IX_ELEN : IX_ELEN+2])) if el <= 4 { el = 12 } 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 { continue } if w.ptrOK(pid, child, "index-node", owner) { queue = append(queue, child) } } } return n, dp default: // 一维条目链 (Time), 由 PageHdr.NextPage 串起。 // (Compose 的处理器底层已是 SBT B+ 树, 走 isBTreeIdx 分支。) pb := newPageBuf() from := uint32(0) for pid := entry; pid != 0; { if !w.ptrOK(from, pid, "index-chain", owner) { break } if !w.take(pid, "index-chain", owner) { break } n++ b, e := pb.page(pid) if e != nil || b[0] != PG_IDATA { break } if isRowStore(it) { dp += w.idxDataPages(b, pid, it, owner) } from = pid pid = le32(b[PH_NEXT : PH_NEXT+4]) } return n, 0 } } // ===== 审计结果 ===== type TableAudit struct { Name string `json:"name"` Rows uint64 `json:"rows"` DataFirst uint32 `json:"dp0"` DataPages int `json:"dataPages"` HeapFirst uint32 `json:"hp0"` HeapDeclared uint32 `json:"hpc"` HeapActual int `json:"heapActual"` HeapRefs uint64 `json:"heapRefs"` HeapLive int `json:"heapLive"` // RefCount > 0 的堆页 HeapEmpty int `json:"heapEmpty"` // RefCount == 0 的堆页 (可整页回收) 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"` } type ReachResult struct { TablePages int `json:"tablePages"` Data int `json:"data"` Heap int `json:"heap"` IdxMeta int `json:"idxMeta"` IdxNode int `json:"idxNode"` IdxDir int `json:"idxDir"` IdxBucket int `json:"idxBucket"` IdxChain int `json:"idxChain"` SchemaExt int `json:"schemaExt"` Wal int `json:"wal"` // 未恢复的 WAL 链页数 Free int `json:"free"` NeverUsed int `json:"neverUsed"` Reserved int `json:"reserved"` Reachable int `json:"reachable"` Unaccounted int `json:"unaccounted"` UnaccountedIDs []uint32 `json:"unaccountedIds"` ByRole map[string]int `json:"byRole"` // UnaccountedByType: 孤儿页按页类型分类 (Free/WAL/Data/IdxData/...), 便于区分成因。 UnaccountedByType map[string]int `json:"unaccountedByType"` // WalLeftover: 其中类型为 WAL 的页数 —— 历史已提交事务的 WAL 链页未被回收 (空间泄漏)。 WalLeftover int `json:"walLeftover"` Truncated bool `json:"truncated"` } type AuditResult struct { File string `json:"file"` Bytes int64 `json:"bytes"` TotalPages int `json:"totalPages"` FirstFree uint32 `json:"firstFree"` // NextFreePage Allocated int `json:"allocated"` // 已下发的页 (2 .. NextFreePage-1) Reserved int `json:"reserved"` // 页 1: 引擎预留, 从不分配 NeverUsed int `json:"neverUsed"` // 从未分配过的尾页 Free *FreeChainInfo `json:"free"` HeaderCrc string `json:"hdrCrc"` // 头页整页 XXH32 校验结论 (OK/CRC 不符/跳过) HeaderCrcD string `json:"hdrCrcDetail"` // 校验说明 (期望/实算值等) Tables []TableAudit `json:"tables"` SumRows uint64 `json:"sumRows"` SumHeapDecl uint64 `json:"sumHeapDecl"` SumHeapAct int `json:"sumHeapAct"` SumHeapRefs uint64 `json:"sumHeapRefs"` SumHeapBytes int64 `json:"sumHeapBytes"` Reach *ReachResult `json:"reach"` Verdict string `json:"verdict"` Notes []string `json:"notes"` // 完整性交叉校验 (对照 SQLite integrity_check / PG amcheck) MultiRef int `json:"multiRef"` // 交叉引用页总数 CrossLinks []CrossLink `json:"crossLinks"` // 一页被两个不同主人认领 FreeLive []uint32 `json:"freeLive"` // 既在空闲链、又被引用 BadRefCount int `json:"badRefCount"` // 非法指针总数 BadRefs []BadRef `json:"badRefs"` // 非法指针明细 (截断) // 索引内容一致性 (对照 PRAGMA integrity_check / amcheck) IdxEntries int `json:"idxEntries"` // 扫描的索引条目总数 IdxBadOrder int `json:"idxBadOrder"` // 键序回退 IdxOrphanPK int `json:"idxOrphanPK"` // 二级索引指向不存在的行 (索引项 -> 行) IdxNotDataPage int `json:"idxNotDataPage"` // 行存储索引指向非数据页 IdxMissingRow int `json:"idxMissingRow"` // 存活行没进主键索引 (行 -> 索引项) IdxMissingEntry int `json:"idxMissingEntry"`// 存活行没进某个二级索引 (行 -> 索引项) IdxIssueTrunc bool `json:"idxIssueTrunc"` // 明细已截断 IdxSkipped string `json:"idxSkipped"` // 跳过原因 (空=已执行) IdxNotes []string `json:"idxNotes"` // 逐表跳过说明 (如行存储是时序索引) IdxIssues []IndexIssue `json:"idxIssues"` // 明细 (截断) IdxStats []IndexStat `json:"idxStats"` // 每个索引的统计 // IdxPending: 结构审计已发布, 索引内容一致性还在后台增量补全。 // 前端据此轮询 /api/audit, 就绪后 (转 false) 重绘 (见 computeAudit / ensureIndexContent)。 IdxPending bool `json:"idxPending"` // structNotes: 结构阶段产生、与索引内容无关的说明 (WAL 等)。finalizeVerdict 据此 // 重建 Notes —— 这样"结构判决"与"补全索引后的最终判决"两次计算不会重复追加说明。 structNotes []string } // runAuditStructural 做"结构审计": 页账 + 空闲链自检 + 每表堆链交叉校验 + 未归属页 + // 交叉引用 / 非法指针校验。返回 (结果, 页归属遍历器, 空闲页集合, 表快照, 是否还需补全索引内容)。 // // 索引内容一致性要在千万级条目上逐条比对, 是整个审计最慢的一段 (大表上占比 >90%), // 所以刻意不放在这里做: 先发布结构结果, 让概览 / 页图 / 孤儿页立刻可用, 索引内容再由 // ensureIndexContent 在后台增量补全 (见 computeAudit)。 func runAuditStructural() (*AuditResult, *walker, map[uint32]bool, []TInfo, bool) { mu.RLock() ts := tabs mu.RUnlock() R := &AuditResult{File: fp, Reach: &ReachResult{ByRole: map[string]int{}, UnaccountedByType: map[string]int{}}} if fi, e := os.Stat(fp); e == nil { R.Bytes = fi.Size() } R.TotalPages = totalPages() R.Free = rFreeChain() // 头页整页 XXH32 校验 (core.PrepareWrite 写入侧的同源复核) R.HeaderCrc, R.HeaderCrcD = checkHeaderCrc(hdr[:], le32(hdr[4:8])) freeSet := make(map[uint32]bool, R.Free.Total) for _, p := range R.Free.Pages { freeSet[p] = true } // 页号约定 (header_page.go newHeader): 0=header, 1=预留(表页), 分配从 2 开始。 // 所以"已下发"= 页 2..NextFreePage-1, 页 1 是从不分配的保留页。 nf := le32(hdr[12:16]) R.FirstFree = nf if nf > 2 { R.Allocated = int(nf) - 2 } if R.Allocated > R.TotalPages { R.Allocated = R.TotalPages } if R.TotalPages >= 1 { R.Reserved = 1 } if R.NeverUsed = R.TotalPages - R.Allocated - R.Reserved; R.NeverUsed < 0 { R.NeverUsed = 0 } w := newWalker() w.total = R.TotalPages if R.Reserved == 1 { w.take(1, "reserved", "(reserved page)") } // 1) 表页链 (TablePage 偏移 48) R.Reach.TablePages = w.chain(le32(hdr[48:52]), "table-page", "(table page)") // 2) schema 扩展页链: 全库共用一条, 链头存在"表页第 1 页"的 PageHdr 之后。 // 这个大端存盘 —— 引擎 TablePage.saveSchemaExtHead / Init 都走 binary.BigEndian // (与 SchemaFragHdr 同一约定), 所以这里必须用 be32。 // 用 le32 会把页号 0x0002A755(173909) 读成 0x55A70200(1437008384), 把一个合法页 // 误报成"指向文件外的非法页指针"。 if tp0 := le32(hdr[48:52]); tp0 != 0 { if b, e := readPage(tp0); e == nil && len(b) >= PHDR+4 { R.Reach.SchemaExt = w.chain(be32(b[PHDR:PHDR+4]), "schema-ext", "(shared)") } } // 2.5) 未恢复的 WAL 链: FirstWALPage != 0 说明上次没干净关闭, 盘上还留着未重放的 // WAL 页。WAL 页用 PageHdr.NextPage 正向串联 (wal.go 注释明确)。这些页是"活的" // 可达根, 不能算孤儿; 但本工具只读文件、不跑恢复, 所以单独标注。 // (v11 起 FirstWALPage 偏移 68。) if w0 := le32(hdr[68:72]); w0 != 0 { R.Reach.Wal = w.chain(w0, "wal-pending", "(WAL)") R.structNotes = append(R.structNotes, fmt.Sprintf( "检测到未恢复的 WAL 链 (FirstWALPage=%d, %d 页): 上次未干净关闭, 或分析的是崩溃现场。"+ "本工具为只读审计, 不会重放 WAL —— 页归属可能不完整。", w0, R.Reach.Wal)) } // 3) 每张表: 数据链 / 堆链 / 索引 hpBuf := newPageBuf() // 堆链逐页读的复用缓冲 (堆页可能有百万级) for i := range ts { t := &ts[i] ta := TableAudit{ Name: t.Name, Rows: t.RowN, DataFirst: t.DP0, HeapFirst: t.HP0, HeapDeclared: t.HPC, } ta.DataPages = w.chain(t.DP0, "data", t.Name) hpFrom := uint32(0) hpSeen := map[uint32]bool{} for pid := t.HP0; pid != 0; { if !w.ptrOK(hpFrom, pid, "heap", t.Name) { break } if hpSeen[pid] { w.badRefs = append(w.badRefs, BadRef{From: hpFrom, To: pid, Role: "heap", Owner: t.Name, Kind: "cycle"}) break } if !w.take(pid, "heap", t.Name) { break } hpSeen[pid] = true b, e := hpBuf.page(pid) if e != nil { break } ta.HeapActual++ rc := uint64(le16(b[PH_REF : PH_REF+2])) ta.HeapRefs += rc if rc > 0 { ta.HeapLive++ } else { ta.HeapEmpty++ } ta.HeapBytes += int64(le32(b[PH_USE : PH_USE+4])) hpFrom = pid pid = le32(b[PH_NEXT : PH_NEXT+4]) } w.chain(t.IP0, "index-meta", t.Name) for _, idx := range t.Idxs { n, dp := w.indexPages(idx.Type, idx.EP, t.Name+"."+idx.Name) ta.IdxPages += n 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", "无堆页" case ta.HeapDeclared == 0: ta.Verdict = "STALE" ta.Detail = "HeapPageCount=0 但链上有页: 该表建于计数器引入之前, 引擎退化为逐页回收, 数据安全" case int(ta.HeapDeclared) == ta.HeapActual: ta.Verdict, ta.Detail = "OK", "声明值与实走链长一致" default: ta.Verdict = "MISMATCH" ta.Detail = "HeapPageCount 与实走链长不一致 (计数器失真: 偏高=页泄漏, 偏低=页被提前回收)" } R.SumRows += ta.Rows R.SumHeapDecl += uint64(ta.HeapDeclared) R.SumHeapAct += ta.HeapActual R.SumHeapRefs += ta.HeapRefs R.SumHeapBytes += ta.HeapBytes R.Tables = append(R.Tables, ta) } // 4) 可达性汇总 for _, role := range w.role { R.Reach.ByRole[role]++ } R.Reach.Reachable = len(w.role) R.Reach.Data = R.Reach.ByRole["data"] R.Reach.Heap = R.Reach.ByRole["heap"] R.Reach.IdxMeta = R.Reach.ByRole["index-meta"] R.Reach.IdxNode = R.Reach.ByRole["index-node"] R.Reach.IdxDir = R.Reach.ByRole["index-dir"] R.Reach.IdxBucket = R.Reach.ByRole["index-bucket"] R.Reach.IdxChain = R.Reach.ByRole["index-chain"] + R.Reach.ByRole["index-compose-chain"] R.Reach.Free = R.Free.Total R.Reach.NeverUsed = R.NeverUsed R.Reach.Reserved = R.Reserved // 5) 未归属页: 已下发区间 (2 .. NextFreePage-1) 内, 既不可达也不在空闲链上 —— 与 // DropTable 会漏收的页同源。页 1 是保留页, 不计入。 pb := newPageBuf() // 孤儿页逐页读头判类型的复用缓冲 for i := 2; i <= R.TotalPages; i++ { pid := uint32(i) if int(pid) >= int(nf) { continue // 从未分配过 } if _, ok := w.role[pid]; ok { continue } if freeSet[pid] { continue } R.Reach.Unaccounted++ // 读页头区分成因: 类型字节=7(PG_WAL) 的孤儿页 = 已提交 WAL 链页未被回收。 if buf, err := pb.page(pid); err == nil && len(buf) > 0 { t := int(buf[0]) R.Reach.UnaccountedByType[pageTypeName(t)]++ if t == PG_WAL { R.Reach.WalLeftover++ } } if len(R.Reach.UnaccountedIDs) < 300 { R.Reach.UnaccountedIDs = append(R.Reach.UnaccountedIDs, pid) } else { R.Reach.Truncated = true } } // 5.5) 完整性交叉校验 —— 与 SQLite integrity_check / PG amcheck 同类: // (a) 交叉引用: 一页被两个不同主人认领 (同表内部的正常 fan-in 除外) // (b) 空闲页被引用: 既在空闲链、又被结构引用 (下次分配即被覆写) // (c) 非法指针: 指向文件外 / 保留页 1 for pid, cs := range w.multi { if len(cs) < 2 { continue } base := ownerTable(cs[0].owner) same := true for _, c := range cs[1:] { if ownerTable(c.owner) != base { same = false break } } if same { continue // 同一张表内部重复认领 (数据链+索引叶 / 同索引叶链) —— 正常 } cl := make([]string, 0, len(cs)) for _, c := range cs { cl = append(cl, c.role+"@"+c.owner) } R.CrossLinks = append(R.CrossLinks, CrossLink{Page: pid, Type: pageTypeName(int(pageTypeByte(pid))), Claims: cl}) } R.MultiRef = len(R.CrossLinks) slices.SortFunc(R.CrossLinks, func(a, b CrossLink) int { return cmp.Compare(a.Page, b.Page) }) if len(R.CrossLinks) > 300 { R.CrossLinks = R.CrossLinks[:300] R.Reach.Truncated = true } for pid := range freeSet { if _, ok := w.role[pid]; ok { R.FreeLive = append(R.FreeLive, pid) } } slices.Sort(R.FreeLive) if len(w.badRefs) > 0 { seen := map[BadRef]bool{} for _, br := range w.badRefs { if seen[br] { continue } seen[br] = true R.BadRefCount++ if len(R.BadRefs) < 300 { R.BadRefs = append(R.BadRefs, br) } else { R.Reach.Truncated = true } } slices.SortFunc(R.BadRefs, func(a, b BadRef) int { if a.From != b.From { return cmp.Compare(a.From, b.From) } return cmp.Compare(a.To, b.To) }) } // 5.6) 索引内容一致性 —— 页归属看不出"内容错位", 这里逐条比对索引项 (对照 integrity_check)。 // WAL 未恢复时盘上不是最终状态, 比对会系统性误报, checkIndexContent 会直接跳过 —— // 那就没什么可增量补全的了, 就地跑完并置 IdxPending=false。 needIdxFill := true if R.Reach.Wal > 0 { checkIndexContent(R, w, ts) R.IdxPending = false needIdxFill = false } else { R.IdxPending = true } finalizeVerdict(R) return R, w, freeSet, ts, needIdxFill } // finalizeVerdict 按严重度算总判决: 交叉校验 / 索引内容 > 孤儿页 > 空闲链 > WAL。 // 结构阶段与索引内容补全阶段都会调它 —— 索引内容补齐后重算一次, 判决才会从 // "结构 OK" 收敛到最终值。 func finalizeVerdict(R *AuditResult) { notes := append([]string(nil), R.structNotes...) switch { case R.MultiRef > 0 || len(R.FreeLive) > 0 || R.BadRefCount > 0 || R.IdxBadOrder > 0 || R.IdxOrphanPK > 0 || R.IdxNotDataPage > 0 || R.IdxMissingRow > 0 || R.IdxMissingEntry > 0: R.Verdict = "CORRUPT" if R.MultiRef > 0 { notes = append(notes, fmt.Sprintf("有 %d 页被两个不同主人同时认领 (交叉引用) —— 典型如两个表共用一个数据页", R.MultiRef)) } if len(R.FreeLive) > 0 { notes = append(notes, fmt.Sprintf("有 %d 页既在空闲链上、又被结构引用 —— 下次分配即被覆写, 属数据损坏前兆", len(R.FreeLive))) } if R.BadRefCount > 0 { notes = append(notes, fmt.Sprintf("有 %d 处非法页指针 (指向文件外, 或指向保留页 1)", R.BadRefCount)) } if R.IdxBadOrder > 0 { notes = append(notes, fmt.Sprintf("有 %d 处索引键序回退 —— B+ 树靠分隔键下行查找, 键序一乱就会查不到已经存在的行", R.IdxBadOrder)) } if R.IdxOrphanPK > 0 { notes = append(notes, fmt.Sprintf("有 %d 个二级索引条目指向已不存在的行 (悬空索引项) —— 删行 / 重建索引漏删的典型残留", R.IdxOrphanPK)) } if R.IdxNotDataPage > 0 { notes = append(notes, fmt.Sprintf("有 %d 个行存储索引条目指向的页不是数据页", R.IdxNotDataPage)) } if R.IdxMissingRow > 0 { notes = append(notes, fmt.Sprintf("有 %d 个存活行没进主键索引 —— 行还在盘上, 但主键 B+ 树里没有它, 按主键查不到", R.IdxMissingRow)) } if R.IdxMissingEntry > 0 { notes = append(notes, fmt.Sprintf("有 %d 个存活行没进某个二级索引 —— 按该索引查这些行会漏", R.IdxMissingEntry)) } case R.Reach.Unaccounted > 0: R.Verdict = "UNACCOUNTED" switch { case R.Reach.WalLeftover > 0 && R.Reach.WalLeftover == R.Reach.Unaccounted: notes = append(notes, fmt.Sprintf( "有 %d 页 WAL 残留: 页类型为 WAL(7), 但页头 FirstWALPage=0 (无待恢复 WAL) 且不在空闲链上 —— "+ "这些是历史已提交事务的 WAL 链页, 本应在提交时归还空闲链却未被回收 (引擎空间回收遗漏, 属空间泄漏)。", R.Reach.WalLeftover)) case R.Reach.WalLeftover > 0: notes = append(notes, fmt.Sprintf( "有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 其中 %d 页为 WAL 残留 (历史已提交 WAL 链未被回收)。", R.Reach.Unaccounted, R.Reach.WalLeftover)) default: notes = append(notes, fmt.Sprintf("有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 可能来自 DropTable / 重建索引漏收, 也可能是本工具未覆盖的结构 (v10 起的 blob 链已覆盖: Type=9 孤儿即真实泄漏)", R.Reach.Unaccounted)) } case R.Free.Verdict != "OK": R.Verdict = "FREE-CHAIN" notes = append(notes, "空闲链自检未通过: "+R.Free.Detail) case R.Reach.Wal > 0: R.Verdict = "DIRTY-WAL" default: R.Verdict = "OK" } for _, ta := range R.Tables { if ta.Verdict == "MISMATCH" { R.Verdict = "HEAP-COUNT" notes = append(notes, ta.Name+": "+ta.Detail) } } R.Notes = notes } // ===== 审计缓存 (按库失效) ===== var ( cacheMu sync.Mutex cachedAudit *AuditResult cachedWalk *walker cachedFree map[uint32]bool auditCalcMu sync.Mutex // 串行化全库审计: 同一时刻只允许一次扫描在跑 auditGen uint64 // 切库代数, 防止旧库的异步审计结果污染新库缓存 auditFailed bool // 最近一次审计是否因 panic 失败 auditErrMsg string // 失败原因 (含堆栈), 供前端展示与排查 ) // computeAudit 计算 (或复用) 全库审计的**结构**结果, 并安排索引内容的后台增量补全。 // // 拆成两段的原因: 结构审计在 mmap + 并行下已是亚秒级, 而索引内容一致性要在千万级 // 条目上逐条比对, 大表上要十几秒。若等它算完才发布, 概览 / 页图 / 孤儿页都得陪着等。 // 所以这里先发布结构结果 (IdxPending=true), 再由 ensureIndexContent 在后台补齐索引 // 内容后整体替换缓存 (IdxPending=false)。 // // 并发的调用者会排队等待正在进行的那次结构计算, 而不是各自再跑一遍; 这样 // "打开库后后台预热" 与用户随后点开某个审计页不会重复扫描整个文件。 // 结果只在切库代数未变化时写回缓存, 避免旧库的慢审计覆盖新库的数据。 func computeAudit() (a *AuditResult, w *walker, fs map[uint32]bool) { auditCalcMu.Lock() defer auditCalcMu.Unlock() // 审计会长时间持有指向映射区的页切片, 登记为在飞任务: // 期间用户换库也不能把这块映射解掉, 否则就是段错误。 enterRead() defer exitRead() cacheMu.Lock() a, w, fs = cachedAudit, cachedWalk, cachedFree gen := auditGen if a == nil { auditFailed = false auditErrMsg = "" } cacheMu.Unlock() if a != nil { return a, w, fs } // 落盘缓存命中: 指纹一致 (文件大小 + 头部页未变) 说明库没被改动, 直接取上次的分析结论, // 跳过最慢的索引内容一致性扫描 —— 千万级条目那一步占了审计 >90% 的时间与几乎全部内存。 // // 命中时 walker / freeSet 不在这里重建: 页号->归属的映射在超大库上同样很占内存, // 而它们只在"单页检查"里用得到, 所以改为首次查看单页时按需重建 (见 pageOwnership)。 cfp := fingerprint() cacheMu.Lock() cacheFP = cfp cacheMu.Unlock() if cached, ok := storeLoad(cfp, gen); ok { cacheMu.Lock() published := gen == auditGen if published { cachedAudit, cachedWalk, cachedFree = cached, nil, nil } cacheMu.Unlock() if published { log.Printf("[cache] 命中 (fp=%s): 库未改动, 直接复用审计结论", cfp) return cached, nil, nil } // 期间换库了: 这份结论作废, 老老实实走下面的全量分析。 } if cfp != "" { log.Printf("[cache] 未命中 (fp=%s): 库有改动或首次分析, 执行全量审计", cfp) } // 审计是"尽力而为"的只读诊断: 某个库触发分析器自身的 bug 时, 绝不能把整个服务带走。 // 后台预热尤其如此 —— 未在 goroutine 里恢复的 panic 会直接终止进程, 前端表现为"打开后断开"。 // 这里兜底: 记录失败原因并让结果保持为空, 由前端显示"审计失败"而不是无限等待。 defer func() { if r := recover(); r != nil { a, w, fs = nil, nil, nil cacheMu.Lock() if gen == auditGen { auditFailed = true auditErrMsg = fmt.Sprintf("%v\n%s", r, debug.Stack()) } cacheMu.Unlock() } }() var ts []TInfo var needFill bool a, w, fs, ts, needFill = runAuditStructural() // 立刻发布结构结果 (可能只是"半成品": IdxPending=true), 让概览 / 页图 / 孤儿页 // 马上可用; 索引内容不阻塞本次调用。 cacheMu.Lock() published := gen == auditGen if published { cachedAudit, cachedWalk, cachedFree = a, w, fs } cacheMu.Unlock() if published { if needFill { go ensureIndexContent(gen, w, ts) } else { // 结构阶段已把索引内容一并算完 (WAL 未恢复场景): 直接落盘, 下次打开直接取。 storeSave(cfp, a) } } return a, w, fs } // ===== 增量审计: 索引内容后台补全 ===== var ( idxFillMu sync.Mutex idxFillGen uint64 // 已安排 (正在跑 / 已完成) 补全的代数; 0 = 尚未安排 ) // ensureIndexContent 在后台补全索引内容一致性检查, 再把"结构结果 + 索引内容"合并成 // 最终结果整体替换缓存。同一代 (同一库) 只会跑一次; 期间换库则放弃发布 (结果作废)。 // // 之所以复制一份再写: 结构结果已经发布出去、可能正被 JSON 序列化, 不能就地改, // 否则就是数据竞争。读接口拿到的永远是自洽快照 (要么结构版, 要么最终版)。 func ensureIndexContent(gen uint64, w *walker, ts []TInfo) { idxFillMu.Lock() if idxFillGen == gen { idxFillMu.Unlock() return } idxFillGen = gen idxFillMu.Unlock() // 长读任务: 期间换库不能把映射解掉, 否则拿着的页切片会变成野指针。 enterRead() defer exitRead() defer func() { if r := recover(); r != nil { log.Printf("[audit] 索引内容补全失败, 保留结构结果: %v", r) cacheMu.Lock() if gen == auditGen { auditFailed = true auditErrMsg = fmt.Sprintf("索引内容补全失败: %v", r) } cacheMu.Unlock() } }() cacheMu.Lock() a := cachedAudit cacheMu.Unlock() if a == nil || !a.IdxPending { return } full := cloneAudit(a) checkIndexContent(full, w, ts) full.IdxPending = false finalizeVerdict(full) cacheMu.Lock() if gen != auditGen { cacheMu.Unlock() return } cachedAudit = full cfp := cacheFP cacheMu.Unlock() // 结论落盘: 下次打开同一个库 (指纹一致) 直接取, 免掉这一遍索引内容扫描。 storeSave(cfp, full) } // cloneAudit 浅拷贝审计结果 —— 供后台补全索引内容时就地写入。已发布的 a 绝不改; // 只重置会被补全阶段写入的字段, 其余 (Free/Tables/Reach 等只读数据) 共享引用。 func cloneAudit(a *AuditResult) *AuditResult { b := *a b.structNotes = append([]string(nil), a.structNotes...) b.Tables = append([]TableAudit(nil), a.Tables...) b.IdxStats = nil b.IdxIssues = nil b.IdxNotes = nil return &b } // auditCached 取审计结果 (首次调用时同步计算)。 func auditCached() *AuditResult { a, _, _ := computeAudit() return a } // peekAudit 非阻塞读取已算好的审计结果; 尚未就绪时返回 nil。 // 打开库时用它, 避免为概览页的汇总值而阻塞整个请求。 func peekAudit() *AuditResult { cacheMu.Lock() defer cacheMu.Unlock() return cachedAudit } // peekWalk 非阻塞取已建好的页归属遍历器; 未就绪时返回 nil (调用方需容忍)。 // 页图分块渲染用它给页上色 —— 不能为此触发一次全库归属重建, 拿不到就先不显示归属。 func peekWalk() *walker { cacheMu.Lock() defer cacheMu.Unlock() return cachedWalk } // peekAuditError 返回最近一次审计是否失败及其原因; 未失败时返回 (false, "")。 func peekAuditError() (bool, string) { cacheMu.Lock() defer cacheMu.Unlock() return auditFailed, auditErrMsg } // startAuditAsync 后台预热全库审计 (幂等: 已缓存时会立即返回)。 func startAuditAsync() { go computeAudit() } // walkBuildMu 串行化"仅重建页归属"的扫描, 避免多个单页检查同时各扫一遍。 var walkBuildMu sync.Mutex // pageOwnership 取 (页归属遍历器, 空闲页集合), 供页检查器查询单页归属。 // // 正常分析路径会顺带建好这两个结构; 而缓存命中时审计只从盘上取回了结论, 没建它们 —— // 那两个 map 是"页号 -> 归属", 在超大库上会有几百万项, 一直占着内存不值当。所以 // 只有真正要看某一页归属时才在这里补建: 补建只走结构链 (亚秒级), 不碰最慢的索引内容比对。 func pageOwnership() (*walker, map[uint32]bool) { cacheMu.Lock() a, w, fs := cachedAudit, cachedWalk, cachedFree cacheMu.Unlock() if a == nil { // 审计还没跑过: 交给 computeAudit (它会连同 walker 一起建好)。 _, w, fs = computeAudit() return w, fs } if w != nil { return w, fs } return rebuildWalker() } // rebuildWalker 只为拿到页归属遍历器而重跑一次结构链。结果按代数写回缓存。 func rebuildWalker() (*walker, map[uint32]bool) { walkBuildMu.Lock() defer walkBuildMu.Unlock() cacheMu.Lock() w, fs := cachedWalk, cachedFree gen := auditGen cacheMu.Unlock() if w != nil { return w, fs } _, w2, fs2, _, _ := runAuditStructural() cacheMu.Lock() if gen == auditGen { cachedWalk, cachedFree = w2, fs2 } cacheMu.Unlock() return w2, fs2 } // invalidateAudit 切库时清空缓存并推进代数。 func invalidateAudit() { cacheMu.Lock() cachedAudit, cachedWalk, cachedFree = nil, nil, nil auditFailed, auditErrMsg = false, "" auditGen++ cacheMu.Unlock() } // ===== 单页检查 ===== type PageInfo struct { ID uint32 `json:"id"` Type int `json:"t"` TypeS string `json:"ts"` Flags byte `json:"flags"` Ref uint16 `json:"ref"` Prev uint32 `json:"pv"` Next uint32 `json:"nx"` Use uint32 `json:"use"` Us float64 `json:"us"` Role string `json:"role"` Owner string `json:"owner"` InFree bool `json:"inFree"` IsAlloc bool `json:"isAlloc"` Hex string `json:"hex"` Err string `json:"e,omitempty"` } func rPageInfo(pid uint32) *PageInfo { b, e := readPage(pid) if e != nil { return &PageInfo{ID: pid, Err: "读页失败: " + e.Error()} } pi := &PageInfo{ ID: pid, Type: int(b[0]), TypeS: pageTypeName(int(b[0])), Flags: b[1], Ref: le16(b[PH_REF : PH_REF+2]), Prev: le32(b[PH_PREV : PH_PREV+4]), Next: le32(b[PH_NEXT : PH_NEXT+4]), Use: le32(b[PH_USE : PH_USE+4]), } if pi.Use > PGSZ { pi.Use = PGSZ } pi.Us = float64(pi.Use) / float64(PGSZ) * 100 pi.Hex = fmt.Sprintf("%x", b[:min(len(b), 64)]) w, freeSet := pageOwnership() if w != nil { pi.Role = w.role[pid] pi.Owner = w.owner[pid] } if freeSet != nil { pi.InFree = freeSet[pid] } nf := le32(hdr[12:16]) pi.IsAlloc = nf > 0 && pid <= nf-1 if pi.Role == "" && pi.InFree { pi.Role = "free" } if pi.TypeS == "Free" && pi.Role == "" { pi.Role = "free" } return pi }