세션 A에서 BEGIN 후 REPEATABLE READ로 한 행을 SELECT했다. 그사이 세션 B가 그 행을 UPDATE하고 커밋까지 마쳤는데도, 세션 A에서 같은 행을 다시 SELECT하면 여전히 옛날 값이 나온다. pg_stat_activity로 세션 B의 커밋을 눈으로 확인했는데도 그렇다. 캐시가 안 갱신됐나 싶어 찾아봤지만, 원인은 훨씬 근본적인 곳에 있었다 — PostgreSQL 안에서 "그 값이 커밋됐는가"와 "그 값이 나에게 보이는가"는 완전히 다른 질문이고, 후자를 매번 계산하는 함수가 따로 있다.
PostgreSQL은 UPDATE를 제자리 수정이 아니라 "새 튜플을 heap에 추가 INSERT + 옛 튜플에 삭제 표시"로 처리한다. 그래서 신·구 버전이 같은 heap 안에 나란히 쌓인다. InnoDB라면 최신 버전만 남기고 옛 버전은 undo log로 밀어내 필요할 때 되감지만, PostgreSQL은 되감을 대상이 없다 — 이미 heap에 다 있다. 그 대신 튜플마다 "누가 이 버전을 만들었고(xmin) 누가 지웠는지(xmax)"를 저장해두고, 조회할 때마다 "이 버전을 만들거나 지운 트랜잭션이 내 스냅샷 기준으로 이미 끝났는가"를 판정한다. 이 판정 하나가 HeapTupleSatisfiesMVCC다.
xmin/xmax는 HeapTupleHeaderData에 항상 박혀 있다.
typedef struct HeapTupleFields {
TransactionId t_xmin; // 이 버전을 만든(INSERT/UPDATE) 트랜잭션 ID
TransactionId t_xmax; // 이 버전을 지운(DELETE/UPDATE) 트랜잭션 ID, 없으면 0
} HeapTupleFields;
문제는 xmin/xmax가 트랜잭션 ID일 뿐이라는 점이다. 커밋됐는지 abort됐는지는 별도로 pg_xact(clog)를 봐야 안다. 매번 clog를 뒤지면 느리므로, 한 번 확인한 결과를 튜플의 t_infomask 비트(HEAP_XMIN_COMMITTED, HEAP_XMAX_COMMITTED 등)에 캐싱해 다음 방문자는 clog를 안 보게 만든다. 다만 이 hint bit는 커밋 WAL이 실제로 flush됐다는 보장이 있을 때만 세워진다 — 버퍼가 WAL보다 먼저 디스크에 내려가면 "커밋됐다"는 hint만 남고 진짜 커밋 레코드는 없는 모순이 생기기 때문이다.
세션이 들고 있는 스냅샷은 세 값으로 요약된다.
xmin: 이 값 미만 XID는 전부 보임 (오래전에 확정)
xmax: 이 값 이상 XID는 전부 안 보임 (스냅샷 이후 시작)
xip[]: xmin~xmax 구간 중 스냅샷을 뜬 순간 "아직 진행 중"이던 XID 목록
HeapTupleSatisfiesMVCC는 튜플의 xmin이 XMIN_COMMITTED hint를 이미 갖고 있어도 곧바로 "보인다"고 답하지 않는다. 그 커밋이 내 스냅샷 이후일 수 있어서, xip[]를 다시 확인한다. 이게 세션 A가 겪은 상황의 정체다 — 세션 B의 UPDATE는 진짜로 커밋됐지만, 세션 A의 스냅샷은 그보다 먼저 떠 있었고, 그 스냅샷의 xip[](또는 xmax 경계)가 "세션 B는 아직 안 끝난 것으로 취급"하도록 고정해버린 것이다. REPEATABLE READ는 트랜잭션 시작 시점 스냅샷을 트랜잭션 내내 재사용하므로, 세션 A가 다시 SELECT해도 결과는 바뀌지 않는다.
같은 트랜잭션 안에서 재현하면 이렇다.
-- 세션 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, v FROM demo WHERE id = 1; -- xmin=100, v='old'
-- 세션 B (다른 접속)
UPDATE demo SET v = 'new' WHERE id = 1;
COMMIT; -- xmin=101인 새 튜플이 커밋됨
-- 세션 A, 같은 트랜잭션 안에서 다시
SELECT xmin, v FROM demo WHERE id = 1; -- 여전히 xmin=100, v='old'
COMMIT;
-- 세션 A, 새 트랜잭션에서
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, v FROM demo WHERE id = 1; -- 이제 xmin=101, v='new'
COMMIT;
세션 A가 트랜잭션을 커밋하고 새로 시작해야만 새 스냅샷을 뜨고, 그제야 xmin=101 버전이 "내 스냅샷 기준으로도 커밋 확정"으로 판정된다. HeapTupleSatisfiesMVCC가 매 SELECT마다 새로 도는 게 아니라 트랜잭션이 뜬 스냅샷 하나를 계속 기준 삼는다는 게 이 결과로 드러난다.
커밋 여부와 가시성 여부는 별개 축이다 — xmin이
XMIN_COMMITTED여도 내 스냅샷의xip[]에 걸리면 안 보인다.
이 xmin/xmax 체계는 이전에 다룬 HOT(Heap-Only Tuples) 글에서 본 t_ctid 체인과 같은 재료 위에서 동작한다 — 체인을 따라갈 때도 "다음 버전의 xmin == 이 튜플의 xmax"를 검증해야 한다. 더 파고들 만한 지점은 두 가지다: XID가 32비트라 발생하는 wraparound와 VACUUM의 freeze 처리, 그리고 이 판정을 페이지 단위로 미리 캐싱해 heap 접근 자체를 건너뛰는 visibility map.
src/include/access/htup_details.h, src/backend/access/heap/heapam_visibility.c, src/include/utils/snapshot.h