Open mrdrivingduck opened 2 years ago
PostgreSQL 内置提供了 psql 作为命令行工具,是用户能够与 PostgreSQL 进行交互:
PostgreSQL 自己本身不带图形接口,但是有很多开源或商业的图形界面。
PostgreSQL 提供两套客户端接口:
多进程 架构。PostMaster 是中央协调进程,负责系统初始化:
也负责系统关闭。另外,还管理与客户端的连接:为每一个新连接的客户端分配一个 backend server 进程。因此 postmaster 进程需要不断监听端口。PostgreSQL 是一个 process-per-connection 模型。
除了 postmaster 和负责对接客户端的 backend server 进程以外,PostgreSQL 还有一些后台工作进程:
在内存管理上,分为进程 local memory 和 shared memory。内存中的 buffer pool 被放置在共享内存中,同时需要被多个进程共享的 lock 等信息也会放在共享内存里。由于使用共享内存,因此 PostgreSQL 只能运行在单机上,如果没有第三方的支持。但是现在很多 PostgreSQL 都被构建为 shared-nothing 的并行数据库。
PostgreSQL 依赖于文件系统的文件作为存储,而不是直接使用物理磁盘分区上的 raw 数据。PostgreSQL 维护了一堆 目录 用于存放文件,这些目录被称为 tablespace。每个 PostgreSQL 安装完都会被初始化一个 default tablespace。
这种存储系统的设计导致了一些性能局限 - double buffering。文件块首先被操作系统读取到内核空间的文件系统缓存中,然后再被拷贝到 PostgreSQL 共享内存的 buffer pool 中。另一个局限是 PostgreSQL 的 block 是 8KB 的,与 OS 内核的 block 大小不一致。
但是现在很多企业都使用了外部存储系统,这种存储系统的性能优化独立于数据库,因此 PostgreSQL 可以直接享受这些系统带来的性能提升。
在 PG 中,表内的元组被存放在所谓 heap file 中。其格式是一个 page header,后续跟上一个数组的 line pointer。Line pointer 中包含元组在 page 内的 offset 和长度。实际的元组从 page 的结尾处开始倒序存放:
Heap file 中的每一个记录都由 tuple ID (TID) 标识:
由于 MVCC 的存在,表中可能会有同一个 tuple 的多个版本,每个元组都有其有效的开始时间和结束时间。删除操作不会立刻删除元组,而是更新元组的结束时间;更新操作不会修改一个元组,而是将旧版本的元组复制一份,旧版本的结束时间被设置为小于新版本的开始时间。无法被任何事务可见的旧元组会在之后被彻底删除,从而引发 page 中有洞。可以对 tuple 进行压实,从而腾出一些空间,同时不影响元组的 TID 值。
物理元组的长度由 page 的大小决定,PG 是无法跨页存储元组的。对于大元组,PG 提供 TOAST 机制,对元组进行压缩,并切分存储到专门的位置。如果压缩后还是存不下,存储在 toast 属性中的数据将会变为只想外部关联 toast 表的指针;在 toast 表中,元组将会被切分为小块 (一页至少能放四块),每块都是独立的一行。
PG 中的所有索引类型也是用 slotted-page 格式。
include
关键字实现,只有 B-tree 支持这个过程中可能会有并发的元组更新。因此创建索引的过程中会上锁防止一切对元组的更新,这会导致表无法并发更新。create index concurrently
支持创建索引时允许表被并发更新,它将会扫描基表两次:
只使用索引就可以返回结果,不需要回表。因为 PG 中的索引都是 二级索引,与堆表存放在不同的文件中。如果想要执行 index-only scan,那么查询中只能引用索引中存在的列,且索引类型需要能够支持 index-only scan;或者可以用到 covering indices。
在 PG 中,使用 index-only scan 并不能保证不需要回表,因为 PG 中的元组是多版本的,索引中包含的数据可能对于执行扫描的事务来说不可见。元组的可见性信息存放在堆表中,因此需要进行表扫描得到元组的可见信息。PG 提供了一种优化:对于每一个堆表,维护一个 visiability map (每个 page 只占两 bit,I/O 很小),追踪每一个页中的元组是否对所有事务可见:
如果页内的元组对所有事务都可见,那么 index-only scan 就不必访问堆表来判断元组是否可见了;否则 index-only scan 需要访问一下堆表,从而退化为 index scan。
分区表能够在查询包含分区列时提高性能,能够只扫描一个或一小部分分区,同时也可以减少批量加载和删除的开销。分区表也使 vacuum 或 reindex 等维护性操作更快。分区表上的索引也比全局索引更小,从而能够 fit in memory。分区类型:
分区表可以通过 表继承 或 显式分区声明 实现。不支持外键引用、不支持将一个已有的表转换为分区表,以及反向操作。
规则重写。比如视图,将会根据视图定义把视图查询转换为 select 查询。规则全部被保存在 catalog 中。重写首先处理 update/delete/insert,最终处理 select。可能需要重复或递归处理,直到没有任何规则可以被应用为止。
每一个 block 都被视为一个隔离的部分产生 plan,顺序为从底向上,从重写后的计划的最里面的子查询开始进行。PG 的优化器大部分是基于代价的,代价模型的输入为 I/O 开销和 CPU 开销。查询的优化可以通过以下两种途径进行:
优化的结果是一个 query plan,是一棵关系代数运算符节点的树,节点可以是一元/二元/n 元的。
代价模型的核心是准确估算每一个运算符节点需要处理的元组数量,估算基于每一个 relation 上的统计信息来进行:
DBA 需要周期性地运行 analyze 来保持统计信息的有效性。
执行器基于 demand-driven 的流水线模型,每个运算符节点都需要实现迭代器接口:
一些重要概念:
利用多 CPU 核心完成查询。由优化器负责决定是否使用并行,以及并行进程的数量。在 PG 11 中,只有 只读查询 可以使用并行。
当使用并行时,backend 进程负责协调:
并行查询计划包含以下两种节点,它们都只会有一个孩子节点:
协调进程和工作进程通过共享内存区来进行通信。
并行支持的操作:
触发器和约束不是在 rewrite 阶段实现,而是在执行器中实现的。
在对元组进行选择运算和投影元算时,实际上是通过解释执行的方式实现的。对于 JIT 来说,可以根据运行时的元组数据类型,直接把函数编译为 native code。在 PG 11 中,使用 JIT 来处理 表达式验证 和 tuple deforming (元组的磁盘表示转换为内存表示) 的工作,因为它们都是 per-tuple 的操作,会很频繁。
JIT 对于长时间占用 CPU 的查询较有优势。在优化器中,根据代价估算以及一些阈值决定是否启用 JIT。PG 使用 LLVM 实现 JIT,以动态链接的方式按需加载。PG 11 中默认不开启。
PostgreSQL 的事务管理同时使用以下两种协议:
SQL 标准定义了三种等级的弱一致性,以允许更高的并发。应用可以根据需要选择一些非最强保证。SQL 标准根据违反串行化的现象来定义 隔离级别:
PG 用户可以使用四种隔离级别中的任意一种,但是 PG 实际上只实现了三种隔离级别 (后三种),默认隔离级别为 Read Committed。
快照隔离的具体实现:多版本并发控制 (MVCC)。核心思想:在同一时间维护每一行的不同版本。在执行命令之前,事务只会选择:
MVCC 带来的特性是:读不阻塞写,写不阻塞读。冲突只有可能发生在两个 writer 更新同一行时。光有快照隔离并不能满足串行化。使用 SSI (Serilizable Snapshot Isolation) 可以在保留快照隔离的性能优势的同时,保证串行化。在运行时,SSI 会检测并发事务之间的冲突,并在错误发生时回滚事务。
MVCC 的核心是 元组可见性。对于事务 T 来说,元组可见的必要条件为:
这两个条件确保每个事务看到的是一致的数据。PostgreSQL 维护了以下状态信息来高效检查元组的可见性:
pg_xact
:记录当前每个事务状态的日志文件 (in progress / abort / committed)有这些数据后,元组可见的条件可以被改写为:
pg_xact
中该事务已提交pg_xact
中该事务已回滚对于 insert 语句,插入一条元组,并将元组的 xmin 初始化为当前事务 id - 这个过程不需要任何并发控制协议,除非 insert 需要检查约束;对于 delete,主要操作时更新元组的 xmax 为当前事务 id;对于 update,除了这一步,还需要创建一个新元组并初始化 xmin 为当前事务 id,并将旧元组的 forward-link 指向新元组。
对于 select/update/delete 来说,需要使用 MVCC 协议,SnapshotData 的获取时机由隔离级别决定:
对于 read committed 下的 update/delete 来说,事务开始时,元组可能已经被其它正在进行的事务更新,那么就需要等待其它事务的结束:
对于 serializable 下的 update/delete 来说,如果其它事务提交,那么将无法保证串行化,当前等待事务将会回滚报错。另外,为了解决幻读,SSI 使用了 谓词锁。
存储多版本的行可能导致额外的存储开销。一个行版本可以被删除,当且仅当它无法被任何事务可见。元组的删除是由 vacuum 机制实现的。PG 使用了一个后台进程自动对表进行 vacuum:
可以通过同一个页面的死元组指向活元组的优化,避免修改索引。
Vacuum 的两种模式:
MVCC 更适合读比写多的场合;两阶段锁定更适合写较多的场景。
MVCC 无法阻拦一些需要独占表的操作。DDL 遵从严格的两阶段锁定协议来对表上锁。DML 一般使用最低级的三种表级锁,这三种锁互相兼容,因为 MVCC 能够实现这些操作的互相保护。
锁被实现在共享内存中的一个 hash 表中,key 为被锁定对象的签名。如果一个事务想要对另一个事务已经上锁的对象上锁,则需要等待。锁等待是由信号量实现的,每个事务与一个信号量相关联。因此锁等待的实现模式是 per-lock-holder 而不是 per-lock-object。每个事务对应一个信号量,而不是每个锁对象对应一个信号量。
索引使用 page-level 的共享/排他锁,在索引行被读写后立刻释放;另外 hash 索引使用 bucket-level 的共享/排他锁,在整个 bucket 被处理完毕后立刻释放。
PostgreSQL 使用基于 WAL 的恢复来保证原子性和持久性。在 PG 中,恢复不需要做 undo,只需要在 pg_xact
中更新事务状态,就可以使这个事务更新的元组不被其它事务可见。唯一可能发生问题的情况是进程发生了 crash,那么它没有机会更新 pg_xact
中的事务状态。PG 的解决方法是在查看 pg_xact
时,判断事务是否在任意一个进程上运行:如果没有,那么说明执行该事务的进程可能已经 crash,将该事务的状态更新为 aborted。
PG 的 HA 集群是通过 WAL 回放实现的。一台机器处于归档模式,另一台机器处于恢复模式。PG 实现了基于文件的日志迁移,虽然性能开销很低,但是可能存在数据丢失的时间窗口。可以通过设置归档周期,强制使归档中的 PG 切换新的 WAL 文件。
流复制能够使备份机器更加 up to date。不需要等待 WAL 文件被写满,就可以异步 (by default) 将操作发送到备份库上。同步备份也是支持的,主库需要等待备库把 WAL 写到磁盘上后,才可以继续下一个操作。这样提升了数据可靠性,但是吞吐量可能会下降。
除了物理备份,PG 还支持逻辑备份。逻辑备份保存逻辑修改操作,而不是物理操作。逻辑备份使用 发布-订阅 模式,主要用于不同平台、不同版本的 PG 数据库之间。
PG 具有对一些非标准类型的支持,也支持通过 create type
命令创建类型。
一些开源贡献的扩展类型:
PG 将数据库、表、列等信息全部存放在 system catalog 中。其它数据库需要修改硬编码的源代码来扩展新类型,而 PG 只需要向 catalog 中添加行即可。数据类型、函数、access method 等信息也全部存放在 catalog 中。内置类型和扩展类型的唯一区别是:内置类型已经链接在 backend 中,并且被提前注册在了 system catalog 里;而扩展类型则需要被手动注册,动态加载并链接到 backend。
组合类型类似表定义的方式,定义好 C 结构体,定义好读写该类型的 C 函数,然后注册到 catalog 中。
PG 支持函数重载。函数可以使用 SQL 实现,或者其它过程语言,或者 C 语言。
添加索引扩展需要提供运算符的定义:
<
<=
=
>=
>
五种运算符,而 hash 索引只提供 =
PL/pgSQL、PL/Tcl、PL/Perl、PL/Python。
FDW 允许用户连接外部数据源,但可以像数据在数据库内部一样使用。FDW 允许 PG 访问其它关系型数据库、KV 数据库、文件,但大部分扩展都不被 PG 官方支持。PG 官方支持的 FDW 模块有:
copy from
命令contrib
:移植工具、分析工具、插件src/bin
:客户端 (前端) 程序src/pl
:对过程语言的支持src/test
:回归测试src/backend
:PG 后端后端代码组织:
scan.l
gram.y
parse_*.c
analyze.c
/path
:决定 join 表的顺序/plan
:产生执行计划/geqo
:处理大量表连接的遗传优化算法扩展功能可以被优雅地实现为向 pg_*
表中插入条目。
实现了层次化的 memory context,一次调用就可以释放特定 memory context 中所有的对象。这样不仅更加高效,还可以避免内存泄漏的问题。
常见的 memory context 有:
Memory context 的基本操作:
MemoryContextCreate()
:创建MemoryContextSwitchTo()
:切换MemoryContextAlloc()
:在上下文内分配一大块内存MemoryContextDelete()
:释放并删除当前 memory context 中的所有对象MemoryContextReset()
:释放所有已分配的对象,但不删除 memory context 自身其中 reset/delete 将会自动 reset/delete 所有 children memory context。
PG 使用了支持 单继承 的对象系统。所有节点的基类:
typedef struct {
NodeTag type;
} Node;
所有节点都需要把 Node
放在结构体定义的第一位,这样就有了类似继承的效果。
makeNode()
:创建节点IsA()
:运行时类型判定equal()
:判断两个节点是否相等copyObject()
:深拷贝一个节点nodeToString()
:序列化一个节点stringToNode()
:反序列化一个节点任何节点类型都可以通过指针类型强制转换得到一个 Node *
指针,并能够访问到节点类型。而将基类指针转换为子类指针则需要经过运行时判断 - castNode
会对 NodeTag
进行判断决定是否可以转换;或者在调用 IsA()
之后直接进行转换。
Datum 是用来存放内部表示的通用数据类型,可以存放值,也可以存放指针。使用 Datum 的代码必须知道它的数据类型,因为 Datum 本身只存放值。内核里提供了很多基本数据类型和 Datum 相互转换的宏。
一个 tuple 包含一系列的 Datum。HeapTupleData
是指向元组的内存数据结构,其中包含了元组的长度,以及指向 tuple header 的指针。该指针有如下几种指向元组的方式:
HeapTupleData
结构体之后的位置HeapTupleData
结构体不相邻的 palloc chunkMinimalTuple
是 HeapTuple
的一种替代表示,用于在执行器内部转移元组,其中的一些事务状态信息已经不需要了。这样可以为每个元组省下一些空间。
PG 开发者推荐使用 TupleTableSlot
来访问各种元组,它屏蔽了 tuple 指针指向哪里的细节。
Query
节点ExecutorStart()
CreateExecutorState():创建 per-query 的 memory context
(swith to per-query memory context)
InitPlan()
ExecInitNode()
CreateExprContext():创建 per-tuple 的 memory context
ExecInitExpr()
ExecutorRun()
ExecutePlan()
ExecProcNode()
ExecEvalExpr():在 per-tuple 的 memory context 中调用
ResetExprContext():释放 per-tuple 的 memory context
ExecutorFinish()
ExecutorEnd()
ExecEndPlan()
ExecEndNode()
FreeExecutorState():释放 per-query 的 memory context 及其 child contexts
ereport()
:用于用户可见的错误,与 C++ 的异常处理机制类似,但是使用很多 C 的宏实现的elog()
:追溯调用栈至最近的错误处理块,处理块可以选择处理异常或抛出异常;最顶层的错误处理块将会中止当前事务,并 reset 事务的 memory context如果想要添加新功能,最好的方法是看看类似的系统函数是如何实现的。鼓励使用内核中已经实现的 API 和数据结构:
Chapter 32 为 这本书 的网上章节,详细介绍了 PostgreSQL 的技术细节,甚至还介绍了一些内核代码 😁。对这个章节进行学习能够很快建立 PostgreSQL 的世界观。
PostgreSQL 由 UC Berkeley 的 Michael StoneBraker 教授开发的 POSTGRES 系统发展而来。目前,PostgreSQL 支持:
用户可以使用新的数据类型、函数、运算符、索引来扩展 PostgreSQL。
PostgreSQL 支持大量编程语言 (接口),也支持 JDBC/ODBC 等数据库接口。PostgreSQL 能够运行在所有类 Unix 操作系统下。