여러 파티션에 대한 쓰기를 하나의 원자적 단위로 묶는 기능. 멱등적 프로듀서가 재시도 중복을 막는다면, 트랜잭션은 스트림 처리 애플리케이션에서 정확히 한 번을 보장하려고 나왔다.

결과 쓰기와 오프셋 커밋 사이의 크래시

스트림 처리의 기본 패턴은 읽기, 처리, 쓰기다. 애플리케이션이 마지막에 해야 할 일이 둘인데, 결과를 출력 토픽에 쓰는 것과 읽어온 메시지의 오프셋을 커밋하는 것이다.

이 둘 사이에서 크래시가 나면 중복이 생긴다. 몇 초 뒤 하트비트가 끊어지면서 리밸런스가 일어나고, 파티션을 넘겨받은 다른 컨슈머가 마지막으로 커밋된 오프셋부터 다시 읽어서 같은 결과를 또 쓴다.

좀비 인스턴스

좀비도 같은 결과를 만든다. 죽었다고 판정된 인스턴스가 되살아나 마지막으로 읽어둔 배치를 처리해서 출력 토픽에 쓴다. 자기가 죽은 것으로 판정된 줄 알아차릴 때까지 그 작업을 계속한다.

원자적 다수 파티션 쓰기

착안점은 오프셋 커밋과 결과 쓰기가 둘 다 파티션에 메시지를 쓰는 일이라는 것이다. 오프셋은 __consumer_offsets 토픽으로 가고 결과는 출력 토픽으로 가지만, 어느 쪽이든 카프카에 쓰는 것이므로 하나의 트랜잭션으로 묶을 수 있다. 이것이 원자적 다수 파티션 쓰기(atomic multipartition write)다. 양쪽에 쓰고 둘 다 성공하면 커밋, 아니면 둘 다 중단이라서 부분적인 결과가 나오지 않는다.

transactional.id와 에포크 펜싱

쓰려면 transactional.id를 설정하고 initTransactions()를 호출한다. 브로커가 자동 생성하는 producer.id와 달리 transactional.id는 프로듀서 설정의 일부라서 재시작해도 값이 유지되고, 주 용도가 바로 재시작 이후에도 같은 프로듀서임을 식별하는 것이다. 브로커는 transactional.idproducer.id의 대응 관계를 유지하다가 같은 ID의 프로듀서가 initTransactions()를 다시 호출하면 새로운 랜덤 값이 아니라 이전에 쓰던 producer.id를 준다.

좀비 펜싱은 에포크(epoch)로 한다. initTransactions()가 호출될 때마다 해당 ID의 에포크가 증가하고, 같은 ID를 가지지만 에포크 값이 낮은 프로듀서는 메시지 전송과 트랜잭션 커밋, 중단 요청이 전부 FencedProducer 에러로 거부된다. 오래된 프로듀서는 출력 스트림에 쓸 수 없고 close() 말고 할 수 있는 일이 없다. 2.5.0부터는 트랜잭션 메타데이터에 컨슈머 그룹 메타데이터를 함께 넣을 수 있어서, 서로 다른 transactional.id를 가진 프로듀서들이 같은 파티션에 쓰면서도 좀비만 펜싱할 수 있게 되었다.

컨슈머의 격리 수준

컨슈머 쪽 설정도 함께 봐야 한다. 트랜잭션으로 쓴 레코드는 중단된 트랜잭션이라도 다른 레코드와 똑같이 파티션에 쓰이기 때문에, 컨슈머에 올바른 격리 수준을 잡지 않으면 정확히 한 번이 성립하지 않는다. 기본값인 isolation.level=read_uncommitted는 진행 중이거나 중단된 트랜잭션의 메시지까지 전부 리턴하고, read_committed는 커밋된 트랜잭션에 속한 메시지와 처음부터 트랜잭션에 속하지 않는 메시지만 리턴한다.

read_committed에도 함정이 둘 있다. 특정 트랜잭션에 속한 모든 메시지가 리턴된다는 보장은 아니다. 그 트랜잭션이 건드린 토픽 중 일부만 구독했다면 일부만 받는다. 그리고 읽기 순서를 보장하려고, 아직 진행 중인 트랜잭션이 처음 시작된 지점(Last Stable Offset, LSO) 이후에 쓰여진 메시지는 리턴하지 않는다. 트랜잭션이 커밋되거나 중단될 때까지, 또는 transaction.timeout.ms가 지나 브로커가 중단시킬 때까지 보류된다. 트랜잭션이 오래 닫히지 않으면 그만큼 컨슈머가 지체되고 종단 지연이 길어진다.

마커와 트랜잭션 로그

