DS1.1 DiskSpaceManager 设计
2025-4-5
| 2025-4-11
Words 4448Read Time 12 min
type
status
date
slug
summary
tags
category
icon
password
这是一个 磁盘空间管理器(disk space manager) 的文档注释,描述了其分页机制、元数据结构和虚拟页编号规则。以下是该注释的翻译与结构解析:

系统概述:Disk Space Manager

该管理器实现了 虚拟页地址映射(virtual page translation),使用两级页目录:

页结构:

  • 每个 header page
    • 包含一个位图(bitmap),指示哪些数据页已被分配
    • 管理 32K 个数据页
  • master page
    • 包含每个 header page 的分配统计信息(用 16-bit 整数表示)
    • 每个 master page 可追踪最多 2K 个 header pages(即:2K × 32K = 64M 数据页)
单个分区最大可管理:64M 个 data pages

缓存策略:

  • master page 和 header pages 常驻内存
  • 每次修改都会立即写回磁盘
  • 内存开销小:128MB 分区仅需缓存 2 页(master + 一个 header)
该缓存机制独立于 buffer manager。

虚拟页编号(Virtual Page Numbers)

  • 使用 64-bit 整数(Java long)表示
  • 编号格式为:
其中:
  • partition_number 是分区编号
  • n 是该分区中第 n 个数据页(从 0 开始)
✅ 优点:易于调试,如:10000000006 表示分区 1 的第 6 页,而不是解析二进制组合出来的编号。

文件结构(每个分区对应一个 OS 文件):

  • 第 0 页:master page
  • 第 1 页:第一个 header page
  • 第 2~32769 页:该 header page 管理的 32K data pages
  • 第 32770 页:第二个 header page
  • 接下来的 32K 页:由第二个 header page 管理
  • 依此类推...

总结重点:

概念
数值/结构
Header 管理页数
32K data pages
Master 管理 header 数量
2K headers → 最多 64M data pages
页编号格式
partition × 10¹⁰ + page_in_partition
缓存策略
header/master 常驻内存,立即刷新磁盘
OS 文件结构
第 0 页 master,第 1 页起为 header 和数据页

为什么这么设计?

这个磁盘空间管理器的设计背后有几个非常清晰的工程动机,它解决的是如何高效地管理超大规模页空间的问题,并在性能、可扩展性、可调试性、与文件系统集成之间做了平衡。我们来拆解设计背后的 5 个核心原因

1. 两级目录结构是为了高扩展性

问题:

直接用一张页表管理几百万甚至几千万个页,会导致:
  • 巨大的元数据(位图或数组)
  • 线性查找和更新变慢
  • 难以持久化、分区

解法:

Master Page + Header Pages 二级结构
  • 每个 header 管 32K data pages
  • 每个 master 管 2K headers → 总共 64M 页
这种分层结构就像页表(Page Table)一样,有效降低了元数据的内存需求,也方便分段缓存。

2. 页编号设计便于调试与分区隔离

页编号格式:

好处:

  • 简单一眼看懂是哪一个分区的哪一页(调试友好)
  • 而不是复杂的 bit manipulation:例如 upper 32 bits = partition,难以肉眼解析
举个例子:
10000000006 就是分区 1 的第 6 页,看起来一目了然。

3. 主索引页和目录页常驻内存,性能更高

  • 这两个页是页管理的“导航图”
  • 常驻内存意味着:
    • 分配页不需要反复读盘
    • 查找页快速
    • 修改立即刷新,保证一致性
只需缓存 2 页/分区,代价很小!

4. 目录信息结构化,方便页分配与释放

Header pages 使用 bitmap 来管理页分配:
  • 哪些 data page 被使用?
  • 哪些是空闲的?
  • 可以快速找到可分配页
Master Page 存了每个 header 的使用统计,可以快速跳过全满的 header page,定位可能有空位的区域。

5. 与 OS 文件一一对应,简化实现和调试

  • 每个 partition 对应一个文件(OS-level file)
  • 页的布局按序号安排:第 0 页是 master,第 1 页是 header...
好处:
  • 简单易查找
  • 无需特殊映射结构
  • 利于调试与故障恢复(能按编号定位)

总结一句话:

这个设计像是“多级页表 + 文件分区 + 可调试编号 + 局部缓存”的结合体,目标是在大规模数据页管理下,实现高效、清晰、可扩展的磁盘管理。
 
