Archipelago

Lec 4数据库内部架构与进程并发模型

讲义完成更新于 2026-09-29

Lec 4 数据库内部架构与进程并发模型(Database Architecture and Process Models)

MIT 6.5830 / 6.5831 · Database Systems · 第 4 讲
核心教材:Readings in Database Systems (5th Edition, Red Book)
配套实验:GoDB (Go-based Database Engine)

TL;DR

  • 数据库内核由连接管理器、查询解析器、优化器、执行引擎、锁管理器与恢复子系统构成。
  • 进程模型经历了单进程单线程、每连接一进程(Process-per-connection)到多线程工作池架构的演进。
  • 内核系统设计始终受制于磁盘 I/O(高延迟、按块传输)与内存(低延迟、按字节寻址)之间的物理性能张力。

架构演进与核心洞察

为什么 DBMS 从不相信操作系统

数据库系统的设计哲学核心之一是“不信任 OS”。OS 的虚拟内存、统一页缓存(Page Cache)和调度器是为通用计算设计的,它们缺乏对查询语义、事务边界和 WAL 刷盘时序的理解。若直接依赖 mmap,操作系统可能在事务提交前将脏页偷换(Steal)到磁盘,破坏 ACID;DBMS 必须自己实现缓冲池管理、页面锁定与直接 I/O(O_DIRECT)。

核心机制与讲义正文

阅读资料:

一、回顾和本讲定位

此前几讲学习了关系模型、Schema 设计和 SQL——这些构成了数据库使用者的"心智模型"和交互接口,许多实际工作负载都可以映射到这套概念模型上。本讲开始关注:这套概念模型究竟是如何被"实现"出来的?需要考虑的问题包括:

  • 性能
  • 工程实现的复杂度
  • 并发环境下的安全性
  • 持久性与容错能力

二、数据库系统的整体架构

截屏2024-07-04 02.19.57

执行 SQL 后内部发生了什么?

2.1 建立连接

客户端调用API(可能基于ODBC或JDBC协议)与DBMS的客户端通信管理器(Client Communications Manager, CCM),建立连接。 CCM还为客户端建立安全证书,为新的连接细节和客户端SQL命令分配空间,并将请求往下传送。

image-20260613152427453

2.2 请求处理流程

一个 SQL 查询到达数据库服务器后,大致经历以下步骤:

  1. 准入控制(Admission Control):防止过多请求同时涌入系统
  2. 基于角色的权限检查(Role-based Privilege Check)
  3. 将查询分配给某个工作单元(Worker)执行

image-20260613152544506

2.2 查询执行流程

SQL 经过解析器(Parser)和绑定器(Binder)生成解析树(Parse Tree),规划器(Planner)在优化器(Optimizer)的参与下生成逻辑计划(Logical Plan)和物理计划(Physical Plan),最终交由执行器(Executor)运行。

下图用一个包含投影、连接、过滤等操作的查询计划作为示例,展示了逻辑计划与物理计划之间的对应关系

image-20260613152726303

存储系统部分则包括访问方法(Access Methods)、缓冲区管理器(Buffer Manager)、事务管理器(Transaction Manager)和日志管理器(Log Manager)

image-20260613152838694

在查询计划的底层,需要从数据库请求数据,这些操作通过函数调用,从DBMS的存储管理器(transactional storage manager)中收集数据,它负责所有数据的CRUD。存储系统包括用于管理磁盘数据的基本算法和数据结构,比如基本的表和索引。它还包括一个缓冲管理器, 用来控制内存缓冲区和磁盘之间的数据传输。查询必须调用事务管理代码来保证“ACID”性质。在获取数据之前,需要通过锁管理器来确保并发情况下运行的正确性。如果客户端的查询 包含对数据库的更新操作,那么,它需要与日志系统进行交互。

image-20260613152959957

当获取数据后,访问方法把控制权转交给查询处理器,查询处理器将数据库的数据组织成结果元组,结果元组生成后被放到客户通信管理器的缓冲区中,然后将结果发送给调用者。

在查询的最后,事务结束、连接关闭,事务管理器中的结果被清空,进程管理器释放无用的数据结构,通信管理器将状态清空。

三、存储基础:磁盘与内存的张力

传统数据库系统假设数据存储在非易失性磁盘上——过去是机械硬盘(HDD),现在更多是固态硬盘(SSD)甚至远程存储。其中存在一个核心矛盾:存储设备速度慢,但持久、容量大;内存速度快,但容量稀缺且不持久。数据库系统的核心任务之一,就是管理数据在两者之间的移动。

image-20260613153358629

下面给出一组人类可感知的时间尺度:

  • L1 缓存延迟约为 1 纳秒(相当于"1 秒")
  • L2 缓存延迟约为 4 纳秒(相当于"4 秒")
  • 主存延迟约为 100 纳秒(相当于约 1.5 分钟)
  • SSD 延迟约为 16,000 纳秒(相当于约 4.5 小时)
  • 机械硬盘延迟约为 2,000,000 纳秒(相当于约 3.5 周)
  • 通过网络访问远程存储的延迟约为 50,000,000 纳秒(相当于约 1.5 年)

