Browse Source

fix(btree): 二级 B+ 树顺序追加走 SequentialRight, 修复最右脊半空膨胀

普查 bench_10m 发现 IdxData(63,931) 反多于 Data(58,596): entryLen=16 的
int64 二级树 40,111 页中 38,290 页 ~50% 空 —— 顺序/重复键插入时 50/50
分裂让最右脊的叶长期半空, 索引体积膨胀 1.8x (非"一条一页", 每叶仍 ~1000 条)。

- split.go: SBTSplitCfg 默认 SequentialRight=true (此前仅 index 包 init
  置 true, btree 包独立使用时仍是 false)
- secondary_insert.go: 叶/内部两处分裂点改传真实插入位置 (原硬编码 false,
  配置开了也不生效)
修复后: 3 万顺序条目叶填充 50.7%→97.9% (页数 58→30); 随机键行为不变
(91.7%, 插入点罕落最右); bench_10m 重跑 87/87 通过, 16B 树 40,111→30,031
页, 文件 2369→2077MB (-291MB); 剩余半空页为删除合并后的正常形态。
新增 TestSBTSequentialFill 回归 (顺序 ≥85% / 随机 ≥60%)。
yl32940 3 weeks ago
parent
commit
d3455be376

+ 2 - 2
DB/core/index/btree/secondary_insert.go

@@ -66,7 +66,7 @@ func SBTInsert(rootPID uint32, entry []byte, handle page.HandleDb) (uint32, erro
 				copy(all[(ip+1)*el:], n.entries[ip*el:cnt*el])
 			}
 			// 分裂点: 左叶保留 [0,mid), 右叶保留 [mid,total)
-			mid := splitPoint(total, SBTSplitCfg, false)
+			mid := splitPoint(total, SBTSplitCfg, ip == cnt) // 顺序追加 (插入点在最右端) 时右叶只留 1 条, 见 btree_handler.go 的 SBTSplitCfg 注释
 			rl := sbtNew(handle, true, el)
 			rc := total - mid
 			copy(rl.entries, all[mid*el:total*el])
@@ -163,7 +163,7 @@ func sbtPromote(handle page.HandleDb, rootPID uint32, path []btPathEntry, leftPI
 		return rootPID, nil
 	}
 	cnt = int(parent.info.Count)
-	mid := splitPoint(cnt, SBTSplitCfg, false)
+	mid := splitPoint(cnt, SBTSplitCfg, pos == cnt) // 同上: 内部节点分裂的顺序追加优化
 	// 分裂点取 mid: 左半保留 [0,mid), 右半保留 [mid,cnt)。
 	// 不能丢掉 entry[mid] (否则它指向的整棵子树会不可达)。
 	rn := sbtNew(handle, false, iel)

+ 8 - 2
DB/core/index/btree/split.go

