PostgreSQL 源码
- 编程
- 数据库
- 源码
目录
- 导览
- src/ 主要子模块
- backend/ 内部
- 关键入口
- 进程模型
- contrib/ 中值得注意的
- 阅读建议
- 一条 SQL 的旅程
- 入口链:从 main() 到 PostgresMain()
- 1. PostgresMain:一个 backend 的一生
- 1.1 三个阶段
- 1.2 阶段 A —— 一次性初始化
- 1.3 阶段 B —— sigsetjmp 错误着陆点
- 1.4 阶段 C —— 主消息循环
- 2. exec_simple_query:一条 SQL 的七步走
- 2.1 函数骨架
- 2.2 七步图示
- 2.3 七步详解
- ① start_xact_command()
- ② pg_parse_query()
- ③ 取快照
- ④ pg_analyze_and_rewrite()
- ⑤ pg_plan_queries()
- ⑥ Portal 执行
- ⑦ finish_xact_command()
- 3. 一图看穿:协议消息时序
- 4. 关键变量速查表
- 查询优化器
- 1. pg_plan_queries:进入优化器的门
- 2. planner / standard_planner:钩子点 + 全局状态
- 3. subquery_planner → grouping_planner → query_planner
- 4. make_one_rel:基数估计真正登场
- 5. set_baserel_size_estimates:单表基数主公式
- 5.1 rel->tuples 哪里来的?
- 6. clauselist_selectivity:选择率合成器
- 6.1 单条短路
- 6.2 扩展统计(multivariate stats)
- 6.3 剩余子句:独立性 + 范围对子识别
- 6.4 范围合并
- 7. clause_selectivity_ext → 各类 *sel 函数
- 8. eqsel / var_eq_const:等值估计的标准模板
- 8.1 var_eq_const 的三层 fallback
- 9. scalarineqsel:范围/不等式估计
- 9.1 直方图
- 9.2 MCV part 的处理
- 10. eqjoinsel:等值连接基数
- 10.1 calc_joinrel_size_estimate
- 11. examine_variable & VariableStatData
- pg_statistic 的 5 个槽位
- 12. estimate_num_groups:GROUP BY / DISTINCT 基数
- 13. estimate_hash_bucket_stats:哈希连接专用
- 14. ANALYZE / pg_statistic 是怎么填的
- default_statistics_target
- 15. 基数估计的”已知误差源”清单
- 16. 给你做基数估计研究的”切入口”
- MVCC
- 为什么需要 MVCC
- 1. 一行数据上的 MVCC 标记
- 1.1 为什么每行要有两个 xid
- 1.2 元组头里到底装了什么
- 1.3 t_infomask 的状态位
- 2. 一行数据的生命周期
- 2.1 INSERT:写一行新数据
- 2.2 DELETE:只是打标记,不删数据
- 2.3 UPDATE:等于一次 DELETE + INSERT
- 2.4 SELECT FOR UPDATE:行级锁
- 3. 快照:每个事务的”宇宙观”
- 3.1 SnapshotData 结构
- 3.2 GetSnapshotData:拍这张快照的过程
- 3.3 GetTransactionSnapshot:何时拍快照?
- 4. 核心:HeapTupleSatisfiesMVCC
- 4.1 Step A:xmin 是否对我可见
- 4.2 Step B:xmax 是否对我已生效
- 4.3 一些反直觉的点
- 5. 隔离级别背后的实现
- 5.1 READ COMMITTED:每语句一张快照
- 5.2 REPEATABLE READ:全事务一张快照
- 5.3 SERIALIZABLE:SSI
- 6. update 链与 HOT
- 6.1 update 链的物理形态
- 6.2 普通 update 的写放大
- 6.3 HOT:Heap-Only Tuple
- 6.4 HOT prune:顺手清理
- 7. VACUUM:清死人
- 7.1 OldestXmin 水位线
- 7.2 VACUUM 在干什么
- 7.3 Freezing:抗 xid 回卷
- 8. CLOG:xid 状态总账本
- 9. 经典场景串讲
- 9.1 一行的完整生命周期
- 9.2 长事务的”链式反应”
源码下载链接:https://www.postgresql.org/ftp/source/v14.23/
导览
代码规模约 2100+ 个 C/H 文件,仅 backend 就有 800+ 个 .c 文件。
postgresql-14.23/
├── configure / configure.ac # GNU autoconf 构建脚本
├── GNUmakefile.in / Makefile # 主构建系统
├── INSTALL / README # 安装与说明文档
├── config/ # autoconf 宏
├── contrib/ # 官方扩展(pg_stat_statements、postgres_fdw、pgcrypto、hstore…)
└── src/ # 主源代码
src/ 主要子模块
| 目录 | 作用 |
|---|---|
| backend/ | PostgreSQL 服务器主体(postgres 守护进程) |
| bin/ | 命令行工具:psql、initdb、pg_dump、pg_ctl、pg_basebackup、pgbench 等 |
| interfaces/ | 客户端库:libpq(C 接口)、ecpg(嵌入式 SQL) |
| include/ | 全局头文件,与 backend/ 子模块对应 |
| common/ | 前后端共享代码 |
| port/ | 平台移植层 |
| pl/ | 过程语言(PL/pgSQL、PL/Perl、PL/Python、PL/Tcl) |
| test/ | 回归测试、隔离测试、TAP 测试 |
| tools/ | 开发者工具 |
| timezone/ | 时区数据库(来自 IANA) |
backend/ 内部
执行流程(一条 SQL 的旅程)大致是:
客户端 → libpq → postmaster (fork) → backend 进程
parser → analyzer → rewriter → planner/optimizer → executor
↓
access methods (heap/btree/...)
↓
storage (buffer/WAL/disk)
对应的源码模块:
main/main.c— 进程入口,分发到 postmaster / standalone / bootstrappostmaster/— 主守护进程、autovacuum、bgwriter、checkpointer、walwriter、syslogger、pgstat、archivertcop/— “Traffic cop”,处理客户端请求;postgres.c是后端主循环(5000+ 行)parser/— 词法/语法解析,生成原始 parse treerewrite/— 视图/规则重写optimizer/— 查询优化器(path/plan/prep/util/geqo)executor/— 火山模型执行器nodes/— Node 抽象(plan/parse 节点的拷贝、读写、比较)access/— 访问方法heap/堆表、nbtree/B-Tree、hash/、gin/、gist/、spgist/、brin/transam/事务管理(XLOG、CLOG、commit/abort)
storage/— buffer pool、共享内存、锁、I/O、page、freespace、smgrcatalog/— 系统目录(pg_class、pg_attribute…)commands/— DDL 实现(CREATE/ALTER/DROP)replication/— 流复制、逻辑复制、WAL sender/receiverutils/— 内存管理(MemoryContext)、缓存、错误处理、数据类型函数、fmgr、GUClibpq/— 服务端的协议栈(注意区别于interfaces/libpq/)statistics/— 扩展统计信息partitioning/— 表分区jit/— LLVM JIT 编译
关键入口
src/backend/main/main.c::main()— 任何 postgres 进程都从这里起步src/backend/postmaster/postmaster.c::PostmasterMain()— 主监听进程,接受连接后 fork backendsrc/backend/tcop/postgres.c::PostgresMain()— 单个 backend 的主循环(解析→规划→执行)src/backend/bootstrap/bootstrap.c—initdb阶段使用的 bootstrap 模式
进程模型
PostgreSQL 是多进程架构(不是多线程):postmaster 监听 → 每个客户端 fork 一个 backend 进程 → 配合一组辅助进程(checkpointer、bgwriter、walwriter、autovacuum、archiver、stats collector、logical replication launcher 等)共享一块共享内存。
contrib/ 中值得注意的
pg_stat_statements— SQL 性能统计postgres_fdw— 外部数据封装,跨库联邦查询pgcrypto— 加密函数pageinspect、pg_buffercache、pgstattuple— 内部页面/缓冲检视pg_prewarm、pg_trgm、hstore、btree_gin/gist、bloom— 常用扩展
阅读建议
如果想深入理解,按重要性顺序推荐:
tcop/postgres.c中的PostgresMain和exec_simple_query—— 抓住”一条 SQL 是如何执行的”主线postmaster/postmaster.c—— 进程模型storage/buffer/bufmgr.c—— 缓冲管理access/transam/xlog.c—— WAL 与崩溃恢复optimizer/plan/planner.c和executor/execMain.c—— 优化与执行access/heap/heapam.c+access/nbtree/—— 表和索引访问
一条 SQL 的旅程
主线问题:客户端发出一条 SELECT * FROM t WHERE id = 1; 之后,PostgreSQL 后端从读到这条字节流,到把结果行写回客户端,中间到底走了哪些代码?
入口链:从 main() 到 PostgresMain()
main() src/backend/main/main.c
└─► PostmasterMain() src/backend/postmaster/postmaster.c
├─► (监听 socket,accept 新连接)
├─► BackendStartup(port) postmaster.c:4216
│ └─► fork_process()
│ └─ (子进程分支)
│ ├─► InitPostmasterChild() /* 脱离 postmaster */
│ ├─► ClosePostmasterPorts() /* 关掉父进程的监听 socket */
│ ├─► BackendInitialize(port) /* 读 startup packet,
│ │ 设置 ps_status,
│ │ 不做认证 */
│ └─► BackendRun(port) postmaster.c:4548
│ └─► PostgresMain(ac, av,
│ port->database_name,
│ port->user_name); ★ 本文起点
│ └─► InitPostgres()
│ └─► PerformAuthentication()
│ └─► ClientAuthentication() /* 真正认证 */
└─► (循环 accept …)
- pospostmaster 是父进程,长期监听端口
- 每个新连接
fork_process()一个 backend 子进程 BackendInitialize只读 startup packet,**认证发生在PostgresMain→InitPostgres→ **PerformAuthentication——这样认证失败也能走标准ereport(ERROR)的sigsetjmp错误- Windows 走
EXEC_BACKEND分支:backend_forkexec重新 exec 出 postgres 进程,由SubPostmasterMain调同样的BackendInitialize/BackendRun
/*
* Any Postgres server process begins execution here.
*/
int
main(int argc, char *argv[])
{
// ...
if (argc > 1 && strcmp(argv[1], "--boot") == 0)
AuxiliaryProcessMain(argc, argv); /* does not return */
else if (argc > 1 && strcmp(argv[1], "--describe-config") == 0)
GucInfoMain(); /* does not return */
else if (argc > 1 && strcmp(argv[1], "--single") == 0)
PostgresMain(argc, argv,
NULL, /* no dbname */
strdup(get_user_name_or_exit(progname))); /* does not return */
else
PostmasterMain(argc, argv); /* does not return */
abort(); /* should not get here */
}
PostmasterMain 代码很长,说一下核心流程,会进入ServerLoop 函数
static int
ServerLoop(void)
{
// ...
/*
* New connection pending on any of our sockets? If so, fork a child
* process to deal with it.
*/
if (selres > 0)
{
int i;
for (i = 0; i < MAXLISTEN; i++)
{
if (ListenSocket[i] == PGINVALID_SOCKET)
break;
if (FD_ISSET(ListenSocket[i], &rmask))
{
Port *port;
port = ConnCreate(ListenSocket[i]);
if (port)
{
BackendStartup(port);
/*
* We no longer need the open socket or port structure
* in this process
*/
StreamClose(port->sock);
ConnFree(port);
}
}
}
}
然后到BackendStartup
static int
BackendStartup(Port *port)
{
// ...
pid = fork_process();
if (pid == 0) /* child */
{
free(bn);
/* Detangle from postmaster */
InitPostmasterChild();
/* Close the postmaster's sockets */
ClosePostmasterPorts(false);
/* Perform additional initialization and collect startup packet */
BackendInitialize(port);
/* And run the backend */
BackendRun(port);
}
BackendRun 很短,不反悔,组装 args
static void
BackendRun(Port *port)
{
char *av[2];
const int ac = 1;
av[0] = "postgres";
av[1] = NULL;
/*
* Make sure we aren't in PostmasterContext anymore. (We can't delete it
* just yet, though, because InitPostgres will need the HBA data.)
*/
MemoryContextSwitchTo(TopMemoryContext);
PostgresMain(ac, av, port->database_name, port->user_name);
}
1. PostgresMain:一个 backend 的一生
源码位置:src/backend/tcop/postgres.c:4011,原型:
void
PostgresMain(int argc, char *argv[],
const char *dbname,
const char *username);
1.1 三个阶段
PostgresMain 的代码可以切成三段来读:
┌─ 阶段 A:进程级初始化(只跑一次) 行 4011 ~ 4250
├─ 阶段 B:sigsetjmp 错误恢复着陆点 行 4273 ~ 4378
└─ 阶段 C:for(;;) 主消息循环(每条 SQL) 行 4390 ~ 终
1.2 阶段 A —— 一次性初始化
按顺序做了这些事(行号近似):
| 行号 | 动作 | 说明 |
|---|---|---|
| 4023 | InitStandaloneProcess | standalone 模式下的伪环境(postgres --single) |
| 4026 | SetProcessingMode(InitProcessing) | 标识当前处于初始化阶段,部分功能受限 |
| 4032 | InitializeGUCOptions | GUC(全局参数)初值 |
| 4037 | process_postgres_switches | 解析 -D、-c 等命令行 |
| 4076–4110 | pqsignal(...) | 安装信号处理器:SIGINT→StatementCancel、SIGTERM→die、SIGQUIT→quickdie |
| 4137 | BaseInit | 共享内存附着、buffer manager 等基础初始化 |
| 4149 | InitProcess | 在共享内存里分配 PGPROC 槽位(这就是连接数硬上限的来源) |
| 4162 | InitPostgres | 真正”打开数据库”:身份认证后续、catalog 装载、relcache、syscache |
| 4177 | SetProcessingMode(NormalProcessing) | 切到正常模式,可以执行用户 SQL |
| 4183 | BeginReportingGUCOptions | 把 GUC 报给客户端(用于驱动行为) |
| 4202 | process_session_preload_libraries | 加载 session_preload_libraries |
| 4207–4216 | 发送 'K' 消息 | 把 pid + cancel key 告诉客户端,用于将来的 query cancel |
| 4228 | 创建 MessageContext | 一条消息的内存上下文,每轮循环重置 |
| 4238 | 创建 row_description_context | 行描述消息的复用缓冲 |
关键内存上下文层级:
所有
palloc()都挂在某个 context 下,context 销毁则全部释放——这是 PG 不写一个free()的秘密。
TopMemoryContext (整个进程)
├── MessageContext (每条客户端消息,每轮 reset)
├── row_description_context (跨消息复用)
├── CacheMemoryContext (relcache/syscache,长生命周期)
└── ErrorContext (错误处理专用)
1.3 阶段 B —— sigsetjmp 错误着陆点
if (sigsetjmp(local_sigjmp_buf, 1) != 0)
{
/* 错误恢复区:任何 ereport(ERROR) 都会 longjmp 跳到这里 */
error_context_stack = NULL;
HOLD_INTERRUPTS();
disable_all_timeouts(false);
pq_comm_reset();
EmitErrorReport(); /* 把 ERROR 信息发给客户端、写日志 */
debug_query_string = NULL;
AbortCurrentTransaction(); /* 回滚事务 */
PortalErrorCleanup();
...
MemoryContextSwitchTo(TopMemoryContext);
FlushErrorState();
if (doing_extended_query_message)
ignore_till_sync = true;
xact_started = false;
RESUME_INTERRUPTS();
}
PG_exception_stack = &local_sigjmp_buf;
这是 PG 异常处理的”最外层”。理解几个要点:
- 不是 try/catch:这里用
sigsetjmp而非PG_TRY,注释明确说原因——PG_TRY在 catch 段没有更外层的 handler,这里是栈底,必须永远在位。 - 任何
ereport(ERROR)落在这里:经siglongjmp跳转,AbortCurrentTransaction回滚事务,但进程不死,下一轮循环继续读消息。 - 这是 PG 错误模型的根基:单条 SQL 出错 ≠ 连接断开 ≠ 进程退出。
1.4 阶段 C —— 主消息循环
简化骨架:
for (;;)
{
/* 每轮 reset MessageContext,释放上一条消息的内存 */
MemoryContextSwitchTo(MessageContext);
MemoryContextResetAndDeleteChildren(MessageContext);
initStringInfo(&input_message);
/* (1) 告诉前端"我空闲了,发命令吧" */
if (send_ready_for_query)
ReadyForQuery(whereToSendOutput);
/* (3) 阻塞读一条客户端消息(这是 backend 大部分时间所在) */
DoingCommandRead = true;
firstchar = ReadCommand(&input_message);
DoingCommandRead = false;
/* (7) 根据消息首字节分发 */
switch (firstchar)
{
case 'Q': exec_simple_query(...); break; /* ★ 简单查询 */
case 'P': exec_parse_message(...); break; /* 扩展协议: Parse */
case 'B': exec_bind_message(...); break; /* 扩展协议: Bind */
case 'E': exec_execute_message(...); break; /* 扩展协议: Execute */
case 'D': exec_describe_*(); break;
case 'C': /* Close */ break;
case 'S': /* Sync */ break;
case 'X': proc_exit(0); break; /* Terminate */
case 'F': HandleFunctionRequest(...); break; /* Fastpath */
case 'd': /* CopyData */ break;
case EOF: proc_exit(0); break;
}
}
2. exec_simple_query:一条 SQL 的七步走
源码位置:src/backend/tcop/postgres.c:923。这是把一条文本 SQL 变成结果集的”主管线”。
2.1 函数骨架
static void
exec_simple_query(const char *query_string)
{
/* (1) 标记进程状态,开始事务命令 */
debug_query_string = query_string;
pgstat_report_activity(STATE_RUNNING, query_string);
start_xact_command();
/* (2) 解析 → 原始 parse tree 列表 */
parsetree_list = pg_parse_query(query_string);
/* 一条消息可能含多条 SQL,循环处理每条 */
foreach(parsetree_item, parsetree_list)
{
/* (3) 拿快照(只为 parse analysis/planning 服务) */
if (analyze_requires_snapshot(parsetree))
PushActiveSnapshot(GetTransactionSnapshot());
/* (4) 语义分析 + 重写 */
querytree_list = pg_analyze_and_rewrite(parsetree, query_string,
NULL, 0, NULL);
/* (5) 规划 / 优化 */
plantree_list = pg_plan_queries(querytree_list, query_string,
CURSOR_OPT_PARALLEL_OK, NULL);
if (snapshot_set) PopActiveSnapshot();
/* (6) 创建 portal、绑 plan、跑 portal */
portal = CreatePortal("", true, true);
PortalDefineQuery(portal, NULL, query_string,
commandTag, plantree_list, NULL);
PortalStart(portal, NULL, 0, InvalidSnapshot);
PortalSetResultFormat(portal, 1, &format);
receiver = CreateDestReceiver(dest);
if (dest == DestRemote)
SetRemoteDestReceiverParams(receiver, portal);
PortalRun(portal, FETCH_ALL, true, true,
receiver, receiver, &qc);
receiver->rDestroy(receiver);
PortalDrop(portal, false);
/* (7) 单语句结束 → CommandCounterIncrement 或提交事务 */
if (last_stmt) finish_xact_command();
else CommandCounterIncrement();
EndCommand(&qc, dest, false);
}
finish_xact_command();
}
2.2 七步图示
┌────────────────────────────────────────────────────────┐
query │ exec_simple_query("SELECT * FROM t WHERE id=1;") │
string └────────────────────────────────────────────────────────┘
────► ① start_xact_command 事务命令开始(隐式 BEGIN)
│
────► ② pg_parse_query 文本 → RawStmt (List<RawStmt*>)
│ 调 flex/bison: scan.l + gram.y
│
────► ③ GetTransactionSnapshot 拿事务/语句快照(MVCC 起点)
│
────► ④ pg_analyze_and_rewrite parse analysis + 规则重写
│ ├─ parse_analyze RawStmt → Query (含 OID / Var / 类型)
│ └─ QueryRewrite 视图展开、规则重写
│
────► ⑤ pg_plan_queries Query → PlannedStmt (Plan tree)
│ └─ standard_planner 优化器:subquery_planner →
│ grouping_planner → make_one_rel
│ 产出 SeqScan / IndexScan / HashJoin / ...
│
────► ⑥ Portal 执行 PortalStart / PortalRun
│ └─ ExecutorStart 初始化 PlanState 树
│ ExecutorRun 火山模型 ExecProcNode 拉行
│ ExecutorEnd 清理
│ 行 → DestReceiver → 'D' DataRow 发回客户端
│
────► ⑦ finish_xact_command 提交事务(写 WAL → 刷盘)
EndCommand: 'C' CommandComplete
外层循环再发 'Z' ReadyForQuery
2.3 七步详解
① start_xact_command()
- 若当前不在事务中,开启隐式事务(PG 的”自动提交”语义在这里)。
- 若已在显式
BEGIN块中,仅推进 command id。 - 内部调
StartTransactionCommand()→StartTransaction(),分配XactTopTransactionId是惰性的,SELECT不写不分配 xid。
② pg_parse_query()
- 进入
src/backend/parser/parser.c::raw_parser - flex(
scan.l)→ bison(gram.y),输出List *ofRawStmt - 此时还不查 catalog——只是字符串到语法节点的转换,连表是否存在都不关心
- 多条用
;分隔的语句会得到多个RawStmt
③ 取快照
analyze_requires_snapshot()决定是否需要 MVCC 快照- DDL 等 utility 通常不需要;大部分 DML/SELECT 需要
GetTransactionSnapshot()在 read committed 下每语句重新拿,可重复读以上是事务首次拿后复用
④ pg_analyze_and_rewrite()
parse_analyze:src/backend/parser/analyze.c- 解析名字(schema 搜索路径)→ 拿到
Oid、列类型 - 类型校验、隐式转换插入
- 子查询、CTE、聚合的语义检查
- 输出
Query节点(仍未带物理执行细节)
- 解析名字(schema 搜索路径)→ 拿到
QueryRewrite:src/backend/rewrite/rewriteHandler.c- 视图替换(视图本质上是规则)
INSERT/UPDATE/DELETE上的 RULE 展开- 一个
Query可能扩展为多个Query(典型如RETURNING的视图)
⑤ pg_plan_queries()
- 入口:
src/backend/optimizer/plan/planner.c::standard_planner - 经典阶段:
subquery_planner
├─ pull_up_sublinks/subqueries 上拉子查询
├─ preprocess_expression 常量折叠
├─ reduce_outer_joins 外连接消去
└─ grouping_planner
└─ query_planner
└─ make_one_rel
├─ set_base_rel_pathlists 生成基表 path
├─ make_rel_from_joinlist 动态规划做 join order
└─ 选最优 path → 生成 Plan 树
- 输出
PlannedStmt,包含一棵Plan树(SeqScan、IndexScan、HashJoin、Sort、Agg等节点) - 标志位
CURSOR_OPT_PARALLEL_OK允许并行查询
⑥ Portal 执行
Portal 是什么? 一个”已绑好计划、可被反复 fetch 的执行上下文”。DECLARE CURSOR 暴露的就是命名 portal;简单查询用一个 "" 匿名 portal。
执行三段:
PortalStart ─► ExecutorStart (executor/execMain.c)
├─ InitPlan:递归 ExecInitNode 建 PlanState 树
└─ 加锁、打开 relation
PortalRun ─► ExecutorRun
└─ ExecutePlan 循环调 ExecProcNode 取 TupleTableSlot
└─ 行 → receiver->receiveSlot()
└─ printtup() 把行编码成 'D' DataRow 写 socket
PortalDrop ─► ExecutorEnd
└─ ExecEndNode 释放各 PlanState、关 relation
火山模型:ExecProcNode 是个 dispatch,按节点类型转到 ExecSeqScan / ExecIndexScan / ExecHashJoin / … 每次返回一行(或一批,PG14 引入了部分向量化)。
DestReceiver:抽象输出端点。常见实现:
DestRemote:写到客户端 socket(printtup.c)DestRemoteExecute:扩展协议下的变种DestSPI:被 PL/pgSQL 内部调用时,把行送入 SPI 缓存DestTuplestore:CTE / 子查询物化DestCopyOut:COPY TO输出
⑦ finish_xact_command()
- 若是隐式事务:
CommitTransactionCommand()→CommitTransaction()- 写 commit WAL 记录(
XLogInsert) - 视
synchronous_commit决定是否等 fsync - 标 CLOG 中此 xid 为 committed
- 释放锁、清理资源
- 写 commit WAL 记录(
- 若在显式
BEGIN块中:只推进 command id,不真提交 - 多条语句之间不提交,仅
CommandCounterIncrement()让本事务后续语句能看到前面的修改
随后 EndCommand() 发送 'C' CommandComplete,主循环顶端再发 'Z' ReadyForQuery,一轮结束。
3. 一图看穿:协议消息时序
以 psql -c "SELECT 1; SELECT 2;" 为例:
Client Backend
│ │
│ StartupMessage │
│ ───────────────────────────────────► │
│ AuthenticationOk + 'K' + GUC... │
│ ◄─────────────────────────────────── │
│ 'Z' ReadyForQuery (I) │
│ ◄─────────────────────────────────── │
│ │
│ 'Q' "SELECT 1; SELECT 2;" │ ─┐
│ ───────────────────────────────────► │ │
│ │ │ exec_simple_query
│ 'T' RowDescription │ │ ┌ pg_parse_query
│ 'D' DataRow (1) │ │ │ pg_analyze_and_rewrite
│ 'C' CommandComplete "SELECT 1" │ │ │ pg_plan_queries
│ 'T' RowDescription │ │ │ PortalRun
│ 'D' DataRow (2) │ │ │ (× 2 parsetree)
│ 'C' CommandComplete "SELECT 1" │ │ └ finish_xact_command
│ 'Z' ReadyForQuery (I) │ ─┘
│ ◄─────────────────────────────────── │
中括号里的
I是事务状态:I=Idle,T=in transaction block,E=failed transaction。客户端用这个判断要不要重置。
4. 关键变量速查表
读 postgres.c 时常见的全局/静态变量:
| 变量 | 含义 |
|---|---|
MyProcPid | 当前 backend 的进程号 |
MyDatabaseId | 当前数据库 OID |
MyProc / MyPgXact | 共享内存中的进程槽指针 |
MessageContext | 每条客户端消息的内存域(每轮重置) |
CurrentMemoryContext | 当前 palloc 默认归属 |
whereToSendOutput | DestRemote / DestNone / DestDebug |
debug_query_string | 当前 SQL 文本,错误日志会带上 |
xact_started | 是否已开过事务命令 |
doing_extended_query_message | 是否在 P/B/E/S 序列中 |
ignore_till_sync | 出错后是否丢弃直到下个 Sync |
unnamed_stmt_psrc | 匿名预备语句的 cached plan |
MaxBackends | 进程数上限(max_connections + 各类辅助进程) |
PG_exception_stack | PG_TRY/PG_CATCH 用的跳转栈 |
查询优化器
流程
exec_simple_query tcop/postgres.c:923
└─► pg_plan_queries(querytrees…) tcop/postgres.c:883
└─► pg_plan_query(query…) tcop/postgres.c:797
└─► planner(parse,…) optimizer/plan/planner.c:264
└─► standard_planner planner.c:277
└─► subquery_planner planner.c:591
└─► grouping_planner
└─► query_planner
└─► make_one_rel path/allpaths.c:153
├─► set_base_rel_sizes ★ 单表基数
│ └─► set_rel_size
│ └─► set_plain_rel_size
│ └─► set_baserel_size_estimates costsize.c:4942
│ └─► clauselist_selectivity ★ 选择率主入口
│ └─► clause_selectivity_ext → eqsel/scalarineqsel/…
│ └─► mcv_selectivity / histogram_selectivity / var_eq_const
│ └─► pg_statistic 直方图 + MCV
├─► set_base_rel_pathlists 生成 SeqScan/IndexScan/...
└─► make_rel_from_joinlist ★ Join 基数 + 顺序枚举
└─► set_joinrel_size_estimates costsize.c:5021
└─► calc_joinrel_size_estimate
├─► get_foreign_key_join_selectivity FK 矫正
└─► clauselist_selectivity (over join clauses)
└─► eqjoinsel(_inner|_semi)
把这条线读熟,做基数估计的工作几乎都落在它的某个分支上。
1. pg_plan_queries:进入优化器的门
src/backend/tcop/postgres.c:883
List *
pg_plan_queries(List *querytrees, const char *query_string,
int cursorOptions, ParamListInfo boundParams)
{
List *stmt_list = NIL;
foreach(query_list, querytrees)
{
Query *query = lfirst_node(Query, query_list);
PlannedStmt *stmt;
if (query->commandType == CMD_UTILITY)
{
/* Utility commands require no planning. */
stmt = makeNode(PlannedStmt);
stmt->commandType = CMD_UTILITY;
...
}
else
{
stmt = pg_plan_query(query, query_string, cursorOptions, boundParams);
}
stmt_list = lappend(stmt_list, stmt);
}
return stmt_list;
}
要点:
- 入参:
querytrees是pg_analyze_and_rewrite的输出,每个元素是一棵Query(已经做完语义分析 + 视图/规则重写)。 - DDL 走另一条路:
CMD_UTILITY直接包装成空PlannedStmt,不进入优化器。基数估计研究通常聚焦CMD_SELECT/INSERT/UPDATE/DELETE。 pg_plan_query(同文件 797 行):薄包装,断言ActiveSnapshotSet()(优化器可能调用用户自定义函数,必须有快照),调用planner(),处理COPY_PARSE_PLAN_TREES/WRITE_READ_PARSE_PLAN_TREES之类的调试副作用。
2. planner / standard_planner:钩子点 + 全局状态
src/backend/optimizer/plan/planner.c:264, 277
PlannedStmt *
planner(Query *parse, const char *query_string,
int cursorOptions, ParamListInfo boundParams)
{
if (planner_hook)
return (*planner_hook)(parse, query_string, cursorOptions, boundParams);
return standard_planner(parse, query_string, cursorOptions, boundParams);
}
基数估计的第一个改造点:
planner_hook
这是替换整个优化器的入口(pg_hint_plan、pg_plan_advisor、AQO 等都从这里切入)。如果你只想换基数估计而不接管整个优化器,这不是合适的钩子——下面会讲到get_relation_stats_hook、get_index_stats_hook、set_baserel_size_estimates_hook之外,PG 在选择度阶段并没有提供单独的”换 cardinality estimator”钩子。常见做法是从planner_hook进入,递归调standard_planner,然后在合适时机修改RelOptInfo->rows。
standard_planner(277 行起)做的关键事:
- **建 **
PlannerGlobal(296 行):跨 subquery 复用的全局状态,挂subplans、subroots、finalrtable、relationOids(用于做 plan 失效判断)等。 - 判定并行可行性(336 行起):
max_parallel_hazard(parse)。基数估计若高估,并行有可能被错误启用。 - **决定 **
tuple_fraction(374 行起):游标优先 →cursor_tuple_fraction;否则 0(要全部行)。这个值会一路传到grouping_planner控制 startup-cost 优化方向。 subquery_planner(403 行):核心入口。- 从
final_rel选最优 path:
final_rel = fetch_upper_rel(root, UPPERREL_FINAL, NULL);
best_path = get_cheapest_fractional_path(final_rel, tuple_fraction);
top_plan = create_plan(root, best_path);
这里”选最优”靠的是每个 path 的 total_cost / startup_cost,而 cost 由 row 估计驱动——基数错则 cost 错,cost 错则选错 plan。
- 收尾:
set_plan_references修指针、JIT 阈值、组装PlannedStmt。
3. subquery_planner → grouping_planner → query_planner
subquery_planner (planner.c:591) 是每个 Query 节点做一次的预处理:
| 阶段 | 函数 | 与基数估计的关系 |
|---|---|---|
| WITH/CTE 处理 | SS_process_ctes | CTE 物化与否影响基数:物化只算一次,inline 重复算 |
pull_up_sublinks | 子链接转 join | 把 EXISTS/IN 转成 SEMI/ANTI join——估计走 eqjoinsel_semi 而不是 SubLink 的默认值 |
preprocess_function_rtes | 函数 RTE inline | 影响后续 join planning |
pull_up_subqueries | 子查询展平 | 让基数能用主查询的统计信息直接算 |
flatten_simple_union_all | UNION ALL 拍扁成 appendrel | appendrel 的 row = ∑ 各子 rel 的 row |
reduce_outer_joins | 外连接弱化 | 改 jointype 直接改基数公式 |
remove_useless_joins | 唯一键 + 外连接消去 | 真正减少 join,不走估计 |
remove_self_joins | 自连接合并 | 同上 |
expand_inherited_tables | 继承/分区展开 | 父表 = 子表 union;分区 prune 后基数可能为 0 |
grouping_planner | 组装 GROUP/HAVING/ORDER/LIMIT | estimate_num_groups(selfuncs.c:3368)算分组基数 |
最终掉到 query_planner → make_one_rel,下一节是它。
4. make_one_rel:基数估计真正登场
src/backend/optimizer/path/allpaths.c:153
RelOptInfo *
make_one_rel(PlannerInfo *root, List *joinlist)
{
/* … 准备 all_baserels … */
set_base_rel_consider_startup(root);
/* ★ 第一步:算每个基表的 rows */
set_base_rel_sizes(root);
/* 算 total_table_pages,给 IO cost 模型用 */
...
/* ★ 第二步:为每个基表生成 access path(含 cost) */
set_base_rel_pathlists(root);
/* ★ 第三步:枚举 join 顺序,算 join 基数 */
rel = make_rel_from_joinlist(root, joinlist);
return rel;
}
这三步对应基数估计的三个阶段:
- 基表基数(rows of base relation):把
clauselist_selectivity应用在baserestrictinfo上,乘以tuples。 - 基表 path 的 cost:
cost_seqscan、cost_index、cost_bitmap_*,每个 path 都依赖前一步的rel->rows。 - join 基数 + join 顺序:动态规划枚举(DPSize/DPCcp)或 GEQO,每枚举一对就调
set_joinrel_size_estimates。
5. set_baserel_size_estimates:单表基数主公式
src/backend/optimizer/path/costsize.c:4942
void
set_baserel_size_estimates(PlannerInfo *root, RelOptInfo *rel)
{
double nrows;
Assert(rel->relid > 0); /* 只用于基表 */
nrows = rel->tuples * /* ★ 基本公式:表行数 × 选择率 */
clauselist_selectivity(root,
rel->baserestrictinfo,
0,
JOIN_INNER,
NULL);
rel->rows = clamp_row_est(nrows); /* 至少 1 行,避免后续除零 */
cost_qual_eval(&rel->baserestrictcost, rel->baserestrictinfo, root);
set_rel_width(root, rel);
}
公式简单到一行:
rel->rows = rel->tuples × selectivity(WHERE 子句)
其中:
rel->tuples来自get_relation_info(plancat.c:114) →estimate_rel_size(plancat.c:999)。clamp_row_est把 0/负数 / 极小值都向上 clamp 到 1。0 行估计会让上层 cost 公式爆炸。baserestrictcost是评估这些 quals 的 CPU 代价,进入 cost 公式但不影响行数估计本身。
基数估计的第二个改造点:
get_relation_info_hook(plancat.c:61)。可以在拿到rel->tuples、rel->pages、rel->indexlist之后做调整——例如注入采样基数、外部学习模型给的总行数估计。
5.1 rel->tuples 哪里来的?
estimate_rel_size(plancat.c:999)核心逻辑(针对普通表):
table_relation_estimate_size(rel, attr_widths, pages, tuples, allvisfrac);
具体走 heapam_handler.c::heapam_estimate_rel_size:
- 读当前真实
RelationGetNumberOfBlocks(rel)(curpages,实时 lseek/stat 出来)。 - 取
pg_class.relpages/reltuples(ANALYZE 时记下的快照)。 - 按页密度外推:
density = relpages > 0 ? reltuples/relpages : 行宽估计,然后tuples = density × curpages。
这套设计的妙处:即使 ANALYZE 很久没跑,只要表”长大了”也能按密度估出新行数。但密度是错的(比如批量 update 后页变稀疏),估计就跟着错。
6. clauselist_selectivity:选择率合成器
src/backend/optimizer/path/clausesel.c:101
Selectivity
clauselist_selectivity(PlannerInfo *root, List *clauses, int varRelid,
JoinType jointype, SpecialJoinInfo *sjinfo)
{
return clauselist_selectivity_ext(root, clauses, varRelid,
jointype, sjinfo, true);
}
真正干活的是 clauselist_selectivity_ext(119 行)。它做四件事:
6.1 单条短路
if (list_length(clauses) == 1)
return clause_selectivity_ext(root, linitial(clauses), …);
6.2 扩展统计(multivariate stats)
rel = find_single_rel_for_clauses(root, clauses);
if (use_extended_stats && rel && rel->rtekind == RTE_RELATION
&& rel->statlist != NIL)
{
s1 = statext_clauselist_selectivity(root, clauses, varRelid,
jointype, sjinfo, rel,
&estimatedclauses, false);
}
走 src/backend/statistics/extended_stats.c。处理 CREATE STATISTICS 创建的 MCV list / 函数依赖 / ndistinct 三类多列统计。被这里”吃掉”的子句不会再走下面的独立性假设。
基数估计的第三个改造点:扩展统计是 PG 跳出”列独立”假设的官方机制。如果你的研究对象是多列相关性,应该重点读
extended_stats.c、mcv.c、dependencies.c。
6.3 剩余子句:独立性 + 范围对子识别
foreach(l, clauses)
{
if (bms_is_member(listidx, estimatedclauses)) continue; /* 已被扩展统计算过 */
s2 = clause_selectivity_ext(root, clause, …); /* 单条选择率 */
/* 识别 < <= > >= 对子,归入 rqlist */
switch (get_oprrest(expr->opno)) {
case F_SCALARLTSEL: case F_SCALARLESEL:
addRangeClause(&rqlist, clause, varonleft, true, s2); break;
case F_SCALARGTSEL: case F_SCALARGESEL:
addRangeClause(&rqlist, clause, varonleft, false, s2); break;
default:
s1 = s1 * s2; /* ★ 默认:独立性假设,相乘 */
}
}
这就是 PG 著名的”列独立性假设”——P(A ∧ B) ≈ P(A) × P(B)。这是绝大多数基数估计错误的根源之一。
6.4 范围合并
x > a AND x < b 这种成对的不等式如果直接相乘会严重低估(两个都 0.3 → 0.09,但其实是 0.3+0.3-1=−0.4 → 一个范围)。所以 PG 单独识别成对不等式:
s2 = rqlist->hibound + rqlist->lobound - 1.0; /* 容斥 */
s2 += nulltestsel(root, IS_NULL, …); /* 修正双重排除 NULL */
if (s2 < -0.01) s2 = DEFAULT_RANGE_INEQ_SEL; /* 0.005 */
else if (s2 <= 0.0) s2 = 1.0e-10; /* 极小但非零 */
s1 *= s2;
DEFAULT_RANGE_INEQ_SEL = 0.005(在 selfuncs.h),意味着”我看到一对范围但凑不出真实值,给你 0.5%“。
7. clause_selectivity_ext → 各类 *sel 函数
src/backend/optimizer/path/clausesel.c:707
逐个节点类型 dispatch:
| 子句类型 | 调用 | 文件 |
|---|---|---|
Var | boolvarsel | selfuncs.c |
OpExpr | 通过 get_oprrest(opno) 拿到 oprrest | pg_operator |
BoolExpr AND/OR/NOT | 递归 + 容斥 | clausesel.c |
NullTest IS NULL | nulltestsel | selfuncs.c |
BooleanTest | booltestsel | selfuncs.c |
RowCompareExpr | rowcomparesel | selfuncs.c |
ScalarArrayOpExpr | scalararraysel | selfuncs.c |
| 其他 | DEFAULT_*_SEL 常量 | selfuncs.h |
oprrest 是注册在 pg_operator 上的函数 OID,例如:
| 操作符 | oprrest |
|---|---|
= | eqsel |
<> | neqsel |
< <= | scalarltsel / scalarlesel |
> >= | scalargtsel / scalargesel |
LIKE | regex_like_support 走 supportfn,否则 likesel |
~ !~ 等正则 | regexeqsel |
这些 *sel 函数全部在 src/backend/utils/adt/selfuncs.c(8000 行,PG 基数估计的”圣经”)。
8. eqsel / var_eq_const:等值估计的标准模板
selfuncs.c:224
Datum
eqsel(PG_FUNCTION_ARGS)
{
PG_RETURN_FLOAT8((float8) eqsel_internal(fcinfo, false));
}
eqsel_internal 解析两个 args,看哪边是 Var,哪边是常量,分发到:
var_eq_const(292 行)—— var = constvar_eq_non_const(458 行)—— var = expr,不能用 MCV 精确匹配
8.1 var_eq_const 的三层 fallback
if (vardata->isunique && vardata->rel->tuples >= 1.0)
selec = 1.0 / vardata->rel->tuples; /* 走唯一索引 */
else if (HeapTupleIsValid(vardata->statsTuple))
{
/* 拿 MCV slot */
if (get_attstatsslot(&sslot, …, STATISTIC_KIND_MCV, …))
{
/* 用对应 = 算子,逐一比对 const 是否落在 MCV 里 */
if (match) selec = sslot.numbers[i]; /* MCV 命中:精确频率 */
else
{
sumcommon = sum(sslot.numbers);
selec = (1 - sumcommon - nullfrac) / (ndistinct - n_mcv); /* 平均分摊 */
if (selec > sslot.numbers[last]) /* 不能比 MCV 末位还高 */
selec = sslot.numbers[last];
}
}
}
else
{
/* 没 ANALYZE:1/ndistinct */
selec = 1.0 / get_variable_numdistinct(vardata, &isdefault);
}
这是 PG 等值估计的核心三段式:
- 唯一性短路:若列有 unique index,等值结果直接 1 行 →
1/tuples。 - MCV 命中:在 most-common-values 里找到 → 用记录的精确频率(
pg_statistic.stanumbersN[i])。 - MCV 未命中:剩余概率均匀分摊给”非 MCV 的不同值”。
- 完全无统计:
1/ndistinct,ndistinct也常常是default_statistics_target决定的近似值。
基数估计研究最高频的”病灶”:第 3 步的均匀分摊。如果剩余分布不均(例如长尾),PG 会严重高估稀有值的频率。
9. scalarineqsel:范围/不等式估计
selfuncs.c:577
selec = mcv_selectivity(vardata, &opproc, …, constval, true, &sumcommon);
hist_selec = ineq_histogram_selectivity(root, vardata, operator, &opproc,
isgt, iseq, …);
selec = 1.0 - stats->stanullfrac - sumcommon; /* 直方图覆盖 = 非 MCV 非 NULL 部分 */
if (hist_selec >= 0.0)
selec *= hist_selec;
else
selec *= 0.5; /* 没直方图,抛硬币 */
selec += mcv_selec; /* MCV 部分单独算后加进来 */
9.1 直方图
pg_statistic 的 stakindN = STATISTIC_KIND_HISTOGRAM (=2),对应 stavaluesN 是等深直方图——default_statistics_target 个分位点(默认 100,最大 10000)把数据切成等频段。
ineq_histogram_selectivity 的逻辑:
- 二分找到
const落在第几个 bin。 - 在 bin 内做线性插值(假设 bin 内均匀分布)。
- 边界 bin 单独处理。
注意:MCV 和直方图互斥 —— ANALYZE 把 top-N 高频值放 MCV,剩下的值才进直方图。所以两者相加才是完整覆盖(再加 NULL 占比)。
9.2 MCV part 的处理
mcv_selectivity(729 行):对 MCV 列表里每个值单独 evaluate 算子(不只是相等!这里复用同一套机制做 < >),把命中的频率加起来。这意味着一个高频值如果在范围内,它的精确占比被原样保留——避免直方图均匀假设带来的误差。
10. eqjoinsel:等值连接基数
selfuncs.c:2237,是 pg_proc 里 = 操作符的 oprjoin(join 选择率函数)。
简化的 inner join 部分(eqjoinsel_inner,2386 行):
/* 双方都拿 MCV */
have_mcvs1 = get_attstatsslot(&sslot1, stats1, STATISTIC_KIND_MCV, …);
have_mcvs2 = get_attstatsslot(&sslot2, stats2, STATISTIC_KIND_MCV, …);
if (have_mcvs1 && have_mcvs2)
{
/* 计算两个 MCV 列表交集匹配的频率,剩余按 ndistinct 估算 */
matchprodfreq = Σ matched_pairs sslot1.numbers[i] * sslot2.numbers[j];
unmatchfreq1 = (1 - sumcommon1) [- nullfrac1];
unmatchfreq2 = (1 - sumcommon2) [- nullfrac2];
selec = matchprodfreq + unmatchfreq1 * unmatchfreq2 / Max(nd1, nd2);
}
else
selec = (1 - nullfrac1)*(1 - nullfrac2) / Max(nd1, nd2);
核心公式(无 MCV 时):
selectivity = (1 - nf1)(1 - nf2) / max(ndistinct1, ndistinct2)
这就是教科书上的**“取大的那个 ndistinct 当分母”**——基于”较大的那一侧每个值都有匹配”的乐观假设。
eqjoinsel_semi(2583 行)专门处理 SEMI/ANTI 的 LHS-only 语义。
10.1 calc_joinrel_size_estimate
costsize.c:5094
/* 先看有没有 FK 可以替代独立性估计 */
fkselec = get_foreign_key_join_selectivity(root, outer->relids, inner->relids,
sjinfo, &restrictlist);
/* 剩余 join clauses 走标准选择率 */
jselec = clauselist_selectivity(root, restrictlist, 0, jointype, sjinfo);
switch (jointype)
{
case JOIN_INNER:
nrows = outer_rows * inner_rows * fkselec * jselec; break;
case JOIN_LEFT:
nrows = max(outer_rows, outer_rows*inner_rows*fkselec*jselec); break;
case JOIN_FULL:
nrows = max(outer_rows, inner_rows, ...); break;
case JOIN_SEMI:
nrows = outer_rows * fkselec * jselec; break;
case JOIN_ANTI:
nrows = outer_rows * (1.0 - fkselec * jselec); break;
}
return clamp_row_est(nrows);
基数估计的第四个改造点:FK 矫正(
get_foreign_key_join_selectivity,5232 行)。它本质上是用”FK 一定有匹配”的真理替换了”独立性 + ndistinct”的估计——你做 ML/学习型 estimator 时,类似的”硬约束”是后处理 clamp 的好范式。
11. examine_variable & VariableStatData
selfuncs.c:4968,所有 *sel 函数访问统计的统一入口。
typedef struct VariableStatData
{
Node *var; /* 解析出的 Var 节点 */
RelOptInfo *rel; /* var 所在 rel */
HeapTuple statsTuple; /* pg_statistic 行;统计可能不存在 */
void (*freefunc)(HeapTuple);
Oid vartype;
Oid atttype;
int32 atttypmod;
bool isunique; /* 命中 unique index? */
bool acl_ok; /* 能否使用统计(安全检查) */
} VariableStatData;
examine_variable 做的事:
- 把
Var顺着 RTE 链找回基表上的 attno。 - 调
get_relation_stats_hook或读pg_statistic。 - 判断是否被 unique 索引覆盖。
基数估计的第五个改造点:
get_relation_stats_hook/get_index_stats_hook(selfuncs.c:144/145)——这两个钩子是注入合成统计的最干净入口。AQO、HypoPG 等扩展都从这里下手。
pg_statistic 的 5 个槽位
每个被 ANALYZE 过的列在 pg_statistic 一行,里面有 stakindN/stanumbersN/stavaluesN(N=1..5),可能装:
STATISTIC_KIND_* | 内容 | 谁用它 |
|---|---|---|
1 MCV | 高频值列表 + 频率 | eqsel、scalarineqsel |
2 HISTOGRAM | 等深直方图分位点 | scalarineqsel、scalarltsel/gtsel |
3 CORRELATION | 物理顺序相关性 | cost_index 估算 IO |
4 MCELEM | 数组/复合类型的 MCV | array_selfuncs |
5 DECHIST | 数组元素直方图 | array_selfuncs |
| 6/7 | range/bounds histogram | rangetypes_selfuncs |
加上行级标量:stanullfrac、stadistinct(负数表示比例)、stawidth。
12. estimate_num_groups:GROUP BY / DISTINCT 基数
selfuncs.c:3368
double
estimate_num_groups(PlannerInfo *root, List *groupExprs, double input_rows,
List **pgset, EstimationInfo *estinfo)
{
/* 对每个 group key 拿 ndistinct */
ndistinct = ∏ get_variable_numdistinct(...);
/* 用 Haas-Stokes 公式:当样本是有放回时,分组数 ≈ N * (1 - (1-1/N)^n) */
numdistinct = (1 - (1 - input_rows/N)^...) * N;
}
要点:
- 同样假设多列独立(直接乘 ndistinct)→ 多列分组严重高估。
- 14 起会借助扩展统计的
ndistinctslot(CREATE STATISTICS … (ndistinct))做修正——见estimate_multivariate_ndistinct(selfuncs.c 内)。 - Haas-Stokes 是”样本中观察 ndistinct 个不同值,估总体 ndistinct”的经典公式,意义是采样修正。
13. estimate_hash_bucket_stats:哈希连接专用
selfuncs.c:3751,给 cost_hashjoin 用的”内表分桶后每桶大小”估计。
- 利用 MCV 频率算桶内最大可能 group size(最坏情况)。
- 直接进入 hash join cost 的 inner-loop 估算。
14. ANALYZE / pg_statistic 是怎么填的
虽然不在 planner 里,但研究基数估计绕不开。
- 入口:
src/backend/commands/analyze.c::do_analyze_rel - 采样:
acquire_sample_rows(默认300 × default_statistics_target行的水库采样) - 每列调
compute_*_stats(一个一个 typanalyze 函数)- 标量:
compute_scalar_stats(distinct 用 Haas-Stokes、MCV 用频次过滤、histogram 用排序后等深) - 数组:
compute_array_stats - 范围类型:
compute_range_stats
- 标量:
- 最终
update_attstats写入pg_statistic。
default_statistics_target
GUC,决定:
- 采样行数 =
300 × target - MCV 槽位数上限 =
target - 直方图分位点数 =
target - per-column 可单独设:
ALTER TABLE … ALTER COLUMN … SET STATISTICS …
做基数估计研究时的对照基线:把 default_statistics_target 拉到 1000 / 10000 看看天花板。
15. 基数估计的”已知误差源”清单
读完上面的代码,PG 基数估计常见的高估/低估病因:
| 病因 | 来源 | 改造方向 |
|---|---|---|
| 列独立性假设 | clauselist_selectivity 第 6.3 节 | 扩展统计 MCV / dependencies |
| 多列分组 ndistinct 相乘 | estimate_num_groups | 扩展统计 ndistinct |
| MCV 外的均匀分摊 | var_eq_const 第 8.1 节第 3 步 | 拉大 statistics_target;自定义采样 |
| 直方图 bin 内均匀 | ineq_histogram_selectivity | 改用更细 bin / 替换为 sketch |
| ndistinct 估计偏差 | compute_scalar_stats(Haas-Stokes) | 大表上误差大;增大采样 |
| join 用 max(ndistinct) | eqjoinsel_inner 第 10 节 | FK 或学习型估计替换 |
| 默认值 fallback | DEFAULT_*_SEL | 出现即说明缺统计;警报 |
| 关联列 join 高估 | 同 join 公式假设独立 | 多 join 多列扩展统计 (PG14+ MCV slot 扩展) |
| 子查询不展平的过保守估计 | 默认 0.005 之类 | 让 planner 尽量 pull-up |
16. 给你做基数估计研究的”切入口”
按”侵入性”由低到高:
get_relation_stats_hook** / **get_index_stats_hook(selfuncs.c:144/145)- 替换
pg_statistic行——返回构造的HeapTuple,所有*sel函数都从这里拿统计 - 适合:合成统计、采样估计、ML 模型预测的统计
- 替换
set_baserel_size_estimates_hook(costsize.c:42)- 单表估完之后改
rel->rows - 适合:在 baseline 估计上做后处理 / clamp
- 单表估完之后改
get_relation_info_hook(plancat.c:61)- 拿到
rel->tuples/rel->pages/ 索引列表后微调 - 适合:注入更准确的全表行数
- 拿到
- **自定义
oprrest/ **oprjoin(pg_operator)- 注册新算子或修改现有算子的选择率函数
- 慎用,全局生效
planner_hook(planner.c:75)- 包装整个 planner,最后递归到
standard_planner - 适合:在 plan 完成后 reset rows / 替换 path / 强制 plan shape
- AQO、pg_hint_plan 走这条
- 包装整个 planner,最后递归到
- **直接改
clauselist_selectivity_ext/ **selfuncs.c- 改源码并重编 PG
- 适合:研究新算法本身(学习型 estimator、sketch、神经网络)
MVCC
为什么需要 MVCC
数据库要回答的核心问题是:“读和写并发发生时怎么办?”
最朴素的方案是加锁——读的时候加读锁,写的时候加写锁,读写互斥。这种方案简单,但在 OLTP 场景下灾难性:一个长查询会把所有写都堵住,反过来也一样。
MVCC(Multi-Version Concurrency Control,多版本并发控制)的思路完全不同:让一行数据同时存在多个版本,每个事务读到的是它该读到的那个版本,从而读写互不阻塞。读不上锁、写不上锁(指读写之间),代价是同一行物理上躺着多个副本,需要后台清理。
PostgreSQL 是 MVCC 的”原教旨主义者”——所有版本都直接堆在主表里(不像 Oracle/MySQL 把旧版本放 undo log 里),删除只是打个标记,更新就是”插一行新的 + 把旧的标记成被删”。这种实现叫 append-only MVCC 或 in-place MVCC with dead tuples。它的好处是回滚极快(只要把事务标成 abort,所有它做过的修改自动失效),坏处是表会”长胖”,需要 VACUUM 持续回收死行。
理解 PG 的 MVCC,关键就是理解三个东西:
- 每行数据怎么打标记(xmin/xmax)
- 每个事务怎么知道哪些版本归它看(snapshot)
- 拿到一行后怎么判断”它对我可见吗”(HeapTupleSatisfiesMVCC)
下面按这个顺序展开。
1. 一行数据上的 MVCC 标记
1.1 为什么每行要有两个 xid
PG 给每个事务分配一个单调递增的 32 位整数,叫 transaction id,简称 xid。任何修改了数据的事务都有一个 xid。只读事务可以不分配 xid(这是 PG 14 的”惰性 xid”优化,省下了大量 xid 分配开销)。
每行物理记录里硬编码两个字段:
t_xmin:插入这一行的事务 xid。“这一行从 xmin 这个事务开始存在”。t_xmax:删除(或正在锁定)这一行的事务 xid。“这一行从 xmax 这个事务开始不再存在”——如果 xmax 还是 0,说明这一行还活着。
为什么这两个字段就够了?因为有了 (xmin, xmax),对于任意事务 T,我们能回答:“这一行在 T 的视角下存在吗?“——只要知道 xmin、xmax 是否对 T 可见就行。可见性判断后面专门讲。
1.2 元组头里到底装了什么
源码定义在 src/include/access/htup_details.h,每个表里的行都带这么一个头:
struct HeapTupleHeaderData
{
union {
HeapTupleFields t_heap; /* 普通行用这个 */
DatumTupleFields t_datum; /* 函数参数等场景用 */
} t_choice;
ItemPointerData t_ctid; /* 当前行自己的 TID,
更新链中指向新版本 */
uint16 t_infomask2;
uint16 t_infomask; /* xmin/xmax 的状态位 */
uint8 t_hoff; /* 头部长度 */
bits8 t_bits[]; /* NULL bitmap */
};
struct HeapTupleFields
{
TransactionId t_xmin; /* 插入它的事务 xid */
TransactionId t_xmax; /* 删除/锁它的事务 xid */
union {
CommandId t_cid; /* 同一事务内是第几条命令 */
TransactionId t_xvac; /* 老版 VACUUM FULL 用,已废弃 */
} t_field3;
};
这里有一个容易被忽略但极重要的字段:t_ctid。它是这一行自己的 (block, offset),即”我在哪一页第几个槽”。当这一行被 update 时,PG 会把 t_ctid 改成新版本所在的位置——这样旧版本就形成了一条链,可以顺藤摸瓜找到当前 live 版本。这是后面讲 update 链和 HOT 的基础。
t_cid 装的是命令号——同一事务里第 0 条命令、第 1 条命令……这玩意是给”同一事务内、不同语句之间”做可见性判断用的。比如一个 PL/pgSQL 函数里先 INSERT 再 SELECT,需要 cid 来回答”我自己刚插的行,本条 SELECT 该不该看到”。
1.3 t_infomask 的状态位
xmin 和 xmax 装着 xid,但只看 xid 是不够的——还得知道这个 xid 是不是已经提交。最朴素的做法是每次都查 pg_xact(事务状态表,后面讲),但那太慢了。PG 的优化是把 xid 的状态缓存到元组头的 flag 位上,叫 hint bits:
#define HEAP_XMIN_COMMITTED 0x0100 /* xmin 已经提交(hint) */
#define HEAP_XMIN_INVALID 0x0200 /* xmin 已经 abort(hint) */
#define HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID)
/* "永远可见",VACUUM 才设置 */
#define HEAP_XMAX_COMMITTED 0x0400
#define HEAP_XMAX_INVALID 0x0800
#define HEAP_XMAX_IS_MULTI 0x1000 /* xmax 是 MultiXactId(多个 locker 共享) */
#define HEAP_XMAX_LOCK_ONLY 0x0080 /* xmax 只是把行锁住了,没真删 */
#define HEAP_HOT_UPDATED 0x4000 /* 这一版被 HOT 更新替换了 */
#define HEAP_ONLY_TUPLE 0x8000 /* HOT 链中的非主元组 */
#define HEAP_COMBOCID 0x0020 /* t_cid 是合成的 combo cid */
工作机制是这样的:
- 第一次有人查这一行的可见性时,发现
xmin没带 hint bit,于是去查pg_xact拿到状态(比如 committed)。 - 拿到状态后,顺手把
HEAP_XMIN_COMMITTED位写进元组头。 - 后续任何人查这一行时,看到 hint bit 就跳过
pg_xact查询,直接走快路径。
这是 PG 性能的隐藏关键。但它也有副作用:修改 hint bit 让页变脏,下次 checkpoint 要刷盘——这就是为什么”刚插完一批数据后立刻 SELECT 一遍会触发大量写 IO”,hint bit 在落盘。
HEAP_XMIN_FROZEN 是个特殊状态:它把 COMMITTED 和 INVALID 两位同时打开(正常情况是互斥的)。VACUUM 用这个组合表示”这一行的 xmin 已经老到不需要再判断了,永远可见”——为了防止 xid 回卷(后面讲)。
2. 一行数据的生命周期
2.1 INSERT:写一行新数据
BEGIN; -- T1 开始,分配 xid = 100
INSERT INTO t VALUES (1, 'foo');
COMMIT;
物理上发生了什么:
- PG 找到 t 表的某个有空间的页(freespace map)。
- 在那一页的某个槽里写下一条新记录:
t_xmin = 100 ← 我(T1)插的
t_xmax = 0 ← 还没人删
t_cid = 0 ← 这是 T1 的第 0 条命令
t_ctid = (本页, 本槽)
t_infomask: 暂时啥 hint 都没有
- T1 提交,
pg_xact[100] = COMMITTED。
注意提交那一步没有去改这一行的 hint bit。要等下次有人读到这一行、触发可见性判断时,才会把 HEAP_XMIN_COMMITTED 写上去。
源码:heap_insert 在 src/backend/access/heap/heapam.c。
2.2 DELETE:只是打标记,不删数据
BEGIN; -- T2 开始,xid = 200
DELETE FROM t WHERE id = 1;
COMMIT;
物理上发生了什么:
- T2 找到要删的那一行(前面 T1 插入的)。
- **不删,只改它的 **
t_xmax:
t_xmin = 100 ← 不动
t_xmax = 200 ← T2 把这里改成自己的 xid
t_cid = 0 ← 这次是 cid 表 T2 删除的命令号
- T2 提交。
那一行的字节并没有从磁盘上消失——它只是”被打了死亡标记”。任何快照如果还能”看见 T2 还没提交”,就还能读到这一行(因为它的 xmin 是已提交的 T1,xmax 是未提交的 T2)。
只有当所有还可能用到这一行的事务都结束之后,VACUUM 才会真的回收这一行的空间。
源码:heap_delete。
2.3 UPDATE:等于一次 DELETE + INSERT
UPDATE t SET name = 'bar' WHERE id = 1;
物理上发生了什么(假设当前事务 xid = 300):
- 找到旧行,把它的
t_xmax改成 300,t_ctid改成新行的位置。 - 在某页(也许同页也许别页)写一条新行:xmin=300、xmax=0、ctid 指向自己。
关键点是两个版本同时存在于堆里:
旧版: { xmin=100, xmax=300, ctid → 新版 }
新版: { xmin=300, xmax=0, ctid → 自己 }
不同事务看到不同版本:
- 还没看见 T300 提交的事务,看到旧版(xmax=300 在它眼里没生效)。
- 看见 T300 提交的事务,沿着 ctid 链跳到新版。
这套机制让我们可以一边有人改、一边有人读,互不阻塞。代价是写多读少的表会持续膨胀。
源码:heap_update。
2.4 SELECT FOR UPDATE:行级锁
SELECT … FOR UPDATE 不修改数据,但要”占住”这一行不让别人改。PG 的做法是:
- 把
t_xmax设成当前事务 xid,但同时设上HEAP_XMAX_LOCK_ONLY位——表明”这只是个锁,不是真的删除”。 - 如果同一行有多个并发的 locker,xmax 装不下那么多 xid,就用 MultiXactId 编一个 id 进去,配合
HEAP_XMAX_IS_MULTI位。真正的成员 xid 列表存在pg_multixact/目录下。
这是 PG 把行锁信息塞进元组头的巧妙做法——不需要单独的锁表来记录每行级锁。
3. 快照:每个事务的”宇宙观”
到此为止,我们已经知道每行数据上有 (xmin, xmax)。但单看一行不足以判断可见性——还要知道 xmin、xmax 这两个 xid 在”我这个事务”看来是什么状态。
考虑一个具体场景:一个 SELECT 扫到了一行,xmin=500、xmax=0。这一行可见吗?
- 如果在 SELECT 开始那一刻,T500 已经提交了:可见。
- 如果在 SELECT 开始那一刻,T500 还没提交:不可见——即使 SELECT 进行过程中 T500 提交了,对这条 SELECT 也应该当作不可见。
这就是 snapshot(快照) 要解决的问题:冻结一个时间点,把”那个时间点上哪些事务还在进行中”记下来,整条 SELECT 都按这个清单来判断。
3.1 SnapshotData 结构
定义在 src/include/utils/snapshot.h:142:
typedef struct SnapshotData
{
SnapshotType snapshot_type;
TransactionId xmin; /* 边界一:所有 xid < xmin 的事务都已经完成(提交或 abort)*/
TransactionId xmax; /* 边界二:所有 xid >= xmax 的事务都还在未来(不可见)*/
TransactionId *xip; /* 灰色地带:xmin <= xid < xmax 但仍在进行中的 xid */
uint32 xcnt; /* xip[] 长度 */
TransactionId *subxip; /* 进行中的子事务 xid */
int32 subxcnt;
bool suboverflowed;
CommandId curcid; /* 当前事务内:cid < curcid 的命令对自己可见 */
...
} SnapshotData;
理解快照的本质就是这三个字段:xmin、xmax、xip[]。
可以这样想象——快照把所有 xid 的世界划成三个区域:
xmin xmax
完成了的事务区 | 灰色地带(一部分还在跑) | 还没出生的未来
─────────────────────────|─────────────────────────────|─────────────────► xid
↑ 这区里的 xid 状态 ↑ 这区里有些跑完了,有些没 ↑ 这区里的全部
由 CLOG 决定 快照用 xip[] 列出"没跑完的" 都看不见
看 committed?可见
看 aborted? 不可见
判断”某个 xid 在快照看来还在进行”的逻辑(XidInMVCCSnapshot,utils/time/snapmgr.c:2259):
if xid < snapshot.xmin: → 已完成(不在进行),具体是 commit 还是 abort 看 CLOG
if xid >= snapshot.xmax: → 在进行中(在快照之后才出现的事务,对快照不可见)
if xid in snapshot.xip[]: → 在进行中
否则: → 已完成
这种”两个边界 + 中间的进行中清单”的设计极致省内存:你不需要列出所有提交了的事务,只列正在进行的就够了,因为正在进行的通常就几十个。
3.2 GetSnapshotData:拍这张快照的过程
源码 src/backend/storage/ipc/procarray.c:2251。简化逻辑:
Snapshot GetSnapshotData(Snapshot snapshot)
{
LWLockAcquire(ProcArrayLock, LW_SHARED);
/* 1. xmax = 现在为止已经"完成"的最大 xid + 1
* 意思就是"凡是大于等于这个的都还没出生" */
xmax = ShmemVariableCache->latestCompletedXid;
TransactionIdAdvance(xmax);
/* 2. 扫描 ProcArray(共享内存里所有 backend 的状态表)
* 把每个还在进行的事务的 xid 收集起来 */
for each PGPROC in procArray:
if PGPROC has active xid:
xip[xcnt++] = PGPROC->xid
/* 3. xmin = xip[] 里最小的,也就是"最早还在跑的事务" */
xmin = min(xip[])
LWLockRelease(ProcArrayLock);
snapshot->xmin = xmin
snapshot->xmax = xmax
snapshot->xip = xip
snapshot->xcnt = xcnt
return snapshot
}
为什么 ProcArrayLock 是个名场面:
每个 backend 都把自己的当前 xid 登记在 ProcArray 这个共享内存数组里。GetSnapshotData 要扫整个数组——上千连接、高 TPS 时,这把锁就成了瓶颈。每个事务开头都要拿一次这把锁、扫一遍 ProcArray,结果几百个 backend 同时挤在这里。
PG 历史上花了很多力气优化这块:
- PG 9.2 给 PGPROC 拆出一个紧凑的 PGXACT 结构,让扫数组更 cache-friendly。
- PG 14 引入
snapXactCompletionCount:如果自上次拿快照以来没事务完成过,就直接复用之前的快照,跳过扫描(GetSnapshotDataReuse,procarray.c:2169)。
这些都不是简单优化,是真材实料的扩展性瓶颈。
3.3 GetTransactionSnapshot:何时拍快照?
源码 utils/time/snapmgr.c:250。这个函数把 PG 三种隔离级别的差异完全说清楚了,慢慢读:
Snapshot GetTransactionSnapshot(void)
{
/* 第一次在事务里拿快照? */
if (!FirstSnapshotSet)
{
if (IsolationUsesXactSnapshot())
{
/* 可重复读 / 可序列化:拿一次就保存起来,
* 整个事务都用它 */
CurrentSnapshot = GetSnapshotData(...);
CurrentSnapshot = CopySnapshot(CurrentSnapshot);
FirstXactSnapshot = CurrentSnapshot;
}
else
/* 读已提交:拿一次但不保存 */
CurrentSnapshot = GetSnapshotData(...);
FirstSnapshotSet = true;
return CurrentSnapshot;
}
if (IsolationUsesXactSnapshot())
/* RR/SI:直接返回事务首次拍的那张 */
return CurrentSnapshot;
/* RC:每次重新拍一张新的 */
return GetSnapshotData(CurrentSnapshot);
}
翻译成人话:
| 隔离级别 | 一个事务里要拍几次快照 | 行为 |
|---|---|---|
| READ COMMITTED(默认) | 每条 SQL 一次 | 同一事务里,前一句 SELECT 看不到的行,后一句可能看到——因为后一句重新拍了快照 |
| REPEATABLE READ | 整个事务只拍一次 | 同一事务里两次 SELECT 必然结果一致 |
| SERIALIZABLE | 同 RR + 谓词锁追踪 | 在 RR 基础上,加上一套基于 SSI 算法的冲突检测 |
PG 没有 READ UNCOMMITTED——它在 PG 里等价于 READ COMMITTED。
4. 核心:HeapTupleSatisfiesMVCC
到此为止我们有了元组上的 xmin/xmax和快照。组合起来回答的问题就是:
拿到一行数据,给定一个快照,这一行对快照可见吗?
源码:src/backend/access/heap/heapam_visibility.c:959,是 PG 最被频繁调用的函数之一——每次 SELECT 扫到的每一行都要走一遍。
整个判断分两步:
Step A:xmin 这边过关了吗?(“我能看到这次插入吗”)
Step B:xmax 这边删除生效了吗?(“我能看到这次删除吗”)
只有 Step A 通过、Step B 不通过(即”看见了创建,没看见删除”),这一行才可见。
4.1 Step A:xmin 是否对我可见
xmin 写在元组头上,但它代表的事务可能:
- 已提交(最常见情况)
- 已 abort(这一行其实从来没真正存在过)
- 还在进行中(包括”是我自己当前事务”)
- 在我快照拍的时候还没提交,但现在提交了——对快照来说仍然不可见
源码(简化版,标了行号):
if (!HeapTupleHeaderXminCommitted(tuple)) /* hint bit 没说 xmin commit */
{
if (HeapTupleHeaderXminInvalid(tuple))
return false; /* abort 过的插入,永远不可见 */
/* 是不是我自己插的? */
else if (TransactionIdIsCurrentTransactionId(xmin))
{
if (cmin >= snapshot->curcid)
return false; /* 我自己刚插的,但本命令开始之后才插的 */
/* 否则进入 Step B */
}
/* 别的事务插的 */
else if (XidInMVCCSnapshot(xmin, snapshot))
return false; /* 在我快照里它还在进行 */
else if (TransactionIdDidCommit(xmin)) /* 查 CLOG */
{
SetHintBits(tuple, HEAP_XMIN_COMMITTED, ...);
/* 提交了,进入 Step B */
}
else
{
SetHintBits(tuple, HEAP_XMIN_INVALID, ...);
return false; /* abort 或 crash 了 */
}
}
else /* hint bit 说 xmin 已 commit */
{
if (!HeapTupleHeaderXminFrozen(tuple) &&
XidInMVCCSnapshot(xmin, snapshot))
return false; /* 虽然提交了,但快照拍的时候还没提交 */
}
/* 至此:xmin 那边过关了 */
注意最后一个分支非常微妙:xmin 已经 commit(hint bit 都设了),但仍然要查一下它在不在我的快照里。为什么?因为我的快照可能是在 xmin 提交之前拍的。即使现在它 commit 了,对这个快照来说它仍然不可见——这就是”快照的时间冻结”含义。
4.2 Step B:xmax 是否对我已生效
逻辑是 Step A 的镜像,但结果反过来:
- xmax 没设 / abort 了 → 这一行还活着,可见
- xmax 是 lock-only → 没真删,可见
- xmax 已提交且对我可见 → 这一行被删了,不可见
- xmax 还在进行中(在我快照里)→ 删除还没生效,可见
源码骨架:
if (tuple->t_infomask & HEAP_XMAX_INVALID)
return true; /* xmax abort 或没设 */
if (HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask))
return true; /* 只是锁,不是删 */
if (xmax 是我自己) {
if (cmax >= curcid) return true; /* 本命令之后才删 */
else return false;
}
if (XidInMVCCSnapshot(xmax, snapshot))
return true; /* 删除事务在我快照里没结束 */
if (TransactionIdDidCommit(xmax))
return false; /* 删除已生效,行不可见 */
return true; /* 删除事务 abort 了,行还活着 */
4.3 一些反直觉的点
(1) 我自己刚插的行,本命令也未必看见。
比如 INSERT INTO t SELECT * FROM t —— 内层 SELECT 不能看到外层 INSERT 正在产生的新行,否则会无限循环。这就是 cmin >= curcid 那个分支干的事。
(2) hint bit 不立刻写 WAL。
SetHintBits 修改的是页内容,但默认情况下不会立刻产生 WAL 记录(除非开了 wal_log_hints 或 page checksums)。这是 PG 的一个性能权衡——hint bit 丢了无所谓,下次重新查 CLOG 就能算出来。但代价是页变脏的频率高于你的直觉,“读密集”的工作负载也可能产生大量脏页 IO。
(3) 查 CLOG 不便宜,所以 hint bit 是性能命脉。
没有 hint bit 时,每次可见性判断都要 TransactionIdDidCommit(access/transam/transam.c:125),最终查 pg_xact(事务状态表)。CLOG 本身是 SLRU 缓存,命中率通常高,但放在 SELECT 主循环里仍然不能忽略。
5. 隔离级别背后的实现
5.1 READ COMMITTED:每语句一张快照
最直观,每条 SQL 进入执行前 GetTransactionSnapshot 重新拍一张。已提交的修改立刻能看到。
但 RC 有一个特别的麻烦:UPDATE 撞上别人的 UPDATE 怎么办?
考虑:
T1: BEGIN; UPDATE t SET v=v+1 WHERE id=1; -- T1 没提交
T2: BEGIN; UPDATE t SET v=v*2 WHERE id=1; -- 等 T1
T1: COMMIT;
T2 拿到行的时候发现 t_xmax = T1 的 xid,意味着这一行被 T1 改了。等 T1 提交后,T2 应该看到 T1 的版本(v 已经 +1)。但 T2 自己的 SELECT 部分(WHERE id=1)是基于它自己的快照评估的——快照里 T1 还没提交,所以 WHERE 评估的是旧版的行。
PG 在 RC 下走一套叫 EvalPlanQual 的逻辑(executor/execMain.c::EvalPlanQual):拿到 T1 的最新版本,重新评估 WHERE 子句。如果新版本仍然满足 WHERE,就在新版本上 apply 自己的 UPDATE;不满足就跳过。
这就是为什么 PG 在 RC 下的 update 不会”用陈旧的 WHERE 评估写入数据”,但代价是lost update:T1 把 v 从 0 改成 1,T2 把 v 从 1 改成 2,T1 的修改被 T2 覆盖了。RC 不防 lost update。
5.2 REPEATABLE READ:全事务一张快照
事务首次 GetTransactionSnapshot 拍一张就保存到 FirstXactSnapshot,后续整个事务复用。所以:
- 同事务里两次 SELECT 必然结果一致(重复读得到一样的行)。
- 撞上别人 UPDATE 的同一行时,不再 EvalPlanQual,而是直接报错:
could not serialize access due to concurrent update。客户端可以选择重试。
这种”读到不一致就报错”的策略,比 RC 强得多,但远不是真的可序列化——它防不住 写偏序(write skew)。例子:
T1: SELECT count(*) FROM doctor WHERE on_call=true; -- 结果 2
T2: SELECT count(*) FROM doctor WHERE on_call=true; -- 结果 2
T1: UPDATE doctor SET on_call=false WHERE id=1; -- 把自己设为不在岗
T2: UPDATE doctor SET on_call=false WHERE id=2; -- 把自己设为不在岗
T1: COMMIT;
T2: COMMIT;
-- 两个事务都基于"还有 2 个医生在岗"做了决策,
-- 提交后变成 0 个在岗,违反了"至少要有 1 个在岗"的业务约束。
RR 不会发现这种冲突——两个事务读的是不同行(id=1 vs id=2),写的也是不同行,PG 看不出业务语义上它们冲突了。
5.3 SERIALIZABLE:SSI
PG 9.1 引入了基于 SSI(Serializable Snapshot Isolation,Cahill 2008 的论文)的可序列化实现。简单说:
- 在 RR 的基础上,追踪每个事务读了什么、写了什么。
- 检测一种叫 rw-antidependency 的模式(一个事务读了 X,另一个事务写了 X,且后者先提交)。
- 当多个 rw-antidependency 形成”危险结构”时,abort 其中一个事务。
代码在 storage/lmgr/predicate.c,是 PG 里最复杂的子系统之一。代价是性能开销,好处是真正的可序列化保证。
6. update 链与 HOT
6.1 update 链的物理形态
回到 update:旧版的 t_ctid 指向新版的位置。如果一行被 update 多次,就形成一条链:
v1 [xmin=100, xmax=200, ctid→v2]
↓
v2 [xmin=200, xmax=300, ctid→v3]
↓
v3 [xmin=300, xmax=0, ctid→自己] ← 当前 live 版本
不同事务沿着链找到自己该看的版本。索引项最初指向 v1。
6.2 普通 update 的写放大
问题:v2、v3 不一定在同一页,往往要分配新页。关键代价是索引——只要表上有索引(即便 update 没改索引列),每个新版本都要插一条新索引项。
这就是 update 在 PG 里”贵”的根源——单次 update 可能产生 N 个索引写(N 是表上的索引数)。
6.3 HOT:Heap-Only Tuple
PG 8.3 引入的优化(详见 src/backend/access/heap/README.HOT)。当满足两个条件时:
- 被改的列没有任何索引
- 新版本能放进同一页
PG 走一条捷径:
- 旧版打上
HEAP_HOT_UPDATED位。 - 新版打上
HEAP_ONLY_TUPLE位。 - 索引不变——索引项还指向旧版。
- 索引扫到旧版后,发现
HEAP_HOT_UPDATED,沿着t_ctid在页内找到 live 版本。
索引 ──► v1 (HEAP_HOT_UPDATED, ctid→v2)
↓ 在同一页
v2 (HEAP_ONLY_TUPLE) ← live
效果:update 不再触发索引写。这对”更新非索引列”的工作负载(比如 last_login_time、计数器)是巨大优化。
代价:HOT 链不能跨页。一旦同页装不下了就退化成普通 update。所以 PG 表的填充因子(fillfactor)默认 100,高频 update 的表往往调到 70 或 80,给 HOT update 留同页空间。
6.4 HOT prune:顺手清理
HOT 链长了也是问题——每次索引扫描都要顺链找。pruneheap.c::heap_page_prune_opt 在 SELECT 路径上做”机会式”修剪:
- 读到一页时,发现 HOT 链头已经 dead,就把链头的 line pointer 直接指向 live 版本(“redirect”),跳过中间死的版本。
- 中间死的版本物理位置可以被新行重用。
这是 PG 里一个”读路径上做写优化”的精巧设计——SELECT 不只是读,还顺带整理。
7. VACUUM:清死人
死元组不会自己消失,必须靠 VACUUM。问题是:什么时候一个死元组真的可以删?
答案:当不存在任何活跃事务还可能读到它的旧版本时。
7.1 OldestXmin 水位线
GetOldestNonRemovableTransactionId(procarray.c)扫描所有活跃事务,取它们 xmin 的最小值(再加上一些 slot/replication 的 xmin),叫做 OldestXmin。
含义:没有任何活跃快照的 xmin 比这个值还小。所以——
任何 xmax < OldestXmin 且 xmax 已 commit 的元组,没人会再读到它了,可以删。
这是 VACUUM 删元组的安全条件。
7.2 VACUUM 在干什么
vacuumlazy.c::lazy_scan_heap 是入口。流程:
- 对每页先做 HOT prune(清理 HOT 链)。
- 用
HeapTupleSatisfiesVacuum给每个元组打标签:DEAD / LIVE / RECENTLY_DEAD / IN_PROGRESS。 - DEAD 元组的 line pointer 标
LP_DEAD。 - 把指向这些死行的索引项也清掉(
lazy_vacuum_indexes)。 - 真正释放 line pointer,把空间归还到 freespace map。
- 必要时 truncate 文件尾部空块。
注意:普通 VACUUM 不归还磁盘空间给操作系统——只是把空间放进 freespace map,让后续插入复用。要真归还磁盘只能 VACUUM FULL,它会重写整个表(非常昂贵,会持锁)。
7.3 Freezing:抗 xid 回卷
xid 是 32 位,约 40 亿个就会回卷。PG 用”对比距离”(环形比较)判 xid 大小,前提是任何两个 xid 距离不超过 ~20 亿。否则可见性判断会反转——一个明显在过去的事务在比较时变成”未来”,灾难性后果。
解决:把老 xid frozen。vacuum_freeze_min_age 默认 5000 万,autovacuum 在 autovacuum_freeze_max_age(2 亿)触发强制 freeze。Freeze 的实质是给元组的 xmin 设 HEAP_XMIN_FROZEN 位(注意是 COMMITTED+INVALID 同时为 1,正常情况互斥的组合用作”永远可见”的标记)。
被 frozen 的元组:可见性判断直接返回 true,不再看 xmin 的具体值。原 xmin 还在元组上(PG 9.4 之后),用于审计但不参与判断。
运维痛点:长事务会卡住 OldestXmin → VACUUM 无法清理 → 表持续膨胀 → 接近 wraparound → autovacuum 紧急启动 → 负载尖峰。监控
pg_stat_activity.backend_xmin是 PG DBA 的日常。
8. CLOG:xid 状态总账本
前面多次提到 pg_xact 和 CLOG。它们是同一个东西——CLOG 是代码里的名字,pg_xact 是磁盘目录的名字(PG 10 之前叫 pg_clog)。
每个 xid 在 CLOG 里占 2 bit:
| 值 | 含义 |
|---|---|
00 | TRANSACTION_STATUS_IN_PROGRESS |
01 | TRANSACTION_STATUS_COMMITTED |
10 | TRANSACTION_STATUS_ABORTED |
11 | TRANSACTION_STATUS_SUB_COMMITTED |
存储:8 KB 一页 → 一页装 32K 个 xid 的状态 → SLRU 缓存(默认 128KB,可调)→ LRU 替换。
可见性查询的性能层次:
最快:元组 hint bit 命中 (无 IO,无锁)
其次:CLOG 缓存命中 (查内存,小锁)
最慢:CLOG miss → 读 pg_xact 文件 (真磁盘 IO)
这就是为什么 hint bit 是性能命脉——它消除了后两步。
PG 还有几个相关的 SLRU:
pg_subtrans/:子事务 → 父事务 xid 映射。判断子事务可见性时要追溯到顶层。pg_multixact/:MultiXactId 解码到成员 xid 列表,用于行级锁。pg_commit_ts/:提交时间戳,用于逻辑解码和track_commit_timestamp。
9. 经典场景串讲
9.1 一行的完整生命周期
T100 INSERT
→ 写下 tup{xmin=100, xmax=0}
→ T100 commit,pg_xact[100] = COMMITTED
→ 元组上的 hint bit 暂时空着
T150 SELECT 扫到这行
→ Step A:xmin=100,没有 hint bit,查 CLOG 发现 commit 了
→ 设上 HEAP_XMIN_COMMITTED
→ 100 在 T150 的快照里吗?不在 → 可见
→ Step B:xmax=0 → 可见
→ 返回这一行
T200 UPDATE 这行
→ 旧 tup: xmax=200, t_ctid → 新 tup
→ 新 tup: xmin=200, xmax=0
→ T200 commit,pg_xact[200] = COMMITTED
T250 SELECT
→ 扫到旧 tup:
Step A:xmin=100 commit 且 < snap.xmin → 可见
Step B:xmax=200 commit 且 < snap.xmin → 不可见
→ 跳过这一版
→ 沿 ctid 到新 tup:
Step A:xmin=200 commit 且 < snap.xmin → 可见
Step B:xmax=0 → 可见
→ 返回新 tup
VACUUM 时刻(OldestXmin=300)
→ 旧 tup:xmax=200 < 300 且 commit → DEAD → 物理回收
→ 索引项被清扫,line pointer 释放
整个过程中没有任何读锁——T150、T250 都是无锁扫描。
9.2 长事务的”链式反应”
T_long: BEGIN; SELECT ...; -- 24 小时不提交,xid=1000
T1001 ... T9999: 持续做 INSERT/UPDATE/DELETE,commit。
后果:
OldestXmin一直被 T_long 的 xmin 卡在 1000。- 期间所有 update/delete 产生的死元组,xmax 都 ≥ 1001。
- VACUUM 看到这些死元组,但
xmax >= OldestXmin,不能删——它们对 T_long 的快照可能仍然可见。 - 表持续膨胀,cost 估计变形。
- 接近 wraparound 时(约 2 亿个 xid 之后),autovacuum 紧急启动,强制工作。
- 负载尖峰,可能触发其他问题(连接池打满、checkpoint 拖慢等)。
监控:
pg_stat_activity.backend_xmin:哪个 backend 卡住了水位线pg_stat_user_tables.n_dead_tup:每张表死元组数pg_stat_database.datfrozenxid:距离 wraparound 还有多少 xid
PG 14 甚至引入了 idle_in_transaction_session_timeout,专门防”开了事务忘了 commit”。