CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 使用Go从零开发一个数据库 说明
  • 1.文件与数据库
  • 2.索引
  • 3.B树:原理
  • 4.B树:实践(第一部分)
  • 5.B树:实践(第二部分)
  • 6.持久化到磁盘
  • 7.空闲列表:重用页面
    • 7.1 设计空闲列表
    • 7.2 空闲列表数据类型
    • 7.3 空闲列表的实现
    • 7.4 管理磁盘页面
      • 步骤1:修改数据结构
      • 步骤2:B树的页面管理
      • 步骤3:空闲列表的页面管理
      • 步骤4:更新空闲列表
      • 步骤5:完成
  • 8.行与列
  • 9.范围查询
  • 10.二级索引
  • 11.原子事务
  • 12.并发读写
  • 13.查询语言:解析器
  • 14.查询语言:执行
目录

7.空闲列表:重用页面

# 07. 空闲列表:重用页面

由于我们的B树是不可变的,键值存储(KV store)的每次更新都会在路径上创建新节点,而不是更新现有节点,这就导致一些节点从最新版本无法访问到。我们需要重用这些旧版本中无法访问的节点,否则数据库文件会无限增长。

# 7.1 设计空闲列表

为了重用这些页面,我们将添加一个持久化的空闲列表来跟踪未使用的页面。更新操作在追加新页面之前会从列表中重用页面,并且当前版本中未使用的页面会被添加到列表中。

这个列表用作栈(后进先出),每次更新操作都可以从列表顶部移除页面并添加新页面。

//  列表中的项目数量
func (fl *FreeList) Total() int
//  获取第n个指针
func (fl *FreeList) Get(topn int) uint64
//  移除popn个指针并添加一些新指针
func (fl *FreeList) Update(popn int, freed []uint64)
1
2
3
4
5
6

和我们的B树一样,空闲列表也是不可变的。每个节点包含:

  1. 多个指向未使用页面的指针。
  2. 指向下一个节点的链接。
  3. 列表中项目的总数。这仅适用于头节点。
|   node1   |      |   node2   |      |   node3   |
+-----------+     +-----------+     +-----------+
|  total=xxx  |      |            |      |            |
|   next=yyy | ==> |   next=qqq  | ==> |   next=eee |  ==> . . .
|   size=zzz |       |   size=ppp |       |   size=rrr  |
|   pointers |       |   pointers |       |   pointers |
1
2
3
4
5
6

节点格式:

| type | size | total | next |   pointers |
|  2B   |  2B  |   8B  |  8B  | size * 8B  |
1
2
const BNODE_FREE_LIST = 3
const FREE_LIST_HEADER = 4 + 8 + 8
const FREE_LIST_CAP = (BTREE_PAGE_SIZE - FREE_LIST_HEADER) / 8
1
2
3

用于访问列表节点的函数:

func flnSize(node BNode) int
func flnNext(node BNode) uint64
func flnPtr(node BNode, idx int)
func flnSetPtr(node BNode, idx int, ptr uint64)
func flnSetHeader(node BNode, size uint16, next uint64)
func flnSetTotal(node BNode, total uint64)
1
2
3
4
5
6

# 7.2 空闲列表数据类型

FreeList类型由头节点指针和管理磁盘页面的回调函数组成。

type FreeList struct {
    head uint64
    //  用于管理磁盘页面的回调函数
    get func(uint64) BNode  //  解引用指针
    new func(BNode) uint64  //  追加新页面
    use func(uint64, BNode)  //  重用页面
}
1
2
3
4
5
6
7

这些回调函数与B树的不同,因为列表使用的页面由列表自身管理。

  • new回调函数仅用于追加新页面,因为空闲列表必须重用自身的页面。
  • 没有del回调函数,因为空闲列表会将未使用的页面添加到自身。
  • use回调函数记录对重用页面的待处理更新。
