【PostgreSQL】 UPDATE するたびにdead tupleが溜まりクエリが遅くなることを検証してみた

【PostgreSQL】 UPDATE するたびにdead tupleが溜まりクエリが遅くなることを検証してみた

2026.05.25

はじめに

PostgreSQL では、UPDATE を実行するたびに dead tuple と呼ばれる不要なデータが蓄積していきます。

本記事では、1レコードしか持たないシンプルなテーブルを用意して、以下の2点を検証します。

  • UPDATE のたびに dead tuple が増えること
  • dead tuple が増えるとクエリが遅くなること、およびその理由

1 レコードのみのテーブルでも、UPDATE を繰り返すだけでクエリが遅くなる様子を確認してみます。

実行環境

検証のための実行環境は EC2(AL2023)にインストールした PostgreSQL を使用しました。
EC2 に PostgreSQL をインストールする手順としては下記をご参照ください。

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/

PostgreSQL 17.8 を利用し、検証していきます。

[ec2-user@ip-xx-xx-xx-xx ~]$ sudo -u postgres psql
psql (17.8)
Type "help" for help.

postgres=# select * from version();
                                                   version                                                    
--------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.8 on x86_64-amazon-linux-gnu, compiled by gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5), 64-bit
(1 row)

UPDATEするたびにdead tupleが増える

検証用にサンプルテーブルを作成します。

-- (不要なテーブルがあれば以下で削除可能)
-- DROP TABLE IF EXISTS <テーブル名>;

-- テーブル作成
CREATE TABLE one_record_test (
    id   INTEGER,
    name TEXT
);

-- autovacuumを無効化(実験中に自動でdead tupleが消えないようにする)
ALTER TABLE one_record_test SET (autovacuum_enabled = false);

-- 1レコードだけ挿入
INSERT INTO one_record_test VALUES (1, 'Alice');

テーブルには 1 行のレコードのみが格納されている状態です。

-- 確認
SELECT * FROM one_record_test;
-- 結果
 id | name  
----+-------
  1 | Alice
(1 row)

現在の dead tuple 数を確認。現在は 0 です。

-- UPDATE前のdead tupleを確認
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';

-- 結果
     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |          0
(1 row)

この状態で、SELECT の実行時間を計測します。
(※クエリ初回実行時はPostgreSQLの内部処理で遅くなることがあるので、複数回実行しています。)

その結果、以下の通り実行時間はおおよそ 0.02〜0.03 ms でした。

