惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

罗磊的独立博客
Martin Fowler
Martin Fowler
J
Java Code Geeks
The GitHub Blog
The GitHub Blog
C
Check Point Blog
H
Help Net Security
Google DeepMind News
Google DeepMind News
人人都是产品经理
人人都是产品经理
博客园 - 聂微东
P
Proofpoint News Feed
V
Visual Studio Blog
Stack Overflow Blog
Stack Overflow Blog
雷峰网
雷峰网
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Vercel News
Vercel News
S
SegmentFault 最新的问题
L
LangChain Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The Cloudflare Blog
Hugging Face - Blog
Hugging Face - Blog
有赞技术团队
有赞技术团队
博客园_首页
小众软件
小众软件
aimingoo的专栏
aimingoo的专栏

LINUX DO - 最新话题

谷歌云盘下载700g数据集,求方法 OpenAI推出了100美元的Pro订阅后,plus的Codex 5小时限额大幅缩水 之前买的super grok居然还没掉 关于CPA认证文件周限 佬们,默认CDK的要求是什么等级啊? 最新版本的微信群聊机器人方案 有没有人知道如何free号没有封,那么是否可以循环使用,因为我看主要是周限 L站改版了?吓我一跳,我以为我浏览器崩了 淘宝这种宽带可信吗,500兆移动宽带月费8元到2099年 docker内部应用访问宿主机mysql和redis时被拒绝connection refuse Erp全栈想转行做Ai有什么推荐的吗 boost有bug 佬们,有没有靠谱点的 Plus 购买渠道 大妈,狗妈用的 lg 服务有源头开源项目吗? 有人有能过验证码打码的嘛 上次帖里好像发过通过大模型来打码的 gpt plus 封号似乎也太快了点,一天就给封号了 按流量/token收费的国产官方AI推荐 我算是知道了为什么Oracle总是ABC了 佬友们帮我分析一下 ChatGPT Team账号只有一个人使用和4个席位邀请满了使用的总额度是一样的吗? gpt-free 10个带rt CPA反代claude是默认1m吗? 我终于敢说我做出来windows上tmux的替代了,目标windows/全平台最强的终端Ai编程工具 claude pro升级max,除了原来的$20,好像还能再领一次$100 关于AI agent的知识框架 独乐乐不如众乐乐,分享一下我的的AI对话程序 佬们自建网站支付问题是怎么解决的 怎么能让gpt模仿claude风格输出 codex free已经死了,下一个会是plus或者team吗 请问chatgpt pro里的fast模式,速度快了,降智吗
【学习记录】CMU 15-445 Lec3-5:数据库是如何把数据存到磁盘...
QiuShunan · 2026-06-20 · via LINUX DO - 最新话题

最近在学习CMU15-445,学完了Lec3-5感觉受益匪浅,这里就以这三节课的内容为基准大致总结一下关于数据库的相关知识。如果有些说的不清楚或者有问题的部分,恳请大佬指出。

所有内容均来自于CMU15-445(2024fall)的课程,notes,slides,以及个人和AI的对话答疑。
学习过程中会使用AI,但是本文内容基本手打。仅只有一个表格是AI弄的,会以截图的形式发出。其余图片均来自于课程的slides。

首先,关于下图这种计算机存储结构的模型这里就不过多赘述了,我将采用课程的说法,统一把DRAM叫做主存/内存/memory,非易失性存储器统一叫做磁盘/外存。

1

核心问题

这三讲聚焦于一个问题:How the DBMS represents the database in files on disk?

即,数据库管理系统如何将数据库表示为磁盘上的文件?

数据必须持久化的存储在磁盘上,但是查询操作需要在内存中进行。

而两者的速度差异决定了整个存储层的设计目标:尽可能减少磁盘I/O次数,尽可能让磁盘I/O是顺序的。

这个矛盾就是这三讲的主线,这三讲的所有知识点,本质上都是在解决这个矛盾。

