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(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 발생 조건

  1. 업데이트된 컬럼이 어떤 인덱스의 key에도 포함되지 않을 것
  2. 같은 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 불가합니다.
  • 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 시행

9. 요약

▸Non-HOT UPDATE

  • 새 heap tuple 생성
  • 모든 관련 인덱스에 새 index entry 생성
  • WAL 및 I/O 증가
  • dead tuples / dead index entries 누적

 

HOT UPDATE

  • 같은 heap 페이지에서 새 tuple 생성
  • index entry 추가 없음
  • index maintenance 비용 감소

오늘은 여기까지~

 

728x90

'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