내부 알고리즘은 Chandy-Lamport 스냅샷 알고리즘의 영향을 받았다. 통신 채널로 마커(marker)라는 제어 메시지를 보내고 그 도착을 기준으로 상태를 일관되게 기록한다. 일부 파티션에만 커밋 마커가 쓰인 상태를 처리하려고 2PC와 트랜잭션 로그를 쓰는데, 진행 중인 트랜잭션의 존재를 연관 파티션들과 함께 기록하고, 커밋이나 중단 시도를 로그에 기록하고, 모든 파티션에 트랜잭션 마커를 쓰고, 트랜잭션 종료를 로그에 쓰는 순서다. 트랜잭션 로그는 __transaction_state라는 내부 토픽이다.

트랜잭션 크기와 처리량

성능은 트랜잭션을 크게 잡을수록 유리하다. 트랜잭션 ID 등록은 한 번뿐이고, 파티션 등록은 트랜잭션마다 파티션별로 한 번씩이며, 커밋 마커는 트랜잭션 끝에 한 번 붙는다. 고정 비용이 트랜잭션 단위로 붙으므로 많은 메시지를 하나에 넣을수록 오버헤드가 줄고 전체 처리량이 오른다.

스트림즈에 맡기기

실제로는 직접 쓰지 않는 쪽이 권장된다. 카프카 스트림즈에서 processing.guaranteeexactly_once_v2로 잡으면 스트림즈가 대신 써준다.

카프카 밖에서는 성립하지 않는 다섯 경우

가장 흔한 오해가 정확히 한 번이 카프카 밖에서도 보장된다고 생각하는 것이다. 도움이 되지 않는 경우를 짚어보면 이렇다.

  1. 부수 효과. 처리 단계에 이메일 발송이 있으면 이메일은 한 번만 가지 않는다. 시퀀스 넘버와 마커는 카프카 안에서만 작동하고, REST API 호출이나 파일 쓰기도 마찬가지다
  2. 카프카에서 읽어서 DB에 쓰는 경우. 결과는 JDBC로 DB에 가고 오프셋은 컨슈머가 카프카에 커밋하는데, 이 둘을 하나의 트랜잭션으로 묶는 메커니즘이 없다. 대신 오프셋도 DB에 저장하면 DB의 트랜잭션 보장 안에서 함께 커밋할 수 있다
  3. DB에서 읽어 카프카를 거쳐 다른 DB에 쓰는 경우. 종단 보장에 필요한 기능이 없다. read_committed는 DB 트랜잭션을 보존하기엔 너무 약해서, 커밋된 트랜잭션의 레코드를 전부 봤다는 보장도 없고 트랜잭션의 경계를 알 방법도 없다
  4. 클러스터 간 복제. 미러메이커 2.0에 정확히 한 번을 넣는 KIP-656이 있지만 각 레코드가 정확히 한 번 복사되는 것이지 트랜잭션의 원자성이 보존되는 것이 아니다. 복사 과정에서 트랜잭션 속성은 유실된다
  5. 발행 구독 패턴. read_committed 컨슈머는 중단된 트랜잭션을 보지 않지만, 오프셋 커밋 로직에 따라 메시지를 한 번 이상 처리할 수 있다. JMS 트랜잭션이 보장하는 수준이지 정확히 한 번이 아니다

메시지를 쓰고 커밋하기 전에 다른 애플리케이션의 응답을 기다리는 패턴도 반드시 피해야 한다. 상대는 트랜잭션이 커밋될 때까지 그 메시지를 받지 못하므로 데드락이 된다.

아웃박스 패턴

둘째 문제, 하나의 트랜잭션 안에서 DB를 갱신하고 카프카에도 메시지를 쓰고 싶은 문제의 일반적인 해법이 아웃박스 패턴이다. 서비스는 아웃박스라 불리는 카프카 토픽에 메시지를 쓰는 것까지만 하고, 별도의 중계 서비스가 그 메시지를 읽어서 DB를 갱신한다. 카프카가 DB 갱신에 대해 정확히 한 번을 보장하지 않으므로 갱신 작업은 반드시 멱등적이어야 한다. 패턴을 반대로 뒤집어 DB 테이블을 아웃박스로 쓰고 중계 서비스가 테이블 변경 내역을 카프카에 쓸 수도 있는데, 고유 키나 외래 키 같은 RDB 제약조건이 필요할 때 이쪽이 유리하다.

참고

원본은 transaction.timeout.ms의 기본값을 15분이라 적었다. 15분은 브로커 설정 transaction.max.timeout.ms의 기본값, 즉 프로듀서가 요청할 수 있는 상한이다. 프로듀서 설정의 기본값은 60000ms다. Kafka Producer Configs

원본은 processing.guarantee 값으로 exactly_onceexactly_once_beta를 적었고, 14장에서 3.0 지원 중단과 4.0 제거 “예정”까지만 적었다. 두 값은 4.0(2025년 3월)에서 실제로 제거되었으므로 지금 쓸 수 있는 값은 at_least_onceexactly_once_v2뿐이다. Apache Kafka 4.0 Release Announcement

관련

출처