物理基础和架构选择

为什么不用操作系统管理?

OS提供了mmap机制来管理磁盘和内存之间的数据,但是mmap会遇到page fault, OS不了解数据内容,访问的方式,无法做出最优的决策。

因此DBMS自己实现了Buffer Pool, 自己决定读写和淘汰页面的时机。

这也就有了这门课程上的一个名句:The operating system is not your friend.

Disk-Oriented DBMS

我们的DBMS建立在磁盘之上,其基本架构如下。通过图片很容易得知,lec3-5解决的就是page上的内容。

2

页的组织

关于页,课程中区分了三种不同的页的概念:

  • 硬件页(通常4KB,原子写入的保证单位)
  • OS页(4KB)
  • 数据库页(512B-32KB)

课程中原句是:A hardware page is the largest block of data that the storage device can guarantee failsafe writes.

可以看出硬件页是可以做到原子性写入的,因此,如果数据库页的大小大于硬件页,写入时需要额外的安全措施保证数据完整性。

有些系统要求页中需要self-contained自己的元数据。例如:Oracle就需要将描述该page中内容的所有元数据,和这些内容数据⼀起保存在该page中,这种情况下,如果你丢失了其他任何page,这并不会影响该page的使用

关于page的存储结构,课程中提到了四种:heap, tree, sequential/sorted, hashing

但是后续课程中只详细讲解了heap的存储结构。

Heap File Organization

这里的heap,不是数据结构中的堆(二叉堆),而是指无序的空间池,这和 C 语言内存模型(heap memory)中的heap是一个意思。因此,在这里与heap相对的,是有序的排列(例如B+树)

关于file和page的区分

在DBMS眼中,OS 的文件只是一个巨大的、连续的字节数组,也就是说,DBMS是不关心file这个层级的内容。对于DBMS来说,page就是其最小的管理单位,一个file是由很多page组成的。

此外,如图所示,还有一个Page Directory的结构,他的作用是:

  • 记录每个page id对应的物理位置的偏移量
  • 记录每个page的剩余空间
  • 类型标识,标记这个page存数据还是存索引

本质上是为了加速性能,而不需要挨个找所有的page哪里有空。

图片中的Table X 和 Index Y 是逻辑对象,而 Page Directory 记录的是这些逻辑对象物理 Page 之间的映射关系。对于查询引擎来说,需要使用Table X,Index Y这样的逻辑对象来代替File0, File1这样的物理层内容,一个Table可以在物理上对应一个或多个文件。

3

页内布局:数据如何在页内组织?

如前文所提的,每个页都一个header,用于记录元数据,包括页的大小,校验和,DBMS版本等内容。

页内的数据组织分为三种不同的存储架构:Tuple-Oriented,Log-Structured,Index-Organized

基于元组的存储 Slotted Page

这种存储方式又叫做Slotted Pages,槽页。具体结构如图,页内有一个槽数组,从页头部向后增长,元组数据则倒过来,从页的尾部向前增长。槽数组记录每个元组的偏移量,当两者相遇时,页满。

4

这样的存储会带来以下问题:

  • 删除元组的时候会在页内留下碎片
  • 更新一个元组的时候需要读取整个块,产生无用的磁盘IO
  • 当需要更新数据的元组分散在不同的页中时,需要大量随机读写

日志结构存储 Log-Structured Storage

针对上述槽页的三个问题,我们可以拥有一个完全不同的思路,既然各种对于元组的操作都会打乱原来的布局,或者是增加很多无用的IO,那么我们就不在原来的数据上更新,而是追加修改的日志。

整个结构基于日志结构化文件系统(Log-Structured File Systems, LSFS)和日志结构化合并树(Log-Structured Merge Trees, LSM Tree)

