indexcheck.go 32 KB

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