3.7. 扩展性和性能

3.7.1. 多连接
3.7.2. 检测问题
3.7.3. 日志记录
3.7.4. 导入和导出
3.7.5. 外部数据包装器 (FDW)
3.7.6. 全文搜索
3.7.7. 实用工具
3.7.8. 内存管理
3.7.9. 网络
3.7.10. 游标
3.7.11. 锁
3.7.12. HOT
3.7.13. 表布局
3.7.14. 事务
3.7.15. WAL 和检查点
3.7.16. 索引
3.7.17. 查询规划
3.7.18. 参考资料

3.7.1. 多连接

想要处理许多并发客户端?那么请执行以下操作:

  • 在每个应用程序服务器以及中央服务器上设置连接池。

  • 限制 max_connections 和实际连接的数量为 CPU 核心数量的几倍,最多几百个。

  • 性能在超过这个限制时往往会下降,主要是因为:

    • 客户端后端占用大量内存,可能导致交换。

    • CPU 上下文切换

    • CPU 缓存行争用

    • 锁,特别是自旋锁:如果一个进程持有自旋锁并且其他进程进入同一保护区域,后来者将会在锁上自旋等待并继续消耗 CPU。

    • 处理 Lightdb 内部数据结构:某些数据结构及其处理取决于连接的数量;创建快照尤其突出。

  • 即使是空闲连接也不是无辜的。它们也导致高资源使用。

3.7.2. 检测问题

提高 track_activity_query_size 用于 ORM(对象关系映射)

  • 一些视图如 pg_stat_activity 和 pg_stat_statements 显示查询字符串。

  • 因为这些查询字符串存储在固定大小的共享内存中,因此每个这样的查询字符串的长度是固定的,即 track_activity_query_size。更长的查询会在达到这个限制时被截断。

  • Hibernate 或其他一些 ORM 会产生非常长的查询。可以考虑将 track_activity_query_size 设置为 32 KB 左右。

3.7.3. 日志记录

服务器日志可能会阻塞但不显示等待

  • 日志收集器(logger)是唯一写入服务器日志文件的进程。

  • 每个后端进程将日志写入其标准错误输出,该标准错误通过 Unix 管道连接到 logger 的读取端点。logger 从管道中读取消息并将它们写入文件。

  • 如果管道满了,当后端尝试向其写入时可能会阻塞。当 logger 落后于大量的日志记录时就会发生这种情况,例如当使用某些组合的 ltAudit、auto_explain 和 log_min_duration_statement,并且有许多并发会话正在运行短查询。

  • 在管道上的阻塞不被视为等待事件(可能是错误),因此后端似乎在消耗 CPU。

3.7.4. 导入和导出

ALTER TABLE SET UNLOGGED/LOGGED 是繁重的操作

  • 这将整个表重写到新的数据文件并在 WAL 中记录这些写入。

  • 因此,不能用于高效的数据加载——将表切换为 UNLOGGED,加载数据到表中,然后再将其设置回 LOGGED。

使用 COPY 加载数据后查询或首次 VACUUM 很慢

  • 这是因为这些命令必须为它们想要看到的行设置提示位。设置提示位会修改共享缓冲区,并且如果启用了数据校验和,则会 WAL 记录更改。这可能会生成大量的写入。

  • COPY (FREEZE) 可以解决这个问题。FREEZE 选项冻结已加载的行并设置它们的提示位。

  • 表必须在当前子事务中创建或截断。这是为了防止其他事务在 COPY 事务提交之前看到被冻结的行。

3.7.5. 外部数据包装器 (FDW)