核心操作如下:

  • 写操作(put/delete)先写入内存中的MemTable
  • 当MemTable满了之后,按照key的排序写到磁盘上的不可变的SSTable上面去
  • 随着SSTable增多,会合并(Compaction)多个SSTable,均只会保留key的最新版本。

大致图示如下:

5

6

7

值得一提的是

  • 图中所展示的MemTable是树形结构,实际的MemTable实现上可以有很多种方式,常见的包括SkipList跳表,平衡搜索树(例如红黑树,AVL),B+ 树等
  • 图中为了展示逻辑结构,将 SSTable 画在了靠近 MemTable 的位置,但实际 SSTable 是持久化存储在磁盘上的。只有 MemTable 和 Summary Table 在内存中,SSTable 在磁盘中
  • 上文中的最新非常重要,Log Structured的思想是:不原地修改旧数据,而是追加新的变更记录。查询时,以最新记录为准。
  • 上图中的Summary Table是放在内存中的辅助元数据结构,作用是帮助DBMS判断查询的时候,应该去哪些SSTable查,避免扫描所有的SSTable。其会记录:
    • 每个SSTable的最大Key和最小的Key,因为每个 SSTable 内部都是按 key 排序的,所以每个 SSTable 都有一个 key 范围。
    • 每一层的过滤器(Filter),用于快速判断某个Key是否在这一层中。
  • 图中所有的合并都是采用的分层的合并(即存在level0 - level1 - level2),这种合并方式叫做Level Compaction。与之相对的,还有另外一种方式叫做Universal Compaction,其支持任意SSTable都可以合并。

当然Log Structured的存储方式也有一些缺点:

  • 读取可能会很慢
  • 合并开销大
  • 写放大:对于每一次逻辑写入,可能会产生多次物理写入(因为用户写入后,这个数据可能经历多次合并)

这个让我想到了xv6的操作系统文件系统的log结构,两者具有一定的相似之处。

xv6是先写log区,commit之后再apply到原处,最后清理log

Log-Structured的结构是先写MemTable,整理到SSTable之后再进行Compaction,然后清理旧的SSTable

但是从目的来看,xv6的log是为了保证崩溃一致性,而LSM实际是为了写性能优化。

在查阅相关资料之后了解到,由于MemTable是纯内存的,如果崩溃了会丢失,所以在实际的系统中会额外维护一个Write-Ahead Log来保证持久性

索引组织存储 Index-Organized Storage

前面两种存储模式都有一个特点:表本身是无序的,查找特定元组的时候需要额外的索引。

然而,我们可以把元组本身作为索引数据结构的值来存储。页面布局类似与Slotted Page,但是元组在页内按照key排序。

例如有一张表

CREATE TABLE student (
    id INT PRIMARY KEY,
    name VARCHAR,
    age INT
);

如果按照 id 建B+树,那么传统方式可能是在B+树的index上标注id = 100的时候,指向heap page中tuple所在的位置。查询时先查B+树得到指针,再跳转到heap page读取tuple

但是在索引组织存储中,B+树的叶子节点直接存储tuple本身,查询时直接在B+ 树上读取tuple,不需要二次跳转。

如图所示,B+树节点本质上仍然是一个Page,Page内部采用类似Slotted Page的布局。在一个 page 内部,tuple 通常会按照 key 排序。

8

这种存储方式是在维护B+树的过程中拥有维护成本。与上面的日志结构存储相比,B+ 树在写入时就付出维护成本;日志结构存储写入时先快写,后面再通过合并付出整理成本。

元组内部的组织:如何解读元组内部的字节

元组本质上就是一段字节序列。具体来说,元组的头部header包含一些元数据,包括事务可见性信息、NULL值的位图(注意:不存储schema(数据库模式)信息),后面就是元组的数据。

数据在数据库中如何表示

DBMS 希望确保元组是字对齐(word-aligned)的,通常有两种方法:

  • 填充(Padding):在属性后添加空的 bit,以确保元组是字对齐的。
  • 重排(Reordering):改变属性在物理布局中的顺序,以确保它们对齐。

