PostgreSQL 源码

发布于 2026-06-02

目录

源码下载链接: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/命令行工具:psqlinitdbpg_dumppg_ctlpg_basebackuppgbench
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)

对应的源码模块:

关键入口

  1. src/backend/main/main.c::main() — 任何 postgres 进程都从这里起步
  2. src/backend/postmaster/postmaster.c::PostmasterMain() — 主监听进程,接受连接后 fork backend
  3. src/backend/tcop/postgres.c::PostgresMain() — 单个 backend 的主循环(解析→规划→执行)
  4. src/backend/bootstrap/bootstrap.cinitdb 阶段使用的 bootstrap 模式

进程模型

PostgreSQL 是多进程架构(不是多线程):postmaster 监听 → 每个客户端 fork 一个 backend 进程 → 配合一组辅助进程(checkpointer、bgwriter、walwriter、autovacuum、archiver、stats collector、logical replication launcher 等)共享一块共享内存。

contrib/ 中值得注意的

阅读建议

如果想深入理解,按重要性顺序推荐:

  1. tcop/postgres.c 中的 PostgresMainexec_simple_query —— 抓住”一条 SQL 是如何执行的”主线
  2. postmaster/postmaster.c —— 进程模型
  3. storage/buffer/bufmgr.c —— 缓冲管理
  4. access/transam/xlog.c —— WAL 与崩溃恢复
  5. optimizer/plan/planner.cexecutor/execMain.c —— 优化与执行
  6. 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 …)
