ClickHouse + Node.js로 실시간 이벤트 분석 파이프라인 구축하기 — MergeTree·Materialized View·ReplacingMergeTree로 억 건 데이터를 밀리초로 집계하는 OLAP 설계 패턴
PostgreSQL에서 로그 테이블을 집계하다가 쿼리 타임아웃을 처음 맞닥뜨렸을 때의 기억이 생생합니다. 행이 5억을 넘어가던 시점이었고, 단순한 GROUP BY가 30초를 넘기기 시작했죠. 그때 알게 된 게 ClickHouse였습니다. 처음엔 '빠른 MySQL이겠지' 하고 얕봤는데, 막상 써보니 컬럼 지향 스토리지와 벡터화 실행이 결합된 OLAP 전용 엔진이 얼마나 다른 세계인지 실감했습니다.
이 글은 ClickHouse가 처음이거나, 이미 써봤지만 MergeTree 엔진 선택이나 Materialized View 설계에서 헷갈렸던 분들을 위해 씁니다. MergeTree 패밀리의 각 엔진이 언제 적합한지, Materialized View가 INSERT 트리거처럼 동작하는 메커니즘, Node.js @clickhouse/client로 배치 인제스션을 구현하는 패턴을 실제 코드와 함께 짚어봅니다. Braze가 수십억 건의 마케팅 이벤트를 실시간으로 처리하고, Mux가 비디오 스트리밍 분석을 ClickHouse를 스트림 처리 엔진으로 대체해 운용하는 데 쓰인 아키텍처가 바로 이 조합입니다.
ClickHouse는 OLTP 데이터베이스가 아닙니다. 단일 행 업데이트나 포인트 룩업이 빈번한 시스템에는 맞지 않고, PostgreSQL과 함께 쓰는 폴리글롯 아키텍처가 일반적입니다. 하지만 대용량 이벤트를 실시간으로 집계하는 분석 레이어로는 현재 시점에서 가장 강력한 선택지 중 하나입니다.
핵심 개념
ClickHouse가 빠른 이유 — 컬럼 스토리지와 벡터화 실행
관계형 데이터베이스는 데이터를 행 단위로 저장합니다. SELECT count(*), sum(revenue) FROM events WHERE date = '2026-07-15' 같은 쿼리를 실행하면 필요 없는 컬럼(사용자 ID, 이벤트 타입 등)까지 디스크에서 읽어옵니다. ClickHouse는 컬럼별로 따로 저장하기 때문에 필요한 컬럼만 정확하게 스캔합니다. 거기에 LZ4/ZSTD 압축이 컬럼 단위로 적용됩니다. 압축률은 데이터 특성에 따라 다르지만 일반적으로 원본 대비 3~10배 저장 공간 절감을 기대할 수 있고, 카디널리티가 낮은 컬럼에서는 더 높은 압축률이 나오기도 합니다. SIMD(AVX2/AVX-512) 벡터화 연산으로 단일 노드에서도 적절한 하드웨어와 쿼리 구조 아래 1억 행 규모의 GROUP BY가 수십 밀리초 이내에 응답합니다.
MergeTree 패밀리 — 엔진 선택 결정 흐름
모든 ClickHouse 테이블의 기반은 MergeTree 엔진 계열입니다. 데이터를 "파트(part)" 단위로 디스크에 정렬 저장하고, 백그라운드에서 파트를 병합(merge)하는 구조입니다. ORDER BY로 지정한 키로 데이터가 물리적으로 정렬되고, 기본 단위(granule) 8,192행마다 스파스 인덱스가 1개 생성됩니다.
파생 엔진이 여럿인데, 실무에서 굳어진 선택 기준을 정리하면 이렇습니다.
MergeTree — 가장 기본적인 엔진입니다. 중복을 허용하고 한번 저장된 데이터는 불변(immutable)입니다. 원시 이벤트 로그처럼 모든 레코드를 보존해야 하는 경우에 씁니다.
ReplacingMergeTree — 동일한 ORDER BY 키를 가진 행 중 최신 버전만 남기는 멱등 업서트(upsert) 엔진입니다. ver 컬럼을 지정하면 그 컬럼의 최대값을 가진 행이 살아남습니다. 사용자 세션 상태, 장치 마지막 상태처럼 "최신 값만 필요한" 엔티티 관리에 적합합니다. 단, 중복 제거는 백그라운드 merge 시점에만 일어나기 때문에 즉시 일관성은 보장되지 않습니다.
AggregatingMergeTree — AggregateFunction 타입 컬럼에 집계 중간 상태를 저장하고 merge 시 자동 병합합니다. uniqState, quantileState, avgState 같은 부분 집계(partial aggregate)를 인크리멘털하게 유지할 수 있어서, Materialized View와 조합하면 억 건 데이터를 사전 집계해두고 쿼리 시에는 미리 계산된 결과만 읽는 패턴이 가능합니다.
SummingMergeTree — 숫자형 컬럼의 합산에 특화됩니다. AggregatingMergeTree가 더 범용적이지만, 구조가 단순한 만큼 스키마를 파악하기 쉽고 유지보수 부담이 낮습니다. 카운터·합계만 필요하고 팀 내에 ClickHouse 경험이 많지 않다면 진입 비용을 줄이는 실용적인 선택지입니다.
Materialized View — INSERT 트리거로 이해하면 명확합니다
솔직히 ClickHouse Materialized View를 처음 접했을 때 RDBMS의 뷰처럼 생각했다가 한참 헤맸습니다. ClickHouse의 MV는 읽기 시점에 쿼리를 실행하는 게 아니라, INSERT 시점에 변환을 수행해 별도 타깃 테이블에 결과를 저장합니다. 삽입 트리거(insert trigger)에 훨씬 가까운 개념입니다.
이 구조의 핵심은 쿼리 시점에 계산 비용이 없다는 것입니다. 억 건짜리 원본 테이블을 건드리지 않고, 이미 계산된 AggregatingMergeTree 테이블만 읽습니다.
AggregatingMergeTree를 타깃으로 쓸 때는 -State / -Merge 함수 쌍을 반드시 맞춰줘야 합니다. 이 부분이 가장 자주 실수하는 지점입니다.
- INSERT 방향 (MV에서):
uniqState(),avgState(),sumState()— 중간 상태를 저장 - SELECT 방향 (조회 시):
uniqMerge(),avgMerge(),sumMerge()또는finalizeAggregation()— 중간 상태를 최종값으로 변환
실전 적용
1단계 — 원시 이벤트 테이블 설계
이커머스 클릭스트림을 예로 들겠습니다. 페이지뷰·클릭·구매 이벤트를 수집하는 시나리오입니다.
CREATE TABLE events
(
event_id UUID,
user_id UInt64,
event_type LowCardinality(String), -- 'pageview', 'click', 'purchase'
page_url String,
revenue Decimal(10, 2),
created_at DateTime64(3) -- 밀리초 정밀도
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at) -- 월별 파티셔닝
ORDER BY (event_type, user_id, created_at);PARTITION BY에서 일(day) 단위 이하로 내려가면 파티션 수가 수십만 개를 넘어 메타데이터 연산이 느려집니다. 월·일 단위가 적절합니다.
ORDER BY의 첫 번째 컬럼을 선택할 때는 쿼리에서 가장 자주 필터링하는 컬럼을 앞에 두되, 카디널리티가 너무 높으면 스파스 인덱스 효과가 줄어들고 너무 낮으면 스캔 범위가 넓어집니다. event_type 같은 저카디널리티 컬럼을 앞에 두고 user_id를 뒤에 두는 패턴이 균형적입니다.
2단계 — Node.js 배치 인제스션
테이블이 준비됐다면 데이터를 넣어야 합니다. 행 단위 INSERT는 소형 파트를 너무 많이 만들어서 Too many parts 오류를 유발합니다. 최소 1,000~10,000행 단위로 묶어서 INSERT하는 배치 버퍼가 필수입니다.
npm install @clickhouse/client클라이언트를 생성자로 주입받는 버퍼 + 타임아웃 플러시 패턴입니다.
import { createClient, ClickHouseClient } from '@clickhouse/client';
interface EventRow {
event_id: string;
user_id: number;
event_type: string;
page_url: string;
revenue: string; // Decimal(10,2) — 부동소수점 오차를 피하려면 문자열로 전달
created_at: Date;
}
class EventBuffer {
private buffer: EventRow[] = [];
private flushTimer: ReturnType<typeof setInterval> | null = null;
private readonly BATCH_SIZE = 5000;
private readonly FLUSH_INTERVAL_MS = 2000;
constructor(private readonly client: ClickHouseClient) {
this.flushTimer = setInterval(() => {
// 주기적 플러시 오류는 로깅만 하고 버퍼는 유지
this.flush().catch((err) => console.error('주기적 플러시 실패:', err));
}, this.FLUSH_INTERVAL_MS);
}
push(event: EventRow): void {
this.buffer.push(event);
if (this.buffer.length >= this.BATCH_SIZE) {
this.flush().catch((err) => console.error('배치 플러시 실패:', err));
}
}
async flush(): Promise<void> {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.buffer.length);
try {
await this.client.insert({
table: 'events',
values: batch,
format: 'JSONEachRow',
});
} catch (err) {
// 삽입 실패 시 배치를 버퍼 앞쪽에 돌려놓아 데이터 유실 방지
this.buffer.unshift(...batch);
throw err;
}
}
async close(): Promise<void> {
if (this.flushTimer) clearInterval(this.flushTimer);
await this.flush();
// 클라이언트 수명은 호출자가 관리 — 여기서 close() 호출하지 않음
}
}
// 사용 예시
const client = createClient({
host: process.env.CLICKHOUSE_HOST ?? 'http://localhost:8123',
database: 'analytics',
username: 'default',
password: process.env.CLICKHOUSE_PASSWORD,
compression: { request: true }, // gzip 압축으로 네트워크 비용 절감
});
const buffer = new EventBuffer(client);
buffer.push({
event_id: crypto.randomUUID(),
user_id: 42,
event_type: 'pageview',
page_url: '/products/123',
revenue: '0.00', // 금액은 문자열로 전달 — JS number의 부동소수점 오차가 Decimal 컬럼에 반영됨
created_at: new Date(),
});BATCH_SIZE와 FLUSH_INTERVAL_MS는 서비스 특성에 따라 조정이 필요합니다. 인입 트래픽이 높으면 배치 크기를 키우고, 실시간성이 중요하면 플러시 간격을 줄이는 트레이드오프가 있습니다.
3단계 — AggregatingMergeTree 집계 테이블과 Materialized View
데이터를 인제스트하는 구조가 갖춰졌으니, 이제 조회 레이어를 설계합니다. 일별·이벤트 타입별 집계 테이블을 만들겠습니다. DAU 근사값, 매출, 이벤트 수를 실시간으로 유지하는 목적입니다.
-- 집계 결과를 저장할 타깃 테이블
CREATE TABLE events_daily_agg
(
date Date,
event_type LowCardinality(String),
event_count AggregateFunction(count),
unique_users AggregateFunction(uniq, UInt64),
total_revenue AggregateFunction(sum, Decimal(10, 2))
)
ENGINE = AggregatingMergeTree
ORDER BY (date, event_type);
-- events 테이블에 INSERT 시 자동 트리거되는 Materialized View
CREATE MATERIALIZED VIEW events_daily_mv
TO events_daily_agg
AS
SELECT
toDate(created_at) AS date,
event_type,
countState() AS event_count,
uniqState(user_id) AS unique_users,
sumState(revenue) AS total_revenue
FROM events
GROUP BY date, event_type;이 MV가 생성된 이후부터 events 테이블에 INSERT가 들어올 때마다 events_daily_agg가 자동으로 업데이트됩니다. MV는 생성 이후의 INSERT부터 집계하므로, 기존에 적재된 데이터는 별도 backfill이 필요합니다. (예: INSERT INTO events_daily_agg SELECT ... FROM events GROUP BY ...)
조회 시에는 -Merge 결합자를 써야 올바른 최종값이 나옵니다.
SELECT
date,
event_type,
countMerge(event_count) AS total_events,
uniqMerge(unique_users) AS dau_approx, -- HyperLogLog 근사값, 오차 약 ±2.2%
sumMerge(total_revenue) AS revenue
FROM events_daily_agg
WHERE date >= today() - 7
GROUP BY date, event_type
ORDER BY date DESC;uniq / uniqMerge는 HyperLogLog 기반 근사 집계입니다. 억 단위 유저를 낮은 메모리로 빠르게 집계하는 데 적합하지만, 정확한 카운트가 필요하다면 uniqExact / uniqExactMerge를 사용해야 합니다. 다만 uniqExact는 정확한 집합을 메모리에 유지하므로 고카디널리티 데이터에서는 메모리 사용량이 급격히 늘어납니다. 분석 대시보드에서 DAU를 제공할 때 어느 함수를 쓰는지 명시하는 것이 중요합니다.
-Merge 없이 그냥 sum(total_revenue)를 쓰면 이진 직렬화된 집계 상태 값이 더해져서 의미 없는 숫자가 나옵니다. 저도 처음에 왜 이상한 값이 나오는지 한참 헤맸던 기억이 납니다.
4단계 — Kafka 테이블 엔진으로 Flink 없이 스트림 처리
Kafka에서 직접 이벤트를 소비하고 Materialized View로 집계하는 패턴입니다. 별도의 Flink 클러스터 없이 ClickHouse 안에서 스트림 파이프라인이 완성됩니다.
Null 테이블을 중간에 두는 이유는 Kafka 테이블에서 직접 집계 MV를 연결하면 타입 변환과 오류 처리가 번거롭기 때문입니다. Null 엔진은 데이터를 저장하지 않으며 버퍼링 기능도 없습니다. 실제 역할은 MV 체인의 연결점이자 타입 변환 단계로, Kafka 테이블과 집계 MV의 관심사를 명확히 분리합니다.
-- Kafka 소비자 테이블 (읽기만 함, 저장 없음)
CREATE TABLE events_kafka
(
event_id String,
user_id UInt64,
event_type String,
page_url String,
revenue Float64,
created_at Int64 -- Unix 타임스탬프 ms
)
ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'user-events',
kafka_group_name = 'clickhouse-consumer',
kafka_format = 'JSONEachRow',
-- 파티션 수를 초과하면 잉여 컨슈머는 idle 상태가 됨 — 토픽 파티션 수에 맞춰 설정
kafka_num_consumers = 4;
-- 타입 변환용 중간 Null 테이블
CREATE TABLE events_raw
(
event_id String,
user_id UInt64,
event_type LowCardinality(String),
page_url String,
revenue Decimal(10, 2),
created_at DateTime64(3)
)
ENGINE = Null;
-- Kafka → Null 연결 MV (타입 변환 처리)
CREATE MATERIALIZED VIEW events_kafka_mv
TO events_raw
AS
SELECT
event_id,
user_id,
event_type,
page_url,
toDecimal64(revenue, 2) AS revenue,
fromUnixTimestamp64Milli(created_at) AS created_at
FROM events_kafka;
-- Null → AggregatingMergeTree 연결 MV (집계)
CREATE MATERIALIZED VIEW events_raw_daily_mv
TO events_daily_agg
AS
SELECT
toDate(created_at) AS date,
event_type,
countState() AS event_count,
uniqState(user_id) AS unique_users,
sumState(revenue) AS total_revenue
FROM events_raw
GROUP BY date, event_type;이 두 MV가 모두 생성되면 Kafka → ClickHouse → 집계까지 파이프라인이 완성됩니다. kafka_num_consumers = 4는 토픽 파티션 수와 맞추는 것이 원칙입니다. 파티션이 4개보다 적다면 일부 컨슈머가 idle 상태로 자원을 낭비하게 됩니다.
보충 — ReplacingMergeTree로 엔티티 최신 상태 유지
이벤트 로그 파이프라인과는 별개로, 사용자 세션의 현재 상태(활성 여부, 마지막 접속 시각)처럼 항상 최신 값 하나만 남겨야 하는 엔티티를 관리해야 할 때 ReplacingMergeTree가 유용합니다.
CREATE TABLE user_sessions
(
user_id UInt64,
session_id UUID,
status LowCardinality(String), -- 'active', 'expired'
last_seen DateTime64(3),
version UInt64 -- 업데이트 버전 번호
)
ENGINE = ReplacingMergeTree(version)
ORDER BY (user_id, session_id);같은 (user_id, session_id) 조합이 들어오면 version이 큰 행만 남습니다. 단, 중복 제거는 백그라운드 merge 때 일어나므로 즉시 최신 상태를 읽어야 할 때는 FINAL이 필요합니다.
-- FINAL은 쿼리 시점에 merge 비용이 추가되어 느려질 수 있음
-- ClickHouse 22.8 이후 max_final_threads 설정으로 파티션 간 병렬 처리가 개선됨
SELECT user_id, status, last_seen
FROM user_sessions FINAL
WHERE user_id = 12345;FINAL의 성능 페널티는 22.8 이전에는 상당했지만, 이후 버전에서는 max_final_threads를 통해 파티션 간 병렬 처리가 도입되어 개선되었습니다. "모든 병렬 최적화가 꺼진다"는 설명은 최신 버전에서는 부정확합니다. 다만 쿼리 시점에 merge 비용이 추가되는 것은 여전하므로, 정확한 최신 상태가 반드시 필요한 쿼리에만 FINAL을 적용하는 것이 바람직합니다.
파티션 경계와 중복 제거의 한계: ReplacingMergeTree는 동일 파티션 내에서만 중복을 제거합니다. 같은 ORDER BY 키를 가진 행이 서로 다른 파티션에 걸쳐 존재하면 중복이 남습니다. 이를 피하려면 ORDER BY에 파티션 키를 포함시켜 동일 키가 항상 동일 파티션에 저장되도록 설계하는 것이 기본입니다. 파티션 경계를 넘는 중복 제거가 반드시 필요하다면 GlassFlow처럼 스트림 처리 레이어에서 중복을 제거하는 외부 도구를 고려할 수 있습니다(GlassFlow는 Kafka 스트림 위에서 동작하는 이벤트 중복 제거 서비스입니다).
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 초고속 집계 | 컬럼 스토리지 + 벡터화 실행으로 1억 행 GROUP BY가 수십 ms 이내 응답 |
| MV 실시간성 | INSERT 시점에 집계가 완료되어 쿼리 시 계산 비용이 없음 |
| 압축 효율 | LZ4/ZSTD 컬럼 압축으로 데이터 특성에 따라 원본 대비 3~10배 저장 공간 절감 |
| 운영 단순성 | Kafka 테이블 엔진 + MV만으로 스트림 파이프라인 구성 가능. Flink 클러스터 불필요 |
| 낮은 진입 장벽 | 표준 SQL에 가까운 문법. Druid, Pinot 대비 신규 개발자가 빠르게 적응 가능 |
| Node.js 공식 클라이언트 | @clickhouse/client v1.x 안정화, ESM/CJS 듀얼 지원, 스트리밍 INSERT 기본 내장 |
단점 및 실무에서 흔한 실수
| 항목 | 내용 | 권장 대응 |
|---|---|---|
| ReplacingMergeTree FINAL 비용 | 쿼리 시점에 merge 비용이 추가되어 느려질 수 있음. ClickHouse 22.8 이후 일부 개선 | 최신 상태가 반드시 필요한 쿼리에만 FINAL 사용. 버전 확인 후 max_final_threads 조정 |
| 파티션 경계 중복 | ReplacingMergeTree는 파티션 경계를 넘는 중복을 제거하지 못함 | ORDER BY에 파티션 키를 포함시켜 동일 키가 같은 파티션에 저장되도록 설계 |
| MV가 UPDATE/DELETE에 반응 안 함 | MV는 INSERT 시점에만 트리거. 원본 수정은 집계 테이블에 반영되지 않음 | MV를 불변 이벤트 소스에만 적용. 변경 가능한 데이터는 ReplacingMergeTree로 별도 관리 |
| MV backfill 누락 | MV는 생성 이후 INSERT부터 집계. 기존 데이터는 수동 backfill 필요 | MV 생성 후 INSERT INTO ... SELECT ... FROM 원본테이블로 초기 집계 |
uniq 근사값 혼동 |
uniqMerge는 HyperLogLog 근사값 (±2.2%). 정확한 카운트가 필요하면 uniqExact 사용 |
대시보드에 근사값임을 명시. 고정밀 집계에는 메모리 비용 증가를 감수하고 uniqExact 선택 |
| -State/-Merge 누락 | AggregatingMergeTree 조회 시 -Merge 없이 읽으면 이진 상태 값이 반환됨 |
조회 쿼리에 항상 uniqMerge, avgMerge 또는 finalizeAggregation() 적용 |
| 행 단위 INSERT | 소형 파트 폭증으로 Too many parts 오류 발생 |
최소 1,000~10,000행 배치 INSERT. 버퍼 + 타임아웃 플러시 패턴 필수 |
| 삽입 실패 시 데이터 유실 | 버퍼에서 꺼낸 배치가 INSERT 실패 시 어디에도 남지 않을 수 있음 | 실패 시 배치를 버퍼 앞쪽에 re-push. 재시도 전략은 서비스 요구사항에 맞게 설계 |
| 과도한 파티셔닝 | 파티션 수 수만 개 이상이면 메타데이터 연산이 느려짐 | PARTITION BY는 월·일 단위로 제한. 시간 단위 이하 사용 지양 |
| kafka_num_consumers 초과 설정 | 토픽 파티션 수를 초과하는 컨슈머는 idle 상태가 되어 낭비 | 파티션 수와 일치하도록 설정 |
| MV 체인 길이 | MV가 여러 단계로 체인될수록 INSERT 경로의 지연이 누적됨 | MV 체인은 2~3단계 이내로 유지. 복잡한 변환은 Kafka 소비 단계에서 처리 |
| OLTP 혼용 | 단일 행 업데이트·포인트 룩업이 잦은 워크로드에는 부적합 | PostgreSQL과 함께 쓰는 폴리글롯 아키텍처 적용. OLAP은 ClickHouse, OLTP는 PostgreSQL |
마치며
ClickHouse + Node.js 스택으로 실시간 이벤트 분석 파이프라인을 구성할 때 핵심을 정리하면 이렇습니다.
원시 이벤트는 MergeTree에, 최신 상태는 ReplacingMergeTree에, 복합 집계는 AggregatingMergeTree + Materialized View에. 이 세 패턴이 ClickHouse OLAP 설계의 90%를 커버합니다. MV는 INSERT 트리거로 동작하고, 조회 시에는 -Merge 함수로 중간 상태를 완성해야 올바른 값이 나옵니다. Node.js에서는 행 단위 INSERT 대신 배치 버퍼 + 타임아웃 플러시가 안정적인 운영의 기본이며, 삽입 실패 시 re-push 로직을 반드시 갖춰야 합니다.
이 아키텍처를 실제 도입하기 전에 확인해볼 만한 질문들입니다.
MV가 정말 INSERT 시점에 트리거되는지 직접 검증했는가? 위 예시 SQL로 테이블과 MV를 생성하고, 수백만 행을 삽입한 직후 events_daily_agg를 쿼리해보면 이미 집계가 완료되어 있을 것입니다. 이 동작을 눈으로 확인하는 것이 출발점입니다.
uniq와 uniqExact의 차이가 의사결정에 영향을 주는가? DAU 지표를 ±2.2% 근사값으로 제공해도 되는지, 아니면 정확한 수치가 필요한지를 팀과 먼저 합의해야 합니다. 이 선택이 집계 테이블 스키마와 메모리 요구사항을 결정합니다.
배치 크기와 플러시 간격이 실제 트래픽 패턴에서 어떻게 동작하는가? BATCH_SIZE와 FLUSH_INTERVAL_MS를 고정값으로 운영하다가 트래픽 스파이크에 Too many parts를 만나는 경우가 흔합니다. 부하 테스트 환경에서 먼저 임계값을 파악해두는 것이 안전합니다.
참고 자료
- ClickHouse 공식 문서 — MergeTree 가이드
- ClickHouse 공식 — 스파스 프라이머리 인덱스 실전 가이드
- ClickHouse 공식 — Materialized View 롤업 시계열 가이드
- ClickHouse 공식 — Kafka 통합 문서
- ClickHouse 공식 JavaScript 클라이언트 문서
- npm — @clickhouse/client
- GitHub — clickhouse-js 공식 저장소
- ReplacingMergeTree in ClickHouse: How It Works and Why Deduplication Can Fail (GlassFlow)
- Limitations of ReplacingMergeTree and Materialized Views (GlassFlow)
- Altinity — ReplacingMergeTree Explained: The Good, The Bad, and The Ugly
- How Braze rebuilt its real-time analytics pipeline with ClickHouse Cloud
- How Mux uses ClickHouse as a real-time stream processing engine
- Simplifying Real-Time Data Pipelines: How ClickHouse Replaced Flink for Kafka Streams
- ClickHouse Parallel Replicas — 100B+ rows in under a second
- Index Sharding in ClickHouse Cloud: Petabyte-scale Indexing
- Top 5 ClickHouse Materialized Views for Real-Time Analytics
- Altinity Knowledge Base — How to pick ORDER BY / PRIMARY KEY / PARTITION BY
- OLAP databases: what's new and what's best in 2026 (Tinybird)