9.1第54章
本章对 PostgreSQL 数据库使用的物理格式进行一个概述。
54.1. 数据库文件布局
本节在文件和目录的层次上描述存储格式。
数据库集群所需要的所有数据都存储在集群的数据目录里,通常用 PGDATA 来引用(用的是可以定义之的环境变量的名字)。 PGDATA 的一个常见位置是 /var/lib/pgsql/data。不同 postmaster 管理的多个集群,可以在同一台机器上共存。
PGDATA 目录包含几个子目录以及一些控制文件,在 Table 50-1 里面显示。除了这些必要的东西之外,集群的配置文件 postgresql.conf,pg_hba.conf,和 pg_ident.conf 通常都存储在 PGDATA (不过从 PostgreSQL 8.0 开始以上的版本,我们可以把它们放在别的地方。)
Table 50-1. PGDATA的内容 项 描述 PG_VERSION 一个包含 PostgreSQL 主版本号的文件 base 包含每个数据库对应的子目录的子目录 global 包含集群范围的表的子目录,比如pg_database pg_clog 包含事务提交状态数据的子目录 pg_multixact 包含多重事务(multi-transaction)状态数据的子目录(用于共享的行锁) pg_subtrans 包含子事务状态数据的子目录 pg_tblspc 包含指向表空间的符号链接的子目录 pg_twophase 包含用于准备好事务状态文件的子目录 pg_xlog 包含 WAL (预写日志)文件的子目录 postmaster.opts 一个记录 postmaster 最后一次启动时使用的命令行参数的文件 postmaster.pid 一个锁文件,记录着当前的 postmaster PID 和共享内存段 ID (在 postmaster 关闭之后不存在)
对于集群里的每个数据库,在 PGDATA/base 里都有一个子目录对应,子目录的名字是该数据库在 pg_database 里的 OID。这个子目录是该数据库文件的缺省位置;特别值得一提的是,该数据库的系统表存储在此。
每个表和索引都存储在独立的文件里,以该表或者该索引的 filenode 号命名,我们可以在 pg_class.relfilenode 找到。
Caution
请注意,虽然一个表的 filenode 通常和它的 OID 相同,但实际上并不必须如此;有些操作,比如 TRUNCATE,REINDEX,CLUSTER 以及一些特殊的 ALTER TABLE 形式,都可以改变 filenode 而同时保留 OID。我们不应该假设 filenode 和表 OID 相同。
在表或者索引超过 1Gb 之后,它就被分裂成一 G 大小的段。第一个段的文件名和 filenode 相同;随后的段名名为 filenode.1,filenode.2,等等。这样的安排避免了在某些有文件大小限制的平台上的问题。表和索引的内容在 Section 50.3 里有进一步讨论。
一个表如果有些字段里面可能存储相当大的数据,那么就会有个相关联的 TOAST 表,用于存储无法在表的数据行中放下的太大的线外数据。如果有的话,pg_class.reltoastrelid 从一个表链接到它的 TOAST 表。参阅 Section 50.2 获取更多信息。
表空间把情况搞得更复杂些。每个用户定义的表空间都在 PGDATA/pg_tblspc 目录里面有一个符号连接,它指向物理的表空间目录(就是在 CREATE TABLESPACE 命令里声明的那个目录)。这个符号连接是用表空间的 OID 命名的。在物理表空间里面包含多个子目录,每个子目录对应着一个在这个表空间里有元素的数据库,并且该子目录以那个数据库的 OID 命名该目录里的表遵循 filenode 的命名规则。 pg_default 没有通过 pg_tblspc 关联,但是对应 PGDATA/base。类似的还有,pg_global 没有通过 pg_tblspc 关联,而是对应 PGDATA/global。
54.2. TOAST
本节提供 TOAST 的一个概述(超尺寸字段存储技巧-The Oversized-Attribute Storage Technique)。
因为 PostgreSQL 的页面大小是固定的(通常是8Kb),并且不允许元组跨越多个页面,因此不可能直接存储非常大的字段值。在 PostgreSQL 7.1 之前,代码里有一个硬限制,限制了一个表中一个数据行存储的数据的总大小为刚好略小于一个页面。从版本 7.1 以及以后的版本开始,这个限制被克服了,方法是允许大的字段值被压缩和/或打碎成多个物理行。这些事情对用户都是透明的,只是在后端代码上有一些小的影响。这个技术的爱称是TOAST(或者"切片面包之后的最好的东西")。
只有一部分数据类型支持 TOAST — 我们没必要在那些不可能生成大的字段值的数据类型强制这种额外开销。要支持 TOAST,数据类型必须有变长(varlena)的表现形式,这个时候,任何存储的数值的头 32 位都是存储着以字节记的数值的总长度(包括长度本身)。 TOAST 并不约束剩下的表现形式。所有支持可以 TOAST 的数据类型之 C 级别的函数都必须仔细处理 TOAST 的输入值。(通常是在对一个输入值做任何事情之前,调用PG_DETOAST_DATUM;但是在某些情况下,更高效的方法也是存在的。)
TOAST 使用变长的长度字的最高两个二进制位,这样就把任何可以TOAST的数据类型的逻辑长度限制在1Gb(230 - 1 字节)。如果两个位都是零,那么数值是该数据类型一个普通的未TOAST的数值。如果其中一个位置了一,那么表示该数值被压缩过,使用前必须先解压缩。如果设置了另外一个位,则表示该数值是在线外存储的。这个时候,该值剩下的部分只是一个指针,而正确的数值必须在其他地方查找。如果两个位都设置上了,那么这个线外数据也被压缩过了。不管哪种情况,长度字里剩下的低位都表示数据的实际尺寸,而不是解压缩或者从线外数据抓过来之后的逻辑尺寸。
如果一个表中有任何一个字段是可以TOAST的,那么该表将有一个关联的TOAST表,其 OID 存储在表的pg_class.reltoastrelid 记录里,线外TOAST过的数值保存在TOAST表里,下面有更详细的描述。
这里使用的压缩技术是非常简单并且非常快速的 LZ 族压缩技巧。参阅 src/backend/utils/adt/pg_lzcompress.c 获取细节。
线外数据被分裂成(如果压缩过,在压缩之后)最多TOAST_MAX_CHUNK_SIZE (这个数值略小于BLCKSZ/4,或者缺省 2000 字节)字节的块,每个块都作为独立的行在TOAST表里为所属表存储。每个TOAST表都有字段chunk_id(一个表示特定的TOAST过之数据的 OID), chunk_seq(一个序列号,存储该块在数值中的位置),和一个chunk_data (该块实际的数据)。在chunk_id和chunk_seq上有一个唯一索引,提供对数值的快速检索。因此,一个表示线外TOAST过的数值的指针数据需要存储要查阅的TOAST的OID 和特定数值的OID(它的chunk_id)。为了方便,指针数据还存储逻辑数据的尺寸(原始的未压缩的数据长度)以及实际存储的尺寸(如果使用了压缩,则两者不同)。加上头部的长度字,一个TOAST指针数据的总尺寸是20字节,不管它代表的数值的实际长度是多大。
TOAST 代码只有在准备向某表中存储超过BLCKSZ/4字节(通常是2Kb)的行的时候才会触发。 TOAST 代码将压缩和/或线外存储字段值,直到数值比BLCKSZ/4字节短,或者无法得到更好的结果的时候才停止。在一个 UPDATE 操作过程中,未改变的字段的数值通常原样保存;所以,如果 UPDATE 一个带有线外数据的行时,假如线外数据值没有变化,那么将不会有TOAST开销存在。
TOAST代码识别四种不同的存储可TOAST字段的策略:
- PLAIN避免压缩或者线外存储。 这只是对那些不能TOAST的数据类型才有可能。
- EXTENDED允许压缩和线外存储。 这是大多数可以TOAST的数据类型的缺省。 首先将企图进行压缩,如果行仍然太大,那么则进行线外存储。
- EXTERNAL 允许线外存储,但是不许压缩。 使用 EXTERNAL 将令那些在 text 和 bytea 字段上的子字串操作更快(代价是增加了存储空间), 因此这些操作是经过优化的:如果线外数据没有压缩,那么它们只会去抓取需要的部分。
- MAIN 允许压缩,但不允许线外存储。 (实际上,在这样的字段上仍然会进行线外存储,但只是作为没有办法把数据行变得更小的情况下的最后的手段。)
每个可以 TOAST 的数据类型都为该数据类型的字段声明一个缺省策略,但是特定表的字段的存储策略可以用 ALTER TABLE SET STORAGE 修改。
这个方法比那些更直接的方法,比如允许行数值直接跨越多个页面,有更多优点。假设查询通常是用相对比较短的键值进行匹配的,那么大多数执行器的工作都将使用主行记录完成。 TOAST 过的属性的大的数值只是在把结果集发送给客户端的时候才抽出来(如果选择了它的话)。因此,主表要小得多,并且它的大部分行都存储在共享缓冲区里,因此就可以不需要任何线外存储。排序集也缩小了,并且排序将更多地在内存里完成。一个小测试表明,一个用于保存 HTML 页面以及它们的 URL 的表,在存储数据的时候将在包括 TOAST 表在内存储将近一半大小的裸数据,而主表只包含全部数据的 10%(URL和一些小的 HTML 页面)。与在一个非 TOAST 的伴侣表里面存储(把全部 HTML 页面裁剪成 7Kb 以匹配页面大小),没有任何运行时的区别。
54.3. 数据库分页文件
本章提供一个 PostgreSQL 的表和索引所使用的页面格式的概述。 [1] 序列和TOAST的格式与普通表一样。
在下面解释中,假定一个字节包含 8 个位。另外,项(item)指的是存储在一个页面里的独立数据值。在一个表里,一个项是一个行;在一个索引里,一个项是一条索引记录。
每个表和索引都以一个固定尺寸(通常是 8K,当然我们可以在编译的时候选择其他的尺寸)的 pages 数组存储。在表里,所有页面逻辑上都相同,所以一个特定的项(行)可以存储在任何页面里。在索引里,第一个页面通常保留为元页面,保存着控制信息,并且依索引访问方法的不同,在索引里可能有不同类型的页面。
Table 50-2显示一个页面的总体布局。每个页面有五个部分。
Table 50-2. 总体页面布局 项 描述 PageHeaderData 20 字节长。包含关于页面的一般信息,包括自由空间指针。 ItemPointerData 一个记录(偏移量,长度)配对对的数组,指向实际项。每个项 4 字节。 Free space(自由空间) 未分配的空间。新项指针从这个区域的开头开始分配,新项从结尾开始分配。 Items(项) 实际的项本身。 Special Space(特殊空间) 索引访问模式相关的数据。不同的索引访问方式存放不同的数据。在普通表中为空。
每个页面的头20个字节组成页头(PageHeaderData)。它的格式在 Table 50-3 里详细介绍。 头两个字节跟踪与此页面相关的最近的 WAL 项。 然后跟着三个 2 字节的整数字段 (pd_lower,pd_upper, 和 pd_special)。 这些字段分别包含页面开始位置与未分配空间开头的字节偏移,与未分配空间结尾的字节偏移, 以及与特殊空间开头的字节偏移。 页面头的最后 2 字节,pd_pagesize_version, 存储页面尺寸和版本指示器。从 PostgreSQL 8.1 开始, 版本号是 3;PostgreSQL 8.0 使用版本号 2; PostgreSQL 7.3 和 7.4 使用版本号 1; 以前的版本使用版本号 0。 (基本页面布局和头格式在这些版本里都没有改变,但是堆的行头部布局有所变化。) 页面大小主要用于交叉检查;目前在一次安装里,还没有支持多于一种页面大小的东西。
Table 50-3. PageHeaderData 布局 字段 类型 长度 描述 pd_lsn XLogRecPtr 8 字节 LSN: 最后修改这个页面的 xlog 记录最后一个字节后面第一个字节 pd_tli TimeLineID 4 字节 最后修改的 TLI pd_lower LocationIndex 2 字节 到自由空间开头的偏移量 pd_upper LocationIndex 2 字节 到自由空间结尾的偏移量 pd_special LocationIndex 2 字节 到特殊空间开头的偏移量 pd_pagesize_version uint16 2 字节 页面大小和布局版本号信息
所有细节都可以在 src/include/storage/bufpage.h 里找到。
在页头后面是项标识符(ItemIdData),每个需要四个字节。 一个项标识符包含一个到项开头的字节偏移量,它自己以字节计的长度, 以及一套属性位,这些属性位影响它的解释。 新的项标识符根据需要从未分配空间的开头分配。 项标识符的数目可以通过查看 pd_lower 来判断,在分配新标识符的时候会递增。 因为一个项标识符在其释放前绝对不会移动,所以它的索引可以用于长时间地引用一个项, 即使该项本身因为压缩自由空间在页面内部进行了移动也如此。实际上,PostgreSQL 创建的每个指向项的指针(ItemPointer,也叫做 CTID)都由一个页号和一个项标识符的索引组成。
项本身存储在从未分配空间末尾开始从后向前分配的空间里。 它们的实际结构因表包含的内容不同而不同。表和序列都使用一种叫做 HeapTupleHeaderData 的结构,在下面描述。
最后一段是"特殊段",它可以包含任何访问方法想存放的东西。 比如,b-tree 索引存储指向页面的左右同宗的链接,以及其他一些和索引结构相关的数据。 普通表并不使用这个段(通过设置 pd_special 等于页面大小来表示)。
所有表行都用同样方法构造。它们有一个定长的头(在大多数机器上占据 27 个字节), 后面跟着一个可选的 null 位图,一个可选的对象 ID 字段,以及用户数据。 头在 Table 50-4 里详细描述。 实际用户数据(行的字段)从 t_hoff 标识的偏移量开始, 它必须是该平台的 MAXALIGN 距离的倍数。null 位图只有在 t_infomask 里面的 HEAP_HASNULL 位设置了的时候才出现。 如果它出现了,那么它紧跟在定长头后面, 占据足够容纳每个数据字段对应一个位的字节数(也就是说,总共 t_natts 位)。 在这个位列里面,为 1 的位表示非空,而为 0 的位表示空。 如果没有出现这个位图,那么所有数据字段都假设为非空的。 对象 ID 只有在设置了 t_infomask 里面的 HEAP_HASOID 位的时候才出现。 如果出现,它正好出现在 t_hoff 范围之前。 如果需要补齐 t_hoff,使之成为 MAXALIGN 的倍数,那么这些填充将出现在 null 位图和对象 ID 之间。 (这样也保证了对象 ID 得到恰当的对齐。)
Table 50-4. HeapTupleHeaderData 布局 字段 类型 长度 描述 t_xmin TransactionId 4 字节 插入 XID 戳记 t_cmin CommandId 4 字节 插入 CID 戳记 t_xmax TransactionId 4 字节 删除 XID 戳记 t_cmax CommandId 4 字节 删除 CID 戳记(与 t_xvac 重叠) t_xvac TransactionId 4 字节 用于移动行版本操作的 VACUUM 的 XID t_ctid ItemPointerData 6 字节 这个或者新行的当前 ID t_natts int16 2 字节 字段数目 t_infomask uint16 2 字节 各种标志位 t_hoff uint8 1 字节 到用户数据的偏移量
所有细节都可以在 src/include/access/htup.h 中找到。
对具体数据的解释只能在从其它表中获取的信息的情况下进行, 这些信息大多数在 pg_attribute 里。 标识字段位置的关键数值是 attlen 和 attalign。 我们没有办法直接获取某个字段,除非它们是定宽并且没有空值的。 所有这些复杂的操作都封装在函数 heap_getattr, fastgetattr 和 heap_getsysattr 里。
要读取数据的话,你需要轮流检查每个字段。首先根据 null 位图检查该字段是否为 NULL。 如果是,那么跳到下一个字段。然后保证你的对齐是正确的。 如果字段是一个定宽字段,那么所有字节都简单地放在那里。 如果它是一个变长字段(attlen = -1),那么它就会更加复杂一些。 所有变长数据类型都使用一个通用的头结构 varattrib, 它包含所存储的数据的全长以及一些标志位。 根据标志的不同,数据可能是内联的或者是在其它表中(TOAST),还可能是压缩的(参阅Section 50.2)。 Notes [1]
实际上,索引访问模式并不需要使用这些页面格式。目前,所有索引方法的确都使用这个基本格式,但保留在索引元数据页里的数据通常并不准确地遵循项布局规则。)