Nightly uniqExact report hits the memory limit on a distributed join
A 3-shard, 2-replica cluster (64 GB RAM per node, max_memory_usage = 30 GiB, disk spilling disabled) runs a nightly report. It joins the Distributed table pageviews (1.8 billion rows; columns event_time, user_id, page_id, url_raw, device, referrer, ...) to a local table pages (12 million rows; page_id, page_url) and computes count() and uniqExact(user_id) grouped by page_url over the last 30 days. The job fails with Memory limit (for query) exceeded: would use 34.20 GiB. It reads roughly 700 GB from disk to produce 45,000 result rows and takes 6 minutes on nights when it succeeds. pageviews is PARTITION BY toYYYYMM(event_time) with ORDER BY (event_time, user_id); pages is a plain MergeTree replicated identically on all shards. Running the same report for a single day finishes in seconds, which makes the platform team suspect the join is not the real problem.
1. Which operator is actually consuming the memory, and what changes would you make - in what order - to get the 30-day report under the limit?
Read the worked answer
The 30-day window is the trigger and the aggregation is the consumer. uniqExact(user_id) inside a GROUP BY page_url keeps an exact hash set of distinct users for every group; over 30 days those sets are large, and the aggregation state is what reaches 34 GiB. The join is not the main culprit - that is what the fast single-day run is telling you, since the join shape is identical.
Order of attack:
- Filter earlier. Move
event_time, device and bot exclusions intoPREWHEREand confirm withEXPLAIN indexes = 1that only the intended monthly partitions are read. Fewer input rows means smaller aggregation states. - Shrink the grouping key before the join. Group by
page_idinstead of the widepage_urlstring, then join the small result topagesat the end. The join then operates on thousands of rows instead of billions, and the hash table holds integers rather than URLs. - Right-size the distinct count.
uniqCombined(12)costs a fraction of the memory for roughly 0.5% error. If the number must be auditable, keepuniqExactbut only after pre-aggregation has cut the input. - Enable spill.
max_bytes_before_external_group_by = 12000000000(about 40% of the 30 GiB limit) and the matchingmax_bytes_before_external_sort. Remember that a query can have a spilling group-by and a spilling sort at the same time, so do not hand both operators the full budget. - Lower
max_threadsfor this query (e.g. 8): each thread keeps its own aggregation buffers, so fewer threads means a lower peak.
Spilling costs latency, which is acceptable for a nightly batch; the goal is to finish reliably, not quickly.
2. Why does a query reading 700 GB produce only 45,000 rows, and how would you diagnose and reduce that ratio?
Read the worked answer
A read-to-output ratio like this means columns or partitions are being touched that never reach the result. Diagnose before changing anything: system.query_log exposes read_rows, read_bytes and read_columns per query, and EXPLAIN indexes = 1 shows which partitions and granules were selected.
Typical culprits, in the order worth checking:
- Wide reads from a subquery or view. A
SELECT *in an inner query materialisesurl_raw,referrer, headers and other columns that the outer query discards. List columns explicitly everywhere. - Filter placement. A date predicate hiding inside
HAVING, a joinONclause, or aWHEREabove an aggregation cannot prune partitions. It must be a top-levelWHERE/PREWHEREonevent_timefor monthly partition pruning to work. - String cardinality.
page_urlwith 45,000 distinct values across billions of rows should beLowCardinality(String); that cuts both bytes read and the memory of the hash table holding the group keys. - Repeated dimension reads.
pagesis 12M rows and nearly static. Loadingpage_id -> page_urlinto aDictionaryand usingdictGetremoves the join entirely and removes the 12M-row hash table from the memory budget. - No pre-aggregation. If the report only needs daily uniques, an
AggregatingMergeTreekeyed by (day, page_id) withuniqCombinedState(user_id)makes the 30-day query merge 30 states per page instead of scanning raw events. Sketch states are composable, souniqCombinedMergeover the month is consistent with the daily numbers.
Start with column lists, filter placement and LowCardinality - cheap, reversible, and usually most of the win - then re-measure before committing to a materialized view.
3. What are the distributed-join semantics you must confirm here, and when would `GLOBAL JOIN` be required?
Read the worked answer
pageviews is Distributed, so the join executes on each shard. What each shard sees for pages determines correctness.
pagesis a local table replicated on every node (your case). Each shard joins its own slice ofpageviewsto the fullpagestable. Results are correct. The cost is that the 12M-row hash table is built three times, once per shard, which matters a lot when you are already near the memory limit - another argument for replacing the join with a dictionary lookup.pagesas aDistributedtable. Then each shard would join only against its own third of the dimension and silently drop matches. This is the case whereGLOBAL JOINis required: the right-hand side is built once on the initiator and shipped to every shard.distributed_product_mode(defaultdeny) governs related IN/JOIN subquery shapes and will reject some of them outright.- Alternatives to shipping.
join_algorithm = 'direct'with aDictionaryturns the join into a local key-value lookup on every node.grace_hashandpartial_mergespill to disk when the build side cannot fit in RAM;partial_mergerequires sorted inputs, which yourORDER BYdoes not provide onpage_id.
Practical checks: run EXPLAIN on the distributed query to see which side is shipped, confirm filters are pushed down to shards rather than applied on the initiator after the join, and verify shard-level row counts sum to the expected total. Given the memory profile, moving page_id -> page_url into a dictionary is the change that removes the join from the critical path altogether.