/*
 * 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 —— 一次性初始化

按顺序做了这些事(行号近似):

行号动作说明
4023InitStandaloneProcessstandalone 模式下的伪环境(postgres --single
4026SetProcessingMode(InitProcessing)标识当前处于初始化阶段,部分功能受限
4032InitializeGUCOptionsGUC(全局参数)初值
4037process_postgres_switches解析 -D-c 等命令行
4076–4110pqsignal(...)安装信号处理器:SIGINT→StatementCancelSIGTERM→dieSIGQUIT→quickdie
4137BaseInit共享内存附着、buffer manager 等基础初始化
4149InitProcess在共享内存里分配 PGPROC 槽位(这就是连接数硬上限的来源)
4162InitPostgres真正”打开数据库”:身份认证后续、catalog 装载、relcache、syscache
4177SetProcessingMode(NormalProcessing)切到正常模式,可以执行用户 SQL
4183BeginReportingGUCOptions把 GUC 报给客户端(用于驱动行为)
4202process_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 异常处理的”最外层”。理解几个要点:

  1. 不是 try/catch:这里用 sigsetjmp 而非 PG_TRY,注释明确说原因——PG_TRY 在 catch 段没有更外层的 handler,这里是栈底,必须永远在位。
  2. 任何 ereport(ERROR) 落在这里:经 siglongjmp 跳转,AbortCurrentTransaction 回滚事务,但进程不死,下一轮循环继续读消息。
  3. 这是 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_parse_query()

③ 取快照

pg_analyze_and_rewrite()

pg_plan_queries()

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 树

⑥ 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:抽象输出端点。常见实现:

finish_xact_command()

随后 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 默认归属
whereToSendOutputDestRemote / 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_stackPG_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;
}

要点:

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_hookget_index_stats_hookset_baserel_size_estimates_hook 之外,PG 在选择度阶段并没有提供单独的”换 cardinality estimator”钩子。常见做法是从 planner_hook 进入,递归调 standard_planner,然后在合适时机修改 RelOptInfo->rows

standard_planner(277 行起)做的关键事:

  1. **建 **PlannerGlobal(296 行):跨 subquery 复用的全局状态,挂 subplanssubrootsfinalrtablerelationOids(用于做 plan 失效判断)等。
  2. 判定并行可行性(336 行起):max_parallel_hazard(parse)。基数估计若高估,并行有可能被错误启用。
  3. **决定 **tuple_fraction(374 行起):游标优先 → cursor_tuple_fraction;否则 0(要全部行)。这个值会一路传到 grouping_planner 控制 startup-cost 优化方向。
  4. subquery_planner(403 行):核心入口。
  5. 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。

  1. 收尾:set_plan_references 修指针、JIT 阈值、组装 PlannedStmt

3. subquery_planner → grouping_planner → query_planner

subquery_planner (planner.c:591) 是每个 Query 节点做一次的预处理:

阶段函数与基数估计的关系
WITH/CTE 处理SS_process_ctesCTE 物化与否影响基数:物化只算一次,inline 重复算
pull_up_sublinks子链接转 joinEXISTS/IN 转成 SEMI/ANTI join——估计走 eqjoinsel_semi 而不是 SubLink 的默认值
preprocess_function_rtes函数 RTE inline影响后续 join planning
pull_up_subqueries子查询展平让基数能用主查询的统计信息直接算
flatten_simple_union_allUNION ALL 拍扁成 appendrelappendrel 的 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/LIMITestimate_num_groups(selfuncs.c:3368)算分组基数

最终掉到 query_plannermake_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;
}

这三步对应基数估计的三个阶段:

  1. 基表基数(rows of base relation):把 clauselist_selectivity 应用在 baserestrictinfo 上,乘以 tuples
  2. 基表 path 的 costcost_seqscancost_indexcost_bitmap_*,每个 path 都依赖前一步的 rel->rows
  3. 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 子句)

其中:

基数估计的第二个改造点get_relation_info_hook(plancat.c:61)。可以在拿到 rel->tuplesrel->pagesrel->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

  1. 读当前真实 RelationGetNumberOfBlocks(rel)curpages实时 lseek/stat 出来)。
  2. pg_class.relpages / reltuples(ANALYZE 时记下的快照)。
  3. 按页密度外推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.cmcv.cdependencies.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:

子句类型调用文件
Varboolvarselselfuncs.c
OpExpr通过 get_oprrest(opno) 拿到 oprrestpg_operator
BoolExpr AND/OR/NOT递归 + 容斥clausesel.c
NullTest IS NULLnulltestselselfuncs.c
BooleanTestbooltestselselfuncs.c
RowCompareExprrowcompareselselfuncs.c
ScalarArrayOpExprscalararrayselselfuncs.c
其他DEFAULT_*_SEL 常量selfuncs.h

oprrest 是注册在 pg_operator 上的函数 OID,例如:

操作符oprrest
=eqsel
<>neqsel
< <=scalarltsel / scalarlesel
> >=scalargtsel / scalargesel
LIKEregex_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,哪边是常量,分发到:

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 等值估计的核心三段式

  1. 唯一性短路:若列有 unique index,等值结果直接 1 行 → 1/tuples
  2. MCV 命中:在 most-common-values 里找到 → 用记录的精确频率(pg_statistic.stanumbersN[i])。
  3. MCV 未命中:剩余概率均匀分摊给”非 MCV 的不同值”。
  4. 完全无统计1/ndistinctndistinct 也常常是 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_statisticstakindN = STATISTIC_KIND_HISTOGRAM (=2),对应 stavaluesN等深直方图——default_statistics_target 个分位点(默认 100,最大 10000)把数据切成等频段。

ineq_histogram_selectivity 的逻辑:

  1. 二分找到 const 落在第几个 bin。
  2. 在 bin 内做线性插值(假设 bin 内均匀分布)。
  3. 边界 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 做的事:

  1. Var 顺着 RTE 链找回基表上的 attno。
  2. get_relation_stats_hook 或读 pg_statistic
  3. 判断是否被 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数组/复合类型的 MCVarray_selfuncs
5 DECHIST数组元素直方图array_selfuncs
6/7range/bounds histogramrangetypes_selfuncs

加上行级标量:stanullfracstadistinct(负数表示比例)、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;
}

要点:

13. estimate_hash_bucket_stats:哈希连接专用

selfuncs.c:3751,给 cost_hashjoin 用的”内表分桶后每桶大小”估计。

14. ANALYZE / pg_statistic 是怎么填的

虽然不在 planner 里,但研究基数估计绕不开。

default_statistics_target

GUC,决定:

做基数估计研究时的对照基线:把 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 或学习型估计替换
默认值 fallbackDEFAULT_*_SEL出现即说明缺统计;警报
关联列 join 高估同 join 公式假设独立多 join 多列扩展统计 (PG14+ MCV slot 扩展)
子查询不展平的过保守估计默认 0.005 之类让 planner 尽量 pull-up

16. 给你做基数估计研究的”切入口”

按”侵入性”由低到高:

  1. get_relation_stats_hook** / **get_index_stats_hook(selfuncs.c:144/145)
    • 替换 pg_statistic——返回构造的 HeapTuple,所有 *sel 函数都从这里拿统计
    • 适合:合成统计、采样估计、ML 模型预测的统计
  2. set_baserel_size_estimates_hook(costsize.c:42)
    • 单表估完之后改 rel->rows
    • 适合:在 baseline 估计上做后处理 / clamp
  3. get_relation_info_hook(plancat.c:61)
    • 拿到 rel->tuples / rel->pages / 索引列表后微调
    • 适合:注入更准确的全表行数
  4. **自定义 oprrest / **oprjoin(pg_operator)
    • 注册新算子或修改现有算子的选择率函数
    • 慎用,全局生效
  5. planner_hook(planner.c:75)
    • 包装整个 planner,最后递归到 standard_planner
    • 适合:在 plan 完成后 reset rows / 替换 path / 强制 plan shape
    • AQO、pg_hint_plan 走这条
  6. **直接改 clauselist_selectivity_ext / **selfuncs.c
    • 改源码并重编 PG
    • 适合:研究新算法本身(学习型 estimator、sketch、神经网络)

MVCC

为什么需要 MVCC

数据库要回答的核心问题是:“读和写并发发生时怎么办?”

最朴素的方案是加锁——读的时候加读锁,写的时候加写锁,读写互斥。这种方案简单,但在 OLTP 场景下灾难性:一个长查询会把所有写都堵住,反过来也一样。

MVCC(Multi-Version Concurrency Control,多版本并发控制)的思路完全不同:让一行数据同时存在多个版本,每个事务读到的是它该读到的那个版本,从而读写互不阻塞。读不上锁、写不上锁(指读写之间),代价是同一行物理上躺着多个副本,需要后台清理。

PostgreSQL 是 MVCC 的”原教旨主义者”——所有版本都直接堆在主表里(不像 Oracle/MySQL 把旧版本放 undo log 里),删除只是打个标记,更新就是”插一行新的 + 把旧的标记成被删”。这种实现叫 append-only MVCCin-place MVCC with dead tuples。它的好处是回滚极快(只要把事务标成 abort,所有它做过的修改自动失效),坏处是表会”长胖”,需要 VACUUM 持续回收死行。

理解 PG 的 MVCC,关键就是理解三个东西:

  1. 每行数据怎么打标记(xmin/xmax)
  2. 每个事务怎么知道哪些版本归它看(snapshot)
  3. 拿到一行后怎么判断”它对我可见吗”(HeapTupleSatisfiesMVCC)

下面按这个顺序展开。

1. 一行数据上的 MVCC 标记

1.1 为什么每行要有两个 xid

PG 给每个事务分配一个单调递增的 32 位整数,叫 transaction id,简称 xid。任何修改了数据的事务都有一个 xid。只读事务可以不分配 xid(这是 PG 14 的”惰性 xid”优化,省下了大量 xid 分配开销)。

每行物理记录里硬编码两个字段:

为什么这两个字段就够了?因为有了 (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 */

