PostgreSQL 架构原理第一期:从进程模型到核心内存机制

0 阅读11分钟

PostgreSQL 架构原理第一期:从进程模型到核心内存机制

引言

PostgreSQL 被誉为“世界上最先进的开源关系型数据库”,其强大的功能和可靠性背后,是一套经过数十年打磨的精致架构。理解 PostgreSQL 的内部工作原理,不仅能帮助我们写出更高效的查询,更能为性能调优和问题诊断打下坚实基础。

本文将带大家走进 PostgreSQL 的内核世界,从宏观的进程模型到微观的内部组件,循序渐进地揭示它是如何工作的。本文将重点涵盖以下内容:

  1. “进程每用户”模型及连接建立流程
  2. 进程间通信与共享内存的核心角色
  3. 查询处理的完整生命周期
  4. WAL 机制及其在数据安全中的作用
  5. 缓冲区管理器的三层结构

一、PostgreSQL 的进程架构:每个用户一个进程

PostgreSQL 的实现采用了一种经典的 “每用户一个进程” (process per user)的客户端/服务器模型。在这个模型中,每个客户端进程都恰好连接到一个后端(backend)进程。

由于我们事先无法预知会有多少个连接建立,PostgreSQL 使用一个特殊的“监督进程”来管理所有连接,这个进程被称为 postmaster。postmaster 在指定的 TCP/IP 端口上持续监听传入的连接请求,每次检测到一个连接请求时,它就派生(fork)出一个新的后端进程来专门处理这个连接。

一旦建立连接,客户端进程就可以向后端进程发送 SQL 查询。查询以纯文本形式传输,客户端无需进行解析。后端进程负责解析查询、创建执行计划、执行计划,并将结果行返回给客户端。

这种进程模型的设计有如下几个优点:

  • 隔离性:每个后端进程拥有独立的内存空间,一个后端进程的崩溃不会影响其他连接。
  • 可扩展性:可以根据连接数灵活地增加或减少进程数量。
  • 安全性:进程级别的隔离为多用户环境提供了天然的安全边界。

二、共享内存:进程协作的桥梁

2.1 共享内存的角色

多个后端进程需要协同工作,共享数据和状态信息。这是通过 共享内存 实现的。PostgreSQL 使用信号量和共享内存来确保在并发数据访问期间的数据完整性。

当数据库服务器启动时,它会向操作系统请求分配一块足够大的共享内存区域,用来存储数据库的全局状态信息、锁信息、缓存数据(如缓冲区缓存、WAL 缓冲区等)以及其他需要快速访问的数据结构。

2.2 共享内存的主要组件

PostgreSQL 的共享内存包含几个关键部分:

  • 共享缓冲区(shared_buffers) :这是共享内存最主要、容量最大的区域,用于缓存表和索引的数据页,以减少磁盘 I/O。
  • WAL 缓冲区(wal_buffers) :用于暂存即将写入预写日志(WAL)的事务日志记录,然后再刷新到磁盘。
  • 锁信息区域:存储各种锁结构,用于协调进程间的并发控制。
  • 全局状态信息:如后台进程的状态、系统统计信息等。

2.3 内存分区概览

除了共享内存外,每个后端进程还拥有独立的本地内存区域,主要包含:

  • work_mem:用于排序操作和哈希表的内存(如 ORDER BY、DISTINCT、哈希连接等)。
  • maintenance_work_mem:用于 VACUUM、REINDEX 等维护操作的内存。
  • temp_buffers:用于存储临时表的内存。

这种本地内存和共享内存分工明确的设计,使得 PostgreSQL 能够在保证数据一致性的同时获得较高的并发性能。

三、SQL 查询的完整生命周期

一条 SQL 查询从提交到返回结果,需要经过多个处理阶段。下面我们按流程逐一展开。

3.1 解析器 —— 语法检查与查询树生成

当查询以纯文本形式从客户端发送到后端进程后,后端进程的 解析器 首先对查询进行语法检查。如果查询语法正确,解析器会创建一个 查询树 数据结构,表示查询的结构和语义。