@@ -22,8 +22,14 @@ type SplitConfig struct {
 var (
 	// PKSplitCfg 主键 B+ 树分裂策略 (由 primary_handler.go 声明)
 	PKSplitCfg = SplitConfig{SplitPercent: 50, SequentialRight: true}
-	// SBTSplitCfg 二级 B+ 树分裂策略 (由 btree_handler.go 声明)
-	SBTSplitCfg = SplitConfig{SplitPercent: 50, SequentialRight: false}
+	// SBTSplitCfg 二级 B+ 树分裂策略 (默认值即最终值; index 包的 init 会重申
+	// 同一配置, 两处保持一致)。
+	//
+	// SequentialRight=true 与主键同款: 自增/递增键总是插到最右端, 若按 50/50
+	// 对半分, 最右脊上的叶会长期 ~50% 空 (实测 bench_10m 1000 万行 int64 二级
+	// 索引 40,111 页中 38,290 页半空, 索引体积膨胀 1.8x)。仅当插入点恰在最右端
+	// 时右节点只留 1 条; 随机键插入行为与纯 50/50 一致 (SplitPercent 兜底)。
+	SBTSplitCfg = SplitConfig{SplitPercent: 50, SequentialRight: true}
 )
 
 // splitPoint 计算分裂分界下标 mid: 左节点保留 [0,mid), 右节点保留 [mid,total)。

+ 85 - 0
DB/core/index/btree/split_sequential_test.go

@@ -0,0 +1,85 @@
+package btree
+
+import (
+	"encoding/binary"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+)
+
+// sbtFillStats 统计一棵 SBT 树所有节点的 (页数, 平均填充字节比)。
+// 直接沿最左叶链走页 (btree 包内白盒)。
+func sbtLeafFill(t *testing.T, h page.HandleDb, root uint32) (pages int, sumRatio float64) {
+	t.Helper()
+	cur := root
+	for {
+		n := sbtLoad(h, cur)
+		if n == nil {
+			t.Fatalf("加载节点 %d 失败", cur)
+		}
+		if n.isLeaf() {
+			break
+		}
+		cur = sbtChildPID(sbtEntry(n, 0))
+	}
+	for cur != 0 {
+		n := sbtLoad(h, cur)
+		if n == nil {
+			break
+		}
+		pages++
+		sumRatio += float64(n.info.Hdr.UseSize) / float64(page.IndexPageSize)
+		cur = n.info.Hdr.NextPage
+	}
+	return pages, sumRatio
+}
+
+// TestSBTSequentialFill 回归: 顺序键 (自增号/流水号) 插入二级 B+ 树,
+// 叶子平均填充必须 ≥85% —— 防 "50/50 分裂导致最右脊长期半空" 复发
+// (实测过: bench_10m 1000 万行 int64 二级索引 40,111 页中 38,290 页 ~50% 空,
+// 索引体积膨胀 1.8x; 修复 = SBTSplitCfg.SequentialRight + 分裂点传真实插入位置)。
+func TestSBTSequentialFill(t *testing.T) {
+	h := newMemHandle().handle()
+	root := uint32(0)
+	key := make([]byte, 8)
+	for i := uint64(1); i <= 30000; i++ {
+		binary.BigEndian.PutUint64(key, i)
+		entry := make([]byte, 16)
+		copy(entry[:8], key)
+		binary.BigEndian.PutUint64(entry[8:], i)
+		nr, err := SBTInsert(root, entry, h)
+		if err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+		root = nr
+	}
+	pages, sum := sbtLeafFill(t, h, root)
+	avg := sum / float64(pages)
+	t.Logf("3 万顺序条目: %d 叶页, 平均填充 %.1f%%", pages, avg*100)
+	if avg < 0.85 {
+		t.Fatalf("顺序插入的二级 B+ 树叶平均填充 %.1f%% < 85%% —— 顺序分裂优化失效/回归", avg*100)
+	}
+	// 随机键对照: 50/50 分裂下随机插入也应保持在合理填充 (>60%), 防优化误伤
+	h2 := newMemHandle().handle()
+	root2 := uint32(0)
+	seed := uint64(12345)
+	for i := 0; i < 30000; i++ {
+		seed = seed*6364136223846793005 + 1442695040888963407 // LCG 伪随机, 顺序无关
+		k := (seed >> 33) % 40000000
+		binary.BigEndian.PutUint64(key, k)
+		entry := make([]byte, 16)
+		copy(entry[:8], key)
+		binary.BigEndian.PutUint64(entry[8:], uint64(i+1))
+		nr, err := SBTInsert(root2, entry, h2)
+		if err != nil {
+			t.Fatalf("随机插入 %d: %v", i, err)
+		}
+		root2 = nr
+	}
+	pages2, sum2 := sbtLeafFill(t, h2, root2)
+	avg2 := sum2 / float64(pages2)
+	t.Logf("3 万随机条目: %d 叶页, 平均填充 %.1f%%", pages2, avg2*100)
+	if avg2 < 0.60 {
+		t.Fatalf("随机插入填充 %.1f%% < 60%% —— 异常", avg2*100)
+	}
+}

+ 9 - 1
DB/core/index/btree_handler.go

@@ -18,7 +18,15 @@ func init() {
 
 	// 二级 B+ 树分裂参数 —— 写死在本文件, 不随索引元数据传递。
 	// 二级键分布随机且允许重复, 对半分(50)时平均填充率最高, 故保持 50/50。
-	btree.SBTSplitCfg = btree.SplitConfig{SplitPercent: 50, SequentialRight: false}
+	// 二级 B+ 树分裂参数 —— 写死在本文件, 不随索引元数据传递。
+	//
+	// SequentialRight=true: 与主键树同款"顺序追加不搬运"策略 —— 自增/递增键
+	// (自增号、时间戳、流水号) 总是插到最右端, 若仍按 50/50 对半分, 分裂出的
+	// 右叶带着半页旧条目离开, 最右脊上的叶全部长期 ~50% 空 (实测 bench_10m
+	// 1000 万行 int64 二级索引: 40,111 页中 38,290 页半空, 索引体积膨胀 1.8x)。
+	// 置 true 后: 仅当插入点恰在最右端时右节点只留 1 条 (左叶保留满页);
+	// 随机键插入点很少落在最右, 行为与纯 50/50 完全一致 (SplitPercent 兜底)。
+	btree.SBTSplitCfg = btree.SplitConfig{SplitPercent: 50, SequentialRight: true}
 }
 
 // SearchAll 查找相同 key 下的所有 pkID