PostgreSQL 存储机制详解

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(柔性数组成员)声明,意味着:

  1. pd_linp紧跟在PageHeaderData头部字段之后

  2. 它是一个变长数组,实际长度由pd_lower决定

  3. 内存布局: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);
}
  1. 图示:
    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 实现稳定的数据定位。

相关推荐
JacksonMx2 小时前
Java CompletableFuture 异步编程实战:从入门到架构师避坑指南
linux·数据库·python
tachibana22 小时前
性能指标的口径选择
数据库·人工智能·架构·大模型·llm
机器人梦想家3 小时前
具身机器人视觉服务的异步回调与并发控制
数据库·算法·机器人
fthux3 小时前
被主流遮住的世界:那些不常见却值得认识的编程语言
人工智能·ai·开源·github
小妖同学学AI3 小时前
开源向量数据库的王者:Milvus 深度解析,专为 AI 应用打造的云原生搜索引擎!
数据库·开源·milvus
l1258654 小时前
# RAG上线评估指标体系:六大核心指标与压测实战全解析
数据库·人工智能·python·mysql·langchain·milvus
william_yangshun4 小时前
【AI Agent 实战】cindy 中文版:开箱即用的开源 AI 代理上手指南
人工智能·开源
ylj_dev4 小时前
从 0 构建 AI Workload Platform(四):从单机工作流内核到可靠控制面
docker·postgresql·架构·golang
Wang's Blog4 小时前
PostgreSQL笔记48: 全文检索与向量检索的对比与混合检索实践
笔记·postgresql·全文检索