ENGINEERING NOTE / 03 MYSQLPRODUCTION FORENSICS
The 20 GB MySQL Had Already Freed
MySQL's own instrumentation said the memory was gone. The kernel said it was still resident. Both were telling the truth.
TABLE OF CONTENTS
A production MySQL node was holding 53.7 GB of RAM on a 62 GB host with a buffer pool of roughly 33 GB. Nothing in the configuration accounted for the difference, and the gap left the host one workload spike away from the OOM killer.
The interesting part is not the number. It is that every layer I asked answered honestly, and the answers still did not add up — which is usually the sign that the question is being asked at the wrong altitude.
- EXPECTED MYSQL
- ~33 GB
- ACTUAL RSS
- 53.7 GB
- UNEXPLAINED
- +20.7 GB
The symptom§
Memory alerting fired on a single node. Resident set size for one mysqld
process sat at 53.7 GB and was not coming back down between workload peaks —
the shape of a leak rather than of a busy cache.
The configured innodb_buffer_pool_size was around 33 GB. Everything else in
the configuration was ordinary: no oversized per-connection buffers, no
unusual plugin, no in-memory temporary table strategy that would explain
twenty gigabytes.
VmRSS 53.7 GB RssAnon 46.1 GB AnonHugePages ~32 GB
That first reading is worth stating carefully, because it already rules something out. Of the 53.7 GB resident, 46.1 GB was anonymous — private, process-owned memory, not page cache the kernel could drop under pressure. Around 32 GB of it was backed by huge pages, which is the buffer pool behaving exactly as intended.
So the buffer pool was accounted for. Roughly 14 GB of anonymous memory beyond it was not.
What did not add up§
The usual suspects each have a signature, and each one was tested against a measurement rather than assumed away.
Temporary tables. Internal temporary tables held kilobytes at the moment of measurement, not gigabytes. Even a pathological history of on-disk temp tables would not persist as anonymous RSS.
Per-connection buffers. The theoretical worst case — every
per-connection buffer at its configured size, multiplied by the whole
max_connections limit — came to well under a gigabyte. This is the
calculation people usually reach for first, and on this host it could not
have produced the gap even at its arithmetic maximum.
The buffer pool itself. Already accounted for, and visible as huge pages.
-
01 SYMPTOM
53.7 GB resident against a ~33 GB buffer pool, not falling between peaks.
-
02 HYPOTHESIS RULED OUT
Temporary tables or per-connection buffers are the overhead.
-
03 EVIDENCE
Performance Schema shows allocation and release in balance.
-
04 HYPOTHESIS RULED OUT
MySQL is leaking memory it never frees.
-
05 LOWER LAYER
Linux process memory: 46.1 GB anonymous, 1,100+ mappings, RSS still climbing.
-
06 ROOT CAUSE CONFIRMED
glibc allocator fragmentation, amplified by Transparent Huge Pages set to always.
-
07 VALIDATION
Track OS resident memory against MySQL-accounted memory after the change.
What the engine said§
MySQL instruments its own allocations. Performance Schema keeps a running total of bytes allocated and bytes freed per event, which makes it possible to ask the engine directly whether it still believes it is holding the memory:
SELECT SUM(SUM_NUMBER_OF_BYTES_ALLOC) AS alloc,
SUM(SUM_NUMBER_OF_BYTES_FREE) AS freed,
SUM(CURRENT_NUMBER_OF_BYTES_USED) AS current_used
FROM performance_schema.memory_summary_global_by_event_name;
The answer was unambiguous.
alloc 97.6 GB freed 97.6 GB current_used 81 MB
Ninety-seven point six gigabytes allocated over the life of the process. Ninety-seven point six gigabytes freed. Eighty-one megabytes currently held.
Dropping the aggregate and ordering by CURRENT_NUMBER_OF_BYTES_USED breaks
the same table down per allocation site — the next place to look, had the
totals not settled it.
This is the moment the investigation changes shape. A leak inside MySQL would
show as CURRENT climbing. CURRENT was flat and small. Whatever was holding
the memory was doing so after MySQL had handed it back.
free() is a promise to the allocator, not to the kernel. The allocator is
under no obligation to return anything.
Crossing the abstraction boundary§
Once the engine’s accounting is clean, the engine’s tooling is finished. The remaining questions are Linux questions, and they get Linux answers.
# Anonymous versus file-backed, and how much is on huge pages
grep -E 'VmRSS|RssAnon|RssFile|AnonHugePages' /proc/$(pgrep -o mysqld)/status
# How many anonymous mappings the process is carrying. Counting
# '^Size:' in smaps would include file-backed ones; a mapping with no
# pathname field at all is the anonymous case.
awk 'NF == 5' /proc/$(pgrep -o mysqld)/maps | wc -l
# Is THP handing out 2 MB pages for everything?
cat /sys/kernel/mm/transparent_hugepage/enabled
Three measurements mattered.
The process was carrying more than 1,100 anonymous mappings. A process whose allocator is returning memory cleanly does not accumulate that many distinct anonymous regions. On its own the count settles nothing, but it is consistent with an address space that has been carved up and only partially handed back.
RSS was still climbing during observation, with CURRENT_NUMBER_OF_BYTES_USED
staying flat. The two lines moving independently is the whole finding in one
sentence.
And Transparent Huge Pages was set to always.
Root cause§
glibc’s allocator keeps freed memory in per-thread arenas rather than
returning it to the kernel on every free(), which is normally the right
trade: brk/mmap round trips are expensive and most workloads reuse what
they release. It returns memory to the OS only when the top of a heap is
contiguously free past a threshold.
A database under sustained, concurrently-sized allocation churn is close to the worst case for that design. Long-lived allocations end up interleaved with short-lived ones, so the arenas are full of free space that is not contiguous enough to be released. The memory is genuinely free — MySQL is not using it, and the allocator will happily hand it out again — but from the kernel’s point of view the process still owns every page.
Transparent Huge Pages amplifies this. With THP set to always, a region that
needs a few kilobytes can be backed by a 2 MB page, and that whole page stays
resident until the entire region is free. Fragmentation that would have cost
kilobytes starts costing megabytes.
It is worth being precise about the strength of that claim. The allocator
behaviour is an inference from converging measurements — clean engine
accounting, high anonymous mapping count, RSS climbing independently of
CURRENT — not something a single counter reports directly. Linux does not
expose “bytes held by the allocator but unused by the application” as a
number. What it exposes is the shape, and the shape was unambiguous.
Remediation§
Both changes target the allocator layer rather than the database
configuration, and both are reversible without a restart of anything but
mysqld.
Disable Transparent Huge Pages. This is long-standing guidance from every major database vendor for exactly this reason, and it removes the amplification rather than the fragmentation:
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
Persisted through the boot configuration so it survives a reboot, not left as a runtime echo somebody has to remember.
Move MySQL onto jemalloc, with decay tuned so dirty pages are returned on a bounded schedule rather than at the allocator’s discretion:
[Service]
Environment="LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
Environment="MALLOC_CONF=background_thread:true,dirty_decay_ms:10000,muzzy_decay_ms:10000"
jemalloc is not magic, and it is not automatically better than glibc. What it
gives here is a policy knob: dirty_decay_ms puts a ceiling on how long
freed memory can sit unreturned, which is precisely the property glibc does
not expose.
Validation§
The change shipped with success criteria written down in advance, because “memory looks better” is not a result.
Two things get tracked after deployment:
- Resident set size over time, across at least one full workload cycle including the peaks that were previously followed by a step up in RSS. The expectation is a sawtooth that returns to a stable floor, not a staircase.
- MySQL-accounted memory against OS resident memory. The two should track within roughly 15% of each other. That tolerance is deliberate — some allocator overhead is normal and expected, and a criterion that demands they match exactly would fail on a perfectly healthy host.
If RSS still diverges from CURRENT_NUMBER_OF_BYTES_USED after the change,
the inference above was wrong and the investigation reopens with the
allocator ruled out rather than blamed. That is the point of writing the
criteria down before the deployment rather than after it.
What I would do differently§
I would have asked Performance Schema first. I spent time on
per-connection buffer arithmetic that the engine’s own accounting answered in
one query. The allocated / freed / currently-held triple in
memory_summary_global_by_event_name is the fastest way to split “MySQL is
holding it” from “something below MySQL is holding it”, and that split is the
fork in the whole investigation.
I would have checked THP at the same time as the buffer pool. It is a one-line read, it is a known interaction, and on this host it was part of the answer. It should be in the same first pass as the configuration review, not three steps later.
I would monitor the divergence, not the absolute. An alert on RSS tells you a host is nearly full. An alert on the gap between engine-accounted memory and OS resident memory tells you why, and would have fired weeks earlier with a much shorter investigation attached to it.
Key takeaways§
free() returns memory to the allocator, not to the kernel. Any diagnosis
that treats those as the same event will eventually produce a number nobody
can explain.
When an engine’s instrumentation is clean and the OS disagrees, the engine is not lying — the question has simply run out of altitude, and the next measurement belongs one layer down.
Transparent Huge Pages is not a tuning preference for database workloads. It turns allocator fragmentation from a kilobyte problem into a megabyte one, and it is the cheapest thing on this list to check.
And the fix that removes the alert is not always the fix that removes the problem. More RAM would have worked, for a while, which is exactly what makes it the tempting answer.