四、 系统设计目标

基于上述张力,数据库系统在存储层面的设计目标包括:

  • 能够管理超出可用内存容量的数据库
  • 必须优化对慢速磁盘的访问,避免长时间停顿和性能问题
  • 尽量减少随机访问,优先使用顺序访问
  • 最大化数据的复用程度

五、面向磁盘的存储:页

数据库以"页"(Page)为基本单位组织磁盘上的数据:

  • 页是固定大小的数据块,可以存放元组、元数据等内容
  • 每个页通过唯一的页号(PageID)标识
  • 页的常见大小在 32~512 KB 之间;为简化起见,GoDB 使用 4 KB 的页

执行器通过缓冲池(Buffer Pool)来获取页:例如请求"第 2 页"时,缓冲池会返回对应内存帧(Frame)中数据的指针;如果该页被修改过,之后还需要将其"写回"(Write-back)磁盘。

image-20260613153728608

六、页存储架构(Page Storage Architecture)

数据库系统需要决定如何在磁盘文件中管理这些页,例如:

  • 作为一个无序的集合(即堆文件,Heap File)
  • 按某种顺序组织(树结构或其他方式)

本讲主要讨论最简单的方式——堆文件。

PageID 对应物理位置

最简单的设计是让 PageID 直接对应文件中的物理位置,并提供 Allocate()、Read()、Write() 等基本操作——GoDB 采用的就是这种架构。这种设计的问题在于"删除":无法真正"删除"一个物理页,上层逻辑必须自行管理垃圾回收(这也是 Lab 1 中的一项附加题)。

7.2 生产级系统的常见做法

许多生产级系统会引入一层"间接寻址":通过一个目录页(Directory Page)维护"逻辑页号 → 物理位置"的映射,并额外维护诸如空闲空间等元数据。

数据库管理系统(DBMS)负责管理内存并协调数据在磁盘与内存之间的双向传输。由于在绝大多数情况下,数据无法直接在磁盘上被操作,因此任何数据库都必须具备高效迁移数据的能力——即将以文件形式存储于磁盘中的数据加载至内存以供使用。图1展示了这一交互过程的示意图。

截屏2024-08-13 07.12.59

(上图中,执行引擎获取页号为2的页面,如果在缓存池中不存在,则需要通过磁盘中找到相应的页面读入磁盘)

从执行引擎(execution engine)的角度来看,理想情况下应实现**"数据全内存化"的透明访问**,即引擎无需关心数据如何被载入内存,所有数据应如同始终存在于内存中一般可被直接操作。

另一种理解该问题的视角是通过空间控制(Spatial Control)和时间控制(Temporal Control)来分析:

  • 空间控制角度涉及将页面写入磁盘的哪个位置。

    • 其目标是将经常一起使用的页面尽可能靠近地存储在磁盘上。这样的话可以减少随机访问,相对增加顺序访问
  • 时间控制角度涉及何时将页面读入内存,以及何时将页面写入磁盘

    • 目标是最小化由于需要从磁盘读取数据而导致的停顿次数

我们专注于面向磁盘的DBMS架构,至顶向下存储层次中,离CPU越近速度越快,但是空间更小, 且单位成本更高。

先总览面向磁盘的DBMS。数据库完全在磁盘上,数据库文件的数据被组织成页(pages),第一页为目录页。为了操作数据,DBMS需要将数据搬到内存,它通过缓冲池(buffer pool)来管理数据从磁盘到内存的来回搬动。DBMS有一个执行引擎用来执行查询(queries),执行引擎会询问缓冲池特定页面,缓冲池会小心地将页面数据带过来,并给到执行引擎指向内存中该页的指针。缓冲池管理器会确保执行引擎在这部分内存操作时,页仍然保持在那。

image-20250814012147764

Outline

有两个主线问题

  • DBMS如何表示用磁盘上的文件来表示数据库?
  • DBMS如何管理内存,以及从磁盘上往返移动数据的?

分为7个部分讲解

  1. OS vs DBMS的存储管理
  2. 文件存储
  3. 页的布局
  4. 元组的布局
  5. 结构化日志存储
  6. 数据表示
  7. 系统目录

1. OS vs DBMS

在高层次设计目标来看,DBMS需要支持数据库能够超过可用内存。因为R/W磁盘非常昂贵,磁盘需要非常小心的管理,我们非常不希望花费大量的Stall(停顿)来从磁盘中Fetch数据,导致系统变得很慢。我们希望DBMS在同时处理多个查询(比如上一下再等待fetch,但是可以执行另外一个查询)。这个设计目标优点类似虚拟内存。一种实现虚拟内存的方式就是通过mmap系统调用,映射一个进程地址空间的文件内容,这让OS有义务将其刷回磁盘。不幸的是,这意味着如果mmap触发了页错误,进程必须阻塞,而你并不希望在DBMS中当你想写入操作有种状况。

