引子
相信对于每个后端开发工程师来说,数据库都是最重要的基本功。在学习数据库系统时,我们往往从 SQL、事务、索引这些偏应用层面的内容开始,这些内容看起来高度抽象,仿佛数据库只是一个逻辑上自洽的黑盒。然而,任何一个真实世界中的数据库管理系统,最终都必须面对一个无法回避的事实:数据必须被持久化地存放在磁盘上,而磁盘的访问代价远高于内存。
正是这种物理约束,决定了数据库系统在最底层的设计形态。为了在性能、可靠性与可扩展性之间取得平衡,DBMS 不可能以"记录"或"表"为单位直接读写磁盘,而是引入了一个关键的中间抽象------数据页。页既是磁盘 I/O 的基本单位,也是内存管理、索引结构、并发控制等几乎所有数据库组件共同依赖的基础。
从页开始,数据库构建起了自己的存储世界:文件如何在磁盘上组织,元组如何在页中布局,不同的工作负载为何催生出不同的存储模型------这些问题并不是实现细节,而是决定系统能力上限的根本因素。无论是面向高并发事务处理的 OLTP 系统,还是面向分析查询的 OLAP 系统,其性能差异,往往源自对"页"这一抽象的不同使用方式。
所以我们这次将从磁盘与 I/O 的现实出发,引入数据页的概念,逐步讨论数据库中的文件组织、页内布局以及由此演化出的行存储、列存储等存储模型。理解这些内容,将为后续讨论内存管理、访问方法和查询执行奠定统一而清晰的基础。
一.面向磁盘的DBMS
众所周知,计算机系统中的存储介质大体可以分为两类:易失存储与非易失存储。前者在断电后数据会丢失,后者则能够提供持久化能力。现代计算机体系结构正是围绕这两类介质,构建起一条分层的存储层级,在性能、容量与成本之间进行权衡。

