PostgreSQL

PostgreSQL: HOT(Heap-Only Tuple)

dewstream 2025. 7. 11. 08:00
728x90

※ PostgreSQL: HOT(Heap-Only Tuple).

 

안녕하세요. 듀스트림입니다.

 

오늘 포스팅은 PosgreSQL 8.3 버전부터 도입된 HOT에 대한 내용입니다.

 

참고로 저는 HOT 세대입니다.


1. PostgreSQL의 MVCC 업데이트 비용과 HOT의 등장 배경

PostgreSQL은 MVCC로 인해 UPDATE 시마다 기존 튜플을 삭제하지 않고 새 버전을 추가합니다.

일반적인(Non-HOT) 업데이트는 아래와 같은 비용을 수반합니다.

  • 모든 인덱스 Re-write: 바뀌지 않은 인덱스 컬럼까지 새 키가 기록됩니다.
  • 불필요한 WAL 증가와 I/O: 인덱스·데이터 블록 모두 더티 페이지가 늘어납니다.
  • 인덱스/테이블 블로트: 시간이 지날수록 dead index entries, dead tuples 누적됩니다.

위와 같은 부담을 줄이기 위해 8.3 버전에 도입된 최적화가 Heap-Only Tuple(HOT) 입니다.

공식 문서에서는 HOT을 “업데이트 시 새 인덱스 엔트리를 만들지 않도록 하는 최적화” 로 정의합니다.


2. HOT 발생 조건

  1. 인덱스 키가 바뀌지 않을 것: 요약 인덱스(BRIN 등)를 제외한 모든 B-tree-계열 인덱스 컬럼이 그대로여야 합니다.
  2. 같은 페이지에 새 버전을 쓸 여유 공간: 즉 페이지 내 free space ≥ 새 튜플 크기여야 합니다.
  3. 해당 테이블에 HOT을 금지하는 특수 인덱스가 없을 것: expression/partial 인덱스는 PostgreSQL 17 기준으로 아직 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 불가합니다.
  • Expression / Partial Index
    • 표현식(expression)이 UPDATE 대상 컬럼 값을 읽어 결과가 달라지면 HOT 차단됩니다.
  • BRIN·요약 인덱스 예외 (PG 16+)
    • BRIN 은 페이지 단위 범위 요약만 보유하므로 행 단위로 인덱스 엔트리를 새로 쓰지 않습니다.
      이 점이 인정돼 BRIN ↔ HOT 충돌 제약이 사라졌습니다.
  • 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 시행

오늘은 여기까지~

 

728x90