-- 1 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..25.88 rows=6 width=36) (actual time=0.007..0.008 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.076 ms
 Execution Time: 0.031 ms
(4 rows)

-- 2 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..25.88 rows=6 width=36) (actual time=0.011..0.011 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.041 ms
 Execution Time: 0.022 ms
(4 rows)

-- 3 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..25.88 rows=6 width=36) (actual time=0.010..0.010 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.039 ms
 Execution Time: 0.021 ms
(4 rows)

続いてこの id=1 のレコードを 1 万回アップデートします。

-- 同じ1レコードを1万回UPDATEする
DO $$
BEGIN
    FOR i IN 1..10000 LOOP
        UPDATE one_record_test
        SET name = 'updated_' || i
        WHERE id = 1;
    END LOOP;
END $$;

UPDATE 後のタプル数を確認します。
以下の通り、dead tuple が 1 万になりました。

SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';

     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |      10000
(1 row)

UPDATE後のクエリ速度を確認するために再度同じクエリを実行します。
こちらも複数回実行します。

-- UPDATE後のクエリ速度を確認

-- 1回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..163.01 rows=40 width=36) (actual time=0.975..0.977 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.050 ms
 Execution Time: 0.989 ms
(4 rows)

-- 2 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..163.01 rows=40 width=36) (actual time=0.044..0.045 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.041 ms
 Execution Time: 0.056 ms
(4 rows)

-- 3 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..163.01 rows=40 width=36) (actual time=0.043..0.044 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.040 ms
 Execution Time: 0.054 ms
(4 rows)

-- 4 回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..163.01 rows=40 width=36) (actual time=0.045..0.046 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.041 ms
 Execution Time: 0.057 ms
(4 rows)

上記を見ると、実行時間は大体 0.05 ms で安定しています。(初回実行は内部処理の関係で遅くなるので無視します。)

このように UPDATE 回数を増やせば増やすほど、どんどんクエリは遅くなります。
以下はもっと増やしてみた(5 万回 UPDATE した)例です。

-- dead tupleをリセット
VACUUM one_record_test;

-- リセット確認
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';
-- 結果
     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |          0
(1 row)

-- 同じ1レコードを5万回UPDATEする
DO $$
BEGIN
    FOR i IN 1..50000 LOOP
        UPDATE one_record_test
        SET name = 'updated_' || i
        WHERE id = 1;
    END LOOP;
END $$;

-- dead tuple 数の確認 
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';
     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |      50000
(1 row)

-- 5万回UPDATE後のクエリ速度を確認

-- 1回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..318.06 rows=1 width=36) (actual time=4.196..4.198 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.046 ms
 Execution Time: 4.212 ms
(4 rows)

-- 2回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..318.06 rows=1 width=36) (actual time=0.144..0.144 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.041 ms
 Execution Time: 0.155 ms
(4 rows)

-- 3回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..318.06 rows=1 width=36) (actual time=0.147..0.148 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.040 ms
 Execution Time: 0.159 ms
(4 rows)

-- 4回目
postgres=# EXPLAIN ANALYZE
SELECT * FROM one_record_test WHERE id = 1;
                                                 QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..318.06 rows=1 width=36) (actual time=0.141..0.142 rows=1 loops=1)
   Filter: (id = 1)
 Planning Time: 0.038 ms
 Execution Time: 0.153 ms
(4 rows)

上記を見ると、実行時間は大体 0.15 ms で安定しています。
(初回実行は内部処理の関係で遅くなるので無視します。)

まとめると以下となり、dead tuple 増加に伴いクエリ処理が遅くなることがわかりました。

UPDATE回数 dead tupleの数 Execution Time ベースラインとの比較
0回 0 約 0.02 ms -
1万回 10,000 約 0.05 ms 約 2.5倍
5万回 50,000 約 0.15 ms 約 7.5倍

dead tuple が増えると遅くなる理由

前項のようにクエリが遅くなる理由は、dead tuple が増えるとクエリ実行時に読むページ(ブロック)数が増えるためです。

PostgreSQL はデータをディスクやメモリに格納するとき、8KB(8192 byte)単位のかたまりに分けて管理しています。
このかたまりのことを ページ(ブロック) と呼びます。

テーブルとインデックスはすべて、固定サイズ(通常8キロバイト。サーバのコンパイル時に異なるサイズを設定可能)のページの集まりとして格納されます。 テーブルでは、すべてのページは論理上等価です。 したがって、あるアイテム(行)はどのページにでも格納することができます。

65.6. データベースページのレイアウト

他社様の記事になりますが、"ページ" について図解されていてかなりわかりやすかったため、以下も併せてご参照いただけたらと存じます。

共有バッファー(shared_buffers)
ディスク上にあるテーブルやインデックスのデータを、ブロック単位で共有メモリー上にキャッシュするための領域です。データファイルは、複数の8,192バイトのブロックで構成されおり、この単位でキャッシュします(OSのシステムキャッシュを経由します)。...
共有バッファーに読み込まれたブロックは、「ページ」と呼びます。なお、各種技術文書では、「ページ」のことを「ブロック」や「バッファー」と表現されることもあります。

PostgreSQLのアーキテクチャー概要

前項と同じ内容をページ数を確認しながら、改めて検証してみます。
まず、テーブルを VACUUM して綺麗な状態にリセットします。

-- テーブルをきれいな状態に戻す
VACUUM FULL ANALYZE one_record_test;

-- dead tuple数の確認
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';

     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |          0
(1 row)

続いて、現時点でのテーブルのディスクサイズとページ総数を確認します。
現在はページ数は 1、ディスクサイズは 8KB(8192 byte)となっています。
1ページが 8 KB なので、計算上もちゃんと合っています。