通过 postgres_fdw 加速查询

  • 手动对远程表运行 ANALYZE

    • autovacuum 不会在远程表上执行 ANALYZE。因此,本地统计信息可能会过时,导致查询计划不佳。

  • 对于长时间运行的查询启用 use_remote_estimate

    • 例如, ALTER FOREIGN SERVER/TABLE ... OPTIONS (use_remote_estimate 'true');

    • 这使得 postgres_fdw 发出 EXPLAIN 命令,在远程服务器上进行成本估算。

    • 查询规划时间会由于 EXPLAIN 的往返而变长。因此,对于短查询来说,这可能不值得。你可以为不同的 OLTP、批处理和分析工作负载使用具有不同设置的不同远程服务器/表。

  • 增加 fetch_size

    • 例如, ALTER FOREIGN SERVER/TABLE ... OPTIONS (fetch_size '1000');

    • postgres_fdw 使用游标从远程表中获取行。fetch_size 确定一次获取的行数。默认值是 100。

    • 如果网络延迟较高,增加此设置减少往返次数可能会有所帮助。请注意,较高的值需要更多的内存来存储获取的行。

  • 增加 batch_size

    • 例如, ALTER FOREIGN SERVER/TABLE ... OPTIONS (batch_size '1000');

    • 默认情况下,postgres_fdw 在多行插入期间每次插入一行到远程表中(INSERT ... SELECT, INSERT ... VALUES (row1), (row2),..., COPY FROM)。

    • 提高此设置将显著提高吞吐量,特别是在网络延迟较高的情况下。

  • 列出 extensions 参数中在本地和远程服务器上具有兼容行为的扩展名

    • 例如, ALTER FOREIGN SERVER ... OPTIONS (extensions 'extension1,extension2');

    • 这些扩展中的不可变函数和运算符被认为在本地和远程服务器上产生相同的结果。因此,它们的执行会被推送到远程服务器。

    • 当这些函数和运算符在 WHERE 子句中使用时,这一点特别有益。那些过滤条件将在远程服务器上执行,从而减少了传输的行数。

3.7.7. 实用工具

快速随机抽样表行

  • 传统方法很慢,因为它扫描并排序整个表。

    • SELECT * FROM mytable ORDER BY random() LIMIT 1;

  • 使用 TABLESAMPLE 子句几乎独立于表大小快速返回行。

    • TABLESAMPLE 获取表的一个样本部分。提供了一些内置的抽样方法。

    • 此外,可以通过添加扩展来自定义抽样方法。例如,tsm_system_rows 检索指定数量的随机行:

      • CREATE EXTENSION tsm_system_rows;

      • SELECT * FROM mytable TABLESAMPLE SYSTEM_ROWS(1);

    • SYSTEM_ROWS 在表的数据文件中挑选一个随机块,然后按顺序从中获取行。如果需要更多行,则会选择额外的块。

3.7.8. 内存管理

使用大页内存

  • 设置 huge_pages = on。

    • 这将显著减少内存使用,因为页表会变得更小。

    • 同时,由于减少了CPU的TLB缓存缺失,性能也将得到改善。

  • 应该优先选择 huge_pages 设置为 "on" 而不是 "try",考虑到减少内存使用和提升性能作为稳定操作的一部分。

    • 当 huge_pages 设置为 "on" 并且操作系统无法分配足够的大页时,Lightdb 将拒绝启动,并发出以下消息:

      • FATAL: could not map anonymous shared memory: Cannot allocate memory

      • HINT: This error usually means that Lightdb's request for a shared memory segment exceeded available memory, swap space, or huge pages. To reduce the request size (currently 1234567890 bytes), reduce Lightdb's shared memory usage, perhaps by reducing shared_buffers or max_connections.

    • 在这种情况下,重新启动操作系统或进行故障转移。

