1. 先把 PostgreSQL 体系结构盘明白,再谈深入代码
很多朋友一上来就抱着postgres.c从main函数开始读,或者到 GitHub 上直接搜热门 issue,读了两天丝毫没有头绪,最后得出结论:内核太难。实际上,读 PostgreSQL 内核跟读普通业务代码完全不是一回事,它要你先建立一张“地图”——从模块划分、进程模型到目录结构,心里有数了之后,代码才会从“一堆符号”变成“一条条可追踪的链路”。
我建议的学习路径,先不要碰处理器相关代码,先搞清楚 PostgreSQL 在操作系统上是怎么跑起来的。它默认是多进程架构,不是多线程。一个主进程(postmaster)负责监听连接、派生工作进程,每个客户端连接对应一个 backend 进程,还有一堆辅助进程,比如检查点进程、BgWriter(后台写进程)、WALWriter(WAL 写入进程)、AutoVacuum(自动清理进程)、统计收集进程等等。
这个架构决定了你分析问题时必须把“进程间通信”和“共享内存”纳入视野。比如shared_buffers这个参数,它指定的大小就是系统启动时在共享内存里开辟的缓冲区块大小,所有 backend 进程都能访问这些缓冲块。你如果不懂共享内存里放了什么,就没办法理解“一个查询怎么读取一张表的数据”,也会卡在缓存一致性问题上。
另外,data目录下的文件布局也是必须提前掌握的。最基本的知识:
base/:每个数据库对应一个子目录,目录名就是数据库的 OID。pg_wal/:存放 WAL 日志段文件。pg_xact/:事务提交状态日志(旧的pg_clog)。global/:集群级共享系统表。pg_tblspc/:表空间软链接。
这些不是靠背,而是当你实际去翻一个表对应的文件时,会发现表的数据默认存放在base/<数据库OID>/<表文件的relfilenode>中。如果你有心,甚至可以在文件系统层面看到这个表占用了哪些文件块,这对理解数据页的刷新机制非常有帮助。
所以,第一阶段的目标不是逐行读代码,而是能回答以下问题:
- 一个查询从连接到返回结果,经过了哪些进程?
shared_buffers到底是什么?里面存的是什么粒度的数据?postmaster与 backend、辅助进程之间的协作关系是什么?data目录下的每个子目录大概是什么用途?
带着这些问题去读官方文档里关于“数据库物理存储”和“进程架构”的章节,再对照源码目录名字去猜功能,比漫无目的地翻代码高效十倍。
2. 沿着一条 SQL 的完整旅程建立主血脉
当你能画出 PostgreSQL 的“地图”之后,下一步就是给地图上画一条“主干道”——选一条最简单的 SQL,比如SELECT * FROM t WHERE id = 1;,从客户端发起查询,到最终返回结果,把这条链路上所有核心步骤走一遍。
很多人一开始就去看执行器代码,但忽略了这个 SQL 在到达执行器之前,已经经历了词法分析、语法分析、逻辑重写、查询优化、计划生成等多个阶段。这条主线才是所有复杂功能的载体。
2.1 从一个查询进门:解析与重写
PostgreSQL 的 SQL 解析流程大致是:
- 词法分析(
scan.l):把字符串拆成 token。 - 语法分析(
gram.y):根据语法规则生成解析树。 - 分析/重写(
analyze.c、rewrite/):把解析树转换成查询树,应用视图、规则、触发器重写规则。
这儿不需要你把每个 token 的规则背下来,但一定要理解“解析树(Parse Tree)”和“查询树(Query Tree)”的区别。解析树是纯语法结构,还没有字段和表的语义绑定;查询树则已经通过系统目录查到了表、列、操作符、函数等元数据。比如你写SELECT * FROM t,语法分析不知道t是什么,直到分析阶段去查pg_class,才知道它是一个表还是视图,然后把*展开成具体的列。
这一步最容易忽略的是重写系统(Rewrite System)。它是实现视图和规则的基础,很多新手查SELECT一条视图时,以为视图是“执行时动态展开”,其实是在优化之前,就已经被重写器从“查视图”改写成了“查视图对应基表”的查询树。理解重写阶段对后来研究规则、物化视图、分区表都有好处,因为分区裁剪在早期就进行了类似操作。
2.2 优化器:拉开差距的核心战场
优化器(src/backend/optimizer/)往往是内核学习中最让人畏惧的部分,但恰恰是最值得花时间的。它决定了同样的 SQL 在不同数据分布下会不会走出天差地别的计划。
关键要理解层次:
- 逻辑计划:由查询树直接变换而来的关系代数树。
- 物理计划:在逻辑计划基础上,根据访问路径(Seq Scan、Index Scan、Index Only Scan等)生成执行计划树。
PostgreSQL 的优化器是一个“基于代价”的优化器。它会给每个可能的计划路径计算一个估算代价,计算公式里包含启动代价(Startup Cost)和总代价(Total Cost)。你会在很多源码注释里看到cost_seqscan、cost_indexscan这样的函数,它们就是做这件事的。
这里有一个让我印象很深的点:统计信息。优化器怎么知道一个表有多少行?怎么知道某个条件能过滤掉多少行?靠的是pg_statistic里的直方图、MCV(最常见值列表)、NULL 比例等等。如果你没有更新统计信息(ANALYZE),或者统计信息过期,优化器就会给出错误的估算,随之产生严重偏差的执行计划。这也是我们在生产环境里遇到“SQL 突然变慢,重启就好了”这类现象的根源之一——统计信息重新收集后计划才变正常。
学优化器不用一开始就死磕动态规划算法(geqo或连接顺序搜索),你应该先掌握:
- 基于顺序扫描、索引扫描的代价估算基本思想
- 路径生成(add_path)与剪枝(add_paths_to_joinrel)
- 为什么
EXPLAIN输出里的 rows 和实际行数可能有巨大差异 - 参数化路径、并行计划的前提
2.3 执行器:看元组是怎么流动的
当优化器生成计划树(Plan Tree)后,执行器(executor/)开始干活。核心概念是节点(Node)。每种执行节点负责一种操作,比如SeqScan、IndexScan、NestLoop、HashJoin、Sort,它们之间通过ExecProcNode这样一个“函数指针”来形成流水线。
把执行器想成一个小型工厂流水线:上游节点每次返回一条元组(Tuple),传递给下游节点进行加工,直到最顶层节点输出结果。PostgreSQL 执行器是经典的一次一行(Volcano 模型)模型,每个节点都要实现ExecInitNode、ExecProcNode、ExecEndNode三件套。
想要快速建立感觉,建议用一个很小的例子,比如两表连查,查看EXPLAIN (COSTS OFF, VERBOSE)的输出计划树,然后在源码里找到对应的节点结构体(如HashJoinState、HashState),跟踪它是怎么从Hash子节点拉取元组,放入哈希表,再从内表取出元匹配合适行的。
这个阶段一定不要过度依赖抽象的理论。我身边一位朋友就是花了一周时间把节点结构体的所有成员都抄在笔记本上,问他 “HashJoin 到底是怎么区分 build 和 probe 侧的”,他却讲不清。所以我强调:读执行器必须结合EXPLAIN,让计划树成为你的阅读提纲,代码是计划树的具体实现,这样便会有目的地去确认“这个节点创建时做了什么状态”“每调用一次 ExecProcNode 走什么分支”。
这个“单条 SQL 主血脉”打通以后,你以后再看并行查询、分区表、增量排序,都是在主干上挂分支,难度骤降。
3. 分模块吃透:缓冲池、事务、WAL、锁,一个都不能少
如果说 SQL 主脉络是“树干”,那下面这几个模块就是深入内核必遇的“四大护法”。很多看起来诡异的现象,最后都能归结到它们身上。
3.1 缓冲池与页面结构
表的数据在磁盘上是按8KB 的数据页(Page)存放的,Pages 在读入内存时由Buffer Manager管理。它维护了一个 Buffer Descriptor 数组,与共享缓冲池中的页一一对应。每个 Buffer 都有一个状态标志位(BM_VALID、BM_DIRTY 等)、一个引用计数(refcount)、一个 usage_count(用于时钟扫描算法)。
要理解缓冲池的行为,必须研究ReadBufferExtended到ReadBuffer_common的调用链。它处理以下几个关键问题:
- 请求的页是否已经在缓冲池中?
- 如果不在,需要从磁盘读取,那应该替换哪一页?
- 若替换的页是脏页,要不要立即写回?(这里会涉及
bgwriter和checkpointer的衔接)
数据页本身也是一个微型“存储系统”。页头(PageHeaderData)里有pd_lsn(最近一次修改该页的WAL记录位置)、pd_checksum(校验和)、pd_lower、pd_upper。行数据从页中间向两端填充,空闲空间在中间。当你插入一条新元组时,会找合适的空闲空间,这跟未来的“行版本”、“堆表膨胀”都有关系。
对新手来说,最直观的实验是:用pageinspect扩展查看一个表页面的头部信息和元组偏移表。我实测发现,读完bufpage.h里的宏定义之后再去pageinspect里翻页面结构,概念会牢固得多。
3.2 事务与 MVCC:PostgreSQL 的根基
PostgreSQL 采用MVCC(多版本并发控制)实现读不阻塞写、写不阻塞读。它不像有些数据库用回滚段保存旧镜像,而是通过元组里保存的 xmin / xmax标记该元组的插入事务 ID 和删除事务 ID,配合事务状态目录(pg_xact)和事务快照机制,判断一条元组对当前事务可见性。
学习 MVCC 最重要的两个切入点:
- 行可见性判断的核心代码在
heapam_visibility.c的HeapTupleSatisfies*系列函数(如HeapTupleSatisfiesMVCC)。它要判断元组的插入事务是否提交、删除事务是否提交、以及当前事务自己的隔离级别。 txid(事务ID)是32位整数,会发生回卷(wraparound)。数据库会通过frozen事务 ID 解决,且XID与 64 位的epoch配合,普通用户不会感知回卷,但内核开发者必须理解TransactionId之间的比较不是普通数值大小比较,而是一个模空间上的距离问题。
MVCC 还直接与 “索引中存储的不是行版本数据,而是(索引键、物理位置 TID)” 这一事实有关系。因此,在索引扫描时,还需要对取到的元组进行可见性检查,如果元组已经更新,索引项可能还指向旧版本;PostgreSQL 用heap_hot_search进入 HOT(堆内元组)技术来缓解这个问题。这些都是非常值得深入研究的经典内容。
建议自己拿个小表试验一下:开两个事务,分别INSERT几条数据,然后使用pg_controldata、pg_current_xact_id_if_assigned查看当前事务号,甚至通过DEBUG1或自定义elog打印 xmin/xmax,让抽象概念落地。
3.3 WAL 与检查点:崩溃恢复的根
写前日志(WAL)是保证事务持久性的关键机制:数据页不必在事务提交时立即落盘,但WAL记录必须先刷到磁盘。PostgreSQL 的 WAL 记录是一条条具有XLogRecord结构的日志项,其中有资源管理器类型、事务ID、前一条日志的位置(xl_prev)、元数据等。
理解 WAL 有几个递进式问题:
- WAL 日志写到哪里?写到
pg_wal/下的 16MB 段文件。 - WAL 记录什么时候必须 flush?事务提交时,或者缓冲区管理发现需要为新读入的页腾出空间而强制写脏页时。
- 页面写入磁盘与 WAL 记录的先后顺序如何保证?通过页头
pd_lsn与 WAL flush 位置的对比,如果脏页的pd_lsn大于 WAL flush 的位置,则必须先 flush WAL 到该位置,再写数据页,这就是 “WAL 先行” 规则。
检查点(Checkpoint)则是在某个一致性状态时刻强制将所有脏页写盘并更新控制文件。崩溃恢复时会从最近的检查点开始,重放该点之后没有被实际刷盘的WAL记录,从而把数据页恢复到崩溃前的一致状态。
很多人学到这里都会混淆 “red point” 和 “backup block”。当更新一个页时,WAL 记录可能只记录了页面变化的增量(如果此前页已经被 WAL 记录过)或者整个页面镜像(全页写,full_page_writes = on时)。分析pg_waldump输出的日志记录类型,是一种非常直观的学习方式。
3.4 锁与并发控制:不要只盯着行锁
PostgreSQL 中的锁是分层级的:
- 表级锁:
AccessShareLock、RowShareLock、RowExclusiveLock、ShareLock、ShareRowExclusiveLock、ExclusiveLock、AccessExclusiveLock,对应不同 SQL 语句在不同的模式下的获取情况。 - 行级锁:通过元组的
xmax字段或tuple infomask相关位实现,不是通过集中式锁表。 - 轻量锁:用于保护内存数据结构(如缓冲池中的页、锁表本身),在
src/backend/storage/lmgr/中。
内核小白经常会混淆事务锁与闩锁(latch)。轻量锁持有时间很短,不能用于等待长时间事务;而事务锁可能保持到事务结束。搞清这两类锁的用途,对排查是否出现锁等待、死锁都有决定性作用。
调试锁问题有个好办法:在代码特定位置写elog(LOG, "got lock: lockmode %d", lockmode),或者使用pg_locks视图查看当前锁情况。理解锁的申请顺序和死锁检测(DeadLockReport),是很多大并发场景下的面试重点。
4. 从“读代码”切换到“改代码”的刻意练习
当你对主干模块都有基本认知后,学习模式必须发生一次跃迁:不再满足于“看懂别人写的代码”,而是主动去修改、调试、验证。这是我个人认为检验内核学习水平的最有效方法。
4.1 找几个“软柿子”函数动手改
不是所有函数都适合入门改。建议挑以下几类:
- 在
elog日志中增加信息输出,比如打印某个节点的执行时间、某个扫描路径的选择率。 - 修改自增计数逻辑,例如在优化器估算
reltuples时打印旧值和新值。 - 修改某个内存上下文名称,看看
pg_backend_memory_contexts中的变化。 - 自定义一个新的简单执行节点(或模仿现有节点实现一个不干活的“pass-through”节点)。
这些练习不会破坏主体结构,也能让你熟悉编译流程、debugger 和回归测试。
以修改一个执行节点为例,我的习惯是:先复制SeqScan节点代码,改成自己的节点名,注册执行节点函数指针,在计划树里强制替换 SeqScan,然后打断点查看调用链。完成这个练习,你对ExecInitNode+ExecProcNode调度机制的理解,会超过你之前读三遍代码的效果。
4.2 必啃的几个高价值函数入口
以下函数我觉得值得反复钻研,含金量很高:
standard_planner:查询优化主入口。add_path与add_paths_to_joinrel:理解路径选择与淘汰。hashjoin的ExecHashJoin:理解一次一行执行模型。heap_insert:理解元组写入、页更新、WAL 日志生成。LWLockAcquire:理解轻量锁的原子操作与等待队列。HeapTupleSatisfiesMVCC:MVCC 可见性判断核心。XLogInsertRecord:了解 WAL 记录的封装与写入。InitPostgres:backend process 启动时的初始化流程。
每读完一个函数,都写一个几行的注释,表达你自己对这个函数设计的理解。不要复制网上的解释,而是用自己的话讲清楚“它为什么需要这样设计,哪个场景会触发这条代码路径”。
4.3 建立自己的调试工具箱
GDB 是必备技能,但要针对 PostgreSQL 特点进行配置。以下是我常用的技巧:
- 在
psql连接建立后,查询pg_backend_pid()获取 backend 进程 PID,然后gdb -p pid附加该进程。 - 对
ExecProcNode设置断点,凡是执行任何节点都会经过它。 - 使用
gdb的call命令直接调用某些内置函数读取结构体信息,比如打印计划节点的type、关系 OID、当前元组内容。 - 如果需要观察 WAL 写入过程,在
XLogInsertRecord函数设置条件断点(比如特定关系 OID)。
另外,PostgreSQL 自带postgres --single -D data单用户模式,可以用它做极小的 SQL 操作而不通过网络。它适合在本地快速验证内核行为。
5. 常见问题与排查技巧实录
这部分整理一些我在带新人和自己学习时遇到的高频问题,按实际踩坑经验来说。
5.1 “看了源码但总觉得记不住”
症状:读每个文件都能读懂,但合上书就忘,问哪都说不清。
原因:把源码当作小说看,缺少“问题驱动”。如果没有带着“这个模块解决了什么问题”“它在整个系统里处于什么位置”的框架,记忆只是零散片段。
对策:以“从问题到源码”为主。比如你首先要问:为什么UPDATE一条记录不直接更新原页,而是要插入新版本?然后再去看heap_update的实现。如果能回答“它是为了支持并发下读旧版本”,再看代码就会记住关键分支——找到空闲空间、处理HOT chain、生成新版本元组、写WAL。
5.2 “本地编译慢、调试不方便”
PostgreSQL 源码编译通常需要几分钟到十几分钟,所以不要频繁改一个宏就全量编译。可以使用make -j并行编译,并开启--enable-debug。另外建议用gdb附加运行中的进程做实验,而不必反复重启。如果你只是修改elog日志级别,用默认的 DEBUG 输出配合log_min_messages参数更容易定位。
5.3 “没耐心读完大模块代码”
这很正常。一个模块动辄几千行,谁都没办法一口气读完。建议采用“从入口到出口”的深度优先遍历方式。
以heapam.c为例,里面函数众多,但heap_getnext、heap_insert、heap_update、heap_delete这类是核心入口,散落的辅助函数可以只在需要时去查。你要做到的是能说出:这个函数的名词在什么业务场景下被调用,参数值从哪里来,最终影响什么外部可见行为。至于一个内部工具函数用了什么算法,完全可以等需要时再细看。
5.4 “理解不了默认参数的取值”
例如shared_buffers默认 128MB,max_connections默认 100。这些默认值背后往往牵涉内存开销、事务管理等方面。内核学习时要结合源码去算:比如每个连接会为Port、Bind分配多少内存?shared_buffers每页有一个BufferDesc结构,内存里多个缓冲池还需要BufferTag哈希表。通过实际计算,你就能明白为什么不能随意把shared_buffers调得过高。
6. 学习路径主线和进阶建议
前面已经讲了具体模块,最后给出我认为最适合多数人的学习主线。
第一阶段(1~2周):系统架构认知
- 学完官方文档中“Architecture”和“System Catalog”相关内容。
- 画出进程模型图和各进程通信流程。
- 用
pg_controldata、pg_waldump、pageinspect等工具观察物理文件。
- 用
第二阶段(3~6周):SQL 主链路
- 写一个简单 SQL,使用
EXPLAIN观察计划。
- 写一个简单 SQL,使用
- 从
pg_parse_query、pg_analyze_and_rewrite、pg_plan_queries、executor等入口,梳理主调用流程。
- 从
- 重点精读优化器中的 cost 估算和路径生成,以及执行器中的节点实现。
第三阶段(7~12周):模块专题
- MVCC + 事务模块。
- WAL + 检查点 + 崩溃恢复。
- 锁管理 + 并发调度。
- 内存管理(共享内存、内存上下文MemoryContext)。
每个专题都建议配合动手实验,如使用debug模式打印关键结构体,或在临时分支上修改代码观察行为。
第四阶段(长期):持续追踪新版本特性,参与补丁和讨论
- 阅读发行说明中的Major Feature Change。
- 关注值得重点研究的语法变化(如增量排序、异步IO、逻辑复制增强等)。
- 选择社区评论里一些经典 patch 的讨论,学会阅读设计邮件列表。
在模块学习阶段,不要贪多求快。我见过很多人一周就啃完“所有模块”,但问他某个具体函数 bug 的触发条件,反应不过来。与其这样,还不如选定一个模块(比如优化器或 MVCC)读三个月,把相关的问题都摸透。内核开发者或者说读过内核的人,需要的并不是“我能不能把所有代码背下来”,而是在遇到任何内存、崩溃、锁竞争问题时,能够顺藤摸瓜,从现象找到根因和治本方案。
如果你现在正打算进入 PostgreSQL 内核学习,我唯一的建议是:先选一小段主线,从EXPLAIN SELECT 1;的流程开始,把这条线走完,然后回头搭自己的架构图。走得慢没关系,关键是每一步都要能说出“这一步拆掉了哪个概念,连接了哪两个模块”。内核学习最忌讳的就是“我了然大 OFF,但代码一行没跑”。
最后分享一个小技巧:每次遇到一个新名词,不要先去翻博客,而是先在源码里搜索(或者用 IDE 的全局符号查找),找到它的声明和使用场景。用代码反推概念,比纸上谈兵可靠得多。日后再遇到没有人解释过的模糊问题,你已有的这套搜索、定位、验证路径,就是核心学习能力的体现。