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/index_meta_page.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. // —— timeindex.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. // timeindex.findDataPageByTime 对页内条目二分 → 页内按 BeginTime 有序。
  37. // 因此检查范围按"算法真正依赖的范围"划定:
  38. // B+ 树 (Primary/BTree/Str/Compose): 页内 + 跨页叶链都查;
  39. // Time: 只查页内 (分段链按时间有序追加, 页内二分定位);
  40. // Hash: 桶内无序, 不查。
  41. //
  42. // 注意: 时序条目的 LastTime 会随追加而增长, 所以键只取 BeginTime 前 8 字节。
  43. // ============================================================================
  44. import (
  45. "cmp"
  46. "bytes"
  47. "encoding/binary"
  48. "fmt"
  49. "hash/maphash"
  50. "runtime"
  51. "slices"
  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); 非法时按主键叶条目 (14) 兜底。
  153. func idxEntryLenOf(b []byte) int {
  154. el := int(le16(b[IX_ELEN : IX_ELEN+2]))
  155. if el <= 4 {
  156. el = 14 // v8 主键叶条目 = [key(8)+dpID(4)+slot(2)]
  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. // 主键叶条目两种布局的键都恰好是前 8 字节 (键 = pkID 的大端编码):
  167. // v8: [key(8) + dpID(4) + slot(2)] = 14B
  168. // v7: [key(8) + dpID(4)] = 12B
  169. // 所以不能再用 el-4 (v8 下会多算进 2 字节 dpID)。
  170. return 8
  171. case IT_BTREE, IT_STR, IT_COMPOSE:
  172. return el - 8
  173. }
  174. return 0
  175. }
  176. // rowStoreRefOff 行存储索引条目里 DataPageID 的偏移。
  177. //
  178. // 主键叶条目自 v8 起在 dpID 之后还有 2B 槽位号, dpID 固定在偏移 8..12, 不能用"末 4B";
  179. // 时序条目是 [BeginTime(8) + LastTime(8) + dpID(4)], dpID 仍在末 4B (LastTime 会随追加增长)。
  180. func rowStoreRefOff(it byte, el int) int {
  181. if it == IT_PRIMARY {
  182. return 8
  183. }
  184. return el - 4
  185. }
  186. // pageChainOf 顺 PageHdr.NextPage 走一条页链 (自带防环)。
  187. func pageChainOf(start uint32) []uint32 {
  188. var out []uint32
  189. seen := map[uint32]bool{}
  190. pb := newPageBuf()
  191. for pid := start; pid != 0 && !seen[pid]; {
  192. seen[pid] = true
  193. out = append(out, pid)
  194. b, e := pb.page(pid)
  195. if e != nil {
  196. break
  197. }
  198. pid = le32(b[PH_NEXT : PH_NEXT+4])
  199. }
  200. return out
  201. }
  202. // idxLeafChain 返回索引叶页 (按键的逻辑顺序)。
  203. // - B+ 树 (含 Compose —— 处理器底层即 SBT): 从根沿"每个内部节点的第 0 个子页"
  204. // 下行到最左叶, 再顺 NextPage 走完叶层。
  205. // 引擎的分隔键不变式是"父条目键 == 子树的键下界", 所以第 0 个条目的子页就是最左子树。
  206. // - 扁平链 (Time): 直接顺 NextPage 走。
  207. // - Hash: 无叶层, 返回 nil (另走桶链)。
  208. func idxLeafChain(it byte, entry uint32) []uint32 {
  209. if entry == 0 || it == IT_HASH {
  210. return nil
  211. }
  212. if !isBTreeIdx(it) {
  213. return pageChainOf(entry)
  214. }
  215. pb := newPageBuf()
  216. cur := entry
  217. for depth := 0; depth <= 64; depth++ {
  218. b, e := pb.page(cur)
  219. if e != nil || len(b) < IDX_HDR || b[0] != PG_IDATA {
  220. return nil
  221. }
  222. if b[IX_LEAF] == 1 {
  223. return pageChainOf(cur)
  224. }
  225. if cn := int(le32(b[IX_CNT : IX_CNT+4])); cn <= 0 {
  226. return nil
  227. }
  228. el := idxEntryLenOf(b)
  229. if IDX_HDR+el > len(b) {
  230. return nil
  231. }
  232. next := be32(b[IDX_HDR+el-4 : IDX_HDR+el])
  233. if next == 0 {
  234. return nil
  235. }
  236. cur = next
  237. }
  238. return nil
  239. }
  240. // hashBucketPages 枚举 Hash 索引的全部桶页: 元数据页 -> 桶目录链 -> 每槽 head -> 桶溢出链。
  241. func hashBucketPages(metaPID uint32) []uint32 {
  242. // 目录页与桶页各用一只缓冲: 桶页是嵌在目录页扫描循环里读的, 共用一只会把
  243. // 目录页内容冲掉 (后面还要从它取 NextPage)。
  244. dirBuf := newPageBuf()
  245. bktBuf := newPageBuf()
  246. meta, e := dirBuf.page(metaPID)
  247. if e != nil || len(meta) < HM_KEYLEN+4 || le32(meta[HM_MAGIC:HM_MAGIC+4]) != HASH_MAGIC {
  248. return nil
  249. }
  250. var out []uint32
  251. dirSeen := map[uint32]bool{}
  252. bucketSeen := map[uint32]bool{}
  253. limit := PHDR + ((PGSZ-PHDR)/HASH_SLOT)*HASH_SLOT
  254. for dir := le32(meta[HM_DIR : HM_DIR+4]); dir != 0 && !dirSeen[dir]; {
  255. dirSeen[dir] = true
  256. b, e := dirBuf.page(dir)
  257. if e != nil {
  258. break
  259. }
  260. for o := PHDR; o+HASH_SLOT <= limit; o += HASH_SLOT {
  261. p := le32(b[o : o+4])
  262. for p != 0 && !bucketSeen[p] {
  263. bucketSeen[p] = true
  264. out = append(out, p)
  265. bb, e := bktBuf.page(p)
  266. if e != nil {
  267. break
  268. }
  269. p = le32(bb[PH_NEXT : PH_NEXT+4])
  270. }
  271. }
  272. dir = le32(b[PH_NEXT : PH_NEXT+4])
  273. }
  274. return out
  275. }
  276. // eachRow 遍历一页里的存活行, 回调 (pkID, 行数据页内偏移, 行数据长度)。
  277. // 行布局对照 index/datapage.nextRow: [rawLen(4B LE)][storeRow],
  278. // rawLen 最高位 = 软删除; storeRow[:8] = pkID (BigEndian), 其后
  279. // rowData = [schemaVer(1B)][列0 W0 B][列结束偏移表 2N][列1..N-1 内容][NULL 位图]
  280. // —— 正好是引擎 extractIndexKey 的入参 (列位置由行内偏移表给出)。
  281. func eachRow(b []byte, fn func(pk uint64, off, ln int)) {
  282. if len(b) < DP_HDR || b[0] != PG_DATA {
  283. return
  284. }
  285. end := int(le32(b[PH_USE : PH_USE+4]))
  286. if end > len(b) || end < DP_HDR {
  287. end = len(b)
  288. }
  289. for off := DP_HDR; off+4 <= end; {
  290. raw := le32(b[off : off+4])
  291. rl := int(raw &^ 0x80000000)
  292. start := off + 4
  293. if rl <= 0 || start+rl > end {
  294. break
  295. }
  296. if raw&0x80000000 == 0 && rl >= 8 {
  297. fn(binary.BigEndian.Uint64(b[start:start+8]), start+8, rl-8)
  298. }
  299. off = start + rl
  300. }
  301. }
  302. // collectLiveRows 读一张表的全部数据页, 收集"存活行"的 pkID 集合。
  303. //
  304. // 只收 pkID, 不再额外记行数据位置 (原 map[uint64]liveRow 在千万行级别要占几百 MB,
  305. // 且整体落入 GC 扫描范围): 反向缺失检查现在直接按数据页现读现算, 见 checkReverseMissing。
  306. func collectLiveRows(pages []uint32) map[uint64]bool {
  307. // 先探前 64 页估出行密度, 给 map 一个靠谱的初始容量。
  308. // 千万级 map 反复扩容 (每轮都要把已有条目全部重新哈希) 的开销远大于这次探测。
  309. pb := newPageBuf()
  310. hint := 0
  311. probe := len(pages)
  312. if probe > 64 {
  313. probe = 64
  314. }
  315. for i := 0; i < probe; i++ {
  316. if b, e := pb.page(pages[i]); e == nil {
  317. eachRow(b, func(uint64, int, int) { hint++ })
  318. }
  319. }
  320. if probe > 0 && len(pages) > probe {
  321. hint = hint * len(pages) / probe
  322. }
  323. set := make(map[uint64]bool, hint)
  324. for _, pid := range pages {
  325. b, e := pb.page(pid)
  326. if e != nil {
  327. continue
  328. }
  329. eachRow(b, func(pk uint64, off, ln int) { set[pk] = true })
  330. }
  331. return set
  332. }
  333. // idxIssueMu 保护 R 上的计数器与问题列表。
  334. // 前向/反向检查现在是并行跑的: 每个索引自己的 st 由该任务独占, 但汇总器 R 共享。
  335. // 只在"发现问题"这条冷路径上加锁, 热路径 (逐条扫描) 不加锁。
  336. var idxIssueMu sync.Mutex
  337. func addIdxIssue(R *AuditResult, st *IndexStat, is IndexIssue) {
  338. idxIssueMu.Lock()
  339. defer idxIssueMu.Unlock()
  340. switch is.Kind {
  341. case "out-of-order":
  342. R.IdxBadOrder++
  343. case "orphan-pk":
  344. R.IdxOrphanPK++
  345. case "not-data-page":
  346. R.IdxNotDataPage++
  347. case "missing-row":
  348. R.IdxMissingRow++
  349. case "missing-entry":
  350. R.IdxMissingEntry++
  351. }
  352. st.Issues++
  353. if len(R.IdxIssues) < idxIssueCap {
  354. R.IdxIssues = append(R.IdxIssues, is)
  355. } else {
  356. R.IdxIssueTrunc = true
  357. }
  358. }
  359. // checkIndexContent 逐表逐索引做内容一致性检查, 结果写回 R。
  360. func checkIndexContent(R *AuditResult, w *walker, ts []TInfo) {
  361. // 未恢复的 WAL 说明盘上不是最终状态, 内容比对会系统性误报 —— 明确跳过并说明。
  362. if R.Reach.Wal > 0 {
  363. R.IdxSkipped = "检测到未恢复的 WAL 链, 盘上不是最终状态 —— 索引内容一致性校验已跳过 (先让引擎跑完恢复)"
  364. return
  365. }
  366. // 预聚合: 表名 -> 该表的数据页。
  367. // 数据页可能由两条路径认领: 数据链 (owner = 表名) 与行存储索引叶 (owner = 表名.索引名),
  368. // ownerTable 归一后取并集, 保证"存活行 pkID 集合"完整。
  369. dataPages := map[string][]uint32{}
  370. for pid, o := range w.owner {
  371. if w.role[pid] == "data" {
  372. tn := ownerTable(o)
  373. dataPages[tn] = append(dataPages[tn], pid)
  374. }
  375. }
  376. for i := range ts {
  377. t := &ts[i]
  378. if len(t.Idxs) == 0 {
  379. continue
  380. }
  381. // 行存储索引决定"行数据"的物理布局: 主键 -> DataPage (布局已知);
  382. // 时序 -> TimeDataPage (布局不同, 本文件不解析)。时序表下"存活行集合"
  383. // 会算成空集, 拿空集去比会把整表二级索引条目全判成悬空 —— 必须整体跳过。
  384. rowStore := byte(0)
  385. for _, idx := range t.Idxs {
  386. if isRowStore(idx.Type) {
  387. if rowStore == 0 || idx.Type == IT_PRIMARY {
  388. rowStore = idx.Type
  389. }
  390. }
  391. }
  392. primaryOK := rowStore == IT_PRIMARY
  393. // 存活行 pkID 集合 (建一次, 全索引复用): 前向扫索引时用来判"悬空 pk"。
  394. // 行数据本体不再另外收集 —— 反向检查按数据页现读现算 (见 checkReverseMissing)。
  395. var live map[uint64]bool
  396. if primaryOK {
  397. live = collectLiveRows(dataPages[t.Name])
  398. }
  399. ctx := make(map[string]*idxCtx, len(t.Idxs))
  400. primaryName := ""
  401. // 每个索引都有自己的 st/col (live 只读), 互不相干 —— 并行扫。
  402. // emp 那种一张表 4 个千万级索引的情形, 这一步就是整个审计最耗时的部分,
  403. // 顺序跑等于把多核晾着。
  404. type fwdTask struct {
  405. idx IInfo
  406. st *IndexStat
  407. col *idxEntries
  408. }
  409. tasks := make([]fwdTask, len(t.Idxs))
  410. for k := range t.Idxs {
  411. idx := t.Idxs[k]
  412. st := &IndexStat{Table: t.Name, Index: idx.Name, IType: idxTypeName(idx.Type)}
  413. col := &idxEntries{it: idx.Type}
  414. switch {
  415. case idx.Type == IT_PRIMARY:
  416. col.pks = map[uint64]bool{}
  417. case idx.Type != IT_TIME:
  418. col.pairs = pairSet{}
  419. }
  420. tasks[k] = fwdTask{idx: idx, st: st, col: col}
  421. }
  422. runParallel(len(tasks), func(k int) {
  423. tk := tasks[k]
  424. if tk.idx.Type == IT_HASH {
  425. checkHashEntries(R, t.Name, tk.idx, tk.st, live, tk.col)
  426. } else {
  427. checkOneIndex(R, t.Name, tk.idx, tk.st, live, tk.col)
  428. }
  429. })
  430. for _, tk := range tasks {
  431. if tk.idx.Type == IT_PRIMARY {
  432. primaryName = tk.idx.Name
  433. }
  434. ctx[tk.idx.Name] = &idxCtx{info: tk.idx, stat: tk.st, set: tk.col}
  435. }
  436. // (4) 反向缺失: 行 -> 索引项。必须在前向扫完、集合齐了之后再做。
  437. // 主键方向 (行有没有掉出 B+ 树) 只要 pkID 集合, 没有二级索引的表 (只有主键,
  438. // 恰恰是数据量最大的那类) 也必须查 —— 那是最致命的一种缺失。
  439. if primaryOK && primaryName != "" {
  440. checkReverseMissing(R, t.Name, t, dataPages[t.Name], primaryName, ctx)
  441. } else if !primaryOK {
  442. R.IdxNotes = append(R.IdxNotes,
  443. fmt.Sprintf("%s: 行存储不是主键索引 (索引类型 %s, 行在 TimeDataPage), 反向缺失校验已跳过",
  444. t.Name, idxTypeName(rowStore)))
  445. }
  446. // 统计汇总放在最后: 反向检查也会往 st.Issues 上加。
  447. for _, idx := range t.Idxs {
  448. c := ctx[idx.Name]
  449. if c == nil {
  450. continue
  451. }
  452. R.IdxEntries += c.stat.Entries
  453. R.IdxStats = append(R.IdxStats, *c.stat)
  454. }
  455. }
  456. // 并行收集时问题的追加顺序随调度而变, 排一遍让报告可复现。
  457. slices.SortStableFunc(R.IdxIssues, func(a, b IndexIssue) int {
  458. if a.Table != b.Table {
  459. return cmp.Compare(a.Table, b.Table)
  460. }
  461. if a.Index != b.Index {
  462. return cmp.Compare(a.Index, b.Index)
  463. }
  464. if a.Page != b.Page {
  465. return cmp.Compare(a.Page, b.Page)
  466. }
  467. if a.At != b.At {
  468. return cmp.Compare(a.At, b.At)
  469. }
  470. return cmp.Compare(a.Kind, b.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/index_meta_page.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. }