ページレイアウトとスロット付きページ構造
可変長の行がディスクページのどこに、どう並ぶのかが腑に落ちます。スロット配列・行ヘッダ・前方ポインタの物理レイアウトと、更新で生じる断片化を原理から解説します。
- 1.DB はページの先頭からスロット配列を、末尾から行本体を向かい合わせに伸ばす。スロットが行の位置と長さを指すので、行は移動しても外部から見える ID(ページ番号+スロット番号)は変わらない。
- 2.行ヘッダには NULL ビットマップと可変長フィールドのオフセットが入り、可変長列は本体を後ろにまとめて格納する。これで「N 番目の列」へ定数時間で到達できる。
- 3.更新で行が伸びて同じページに収まらないと、別ページへ移し元スロットに前方ポインタを残す。これと削除の隙間が断片化を生み、定期的な再編成(VACUUM/OPTIMIZE)で回収する。
ページが「行の住所」を安定させる理由
DB はテーブルを固定長のページ(PostgreSQL なら 8KB、InnoDB なら 16KB)の連なりとしてディスクに置きます。1ページが I/O とバッファ管理の最小単位であることは バッファプールとページ置換 や B+Tree インデックスの内部構造 で扱った通りです。ではその 1 ページの内側で、長さの違う行をどう並べるのか。素朴に「行を先頭から詰める」だけでは、ある行が伸び縮みするたびに後続の全行がずれ、その行を指していたインデックスが全て無効になります。これを避けるために考案されたのが スロット付きページ(slotted page) です。
中心となる発想は、行の物理位置(オフセット)を、外から見える行 ID から切り離す ことです。インデックスは「ページ 5 のスロット 3」を指し、スロット 3 が実際の行のオフセットを保持する。行がページ内で動いてもスロットの中身を書き換えるだけで済み、ID は不変に保たれます。PostgreSQL の ctid((ページ番号, スロット番号) の組)はまさにこの ID で、MVCC の内部実装 で版を鎖状に繋ぐのにも使われます。
向かい合わせに伸びる二つの領域
スロット付きページは、ひとつのページを両端から使います。
+--------------------------------------------------+
| ページヘッダ (LSN, チェックサム, 空き境界など) |
+--------------------------------------------------+
| スロット0 | スロット1 | スロット2 | ... | ← 先頭から下へ伸びる
| (空き領域・free space) |
| ... | 行2 | 行1 | 行0 | ← 末尾から上へ伸びる
+--------------------------------------------------+
- スロット配列(slot array / line pointer array) はヘッダ直後から下へ伸びる。各スロットは
(行本体へのオフセット, 行の長さ, フラグ)を持つ固定長の小さなエントリです。 - 行本体 はページ末尾から上へ向かって積まれる。
- 両者の間が 空き領域 で、行を 1 件挿入するたびにスロットが下へ 1 つ、行本体が上へ 1 件分伸び、空き領域が両側から削られていきます。両者が出会ったらそのページは満杯です。
スロットと行本体を逆向きに伸ばすと、空き領域が常に中央の一塊になります。挿入時は「中央の空きが要求サイズ以上か」を境界ポインタ 2 つの比較だけで判定でき、断片化していなければ空き管理が極めて単純です。先頭から両方詰める設計だと、スロット領域と本体領域の境界をあらかじめ決め打つ必要が生じ、片方が枯渇したらもう片方に空きがあっても挿入できません。
スロット番号は安定、オフセットは可変
ここがスロット付きページの肝です。行を削除しても、そのスロット番号はすぐには詰めません。スロットには「無効」フラグを立てるだけで、後続スロットの番号はずらさない。番号をずらすと、その番号を指していた全インデックス・全 ctid が狂うからです。
同様に、ページ内で行本体を再配置(コンパクション)して隙間を詰めても、各スロットのオフセットを書き換えるだけで、スロット番号と行の対応は保たれます。つまりスロット番号は論理的に安定な行の識別子、オフセットは物理的に可変な実体への参照、という二層構造になっています。
| 性質 | スロット番号 | 行本体のオフセット |
|---|---|---|
| 役割 | 行の安定した ID(インデックスが指す先) | ページ内の実際の格納位置 |
| 削除時 | 番号は欠番として残す(再利用は後で) | 本体は隙間になり、後で回収対象 |
| コンパクション時 | 不変 | 詰め直して書き換わる |
| 外部から可視か | 可視(ctid の下位) | 不可視(内部のみ) |
行ヘッダと可変長フィールドの物理配置
1 行の中身も、ただバイトを並べるだけではありません。可変長の列を含む行は、おおむね次の順で格納されます。
[行ヘッダ][NULLビットマップ][固定長列...][可変長列の本体...]
│ │ │
│ │ └ varchar/text などの実体を後ろにまとめる
│ └ どの列が NULL かを 1 ビットずつ
└ MVCC 情報(xmin/xmax 等)・列数・行全体の長さ
ポイントは 可変長列を行の後方にまとめ、固定長部を前に置く ことです。固定長列は先頭からの距離が一定なので、N 番目の固定長列へは加算だけで定数時間で到達できます。一方 varchar のような可変長列は、行ヘッダ内(または可変長領域の先頭)に各列の 長さまたは終端オフセット を並べておき、それを累積して各列の開始位置を求めます。長さの前置によって、可変長が混じっても列アクセスが線形探索にならずに済むわけです。
NULL は値そのものを格納せず、行頭の NULL ビットマップ の対応ビットで表します。NULL 列は本体にバイトを一切持たないため、NULL の多い疎なテーブルは物理的にも小さくなります。これが「末尾に NULL 可の列を足す ALTER は速い」ことの一因です。
1 行がページに収まらないほど大きい場合、可変長列を別領域へ追い出します。PostgreSQL は閾値(既定で約 2KB)を超えると値を圧縮し、なお大きければ TOAST テーブルへチャンク分割して退避し、本体行にはポインタだけを残します。InnoDB も同様に長い VARCHAR/BLOB をオーバーフローページへ逃がします。本体行が小さく保たれるほど 1 ページに入る行数が増え、走査の I/O 効率が上がります。
前方ポインタ:可変長更新が断片化を生む瞬間
可変長行の更新こそ、スロット付きページの最大の難所です。UPDATE で行が 伸びて 元のスロットが指す隙間に収まらなくなったとき、DB は二つの選択を迫られます。
- 同じページに十分な空きがあれば、行をページ内の別オフセットへ移動してスロットのオフセットだけ書き換える。スロット番号は不変なのでインデックスは無傷。
- 同じページに収まらなければ、行を別ページへ移す。このとき元のスロットには移動先
(ページ, スロット)を指す 前方ポインタ(forwarding pointer / pointer record) を残します。
問題は 2 です。前方ポインタを残すと、その行を読むたびに「元ページ → 前方ポインタ → 移動先ページ」と 2 回の I/O が要ります。ポインタの連鎖(移動先がさらに移動)を避けるため、多くの実装は前方ポインタを 1 段に制限し、再移動時は元ページのポインタを張り替えます。InnoDB はクラスタ化インデックスが主キー値で行を引くため前方ポインタは使いませんが、ヒープ構造の Oracle や SQL Server では行移動として明確に現れ、性能劣化の典型要因になります(Oracle では row migration、SQL Server のヒープでは forwarded record と呼ぶ。Oracle の row chaining は別概念で、1 行が 1 ブロックに収まらず複数ブロックに分割される現象を指す)。
行を INSERT 後に UPDATE で NULL や短い値を長い文字列へ書き換えると、確保済みの隙間に収まらず行移動・前方ポインタが多発します。フィルファクタ(ページに残す余白)を下げて更新ぶんの伸びしろを確保するか、可変長列を最初から実寸に近い値で挿入することで移動を抑えられます。これは B+Tree インデックス でフィルファクタを下げてノード分割を減らすのと同じ発想です。
断片化の二つの顔と再編成
スロット付きページで生じる断片化は、原因の違う二種類に分けて捉えると整理できます。
| 種類 | 何が起きているか | 回収する操作 |
|---|---|---|
| 内部断片化(ページ内) | 削除・更新でページ中央に虫食いの隙間。空きはあるが連続していない | ページ内コンパクション(行を末尾へ寄せ、空きを中央に一本化) |
| 外部断片化(ページ間) | 前方ポインタ・行移動で関連行が複数ページへ散る。論理順と物理順がずれる | テーブル再編成(CLUSTER / OPTIMIZE TABLE で再書き出し) |
ページ内コンパクションは、各行本体を末尾側へ詰め直してスロットのオフセットを更新し、欠番スロットを回収する操作です。スロット番号は変わらないため安全に実行できます。PostgreSQL の VACUUM はこの回収を担い、死んだタプルの跡地を同じテーブル内の挿入に再利用します(詳細は MVCC の内部実装 を参照)。一方、行移動で物理的に散らばった行や、論理順と物理順の乖離はページ内処理では直せず、CLUSTER(PostgreSQL)や OPTIMIZE TABLE(MySQL)でテーブルごと並べ替えて書き直す必要があります。
なお、ここまでは更新を「その場で行を書き換える」前提で説明しましたが、追記専用にして断片化と前方ポインタの問題を構造ごと回避するのが LSM-Tree とログ構造化ストレージ のアプローチです。スロット付きページは更新インプレース型ストレージの基盤であり、その物理レイアウトを押さえておくと、VACUUM が必要な理由も行移動による性能劣化も、現象としてではなく原理として説明できるようになります。
データベース Article
ページレイアウトとスロット付きページ構造を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ストレージ
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
行ヘッダには NULL ビットマップと可変長フィールドのオフセットが入り、可変長列は本体を後ろにまとめて格納する。これで「N 番目の列」へ定数時間で到達できる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「ストレージ / ページ」に近いか確認する。
- 強みである「DB はページの先頭からスロット配列を、末尾から行本体を向かい合わせに伸ばす。スロットが行の位置と長さを指すので、行は移動しても外部から見える ID(ページ番号+スロット番号)は変わらない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。