notion image

整体结构说明:Disk Space Manager 流程图

我们可以把这个流程拆成 4 个步骤,如下所示:

Step 1: 请求分配虚拟页(Virtual Page)

用户发起一个操作,比如:
这表示我们要在某个分区中分配一个新的 data page。

Step 2: 访问 Master Page

系统从磁盘或缓存中读取该分区的 Master Page(第 0 页),这里保存了:
  • 该分区下每个 header page 管理的使用情况(空闲页数量)
目的:快速找到可能有空间的 header page

Step 3: 查找 Header Page

根据 Master Page 的信息,我们选中一个 尚有空闲页的 Header Page(比如第 1 页、第 32770 页等)
Header Page 中有一个 bitmap,它标记了:
  • 哪些 data page 是空闲的(0)
  • 哪些已经分配了(1)

Step 4: 分配 Data Page + 更新元数据

在这个 header page 下,我们找到第一个空位(比如 bitmap 中第 24 位是 0):
  • 将该位设置为 1(表示分配)
  • 更新 Master Page 中该 header 的已用计数
  • 返回对应的虚拟页号,例如:
假设 partition_id = 3,这是 header 的第 2 个页、第 24 个位置:

总结:流程图逻辑串联起来就是——

为什么要设计 Partition?

在数据库系统中,磁盘空间管理器的目标是高效管理 海量页空间,如果整个数据库所有页都用一个统一的地址空间管理,会遇到几个问题:

单一大文件的问题:

  1. 元数据巨无霸(header bitmap 太大)
  1. 难以并发扩展和维护
  1. 跨表、跨事务资源难隔离
  1. 文件系统本身可能对单文件大小有限制

使用 Partition 的优点:

优点
描述
物理隔离
每个 partition 是一个独立文件,便于维护和备份
逻辑隔离
可以按表、索引、日志等类型分配不同 partition
并行扩展
多个 partition 可并行访问,提高 I/O 吞吐
易于调试
virtual_page_number 中包含 partition_id,定位直观
轻量索引
每个 partition 只需一个 master page 和 header 目录即可

每个 Partition 是什么?

每个 partition 就是一个 数据库文件(OS file),它的内部结构如下:
页编号
类型
内容
0
Master Page
记录各 header page 的使用统计
1
Header Page 1
管理 data page 0~32K-1 的位图
2~32769
Data Pages
真正存放记录的数据页
32770
Header Page 2
管理 data page 32K~64K-1
...
...
每 32K 页对应一个 header 页

虚拟页编号中的 Partition 是怎么用的?

系统定义虚拟页号格式为:
举个例子:
  • virtual_page_number = 20000001234
  • 说明这是 第 2 个 partition 中的第 1234
这个编号设计既能保证全局唯一,又容易拆解出分区信息。

总结一下 Partition 的设计哲学:

“Partition 是数据库页空间的物理与逻辑分片单位,提供了隔离性、可扩展性、并发性与易维护性。”

Partition 的分配策略

数据库系统在底层如何将逻辑对象(如表、索引、日志等)映射到具体的 partition(磁盘分区 / 文件),这一点直接影响系统的 可扩展性、并发性、数据隔离能力,属于 空间分配的核心设计策略

1. 按用途(Usage-Based Partitioning)

每种用途一个 partition:

Partition ID
用途(例子)
0
系统元数据(catalog)
1
表(user table)
2
索引(B+ Tree)
3
日志 / undo log
4
临时表 / 工作区

优点:

  • 物理隔离,不同类型数据独立管理
  • 日志、临时页可以快速清空、分区独立销毁
  • 容易设置缓存策略(例如:日志页高频写、表页高频读)

2. 按对象(Object-Based Partitioning)

每个对象(表/索引)一个 partition:

Partition ID
对象类型
名称
10
Users
11
Orders
12
索引
idx_date

优点:

  • 强隔离性:单个表可独立备份、回滚、压缩
  • 容易按对象统计页数、增长趋势
  • 更便于并发访问(页表不会成为瓶颈)
❗ 缺点:对象太多会导致 partition 数量过多,master/header 内存占用提升

3. 按空间大小或页数(Capacity-Based Partitioning)

分区达到阈值则开启新 partition:

比如:每个 partition 最多 100MB,当满了就新建一个
Partition ID
状态
0
1
2
当前活跃分配区

