lock-contention

2 개의 포스트

cloudflare4분 읽기큐레이션 요약

청구 파이프라인이 갑자기 느려졌다. 원인은 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를 모두 활용하는 것이 중요하다.

원문 읽기(새 탭에서 열림)
datadog원문

.NET 지속적 프로파일러: 예외 및 락 경합 (새 탭에서 열림)

Datadog의 .NET 컨티뉴어스 프로파일러는 애플리케이션의 성능에 보이지 않는 영향을 미치는 예외(Exception)와 락 경합(Lock Contention)을 정밀하게 추적합니다. 단순히 발생 횟수를 세는 것을 넘어, 로우 레벨 CLR 콜백과 메타데이터 분석을 통해 예외 메시지와 락 유지 시간 등 구체적인 컨텍스트를 제공하여 성능 병목의 원인을 정확히 파악하도록 돕습니다. 이를 통해 개발자는 무분별한 예외 발생으로 인한 CPU 낭비와 병렬 알고리즘의 지연 시간을 효과적으로 최적화할 수 있습니다. **예외 발생 데이터 수집 및 타입 분석** * 예외가 던져질 때 CLR은 `ICorProfilerCallback::ExceptionThrown`을 호출하며, 프로파일러는 이를 통해 예외 객체의 `ObjectID`를 획득합니다. * `ICorProfilerInfo::GetClassFromObject`를 사용하여 예외 인스턴스의 `ClassID`를 구하고, 이를 `FrameStore`와 연동하여 클래스 이름을 확인하고 캐싱합니다. * 예외 처리는 `catch` 및 `finally` 블록의 실행을 보장하기 위해 런타임에서 많은 CPU 사이클을 소모하므로, 발생 지점과 타입별 통계를 파악하는 것이 중요합니다. **System.Exception 메시지 추출을 위한 메타데이터 탐색** * 예외의 세부 내용을 파악하기 위해 `_message` 필드의 값을 읽어야 하며, 이를 위해서는 해당 필드의 정확한 메모리 오프셋을 알아야 합니다. * .NET 버전(Framework 또는 Core)에 따라 `mscorlib`나 `System.Private.CoreLib` 모듈을 식별한 후, `IMetaDataImport`를 통해 `System.Exception` 클래스의 메타데이터 토큰을 찾습니다. * `ICorProfilerInfo2::GetClassLayout`을 호출하여 클래스의 필드 레이아웃 정보를 얻고, `_message` 필드의 `COR_FIELD_OFFSET`을 계산하여 문자열 버퍼의 위치를 특정합니다. **락 경합의 지속 시간 및 원인 식별** * .NET 런타임은 `Monitor.Enter` 등을 사용하는 락 패턴에 대해 `ContentionStart`와 `ContentionStop` 이벤트를 발생시킵니다. * .NET Framework와 같이 이벤트 자체에서 지속 시간을 제공하지 않는 경우, 프로파일러가 직접 스레드별로 시작 시점의 타임스탬프를 관리하여 대기 시간을 계산합니다. * .NET 8부터는 `ContentionStart` 이벤트에 락의 `ObjectID`와 현재 락을 점유 중인 스레드 정보가 포함되어, 어떤 스레드가 다른 스레드를 대기하게 만드는지 구체적으로 가시화할 수 있습니다. **EventPipe를 통한 효율적인 실시간 이벤트 모니터링** * .NET 5 이상에서는 `ICorProfilerCallback10::EventPipeEventDelivered` 메서드를 통해 CLR 이벤트를 동기적으로 수신할 수 있습니다. * `ClrEventParser` 클래스는 이벤트 페이로드에서 ID와 키워드를 기반으로 필요한 필드만 추출하여 성능 부하를 최소화합니다. * 이러한 메커니즘을 통해 애플리케이션 실행에 거의 영향을 주지 않으면서도(Negligible impact) 상세한 프로파일링 데이터를 확보합니다. 성능 최적화를 위해서는 `Parse` 대신 `TryParse`를 사용하여 불필요한 예외 비용을 줄이고, 프로파일러가 제공하는 락 지속 시간 데이터를 바탕으로 과도한 동기화 구문을 개선하는 실용적인 접근이 필요합니다.