DBMS(大部分)总是想自己控制所有的东西,并且确实也能做的比OS好 。比如哪些数据正在被访问,哪些查询正在被处理。mmap当然也有应对这些问题解决方法,如下所示,历史中也有数据库采用这个方法。 比如SQLite, MongoDB(现在已经不用了)

  • madvise: 告知操作系统你预计如何读取某些页面
  • mlock:告知操作系统内存范围不能被换出
  • msync:告知操作系统将内存范围刷新到磁盘

总得来说, 操作系统并不是你的朋友的!(论文: Are You Sure You Want to Use MMAP in Your Database Mangement System?)

2. 文件存储

DBMS存储数据库,最基本的形式,是作为磁盘上文件。有些使用文件分层,有些则用单一文件(比如SQLite),OS对这些文件一无所知,只有DBMS知道如何辨认它们的内容,因为它被编码成DBMS特有格式。DBMS的**存储管理器(storage manager)**负责维护数据库文件,它将文件组织为页面集合,并且,跟踪已经读取出或写入页面的数据, 跟踪这些页面的可用空间

3. 数据库页

概念

DBMS通过跨多个文件来组织数据库的,其中,固定大小的数据块称其为页面(Database page)。 页可以包含不同种类的数据(比如,tuple、索引等),但大部分数据库不会在同一页中混用类型。有些系统要求页是自包含的,意味着每个页都存储着读取其有效数据必要的信息。

每个页面会被赋予唯一标识符。如果数据库是单文件的,那么Page ID只是文件的偏移,而大部分数据库有间接层将Page ID映射到(文件路径, 偏移),提供给系统的最顶层,通过页ID获取某个页内容。

大部分DBMS使用固定大小页面,避免工程上的复杂度。比如,对于可变大小页面,删除页可能会给DBMS带来不好处理的空洞。

在DBMS里面有3种“页面”概念,默认是说第三种

  • 硬件页面(hardware page),通常是4KB
  • OS页,通常4KB
  • 数据库页(1K ~ 16KB)
    • 4K: SQLlite, Oracle
    • 8K: SQL Server 、PostgreSQL
    • 16K: MySQL
    • 更大的页需要的系统调用就比较少

存储设备能够保证硬件页大小(如4KB)的原子写入,这意味着我们的数据库页若大于硬件页,崩溃时可能导致部分写入,需要采取额外的手段来修复不一致性。

有很多种方式找到磁盘上某个页的位置,堆文件(heap file)组织结构就是其中一种,是一种无序集合,tuple以任意顺序存储。

DBMS主要有两种形式 在给定一个page_id定位磁盘中的页面,页面链表和页目录形式

  1. 链表Linked List:头部页面维护着指向空闲页面的指针,以及指向数据链表的指针。然而,如果DBMS想要查找特定页面,它必须做顺序扫描。
  2. 页目录Page Directory:DBMS维护着特殊页,这种页能够跟踪数据页的位置及其空闲大小

页面布局

了解如何找到页面, 我们接下来看下一个页面的布局,每个页都包含一个头部,里面记录着这个页的元数据

  • 页大小
  • checksum
  • DBMS版本
  • 事务可视性信息
  • 自包含信息(有些系统比如Oracle需要这个,关于怎么解读这些数据的schema信息等,用于恢复)

除了头部外,如何布局数据呢? strawman approach 是跟踪该页有多少条记录(tuple),并且每次新增tuple都从尾部添加。但是问题出现在tuple删除和tuple有可变属性的场景时。

有两种主要方法来布局页面的数据: 1)Slotted-pages 2)log-structure

分页槽

分页槽, Slotted-pages,最通用的布局方式。 用slot数组映射出所有元组的起始位置,这个方法适合固定或者不定长的元组。页头部记录已使用的槽位数(used_slots)、上一次使用槽位的偏移量(last_slot_offset)和槽位数组,槽位数组存储每个tuple的起始位置。

当要添加一条tuple时,tuple是从尾部向头部填充,区别于槽位数组从页头部向尾部填充。

截屏2024-07-18 03.58.18

如果我删除了元组3,会发生什么?

Solution:将tuple3对应的slot标记为空闲(比如设置为-1或者NULL),used_slots(已用槽位数)减1,其他tuple的偏移量保持不变,元组3的数据仍保留在页中,但逻辑上被视为“可覆盖”的无效数据。不会立即整理空间,避免额外I/O,当页内空间碎片化严重时,可以触发页内整理。□

如果我插入/更新tuple会发送什么?

Solution:

插入tuple情况:

→ 检查页面目录,找到一个有空闲槽的页面。

→ 从磁盘取回页面(如果不在内存中)。

→ 检查槽数组,找到页面中适合新数据的空闲空间并插入。