type BTree struct {
    //  指针(非零的页号)
    root uint64
    //  用于管理磁盘页面的回调函数
    get func(uint64) BNode //  解引用指针
    new func(BNode) uint64 //  分配新页面
    del func(uint64)       //  释放页面
}
1
2
3
4
5
6
7
8

# 7.3 空闲列表的实现

从列表中获取第n个项目只是简单的列表遍历。

func (fl *FreeList) Get(topn int) uint64 {
    assert(0 <= topn && topn < fl.Total())
    node := fl.get(fl.head)
    for flnSize(node) <= topn {
        topn -= flnSize(node)
        next := flnNext(node)
        assert(next != 0)
        node = fl.get(next)
    }
    return flnPtr(node, flnSize(node)-topn-1)
}
1
2
3
4
5
6
7
8
9
10
11

更新列表比较复杂。它首先从列表中移除popn个项目,然后将释放的页面添加到列表中,这可以分为三个阶段:

  1. 如果头节点的项目数大于popn,则移除该节点。该节点本身稍后会被添加到列表中。重复此步骤,直到无法再进行。
  2. 我们可能需要从列表中移除一些项目,并可能向列表中添加一些新项目。更新列表头需要新页面,新页面应从列表自身的项目中重用。逐个从列表中弹出项目,直到有足够的页面用于下一阶段。
  3. 通过添加新节点来修改列表。
