Kafka는 수억 건 중 특정 offset을 어떻게 O(log n)에 찾는가 — 로그 세그먼트와 sparse index

seonwoo_jung·3일 전

Kafka는 파티션 하나에 수억 건의 메시지를 append-only로 쌓아두고도, 컨슈머가 "offset 8,512,393번부터 읽어줘"라고 하면 거의 즉시 그 지점부터 스트리밍을 시작한다. 파일을 처음부터 스캔하면 O(n)일 텐데 어떻게 이게 가능한가? 나는 막연히 "offset이 곧 파일의 byte 위치겠지"라고 생각했는데, 레코드가 가변 길이라는 사실과 곧바로 모순됐다. 그래서 Kafka가 offset을 물리적 파일 위치로 옮기는 경로를 소스 레벨까지 따라가 봤다.

핵심: 파티션은 세그먼트의 나열이고, 인덱스는 sparse하다

먼저 결론을 한 문장으로 잡고 들어가자.

Kafka 파티션은 append-only 세그먼트 파일들의 나열이고, 각 세그먼트는 일정 byte마다 한 번씩만 offset→파일 위치를 기록한 희소(sparse) 인덱스를 mmap으로 들고 있다. 그래서 offset 조회는 "인덱스 이진 탐색 + 짧은 선형 스캔"으로 끝난다.

여기서 두 가지가 핵심이다. (1) 거대한 로그를 통째로 다루지 않고 세그먼트 단위로 쪼개 탐색 범위를 먼저 좁힌다. (2) 모든 레코드를 색인하는 dense 인덱스가 아니라, 듬성듬성 색인하는 sparse 인덱스라서 인덱스 전체가 메모리에 올라간다. 이 둘이 합쳐져 O(log n) 조회가 된다.

1. 디렉터리 레이아웃 — 파일명이 곧 1차 인덱스

파티션 디렉터리(<topic>-<partition>/) 안에는 세그먼트 3종 파일이 base offset 이름으로 묶여 산다.

my-topic-0/
  00000000000000000000.log         # 실제 레코드 (append-only)
  00000000000000000000.index        # offset    → 물리 위치 (sparse)
  00000000000000000000.timeindex     # timestamp → offset    (sparse)
  00000000000000368122.log          # 다음 세그먼트, base offset=368122
  00000000000000368122.index
  ...

파일명의 20자리 숫자는 그 세그먼트 첫 레코드의 절대 offset, 즉 base offset이다. 쓰기는 가장 최근 세그먼트(active segment) 하나에만 일어나고, log.segment.bytes(기본 1GB)를 넘거나 log.roll.ms(기본 7일)가 지나면 롤(roll) 해서 새 세그먼트를 연다. base offset을 파일명에 박아두기 때문에, 어떤 offset이 어느 세그먼트에 있는지는 파일을 열지 않고 이름만 보고도 좁힐 수 있다. 파일명 자체가 가장 바깥쪽 인덱스인 셈이다.

2. .index의 자료구조 — 8바이트 고정 엔트리

.index 한 엔트리는 정확히 8바이트 고정이다.

[ relative offset : 4B (int) ][ physical position : 4B (int) ]
  • relative offset = 절대 offset − base offset. base offset을 파일명으로 빼두기 때문에 4바이트로 충분하다.
  • physical position = 그 레코드가 .log 안에서 시작하는 byte 위치. 이 값이 4바이트라서 세그먼트 하나의 상한이 ~4GB가 되고, 그래서 log.segment.bytes 기본값이 1GB대인 것으로 알려져 있다.

가장 중요한 점은 모든 레코드마다 엔트리를 만들지 않는다는 것이다. log.index.interval.bytes(기본 4096)만큼 .log가 쌓일 때마다 한 번씩만 엔트리를 추가한다 — 이래서 sparse다. 100만 건이 쌓여도 인덱스 엔트리는 수천 개 수준이라, 인덱스를 통째로 메모리에 올려둘 수 있다.

3. mmap — 인덱스는 메모리에 매핑된 채로 산다

.indexMappedByteBuffer로 메모리 매핑(mmap)된다. 세그먼트가 활성일 때는 log.index.size.max.bytes(기본 10MB)로 미리 할당해 두고, 롤되며 닫힐 때 실제 크기로 truncate한다. 덕분에 인덱스 탐색은 디스크 I/O가 아니라 OS 페이지 캐시 위의 메모리 접근이 된다. 8바이트 고정 엔트리라는 점도 여기서 빛을 발한다 — i번째 엔트리의 위치가 i * 8로 바로 계산되니 mmap 버퍼 위에서 이진 탐색이 가능하다.

4. offset 조회 알고리즘 — 두 단계 이진 탐색 + 선형 스캔

