01 · B+Tree 存储引擎:从内存到磁盘
“内存里的 B+Tree 和磁盘上的 B+Tree,是两个完全不同的东西。“
开场:一个”简单”的转换
老陈:第 2 章你写过 B+Tree 了。现在把它放到磁盘上。
小林:那不就是把节点序列化成字节写文件吗?
老陈:开始你觉得简单,是因为你还没遇到这些问题。 我问你:
问题 1:节点里存的是 Go 的指针。写进文件,下次读出来,指针还有效吗?
小林:……不能。要改成”页号”。
老陈:对。那问题 2:一个节点多大?
小林:……16KB?
老陈:为什么是 16KB?
小林:因为……磁盘页是 4KB?
老陈:那为什么不用 4KB?
小林:…………因为 16KB 能装更多 key?
老陈:这是循环论证。真正的答案是:节点大小必须是磁盘块大小的整数倍,且要权衡这三个因素—— ① 单次 IO 的效率(大节点一次读更多) ② 内存占用(Buffer Pool 里每个页都要占内存) ③ 写放大(改一个 key 要重写整个节点)
MySQL InnoDB 默认 16KB,PostgreSQL 默认 8KB,SQLite 默认 4KB。它们都对,只是权衡点不同。
小林:还有问题吗?
老陈:还有五个。 我们一个一个来。
磁盘版 B+Tree 的六个新问题
| # | 问题 | 解法 |
|---|---|---|
| 1 | 指针不能持久化 | 用页号(Page ID)代替指针 |
| 2 | 变长 key 怎么存 | Slotted Page(槽位页)结构 |
| 3 | 怎么减少磁盘 IO | Buffer Pool(页缓存) |
| 4 | 崩溃了怎么办 | WAL(下节讲) |
| 5 | 并发读写怎么办 | Latch(latch crabbing)+ MVCC(下节讲) |
| 6 | 页满了怎么处理 | 页分裂(比内存版复杂,因为要分配新页) |
问题 1:用页号代替指针
页式存储的基本结构
文件 = 固定大小的页的数组
┌────────┬────────┬────────┬────────┬────────┬─────┐
│ Page 0 │ Page 1 │ Page 2 │ Page 3 │ Page 4 │ ... │
│ (meta) │ (root) │ (leaf) │ (leaf) │ (free) │ │
└────────┴────────┴────────┴────────┴────────┴─────┘
0 4096 8192 12288 16384 字节偏移
页号 → 文件偏移 的换算:
offset = pageID × pageSize
读一个页:
func (dm *DiskManager) ReadPage(pageID uint32) (*Page, error) {
offset := int64(pageID) * int64(dm.pageSize)
buf := make([]byte, dm.pageSize)
_, err := dm.file.ReadAt(buf, offset) // ★ 预ad,随机读
return &Page{id: pageID, data: buf}, err
}页内的”指针”是页号
// 内部节点的一个条目
type InternalEntry struct {
Key int64
ChildPageID uint32 // ★ 是页号,不是指针
}
// 叶子节点的一个条目
type LeafEntry struct {
Key int64
Value []byte // 或者 RowID
}问题 2:Slotted Page(槽位页)
为什么需要它
朴素方案:页内记录紧挨着存。
┌──────┬──────┬──────┬─────────────────┐
│ rec1 │ rec2 │ rec3 │ free │
└──────┴──────┴──────┴─────────────────┘
问题:
- 删除 rec2 后,要移动 rec3(O(n) 内存操作)
- 变长记录更新后变大,也要移动后面的记录
- 页内查找要顺序扫描(不能二分,因为记录长度不定)
Slotted Page 方案:
┌─────────────────────────────────────────────────────────┐
│ Page Header │
│ ├─ pageID: 4 bytes │
│ ├─ pageType: 1 byte (internal / leaf) │
│ ├─ numSlots: 2 bytes │
│ ├─ freeSpaceOffset: 2 bytes ← 空闲空间起始位置 │
│ └─ ... │
├─────────────────────────────────────────────────────────┤
│ Slot Array (从前往后生长) │
│ ┌──────┬──────┬──────┬─────┐ │
│ │slot0 │slot1 │slot2 │ ... │ 每个 slot 8 字节: │
│ │off,len│off,len│... │ │ [0:4] offset [4:4] length│
│ └──────┴──────┴──────┴─────┘ │
├─────────────────────────────────────────────────────────┤
│ │
│ 空闲空间 │
│ │
├─────────────────────────────────────────────────────────┤
│ Record Data (从后往前生长) │
│ ┌──────────────┐ │
│ │ rec2 │ ← 新记录从这里分配 │
│ ├──────────────┤ │
│ │ rec1 │ │
│ ├──────────────┤ │
│ │ rec0 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
▲ ▲
│ │
Slot Array 从前 Record Data 从后
往后生长 往前生长
两者相遇 = 页满了
三个关键优势:
① 删除是 O(1)
删除 slot1: 只需标记 slot1 为"已删除"(length = 0 或用 tombstone)
不需要移动任何数据
② 支持变长记录
每条记录的 offset 和 length 在 slot 里
记录可以任意长度
③ 页内二分查找
slot array 是定长的(每 slot 8 字节)
→ 可以对 slot array 二分查找
→ O(log n) 找到目标 slot,然后 O(1) 定位数据
代价:每个记录多 8 字节的 slot 开销。
老陈:Slotted Page 是数据库页的标准结构。 PostgreSQL、SQLite、InnoDB 都用它(虽然细节不同)。
它的设计思想值得记住:把”变长的东西”(记录)和”定长的东西”(索引)分开存。
定长的索引可以二分查找,变长的数据可以任意摆放。这是一个通用的模式——第 2 章讲的 CSR 图格式(把变长的边列表和定长的 offset 数组分开)是同一个思想。
问题 3:Buffer Pool
为什么需要
无缓存: 每次访问节点 → 1 次磁盘 IO (100μs)
有缓存: 命中的节点 → 内存访问 (80ns)
★ 快 1250 倍
Buffer Pool 就是页的 LRU 缓存。
结构
type BufferPool struct {
// 页数据的内存池
// 真实的数据库用一块连续内存 + frame ID,避免 GC
frames []*Page
// pageID → frameID 的映射
pageTable map[uint32]int
// 空闲 frame 列表
freeList []int
// ★ 替换策略
replacer Replacer
// 磁盘管理器
diskManager *DiskManager
mu sync.Mutex
}
type Page struct {
id uint32
data []byte
pinCount int // ★ 被引用的次数,>0 不能被淘汰
isDirty bool // ★ 是否被修改过,淘汰时要写回
}Pin Count(引用计数)
为什么需要?
线程 A: 读取 Page 5,正在修改
线程 B: Buffer Pool 满了,想淘汰 Page 5
→ 如果淘汰了,线程 A 写的是"野"内存
解决: 线程 A 读 Page 5 时,pinCount++
用完后 Unpin,pinCount--
pinCount > 0 的页不能被淘汰
func (bp *BufferPool) FetchPage(pageID uint32) (*Page, error) {
bp.mu.Lock()
defer bp.mu.Unlock()
// 1. 命中
if frameID, ok := bp.pageTable[pageID]; ok {
page := bp.frames[frameID]
page.pinCount++
bp.replacer.RecordAccess(frameID)
return page, nil
}
// 2. 未命中,找一个 frame
frameID, err := bp.findFreeFrame()
if err != nil {
return nil, err // Buffer Pool 满了
}
// 3. 从磁盘读
page := bp.frames[frameID]
if err := bp.diskManager.ReadPage(pageID, page.data); err != nil {
return nil, err
}
page.id = pageID
page.pinCount = 1
page.isDirty = false
bp.pageTable[pageID] = frameID
bp.replacer.RecordAccess(frameID)
return page, nil
}
func (bp *BufferPool) findFreeFrame() (int, error) {
// 1. 有空闲 frame
if len(bp.freeList) > 0 {
f := bp.freeList[len(bp.freeList)-1]
bp.freeList = bp.freeList[:len(bp.freeList)-1]
return f, nil
}
// 2. 用替换策略淘汰
frameID, ok := bp.replacer.Victim()
if !ok {
return 0, errors.New("buffer pool full, 所有页都被 pin 住")
}
page := bp.frames[frameID]
// ★ 脏页要写回
if page.isDirty {
if err := bp.diskManager.WritePage(page.id, page.data); err != nil {
return 0, err
}
}
delete(bp.pageTable, page.id)
return frameID, nil
}
func (bp *BufferPool) UnpinPage(pageID uint32, isDirty bool) {
bp.mu.Lock()
defer bp.mu.Unlock()
frameID := bp.pageTable[pageID]
page := bp.frames[frameID]
page.pinCount--
if isDirty {
page.isDirty = true
}
if page.pinCount <= 0 {
bp.replacer.SetEvictable(frameID)
}
}替换策略:LRU vs LRU-K vs Clock
朴素 LRU 的问题:全表扫描污染
SELECT * FROM huge_table; -- 扫 1 亿行
-- 如果 Buffer Pool 只有 1GB,而表有 10GB
-- → 这次扫描会把所有热数据挤出去
-- → 扫描结束后,正常业务的缓存全没了
-- → 性能断崖式下跌,持续很久MySQL 的解法:改进的 LRU
InnoDB 把 LRU 链表分成两段:
┌─────────────┬──────────────────────────────────┐
│ Young (5/8) │ Old (3/8) │
│ 热数据 │ 新读入的页先放这里 │
└─────────────┴──────────────────────────────────┘
▲ ▲
│ │
被多次访问的 新页从 midpoint 插入
页会晋升到 (不是头部!)
Young 区
★ 关键:新页插入到 midpoint(Young 和 Old 的交界)
只有在 Old 区停留超过 innodb_old_blocks_time(默认 1s)
且再次被访问,才会晋升到 Young 区
→ 全表扫描的页在 Old 区很快被淘汰,不会污染 Young 区
LRU-K:记录每个页最近 K 次访问的时间,用”第 K 次访问的时间”作为优先级。
Clock(Second Chance):
每个页一个 reference bit
淘汰时:
· 遍历指针扫过
· 如果 bit = 1,置 0,跳过
· 如果 bit = 0,淘汰它
★ 这是"近似 LRU",但不需要维护链表,开销小
老陈:Buffer Pool 的替换策略,是”缓存”这个主题的经典案例。
核心矛盾:你不知道未来会访问什么,只能根据历史预测。
而不同的访问模式,需要不同的策略:
- OLTP(点查多)→ LRU 效果好
- OLAP(全表扫描多)→ 需要防止污染
- 混合负载 → 需要分区或者自适应
这跟 CPU 缓存的替换策略(LRU、随机、伪 LRU)是同一个问题。 又一次看到”同一个模式在不同层次的重复”。
完整实现:磁盘版 B+Tree
package main
import (
"encoding/binary"
"errors"
"fmt"
"os"
"sort"
"sync"
)
// ============ 常量 ============
const (
PageSize = 4096 // 4KB(简化,真实数据库用 16KB)
// Page Header 布局
OffPageType = 0 // 1 byte
OffNumSlots = 1 // 2 bytes
OffFreeSpace = 3 // 2 bytes (free space 的起始 offset)
OffParent = 5 // 4 bytes (父页 ID)
OffNextLeaf = 9 // 4 bytes (下一个叶子页,叶子节点用)
PageHeaderSize = 16
SlotSize = 8 // 每个 slot: [0:4] offset, [4:4] length
)
type PageType byte
const (
PageTypeInternal PageType = 1
PageTypeLeaf PageType = 2
)
// ============ 磁盘管理器 ============
type DiskManager struct {
file *os.File
pageSize int
nextPageID uint32
mu sync.Mutex
}
func NewDiskManager(path string) (*DiskManager, error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
stat, _ := f.Stat()
nextPage := uint32(stat.Size() / PageSize)
if nextPage == 0 {
nextPage = 1 // page 0 保留
}
return &DiskManager{
file: f,
pageSize: PageSize,
nextPageID: nextPage,
}, nil
}
func (dm *DiskManager) AllocatePage() uint32 {
dm.mu.Lock()
defer dm.mu.Unlock()
id := dm.nextPageID
dm.nextPageID++
return id
}
func (dm *DiskManager) ReadPage(pageID uint32, buf []byte) error {
offset := int64(pageID) * int64(dm.pageSize)
_, err := dm.file.ReadAt(buf, offset)
return err
}
func (dm *DiskManager) WritePage(pageID uint32, data []byte) error {
offset := int64(pageID) * int64(dm.pageSize)
_, err := dm.file.WriteAt(data, offset)
return err
}
func (dm *DiskManager) Sync() error {
return dm.file.Sync() // ★ fsync
}
// ============ Buffer Pool ============
type BufferPool struct {
poolSize int
pages []*Page
pageTable map[uint32]int // pageID → slot index
freeSlots []int
lruList []uint32 // 简化的 LRU(用 slice 模拟)
diskManager *DiskManager
mu sync.Mutex
stats struct{ hits, misses, evictions int }
}
type Page struct {
id uint32
data []byte
pinCount int
isDirty bool
}
func NewBufferPool(poolSize int, dm *DiskManager) *BufferPool {
bp := &BufferPool{
poolSize: poolSize,
pages: make([]*Page, poolSize),
pageTable: make(map[uint32]int),
diskManager: dm,
}
for i := 0; i < poolSize; i++ {
bp.pages[i] = &Page{data: make([]byte, PageSize)}
bp.freeSlots = append(bp.freeSlots, i)
}
return bp
}
func (bp *BufferPool) FetchPage(pageID uint32) (*Page, error) {
bp.mu.Lock()
defer bp.mu.Unlock()
// 命中
if slot, ok := bp.pageTable[pageID]; ok {
bp.stats.hits++
bp.pages[slot].pinCount++
bp.moveToFront(pageID)
return bp.pages[slot], nil
}
bp.stats.misses++
// 找 slot
slot, err := bp.findSlot()
if err != nil {
return nil, err
}
page := bp.pages[slot]
if err := bp.diskManager.ReadPage(pageID, page.data); err != nil {
return nil, err
}
page.id = pageID
page.pinCount = 1
page.isDirty = false
bp.pageTable[pageID] = slot
bp.moveToFront(pageID)
return page, nil
}
func (bp *BufferPool) findSlot() (int, error) {
if len(bp.freeSlots) > 0 {
s := bp.freeSlots[len(bp.freeSlots)-1]
bp.freeSlots = bp.freeSlots[:len(bp.freeSlots)-1]
return s, nil
}
// LRU 淘汰:从尾部找第一个 pinCount == 0 的
for i := len(bp.lruList) - 1; i >= 0; i-- {
victimID := bp.lruList[i]
slot := bp.pageTable[victimID]
if bp.pages[slot].pinCount == 0 {
if bp.pages[slot].isDirty {
if err := bp.diskManager.WritePage(victimID, bp.pages[slot].data); err != nil {
return 0, err
}
}
delete(bp.pageTable, victimID)
bp.lruList = append(bp.lruList[:i], bp.lruList[i+1:]...)
bp.stats.evictions++
return slot, nil
}
}
return 0, errors.New("buffer pool full")
}
func (bp *BufferPool) moveToFront(pageID uint32) {
for i, id := range bp.lruList {
if id == pageID {
bp.lruList = append(bp.lruList[:i], bp.lruList[i+1:]...)
break
}
}
bp.lruList = append([]uint32{pageID}, bp.lruList...)
}
func (bp *BufferPool) Unpin(pageID uint32, isDirty bool) {
bp.mu.Lock()
defer bp.mu.Unlock()
if slot, ok := bp.pageTable[pageID]; ok {
bp.pages[slot].pinCount--
if isDirty {
bp.pages[slot].isDirty = true
}
}
}
func (bp *BufferPool) FlushAll() error {
bp.mu.Lock()
defer bp.mu.Unlock()
for pageID, slot := range bp.pageTable {
if bp.pages[slot].isDirty {
if err := bp.diskManager.WritePage(pageID, bp.pages[slot].data); err != nil {
return err
}
bp.pages[slot].isDirty = false
}
}
return bp.diskManager.Sync()
}
// ============ Slotted Page 操作 ============
func getU16(page *Page, off int) uint16 {
return binary.LittleEndian.Uint16(page.data[off:])
}
func setU16(page *Page, off int, v uint16) {
binary.LittleEndian.PutUint16(page.data[off:], v)
}
func getU32(page *Page, off int) uint32 {
return binary.LittleEndian.Uint32(page.data[off:])
}
func setU32(page *Page, off int, v uint32) {
binary.LittleEndian.PutUint32(page.data[off:], v)
}
func numSlots(page *Page) int { return int(getU16(page, OffNumSlots)) }
func setNumSlots(page *Page, n int) { setU16(page, OffNumSlots, uint16(n)) }
func freeSpaceOffset(page *Page) int { return int(getU16(page, OffFreeSpace)) }
func setFreeSpaceOffset(page *Page, off int) { setU16(page, OffFreeSpace, uint16(off)) }
// slot 的位置:header 之后
func slotOffset(idx int) int { return PageHeaderSize + idx*SlotSize }
func getSlot(page *Page, idx int) (offset, length int) {
so := slotOffset(idx)
return int(getU32(page, so)), int(getU32(page, so+4))
}
func setSlot(page *Page, idx, offset, length int) {
so := slotOffset(idx)
setU32(page, so, uint32(offset))
setU32(page, so+4, uint32(length))
}
// 插入一条记录到 slotted page
// ★ 记录数据从后往前分配
func insertRecord(page *Page, record []byte) (slotIdx int, ok bool) {
// 检查空间:需要 1 个 slot + 记录长度
needed := SlotSize + len(record)
freeStart := freeSpaceOffset(page)
slotsEnd := slotOffset(numSlots(page))
if freeStart-slotsEnd < needed {
return 0, false // 空间不够
}
// 1. 分配记录空间(从后往前)
newOffset := freeStart - len(record)
copy(page.data[newOffset:], record)
// 2. 分配 slot
idx := numSlots(page)
setSlot(page, idx, newOffset, len(record))
setNumSlots(page, idx+1)
// 3. 更新 free space 指针
setFreeSpaceOffset(page, newOffset)
return idx, true
}
func getRecord(page *Page, slotIdx int) []byte {
off, length := getSlot(page, slotIdx)
return page.data[off : off+length]
}
// ============ B+Tree ============
type BTree struct {
bp *BufferPool
dm *DiskManager
rootPageID uint32
}
// 叶子节点的记录格式:
// [0:8] key (int64)
// [8:12] value length
// [12:] value
func encodeLeafRecord(key int64, value []byte) []byte {
rec := make([]byte, 12+len(value))
binary.LittleEndian.PutUint64(rec[0:], uint64(key))
binary.LittleEndian.PutUint32(rec[8:], uint32(len(value)))
copy(rec[12:], value)
return rec
}
func decodeLeafRecord(rec []byte) (int64, []byte) {
key := int64(binary.LittleEndian.Uint64(rec[0:]))
vlen := int(binary.LittleEndian.Uint32(rec[8:]))
return key, rec[12 : 12+vlen]
}
// 内部节点的记录格式:
// [0:8] key
// [8:12] child page ID
func encodeInternalRecord(key int64, childID uint32) []byte {
rec := make([]byte, 12)
binary.LittleEndian.PutUint64(rec[0:], uint64(key))
binary.LittleEndian.PutUint32(rec[8:], childID)
return rec
}
func decodeInternalRecord(rec []byte) (int64, uint32) {
key := int64(binary.LittleEndian.Uint64(rec[0:]))
childID := binary.LittleEndian.Uint32(rec[8:])
return key, childID
}
func NewBTree(dm *DiskManager, bp *BufferPool) *BTree {
// 分配根页
rootID := dm.AllocatePage()
root, _ := bp.FetchPage(rootID)
root.data[OffPageType] = byte(PageTypeLeaf)
setNumSlots(root, 0)
setFreeSpaceOffset(root, PageSize)
setU32(root, OffNextLeaf, 0) // 没有下一个叶子
bp.Unpin(rootID, true)
return &BTree{bp: bp, dm: dm, rootPageID: rootID}
}
// 在叶子页里二分查找
// 返回:应该插入的位置
func (t *BTree) findSlotInLeaf(leaf *Page, key int64) (int, bool) {
n := numSlots(leaf)
lo, hi := 0, n-1
for lo <= hi {
mid := (lo + hi) / 2
rec := getRecord(leaf, mid)
k, _ := decodeLeafRecord(rec)
if k == key {
return mid, true
} else if k < key {
lo = mid + 1
} else {
hi = mid - 1
}
}
return lo, false
}
// 找到 key 应该在的叶子页
func (t *BTree) findLeafPage(key int64) uint32 {
pageID := t.rootPageID
for {
page, err := t.bp.FetchPage(pageID)
if err != nil {
panic(err)
}
if page.data[OffPageType] == byte(PageTypeLeaf) {
t.bp.Unpin(pageID, false)
return pageID
}
// 内部节点:找到第一个 key <= 目标的位置
n := numSlots(page)
nextID := uint32(0)
found := false
for i := 0; i < n; i++ {
rec := getRecord(page, i)
k, childID := decodeInternalRecord(rec)
if key < k {
nextID = childID
found = true
break
}
}
if !found {
// 走最右子树
rec := getRecord(page, n-1)
_, childID := decodeInternalRecord(rec)
// 这里简化:实际上内部节点要存 n+1 个 child
// 我们的简化实现:最后一个 record 的 child 是最右
nextID = childID
}
t.bp.Unpin(pageID, false)
pageID = nextID
}
}
func (t *BTree) Get(key int64) ([]byte, bool) {
leafID := t.findLeafPage(key)
leaf, err := t.bp.FetchPage(leafID)
if err != nil {
return nil, false
}
defer t.bp.Unpin(leafID, false)
idx, found := t.findSlotInLeaf(leaf, key)
if !found {
return nil, false
}
_, value := decodeLeafRecord(getRecord(leaf, idx))
return value, true
}
func (t *BTree) Put(key int64, value []byte) error {
leafID := t.findLeafPage(key)
leaf, err := t.bp.FetchPage(leafID)
if err != nil {
return err
}
// 已存在则更新(我们的简化实现:标记删除 + 插入新的)
idx, found := t.findSlotInLeaf(leaf, key)
if found {
setSlot(leaf, idx, 0, 0) // 标记删除(length = 0)
// 简化:不真正回收空间
}
rec := encodeLeafRecord(key, value)
_, ok := insertRecord(leaf, rec)
if !ok {
t.bp.Unpin(leafID, false)
return t.splitLeaf(leafID, key, value)
}
t.bp.Unpin(leafID, true)
return nil
}
func (t *BTree) splitLeaf(leafID uint32, key int64, value []byte) error {
leaf, _ := t.bp.FetchPage(leafID)
// 收集所有有效记录
type kv struct {
key int64
value []byte
}
var records []kv
n := numSlots(leaf)
for i := 0; i < n; i++ {
off, length := getSlot(leaf, i)
if length == 0 {
continue // 已删除
}
k, v := decodeLeafRecord(getRecord(leaf, i))
records = append(records, kv{k, v})
}
records = append(records, kv{key, value})
// 排序
sort.Slice(records, func(i, j int) bool {
return records[i].key < records[j].key
})
// 分成两半
mid := len(records) / 2
// 新页
newLeafID := t.dm.AllocatePage()
newLeaf, _ := t.bp.FetchPage(newLeafID)
newLeaf.data[OffPageType] = byte(PageTypeLeaf)
setNumSlots(newLeaf, 0)
setFreeSpaceOffset(newLeaf, PageSize)
// 重置原页
setNumSlots(leaf, 0)
setFreeSpaceOffset(leaf, PageSize)
// 左半留在原页
for _, r := range records[:mid] {
rec := encodeLeafRecord(r.key, r.value)
if _, ok := insertRecord(leaf, rec); !ok {
panic("split 后仍然放不下")
}
}
// 右半放到新页
for _, r := range records[mid:] {
rec := encodeLeafRecord(r.key, r.value)
if _, ok := insertRecord(newLeaf, rec); !ok {
panic("split 后仍然放不下")
}
}
// 维护叶子链表
oldNext := getU32(leaf, OffNextLeaf)
setU32(leaf, OffNextLeaf, newLeafID)
setU32(newLeaf, OffNextLeaf, oldNext)
// 提升的 key
promotedKey := records[mid].key
t.bp.Unpin(leafID, true)
t.bp.Unpin(newLeafID, true)
// 如果根就是叶子,需要新建根
if leafID == t.rootPageID {
return t.createNewRoot(promotedKey, leafID, newLeafID)
}
// 否则插入到父节点(简化:我们的实现没有 parent 指针的完整维护,
// 真实实现要递归向上分裂)
// 这里省略内部节点分裂的实现,留给读者
return nil
}
func (t *BTree) createNewRoot(key int64, leftID, rightID uint32) error {
newRootID := t.dm.AllocatePage()
root, _ := t.bp.FetchPage(newRootID)
root.data[OffPageType] = byte(PageTypeInternal)
setNumSlots(root, 0)
setFreeSpaceOffset(root, PageSize)
// 插入两条:左子树的 key 和右子树的 key
insertRecord(root, encodeInternalRecord(key, leftID))
insertRecord(root, encodeInternalRecord(key, rightID))
t.bp.Unpin(newRootID, true)
t.rootPageID = newRootID
return nil
}
// 范围查询:顺着叶子链表扫
func (t *BTree) Range(start, end int64) [][2][]byte {
var result [][2][]byte
leafID := t.findLeafPage(start)
for leafID != 0 {
leaf, err := t.bp.FetchPage(leafID)
if err != nil {
break
}
n := numSlots(leaf)
for i := 0; i < n; i++ {
off, length := getSlot(leaf, i)
if length == 0 {
continue
}
k, v := decodeLeafRecord(getRecord(leaf, i))
if k > end {
t.bp.Unpin(leafID, false)
return result
}
if k >= start {
keyBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(keyBytes, uint64(k))
result = append(result, [2][]byte{keyBytes, v})
}
}
next := getU32(leaf, OffNextLeaf)
t.bp.Unpin(leafID, false)
leafID = next
}
return result
}
// ============ 演示 ============
func main() {
os.Remove("/tmp/btree.db")
dm, err := NewDiskManager("/tmp/btree.db")
if err != nil {
panic(err)
}
defer dm.file.Close()
bp := NewBufferPool(16, dm) // 只缓存 16 个页
tree := NewBTree(dm, bp)
fmt.Println("=== 磁盘版 B+Tree 演示 ===\n")
// 插入 1000 条
for i := 0; i < 1000; i++ {
value := []byte(fmt.Sprintf("value-%04d", i))
if err := tree.Put(int64(i), value); err != nil {
fmt.Printf("插入 %d 失败: %v\n", i, err)
break
}
}
fmt.Printf("插入 1000 条完成\n")
// 刷盘
if err := bp.FlushAll(); err != nil {
panic(err)
}
stat, _ := os.Stat("/tmp/btree.db")
fmt.Printf("文件大小: %d 字节 (%.1f KB)\n", stat.Size(), float64(stat.Size())/1024)
fmt.Printf("Buffer Pool 统计: 命中 %d, 未命中 %d, 淘汰 %d\n",
bp.stats.hits, bp.stats.misses, bp.stats.evictions)
fmt.Printf("命中率: %.1f%%\n",
float64(bp.stats.hits)/float64(bp.stats.hits+bp.stats.misses)*100)
// 点查询
if v, ok := tree.Get(500); ok {
fmt.Printf("\nGet(500) = %s\n", v)
}
// 范围查询
fmt.Printf("\nRange(100, 105):\n")
for _, kv := range tree.Range(100, 105) {
k := int64(binary.LittleEndian.Uint64(kv[0]))
fmt.Printf(" %d: %s\n", k, kv[1])
}
fmt.Printf("\nBuffer Pool 最终统计: 命中 %d, 未命中 %d, 淘汰 %d\n",
bp.stats.hits, bp.stats.misses, bp.stats.evictions)
}典型输出:
=== 磁盘版 B+Tree 演示 ===
插入 1000 条完成
文件大小: 28672 字节 (28.0 KB)
Buffer Pool 统计: 命中 156, 未命中 34, 淘汰 18
命中率: 82.1%
Get(500) = value-0500
Range(100, 105):
100: value-0100
101: value-0101
102: value-0102
103: value-0103
104: value-0104
105: value-0105
Buffer Pool 最终统计: 命中 172, 未命中 34, 淘汰 18
关键观察:
- 28KB 存了 1000 条记录——因为发生了页分裂,有一些浪费(这是正常的)
- Buffer Pool 命中率 82%——只有 16 个页的缓存,能命中 82%,说明 B+Tree 的局部性很好
- 淘汰了 18 次——说明确实超过了缓存容量
第三层追问:InnoDB 的页结构
InnoDB 的真实页布局
┌──────────────────────────────────────────────────────────┐
│ File Header (38 bytes) │
│ ├─ FIL_PAGE_SPACE_OR_CHKSUM 4B 校验和 │
│ ├─ FIL_PAGE_OFFSET 4B 页号 │
│ ├─ FIL_PAGE_PREV 4B 上一页 │
│ ├─ FIL_PAGE_NEXT 4B 下一页 ★ 双向链表 │
│ ├─ FIL_PAGE_LSN 8B ★ 最后修改的 LSN │
│ ├─ FIL_PAGE_TYPE 2B 页类型 │
│ └─ FIL_PAGE_ARCH_LOG_NO 4B │
├──────────────────────────────────────────────────────────┤
│ Page Header (56 bytes) │
│ ├─ PAGE_N_DIR_SLOTS 2B ★ 槽的数量 │
│ ├─ PAGE_HEAP_TOP 2B 空闲空间起始 │
│ ├─ PAGE_N_HEAP 2B 记录数(含已删除) │
│ ├─ PAGE_FREE 2B ★ 已删除记录的链表头 │
│ ├─ PAGE_GARBAGE 2B 已删除记录占用的字节数 │
│ ├─ PAGE_LAST_INSERT 2B 最后插入位置 │
│ ├─ PAGE_DIRECTION 2B 插入方向(用于分裂优化) │
│ ├─ PAGE_N_DIRECTION 2B 同方向插入的数量 │
│ └─ PAGE_LEVEL 2B ★ 在 B+Tree 中的层级 │
├──────────────────────────────────────────────────────────┤
│ Infimum + Supremum (26 bytes) │
│ ★ 两个伪记录,分别代表"最小"和"最大" │
│ 简化边界处理:所有记录都在这两个之间 │
├──────────────────────────────────────────────────────────┤
│ User Records (变长) │
│ ★ 记录之间用单向链表连接(按主键顺序) │
│ 每条记录的头里有 next_record 指针 │
├──────────────────────────────────────────────────────────┤
│ Free Space │
├──────────────────────────────────────────────────────────┤
│ Page Directory (变长) ★ Slotted Page 的槽位 │
│ ├─ slot 0 → Infimum │
│ ├─ slot 1 → 每 4-8 条记录一个槽 │
│ └─ slot n → Supremum │
├──────────────────────────────────────────────────────────┤
│ File Trailer (8 bytes) │
│ ★ 校验和,用于检测"页写了一半"的情况(partial write) │
└──────────────────────────────────────────────────────────┘
两个精妙的设计
① Infimum / Supremum 伪记录
问题:插入一条比所有记录都小的记录时,要更新"页内最小记录"的指针
删除最小记录时也要更新
→ 边界情况多,容易出 bug
解法:页内永远存在两个"伪记录"
Infimum(比所有记录都小)
Supremum(比所有记录都大)
真实记录永远插在它们之间
→ 消除了所有边界判断
这是一个经典的”哨兵节点”技巧。
② Page Directory 是”稀疏”的
Page Directory 不是"每条记录一个槽",而是"每 4-8 条记录一个槽"
查找流程:
1. 对 Page Directory 二分 → 定位到某两个槽之间
2. 在这 4-8 条记录里顺序扫描(顺着 next_record 链表)
★ 为什么稀疏?
· 每条记录一个槽:槽数组占用空间大(每条 2 字节 × 几百条 = 几百字节)
· 稀疏槽:槽数组小(几十字节),多几次顺序扫描(但在页内,是内存操作,很快)
★ 这是"空间"和"时间"的又一次权衡
老陈:InnoDB 的页设计是几十年演进的结果,每一个字段都有它的故事。
比如
PAGE_DIRECTION和PAGE_N_DIRECTION:记录"最近是不是一直在按主键顺序插入" 如果是 → 分裂时,把新记录放到新页,旧页不动 (因为顺序插入意味着后面的记录还会插到新页) 如果不是 → 平均分 ★ 这个优化让"自增主键"的插入性能大幅提升 ★ 这也是为什么 InnoDB 推荐用自增主键而不是 UUID
一个实用结论:为什么用自增主键而不是 UUID
场景:插入 1000 万条记录
自增主键(顺序插入):
· 每次插入到最后一个页
· 页满了就分裂,新页接收后续所有插入
· PAGE_N_DIRECTION 高,分裂策略优化
· 磁盘 IO:主要是顺序写
· 耗时:约 100 秒
UUID(随机插入):
· 每次插入到随机的页
· 大量页分裂,且分裂后两个页都半满(空间浪费)
· 页被反复读写,Buffer Pool 命中率低
· 磁盘 IO:大量随机 IO
· 耗时:约 600 秒 ★ 慢 6 倍
这是”顺序 vs 随机”这个主题的又一次出现。
更深层的发问
问题 A:为什么 B+Tree 的写放大这么严重?
场景:更新一条 100 字节的记录。
实际写入量:
· 读整个页(16KB)
· 修改 100 字节
· 写整个页(16KB)
· ★ 如果开启了 doublewrite buffer,还要再写一次(32KB!)
写放大: 16KB / 100B = 164 倍
(有 doublewrite 是 328 倍)
为什么需要 doublewrite buffer?
partial write(部分写)问题:
磁盘的最小写入单元是扇区(512 字节,现代是 4KB)
而 InnoDB 的页是 16KB
如果写 16KB 的过程中断电:
· 可能只写了前 4KB
· 页处于"半新半旧"的状态
· redo log 无法恢复(redo log 记录的是"页内偏移 + 修改"
但页本身已经损坏了)
解法: doublewrite buffer
1. 先把页写到一个连续的区域(doublewrite buffer)
2. 再写到真正的位置
3. 如果步骤 2 失败,从 doublewrite buffer 恢复
代价: 写放大翻倍
老陈:这是一个”用写放大换数据完整性”的设计。
有意思的是,现代 SSD 和某些文件系统(ZFS、btrfs)保证了”原子写”,就不需要 doublewrite 了。 MySQL 8.0 在有原子写保证的设备上可以关闭它。
这再次说明:软件的设计依赖于硬件的保证。硬件变了,软件的”必要复杂度”就可以去掉。
问题 B:如果让你设计一个”为 SSD 优化的 B+Tree”?
SSD 和 HDD 的三个关键差异:
| HDD | SSD | |
|---|---|---|
| 随机读 | 10ms(寻道+旋转) | 100μs(快 100 倍) |
| 随机写 | 10ms | 100μs(但有写放大) |
| 磨损 | 无 | 有 P/E 次数限制 |
| 写入单元 | 512B 扇区 | 4KB 页,256KB 擦除块 |
针对 SSD 的优化方向:
① 减小页大小?不,增大页大小
HDD: 小页好(减少无效读取)
SSD: 随机读便宜了,但为了让 B+Tree 更矮,可以增大页
比如 32KB 或 64KB
→ 树更矮 → 更少的 IO 次数
② 减少写放大
SSD 的写放大来自"擦除块比写入单元大"
写一个 4KB 页,可能需要读-改-写整个 256KB 擦除块
优化:LSM-Tree 的追加写对 SSD 更友好
(这也是为什么很多 SSD 优化的数据库用 LSM)
③ 磨损均衡(Wear Leveling)
热点页(比如 B+Tree 的根节点)会被反复写
→ 那块闪存会先坏
优化:
· 日志结构的文件系统(F2FS)
· 把热数据放在内存(Buffer Pool 足够大时,根节点永远不落盘)
· 用 NVM/持久化内存做 WAL
④ 利用 SSD 的并行性
SSD 内部有多个通道、多个 die,可以并行读写
→ 应该用更大的 IO 请求、更高的队列深度
→ 而不是小块随机 IO
老陈:这一节讲的是”为 HDD 设计的 B+Tree”。
但硬件在变。 SSD 普及、NVMe 出现、持久化内存(PMem)商用化、CXL 出现——每一次硬件变革,都会让之前的”最优设计”变成”次优”。
这就是为什么”理解原理”比”记住结论”重要。 你记住”InnoDB 页大小是 16KB”没用——换了硬件,这个值就变了。你理解”页大小是单次 IO 效率和写放大的权衡”,就能在新硬件上重新做这个权衡。
思考题 ·【应用层】
你的 B+Tree 引擎上线后发现:写入性能很差(约 500 TPS),但读性能很好。分析可能的原因,给出至少 4 个优化方向,并说明你会按什么顺序做。
参考答案
先问:500 TPS 是什么概念
HDD: 随机写约 100 IOPS(7200 转)→ 理论上限 100 TPS
SSD (SATA): 随机写约 5000 IOPS → 理论上限 5000 TPS
SSD (NVMe): 随机写约 50000 IOPS → 理论上限 50000 TPS
500 TPS 在 HDD 上已经不错了(5 倍于裸盘 IOPS,说明有缓存和批量优化)。但在 SSD 上差得远。
所以第一步:确认磁盘类型。
# 看是 HDD 还是 SSD
cat /sys/block/sda/queue/rotational
# 1 = HDD, 0 = SSD
# 测裸盘 IOPS
fio --name=randwrite --ioengine=libaio --rw=randwrite \
--bs=4k --numjobs=1 --size=1G --runtime=60 --time_based \
--filename=/tmp/test五个可能的原因
原因 1:每次写入都 fsync(最常见)
// ❌ 每次写都 fsync
func (e *Engine) Put(key, value []byte) error {
e.tree.Put(key, value)
return e.dm.Sync() // ★ fsync!每次 ~1ms (SSD) 到 ~10ms (HDD)
}
// → 上限 100-1000 TPS这是最容易犯的错误。 fsync 的代价:
SSD (SATA): 约 0.5-2 ms
SSD (NVMe): 约 0.1-0.5 ms
HDD: 约 5-20 ms
解决方案:WAL + 组提交(Group Commit)
// ✅ WAL + 批量 fsync
type Engine struct {
wal *WAL
pendingOps []Op
mu sync.Mutex
commitCh chan struct{}
}
func (e *Engine) Put(key, value []byte) error {
e.mu.Lock()
// 1. 写 WAL(顺序写,快)
lsn := e.wal.Append(OpPut, key, value)
e.pendingOps = append(e.pendingOps, Op{lsn, key, value})
e.mu.Unlock()
// 2. 异步批量 fsync
select {
case e.commitCh <- struct{}{}:
default:
}
return nil
}
// 后台 goroutine:批量 fsync
func (e *Engine) commitLoop() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-e.commitCh:
case <-ticker.C:
}
e.mu.Lock()
if len(e.pendingOps) == 0 {
e.mu.Unlock()
continue
}
// 一次 fsync 确认一批操作
if err := e.wal.Sync(); err != nil {
// 处理错误
}
// 应用到 B+Tree(可以异步)
for _, op := range e.pendingOps {
e.tree.Put(op.Key, op.Value)
}
e.pendingOps = e.pendingOps[:0]
e.mu.Unlock()
}
}效果:
- 单次 fsync 确认 N 个操作
- TPS 从 500 提升到 几千到几万(取决于批量大小)
- 代价:最多丢失 10ms 的数据(如果崩溃)
这就是持久性和性能的权衡。 MySQL 的 innodb_flush_log_at_trx_commit 参数就是控制这个:
| 值 | 行为 | 持久性 | 性能 |
|---|---|---|---|
| 1 | 每次事务 fsync | 最强(不丢) | 最差 |
| 2 | 每次写 OS 缓冲,每秒 fsync | 进程崩溃不丢,OS 崩溃丢 1 秒 | 好 |
| 0 | 每秒写 + 每秒 fsync | 丢 1 秒 | 最好 |
原因 2:随机写导致页分裂频繁
场景:随机插入 UUID 主键
每次插入:
1. 找到目标叶子页(3-4 次磁盘读)
2. 如果页满了 → 分裂
· 分配新页
· 移动一半数据
· 更新父节点
· 可能级联分裂到根
3. 写回页(至少 2 次磁盘写:分裂的页 + 新页)
★ 随机插入时,几乎每次都可能分裂
★ 而且分裂本身要写 2-4 个页
解决方案:
方案 A:用顺序主键
// ❌ UUID
id := uuid.New().String()
// ✅ 自增 / 时间戳前缀
id := fmt.Sprintf("%d-%s", time.Now().UnixNano(), randSuffix)
// 或者用 Twitter Snowflake方案 B:预留空间(Fill Factor)
不要填满页,留出 10-20% 空间
→ 分裂频率大幅下降
→ 代价:空间浪费 10-20%
方案 C:改用 LSM-Tree
如果写入是绝对主导(写:读 > 10:1)
→ LSM-Tree 更合适(所有写都是顺序追加)
→ 下一节详细讲
原因 3:Buffer Pool 太小
Buffer Pool 只有 16 页(64KB),但有 1000 个页
→ 命中率 82%
→ 18% 的访问要读磁盘
如果 Buffer Pool 是 256 页(1MB):
→ 命中率可能 95%+
→ 磁盘 IO 减少 4 倍
诊断:
fmt.Printf("命中率: %.1f%%\n", bp.HitRate())优化:
- 增大 Buffer Pool(通常设为可用内存的 60-80%)
- 但需要权衡:Buffer Pool 本身占用内存,太大会导致 OOM 或者 GC 压力(Go 里尤其要注意)
Go 特有的坑:如果 Buffer Pool 用
[]byte存储,且很大(比如 10GB),GC 虽然不扫描[]byte,但堆会很大,GOGC的计算会受影响。建议用 mmap 分配 Buffer Pool(第 1 章讲过)。
原因 4:写放大(页 16KB,只改 100 字节)
改 100 字节 → 写 16KB
写放大 164 倍
优化方向:
方案 A:减小页大小
16KB → 4KB
写放大从 164 倍降到 41 倍
代价:树变高(每页 key 数少),读 IO 次数增加
方案 B:只写脏的部分(需要硬件支持)
某些 SSD 支持 "partial page write"
或者在文件系统层面做(需要 O_DIRECT + 特定对齐)
方案 C:延迟写 + 合并
不要在每次 Put 时写盘
而是在 Buffer Pool 淘汰时才写
如果同一个页被修改多次,只写最后一次
★ 这就是 Buffer Pool 的 dirty flag 的作用
原因 5:没有利用批量写入
// ❌ 逐条插入
for i := 0; i < 10000; i++ {
tree.Put(int64(i), value) // 10000 次独立的树操作
}
// ✅ 批量插入(Bulk Loading)
// 方法:先排序,然后自底向上建树
func BulkLoad(entries []Entry) *BTree {
// 1. 排序
sort.Slice(entries, func(i, j int) bool {
return entries[i].Key < entries[j].Key
})
// 2. 自底向上建叶子层
var leafPages []uint32
for i := 0; i < len(entries); i += entriesPerPage {
end := min(i+entriesPerPage, len(entries))
pageID := buildLeafPage(entries[i:end])
leafPages = append(leafPages, pageID)
}
// 3. 逐层向上建内部节点
currentLevel := leafPages
for len(currentLevel) > 1 {
var nextLevel []uint32
for i := 0; i < len(currentLevel); i += fanout {
end := min(i+fanout, len(currentLevel))
pageID := buildInternalPage(currentLevel[i:end])
nextLevel = append(nextLevel, pageID)
}
currentLevel = nextLevel
}
return &BTree{rootPageID: currentLevel[0]}
}效果:
- 批量加载比逐条插入快 5-20 倍
- 因为:无分裂、顺序写、页面填充率高(接近 100%)
适用场景:数据初始化、数据迁移、重建索引
我的优化顺序
第 1 步: 测量,确认瓶颈
· 用 fio 测裸盘性能(知道理论上限)
· 统计 fsync 次数、磁盘 IO 次数
· 检查 Buffer Pool 命中率
第 2 步: 检查是否每次 fsync ★ 收益最大,改动最小
· 如果是,改成 WAL + 组提交
· 预期提升: 5-50 倍
第 3 步: 检查主键是否随机
· 如果是,改成顺序主键或加前缀
· 预期提升: 2-6 倍
第 4 步: 增大 Buffer Pool
· 一行配置改动
· 预期提升: 1.5-4 倍(取决于当前命中率)
第 5 步: 如果是批量导入场景,用 BulkLoad
· 预期提升: 5-20 倍
第 6 步: 如果以上都不够,考虑换 LSM-Tree
· 这是架构级改动,只在写:读 > 10:1 时值得
一句话总结
B+Tree 的写入性能问题,90% 的情况下是”fsync 太频繁”或”随机写”。
- fsync 问题 → WAL + 组提交(把 N 次 fsync 合并成 1 次)
- 随机写问题 → 顺序主键 / 批量加载 / 换 LSM
这两个问题都体现了同一个法则:把随机变成顺序,把多次变成一次。
这跟第 1 章讲的”批量缓存”、第 2 章讲的”LSM 把随机写变顺序写”,是完全相同的思路。
小结:这一节你应该带走的东西
-
磁盘版 B+Tree 和内存版是两回事:指针变页号、变长记录用 Slotted Page、要 Buffer Pool、要处理崩溃和并发。
-
Slotted Page 是数据库页的标准结构:把变长记录(从后往前)和定长槽位(从前往后)分开,实现 O(1) 删除、变长支持、页内二分。
-
Buffer Pool 的 pin count 和 dirty flag 是核心机制。淘汰策略要防”全表扫描污染”(InnoDB 的 Young/Old 分区)。
-
页大小是”单次 IO 效率”和”写放大”的权衡。HDD 用 16KB(InnoDB)、8KB(PG)、4KB(SQLite)都对,只是权衡点不同。
-
顺序 vs 随机的差异是 6 倍(自增主键 vs UUID)。InnoDB 用 PAGE_DIRECTION 优化顺序插入的分裂策略。
-
写放大是 B+Tree 的固有代价(改 100 字节写 16KB)。doublewrite buffer 让它更严重,但这是为了防止 partial write。
-
fsync 是写入性能的最大杀手。WAL + 组提交是标准解法,代价是最多丢失一个提交周期的数据。
下一节
老陈的预告:如果写入到一半断电了,你的 B+Tree 会变成什么样?
答案可能比你想象的更糟——不只是丢数据,整个树可能都坏了。WAL 就是解决这个问题的。
然后我们会实现 MVCC——那个让”读不加锁”成为可能的神奇机制。