<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: OtezVikentiy</title>
    <description>The latest articles on DEV Community by OtezVikentiy (@otezvikentiy).</description>
    <link>https://dev.to/otezvikentiy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4046100%2Fb03a824c-f541-4d76-93b9-ee52f7c2fe05.png</url>
      <title>DEV Community: OtezVikentiy</title>
      <link>https://dev.to/otezvikentiy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/otezvikentiy"/>
    <language>en</language>
    <item>
      <title>My idle ClickHouse was merging 11 million rows every 30 seconds</title>
      <dc:creator>OtezVikentiy</dc:creator>
      <pubDate>Fri, 24 Jul 2026 21:12:14 +0000</pubDate>
      <link>https://dev.to/otezvikentiy/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds-2d4i</link>
      <guid>https://dev.to/otezvikentiy/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds-2d4i</guid>
      <description>&lt;p&gt;I run a small self-hosted observability tool on the cheapest VPS I could find on purpose: &lt;strong&gt;2 cores, 2 GB RAM, 20 GB SATA SSD&lt;/strong&gt;. It ingests errors, traces and metrics from two low-traffic sites of mine. The stack is three containers — a Go app, PostgreSQL, and ClickHouse.&lt;/p&gt;

&lt;p&gt;One evening &lt;code&gt;docker stats&lt;/code&gt; showed ClickHouse sitting on &lt;strong&gt;880 MB of its 1 GB limit&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  543 KB of my data, 579 MB of ClickHouse talking about ClickHouse
&lt;/h2&gt;

&lt;p&gt;First thing I checked: how much data had my app actually stored versus how much ClickHouse had stored about &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My application database: &lt;strong&gt;543 KB, 16k rows&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;system&lt;/code&gt; database: &lt;strong&gt;579 MB, 46.3M rows&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly a thousand to one. Disk was &lt;strong&gt;12 GB used out of 20&lt;/strong&gt; — on a tool that had recorded half a megabyte of real telemetry.&lt;/p&gt;

&lt;p&gt;The culprit was ClickHouse's own system logs, several of which &lt;strong&gt;have no TTL by default&lt;/strong&gt; and therefore grow forever:&lt;/p&gt;

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

&lt;p&gt;Only &lt;code&gt;metric_log&lt;/code&gt;, &lt;code&gt;processors_profile_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt; ship with a TTL. Everything else just accumulates.&lt;/p&gt;

&lt;p&gt;Then I looked at the insert rate over 30 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trace_log&lt;/code&gt; — 227 rows/s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;asynchronous_metric_log&lt;/code&gt; — 157 rows/s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;text_log&lt;/code&gt; — 44 rows/s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;my application — about 5 rows/s&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;98.8% of all inserts were ClickHouse narrating its own internals.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that's expensive beyond disk
&lt;/h2&gt;

&lt;p&gt;Here's the number that made me stop. Over the same 30 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rows &lt;strong&gt;inserted&lt;/strong&gt;: 16,222&lt;/li&gt;
&lt;li&gt;rows &lt;strong&gt;merged&lt;/strong&gt;: 11,007,643&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a &lt;strong&gt;1 : 678&lt;/strong&gt; ratio. For every row written, the engine rewrote 678 already-sitting rows.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hypothesis that was wrong
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;CPU didn't drop.&lt;/strong&gt; ~69% before, ~81% after.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I disabled the heavy logs entirely and re-measured. Merges collapsed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;merged rows / 30s: &lt;strong&gt;11,007,643 → 5,727&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;inserted rows / 30s: &lt;strong&gt;16,222 → 35&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;disk: &lt;strong&gt;2.7 GB freed&lt;/strong&gt; out of 20&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And CPU… barely moved: 69% → 61%.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The trap I set for myself
&lt;/h2&gt;

&lt;p&gt;While writing this up I'd claimed ClickHouse was "eating half the machine." It wasn't, and the mistake is a common one: &lt;strong&gt;&lt;code&gt;docker stats&lt;/code&gt; reports CPU as a percentage of one core, not the whole machine.&lt;/strong&gt; 60% in &lt;code&gt;docker stats&lt;/code&gt; on a 2-core box is ~30% of the box. &lt;code&gt;ps&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the needle
&lt;/h2&gt;

&lt;p&gt;Disabling the heavy logs, plus tuning for a small machine:&lt;/p&gt;

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

&lt;p&gt;Result, averaged over 15 samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container memory&lt;/td&gt;
&lt;td&gt;842–920 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;256 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host memory used&lt;/td&gt;
&lt;td&gt;1043 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;779 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load average&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.79&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk used&lt;/td&gt;
&lt;td&gt;12 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that took prod down
&lt;/h2&gt;

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

&lt;p&gt;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, &lt;em&gt;then&lt;/em&gt; ship. It takes thirty seconds, which is less than a prod rollback. The rollback, thankfully, was clean: a &lt;code&gt;.bak&lt;/code&gt; next to the file, restored in 8 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't tune your defaults down to the minimum
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;random_page_cost=1.1&lt;/code&gt; and &lt;code&gt;effective_io_concurrency=200&lt;/code&gt; — those are facts about SSDs, not about machine size&lt;/li&gt;
&lt;li&gt;a TTL on the system logs — unbounded growth is bad on any hardware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other eight actively hurt a big box: &lt;code&gt;background_pool_size=4&lt;/code&gt; caps merge parallelism and invites "too many parts", a shrunken &lt;code&gt;mark_cache_size&lt;/code&gt; adds disk reads, &lt;code&gt;shared_buffers=64MB&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.small.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The tool I was measuring is &lt;a href="https://github.com/OtezVikentiy/gotcha" rel="noopener noreferrer"&gt;github.com/OtezVikentiy/gotcha&lt;/a&gt; — 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.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>devops</category>
      <category>database</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
