行フォーマットとTOAST・オーバーフローページ
ページに収まらない大きな値がどこへ消えるのかが原理から腑に落ちます。PostgreSQL の TOAST と InnoDB のオーバーフローページの仕組み、圧縮・インライン閾値の設計判断まで押さえられます。
- 1.1行はページに収まる必要があるため、大きな可変長値は外部領域へ追い出す。PostgreSQL は TOAST テーブルへチャンク分割、InnoDB は BLOB ページの連鎖へ逃がし、本体行には20バイト前後のポインタだけを残す。
- 2.PostgreSQL は1タプルが約2KB(TOAST_TUPLE_THRESHOLD)を超えると、対象列を圧縮し、なお収まらなければ外部化する。外部値は約2000バイトのチャンクに切って TOAST テーブルへ別タプルとして格納する。
- 3.InnoDB の DYNAMIC 行は外部化列の本体を全てオーバーフローページへ移し本体には20バイトポインタのみ残すが、COMPACT は先頭768バイトを本体に残す。この差が本体ページの行密度とスキャン効率を左右する。
なぜ「大きな値の外出し」が必要なのか
DB はテーブルを固定長のページ(PostgreSQL は 8KB、InnoDB は既定 16KB)の連なりとして格納し、ページレイアウトとスロット付きページ構造 で見たように1ページの中へ行を詰めます。ここに根本的な制約が生まれます。1行(1タプル)は1ページに収まらなければならないのです。B+Tree も連結リストもページ単位で行を指すため、1行が複数ページにまたがると行 ID(PostgreSQL の ctid、InnoDB のクラスタ化インデックス葉)が指す先が一意に定まらなくなります。
実際には「1ページ1行」では密度が悪すぎるので、各実装はもっと厳しい上限を課します。PostgreSQL は 1ページに最低4タプル入ることを目標にし、InnoDB のクラスタ化インデックスは 1ノードに最低2行入ることを要求します。つまり PostgreSQL なら 8KB÷4 ≒ 2KB、InnoDB なら 16KB÷2 弱が「本体に置ける行サイズの上限」の目安です。これを超える大きな text/varchar/blob/jsonb は、本体行から切り離して別の領域へ追い出すしかありません。これが PostgreSQL の TOAST と InnoDB の オーバーフローページです。
TOAST は The Oversized-Attribute Storage Technique の略です。「ページに乗り切らない属性」を別テーブルへ退避する技術、という意味そのままの命名です。対象になるのは TOAST 可能な可変長型(text, varchar, bytea, jsonb, 配列など)に限られ、integer のような固定長型は対象外です。
PostgreSQL:圧縮 → 外部化の二段構え
PostgreSQL の処理は1タプルを書き込む直前に走り、しきい値と戦略の二軸で決まります。
しきい値は TOAST_TUPLE_THRESHOLD(既定で約 2KB、正確には 2032 バイト)です。タプル全体がこれを超えると TOAST 処理が起動し、TOAST_TUPLE_TARGET(既定で同値)を下回るまで、大きい列から順に処理していきます。
各列の扱い方は、列ごとに設定できる storage 戦略で決まります。
| storage | 圧縮 | 外部化 | 既定で使う型 |
|---|---|---|---|
| PLAIN | しない | しない(TOAST不可) | 固定長型(integer 等) |
| EXTENDED | する | する | text / jsonb / bytea など |
| EXTERNAL | しない | する | 圧縮を避けたい場合に手動指定 |
| MAIN | する | 極力しない(最後の手段) | 圧縮で収めたい場合に手動指定 |
EXTENDED な列は、まずインラインのまま圧縮を試みます(既定の圧縮器は PGLZ、PostgreSQL 14 以降は LZ4 も選択可)。圧縮しても本体タプルがしきい値を超えるなら、次に値を外部化します。外部化された値は約 2000 バイトのチャンクに切り分けられ、対象テーブルに紐づく TOAST テーブル(pg_toast.pg_toast_<oid> という裏テーブル)へ、(chunk_id, chunk_seq, chunk_data) の別タプルとして格納されます。本体行に残るのは TOAST ポインタ(約18〜20バイト)だけで、そこに chunk_id・元の長さ・圧縮後の長さが入ります。
substring(doc, 1, 100) のように大きな値の先頭だけ読むクエリでは、storage を EXTERNAL(非圧縮)にすると速くなります。圧縮済みの値は途中だけ取り出すにも全体を復号する必要がありますが、非圧縮ならチャンク境界を計算して必要な範囲のチャンクだけ TOAST テーブルから読めるからです。容量と引き換えにランダムアクセス性を買う設計判断です。
外部化の鍵は、TOAST 値が更新されない限りコピーされないことです。大きな jsonb 列を持つ行で別の小さな列だけを UPDATE すると、PostgreSQL は新しいバージョンのタプルを作りますが(MVCC の内部実装 のとおり追記型)、TOAST ポインタは使い回され、TOAST テーブル側のチャンクは複製されません。大きな値を含む行の頻繁な部分更新が、見た目より安いのはこのためです。
InnoDB:行フォーマットで変わる「本体に残す量」
InnoDB も発想は同じで、長い可変長列をクラスタ化インデックスの葉ページ(クラスタ化インデックスと非クラスタ化インデックスの内部差 参照)から オーバーフローページ(BLOB ページ)の連鎖へ逃がします。違いは「本体に何バイト残すか」が 行フォーマットで決まる点です。
| 行フォーマット | 外部化時に本体へ残す量 | インデックス前置詞 |
|---|---|---|
| REDUNDANT / COMPACT | 値の先頭 768 バイト + 20バイトポインタ | 先頭768バイトで間に合う |
| DYNAMIC(既定) | 20バイトポインタのみ(本体は全て外部) | 前置詞を別途オーバーフローから取得 |
| COMPRESSED | DYNAMIC同様+ページ全体を zlib 圧縮 | 同上 |
古い COMPACT 形式は外部化した列の先頭 768 バイトを本体に残すため、本体行が太り、1ページに入る行数が減ってスキャン効率が落ちます。MySQL 5.7 以降の既定である DYNAMIC はこの 768 バイトを残さず、外部化列は本体に 20 バイトのポインタだけを置きます。本体行が痩せる分、クラスタ化インデックスの走査で1ページあたり多くの行を読め、I/O 効率が上がります。
InnoDB が列を外部化するかどうかは、行全体がページの半分弱(16KB なら約 8126 バイト=行サイズ上限)に収まるかで動的に決まります。収まらなければ、最も長い可変長列から順にオーバーフローへ追い出して本体を縮めます。PostgreSQL がタプルしきい値で起動するのと違い、InnoDB は「1ページに2行入る」制約を満たすための調整、と捉えると分かりやすいです。
外部化された列はインデックスキーに使うとき、値全体ではなく前置詞だけを索引します(INDEX (long_text(255)) のような前置詞索引、あるいは内部上限)。DYNAMIC では本体に先頭バイトが残らないため、前置詞をオーバーフローページから読み出す必要があり、768バイトの前置詞索引はキーが innodb_page_size の制約に当たることがあります。大きな列を索引するなら、前置詞長を明示し、生成列+ハッシュなど別設計を検討するのが安全です。
インライン閾値をどう設計するか
外部化は「本体行を痩せさせてスキャンを速くする」一方、「大きな値へのアクセスに1回余分な I/O を足す」トレードオフです。設計の勘所は、その列のアクセスパターンに合わせて閾値と圧縮を調整することです。
- 大きな値をほとんど読まない場合(ログの本文、添付の中身など)。外部化を積極化したい。本体が痩せれば、その列を含まない大半のクエリが速くなる。PostgreSQL なら
MAINを避け既定のEXTENDEDのまま、InnoDB なら DYNAMIC を使う。 - 大きな値を常に丸ごと読む場合。外部化の余分な I/O が常に乗るため、列を分割するか、圧縮で本体に収める(PostgreSQL の
MAIN)方が有利なこともある。 - 大きな値の先頭・一部だけ読む場合。前述の EXTERNAL(非圧縮外部化)でチャンク部分取得を効かせる。
PostgreSQL では ALTER TABLE ... ALTER COLUMN col SET STORAGE EXTERNAL や SET (toast_tuple_target = N) で挙動を調整でき、toast_tuple_target を下げれば早めに外部化、上げれば本体に粘らせる、という制御ができます。
-- PostgreSQL: この列は圧縮せず外部化(部分取得を速く)
ALTER TABLE docs ALTER COLUMN body SET STORAGE EXTERNAL;
-- 早めに外部化させて本体行を痩せさせる
ALTER TABLE docs SET (toast_tuple_target = 1024);
TOAST もオーバーフローページも「1行が1ページに収まる」制約を守るための外出し機構である点が共通の原理です。PostgreSQL は圧縮を先に試してから外部の TOAST テーブルへチャンク分割、InnoDB は行フォーマット(COMPACT は先頭768バイト残し、DYNAMIC は全外部)でオーバーフローページ連鎖へ、と外出しの粒度が異なります。本体行が痩せるほど無関係なクエリの I/O が減る、という効果は両者に共通します。
行フォーマットの物理レイアウトそのものは ページレイアウトとスロット付きページ構造 と ヒープファイルとテーブルアクセスメソッドの内部 に立ち戻ると一段深く理解できます。TOAST テーブル自体も普通のヒープであり、本体テーブルと同じスロット付きページの仕組みでチャンクタプルを格納している、という入れ子構造に気づくと、「大きな値の外出し」が特別な機構ではなく既存の仕組みの再利用であることが見えてきます。
データベース Article
行フォーマットとTOAST・オーバーフローページを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ストレージ
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
PostgreSQL は1タプルが約2KB(TOAST_TUPLE_THRESHOLD)を超えると、対象列を圧縮し、なお収まらなければ外部化する。外部値は約2000バイトのチャンクに切って TOAST テーブルへ別タプルとして格納する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「ストレージ / TOAST」に近いか確認する。
- 強みである「1行はページに収まる必要があるため、大きな可変長値は外部領域へ追い出す。PostgreSQL は TOAST テーブルへチャンク分割、InnoDB は BLOB ページの連鎖へ逃がし、本体行には20バイト前後のポインタだけを残す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。