Tracking Memory Pressure with system.metrics
ClickHouse OOM kills are almost never a surprise in hindsight — system.metrics and system.asynchronous_metrics expose the exact climb toward the ceiling, if you are sampling the right gauges. This how-to reads MemoryTracking against max_server_memory_usage, correlates it with concurrent Query count and background pool depth, and turns those into alert thresholds that fire before the kernel does.
The core distinction to internalize: system.metrics holds instantaneous gauges the server updates continuously (memory in use right now, queries running right now), while system.asynchronous_metrics holds values sampled on a background interval (OS memory available, jemalloc arena sizes). Memory pressure is a story told by both — server-tracked allocation from the first, and the OS-level reality and allocator overhead from the second.
Prerequisites
How the memory budget is layered
ClickHouse allocates within a server-wide ceiling, max_server_memory_usage, which is itself a fraction of physical RAM (default ratio 0.9). The live total is MemoryTracking in system.metrics. Each query draws against a per-query ceiling, max_memory_usage, inside that server budget. The space between MemoryTracking and the server ceiling is the headroom new queries and merges can consume; the space above the ceiling is what the OS keeps for the page cache and everything non-ClickHouse.
Step 1 — Read the live memory gauge
MemoryTracking is the server’s own accounting of every allocation it controls. It is the single most important pressure signal.
SELECT
metric,
formatReadableSize(value) AS readable,
value
FROM system.metrics
WHERE metric IN (
'MemoryTracking', -- total server-tracked bytes
'Query', -- queries running right now
'BackgroundMergesAndMutationsPoolTask', -- active merges + mutations
'BackgroundFetchesPoolTask' -- active replica fetches
);
Expected output on a busy but healthy node:
┌─metric────────────────────────────────┬─readable──┬───────value─┐
│ MemoryTracking │ 78.00 GiB │ 83751862272 │
│ Query │ 42.00 B │ 42 │
│ BackgroundMergesAndMutationsPoolTask │ 11.00 B │ 11 │
│ BackgroundFetchesPoolTask │ 3.00 B │ 3 │
└───────────────────────────────────────┴───────────┴─────────────┘
Query and the two background counters are the why behind MemoryTracking: a memory climb driven by a spike in Query is user load, while a climb with flat Query but rising BackgroundMergesAndMutationsPoolTask is merge pressure — often a symptom of the part-count problems tracked in part & merge health monitoring.
Step 2 — Read the ceiling and compute utilization
max_server_memory_usage is the ceiling MemoryTracking is racing toward. Read it live and compute the ratio in one query.
SELECT
(SELECT value FROM system.metrics WHERE metric = 'MemoryTracking') AS tracked,
(SELECT toUInt64(value) FROM system.asynchronous_metrics
WHERE metric = 'CGroupMemoryTotal') AS cgroup_total,
(SELECT toUInt64(value) FROM system.settings WHERE name = 'max_server_memory_usage') AS server_cap,
round(100 * tracked / nullIf(server_cap, 0), 1) AS pct_of_cap;
When max_server_memory_usage is 0 it is derived from the ratio setting max_server_memory_usage_to_ram_ratio (default 0.9) against physical or cgroup RAM, so fall back to 0.9 * cgroup_total for the effective ceiling. pct_of_cap is the number your alerts key on: sustained above ~85% means new large queries will start failing with MEMORY_LIMIT_EXCEEDED.
Step 3 — Cross-check against OS and allocator reality
The server’s own accounting can diverge from what the OS sees because of allocator overhead. system.asynchronous_metrics exposes both the OS view and jemalloc internals.
SELECT metric, formatReadableSize(value) AS readable
FROM system.asynchronous_metrics
WHERE metric IN (
'OSMemoryAvailable', -- RAM the OS reports as free for allocation
'MemoryResident', -- RSS of the clickhouse process
'jemalloc.resident', -- pages jemalloc holds resident
'jemalloc.allocated', -- bytes actually allocated by jemalloc
'jemalloc.retained' -- unmapped-but-reserved; harmless overhead
)
ORDER BY metric;
A healthy node shows MemoryResident modestly above MemoryTracking (allocator overhead), jemalloc.retained accounting for most of the gap, and OSMemoryAvailable comfortably positive. The danger sign is OSMemoryAvailable collapsing toward zero while MemoryTracking looks fine — that means untracked memory (dictionaries, the page cache under pressure, or a memory leak) is consuming RAM the server is not counting, and the kernel OOM killer, not ClickHouse’s soft limit, will act first.
Step 4 — Bound individual queries
Server-wide limits protect the node; per-query limits protect the node from one query. Set max_memory_usage so a single runaway aggregation cannot claim the whole budget.
-- Cap one query at 20 GiB; it fails with MEMORY_LIMIT_EXCEEDED rather than starving peers.
SELECT event_type, uniqExact(user_id) AS uniques
FROM analytics.events_raw
GROUP BY event_type
SETTINGS max_memory_usage = 21474836480; -- 20 GiB
Set this as a profile default for interactive roles rather than per statement. A sane arrangement on a 115 GiB server cap is max_memory_usage around 15–25% of the cap, leaving room for concurrency. Queries that legitimately need more should spill to disk with max_bytes_before_external_group_by and max_bytes_before_external_sort rather than raising their memory ceiling.
Step 5 — Alert on sustained pressure
Turn the utilization ratio into a trend alert so a brief spike from one heavy query does not page anyone.
-- Fire when the node sits above 85% of its effective ceiling for a sustained window.
SELECT host,
max(mem_pct) AS worst_pct,
avg(mem_pct) AS avg_pct
FROM (
SELECT host, sampled_at,
100 * memory_tracking / effective_cap AS mem_pct
FROM monitoring.memory_pressure_history
WHERE sampled_at > now() - INTERVAL 3 MINUTE
)
GROUP BY host
HAVING avg_pct > 85
ORDER BY worst_pct DESC;
Practical thresholds: warn at 80% of the effective ceiling sustained for two minutes, page at 90%, and page immediately if OSMemoryAvailable drops below roughly 5% of total RAM regardless of MemoryTracking — the OS-level signal catches the untracked-memory case the server gauge misses.
Pair the utilization alert with a concurrency signal so the on-call knows the shape of the incident before opening a laptop. A pressure alert that arrives with Query at 3× its baseline is a load spike — shed traffic or scale out. The same alert with flat Query but elevated BackgroundMergesAndMutationsPoolTask is merge pressure — reduce insert-side part creation or throttle merges. Attaching that second dimension to the page turns a generic “memory high” into an actionable “memory high because of X,” which is the difference between a two-minute and a two-hour mitigation.
Verification
Confirm your understanding of the node’s live pressure with a single consolidated read, and check system.events for allocation failures that already happened:
SELECT
(SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking') AS tracked,
(SELECT formatReadableSize(value) FROM system.asynchronous_metrics WHERE metric = 'OSMemoryAvailable') AS os_free,
(SELECT value FROM system.events WHERE event = 'QueryMemoryLimitExceeded') AS query_ooms;
-- query_ooms climbing between reads means queries are already hitting max_memory_usage.
A query_ooms counter that increments between samples confirms queries are being rejected at their per-query ceiling — the soft limit is doing its job, but it is time to either raise headroom or investigate the offending query shapes in the query log.
Gotchas and edge cases
MemoryTrackingundercounts. It tracks what the server allocates through its own arenas, not the OS page cache, mmap’d dictionaries, or external libraries. Always pair it withOSMemoryAvailable— a node can OOM withMemoryTrackingwell under the cap.max_server_memory_usage = 0does not mean unlimited. Zero means “derive from the ratio,” so the effective ceiling ismax_server_memory_usage_to_ram_ratiotimes RAM. Reading the setting alone will mislead you; compute the effective value.- cgroup limits override physical RAM in containers. In Kubernetes the ratio applies to the cgroup limit, not host RAM. Sample
CGroupMemoryTotalfromsystem.asynchronous_metrics, not/proc/meminfo, or your utilization math will be wrong. jemalloc.retainedis not a leak. Retained memory is unmapped address space jemalloc keeps for reuse; it inflates virtual size but not real pressure. Do not alert on it — alert onjemalloc.allocatedandMemoryResident.- Merges and fetches during replica catch-up spike memory. A recovering replica running many concurrent fetches and merges can breach the ceiling even with no user queries; watch this alongside the replication runbook in monitoring replication lag with system.replicas.
Related
- Replication & availability monitoring — the parent runbook where recovery-time memory spikes originate.
- Monitoring replication lag with system.replicas — the catch-up workload that drives background memory pressure.
- Part & merge health monitoring — merge pressure as a root cause of a rising MemoryTracking with flat query load.