DEV Community

Cover image for My idle ClickHouse was merging 11 million rows every 30 seconds
OtezVikentiy
OtezVikentiy

Posted on

My idle ClickHouse was merging 11 million rows every 30 seconds

I run a small self-hosted observability tool on the cheapest VPS I could find on purpose: 2 cores, 2 GB RAM, 20 GB SATA SSD. It ingests errors, traces and metrics from two low-traffic sites of mine. The stack is three containers — a Go app, PostgreSQL, and ClickHouse.

One evening docker stats showed ClickHouse sitting on 880 MB of its 1 GB limit and the box swapping, with basically zero events coming in. So I went looking for where the memory and disk had gone. The answer turned out to be a good lesson in how a database can spend almost all of its I/O talking to itself.

543 KB of my data, 579 MB of ClickHouse talking about ClickHouse

First thing I checked: how much data had my app actually stored versus how much ClickHouse had stored about itself.

  • My application database: 543 KB, 16k rows
  • The system database: 579 MB, 46.3M rows

Roughly a thousand to one. Disk was 12 GB used out of 20 — on a tool that had recorded half a megabyte of real telemetry.

The culprit was ClickHouse's own system logs, several of which have no TTL by default and therefore grow forever:

  • trace_log — 404 MB, 26M rows (the query profiler writes here; it's on by default, sampling once per second)
  • asynchronous_metric_log — 16.6M rows
  • text_log — 132 MB
  • plus query_log, latency_log

Only metric_log, processors_profile_log and part_log ship with a TTL. Everything else just accumulates.

Then I looked at the insert rate over 30 seconds:

  • trace_log — 227 rows/s
  • asynchronous_metric_log — 157 rows/s
  • text_log — 44 rows/s
  • my application — about 5 rows/s

98.8% of all inserts were ClickHouse narrating its own internals.

The part that's expensive beyond disk

Here's the number that made me stop. Over the same 30 seconds:

  • rows inserted: 16,222
  • rows merged: 11,007,643

That's a 1 : 678 ratio. For every row written, the engine rewrote 678 already-sitting rows.

The mechanics: MergeTree drops every insert into its own data part, then merges parts into bigger ones so reads stay fast. When the table is small this is cheap. But when a table holds 26M rows and the inserts are tiny and constant, each successive merge drags along more and more already-written data. In the limit, the engine spends most of its I/O shuffling old rows around, not storing new ones. That was my "60% disk busy, idle app" mystery.

The hypothesis that was wrong

I was sure I had it: the bloated trace_log drives the merges, the merges burn CPU, and the profiler samples the merge threads too — a nice closed loop. Test: measure CPU, TRUNCATE TABLE system.trace_log, measure again.

CPU didn't drop. ~69% before, ~81% after.

Honest caveat: my "after" window opened 60 seconds after deleting 400 MB, and part cleanup itself loads the machine, so some of that rise could be the TRUNCATE. But the point stood — one table didn't explain it.

So I disabled the heavy logs entirely and re-measured. Merges collapsed:

  • merged rows / 30s: 11,007,643 → 5,727
  • inserted rows / 30s: 16,222 → 35
  • disk: 2.7 GB freed out of 20

And CPU… barely moved: 69% → 61%.

The conclusion I had to accept: the system logs were a real disk and I/O problem, but they were not the CPU cause. Two separate symptoms I'd carelessly glued into one.

The trap I set for myself

While writing this up I'd claimed ClickHouse was "eating half the machine." It wasn't, and the mistake is a common one: docker stats reports CPU as a percentage of one core, not the whole machine. 60% in docker stats on a 2-core box is ~30% of the box. ps on the host confirmed it: 51.4% across two cores. If you debug container load, always cross-check against the host — it's easy to double your own problem on paper.

What actually moved the needle

Disabling the heavy logs, plus tuning for a small machine:

  • asynchronous_metrics_update_period_s: 1 → 60
  • metric_log: collect 1s → 30s, flush 7.5s → 60s, 3-day TTL
  • background pools: background_schedule_pool_size 128 → 8 (the default targets many-core servers), background_pool_size 4, the rest at 2
  • mark_cache_size: 5 GiB → 256 MiB. Yes — the default mark cache is five times the whole container's memory limit.

Result, averaged over 15 samples:

Before After
Container memory 842–920 MB 256 MB
Host memory used 1043 MB 779 MB
Load average 1.15 0.79
Disk used 12 GB 9.2 GB

The big win is memory: −66%. ClickHouse had been living at 85–90% of its cgroup limit and would OOM on any spike; now it sits at ~25% with real headroom. The mark cache is most of that.

The mistake that took prod down

Worth telling, because it's the one that stings. I applied the first tuning config straight to the live server without testing it locally. ClickHouse runs a sanity check at startup: number_of_free_entries_in_pool_to_execute_mutation (default 20) must not exceed background_pool_size × background_merges_mutations_concurrency_ratio. I'd set background_pool_size=4, the product was 8, the check failed → BAD_ARGUMENTS → the server refused to start. Monitoring was down for a few minutes.

The bug wasn't the number. It was the order of operations. The fix — which is also how I recovered — is to boot a throwaway container of the same version with the config locally, confirm it starts, then ship. It takes thirty seconds, which is less than a prod rollback. The rollback, thankfully, was clean: a .bak next to the file, restored in 8 seconds.

Don't tune your defaults down to the minimum

When I brought these settings into the project repo, someone reasonably asked: won't they hurt a 10-core / 10 GB server? Yes, they would. I checked all eleven settings; only three are universal:

  • random_page_cost=1.1 and effective_io_concurrency=200 — those are facts about SSDs, not about machine size
  • a TTL on the system logs — unbounded growth is bad on any hardware

The other eight actively hurt a big box: background_pool_size=4 caps merge parallelism and invites "too many parts", a shrunken mark_cache_size adds disk reads, shared_buffers=64MB is absurd at 10 GB, and so on. So the config ended up in two layers — a base compose file with only the universal settings, and an opt-in overlay for constrained machines:

docker compose -f docker-compose.yml -f docker-compose.small.yml up -d
Enter fullscreen mode Exit fullscreen mode

The rule I took away: if a setting is proportional to resources, it doesn't belong in your default config. Defaults should only hold what's true on 2 cores and on 20.

Takeaway

None of this is really about my project — it's about stock database defaults. ClickHouse out of the box assumes it has many cores, plenty of RAM, and that nobody's counting disk. When that isn't true, you can hand back a couple of gigabytes of disk and two-thirds of your memory with one config file. Just test it locally first.

The tool I was measuring is github.com/OtezVikentiy/gotcha — Go, self-hosted, speaks the Sentry SDK protocol and OTLP. Everything above is reproducible: the configs are in the repo and the box is described up top.

Top comments (0)