Stack Overflow 데이터셋
s3://datasets-documentation/stackoverflow/parquet/에서 Parquet 포맷으로 제공됩니다:
표시된 기본 키와 관계는 제약 조건으로 강제되지 않으며(Parquet는 테이블 포맷이 아니라 파일 포맷임), 데이터가 어떻게 서로 연결되는지와 어떤 고유 키를 가지는지만 보여줍니다.
Stack Overflow 데이터셋에는 서로 연관된 여러 테이블이 포함되어 있습니다. 데이터 모델링 작업을 수행할 때는 먼저 프라이머리 테이블을 로드하는 데 집중하는 것이 좋습니다. 이 테이블이 반드시 가장 큰 테이블일 필요는 없으며, 오히려 대부분의 분석 쿼리가 수행될 것으로 예상되는 테이블입니다. 이렇게 하면 ClickHouse의 주요 개념과 타입에 익숙해질 수 있으며, 특히 주로 OLTP 환경을 사용해 온 경우 더욱 중요합니다. 이후 추가 테이블을 더해 ClickHouse 기능을 최대한 활용하고 최적의 성능을 얻으려면 이 테이블을 다시 모델링해야 할 수도 있습니다. 위 스키마는 이 가이드의 목적에 맞게 의도적으로 최적화하지 않았습니다.
초기 스키마 설정
posts 테이블은 대부분의 분석 쿼리의 대상이 되므로, 이 테이블의 스키마를 설정하는 데 중점을 둡니다. 이 데이터는 공개 S3 버킷 s3://datasets-documentation/stackoverflow/parquet/posts/*.parquet에서 제공되며, 연도별로 파일이 하나씩 있습니다.
S3에서 Parquet 포맷으로 데이터를 로드하는 것은 ClickHouse에 데이터를 적재하는 가장 일반적이고 권장되는 방법입니다. ClickHouse는 Parquet 처리에 최적화되어 있어, 초당 S3에서 수천만 개의 행을 읽고 삽입할 수 있습니다.ClickHouse는 데이터셋의 타입을 자동으로 식별하는 스키마 추론 기능을 제공합니다. 이 기능은 Parquet를 포함한 모든 데이터 포맷에서 지원됩니다. 이 기능을 활용하면 s3 테이블 함수와
DESCRIBE 명령을 통해 데이터의 ClickHouse 타입을 식별할 수 있습니다. 아래 예시에서는 stackoverflow/parquet/posts 폴더의 모든 파일을 읽기 위해 글롭 패턴 *.parquet를 사용합니다.
S3 테이블 함수를 사용하면 S3에 있는 데이터를 ClickHouse에서 별도 적재 없이 직접 쿼리할 수 있습니다. 이 함수는 ClickHouse가 지원하는 모든 파일 포맷과 호환됩니다.이렇게 하면 아직 최적화되지 않은 초기 스키마를 얻을 수 있습니다. 기본적으로 ClickHouse는 이를 해당하는 널 허용 타입으로 매핑합니다. 간단한
CREATE EMPTY AS SELECT 명령으로 이러한 타입을 사용하는 ClickHouse 테이블을 생성할 수 있습니다.
ORDER BY () 절은 인덱스가 없다는 뜻이며, 더 정확히는 데이터에 정렬 순서가 없다는 의미입니다. 이에 대해서는 뒤에서 더 설명하겠습니다. 지금은 모든 쿼리에 선형 스캔이 필요하다는 점만 알아두면 됩니다.
테이블이 생성되었는지 확인하려면:
INSERT INTO SELECT로 데이터를 적재할 수 있습니다. 다음 예시는 8코어 ClickHouse Cloud 인스턴스에서 약 2분 만에 posts 데이터를 적재합니다.
위 쿼리는 6천만 개의 행을 로드합니다. ClickHouse에서는 작은 규모이지만, 인터넷 연결이 느린 경우 데이터의 일부만 로드하는 편이 나을 수 있습니다. 이는 로드할 연도를 글롭 패턴으로 지정하면 간단히 구현할 수 있습니다. 예:https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/2008.parquet또는https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/{2008, 2009}.parquet. 글롭 패턴을 사용해 파일의 일부 집합을 지정하는 방법은 여기를 참조하십시오.
타입 최적화
ClickHouse가 데이터를 매우 효율적으로 압축하는 이유는 이 글을 참고하십시오. 요약하면, ClickHouse는 컬럼 지향 데이터베이스이므로 값이 컬럼 순서대로 기록됩니다. 이 값들이 정렬되어 있으면 동일한 값들이 서로 인접하게 배치됩니다. 압축 알고리즘은 이러한 연속적인 데이터 패턴을 활용합니다. 여기에 더해 ClickHouse는 코덱과 세분화된 데이터 타입을 제공하므로 압축 방식을 더 세밀하게 조정할 수 있습니다.ClickHouse의 압축 효율은 3가지 주요 요소의 영향을 받습니다. 정렬 키, 데이터 타입, 그리고 사용하는 코덱입니다. 이 모든 요소는 스키마를 통해 구성됩니다. 압축과 쿼리 성능을 초기에 크게 개선하는 가장 쉬운 방법은 타입 최적화입니다. 스키마를 최적화할 때는 몇 가지 간단한 규칙을 적용할 수 있습니다.
- 엄격한 타입 사용 - 초기 스키마에서는 분명 숫자인 많은 컬럼에 String을 사용했습니다. 올바른 타입을 사용하면 필터링과 집계 시 기대한 의미가 정확히 반영됩니다. 이는 날짜 타입에도 동일하게 적용되며, 날짜 타입은 Parquet 파일에 이미 올바르게 제공되어 있습니다.
- 널 허용 컬럼 피하기 - 기본적으로 위 컬럼들은 Null일 수 있다고 가정되었습니다. 널 허용 타입을 사용하면 쿼리에서 빈 값과 Null 값의 차이를 구분할 수 있습니다. 이로 인해 UInt8 타입의 별도 컬럼이 생성됩니다. 이 추가 컬럼은 널 허용 컬럼을 사용할 때마다 항상 함께 처리되어야 합니다. 그 결과 저장 공간이 더 필요해지고, 거의 항상 쿼리 성능에도 부정적인 영향을 줍니다. 타입의 기본 빈 값과 Null 사이에 실제 의미 차이가 있을 때만 널 허용을 사용하십시오. 예를 들어
ViewCount컬럼에서는 빈 값을 0으로 처리해도 대부분의 쿼리에 충분하며 결과에도 영향을 주지 않을 가능성이 높습니다. 빈 값을 다르게 처리해야 한다면, 대개 필터를 사용해 쿼리에서 제외할 수도 있습니다. - 숫자 타입에는 최소 정밀도 사용 - ClickHouse는 다양한 숫자 범위와 정밀도에 맞는 여러 숫자 타입을 제공합니다. 항상 컬럼을 표현하는 데 필요한 비트 수를 최소화하도록 하십시오. ClickHouse는 Int16처럼 크기가 다른 정수 타입뿐 아니라 최소값이 0인 부호 없는 타입도 제공합니다. 이런 타입을 사용하면 더 적은 비트로 컬럼을 표현할 수 있습니다. 예를 들어 UInt16의 최댓값은 65535로 Int16의 두 배입니다. 가능하다면 더 큰 부호 있는 타입보다 이러한 타입을 우선 사용하십시오.
- 날짜 타입에는 최소 정밀도 사용 - ClickHouse는 여러 날짜 및 날짜-시간 타입을 지원합니다. Date와 Date32는 순수한 날짜를 저장하는 데 사용할 수 있으며, Date32는 더 많은 비트를 사용하는 대신 더 넓은 날짜 범위를 지원합니다. DateTime과 DateTime64는 날짜-시간 값을 지원합니다. DateTime은 초 단위 세분화 수준으로 제한되며 32비트를 사용합니다. 이름에서 알 수 있듯 DateTime64는 64비트를 사용하지만 나노초 단위 세분화 수준까지 지원합니다. 여기서도 마찬가지로, 쿼리에 허용되는 범위 안에서 더 거친 타입을 선택해 필요한 비트 수를 최소화하십시오.
- LowCardinality 사용 - 고유값 수가 적은 숫자, 문자열, Date 또는 DateTime 컬럼은 LowCardinality 타입으로 인코딩할 수 있습니다. 이 타입은 값을 딕셔너리로 인코딩해 디스크 사용량을 줄입니다. 고유값이 1만 개 미만인 컬럼에서는 이를 고려하십시오.
- 특수한 경우에는 FixedString 사용 - 길이가 고정된 문자열은 FixedString 타입으로 인코딩할 수 있습니다. 예를 들어 언어 코드나 통화 코드가 여기에 해당합니다. 이 방식은 데이터 길이가 정확히 N바이트일 때 효율적입니다. 그 외의 경우에는 오히려 효율이 떨어질 가능성이 높으므로 LowCardinality를 사용하는 편이 더 적합합니다.
- 데이터 검증을 위한 Enum - Enum 타입은 열거형 타입을 효율적으로 인코딩하는 데 사용할 수 있습니다. Enum은 저장해야 하는 고유값 수에 따라 8비트 또는 16비트가 됩니다. 삽입 시점에 검증이 필요하거나(선언되지 않은 값은 거부됨), Enum 값의 자연스러운 순서를 활용하는 쿼리를 수행하려는 경우 사용을 고려하십시오. 예를 들어 사용자 응답을 담는 피드백 컬럼이
Enum(':(' = 1, ':|' = 2, ':)' = 3)형태일 수 있습니다.
팁: 모든 컬럼의 범위와 고유값 개수를 확인하려면 간단한 쿼리 SELECT * APPLY min, * APPLY max, * APPLY uniq FROM table FORMAT Vertical를 사용할 수 있습니다. 이 작업은 비용이 클 수 있으므로 더 작은 데이터 부분집합에서 수행하는 것을 권장합니다. 정확한 결과를 얻으려면 숫자 값이 최소한 숫자 타입으로 정의되어 있어야 합니다. 즉, String이어서는 안 됩니다.
이 간단한 규칙을 Posts 테이블에 적용하면 각 컬럼에 가장 적합한 타입을 식별할 수 있습니다.
| 컬럼 | 숫자형 여부 | 최솟값, 최댓값 | 고유 값 | NULL 값 | 설명 | 최적화 유형 |
|---|---|---|---|---|---|---|
PostTypeId | 예 | 1, 8 | 8 | 아니요 | Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8) | |
AcceptedAnswerId | 예 | 0, 78285170 | 12282094 | 예 | NULL과 0 값을 구분 | UInt32 |
CreationDate | 아니요 | 2008-07-31 21:42:52.667000000, 2024-03-31 23:59:17.697000000 | * | 아니요 | 밀리초 세분화 수준이 필요하지 않으므로 DateTime을 사용합니다 | DateTime |
Score | 예 | -217, 34970 | 3236 | 아니요 | Int32 | |
ViewCount | 예 | 2, 13962748 | 170867 | 아니요 | UInt32 | |
Body | 아니요 | - | * | 아니요 | String | |
OwnerUserId | 예 | -1, 4056915 | 6256237 | 예 | Int32 | |
OwnerDisplayName | 아니요 | - | 181251 | 예 | NULL은 빈 문자열로 간주 | String |
LastEditorUserId | 예 | -1, 9999993 | 1104694 | 예 | 0은 사용되지 않는 값이므로 NULL 값으로 사용할 수 있습니다 | Int32 |
LastEditorDisplayName | 아니요 | * | 70952 | 예 | NULL은 빈 문자열로 간주합니다. LowCardinality를 테스트했지만 효과가 없었습니다 | String |
LastEditDate | 아니오 | 2008-08-01 13:24:35.051000000, 2024-04-06 21:01:22.697000000 | - | 아니오 | 밀리초 세분화 수준이 필요하지 않으므로 DateTime을 사용합니다 | DateTime |
LastActivityDate | 아니오 | 2008-08-01 12:19:17.417000000, 2024-04-06 21:01:22.697000000 | * | 없음 | 밀리초 단위의 세분화 수준이 필요하지 않으므로 DateTime을 사용합니다 | DateTime |
Title | 아니요 | - | * | 아니요 | NULL을 빈 문자열로 처리합니다 | String |
태그 | 아니오 | - | * | 아니오 | NULL을 빈 문자열로 처리합니다 | String |
AnswerCount | 예 | 0, 518 | 216 | 아니요 | NULL과 0을 동일하게 간주 | UInt16 |
CommentCount | 예 | 0, 135 | 100 | 아니요 | NULL과 0을 동일하게 취급 | UInt8 |
FavoriteCount | 예 | 0, 225 | 6 | 예 | NULL과 0을 동일한 값으로 간주 | UInt8 |
ContentLicense | 아니요 | - | 3 | 아니요 | LowCardinality가 FixedString보다 성능이 더 우수합니다 | LowCardinality(String) |
ParentId | 아니요 | * | 20696028 | 예 | NULL을 빈 문자열로 간주합니다 | String |
CommunityOwnedDate | 아니오 | 2008-08-12 04:59:35.017000000, 2024-04-01 05:36:41.380000000 | - | 예 | NULL 값의 경우 기본값으로 1970-01-01을 사용하는 것을 고려하십시오. 밀리초 단위는 필요하지 않으므로 DateTime을 사용하십시오 | DateTime |
ClosedDate | 아니오 | 2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000 | * | 예 | NULL에는 기본값으로 1970-01-01을 고려하세요. 밀리초 단위의 세분화 수준은 필요하지 않으므로 DateTime을 사용하세요 | DateTime |
위 내용을 바탕으로 다음과 같은 스키마를 얻을 수 있습니다:
INSERT INTO SELECT로 데이터를 채울 수 있습니다:
순서 지정 키 선택하기
테이블의 모든 컬럼은 키 자체에 포함되어 있는지와 관계없이, 지정된 순서 지정 키 값을 기준으로 정렬됩니다. 예를 들어순서 지정 키를 선택하는 데 도움이 되는 몇 가지 간단한 규칙이 있습니다. 아래 항목들은 때로 서로 충돌할 수 있으므로, 순서대로 검토하십시오. 이 과정을 통해 여러 키를 식별할 수 있으며, 일반적으로 4~5개면 충분합니다.CreationDate를 키로 사용하면 다른 모든 컬럼의 값 순서도CreationDate컬럼의 값 순서를 따릅니다. 여러 개의 순서 지정 키를 지정할 수 있으며, 이 경우SELECT쿼리의ORDER BY절과 동일한 의미로 정렬됩니다.
- 자주 사용하는 필터와 일치하는 컬럼을 선택하십시오. 어떤 컬럼이
WHERE절에서 자주 사용된다면, 덜 자주 사용되는 컬럼보다 해당 컬럼을 키에 포함하는 것을 우선하십시오. 필터링할 때 전체 행의 상당 부분을 제외하는 데 도움이 되는 컬럼을 우선하면, 읽어야 하는 데이터 양을 줄일 수 있습니다. - 테이블의 다른 컬럼과 높은 상관관계를 가질 가능성이 큰 컬럼을 우선하십시오. 이렇게 하면 해당 값들도 연속적으로 저장될 가능성이 높아져 압축 효율이 향상됩니다.
순서 지정 키에 포함된 컬럼에 대한
GROUP BY및ORDER BY연산도 메모리를 더 효율적으로 사용할 수 있습니다.
예시
posts 테이블에 적용해 보겠습니다. 예를 들어, 사용자가 날짜와 게시물 유형을 기준으로 필터링하는 분석을 수행하려고 한다고 가정하겠습니다.
“지난 3개월 동안 댓글이 가장 많이 달린 질문은 무엇인가”
타입은 최적화되었지만 순서 지정 키는 없는 앞서의 posts_v2 테이블을 사용하면, 이 질문에 대한 쿼리는 다음과 같습니다.
여기의 쿼리는 6천만 행 전체를 선형 스캔했는데도 매우 빠릅니다. ClickHouse는 원래 빠르니까요 :) TB 및 PB 규모에서는 순서 지정 키가 충분한 가치가 있다는 점을 일단 믿고 넘어가야 합니다!이제
PostTypeId와 CreationDate 컬럼을 순서 지정 키로 선택하겠습니다.
이 예시에서는 사용자가 항상 PostTypeId로 필터링한다고 가정할 수 있습니다. 이 값의 카디널리티는 8이므로 순서 지정 키의 첫 번째 항목으로 적절합니다. 날짜 세분화 수준으로 필터링해도 충분할 가능성이 높고(datetime 필터에도 여전히 도움이 됨), 따라서 키의 두 번째 요소로 toDate(CreationDate)를 사용합니다. 이렇게 하면 날짜를 16비트로 표현할 수 있어 인덱스가 더 작아지고 필터링 속도도 빨라집니다. 마지막 키 항목은 댓글이 많은 게시물을 더 쉽게 찾을 수 있도록 CommentCount로 지정합니다(최종 정렬).
다음: 데이터 모델링 기법
Posts를 유지하는 것을 목표로 합니다. 다른 테이블도 개별적으로 쿼리할 수 있지만, 대부분의 분석은 posts를 맥락으로 수행된다고 가정합니다.
이 섹션에서는 다른 테이블의 최적화된 변형을 사용합니다. 이러한 스키마는 제공하지만, 간결성을 위해 어떤 결정을 내렸는지는 설명하지 않습니다. 이는 앞서 설명한 규칙을 기반으로 하며, 그 결정 과정은 독자가 유추할 수 있도록 남겨 둡니다.다음 접근 방식은 모두 읽기를 최적화하고 쿼리 성능을 높이기 위해 JOIN의 필요성을 최소화하는 것을 목표로 합니다. ClickHouse는 JOIN을 완전히 지원하지만, 최적의 성능을 위해서는 가급적 제한적으로 사용하는 것을 권장합니다(JOIN 쿼리에서 2~3개 테이블을 사용하는 정도는 괜찮습니다).
ClickHouse에는 외래 키라는 개념이 없습니다. 그렇다고 JOIN을 사용할 수 없는 것은 아니지만, 참조 무결성은 사용자가 애플리케이션 수준에서 관리해야 함을 의미합니다. ClickHouse와 같은 OLAP 시스템에서는 데이터 무결성을 데이터베이스 자체에서 강제하기보다 애플리케이션 수준이나 데이터 수집 과정에서 관리하는 경우가 많습니다. 데이터베이스 자체에서 이를 강제하면 상당한 오버헤드가 발생하기 때문입니다. 이러한 접근 방식은 더 높은 유연성과 더 빠른 데이터 삽입을 가능하게 합니다. 이는 매우 큰 데이터셋에서 읽기 및 삽입 쿼리의 속도와 확장성에 초점을 맞춘 ClickHouse의 방향성과도 일치합니다.쿼리 시점의 JOIN 사용을 최소화하기 위해 사용할 수 있는 여러 도구와 접근 방식이 있습니다:
- 데이터 비정규화 - 테이블을 결합하고 1:1이 아닌 관계에는 복합 타입을 사용해 데이터를 비정규화합니다. 여기에는 조인을 쿼리 시점에서 삽입 시점으로 옮기는 작업이 포함되는 경우가 많습니다.
- Dictionaries - Direct JOIN 및 키-값 조회를 처리하기 위한 ClickHouse 고유 기능입니다.
- 증분형 materialized view - 계산 비용을 쿼리 시점에서 삽입 시점으로 옮기는 ClickHouse 기능이며, 집계 값을 점진적으로 계산하는 기능도 포함합니다.
- 갱신 가능 구체화 뷰 - 다른 데이터베이스 제품의 materialized view와 유사하게, 쿼리 결과를 주기적으로 계산하고 그 결과를 캐시할 수 있습니다.