PostgreSQL の UPDATE が新しいインデックスエントリを作らない条件:HOT 更新と fillfactor
PostgreSQL の HOT(heap-only tuple)更新が起きる2つの条件、インデックスが参照する列と同じページの空き領域を公式ドキュメントで整理しました。fillfactor の設定と pg_stat_user_tables で比率を見る方法も扱います。
PostgreSQL の UPDATE は行をその場で書き換えません。多版型同時実行制御(MVCC)のため、新しい行バージョンをテーブルに追加し、その行を指すインデックスごとに新しいエントリが必要になることもあります。数値を1つ変えるだけの更新でもインデックスへの書き込みが増えるわけですが、一定の条件がそろえば PostgreSQL はインデックスにこの新しいエントリを入れずに済ませます(BRIN のような要約インデックスはそれでも更新されることがあり、この例外は後で扱います)。この最適化が HOT(heap-only tuple)です。この記事は2026年10月12日時点の最新の正式版である PostgreSQL 18(マイナー 18.6)のドキュメントをもとにしています。
HOT になる2つの条件
ドキュメントは HOT が可能な場合を2つ挙げています。
- 更新が、テーブルのインデックスが参照する列を1つも変更しない。ただし要約(summarizing)インデックスは対象外で、コア配布物で要約インデックスの方式は BRIN だけである。
- 元の行があるページに、更新後の行が入るだけの空き領域が十分にある。
たとえば次のテーブルを考えます。
CREATE TABLE sessions (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
last_seen_at timestamptz NOT NULL,
hits integer NOT NULL DEFAULT 0
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
UPDATE sessions
SET hits = hits + 1, last_seen_at = now()
WHERE id = 42;
インデックスは主キーの id と user_id にだけあり、この UPDATE が変えるのは hits と last_seen_at だけです。1つ目の条件を満たすので、同じページに空きがあれば HOT 更新になります。
ここに CREATE INDEX ON sessions (last_seen_at); を追加すると、同じ UPDATE がインデックスの参照する列を変えることになり、HOT の対象から外れます。検索を速くするために作ったインデックス1つが頻繁に変わる列にかかると、すべての更新のコストが変わりうるということです。
ドキュメントの表現が「インデックスキー」ではなく「インデックスが参照する列」である点にも注意が要ります。式インデックスの式の中で使う列や、部分インデックスの WHERE 条件で使う列も、そのインデックスが参照する列です。たとえば CREATE INDEX ON sessions (user_id) WHERE hits > 100; はキーが user_id ですが、条件で hits を参照しています。業務ロジックの上では「検索キーではないカウンター」のつもりの列でも、インデックス定義のどこかに入っていれば、その列の更新は1つ目の条件を満たしません。
逆に last_seen_at に BRIN インデックスを作る場合は要約インデックスなので、1つ目の条件を崩しません。ただしドキュメントは、HOT 更新でも要約インデックスは更新が必要になることがあると書いています。
HOT が節約するもの
2つの条件を満たしたとき、ドキュメントが挙げる利点は2つです。
1つ目に、更新後の行を表すためにインデックスへ新しいエントリを入れる必要がありません。上の例なら、UPDATE のたびに主キーのインデックスと sessions_user_id_idx に書いていたエントリが生じません。
2つ目に、1つの行が何度も更新されたとき、最も古いバージョンと最も新しいバージョン以外の中間バージョンを、定期的な VACUUM を待たずに通常の処理中に完全に削除できます。ドキュメントはここに SELECT も含まれると明記しています。読み取りクエリがページを片付けることがあるということです。
これが可能なのはページ構造のおかげです。テーブルとインデックスは通常 8kB の固定サイズのページの配列として保存され、各ページには実際の行を指す4バイトのアイテム識別子(item identifier)の配列があります。PostgreSQL の行ポインタ(CTID)はページ番号とこのアイテム識別子の番号でできていて、アイテム識別子は解放されるまで移動しないので長期間参照できます。HOT では、インデックスは常に元の行バージョンのアイテム識別子を指します。整理のときはその行バージョンのデータを削除し、アイテム識別子を、まだどこかの同時実行トランザクションから見える可能性がある最も古いバージョンへのリダイレクトに変えます。誰からも見えない中間バージョンは完全に削除され、そのアイテム識別子は再利用されます。
同じページという条件がある理由もここから推測できます。インデックスは元のアイテム識別子しか知らないので、新しいバージョンが同じページ内にあって初めて、インデックスを直さずにたどれます。
fillfactor でページに空きを残す
2つ目の条件は、テーブルの fillfactor で起こりやすくできます。CREATE TABLE のドキュメントによると、テーブルの fillfactor は10から100までのパーセンテージで、デフォルトは100、つまりページを完全に詰めます。小さくすると INSERT はページをその割合までしか埋めず、残りはそのページにある行の更新用に取っておきます。すると UPDATE が更新後の行を元の行と同じページに置ける機会が生まれ、ドキュメントはこれを別のページに置くより効率的で、HOT 更新を起こりやすくすると説明しています。
新しいテーブルでは WITH 句で指定します。下の70はドキュメントの例の値で、推奨値ではありません。
CREATE TABLE sessions (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
last_seen_at timestamptz NOT NULL,
hits integer NOT NULL DEFAULT 0
) WITH (fillfactor = 70);
既存のテーブルは ALTER TABLE ... SET で変えます。
ALTER TABLE sessions SET (fillfactor = 70);
ALTER TABLE のドキュメントによると、このコマンドはテーブルの内容をすぐには変えず、パラメータによっては望む効果を得るためにテーブルを書き直す必要があります。ドキュメントが挙げる方法は VACUUM FULL、CLUSTER、またはテーブルの書き直しを伴う形式の ALTER TABLE です。どれもテーブル全体を書き直すので、運用中のテーブルで実行する前に、それぞれのコマンドのドキュメントでロックの動作を確認しておくのが無難です。fillfactor の変更そのものは SHARE UPDATE EXCLUSIVE ロックを取ります。
どの値がよいかはドキュメントには書かれていません。書かれているのは、更新されないテーブルには完全に詰めるのが最善で、更新の多いテーブルには小さい値が適切だという方向だけです。定義上、ページを詰めないほど同じ行数に多くのページが必要になります。また fillfactor を下げなくても HOT は起こります。新しい行が自然に新しいページへ移っていき、既存のページに空きができるからです。
HOT の比率を確かめる
ドキュメントは、HOT 更新とそうでない更新を pg_stat_all_tables ビューで監視できると案内しています。ユーザーテーブルだけを見るなら、同じ列を持つ pg_stat_user_tables を使います。関係する列は3つです。
n_tup_upd:更新された行の総数。次の2列で数えた更新と、残りの HOT でない更新をすべて含みます。n_tup_hot_upd:HOT で更新された行数。インデックスに後続バージョンが必要なかった更新です。n_tup_newpage_upd:後続バージョンが新しいヒープページに置かれた更新の数。これは常に HOT ではありません。
SELECT relname,
n_tup_upd,
n_tup_hot_upd,
n_tup_newpage_upd,
n_tup_upd - n_tup_hot_upd - n_tup_newpage_upd AS other_non_hot,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC
LIMIT 20;
other_non_hot はドキュメントの定義から計算した値で、新しいページに移らなかったのに HOT でもなかった更新です。2つの条件に照らすと、この値が大きければインデックスの参照する列を変える更新が多いと推定でき、n_tup_newpage_upd が大きければページの空きが足りなかった手がかりになります。ただし新しいページに移った更新にもインデックス列を変えたものが混ざりうるので、原因を確定するには実際の UPDATE 文とインデックス定義をあわせて見る必要があります。
この数値を読むときに知っておきたい点があります。
- 累積統計です。最後のリセット以降の合計なので、今の傾向を見るには時間をおいて2回問い合わせ、差を比べるほうがよいでしょう。
- 正常に停止すれば統計は保存されますが、即時停止やサーバーのクラッシュのあと、あるいはベースバックアップやポイントインタイムリカバリ(PITR)から起動すると、すべてのカウンターがリセットされます。
- リアルタイムではありません。各サーバープロセスはアイドルになる直前に、多くても1秒に1回(ビルド時のデフォルト)統計を共有メモリへ書き出すので、実行中のトランザクションはまだ反映されません。デフォルト設定では、1つのトランザクション内で読んだ値はそのトランザクションが終わるまでキャッシュされます。
- テーブルとインデックスのアクセス統計は
track_countsが有効なときにだけ収集されます。
HOT の比率が何パーセントであるべきかという基準はドキュメントにありません。更新のたびにインデックス列を変えるのが業務上当然のテーブルなら、比率が低いのが正常です。数値は目標ではなく、「このテーブルの更新はなぜインデックスに触れるのか」を問うための出発点として使うのが妥当です。
結果は変わらない
HOT は更新のコストを減らすストレージレベルの最適化で、正しさに関わる設定ではありません。HOT であってもなくても MVCC が新しい行バージョンを作ることは同じで、各文がどの行バージョンを見るかも分離レベルが決めます。その部分は READ COMMITTED とスナップショット で扱いました。ですから fillfactor を変えるかインデックスを削除するかを決めるときに考えるべきなのは、クエリの結果ではなく、ストレージ、書き込みのコスト、そしてそのインデックスが実際に助けている読み取りです。
参考にしたドキュメント
- PostgreSQL 18 — 66.7 Heap-Only Tuples (HOT):HOT の条件と2つの最適化、fillfactor、監視
- PostgreSQL 18 — 66.6 Database Page Layout:ページサイズ、アイテム識別子、CTID
- PostgreSQL 18 — CREATE TABLE:Storage Parameters の fillfactor、例
- PostgreSQL 18 — ALTER TABLE:
SET ( storage_parameter ... ) - PostgreSQL 18 — 27.2 The Cumulative Statistics System:27.2.1、27.2.2、
pg_stat_all_tables - PostgreSQL Versioning Policy:サポート中のバージョンとマイナーバージョン
リンクと内容は2026年10月12日に確認しました。