共享缓冲区的提示

  • 避免客户端后端写磁盘。

    • 如果服务器进程想要一个新的页面时没有可用的共享缓冲区,它必须驱逐一个已使用的缓冲区。如果被驱逐的页面是脏的,服务器进程需要将页面写入磁盘。这增加了响应时间。

    • 这种不理想的情况可以通过检查 pg_stat_bgwriter 中的buffers_backend来检测。如果buffers_backend_fsync也很高,情况更糟。

    • 为了缓解这种情况:

      • 增加更多空闲缓冲区:增加shared_buffers。

      • 增加更多干净缓冲区:增加bgwriter_lru_multiplier以便后台写入器更积极地写入脏缓冲区。如果pg_stat_bgwriter中的maxwritten_clean经常上升,请尝试增加bgwriter_lru_maxpages。

  • 过大的共享缓冲区可能是反作用的。

    • 在高性能存储主机上,共享缓冲区的好处会减弱。

    • 这是因为Lightdb使用操作系统的文件系统缓存:数据同时被缓存在文件系统缓存和共享缓冲区中(双重缓存)。

    • 因此,从RAM的25%开始用于共享缓冲区,并根据观察到的一些改进将其增加到大约40%。

    • 然而,一些基准测试表明64GB或更多可能是有害的。

  • 利用 lt_prewarm 在故障转移后迅速恢复性能。

    • 数据库服务器重启或故障转移后,共享缓冲区的内容为空或与故障转移前的内容有很大不同。因此,应用程序响应时间会变差,直到共享缓冲区预热为止。

    • 在shared_preload_libraries中添加lt_prewarm,并设置lt_prewarm.autoprewarm为on。

    • 这启动了autoprewarm工作者,它周期性地保存一个文件,其中包含共享缓冲区中缓存的关系和块号列表。在服务器启动时,lt_prewarm工作者读取该文件以重新填充共享缓冲区。

  • "SELECT * FROM some_table;"不一定缓存整个表。

    • 你可能想这样做来进行性能测试或应用程序预热,但实际上并没有效果。而且,它根本不会缓存索引。

    • 如果关系的大小大于共享缓冲区的四分之一,其顺序扫描仅使用256KB的共享缓冲区。

    • 这背后的想法是,只有通过此类扫描访问过的页面很可能不会很快再次需要,因此Lightdb试图防止如此大的顺序扫描从共享缓冲区中驱逐许多有用的页面。

    • 同样,大量写入操作,如COPY FROM和CREATE TABLE AS SELECT,也仅使用16MB的共享缓冲区。

    • 为了缓存整个关系,运行SELECT pg_prewarm('relation_name')。这也适用于索引。

关于本地内存的建议

  • 设置足够的 work_mem 需要反复试验。

    • 不幸的是,没有简单的方法可以估计一个 work_mem 设置以避免磁盘溢出。

    • 日志中显示的临时文件 (log_temp_files) 并不足以估计所需的内存。还必须包括在内存中缓冲临时数据的额外开销。

    • 一种估算 work_mem 的方法是乘以查询计划中排序或哈希的行的宽度和数量。再为开销加上一些余量,比如说再乘以 1.1 或类似数值。

    • 如果使用并行查询,则需要将结果除以 (使用的并行工作进程数 + 1)。“+1” 是为了并行主进程。

    • 运行 EXPLAIN ANALYZE 查看是否使用了外部文件。尝试增加 work_mem 直到不再使用外部文件为止。

  • effective_cache_size 并不分配任何内存。

    • 这个参数只用于估计索引扫描的成本。规划器假设这个数量的内存可用于缓存查询数据。

    • 较高的值使得使用索引扫描的可能性更大,较低的值使得使用顺序扫描的可能性更大。


3.7.9. 网络

运行大量短 SQL 命令时注意网络延迟

  • 您的应用程序在迁移至新环境后是否变得慢了很多,尤其是在执行大量短 SQL 命令的情况下?

  • 可能是因为网络延迟较高。请检查网络通信是否缓慢。

    • 测量简单的 SQL 命令的往返时间,例如,

      • \timing on

      • SELECT 1;

    • 检查 ClientRead 和 ClientWrite 等待事件是否增加。


3.7.10. 游标

  • DECLARE CURSOR 是快速的。它创建了一个查询计划,但并不计算结果集。FETCH 开始计算结果集。

  • 游标查询与非游标查询的规划方式不同。相同的 SELECT 命令可能会有不同的查询计划。

    • 非游标查询优化的是总运行时间。优化器假定客户端将消费完整的结果集。

      • 更有可能选择顺序扫描和排序,因为索引扫描被认为代价更高。

    • 游标查询优化的是启动时间和初始数据检索的时间。优化器假定客户端只会获取结果集的一部分。

      • 优化器会选择索引扫描来加速最初 10% 数据的创建。

      • “10%” 可以通过 cursor_tuple_fraction 参数来配置。


3.7.11. 

