audit.go 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419
  1. package main
  2. // ============================================================================
  3. // 页级审计 —— 回答"每个已分配的页现在归谁"。
  4. //
  5. // 这里所有枚举规则都逐条对照引擎自己的实现写死, 不做任何推测:
  6. // 空闲链 next header_page.go recyclePage / recycleChain (页头 NextPage, 偏移 12)
  7. // 堆链 / 数据链 table_page.go heapAllocBlock / DataPage 链 (偏移 12)
  8. // schema 扩展链 schema_ext.go appendPage (偏移 12)
  9. // 索引页 btree.CollectAllPageIDs / 各 handler 的 CollectPageIDs
  10. // blob 链 (v10) blob_read.go BlobFree 整链回收的同款走链 (Type=9, NextPage 串联,
  11. // 根 = 行内 8B BlobRef, 见 blobwalk.go)
  12. //
  13. // 这样 analyzer 报出的"不可达页"才能和引擎 DropTable 时会漏收的页是同一批,
  14. // 否则这工具只是好看, 不能当证据用。
  15. // ============================================================================
  16. import (
  17. "cmp"
  18. "encoding/binary"
  19. "fmt"
  20. "log"
  21. "os"
  22. "runtime/debug"
  23. "slices"
  24. "strings"
  25. "sync"
  26. "sync/atomic"
  27. "git.ycl.pub/yl32940/TsDB2/DB/core"
  28. )
  29. // ---- HeaderPage 额外字段 ----
  30. // v11 起头页重排: FreePageStart(56) 与 FreePageEnd(60) 紧邻, 尾指针不再排在 Comment 之后。
  31. const HDR_FREE_END = 60
  32. // HDR_CRC HeaderCrc 字段偏移 (84): 整页 XXH32, 覆盖除该字段自身外的 4096 字节。
  33. // 与 core/header_page.go 的编译期断言互相独立地钉住同一布局 —— 任一侧改了都编译失败。
  34. const HDR_CRC = 84
  35. // checkHeaderCrc 校验头页整页 XXH32。返回 (结论, 说明):
  36. // - v11 且 CRC==0: 旧库无校验, ("跳过", "旧 v11 库, 首次写头页自动生成 CRC")
  37. // - 匹配: ("OK", "整页 XXH32 与 HeaderCrc 一致")
  38. // - 失配: ("CRC 不符", "期望/实际值 —— 头页写坏或文件非 VRTX")
  39. //
  40. // b 必须是完整的头页字节 (>= 4096); 计算在副本上进行, 不改动调用方数据。
  41. // 算法直接复用 core.XXH32, 与引擎写入侧 (PrepareWrite) 同源, 不存在两套实现漂移。
  42. func checkHeaderCrc(b []byte, version uint32) (string, string) {
  43. stored := le32(b[HDR_CRC : HDR_CRC+4])
  44. if version == 11 && stored == 0 {
  45. return "跳过", "旧 v11 库 (HeaderCrc=0 为 ExtConfig 预留区), 首次写头页自动生成 CRC"
  46. }
  47. page := make([]byte, 4096)
  48. copy(page, b[:4096])
  49. for i := HDR_CRC; i < HDR_CRC+4; i++ {
  50. page[i] = 0 // CRC 覆盖范围: 除自身外的整页
  51. }
  52. if got := core.XXH32(page); got != stored {
  53. return "CRC 不符", fmt.Sprintf("HeaderCrc=%08x, 实算=%08x —— 头页损坏或文件非 VRTX", stored, got)
  54. }
  55. return "OK", "整页 XXH32 与 HeaderCrc 一致"
  56. }
  57. // ---- IndexHead (索引页头) 字段偏移 ----
  58. const (
  59. IX_TYP = 20 // IndexType
  60. IX_ELEN = 22 // EntryLen
  61. IX_CNT = 24 // Count
  62. IX_LEAF = 28 // IsLeaf
  63. )
  64. // ---- Hash 索引元数据 (IndexHead 之后) ----
  65. const (
  66. HM_MAGIC = 32 // uint32 HashMagic
  67. HM_TOTAL = 44 // 条目总数
  68. HM_DIR = 48 // 桶目录链头
  69. HM_DIRPG = 52 // 目录页数
  70. HM_KEYLEN = 56 // 二级键长度 (hash_handler: hashMetaFixedOff+24)
  71. HM_DIRTAIL = 60 // 目录链尾
  72. HASH_MAGIC = 0x54484833
  73. HASH_SLOT = 12 // 每个目录槽 [head][tail][count]
  74. // 桶页: [PageHdr(20)][BucketID(4)][Count(4)] 之后就是条目 —— 按 IndexHead 解析会把
  75. // BucketID / NextPage 当成 EntryLen / IsLeaf, 所以单独给一个头长。
  76. HASH_BUCKET_HDR = 28
  77. )
  78. // 索引类型 (page/base_page.go)
  79. const (
  80. IT_TIME = 1
  81. IT_PRIMARY = 2
  82. IT_STR = 3
  83. IT_COMPOSE = 4
  84. IT_BTREE = 5
  85. IT_HASH = 6
  86. )
  87. // isRowStore 叶子条目最后 4 字节 = DataPageID 的索引 (即 index.RowStoreHandler 的实现者)
  88. func isRowStore(it byte) bool { return it == IT_TIME || it == IT_PRIMARY }
  89. // isBTreeIdx 走 B+ 树页格式的索引类型 (TablePage.isBTreePageIndex)。
  90. // Compose 的处理器底层即 SBT 二级 B+ 树, 页格式与 BTree 相同。
  91. func isBTreeIdx(it byte) bool { return it == IT_PRIMARY || it == IT_BTREE || it == IT_STR || it == IT_COMPOSE }
  92. func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) }
  93. func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) }
  94. func be32(b []byte) uint32 { return binary.BigEndian.Uint32(b) }
  95. func pageTypeName(t int) string {
  96. switch t {
  97. case PG_HEAD:
  98. return "Free"
  99. case PG_TABLE:
  100. return "Table"
  101. case PG_DATA:
  102. return "Data"
  103. case PG_HEAP:
  104. return "Heap"
  105. case PG_IMETA:
  106. return "IdxMeta"
  107. case PG_IDATA:
  108. return "IdxData"
  109. case PG_OVER:
  110. return "DataOverflow(deprecated)"
  111. case PG_WAL:
  112. return "WAL"
  113. case PG_SEXT:
  114. return "SchemaExt"
  115. case 9:
  116. return "BlobIndex"
  117. default:
  118. return fmt.Sprintf("?%d", t)
  119. }
  120. }
  121. // ===== 共享文件句柄 =====
  122. // 全库审计要读十几万页。这里刻意**不做整文件映射**: 把整个库映射进地址空间后,
  123. // 扫过的页会全部变成进程常驻页 —— bench_10m (2.7G) 光映射就吃掉 2.7G 工作集,
  124. // 库再大一点等于把整库读进内存, 正是要避免的事。
  125. //
  126. // 改成按页 ReadAt: 页落在系统文件缓存里 (可回收, 不计进程工作集), 进程自己只留
  127. // 几只 16KB 的复用缓冲。逐页扫的热循环用 pageBuf 复用自己的缓冲, 避免"每页一次
  128. // 16KB 分配"在上千万次读里制造 GB 级垃圾把 GC 拖垮; 冷路径走 readPage。
  129. var (
  130. fileMu sync.RWMutex
  131. dbf *os.File
  132. // 换库时不能立刻关掉旧句柄: 可能还有读操作正拿着它的数据。
  133. // 旧句柄先进 retired, 等"在飞任务数"归零后再关 (见 gcRetired)。
  134. retiredMu sync.Mutex
  135. retired []*os.File
  136. inflight atomic.Int64 // 在飞的长读任务数: HTTP 请求 + 后台审计/建索引
  137. )
  138. // pgOff 第 pid 页在文件中的偏移: 头部固定 4096 字节, 之后每页 16KB。
  139. func pgOff(pid uint32) int64 { return int64(HDRSZ) + int64(pid-1)*int64(PGSZ) }
  140. // gcRetired 关闭在飞的旧句柄。只有在没有任何读任务在跑时才是安全的,
  141. // 否则留着等下一次机会 (换库时不常有, 泄漏一个句柄无所谓, 崩掉不行)。
  142. func gcRetired() {
  143. if inflight.Load() != 0 {
  144. return
  145. }
  146. retiredMu.Lock()
  147. old := retired
  148. retired = nil
  149. retiredMu.Unlock()
  150. for _, f := range old {
  151. if f != nil {
  152. f.Close()
  153. }
  154. }
  155. }
  156. // useDB 切换当前库文件并接管句柄。
  157. func useDB(p string) error {
  158. f, e := os.Open(p)
  159. if e != nil {
  160. return e
  161. }
  162. fileMu.Lock()
  163. old := dbf
  164. dbf = f
  165. fileMu.Unlock()
  166. if old != nil {
  167. retiredMu.Lock()
  168. retired = append(retired, old)
  169. retiredMu.Unlock()
  170. }
  171. gcRetired()
  172. return nil
  173. }
  174. // dbFile 取当前句柄 (未打开则惰性打开)。
  175. func dbFile() (*os.File, error) {
  176. fileMu.RLock()
  177. f := dbf
  178. fileMu.RUnlock()
  179. if f != nil {
  180. return f, nil
  181. }
  182. fileMu.Lock()
  183. defer fileMu.Unlock()
  184. if dbf != nil {
  185. return dbf, nil
  186. }
  187. if fp == "" {
  188. return nil, os.ErrNotExist
  189. }
  190. f, e := os.Open(fp)
  191. if e != nil {
  192. return nil, e
  193. }
  194. dbf = f
  195. return f, nil
  196. }
  197. // readPage 读第 pid 页 (页号从 1 起), 每次新分配一块 16KB 缓冲。
  198. // 逐页顺序扫的热循环请改用 pageBuf 复用缓冲, 否则千万次分配会把 GC 拖垮。
  199. func readPage(pid uint32) ([]byte, error) {
  200. b := make([]byte, PGSZ)
  201. return readPageInto(pid, b)
  202. }
  203. // readPageInto 把第 pid 页读进 buf 并返回 buf[:PGSZ]。buf 长度须 >= PGSZ。
  204. // 返回的切片就是 buf —— 用同一只 buf 读下一页会覆盖它, 调用方不得留存。
  205. func readPageInto(pid uint32, buf []byte) ([]byte, error) {
  206. if pid == 0 {
  207. return nil, os.ErrInvalid
  208. }
  209. f, e := dbFile()
  210. if e != nil {
  211. return nil, e
  212. }
  213. b := buf[:PGSZ]
  214. if _, e := f.ReadAt(b, pgOff(pid)); e != nil {
  215. return nil, e
  216. }
  217. return b, nil
  218. }
  219. // pageBuf 一只可复用的页缓冲 (16KB)。逐页扫盘的热循环自己持有一只,
  220. // 避免"每页一次 16KB 分配"在百万次读里制造 GB 级垃圾。
  221. type pageBuf struct{ b []byte }
  222. func newPageBuf() *pageBuf { return &pageBuf{b: make([]byte, PGSZ)} }
  223. // page 读第 pid 页到本缓冲并返回。返回的切片随下次调用失效, 不得留存。
  224. func (p *pageBuf) page(pid uint32) ([]byte, error) { return readPageInto(pid, p.b) }
  225. // enterRead / exitRead 把一段长读工作登记为在飞任务, 使换库时不会
  226. // 把仍在使用的旧文件句柄提前关掉 (见 gcRetired)。
  227. func enterRead() { inflight.Add(1) }
  228. func exitRead() { inflight.Add(-1); gcRetired() }
  229. // totalPages 数据页总数。
  230. // 文件 = 4096 字节头 + N×16KB, 所以 N = (size-4096)/16KB。
  231. // 注意旧实现用的是 size/PGSZ-1, 会把最后一页整页漏掉。
  232. func totalPages() int {
  233. fi, e := os.Stat(fp)
  234. if e != nil {
  235. return 0
  236. }
  237. n := int((fi.Size() - HDRSZ) / PGSZ)
  238. if n < 0 {
  239. return 0
  240. }
  241. return n
  242. }
  243. // ===== 空闲链 =====
  244. type FreeChainInfo struct {
  245. Pages []uint32 `json:"pages"`
  246. Total int `json:"total"`
  247. Head uint32 `json:"head"`
  248. Tail uint32 `json:"tail"`
  249. Declared uint32 `json:"declared"` // header.FreePageCount
  250. DeclEnd uint32 `json:"declEnd"` // header.FreePageEnd
  251. BadPtr int `json:"badPtr"`
  252. Bytes int64 `json:"bytes"`
  253. Verdict string `json:"verdict"`
  254. Detail string `json:"detail"`
  255. }
  256. // rFreeChain 实走空闲链, 并与 header 里的三个声明值 (start/count/end) 互校。
  257. // 空闲链指针写在页头 NextPage (偏移 12): header_page.go recyclePage 注释明确
  258. // "不再占用偏移 0 的 Type/RefCount 字段"。读偏移 0 会得到 0, 链在第 1 页即断。
  259. func rFreeChain() *FreeChainInfo {
  260. fi := &FreeChainInfo{
  261. Head: le32(hdr[56:60]), // FreePageStart (v11 起偏移 56)
  262. DeclEnd: le32(hdr[HDR_FREE_END : HDR_FREE_END+4]),
  263. Declared: le32(hdr[64:68]), // FreePageCount (v11 起偏移 64)
  264. }
  265. total := totalPages()
  266. seen := make(map[uint32]bool)
  267. pb := newPageBuf()
  268. for pid := fi.Head; pid != 0; {
  269. if int(pid) > total {
  270. fi.BadPtr++ // 越界: 指到文件外
  271. break
  272. }
  273. if seen[pid] {
  274. fi.BadPtr++ // 成环
  275. break
  276. }
  277. seen[pid] = true
  278. fi.Pages = append(fi.Pages, pid)
  279. b, e := pb.page(pid)
  280. if e != nil {
  281. fi.BadPtr++
  282. break
  283. }
  284. pid = le32(b[PH_NEXT : PH_NEXT+4])
  285. }
  286. fi.Total = len(fi.Pages)
  287. if fi.Total > 0 {
  288. fi.Tail = fi.Pages[fi.Total-1]
  289. }
  290. fi.Bytes = int64(fi.Total) * PGSZ
  291. switch {
  292. case fi.BadPtr > 0:
  293. fi.Verdict, fi.Detail = "损坏", "空闲链出现成环或越界指针"
  294. case uint32(fi.Total) != fi.Declared:
  295. fi.Verdict, fi.Detail = "计数不符", "实走链长与 header.FreePageCount 不一致"
  296. case fi.DeclEnd != 0 && fi.Tail != fi.DeclEnd:
  297. fi.Verdict, fi.Detail = "尾指针不符", "实走链尾与 header.FreePageEnd 不一致"
  298. case fi.Head == 0 && fi.DeclEnd != 0:
  299. fi.Verdict, fi.Detail = "头尾不符", "空闲链为空却仍存在尾指针"
  300. case fi.Head != 0 && fi.DeclEnd == 0:
  301. // v11 起 FreePageEnd 紧挨 FreePageStart, 引擎的所有写路径 (recyclePage /
  302. // recycleChain / newPageWithReader) 都维护这对指针同生同灭 —— 链非空而尾指针
  303. // 为 0 违反该不变量, 只可能是头页写坏或引擎漏写尾指针 (旧库已被版本闸门拒绝,
  304. // 不存在"字段引入前生成的库"这种情况)。
  305. fi.Verdict, fi.Detail = "尾指针不符", "空闲链非空但 header.FreePageEnd=0 —— 违反头尾同生同灭不变量 (v11 起引擎全路径维护尾指针)"
  306. default:
  307. fi.Verdict, fi.Detail = "OK", "链长 / 尾指针 / 头尾三者自洽"
  308. }
  309. return fi
  310. }
  311. // ===== 页归属遍历 =====
  312. // 光有"这页归谁"还不够 —— 单靠 role/owner 表看不出"一页被两个主人同时认领",
  313. // 因为第二次认领会被静默丢弃。这里把重复认领、非法指针都收集下来, 供完整性交叉校验
  314. // (对照 SQLite 的 PRAGMA integrity_check / PG 的 amcheck 思路)。
  315. type claim struct{ role, owner string }
  316. // CrossLink 一页被两个不同主人认领 (已剔除"同一张表内部的正常 fan-in")。
  317. type CrossLink struct {
  318. Page uint32 `json:"page"`
  319. Type string `json:"type"`
  320. Claims []string `json:"claims"` // "role@owner"
  321. }
  322. // BadRef 链 / 索引里出现非法页指针: 指向文件外, 或指向引擎保留页 1。
  323. type BadRef struct {
  324. From uint32 `json:"from"` // 指向它的页 (0=直接来自 header 根)
  325. To uint32 `json:"to"`
  326. Role string `json:"role"`
  327. Owner string `json:"owner"`
  328. Kind string `json:"kind"`
  329. }
  330. type walker struct {
  331. role map[uint32]string
  332. owner map[uint32]string
  333. multi map[uint32][]claim
  334. badRefs []BadRef
  335. total int
  336. pb *pageBuf // 走链时的复用页缓冲 (避免千万次 16KB 分配制造垃圾)
  337. }
  338. func newWalker() *walker {
  339. return &walker{
  340. role: map[uint32]string{}, owner: map[uint32]string{}, multi: map[uint32][]claim{},
  341. pb: newPageBuf(),
  342. }
  343. }
  344. // ptrOK 校验一个待跳转页号: 0 是链终止符; 1 是引擎保留页; 合法目标只能是 2 .. total。
  345. // 非法就记一条 BadRef —— 链条里的坏指针必须报出来, 不能吞掉。
  346. func (w *walker) ptrOK(from, to uint32, role, owner string) bool {
  347. if to >= 2 && int(to) <= w.total {
  348. return true
  349. }
  350. kind := "out-of-range"
  351. if to == 1 {
  352. kind = "reserved-page"
  353. }
  354. w.badRefs = append(w.badRefs, BadRef{From: from, To: to, Role: role, Owner: owner, Kind: kind})
  355. return false
  356. }
  357. // take 认领一页; 已被认领返回 false (成环 / 交叉引用), 并把重复认领记入 multi。
  358. //
  359. // multi 里按 (role, owner) 去重: 行存储索引的每个条目都会引用自己那行的数据页,
  360. // 一页的数据会被同一个索引叶反复认领 —— 千万行下这曾是 1000 万条 claim (约 400MB),
  361. // 而交叉引用判定只关心"有哪些不同的主人", 重复的同一主人没有意义。
  362. func (w *walker) take(pid uint32, role, owner string) bool {
  363. if pid == 0 {
  364. return false
  365. }
  366. r0, ok := w.role[pid]
  367. if !ok {
  368. w.role[pid] = role
  369. w.owner[pid] = owner
  370. return true
  371. }
  372. if r0 == role && w.owner[pid] == owner {
  373. return false // 同一主人重复认领同一页
  374. }
  375. cs := w.multi[pid]
  376. if cs == nil {
  377. cs = []claim{{r0, w.owner[pid]}}
  378. }
  379. for _, c := range cs {
  380. if c.role == role && c.owner == owner {
  381. w.multi[pid] = cs
  382. return false // 该主人之前已记过 (distinct 主人数不变)
  383. }
  384. }
  385. w.multi[pid] = append(cs, claim{role, owner})
  386. return false
  387. }
  388. // chain 沿页头 NextPage 走链并整链认领。
  389. func (w *walker) chain(start uint32, role, owner string) int {
  390. n := 0
  391. from := uint32(0)
  392. seen := map[uint32]bool{}
  393. for pid := start; pid != 0; {
  394. if !w.ptrOK(from, pid, role, owner) {
  395. break
  396. }
  397. if seen[pid] {
  398. // 同一条链里又见回同一页 —— 链内成环 (区别于跨链交叉引用)。
  399. w.badRefs = append(w.badRefs, BadRef{From: from, To: pid, Role: role, Owner: owner, Kind: "cycle"})
  400. break
  401. }
  402. if !w.take(pid, role, owner) {
  403. break
  404. }
  405. seen[pid] = true
  406. n++
  407. b, e := w.pb.page(pid)
  408. if e != nil {
  409. w.badRefs = append(w.badRefs, BadRef{From: pid, To: pid, Role: role, Owner: owner, Kind: "read-error"})
  410. break
  411. }
  412. from = pid
  413. pid = le32(b[PH_NEXT : PH_NEXT+4])
  414. }
  415. return n
  416. }
  417. // idxDataPages 收下一张行存储索引叶页上所有条目引用的 DataPageID。
  418. // src = 当前页号, 用于定位越界引用来自哪一页; it = 索引类型, 决定取引用字段的偏移。
  419. //
  420. // 【不要再用"末 4 字节"】v8 起主键叶条目是 [key(8)][dpID(4)][slot(2)] = 14B,
  421. // dpID 固定在偏移 8..12 (见 indexcheck.go:rowStoreRefOff)。若按末 4 字节读,
  422. // 得到的是 [dpID 低 2 字节][slot 2 字节]: 实测健康的 emp.pk_emp 会被读成
  423. // 0x000C0001 = 786433, 一个远超文件页数的"页号" → analyzer 满屏 not-data-page /
  424. // unaccounted 并把好库判成 CORRUPT。引用偏移一律由 rowStoreRefOff 给出。
  425. func (w *walker) idxDataPages(b []byte, src uint32, it byte, owner string) int {
  426. if b[0] != PG_IDATA {
  427. return 0
  428. }
  429. cn := int(le32(b[IX_CNT : IX_CNT+4]))
  430. el := int(le16(b[IX_ELEN : IX_ELEN+2]))
  431. if el <= 4 {
  432. el = 12
  433. }
  434. refOff := rowStoreRefOff(it, el)
  435. if refOff < 0 || refOff+4 > el {
  436. return 0
  437. }
  438. n := 0
  439. for i := 0; i < cn; i++ {
  440. o := IDX_HDR + i*el
  441. if o+el > len(b) {
  442. break
  443. }
  444. if p := be32(b[o+refOff : o+refOff+4]); p != 0 {
  445. if !w.ptrOK(src, p, "data", owner) {
  446. continue
  447. }
  448. if w.take(p, "data", owner) {
  449. n++
  450. }
  451. }
  452. }
  453. return n
  454. }
  455. // ownerTable 取 owner 的表名部分 (owner 形如 "表名" 或 "表名.索引名"; 括号开头的是引擎保留结构)。
  456. func ownerTable(o string) string {
  457. if strings.HasPrefix(o, "(") {
  458. return o
  459. }
  460. if k := strings.IndexByte(o, '.'); k >= 0 {
  461. return o[:k]
  462. }
  463. return o
  464. }
  465. // pageTypeByte 读某页的类型字节 (用于把交叉引用页标注类型)。
  466. func pageTypeByte(pid uint32) byte {
  467. if b, e := readPage(pid); e == nil && len(b) > 0 {
  468. return b[0]
  469. }
  470. return 255
  471. }
  472. // indexPages 枚举一个索引占用的全部页, 返回 (索引页数, 它引用的 DataPage 数)。
  473. // 对照: btree.CollectAllPageIDs / TimeIndexHandler.CollectPageIDs /
  474. // ComposeChainHandler.CollectPageIDs / HashHandler.CollectPageIDs。
  475. func (w *walker) indexPages(it byte, entry uint32, owner string) (n, dp int) {
  476. if entry == 0 {
  477. return 0, 0
  478. }
  479. // 索引根页来自表结构, 也可能被写坏 —— 先做一次指针合法性校验。
  480. if !w.ptrOK(0, entry, "index-root", owner) {
  481. return 0, 0
  482. }
  483. switch {
  484. case it == IT_HASH:
  485. pb := newPageBuf()
  486. meta, e := pb.page(entry)
  487. if e != nil || le32(meta[HM_MAGIC:HM_MAGIC+4]) != HASH_MAGIC {
  488. return 0, 0 // magic 对不上就不猜 —— 与引擎"宁可泄漏也不乱收"一致
  489. }
  490. w.take(entry, "index-meta", owner)
  491. n++
  492. limit := PHDR + ((PGSZ-PHDR)/HASH_SLOT)*HASH_SLOT
  493. dir, from := le32(meta[HM_DIR:HM_DIR+4]), entry
  494. for dir != 0 {
  495. if !w.ptrOK(from, dir, "index-dir", owner) {
  496. break
  497. }
  498. if !w.take(dir, "index-dir", owner) {
  499. break
  500. }
  501. n++
  502. b, e := pb.page(dir)
  503. if e != nil {
  504. break
  505. }
  506. for o := PHDR; o+HASH_SLOT <= limit; o += HASH_SLOT {
  507. head := le32(b[o : o+4])
  508. tail := le32(b[o+4 : o+8])
  509. cnt := le32(b[o+8 : o+12])
  510. if head == 0 && tail == 0 && cnt == 0 {
  511. continue
  512. }
  513. if head != 0 {
  514. n += w.chain(head, "index-bucket", owner)
  515. }
  516. }
  517. from = dir
  518. dir = le32(b[PH_NEXT : PH_NEXT+4])
  519. }
  520. return n, 0
  521. case isBTreeIdx(it):
  522. // B+ 树: 内部条目的末 4B 是子页号 (主键 [key(8)][childPID(4)] 与二级
  523. // [key(keyLen)][childPID(4)] 都成立); 叶子条目里, 只有行存储索引 (主键/时序)
  524. // 才携带 DataPage 引用, 且偏移随类型不同 —— 必须走 rowStoreRefOff, 不能一律取末 4B。
  525. pb := newPageBuf()
  526. queue := []uint32{entry}
  527. seen := map[uint32]bool{}
  528. for len(queue) > 0 {
  529. pid := queue[0]
  530. queue = queue[1:]
  531. if seen[pid] {
  532. continue
  533. }
  534. seen[pid] = true
  535. if !w.take(pid, "index-node", owner) {
  536. continue
  537. }
  538. n++
  539. b, e := pb.page(pid)
  540. if e != nil || b[0] != PG_IDATA {
  541. continue
  542. }
  543. if b[IX_LEAF] == 1 {
  544. if isRowStore(it) {
  545. dp += w.idxDataPages(b, pid, it, owner)
  546. }
  547. // 叶子同层链: GetDataPageIDs 就是顺着它扫完所有叶子的
  548. from := pid
  549. for nx := le32(b[PH_NEXT : PH_NEXT+4]); nx != 0; {
  550. if !w.ptrOK(from, nx, "index-node", owner) {
  551. break
  552. }
  553. if !w.take(nx, "index-node", owner) {
  554. break
  555. }
  556. n++
  557. nb, e := pb.page(nx)
  558. if e != nil {
  559. break
  560. }
  561. if isRowStore(it) {
  562. dp += w.idxDataPages(nb, nx, it, owner)
  563. }
  564. from = nx
  565. nx = le32(nb[PH_NEXT : PH_NEXT+4])
  566. }
  567. continue
  568. }
  569. cn := int(le32(b[IX_CNT : IX_CNT+4]))
  570. el := int(le16(b[IX_ELEN : IX_ELEN+2]))
  571. if el <= 4 {
  572. el = 12
  573. }
  574. for i := 0; i < cn; i++ {
  575. o := IDX_HDR + i*el
  576. if o+el > len(b) {
  577. break
  578. }
  579. child := be32(b[o+el-4 : o+el])
  580. if child == 0 {
  581. continue
  582. }
  583. if w.ptrOK(pid, child, "index-node", owner) {
  584. queue = append(queue, child)
  585. }
  586. }
  587. }
  588. return n, dp
  589. default:
  590. // 一维条目链 (Time), 由 PageHdr.NextPage 串起。
  591. // (Compose 的处理器底层已是 SBT B+ 树, 走 isBTreeIdx 分支。)
  592. pb := newPageBuf()
  593. from := uint32(0)
  594. for pid := entry; pid != 0; {
  595. if !w.ptrOK(from, pid, "index-chain", owner) {
  596. break
  597. }
  598. if !w.take(pid, "index-chain", owner) {
  599. break
  600. }
  601. n++
  602. b, e := pb.page(pid)
  603. if e != nil || b[0] != PG_IDATA {
  604. break
  605. }
  606. if isRowStore(it) {
  607. dp += w.idxDataPages(b, pid, it, owner)
  608. }
  609. from = pid
  610. pid = le32(b[PH_NEXT : PH_NEXT+4])
  611. }
  612. return n, 0
  613. }
  614. }
  615. // ===== 审计结果 =====
  616. type TableAudit struct {
  617. Name string `json:"name"`
  618. Rows uint64 `json:"rows"`
  619. DataFirst uint32 `json:"dp0"`
  620. DataPages int `json:"dataPages"`
  621. HeapFirst uint32 `json:"hp0"`
  622. HeapDeclared uint32 `json:"hpc"`
  623. HeapActual int `json:"heapActual"`
  624. HeapRefs uint64 `json:"heapRefs"`
  625. HeapLive int `json:"heapLive"` // RefCount > 0 的堆页
  626. HeapEmpty int `json:"heapEmpty"` // RefCount == 0 的堆页 (可整页回收)
  627. HeapBytes int64 `json:"heapBytes"`
  628. IdxPages int `json:"idxPages"`
  629. IdxDPs int `json:"idxDps"`
  630. BlobChains int `json:"blobChains"` // 行内 BlobRef 走出的链数 (v10)
  631. BlobPages int `json:"blobPages"` // 这些链占的页数 (Type=9)
  632. Verdict string `json:"verdict"`
  633. Detail string `json:"detail"`
  634. }
  635. type ReachResult struct {
  636. TablePages int `json:"tablePages"`
  637. Data int `json:"data"`
  638. Heap int `json:"heap"`
  639. IdxMeta int `json:"idxMeta"`
  640. IdxNode int `json:"idxNode"`
  641. IdxDir int `json:"idxDir"`
  642. IdxBucket int `json:"idxBucket"`
  643. IdxChain int `json:"idxChain"`
  644. SchemaExt int `json:"schemaExt"`
  645. Wal int `json:"wal"` // 未恢复的 WAL 链页数
  646. Free int `json:"free"`
  647. NeverUsed int `json:"neverUsed"`
  648. Reserved int `json:"reserved"`
  649. Reachable int `json:"reachable"`
  650. Unaccounted int `json:"unaccounted"`
  651. UnaccountedIDs []uint32 `json:"unaccountedIds"`
  652. ByRole map[string]int `json:"byRole"`
  653. // UnaccountedByType: 孤儿页按页类型分类 (Free/WAL/Data/IdxData/...), 便于区分成因。
  654. UnaccountedByType map[string]int `json:"unaccountedByType"`
  655. // WalLeftover: 其中类型为 WAL 的页数 —— 历史已提交事务的 WAL 链页未被回收 (空间泄漏)。
  656. WalLeftover int `json:"walLeftover"`
  657. Truncated bool `json:"truncated"`
  658. }
  659. type AuditResult struct {
  660. File string `json:"file"`
  661. Bytes int64 `json:"bytes"`
  662. TotalPages int `json:"totalPages"`
  663. FirstFree uint32 `json:"firstFree"` // NextFreePage
  664. Allocated int `json:"allocated"` // 已下发的页 (2 .. NextFreePage-1)
  665. Reserved int `json:"reserved"` // 页 1: 引擎预留, 从不分配
  666. NeverUsed int `json:"neverUsed"` // 从未分配过的尾页
  667. Free *FreeChainInfo `json:"free"`
  668. HeaderCrc string `json:"hdrCrc"` // 头页整页 XXH32 校验结论 (OK/CRC 不符/跳过)
  669. HeaderCrcD string `json:"hdrCrcDetail"` // 校验说明 (期望/实算值等)
  670. Tables []TableAudit `json:"tables"`
  671. SumRows uint64 `json:"sumRows"`
  672. SumHeapDecl uint64 `json:"sumHeapDecl"`
  673. SumHeapAct int `json:"sumHeapAct"`
  674. SumHeapRefs uint64 `json:"sumHeapRefs"`
  675. SumHeapBytes int64 `json:"sumHeapBytes"`
  676. Reach *ReachResult `json:"reach"`
  677. Verdict string `json:"verdict"`
  678. Notes []string `json:"notes"`
  679. // 完整性交叉校验 (对照 SQLite integrity_check / PG amcheck)
  680. MultiRef int `json:"multiRef"` // 交叉引用页总数
  681. CrossLinks []CrossLink `json:"crossLinks"` // 一页被两个不同主人认领
  682. FreeLive []uint32 `json:"freeLive"` // 既在空闲链、又被引用
  683. BadRefCount int `json:"badRefCount"` // 非法指针总数
  684. BadRefs []BadRef `json:"badRefs"` // 非法指针明细 (截断)
  685. // 索引内容一致性 (对照 PRAGMA integrity_check / amcheck)
  686. IdxEntries int `json:"idxEntries"` // 扫描的索引条目总数
  687. IdxBadOrder int `json:"idxBadOrder"` // 键序回退
  688. IdxOrphanPK int `json:"idxOrphanPK"` // 二级索引指向不存在的行 (索引项 -> 行)
  689. IdxNotDataPage int `json:"idxNotDataPage"` // 行存储索引指向非数据页
  690. IdxMissingRow int `json:"idxMissingRow"` // 存活行没进主键索引 (行 -> 索引项)
  691. IdxMissingEntry int `json:"idxMissingEntry"`// 存活行没进某个二级索引 (行 -> 索引项)
  692. IdxIssueTrunc bool `json:"idxIssueTrunc"` // 明细已截断
  693. IdxSkipped string `json:"idxSkipped"` // 跳过原因 (空=已执行)
  694. IdxNotes []string `json:"idxNotes"` // 逐表跳过说明 (如行存储是时序索引)
  695. IdxIssues []IndexIssue `json:"idxIssues"` // 明细 (截断)
  696. IdxStats []IndexStat `json:"idxStats"` // 每个索引的统计
  697. // IdxPending: 结构审计已发布, 索引内容一致性还在后台增量补全。
  698. // 前端据此轮询 /api/audit, 就绪后 (转 false) 重绘 (见 computeAudit / ensureIndexContent)。
  699. IdxPending bool `json:"idxPending"`
  700. // structNotes: 结构阶段产生、与索引内容无关的说明 (WAL 等)。finalizeVerdict 据此
  701. // 重建 Notes —— 这样"结构判决"与"补全索引后的最终判决"两次计算不会重复追加说明。
  702. structNotes []string
  703. }
  704. // runAuditStructural 做"结构审计": 页账 + 空闲链自检 + 每表堆链交叉校验 + 未归属页 +
  705. // 交叉引用 / 非法指针校验。返回 (结果, 页归属遍历器, 空闲页集合, 表快照, 是否还需补全索引内容)。
  706. //
  707. // 索引内容一致性要在千万级条目上逐条比对, 是整个审计最慢的一段 (大表上占比 >90%),
  708. // 所以刻意不放在这里做: 先发布结构结果, 让概览 / 页图 / 孤儿页立刻可用, 索引内容再由
  709. // ensureIndexContent 在后台增量补全 (见 computeAudit)。
  710. func runAuditStructural() (*AuditResult, *walker, map[uint32]bool, []TInfo, bool) {
  711. mu.RLock()
  712. ts := tabs
  713. mu.RUnlock()
  714. R := &AuditResult{File: fp, Reach: &ReachResult{ByRole: map[string]int{}, UnaccountedByType: map[string]int{}}}
  715. if fi, e := os.Stat(fp); e == nil {
  716. R.Bytes = fi.Size()
  717. }
  718. R.TotalPages = totalPages()
  719. R.Free = rFreeChain()
  720. // 头页整页 XXH32 校验 (core.PrepareWrite 写入侧的同源复核)
  721. R.HeaderCrc, R.HeaderCrcD = checkHeaderCrc(hdr[:], le32(hdr[4:8]))
  722. freeSet := make(map[uint32]bool, R.Free.Total)
  723. for _, p := range R.Free.Pages {
  724. freeSet[p] = true
  725. }
  726. // 页号约定 (header_page.go newHeader): 0=header, 1=预留(表页), 分配从 2 开始。
  727. // 所以"已下发"= 页 2..NextFreePage-1, 页 1 是从不分配的保留页。
  728. nf := le32(hdr[12:16])
  729. R.FirstFree = nf
  730. if nf > 2 {
  731. R.Allocated = int(nf) - 2
  732. }
  733. if R.Allocated > R.TotalPages {
  734. R.Allocated = R.TotalPages
  735. }
  736. if R.TotalPages >= 1 {
  737. R.Reserved = 1
  738. }
  739. if R.NeverUsed = R.TotalPages - R.Allocated - R.Reserved; R.NeverUsed < 0 {
  740. R.NeverUsed = 0
  741. }
  742. w := newWalker()
  743. w.total = R.TotalPages
  744. if R.Reserved == 1 {
  745. w.take(1, "reserved", "(reserved page)")
  746. }
  747. // 1) 表页链 (TablePage 偏移 48)
  748. R.Reach.TablePages = w.chain(le32(hdr[48:52]), "table-page", "(table page)")
  749. // 2) schema 扩展页链: 全库共用一条, 链头存在"表页第 1 页"的 PageHdr 之后。
  750. // 这个大端存盘 —— 引擎 TablePage.saveSchemaExtHead / Init 都走 binary.BigEndian
  751. // (与 SchemaFragHdr 同一约定), 所以这里必须用 be32。
  752. // 用 le32 会把页号 0x0002A755(173909) 读成 0x55A70200(1437008384), 把一个合法页
  753. // 误报成"指向文件外的非法页指针"。
  754. if tp0 := le32(hdr[48:52]); tp0 != 0 {
  755. if b, e := readPage(tp0); e == nil && len(b) >= PHDR+4 {
  756. R.Reach.SchemaExt = w.chain(be32(b[PHDR:PHDR+4]), "schema-ext", "(shared)")
  757. }
  758. }
  759. // 2.5) 未恢复的 WAL 链: FirstWALPage != 0 说明上次没干净关闭, 盘上还留着未重放的
  760. // WAL 页。WAL 页用 PageHdr.NextPage 正向串联 (wal.go 注释明确)。这些页是"活的"
  761. // 可达根, 不能算孤儿; 但本工具只读文件、不跑恢复, 所以单独标注。
  762. // (v11 起 FirstWALPage 偏移 68。)
  763. if w0 := le32(hdr[68:72]); w0 != 0 {
  764. R.Reach.Wal = w.chain(w0, "wal-pending", "(WAL)")
  765. R.structNotes = append(R.structNotes, fmt.Sprintf(
  766. "检测到未恢复的 WAL 链 (FirstWALPage=%d, %d 页): 上次未干净关闭, 或分析的是崩溃现场。"+
  767. "本工具为只读审计, 不会重放 WAL —— 页归属可能不完整。", w0, R.Reach.Wal))
  768. }
  769. // 3) 每张表: 数据链 / 堆链 / 索引
  770. hpBuf := newPageBuf() // 堆链逐页读的复用缓冲 (堆页可能有百万级)
  771. for i := range ts {
  772. t := &ts[i]
  773. ta := TableAudit{
  774. Name: t.Name, Rows: t.RowN, DataFirst: t.DP0,
  775. HeapFirst: t.HP0, HeapDeclared: t.HPC,
  776. }
  777. ta.DataPages = w.chain(t.DP0, "data", t.Name)
  778. hpFrom := uint32(0)
  779. hpSeen := map[uint32]bool{}
  780. for pid := t.HP0; pid != 0; {
  781. if !w.ptrOK(hpFrom, pid, "heap", t.Name) {
  782. break
  783. }
  784. if hpSeen[pid] {
  785. w.badRefs = append(w.badRefs, BadRef{From: hpFrom, To: pid, Role: "heap", Owner: t.Name, Kind: "cycle"})
  786. break
  787. }
  788. if !w.take(pid, "heap", t.Name) {
  789. break
  790. }
  791. hpSeen[pid] = true
  792. b, e := hpBuf.page(pid)
  793. if e != nil {
  794. break
  795. }
  796. ta.HeapActual++
  797. rc := uint64(le16(b[PH_REF : PH_REF+2]))
  798. ta.HeapRefs += rc
  799. if rc > 0 {
  800. ta.HeapLive++
  801. } else {
  802. ta.HeapEmpty++
  803. }
  804. ta.HeapBytes += int64(le32(b[PH_USE : PH_USE+4]))
  805. hpFrom = pid
  806. pid = le32(b[PH_NEXT : PH_NEXT+4])
  807. }
  808. w.chain(t.IP0, "index-meta", t.Name)
  809. for _, idx := range t.Idxs {
  810. n, dp := w.indexPages(idx.Type, idx.EP, t.Name+"."+idx.Name)
  811. ta.IdxPages += n
  812. ta.IdxDPs += dp
  813. }
  814. // 2.8) v10 blob 链: 行内 8B BlobRef 指向的 Type=9 链页是行的外部持有页,
  815. // 与堆块同类。不走这条链, 带 blob 数据的库会被整链误报成孤儿页。
  816. ta.BlobChains, ta.BlobPages = walkTableBlobChains(w, t)
  817. switch {
  818. case ta.HeapActual == 0 && ta.HeapDeclared == 0:
  819. ta.Verdict, ta.Detail = "OK", "无堆页"
  820. case ta.HeapDeclared == 0:
  821. ta.Verdict = "STALE"
  822. ta.Detail = "HeapPageCount=0 但链上有页: 该表建于计数器引入之前, 引擎退化为逐页回收, 数据安全"
  823. case int(ta.HeapDeclared) == ta.HeapActual:
  824. ta.Verdict, ta.Detail = "OK", "声明值与实走链长一致"
  825. default:
  826. ta.Verdict = "MISMATCH"
  827. ta.Detail = "HeapPageCount 与实走链长不一致 (计数器失真: 偏高=页泄漏, 偏低=页被提前回收)"
  828. }
  829. R.SumRows += ta.Rows
  830. R.SumHeapDecl += uint64(ta.HeapDeclared)
  831. R.SumHeapAct += ta.HeapActual
  832. R.SumHeapRefs += ta.HeapRefs
  833. R.SumHeapBytes += ta.HeapBytes
  834. R.Tables = append(R.Tables, ta)
  835. }
  836. // 4) 可达性汇总
  837. for _, role := range w.role {
  838. R.Reach.ByRole[role]++
  839. }
  840. R.Reach.Reachable = len(w.role)
  841. R.Reach.Data = R.Reach.ByRole["data"]
  842. R.Reach.Heap = R.Reach.ByRole["heap"]
  843. R.Reach.IdxMeta = R.Reach.ByRole["index-meta"]
  844. R.Reach.IdxNode = R.Reach.ByRole["index-node"]
  845. R.Reach.IdxDir = R.Reach.ByRole["index-dir"]
  846. R.Reach.IdxBucket = R.Reach.ByRole["index-bucket"]
  847. R.Reach.IdxChain = R.Reach.ByRole["index-chain"] + R.Reach.ByRole["index-compose-chain"]
  848. R.Reach.Free = R.Free.Total
  849. R.Reach.NeverUsed = R.NeverUsed
  850. R.Reach.Reserved = R.Reserved
  851. // 5) 未归属页: 已下发区间 (2 .. NextFreePage-1) 内, 既不可达也不在空闲链上 —— 与
  852. // DropTable 会漏收的页同源。页 1 是保留页, 不计入。
  853. pb := newPageBuf() // 孤儿页逐页读头判类型的复用缓冲
  854. for i := 2; i <= R.TotalPages; i++ {
  855. pid := uint32(i)
  856. if int(pid) >= int(nf) {
  857. continue // 从未分配过
  858. }
  859. if _, ok := w.role[pid]; ok {
  860. continue
  861. }
  862. if freeSet[pid] {
  863. continue
  864. }
  865. R.Reach.Unaccounted++
  866. // 读页头区分成因: 类型字节=7(PG_WAL) 的孤儿页 = 已提交 WAL 链页未被回收。
  867. if buf, err := pb.page(pid); err == nil && len(buf) > 0 {
  868. t := int(buf[0])
  869. R.Reach.UnaccountedByType[pageTypeName(t)]++
  870. if t == PG_WAL {
  871. R.Reach.WalLeftover++
  872. }
  873. }
  874. if len(R.Reach.UnaccountedIDs) < 300 {
  875. R.Reach.UnaccountedIDs = append(R.Reach.UnaccountedIDs, pid)
  876. } else {
  877. R.Reach.Truncated = true
  878. }
  879. }
  880. // 5.5) 完整性交叉校验 —— 与 SQLite integrity_check / PG amcheck 同类:
  881. // (a) 交叉引用: 一页被两个不同主人认领 (同表内部的正常 fan-in 除外)
  882. // (b) 空闲页被引用: 既在空闲链、又被结构引用 (下次分配即被覆写)
  883. // (c) 非法指针: 指向文件外 / 保留页 1
  884. for pid, cs := range w.multi {
  885. if len(cs) < 2 {
  886. continue
  887. }
  888. base := ownerTable(cs[0].owner)
  889. same := true
  890. for _, c := range cs[1:] {
  891. if ownerTable(c.owner) != base {
  892. same = false
  893. break
  894. }
  895. }
  896. if same {
  897. continue // 同一张表内部重复认领 (数据链+索引叶 / 同索引叶链) —— 正常
  898. }
  899. cl := make([]string, 0, len(cs))
  900. for _, c := range cs {
  901. cl = append(cl, c.role+"@"+c.owner)
  902. }
  903. R.CrossLinks = append(R.CrossLinks, CrossLink{Page: pid, Type: pageTypeName(int(pageTypeByte(pid))), Claims: cl})
  904. }
  905. R.MultiRef = len(R.CrossLinks)
  906. slices.SortFunc(R.CrossLinks, func(a, b CrossLink) int { return cmp.Compare(a.Page, b.Page) })
  907. if len(R.CrossLinks) > 300 {
  908. R.CrossLinks = R.CrossLinks[:300]
  909. R.Reach.Truncated = true
  910. }
  911. for pid := range freeSet {
  912. if _, ok := w.role[pid]; ok {
  913. R.FreeLive = append(R.FreeLive, pid)
  914. }
  915. }
  916. slices.Sort(R.FreeLive)
  917. if len(w.badRefs) > 0 {
  918. seen := map[BadRef]bool{}
  919. for _, br := range w.badRefs {
  920. if seen[br] {
  921. continue
  922. }
  923. seen[br] = true
  924. R.BadRefCount++
  925. if len(R.BadRefs) < 300 {
  926. R.BadRefs = append(R.BadRefs, br)
  927. } else {
  928. R.Reach.Truncated = true
  929. }
  930. }
  931. slices.SortFunc(R.BadRefs, func(a, b BadRef) int {
  932. if a.From != b.From {
  933. return cmp.Compare(a.From, b.From)
  934. }
  935. return cmp.Compare(a.To, b.To)
  936. })
  937. }
  938. // 5.6) 索引内容一致性 —— 页归属看不出"内容错位", 这里逐条比对索引项 (对照 integrity_check)。
  939. // WAL 未恢复时盘上不是最终状态, 比对会系统性误报, checkIndexContent 会直接跳过 ——
  940. // 那就没什么可增量补全的了, 就地跑完并置 IdxPending=false。
  941. needIdxFill := true
  942. if R.Reach.Wal > 0 {
  943. checkIndexContent(R, w, ts)
  944. R.IdxPending = false
  945. needIdxFill = false
  946. } else {
  947. R.IdxPending = true
  948. }
  949. finalizeVerdict(R)
  950. return R, w, freeSet, ts, needIdxFill
  951. }
  952. // finalizeVerdict 按严重度算总判决: 交叉校验 / 索引内容 > 孤儿页 > 空闲链 > WAL。
  953. // 结构阶段与索引内容补全阶段都会调它 —— 索引内容补齐后重算一次, 判决才会从
  954. // "结构 OK" 收敛到最终值。
  955. func finalizeVerdict(R *AuditResult) {
  956. notes := append([]string(nil), R.structNotes...)
  957. switch {
  958. case R.MultiRef > 0 || len(R.FreeLive) > 0 || R.BadRefCount > 0 ||
  959. R.IdxBadOrder > 0 || R.IdxOrphanPK > 0 || R.IdxNotDataPage > 0 ||
  960. R.IdxMissingRow > 0 || R.IdxMissingEntry > 0:
  961. R.Verdict = "CORRUPT"
  962. if R.MultiRef > 0 {
  963. notes = append(notes, fmt.Sprintf("有 %d 页被两个不同主人同时认领 (交叉引用) —— 典型如两个表共用一个数据页", R.MultiRef))
  964. }
  965. if len(R.FreeLive) > 0 {
  966. notes = append(notes, fmt.Sprintf("有 %d 页既在空闲链上、又被结构引用 —— 下次分配即被覆写, 属数据损坏前兆", len(R.FreeLive)))
  967. }
  968. if R.BadRefCount > 0 {
  969. notes = append(notes, fmt.Sprintf("有 %d 处非法页指针 (指向文件外, 或指向保留页 1)", R.BadRefCount))
  970. }
  971. if R.IdxBadOrder > 0 {
  972. notes = append(notes, fmt.Sprintf("有 %d 处索引键序回退 —— B+ 树靠分隔键下行查找, 键序一乱就会查不到已经存在的行", R.IdxBadOrder))
  973. }
  974. if R.IdxOrphanPK > 0 {
  975. notes = append(notes, fmt.Sprintf("有 %d 个二级索引条目指向已不存在的行 (悬空索引项) —— 删行 / 重建索引漏删的典型残留", R.IdxOrphanPK))
  976. }
  977. if R.IdxNotDataPage > 0 {
  978. notes = append(notes, fmt.Sprintf("有 %d 个行存储索引条目指向的页不是数据页", R.IdxNotDataPage))
  979. }
  980. if R.IdxMissingRow > 0 {
  981. notes = append(notes, fmt.Sprintf("有 %d 个存活行没进主键索引 —— 行还在盘上, 但主键 B+ 树里没有它, 按主键查不到", R.IdxMissingRow))
  982. }
  983. if R.IdxMissingEntry > 0 {
  984. notes = append(notes, fmt.Sprintf("有 %d 个存活行没进某个二级索引 —— 按该索引查这些行会漏", R.IdxMissingEntry))
  985. }
  986. case R.Reach.Unaccounted > 0:
  987. R.Verdict = "UNACCOUNTED"
  988. switch {
  989. case R.Reach.WalLeftover > 0 && R.Reach.WalLeftover == R.Reach.Unaccounted:
  990. notes = append(notes, fmt.Sprintf(
  991. "有 %d 页 WAL 残留: 页类型为 WAL(7), 但页头 FirstWALPage=0 (无待恢复 WAL) 且不在空闲链上 —— "+
  992. "这些是历史已提交事务的 WAL 链页, 本应在提交时归还空闲链却未被回收 (引擎空间回收遗漏, 属空间泄漏)。", R.Reach.WalLeftover))
  993. case R.Reach.WalLeftover > 0:
  994. notes = append(notes, fmt.Sprintf(
  995. "有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 其中 %d 页为 WAL 残留 (历史已提交 WAL 链未被回收)。",
  996. R.Reach.Unaccounted, R.Reach.WalLeftover))
  997. default:
  998. notes = append(notes,
  999. fmt.Sprintf("有 %d 页既不属于任何表/索引, 也不在空闲链上 —— 孤儿页, 可能来自 DropTable / 重建索引漏收, 也可能是本工具未覆盖的结构 (v10 起的 blob 链已覆盖: Type=9 孤儿即真实泄漏)", R.Reach.Unaccounted))
  1000. }
  1001. case R.Free.Verdict != "OK":
  1002. R.Verdict = "FREE-CHAIN"
  1003. notes = append(notes, "空闲链自检未通过: "+R.Free.Detail)
  1004. case R.Reach.Wal > 0:
  1005. R.Verdict = "DIRTY-WAL"
  1006. default:
  1007. R.Verdict = "OK"
  1008. }
  1009. for _, ta := range R.Tables {
  1010. if ta.Verdict == "MISMATCH" {
  1011. R.Verdict = "HEAP-COUNT"
  1012. notes = append(notes, ta.Name+": "+ta.Detail)
  1013. }
  1014. }
  1015. R.Notes = notes
  1016. }
  1017. // ===== 审计缓存 (按库失效) =====
  1018. var (
  1019. cacheMu sync.Mutex
  1020. cachedAudit *AuditResult
  1021. cachedWalk *walker
  1022. cachedFree map[uint32]bool
  1023. auditCalcMu sync.Mutex // 串行化全库审计: 同一时刻只允许一次扫描在跑
  1024. auditGen uint64 // 切库代数, 防止旧库的异步审计结果污染新库缓存
  1025. auditFailed bool // 最近一次审计是否因 panic 失败
  1026. auditErrMsg string // 失败原因 (含堆栈), 供前端展示与排查
  1027. )
  1028. // computeAudit 计算 (或复用) 全库审计的**结构**结果, 并安排索引内容的后台增量补全。
  1029. //
  1030. // 拆成两段的原因: 结构审计在 mmap + 并行下已是亚秒级, 而索引内容一致性要在千万级
  1031. // 条目上逐条比对, 大表上要十几秒。若等它算完才发布, 概览 / 页图 / 孤儿页都得陪着等。
  1032. // 所以这里先发布结构结果 (IdxPending=true), 再由 ensureIndexContent 在后台补齐索引
  1033. // 内容后整体替换缓存 (IdxPending=false)。
  1034. //
  1035. // 并发的调用者会排队等待正在进行的那次结构计算, 而不是各自再跑一遍; 这样
  1036. // "打开库后后台预热" 与用户随后点开某个审计页不会重复扫描整个文件。
  1037. // 结果只在切库代数未变化时写回缓存, 避免旧库的慢审计覆盖新库的数据。
  1038. func computeAudit() (a *AuditResult, w *walker, fs map[uint32]bool) {
  1039. auditCalcMu.Lock()
  1040. defer auditCalcMu.Unlock()
  1041. // 审计会长时间持有指向映射区的页切片, 登记为在飞任务:
  1042. // 期间用户换库也不能把这块映射解掉, 否则就是段错误。
  1043. enterRead()
  1044. defer exitRead()
  1045. cacheMu.Lock()
  1046. a, w, fs = cachedAudit, cachedWalk, cachedFree
  1047. gen := auditGen
  1048. if a == nil {
  1049. auditFailed = false
  1050. auditErrMsg = ""
  1051. }
  1052. cacheMu.Unlock()
  1053. if a != nil {
  1054. return a, w, fs
  1055. }
  1056. // 落盘缓存命中: 指纹一致 (文件大小 + 头部页未变) 说明库没被改动, 直接取上次的分析结论,
  1057. // 跳过最慢的索引内容一致性扫描 —— 千万级条目那一步占了审计 >90% 的时间与几乎全部内存。
  1058. //
  1059. // 命中时 walker / freeSet 不在这里重建: 页号->归属的映射在超大库上同样很占内存,
  1060. // 而它们只在"单页检查"里用得到, 所以改为首次查看单页时按需重建 (见 pageOwnership)。
  1061. cfp := fingerprint()
  1062. cacheMu.Lock()
  1063. cacheFP = cfp
  1064. cacheMu.Unlock()
  1065. if cached, ok := storeLoad(cfp, gen); ok {
  1066. cacheMu.Lock()
  1067. published := gen == auditGen
  1068. if published {
  1069. cachedAudit, cachedWalk, cachedFree = cached, nil, nil
  1070. }
  1071. cacheMu.Unlock()
  1072. if published {
  1073. log.Printf("[cache] 命中 (fp=%s): 库未改动, 直接复用审计结论", cfp)
  1074. return cached, nil, nil
  1075. }
  1076. // 期间换库了: 这份结论作废, 老老实实走下面的全量分析。
  1077. }
  1078. if cfp != "" {
  1079. log.Printf("[cache] 未命中 (fp=%s): 库有改动或首次分析, 执行全量审计", cfp)
  1080. }
  1081. // 审计是"尽力而为"的只读诊断: 某个库触发分析器自身的 bug 时, 绝不能把整个服务带走。
  1082. // 后台预热尤其如此 —— 未在 goroutine 里恢复的 panic 会直接终止进程, 前端表现为"打开后断开"。
  1083. // 这里兜底: 记录失败原因并让结果保持为空, 由前端显示"审计失败"而不是无限等待。
  1084. defer func() {
  1085. if r := recover(); r != nil {
  1086. a, w, fs = nil, nil, nil
  1087. cacheMu.Lock()
  1088. if gen == auditGen {
  1089. auditFailed = true
  1090. auditErrMsg = fmt.Sprintf("%v\n%s", r, debug.Stack())
  1091. }
  1092. cacheMu.Unlock()
  1093. }
  1094. }()
  1095. var ts []TInfo
  1096. var needFill bool
  1097. a, w, fs, ts, needFill = runAuditStructural()
  1098. // 立刻发布结构结果 (可能只是"半成品": IdxPending=true), 让概览 / 页图 / 孤儿页
  1099. // 马上可用; 索引内容不阻塞本次调用。
  1100. cacheMu.Lock()
  1101. published := gen == auditGen
  1102. if published {
  1103. cachedAudit, cachedWalk, cachedFree = a, w, fs
  1104. }
  1105. cacheMu.Unlock()
  1106. if published {
  1107. if needFill {
  1108. go ensureIndexContent(gen, w, ts)
  1109. } else {
  1110. // 结构阶段已把索引内容一并算完 (WAL 未恢复场景): 直接落盘, 下次打开直接取。
  1111. storeSave(cfp, a)
  1112. }
  1113. }
  1114. return a, w, fs
  1115. }
  1116. // ===== 增量审计: 索引内容后台补全 =====
  1117. var (
  1118. idxFillMu sync.Mutex
  1119. idxFillGen uint64 // 已安排 (正在跑 / 已完成) 补全的代数; 0 = 尚未安排
  1120. )
  1121. // ensureIndexContent 在后台补全索引内容一致性检查, 再把"结构结果 + 索引内容"合并成
  1122. // 最终结果整体替换缓存。同一代 (同一库) 只会跑一次; 期间换库则放弃发布 (结果作废)。
  1123. //
  1124. // 之所以复制一份再写: 结构结果已经发布出去、可能正被 JSON 序列化, 不能就地改,
  1125. // 否则就是数据竞争。读接口拿到的永远是自洽快照 (要么结构版, 要么最终版)。
  1126. func ensureIndexContent(gen uint64, w *walker, ts []TInfo) {
  1127. idxFillMu.Lock()
  1128. if idxFillGen == gen {
  1129. idxFillMu.Unlock()
  1130. return
  1131. }
  1132. idxFillGen = gen
  1133. idxFillMu.Unlock()
  1134. // 长读任务: 期间换库不能把映射解掉, 否则拿着的页切片会变成野指针。
  1135. enterRead()
  1136. defer exitRead()
  1137. defer func() {
  1138. if r := recover(); r != nil {
  1139. log.Printf("[audit] 索引内容补全失败, 保留结构结果: %v", r)
  1140. cacheMu.Lock()
  1141. if gen == auditGen {
  1142. auditFailed = true
  1143. auditErrMsg = fmt.Sprintf("索引内容补全失败: %v", r)
  1144. }
  1145. cacheMu.Unlock()
  1146. }
  1147. }()
  1148. cacheMu.Lock()
  1149. a := cachedAudit
  1150. cacheMu.Unlock()
  1151. if a == nil || !a.IdxPending {
  1152. return
  1153. }
  1154. full := cloneAudit(a)
  1155. checkIndexContent(full, w, ts)
  1156. full.IdxPending = false
  1157. finalizeVerdict(full)
  1158. cacheMu.Lock()
  1159. if gen != auditGen {
  1160. cacheMu.Unlock()
  1161. return
  1162. }
  1163. cachedAudit = full
  1164. cfp := cacheFP
  1165. cacheMu.Unlock()
  1166. // 结论落盘: 下次打开同一个库 (指纹一致) 直接取, 免掉这一遍索引内容扫描。
  1167. storeSave(cfp, full)
  1168. }
  1169. // cloneAudit 浅拷贝审计结果 —— 供后台补全索引内容时就地写入。已发布的 a 绝不改;
  1170. // 只重置会被补全阶段写入的字段, 其余 (Free/Tables/Reach 等只读数据) 共享引用。
  1171. func cloneAudit(a *AuditResult) *AuditResult {
  1172. b := *a
  1173. b.structNotes = append([]string(nil), a.structNotes...)
  1174. b.Tables = append([]TableAudit(nil), a.Tables...)
  1175. b.IdxStats = nil
  1176. b.IdxIssues = nil
  1177. b.IdxNotes = nil
  1178. return &b
  1179. }
  1180. // auditCached 取审计结果 (首次调用时同步计算)。
  1181. func auditCached() *AuditResult {
  1182. a, _, _ := computeAudit()
  1183. return a
  1184. }
  1185. // peekAudit 非阻塞读取已算好的审计结果; 尚未就绪时返回 nil。
  1186. // 打开库时用它, 避免为概览页的汇总值而阻塞整个请求。
  1187. func peekAudit() *AuditResult {
  1188. cacheMu.Lock()
  1189. defer cacheMu.Unlock()
  1190. return cachedAudit
  1191. }
  1192. // peekWalk 非阻塞取已建好的页归属遍历器; 未就绪时返回 nil (调用方需容忍)。
  1193. // 页图分块渲染用它给页上色 —— 不能为此触发一次全库归属重建, 拿不到就先不显示归属。
  1194. func peekWalk() *walker {
  1195. cacheMu.Lock()
  1196. defer cacheMu.Unlock()
  1197. return cachedWalk
  1198. }
  1199. // peekAuditError 返回最近一次审计是否失败及其原因; 未失败时返回 (false, "")。
  1200. func peekAuditError() (bool, string) {
  1201. cacheMu.Lock()
  1202. defer cacheMu.Unlock()
  1203. return auditFailed, auditErrMsg
  1204. }
  1205. // startAuditAsync 后台预热全库审计 (幂等: 已缓存时会立即返回)。
  1206. func startAuditAsync() {
  1207. go computeAudit()
  1208. }
  1209. // walkBuildMu 串行化"仅重建页归属"的扫描, 避免多个单页检查同时各扫一遍。
  1210. var walkBuildMu sync.Mutex
  1211. // pageOwnership 取 (页归属遍历器, 空闲页集合), 供页检查器查询单页归属。
  1212. //
  1213. // 正常分析路径会顺带建好这两个结构; 而缓存命中时审计只从盘上取回了结论, 没建它们 ——
  1214. // 那两个 map 是"页号 -> 归属", 在超大库上会有几百万项, 一直占着内存不值当。所以
  1215. // 只有真正要看某一页归属时才在这里补建: 补建只走结构链 (亚秒级), 不碰最慢的索引内容比对。
  1216. func pageOwnership() (*walker, map[uint32]bool) {
  1217. cacheMu.Lock()
  1218. a, w, fs := cachedAudit, cachedWalk, cachedFree
  1219. cacheMu.Unlock()
  1220. if a == nil {
  1221. // 审计还没跑过: 交给 computeAudit (它会连同 walker 一起建好)。
  1222. _, w, fs = computeAudit()
  1223. return w, fs
  1224. }
  1225. if w != nil {
  1226. return w, fs
  1227. }
  1228. return rebuildWalker()
  1229. }
  1230. // rebuildWalker 只为拿到页归属遍历器而重跑一次结构链。结果按代数写回缓存。
  1231. func rebuildWalker() (*walker, map[uint32]bool) {
  1232. walkBuildMu.Lock()
  1233. defer walkBuildMu.Unlock()
  1234. cacheMu.Lock()
  1235. w, fs := cachedWalk, cachedFree
  1236. gen := auditGen
  1237. cacheMu.Unlock()
  1238. if w != nil {
  1239. return w, fs
  1240. }
  1241. _, w2, fs2, _, _ := runAuditStructural()
  1242. cacheMu.Lock()
  1243. if gen == auditGen {
  1244. cachedWalk, cachedFree = w2, fs2
  1245. }
  1246. cacheMu.Unlock()
  1247. return w2, fs2
  1248. }
  1249. // invalidateAudit 切库时清空缓存并推进代数。
  1250. func invalidateAudit() {
  1251. cacheMu.Lock()
  1252. cachedAudit, cachedWalk, cachedFree = nil, nil, nil
  1253. auditFailed, auditErrMsg = false, ""
  1254. auditGen++
  1255. cacheMu.Unlock()
  1256. }
  1257. // ===== 单页检查 =====
  1258. type PageInfo struct {
  1259. ID uint32 `json:"id"`
  1260. Type int `json:"t"`
  1261. TypeS string `json:"ts"`
  1262. Flags byte `json:"flags"`
  1263. Ref uint16 `json:"ref"`
  1264. Prev uint32 `json:"pv"`
  1265. Next uint32 `json:"nx"`
  1266. Use uint32 `json:"use"`
  1267. Us float64 `json:"us"`
  1268. Role string `json:"role"`
  1269. Owner string `json:"owner"`
  1270. InFree bool `json:"inFree"`
  1271. IsAlloc bool `json:"isAlloc"`
  1272. Hex string `json:"hex"`
  1273. Err string `json:"e,omitempty"`
  1274. }
  1275. func rPageInfo(pid uint32) *PageInfo {
  1276. b, e := readPage(pid)
  1277. if e != nil {
  1278. return &PageInfo{ID: pid, Err: "读页失败: " + e.Error()}
  1279. }
  1280. pi := &PageInfo{
  1281. ID: pid, Type: int(b[0]), TypeS: pageTypeName(int(b[0])),
  1282. Flags: b[1],
  1283. Ref: le16(b[PH_REF : PH_REF+2]),
  1284. Prev: le32(b[PH_PREV : PH_PREV+4]),
  1285. Next: le32(b[PH_NEXT : PH_NEXT+4]),
  1286. Use: le32(b[PH_USE : PH_USE+4]),
  1287. }
  1288. if pi.Use > PGSZ {
  1289. pi.Use = PGSZ
  1290. }
  1291. pi.Us = float64(pi.Use) / float64(PGSZ) * 100
  1292. pi.Hex = fmt.Sprintf("%x", b[:min(len(b), 64)])
  1293. w, freeSet := pageOwnership()
  1294. if w != nil {
  1295. pi.Role = w.role[pid]
  1296. pi.Owner = w.owner[pid]
  1297. }
  1298. if freeSet != nil {
  1299. pi.InFree = freeSet[pid]
  1300. }
  1301. nf := le32(hdr[12:16])
  1302. pi.IsAlloc = nf > 0 && pid <= nf-1
  1303. if pi.Role == "" && pi.InFree {
  1304. pi.Role = "free"
  1305. }
  1306. if pi.TypeS == "Free" && pi.Role == "" {
  1307. pi.Role = "free"
  1308. }
  1309. return pi
  1310. }