PostgreSQL是我用得最多的关系型数据库,从工作到现在,用了快十年了。

但说实话,很长一段时间里,我只是把它当成一个黑盒,会写SQL、会建索引、会做基本的优化,对它的底层原理一知半解。直到最近几年,因为要做一些深度的性能优化和问题排查,我才开始认真研究PostgreSQL的底层机制。

越研究越发现,PostgreSQL的设计非常精妙,很多机制都值得深入学习。这篇文章,我想结合PostgreSQL 17的新特性,剖析一下PostgreSQL的底层原理,从存储引擎到查询优化,从并发控制到崩溃恢复,聊聊它的核心机制。

PostgreSQL的整体架构

先从整体架构说起。

PostgreSQL采用的是进程架构,而不是线程架构。这是它和MySQL的一个重要区别。

PostgreSQL的主要进程包括:

  • postmaster(主进程):负责监听连接、fork子进程、管理数据库实例的启动和关闭。
  • backend(后端进程):每个客户端连接对应一个backend进程,负责处理这个连接的所有请求。
  • background writer(后台写进程):负责把共享内存中的脏页刷到磁盘。
  • wal writer(WAL写进程):负责把WAL日志刷到磁盘。
  • checkpointer(检查点进程):负责执行检查点,把脏页刷到磁盘,更新控制文件。
  • autovacuum(自动清理进程):负责自动执行VACUUM,清理死元组,更新统计信息。
  • stats collector(统计收集进程):负责收集数据库的统计信息。
  • logical replication launcher(逻辑复制进程):负责逻辑复制。

这种进程架构的优点是:进程之间隔离性好,一个backend进程崩溃不会影响其他进程;缺点是:每个连接都要fork一个进程,连接的开销比较大,高并发下需要用连接池。

PostgreSQL的内存分为两部分:本地内存和共享内存。本地内存是每个backend进程私有的,包括workmem、maintenanceworkmem等。共享内存是所有进程共享的,包括sharedbuffers(数据页缓存)、WAL缓冲区、锁表等。

理解了整体架构,我们再深入各个子系统。

存储引擎:堆表和元组

PostgreSQL的存储引擎是堆组织表(Heap Organized Table),也就是说,数据是按插入顺序存储在堆里的,而不是按主键排序的。这和MySQL InnoDB的索引组织表(IOT)不同。

PostgreSQL的物理存储结构,从大到小是:

  • 数据库集群(Database Cluster):一个PostgreSQL实例管理的所有数据库的集合。
  • 数据库(Database):一个集群可以有多个数据库。
  • 表空间(Tablespace):可以指定表存储在不同的目录下。
  • 表(Table):每个表对应一个或多个文件。
  • 页(Page/Block):PostgreSQL的最小IO单元,默认是8KB。每个文件由很多页组成。
  • 元组(Tuple):每一行数据就是一个元组,存储在页里。

每个页的结构包括:

  • PageHeaderData:页头,存储页的元信息,比如页的LSN、空闲空间起始位置、特殊区域起始位置等。
  • ItemPointerData(行指针):每个元组对应一个行指针,指向元组在页中的位置。行指针从页头往后增长。
  • Tuple(元组):实际的行数据,从页尾往前增长。
  • Special Space:特殊区域,索引页用这个区域存储索引的元数据,普通堆表这个区域为空。

元组的结构包括:

  • TupleHeader:元组头,存储元组的元信息,比如xmin(插入事务ID)、xmax(删除/更新事务ID)、cid(命令ID)、ctid(元组的物理位置)、标志位等。
  • 用户数据:实际的列数据。

这里要特别注意xmin和xmax,这是PostgreSQL实现MVCC(多版本并发控制)的基础。每个元组都有xmin和xmax,记录了这个元组的生命周期。