利用快速路径锁实现高性能

  • 如果大量的并发短事务各自触及多个关系表,则保护锁表的轻量级锁(lwlocks)可能会成为瓶颈。这种争用可以通过 LWLock:LockManager 等待事件看到。

  • 尽管锁表被分为 16 个分区并且由不同的轻量级锁覆盖,数百个并发事务仍可能导致这些轻量级锁上的等待。

  • 快速路径锁解决了这一问题:

    • 弱锁(Access Share、Row Share 和 Row Exclusive 模式)是通过快速路径锁机制获取的。它不使用锁表。相反,这些锁记录在共享内存中的每个后端区域。

    • SELECT 和 DML 操作获取这些弱锁,因此它们不会受到锁管理器轻量级锁争用的影响。

  • 然而,如果满足以下任一条件,则不能使用快速路径锁:

    • 事务已经拥有 16 个快速路径关系锁。每个后端记录区域限制为 16 项。访问具有多个分区和索引的表或者连接多个表的查询将无法使用快速路径锁。

    • 某些事务尝试获取强锁(Share、ShareRowExclusive、Exclusive 和 AccessExclusiveLock 模式)。

      • 同一关系上的现有快速路径锁将转移到锁表。

      • 如果有人持有或请求强锁,后续在不同关系上获取锁的事务可能无法使用快速路径锁。这是因为强锁的存在通过一个包含 1024 个整数计数器的数组来管理,实际上是对锁空间进行了 1024 方式的划分。如果请求的弱锁需要在同一分区中管理,则无法使用快速路径锁。

  • 快速路径锁会在 pg_locks 视图中显示为 fastpath 列为 true。


3.7.12. HOT

利用 HOT (Heap-Only Tuple) 机制

  • HOT 能够加快 UPDATE 操作的速度。

  • 如果不使用 HOT 会有什么问题?

    • 索引会变大,因为每一行的新版本都会在每个索引中有相应的条目。使用这些索引的索引扫描也会变慢。

    • WAL 体积将会更大,更新速度也会更慢,因为任何列的更新都会在所有索引中插入新的条目。

  • 要使 HOT 生效,必须同时满足以下两个条件:

    • 包含更新行的块有足够的自由空间来容纳新的行版本。

    • 更新不修改任何被索引的列。

  • 那么我应该怎么做呢?

    • 在表上设置 fillfactor 以便为新行版本留出空间。

      • 例如,CREATE TABLE mytable ... WITH (fillfactor = 90);ALTER TABLE mytable SET (fillfactor = 90);

      • 较低的 fillfactor 会使表更大,从而导致共享缓冲区缺失和更长的顺序扫描。

      • 也许你可以从 fillfactor = 90 开始,如果 HOT 效果不佳则降低该设置。

    • 删除不必要的索引。

  • 如何知道 HOT 是否在工作?


3.7.13. 表布局

为了达到最佳的存储效率和性能,请按照从最大固定长度类型(如 bigint, timestamp)到最小固定长度类型(如 smallint, bool),再到可变长度类型(如 numeric, text, bytea)的顺序声明表的列。

  • 存储效率来源于数据对齐要求。

    • 例如,bigint 对齐于 8 字节边界,而 bool 对齐于 1 字节边界。

    • 在下面的例子中,前者返回 48,后者返回 39。

      • SELECT pg_column_size(ROW('true'::bool, '1'::bigint, '1'::smallint, '1'::int));

      • SELECT pg_column_size(ROW('1'::bigint, '1'::int, '1'::smallint, 'true'::bool));

    • 可以通过以下命令查看对齐要求:SELECT typalign, typname FROM pg_type ORDER BY 1, 2;

  • 更好的性能来源于上述更小的数据大小和直接的列访问。

    • 如果固定长度的列位于行的前面,Lightdb 可以计算并缓存行中固定长度列的位置。因此,可以使用偏移量直接访问任何行的所请求的固定长度列数据。

    • 一旦出现可变长度列,后续列的位置就需要为每一行进行计算,通过累加其各列的实际长度。结果是,访问行末尾的列将变得较慢。

在 FROM 子句中使用返回复合类型的函数而不是在 SELECT 列表中使用。

  • 假设 sample_func() 的返回类型是一个复合类型 (a int, b int, c int)

  • 不好:SELECT (sample_func()).*;

  • 好:SELECT * FROM sample_func();

  • 在不好的情况下,"(sample_func()).*" 展开成 "(sample_func()).a", "(sample_func()).b", "(sample_func()).c"。因此,函数被调用了三次。