工作机制是这样的:

这是 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;

物理上发生了什么:

  1. PG 找到 t 表的某个有空间的页(freespace map)。
  2. 在那一页的某个槽里写下一条新记录:
t_xmin = 100        ← 我(T1)插的
t_xmax = 0          ← 还没人删
t_cid  = 0          ← 这是 T1 的第 0 条命令
t_ctid = (本页, 本槽)
t_infomask: 暂时啥 hint 都没有
  1. T1 提交,pg_xact[100] = COMMITTED

注意提交那一步没有去改这一行的 hint bit。要等下次有人读到这一行、触发可见性判断时,才会把 HEAP_XMIN_COMMITTED 写上去。

源码:heap_insertsrc/backend/access/heap/heapam.c

2.2 DELETE:只是打标记,不删数据

BEGIN;                                    -- T2 开始,xid = 200
DELETE FROM t WHERE id = 1;
COMMIT;

物理上发生了什么:

  1. T2 找到要删的那一行(前面 T1 插入的)。
  2. **不删,只改它的 **t_xmax
t_xmin = 100        ← 不动
t_xmax = 200        ← T2 把这里改成自己的 xid
t_cid  = 0          ← 这次是 cid 表 T2 删除的命令号
  1. T2 提交。

那一行的字节并没有从磁盘上消失——它只是”被打了死亡标记”。任何快照如果还能”看见 T2 还没提交”,就还能读到这一行(因为它的 xmin 是已提交的 T1,xmax 是未提交的 T2)。

只有当所有还可能用到这一行的事务都结束之后,VACUUM 才会真的回收这一行的空间。

源码:heap_delete

2.3 UPDATE:等于一次 DELETE + INSERT

UPDATE t SET name = 'bar' WHERE id = 1;

物理上发生了什么(假设当前事务 xid = 300):

  1. 找到旧行,把它的 t_xmax 改成 300,t_ctid 改成新行的位置。
  2. 在某页(也许同页也许别页)写一条新行:xmin=300、xmax=0、ctid 指向自己。