-- ページ数とテーブルのディスクサイズの確認
SELECT 
    pg_relation_size('one_record_test') / 8192 AS actual_pages,
    pg_size_pretty(pg_relation_size('one_record_test')) AS size;

 actual_pages |    size    
--------------+------------
            1 | 8192 bytes
(1 row)

※(補足)上記のディスクサイズ計算時に使用される pg_relation_sizepg_size_pretty 関数については過去ブログのテーブル容量の確認の節をご参照ください。

id=1 のレコードをクエリします。
クエリの際に EXPLAIN コマンドのオプションとして BUFFERS を指定します。
これを指定することで、クエリ実行時に参照したページ数を確認することができます。

-- 1回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                
----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..1.01 rows=1 width=18) (actual time=0.007..0.008 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=1
 Planning:
   Buffers: shared hit=13
 Planning Time: 0.095 ms
 Execution Time: 0.019 ms
(7 rows)

-- 2 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                
----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..1.01 rows=1 width=18) (actual time=0.010..0.011 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=1
 Planning Time: 0.041 ms
 Execution Time: 0.021 ms
(5 rows)

-- 3 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                
----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..1.01 rows=1 width=18) (actual time=0.010..0.011 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=1
 Planning Time: 0.040 ms
 Execution Time: 0.021 ms
(5 rows)

BUFFERS オプションを指定すると、結果に以下の情報が追加されます。

項目 意味
shared hit 共有バッファ(メモリ)上にあったページを参照した数
shared read ディスク(またはOSキャッシュ)から読み込んだページ数

※(補足)共有バッファとかいきなり言葉がちょっと難しくなるのですが、再掲ではありますが PostgreSQLのアーキテクチャー概要「図2 共有バッファー(shared_buffers) が非常に分かりやすいためこちらも併せてご参照いただけたらと存じます。

上記の結果では shared read は出ていないため、クエリ実行時に必要なデータはすべて共有バッファ上にあったことがわかります。

上記結果の Buffers: shared hit=1 の部分が、クエリの実行時に共有バッファ上の 1 ページを参照したことを意味しています。
現在のテーブルはページ数が 1 つなので、1 ページだけ参照した形です。

なお、初回クエリでは Planning: の下にも Buffers: shared hit=13 という行が出ていますが、これはクエリの実行計画を立てる段階で参照したページ数です。今回注目するのはその上の Buffers: shared hit=1 の方なのでこの値は無視して大丈夫です。

続いて 1 万回 UPDATE します。

-- 1万回UPDATE
DO $$
BEGIN
    FOR i IN 1..10000 LOOP
        UPDATE one_record_test
        SET name = 'updated_' || i
        WHERE id = 1;
    END LOOP;
END $$;

dead tuple 数が 1 万に増えました。

-- dead tuple数の確認
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'one_record_test';

     relname     | n_live_tup | n_dead_tup 
-----------------+------------+------------
 one_record_test |          1 |      10000
(1 row)

ページ数を確認すると、63 ページに増えています。
8 KB x 63 = 504 KB なので計算上も合っています。

postgres=# SELECT 
    pg_relation_size('one_record_test') / 8192 AS actual_pages,
    pg_size_pretty(pg_relation_size('one_record_test')) AS size;

 actual_pages |  size  
--------------+--------
           63 | 504 kB
(1 row)

クエリ実行時の参照ページ数を確認します。
以下の通り、shared hit=63 となり、クエリ実行時に参照されたページ数が増加していることがわかりました。

-- BUFFERSつきのEXPLAIN ANALYZE

-- 1 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..63.79 rows=1 width=18) (actual time=0.877..0.878 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=63 dirtied=63
 Planning Time: 0.042 ms
 Execution Time: 0.889 ms
(5 rows)

-- 2 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..63.79 rows=1 width=18) (actual time=0.044..0.044 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=63
 Planning Time: 0.061 ms
 Execution Time: 0.055 ms
(5 rows)

-- 3 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..63.79 rows=1 width=18) (actual time=0.045..0.046 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=63
 Planning Time: 0.041 ms
 Execution Time: 0.056 ms
(5 rows)

-- 4 回目
postgres=# EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM one_record_test WHERE id = 1;
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on one_record_test  (cost=0.00..63.79 rows=1 width=18) (actual time=0.046..0.047 rows=1 loops=1)
   Filter: (id = 1)
   Buffers: shared hit=63
 Planning Time: 0.041 ms
 Execution Time: 0.057 ms
(5 rows)

※ 上記の1回目の結果に出ている dirtied=63 はダーティーページ(共有バッファ上で更新操作により内容が変わったけどまだディスクに書き戻されていないページ)を意味していますが今回の主題ではないので無視してください。

結果をまとめると以下となります。

状態 ページ数 shared hit Execution Time
UPDATE前 1 1 約 0.021 ms
1万回UPDATE後 63 63 約 0.056 ms

ページ数と shared hit がぴったり一致しています。
今回の検証のように Seq Scan (全件スキャン)はテーブルの全ページを先頭から順番に読むため、ページ数が増えるほど読む量が増え、クエリが遅くなります。

以下イメージ図の例になります。

【最初の状態(INSERT直後)】
・ページ1つにタプル1つ

┌─────────────────────────── ページ 1(8KB)───────────────────────────┐
│                                                                      │
│  ✅ live tuple │ id=1, Alice                                         │
│                                                                      │
│                        (空き領域)                                   │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘
【1回UPDATEした後】
UPDATE では既存のタプルを上書きせず、
古いタプルを dead tuple(不要なデータ) として残したまま、新しいタプルを追加する

┌─────────────────────────── ページ 1(8KB)───────────────────────────┐
│                                                                      │
│  💀 dead tuple │ id=1, Alice      ← 古いデータ。もう読まれない       │
│  ✅ live tuple │ id=1, updated_1  ← 現在の正しいデータ               │
│                                                                      │
│                        (空き領域)                                   │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘
【1万回UPDATEした後】

┌─────────────────────────── ページ 1(8KB)───────────────────────────┐
│  💀 dead tuple │ id=1, Alice                                         │
│  💀 dead tuple │ id=1, updated_1                                     │
│  💀 dead tuple │ id=1, updated_2                                     │
│  💀 dead tuple │ id=1, updated_3                                     │
│       ...                                                            │
└──────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── ページ 2(8KB)───────────────────────────┐
│  💀 dead tuple │ id=1, updated_xxx                                   │
│  💀 dead tuple │ id=1, updated_xxx                                   │
│       ...                                                            │
└──────────────────────────────────────────────────────────────────────┘

           ・・・ページがどんどん増えていく・・・

┌─────────────────────────── ページ 63(8KB)──────────────────────────┐
│  💀 dead tuple │ id=1, updated_9999                                  │
│  ✅ live tuple │ id=1, updated_10000  ← 現在の正しいデータ           │
└──────────────────────────────────────────────────────────────────────┘

dead tuple はページを占領し続けるため、1 ページに収まりきらなくなるとページ数がどんどん増えていきます。
Seq Scan は全ページを読むため、ページ数が 1 → 63 に増えた分だけ読む量も増え、クエリが遅くなるという仕組みでした。

終わりに

今回は、UPDATE を繰り返すことでクエリが遅くなる様子を確認してみました。
その結果以下の流れでクエリが遅くなることがわかりました。

  • UPDATE 増加 → dead tuple 増加 → 参照ページ数の増加 → クエリ遅延

「ページ」というこれまで難しそうと思っていた概念についても今回の検証を通して学べたため、とても有意義でした。

本記事がどなたかの理解の助けになれば幸いです。

参考情報

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/
https://www.postgresql.jp/document/17/html/storage-page-layout.html
https://global.fujitsu/ja-jp/local/software/postgres/architecture-overview
https://dev.classmethod.jp/articles/postgresql-delete-disk-space-not-reduced-vacuum/#%25E3%2583%2586%25E3%2583%25BC%25E3%2583%2596%25E3%2583%25AB%25E5%25AE%25B9%25E9%2587%258F%25E3%2581%25AE%25E7%25A2%25BA%25E8%25AA%258D

この記事をシェアする

関連記事