ClickHouse Charges Memory Through a Hierarchy of Trackers
ClickHouse charges every allocation to a chain of memory trackers — global, user, query, thread — each with its own hard limit, walked one link at a time.
TL;DR: Every allocation in ClickHouse is charged to a chain of
MemoryTrackerobjects:total_memory_tracker(global) → the user’s tracker → the query’s tracker → the thread’s tracker. Each link has its own hard limit, and the allocation is offered to each link in turn. A limit hit anywhere in the chain aborts the allocation with aMEMORY_LIMIT_EXCEEDEDexception.
A columnar database that runs 200 concurrent queries on 64 cores has a bookkeeping problem that a row store does not. It has to answer four questions at the same time:
- Is the server as a whole about to run out of RAM?
- Is this user about to run out of RAM?
- Is this one query about to run out of RAM?
- And, at the lowest level, which thread should be charged for the bytes?
Answering them with four independent counters is the obvious approach and it is
wrong, for two reasons. First, a query’s memory is not owned by one thread: a
SELECT fans out across a thread pool, blocks are handed between threads, and
the final aggregation happens on yet another one. Second, the four answers are
not independent — a query’s limit is meaningless if the server’s limit is
lower.
ClickHouse’s answer is a tree of counters with a parent pointer, where one allocation walks up the tree and is offered to every level.
The four levels
The levels are enumerated, and the enumeration order is the tree order — a lower value is closer to the root:
// src/Common/VariableContext.h:7
enum class VariableContext : uint8_t
{
Global = 0,
User, /// Group of processes
Process, /// For example, a query or a merge
Thread, /// A thread of a process
Max, /// size of the enum, not a real level
};There are exactly two global-scope trackers, created at static-init time:
// src/Common/MemoryTracker.cpp:160
MemoryTracker total_memory_tracker(nullptr, VariableContext::Global);
MemoryTracker background_memory_tracker(&total_memory_tracker, VariableContext::User, false);total_memory_tracker is the server total: it has no parent, and its hard
limit is the server’s max_server_memory_usage. background_memory_tracker
is a User-level tracker for work that is not a user query — merges,
mutations, and other background jobs:
// src/Interpreters/ThreadStatusExt.cpp:275
ThreadGroupPtr ThreadGroup::createForMergeMutate(ContextPtr storage_context)
{
const Int32 os_threads_nice_value = storage_context->getServerSettings()[ServerSetting::os_threads_nice_value_merge_mutate];
auto group = create(storage_context, os_threads_nice_value);
group->memory_tracker.setDescription("Background process (mutate/merge)");
group->memory_tracker.setParent(&background_memory_tracker);
return group;
}Note the third constructor argument false on background_memory_tracker:
background work does not get a “peak memory usage” log line in the
destructor.
The per-user and per-query trackers are created at query start. The per-user
one lives in ProcessListForUser:
// src/Interpreters/ProcessList.h:350
MemoryTracker user_memory_tracker{VariableContext::User};and the per-query one lives in ThreadGroup, which is created per query:
// src/Common/ThreadStatus.h:103
MemoryTracker memory_tracker{VariableContext::Process};The chain, at steady state, is:
total_memory_tracker VariableContext::Global
hard limit: max_server_memory_usage
^ parent
|
user_memory_tracker VariableContext::User
hard limit: max_memory_usage_for_user
^ parent
|
ThreadGroup::memory_tracker VariableContext::Process
hard limit: max_memory_usage
^ parent
|
ThreadStatus::memory_tracker VariableContext::Thread
no hard limit (see below)A fourth level, Thread, exists per OS thread. It carries no limit, and the
source is explicit about why:
// src/Interpreters/ProcessList.cpp:385
/// NOTE: Do not set the limit for thread-level memory tracker since it could show unreal values
/// since allocation and deallocation could happen in different threads
That comment is the whole reason the tree is a tree and not a flat set of counters. A block allocated on thread A and freed on thread B would make a per-thread counter go negative. The thread tracker is therefore a routing node, not a limit node: it exists so that the chain has a leaf the thread’s allocations can be charged to, and so that the blocker’s level has something to compare against.
The parent pointer
Each tracker holds a single atomic pointer to its parent, and the header explains exactly why it is atomic and why the ordering matters:
// src/Common/MemoryTracker.h:91
/// Singly-linked list. All information will be passed to subsequent memory trackers also (it allows to implement trackers hierarchy).
/// In terms of tree nodes it is the list of parents. Lifetime of these trackers should "include" lifetime of current tracker.
/// Requires acquire-release:
/// 1. Thread A constructs MemoryTracker object and attaches MemoryTracker pointer
/// (e.g. ProcessList::insert where the user's MemoryTracker is constructed right before calling setParent).
/// 2. Thread B traverses the chain and dereferences each pointer (e.g. another thread in thread group).
/// 3. If Thread B sees a pointer, it should be guaranteed to see the object's memory without data races.
/// Hence, we need the Thread A's pointer store to synchronize-with the Thread B's pointer load.
std::atomic<MemoryTracker *> parent {};setParent is the only place the link is written, and it does two things that
are easy to get wrong: it flushes the thread’s deferred bytes before moving
the pointer, and it attaches the child to the new parent before publishing
the pointer, so a concurrent reader that sees the new parent also sees the
child counted:
// src/Common/MemoryTracker.cpp:1058
void MemoryTracker::setParent(MemoryTracker * elem)
{
/// Untracked memory shouldn't be accounted to a query or a user if it was allocated before the thread was attached
/// to a query thread group or a user group, because this memory will be (🤞) freed outside of these scopes.
if (level == VariableContext::Thread && DB::current_thread)
DB::current_thread->flushUntrackedMemory();
/// Attach before publishing, so the new parent counts this child before any of its bytes arrive.
if (elem && level == VariableContext::Process)
elem->attachChild();
auto * old_parent = parent.exchange(elem, std::memory_order_acq_rel);
if (old_parent && level == VariableContext::Process)
old_parent->detachChild();The last four lines are the interesting ones. A Process-level tracker counts
its children:
// src/Common/MemoryTracker.cpp:737
void MemoryTracker::attachChild()
{
if (level == VariableContext::User)
children_count.fetch_add(1);
}and when the last child goes away, whatever residual is left on the user is settled, so a user does not accumulate a permanent residue:
// src/Common/MemoryTracker.cpp:744
void MemoryTracker::detachChild()
{
if (level != VariableContext::User || children_count.fetch_sub(1) != 1)
return;
/// Read before checking again: a child attached meanwhile is either seen below or charges after the read.
Int64 residual = amount.load();
if (residual == 0 || children_count.load() != 0)
return;Walking the chain
The whole design reduces to one recursive function. MemoryTracker::allocImpl
charges this level, then hands the same bytes to the parent:
// src/Common/MemoryTracker.cpp:391
Int64 will_be = size ? size + amount.fetch_add(size, std::memory_order_relaxed) : amount.load(std::memory_order_relaxed);
Int64 will_be_rss = 0;
if (level == VariableContext::Global)
will_be_rss = size ? size + rss.fetch_add(size, std::memory_order_relaxed) : rss.load(std::memory_order_relaxed);
Int64 current_hard_limit = hard_limit.load(std::memory_order_relaxed);
bool memory_limit_exceeded_ignored = false;The amount.fetch_add happens before the limit check, which means the
counter is momentarily over the limit while the check runs. The comment above
it says so explicitly:
// src/Common/MemoryTracker.cpp:387
/** Using memory_order_relaxed means that if allocations are done simultaneously,
* we allow exception about memory limit exceeded to be thrown only on next allocation.
* So, we allow over-allocations.
*/This is a deliberate trade: relaxed ordering avoids the cost of an
acquire-release fence on the hottest path in the server, at the price of
letting several threads briefly overshoot the limit before any of them is
refused. The overshoot is bounded by the number of threads racing, not by the
size of the allocations.
After the check, the chain continues:
// src/Common/MemoryTracker.cpp:574
AllocationTrace allocation_trace(_sample_probability);
if (auto * loaded_next = parent.load(std::memory_order_acquire))
{
MemoryTracker * tracker = level == VariableContext::Process ? this : query_tracker;
try
{
allocation_trace = loaded_next->allocImpl(size, enforce_memory_limit, tracker, _sample_probability);
}
catch (...)
{
decrementLocalUsage(size);
throw;
}
}Note the catch: if the parent refuses the allocation, this level has
already added the bytes to its own counter, so it must undo them before
rethrowing. That single catch block is the difference between “the query
hit its limit” and “the server now permanently believes the query is holding
50 GB”. The unit tests in src/Common/tests/gtest_memory_tracker.cpp exist
specifically to pin this down — ParentLimitFailureRollsBackHierarchy,
ProcessLimitFailureRollsBackDescendants, RepeatedParentLimitFailuresDoNotAccumulate.
Carrying the query identity down the chain
The query_tracker argument is a small but load-bearing trick. The Global
level needs to know which query asked for the memory, because the
overcommit mechanism has to pick a victim query to kill — and the global
tracker has no idea which query that is. So the Process-level tracker passes
itself down as the “query tracker” and the global level keeps it:
// src/Common/MemoryTracker.cpp:377
/// Since the MemoryTrackerBlockerInThread should respect the level, we should go to the next parent.
if (auto * loaded_next = parent.load(std::memory_order_acquire))
{
MemoryTracker * tracker = level == VariableContext::Process ? this : query_tracker;
return loaded_next->allocImpl(size, enforce_memory_limit, tracker, _sample_probability);
}The same expression appears twice — once in the blocked path above and once in
the normal path. The invariant is: at the Global level, query_tracker is
the innermost Process-level tracker that was on the chain, and at any other
level it is passed through unchanged.
Where the limits come from
The limits are session settings, applied when the query is registered:
// src/Interpreters/ProcessList.cpp:322
/// Track memory usage for all simultaneously running queries from single user.
if (starts_a_new_period)
user_process_list.user_memory_tracker.setHardLimit(settings[Setting::max_memory_usage_for_user]);
else
user_process_list.user_memory_tracker.setOrRaiseHardLimit(settings[Setting::max_memory_usage_for_user]);
user_process_list.user_memory_tracker.setSoftLimit(settings[Setting::memory_overcommit_ratio_denominator_for_user]);
user_process_list.user_memory_tracker.setDescription("User");and for the query itself:
// src/Interpreters/ProcessList.cpp:359
/// Set query-level memory trackers
thread_group->memory_tracker.setOrRaiseHardLimit(settings[Setting::max_memory_usage]);
configureMemoryTrackerFromSettings(query_context->hasTraceCollector(), thread_group->memory_tracker, settings);Two different setters, and the difference is deliberate. setHardLimit
replaces the limit outright; setOrRaiseHardLimit only ever raises it:
// src/Common/MemoryTracker.cpp:1034
void MemoryTracker::setOrRaiseHardLimit(Int64 value)
{
/// This is just atomic set to maximum.
Int64 old_value = hard_limit.load(std::memory_order_relaxed);
while ((value == 0 || old_value < value) && !hard_limit.compare_exchange_weak(old_value, value))
;
}The value == 0 case is the interesting one: zero means “unlimited”, and
“unlimited” is encoded as raise to infinity. So a user who runs one query
with max_memory_usage_for_user = 10 GiB and then a second with
max_memory_usage_for_user = 0 does not get an unlimited user — the user
stays capped at 10 GiB. The starts_a_new_period flag above decides when the
cap is reset: a “period” ends when the user’s last query leaves the list.
The setSoftLimit line is the overcommit knob. memory_overcommit_ratio_denominator
defaults to 1 GiB:
// src/Core/Settings.cpp:4623
DECLARE(UInt64, memory_overcommit_ratio_denominator, 1_GiB, R"(
It represents the soft memory limit when the hard limit is reached on the global level.
This value is used to compute the overcommit ratio for the query.
Zero means skip the query.
Read more about [memory overcommit](/concepts/features/configuration/settings/memory-overcommit).
)", 0, \
{"22.5", 0, 1073741824, "Enable memory overcommit feature by default"}) \
The soft limit is not a cap. It is the denominator of the ratio used to rank queries when the server has to choose one to kill.
Where the thread tracker joins
A thread attaches to a query’s group when the group is created, and the thread’s tracker is reparented at that moment:
// src/Interpreters/ThreadStatusExt.cpp:446
/// Reparenting the memory tracker flushes the untracked balance the thread carried in, so the
/// counters must be reparented after it, or those bytes are reported as this group's.
memory_tracker.setParent(&thread_group->memory_tracker);
performance_counters.setParent(&thread_group->performance_counters);The comment records the ordering constraint precisely: the reparenting flushes the thread’s deferred (untracked) balance, and that flush has to land on the new parent, so the tracker must be reparented before the performance counters are. The inverse ordering would report the flushed bytes against the wrong scope.
When the thread detaches — a query finishing, or a pool thread returning to the pool — the tracker goes back to the global tracker and the deferred bytes are flushed one last time:
// src/Interpreters/ThreadStatusExt.cpp:481
void ThreadStatus::detachFromGroup()
{
if (!thread_group)
return;
LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global);
/// flush untracked memory before resetting memory_tracker parent
flushUntrackedMemory();The LockMemoryExceptionInThread at the top matters: detaching happens
during stack unwinding when a query is cancelled, and an exception thrown
from a destructor during unwinding is std::terminate. The lock is the
mechanism that says “no memory-limit exceptions from here on”.
The free path
Freeing is not symmetric with allocating, and the difference is where most of
the subtle bugs live. MemoryTracker::free takes the bytes off this level
first, and then only passes on what was actually there:
// src/Common/MemoryTracker.cpp:890
AllocationTrace MemoryTracker::free(Int64 size, double _sample_probability)
{
if (_sample_probability < 0)
_sample_probability = sample_probability.load(std::memory_order_relaxed);
if (!isSizeOkForSampling(size))
_sample_probability = 0;
if (MemoryTrackerBlockerInThread::isBlocked(level))
{
if (level == VariableContext::Global)
{
/// For global memory tracker always update memory usage.
amount.fetch_sub(size, std::memory_order_relaxed);
rss.fetch_sub(size, std::memory_order_relaxed);
auto metric_loaded = metric.load(std::memory_order_relaxed);
if (metric_loaded != CurrentMetrics::end())
CurrentMetrics::sub(metric_loaded, size);
}The removal itself saturates at zero for the User and Process levels,
because a query may free memory it never held (a cache eviction, a pool
object freed by a different query’s thread):
// src/Common/MemoryTracker.cpp:607
else
{
/// A query may free memory it never held (e.g. a cache eviction): take only what is there, in one
/// compare-exchange so that concurrent frees cannot together remove more than is there.
Int64 current = amount.load(std::memory_order_relaxed);
do
{
accounted_size = std::min(size, std::max<Int64>(current, 0));
}
while (!amount.compare_exchange_weak(current, current - accounted_size, std::memory_order_relaxed));
}and the “freed more than we held” residue is not silently dropped — it goes to the server total, and the deficit is remembered:
// src/Common/MemoryTracker.cpp:924
/// A query freeing what it never held does not free it for its user either; only the server total holds it.
if (accounted_size < size && loaded_next->level == VariableContext::User)
{
std::ignore = total_memory_tracker.free(size - accounted_size, _sample_probability);
#ifdef DEBUG_OR_SANITIZER_BUILD
freed_beyond_held.fetch_add(size - accounted_size, std::memory_order_relaxed);
#endif
size = accounted_size;
}The freed_beyond_held counter exists only in debug builds, and it is there
to explain a specific false positive: thread A of a query allocates 2 MiB that
stays in its untracked (deferred) buffer, and thread B frees that block. The
free reaches the server total first, so the query now holds 2 MiB less than
it should, and when A finally flushes, the query is charged for a block that is
already gone. That shows up as drift at the end of the query. The header
documents it in full:
// src/Common/MemoryTracker.h:158
/// What a query root freed beyond what it held, which went straight to the server total (see `free`). For example,
/// thread A of a query allocates a 2 MiB block that stays in its untracked memory, below `max_untracked_memory`, and
/// thread B frees the block and flushes: the query holds less than 2 MiB, so the rest goes here. When A flushes later,
/// the query is charged for a block that is already freed, which shows as drift at the end of the query but is not.
std::atomic<Int64> freed_beyond_held = 0;Settling what a query leaves behind
When a query’s tracker is destroyed, whatever it still holds is taken off its user — so that a user does not end up charged for a query’s leftovers:
// src/Common/MemoryTracker.cpp:697
void MemoryTracker::settleDriftOnQueryEnd()
{
Int64 drift = amount.load(std::memory_order_relaxed);
if (drift == 0)
return;
/// Nested groups (a view, a merge inside `OPTIMIZE`) leave their drift to the query above them.
auto * user_tracker = parent.load(std::memory_order_relaxed);
if (!user_tracker || user_tracker->level != VariableContext::User)
return;
/// Another query of the same user may be releasing the same bytes right now, so count what was actually removed.
Int64 settled = user_tracker->adjustLocally(drift);
adjustLocally(drift);
if (settled == 0)
return;
ProfileEvents::increment(ProfileEvents::QueryMemoryDriftSettled);
ProfileEvents::increment(ProfileEvents::QueryMemoryDriftSettledBytes, std::abs(settled));
}The “nested groups” line is a subtlety worth pausing on. A SELECT from a
view, or an OPTIMIZE that runs a merge, creates a nested thread group. The
nested group’s tracker is not attached to the user — it charges through the
group above it, so the user is not billed twice:
// src/Interpreters/ProcessList.cpp:337
/// A nested group (a view, a flush run by `SYSTEM FLUSH ASYNC INSERT QUEUE`, a dictionary load) is
/// charged through the group above it, and would be charged to the user twice.
if (!thread_group->isNested())
{
thread_group->memory_tracker.reparent(&user_process_list.user_memory_tracker);
thread_group->memory_pressure_monitor.setParent(user_process_list.user_memory_pressure_monitor);
}isNested() is just “does this group have a parent group”:
// src/Common/ThreadStatus.h:157
bool isNested() const { return parent != nullptr; }And the settleDriftOnQueryEnd guard user_tracker->level != VariableContext::User
is what implements that: a nested group’s parent is a Process-level tracker,
not a User-level one, so the nested group returns early and leaves its drift
to the query above it, which does have a User parent and does settle.
The hot fields
The tracker’s own layout is hand-tuned, because amount is written on every
allocation and every free:
// src/Common/MemoryTracker.h:72
/// Hot: every field touched on the alloc/free path. Packed into a single cache line
/// (24B writes + 40B reads = 64B exactly) so each allocation needs at most one line
/// in flight. The line bounces among writer cores either way; co-locating the
/// read-mostly limits/parent/metric here means writers get them for free with the
/// same fetch. The only cost is reader-only cores re-fetching when allocator writes
/// invalidate, but allocations dominate over pure reads in practice.
struct
{
/// Hot writes: updated on every alloc/free flush.
alignas(DB::CH_CACHE_LINE_SIZE) std::atomic<Int64> amount {0};
std::atomic<Int64> peak {0};
std::atomic<Int64> rss {0};Twenty-four bytes of writes (three Int64 counters) plus forty bytes of
reads (three limits, the parent pointer, the metric) fills a 64-byte cache
line exactly. The whole struct is aligned to the cache line:
// src/Common/MemoryTracker.h:421
static_assert(std::alignment_of_v<MemoryTracker> == DB::CH_CACHE_LINE_SIZE);and the “cold” fields — the description string, the fault-injection probability, the sampling configuration — live in a second cache line that is never touched on the alloc/free path.
Metrics and observability
A tracker can be attached to a CurrentMetrics counter, which is how
background work reports itself:
// src/Common/MemoryTracker.h:337
/// The memory consumption could be shown in realtime via CurrentMetrics counter
void setMetric(CurrentMetrics::Metric metric_)
{
metric.store(metric_, std::memory_order_relaxed);
}The server total is not a metric but a tracker, and it is reported:
// src/Common/CurrentMetrics.cpp:75
M(MemoryTracking, "Total amount of memory (bytes) tracked by the server. By default the background memory worker corrects this value on every tick from an external measurement of the memory the process really uses (see the `memory_worker_correct_memory_tracker` server setting), so it follows the measured usage rather than a plain count of allocations. See `MemoryTrackingUncorrected` for the value with no corrections applied.") \
The distinction matters: MemoryTracking is corrected against the real RSS
by the background memory worker, while MemoryTrackingUncorrected is the raw
counter of the tree. The gap between them is the accounting drift plus the
memory the process uses that never went through the counter at all — the pages
the allocator retains.
Per-query usage is visible in the process list and the query log:
SELECT query_id, memory_usage, peak_memory_usage
FROM system.processes
WHERE memory_usage > 0
ORDER BY memory_usage DESC;SELECT
query_id,
formatReadableSize(memory_usage) AS used,
formatReadableSize(peak_memory_usage) AS peak,
exception
FROM system.query_log
WHERE type = 'QueryFinish' AND memory_usage > 0
ORDER BY peak_memory_usage DESC
LIMIT 10;What the hierarchy buys and what it costs
What it buys. One allocation is checked against four independent budgets without any of them knowing about the others. The user’s limit works even when the user’s queries run on 32 different threads. The server’s limit works even when the server has 200 users. And because the check happens before the bytes are handed to the allocator, an over-budget allocation costs nothing but the counter update.
What it costs.
The chain walk is a pointer chase on the hottest path in the server. Four levels means four atomic loads, four
fetch_adds, four limit checks, and fourfetch_subs on the free side — per allocation. This is why the thread level batches allocations instead of charging them one by one, and why the whole tracker struct is packed into a single cache line.The counters can overshoot.
relaxedordering plus the add-then-check order means the reported usage can exceed the limit by whatever several racing threads allocated. The tests acknowledge this with a tolerance rather than an equality.The tree is mutable at run time.
reparentmoves a tracker and its bytes between subtrees, and it has to check the new ancestors’ limits before the move, because moving is not an allocation and would not go through the normal path:cpp// src/Common/MemoryTracker.cpp:792 /// Moving is not an allocation, so check the limits here, before anything changes. for (const auto * tracker = new_parent; size > 0 && tracker && tracker != common; tracker = tracker->parent.load(std::memory_order_relaxed)) { Int64 limit = tracker->hard_limit.load(std::memory_order_relaxed); Int64 will_be = tracker->amount.load(std::memory_order_relaxed) + size; if (limit && will_be > limit) { const auto * description = tracker->description_ptr.load(std::memory_order_relaxed); throw DB::Exception( DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, "{}{}exceeded: would use {} (attempt to move {} already allocated), maximum: {}",The
Threadlevel is not a limit and the source says so. Any attempt to put one there produces numbers that “could show unreal values”.
The part people get wrong. A query that fails with
MEMORY_LIMIT_EXCEEDED did not necessarily hit its own max_memory_usage.
The chain is checked from the leaf up, and the global tracker checks both
its own byte counter and the process RSS:
// src/Common/MemoryTracker.cpp:427
if (unlikely(
current_hard_limit && (will_be > current_hard_limit || (level == VariableContext::Global && will_be_rss > current_hard_limit))))So a server-level max_server_memory_usage can refuse an allocation that is
perfectly within the query’s budget, and the message says which one it was.
What to go do about it. Read the exception text. The global-level message
carries the RSS, the untracked-memory total and the overcommit decision; the
query-level message carries only the numbers. The difference tells you whether
you have a query problem or a server problem, and no amount of max_memory_usage
tuning fixes the latter.