PostgreSQL的更新(UPDATE)不是原地更新,而是标记旧元组为删除,然后插入一个新元组。所以,更新一行数据,实际上是写了一个新的版本,旧版本还留在堆里,直到被VACUUM清理。这就是PostgreSQL的MVCC机制,也是为什么需要VACUUM的原因。

MVCC和事务隔离

PostgreSQL的MVCC(多版本并发控制)是它的核心特性之一。

MVCC的基本思想是:每个事务看到的是数据的一个快照,读写不互相阻塞。读事务不会阻塞写事务,写事务也不会阻塞读事务。

PostgreSQL的MVCC是基于元组的版本号实现的。每个元组有xmin和xmax:

  • xmin:插入这个元组的事务ID。
  • xmax:删除或更新这个元组的事务ID。如果是0,表示这个元组是有效的。

当一个事务读取数据时,它会根据自己的事务ID和隔离级别,判断哪些元组版本是可见的。

比如,在Read Committed隔离级别下,一个事务只能看到在它开始之前已经提交的事务插入的数据,以及它自己修改的数据。在Repeatable Read隔离级别下,事务看到的是事务开始时的快照,整个事务期间看到的数据都是一致的。

PostgreSQL支持四种事务隔离级别:Read Uncommitted、Read Committed、Repeatable Read、Serializable。其中,Read Uncommitted在PostgreSQL中和Read Committed是一样的,因为PostgreSQL的MVCC不允许脏读。

Serializable隔离级别是PostgreSQL 9.1之后引入的,基于可序列化快照隔离(SSI)实现,能真正防止序列化异常。但性能开销比较大,一般用得不多。

MVCC的优点是读写不阻塞,并发性能好。缺点是会产生死元组(旧版本),需要定期VACUUM清理。如果VACUUM不及时,会导致表膨胀,性能下降。

WAL和崩溃恢复

WAL(Write-Ahead Logging,预写式日志)是PostgreSQL保证数据一致性和持久性的核心机制。

WAL的基本思想是:先写日志,再写数据。修改数据的时候,先把修改记录写到WAL日志里,然后再修改数据页。这样,即使数据库崩溃,也可以通过重放WAL日志来恢复数据。

WAL的流程是:

  1. 事务修改数据时,先在共享内存中修改数据页(产生脏页)。
  2. 同时,把修改记录写到WAL缓冲区。
  3. 事务提交时,把WAL缓冲区的内容刷到磁盘(fsync)。
  4. 脏页不需要立即刷到磁盘,由background writer和checkpointer在合适的时机刷盘。

这样做的好处是:

  • 提交时只需要刷WAL日志,不需要刷所有脏页,性能更好。
  • 崩溃恢复时,重放WAL日志,把没有刷到磁盘的修改恢复出来。
  • 支持时间点恢复(PITR),可以恢复到任意时间点。

WAL日志是分段的,每个段默认16MB。WAL日志会被归档,用于备份和恢复。

崩溃恢复的过程是:

  1. 数据库启动时,检查控制文件,找到最后一个检查点的位置。
  2. 从最后一个检查点开始,重放WAL日志,把所有已提交但未刷盘的修改应用到数据页。
  3. 回滚所有未提交的事务。
  4. 恢复完成,数据库接受连接。

这个过程是自动的,不需要人工干预。但如果WAL日志损坏或者丢失,就可能无法恢复,所以WAL的备份非常重要。

查询优化器

PostgreSQL的查询优化器是基于代价的优化器(CBO),它会估算不同执行计划的代价,选择代价最低的执行计划。

查询优化的过程分为几个阶段:

  1. 解析(Parse):把SQL文本解析成语法树。
  2. 分析(Analyze):对语法树进行语义分析,解析表名、列名、类型等,生成查询树。
  3. 重写(Rewrite):根据规则对查询树进行重写,比如视图展开、子查询提升等。
  4. 规划(Plan):优化器生成所有可能的执行计划,估算每个计划的代价,选择最优的执行计划。
  5. 执行(Execute):执行器按照执行计划执行查询,返回结果。

