SELECT page, quantile(0.75)(lcp_ms)
FROM page_events
WHERE event_time >= now() - INTERVAL 5 MINUTE
GROUP BY page
-- EXPLAIN indexes = 1 ReadFromMergeTree (web_perf_raw) MinMax event_time Granules: 190/636 Partition toDate(event_time) Granules: 190/190 PrimaryKey metric, event_time Granules: 30/190
| 단계 | 남은 granule |
|---|---|
| 전체 | 636 |
| part별 min, max로 거름 | 190 |
| primary key 이진 탐색 | 30 |
| 방법 | 하는 일 | 대가 |
|---|---|---|
| projection | 같은 행을 다른 primary key로 정렬한 사본을 table 안에 둔다. optimizer가 I/O 추정으로 고른다 | INSERT, merge, 저장 공간이 늘어난다. 기존 part는 직접 materialize해야 채워진다 |
| skipping index min-max, set, bloom filter | granule 여러 개를 묶은 단위마다 작은 요약을 둔다. 대충 정렬된 값엔 min-max, 값이 뭉쳐 있으면 set, 문자열 검색엔 bloom | 요약이 걸러 주지 못하면 읽기만 늘어난다. bloom은 범위 조건과 부정 조건에 못 쓴다 |
| partition | partition마다 partition 식의 min, max를 저장해 통째로 건너뛴다 | partition을 잘게 나누면 part 수가 늘어난다 |
| merge 종류 | 합치면서 하는 일 | 쓰임 |
|---|---|---|
| Replacing | primary key가 같은 행 중 가장 최근 것만 남긴다 | merge 시점의 update, 중복 제거 |
| Aggregating | key가 같은 행의 부분 집계 상태를 하나로 합친다 | materialized view |
| TTL | 조건을 만족한 part를 S3로 옮기거나, 다시 압축하거나, 지우거나, roll-up한다 | 오래된 데이터 정리 |
| 설계 | 얻은 것 | 내준 것 |
|---|---|---|
| merge는 비동기 | INSERT가 빠르다 | merge 전에는 중복이나 덜 합친 행이 보인다. 확실히 보려면 FINAL 비용 |
| mutation | 물리적으로 확실히 고친다 | 모든 part를 다시 쓰고, 도중에 SELECT가 고친 part와 안 고친 part를 섞어 읽는다 |
| lightweight delete | 삭제가 빠르다 | bitmap 필터가 붙어 SELECT가 느려지고, 실제 삭제는 언젠가의 merge |
| 복제 | Keeper(Raft) log로 어느 노드에서나 쓴다 | eventually consistent. 노드마다 잠깐 옛 상태를 읽는다 |
| 트랜잭션 | part snapshot으로 잠금 없이 읽는다 | snapshot isolation. 일반적으로 ACID가 아니고 기본값으로 fsync도 안 한다 |
| 관점 | BigQuery | ClickHouse |
|---|---|---|
| 최근 5분 찾기 | partition과 clustering으로 줄인다 | 정렬 키와 희소 index로 granule 단위까지 줄인다 |
| p75를 매번 | 쿼리마다 원본을 다시 집계하거나, 사전 집계를 따로 설계한다 | 증분 MV가 INSERT 때 t-digest 상태를 만들어 둔다 |
| 15초 주기 비용 | 실행마다 스캔량이나 slot을 쓴다. 주기와 비용을 같이 본다 | 상태 table 몇천 행만 읽는다. 서버는 이미 떠 있다 |
| 운영 | 서버리스. 운영할 것이 거의 없다 | 직접 띄우면 서버, 복제, merge, 디스크를 직접 본다 |
CREATE TABLE web_perf_raw ( event_id UUID, event_time DateTime64(3), metric LowCardinality(String), page LowCardinality(String), value Float64 ) ENGINE = MergeTree PARTITION BY toDate(event_time) ORDER BY (metric, page, event_time) TTL toDateTime(event_time) + INTERVAL 7 DAY; CREATE TABLE web_perf_10s ( metric LowCardinality(String), bucket DateTime, page LowCardinality(String), p75 AggregateFunction(quantileTDigest(0.75), Float64), n AggregateFunction(count) ) ENGINE = AggregatingMergeTree ORDER BY (metric, bucket, page) TTL bucket + INTERVAL 30 DAY; CREATE MATERIALIZED VIEW web_perf_10s_mv TO web_perf_10s AS SELECT metric, toStartOfInterval(event_time, INTERVAL 10 SECOND) AS bucket, page, quantileTDigestState(0.75)(value) AS p75, countState() AS n FROM web_perf_raw GROUP BY metric, bucket, page;
-- 15초마다 평가. 기준은 web.dev "good" 경계 SELECT metric, page, quantileTDigestMerge(0.75)(p75) AS p75_ms, countMerge(n) AS n, transform(metric, ['LCP','INP'], [2500,200], 0) AS threshold_ms FROM web_perf_10s WHERE metric IN ('LCP','INP') AND bucket >= now() - INTERVAL 5 MINUTE GROUP BY metric, page HAVING n >= 100 AND p75_ms > threshold_ms
-- Flink JDBC sink 연결 설정
async_insert = 1
wait_for_async_insert = 1
async_insert_busy_timeout_ms = 1000
-- Grafana 알림 규칙의 쿼리 SELECT metric, page, quantileTDigestMerge(0.75)(p75) / transform(metric, ['LCP','INP'], [2500,200], 1) AS ratio FROM web_perf_10s WHERE metric IN ('LCP','INP') AND bucket >= now() - INTERVAL 5 MINUTE GROUP BY metric, page HAVING countMerge(n) >= 100
| 항목 | 값 |
|---|---|
| threshold | ratio > 1 |
| 평가 주기 | 15초 |
| pending period | 15초: 첫 평가는 pending, 다음 평가도 넘으면 firing |
| 쿼리 | 읽은 행 | 읽은 바이트 | 전체 대비 |
|---|---|---|---|
| 원본, 시간 조건 없이 | 5,200,000 | 49.6 MiB | 1 |
| 원본, 최근 5분 (정렬 키로 pruning) | 238,650 | 4.1 MiB | 1/22 |
| 10초 상태 table, 최근 5분 | 1,410 | 195.5 KiB | 1/3,688 |
CREATE TABLE my_dataset.web_perf_raw (...) PARTITION BY DATE(event_time) CLUSTER BY metric, event_time OPTIONS (partition_expiration_days = 7); SELECT metric, page, APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75_ms, COUNT(*) AS n FROM my_dataset.web_perf_raw WHERE metric IN ('LCP','INP') AND event_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 5 MINUTE) GROUP BY metric, page HAVING n >= 100 AND p75_ms > CASE metric WHEN 'LCP' THEN 2500 WHEN 'INP' THEN 200 END;