PostgreSQL 18がやってくる(9) Statistics Import and Export

# はじめに
にゃーん。
今日はPostgreSQL 18で追加された「Statistics Import and Export」について調べてみました。

# Statistics（統計情報）
統計情報はテーブルに格納されたデータをサンプリングして集計した情報である。
与えられたSQL問い合わせを解析後に、プランナを用いて実行計画を生成・選択し、エグゼキュータでその実行計画に基づいた処理を行う。
統計情報は``ANALYZE``,``VACUUM``等のメンテナンス用コマンドの実行、および自動バキューム機能の背景で更新される。

これまで、PostgreSQLの本体機能では統計情報のインポート・エクスポートの機能はなかった。[^1]
PostgreSQL 18でそれが追加されたようである。

# 統計情報のインポート・エクスポートがあると何が嬉しい？
一番ありそうなケースとしては、本番環境の統計情報を用いて、試験環境上で本番環境と同様の実行計画を確認する、というものがある。

性能問題が発生した場合、本番環境の情報そのものを試験環境上に再現し、その環境で問題となったSQLの実行計画や実行時間等を確認できるのが理想ではあるが、実際には以下のような問題のために、本番環境のデータそのものを使うことができないことも多い。
* 機密性の高い情報（特に個人情報）を含むデータを持ち出すことができない
* 本番環境のデータ量が大きく、試験環境上に膨大なデータをロードできない

このようなケースで、統計情報だけを再現して解析できないか、と思っているPostgreSQLサポートの仕事をしている人は多いのではないだろうか。

