跳转至

Lecture 4

Database Storage Part 2

Storage

File Organization

Slotted Pages

页中的元组是变长的,所以需要一个额外的数据结构来存储元组的位置信息。这个数据结构叫做Slot,每个Slot对应一个元组。

Slotted Page 内部结构如下图所示:

block-beta
    columns 1
    block:header["Page Header"]
        columns 3
        A["num_slots"] B["num_used"] C["free_start"]
    end
    block:slots["Slot Array (grows →)"]
        columns 4
        S1["Slot 0: offset, len"] S2["Slot 1: offset, len"] S3["Slot 2: offset, len"] S4["..."]
    end
    space
    block:data["Tuple Data (← grows)"]
        columns 3
        T1["Tuple 2"] T2["Tuple 1"] T3["Tuple 0"]
    end
Slotted Page 详细工作原理

Header 中记录: - num_slots:已分配的 slot 总数 - num_used:实际使用的 slot 数量 - free_start:data 区域的空闲空间起始偏移

Slot Array 从页头向后增长,每个 entry 记录对应 tuple 的 (offset, length)

Tuple Data 从页尾向前增长,两者之间的区域即为 free space

删除元组时: 1. 将对应 slot 标记为 invalid(或移除) 2. 移动 tuple 数据以填补空隙 3. 更新受影响 slot 的 offset

这种设计使得 tuple 可以在页内任意位置存放,不需要固定大小的 slot,非常适合变长记录。

  • 支持变长元组,空间利用率高
  • 删除元组后可以 compact 页内空间
  • tuple 无需连续存放,便于碎片管理
  • 需要额外的 slot array 空间开销
  • 删除操作涉及数据移动,有一定性能代价
  • 随机访问需要先查 slot array,间接寻址
实际应用

PostgreSQL 的 heap 表使用类似的 slotted page 机制,其 Page Header 中 pd_lowerpd_upper 分别指向 slot array 结尾和 tuple data 起始,中间为 free space。MySQL InnoDB 的页结构也有类似的记录头信息,但采用了不同的组织方式(如 Compact 格式)。


Log-Structured File Organization

Log-Structured File Organization是一种基于日志的文件组织方式。它将所有的更新操作都记录在一个日志中。DBMS只储存日志记录,而不是直接记录数据。如果需要读取数据,DBMS会先读取日志,然后重新构建数据。使用indexes可以加速找到日志的位置。周期性地compact(压缩)日志可以减少读取的时间。

LSM-Tree 与 Log-Structured Storage 的关系

Log-Structured Storage 的核心思想来源于 LSM-Tree (Log-Structured Merge-Tree),其工作流程如下:

