ClickHouse multi-table split
One pipeline can write to several ClickHouse tables, routing each record to the table for its kind — each table with its own columns, encoder, shard router, and part tuning. This is the connector-specific side of the multi-sink split concept: the split terminal is generic; here is how the pieces line up for ClickHouse.
Reach for it when your stream carries genuinely different record types (each
wanting its own columns and ORDER BY) and you'd rather the ETL fan them out
than a Null table + one MATERIALIZED VIEW per type. The trade-off — ETL-side
fan-out CPU and part control vs. server-side fan-out — is measured in the
multi-table split benchmark.
Configuration: a sinks: map
Replace the single sink: section with a sinks: map keyed by name. Each entry
is a full, ordinary ClickHouse sink section — its own table, columns,
format, shards, and batch/inflight — so per-table part sizing is tuned
independently. The map key is the name the chain resolves with ctx.sink(...).
sinks:
gauge:
clickhouse:
table: metrics_gauge
columns: [host, ts_ms, name, value] # this table's own columns/order
format: native
shards:
- replicas: ["${CLICKHOUSE_URL:-http://localhost:8123}"]
batch: { max_rows: 500000, linger: 1s }
text:
clickhouse:
table: metrics_text
columns: [host, ts_ms, name, text] # a different schema
format: native
shards:
- replicas: ["${CLICKHOUSE_URL:-http://localhost:8123}"]
# A low-volume table: linger longer to build bigger parts (at the cost of
# checkpoint lag — see the tuning note below).
batch: { max_rows: 500000, linger: 5s }
sink: and sinks: are mutually exclusive; a single-sink pipeline keeps using
sink: (addressed as "default"). See the
configuration reference. Every key
documented for a single ClickHouse sink applies
per-entry.
Wiring: one sink, encoder, and router per table
Each table is an ordinary ClickHouseSink — build it from its named config
section, mint its Native encoder (from its system.columns) and its shard
router, then install it with add_sink(name, ...). This is exactly the
single-sink wiring, N times:
// One sink per table, from the `sinks:` map.
let gauge_sink = etl::clickhouse::config::from_component_config(pipeline.config().sink_config("gauge")?)?;
let text_sink = etl::clickhouse::config::from_component_config(pipeline.config().sink_config("text")?)?;
// Each table's own columnar encoder (its Native schema) and shard router.
let gauge_router = gauge_sink.router::<GaugeFam>(host_key);
let text_router = text_sink.router::<TextFam>(host_key);
let gauge_enc = NativeEncoder::<GaugeFam>::new(pipeline.block_on(gauge_sink.native_schema())?);
let text_enc = NativeEncoder::<TextFam>::new(pipeline.block_on(text_sink.native_schema())?);
let report = pipeline
.add_sink("gauge", gauge_sink)?
.add_sink("text", text_sink)?
.chains(move |ctx| {
let mut split = chain::<SampleFam, _>(deserializer.clone())
.with_metrics(ctx.pipeline.clone(), "main")
.split(ChunkConfig::default(), ErrorPolicy::Skip);
let gauge = split.add::<GaugeFam, _, _>(gauge_enc.clone(), gauge_router.clone(), ctx.sink("gauge"));
let text = split.add::<TextFam, _, _>(text_enc.clone(), text_router.clone(), ctx.sink("text"));
split.route(move |s: Sample<'_>, out| match s.kind {
"gauge" => out.emit(gauge, GaugeRow { /* … */ }),
"text" => out.emit(text, TextRow { /* … */ }),
_ => {}
}).build()
})
.run(source)?;
The complete, compiling program is crates/etl/examples/multi_table_split.rs
(--features full,avro-fast). See the concept page for the
routing DSL walkthrough.
What carries over per table, unchanged
Each branch is a full ClickHouse sink, so everything on the ClickHouse sink page holds per table:
- Native (or RowBinary) encoding on pipeline threads, positional against
that table's
columns. Mixed formats across tables are fine — each sink chooses its own. - Direct-to-shard writes with
wait_end_of_query=1; a table's write is its own durable-ack point. The split holds the source batch until every table it touched has acked (see delivery). - Per-batch
insert_deduplication_tokenper table. The same window warning applies: setnon_replicated_deduplication_windowon every target table, or retries duplicate silently. - Replica rotation, circuit breaker, readiness probe per table.
/readyzreports connected only when every table's replicas probe healthy. - Metrics per table: each sink's
etl_sink_*series carry the sink name as theircomponentlabel, soetl_sink_records_total{component="gauge"}and{component="text"}are distinct.
Per-table part tuning — and the checkpoint-lag cost
The whole point of splitting in the ETL is per-table part control: size each
table's batch.max_rows/max_bytes/linger for its volume. A hot table
seals on rows in milliseconds; a rare table would trickle, so you raise its
linger to build bigger parts.
[!IMPORTANT]
lingeron a rare table is not free: because the source watermark is held until every table a batch touched has flushed, a long rare-tablelingerholds the offsets of every source batch that carried even one rare record — inflating checkpoint lag and restart replay. Keep rare-tablelingerwell undercheckpoint.stalled_fail_after, and prefer sealing on a modestmax_rowssolingeronly bites in genuine lulls. The full reasoning — including segregating the source by kind to dissolve the tension — is in the concept page's trade-off section.
distributed_check per table
Each table can carry its own distributed_check: the
router placement is verified against that table's Distributed DDL
independently, so a sharded multi-table deployment keeps every table aligned
with its cluster.
Related
- Multi-sink split — the concept, the routing DSL, and the at-least-once + part-size trade.
- ClickHouse sink — everything a single sink does, which each branch inherits.
- Multi-table split benchmark — split vs
Null+MV on a CPU-bound server. - Configuration reference — the
sinks:map.