基本的に怠Diary

主に日常と作ったものを書いていく。

PostgreSQL 17でTOASTの動きを調べる その1

TOASTとは

PostgreSQLには「TOAST」という、大きなデータを効率的に扱うための仕組みがある。

TOASTは "The Oversized-Attribute Storage Technique"(過大属性格納技法) の略で、PostgreSQLのページサイズ(8KB)を超えるような大きなデータを保存する際に自動的に働く。名前の由来は、データを「スライスしたパン(toast)」のように小さなチャンク(塊)に分割して管理することから来ている。

TOASTの基本的な動作

  • 対象データ型:TEXT、BYTEA、JSON、配列など、可変長データ型のみ
  • 動作タイミング:データが約2KB(正確には1996バイト)を超えると発動
  • 保存方法:データを圧縮または分割して、専用のTOASTテーブル(pg_toast_xxxxx)に保存
  • データ構造:全ての可変長データは先頭4バイトに値の長さを格納(varlenaヘッダ)

PostgreSQLは以下の順序でデータを処理する:

  1. データが小さい(約2KB以下)→ そのまま元テーブルに保存
  2. データが大きい → まず圧縮を試みる
  3. 圧縮後も大きい → TOASTテーブルに分割保存し、元テーブルにはポインタのみ残す

本記事では、PostgreSQL 17を使ってTOASTの実際の動作を観察する。

実験環境

services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: toast_test
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    command:
      - "postgres"
      - "-c"
      - "shared_preload_libraries=pg_stat_statements"
      - "-c"
      - "max_connections=100"

volumes:
  pgdata:

実験:TOASTの動作を観察する

実験1:TOASTテーブルの確認

まず、TOASTをサポートするデータ型(TEXT)を含むテーブルを作成する。

CREATE TABLE toast_test (
    id SERIAL PRIMARY KEY,
    data TEXT
);

この時点で、PostgreSQLは自動的にTOASTテーブルを作成する。以下のクエリで確認できる。

SELECT 
    c.relname as table_name,
    t.relname as toast_table_name
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relname = 'toast_test';

結果:

 table_name | toast_table_name
------------+------------------
 toast_test | pg_toast_16426

pg_toast_16426という名前のTOASTテーブルが作成されている。この時点ではまだデータを挿入していないため、TOASTテーブルの中身は空だ。

実験2:小さなデータの挿入

小さなデータを挿入してみる。

-- 様々なサイズのデータを挿入
INSERT INTO toast_test (data) VALUES (repeat('A', 100));
INSERT INTO toast_test (data) VALUES (repeat('A', 500));
INSERT INTO toast_test (data) VALUES (repeat('A', 1000));
INSERT INTO toast_test (data) VALUES (repeat('A', 2000));
INSERT INTO toast_test (data) VALUES (repeat('A', 3000));

-- サイズ確認
SELECT 
    id,
    length(data) as text_length,
    pg_column_size(data) as stored_size
FROM toast_test
ORDER BY id;

結果:

 id | text_length | stored_size
----+-------------+-------------
  1 |         100 |         104
  2 |         500 |         504
  3 |        1000 |        1004
  4 |        2000 |        2004
  5 |        3000 |        3004

この時点でTOASTテーブルを確認しても、まだデータは入っていない。

SELECT 
    chunk_id,
    chunk_seq,
    pg_column_size(chunk_data) as chunk_size
FROM pg_toast.pg_toast_16426
ORDER BY chunk_id, chunk_seq;

結果:

 chunk_id | chunk_seq | chunk_size 
----------+-----------+------------
(0 rows)

なぜTOASTに保存されないのか?

上記のデータは全て同じ文字('A')の繰り返しだ。PostgreSQLはデフォルトでデータを圧縮するため、'A'の繰り返しは非常に高い圧縮率で圧縮され、2KB以下に収まってしまう。結果として、TOASTテーブルに追い出される閾値(約2KB)に達しない。

実験3:圧縮できないデータの挿入

圧縮できないランダムなデータを大量に挿入する。

-- ランダムな文字列を32KB分生成して挿入
INSERT INTO toast_test (data) 
SELECT string_agg(md5(random()::text), '') 
FROM generate_series(1, 1000);

このデータはmd5()で生成されたランダムな文字列(32文字)を1000個連結したもので、合計約32KBになる。ランダムなデータは圧縮効率が低いため、TOASTテーブルに保存されるはずだ。

再度TOASTテーブルを確認する。

SELECT 
    chunk_id,
    chunk_seq,
    pg_column_size(chunk_data) as chunk_size
FROM pg_toast.pg_toast_16426
ORDER BY chunk_id, chunk_seq;

結果:

 chunk_id | chunk_seq | chunk_size 
----------+-----------+------------
    16434 |         0 |       2000
    16434 |         1 |       2000
    16434 |         2 |       2000
    16434 |         3 |       2000
    16434 |         4 |       2000
    16434 |         5 |       2000
    16434 |         6 |       2000
    16434 |         7 |       2000
    16434 |         8 |       2000
    16434 |         9 |       2000
    16434 |        10 |       2000
    16434 |        11 |       2000
    16434 |        12 |       2000
    16434 |        13 |       2000
    16434 |        14 |       2000
    16434 |        15 |       2000
    16434 |        16 |         68
(17 rows)

観察結果:

  • chunk_id: 16434 - このデータを識別するID(元テーブルの該当行に対応)
  • chunk_seq: 0〜16 - データが17個のチャンクに分割されている
  • chunk_size: 2000バイト(最後だけ68バイト) - 約2KBずつ分割

合計サイズ:2000 × 16 + 68 = 32,068バイト

PostgreSQLは大きなデータを約2KB(デフォルトではTOAST_MAX_CHUNK_SIZE = 1996バイト、ヘッダ込みで2000バイト前後)ごとに分割し、TOASTテーブルに保存していることが確認できた。


わかったこと

1. TOASTテーブルはテーブル作成時に自動生成される

可変長データ型を含むテーブルを作成すると、PostgreSQLは自動的に対応するTOASTテーブル(pg_toast_xxxxx)を作成する。

2. 小さなデータはTOASTに保存されない

データサイズが約2KB以下の場合、TOASTテーブルには保存されず、元のテーブルにそのまま格納される。

3. 圧縮可能なデータは圧縮される

PostgreSQLはデフォルトでデータを圧縮する(pglzアルゴリズム使用)。圧縮後に2KB以下になれば、TOASTテーブルには保存されない。

4. TOASTは約2KBごとにデータを分割する

大きなデータは約2KB(1996バイト + ヘッダ)ごとに分割され、chunk_seqで順序が管理される。最後のチャンクは残りのサイズになる。


今後の調査項目

  • データを削除した場合、TOASTテーブルのレコードはいつ削除されるのか(即座か、VACUUMまで残るのか)
  • 行外インメモリTOAST(PostgreSQL 14以降の機能)の動作確認
  • TOAST戦略(PLAIN/EXTENDED/EXTERNAL/MAIN)の違いとパフォーマンスへの影響
  • chunk_idと元テーブルの行(ctid)の対応関係
  • 圧縮アルゴリズム(pglz vs lz4)の性能比較
  • TOASTによるSELECT/UPDATE性能への影響

次回はこれらの調査の続きを行う。


参考文献