※ PostgreSQL: HOT(Heap-Only Tuple).
안녕하세요. 듀스트림입니다.
오늘 포스팅은 PosgreSQL 8.3 버전부터 도입된 HOT에 대한 내용입니다.
참고로 저는 HOT 세대입니다.
1. PostgreSQL의 MVCC 업데이트 비용과 HOT의 등장 배경
PostgreSQL은 MVCC(Multi-Version Concurrency Control) 구조를 사용하기 때문에, UPDATE가 발생할 때 기존 튜플을 직접 수정하지 않습니다. 대신 기존 튜플은 그대로 두고 새로운 버전의 튜플을 추가로 생성합니다.
이러한 방식에서 일반적인 Non-HOT 업데이트는 다음과 같은 비용을 수반합니다.
- 새로운 heap tuple 생성
- 해당 row를 참조하는 모든 인덱스에 새로운 index entry 추가
- WAL 증가 및 추가적인 I/O 발생
- dead tuples 및 dead index entries 누적으로 인한 테이블·인덱스 bloat 증가
이처럼 UPDATE가 수행될 때마다 인덱스까지 함께 갱신해야 하는 비용을 줄이기 위해, PostgreSQL 8.3에서는 Heap-Only Tuple(HOT) 업데이트 최적화가 도입되었습니다.
HOT 업데이트는 같은 heap 페이지 내에서 새로운 튜플 버전을 생성하면서도 새로운 인덱스 엔트리를 만들지 않도록 하는 메커니즘입니다.
2. HOT 발생 조건
- 업데이트된 컬럼이 어떤 인덱스의 key에도 포함되지 않을 것
- 같은 heap page에 새 tuple을 저장할 충분한 free space가 존재할 것
expression / partial index는 HOT을 직접적으로 금지하지 않습니다.
단, 업데이트가 해당 인덱스 expression 또는 predicate에 영향을 줄 경우 HOT이 발생하지 않습니다.
+ 핵심 조건은 “해당 행에 붙어 있는 어떤 인덱스 엔트리도 값이 달라지지 않을 것.”입니다.
| 시나리오 | HOT | 설명 |
| 테이블에 인덱스가 전혀 없다. | 가능 | 인덱스 쓰기 자체가 없으므로 조건 자동 충족 |
| 인덱스는 있지만, UPDATE가 그 인덱스에서 참조하는 값들을 바꾸지 않는다. | 가능 | B-tree·GIN·GiST·SP-GiST·BRIN 등 모든 인덱스가 “안 바뀐다”가 핵심 (crunchydata.com) |
| 인덱스 키가 바뀌면 (= PK·UK·일반 B-tree 컬럼, INCLUDE 컬럼, 또는 표현식/부분 인덱스가 참조하는 값 변경) |
불가 | 새 값으로 인덱스 엔트리가 반드시 다시 써져야 하므로 HOT 패스 차단 (cybertec-postgresql.com, medium.com) |
| BRIN · 요약(summarizing) 인덱스만 존재 + PG 16 이상 그리고 BRIN 요약 컬럼만 변한다 | 가능 | PG 16부터 BRIN은 HOT을 막지 않도록 패치됨 (pganalyze.com) |
++ 세부 사항
- INCLUDE 컬럼
- B-tree 에서 CREATE INDEX ... INCLUDE (col2) 처럼 포함시킨 컬럼은 키는 아니지만 물리적으로 인덱스 엔트리에 저장됩니다.
→ col2 를 바꾸면 인덱스 항목도 새로 써야 하므로 HOT 불가합니다.
- B-tree 에서 CREATE INDEX ... INCLUDE (col2) 처럼 포함시킨 컬럼은 키는 아니지만 물리적으로 인덱스 엔트리에 저장됩니다.
- Expression / Partial Index
- 표현식(expression)이 UPDATE 대상 컬럼 값을 읽어 결과가 달라지면 HOT 차단됩니다.
- BRIN·요약 인덱스 예외 (PG 16+)
- BRIN 은 페이지 단위 범위 요약만 보유하므로 행 단위로 인덱스 엔트리를 새로 쓰지 않습니다.
이 점이 인정돼 BRIN ↔ HOT 충돌 제약이 사라졌습니다.
- BRIN 은 페이지 단위 범위 요약만 보유하므로 행 단위로 인덱스 엔트리를 새로 쓰지 않습니다.
- free space 조건
- 인덱스가 안 바뀌어도 같은 페이지에 빈 공간이 없으면 HOT 실패합니다.
3. 힙 페이지 레이아웃과 HOT 체인
페이지는 아래와 같은 구조를 가집니다.

