audit.go 44 KB

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