- 프라이머리 키(primary key) 선택 - 기본 스키마는 특정 액세스 패턴에 최적화된
ORDER BY를 사용합니다. 대부분의 경우 실제 액세스 패턴은 여기에 맞지 않습니다. - 구조 추출 - 기존 컬럼(예:
Body컬럼)에서 새 컬럼을 추출해야 할 수 있습니다. 이는 materialized 컬럼(더 복잡한 경우에는 materialized view)을 사용해 수행할 수 있습니다. 이를 위해서는 스키마 변경이 필요합니다. - 맵 최적화 - 기본 스키마는 속성을 저장하기 위해 맵(Map) 타입을 사용합니다. 이러한 컬럼을 사용하면 임의의 메타데이터를 저장할 수 있습니다. 이는 중요한 기능입니다. 이벤트 메타데이터는 사전에 정의되지 않는 경우가 많아 ClickHouse와 같은 강타입 데이터베이스에는 다른 방식으로 저장하기 어렵기 때문입니다. 다만 맵 키와 해당 값에 대한 액세스는 일반 컬럼에 대한 액세스만큼 효율적이지 않습니다. 이를 해결하기 위해 스키마를 수정하여 가장 자주 액세스하는 맵 키를 최상위 컬럼으로 올립니다. 자세한 내용은 “SQL로 구조 추출”을 참조하십시오. 이를 위해서는 스키마 변경이 필요합니다.
- 맵 키 액세스 단순화 - 맵의 키에 액세스하려면 더 장황한 구문이 필요합니다. 이는 Aliases로 완화할 수 있습니다. 쿼리를 단순화하는 방법은 “Aliases 사용”을 참조하십시오.
- 보조 인덱스 - 기본 스키마는 맵에 대한 액세스 속도를 높이고 텍스트 쿼리를 가속하기 위해 보조 인덱스를 사용합니다. 일반적으로 이러한 인덱스는 꼭 필요하지 않으며 추가 디스크 공간을 차지합니다. 사용할 수는 있지만 실제로 필요한지 반드시 테스트해야 합니다. “보조 / 데이터 스키핑 인덱스”를 참조하십시오.
- 코덱 사용 - 예상 데이터 특성을 잘 이해하고, 압축 개선 효과에 대한 근거가 있다면 컬럼별 코덱을 사용자 지정할 수 있습니다.
SQL로 구조 추출하기
- 문자열 blob에서 컬럼 추출. 이렇게 추출한 컬럼에 쿼리하면 쿼리 시점에 문자열 연산을 사용하는 것보다 더 빠릅니다.
- 맵에서 키 추출. 기본 스키마는 임의의 속성을 Map 타입의 컬럼에 저장합니다. 이 타입은 스키마를 미리 고정하지 않아도 되는 특성을 제공하므로, 로그와 트레이스를 정의할 때 속성 컬럼을 사전에 정의할 필요가 없다는 장점이 있습니다. 특히 Kubernetes에서 로그를 수집하면서 나중에 검색할 수 있도록 파드 레이블을 유지하려는 경우에는, 이를 사전에 정의하는 것이 사실상 불가능한 경우가 많습니다. 맵 키와 해당 값을 조회하는 작업은 일반적인 ClickHouse 컬럼에 쿼리하는 것보다 더 느립니다. 따라서 맵의 키를 루트 테이블 컬럼으로 추출하는 것이 바람직한 경우가 많습니다.
Body 컬럼에 String으로 저장됩니다. 또한 사용자가 collector에서 json_parser를 활성화한 경우 LogAttributes 컬럼에 Map(String, String)으로도 저장될 수 있습니다.
LogAttributes를 사용할 수 있다고 가정하면, 사이트에서 어떤 URL 경로가 가장 많은 POST 요청을 받는지 집계하는 쿼리는 다음과 같습니다:
LogAttributes['request_path']와 같이 작성하며, URL에서 쿼리 매개변수를 제거하려면 path 함수를 사용합니다.
사용자가 collector에서 JSON 파싱을 활성화하지 않은 경우 LogAttributes는 비어 있으므로, String Body에서 컬럼을 추출하려면 JSON 함수를 사용해야 합니다.
파싱은 ClickHouse에서 수행하는 것을 권장합니다일반적으로 구조화된 로그의 JSON 파싱은 ClickHouse에서 수행하는 것을 권장합니다. ClickHouse가 가장 빠른 JSON 파싱 구현을 제공한다고 확신합니다. 다만 로그를 다른 소스로 전송해야 하거나, 이 로직을 SQL에 두고 싶지 않을 수도 있다는 점도 알고 있습니다.
extractAllGroupsVertical 함수를 통해 정규식을 사용해야 합니다.
딕셔너리 사용을 고려하십시오위 쿼리는 정규식 딕셔너리를 활용하도록 최적화할 수 있습니다. 자세한 내용은 Dictionaries 사용하기를 참조하십시오.
처리는 OTel collector와 ClickHouse 중 어디에서 해야 합니까?여기에 설명된 대로 OTel collector의 processor와 연산자를 사용해 처리할 수도 있습니다. 대부분의 경우 ClickHouse는 collector의 processor보다 리소스를 훨씬 적게 사용하며 더 빠릅니다. 모든 이벤트 처리를 SQL에서 수행할 때의 가장 큰 단점은 솔루션이 ClickHouse에 종속된다는 점입니다. 예를 들어, 처리된 로그를 OTel collector에서 S3와 같은 다른 대상으로 보내고자 할 수 있습니다.
materialized 컬럼
오버헤드materialized 컬럼은 삽입 시점에 값이 디스크의 새 컬럼으로 추출되므로 추가 저장소 오버헤드가 발생합니다.
LogAttributes 컬럼으로 추출한 로그의 스키마 예시입니다:
Body에서 JSON 함수를 사용해 값을 추출할 때 해당하는 스키마(schema)는 여기에서 확인할 수 있습니다.
세 개의 materialized 컬럼은 요청 페이지, 요청 유형, 그리고 리퍼러의 도메인을 추출합니다. 이 컬럼들은 맵 키에 접근해 해당 값에 함수를 적용합니다. 그 결과 이어지는 쿼리는 훨씬 더 빨라집니다:
기본적으로 materialized 컬럼은
SELECT * 결과에 포함되지 않습니다. 이는 SELECT *의 결과를 항상 INSERT를 사용해 다시 테이블에 삽입할 수 있다는 불변 조건을 유지하기 위한 것입니다. 이 동작은 asterisk_include_materialized_columns=1을 설정하면 비활성화할 수 있으며, Grafana에서도 활성화할 수 있습니다(데이터 소스 구성의 Additional Settings -> Custom Settings 참조).materialized views
실시간 업데이트ClickHouse의 materialized views는 기반이 되는 테이블로 데이터가 유입되면 실시간으로 업데이트되며, 지속적으로 갱신되는 인덱스처럼 동작합니다. 반면 다른 데이터베이스의 materialized views는 일반적으로 갱신이 필요한 정적 쿼리 스냅샷입니다(ClickHouse의 갱신 가능 구체화 뷰와 유사함).
SELECT statement를 사용할 수 있다고 보면 됩니다.
쿼리는 테이블에 삽입되는 행(소스 테이블)에 대해 실행되는 트리거일 뿐이며, 그 결과는 새 테이블(대상 테이블)로 전달된다는 점을 기억해야 합니다.
데이터가 두 번 저장되지 않도록(소스 테이블과 대상 테이블 모두에) 원래 스키마를 유지한 채 소스 테이블의 테이블 엔진을 Null table engine으로 변경할 수 있습니다. OTel collector는 계속 이 테이블로 데이터를 전송합니다. 예를 들어 로그의 경우 otel_logs 테이블은 다음과 같이 됩니다:
/dev/null이라고 생각하면 이해하기 쉽습니다. 이 테이블은 데이터를 전혀 저장하지 않지만, attached 상태인 materialized view는 데이터가 폐기되기 전에 삽입된 행에 대해 계속 실행됩니다.
다음 쿼리를 살펴보겠습니다. 이 쿼리는 행을 유지하려는 포맷으로 변환하고, LogAttributes에서 모든 컬럼을 추출하며(이는 collector가 json_parser 연산자를 사용해 설정했다고 가정합니다), SeverityText와 SeverityNumber를 설정합니다(몇 가지 단순한 조건과 이 컬럼들의 정의를 기반으로 합니다). 여기서는 실제로 값이 채워질 것으로 예상되는 컬럼만 선택하고, TraceId, SpanId, TraceFlags 같은 컬럼은 제외합니다.
Body 컬럼도 추출합니다. 이는 나중에 SQL로 추출되지 않는 추가 속성이 생길 경우를 대비한 것입니다. 이 컬럼은 ClickHouse에서 압축 효율이 높고 거의 조회되지 않으므로 쿼리 성능에 영향을 미치지 않습니다. 마지막으로, cast를 사용하여 Timestamp를 DateTime으로 변환합니다(공간 절약을 위해 “Optimizing Types” 참조).
이 결과를 저장할 테이블이 필요합니다. 아래의 대상 테이블(target table)은 위의 쿼리에 대응합니다:
스키마가 크게 바뀐 점에 주목하십시오. 실제 환경에서는 보존해야 할 Trace 컬럼이 있을 가능성이 높고,
ResourceAttributes 컬럼도 마찬가지입니다(이 컬럼에는 보통 Kubernetes 메타데이터가 포함됩니다). Grafana는 Trace 컬럼을 활용해 로그와 트레이스 사이를 연결하는 기능을 제공할 수 있습니다. 자세한 내용은 “Grafana 사용”를 참조하십시오.otel_logs 테이블에 대해 위의 SELECT를 실행하고 그 결과를 otel_logs_v2로 전달하는 materialized view otel_logs_mv를 생성합니다.
otel_logs_v2에 표시됩니다. typed JSON extract 함수를 사용한다는 점에 유의하십시오.
Body 컬럼에서 컬럼을 추출하는 동등한 materialized view가 나와 있습니다:
타입에 주의하세요
LogAttributes 맵을 사용할 때 그렇습니다. ClickHouse는 추출된 값을 대상 테이블의 타입으로 자동 형변환하는 경우가 많아 필요한 구문을 줄여 줍니다. 그러나 동일한 스키마(schema)를 사용하는 대상 테이블에 대해 뷰의 SELECT SQL 문을 INSERT INTO SQL 문과 함께 사용해 항상 테스트하는 것을 권장합니다. 이렇게 하면 타입이 올바르게 처리되는지 확인할 수 있습니다. 특히 다음 경우에 유의하십시오.
- 맵에 키가 없으면 빈 문자열이 반환됩니다. 숫자값의 경우 이를 적절한 값으로 매핑해야 합니다. 이는 조건식 예:
if(LogAttributes['status'] = ", 200, LogAttributes['status'])또는 기본값을 허용할 수 있다면 형변환 함수 예:toUInt8OrDefault(LogAttributes['status'] )로 처리할 수 있습니다. - 일부 타입은 항상 형변환되지 않습니다. 예를 들어 숫자의 문자열 표현은 enum 값으로 형변환되지 않습니다.
- JSON 추출 함수는 값을 찾지 못하면 해당 타입의 기본값을 반환합니다. 이 값이 적절한지 확인하십시오!
프라이머리(정렬) 키 선택하기
- 자주 사용하는 필터와 액세스 패턴에 맞는 컬럼을 선택하십시오. 관측성 조사 작업을 보통 특정 컬럼(예: 파드 이름)으로 필터링하는 것부터 시작한다면, 이 컬럼은
WHERE절에서 자주 사용됩니다. 사용 빈도가 낮은 컬럼보다 이러한 컬럼을 키에 포함하는 것을 우선하십시오. - 필터링 시 전체 행의 큰 비율을 제외할 수 있는 컬럼을 우선하십시오. 그러면 읽어야 하는 데이터 양을 줄일 수 있습니다. 서비스 이름과 상태 코드는 좋은 후보인 경우가 많습니다. 다만 상태 코드의 경우에는 대부분의 행을 제외하는 값으로 필터링할 때만 그렇습니다. 예를 들어 200번대 상태 코드로 필터링하면 대부분의 시스템에서 대다수 행이 일치하지만, 500 오류로 필터링하면 보통 작은 부분집합만 일치합니다.
- 테이블의 다른 컬럼과 높은 상관성을 가질 가능성이 큰 컬럼을 우선하십시오. 그러면 이러한 값들도 서로 인접하게 저장되어 압축이 개선됩니다.
- 정렬 키에 포함된 컬럼에 대한
GROUP BY및ORDER BY연산은 메모리를 더 효율적으로 사용할 수 있습니다.
정렬 키에 사용할 컬럼 부분집합을 정한 뒤에는, 이를 특정한 순서로 선언해야 합니다. 이 순서는 쿼리에서 후행 키 컬럼에 대한 필터링 효율과 테이블 데이터 파일의 압축률에 모두 큰 영향을 줄 수 있습니다. 일반적으로는 카디널리티가 낮은 것부터 높은 것 순으로 키를 정렬하는 것이 가장 좋습니다. 다만 정렬 키에서 뒤쪽에 오는 컬럼에 대한 필터링은 튜플 앞쪽에 오는 컬럼보다 효율이 떨어진다는 점도 함께 고려해야 합니다. 이러한 특성 사이에서 균형을 잡고 액세스 패턴을 고려하십시오. 무엇보다도 다양한 변형을 테스트하십시오. 정렬 키와 최적화 방법을 더 자세히 이해하려면 이 문서를 참고하십시오.
구조를 먼저 정하십시오로그를 구조화한 뒤에 정렬 키를 결정할 것을 권장합니다. 정렬 키에 속성 맵의 키나 JSON 추출 표현식을 사용하지 마십시오. 정렬 키는 테이블의 루트 컬럼으로 두십시오.
맵 사용하기
Map(String, String) 컬럼의 값에 접근하기 위해 맵 구문 map['key']을 사용하는 방법을 보여주었습니다. 중첩된 키에 접근할 때도 맵 표기법을 사용할 수 있으며, 이와 함께 이러한 컬럼을 필터링하거나 선택하기 위한 ClickHouse의 특화된 맵 함수도 제공됩니다.
예를 들어, 다음 쿼리는 mapKeys 함수와 이어서 groupArrayDistinctArray 함수(combinator)를 사용해 LogAttributes 컬럼에서 사용할 수 있는 모든 고유 키를 식별합니다.
점 사용 금지맵 컬럼 이름에는 점을 사용하지 않는 것이 좋으며, 이 사용 방식은 향후 더 이상 지원되지 않을 수 있습니다. 대신
_를 사용하십시오.별칭 사용
LogAttributes에 접근하는 ALIAS 컬럼 RemoteAddr가 있습니다. 이제 이 컬럼을 통해 LogAttributes['remote_addr'] 값을 조회할 수 있으므로 쿼리를 더 단순하게 작성할 수 있습니다. 즉,
ALTER TABLE 명령으로 ALIAS를 손쉽게 추가할 수 있습니다. 예를 들어, 이러한 컬럼은 즉시 사용할 수 있습니다.
기본적으로 별칭는 제외됩니다기본적으로
SELECT *는 ALIAS 컬럼을 제외합니다. 이 동작은 asterisk_include_alias_columns=1로 설정하면 비활성화할 수 있습니다.타입 최적화
코덱 사용
ZSTD 코덱은 로깅 및 트레이스 데이터셋에 매우 적합합니다. 기본값 1에서 압축 값을 높이면 압축률이 개선될 수 있습니다. 다만 값이 높을수록 데이터 삽입 시 CPU 오버헤드가 커지므로, 반드시 테스트해야 합니다. 일반적으로는 이 값을 높여도 큰 이점은 없습니다.
또한 타임스탬프는 압축 측면에서는 델타 인코딩의 이점을 얻을 수 있지만, 이 컬럼을 프라이머리/정렬 키로 사용하면 느린 쿼리 성능을 유발할 수 있는 것으로 확인되었습니다. 따라서 압축률과 쿼리 성능 간의 절충점을 평가할 것을 권장합니다.
딕셔너리 사용
조인 가속화딕셔너리를 사용해 조인을 가속화하려는 경우, 자세한 내용은 여기에서 확인할 수 있습니다.
삽입 시점 vs 쿼리 시점
- 삽입 시점 - 일반적으로 보강 값이 변경되지 않고, 딕셔너리를 채우는 데 사용할 수 있는 외부 소스에 해당 값이 있는 경우에 적합합니다. 이 경우 삽입 시점에 행을 보강하면 쿼리 시점에 딕셔너리를 조회할 필요가 없습니다. 다만 보강된 값이 컬럼으로 저장되므로 삽입 성능이 저하될 수 있고, 추가 저장소 오버헤드도 발생합니다.
- 쿼리 시점 - 딕셔너리의 값이 자주 변경된다면 쿼리 시점 조회가 더 적합한 경우가 많습니다. 이렇게 하면 매핑된 값이 바뀌더라도 컬럼을 업데이트하거나 데이터를 재작성할 필요가 없습니다. 이러한 유연성에는 쿼리 시점 조회 비용이 따른다는 대가가 있습니다. 이 비용은 일반적으로 많은 행에 대해 조회가 필요할 때 눈에 띄며, 예를 들어 filter 절에서 딕셔너리 조회를 사용하는 경우가 그렇습니다. 결과 보강, 즉
SELECT에서의 경우에는 이 오버헤드가 대체로 크지 않습니다.
IP 딕셔너리 사용하기
ip_trie 구조화된 딕셔너리를 사용해 구현할 수 있습니다.
DB-IP.com에서 CC BY 4.0 라이선스 약관에 따라 제공하는 공개 DB-IP city-level dataset을 사용합니다.
README를 보면 데이터가 다음과 같은 구조로 되어 있음을 확인할 수 있습니다:
URL() 테이블 엔진을 사용해 필드 이름이 있는 ClickHouse 테이블 객체를 만들고 전체 행 수를 확인하겠습니다:
ip_trie 딕셔너리에서는 IP 주소 범위를 CIDR 표기법으로 나타내야 하므로, ip_range_start와 ip_range_end를 변환해야 합니다.
각 범위의 CIDR은 다음 쿼리로 간단히 계산할 수 있습니다:
위 쿼리에는 많은 내용이 포함되어 있습니다. 자세히 알고 싶다면 이 훌륭한 설명을 읽어보십시오. 그렇지 않다면, 위 쿼리가 IP 범위의 CIDR을 계산한다고 이해하시면 됩니다.
ip_trie 딕셔너리 구조를 제공합니다. 다음 쿼리는 이 레이아웃을 사용하고 위 테이블을 소스로 하는 딕셔너리를 지정합니다.
주기적 갱신ClickHouse의 딕셔너리는 기반이 되는 테이블 데이터와 위에서 사용한 lifetime 절에 따라 주기적으로 갱신됩니다. DB-IP 데이터셋의 최신 변경 사항이 Geo IP 딕셔너리에 반영되도록 하려면, geoip_url 원격 테이블의 데이터를 변환 적용 후
geoip 테이블에 다시 삽입하면 됩니다.ip_trie 딕셔너리(편의상 이름도 ip_trie입니다)에 로드되었으므로, 이를 IP 지리 위치 확인에 사용할 수 있습니다. 이는 다음과 같이 dictGet() 함수를 사용해 수행할 수 있습니다:
RemoteAddress 컬럼이 포함되어 있습니다.
주기적으로 업데이트사용자는 새 데이터를 반영해 IP 보강용 딕셔너리가 주기적으로 업데이트되기를 원할 수 있습니다. 이는 딕셔너리의
LIFETIME 절을 사용해 구현할 수 있으며, 이렇게 하면 기반 테이블에서 딕셔너리를 주기적으로 다시 로드하게 됩니다. 기반 테이블을 업데이트하는 방법은 “갱신 가능 구체화 뷰”를 참조하십시오.정규식 딕셔너리 사용(user agent 파싱)
다음 Memory 테이블을 생성하십시오. 이 테이블에는 디바이스, 브라우저, 운영 체제를 파싱하기 위한 정규식이 저장됩니다.
url 테이블 함수를 사용해 다음과 같이 공개적으로 호스팅된 CSV 파일에서 데이터를 채울 수 있습니다:
otel_logs_v2의 스키마를 수정해야 합니다:
복잡한 구조를 위한 튜플이러한 user agent 컬럼에는 튜플을 사용한다는 점에 유의하십시오. 튜플은 계층 구조를 미리 알고 있는 복잡한 구조에 권장됩니다. 서브컬럼은 서로 다른 타입을 허용하면서도 일반 컬럼과 동일한 성능을 제공합니다(
Map 키와는 달리).추가 자료
쿼리 성능 가속화
집계를 위해 materialized view(증분) 사용
앞서
LogAttributes 맵에서 size 키를 추출하는 materialized view로 생성한 otel_logs_v2 테이블을 사용했다면 이 쿼리는 10배 더 빨라졌을 것입니다. 여기서는 설명을 위해 원시 데이터를 사용할 뿐이며, 이것이 일반적인 쿼리라면 앞서의 뷰를 사용하는 것을 권장합니다.bytes_per_hour 테이블이 비어 있고 아직 데이터가 전혀 수신되지 않았다고 가정하겠습니다. 이 materialized view는 otel_logs에 삽입된 데이터에 대해 위의 SELECT를 수행하며(이 작업은 구성된 크기의 block 단위로 수행됩니다), 그 결과를 bytes_per_hour로 보냅니다. 구문은 아래와 같습니다:
TO 절은 중요하며, 결과가 어디로 전송되는지, 즉 bytes_per_hour로 전송된다는 것을 나타냅니다.
OTel collector를 다시 시작하고 logs를 다시 보내면 bytes_per_hour 테이블이 위 쿼리 결과로 점진적으로 채워집니다. 완료되면 bytes_per_hour의 크기를 확인할 수 있으며, 시간당 1개의 행이 있어야 합니다:
otel_logs)에서 113개로 효과적으로 줄였습니다. 핵심은 새 logs가 otel_logs 테이블에 삽입되면 해당 시간에 대한 새 값이 bytes_per_hour로 전송되고, 그곳에서 백그라운드에서 비동기적으로 자동 머지된다는 점입니다. 즉, 시간당 하나의 행만 유지하므로 bytes_per_hour는 항상 작고 최신 상태를 유지합니다.
행 머지는 비동기적으로 수행되므로 사용자가 쿼리할 때는 시간당 둘 이상의 행이 있을 수 있습니다. 쿼리 시점에 아직 머지되지 않은 행까지 모두 머지되도록 보장하는 방법은 두 가지입니다.
- 테이블 이름에
FINAL수정자를 사용합니다(위의 count 쿼리에서 사용한 방법입니다). - 최종 테이블에서 사용하는 정렬 키, 즉 Timestamp를 기준으로 집계하고 메트릭을 합산합니다.
더 큰 데이터셋에서 더 복잡한 쿼리를 실행하면 이러한 절감 효과는 훨씬 더 커질 수 있습니다. 예시는 here에서 확인하십시오.
더 복잡한 예시
UniqueUsers 컬럼을 AggregateFunction 타입으로 정의하고, 부분 상태(uniq)의 함수 소스와 소스 컬럼의 타입(IPv4)을 지정합니다. SummingMergeTree와 마찬가지로 동일한 ORDER BY 키 값을 가진 행은 머지됩니다(위 예시에서는 Hour).
해당 materialized view는 앞서의 쿼리를 사용합니다:
State를 붙인 점에 유의하십시오. 이렇게 하면 최종 결과 대신 함수의 집계 상태가 반환됩니다. 여기에는 이 부분 상태를 다른 상태와 머지하는 데 필요한 추가 정보가 포함됩니다.
collector를 다시 시작해 데이터를 다시 로드한 후에는 unique_visitors_per_hour 테이블에서 113개의 행을 확인할 수 있습니다.
FINAL 대신 GROUP BY를 사용한다는 점에 유의하십시오.
빠른 조회를 위한 Materialized views (incremental) 사용
ServiceName, SpanName, Timestamp로 필터링하는 데 최적화되어 있습니다. 트레이싱에서는 특정 TraceId로 lookup을 수행하고 해당 trace에 속한 스팬을 가져올 수 있어야 합니다. 이는 순서 지정 키에 포함되어 있지만 끝부분에 위치하므로 필터링 효율이 높지 않으며, 단일 trace를 조회할 때도 상당한 양의 데이터를 스캔해야 할 가능성이 큽니다.
이 챌린지를 해결하기 위해 OTel collector는 materialized view와 관련 테이블도 함께 설치합니다. 테이블과 뷰는 아래와 같습니다:
otel_traces_trace_id_ts 테이블에 각 트레이스의 최소 및 최대 타임스탬프가 저장되도록 합니다. 이 테이블은 TraceId 기준으로 정렬되어 있으므로 이러한 타임스탬프를 효율적으로 조회할 수 있습니다. 이렇게 얻은 타임스탬프 범위는 다시 기본 otel_traces 테이블을 쿼리할 때 사용할 수 있습니다. 좀 더 구체적으로 말하면, Grafana는 id로 트레이스를 조회할 때 다음 쿼리를 사용합니다.
ae9226c78d1d360601e6383928e4d22d의 최소 및 최대 타임스탬프를 구한 뒤, 이를 사용해 관련 스팬이 있는 주 otel_traces를 필터링합니다.
이와 같은 접근 방식은 유사한 액세스 패턴에도 적용할 수 있습니다. 비슷한 예시는 데이터 모델링의 여기에서 살펴봅니다.
프로젝션 사용하기
ORDER BY 절을 지정할 수 있습니다.
이전 섹션에서는 ClickHouse에서 materialized view를 사용해 집계를 사전 계산하고, 행을 변환하며, 다양한 액세스 패턴에 맞게 관측성 쿼리를 최적화하는 방법을 살펴보았습니다.
앞서 본 예시에서는 materialized view가, 삽입을 받는 원본 테이블과는 다른 정렬 키를 가진 대상 테이블로 행을 보내 트레이스 ID 조회를 최적화했습니다.
프로젝션도 같은 문제를 해결하는 데 사용할 수 있으며, 프라이머리 키에 포함되지 않은 컬럼에 대한 쿼리도 최적화할 수 있습니다.
이론적으로는 이 기능을 사용해 하나의 테이블에 여러 정렬 키를 제공할 수 있지만, 분명한 단점이 하나 있습니다. 바로 데이터 중복입니다. 구체적으로는 데이터가 기본 프라이머리 키 순서로 한 번 기록되고, 각 프로젝션에 지정된 순서로도 추가로 기록되어야 합니다. 이로 인해 삽입 속도가 느려지고 디스크 공간도 더 많이 사용하게 됩니다.
프로젝션과 materialized view 비교프로젝션은 materialized view와 비슷한 기능을 많이 제공하지만, 대개는 후자가 더 적합하므로 제한적으로 사용하는 것이 좋습니다. 어떤 단점이 있는지, 그리고 언제 적절한지 이해해야 합니다. 예를 들어 프로젝션을 사용해 집계를 사전 계산할 수는 있지만, 이 용도에는 Materialized views를 사용할 것을 권장합니다.
otel_logs_v2 테이블에서 500 오류 코드를 기준으로 필터링합니다. 사용자가 오류 코드로 필터링하려는 경우가 많기 때문에, 이는 로깅에서 흔한 액세스 패턴일 가능성이 높습니다:
성능 측정을 위해 Null 사용여기서는
FORMAT Null을 사용하므로 결과를 출력하지 않습니다. 이렇게 하면 모든 결과를 읽지만 반환하지 않으므로, LIMIT 때문에 쿼리가 조기에 종료되는 것을 방지할 수 있습니다. 이는 전체 1,000만 행을 스캔하는 데 걸리는 시간을 보여주기 위한 것입니다.(ServiceName, Timestamp)에서는 선형 스캔이 필요합니다. 위 쿼리의 성능을 높이기 위해 정렬 키 끝에 Status를 추가할 수도 있지만, 프로젝션을 추가할 수도 있습니다.
ALTER를 통해 생성된 경우 MATERIALIZE PROJECTION 명령을 실행하면 생성이 비동기적으로 진행된다는 것입니다. 다음 쿼리로 이 작업의 진행 상태를 확인하고, is_done=1이 될 때까지 기다리십시오.
SELECT *를 사용하면 모든 컬럼이 저장됩니다. 이렇게 하면 더 많은 쿼리(임의의 컬럼 부분집합을 사용하는 경우)가 프로젝션의 이점을 활용할 수 있지만, 그만큼 추가 저장 공간이 필요합니다. 디스크 공간과 압축을 측정하는 방법은 “테이블 크기 및 압축 측정”을 참조하십시오.
보조/데이터 스킵 인덱스
전문 검색을 위한 텍스트 인덱스
tokenizer 인수가 필요합니다. 필요에 따라 토큰화 전에 입력 문자열을 변환하는 전처리기 함수를 지정할 수도 있습니다.
인덱스 검색에 권장되는 함수는 hasAnyTokens와 hasAllTokens입니다.
텍스트 인덱스가 있으면 일부 기존 문자열 검색 함수도 자동으로 최적화됩니다.
자세한 내용과 지원되는 함수는 여기 및 여기를 참조하십시오.
아래 예시에서는 구조화된 로그 데이터셋을 사용합니다.
hasAnyTokens는 text index 없이도 사용할 수 있지만, 이 경우 Body 컬럼을 전체 스캔하므로 쿼리 성능이 느려집니다:
텍스트 인덱스 추가
ALTER TABLE을 사용해 추가할 수 있습니다:
전처리기 사용
msg, id, ctx, attr 등)을 포함하는 JSON 형식의 문자열이 들어 있습니다.
msg field만 검색한다고 가정합니다.
전체 JSON 문자열에 인덱스를 생성하는 대신, 토큰화 전에 msg 값만 추출하도록 전처리기를 정의할 수 있습니다.
예시:
- 토큰화 및 인덱싱되는 텍스트 양을 줄입니다.
- 인덱스 크기를 줄입니다.
- 거짓 양성(false positive) 발생 가능성을 낮춥니다.
- 쿼리 성능을 향상시킵니다.