- Tuple 1의 ItemId는 LP_REDIRECT로 표시되어 있고 t_ctid가 Tuple 2 → Tuple 3으로 이어지는 HOT 체인을 형성합니다.
- 인덱스는 항상 루트(ItemId)만 가리킵니다.
- Executor가 루트를 읽으면 heap_hot_search_buffer()로 체인을 따라가 가장 최신이며 MVCC 가시성이 만족되는 튜플을 찾습니다.
4. 소스 코드 분석
PostgreSQL Source Code: src/backend/access/heap/heapam.c Source File
PostgreSQL Source Code git master
doxygen.postgresql.org
아래 흐름은 src/backend/access/heap/heapam.c의 핵심 부분을 그대로 따라갑니다.
| N | 단계 | 설명 |
| 1 | 수정된 컬럼 분석 | HeapDetermineColumnsInfo()로 modified_attrs 비트맵 작성 |
| 2 | 페이지 여유 판단 | 같은 버퍼에 free space 있으면 newbuf == buffer |
| 3 | HOT 가능성 검사 | !bms_overlap(modified_attrs, hot_attrs) → use_hot_update=true 설정 |
| 4 | HOT 플래그 설정 | HeapTupleSetHotUpdated(&oldtup) HeapTupleSetHeapOnly(heaptup) |
| 5 | t_ctid 체인 링크 | oldtup.t_data->t_ctid = heaptup->t_self |
| 6 | WAL 기록 | log_heap_update() 호출, 두 버퍼 모두 XLOG 기록 |
| 7 | 페이지 프루닝 힌트 | PageSetPrunable(page, xid)로 이후 pruning 예약 |
읽기 경로
- 인덱스가 Root를 넘기면 Executor는 heap_fetch() → heap_hot_search_buffer()로 진입합니다.
- 루트가 LP_REDIRECT면 아래 로직을 반복합니다.
while(true)
{
HeapTupleHeader htup = PageGetItem(page, lp);
if (HeapTupleSatisfiesVisibility(htup, snapshot, buffer))
return htup; /* 최신 가시 버전 */
if (htup->t_ctid.ip_posid == InvalidOffsetNumber)
break; /* 체인 끝 */
lp = PageGetItemId(page, htup->t_ctid.ip_posid); /* 다음 링크 */
}
5. Pruning / VACUUM 작동 방식
- 핫 체인이 길어지면 heap_page_prune()가 호출되어 중간 버전을 제거하고 빈 ItemId를 압축합니다.
- Prune 시점:
- Buffer를 pin & cleanup lock으로 잡은 Backend가 접근할 때.
- Autovacuum의 lazy_scan_prune() 단계.
- DEAD tuple 정리 후 free space가 복원되면 이후 HOT 기회가 다시 생깁니다.
6. 테스트
▸pageinspect 익스텐션 추가
CREATE EXTENSION IF NOT EXISTS pageinspect;
▸뷰 쿼리
SELECT relname,
n_tup_upd, -- 전체 UPDATE
n_tup_hot_upd, -- HOT UPDATE
n_dead_tup
FROM pg_stat_user_tables
-- WHERE relname = 'rel_name'
;
▸Case 1: HOT vs Non-HOT
-- 테이블 생성
DROP TABLE IF EXISTS hot_test;
CREATE TABLE hot_test(
id serial PRIMARY KEY,
status text,
note text -- 인덱스 미포함 컬럼
) WITH (fillfactor = 70);
-- 오토배큠 비활성화
ALTER TABLE hot_test SET (autovacuum_enabled = false);
-- 데이터 삽입
INSERT INTO hot_test(status,note)
SELECT 'init','memo' FROM generate_series(1,10000);
SELECT pg_stat_reset();
-- 업데이트
UPDATE hot_test SET note = note||'_v2' WHERE id <= 5000;
-- 확인
SELECT relname,n_tup_upd,n_tup_hot_upd,n_dead_tup
FROM pg_stat_user_tables WHERE relname='hot_test';