优点:

  • 动态扩展,不提前绑定逻辑对象
  • 大型对象(如堆表)可跨多个 partition 分布
📍 常用于:Log-structured merge trees (LSM)、分片(sharding)等系统中

实际系统中的混合策略

现代数据库通常混合使用以上策略,例如:
  • 系统页、日志页 → 固定用途的 partition
  • 每个表/索引 → 单独分配 partition
  • 表特别大时再用 paging 或动态扩展

🔧 如何选择策略?

目标
推荐策略
高并发隔离
按对象
简单实现、空间节省
按用途
可动态扩展
按容量 / 动态创建
容灾/备份友好
按对象 + 按用途结合

延伸应用:支持并行 IO

多个 partition → 多个文件 → 可以用 多个磁盘或线程并发读写
→ 带来显著吞吐量提升,尤其在 SSD 或分布式系统中效果明显

Partition Allocator 的设计模式

目标:在数据库内部如何 生成 / 分配 / 管理 partition ID 和对应文件

背景回顾

我们知道每个 partition 对应一个磁盘文件(如 partition_0001.db),并用一个整数 partition_id 唯一标识它。所有虚拟页号都是:
所以 partition allocator 的职责是:
功能
描述
分配新 partition ID
不冲突,不重复,可回收
持久化映射关系
记录 partition_id ↔ 文件路径
提供查找接口
能根据 virtual_page_number 快速定位分区文件
管理生命周期
Drop 时释放 partition,避免泄漏

常见分配策略

① 自增分配(Monotonic Counter)

优点:简单实现、递增编号、调试方便
缺点:drop 后不能复用 ID,长期增长(但通常问题不大)

② 空洞复用(Gap Filling)

  • 维护一个 空闲 ID 集合(free list)
  • 优先复用已被 drop 的 partition_id
优点:节省编号空间
缺点:实现复杂些,需持久化 free_ids 状态

③ 哈希分配(Hash-Based)

如果 partition 与某种逻辑对象强绑定,比如:
优点:可快速查找
缺点:不适合动态增长或管理生命周期(hash 冲突难处理)

文件名 & 路径策略(配合编号)

Partition ID
文件路径示例
0
data/part_0000.db
42
data/part_0042.db
12345
data/part_12345.db
  • 规则命名有助于调试、自动化、恢复
  • 你还可以加用途前缀,比如 index_part_001.dblog_part_007.db

元数据持久化建议

为了让系统重启后还知道有哪些 partition,建议维护一个持久化的映射表:
这个文件可以叫做 partition_catalog.meta 或类似名字。

🔧 接口设计建议(伪代码)

延伸功能

功能
实现点子
truncate/drop 表
删除对应 partition 文件 + 回收 ID
压缩 partition ID 空间
重建 ID 映射表(需要全局迁移)
版本控制
文件命名加 .v1, .v2 标记

总结

设计目标
推荐实现方式
简洁、调试方便
自增分配 + 显式命名
节省 ID 空间
空洞复用 + 持久化 free list
快速恢复
映射表持久化 (partition_catalog)
易扩展
封装为模块,提供独立接口

partition num 如何分配?

这段 Java 代码是一个 分配新的 partition 的内部辅助方法 allocPartHelper,核心任务是:
在磁盘上创建并初始化一个新分区,并将其注册到系统的 partition 管理结构中,同时配合事务和日志系统完成持久化记录。

