Case study

665 GiB of dictionaries: when ClickHouse dictionaries stop scaling

Nine hashed dictionaries quietly grew to two thirds of a terabyte per node, and every reload tried to build a second copy. How we found that only five views used them, and replaced them with joins that read 17 granules out of 282,102.

3 min read · Markdown

TL;DR

The symptom

Reports on the cluster started failing with MEMORY_LIMIT_EXCEEDED, in waves. Nothing obvious had changed: no new heavy queries, no data spike. system.dictionaries showed the cause. Two dictionaries had been stuck in LOADED_AND_RELOADING for three days, and the total bytes_allocated across nine dictionaries was 663.5 GiB on each node, against a server memory limit of 906 GiB (0.9 × RAM).

The mechanism

The dictionaries mapped product and size IDs to names, categories and attributes.

The growth explains why it worked before. Over two years the number of products grew about eight times, and one product dictionary went from 17 GiB to 134 GiB. Hashed dictionaries scale with the key space, not with what anyone actually looks up.

Who actually used them

Before touching anything, we needed the real consumers, not the ones the repository implied:

So 663 GiB of RAM per node was spent to look up names for a few dozen rows per call.

The fix

A precedent existed on another cluster. Instead of a dictionary lookup per row, each view first collects the IDs it needs, then joins only those rows of the source:

WITH chrt AS (
    SELECT chrt_id, subject_name, brand_name /* … */
    FROM chrt_source FINAL
    WHERE is_deleted = 0
      AND chrt_id IN (SELECT DISTINCT chrt_id FROM report_rows)
)
SELECT r.*, coalesce(c.subject_name, '') AS subject_name /* … */
FROM report_rows AS r
ANY LEFT JOIN chrt AS c USING (chrt_id)

The source table is ordered by the ID, so the IN subquery prunes by primary key even on ClickHouse 23.8: EXPLAIN indexes = 1 showed 17 granules read out of 282,102. The join costs milliseconds and no standing memory.

The five views were rewritten and benchmarked against the old ones on production data, and the dictionaries were dropped. About 540 GiB of RAM per node came back at once, and the memory-limit storm ended. The last dictionary, 126 GiB used by a single report, was left for a second phase.

A trick that didn't work. A DIRECT layout over a view on numbers() avoids holding the dictionary in memory, but it relies on predicate pushdown into numbers() that arrived only in 23.11. On 23.8 it was a dead end.

Takeaways


All writing · A similar problem? contact@ivanblinov.com