数据库--页(page)
在数据库系统中,页(Page) 是数据存储和管理的最小物理单元,它是磁盘与内存之间数据传输的基本单位。
postgres官网对页(page)的介绍
页的核心定义
- 物理存储单元:页是数据库在磁盘上分配的一段固定大小的连续存储空间,用于存储表数据、索引、元数据等。
- 内存操作单元:当数据库读写数据时,以页为单位将数据从磁盘加载到内存(Buffer Pool)或写回磁盘。
- 统一管理单位:所有增删改查操作最终都转化为对页的修改。
常见页大小
数据库/存储引擎 | 默认页大小 | 可配置性 |
---|---|---|
MySQL InnoDB | 16 KB | 支持 (innodb_page_size ) |
PostgreSQL | 8 KB | 编译时固定 (可以配置) |
SQL Server | 8 KB | 不可配置 |
Oracle | 8 KB / 16 KB | 表空间级别配置 |
页的内部结构
一个页通常包含以下组成部分(以InnoDB为例):
- 页头(Page Header)
- 存储页的元信息:页类型(数据页、索引页等)、当前记录数、上一页/下一页指针(用于双向链表)。
- 数据行(User Records)
- 实际存储的数据行(Row),按主键顺序或插入顺序排列。
- 空闲空间(Free Space)
- 未使用的存储区域,用于后续插入新数据。
- 页目录(Page Directory)
- 类似目录结构,存储数据行的相对位置(槽/Slot),用于加速页内数据查找(二分查找)。
- 页尾(Page Trailer)
- 校验信息(如Checksum),确保数据完整性。
以下是 PostgreSQL 官方文档中对 页(Page) 的详细描述(基于提到的 Table 65.2. Overall Page Layout),结合技术原理和实际应用场景的解析:
PostgreSQL 页(Page)的物理结构
PostgreSQL 使用 堆表(Heap-Organized Table) 存储数据,所有数据页的物理结构遵循统一布局。以下内容来自官方文档:Database Page Layout。
表 65.2 整体页布局(核心组件)
组件 | 描述 |
---|---|
PageHeaderData | 页头信息(24字节),包含页的元数据。 |
ItemIdData | 项标识符数组,每个条目指向页内数据行的位置(4字节/条目)。 |
Free Space | 未分配空间,新数据行从尾部插入,新项标识符从头部分配。 |
Items | 实际存储的数据行(即表中的行或索引条目)。 |
Special Space | 特殊空间,索引访问方法专用(如B-Tree索引存储额外元数据),普通表为空。 |
1. PageHeaderData(页头)
• 作用:存储页的全局管理信息。
• 核心字段:
typedef struct PageHeaderData {PageXLogRecPtr pd_lsn; // 最后一次修改的WAL日志位置(用于崩溃恢复)uint16 pd_checksum; // 页的校验和(可选,用于数据完整性验证)uint16 pd_flags; // 标志位(如是否为空、是否需要清理)LocationIndex pd_lower; // 空闲空间起始位置(ItemIdData数组的结束位置)LocationIndex pd_upper; // 空闲空间结束位置(Items的起始位置)LocationIndex pd_special; // 特殊空间的起始位置uint16 pd_pagesize_version; // 页大小和版本号TransactionId pd_prune_xid; // 最旧的可修剪事务ID(用于MVCC垃圾回收)ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; // 项标识符数组(ItemIdData)
} PageHeaderData;
- 关键意义:
通过pd_lower
和pd_upper
管理空闲空间,确保高效分配和回收。
pd_prune_xid
支持 MVCC(多版本并发控制)的过期数据清理。
2. ItemIdData(项标识符)
- 作用:定位页内数据行的物理位置。
- 结构:
每个ItemIdData
是一个(offset, length)
对,占4字节:
• offset:数据行在页内的偏移量(从页头开始的字节数)。
• length:数据行的长度(包括行头和数据内容)。 - 示例:
若某行的ItemIdData
为(100, 64)
,表示该行从页的第100字节开始,占64字节。
3. Free Space(空闲空间)
- 动态管理:
• 插入数据:新数据行从空闲空间的尾部(pd_upper
)开始分配。
• 分配项标识符:新ItemIdData
从空闲空间的头部(pd_lower
)分配。 - 空间回收:
• 删除数据行时,对应的ItemIdData
被标记为“死亡”,空间可被后续插入重用。
• 通过VACUUM
命令可回收碎片化的空闲空间。
4. Items(数据行)
- 存储内容:
• 普通表:存储表的数据行(Heap Tuple),包含行头(HeapTupleHeader)和用户数据。
• 索引:存储索引条目(如B-Tree的键值和指向堆表行的CTID)。 - 行头结构(HeapTupleHeader):
typedef struct HeapTupleHeaderData {uint32 t_xmin; // 插入事务IDuint32 t_xmax; // 删除/更新事务IDCommandId t_cid; // 命令ID(同一事务内的操作顺序)ItemPointerData t_ctid; // 当前行版本的CTID(用于行更新)uint16 t_infomask; // 标志位(如行是否可见、是否有NULL字段)uint8 t_hoff; // 行头长度bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; // NULL字段位图 } HeapTupleHeaderData;
- MVCC支持:
通过t_xmin
和t_xmax
实现多版本控制,旧版本数据对活跃事务可见,直到被VACUUM
清理。
5. Special Space(特殊空间)
- 索引专用:
• B-Tree索引:存储索引页的元数据,如右侧子页的指针、页层级等。
• GiST/GIN索引:存储与索引方法相关的扩展信息。 - 普通表:此区域为空。
页结构对性能的影响
-
插入性能:
• 频繁插入导致页内空闲空间减少,可能触发新页分配(非聚簇索引无页分裂)。
• 使用fillfactor
预留空间减少更新时的页分裂:CREATE TABLE mytable (id INT) WITH (fillfactor = 80); -- 预留20%空间
-
查询性能:
• 通过索引找到CTID后,需根据页号和偏移量读取堆表页(可能触发随机I/O)。
• 使用覆盖索引或CLUSTER
命令优化顺序访问。 -
空间效率:
• 小行(如INT类型)可能导致页内空间浪费,大行(如TEXT)触发TOAST机制分页存储。
PostgreSQL页物理结构的总结
• 页是PostgreSQL物理存储的核心,通过固定结构管理数据行、空闲空间和事务版本。
• 设计建议:
• 合理使用 fillfactor
平衡插入性能和存储效率。
• 定期执行 VACUUM
和 CLUSTER
维护页的空间布局。
• 扩展阅读:
TOAST Storage(大字段分页存储机制)。
页的生命周期(以 PostgreSQL 为例)
在 PostgreSQL 中,页(Page) 是数据存储的核心物理单元,其生命周期从磁盘分配开始,经历数据写入
、空间管理
、回收重用
,最终可能被释放或覆盖
。以下是页的完整生命周期解析:
1. 初始化:页的创建
触发条件:
- 表/索引扩展:当现有页无法容纳新数据时,PostgreSQL 向文件系统申请新的页。
- 批量导入:通过
COPY
或INSERT ... SELECT
快速插入大量数据,触发表文件扩展。
初始化操作:
- 格式化为空页:页头(
PageHeaderData
)初始化,pd_lower
指向项标识符数组起点,pd_upper
指向空闲空间尾部,pd_special
根据页类型(数据页/索引页)设置。 - 加入空闲空间映射(FSM):记录该页的空闲空间信息,供后续插入使用。
页的变化:
页头(PageHeaderData):
pd_lower
:初始指向项标识符数组
(ItemIdData)的起始位置,此时数组为空,因此pd_lower
值为SizeOfPageHeaderData
(页头固定大小,通常 24 字节)。pd_upper
:初始指向空闲空间的尾部
,即页末尾减去pd_special
的空间(例如,索引页可能有特殊用途区域)。对于普通堆表页,pd_special
为 0,因此pd_upper
初始值为BLCKSZ - 1
(如 8KB 页为 8192 字节)。pd_flags
:标记页类型(如普通数据页、索引页等)及状态(如是否含 HOT 更新)。
项标识符数组(ItemIdData):
- 初始为空,未分配任何项标识符。
数据区域:
- 完全空闲,无数据行(tuple)。
2. 使用阶段:数据写入与更新
数据插入:
- 分配项标识符:从空闲空间头部(
pd_lower
)分配ItemIdData
,指向空闲空间尾部(pd_upper
)的新数据行。 - 更新页头:
pd_lower
和pd_upper
动态调整,反映页内空间占用情况。
数据更新:
- MVCC 机制:旧数据行保留在页中,新版本写入其他页(或同一页的空闲空间),原行标记为“死亡”(dead tuple)。
- HOT(Heap-Only Tuple)优化:若新版本与旧版本在同一页且索引键未修改,直接复用项标识符,避免索引更新。
数据删除:
- 标记死亡行:行头
t_xmax
设置为删除事务ID,空间暂时不可用,需等待VACUUM
回收。
页的变化
插入数据:
- 项标识符分配:从
pd_lower
处分配一个项标识符(每个占 4 字节),记录数据行的偏移量和长度。每插入一行,pd_lower 向页尾方向移动
(如从 24字节 → 28字节)。 - 数据行写入:实际数据行
从页尾的空闲空间起始位置
(pd_upper)开始写入。每插入一行,pd_upper 向页头方向移动
(如从 8192字节 → 8092字节)。(如从 8192 字节 → 8192 - 100 字节 = 8092 字节)。 - 空闲空间:
pd_upper - pd_lower
逐渐减小,表示可用空间减少。例如 8092 - 28 = 8064 字节。
更新数据(MVCC):
- 旧数据行标记:原数据行的行头
t_xmax
设置为删除事务 ID,标记为死亡。 - 新数据行写入:新版本行写入当前页或其他页的空闲空间,占用新的项标识符和数据区域,
pd_lower
和pd_upper
继续调整。
删除数据:
- 行头标记:与更新类似,
t_xmax
标记为删除事务 ID,空间暂不回收。
3. 维护阶段:空间回收与优化
VACUUM 操作
- 普通 VACUUM:逻辑清理(不移动数据行)
- 清理死亡行:
仅将死亡行的项标识符标记为“未使用”,并更新 pd_lower(移除无效项标识符)。
不移动存活数据行,因此 pd_upper 不会改变。 - 空闲空间合并:
存活数据行仍分散在页中,空闲空间可能碎片化
。
后续插入会优先使用空闲区域,但无法保证连续性。
清理死亡行:移除
已提交事务不再需要的死亡行,回收
空间到空闲区域。
更新 FSM:向空闲空间映射报告当前页的空闲容量。
- VACUUM FULL / CLUSTER:物理重组(移动数据行)
- 移动存活数据行:
所有存活数据行被 从页尾向页头方向紧凑排列
,消除碎片
。
存活数据行的新位置 从页头开始连续存储(与插入方向相反
)。 - 重置指针:
pd_upper
被重置为 存活数据行的末尾位置
(即空闲空间起始位置)。
pd_lower
被重置为项标识符数组的末尾位置
。 - 结果:
空闲空间从 pd_upper 到页尾变为 连续大块
,后续插入可高效利用
页内空间合并:
- 若空闲空间碎片化,
VACUUM FULL
或CLUSTER
命令会将数据行紧凑排列
,重置pd_lower
和pd_upper
。
索引页分裂(仅索引页):
- B-Tree 索引页分裂:当索引页无法容纳新键值时,分裂为两个页,旧页保留部分数据,新页分配并插入剩余数据。
页的变化
VACUUM 操作:
- 清理死亡行:扫描页内所有项标识符,移除指向死亡行的项,回收其占用的空间。
- 合并空闲空间:
◦ 存活数据行向页头部紧凑排列,pd_upper
重置为存活数据行末尾位置。
◦pd_lower
重置为项标识符数组末尾位置(如从 28 字节 → 24 + 4*N 字节,N 为存活项数量)。 - 更新 FSM:将当前页的空闲空间大小(
pd_upper - pd_lower
)记录到空闲空间映射。
VACUUM FULL / CLUSTER:
- 页内重组:将数据行完全重新排列,消除碎片,
pd_lower
和pd_upper
恢复为类似初始化的紧凑状态。
索引页分裂:
- 旧页调整:分裂后,旧页保留部分键值,
pd_lower
和pd_upper
更新以反映剩余数据。 - 新页初始化:新分配的页经历初始化阶段,结构与旧页分裂前类似。
矛盾点解析:为什么重组后数据行“向页头紧凑”?
• 插入方向:数据行从页尾写入,是 为了避免移动已有数据,提高插入效率。
• 重组方向:数据行从页头紧凑,是 为了消除碎片,保证空闲空间连续。
看似矛盾,实则互补
• 插入阶段:牺牲空间碎片化换取插入速度。
• 维护阶段:牺牲重组时间成本换取空间连续性。
示例:页结构变化全流程
假设页大小为 8KB(8192字节),初始状态:
• pd_lower = 24
(页头固定大小)
• pd_upper = 8192
(页尾)
步骤1:插入3行(每行100字节)
• 分配项标识符:pd_lower = 24 + 3*4 = 36
• 写入数据行:pd_upper = 8192 - 3*100 = 7892
• 空闲空间:7892 - 36 = 7856 字节
(但分布碎片化)。
步骤2:删除1行(假设第二行死亡)
• 项标识符标记为无效,但数据行仍占用空间。
• pd_lower
和 pd_upper
不变。
步骤3:执行 VACUUM FULL
• 移动存活数据行(第1、3行)到页头:
• 第1行写入位置 24 + 2*4 = 32
(项标识符结束位置)。
• 第3行写入位置 32 + 100 = 132
。
• 重置指针:
• pd_lower = 32
(2个有效项标识符)。
• pd_upper = 132 + 100 = 232
(存活数据行结束位置)。
• 空闲空间:8192 - 232 = 7960 字节
(连续大块)。
总结
• 插入方向:从页尾向页头写入,避免移动数据,提高插入效率。
• 重组方向:从页头向页尾紧凑,消除碎片,优化空间连续性。
• 优化平衡:
• 频繁插入/更新的表建议设置 fillfactor < 100
预留空间。
• 定期执行 VACUUM
或自动化 autovacuum
清理碎片。
• 对性能敏感场景谨慎使用 VACUUM FULL
(锁表且耗时)。
重新整理后的页在插入新数据时的详细过程
在 PostgreSQL 中,当页经过 VACUUM FULL 或 CLUSTER 维护操作后,其空闲空间被整理为连续的大块,此时插入新数据的过程会更加高效。以下是重新整理后的页在插入新数据时的详细过程:
1. 维护后的页状态
假设页经过重组后,结构如下:
• pd_lower
:指向项标识符数组(ItemIdData)的末尾(例如 32 字节
,表示已分配 2 个项标识符)。
• pd_upper
:指向存活数据行的末尾(例如 232 字节
)。
• 空闲空间:从 232 字节
到页尾(8192 字节
),连续可用空间为 8192 - 232 = 7960 字节
。
2. 插入新数据的具体流程
(1) 分配项标识符(ItemIdData)
• 位置:从项标识符数组的末尾(pd_lower
)开始分配新的项标识符。
• 操作:
• 新项标识符占 4 字节,插入到 pd_lower
位置。
• pd_lower
向页尾方向移动(如从 32 字节 → 36 字节
)。
(2) 写入数据行
- 位置:从空闲空间的起始位置(
pd_upper
,即232 字节
)开始写入新数据行。 - 操作:
假设新行大小为 100 字节,数据写入后占据232 字节
到332 字节
。
pd_upper
向页头方向移动(从232 字节 → 332 字节
)。
(3) 更新页头元数据
- 空闲空间:
- 空闲空间从
7960 字节
减少为7960 - 100 = 7860 字节
。 - 新的空闲空间范围:
332 字节
到8192 字节
(仍然连续)。
(4) 最终页状态
• pd_lower = 36 字节
(3 个项标识符)。
• pd_upper = 332 字节
(存活数据行末尾)。
• 空闲空间:8192 - 332 = 7860 字节
。
3. 重新整理后的插入优势
与未整理的页相比,重组后的页插入新数据具有以下优势:
- 连续空间利用:
- 空闲空间是连续的,新数据行可以直接从
pd_upper
开始写入,无需跳过碎片区域。
- 空闲空间是连续的,新数据行可以直接从
- 避免页分裂(针对索引页):
- 对于索引页(如 B-Tree),连续空间减少了因中间插入键值导致的页分裂概率。
- 高效 HOT 更新:
- 如果新更新的行与原行在同一页且索引键未修改,可直接复用项标识符,避免索引页更新。
4. 示例场景
场景1:插入小行(100 字节)
• 步骤:
- 分配项标识符:
pd_lower=32 → 36
。 - 写入数据行:
pd_upper=232 → 332
。 - 空闲空间减少,但仍保持连续。
• 结果:插入高效,无碎片。
场景2:插入大行(2000 字节)
• 步骤:
- 检查空闲空间是否足够(
7860 字节 > 2000 字节
)。 - 分配项标识符:
pd_lower=36 → 40
。 - 写入数据行:
pd_upper=332 → 2332
。
• 结果:成功插入,空闲空间仍连续(8192 - 2332 = 5860 字节
)。
5. 维护后的潜在问题与优化
(1) 问题:预留空间耗尽
• 现象:若插入大量数据导致 pd_upper
接近 pd_lower
,页将再次填满。
• 优化:
• 设置 fillfactor
预留空间(如 fillfactor=80
),仅填充页的 80%,剩余 20% 用于更新和插入。
sql CREATE TABLE mytable (id INT) WITH (fillfactor = 80);
(2) 问题:随机插入导致碎片再生
• 现象:若主键非自增,新数据可能插入到页中间(如索引页),破坏连续性。
• 优化:
• 对频繁更新的表使用 自增主键 或 时序字段,使插入集中在页尾部。
• 定期执行 CLUSTER
按主键顺序重组数据。
6. 与未整理页的对比
场景 | 未整理的页 | 整理后的页 |
---|---|---|
空闲空间 | 碎片化(多个不连续区域) | 连续大块 |
插入速度 | 可能需遍历碎片区域 | 直接分配尾部空间,速度更快 |
更新优化(HOT) | 空间碎片可能导致跨页更新 | 紧凑空间更易触发 HOT 更新 |
索引页分裂概率 | 较高(随机插入导致中间分裂) | 较低(顺序插入占优) |
总结
- 重新整理后的插入过程:
项标识符从页头分配,数据行从页尾写入,空闲空间连续,插入高效。 - 优化建议:
• 对高频写入表设置fillfactor
预留空间。
• 优先使用顺序插入(如自增主键)减少碎片。
• 定期维护(VACUUM
、CLUSTER
)保持页结构紧凑。 - 维护的价值:
将碎片化空间整理为连续块,显著提升插入效率,降低页分裂和随机 I/O 的开销。
4. 重用阶段:空闲页的再分配
同一对象重用:
- 若页被完全清空(如所有行被删除),该页被标记为“空闲”,优先用于同一表或索引的后续插入。
跨对象重用:
- 空闲页可被其他表或索引通过全局空闲空间池(Free Space Map)分配使用,减少磁盘空间申请开销。
页的变化
同一对象重用:
- 插入新数据时,优先使用已回收的空闲页。
pd_lower
和pd_upper
根据新数据插入动态调整,类似于使用阶段。
跨对象重用:
- 页被重新分配给其他表或索引时,需重新初始化(格式化页头、重置
pd_lower
和pd_upper
),并更新 FSM。
5. 释放阶段:页的终结
文件截断(Truncate):
- 执行
TRUNCATE TABLE
时,表文件末尾的空闲页可能被释放回操作系统(需满足wal_level=minimal
且无并发事务)。
物理删除:
- 表或索引被
DROP
时,所有关联页最终由文件系统回收。
页的变化
文件截断(TRUNCATE):
- 表末尾的空闲页被释放,文件大小缩减,操作系统回收物理空间。这些页的元数据(如页头)被清除。
物理删除(DROP):
- 页所属文件被删除,所有关联页的磁盘空间由文件系统回收。
生命周期中的关键影响
- 性能瓶颈:
- 频繁更新/删除:导致死亡行堆积,需频繁
VACUUM
避免空间膨胀。 - 随机插入:非自增主键插入可能导致页分裂(索引页)或空间碎片化(堆表页)。
- 频繁更新/删除:导致死亡行堆积,需频繁
- 优化策略:
- 定期维护:通过
VACUUM
和ANALYZE
保持页内空间高效。 - 合理设计表结构:使用
fillfactor
预留更新空间,避免小字段浪费页容量。 - 使用 HOT 更新:减少索引页修改频率。
- 定期维护:通过
示例:页生命周期全流程
- 新建表
users
:
• 初始为 0 页,插入第一条数据时分配第 1 页(初始化)。 - 持续插入数据:
• 页 1 填满后分配页 2,依此类推。 - 更新
users
表:
• 页 1 中某行被更新,旧数据标记为死亡,新数据写入页 2(HOT 更新可能复用页 1)。 - 执行
VACUUM
:
• 清理页 1 的死亡行,空闲空间可用于新插入。 - 删除所有数据:
• 页 1 和页 2 被标记为空闲,后续插入可能重用这些页。 - 删除表
users
:
• 所有关联页被标记为可释放,最终由文件系统回收。
总结
PostgreSQL 的页生命周期围绕 空间分配 → 数据操作 → 维护回收 → 释放 展开,核心目标是高效管理磁盘与内存资源。理解这一过程有助于优化数据存储设计、制定维护策略(如自动化 VACUUM
),并诊断空间膨胀或写入性能问题。
pd_lower不是应该挨着存活数据行的头部吗,32字节变36字节,这个时候不应该是存活数据行占用着33到232字节吗
提到的困惑源于对 项标识符(ItemIdData) 和 数据行(Items) 存储位置的混淆。让我们通过一个具体示例和图示彻底理清这一机制:
1. 页结构的核心规则
在 PostgreSQL 中,页的存储遵循以下规则:
- 项标识符数组(ItemIdData)
• 从页头(PageHeaderData
结束位置)开始分配,向页尾方向增长。
• 每个项标识符占 4 字节,记录数据行的 偏移量(offset) 和 长度(length)。 - 数据行(Items)
• 从页尾开始分配,向页头方向增长。
• 数据行的实际存储位置由项标识符中的offset
决定。
2. 维护后的页结构(重组后)
假设页经过 VACUUM FULL
重组后,状态如下:
• 页头(PageHeaderData):24 字节。
• 项标识符数组(ItemIdData):2 个有效项,占 8 字节(24 → 32 字节
)。
• 数据行(Items):
• 第 1 行:偏移量 200
,长度 100
(占用 200 → 300 字节
)。
• 第 2 行:偏移量 300
,长度 100
(占用 300 → 400 字节
)。
• 指针位置:
• pd_lower = 32
(项标识符数组末尾)。
• pd_upper = 400
(数据行末尾)。
• 空闲空间:400 → 8192 字节
(连续大块)。
3. 插入新数据的过程
(1) 分配项标识符
• 位置:从 pd_lower = 32
分配新项标识符(4 字节)。
• 结果:
• 项标识符数组扩展为 24 → 36 字节
。
• pd_lower
更新为 36
。
(2) 写入数据行
• 位置:从 pd_upper = 400
开始写入新数据行(假设长度 100 字节)。
• 结果:
• 数据行占用 400 → 500 字节
。
• pd_upper
更新为 500
。
(3) 更新项标识符
• 新项标识符内容:(offset=400, length=100)
。
• 作用:通过该标识符可定位新数据行的位置。
(4) 最终页结构
- 项标识符数组:3 个项,占
24 → 36 字节
。 - 数据行:
• 第 1 行:200 → 300
• 第 2 行:300 → 400
• 第 3 行:400 → 500
- 指针位置:
•pd_lower = 36
•pd_upper = 500
- 空闲空间:
500 → 8192 字节
(仍连续)。
4. 关键澄清:为什么数据行不紧挨项标识符数组?
-
项标识符是索引,而非数据行存储位置:
项标识符数组仅存储指向数据行的元数据(offset 和 length),实际数据行始终从页尾向页头写入。即使经过VACUUM FULL
重组,数据行仍按偏移量分布在页中,而非紧邻项标识符数组。 -
示例中的矛盾点解析:
你提到的「存活数据行占据 33 到 232 字节」是误解。实际数据行的位置由项标识符中的offset
决定,可能分布在页的任意位置(如示例中的200 → 500 字节
),与项标识符数组(24 → 36 字节
)无直接相邻关系。
5. 为何重组后数据行看似“向页头紧凑”?
- 重组的目标是消除碎片,而非改变写入方向:
VACUUM FULL
会将所有存活数据行的 偏移量(offset) 调整为从页尾开始连续排列,但写入方向仍保持 从页尾向页头。重组后,空闲空间集中在页尾,新插入的数据依然从pd_upper
(重组后的数据行末尾)开始写入。
重组前后的对比
操作 | 数据行分布 | 空闲空间 |
---|---|---|
重组前(碎片化) | 分散在 200-300, 500-600 | 300-500, 600-8192 |
重组后(紧凑) | 连续在 200-400 | 400-8192(连续大块) |
6. 总结
- 项标识符和数据行是分离的:
项标识符数组在页头附近增长,数据行在页尾附近增长,两者通过offset
关联,物理位置无直接相邻关系。 - 重组不改变写入方向:
重组仅合并空闲空间,数据行仍从页尾写入,但偏移量连续排列以消除碎片。 - 性能优化本质:
通过整理碎片,确保后续插入能利用连续大块空间,减少随机 I/O 和页分裂。
理解这一机制后,可以更精准地设计表结构(如选择自增主键)和制定维护策略(如定期 VACUUM
),从而提升数据库性能。
页的作用与意义
(1) 高效磁盘I/O
按页加载
:数据库读取数据时,即使只需一行数据,也会将整个页加载到内存。
示例:
若页大小为16 KB,查询一行数据(假设100字节)时,实际读取16 KB的数据到内存。后续
访问同一页内的其他数据时,无需磁盘I/O
。
(2) 数据组织与管理
- 数据行存储:页内的数据行按
主键顺序(聚簇索引)
或插入顺序(堆表)排列
。 - 空间复用:删除数据时,页内空间被标记为可重用,避免频繁申请新页。
(3) 索引实现的基础
- B+树节点即页:
索引的每个节点(根节点、分支节点、叶子节点)对应一个页
。 - 示例:
InnoDB的B+树索引中:
• 叶子节点页:存储实际数据行(聚簇索引)或主键值+指针(二级索引)。
• 非叶子节点页:存储索引键值范围和指向子页的指针。
4. 页与性能优化
(1) 页分裂(Page Split)
- 触发条件:当向已满的页插入新数据时(尤其在聚簇索引中),数据库会将页分裂为两个新页。
- 性能影响:
• 增加I/O操作和锁竞争,降低写入性能。
• 导致页填充率下降(例如从100%降至约50%),产生存储碎片。 - 优化方法:
• 使用自增主键(顺序插入)减少页分裂。
• 定期执行OPTIMIZE TABLE
(MySQL)或VACUUM FULL
(PostgreSQL)重组页。
(2) 页合并(Page Merge)
- 触发条件:当相邻页的空间利用率较低时,数据库可能合并这些页以释放空闲空间。
- 优化建议:
• 避免频繁的删除操作,或定期维护表空间。
(3) 内存缓存(Buffer Pool)
- 缓存页:数据库将热点数据页缓存在内存的Buffer Pool中,减少磁盘I/O。
- 优化参数:
• MySQL的innodb_buffer_pool_size
:建议设置为物理内存的70%~80%。
• PostgreSQL的shared_buffers
:建议设置为物理内存的25%~40%。
5. 不同数据库的页实现差异
(1) MySQL InnoDB
- 页类型多样化:
• 数据页(INDEX)、Undo页(UNDO_LOG)、系统页(SYS)等。 - 支持压缩页:通过
KEY_BLOCK_SIZE
压缩表数据,减少存储占用(例如压缩为8 KB或4 KB)。
(2) PostgreSQL
- 堆表(Heap Table):数据页不强制按主键顺序存储,依赖额外的索引(如B-Tree)加速查询。
- TOAST机制:针对大字段(如TEXT、JSONB),自动将数据拆分到多个TOAST页中存储。
6. 页的直观比喻
将数据库表比作一本书:
• 页(Page) = 书的一页纸。
• 行(Row) = 页中的一行文字。
• 索引(Index) = 书的目录,通过目录(索引)快速定位到某一页(页指针)。
总结
• 页是数据库存储的原子单位,决定了数据物理存储和内存管理的效率。
• 理解页的结构和机制,有助于优化表设计(如主键选择、字段类型)、诊断性能问题(如页分裂、I/O瓶颈)和配置数据库参数。
页分裂(Page Split)
定义与原理
在数据库系统中,页分裂
(Page Split) 是一种物理存储重组机制
,主要发生在 索引结构(如B+树) 中。当向已满的页插入新数据时,数据库会将其分裂为两个新页,以容纳更多数据
页分裂的触发条件
- 索引页已满:当插入新键值(Key)时,目标页的剩余空间不足以容纳新数据。
- 非顺序插入:如果插入的键值不在当前页的最大/最小值范围内(如随机主键插入),可能导致中间位置插入,触发分裂。
发生场景
• 插入新数据:当数据必须插入到已满的页中(如非自增主键的随机插入)。
• 更新数据:数据更新后长度增加,导致页空间不足。
具体过程(以B+树索引为例)
假设有一个B+树索引页(页A),已存储键值 [10, 20, 30, 40],页容量为4个键值。当插入新键值 25 时触发分裂:
- 触发条件:
向页A插入新数据,但页A剩余空间不足。 - 确定分裂点
数据库根据算法确定分裂点(如中间位置)。
示例:将键值 30 作为分裂点,左侧保留 [10, 20],右侧包含 [25, 30, 40]。 - 分裂操作:
• 创建新页B,将分裂点右侧的键值(25, 30, 40)迁移到页B。。
• 原页A保留左侧键值(10,20) ,将页A的部分数据迁移到页B,并重新分配数据,使新旧页均满足填充因子(Fill Factor)要求。
• 更新索引结构,维护页之间的双向链表指针。 - 更新父页指针
将分裂点键值(30)提升到父页,建立页A和页B的父子关系
示例:父业新增条目30,指向页B - 插入新数据
新建值25插入页B的合适位置(最终页B包含25,30,40)
示例对比
• 自增主键:新数据按顺序插入页末尾,减少分裂概率。
CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键name VARCHAR(50)
);
• 随机主键(如UUID):新数据随机插入,频繁触发页中间分裂。
CREATE TABLE users (id CHAR(36) PRIMARY KEY, -- UUID主键name VARCHAR(50)
);
页分裂的性能影响:
-
写入延迟:分裂涉及
磁盘I/O
(分配新页、迁移数据、更新父页)和锁竞争
,导致插入性能下降。 -
空间碎片:分裂后,新页的填充率通常为50%~70%(例如原页100%满,分裂后各页50%),浪费存储空间。
-
索引树高度增加
:若父页也因分裂而满,可能递归触发分裂,导致树层级加深,查询路径变长。
不同数据库的页分裂差异
- MySQL InnoDB(聚簇索引
-
聚簇索引:数据按主键顺序存储在叶子节点页中。
• 顺序插入(如自增主键):新数据追加到页尾部,分裂概率低。
• 随机插入(如UUID主键):频繁中间插入,触发分裂。
• 优化建议:CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键减少分裂name VARCHAR(50) );
- PostgreSQL(堆表+索引)
-
堆表数据无序
:数据页本身不按主键顺序存储,插入新行时不会触发数据页分裂。 -
索引页分裂:B-Tree索引的页分裂逻辑与MySQL类似,但索引条目指向堆表行的CTID(物理地址)。
-
优化建议:
CREATE INDEX idx_name ON users(name) WITH (fillfactor=90); -- 预留10%空间
页分裂的监控与诊断
(1) 监控指标
• MySQL:
SHOW ENGINE INNODB STATUS\G -- 查看 "INSERT BUFFER AND ADAPTIVE HASH INDEX" 中的分裂计数
• PostgreSQL:
SELECT * FROM pg_stat_user_indexes; -- 观察索引扫描和更新频率
(2) 日志分析
• 启用数据库的页分裂日志(如MySQL的InnoDB监控日志),定位高频分裂的索引。
如何避免或减少页分裂
-
合理设计主键
• 使用自增主键:确保数据顺序插入,减少中间分裂。
• 避免随机主键:如UUID或哈希值,改用有序值(如雪花算法ID)。 -
调整填充因子(Fill Factor)
• 预留空间:设置索引页的填充因子(如90%),预留空间给未来更新。
-- PostgreSQL
CREATE INDEX idx_orders ON orders(order_date) WITH (fillfactor=80);-- MySQL(通过重建表生效)
ALTER TABLE orders KEY_BLOCK_SIZE=8;
- 定期维护
• 重建索引:消除碎片,恢复高填充率。
-- PostgreSQL
REINDEX INDEX idx_orders;-- MySQL
OPTIMIZE TABLE orders;
- 分区与分表
• 水平分片:将数据分散到多个物理表中,减少单个索引的写入压力。
页分裂 vs. 页合并
特性 | 页分裂(Page Split) | 页合并(Page Merge) |
---|---|---|
触发条件 | 页空间不足时插入新数据 | 相邻页空间利用率低时删除数据 |
操作代价 | 高(需分配新页、迁移数据、更新父页) | 中(合并相邻页,更新指针) |
优化目标 | 解决空间不足问题 | 减少空间碎片,提高存储利用率 |
频率 | 高频写入场景下常见 | 低频(依赖删除操作和后台维护) |
页分裂总结
-
优化核心:通过顺序插入、预留空间、定期维护等手段减少分裂频率。
-
选择策略:
• OLTP场景(高并发写入):优先使用自增主键和填充因子。
• OLAP场景(批量加载):关注索引重建和存储参数调优。
MySQL vs PostgreSQL的差异
特性 | MySQL (InnoDB) | PostgreSQL |
---|---|---|
回表机制 | 二级索引存储主键值,回表查聚簇索引。 | 所有索引存储CTID(物理地址),直接定位堆表数据。 |
页分裂场景 | 仅聚簇索引会触发页分裂。 | 堆表数据无序,插入新数据不会导致页分裂,但索引更新可能触发索引页分裂。 |
优化手段 | 使用自增主键减少分裂。 | 定期执行 CLUSTER 命令重组表数据,或调整填充因子(fillfactor)。 |
如何避免回表和页分裂?
-
回表优化:
• 使用覆盖索引(Covering Index)。
• 避免SELECT *
,只查询必要字段。 -
页分裂优化:
• MySQL:使用自增主键,避免随机插入。
• PostgreSQL:定期执行CLUSTER
命令或使用fillfactor
预留页空间。CREATE INDEX idx_name ON users(name) WITH (fillfactor=90); -- 预留10%空间
总结
• 回表是二级索引查询的额外步骤,可通过覆盖索引优化。
• 页分裂是聚簇索引写入的物理重组操作,合理设计主键可显著降低其频率。
• MySQL和PostgreSQL在实现细节上有本质差异,需根据数据库类型选择优化策略。