更新情况:根据记录 ID(Record ID 或 RID,

→ 检查页面目录,找到页面的位置。

→ 从磁盘取回页面(如果不在内存中)。

→ 使用槽数组找到页面中的偏移量。

→ 覆盖现有数据(如果新数据适合)。□

分槽页面设计存在的一些问题:

  • 碎片化(Fragmentation):删除元组后可能在页面中留下空隙,导致空间无法充分利用;
  • 无效磁盘 I/O(Useless Disk I/O):由于非易失性存储的块式特性,更新一个元组时必须整个块都读入内存;
  • 随机磁盘 I/O(Random Disk I/O):如果要更新 20 个不同的元组,磁盘读写器可能需要跳转到 20 个不同位置,速度会很慢。

日志结构页面

我们采用另外一种思路, 只能创建新页面而不能重写已有页面的系统,比如云存储(S3),HDFS, 那么日志结构化存储模型(Log-Structured Storage)就是专门为这种假设而设计的,并能解决分页槽的设计存在的问题

DBMS 不再直接存储元组,而是只记录对元组的更改日志。它会将新的日志条目追加到内存缓冲区中,而不检查之前的记录,然后将这些更改按顺序写入磁盘。

  • 每条记录包括tuple的唯一标识符、操作类型(PUT 或 DELETE),如果是PUT、还包括tuple的内容;
  • 读取记录时,DBMS 会从日志文件的末尾向前扫描,查找元组的最新内容。
  • 写入速度快,但读取可能较慢。磁盘写入是顺序的,已有页面不可修改,能减少随机 I/O,适合只追加写入的存储
  • 为避免读取时太慢,DBMS 可以维护索引以便跳转到日志中的特定位置;
  • 日志文件最终会变得非常大,DBMS 可定期**压缩(compaction)**日志,只保留每个元组的最新更改;
  • 压缩后,每个元组只剩下一个版本,不再需要保留写入顺序,因此可以按 ID 排序以加快查找。这类结构称为 有序字符串表(SSTables, Sorted String Tables);
  • 在通用压缩(Universal Compaction)中,任何日志文件都可以一起进行压缩。而在层次压缩(Level Compaction)中,最小的文件位于第 0 层。第 0 层的文件可以被压缩成更大的第 1 层文件,第 1 层的文件可以被压缩成第 2 层文件,以此类推。
  • 这种方法的缺点是压缩过程开销大,并且会导致写放大(对于每个逻辑写入,可能会有多个物理写入)。

要读取具有特定ID的元组,DBMS会查找与该ID对应的最新日志记录。

  • 从最新到最旧扫描日志记录。
  • 维护一个索引,将元组ID映射到最新的日志记录。
  • 如果日志记录在内存中,则直接读取。
  • 如果日志记录在磁盘页面上,则取回它。

截屏2024-07-18 13.53.02

压缩页

日志会永久增长,因此DBMS需要定期压缩页来减少空间的浪费

截屏2024-07-18 13.58.21

在页面被压缩后,数据库管理系统(DBMS)不需要维护页面内记录的时间顺序。每个元组ID在页面中最多出现一次。相反,DBMS可以根据ID顺序对页面进行排序,以提高将来查找的效率。这被称为Sorted String Tables(SSTables)。

截屏2024-07-18 14.03.38

用RocksDB而言有两种方式,

  • Universal Compaction
  • Level Compaction

​ 截屏2024-07-18 14.07.17

日志结构化存储管理器如今更为常见。这在一定程度上归因于RocksDB的普及。

这种方法的一些缺点包括:

  • 写放大(Write-Amplification):写了一个记录,然后就不碰它了,导致每次压缩都得带上它,但是又消除不了。
  • 压缩代价高昂

索引组织存储

传统存储(如面向页面存储和日志结构存储)依赖于额外的索引来查找表中的元组,因为表本身没有排序。

索引组织存储直接将表中的元组存储为索引的值,且元组通常会根据键值排序。分页槽布局有助于管理元组,提供高效的数据存储和访问。通过这种方式,表中的数据和索引是紧密结合的,使得检索和插入操作更加高效。

页面的存储

有很多种方法在磁盘上找到DBMS想要的页的位置,大致有如下

  • 堆文件组织(最简单的方式)
  • 树形文件组织
  • 顺序/有序文件组织(ISAM)
  • 哈希文件组织

在层次结构的这一点上,我们不需要了解页面内部的具体内容。

堆文件组织

堆文件是一个无序的页面集合,元组以随机顺序存储在页面中。每页都是等大小的。每页由头部信息和记录组成。头部信息包含了每条记录的起始偏移量、该页的哪些部分被占用等信息。而且,假如只有一个堆文件,想要获取堆文件的某个页ID的页所在位置,只需要通过页序号*页大小即可。另外, 读、写、缓存数据都是在页的粒度下进行,堆扫描时, 按照存储顺序读取堆文件。即使有谓词,也要读取所有的文件。

截屏2024-07-18 03.27.17

更通用说,DBMS能够通过给定页ID找到在磁盘的页,需要借助链表页或者页目录实现。

  • 链表法:头部页维持着空闲页链表和数据页链表。缺点:当DBMS需要找特定页时,他需要做线性扫描

  • 页目录法:DBMS维护这一个特殊页,里面跟踪着数据页位置以及每页的空闲位置。(后面有详细介绍),缺点:必须确保目录页面与数据页面保持同步。

    截屏2024-07-18 10.36.47

树形文件组织

todo

顺序文件组织

todo

哈希文件组织

todo

4. 元组的布局

一个元组本质上是一个字节序列,解释这些字节为属性类型和值是 DBMS 的职责

截屏2024-07-18 11.35.28

  • tuple头部: 包含了tuple的元数据。

    • 包含用于并发控制协议的可见性信息(比如那个事务创建/修改了这个tuple的信息)
    • NULL值位图(bitmap):标记哪些属性值是NULL
    • 注意:tuple头部不需要存储数据表的schema信息,因为schema是全局定义的。
  • tuple数据:存储实际的属性值

    • 属性通常按照创建表时定义的顺序进行存储

    • 大多数 DBMS 不允许一个元组超出一个页面的大小(即每个元组必须完整地存储在一个页面内)

唯一标识符

  • 数据库每个tuple都有一个唯一标识符
  • 最常见的形式:page_id + offset or slot
  • 应用程序不能依赖这些标识符来表达任何语义,它们仅用于 DBMS 内部管理。因为这个ID可能会被数据更改,这是逻辑和物理之间的抽象或者分离,我们知道元组是什么即知道他们的逻辑信息,但是实际存储在哪里,如何存储,我们不应该知道。

截屏2024-07-18 11.36.58

去范式化的tuple数据:

  • 如果两个表有关联,物理上进行非规范化(例如,"预连接")相关元组,并将它们存储在同一页面中,也有不少NoSQL这样做
  • 这样可以加快读取速度,因为 DBMS 只需要加载一个页面而不是两个
  • 但这会让更新操作变得更昂贵,因为每个元组需要更多的存储空间

截屏2024-07-18 11.38.06

截屏2024-07-18 11.49.00 截屏2024-07-18 11.49.22

5. 数据类型

元组中的数据本质上只是字节数组(byte arrays),不会记录各个属性的具体数据类型。由 DBMS 来负责跟踪这些信息,并解释这些字节。数据表示schema(data representation scheme)是指DBMS如何将某个值以字节的形式存储起来。

DBMS 希望元组在内存中是按字对齐(word-aligned)的,这样 CPU 在访问数据时不会出现意外行为,也无需额外操作。通常有两种方法实现对齐:

  • 填充(padding):在某些属性之后添加空位(bits),以确保整个元组是字对齐的;
  • 重排(reordering):在物理布局中调整属性的顺序,以确保对齐;

在元组中可以存储的高层次数据类型主要有五种:整数(integers)、变精度数(variable-precision numbers)、定点精度数(fixed-point precision numbers)、变长值(variable length values)、以及日期/时间(dates/times)。

整数

大多数DBMS用”原生“C/C++类型,根据IEEE-754标准,这些值是固定长度。例如,INTEGER、BIGINT、SMALLINT、TINYINT`

变精度数

这些是非精确的、变精度(variable-precision)数值类型,使用的是由 IEEE-754 标准定义的“原生” C/C++ 数据类型。这类数值的长度是固定的(fixed length)。

与任意精度数(arbitrary precision numbers)相比,变精度数的计算速度更快,因为 CPU 可以直接对其执行指令。但在执行计算时,可能会出现舍入误差(rounding errors),因为某些数值无法被精确表示。

例如:FLOAT、REAL

定点精度数

这些是具有任意精度和小数位数(scale)的数值类型。它们通常以精确的、变长的二进制形式存储(几乎类似于字符串),并附带一些元数据,用于告诉系统诸如数据长度以及小数点位置等信息。

当不能容忍舍入误差时,会使用这类数据类型,但为了获得这种精度,DBMS 需要付出性能上的代价。

例如:NUMERIC、DECIMAL。

变长值

这些是表示任意长度的数据类型。它们通常带有一个头部信息(header),用于记录字符串的长度,方便系统跳转到下一个值的位置。这个头部有时还会包含一个校验和(checksum)用于校验数据完整性。

大多数 DBMS 不允许一个元组的大小超过单个页面(page)的容量。如果超出,有些系统会将数据存储在一个特殊的“溢出页面(overflow page)”上,并在元组中保存对该页面的引用。一个溢出页面可以继续指向其他溢出页面,直到所有数据都被存储完为止。

某些系统还允许将这类大数据值存储在外部文件中,此时元组中只包含一个指向该文件的指针。例如,如果数据库用于存储照片信息,DBMS 可以将照片实际存放在外部文件中,而不是占用数据库的大量空间。但这种方式的缺点是:DBMS 无法直接操作这些文件的内容,因此无法提供持久性(durability)或事务(transaction)保护。

日期与时间

日期/时间的表示方式因系统而异。通常,它们被表示为自 Unix 纪元(Unix epoch)以来经过的某种单位的时间(微秒或毫秒)。

例如:TIME、DATE、TIMESTAMP。

空值数据类型

在 DBMS 中,有三种常见的方法用于表示空值(NULL):

  • 空值列位图头(Null Column Bitmap Header):在集中式的头部存储一个位图,用于标记哪些属性是 NULL。这是最常见的方法;
  • 特殊值(Special Values):为某种数据类型指定一个特殊的值表示 NULL(例如,INT32 的最小值);
  • 每个属性独立标志(Per Attribute Null Flag):为每个属性单独存储一个标志位来表示是否为 NULL。由于这种方式在内存使用上效率较低(为了保证字对齐,每个标志不能只占一个比特),所以不推荐使用。

6. 系统目录

系统目录(system catalogs),也称目录管理器(Catalogs Manager)。它会维护一个内部目录,用于存储数据库的元数据(meta-data), 包含数据库使用的表的schema信息,这些信息帮助系统确定元组的布局

元数据的内容包括:

  • 数据库中有哪些表(tables)和列(columns),以及这些表上有哪些索引(indexes);
  • 数据库的用户信息,以及他们拥有哪些权限(permissions);
  • 关于表的统计信息,以及这些表中包含的内容(例如某个属性的最大值);

几乎所有的DBMS的元数据保持与数据一样的格式,可以使得系统在使用上更加紧凑、简 单:用户可以使用同一种语言和工具来研究其他数据的元数据,这是是重要的经验。它们会使用特殊的代码来“引导(bootstrap)”这些目录表的建立。

实战: 获取表的schema

你可以查询DBMS的内部INFORMATION_SCHEMA目录,获取有关数据库的信息。

  • INFORMATION_SCHEMA是ANSI标准的一组只读视图,提供有关数据库中所有表、视图、列和过程的信息。
  • DBMS还具有非标准的快捷方式来检索这些信息。

截屏2024-07-18 14.55.11

Outline

  • 锁和闩锁
  • 缓冲池
  • 缓冲池优化
  • 驱逐策略
  • 其他缓冲池
  • 操作系统页缓存
  • 磁盘I/O调度

锁和闩锁

在讨论DBMS如何保护其内部元素时,需明确区分锁(lock)和闩shuan锁 (latche)

锁

锁(lock):锁是一种高层级的逻辑原语,用于保护数据库中的逻辑内容(如元组、表、数据库等),防止其他事务并发访问导致冲突。可以跟任何一个名字关联, 比如磁盘页,逻辑项(如元组,文件,卷),通过锁管理器提供的申请或检查。每个锁都必须关联到一个事务上(绑定事务ID),锁的持有时间为整个事务的秩序时间。锁机制需具备回滚修改的能力(如事务中止时释放锁并撤销变更)

接口

  • 遵循2PL协议, 支持两个基本函数
    • lock(lockname, tid, mode)
    • remove_transaction(tid)
    • 由于遵循2PL协议,不能简单释放某个锁
  • 较低水平的隔离性时
    • unlock(lockname, tid)
    • lock_upgrade(lockname, tid, newmode),将锁升级到较高类型(共享锁升级到排他锁),这就不需要释放和重新申请了。
    • (optional) condition_lock(lockname, tid, mode),调用后立即返回,并指出是否成功获得了

数据结构

DBMS通过锁表来管理并发事务的,其核心数据包括两个哈希表,分别以锁名称和事务ID(TID)为key。

  • lockname->锁的元数据,大致为

    • 模式mode: 共享S、排他X、意向锁等
    • 请求该锁的事务列表,通常是一个链表或数组。格式为(tid, mode)

    功能是大致为

    • 快速判断某个锁是否已被持有,以及当前锁的模式
    • 检测锁冲突
    • 支持锁等待队列管理(如阻塞事务的唤醒顺序)
  • tid -> 事务的元数据,大致为

    • 线程指针:指向管理该事务的DBMS线程的指针, 这使得事务因请求锁而等待时可以进行线程调度
    • 指向锁表中该事务所有锁请求的指针的链表,这会有利于移除某个事务的所有锁(如当事务提交或者中 止)

死锁检测器

  • DBMS 线程, 周期性地检查锁表中是否 存在等待环(环中每一个 DBMS 工作者都在等待下一个,因而形成环)。根据对死锁的检测, 死锁检测器会中止死锁中的某一个事务
  • 在无共享或共享磁盘系统中,一个分布式死锁检测 或一个更原始的超时死锁检测是必需的

闩锁, Latches

保护数据库管理系统(DBMS)内部数据结构的关键部分不受其他线程的干扰,闩锁的持有时间为操作的持续时间(短时持有,通常微妙级),不需要支持回滚更改。

接口

  • latch(object, mode)

    • 获取指定内存对象(如页面指针)的闩锁
    • 大多数DBMS而言, mode只有共享和排他
  • unlatch(object)

  • conditon_latch(object, mode)

    • 带条件的闩锁(如非阻塞尝试、超时等待)

数据结构

通过哈希表管理所有活跃闩锁:

  • 内存对象地址(如0x7f3a1b00)-> 等待队列(记录所有阻塞线程,含请求mode)
  • 当前持有者,线程ID + 持有模式(S/X)

工作流程:

  1. 线程A请求latch(&page, X)时,哈希表检查冲突:
    • 若无冲突:立即授予,记录持有者
    • 若存在S模式持有者:线程A加入等待队列
  2. 持有线程调用unlatch()时唤醒队列首线程

缓冲池

缓冲池是一个用于缓存从磁盘读取的页面的内存区域。它本质上是数据库内部分配的一块较大的内存,用于存储从磁盘中取出的页面。

组织结构

缓冲池的内存区域被组织为一个固定大小页面组成的数组。数组中的每一个条目被称为一个帧(frame)。DBMS请求一个页面时,该页面会被从磁盘复制到缓冲池的某个帧中,并返回指向该页面的指针。当需要访问某个页面时,数据库系统会首先在缓冲池中查找该页面,只有在找不到的情况下,系统才会从磁盘中取出该页面的副本。被修改过的脏页会暂存在缓冲池中,并不会立即写回磁盘。缓冲池的内存组织结构见图 2

image-20250415052014848

元数据

为了高效且正确使用缓冲池,系统必须维护一些元数据。首先,页表(page table) 是一个驻留在内存中的哈希表,用于追踪当前在内存中的页面。它将页面 ID 映射到缓冲池中的帧位置。由于缓冲池中页面的排列顺序不一定与磁盘中的顺序相同,这一额外的间接层便于识别页面在缓冲池中的位置。

截屏2024-08-13 07.26.21

[!NOTE]

页表不同于页目录(page directory),后者是将页面 ID 映射到数据库文件中页面位置的结构。为了支持数据库重启后仍能找到页面,所有对页目录的更改都必须记录在磁盘上。

页表还会为每个页面维护额外的元数据,例如脏标记(dirty-flag)和引用计数器(pin/reference counter)。

  • 脏标志(Dirty Flag):当线程修改了页面时,就会设置该标记。这表示该页面必须由存储管理器写回磁盘
  • Pin/引用计数器(Pin/Reference Counter):用于记录当前有多少线程正在访问该页面(无论是读取还是修改)。线程在访问页面前必须先增加该计数器。如果某页面的引用计数大于零,存储管理器就不能将其从内存中驱逐。引用计数不会阻止其他事务并发访问该页面。
  • 闩锁

举个例子,当你的请求需要对页面 2需要做一些事情,通过pin标记,阻止缓冲池驱逐它,直到它完成,当你对页面 2 获取一个闩锁时,这个闩锁会阻止其他线程(或进程)干扰当前帧的使用

内存分配策略

数据库中的内存是根据两种策略分配给缓冲池的:

  • 全局策略:由 DBMS 做出决策,以使整个工作负载的执行更加高效。它会考虑所有活跃事务的行为,从全局角度寻找最佳的内存分配方案。

    • 在这种策略下,内存页帧的分配是全局考虑的,即所有查询会竞争相同的内存资源。
  • 局部策略:关注单个查询或事务的性能,即使这种决策不利于整个工作负载。局部策略会将帧分配给特定事务,而不考虑并发事务的行为

缓冲池优化

有多种方式可以优化缓冲池,以更好地适配应用程序的工作负载。

  • 多缓冲池
  • 预取页面
  • 扫描共享
  • 缓冲池旁路技术

多缓冲池

DBMS 可以维护多个用途不同的缓冲池(例如:按数据库划分的缓冲池、按页面类型划分的缓冲池)。这样,每个缓冲池都可以采用适合其所存数据的本地策略。该方法有助于减少闩锁(latch)竞争,并提升局部性。

-- 创建自定义缓冲池
CREATE BUFFERPOOL custom_pool SIZE 250 PAGESIZE 8k;
-- 创建自定义表空间
CREATE TABLESPACE custom_tablespace PAGESIZE 8k BUFFERPOOL custom_pool;
--  创建新表
CREATE TABLE new_table TABLESPACE custom_tablespace ( ... );

数据页应该存储在哪个特定缓冲池?

  1. 对象ID通过扩展记录ID,使其包含一个对象标志符。通过该标识符,可以维护从对象到缓冲池的映射关系。
  2. 哈希方法则是将页面 ID 进行哈希,以选择访问哪个缓冲池

截屏2024-08-13 09.09.06

其中#123为 <ObjId, PageId, SlotNum>

预取页面

DBMS 还可以通过根据查询计划提前预取页面来进行优化。这样,在处理第一批页面的同时,第二批页面可以提前加载到缓冲池中。该方法常用于数据库系统在顺序访问大量页面时。缓冲池管理器也可以预取树状索引结构中的叶子节点页面。

以下是两种常见的预取页面的方式

  • 顺序扫描。当数据库执行顺序扫描时,它会按顺序逐行读取整个表或数据集。为了提高效率,DBMS 可以在执行扫描时预取未来即将访问的页面。
  • 索引扫描,在执行索引扫描时,DBMS 根据索引来访问数据页面。预取策略可以用来提前加载与索引匹配的页面

截屏2024-08-13 09.27.13

扫描共享(游标共享)

扫描共享(Scan Sharing / Synchronized Scans),也称游标共享。查询游标可以复用已经从存储或运算操作中获取的数据。这允许多个查询附加到同一个扫描整个表的游标上。如果有一个查询正在进行扫描,而另一个查询也需要扫描同一个表,DBMS 会将第二个查询的游标附加到第一个游标上。DBMS 会记录第二个查询加入的位置,以便在扫描结束时,确保第二个查询也能完整地完成扫描。

示例: DB2、MSSQL和Postgres

截屏2024-08-14 02.58.34

缓冲池旁路技术

顺序扫描操作符在读取页面时不会将其存入缓冲池,以避免额外开销。相反,这些页面直接存在于运行查询所使用的本地内存中。这种策略适用于需要读取大量连续磁盘页面的操作。缓冲池绕过也可以用于处理临时数据,例如排序或连接操作中的中间结果。在Informix中称为“轻量扫描”

缓冲池的页面替换策略

DBMS需要腾出缓冲池空间来加载新页面时,必须通过置换策略(Replacement Policy)决定淘汰哪些现有页面。理想的置换算法需在正确性、速度和元数据开销之前取得平衡。

最近最少算法(LRU)

最近最少算法(Least Recently Used,LRU),淘汰最久未被访问的页面。通过为每个页面维护一个最后访问时间戳,使用队列或链表按时间排序,淘汰时间戳最早的页面。优点是简单高效、符合局部性原理

时钟算法

通过”引用位“模拟LRU,减少时间戳开销。首先为每个页面关联一个引用位(初始为0),访问页面时置为1。接着,所有页面的组织为环形缓冲区(Clock Hand循环扫描),淘汰时,若引用位为1,则置为0,跳过该页;若引用位为0,直接淘汰。优点是节省内存,实现更轻量。但是频繁访问场景中可能退化为FIFO

image-20250415051502545

优化方案

具体而言,LRU和CLOCK算法容易受到顺序扫描冲击(sequential flooding)的影响,即顺序扫描会破坏缓冲池的内容。由于顺序扫描会快速读取大量页面,缓冲池会被填满,而其他查询的页面因时间戳更早而被淘汰。这种情况下,最近的时间戳并不能准确反映我们实际需要淘汰的页面。

针对LRU和CLOCK策略的缺陷,现有3种解决方案;

  • LRU-K算法,通过记录最近K次访问的时间戳历史,并计算后续访问间隔,以此预测页面下一次被访问的时间
  • 查询级本地化优化(localization per query),即DBMS基于每个事务/查询单独选择待淘汰页面,从而最大限度减少各查询对缓冲池的污染。
  • 优先级提示(priority hints),事务可根据查询执行时的上下文,告知缓冲池特定页面是否重要。

脏页处理

处理带有脏位的页面有两种方法。最快的方法是直接丢弃缓冲池中未被修改(即非脏页)的页面。另一种较慢的方法是将脏页写回磁盘,以确保其更改被持久化。这两种方法体现了快速驱逐页面与写回那些将来不会再被读取的脏页之间的权衡。

为避免不必要地写出页面,可以使用后台写入机制。通过后台写入,DBMS可以定期遍历页表,并将脏页写入磁盘。当脏页被安全地写入后,DBMS 可以选择将其驱逐出缓冲池,或者只是取消其脏位标记。

其他缓冲池

DBMS不仅需要内存来存储元组和索引,还需维护其他专用内存池,其实现因实现因系统而异,部分数据不一定持久化到磁盘。主要有:

  • 排序与连接缓冲区(Sorting + Join Buffers)
    • 临时存储排序中间结果(如ORDER BY)或哈希连接(Hash Join)的构建表。通常为临时性内存,若内存不足可能溢出到磁盘
  • 查询缓存(Query Caches)
    • 缓存查询结果(如SELECT语句的哈希值-结果映射),避免重复计算。
  • 维护缓冲区(Maintenance Buffers)
    • 支持后台任务(如索引构建、统计信息收集、垃圾回收)的临时工作区。
  • 日志缓冲区(Log Buffers)
    • 暂存事务日志(如WAL)以批量写入磁盘,减少I/O次数
  • 字典缓存(Dictionary Caches)
    • 缓存元数据(如表结构、权限信息),加速DDL操作解析

操作系统页缓存

大多数磁盘操作通过操作系统API执行。除非DBMS特别要求,否则默认情况下,操作系统会维护自己的文件系统缓存(Page Cache),用于缓存磁盘数据,以减少物理I/O次数。但大多数DBMS使用直接I/O(O_DIRECT)来绕过操作系统的缓存。原因如下

  • 双重缓存浪费空间
  • 不同的页面驱逐策略:操作系统和数据库系统可能采用不同的页面驱逐(缓存失效)策略

截屏2024-08-14 03.05.19

PostgreSQL是少数依赖OS页缓存的数据库系统。

磁盘I/O调度

DBMS通过内部任务队列管理系统中的所有页面读写请求(Page I/O Requests),并根据以下因素动态调整任务优先级:

  • 顺序 vs 随机I/O。连续访问磁盘块(如全表扫描、日志追加写入),优先级通常更高(因磁盘预读优化效率)。而离散访问(如索引查找),优先级较低(可能合并请求以减少寻道时间)。
  • 关键路径 vs 后台任务。
  • 表 vs 索引 vs 日志 vs 临时数据。WAL(预写日志)的写入优先级最高(确保故障恢复能力); 索引高于表数据(因索引访问直接影响查询性能)。临时表数据优先级最低。
  • 事务信息。长事务或高隔离级别(如Serializable)的I/O请求可能被优先调度,以避免阻塞链扩散
  • 用户SLA约束。根据用户定义的服务等级协议(如OLTP查询优先于分析型查询),动态调整优先级。