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

(上图中,执行引擎获取页号为2的页面,如果在缓存池中不存在,则需要通过磁盘中找到相应的页面读入磁盘)
从执行引擎(execution engine)的角度来看,理想情况下应实现"数据全内存化"的透明访问,即引擎无需关心数据如何被载入内存,所有数据应如同始终存在于内存中一般可被直接操作。
另一种理解该问题的视角是通过空间控制(Spatial Control)和时间控制(Temporal Control)来分析:
空间控制角度涉及将页面写入磁盘的哪个位置。
- 其目标是将经常一起使用的页面尽可能靠近地存储在磁盘上。这样的话可以减少随机访问,相对增加顺序访问
时间控制角度涉及何时将页面读入内存,以及何时将页面写入磁盘
- 目标是最小化由于需要从磁盘读取数据而导致的停顿次数
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)
工作流程:
- 线程A请求
latch(&page, X)时,哈希表检查冲突:- 若无冲突:立即授予,记录持有者
- 若存在S模式持有者:线程A加入等待队列
- 持有线程调用
unlatch()时唤醒队列首线程
缓冲池
缓冲池是一个用于缓存从磁盘读取的页面的内存区域。它本质上是数据库内部分配的一块较大的内存,用于存储从磁盘中取出的页面。
组织结构
缓冲池的内存区域被组织为一个固定大小页面组成的数组。数组中的每一个条目被称为一个帧(frame)。DBMS请求一个页面时,该页面会被从磁盘复制到缓冲池的某个帧中,并返回指向该页面的指针。当需要访问某个页面时,数据库系统会首先在缓冲池中查找该页面,只有在找不到的情况下,系统才会从磁盘中取出该页面的副本。被修改过的脏页会暂存在缓冲池中,并不会立即写回磁盘。缓冲池的内存组织结构见图 2

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

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 ( ... );数据页应该存储在哪个特定缓冲池?
- 对象ID通过扩展记录ID,使其包含一个对象标志符。通过该标识符,可以维护从对象到缓冲池的映射关系。
- 哈希方法则是将页面 ID 进行哈希,以选择访问哪个缓冲池

其中#123为 <ObjId, PageId, SlotNum>
预取页面
DBMS 还可以通过根据查询计划提前预取页面来进行优化。这样,在处理第一批页面的同时,第二批页面可以提前加载到缓冲池中。该方法常用于数据库系统在顺序访问大量页面时。缓冲池管理器也可以预取树状索引结构中的叶子节点页面。
以下是两种常见的预取页面的方式
- 顺序扫描。当数据库执行顺序扫描时,它会按顺序逐行读取整个表或数据集。为了提高效率,DBMS 可以在执行扫描时预取未来即将访问的页面。
- 索引扫描,在执行索引扫描时,DBMS 根据索引来访问数据页面。预取策略可以用来提前加载与索引匹配的页面

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

缓冲池旁路技术
顺序扫描操作符在读取页面时不会将其存入缓冲池,以避免额外开销。相反,这些页面直接存在于运行查询所使用的本地内存中。这种策略适用于需要读取大量连续磁盘页面的操作。缓冲池绕过也可以用于处理临时数据,例如排序或连接操作中的中间结果。在Informix中称为“轻量扫描”
缓冲池的页面替换策略
DBMS需要腾出缓冲池空间来加载新页面时,必须通过置换策略(Replacement Policy)决定淘汰哪些现有页面。理想的置换算法需在正确性、速度和元数据开销之前取得平衡。
最近最少算法(LRU)
最近最少算法(Least Recently Used,LRU),淘汰最久未被访问的页面。通过为每个页面维护一个最后访问时间戳,使用队列或链表按时间排序,淘汰时间戳最早的页面。优点是简单高效、符合局部性原理
时钟算法
通过”引用位“模拟LRU,减少时间戳开销。首先为每个页面关联一个引用位(初始为0),访问页面时置为1。接着,所有页面的组织为环形缓冲区(Clock Hand循环扫描),淘汰时,若引用位为1,则置为0,跳过该页;若引用位为0,直接淘汰。优点是节省内存,实现更轻量。但是频繁访问场景中可能退化为FIFO

优化方案
具体而言,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)来绕过操作系统的缓存。原因如下
- 双重缓存浪费空间
- 不同的页面驱逐策略:操作系统和数据库系统可能采用不同的页面驱逐(缓存失效)策略

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