Overview
PostgreSQL 内核学习笔记,目录镜像 src/backend/ 源码结构;跨模块调用链见 traces/。
快速入口
- 整体架构 — 进程模型、共享内存与 IPC
- 编译与调试 — 环境搭建与 GDB/LLDB
- 源码目录 —
src/backend/模块导览 - 启动流程 — Postmaster → Backend 生命周期
核心模块
- 查询全链路 — tcop → parser → optimizer → executor
- 页面布局 — Heap Page
- 事务 / MVCC — XID、WAL、可见性
- 内存管理 — MemoryContext
阅读建议
- run:参考 编译文档 本地构建 PG,学会 attach 进程调试。
- code:笔记只是线索,核心逻辑以
src/backend下 C 代码为准。 - debug:通过断点观察关键结构体(如
ProcessUtility、ExecScan)的运行时状态。
参考资料
Maintainer: coreele
Architecture
Process Architecture
https://medium.com/@reetesh043/ee5b24b52a30
https://www.interdb.jp/pg/pgsql02/01.html
PG 内核全景
- 控制:分析, 优化, 执行
- 数据:访问方法(Heap/Index, Buffer Cache, 物理磁盘)
- 事务:Lock Manager, WAL/CLOG, MVCC (Visible check)
- 元数据:Syscache(系统表缓存), Relcache(表定义缓存)
- 运行:MemoryContext, 信号量/共享内存, 辅助进程
Code
代码仓库
- pg 官方仓库:https://git.postgresql.org/git/postgresql.git
- github 仓库:https://github.com/postgres/postgres.git
- gitee 仓库:https://gitee.com/mirrors/PostgreSQL.git
代码目录
.
├── access 实现核心存储访问机制,包括表和索引的数据读写、事务可见性控制等
├── archive 提供 WAL(预写日志)归档功能相关代码,支持将 WAL 日志归档到外部存储
├── backup 包含物理备份与恢复相关逻辑,如基础备份生成、增量备份处理等
├── bootstrap 负责数据库初始化工作,如系统表创建、初始数据加载等启动流程
├── catalog 管理系统目录(系统表),维护数据库对象(表、索引等)的元数据
├── commands 实现 SQL 命令执行逻辑,处理 CREATE、ALTER、DROP 等 DDL 及部分 DML 命令
├── executor SQL 执行器核心,将查询计划转换为实际数据操作,执行查询并返回结果
├── foreign 实现外部数据包装器(FDW)框架,支持访问 PostgreSQL 以外的外部数据源
├── jit 包含即时编译(JIT)功能代码,通过动态生成机器码优化查询执行效率
├── lib 存放后端内部通用函数库,如字符串处理、数学计算、内存管理等基础工具函数
├── libpq 实现 C 语言客户端通信库,处理客户端与服务器的网络连接和协议交互
├── main 包含后端进程入口函数,负责进程初始化与主逻辑调度
├── nls.mk 国际化相关 Makefile 配置,管理后端消息翻译文件的生成与编译
├── nodes 定义内部数据结构(如查询树、计划树节点)及节点操作函数
├── optimizer 查询优化器核心,分析 SQL 查询并生成最优查询计划
├── parser 负责 SQL 语句的解析,将文本转换为抽象语法树(AST)
├── partitioning 实现表分区功能,处理分区表创建、数据路由、分区修剪等
├── po 存放国际化翻译文件(.po),包含各语言对后端消息的翻译文本
├── port 提供跨平台兼容性代码,适配不同操作系统的系统调用与特性差异
├── postmaster 主进程(守护进程)代码,负责监听连接、管理后端进程生命周期
├── regex 集成正则表达式处理库,提供 SQL 中 REGEXP 相关操作的实现
├── replication 实现数据库复制功能,包含主从复制、流复制、逻辑复制逻辑
├── rewrite 处理规则(Rule)和视图(View),将对视图的查询重写为对基表的查询
├── snowball 集成 Snowball 词根提取库,为全文搜索提供多语言词根分析支持
├── statistics 管理数据库统计信息,为查询优化提供数据分布等支撑信息
├── storage 实现存储子系统,包含缓冲池、事务日志(WAL)、磁盘文件管理等
├── tcop “查询编译器” 模块,协调客户端 SQL 请求的接收、解析、重写等过程 traffic cop
├── tsearch 实现全文搜索功能,包含文本分词、索引创建、检索匹配逻辑
└── utils 存放通用工具模块,如内存分配、错误处理、日期时间处理等辅助功能
核心目录
.
├── main 包含后端进程入口函数,负责进程初始化与主逻辑调度
├── postmaster 主进程(守护进程)代码,负责监听连接、管理后端进程生命周期
├── tcop “查询编译器” 模块,协调客户端 SQL 请求的接收、解析、重写等过程 traffic cop
├── parser 负责 SQL 语句的解析,将文本转换为抽象语法树(AST)
├── catalog 管理系统目录(系统表),维护数据库对象(表、索引等)的元数据
├── rewrite 处理规则(Rule)和视图(View),将对视图的查询重写为对基表的查询
├── optimizer 查询优化器核心,分析 SQL 查询并生成最优查询计划
├── executor SQL 执行器核心,将查询计划转换为实际数据操作,执行查询并返回结果
├── access 实现核心存储访问机制,包括表和索引的数据读写、事务可见性控制等; heap, btree, mvcc(visibility)
└── storage 实现存储子系统,包含缓冲池、事务日志(WAL)、磁盘文件管理等
Compile
基于 macOS 15.4.1
源码获取
- pg 官方仓库:https://git.postgresql.org/git/postgresql.git
- github 仓库:https://github.com/postgres/postgres.git
- gitee 仓库:https://gitee.com/mirrors/PostgreSQL.git
git clone https://gitee.com/mirrors/PostgreSQL.git
cd postgresql
git checkout REL_16_11
版本规则
- PG 版本维护规则:https://www.postgresql.org/support/versioning/
- 发布版命名规则:
REL_<主版本>_<维护版本> - REL_16_11 处于 16 主版本的「稳定维护阶段」
- 更新频率:每年发布一个主版本(15→16→17→18),每 1-2 个月发布一个维护版本(如 16_1→16_2→…→16_11)
源码编译
cd postgres
mkdir build && cd build
CFLAGS="-O0 -g3 -fno-inline -fno-omit-frame-pointer" \
../configure --prefix=$HOME/app/pgdebug --without-icu --with-libxml --enable-debug --enable-cassert
make -j4
make install
# make distclean
# cp ./src/backend/postgres ~/app/pgdebug/bin/postgres
初始化
cd ~/app/pgdebug/bin
./initdb -D ~/pgdata
- PostgreSQL 中初始化数据库集群(Database Cluster) 的核心命令
- 在指定目录
~/pgdata中创建 PostgreSQL 运行所需的基础目录结构、系统表、配置文件
启动和停止
cd ~/app/pgdebug/bin
./pg_ctl start -D ~/pgdata -l ~/pgdebug.log
./pg_ctl stop -D ~/pgdata -l ~/pgdebug.log
./pg_ctl restart -D ~/pgdata -l ~/pgdebug.log
连接
配置客户端访问 pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
# "local" is for Unix domain socket connections only
local all all trust
# IPv4 local connections:
host all all 127.0.0.1/32 trust
连接
psql -U postgres -d postgres -h 127.0.0.1 -p 5432
查询当前 pid
// 查询当前pid
select pg_backend_pid();
vscode 配置调试
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach PG",
"type": "cppdbg",
"request": "attach",
"program": "~/SourceCodes/postgres/src/backend/postgres", // PostgreSQL 主程序路径
"processId": "${command:pickProcess}", // 允许手动选择进程ID
"MIMode": "lldb", // 使用 GDB 调试器
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"logging": {
"moduleLoad": false,
"trace": true
}
}
]
}
Boot
| 函数名 | 核心功能 | 关键细节 |
|---|---|---|
exec_simple_query | 执行简单 SQL(单命令) | 执行 SQL:解析 → 重写 → 优化 → 执行 |
PostgresMain | 后端主函数 | 循环处理多命令,关联 MessageContext/row_description_context |
BackendRun | 运行后端逻辑 | 切换内存上下文至 TopMemoryContext |
BackendStartup | 创建后端进程 | fork_process 创建子进程,失败返回-1 |
ServerLoop | 服务器事件循环 | 等待并处理客户端连接 |
PostmasterMain | 主进程主循环 | 读取配置,监听连接,管理子进程,PostmasterContext=TopMemoryContext |
main | 程序入口 | 初始化环境,分配 TopMemoryContext,调用 PostmasterMain |
main
PostmasterMain
InitProcessGlobals()
CreateDataDirLockFile() // create postmaster.pid
ServerLoop
BackendStartup
fork_process
BackendRun
PostgresMain(port->database_name, port->user_name);
exec_simple_query(query_string);
Overview
- A Comprehensive Overview of PostgreSQL Query Processing Stages
- The Internals of PostgreSQL: 3 Query Processing
- postgres.c
exec_simple_query
/* parse */
pg_parse_query
raw_parser
/* analyze and rewrite */
pg_analyze_and_rewrite_fixedparams
parse_analyze_fixedparams
pg_rewrite_query
/* plan */
pg_plan_queries
/* execute */
PortalStart
ExecutorStart
PortalRun - PortalRunSelect
ExecutorRun
PortalDrop
ExecutorFinish
ExecutorEnd
完整调用栈
/* 1. Parse */
pg_parse_query // Parse by Bison(gram.y), support multi queries
raw_parser
/* 2. Analyze & Rewrite */
pg_analyze_and_rewrite_fixedparams // analyze and rewrite RawStmt, RawStmt -> Query
query = parse_analyze_fixedparams // Perform parse analysis. RawStmt -> Query
transformTopLevelStmt - transformOptionalSelectInto - transformStmt
transformSelectStmt
Query *qry = makeNode(Query);
transform***Clause
querytree_list = pg_rewrite_query // Rewrite the queries, as necessary. Query -> List(Query)
QueryRewrite // don't rewrite utilities
/* 3. Plan */
pg_plan_queries // querytree_list -> plantree_list(PlannedStmt), plan just for dml(select, insert, update, delete, merge)
pg_plan_query - planner - standard_planner - subquery_planner
/* primary planning entry point (may recurse for subqueries) */
root = subquery_planner(glob, parse, NULL, false, tuple_fraction);
/* Select best Path and turn it into a Plan */
final_rel = fetch_upper_rel(root, UPPERREL_FINAL, NULL);
best_path = get_cheapest_fractional_path(final_rel, tuple_fraction);
PlannedStmt.planTree = create_plan(root, best_path);
/* 4. Portal */
CreatePortal
PortalDefineQuery // portal->stmts = plantree_list;
PortalStart // Prepare a portal for execution. params, strategy, queryDesc
queryDesc = CreateQueryDesc
ExecutorStart(queryDesc, myeflags); // prepare the plan for execution
standard_ExecutorStart
portal->queryDesc = queryDesc;
portal->status = PORTAL_READY;
PortalSetResultFormat
PortalRun - PortalRunSelect
/* 5. Executor */
ExecutorRun - tandard_ExecutorRun - ExecutePlan // Processes the query plan until retrieved 'numberTuples' tuples
ExecProcNode - ExecSeqScan
ExecScan - ExecScanFetch - SeqNext // executor module
/* Access + Storage*/
table_scan_getnextslot - heap_getnextslot - heapgettup_pagemode
heapgetpage
ReadBufferExtended
ReadBuffer_common
BufferAlloc
InitBufferTag
LWLockAcquire(newPartitionLock, LW_SHARED);
existing_buf_id = BufTableLookup(&newTag, newHash);
LockBuffer(buffer, BUFFER_LOCK_SHARE);
BufferGetPage - BufferGetBlock
return (Block) (BufferBlocks + ((Size) (buffer - 1)) * BLCKSZ);
for (lineoff = FirstOffsetNumber; lineoff <= lines; lineoff++)
PageGetItemId // Returns an item identifier of a page.
return &((PageHeader) page)->pd_linp[offsetNumber - 1];
PageGetItem // Retrieves an item on the given page.
return (Item) (((char *) page) + ItemIdGetOffset(itemId));
// True if heap tuple satisfies a time qual
HeapTupleSatisfiesVisibility - HeapTupleSatisfiesMVCC
HeapCheckForSerializableConflictOut
scan->rs_vistuples[ntup++] = lineoff;
LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
return slot;
ExecProject
PortalDrop
insert
- 调试语句:
insert into tb values(1) - insert 核心流程梳理,将从最简单的插入数据开始,逐步讨论事务、锁、资源管理等相关内容
- 调试语句:
insert into tb values(1)
概览
start_xact_command
pg_parse_query
pg_analyze_and_rewrite_fixedparams
pg_plan_queries
PortalDefineQuery
PortalRun | PortalRunMulti | ProcessQuery /* tcop */
EndCommand
finish_xact_command
ProcessQuery
/* ... */
pg_plan_queries
CreatePortal
PortalDefineQuery
PortalStart
PortalRun | PortalRunMulti | ProcessQuery /* tcop */
CreateQueryDesc
ExecutorStart
ExecutorRun | standard_ExecutorRun | ExecutePlan | ExecProcNode | ExecProcNodeFirst /* executor */
ExecModifyTable | ExecInsert /* executor */
table_tuple_insert /* access/tableam.h call Relation::TableAmRoutine::tuple_insert */
heapam_tuple_insert /* access/heap/heapam_handler.c */
heap_insert /* access/heap/heapam.c */
RelationGetBufferForTuple /* access/heap/hio.c */
RelationPutHeapTuple /* access/heap/hio.c */
PageAddItemExtended /* storage/page/bufpage.c */
MarkBufferDirty(buffer) /* storage/buffer/bufmgr.c */
XLogInsert /* access/transam/xloginsert.c */
XLogRecordAssemble
XLogInsertRecord
PageSetLSN
ExecutorFinish
PortalDrop
EndCommand
finish_xact_command
CommitTransaction
start_xact_command
pg_parse_query
pg_analyze_and_rewrite_fixedparams
pg_plan_queries
CreatePortal
PortalDefineQuery
PortalStart
PortalRun | PortalRunMulti | ProcessQuery /* tcop */
PortalDrop
EndCommand
finish_xact_command
CommitTransactionCommand
CommitTransaction
s->state = TRANS_COMMIT;
RecordTransactionCommit
XactLogCommitRecord
XLogInsert
XLogFlush /* wal -> disk */
TransactionIdCommitTree
TransactionIdSetTreeStatus
TransactionIdSetPageStatus
TransactionIdSetPageStatusInternal
s->state = TRANS_DEFAULT;
xact_started = false;
完整过程
start_xact_command
StartTransactionCommand
StartTransaction
s->state = TRANS_START;
/* initialize current transaction state fields */
/* ... */
s->state = TRANS_INPROGRESS;
xact_started = true;
pg_parse_query
pg_analyze_and_rewrite_fixedparams
pg_plan_queries
CreatePortal
PortalDefineQuery
PortalStart
PortalRun | PortalRunMulti | ProcessQuery /* tcop */
CreateQueryDesc
ExecutorStart
ExecutorRun | standard_ExecutorRun | ExecutePlan | ExecProcNode | ExecProcNodeFirst /* executor */
ExecModifyTable | ExecInsert /* executor */
table_tuple_insert /* access/tableam.h call Relation::TableAmRoutine::tuple_insert */
heapam_tuple_insert /* access/heap/heapam_handler.c */
heap_insert /* access/heap/heapam.c */
RelationGetBufferForTuple /* access/heap/hio.c */
RelationPutHeapTuple /* access/heap/hio.c */
PageAddItemExtended /* storage/page/bufpage.c */
MarkBufferDirty(buffer) /* storage/buffer/bufmgr.c */
XLogInsert /* access/transam/xloginsert.c */
XLogRecordAssemble
XLogInsertRecord
PageSetLSN
ExecutorFinish
PortalDrop
EndCommand
finish_xact_command
CommitTransactionCommand
CommitTransaction
s->state = TRANS_COMMIT;
RecordTransactionCommit
XactLogCommitRecord
XLogInsert
XLogFlush /* wal -> disk */
TransactionIdCommitTree
TransactionIdSetTreeStatus
TransactionIdSetPageStatus
TransactionIdSetPageStatusInternal
s->state = TRANS_DEFAULT;
xact_started = false;
Hint Bits
依赖扩展: pageinspect: 用于直接查看页面和元组信息
drop table if exists tb;
create table tb(a int);
关闭自动提交并插入数据
\set AUTOCOMMIT off
insert into tb values (1);
插入后不提交,此时新开一个 psql 客户端无法查询到 tb 中的数据,但是使用 pageinspect 工具可以看到已经有记录已经占据了页面空间,upper 为 8160
select from tb;
(0 rows)
select * from page_header(get_raw_page('tb', 0));
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
| lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid |
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
| 0/2A5EB38 | 0 | 0 | 28 | 8160 | 8192 | 8192 | 4 | 0 |
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
(1 row)
此时 t_xmin 表示插入数据的事务 id
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3, t_ctid, t_infomask from heap_page_items(get_raw_page('tb', 0));
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| 1 | 8160 | 1 | 28 | 1228 | 0 | 0 | (0,1) | 2048 |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
lp: 行指针序号
lp_off: 页面内物理偏移量
lp_flags: 状态标记(1: LP_NORMAL, 2: REDIRECT, 3: DEAD, 0: UNUSED)
lp_len: 元组长度。这行数据(含头+数据+对齐)总共占用了 28 字节,实际存储占用 32 字节(8 字节对齐)
t_xmin: 插入事务 ID。表示这个元组是由事务号为 1228 的操作创建的
t_xmax: 删除/锁定事务 ID。0 表示该行目前是“活的”,尚未被删除或更新
t_field3: 命令 ID (t_cid)。表示这是事务 1228 里的第几个命令(从 0 开始计数)
t_ctid: 物理指针, 指向最新版本
t_infomask: 状态信息, HEAP_XMAX_INVALID
此时执行提交
commit;
再次使用 heap_page_items 发现 t_infomask 无变化
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3, t_ctid, t_infomask from heap_page_items(get_raw_page('tb', 0));
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| 1 | 8160 | 1 | 28 | 1228 | 0 | 0 | (0,1) | 2048 |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
另启客户端访问一下 tb
select from tb;
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3, t_ctid, t_infomask from heap_page_items(get_raw_page('tb', 0));
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| 1 | 8160 | 1 | 28 | 1228 | 0 | 0 | (0,1) | 2304 |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
再次使用 heap_page_items 发现 t_infomask 变为 2304 = 2048 + 256 = HEAP_XMIN_COMMITTED + HEAP_XMAX_INVALID
解释:基于 Hint Bits 的延迟状态更新
Hint Bits 是 CLOG 的缓存,提交/回滚时不改数据页,首次访问时再回填。
| 阶段 | 动作 | 页上 t_infomask |
|---|---|---|
| COMMIT | 只写 WAL + CLOG | XMIN_COMMITTED 仍为 0 |
| 首次访问 | 查 CLOG → Buffer 设 hint | 置 HEAP_XMIN_COMMITTED |
| ROLLBACK | 不改页 | 后续置 HEAP_XMIN_INVALID,行不可见 |
目的:提交轻量,避免为 hint 同步刷大量数据页。
WAL 日志
DROP TABLE IF EXISTS tb;
CREATE TABLE tb(a int);
\set AUTOCOMMIT off
SELECT pg_current_wal_insert_lsn() AS lsn_before;
lsn_before
------------
0/102BEB10
(1 row)
INSERT INTO tb VALUES (1);
SELECT pg_current_wal_insert_lsn() AS lsn_after; -- INSERT 后 lsn 已前进
lsn_after
------------
0/102BEB50
(1 row)
SELECT lsn FROM page_header(get_raw_page('tb', 0)); -- 页 lsn 对应 heap WAL
lsn
------------
0/102BEB50
(1 row)
COMMIT;
# 从 lsn_before 起读 WAL(替换为实际值)
bin> pg_waldump -s 0/102BEB10 -n 10 -p ~/pgdata/pg_wal
rmgr: Heap len (rec/tot): 59/ 59, tx: 1608, lsn: 0/102BEB10, prev 0/102BE968, desc: INSERT+INIT off: 1, flags: 0x00, blkref #0: rel 1663/5/91165 blk 0
rmgr: Transaction len (rec/tot): 34/ 34, tx: 1608, lsn: 0/102BEB50, prev 0/102BEB10, desc: COMMIT 2026-07-15 21:15:04.305921 CST
rmgr: Standby len (rec/tot): 50/ 50, tx: 0, lsn: 0/102BEB78, prev 0/102BEB50, desc: RUNNING_XACTS nextXid 1609 latestCompletedXid 1608 oldestRunningXid 1609
延伸阅读
- WAL 原理图:
../backend/access/transam/assets/draw_wal_principle.md - 恢复:
../backend/access/transam/14_wal_recovery.md - 事务概览:
../backend/access/transam/01_overview.md
delete
在 PG 中,所谓的删除其实是标记死亡 + 空间异步回收。
打上“死亡标记”
执行 DELETE FROM tb WHERE a = 1; 时,磁盘上的数据并不会立即消失,而是发生了以下变化:
- 找到元组:通过索引或全表扫描找到
a=1的那行数据。 - 修改
t_xmax:在元组头(HeapTupleHeader)中,原本为 0 的t_xmax被填入了当前执行删除操作的事务 ID。 - 状态标记:
infomask会随之更新,记录该元组目前处于“被删除/锁定”的状态,取消标记与t_xmax相关的HEAP_XMAX_INVALID使其生效 - 物理现状:
ItemId依然是LP_NORMAL,指向地址8160。- 元组依然占据着那 28 字节的空间。
- 可见性判定:后续其他事务再来读时,发现
t_xmax有值且事务已提交,跳过这行。
ExecutePlan | ExecProcNode | ExecProcNodeFirst
ExecModifyTable
ExecDelete | ExecDeleteAct
table_tuple_delete
heapam_tuple_delete
heap_delete
compute_new_xmax_infomask
页内修剪(Page Pruning)
这是为了防止 Page 空间过早耗尽。当下次有事务访问这个 Page,或者 Page 空间不足时:
- 判断过期:根据
prune_xid判定该元组已经对所有活跃事务都不可见。 - 物理抹除:系统直接把
upper到页尾之间的这 28 字节“活元组”进行平移,抹掉死元组。 - 指针重设:
ItemId里的off被清空或重定向,但这个ItemId小方块本身还在。 - 空间释放:你图中的
free space区域会由于元组的抹除而物理增大。
彻底清理(VACUUM)
虽然元组物理消失了,但那个 ItemId 指针还在占用 pd_lower 的空间。
- 回收
ItemId:VACUUM确认没有任何索引再指向这个元组。 - 标记
LP_UNUSED:将ItemId的标志位改为LP_UNUSED。 - 循环利用:此时,
pd_lower计数器虽然没变,但这个“槽位”已经空出来了。下一个INSERT进来时,会优先抢占这个1号槽位,而不是去开辟4号槽位。
总结
- 逻辑删除 = 写
t_xmax:数据依然在磁盘,只是通过 MVCC 逻辑让别人“看不见”。 - 空间回收 = 移动
upper指针:通过Pruning或VACUUM把原本被占据的区域划归回free space。
update
drop table if exists tb;
create table tb(a int);
insert into tb values (1);
update tb set a = 1;
select * from tb; -- 触发延迟更新 t_infomask
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3 as cid, t_ctid, t_infomask2, t_infomask, t_hoff from heap_page_items(get_raw_page('tb', 0));
vacuum tb;
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3 as cid, t_ctid, t_infomask2, t_infomask, t_hoff from heap_page_items(get_raw_page('tb', 0));
核心目标:消除由于 UPDATE 导致的索引膨胀。在非 HOT 更新中,即使不修改索引列,由于元组物理位置(ctid)变了,也必须在索引中插入新记录。
物理实现:当更新不涉及索引列且当前 Page 有足够空间时,新元组会被打上 HEAP_ONLY_TUPLE (0x8000) 标记,且不再建立新的索引条目。
链条跳转:旧元组标记为 HEAP_HOT_UPDATED (0x4000),其 t_ctid 指向新元组。索引扫描时,先找到旧元组,再顺着 Page 内部的物理链条“跳”到新元组。
空间收割:通过“页内修剪”(Page Pruning),系统可以物理删除中间的死元组,并将索引指向的 ItemId 直接重定向(LP_REDIRECT)到最新的 ItemId,从而彻底斩断冗长的物理链条。
crash recovery
CHECKPOINT → INSERT + COMMIT → kill -9 → 重启 Startup redo。机制见 15_crash_recovery_redo;写路径见 01_insert。仅本地 crash recovery。
时序
t0 CHECKPOINT -> CheckPoint.redo = R
t1 INSERT -> Heap INSERT WAL; page dirty in buffers
t2 COMMIT + XLogFlush -> COMMIT WAL on disk
t3 kill -9 postmaster -> no DB_SHUTDOWNED; buffers gone
t4 restart -> StartupXLOG -> PerformWalRecovery (R .. EndOfWAL)
崩溃后可信的是已 flush 的 WAL;堆文件页可能仍旧。
调用栈
StartupXLOG /* access/transam/xlog.c */
PerformWalRecovery /* access/transam/xlogrecovery.c */
ReadRecord
ApplyWalRecord
RmgrTable[rmid].rm_redo
// Heap INSERT:
heap_xlog_insert /* access/heap/heapam_xlog.c */
XLogReadBufferForRedo* /* -> BLK_RESTORED|DONE|NEEDS_REDO */
// Transaction COMMIT:
xact_redo_commit /* access/transam/xact.c */
// end-of-recovery checkpoint
本场景常见:未刷脏 → BLK_NEEDS_REDO;崩溃前已 CHECKPOINT/刷页 → BLK_DONE;带 FPI APPLY → BLK_RESTORED。分支条件见 15_crash_recovery_redo §4。
实验
DROP TABLE IF EXISTS tb;
CREATE TABLE tb(a int);
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS redo_anchor;
INSERT INTO tb VALUES (1);
COMMIT;
-- 立刻 kill -9 postmaster(勿 pg_ctl stop)
-- 重启后: SELECT * FROM tb; -- 应仍有行
pg_waldump -s <redo_anchor> -n 20 -p $PGDATA/pg_wal
# Heap INSERT ... then Transaction COMMIT ...
要稳定打到 NEEDS_REDO:提交后立刻杀,别再等刷脏。对照 DONE:提交后再 CHECKPOINT 再杀。
gdb
redo 在 Startup,不在 postmaster / backend。需 -g 构建(Compile)。
造数(可选)attach backend:break heap_insert / XLogFlush,跑完上节 SQL 后 kill -9 postmaster。
跟 Startup:
gdb --args /path/to/postgres -D $PGDATA
(gdb) break StartupXLOG
(gdb) break PerformWalRecovery
(gdb) break XLogReadBufferForRedoExtended
(gdb) break heap_xlog_insert
(gdb) set follow-fork-mode child
(gdb) set detach-on-fork on
(gdb) run
fork 到无关进程时 continue 直到 StartupXLOG,或 info inferiors / inferior <n>。在 XLogReadBufferForRedoExtended 返回处看 BLK_*。
Startup 太快时:在 PerformWalRecovery 入口临时 pg_usleep(30 * 1000000L);,pg_ctl start 后 gdb -p 到 postgres: startup,调完删 sleep。
约束:必须非正常退出才进 crash redo;backend 造数与 Startup redo 分两次 gdb。
tcop
tcop
tcop = traffic cop(调度前端请求到 parser / optimizer / executor 等)
命令分类
在 PostgreSQL 16 中,后端(Postgres 进程)通过解析前端(客户端,如 psql、libpq 程序)发送的消息类型标识(单字符)来区分请求类型
| 字符 | 函数/处理逻辑 | 核心职责 |
|---|---|---|
Q | exec_simple_query | 处理「简单查询」(Simple Query),是最基础的 SQL 执行入口 |
P | exec_parse_message | 处理「解析消息」(Parse),编译 SQL 文本为预备语句(Prepared Statement) |
B | exec_bind_message | 处理「绑定消息」(Bind),为预备语句绑定参数值,生成可执行的门户 |
E | exec_execute_message | 处理「执行消息」(Execute),执行已绑定参数的门户(Portal) |
F | HandleFunctionRequest | 处理「函数调用请求」(Function Call),直接调用后端函数(非 SQL 文本) |
S | finish_xact_command | 「同步消息」(Sync),标记一个事务块的结束,等待后端响应完成 |
典型场景
| 字符 | 典型场景 |
|---|---|
Q | 1. psql 直接执行 SELECT * FROM t;、INSERT INTO t VALUES(1); 等单行 SQL2. 客户端未使用预备语句(Prepare),直接发送 SQL 文本执行 3. 源码中该路径会跳过 Parse/Bind/Execute 流程,直接解析 → 规划 → 执行 SQL |
P | 1. 客户端执行 PREPARE stmt AS SELECT * FROM t WHERE id=$1; 时的解析阶段2. 批量执行相同 SQL 前的预编译(减少重复解析/规划开销) 3. 源码中会生成解析树/规划树,存储在门户(Portal)的上下文里 |
B | 1. 客户端执行 EXECUTE stmt(1); 前的参数绑定(将 $1 替换为具体值 1)2. 绑定参数类型/值到预备语句,确定执行上下文 3. 源码中会校验参数类型、填充执行计划的参数槽,关联 PortalContext |
E | 1. 客户端绑定参数后,触发实际的 SQL 执行(如 EXECUTE stmt)2. 游标(CURSOR)的 FETCH 操作(本质是执行门户获取部分结果)3. 源码中调用 ExecutorStart / ExecutorRun 执行计划,关联 PortalContext 和执行器上下文 |
F | 1. 客户端通过 libpq 直接调用 PostgreSQL 内置函数/自定义函数(如 pg_get_userbyid(10))2. 扩展插件通过前端消息直接触发函数执行,跳过 SQL 解析 3. 源码中直接定位函数 OID,执行函数并返回结果 |
S | 1. 客户端在 Parse/Bind/Execute 序列后发送 Sync,确保后端完成所有操作并返回状态 2. 事务结束时(如 COMMIT 后),同步后端状态与前端3. 源码中会重置事务状态,清理临时上下文,响应 CommandComplete 消息 |
核心流程关联(便于理解源码)
- 简单查询流程:
Q→ 直接解析/规划/执行 → 响应结果; - 预备语句流程(常用于 JDBC 等):
P(解析)→B(绑定)→E(执行)→C(关闭)→S(同步);
parser
Parser
-
相关函数:
pg_parse_query -
核心结构
RawStmt: container for any one statement’s raw parse tree
stmtmulti /* list */
toplevel_stmt /* aka: RawStmt */
Stmt /* regular SQL statement: START TRANSACTION; */
SelectStmt
InsertStmt
DeleteStmt
UpdateStmt
MergeStmt
/*...*/
TransactionStmtLegacy /* Legacy transaction statement (BEGIN) for PG compatibility */
typedef struct RawStmt
{
pg_node_attr(no_query_jumble)
NodeTag type;
Node *stmt; /* raw parse tree */
int stmt_location; /* start location, or -1 if unknown */
int stmt_len; /* length in bytes; 0 means "rest of string" */
} RawStmt;
SelectStmt
SelectStmt
select_no_parens
simple_select
SELECT opt_all_clause opt_target_list
into_clause from_clause where_clause
group_clause having_clause window_clause
{
SelectStmt *n = makeNode(SelectStmt);
n->targetList = $3;
n->intoClause = $4;
n->fromClause = $5;
n->whereClause = $6;
n->groupClause = ($7)->list;
n->groupDistinct = ($7)->distinct;
n->havingClause = $8;
n->windowClause = $9;
$$ = (Node *) n;
}
typedef struct SelectStmt
{
NodeTag type;
/* These fields are used only in "leaf" SelectStmts. */
List *distinctClause; /* NULL, list of DISTINCT ON exprs, or lcons(NIL,NIL) for all (SELECT DISTINCT) */
IntoClause *intoClause; /* target for SELECT INTO */
List *targetList; /* the target list (of ResTarget) */
List *fromClause; /* the FROM clause */
Node *whereClause; /* WHERE qualification */
List *groupClause; /* GROUP BY clauses */
bool groupDistinct; /* Is this GROUP BY DISTINCT? */
Node *havingClause; /* HAVING conditional-expression */
List *windowClause; /* WINDOW window_name AS (...), ... */
List *valuesLists; /* untransformed list of expression lists */
/* These fields are used in both "leaf" SelectStmts and upper-level SelectStmts. */
List *sortClause; /* sort clause (a list of SortBy's) */
Node *limitOffset; /* # of result tuples to skip */
Node *limitCount; /* # of result tuples to return */
LimitOption limitOption; /* limit type */
List *lockingClause; /* FOR UPDATE (list of LockingClause's) */
WithClause *withClause; /* WITH clause */
/* These fields are used only in upper-level SelectStmts. */
SetOperation op; /* type of set op */
bool all; /* ALL specified? */
struct SelectStmt *larg; /* left child */
struct SelectStmt *rarg; /* right child */
} SelectStmt;
select a, b from tb where a = 2; –––––> gram.y(bison) –––––> struct SelectStmt
simple_select
SELECT opt_all_clause opt_target_list /* optional */
into_clause from_clause where_clause
group_clause having_clause window_clause
{
SelectStmt *n = makeNode(SelectStmt);
n->targetList = $3;
n->intoClause = $4;
n->fromClause = $5;
n->whereClause = $6;
n->groupClause = ($7)->list;
n->groupDistinct = ($7)->distinct;
n->havingClause = $8;
n->windowClause = $9;
$$ = (Node *) n;
}
NB: opt_tartget_list is optional!!!
select a from tb;
select all a, b from tb;
select from tb;
涉及的其他子句:from_clause, opt_target_list, where_clause, …
Analyzer
Overview
语义分析任务
语义分析: 将以 RawStmt 节点为根的原始解析树(Parse Tree),转换为 Query 节点树
-
相关函数:
parse_analyze_fixedparams -
核心流程
RawStmt/parsetree ––> parse analysis(analyze.c) + ParseState ––> Query
- 关键结构:
ParseState: 语义分析的工作上下文(working context),转换ParseTree为Query结构的临时结构,生成Query后丢弃
/* State information used during parse analysis */
struct ParseState
{
const char *p_sourcetext; /* source text, or NULL if not available */
List *p_rtable; /* range table so far */
List *p_joinexprs; /* JoinExprs for RTE_JOIN p_rtable entries */
List *p_joinlist; /* join items so far (will become FromExpr node's fromlist) */
List *p_namespace; /* currently-referenceable RTEs (List of ParseNamespaceItem) */
ParseNamespaceItem *p_target_nsitem; /* target rel's NSItem, or NULL */
int p_next_resno; /* next targetlist resno to assign */
/* ... */
};
Query
typedef struct Query
{
NodeTag type;
CmdType commandType; /* select|insert|update|delete|merge|utility */
/* where did I come from? */
QuerySource querySource pg_node_attr(query_jumble_ignore);
/* ... */
Node *utilityStmt; /* non-null if commandType == CMD_UTILITY */
/* ... */
List *rtable; /* list of range table entries */
/* ... */
List *targetList; /* target list (of TargetEntry) */
/* ... */
Node *havingQual; /* qualifications applied to groups */
/* ... */
} Query;
查询分类
transformStmt 处理三种不同查询 select(select into 会被视为 create table as,在 transformOptionalselectInto 中处理)
exec_simple_query - pg_analyze_and_rewrite_fixedparams - pg_analyze_and_rewrite_fixedparams
transformTopLevelStmt - transformOptionalselectInto
transformStmt
| transformValuesClause /* values (1, 2); */
| transformselectStmt /* select a, b from tb; */
| transformSetOperationStmt /* select a, b from tb union values (1, 2);*/
DROP TABLE IF EXISTS tb;
CREATE TABLE tb AS select n AS a, n * 10 AS b, n * 100 AS c FROM generate_series(1, 5) AS n;
select a, b FROM tb where a = 2;
对象层级关系
| 层级 | 中文名称 | 核心作用 | 唯一标识 | 关联系统表 |
|---|---|---|---|---|
| Database | 数据库 | 最高级隔离单元(独立的系统表集合) | OID | pg_database |
| Schema | 模式 / 名称空间 | 数据库内的逻辑隔离单元 | OID | pg_namespace |
| Relation | 关系 | 模式内的核心对象(表/索引/视图等) | OID | pg_class |
| Column | 字段 | 关系内的最小数据单元 | OID+attrnum | pg_attribute |
参考文档:
- https://postgres-internals.cn/docs/chapter01/
- https://www.interdb.jp/pg/pgsql01/02.html
- download pdAdmin 4
查询分析总体流程
transformselectStmt
transformselectStmt
transformFromClause /* from tb */
transformTargetList /* select a, b */
transformWhereClause /* where a = 2 */
transformSortClause
transformGroupClause
transformDistinctClause
transformLimitClause
transformWindowDefinitions
transformLockingClause
/* ... */
分析表名 from tb
函数: transformFromClause(pstate, stmt->fromClause); + transformFromClauseItem
任务: Process the FROM clause and add items to the query’s range table, joinlist, and namespace.
表的多种抽象形式
- 文本标识:
tb --- Identifier —— RangeVar - 语法分析:
RangeVar --- selectStmt::fromClause - 语义分析:
RangeTableEntry --- ParseState::p_rtable --- Query::p_rtable - 名称空间:
NamespaceItem --- ParseState::p_namespace - 优化结构:
RelOptInfo: TODO - 关系缓存:
Relation ---- relation_open() - 持久数据:
pg_class+pg_attribute,pg_attrdef,pg_index,pg_constraint,pg_rewrite, …
SQL: FROM tb
↓
RangeVar —— 名字
↓
RangeTblEntry —— 语义对象(作用域)
↓
Var —— 列绑定(编号)
↓
RelOptInfo —— 优化对象(代价)
↓
Relation —— 物理对象(存储)
Query查询树中的关键抽象: Range Table(范围清单) : A range table is a List of RangeTblEntry nodes.
typedef struct Query
{
/* ... */
List *rtable; /* list of range table entries: RangeTblEntry */
/* ... */
} Query;
typedef struct RangeTblEntry
{
RTEKind rtekind; /* Range kind */
Oid relid; /* OID of the relation */
char relkind; /* relation kind */
/* ... */
} RangeTblEntry;
typedef enum RTEKind
{
RTE_RELATION, /* ordinary relation reference */
RTE_SUBQUERY, /* subquery in FROM */
RTE_JOIN, /* join */
/* ... */
} RTEKind;
transformFromClause
transformFromClauseItem
transformTableEntry
addRangeTableEntry
RangeVar -> parserOpenTable() -> Relation
/* build RTE */
pstate->p_rtable = lappend(pstate->p_rtable, rte);
buildNSItemFromTupleDesc
- 通过语法分析得到的表名称被封装在
RangeVar变量中 parserOpenTable语义分析的重要任务: 访问元数据检索Relation- 将
relid添加到RangeTblEntry中并构建名称空间Item以供后续分析列
检索表结构Relation
parserOpenTable 如何根据 RangeVar(表名) 找到 Relation 结构?
- 核心缓存定义
RelCache (关系描述符缓存)
- 本质:表的句柄,以动态哈希表存储
RelationData(重型对象) - 内容:封装元组描述、锁信息及存储状态,是内核操作表的物理入口
- 约束:底层物理检索仅支持
relid唯一键【无法直接通过RangeVar检索】
SysCache (系统元组缓存)
- 缓存:建立在系统表唯一索引(常用索引)之上的内存哈希缓存
- 封装:
CatCTup,封装了来自基表的完整元组(Heap Tuple) - 优势:无需“回表”。一旦命中,直接返回指向内存副本的指针,性能比索引扫描快数十倍
pg_class上的索引:
"pg_class_oid_index" PRIMARY KEY, btree (oid)
"pg_class_relname_nsp_index" UNIQUE CONSTRAINT, btree (relname, relnamespace)
对应的SysCache缓存:
SysCache[RELOID]
SysCache[RELNAMENSP]
2 parserOpenTable 执行逻辑
-
获取
relid(RangeVar->relid)- 由于
RelCache不支持字符串查找,内核首先访问SysCache(具体为RELNAMENSP缓存) - 利用
RangeVar提供的表名和 Schema 信息进行匹配,获取该表的唯一身份 id:relid
- 由于
-
查找表结构 (
relid->Relation)- 拿到
relid后,内核转而访问RelCache - 通过
relid这一唯一键检索RelationIdCache哈希表
- 拿到
RangeVar –> relid –> Relation
parserOpenTable /* parser/parse_relation.c: parser support routines dealing with relations */
table_openrv_extended /* access/table/table.c: Generic routines for table related code*/
relation_openrv_extended /* access/common/relation.c: Generic relation related routines */
relOid = RangeVarGetRelidExtended /* catlog/namespace.c: searching namespaces */
relation_open(relOid) /* access/common/relation.c: open any relation by relation OID */
Relation rd;
RelationIdCacheLookup /* utils/cache/relcache.c: Lookup a reldesc by OID */
RelationIncrementReferenceCount
return rd;
RangeVarGetRelidExtended 内部查找过程
RangeVarGetRelidExtended
RelnameGetRelid
get_relname_relid /* utils/cache/lsyscache.c: routines for common queries in system catalog cache */
GetSysCacheOid /* utils/cache/syscache.c: System cache management routines*/
tuple = SearchSysCache /* get tuple */
SearchCatCache /* utils/cache/catcache.c: System catalog cache for tuples matching a key*/
SearchCatCacheInternal /* hash and iterate */
return heap_getattr(tuple, oidcol, ...);
系统目录缓存管理抽象层次:
lsyscache.c: 封装syscache的轻量 APIsyscache.c: 逻辑索引缓存管理层catcache.c: 底层元组缓存实现
系统表缓存 SysCache
https://cloud.tencent.com/developer/article/2000765?from_column=20421&from=20421
添加名称空间 buildNSItemFromTupleDesc
为什么 PG 需要“名称空间” (NSItem)?
两种别名方式
-
select a as x, b as y from tb; -
select x, y, c from tb as t(x, y);(仅 PG 支持)
在 PostgreSQL 中,这两种方式分别对应 “投影别名” 和 “数据源别名”。
| 方式 | 语法示例 | 生效阶段 | 核心作用 |
|---|---|---|---|
| 投影别名 | select a AS x ... | 输出层 (Output) | 修饰性:重命名输出列 |
| 数据源别名 | FROM tb AS t(x, y) | 输入层 (Input) | 结构性:重定义表结构标识 |
为什么需要数据源别名?
- 解决“无名数据”的定义问题
select a from (values (1), (2), (3)) as tb(a) where a < 3;
- 简化复杂查询的引用
select x, y, c from tb as t(x, y) where x < 3;
- 表结构标识重命名
buildRelationAliases 合并用户定义的别名和原列名形成完整的别名结构 Alias 保存到 RTE 的 eref 字段
分析列名 select a, b
qry->targetList = transformTargetList
transformTargetList /* parser/parse_target.c */
transformTargetEntry
transformExpr --> expr/* parser/parser_expr.c */
transformExprRecurse
transformColumnRef
colNameToVar /* parser/parser_relation.c*/
scanNSItemForColumn
scanRTEForColumn /* Scan the nsitem's column names (or aliases) for a match */
foreach(c, eref->colnames) /* Scan the user column names (or aliases) for a match */
specialAttNum(colname) /* quick check to see if name could be a system column */
SystemAttributeByName /* ctid, xmin, cmin, xmax, cmax, tableoid */
SearchSysCacheExists2
makeVar
makeTargetEntry
tle->expr, tle->resno, tle->resname
return TargetEntry
FigureColname
makeTargetEntry /* creates a TargetEntry node */
分析过滤条件 where a = 2
qual = transformWhereClause
操作符元数据查询,pg_operator 元数据中重点关注
select * from pg_operator where oprname = '=';
select * from pg_operator where oid = 96;
select * from pg_type where oid = 23;
prname: 操作符名称oprleft: 左操作符类型oidoprright: 右操作符类型oidoprcode: 操作符的函数实现
语义分析过程
transformWhereClause
transformExpr
transformExprRecurse
transformAExprOp
lexpr = transformExprRecurse(pstate, lexpr);
transformColumnRef
rexpr = transformExprRecurse(pstate, rexpr);
make_const
make_op
oper(pstate, opname, ltypeId, rtypeId, false, location);
make_oper_cache_key
find_oper_cache_entry
result->opno = oprid(tup); /* pg_operator: oid=96 | oprname='=' | oprcode='int4eq' */
result->opfuncid = opform->oprcode; /*op_proc: oid=65 | proname='int4eq' */
result->args = args
qry->jointree = makeFromExpr(pstate->p_joinlist, qual);
executor
Executor
执行器生命周期
| 阶段 | 核心函数 | 关键动作 | 节点操作 |
|---|---|---|---|
| Init | ExecutorStart | 解析计划树,构建运行时状态树,打开表,编译表达式。 | ExecInitNode: Plan -> PlanState |
| Run | ExecutorRun | 循环拉取数据,逐行处理,发送给客户端 | ExecProcNode: TupleTableSlot, ExprContext |
| Finish | ExecutorFinish | 执行排队的 AFTER 触发器,更新统计信息 | AfterTriggerEndQuery |
| End | ExecutorEnd | 关闭文件/扫描描述符,销毁临时占用资源 | ExecEndNode |
执行流程梳理
/* ... */
/* Portal & Executor */
CreatePortal
PortalDefineQuery // portal->stmts = plantree_list;
PortalStart // Prepare a portal for execution. params, strategy, queryDesc
ExecutorStart // prepare the plan for execution
standard_ExecutorStart
InitPlan /* Initialize the plan state tree */
**ExecInitNode** | ExecInitSeqScan
ExecOpenScanRelation
PORTAL_READY
PortalRun | PortalRunSelect
/* Executor */
ExecutorRun | tandard_ExecutorRun | ExecutePlan // Processes the query plan until retrieved 'numberTuples' tuples
**ExecProcNode** | ExecSeqScan
ExecScan | ExecScanFetch | SeqNext // executor module
/* Access + Storage*/
table_scan_getnextslot | heap_getnextslot | heapgettup_pagemode
heapgetpage | ReadBufferExtended | ReadBuffer_common
PortalDrop
PortalCleanup
ExecutorFinish
ExecPostprocessPlan
AfterTriggerEndQuery
ExecutorEnd
ExecEndPlan
**ExecEndNode** | ExecEndSeqScan
FreeQueryDesc
Executor
数据流转路径
Client<---->TCop<---->Portal<---->Executor<---->Access<---->[ Buffer/WAL ]<---->Storage
Storage --> Access:数据从 磁盘 Page(二进制块)转换成了 HeapTuple(原始行)。Access --> Executor:数据从 物理行 被包装进了 TupleTableSlot(统一的槽位,屏蔽了是索引行还是表行的差异)。Executor --> Portal:数据经过计算,变成了 最终结果行。Portal --> Client:数据被DestReceiver序列化为 网络字节流。
Portal 生命周期
CreatePortal /* Create unnamed portal to run the query or queries in */
portal->status = PORTAL_NEW;
PortalDefineQuery /* A simple subroutine to establish a portal's query */
portal->stmts = stmts
portal->status = PORTAL_DEFINED;
PortalStart /* Prepare a portal for execution */
CreateQueryDesc /* Create QueryDesc in portal's context */
qd->plannedstmt = plannedstmt
qd->snapshot = RegisterSnapshot(snapshot); /* snapshot */
ExecutorStart
standard_ExecutorStart
CreateExecutorState
InitPlan
planstate = ExecInitNode(plan, estate, eflags);
ExecInitSeqScan
scanstate->ss.ps.plan = (Plan *) node;
scanstate->ss.ps.ExecProcNode = ExecSeqScan;
tupType = ExecGetResultType(planstate);
queryDesc->tupDesc = tupType;
queryDesc->planstate = planstate;
portal->queryDesc = queryDesc
portal->tupDesc = queryDesc->tupDesc;
receiver = CreateDestReceiver(dest);
portal->status = PORTAL_READY;
PortalRun /* Run a portal's query or queries */
MarkPortalActive
portal->status = PORTAL_ACTIVE;
PortalRunSelect
ExecutorRun - tandard_ExecutorRun - ExecutePlan
/* It accepts the query descriptor from the traffic cop and executes the query plan */
portal->status = PORTAL_READY;
PortalDrop /* PORTAL_DEFINED */
PortalCleanup
ExecutorFinish
standard_ExecutorFinish
ExecutorEnd
standard_ExecutorEnd
FreeExecutorState
ExecutePlan
Processes the query plan until we have retrieved ‘numberTuples’ tuples, moving in the specified direction.
/* Loop until we've processed the proper number of tuples from the plan. */
for (;;)
{
/* Reset the per-output-tuple exprcontext */
ResetPerTupleExprContext(estate);
/* Execute the plan and obtain a tuple */
slot = ExecProcNode(planstate);
/* send the tuple somewhere */
dest->receiveSlot(slot, dest)
/*
* check our tuple count.. if we've processed the proper number then
* quit, else loop again and process more tuples. Zero numberTuples
* means no limit.
*/
current_tuple_count++;
if (numberTuples && numberTuples == current_tuple_count)
break;
}
ExecProcNode
ExecProcNode - ExecSeqScan
ExecScan - ExecScanFetch - SeqNext // executor module
/* Access + Storage*/
table_scan_getnextslot - heap_getnextslot - heapgettup_pagemode
heapgetpage
ReadBufferExtended | ReadBuffer_common
LockBuffer(buffer, BUFFER_LOCK_SHARE);
BufferGetPage - BufferGetBlock
return (Block) (BufferBlocks + ((Size) (buffer - 1)) * BLCKSZ);
for (lineoff = FirstOffsetNumber; lineoff <= lines; lineoff++)
PageGetItemId // Returns an item identifier of a page.
return &((PageHeader) page)->pd_linp[offsetNumber - 1];
PageGetItem // Retrieves an item on the given page.
return (Item) (((char *) page) + ItemIdGetOffset(itemId));
// True if heap tuple satisfies a time qual
HeapTupleSatisfiesVisibility - HeapTupleSatisfiesMVCC
HeapCheckForSerializableConflictOut
scan->rs_vistuples[ntup++] = lineoff;
LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
EState
| 结构体 | 存在的层级 | 生命周期 | 核心协同作用 |
|---|---|---|---|
| Portal | 顶层 | 会话级/Cursor级 | 管理查询执行的任务流 |
| QueryDesc | 执行前 | 执行周期级 | 封装执行器所需的全部入参、计划和快照 |
| EState | 执行期间 | 执行周期级 | 管理全局内存上下文、元组槽表和事务快照 |
| PlanState | 执行期间 | 执行周期级 | 算子执行算子,负责递归计算和状态维护 |
| TupleTableSlot | 算子间 | 算子执行级 | 算子间数据传递的容器,减少拷贝开销 |
access
nbtree
src/backend/access/nbtree/README
Btree Indexing(B树索引)
该目录实现了 Lehman-Yao 高并发B树管理算法(论文:P. Lehman 与 S. Yao,《Efficient Locking for Concurrent Operations on B-Trees》,ACM Transactions on Database Systems,第6卷第4期,1981年12月,650-670页)。 同时简化实现了 Lanin & Shasha 提出的删除逻辑(论文:V. Lanin 与 D. Shasha,《A Symmetric Concurrent B-Tree Algorithm》,1986年秋季联合计算机会议论文集,380-389页)。
Lehman & Yao 基础算法原理
相较于经典B树,L&Y算法给每一页新增两个核心结构:
- 右兄弟指针(right-link):指向当前页面的右侧兄弟页;
- 高位键(high key):当前页面允许存储的所有键值的上界。
依靠这两个新增结构,事务可以感知页面被并发分裂的场景;查询过程全程无需持有读锁(仅在读单页时短暂防止页面被并发修改)。
查询流程逻辑: 当遍历器通过向下指针进入子页面时,会对比查询键与当前页的高位键:
- 若查询键大于页面高位键,说明该页已被其他事务并发分裂;
- 必须沿着右兄弟指针跳转,去新页面中查找目标键区间;
- 若页面发生过多次分裂,则该跳转逻辑需要循环执行,直到定位到正确页面。
Lehman & Yao 原文中,内部页面交替存储 分隔键(separator) 与向下指针,而非数据元组/记录。 本实现使用术语 枢轴元组(pivot tuple) 描述一类特殊元组:它不指向堆表数据,仅用于树结构路由导航。
- 所有非叶子页上的元组、叶子页的高位键,均属于枢轴元组;
- 枢轴元组仅用于划分各页面的键值域,其字段值可以复用那些被VACUUM清理删除的普通数据元组字段;
- 枢轴元组存在三种形态:
- 同时包含分隔键与向下指针;
- 仅含分隔键(向下指针隐式无效);
- 仅含向下指针(所有字段均被后缀截断)。
索引键唯一性保证
所有B树索引键的唯一性,依靠 堆表TID(行号) 作为 排序决胜字段 实现:逻辑重复的索引键,会按照堆表TID排序区分。 该唯一性是L&Y算法的硬性前提:父页面中相邻两个键 $K_i$、$K_{i+1}$ 对应的子树S,其值域必须满足 $K_i < v \le K_{i+1}$;区间左边界为严格小于,只有全局唯一键才能稳定满足该约束。树中同一层级的所有键均唯一,仅有一个例外:叶子页的高位键允许和页内最后一条记录的键完全相等。
PostgreSQL 的 后缀截断(suffix truncation) 实现必须保证 L&Y 算法的不变式恒成立;对于枢轴元组中被截断、缺失的字段,统一用哨兵值负无穷(minus infinity) 表示。后文专门讲解后缀截断的章节会结合实例,清晰说明 L&Y 不变式在工程实现中的约束规则。
Differences to the Lehman & Yao algorithm
We have made the following changes in order to incorporate the L&Y algorithm into Postgres:
Lehman and Yao don’t require read locks, but assume that in-memory copies of tree pages are unshared. Postgres shares in-memory buffers among backends. As a result, we do page-level read locking on btree pages in order to guarantee that no record is modified while we are examining it. This reduces concurrency but guarantees correct behavior.
We support the notion of an ordered “scan” of an index as well as insertions, deletions, and simple lookups. A scan in the forward direction is no problem, we just use the right-sibling pointers that L&Y require anyway. (Thus, once we have descended the tree to the correct start point for the scan, the scan looks only at leaf pages and never at higher tree levels.) To support scans in the backward direction, we also store a “left sibling” link much like the “right sibling”. (This adds an extra step to the L&Y split algorithm: while holding the write lock on the page being split, we also lock its former right sibling to update that page’s left-link. This is safe since no writer of that page can be interested in acquiring a write lock on our page.) A backwards scan has one additional bit of complexity: after following the left-link we must account for the possibility that the left sibling page got split before we could read it. So, we have to move right until we find a page whose right-link matches the page we came from. (Actually, it’s even harder than that; see page deletion discussion below.)
Page read locks are held only for as long as a scan is examining a page. To minimize lock/unlock traffic, an index scan always searches a leaf page to identify all the matching items at once, copying their heap tuple IDs into backend-local storage. The heap tuple IDs are then processed while not holding any page lock within the index. We do continue to hold a pin on the leaf page in some circumstances, to protect against concurrent deletions (see below). In this state the scan is effectively stopped “between” pages, either before or after the page it has pinned. This is safe in the presence of concurrent insertions and even page splits, because items are never moved across pre-existing page boundaries — so the scan cannot miss any items it should have seen, nor accidentally return the same item twice. The scan must remember the page’s right-link at the time it was scanned, since that is the page to move right to; if we move right to the current right-link then we’d re-scan any items moved by a page split. We don’t similarly remember the left-link, since it’s best to use the most up-to-date left-link when trying to move left (see detailed move-left algorithm below).
In most cases we release our lock and pin on a page before attempting to acquire pin and lock on the page we are moving to. In a few places it is necessary to lock the next page before releasing the current one. This is safe when moving right or up, but not when moving left or down (else we’d create the possibility of deadlocks).
Lehman and Yao fail to discuss what must happen when the root page becomes full and must be split. Our implementation is to split the root in the same way that any other page would be split, then construct a new root page holding pointers to both of the resulting pages (which now become siblings on the next level of the tree). The new root page is then installed by altering the root pointer in the meta-data page (see below). This works because the root is not treated specially in any other way — in particular, searches will move right using its link pointer if the link is set. Therefore, searches will find the data that’s been moved into the right sibling even if they read the meta-data page before it got updated. This is the same reasoning that makes a split of a non-root page safe. The locking considerations are similar too.
When an inserter recurses up the tree, splitting internal pages to insert links to pages inserted on the level below, it is possible that it will need to access a page above the level that was the root when it began its descent (or more accurately, the level that was the root when it read the meta-data page). In this case the stack it made while descending does not help for finding the correct page. When this happens, we find the correct place by re-descending the tree until we reach the level one above the level we need to insert a link to, and then moving right as necessary. (Typically this will take only two fetches, the meta-data page and the new root, but in principle there could have been more than one root split since we saw the root. We can identify the correct tree level by means of the level numbers stored in each page. The situation is rare enough that we do not need a more efficient solution.)
Lehman and Yao must couple/chain locks as part of moving right when relocating a child page’s downlink during an ascent of the tree. This is the only point where Lehman and Yao have to simultaneously hold three locks (a lock on the child, the original parent, and the original parent’s right sibling). We don’t need to couple internal page locks for pages on the same level, though. We match a child’s block number to a downlink from a pivot tuple one level up, whereas Lehman and Yao match on the separator key associated with the downlink that was followed during the initial descent. We can release the lock on the original parent page before acquiring a lock on its right sibling, since there is never any need to deal with the case where the separator key that we must relocate becomes the original parent’s high key. Lanin and Shasha don’t couple locks here either, though they also don’t couple locks between levels during ascents. They are willing to “wait and try again” to avoid races. Their algorithm is optimistic, which means that “an insertion holds no more than one write lock at a time during its ascent”. We more or less stick with Lehman and Yao’s approach of conservatively coupling parent and child locks when ascending the tree, since it’s far simpler.
Lehman and Yao assume fixed-size keys, but we must deal with variable-size keys. Therefore there is not a fixed maximum number of keys per page; we just stuff in as many as will fit. When we split a page, we try to equalize the number of bytes, not items, assigned to pages (though suffix truncation is also considered). Note we must include the incoming item in this calculation, otherwise it is possible to find that the incoming item doesn’t fit on the split page where it needs to go!
Deleting index tuples during VACUUM
Before deleting a leaf item, we get a full cleanup lock on the target page, so that no other backend has a pin on the page when the deletion starts. This is not necessary for correctness in terms of the btree index operations themselves; as explained above, index scans logically stop “between” pages and so can’t lose their place. The reason we do it is to provide an interlock between VACUUM and index scans that are not prepared to deal with concurrent TID recycling when visiting the heap. Since only VACUUM can ever mark pointed-to items LPUNUSED in the heap, and since this only ever happens _after btbulkdelete returns, having index scans hold on to the pin (used when reading from the leaf page) until after they’re done visiting the heap (for TIDs from pinned leaf page) prevents concurrent TID recycling. VACUUM cannot get a conflicting cleanup lock until the index scan is totally finished processing its leaf page.
This approach is fairly coarse, so we avoid it whenever possible. In practice most index scans won’t hold onto their pin, and so won’t block VACUUM. These index scans must deal with TID recycling directly, which is more complicated and not always possible. See later section on making concurrent TID recycling safe.
Opportunistic index tuple deletion performs almost the same page-level modifications while only holding an exclusive lock. This is safe because there is no question of TID recycling taking place later on – only VACUUM can make TIDs recyclable. See also simple deletion and bottom-up deletion, below.
Because a pin is not always held, and a page can be split even while someone does hold a pin on it, it is possible that an indexscan will return items that are no longer stored on the page it has a pin on, but rather somewhere to the right of that page. To ensure that VACUUM can’t prematurely make TIDs recyclable in this scenario, we require btbulkdelete to obtain a cleanup lock on every leaf page in the index, even pages that don’t contain any deletable tuples. Note that this requirement does not say that btbulkdelete must visit the pages in any particular order.
VACUUM’s linear scan, concurrent page splits
VACUUM accesses the index by doing a linear scan to search for deletable TIDs, while considering the possibility of deleting empty pages in passing. This is in physical/block order, not logical/keyspace order. The tricky part of this is avoiding missing any deletable tuples in the presence of concurrent page splits: a page split could easily move some tuples from a page not yet passed over by the sequential scan to a lower-numbered page already passed over.
To implement this, we provide a “vacuum cycle ID” mechanism that makes it possible to determine whether a page has been split since the current btbulkdelete cycle started. If btbulkdelete finds a page that has been split since it started, and has a right-link pointing to a lower page number, then it temporarily suspends its sequential scan and visits that page instead. It must continue to follow right-links and vacuum dead tuples until reaching a page that either hasn’t been split since btbulkdelete started, or is above the location of the outer sequential scan. Then it can resume the sequential scan. This ensures that all tuples are visited. It may be that some tuples are visited twice, but that has no worse effect than an inaccurate index tuple count (and we can’t guarantee an accurate count anyway in the face of concurrent activity). Note that this still works if the has-been-recently-split test has a small probability of false positives, so long as it never gives a false negative. This makes it possible to implement the test with a small counter value stored on each index page.
Deleting entire pages during VACUUM
We consider deleting an entire page from the btree only when it’s become completely empty of items. (Merging partly-full pages would allow better space reuse, but it seems impractical to move existing data items left or right to make this happen — a scan moving in the opposite direction might miss the items if so.) Also, we never delete the rightmost page on a tree level (this restriction simplifies the traversal algorithms, as explained below). Page deletion always begins from an empty leaf page. An internal page can only be deleted as part of deleting an entire subtree. This is always a “skinny” subtree consisting of a “chain” of internal pages plus a single leaf page. There is one page on each level of the subtree, and each level/page covers the same key space.
Deleting a leaf page is a two-stage process. In the first stage, the page is unlinked from its parent, and marked as half-dead. The parent page must be found using the same type of search as used to find the parent during an insertion split. We lock the target and the parent pages, change the target’s downlink to point to the right sibling, and remove its old downlink. This causes the target page’s key space to effectively belong to its right sibling. (Neither the left nor right sibling pages need to change their “high key” if any; so there is no problem with possibly not having enough space to replace a high key.) At the same time, we mark the target page as half-dead, which causes any subsequent searches to ignore it and move right (or left, in a backwards scan). This leaves the tree in a similar state as during a page split: the page has no downlink pointing to it, but it’s still linked to its siblings.
(Note: Lanin and Shasha prefer to make the key space move left, but their argument for doing so hinges on not having left-links, which we have anyway. So we simplify the algorithm by moving the key space right. This is only possible because we don’t match on a separator key when ascending the tree during a page split, unlike Lehman and Yao/Lanin and Shasha – it doesn’t matter if the downlink is re-found in a pivot tuple whose separator key does not match the one encountered when inserter initially descended the tree.)
To preserve consistency on the parent level, we cannot merge the key space of a page into its right sibling unless the right sibling is a child of the same parent — otherwise, the parent’s key space assignment changes too, meaning we’d have to make bounding-key updates in its parent, and perhaps all the way up the tree. Since we can’t possibly do that atomically, we forbid this case. That means that the rightmost child of a parent node can’t be deleted unless it’s the only remaining child, in which case we will delete the parent too (see below).
In the second-stage, the half-dead leaf page is unlinked from its siblings. We first lock the left sibling (if any) of the target, the target page itself, and its right sibling (there must be one) in that order. Then we update the side-links in the siblings, and mark the target page deleted.
When we’re about to delete the last remaining child of a parent page, things are slightly more complicated. In the first stage, we leave the immediate parent of the leaf page alone, and remove the downlink to the parent page instead, from the grandparent. If it’s the last child of the grandparent too, we recurse up until we find a parent with more than one child, and remove the downlink of that page. The leaf page is marked as half-dead, and the block number of the page whose downlink was removed is stashed in the half-dead leaf page. This leaves us with a chain of internal pages, with one downlink each, leading to the half-dead leaf page, and no downlink pointing to the topmost page in the chain.
While we recurse up to find the topmost parent in the chain, we keep the leaf page locked, but don’t need to hold locks on the intermediate pages between the leaf and the topmost parent – insertions into upper tree levels happen only as a result of splits of child pages, and that can’t happen as long as we’re keeping the leaf locked. The internal pages in the chain cannot acquire new children afterwards either, because the leaf page is marked as half-dead and won’t be split.
Removing the downlink to the top of the to-be-deleted subtree/chain effectively transfers the key space to the right sibling for all the intermediate levels too, in one atomic operation. A concurrent search might still visit the intermediate pages, but it will move right when it reaches the half-dead page at the leaf level. In particular, the search will move to the subtree to the right of the half-dead leaf page/to-be-deleted subtree, since the half-dead leaf page’s right sibling must be a “cousin” page, not a “true” sibling page (or a second cousin page when the to-be-deleted chain starts at leaf page’s grandparent page, and so on).
In the second stage, the topmost page in the chain is unlinked from its siblings, and the half-dead leaf page is updated to point to the next page down in the chain. This is repeated until there are no internal pages left in the chain. Finally, the half-dead leaf page itself is unlinked from its siblings.
A deleted page cannot be recycled immediately, since there may be other processes waiting to reference it (ie, search processes that just left the parent, or scans moving right or left from one of the siblings). These processes must be able to observe a deleted page for some time after the deletion operation, in order to be able to at least recover from it (they recover by moving right, as with concurrent page splits). Searchers never have to worry about concurrent page recycling.
See “Placing deleted pages in the FSM” section below for a description of when and how deleted pages become safe for VACUUM to make recyclable.
Page deletion and backwards scans
Moving left in a backward scan is complicated because we must consider the possibility that the left sibling was just split (meaning we must find the rightmost page derived from the left sibling), plus the possibility that the page we were just on has now been deleted and hence isn’t in the sibling chain at all anymore. So the move-left algorithm becomes:
- Remember the page we are on as the “original page”.
- Follow the original page’s left-link (we’re done if this is zero).
- If the current page is live and its right-link matches the “original page”, we are done.
- Otherwise, move right one or more times looking for a live page whose right-link matches the “original page”. If found, we are done. (In principle we could scan all the way to the right end of the index, but in practice it seems better to give up after a small number of tries. It’s unlikely the original page’s sibling split more than a few times while we were in flight to it; if we do not find a matching link in a few tries, then most likely the original page is deleted.)
- Return to the “original page”. If it is still live, return to step 1 (we guessed wrong about it being deleted, and should restart with its current left-link). If it is dead, move right until a non-dead page is found (there must be one, since rightmost pages are never deleted), mark that as the new “original page”, and return to step 1.
This algorithm is correct because the live page found by step 4 will have the same left keyspace boundary as the page we started from. Therefore, when we ultimately exit, it must be on a page whose right keyspace boundary matches the left boundary of where we started — which is what we need to be sure we don’t miss or re-scan any items.
Page deletion and tree height
Because we never delete the rightmost page of any level (and in particular never delete the root), it’s impossible for the height of the tree to decrease. After massive deletions we might have a scenario in which the tree is “skinny”, with several single-page levels below the root. Operations will still be correct in this case, but we’d waste cycles descending through the single-page levels. To handle this we use an idea from Lanin and Shasha: we keep track of the “fast root” level, which is the lowest single-page level. The meta-data page keeps a pointer to this level as well as the true root. All ordinary operations initiate their searches at the fast root not the true root. When we split a page that is alone on its level or delete the next-to-last page on a level (both cases are easily detected), we have to make sure that the fast root pointer is adjusted appropriately. In the split case, we do this work as part of the atomic update for the insertion into the parent level; in the delete case as part of the atomic update for the delete (either way, the metapage has to be the last page locked in the update to avoid deadlock risks). This avoids race conditions if two such operations are executing concurrently.
Placing deleted pages in the FSM
Recycling a page is decoupled from page deletion. A deleted page can only be put in the FSM to be recycled once there is no possible scan or search that has a reference to it; until then, it must stay in place with its sibling links undisturbed, as a tombstone that allows concurrent searches to detect and then recover from concurrent deletions (which are rather like concurrent page splits to searchers). This design is an implementation of what Lanin and Shasha call “the drain technique”.
We implement the technique by waiting until all active snapshots and registered snapshots as of the page deletion are gone; which is overly strong, but is simple to implement within Postgres. When marked fully dead, a deleted page is labeled with the next-transaction counter value. VACUUM can reclaim the page for re-use when the stored XID is guaranteed to be “visible to everyone”. As collateral damage, we wait for snapshots taken until the next transaction to allocate an XID commits. We also wait for running XIDs with no snapshots.
Prior to PostgreSQL 14, VACUUM would only place old deleted pages that it encounters during its linear scan (pages deleted by a previous VACUUM operation) in the FSM. Newly deleted pages were never placed in the FSM, because that was assumed to always be unsafe. That assumption was unnecessarily pessimistic in practice, though – it often doesn’t take very long for newly deleted pages to become safe to place in the FSM. There is no truly principled way to predict when deleted pages will become safe to place in the FSM for recycling – it might become safe almost immediately (long before the current VACUUM completes), or it might not even be safe by the time the next VACUUM takes place. Recycle safety is purely a question of maintaining the consistency (or at least the apparent consistency) of a physical data structure. The state within the backend running VACUUM is simply not relevant.
PostgreSQL 14 added the ability for VACUUM to consider if it’s possible to recycle newly deleted pages at the end of the full index scan where the page deletion took place. It is convenient to check if it’s safe at that point. This does require that VACUUM keep around a little bookkeeping information about newly deleted pages, but that’s very cheap. Using in-memory state for this avoids the need to revisit newly deleted pages a second time later on – we can just use safexid values from the local bookkeeping state to determine recycle safety in a deferred fashion.
The need for additional FSM indirection after a page deletion operation takes place is a natural consequence of the highly permissive rules for index scans with Lehman and Yao’s design. In general an index scan doesn’t have to hold a lock or even a pin on any page when it descends the tree (nothing that you’d usually think of as an interlock is held “between levels”). At the same time, index scans cannot be allowed to land on a truly unrelated page due to concurrent recycling (not to be confused with concurrent deletion), because that results in wrong answers to queries. Simpler approaches to page deletion that don’t need to defer recycling are possible, but none seem compatible with Lehman and Yao’s design.
Placing an already-deleted page in the FSM to be recycled when needed doesn’t actually change the state of the page. The page will be changed whenever it is subsequently taken from the FSM for reuse. The deleted page’s contents will be overwritten by the split operation (it will become the new right sibling page).
Making concurrent TID recycling safe
As explained in the earlier section about deleting index tuples during VACUUM, we implement a locking protocol that allows individual index scans to avoid concurrent TID recycling. Index scans opt-out (and so drop their leaf page pin when visiting the heap) whenever it’s safe to do so, though. Dropping the pin early is useful because it avoids blocking progress by VACUUM. This is particularly important with index scans used by cursors, since idle cursors sometimes stop for relatively long periods of time. In extreme cases, a client application may hold on to an idle cursors for hours or even days. Blocking VACUUM for that long could be disastrous.
Index scans that don’t hold on to a buffer pin are protected by holding an MVCC snapshot instead. This more limited interlock prevents wrong answers to queries, but it does not prevent concurrent TID recycling itself (only holding onto the leaf page pin while accessing the heap ensures that).
Index-only scans can never drop their buffer pin, since they are unable to tolerate having a referenced TID become recyclable. Index-only scans typically just visit the visibility map (not the heap proper), and so will not reliably notice that any stale TID reference (for a TID that pointed to a dead-to-all heap item at first) was concurrently marked LP_UNUSED in the heap by VACUUM. This could easily allow VACUUM to set the whole heap page to all-visible in the visibility map immediately afterwards. An MVCC snapshot is only sufficient to avoid problems during plain index scans because they must access granular visibility information from the heap proper. A plain index scan will even recognize LP_UNUSED items in the heap (items that could be recycled but haven’t been just yet) as “not visible” – even when the heap page is generally considered all-visible.
LP_DEAD setting of index tuples by the kill_prior_tuple optimization (described in full in simple deletion, below) is also more complicated for index scans that drop their leaf page pins. We must be careful to avoid LP_DEAD-marking any new index tuple that looks like a known-dead index tuple because it happens to share the same TID, following concurrent TID recycling. It’s just about possible that some other session inserted a new, unrelated index tuple, on the same leaf page, which has the same original TID. It would be totally wrong to LP_DEAD-set this new, unrelated index tuple.
We handle this kill_prior_tuple race condition by having affected index scans conservatively assume that any change to the leaf page at all implies that it was reached by btbulkdelete in the interim period when no buffer pin was held. This is implemented by not setting any LP_DEAD bits on the leaf page at all when the page’s LSN has changed. (That won’t work with an unlogged index, so for now we don’t ever apply the “don’t hold onto pin” optimization there.)
Fastpath For Index Insertion
We optimize for a common case of insertion of increasing index key values by caching the last page to which this backend inserted the last value, if this page was the rightmost leaf page. For the next insert, we can then quickly check if the cached page is still the rightmost leaf page and also the correct place to hold the current value. We can avoid the cost of walking down the tree in such common cases.
The optimization works on the assumption that there can only be one non-ignorable leaf rightmost page, and so not even a visible-to-everyone style interlock is required. We cannot fail to detect that our hint was invalidated, because there can only be one such page in the B-Tree at any time. It’s possible that the page will be deleted and recycled without a backend’s cached page also being detected as invalidated, but only when we happen to recycle a block that once again gets recycled as the rightmost leaf page.
Simple deletion
If a process visits a heap tuple and finds that it’s dead and removable (ie, dead to all open transactions, not only that process), then we can return to the index and mark the corresponding index entry “known dead”, allowing subsequent index scans to skip visiting the heap tuple. The “known dead” marking works by setting the index item’s lp_flags state to LP_DEAD. This is currently only done in plain indexscans, not bitmap scans, because only plain scans visit the heap and index “in sync” and so there’s not a convenient way to do it for bitmap scans. Note also that LP_DEAD bits are often set when checking a unique index for conflicts on insert (this is simpler because it takes place when we hold an exclusive lock on the leaf page).
Once an index tuple has been marked LP_DEAD it can actually be deleted from the index immediately; since index scans only stop “between” pages, no scan can lose its place from such a deletion. We separate the steps because we allow LP_DEAD to be set with only a share lock (it’s like a hint bit for a heap tuple), but physically deleting tuples requires an exclusive lock. We also need to generate a snapshotConflictHorizon for each deletion operation’s WAL record, which requires additional coordinating with the tableam when the deletion actually takes place. (snapshotConflictHorizon value may be used to generate a conflict during subsequent REDO of the record by a standby.)
Delaying and batching index tuple deletion like this enables a further optimization: opportunistic checking of “extra” nearby index tuples (tuples that are not LP_DEAD-set) when they happen to be very cheap to check in passing (because we already know that the tableam will be visiting their table block to generate a snapshotConflictHorizon). Any index tuples that turn out to be safe to delete will also be deleted. Simple deletion will behave as if the extra tuples that actually turn out to be delete-safe had their LP_DEAD bits set right from the start.
Deduplication can also prevent a page split, but index tuple deletion is our preferred approach. Note that posting list tuples can only have their LPDEAD bit set when every table TID within the posting list is known dead. This isn’t much of a problem in practice because LP_DEAD bits are just a starting point for deletion. What really matters is that _some deletion operation that targets related nearby-in-table TIDs takes place at some point before the page finally splits. That’s all that’s required for the deletion process to perform granular removal of groups of dead TIDs from posting list tuples (without the situation ever being allowed to get out of hand).
Bottom-Up deletion
We attempt to delete whatever duplicates happen to be present on the page when the duplicates are suspected to be caused by version churn from successive UPDATEs. This only happens when we receive an executor hint indicating that optimizations like heapam’s HOT have not worked out for the index – the incoming tuple must be a logically unchanged duplicate which is needed for MVCC purposes, suggesting that that might well be the dominant source of new index tuples on the leaf page in question. (Also, bottom-up deletion is triggered within unique indexes in cases with continual INSERT and DELETE related churn, since that is easy to detect without any external hint.)
Simple deletion will already have failed to prevent a page split when a bottom-up deletion pass takes place (often because no LP_DEAD bits were ever set on the page). The two mechanisms have closely related implementations. The same WAL records are used for each operation, and the same tableam infrastructure is used to determine what TIDs/tuples are actually safe to delete. The implementations only differ in how they pick TIDs to consider for deletion, and whether or not the tableam will give up before accessing all table blocks (bottom-up deletion lives with the uncertainty of its success by keeping the cost of failure low). Even still, the two mechanisms are clearly distinct at the conceptual level.
Bottom-up index deletion is driven entirely by heuristics (whereas simple deletion is guaranteed to delete at least those index tuples that are already LP_DEAD marked – there must be at least one). We have no certainty that we’ll find even one index tuple to delete. That’s why we closely cooperate with the tableam to keep the costs it pays in balance with the benefits we receive. The interface that we use for this is described in detail in access/tableam.h.
Bottom-up index deletion can be thought of as a backstop mechanism against unnecessary version-driven page splits. It is based in part on an idea from generational garbage collection: the “generational hypothesis”. This is the empirical observation that “most objects die young”. Within nbtree, new index tuples often quickly appear in the same place, and then quickly become garbage. There can be intense concentrations of garbage in relatively few leaf pages with certain workloads (or there could be in earlier versions of PostgreSQL without bottom-up index deletion, at least). See doc/src/sgml/btree.sgml for a high-level description of the design principles behind bottom-up index deletion in nbtree, including details of how it complements VACUUM.
We expect to find a reasonably large number of tuples that are safe to delete within each bottom-up pass. If we don’t then we won’t need to consider the question of bottom-up deletion for the same leaf page for quite a while (usually because the page splits, which resolves the situation for the time being). We expect to perform regular bottom-up deletion operations against pages that are at constant risk of unnecessary page splits caused only by version churn. When the mechanism works well we’ll constantly be “on the verge” of having version-churn-driven page splits, but never actually have even one.
Our duplicate heuristics work well despite being fairly simple. Unnecessary page splits only occur when there are truly pathological levels of version churn (in theory a small amount of version churn could make a page split occur earlier than strictly necessary, but that’s pretty harmless). We don’t have to understand the underlying workload; we only have to understand the general nature of the pathology that we target. Version churn is easy to spot when it is truly pathological. Affected leaf pages are fairly homogeneous.
WAL Considerations
The insertion and deletion algorithms in themselves don’t guarantee btree consistency after a crash. To provide robustness, we depend on WAL replay. A single WAL entry is effectively an atomic action — we can redo it from the log if it fails to complete.
Ordinary item insertions (that don’t force a page split) are of course single WAL entries, since they only affect one page. The same for leaf-item deletions (if the deletion brings the leaf page to zero items, it is now a candidate to be deleted, but that is a separate action).
An insertion that causes a page split is logged as a single WAL entry for the changes occurring on the insertion’s level — including update of the right sibling’s left-link — followed by a second WAL entry for the insertion on the parent level (which might itself be a page split, requiring an additional insertion above that, etc).
For a root split, the follow-on WAL entry is a “new root” entry rather than an “insertion” entry, but details are otherwise much the same.
Because splitting involves multiple atomic actions, it’s possible that the system crashes between splitting a page and inserting the downlink for the new half to the parent. After recovery, the downlink for the new page will be missing. The search algorithm works correctly, as the page will be found by following the right-link from its left sibling, although if a lot of downlinks in the tree are missing, performance will suffer. A more serious consequence is that if the page without a downlink gets split again, the insertion algorithm will fail to find the location in the parent level to insert the downlink.
Our approach is to create any missing downlinks on-the-fly, when searching the tree for a new insertion. It could be done during searches, too, but it seems best not to put any extra updates in what would otherwise be a read-only operation (updating is not possible in hot standby mode anyway). It would seem natural to add the missing downlinks in VACUUM, but since inserting a downlink might require splitting a page, it might fail if you run out of disk space. That would be bad during VACUUM - the reason for running VACUUM in the first place might be that you run out of disk space, and now VACUUM won’t finish because you’re out of disk space. In contrast, an insertion can require enlarging the physical file anyway. There is one minor exception: VACUUM finishes interrupted splits of internal pages when deleting their children. This allows the code for re-finding parent items to be used by both page splits and page deletion.
To identify missing downlinks, when a page is split, the left page is flagged to indicate that the split is not yet complete (INCOMPLETE_SPLIT). When the downlink is inserted to the parent, the flag is cleared atomically with the insertion. The child page is kept locked until the insertion in the parent is finished and the flag in the child cleared, but can be released immediately after that, before recursing up the tree if the parent also needs to be split. This ensures that incompletely split pages should not be seen under normal circumstances; only if insertion to the parent has failed for some reason. (It’s also possible for a reader to observe a page with the incomplete split flag set during recovery; see later section on “Scans during Recovery” for details.)
We flag the left page, even though it’s the right page that’s missing the downlink, because it’s more convenient to know already when following the right-link from the left page to the right page that it will need to have its downlink inserted to the parent.
When splitting a non-root page that is alone on its level, the required metapage update (of the “fast root” link) is performed and logged as part of the insertion into the parent level. When splitting the root page, the metapage update is handled as part of the “new root” action.
Each step in page deletion is logged as a separate WAL entry: marking the leaf as half-dead and removing the downlink is one record, and unlinking a page is a second record. If vacuum is interrupted for some reason, or the system crashes, the tree is consistent for searches and insertions. The next VACUUM will find the half-dead leaf page and continue the deletion.
Before 9.4, we used to keep track of incomplete splits and page deletions during recovery and finish them immediately at end of recovery, instead of doing it lazily at the next insertion or vacuum. However, that made the recovery much more complicated, and only fixed the problem when crash recovery was performed. An incomplete split can also occur if an otherwise recoverable error, like out-of-memory or out-of-disk-space, happens while inserting the downlink to the parent.
Scans during Recovery
nbtree indexes support read queries in Hot Standby mode. Every atomic action/WAL record makes isolated changes that leave the tree in a consistent state for readers. Readers lock pages according to the same rules that readers follow on the primary. (Readers may have to move right to recover from a “concurrent” page split or page deletion, just like on the primary.)
However, there are a couple of differences in how pages are locked by replay/the startup process as compared to the original write operation on the primary. The exceptions involve page splits and page deletions. The first phase and second phase of a page split are processed independently during replay, since they are independent atomic actions. We do not attempt to recreate the coupling of parent and child page write locks that took place on the primary. This is safe because readers never care about the incomplete split flag anyway. Holding on to an extra write lock on the primary is only necessary so that a second writer cannot observe the incomplete split flag before the first writer finishes the split. If we let concurrent writers on the primary observe an incomplete split flag on the same page, each writer would attempt to complete the unfinished split, corrupting the parent page. (Similarly, replay of page deletion records does not hold a write lock on the target leaf page throughout; only the primary needs to block out concurrent writers that insert on to the page being deleted.)
WAL replay holds same-level locks in a way that matches the approach taken during original execution, though. This prevent readers from observing same-level inconsistencies. It’s probably possible to be more lax about how same-level locks are acquired during recovery (most kinds of readers could still move right to recover if we didn’t couple same-level locks), but we prefer to be conservative here.
During recovery all index scans start with ignore_killed_tuples = false and we never set kill_prior_tuple. We do this because the oldest xmin on the standby server can be older than the oldest xmin on the primary server, which means tuples can be marked LP_DEAD even when they are still visible on the standby. We don’t WAL log tuple LP_DEAD bits, but they can still appear in the standby because of full page writes. So we must always ignore them in standby, and that means it’s not worth setting them either. (When LP_DEAD-marked tuples are eventually deleted on the primary, the deletion is WAL-logged. Queries that run on a standby therefore get much of the benefit of any LP_DEAD setting that takes place on the primary.)
Note that we talk about scans that are started during recovery. We go to a little trouble to allow a scan to start during recovery and end during normal running after recovery has completed. This is a key capability because it allows running applications to continue while the standby changes state into a normally running server.
The interlocking required to avoid returning incorrect results from non-MVCC scans is not required on standby nodes. We still get a full cleanup lock when replaying VACUUM records during recovery, but recovery does not need to lock every leaf page (only those leaf pages that have items to delete) – that’s sufficient to avoid breaking index-only scans during recovery (see section above about making TID recycling safe). That leaves concern only for plain index scans. (XXX: Not actually clear why this is totally unnecessary during recovery.)
MVCC snapshot plain index scans are always safe, for the same reasons that they’re safe during original execution. HeapTupleSatisfiesToast() doesn’t use MVCC semantics, though that’s because it doesn’t need to - if the main heap row is visible then the toast rows will also be visible. So as long as we follow a toast pointer from a visible (live) tuple the corresponding toast rows will also be visible, so we do not need to recheck MVCC on them.
Other Things That Are Handy to Know
Page zero of every btree is a meta-data page. This page stores the location of the root page — both the true root and the current effective root (“fast” root). To avoid fetching the metapage for every single index search, we cache a copy of the meta-data information in the index’s relcache entry (rd_amcache). This is a bit ticklish since using the cache implies following a root page pointer that could be stale. However, a backend following a cached pointer can sufficiently verify whether it reached the intended page; either by checking the is-root flag when it is going to the true root, or by checking that the page has no siblings when going to the fast root. At worst, this could result in descending some extra tree levels if we have a cached pointer to a fast root that is now above the real fast root. Such cases shouldn’t arise often enough to be worth optimizing; and in any case we can expect a relcache flush will discard the cached metapage before long, since a VACUUM that’s moved the fast root pointer can be expected to issue a statistics update for the index.
The algorithm assumes we can fit at least three items per page (a “high key” and two real data items). Therefore it’s unsafe to accept items larger than 1/3rd page size. Larger items would work sometimes, but could cause failures later on depending on what else gets put on their page.
“ScanKey” data structures are used in two fundamentally different ways in this code, which we describe as “search” scankeys and “insertion” scankeys. A search scankey is the kind passed to btbeginscan() or btrescan() from outside the btree code. The sk_func pointers in a search scankey point to comparison functions that return boolean, such as int4lt. There might be more than one scankey entry for a given index column, or none at all. (We require the keys to appear in index column order, but the order of multiple keys for a given column is unspecified.) An insertion scankey (“BTScanInsert” data structure) uses a similar array-of-ScanKey data structure, but the sk_func pointers point to btree comparison support functions (ie, 3-way comparators that return int4 values interpreted as <0, =0, >0). In an insertion scankey there is at most one entry per index column. There is also other data about the rules used to locate where to begin the scan, such as whether or not the scan is a “nextkey” scan. Insertion scankeys are built within the btree code (eg, by _bt_mkscankey()) and are used to locate the starting point of a scan, as well as for locating the place to insert a new index tuple. (Note: in the case of an insertion scankey built from a search scankey or built from a truncated pivot tuple, there might be fewer keys than index columns, indicating that we have no constraints for the remaining index columns.) After we have located the starting point of a scan, the original search scankey is consulted as each index entry is sequentially scanned to decide whether to return the entry and whether the scan can stop (see _bt_checkkeys()).
Notes about suffix truncation
We truncate away suffix key attributes that are not needed for a page high key during a leaf page split. The remaining attributes must distinguish the last index tuple on the post-split left page as belonging on the left page, and the first index tuple on the post-split right page as belonging on the right page. Tuples logically retain truncated key attributes, though they implicitly have “negative infinity” as their value, and have no storage overhead. Since the high key is subsequently reused as the downlink in the parent page for the new right page, suffix truncation makes pivot tuples short. INCLUDE indexes are guaranteed to have non-key attributes truncated at the time of a leaf page split, but may also have some key attributes truncated away, based on the usual criteria for key attributes. They are not a special case, since non-key attributes are merely payload to B-Tree searches.
The goal of suffix truncation of key attributes is to improve index fan-out. The technique was first described by Bayer and Unterauer (R.Bayer and K.Unterauer, Prefix B-Trees, ACM Transactions on Database Systems, Vol 2, No. 1, March 1977, pp 11-26). The Postgres implementation is loosely based on their paper. Note that Postgres only implements what the paper refers to as simple prefix B-Trees. Note also that the paper assumes that the tree has keys that consist of single strings that maintain the “prefix property”, much like strings that are stored in a suffix tree (comparisons of earlier bytes must always be more significant than comparisons of later bytes, and, in general, the strings must compare in a way that doesn’t break transitive consistency as they’re split into pieces). Suffix truncation in Postgres currently only works at the whole-attribute granularity, but it would be straightforward to invent opclass infrastructure that manufactures a smaller attribute value in the case of variable-length types, such as text. An opclass support function could manufacture the shortest possible key value that still correctly separates each half of a leaf page split.
There is sophisticated criteria for choosing a leaf page split point. The general idea is to make suffix truncation effective without unduly influencing the balance of space for each half of the page split. The choice of leaf split point can be thought of as a choice among points between items on the page to be split, at least if you pretend that the incoming tuple was placed on the page already (you have to pretend because there won’t actually be enough space for it on the page). Choosing the split point between two index tuples where the first non-equal attribute appears as early as possible results in truncating away as many suffix attributes as possible. Evenly balancing space among each half of the split is usually the first concern, but even small adjustments in the precise split point can allow truncation to be far more effective.
Suffix truncation is primarily valuable because it makes pivot tuples smaller, which delays splits of internal pages, but that isn’t the only reason why it’s effective. Even truncation that doesn’t make pivot tuples smaller due to alignment still prevents pivot tuples from being more restrictive than truly necessary in how they describe which values belong on which pages.
While it’s not possible to correctly perform suffix truncation during internal page splits, it’s still useful to be discriminating when splitting an internal page. The split point that implies a downlink be inserted in the parent that’s the smallest one available within an acceptable range of the fillfactor-wise optimal split point is chosen. This idea also comes from the Prefix B-Tree paper. This process has much in common with what happens at the leaf level to make suffix truncation effective. The overall effect is that suffix truncation tends to produce smaller, more discriminating pivot tuples, especially early in the lifetime of the index, while biasing internal page splits makes the earlier, smaller pivot tuples end up in the root page, delaying root page splits.
Logical duplicates are given special consideration. The logic for selecting a split point goes to great lengths to avoid having duplicates span more than one page, and almost always manages to pick a split point between two user-key-distinct tuples, accepting a completely lopsided split if it must. When a page that’s already full of duplicates must be split, the fallback strategy assumes that duplicates are mostly inserted in ascending heap TID order. The page is split in a way that leaves the left half of the page mostly full, and the right half of the page mostly empty. The overall effect is that leaf page splits gracefully adapt to inserts of large groups of duplicates, maximizing space utilization. Note also that “trapping” large groups of duplicates on the same leaf page like this makes deduplication more efficient. Deduplication can be performed infrequently, without merging together existing posting list tuples too often.
Notes about deduplication
We deduplicate non-pivot tuples in non-unique indexes to reduce storage overhead, and to avoid (or at least delay) page splits. Note that the goals for deduplication in unique indexes are rather different; see later section for details. Deduplication alters the physical representation of tuples without changing the logical contents of the index, and without adding overhead to read queries. Non-pivot tuples are merged together into a single physical tuple with a posting list (a simple array of heap TIDs with the standard item pointer format). Deduplication is always applied lazily, at the point where it would otherwise be necessary to perform a page split. It occurs only when LP_DEAD items have been removed, as our last line of defense against splitting a leaf page (bottom-up index deletion may be attempted first, as our second last line of defense). We can set the LP_DEAD bit with posting list tuples, though only when all TIDs are known dead.
Our lazy approach to deduplication allows the page space accounting used during page splits to have absolutely minimal special case logic for posting lists. Posting lists can be thought of as extra payload that suffix truncation will reliably truncate away as needed during page splits, just like non-key columns from an INCLUDE index tuple. Incoming/new tuples can generally be treated as non-overlapping plain items (though see section on posting list splits for information about how overlapping new/incoming items are really handled).
The representation of posting lists is almost identical to the posting lists used by GIN, so it would be straightforward to apply GIN’s varbyte encoding compression scheme to individual posting lists. Posting list compression would break the assumptions made by posting list splits about page space accounting (see later section), so it’s not clear how compression could be integrated with nbtree. Besides, posting list compression does not offer a compelling trade-off for nbtree, since in general nbtree is optimized for consistent performance with many concurrent readers and writers. Compression would also make the deletion of a subset of TIDs from a posting list slow and complicated, which would be a big problem for workloads that depend heavily on bottom-up index deletion.
A major goal of our lazy approach to deduplication is to limit the performance impact of deduplication with random updates. Even concurrent append-only inserts of the same key value will tend to have inserts of individual index tuples in an order that doesn’t quite match heap TID order. Delaying deduplication minimizes page level fragmentation.
Deduplication in unique indexes
Very often, the number of distinct values that can ever be placed on almost any given leaf page in a unique index is fixed and permanent. For example, a primary key on an identity column will usually only have leaf page splits caused by the insertion of new logical rows within the rightmost leaf page. If there is a split of a non-rightmost leaf page, then the split must have been triggered by inserts associated with UPDATEs of existing logical rows. Splitting a leaf page purely to store multiple versions is a false economy. In effect, we’re permanently degrading the index structure just to absorb a temporary burst of duplicates.
Deduplication in unique indexes helps to prevent these pathological page splits. Storing duplicates in a space efficient manner is not the goal, since in the long run there won’t be any duplicates anyway. Rather, we’re buying time for standard garbage collection mechanisms to run before a page split is needed.
Unique index leaf pages only get a deduplication pass when an insertion (that might have to split the page) observed an existing duplicate on the page in passing. This is based on the assumption that deduplication will only work out when all new insertions are duplicates from UPDATEs. This may mean that we miss an opportunity to delay a page split, but that’s okay because our ultimate goal is to delay leaf page splits indefinitely (i.e. to prevent them altogether). There is little point in trying to delay a split that is probably inevitable anyway. This allows us to avoid the overhead of attempting to deduplicate with unique indexes that always have few or no duplicates.
Note: Avoiding “unnecessary” page splits driven by version churn is also the goal of bottom-up index deletion, which was added to PostgreSQL 14. Bottom-up index deletion is now the preferred way to deal with this problem (with all kinds of indexes, though especially with unique indexes). Still, deduplication can sometimes augment bottom-up index deletion. When deletion cannot free tuples (due to an old snapshot holding up cleanup), falling back on deduplication provides additional capacity. Delaying the page split by deduplicating can allow a future bottom-up deletion pass of the same page to succeed.
Posting list splits
When the incoming tuple happens to overlap with an existing posting list, a posting list split is performed. Like a page split, a posting list split resolves a situation where a new/incoming item “won’t fit”, while inserting the incoming item in passing (i.e. as part of the same atomic action). It’s possible (though not particularly likely) that an insert of a new item on to an almost-full page will overlap with a posting list, resulting in both a posting list split and a page split. Even then, the atomic action that splits the posting list also inserts the new item (since page splits always insert the new item in passing). Including the posting list split in the same atomic action as the insert avoids problems caused by concurrent inserts into the same posting list – the exact details of how we change the posting list depend upon the new item, and vice-versa. A single atomic action also minimizes the volume of extra WAL required for a posting list split, since we don’t have to explicitly WAL-log the original posting list tuple.
Despite piggy-backing on the same atomic action that inserts a new tuple, posting list splits can be thought of as a separate, extra action to the insert itself (or to the page split itself). Posting list splits conceptually “rewrite” an insert that overlaps with an existing posting list into an insert that adds its final new item just to the right of the posting list instead. The size of the posting list won’t change, and so page space accounting code does not need to care about posting list splits at all. This is an important upside of our design; the page split point choice logic is very subtle even without it needing to deal with posting list splits.
Only a few isolated extra steps are required to preserve the illusion that the new item never overlapped with an existing posting list in the first place: the heap TID of the incoming tuple has its TID replaced with the rightmost/max heap TID from the existing/originally overlapping posting list. Similarly, the original incoming item’s TID is relocated to the appropriate offset in the posting list (we usually shift TIDs out of the way to make a hole for it). Finally, the posting-split-with-page-split case must generate a new high key based on an imaginary version of the original page that has both the final new item and the after-list-split posting tuple (page splits usually just operate against an imaginary version that contains the new item/item that won’t fit).
This approach avoids inventing an “eager” atomic posting split operation that splits the posting list without simultaneously finishing the insert of the incoming item. This alternative design might seem cleaner, but it creates subtle problems for page space accounting. In general, there might not be enough free space on the page to split a posting list such that the incoming/new item no longer overlaps with either posting list half — the operation could fail before the actual retail insert of the new item even begins. We’d end up having to handle posting list splits that need a page split anyway. Besides, supporting variable “split points” while splitting posting lists won’t actually improve overall space utilization.
Notes About Data Representation
The right-sibling link required by L&Y is kept in the page “opaque data” area, as is the left-sibling link, the page level, and some flags. The page level counts upwards from zero at the leaf level, to the tree depth minus 1 at the root. (Counting up from the leaves ensures that we don’t need to renumber any existing pages when splitting the root.)
The Postgres disk block data format (an array of items) doesn’t fit Lehman and Yao’s alternating-keys-and-pointers notion of a disk page, so we have to play some games. (The alternating-keys-and-pointers notion is important for internal page splits, which conceptually split at the middle of an existing pivot tuple – the tuple’s “separator” key goes on the left side of the split as the left side’s new high key, while the tuple’s pointer/downlink goes on the right side as the first/minus infinity downlink.)
On a page that is not rightmost in its tree level, the “high key” is kept in the page’s first item, and real data items start at item 2. The link portion of the “high key” item goes unused. A page that is rightmost has no “high key” (it’s implicitly positive infinity), so data items start with the first item. Putting the high key at the left, rather than the right, may seem odd, but it avoids moving the high key as we add data items.
On a leaf page, the data items are simply links to (TIDs of) tuples in the relation being indexed, with the associated key values.
On a non-leaf page, the data items are down-links to child pages with bounding keys. The key in each data item is a strict lower bound for keys on that child page, so logically the key is to the left of that downlink. The high key (if present) is the upper bound for the last downlink. The first data item on each such page has no lower bound — or lower bound of minus infinity, if you prefer. The comparison routines must treat it accordingly. The actual key stored in the item is irrelevant, and need not be stored at all. This arrangement corresponds to the fact that an L&Y non-leaf page has one more pointer than key. Suffix truncation’s negative infinity attributes behave in the same way.
INDEX plan
drop table if exists tb;
create table tb (a int, b bigint);
insert into tb select n, n from generate_series(1, 100000) as n;
ANALYZE tb;
sequence scan
explain select * from tb where a = 5432;
QUERY PLAN
------------------------------------------------------
Seq Scan on tb (cost=0.00..1791.00 rows=1 width=12)
Filter: (a = 5432)
index scan
create index idx on tb(a);
ANALYZE tb;
explain select * from tb where a = 5432;
QUERY PLAN
---------------------------------------------------------------
Index Scan using idx on tb (cost=0.29..8.31 rows=1 width=12)
Index Cond: (a = 5432)
why width = 12?
select attname, avg_width from pg_stats where tablename='tb';
attname | avg_width
---------+-----------
a | 4
b | 8
Index Only Scan
explain select a from tb where a = 5432;
QUERY PLAN
-------------------------------------------------------------------
Index Only Scan using idx on tb (cost=0.29..4.31 rows=1 width=4)
Index Cond: (a = 5432)
Bitmap Index Scan
explain select * from tb where a = 5000 or a = 8000;
QUERY PLAN
------------------------------------------------------------------------
Bitmap Heap Scan on tb (cost=8.60..16.27 rows=2 width=12)
Recheck Cond: ((a = 5000) OR (a = 8000))
-> BitmapOr (cost=8.60..8.60 rows=2 width=0)
-> Bitmap Index Scan on idx (cost=0.00..4.30 rows=1 width=0)
Index Cond: (a = 5000)
-> Bitmap Index Scan on idx (cost=0.00..4.30 rows=1 width=0)
Index Cond: (a = 8000)
explain select * from tb where a in (5000, 8000);
QUERY PLAN
----------------------------------------------------------------
Index Scan using idx on tb (cost=0.29..12.62 rows=2 width=12)
Index Cond: (a = ANY ('{5000,8000}'::integer[]))
why different with a in(5000, 8000)?
B-Tree 索引的多键下跳(Multi-Index Scan / Multi-scan) | ScalarArrayOpExpr Index Optimization
INDEX page
drop table if exists tb;
create table tb (a int, b bigint);
insert into tb select n, n from generate_series(1, 100000) as n;
create index idx on tb(a);
ANALYZE tb;
explain select * from tb where a = 5432;
QUERY PLAN
---------------------------------------------------------------
Index Scan using idx on tb (cost=0.29..8.31 rows=1 width=12)
Index Cond: (a = 5432)
index file
select pg_relation_filepath('idx');
pg_relation_filepath
----------------------
base/5/99591
index structure
SELECT relpages FROM pg_class WHERE relname = 'idx';
relpages
----------
276
SELECT magic, version, root, level FROM bt_metap('idx');
magic | version | root | level
--------+---------+------+-------
340322 | 4 | 3 | 1
index page
+-----------------------------------------------------------------------+
| PageHeaderData (24 bytes) |
+-----------------------------------------------------------------------+
| Line Pointer 1 (4 bytes) ==> ItemID |
| Line Pointer 2 (4 bytes) |
| Line Pointer 3 (4 bytes) |
| ... |
| ---------------------> (Grows Downward) |
| |
| <--- Free Space ---> |
| |
| <--------------------- (Grows Upward) |
| ... |
| Index Tuple 3 (Data) ==> IndexTupleData |
| Index Tuple 2 (Data) |
| Index Tuple 1 (Data) |
+-----------------------------------------------------------------------+
| Special Space (B-Tree opaque data like sibling pointers, 16 bytes) |
+-----------------------------------------------------------------------+
index tuple
select * from bt_page_items('idx', 1) limit 5;
itemoffset | ctid | itemlen | nulls | vars | data | dead | htid | tids
------------+-------+---------+-------+------+-------------------------+------+-------+------
1 | (1,1) | 16 | f | f | 6f 01 00 00 00 00 00 00 | | |
2 | (0,1) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,1) |
3 | (0,2) | 16 | f | f | 02 00 00 00 00 00 00 00 | f | (0,2) |
4 | (0,3) | 16 | f | f | 03 00 00 00 00 00 00 00 | f | (0,3) |
5 | (0,4) | 16 | f | f | 04 00 00 00 00 00 00 00 | f | (0,4) |
INDEX execute
drop table if exists tb;
create table tb (a int, b text);
insert into tb select n, '1234567890' from generate_series(1, 100000) as n;
create index idx on tb(a);
ANALYZE tb;
explain select * from tb where a between 5000 and 5001;
QUERY PLAN
---------------------------------------------------------------
Index Scan using idx on tb (cost=0.29..8.33 rows=2 width=15)
Index Cond: ((a >= 5000) AND (a <= 5001))
IndexNext
index_getnext_tid: 获取 tidindex_fetch_heap: 回表获得原始数据
PortalStart
ExecutorStart | standard_ExecutorStart
InitPlan | ExecInitNode | ExecInitIndexScan
indexstate->ss.ps.ExecProcNode = ExecIndexScan;
ExecOpenScanRelation
index_open
ExecIndexBuildScanKeys
PortalRun | PortalRunSelect
ExecutorRun | standard_ExecutorRun
ExecutePlan | ExecProcNode
ExecIndexScan | ExecScan(.., IndexNext : ExecScanAccessMtd, ..)
ExecScanFetch
IndexNext
index_beginscan
index_getnext_slot
index_getnext_tid /* get tid */
btgettuple
index_fetch_heap /* get tuple */
table_index_fetch_tuple
heapam_index_fetch_tuple
heap_hot_search_buffer
ExecQual
ExecProject /* where a = 5432 and b <> 'aaa' */
PortalDrop
btgettuple
_bt_first: Find the first item in a scan_bt_next: Get the next item in a scan
index_getnext_tid
btgettuple
_bt_first /* or _bt_next */
_bt_search
_bt_getroot
_bt_binsrch /* binary search */
_bt_compare
child = BTreeTupleGetDownLink(itup);
ItemPointerGetBlockNumberNoCheck
BlockIdGetBlockNumber
_bt_binsrch
_bt_readpage /* get tid */
_bt_checkkeys
_bt_saveitem
transam
src/backend/access/transam/README
事务系统
PostgreSQL 的事务系统是一个三层系统。底层实现低级事务和子事务,其上构建主循环的控制代码,进而实现用户可见的事务和保存点。
中间层代码在 postgres.c 中被调用,在每个查询处理前后,或在检测到错误后调用:
StartTransactionCommand
CommitTransactionCommand
AbortCurrentTransaction
同时,用户可以通过发出 SQL 命令 BEGIN、COMMIT、ROLLBACK、SAVEPOINT、ROLLBACK TO 或 RELEASE 来改变系统状态。流量控制器将这些调用重定向到顶层例程:
BeginTransactionBlock
EndTransactionBlock
UserAbortTransactionBlock
DefineSavepoint
RollbackToSavepoint
ReleaseSavepoint
根据系统的当前状态,这些函数调用低级函数来激活真正的事务系统:
StartTransaction
CommitTransaction
AbortTransaction
CleanupTransaction
StartSubTransaction
CommitSubTransaction
AbortSubTransaction
CleanupSubTransaction
此外,在事务内部,CommandCounterIncrement 被调用来递增命令计数器,这使得后续命令能够“看到“同一事务中先前命令的效果。注意,这在事务块内的每个查询之后由 CommitTransactionCommand 自动完成,但某些实用函数也在内部执行此操作,以允许同一实用命令中的后续操作看到某些操作(通常在系统目录中)的效果。(例如,在 DefineRelation 中,它在创建堆表之后执行,使 pg_class 行可见,以便能够锁定它。)
例如,考虑以下用户命令序列:
1. BEGIN
2. SELECT * FROM foo
3. INSERT INTO foo VALUES (...)
4. COMMIT
在主处理循环中,这导致以下函数调用序列:
/ StartTransactionCommand;
/ StartTransaction;
1) < ProcessUtility; << BEGIN
\ BeginTransactionBlock;
\ CommitTransactionCommand;
/ StartTransactionCommand;
2) / PortalRunSelect; << SELECT ...
\ CommitTransactionCommand;
\ CommandCounterIncrement;
/ StartTransactionCommand;
3) / ProcessQuery; << INSERT ...
\ CommitTransactionCommand;
\ CommandCounterIncrement;
/ StartTransactionCommand;
/ ProcessUtility; << COMMIT
4) < EndTransactionBlock;
\ CommitTransactionCommand;
\ CommitTransaction;
这个例子的重点在于展示 StartTransactionCommand 和 CommitTransactionCommand 需要具备状态感知能力——它们应该在 BeginTransactionBlock 和 EndTransactionBlock 调用之间调用 CommandCounterIncrement,而在这些调用之外则需要执行正常的启动、提交或中止处理。
此外,假设 “SELECT * FROM foo” 导致了中止条件。在这种情况下会调用 AbortCurrentTransaction,事务被置于中止状态。在此状态下,除了事务终止语句或 ROLLBACK TO <savepoint> 命令外,任何用户输入都将被忽略。
事务中止可以通过两种方式发生:
- 系统因某些内部原因而中止(语法错误等)
- 用户输入 ROLLBACK
我们必须区分它们的原因通过以下两种情况来说明:
case 1 case 2
------ ------
1) user types BEGIN 1) user types BEGIN
2) user does something 2) user does something
3) user does not like what 3) system aborts for some reason
she sees and types ABORT (syntax error, etc)
在情况 1 中,我们希望中止事务并返回到默认状态。在情况 2 中,可能会有更多属于同一事务块的命令到来;我们必须忽略这些命令,直到看到 COMMIT 或 ROLLBACK。
内部中止由 AbortCurrentTransaction 处理,而用户中止由 UserAbortTransactionBlock 处理。两者都依赖 AbortTransaction 来完成所有实际工作。唯一的区别是 AbortTransaction 完成工作后我们进入什么状态:
- AbortCurrentTransaction 使我们处于 TBLOCK_ABORT 状态,
- UserAbortTransactionBlock 使我们处于 TBLOCK_ABORT_END 状态
低级事务中止处理分为两个阶段:
- AbortTransaction 在我们意识到事务失败后立即执行。它应该释放所有共享资源(锁等),以免不必要地延迟其他后端进程。
- CleanupTransaction 在我们最终看到用户 COMMIT 或 ROLLBACK 命令时执行;它清理所有内容并使我们完全退出事务。特别是,在此之前我们不能销毁 TopTransactionContext。
另外,请注意,当事务提交时,我们不会立即关闭它。而是将其置于 TBLOCK_END 状态,这意味着当查询处理完成后调用 CommitTransactionCommand 时,事务必须被关闭。这种区别很微妙但很重要,因为它意味着控制权将带着打开的事务离开 xact.c 代码,主循环将能够在同一事务内继续处理。因此,从某种意义上说,事务提交也分两个阶段处理,第一个阶段在 EndTransactionBlock,第二个阶段在 CommitTransactionCommand(这里实际调用 CommitTransaction)。
xact.c 中的其余代码是支持创建和完成事务及子事务的例程。例如,AtStart_Memory 负责在主事务启动时初始化内存子系统。
子事务处理
子事务使用 TransactionState 结构栈来实现,每个结构都有一个指向其父事务结构的指针。当要打开新的子事务时,调用 PushTransaction,它创建一个新的 TransactionState,其父链接指向当前事务。StartSubTransaction 负责将新的 TransactionState 初始化为合理的值,并正确初始化其他子系统(AtSubStart 例程)。
当关闭子事务时,要么调用 CommitSubTransaction(如果子事务正在提交),要么调用 AbortSubTransaction 和 CleanupSubTransaction(如果正在中止)。无论哪种情况,都会调用 PopTransaction,使系统返回到父事务。
关于子事务处理的一个重要点是,可能需要响应单个用户命令关闭多个子事务。这是因为保存点有名称,我们允许按名称提交或回滚保存点,而不一定是最后打开的那个。此外,COMMIT 或 ROLLBACK 命令必须能够关闭整个栈。我们通过让实用命令子程序将所有状态栈条目标记为待提交或待中止来处理这个问题,然后当主循环到达 CommitTransactionCommand 时,执行实际工作。这样做的主要优点是,如果在弹出状态栈条目时出现错误,剩余的栈条目仍然显示我们需要做什么来完成收尾工作。
在 ROLLBACK TO <savepoint> 的情况下,我们中止所有直到由保存点名称标识的子事务,然后用相同的名称重新创建该子事务级别。因此,就内部而言,这是一个全新的子事务。
其他子系统允许启动“内部“子事务,由 BeginInternalSubTransaction 处理。这是为了允许实现异常处理,例如在 PL/pgSQL 中。ReleaseCurrentSubTransaction 和 RollbackAndReleaseCurrentSubTransaction 允许子系统关闭所述子事务。这与保存点/释放路径的主要区别在于,我们在每个子程序中立即执行完整的状态转换,而不是将一些工作推迟到 CommitTransactionCommand。另一个区别是,当没有建立显式事务块时,允许 BeginInternalSubTransaction,而 DefineSavepoint 则不允许。
事务和子事务编号
事务和子事务只有在首次执行需要 XID 的操作时才会被分配永久 XID——通常是插入/更新/删除元组,尽管还有其他一些地方需要分配 XID。如果子事务需要 XID,我们总是先为其父事务分配一个。这保持了子事务的 XID 晚于其父事务的不变性,这在许多地方都有假设。
获取 XID 锁并将其输入 pg_subtrans 和 PGPROC 的辅助操作在分配时完成。
没有 XID 的事务仍需要出于各种目的进行标识,特别是持有锁。为此,我们为每个顶级事务分配一个“虚拟事务 ID“或 VXID。VXID 由两个字段组成:backendID 和后端本地计数器;这种安排允许在事务启动时分配新的 VXID,而不会对共享内存产生任何争用。为了确保 VXID 在后端退出后不会过早重用,我们在后端退出时将最后一个本地计数器值存储到共享内存中,并在后端启动时从同一 backendID 槽的前一个值初始化它。所有这些计数器在共享内存重新初始化时都会回到零,但这没关系,因为 VXID 永远不会出现在磁盘上的任何地方。
在内部,后端需要一种方法来标识子事务,无论它们是否有 XID;但这种需求仅在父顶级事务持续期间存在。因此,我们有 SubTransactionId,它有点像 CommandId,由一个计数器生成,我们在每个顶级事务开始时重置该计数器。顶级事务本身的 SubTransactionId 为 1,子事务的 ID 为 2 及以上。(零保留给 InvalidSubTransactionId。)注意,子事务没有自己的 VXID;它们使用父顶级事务的 VXID。
事务开始、事务结束和快照的互锁
我们努力最小化在频繁的开始/结束事务和获取快照活动中涉及的开销和锁争用。不幸的是,我们必须对此进行一些互锁,因为我们必须确保事务提交顺序的一致性。例如,假设事务 A 中的 UPDATE 被事务 B 先前对同一行的更新阻塞,而事务 B 正在提交,同时事务 C 获取快照。事务 A 可以在 B 释放其锁后立即完成并提交。如果事务 C 的 GetSnapshotData 看到事务 B 仍在运行,那么它最好也看到事务 A 仍在运行,否则它将能够看到两个元组版本——一个被事务 B 删除,一个被事务 A 插入。这不好的另一个原因是 C 会在(由 A 插入的行中)看到 B 的早期更改,而 C 在数据库的其他地方看不到 B 的任何更改是不一致的。
正式地说,正确性要求是“如果快照 A 认为事务 X 已提交,并且事务 X 的任何快照认为事务 Y 已提交,那么快照 A 必须认为事务 Y 已提交“。
我们实际强制执行的是提交和回滚与快照获取的严格序列化:在获取快照时,我们不允许任何事务退出正在运行的事务集。(这条规则比一致性所需的更强,但相对容易执行,并且有助于下面解释的其他一些问题。)其实现方式是 GetSnapshotData 以共享模式获取 ProcArrayLock(以便多个后端可以并行获取快照),但 ProcArrayEndTransaction 必须在事务结束时(提交或中止)清除 ProcGlobal->xids[] 条目时以独占模式获取 ProcArrayLock。(为了减少上下文切换,当多个事务几乎同时提交时,我们让一个后端获取 ProcArrayLock 并一次性清除多个进程的 XID。)
ProcArrayEndTransaction 在推进共享的 latestCompletedXid 变量时也持有锁。这允许 GetSnapshotData 使用 latestCompletedXid + 1 作为其快照的 xmax:不可能有需要快照视为已完成的大于或等于此 xid 值的事务。
简而言之,规则是在我们获取 latestCompletedXid 和我们完成构建快照之间的时间内,任何事务都不能退出当前运行的事务集。但是,此限制仅适用于具有 XID 的事务——只读事务可以在不获取 ProcArrayLock 的情况下结束,因为它们不影响其他人的快照或 latestCompletedXid。
事务启动本身与这些考虑没有任何互锁,因为我们不再在事务启动时立即分配 XID。但是当我们决定分配 XID 时,GetNewTransactionId 必须在释放 XidGenLock 之前将新 XID 存储到共享 ProcArray 中。这确保所有小于或等于 latestCompletedXid 的顶级 XID 要么存在于 ProcArray 中,要么不再运行。(此保证不适用于子事务 XID,因为 subxid 数组中可能没有足够的空间容纳它们;相反,我们保证它们存在或设置了溢出标志。)如果后端在将其 XID 存储到 ProcGlobal->xids[] 之前释放了 XidGenLock,那么另一个后端可能会分配并提交一个更晚的 XID,导致 latestCompletedXid 超过第一个后端的 XID,而该值尚未在 ProcArray 中可见。这将破坏 ComputeXidHorizons,如下文所述。
我们允许 GetNewTransactionId 在不获取 ProcArrayLock 的情况下将 XID 存储到 ProcGlobal->xids[](或 subxid 数组)中。这曾经对于避免死锁是必要的;虽然情况已不再如此,但它仍然有利于性能。因此,我们依赖于 XID 的获取/存储是原子的,否则其他后端可能会看到部分设置的 XID。这也意味着 ProcArray xid 字段的读取者必须小心只获取一次值,而不是假设他们可以多次读取它并每次都得到相同的答案。(在执行此操作时使用 volatile 限定的指针,以确保 C 编译器完全按照您的指示执行。)
使用共享 ProcArray 的另一个重要活动是 ComputeXidHorizons,它必须确定系统范围内任何活动 MVCC 快照的最旧 xmin 的下界。每个单独的后端在 MyProc->xmin 中公布其自身快照的最小 xmin,如果当前没有活动快照(例如,如果在事务之间或尚未为新事务设置快照),则为零。ComputeXidHorizons 取有效 xmin 字段的最小值。它只对 ProcArrayLock 持有共享锁,这意味着与其他并发执行 GetSnapshotData 的后端存在潜在的竞态条件:我们必须确保即将设置其 xmin 的并发后端计算的 xmin 不小于 ComputeXidHorizons 确定的值。我们通过将所有活动 XID 与有效 xmin 一起包含在 MIN() 计算中来确保这一点。事务不能在未获取独占 ProcArrayLock 的情况下退出的规则确保共享 ProcArrayLock 的并发持有者将计算相同的当前活动 XID 最小值:在我们持有共享 ProcArrayLock 时,没有事务,特别是最老的事务,可以退出。因此,ComputeXidHorizons 对最小活动 XID 的看法将与任何并发 GetSnapshotData 相同,因此它不会产生高估。如果根本没有活动事务,ComputeXidHorizons 使用 latestCompletedXid + 1,这是并发或后续 GetSnapshotData 调用可能计算的 xmin 的下界。(我们知道不会有小于此值的 XID 即将出现在 ProcArray 中,因为上面讨论的 XidGenLock 互锁。)
由于 GetSnapshotData 对性能至关重要,它不执行精确的 oldest-xmin 计算(直到 v14 版本之前都是这样做的)。快照的内容仅取决于其他后端的 xid,而不是它们的 xmin。由于后端的 xmin 变化比其 xid 频繁得多,让 GetSnapshotData 查看 xmin 可能导致大量不必要的缓存行乒乓效应。相反,GetSnapshotData 更新近似阈值(一个保证可以删除比它更早的已删除行,另一个确定不能删除比它更新的已删除行)。GlobalVisTest* 使用这些阈值来做不可见性决策,必要时回退到 ComputeXidHorizons。
注意,虽然可以确定两个并发执行的 GetSnapshotData 将为它们自己的快照计算相同的 xmin,但对于 ComputeXidHorizons 计算的地平线没有这样的保证。这是因为我们允许无 XID 的事务异步清除它们的 MyProc->xmin(不获取 ProcArrayLock),所以一次执行可能会看到曾经是最旧的 xmin,而另一次则不会。这没关系,因为阈值只需要是有效的下界。如上所述,我们已经假设 xid 字段的获取/存储是原子的,所以对 xmin 也做同样的假设不会带来额外风险。
pg_xact 和 pg_subtrans
pg_xact 和 pg_subtrans 是事务相关信息的永久(磁盘)存储。每种只有有限数量的页面保存在内存中,因此在许多情况下不需要实际从磁盘读取。但是,如果有长时间运行的事务或后端闲置且事务打开,则可能需要能够从磁盘读写这些信息。它们还允许信息在服务器重启后保持永久。
pg_xact 记录每个已分配 XID 的事务的提交状态。事务可以处于进行中、已提交、已中止或“子提交“状态。最后一种状态意味着它是一个不再运行的子事务,但其父事务尚未更新其状态。没有必要将子事务的事务状态更新为子提交,所以我们可以将其推迟到主事务提交。将事务标记为子提交的主要作用是在事务状态分布在多个 clog 页面时提供原子提交协议。因此,每当事务状态分布在多个页面上时,我们必须使用两阶段提交协议:第一阶段是将子事务标记为子提交,然后我们将顶级事务及其所有子事务标记为已提交(按此顺序)。因此,未中止的子事务即使已经完成也显示为进行中,子提交状态在主事务提交期间表现为非常短暂的过渡状态。子事务中止总是在发生时立即在 clog 中标记。当事务状态全部适合单个 CLOG 页面时,我们以原子方式将它们全部标记为已提交,而不必费心中间的子提交状态。
保存点使用子事务实现。子事务是事务内部的事务;其提交或中止状态不仅取决于它是否自行提交,还取决于其父事务是否提交。为了在事务中实现多个保存点,我们允许无限的事务嵌套深度,因此任何特定子事务的提交状态取决于每个祖先事务的提交状态。
“子事务父级”(pg_subtrans)机制为每个具有 XID 的事务记录其父事务的 TransactionId。此信息在子事务被分配 XID 时立即存储。顶级事务没有父级,因此它们的 pg_subtrans 条目设置为默认值零(InvalidTransactionId)。
pg_subtrans 用于检查相关事务是否仍在运行——事务的主 Xid 记录在 ProcGlobal->xids[] 中,PGPROC->xid 中有副本,但由于我们允许子事务任意嵌套,我们无法将所有 Xid 放入共享内存,因此必须将它们存储在磁盘上。但是,请注意,对于每个事务,我们保留已知属于事务树的 Xid 的“缓存“,因此除非我们知道缓存已溢出,否则可以跳过查看 pg_subtrans。有关详细信息,请参阅 storage/ipc/procarray.c。
slru.c 是 pg_xact 和 pg_subtrans 的支持机制。它为内存缓冲页面实现 LRU 策略。pg_xact 的高级例程在 transam.c 中实现,而低级函数在 clog.c 中。pg_subtrans 完全包含在 subtrans.c 中。
预写日志编码
WAL 子系统(在代码中也称为 XLOG)的存在是为了保证崩溃恢复。它还可用于提供时间点恢复,以及通过日志传送的热备复制。以下是关于其设计中不太明显方面的一些说明。
预写日志的基本假设是日志条目必须在它们描述的数据页面更改之前到达稳定存储。这确保将日志重放到末尾将使我们要达到一致状态,其中没有部分执行的事务。为了保证这一点,每个数据页面(堆或索引)都标记有影响该页面的最新 XLOG 记录的 LSN(日志序列号——实际上是一个 WAL 文件位置)。在 bufmgr 可以写出脏页之前,它必须确保 xlog 至少已刷新到页面的 LSN。这种低级交互通过不在必要时等待 XLOG I/O 来提高性能。LSN 检查仅存在于共享缓冲区管理器中,而不存在于用于临时表的本地缓冲区管理器中;因此临时表上的操作不得进行 WAL 记录。
在 WAL 重放期间,我们可以检查页面的 LSN 以检测当前日志条目记录的更改是否已应用(如果页面 LSN >= 日志条目的 WAL 位置,则已应用)。
通常,日志条目仅包含足够的信息来重做页面(或小页面组)上的单个增量更新。这仅在文件系统和硬件将数据页面写入实现为原子操作时才有效,这样页面永远不会处于损坏的部分写入状态。由于这在实际中往往是站不住脚的假设,我们记录额外信息以允许完全重建修改的页面。检查点后影响给定页面的第一个 WAL 记录包含整个页面的副本,我们通过恢复该页面副本来实现重放,而不是重做更新。(这比数据存储本身更可靠,因为我们可以检查 WAL 记录 CRC 的有效性。)我们可以通过注意页面的旧 LSN 是否在最后一个检查点的 WAL 末尾(RedoRecPtr)之前来检测“检查点后的第一个更改“。
执行 WAL 记录操作的一般模式是:
-
Pin 并独占锁定包含要修改的数据页面的共享缓冲区。
-
START_CRIT_SECTION()(接下来三个步骤中的任何错误都必须导致 PANIC,因为共享缓冲区将包含未记录的更改,我们必须确保这些更改不会到达磁盘。显然,在开始临界区之前,您应该检查条件,例如页面上是否有足够的空闲空间。)
-
将所需的更改应用于共享缓冲区。
-
使用 MarkBufferDirty() 将共享缓冲区标记为脏。(这必须在插入 WAL 记录之前发生;参见 SyncOneBuffer() 中的注释。)注意,只有当您写入 WAL 记录时,才应该使用 MarkBufferDirty() 将缓冲区标记为脏;参见下面的“编写提示“。
-
如果关系需要 WAL 记录,使用 XLogBeginInsert 和 XLogRegister* 函数构建 WAL 记录,并插入它。(参见下面的“构造 WAL 记录“。)然后使用返回的 XLOG 位置更新页面的 LSN。例如:
XLogBeginInsert(); XLogRegisterBuffer(...) XLogRegisterData(...) recptr = XLogInsert(rmgr_id, info); PageSetLSN(dp, recptr); -
END_CRIT_SECTION()
-
解锁并取消 Pin 缓冲区。
复杂更改(如多级索引插入)通常需要由一系列原子操作 WAL 记录来描述。中间状态必须是自洽的,这样如果重放在任何两个操作之间中断,系统仍然是完全功能的。例如,在 btree 索引中,页面分裂需要分配一个新页面,并在父 btree 级别插入一个新键,但由于锁定原因,这必须由两个单独的 WAL 记录反映。重放第一个记录(分配新页面并将元组移动到它)会在页面上设置一个标志,指示键尚未插入到父级。重放第二个记录会清除该标志。这个中间状态在正常操作期间永远不会被其他后端看到,因为子页面上的锁在两个操作之间保持,但如果操作在写入第二个 WAL 记录之前中断,将会看到这个状态。搜索算法像往常一样处理中间状态,但如果插入遇到设置了不完整分裂标志的页面,它将在继续之前通过将键插入父级来完成中断的分裂。
构造 WAL 记录
WAL 记录由所有 WAL 记录类型通用的头部、记录特定数据和有关修改的数据块的信息组成。每个修改的数据块都由 ID 号标识,并且可以选择具有与该块关联的更多记录特定数据。如果 XLogInsert 决定需要获取块的完整页面映像,则与该块关联的数据不包括在内。
构造 WAL 记录的 API 由五个函数组成:XLogBeginInsert、XLogRegisterBuffer、XLogRegisterData、XLogRegisterBufData 和 XLogInsert。首先,调用 XLogBeginInsert()。然后使用 XLogRegister* 函数注册所有修改的缓冲区和重放更改所需的数据。最后,通过调用 XLogInsert() 将构造的记录插入 WAL。
XLogBeginInsert();
/* 注册作为此 WAL 记录操作一部分修改的缓冲区 */
XLogRegisterBuffer(0, lbuffer, REGBUF_STANDARD);
XLogRegisterBuffer(1, rbuffer, REGBUF_STANDARD);
/* 注册始终包含在 WAL 记录中的数据 */
XLogRegisterData(&xlrec, SizeOfFictionalAction);
/*
* 注册与缓冲区关联的数据。如果获取完整页面映像,
* 这将不包括在记录中。
*/
XLogRegisterBufData(0, tuple->data, tuple->len);
/* 与缓冲区关联的更多数据 */
XLogRegisterBufData(0, data2, len2);
/*
* 好的,要包含在 WAL 记录中的所有数据和缓冲区
* 都已注册。插入记录。
*/
recptr = XLogInsert(RM_FOO_ID, XLOG_FOOBAR_DO_STUFF);
API 函数的详细信息:
void XLogBeginInsert(void)
必须在 XLogRegisterBuffer 和 XLogRegisterData 之前调用。
void XLogResetInsertion(void)
从 WAL 记录构造工作区中清除任何当前注册的数据和缓冲区。这仅在您已经调用了 XLogBeginInsert(),但最终决定不插入记录时才需要。
void XLogEnsureRecordSpace(int max_block_id, int ndatas)
通常,WAL 记录构造缓冲区有以下限制:
* 可以使用的最高块 ID 是 4(允许五个块引用)
* 最多 20 个注册数据块
这些默认限制足以满足大多数更改某些磁盘结构的记录类型。对于需要更多数据或需要修改更多缓冲区的罕见情况,可以通过调用 XLogEnsureRecordSpace() 来提高这些限制。XLogEnsureRecordSpace() 必须在 XLogBeginInsert() 之前调用,并且在临界区之外。
void XLogRegisterBuffer(uint8 block_id, Buffer buf, uint8 flags);
XLogRegisterBuffer 向 WAL 记录添加有关数据块的信息。block_id 是一个任意数字,用于在重做例程中标识此页面引用。重做时重新找到页面所需的信息——relfilelocator、fork 和块号——都包含在 WAL 记录中。
如果这是自上次检查点以来对缓冲区的第一次修改,XLogInsert 将自动包含页面内容的完整副本。使用 XLogRegisterBuffer 注册操作修改的每个缓冲区以避免撕裂页面危险非常重要。
标志控制何时以及如何将缓冲区内容包含在 WAL 记录中。通常,仅当页面自上次检查点以来未被修改,并且仅当 full_page_writes=on 或正在进行在线备份时,才获取完整页面映像。REGBUF_FORCE_IMAGE 标志可用于强制始终包含完整页面映像;这对于重写大部分页面的操作很有用,因此跟踪细节不值得。对于不需要防止撕裂页面的罕见情况,可以使用 REGBUF_NO_IMAGE 标志来抑制获取完整页面映像。REGBUF_WILL_INIT 也抑制完整页面映像,但重做例程必须从头开始重新生成页面,而不查看旧页面内容。重新初始化页面像完整页面映像一样防止撕裂页面危险。
REGBUF_STANDARD 标志可以与其他标志一起指定,以指示页面遵循标准页面布局。它导致 pd_lower 和 pd_upper 之间的区域从映像中排除,减少 WAL 量。
如果给出 REGBUF_KEEP_DATA 标志,则即使获取完整页面映像,使用 XLogRegisterBufData() 注册的每个缓冲区数据也包含在 WAL 记录中。
void XLogRegisterData(char *data, int len);
XLogRegisterData 用于在 WAL 记录中包含任意数据。如果多次调用 XLogRegisterData(),数据会被追加,并将作为一个连续块提供给重做例程。
void XLogRegisterBufData(uint8 block_id, char *data, int len);
XLogRegisterBufData 用于包含与之前使用 XLogRegisterBuffer() 注册的特定缓冲区关联的数据。如果使用相同的块 ID 多次调用 XLogRegisterBufData(),数据会被追加,并将作为一个连续块提供给重做例程。
如果在插入时获取缓冲区的完整页面映像,则数据不包括在 WAL 记录中,除非使用 REGBUF_KEEP_DATA 标志。
编写 REDO 例程
REDO 例程使用 WAL 记录中包含的数据和页面引用来重建页面的新状态。可以使用 xlogreader.c/h 中的记录解码函数和宏从记录中提取数据。
当重放描述多个页面更改的 WAL 记录时,您必须小心正确锁定页面,以防止并发热备查询看到不一致的状态。如果这需要同时持有两个或更多缓冲区锁,您必须以适当的顺序锁定页面,并且在完成所有更改之前不要释放锁。
注意,我们只有在知道操作是序列化的情况下才能使用 PageSetLSN/PageGetLSN()。只有 Startup 进程可以在恢复期间修改数据块,因此 Startup 进程可以执行 PageGetLSN() 而不必担心序列化问题。所有其他进程只有在持有独占缓冲区锁或共享锁加缓冲区头锁时,或者在持有关系的 AccessExclusiveLock 时直接写入数据块而不是通过共享缓冲区时,才能调用 PageSet/GetLSN。
编写提示
在某些情况下,我们在不写入前面的 WAL 记录的情况下向数据块写入额外信息。这应该仅在数据可以在崩溃后重建且该操作仅仅是优化性能的情况下发生。当写入提示时,我们使用 MarkBufferDirtyHint() 将块标记为脏。
如果缓冲区是干净的且使用校验和,则 MarkBufferDirtyHint() 插入 XLOG_FPI_FOR_HINT 记录以确保我们获取包含提示的完整页面映像。我们这样做是为了在写入脏页时避免部分页面写入。恢复期间不写入 WAL,因此我们在恢复时简单地跳过因提示而脏化块。
如果您确实决定优化掉 WAL 记录,则必须将对 MarkBufferDirty() 的任何调用替换为 MarkBufferDirtyHint(),否则您将暴露部分页面写入的风险。
堆页面中的全可见提示(PD_ALL_VISIBLE)是一种特殊情况,因为它在某些方面被视为持久更改,在其他方面被视为提示。它必须满足不变性:如果堆页面的关联可见性映射(VM)位被设置,则堆页面本身上的 PD_ALL_VISIBLE 也被设置。清除 PD_ALL_VISIBLE 始终被视为完全持久更改以维持此不变性。此外,如果启用了校验和或 wal_log_hints,设置 PD_ALL_VISIBLE 也被视为完全持久更改以防止撕裂页面。
但是,如果既未启用校验和也未启用 wal_log_hints,如果唯一更改是 PD_ALL_VISIBLE,则撕裂页面无关紧要;因此不获取完整堆页面映像,也不更新堆页面的 LSN。注意:即使有关联的 WAL 记录,在应用此优化时更新堆页面的 LSN 也是不正确的,因为页面的后续修改者(例如不相关的 UPDATE)可能会错误地认为不需要完整页面映像。
文件系统操作的预写日志
上一节描述了如何对仅更改共享缓冲区内页面内容的操作进行 WAL 记录。对于那种类型的操作,通常在开始进行实际更改之前检查所有可能的错误情况(例如页面上空间不足)是可能的。因此,我们可以通过将它们包装到临界区中使更改和相关 WAL 日志记录的创建成为“原子“操作——中途失败的几率足够低,如果真的发生,PANIC 是可以接受的。
显然,这种方法不适用于要记录的操作中存在显著失败概率的情况,例如创建新文件或数据库。我们不希望 PANIC,尤其不希望在我们已经写入了说我们执行了操作的 WAL 记录之后 PANIC——如果我们这样做了,记录的重放可能会再次失败并再次 PANIC,使故障无法恢复。这意味着普通的 WAL 规则“在更改之前写入 WAL“不起作用,我们需要为这种情况设计不同的方案。
有几种基本类型的文件系统操作存在这个问题。以下是我们如何处理每一种:
- 向现有表添加磁盘页面。
此操作根本不进行 WAL 记录。我们通过在表末尾写入一页零来扩展表。我们必须实际执行此写入,以确保文件系统已分配空间。如果写入失败,我们可以正常报错。一旦知道空间已分配,我们就可以通过一个或多个正常的 WAL 记录操作初始化和填充页面。因为我们可能在扩展文件和写出 WAL 条目之间崩溃,所以我们必须将发现表或索引中的全零页面视为非错误条件。在这种情况下,我们可以回收空间以供重用。
- 创建新表,需要在文件系统中创建新文件。
我们尝试创建文件,如果成功,我们创建一个 WAL 记录说明我们做到了。如果不成功,我们可以抛出错误。注意,有一个窗口期,我们已经创建了文件但尚未向其写入任何 WAL 到磁盘。如果在此期间崩溃,文件将作为“孤儿“留在磁盘上。可以通过让数据库重启搜索 pg_class 中没有已提交条目的文件来清理此类孤儿,但目前没有这样做,因为有可能删除对崩溃取证分析有用的数据。孤儿文件是无害的——最坏情况下它们浪费一点磁盘空间——因为我们在分配新的 relfilenumber OID 时检查磁盘冲突。因此清理并不是真的必要。
- 删除表,需要可能失败的 unlink()。
我们的方法是先对操作进行 WAL 记录,但将实际 unlink() 调用的失败视为警告而不是错误条件。同样,这可能会留下孤儿文件,但与替代方案相比,这是廉价的。由于我们只有在提交了 DROP TABLE 事务之后才能真正执行 unlink(),无论如何抛出错误都是不可能的。(值得注意的是,关于文件删除的 WAL 条目实际上是删除事务的提交记录的一部分。)
- 创建和删除数据库和表空间,需要创建和删除目录和整个目录树。
这些情况的处理方式类似于创建单个文件,即我们先尝试执行操作,如果成功则写入 WAL 条目。当然,可能浪费的磁盘空间量要大得多。在创建的情况下,如果创建失败,我们尝试再次删除目录树,以减少浪费空间的风险。删除操作中途失败会导致数据库损坏:DROP 失败,但一些数据已经丢失。对此我们无能为力,而且无论如何这可能是用户不再想要的数据。
在所有这些情况下,如果 WAL 重放无法重做原始操作,我们必须 panic 并中止恢复。DBA 将不得不手动清理(例如,释放一些磁盘空间或修复目录权限),然后重新启动恢复。这是不在成功执行原始操作之前写入 WAL 条目的部分原因。
跳过新 RelFileLocator 的 WAL
在 wal_level=minimal 下,如果更改修改了 ROLLBACK 将 unlink 的 relfilenumber,树内访问方法不为该更改写入 WAL。不调用 RelationNeedsWAL() 而写入 WAL 的代码必须检查这种情况。这种跳过是强制性的。如果同一块的 WAL 写入更改 precede WAL 跳过更改,REDO 可能会覆盖 WAL 跳过更改。如果同一块的 WAL 写入更改跟随 WAL 跳过更改,会出现相关问题。当 WAL 记录不包含完整页面映像时,REDO 期望页面与其在记录插入之前的内容匹配。WAL 跳过更改可能根本不会到达磁盘,在 full_page_writes=off 下违反 REDO 的预期。对于任何访问方法,CommitTransaction() 在记录提交之前写入并 fsync 受影响的块。
未来的访问方法最好也这样做。但是,还有两种其他方法可行。首先,访问方法可以通过调用 FlushRelationBuffers() 和 smgrimmedsync() 不可逆地将给定 fork 从 WAL 跳过转换为 WAL 写入。其次,访问方法可以选择无条件地为永久关系写入 WAL。在这些方法下,访问方法回调不得调用对 RelationNeedsWAL() 做出反应的函数。
这仅适用于其重放将修改存储在新 relfilenumber 中的字节的 WAL 记录。它不适用于关于 relfilenumber 的其他记录,例如 XLOG_SMGR_CREATE。因为它在单个 relfilenumbers 级别操作,RelationNeedsWAL() 对于紧密耦合的关系可能不同。考虑 “CREATE TABLE t (); BEGIN; ALTER TABLE t ADD c text; …”,其中 ALTER TABLE 添加 TOAST 关系。TOAST 关系将跳过 WAL,而拥有它的表不会。ALTER TABLE SET TABLESPACE 将导致表跳过 WAL,但这不会影响其索引。
异步提交
从 PostgreSQL 8.3 开始,可以执行异步提交——即,我们不等待提交的 WAL 记录被 fsync’ed。当 synchronous_commit = off 时,我们执行异步提交。我们不执行到提交 LSN 的 XLogFlush(),而只是在共享内存中记录 LSN。然后后端继续其他工作。我们仅为异步提交记录 LSN,不为中止记录;永远不需要刷新一份中止记录,因为崩溃后的假设是事务无论如何都中止了。
当事务正在删除关系时,我们总是强制同步提交,以确保在从文件系统中删除关系之前提交记录已到达磁盘。此外,某些具有不可回滚副作用(例如文件系统更改)的实用命令强制同步提交,以最小化文件系统更改已完成但事务未保证提交的窗口期。
walwriter 定期唤醒(通过 wal_writer_delay)或被唤醒(通过其 latch,由异步提交的后端设置)并执行 XLogBackgroundFlush()。这会检查最后一个完全填充的 WAL 页面的位置。如果该位置向前移动,那么我们写入到该点的所有更改缓冲区,以便在满负载下我们只写入整个缓冲区。如果活动中断且当前 WAL 页面与之前相同,那么我们找出最近异步提交的 LSN,并在需要时写入到该点(即,如果它在当前 WAL 页面中)。如果自上次刷新以来已经过去超过 wal_writer_delay,或者已经写入超过 wal_writer_flush_after 块,WAL 也会刷新到当前位置。这种安排本身将保证异步提交记录在事务完成后最多两次 wal_writer_delay 后到达磁盘。但是,我们也允许 XLogFlush “灵活地“写入/刷新完整缓冲区(即,不在循环 WAL 缓冲区区域的末尾环绕),以最小化在高负载下每个 walwriter 周期填充多个 WAL 页面时发出的写入次数。这使得最坏情况延迟为三个 wal_writer_delay 周期。
异步提交还有一些其他细微要点需要考虑。首先,对于 CLOG 的每个页面,我们必须记住影响该页面的最新提交的 LSN,以便我们可以执行与普通关系页面相同的“先刷新 WAL 再写入“规则。否则,提交记录可能会在 WAL 记录之前到达磁盘。同样,中止记录不需要纳入此考虑。
实际上,我们为每个 clog 页面存储多个 LSN。这与我们在可见性测试期间设置事务状态提示位的方式有关。我们不能在关系页面上设置事务已提交的提示位并使该记录在提交的 WAL 记录之前到达磁盘。由于可见性测试通常在持有缓冲区共享锁时进行,我们没有选项更改页面的 LSN 以保证 WAL 同步。相反,如果我们尚未将 WAL 刷新到与事务关联的 LSN,我们推迟设置提示位。这需要跟踪每个未刷新的异步提交的 LSN。将此数据与 clog 缓冲区关联很方便:因为我们会在写入 clog 页面之前刷新 WAL,我们知道只要保存其提交状态的 clog 页面仍在内存中,就不需要记住事务的 LSN。但是,为每个 clog 位置存储 LSN 的天真方法并不吸引人:LSN 比两位提交状态字段大 32 倍,因此每个 8K clog 缓冲页面我们需要 256K 的额外共享内存。我们选择改为每页面存储较少数量的 LSN,其中每个 LSN 是与该页面上连续事务 ID 范围内的任何事务提交关联的最高 LSN。这以设置事务提示位时可能不必要的延迟为代价节省了存储。
多少个事务应该共享相同的缓存 LSN(N)?如果系统的工作负载仅由小型异步提交事务组成,那么让 N 类似于每个 walwriter 周期的事务数是合理的,因为那是事务真正提交(因此可提示)的粒度。最坏的情况是同步提交事务与稍后提交的异步提交事务共享缓存 LSN;即使我们付费将第一个事务同步到磁盘,我们也无法提示其输出,直到第二个事务同步,最多三个 walwriter 周期后。这主张尽可能保持 N(组大小)小。目前我们将组大小设置为 32,这使得 LSN 缓存空间与实际 clog 缓冲空间大小相同(独立于 BLCKSZ)。
我们可以同时运行同步和异步提交事务是有用的,但这的安全性可能不是立即显而易见的。假设我们有两个事务 T1 和 T2。日志序列号(LSN)是 WAL 序列中记录事务提交的点,因此 LSN1 和 LSN2 是那些事务的提交记录。如果 T2 可以看到 T1 所做的更改,那么当 T2 提交时,LSN2 必须跟在 LSN1 之后。因此,当 T2 提交时,可以确定 T1 所做的所有更改现在也记录在 WAL 中。无论 T1 是异步还是同步,这都是正确的。因此,异步提交和同步提交可以安全地并发工作,而不会危及同步提交写入的数据。子事务在这里不重要,因为最终的磁盘写入仅发生在顶级事务的提交时。
数据块的更改除非 WAL 刷新到数据块 LSN 的点,否则无法到达磁盘。任何尝试将不安全数据写入磁盘的操作都将触发写入,确保该事务和先前事务写入的所有数据的安全。数据块和 clog 页面都受到 LSN 的保护。
临时表的更改不进行 WAL 记录,因此可能在 T1 提交之前到达磁盘,但我们不在乎,因为临时表内容无论如何都不会在崩溃后幸存。
跳过新 relfilenumbers 的 WAL 的数据库写入也是安全的。在这些情况下,数据完全有可能在 T1 提交之前到达磁盘,因为 T1 将在没有任何互锁的情况下将其 fsync 到磁盘。但是,所有这些路径都设计为写入其他事务在 T1 提交之前无法看到的数据。因此,情况与普通 WAL 记录的更新没有什么不同。
恢复期间的事务模拟
在恢复期间,我们按发生的顺序重放事务更改。作为此重放的一部分,我们模拟一些事务行为,以便只读后端可以获取 MVCC 快照。我们通过维护属于正在重放的事务的 XID 列表来做到这一点,因此每个已为数据库写入记录 WAL 记录的事务都存在於数组中,直到它提交。更多细节在 procarray.c 的注释中给出。
许多操作根本不写入 WAL 记录,例如只读事务。这些对恢复中的 MVCC 没有影响,我们可以假装它们从未发生过。子事务提交也不写入 WAL 记录,影响很小,因为锁等待者需要等待父事务完成。
并非所有事务行为都被模拟,例如我们不将事务条目插入锁表,也不在内存中维护事务栈。Clog、multixact 和 commit_ts 条目正常创建。Subtrans 在恢复期间维护,但事务树的细节被忽略,所有子事务直接引用顶级 TransactionId。由于提交是原子的,这提供了正确的锁等待行为,同时大大简化了子事务的模拟。
恢复中锁定机制的更多细节在 Lock rmgr 代码的注释中给出。
Transaction Overview
- 隔离性1: https://www.postgresql.org/docs/16/transaction-iso.html
- 隔离性2: https://postgres-internals.cn/docs/chapter02/
- 快照: https://postgres-internals.cn/docs/chapter04/
- 预写式日志: https://postgres-internals.cn/docs/chapter10/
- WAL: https://www.interdb.jp/pg/pgsql09/index.html
- 锁: https://postgres-internals.cn/docs/chapter12/
- 并发: https://www.interdb.jp/pg/pgsql05/index.html
| 事务特性 | 核心实现方式 | 关键补充 (内核视角) |
|---|---|---|
| 隔离性 (I) | MVCC (快照隔离) + Lock Manager (锁机制) | DDL 也是基于 MVCC。2PL (两阶段锁) 用于处理读写冲突。 |
| 持久性 (D) | WAL (预写日志) + Checkpointer | 还有 Double Write 机制(在某些存储环境下)防止半写。 |
| 原子性 (A) | CLog (状态位) + WAL | 事务提交本质上是修改 CLog 里的 2 个 bit 位。 |
| 一致性 (C) | 它是 A+I+D 的综合结果 + 数据完整性约束 | 包括 唯一索引、外键、Check 约束等主动校验。 |
隔离级别
| Isolation Level | Dirty Read | Nonrepeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| Read uncommitted | Allowed, but not in PG | Possible | Possible | Possible |
| Read committed | Not possible | Possible | Possible | Possible |
| Repeatable read | Not possible | Not possible | Allowed, but not in PG | Possible |
| Serializable | Not possible | Not possible | Not possible | Not possible |
丢失更新
丢失更新是“基于过时前提做出的正确决定”。
- 事务本身没问题:指令是合法的。
- 并发逻辑有问题:它掩盖了数据状态的真实演变过程。
create table tb(id int, account int);
insert into tb(id, account) values (1, 100);
| 事务A | 事务B |
|---|---|
BEGIN ISOLATION LEVEL READ COMMITTED; | |
BEGIN ISOLATION LEVEL READ COMMITTED; | |
select * from tb; | |
select * from tb; | |
update tb set account = 100 + 50 where id = 1; | |
commit | |
update tb set account = 100 - 20 where id = 1; | |
commit | |
select * from tb; 结果为80,丢失+50 |
解决方法:
- 使用RR隔离级别
BEGIN ISOLATION LEVEL REPEATABLE READ; - 使用行级锁
select * from accounts for update; - 使用原子更新
update accounts set balance = balance + 50 where id = 1;
持久性
wal
checkpoint 触发时机
- 时间触发:后台 checkpoint 进程会定时检查时间,如果距离上次 checkpoint 执行开始时的间隔超过了指定值,就会触发 checkpoint。这个指定值是配置文件的checkpoint_timeout 值,范围在 30s ~ 1 day,默认值为300s。
- wal日志:当最新的 wal 日志,和上次 checkpoint 的刷新点的距离大于指定值,就会触发 checkpoint。
- 手动触发:当用户执行checkpoint命令也会触发,这个命令必须由超级用户才能执行。
- 数据库关闭:当数据库正常关闭时,会触发一次 checkpoint 。
- 基础备份:当进行数据基础备份时,会执行pg_start_backup命令,触发 checkpoint。
- 数据库崩溃修复:数据库异常退出后,比如数据库进程被kill -9,来不及清理操作 。在重新启动时,会进行崩溃修复,修复完成后会触发 checkpoint。
pg_walinspect 介绍
- 代码位于
postgres/contrib/pg_walinspect/,编译后使用
# 1. 自动获取PG服务端头文件目录(模糊化安装路径)
PG_INCLUDE=$(~app/pgdebug/bin/pg_config --includedir-server)
# 2. 编译扩展(指定PG版本+头文件路径)
make PG_CONFIG=~app/pgdebug/bin/pg_config CPPFLAGS="-I$PG_INCLUDE"
# 3. 安装扩展(指定PG版本)
make install PG_CONFIG=~app/pgdebug/bin/pg_config
# 4. 客户端安装扩展到数据库实例
create extension pg_walinspect;
transaction 管理
exec_simple_query
start_xact_command
StartTransactionCommand
xact_started = true;
finish_xact_command
CommitTransactionCommand
xact_started = false;
Transaction Process
begin;
insert into tb values(1);
commit;
static TransactionStateData TopTransactionStateData = {
.state = TRANS_DEFAULT,
.blockState = TBLOCK_DEFAULT,
.topXidLogged = false,
};
1. BEGIN;
- 事务状态:事务控制块(TopTransactionStateData)初始化其状态为
TRANS_INPROGRESS。 - 注意:在 PG 中,执行
BEGIN时通常还不会分配正式的事务 ID(XID),而是先分配一个 虚拟事务 ID (VirtualXID),以节省 XID 资源。
exec_simple_query
start_xact_command
StartTransactionCommand
StartTransaction
s->state = TRANS_START;
/* initialize */
s->state = TRANS_INPROGRESS;
s->blockState = TBLOCK_STARTED;
PortalRun | PortalRunMulti | PortalRunUtility | ProcessUtility | standard_ProcessUtility
BeginTransactionBlock
s->blockState = TBLOCK_BEGIN;
finish_xact_command
CommitTransactionCommand
s->blockState = TBLOCK_INPROGRESS;
2. INSERT INTO tb VALUES(1);
A. 元数据检索(Syscache / Relcache)
- 控制面必须先搞清楚
tb是什么。它通过 Syscache 快速查询系统表(如pg_class,pg_attribute),确定表的字段类型、是否有约束、是否有索引。
B. 逻辑锁定(Heavyweight Lock)
- 表级锁申请:控制面调用 Lock Manager,申请
tb的RowExclusiveLock(行排他锁)。 - 作用:防止你在插入时,另一个事务执行
DROP TABLE(控制面逻辑保护)。
C. 物理锁定与空间寻找(Data Buffer & FSM)
- 寻找空位:访问 FSM (Free Space Map),找到
tb对应的数据面 Page。 - Pin & Lock:Buffer Manager 将该 Page 载入 Shared Buffers。为了物理安全,先对 Buffer 加 Pin(防止被换出),再加 LWLock (轻量级锁)(防止字节级冲突)。
D. 正式事务 ID 分配(XID & CLOG)
- 此时,控制面正式分配一个 32 位的 XID。
E. 生成数据变更(MVCC & WAL)
- MVCC 标记:在内存 Page 的元组头部写入数据
1,并将xmin设置为当前的 XID。 - WAL 日志:控制面生成一条 WAL Record(描述:在某 Page 插入了数据 1),并写入 WAL Buffer。
exec_simple_query
start_xact_command
StartTransactionCommand
break;
parsetree_list = pg_parse_query(query_string);
foreach(parsetree_item, parsetree_list)
/* analyze and plan */
start_xact_command
pg_analyze_and_rewrite_fixedparams | parse_analyze_fixedparams
transformTopLevelStmt | transformOptionalSelectInto | transformStmt | transformInsertStmt
setTargetTable | table_openrv_extended | relation_openrv_extended | RangeVarGetRelid
LockRelationOid(relId, RowExclusiveLock);
PortalRun | PortalRunMulti | ProcessQuery
ExecutorStart | standard_ExecutorStart
GetCurrentCommandId(true);
currentCommandIdUsed = true;
return currentCommandId;
ExecutorRun | standard_ExecutorRun | ExecutePlan | ExecProcNode
ExecModifyTable | ExecInsert | table_tuple_insert | heapam_tuple_insert
heap_insert
TransactionId xid = GetCurrentTransactionId();
finish_xact_command
CommitTransactionCommand
CommandCounterIncrement
currentCommandId += 1;
currentCommandIdUsed = false;
SnapshotSetCommandId(currentCommandId);
finish_xact_command /* This will only do something if the parsetree list was empty */
3. COMMIT;
这一步是确保“原子性”和“持久性”的关键。
A. 预写日志冲刷(WAL Flush - 持久性保证)
- 控制面调用操作系统指令(如
fsync),将 WAL Buffer 里的日志强制刷入磁盘。 - 核心逻辑:只要 WAL 落地了,即便此时掉电,数据库重启后也能根据 WAL 重建数据面。
B. 状态变更(CLOG 更新 - 原子性保证)
- 在 CLOG 中将该 XID 的状态由
IN_PROGRESS修改为COMMITTED。 - 注意:一旦 CLOG 状态改变,这个事务在逻辑上就“永久生效”了,其他事务通过 MVCC 判定就能看到这行数据。
C. 资源释放(Lock & Runtime Cleanup)
- 释放锁:调用
LockReleaseAll,释放之前持有的RowExclusiveLock(让别人可以改表结构)。 - 资源回收:销毁事务级别的 MemoryContext,清理临时内存。
- 状态归位:Backend 进程状态变回
Idle。
exec_simple_query
start_xact_command
StartTransactionCommand
break;
PortalRun | PortalRunMulti | PortalRunUtility | ProcessUtility | standard_ProcessUtility
EndTransactionBlock
s->blockState = TBLOCK_END;
finish_xact_command
CommitTransactionCommand
CommitTransaction
s->state = TRANS_COMMIT;
/* release */
ResourceOwnerRelease | ResourceOwnerReleaseInternal
ProcReleaseLocks
LockReleaseAll(DEFAULT_LOCKMETHOD, !isCommit);
s->state = TRANS_DEFAULT;
s->blockState = TBLOCK_DEFAULT;
核心技术
| 步骤 | 涉及技术(控制面/运行面) | 涉及技术(数据面) | 目的 |
|---|---|---|---|
BEGIN | Transaction State, VirtualXID | - | 环境准备 |
INSERT | Lock Manager, Syscache, XID | Shared Buffer, FSM, WAL Buffer | 逻辑执行与物理写入 |
COMMIT | CLOG, MVCC | Disk (WAL File) | 状态确认与持久化 |
作用
- 锁(Lock) 保证执行时没人捣乱
- MVCC/CLOG 保证了别人什么时候能看到修改
- WAL 保证了修改绝对不会丢
- Buffer 保证了操作数据时的极致速度
Transaction State
TBlockState: 事务块状态
/*
* transaction block states - transaction state of client queries
*
* Note: the subtransaction states are used only for non-topmost
* transactions; the others appear only in the topmost transaction.
*/
typedef enum TBlockState
{
/* not-in-transaction-block states */
TBLOCK_DEFAULT, /* idle */
TBLOCK_STARTED, /* running single-query transaction */
/* transaction block states */
TBLOCK_BEGIN, /* starting transaction block */
TBLOCK_INPROGRESS, /* live transaction */
TBLOCK_IMPLICIT_INPROGRESS, /* live transaction after implicit BEGIN */
TBLOCK_PARALLEL_INPROGRESS, /* live transaction inside parallel worker */
TBLOCK_END, /* COMMIT received */
TBLOCK_ABORT, /* failed xact, awaiting ROLLBACK */
TBLOCK_ABORT_END, /* failed xact, ROLLBACK received */
TBLOCK_ABORT_PENDING, /* live xact, ROLLBACK received */
TBLOCK_PREPARE, /* live xact, PREPARE received */
/* subtransaction states */
TBLOCK_SUBBEGIN, /* starting a subtransaction */
TBLOCK_SUBINPROGRESS, /* live subtransaction */
TBLOCK_SUBRELEASE, /* RELEASE received */
TBLOCK_SUBCOMMIT, /* COMMIT received while TBLOCK_SUBINPROGRESS */
TBLOCK_SUBABORT, /* failed subxact, awaiting ROLLBACK */
TBLOCK_SUBABORT_END, /* failed subxact, ROLLBACK received */
TBLOCK_SUBABORT_PENDING, /* live subxact, ROLLBACK received */
TBLOCK_SUBRESTART, /* live subxact, ROLLBACK TO received */
TBLOCK_SUBABORT_RESTART /* failed subxact, ROLLBACK TO received */
} TBlockState;
作用: 描述事务块的控制流状态,从SQL语法层面反映用户命令执行流程。
TransState: 事务状态
/*
* transaction states - transaction state from server perspective
*/
typedef enum TransState
{
TRANS_DEFAULT, /* idle */
TRANS_START, /* transaction starting */
TRANS_INPROGRESS, /* inside a valid transaction */
TRANS_COMMIT, /* commit in progress */
TRANS_ABORT, /* abort in progress */
TRANS_PREPARE /* prepare in progress */
} TransState;
作用: 描述事务本身的执行状态,从服务器内核角度反映事务进度。
两者的关系
┌─────────────────────────────────────┐
│ BEGIN; INSERT; COMMIT; │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ TBlockState (SQL) │
│ ├─ TBLOCK_DEFAULT │
│ ├─ TBLOCK_STARTED │
│ ├─ TBLOCK_BEGIN (BEGIN) │
│ ├─ TBLOCK_INPROGRESS (INSERT) │
│ └─ TBLOCK_END (COMMIT) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ TransState (SERVER) │
│ ├─ TRANS_DEFAULT │
│ ├─ TRANS_START (BEGIN) │
│ ├─ TRANS_INPROGRESS (INSERT) │
│ └─ TRANS_COMMIT (COMMIT) │
└─────────────────────────────────────┘
层次划分
| 层级 | 状态 | 职责 | 视角 |
|---|---|---|---|
| SQL 层 | TBlockState | 处理 BEGIN、COMMIT、ROLLBACK 等命令 | 用户命令流 |
| 内核层 | TransState | 管理事务的实际执行进度 | 事务执行进度 |
Transaction VXID
- 在 PostgreSQL 的事务系统中,VXID (Virtual Transaction ID) 是控制面(Control Plane)实现资源解耦与并发性能的核心设计。
- 相比于落盘的物理事务 ID (XID),VXID 是一种仅存在于内存中的轻量级身份标识。
1. 物理结构与组成
typedef struct
{
BackendId backendId; /* backendId from PGPROC */
LocalTransactionId localTransactionId; /* lxid from PGPROC */
} VirtualTransactionId;
VXID 的表现形式通常为 BackendID / LocalTransactionID(例如 3/29):
- BackendID:该后端进程在共享内存
ProcArray数组中的槽位索引。 - LocalTransactionID:该进程内部自增的 32 位序列号,记录该进程自启动以来处理的事务总数。
2. VXID 获取时机
- 当一个会话(Session)发送一条 SQL 时,执行引擎会进入 StartTransactionCommand
- Backend 进程从自己的私有内存(局部变量)中获取 nextLocalTransactionId 并自增
- 将当前进程的 BackendId(ProcArray 数组下标)与该计数值组合,生成如 3/29 的 VXID
- 记录 lxid 到 MyProc
/*
* Assign a new LocalTransactionId, and combine it with the backendId to
* form a virtual transaction id.
*/
vxid.backendId = MyBackendId;
vxid.localTransactionId = GetNextLocalTransactionId();
/*
* Lock the virtual transaction id before we announce it in the proc array
*/
VirtualXactLockTableInsert(vxid);
MyProc->lxid = vxid.localTransactionId;
3. 设计意图:减少物理 XID 分配
VXID 的存在是为了推迟并减少物理 XID 的分配。
- 降低全局竞争:只读事务(SELECT)仅持有 VXID。只有当事务触发数据修改(INSERT/UPDATE/DELETE)时,内核才会通过
GetNewTransactionId()申请全局唯一的物理 XID。 - 延缓 XID 耗尽:由于 VXID 不记录在磁盘(WAL 或数据页),它不占用 4 亿有限的 XID 空间,从根本上缓解了事务号回卷(Wraparound)的压力。
4. 运行面约束:基于 VXID 的锁定机制
VXID 在 pg_locks 视图中承担着关键的“占位”角色:
- 自我宣告:每个事务启动后,会自动持有一把类型为
virtualxid的ExclusiveLock。 - 依赖等待:当其他进程(如
VACUUM或HOT清理)需要确认某个旧事务是否结束时,它会尝试获取该 VXID 的共享锁。 - 逻辑闭环:无法获取锁意味着事务仍在运行;成功获取则代表该进程的控制逻辑已结束,相关旧版本快照可安全回收。
5. 查询 vxid
CREATE OR REPLACE VIEW vw_vxid AS
SELECT
l.pid,
l.virtualxid AS vxid,
a.backend_xid AS xid,
a.state,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.locktype = 'virtualxid' -- 只看宣告身份的那一行
AND l.granted = true; -- 只看锁的持有者
| client 1 | client 2 |
|---|---|
begin; | |
select pg_backend_pid(); --18479 | |
select * from vw_vxid where pid = 18479; | |
select pg_stat_get_backend_pid(10); | |
select txid_current_if_assigned(); | |
insert into tb values(1); | |
select * from vw_vxid where pid = 18479; | |
select txid_current_if_assigned(); | |
commit; |
[!NOTE] 注意 事务开始时即分配 vxid,但无 xid,执行插入数据时,内核分配 xid
总结
VXID 是 PostgreSQL 实现“读不阻塞写”以及“轻量级事务管理”的基石。
Transaction XID
https://www.interdb.jp/pg/pgsql05/01.html
- VXID 是事务在内存中的临时工牌
- XID (Transaction ID) 就是写入数据页(Page Header)和日志(WAL)的“永久烙印”
- XID 是 PostgreSQL 实现多版本并发控制(MVCC)与数据持久化的基石
1. 分配时机:按需升级
与 VXID 随事务启动即分配不同,XID 的分配遵循延迟加载原则:
- 只读事务:永不分配 XID,仅持有 VXID。
- 写事务:只有在事务产生数据变更(INSERT/UPDATE/DELETE)的瞬间,才会调用
GetNewTransactionId()获取一个全局唯一的 XID。 - 设计意图:保护有限的 XID 序列空间,减少非必要的全局锁(XidGenLock)竞争。
2. 物理特性:落盘的可见性标识
XID 是一个 32 位的无符号整数,它在数据面(Data Plane)承担双重任务:
- 行级标记:每一行数据(Tuple)的头部都存有
xmin(创建该行的 XID)和xmax(删除该行的 XID)。 - 可见性判定:通过对比当前快照与行头部的 XID,数据库在不加读锁的情况下,确定该行对当前事务是否可见。
3. 核心约束:事务回卷
由于 XID 仅有 32 位,其取值范围约为 0 到 42 亿。这带来了数据库内核最沉重的治理任务:事务回卷(Wraparound)。
- 逻辑环:XID 被视为一个循环圆环。环中逆时针方向的 21 亿个数字被定义为“过去”。环中顺时针方向的 21 亿个数字被定义为“未来”。
- 冷冻(Freeze):为了防止新旧事务混淆,系统必须通过
VACUUM FREEZE将老旧的 XID 转换为特殊的特殊标识(Frozen XID, 2),确保它们永远被视为“过去”。
4. 存储开销:CLOG (Commit Log)
XID 的状态(提交、回滚、运行中)并不记录在数据页,而是维护在 CLOG(又称 pg_xact)中:
- 每个 XID 在 CLOG 中占用 2 个比特位。
- 查询逻辑:当引擎看到数据页上的 XID 时,会去 CLOG 查最终结局,再决定可见性(专题:CLOG)。
5. 核心函数 GetCurrentTransactionId
/*
* GetCurrentTransactionId
*
* This will return the XID of the current transaction (main or sub
* transaction), assigning one if it's not yet set. Be careful to call this
* only inside a valid xact.
*/
TransactionId
GetCurrentTransactionId(void)
{
TransactionState s = CurrentTransactionState;
if (!FullTransactionIdIsValid(s->fullTransactionId))
AssignTransactionId(s); // Assigns a new permanent FullTransactionId to the given TransactionState
return XidFromFullTransactionId(s->fullTransactionId);
}
6. 总结:VXID 与 XID 的权责对等
| 特性 | VXID (控制面) | XID (数据面) |
|---|---|---|
| 本质 | 内存标识,处理并发冲突 | 磁盘标识,处理数据版本 |
| 持久化 | 随进程结束消失 | 永久写入 WAL 和 Page |
| 全局性 | 局部唯一(进程内) | 全局递增(实例级) |
| 成本 | 几乎为零 | 昂贵(触发 I/O、占用存储、需回卷治理) |
How: CLOG (pg_xact)
1. 定义
Heap tuple 头部保存的是创建/删除该版本的 XID(xmin / xmax),并不保存该事务的提交结果。提交结果集中存放在 CLOG(Commit Log)中,磁盘路径为 $PGDATA/pg_xact。
每个已分配 XID 占用 2 bit,对应四种状态:
| 状态 | 含义 |
|---|---|
| in progress | 尚未写入终态(事务仍在执行,或状态尚未落盘) |
| committed | 已提交 |
| aborted | 已中止 |
| subcommitted | 子事务过渡态:子事务自身已结束,其祖先尚未完成整棵事务树的最终提交标记 |
可见性判定通过 TransactionIdDidCommit / TransactionIdDidAbort 查询 CLOG;若元组上已设置相应 hint bit,则可省略本次 CLOG 查找。
CLOG 不是 undo log:它不保存旧行镜像,仅记录事务结局。未提交事务所造成的堆修改在恢复后仍可能留在页面上,对快照不可见,随后由 VACUUM 回收。
2. 独立存储的原因
若将 committed / aborted 直接写在每一行上,提交时需要更新该 XID 触及的全部页面,代价过高。按 XID 集中索引后:
- 提交路径只需更新 CLOG 中少量状态位(以及子事务树)
- 读路径根据 XID 定位到对应 SLRU 页中的 2 bit
因此 CLOG 的空间开销很小(每 XID 2 bit)。XID 相关的主要约束来自 32-bit 编号空间 及 wraparound,需通过 freeze 等机制处理,与 CLOG 位图体积无关。
3. 源码入口
| 层次 | 位置 |
|---|---|
| CLOG 读写 | src/backend/access/transam/clog.c |
| 状态查询与提交标记 | transam.c:TransactionIdDidCommit、TransactionIdAbortTree、TransactionIdCommitTree 等 |
| SLRU 缓冲 | slru.c(pg_xact 与 pg_subtrans 共用) |
| 持久化目录 | $PGDATA/pg_xact/ |
TransactionIdCommitTree 将顶级 XID 及其子 XID 标记为 committed。当事务状态跨越多个 CLOG 页时,采用 README 所述的子提交协议,以保证外部观察不会看到「部分已提交」的中间状态。
4. 提交路径中的位置
参见 Process 中的 COMMIT 流程:
- 事务执行期间,堆/索引修改及其 WAL 已按多次 MTR 写入。
- COMMIT 写入 commit 记录;是否立即
XLogFlush由synchronous_commit决定。 - 调用
TransactionIdCommitTree等将 CLOG 标记为 committed。 - 此后,其他事务的快照与可见性逻辑可将该 XID 视为已提交。
持久化顺序:在将某一 CLOG 页写出之前,影响该页的 commit WAL 必须先达到安全位点(与关系页的 WAL-before-data 规则同类)。异步提交允许 commit 记录延迟 flush,但写出 CLOG 页时仍须满足上述约束。实现上,每个 clog 缓冲页缓存若干 LSN,用于记录「写出该页前 WAL 至少需推进到的位置」(详见 transam/README 异步提交一节)。
崩溃恢复时:
- 已 flush 的 WAL 经 redo 后,页面上可能仍保留未提交 XID 的修改;
- 仅当恢复后的 CLOG 中该 XID 为 committed 时,快照才视其效果为可见;
- redo 不会依据「未提交」回滚或擦除堆修改。
5. Hint bit
频繁访问 CLOG 存在开销。Heap 元组上的 hint(如 HEAP_XMIN_COMMITTED)缓存「xmin 已确认提交」的判定结果。
设置 hint 受 WAL / CLOG 进度约束:在对应 commit 尚未安全持久化之前,不得将「已提交」hint 随数据页写出。异步提交场景下,通常对照 clog 页上缓存的 LSN 决定是否允许设置 hint;若不允许,则下次可见性检查仍查询 CLOG。
Hint 为性能优化;权威状态仍以 CLOG(含恢复后的 CLOG)为准。
6. 子事务与 subcommitted
子事务可拥有独立 XID。父事务提交时,须将整棵 XID 树一并标记为 committed。
若相关状态位位于同一 CLOG 页,可一次完成标记。若跨越多个 CLOG 页,则采用两阶段协议:
- 先将子事务标记为
subcommitted - 再将顶级事务及整树标记为
committed
子事务中止时通常立即标记为 aborted。subcommitted 在读路径上窗口极短;跨页提交协议的细节见 README: pg_xact。
父子 XID 关系保存在 pg_subtrans,不属于 CLOG。
7. 与相关模块的关系
| 模块 | 关系 |
|---|---|
| XID | 分配事务标识;CLOG 按标识存储提交状态 |
| Snapshot / Visibility | 结合 DidCommit/DidAbort 与快照判定可见性 |
| MTR | 物理原子性由单条 WAL 保证;逻辑提交结果由 top XID 的 CLOG 状态决定 |
| Crash redo | 使数据页与已 flush 的 WAL 一致;对外可见性仍取决于 CLOG |
组提交、CLOG 截断以及与 freeze / wraparound 的交界,见 XID wraparound 与 VACUUM 相关笔记。
相关笔记: XID · Process · MVCC Visibility · Mini-Transaction · Crash Recovery Redo · README: pg_xact
最后更新: 2026-07-30 | 适用版本: PostgreSQL 15.x / 16.x / devel
Transaction Isolation
Repeatable Read and SERIALIZABLE
读已提交作为大多数数据库的默认隔离级别,已被广泛应用并为开发者所熟知。其可能产生的不可重复读和幻读等现象也相对容易理解。本文将简要介绍可重复读隔离级别下可能出现的写偏斜问题,并进一步概述 PostgreSQL 串行化隔离级别的实现机制。
Repeatable Read
写偏移(Write Skew)
PostgreSQL 的事务隔离级别主要基于 MVCC(多版本并发控制)机制实现,其中读已提交(RC)与可重复读(RR)的核心差异体现在快照的获取时机:RC 隔离级别下,每条 SQL 语句执行时都会生成并使用全新的快照;而 RR 隔离级别仅在事务启动时生成一次快照,后续所有语句均复用该事务快照。这一机制从根本上让 RR 避免了不可重复读和幻读异常的发生,但需注意,可重复读隔离级别下仍可能出现 “写偏移”(Write Skew)的并发异常。
写偏移异常示例:
假设表中有两名员工A和B,用 1 表示值班,0 表示休假,规定二者不能同时休假,因此A和B申请休假时,需检查是否已有人值班,SQL模拟如下
- 初始化数据
drop table if exists stuff;
create table stuff (
name text primary key,
status int -- 0 = 休假, 1 = 值班
);
insert into stuff values ('A', 1), ('B', 1);
select * from stuff;
-- 创建 procedure 更新状态,输入参数为员工名称
create or replace procedure update_status(p_name text)
as $$
begin
update stuff
set status = 0
where name = p_name
and (select count(*) from stuff where name != p_name and status = 1) > 0;
end;
$$ language plpgsql;
- 并发更新
| Employee A | Employee B |
|---|---|
start transaction isolation level repeatable read; | start transaction isolation level repeatable read; |
call update_status('A'); | |
call update_status('B'); | |
commit; | commit; |
- 最终结果
postgres=# select * from stuff;
+------+--------+
| name | status |
+------+--------+
| A | 0 |
| B | 0 |
+------+--------+
基于完全正确的基础数据前提,因计算 / 定位时的微小偏移(如索引、行数、边界值),最终得出了完全错误的结果。
Write skew is an anomaly where two concurrent transactions each read overlapping data and then write non-overlapping fields, resulting in an overall invalid state despite no single column being overwritten.
写偏斜是一种异常现象:两个并发事务分别读取了重叠的数据集合,然后各自写入互不重叠的字段,尽管没有任何单个列被同时覆盖,但最终却导致整体数据状态变为无效或违反约束。
只读事务异常(Read-Only Transaction Anomaly)
Analyzing a read-only transaction anomaly under snapshot isolation
定义
即使一个事务只进行读操作(不修改数据),它在可重复读隔离级别下看到的数据状态,在逻辑上也可能是不一致的,无法对应任何串行执行的结果。
- 快照隔离允许事务看到某个时间点的数据版本。
- 但是,它不检查读写依赖。如果两个并发写事务修改了不同的行,但这两行数据在业务逻辑上是关联的(例如有约束关系),只读事务可能会看到一个“中间状态”,这个状态在任何串行执行顺序下都不应该存在。
示例
- Fekete 等人提供的示例涉及一家银行,客户在该银行同时拥有支票账户(checking account)和储蓄账户(savings account)
- 如果取款导致合并余额为负,则银行会收取透支费用
在示例中,支票账户和储蓄账户的初始余额均为 0,随后发生以下并发事务:
- 事务 1:向储蓄账户存入 20。
- 事务 2:从支票账户扣除 10。如果此举导致(支票账户 + 储蓄账户)变为负数,则额外扣除 1 作为透支费。
- 事务 3:读取余额(支票账户,储蓄账户)。
| Txn 1 | Txn 2 | Txn 3 |
|---|---|---|
| R(checking) → 0 | ||
| R(savings) → 0 | ||
| R(savings) → 0 | ||
| W(savings) ← 20 | ||
| Commit | ||
| R(checking) → 0 | ||
| R(savings) → 20 | ||
| Commit | ||
| W(checking) ← -11 | ||
| Commit |
分析:
- 由于事务 3 在事务 1 提交后才开始读取,因此它看到了储蓄账户的存款入账。
- 然而,因为事务 2 在事务 1 提交之前就已经启动,所以它没有看到这笔存款,因此对支票账户收取了透支费。
- 事务 1 与事务 2 是并发的,且最终结果与“事务 2 先于事务 1“的串行顺序一致。
- 然而,事务 3 的输出(与事务 2 并发但不与事务 1 并发)却与相反的串行顺序一致,即“事务 1 先于事务 2“。
- 归根结底,事务 3 的输出无法与产生最终状态的任何串行顺序相吻合。
与“幻读”的区别
- 幻读(Phantom Read):侧重于行的数量变化。比如第一次查询有 5 行,第二次查询变成了 6 行(有新行插入)。
- 只读事务异常:侧重于数据间的逻辑一致性。行数可能没变,但数据值之间的关系违反了业务逻辑或约束。
SQL语句用例:
准备数据:
-- 0. 建表
CREATE TABLE bank_accounts (
account_type VARCHAR(20) PRIMARY KEY,
balance INT
);
-- 0. 初始化数据
INSERT INTO bank_accounts (account_type, balance) VALUES ('checking', 0), ('savings', 0);
TXN 2:
-- 1.设置隔离级别
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 2. R(checking) -> 0
SELECT balance FROM bank_accounts WHERE account_type = 'checking';
-- 3. R(savings) -> 0
-- 因为事务开始得早,此时还没看到 Session 1 的提交,所以读到 0
SELECT balance FROM bank_accounts WHERE account_type = 'savings';
-- 11. W(checking) <- -11
UPDATE bank_accounts SET balance = -11 WHERE account_type = 'checking';
-- 12. Commit
COMMIT;
TXN 1:
-- 4. 设置隔离级别为 REPEATABLE READ (快照隔离)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 5. R(savings) -> 0
SELECT balance FROM bank_accounts WHERE account_type = 'savings';
-- 6. W(savings) <- 20 (实际是 update balance = balance + 20)
UPDATE bank_accounts SET balance = balance + 20 WHERE account_type = 'savings';
-- 7. Commit
COMMIT;
TXN 3:
-- 8. 在 Session 1 提交之后执行
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 9. R(checking) -> 0, R(savings) -> 20
SELECT balance FROM bank_accounts;
-- 10. Commit
COMMIT;
当修改隔离级别为 SERIALIZABLE 时, 第 11 步更新报错
ERROR: could not serialize access due to read/write dependencies among transactions Reason code: Canceled on identification as a pivot, during write.
SQL state: 40001
Detail: Reason code: Canceled on identification as a pivot, during write.
Hint: The transaction might succeed if retried.
解释:由于事务之间存在读/写依赖,无法序列化访问;PostgreSQL 的 SSI(可串行化快照隔离) 机制检测到了并发事务之间形成了循环依赖,如果允许这些事务全部提交,就会产生只读事务异常。因此,PG 主动回滚了其中一个事务来保证数据一致性。
SERIALIZABLE
PostgreSQL 的 SERIALIZABLE 隔离级别基于 SSI (Serializable Snapshot Isolation) 算法实现。
- 核心思想
SSI 的核心思想是 “乐观并发控制 + 冲突依赖监控”。
它允许事务像“可重复读”(Repeatable Read)一样完全并发执行,不引入任何阻塞读写的锁定,但在后台实时监控事务间的读写依赖关系。一旦发现可能导致逻辑不一致的“异常结构”,就强制回滚其中一个事务。
- 三个关键技术支柱
- SIReadLock (谓词锁/意向锁): 当事务读取数据时,在内存中留下一个“哨兵”。它不阻塞任何人,仅记录“我读过这个范围”。
- RW-Conflict (读写冲突检测): 如果事务 A 读了某行,随后事务 B 修改了该行,系统记录一条从 A 到 B 的依赖边($T1 \to T2$)。
- 危险结构识别 (Pivot Detection): SSI 监控依赖图中是否存在特定的环形冲突模式。关键发现:可序列化性被破坏当且仅当存在以下模式:$T1 \to T2 \to T1$ 时,即事务的读写冲突形成环,才可能发生破坏串行化的“写偏斜”异常,此时,系统中止其中一个事务以破坏环,恢复可序列化性。
- 技术对比
| 维度 | 传统可串行化 (Lock-based) | PostgreSQL SSI |
|---|---|---|
| 手段 | 悲观:读写互相阻塞(等锁) | 乐观:读写完全并发(记录依赖) |
| 性能 | 并发度低,容易死锁 | 并发度高,但冲突时需应用层重试 |
| 代价 | 时间损耗(等待) | 资源损耗(监控依赖及回滚开销) |
MVCC Snapshot
MVCC 核心: Snapshot
0. 核心用例设计
假设表 accounts 中有一条初始数据:id=1, balance=100,其事务 ID(XID)为 500。
现在有两个并发事务:
- 事务 A (XID 601):隔离级别为
READ COMMITTED。 - 事务 B (XID 602):执行
UPDATE balance = 200,但尚未提交。
create table accounts(id int, balance int);
insert into accounts values (1, 500);
| TXN A | TXN B |
|---|---|
begin; | begin; |
update accounts set balance = 200 where id = 1; | |
select * from accounts; --500 | |
commit; | |
select * from accounts; --200 | |
commit; |
- 默认隔离级别为
read committed,只要事务B提交就可以读到最新结果,因此存在不可重复读问题 - 将隔离级别修改为
repeatable read读取结果一致
| TXN A | TXN B |
|---|---|
start transaction isolation level repeatable read; | begin; |
update accounts set balance = 200 where id = 1; | |
select * from accounts; --500 | |
commit; | |
select * from accounts; --500 | |
commit; |
当指定隔离界别为 repeatable read, 两次读取数据相同,实现可重复读,且由于快照隔离的天然特性,也不存在幻读问题;实现方式:快照!
1. Snapshot 数据结构关键字段
在源码中,快照由 SnapshotData 结构体表示。它的核心就像一张“合影”,记录了那一刻全系统的事务状态。
xmin:最早的活跃事务 ID。所有 XID < xmin 的事务都已经完成了(提交或回滚),它们的数据对该快照一定可见。xmax:快照发放时,系统分配过的最大 XID + 1。所有 XID ≥ xmax 的事务在拍照片时还没出生,其数据对该快照一定不可见。xip[](Transaction ID Array):在xmin和xmax之间的“灰色地带”,记录了拍照那一刻正在运行的事务 ID 列表。
typedef struct SnapshotData
{
TransactionId xmin; /* all XID < xmin are visible to me */
TransactionId xmax; /* all XID >= xmax are invisible to me */
TransactionId *xip; /* in progress */
}
2. 行的可见性判定逻辑 mvcc_visibility
每一行数据(HeapTuple)头部都有 t_xmin(插入者的 XID)和 t_xmax(删除/更新者的 XID)。基本方式如下:
- 看插入者 (
t_xmin):
- 如果
t_xmin在快照中是“已提交”的,且不在xip[]列表中,说明插入已生效。
- 看删除者 (
t_xmax):
- 如果
t_xmax为 0,说明没被删除,可见。 - 如果
t_xmax在快照中是“活跃”的或“未出生”的,说明删除动作还没生效,可见。 - 如果
t_xmax在快照中是“已提交”的,说明行已过期,不可见。
3. GetTransactionSnapshot() 的调用时机
隔离级别决定了“拍照”的频率:
- READ COMMITTED:每条 SQL 语句执行前都会调用一次。所以你能看到其他事务刚提交的修改。
- REPEATABLE READ / SERIALIZABLE:只在事务的第一条 SQL 执行前调用一次,后续整段事务都复用这张旧照片。
if (IsolationUsesXactSnapshot()) /* 根据隔离级别判断是否复用快照 */
return CurrentSnapshot;
/* Don't allow catalog snapshot to be older than xact snapshot. */
InvalidateCatalogSnapshot();
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
return CurrentSnapshot;
4. 活跃事务数组 (ProcArray)
- 作用:它是快照数据的源泉。维护在共享内存中,记录了当前所有连接正在运行的 XID。
- 事务开始:进程将自己的 XID 填入
ProcArray。 - 事务结束:
CommitTransaction或AbortTransaction的最后阶段,进程将自己从ProcArray中移除。
5. 核心函数(GetSnapshotData)
/*
* The returned snapshot includes xmin (lowest still-running xact ID),
* xmax (highest completed xact ID + 1), and a list of running xact IDs
* in the range xmin <= xid < xmax. It is used as follows:
* All xact IDs < xmin are considered finished.
* All xact IDs >= xmax are considered still running.
* For an xact ID xmin <= xid < xmax, consult list to see whether
* it is considered running or not.
* This ensures that the set of transactions seen as "running" by the
* current xact will not change after it takes the snapshot.
*/
获取 xmax 和 xmin:
xmax = XidFromFullTransactionId(latest_completed);
TransactionIdAdvance(xmax);
/* initialize xmin calculation with xmax */
xmin = xmax;
for (int pgxactoff = 0; pgxactoff < numProcs; pgxactoff++)
{
if (NormalTransactionIdPrecedes(xid, xmin))
xmin = xid;
/* Add XID to snapshot. */
xip[count++] = xid;
}
MVCC Visibility
| client 1 | client 2 | note |
|---|---|---|
insert into tb values(1); | implicit transaction | |
begin; | ||
insert into tb values(2); | ||
select * from tb; | 2 is invisible | |
commit; | ||
select * from tb; | 2 is visible |
核心函数 HeapTupleSatisfiesMVCC
SeqNext | table_scan_getnextslot | heap_getnextslot
heapgettup_pagemode | heapgetpage
HeapTupleSatisfiesVisibility
HeapTupleSatisfiesMVCC
PostgreSQL MVCC 可见性判断核心规则
PostgreSQL 中 HeapTupleSatisfiesMVCC 函数判断元组对当前快照可见性的逻辑,可以分为两大阶段:
- 判断 tuple 是否诞生:
xmin - 判断 tuple 是否消亡:
xmax
PostgreSQL 堆表元组仅定义插入与删除两种状态。MVCC 可见性判定核心为:基于当前快照,元组创建事务(t_xmin)可见且删除事务(t_xmax)不可见。
UPDATE 操作被底层解耦为 “旧元组标记删除 + 新元组插入”,并通过 ctid 链接版本链。该设计消除了对 UPDATE 的特殊逻辑依赖,仅通过统一管理元组状态即可覆盖所有写操作,确保了内核逻辑的极简与自洽。
PostgreSQL 堆表(Heap Table)的元组管理展现了极致的逻辑简约和一致性。
核心原则
- 快照隔离:元组的可见性完全由当前事务的快照决定,只“看到”快照建立前已提交的插入,以及快照建立后未提交的删除。
- 提示位优化:通过
HEAP_XMIN_COMMITTED/HEAP_XMAX_INVALID等提示位缓存事务状态,避免重复查询事务日志,提升性能。 - 事务 ID 生命周期:每个元组的 XMIN/XMAX 都对应事务的完整生命周期(活跃、提交、终止),函数会根据其状态逐步校验。
How: WAL Record Structure & Insertion
1. 定义
一条 WAL 记录在插入前于后端本地组装:先用 XLogRegister* 登记缓冲区与载荷,再由 XLogInsert → XLogRecordAssemble 链成可写入 WAL 缓冲的 XLogRecData 链表。实现主要在 xloginsert.c。
构造原则:
- 零拷贝:
XLogRecData只保存指向调用方缓冲的指针,组装时改next,不搬数据 - 两阶段:注册收集;组装按磁盘记录布局链接(含可选 FPI)
- 对象池:
XLogRecData/registered_buffer预分配,插入结束后重置计数,避免频繁 malloc
2. 关键文件与 API
- 实现:
src/backend/access/transam/xloginsert.c - 头文件:
src/include/access/xloginsert.h、xlog_internal.h
void XLogBeginInsert(void);
void XLogRegisterData(char *data, uint32 len); /* main data */
void XLogRegisterBuffer(uint8 block_id, Buffer buffer, uint8 flags);
void XLogRegisterBufData(uint8 block_id, char *data, uint32 len); /* per-buffer data */
XLogRecPtr XLogInsert(RmgrId rmid, uint8 info);
须先 XLogBeginInsert,再 Register,最后 XLogInsert。对某一 block_id 调用 XLogRegisterBufData 之前,必须已对该 id 调用过 XLogRegisterBuffer。
3. 数据结构(简化)
typedef struct XLogRecData {
struct XLogRecData *next;
char *data;
uint32 len;
} XLogRecData;
后端静态状态(概念视图):
/* Main data:XLogRegisterData */
static XLogRecData *rdatas;
static int num_rdatas;
static XLogRecData *mainrdata_head;
static uint64 mainrdata_len;
/* 每个已注册 buffer */
typedef struct {
bool in_use;
uint8 flags;
RelFileLocator rlocator;
BlockNumber block;
Page page;
XLogRecData *rdata_head;
uint32 rdata_len;
} registered_buffer;
static registered_buffer *registered_buffers;
4. Main Data 与 BufData
Main Data(XLogRegisterData) | BufData(XLogRegisterBufData) | |
|---|---|---|
| 绑定 | 不绑定具体 page | 绑定已注册的 block_id |
| 典型内容 | 记录级元信息(offnum、flags、split 描述等) | 重做该页所需的页外载荷(新 tuple 字节、offset 列表等) |
| 大小 | 无 64KB 上限 | 单段 len ≤ 65535;可多次注册追加 |
| 与 FPI | 组装时通常仍保留 | 若该块带 full-page image,默认可省略;REGBUF_KEEP_DATA 可强制保留 |
选择:
- 载荷明确属于某一页的 redo 材料 → BufData
- 跨页共享、或仅作记录头/操作描述、或可能 >64KB → Main Data
组装顺序:按 block_id 链接各块的 FPI / BufData,最后接 Main Data。回放侧常先读 Main Data 取得上下文,再按块消费 buffer 侧数据。
/* Heap insert */
XLogBeginInsert();
XLogRegisterBuffer(0, buffer, REGBUF_STANDARD);
XLogRegisterBufData(0, newtup->t_data, newtup->t_len);
XLogRegisterData(&xlrec, sizeof(xlrec));
XLogInsert(RM_HEAP_ID, XLOG_HEAP_INSERT);
/* B-tree split(多页) */
XLogBeginInsert();
XLogRegisterBuffer(0, leftbuf, REGBUF_STANDARD);
XLogRegisterBufData(0, moved_left, len0);
XLogRegisterBuffer(1, rightbuf, REGBUF_STANDARD);
XLogRegisterBufData(1, moved_right, len1);
XLogRegisterData(&split_meta, sizeof(split_meta));
XLogInsert(RM_BTREE_ID, XLOG_BTREE_SPLIT);
5. 注册与组装
注册结束后,调用方侧逻辑上存在多条链,例如:
MainData: [xlrec] -> NULL
Buffer 0: [tuple_data] -> NULL
XLogRecordAssemble(示意)把它们收成一条链:
static XLogRecData *
XLogRecordAssemble(RmgrId rmid, uint8 info, ...)
{
XLogRecData *result = &hdr_rdt;
for (block_id = 0; block_id <= max_registered_block_id; block_id++) {
/* skip unused */
if (needs_backup)
/* link FPI chunks for this block */;
if (needs_data)
/* link rdata_head .. rdata_tail */;
}
if (mainrdata_len > 0)
/* link mainrdata_head */;
result->next = NULL;
return &hdr_rdt;
}
逻辑链:
[Header] -> [block0 ...] -> [block1 ...] -> [Main Data] -> NULL
落盘布局(概念):
+--------------------+
| XLogRecord header |
+--------------------+
| Block 0 header |
| optional FPI |
| optional BufData |
+--------------------+
| Block 1 ... |
+--------------------+
| Main Data |
+--------------------+
是否附带 FPI、以及 BufData 是否省略,见 Full Page Writes。
6. RegisterBuffer 与 RegisterBufData
XLogRegisterBuffer | XLogRegisterBufData | |
|---|---|---|
| 作用 | 声明本记录修改哪一页(及 flags) | 提供该页 redo 所需的附加字节 |
| 限制 | — | 单次长度受 uint16 约束 |
| 与 FPI | 块始终参与记录(可含 image) | 有 FPI 时默认可不写 BufData |
仅注册 buffer 而不提供 BufData(且无 FPI)时,回放往往缺少「如何改」的材料;仅有 Main Data 而无 buffer 时,则缺少页定位。Heap insert 典型组合:
XLogBeginInsert();
XLogRegisterBuffer(0, buffer, REGBUF_STANDARD);
xlrec.offnum = ItemPointerGetOffsetNumber(&newtup->t_self);
XLogRegisterData((char *) &xlrec, SizeOfHeapInsert);
XLogRegisterBufData(0, (char *) newtup->t_data, newtup->t_len);
XLogInsert(RM_HEAP_ID, XLOG_HEAP_INSERT);
| 操作 | Buffer | BufData | Main Data |
|---|---|---|---|
| Heap Insert | 目标页 | tuple 字节 | offnum / flags |
| B-tree Split | 多页 | 迁到各页的内容 | split 元信息 |
| Page Vacuum | 清理页 | 删除的 offset 等 | 元信息 |
| Meta 页更新 | meta 页 | 常无 | 新 meta 内容 |
约束:
- 未
XLogRegisterBuffer(id, …)即对id调用XLogRegisterBufData— 非法 - 单次 BufData
len> 65535 — 须拆成多次注册,或改走 Main Data
7. 小结
- 插入路径 = 注册(多链)+ 组装(按 block 再 Main)+ 写入 WAL。
- 三类登记:
RegisterBuffer(页)、RegisterBufData(页附属 redo 数据)、RegisterData(记录级 Main Data)。 - 指针链表 + 对象池降低拷贝与分配;FPI 与 BufData 的取舍在组装阶段决定。
相关笔记: Full Page Writes · XLogRecPtr (LSN) · Mini-Transaction · Crash Recovery Redo · insert 链路
最后更新: 2026-08-03 | 适用版本: PostgreSQL 15.x / 16.x / devel
What & Why: XLogRecPtr (LSN)
1. What is LSN
LSN(Log Sequence Number)在源码里就是 XLogRecPtr:typedef uint64 XLogRecPtr,表示 WAL 字节流上的位置(当前时间线上的偏移)。
显示格式(LSN_FORMAT_ARGS):
0/102BEB10 → 高 32 位 / 低 32 位,合起来一个 64 位偏移
InvalidXLogRecPtr = 0 表示无效位置。
2. 核心设计思想
- 统一坐标:WAL 记录、数据页、checkpoint、复制、PITR 都用同一套 LSN 刻度
- 记录链:每条 WAL 的
xl_prev指向上一条的 start,顺序回放时校验链接 - 页版本戳:
pd_lsn记下「最后一次改这页的 WAL 记录结束位置」,redo / FPW 都靠它比较 - 持久边界:Insert → Write → Flush 三级指针,区分「已生成 / 已写内核 / 已落盘」
3. 关键文件与 API
| 概念 | 源码 / SQL |
|---|---|
| 类型定义 | src/include/access/xlogdefs.h — XLogRecPtr |
| 记录头 | src/include/access/xlogrecord.h — xl_prev |
| 页 LSN | src/include/storage/bufpage.h — pd_lsn / PageGetLSN / PageSetLSN |
| 插入返回值 | src/backend/access/transam/xloginsert.c — XLogInsert → EndPos |
| 恢复起点 | src/include/catalog/pg_control.h — CheckPoint.redo |
| 共享指针 | src/backend/access/transam/xlog.c — GetRedoRecPtr / GetFlushRecPtr / GetXLogWriteRecPtr |
| SQL 观测 | src/backend/access/transam/xlogfuncs.c — pg_current_wal_* |
4. 三种 LSN 角色(先分清)
4.1 WAL 记录上的 LSN
一条记录在字节流上占 [start, end):
| 字段 / 工具输出 | 含义 |
|---|---|
xl_prev | 上一条记录的开始位置(ReserveXLogInsertLocation 写入) |
XLogInsert() 返回值 | 本条记录的结束位置(EndRecPtr) |
pg_waldump 的 lsn: | 本条记录的 EndRecPtr |
pg_waldump 的 prev: | 本条 xl_prev = 上一条记录的 start |
4.2 数据页上的 LSN(page_lsn)
页头 pd_lsn 注释(bufpage.h):
next byte after last byte of xlog record for last change to this page
即:最后一次修改该页的 WAL 记录的 EndRecPtr。
典型路径(heap_insert):
recptr = XLogInsert(...);
PageSetLSN(page, recptr);
pageinspect 里 page_header.lsn 应与对应 Heap WAL 记录的 lsn 一致(见 pageinspect、traces/01_insert.md 实验)。
4.3 系统级 LSN 指针
| 指针 | 获取函数 | 含义 |
|---|---|---|
| Insert | GetXLogInsertRecPtr() | WAL 已保留到的位置(end+1,下一条从这里插);pg_current_wal_insert_lsn() |
| Write | GetXLogWriteRecPtr() | 已写入 OS 缓存;pg_current_wal_lsn() |
| Flush | GetFlushRecPtr() | 已 fsync 到盘;pg_current_wal_flush_lsn() |
| Redo | GetRedoRecPtr() | 当前 checkpoint 的恢复起点 |
关系(正常主库,瞬时值可能有微小先后差):
Insert LSN ≥ Write LSN ≥ Flush LSN
Insert 领先 Write:后端已 XLogInsert 进共享缓冲,walwriter 尚未 write()。Write 领先 Flush:已 write() 进内核缓存,尚未 fsync(synchronous_commit=off 时 COMMIT 后常见)。
COMMIT 时 XLogFlush(commit_lsn) 推进 Flush,保证已提交事务的 WAL 可崩溃恢复。
RedoRecPtr(CheckPoint.redo):最近一次 checkpoint 开始时记下的「下一条可用 LSN」;崩溃恢复从此重放。也是 FPW 里 page_lsn <= RedoRecPtr 的参照点(见 Full Page Writes)。
5. 比较语义
5.1 Redo 跳过
XLogReadBufferForRedoExtended(xlogutils.c):
lsn = record->EndRecPtr;
if (lsn <= PageGetLSN(page))
return BLK_DONE; /* 页已包含本条及更早的修改 */
页上 LSN 是「已应用到的 WAL 位置」;当前记录结束位置不比页新 → 不必再 redo。
5.2 FPW 首次修改
needs_backup = (PageGetLSN(page) <= RedoRecPtr);
页 LSN 还没越过本次 checkpoint 的 redo 点 → 本周期内首次 WAL 修改 → 附带 FPI。
6. 物理落点(简图)
WAL 按 segment 文件(pg_wal/000000010000000000000001)顺序追加;segment 内再按 XLOG 页(通常 8KB)切分。XLogRecPtr 是跨 segment 的全局字节偏移,XLByteToSeg 等宏负责换算文件名与段内偏移。
7. 实验(与 INSERT trace 衔接)
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_lsn() AS write_lsn,
pg_current_wal_flush_lsn() AS flush_lsn;
-- 记下 insert_lsn 后
INSERT INTO tb VALUES (1);
COMMIT;
SELECT pg_current_wal_flush_lsn(); -- 应 ≥ insert_lsn
SELECT lsn FROM page_header(get_raw_page('tb', 0)); -- 与 pg_waldump 中 Heap 记录 lsn 对齐
pg_waldump -s <insert_lsn> -n 3
对照:prev(上条 start)→ lsn(本条 end+1)→ 页 pd_lsn(= 改页那条 WAL 的 end+1)。
8. 速查
| 问题 | 答案 |
|---|---|
| LSN 是什么类型? | uint64 字节偏移 |
xl_prev 存什么? | 上一条记录的 start |
page_lsn / pg_waldump lsn 存什么? | 本条 WAL 的 end+1(EndRecPtr) |
pg_current_wal_insert_lsn 是什么? | 全局最新 end+1(下一条插入位置) |
| 恢复从哪开始? | 最近 checkpoint 的 redo / GetRedoRecPtr() |
pg_current_wal_lsn 是刷盘了吗? | 否,只到 Write;刷盘看 pg_current_wal_flush_lsn |
| 和事务提交的关系? | COMMIT 记录写入后 XLogFlush,把 Flush 推到 commit LSN |
9. 总结
- What:
XLogRecPtr= WAL 上的 64 位位置;对外常叫 LSN。 - Why:同一刻度串联 WAL 链、页版本、恢复起点与复制位点。
- How:改页 →
XLogInsert得 EndRecPtr →PageSetLSN;恢复 / FPW 用<=比较页 LSN 与记录 / redo 位置。
相关笔记: Full Page Writes · WAL Record Structure & Insertion · insert 链路
最后更新: 2026-07-16 | 适用版本: PostgreSQL 15.x / 16.x / devel
What & Why: Mini-Transaction
1. What is MTR
- MTR: Mini-Transaction, 是比 SQL 事务更小的物理原子单元:改共享缓冲、写入一条 WAL、再更新相关页的
pd_lsn。崩溃恢复时,这条 WAL 对应的改动要么整段生效,要么整段不生效。 - 实现: atomic action。一条 WAL 记录 = 一次可 redo 的原子动作
START_CRIT_SECTION/END_CRIT_SECTION包住改页与记 WAL
nbtree README:
A single WAL entry is effectively an atomic action
- 用户事务(top-level,
BEGIN…COMMIT):逻辑原子边界;靠 top XID / CLOG / 可见性回答「整笔是否已提交」 - 子事务(SAVEPOINT / 内部 subxact):嵌在用户事务里的逻辑子边界;可有自己的 XID,但最终仍随祖先提交或回滚;回答「这段逻辑改动在父事务内是否保留」
- MTR(atomic action):物理原子边界;一条 WAL(可多页)回答「这条记录覆盖的页改动在 redo 时是否一体」
一次用户事务(及其子事务)里通常有许多条 MTR;子事务回滚只改逻辑可见性,不撤销「已写出的 WAL 物理原子性」。
2. 核心设计思想
Redo 的原子边界 = 一条 WAL 记录。
判定标准:哪些页改动若只应用一半,读者/后续插入会看到结构上不可搜索或不可修复的状态?这些必须同记一条。
以 B-tree split 为例,一条 XLOG_BTREE_SPLIT 至少要盖住本层一体:
- 左页(收缩、high key、
INCOMPLETE_SPLIT…) - 右页(新页 + 挪过去的元组)
- 原右兄弟的 left-link(若有)
可以不在这一条里:往父页插 downlink(下一条 WAL)。两步之间允许「缺 downlink」,但靠 flag + 后续插入可补完,搜索仍可用。
配套约束:这些改动包在同一临界区里写 WAL;半路 ERROR→PANIC,避免「缓冲已脏、WAL 未记」。
3. 关键文件与 API
| 概念 | 源码 / 文档 |
|---|---|
| 临界区 | src/include/miscadmin.h — START_CRIT_SECTION / END_CRIT_SECTION |
| 改页标准序 | src/backend/access/transam/README(预写日志编码);heap_insert / heap_update |
| 多页注册 | src/backend/access/transam/xloginsert.c — XLogBeginInsert / XLogRegisterBuffer / XLogInsert |
| B-tree 原子动作 | src/backend/access/nbtree/README(WAL / incomplete split);nbtxlog.c / _bt_split |
标准骨架(与 INSERT / UPDATE trace 一致):
pin + exclusive lock page(s)
-> (outside crit: space checks etc.; ERROR ok)
START_CRIT_SECTION()
-> modify shared buffers
-> MarkBufferDirty
-> XLogBeginInsert / Register* / XLogInsert
-> PageSetLSN(each touched page, EndRecPtr)
END_CRIT_SECTION()
unlock / unpin
临界区内出错会 PANIC:共享缓冲已有未记 WAL 的修改,不能让这类脏页刷盘。空间是否足够等检查必须放在 START_CRIT_SECTION 之前。
危险窗口:已改页并 MarkBufferDirty,但 XLogInsert 尚未成功。此时页上 pd_lsn 往往仍是旧值;若仅 ERROR 回滚而后端继续运行,bgwriter 可能按旧 LSN 刷脏,把未进 WAL 的内容写入数据文件,恢复时又因页 LSN 够新而跳过 redo。PANIC 丢掉共享内存并走 crash recovery,避免这种静默损坏。
4. 与用户事务的分层
4.1 两层原子性
| 层 | 粒度 | 保证 |
|---|---|---|
| 用户事务 | BEGIN…COMMIT | 多语句逻辑原子;靠 top XID / CLOG / 可见性 |
| 子事务 | SAVEPOINT / 内部 subxact | 父事务内的逻辑子边界;提交仍取决于祖先 |
| MTR / atomic action | 一条 WAL(可多页) | 崩溃后 redo 时,该记录覆盖的物理状态自洽 |
COMMIT 之前崩溃:事务逻辑上未提交,但已写入的 WAL 仍会 redo;页上物理修改可以留下,靠 MVCC 对未提交 XID 不可见。
MTR 约束的是另一类不变式:同一条 redo 记录涉及的多页,不能只应用一半。
4.2 单页与多页
普通 heap insert:一页 + 一条 XLOG_HEAP_INSERT,本身就是一个 MTR。
B-tree 页分裂时,一条 XLOG_BTREE_SPLIT 覆盖本层必须一体的改动;父页 downlink 另写一条 WAL:
XLOG_BTREE_SPLIT (one atomic action)
left page (shrink, high key, INCOMPLETE_SPLIT, ...)
right page (new page + moved tuples)
old right sibling left-link (if any)
next WAL record: insert downlink in parent
(may itself split and emit more records)
注册形态(buffer 列表可再含兄弟页;BufData / MainData 见 WAL Record Structure & Insertion):
XLogBeginInsert();
XLogRegisterBuffer(0, leftbuf, REGBUF_STANDARD);
XLogRegisterBuffer(1, rightbuf, REGBUF_STANDARD);
/* sibling buffers, BufData / MainData ... */
XLogInsert(RM_BTREE_ID, XLOG_BTREE_SPLIT);
nbtree README:分裂由多个 atomic action 组成。两步之间崩溃会缺 downlink;靠 INCOMPLETE_SPLIT 与后续插入补完,搜索仍可用。
WAL redo 的原子边界是记录,不是 SQL 事务。必须同进同退的多页物理不变式,收进同一条 WAL,并包在同一临界区里。
4.3 与 FPW / LSN
- FPW:防单页半写。MTR 里注册的每个 buffer 仍按
page_lsn <= RedoRecPtr决定是否拍 FPI(Full Page Writes)。 - LSN:
XLogInsert返回的 EndRecPtr 写到本条记录改过的每一页的pd_lsn;redo 用lsn <= PageGetLSN判断该页是否已含本 atomic action(XLogRecPtr (LSN))。
职责划分:MTR 决定「哪些页挂在同一条 WAL」;FPW / LSN 决定「如何防半写、如何判断该记录是否已应用到某页」。
5. 跨多条 WAL 的中间状态
多级索引插入拆成一串 MTR;每条结束后树必须可搜索:
MTR-1: leaf split
(downlink may be missing; left page has INCOMPLETE_SPLIT)
crash A: readers follow right-link;
inserters finish split when flag seen
MTR-2: insert downlink in parent; clear incomplete flag
crash B: structure complete
(parent full -> more MTRs up the tree)
约束(transam/README / nbtree README):
- 正常运行时,子页写锁跨过 MTR-1→MTR-2,挡住的是第二个插入者/writer(防其重复补完分裂);读者本就忽略该 flag,靠右移穿过。
- 崩溃后可能看到 incomplete;算法必须可处理(lazy finish,不在 end-of-recovery 强行补完)。
- Hot Standby 重放时,每条 WAL 独立回放:跨层锁耦合不重建(读者不关心 incomplete flag),但同层锁仍按主库方式持有,避免读者看到同层不一致。
6. 速查
| 问题 | 答案 |
|---|---|
| MTR 是用户事务的子集吗? | 不是同一层;是物理 WAL 原子单元 |
| 原子边界是什么? | 一条 WAL 记录(可含多 block) |
| 为何 critical section 内 ERROR→PANIC? | 改页与记 WAL 之间失败会留下未记录脏页;ERROR 清不掉共享缓冲 |
| 单页 heap insert 算 MTR 吗? | 算;最简单形态 |
| B-tree split 几个 MTR? | 本层 split 一条;父级 downlink(及可能的上层 split)另算 |
| 与 InnoDB MTR? | 「Mini-Transaction」是借用 InnoDB/ARIES 叫法;PG 源码几乎只用 atomic action |
7. 总结
- What:MTR = 临界区包住的「改(多)页 + 一条 WAL + 统一
PageSetLSN」;源码称 atomic action。 - Why:崩溃恢复按 WAL 记录 redo;多页不变式必须同记一条,否则半分裂。
- 边界:整事务可拆成多条 MTR;中间态须对搜索/插入可修复(incomplete split)。
- 范围外:
_bt_split/XLOG_BTREE_SPLIT的逐页字段与实验对照,放在 nbtree 写路径笔记。
相关笔记: XLogRecPtr (LSN) · Full Page Writes · WAL Record Structure & Insertion · nbtree README · insert 链路
最后更新: 2026-07-20 | 适用版本: PostgreSQL 15.x / 16.x / devel
Why: Full Page Writes
1. What is FPW
Full Page Writes(FPW):在增量 WAL 之外,条件满足时把整张数据页(通常 8KB)写入 WAL。这份拷贝称 Full Page Image(FPI) / backup block。
- FPW:机制(GUC
full_page_writes) - FPI:WAL 中的整页字节
回放时若记录带可 APPLY 的 FPI,先整页覆盖,再处理后续增量。
2. 核心设计思想
- 问题:页 8KB、扇区常 512B,崩溃可能留下新旧拼盘的半写页(torn page);8KB ÷ 512B = 16 次扇区写(物理原子写)
- 解法:每个 checkpoint 周期内,页的首次修改附带 FPI(page 全量),之后只记增量
- 代价:WAL 体积增大,换崩溃后可恢复的一致页
3. 关键文件与 API
源代码:
src/backend/access/transam/xloginsert.c—XLogRecordAssemble/XLogCheckBufferNeedsBackupsrc/backend/access/transam/xlog.c—fullPageWrites/doPageWrites/GetFullPageWriteInfo/UpdateFullPageWritessrc/backend/access/transam/xlogutils.c—XLogReadBufferForRedoExtended/RestoreBlockImagesrc/include/access/xlogrecord.h—BKPBLOCK_HAS_IMAGE/BKPIMAGE_APPLY
配置: full_page_writes(默认 on)
核心判定入口(组装前):
GetFullPageWriteInfo(&RedoRecPtr, &doPageWrites);
rdt = XLogRecordAssemble(rmid, info, RedoRecPtr, doPageWrites, ...);
4. Why:半写页为何致命
WAL 正常路径:
改共享缓冲中的页 → 写增量 WAL → PageSetLSN →(稍后)刷脏页到数据文件
崩溃恢复从最近 checkpoint 的 redo 点重放。增量记录的隐含前提:
磁盘上该页 = 记录插入之前的完整、一致内容;只需按 WAL 再改一遍。
若刷脏页时 OS/磁盘只写完部分扇区:
理想 8KB 页: [AAAAAAAA][AAAAAAAA][AAAAAAAA][AAAAAAAA]
半写后: [BBBBBBBB][BBBBBBBB][AAAAAAAA][AAAAAAAA] ← 新旧拼盘
此时:
- 页校验和(若开启)会失败;
- 更糟的是无校验和时:页看起来「能读」,但混了新旧字节;
- 再套用「在干净旧页上 redo 增量」会得到错误结果,且可能无声损坏。
官方文档(wal.sgml / full_page_writes GUC)把这一点说死:进程中的 page write 可能只完成一部分,行级变更数据不足以完全恢复该页。
FPW 的一句话回答 Why:在页有机会被半写刷盘之前,先在 WAL 里留下一份完整「底片」;恢复时先整页覆盖,再(若有)应用后续增量。
5. When:何时拍整页映像
决策在 XLogRecordAssemble(xloginsert.c):
if (regbuf->flags & REGBUF_FORCE_IMAGE)
needs_backup = true;
else if (regbuf->flags & REGBUF_NO_IMAGE)
needs_backup = false;
else if (!doPageWrites)
needs_backup = false;
else
{
XLogRecPtr page_lsn = PageGetLSN(regbuf->page);
needs_backup = (page_lsn <= RedoRecPtr); /* 本 checkpoint 周期内首次修改 */
}
| 条件 | needs_backup | 含义 |
|---|---|---|
REGBUF_FORCE_IMAGE | true | 调用方强制 FPI(大改页时差分不划算) |
REGBUF_NO_IMAGE | false | 明确不需要防半写(罕见) |
!doPageWrites | false | full_page_writes=off 且无在线备份 |
page_lsn <= RedoRecPtr | true | 自上次 checkpoint redo 点以来尚未改过 → 首次修改,拍 FPI |
page_lsn > RedoRecPtr | false | 本周期已拍过 / 已有更新 LSN → 只写增量 |
doPageWrites 定义(xlog.c):
doPageWrites = (Insert->fullPageWrites || Insert->runningBackups > 0);
即:GUC 开启 或 有在线备份在跑,都必须拍 FPI(备份依赖 WAL 中的完整页映像)。
有 FPI 时,默认省略 XLogRegisterBufData(除非 REGBUF_KEEP_DATA):
needs_data = !needs_backup; /* 有整页映像则增量 buf data 可省 */
6. How:如何使用 image
6.1 写入侧
include_image = needs_backup || (info & XLR_CHECK_CONSISTENCY):
- 真正需要备份时置
BKPIMAGE_APPLY(回放必须覆盖); - 仅一致性检查时也可能带映像,但不一定 APPLY。
标准页(REGBUF_STANDARD)可挖「洞」:pd_lower~pd_upper 之间的空闲区不写入 WAL;可选 wal_compression 再压一档。
组装后的 block 布局(有 FPI 时):
[Block Header | BKPBLOCK_HAS_IMAGE]
[Image Header | length / hole / compress flags]
[Full-Page Image 字节(可跳洞、可压缩)]
[可选 Buffer Data | 仅 KEEP_DATA]
6.2 回放侧
XLogReadBufferForRedoExtended:
if (XLogRecBlockImageApply(record, block_id))
{
/* 读入缓冲 → RestoreBlockImage 整页覆盖 → PageSetLSN → dirty */
return BLK_RESTORED; /* 调用方通常不必再套增量 redo */
}
else if (lsn <= PageGetLSN(page))
return BLK_DONE; /* 页已更新到此 LSN 之后,跳过 */
else
return BLK_NEEDS_REDO; /* 在现有页上应用增量 */
崩溃恢复时:若磁盘页已半写,FPI 直接盖掉;若页完好且 LSN 已够新,跳过。
6.3 与 WILL_INIT / INSERT+INIT 的关系
REGBUF_WILL_INIT / BKPBLOCK_WILL_INIT:不拍 FPI,但 redo 必须用 RBM_ZERO_* 从零重建页。
空表首插的 INSERT+INIT(见 traces / L001)走的是「整页重建」路径,效果上同样避开「在半写旧页上套增量」——与 FPW 是两类互斥手段:
| 手段 | 何时 | 基线从哪来 |
|---|---|---|
| Full Page Image | 本周期首次改已有页 | WAL 里的整页底片 |
| WILL_INIT | 新建 / 重初始化页 | redo 清零后按记录重建 |
7. 与 Hint Bits / Checksum 的边角(略)
普通 hint bit 更新默认不记 WAL。但若开了 data checksums 或 wal_log_hints:
#define XLogHintBitIsNeeded() (DataChecksumsEnabled() || wal_log_hints)
半写会让「任意 bit 组合都逻辑自洽、但校验和错乱」。于是通过 XLogSaveBufferForHint 在本 checkpoint 周期内对该页补一次 FPI(XLOG_FPI_FOR_HINT),保护校验和语义。
8. 性能与运维要点
| 点 | 说明 |
|---|---|
| WAL 膨胀 | 每个页每 checkpoint 周期最多一次 ~8KB 映像(可挖洞/压缩) |
| 降低代价 | 拉长 checkpoint_timeout / max_wal_size → 单位时间 FPI 次数下降 |
| 何时可关 | 文件系统保证无 partial page write(如部分 ZFS 场景);风险类似关 fsync,需同等谨慎 |
| 在线备份 | 备份期间强制 FPI |
| 与 MySQL Doublewrite | 同为防半写;PG 把底片放进 WAL,不另建 doublewrite buffer |
9. 判定速查
改页且要写 WAL?
├─ REGBUF_WILL_INIT → 不拍 FPI,redo 清零重建
├─ REGBUF_FORCE_IMAGE → 必拍 FPI
├─ REGBUF_NO_IMAGE → 不拍
├─ !doPageWrites → 不拍(配置关且无备份)
└─ page_lsn <= RedoRecPtr → 拍 FPI(本周期首次)
else → 只写增量 BufData / MainData
10. 总结
- Why:8KB 页可能半写;增量 WAL 不能在「拼盘页」上安全 redo。
- What:checkpoint 后每页第一次修改附带 Full Page Image。
- How:
XLogRecordAssemble用doPageWrites+page_lsn <= RedoRecPtr判定;redo 走RestoreBlockImage。 - Trade-off:WAL 变大 ↔ 崩溃后页可重建;拉长 checkpoint 或压缩是常见降本手段。
相关笔记: WAL Record Structure & Insertion · WAL Recovery · Base Backup / runningBackups · insert 链路
最后更新: 2026-07-16 | 适用版本: PostgreSQL 15.x / 16.x / devel
Recovery
Failure Domain
| 等级 | 故障域(Failure Domain) | 典型场景 | RTO | RPO |
|---|---|---|---|---|
| L1 | 数据对象级故障(Object-Level Failure) | DROP TABLE、误 DELETE、误 UPDATE | 分钟级 | 0~秒级 |
| L2 | 实例级故障(Instance Failure) | PostgreSQL 进程崩溃、OS Crash | 秒级~分钟级 | 0 |
| L3 | 节点级故障(Node Failure) | 主机损坏、磁盘损坏、电源故障 | 秒级~分钟级 | 0~秒级 |
| L4 | 集群级故障(Cluster Failure) | 主库与备库同时损坏、存储阵列损坏 | 小时级 | 分钟级~小时级 |
| L5 | 站点级灾难(Site Disaster) | 机房断电、火灾、洪水、网络隔离 | 小时级~天级 | 分钟级~小时级 |
| L6 | 区域级灾难(Regional Disaster) | 城市级停电、区域网络瘫痪 | 天级 | 小时级~天级 |
Recovery Mechanism
| 层级 | 故障域 (Failure Domain) | 能力类别 (Capability) | 恢复机制 (Recovery Mechanism) | 典型场景 |
|---|---|---|---|---|
| L1 | Object | Logical Recovery | PITR / Flashback | DROP TABLE、误 DELETE、误 UPDATE |
| L2 | Instance | Crash Recovery | WAL Replay | PostgreSQL 进程崩溃、OS Crash |
| L3 | Node | High Availability | Failover / Switchover | 主机损坏、磁盘损坏、电源故障 |
| L4 | Cluster | Backup Recovery | Restore + PITR | WAL归档系统损坏、脑裂 |
| L5 | Site | Metro Disaster Recovery | Metro DR Failover | 机房断电、火灾、洪水、网络隔离 |
| L6 | Region | Geo Disaster Recovery | Geo DR Failover | 城市级停电、区域网络瘫痪 |
How: Crash Recovery Redo Path
1. What is Crash Recovery Redo
Crash recovery:实例非正常退出后,Startup 进程从最近一次 checkpoint 记下的 CheckPoint.redo 起,按序重放 WAL,直到本地 WAL 末尾(已 flush 的部分),把数据文件推回与 WAL 一致的状态。
本笔记只覆盖本地崩溃恢复的 redo 主路径。Archive recovery / PITR、流复制持续 apply、restartpoint 另篇。
2. 核心设计思想
- 问题:共享缓冲在进程死后作废;数据文件上的脏页可能未刷完,且可能停在任意 LSN。仅靠数据文件无法知道「缺了哪些已持久化的修改」。
- 解法:持久真相在已 flush 的 WAL。从
redo点顺序rm_redo;页上pd_lsn决定跳过或应用;有 FPI 则先整页覆盖。 - 边界:只保证 flush 到盘的 WAL;未 flush 的提交按
synchronous_commit语义可能丢失。Redo 不「撤销」用户事务,未提交 XID 靠 MVCC / CLOG 不可见。
3. 关键文件与 API
| 概念 | 源码 |
|---|---|
| Startup 进程入口 | src/backend/postmaster/startup.c |
| 启动与控制文件 | src/backend/access/transam/xlog.c — StartupXLOG |
| 恢复主循环 | src/backend/access/transam/xlogrecovery.c — PerformWalRecovery |
| 读缓冲 / 跳过 / FPI | src/backend/access/transam/xlogutils.c — XLogReadBufferForRedoExtended |
| 检查点记录 | src/include/catalog/pg_control.h — CheckPoint(含 redo) |
| 资源管理器表 | src/backend/access/rmgrdesc.c / 各 *xlog.c — rm_redo |
| Heap / B-tree redo | heapam_xlog.c、nbtxlog.c 等 |
控制文件里与起点相关的字段:
| 字段 | 含义 |
|---|---|
CheckPoint.redo | 本次 checkpoint 开始时的「下一条可写 LSN」;崩溃恢复从此读起 |
state | 如 DB_SHUTDOWNED / DB_IN_CRASH_RECOVERY / DB_IN_PRODUCTION |
| timeline / WAL 位置 | 决定打开哪条时间线、哪个 segment |
4. 为何从 redo 起、而非从「上次刷脏」起
Checkpoint 并不保证「redo 点之后的脏页都已落盘」;它保证的是:
- 写下一条 checkpoint WAL,并把控制文件里的
redo指到该点(或该点附近约定位置); redo之前的修改,对应脏页会在 checkpoint 完成前刷到数据文件(或等价地可被后续逻辑覆盖)。
因此崩溃后:
data files: pages durable up to roughly last completed checkpoint
WAL: flushed records from CheckPoint.redo .. EndOfWAL
replay: apply that WAL range onto data files
redo 之后、崩溃之前已刷盘的页:页上 pd_lsn 已较新 → redo 时 BLK_DONE,不重复改。redo 之后未刷盘的页:靠 WAL(增量或 FPI)重建。
5. 启动到恢复结束的时序
postmaster
-> fork Startup
-> StartupXLOG
read ControlFile / last CheckPoint
if clean shutdown (DB_SHUTDOWNED):
skip crash redo (or minimal validation)
else:
enter crash recovery
PerformWalRecovery
loop:
ReadRecord
ApplyWalRecord
RmgrTable[rmid].rm_redo(record)
until end of available WAL
end-of-recovery checkpoint
-> signal postmaster: recovery finished
-> start normal backends
PerformWalRecovery 内对每条记录的处理骨架:
record = ReadRecord(...)
rmid = XLogRecGetRmid(record)
RmgrTable[rmid].rm_redo(record)
// typical page-touching rm_redo:
XLogReadBufferForRedo(record, block_id, &buf)
-> BLK_RESTORED | BLK_DONE | BLK_NEEDS_REDO
if NEEDS_REDO: apply incremental change; PageSetLSN; MarkBufferDirty
资源管理器按记录类型分发:RM_HEAP_ID → heap redo,RM_BTREE_ID → btree redo,checkpoint / clog / 等各有入口。一条 WAL 一个 atomic action,与 Mini-Transaction 一致。
6. 单页:何时跳过、何时套增量、何时整页覆盖
XLogReadBufferForRedoExtended(与 Full Page Writes、LSN 对照):
| 返回 | 条件 | 行为 |
|---|---|---|
BLK_RESTORED | 记录带须 APPLY 的 FPI | 整页覆盖,通常不再套本条增量 |
BLK_DONE | record->EndRecPtr <= PageGetLSN(page) | 页已含本条及更早修改,跳过 |
BLK_NEEDS_REDO | 页旧于本条且无可用 FPI APPLY | 在现有页上应用增量 |
has FPI to APPLY? --yes--> RestoreBlockImage -> BLK_RESTORED
|
no
v
EndRecPtr <= page_lsn? --yes--> BLK_DONE
|
no
v
BLK_NEEDS_REDO -> rm_redo incremental apply
半写页:若本周期曾拍 FPI,恢复时先覆盖再继续;这是 FPW 存在的直接消费者。
7. 与用户事务、可见性的关系
| 现象 | 解释 |
|---|---|
| 未 COMMIT 的修改出现在 redo 后的页上 | 物理 redo 不区分是否提交;可见性看 XID / CLOG / 快照 |
COMMIT 已 XLogFlush 后崩溃 | 对应 WAL 在盘上 → redo 后事务仍提交 |
synchronous_commit=off 且 COMMIT 未 flush 就崩 | 提交可能丢失;与 redo 路径无关,是持久边界问题 |
| 临界区内 PANIC | 共享内存丢弃后走本路径;未记 WAL 的脏改不会进入 redo 输入 |
Crash recovery 不做传统 undo 日志回滚堆元组;中止未完成事务靠事务状态与 MVCC。索引 incomplete split 等中间态由访问方法在后续插入时 lazy finish(见 MTR / nbtree)。
8. 结束条件与后续
Crash recovery 读到本地可提供的 WAL 末尾(通常受 Flush 边界约束)后结束,并做 end-of-recovery checkpoint,推进控制文件中的一致点,然后才允许普通后端进入。
与后续主题的划界:
| 主题 | 与本稿差异 |
|---|---|
| Archive recovery / PITR | 还可读 archive;可在 recovery_target_* 停 |
| Hot Standby / 流复制 | 同一套 rm_redo,但 WAL 由 walreceiver 持续供给;另有 restartpoint |
| Base backup | 提供可 redo 的数据文件起点;不替代 redo 本身 |
9. 速查
| 问题 | 答案 |
|---|---|
| 从哪开始 redo? | 控制文件里最近 checkpoint 的 CheckPoint.redo |
| 谁跑恢复? | Startup 进程:StartupXLOG → PerformWalRecovery |
| 一条记录怎么应用到页? | rm_redo + XLogReadBufferForRedo* |
| 为何有的记录不改页? | page_lsn 已 ≥ 本条 EndRecPtr |
| FPI 在恢复里干什么? | 整页覆盖,躲开半写 / 缺旧基线 |
| 恢复完才能连库吗? | 是;结束后 checkpoint,再放行正常后端 |
10. 总结
- What:从
CheckPoint.redo顺序重放已 flush WAL,经各rm_redo把数据文件补到与 WAL 一致。 - Why:崩溃后共享缓冲与未刷脏页不可信;可依赖的是 WAL + 页 LSN / FPI。
- How:
StartupXLOG→PerformWalRecovery→ ReadRecord →rm_redo→BLK_RESTORED/DONE/NEEDS_REDO。 - 范围外:备库持续 apply、archive/PITR 目标点、base backup 引导。
相关笔记: WAL Recovery(故障域) · Full Page Writes · XLogRecPtr (LSN) · Mini-Transaction · Base Backup · Streaming Replication · trace: crash recovery
最后更新: 2026-07-20 | 适用版本: PostgreSQL 15.x / 16.x / devel
How: Base Backup
1. What & Why
Base backup:在线拷贝数据目录(及表空间),再配上备份窗口内的 WAL,恢复时靠 redo(含 FPI)把「不一致的文件拷贝」推到一致点。
- 问题:在线逐文件拷贝不是库级快照——各文件拷于不同时刻,对不齐同一 LSN;若拷时该页正在 flush,备份里还可能半写。
- 解法:备份会话期间强制拍 FPI(
runningBackups);从backup_label的 start LSN 起重放 WAL,用 FPI/增量把副本推齐。 - 边界:本稿讲与 FPW /
runningBackups的耦合;流复制持续 apply、增量 base backup 协议细节另篇。
稳定旧页、无并发写时:纯读文件得到的 8KB 与盘上一致,拷贝本身不会造半写。半写只来自「拷的同时该页正在被写」。FPI 解决这类并发半写;跨文件时间错位靠 start~stop 的 WAL 重放。
对照 Full Page Writes:crash recovery 防本机半写;base backup 防备份窗口内并发写造成的副本半写,并消化跨文件不一致。
2. 核心设计思想
Crash recovery(full_page_writes) | Base backup(runningBackups) | |
|---|---|---|
| 触发 | GUC;按 checkpoint 周期 page_lsn <= RedoRecPtr 首次改页拍 FPI | 备份进行中强制 doPageWrites |
| 保护对象 | 本机数据文件 + 本地 WAL | 备份副本中的页 + 备份窗口 WAL |
| 关闭 GUC | full_page_writes=off 可关(有风险) | runningBackups > 0 时仍必须拍 FPI |
| 恢复入口 | Startup crash redo | 恢复簇 + backup_label 指引的 WAL 区间 |
doPageWrites(写入侧本地缓存,权威在 XLogCtl->Insert):
doPageWrites = (Insert->fullPageWrites || Insert->runningBackups > 0);
/* 或等价:forcePageWrites || fullPageWrites;force 随 runningBackups 置位 */
一句话:有在线备份时,即使关掉 full_page_writes,WAL 仍必须带够 FPI,否则从该备份恢复时无法修好拷贝里的坏页。
3. 关键文件与 API
| 概念 | 源码 / 入口 |
|---|---|
| 备份开始 / 结束 | xlog.c — do_pg_backup_start / do_pg_backup_stop(SQL:pg_backup_start / pg_backup_stop) |
| 计数器 | XLogCtl->Insert.runningBackups;lastBackupStart |
| 组装是否拍 FPI | GetFullPageWriteInfo → XLogRecordAssemble / XLogCheckBufferNeedsBackup(同 FPW) |
| 复制协议拷贝 | backup/basebackup*.c;客户端 pg_basebackup |
| 恢复元数据 | 数据目录内 backup_label(及 tablespace_map) |
非独占备份(现行默认路径):不写 exclusive lock 文件挡其它备份;可与 pg_basebackup 并发会话,每个会话 runningBackups++。
4. 时序
pg_backup_start / BASE_BACKUP start
-> (often) CHECKPOINT
-> WALInsertLock; runningBackups++
-> record start LSN (backup start location)
copy PGDATA / tablespaces // files may be torn or mutually inconsistent
pg_backup_stop
-> record stop LSN
-> runningBackups--
-> need WAL [start .. stop] (archive or stream with backup)
restore:
place files + backup_label
Startup recovery applies WAL until consistent (FPI / incremental)
拷贝窗口内单页:可能是稳定旧/新镜像(字节完整),或并发 flush 下的半写。恢复不依赖「整库拷齐」,依赖 start 之后 WAL 里的 FPI/增量。
5. 与 FPW 判定的衔接
备份进行中 doPageWrites == true,之后与平常 FPW 相同:
needs_backup = (PageGetLSN(page) <= RedoRecPtr); /* 本周期首次修改 */
| 现象 | 解释 |
|---|---|
| 备份期间 WAL 变胖 | runningBackups > 0 强制 page writes;首次改页带 FPI |
full_page_writes=off 仍见 FPI | 有未结束的 pg_backup_* / pg_basebackup |
| 只拷文件、不留 WAL | 无法恢复到一致;缺 start~stop 的 WAL 会失败或损坏 |
与 crash redo 共用 rm_redo | 恢复路径同一套;差别在是否有 backup_label / 恢复目标 |
RedoRecPtr 仍随 checkpoint 推进;备份不会改成「另一套 FPW 算法」,只是把 doPageWrites 钉死为开。
6. 运维对应(最小)
SELECT pg_backup_start('label', false);
-- 外部拷贝 $PGDATA
SELECT * FROM pg_backup_stop(true);
-- 或走 pg_basebackup,内部走复制协议
pg_basebackup:一次会话内完成 start → 流式拷文件 → 拉齐所需 WAL → stop,不必手写两段 SQL。
恢复:用备份目录启动,存在 backup_label 时按其中 start 位置进入恢复,直到备份结束点(及配置的 recovery target)一致。
7. 速查
| 问题 | 答案 |
|---|---|
| base backup 解决什么? | 跨文件时间错位 + 并发拷写时的半写;用 WAL+FPI 推齐 |
| 稳定旧页纯拷会半写吗? | 不会;半写要有对该页的并发写 |
runningBackups 干什么? | 计数在线备份;>0 则强制 doPageWrites |
和 full_page_writes? | 或关系:GUC 开 或 备份中,都要拍 FPI |
| 为何对照 FPW? | 同一套 needs_backup / FPI;动机从「本机半写」扩到「备份副本半写」 |
| 本稿不含? | 流复制位点持续 apply、slot、增量 base backup 报文细节 |
相关笔记: Full Page Writes · Crash Recovery Redo · Streaming Replication & Log Decoding · XLogRecPtr (LSN) · WAL Recovery
最后更新: 2026-07-21 | 适用版本: PostgreSQL 15.x / 16.x / devel
replication
src/backend/replication/README
Walreceiver — libpqwalreceiver API
Walreceiver 中与传输相关的部分(连接主库、接收 WAL、发送消息)采用动态加载,以免把主服务端二进制直接链接到 libpq。动态模块位于 libpqwalreceiver 子目录。
该模块实现一组函数;各函数说明见 src/include/replication/walreceiver.h。
目前应把此 API 视为内部接口。将来有可能向第三方开放,允许用可插拔方式替换 libpqwalreceiver,从而自定义接收 WAL 的方法。
Walreceiver IPC
当 Startup 进程中的 WAL 重放已经走到归档 WAL 的末尾(通过 restore_command 可恢复的部分),若配置了流复制,就会启动 walreceiver 进程去拉取更多 WAL。
Walreceiver 是 postmaster 的子进程,因此 Startup 不能直接 fork 它。做法是:向 postmaster 发信号,请 postmaster 拉起 walreceiver。在此之前,Startup 会先填好 WalRcvData->conninfo、WalRcvData->slotname,并把起始位点写到 WalRcvData->receiveStart。
Walreceiver 从主库收到 WAL,写入并 flush 到本地磁盘(pg_wal)后,会更新 WalRcvData->flushedUpto,并信号通知 Startup,使其知道重放可以推进到何处。
每当写入或 flush 了新的 WAL,或到达指定的时间间隔,walreceiver 会把复制进度信息发回主库,用于汇报。
Walsender IPC
关机时,postmaster 对 walsender 的处理与普通 backend 不同。对普通 backend,它会等它们都退出后,再写 shutdown checkpoint,并结束 pgarch 等辅助进程;但对 walsender 不合适——我们希望备库在主库关掉之前,收到包括 shutdown checkpoint 在内的全部 WAL。因此 postmaster 把 walsender 当作类似 pgarch 来对待:在 PM_SHUTDOWN_2 阶段才让它们退出,此时普通 backend 已死、checkpointer 也已写出 shutdown checkpoint。
Postmaster 接受连接后会立刻 fork 新进程做握手与认证,该进程初始化成 backend。此时 postmaster 还不知道它最终是普通 backend 还是 walsender——这要在连接握手里才能区分——所以需要额外信号,让 postmaster 能识别 walsender。
Walsender 启动时,会在 PMSignal 数组里把自己标成 walsender,postmaster 据此与普通 backend 区分。
若 postmaster 误把 walsender 当成普通 backend,通常也无大碍:只是会更早结束该 walsender。在完成初始化并在 PMSignal 中标记之前,以及进程退出、清除 PMSignal 槽位之后,walsender 在外观上都会像普通 backend。
每个 walsender 从 WalSndCtl 数组分配一项,跟踪复制进度;用户可通过统计视图监控。
Walsender — walreceiver 协议
见手册。
How: Streaming Replication & Log Decoding
1. What & Why
两条把 WAL「送出去」的路径,消费方式不同:
| 路径 | 传什么 | 备端 / 下游怎么用 |
|---|---|---|
| Streaming replication(物理流复制) | 物理 WAL 字节流 | 同一套 rm_redo 持续 apply,得到页级副本 |
| Log decoding(逻辑解码) | 仍读物理 WAL,解码成逻辑变更 | 输出插件变成 INSERT/UPDATE/DELETE 等逻辑流(逻辑复制、CDC) |
- 问题:crash recovery / base backup 是「一段 WAL 用完即止」;HA 与持续同步需要不断把主库新 WAL 送到另一进程/节点。
- 解法:物理路径用 walsender → walreceiver → Startup redo;逻辑路径用 decoding 读 WAL → ReorderBuffer → output plugin。
- 边界:slot / timeline 见 Replication Slot & Timeline;本稿先钉进程、LSN 位点与两条路径的对照。
物理流复制通常先有一份 Base Backup,再从 backup stop(或指定 LSN)起追 WAL。逻辑解码不要求备库有整份数据文件镜像,但要求有能读到的 WAL(及常配合 replication slot)。
2. 核心设计思想
2.1 物理流复制
primary standby
backends -> WAL insert/flush
walsender ---- WAL bytes ----> walreceiver
|
v
Startup / redo
(same rm_redo as crash recovery)
| Crash recovery | Streaming (physical) | |
|---|---|---|
| WAL 从哪来 | 本地 pg_wal(已 flush) | walreceiver 写入的 WAL,再 redo |
| 何时停 | 本地 WAL 末尾 | 不停;主库持续推送 |
| 检查点 | end-of-recovery checkpoint | 备库 restartpoint(类 checkpoint,推进可清理位点) |
| 读查询 | 恢复结束前不可用 | Hot Standby:redo 同时可开只读会话 |
一句话:流复制 = 「永不结束的 crash redo」,WAL 由网络持续供给,而不是只读本地文件到 EOF。
2.2 逻辑解码
WAL (physical records)
-> Logical decoding
reorder by XID / commit order
output plugin -> logical change stream
物理 apply(rm_redo) | 逻辑解码 | |
|---|---|---|
| 单位 | 页 / 块变更 | 行级 / 事务级逻辑变更 |
| 是否改本地数据文件 | 是(备库页) | 解码本身只产出变更流;逻辑订阅端另说 |
| 典型用途 | 热备、物理 HA | 逻辑复制、CDC、审计 |
| 与 FPW | 备库 apply 依赖主库 WAL 中的 FPI/增量 | 解码关注堆变更语义;仍读同一条物理 WAL |
3. 关键文件与 API
| 概念 | 源码 / 入口 |
|---|---|
| 主库发送 | replication/walsender.c — walsender |
| 备库接收 | replication/walreceiver.c — walreceiver |
| 持续 redo | access/transam/xlogrecovery.c — PerformWalRecovery(恢复模式不退出) |
| 复制连接 | 复制协议(START_REPLICATION 等);primary_conninfo |
| 逻辑解码 | replication/logical/ — decode.c、logical.c、reorderbuffer.c |
| 输出插件 | pgoutput(内置逻辑复制)等 |
| SQL 入口(解码) | pg_logical_slot_get_changes / 逻辑复制 publication·subscription |
配置侧常见:wal_level >= replica(物理);逻辑解码 / 逻辑复制要 wal_level = logical。
4. 时序:物理流复制
1. standby = base backup (+ backup_label) of primary
2. configure recovery / standby.signal, primary_conninfo
3. start standby
Startup enters recovery
walreceiver connects to primary
4. primary forks walsender
streams WAL from requested LSN
5. walreceiver writes WAL locally
Startup ReadRecord / rm_redo (continuous)
6. optional: Hot Standby backends read consistent snapshots
7. promote: stop receiving, finish recovery, become primary
位点(名字随版本略有差异,语义如下):
| 位点 | 含义 |
|---|---|
| send / write | 主库已发给 / 备库已写入的 WAL 位置 |
| flush | 备库已持久化到盘的 WAL |
| apply / replay | Startup 已 redo 到的位置 |
Lag ≈ 主库 flush LSN − 备库 apply LSN(还受网络与 redo 速度影响)。
5. 时序:逻辑解码(最小)
create logical slot (pin WAL from restart_lsn)
client / apply worker asks for changes
-> read WAL from slot position
-> decode heap/xact records
-> ReorderBuffer until commit
-> output plugin emits change
advance slot confirmed_flush
未提交事务的变更会在 ReorderBuffer 中暂存,按提交顺序输出。槽位钉住 WAL,防止 restart_lsn 之前的段被回收(细节见 Replication Slot & Timeline)。
6. 与 crash redo / base backup 的衔接
| 机制 | 角色 |
|---|---|
| Crash redo | 单机、本地 WAL、有终点 |
| Base backup | 给物理备库(或 PITR)一个可 redo 的文件起点 |
| Streaming(物理) | 起点之后持续喂 WAL + 同一套 rm_redo |
| Log decoding | 同一条 WAL 的另一条消费管道;不替代物理 apply |
备库上的 restartpoint:在持续恢复中周期性做「像 checkpoint 一样」的落点,便于推进可回收 WAL / 缩短再次启动时的重放量;不是主库那种结束恢复的 end-of-recovery checkpoint。
7. 易混点
| 说法 | 澄清 |
|---|---|
| 流复制 = 拷贝数据文件 | 否;文件靠 base backup(或等价),之后只流 WAL |
| 逻辑解码 = 另一套 WAL 格式 | 否;读物理 WAL,解码成逻辑变更 |
| Hot Standby 与逻辑复制 | 前者是物理备上只读;后者是逻辑变更订阅,可异构 |
wal_level = replica 够逻辑复制吗 | 不够;逻辑解码需要 logical |
8. 速查
| 问题 | 答案 |
|---|---|
| 物理流复制解决什么 | 持续页级副本 / HA;redo 不停 |
| 谁发送、谁接收 | walsender → walreceiver → Startup redo |
| 和 crash redo 差别 | WAL 来源与是否结束;rm_redo 相同 |
| 逻辑解码解决什么 | 从物理 WAL 抽出逻辑变更流 |
| 为何常和 slot 一起 | 钉住 WAL,避免解码所需段被删 |
| 本稿不含 | slot 生命周期、timeline history、级联复制细节(→ 02) |
相关笔记: Replication Slot & Timeline · WAL Recovery · Crash Recovery Redo · Base Backup · Full Page Writes
最后更新: 2026-07-21 | 适用版本: PostgreSQL 15.x / 16.x / devel
What: Replication Slot & Timeline
1. What & Why
两个正交概念,一起决定「WAL 能不能被下游持续消费、升主后还能不能接上」:
| 概念 | 是什么 | 解决什么问题 |
|---|---|---|
| Replication slot | 主库上持久化的「消费者预订」 | 下游还没读到的 WAL(及逻辑解码所需 catalog 行)别被回收 |
| Timeline | WAL 历史的分支编号(TimeLineID) | 升主 / PITR 后出现分叉时,知道该跟哪条历史、哪段 WAL |
- 问题:checkpoint / 回收会删旧 WAL;逻辑解码还依赖旧 catalog 行。无预订则备库或解码客户端一断连就可能「段已删」。升主后新旧主各写各的 WAL,仅靠 LSN 不够区分历史。
- 解法:slot 钉住最小保留位点;timeline +
*.history描述分支与父子关系,恢复/复制按历史拼路径。 - 边界:本稿讲 What(语义与字段);slot 失效策略细节、级联复制、failover slot 同步(如
pg_sync_replication_slots)不展开。
接上一课:Streaming Replication & Log Decoding 里「为何常和 slot 一起」「升主」在此落地。
2. 核心设计思想
2.1 Slot = 持久化的消费游标 + 保留约束
下游进度反馈 / 解码确认
|
v
ReplicationSlot (pg_replslot/<name>/)
|
+--> restart_lsn : 回收不得越过(物理/逻辑重启点)
+--> confirmed_flush : 逻辑侧「已确认交付」位点(推进更积极)
+--> catalog_xmin : 逻辑侧:系统表 vacuum 不得越过
一句话:slot 把「某个消费者还需要的最老 WAL / catalog」写进主库元数据,checkpoint 与 vacuum 都要看它。
2.2 Timeline = WAL 字节流的命名空间
timeline 1: ....----A----B----C
\
timeline 2: ----D----E (promote / PITR 开新支)
同一数值的 LSN 可以出现在不同 timeline 上(分叉后各自推进)。因此「从某 LSN 追 WAL」必须带上 TimeLineID(以及必要时读 history 找祖先段)。
2.3 二者如何配合
| 场景 | Slot | Timeline |
|---|---|---|
| 正常流复制 | 物理 slot 钉 restart_lsn,备库断连也可重连续传 | 主备同 timeline |
| 逻辑解码 / 逻辑复制 | 逻辑 slot 钉 WAL + catalog_xmin | 通常仍在同一 timeline 读主库 WAL |
| 备库 promote | 旧主上的 slot 不会自动变新主的;需新拓扑重建或同步机制 | 新主开新 timeline;旧主若仍在跑则成平行历史 |
| Archive / PITR | 一般不靠 slot;靠归档保留 | recovery_target_timeline + history 决定跟哪支 |
3. 关键文件与 API
| 概念 | 源码 / SQL |
|---|---|
| Slot 核心 | src/backend/replication/slot.c、slot.h — ReplicationSlot* |
| 持久化目录 | $PGDATA/pg_replslot/<slotname>/(state 文件) |
| 物理建槽 | pg_create_physical_replication_slot;复制协议 CREATE_REPLICATION_SLOT ... PHYSICAL |
| 逻辑建槽 | pg_create_logical_replication_slot;CREATE_REPLICATION_SLOT ... LOGICAL |
| 观测 | pg_replication_slots |
| Timeline 类型 | TimeLineID(xlogdefs.h 等) |
| History 读写 | src/backend/access/transam/timeline.c — *.history |
| 恢复跟线 | xlogrecovery.c — recovery_target_timeline、切换 / 校验 |
| 控制文件 | pg_control 中当前 timeline / checkpoint 相关字段 |
| WAL 文件名 | TTTTTTTTXXXXXXXXYYYYYYYY(timeline + 逻辑段号) |
配置相关:max_replication_slots、max_wal_senders、max_slot_wal_keep_size(可限制槽位无限囤积 WAL;触顶可使槽失效)、wal_level。
4. Replication Slot
4.1 物理 vs 逻辑
| Physical | Logical | |
|---|---|---|
| 消费者 | 流复制备库(walsender 按槽续传) | 逻辑解码客户端 / 逻辑复制 |
| 钉住什么 | 主要是 WAL(restart_lsn) | WAL + 解码重启所需状态 + 常含 catalog_xmin |
wal_level | replica 即可 | 要 logical |
| 典型用途 | HA 备库不断档 | CDC、pgoutput、审计 |
物理槽可与 primary_slot_name(备库)绑定,让主库按该备的反馈推进保留位点。
4.2 关键位点(逻辑槽尤甚)
| 字段(概念名) | 含义 |
|---|---|
restart_lsn | 下游若要从头「安全重启」所需的最老 WAL 位置;回收下限 |
confirmed_flush_lsn | 消费者已确认处理到的位置;逻辑侧常据此推进,可比 restart_lsn 新 |
catalog_xmin | 逻辑解码仍可能需要的系统表行的 xmin 下界;挡住过早 vacuum |
xmin(若暴露) | 与快照 / 水平相关的保留(随版本与槽类型关注点不同) |
推进规则直觉:
消费者确认进度 → 更新 confirmed_flush(等)
→ 在安全时抬高 restart_lsn
→ 更老的 WAL 段才允许被删
滞后的槽 → pg_wal 膨胀;删槽或修好消费者后才会释放。
4.3 生命周期(最小)
create slot → 占用 max_replication_slots 配额,写入 pg_replslot/
active → walsender / 解码会话占用(pg_replication_slots.active)
inactive → 仍保留 WAL;断连不等于丢槽
drop / 失效 → 释放保留;max_slot_wal_keep_size 等可令槽 invalid
易踩坑:只建槽不消费、或备库长期宕机 → 主库磁盘被 WAL 撑满。运维上要监控 restart_lsn 与磁盘,并理解 max_slot_wal_keep_size 是「宁可槽失效也不塞爆盘」的权衡。
5. Timeline
5.1 什么时候分叉
| 事件 | 行为 |
|---|---|
| 备库 promote 成主 | 新主切换到新 TimeLineID,写出 history,之后 WAL 写在新线上 |
| PITR 恢复到某目标后以新主身份跑 | 同样进入新 timeline,避免与「原主若仍存活」的历史混淆 |
| 普通 crash recovery(同实例重启) | 通常不换 timeline,继续原线 |
5.2 History 文件
形如 00000002.history(内容随版本为文本行):记录「本 timeline 从哪条父线、在哪个 LSN 分出」。
恢复 / 追归档时:若目标 LSN 落在父线段,需按 history 回溯父 timeline 打开正确的 WAL 文件名(同 offset、不同 TTTTTTTT 前缀)。
5.3 与复制、升主
1. 主(TLI=1) ----WAL----> 备(TLI=1) redo
2. 备 promote → 新主(TLI=2),写 00000002.history
3. 旧主若仍接受写入 → 仍在 TLI=1 上前进(脑裂风险;运维上应 fence)
4. 其他备要跟新主:改连新主,并按 timeline history 衔接 WAL
流复制握手会交换 / 校验 timeline;对不上就无法简单「同一 LSN 接着传」。
6. 与 streaming / backup / redo 的衔接
| 机制 | 角色 |
|---|---|
| Crash redo | 本地、通常单 timeline、有终点 |
| Base backup | 文件起点;backup_label 含 start 点(含 timeline 语境) |
| Streaming | 持续喂 WAL;物理槽降低「段已删」概率 |
| Slot | 主库侧保留契约 |
| Timeline | 升主 / PITR 后的历史坐标系 |
备库 restartpoint 推进本地可回收位点;主库是否删段还要看所有 slot 的 restart_lsn(以及 wal_keep_size 等)。
7. 易混点
| 说法 | 澄清 |
|---|---|
| Slot = 备库上的对象 | 否;槽建在提供 WAL 的那一侧(通常是主库) |
有 wal_keep_size 就不用 slot | wal_keep_size 是粗粒度多留一段;slot 按消费者进度精确保留,逻辑解码还要 catalog_xmin |
| LSN 全局唯一跨升主 | LSN 是线上的字节偏移;跨 timeline 必须带 TLI,不能只比数字 |
| Promote 后旧主 slot 自动跟上 | 不会;拓扑变了要重建或专用同步手段 |
| 逻辑槽只挡 WAL | 还常挡 catalog vacuum;忽略会导致解码失败或槽增长 |
8. 速查
| 问题 | 答案 |
|---|---|
| Slot 解决什么? | 钉住下游仍需的 WAL(及逻辑 catalog),防回收 |
| 物理 / 逻辑槽差别? | 逻辑额外要 wal_level=logical 与解码相关保留 |
restart_lsn 是什么? | 槽的回收下界 / 安全重启点 |
| Timeline 解决什么? | WAL 历史分叉后的命名与追溯 |
| 何时新 timeline? | Promote、PITR 开新主写等 |
| History 干什么? | 记录父线与分叉 LSN,供恢复拼路径 |
| 本稿不含 | 失效抢修流程、槽同步升主、级联、具体报文格式 |
9. 总结
- Slot:主库上的持久消费者预订 → 保留 WAL(逻辑再加 catalog)→ 支撑断连续传与解码。
- Timeline:WAL 历史的分支 ID + history → 升主/PITR 后仍能找到正确段。
- 合起来:流复制日常靠 slot 保段;拓扑变更靠 timeline 保「跟对历史」。
相关笔记: Streaming Replication & Log Decoding · README(walreceiver/walsender IPC) · Crash Recovery Redo · Base Backup · XLogRecPtr (LSN) · WAL Recovery
最后更新: 2026-07-30 | 适用版本: PostgreSQL 15.x / 16.x / devel
storage
page
src/backend/storage/page/README
Checksums
数据页校验和用于检测 I/O 系统引入的损坏。我们不为缓冲对抗不可纠正的内存错误:据大型机房研究,此类错误实测发生率很低(http://www.cs.toronto.edu/~bianca/papers/sigmetrics09.pdf;2010/12/22 在 -hackers 上讨论过)。
当前实现要求在 initdb 时整库启用,或对离线集群使用 pg_checksums。
校验和并非在数据页上始终有效。
页离开共享缓冲池时校验和有效;之后因 I/O 再次进入共享缓冲池时会校验。我们在即将 flush 共享池中的缓冲之前设置校验和。因此,一旦因数据修改甚至 hint 而改动页面,该页的校验和即被隐式失效。共享缓冲中大量(甚至多数)页面的 pd_checksum 无效,解读该字段时需注意。
因此,经 WAL 记录的页修改不会更新页校验和,全页镜像(FPI)上的校验和也可能无效。这些页镜像由 WAL 的 CRC 覆盖,与本机制分开校验。WAL 重放时不应检查全页镜像的页校验和。
可这样理解:WAL CRC 保护进入 WAL 流的记录;数据页校验保护进入共享缓冲池的块。目的相近,机制完全独立;二者合起来用于检测数据重新进入 PostgreSQL 可控内存时的错误。另:WAL 校验为 32-bit CRC,页校验和仅为 16-bit。
对数据块的任何写出都可能在写失败时造成半写(torn page)。全页写入(full page writes)写入 WAL 以防御该问题。页已脏时设置 hint bit 是安全的,因为自上次 checkpoint 以来必然已为该页写过 FPI。在原本干净的页上设置 hint bit 则可能引入半写;通常无关紧要(hint 本身可丢),但若启用了页校验和,丢失若干 bit 会使校验和失效。因此在 full_page_writes = on 且启用校验和时,必须专门写一条 WAL,以便在 WAL 中记录全页镜像。Hint 更新应通过 MarkBufferDirtyHint() 保护,由该函数在必要时写出 FPI。
计算页校验和时会纳入标准页中部「空洞」里那些本应为零的字节。从存储读回块时,也就隐式检查空洞是否仍全为零,以便发现虽未必已毁掉用户数据、却可能毁掉数据的错误。WAL 中的全页镜像不检查空洞是否为零:空洞中的数据被跳过,重放 backup block 时再填零。原因是:WAL 失败是致命错误,会阻断后续恢复;而普通数据块校验失败对服务器是严重错误,但通常不构成 critical failure(对用户仍非常糟糕)。
恢复期间不能写新的 WAL 记录。因此在启用校验和时,恢复过程中设置 hint bit 时,若缓冲尚未脏,则不得将页标脏。Hot Standby 可能从设置 hint 中受益,但启用校验和时,设置 hint 后不能把页弄脏(半写风险)。须等待主库传来的、已包含这些 hint 更新的全页镜像。
Page Layout
页内字段与 pageinspect 观测见 pageinspect;校验和语义见 README。
freespace
README
src/backend/storage/freespace/README
Free Space Map
The purpose of the free space map is to quickly locate a page with enough free space to hold a tuple to be stored; or to determine that no such page exists and the relation must be extended by one page. As of PostgreSQL 8.4 each relation has its own, extensible free space map stored in a separate “fork” of its relation. This eliminates the disadvantages of the former fixed-size FSM.
It is important to keep the map small so that it can be searched rapidly. Therefore, we don’t attempt to record the exact free space on a page. We allocate one map byte to each page, allowing us to record free space at a granularity of 1/256th of a page. Another way to say it is that the stored value is the free space divided by BLCKSZ/256 (rounding down). We assume that the free space must always be less than BLCKSZ, since all pages have some overhead; so the maximum map value is 255.
To assist in fast searching, the map isn’t simply an array of per-page entries, but has a tree structure above those entries. There is a tree structure of pages, and a tree structure within each page, as described below.
FSM page structure
Within each FSM page, we use a binary tree structure where leaf nodes store the amount of free space on heap pages (or lower level FSM pages, see “Higher-level structure” below), with one leaf node per heap page. A non-leaf node stores the max amount of free space on any of its children.
For example:
4
4 2
3 4 0 2 <- This level represents heap pages
We need two basic operations: search and update.
To search for a page with X amount of free space, traverse down the tree along a path where n >= X, until you hit the bottom. If both children of a node satisfy the condition, you can pick either one arbitrarily.
To update the amount of free space on a page to X, first update the leaf node corresponding to the heap page, then “bubble up” the change to upper nodes, by walking up to each parent and recomputing its value as the max of its two children. Repeat until reaching the root or a parent whose value doesn’t change.
This data structure has a couple of nice properties:
- to discover that there is no page with X bytes of free space, you only need to look at the root node
- by varying which child to traverse to in the search algorithm, when you have a choice, we can implement various strategies, like preferring pages closer to a given page, or spreading the load across the table.
Higher-level routines that use FSM pages access them through the fsm_set_avail()
and fsm_search_avail() functions. The interface to those functions hides the
page’s internal tree structure, treating the FSM page as a black box that has
a certain number of “slots” for storing free space information. (However,
the higher routines have to be aware of the tree structure of the whole map.)
The binary tree is stored on each FSM page as an array. Because the page header takes some space on a page, the binary tree isn’t perfect. That is, a few right-most leaf nodes are missing, and there are some useless non-leaf nodes at the right. So the tree looks something like this:
0
1 2
3 4 5 6
7 8 9 A B
where the numbers denote each node’s position in the array. Note that the tree is guaranteed complete above the leaf level; only some leaf nodes are missing. This is reflected in the number of usable “slots” per page not being an exact power of 2.
A FSM page also has a next slot pointer, fp_next_slot, that determines where to start the next search for free space within that page. The reason for that is to spread out the pages that are returned by FSM searches. When several backends are concurrently inserting into a relation, contention can be avoided by having them insert into different pages. But it is also desirable to fill up pages in sequential order, to get the benefit of OS prefetching and batched writes. The FSM is responsible for making that happen, and the next slot pointer helps provide the desired behavior.
Higher-level structure
To scale up the data structure described above beyond a single page, we maintain a similar tree-structure across pages. Leaf nodes in higher level pages correspond to lower level FSM pages. The root node within each page has the same value as the corresponding leaf node on its parent page.
The root page is always stored at physical block 0.
For example, assuming each FSM page can hold information about 4 pages (in reality, it holds (BLCKSZ - headers) / 2, or ~4000 with default BLCKSZ), we get a disk layout like this:
0 <-- page 0 at level 2 (root page)
0 <-- page 0 at level 1
0 <-- page 0 at level 0
1 <-- page 1 at level 0
2 <-- ...
3
1 <-- page 1 at level 1
4
5
6
7
2
8
9
10
11
3
12
13
14
15
where the numbers are page numbers at that level, starting from 0.
To find the physical block # corresponding to leaf page n, we need to count the number of leaf and upper-level pages preceding page n. This turns out to be
y = n + (n / F + 1) + (n / F^2 + 1) + ... + 1
where F is the fanout (4 in the above example). The first term n is the number of preceding leaf pages, the second term is the number of pages at level 1, and so forth.
To keep things simple, the tree is always constant height. To cover the maximum relation size of 2^32-1 blocks, three levels is enough with the default BLCKSZ (4000^3 > 2^32).
Addressing
The higher-level routines operate on “logical” addresses, consisting of
- level,
- logical page number, and
- slot (if applicable)
Bottom level FSM pages have level of 0, the level above that 1, and root 2. As in the diagram above, logical page number is the page number at that level, starting from 0.
Locking
When traversing down to search for free space, only one page is locked at a time: the parent page is released before locking the child. If the child page is concurrently modified, and there no longer is free space on the child page when you land on it, you need to start from scratch (after correcting the parent page, so that you don’t get into an infinite loop).
We use shared buffer locks when searching, but exclusive buffer lock when updating a page. However, the next slot search pointer is updated during searches even though we have only a shared lock. fp_next_slot is just a hint and we can easily reset it if it gets corrupted; so it seems better to accept some risk of that type than to pay the overhead of exclusive locking.
Recovery
The FSM is not explicitly WAL-logged. Instead, we rely on a bunch of self-correcting measures to repair possible corruption.
First of all, whenever a value is set on an FSM page, the root node of the page is compared against the new value after bubbling up the change is finished. It should be greater than or equal to the value just set, or we have a corrupted page, with a parent somewhere with too small a value. Secondly, if we detect corrupted pages while we search, traversing down the tree. That check will notice if a parent node is set to too high a value. In both cases, the upper nodes on the page are immediately rebuilt, fixing the corruption so far as that page is concerned.
VACUUM updates all the bottom-level FSM pages with the correct amount of free
space on corresponding heap pages, as it proceeds through the heap. This
goes through fsm_set_avail(), so that the upper nodes on those pages are
immediately updated. Periodically, VACUUM calls FreeSpaceMapVacuum[Range]
to propagate the new free-space info into the upper pages of the FSM tree.
As a result when we write to the FSM we treat that as a hint and thus use
MarkBufferDirtyHint() rather than MarkBufferDirty(). Every read here uses
RBM_ZERO_ON_ERROR to bypass checksum mismatches and other verification
failures. We’d operate correctly without the full page images that
MarkBufferDirtyHint() provides, but they do decrease the chance of losing slot
knowledge to RBM_ZERO_ON_ERROR.
Relation extension is not WAL-logged. Hence, after WAL replay, an on-disk FSM slot may indicate free space in PageIsNew() blocks that never reached disk. We detect this case by comparing against the actual relation size, and we mark the block as full in that case.
TODO
- fastroot to avoid traversing upper nodes with just 1 child
- use a different system for tables that fit into one FSM page, with a mechanism to switch to the real thing as it grows.
Why & How: Free Space Map (FSM)
1. 定义
Free Space Map(FSM):关系的独立 fork(<relfilenode>_fsm,FSM_FORKNUM),按 堆/索引页 记录「大约还有多少空闲空间」,供插入与扩展决策快速查询。
- 每数据页对应 1 个 category 字节:空闲量 ≈
cat * (BLCKSZ/256)(向下取整);假定空闲 <BLCKSZ,故 cat ∈ 0…255。 - 不存精确字节数,以便 map 小、可树形搜索。
- Heap 与多数索引(hash 除外)有 FSM。
源码:src/backend/storage/freespace/(freespace.c、fsmpage.c、README)。
2. 为何需要
heap_insert → RelationGetBufferForTuple 需要一块能容纳新 tuple(含对齐/填充)的页。
若无 FSM,只能从 block 0 起试探或扫表,随 nblocks 恶化。FSM 把「是否存在 ≥ X 空闲」变成对 FSM 树的下降搜索;整棵子树不够时,根节点一次即可否定。返回 InvalidBlockNumber 时由调用方 extend 关系。
信息是近似且可能过期的:并发插入、粒度舍入、未及时 RecordPageWithFreeSpace 都会让「FSM 说够、页上不够」出现;API 契约要求调用方能处理该情况。
3. 页内结构:byte 数组上的 max 树
每个 FSM 页把一棵二叉树摊在数组里:叶子存「某一堆页(或下一层 FSM 页)的空闲类别」;非叶 = 两子的 max。
4
4 2
3 4 0 2 <- 叶:对应数据页(或下层 FSM)
因页头占用,叶层不是完美 2 的幂:右侧缺若干叶,上层仍保持完全。对外用「slot」抽象,由 fsm_search_avail / fsm_set_avail 隐藏数组下标细节。
搜索(要 cat ≥ X):从根往下,选「子 ≥ X」的分支;两子都可则按策略选一(可偏向某页邻近,或打散负载)。fp_next_slot 等状态用于轮转起点,减少总挤同一叶。
更新:写叶 → 沿父 bubble up 重算 max,直到根或父值不变。
性质:根 < X ⇒ 本 FSM 页覆盖范围内不存在足够空闲。
4. 跨页:FSM 页树
数据页很多时,底层 FSM 页的根再作为上层 FSM 页的叶子,形成多层。freespace.c 负责地址换算与层间遍历;单页内算法在 fsmpage.c。
物理上 FSM fork 随关系增长扩展;与 main fork 的 block 编号通过固定扇出关系映射(见 README「Higher-level structure」)。
5. 对外 API 与插入路径
| 函数 | 作用 |
|---|---|
GetPageWithFreeSpace(rel, spaceNeeded) | fsm_space_needed_to_cat 后 fsm_search;命中返回 BlockNumber,否则 InvalidBlockNumber |
RecordPageWithFreeSpace(rel, heapBlk, spaceAvail) | 把该堆页的实测/估计空闲写回 FSM |
RecordAndGetPageWithFreeSpace(...) | 先更新「刚失败的那页」空闲,再搜下一候选(插入重试) |
FreeSpaceMapVacuum 等 | VACUUM 后批量校正 FSM(与清理路径配合) |
典型插入:
RelationGetBufferForTuple
-> GetPageWithFreeSpace(spaceNeeded)
命中 -> 锁页、复核空闲
不够 -> RecordAndGetPageWithFreeSpace / 再试
未命中 -> RelationAddBlocks 扩展 main fork,初始化新页
-> 放入 tuple 后视情况 RecordPageWithFreeSpace
VACUUM / page prune 回收空间后应更新 FSM,否则空闲「看不见」,表会不必要地膨胀。
观测:contrib pg_freespacemap。
6. 源码入口
- 设计说明:
src/backend/storage/freespace/README - 关系级搜索/记录:
freespace.c - 页内树:
fsmpage.c - 插入选页:
access/heap/hio.c—RelationGetBufferForTuple - Fork 常量:
FSM_FORKNUM(relfilenode.h/ smgr)
7. 小结
- FSM = 每数据页一字节空闲类别 + 页内/跨页 max 树,加速「找够大的页」。
- 粒度与并发使结果不可盲信;选页后必须在堆页上复核。
- 扩展与 VACUUM 都要维护 FSM,否则插入只见「假满」。
相关笔记: FSM README · Page Layout · insert
最后更新: 2026-08-03 | 适用版本: PostgreSQL 15.x / 16.x / devel
buffer
Buffer readme
关于共享缓冲区访问规则的说明 (Notes About Shared Buffer Access Rules)
共享磁盘缓冲区有两种独立的访问控制机制:引用计数(也称为 Pin 计数)和 缓冲区内容锁。(实际上还有第三层访问控制:在合法访问属于某个关系的任何页面之前,必须持有该关系的适当类型的锁。)
Pins(引脚/引用计数)
在对缓冲区进行任何操作之前,必须先“持有该缓冲区的 Pin”(增加其引用计数)。未 pinned 的缓冲区随时可能被回收并用于其他页面,因此访问它是不安全的。
通常通过 ReadBuffer 获取 Pin,通过 ReleaseBuffer 释放 Pin。
单个后端进程同时多次 Pin 同一个页面不仅是允许的,而且很常见;缓冲区管理器会高效地处理这种情况。
长时间持有 Pin 也是允许的——例如,顺序扫描会在处理完页面上的所有元组之前一直持有当前页面的 Pin,如果该扫描是连接操作的外部扫描,这可能会持续相当长的时间。同样,B-Tree 索引扫描也可能持有当前索引页面的 Pin,这是可以的,因为正常操作永远不会等待页面的 Pin 计数降为零。(任何可能需要等待 Pin 计数归零的操作,转而通过等待获取关系级锁来处理,这就是为什么你最好先持有关系级锁的原因。)但是,Pin 不能跨越事务边界持有。
Buffer Content Locks(缓冲区内容锁)
缓冲区锁有两种:共享锁和排他锁,其行为符合预期:多个后端可以持有同一缓冲区的共享锁,但排他锁会阻止其他人持有任何共享或排他锁。(这些也可以称为 READ 锁和 WRITE 锁。)
这些锁旨在短期持有:不应长时间持有。缓冲区锁通过 LockBuffer() 获取和释放。
单个后端尝试对同一缓冲区获取多个锁是行不通的。在尝试锁定缓冲区之前,必须先 Pin 住该缓冲区。
缓冲区访问规则 (Buffer access rules)
-
扫描页面中的元组:必须持有 Pin 以及共享或排他内容锁。要检查共享缓冲区中元组的提交状态(XIDs 和状态位),同样必须持有 Pin 以及共享或排他锁。
-
确定元组可见后释放内容锁:一旦确定某个元组是感兴趣的(对当前事务可见),就可以放下内容锁,但只要持有缓冲区 Pin,就可以继续访问该元组的数据。堆扫描(heap scans)通常这样做,因为
heap_fetch返回的元组包含指向共享缓冲区中元组数据的指针。因此,只要持有 Pin,元组就不会消失(见规则5)。其状态可能会改变,但在初始可见性判定完成后,假设这无关紧要。 -
修改元组:要添加元组或更改现有元组的
xmin/xmax字段,必须持有包含该元组的缓冲区的 Pin 和排他内容锁。这确保了在进行可见性检查时,其他人不会看到元组的半更新状态。 -
更新提交状态位(Hint Bits):在仅持有缓冲区的共享锁和 Pin 的情况下,更新元组的提交状态位(即对
t_infomask执行 OR 操作,设置HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_COMMITTED, 或HEAP_XMAX_INVALID)是被允许的。- 原因:另一个后端如果在大约同一时间查看该元组,也会将相同的位 OR 进字段,因此冲突更新的风险很小或没有。即使真的发生冲突,也仅仅意味着一次位更新丢失,稍后需要重做。
- 注意:这四个位只是提示(它们缓存了
pg_xact中事务状态的查找结果),所以如果因冲突更新而被重置为零,也不会造成太大危害。 - 例外:冻结元组是通过同时设置
HEAP_XMIN_INVALID和HEAP_XMIN_COMMITTED来完成的;这是一个关键更新,因此需要排他缓冲区锁(并且必须进行 WAL 日志记录)。
-
物理删除元组或压缩空闲空间:必须持有 Pin 和排他锁,并且在持有排他锁期间观察到缓冲区的共享引用计数为 1(即没有其他后端持有 Pin)。
- 如果满足这些条件,则在排他锁释放之前,没有其他后端可以执行页面扫描,也没有其他后端可以持有对现有元组的引用(它可能期望再次检查该元组)。
- 注意:另一个后端可能在执行清理时 Pin 住缓冲区(增加 refcount),但在获取共享或排他内容锁之前,它无法实际检查页面。
获取规则 #5 所需的锁由 bufmgr 例程
LockBufferForCleanup()或ConditionalLockBufferForCleanup()完成。它们首先获取排他锁,然后检查共享 Pin 计数当前是否为 1。如果不是,ConditionalLockBufferForCleanup()释放排他锁并返回 false;而LockBufferForCleanup()释放排他锁(但不释放调用者的 Pin)并等待,直到被另一个后端信号唤醒,然后重试。当UnpinBuffer将共享 Pin 计数递减到 1 时,会发生信号。如上所述,此操作在获取锁之前可能需要等待很长时间,但这对于并发 VACUUM 来说应该没关系。当前实现仅支持每个特定共享缓冲区上只有一个等待 Pin 计数为 1 的等待者。这对于 VACUUM 的使用来说已经足够,因为我们不允许在同一关系上并发进行多个 VACUUM。任何希望在恢复或 VACUUM 之外获取清理锁的人必须使用该函数的条件变体。
缓冲区管理器的内部锁定 (Buffer Manager’s Internal Locking)
在 PostgreSQL 8.1 之前,共享缓冲区管理器的所有操作都受单一的系统级锁 BufMgrLock 保护,这 unsurprisingly(不出所料地)成为争用的来源。新的锁定方案避免了在常见代码路径中获取系统级排他锁。其工作原理如下:
-
BufMappingLock:有一个系统级的 LWLock,名为
BufMappingLock,名义上保护从缓冲区标签(页面标识符)到缓冲区的映射。(物理上,可以认为它保护由buf_table.c维护的哈希表。)- 要查找是否存在某个标签对应的缓冲区,只需获取
BufMappingLock的共享锁。 - 注意:在释放
BufMappingLock之前,必须 Pin 住找到的缓冲区(如果有)。 - 要更改任何缓冲区的页面分配,必须持有
BufMappingLock的排他锁。在调整缓冲区头字段和更改buf_table哈希表时必须持有此锁。唯一需要排他锁的常见操作是读取尚未在共享缓冲区中的页面,这至少需要一个内核调用,通常还需要等待 I/O,因此无论如何都会很慢。
- 要查找是否存在某个标签对应的缓冲区,只需获取
-
分区 BufMappingLock:从 PG 8.2 开始,
BufMappingLock已被拆分为NUM_BUFFER_PARTITIONS个单独的锁,每个锁保护一部分缓冲区标签空间。这进一步减少了正常代码路径中的争用。特定缓冲区标签所属的分区由标签哈希值的低位决定。上述规则独立适用于每个分区。如果需要同时锁定多个分区,必须按分区编号顺序锁定它们,以避免死锁风险。 -
buffer_strategy_lock:一个单独的系统级自旋锁
buffer_strategy_lock,为访问缓冲区空闲列表或选择替换缓冲区的操作提供互斥。这里使用自旋锁而不是轻量级锁(LWLock)以提高效率;在持有buffer_strategy_lock时,不应获取任何其他类型的锁。这对于允许多个后端以合理的并发性进行缓冲区替换至关重要。 -
缓冲区头自旋锁:每个缓冲区头包含一个自旋锁,在检查或更改该缓冲区头的字段时必须获取。这允许诸如
ReleaseBuffer之类的操作在不获取任何系统级锁的情况下进行本地状态更改。我们使用自旋锁而不是 LWLock,因为没有情况需要持有该锁超过几条指令的时间。- 注意:缓冲区头的自旋锁不控制对缓冲区内数据的访问。每个缓冲区头还包含一个 LWLock,即“缓冲区内容锁”,它确实代表访问缓冲区中数据的权利。它按照上述规则使用。
-
BM_IO_IN_PROGRESS 标志:充当一种锁,用于等待缓冲区上的 I/O 完成(在版本 14 之前,它伴随着一个每缓冲区的 LWLock)。执行读取或写入的进程在此期间设置该标志,需要等待其清除的进程则在条件变量上睡眠。
正常缓冲区替换策略 (Normal Buffer Replacement Strategy)
有一个“空闲列表”(free list),其中的缓冲区是替换的主要候选者。特别是,完全空闲(不包含有效页面)的缓冲区始终在此列表中。如果我们认为某些页面不太可能很快被需要,也可以将它们放入此列表;然而,当前算法从不这样做。
该列表是使用缓冲区头中的字段链接的单链表;我们在全局变量中维护头尾指针。(注意:虽然列表链接在缓冲区头中,但它们被认为受 buffer_strategy_lock 保护,而不是缓冲区头自旋锁。)
当没有空闲缓冲区可用时,为了选择要回收的受害者缓冲区,我们使用简单的 Clock-Sweep(时钟扫描)算法,这避免了在常见操作期间获取系统级锁。其工作原理如下:
每个缓冲区头包含一个 usage counter(使用计数器),每当缓冲区被 Pin 时,该计数器就会递增(上限为一个较小的限制值)。(这只需要缓冲区头自旋锁,无论如何为了增加缓冲区引用计数都必须获取该锁,因此几乎是免费的。)
“时钟指针”是一个缓冲区索引 nextVictimBuffer,它在所有可用缓冲区中循环移动。nextVictimBuffer 受 buffer_strategy_lock 保护。
需要获取受害者缓冲区的进程的算法如下:
- 获取
buffer_strategy_lock。 - 如果缓冲区空闲列表非空,移除其头部缓冲区。释放
buffer_strategy_lock。如果该缓冲区被 Pin 住或使用计数不为零,则不能使用;忽略它并回到步骤 1。否则,Pin 住该缓冲区并返回。 - 否则,缓冲区空闲列表为空。选择
nextVictimBuffer指向的缓冲区,并为下次循环推进nextVictimBuffer。释放buffer_strategy_lock。 - 如果选定的缓冲区被 Pin 住或使用计数不为零,则不能使用。递减其使用计数(如果不为零),重新获取
buffer_strategy_lock,并返回步骤 3 以检查下一个缓冲区。 - Pin 住选定的缓冲区,并返回。
(注意:如果选定的缓冲区是脏的,我们在回收它之前必须将其写出;如果与此同时其他人 Pin 住了该缓冲区,我们将不得不放弃并尝试另一个缓冲区。然而,这不是基本选择受害者缓冲区算法的关注点。)
缓冲区环替换策略 (Buffer Ring Replacement Strategy)
当运行需要一次性访问大量页面的查询时(例如 VACUUM 或大型顺序扫描),会使用不同的策略。 仅由此类扫描触及的页面不太可能很快再次被需要,因此与其运行正常的时钟扫描算法并吹掉整个缓冲区缓存,不如使用正常的时钟扫描算法分配一个小环(ring)的缓冲区,并在整个扫描过程中重用这些缓冲区。这也意味着由此类语句引起的大部分写流量将由后端本身完成,而不是推卸给其他进程。
-
顺序扫描:使用 256KB 的环。这足够小以放入 L2 缓存,这使得从 OS 缓存传输页面到共享缓冲区缓存变得高效。即使更少通常也足够了,但环必须足够大以容纳扫描中同时被 Pin 的所有页面。256KB 也应该足以留下一个小缓存轨迹,供其他后端加入同步顺序扫描。如果环缓冲区被弄脏且其 LSN 更新,我们通常必须在重用缓冲区之前写入并刷新 WAL;在这种情况下,我们改为从环中丢弃该缓冲区,并(稍后)使用正常的时钟扫描算法选择替换。因此,这种策略最适用于只读扫描(或者最多更新 hint bits 的扫描)。在修改扫描中每个页面的扫描中,如批量 UPDATE 或 DELETE,环中的缓冲区将始终被弄脏,环策略实际上退化为正常策略。
-
VACUUM:像顺序扫描一样使用环,但是,这个环的大小由 GUC 参数
vacuum_buffer_usage_limit控制。脏页面不会从环中移除。相反,如果需要,会刷新 WAL 以允许重用缓冲区。在 8.3 引入缓冲区环策略之前,VACUUM 的缓冲区被发送到空闲列表,这实际上是一个大小为 1 的缓冲区环,导致过多的 WAL 刷新。 -
批量写入:工作方式类似于 VACUUM。目前这仅适用于
COPY IN和CREATE TABLE AS SELECT。(使 seqscan UPDATE 和 DELETE 使用 bulkwrite 策略是否有趣?)对于批量写入,我们使用 16MB 的环大小(但不超过shared_buffers的 1/8)。较小的尺寸已被证明会导致 COPY 因 WAL 刷新而过于频繁地阻塞。虽然后台 vacuum 因执行自己的 WAL 刷新而变慢是可以接受的,但我们希望 COPY 不受此影响,所以我们让它使用更多的缓冲区区域。
Background Writer 的处理 (Background Writer’s Processing)
Background Writer 旨在写出可能很快被回收的页面,从而将写入工作从活动后端卸载。
为此,它从 nextVictimBuffer 的当前位置向前循环扫描(它不会改变 nextVictimBuffer!),寻找那些脏的、未被 Pin 住且未标记正使用计数的缓冲区。它 Pin 住、写入并释放任何这样的缓冲区。
如果我们可以假设读取 nextVictimBuffer 是一个原子动作,那么 writer 甚至不需要获取 buffer_strategy_lock 来寻找要写入的缓冲区;它只需要自旋锁定每个缓冲区头足够长的时间来检查 dirtybit。即使没有这个假设,writer 也只需要获取锁足够长的时间来读取变量值,而不是在扫描缓冲区时。(与 PG 8.0 相比,这是 writer 争用成本的实质性改进。)
Background Writer 在写出缓冲区时对其获取共享内容锁(任何将缓冲区内容刷新到磁盘的人也必须这样做)。这确保了传输到磁盘的页面图像具有合理的一致性。我们可能会错过一两个 hint-bit 更新,但这不是问题,原因与缓冲区访问规则下提到的相同。
从 8.4 开始,background writer 在执行某种形式的潜在扩展恢复时在恢复模式期间启动。它提供与正常处理相同的服务,除了它写入的检查点在技术上是 restartpoints。
Buffer Overview
核心价值
- 读优化: 内存比磁盘快几个数量级。数据页首次读取后缓存在内存,后续访问直接命中内存,不再触发慢速磁盘 I/O。
- 写优化: 修改数据时先只改内存(标记为脏页),然后通过后台进程异步、批量刷回磁盘。避免每次修改都直接卡在慢速磁盘写上。
缓存结构
BufferMapping
SharedBufHashbuf_table.c- mapping BufferTags to buffer indexes
/* entry for buffer lookup hashtable */
typedef struct
{
BufferTag key; /* Tag of a disk page */
int id; /* Associated buffer ID */
} BufferLookupEnt;
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;
BufferDescriptors
BufferDescPadded *BufferDescriptors;
typedef struct BufferDesc
{
BufferTag tag; /* ID of page contained in buffer */
int buf_id; /* buffer's index number (from 0) */
/* state of the tag, containing flags, refcount and usagecount */
pg_atomic_uint32 state;
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;
BufferBlocks
char *BufferBlocks;- shared memory
- shared_buffers = 128M
其他缓存
Ring Buffer
When reading or writing a huge table, PostgreSQL uses a ring buffer instead of the buffer pool.
The ring buffer is a small, temporary buffer area. It is allocated in shared memory when any of the following conditions is met:
-
Bulk-reading: When scanning a relation whose size exceeds one-quarter of the buffer pool size (shared_buffers/4). In this case, the ring buffer size is 256 KB.
-
Bulk-writing: When executing the following SQL commands, the ring buffer size is 16 MB:
- COPY FROM command.
- CREATE TABLE AS command.
- CREATE MATERIALIZED VIEW or REFRESH MATERIALIZED VIEW command.
- ALTER TABLE command.
-
Vacuum-processing: When an autovacuum process performs vacuuming. In this case, the ring buffer size is 256 KB.
Local Buffer
When a backend creates a temporary table, the buffer manager allocates a memory area for the backend and creates a local buffer.
脏页落盘
- Checkpointer: 缩短崩溃恢复(Crash Recovery)时间
- Background Writer: 保证 Backend 进程随时有干净的 Buffer 可用(提前把
nextVictimBuffer指针前方的脏页刷出)
参考文档
- https://www.interdb.jp/pg/pgsql08/index.html
Buffer Victim
读取缓存
heapgettup_pagemode
heapgetpage
ReadBufferExtended | ReadBuffer_common
/* 1. Local Buffers */
/* 2. Shared Buffers */
BufferAlloc
/* A. Cache Hit */
StartBufferIO*
/* B. Cache Miss */
GetVictimBuffer
StrategyGetBuffer
/* a. GetBufferFromRing */
/* b. firstFreeBuffer */
/* c. clock sweep(REFCOUNT >= USAGECOUNT) */
if REFCOUNT == 0 && USAGECOUNT == 0
BufTableInsert
StartBufferIO*
smgrread*
TerminateBufferIO*
return BufferDescriptorGetBuffer(bufHdr);
HeapTupleSatisfiesVisibility
scan->rs_vistuples[ntup++] = lineoff;
lineoff = scan->rs_vistuples[lineindex];
lpp = PageGetItemId(page, lineoff);
/* end of scan */
if (BufferIsValid(scan->rs_cbuf))
ReleaseBuffer(scan->rs_cbuf);
获取缓存
- GetBufferFromRing
- StrategyControl->firstFreeBuffer
- Clock Sweep(时钟扫描算法)
BufferDesc *
StrategyGetBuffer(BufferAccessStrategy strategy, uint32 *buf_state, bool *from_ring)
{
/* a. GetBufferFromRing ... */
/* b. firstFreeBuffer ... */
/* c. clock sweep(REFCOUNT >= USAGECOUNT) */
/* Nothing on the freelist, so run the "clock sweep" algorithm */
trycounter = NBuffers;
for (;;)
{
buf = GetBufferDescriptor(ClockSweepTick());
/*
* If the buffer is pinned or has a nonzero usage_count, we cannot use
* it; decrement the usage_count (unless pinned) and keep scanning.
*/
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0)
{
if (BUF_STATE_GET_USAGECOUNT(local_buf_state) != 0)
{
local_buf_state -= BUF_USAGECOUNT_ONE;
trycounter = NBuffers;
}
else
{
/* Found a usable buffer */
if (strategy != NULL)
AddBufferToRing(strategy, buf);
*buf_state = local_buf_state;
return buf;
}
}
else if (--trycounter == 0)
{
/*
* We've scanned all the buffers without making any state changes,
* so all the buffers are pinned (or were when we looked at them).
* We could hope that someone will free one eventually, but it's
* probably better to fail than to risk getting stuck in an
* infinite loop.
*/
UnlockBufHdr(buf, local_buf_state);
elog(ERROR, "no unpinned buffers available");
}
UnlockBufHdr(buf, local_buf_state);
}
}
lmgr
Lock readme
锁概述
Postgres 使用四种类型的进程间锁:
-
自旋锁 (Spinlocks)。 这类锁旨在用于极短期的锁定。如果锁需要持有超过几十条指令的时间,或者跨越任何类型的内核调用(甚至是调用一个非平凡的子程序),请不要使用自旋锁。自旋锁主要用作轻量级锁的基础设施。如果可用,它们是使用硬件原子测试并设置 (atomic-test-and-set) 指令实现的。等待的进程会进行忙循环 (busy-loop),直到它们获取到锁。不提供死锁检测、出错时自动释放或任何其他便利功能。如果在一分钟左右无法获取锁,会有超时机制(相对于预期的锁持有时间,这大约是永远,因此这肯定是一个错误条件)。
-
轻量级锁 (Lightweight locks, LWLocks)。 这些锁通常用于互锁访问共享内存中的数据结构。LWLock 支持排他和共享两种锁模式(用于共享对象的读/写和只读访问)。不提供死锁检测,但 LWLock 管理器会在
elog()恢复期间自动释放已持有的 LWLock,因此在持有 LWLock 时抛出错误是安全的。当没有锁竞争时,获取或释放 LWLock 非常快(几十条指令)。当进程必须等待 LWLock 时,它会阻塞在 SysV 信号量上,以便不消耗 CPU 时间。等待的进程将按到达顺序被授予锁。没有超时机制。 -
常规锁 (Regular locks)(又称重量级锁 heavyweight locks)。 常规锁管理器支持多种锁模式,具有表驱动 (table-driven) 语义,并且拥有完整的死锁检测和事务结束时自动释放的功能。所有用户驱动的锁请求都应使用常规锁。
-
SIReadLock 谓词锁 (predicate locks)。 详情请参见单独的 README-SSI 文件。
获取自旋锁或轻量级锁会导致查询取消 (query cancel) 和 die() 中断被挂起 (held off),直到所有此类锁被释放。然而,常规锁不存在此类限制。另外请注意,我们在等待常规锁时可以接受查询取消和 die() 中断,但在等待自旋锁或 LW 锁时不会接受它们。因此,当等待时间可能超过几秒钟时,使用 LW 锁并不是一个好主意。
本 README 文件的其余部分将详细讨论常规锁管理器。
锁数据结构
锁方法 (Lock methods) 描述了整体的锁定行为。目前有两种锁方法:DEFAULT(默认)和 USER(用户)。
锁模式 (Lock modes) 描述了锁的类型(读/写 或 共享/排他)。原则上,每种锁方法都可以拥有自己的一套锁模式及不同的冲突规则,但目前 DEFAULT 和 USER 方法使用的是完全相同的锁模式集合。有关更多细节,请参阅 src/include/storage/lock.h。(在代码和文档的某些地方,锁模式也被称为锁类型。)
在共享内存中记录锁主要有两种机制:
-
主要机制使用两个核心结构体:
LOCK结构体:针对每个可锁定对象(per-lockable-object)。只要某个可锁定对象当前有被持有或被请求的锁,就会存在一个对应的LOCK对象。PROCLOCK结构体:针对每个后端进程与每个LOCK对象之间的锁定关系(per-lock-and-requestor)。如果一个后端进程正在持有或请求某个LOCK对象上的锁,就会存在一个对应的PROCLOCK结构体。
-
特殊的“快速路径” (fast path) 机制:后端进程可以使用此机制来记录数量有限且具有非常特定特征的锁。这些锁必须满足以下条件:
- 必须使用
DEFAULT锁方法; - 必须代表对数据库关系(relation)的锁(不能是共享关系);
- 必须是“弱”锁,即不太可能发生冲突的锁(具体指
AccessShareLock、RowShareLock或RowExclusiveLock); - 系统必须能够快速验证不可能存在任何冲突的锁。 有关更多细节,请参阅下文的“快速路径锁定 (Fast Path Locking)“。
- 必须使用
此外,每个后端进程还会为它当前正在持有或请求的每个可锁定对象及锁模式,维护一个非共享的 LOCALLOCK 结构体。
共享锁结构体仅允许针对每个“可锁定对象/锁模式/后端进程”组合进行一次锁授予。然而,在后端进程内部,同一个锁可能在事务中被多次请求甚至释放,也可以同时以事务级和会话级的方式持有。内部的请求计数保存在 LOCALLOCK 中,这样就不需要访问共享数据结构来修改它们了。
LOCK
typedef struct LOCK
{
/* hash key */
LOCKTAG tag; /* unique identifier of lockable object */
/* data */
LOCKMASK grantMask; /* bitmask for lock types already granted */
LOCKMASK waitMask; /* bitmask for lock types awaited */
dlist_head procLocks; /* list of PROCLOCK objects assoc. with lock */
dclist_head waitProcs; /* list of PGPROC objects waiting on lock */
int requested[MAX_LOCKMODES]; /* counts of requested locks */
int nRequested; /* total of requested[] array */
int granted[MAX_LOCKMODES]; /* counts of granted locks */
int nGranted; /* total of granted[] array */
} LOCK;
锁管理器的 LOCK 对象包含以下字段:
-
tag(标签)- 这是用于在共享内存锁哈希表中对锁进行哈希处理的关键字段。
tag的内容本质上定义了一个独立的可锁定对象。 - 关于支持的可锁定对象类型的详细信息,请参阅
include/storage/lock.h。 - 它被声明为一个单独的结构体,以确保我们总是能清零正确数量的字节。至关重要的是,编译器可能在结构体中插入的任何对齐填充字节(alignment-padding bytes)都必须被清零,否则哈希计算将是随机的。(目前,我们小心翼翼地定义
struct LOCKTAG,以确保其中没有填充字节。)
- 这是用于在共享内存锁哈希表中对锁进行哈希处理的关键字段。
-
grantMask(授予掩码)- 这是一个位掩码 (bitmask),指示当前在该可锁定对象上持有哪些类型的锁。
- 它用于(结合锁表的冲突表)确定新的锁请求是否会与现有的已持有锁类型发生冲突。
- 冲突是通过将
grantMask与所请求锁类型对应的冲突表条目进行按位与 (bitwise AND) 操作来确定的。 - 当且仅当
granted[i] > 0时,grantMask的第i位为 1。
-
waitMask(等待掩码)- 这是一个位掩码,显示当前正在等待哪些类型的锁。
- 当且仅当
requested[i] > granted[i]时,waitMask的第i位为 1。
-
procLocks(进程锁链表)- 这是一个位于共享内存中的队列,包含与该锁对象关联的所有
PROCLOCK结构体。 - 请注意,已授予和正在等待的
PROCLOCK都在这个列表中(事实上,同一个PROCLOCK可能已经持有一些已授予的锁,同时还在等待更多的锁!)。
- 这是一个位于共享内存中的队列,包含与该锁对象关联的所有
-
waitProcs(等待进程队列)- 这是一个位于共享内存中的队列,包含所有因等待其他后端释放此锁而处于等待(睡眠)状态的
PGPROC结构体(对应后端进程)。 - 进程结构体中持有必要的信息,用于确定当锁被释放时是否应该唤醒该进程。
- 这是一个位于共享内存中的队列,包含所有因等待其他后端释放此锁而处于等待(睡眠)状态的
-
nRequested(总请求次数)- 记录尝试获取此锁的总次数。
- 该计数包括那些因冲突而被放入睡眠状态的进程的尝试。
- 如果同一个后端进程先获取了读锁,然后又获取了写锁,它会被计数两次。
- (但是,同一个后端进程内部对同一锁/同一模式的多次获取不会在此处重复计数;这些记录仅保存在后端的
LOCALLOCK结构体中。)
-
requested(各模式请求计数数组)- 记录每种类型的锁被尝试请求的次数。
- 仅使用索引
1到MAX_LOCKMODES-1的元素,因为它们对应于定义的锁类型常量(索引 0 不使用)。 - 对
requested[]数组中的所有值求和,结果应等于nRequested。
-
nGranted(总授予次数)- 记录成功获取此锁的总次数。
- 该计数不包括因冲突而正在等待的尝试。
- 其他的计数规则与
nRequested相同。
-
granted(各模式授予计数数组)- 记录当前持有的每种类型的锁的数量。
- 同样,仅使用索引
1到MAX_LOCKMODES-1的元素(0 不使用)。 - 与
requested[]类似,对granted[]数组中的所有值求和,结果应等于nGranted。
不变式约束: 我们必须始终满足:
0 <= nGranted <= nRequested- 对于每个
i,0 <= granted[i] <= requested[i]
当所有请求计数归零时,LOCK 对象不再需要,可以被释放。
PROCLOCK
锁管理器的 PROCLOCK 对象包含以下字段:
-
tag(标签)- 这是用于在共享内存
PROCLOCK哈希表中对条目进行哈希处理的关键字段。 - 它被声明为一个单独的结构体,以确保我们总是能清零正确数量的字节。至关重要的是,编译器可能在结构体中插入的任何对齐填充字节(alignment-padding bytes)都必须被清零,否则哈希计算将是随机的。(目前,我们小心翼翼地定义
struct PROCLOCKTAG,以确保其中没有填充字节。) tag.myLock: 指向此PROCLOCK所对应的共享LOCK对象的指针。tag.myProc: 指向拥有此PROCLOCK的后端进程的PGPROC结构的指针。- 注意:在这里使用指针是安全的,因为
PROCLOCK的生命周期绝不会超过其关联的锁(LOCK)或其关联的进程(PGPROC)。因此,只要该PROCLOCK存在,这个标签就是唯一的,即使相同的指针值在其他时间点可能代表完全不同的含义(即内存复用后)。
- 这是用于在共享内存
-
holdMask(持有掩码)- 这是一个位掩码,表示此
PROCLOCK成功获取的锁模式。 - 它应该是
LOCK对象的grantMask的子集。 - 同时,如果该
PGPROC正在等待同一锁对象上的其他模式锁,它也应该是PGPROC对象的heldLocks掩码的子集。 - 通俗理解:这就是该进程当前“手里实实在在拿着”的锁有哪些。
- 这是一个位掩码,表示此
-
releaseMask(释放掩码)- 这是一个位掩码,表示在调用
LockReleaseAll时即将被释放的锁模式。 - 它必须是
holdMask的子集(你只能释放你持有的锁)。 - 重要并发细节:这个字段的修改不需要获取分区的 LWLock(轻量级锁)。因此,除了拥有此
PROCLOCK的那个后端进程本身外,任何其他后端进程检查或修改此字段都是不安全的。 - 设计意图:这是一种优化。当事务结束需要批量释放所有锁时, owning backend 可以独自快速标记哪些锁要放掉,而无需争抢全局锁,从而提高事务提交/回滚的性能。
- 这是一个位掩码,表示在调用
-
lockLink(锁链表链接)- 这是用于将所有属于同一个
LOCK对象的PROCLOCK对象链接起来的列表指针。 - 通过它,可以从
LOCK对象找到所有对该对象感兴趣(持有或等待)的进程(即前文提到的procLocks队列)。
- 这是用于将所有属于同一个
-
procLink(进程链表链接)- 这是用于将所有属于同一个后端进程的
PROCLOCK对象链接起来的列表指针。 - 通过它,可以从
PGPROC(进程结构)快速找到该进程当前参与的所有锁对象。这对于事务结束时快速遍历并释放该进程持有的所有锁非常关键。
- 这是用于将所有属于同一个后端进程的
核心要点总结
- 唯一性由指针保证:
tag直接存储LOCK*和PGPROC*指针。因为 PG 的内存管理保证了只要PROCLOCK活着,它指向的锁和进程就一定活着,所以不用担心悬空指针问题。这也避免了在 tag 中存储复杂的 ID 映射,提升了查找速度。 - 双重链表归属:
lockLink让PROCLOCK挂在 锁 的维度上(方便锁管理器看谁在等这个锁)。procLink让PROCLOCK挂在 进程 的维度上(方便进程看自己持有了哪些锁,或在退出时清理)。- 这使得
PROCLOCK成为连接“资源”与“请求者”的完美桥梁。
- 无锁优化 (
releaseMask):releaseMask的设计体现了高性能数据库的典型特征——在能保证正确性的前提下(只有所有者能改),尽可能减少锁竞争,让事务清理阶段更快。
[LOCK: Table A]
|
+-- procLocks (链表) --> [PROCLOCK: Proc 1 & Table A] --> [PROCLOCK: Proc 2 & Table A] --> ...
| | |
| v v
| (指向 Proc 1) (指向 Proc 2)
|
+-- waitProcs (队列) --> [PGPROC: Proc 2] (如果在睡)
[PGPROC: Current Backend]
|
+-- myProcLocks[0] --> [PROCLOCK: Me & Lock X] --> [PROCLOCK: Me & Lock Y]
| | |
| v v
| (指向 LOCK X) (指向 LOCK Y)
|
+-- myProcLocks[1] --> [PROCLOCK: Me & Lock Z]
| |
| v
| (指向 LOCK Z)
...
+-- myProcLocks[N] --> (空)
保存位置
/*
* Pointers to hash tables containing lock state
*
* The LockMethodLockHash and LockMethodProcLockHash hash tables are in
* shared memory; LockMethodLocalHash is local to each backend.
*/
static HTAB *LockMethodLockHash;
static HTAB *LockMethodProcLockHash;
static HTAB *LockMethodLocalHash;
锁管理器内部锁定机制
在 PostgreSQL 8.2 之前,锁管理器使用的所有共享内存数据结构都由单个轻量级锁(LWLock)—— LockMgrLock 进行保护;任何涉及这些数据结构的操作都必须独占性地锁定 LockMgrLock。不出所料,这成为了一个竞争瓶颈。
为了减少竞争,锁管理器的数据结构已被拆分为多个“分区 (partitions)“,每个分区由一个独立的 LWLock 保护。大多数操作只需要锁定它们正在操作的那个单一分区即可。具体细节如下:
-
锁的分区分配:每个可能的锁根据其
LOCKTAG值的哈希结果被分配到一个特定的分区。该分区的 LWLock 被认为保护了该分区内的所有LOCK对象及其附属的PROCLOCK对象。 -
哈希表的分区组织:
- 用于
LOCK和PROCLOCK的共享内存哈希表经过组织,使得不同的分区使用不同的哈希链。因此,操作不同分区中的对象时不会产生冲突。 - 对于
LOCK表,这直接由dynahash.c的“分区表”机制支持:我们只需确保分区号取自LOCKTAG的dynahash哈希值的低位比特。 - 为了让这对
PROCLOCK也生效,我们必须确保PROCLOCK的哈希值与其关联的LOCK具有相同的低位比特。这需要专门的哈希函数(参见proclock_hash)。
- 用于
-
PGPROC 列表的分区化:
- 以前,每个
PGPROC(进程结构)只有一个属于它的PROCLOCK列表。 - 现在,这已被拆分为每个分区一个列表。这样,访问特定的
PROCLOCK列表就可以由相关联分区的 LWLock 来保护。 - (这条规则允许一个后端进程操作另一个后端进程的
PROCLOCK列表。这在最初并非必要,但现在为了配合“快速路径锁定 (fast-path locking)“已成为必需;详见下文。)
- 以前,每个
-
PGPROC 其他字段的保护:
PGPROC中其他与锁相关的字段仅在该PGPROC等待锁时才相关,因此我们认为它们由所等待锁所在分区的 LWLock 保护。
处理锁请求函数签名
/*
* Find or create LOCK and PROCLOCK objects as needed for a new lock
* request.
*
* Returns the PROCLOCK object, or NULL if we failed to create the objects
* for lack of shared memory.
*
* The appropriate partition lock must be held at entry, and will be
* held at exit.
*/
static PROCLOCK *
SetupLockInTable(LockMethod lockMethodTable, PGPROC *proc,
const LOCKTAG *locktag, uint32 hashcode, LOCKMODE lockmode);
关于正常操作与死锁检测:
- 正常的锁获取与释放:只需锁定包含目标锁的那个分区就足够了。
- 死锁检测:通常需要接触多个分区。为了简化实现,我们让其按分区编号顺序锁定所有分区。
- 防止 LWLock 死锁的规则:任何需要同时锁定多个分区的后端进程,必须按分区编号升序依次锁定它们。
- 虽然在典型情况下,死锁检测可能无需触碰每一个分区就能完成,但在一个运行正常的系统中,死锁检测不应频繁到成为性能关键点的程度。因此,试图优化这一点似乎并不是高效利用开发精力的做法。
关于 LOCALLOCK:
- 后端的内部
LOCALLOCK哈希表没有进行分区。 - 我们在
LOCALLOCK表条目中存储了锁标签(locktag)哈希码的副本,从中可以计算出分区号。 - 这是一种典型的空间换时间 (speed-for-space) 的权衡:我们也可以选择在需要时从
LOCKTAG重新计算分区号,但存储副本可以避免重复计算,提升速度。
快速路径锁定 (Fast Path Locking)
快速路径锁定是一种专用机制,旨在降低获取和释放某些特定类型锁的开销。这些锁的特点是:被频繁地获取和释放,但极少发生冲突。目前,该机制主要涵盖两类锁:
-
弱关系锁 (Weak relation locks):
SELECT、INSERT、UPDATE和DELETE操作必须获取它们所操作的每个关系(表)以及各种内部使用的系统目录的锁。- 许多 DML 操作可以针对同一张表并行执行;只有 DDL 操作(如
CLUSTER、ALTER TABLE或DROP)或用户的显式操作(如LOCK TABLE)才会与 DML 操作获取的“弱”锁(即AccessShareLock、RowShareLock、RowExclusiveLock)产生冲突。
-
VXID 锁 (虚拟事务 ID 锁):
- 每个事务都会获取其自身虚拟事务 ID (VXID) 的锁。
- 目前,只有
CREATE INDEX CONCURRENTLY和热备 (Hot Standby,在发生冲突时) 等操作会等待这些锁。因此,大多数 VXID 锁由其所有者获取和释放,无需其他进程关心。
主要问题: 主要的锁机制无法很好地应对这种工作负载。即使锁管理器的锁已经进行了分区,任何给定关系的锁标签 (locktag) 仍然只落在一个且唯一一个分区中。因此,如果许多短查询同时访问同一个关系,该分区的锁管理器分区锁就会成为竞争瓶颈。这种效应在双核服务器上就已经可测量,并且随着核心数量的增加而变得非常显著。
解决方案:
为了缓解这一瓶颈,从 PostgreSQL 9.2 开始,允许每个后端进程在其 PGPROC 结构内的一个数组中记录有限数量的非共享关系上的锁,而不是使用主锁表。
- 使用条件:仅当加锁者能够验证在获取锁的时刻不存在任何冲突锁时,才能使用此机制。
核心算法逻辑: 该算法的关键点在于:必须能够在不争抢共享 LWLock 或自旋锁的情况下,验证是否存在潜在的冲突锁。否则,这只是将竞争瓶颈从一个地方转移到了另一个地方,毫无意义。
我们如何实现这一点?
- 我们使用了一个包含 1024 个整数计数器的数组 (
FastPathStrongRelationLocks)。这实际上是将锁空间进行了 1024 路分区。 - 每个计数器记录了落入该分区的非共享关系上的“强“锁(即
ShareLock、ShareRowExclusiveLock、ExclusiveLock和AccessExclusiveLock)的数量。 - 规则:当某个计数器非零时,禁止在该分区内使用快速路径机制来获取新的关系锁。
- 强锁获取流程:
- 想要获取强锁的进程首先将该计数器加 1 (“bump the counter”)。
- 然后扫描每个后端进程的快速路径数组,查找匹配的快速路径锁。
- 如果发现任何匹配项,必须在尝试获取锁之前,将这些锁转移 (transfer) 到主锁表中。这是为了确保正确的锁冲突检测和死锁检测。
内存同步 (SMP 系统): 在多处理器 (SMP) 系统上,我们必须保证适当的内存同步。这里我们依赖一个事实:LWLock 的获取充当了内存序列点 (memory sequence point)。
- 原理:如果进程 A 执行了存储操作,随后进程 A 和 B 以任意顺序获取了同一个 LWLock,接着进程 B 对同一内存位置执行加载操作,那么 B 保证能看到 A 的存储结果。
- 应用:
- 每个后端的快速路径锁队列都由一个 LWLock 保护。
- 想获取快速路径锁的后端:在检查
FastPathStrongRelationLocks以确认是否存在冲突的强锁之前,必须先获取这个 LWLock。 - 想获取强锁的后端:由于它必须将所有通过快速路径获取的匹配弱锁转移到共享锁表,因此它将依次获取每一个保护后端快速路径队列的 LWLock。
- 结论:如果我们检查
FastPathStrongRelationLocks发现值为 0,那么要么该值确实为 0;要么它是一个过时的值,但在这种情况下,获取强锁的进程尚未获取到我们当前持有的那个后端 LWLock(甚至可能是第一个后端 LWLock)。一旦它获取到该锁,它就会注意到我们刚刚获取的任何弱锁。
关于 VXID 锁的特殊处理:
- 快速路径 VXID 锁不使用
FastPathStrongRelationLocks表。 - VXID 上的第一个锁始终是其所有者获取的
ExclusiveLock。 - 任何后续的加锁者都是等待 VXID 结束的共享锁持有者。
- 事实上,VXID 锁之所以使用锁管理器(而不是通过其他方式等待 VXID 结束),唯一的原因是为了死锁检测。
- 因此,初始的 VXID 锁总是可以通过快速路径获取,无需检查冲突。
- 任何后续的加锁者必须检查该锁是否已被转移到主锁表;如果没有,则执行转移操作。
- 拥有 VXID 的后端必须在事务结束时小心清理主锁表中的任何条目。
死锁检测: 死锁检测不需要检查快速路径数据结构,因为任何可能卷入死锁的锁,在此之前都必然已经被转移到了主表中。
The Deadlock Detection Algorithm
Miscellaneous Notes
Group Locking
User Locks (Advisory Locks)
Locking during Hot Standby
Lock Overview
Overview
PostgreSQL 基于 MVCC 机制实现了读写无阻塞,允许事务通过快照访问历史版本数据;然而针对写写冲突,系统仍需依赖锁机制进行协调,以确保同一行数据在并发修改时的原子性与一致性。
- 初始化数据
create table tb
insert into tb values
- Txn 1
begin;
update tb set a = 1;
- Txn 2
begin;
update tb set a = 2; -- blocked
- Locks
#define AccessShareLock 1 /* SELECT */
#define RowShareLock 2 /* SELECT FOR UPDATE/FOR SHARE */
#define RowExclusiveLock 3 /* INSERT, UPDATE, DELETE */
#define ShareUpdateExclusiveLock 4 /* VACUUM (non-FULL), ANALYZE, CREATE INDEX CONCURRENTLY */
#define ShareLock 5 /* CREATE INDEX (WITHOUT CONCURRENTLY) */
#define ShareRowExclusiveLock 6 /* like EXCLUSIVE MODE, but allows ROW SHARE */
#define ExclusiveLock 7 /* blocks ROW SHARE/SELECT...FOR UPDATE */
#define AccessExclusiveLock 8 /* ALTER TABLE, DROP TABLE, VACUUM FULL, and unqualified LOCK TABLE */
- conflict matrix
ACCESS SHARE | ROW SHARE | ROW EXCL. | SHARE UPDATE EXCL. | SHARE | SHARE ROW EXCL. | EXCL. | ACCESS EXCL. | |
|---|---|---|---|---|---|---|---|---|
ACCESS SHARE | X | |||||||
ROW SHARE | X | X | ||||||
ROW EXCL. | X | X | X | X | ||||
SHARE UPDATE EXCL. | X | X | X | X | X | |||
SHARE | X | X | X | X | X | |||
SHARE ROW EXCL. | X | X | X | X | X | X | ||
EXCL. | X | X | X | X | X | X | X | |
ACCESS EXCL. | X | X | X | X | X | X | X | X |
业务操作
│
├─ SELECT
│
├─ 写操作
│ INSERT / UPDATE / DELETE
│
├─ 维护操作
│ VACUUM / ANALYZE
│
├─ 弱DDL
│ CREATE INDEX
│ CREATE TRIGGER
│
└─ 强DDL
ALTER TABLE
DROP TABLE
TRUNCATE
Lock in update
-- psql 1
select pg_backend_pid(); -- 4840
-- psql 2
select * from pg_locks where pid = 4840;
-- psql 1
update tb set a = 1;
0. exec_simple_query
exec_simple_query
start_xact_command
pg_analyze_and_rewrite_fixedparams
PortalRun | PortalRunMulti | ProcessQuery
finish_xact_command
1. vxid lock
start_xact_command
StartTransaction
GetNextLocalTransactionId
VirtualXactLockTableInsert /* Take vxid lock via the fast-path */
2. RowExclusiveLock
pg_analyze_and_rewrite_fixedparams
parse_analyze_fixedparams | transformTopLevelStmt | transformOptionalSelectInto
transformStmt | transformUpdateStmt
setTargetTable | parserOpenTable(pstate, relation, RowExclusiveLock)
table_openrv_extended | relation_openrv_extended | RangeVarGetRelidExtended
LockRelationOid /* lmgr.c */
LockAcquireExtended /* lock.c */
3. transactionid lock
PortalRun | PortalRunMulti | ProcessQuery
ExecutorRun | standard_ExecutorRun | ExecutePlan | ...
heap_update
GetCurrentTransactionId | AssignTransactionId
XactLockTableInsert
LockAcquire /* sleep if conflict found, set lock if/when no conflicts.*/
LockAcquireExtended
partitionLock = LockHashPartitionLock(hashcode);
LWLockAcquire(partitionLock, LW_EXCLUSIVE);
proclock = SetupLockInTable
LockCheckConflicts
GrantLock(lock, proclock, lockmode);
GrantLockLocal(locallock, owner);
LWLockRelease(partitionLock);
return LOCKACQUIRE_OK;
4. tuple lock
heap_update
GetCurrentTransactionId | AssignTransactionId
XactLockTableInsert
HeapTupleSatisfiesUpdate
compute_new_xmax_infomask
CheckForSerializableConflictIn
START_CRIT_SECTION();
PageSetPrunable(page, xid);
HeapTupleSetHotUpdated(&oldtup); /* Mark the old tuple as HOT-updated */
HeapTupleSetHeapOnly(heaptup); /* And mark the new tuple as heap-only */
HeapTupleSetHeapOnly(newtup); /* Mark the caller's copy too, in case different from heaptup */
RelationPutHeapTuple
oldtup.t_data->t_ctid = heaptup->t_self; /* record address of new tuple in t_ctid of old one */
MarkBufferDirty(buffer);
/* XLOG stuff */
log_heap_update
PageSetLSN
END_CRIT_SECTION();
return TM_Ok;
5. finish_xact_command
finish_xact_command
CommitTransactionCommand
CommitTransaction
ResourceOwnerRelease
ResourceOwnerReleaseInternal
ProcReleaseLocks
LockReleaseAll
VirtualXactLockTableCleanup
完整过程
exec_simple_query
start_xact_command
StartTransaction
GetNextLocalTransactionId
VirtualXactLockTableInsert /* Take vxid lock via the fast-path */
pg_analyze_and_rewrite_fixedparams
parse_analyze_fixedparams | transformTopLevelStmt | transformOptionalSelectInto
transformStmt | transformUpdateStmt
setTargetTable | parserOpenTable(pstate, relation, RowExclusiveLock)
table_openrv_extended | relation_openrv_extended | RangeVarGetRelidExtended
LockRelationOid /* Lock a relation given only its OID */
PortalRun | PortalRunMulti | ProcessQuery
ExecutorRun | standard_ExecutorRun | ExecutePlan | ...
heap_update
GetCurrentTransactionId | AssignTransactionId
XactLockTableInsert
LockAcquire /* sleep if conflict found, set lock if/when no conflicts.*/
LockAcquireExtended
partitionLock = LockHashPartitionLock(hashcode);
LWLockAcquire(partitionLock, LW_EXCLUSIVE);
proclock = SetupLockInTable
LockCheckConflicts
GrantLock(lock, proclock, lockmode);
GrantLockLocal(locallock, owner);
LWLockRelease(partitionLock);
return LOCKACQUIRE_OK;
HeapTupleSatisfiesUpdate
compute_new_xmax_infomask
CheckForSerializableConflictIn
START_CRIT_SECTION();
PageSetPrunable(page, xid);
HeapTupleSetHotUpdated(&oldtup); /* Mark the old tuple as HOT-updated */
HeapTupleSetHeapOnly(heaptup); /* And mark the new tuple as heap-only */
HeapTupleSetHeapOnly(newtup); /* Mark the caller's copy too, in case different from heaptup */
RelationPutHeapTuple
oldtup.t_data->t_ctid = heaptup->t_self; /* record address of new tuple in t_ctid of old one */
MarkBufferDirty(buffer);
/* XLOG stuff */
log_heap_update
PageSetLSN
END_CRIT_SECTION();
return TM_Ok;
finish_xact_command
CommitTransactionCommand
CommitTransaction
ResourceOwnerRelease
ResourceOwnerReleaseInternal
ProcReleaseLocks
LockReleaseAll
VirtualXactLockTableCleanup
Lock conflict
begin;
select * from tb;
alter table tb add c int;
exec_simple_query
PortalRun | PortalRunMulti | PortalRunUtility | ProcessUtility
standard_ProcessUtility | ProcessUtilitySlow
AlterTableLookupRelation | RangeVarGetRelidExtended
LockRelationOid | LockAcquireExtended | WaitOnLock
ProcSleep
WaitLatch | WaitEventSetWait /* src/backend/storage/ipc/latch.c */
WaitEventSetWaitBlock
进程A(持锁) 进程B(请求锁)
LockAcquire
LockAcquire
→ 冲突
→ 加入 wait queue
→ ProcSleep(睡眠)
LockRelease
→ ProcLockWakeup
→ SetLatch(B)
被唤醒
→ 重新检查
→ 获取锁成功
| PROC A (持有锁) | PROC B(请求锁) |
|---|---|
| LockAcquire | |
| LockAcquire -> 冲突 -> 加入 wait queue -> ProcSleep | |
| LockRelease -> ProcLockWakeup -> SetLatch(B) | |
| 被唤醒 -> 重新检查 -> 获取锁 |
utils
mmgr
Memory Readme
Memory Context System Design Overview
背景
我们的绝大多数内存分配都在 “内存上下文” 中完成,内存上下文通常是由 src/backend/utils/mmgr/aset.c 实现的 AllocSet 结构。实现低开销且可靠的内存管理,关键在于定义一套生命周期合理的内存上下文集合。
内存上下文的基本操作包括:
- 创建一个上下文
- 在上下文内分配一块内存(等价于标准 C 库的 malloc ())
- 删除一个上下文(同时释放其中分配的所有内存)
- 重置一个上下文(释放上下文内分配的所有内存,但不销毁上下文对象本身)
- 查询分配给该上下文的总内存大小(指上下文用于分配的原始内存,而非单个内存块)
对于已从某个上下文分配的内存块,可以对其进行释放,或者进行扩容、缩容(对应标准 C 库的 free() 和 realloc())。这些操作都会将内存归还到最初分配该块的上下文,或从该上下文申请更多内存。
系统始终存在一个由全局变量 CurrentMemoryContext 标识的 “当前” 内存上下文。palloc() 会隐式在当前上下文中分配空间。MemoryContextSwitchTo() 用于切换新的当前上下文,并返回切换前的上下文,以便调用者在退出前恢复原上下文。
相比直接使用 malloc/free,内存上下文的主要优势在于可以一次性释放整个上下文的所有内存,无需逐个释放内部的每一块内存。这种方式比单独管理每块内存更快、更可靠。我们在事务结束时利用这一特性进行内存清理:通过重置所有事务级或更短生命周期的活动上下文,即可回收所有临时内存。同理,也可以在每条查询结束时,或查询处理完每一行元组后完成清理。
关于 palloc API 与标准 C 库的区别说明
palloc 及其相关函数的行为与标准 C 库的 malloc 系列函数类似,但也存在一些刻意设计的差异。以下说明用于明确其行为特性。
-
若内存不足,palloc 和 repalloc 会通过 elog(ERROR) 直接退出程序。它们永远不会返回 NULL,因此检测返回值是否为 NULL 是不必要且无意义的。在使用 palloc_extended() 时,可以通过 MCXT_ALLOC_NO_OOM 标志覆盖该行为。
-
palloc(0) 是明确合法的操作。它不会返回 NULL 指针,而是会返回一个有效的内存块,只是该内存块不允许使用任何字节。不过,该内存块后续可以通过 repalloc 扩容,也可以无错误地通过 pfree 释放。同理,repalloc 允许将内存重分配为 0 大小。
-
pfree 和 repalloc 不接受 NULL 指针,这是刻意设计的规则。 (对于 repalloc 而言,这是必要的:如前所述,repalloc 不依赖当前内存上下文,因此必须知道在哪个内存上下文中执行分配。所以首次分配必须在 repalloc 之外完成。对于 pfree 而言,该行为主要是历史原因,部分原因是额外的空指针检查会影响性能。)
当前内存上下文
由于总是将合适的内存上下文传递给被调用函数会带来过大的代码编写开销,因此系统中始终存在一个 当前内存上下文(CurrentMemoryContext) 的概念。 如果没有它,例如 copyObject 函数就需要额外传递一个上下文参数,返回引用传递数据类型的函数执行函数同样也需要。对于那些内部临时分配内存、却不会将内存返回给调用者的函数来说也是如此。我们显然不希望让系统中的每一处调用都充斥着“请使用这个上下文进行你可能需要的任何临时内存分配”这样的冗余代码。
不过,基于上述考虑得出的结论是:CurrentMemoryContext 应尽可能指向一个生命周期较短的上下文。在查询执行期间,它通常指向一个每处理完一个元组就会被重置的上下文。只有在极其有限的代码中,才可以让它指向生命周期超过事务的上下文,因为这样做存在造成永久性内存泄漏的风险。
pfree/repalloc 不依赖当前内存上下文
pfree() 和 repalloc() 可作用于任意内存块,无论该内存块是否属于当前内存上下文——系统都会找到该内存块所属的上下文,并由其负责处理对应的操作。
父、子上下文
如果所有上下文都是相互独立的,将会很难对它们进行管理,尤其是在出错的场景下。这一问题通过构建“父-子”上下文的树形结构来解决。创建内存上下文时,可以将新上下文指定为某个已有上下文的子节点。一个上下文可以拥有多个子上下文,但只能有一个父节点。通过这种方式,所有上下文构成一片森林(并非一定是单棵树,因为可以存在多个顶层上下文;不过在当前实际实现中,只有一个顶层上下文 TopMemoryContext)。
删除一个上下文时,会同时删除其所有直接和间接子上下文。重置一个上下文时,删除其子上下文通常更符合实际需求,因此 MemoryContextReset() 就是这样设计的;如果你确实需要保留树形结构、只清空上下文内容,则需要调用 MemoryContextResetOnly() 再配合 MemoryContextResetChildren()。
这些机制让我们可以安全地管理大量上下文,不必担心出现泄漏。我们只需要维护一个会在事务结束时删除的顶层上下文,并确保创建的所有生命周期更短的上下文都是它的后代即可。由于树形结构可以有多层,我们可以轻松处理嵌套的存储生命周期,例如事务级、语句级、扫描级、元组级。对于仅部分重叠的存储生命周期,可以通过从上下文森林的不同树中分配内存来处理(下一节会给出一些示例)。
为方便使用,系统还提供了一类操作:重置或删除指定上下文的所有子节点,但不改动该上下文本身。
内存上下文重置/删除回调函数
PostgreSQL 9.5 引入的一项特性,允许内存上下文不仅用于管理普通的 palloc 内存,还能管理更多类型的资源。实现方式是为内存上下文注册“重置回调函数”。该函数会在上下文下一次被重置或删除之前被调用一次,可用于释放那些与上下文中分配的对象存在关联的资源。典型应用场景包括:
- 关闭与元组排序对象相关联的已打开文件;
- 释放被待重置上下文中的对象所持有的、长生命周期缓存对象的引用计数;
- 释放与某些 palloc 对象关联的、由 malloc 管理的内存。
最后一种场景在纯 PostgreSQL 代码中属于不良编程习惯;更好的做法是统一在目标上下文或其子上下文中使用 palloc 完成所有内存分配。不过,在与非 PostgreSQL 库交互的代码中,这种方式会非常实用。
一个内存上下文可以注册任意数量的重置回调,调用顺序与注册顺序相反。当一整棵上下文树被重置或删除时,子上下文的回调会先于父上下文的回调执行。
对应的 API 要求调用者提供一个 MemoryContextCallback 内存块,用于保存回调的状态信息。通常这块内存应分配在逻辑上与之关联的同一个上下文中,以便使用后能自动释放。要求调用者自行提供这段内存的原因是:在大多数使用场景下,调用者会在目标上下文中创建一个更大的结构体,将 MemoryContextCallback 结构体嵌入其中,无需单独执行 palloc() 即可“免费”获得该结构体空间。
Memory Contexts in Practice
全局已知内存上下文
系统中存在若干广泛使用、通过全局变量引用的内存上下文。在任意时刻,系统可能还包含许多其他上下文,但所有这些上下文都必须是下列上下文的直接或间接子节点,以确保在发生错误时不会发生内存泄漏。
TopMemoryContext —— 这是上下文树真正的顶层节点,其他所有上下文都是它的直接或间接子节点。在此分配内存本质上等同于使用 malloc,因为该上下文永远不会被重置或删除。它用于存放需要永久存活的数据,或由对应管理模块负责在合适时机删除的数据。例如 fd.c 中的打开文件管理表。除非绝对必要,否则应避免在此分配内存,尤其要避免将 CurrentMemoryContext 指向此处。
PostmasterContext —— 这是 Postmaster 主进程的常规工作上下文。在衍生出后端进程后,后端进程可以删除此上下文,以释放不需要的、从 Postmaster 继承的内存。注意在非 EXEC_BACKEND 编译模式下,Postmaster 持有的 pg_hba.conf 和 pg_ident.conf 配置数据会在后端进程认证阶段被直接使用,因此后端进程必须在认证完成后才能删除此上下文。(Postmaster 仅拥有 TopMemoryContext、PostmasterContext 和 ErrorContext,其余顶层上下文均在各个后端进程启动时创建。)
CacheMemoryContext —— 用于关系缓存、系统表缓存及相关模块的永久存储空间。该上下文同样永远不会被重置或删除,因此从功能上看它与 TopMemoryContext 并无本质区别。但为了便于调试,保留这一区分是有意义的。(注意:CacheMemoryContext 拥有生命周期更短的子上下文。例如,与关系缓存条目相关的辅助存储最适合放在子上下文中,这样可以轻松释放规则解析树等资源,而不必依赖实现可靠的 freeObject()。)
MessageContext —— 该上下文用于存放来自前端的当前命令消息,以及仅需存活至当前消息处理完成的临时存储(例如在简单查询模式下,语法解析树和执行计划树可存放在此处)。在 PostgresMain 主循环的每一轮处理开始前,此上下文都会被重置,其所有子节点都会被删除。它与事务级、Portal 级上下文相互独立,因为查询字符串的存活周期可能长于或短于单个事务或 Portal。
TopTransactionContext —— 存放所有需要存活至顶层事务结束的数据。该上下文会在每次顶层事务周期结束时被重置,其所有子节点都会被删除。大多数情况下不应直接在此分配内存,而应在 CurTransactionContext 中分配;此处仅用于存放明确需要跨多个子事务管理状态的控制信息。注意:该上下文在出错时不会立即清空,其内容会保留到事务块通过 COMMIT/ROLLBACK 退出为止。
CurTransactionContext —— 存放必须存活至当前事务结束、且在顶层事务提交时需要使用的数据。在顶层事务中,它与 TopTransactionContext 指向同一个上下文;在子事务中,它指向一个子节点上下文。需要重点注意:如果子事务中止,其 CurTransactionContext 会在中止处理完成后被丢弃;而已提交的子事务的 CurTransactionContext 会被保留至顶层事务提交(除非中间某层子事务中止)。这一机制确保不会长期保留失败子事务产生的数据。基于此行为,在子事务中止时必须正确清理状态:子事务的数据结构必须从上层事务的指针或链表中解除关联,否则会产生悬空指针并导致顶层提交时进程崩溃。典型例子是待发送的 NOTIFY 消息,它们仅在生成该消息的子事务未中止时,才会在顶层事务提交时发送。
PortalContext —— 这并非一个实际独立的上下文,而是一个全局变量,指向当前活跃执行 Portal 的专属上下文。当需要分配仅存活于当前 Portal 执行周期的内存时,可以使用该上下文。
ErrorContext —— 这是一个永久上下文,专门用于错误恢复处理,并在恢复完成后被重置。系统始终保证该上下文中有几 KB 的可用内存。这样即使后端进程已耗尽其他内存,仍能确保错误恢复流程拥有可用内存,从而将内存不足处理为普通 ERROR 级别错误,而非 FATAL 致命错误。
Contexts For Prepared Statements And Portals
Logical Replication Worker Contexts
Transient Contexts During Execution
Mechanisms to Allow Multiple Types of Contexts
More Control Over aset.c Behavior
Alternative Memory Context Implementations
Memory Accounting
Memory Overview
MemoryContext
基础 malloc/free 独立分配释放,效率低、管理复杂。(相当于直接在根目录下管理文件)
void *malloc(size_t size);
void *realloc( void *ptr, size_t new_size);
void free( void *ptr );
PostgreSQL 通过 MemoryContext 实现按生命周期统一内存管理,提升效率与可靠性。palloc (相当于在根目录下创建子目录独立管理)
核心 API
void *palloc(Size size);
void *repalloc(void *pointer, Size size);
void pfree(void *pointer);
上下文相关核心 API
/* 创建 */
AllocSetContextCreate
AllocSetContextCreateInternal
MemoryContextCreate
/* 切换 */
MemoryContextSwitchTo
/* 删除 (递归删除所有子上下文 + 释放内存)*/
MemoryContextDelete
/* 重置 (释放所有内存,但保留上下文本身)*/
MemoryContextReset
类比文件系统
| MemoryContext 概念 | 文件系统类比 | 说明 |
|---|---|---|
MemoryContext | 目录 (Directory) | 内存对象的容器 |
TopMemoryContext | 根目录 / | 永远存在,所有目录的父节点 |
palloc() | touch file | 在当前目录下创建文件 |
CurrentMemoryContext | pwd | 新文件默认创建在这里 |
MemoryContextSwitchTo() | cd /path/to/dir | 切换当前工作目录 |
MemoryContextDelete() | rm -rf dir | 删除目录及旗下所有文件 |
MemoryContextReset() | rm -rf dir/* | 清空内容,目录留着下次复用 |
MemoryContextSetParent | mv | 移动到其他上下文 |
| 子上下文 | 子目录 | 父目录删除时,子目录自动被删 |
| 内存泄漏 | 忘记删临时目录 | 文件残留,占用磁盘空间 |
Memory TopMemoryCtx
TopMemoryContext (后端生命周期)
├── ErrorContext 用于错误恢复处理
├── PostmasterContext* Postmaster 主进程专用(fork 后子进程删除)
├── CacheMemoryContext 缓存关系、系统表、CachedPlanSource(扩展协议)
├── TopPortalContext 管理查询执行实例(Portal),支持游标/分步获取/跨消息状态保持
├── MessageContext 处理单条消息,原始语法树/消息缓冲区
├── RowDescriptionContext 构建列描述信息(扩展协议)
└── TopTransactionContext 存放生命周期和顶层事务一致的数据
| 上下文名称 | 内部数据有效生命周期 | 重置触发时机 |
|---|---|---|
| ErrorContext | 错误处理期间 | 错误处理完后手动重置 |
| CacheMemoryContext | 会话级/缓存失效 | 显式失效或内存压力 |
| MessageContext | 消息级 (几毫秒) | 每条新消息到来前 |
| TopTransactionContext | 事务级 (几秒/分) | 事务 Commit/Rollback 后 |
| TopPortalContext | 语句/游标级 | 查询结束或 Cursor Close |
PostmasterContext仅存在于 Postmaster 主守护进程中,普通后端进程 (Backend) 无此上下文。
boot 相关上下文
main
MemoryContextInit
TopMemoryContext = AllocSetContextCreate((MemoryContext) NULL, ...);
CurrentMemoryContext = TopMemoryContext;
ErrorContext = AllocSetContextCreate(TopMemoryContext, ...);
PostmasterMain
PostmasterContext = AllocSetContextCreate(TopMemoryContext, ...);
MemoryContextSwitchTo(PostmasterContext);
ServerLoop | BackendStartup | BackendRun
/* child process */
MemoryContextSwitchTo(TopMemoryContext);
PostgresMain
InitPostgres
RelationCacheInitialize | CreateCacheMemoryContext
CacheMemoryContext = AllocSetContextCreate(TopMemoryContext, ...)
InitCatalogCache
EnablePortalManager
TopPortalContext = AllocSetContextCreate(TopMemoryContext, ...)
MemoryContextDelete(PostmasterContext) // delete postmaster in child context
MessageContext = AllocSetContextCreate(TopMemoryContext, ...)
row_description_context = AllocSetContextCreate(TopMemoryContext, ...) // for RowDescription messages
for (;;) /* queries loop */
{
MemoryContextSwitchTo(MessageContext);
MemoryContextResetAndDeleteChildren(MessageContext);
exec_simple_query(query_string);
}
Memory QueryContext
执行阶段
TopPortalContext
└── PortalContext
└── QueryContext("ExecutorState")
├── ExprContext
├── tmpcontext("printtup")
└── SortContext | HashContext
“simple Query”
MemoryContextSwitchTo(MessageContext);
MemoryContextResetAndDeleteChildren(MessageContext);
exec_simple_query
/* create and switch to TopTransactionContext */
start_xact_command | StartTransactionCommand | StartTransaction | AtStart_Memory
TopTransactionContext = AllocSetContextCreate(TopMemoryContext, ...)
CurTransactionContext = TopTransactionContext;
MemoryContextSwitchTo(CurTransactionContext);
/* ... */
MemoryContextSwitchTo(MessageContext);
pg_parse_query
pg_analyze_and_rewrite_fixedparams
pg_plan_queries
CreatePortal
portal->portalContext = AllocSetContextCreate(TopPortalContext, ...)
PortalStart
MemoryContextSwitchTo(PortalContext)
CreateQueryDesc
ExecutorStart | standard_ExecutorStart | standard_ExecutorStart
estate = CreateExecutorState()
qcontext = AllocSetContextCreate(CurrentMemoryContext, ...)
MemoryContextSwitchTo(qcontext)
estate = makeNode(EState);
estate->es_query_cxt = qcontext
MemoryContextSwitchTo(estate->es_query_cxt)
InitPlan | ExecInitNode | ExecInitSeqScan
/* create expression context for node */
ExecAssignExprContext
planstate->ps_ExprContext = CreateExprContext(estate);
CreateExprContextInternal
econtext->ecxt_per_tuple_memory = AllocSetContextCreate(estate->es_query_cxt, "ExprContext")
return econtext;
/* ... */
PortalRun
MemoryContextSwitchTo(PortalContext)
PortalRunSelect
/* ... */
PortalDrop
portal->cleanup(portal);
PortalCleanup
ExecutorFinish
ExecutorEnd | standard_ExecutorEnd | FreeExecutorState
FreeExprContext
MemoryContextDelete(econtext->ecxt_per_tuple_memory);
MemoryContextDelete(estate->es_query_cxt);
MemoryContextDelete(portal->portalContext);
finish_xact_command | CommitTransactionCommand | CommitTransaction
AtCommit_Memory
MemoryContextSwitchTo(TopMemoryContext);
MemoryContextDelete(TopTransactionContext);
PortalRun
PortalRun
MemoryContextSwitchTo(PortalContext)
PortalRunSelect
ExecutorRun | standard_ExecutorRun
MemoryContextSwitchTo(estate->es_query_cxt)
printtup_startup
/* a temporary memory context that we can reset once per row to recover palloc'd memory */
myState->tmpcontext = AllocSetContextCreate(CurrentMemoryContext, "printtup", ...)
ExecutePlan /* Loop until we've processed the proper number of tuples from the plan. */
ResetPerTupleExprContext(estate); /* (estate)->es_per_tuple_exprcontext */
ExecProcNode | ExecSeqScan | ExecScan
ResetExprContext(node->ps.ps_ExprContext);
/* get a tuple for(;;)*/
ExecProject
ExecEvalExprSwitchContext
oldContext = MemoryContextSwitchTo(econtext->ecxt_per_tuple_memory);
retDatum = state->evalfunc(state, econtext, isNull);
MemoryContextSwitchTo(oldContext);
return retDatum;
printtup
/* Switch into per-row context so we can recover memory below */
oldcontext = MemoryContextSwitchTo(myState->tmpcontext);
/* send message, text/binary */
MemoryContextSwitchTo(QueryContext)
MemoryContextReset(myState->tmpcontext)
dest->rShutdown(dest);
printtup_shutdown
MemoryContextDelete(myState->tmpcontext);
Memory Duty
PostgreSQL 的内存管理:
- MemoryContext:管内存数据。核心是“管理生命周期”,专供解析、运行所需临时数据,靠树形结构批量释放。
- Buffer Manager:管磁盘数据。核心是“缓存数据”,例如用 LRU 等算法把热点页留在内存,解决 IO 慢的问题。
- ResourceOwner:管资源归属。核心是“谁申请、谁负责释放”,用于跟踪事务/子事务持有的资源(如 buffer pin、锁、临时文件等),在事务结束或出错时统一回收,保证不会泄漏。
| 维度 | MemoryContext | Buffer Manager | ResourceOwner |
|---|---|---|---|
| 本质定义 | 生命周期管理器 | 数据缓存(通常进程间共享) | 资源归属与释放控制器 |
| 服务对象 | CPU / 计算逻辑 | 磁盘 / 持久化存储 | 事务 / 执行过程 |
| 核心关注 | 时间维度(生命周期) | 价值维度(热点数据) | 所有权维度(谁持有资源) |
| 数据性质 | 易失;临时副本、中间结果、解析树等 | 持久代理;数据、日志、clog等 | 资源句柄(lock、buffer pin、fd 等) |
| 管理机制 | 树形结构;批量操作 | 数组+算法;随机访问 | 栈/树结构(嵌套事务);数组记录资源 |
| 分配策略 | 弹性增长;按需分配 | 刚性限制;按需淘汰 | 显式登记;remember / forget |
| 清理方式 | 批量清零 (Reset) | 精细淘汰 (LRU/Clock) | 按作用域释放(事务结束统一释放) |
| 典型实现 | AllocSet, Generation, Slab | Shared Buffers, Temp Buffers, WAL Buffers | TopTransactionResourceOwner |
Memory Impl
| 上下文类型 | AllocSetContext(默认) | GenerationContext | SlabContext |
|---|---|---|---|
| 核心特点 | 维护多块内存 按大小分空闲链表,复用碎片 | 不重用单个空闲块 整块只有全空才释放 | 只能分配固定大小 无碎片、O(1) 分配/释放 |
| 适用场景 | 通用场景 | FIFO; 同生命周期对象 | 大量同尺寸对象 |
| 实现文件 | aset.c | generation.c | slab.c |
多态实现:
AllocSetContext,GenerationContext,SlabContext是MemoryContext的三种实现- 其中
MemoryContext类似抽象类,palloc的核心是调用具体上下文实现的虚函数(虚函数表MemoryContext::methods) - methods 在
MemoryContext中声明,在xxContextCreate实例化时赋值为特定上下文的函数指针,从而实现多态
palloc
MemoryContext context = CurrentMemoryContext;
ret = context->methods->alloc(context, size);
三种实现的基本元素:
Block: 调用malloc一次获得指定大小内存空间, Block 构成双向链表Chunk: 调用alloc实际得到的内存空间
resowner
README
资源所有者(Resource Owners)相关说明
概述
ResourceOwner 对象是一个旨在简化查询相关资源(如缓冲区引脚 buffer pins 和表锁)管理的概念。这些资源需要以可靠的方式进行跟踪,以确保即使查询因错误而失败,也能在查询结束时释放它们。与其期望整个执行器拥有无懈可击的数据结构,我们将此类资源的跟踪工作局部化到一个单独的模块中。
ResourceOwner API 的设计借鉴了我们的 MemoryContext API,后者在防止内存泄漏方面已被证明非常灵活且成功。特别是,我们允许 ResourceOwner 拥有子 ResourceOwner 对象,从而形成资源所有者的“森林”结构;释放父 ResourceOwner 时,会同时作用于其所有直接和间接子对象。
(虽然将 ResourceOwners 和 MemoryContexts 统一为单一对象类型颇具诱惑力,但由于它们的使用模式存在显著差异,这样做可能并无实际帮助。)
我们会为每个事务或子事务创建一个 ResourceOwner,也为每个 Portal 创建一个。在 Portal 执行期间,全局变量 CurrentResourceOwner 指向该 Portal 的 ResourceOwner。这使得 ReadBuffer 和 LockAcquire 等操作能够将所获取资源的所有权记录在该 ResourceOwner 对象中。
当 Portal 关闭时,任何剩余的资源(通常仅是锁)将移交给当前事务负责。这在实现上表现为将 Portal 的 ResourceOwner 设为当前事务 ResourceOwner 的子对象。resowner.c 会在释放子对象时自动将资源转移给父对象。同样,子事务的 ResourceOwner 也是其直接父事务的子对象。
我们需要事务相关的 ResourceOwner 以及 Portal 相关的 ResourceOwner,因为事务可能会在没有关联 Portal 存在的情况下发起需要资源的操作(例如查询解析)。
API 概览
ResourceOwner 的基本操作包括:
- 创建一个 ResourceOwner
- 将某些资源与 ResourceOwner 关联或解除关联
- 释放(Release)ResourceOwner 的资产(释放所有拥有的资源,但不释放 owner 对象本身)
- 删除(Delete)一个 ResourceOwner(包括子 owner 对象);在此之前必须已释放所有资源
此 API 直接支持 src/backend/utils/resowner/resowner.c 中 ResourceOwnerData 结构体定义所列出的资源类型。其他对象可以通过在其内部记录所属 ResourceOwner 的地址来与 ResourceOwner 关联。API 提供了钩子机制,允许其他模块在 ResourceOwner 释放期间介入,以便扫描各自的数据结构并找到需要删除的对象。
锁的特殊处理:
锁的处理方式较为特殊,因为在非错误情况下,即使锁最初是由子事务或 Portal 获取的,也应持有至事务结束。因此,如果 isCommit 为真,对子 ResourceOwner 执行“释放”操作时,会将锁的所有权转移给父对象,而不是真正释放锁。
只要处于事务内部,全局变量 CurrentResourceOwner 就指示当前获取的资源应归属于哪个资源所有者。需要注意的是,当事务之外(或处于失败的事务中)时,CurrentResourceOwner 为 NULL。在这种情况下,获取具有 Query 生命周期的资源是无效的。
当取消缓冲区引脚(unpinning a buffer)、释放锁或缓存引用时,CurrentResourceOwner 必须指向与获取该缓冲区、锁或缓存引用时相同的那个资源所有者。虽然通过额外的簿记工作可以放宽这一限制,但目前看来并无此必要。
ResourceOwner Overview
ResourceOwner 用于“统一管理非内存资源生命周期”的机制,确保资源在事务/执行结束或异常时被正确释放(手动实现 RAII )。
- 资源分散在各模块(buffer / lock / snapshot / file …)
- 执行中可能随时中断
- 需要统一兜底释放
核心设计
树结构(作用域)
TopTransaction
├── SubTransaction
└── Portal
分阶段释放(保证资源依赖顺序正确)
BEFORE LOCKS → LOCKS → AFTER LOCKS
核心结构
/*
* ResourceOwner objects look like this
*/
typedef struct ResourceOwnerData
{
ResourceOwner parent; /* NULL if no parent (toplevel owner) */
ResourceOwner firstchild; /* head of linked list of children */
ResourceOwner nextchild; /* next child of same parent */
const char *name; /* name (just for debugging) */
/* We have built-in support for remembering: */
ResourceArray bufferarr; /* owned buffers */
ResourceArray bufferioarr; /* in-progress buffer IO */
ResourceArray catrefarr; /* catcache references */
ResourceArray catlistrefarr; /* catcache-list pins */
ResourceArray relrefarr; /* relcache references */
ResourceArray planrefarr; /* plancache references */
ResourceArray tupdescarr; /* tupdesc references */
ResourceArray snapshotarr; /* snapshot references */
ResourceArray filearr; /* open temporary files */
ResourceArray dsmarr; /* dynamic shmem segments */
ResourceArray jitarr; /* JIT contexts */
ResourceArray cryptohasharr; /* cryptohash contexts */
ResourceArray hmacarr; /* HMAC contexts */
/* We can remember up to MAX_RESOWNER_LOCKS references to local locks. */
int nlocks; /* number of owned locks */
LOCALLOCK *locks[MAX_RESOWNER_LOCKS]; /* list of owned locks */
} ResourceOwnerData;
核心接口
ResourceOwnerCreate
ResourceOwnerRelease
ResourceOwnerDelete
ResourceOwnerRemember_____
ResourceOwnerForget_____
BEGIN;
INSERT INTO t VALUES (0);
SAVEPOINT sp1;
INSERT INTO t VALUES (1);
ROLLBACK TO sp1;
SAVEPOINT sp2;
INSERT INTO t VALUES (2);
COMMIT;
pageinspect
观测堆/索引页二进制布局的 contrib 工具。页结构示意见 Page Layout。
table and page
-- 空表初始文件 0 KB
create table tb (a int);
-- 插入第一行数据,文件扩展为 8 KB
insert into tb values (1), (2), (3);
pageinspect 介绍
- PostgreSQL 提供的一个内省扩展模块
- 允许用户通过 SQL 界面直接观察磁盘数据页(Page)的原始二进制内容及元数据结构
- 代码位于
postgres/contrib/pageinspect/,编译后使用
# 1. 自动获取PG服务端头文件目录(模糊化安装路径)
PG_INCLUDE=$(<PG_INSTALL_DIR>/bin/pg_config --includedir-server)
# 2. 编译扩展(指定PG版本+头文件路径)
make PG_CONFIG=<PG_INSTALL_DIR>/bin/pg_config CPPFLAGS="-I$PG_INCLUDE"
# 3. 安装扩展(指定PG版本)
make install PG_CONFIG=<PG_INSTALL_DIR>/bin/pg_config
psql 客户端运行
create extension pageinspect;
常用函数说明:
-
get_raw_page: 从磁盘读取原始 8KB 数据块 -
page_header: 查看 LSN、lower、upper 等页头元数据 -
heap_page_items: 解析行指针和元组头(xmin, xmax) -
bt_page_items: 查看索引记录及其指向的元组地址 -
heap_page_item_attrs: 解码字段内容
page
查询页头信息
select * from page_header(get_raw_page('tb', 0));
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
| lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid |
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
| 0/2926978 | 0 | 0 | 36 | 8096 | 8192 | 8192 | 4 | 0 |
+-----------+----------+-------+-------+-------+---------+----------+---------+-----------+
原因
源码结构
typedef struct PageHeaderData
{
/* XXX LSN is member of *any* block, not only page-organized ones */
PageXLogRecPtr pd_lsn; /* LSN */
uint16 pd_checksum; /* checksum */
uint16 pd_flags; /* flag bits, see below */
LocationIndex pd_lower; /* offset to start of free space */
LocationIndex pd_upper; /* offset to end of free space */
LocationIndex pd_special; /* offset to start of special space */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; /* oldest prunable XID, or zero if none */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointer array */
} PageHeaderData;
tuple
select lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_field3, t_ctid, t_infomask from heap_page_items(get_raw_page('tb', 0));
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
| 1 | 8160 | 1 | 28 | 1228 | 0 | 0 | (0,1) | 2048 |
+----+--------+----------+--------+--------+--------+----------+--------+------------+
ItemIdData: 元组行指针 line pointer
HeapTupleHeaderData: 元组在磁盘上的二进制布局信息
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;
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, or both */
TransactionId t_xvac; /* old-style VACUUM FULL xact ID */
} t_field3;
} HeapTupleFields;
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, see below */
uint8 t_hoff; /* sizeof header incl. bitmap, padding */
/* ^ - 23 bytes - ^ */
bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* bitmap of NULLs */
/* MORE DATA FOLLOWS AT END OF STRUCT */
};