从上到下,这一层级通常包括 CPU 缓存(Cache)、主存(DRAM)以及磁盘(SSD / HDD)。这里我需要特别强调的是,缓存和内存都是按字节寻址的存储介质 :程序可以以任意字节偏移直接访问其中的数据,访问粒度极细,延迟也极低。而磁盘则完全不同,磁盘是按块寻址的设备。无论是 SSD 还是 HDD,CPU 都无法直接访问某一个字节,而必须以"块"为单位,将整块数据读入内存之后,才能访问其中的任意位置。
正是这种寻址方式和访问粒度的根本差异,构成了数据库系统设计的物理基础。一次磁盘 I/O 的代价,往往比一次内存访问高出几个数量级;更重要的是,磁盘 I/O 具有明显的访问模式差异------顺序 I/O 可以充分利用设备的带宽,而 随机 I/O 则会带来严重的性能下降。因此,在数据库系统中,如何组织数据、如何安排访问路径,核心目标之一就是:尽可能将访问转化为顺序 I/O,并减少不必要的磁盘读写。
近年来,硬件领域也在尝试模糊内存与持久化存储之间的边界。例如 CXL Type-3 设备,提供了一种具有持久化语义、同时又支持近似内存访问模型的存储形态。这类"持久内存"技术被认为可能对数据库系统产生深远影响。但即便如此,在当前主流系统中,磁盘仍然是数据库持久化存储的核心载体,其访问特性也依然决定着系统设计的基本假设。
数据库系统的另一个基本要求是:必须支持远大于单机内存容量的数据规模 。这听起来与操作系统中的虚拟内存机制颇为相似,但在数据库领域,我们通常不会也不应当依赖操作系统的虚拟内存来管理数据。原因在于,虚拟内存的页面调度策略对数据库而言既不可控,也缺乏对访问模式的理解;一旦发生频繁的页面换入换出,磁盘 I/O 的代价将迅速吞噬系统性能。数据库不能"总是从磁盘出发",而必须以更加主动、更加精细的方式管理数据在内存与磁盘之间的流动。
从经典的冯·诺伊曼架构视角来看,系统的本职工作就是协调数据在非易失存储与易失存储之间的移动。在数据库领域,这一职责被 DBMS 自身牢牢掌握。操作系统在某种意义上更像是一个"损友":它提供了基础的 I/O 能力,但其通用策略往往并不适合数据库的访问模式。因此,现代数据库系统通常选择绕过操作系统的大部分抽象,直接管理磁盘上的数据布局、I/O 调度以及内存中的缓存结构,构建一个相对独立的存储子系统。
在接下来的内容中,我们将从磁盘出发,引入数据库中的核心抽象------数据页,并讨论 DBMS 是如何围绕页来组织文件、存储元组以及构建不同的存储模型。而数据库如何在内存中管理这些页、如何高效地在磁盘与内存之间移动数据,我先卖个关子,咱留到下一篇文章中再详细展开介绍。
二.IO现实与数据页的产生
在前文中我们已经提到,数据库系统设计的一个核心原则是:尽可能将数据访问转化为顺序 I/O,并减少不必要的磁盘读写。这一原则并非经验总结,而是直接来源于磁盘设备的物理特性。面对高昂且不可避免的 I/O 成本,数据库系统不可能以"元组"或"记录"为粒度直接与磁盘交互,而必须引入一种更粗粒度、更加工程化的抽象来组织数据访问。这正是数据页产生的背景。
在数据库系统中,页是磁盘与内存之间交换数据的基本单位:一次磁盘 I/O 读写的对象永远是一个完整的页。无论查询最终只需要其中的一小部分数据,还是需要遍历整页内容,DBMS 都必须先将整个页面调入内存后才能进行访问。这种设计看似"浪费",但它使得数据库能够有效地聚合访问请求、摊薄 I/O 成本,并为顺序访问创造条件。
数据库页面通常具有统一的结构与固定的大小 ,每一个页面都服务于数据库中的某一种逻辑实体(例如数据页、索引页等)。为了保证数据可靠性与系统可恢复性,页面中往往还会包含额外的元数据,例如校验和用于检测页面是否损坏,页面标识与日志相关信息用于支持崩溃恢复。这些内容并不直接服务于查询逻辑,却是构建一个可靠 DBMS 所不可或缺的基础设施。
围绕"页"这一抽象,DBMS 需要承担一系列职责:决定哪些数据应当被读入或写回到页面中;在数据删除或更新时,如何重用页面内部或页面之间的空间;以及在存储层面是否、以及如何对页面进行压缩。这些决策都直接影响磁盘 I/O 行为,也决定了系统在不同工作负载下的性能上限。在讨论数据页时,有一个容易混淆但必须澄清的问题:数据库语境中的"页"并不是单一概念,而是存在于多个层面的不同抽象。
首先是硬件层面的页(或块)。这是存储设备能够保证原子写入的最小单位,在传统系统中通常是 4KB。硬件只能以这样的粒度完成持久化写入,任何更小粒度的修改都必须通过整页写回来实现。
其次是操作系统层面的页。这是操作系统用于管理虚拟内存和物理内存映射的基本单位,在 x86 架构下通常也是 4KB。随着硬件与操作系统的发展,出现了所谓的"巨页"(Huge Page),其大小可以达到 2MB 甚至 1GB,用于减少页表开销和 TLB miss。这一层的页主要服务于通用程序的内存管理需求,其调度策略对数据库工作负载并不透明。
最后才是数据库层面的页。这是 DBMS 在逻辑上定义的数据组织与 I/O 单位,用于在磁盘与内存之间批量移动数据。数据库页的大小由系统自行决定,不同 DBMS 之间存在显著差异。例如,SQLite、Oracle 和 MongoDB 常采用 4KB 页,SQL Server 和 PostgreSQL 通常使用 8KB 页,MySQL InnoDB 默认采用 16KB 页,而 DB2 甚至允许用户在创建表空间时动态指定页大小。页面大小的选择,直接影响 I/O 次数、内部碎片以及缓存利用率,这是一个典型的工程权衡问题。
需要注意的是,数据库页并不要求与操作系统页或硬件页严格对齐,但在实践中,系统通常会选择与底层页大小成倍数关系的设计,以避免额外的拷贝与对齐成本。数据库并不会将页面的管理完全交给操作系统,而是基于自身对访问模式的理解,自行决定页面的组织与使用方式。
三.页面内部的布局
1.元组的布局
在关系型数据库中,存储的基本实体是元组(tuple) 。一个元组对应一条记录(row),包含了多个属性值的字节序列,这些字节序列在逻辑上按表定义的 schema 组织。尽管我们在 SQL 层面上习惯按行来描述数据,但在底层磁盘存储中,数据库真正关心的是**元组的位置,**即它在哪个页面内、在页面内哪个偏移量。为什么这一点如此重要?原因主要有两个:
-
碎片问题:随着插入、删除和更新操作的交替发生,页面内部的空闲空间会变得零散。如果没有合理的空间管理,会导致空间无法被有效重用,从而浪费磁盘空间并增加 I/O 成本。
-
无意义的 I/O 操作:数据库必须将整个页面从磁盘读入内存才能访问其中的某个元组。如果没有有效的页面内部组织,查找和修改单个元组可能导致不必要的随机 I/O,毫无疑问这会极大拉低系统性能。
为了同时解决这两个问题,关系型数据库中最常见的页面内部布局策略被称为槽页 (Slotted Page)。在经典的数据库存储教材与课程资料中,这种布局被描述为最常用的方案,它能够灵活管理元组的位置,同时兼容定长与变长元组。槽页的组织方式大致如下图,朋友们可以结合图来理解:

首先页面起始部分是一个 Header :用于存储该页面的元数据,如已使用的槽数量、当前可用的自由空间起始位置等。接下来是一个 Slot Array (槽数组):槽数组中的每一个条目都保存着一个指向元组数据的偏移量。这些偏移量使得数据库可以快速定位页面内部的元组,而不必线性扫描整个页面。元组数据则存储在页面的另一端,并从页面后方向前增长。随着插入操作的进行,元组数据向页面的前部扩展,而槽数组从前部向页面的后端扩展。两者在页面内部从两端向中间生长,当它们相遇时,说明该页面已无足够空间插入新的元组。
之所以采用这种设计,而不是简单地把元组顺序追加在 Header 之后,是因为这种设计模式支持变长元组 :很多数据库表的某些字段(如字符串、可变二进制数据)是可变长度的。使用槽数组可以在空间紧凑且碎片相对可控的情况下存放变长元组。其次删除与更新时空间管理更简单 :如果一个元组被删除,只需在槽数组中标记该槽为空,同时回收相应数据空间(可能延后实际压缩)。不必移动页面中所有的其他元组。随着时间推移,通过"页面压缩/整理"操作(比如说 PostgreSQL 的 VACUUM)可以统一整理碎片并回收空间。而且更新位置透明 :当一个元组被更新后,其实际数据可能在页面内部移动以适应新的大小,但数据库外部对它的引用(如通过 record ID 、行标识符等)通常是基于槽索引而不是绝对偏移。槽数组只需要更新偏移量,而不需要更新所有外部引用。
总的来讲,槽页结构提供了一种高效、灵活且支持变长记录的页面内部布局策略,同时使得数据库可以在进行插入、删除、更新等操作时,避免过度的随机 I/O 和过度的碎片开销。我查阅了相关资料发现,现代关系型数据库(如 PostgreSQL、MySQL InnoDB 等)在不同页面类型下都采用了类似的机制作为默认数据页布局。
2.元组的组织
在数据库系统中,元组在物理层面本质上只是一个连续的字节序列 。DBMS 之所以能够正确地解析这些字节,是因为每个元组在其起始位置通常包含一个元组标头,用于描述后续字节的语义信息,例如字段数量、空值标记、版本信息或可变长度字段的偏移量。正是依赖这些元数据,数据库系统才能将一段无结构的字节数组还原为具有类型与语义的逻辑记录。
需要注意的是,逻辑视图与物理视图并不要求一一对应。从逻辑上看,用户期望按字段顺序访问元组;但在物理层面,DBMS 完全可以重新排列字段在字节序列中的布局,以适应底层硬件特性并提升访问效率。
现代 CPU 在内存访问时对字节对齐高度敏感。以 64 位架构为例,若一个 8 字节数据跨越了 64-bit 边界,CPU 虽然仍能正确执行读取操作,但往往需要进行多次内存访问或额外的缓存合并操作。这些代价通常会被硬件机制部分隐藏,但在高频访问的数据库场景下仍会累积为可观的性能损耗。
因此,数据库系统在设计元组布局时,通常会采用填充(padding)策略,在字段之间插入若干无意义的填充字节(通常为 0),以保证关键字段按照硬件友好的边界对齐。这种做法牺牲了一定的存储空间,却换来了更高效、更可预测的访问性能。
总的来讲,元组的物理组织是一个在空间效率与访问效率之间权衡的工程问题:逻辑上保持字段语义清晰,物理上则通过重新排列与对齐,将数据紧凑而高效地打包进页面中的字节数组。
四.文件的组织形式
在进入文件组织形式的讨论之前,有必要先明确咱们所讨论的**"页"处于哪一个层面。这里我们关注的是数据库系统层面的页。** 即 DBMS 作为存储与访问的基本单位,从磁盘中读入内存、并在内存中进行修改后再写回磁盘的数据块。在数据库系统中,文件本质上是一组数据页在磁盘上的逻辑集合。因此,讨论"文件的组织形式",本质上就是在讨论:
数据页以何种方式被组织、链接和管理,从而支持插入、删除、更新和扫描等操作。
1.堆文件
最简单、也是概念上最直接的一种文件组织方式,便是堆文件(Heap File) 。堆文件可以理解为一个无序的数据页集合 。页面在文件中的排列不反映任何记录层面的顺序或结构假设,DBMS 不对记录进行排序,也不维护额外的组织约束。每个页面内部仍然采用前面讨论的页面布局(例如槽页)来管理元组,但页面之间不存在顺序语义 。这种设计的最大优势在于其写入路径极其简单 。当系统需要插入一条新记录时,DBMS 只需找到一个存在可用空间的页面即可完成写入;如果当前没有任何页面有足够的空闲空间,系统便直接在文件末尾追加一个新的数据页。这种"追加式(append-only) "的策略意味着:不需要为了插入而重排已有记录,不需要移动其他页面,不需要重写整个文件。因此,堆文件在写入密集型工作负载中具有非常强的写性能,也非常适合作为系统中最基础、最通用的存储结构。
然而,这种简单性是以查询效率 为代价的。由于页面之间没有任何顺序或索引结构,针对堆文件的查询(例如基于某个属性的选择条件)通常只能通过全文件扫描来完成:DBMS 必须逐页读取文件中的所有页面,并在每个页面内检查元组是否满足条件。这不仅带来了大量不必要的 I/O 操作,也使得堆文件在选择性较高的查询场景下性能表现较差。
从空间管理的角度来看,堆文件也引入了新的挑战。随着记录的不断插入和删除,页面内部会逐渐产生碎片,而页面之间也会出现"部分填充页面 "。为了避免频繁扫描整个文件来寻找可用空间,实际系统通常会为堆文件维护额外的元数据结构(例如空闲页面列表、目录页等),用于快速定位存在可用空间的数据页。这些优化并不会改变堆文件"无序页面集合"的本质,但在工程上显著提升了其可用性。一句话总结,堆文件代表了一种以最小组织成本换取最大写入灵活性的文件组织方式。在工程实践中,应用堆文件最典型的系统就是PostgreSQL。
2.日志结构存储
在前面对堆文件的讨论中,我们的分析是以元组为中心展开的 。这种设计隐含了两个重要假设:元组是稳定的物理实体 ,且拥有固定的物理位置 。在这一模型下,元组的更新要么发生在原地,要么在移动后维护额外的指针关系。正如前文所述,这种以槽页为代表的设计在早期磁盘环境和典型 OLTP 负载下是合理且高效的,但在现代硬件条件和高写入吞吐场景中,它逐渐暴露出一系列问题:随机写放大、页面级锁竞争、碎片整理成本高昂,以及对缓存和 NUMA 架构的不友好。而正是为了系统性地克服这些面向元组槽页的架构缺陷 ,数据库领域引入了一种完全不同的设计思路------日志结构存储(Log-Structured Storage)。
这里我有必要向朋友们首先澄清一个常见误区:日志结构存储并不是对槽页的一种改进或优化,而是对槽页模型的有意绕过。 在日志结构存储中,数据库系统放弃了"原地更新"的基本假设 。取而代之的是,系统维护一个只追加(append-only)的日志结构,可以将其类比为一本账本 :每一条记录代表一次写操作(例如一次 Put 或 Delete),更新并不修改已有数据,而是追加一个更新版本; 数据的"当前值"由时间顺序或版本语义决定,而非固定物理位置。这一设计直接带来了两个重要后果:写入路径被完全顺序化 ,可以最大化顺序 I/O 吞吐,甚至在某些实现中实现"盲写";页面不再承担"可变元组容器"的职责,由此碎片管理、槽位维护等复杂机制随之消失。
日志结构存储是一种设计哲学,而**日志结构合并树(LSM-Tree)**则是这一哲学下最经典、也最成功的工程实现之一。在 LSM-Tree 中,系统通常维护如下层级结构(此处我引用LevelDB的实现架构图进行参考):