查询树包含了查询中涉及的表、列、常量表达式以及操作符等信息,是后续处理步骤的基础。

3.2 重写系统 —— 视图展开与规则应用

解析生成的查询树随后进入 重写系统。重写系统的核心任务是根据系统表(system catalogs)中存储的规则,对查询树进行转换。

视图(View)是重写系统的一个典型应用场景。当用户查询一个视图时,重写系统会将针对视图的查询重写为直接访问基表的查询。这个转换对用户而言是完全透明的。

3.3 优化器 —— 寻找最优执行计划

重写后的查询树被传递给 规划器/优化器。优化器的任务是创建一个预计执行速度最快的执行计划。

优化器的工作流程大致如下:

  1. 生成扫描计划:首先为查询中使用的每个关系(表)生成可能的扫描方式,例如顺序扫描和索引扫描。
  2. 选择连接策略:如果查询涉及连接操作,优化器会从三种策略中选择——嵌套循环连接合并连接哈希连接,并搜索不同的连接顺序。
  3. 代价估算:优化器使用一种基于 路径(Path) 的数据结构来简化计划的表示,并为每个候选路径估算执行代价。
  4. 选择最佳路径:将代价最低的路径展开为完整的 计划树,传递给执行器。

当查询中涉及的关系数量超过 geqo_threshold 阈值时,优化器会使用 遗传查询优化器(GEQO) 进行启发式搜索。

3.4 执行器 —— 执行计划并返回结果

最终阶段,执行器 接收计划树,递归地遍历树的各个节点,按照计划所描述的方式从存储系统中扫描关系、执行排序和连接操作、计算条件表达式,并将最终的行结果返回给客户端。

执行器在处理过程中会调用缓冲区管理器来高效地读写磁盘页面。

四、预写日志(WAL):数据完整性的基石

预写日志(Write-Ahead Logging,WAL)是 PostgreSQL 确保数据完整性和持久性的核心机制。

4.1 WAL 的核心概念

WAL 的核心概念非常简单:对数据文件的更改必须在这些更改被记录到日志文件之后才能写入磁盘。也就是说,描述更改的 WAL 记录必须先刷新到持久存储。

通过这个过程,即使在数据库崩溃后,我们也能使用 WAL 日志来恢复数据:任何尚未应用于数据页的更改都可以从 WAL 记录中重做(REDO),从而使数据库恢复到一致的状态。

4.2 WAL 的性能优势

WAL 带来了显著的性能提升:

  • 减少磁盘写入:只需要将 WAL 文件刷新到磁盘即可保证事务已提交,不再需要将每个数据文件都刷新。
  • 顺序写入友好:WAL 文件是顺序写入的,而顺序写入的成本远低于随机写入。
  • 批量提交:处理多个并发小事务时,一次 fsync 即可提交多个事务。

4.3 WAL 的存储结构

WAL 文件存储在数据目录下的 pg_wal 子目录中,作为一系列段文件,每个段文件默认大小为 16MB。每个段又划分为页面,每页默认 8KB。

每个 WAL 记录的位置由 日志序列号(LSN) 唯一标识,LSN 是 WAL 中的一个字节偏移量,随着每个新记录的写入而单调递增。

4.4 检查点与恢复

PostgreSQL 定期执行 检查点 操作,将所有脏页(已被修改的数据页)写入磁盘,这使得崩溃后的恢复只需从最新的检查点开始重放后续的 WAL 记录即可,极大地加速了恢复过程。

WAL 技术还使得 在线备份时间点恢复(PITR) 成为可能,只需保存数据库的物理备份和归档的 WAL 日志,即可恢复到任意时间点。

五、缓冲区管理器:内存与磁盘的中转站

5.1 总体结构