//  移除popn个指针并添加一些新指针
func (fl *FreeList) Update(popn int, freed []uint64) {
    assert(popn <= fl.Total())
    if popn == 0 && len(freed) == 0 {
        return  //  无需操作
    }

    //  准备构建新列表
    total := fl.Total()
    reuse := []uint64{}
    for fl.head != 0 && len(reuse)*FREE_LIST_CAP < len(freed) {
        node := fl.get(fl.head)
        freed = append(freed, fl.head)  //  回收节点本身
        if popn >= flnSize(node) {
            //  阶段1
            //  移除该节点中的所有指针
            popn -= flnSize(node)
        } else {
            //  阶段2:
            //  移除一些指针
            remain := flnSize(node) - popn
            popn = 0
            //  从空闲列表本身重用指针
            for remain > 0 && len(reuse)*FREE_LIST_CAP < len(freed)+remain {
                remain--
                reuse = append(reuse, flnPtr(node, remain))
            }
            //  将节点中的剩余指针移动到freed列表
            for i := 0; i < remain; i++ {
                freed = append(freed, flnPtr(node, i))
            }
        }
        //  丢弃当前节点并移动到下一个节点
        total -= flnSize(node)
        fl.head = flnNext(node)
    }
    assert(len(reuse)*FREE_LIST_CAP >= len(freed) || fl.head == 0)

    //  阶段3:  在列表开头添加新节点
    flPush(fl, freed, reuse)

    //  完成
    flnSetTotal(fl.get(fl.head), uint64(total+len(freed)))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func flPush(fl *FreeList, freed []uint64, reuse []uint64) {
    for len(freed) > 0 {
        new := BNode{make([]byte, BTREE_PAGE_SIZE)}

        //  构建一个新节点
        size := len(freed)
        if size > FREE_LIST_CAP {
            size = FREE_LIST_CAP
        }
        flnSetHeader(new, uint16(size), fl.head)
        for i, ptr := range freed[:size] {
            flnSetPtr(new, i, ptr)
        }
        freed = freed[size:]

        if len(reuse) > 0 {
            //  从列表中重用一个指针
            fl.head, reuse = reuse[0], reuse[1:]
            fl.use(fl.head, new)
        } else {
            //  或者追加一个页面来存储新节点
            fl.head = fl.new(new)
        }
    }
    assert(len(reuse) == 0)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 7.4 管理磁盘页面

# 步骤1:修改数据结构

数据结构被修改了。临时页面保存在一个以分配的页号为键的映射中。被移除的页号也记录在其中。

type KV struct {
    //  省略部分代码...
    page struct {
        flushed uint64 //  以页数表示的数据库大小
        nfree   int    //  从空闲列表获取的页面数量
        nappend int    //  要追加的页面数量
        //  新分配或释放的页面,以指针为键。
        //  nil值表示已释放的页面。
        updates map[uint64][]byte
    }
}
1
2
3
4
5
6
7
8
9
10
11

# 步骤2:B树的页面管理

pageGet函数被修改,使其也能返回临时页面,因为空闲列表代码依赖此行为。

//  B树和空闲列表的回调函数,解引用指针。
func (db *KV) pageGet(ptr uint64) BNode {
    if page, ok := db.page.updates[ptr]; ok {
        assert(page != nil)
        return BNode{page}  //  对于新页面
    }
    return pageGetMapped(db, ptr)  //  对于已写入的页面
}

func pageGetMapped(db *KV, ptr uint64) BNode {
    start := uint64(0)
    for _, chunk := range db.mmap.chunks {
        end := start + uint64(len(chunk))/BTREE_PAGE_SIZE
        if ptr < end {
            offset := BTREE_PAGE_SIZE * (ptr - start)
            return BNode{chunk[offset : offset+BTREE_PAGE_SIZE]}
        }
        start = end
    }
    panic("bad ptr")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

分配B树页面的函数被修改为首先从空闲列表中重用页面。

//  B树的回调函数,分配新页面。
func (db *KV) pageNew(node BNode) uint64 {
    assert(len(node.data) <= BTREE_PAGE_SIZE)
    ptr := uint64(0)
    if db.page.nfree < db.free.Total() {
        //  重用已释放的页面
        ptr = db.free.Get(db.page.nfree)
        db.page.nfree++
    } else {
        //  追加新页面
        ptr = db.page.flushed + uint64(db.page.nappend)
        db.page.nappend++
    }
    db.page.updates[ptr] = node.data
    return ptr
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

被移除的页面会被标记,以便稍后更新空闲列表。

//  B树的回调函数,释放页面。
func (db *KV) pageDel(ptr uint64) {
    db.page.updates[ptr] = nil
}
1
2
3
4

# 步骤3:空闲列表的页面管理

空闲列表追加新页面和重用页面的回调函数:

//  空闲列表的回调函数,分配新页面。
func (db *KV) pageAppend(node BNode) uint64 {
    assert(len(node.data) <= BTREE_PAGE_SIZE)
    ptr := db.page.flushed + uint64(db.page.nappend)
    db.page.nappend++
    db.page.updates[ptr] = node.data
    return ptr
}

//  空闲列表的回调函数,重用页面。
func (db *KV) pageUse(ptr uint64, node BNode) {
    db.page.updates[ptr] = node.data
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 步骤4:更新空闲列表

在扩展文件并将页面写入磁盘之前,我们必须先更新空闲列表,因为它也会产生待处理的写入操作。

func writePages(db *KV) error {
    //  更新空闲列表
    freed := []uint64{}
    for ptr, page := range db.page.updates {
        if page == nil {
            freed = append(freed, ptr)
        }
    }
    db.free.Update(db.page.nfree, freed)

    //  如果需要,扩展文件和内存映射
    //  省略部分代码...

    //  将页面复制到文件
    for ptr, page := range db.page.updates {
        if page != nil {
            copy(pageGetMapped(db, ptr).data, page)
        }
    }
    return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

列表头指针被添加到主页面:

| sig | btree_root | page_used | free_list |
| 16B  |     8B     |     8B      |     8B    |
1
2

# 步骤5:完成

键值存储完成了。它是持久化的并且具有抗崩溃能力,尽管只能顺序访问。

本专栏第二部分还有更多内容要学习:

  • 在键值存储之上构建关系型数据库。
  • 数据库的并发访问和事务处理。
上次更新: 2025/04/16, 02:04:00
6.持久化到磁盘
8.行与列

← 6.持久化到磁盘 8.行与列→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式