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.

7 MIN READ

MYSQLLINUXMEMORYALLOCATORPRODUCTION

TABLE OF CONTENTS
  1. The symptom
  2. What did not add up
  3. What the engine said
  4. Crossing the abstraction boundary
  5. Root cause
  6. Remediation
  7. Validation
  8. What I would do differently
  9. Key takeaways

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.

MEASURED /proc/<pid>/status
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.

  1. 01 SYMPTOM

    53.7 GB resident against a ~33 GB buffer pool, not falling between peaks.

  2. 02 HYPOTHESIS RULED OUT

    Temporary tables or per-connection buffers are the overhead.

  3. 03 EVIDENCE

    Performance Schema shows allocation and release in balance.

  4. 04 HYPOTHESIS RULED OUT

    MySQL is leaking memory it never frees.

  5. 05 LOWER LAYER

    Linux process memory: 46.1 GB anonymous, 1,100+ mappings, RSS still climbing.

  6. 06 ROOT CAUSE CONFIRMED

    glibc allocator fragmentation, amplified by Transparent Huge Pages set to always.

  7. 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.

MEASURED performance_schema.memory_summary_global_by_event_name
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.