写入首先进入 MemTable ,这是一个驻留在内存中的数据结构,用于保存最近的更新操作。
它以键值对(key-value)的形式组织数据,但并不试图表示整张表的完整状态 ,而只包含最近写入或更新的键。MemTable 的具体实现并不固定,常见选择包括跳表 (如 RocksDB、DuckDB),红黑树或其他有序表结构,以及Trie。这里我们需要强调的是,这里的"原地更新"仅发生在内存数据结构内部,并不涉及磁盘页面。
当 MemTable 达到容量阈值后,系统会将其内容刷到磁盘 ,形成一种称为 SSTable(Sorted String Table) 的磁盘文件。SSTable 具有几个关键特征:首先文件一旦生成即不可变;内部按键有序存储;通常以顺序 I/O 写入磁盘;以"运行(run)"的形式分层组织。这里我需要纠正一个常见疑问:系统并不会简单地将 MemTable 的内存布局直接序列化写入磁盘。 原因在于,SSTable 并不关心内存结构中的引导信息(如指针、层级结构),它只需要一段按键排序后的日志式记录流,从而获得更紧凑、更易合并的磁盘表示。
随着时间推移,磁盘上会存在多层 SSTable。为了回收空间、清理过期版本并维持查询效率,系统需要周期性地执行 Compaction(合并) 操作。Compaction 的代价实际上并不低,因为同一条记录可能被多次重写,这引入了不可忽视的写放大(Write Amplification) 。写放大指的是:应用程序逻辑上只写了很少的数据,但底层系统为了完成这次写入,实际上写了成倍甚至数量级更多的数据。 因此,LSM-Tree 的设计本质上是一种权衡:用写放大和后台合并,换取前台写入吞吐和顺序 I/O 性能。
在读取时,系统需要在 MemTable 以及多层 SSTable 中通过二分查找的方式定位目标键。为了避免对每个 SSTable 进行全量扫描,LSM 系统通常会维护额外的辅助元数据结构 ,包括稀疏索引 / 摘要表(Summary) :记录 SSTable 中部分关键键及其偏移位置,用于快速定位磁盘区间;布隆过滤器(Bloom Filter) :快速判断某个 SSTable 是否"可能包含"目标键,减少无意义 I/O。这是一个非常重要的数据结构,我们后面会重点介绍!层级元数据 :记录各层 SSTable 的键范围与时间顺序。这些结构的目标并非解决写放大问题,而是显著降低读放大和无效磁盘访问。在工程实践中,应用日志结构存储典型的系统有RocksDB / LevelDB。
3.索引结构存储
在前文介绍的堆文件与日志结构存储中,数据页的组织方式分别侧重于元组的独立存放 与写入顺序的优化 。然而,在大量以主键访问为主、点查和范围查询频繁的业务场景下,这两种方式都存在一个共同问题:**查询往往需要"先索引、再定位数据",多了一次额外的跳转。**正是在这一背景下,索引结构存储(Index-Organized Storage)应运而生。
索引结构存储的核心思想非常直接:数据本身即按照索引结构进行组织,不再存在独立的堆文件。 换言之,索引的叶子节点中存放的已经不再是 Record ID 或指向元组的指针,而是元组本身 。在实际系统中,这类存储方式几乎清一色采用 B+ 树 作为底层结构。其中内部节点仅保存键值与子节点指针,用于导航与分裂控制;叶子节点则顺序存放完整记录,并通过链表相连以支持高效范围扫描。
由于数据直接嵌入索引中,主键查询可以在一次树遍历中完成,避免了"回表"开销;但代价也同样明显------任何插入或更新操作都必须维护索引结构本身,可能引发节点分裂和重平衡。
除 B+ 树外,也存在一些变体与探索方向,例如:针对内存或特定负载优化的 B-tree 变体;在部分系统中尝试过的 Trie / Radix Tree(多见于路由、KV 场景)。在工程实践中,应用索引结构存储典型的系统包括:MySQL InnoDB (主键聚簇索引),SQLite (表即 B-tree)。至于 B+ 树的具体结构、分裂策略与并发控制机制,这里卖个关子,我们将在后续的索引专题中专门展开,这里不再赘述。
五.工作负载和存储模型
在前面我们讨论了页面内部如何布局元组、以及页面如何组成不同的文件组织形式。这些物理组织结构本质上是为了解决数据库在磁盘与内存之间高效管理数据的工程问题。当我们向更上层看系统的整体行为时,就会发现不同的应用对数据访问模式的需求显著不同,这直接驱动了对存储模型的选择。
数据库的工作负载通常可以粗略归纳为两类极端模式:OLTP(在线事务处理) :这类工作负载主要处理来自外部世界的事务请求,例如订单创建、余额更新、库存扣减等。它们的特点是每次访问涉及的数据量很小、操作延迟要求极低、更新频繁且并发度高,典型应用包括银行核心系统、电商订单系统和库存管理等。OLTP 系统通常需要快速响应、强一致性以及高并发的插入、更新与删除操作。
OLAP(在线分析处理) :与 OLTP 相对,OLAP 关注对现有数据集进行复杂的分析查询 ,例如报表生成、趋势分析、聚合计算等。OLAP 查询往往会处理大量数据,涉及多个表的连接、聚合和过滤,读操作远多于写操作、查询计算量大、对单次响应时间的敏感度相对较低。这种模式常见于数据仓库、BI(商业智能)系统和历史数据分析场景。
在实际企业级生产系统中,很少存在纯粹的 OLTP 或纯粹的 OLAP ,多数应用属于某种程度的混合负载。例如某电商平台既要处理订单交易(OLTP),又要基于历史订单数据做销售分析(OLAP)。这种场景催生了 HTAP 架构,使得一个系统能够同时支撑两类负载。

