第九章 关系数据库存储管理
课程引言与DBMS层次结构
在深入存储管理之前,我们首先要回顾一下DBMS(数据库管理系统)的层次结构。这有助于我们理解存储管理层在整个系统中的位置和作用。
- DBMS的层次结构 (由上至下):
- 应用层: 用户和应用程序(如SQL查询)的接口。
- 语言翻译处理层 : 负责解析、编译和优化查询语句(如SQL)。它处理的是逻辑数据结构(元组、关系、视图)。
- 数据存取层 : 负责实现逻辑与物理数据之间的转换。它处理的是逻辑记录 、逻辑块 和逻辑存取路径。
- 数据存储层 : 这是本章的重点。它负责管理物理存储,包括存储记录 、物理块 以及系统缓冲区。
- 操作系统 (OS): DBMS建立在OS之上,依赖OS进行最终的磁盘I/O操作。
- 接口 :
- 多元组接口 (如SQL): 应用层与语言翻译处理层之间。
- 单元组接口: 语言翻译处理层与数据存取层之间。
- 存储器接口: 数据存取层与数据存储层之间。

存储管理概述
存储管理的核心功能:
- 缓冲区管理: 管理内存中的缓冲区,这是DBMS性能的关键。
- 内外存交换: 负责在缓冲区(内存)和数据库(外存/磁盘)之间高效地交换数据。
- 外存管理: 管理数据库在磁盘上的物理存储空间。
存储管理层的接口:
- 向上: 向数据存取层提供"存储器接口",即一个由定长页面(Page)组成的逻辑线性地址空间(系统缓冲区)。
- 向下: 依赖操作系统的存取原语(I/O调用)来访问物理磁盘。
9.1 缓冲区管理
缓冲区管理是DBMS性能调优的核心。
1. 设置缓冲区的原因
- 提供设备独立性: 上层操作(如查询处理)是基于系统缓冲区的(逻辑页面),而不是直接基于物理磁盘。这使得底层外存设备(如更换不同型号的磁盘)的变更不会影响上层逻辑。
- 提高存取效率 :
- 利用内存远高于磁盘的读写速度。
- 实现异步读写 :
- 预读: 预测后续可能需要的数据页,并提前将其读入缓冲区。
- 延迟写: 对数据的修改只在缓冲区中进行,并标记为"脏页"(Dirty Page),稍后在系统空闲或缓冲区满时再批量写回磁盘,减少I/O次数。
2. 缓冲区大小
- 太大: 会过多占据服务器的内存空间,影响其他进程运行。
- 太小: 导致频繁的"缺页"和"调页"(即缓冲区命中率低),需要不断从磁盘读取数据,可能导致系统"抖动"(Thrashing),严重影响效率。
- 结论: 设置缓冲区大小是数据库参数调优的重要任务。
3. 缓冲区管理结构
- 缓冲区只在内存中: RDBMS(关系型数据库)通常采用此方法。DBMS自己管理一块专用的内存空间作为缓冲区。
- 缓冲区在虚存中 : 内存DBMS和OODBMS(面向对象数据库)可能采用。DBMS将缓冲区建立在虚拟内存中,由OS来决定哪些页面常驻内存,哪些交换到磁盘的"交换空间"(Swap Space)。
4. 缓冲区的组织与使用
-
组织 : 缓冲区被划分为许多大小相同的帧 (Frame) 。一个帧的大小通常等于一个磁盘块(或页 Page)的大小。

-
使用流程:
-
读数据 (READBUF) :

