PostgreSQL 存储机制讲解
要点: WAL、Buffer、Dirty Page,到 Page Layout、Slot、Tuple。
author:何绍泽
1. 整体存储模型
数据存储可以分为几个核心层:
SQL
|
Executor
|
Heap Storage
|
Buffer Manager
|
WAL
|
Data File
核心思想:
- 数据最终保存在磁盘的数据文件中
- 修改通常先发生在内存 Buffer
- 修改记录先写 WAL
- 后台进程再把数据页刷回磁盘
关键代码 - BufferTag(标识磁盘块):
c
// 文件: src/include/storage/buf_internals.h
typedef struct buftag
{
Oid spcOid; /* tablespace oid */
Oid dbOid; /* database oid */
RelFileNumber relNumber; /* relation file number */
ForkNumber forkNum; /* fork number */
BlockNumber blockNum; /* blknum relative to begin of reln */
} BufferTag;
2. WAL(Write Ahead Log)
2.1 WAL 是什么?
WAL 是预写日志。
它不是存储最终数据,而是记录:如何把旧数据修改成新数据。
例如:
原数据:
id=1 name=Tom
修改:
id=1 name=Bob
WAL 记录类似:
Page 100
Tuple offset 5
Tom -> Bob
关键代码 - XLogRecord(WAL记录核心结构):
c
// 文件: src/include/access/xlogrecord.h
typedef struct XLogRecord
{
uint32 xl_tot_len; /* total len of entire record */
TransactionId xl_xid; /* xact id */
XLogRecPtr xl_prev; /* ptr to previous record in log */
uint8 xl_info; /* flag bits, see below */
RmgrId xl_rmid; /* resource manager for this record */
pg_crc32c xl_crc; /* CRC for this record */
} XLogRecord;
关键代码 - XLogRecData(WAL记录数据链):
c
// 文件: src/include/access/xlog_internal.h
typedef struct XLogRecData
{
struct XLogRecData *next; /* next struct in chain, or NULL */
char *data; /* start of rmgr data to include */
uint32 len; /* length of rmgr data to include */
} XLogRecData;
2.2 为什么需要 WAL?
因为磁盘数据写入很慢。
如果每次修改:
UPDATE
|
写数据文件
|
fsync
性能会很差。
所以 PostgreSQL:
修改内存 Page
|
v
生成 WAL
|
v
WAL 先刷盘
|
v
事务提交成功
3. 数据定位机制:SQL如何找到具体Page(Block)
在理解 Buffer、Page、Tuple 之前,需要先解决一个核心问题:
数据库如何知道我要访问的数据在哪一个Page?
完整链路:
SQL
|
Executor
|
定位Block(Page)
|
Buffer Manager
|
Shared Buffer
|
Page
|
Slot
|
Tuple
数据库不会直接扫描磁盘寻找某一行,而是通过不同机制定位数据。
关键代码 - ItemPointerData(TID的底层结构):
c
// 文件: src/include/storage/itemptr.h
typedef struct ItemPointerData
{
BlockIdData ip_blkid; /* block id */
OffsetNumber ip_posid; /* entry in linp array */
}
pg_attribute_packed()
pg_attribute_aligned(2)
ItemPointerData;
TID 共 6 字节(3 个 int16),被 packed 以避免对齐浪费。
3.1 UPDATE如何找到Page?
例如:
sql
UPDATE users
SET name='Bob'
WHERE id=100;
因为数据已经存在,所以UPDATE需要先找到原Tuple。
注:这里只介绍索引流程,全表扫描就是顺着读,原理都差不多。
根据已有条件,去索引里查对应的page并获取块号。
通常流程(走索引流程):
SQL
↓
Index Scan
↓
Index
↓
TID
↓
(block number, offset number)
↓
Buffer Manager
↓
加载Page
↓
Slot找到Tuple
TID:
(block number, offset number)
TID(Tuple Identifier,元组标识符)就是一条数据记录(Tuple)在物理存储中的唯一地址.
例如:
(10,5)
表示:
第10个Page
第5个Slot
所以:
Index
|
v
TID
|
v
Page
|
v
Slot
|
v
Tuple
3.2 INSERT如何找到Page?
INSERT与UPDATE不同。
原因:
- INSERT的数据之前不存在
- 没有TID
- 需要寻找可以存放新Tuple的位置
因此INSERT需要一个新的存储空间,会从表文件里的FSM里找一个合适的空闲page,将新的数据插入。
关键代码 - FSMPageData(Free Space Map页面结构):
c
// 文件: src/include/storage/fsm_internals.h
typedef struct
{
int fp_next_slot; // 下一个要返回的槽位(round-robin分发负载)
uint8 fp_nodes[FLEXIBLE_ARRAY_MEMBER]; // 存储二叉树的数组
} FSMPageData;
typedef FSMPageData *FSMPage;
关键代码 - FSMAddress(FSM内部地址):
c
// 文件: src/backend/storage/freespace/freespace.c
typedef struct
{
int level; // 树的层级
int logpageno; // 层级内的页号
} FSMAddress;
关键函数 - 查找空闲空间页面:
c
// 文件: src/include/storage/freespace.h
BlockNumber GetPageWithFreeSpace(Relation rel, Size spaceNeeded);
流程:
INSERT
↓
查找FSM
↓
寻找空闲Page
↓
加载Page
↓
插入Tuple
FSM: Free Space Map(空闲空间映射表),记录每个数据 Page(Block)还有多少可用空间,帮助 INSERT 快速找到可以插入新 Tuple 的 Page。
情况1:现有Page存在空闲空间
例如:
Block0
+-------------+
| Tuple |
| free 100B |
+-------------+
Block1
+-------------+
| Tuple |
| free 800B |
+-------------+
插入:
Tuple大小 = 300B
通过:
FSM(Free Space Map)
找到:
Block1
然后:
Block1
↓
Buffer Manager
↓
Shared Buffer
↓
插入Tuple
情况2:所有Page都没有足够空间
例如:
Block0 free 50B
Block1 free 30B
但是:
新Tuple = 200B
FSM无法找到合适Page。
此时:PostgreSQL扩展Heap文件,创建新的Block(Page)。
原:
users
Block0
Block1
Block2
扩展:
users
Block0
Block1
Block2
Block3
新Block编号:
当前最大Block + 1
关键源代码 - RelationAddBlocks(扩展Relation添加新Page):
c
// 文件: src/backend/access/heap/hio.c
// 当所有现有Page都没有足够空间时,扩展Relation添加新Page
static Buffer
RelationAddBlocks(Relation relation, BulkInsertState bistate,
int num_pages, bool use_fsm, bool *did_unlock)
{
// 1. 确定扩展的Page数量(最多64个Page)
extend_by_pages = num_pages;
// 2. 获取victim buffers并零填充
for (uint32 i = 0; i < extend_by; i++)
{
buffers[i] = GetVictimBuffer(strategy, io_context);
buf_block = BufHdrGetBlock(GetBufferDescriptor(buffers[i] - 1));
MemSet((char *) buf_block, 0, BLCKSZ); // 零填充新buffer
}
// 3. 获取Relation扩展锁,防止并发扩展
LockRelationForExtension(bmr.rel, ExclusiveLock);
// 4. 通过Buffer Manager扩展Relation
first_block = ExtendBufferedRelBy(BMR_REL(relation), MAIN_FORKNUM,
bistate ? bistate->strategy : NULL,
EB_LOCK_FIRST,
extend_by_pages,
victim_buffers,
&extend_by_pages);
// 5. 初始化新Page
page = BufferGetPage(buffer);
if (!PageIsNew(page))
elog(ERROR, "page %u of relation \"%s\" should be empty but is not",
first_block, RelationGetRelationName(relation));
PageInit(page, BufferGetPageSize(buffer), 0); // 初始化页面头部
MarkBufferDirty(buffer); // 标记buffer为脏页
// 6. 释放扩展锁
UnlockRelationForExtension(bmr.rel, ExclusiveLock);
return buffer;
}
关键源代码 - ExtendBufferedRelShared(Buffer Manager层的实际扩展实现):
c
// 文件: src/backend/storage/buffer/bufmgr.c
static BlockNumber
ExtendBufferedRelShared(BufferManagerRelation bmr,
ForkNumber fork,
BufferAccessStrategy strategy,
uint32 flags,
uint32 extend_by,
BlockNumber extend_upto,
Buffer *buffers,
uint32 *extended_by)
{
// 1. 获取victim buffers并零填充(在持有扩展锁之前完成)
for (uint32 i = 0; i < extend_by; i++)
{
buffers[i] = GetVictimBuffer(strategy, io_context);
buf_block = BufHdrGetBlock(GetBufferDescriptor(buffers[i] - 1));
MemSet((char *) buf_block, 0, BLCKSZ);
}
// 2. 获取Relation扩展锁
LockRelationForExtension(bmr.rel, ExclusiveLock);
// 3. 获取当前Relation大小
first_block = smgrnblocks(bmr.smgr, fork);
// 4. 检查不超过最大Page数限制
if ((uint64) first_block + extend_by >= MaxBlockNumber)
ereport(ERROR, ...);
// 5. 将新buffers插入Buffer Table,处理并发冲突
for (int i = 0; i < extend_by; i++)
{
BufferTag tag;
InitBufferTag(&tag, &bmr.smgr->smgr_rlocator.locator, fork, first_block + i);
hash = BufTableHashCode(&tag);
partition_lock = BufMappingPartitionLock(hash);
LWLockAcquire(partition_lock, LW_EXCLUSIVE);
existing_id = BufTableInsert(&tag, hash, victim_buf_hdr->buf_id);
LWLockRelease(partition_lock);
}
// 6. 将零块写入磁盘(实际的磁盘扩展操作)
smgrzeroextend(bmr.smgr, fork, first_block, extend_by, false);
// 7. 释放扩展锁
UnlockRelationForExtension(bmr.rel, ExclusiveLock);
// 8. 设置BM_VALID,终止IO,唤醒等待者
for (int i = 0; i < extend_by; i++)
{
TerminateBufferIO(victim_buf_hdr, true, INSTR_TIME_GET_MILLISEC(io_start));
}
*extended_by = extend_by;
return first_block;
}
关键源代码 - smgrzeroextend(存储管理器层的批量零块扩展):
c
// 文件: src/backend/storage/smgr/smgr.c
void
smgrzeroextend(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum,
int nblocks, bool skipFsync)
{
// 调用磁盘后端(mdzeroextend)执行批量零块扩展
smgrsw[reln->smgr_which].smgr_zeroextend(reln, forknum, blocknum,
nblocks, skipFsync);
// 更新缓存的nblocks值
if (reln->smgr_cached_nblocks[forknum] == blocknum)
reln->smgr_cached_nblocks[forknum] = blocknum + nblocks;
else
reln->smgr_cached_nblocks[forknum] = InvalidBlockNumber;
}
关键源代码 - PageInit(初始化新Page的页面头部):
c
// 文件: src/backend/storage/page/bufpage.c
void
PageInit(Page page, Size pageSize, Size specialSize)
{
PageHeader p = (PageHeader) page;
specialSize = MAXALIGN(specialSize);
// 清零整个Page
MemSet(p, 0, pageSize);
// 初始化页面头部字段
p->pd_flags = 0;
p->pd_lower = SizeOfPageHeaderData; // 行指针区域起始位置
p->pd_upper = pageSize - specialSize; // 数据区域起始位置
p->pd_special = pageSize - specialSize; // 特殊空间起始位置
PageSetPageSizeAndVersion(page, pageSize, PG_PAGE_LAYOUT_VERSION);
}
调用链总结:
RelationAddBlocks()
|
v
ExtendBufferedRelBy()
|
v
ExtendBufferedRelShared()
|
v
smgrzeroextend() → mdzeroextend() // 写入磁盘
|
v
PageInit() // 初始化页面头部
|
v
MarkBufferDirty() // 标记为脏页
新Page创建流程
新Page需要初始化:
+----------------+
| Page Header |
+----------------+
| Slot数组 |
+----------------+
| Free Space |
+----------------+
| Tuple区域 |
+----------------+
然后:
创建Block
↓
申请Buffer
↓
初始化Page
↓
插入Tuple
↓
Dirty Page
↓
WAL
↓
刷回Data File
3.3 INSERT和UPDATE定位Page区别
操作 定位方式 核心结构
INSERT FSM寻找空闲Page Free Space Map
INSERT无空间 扩展Heap文件创建新Page Relation Extension
UPDATE Index找到TID BTree Index
SELECT Index Scan Index找到TID Index
SELECT Seq Scan 顺序扫描Page Heap Scan
**小结:**这个小结我们只需要知道,INSERT和UPDATE如何找到需要的块,下一节将会介绍:将找到的块加载到内存buffer里进行修改。
4. Buffer Manager 和 Shared Buffer
Shared Buffer(共享缓冲区)是 PostgreSQL 在内存中预先分配的一块缓存区域,用于存放从磁盘读取的数据 Page,以及暂存尚未写回磁盘的修改 Page
Buffer Manager(缓冲区管理器)是 PostgreSQL 内部负责管理 Shared Buffer 的组件,负责控制 Page 如何从磁盘加载到内存、如何淘汰、如何标记修改以及何时写回磁盘
3.1 数据不会直接修改磁盘
磁盘中的 Page:
Disk Page
+-------------+
| Tuple |
| Tuple |
+-------------+
读取后:
复制到内存:
Shared Buffer
+-------------+
| Tuple |
| Tuple |
+-------------+
注意:不是移动。
而是:
磁盘 Page
|
| copy
v
内存 Buffer Page
关键代码 - Buffer(Buffer标识符类型):
c
// 文件: src/include/storage/buf.h
typedef int Buffer;
#define InvalidBuffer 0
// 正值: shared buffer (1..NBuffers)
// 负值: local buffer (-1 .. -NLocBuffer)
关键代码 - BufferDesc(共享Buffer描述符):
c
// 文件: src/include/storage/buf_internals.h
typedef struct BufferDesc
{
BufferTag tag; /* ID of page contained in buffer */
int buf_id; /* buffer's index number (from 0) */
pg_atomic_uint32 state; /* flags, refcount and usagecount */
int wait_backend_pgprocno; /* backend of pin-count waiter */
int freeNext; /* link in freelist chain */
LWLock content_lock; /* to lock access to buffer contents */
} BufferDesc;
关键宏 - Buffer状态位:
c
// 文件: src/include/storage/buf_internals.h
#define BM_LOCKED (1U << 22)
#define BM_DIRTY (1U << 23) // 脏页标志
#define BM_VALID (1U << 24)
#define BM_TAG_VALID (1U << 25)
#define BM_IO_IN_PROGRESS (1U << 26)
5. Page 是什么?
存储单位不是一行,而是 Page。
默认:
8KB
表文件:
users table
Page0
Page1
Page2
...
每个 Page 内部:
+----------------+
| Page Header |
+----------------+
| Slot数组 |
+----------------+
| 空闲空间 |
+----------------+
| Tuple数据 |
+----------------+
关键代码 - PageHeaderData(页面头结构):
c
// 文件: src/include/storage/bufpage.h
typedef struct PageHeaderData
{
PageXLogRecPtr pd_lsn;
uint16 pd_checksum;
uint16 pd_flags;
LocationIndex pd_lower; // Slot数组的末尾位置
LocationIndex pd_upper; // Tuple区域的起始位置
LocationIndex pd_special;
uint16 pd_pagesize_version;
TransactionId pd_prune_xid;
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; // ← 这就是管理ItemIdData的数组
} PageHeaderData;
pd_linp是存slot的数组,它用FLEXIBLE_ARRAY_MEMBER(柔性数组成员)声明,意味着:
-
pd_linp紧跟在PageHeaderData头部字段之后
-
它是一个变长数组,实际长度由pd_lower决定
-
内存布局:PageHeader字段 linp\[0] linp\[1] linp\[2] ... 空闲空间 Tuple数据
数组长度的计算方式(bufpage.h:405):
static
PageGetMaxOffsetNumber(Page page)
{
// (pd_lower - 头部大小) / 每个ItemIdData大小 = 数组元素个数
return (pageheader->pd_lower - SizeOfPageHeaderData) / sizeof(ItemIdData);
}
-
图示:
Page内存布局:┌─────────────────────────────────────────────────────────┐
│ PageHeaderData │
│ pd_lsn, pd_checksum, pd_flags, pd_lower, pd_upper ... │
├─────────────────────────────────────────────────────────┤
│ pd_linp[0] (ItemIdData, 4字节) ──→ 指向Tuple1 │
│ pd_linp[1] (ItemIdData, 4字节) ──→ 指向Tuple2 │
│ pd_linp[2] (ItemIdData, 4字节) ──→ 指向Tuple3 │
│ ... │
│ pd_linp[N] (ItemIdData, 4字节) ──→ 指向TupleN │
├────────────────────────────── pd_lower ─────────────────┤
│ 空闲空间 │
├────────────────────────────── pd_upper ─────────────────┤
│ TupleN ... │
│ ... Tuple2 Tuple1 │
├────────────────────────────── pd_special ───────────────┤
│ 特殊空间 │
└─────────────────────────────────────────────────────────┘
Slot数量 = (pd_lower - 24) / 4
6. 内存 Page 与磁盘 Page 为什么不一致?
初始:
磁盘:
Tom
内存:
Tom
一致。
执行:
UPDATE name='Bob'
修改内存:
内存:
Bob
磁盘:
Tom
此时:
Memory Page != Disk Page
内存里这个 Page 就叫:
Dirty Page 脏页。
7. Dirty Page(脏页)
定义:
Dirty Page
=
Page 被修改
AND
修改没有写回数据文件
两个条件缺一不可。
关键代码 - Buffer标记脏页的宏:
c
// 文件: src/include/storage/buf_internals.h
#define BM_DIRTY (1U << 23) // 脏页标志位
关键代码 - MarkBufferDirty函数(标记Buffer为脏):
c
// 文件: src/backend/storage/buffer/bufmgr.c
// 该函数将Buffer标记为脏页,表示内存中的Page已被修改但尚未写回磁盘
void MarkBufferDirty(Buffer buffer)
{ //函数大致流程:
// 设置BM_DIRTY标志位
// 记录LSN(日志序列号)
// 后续由Background Writer或Checkpointer写回磁盘
}
不是脏页:
刚读取:
磁盘:
Tom
内存:
Tom
一致。
是脏页:
磁盘:
Tom
内存:
Bob
不一致。
8. Background Writer 与 Checkpointer
7.1 Background Writer
作用:
提前慢慢刷脏页,避免突然产生大量写压力。
类似:
每天清理垃圾。
它关注:
- 性能
- 平滑 IO
关键代码 - BackgroundWriterMain函数:
c
// 文件: src/backend/PostgreSQLsvr/bgwriter.c
void BackgroundWriterMain(void)
{
// 后台写进程主循环
// 周期性扫描共享缓冲区,将脏页写回磁盘
// 通过BgWriterDelay控制扫描间隔(默认200ms)
}
关键代码 - BgWriterDelay参数:
c
// 文件: src/backend/PostgreSQLsvr/bgwriter.c
int BgWriterDelay = 200; // 后台写进程延迟(毫秒)
7.2 Checkpointer
作用:
创建恢复检查点。
类似:
游戏存档,游戏里也有检查点这个说法,也就是存档,当死亡后可以从检查点重开不用从头开始。
Checkpoint 表示:
某个时间点之前的数据已经安全写入数据文件。
恢复时:
从 checkpoint 后的 WAL 开始恢复。
关键代码 - CheckPoint结构(检查点信息):
c
// 文件: src/include/catalog/pg_control.h
typedef struct CheckPoint
{
XLogRecPtr redo; // REDO起始点
TimeLineID ThisTimeLineID; // 当前时间线ID
TimeLineID PrevTimeLineID; // 前一个时间线ID
bool fullPageWrites; // 当前full_page_writes设置
FullTransactionId nextXid; // 下一个空闲事务ID
Oid nextOid; // 下一个空闲OID
MultiXactId nextMulti; // 下一个空闲MultiXactId
MultiXactOffset nextMultiOffset; // 下一个空闲MultiXact偏移
TransactionId oldestXid; // 集群级最小datfrozenxid
Oid oldestXidDB; // 最小datfrozenxid的数据库
MultiXactId oldestMulti; // 集群级最小datminmxid
Oid oldestMultiDB; // 最小datminmxid的数据库
pg_time_t time; // checkpoint时间戳
TransactionId oldestCommitTsXid; // 最老的有效提交时间戳Xid
TransactionId newestCommitTsXid; // 最新的有效提交时间戳Xid
TransactionId oldestActiveXid; // 最老的仍在运行的XID
} CheckPoint;
关键代码 - CheckpointerMain函数:
c
// 文件: src/backend/PostgreSQLsvr/checkpointer.c
void CheckpointerMain(void)
{
// Checkpointer主循环
// 周期性创建检查点
// 将所有脏页写回磁盘
// 通过CheckPointTimeout控制超时(默认300秒)
// 通过CheckPointCompletionTarget控制完成目标(默认0.9)
}
关键参数:
c
// 文件: src/backend/PostgreSQLsvr/checkpointer.c
int CheckPointTimeout = 300; // 检查点超时(秒)
int CheckPointWarning = 30; // 检查点警告阈值(秒)
double CheckPointCompletionTarget = 0.9; // 检查点完成目标(0-1)
7.3 两者作用
在我们在Buffer 里修改完数据后,需要存回到磁盘。
流程:
1 先写wal日志,记录数据的修改。
2 wal 日志写完 事务才提交。
3 事务提交时不会立即把修改后的数据页刷回磁盘,因为频繁刷Page会产生大量IO开销。PostgreSQL采用WAL机制保证事务持久性:提交时只需要保证WAL落盘即可。事务提交后,后台由Background Writer持续平滑刷写Dirty Page,Checkpoint周期性创建恢复点并保证一致性。如果系统在Dirty Page尚未写回数据文件前发生崩溃,PostgreSQL启动恢复时会从最近Checkpoint开始读取后续WAL,通过Redo重新应用修改,恢复数据。
9. Page 内部如何找到数据?
现在我们拿到一个page了,我们要理解在这个page里他是如何管理这些tuple,如何查找的。
一个 Page 不是简单排列:
Tom
Jack
Bob
而是:
Page
+----------------+
| Page Header |
+----------------+
| Slot目录 |
+----------------+
| |
| Tuple数据 |
| |
+----------------+
数据库通过 Slot 找 Tuple。
关键代码 - Page操作相关宏:
c
// 文件: src/include/storage/bufpage.h
// 获取指定offset的line pointer(Slot)
#define PageGetItemId(page, offsetNumber) \
((ItemId) (&((PageHeaderData *)(page))->pd_linp[(offsetNumber) - 1]))
// 通过line pointer获取实际tuple数据
#define PageGetItem(page, itemId) \
((Item) ((char *)(page) + ItemIdGetOffset(itemId)))
// 获取页面最大offset number(即tuple数量)
#define PageGetMaxOffsetNumber(page) \
(((PageHeaderData *)(page))->pd_lower != 0 ? \
(((PageHeaderData *)(page))->pd_lower - offsetof(PageHeaderData, pd_linp)) / sizeof(ItemIdData) : 0)
// 获取页面剩余空间
#define PageGetFreeSpace(page) \
(ItemIdGetOffset(((PageHeaderData *)(page))->pd_special) - \
((PageHeaderData *)(page))->pd_upper)
10. Slot 是什么?
Slot 是 Tuple 的定位目录,不是数据本身。
Slot对应ItemIdData 是教学文档里称呼,在内核源码里其实是ItemIdData。
关键代码 - ItemIdData(Slot的底层结构):
c
// 文件: src/include/storage/itemid.h
typedef struct ItemIdData
{
unsigned lp_off:15, /* offset to tuple (from start of page) */
lp_flags:2, /* state of line pointer, see below */
lp_len:15; /* byte length of tuple */
} ItemIdData;
lp_flags 状态值:
| 值 | 宏 | 说明 |
|---|---|---|
| 0 | LP_UNUSED |
未使用 (lp_len=0) |
| 1 | LP_NORMAL |
正常使用 (lp_len>0) |
| 2 | LP_REDIRECT |
HOT 重定向 (lp_len=0) |
| 3 | LP_DEAD |
已死亡 |
关系:
Slot
|
v
Tuple
不是:
Slot
|
包含
|
Tuple
解释:就好像指针,slot指向了一个tuple
以文件柜类比
Page:文件柜
Tuple:文件
Slot:柜门上的目录卡
目录:
文件A -> 第5层
文件B -> 第8层
11. Slot 保存什么?
Slot 保存:
- Tuple 在 Page 中的位置
- Tuple 长度
- 状态信息
例如:
Slot1:
offset=5000
length=120
表示:
从 Page 第5000字节开始读取120字节,就是这个 Tuple。
12. 为什么需要 Slot?
因为数据会移动。
没有 Slot:
Tuple地址变化
所有引用都需要修改
有 Slot:
外部引用
(page,slot)
|
v
Slot
|
v
Tuple位置
移动 Tuple:
只修改 Slot。
外部不用变化,因为外部只认slot你这个slot就是那个tuple了。
13. Tuple 和 Slot 的关系
物理层:
Page
Slot1 ---> Tuple1
Slot2 ---> Tuple2
Slot3 ---> Tuple3
一个 Slot 对应一个 Tuple。
关键代码 - HeapTupleHeaderData(元组头结构):
c
// 文件: src/include/access/htup_details.h
struct HeapTupleHeaderData
{
union
{
HeapTupleFields t_heap;
DatumTupleFields t_datum;
} t_choice;
ItemPointerData t_ctid; /* current TID of this or newer tuple */
uint16 t_infomask2; /* number of attributes + various flags */
uint16 t_infomask; /* various flag bits */
uint8 t_hoff; /* sizeof header incl. bitmap, padding */
/* ^ - 23 bytes - ^ */
bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* bitmap of NULLs */
};
关键代码 - HeapTupleFields(元组字段信息):
c
// 文件: src/include/access/htup_details.h
typedef struct HeapTupleFields
{
TransactionId t_xmin; /* inserting xact ID */
TransactionId t_xmax; /* deleting or locking xact ID */
union
{
CommandId t_cid; /* inserting or deleting command ID */
MultiXactId t_xvac; /* old-style VACUUM FULL xact ID */
} t_field3;
} HeapTupleFields;
关键代码 - PageAddItemExtended(添加Tuple到Page并创建Slot):
c
// 文件: src/backend/storage/page/bufpage.c
// 添加Tuple到Page,创建Slot指向Tuple
OffsetNumber
PageAddItemExtended(Page page,
Item item,
Size size,
OffsetNumber offsetNumber,
int flags)
{
PageHeader phdr = (PageHeader) page;
Size alignedSize;
int lower;
int upper;
ItemId itemId;
OffsetNumber limit;
bool needshuffle = false;
// 1. 确定要使用的Slot位置
limit = OffsetNumberNext(PageGetMaxOffsetNumber(page));
if (OffsetNumberIsValid(offsetNumber))
{
// 指定了偏移量的情况
if ((flags & PAI_OVERWRITE) != 0)
{
// 覆写模式:检查该Slot未被使用
itemId = PageGetItemId(page, offsetNumber);
if (ItemIdIsUsed(itemId) || ItemIdHasStorage(itemId))
{
elog(WARNING, "will not overwrite a used ItemId");
return InvalidOffsetNumber;
}
}
else
{
if (offsetNumber < limit)
needshuffle = true; // 需要移动现有的linp数组
}
}
else
{
// 未指定偏移量:寻找空闲Slot
if (PageHasFreeLinePointers(page))
{
for (offsetNumber = FirstOffsetNumber;
offsetNumber < limit;
offsetNumber++)
{
itemId = PageGetItemId(page, offsetNumber);
if (!ItemIdIsUsed(itemId) && !ItemIdHasStorage(itemId))
break;
}
}
else
{
offsetNumber = limit; // 直接放在末尾
}
}
// 2. 计算新的pd_lower和pd_upper,判断空间是否足够
if (offsetNumber == limit || needshuffle)
lower = phdr->pd_lower + sizeof(ItemIdData); // Slot区域需要扩展
else
lower = phdr->pd_lower;
alignedSize = MAXALIGN(size);
upper = (int) phdr->pd_upper - (int) alignedSize; // Tuple区域向前扩展
if (lower > upper)
return InvalidOffsetNumber; // 空间不足
// 3. 如果需要,在Slot数组中腾出位置
itemId = PageGetItemId(page, offsetNumber);
if (needshuffle)
memmove(itemId + 1, itemId,
(limit - offsetNumber) * sizeof(ItemIdData));
// 4. 设置LinePointer(Slot),建立Slot → Tuple的映射
ItemIdSetNormal(itemId, upper, size);
// 等价于:
// itemId->lp_flags = LP_NORMAL;
// itemId->lp_off = upper; // Tuple在Page中的字节偏移
// itemId->lp_len = size; // Tuple的字节长度
// 5. 将Tuple数据拷贝到Page的Tuple区域
memcpy((char *) page + upper, item, size);
// 6. 更新Page头部的边界指针
phdr->pd_lower = (LocationIndex) lower; // Slot数组增长
phdr->pd_upper = (LocationIndex) upper; // Tuple区域缩小
return offsetNumber;
}
关键代码 - PageGetItemId(通过Slot编号获取Slot指针):
c
// 文件: src/include/storage/bufpage.h
// OffsetNumber是1-based索引,pd_linp是0-based数组
static inline ItemId
PageGetItemId(Page page, OffsetNumber offsetNumber)
{
return &((PageHeader) page)->pd_linp[offsetNumber - 1];
}
关键代码 - PageGetItem(通过Slot获取Tuple数据):
c
// 文件: src/include/storage/bufpage.h
// 通过Slot的lp_off偏移量获取Tuple实际地址
static inline Item
PageGetItem(Page page, ItemId itemId)
{
Assert(page);
Assert(ItemIdHasStorage(itemId));
return (Item) (((char *) page) + ItemIdGetOffset(itemId));
}
// 等价于:Tuple地址 = page基地址 + itemId->lp_off
关键代码 - RelationPutHeapTuple(Heap Tuple插入的完整调用链):
c
// 文件: src/backend/access/heap/hio.c
// 将HeapTuple插入到Page中
void
RelationPutHeapTuple(Relation relation, Buffer buffer, HeapTuple tuple, bool token)
{
Page pageHeader;
OffsetNumber offnum;
pageHeader = BufferGetPage(buffer);
// 1. 调用PageAddItem创建Slot并存入Tuple
offnum = PageAddItem(pageHeader, (Item) tuple->t_data,
tuple->t_len, InvalidOffsetNumber, false, true);
if (offnum == InvalidOffsetNumber)
elog(PANIC, "failed to add tuple to page");
// 2. 更新tuple->t_self为实际存储位置(BlockNumber + OffsetNumber)
ItemPointerSet(&(tuple->t_self), BufferGetBlockNumber(buffer), offnum);
// 3. 设置存储后的CTID(自身指针)
if (!token)
{
ItemId itemId = PageGetItemId(pageHeader, offnum); // 通过Slot获取
HeapTupleHeader item = (HeapTupleHeader) PageGetItem(pageHeader, itemId); // 通过Slot获取Tuple
item->t_ctid = tuple->t_self;
}
}
Slot → Tuple 对应关系总结:
外部引用路径:
ItemPointerData.ip_blkid ──→ 定位到Page
ItemPointerData.ip_posid ──→ 定位到Slot (OffsetNumber)
Page内部结构:
Page起始地址
├── PageHeaderData (头部)
│ ├── pd_lower ──→ Slot数组结束位置
│ ├── pd_upper ──→ Tuple区域开始位置
│ └── pd_linp[] ──→ Slot数组 (LinePointer数组)
│ ├── [0] linp1 (ItemIdData: lp_off, lp_flags, lp_len) ──→ Tuple1
│ ├── [1] linp2 ──→ Tuple2
│ ├── ...
│ └── [N] linpN ──→ TupleN
├── 空闲空间
├── TupleN (从pd_upper开始,向低地址增长)
├── ...
├── Tuple2
├── Tuple1
└── 特殊空间 (从pd_special开始)
Slot → Tuple 的映射公式:
Tuple在Page中的地址 = page基地址 + itemId->lp_off
Tuple的长度 = itemId->lp_len
关键数据结构 - ItemIdData(Slot的底层结构):
c
// 文件: src/include/storage/itemid.h
typedef struct ItemIdData
{
unsigned lp_off:15, // Tuple在Page内的字节偏移量(从Page起始处算起)
lp_flags:2, // LinePointer的状态标志
lp_len:15; // Tuple的字节长度
} ItemIdData;
// lp_flags状态值
#define LP_UNUSED 0 // 未使用(可立即复用)
#define LP_NORMAL 1 // 正常使用中(有Tuple数据)
#define LP_REDIRECT 2 // HOT重定向
#define LP_DEAD 3 // 已死亡
// 设置Slot指向Tuple
#define ItemIdSetNormal(itemId, off, len) \
( \
(itemId)->lp_flags = LP_NORMAL, \
(itemId)->lp_off = (off), \
(itemId)->lp_len = (len) \
)
但是:
业务上一行数据可能因为 MVCC 产生多个 Tuple 版本,这是比较复杂的处理。正常而言一个 Slot 对应一个 Tuple,可以这样理解。
14. TID(Tuple Identifier)
前面说过了
定位一条物理记录:
(block number, offset number)
例如:
(10,5)
表示:
第10个 Page
第5个 Slot。
15. MVCC 与 Slot 的关系
UPDATE 不一定直接修改旧 Tuple。
例如:
原:
Tuple A
name=Tom
更新:
Tuple B
name=Jerry
可能产生:
Slot1 -> Tuple A
Slot2 -> Tuple B
旧版本等待 VACUUM 清理。
关键代码 - MVCC相关标志位(t_infomask):
c
// 文件: src/include/access/htup_details.h
// xmin相关标志位
#define HEAP_XMIN_COMMITTED 0x0100 // t_xmin已提交
#define HEAP_XMIN_INVALID 0x0200 // t_xmin无效/已中止
#define HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID) // t_xmin已冻结
// xmax相关标志位
#define HEAP_XMAX_COMMITTED 0x0400 // t_xmax已提交
#define HEAP_XMAX_INVALID 0x0800 // t_xmax无效/已中止
#define HEAP_XMAX_IS_MULTI 0x1000 // t_xmax为MultiXactId
// 其他标志位
#define HEAP_UPDATED 0x2000 // 行的UPDATE版本
关键代码 - TransactionId(事务ID):
c
// 文件: src/include/c.h
typedef uint32 TransactionId;
// 文件: src/include/access/transam.h
#define InvalidTransactionId ((TransactionId) 0)
#define FirstNormalTransactionId ((TransactionId) 3)
#define MaxTransactionId ((TransactionId) 0xFFFFFFFF)
关键代码 - CommandId(命令ID):
c
// 文件: src/include/c.h
typedef uint32 CommandId;
#define FirstCommandId ((CommandId) 0)
关键代码 - HeapTupleSatisfiesMVCC(MVCC可见性检查):
c
// 文件: src/backend/access/heap/heapam_visibility.c
bool HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer)
{
// 核心MVCC可见性检查函数
// 检查xmin是否已提交
// 检查xmax是否已提交
// 根据snapshot判断元组是否可见
}
16. 总结
存储核心链路:
SQL修改
↓
修改 Shared Buffer 中 Page
↓
Page变Dirty 脏页
↓
生成WAL
↓
WAL先落盘
↓
Background Writer / Checkpoint (这两个进程配合慢慢刷数据到盘中
↓
数据页写回磁盘
Page内部:
Page
↓
Slot目录
↓
Tuple位置
↓
真实数据
核心思想:
内存保存磁盘 Page 的副本,修改先发生在内存,通过 WAL 保证可靠性,通过
Slot 实现稳定的数据定位。