不同的工作负载对数据的访问模式提出了不同的要求,也就推动了不同的存储模型 的演化。需要强调的是,关系模型本身只是规定了逻辑结构,并未对数据在表或元组中如何物理存储做出约束。因此,在具体实现中,DBMS 会选择不同的存储模型以匹配不同的访问模式,这主要包括以下几种:
行存储(NSM) :这是传统关系型数据库最常见的存储模型,在一个页面中将一个元组的所有属性值按顺序连续存储。对于 OLTP 工作负载,这种布局非常高效,因为一次 I/O 可以获取一个完整的元组,适合点查与短事务。
列存储(DSM) :与行存储相反,列存储把每个属性的所有值分别存储,使得对某些列的批量读取可以绕过不相关数据,从而大幅减少 I/O。列存储极适合 OLAP 类型的批量聚合与扫描查询,提高缓存效率与压缩比。
PAX 混合存储 :作为行存储与列存储之间的折中方案,PAX 在每个页面内将属性垂直分区。换句话说,在一个页内把同一属性的值聚集在一起,同时又保留了页面级的局部性。这种设计在某些硬件与查询模式下可以同时获得行存与列存的优势。
六.页面压缩
1.页面压缩-空间与时间的经典权衡
在数据库系统中有一个经典的设计权衡:速度(CPU / I/O)与存储空间利用率之间的折衷 。磁盘存储虽然比内存便宜得多,但 I/O 带宽和延迟仍是性能的主要瓶颈。因此,一个看似简单的思路便是对页面数据进行压缩存储:用更少的字节表示更多的数据,以减少磁盘 I/O 的负担。
然而,压缩意味着在读取时要付出额外的 CPU 解压成本 。这引出了空间节省与 CPU 消耗之间的权衡:我们可能愿意在查询执行期间付出更高的 CPU 代价,以换取更少的磁盘数据读取量,从而在整体上提升查询性能。这种权衡在现代系统中非常普遍,尤其是在 SSD 或 NVMe 环境下,CPU 通常更容易成为瓶颈而不是 I/O。
此时一个重要概念自然出现------延迟物化(Late Materialization) 。延迟物化是指在查询处理中尽可能延后对压缩数据的真正解压与实体构建 ,以便在压缩表示上尽量执行尽可能多的操作。例如,在列存储中,当我们只关心某几列聚合时,可以先在压缩列上计算汇总结果,而不必解压完整记录。这样既减少了 I/O,又避免了过早引入解压成本。延迟物化是一种广泛用于列式查询引擎的优化策略,它利用了对压缩格式的直接操作能力 ,以达到 I/O 与 CPU 之间的更优平衡。这里我需要强调的是,这种压缩必须是无损压缩。与 MP3 或 MP4 等媒体格式不同,数据库压缩不能丢弃任何用户数据的比特。任何有损压缩在数据库语义下都是不可接受的,因为它会破坏数据的精确性。
2.常见页面压缩方案
在数据库存储引擎中可以采用多种无损压缩方法,它们各自针对不同的数据特征与访问模式,这些压缩方法各有优劣,通常需要根据数据分布来选择或组合使用:游程编码( RLE) 对于具有重复值的数据序列非常有效,例如某个列中有很多连续相同的值。RLE 将一系列重复值压缩成值+次数的对;位打包(Bit-Packing) 当数据的取值范围小于它的原始表示宽度时,可以用更少的位来表示。例如一个布尔列只需要 1 位,而不是 8 位或 32 位。增量编码 的思想是存储序列中每个值与前一个值的差分,而不是存储完整值,适合用于递增或规律性数据。字典编码为实际数据值建立一个字典,将每个重复值替换为较小的字典键。这种方法既可以减小存储也可以加快等值查询。
3.页面压缩的不同策略与实现层级
在讨论页面压缩时,我们需要区分两个层次:首先是**块级压缩(Block / Page-Level Compression),**这是最常见的压缩策略,数据库将一个完整的页面作为压缩单位进行编码。在写入时对整个页面压缩,在读出页面后解压以访问其中的数据。该策略对 OLTP 查询的即时解压有较高成本,而且由于行存布局中数据分散,压缩比可能不理想。
实际系统中,为了支持写密集场景,有时会引入类似于mod log / delta log的机制来吸收写入:先把增量写入记录在一个小的日志结构中,减少对压缩页面的即时解压。读取时先检查这个日志,如果没有命中,再解压页面本体。这种技术可以被视为"减少即时解压的一种工程策略"。

另外一种是直接在压缩表示上操作, 更高级的存储模型希望在压缩格式上就能执行查询操作,而无需先把数据完全解压 。这通常只在列式存储或向量化引擎中才真正可行。例如,在列存结构下,当查询只涉及某些列时,可以直接对压缩列执行扫描与聚合,而不是完整解压为物理元组。这种方式与前面介绍的延迟物化紧密相关,并能显著降低总体 CPU + I/O 成本。

结语
在这一篇中,我们从磁盘与 I/O 现实出发,引入了数据页这一数据库存储的基本抽象,依次讨论了页面内部布局、文件组织形式以及不同工作负载下的存储模型与页面压缩策略。可以看到,数据库存储层的所有设计,本质上都是围绕"减少无意义的 I/O、提高局部性、在空间与计算之间做权衡"这一核心目标展开的。然而,磁盘之上的这些精巧布局只有在被高效地加载、缓存和复用 时才能真正发挥价值。下一节中,我们将把视角从磁盘转向内存,深入讨论数据库系统如何通过 Buffer Pool 与内存管理机制,在有限的内存资源下高效地支撑这些存储设计。本文参考了诸多论文与博客,以及我自己的学习笔记,如有疏漏请及时指正,我们一起进步!