- DBMS请求一个数据页。
- 在缓冲区中查找页(可使用顺序查找、折半查找或Hash查找算法)。
- 命中: 在缓冲区中找到,直接从内存返回数据。
- 未命中 :
- 在缓冲区中申请一个空闲帧。
- 有空闲帧: 直接使用该帧。
- 无空闲帧 : 按淘汰策略 选择一个现有帧进行淘汰。
- 淘汰操作 : 如果被淘汰的帧是"脏页",必须先将其写回 磁盘。
- 从外存(磁盘)将请求的数据页读入 到这个帧中。
- 返回该页在缓冲区中的位置。
-
更新数据:
- 在缓冲区中直接修改数据页。
- 将该页标记为"脏数据" 。
- 脏数据必须在后续某个时刻(如缓冲区清空、页被替换时)写回磁盘,以保证数据持久性。
-
缓冲区置换:
- 当缓冲区满时,必须选择一个已缓存的页进行替换。替换策略对缓存性能影响极大。
-
5. 页面置换策略
LRU (Least Recently Used, 最近最少使用)
- 思想: 淘汰最长时间没有被读或写过的缓冲块。
- 实现 : 维护一个记录所有缓冲块访问时间戳的表,或(更常见的)使用一个双向链表。
- LRU链表 :
- 链表头 (MRU, Most Recently Used): 存放最近刚被访问的块。
- 链表尾 (LRU, Least Recently Used): 存放最久未被访问的块。
- 访问时: 将被访问的块移动到链表头。
- 淘汰时: 总是淘汰链表尾的块。
FIFO (First-In, First-Out, 先进先出)
- 思想: 淘汰最早读入缓冲区的块。
- 实现: 维护一个队列,或记录每个块读入的时间。
时钟算法
- 思想: LRU的近似实现,性能接近LRU但开销远小于LRU,是FIFO的一种改进。
- 实现 :
- 所有页面组织在一个环形链表中,一个指针("表针")指向最老的页面。
- 每个页面有两个标记位:
refcount(引用计数): 1表示正在被使用(被PIN住),0表示未使用。usage_count(访问计数): 记录被访问的次数(或简化为1位的访问位)。
- 淘汰时 :
- 表针从当前位置开始按FIFO顺序扫描。
- 遇到
refcount == 1(正在使用): 跳过,不能淘汰。 - 遇到
refcount == 0且usage_count > 0: 给予第二次机会 。将其usage_count减1(或清零),表针继续前进。 - 遇到
refcount == 0且usage_count == 0: 命中,淘汰此页。
- 访问时 : 访问一个页面时,将其
usage_count加1(或置1)。
RAND (随机算法)
- 思想: 使用随机数发生器来确定要淘汰的缓冲页面。
系统控制法
- 思想: 允许系统**"钉住" (PIN)** 某些重要的块,使其不能被淘汰。例如,B+树的根节点块。
- 注意 : 不单独使用,与上述其他方法(如LRU)结合使用。
混合算法
- 思想: 根据不同的操作(如索引扫描、全表扫描)选用不同的缓冲策略。
9.2 数据库的逻辑组织
- 两种主要方式 :
- 一个对象对应一个文件 : (如
table.dat,index.idx)。存储管理部分交由OS。 - 整个DB对应一个或若干个文件: 由DBMS进行精细化的存储管理。RDBMS普遍采用此方式。
- 一个对象对应一个文件 : (如
- RDBMS的存储管理 (以ORACLE为例) :
-
DBMS会把"文件"这个大单位划分成更小的逻辑单位(如段、分区),以增加灵活性。

-
逻辑组织层次:
- 数据库: 包含一个或多个表空间。
- 表空间 :
- 逻辑概念,是最大的逻辑存储单元。
- 物理上 对应一个或多个物理数据文件(
.dbf)。 - 用于逻辑地和物理地组织数据(如把数据和索引分开存放,把不同业务表分开存放)。
- 类型:系统表空间、联机/脱机表空间、永久/临时表空间等。
- 段 :
- 由多个分区 组成,是占用特定存储空间的对象(如表、索引)。
- 类型:数据段(存表数据)、索引段、回滚段、临时段等。
- 分区 :
- 由一组连续的数据块组成,是空间分配的中间单位。
- 数据块 :
- 最小的I/O单元(磁盘存取单元)。
- 也是缓冲区管理的基本单位(即"页 Page")。
- 其大小必须是服务器操作系统块大小的整数倍。
-
9.3 数据库的物理组织

9.3.1 记录的组织
1. 定长记录
-
构造 : 记录中所有字段都有固定长度(如
char(30))。字段在记录中按顺序连续存放。sqlCREATE TABLE MovieStar( Name char(30) primary key, Address varchar(255), Gender char(1), Birthdate date); -
地址对齐:
- 某些CPU架构(如RISC)要求特定类型的数据(如4字节整数、8字节双精度实数)必须从内存中4或8的倍数地址开始存取,否则会出错或效率低下。
- 解决方案 : 为了简化地址转换和提高可移植性,DBMS可能规定:
- 每条记录在块内从4(或8)的倍数字节处开始。
- 记录内的字段也从相对于记录起点的4(或8)的倍数字节处开始。
- 这可能会在字段间引入填充字节,浪费少量空间。

- 记录首部 :
- 存放在每条记录的开头,包含元数据。
- 内容: 指向模式的指针、记录长度、时间戳(最后修改或读取时间)等。

2. 变长记录
-
A. 具有变长字段的记录 (3种策略):
-
长度前缀法 : 在每个变长字段前加上一个固定大小(如1或2字节)的长度值。

-
定长部分优先法 : 先集中存放所有定长字段,再依次存放所有变长字段。这种方式访问定长字段较快。

-
单独存放法 : 在记录的定长部分只存放一个指向实际变长数据的指针,变长数据统一存放在块内的其他位置 或单独的块 中。

-
-
B. 具有重复字段的记录 (3种策略):
-
指针法 : 在记录首部用一组指针指向每个重复字段 的起始位置。

-
单独存放法 : 类似于变长字段,将所有重复字段实例存放在附加空间 中。

-
折衷方案: 在记录的定长部分预留足够的空间,存储下列信息
- 重复字段合理的出现次数;
- 指向可以找到这个重复字段其他出现的地方的指针;
- 其他出现的次数。
-
3. 可变格式记录
- 用途 :
- 数据集成时,不同源头的数据模式可能不统一。
- 半结构化数据,模式非常灵活(如许多字段可能重复,或根本不出现)。
- 通过只列出非空字段来节省空间。
- **实现策略:自描述 **
- 记录中不仅存值 ,还存模式信息(字段标识、类型)。
- 格式 :
(字段总数, 字段1ID, 字段1类型, 字段1值, 字段2ID, 字段2类型, 字段2长度, 字段2值, ...)

9.3.2 块 (Block) 的组织
块是I/O的基本单位,一个块中通常存放多条记录。
1. 定长记录的块组织
-
结构 :
[块头 (Header)] + [记录1] + [记录2] + ... + [记录N] + [空闲空间]
-
块头 : 块ID、时间戳、空闲空间头指针、每条元组在块内的偏移量(或一个位图标记哪些槽位被占用/删除)。
-
维护:
- 增: 在空闲空间头部直接插入新元组,更新块头信息。
- 改: 定长记录,直接在原位置修改。
- 删 :
- 方法1 (物理删除): 回收空间,将该记录之后的所有记录前移(开销大)。
- 方法2 (标记删除): 不移动记录,只在块头的偏移量表/位图中标记该记录"已删除"。空间在后续的块重组时统一回收。
2. 变长记录的块组织
-
结构 (槽页法) :
[块头] + [偏移量表 (Slot Array)] + [空闲空间] + [记录N] + ... + [记录2] + [记录1]
-
说明:
- 块头: 包含块信息,以及指向"空闲空间尾部"(即第一条记录的起始位置)的指针。
- 偏移量表 : 记录从块头之后开始增长。表中每个条目(槽 Slot)是一个指向对应记录在块内起始位置的指针(偏移量)。
- 记录区 : 记录从块的尾部向前"堆放"。
- 空闲空间: 位于"偏移量表"的末尾和"记录区"的开头之间。
-
优点 : 外部(如索引)对记录的引用 (RID - Record ID) 可以是
(块号, 槽号)。即使记录在块内因为删除或更新而移动了物理位置,只需要更新偏移量表中的指针即可,外部的引用(RID)保持不变。 -
维护:
- 增 :
- 在偏移量表中申请一个新槽。
- 从空闲空间尾部(记录区开头)分配空间给新记录。
- 在新槽中记录该记录的起始位置。
- 调整块头的"空闲空间尾指针"。
- 删 :
- 在偏移量表中将该记录对应的槽标记为"已删除"(或置空指针)。
- 释放记录占用的空间,并将该记录"前面"(物理地址更靠后)的记录前移,以保证记录区连续,空闲空间也连续。
- 修改所有被移动记录在偏移量表中的指针。
- 调整"空闲空间尾指针"。
- 改 :
- 原地放得下: 在原位置修改。
- 原地放不下: 类似于"删除+新增"。将记录迁移到新位置(可能在块内,也可能在其他块,即"行迁移" ),并更新偏移量表中的指针。
- 增 :
9.3.3 关系表的组织
-
堆存放方式
- 组织 : 表中的记录可以存放在该表的任何块中,没有顺序要求。
- 插入: 在该表的块中找到任何合适的空闲空间即可。如果所有块都满了,就为该表申请新的块。
- 特点: 插入速度最快,但查询(尤其是全表扫描之外的)效率低。
-
顺序存放方式
- 组织 : 表中的记录根据指定的属性 (或属性组)的取值大小顺序存放。
- 实现: 同一个表的不同块之间通过指针链接(形成链表),以实现全局有序。
- 特点: 插入和删除操作开销大(可能需要移动大量记录以保持顺序),但按排序键的范围查询极快。
-
多表聚簇存放方式
-
组织 : 将不同表 的、具有相同连接键值的元组"聚簇"存放在同一组物理块中。
-
示例 :
Student表 (主码Sno) 和SC表 (外码Sno),按照Sno相等进行聚簇存放。- 物理块中会像这样存放:
Student(Sno=1)->SC(Sno=1)->SC(Sno=1)... ->Student(Sno=2)->SC(Sno=2)...
- 物理块中会像这样存放:
-
优势:
- 极大减少连接操作(JOIN)带来的I/O开销。
- 对于基于连接键的连接查询 (
...WHERE Student.Sno = SC.Sno)和分组查询 (...GROUP BY Sname)效率极高。
-
劣势:
-
降低单表查询的效率(因为一个表的元组被分散到了更多块中),例如
sqlSELECT * FROM Student WHERE Smajor='计算机科学与技术' AND Ssex='女'; -
更新操作(特别是插入)会带来更频繁的数据迁移,维护成本高。
-
-
-
B+树存放方式
-
组织 : 表的数据记录本身就存储在B+树的叶子节点 中,并且按主键有序。

-
特点: 这是一种"索引组织表"(IOT)。访问效率高(始终通过索引),插入删除维护开销适中(B+树的分裂与合并)。
-
-
哈希存放方式
- 组织 : 用一个哈希函数 计算表中指定属性(哈希键)的哈希值,以此确定该记录应该放在哪个**哈希桶 **(对应一个或多个物理块)中。
- 哈希表: 由B个哈希桶组成(编号0到B-1)。
- 哈希函数 :
h(key)->Bucket_ID(0 到 B-1 之间的整数)。 - 特点 : 按哈希键的等值查询(
...WHERE key = value)速度极快(理论上O(1)次I/O)。但范围查询(...WHERE key > value)和无键查询无法支持,哈希冲突和数据倾斜处理较复杂。
-
LSM树存放方式
-
定义 : LSM树是一种分层、有序、对硬盘(特别是SSD)友好的数据存储方式。它充分利用了磁盘批量顺序写 远高于随机写 的性能。

-
结构 : 采用"内存+磁盘"的多层存储结构。
- 内存 (RAM) :
WAL Log (Write-Ahead Log): 所有写操作在执行前先顺序写入磁盘上的WAL日志,用于故障恢复。Memtable: 内存中的数据结构(通常是B+树、跳表等有序结构)。数据首先被写入Memtable并按键值有序。Immutable Memtable: 当Memtable大小达到阈值时,它会变为"不可变" (Immutable) 状态,等待刷入磁盘。此时新的写操作由新创建的Memtable处理,写操作不被阻塞。
- 磁盘 (DISK) :
SSTable (Sorted String Table): 一种持久化、有序且不可变的磁盘键值存储文件。- 分层 : 磁盘存储被分为多个层级
(Level 0, Level 1, ..., Level N)。
- 内存 (RAM) :
-
工作流程:
- 写 :
WAL Log→Memtable(在内存中有序)。 - 刷盘 :
Memtable满 → 变为Immutable Memtable→ (Minor Compaction) → 刷入磁盘成为Level 0层的一个新的SSTable文件。 - 合并 :
- 当
Level 0的SSTable文件数量达到阈值时,会触发 Major Compaction。 Level 0的SSTable(L0的SSTable之间可能有键重叠) 会和Level 1中键范围重叠的SSTable(L1内部SSTable键不重叠) 进行归并排序。- 合并后的新
SSTable写入Level 1。 - 以此类推,当
Level i大小达到阈值,会合并到Level i+1。层级越高,数据越"老",数据量越大(如 L0 10MB, L1 100MB, L2 1000MB...)。
- 当
- 写 :
-
读:
- 由于最新的数据可能在
Memtable,较老的数据在Immutable Memtable,更老的数据在 L0、L1...LN,且一个键的"删除"也是通过写入一个"删除标记"实现的。 - 因此,读操作 需要从新到老 依次查找:
Memtable→Immutable Memtable→Level 0(可能多个文件) ->Level 1-> ... ->Level N - 直到找到该键的第一个版本(可能是数据,也可能是"删除标记")。
- 由于最新的数据可能在
-
应用: 广泛用于NoSQL数据库 (Cassandra, RocksDB, HBase, LevelDB) 和 NewSQL数据库 (TiDB)。
-
9.4 索引组织方式
9.4.1 索引的关键点
-
为什么需要索引?
- 关系数据库(区别于层次、网状数据库)的数据是无序集合,索引是其重要实现技术 ,用于提升查询效率。
-
索引为什么能提高效率?
- 数量少: 索引块的数量通常远少于数据块的数量。
- 查找快: 索引自身有高效的查找方法(如有序索引用二分查找,B+树索引从根到叶,Hash索引直接计算)。
- 减少I/O: 如果索引文件足够小,可以长期驻留内存缓冲区,访问索引几乎没有I/O开销。
-
索引的额外开销?
- 存储开销: 索引本身需要占用磁盘空间。
- 建立开销: 创建索引需要时间。
- 维护开销 : 当数据增、删、改 时,必须同时维护索引,以保证索引和数据的一致性,这会增加这些操作的开销。
-
索引提高什么操作的效率?
- 查询 (SELECT): 极大提高。
- 增 (INSERT)、删 (DELETE)、改 (UPDATE) : 会降低这些操作的效率(因为有索引维护开销)。
-
什么是好索引? (设计考量因素)
- 访问类型: 支持哪些查询(等值、范围、...)。
- 访问时间: 查询速度快。
- 插入时间: 插入(维护)开销低。
- 删除时间: 删除(维护)开销低。
- 索引空间开销: 占用空间小。
-
几类索引方法
- 排序文件上的简单索引 (顺序文件索引)
- 非排序文件上的辅助索引
- B树及其变种 (B-Tree, B+Tree, B*Tree)
- HASH (哈希索引)
- Bitmap (位图索引)
- 多属性索引
9.4.2 顺序文件上的索引
1. 稠密索引
- 定义 : 索引块中为数据文件中的每一条记录 都存放一个索引项(
<码值, 指针>)。 - 指针: 指向记录本身。
- 查找 : 由于索引项也是有序的,可以采用二分查找。

2. 稀疏索引
- 定义 : 索引块中只为数据文件的每一个块存放一个索引项。
- 索引项 :
<该块中第一条记录的码值, 指向该块的指针>。 - 特点: 索引项数 = 数据文件块数,索引文件极小。
- 查找 (查找码K) :
- (二分查找)在稀疏索引中找到小于或等于K的最大码值对应的索引项。
- 根据其指针定位到对应的数据块。
- 在该数据块中(顺序)搜索码值为K的记录。

3. 多级索引
- 引入 : 当索引本身(即一级索引)仍然很大,无法完全载入内存时,可以在这个一级索引上再建索引,形成二级索引。
- 结构 :
- 一级索引(稠密或稀疏)。
- 二级及更高级索引必须是稀疏索引(对下层索引块建立索引)。
- 查找 (以两级为例) :
- (二分)查找二级索引,找到对应的一级索引块。
- 将该一级索引块读入内存。
- (二分)查找一级索引,找到对应的数据块(稀疏)或数据记录(稠密)。
- (若为稀疏)在数据块中查找记录。

4. 取重复值属性上的索引
当索引建立在非唯一键(如"专业")上时:
- 策略一 (稠密, 重复索引项) :
- 为每条记录都建立一个索引项,索引文件中允许出现重复的码值。
- 查找 : 找到第一个KKK,然后顺序扫描所有KKK。
- 缺点 : 索引文件大,可能无法放入内存,从而增大I/OI/OI/O开销。

- 策略二 (稠密, 单一索引项 + 顺序查找) :
- 只为码值K的第一条记录设一个索引项。
- 查找 : 通过索引找到第一个K,然后在数据文件中顺序向下查找其他K(因为数据文件是有序的,所有K必然相邻)。

- 策略三 (稀疏, 传统方式) :
- 索引项对应每个数据块的第一个码值。
- 查找 :(复杂)可能需要回溯索引。
- 首先找到索引中键值小于或等于KKK的最后一个索引项E1E_1E1;
- 然后向索引起始方向搜索,直到第一个索引项或找到一个严格小于KKK的索引项E2E_2E2为止;
- 从E2E_2E2到E1E_1E1的索引项(含E2E_2E2和E1E_1E1)指向所有可能包含码为KKK的记录的数据块。在这些数据块中顺序查找码为KKK的记录即可。

- 策略四 (稀疏, 改进方式) :
- 为每个数据块中新出现的最小码值设一索引项。
- 查找 :
- 在索引中查找第一个码值满足如下条件之一的索引项
- 等于KKK;
- 小于KKK,但下一个码值大于KKK。
- 按照这个索引项的指针找到相应的数据块,在该数据块中查找到码值为KKK的所有记录。
- 如果其中一个记录为该块的第一条记录,则继续向上查找其他数据块,直到找出所有查找码为KKK的记录;
- 如果其中一个记录为该块的最后一条记录,则继续向下查找其他数据块,直到找出所有查找码为KKK的记录。
- 在索引中查找第一个码值满足如下条件之一的索引项

5. 索引维护

- 删除 :
- 稠密索引: 找到对应索引项并删除。
- 稀疏索引 : (复杂)如果要删除的记录是块的第一条记录,则索引项可能需要更新为该块新的第一条记录的码值(如30被删,索引项30->40)。如果整个块被删除,索引项也需删除。

- 插入 :
- 稠密索引: 需在索引中插入新项。
- 稀疏索引 :
-
情况1 (幸运) : 插入的记录KKK所在的块中已有空闲空间。索引不变 。

-
情况2 (块满, 移动) : 块满,记录KKK插入后导致部分记录移到下一个块(立即重组 )。索引可能需更新 (如15插入,导致
原块10,20→新块10,15,原块30,30→新块20,30,索引30->20)。
-
情况3 (块满, 溢出) : 块满,不重组,而是将新记录放入一个"溢出块"中,并用指针连接。索引不变 ,但数据文件失去纯粹顺序性,查询变慢。

-
9.4.3 辅助索引
- 定义 : 建立在非排序 文件(堆文件)上的索引,或建立在已排序文件但非排序键上的索引。
1. 辅助索引的特点
- 不决定存放位置 ,只指明记录的当前存放位置。
- 辅助索引总是稠密索引 ,必须为每一条 记录建立索引项。
- 原因: 数据文件是无序的,无法像稀疏索引那样通过一个块的第一个键值来预测该块中是否包含其他键值。
- 通常建在取重复值的属性上。
2. 辅助索引的实现技术 (处理重复值)
-
问题: 如何高效存储对"部门='Toy'"的所有员工的索引?
-
选项1: 重复索引项

('Toy', ptr1), ('Toy', ptr2), ...- 缺点: 索引开销巨大(空间、搜索时间)。
-
选项2: 变长索引记录

('Toy', <ptr1, ptr2, ptr3, ...>)- 缺点: 索引记录变长,索引块的管理变得复杂(同9.3.3中的变长记录块组织)。
-
选项3: 记录链

- 索引指向第一个
'Toy'记录,该记录中有一个附加指针指向下一个'Toy'记录,... - 缺点: 需要修改数据记录的结构;查询时必须遍历链表,I/O高。
- 索引指向第一个
-
选项4: 桶 / 间接法

- 结构 : 索引项
('Toy', ptr_bucket1)指向一个"桶"(一个指针块)。这个桶中集中存放 了所有指向'Toy'员工记录的真实指针(ptr1, ptr2, ptr3, ...)。 - 优点: 索引文件本身保持定长(或只索引唯一的码值),易于管理;桶可以按需分配。
- 应用 (查询优化) :
Query: Get EMP in (Toy Dept) AND (2nd Floor)- 查
Dept.index找到Toy的桶 (含ptr1, ptr2, ptr5)。 - 查
Floor.index找到2nd的桶 (含ptr2, ptr4, ptr5)。 - 在内存中对两个指针桶(RID列表)求交集 (
ptr2, ptr5)。 - 最后根据交集结果 (
ptr2, ptr5) 去访问数据文件,只需2次I/O。
- 查

- 倒排列表 : 这种"桶"的思想在信息检索(IR)中称为倒排列表,用于索引"包含某个词(cat, dog)的所有文档"。

- 结构 : 索引项
9.4.4 B树及其变种
- 引入 : 传统的顺序索引(无论稠密还是稀疏)存在一个致命问题:插入和删除的维护成本高,要么需要昂贵的重组,要么会导致使用溢出块而失去平衡和顺序性。
- B树的目标 : 放弃索引的严格物理顺序性,换取树的**"平衡"**,使得插入、删除、查询操作的效率都能保持在可控的 O(log N)O(\log\ N)O(log N) 范围内。
1. 基本B树 (B-Tree)
一棵n阶的B树具有以下特征:
-
结点容量: 每个结点最多包含 n 个键 (key)。
-
最小充满度:
- 根结点:最少包含 1 个键。
- 非根结点 :最少包含 ⌈n/2⌉\lceil {n/2} \rceil⌈n/2⌉ 个键。
-
子树 : 含有 jjj 个键的(非叶)结点,必有 j+1j+1j+1 个儿子(指针)。
-
平衡 : 所有的叶结点都在同一级上。
-
结点结构 :
P0, K1, P1, K2, P2, ... Kj, Pj
- KiK_iKi 是码,并且ki<ki+1k_i<k_{i+1}ki<ki+1;
- PiP_iPi 是指向子树的指针;
- PiP_iPi所指向的子树中码值均<Ki+1<K_{i+1}<Ki+1,≥Ki\ge K_i≥Ki;
- 重要 : 在B树中,数据记录的指针(或数据本身)存储在所有结点中 ,与键 KiK_iKi 关联。
2. B+树 (B+ Tree)
B+树是B树的变种,是现代关系数据库中最核心的索引结构。
- 定义: B树 + 顺序集。
- 与B树的核B心区别 :
- 数据位置 : B+树中,所有数据记录的指针只存在于叶结点中。
- 内部结点 : 非叶结点(内部结点)只存储键 (key) ,作为"分隔元"或"路标",不存数据指针。这使得非叶结点可以容纳更多的键,从而极大降低树的高度。
- 键的冗余 : 非叶结点中的键
Ki会冗余地出现在叶结点(或下层结点)中。 - 顺序集: 所有的叶结点通过**"顺序指针"** 链接在一起,形成一个全局有序的链表。
- B+树结点结构 (以n=3为例) :
-
非叶结点 :
(P0, K1, P1, K2, P2, K3, P3)。最多n=3个键,n+1=4个指针。
-
叶结点 :
( (K1, ptr1), (K2, ptr2), (K3, ptr3), P_next )。
Kj是键,ptrj是指向真实数据记录的指针 (RID)。- PnextP_{next}Pnext 是指向下一个叶结点的顺序指针。
-
- B+树的充满度规则 (Order n):
| 结点类型 | 最大指针数 | 最大键数 | 最小指针数 | 最小键数 |
|---|---|---|---|---|
| 非叶结点 (非根) | n+1n+1n+1 | nnn | ⌈(n+1)/2⌉\lceil (n+1)/2 \rceil⌈(n+1)/2⌉ | ⌈(n+1)/2⌉−1\lceil (n+1)/2 \rceil - 1⌈(n+1)/2⌉−1 |
| 叶结点 (非根) | n+1n+1n+1 | nnn | ⌊(n+1)/2⌋\lfloor (n+1)/2 \rfloor⌊(n+1)/2⌋ | ⌊(n+1)/2⌋\lfloor (n+1)/2 \rfloor⌊(n+1)/2⌋ |
| 根结点 | n+1n+1n+1 | nnn | 若为叶: 111 ; 若非叶: 222 | 111 |

-
B+树的查找:
- 随机查找 (点查询) : 从根结点开始,比较键值,顺着指针
Pi一路向下,必须到达叶结点才能找到对应的数据指针。 - 顺序查找 (范围查询) :
- 先通过随机查找定位到范围的起始键所在的叶结点。
- 然后利用叶结点上的顺序指针
P_next,遍历叶结点链表,直到范围结束。
- 随机查找 (点查询) : 从根结点开始,比较键值,顺着指针
-
B+树的维护:插入 (Insert)
-
查找: 找到应该插入该键的叶结点。
-
(a) 简单情况 : 该叶结点未满,直接插入
(K, ptr),保持有序。
-
(b) 叶结点溢出 :

- 该叶结点已满 (n个键)。
- 分裂 : 创建一个新叶结点。将原结点的 n+1 个键(含新键)平分(约
(n+1)/2个)到两个结点中。 - 上提 : 将新结点(右兄弟)的第一个键
K_new"上提"到父结点中,作为新的分隔元,并添加指向新结点的指针。
-
© 非叶结点溢出 :

- 上提操作导致父结点也满了 (n个键, n+1个指针)。
- 分裂: 将父结点也进行分裂,将其 n+1 个键(含上提键)和 n+2 个指针平分到两个非叶结点中。
- 上提 : 将分裂点(中间)的那个键再次上提 到祖父结点中。
-
(d) 新根 :

- 分裂操作递归传递,直到根结点也溢出。
- 分裂根结点: 分裂为两个子结点。
- 创建新根: 创建一个新的根结点,它只包含一个上提的键和两个分别指向那两个子结点的指针。树的高度 +1。
-
-
B+树的维护:删除 (Delete)
-
查找 : 找到包含该键
K的叶结点。 -
删除 : 从叶结点中删除
(K, ptr)。 -
检查下溢 : 删除后,若该叶结点的键数量 < ⌊n+12⌋\lfloor{\frac{n+1}{2}}\rfloor⌊2n+1⌋(即不满足最小充满度)。
-
(a) 简单情况 : 删除后仍满足最小充满度。操作结束。(注意:如果被删除的键
K恰好也存在于非叶结点中作为分隔元,分隔元不必立即修改 )。
-
(b/c) 叶结点下溢:
-
尝试 ( c )重分配 : 检查相邻的兄弟结点(左或右)。如果兄弟结点"富余"(键数 > 最小),则从兄弟"借"一个键过来。
- 借的过程可能需要父结点的分隔元"转手",并更新父结点的分隔元。

- 借的过程可能需要父结点的分隔元"转手",并更新父结点的分隔元。
-
执行 ( b ) 合并 : 如果左右兄弟都不富余(都处于最小临界状态)。则将该结点与一个兄弟合并。
- 合并后,父结点中对应的分隔元也需删除 。

- 合并后,父结点中对应的分隔元也需删除 。
-
-
( d ) 非叶结点下溢:
- 父结点的分隔元被删除,可能导致父结点也下溢。
- 递归处理: 对父结点执行相同的 ( c ) 重分配 或 ( b ) 合并 逻辑。
- 如果合并操作递归传递到根,导致根只剩一个指针,则该根被删除,其唯一的子结点成为新根。树的高度 -1。

-
-
删除操作注意:
- B+树的删除总是发生在叶结点上。
- 非叶结点中的键(分隔元)
K只是一个"路标"。即使叶结点中键值为K的记录被删了,只要不发生合并,上层的K不必更新。它仍然是有效的分隔(它指向的子树中最小键可能不再是K,但是一定 >= K)。
3. B树的其它变种
- 目标: 进一步降低树的高度。
- 方法 :
- 提高空间利用率 :
- B*树 : 规定结点至少装满 2/3(而不是B+树的1/2)。分裂时不再是1分为2,而是2分为3(或3分为4),合并时也更复杂。平均空间利用率提高。
- 尽可能提高秩 (n) :
- 前缀B树 : 非叶结点中不存储完整的键,而是存储能区分左右子树的最短前缀(如 "Smith" 和 "Snyder" 之间用 "Sn" 分隔)。键变短,n值(阶数)就能极大提高。
- 压缩技术: 对键值进行压缩,也能提高n值。
- 提高空间利用率 :