# 概要
[Commitfest 2025-03](https://commitfest.postgresql.org/52/)でコミットされた項目である。

|タイトル|[Statistics Import and Export](https://commitfest.postgresql.org/patch/4538/)|
|:--|:--|
|Topic|Performance|
|Created|2023-08-31 19:21:04|
|Last modified|2025-03-18 09:47:04|
|Emails|[Statistics Import and Export](https://www.postgresql.org/message-id/flat/CADkLM=cB0rF3p_FuWRTMSV0983ihTRpsH+OCpNyiqE7Wk0vUWA@mail.gmail.com)|

Createdが2023年8月末なので、そこから2年近くかけて長々と議論されている。
（ML自体はちら見しかしてないけど、当初は``EXPORT STATISTICS / IMPORT STATISTICS``のようなSQLコマンド案も検討されていたらしい）

# PostgreSQL 18での改善点（たぶん）
たぶん、この機能に対応したものとしては、以下の2項目の修正がある。
* ``pg_dump``のオプションの追加
* リレーション毎および列毎のオプティマイザの統計情報を変更する関数の追加

## ``pg_dump``のオプションの追加
PostgreSQL 18文書のの[pg_dump](https://www.postgresql.org/docs/devel/app-pgdump.html)[^2]のページを見ると
```text
--no-statistics

    Do not dump statistics.
```
```text
--statistics-only

    Dump only the statistics, not the schema (data definitions) or data. Statistics for tables, materialized views, and indexes are dumped.
```
といったオプションが追加されている。実際に``pg_dump``のヘルプを見ると
```sh
$ pg_dump --version
pg_dump (PostgreSQL) 18devel
$ pg_dump --help
pg_dump dumps a database as a text file or to other formats.
（略）
  --no-statistics              do not dump statistics
（略）
  --statistics-only            dump only the statistics, not schema or data
（略）
Report bugs to <pgsql-bugs@lists.postgresql.org>.
PostgreSQL home page: <https://www.postgresql.org/>
$
```
となっている。

## リレーション毎および列毎のオプティマイザの統計情報を変更する関数の追加
以下の4つの関数が追加された。

|関数名|説明|
|:--|:--|
|pg_restore_relation_stats()|テーブルレベルの統計情報を更新する。<br>``pg_dump``に``--statistics-only``オプションを付与すると、テーブル毎にこの関数を使ったSQLが生成される。|
|pg_restore_attribute_stats()|列レベルの統計情報を更新する。<br>``pg_dump``に``--statistics-only``オプションを付与すると、テーブル毎にこの関数を使ったSQLが生成される。|
|pg_clear_relation_stats()|指定されたリレーションのテーブルレベルの統計情報を消去する。|
|pg_clear_attribute_stats()|指定された列レベルの統計情報を消去する。|

詳細はPostgreSQL 文書[Database Object Statistics Manipulation Functions](https://www.postgresql.org/docs/devel/functions-admin.html#FUNCTIONS-ADMIN-STATSMOD)参照。

# 統計情報インポート・エクスポートの実験
* データベースから、スキーマ情報（``--schema-only``）, 統計情報（``--statistics-only``）の2つのダンプを作成（エクスポート）する。
* 別のデータベースを新規作成し、そこにスキーマ情報をリストアし、更に統計情報をリストア（インポート）する。

という手順を試してみる。

## 検証手順

* 2つのデータベース（``testdb1``,``testdb2``）を作成する。
* データベース1（``testdb1``）に対して``pgbench``を使ってデータを生成する。
  * 今回は``--unlogged-tables -s -i 100``のオプションで生成する。
  * この処理の最後に``VACUUM``は実行されるので、手動での``ANALYZE``はかけない
* ``testdb1``から、2つのダンプファイルを作成する。
  * ダンプ①：スキーマのみ
  * ダンプ②：統計情報のみ（統計情報のエクスポート）
* ``testdb2``（テーブル等何もない状態）に対してダンプ①の内容をリストアしてスキーマをリストアする。
* ``testdb2``に対してダンプ②の内容をリストア（統計情報のインポート）を行う。

## 検証
### ロード元情報の生成
データベースを作成し、``testdb1``にデータをロードする。
```sql
$ createdb testdb1
$ createdb testdb2
$ pgbench -q --unlogged-tables -i -s 100 testdb1
dropping old tables...
NOTICE:  table "pgbench_accounts" does not exist, skipping
NOTICE:  table "pgbench_branches" does not exist, skipping
NOTICE:  table "pgbench_history" does not exist, skipping
NOTICE:  table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
vacuuming...
creating primary keys...
done in 19.88 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 12.77 s, vacuum 1.44 s, primary keys 5.67 s).
$
$ psql testdb1 -c "\d"
              List of relations
 Schema |       Name       | Type  |  Owner
--------+------------------+-------+----------
 public | pgbench_accounts | table | postgres
 public | pgbench_branches | table | postgres
 public | pgbench_history  | table | postgres
 public | pgbench_tellers  | table | postgres
(4 rows)
```
この状態で、``pgbench_accounts``テーブルの統計情報（``pg_class``の一部情報）と、``phbench_accounts``テーブルの``aid``列の統計情報（``pg_stat``の情報）を確認する。

```sql
SELECT * FROM pg_class WHERE relname = 'pgbench_accounts';
-[ RECORD 1 ]-------+-----------------
oid                 | 17243
relname             | pgbench_accounts
（略）
relpages            | 163935
reltuples           | 1e+07
（略）
reloptions          | {fillfactor=100}
relpartbound        |
```
* relpages が 163935
* reltuples が 1e+07

になっている。

同様に、``aid``に関する``pg_stats``の内容を確認する。

```sql
SELECT * FROM pg_stats WHERE tablename = 'pgbench_accounts' and attname = 'aid';
-[ RECORD 1 ]----------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
schemaname             | public
tablename              | pgbench_accounts
attname                | aid
inherited              | f
null_frac              | 0
avg_width              | 4
n_distinct             | -1
most_common_vals       |
most_common_freqs      |
histogram_bounds       | {202,102981,186932,295469,396364,491295,580200,691961,789102,887906,992670,1082830,1185283,1282845,1392116,1496038,1601925,1708536,1800800,1904134,2015175,2107177,2207968,2306222,2407679,2498369,2600655,2704436,2794225,2900161,3000809,3098785,3195967,3297153,3400959,3506560,3611174,3724172,3831007,3910212,4004889,4110323,4207622,4318832,4407394,4508261,4615179,4714502,4838380,4936410,5021386,5118124,5224184,5324584,5409281,5504100,5606067,5701194,5795212,5901847,6014239,6120474,6218626,6324358,6424110,6525519,6615489,6705850,6808518,6916014,7014634,7109569,7190262,7289520,7395776,7502947,7602610,7696250,7797450,7890249,7985488,8097459,8195387,8278495,8369879,8462652,8562648,8670678,8777171,8867856,8957037,9055955,9166838,9287990,9395257,9490087,9585257,9696292,9802422,9901165,9999874}
correlation            | 1
most_common_elems      |
most_common_elem_freqs |
elem_count_histogram   |
range_length_histogram |
range_empty_frac       |
range_bounds_histogram |
```

### スキーマと統計情報のダンプ

次に``testdb1``からダンプ①とダンプ②を生成する。
```sql
$ pg_dump testdb1 --schema-only > dump1.txt
$ pg_dump testdb1 --statistics-only > dump2.txt
$
```
さて、``dump2.txt``の内容をざっと見てみると
* pg_catalog.pg_restore_relation_stats()
* pg_catalog.pg_restore_attribute_stats()

の関数をSELECT文の中で実行しているのがわかる。
名前から推測すると

|関数名|内容|
|:--|:--|
|pg_restore_relation_stats()|リレーション（テーブル）の統計情報を設定する|
|pg_restore_attribute_stats()|列の統計情報を設定する|

だと思われる。
``pgbench_accounts``テーブルに対する``pg_restore_relation_stats()``を含むダンプ結果は以下のようになっている。
```sql
SELECT * FROM pg_catalog.pg_restore_relation_stats(
        'version', '180000'::integer,
        'schemaname', 'public',
        'relname', 'pgbench_accounts',
        'relpages', '163935'::integer,
        'reltuples', '1e+07'::real,
        'relallvisible', '163935'::integer,
        'relallfrozen', '163935'::integer

```
同様に、``pgbench_accounts``テーブルの``aid``列に対する``pg_restore_relation_stats()``を含むダンプ結果は以下のようになっている。
```sql
SELECT * FROM pg_catalog.pg_restore_attribute_stats(
        'version', '180000'::integer,
        'schemaname', 'public',
        'relname', 'pgbench_accounts',
        'attname', 'aid',
        'inherited', 'f'::boolean,
        'null_frac', '0'::real,
        'avg_width', '4'::integer,
        'n_distinct', '-1'::real,
        'histogram_bounds', '{1291,104116,199210,296747,405472,510215,608202,697506,787612,886962,986650,1090821,1198489,1299964,1394042,1493898,1596413,1691514,1799730,1900473,1997932,2104966,2197069,2286913,2394862,2495289,2596356,2696193,2797842,2894879,2992962,3094084,3197517,3298637,3392298,3501566,3600174,3691425,3795028,3898344,4008606,4122378,4216826,4307612,4407529,4501581,4599542,4708353,4807296,4905132,5005802,5104508,5214602,5321277,5422584,5516353,5612240,5697724,5792144,5907602,6018318,6113929,6228788,6329540,6438817,6537314,6634250,6734005,6831244,6917103,7015041,7117619,7226234,7318893,7411333,7528255,7622165,7721743,7832131,7937141,8030219,8135315,8224764,8325874,8429616,8513793,8615726,8717252,8826804,8931979,9030454,9133285,9225233,9328865,9426936,9518427,9619092,9716573,9808143,9909959,9999638}'::text,
        'correlation', '1'::real
);
```

``--statistics-only``オプションによるダンプファイルは、以下を生成するようだ。
* ダンプ対象のテーブル数分、``pg_restore_relation_stats()``を実行するSELECT文
* ダンプ対象のテーブルに含まれる列数分、pg_restore_attribute_stats()を実行するSELECT文

### 新しいデータベースへのスキーマと統計情報のインポート
次に、空のデータベース（``testdb2``）にダンプをリストアする。
まず、スキーマ情報（``dump1.txt``）をリストアしてスキーマのみを作成する。

```sql
$ psql testdb2 -c "\d"
Did not find any relations.
$ psql testdb2 -f dump1.txt
（略）
$ psql testdb2 -c "\d"
              List of relations
 Schema |       Name       | Type  |  Owner
--------+------------------+-------+----------
 public | pgbench_accounts | table | postgres
 public | pgbench_branches | table | postgres
 public | pgbench_history  | table | postgres
 public | pgbench_tellers  | table | postgres
(4 rows)

$
```
この状態で、統計情報（``dump2.txt`）をインポートする。
```sql
$ psql -p 18001 -U postgres testdb2 -f dump2.txt
（略）
 pg_restore_relation_stats
---------------------------
 t
(1 row)

 pg_restore_attribute_stats
----------------------------
 t
(1 row)
（略）
$
```

### インポートされた統計情報の確認
インポートされたデータベース上の``pgbench_accounts``に関する統計情報と、``aid``列に関する統計情報を確認する。

```sql
$ psql -p 18001 -U postgres testdb2 -a -f test.sql
\x
Expanded display is on.
SELECT * FROM pg_class WHERE relname = 'pgbench_accounts';
-[ RECORD 1 ]-------+-----------------
oid                 | 17287
relname             | pgbench_accounts
（略）
relpages            | 163935
reltuples           | 1e+07
（略）
reloptions          | {fillfactor=100}
relpartbound        |

SELECT * FROM pg_stats WHERE tablename = 'pgbench_accounts' and attname = 'aid';
-[ RECORD 1 ]----------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
schemaname             | public
tablename              | pgbench_accounts
attname                | aid
inherited              | f
null_frac              | 0
avg_width              | 4
n_distinct             | -1
most_common_vals       |
most_common_freqs      |
histogram_bounds       | {1291,104116,199210,296747,405472,510215,608202,697506,787612,886962,986650,1090821,1198489,1299964,1394042,1493898,1596413,1691514,1799730,1900473,1997932,2104966,2197069,2286913,2394862,2495289,2596356,2696193,2797842,2894879,2992962,3094084,3197517,3298637,3392298,3501566,3600174,3691425,3795028,3898344,4008606,4122378,4216826,4307612,4407529,4501581,4599542,4708353,4807296,4905132,5005802,5104508,5214602,5321277,5422584,5516353,5612240,5697724,5792144,5907602,6018318,6113929,6228788,6329540,6438817,6537314,6634250,6734005,6831244,6917103,7015041,7117619,7226234,7318893,7411333,7528255,7622165,7721743,7832131,7937141,8030219,8135315,8224764,8325874,8429616,8513793,8615726,8717252,8826804,8931979,9030454,9133285,9225233,9328865,9426936,9518427,9619092,9716573,9808143,9909959,9999638}
correlation            | 1
most_common_elems      |
most_common_elem_freqs |
elem_count_histogram   |
range_length_histogram |
range_empty_frac       |
range_bounds_histogram |

```
エクスポート元データベース(``testdb1``）と同様の統計情報がインポートされていることがわかる。

## 実行計画はどうなる？
ここまでの手順で``testdb2``ににも同じ統計情報が設定されたが、では、レコードが1件もない状態でも、``testdb1``と同じ実行計画が作成されるのだろうか？
全件検索と``aid``列へのインデックス検索のケースを試してみると・・・

```sql
EXPLAIN SELECT count(aid) FROM pgbench_accounts;
                              QUERY PLAN
----------------------------------------------------------------------
 Aggregate  (cost=0.00..0.01 rows=1 width=8)
   ->  Seq Scan on pgbench_accounts  (cost=0.00..0.00 rows=1 width=4)
(2 rows)

EXPLAIN SELECT * FROM pgbench_accounts WHERE aid = 1;
                           QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on pgbench_accounts  (cost=0.00..0.00 rows=1 width=97)
   Filter: (aid = 1)
(2 rows)

```
そんな都合良くはいかない。reltuples, relpagesの値は書き換わっているはずなのに何故・・・

PostgreSQL 16（日本語文書）の[行数推定の例](https://www.postgresql.jp/document/16/html/row-estimation-examples.html)を読むと、
> これらの値は最後にそのテーブルをVACUUMまたはANALYZEを行った時点のものです。 プランナはその後、テーブル内の実際のページ数を取り出します（これはテーブルスキャンを行わない安価な操作です）。 それがrelpagesと異なる場合、reltuplesを得られたページ数の割合に応じて変更して現在の推定行数を求めます。 

とある。今回、``testdb2``はデータ0件のままになっている。つまり、実際のページ数は0のまま。
ページ数が0なので、reltuplesも実質0と見なされてcostを計算してしまうようだ。
（なので、``WHERE aid = 1``のケースもインデックス検索にはならないのだろう）

# おわりに
今回は統計情報のインポートやエクスポートに関する、PostgreSQL 18の改善内容について調べてみました。統計情報のインポートやエクスポート自体の挙動は確認できましたが、この機能だけだと、本番環境から統計情報だけエクスポートして、試験環境に統計情報だけインポートするだけなので、残念ながらレコードをロードせずに、本番環境と同じ実行計画を生成を再現させる、というのはできなさそうです。
**もしかすると、何か手順を忘れているだけかもしれないですが・・・**


[^1]: 拡張機能として統計情報のインポート・エクスポートを行うために、[pg_dbms_stats](https://github.com/ossc-db/pg_dbms_stats/tree/master)がある。これは統計情報のインポート・エクスポートだけでなく、統計情報の固定化する機能ももつ。ただ、AWS Aurora等のPostgreSQL PaaSでは対応していない。
[^2]: 2025-05-04時点。