-- 실패 유도 : 인덱스 컬럼 수정
CREATE INDEX hot_test_status_idx ON hot_test(status);
BEGIN;
UPDATE hot_test SET status = 'changed' WHERE id BETWEEN 6000 AND 7000;
COMMIT;
-- 확인
SELECT relname,n_tup_upd,n_tup_hot_upd,n_dead_tup
FROM pg_stat_user_tables WHERE relname='hot_test';

▸Case 2: fillfactor 100 vs 70
DROP TABLE IF EXISTS hot_ff100, hot_ff70;
CREATE TABLE hot_ff100(id serial PRIMARY KEY, note text) WITH (fillfactor=100);
CREATE TABLE hot_ff70 (id serial PRIMARY KEY, note text) WITH (fillfactor=70);
INSERT INTO hot_ff100(note) SELECT 'memo' FROM generate_series(1,20000);
INSERT INTO hot_ff70 (note) SELECT 'memo' FROM generate_series(1,20000);
UPDATE hot_ff100 SET note = note||'_v2' WHERE id <= 10000;
UPDATE hot_ff70 SET note = note||'_v2' WHERE id <= 10000;
SELECT c.relname,
s.n_tup_upd,
s.n_tup_hot_upd,
ROUND(100.0 * s.n_tup_hot_upd / NULLIF(s.n_tup_upd, 0), 2) AS hot_pct,
c.reloptions -- fillfactor 등 저장 옵션
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relname IN ('hot_ff100', 'hot_ff70');

7. HOT이 완화 또는 방지하는 단편화 범위
| 항목 | 완화 가능 | 해결 불가 / 주의 |
| 인덱스 블로트 | 키 값 불변 시 인덱스 Re-write 0 → Bloat 최소화 | 인덱스 컬럼 업데이트 시 HOT 불가 |
| 페이지 간(외부) 단편화 | 새 버전이 같은 페이지에 남음 → 이주 억제 | free space 없으면 외부 단편화 발생 |
| 페이지 내부 파편화 | pruning이 실행되면 중간 버전 정리·압축 | pruning 주기 길면 free space 파편화 |
8. 팁
- 업데이트 빈도가 높은 컬럼은 가능하면 인덱스에서 제외 (HOT 적용률을 극대화)
- 업데이트 빈도가 높은 테이블은 fillfactor 70 ~ 90 % 권장 (너무 낮으면 시퀀셜 스캔 성능·캐시 효율 하락)
- pageinspect로 힙 내부를 수시 점검 (HOT 체인 길이와 free space 모니터링)
- 긴 체인이 잦으면 autovacuum 튜닝(autovacuum_vacuum_cost_limit, naptime)과 주기적 VACUUM 시행
9. 요약
▸Non-HOT UPDATE
- 새 heap tuple 생성
- 모든 관련 인덱스에 새 index entry 생성
- WAL 및 I/O 증가
- dead tuples / dead index entries 누적
▸HOT UPDATE
- 같은 heap 페이지에서 새 tuple 생성
- index entry 추가 없음
- index maintenance 비용 감소
오늘은 여기까지~
'PostgreSQL' 카테고리의 다른 글
| PostgreSQL: Locale (0) | 2025.07.15 |
|---|---|
| PostgreSQL: 온라인 인덱스 작업(CONCURRENTLY) (2) | 2025.07.14 |
| PostgreSQL: pg_store_plans (4) | 2025.07.10 |
| PostgreSQL: Logical Replication (0) | 2025.07.02 |
| PostgreSQL: io_combine_limit (0) | 2025.07.01 |