인덱스가 5개 걸린 테이블에서, 인덱스에 안 걸린 컬럼 하나만 UPDATE 하면 인덱스 write는 몇 번 일어날까? "PostgreSQL의 UPDATE는 결국 DELETE + INSERT라 모든 인덱스에 새 엔트리가 생긴다"고 알고 있었다면 답은 5번이다. 하지만 실제로는 0번일 수 있다. 그걸 가능하게 하는 게 HOT(Heap-Only Tuples)이고, 이 글은 "인덱스를 안 건드렸는데 어떻게 최신 버전을 찾아가는가"를 소스와 문서를 따라가며 정리한 것이다.
PostgreSQL은 MVCC를 위해 UPDATE를 제자리 수정이 아니라 새 버전 추가(append) 로 처리한다. 기존 튜플을 지우지 않고, 새 물리 위치에 새 버전을 쓴다.
문제는 인덱스 엔트리가 항상 힙 튜플의 물리 위치(TID = 페이지 번호 + 라인포인터) 를 가리킨다는 점이다. 새 버전이 새 위치에 생기면, 원칙적으로 그 row를 참조하는 모든 인덱스에 "새 TID를 가리키는 엔트리"가 추가돼야 한다. 인덱스가 5개면 컬럼 값이 하나도 안 바뀌어도 UPDATE 한 번에 인덱스 write가 5번. 여기에 인덱스 bloat(불필요하게 커진 인덱스)까지 따라온다. 이게 HOT 이전의 세계다.
src/backend/access/heap/README.HOT이 정의하는 HOT UPDATE 조건은 두 가지다.
두 조건을 만족하면 이런 일이 벌어진다.
t_ctid 필드가 새 튜플의 라인포인터를 가리키도록 갱신된다 → HOT 체인이 만들어진다.HEAP_HOT_UPDATED, 새 튜플 헤더에 HEAP_ONLY_TUPLE 플래그가 켜진다.인덱스는 여전히 체인의 첫(루트) 튜플의 TID만 안다. 인덱스로 그 TID에 도달한 뒤, 실행기는 같은 힙 페이지 안에서 t_ctid를 따라 체인을 타고 내려가며 각 버전의 가시성(스냅샷 대비 보이는가)을 판정한다. 체인은 한 페이지 안에서만 존재하므로, 이 추적은 추가 페이지 I/O 없이 페이지 내부 포인터 점프로 끝난다. 이것이 "인덱스는 안 건드렸는데도 최신 버전을 찾는다"의 정체다.
[Index] --TID--> (LP1) ← 인덱스가 가리키는 유일한 지점
힙 페이지:
LP1 → tuple v1 [HEAP_HOT_UPDATED] t_ctid = LP2
LP2 → tuple v2 [HEAP_ONLY_TUPLE, HEAP_HOT_UPDATED] t_ctid = LP3
LP3 → tuple v3 [HEAP_ONLY_TUPLE] t_ctid = self ← 현재 최신
체인이 길어지면 스캔 비용이 늘고 페이지가 찬다. PostgreSQL은 일반 페이지 접근 중에도(SELECT/UPDATE가 그 페이지를 읽을 때) 기회적으로 heap_page_prune을 돌린다.
LP_REDIRECT)로 바꿔 체인의 살아있는 첫 튜플을 가리키게 한다. 인덱스는 여전히 LP1을 가리키고, LP1은 이제 "여기 말고 저기"라고 재지향한다.LP_DEAD로 표시해 재사용 대기 상태로 둔다.핵심은 HOT pruning이 인덱스를 전혀 만지지 않는다는 점이다. heap-only 튜플들은 애초에 인덱스가 안 가리키므로 힙 안에서 조용히 정리할 수 있다. 인덱스까지 청소하는 일반 VACUUM과 다른, HOT이 주는 저비용 정리다.
라인포인터(ItemId)가 오가는 세 상태를 정리하면 이렇다.
| 상태 | 의미 | 인덱스가 가리켜도 되나 |
|---|---|---|
LP_NORMAL | 실제 튜플을 가리키는 정상 포인터 | 예 |
LP_REDIRECT | "여기 말고 저 라인포인터로" 재지향 | 예 (재지향을 따라감) |
LP_DEAD | 튜플은 회수됐고 포인터만 남음 (재사용 대기) | 아니오 |
미묘한 점 하나: HOT 체인은 커밋된 버전만이 아니라 진행 중이거나 롤백된 버전도 잠시 담는다. UPDATE가 abort되면 그 새 튜플은 곧 dead가 되어 다음 pruning 때 회수된다. 인덱스는 처음부터 이 실패한 버전을 안 가리켰으므로 롤백해도 인덱스 정합성 문제가 없다 — HOT이 없었다면 abort된 인덱스 엔트리까지 나중에 청소해야 했을 것이다.
두 번째 조건("같은 페이지에 자리")을 자주 만족시키려면 페이지에 빈 공간이 있어야 한다. fillfactor(기본 100)를 90 등으로 낮추면 INSERT 시 페이지의 10%를 비워두고, 이후 UPDATE의 새 버전이 그 여백에 들어가 HOT을 유지할 확률이 올라간다. 자주 UPDATE되는 테이블에서 fillfactor를 낮추는 튜닝이 여기서 나온다.
pageinspect 확장으로 플래그와 체인을 직접 따라가 볼 수 있다.
CREATE EXTENSION pageinspect;
CREATE TABLE t (id int PRIMARY KEY, val text, memo text) WITH (fillfactor = 90);
CREATE INDEX ON t (val); -- val은 인덱싱됨
INSERT INTO t VALUES (1, 'a', 'x');
-- (A) 인덱스 안 걸린 memo만 UPDATE → HOT 기대
UPDATE t SET memo = 'y' WHERE id = 1;
-- (B) 인덱싱된 val을 UPDATE → HOT 불가, 새 인덱스 엔트리 발생 기대
UPDATE t SET val = 'b' WHERE id = 1;
-- 힙 튜플 플래그 확인 (t_infomask2의 HEAP_HOT_UPDATED / HEAP_ONLY_TUPLE 비트)
SELECT lp, t_ctid, t_infomask2
FROM heap_page_items(get_raw_page('t', 0));
HEAP_ONLY_TUPLE, 구 튜플에 HEAP_HOT_UPDATED가 켜지고 t_ctid가 다음 라인포인터를 가리킨다.val 인덱스에 새 엔트리가 추가된다.통계로도 갈린다. SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't'; 에서 (A)는 n_tup_hot_upd를 올리지만 (B)는 n_tup_upd만 올린다. (버전에 따라 n_tup_newpage_upd 같은 컬럼이 추가됐다고 공식 문서에 나온다.) 즉 n_tup_hot_upd / n_tup_upd 비율은 실전에서 HOT 성공률을 진단하는 지표로 쓸 수 있다.
세 가지를 바로잡았다.
t_ctid를 따라가 찾는다.HOT은 "인덱싱 컬럼이 안 바뀐 UPDATE"에 한해 인덱스를 그대로 두고, 같은 페이지 안에서
t_ctid체인으로 버전을 잇는 최적화다. 인덱스 write와 인덱스 bloat를 통째로 없앤다.
더 파고들 만한 주제로는 (1) redirect/dead 라인포인터가 쌓일 때의 인덱스 bloat와 VACUUM의 LP_DEAD 회수 상호작용, (2) HOT과 index-only scan·visibility map의 관계가 남아 있다.
src/backend/access/heap/README.HOTpageinspect, pg_stat_user_tables