优化器估算代价的依据是统计信息。PostgreSQL通过ANALYZE命令收集表的统计信息,包括:

  • 表的行数。
  • 每列的空值率、唯一值数量、最常见值和频率、直方图等。
  • 索引的统计信息。

优化器根据这些统计信息,估算每个操作的行数和代价。代价包括:

  • 顺序扫描的代价:和表的大小有关。
  • 索引扫描的代价:和索引的高度、选择率有关。
  • 嵌套循环连接的代价:和外表行数、内表扫描代价有关。
  • 哈希连接的代价:和构建哈希表的代价、探测的代价有关。
  • 排序的代价:和排序的数据量有关。

统计信息的准确性直接影响优化器的决策。如果统计信息过时,优化器可能会选择错误的执行计划,导致性能问题。所以,定期ANALYZE很重要。

PostgreSQL 17在查询优化器方面也有一些改进,比如更好的并行查询支持、更准确的代价估算、新的连接策略等。

索引机制

PostgreSQL支持多种索引类型,每种索引适用于不同的场景。

B-Tree索引:最常用的索引类型,支持等值查询和范围查询,适合大多数场景。B-Tree索引是平衡树,查找、插入、删除的时间复杂度都是O(log n)。

Hash索引:只支持等值查询,不支持范围查询。以前Hash索引有bug,不推荐使用,PostgreSQL 10之后修复了,性能和B-Tree差不多,但适用场景有限。

GiST索引:通用搜索树,支持自定义的数据类型和操作符。常用于地理空间数据、全文搜索、范围类型等。

GIN索引:倒排索引,适合多值类型(如数组、全文搜索)。对于包含查询(比如数组包含某个元素),GIN索引比B-Tree高效得多。

BRIN索引:块范围索引,适合大表、数据按某个列有序存储的场景。BRIN索引只存储每个数据块的最小值和最大值,体积很小,查询时可以跳过不相关的块。

SP-GiST索引:空间分区GiST,适合非平衡的数据结构,比如前缀树、四叉树。

索引的原理是:通过索引找到满足条件的元组的物理位置(ctid),然后回表读取元组。如果索引能覆盖查询需要的所有列,就不需要回表,这叫覆盖索引,性能更好。

PostgreSQL 17在索引方面也有一些改进,比如更好的索引维护性能、支持更多的索引类型等。

VACUUM和自动清理

前面提到,PostgreSQL的UPDATE和DELETE不是真正删除数据,而是标记旧元组为死元组。这些死元组需要定期清理,否则会导致表膨胀。

VACUUM就是用来清理死元组的命令。VACUUM有两种模式:

  • 标准VACUUM:清理死元组,释放空间,但空间不会还给操作系统,而是保留在表中供后续插入使用。标准VACUUM可以和其他操作并发执行。
  • VACUUM FULL:彻底清理表,把死元组都删掉,把空间还给操作系统。但VACUUM FULL会锁表,不能和其他操作并发,而且需要额外的磁盘空间。

autovacuum是PostgreSQL的自动清理进程,会根据配置自动触发VACUUM和ANALYZE。相关的配置参数有:

  • autovacuum:是否开启自动清理,默认开启。
  • autovacuumvacuumthreshold:触发VACUUM的死元组数量阈值。
  • autovacuumvacuumscale_factor:触发VACUUM的死元组比例阈值。
  • autovacuumanalyzethreshold:触发ANALYZE的修改行数阈值。
  • autovacuumanalyzescale_factor:触发ANALYZE的修改行比例阈值。

合理配置autovacuum很重要。如果autovacuum太激进,会占用大量资源,影响业务;如果太保守,死元组清理不及时,会导致表膨胀。

PostgreSQL 17在VACUUM方面也有改进,比如更快的VACUUM速度、更好的冻结机制、减少不必要的VACUUM等。

