본문으로 건너뛰기
피드

클릭하우스가 PgBouncer 처리량을 4배로 끌어올린 방법

backend 약 9분
vote
0
댓글
북마크

ClickHouse Managed Postgres 팀은 단일 스레드인 PgBouncer를 여러 프로세스로 띄우고 `so_reuseport`와 피어링(peering)을 조합해 16 vCPU 머신을 제대로 쓰게 만들었다. 동일한 AWS 환경에서 단일 프로세스는 약 8.7만 TPS 근처에서 막혔지만, 16개 프로세스 구성은 약 33.6만 TPS까지 올라갔다.

  • 1

    PgBouncer는 프로세스당 CPU 코어 1개만 쓰기 때문에 큰 인스턴스에서도 병목이 될 수 있음

  • 2

    `so_reuseport`로 여러 PgBouncer 프로세스가 같은 포트를 공유하고 커널이 연결을 분산함

  • 3

    Postgres 쿼리 취소 요청은 새 연결로 들어오기 때문에, 잘못된 프로세스에 도착하면 peering으로 실제 세션 소유자에게 전달해야 함

  • 4

    16 vCPU c7i.4xlarge 실험에서 단일 프로세스는 약 87k TPS, 16개 프로세스 플릿은 약 336k TPS를 기록함

  • PgBouncer의 기본 함정은 단순함. 단일 프로세스가 CPU 코어 하나만 쓴다는 것임

    • 16 vCPU 머신에 올려도 PgBouncer 프로세스 하나는 코어 1개만 바쁘게 씀
    • 나머지 15개 코어가 놀고 있는데도, Postgres가 아니라 풀러가 먼저 처리량 상한을 만들어버릴 수 있음
  • ClickHouse Managed Postgres는 PgBouncer를 코어 수에 맞춰 여러 프로세스로 띄우는 방식을 썼음

    • 각 PgBouncer 프로세스가 같은 포트에 바인딩함
    • 이때 so_reuseport를 켜서 커널이 새 클라이언트 연결을 여러 프로세스에 분산하게 만듦
    • 클라이언트는 여전히 하나의 엔드포인트로 접속하니 구성 복잡도는 밖으로 새지 않음
  • 그런데 so_reuseport만 켜면 Postgres 쿼리 취소가 깨질 수 있음

    • Postgres의 cancel request는 실행 중인 쿼리 연결을 타고 오는 게 아니라, cancel key를 들고 새 연결로 들어옴
    • 커널은 이 새 연결을 아무 PgBouncer 프로세스에나 보낼 수 있음
    • 그래서 실제 쿼리 세션을 가진 프로세스가 아닌 곳에 취소 요청이 도착하면, 그 프로세스는 “이 쿼리 모르는데?” 상태가 됨
sequenceDiagram
    participant 클라이언트
    participant 커널
    participant 풀러A
    participant 풀러B
    participant 포스트그레스
    클라이언트->>커널: 쿼리 실행 연결
    커널->>풀러A: 연결 분산
    풀러A->>포스트그레스: 쿼리 전달
    클라이언트->>커널: 취소 요청 새 연결
    커널->>풀러B: 다른 프로세스로 전달
    풀러B->>풀러A: 세션 소유자에게 취소 전달
    풀러A->>포스트그레스: 쿼리 취소
  • 해결책은 PgBouncer 프로세스끼리 서로를 알게 하는 peering임
    • 취소 요청이 엉뚱한 프로세스에 도착해도, 그 프로세스가 실제 세션을 가진 프로세스로 전달함
    • 덕분에 여러 프로세스가 같은 포트를 나눠 쓰면서도 쿼리 취소 기능이 전체 플릿에서 정상 동작함

중요

