본 콘텐츠는 사용자의 편의를 고려해 자동 기계 번역 서비스를 사용하였습니다. 영어 원문과 다른 오류, 누락 또는 해석상의 미묘한 차이가 포함될 수 있습니다. 필요하시다면 영어 원문을 참조하시기를 바랍니다.
Cloudflare는 오픈 소스 온라인 분석 처리(OLAP) 데이터베이스인 ClickHouse를 많이 사용합니다. 우리는 사용자에게 Cloudflare 제품 사용에 대해 요금을 청구할 금액을 결정하기 위해 매일 ClickHouse로 수백만 건의 호출을 합니다. 이러한 작업을 적시에 완료하지 않으면 송장을 조정하기가 아주 어려워집니다.
이 파이프라인은 사용 수익, 사기 시스템 등에서 수억 달러의 수익을 창출하므로 지연되면 다운스트림에도 큰 영향이 미칩니다.
Cloudflare의 청구서가 나가는 것을 담당하는 ClickHouse의 일일 집계 작업이 마이그레이션 후에 크게 느려졌을 때 큰 문제가 되었던 이유가 바로 그것입니다. I/O, 메모리, 스캔한 행, 읽은 부분 등 모든 일반적인 용의자가 깨끗해 보였습니다. ClickHouse 쿼리가 느리면 일반적으로 확인하는 모든 것이 정상적인 것으로 보였습니다.
ClickHouse의 내부 깊숙이 위치한 숨겨진 병목 현상을 저희가 발견하고 이를 해결하기 위해 작성한 세 가지 패치에 대한 이야기를 들어보겠습니다.
우리는 ClickHouse를 사용하여 수십 개의 클러스터에 100페타바이트 이상의 데이터를 저장합니다. 많은 내부 팀을 위해 온보딩을 간소화하기 위해 2022년 초에 "준비된 분석"이라는 시스템을 구축했습니다.
전제는 간단합니다. 팀에서 새로운 테이블을 설계하는 대신 데이터를 하나의 방대한 테이블로 스트리밍할 수 있다는 것입니다. 데이터세트는 네임스페이스로 명확화되며 각 레코드는 표준 스키마(예: 부동 필드 20개, 문자열 필드 20개, 타임스탬프, indexID)를 사용합니다.
ClickHouse에서 데이터 정렬 방식은 쿼리 성능에 매우 중요합니다. 여기에서 indexID가 필요합니다. 기본 키의 일부를 형성하는 문자열 필드이므로 모든 개별 네임스페이스는 해당 네임스페이스의 소유자가 실행할 것으로 예상되는 쿼리에 최적화된 방식으로 데이터를 정렬할 수 있습니다. 결국, 다음과 같은 기본 키가 완성됩니다. (네임스페이스, indexID, 타임스탬프).
이 시스템은 수백 개의 애플리케이션에서 널리 사용 중입니다. 2024년 12월에는 이미 2PiB 이상의 데이터와 초당 수백만 행의 수집 속도를 달성했습니다. 하지만 여기에는 보존 정책이라는 치명적인 결함이 있었습니다.
Cloudflare는 Time-to-Live(TTL) 기능을 네이티브로 도입하기 전부터 여러 해 동안 ClickHouse를 사용해 왔습니다. 따라서 우리는 파티셔닝을 기반으로 하는 자체 보존 시스템을 구축했습니다. 레디-분석 테이블은 요일별로 분할되었고, 우리의 보존 작업은 31일 이상 된 파티션을 삭제했을 뿐입니다.
이 "만능" 31일 보존이 주요 한계였습니다. 법적 또는 계약 의무로 인해 데이터를 수년간 저장해야 하는 팀도 있었지만, 며칠만 필요한 팀도 있었습니다. 이러한 제약으로 인해 이러한 사용 사례에서는 레디 분석을 사용할 수 없었으며 훨씬 더 복잡한 온보딩 프로세스가 있는 기존 설정을 선택해야 했습니다.
네임스페이스별로 보존할 수 있는 새로운 시스템이 필요했습니다.
Cloudflare에서는 두 가지 주요 접근 방식을 고려했습니다.
네임스페이스별 테이블: 이는 당연히 보존 문제를 해결하지만, 온디맨드 수천 개의 테이블을 관리하려면 상당한 신규 자동화가 필요합니다.
새로운 파티셔닝 키: 파티셔닝 키를 (day) 에서 (namespace, day)로 변경할 수 있습니다.
우리는 두 번째 옵션을 선택했습니다. 이를 통해 기존 유지 시스템으로 파티션을 계속 관리할 수 있지만, 이제는 네임스페이스별로 세분화할 수 있습니다.
이렇게 하면 테이블의 총 데이터 부분 수가 증가할 것이라는 것을 알고 있었지만, 모든 쿼리는 특정 네임스페이스로 필터링되므로 단일 쿼리에서 읽는 부분 개수는 변경되지 않아야 합니다. 따라서 성능에는 영향이 없을 것으로 생각했습니다.
파티셔닝을 변경하여 단일 네임스페이스의 데이터를 저렴하게 삭제할 수 있었던 방법을 보여줍니다
이 새로운 시스템을 통해 정교한 스토리지 관리 계층도 구축할 수 있었습니다. 최대-최소 공정성 알고리즘을 사용하여 목표 디스크 사용률(예: 90%)을 설정할 수 있었고, 사용 가능한 공간을 자동으로 "공유"할 수 있습니다. 네임스페이스가 공정한 점유율보다 적게 사용하면 사용하지 않은 용량이 더 필요한 곳에 할당됩니다. 따라서 90%의 활용도로 자신 있게 클러스터를 실행할 수 있었습니다.
Cloudflare는 2025년 1월에 마이그레이션을 시작했습니다. 저희는 ClickHouse의 Merge 테이블 기능을 사용하여 기존 테이블과 새 테이블을 결합함으로써 기존 데이터가 노후화되는 동안 새 파티션을 나눈 테이블에 새 데이터를 모두 기록했습니다.
두 달 후인 2025년 3월 말에, 청구팀에서는 일일 집계 작업이 둔화되고 있다고 보고했습니다. 이러한 작업은 시간이 중요합니다. 처리하지 않으면 청구서가 출력되지 않습니다. 작업은 점점 더 느려지고 있었고, 기한이 임박했습니다.
조사를 했지만 일반적인 용의자를 비난하지 않았습니다. I/O는 정상이었습니다. 메모리는 정상이었습니다. 개별 쿼리에 대한 메트릭을 보면 이전보다 더 많은 데이터나 더 많은 부분을 읽지 않았던 것으로 나타났습니다. 처음에는 우리의 가정이 맞는 것 같았지만, 시스템은 점점 더 굳어가고 있었습니다.
이론을 갖기까지 며칠이 걸렸습니다. 마지막으로 클러스터의 총 부품 수 에 대한 쿼리 지속 시간을 그래프로 그렸습니다. 이러한 상관관계는 부인할 수 없습니다.
준비되어 있는 Analytics ClickHouse 클러스터에 대한 평균 SELECT 쿼리 Duration으로, 점진적인 성능 저하를 보여줍니다.
새로운 (네임스페이스, 데이) 파티셔닝 방식에 따른 테이블 Replica당 총 데이터 파트 수의 선형적인 증가율.
하지만 왜 그랬을까요? 추가 부분을 읽을 생각이 없다면 왜 그 자체만으로도 우리의 속도가 느려질까요?
저희는 ClickHouse의 내장 trace_log 를 사용하여 플레임 그래프를 생성했습니다. 이는 실행 중인 ClickHouse 서버의 트레이스를 기록하는 기본 제공 테이블입니다. 여기에는 어떤 코드가 실행 중인지에 대한 추적도 포함되어 있을 뿐만 아니라 이를 특정 사용자, 쿼리 ID 및 기타 메타데이터와 연결하기도 합니다. 즉, 필요한 경우 아주 정확한 이벤트 세트로 필터링할 수 있습니다. 저희의 경우, 리프 SELECT 쿼리를 구체적으로 살펴보고자 했습니다. 이 작업은 이 표에 나와 있는 메타데이터 덕분에 쉬웠습니다.
첫 번째 CPU 기반 플레임 그래프는 쿼리 계획에 엄청난 시간이 소요되고 있다는 우리의 의심을 빠르게 확인시켜 주었습니다. 이 단계는 ClickHouse가 읽을 부분을 결정하는 실행 전 입니다.
리프 쿼리 CPU 시간의 45%가 파티션 ID를 기반으로 파트 벡터를 필터링하는 데 사용되고 있음을 보여주는 Flame 그래프
플레임 그래프는 명확했습니다. 샘플링된 CPU 시간의 45%가 filterPartsByPartition이라는 단일 함수에서 사용되고 있었습니다.
해결을 위한 첫 번째 시도는 이 정확한 코드 경로에 대한 작은 패치였습니다. 플래너가 휴리스틱을 평가하여 부분을 정리하는데, 우리는 테이블에서 최적의 순서로 평가되지 않는다고 생각했습니다. Cloudflare의 패치로 인해 순서가 바뀌면서 소폭 5%의 개선이 있었습니다. 우리는 올바른 길을 가고 있었지만, 진짜 문제를 놓치고 있었습니다.
우리는 활성 스레드만 샘플링하는 "CPU" 트레이스를 생성하고 있었습니다. 비활성 상태이거나 대기 중인 스레드를 포함한 모든 스레드를 샘플링하는 "실제" 트레이스로 전환했습니다. 새로운 Flame 그래프가 공개되었습니다.
리프 쿼리 지속 시간의 절반 이상이 활성 파트 목록을 보호하는 뮤텍스트를 기다리는 데 소요된다는 것을 보여주는 Flame 그래프
문제는 CPU 위주의 작업이 아니었습니다. 바로 대규모 잠금 경합이었습니다. 쿼리 지속 시간의 절반 이상이 테이블의 부분 목록을 보호하는 단일 뮤텍스(MergeTreeData)를 획득하기 위해 대기 하는 데 사용되었습니다. 쿼리를 계획하려면 모든 스레드가 다음을 거쳐야 했습니다.
이 뮤텍스에 대한 배타적 잠금 을 획득합니다.
표에 있는 모든 부품 목록의 전체 사본을 만드세요.
잠금을 해제합니다.
해당 목록을 관련 있는 부분으로 필터링합니다.
수만 개의 부분과 수백 개의 동시 쿼리를 통해 모든 작업이 하나의 파일 줄에 서 있었습니다.
이러한 인사이트는 이러한 핫스팟을 완화하기 위한 일련의 최적화를 계획하는 데 도움이 되었습니다. ClickHouse의 모든 패치와 마찬가지로 우리는 일반화하려고 노력하며 결국 업스트림 코드베이스에 기여합니다. 따라서 포크를 더 쉽게 유지할 수 있으며, 커뮤니티에서도 Cloudflare가 적용할 수 있는 이점을 누릴 수 있습니다!
쿼리 플래너는 부품 목록을 수정하지 않습니다. 그냥 읽습니다. 이 회사는 독점 잠금을 사용하는 업무가 없었습니다.
수정: 대신 공유 잠금 (std::shared_lock)을 획득하도록 코드를 수정했습니다. 이를 통해 모든 쿼리 플래너가 중요 섹션에 동시에 진입할 수 있었습니다.
결과: 쿼리 지속 시간이 즉각적으로 엄청나게 줄어듭니다. 잠금 경합이 사라졌습니다.
공유 잠금 최적화(최적화 1)가 평균 SELECT 쿼리 지속 시간에 미치는 즉각적인 영향은 잠금 경합의 해결 방법을 보여줍니다.
성능이 크게 개선되었지만, 여전히 기준선으로는 돌아가지 않았습니다. 다시 트레이스 로그로 돌아가 '실제' 플레임 그래프를 만들었습니다.
리프 쿼리 지속 시간의 4분의 1이 모든 부분의 벡터를 복사하는 데 소비되고, 또 다른 4분의 1은 벡터를 필터링하는 데 소비됨(다시 복사)을 보여주는 Flame 그래프.
새로운 프레임 그래프는 병목 현상이 단순히 이동한 것으로 나타났습니다. 이제는 공유 자물쇠를 사용하더라도 엄청난 양의 부품을 복제하는 데 시간이 소모되고 있었습니다. 벡터를 복사하는 것은 직관적으로 비용이 적게 드는 것처럼 보이지만, 여기에는 수만 개의 요소가 있고 초당 수백 번 수행하면 비용이 더 많이 듭니다.
수정 사항: 복사를 완전히 미루었습니다. Cloudflare는 부품 목록의 "공유 사본"을 생성했습니다. 읽기 전용 작업(예: 쿼리 계획)은 이 사본에서 읽기만 합니다. 부품 세트를 수정하는 모든 작업(새 삽입과 같은)은 캐시를 재생성합니다. 이제 플래너는 실제로 필요한 부품의 필터링된 목록만 복사합니다.
결과: 또 다른 중요한 성능 향상.
벡터 카피 최적화 도입 후 추가적인 성능 개선(최적화 2).
이러한 엄청난 비용 절감을 내부적으로 확인한 후, Cloudflare는 이러한 변경 사항을 커뮤니티에도 적용하기로 결정했습니다. ClickHouse Inc.의 유지 관리자와 함께 약간의 설계 반복을 거쳐, 변경 사항을 PR #85535에 병합했습니다. 이들은 ClickHouse 버전 25.11부터 사용할 수 있었습니다.
아직 끝난 것이 아닙니다. 부품 수가 늘어날수록 성능은 여전히 훨씬 더 느리게 저하됩니다. 부품 수와의 상관관계는 여전히 있었습니다. 몇 달 후 다시 이 그래프를 보면, 새로운 프레임 그래프(그림 3과 동일하게 표시됨)에는 필터링 코드 경로(우리가 먼저 해결하려고 했던 경로)에 소요되는 시간이 표시됩니다. 이 코드는 모든 부분에 대해 선형 스캔 을 수행하여 각 부분에 대해 조건자를 평가합니다. 몇 달에 걸쳐 최적화 전의 지속 시간을 선택할 수 있었습니다.
하지만 이 부분 목록은 파티셔닝 키 기준으로 정렬되어 있습니다. 파티션 키의 첫 번째 열은 "테넌트"를 식별하기 때문에 대부분의 쿼리가 필터링되는 네임스페이스입니다. 이를 어떻게 활용할 수 있을까요?
수정: 파티션 ID의 네임스페이스 부분을 기반으로 바이너리 검색을 구현했습니다. 이 방법은 벡터가 정렬되어 있어 항목을 실제로 보지 않고도 많은 항목을 필터링할 수 있기 때문에 효과가 있습니다. 네임스페이스 가 해당 정렬 키의 첫 번째 부분이므로 이 방법은 특히 효과적입니다. 이 바이너리 검색의 첫 번째 단계 후에는 검사해야 하는 부분의 범위가 훨씬 작아졌으며, 각 항목에 대해서는 여전히 이전과 동일한 논리를 적용하여 다른 조건에 따라 부분을 제외하면서 각 항목을 검토합니다.
결과: 2026년 3월에 이 패치를 배포한 후 쿼리 지속 시간이 50% 감소했습니다(그림 8 참조). 더 중요한 것은 이렇게 하면 쿼리 지속 시간과 부분 수의 상관관계가 결국 깨졌다는 점입니다. 안타깝게도 이 솔루션은 임의의 쿼리 조건(예: (5,10)의 네임스페이스와 같은 조건). 저희는 부분 필터링을 포함하기 위해 쿼리 조건 캐시를 확장하는 것과 같은 보다 일반적인 접근 방식을 찾고 있습니다.
부품 프루닝을 위한 이진 검색 구현 후 대기 시간 지속 감소(최적화 3).
이러한 최적화로 대금 청구 시스템으로 즉각적인 위기 상황이 해결되었습니다. 하지만 이 여정을 통해 파티셔닝 선택에 따르는 명확하지 않은 큰 비용이 노출되었습니다.
다른 문제가 남아 있습니다. 이 블로그 게시물에서는 부품 수를 늘리면 선택한 기간에서 발생한 문제에 대해서만 설명했지만, 이는 ClickHouse의 모든 부품에 대한 메타데이터를 추적하는 ZoomKeeper에도 문제를 발생시켰습니다. 언젠가 우리도 100기가바이트의 ZoomKeeper 클러스터에 대해 이야기할 날이 올 것입니다.
상당한 여유 공간은 확보했지만, 근본적인 질문은 여전히 남아 있습니다. 이 파티셔닝 계획이 장기적으로 올바른 선택이었을까요? 아니면 결국 큰 고통을 겪고 다른 아키텍처로 옮겨가야 할까요? 일단 패치는 현재 상태를 유지하고 있지만, 이번 경험은 잘 계획된 변경조차도 잘못된 가정의 희생양이 될 수 있음을 보여주는 분명한 예였습니다.
대금 청구 팀에서 이 문제를 처음 보고했을 때 복제본당 부품 개수는 30,000개였습니다. 부품 비율은 멈추지 않았고, 1년 후 복제본당 160,000개의 부품을 기록했지만, 여기에서 수행한 최적화 덕분에 쿼리 지속 시간은 안정적으로 유지되었습니다.
Cloudflare에서는 복잡한 엔지니어링 문제를 대규모로 해결합니다. 여기에서 설명한 디버깅 및 최적화가 이러한 문제와 관련이 있다고 생각하신다면, Cloudflare에서 채용하고 있는 채용 중인 직무를 확인해 보세요.