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_lower 和 pd_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 | |
|---|---|
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 = 2,scale = 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)+ 压缩是首选