Lobsters

We used a database as a message queue. Now we use Kafka

메시지 큐로 쓰던 데이터베이스, 이제 Kafka를 씁니다

Tigris는 FoundationDB 기반 큐 QuiCK를 운영하다가 작업 증가로 생기는 읽기·쓰기 부담과 커스텀 코드 문제를 줄이려고 일부 비동기 작업을 Kafka로 옮겼습니다. 복제 작업은 이중 쓰기 문제를 피하려고 FoundationDB에 남겼으며, 두 시스템의 역할을 나눈 기준과 전환 뒤 쓰기량이 줄어든 과정을 설명합니다.

AI 요약

Tigris는 테넌트 정보와 객체 메타데이터, 리전 간 복제 로그 등을 FoundationDB에 저장해 왔습니다. 비동기 작업 큐도 FoundationDB 위에 구현했습니다. 큐와 업무 데이터가 한 데이터베이스에 있으면 트랜잭션을 한곳에서 처리해 이중 쓰기 문제를 피할 수 있다는 장점이 있습니다. 하지만 기능이 늘면서 작업 수도 많아졌고, 큐를 운영하는 비용이 커졌습니다.

FoundationDB 큐의 장점과 부담

기본적인 큐는 FoundationDB 키 공간 한쪽에 메시지를 넣고 반대쪽에서 읽습니다. 단순하게 증가하는 ID를 붙이면 여러 생산자가 같은 ID를 만들 수 있습니다. UUID 같은 식별자를 써도 작업자가 메시지를 꺼낸 뒤 처리 중 죽으면 작업을 잃을 수 있습니다. 작업을 내구성 있게 처리하려면 작업을 가져가고, 임대를 갱신하고, 완료 뒤 삭제하는 절차가 필요합니다.

Tigris는 Apple이 CloudKit에서 사용한 큐 시스템 QuiCK 논문을 참고해 직접 구현했습니다. 이 방식은 작업 종류, 항목 공간, 실행 시각, 우선순위, 고유 ID를 작업 식별에 반영합니다. 작업자는 현재 실행할 수 있는 작업을 찾아 임대하고, 실행 시각을 미래로 옮겨 다른 작업자가 동시에 가져가지 않게 합니다. 작업자가 죽으면 임대 시간이 지난 뒤 다른 작업자가 다시 처리합니다. 작업이 임대 시간보다 오래 걸리면 임대를 연장해 중복 실행을 막습니다.

이 설계는 작업을 잃지 않으면서 별도 조정 서비스를 두지 않는 장점이 있습니다. 대신 작업을 찾고 임대하는 과정에서 FoundationDB 읽기와 쓰기가 늘고, 트랜잭션 충돌도 생깁니다. 무작위로 작업을 선택하면 충돌 가능성을 낮출 수 있지만, 운 나쁜 작업이 오래 선택되지 않을 수 있습니다. 시계 동기화도 필요합니다. Tigris는 Apple의 구현 코드를 그대로 쓴 것이 아니라 기존 FoundationDB 레코드 계층을 활용하고, 고정된 큐와 장시간 작업의 체크포인트, cron 작업 등을 추가했습니다.

작업을 나누고 큐를 샤딩했습니다

Tigris는 작업 키의 암호학적 해시를 기준으로 큐를 여러 레인(lane)으로 나눴습니다. 작업을 임대하면 실행 시각이 바뀌고 키도 달라져 다른 레인으로 이동합니다. 작업자는 여러 레인을 감시합니다. 이렇게 하면 무작위 선택에만 의존할 때 생기는 충돌과 작업 지연을 줄이고, 특정 작업자나 레인에 작업이 몰리는 현상을 완화할 수 있습니다.

Kafka로 옮긴 이유와 남겨 둔 작업

큐 작업이 늘면서 FoundationDB 저장 서버가 트랜잭션 로그를 따라가지 못하는 일이 생겼습니다. 이때 데이터베이스가 전체 처리량을 제한하므로 사용자 요청에도 영향이 갔습니다. 객체 저장 요청 하나가 추가 쓰기를 만들고, 큐의 작업 처리도 같은 클러스터에 쓰기 부담을 더했습니다. Tigris는 클러스터를 샤딩하거나 하드웨어를 늘리는 대신 Kafka를 도입했습니다.