方法签名

  • 传入参数:partNum 是即将分配的新 partition 的编号(ID)
  • 返回值:成功创建的 partition ID(即 partNum

步骤解析

我们按顺序分段讲:

1. 获取全局管理器锁(managerLock

说明:整个 DiskSpaceManager 是并发访问的,要先锁住它,避免多个线程同时改 partition 表。

2. 验证该 partition 不存在

说明:确保没有重复分配同一个 partition(防止覆盖已有文件或元数据冲突)

3. 创建 PartitionHandle 并注册

说明:
  • PartitionHandle 是这个 partition 的运行时句柄(包含锁、路径、状态等)
  • 注册到 partInfo 映射中:Map<partNum, PartitionHandle>

4. 给这个 partition 单独加锁

注意:虽然你释放了全局锁 managerLock,但现在你已经锁住了具体这个 partition,防止别的线程操作它。

5. 日志记录:记录这个分区已被分配

说明:
  • 如果当前有活跃事务(即不是 crash 恢复阶段),就要写一条 WAL 日志(Write-Ahead Logging)
  • 日志内容是:"Transaction X 分配了 partition Y"
这是数据库恢复机制的一部分!

6. 打开分区对应的文件/目录

实际上是去创建或打开 db/partitions/00042/ 这样的目录结构和初始文件(如 master page)

7. 成功返回分配的 partition ID


8. 释放 partition 的锁

保证锁释放在 finally 中,即使前面出异常也能释放

总结流程图式说明


为什么这样设计?

设计点
目的
两级锁(managerLock → partitionLock)
控制粒度,避免死锁 / 粗粒度锁
日志记录
支持事务 + 崩溃恢复(WAL)
PartitionHandle 抽象
解耦 partition 的状态与操作
延迟 open 操作
避免 manager 和 log manager 死锁

为什么要给这个 partition 单独加锁

即这句代码:
是在对某一个 partition 的结构和资源加独立锁,即使我们已经在外层用了全局锁 managerLock。为什么还要多此一举?其实背后是非常精妙的并发控制设计。

核心目标:细粒度并发控制,避免竞争与死锁


背景回顾:两种锁

锁名
作用范围
作用时机
managerLock
全局 partition 注册表
用于管理 partInfo(如 put)
partitionLock
某个具体 partition 的内部
用于操作 partition 文件/状态

为什么要再加 partitionLock

1. 允许并发创建多个 partition

如果你只用 managerLock,每次只能创建一个 partition,别的线程全阻塞(性能低)。
而用这种设计:
  • 短时间持有 managerLock:只用来注册 PartitionHandle
  • 然后立即释放
  • 再用 partitionLock 来保护真正的操作(如 open 文件)
多个线程可以并行地创建不同 partition,彼此互不干扰!

2. 保证 partition open 和初始化是原子的

open() 是涉及文件系统、页表、日志的复杂操作,可能失败或并发修改。
如果没有 partitionLock,你可能遇到这种情况:
  • 线程 A 正在 open
  • 线程 B 也来读或写这个 partition
  • 会造成 状态不一致或文件损坏
加锁可以避免这种 race condition,确保 partition 的生命周期行为是原子的、串行的、安全的

3. 支持未来对 partition 的并发访问

比如你以后实现:
你就可以让多个线程并发访问 不同的 partition,但一个 partition 依然是串行访问。
这是典型的 per-resource concurrency control 模式

如果不加 partitionLock 会怎么样?

  • 如果多个线程操作同一个 partition,可能会同时 open 两次、并发写文件,导致 crash 或数据损坏
  • 在恢复过程中(涉及 WAL 日志和文件状态)也可能出错
  • 未来实现多线程读写页时,无法实现正确并发

总结一句话:

partitionLock 的设计是为了实现对每个 partition 的“最小隔离单元”保护,它允许系统同时管理多个 partition,又能保证每个 partition 自身是线程安全的。

多线程 Partition 分配流程:锁依赖与调度表

步骤
Thread A (allocPartHelper(42))
Thread B (allocPartHelper(43))
说明
managerLock.lock()
等待 managerLock
A 先抢到 manager 锁
validatePartitionUniqueness(42)
A 验证 partNum 唯一性
PartitionHandle(42) 创建 + 注册
注册到 partInfo
partitionLock(42).lock()
加细粒度锁
managerLock.unlock()
managerLock.lock() 成功
A 释放后,B 拿到 manager 锁
validatePartitionUniqueness(43)
B 验证唯一性
PartitionHandle(43) 创建 + 注册
B 注册
partitionLock(43).lock()
B 加自己的 partition 锁
写 WAL: logAllocPart(42)
写 WAL: logAllocPart(43)
各自记录日志(事务存在时)
pi.open("dbDir/42")
pi.open("dbDir/43")
打开各自 partition 目录
partitionLock(42).unlock()
partitionLock(43).unlock()
释放锁,完成初始化

图解锁依赖(逻辑流程):


并发安全性分析

说明
✅ 全局注册安全
managerLock 确保不会同时注册两个 partition
✅ 局部互斥
partitionLock 保证每个 partition 的 open 是串行的
✅ 高并发
不同 partition 并发创建、独立加锁,无阻塞
✅ 无死锁
所有线程按相同顺序加锁:manager → partition,避免循环依赖
 
  • database
  • Database System (二)Database System (一)
    Loading...
    Catalog
    0%