TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 32 READS

Postgres로 큐를 초당 3만 건까지 굴리기: DBOS가 찾은 병목과 해법

Postgres로 큐를 초당 3만 건까지 굴리기: DBOS가 찾은 병목과 해법
SOURCE IMAGE · HACKER NEWS

“Postgres로 큐를 만들면 규모가 안 나온다.” 대량 워크로드를 감당하려면 RabbitMQ와 Celery, 혹은 Redis와 BullMQ 같은 전용 큐 시스템을 써야 한다는 것이 오랜 통념이다. 이 말에는 근거가 있다. 수천 개의 워커가 동시에 큐 테이블을 폴링하면 경합(contention)이 생기고 인덱스가 계속 갱신되면서, 큐는 Postgres에게 상당히 부담스러운 워크로드가 된다. 그런데 durable execution 플랫폼을 만드는 DBOS는 월 수백억 건의 워크플로를 처리하는 사용자를 지원하면서, 적절한 최적화를 거치면 Postgres 기반 큐로도 초당 3만 건, 월 800억 건 규모까지 감당할 수 있음을 보여줬다. 어떤 병목을 어떤 순서로 걷어냈는지 뜯어보면, 관계형 DB를 큐로 재활용하려는 팀이 참고할 만한 실용적 교훈이 나온다.

첫 번째 벽: 워커들이 같은 행을 놓고 다툰다

Postgres 기반 큐의 기본 구조는 단순하다. 클라이언트가 큐 테이블에 워크플로를 넣고, 워커는 가장 오래된 항목부터 꺼내(FIFO) 처리한다. 문제는 여러 워커가 “가장 오래된 N개”를 찾는 동일한 쿼리를 동시에 실행하는 순간 드러난다. 모든 워커가 똑같은 행을 보고 동시에 차지하려 들지만, 각 워크플로는 단 하나의 워커만 가져갈 수 있으므로 대부분은 실패하고 재시도를 반복한다. 이 경합 탓에 순진하게 구현하면 초당 약 100건 언저리에서 처리량이 막힌다.

해법은 Postgres가 제공하는 잠금 절(locking clause), 특히 FOR UPDATE SKIP LOCKED다. 이 구문은 선택한 행을 잠가 다른 워커가 집지 못하게 하는 동시에, 이미 잠긴 행은 건너뛴다. 즉 각 워커는 “가장 오래된 N개”가 아니라 “아직 잠기지 않은 가장 오래된 N개”를 가져간다. 한 워커가 앞의 N개를 잠그면 다음 워커는 그다음 N개를 집는 식으로, 서로 밟지 않고 병렬로 작업을 분배한다. SKIP LOCKED가 주기적으로 재발견되는 고전 트릭인 이유이며, 이것이 없으면 애초에 확장 자체가 불가능하다.

두 번째 벽: 격리 수준이 만든 직렬화 실패

잠금 절로 성능이 크게 올랐지만, 초당 약 1,000건을 넘기자 이번엔 대다수 dequeue 작업이 Postgres의 “Serialization Failure” 예외로 실패하고 재시도에 빠졌다. 범인은 트랜잭션 격리 수준이었다. dequeue 트랜잭션은 원래 REPEATABLE READ로 돌고 있었는데, 이는 “전체 워커를 통틀어 최대 N개만 동시 실행” 같은 전역(global) 동시성 제한을 지원하기 위한 선택이었다. 전역 제한을 지키려면 워커들이 큐 상태에 대한 일관된 스냅숏을 공유해야 하고, REPEATABLE READ가 바로 그 고정 스냅숏을 보장한다.

하지만 이 격리 수준은 높은 동시성에서 비싸진다. 여러 워커가 겹치는 행을 동시에 수정하면 Postgres는 그중 하나를 직렬화 실패로 중단시키고, 결국 워커들은 워크플로를 처리하기보다 트랜잭션을 재시도하는 데 더 많은 시간을 쓰게 된다. 핵심 통찰은 “가장 큰 큐일수록 전역 흐름 제어를 거의 쓰지 않는다”는 관찰이었다. 대규모 사용자는 대체로 ‘워커당 최대 10개’ 같은 지역(local) 제한을 선호하는데, 이런 제한은 워커 간 조정이 필요 없다. 그래서 DBOS는 격리 수준을 조건부로 바꿨다. 전역 제한을 쓰는 큐만 REPEATABLE READ를 유지하고, 그렇지 않은 큐는 READ COMMITTED를 써서 직렬화 실패를 아예 없앴다.