TOAST(The Oversized-Attribute Storage Technique)

  • 这是一种用于存储高达 1GB - 1 大小值的机制。

  • 一个元组不能跨越多个页面。那么,如何存储大于页面大小(通常是 8KB)的列值?

  • 大型列值会被压缩和/或分割成片段。每个片段作为单独的一行存储在与表关联的 TOAST 表中。片段大小的选择使得四个片段行可以适配在一个页面内。对于 8KB 的页面大小来说,这大约是 2000 字节。

  • TOAST-able 数据类型是指具有可变长度(varlena)表示的数据类型。即,一个 1 或 4 字节的 varlena 头部后面跟着列值。char(n) 看起来像是固定长度的,但它具有 varlena 格式。

  • TOAST 表

    • 每个表有 0 或 1 个 TOAST 表和 TOAST 索引。

    • 如果需要的话,TOAST 表及其索引会在 CREATE/ALTER TABLE 时创建。

    • TOAST 表是 pg_toast.pg_toast_<主表OID>。

    • TOAST 索引是 pg_toast.pg_toast_<主表OID>_index。

    • TOAST 表的 OID 存储在表的 pg_class.reltoastrelid 中。

    • TOAST 索引的 OID 存储在 TOAST 表的 pg_class.reltoastidxid 中。

    • 每个 TOAST 表都有以下列:

      • chunk_id OID:标识特定 TOASTed 值的 OID。

      • chunk_seq int:该片段在其值中的序列号。

      • chunk_data bytea:片段的实际数据。

      • 主键(chunk_id, chunk_seq)

  • TOAST 如何工作

    • 当要存储在一个表中的行值宽于 2KB(当页面大小为 8KB 时)时才会触发。

    • 压缩和/或将列值移动到 TOAST 表,直到行值短于 2KB(当页面大小为 8KB 时)或无法获得更多的好处。这个 2KB 的阈值可以通过 CREATE/ALTER TABLE 中的存储参数 toast_tuple_target 来为每个表调整。

    • 将 TOASTed 值的 chunk_id 存储在主表的列中。这被称为 TOAST 指针。

  • 值存储策略——是否应该压缩或移动到 TOAST 表——可以通过四种选项之一来选择: ALTER TABLE ALTER COLUMN column_name SET STORAGE { PLAIN | EXTERNAL | EXTENDED | MAIN }

  • 压缩方法可以在 pglz 和 lz4 之间选择。可以通过 CREATE/ALTER TABLE 中的 COMPRESSION 列选项或默认的 toast_compression 参数为每列设置。

  • 插入一个 TOASTed 值可能会意外地变得很慢。

    • 这通常发生在目标表已经有数百万个 TOASTed 值的情况下,特别是在连续插入大量行之后。

    • 为什么?

      • 每个 TOASTed 值由一个 OID 标识。

      • OID 是一个无符号 4 字节的值,它从一个集群范围内的计数器生成,每 40 亿个值循环一次。因此,单个表不能有超过 2^32(40 亿)个 TOASTed 值。

      • 在插入一个 TOASTed 值时,Lightdb 会为其生成一个新的 OID,检查目标表中是否已经有一个现有的 TOASTed 值使用相同的 OID。如果已使用,则 Lightdb 生成下一个 OID 并再次执行检查。这个过程一直重复直到找到一个空闲的 OID。

      • 如果目标表中连续使用 OID,则这种重试会花费很长时间。

  • 解决方案是分区表。每个分区有自己的 TOAST 表。因此,每个分区中重复 OID 的可能性降低了。


3.7.14. 事务

已删除(已死)的索引元组可能给查询带来神秘的速度提升

  • 如果您遇到同一个查询的相同执行计划的执行时间不稳定,那可能是由于已删除的索引元组导致的。

  • 每当索引扫描获取了一个堆元组,却发现它是已删除的,它就会标记该索引元组为已删除(已死)。然后未来的索引扫描将会忽略它。这避免了它的索引键比较以及堆元组获取。