> 이 구성의 핵심은 “프로세스를 여러 개 띄운다”에서 끝나지 않음. Postgres cancel request처럼 새 연결로 들어오는 프로토콜 동작까지 처리해야 실제 운영에서 안 깨짐.

  • 풀링 모드는 transaction mode를 사용함

    • 트랜잭션이 커밋되는 순간 서버 연결을 다시 풀에 돌려줌
    • max_client_connmax_db_connections는 프로세스 수로 나눠서, 전체 플릿이 Postgres 연결 예산을 넘지 않게 함
    • 여러 프로세스를 띄웠다고 Postgres까지 과도하게 물고 늘어지면 그건 또 다른 장애 포인트가 되기 때문임
  • 벤치마크 환경은 꽤 깔끔하게 통제됐음

    • 풀러 머신은 AWS EC2 c7i.4xlarge 16 vCPU
    • Postgres는 별도 머신, 부하 생성기도 별도 머신
    • 워크로드는 pgbench의 select-only, transaction-pooled 모드
    • 비교 변수는 PgBouncer 1개 프로세스냐, 16개 프로세스 플릿이냐 하나였음
  • 결과는 숫자로 바로 갈림. 단일 프로세스는 약 87k TPS 근처에서 막힘

    • 클라이언트 연결을 8개에서 256개까지 늘렸을 때, 단일 PgBouncer는 약 87k transactions/sec에서 정점 찍고 오히려 내려감
    • 256 클라이언트에서는 약 77k TPS까지 밀림
    • 이유는 단순함. 하나의 코어에 모든 경합이 몰리기 때문임
  • 16개 프로세스 플릿은 약 336k TPS까지 올라감

    • 대략 4배 처리량임
    • PgBouncer가 더 많은 코어를 쓰기 시작했고, 풀러가 아니라 Postgres와 부하 생성기가 한계가 되는 지점까지 올라감
    • 단일 프로세스가 16 vCPU 머신을 사실상 1코어 장비처럼 쓰던 걸 바로잡은 셈임
  • CPU 사용률 지표도 같은 얘기를 함

    • 단일 프로세스는 PgBouncer 프로세스 하나가 약 97% CPU, 즉 코어 하나를 꽉 채움
    • 그런데 16 vCPU 머신 전체 사용률은 10% 미만에 머뭄
    • 플릿 구성은 약 8코어까지 바쁘게 쓰고, 256 클라이언트 고정 테스트에서 전체 CPU가 약 52%까지 올라감
  • CloudWatch에서 봐도 차이는 그대로였음

    • 단일 프로세스 인스턴스는 평균 약 16% CPUUtilization
    • 플릿 인스턴스는 평균 약 60% CPUUtilization
    • 같은 돈 내고 16 vCPU를 샀는데, 단일 PgBouncer는 대부분을 바닥에 버리고 있었던 것임

💡

> PgBouncer 앞단 병목이 의심되면 Postgres 튜닝부터 들어가기 전에 풀러 프로세스 CPU 사용률을 먼저 봐야 함. 전체 머신 CPU가 낮아도 단일 프로세스가 코어 하나를 꽉 채우고 있을 수 있음.

  • 연결 제한도 플릿 기준으로 다시 생각해야 함

    • 단일 프로세스는 max_client_conn을 혼자 강제하므로 한계에 닿으면 새 클라이언트를 거절함
    • 플릿에서는 전체 연결 예산을 프로세스별로 나눠야 aggregate ceiling을 올리면서도 Postgres를 안전하게 보호할 수 있음
  • 결론은 PgBouncer가 기본값으로는 충분하지만, 고동시성에서는 직접 병목이 될 수 있다는 것임

    • 연결 수가 적을 때는 단일 프로세스가 오히려 살짝 빠를 수도 있음
    • 하지만 실서비스처럼 동시성이 올라가는 순간, 코어 하나짜리 벽이 먼저 등장함
    • ClickHouse Managed Postgres는 이 구성을 기본으로 제공한다고 함