元组中可以存储五类高级数据类型:

  1. 整数 INTEGER / BIGINT / SMALLINT / TINYINT

    存储上与C/C++相同

  2. 可变精度数字 FLOAT / REAL

    存储上与C/C++相同,使用IEEE-754的存储方式。但是其存在一些舍入的误差,因此我们需要更高精度的数据表示

  3. 定点精度数字 NUMERIC / DECIMAL
    这些是具有任意精度和小数位数的数字数据类型。
    会带有额外的元数据,用来告诉系统一些信息,例如:数据的长度,小数点应该在哪里。
    当舍入误差不可接受时,会使用这些数据类型。一个实际例子如图

9
  1. 可变长度值 VARCHAR / VARBINARY / TEXT / BLOB

    大多数DBMS不允许一个元组超过单个页面的大小。允许超过单页大小的系统,会把数据存储在特殊的溢出页面(overflow page)上,并让元组包含一个指向该页面的引用,溢出页面可以包含指向额外溢出页面的指针,直到所有数据都能被存储下来。

10

有些系统允许你把这些大值存储在外部文件中,然后让元组包含一个指向该文件的指针。比如说数据库存储的是照片信息,DBMS可以把照片存储在外部文件中,而不是让它们在 DBMS内部占用大量空间。这种做法的一个缺点是:DBMS 无法操作这个文件的内容。因此,这些文件没有持久性保护,也没有事务保护。

  1. 日期 / 时间 TIME / DATE /TIMESTAMP / INTERVAL

NULL值如何表示

存在三种选择:

  • NULL bitmap:在头部用位图标记哪些属性为NULL
  • 特殊值:用特定值(如INT32_MIN)代表NULL
  • 逐属性标记:每个属性一个标记

不采用第三种,浪费空间而且破坏字对齐

系统目录 System Catalog

为了让 DBMS 能够解释元组中的内容,它会维护一个内部 catalog,用来告诉它关于数据库的元数据,具体来说,元数据包括:

  • 存储所有表、列、索引的元数据
  • 存储用户权限
  • 存储统计信息(如属性的最大值)
  • 目录本身也存储在DBMS的表中

面向工作负载的存储模型选择

到目前为止我们解决了数据怎么存,但不同工作负载对存储的需求截然不同。一种布局能同时满足所有场景吗?有一说一,我在听lec5前,从来没有想到数据库还能按照列来存储。

按照工作负载来说,我们分为三种不同的工作负载

类型 特征 典型场景
OLTP 短事务,简单查询,以写为主,每次操作少量数据 网购平台下单、购物车
OLAP 长查询,复杂聚合,以读为主,扫描大量数据 用户行为分析、推荐系统
HTAP 二者混合 同一实例同时支持交易和分析

而对于OLAP这样的情况,如果按照我们传统认知的视角,在访问的时候会访问所有页面,而且存在大量无用的数据访问(如图)为了解决这个问题,column store应运而生。

11

平时我们认知里的存储模式,以及上文的存储模式,都是行存储,又叫做N-ary Storage Model(NSM)

这样的存储模式就是将一个元组的所有属性连续存储在同一页中,其适合OLTP但是不适合OLAP

而列存储,又叫做Decomposition Storage Model (DSM),就是倒过来,将同一属性的所有值连续存储在一起。自然而然,这样的存储模式适合OLAP而不适合OLTP

12

然而这样的存放带来一个问题:我怎么判断这一个的某个数据是哪个tuple的呢?对于固定长度的数据还可以直接计算offset,而对于可变长度的数据呢?有一个反面教材,那就是创造一个数据结构,每一个数据都对应一个序号,如下图。

13

这种做法完全违背了列存储的初衷,首先每存储一个值,都要造成一定程度的空间浪费额外存储一个 Tuple ID,其次,数据库读取时必须:读取 [ID, value] 对,然后提取出 ID 和 value。那我列存储的空间连续性就被破坏了。