子事务可能有害

  • 子事务是事务的一部分,它可以回滚而不影响主要(顶层)事务。

  • 子事务是由 SAVEPOINT 命令显式开始的,或者是在 PL/lightdb 中进入带有 EXCEPTION 子句的块时隐式开始的。

  • 一些客户端驱动程序提供了一个选项,为每个 SQL 语句启动和结束一个子事务,比如 ltJDBC 的连接参数 "autosave"。请注意它们的默认值。

  • 每个子事务在其执行需要 XID 的操作(如修改数据或锁定行)时都会分配自己的 XID。

  • 元组头的 xmin 和 xmax 字段记录了更新它的子事务的 XID。为了检查元组的可见性,一个事务看到 xmin/xmax 时需要知道主要事务而非子事务是否已经结束。

  • 如何知道子事务的主要事务:

    • 当子事务分配其 XID 时,它记录其直接父级的 XID 在 $LTDATA/lt_subtrans/ 中。

    • lt_subtrans 的结构是一个 XID 数组。例如,XID 100 的父 XID 存储在数组的第 101 个元素中。数组被分成 8KB 页面。

    • lt_subtrans 数据被缓存在一个包含 32 页的内存区域中。该区域由 SLRU(简单最近最少使用)缓冲区管理。所以,缓存可以包含 32 页 * 8KB / 4 = 65,536 个事务。

    • 因此,为了获取主要事务的 XID,需要遍历与子事务嵌套深度一样多的条目。

  • 检查元组可见性时总是检查 lt_subtrans 吗?

    • 不是。快照不仅存储主要事务的 XID,还存储子事务的 XID。如果检查者的快照包含了所有子事务,那么它就可以不查阅 lt_subtrans 而完成任务。

    • 然而,情况并非总是如此。每个后端在其共享内存中的 ProcArray 条目中最多可以拥有 64 个子事务 XID。如果主要事务有超过 64 个子事务,其 ProcArray 条目将被标记为溢出。

    • 在创建快照时,会扫描所有运行事务的 ProcArray 条目来收集主要和子事务的 XID。如果有任何条目被标记为溢出,快照将被标记为子溢出。

    • 子溢出的快照不包含确定可见性所需的所有数据,因此必须使用 lt_subtrans 追溯元组的 xmin/xmax 到其顶级事务的 XID。

  • 那么问题是什么呢?

    • 读取 lt_subtrans 的进程与写入者(注册其父 XID)争夺用于保护 SLRU 缓冲区的 lwlock。读者和写入者分别获取共享模式和排他模式锁。

    • lt_subtrans 缓存不是很大。在并发子事务较多的情况下,会产生磁盘 I/O。

  • 我怎么知道这种情况发生的可能性呢?

    • 等待事件 LWLock:SubtransBuffer、LWLock:SubtransSLRU、IO:SLRURead 和 IO:SLRUWrite 持续增长。

    • pg_stat_slru 显示其 Subtrans 行中的 blks_read 和 blks_hit 增加。

MultiXact 可能会在幕后损害性能

  • 什么是 MultiXact?

    • 一种记录多个锁定者 XID 的机制。(多事务)

    • 元组头中的 xmax 字段记录锁定该元组的 XID。

    • 那么,当多个事务对同一元组获取锁时会发生什么?

      • 例如,第一个事务 XID 100 执行 SELECT ... FOR SHARE。xmax 变为 100。

      • 接下来,第二个事务 XID 101 对同一元组执行相同的 SELECT ... FOR SHARE。然后,分配一个新的 MultiXact ID,比如说 1,并设置为 xmax 字段。

      • MultiXact ID 1 到实际锁定者 XID(100, 101)的映射被添加到 $LTDATA/lt_multixact/ 中。

  • 外键约束是通过执行 "SELECT ... FOR KEY SHARE" 的约束触发器实现的。因此,MultiXact 可能在您不知情的情况下被使用。

  • 可能的问题是什么?

    • 与 lt_subtrans 类似,lt_multixact 也通过 SLRU 缓存。因此,它可能会遭受 lwlock 争用和磁盘 I/O 的影响。

    • 当一个新的 XID 被添加到现有的 MultiXact ID 作为新成员时,会分配一个新的 MultiXact ID,并将现有成员 XID 复制到新的位置。在上面的例子中,当 XID 102 加入 MultiXact ID 1 的成员(100, 101)时,新的 MultiXact 2 被分配,(100, 101)被复制到这里,102 被添加。如果许多事务并发地锁定同一行,这种复制会变得更严重。

  • 我怎么能知道这种情况发生的可能性?

    • 等待事件 LWLock:MultiXact*、IO:SLRURead 和 IO:SLRUWrite 持续增长。

    • pg_stat_slru 显示其 MultiXactOffset 和 MultiXactMember 行中的 blks_read 和 blks_hit 增加。

    • pg_get_multixact_members('<MultiXact ID>') 返回一组成员 XID 及其锁定模式。


