package main import ( "crypto/sha1" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "log" "math" "math/big" "net/http" _ "net/http/pprof" // /debug/pprof/* 挂在 DefaultServeMux 上, 供排查内存/CPU "os" "path/filepath" "runtime" "runtime/debug" "strconv" "strings" "sync" "time" "unsafe" // analyzer 的 go.mod 有 replace TsDB2 => ../, 可以直接读 core 的类型表。 // 列型的宽度/名字改为投影自 typeRegistry, 见 fSz / fName 的注释。 "git.ycl.pub/yl32940/TsDB2/DB/core" "git.ycl.pub/yl32940/TsDB2/DB/core/types" ) const ( PGSZ = 16384 HDRSZ = 4096 PHDR = 20 // PageHdr actual size (with alignment) DP_HDR = 48 // DataPage header (DB/core/page/datapage.go DataPageHdrSize): // PageHdr(20) + Count(4) + Min(8) + Max(8) + LiveCnt(4) + Dead(4) = 48 // v10 在 Count/Min/Max 之后新增了 LiveCnt/Dead 用于 O(1) COUNT 与 // 失效占比判断; 漏掉这 8B 会让 eachRow 从 LiveCnt/Dead 上读出 // 假 row header, pkID 全部错位, 进而把整库二级索引条目误判为悬空。 IDX_HDR = 32 // PHDR(20) + pad(1) + IndexType(1) + EntryLen(2) + Count(4) + IsLeaf(1) + pad(3) // 下面两个尺寸与 TI_* / IM_* 偏移与 DB/core 的 IndexMeta/TableInfo 布局一一对应。 // TableNameSize = 48 且 IndexNameSize = 64 时: IndexMeta 100 字节, TableInfo 104 字节。 // 这里的每一个常量都由 layout_assert.go 的编译期断言钉在 DB/core 的真实结构体上, // 改了 DB/core 布局却漏改这里会直接编译失败 (而不是静默按错位读盘)。 META_SZ = 100 // IndexMeta entry size (with Go alignment padding) TINFO_SZ = 104 // TableInfo struct size (with alignment) PG_HEAD = 0 PG_TABLE = 1 PG_DATA = 2 PG_HEAP = 3 PG_IMETA = 4 PG_IDATA = 5 PG_OVER = 6 // DataOverflowPage: 数据页溢出页 (已废弃) PG_WAL = 7 // WALIndex: WAL 日志页 PG_SEXT = 8 // SchemaExtIndex: 全库共享的表结构扩展页 // 分析器自造的哨兵 PG_NEVER = -1 // 页号 >= NextFreePage, 从未被分配过 PG_RESERVED = -2 // 页 1: 引擎预留, 从不分配 (header_page.go newHeader) PG_ORPHAN = -3 // 已下发区间内、类型字节=0 却不在空闲链上 (被遗漏的空闲页/泄漏页) PG_OTHER = -4 // 未知类型字节 // PageHdr 字段偏移 (共 20 字节) PH_REF = 2 // RefCount uint16 (仅堆页维护: 指向本页的活引用数) PH_PREV = 8 // PrePage uint32 PH_NEXT = 12 // NextPage uint32 PH_USE = 16 // UseSize uint32 // 表页在 PageHdr 之后为共享扩展页链头 (SchemaExtPageID) 统一预留 4 字节, // 因此每条表记录的起始偏移是 PHDR + SEXT_HDR (所有表页一致)。 SEXT_HDR = 4 REC_OFF = PHDR + SEXT_HDR // SchemaFragHdr: NextPage(4B BE) + NextOff(2B BE) + DataLen(2B BE) FRAG_HDR = 8 // TableInfo 字段偏移 (共 104 字节, TableNameSize=48) TI_FLAG = 48 // Flag byte (0x80 = 已删除/空洞) TI_HPCNT = 52 // HeapPageCount uint32 (复用 TotalRows 前的对齐填充) TI_ROWS = 56 // TotalRows uint64 TI_FHEAP = 64 // FirstHeap uint32 TI_FDATA = 72 // FirstData uint32 TI_ROWSZ = 80 // RowSize uint16 TI_ALLOC = 82 // FieldAllocateSize uint16 TI_FIDX = 88 // FirstIndexID uint32 TI_EXTPG = 96 // ExtPage uint32 (schema 续块所在扩展页) TI_EXTOFF = 100 // ExtOff uint16 (首个续块在页内的偏移) // IndexMeta 字段偏移 (共 100 字节, IndexNameSize=64) // // 这些偏移全都随 IndexNameSize 整体平移 —— 索引名数组一变长, 其后每个字段的位置 // 都跟着变。名字长度单独列成 IM_NAMSZ, slice 时就不必再写 64 这个魔法数字。 IM_NAMSZ = 64 // IndexName [IndexNameSize]byte 的字节数 (索引名: im[0:64]) IM_TYPE = 64 // IndexType byte IM_FCNT = 65 // FieldCnt byte IM_FLDS = 66 // FieldIDs [16]byte IM_EP = 84 // EntryPage uint32 (跳过 2 字节对齐填充) IM_FLAG = 96 // Flag byte // 索引 Flag 的位 (对应 core 的 IndexFlag*: bit7 软删除 / bit0 唯一索引)。 // 取值由 layout_assert.go 拿 core 的常量编译期钉住 —— 这里手写位掩码是为了 // 不依赖 core 的常量名, 但绝不能和它漂移。 IM_FLAG_DELETED = 0x80 IM_FLAG_UNIQUE = 0x01 // 列 (FieldInfo) Flag 的位, 对应 core/types 的 Flag* 常量: // bit7 = 已删除 / bit6 = 可为 NULL / bit5 = 唯一 // bit4..bit0 = 列级类型参数 (按类型解释: decimal 是 scale, 字符串列是宽度码) // 掩码名沿用 FLAG_SCALE (core 侧叫 FlagScaleMask), 两个用途共用同一格。 FLAG_DELETED = 0x80 FLAG_NULLABLE = 0x40 FLAG_UNIQUE = 0x20 FLAG_SCALE = 0x1f ) // ===== Data Structures ===== type TInfo struct { Name string `json:"name"` RowN uint64 `json:"rowN"` FSz uint16 `json:"fsz"` DP0 uint32 `json:"dp0"` IP0 uint32 `json:"ip0"` HP0 uint32 `json:"hp0"` HPC uint32 `json:"hpc"` // HeapPageCount: 声明式堆链长度 ActiveVer byte `json:"activeVer"` MasterFields []MFInfo `json:"masterFields"` Versions []VInfo `json:"versions"` Idxs []IInfo `json:"idxs"` } type MFInfo struct { Name string `json:"name"` Type byte `json:"type"` TypeName string `json:"typeName"` // Flag 列级标志字节, 随 schema 落盘 (对应 types.FieldInfo.Flag): // // bit7 bit6 bit5 | bit4 ........ bit0 // 删除 可为 NULL 唯一 | 列级类型参数 (0..31) // // 低 5 位按类型解释 (如 decimal:2 → 2, maxlen 声明出来的字符串列 → 宽度码); // 删除标记不再占用 Typ 的最高位。 Flag byte `json:"flag"` // Param 列级类型参数 (Flag 低 5 位): decimal 是 scale, 字符串家族是行内宽度码, // 其余类型为 0。 Param byte `json:"param"` // Nullable / Unique 从 Flag 拆出来的状态位。 // // 位掩码只在这一个地方拆一次, 前端 / 检查逻辑都用这两个布尔 —— 每个消费方 // 各自去 &0x40 / &0x20, 迟早会有人写错位, 而"列可不可空"读错的后果是悄悄 // 把 NULL 当成 0 来解释。 Nullable bool `json:"nullable"` Unique bool `json:"unique"` Sz int `json:"sz"` // Label / Tip 类型徽章的展示文本与悬浮说明, 由 fLabel / fTip 在【后端】算好。 // // 字符串列的 label 会显式带出行内宽度 (string(1024B)): 类型档 (string) 与真实 // 宽度矛盾时, 列表里能一眼看出这列到底占多大, 不必往右看 Size 列。 // 放在后端算与 nullable/unique 同一个理由: Flag 低 5 位是【按类型重载】的一格 // (decimal 是 scale, 字符串家族是宽度码), 前端若能拿到它, 迟早有人当字节数显示 —— // 而"宽度码 20"对应的那列是 1024 字节, 差 50 倍。 Label string `json:"label"` Tip string `json:"tip"` Dropped bool `json:"dropped"` } type VInfo struct { Ver byte `json:"ver"` Fields []string `json:"fields"` // Indices 本版字段在 masterFields 里的下标 (顺序 = 行内字段顺序)。 // // 对外必须是 []int 而不是盘上原样的 []byte: 盘上每版字段数是 1 字节, 解析时直接 // copy 最省事, 但 []byte 过 JSON 会变成 base64 字符串 —— 前端 v.indices[i] 拿到的是 // 第 i 个字符, t.masterFields['A'] 恒为 undefined, "这一版每列多宽"整块静默消失。 // 宽化在解析层一次做完, 前端拿到的就是能直接当下标用的数字。 Indices []int `json:"indices"` } type FInfo struct { Name string `json:"name"` Type byte `json:"type"` Sz int `json:"sz"` } type IInfo struct { Name string `json:"name"` Type byte `json:"type"` TS string `json:"ts"` EP uint32 `json:"ep"` Flds []int `json:"flds"` // Uniq 唯一索引 (IndexMeta.Flag 的 bit0)。索引的 Flag 字节里只有两位: // // bit7 bit6 bit0 // 已删除 (未用) 唯一索引 // // 唯一位不参与一致性校验 (它只约束写入路径, 不影响盘上结构), 但漏掉它会让 // 分析器把唯一索引和普通索引用同一种样子画出来 —— 从外部看, 一张表的约束 // 就完全不可见了。 Uniq bool `json:"uniq"` } type DP struct { ID uint32 `json:"id"` Nx uint32 `json:"nx"` Pv uint32 `json:"pv"` Cn uint32 `json:"cn"` Mx uint64 `json:"mx"` Mn uint64 `json:"mn"` Us float64 `json:"us"` Rs []DR `json:"rs"` } type DR struct { I int `json:"i"` PKID uint64 `json:"pk"` SchemaV byte `json:"sv"` Hex string `json:"h"` Decoded string `json:"decoded"` } type IP struct { ID uint32 `json:"id"` Nx uint32 `json:"nx"` Pv uint32 `json:"pv"` Lf bool `json:"lf"` Cn uint32 `json:"cn"` Us float64 `json:"us"` Ents []IE `json:"ents"` IdxType byte `json:"it"` } type IE struct { K string `json:"k"` P uint32 `json:"p"` DP uint64 `json:"dp"` } type HdrInfo struct { Magic string `json:"magic"` Version uint32 `json:"ver"` PageSize uint32 `json:"pgsz"` NextFree uint32 `json:"nf"` TablePage uint32 `json:"tp"` TableCount uint32 `json:"tc"` CreateTime string `json:"ct"` ModifyTime string `json:"mt"` FreeStart uint32 `json:"fs"` FreeEnd uint32 `json:"fe"` FreeCount uint32 `json:"fc"` GlobalFlags uint64 `json:"gf"` WALFirst uint32 `json:"wal0"` WALLast uint32 `json:"wal1"` TxnID uint64 `json:"txn"` Comment string `json:"cm"` HeaderCrc uint32 `json:"crc"` // 头页声明的整页 XXH32 (旧库为 0) CrcVerdict string `json:"cv"` // 校验结论: OK / CRC 不符 / 跳过 CrcDetail string `json:"cd"` // 校验说明 // 实算值 (与上面的声明值互校) FreeWalked int `json:"fw"` // 实走空闲链长度 Pages int `json:"pg"` // 数据页总数 Allocated int `json:"ap"` // 已分配页数 NeverUsed int `json:"nu"` // 从未分配的尾页 FreeVerdict string `json:"fv"` // 空闲链自检结论 FreeDetail string `json:"fd"` // 自检说明 } type HeapChain struct { Pages []HeapPageInfo `json:"pages"` Total int `json:"total"` } type HeapPageInfo struct { ID uint32 `json:"id"` Next uint32 `json:"nx"` Prev uint32 `json:"pv"` Use uint32 `json:"use"` Us float64 `json:"us"` Ref uint16 `json:"ref"` // 页头 RefCount: 指向本页的活引用数 (仅堆页有意义) } var ( dbF string fp string hdr [HDRSZ]byte tabs []TInfo mu sync.RWMutex ) func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) fmt.Println("╔══════════════════════════════════════════════╗") fmt.Println("║ TsDB2 Analyzer v3.0 (Schema Aware) ║") fmt.Println("║ http://localhost:8081 ║") fmt.Println("╚══════════════════════════════════════════════╝") http.HandleFunc("/", homeH) http.HandleFunc("/api/list-dir", ldH) http.HandleFunc("/api/open-db", openH) http.HandleFunc("/api/stats", stH) http.HandleFunc("/api/tables", taH) http.HandleFunc("/api/table/", tdH) http.HandleFunc("/api/data-page", dpH) http.HandleFunc("/api/index-page", ipH) http.HandleFunc("/api/btree", btH) http.HandleFunc("/api/header", hdrH) http.HandleFunc("/api/heap-chain", heapH) http.HandleFunc("/api/free-pages", freeH) http.HandleFunc("/api/pages", pagesH) http.HandleFunc("/api/pagemap", pagemapH) http.HandleFunc("/api/find-pk", findPkH) http.HandleFunc("/api/audit", auditH) http.HandleFunc("/api/mem", memH) http.HandleFunc("/api/audit/issues", auditIssuesH) http.HandleFunc("/api/audit/unacc", auditUnaccH) http.HandleFunc("/api/heap-chains", heapChainsH) http.HandleFunc("/api/page-info", pageInfoH) // 静态资源 (index.html 引用的 js/css/字体等) 一律走 embed, 不再暴露工作目录。 http.Handle("/s/", http.StripPrefix("/s/", http.FileServer(http.FS(webRoot())))) log.Fatal(http.ListenAndServe(":8081", countInflight(http.DefaultServeMux))) } // countInflight 把每个请求登记为"在飞读任务": 请求期间可能持有指向映射区的 // 页切片, 换库时不能把映射解掉 (见 audit.go 的 gcRetired)。 func countInflight(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { enterRead() defer exitRead() h.ServeHTTP(w, r) }) } // ===== Low-level page reading ===== // rp 读一页 —— 统一走 audit.go 的共享句柄 (全库审计要读上千页, 不能每页都 open)。 func rp(pid uint32) ([]byte, error) { return readPage(pid) } // rHdr 把 4096 字节的 HeaderPage 读进全局 hdr。 func rHdr() { f, e := dbFile() if e != nil { return } f.ReadAt(hdr[:], 0) } // ===== Parse all tables from TablePage chain ===== func pTabs() []TInfo { var R []TInfo tp := binary.LittleEndian.Uint32(hdr[48:52]) // TablePage (v11 起偏移 48) if tp == 0 { return R } for pid := tp; pid != 0; { b, e := rp(pid) if e != nil { break } us := binary.LittleEndian.Uint32(b[PH_USE : PH_USE+4]) if us > PGSZ { us = PGSZ } // 所有表页都在 PageHdr 之后统一预留 SEXT_HDR 字节, 记录从 REC_OFF 开始 off := uint32(REC_OFF) for off+TINFO_SZ <= us && off+TINFO_SZ <= PGSZ { tb := b[off : off+TINFO_SZ] alloc := uint32(binary.LittleEndian.Uint16(tb[TI_ALLOC : TI_ALLOC+2])) if tb[TI_FLAG]&0x80 != 0 { // Drop 留下的空洞 (墓碑): 按记录自身长度跳过 off += TINFO_SZ + alloc continue } nm := strings.TrimRight(string(tb[:48]), "\x00") // 表名: [0, TableNameSize) rn := binary.LittleEndian.Uint64(tb[TI_ROWS : TI_ROWS+8]) hpc := binary.LittleEndian.Uint32(tb[TI_HPCNT : TI_HPCNT+4]) // 重建完整 schema: 内联块 (alloc 字节) + 共享扩展页续块链 schema := readSchemaBytes(b, off, alloc, tb) // Parse new schema format activeVer, masterFields, versions := parseSchemaFields(schema) // Parse indexes var is []IInfo iip := binary.LittleEndian.Uint32(tb[TI_FIDX : TI_FIDX+4]) for ip := iip; ip != 0; { ib, e := rp(ip) if e != nil { break } if ib[0] != PG_IMETA { break } iu := binary.LittleEndian.Uint32(ib[PH_USE : PH_USE+4]) if iu > PGSZ { iu = PGSZ } io := uint32(PHDR) for io+META_SZ <= iu && io+META_SZ <= PGSZ { im := ib[io : io+META_SZ] // IndexMeta struct (Name[64] + Type + FieldCnt + FieldIDs[16] + pad(2) // + EntryPage + Prev + Next + Flag + pad(3) = 100): 各字段偏移见文件头的 IM_* // 常量, 由 layout_assert.go 的编译期断言钉在 DB/core 的真实结构体上。 if im[IM_FLAG]&IM_FLAG_DELETED == 0 { in := strings.TrimRight(string(im[:IM_NAMSZ]), "\x00") // IndexName: [0, IndexNameSize) it := im[IM_TYPE] // IndexType ic := int(im[IM_FCNT]) // FieldCnt ep := binary.LittleEndian.Uint32(im[IM_EP : IM_EP+4]) // EntryPage (跳过 2B 对齐填充) uq := im[IM_FLAG]&IM_FLAG_UNIQUE != 0 // 唯一索引 // 验证 EntryPage 指向有效索引页 (防止已删除表的孤立条目) if ep != 0 { pb, pe := rp(ep) if pe != nil || pb[0] != PG_IDATA { ep = 0 // 无效页, 跳过 } } if ep != 0 { var fds []int for j := 0; j < ic; j++ { fds = append(fds, int(im[IM_FLDS+j])) // FieldIDs } is = append(is, IInfo{Name: in, Type: it, TS: tNm(it), EP: ep, Flds: fds, Uniq: uq}) } } io += META_SZ } ip = binary.LittleEndian.Uint32(ib[PH_NEXT : PH_NEXT+4]) } R = append(R, TInfo{ Name: nm, RowN: rn, FSz: binary.LittleEndian.Uint16(tb[TI_ROWSZ : TI_ROWSZ+2]), DP0: binary.LittleEndian.Uint32(tb[TI_FDATA : TI_FDATA+4]), IP0: binary.LittleEndian.Uint32(tb[TI_FIDX : TI_FIDX+4]), HP0: binary.LittleEndian.Uint32(tb[TI_FHEAP : TI_FHEAP+4]), HPC: hpc, ActiveVer: activeVer, MasterFields: masterFields, Versions: versions, Idxs: is, }) off += TINFO_SZ + alloc } nx := binary.LittleEndian.Uint32(b[PH_NEXT : PH_NEXT+4]) if nx == 0 || nx == pid { break } pid = nx } return R } // readSchemaBytes 重建一张表的完整 schema 字节: // 表页内联块 (alloc 字节) + 共享扩展页里的续块链 [SchemaFragHdr 8B][payload] func readSchemaBytes(pageBuf []byte, recOff, alloc uint32, tb []byte) []byte { schema := make([]byte, 0, alloc) start := recOff + TINFO_SZ if end := start + alloc; end <= uint32(len(pageBuf)) { schema = append(schema, pageBuf[start:end]...) } else if start < uint32(len(pageBuf)) { schema = append(schema, pageBuf[start:]...) } extPage := binary.LittleEndian.Uint32(tb[TI_EXTPG : TI_EXTPG+4]) extOff := uint32(binary.LittleEndian.Uint16(tb[TI_EXTOFF : TI_EXTOFF+2])) for pid, off := extPage, extOff; pid != 0; { fb, e := rp(pid) if e != nil || int(off)+FRAG_HDR > len(fb) { break } nextPage := binary.BigEndian.Uint32(fb[off : off+4]) nextOff := binary.BigEndian.Uint16(fb[off+4 : off+6]) dataLen := int(binary.BigEndian.Uint16(fb[off+6 : off+8])) s := int(off) + FRAG_HDR if s+dataLen > len(fb) { break } schema = append(schema, fb[s:s+dataLen]...) pid, off = nextPage, uint32(nextOff) } return schema } // parseSchemaFields 解析新版 schema 字段定义链 // // 格式 (与 DB/core 的 serializeSchema 逐字节对应): // // [activeVer 1B][masterCount 1B][typ 1B][flag 1B][nameLen 1B][name]×masterCount // [verCount 1B][ver 1B][indexCount 1B][indices]×verCount // // flag 是**列级标志字节**, 紧跟在 typ 之后 (对应 types.FieldInfo.Flag): // // bit7 bit6 bit5 | bit4 ........ bit0 // 删除 可为 NULL 唯一 | 列级类型参数 (0..31) // // 字节位置与原来的 param 完全一致, 只是含义被扩展了: 低 5 位【按类型解释】—— // decimal 是 scale (例如 `12 02 06 "Amount"` 就是 decimal(2)), 字符串家族是行内宽度码 // (maxlen 声明出来的列靠它决定宽度)。其余类型低 5 位为 0, 但字节一定在。 // // 所以下面算 Sz 时必须把 flag 一起传进去 (fSzWithParam): 只看 typ 会给宽度码列算成 // 类型默认档 256, 而它是 2048 就整行错位 —— 与本函数注释开头说的那次事故同一类。 // // 【这里漏掉这个字节曾造成整表读错】: 原实现按 `[typ][nameLen][name]` 走位, 于是把 // 它当成了 nameLen —— 第一个字段名读成空串, 之后 masterFields / versions 全部错位。 // 症状很误导人: 表显示成"只有 1 个字段且无名"、版本号是乱数、数据页一行都解不出, 而 // 页头那些不依赖 schema 的字段 (TotalRows / RowSize / 页号) 却完全正确, 于是很难往 // "schema 解析"上想。TestParseSchemaAgainstCore 用 core 造的**真库**来钉这件事: // 它不重述格式, 而是要求两侧对同一份盘上字节的解释一致。 func parseSchemaFields(b []byte) (byte, []MFInfo, []VInfo) { if len(b) < 2 { return 0, nil, nil } pos := 0 activeVer := b[pos] pos++ masterCount := int(b[pos]) pos++ var masterFields []MFInfo for i := 0; i < masterCount && pos+3 <= len(b); i++ { typ := b[pos] flag := b[pos+1] nameLen := int(b[pos+2]) pos += 3 if pos+nameLen > len(b) { break } name := strings.TrimRight(string(b[pos:pos+nameLen]), "\x00") pos += nameLen // typ 是纯类型值 (删除标记不再占最高位), 状态位全在 flag 里。 // param / sz 各算一次并复用: 宽度是"行内按多少字节付满"的唯一依据, // 下面三处 (Sz / Label / Tip) 必须拿到同一个值, 分头算迟早会漂移。 param := flag & FLAG_SCALE sz := fSzWithParam(typ, param) masterFields = append(masterFields, MFInfo{ Name: name, Type: typ, TypeName: fName(typ), Flag: flag, Sz: sz, Param: param, Label: fLabel(typ, param, sz), Tip: fTip(typ, param, sz), Dropped: flag&FLAG_DELETED != 0, Nullable: flag&FLAG_NULLABLE != 0, Unique: flag&FLAG_UNIQUE != 0, }) } var versions []VInfo if pos < len(b) { verCount := int(b[pos]) pos++ for i := 0; i < verCount && pos < len(b); i++ { verNo := b[pos] pos++ if pos >= len(b) { break } fieldCount := int(b[pos]) pos++ // 下标逐个宽化成 int (见 VInfo.Indices): 盘上 1 字节/个, 但对外要数字数组。 indices := make([]int, fieldCount) if pos+fieldCount <= len(b) { for i, raw := range b[pos : pos+fieldCount] { indices[i] = int(raw) } pos += fieldCount } // Build field name list for this version var fieldNames []string for _, idx := range indices { if idx < len(masterFields) { mf := masterFields[idx] n := mf.Name if mf.Dropped { n += " (dropped)" } fieldNames = append(fieldNames, n) } else { fieldNames = append(fieldNames, "?") } } versions = append(versions, VInfo{Ver: verNo, Fields: fieldNames, Indices: indices}) } } return activeVer, masterFields, versions } // ===== Field type helpers ===== // fSz 列在行内的字节宽度。 // // 【为什么是投影而不是自己 switch】这里原本手写了一张 1..15 的表, core 新增 7 个列型 // (bool/float32/decimal/uuid/year/int128/blob) 时没同步, 于是 fSz 对新类型返回 0 —— // 这不是"显示成 ?"那么轻: 行解码靠它推进偏移 (decodeRows 里的 off += sz), 0 会让新类型 // 字段【之后的所有列】都从同一个错位偏移解码, 整行静默错位; indexcheck 也会因为 // size <= 0 直接放弃索引键校验。 // // 改成投影到 core 的 typeRegistry 之后, 这类漏同步在结构上不可能再发生: core 那边有 // allTypes + CheckRegistry 兜底, 这里自动覆盖全部已声明类型。复用 core 的布局事实是 // 本项目既有做法 (见 layout_assert.go 的编译期断言)。 // // 注意它给的是【类型默认档】宽度; 行内真实宽度还要乘上列级参数 (字符串列的宽度码), // 行解码/索引键切分一律用 fSzWithParam。 func fSz(t byte) int { return int(types.GetSize(types.FieldType(t))) } // fSzWithParam 列在行内的真实字节宽度 = types.FieldSize(类型, 列级参数)。 // // param 取列 flag 的低 5 位 (字符串列是宽度码, decimal 是 scale)。少传它就会给 // "maxlen 声明出来的字符串列"算出类型默认档 256, 而盘上那一列是 1024 字节 —— // off += sz 之后整行字段静默错位, 且错得"很像正常数据" (字段都有值, 只是全错位)。 // 返回 0 表示类型未注册或参数非法: 调用方必须放弃解析该行, 不能当真值用。 func fSzWithParam(t byte, param byte) int { return int(types.FieldSize(types.FieldType(t), param)) } func tNm(t byte) string { switch t { case 1: return "Time" case 2: return "Primary" case 3: return "String" case 4: return "Compose" case 5: return "BTree" case 6: return "Hash" default: return "?" } } // fName 列类型的可读名。同样投影自 core 的 typeRegistry。 // // 名字一律投影自 types 注册表 (uint8/uint16/…/string/dynamic/float64/int8/…/int64), // 因此 core 增删类型时这里自动跟随 —— 不需要再维护第二份会漂移的名单。 func fName(t byte) string { if n := types.TypeNameOf(types.FieldType(t)); n != "" { return n } return "?" } // fLabel 类型徽章的展示文本 (MFInfo.Label)。 // // 【为什么字符串列要改写】定长字符串列的行内宽度不是类型自带的, 而是靠 Flag 低 5 位的 // 【宽度码】声明 (见 types.FieldSize)。于是同一个 String 类型下会出现"真实行宽 1024 字节" // 的列 —— 列表里只写类型名, 看起来就是默认的 256 字节, 与盘上差了 4 倍, 而"字符串大小" // 这件事恰恰是看 schema 时最想知道的。所以宽度码不为 0 的字符串列显示成 string(1024B)。 // // 宽度码为 0 (沿用类型默认宽度 256) 时只显示类型名 string。 // // decimal 显示 scale (与改动前一致); 其余类型就是类型名; 未注册类型给 "?"。 func fLabel(t byte, param byte, sz int) string { name := fName(t) if name == "?" { return unknownFieldValue } ft := types.FieldType(t) switch { case ft == types.FieldType_Decimal: if param > 0 { return fmt.Sprintf("%s(%d)", name, param) } case types.IsStringType(ft) && param > 0: return fmt.Sprintf("string(%dB)", sz) } return name } // fTip 类型徽章的悬浮说明 (MFInfo.Tip): 把"类型档 + 列级参数 + 行内宽度"三件事一次讲清。 // // 徽章上放不下这些, 而它们正是排查行宽问题时要对照的东西 —— 尤其是字符串列: // 盘上只存宽度码, 声明值本身【没有保留】, 因此这里只能给"向上取档后的宽度", // 这一点必须在提示里说破, 否则会有人拿它当声明值对账。 func fTip(t byte, param byte, sz int) string { name := fName(t) if name == "?" { return fmt.Sprintf("未注册的类型 %d: 行内宽度算不出来, 本行无法解码", t) } ft := types.FieldType(t) switch { case types.IsStringType(ft): tip := fmt.Sprintf("定长字符串列: 类型档 %s, 行内宽度 %d 字节", name, sz) if param > 0 { tip += fmt.Sprintf(" (宽度码 %d; 声明容量向上取档后的宽度, 每行按它付满)", param) } return tip case ft == types.FieldType_Decimal: return fmt.Sprintf("定点小数: scale=%d, 行内宽度 %d 字节", param, sz) case sz <= 0: return fmt.Sprintf("类型 %s: 行内宽度算不出来", name) } return fmt.Sprintf("%s: 行内宽度 %d 字节", name, sz) } // ===== Read DataPage with schema version awareness ===== // rDP reads a DataPage (sequential row layout, no directory) func rDP(pid uint32) *DP { b, e := rp(pid) if e != nil { return nil } if b[0] != PG_DATA { return nil } cn := binary.LittleEndian.Uint32(b[PHDR : PHDR+4]) mn := binary.BigEndian.Uint64(b[PHDR+4 : PHDR+12]) mx := binary.BigEndian.Uint64(b[PHDR+12 : PHDR+20]) us := float64(binary.LittleEndian.Uint32(b[16:20])) / float64(PGSZ) * 100 var rs []DR off := DP_HDR for i := uint32(0); i < cn && off+4 <= len(b); i++ { // 长度字段最高位 (0x80000000) 为软删除标志, 必须屏蔽后再取实际长度 rawLen := binary.LittleEndian.Uint32(b[off : off+4]) rl := int(rawLen &^ 0x80000000) deleted := rawLen&0x80000000 != 0 off += 4 if off+rl > len(b) || rl <= 0 { break } storeRow := b[off : off+rl] off += rl if deleted { continue } // storeRow = [pkID(8B BE)] + rowData var pkID uint64 var rowData []byte if len(storeRow) >= 8 { pkID = binary.BigEndian.Uint64(storeRow[:8]) rowData = storeRow[8:] } else { rowData = storeRow } schemaV := byte(0) if len(rowData) > 0 { schemaV = rowData[0] } hx := fmt.Sprintf("%x", storeRow[:min(len(storeRow), 64)]) if len(storeRow) > 64 { hx += "..." } decoded := decodeRowData(rowData, schemaV) rs = append(rs, DR{int(i), pkID, schemaV, hx, decoded}) } return &DP{pid, binary.LittleEndian.Uint32(b[PH_NEXT : PH_NEXT+4]), binary.LittleEndian.Uint32(b[PH_PREV : PH_PREV+4]), cn, mx, mn, us, rs} } // decodeRowData 尝试解码行数据 (显示 schemaVer + 各字段值) // // 行布局 v9: [schemaVer 1B][列0 W0 B][列结束偏移表 2N B][列1..N-1 内容][NULL 位图 ⌈n/8⌉B] // (位图长度按该行自己的版本算, n = 该版本 Indices 长度)。两处必须照做, 否则整行错位: // - 列位置一律由【行内偏移表】给出: 列 0 恒占 W0 字节 (档位宽度, 不足补零), 偏移表基址 // = 1+W0, 表项 0 = 变长内容区起点 (= 1+W0+2N), 表项 i(i≥1) = 列 i 结束偏移, 列 i(i≥1) // 起点 = 表项 i-1, 位图起点 = 表末项。v9 字符串列按实际长度存储, 再沿用"累加档位宽度" // 定位会越算越偏 (表现为整行错列)。 // - 列 0 恒占 W0 字节也让时序键固定在绝对偏移 9 (读取侧写死的偏移, W0 == 8 时成立)。 // - 已删除列在行内【仍占位】(DropField 不新增版本, 该列不会从 Indices 消失), 所以判定 // Dropped 只能跳过"显示", 绝不能影响列定位 —— 偏移表项照样按 i 取。 func decodeRowData(rowData []byte, schemaV byte) string { if len(rowData) < 1 { return "" } parts := []string{fmt.Sprintf("v%d", schemaV)} if len(rowData) < 2 { return strings.Join(parts, " ") } for _, t := range tabs { sv := findVersion(t, schemaV) if sv == nil { continue } rowN := len(sv.Indices) if rowN == 0 { break } if sv.Indices[0] >= len(t.MasterFields) || t.MasterFields[sv.Indices[0]].Sz <= 0 { break } tblBase := 1 + t.MasterFields[sv.Indices[0]].Sz // 偏移表基址 == 列 0 结束偏移 header := tblBase + 2*rowN // 变长内容区起点 == 表项 0 if header > len(rowData) { break } if int(binary.BigEndian.Uint16(rowData[tblBase:tblBase+2])) != header { break // 表项 0 不是变长内容区起点: 布局不可信, 不猜 } bitmapLen := (rowN + 7) / 8 prev := header bad := false for i := 0; i < rowN; i++ { off := int(binary.BigEndian.Uint16(rowData[tblBase+2*i:tblBase+2*i+2])) & rowVarOffMask if off < prev || off > len(rowData) { bad = true break } prev = off } if bad || prev+bitmapLen > len(rowData) { break } bitmap := rowData[prev : prev+bitmapLen] for i, idx := range sv.Indices { if idx >= len(t.MasterFields) { break } mf := t.MasterFields[idx] start := 1 if i > 0 { start = int(binary.BigEndian.Uint16(rowData[tblBase+2*(i-1):tblBase+2*i])) & rowVarOffMask } end := tblBase // 列 0 结束恒为 1+W0 == 表基址 if i > 0 { end = int(binary.BigEndian.Uint16(rowData[tblBase+2*i:tblBase+2*i+2])) & rowVarOffMask } if end < start || end > len(rowData) { break } if !mf.Dropped { // 已删除列仍占位, 只是不显示 if bitmap[i>>3]&(1< len(b) { break } entry := b[o : o+el] var ie IE switch it { case 2: ie.K = fmt.Sprintf("%d", binary.BigEndian.Uint64(entry[:8])) ie.P = binary.BigEndian.Uint32(entry[8:12]) case 1: ie.K = fmt.Sprintf("%d ~ %d", binary.BigEndian.Uint64(entry[:8]), binary.BigEndian.Uint64(entry[8:16])) ie.P = binary.BigEndian.Uint32(entry[16:20]) case 3: ie.K = strings.TrimRight(string(entry[:16]), "\x00") ie.DP = binary.BigEndian.Uint64(entry[16:24]) if lf { ie.P = binary.BigEndian.Uint32(entry[20:24]) } case 4: nk := (el - 8) / 8 parts := make([]string, nk) for j := 0; j < nk; j++ { parts[j] = fmt.Sprintf("%d", binary.BigEndian.Uint64(entry[j*8:])) } ie.K = strings.Join(parts, ", ") ie.DP = binary.BigEndian.Uint64(entry[nk*8:]) if lf { ie.P = binary.BigEndian.Uint32(entry[el-4:]) } case 5: if lf { kl := el - 8 if kl == 8 { ie.K = fmt.Sprintf("%d", binary.BigEndian.Uint64(entry[:8])) } else { ie.K = fmt.Sprintf("%x", entry[:kl]) } ie.DP = binary.BigEndian.Uint64(entry[kl:]) ie.P = binary.BigEndian.Uint32(entry[el-4:]) } else { kl := el - 4 if kl == 8 { ie.K = fmt.Sprintf("%d", binary.BigEndian.Uint64(entry[:8])) } else { ie.K = fmt.Sprintf("%x", entry[:kl]) } ie.P = binary.BigEndian.Uint32(entry[el-4:]) } default: ie.K = fmt.Sprintf("%x", entry[:el-4]) ie.P = binary.BigEndian.Uint32(entry[el-4:]) } ents = append(ents, ie) } return ents } func rBT(pid uint32, it byte) *IP { return rIP(pid, it) } // ===== Header + Heap + Free ===== func rHdrInfo() *HdrInfo { f, e := dbFile() if e != nil { return nil } b := make([]byte, HDRSZ) f.ReadAt(b, 0) // v11 起 HeaderPage 重排 (标量 8 字节对齐、零填充; 布局见 DB/core/header_page.go): // 0 Magic | 4 Version | 8 PageSize | 12 NextFreePage // 16 CreateTime | 24 ModifyTime | 32 NextTxnID | 40 GlobalFlags // 48 TablePage | 52 TableCount | 56 FreePageStart | 60 FreePageEnd // 64 FreePageCount | 68 FirstWALPage | 72 LastWALPage // 76 ExtConfigHdr[8] | 84 HeaderCrc | 88 ExtConfig[244] // 332 Comment[3764] (占满整页余下空间) magic := string(b[:4]) ver := binary.LittleEndian.Uint32(b[4:8]) pgsz := binary.LittleEndian.Uint32(b[8:12]) nf := binary.LittleEndian.Uint32(b[12:16]) ct := int64(binary.LittleEndian.Uint64(b[16:24])) mt := int64(binary.LittleEndian.Uint64(b[24:32])) txn := binary.LittleEndian.Uint64(b[32:40]) gf := binary.LittleEndian.Uint64(b[40:48]) tp := binary.LittleEndian.Uint32(b[48:52]) tc := binary.LittleEndian.Uint32(b[52:56]) fs := binary.LittleEndian.Uint32(b[56:60]) fe := binary.LittleEndian.Uint32(b[HDR_FREE_END : HDR_FREE_END+4]) fc := binary.LittleEndian.Uint32(b[64:68]) wal0 := binary.LittleEndian.Uint32(b[68:72]) wal1 := binary.LittleEndian.Uint32(b[72:76]) cm := strings.TrimRight(string(b[332:4096]), "\x00") crc := le32(b[HDR_CRC : HDR_CRC+4]) crcV, crcD := checkHeaderCrc(b, ver) fcW := rFreeChain() total := totalPages() h := &HdrInfo{ Magic: magic, Version: ver, PageSize: pgsz, NextFree: nf, TablePage: tp, TableCount: tc, CreateTime: time.Unix(0, ct).Format("2006-01-02 15:04:05"), ModifyTime: time.Unix(0, mt).Format("2006-01-02 15:04:05"), FreeStart: fs, FreeEnd: fe, FreeCount: fc, GlobalFlags: gf, WALFirst: wal0, WALLast: wal1, TxnID: txn, Comment: cm, HeaderCrc: crc, CrcVerdict: crcV, CrcDetail: crcD, FreeWalked: fcW.Total, Pages: total, FreeVerdict: fcW.Verdict, FreeDetail: fcW.Detail, } if nf > 0 { h.Allocated = int(nf) - 1 } if h.Allocated > total { h.Allocated = total } if h.NeverUsed = total - h.Allocated; h.NeverUsed < 0 { h.NeverUsed = 0 } return h } func rHeapChain(hp0 uint32) *HeapChain { var pages []HeapPageInfo for pid := hp0; pid != 0; { b, e := rp(pid) if e != nil || b[0] != PG_HEAP { break } nx := binary.LittleEndian.Uint32(b[PH_NEXT : PH_NEXT+4]) pv := binary.LittleEndian.Uint32(b[PH_PREV : PH_PREV+4]) use := binary.LittleEndian.Uint32(b[PH_USE : PH_USE+4]) us := float64(use) / float64(PGSZ) * 100 ref := binary.LittleEndian.Uint16(b[PH_REF : PH_REF+2]) pages = append(pages, HeapPageInfo{pid, nx, pv, use, us, ref}) if nx == pid || nx == 0 { break } pid = nx } return &HeapChain{pages, len(pages)} } // rFreePages 空闲链 —— 直接用审计里那份带自检的版本。 // 旧实现读的是偏移 0 的大端值, 而空闲链指针其实写在页头 NextPage (偏移 12); // 偏移 0 是 Type(空闲页=0) 那一格, 恒等于 0, 所以链永远只走出第 1 页。 func rFreePages() *FreeChainInfo { return rFreeChain() } // ===== Page visualization ===== type PageItem struct { ID uint32 `json:"id"` Type int `json:"t"` TS string `json:"ts"` Us float64 `json:"us"` } type PagesResult struct { Pages []PageItem `json:"pages"` Total int `json:"total"` Counts map[string]int `json:"counts"` Allocated int `json:"allocated"` NeverUsed int `json:"neverUsed"` Role map[string]string `json:"role"` Owner map[string]string `json:"owner"` } // rPages 页映射的**一个分片** —— 类型/使用率取自缓存的紧凑索引 (pagemap.go), // 角色/属主只为这一段页查询。 // // 旧实现每次调用都要全量重算 19.5 万页, 并把整张页表 + 两张 19.5 万键的归属 // map 一次性序列化 (单次响应几十 MB), 且被概览/堆链/空闲页三个视图各调一遍 —— // 前端必然卡死。新的页图走 /api/pagemap 分块拉取, 这里只保留分页语义。 func rPages(start, count int) *PagesResult { idx := pagemapCached() res := &PagesResult{ Total: idx.total, Counts: idx.counts, Allocated: idx.allocated, NeverUsed: idx.neverUsed, Role: map[string]string{}, Owner: map[string]string{}, } if idx.total <= 0 { return res } if start < 1 { start = 1 } if count <= 0 || count > pmChunkMax { count = pmChunkDefault } if start > idx.total { start = idx.total + 1 } end := start + count if end > idx.total+1 { end = idx.total + 1 } w := peekWalk() for pid := start; pid < end; pid++ { pt, tn := pmKind(int(idx.typ[pid])) res.Pages = append(res.Pages, PageItem{uint32(pid), pt, tn, float64(idx.usg[pid])}) if w == nil { continue } k := strconv.Itoa(pid) if v, ok := w.role[uint32(pid)]; ok { res.Role[k] = v } if v, ok := w.owner[uint32(pid)]; ok { res.Owner[k] = v } } return res } // pagesH 分页返回页映射: ?start=<首页号>&count=<页数> (默认第一块)。 func pagesH(w http.ResponseWriter, r *http.Request) { if fp == "" { jw(w, map[string]any{"e": "no db"}) return } start, _ := strconv.Atoi(r.URL.Query().Get("start")) count, _ := strconv.Atoi(r.URL.Query().Get("count")) jw(w, rPages(start, count)) } // findPkH 通过主键 B+ 树查找 PKID 对应的 DataPage func findPkH(w http.ResponseWriter, r *http.Request) { if fp == "" { jw(w, map[string]any{"e": "no db"}) return } pkStr := r.URL.Query().Get("pk") rootStr := r.URL.Query().Get("root") var pk, root uint32 fmt.Sscanf(pkStr, "%d", &pk) fmt.Sscanf(rootStr, "%d", &root) if root == 0 { jw(w, map[string]any{"e": "no root"}) return } dp := findDPByPK(root, uint64(pk)) if dp == 0 { jw(w, map[string]any{"e": "not found"}) return } jw(w, map[string]any{"dp": dp, "pk": pk}) } // findDPByPK 在主键 B+ 树中查找 PKID 对应的 DataPage ID func findDPByPK(rootPID uint32, targetPK uint64) uint32 { cur := rootPID for { b, e := rp(cur) if e != nil || b[0] != PG_IDATA { return 0 } lf := b[28] == 1 cn := int(binary.LittleEndian.Uint32(b[24:28])) el := int(binary.LittleEndian.Uint16(b[22:24])) if el <= 4 { el = 12 } if lf { // 叶子节点: 查找匹配的 PK for i := 0; i < cn; i++ { o := IDX_HDR + i*el if o+el > len(b) { break } key := binary.BigEndian.Uint64(b[o : o+8]) if key == targetPK { return binary.BigEndian.Uint32(b[o+8 : o+12]) } } return 0 } // 内部节点: 二分查找最后一个 key <= targetPK lo, hi := 0, cn-1 pos := 0 for lo <= hi { mid := (lo + hi) / 2 o := IDX_HDR + mid*el if o+8 > len(b) { break } key := binary.BigEndian.Uint64(b[o : o+8]) if key <= targetPK { pos = mid lo = mid + 1 } else { hi = mid - 1 } } o := IDX_HDR + pos*el childPID := binary.BigEndian.Uint32(b[o+el-4 : o+el]) if childPID == 0 { return 0 } cur = childPID } } // ===== Utility ===== func min(a, b int) int { if a < b { return a } return b } func fB(n int64) string { if n < 1024 { return fmt.Sprintf("%d B", n) } if n < 1048576 { return fmt.Sprintf("%.1f KB", float64(n)/1024) } if n < 1073741824 { return fmt.Sprintf("%.2f MB", float64(n)/1048576) } return fmt.Sprintf("%.2f GB", float64(n)/1073741824) } func fE(p string) bool { _, e := os.Stat(p); return e == nil } func jw(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } // ===== HTTP Handlers ===== func ldH(w http.ResponseWriter, r *http.Request) { p := r.URL.Query().Get("path") if p == "" { p, _ = os.Getwd() } if !filepath.IsAbs(p) { c, _ := os.Getwd() p = filepath.Join(c, p) } es, e := os.ReadDir(p) if e != nil { jw(w, map[string]any{"err": e.Error()}) return } type F struct { N string `json:"n"` D bool `json:"d"` S int64 `json:"s"` } var ds, fs []F for _, x := range es { i, _ := x.Info() s := int64(0) if i != nil { s = i.Size() } if x.IsDir() { ds = append(ds, F{x.Name(), true, s}) } else { fs = append(fs, F{x.Name(), false, s}) } } jw(w, map[string]any{"p": p, "pp": filepath.Dir(p), "d": ds, "f": fs}) } func openH(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "no", 405) return } var q struct { P string `json:"p"` } json.NewDecoder(r.Body).Decode(&q) if !fE(q.P) { c, _ := os.Getwd() q2 := filepath.Join(c, q.P) if fE(q2) { q.P = q2 } else { jw(w, map[string]any{"ok": false, "e": "not found"}) 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()}) return } invalidateAudit() mu.Lock() fp = q.P rHdr() tabs = pTabs() mu.Unlock() fi, _ := os.Stat(fp) dbF = fp setCacheFP(fingerprint()) // 全库审计可能很慢 (要扫上千页), 放到后台预热, 让打开请求立即返回。 // 前端先显示基础信息与"审计中", 就绪后轮询 /api/stats 填充汇总。 startAuditAsync() // 页映射索引同样在后台建好 (排在审计之后), 免得用户点开概览时再等一次全库扫描。 startPagemapAsync() 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) if fi != nil { s = fi.Size() } m := map[string]any{"f": dbF, "s": s, "fs": fB(s), "pg": totalPages(), "tc": len(tabs)} // 概览页要用到审计汇总 (空闲页数 / 堆页数 / 行数 / 总判决 / 孤儿页)。 // 这里只读已算好的缓存, 不触发计算: 未就绪时返回 auditReady=false, // 由前端显示"审计中"并轮询本接口, 避免打开库时长时间卡死。 // // 增量审计: 结构结果先就绪, 索引内容一致性随后补全。总判决里含索引内容, // 所以只有索引内容也补全了 (IdxPending=false) 才算真正就绪。 if a := peekAudit(); a != nil && !a.IdxPending { heap, rows := 0, uint64(0) for _, t := range a.Tables { heap += t.HeapActual rows += t.Rows } m["free"] = a.Free.Total m["heap"] = heap m["rows"] = rows m["verdict"] = a.Verdict m["unacc"] = a.Reach.Unaccounted m["reserved"] = a.Reserved m["never"] = a.NeverUsed m["auditReady"] = true } else { m["auditReady"] = false if failed, msg := peekAuditError(); failed { m["auditFailed"] = true m["auditError"] = msg } } return m } func stH(w http.ResponseWriter, r *http.Request) { if dbF == "" { jw(w, map[string]any{"e": "no db"}) return } fi, _ := os.Stat(fp) jw(w, st(fi)) } // memH 报告 Go 运行时内存与映射区大小, 用来回答"内存到底花在哪"。 // ?gc=1 强制一次 GC 并归还内存, 便于区分"活对象"与"待回收/未归还"。 func memH(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("gc") == "1" { runtime.GC() debug.FreeOSMemory() } var m runtime.MemStats runtime.ReadMemStats(&m) // 库文件大小: 现在按页 ReadAt 读取, 页留在系统文件缓存里 (可回收), // 不再整文件映射进进程地址空间, 因此这里不再有 mappedDBSZ 这一项。 var fileMB int64 if fi, e := os.Stat(fp); e == nil { fileMB = fi.Size() >> 20 } jw(w, map[string]any{ "heapAllocMB": m.HeapAlloc >> 20, // 活对象 "heapInuseMB": m.HeapInuse >> 20, // 已用 span "heapIdleMB": m.HeapIdle >> 20, // 空闲 span (可归还) "heapSysMB": m.HeapSys >> 20, // 向 OS 要的堆 "stackMB": m.StackInuse >> 20, // goroutine 栈 "otherSysMB": m.OtherSys >> 20, "sysMB": m.Sys >> 20, // Go 全部向 OS 要的内存 "dbFileMB": fileMB, // 库文件大小 (按页 ReadAt, 不进进程工作集) "numGC": m.NumGC, "goroutines": runtime.NumGoroutine(), }) } func taH(w http.ResponseWriter, r *http.Request) { jw(w, tabs) } func tdH(w http.ResponseWriter, r *http.Request) { n := r.URL.Path[len("/api/table/"):] for _, t := range tabs { if t.Name == n { jw(w, t) return } } jw(w, map[string]any{"e": "not found"}) } func dpH(w http.ResponseWriter, r *http.Request) { var pid uint32 fmt.Sscanf(r.URL.Query().Get("id"), "%d", &pid) d := rDP(pid) if d == nil { jw(w, map[string]any{"e": "err"}) return } jw(w, d) } func ipH(w http.ResponseWriter, r *http.Request) { var pid uint32 fmt.Sscanf(r.URL.Query().Get("id"), "%d", &pid) it, _ := strconv.Atoi(r.URL.Query().Get("t")) d := rIP(pid, byte(it)) if d == nil { jw(w, map[string]any{"e": "err"}) return } jw(w, d) } func btH(w http.ResponseWriter, r *http.Request) { var pid uint32 fmt.Sscanf(r.URL.Query().Get("id"), "%d", &pid) it, _ := strconv.Atoi(r.URL.Query().Get("t")) d := rBT(pid, byte(it)) if d == nil { jw(w, map[string]any{"e": "err"}) return } jw(w, d) } func hdrH(w http.ResponseWriter, r *http.Request) { d := rHdrInfo() if d == nil { jw(w, map[string]any{"e": "err"}) return } jw(w, d) } func heapH(w http.ResponseWriter, r *http.Request) { var hp0 uint32 fmt.Sscanf(r.URL.Query().Get("id"), "%d", &hp0) jw(w, rHeapChain(hp0)) } func freeH(w http.ResponseWriter, r *http.Request) { jw(w, rFreePages()) } // ===== 审计 / 归属 / 单页检查 ===== // auditH 全库审计: 页账 + 空闲链自检 + 每表堆链交叉校验 + 未归属页。 func auditH(w http.ResponseWriter, r *http.Request) { if dbF == "" { jw(w, map[string]any{"e": "no db"}) return } jw(w, auditCached()) } // pageArgs 解析分页参数: offset 默认 0, limit 默认 100 (上限 500)。 func pageArgs(r *http.Request) (int, int) { off, _ := strconv.Atoi(r.URL.Query().Get("offset")) lim, _ := strconv.Atoi(r.URL.Query().Get("limit")) if off < 0 { off = 0 } if lim <= 0 || lim > 500 { lim = 100 } return off, lim } // auditIssuesH 分页取索引问题明细 —— 从落盘缓存库里按页取, 不整份读进内存。 func auditIssuesH(w http.ResponseWriter, r *http.Request) { off, lim := pageArgs(r) rows, total, err := cachePageIssues(off, lim) if err != nil { jw(w, map[string]any{"e": err.Error()}) return } jw(w, map[string]any{"rows": rows, "total": total, "offset": off, "limit": lim}) } // auditUnaccH 分页取孤儿页明细 —— 同上, 从缓存库按页取。 func auditUnaccH(w http.ResponseWriter, r *http.Request) { off, lim := pageArgs(r) rows, total, err := cachePageUnacc(off, lim) if err != nil { jw(w, map[string]any{"e": err.Error()}) return } jw(w, map[string]any{"rows": rows, "total": total, "offset": off, "limit": lim}) } // heapChainsH 所有表的堆链汇总 (声明值 / 实走值 / 引用数), 供"堆链总览"页。 func heapChainsH(w http.ResponseWriter, r *http.Request) { if dbF == "" { jw(w, map[string]any{"e": "no db"}) return } a := auditCached() jw(w, map[string]any{"tables": a.Tables, "free": a.Free, "reach": a.Reach, "verdict": a.Verdict}) } // pageInfoH 单页检查: 原始页头 + 归属 + 是否空闲 / 从未分配。 func pageInfoH(w http.ResponseWriter, r *http.Request) { if dbF == "" { jw(w, map[string]any{"e": "no db"}) return } var pid uint32 fmt.Sscanf(r.URL.Query().Get("id"), "%d", &pid) if pid == 0 { jw(w, map[string]any{"e": "bad id"}) return } jw(w, rPageInfo(pid)) } var _ = unsafe.Sizeof(0)