运维管理指南
由于IvorySQL基于PostgreSQL开发而成,运维人员在阅读理解本节内容时,建议同时参阅 手册。
1. 升级IvorySQL版本
1.1. 升级方案概述
IvorySQL版本号由主要版本和次要版本组成。例如,IvorySQL 1.3 中的1是主要版本,3是次要版本。
发布次要版本是不会改变内存的存储格式,因此总是和相同的主要版本兼容。例如,IvorySQL 1.3 和Ivory SQL 1.0 以及后续的 Ivory SQL 1.x 兼容。对于这些兼容版本的升级非常简单,只要关闭数据库服务,安装替换二进制的可执行文件,重新启动服务即可。
接下来,我们主要讨论IvorySQL的跨版本升级问题,例如,从 IvorySQL 1.3 升级到 IvorySQL 2.1 。主要版本的升级可能会修改内部数据的存储格式,因此需要执行额外的操作。常用的跨版本升级方法和适用场景如下:
升级方法 |
适用场景 |
停机时间 |
通过pg_dumpall升级数据 |
中小型数据库,例如小于100GB支持跨平台数据迁移 |
取决于数据库的大小 |
pg_upgrade工具 |
大中型数据库,例如大于100GB本机就地升级 |
几分钟 |
逻辑复制 |
大中型数据库,例如大于100GB跨平台支持 |
几秒钟 |
注意事项: 新的主版本通常会引入一些用户可见的不兼容性,因此可能需要应用程序编程上的改变。所有用户可见的更改都被列在 说明中,请特别注意标有“Migration”的小节。尽管你可能从一个主版本升级到另一个,而不用升级中间版本,你应该阅读全部中间版本的主要发行说明。
1.2. 通过pg_dumpall升级数据
传统的跨版本升级方法利用pg_dump/pg_dumpall逻辑备份到处数据库,然后在新版本中通过pg_restore进行还原。导出旧版本数据库时推荐使用新版本的pg_dump/pg_dumpall工具,可以利用最新的并行导出和还原功能,同时可以减少数据库膨胀问题。
逻辑备份与还原非常简单但速度比较慢,停机时间取决于数据库的大小,因此适合中小型数据库的升级。
下面介绍这种升级方法的具体操作,假如当前IvorySQL软件的安装目录位于/usr/local/pgsql,同时数据目录位于/usr/local/pgsql/data,我们在同一台服务器上进行升级。
-
执行逻辑备份之前停止应用程序,确保没有数据更新,因为备份开始后的更新不会被导出。如有必要,可以修改/usr/local/pgsql/data/pg_hba.conf文件禁止其他人访问数据库。然后备份数据库:
pg_dumpall > outputfile
如果已经安装了新版本的IvorySQL,可以使用新版本的pg_dumpall命令备份旧版本数据库。
2.停止旧版本的后台服务:
pg_ctl stop
或者通过其他方式停止后台服务。
3.如果安装目录没有包含特定版本标识,可以将目录改名,必要时可以再修改回来。可以使用类似以下的命令重命名目录:
mv /usr/local/pgsql /usr/local/pgsql.old
4.安装新版本IvorySQL软件,假如安装目录仍然是/usr/local/pgsql。
5.初始化一个新的数据库集群,需要使用数据库专用用户(通常是postgres;如果升级版本,应该已经存在该用户)执行操作:
/usr/local/pgsql/bin/initdb -D /usr/local/pgsql/data
6.将旧版本配置文件pg_hba.conf 和 postgresql.conf 等中的改动在对应的新配置文件中再次进行修改。
7.使用数据库专用用户启动新版本的后台服务:
/usr/local/pgsql/bin/postgres -D /usr/local/pgsql/data
8.最后利用新版本的psql命令还原数据:
/usr/local/pqsql/bin/psql -d postgres -f outputfile
为了减少停机时间,可以将新版本的IvorySQL安装到另一个目录,同时使用不同的端口启动服务。然后同时执行数据库的导出和导入:
pg_dumpall -p 5432 | psql -d postgres -p 5433
执行以上操作时,新旧版本的后台服务同时运行,新版本使用5433端口,旧版本使用5432端口。
1.3. 利用pg_upgrade 工具进行升级
pg_upgrade 工具可以支持IvorySQL跨版本的就地升级。 升级可以在数分钟内被执行,特别是使用—link模式时。它要求和上面的pg_dumpall相似的步骤,例如启动/停止 服务器、运行initdb。pg_upgrade 文档概述了所需的步骤。
2. 管理IvorySQL版本
IvorySQL基于PostgreSQL开发,版本更新频率与PostgreSQL版本更新频率保持一致,每年更新一个大版本,每季度更新一个小版本。IvorySQL目前发布的版本有1.0到3.2,分别基于PostgreSQL 14.0到16.2进行开发,最新版本为IvorySQL 2, 基于PostgreSQL 16.2 进行开发。IvorySQL 的所有版本全部都做到了向下兼容。相关版本特性可以查看 官网。
3. 管理IvorySQL数据库访问
IvorySQL使用 角色 的概念管理数据库访问权限。一个角色可以看成是一个数据库用户或者是数据库用户组,这取决于角色被怎样设置。角色可以拥有数据库对象(例如,表和函数)并且能够把那些对象上的权限赋予给其他角色来控制谁能访问哪些对象。此外,还可以把一个角色中的 成员资格 授予给另一个角色,这样允许成员角色使用被赋予给另一个角色的权限。
角色的概念把“用户”和“组”的概念都包括在内。
数据库角色在概念上已经完全与操作系统用户独立开来。 事实上可能维护一个对应关系会比较方便,但是这并非必需。 数据库角色在一个数据库集簇安装范围内是全局的(而不是独立数据库内)。 要创建一个角色,可使用CREATE ROLE SQL 命令:
CREATE ROLE name;
name遵循 SQL 标识符的规则:或是未经装饰没有特殊字符,或是用双引号包围(实际上,你将总是给该命令要加上额外选项,例如LOGIN。更多细节可见下文)。 要移除一个已有的角色,使用相似的DROP ROLE命令:
DROP ROLE name;
为了方便,createuser和dropuser程序被提供作为这些 SQL 命令的包装器,它们可以从 shell 命令行调用:
createuser name dropuser name
要决定现有角色的集合,检查pg_roles系统目录,例如:
SELECT rolname FROM pg_roles;
psql程序的\du元命令也可以用来列出现有角色。
为了引导数据库系统,一个刚刚被初始化好的系统总是包含一个预定义角色。这个角色总是一个“superuser”,并且默认情况下(除非在运行initdb时修改)它的名字和初始化数据库集簇的操作系统用户相同。习惯上,这个角色将被命名为postgres。为了创建更多角色,你首先必须以初始角色的身份连接。
每一个到数据库服务器的连接都是使用某个特定角色名建立的,并且这个角色决定发起连接的命令的初始访问权限。要使用一个特定数据库连接的角色名由客户端指示,该客户端以一种应用相关的风格发起连接请求。例如,psql程序使用-U命令行选项来指定要以哪个角色连接。很多应用假定该名字默认是当前操作系统用户(包括createuser和psql)。因此在角色和操作系统用户之间维护一个名字对应关系通常是很方便的。
一个给定客户端连接能够用来连接的数据库角色的集合由该客户端的认证设置决定,因此,一个客户端不止限于以匹配其操作系统用户的角色连接,就像一个人的登录名不需要匹配她的真实名字一样。因为角色身份决定一个已连接客户端可用的权限集合,在设置一个多用户环境时要小心地配置权限。
一个数据库角色可以有一些属性,它们定义角色的权限并且与客户端认证系统交互。
把用户分组在一起来便于管理权限常常很方便:那样,权限可以被授予一整个组或从一整个组回收。在IvorySQL中通过创建一个表示组的角色来实现,并且然后将在该组角色中的 成员关系 授予给单独的用户角色。
由于角色可以拥有数据库对象并且能持有访问其他对象的特权,删除一个角色常常并非一次DROP ROLE就能解决。 任何被该用户所拥有的对象必须首先被删除或者转移给其他拥有者,并且任何已被授予给该角色的权限必须被收回。
更多有关数据库访问管理的细节,可以参阅 手册。
4. 定义数据对象
IvorySQL基于PostgreSQL,具有完整的SQL,其定义数据对象可以参考 手册。在此基础之上,IvorySQL为兼容Oracle,还做了一些Oracle专有数据对象的兼容。
5. 查询数据
IvorySQL基于PostgreSQL开发,具有完全的SQL,查询数据具体的操作可以参考 手册。
6. 使用外部数据
IvorySQL实现了部分的SQL/MED规定,允许我们使用普通SQL查询来访问位于IvorySQL之外的数据。这种数据被称为外部数据(注意这种用法不要和外键混淆,后者是数据库中的一种约束)。
外部数据可以在一个外部数据包装器的帮助下被访问。一个外部数据包装器是一个库,它可以与一个外部数据源通讯,并隐藏连接到数据源和从它获取数据的细节。在contrib模块中有一些外部数据包装器,参见 文档。其他类型的外部数据包装器可以在第三方产品中找到。如果这些现有的外部数据包装器都不能满足你的需要,可以自己编写一个,参见 手册。
要访问外部数据,我们需要建立一个外部服务器对象,它根据它所支持的外部数据包装器所使用的一组选项定义了如何连接到一个特定的外部数据源。接着我们需要创建一个或多个外部表,它们定义了外部数据的结构。一个外部表可以在查询中像一个普通表一样地使用,但是在IvorySQL服务器中外部表没有存储数据。不管使用什么外部数据包装器,IvorySQL会要求外部数据包装器从外部数据源获取数据,或者在更新命令的情况下传送数据到外部数据源。
访问远程数据可能需要在外部数据源的授权。这些信息通过一个用户映射提供,它基于当前的IvorySQL角色提供了附加的数据例如用户名和密码。
7. 备份与恢复
由于包含着有价值的数据,IvorySQL数据库应当被定期地备份。虽然过程相当简单,但清晰地理解其底层技术和假设是非常重要的。
有三种不同的基本方法来备份 IvorySQL 数据:
-
SQL转储
-
文件系统级备份
-
连续归档
7.1. SQL转储
SQL 转储方法的思想是创建一个由SQL命令组成的文件,当把这个文件回馈给服务器时,服务器将利用其中的SQL命令重建与转储时状态一样的数据库。 IvorySQL为此提供了工具pg_dump。这个工具的基本用法是:
pg_dump dbname > dumpfile
正如你所见,pg_dump把结果输出到标准输出。我们后面将看到这样做有什么用处。 尽管上述命令会创建一个文本文件,pg_dump可以用其他格式创建文件以支持并行 和细粒度的对象恢复控制。
pg_dump是一个普通的IvorySQL客户端应用(尽管是个 相当聪明的东西)。这就意味着你可以在任何可以访问该数据库的远端主机上进行备份工作。但是请记住 pg_dump不会以任何特殊权限运行。具体说来,就是它必须要有你想备份的表的读权限,因此为了备份整个数据库你几乎总是必须以一个数据库超级用户来运行它(如果你没有足够的特权来备份整个数据库,你仍然可以使用诸如-n schema 或-t table选项来备份该数据库中你能够访问的部分)。
要声明pg_dump连接哪个数据库服务器,使用命令行选项-h host和 -p port。 默认主机是本地主机或你的HOST环境变量指定的主机。 类似地,默认端口是环境变量PORT或(如果PORT不存在)内建的默认值。 (服务器通常有相同的默认值,所以还算方便。)
pg_dump默认使用与当前操作系统用户名同名的数据库用户名进行连接。 要使用其他名字,要么声明-U选项,要么设置环境变量PGUSER。请注意pg_dump的连接也要通过客户认证机制。
pg_dump对于其他备份方法的一个重要优势是,pg_dump的输出可以很容易地在新版本的IvorySQL中载入,而文件级备份和连续归档都是极度的服务器版本限定的。pg_dump也是唯一可以将一个数据库传送到一个不同机器架构上的方法,例如从一个32位服务器到一个64位服务器。
由pg_dump创建的备份在内部是一致的, 也就是说,转储表现了pg_dump开始运行时刻的数据库快照,且在pg_dump运行过程中发生的更新将不会被转储。pg_dump工作的时候并不阻塞其他的对数据库的操作。(但是会阻塞那些需要排它锁的操作,比如大部分形式的ALTER TABLE)
7.1.1. 从转储中恢复
pg_dump生成的文本文件可以由psql程序读取。 从转储中恢复的常用命令是:
psql dbname < dumpfile
其中dumpfile就是pg_dump命令的输出文件。这条命令不会创建数据库dbname,你必须在执行psql前自己从template0创建(例如,用命令createdb -T template0 dbname)。psql支持类似pg_dump的选项用以指定要连接的数据库服务器和要使用的用户名。参阅psql的手册获取更多信息。 非文本文件转储可以使用pg_restore工具来恢复。
在开始恢复之前,转储库中对象的拥有者以及在其上被授予了权限的用户必须已经存在。如果它们不存在,那么恢复过程将无法将对象创建成具有原来的所属关系以及权限(有时候这就是你所需要的,但通常不是)。
默认情况下,psql脚本在遇到一个SQL错误后会继续执行。你也许希望在遇到一个SQL错误后让psql退出,那么可以设置ON_ERROR_STOP变量来运行psql,这将使psql在遇到SQL错误后退出并返回状态3:
psql --set ON_ERROR_STOP=on dbname < infile
不管怎样,你将只能得到一个部分恢复的数据库。作为另一种选择,你可以指定让整个恢复作为一个单独的事务运行,这样恢复要么完全完成要么完全回滚。这种模式可以通过向psql传递-1或—single-transaction命令行选项来指定。在使用这种模式时,注意即使是很小的一个错误也会导致运行了数小时的恢复被回滚。但是,这仍然比在一个部分恢复后手工清理复杂的数据库要更好。
pg_dump和psql读写管道的能力使得直接从一个服务器转储一个数据库到另一个服务器成为可能,例如:
pg_dump -h host1 dbname | psql -h host2 dbname
重要:pg_dump产生的转储是相对于template0。这意味着在template1中加入的任何语言、过程等都会被pg_dump转储。结果是,如果在恢复时使用的是一个自定义的template1,你必须从template0创建一个空的数据库,正如上面的例子所示。
一旦完成恢复,在每个数据库上运行ANALYZE是明智的举动,这样优化器就有有用的统计数据了。
7.1.2. 使用pg_dumpall
pg_dump每次只转储一个数据库,而且它不会转储关于角色或表空间(因为它们是集簇范围的)的信息。为了支持方便地转储一个数据库集簇的全部内容,提供了pg_dumpall程序。pg_dumpall备份一个给定集簇中的每一个数据库,并且也保留了集簇范围的数据,如角色和表空间定义。该命令的基本用法是:
pg_dumpall > dumpfile
转储的结果可以使用psql恢复:
psql -f dumpfile postgres
(实际上,你可以指定恢复到任何已有数据库名,但是如果你正在将转储载入到一个空集簇中则通常要用(postgres)。在恢复一个pg_dumpall转储时常常需要具有数据库超级用户访问权限,因为它需要恢复角色和表空间信息。如果你在使用表空间,请确保转储中的表空间路径适合于新的安装。
pg_dumpall工作时会发出命令重新创建角色、表空间和空数据库,接着为每一个数据库pg_dump。这意味着每个数据库自身是一致的,但是不同数据库的快照并不同步。
集簇范围的数据可以使用pg_dumpall的—globals-only选项来单独转储。如果在单个数据库上运行pg_dump命令,上述做法对于完全备份整个集簇是必需的。
7.1.3. 处理大型数据库
在一些具有最大文件尺寸限制的操作系统上创建大型的pg_dump输出文件可能会出现问题。幸运地是,pg_dump可以写出到标准输出,因此你可以使用标准Unix工具来处理这种潜在的问题。有几种可能的方法:
使用压缩转储。你可以使用你喜欢的压缩程序,例如gzip:
pg_dump dbname | gzip > filename.gz
恢复:
gunzip -c filename.gz | psql dbname
或者:
cat filename.gz | gunzip | psql dbname
使用split。split命令允许你将输出分割成较小的文件以便能够适应底层文件系统的尺寸要求。例如,让每一块的大小为2G字节:
pg_dump dbname | split -b 2G - filename
恢复:
cat filename* | psql dbname
如果使用GNU split,可能会把它和gzip一起使用:
pg_dump dbname | split -b 2G -−filter='gzip > $FILE.gz'
它可以使用zcat恢复。
使用pg_dump的自定义转储格式。. 如果 IvorySQL 所在的系统上安装了zlib压缩库,自定义转储格式将在写出数据到输出文件时对其压缩。这将产生和使用gzip时差不多大小的转储文件,但是这种方式的一个优势是其中的表可以被有选择地恢复。下面的命令使用自定义转储格式来转储一个数据库:
pg_dump -Fc dbname > filename
自定义格式的转储不是psql的脚本,只能通过pg_restore恢复,例如:
pg_restore -d dbname filename
更多细节可以参阅 手册。
对于非常大型的数据库,你可能需要将split配合其他两种方法之一进行使用。
使用pg_dump的并行转储特性。 为了加快转储一个大型数据库的速度,你可以使用pg_dump的并行模式。它将同时转储多个表。你可以使用-j参数控制并行度。并行转储只支持“目录”归档格式。
pg_dump -j num -F d -f out.dir dbname
你可以使用pg_restore -j来以并行方式恢复一个转储。它只能适合于“自定义”归档或者“目录”归档,但不管归档是否由pg_dump -j创建。
7.2. 文件系统级别备份
另外一种备份策略是直接复制 IvorySQL用于存储数据库中数据的文件,你可以采用任何你喜欢的方式进行文件系统备份,例如:
tar -cf backup.tar /usr/local/pgsql/data
但是这种方法有两个限制,使得这种方法不实用,或者说至少比pg_dump方法差:
-
为了得到一个可用的备份,数据库服务器必须被关闭。例如阻止所有连接的半路措施是不起作用的(部分原因是tar和类似工具无法得到文件系统状态的一个原子的快照,还有服务器内部缓冲的原因)。不用说,在恢复数据之前你也需要关闭服务器。
-
如果你已经深入地了解了数据库的文件系统布局的细节,你可能会有兴趣尝试通过相应的文件或目录来备份或恢复特定的表或数据库。这种方法也不会起作用,因为包含在这些文件中的信息只有配合提交日志文件(pg_xact/*)才有用,提交日志文件包含了所有事务的提交状态。一个表文件只有和这些信息一起才有用。当然也不可能只恢复一个表及相关的pg_xact数据,因为这会导致数据库集簇中所有其他表变得无用。因此文件系统备份值适合于完整地备份或恢复整个数据库集簇。
另一种文件系统备份方法是创建一个数据目录的“一致快照”,如果文件系统支持此功能(并且你相信它的实现正确)。典型的过程是创建一个包含数据库的卷的“冻结快照”,然后从该快照复制整个数据目录(如上,不能是部分复制)到备份设备,最后释放冻结快照。即使在数据库服务器运行时,这种方式也有效。但是,以这种方式创建的备份保存的文件看起来就像数据库没有被正确关闭时的状态。因此,当你从备份数据上启动数据库服务器时,它会认为上一次的服务器实例崩溃了并尝试重放WAL日志。这不是问题,只是需要注意(当然WAL文件必须要包括在备份中)。你可以在拍摄快照之前执行一次CHECKPOINT以便节省恢复时间。
如果你的数据库跨越多个文件系统,可能没有任何方式可以对所有卷获得完全同步的冻结快照。例如,如果你的数据文件和WAL日志放置在不同的磁盘上,或者表空间在不同的文件系统中,可能没有办法使用快照备份,因为快照必须是同步的。在这些情况下,一定要仔细阅读你的文件系统文档以了解其对一致快照技术的支持。
如果没有可能获得同步快照,一种选择是将数据库服务器关闭足够长的时间以建立所有的冻结快照。另一种选择是执行一次连续归档基础备份,因为这种备份对于备份期间发生的文件系统改变是免疫的。这要求在备份过程中允许连续归档,恢复时使用连续归档恢复。
还有一种选择是使用rsync来执行一次文件系统备份。其做法是先在数据库服务器运行时执行rsync,然后关闭数据库服务器足够长时间来做一次rsync --checksum (--checksum是必需的,因为rsync的文件修改 时间粒度只能精确到秒)。第二次rsync会比第一次快,因为它只需要传送相对很少的数据,由于服务器是停止的,所以最终结果将是一致的。这种方法允许在最小停机时间内执行一次文件系统备份。
注意一个文件系统备份通常会比一个SQL转储体积更大(例如pg_dump不需要转储索引的内容,而是转储用于重建索引的命令)。但是,做一次文件系统备份可能更快。
7.3. 连续归档和时间点恢复(PITR)
在任何时间,IvorySQL在数据集簇目录的pg_wal/子目录下都保持有一个预写式日志(WAL)。这个日志存在的目的是为了保证崩溃后的安全:如果系统崩溃,可以“重放”从最后一次检查点以来的日志项来恢复数据库的一致性。该日志的存在也使得第三种备份数据库的策略变得可能:我们可以把一个文件系统级别的备份和WAL文件的备份结合起来。当需要恢复时,我们先恢复文件系统备份,然后从备份的WAL文件中重放来把系统带到一个当前状态。这种方法比之前的方法管理起来要更复杂,但是有其显著的优点:
-
我们不需要一个完美的一致的文件系统备份作为开始点。备份中的任何内部不一致性将通过日志重放(这和崩溃恢复期间发生的并无显著不同)来修正。因此我们不需要文件系统快照功能,只需要tar或一个类似的归档工具。
-
由于我们可以结合一个无穷长的WAL文件序列用于重放,可以通过简单地归档WAL文件来达到连续备份。这对于大型数据库特别有用,因为在其中不方便频繁地进行完全备份。
-
并不需要一直重放WAL项一直到最后。我们可以在任何点停止重放,并得到一个数据库在当时的一致快照。这样,该技术支持时间点恢复: 在得到你的基础备份以后,可以将数据库恢复到它在其后任何时间的状态。
-
如果我们连续地将一系列WAL文件输送给另一台已经载入了相同基础备份文件的机器, 我们就得到了一个热后备系统: 在任何时间点我们都能提出第二台机器, 它差不多是数据库的当前副本。
注意: pg_dump 和 pg_dumpall不会产生文件系统级别的备份,并且不能用于连续归档方案。这类转换是逻辑的并且不包含足够的信息用于WAL重放。
就简单的文件系统备份技术来说,这种方法只能支持整个数据库集簇的恢复,却无法支持其中一个子集的恢复。另外,它需要大量的归档存储:一个基础备份的体积可能很庞大,并且一个繁忙的系统将会产生大量需要被归档的WAL流量。尽管如此,在很多需要高可靠性的情况下,它是首选的备份技术。
要使用连续归档(也被很多数据库厂商称为“在线备份”)成功地恢复,你需要一个从基础备份时间开始的连续的归档WAL文件序列。为了开始,在你建立第一个基础备份之前,你应该建立并测试用于归档WAL文件的过程。对应地,我们首先讨论归档WAL文件的机制。关于如何建立归档和备份的方式以及操作过程中的要点,请参阅 手册。
8. 装卸数据
copy 在 IvorySQL表和标准文件之间移动数据。COPY TO 把一个表的内容复制到一个文件,而COPY FROM 则从一个文件复制数据到一个表(把数据追加到表中原有数据)。COPY TO 也能复制一个SELECT查询的结果。
如果指定了一个列列表,COPY TO将只把指定列的数据复制到文件。对于COPY FROM,文件中的每个字段将按顺序插入到指定列中。COPY FROM命令的列列表中没有指定的表列则会采纳其默认值。
带一个文件名的COPY指示IvorySQL服务器直接从一个文件读取或者写入到一个文件。该文件必须是IvorySQL用户(运行服务器的用户ID)可访问的并且应该以服务器的视角来指定其名称。当指定了PROGRAM时,服务器执行给定的命令并且从该程序的标准输出读取或者写入到该程序的标准输入。该程序必须以服务器的视角指定,并且必须是IvorySQL用户可执行的。在指定STDIN或者STDOUT时,数据会通过客户端和服务器之间的连接传输。
运行COPY的每个后端将在pg_stat_progress_copy视图中报告其进度。
8.1. 大纲
COPY table_name [ ( column_name [, ...] ) ]
FROM { 'filename' | PROGRAM 'command' | STDIN }
[ [ WITH ] ( option [, ...] ) ]
[ WHERE condition ]
COPY { table_name [ ( column_name [, ...] ) ] | ( query ) }
TO { 'filename' | PROGRAM 'command' | STDOUT }
[ [ WITH ] ( option [, ...] ) ]
其中 option 可以是下列之一:
FORMAT format_name
FREEZE [ boolean ]
DELIMITER 'delimiter_character'
NULL 'null_string'
HEADER [ boolean ]
QUOTE 'quote_character'
ESCAPE 'escape_character'
FORCE_QUOTE { ( column_name [, ...] ) | * }
FORCE_NOT_NULL ( column_name [, ...] )
FORCE_NULL ( column_name [, ...] )
ENCODING 'encoding_name'
详细参数设置,请参阅 手册。
8.2. 输出
在成功完成时,一个COPY命令会返回一个形为
COPY count
count 是被复制的行数。 注意: 如果命令不是COPY … TO STDOUT或者等效的psql命令\copy … to stdout, psql将只打印这个命令标签。这是为了防止弄混命令标签和刚刚打印的数据。
8.3. 注解
COPY TO只能用于普通表,而不能用于视图,并且不能从子表或子分区复制行。 例如,COPY table TO 复制与SELECT * FROM ONLY table 相同的行。 语法COPY (SELECT * FROM table) TO … 可用于转储一个继承层次结构、分区表或视图中的所有行。
COPY FROM可以被用于普通表、外部表、分区表或者具有INSTEAD OF INSERT触发器的视图。
你必须拥有被COPY TO读取的表上的选择特权, 以及被COPY FROM插入的表上的插入特权。 拥有在命令中列出的列上的特权就足够了。
如果对表启用了行级安全性,相关的SELECT策略将应用于COPY table TO语句。当前,有行级安全性的表不支持COPY FROM。不过可以使用等效的INSERT语句。
COPY命令中提到的文件会被服务器(而不是 客户端应用)直接读取或写入。因此它们必须位于数据库服务器(不是客户 端)的机器上或者是数据库服务器可以访问的。它们必须是 IvorySQL 用户(运行服务器的用户 ID)可访问的并且是可读或者可写的。类似地,用PROGRAM 指定的命令也会由服务器(不是客户端应用)直接执行,它也必须是 IvorySQL 用户可以执行的。 只允许数据库超级用户或者授予了角色pg_read_server_files、 pg_write_server_files及pg_execute_server_program 之一的用户COPY一个文件或者命令, 因为它允许读取或者写入服务器有特权访问的任何文件或者运行服务器有特权访问的程序。
不要把COPY和 psql指令 \copy 弄混。\copy会调用 COPY FROM STDIN或者COPY TO STDOUT,然后读取/存储一个 psql客户端可访问的文件中的数据。 因此,在使用\copy时,文件的可访 问性和访问权利取决于客户端而不是服务器。
我们推荐在COPY中使用的文件名总是 指定为一个绝对路径。在COPY TO的 情况下服务器会强制这一点,但是对于 COPY FROM你可以选择从一个用相对 路径指定的文件中读取。该路径将根据服务器进程(而不是客户端) 的工作目录(通常是集簇的数据目录)解释。
用PROGRAM执行一个命令可能会受到操作系统 的访问控制机制(如 SELinux)的限制。
COPY FROM将调用目标表上的任何触发器 和检查约束。但是它不会调用规则。
对于标识列,COPY FROM命令将总是写上输入数据中提供的列值,这和INSERT的选项OVERRIDING SYSTEM VALUE的行为一样。
COPY输入和输出受到 DateStyle的影响。为了确保到其他 可能使用非默认DateStyle设置的 IvorySQL 安装的可移植性,在使用 COPY TO前应该把 DateStyle设置为ISO。避免转储把 IntervalStyle设置为 sql_standard的数据也是一个好主意,因为负的区间值可能会 被具有不同IntervalStyle设置的服务器解释错误。
即使数据会被服务器直接从一个文件读取或者写入一个文件而不通过 客户端,输入数据也会被根据ENCODING选项或者当前 客户端编码解释,并且输出数据会被根据ENCODING或 者当前客户端编码进行编码。
COPY会在第一个错误处停止操作。这在 COPY TO的情况下不会导致问题,但是 在COPY FROM中目标表将已经收到了一 些行。这些行将不会变得可见或者可访问,但是它们仍然占据磁盘空间。 如果在一次大型的复制操作中出现错误,这可能浪费相当可观的磁盘空间。 你可能希望调用VACUUM来恢复被浪费的 空间。
FORCE_NULL和FORCE_NOT_NULL可以被同时 用在同一列上。这会导致把已被引用的空值串转换为空值并且把未引用的空值 串转换为空串。
8.4. 文件格式
8.4.1. 文本格式
在使用text格式时,读取或写入的是一个文本文件,其中每一行就是表中的一行。一行中的列被定界字符分隔。列值本身是由输出函数产生的或者是可被输入函数接受的属于每个属性数据类型的字符串。在为空值的列的位置使用指定的空值串。如果输入文件的任何行包含比预期更多或者更少的列,COPY FROM将会抛出一个错误。
数据的结束可以表示为一个只包含反斜线-点号(\.)的单一行。从一个文件读取时,数据结束标记并不是必要的,因为文件结束符就已经足够用了。只有使用3.0客户端协议之前的客户端应用复制数据时才需要它。
反斜线字符(\)可以被用在COPY数据中来引用被用作行或者列界定符的字符。特别地,如果下列字符作为一个列值的一部分出现,它们 必须被前置一个反斜线:反斜线本身、新行、回车以及 当前的定界符字符。
COPY TO会不加任何反斜线返回指定的空值串。 相反,COPY FROM会在移除反斜线之前把输入 与空值串相匹配。因此,一个空值串(例如\N)不会与实 际的数据值\N(它会被表示为\\N)搞混。
COPY FROM识别下列特殊的反斜线序列:
序列 |
表示 |
\b |
退格(ASCII 8) |
\f |
换页(ASCII 12) |
\n |
新行(ASCII 10) |
\r |
回车(ASCII 13) |
\t |
制表(ASCII 9) |
\v |
纵向制表(ASCII 11) |
\digits |
反斜线后跟一到三个八进制数字表示该数字代码对应的字节 |
\xdigits |
反斜线加x后跟一到三个十六进制数字表示该数字代码对应的字节 |
当前,COPY TO不会发出一个八进制或十六进制位 反斜线序列,但是它确实把上面列出的其他序列用于那些控制字符。
任何上述表格中没有提到的其他反斜线字符将被当作表示其本身。不过,要注意 增加不必要的反斜线,因为那可能意外地产生一个匹配数据结束标记( \.)或者空值串(默认是\N)的字符串。这些字符串 将在完成任何其他反斜线处理之前被识别。
强烈建议产生COPY数据的应用把数据新行和回车分别 转换为\n和\r序列。当前可以把一个数据回车表示为 一个反斜线和回车,把一个数据新行表示为一个反斜线和新行。不过,未来的发行 可能不会接受这些表示。如果在不同的机器之间(例如从 Unix 到 Windows) 传输COPY文件,它们也很容易受到破坏。
所有反斜杠序列都在编码转换后进行解释。 用八进制和十六进制数字反斜杠序列指定的字节必须在数据库编码中形成有效字符。
COPY TO将用一个 Unix 风格的新行( “\n”)终止每一行。运行在 Microsoft Windows 上的服务器则会输出回车/新行(“\r\n”),不过只对 COPY到一个服务器文件这样做。为了做到跨平台一致, COPY TO STDOUT总是发送“\n”而 不管服务器平台是什么。COPY FROM能够处理以 新行、回车或者回车/新行结尾的行。为了减少由作为数据的未加反斜线的新行 或者回车带来的风险,如果输出中的行结束并不完全相似, COPY FROM将会抱怨。
8.4.2. CSV格式
这种格式选项被用于导入和导出很多其他程序(例如电子表格)使用的逗号 分隔值(CSV)文件格式。不同于 IvorySQL标准文本格式使用的转义 规则,它产生并且识别一般的 CSV 转义机制。
每个记录中的值用DELIMITER字符分隔。如果值包含 定界符字符、QUOTE字符、NULL字符串、 一个回车或者换行字符,那么整个值会被加上QUOTE字符 作为前缀或者后缀,并且在该值内QUOTE字符或者 ESCAPE字符的任何一次出现之前放上转义字符。在输出 指定列中非NULL值时,还可以使用 FORCE_QUOTE来强制加上引用。
CSV格式没有标准方式来区分NULL值和空字符串。 IvorySQL的COPY用引用来处理 这种区分工作。NULL被按照NULL参数字符串输出 并且不会被引用,而匹配NULL参数字符串的非NULL 值会被加上引用。例如,使用默认设置时,NULL被写作一个未 被引用的空字符串,而一个空字符串数据值会被写成带双引号("")。 值的读取遵循类似的规则。你可以用FORCE_NOT_NULL来防止 对指定列的NULL输入比较。你还可以使用 FORCE_NULL把带引用的空值字符串数据值转换成NULL。
因为反斜线在CSV格式中不是一种特殊字符,数据结束标记 \.也可以作为一个数据值出现。为了避免任何解释误会,在 一行上作为孤项出现的\.数据值输出时会自动被引用,并且 输入时如果被引用,则不会被解释为数据结束标记。如果正在载入一个由 另一个应用创建的文件并且其中具有一个未被引用的列且可能具有 \.值,你可能需要在输入文件中引用该值。
8.4.3. 二进制格式
binary格式选项导致所有数据被以二进制格式 而不是文本格式存储/读取。它比文本和CSV格式要 快一些,但是二进制格式文件在不同的机器架构和 IvorySQL 版本之间的可移 植性要差些。还有,二进制格式与数据格式非常相关。例如不能从 一个smallint列中输出二进制数据并且把它读入到一个 integer列中,虽然这样做在文本格式中是可行的。
binary文件格式由一个文件头、零个或者更多个包含 行数据的元组以及一个文件尾构成。头部和数据都以网络字节序表示。
- 文件头
-
文件头由 15 字节的固定域构成,后面跟着一个变长的头部扩展区。
固定域有:
- 签名
-
11-字节的序列PGCOPY\n\377\r\n\0 — 注意 零字节是签名的一个必要的部分(该签名是为了能容易地发现文件被 无法正确处理 8 位字符编码的传输所破坏。这个签名将被行尾翻译过 滤器、删除零字节、删除高位或者奇偶修改等改变)。
- 标志域
-
32-位整数位掩码,用以表示该文件格式的重要方面。位被编号为 从 0 (LSB)到 31(MSB)。 注意这个域以网络字节序存放(最高有效位在前),所有该文件格式 中使用的整数域都是这样。16-31 位被保留用来表示严重的文件格式 问题, 读取者如果在这个范围内发现预期之外的被设置位,它应该 中止。0-15 位被保留用来表示向后兼容的格式问题,读取者应该简单 地略过这个范围内任何预期之外的被设置位。当前只定义了一个标志 位,其他位必须为零:
- 位 16
-
如果为 1,表示数据中包含 OID;如果为 0,则不包含。IvorySQL不再支持Oid系统列,但是格式仍然包含该指示符。
- 头部扩展区长度
-
32-为整数,表示头部剩余部分的以字节计的长度,不包括其本身。 当前,这个长度为零,并且其后就紧跟着第一个元组。未来对该 格式的更改可能会允许在头部中表示额外的数据。如果读取者不知 道要对头部扩展区数据做什么,可以安静地跳过它。
头部扩展区域被预期包含一个能自我解释的块的序列。 该标志域并不想告诉读取者扩展数据是什么。详细的 头部扩展内容的设计留给后来的发行去做。
这种设计允许向后兼容的头部增加(增加头部扩展块或者设置低位标志位)以及 非向后兼容的更改(设置高位标志位来表示这类更改并且在需要时向扩展区域 中增加支持数据)。
- 元组
-
每一个元组由一个表示元组中域数量的 16 位整数计数开始(当前,一个表中 的所有元组都应该具有相同的计数,但是这可能不会总是为真)。然后是元组 中的每一个域,它是一个 32 位的长度字,后面则跟随着这么多个字节的域数 据(长度字不包括其本身,并且可以是零)。作为一种特殊情况,-1 表示一个 NULL 域值。在 NULL 情况下,后面不会跟随值字节。
在域之间没有对齐填充或者任何其他额外的数据。
当前,一个二进制格式文件中的所有数据值都被假设为二进制格式(格式代码一)。 可以预见未来的扩展可能会增加一个允许独立指定各列的格式代码的头部域。
要为实际的元组数据决定合适的二进制格式,你应该参考 IvorySQL源码,特别是用于各列 数据类型的*send和*recv函数(通常可 以在源码的src/backend/utils/adt/目录中找到 这些函数)。
如果文件中包含 OID,OID 域会紧跟在域计数字之后。它是一个普通域, 不过它没有被包含在域计数中。注意IvorySQL当前版本不支持oid系统列。
- 文件尾
-
文件位由一个包含 -1 的 16 位整数字组成。这很容易与一个 元组的域计数字区分开。
如果一个域计数字不是 -1 也不是期望的列数,读取者应该报告错误。 这提供了一种针对某种数据不同步的额外检查。
8.5. 示例
下面的例子使用竖线(|)作为域定界符把一个表复制到客户端:
COPY country TO STDOUT (DELIMITER '|');
从一个文件中复制数据到country表中:
COPY country TO STDOUT (DELIMITER '|');
只把名称以 'A' 开头的国家复制到一个文件中:
COPY (SELECT * FROM country WHERE country_name LIKE 'A%') TO '/usr1/proj/bray/sql/a_list_countries.copy';
要复制到一个压缩文件中,你可以用管道把输出导到一个外部压缩程序:
COPY country TO PROGRAM 'gzip > /usr1/proj/bray/sql/country_data.gz';
这里是一个适合于从STDIN复制到表中的数据:
AF AFGHANISTAN
AL ALBANIA
DZ ALGERIA
ZM ZAMBIA
ZW ZIMBABWE
注意每一行上的空白实际是一个制表符。
下面是用二进制格式输出的相同数据。该数据是用 Unix 工具 od -c过滤后显示的。该表具有三列, 第一列类型是char(2),第二列类型是text, 第三列类型是integer。所有行在第三列都是空值。
0000000 P G C O P Y \n 377 \r \n \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 003 \0 \0 \0 002 A F \0 \0 \0 013 A
0000040 F G H A N I S T A N 377 377 377 377 \0 003
0000060 \0 \0 \0 002 A L \0 \0 \0 007 A L B A N I
0000100 A 377 377 377 377 \0 003 \0 \0 \0 002 D Z \0 \0 \0
0000120 007 A L G E R I A 377 377 377 377 \0 003 \0 \0
0000140 \0 002 Z M \0 \0 \0 006 Z A M B I A 377 377
0000160 377 377 \0 003 \0 \0 \0 002 Z W \0 \0 \0 \b Z I
0000200 M B A B W E 377 377 377 377 377 377
剩余的详细信息可以参阅 手册。
9. 性能管理
查询性能可能受多种因素影响。其中一些因素可以由用户控制,而其他的则属于系统下层设计的基本原理。
9.1. 使用EXPLAIN
IvorySQL为每个收到查询产生一个查询计划。 选择正确的计划来匹配查询结构和数据的属性对于好的性能来说绝对是最关键的,因此系统包含了一个复杂的规划器来尝试选择好的计划。 你可以使用EXPLAIN命令察看规划器为任何查询生成的查询计划。 阅读查询计划是一门艺术,它要求一些经验来掌握,但是本节只试图覆盖一些基础。
这些例子使用EXPLAIN的默认“text”输出格式,这种格式紧凑并且便于阅读。如果你想把EXPLAIN的输出交给一个程序做进一步分析,你应该使用它的某种机器可读的输出格式(XML、JSON 或 YAML)。
9.1.1. EXPLAIN基础
查询计划的结构是一个计划结点的树。最底层的结点是扫描结点:它们从表中返回未经处理的行。 不同的表访问模式有不同的扫描结点类型:顺序扫描、索引扫描、位图索引扫描。 也还有不是表的行来源,例如VALUES子句和FROM中返回集合的函数,它们有自己的结点类型。如果查询需要连接、聚集、排序、或者在未经处理的行上的其它操作,那么就会在扫描结点之上有其它额外的结点来执行这些操作。 并且,做这些操作通常都有多种方法,因此在这些位置也有可能出现不同的结点类型。 EXPLAIN给计划树中每个结点都输出一行,显示基本的结点类型和计划器为该计划结点的执行所做的开销估计。 第一行(最上层的结点)是对该计划的总执行开销的估计;计划器试图最小化的就是这个数字。
这里是一个简单的例子,只是用来显示输出看起来是什么样的:
EXPLAIN SELECT * FROM tenk1;
QUERY PLAN
-------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..458.00 rows=10000 width=244)
由于这个查询没有WHERE子句,它必须扫描表中的所有行,因此计划器只能选择使用一个简单的顺序扫描计划。被包含在圆括号中的数字是(从左至右):
-
估计的启动开销。在输出阶段可以开始之前消耗的时间,例如在一个排序节点里执行排序的时间。
-
估计的总开销。这个估计值基于的假设是计划结点会被运行到完成, 即所有可用的行都被检索。不过实际上一个结点的父节点可能很快停止读取所有可用的行(见下面的LIMIT例子)。
-
这个计划结点输出行数的估计值。同样,也假定该结点能运行到完成。
-
预计这个计划结点输出的行平均宽度(以字节计算)。
开销是用规划器的开销参数所决定的捏造单位来衡量的。传统上以取磁盘页面为单位来度量开销; 也就是seq_page_cost将被按照习惯设为1.0,其它开销参数将相对于它来设置。 本节的例子都假定这些参数使用默认值。
有一点很重要:一个上层结点的开销包括它的所有子结点的开销。还有一点也很重要:这个开销只反映规划器关心的东西。特别是这个开销没有考虑结果行传递给客户端所花费的时间,这个时间可能是实际花费时间中的一个重要因素;但是它被规划器忽略了,因为它无法通过修改计划来改变(我们相信,每个正确的计划都将输出同样的行集)。
行数值有一些小技巧,因为它不是计划结点处理或扫描过的行数,而是该结点发出的行数。这通常比被扫描的行数少一些, 因为有些被扫描的行会被应用于此结点上的任意WHERE子句条件过滤掉。 理想中顶层的行估计会接近于查询实际返回、更新、删除的行数。
回到我们的例子:
EXPLAIN SELECT * FROM tenk1;
QUERY PLAN
-------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..458.00 rows=10000 width=244)
这些数字的产生非常直接。如果你执行:
SELECT relpages, reltuples FROM pg_class WHERE relname = 'tenk1';
你会发现tenk1有358个磁盘页面和10000行。 开销被计算为 (页面读取数*seq_page_cost)+(扫描的行数*cpu_tuple_cost)。默认情况下,seq_page_cost是1.0,cpu_tuple_cost是0.01, 因此估计的开销是 (358 * 1.0) + (10000 * 0.01) = 458。
现在让我们修改查询并增加一个WHERE条件:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 7000;
QUERY PLAN
------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..483.00 rows=7001 width=244)
Filter: (unique1 < 7000)
请注意EXPLAIN输出显示WHERE子句被当做一个“过滤器”条件附加到顺序扫描计划结点。 这意味着该计划结点为它扫描的每一行检查该条件,并且只输出通过该条件的行。因为WHERE子句的存在,估计的输出行数降低了。不过,扫描仍将必须访问所有 10000 行,因此开销没有被降低;实际上开销还有所上升(准确来说,上升了 10000 * cpu_operator_cost)以反映检查WHERE条件所花费的额外 CPU 时间。
这条查询实际选择的行数是 7000,但是估计的rows只是个近似值。如果你尝试重复这个试验,那么你很可能得到略有不同的估计。 此外,这个估计会在每次ANALYZE命令之后改变, 因为ANALYZE生成的统计数据是从该表中随机采样计算的。
现在,让我们把条件变得更严格:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
这里,规划器决定使用一个两步的计划:子计划结点访问一个索引来找出匹配索引条件的行的位置,然后上层计划结点实际地从表中取出那些行。独立地抓取行比顺序地读取它们的开销高很多,但是不是所有的表页面都被访问,这么做实际上仍然比一次顺序扫描开销要少(使用两层计划的原因是因为上层规划结点把索引标识出来的行位置在读取之前按照物理位置排序,这样可以最小化单独抓取的开销。结点名称里面提到的“位图”是执行该排序的机制)。
现在让我们给WHERE子句增加另一个条件:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND stringu1 = 'xxx';
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=5.04..229.43 rows=1 width=244)
Recheck Cond: (unique1 < 100)
Filter: (stringu1 = 'xxx'::name)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
新增的条件stringu1 = 'xxx’减少了估计的输出行计数, 但是没有减少开销,因为我们仍然需要访问相同的行集合。 请注意,stringu1子句不能被应用为一个索引条件,因为这个索引只是在unique1列上。 它被用来过滤从索引中检索出的行。因此开销实际上略微增加了一些以反映这个额外的检查。
在某些情况下规划器将更倾向于一个“simple”索引扫描计划:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 = 42;
QUERY PLAN
------------------------------------------------------------------------------
Index Scan using tenk1_unique1 on tenk1 (cost=0.29..8.30 rows=1 width=244)
Index Cond: (unique1 = 42)
在这类计划中,表行被按照索引顺序取得,这使得读取它们开销更高,但是其中有一些是对行位置排序的额外开销。 你很多时候将在只取得一个单一行的查询中看到这种计划类型。 它也经常被用于拥有匹配索引顺序的ORDER BY子句的查询中, 因为那样就不需要额外的排序步骤来满足ORDER BY。在此示例中,添加 ORDER BY unique1将使用相同的计划,因为索引已经隐式提供了请求的排序。
规划器可以通过多种方式实现ORDER BY子句。上面的例子表明,这样的排序子句可以隐式实现。 规划器还可以添加一个明确的sort步骤:
EXPLAIN SELECT * FROM tenk1 ORDER BY unique1;
QUERY PLAN
-------------------------------------------------------------------
Sort (cost=1109.39..1134.39 rows=10000 width=244)
Sort Key: unique1
-> Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=244)
如果计划的一部分保证对所需排序键的前缀进行排序,那么计划器可能会决定使用incremental sort步骤:
EXPLAIN SELECT * FROM tenk1 ORDER BY four, ten LIMIT 100;
QUERY PLAN
------------------------------------------------------------------------------------------------------
Limit (cost=521.06..538.05 rows=100 width=244)
-> Incremental Sort (cost=521.06..2220.95 rows=10000 width=244)
Sort Key: four, ten
Presorted Key: four
-> Index Scan using index_tenk1_on_four on tenk1 (cost=0.29..1510.08 rows=10000 width=244)
与常规排序相比,增量排序允许在对整个结果集进行排序之前返回元组,这尤其可以使用LIMIT查询进行优化。 它还可以减少内存使用和将排序溢出到磁盘的可能性,但其代价是将结果集拆分为多个排序批次的开销增加。
如果在WHERE引用的多个行上有独立的索引,规划器可能会选择使用这些索引的一个 AND 或 OR 组合:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;
QUERY PLAN
-------------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=25.08..60.21 rows=10 width=244)
Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
-> BitmapAnd (cost=25.08..25.08 rows=10 width=0)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique2 (cost=0.00..19.78 rows=999 width=0)
Index Cond: (unique2 > 9000)
但是这要求访问两个索引,所以与只使用一个索引并把其他条件作为过滤器相比,它不一定能胜出。如果你变动涉及到的范围,你将看到计划也会相应改变。
下面是一个例子,它展示了LIMIT的效果:
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;
QUERY PLAN
-------------------------------------------------------------------------------------
Limit (cost=0.29..14.48 rows=2 width=244)
-> Index Scan using tenk1_unique2 on tenk1 (cost=0.29..71.27 rows=10 width=244)
Index Cond: (unique2 > 9000)
Filter: (unique1 < 100)
这是和上面相同的查询,但是我们增加了一个LIMIT这样不是所有的行都需要被检索,并且规划器改变了它的决定。注意索引扫描结点的总开销和行计数显示出好像它会被运行到完成。但是,限制结点在检索到这些行的五分之一后就会停止,因此它的总开销只是索引扫描结点的五分之一,并且这是查询的实际估计开销。之所以用这个计划而不是在之前的计划上增加一个限制结点是因为限制无法避免在位图扫描上花费启动开销,因此总开销会是超过那种方法(25个单位)的某个值。
让我们尝试连接两个表,使用我们已经讨论过的列:
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
QUERY PLAN
-------------------------------------------------------------------------------------
Nested Loop (cost=4.65..118.62 rows=10 width=488)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0)
Index Cond: (unique1 < 10)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..7.91 rows=1 width=244)
Index Cond: (unique2 = t1.unique2)
在这个计划中,我们有一个嵌套循环连接结点,它有两个表扫描作为输入或子结点。该结点的摘要行的缩进反映了计划树的结构。连接的第一个(或“outer”)子结点是一个与前面见到的相似的位图扫描。它的开销和行计数与我们从SELECT … WHERE unique1 < 10得到的相同,因为我们将WHERE子句unique1 < 10用在了那个结点上。t1.unique2 = t2.unique2子句现在还不相关,因此它不影响 outer 扫描的行计数。嵌套循环连接结点将为从 outer 子结点得到的每一行运行它的第二个(或“inner”)子结点。当前 outer 行的列值可以被插入 inner 扫描。这里,来自 outer 行的t1.unique2值是可用的,所以我们得到的计划和开销与前面见到的简单SELECT … WHERE t2.unique2 = constant情况相似(估计的开销实际上比前面看到的略低,是因为在t2上的重复索引扫描会利用到高速缓存)。循环结点的开销则被以 outer 扫描的开销为基础设置,外加对每一个 outer 行都要进行一次 inner 扫描 (10 * 7.87),再加上用于连接处理一点 CPU 时间。
在这个例子里,连接的输出行计数等于两个扫描的行计数的乘积,但通常并不是所有的情况中都如此, 因为可能有同时提及两个表的 额外WHERE子句,并且因此它只能被应用于连接点,而不能影响任何一个输入扫描。这里是一个例子:
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t2.unique2 < 10 AND t1.hundred < t2.hundred;
QUERY PLAN
-------------------------------------------------------------------------------------
Nested Loop (cost=4.65..49.46 rows=33 width=488)
Join Filter: (t1.hundred < t2.hundred)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0)
Index Cond: (unique1 < 10)
-> Materialize (cost=0.29..8.51 rows=10 width=244)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..8.46 rows=10 width=244)
Index Cond: (unique2 < 10)
条件t1.hundred < t2.hundred不能在tenk2_unique2索引中被测试,因此它被应用在连接结点。这缩减了连接结点的估计输出行计数,但是没有改变任何输入扫描。
注意这里规划器选择了“物化”连接的 inner 关系,方法是在它的上方放了一个物化计划结点。这意味着t2索引扫描将只被做一次,即使嵌套循环连接结点需要读取其数据十次(每个来自 outer 关系的行都要读一次)。物化结点在读取数据时将它保存在内存中,然后在每一次后续执行时从内存返回数据。
在处理外连接时,你可能会看到连接计划结点同时附加有“连接过滤器”和普通“过滤器”条件。连接过滤器条件来自于外连接的ON子句,因此一个无法通过连接过滤器条件的行也能够作为一个空值扩展的行被发出。但是一个普通过滤器条件被应用在外连接条件之后并且因此无条件移除行。在一个内连接中这两种过滤器类型没有语义区别。
如果我们把查询的选择度改变一点,我们可能得到一个非常不同的连接计划:
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
-------------------------------------------------------------------------------------
Hash Join (cost=230.47..713.98 rows=101 width=488)
Hash Cond: (t2.unique2 = t1.unique2)
-> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244)
-> Hash (cost=229.20..229.20 rows=101 width=244)
-> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
这里规划器选择了使用一个哈希连接,在其中一个表的行被放入一个内存哈希表,在这之后其他表被扫描并且为每一行查找哈希表来寻找匹配。同样要注意缩进是如何反映计划结构的:tenk1上的位图扫描是哈希结点的输入,哈希结点会构造哈希表。然后哈希表会返回给哈希连接结点,哈希连接结点将从它的 outer 子计划读取行,并为每一个行搜索哈希表。
另一种可能的连接类型是一个归并连接,如下所示:
EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
-------------------------------------------------------------------------------------
Merge Join (cost=198.11..268.19 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
-> Index Scan using tenk1_unique2 on tenk1 t1 (cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
-> Sort (cost=197.83..200.33 rows=1000 width=244)
Sort Key: t2.unique2
-> Seq Scan on onek t2 (cost=0.00..148.00 rows=1000 width=244)
归并连接要求它的输入数据被按照连接键排序。在这个计划中,tenk1数据被使用一个索引扫描排序,以便能够按照正确的顺序来访问行。但是对于onek则更倾向于一个顺序扫描和排序,因为在那个表中有更多行需要被访问(对于很多行的排序,顺序扫描加排序常常比一个索引扫描好,因为索引扫描需要非顺序的磁盘访问)。
一种查看变体计划的方法是强制规划器丢弃它认为开销最低的任何策略,这可以使用启用/禁用标志实现例如,如果我们并不认同在前面的例子中顺序扫描加排序是处理表onek的最佳方法,我们可以尝试:
SET enable_sort = off;
EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
------------------------------------------------------------------------------------------
Merge Join (cost=0.56..292.65 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
-> Index Scan using tenk1_unique2 on tenk1 t1 (cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
-> Index Scan using onek_unique2 on onek t2 (cost=0.28..224.79 rows=1000 width=244)
这显示规划器认为用索引扫描来排序onek的开销要比用顺序扫描加排序的方式高大约12%。当然,下一个问题是是否真的是这样。我们可以通过使用EXPLAIN ANALYZE来仔细研究一下,如下文所述。
9.1.2. EXPLAIN ANALYZE
可以通过使用EXPLAIN的ANALYZE选项来检查规划器估计值的准确性。通过使用这个选项,EXPLAIN会实际执行该查询,然后显示真实的行计数和在每个计划结点中累计的真实运行时间,还会有一个普通EXPLAIN显示的估计值。例如,我们可能得到这样一个结果:
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=4.65..118.62 rows=10 width=488) (actual time=0.128..0.377 rows=10 loops=1)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244) (actual time=0.057..0.121 rows=10 loops=1)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0) (actual time=0.024..0.024 rows=10 loops=1)
Index Cond: (unique1 < 10)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..7.91 rows=1 width=244) (actual time=0.021..0.022 rows=1 loops=10)
Index Cond: (unique2 = t1.unique2)
Planning time: 0.181 ms
Execution time: 0.501 ms
注意“actual time”值是以毫秒计的真实时间,而cost估计值被以捏造的单位表示,因此它们不大可能匹配上。在这里面要查看的最重要的一点是估计的行计数是否合理地接近实际值。在这个例子中,估计值都是完全正确的,但是在实际中非常少见。
在某些查询计划中,可以多次执行一个子计划结点。例如,inner 索引扫描可能会因为上层嵌套循环计划中的每一个 outer 行而被执行一次。在这种情况下,loops值报告了执行该结点的总次数,并且 actual time 和行数值是这些执行的平均值。这是为了让这些数字能够与开销估计被显示的方式有可比性。将这些值乘上loops值可以得到在该结点中实际消耗的总时间。在上面的例子中,我们在执行tenk2的索引扫描上花费了总共 0.220 毫秒。
在某些情况中,EXPLAIN ANALYZE会显示计划结点执行时间和行计数之外的额外执行统计信息。例如,排序和哈希结点提供额外的信息:
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
Sort (cost=717.34..717.59 rows=101 width=488) (actual time=7.761..7.774 rows=100 loops=1)
Sort Key: t1.fivethous
Sort Method: quicksort Memory: 77kB
-> Hash Join (cost=230.47..713.98 rows=101 width=488) (actual time=0.711..7.427 rows=100 loops=1)
Hash Cond: (t2.unique2 = t1.unique2)
-> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244) (actual time=0.007..2.583 rows=10000 loops=1)
-> Hash (cost=229.20..229.20 rows=101 width=244) (actual time=0.659..0.659 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 28kB
-> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244) (actual time=0.080..0.526 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0) (actual time=0.049..0.049 rows=100 loops=1)
Index Cond: (unique1 < 100)
Planning time: 0.194 ms
Execution time: 8.008 ms
排序结点显示使用的排序方法(尤其是,排序是在内存中还是磁盘上进行)和需要的内存或磁盘空间量。哈希结点显示了哈希桶的数量和批数,以及被哈希表所使用的内存量的峰值(如果批数超过一,也将会涉及到磁盘空间使用,但是并没有被显示)。
另一种类型的额外信息是被一个过滤器条件移除的行数:
EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE ten < 7;
QUERY PLAN
---------------------------------------------------------------------------------------------------------
Seq Scan on tenk1 (cost=0.00..483.00 rows=7000 width=244) (actual time=0.016..5.107 rows=7000 loops=1)
Filter: (ten < 7)
Rows Removed by Filter: 3000
Planning time: 0.083 ms
Execution time: 5.905 ms
这些值对于被应用在连接结点上的过滤器条件特别有价值。只有在至少有一个被扫描行或者在连接结点中一个可能的连接对被过滤器条件拒绝时,“Rows Removed”行才会出现。
一个与过滤器条件相似的情况出现在“有损”索引扫描中。例如,考虑这个查询,它搜索包含一个指定点的多边形:
EXPLAIN ANALYZE SELECT * FROM polygon_tbl WHERE f1 @> polygon '(0.5,2.0)';
QUERY PLAN
------------------------------------------------------------------------------------------------------
Seq Scan on polygon_tbl (cost=0.00..1.05 rows=1 width=32) (actual time=0.044..0.044 rows=0 loops=1)
Filter: (f1 @> '((0.5,2))'::polygon)
Rows Removed by Filter: 4
Planning time: 0.040 ms
Execution time: 0.083 ms
规划器认为(非常正确)这个采样表太小不值得劳烦一次索引扫描,因此我们得到了一个普通的顺序扫描,其中的所有行都被过滤器条件拒绝。但是如果我们强制使得一次索引扫描可以被使用,我们看到:
SET enable_seqscan TO off;
EXPLAIN ANALYZE SELECT * FROM polygon_tbl WHERE f1 @> polygon '(0.5,2.0)';
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------
Index Scan using gpolygonind on polygon_tbl (cost=0.13..8.15 rows=1 width=32) (actual time=0.062..0.062 rows=0 loops=1)
Index Cond: (f1 @> '((0.5,2))'::polygon)
Rows Removed by Index Recheck: 1
Planning time: 0.034 ms
Execution time: 0.144 ms
这里我们可以看到索引返回一个候选行,然后它会被索引条件的重新检查拒绝。这是因为一个 GiST 索引对于多边形包含测试是 “有损的”:它确实返回覆盖目标的多边形的行,然后我们必须在那些行上做精确的包含性测试。
EXPLAIN有一个BUFFERS选项可以和ANALYZE一起使用来得到更多运行时统计信息:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=25.08..60.21 rows=10 width=244) (actual time=0.323..0.342 rows=10 loops=1)
Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
Buffers: shared hit=15
-> BitmapAnd (cost=25.08..25.08 rows=10 width=0) (actual time=0.309..0.309 rows=0 loops=1)
Buffers: shared hit=7
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0) (actual time=0.043..0.043 rows=100 loops=1)
Index Cond: (unique1 < 100)
Buffers: shared hit=2
-> Bitmap Index Scan on tenk1_unique2 (cost=0.00..19.78 rows=999 width=0) (actual time=0.227..0.227 rows=999 loops=1)
Index Cond: (unique2 > 9000)
Buffers: shared hit=5
Planning time: 0.088 ms
Execution time: 0.423 ms
BUFFERS提供的数字帮助我们标识查询的哪些部分是对 I/O 最敏感的。
记住因为EXPLAIN ANALYZE实际运行查询,任何副作用都将照常发生,即使查询可能输出的任何结果被丢弃来支持打印EXPLAIN数据。如果你想要分析一个数据修改查询而不想改变你的表,你可以在分析完后回滚命令,例如:
BEGIN;
EXPLAIN ANALYZE UPDATE tenk1 SET hundred = hundred + 1 WHERE unique1 < 100;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
Update on tenk1 (cost=5.07..229.46 rows=101 width=250) (actual time=14.628..14.628 rows=0 loops=1)
-> Bitmap Heap Scan on tenk1 (cost=5.07..229.46 rows=101 width=250) (actual time=0.101..0.439 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0) (actual time=0.043..0.043 rows=100 loops=1)
Index Cond: (unique1 < 100)
Planning time: 0.079 ms
Execution time: 14.727 ms
ROLLBACK;
正如在这个例子中所看到的,当查询是一个INSERT、UPDATE或DELETE命令时,应用表更改的实际工作由顶层插入、更新或删除计划结点完成。这个结点之下的计划结点执行定位旧行以及/或者计算新数据的工作。因此在上面,我们看到我们已经见过的位图表扫描,它的输出被交给一个更新结点,更新结点会存储被更新过的行。还有一点值得注意的是,尽管数据修改结点可能要可观的运行时间(这里,它消耗最大份额的时间),规划器当前并没有对开销估计增加任何东西来说明这些工作。这是因为这些工作对每一个正确的查询计划都得做,所以它不影响计划的选择。
当一个UPDATE或者DELETE命令影响继承层次时, 输出可能像这样:
EXPLAIN UPDATE parent SET f2 = f2 + 1 WHERE f1 = 101;
QUERY PLAN
-----------------------------------------------------------------------------------
Update on parent (cost=0.00..24.53 rows=4 width=14)
Update on parent
Update on child1
Update on child2
Update on child3
-> Seq Scan on parent (cost=0.00..0.00 rows=1 width=14)
Filter: (f1 = 101)
-> Index Scan using child1_f1_key on child1 (cost=0.15..8.17 rows=1 width=14)
Index Cond: (f1 = 101)
-> Index Scan using child2_f1_key on child2 (cost=0.15..8.17 rows=1 width=14)
Index Cond: (f1 = 101)
-> Index Scan using child3_f1_key on child3 (cost=0.15..8.17 rows=1 width=14)
Index Cond: (f1 = 101)
在这个例子中,更新节点需要考虑三个子表以及最初提到的父表。因此有四个输入 的扫描子计划,每一个对应于一个表。为清楚起见,在更新节点上标注了将被更新 的相关目标表,显示的顺序与相应的子计划相同(这些标注是从 PostgreSQL 9.5 开始新增的,在以前的版本中读者必须通过 观察子计划才能知道这些目标表)。
EXPLAIN ANALYZE显示的 Planning time是从一个已解析的查询生成查询计划并进行优化 所花费的时间,其中不包括解析和重写。
EXPLAIN ANALYZE显示的Execution time包括执行器的启动和关闭时间,以及运行被触发的任何触发器的时间,但是它不包括解析、重写或规划的时间。如果有花在执行BEFORE执行器的时间,它将被包括在相关的插入、更新或删除结点的时间内;但是用来执行AFTER 触发器的时间没有被计算,因为AFTER触发器是在整个计划完成后被触发的。在每个触发器(BEFORE或AFTER)也被独立地显示。注意延迟约束触发器将不会被执行,直到事务结束,并且因此根本不会被EXPLAIN ANALYZE考虑。
9.1.3. 警告
在两种有效的方法中EXPLAIN ANALYZE所度量的运行时间可能偏离同一个查询的正常执行。首先,由于不会有输出行被递交给客户端,网络传输开销和 I/O 转换开销没有被包括在内。其次,由EXPLAIN ANALYZE所增加的度量开销可能会很可观,特别是在操作系统调用gettimeofday()很慢的机器上。你可以使用pg_test_timing工具来度量在你的系统上的计时开销。
EXPLAIN结果不应该被外推到与你实际测试的非常不同的情况。例如,一个很小的表上的结果不能被假定成适合大型表。规划器的开销估计不是线性的,并且因此它可能为一个更大或更小的表选择一个不同的计划。一个极端例子是,在一个只占据一个磁盘页面的表上,你将几乎总是得到一个顺序扫描计划,而不管索引是否可用。规划器认识到它在任何情况下都将采用一次磁盘页面读取来处理该表,因此用额外的页面读取去查看一个索引是没有价值的(我们已经在前面的polygon_tbl例子中见过)。
在一些情况中,实际的值和估计的值不会匹配得很好,但是这并非错误。一种这样的情况发生在计划结点的执行被LIMIT或类似的效果很快停止。例如,在我们之前用过的LIMIT查询中:
EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.29..14.71 rows=2 width=244) (actual time=0.177..0.249 rows=2 loops=1)
-> Index Scan using tenk1_unique2 on tenk1 (cost=0.29..72.42 rows=10 width=244) (actual time=0.174..0.244 rows=2 loops=1)
Index Cond: (unique2 > 9000)
Filter: (unique1 < 100)
Rows Removed by Filter: 287
Planning time: 0.096 ms
Execution time: 0.336 ms
索引扫描结点的估计开销和行计数被显示成好像它会运行到完成。但是实际上限制结点在得到两个行之后就停止请求行,因此实际的行计数只有 2 并且运行时间远低于开销估计所建议的时间。这并非估计错误,这仅仅一种估计值和实际值显示方式上的不同。
归并连接也有类似的现象。如果一个归并连接用尽了一个输入并且其中的最后一个键值小于另一个输入中的下一个键值,它将停止读取另一个输入。在这种情况下,不会有更多的匹配并且因此不需要扫描第二个输入的剩余部分。这会导致不读取一个子结点的所有内容,其结果就像在LIMIT中所提到的。另外,如果 outer (第一个)子结点包含带有重复键值的行,inner(第二个)子结点会被倒退并且被重新扫描来找能匹配那个键值的行。EXPLAIN ANALYZE会统计相同 inner 行的重复发出,就好像它们是真实的额外行。当有很多 outer 重复时,对 inner 子计划结点所报告的实际行计数会显著地大于实际在 inner 关系中的行数。
由于实现的限制,BitmapAnd 和 BitmapOr 结点总是报告它们的实际行计数为零。
通常,EXPLAIN将显示规划器生成的每个计划节点。 但是,在某些情况下,执行器可以不执行某些节点,因为根据规划时不可用的参数值能确定这些节点无法产生任何行。 (当前,这仅会在扫描分区表的Append或MergeAppend节点的子节点中发生。) 发生这种情况时,将从EXPLAIN输出中省略这些计划节点,并显示Subplans Removed:N的标识。
9.2. 规划器使用的统计信息
9.2.1. 单列统计信息
如我们在上一节所见,查询规划器需要估计一个查询要检索的行数,这样才能对查询计划做出好的选择。 本节对系统用于这些估计的统计信息进行一个快速的介绍。
统计信息的一个部分就是每个表和索引中的项的总数,以及每个表和索引占用的磁盘块数。这些信息保存在pg_class表的reltuples和relpages列中。 我们可以用类似下面的查询查看这些信息:
SELECT relname, relkind, reltuples, relpages
FROM pg_class
WHERE relname LIKE 'tenk1%';
relname | relkind | reltuples | relpages
----------------------+---------+-----------+----------
tenk1 | r | 10000 | 358
tenk1_hundred | i | 10000 | 30
tenk1_thous_tenthous | i | 10000 | 30
tenk1_unique1 | i | 10000 | 30
tenk1_unique2 | i | 10000 | 30
(5 rows)
这里我们可以看到tenk1包含 10000 行, 它的索引也有这么多行,但是索引远比表小得多(不奇怪)。
出于效率考虑,reltuples和relpages不是实时更新的 ,因此它们通常包含有些过时的值。它们被VACUUM、ANALYZE和几个 DDL 命令(例如CREATE INDEX)更新。一个不扫描全表的VACUUM或ANALYZE操作(常见情况)将以它扫描的部分为基础增量更新reltuples计数,这就导致了一个近似值。在任何情况中,规划器将缩放它在pg_class中找到的值来匹配当前的物理表尺寸,这样得到一个较紧的近似。
大多数查询只是检索表中行的一部分,因为它们有限制要被检查的行的WHERE子句。 因此规划器需要估算WHERE子句的选择度,即符合WHERE子句中每个条件的行的比例。 用于这个任务的信息存储在pg_statistic系统目录中。 在pg_statistic中的项由ANALYZE和VACUUM ANALYZE命令更新, 并且总是近似值(即使刚刚更新完)。
除了直接查看pg_statistic之外, 手工检查统计信息的时候最好查看它的视图pg_stats。pg_stats被设计为更容易阅读。 而且,pg_stats是所有人都可以读取的,而pg_statistic只能由超级用户读取(这样可以避免非授权用户从统计信息中获取一些其他人的表的内容的信息。pg_stats视图被限制为只显示当前用户可读的表)。例如,我们可以:
SELECT attname, inherited, n_distinct,
array_to_string(most_common_vals, E'\n') as most_common_vals
FROM pg_stats
WHERE tablename = 'road';
attname | inherited | n_distinct | most_common_vals
---------+-----------+------------+------------------------------------
name | f | -0.363388 | I- 580 Ramp+
| | | I- 880 Ramp+
| | | Sp Railroad +
| | | I- 580 +
| | | I- 680 Ramp
name | t | -0.284859 | I- 880 Ramp+
| | | I- 580 Ramp+
| | | I- 680 Ramp+
| | | I- 580 +
| | | State Hwy 13 Ramp
(2 rows)
注意,这两行显示的是相同的列,一个对应开始于road表(inherited=t)的完全继承层次, 另一个只包括road表本身(inherited=f)。
ANALYZE在pg_statistic中存储的信息量(特别是每个列的most_common_vals中的最大项数和histogram_bounds数组)可以用ALTER TABLE SET STATISTICS命令为每一列设置, 或者通过设置配置变量default_statistics_target进行全局设置。 目前的默认限制是 100 个项。提升该限制可能会让规划器做出更准确的估计(特别是对那些有不规则数据分布的列), 其代价是在pg_statistic中消耗了更多空间,并且需要略微多一些的时间来计算估计数值。 相比之下,比较低的限制可能更适合那些数据分布比较简单的列。
更多规划器对统计信息的使用可以参阅 手册。
9.2.2. 扩展统计信息
常常可以看到由于查询子句中用到的多个列相互关联而运行着糟糕的执行计划的慢查询。规划器通常会假设多个条件是彼此独立的,这种假设在列值相互关联的情况下是不成立的。由于常规的统计信息天然的针对个体列的性质,它们无法捕捉到跨列关联的知识。不过,IvorySQL有能力计算多元统计信息,它能捕捉这类信息。
由于可能的列组合数非常巨大,所以不可能自动计算多元统计信息。可以创建扩展统计信息对象(更常被称为统计信息对象)来指示服务器获得跨感兴趣列集合的统计信息。
统计信息对象可以使用CREATE STATISTICS命令创建。这样一个对象的创建仅仅是创建了一个目录项来表示对统计信息有兴趣。实际的数据收集是由ANALYZE(或者是一个手工命令,或者是后台的自动分析)执行的。收集到的值可以在pg_statistic_ext_data目录中看到。
ANALYZE基于它用来计算常规单列统计信息的表行样本来计算扩展统计信息。由于样本的尺寸会随着表或者表列的统计信息目标(如前一节所述)增大而增加,更大的统计信息目标通常将会导致更准确的扩展统计信息,同时也会导致更多花在计算扩展统计信息之上的时间。
下面的小节介绍当前支持的扩展统计信息类型。
9.2.2.1. 函数依赖
最简单的一类扩展统计信息跟踪函数依赖,这是在数据库范式定义中使用的概念。如果列a的值的知识足以决定列b的值,即不会有两个行具有相同的a值但是有不同的b值,我们就说列b函数依赖于列a。在一个完全规范化的数据库中,函数依赖应该仅存在于主键和超键上。不过,实际上很多数据集合会由于各种原因无法被完全规范化,常见的例子是为了性能而有意地反规范化。即使在一个完全规范化的数据库中,也会有某些列之间的部分关联,这些可以表达成部分函数依赖。
函数依赖的存在直接影响了特定查询中估计的准确性。如果一个查询包含独立列和依赖列上的条件,依赖列上的条件不会进一步降低结果的尺寸。但是如果没有函数依赖的知识,查询规划器将假定条件是独立的,导致对结果尺寸的低估。
要告知规划器有关函数依赖的信息,ANALYZE可以收集跨列依赖的测度。评估所有列组之间的依赖程度可能会昂贵到不可实现,因此数据收集被限制为针对那些在一个统计信息对象中一起出现的列组(用dependencies选项定义)。建议只对强相关的列组创建dependencies统计信息,以避免ANALYZE以及后期查询规划中不必要的开销。
这里是一个收集函数依赖统计信息的例子:
CREATE STATISTICS stts (dependencies) ON city, zip FROM zipcodes;
ANALYZE zipcodes;
SELECT stxname, stxkeys, stxddependencies
FROM pg_statistic_ext join pg_statistic_ext_data on (oid = stxoid)
WHERE stxname = 'stts';
stxname | stxkeys | stxddependencies
---------+---------+------------------------------------------
stts | 1 5 | {"1 => 5": 1.000000, "5 => 1": 0.423130}
(1 row)
这里可以看到列1(邮编)完全决定列5(城市),因此系数为1.0,而城市仅决定42%的邮编,意味着有很多城市(58%)有多个邮编。
在为涉及函数依赖列的查询计算选择度时,规划器会使用依赖系数来调整针对条件的选择度估计,这样就不会产生低估。
9.2.2.1.1. 函数依赖的限制
当前只有在考虑简单等值条件(将列与常量值比较)和具有常量值的IN 子句时,函数依赖才适用。不会使用它们来改进比较两个列或者比较列和表达式的等值条件的估计, 也不会用它们来改进范围子句、LIKE或者任何其他类型的条件。
在用函数依赖估计时,规划器假定在涉及的列上的条件是兼容的并且因此是冗余的。如果它们是不兼容的,正确的估计将是零行,但是那种可能性不会被考虑。例如,给定一个这样的查询
SELECT * FROM zipcodes WHERE city = 'San Francisco' AND zip = '94105';
规划器将会忽视city子句,因为它不改变选择度,这是正确的。不过,即便真地只有零行满足下面的查询,规划器也会做出同样的假设
SELECT * FROM zipcodes WHERE city = 'San Francisco' AND zip = '90210';
不过,函数依赖统计信息无法提供足够的信息来排除这种情况。
在很多实际情况中,这种假设通常是能满足的。例如,在应用程序中可能有一个GUI仅允许选择兼容的城市和邮编值用在查询中。但是如果不是这样,函数依赖可能就不是一个可行的选项。
9.2.2.2. 多元可区分值计数
单列统计信息存储每一列中可区分值的数量。在组合多个列(例如GROUP BY a, b)时,如果规划器只有单列统计数据,则对可区分值数量的估计常常会错误,导致选择不好的计划。
为了改进这种估计,ANALYZE可以为列组收集可区分值统计信息。和以前一样,为每一种可能的列组合做这件事情是不切实际的,因此只会为一起出现在一个统计信息对象(用ndistinct选项定义)中的列组收集数据。将会为列组中列出的列的每一种可能的组合都收集数据。
继续之前的例子,ZIP代码表中的可区分值计数可能像这样:
CREATE STATISTICS stts2 (ndistinct) ON city, state, zip FROM zipcodes;
ANALYZE zipcodes;
SELECT stxkeys AS k, stxdndistinct AS nd
FROM pg_statistic_ext join pg_statistic_ext_data on (oid = stxoid)
WHERE stxname = 'stts2';
-[ RECORD 1 ]--------------------------------------------------------
k | 1 2 5
nd | {"1, 2": 33178, "1, 5": 33178, "2, 5": 27435, "1, 2, 5": 33178}
(1 row)
这表示有三种列组合有33178个可区分值:ZIP代码和州、ZIP代码和城市、ZIP代码+城市+周(事实上对于表中给定的一个唯一的ZIP代码,它们本来就应该是相等的)。另一方面,城市和州的组合只有27435个可区分值。
建议只对实际用于分组的列组合以及分组数错误估计导致了糟糕计划的列组合创建ndistinct统计信息对象。否则,ANALYZE循环只会被浪费。
9.2.2.3. 多元MCV列表
为每列存储的另一种统计信息是频繁值列表。 这样可以对单个列进行非常准确的估计,但是对于在多个列上具有条件的查询,可能会导致严重的错误估计。
为了改善这种估计,ANALYZE可以收集列组合上的MCV列表。 与功能依赖和n-distinct系数类似,对每种可能的列分组进行此操作都是不切实际的。 在这种情况下,甚至更是如此,因为MCV列表(与功能依赖性和n-distinct系数不同)存储了公共列值。 因此,仅收集在使用mcv选项定义的统计对象中同时出现的那些列组的数据。
继续前面的示例,邮政编码表的MCV列表可能类似于以下内容(与更简单的统计信息不同,它需要一个函数来检查MCV内容):
CREATE STATISTICS stts3 (mcv) ON city, state FROM zipcodes;
ANALYZE zipcodes;
SELECT m.* FROM pg_statistic_ext join pg_statistic_ext_data on (oid = stxoid),
pg_mcv_list_items(stxdmcv) m WHERE stxname = 'stts3';
index | values | nulls | frequency | base_frequency
-------+------------------------+-------+-----------+----------------
0 | {Washington, DC} | {f,f} | 0.003467 | 2.7e-05
1 | {Apo, AE} | {f,f} | 0.003067 | 1.9e-05
2 | {Houston, TX} | {f,f} | 0.002167 | 0.000133
3 | {El Paso, TX} | {f,f} | 0.002 | 0.000113
4 | {New York, NY} | {f,f} | 0.001967 | 0.000114
5 | {Atlanta, GA} | {f,f} | 0.001633 | 3.3e-05
6 | {Sacramento, CA} | {f,f} | 0.001433 | 7.8e-05
7 | {Miami, FL} | {f,f} | 0.0014 | 6e-05
8 | {Dallas, TX} | {f,f} | 0.001367 | 8.8e-05
9 | {Chicago, IL} | {f,f} | 0.001333 | 5.1e-05
...
(99 rows)
这表明城市和州的最常见组合是华盛顿特区,实际频率(在样本中)约为0.35%。 组合的基本频率(根据简单的每列频率计算)仅为0.0027%,导致两个数量级的低估。
建议仅在实际在条件中一起使用的列的组合上创建MCV统计对象,对于这些组合,错误估计组数会导致糟糕的执行计划。 否则,只会浪费ANALYZE和规划时间。
9.3. 用显示JOIN子句控制规划器
我们可以在一定程度上用显式JOIN语法控制查询规划器。要明白为什么需要它,我们首先需要一些背景知识。
在一个简单的连接查询中,例如:
SELECT * FROM a, b, c WHERE a.id = b.id AND b.ref = c.id;
规划器可以自由地按照任何顺序连接给定的表。例如,它可以生成一个使用WHERE条件a.id = b.id连接 A 到 B 的查询计划,然后用另外一个WHERE条件把 C 连接到这个连接表。或者它可以先连接 B 和 C 然后再连接 A 得到同样的结果。 或者也可以连接 A 到 C 然后把结果与 B 连接 — 不过这么做效率不好,因为必须生成完整的 A 和 C 的迪卡尔积,而在WHERE子句中没有可用条件来优化该连接(IvorySQL执行器中的所有连接都发生在两个输入表之间, 所以它必须以这些形式之一建立结果)。 重要的一点是这些不同的连接可能性给出在语义等效的结果,但在执行开销上却可能有巨大的差别。 因此,规划器会对它们进行探索并尝试找出最高效的查询计划。
当一个查询只涉及两个或三个表时,那么不需要考虑很多连接顺序。但是可能的连接顺序数随着表数目的增加成指数增长。 当超过十个左右的表以后,实际上根本不可能对所有可能性做一次穷举搜索,甚至对六七个表都需要相当长的时间进行规划。 当有太多的输入表时,IvorySQL规划器将从穷举搜索切换为一种遗传概率搜索,它只需要考虑有限数量的可能性(切换的阈值用geqo_threshold运行时参数设置)。遗传搜索用时更少,但是并不一定会找到最好的计划。
当查询涉及外连接时,规划器比处理普通(内)连接时拥有更小的自由度。例如,考虑:
SELECT * FROM a LEFT JOIN (b JOIN c ON (b.ref = c.id)) ON (a.id = b.id);
尽管这个查询的约束表面上和前一个非常相似,但它们的语义却不同, 因为如果 A 里有任何一行不能匹配 B 和 C的连接表中的行,它也必须被输出。因此这里规划器对连接顺序没有什么选择:它必须先连接 B 到 C,然后把 A 连接到该结果上。 相应地,这个查询比前面一个花在规划上的时间更少。在其它情况下,规划器就有可能确定多种连接顺序都是安全的。例如,给定:
SELECT * FROM a LEFT JOIN b ON (a.bid = b.id) LEFT JOIN c ON (a.cid = c.id);
将 A 首先连接到 B 或 C 都是有效的。当前,只有FULL JOIN完全约束连接顺序。大多数涉及LEFT JOIN或RIGHT JOIN的实际情况都在某种程度上可以被重新排列。
显式连接语法(INNER JOIN、CROSS JOIN或无修饰的JOIN)在语义上和FROM中列出输入关系是一样的, 因此它不约束连接顺序。
即使大多数类型的JOIN并不完全约束连接顺序,但仍然可以指示IvorySQL查询规划器将所有JOIN子句当作有连接顺序约束来对待。例如,这里的三个查询在逻辑上是等效的:
SELECT * FROM a, b, c WHERE a.id = b.id AND b.ref = c.id;
SELECT * FROM a CROSS JOIN b CROSS JOIN c WHERE a.id = b.id AND b.ref = c.id;
SELECT * FROM a JOIN (b JOIN c ON (b.ref = c.id)) ON (a.id = b.id);
但如果我们告诉规划器遵循JOIN的顺序,那么第二个和第三个还是要比第一个花在规划上的时间少。 这个效果对于只有三个表的连接而言是微不足道的,但对于数目众多的表,可能就是救命稻草了。
要强制规划器遵循显式JOIN的连接顺序, 我们可以把运行时参数join_collapse_limit设置为 1(其它可能值在下文讨论)。
你不必为了缩短搜索时间来完全约束连接顺序,因为可以在一个普通FROM列表里使用JOIN操作符。例如,考虑:
SELECT * FROM a CROSS JOIN b, c, d, e WHERE ...;
如果设置join_collapse_limit = 1,那么这就强迫规划器先把 A 连接到 B, 然后再连接到其它的表上,但并不约束它的选择。在这个例子中,可能的连接顺序的数目减少了 5 倍。
按照这种方法约束规划器的搜索是一个有用的技巧,不管是对减少规划时间还是对引导规划器生成好的查询计划。 如果规划器按照默认选择了一个糟糕的连接顺序,你可以通过JOIN语法强迫它选择一个更好的顺序 — 假设你知道一个更好的顺序。我们推荐进行实验。
一个非常相近的影响规划时间的问题是把子查询压缩到它们的父查询中。例如,考虑:
SELECT *
FROM x, y,
(SELECT * FROM a, b, c WHERE something) AS ss
WHERE somethingelse;
这种情况可能在使用包含连接的视图时出现;该视图的SELECT规则将被插入到引用视图的地方,得到与上文非常相似的查询。 通常,规划器会尝试把子查询压缩到父查询里,得到:
SELECT * FROM x, y, a, b, c WHERE something AND somethingelse;
这样通常会生成一个比独立的子查询更好些的计划(例如,outer 的WHERE条件可能先把 X 连接到 A 上,这样就消除了 A 中的许多行, 因此避免了形成子查询的全部逻辑输出)。但是同时,我们增加了规划的时间; 在这里,我们用五路连接问题替代了两个独立的三路连接问题。这样的差别是巨大的,因为可能的计划数的是按照指数增长的。 如果有超过from_collapse_limit个FROM项将会导致父查询,规划器将尝试通过停止提升子查询来避免卡在巨大的连接搜索问题中。你可以通过调高或调低这个运行时参数在规划时间和计划的质量之间取得平衡。
from_collapse_limit和join_collapse_limit的命名相似,因为它们做的几乎是同一件事:一个控制规划器何时将把子查询“平面化”,另外一个控制何时把显式连接平面化。通常,你要么把join_collapse_limit设置成和from_collapse_limit一样(这样显式连接和子查询的行为类似), 要么把join_collapse_limit设置为 1(如果你想用显式连接控制连接顺序)。 但是你可以把它们设置成不同的值,这样你就可以细粒度地调节规划时间和运行时间之间的平衡。
9.4. 填充一个数据库
第一次填充数据库时可能需要插入大量的数据。本节包含一些如何让这个处理尽可能高效的建议。
9.4.1. 禁用自动提交
在使用多个INSERT时,关闭自动提交并且只在最后做一次提交(在普通 SQL 中,这意味着在开始发出BEGIN并且在结束时发出COMMIT。某些客户端库可能背着你就做了这些,在这种情况下你需要确定在你需要做这些时该库确实帮你做了)。如果你允许每一个插入都被独立地提交,IvorySQL要为每一个被增加的行做很多工作。在一个事务中做所有插入的一个额外好处是:如果一个行的插入失败则所有之前插入的行都会被回滚,这样你不会被卡在部分载入的数据中。
9.4.2. 使用COPY
使用COPY在一条命令中装载所有记录,而不是一系列INSERT命令。 COPY命令是为装载大量行而优化过的; 它没INSERT那么灵活,但是在大量数据装载时导致的负荷也更少。 因为COPY是单条命令,因此使用这种方法填充表时无须关闭自动提交。
如果你不能使用COPY,那么使用PREPARE来创建一个预备INSERT语句也有所帮助,然后根据需要使用EXECUTE多次。这样就避免了重复分析和规划INSERT的负荷。不同接口以不同的方式提供该功能, 可参阅接口文档中的“预备语句”。
请注意,在载入大量行时,使用COPY几乎总是比使用INSERT快, 即使使用了PREPARE并且把多个插入被成批地放入一个单一事务。
同样的事务中,COPY比更早的CREATE TABLE或TRUNCATE命令更快。 在这种情况下,不需要写 WAL,因为在一个错误的情况下,包含新载入数据的文件不管怎样都将被移除。不过,只有当wal_level设置为minimal(此时所有的命令必须写 WAL)时才会应用这种考虑。
9.4.3. 移除索引
如果你正在载入一个新创建的表,最快的方法是创建该表,用COPY批量载入该表的数据,然后创建表需要的任何索引。在已存在数据的表上创建索引要比在每一行被载入时增量地更新它更快。
如果你正在对现有表增加大量的数据,删除索引、载入表然后重新创建索引可能是最好的方案。 当然,在缺少索引的期间,其它数据库用户的数据库性能将会下降。 我们在删除唯一索引之前还需要仔细考虑清楚,因为唯一约束提供的错误检查在缺少索引的时候会丢失。
9.4.4. 移除外键约束
和索引一样,“成批地”检查外键约束比一行行检查效率更高。 因此,先删除外键约束、载入数据然后重建约束会很有用。 同样,载入数据和约束缺失期间错误检查的丢失之间也存在平衡。
更重要的是,当你在已有外键约束的情况下向表中载入数据时, 每个新行需要一个在服务器的待处理触发器事件(因为是一个触发器的触发会检查行的外键约束)列表的条目。载入数百万行会导致触发器事件队列溢出可用内存, 造成不能接受的交换或者甚至是命令的彻底失败。因此在载入大量数据时,可能需要(而不仅仅是期望)删除并重新应用外键。如果临时移除约束不可接受,那唯一的其他办法可能是就是将载入操作分解成更小的事务。
9.4.5. 增加 maintenance_work_mem
在载入大量数据时,临时增大maintenance_work_mem配置变量可以改进性能。这个参数也可以帮助加速CREATE INDEX命令和ALTER TABLE ADD FOREIGN KEY命令。 它不会对COPY本身起很大作用,所以这个建议只有在你使用上面的一个或两个技巧时才有用。
9.4.6. 增加max_wal_size
临时增大max_wal_size配置变量也可以让大量数据载入更快。 这是因为向IvorySQL中载入大量的数据将导致检查点的发生比平常(由checkpoint_timeout配置变量指定)更频繁。无论何时发生一个检查点时,所有脏页都必须被刷写到磁盘上。 通过在批量数据载入时临时增加max_wal_size,所需的检查点数目可以被缩减。
9.4.7. 禁用WAL归档和流复制
当使用 WAL 归档或流复制向一个安装中载入大量数据时,在录入结束后执行一次新的基础备份比处理大量的增量 WAL 数据更快。为了防止载入时记录增量 WAL,通过将wal_level设置为minimal、将archive_mode设置为off以及将max_wal_senders设置为零来禁用归档和流复制。 但需要注意的是,修改这些设置需要重启服务。
除了避免归档器或 WAL 发送者处理 WAL 数据的时间之外,这样做将实际上使某些命令更快,因为如果wal_level是minimal并且当前子事务(或顶级事务)创建或截断了它们更改的表或索引,则它们根本不编写 WAL。(通过在最后执行一个fsync而不是写 WAL,它们能以更小地代价保证崩溃安全)。
9.4.8. 事后运行ANALYZE
不管什么时候你显著地改变了表中的数据分布后,我们都强烈推荐运行ANALYZE。这包括向表中批量载入大量数据。运行ANALYZE(或者VACUUM ANALYZE)保证规划器有表的最新统计信息。 如果没有统计数据或者统计数据过时,那么规划器在查询规划时可能做出很差劲决定,导致在任意表上的性能低下。需要注意的是,如果启用了 autovacuum 守护进程,它可能会自动运行ANALYZE。
9.4.9. 关于pg_dump的一些注记
pg_dump生成的转储脚本自动应用上面的若干个(但不是全部)技巧。 要尽可能快地载入pg_dump转储,你需要手工做一些额外的事情(请注意,这些要点适用于恢复一个转储,而不是创建它的时候。同样的要点也适用于使用psql载入一个文本转储或用pg_restore从一个pg_dump归档文件载入)。
默认情况下,pg_dump使用COPY,并且当它在生成一个完整的模式和数据转储时, 它会很小心地先装载数据,然后创建索引和外键。因此在这种情况下,一些指导方针是被自动处理的。你需要做的是:
-
为maintenance_work_mem和max_wal_size设置适当的(即比正常值大的)值。
-
如果使用 WAL 归档或流复制,在转储时考虑禁用它们。在载入转储之前,可通过将archive_mode设置为off、将wal_level设置为minimal以及将max_wal_senders设置为零(在录入dump前)来实现禁用。 之后,将它们设回正确的值并执行一次新的基础备份。
-
采用pg_dump和pg_restore的并行转储和恢复模式进行实验并且找出要使用的最佳并发任务数量。通过使用-j选项的并行转储和恢复应该能为你带来比串行模式高得多的性能。
-
考虑是否应该在一个单一事务中恢复整个转储。要这样做,将-1或—single-transaction命令行选项传递给psql或pg_restore。 当使用这种模式时,即使是一个很小的错误也会回滚整个恢复,可能会丢弃已经处理了很多个小时的工作。根据数据间的相关性, 可能手动清理更好。如果你使用一个单一事务并且关闭了 WAL 归档,COPY命令将运行得最快。
-
如果在数据库服务器上有多个 CPU 可用,可以考虑使用pg_restore的—jobs选项。这允许并行数据载入和索引创建。
-
之后运行ANALYZE。
一个只涉及数据的转储仍将使用COPY,但是它不会删除或重建索引,并且它通常不会触碰外键。 因此当载入一个只有数据的转储时,如果你希望使用那些技术,你需要负责删除并重建索引和外键。在载入数据时增加max_wal_size仍然有用,但是不要去增加maintenance_work_mem;不如说在以后手工重建索引和外键时你已经做了这些。并且不要忘记在完成后执行ANALYZE。
9.5. 非持久设置
持久性是数据库的一个保证已提交事务的记录的特性(即使是发生服务器崩溃或断电)。 然而,持久性会明显增加数据库的负荷,因此如果你的站点不需要这个保证,IvorySQL可以被配置成运行更快。在这种情况下,你可以调整下列配置来提高性能。除了下面列出的,在数据库软件崩溃的情况下也能保证持久性。当这些设置被使用时,只有突然的操作系统停止会产生数据丢失或损坏的风险。
-
将数据库集簇的数据目录放在一个内存支持的文件系统上(即RAM磁盘)。这消除了所有的数据库磁盘I/O,但将数据存储限制到可用的内存量(可能有交换区)。
-
关闭fsync; 不需要将数据刷入磁盘。
-
关闭synchronous_commit;可能不需要在每次提交时强制把WAL写入磁盘。这种设置可能会在数据库崩溃时带来事务丢失的风险(但是没有数据破坏)。
-
关闭full_page_writes; 不需要警惕部分页面写入。
-
增加max_wal_size和checkpoint_timeout; 这会降低检查点的频率,但会增加/pg_wal的存储要求。
-
创建不做日志的表来避免WAL写入,不过这会让表在崩溃时不安全。