更新 PostgreSQL 表带来的索引写放大

John Doe 九月 22, 2025

摘要:在本文中,我们将了解更新 PostgreSQL 表带来的索引写放大问题,以及相关的解决方案。

目录

表数据更新场景: 用例

下面我们在 PostgreSQL 的数据库中,创建一个表,插入一些数据,并对表数据进行更新操作,查看更新操作修改的页面数目。

CREATE TABLE boat (id integer, name text, reg double precision);

INSERT INTO boat(id, name, reg)
  SELECT i, md5(i::text), log(i)
    FROM generate_series(1, 500000) as i;

CREATE INDEX boat_idx1 ON boat(id);

VACUUM boat;
CHECKPOINT;

上面我们进行了检查点操作,刷写了缓冲区中的所有脏页面。这样,就可以通过 EXPLAIN ANALYZE 来查看修改的页面情况:

EXPLAIN (analyze, buffers)
  UPDATE boat SET name = 'trade winds'
    WHERE mod(id, 1000) = 1;
                                                  QUERY PLAN
---------------------------------------------------------------------------------------------------------------
 Update on boat  (cost=0.00..12173.00 rows=0 width=0) (actual time=111.126..111.127 rows=0 loops=1)
   Buffers: shared hit=7686 read=506 dirtied=1009 written=4
   ->  Seq Scan on boat  (cost=0.00..12173.00 rows=2500 width=38) (actual time=0.027..95.165 rows=500 loops=1)
         Filter: (mod(id, 1000) = 1)
         Rows Removed by Filter: 499500
         Buffers: shared hit=4673
 Planning:
   Buffers: shared hit=13 read=1
 Planning Time: 0.332 ms
 Execution Time: 111.328 ms
(10 rows)

上面的 UPDATE 操作更新了 500 条记录,修改了 1009 个页面,里面涉及约 500 个堆表页面和 500 个索引页面。

表数据更新场景: 内部原理

下面是我们在 PostgreSQL 中进行表更新时,单个页面内数据记录发生的变化:

索引写放大

PostgreSQL 的多版本并发控制(MVCC)系统旨在减少锁竞争:对于可能仍被旧事务需要的行,更新操作不会直接覆盖,而是在堆表中创建新的行版本(新元组)。当新元组与旧元组不位于同一数据页,或者虽然处于同一数据页,但更新修改了索引相关的列时,就需要创建新的索引条目。之后,旧元组会在堆表中被标记为 “无效”(dead),等待后续清理。

Redrock Postgres 的解决方案

Redrock Postgres 引入了撤消日志,修改元组时可实现就地更新,无需添加索引记录。由于无需修改索引,可避免冗余的索引插入操作,从而帮助减少索引膨胀。

下面我们在 Redrock Postgres 的数据库中,创建和上面相同的表,并插入同样的数据,对表数据进行更新操作,查看更新操作修改的页面数目。

CREATE TABLE boat (id integer, name text, reg double precision);

INSERT INTO boat(id, name, reg)
  SELECT i, md5(i::text), log(i)
    FROM generate_series(1, 500000) as i;

CREATE INDEX boat_idx1 ON boat(id);

VACUUM boat;
CHECKPOINT;

上面我们进行了检查点操作,刷写了缓冲区中的所有脏页面。这样,就可以通过 EXPLAIN ANALYZE 来查看修改的页面情况:

EXPLAIN (analyze, buffers)
  UPDATE boat SET name = 'trade winds'
    WHERE mod(id, 1000) = 1;
                                                  QUERY PLAN
---------------------------------------------------------------------------------------------------------------
 Update on boat  (cost=0.00..11232.00 rows=0 width=0) (actual time=31.615..31.616 rows=0 loops=1)
   Buffers: shared hit=4753 read=7 dirtied=508
   ->  Seq Scan on boat  (cost=0.00..11232.00 rows=2500 width=38) (actual time=0.014..29.597 rows=500 loops=1)
         Filter: (mod(id, 1000) = 1)
         Rows Removed by Filter: 499500
         Buffers: shared hit=3732
 Planning:
   Buffers: shared hit=2 read=7
 Planning Time: 0.260 ms
 Execution Time: 31.707 ms
(10 rows)

上面的 UPDATE 操作更新了 500 条记录,修改了 508 个页面,涉及约 500 个堆表页面和少数其他页面。

显然,此处的更新操作没有修改任何索引页面。并且,执行性能相对会更加高效。