모든 큐를 한꺼번에 옮기지는 않았습니다. S3 API 호출이나 이벤트로 발생하는 작업은 Kafka로 보냅니다. TTL 만료나 라이프사이클 전환처럼 스캔으로 찾아야 하는 작업은 FoundationDB가 계속 찾되, 발견한 작업을 Kafka에 넣습니다. 복제 작업은 FoundationDB에 남겼습니다. 복제 기록과 작업 큐를 별도 시스템에 쓰면 한쪽만 성공하는 이중 쓰기 문제가 생길 수 있기 때문입니다.

전환 효과는 삭제 작업에서 뚜렷합니다. 객체를 삭제할 때는 먼저 tombstone을 기록하고, 보존 기간이 지난 항목을 찾아 정리하는 작업을 예약합니다. QuiCK에서는 작업 하나마다 큐 등록, 작업 선점, 임대, 완료 후 삭제 등의 쓰기가 추가됩니다. 글의 계산에 따르면 tombstone 하나를 정리하는 작업에서 큐 자체가 만드는 쓰기는 네 번이며, tombstone을 찾는 버킷 단위 작업에도 추가 쓰기와 범위 스캔이 듭니다. 백만 개를 삭제하면 큐 쓰기만 최소 400만 건에 이릅니다.

Kafka로 옮긴 뒤에는 FoundationDB에 tombstone을 쓰고 정리 메시지를 Kafka에 넣습니다. Kafka 소비자 오프셋이 QuiCK의 실행 시각 역할을 하므로 작업 예약을 위해 FoundationDB에 쓰지 않아도 됩니다. FoundationDB 기록은 성공했지만 Kafka 메시지 전송이 실패하면 데이터가 사라지는 대신 tombstone 정리가 늦어집니다. Tigris는 이 실패 방식이 받아들일 만하다고 판단했습니다. FoundationDB는 계속 쓰되, 클러스터 샤딩에 나설 때까지 쓰기 부담을 줄이려고 작업별로 두 시스템을 나눠 운영합니다.