3.7.15. WAL 和检查点

检查点概述

  • 一种处理过程,通过刷新未写入(=脏)的缓存数据来同步内存和存储中的数据。

  • 它何时执行?

    • 自上次检查点以来已过了由 checkpoint_timeout 指定的时间。

    • 积累了一定量的 WAL,这是基于 max_wal_size 的。

    • 在基础备份开始时(pg_basebackup,pg_start_backup())。

    • 关闭数据库实例时。

    • 完成任何形式的恢复时。

    • 其他各种必要的时机,如 CREATE DATABASE,以便可以在不经过共享缓冲区的情况下复制/移动数据文件。

  • 由 checkpoint_timeout 引起的检查点称为定期检查点,其他则称为请求检查点。

  • 在完成检查点时,根据 min_wal_size 删除或回收旧的 WAL 段文件以供将来重用。

    • 这里的“旧”意味着“不再需要用于崩溃恢复,因为这些 WAL 段中的所有更改都已持久化到数据文件中。”

    • 但是,旧的 WAL 段文件会一直保留直到它们被归档并且不再被 wal_keep_size 或任何复制槽位所需要。

  • 检查点具有侵入性是因为:

    • 对数据和 WAL 的存储 I/O 争用。

    • 缓冲区内容锁 lwlock 争用:当检查点持有共享缓冲区的缓冲区内容锁处于共享模式时,修改相同缓冲区的事务需要等待 lwlock 释放。

    • 由于全页写入导致的 WAL 体积增加。

  • 什么是全页写入?

    • 在检查点之后对每个数据页进行首次修改时,整个页面内容会被 WAL 记录而不是仅记录更改。这是为了在恢复期间恢复撕裂页所必需的。

    • 如果主机在 Lightdb 写入页面时崩溃,可能会导致撕裂页。因为 I/O 的原子单位通常小于 Lightdb 的页面大小(通常是 8 KB),因此可能会有一部分页面是新的,而另一部分是旧的。

减少检查点的影响

  • 监控检查点的频率

    • 定期和请求检查点的数量可以通过 pg_stat_bgwriter 中的 checkpoints_timed 和 checkpoints_req 分别查看。

      • 绝大多数检查点应该是定期的而不是请求的。定期检查点允许负载在整个系统的正常运行中均匀分布。频繁的请求检查点可能会导致性能波动。

    • 如果两次连续检查点之间的时间短于 checkpoint_warning 并且较新的一次是由 WAL 积累请求的,则服务器日志会显示以下消息:

      • LOG: checkpoints are occurring too frequently (8 seconds apart)

      • HINT: Consider increasing the configuration parameter "max_wal_size".

  • 通过增加 max_wal_size 和/或 checkpoint_timeout 来降低检查点的频率。

    • 注意,这可能会增加崩溃恢复所需的时间。

  • 将 wal_compression 设置为 on。这会减少全页写入的 WAL。

  • 增加 min_wal_size。这减少了事务需要创建新的 WAL 段文件的需求。

3.7.16. 索引

索引的缺点

  • 索引消耗磁盘空间。

  • 更大的磁盘空间增加了物理备份的大小和持续时间。

  • 索引会减慢 INSERT/DELETE/COPY 语句的速度,因为它们总是必须修改所有索引。

  • 索引阻止了 HOT 更新。HOT 只适用于非索引列的修改。