세 번째 벽: 인덱스가 CPU를 잡아먹는다

잠금 절과 낮춘 격리 수준을 합치자 수천 워커에서도 경합이 사실상 사라졌다. 그러나 초당 약 8,000건을 넘기자 이번엔 CPU 사용량이 치솟았다. 원인은 겉보기에 무관해 보이는 두 곳, 즉 dequeue 쿼리 자체와 Postgres 오토배큠(auto-vacuum)이었는데 뿌리는 같았다. 비효율적인 인덱스였다. dequeue용 인덱스는 특정 큐의 ENQUEUED 워크플로를 빠르게 찾아주긴 했지만 정렬 순서를 보장하지 않아, 쿼리가 매번 타임스탬프로 정렬하는 비용을 치렀다. 게다가 상태 갱신(입력·출력·완료)마다 모든 보조 인덱스를 갱신해야 했고, 그 뒤 오토배큠이 낡은 인덱스 항목을 청소하느라 고처리량에서 DB CPU의 상당 부분을 소모했다.

해법은 인덱스를 더 선별적으로(selective) 만드는 것이었다. 먼저 dequeue 인덱스가 큐 이름별 ENQUEUED 워크플로를 우선순위와 타임스탬프 순으로 정렬된 상태로 반환하도록 바꿔 쿼리의 정렬 단계를 없앴다. 동시에 이를 부분 인덱스(partial index)로 전환해 상태가 ENQUEUED인 동안에만 유지되게 했다. 그러면 워크플로가 dequeue되는 순간 Postgres는 인덱스 항목을 계속 관리하는 대신 그냥 삭제하면 되므로 유지·배큠 비용이 줄어든다. 관측용 인덱스에도 같은 원리를 적용해, 예컨대 부모 워크플로 ID 인덱스는 실제로 부모가 있는 워크플로에 대해서만 유지하도록 했다. 이 최적화들을 합쳐 CPU 사용량을 크게 낮춘 결과가 초당 3만 건, 월 800억 건이라는 수치다.

실무자가 가져갈 것과 남는 한계

이 사례가 주는 메시지는 “Postgres 큐가 안 된다”는 통념이 절대적이지 않다는 것이다. 다만 세 가지 최적화가 모두 필요했다는 점에 주의할 필요가 있다. SKIP LOCKED로 워커 경합을 없애고, 워크로드 성격에 맞춰 격리 수준을 조건부로 낮추며, 상태에 종속된 부분 인덱스로 정렬과 배큠 비용을 줄이는 세 축이 함께 맞물려야 비로소 큰 규모가 나온다. 이미 Postgres를 운영 데이터베이스로 쓰는 팀이라면 별도 큐 인프라의 운영 부담과 데이터 정합성 문제를 피하면서 상당한 처리량을 얻을 수 있다는 뜻이기도 하다.

동시에 이 결과가 만능은 아니라는 점도 분명히 해두는 것이 좋다. 여기서 직렬화 실패를 없앤 전제는 대다수 큐가 전역 흐름 제어를 필요로 하지 않는다는 관찰이었고, 전역 동시성 제한이 반드시 필요한 워크로드라면 여전히 REPEATABLE READ의 비용을 감수해야 한다. 또한 3만 건이라는 수치는 워크플로 오케스트레이션 성격의 작업 분배를 전제로 한 것으로, 메시지 브로커가 제공하는 팬아웃·라우팅·백프레셔 같은 기능을 그대로 대체한다는 의미는 아니다. 결국 이 글은 ‘Postgres냐 전용 큐냐’를 이분법으로 정하기 전에, 자기 워크로드의 경합 지점과 인덱스 유지 비용을 먼저 측정해 보라는 실무적 권고로 읽는 편이 맞다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://www.dbos.dev/blog/making-postgres-queues-scale
SHARE
처리 중...