flowchart LR
    W["写操作"] --> M["MemTable (内存)]
    M -->|达到阈值| I["Immutable MemTable"]
    I -->|flush| L0["Level 0 SSTable"]
    L0 -->|merge| L1["Level 1 SSTable"]
    L1 -->|merge| L2["Level 2 SSTable"]
    L2 -->|...| LN["Level N"]

写入流程: 1. 写操作首先记录到 WAL (Write-Ahead Log) 以保证持久性 2. 数据写入内存中的 MemTable(通常用跳表或红黑树实现) 3. MemTable 达到阈值后转为 Immutable MemTable,然后 flush 到磁盘形成 SSTable 4. 后台执行 compaction,将多个 SSTable 合并以减少读放大

读取流程:依次查找 MemTable → L0 → L1 → ... → LN,使用 Bloom Filter 跳过不含目标 key 的 SSTable。

指标 含义 Log-Structured 特点
Write Amplification 实际写入磁盘量 / 用户写入量 由于 compaction,写放大较高
Read Amplification 一次读取需要的 I/O 次数 无 index 时需要遍历多层
Space Amplification 实际占用空间 / 有效数据空间 compaction 前可能存在冗余

设计 LSM-Tree 时需要在三者之间做 trade-off

  • 写入性能极高(顺序写,避免随机 I/O)
  • 天然适合写多读少的工作负载
  • SSTable 内部有序,便于压缩和合并
  • 读取需要查找多个层级,可能需要 index 辅助
  • Compaction 会占用 I/O 带宽,影响前台性能
  • 空间放大问题(compaction 前旧版本数据仍占用空间)
实际应用
  • LevelDB / RocksDB(Google/Facebook):经典的 LSM-Tree 实现,广泛用于嵌入式存储引擎
  • Cassandra / ScyllaDB:分布式数据库使用 LSM-Tree 作为存储引擎
  • MongoDB WiredTiger:支持 LSM 和 B-tree 两种模式
  • InnoDB 虽以 B-tree 为主,但其 redo log 也采用了类似 log-structured 的思想

Data Representation

元组只是字节的序列,可以使用不同精度的数据类型来表示数据,如Integer、Float、Decimal、Date、Time、String等。

不同数字类型有不同的精度和范围,使用过程中要避免舍入误差的问题。使用带有元数据、精确、可变长的二进制表示可以解决这个问题。

常见数据类型在磁盘上的存储方式
block-beta
    columns 1
    block:int["Integer: 定长 1/2/4/8 字节"]
        A["Big-Endian / Little-Endian 序列化"]
    end
    block:float["Float: IEEE 754 标准"]
        B["4B (float32) / 8B (float64)"]
    end
    block:str["String: 变长"]
        C["方案1: 空终止符 (\\0)"]
        D["方案2: 前缀长度 (1~4B) + 数据"]
    end
    block:var["Varlena (PostgreSQL)"]
        E["1B header + data (小对象)"]
        F["4B header + data (大对象, TOAST)"]
    end
类型 存储方式 注意事项
Integer 固定长度,直接序列化 注意字节序(endianness)
Float IEEE 754 二进制表示 存在精度丢失,不适合精确计算
Decimal 变长二进制编码,含元数据 精确,无舍入误差,适合财务数据
Date/Time 通常用 int64 存储 epoch 偏移 注意时区问题
String 变长,需额外存储长度 编码问题(UTF-8 vs ASCII)

Storage Models: NSM / DSM / PAX

元组在页内的组织方式也会影响查询性能。不同的存储模型适用于不同的工作负载。

block-beta
    columns 1
    block:nsm["NSM (N-ary Storage Model) — 行存储"]
        columns 4
        N1["Col A"] N2["Col B"] N3["Col C"] N4["Col D"]
    end
    block:dsm["DSM (Decomposition Storage Model) — 列存储"]
        columns 1
        D1["All Col A values"]
        D2["All Col B values"]
        D3["All Col C values"]
        D4["All Col D values"]
    end
    block:pax["PAX (Partition Attributes Across) — 混合存储"]
        columns 2
        P1["Page 1: Col A | Col B"] P2["Page 2: Col C | Col D"]
    end
三种存储模型详解
  • 每个页存储完整的元组,列值连续存放
  • 适合 OLTP(点查询、插入、更新)
  • 优点:元组访问快,一次 I/O 读取完整记录
  • 缺点:分析查询需要扫描整行,缓存不友好
  • 代表:PostgreSQL、MySQL InnoDB、Oracle
  • 每个列单独存储,同一列的值连续存放
  • 适合 OLAP(聚合查询、列裁剪)
  • 优点:只读取需要的列,压缩比高,向量化处理友好
  • 缺点:点查询需要拼接多列,元组重构代价高
  • 代表:ClickHouse、Apache Parquet、Vertica
  • 页内按列分区存储,页间仍按行组织
  • 兼顾行存储的元组访问和列存储的缓存友好性
  • 优点:页内列分区提高 CPU cache 命中率
  • 缺点:实现复杂度较高
  • 代表:Apache ORC、部分商业数据库的内部格式

Postgres:Numeric

用C语言来表示储存细节:

C
1
2
3
4
5
6
7
typedef unsigned char NumericDigit;
typedef struct {
    int ndigits; // number of digits in digits[]
    int weight; // weight of first digit
    int sign; // NUMERIC_POS, NUMERIC_NEG, or NUMERIC_NAN
    NumericDigit *digits; // array of digits, most significant first
} numeric;
PostgreSQL NUMERIC 内部表示详解

PostgreSQL 的 NUMERIC 类型使用 base-10000 的进制来存储每一位数字(每个 NumericDigit 存储 0-9999),这样可以在二进制内存中高效地表示任意精度的十进制数。

  • weight:第一个 digit 的权重(即该 digit 对应 10000^weight 位)
  • scale:小数点后保留的 digit 位数
  • sign:正数、负数或 NaN

例如,数字 123456789.01 在内存中表示为: - ndigits = 3(存储了 3 个 base-10000 的 digit) - digits = [1, 2345, 6789](即 1×10^8 + 2345×10^4 + 6789×10^0) - weight = 2scale = 2


Large Values

大多数DBMS都不允许元组的大小超过一个页的大小。为了存储大的数据,DBMS会将数据分割成多个页,然后使用指针来连接这些页,即seperate overflow pages。

flowchart LR
    P["主元组页"] -->|指针| O1["Overflow Page 1"]
    O1 -->|指针| O2["Overflow Page 2"]
    O2 -->|指针| O3["Overflow Page 3"]
大对象存储策略对比
  • 数据分散在多个页中,通过指针串联
  • 优点:实现简单,与页大小解耦
  • 缺点:顺序扫描时产生大量随机 I/O
  • 代表:PostgreSQL TOAST、SQL Server row-overflow
  • 大对象存储在独立文件中,元组内只存文件路径/引用
  • 优点:文件系统天然支持大文件,可以利用 OS 缓存
  • 缺点:DBMS 无法直接管理,无法保证事务语义(ACID)
  • 代表:Oracle BFILE、MySQL 的 FILE 引用
  • 小于阈值的 LOB 直接存储在元组内
  • 避免额外的 I/O,读取性能好
  • 阈值通常为几 KB(如 SQL Server 为 8KB)

External Value Storage

有时候,元组中的数据可能会很大,不适合存储在页中。这时候可以使用外部存储,将数据存储在文件中,然后在页中存储指向文件的指针。DBMS无法直接访问外部存储,也无法提供一些保护,如durability(持久性)、transaction(事务)等。

外部存储的事务安全问题

当数据存储在 DBMS 外部时: 1. Durability:外部文件的写入不受 WAL 保护,崩溃后可能出现不一致 2. Isolation:外部文件不受锁机制保护,并发访问可能导致数据竞争 3. Atomicity:外部文件的修改无法回滚,只能靠应用层逻辑处理 4. Backup:DBMS 的备份工具通常不包含外部文件,需要额外处理

因此,除非有特殊需求(如存储媒体文件、日志文件等),通常建议将数据存储在 DBMS 内部(使用 LOB 类型或 overflow pages)。


Storage Summary: 三种文件组织方式对比

Heap vs Log-Structured vs Index-Organized
特性 Heap File Log-Structured Index-Organized
写入模式 随机写(原地更新) 顺序写(append-only) 随机写(维护索引)
读取模式 需要额外索引 需要遍历日志/多层 直接通过索引定位
写放大 高(compaction) 中等
读放大 低(有索引时) 高(无索引时)
空间放大 中等(碎片) 高(compaction 前)
适用场景 OLTP 混合负载 写密集型 读密集型、点查询
代表系统 PostgreSQL Heap RocksDB / LevelDB MySQL InnoDB Clustered Index
如何选择存储引擎
  • 写多读少(如日志系统、时序数据库):选择 Log-Structured(LSM-Tree)
  • 读多写少(如 OLTP 点查询):选择 B-tree / Index-Organized
  • 混合负载:Heap + 索引 是最常见的选择
  • OLAP 分析:列存储(DSM)+ 压缩是首选