Lobsters 반응

  • @trousers — 여러 곳에서 Kafka를 작업 큐로 쓰는 걸 봤는데, 대체로 어색하게 느껴졌거나 재처리 상황을 다루는 사용자 코드가 많이 필요했습니다. 나라면 NATS/JetStream을 썼을 것 같습니다.
    • @doctor_eval — 데이터베이스를 메시지 큐로 쓰는 방식은 좋아하지 않습니다. 저도 전임자들이 Oracle에서 그렇게 한 결과를 매일 겪고 있습니다. 그래도 NATS는 Kafka보다 거의 모든 면에서 훨씬 쉽습니다. NATS를 정말 좋아합니다.
    • @2 — Kafka가 맞는 도구인지도 의문입니다. NATS/JetStream을 써 봤는데 경험이 좋지 않았습니다. 제가 제대로 다루지 못했을 수도 있지만 작업 큐로 쓰기엔 처리량이 너무 낮았습니다. 최근 Jepsen의 NATS/JetStream 분석을 읽고는 쓰지 않기로 했습니다. 저는 RabbitMQ를 고르겠습니다.
    • @rkaw92 — 비교적 잘 알려지지 않은 Kafka의 새 큐 기능을 쓸 수도 있습니다. 기본 Kafka는 연속된 샤드형 레코드 스트림이라 큐로 쓰기 나쁩니다. 작업 하나가 느리면 파티션 전체가 막히고, 재처리와 오프셋의 장애 안전성도 복잡합니다. 사람들이 RabbitMQ는 초당 3만 건, Kafka는 단일 노드 실측으로 초당 20만 건을 처리하니 후자가 더 낫다고 생각해서 쓰는 것 같습니다.
    • @mdaniel — NATS나 JetStream이 철회된 줄 잘못 알고 있었습니다. 확인해 보니 Apache 2 라이선스의 CNCF 프로젝트였습니다. 왜 그렇게 생각했는지 찾아보다가 Synadia가 CNCF를 떠나면 NATS 서버에 BUSL을 적용할 거라는 글을 발견했는데, 제 기억과 맞아떨어집니다.
    • @tonyarkles — 저도 그렇게 알고 있었는데, 그 무렵 기술 비교 평가를 하면서 그렇게 이해했던 것 같습니다. CNCF에 남아 Apache 라이선스를 유지했다니 반갑습니다.
    • @babak — NATS는 잘 모릅니다. NATS와 AMQP를 비교하면 어떤가요?
    • @tonyarkles — 용도가 꽤 다릅니다. RabbitMQ와 NATS를 시험했을 때 NATS는 기본값이 비영속 큐였고, 전달 보장도 기본적으로 최대 한 번(at-most-once)이었던 것 같습니다. RabbitMQ는 메시지 라우팅과 팬아웃 선택지가 훨씬 많았습니다. NATS는 서버 바이너리 하나로 지연 시간이 짧고 처리량이 높았습니다.
    • @valenterry — 기본값이 그렇다는 건 쉽게 바꿀 수 있다는 뜻인가요, 아니면 바꾸기 어렵다는 뜻인가요? 저는 대개 최소 한 번(at-least-once) 전달이 필요합니다. 또 우선순위 큐도 중요합니다. Kafka에서는 잘 안 되고, 높은 우선순위 작업을 넣거나 오래된 작업을 낮추고 싶을 때가 많습니다.
    • @tonyarkles — 신뢰성과 내구성을 제공하는 계층은 JetStream이며, 켤 수 있습니다. 저는 이벤트를 특정 소비자가 놓치지 않는 게 중요하지 않은 빠른 시스템이 필요해서 직접 시험해 보지는 않았습니다.
    • @rkaw92 — 저는 둘 다 써 봤습니다. NATS는 릴레이가 있는 ZeroMQ에 가깝습니다. 발행·구독은 되지만 자체적으로 데이터를 보존하지 않습니다. JetStream 같은 추가 구성요소는 NATS를 전송 수단으로 쓰는 RPC 서버에 가깝고, 내구성 있는 테이프와 오프셋 의미를 제공해 큐처럼 쓸 수 있게 합니다. JetStream은 개별 확인(ack)을 추적한다는 점에서 일반 Kafka보다 낫습니다. Kafka는 오프셋의 최종 위치만 추적합니다. Apache Pulsar도 오프셋과 누락 구간을 추적합니다. JetStream에는 우선순위 큐가 없어서 pull consumer와 우선순위별 큐를 조합해야 합니다. RabbitMQ에서도 우선순위 큐는 성능 비용이 있고 우선순위 역전 위험이 있으니 신중해야 합니다. 높은 우선순위 작업 전용 작업자 풀을 두는 편이 사용자 경험에 나을 수도 있습니다.
    • @alper — NATS를 쓰기로 하고 Kafka 용도까지 모두 NATS로 처리할 수 있다면 그렇게 하면 됩니다. 이미 Kafka를 운영하고 있다면 별도 시스템을 더 유지하는 대신 Kafka에 큐를 두는 편이 낫습니다.
  • @stephenr — 이 글은 데이터베이스 전반이 큐에 맞지 않는다는 걸 설득하지 않습니다. FoundationDB가 대부분의 용도에서 큐에 맞지 않는다는 점을 보여줍니다.
    • @alper — 기술 선택을 잘한다고 생각하는 여러 조직이 FoundationDB를 쓰고 있다는 점과 어울리지 않아 보입니다.
    • @felixyz — 그 조직들이 메시지 큐로 쓴다는 뜻인가요? FoundationDB가 훌륭하다는 점은 분명하지만, 이 용도에도 좋은가요?
    • @stephenr — 상관관계가 인과관계는 아닙니다. 좋은 결정을 내릴 사람이 결정권을 갖지 못해 주어진 도구를 쓰는 경우도 있습니다. 또 기술 선택을 잘한다고 생각하는 조직이라는 사실만으로는 제게 큰 근거가 되지 않습니다.

원문: Tigris / 번역·요약: Trawling