你可能没有注意到的索引优点

  • B树索引可以加速 max() 和 min() 聚合函数。它们可以直接读取索引末尾的索引条目。

  • 表达式上的索引也会收集表达式计算值的统计信息。

    • 例如, CREATE INDEX myindex1 ON mytable ((col1 + col2 * 3));

    • 您可以看到索引表达式的统计信息。在这种情况下,统计数据会出现在 pg_stats 中,表名为 myindex1,attname 为 expr。

    • 可以为索引表达式设置统计目标。例如, ALTER INDEX index_name ALTER COLUMN expr SET STATISTICS 1000;

  • 外键上的索引可以加快约束处理的速度。

    • 例如, CREATE TABLE orders (..., product_id int REFERENCES products ON CASCADE DELETE);

    • 您可以通过 EXPLAIN ANALYZE 和 auto_explain 查看约束级联处理所花费的时间。外键约束内部是通过触发器实现的。

      • 例如, EXPLAIN ANALYZE DELETE FROM products WHERE product_id = 2;

      • ... Trigger for constraint orders_product_id_fkey: time=0.322 calls=1

让索引扫描工作

  • 使用 EXPLAIN ANALYZE 查看索引扫描需要多少次读取堆。例如,它会显示类似 “Heap Fetches: 0” 的信息。0 是最好的结果。

  • 提高 autovacuum 的频率或运行 VACUUM 来更新可见性图。这将减少堆的读取次数。

3.7.17. 查询规划

ANALYZE 的陷阱

  • autovacuum 不会对临时表或外部表运行 ANALYZE。请手动运行 ANALYZE。

  • 即使表内容没有改变,在运行 ANALYZE 后查询计划也可能会改变。

    • ANALYZE 会从表内容中随机抽取样本(300 x default_statistics_target 行)。因此,收集到的统计信息会因读取的行不同而变化。

    • 为了避免或减少这种查询计划的变化,可以采取以下措施之一:

      • 使用第三方软件如 lt_hint_plan 固定查询计划。

      • 增加 ANALYZE 收集的统计信息量,即使用 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS。 使用更多的行数会使统计波动减少,但这也使得 ANALYZE 和查询规划变慢,因为需要写入或读取更多统计信息。

      • 设置表的存储参数 autovacuum_analyze_threshold 和 autovacuum_analyze_scale_factor 为较大的值,使得 autovacuum 实际上不会对其进行 ANALYZE。然后在必要时手动运行 ANALYZE。

使用返回集合的函数可能导致较差的查询计划

  • 当函数用于 WHERE 子句或连接中过滤行时,可能会观察到这种情况。

  • 这是因为规划器无法获得足够准确的选择性信息。因此,其成本估计将是不准确的。

  • CREATE/ALTER FUNCTION 可以设置固定的开销成本和返回的行数。通过 SUPPORT 子句提供的规划支持函数(需要用 C 语言编写)可以动态地更改开销成本和行数。

    • CREATE FUNCTION ... RETURNS {SETOF ... | TABLE(...)} COST execution_cost ROWS result_rows SUPPORT support_function

定制计划与通用计划

  • PREPARE 执行解析、分析和重写以生成预处理语句。

    • 例如, PREPARE stmt(int) AS SELECT * FROM mytable WHERE col = $1;

  • EXECUTE 制定查询计划并执行它。

    • 例如,EXECUTE stmt(123);

  • 考虑特定参数值的查询计划是最好的。这样的计划称为定制计划。另一方面,不考虑参数值的查询计划称为通用计划。

  • 你可以通过是否存在占位符来区分定制计划和通用计划。例如,

    • 定制计划:Filter: (col = 123)

    • 通用计划:Filter: (col = $1)

  • 但制定计划是有代价的。如果通用计划足够好,Lightdb 会使用它来避免制定定制计划。

    • Lightdb 在预处理语句的前五次执行中使用定制计划。

    • 在第六次执行时,生成一个通用计划,并将其成本与过去五次执行的平均成本进行比较。

    • 如果通用计划的成本较低,则继续采用它。不会考虑定制计划。

    • 否则,创建一个新的定制计划并使用它。在后续的执行中,将通用计划的成本与所有过去的定制计划执行的平均成本进行比较,并选择成本较低的计划。

  • 你可以通过将 plan_cache_mode 设置为 force_generic_plan 或 force_custom_plan 来强制使用通用计划或定制计划。如果通用计划的成本估计过低,这可能是必要的。

3.7.18. 参考资料

Lightdb 文档