indexcheck.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  1. package main
  2. // ============================================================================
  3. // 索引内容一致性 —— 页归属只能看出"页放错了地方", 看不出"页没放错、内容却错位"。
  4. //
  5. // 对照 SQLite 的 PRAGMA integrity_check (逐条比对"索引项 ↔ 表行") 与
  6. // PG 的 amcheck (校验 B+ 树键序 / 分隔键), 这里补上三条:
  7. //
  8. // (1) 键序单调 条目键必须按引擎的查找前提有序 —— 见下方"检查范围"。
  9. // (2) 悬空二级索引 二级索引条目尾 8B 是 pkID, 必须能在本表数据页里回表到
  10. // "存活行"; 否则就是指向已不存在行的索引项 (删行 / 重建
  11. // 索引漏删的典型残留)。
  12. // (3) 索引指向非数据页 行存储索引(主键 / 时序)条目尾 4B 是 DataPageID,
  13. // 该页类型必须真的是数据页 (DataIndex=2)。
  14. // (4) 反向缺失 每个"存活行"都必须在每个索引里留下自己的条目。
  15. // 前三条只证明"索引里没有废条目", 证明不了"该有的条目都在":
  16. // 更新时"删旧键/插新键"只做了一半、或建索引漏了一批行,
  17. // 只要不留废条目就完全静默 —— 必须反过来从行出发查一次。
  18. //
  19. // (4) 需要从行字节反算"这一行在某索引里该有的键", 所以复刻了引擎的取键函数
  20. // DB/core/IndexMetaPage.go:extractIndexKey (按 FieldIDs 顺序定长拼接分量字段,
  21. // 行内偏移按该行自己的 schemaVer 沿 sv.FieldIndices 推)。返回 nil 的语义也一致:
  22. // 分量列在该行版本里缺失 → 引擎本来就跳过这一行, 我们也不能判它缺失。
  23. //
  24. // 条目布局逐条对照引擎 handler, 不做推测:
  25. // Primary (btree) 叶 [pkID(8B BE)][DataPageID(4B BE)] el=12
  26. // Time (扁平链) 叶 [BeginTime(8B)][LastTime(8B)][PageID(4B)] el=20
  27. // —— timedatapage.findDataPageByTime
  28. // BTree/Str/Compose 叶 [key(N*8B)][pkID(8B BE)] el=N*8+8
  29. // —— compose_handler / composepage.Insert
  30. // Hash (桶链) 条目 [key(keyLen)][pkID(8B BE, 最高位=删除标记)]
  31. // —— hash_handler 桶页 [PageHdr(20)][BucketID(4)][Count(4)]
  32. //
  33. // "有序"这条前提是被引擎的查找算法强行要求的, 不是我们的审美:
  34. // btree 按分隔键下行 → 叶层全局有序;
  35. // composepage.Insert 二分定位插入点 → 页内有序;
  36. // timedatapage.findDataPageByTime 对页内条目二分 → 页内按 BeginTime 有序。
  37. // 因此检查范围按"算法真正依赖的范围"划定:
  38. // B+ 树 (Primary/BTree/Str): 页内 + 跨页叶链都查;
  39. // Time / Compose: 只查页内 (Compose 的链是"填满一页再下一页",
  40. // 晚到的小键会落到后面的页, 跨页并不保证全局有序);
  41. // Hash: 桶内无序, 不查。
  42. //
  43. // 注意: 时序条目的 LastTime 会随追加而增长, 所以键只取 BeginTime 前 8 字节。
  44. // ============================================================================
  45. import (
  46. "bytes"
  47. "encoding/binary"
  48. "fmt"
  49. "hash/maphash"
  50. "runtime"
  51. "sort"
  52. "sync"
  53. )
  54. // ===== 并行设施 =====
  55. // runParallel 并发跑 n 个互不相干的任务 (参数是任务下标)。
  56. // 并发度 = min(n, NumCPU); n<=1 时直接顺序跑, 不额外起 goroutine。
  57. func runParallel(n int, fn func(int)) {
  58. if n <= 0 {
  59. return
  60. }
  61. w := runtime.NumCPU()
  62. if w > n {
  63. w = n
  64. }
  65. if w <= 1 {
  66. for i := 0; i < n; i++ {
  67. fn(i)
  68. }
  69. return
  70. }
  71. var wg sync.WaitGroup
  72. jobs := make(chan int, n)
  73. for i := 0; i < n; i++ {
  74. jobs <- i
  75. }
  76. close(jobs)
  77. wg.Add(w)
  78. for k := 0; k < w; k++ {
  79. go func() {
  80. defer wg.Done()
  81. for i := range jobs {
  82. fn(i)
  83. }
  84. }()
  85. }
  86. wg.Wait()
  87. }
  88. // ===== 条目存在性集合 =====
  89. // pairSet 二级索引的"条目存在集"。
  90. //
  91. // 原来用 map[string]bool, 键是整条条目字节。千万行级别下这就是 4000 万次字符串
  92. // 分配 —— 既吃内存又让 GC 反复扫描 (string 含指针, 整个 map 都在 GC 的扫描范围内)。
  93. // 换成定长指纹: 键不含指针, GC 不用管, 也不再为每条分配字符串。
  94. //
  95. // 指纹取 64 位而非 128 位: Go map 的桶内存按 sizeof(key) 线性增长, [2]uint64 桶
  96. // 每桶约 152B / 装 6.5 条 ≈ 23B/条, 换成 uint64 后约 88B / 6.5 ≈ 13.5B/条 ——
  97. // 三个千万级二级索引合计省下约 300MB。代价是理论上会指纹碰撞 (表现为"漏报一条
  98. // missing-entry"): 一千万条时单次查询碰撞率约 1e-7, 对审计结论无实质影响。
  99. type pairSet map[uint64]bool
  100. var pairSeedA = maphash.MakeSeed()
  101. // pairKey 条目字节 -> 64 位指纹 (前向侧: 条目本身就是 键||pkID(8B BE))。
  102. func pairKey(ent []byte) uint64 {
  103. return maphash.Bytes(pairSeedA, ent)
  104. }
  105. // pairKey2 (键, pkID) -> 64 位指纹, 语义等于 pairKey(键||pkID大端8字节),
  106. // 但不真的拼出中间字节串 —— 反向检查每行每索引都要算一次, 省掉上亿次分配。
  107. func pairKey2(key []byte, pk uint64) uint64 {
  108. var h maphash.Hash
  109. h.SetSeed(pairSeedA)
  110. var b8 [8]byte
  111. binary.BigEndian.PutUint64(b8[:], pk)
  112. h.Write(key)
  113. h.Write(b8[:])
  114. return h.Sum64()
  115. }
  116. // IndexIssue 一条索引内容一致性问题。
  117. type IndexIssue struct {
  118. Table string `json:"table"`
  119. Index string `json:"index"`
  120. IType string `json:"itype"`
  121. Kind string `json:"kind"` // out-of-order / orphan-pk / not-data-page
  122. Page uint32 `json:"page"`
  123. At int `json:"at"` // 页内条目序号
  124. Detail string `json:"detail"`
  125. }
  126. // IndexStat 单个索引的扫描统计。
  127. type IndexStat struct {
  128. Table string `json:"table"`
  129. Index string `json:"index"`
  130. IType string `json:"itype"`
  131. Entries int `json:"entries"` // 扫到的条目数
  132. Issues int `json:"issues"`
  133. }
  134. const idxIssueCap = 200
  135. func idxTypeName(it byte) string {
  136. switch it {
  137. case IT_TIME:
  138. return "Time"
  139. case IT_PRIMARY:
  140. return "Primary"
  141. case IT_STR:
  142. return "Str"
  143. case IT_COMPOSE:
  144. return "Compose"
  145. case IT_BTREE:
  146. return "BTree"
  147. case IT_HASH:
  148. return "Hash"
  149. }
  150. return fmt.Sprintf("?%d", it)
  151. }
  152. // idxEntryLenOf 读索引页的条目长度 (IndexHead.EntryLen); 非法时按主键叶条目 (12) 兜底。
  153. func idxEntryLenOf(b []byte) int {
  154. el := int(le16(b[IX_ELEN : IX_ELEN+2]))
  155. if el <= 4 {
  156. el = 12
  157. }
  158. return el
  159. }
  160. // idxKeyLenOf 该索引在"某页 el 长度"下, 键占的字节数 (0 = 不适用/不检查)。
  161. func idxKeyLenOf(it byte, el int) int {
  162. switch it {
  163. case IT_TIME:
  164. return 8 // 只取 BeginTime: LastTime 会随追加增长, 不是稳定键
  165. case IT_PRIMARY:
  166. return el - 4
  167. case IT_BTREE, IT_STR, IT_COMPOSE:
  168. return el - 8
  169. }
  170. return 0
  171. }
  172. // pageChainOf 顺 PageHdr.NextPage 走一条页链 (自带防环)。
  173. func pageChainOf(start uint32) []uint32 {
  174. var out []uint32
  175. seen := map[uint32]bool{}
  176. pb := newPageBuf()
  177. for pid := start; pid != 0 && !seen[pid]; {
  178. seen[pid] = true
  179. out = append(out, pid)
  180. b, e := pb.page(pid)
  181. if e != nil {
  182. break
  183. }
  184. pid = le32(b[PH_NEXT : PH_NEXT+4])
  185. }
  186. return out
  187. }
  188. // idxLeafChain 返回索引叶页 (按键的逻辑顺序)。
  189. // - B+ 树: 从根沿"每个内部节点的第 0 个子页"下行到最左叶, 再顺 NextPage 走完叶层。
  190. // 引擎的分隔键不变式是"父条目键 == 子树的键下界", 所以第 0 个条目的子页就是最左子树。
  191. // - 扁平链 (Time/Compose): 直接顺 NextPage 走。
  192. // - Hash: 无叶层, 返回 nil (另走桶链)。
  193. func idxLeafChain(it byte, entry uint32) []uint32 {
  194. if entry == 0 || it == IT_HASH {
  195. return nil
  196. }
  197. if !isBTreeIdx(it) {
  198. return pageChainOf(entry)
  199. }
  200. pb := newPageBuf()
  201. cur := entry
  202. for depth := 0; depth <= 64; depth++ {
  203. b, e := pb.page(cur)
  204. if e != nil || len(b) < IDX_HDR || b[0] != PG_IDATA {
  205. return nil
  206. }
  207. if b[IX_LEAF] == 1 {
  208. return pageChainOf(cur)
  209. }
  210. if cn := int(le32(b[IX_CNT : IX_CNT+4])); cn <= 0 {
  211. return nil
  212. }
  213. el := idxEntryLenOf(b)
  214. if IDX_HDR+el > len(b) {
  215. return nil
  216. }
  217. next := be32(b[IDX_HDR+el-4 : IDX_HDR+el])
  218. if next == 0 {
  219. return nil
  220. }
  221. cur = next
  222. }
  223. return nil
  224. }
  225. // hashBucketPages 枚举 Hash 索引的全部桶页: 元数据页 -> 桶目录链 -> 每槽 head -> 桶溢出链。
  226. func hashBucketPages(metaPID uint32) []uint32 {
  227. // 目录页与桶页各用一只缓冲: 桶页是嵌在目录页扫描循环里读的, 共用一只会把
  228. // 目录页内容冲掉 (后面还要从它取 NextPage)。
  229. dirBuf := newPageBuf()
  230. bktBuf := newPageBuf()
  231. meta, e := dirBuf.page(metaPID)
  232. if e != nil || len(meta) < HM_KEYLEN+4 || le32(meta[HM_MAGIC:HM_MAGIC+4]) != HASH_MAGIC {
  233. return nil
  234. }
  235. var out []uint32
  236. dirSeen := map[uint32]bool{}
  237. bucketSeen := map[uint32]bool{}
  238. limit := PHDR + ((PGSZ-PHDR)/HASH_SLOT)*HASH_SLOT
  239. for dir := le32(meta[HM_DIR : HM_DIR+4]); dir != 0 && !dirSeen[dir]; {
  240. dirSeen[dir] = true
  241. b, e := dirBuf.page(dir)
  242. if e != nil {
  243. break
  244. }
  245. for o := PHDR; o+HASH_SLOT <= limit; o += HASH_SLOT {
  246. p := le32(b[o : o+4])
  247. for p != 0 && !bucketSeen[p] {
  248. bucketSeen[p] = true
  249. out = append(out, p)
  250. bb, e := bktBuf.page(p)
  251. if e != nil {
  252. break
  253. }
  254. p = le32(bb[PH_NEXT : PH_NEXT+4])
  255. }
  256. }
  257. dir = le32(b[PH_NEXT : PH_NEXT+4])
  258. }
  259. return out
  260. }
  261. // eachRow 遍历一页里的存活行, 回调 (pkID, 行数据页内偏移, 行数据长度)。
  262. // 行布局对照 index/datapage.nextRow: [rawLen(4B LE)][storeRow],
  263. // rawLen 最高位 = 软删除; storeRow[:8] = pkID (BigEndian), 其后
  264. // rowData = [schemaVer(1B)][field_data...] —— 正好是引擎 extractIndexKey 的入参。
  265. func eachRow(b []byte, fn func(pk uint64, off, ln int)) {
  266. if len(b) < DP_HDR || b[0] != PG_DATA {
  267. return
  268. }
  269. end := int(le32(b[PH_USE : PH_USE+4]))
  270. if end > len(b) || end < DP_HDR {
  271. end = len(b)
  272. }
  273. for off := DP_HDR; off+4 <= end; {
  274. raw := le32(b[off : off+4])
  275. rl := int(raw &^ 0x80000000)
  276. start := off + 4
  277. if rl <= 0 || start+rl > end {
  278. break
  279. }
  280. if raw&0x80000000 == 0 && rl >= 8 {
  281. fn(binary.BigEndian.Uint64(b[start:start+8]), start+8, rl-8)
  282. }
  283. off = start + rl
  284. }
  285. }
  286. // collectLiveRows 读一张表的全部数据页, 收集"存活行"的 pkID 集合。
  287. //
  288. // 只收 pkID, 不再额外记行数据位置 (原 map[uint64]liveRow 在千万行级别要占几百 MB,
  289. // 且整体落入 GC 扫描范围): 反向缺失检查现在直接按数据页现读现算, 见 checkReverseMissing。
  290. func collectLiveRows(pages []uint32) map[uint64]bool {
  291. // 先探前 64 页估出行密度, 给 map 一个靠谱的初始容量。
  292. // 千万级 map 反复扩容 (每轮都要把已有条目全部重新哈希) 的开销远大于这次探测。
  293. pb := newPageBuf()
  294. hint := 0
  295. probe := len(pages)
  296. if probe > 64 {
  297. probe = 64
  298. }
  299. for i := 0; i < probe; i++ {
  300. if b, e := pb.page(pages[i]); e == nil {
  301. eachRow(b, func(uint64, int, int) { hint++ })
  302. }
  303. }
  304. if probe > 0 && len(pages) > probe {
  305. hint = hint * len(pages) / probe
  306. }
  307. set := make(map[uint64]bool, hint)
  308. for _, pid := range pages {
  309. b, e := pb.page(pid)
  310. if e != nil {
  311. continue
  312. }
  313. eachRow(b, func(pk uint64, off, ln int) { set[pk] = true })
  314. }
  315. return set
  316. }
  317. // idxIssueMu 保护 R 上的计数器与问题列表。
  318. // 前向/反向检查现在是并行跑的: 每个索引自己的 st 由该任务独占, 但汇总器 R 共享。
  319. // 只在"发现问题"这条冷路径上加锁, 热路径 (逐条扫描) 不加锁。
  320. var idxIssueMu sync.Mutex
  321. func addIdxIssue(R *AuditResult, st *IndexStat, is IndexIssue) {
  322. idxIssueMu.Lock()
  323. defer idxIssueMu.Unlock()
  324. switch is.Kind {
  325. case "out-of-order":
  326. R.IdxBadOrder++
  327. case "orphan-pk":
  328. R.IdxOrphanPK++
  329. case "not-data-page":
  330. R.IdxNotDataPage++
  331. case "missing-row":
  332. R.IdxMissingRow++
  333. case "missing-entry":
  334. R.IdxMissingEntry++
  335. }
  336. st.Issues++
  337. if len(R.IdxIssues) < idxIssueCap {
  338. R.IdxIssues = append(R.IdxIssues, is)
  339. } else {
  340. R.IdxIssueTrunc = true
  341. }
  342. }
  343. // checkIndexContent 逐表逐索引做内容一致性检查, 结果写回 R。
  344. func checkIndexContent(R *AuditResult, w *walker, ts []TInfo) {
  345. // 未恢复的 WAL 说明盘上不是最终状态, 内容比对会系统性误报 —— 明确跳过并说明。
  346. if R.Reach.Wal > 0 {
  347. R.IdxSkipped = "检测到未恢复的 WAL 链, 盘上不是最终状态 —— 索引内容一致性校验已跳过 (先让引擎跑完恢复)"
  348. return
  349. }
  350. // 预聚合: 表名 -> 该表的数据页。
  351. // 数据页可能由两条路径认领: 数据链 (owner = 表名) 与行存储索引叶 (owner = 表名.索引名),
  352. // ownerTable 归一后取并集, 保证"存活行 pkID 集合"完整。
  353. dataPages := map[string][]uint32{}
  354. for pid, o := range w.owner {
  355. if w.role[pid] == "data" {
  356. tn := ownerTable(o)
  357. dataPages[tn] = append(dataPages[tn], pid)
  358. }
  359. }
  360. for i := range ts {
  361. t := &ts[i]
  362. if len(t.Idxs) == 0 {
  363. continue
  364. }
  365. // 行存储索引决定"行数据"的物理布局: 主键 -> DataPage (布局已知);
  366. // 时序 -> TimeDataPage (布局不同, 本文件不解析)。时序表下"存活行集合"
  367. // 会算成空集, 拿空集去比会把整表二级索引条目全判成悬空 —— 必须整体跳过。
  368. rowStore := byte(0)
  369. for _, idx := range t.Idxs {
  370. if isRowStore(idx.Type) {
  371. if rowStore == 0 || idx.Type == IT_PRIMARY {
  372. rowStore = idx.Type
  373. }
  374. }
  375. }
  376. primaryOK := rowStore == IT_PRIMARY
  377. // 存活行 pkID 集合 (建一次, 全索引复用): 前向扫索引时用来判"悬空 pk"。
  378. // 行数据本体不再另外收集 —— 反向检查按数据页现读现算 (见 checkReverseMissing)。
  379. var live map[uint64]bool
  380. if primaryOK {
  381. live = collectLiveRows(dataPages[t.Name])
  382. }
  383. ctx := make(map[string]*idxCtx, len(t.Idxs))
  384. primaryName := ""
  385. // 每个索引都有自己的 st/col (live 只读), 互不相干 —— 并行扫。
  386. // emp 那种一张表 4 个千万级索引的情形, 这一步就是整个审计最耗时的部分,
  387. // 顺序跑等于把多核晾着。
  388. type fwdTask struct {
  389. idx IInfo
  390. st *IndexStat
  391. col *idxEntries
  392. }
  393. tasks := make([]fwdTask, len(t.Idxs))
  394. for k := range t.Idxs {
  395. idx := t.Idxs[k]
  396. st := &IndexStat{Table: t.Name, Index: idx.Name, IType: idxTypeName(idx.Type)}
  397. col := &idxEntries{it: idx.Type}
  398. switch {
  399. case idx.Type == IT_PRIMARY:
  400. col.pks = map[uint64]bool{}
  401. case idx.Type != IT_TIME:
  402. col.pairs = pairSet{}
  403. }
  404. tasks[k] = fwdTask{idx: idx, st: st, col: col}
  405. }
  406. runParallel(len(tasks), func(k int) {
  407. tk := tasks[k]
  408. if tk.idx.Type == IT_HASH {
  409. checkHashEntries(R, t.Name, tk.idx, tk.st, live, tk.col)
  410. } else {
  411. checkOneIndex(R, t.Name, tk.idx, tk.st, live, tk.col)
  412. }
  413. })
  414. for _, tk := range tasks {
  415. if tk.idx.Type == IT_PRIMARY {
  416. primaryName = tk.idx.Name
  417. }
  418. ctx[tk.idx.Name] = &idxCtx{info: tk.idx, stat: tk.st, set: tk.col}
  419. }
  420. // (4) 反向缺失: 行 -> 索引项。必须在前向扫完、集合齐了之后再做。
  421. // 主键方向 (行有没有掉出 B+ 树) 只要 pkID 集合, 没有二级索引的表 (只有主键,
  422. // 恰恰是数据量最大的那类) 也必须查 —— 那是最致命的一种缺失。
  423. if primaryOK && primaryName != "" {
  424. checkReverseMissing(R, t.Name, t, dataPages[t.Name], primaryName, ctx)
  425. } else if !primaryOK {
  426. R.IdxNotes = append(R.IdxNotes,
  427. fmt.Sprintf("%s: 行存储不是主键索引 (索引类型 %s, 行在 TimeDataPage), 反向缺失校验已跳过",
  428. t.Name, idxTypeName(rowStore)))
  429. }
  430. // 统计汇总放在最后: 反向检查也会往 st.Issues 上加。
  431. for _, idx := range t.Idxs {
  432. c := ctx[idx.Name]
  433. if c == nil {
  434. continue
  435. }
  436. R.IdxEntries += c.stat.Entries
  437. R.IdxStats = append(R.IdxStats, *c.stat)
  438. }
  439. }
  440. // 并行收集时问题的追加顺序随调度而变, 排一遍让报告可复现。
  441. sort.SliceStable(R.IdxIssues, func(a, b int) bool {
  442. x, y := &R.IdxIssues[a], &R.IdxIssues[b]
  443. if x.Table != y.Table {
  444. return x.Table < y.Table
  445. }
  446. if x.Index != y.Index {
  447. return x.Index < y.Index
  448. }
  449. if x.Page != y.Page {
  450. return x.Page < y.Page
  451. }
  452. if x.At != y.At {
  453. return x.At < y.At
  454. }
  455. return x.Kind < y.Kind
  456. })
  457. }
  458. // checkOneIndex 校验单个非 Hash 索引。
  459. // col != nil 时顺带把"条目存在性"记进去, 供反向缺失检查复用 —— 不重复扫一遍盘。
  460. func checkOneIndex(R *AuditResult, table string, idx IInfo, st *IndexStat, live map[uint64]bool, col *idxEntries) {
  461. it := idx.Type
  462. crossPage := isBTreeIdx(it) // 只有 B+ 树保证叶层全局有序 (见文件头)
  463. var inPage []byte // 页内前一条键 (每页重置)
  464. var lastOfPrevPage []byte
  465. leafBuf := newPageBuf() // 叶页迭代缓冲
  466. refBuf := newPageBuf() // 条目引用页缓冲 (与叶页分开, 否则会把正在遍历的叶页冲掉)
  467. lastRef := uint32(0) // 连续条目常指向同一数据页 —— 记住上一次, 免重复读
  468. lastRefRead := false
  469. lastRefBad := false
  470. lastRefType := byte(0)
  471. for _, lp := range idxLeafChain(it, idx.EP) {
  472. b, e := leafBuf.page(lp)
  473. if e != nil || len(b) < IDX_HDR || b[0] != PG_IDATA {
  474. continue
  475. }
  476. el := idxEntryLenOf(b)
  477. cn := int(le32(b[IX_CNT : IX_CNT+4]))
  478. keyLen := idxKeyLenOf(it, el)
  479. if cn <= 0 || el <= 4 || keyLen <= 0 || keyLen > el {
  480. continue
  481. }
  482. inPage = inPage[:0]
  483. for k := 0; k < cn; k++ {
  484. o := IDX_HDR + k*el
  485. if o+el > len(b) {
  486. break
  487. }
  488. ent := b[o : o+el]
  489. st.Entries++
  490. key := ent[:keyLen]
  491. // 顺带收集条目存在性 (反向缺失用)。
  492. if col != nil {
  493. if it == IT_PRIMARY {
  494. col.pks[binary.BigEndian.Uint64(key)] = true
  495. } else if col.pairs != nil {
  496. // TIME 索引不参与反向缺失校验 (条目里的 LastTime 随追加增长、不是稳定键),
  497. // 因此创建 idxEntries 时故意没有为它分配 pairs —— 这里必须判空,
  498. // 否则往 nil map 写入会 panic (曾导致打开 orm_full.db 时分析器进程崩溃)。
  499. col.pairs[pairKey(ent)] = true // 条目字节本身就是 key||pkID(8B BE)
  500. }
  501. }
  502. // (1) 键序。
  503. switch {
  504. case len(inPage) > 0:
  505. if bytes.Compare(inPage, key) > 0 {
  506. addIdxIssue(R, st, IndexIssue{
  507. Table: table, Index: idx.Name, IType: idxTypeName(it),
  508. Kind: "out-of-order", Page: lp, At: k,
  509. Detail: fmt.Sprintf("页内键序回退: 前一条 %s > 本条 %s", hexKey(inPage), hexKey(key)),
  510. })
  511. }
  512. case crossPage && len(lastOfPrevPage) > 0:
  513. if bytes.Compare(lastOfPrevPage, key) > 0 {
  514. addIdxIssue(R, st, IndexIssue{
  515. Table: table, Index: idx.Name, IType: idxTypeName(it),
  516. Kind: "out-of-order", Page: lp, At: k,
  517. Detail: fmt.Sprintf("跨页键序回退: 上一叶末条 %s > 本叶首条 %s", hexKey(lastOfPrevPage), hexKey(key)),
  518. })
  519. }
  520. }
  521. inPage = append(inPage[:0], key...)
  522. // (3) 行存储索引: 尾 4B 是 DataPageID, 必须真的是数据页。
  523. if isRowStore(it) {
  524. if ref := be32(ent[el-4 : el]); ref != 0 {
  525. if !lastRefRead || ref != lastRef {
  526. rb, e := refBuf.page(ref)
  527. lastRef, lastRefRead = ref, true
  528. lastRefBad = e == nil && len(rb) > 0 && rb[0] != PG_DATA
  529. if lastRefBad {
  530. lastRefType = rb[0]
  531. }
  532. }
  533. if lastRefBad {
  534. addIdxIssue(R, st, IndexIssue{
  535. Table: table, Index: idx.Name, IType: idxTypeName(it),
  536. Kind: "not-data-page", Page: lp, At: k,
  537. Detail: fmt.Sprintf("条目引用的页 %d 实际类型是 %s, 不是数据页",
  538. ref, pageTypeName(int(lastRefType))),
  539. })
  540. }
  541. }
  542. continue
  543. }
  544. // (2) 二级索引: 尾 8B 是 pkID, 必须能在本表回表到存活行。
  545. if live != nil {
  546. pk := binary.BigEndian.Uint64(ent[el-8 : el])
  547. if !live[pk] {
  548. addIdxIssue(R, st, IndexIssue{
  549. Table: table, Index: idx.Name, IType: idxTypeName(it),
  550. Kind: "orphan-pk", Page: lp, At: k,
  551. Detail: fmt.Sprintf("二级索引条目指向 pkID=%d 的行, 但该表数据页里没有这一行", pk),
  552. })
  553. }
  554. }
  555. }
  556. if crossPage {
  557. lastOfPrevPage = append(lastOfPrevPage[:0], inPage...)
  558. }
  559. }
  560. }
  561. // checkHashEntries 校验 Hash 索引桶条目 (桶内无序, 只查悬空 pkID)。
  562. func checkHashEntries(R *AuditResult, table string, idx IInfo, st *IndexStat, live map[uint64]bool, col *idxEntries) {
  563. meta, e := readPage(idx.EP)
  564. if e != nil || len(meta) < HM_KEYLEN+4 || le32(meta[HM_MAGIC:HM_MAGIC+4]) != HASH_MAGIC {
  565. return
  566. }
  567. keyLen := int(le32(meta[HM_KEYLEN : HM_KEYLEN+4]))
  568. el := keyLen + 8
  569. if keyLen <= 0 || el > PGSZ-HASH_BUCKET_HDR {
  570. return // 元数据不可信: 不猜 (页归属 / 交叉引用已覆盖结构问题)
  571. }
  572. pb := newPageBuf()
  573. for _, bp := range hashBucketPages(idx.EP) {
  574. b, e := pb.page(bp)
  575. if e != nil || len(b) < HASH_BUCKET_HDR {
  576. continue
  577. }
  578. n := 0
  579. if us := int(le32(b[PH_USE : PH_USE+4])); us >= HASH_BUCKET_HDR && us <= len(b) {
  580. n = (us - HASH_BUCKET_HDR) / el
  581. } else if c := int(le32(b[24:28])); c > 0 {
  582. n = c // 退化: 用桶页 Count 推算
  583. }
  584. for k := 0; k < n; k++ {
  585. o := HASH_BUCKET_HDR + k*el
  586. if o+el > len(b) {
  587. break
  588. }
  589. st.Entries++
  590. pk := binary.BigEndian.Uint64(b[o+keyLen : o+el])
  591. if pk&(1<<63) != 0 {
  592. continue // 已删除条目 (hashDeletedFlag)
  593. }
  594. if col != nil {
  595. col.pairs[pairKey2(b[o:o+keyLen], pk)] = true
  596. }
  597. if live != nil && !live[pk] {
  598. addIdxIssue(R, st, IndexIssue{
  599. Table: table, Index: idx.Name, IType: idxTypeName(idx.Type),
  600. Kind: "orphan-pk", Page: bp, At: k,
  601. Detail: fmt.Sprintf("Hash 条目指向 pkID=%d 的行, 但该表数据页里没有这一行", pk),
  602. })
  603. }
  604. }
  605. }
  606. }
  607. // hexKey 把条目键按十六进制显示 (最多 16 字节)。
  608. func hexKey(k []byte) string {
  609. if len(k) > 16 {
  610. k = k[:16]
  611. }
  612. return "0x" + fmt.Sprintf("%x", k)
  613. }
  614. // ============================================================================
  615. // 反向缺失 (行 -> 索引项)
  616. // ============================================================================
  617. // idxEntries 一个索引里"已存在哪些条目"。
  618. // 前向扫盘时顺手记下, 反向检查直接查表, 不再扫第二遍。
  619. type idxEntries struct {
  620. it byte
  621. pks map[uint64]bool // 主键索引: 条目键就是 pkID
  622. pairs pairSet // 二级索引: key||pkID(8B BE) 的 64 位指纹集
  623. }
  624. // idxCtx 一个索引的三件套 (元信息 / 统计 / 条目集合)。
  625. type idxCtx struct {
  626. info IInfo
  627. stat *IndexStat
  628. set *idxEntries
  629. }
  630. // keyScratch 复算索引键时的复用暂存: 分量表与结果缓冲都复用, 避免"每行每索引"
  631. // 都分配 seg/key 两份切片 (千万行级别下这是 GB 级垃圾, 堆剖析里就落在这一处)。
  632. type keyScratch struct {
  633. seg [][]byte
  634. key []byte
  635. }
  636. // extract 复刻引擎 DB/core/IndexMetaPage.go:extractIndexKey。
  637. //
  638. // rowData = [schemaVer(1B)][field_data...][NULL 位图]; 键 = 按 FieldIDs 顺序定长拼接
  639. // 各分量字段的原始字节。行内偏移按该行自己的 schemaVer 沿 sv.Indices (即引擎的
  640. // FieldIndices) 推。
  641. //
  642. // 返回 nil 与引擎同义 —— "这一行在该版本里凑不齐分量列", 引擎会跳过它, 因此
  643. // 反向检查也必须跳过, 否则会把合法跳过的行误报成缺失。
  644. //
  645. // 返回的切片由 scratch 持有, 下次调用即被覆盖 —— 调用方必须立即用掉, 不得留存。
  646. func (s *keyScratch) extract(rowData []byte, idx IInfo, t *TInfo) []byte {
  647. n := len(idx.Flds)
  648. if n == 0 || len(rowData) < 1 {
  649. return nil
  650. }
  651. if n > 16 { // 引擎 FieldIDs 上限 16
  652. return nil
  653. }
  654. ver := rowData[0]
  655. var sv *VInfo
  656. for i := range t.Versions {
  657. if t.Versions[i].Ver == ver {
  658. sv = &t.Versions[i]
  659. break
  660. }
  661. }
  662. if sv == nil {
  663. return nil
  664. }
  665. if cap(s.seg) < n {
  666. s.seg = make([][]byte, n)
  667. }
  668. seg := s.seg[:n]
  669. for i := range seg {
  670. seg[i] = nil // 清掉上一行的残留, 否则结果键会把旧分量算进来
  671. }
  672. // NULL 位图【在行尾】(同引擎布局), 长度按该行自己的 schemaVer 算
  673. // (同引擎 types.NullBitmapLen), 位 i 对应 sv.Indices[i]。放行尾是引擎的强约束:
  674. // 字段区起点恒为偏移 1 (== [schemaVer 1B]), 时序索引才能按固定偏移 9 读首列时间键。
  675. // 分量列为 NULL 时引擎不把它编进索引键 (整行不进该索引) —— 这里也返回 nil,
  676. // 否则会把"合法不进索引的行"误报成缺失。
  677. bitmapLen := (len(sv.Indices) + 7) / 8
  678. fieldArea := 0
  679. for _, fi := range sv.Indices {
  680. if fi >= len(t.MasterFields) {
  681. return nil
  682. }
  683. if t.MasterFields[fi].Sz <= 0 { // 未知类型: 长度算不出来, 不猜
  684. return nil
  685. }
  686. fieldArea += t.MasterFields[fi].Sz
  687. }
  688. if fieldArea+bitmapLen > len(rowData)-1 {
  689. return nil
  690. }
  691. bitmap := rowData[1+fieldArea : 1+fieldArea+bitmapLen]
  692. off := 1
  693. need := n
  694. for i, fi := range sv.Indices {
  695. size := t.MasterFields[fi].Sz
  696. for k := 0; k < n; k++ {
  697. if idx.Flds[k] != fi {
  698. continue
  699. }
  700. if bitmap[i>>3]&(1<<uint(i&7)) != 0 {
  701. return nil // 分量是 NULL → 该行不进本索引
  702. }
  703. if off+size > 1+fieldArea {
  704. return nil
  705. }
  706. seg[k] = rowData[off : off+size]
  707. need--
  708. break
  709. }
  710. off += size
  711. if need == 0 {
  712. break
  713. }
  714. }
  715. if need != 0 {
  716. return nil
  717. }
  718. total := 0
  719. for k := 0; k < n; k++ {
  720. total += len(seg[k])
  721. }
  722. if cap(s.key) < total {
  723. s.key = make([]byte, 0, total)
  724. }
  725. key := s.key[:0]
  726. for k := 0; k < n; k++ {
  727. key = append(key, seg[k]...)
  728. }
  729. s.key = key
  730. return key
  731. }
  732. // pageLoc 输出用的" (数据页 N)" 后缀; 页号 0 表示未知。
  733. func pageLoc(page uint32) string {
  734. if page == 0 {
  735. return ""
  736. }
  737. return fmt.Sprintf(" (数据页 %d)", page)
  738. }
  739. // checkReverseMissing 反向缺失: 每个存活行都必须在每个索引里留下条目。
  740. //
  741. // 这一步补的是前三条补不上的那一半。前三条全是"索引项 -> 行"方向, 它们能证明
  742. // "索引里没有废条目", 但证明不了"该有的条目都在": 一行整体从某个索引里掉了
  743. // (更新时删旧键/插新键只做了一半、重建索引漏了一批行), 只要不留废条目,
  744. // 前三条一个都不会响 —— 必须从行出发反向查一次。
  745. //
  746. // 直接按数据页并行扫描: 不再预建 map[uint64]liveRow (千万行级别要占几百 MB, 且整体
  747. // 落入 GC 扫描范围)。行数据本就躺在数据页里, 这里现读现算; 每只 worker 只多占
  748. // 一只 16KB 页缓冲 + 一份 keyScratch。
  749. func checkReverseMissing(R *AuditResult, table string, t *TInfo, dataPages []uint32,
  750. primaryName string, ctx map[string]*idxCtx) {
  751. if len(dataPages) == 0 {
  752. return
  753. }
  754. // 反向检查是纯读 (各索引条目集在前向阶段已全部定型) + 计数回报, 按数据页切块并行;
  755. // 问题汇总由 addIdxIssue 里的锁保证安全。
  756. wk := runtime.NumCPU()
  757. if wk > len(dataPages) {
  758. wk = len(dataPages)
  759. }
  760. chunk := (len(dataPages) + wk - 1) / wk
  761. var wg sync.WaitGroup
  762. for k := 0; k < wk; k++ {
  763. lo := k * chunk
  764. hi := lo + chunk
  765. if lo >= len(dataPages) {
  766. break
  767. }
  768. if hi > len(dataPages) {
  769. hi = len(dataPages)
  770. }
  771. wg.Add(1)
  772. go func(dps []uint32) {
  773. defer wg.Done()
  774. pb := newPageBuf()
  775. var sc keyScratch
  776. for _, page := range dps {
  777. revScanPage(R, table, t, page, primaryName, ctx, pb, &sc)
  778. }
  779. }(dataPages[lo:hi])
  780. }
  781. wg.Wait()
  782. }
  783. // revScanPage 反向缺失检查的单页。
  784. func revScanPage(R *AuditResult, table string, t *TInfo, page uint32, primaryName string,
  785. ctx map[string]*idxCtx, pb *pageBuf, sc *keyScratch) {
  786. b, e := pb.page(page)
  787. if e != nil || len(b) < DP_HDR || b[0] != PG_DATA {
  788. return
  789. }
  790. loc := pageLoc(page)
  791. eachRow(b, func(pk uint64, off, ln int) {
  792. // (4a) 主键索引里必须能找到这一行自己。
  793. if c := ctx[primaryName]; c != nil && c.set != nil && c.set.pks != nil {
  794. if !c.set.pks[pk] {
  795. addIdxIssue(R, c.stat, IndexIssue{
  796. Table: table, Index: primaryName, IType: idxTypeName(c.info.Type),
  797. Kind: "missing-row", Page: page, At: -1,
  798. Detail: fmt.Sprintf("存活行 pkID=%d%s 在主键索引里没有条目 —— "+
  799. "这行已从 B+ 树掉出去, 按主键和任何索引都查不到它", pk, loc),
  800. })
  801. }
  802. }
  803. // (4b) 每个二级索引里都必须有该行的条目 (键用引擎同款算法复算)。
  804. rowData := b[off : off+ln]
  805. for _, idx := range t.Idxs {
  806. if isRowStore(idx.Type) {
  807. continue
  808. }
  809. c := ctx[idx.Name]
  810. if c == nil || c.set == nil || c.set.pairs == nil {
  811. continue
  812. }
  813. key := sc.extract(rowData, idx, t)
  814. if key == nil {
  815. continue // 引擎同样会跳过这一行 (分量列缺失 / 版本不符)
  816. }
  817. if !c.set.pairs[pairKey2(key, pk)] {
  818. addIdxIssue(R, c.stat, IndexIssue{
  819. Table: table, Index: idx.Name, IType: idxTypeName(idx.Type),
  820. Kind: "missing-entry", Page: page, At: -1,
  821. Detail: fmt.Sprintf("存活行 pkID=%d%s 在本索引里没有条目 (该有的键 %s) —— "+
  822. "按这个索引查这一行会查不到", pk, loc, hexKey(key)),
  823. })
  824. }
  825. }
  826. })
  827. }