并发控制和锁

PostgreSQL的并发控制,除了MVCC,还有锁机制。

PostgreSQL的锁分为几种:

  • 表级锁:LOCK TABLE命令获取,包括ACCESS SHARE、ROW SHARE、ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE。不同的操作会获取不同级别的表锁,锁之间有兼容和冲突关系。
  • 行级锁:SELECT FOR UPDATE、SELECT FOR SHARE等命令获取,锁定特定的行。行级锁不会阻塞读,只会阻塞写。
  • 咨询锁(Advisory Lock):用户自定义的锁,用于应用层的并发控制。
  • 死锁检测:PostgreSQL会自动检测死锁,回滚其中一个事务。

理解锁的兼容性很重要。比如,SELECT会获取ACCESS SHARE锁,和所有锁都兼容(除了ACCESS EXCLUSIVE)。所以SELECT不会阻塞任何操作。而ALTER TABLE会获取ACCESS EXCLUSIVE锁,和所有锁都冲突,所以ALTER TABLE期间表不能被访问。

行级锁是在元组上设置的,通过xmax和标志位实现。当一个事务更新一行时,会把这行的xmax设为自己的事务ID,并设置锁标志。其他事务更新这行时,发现xmax是活动事务,就会等待。

PostgreSQL 17在锁方面也有一些改进,比如更细粒度的锁、减少锁冲突、提高并发性能等。

复制和高可用

PostgreSQL支持多种复制方式,用于高可用和读写分离。

物理复制(流复制):基于WAL日志的复制,主库把WAL日志发送给从库,从库重放WAL日志,保持和主库的数据一致。物理复制是块级别的复制,从库的数据和主库完全一样。

物理复制分为:

  • 同步复制:主库提交时,等待至少一个从库收到WAL日志,才返回成功。数据安全性高,但性能有损失。
  • 异步复制:主库提交时,不需要等待从库,直接返回成功。性能好,但主库崩溃时可能丢失少量数据。

逻辑复制:基于逻辑解析的复制,把WAL日志解析成逻辑变更(INSERT、UPDATE、DELETE),发送给从库。逻辑复制是表级别的,可以只复制部分表,也可以跨大版本复制。

高可用方案通常用流复制+自动故障切换。常用的工具有Patroni、Repmgr、pgautofailover等。这些工具会监控主库的状态,主库挂了之后自动提升从库为主库,实现高可用。

PostgreSQL 17在复制方面也有改进,比如更快的逻辑复制、更好的监控、更灵活的复制配置等。

PostgreSQL 17的新特性

最后简单说说PostgreSQL 17的一些重要新特性。

第一个是,性能提升。PostgreSQL 17在查询优化、并行执行、VACUUM、索引维护等方面都有性能改进,尤其是在大数据量和高并发场景下。

第二个是,新的数据类型和函数。PostgreSQL 17增加了一些新的数据类型和函数,增强了对JSON、数组、范围类型等的支持。

第三个是,安全增强。加强了权限控制、加密支持、审计功能。

第四个是,可观测性提升。增加了更多的监控视图和统计信息,方便DBA排查问题。

第五个是,复制和高可用改进。逻辑复制更强大,故障切换更可靠。

当然,PostgreSQL 17的具体特性,建议参考官方发布说明,这里只是一个概览。

写在最后

PostgreSQL是一个非常强大的数据库,它的底层设计充满了智慧。深入理解它的原理,不仅能帮助我们更好地使用它、优化它,也能让我们学到很多数据库设计的思想。

这篇文章只是一个概览,每个子系统都可以展开写很多。如果你对某个部分感兴趣,建议深入研究官方文档和源码,那才是最权威的资料。

数据库是后端开发的基础,把数据库搞懂了,很多问题就迎刃而解了。希望这篇文章能给你一些启发,也欢迎大家在评论区交流PostgreSQL的使用经验。