청구 파이프라인이 갑자기 느려졌다. 원인은 ClickHouse의 숨겨진 병목이었다 (새 탭에서 열림)
Cloudflare는 ClickHouse의 파티션 키를 `(day)`에서 `(namespace, day)`로 변경해 테넌트별 보존 기간을 지원하려 했지만, 수 주 뒤 청구용 쿼리가 급격히 느려졌다. 디스크 I/O, 메모리, 스캔 행 수, 읽은 파트 수는 정상처럼 보였지만, 실제 원인은 쿼리 실행 전 계획 수립 단계에서 발생한 `MergeTreeData` 뮤텍스 경합이었다. 파티션 수 증가가 개별 쿼리의 읽기량을 늘리지는 않았지만, 모든 쿼리 스레드가 전체 파트 목록을 보호하는 하나의 잠금을 기다리면서 지연이 누적됐다. ## 페타바이트 규모의 Ready-Analytics 플랫폼 - Cloudflare는 수십 개 클러스터에서 100PB 이상의 ClickHouse 데이터를 운영한다. - 내부 팀의 온보딩을 단순화하기 위해 `Ready-Analytics`라는 대규모 공용 테이블을 구축했다. - 데이터는 `namespace`로 구분하고, 표준 스키마에 따라 저장했다. - 기본 키는 `(namespace, indexID, timestamp)`로 구성했다. - `namespace`: 데이터 소유 팀 또는 애플리케이션 구분 - `indexID`: 네임스페이스별 쿼리 패턴에 맞춘 정렬 기준 - `timestamp`: 시간 범위 조회 지원 - 2024년 12월 기준 2PiB 이상, 초당 수백만 행의 데이터가 유입되고 있었다. ## 단일 보존 정책의 한계 - 기존 시스템은 ClickHouse의 네이티브 TTL이 보편화되기 전부터 운영되어 자체 파티션 기반 보존 시스템을 사용했다. - 테이블은 날짜별로 파티셔닝되었고, 31일보다 오래된 파티션을 삭제했다. - 모든 네임스페이스에 동일한 31일 보존 기간이 적용됐다. - 법적·계약상 수년간 데이터를 보관해야 하는 팀과 며칠만 보관하면 되는 팀을 동시에 지원할 수 없었다. - 그 결과 일부 사용 사례는 Ready-Analytics 대신 별도의 테이블과 복잡한 온보딩 절차를 이용해야 했다. ## `(namespace, day)` 파티션으로의 변경 - 검토한 선택지는 두 가지였다. - 네임스페이스마다 별도 테이블을 생성하는 방식 - 기존 파티션 키를 `(day)`에서 `(namespace, day)`로 변경하는 방식 - 별도 테이블 방식은 수천 개의 테이블을 자동으로 생성·관리해야 하므로 운영 복잡도가 컸다. - 최종적으로 `(namespace, day)` 파티션을 선택했다. - 기존 보존 시스템을 재사용할 수 있었다. - 특정 네임스페이스의 오래된 데이터만 선택적으로 삭제할 수 있었다. - 설계 당시에는 전체 데이터 파트 수가 늘어날 것을 알고 있었지만, 모든 쿼리가 특정 `namespace`로 필터링되므로 개별 쿼리가 읽는 파트 수는 변하지 않을 것이라고 판단했다. - 따라서 쿼리 성능도 크게 변하지 않을 것으로 예상했다. - 이 구조는 디스크 사용량을 네임스페이스별로 관리하는 기반도 제공했다. - max-min fairness 알고리즘으로 여유 디스크를 네임스페이스 간에 공유 - 목표 디스크 사용률을 90% 수준으로 유지 - 2025년 1월부터 마이그레이션을 시작했고, ClickHouse의 `Merge` 테이블 기능을 활용해 구 테이블과 신 테이블을 함께 운영하며 데이터를 이전했다. ## 청구 작업에서 드러난 성능 저하 - 2025년 3월 말, 마이그레이션 약 두 달 뒤 청구 팀이 일일 집계 작업의 지연을 보고했다. - 청구 작업은 정해진 시간 내 완료되지 않으면 청구서 발행이 지연되므로 강한 마감 시간이 있었다. - 성능 저하는 점진적으로 심해졌지만 일반적인 원인은 발견되지 않았다. - I/O 문제 없음 - 메모리 부족 없음 - 쿼리별 스캔 행 수 변화 없음 - 읽은 데이터 파트 수 증가 없음 - 전체 클러스터의 파트 수와 평균 `SELECT` 쿼리 시간을 비교한 결과, 두 지표 사이에 뚜렷한 상관관계가 나타났다. - 즉, 쿼리가 직접 읽지 않는 파트라도 테이블에 존재하는 것만으로 성능에 영향을 주고 있었다. ## Flame Graph로 찾은 쿼리 계획 병목 - ClickHouse의 `trace_log`를 이용해 쿼리 실행 중 어떤 코드가 시간을 사용하는지 분석했다. - `trace_log`는 실행 코드뿐 아니라 사용자, 쿼리 ID 등의 메타데이터도 제공해 특정 리프 `SELECT` 쿼리를 대상으로 분석할 수 있었다. - CPU 샘플 기반 Flame Graph에서는 쿼리 계획 단계에 상당한 시간이 소요되는 것이 확인됐다. - 특히 `filterPartsByPartition` 함수가 샘플 CPU 시간의 약 45%를 차지했다. - 파트 제거 휴리스틱의 실행 순서를 바꾸는 패치를 적용했지만 성능 향상은 약 5%에 그쳤다. - 이후 실행 중인 스레드만 측정하는 CPU trace 대신, 대기 중인 스레드까지 포함하는 Real trace를 사용했다. - 그 결과 실제 병목은 CPU 연산이 아니라 잠금 대기임이 밝혀졌다. ## `MergeTreeData` 뮤텍스 경합 - 쿼리 실행 시간의 절반 이상이 `MergeTreeData` 뮤텍스를 획득하기 위해 대기하는 데 사용됐다. - 이 뮤텍스는 테이블의 활성 데이터 파트 목록을 보호한다. - 쿼리 계획을 수립할 때 각 스레드는 읽을 파트를 결정하기 위해 이 파트 목록에 접근해야 한다. - `(namespace, day)` 파티션 도입으로 전체 파트 수가 계속 증가하면서, 파트 목록을 대상으로 하는 계획 수립 작업과 잠금 경쟁도 함께 커졌다. - 따라서 개별 쿼리가 실제로 읽는 파트 수가 동일해도, 전체 테이블의 파트 수 증가만으로 쿼리 지연이 누적될 수 있었다. ## 실용적인 결론 ClickHouse에서 파티션 수는 저장 공간이나 데이터 스캔량뿐 아니라 쿼리 계획 단계의 내부 자료구조와 잠금 경합에도 영향을 준다. 파티션 키를 세분화할 때는 “쿼리당 읽는 파트 수”만 보지 말고 전체 파트 수 증가, 계획 수립 시간, 대기 시간까지 함께 추적해야 하며, CPU trace와 대기 스레드를 포함한 Real trace를 모두 활용하는 것이 중요하다.