关键点是两个版本同时存在于堆里

旧版: { xmin=100, xmax=300, ctid → 新版 }
新版: { xmin=300, xmax=0,   ctid → 自己 }

不同事务看到不同版本:

这套机制让我们可以一边有人改、一边有人读,互不阻塞。代价是写多读少的表会持续膨胀。

源码:heap_update

2.4 SELECT FOR UPDATE:行级锁

SELECT … FOR UPDATE 不修改数据,但要”占住”这一行不让别人改。PG 的做法是:

这是 PG 把行锁信息塞进元组头的巧妙做法——不需要单独的锁表来记录每行级锁。

3. 快照:每个事务的”宇宙观”

到此为止,我们已经知道每行数据上有 (xmin, xmax)。但单看一行不足以判断可见性——还要知道 xmin、xmax 这两个 xid 在”我这个事务”看来是什么状态

考虑一个具体场景:一个 SELECT 扫到了一行,xmin=500、xmax=0。这一行可见吗?

这就是 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 在快照看来还在进行”的逻辑(XidInMVCCSnapshotutils/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 历史上花了很多力气优化这块:

这些都不是简单优化,是真材实料的扩展性瓶颈。

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 写在元组头上,但它代表的事务可能:

  1. 已提交(最常见情况)
  2. 已 abort(这一行其实从来没真正存在过)
  3. 还在进行中(包括”是我自己当前事务”)
  4. 在我快照拍的时候还没提交,但现在提交了——对快照来说仍然不可见

源码(简化版,标了行号):

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 的镜像,但结果反过来:

源码骨架:

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 时,每次可见性判断都要 TransactionIdDidCommitaccess/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,后续整个事务复用。所以:

这种”读到不一致就报错”的策略,比 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 的论文)的可序列化实现。简单说:

代码在 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)。当满足两个条件时:

  1. 被改的列没有任何索引
  2. 新版本能放进同一页

PG 走一条捷径:

索引 ──► 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 路径上做”机会式”修剪:

这是 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 是入口。流程:

  1. 对每页先做 HOT prune(清理 HOT 链)。
  2. HeapTupleSatisfiesVacuum 给每个元组打标签:DEAD / LIVE / RECENTLY_DEAD / IN_PROGRESS。
  3. DEAD 元组的 line pointer 标 LP_DEAD
  4. 把指向这些死行的索引项也清掉(lazy_vacuum_indexes)。
  5. 真正释放 line pointer,把空间归还到 freespace map。
  6. 必要时 truncate 文件尾部空块。

注意:普通 VACUUM 不归还磁盘空间给操作系统——只是把空间放进 freespace map,让后续插入复用。要真归还磁盘只能 VACUUM FULL,它会重写整个表(非常昂贵,会持锁)。

7.3 Freezing:抗 xid 回卷

xid 是 32 位,约 40 亿个就会回卷。PG 用”对比距离”(环形比较)判 xid 大小,前提是任何两个 xid 距离不超过 ~20 亿。否则可见性判断会反转——一个明显在过去的事务在比较时变成”未来”,灾难性后果。

解决:把老 xid frozenvacuum_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

含义
00TRANSACTION_STATUS_IN_PROGRESS
01TRANSACTION_STATUS_COMMITTED
10TRANSACTION_STATUS_ABORTED
11TRANSACTION_STATUS_SUB_COMMITTED

存储:8 KB 一页 → 一页装 32K 个 xid 的状态 → SLRU 缓存(默认 128KB,可调)→ LRU 替换。

可见性查询的性能层次:

最快:元组 hint bit 命中               (无 IO,无锁)
其次:CLOG 缓存命中                    (查内存,小锁)
最慢:CLOG miss → 读 pg_xact 文件      (真磁盘 IO)

这就是为什么 hint bit 是性能命脉——它消除了后两步。

PG 还有几个相关的 SLRU:

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。

后果:

  1. OldestXmin 一直被 T_long 的 xmin 卡在 1000。
  2. 期间所有 update/delete 产生的死元组,xmax 都 ≥ 1001。
  3. VACUUM 看到这些死元组,但 xmax >= OldestXmin,不能删——它们对 T_long 的快照可能仍然可见。
  4. 表持续膨胀,cost 估计变形。
  5. 接近 wraparound 时(约 2 亿个 xid 之后),autovacuum 紧急启动,强制工作。
  6. 负载尖峰,可能触发其他问题(连接池打满、checkpoint 拖慢等)。

监控:

PG 14 甚至引入了 idle_in_transaction_session_timeout,专门防”开了事务忘了 commit”。