General
The ClickHouse "Too Many Parts" Crisis: Why Streaming Micro-Batch Inserts Silently Paralyze MergeTree Engines
calricon / field note
When engineering teams treat ClickHouse like an OLTP database by streaming low-latency micro-batch inserts, the MergeTree engine collapses under the dreaded "Too many parts (300)" exception. Discover why background merges fail to keep pace and how elite data architects engineer deterministic high-throughput ingestion.
The ClickHouse "Too Many Parts" Crisis: Why Streaming Micro-Batch Inserts Silently Paralyze MergeTree Engines
In real-time analytical data platforms, ClickHouse is widely celebrated for executing sub-second aggregations across billions of rows. Eager to achieve instantaneous observability, fraud detection, or event analytics, engineering teams frequently connect streaming Kafka consumers, microservice API logging hooks, or event dispatchers directly to ClickHouse tables. Operating under the familiar mental model of relational databases (where single-row or tiny micro-batch INSERT statements are routine), the application streams hundreds of concurrent writes per second into the database. Within hours, the ingestion pipeline crashes into an impenetrable brick wall: the database rejects all incoming write transactions with the fatal error "Code: 252. DB::Exception: Too many parts (300) in all data parts in table ... Merges are processing significantly slower than inserts." The write pipeline halts, upstream message queues overflow, and real-time dashboards go completely dark.
ClickHouse isn't Postgres. Every single INSERT statement creates a brand-new immutable directory on disk. If you stream hundreds of tiny writes per second, your background merge threads can't physically keep up—and ClickHouse will slam the emergency brake on your entire write pipeline.
The Physics of MergeTree: Why Every INSERT Creates a File System Landmine
To understand why ClickHouse chokes on streaming writes, engineers must discard the row-store assumptions of PostgreSQL and MySQL and confront the physical architecture of the MergeTree storage engine.
In ClickHouse, data is not appended to a single, monolithic open file. Instead, the storage architecture adheres to a strict, immutable part model:
- Atomic Part Creation: Every single
INSERTstatement executed against a MergeTree table—whether it contains one row or one million rows—creates a completely separate, immutable physical directory on disk containing columnar data files (.bin) and primary index marks (.mrk). This directory is called a "data part." - The Asynchronous Merge Cycle: To keep query scans fast, a background maintenance thread pool continuously identifies small adjacent parts and combines them into larger, compressed, and sorted parts via asynchronous merges.
- The Geometric Imbalance: A single background merge operation requires reading multiple parts from NVMe storage, decompressing column blocks, sorting records according to the primary key, re-compressing them, and writing a new part to disk. If your application sends 200 small
INSERTstatements per second, ClickHouse is forced to create 200 physical disk directories per second—a rate that mathematically outpaces the physical disk I/O and CPU throughput of any background merge thread pool.
To prevent the filesystem from collapsing under millions of tiny files and to protect query scan performance from suffocating under thousands of unmerged parts, ClickHouse enforces an emergency hard safety brake: parts_to_throw_insert (defaulting to 300 parts per partition). When the number of unmerged active parts in any single partition hits 300, ClickHouse instantly aborts all write transactions with Exception 252.
The Three Architectural Sins That Guarantee Part Saturation
1. Direct Microservice Ingestion (The OLTP Delusion)
The most ubiquitous root cause is microservices writing directly to ClickHouse. An API gateway or user tracking service receives an HTTP POST request and executes:
INSERT INTO events VALUES ('user_123', 'click', now());Even if throughput is modest (e.g., 50 requests per second), ClickHouse generates 3,000 distinct data parts every single minute. Within minutes, the merge engine falls hopelessly behind, and the table locks out all writes.
2. Catastrophic Partitioning Granularity (Over-Partitioning)
ClickHouse tables partition data according to the PARTITION BY expression. A common beginner blunder is partitioning by excessive temporal or categorical granularity:
PARTITION BY (toYYYYMMDD(event_time), user_id)
-- or:
PARTITION BY toStartOfHour(event_time)When a table is partitioned by hour or user ID, incoming writes are scattered across dozens or hundreds of distinct partitions simultaneously. Because ClickHouse merges parts strictly within the boundary of a single partition—and cannot merge parts across different partitions—each active partition maintains its own independent part counter. A modest write stream scattered across 50 hourly partitions multiplies the active part footprint by 50x, triggering Exception 252 across multiple partitions simultaneously.
3. Kafka Engine Misconfigurations
Teams frequently utilize the native Kafka table engine paired with a Materialized View to ingest events. However, if the Kafka consumer table is configured with tiny poll timeouts (stream_poll_timeout_ms = 100) or tiny batch sizes without proper buffering thresholds, the consumer commits micro-batches containing only 10 to 50 messages, hammering the target MergeTree table with hundreds of tiny writes per second.
Naive Streaming Ingestion vs. Calricon High-Throughput Batch Architecture
| Evaluation Dimension | Naive Streaming Model | Calricon Production Standard |
|---|---|---|
| Write Cadence | Continuous streaming (10 to 500 INSERTs/sec) | Metered batching (1 INSERT every 1 to 5 seconds) |
| Rows per INSERT Statement | 1 to 100 rows per transaction | 10,000 to 100,000 rows per transaction |
| Disk Part Generation Rate | 3,000 to 30,000 unmerged parts per hour | 720 to 3,600 large, pre-coalesced parts per hour |
| Background Merge Load | 100% disk I/O saturation; merges fail to keep pace | <15% background disk I/O; linear compaction |
| Write Rejection Resilience | High vulnerability to Exception 252 (Too many parts) | Absolute immunity; parts count maintained <30 |
The Calricon Production Playbook: Taming the MergeTree Engine
Eliminating the "Too many parts" crisis requires shifting ingestion from an unmetered push model to a disciplined, batched architecture.
Step 1: Leverage Native Asynchronous Inserts (async_insert)
Modern ClickHouse (v21.11+) includes native server-side batching via async_insert. Instead of writing a new part immediately upon receiving an INSERT, ClickHouse buffers incoming small inserts in server RAM and flushes them into a single, optimized data part once a configured size or time threshold is reached:
-- Enable server-side asynchronous batching in users.xml or per-session
SET async_insert = 1;
SET wait_for_async_insert = 1;
SET async_insert_busy_timeout_ms = 1000;
SET async_insert_max_data_size = 10485760; -- 10MB batch bufferWhen wait_for_async_insert = 1 is enabled, the client connection blocks until the batch is flushed to disk, ensuring client-side backpressure and data durability without creating millions of tiny parts.
Step 2: Enforce Coarse Temporal Partitioning
Audit your PARTITION BY expressions today. Never partition by day, hour, or high-cardinality keys unless retention policies strictly mandate dropping full days at a time. The gold standard for enterprise ClickHouse tables is monthly partitioning:
CREATE TABLE production_events (
event_id UUID,
event_time DateTime64(3),
user_id UInt64,
payload String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_id, event_time);Monthly partitioning consolidates incoming writes into one or two active partitions (current month and prior month transitions), allowing background merges to operate with maximum efficiency.
Step 3: Deploy Client-Side Buffer Collectors (Vector / FluentBit)
For microservice architectures generating millions of logs and metrics, do not allow application pods to connect directly to ClickHouse. Interpose a lightweight, high-performance edge collector (such as Vector or FluentBit). Configure the collector to accumulate records in memory and dispatch bulk batches:
# Example: Vector ClickHouse Sink Configuration
[sinks.clickhouse_events]
type = "clickhouse"
inputs = ["transform_events"]
endpoint = "http://clickhouse-cluster:8123"
database = "analytics"
table = "production_events"
batch.max_bytes = 10485760 # 10 MB batches
batch.timeout_secs = 2 # Flush every 2 seconds maximumStep 4: Instrument Prometheus Alerts on Active Part Ratios
Never wait for ClickHouse to throw Exception 252. Monitor active part counts per partition using system.parts:
- alert: ClickHousePartsPerPartitionApproachingLimit
expr: max(count() by (table, partition, instance)) by (table, instance) from system.parts where active = 1 > 200
for: 3m
labels:
severity: warning
annotations:
summary: "ClickHouse table {{ $labels.table }} on {{ $labels.instance }} has over 200 active parts in a partition; approaching 300 limit."
- alert: ClickHouseMergeSpeedLagging
expr: rate(ClickHouseProfileEvents_MergedRows[5m]) < rate(ClickHouseProfileEvents_InsertedRows[5m])
for: 10m
labels:
severity: critical
annotations:
summary: "ClickHouse ingestion rate exceeds background merge compaction throughput."Stop Treating Analytical Engines Like Transactional Sinks
ClickHouse is a Formula 1 racing engine engineered to execute complex vector math across columnar data at memory-bus speeds. But when you feed it a continuous stream of single-row writes, you are forcing that racing engine to drive through a congested parking lot.
Audit your ingestion pipelines today. Turn on async_insert, replace fine-grained daily partitions with monthly boundaries, interpose batching buffers between your microservices and the database, and monitor your active part ceilings. In analytical data architecture, speed is not achieved by writing instantly; it is unlocked by writing in disciplined, massive, and unstoppable waves.