<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building Gotcha]]></title><description><![CDATA[Building Gotcha]]></description><link>https://buildinggotcha.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a63d99ade88e09b032e62bf/e7fdd750-a903-4e30-9763-2398633c7f4d.png</url><title>Building Gotcha</title><link>https://buildinggotcha.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 23 Sep 2026 09:26:27 GMT</lastBuildDate><atom:link href="https://buildinggotcha.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My idle ClickHouse was merging 11 million rows every 30 seconds]]></title><description><![CDATA[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. T]]></description><link>https://buildinggotcha.hashnode.dev/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds</link><guid isPermaLink="true">https://buildinggotcha.hashnode.dev/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds</guid><category><![CDATA[ClickHouse]]></category><category><![CDATA[Devops]]></category><category><![CDATA[database]]></category><category><![CDATA[#selfhosted]]></category><dc:creator><![CDATA[OtezVikentiy]]></dc:creator><pubDate>Fri, 24 Jul 2026 21:46:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a63d99ade88e09b032e62bf/2cc8af66-2c91-4ae2-a639-8ebfda9c019d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I run a small self-hosted observability tool on the cheapest VPS I could find on purpose: <strong>2 cores, 2 GB RAM, 20 GB SATA SSD</strong>. It ingests errors, traces and metrics from two low-traffic sites of mine. The stack is three containers — a Go app, PostgreSQL, and ClickHouse.</p>
<p>One evening <code>docker stats</code> showed ClickHouse sitting on <strong>880 MB of its 1 GB limit</strong> 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.</p>
<h2>543 KB of my data, 579 MB of ClickHouse talking about ClickHouse</h2>
<p>First thing I checked: how much data had my app actually stored versus how much ClickHouse had stored about <em>itself</em>.</p>
<ul>
<li>My application database: <strong>543 KB, 16k rows</strong></li>
<li>The <code>system</code> database: <strong>579 MB, 46.3M rows</strong></li>
</ul>
<p>Roughly a thousand to one. Disk was <strong>12 GB used out of 20</strong> — on a tool that had recorded half a megabyte of real telemetry.</p>
<p>The culprit was ClickHouse's own system logs, several of which <strong>have no TTL by default</strong> and therefore grow forever:</p>
<ul>
<li><code>trace_log</code> — 404 MB, 26M rows (the query profiler writes here; it's on by default, sampling once per second)</li>
<li><code>asynchronous_metric_log</code> — 16.6M rows</li>
<li><code>text_log</code> — 132 MB</li>
<li>plus <code>query_log</code>, <code>latency_log</code></li>
</ul>
<p>Only <code>metric_log</code>, <code>processors_profile_log</code> and <code>part_log</code> ship with a TTL. Everything else just accumulates.</p>
<p>Then I looked at the insert rate over 30 seconds:</p>
<ul>
<li><code>trace_log</code> — 227 rows/s</li>
<li><code>asynchronous_metric_log</code> — 157 rows/s</li>
<li><code>text_log</code> — 44 rows/s</li>
<li><strong>my application — about 5 rows/s</strong></li>
</ul>
<p><strong>98.8% of all inserts were ClickHouse narrating its own internals.</strong></p>
<h2>The part that's expensive beyond disk</h2>
<p>Here's the number that made me stop. Over the same 30 seconds:</p>
<ul>
<li>rows <strong>inserted</strong>: 16,222</li>
<li>rows <strong>merged</strong>: 11,007,643</li>
</ul>
<p>That's a <strong>1 : 678</strong> ratio. For every row written, the engine rewrote 678 already-sitting rows.</p>
<p>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.</p>
<h2>The hypothesis that was wrong</h2>
<p>I was sure I had it: the bloated <code>trace_log</code> drives the merges, the merges burn CPU, and the profiler samples the merge threads too — a nice closed loop. Test: measure CPU, <code>TRUNCATE TABLE system.trace_log</code>, measure again.</p>
<p><strong>CPU didn't drop.</strong> ~69% before, ~81% after.</p>
<p>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.</p>
<p>So I disabled the heavy logs entirely and re-measured. Merges collapsed:</p>
<ul>
<li>merged rows / 30s: <strong>11,007,643 → 5,727</strong></li>
<li>inserted rows / 30s: <strong>16,222 → 35</strong></li>
<li>disk: <strong>2.7 GB freed</strong> out of 20</li>
</ul>
<p>And CPU… barely moved: 69% → 61%.</p>
<p>The conclusion I had to accept: the system logs were a <strong>real disk and I/O problem</strong>, but they were <strong>not the CPU cause</strong>. Two separate symptoms I'd carelessly glued into one.</p>
<h2>The trap I set for myself</h2>
<p>While writing this up I'd claimed ClickHouse was "eating half the machine." It wasn't, and the mistake is a common one: <strong><code>docker stats</code> reports CPU as a percentage of one core, not the whole machine.</strong> 60% in <code>docker stats</code> on a 2-core box is ~30% of the box. <code>ps</code> 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.</p>
<h2>What actually moved the needle</h2>
<p>Disabling the heavy logs, plus tuning for a small machine:</p>
<ul>
<li><code>asynchronous_metrics_update_period_s</code>: 1 → 60</li>
<li><code>metric_log</code>: collect 1s → 30s, flush 7.5s → 60s, 3-day TTL</li>
<li>background pools: <code>background_schedule_pool_size</code> <strong>128 → 8</strong> (the default targets many-core servers), <code>background_pool_size</code> 4, the rest at 2</li>
<li><code>mark_cache_size</code>: <strong>5 GiB → 256 MiB</strong>. Yes — the default mark cache is five times the whole container's memory limit.</li>
</ul>
<p>Result, averaged over 15 samples:</p>
<table>
<thead>
<tr>
<th></th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Container memory</td>
<td>842–920 MB</td>
<td><strong>256 MB</strong></td>
</tr>
<tr>
<td>Host memory used</td>
<td>1043 MB</td>
<td><strong>779 MB</strong></td>
</tr>
<tr>
<td>Load average</td>
<td>1.15</td>
<td><strong>0.79</strong></td>
</tr>
<tr>
<td>Disk used</td>
<td>12 GB</td>
<td><strong>9.2 GB</strong></td>
</tr>
</tbody></table>
<p>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.</p>
<h2>The mistake that took prod down</h2>
<p>Worth telling, because it's the one that stings. I applied the first tuning config <strong>straight to the live server without testing it locally</strong>. ClickHouse runs a sanity check at startup: <code>number_of_free_entries_in_pool_to_execute_mutation</code> (default 20) must not exceed <code>background_pool_size × background_merges_mutations_concurrency_ratio</code>. I'd set <code>background_pool_size=4</code>, the product was 8, the check failed → <code>BAD_ARGUMENTS</code> → the server refused to start. Monitoring was down for a few minutes.</p>
<p>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, <em>then</em> ship. It takes thirty seconds, which is less than a prod rollback. The rollback, thankfully, was clean: a <code>.bak</code> next to the file, restored in 8 seconds.</p>
<h2>Don't tune your defaults down to the minimum</h2>
<p>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:</p>
<ul>
<li><code>random_page_cost=1.1</code> and <code>effective_io_concurrency=200</code> — those are facts about SSDs, not about machine size</li>
<li>a TTL on the system logs — unbounded growth is bad on any hardware</li>
</ul>
<p>The other eight actively hurt a big box: <code>background_pool_size=4</code> caps merge parallelism and invites "too many parts", a shrunken <code>mark_cache_size</code> adds disk reads, <code>shared_buffers=64MB</code> 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:</p>
<pre><code>docker compose -f docker-compose.yml -f docker-compose.small.yml up -d
</code></pre>
<p>The rule I took away: <strong>if a setting is proportional to resources, it doesn't belong in your default config.</strong> Defaults should only hold what's true on 2 cores and on 20.</p>
<h2>Takeaway</h2>
<p>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.</p>
<p>The tool I was measuring is <a href="https://github.com/OtezVikentiy/gotcha">github.com/OtezVikentiy/gotcha</a> — 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.</p>
<p><em>This was originally published <a href="https://dev.to/otezvikentiy/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds-2d4i">on my dev.to</a>.</em></p>
]]></content:encoded></item></channel></rss>