orphancheck.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. // Package orphancheck 取证工具: 找出 VRTX 文件里"既不在空闲链、也不被任何结构引用"的孤儿页,
  2. // 并按页类型出直方图 + 样例 hex。
  3. //
  4. // 两套口径并排输出:
  5. //
  6. // - analyzer 口径: 与 analyzer/audit.go 的 runAuditStructural 同一批可达根
  7. // (表页链 / schema 扩展链 / 未恢复 WAL 链 / 每表 data-heap-index 链),
  8. // 不含 blob 链 —— 它就是 analyzer 报的 "44 页既不属于任何表/索引, 也不在空闲链上" 的数字。
  9. // - blob 感知口径: 在上面基础上, 用引擎只读打开库、全表扫出行内 BlobRef,
  10. // 把 blob 链页 (Type=9) 也计入可达 —— 剩余才是真正的泄漏页。
  11. //
  12. // 两口径的差集 = "analyzer 误报的孤儿" (活 blob 页)。若 blob 感知口径仍有孤儿,
  13. // 按类型直方图指认泄漏路径。
  14. //
  15. // 所有页布局常量与 analyzer/main.go 逐一对照 (那边由 layout_assert.go 编译期钉在
  16. // DB/core 的真实结构体上); 本包不 import analyzer (它是 package main), 常量独立维护。
  17. package orphancheck
  18. import (
  19. "encoding/binary"
  20. "encoding/hex"
  21. "fmt"
  22. "os"
  23. "sort"
  24. "strings"
  25. "git.ycl.pub/yl32940/TsDB2/DB/core"
  26. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  27. )
  28. const (
  29. pgsz = 16384
  30. hdrsz = 4096
  31. phdr = 20
  32. pgHead = 0
  33. pgTable = 1
  34. pgData = 2
  35. pgHeap = 3
  36. pgIMeta = 4
  37. pgIData = 5
  38. pgOver = 6
  39. pgWAL = 7
  40. pgSExt = 8
  41. pgBlob = 9
  42. // PageHdr 字段偏移
  43. phNext = 12
  44. phUse = 16
  45. // 头页字段偏移 (core/header_page.go v11 布局, 编译期断言钉死)
  46. hdrNextFree = 12
  47. hdrTablePage = 48
  48. hdrFreeStart = 56
  49. hdrFreeEnd = 60
  50. hdrFreeCount = 64
  51. hdrFirstWAL = 68
  52. // 表页/表记录 (TableInfo=104B, TableNameSize=48)
  53. tinfoSZ = 104
  54. tiFlag = 48
  55. tiHPCnt = 52
  56. tiFHeap = 64
  57. tiFData = 72
  58. tiAlloc = 82
  59. tiFIdx = 88
  60. sextHdr = 4
  61. recOff = phdr + sextHdr
  62. flagTomb = 0x80
  63. // IndexMeta 记录 (100B, IndexNameSize=64)
  64. metaSZ = 100
  65. imNameSz = 64
  66. imType = 64
  67. imFcnt = 65
  68. imFlds = 66
  69. imEP = 84
  70. imFlag = 96
  71. imFlagDel = 0x80
  72. imFlagUniq = 0x01
  73. idxHdr = 32
  74. ixTyp = 20
  75. ixELen = 22
  76. ixCnt = 24
  77. ixLeaf = 28
  78. // 索引类型 (page/base_page.go)
  79. itTime = 1
  80. itPrimary = 2
  81. itStr = 3
  82. itCompose = 4
  83. itBTree = 5
  84. itHash = 6
  85. // Hash 索引元数据/桶页偏移 (analyzer/audit.go)
  86. hmMagic = 32
  87. hmDir = 48
  88. hashMagic = 0x54484833
  89. hashSlot = 12
  90. hashBktHd = 28
  91. )
  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. // PageInfo 一个孤儿页的取证信息。
  96. type PageInfo struct {
  97. ID uint32
  98. TypeByte byte
  99. UseSize uint32
  100. Next uint32
  101. Head64 []byte // 页头 64 字节样例
  102. }
  103. // ChainInfo 一条 blob 链的取证信息。
  104. type ChainInfo struct {
  105. Table string
  106. PKID uint64
  107. FirstPage uint32
  108. Pages int
  109. BrokenAt uint32 // 链中途断掉/成环/越界时的页号 (0=完整)
  110. }
  111. // Result 取证结果。
  112. type Result struct {
  113. File string
  114. TotalPages int
  115. NextFreePage uint32
  116. FreeCount int
  117. FreeDeclared uint32
  118. FreeVerdict string
  119. WALPending int // 头页 FirstWALPage != 0 时的未恢复 WAL 链页数 (只读打开跳过恢复)
  120. Notes []string
  121. ReachableNoBlob int // analyzer 口径可达页数 (含页 1 / 空闲页不计)
  122. BlobPages int // blob 链页总数 (含与结构页重叠数)
  123. // AnalyzerOrphans: 与 analyzer 报数完全同口径的孤儿 (不含 blob 链可达性)
  124. AnalyzerOrphans []PageInfo
  125. AnalyzerByType map[string]int
  126. AnalyzerWalLeft int
  127. // Orphans: blob 感知口径的真正孤儿
  128. Orphans []PageInfo
  129. ByType map[string]int
  130. WalLeft int
  131. Samples map[string][]PageInfo // 每类型最多 N 个样例 (头 64B)
  132. Chains []ChainInfo
  133. BrokenChains int
  134. }
  135. // TypeName 页类型字节 -> 名字 (9=BlobIndex 是 analyzer 不认识的, 这里显式命名)。
  136. func TypeName(t byte) string {
  137. switch t {
  138. case pgHead:
  139. return "Free(0)"
  140. case pgTable:
  141. return "Table(1)"
  142. case pgData:
  143. return "Data(2)"
  144. case pgHeap:
  145. return "Heap(3)"
  146. case pgIMeta:
  147. return "IdxMeta(4)"
  148. case pgIData:
  149. return "IdxData(5)"
  150. case pgOver:
  151. return "Overflow(6,deprecated)"
  152. case pgWAL:
  153. return "WAL(7)"
  154. case pgSExt:
  155. return "SchemaExt(8)"
  156. case pgBlob:
  157. return "BlobIndex(9)"
  158. }
  159. return fmt.Sprintf("?(%d)", t)
  160. }
  161. type walker struct {
  162. f *os.File
  163. total int
  164. claim map[uint32]string // pid -> role
  165. cross [][2]string // 重复认领记录 (诊断)
  166. }
  167. // page 读一页。每次新分配 16KB —— 调用方拿到的切片在下一次 page() 之前有效。
  168. // 取证工具是 one-shot, 不需要 analyzer 那种复用缓冲 (那里要在热循环里省 GC);
  169. // 换来的正确性: 链 walk 里随时再读子页/元页, 复用单缓冲会被覆盖 (aliasing bug)。
  170. func (w *walker) page(pid uint32) ([]byte, error) {
  171. b := make([]byte, pgsz)
  172. if _, err := w.f.ReadAt(b, hdrsz+int64(pid-1)*pgsz); err != nil {
  173. return nil, err
  174. }
  175. return b, nil
  176. }
  177. // take 认领一页; 重复认领记入 cross (同角色不算)。
  178. func (w *walker) take(pid uint32, role string) bool {
  179. if pid == 0 {
  180. return false
  181. }
  182. if r, ok := w.claim[pid]; ok {
  183. if r != role {
  184. w.cross = append(w.cross, [2]string{r, role})
  185. }
  186. return false
  187. }
  188. w.claim[pid] = role
  189. return true
  190. }
  191. // chain 沿 NextPage 走链并认领 (带越界/成环保护)。
  192. func (w *walker) chain(start uint32, role string) int {
  193. n := 0
  194. seen := map[uint32]bool{}
  195. for pid := start; pid != 0; {
  196. if pid < 2 || int(pid) > w.total || seen[pid] {
  197. return n
  198. }
  199. seen[pid] = true
  200. if !w.take(pid, role) {
  201. return n
  202. }
  203. n++
  204. b, err := w.page(pid)
  205. if err != nil {
  206. return n
  207. }
  208. pid = le32(b[phNext : phNext+4])
  209. }
  210. return n
  211. }
  212. // idxDataPages 收下一页行存储索引叶条目引用的 DataPageID (与 analyzer 同逻辑)。
  213. func (w *walker) idxDataPages(b []byte, it byte) int {
  214. if b[0] != pgIData {
  215. return 0
  216. }
  217. cn := int(le32(b[ixCnt : ixCnt+4]))
  218. el := int(le16(b[ixELen : ixELen+2]))
  219. if el <= 4 {
  220. el = 12
  221. }
  222. refOff := el - 4
  223. if it == itPrimary {
  224. refOff = 8
  225. }
  226. if refOff < 0 || refOff+4 > el {
  227. return 0
  228. }
  229. n := 0
  230. for i := 0; i < cn; i++ {
  231. o := idxHdr + i*el
  232. if o+el > len(b) {
  233. break
  234. }
  235. if p := be32(b[o+refOff : o+refOff+4]); p != 0 {
  236. if w.take(p, "data(idx)") {
  237. n++
  238. }
  239. }
  240. }
  241. return n
  242. }
  243. func isRowStore(it byte) bool { return it == itTime || it == itPrimary }
  244. func isBTreeIdx(it byte) bool { return it == itPrimary || it == itBTree || it == itStr || it == itCompose }
  245. // indexPages 枚举一个索引占用的全部页 (移植 analyzer/audit.go indexPages)。
  246. func (w *walker) indexPages(it byte, entry uint32) (n, dp int) {
  247. if entry == 0 || int(entry) > w.total {
  248. return 0, 0
  249. }
  250. switch {
  251. case it == itHash:
  252. meta, err := w.page(entry)
  253. if err != nil || le32(meta[hmMagic:hmMagic+4]) != hashMagic {
  254. return 0, 0
  255. }
  256. w.take(entry, "index-meta")
  257. n++
  258. limit := phdr + ((pgsz-phdr)/hashSlot)*hashSlot
  259. for dir := le32(meta[hmDir : hmDir+4]); dir != 0; {
  260. if dir < 2 || int(dir) > w.total {
  261. break
  262. }
  263. if !w.take(dir, "index-dir") {
  264. break
  265. }
  266. n++
  267. b, err := w.page(dir)
  268. if err != nil {
  269. break
  270. }
  271. for o := phdr; o+hashSlot <= limit; o += hashSlot {
  272. if head := le32(b[o : o+4]); head != 0 {
  273. n += w.chain(head, "index-bucket")
  274. }
  275. }
  276. dir = le32(b[phNext : phNext+4])
  277. }
  278. return n, 0
  279. case isBTreeIdx(it):
  280. queue := []uint32{entry}
  281. seen := map[uint32]bool{}
  282. for len(queue) > 0 {
  283. pid := queue[0]
  284. queue = queue[1:]
  285. if seen[pid] {
  286. continue
  287. }
  288. seen[pid] = true
  289. if pid < 2 || int(pid) > w.total {
  290. continue
  291. }
  292. if !w.take(pid, "index-node") {
  293. continue
  294. }
  295. n++
  296. b, err := w.page(pid)
  297. if err != nil || b[0] != pgIData {
  298. continue
  299. }
  300. if b[ixLeaf] == 1 {
  301. if isRowStore(it) {
  302. dp += w.idxDataPages(b, it)
  303. }
  304. for nx := le32(b[phNext : phNext+4]); nx != 0; {
  305. if nx < 2 || int(nx) > w.total || seen[nx] {
  306. break
  307. }
  308. seen[nx] = true
  309. if !w.take(nx, "index-node") {
  310. break
  311. }
  312. n++
  313. nb, err := w.page(nx)
  314. if err != nil {
  315. break
  316. }
  317. if isRowStore(it) {
  318. dp += w.idxDataPages(nb, it)
  319. }
  320. nx = le32(nb[phNext : phNext+4])
  321. }
  322. continue
  323. }
  324. cn := int(le32(b[ixCnt : ixCnt+4]))
  325. el := int(le16(b[ixELen : ixELen+2]))
  326. if el <= 4 {
  327. el = 12
  328. }
  329. for i := 0; i < cn; i++ {
  330. o := idxHdr + i*el
  331. if o+el > len(b) {
  332. break
  333. }
  334. if child := be32(b[o+el-4 : o+el]); child != 0 {
  335. queue = append(queue, child)
  336. }
  337. }
  338. }
  339. return n, dp
  340. default:
  341. n = w.chain(entry, "index-chain")
  342. return n, 0
  343. }
  344. }
  345. // tableInfo 结构走查所需的最小表信息 (不含 schema)。
  346. type tableInfo struct {
  347. name string
  348. dp0 uint32
  349. hp0 uint32
  350. ip0 uint32
  351. index []idxInfo
  352. }
  353. type idxInfo struct {
  354. typ byte
  355. ep uint32
  356. }
  357. // parseTables 移植 analyzer pTabs 的表链/表记录/索引元数据解析 (跳过 schema 重建)。
  358. func (w *walker) parseTables() []tableInfo {
  359. var out []tableInfo
  360. hdr := make([]byte, hdrsz)
  361. if _, err := w.f.ReadAt(hdr, 0); err != nil {
  362. return out
  363. }
  364. for pid := le32(hdr[hdrTablePage : hdrTablePage+4]); pid != 0; {
  365. if pid < 2 || int(pid) > w.total {
  366. break
  367. }
  368. b, err := w.page(pid)
  369. if err != nil || b[0] != pgTable {
  370. break
  371. }
  372. us := le32(b[phUse : phUse+4])
  373. if us > pgsz {
  374. us = pgsz
  375. }
  376. for off := uint32(recOff); off+tinfoSZ <= us && off+tinfoSZ <= pgsz; {
  377. tb := b[off : off+tinfoSZ]
  378. alloc := uint32(le16(tb[tiAlloc : tiAlloc+2]))
  379. if tb[tiFlag]&flagTomb != 0 {
  380. off += tinfoSZ + alloc
  381. continue
  382. }
  383. t := tableInfo{
  384. name: strings.TrimRight(string(tb[:48]), "\x00"),
  385. dp0: le32(tb[tiFData : tiFData+4]),
  386. hp0: le32(tb[tiFHeap : tiFHeap+4]),
  387. ip0: le32(tb[tiFIdx : tiFIdx+4]),
  388. }
  389. // 索引元数据链
  390. for ip := t.ip0; ip != 0; {
  391. if ip < 2 || int(ip) > w.total {
  392. break
  393. }
  394. ib, err := w.page(ip)
  395. if err != nil || ib[0] != pgIMeta {
  396. break
  397. }
  398. iu := le32(ib[phUse : phUse+4])
  399. if iu > pgsz {
  400. iu = pgsz
  401. }
  402. for io := uint32(phdr); io+metaSZ <= iu && io+metaSZ <= pgsz; io += metaSZ {
  403. im := ib[io : io+metaSZ]
  404. if im[imFlag]&imFlagDel != 0 {
  405. continue
  406. }
  407. ep := le32(im[imEP : imEP+4])
  408. if ep != 0 {
  409. if pb, err := w.page(ep); err != nil || pb[0] != pgIData {
  410. ep = 0
  411. }
  412. }
  413. if ep != 0 {
  414. t.index = append(t.index, idxInfo{typ: im[imType], ep: ep})
  415. }
  416. }
  417. ip = le32(ib[phNext : phNext+4])
  418. }
  419. out = append(out, t)
  420. off += tinfoSZ + alloc
  421. }
  422. nx := le32(b[phNext : phNext+4])
  423. if nx == 0 || nx == pid {
  424. break
  425. }
  426. pid = nx
  427. }
  428. return out
  429. }
  430. // Options 取证选项。
  431. type Options struct {
  432. // EngineBlobScan 用引擎只读打开库扫行内 BlobRef (推荐; 只在副本上跑)。
  433. EngineBlobScan bool
  434. // SamplesPerType 每种类型的样例页数 (头 64B hex)。
  435. SamplesPerType int
  436. }
  437. // Run 对 dbPath 执行取证。调用方必须保证 dbPath 是副本 (本函数只读打开,
  438. // 但"只读打开"本身不适合直接在唯一原件上做)。
  439. func Run(dbPath string, opt Options) (*Result, error) {
  440. if opt.SamplesPerType <= 0 {
  441. opt.SamplesPerType = 3
  442. }
  443. f, err := os.Open(dbPath)
  444. if err != nil {
  445. return nil, err
  446. }
  447. defer f.Close()
  448. st, err := f.Stat()
  449. if err != nil {
  450. return nil, err
  451. }
  452. R := &Result{
  453. File: dbPath,
  454. TotalPages: int((st.Size() - hdrsz) / pgsz),
  455. ByType: map[string]int{},
  456. AnalyzerByType: map[string]int{},
  457. Samples: map[string][]PageInfo{},
  458. }
  459. if R.TotalPages < 0 {
  460. return nil, fmt.Errorf("文件 %s 不足 4KB 头页", dbPath)
  461. }
  462. hdr := make([]byte, hdrsz)
  463. if _, err := f.ReadAt(hdr, 0); err != nil {
  464. return nil, err
  465. }
  466. nf := le32(hdr[hdrNextFree : hdrNextFree+4])
  467. R.NextFreePage = nf
  468. freeHead := le32(hdr[hdrFreeStart : hdrFreeStart+4])
  469. R.FreeDeclared = le32(hdr[hdrFreeCount : hdrFreeCount+4])
  470. freeEnd := le32(hdr[hdrFreeEnd : hdrFreeEnd+4])
  471. w := &walker{f: f, total: R.TotalPages, claim: map[uint32]string{}}
  472. // 1) 空闲链
  473. freeSet := map[uint32]bool{}
  474. {
  475. seen := map[uint32]bool{}
  476. bad := 0
  477. var tail uint32
  478. for pid := freeHead; pid != 0; {
  479. if int(pid) > w.total || seen[pid] {
  480. bad++
  481. break
  482. }
  483. seen[pid] = true
  484. freeSet[pid] = true
  485. tail = pid
  486. b, err := w.page(pid)
  487. if err != nil {
  488. bad++
  489. break
  490. }
  491. pid = le32(b[phNext : phNext+4])
  492. }
  493. R.FreeCount = len(freeSet)
  494. switch {
  495. case bad > 0:
  496. R.FreeVerdict = "损坏 (成环/越界)"
  497. case uint32(R.FreeCount) != R.FreeDeclared:
  498. R.FreeVerdict = fmt.Sprintf("计数不符 (实走 %d, header 声明 %d)", R.FreeCount, R.FreeDeclared)
  499. case freeHead != 0 && freeEnd != tail:
  500. R.FreeVerdict = fmt.Sprintf("尾指针不符 (实走尾 %d, header 声明 %d)", tail, freeEnd)
  501. default:
  502. R.FreeVerdict = "OK"
  503. }
  504. }
  505. // 2) 结构可达 (analyzer 同口径)
  506. w.take(1, "reserved")
  507. w.chain(le32(hdr[hdrTablePage:hdrTablePage+4]), "table-page")
  508. // schema 扩展链 (表页第 1 页 PHDR 之后, 大端)
  509. if tp0 := le32(hdr[hdrTablePage : hdrTablePage+4]); tp0 != 0 {
  510. if b, err := w.page(tp0); err == nil && len(b) >= phdr+4 {
  511. w.chain(be32(b[phdr:phdr+4]), "schema-ext")
  512. }
  513. }
  514. // 未恢复 WAL 链 (只读打开引擎不会重放, 链页仍是活的可达根)
  515. if w0 := le32(hdr[hdrFirstWAL : hdrFirstWAL+4]); w0 != 0 {
  516. R.WALPending = w.chain(w0, "wal-pending")
  517. R.Notes = append(R.Notes, fmt.Sprintf("头页 FirstWALPage=%d (%d 页): 上次未干净关闭或只读跳过恢复, 页归属可能不完整", w0, R.WALPending))
  518. }
  519. tables := w.parseTables()
  520. for i := range tables {
  521. t := &tables[i]
  522. w.chain(t.dp0, "data:"+t.name)
  523. w.chain(t.hp0, "heap:"+t.name)
  524. w.chain(t.ip0, "imeta:"+t.name)
  525. for _, idx := range t.index {
  526. w.indexPages(idx.typ, idx.ep)
  527. }
  528. }
  529. R.ReachableNoBlob = len(w.claim)
  530. // 3) 孤儿: analyzer 口径 (此时 claim 里还没有 blob 链)
  531. snapNoBlob := make(map[uint32]string, len(w.claim))
  532. for pid, role := range w.claim {
  533. snapNoBlob[pid] = role
  534. }
  535. R.AnalyzerOrphans, R.AnalyzerByType, R.AnalyzerWalLeft = collectOrphans(f, w, nf, freeSet, snapNoBlob)
  536. // 4) blob 链可达 (引擎只读扫描行内 BlobRef)
  537. blobClaimed := 0
  538. if opt.EngineBlobScan {
  539. chains, notes, claimed := scanBlobChains(dbPath, tables, w)
  540. R.Chains = chains
  541. R.Notes = append(R.Notes, notes...)
  542. blobClaimed = claimed
  543. }
  544. // 5) 孤儿: blob 感知口径 (在结构可达上叠加 blob 链), 并按类型留样
  545. R.Orphans, R.ByType, R.WalLeft = collectOrphans(f, w, nf, freeSet, w.claim)
  546. R.BlobPages = blobClaimed
  547. for _, ci := range R.Chains {
  548. if ci.BrokenAt != 0 {
  549. R.BrokenChains++
  550. }
  551. }
  552. perType := map[string]int{}
  553. for _, p := range R.Orphans {
  554. tn := TypeName(p.TypeByte)
  555. if perType[tn] < opt.SamplesPerType {
  556. R.Samples[tn] = append(R.Samples[tn], p)
  557. perType[tn]++
  558. }
  559. }
  560. if len(w.cross) > 0 {
  561. R.Notes = append(R.Notes, fmt.Sprintf("有 %d 次跨角色重复认领 (一页多个主人), 详见交叉引用", len(w.cross)))
  562. }
  563. return R, nil
  564. }
  565. // collectOrphans 算 [2, NextFreePage) 内不在空闲链、不在 claim 集合里的页,
  566. // 返回 (全部孤儿, 按类型直方图, 其中 WAL 类型页数)。
  567. func collectOrphans(f *os.File, w *walker, nf uint32, freeSet map[uint32]bool,
  568. claim map[uint32]string) ([]PageInfo, map[string]int, int) {
  569. var out []PageInfo
  570. byType := map[string]int{}
  571. walLeft := 0
  572. buf := make([]byte, pgsz)
  573. for pid := uint32(2); int(pid) < int(nf) && int(pid) <= w.total; pid++ {
  574. if _, ok := claim[pid]; ok {
  575. continue
  576. }
  577. if freeSet[pid] {
  578. continue
  579. }
  580. pi := PageInfo{ID: pid}
  581. if _, err := f.ReadAt(buf, hdrsz+int64(pid-1)*pgsz); err == nil {
  582. pi.TypeByte = buf[0]
  583. pi.UseSize = le32(buf[phUse : phUse+4])
  584. pi.Next = le32(buf[phNext : phNext+4])
  585. pi.Head64 = append([]byte(nil), buf[:64]...)
  586. }
  587. out = append(out, pi)
  588. byType[TypeName(pi.TypeByte)]++
  589. if pi.TypeByte == pgWAL {
  590. walLeft++
  591. }
  592. }
  593. return out, byType, walLeft
  594. }
  595. // scanBlobChains 引擎只读打开库, 全表扫出行内 BlobRef, 走链认领 blob 页。
  596. // 返回 (链清单, 备注, 认领页数)。任何引擎错误都不致命 —— 降级为"未走 blob 链"。
  597. func scanBlobChains(dbPath string, tables []tableInfo, w *walker) ([]ChainInfo, []string, int) {
  598. var notes []string
  599. db, err := core.OpenWithOptions(dbPath, core.OpenOptions{ReadOnly: true})
  600. if err != nil {
  601. return nil, []string{fmt.Sprintf("引擎只读打开失败, 跳过 blob 链走查: %v", err)}, 0
  602. }
  603. defer db.Close()
  604. before := len(w.claim)
  605. var chains []ChainInfo
  606. broken := 0
  607. refs := 0
  608. for i := range tables {
  609. t := &tables[i]
  610. tbl, ok := db.FindTable(t.name)
  611. if !ok {
  612. notes = append(notes, fmt.Sprintf("表 %s: 引擎 FindTable 失败, 该表 blob 列未走查", t.name))
  613. continue
  614. }
  615. perPK := map[uint64][]types.BlobRef{}
  616. scanErr := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
  617. for _, fv := range vals {
  618. if ref, ok := fv.Value.(types.BlobRef); ok && !ref.Empty() {
  619. perPK[pkID] = append(perPK[pkID], ref)
  620. }
  621. }
  622. return true
  623. })
  624. if scanErr != nil {
  625. notes = append(notes, fmt.Sprintf("表 %s: 扫描失败 (%v), blob 列可能漏走", t.name, scanErr))
  626. }
  627. for pkID, rs := range perPK {
  628. for _, ref := range rs {
  629. refs++
  630. ci := ChainInfo{Table: t.name, PKID: pkID, FirstPage: ref.FirstPageID}
  631. seen := map[uint32]bool{}
  632. for p := ref.FirstPageID; p != 0; {
  633. if p < 2 || int(p) > w.total || seen[p] {
  634. ci.BrokenAt = p
  635. broken++
  636. break
  637. }
  638. seen[p] = true
  639. ci.Pages++
  640. w.take(p, "blob:"+t.name)
  641. b, err := w.page(p)
  642. if err != nil {
  643. ci.BrokenAt = p
  644. broken++
  645. break
  646. }
  647. if b[0] != pgBlob {
  648. ci.BrokenAt = p
  649. broken++
  650. break
  651. }
  652. p = le32(b[phNext : phNext+4])
  653. }
  654. chains = append(chains, ci)
  655. }
  656. }
  657. }
  658. if refs == 0 {
  659. notes = append(notes, "全库行内没有任何非空 BlobRef (无 blob 列或无 blob 数据)")
  660. }
  661. if broken > 0 {
  662. notes = append(notes, fmt.Sprintf("有 %d 条 blob 链中途断裂/类型不符/越界 —— 链页泄漏或行内引用损坏", broken))
  663. }
  664. return chains, notes, len(w.claim) - before
  665. }
  666. // SampleHex 把样例页的头 64B 格式化成 hex 行。
  667. func SampleHex(p PageInfo) string {
  668. return hex.Dump(p.Head64)
  669. }
  670. // SortedTypes 直方图按类型名排序输出。
  671. func SortedTypes(m map[string]int) []string {
  672. ks := make([]string, 0, len(m))
  673. for k := range m {
  674. ks = append(ks, k)
  675. }
  676. sort.Strings(ks)
  677. return ks
  678. }