缓冲区管理器管理着共享内存缓冲池和持久存储之间的所有数据传输,对数据库的性能有着至关重要的影响。PostgreSQL 的缓冲区管理器由三个核心组件构成:

  1. 缓冲池(Buffer Pool) :一个数组结构,每个槽存储一个数据文件页面(如表页、索引页),每个页面大小为 8KB。数组的索引称为 buffer_id
  2. 缓冲区描述符(Buffer Descriptor) :与缓冲池槽一一对应的结构体数组。每个描述符保存着对应槽的元数据,包括页面的状态(如是否脏页)、引用计数、使用次数等信息。
  3. 缓冲表(Buffer Table) :一个散列表(哈希表),存储着页面标识符 buffer_tagbuffer_id 之间的映射关系,用于快速定位某个磁盘页面是否已在缓冲池中。

5.2 页面标识机制

每个数据文件页面都被分配一个唯一的 buffer_tag,由三部分组成:

  • relfilenode:关系(表/索引等)的物理文件标识符(包含表空间、数据库、关系的 OID)
  • fork number:关系分支编号(0 表示主分支,1 表示空闲空间映射 FSM,2 表示可见性映射 VM)
  • block number:在关系中的块号

5.3 后端进程读取数据页的流程

当后端进程需要访问某个页面时,它调用 ReadBufferExtended 函数,通常情况下缓冲区管理器会按以下步骤工作并处理三种情况:

情况一:页面已在缓冲池中

  1. 为目标页面创建 buffer_tag,并走散列计算定位到对应的散列桶槽。
  2. 以共享模式获取对应分区上的 BufMappingLock 锁。
  3. 在缓冲区表中查找目标 buffer_tag,从条目中获取 buffer_id
  4. 将对应的缓冲区描述符“钉住”——增加描述符的 refcountusage_count
  5. 释放 BufMappingLock
  6. 访问缓冲池中对应的页面。

对于读取操作,PostgreSQL 进程会获取描述符的共享 content_lock,允许多个进程同时读取同一页面;对于插入/更新/删除操作,进程会获取独占 content_lock,并将页面标记为“脏页”。

情况二:页面不在缓冲池中,且有空闲槽

当所需页面不在缓冲池中且空闲列表(freelist)中有空闲槽时,缓冲区管理器会执行加载流程:先在缓冲区表中查找确认页面不存在,然后从空闲槽中选择一个,将页面从磁盘加载到该槽中,并更新缓冲区表中的映射关系,最后返回该槽的 buffer_id 供后端进程访问。

情况三:页面不在缓冲池中,且无空闲槽

如果所需页面不在缓冲池中,且空闲列表中没有空闲槽,缓冲区管理器就需要淘汰(evict)一个现有页面。为了公平起见,PostgreSQL 将缓冲池视为一个循环列表,通过一个“下一个受害者缓冲区”索引来依次遍历,并结合描述符中的 usage_count 来决定哪个页面更适合被替换出去,而不是盲目地淘汰第一个遇到的页面。

当淘汰页面是脏页时,缓冲区管理器还会负责将其写回磁盘,以确保数据不丢失。

结语

本文作为 PostgreSQL 架构原理系列的第一期,从宏观层面梳理了 PostgreSQL 的进程架构、临界共享组件、SQL 查询的执行全流程、WAL 日志机制以及缓冲区管理器的设计。可以看到,PostgreSQL 将这些组件巧妙地组合在一起,在满足丰富功能的同时兼顾了高并发和可靠性。

后续文章将进一步深入讲解 PostgreSQL 的存储结构(堆表、索引、TOAST)、事务与并发控制(MVCC 与锁机制)、备份与恢复(基础备份、PITR、流复制与逻辑复制)、统计信息与代价模型(pg_statistic、ANALYZE 工作原理)、性能调优实战(执行计划分析、参数调优方法论)等主题,敬请期待。

思考题

  1. PostgreSQL 的“每用户一个进程”模型相较于线程模型,有哪些优劣?
  2. 一个 UPDATE 操作从提交到磁盘确认完成,WAL 参与了几次写入操作?
  3. 为什么即使查询可能需要读取大量数据,PostgreSQL 仍然可能选择索引扫描而非顺序扫描?optimizer 的 cost model 包含哪些维度的考量?

欢迎在评论区留言讨论!