那么对于可变长度的数据,一般采用间接层或者字典编码的方式。

间接层指我们在可变数据的列存储中只存储对应的指针,真正的数据单独存储在一个可变长度的区域 Variable Pool

字典编码适用于有很多重复值的情况,可以直接字典压缩。例如:

字典: ["Andy", "Mr.Pickles", "Bo"]
某一列: [0, 1, 2, 0, 1] ← 固定长度的整数数组

既然存在行存储和列存储,那我们自然可以把这两个结合起来——Partition Attributes Across (PAX),叫做混合存储。

其存储方式如下:

  • 先将行水平分组
  • 在每个行组内,将属性垂直分列存储。
  • 每个行组相当于一个小型列存。
14

在I/O瓶颈下榨取更多价值——压缩

在文章最开头我提到:

数据必须持久化的存储在磁盘上,但是查询操作需要在内存中进行。而两者的速度差异决定了整个存储层的设计目标:尽可能减少磁盘I/O次数,尽可能让磁盘I/O是顺序的。

而在前文,我们通过使用各种存储手法,尽可能减少了I/O次数。但是我们还有一个方法,可以在单次I/O中获取更多有用的信息——压缩

我们进行数据库压缩具有如下目标:

  • 必须产生定长值(变长数据除外),以支持偏移寻址。
  • 尽可能延迟解压(Late Materialization,又叫做延迟物化):在压缩数据上直接执行查询。
  • 必须无损

压缩也存在不同的粒度

  • 块级别(Block Level):压缩同一张表的一个元组块。
  • 元组级别(Tuple Level):压缩整个元组的内容(仅适用于 NSM)
  • 属性级别(Attribute Level):压缩一个元组内的单个属性值。可以针对同一元组的多个属性。
  • 列级别(Columnar Level):压缩为多个元组存储的一个或多个属性的多个值(仅适用于 DSM)。这允许使用更复杂的压缩方案。

在这里我们介绍两种压缩形式

朴素压缩(Naive Compression)

使用通用压缩算法(gzip, LZ4, Snappy, Zstd等),这类压缩存在如下问题:

  • 压缩/解压的范围有限(不能压缩整张表再逐条访问)
  • 不理解数据语义:不知道数据结构,不知道查询如何访问数据
  • 无法延迟解压:每次访问都必须先解压

例如:MySQL InnoDB压缩页到2的幂KB大小存入缓冲池,但每次读/更新都必须先解压。

列式压缩(Columnar Compression)

(该表格为AI生成)

image

例子:

  • RLE
15
  • RLE进行排序,效率更高
16
  • Bit-Packing
17
  • Mostly Encoding
18
  • Bitmap Encoding
19
  • Delta Encoding
20

21
  • Dictionary Encoding
22

对于Dictionary Encoding的补充

  • 需要保序,编码后的顺序与原值顺序一致。这使得比较、排序等操作可以直接在编码上执行,无需解压。如果不一致,那么在压缩的数据上进行范围查询的时候,就需要先解压再比较,延迟物化就失效了
  • 需要能够快速解码/编码:不能使用hash函数,hash函数的逆映射难求。

总结

我们需要解决的问题就是:数据库如何在磁盘文件中表示数据?

紧接着我们按照物理基础 → 文件层 → 页层 → 元组层 → 压缩层的顺序梳理了一遍如何表示数据,如何提高查询/插入的速率

每一层的设计都不是孤立的,而是由上一层所暴露的问题驱动的:物理硬件的速度差距 → 自己管理I/O → 固定大小的页 → 页内如何组织元组 → 工作负载决定行存还是列存 → 列存使得高效压缩成为可能 → 压缩进一步减少I/O

可以看到整个过程就是一条环环相扣的链条,学完这三个lec也是让我受益匪浅啊