Compaction Conflict Resolution

“Compaction은 논리적으로 noop인데도 동시 트랜잭션을 무효화한다. 재실행 대신 rebase하라.”


핵심 개념

레이크하우스 테이블 포맷에서 compaction은 논리적 항등 연산(logical noop) 이다. 행을 하나도 더하거나 빼지 않고 파일 레이아웃만 재조직한다. 그런데도 동시에 실행 중인 트랜잭션을 깨뜨린다.

이유는 하나다: 테이블 포맷의 업데이트가 테이블의 상태뿐 아니라 레이아웃에도 의존하기 때문이다.

Iceberg의 position delete vector는 “이 오브젝트의 논리적 위치 N번 행을 지운다”고 말한다. base.parquet의 5번 행을 tombstone한 트랜잭션은 base.parquet이라는 이름과 5라는 오프셋에 묶여 있다. compaction이 base.parquet을 compacted.parquet으로 병합하는 순간, 그 참조는 존재하지 않는 오브젝트를 가리킨다.

왜 이것이 프로덕션 문제인가 — 재실행의 악순환

delta commit 누적 → 읽기 레이턴시 2~3배 ↑ → compaction 필요
compaction 실행 → 동시 트랜잭션과 충돌 → 둘 중 하나 재실행
재실행 실패 누적 → backlog → 더 작고 더 잦은 compaction
더 잦은 compaction → 더 많은 커밋 → 더 많은 충돌  ⟲

해법이 재실행이면 같은 데이터에 대해 같은 작업을 반복하는 것이고, 지속적 쓰기 하에서는 실패한 compaction이 backlog로 쌓인다. 그러면 운영자는 더 작고 더 빈번한 compaction 쪽으로 밀려나는데, 각 compaction이 새 커밋이므로 충돌 표면이 오히려 넓어진다.

결과: SLO가 있는 단순한 워크로드조차 튜닝이 어려워진다. 테이블에 데이터를 적재하는 단일 writer 하나조차 compaction에 방해받아 데드라인을 놓칠 수 있다.

필요한 유지보수와 워크로드 튜닝 사이의 긴장은 프로덕션 테이블의 실제 문제다.

대칭성 — 누가 먼저 커밋하든 같다

충돌은 양방향이다.

먼저 커밋문제
compaction트랜잭션의 delete vector가 사라진 오브젝트를 참조
트랜잭션compaction이 준비한 스냅샷이 그 트랜잭션의 delete vector가 참조하는 오브젝트를 생략했으므로 compaction이 그 트랜잭션의 successor가 될 수 없다

Compaction Map — 매핑을 기록하기만 하면 된다

핵심 통찰: compaction은 이미 옛 레이아웃과 새 레이아웃 사이의 매핑을 만든다. 단지 그것을 기록하지 않을 뿐이다.

그리고 rebase가 건전(sound) 하다 — compaction이 스냅샷의 모든 live 행을 보존하는 논리적 noop이므로, 옛 레이아웃에 대해 실행된 어떤 successor 트랜잭션도 compaction이 보존하는 데이터의 부분집합에 의존한다.

또 하나: delta update의 rebase는 저렴하다. 새로 삽입된 행은 변하지 않고, 오직 (압축된) position delete vector만 옛 레이아웃을 참조한다.

구조

compaction은 보통 긴 행 run을 통째로 옮기므로, 매핑을 기술하는 데 아주 적은 상태만 필요하다. run 하나당 (src_file, src_start, tgt_file, tgt_start, length) 정도이고, 새 위치는:

new_pos = tgt_start + (pos - src_start)

맵 크기: run 10개에 2.6 KiB, run 10,000개에 8.9 KiB. Avro로 인코딩되어 manifest list에서 참조된다.

부수 효과 — SERIALIZABLE의 read conflict도 무시할 수 있다

manifest list의 맵 참조가 플래그 역할을 한다. SERIALIZABLE 트랜잭션은 compaction이 도입한 read conflict를 무시할 수 있다 — compaction이 논리 스냅샷을 바꾸지 않았음이 증명되기 때문이다.

성능 — I/O가 지배한다

항목값
v3 (Roaring bitmap, Puffin) remapping수십~수백 ms (최대 측정치 293~463 ms, 클라우드별)
v2 (Parquet position delete file)~2초 (비용 대부분이 Parquet 인코딩·쓰기)
재실행수 분

remapping 알고리즘 선택(run 수 vs position 수 에 따라 range lookup ↔ sorted merge)은 naive 대비 자릿수 speedup처럼 들리지만 실무에서 차이는 거의 유의하지 않다 — 레이턴시가 알고리즘이 아니라 I/O에 지배되기 때문이다. bulk bitmap 구성 최적화가 전체 감소분 중 12~24 ms만 설명했다.

설계 교훈: 이 층위의 최적화에서 알고리즘 선택보다 “한 번에 얼마나 적게 읽고 쓰느냐” 가 중요하다.

착안 출처 — REMIX 인덱스

compaction map은 LSM 트리용 REMIX 인덱스에서 왔다. REMIX는 SSTable 병합 경로를 기록해 reader마다 같은 비교를 반복하지 않고 iterator를 전환하게 한다.

같은 발상의 확장 가능성: REMIX의 anchor key를 compaction map에 더하면, compaction을 수행하는 대신 맵만 만들어 read 성능 일부를 스토리지 비용의 일부로 회복할 수도 있다 (compaction은 옛 checkpoint가 GC될 때까지 스토리지를 회수하지 않으므로).

어디까지 일반화되는가

연산적용 가능?비고
compaction✅논리적 noop — 원래 대상
deduplication⚠️ 조건부논리적 noop이 아니다(행을 제거). 하지만 중복에 둔감한 트랜잭션에는 문제 모양이 같다 — 유지보수 연산이 논리적으로 충돌하지 않는 동시 작업을 무효화한다. dedup map
mostly-sorted 테이블 재정렬⚠️ 조건부최근 데이터만 정렬 안 되어 있으면 맵이 관리 가능한 크기
Hudi reclustering⚠️ 가능성Hudi는 position delete vector를 안 써서 compaction이 레이아웃 충돌을 안 만들지만, 키를 file group으로 clustering하므로 recluster는 유사하게 충돌
z-order / 전체 재정렬❌최악의 경우 맵이 행마다 엔트리를 갖는다. compact된 데이터에서 재실행하는 게 때로 더 싸다 — 애초에 그것이 compaction의 동기였다

경계선: 긴 연속 run이 보존되는 레이아웃 변경에만 값을 한다. 행을 개별적으로 흩뿌리는 변환은 맵이 데이터만큼 커진다.

연관 개념


Source: Compaction Maps