컨슈머가 offset X를 요청하면 다음 순서로 물리 위치를 찾는다.

1) 세그먼트 선택:
   세그먼트 base offset들을 정렬해 둔 자료구조(SkipList)에서
   base ≤ X 인 가장 큰 세그먼트(floor)를 고른다.        O(log S)

2) 세그먼트 내부:
   해당 .index에서 relativeOffset ≤ (X − base) 인
   가장 큰 엔트리를 이진 탐색 → 시작 physical position p.  O(log E)

3) 선형 스캔:
   .log 의 p 부터 레코드를 앞으로 읽으며
   offset == X 인 레코드를 만날 때까지 스캔.   (최대 ~4KB 분량)

sparse 인덱스가 가리키는 위치는 항상 X보다 "조금 앞"(또는 같음)이다. 이진 탐색이 relOff ≤ 목표하한을 고르기 때문에, 스캔은 절대 X를 지나쳐서 시작하지 않는다. 그리고 그 간극은 최대 index.interval.bytes(≈4KB)로 묶이므로 선형 스캔 비용이 상수가 된다. 전체적으로 O(log n) + 상수 스캔이다.

그림으로 보면 X=37을 찾을 때 인덱스의 relOff=33 엔트리(pos 4096)로 점프한 뒤, 4096부터 짧게 스캔해 off=37에 도달한다.

.index (sparse)                 .log
relOff  pos                     pos
  0  ->   0      ─────────────►  0:    off=0  ...
 33  -> 4096     ─────────────►  4096: off=33 ...   ← X=37 요청 시
 70  -> 8210                       4096부터 스캔해 off=37 도달

5. 왜 빠른가 — sequential write + page cache + zero-copy

조회 구조만큼이나 Kafka의 처리량을 떠받치는 건 디스크를 다루는 방식이다.

  • append-only 순차 쓰기: 랜덤 쓰기가 없어 디스크 헤드 이동이 거의 없고, HDD에서도 빠르다.
  • page cache 의존: Kafka는 자체 캐시를 거의 두지 않고 읽기/쓰기를 OS 페이지 캐시에 맡긴다. 최근 데이터는 대개 캐시에 떠 있어 디스크를 안 친다.
  • zero-copy: 컨슈머로 보낼 때 FileChannel.transferTo(→ 리눅스 sendfile)로 페이지 캐시에서 소켓으로 바로 보낸다. user space로의 복사가 없다(Kafka Documentation, "Persistence/Efficiency").

6. 직접 확인하기 — kafka-dump-log.sh

kafka-dump-log.sh로 인덱스를 떠보면 sparse 구조가 눈에 보인다.

$ kafka-dump-log.sh --files 00000000000000000000.index
offset: 0     position: 0
offset: 33    position: 4096      # 33건마다가 아니라 ~4096B마다
offset: 70    position: 8210
...

offset이 0,1,2…로 촘촘하지 않고 약 4096바이트가 쌓일 때마다 한 칸씩 건너뛴다 — 엔트리 수가 레코드 수보다 훨씬 적다는 걸 직접 볼 수 있다. .index 파일 크기를 8로 나누면 엔트리 개수가 떨어진다는 산술도 8바이트 고정 엔트리를 확인해 준다.

정리

핵심을 한 줄로 남기면: Kafka의 빠른 offset 조회는 "파일명(base offset) → sparse 인덱스 이진 탐색 → 최대 4KB 선형 스캔"이라는 3단 좁히기다. offset은 byte 위치가 아니라 논리적 일련번호이고, 그 둘의 매핑을 듬성듬성한 인덱스가 메모리 위에서 책임진다.

내가 잘못 알고 있던 것 두 가지를 정정하며 마친다. (1) "offset = 파일의 byte 위치"가 아니다 — 가변 길이 레코드라 성립하지 않는다. (2) "인덱스는 레코드마다 한 줄 있는 dense 구조"가 아니다 — 4KB 간격의 sparse 인덱스라서 작고, 대신 짧은 선형 스캔이 따라붙는다.

다음에 더 파고들 만한 주제는 두 가지다. 키별 최신 값만 남기며 세그먼트를 재작성하는 Log compaction, 그리고 log.retention.*에 따라 세그먼트 단위로 통째 삭제할 때 활성 세그먼트가 보호되는 규칙이다.

참고 자료

  • Apache Kafka Documentation — Design / Persistence, Log 섹션 (kafka.apache.org/documentation/#design)
  • Kafka 소스: OffsetIndex, TimeIndex, LogSegment, LogSegments(세그먼트 SkipList)
  • Kafka: The Definitive Guide (2nd ed.) Ch.5 — Physical Storage

0개의 댓글