PostgreSQL 插入对查询的性能影响

John Doe 十一月 7, 2025

摘要:在本文中,我们将了解 PostgreSQL 插入对查询的影响,及其背后的原理。

目录

测试用例

首先,我们在初始化 PostgreSQL 数据库时启用数据校验和:

initdb --data-checksums

注意:PostgreSQL 18 更改了 initdb 的默认设置,启用了数据校验和(data checksums)。可以使用新的 initdb 选项 –no-data-checksums 来禁用校验和。

创建一个表,插入一定量的初始数据,并对表数据进行清理操作。

CREATE TABLE test_table(id int PRIMARY KEY, info text);

INSERT INTO test_table
  SELECT n, md5(random()::text)
    FROM generate_series(1, 1000000) as n;

VACUUM (analyze) test_table;

再插入一小部分数据,插入数量在全表总行数的 10%。再触发一次检查点操作,刷写缓冲区中的所有脏页面。

INSERT INTO test_table
  SELECT n, md5(random()::text)
    FROM generate_series(1000001, 1100000) as n;

CHECKPOINT;

下面我们来查询同一页面中的 3 条记录,观察这 3 次查询生成的 WAL 记录信息。

EXPLAIN (analyze, buffers, wal, costs false)
  SELECT ctid, info FROM test_table WHERE id = 1050001;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Index Scan using test_table_pkey on test_table (actual time=0.063..0.065 rows=1 loops=1)
   Index Cond: (id = 1050001)
   Buffers: shared hit=4 dirtied=1
   WAL: records=1 fpi=1 bytes=8233
 Planning Time: 0.099 ms
 Execution Time: 0.090 ms
(6 rows)

EXPLAIN (analyze, buffers, wal, costs false)
  SELECT ctid, info FROM test_table WHERE id = 1050002;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Index Scan using test_table_pkey on test_table (actual time=0.065..0.067 rows=1 loops=1)
   Index Cond: (id = 1050002)
   Buffers: shared hit=4
 Planning Time: 0.129 ms
 Execution Time: 0.108 ms
(5 rows)

CHECKPOINT;

EXPLAIN (analyze, buffers, wal, costs false)
  SELECT ctid, info FROM test_table WHERE id = 1050003;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Index Scan using test_table_pkey on test_table (actual time=0.095..0.099 rows=1 loops=1)
   Index Cond: (id = 1050003)
   Buffers: shared hit=4 dirtied=1
   WAL: records=1 fpi=1 bytes=8233
 Planning Time: 0.164 ms
 Execution Time: 0.147 ms
(6 rows)

上面,我们在查询第 3 条记录之前,触发了一次检查点操作,刷写缓冲区中的所有脏页面。可以看到,在页面不脏的情况下,查询新的记录都会生成记录页面镜像的 WAL 日志。同时,这也给查询带来了一定的性能损耗。这里的测试结果并不明显,因为 SELECT 查询不涉及事务提交时刷写 WAL 日志的动作,如果是出现在事务块中,结果就不一样了。

下面,我们来确认下前面查询的 3 条记录的物理位置。如下,可知这 3 条记录都位于页面号为 8750 的同一页面中。

SELECT ctid, info FROM test_table
  WHERE id > 1050000 AND id <= 1050003;
   ctid   |               info
----------+----------------------------------
 (8750,1) | 3b93bf1cbf252960adf883830392c32a
 (8750,2) | 1ef1dd34d614bec1ff42ed41b1b83db8
 (8750,3) | d3e2c47bb12853a9392c850bc37b307a
(3 rows)

为什么在查询第 2 条记录的时候,并没有生成 WAL 记录?让我们来看看 PostgreSQL 源代码中对此的解释:

如果某事务提交时添加 / 删除了大量元组,而我们正对此事务提交后的页面进行首次扫描,那么同一页面可能会多次调用该例程。因此,若缓冲区已处于脏状态(dirty),我们应尽可能加快处理速度。

INSERT 后的清理成本

PostgreSQL 的 INSERT 操作完成后,后面实际上还会有一个延迟的二次更改的过程。

PostgreSQL 向表中插入数据时会创建一个新元组,元组头部会记录创建该元组的事务 ID,称为元组的 xmin。PostgreSQL 将每个事务的当前状态信息存储在提交日志 (CLOG) 中。检查 CLOG 中大量事务的状态会消耗大量资源,因此 PostgreSQL 会将事务状态信息直接缓存到元组的头部。例如,如果在执行 SELECT 语句时检测到 xmin 事务已完成,PostgreSQL 会将此信息保存到元组的所谓“提示位”中。这个过程会将表页面弄脏再写入磁盘,并需要记录 WAL 日志。

也就是说,PostgreSQL 中的 INSERT 实际上将一部分的成本,转移分摊到了后面的 SELECT 操作里面了。

结语

数据页面的校验和特性,是从 PostgreSQL 9.3 就已经支持的了,只是一直到 PostgreSQL 17 以来都是默认关闭的。

上面的问题现象,在关闭数据校验和的情况下是不存在的。这里,记录下问题发现过程:

前几天,有社区博主对某款最近开源的存储引擎进行测评,发现其 INSERT 插入性能较 PostgreSQL 社区版下降一倍。

接下来,我们测试和对比了 PostgreSQL 的插入性能,在做进一步调研分析时,发现了 PostgreSQL 18 引入的这一版本风险。

目前红石 PG 的产品团队正在针对该问题,分析制定升级改进该版本的解决方案。