기술 맥락

  • 여기서 선택한 구조는 PgBouncer를 스레드화하는 게 아니라, 단일 스레드 프로세스를 여러 개 띄우는 방식이에요. PgBouncer 자체가 프로세스당 한 코어만 쓰기 때문에, 16 vCPU 머신을 제대로 쓰려면 프로세스 수를 늘리는 쪽이 현실적인 선택이거든요.

  • so_reuseport가 중요한 이유는 클라이언트 엔드포인트를 하나로 유지하면서도 내부 병렬성을 얻을 수 있기 때문이에요. 애플리케이션 설정을 여러 포트로 쪼개지 않아도 커널이 새 연결을 PgBouncer 프로세스들에 분산해줘요.

  • 다만 Postgres의 cancel request는 평범한 요청이 아니에요. 실행 중인 연결이 아니라 새 연결로 들어오고 cancel key만 들고 오기 때문에, so_reuseport 환경에서는 원래 세션을 가진 프로세스와 다른 곳에 도착할 수 있어요. 그래서 peering이 없으면 취소가 조용히 실패할 수 있어요.

  • 연결 예산을 프로세스 수로 나누는 것도 운영상 핵심이에요. PgBouncer 프로세스를 16개 띄웠다고 각자 같은 max_db_connections를 가지면 Postgres 입장에서는 전체 연결 수가 16배로 터질 수 있거든요. 처리량을 올리면서도 데이터베이스를 보호하려면 총량 기준으로 계산해야 해요.

  • 벤치마크가 설득력 있는 이유는 병목 위치가 숫자로 보이기 때문이에요. 단일 프로세스는 프로세스 CPU가 약 97%인데 머신 전체는 10% 미만이고, 플릿은 약 336k TPS까지 올라가요. 이건 Postgres 튜닝 문제가 아니라 풀러가 코어를 못 쓰던 문제였다는 걸 꽤 직접적으로 보여줘요.

Postgres 성능 문제라고 생각했는데 실제 병목이 PgBouncer 한 프로세스였다는 케이스라 실무 감각이 좋다. 커넥션 풀러는 ‘그냥 앞단에 두는 것’이 아니라, CPU 코어 수와 취소 요청 같은 프로토콜 동작까지 같이 봐야 함.

댓글

댓글

댓글을 불러오는 중...

backend

SQLite 쓸 거면 STRICT 테이블을 기본값으로 보자는 주장

SQLite의 STRICT 테이블은 INTEGER 컬럼에 문자열이 들어가는 식의 타입 실수를 막아주는 기능이다. 글쓴이는 SQLite 특유의 유연한 타입 시스템보다, 실수는 빨리 터뜨리는 쪽이 실무에서 낫다고 주장한다. 다만 기존 테이블을 바로 STRICT로 바꾸기 어렵고, SQLite 3.37.0 이상에서만 쓸 수 있다는 제약도 있다.

backend

왜 아직도 Lisp를 배워볼 만한가

Lisp는 괄호가 많은 이상한 언어처럼 보이지만, 핵심은 문법이 아니라 언어 자체를 확장할 수 있다는 점에 있음. 매크로, 코드-데이터 동일성, REPL 중심 개발이 합쳐지면 프로그램을 '작성'한다기보다 살아 있는 시스템을 계속 진화시키는 방식으로 개발하게 됨.

backend

EDB 포스트그레스 AI, 포레스터 멀티모델 데이터 플랫폼 리더 선정

EDB의 AI 데이터 플랫폼 EDB 포스트그레스 AI가 2026년 2분기 포레스터 웨이브 멀티모델 데이터 플랫폼 보고서에서 리더로 선정됐음. 트랜잭션, 분석, AI 워크로드를 하나의 플랫폼에서 다루는 흐름이 강해지는 가운데, EDB는 전략과 현재 제공 기능 모두에서 높은 평가를 받았다고 밝힘.

backend

Bun, Zig에서 Rust로 갈아탔다: 11일짜리 초대형 런타임 포팅 실험

Bun 팀이 53만 줄이 넘는 Zig 코드베이스를 Rust로 기계적으로 포팅했고, Bun v1.4.0부터 Rust 기반으로 전환될 예정임. 핵심 이유는 성능보다 안정성이었고, use-after-free, double-free, 메모리 누수 같은 문제를 컴파일러와 타입 시스템으로 더 빨리 잡기 위해서였음. Claude Code 워크플로 64개를 병렬로 돌려 11일 만에 전 플랫폼 테스트 통과까지 밀어붙인 사례라, LLM 기반 대규모 코드 마이그레이션의 꽤 강한 신호탄임.

backend

시카고 미술관 API에 숨어 있는 ‘조회수 낮은 작품’ 필드

시카고 미술관 API에는 웹사이트에서 거의 조회되지 않은 작품을 표시하는 불리언 필드가 있다. 기준은 2010년 1월 1일 이후 조회수 200회 미만으로, API 설계 안에 꽤 인간적인 큐레이션 포인트가 숨어 있는 셈이다.