MySQL6 min read

InnoDB Purge Threads: Tuning the Garbage Collector Nobody Watches

Purge lag is usually a pinned read view, not weak purge threads. Here is how to tell the two failures apart, what innodb_purge_threads actually buys, and the alerts that matter.

A few years ago I watched a healthy MySQL primary get slower every afternoon between two and four, like clockwork. Queries that read hot rows degraded first, then everything followed, and the only metric that moved in step was the history list length, climbing into the tens of millions before sagging back overnight. The team was ready to throw purge threads at the problem. The actual cause was an analytics connection that opened a repeatable-read snapshot at lunchtime and held it for hours, and no number of purge threads would have fixed that, because purge was not slow: it was pinned. Still, the capacity question is real, and this article is the purge-thread half of the story. The anatomy of the history list itself, what it is and why it strangles reads, is in the history list length piece; here I want to cover the threads that drain it, the knobs that exist, and the failure modes worth recognizing.

What purge actually does

Every UPDATE and DELETE in InnoDB does not remove data; it writes new versions and leaves the old ones in the undo log, because some open transaction's read view might still need them. Purge is the background machinery that decides when nobody can need a version anymore and then reclaims it: it removes obsolete undo records and physically deletes the delete-marked secondary index entries that DML left behind. The work runs on dedicated threads, innodb_purge_threads of them, four by default and up to thirty-two. One thread coordinates, slicing the backlog into batches of innodb_purge_batch_size undo pages, three hundred by default, and the workers chew through them. The backlog itself is the history list length: committed undo that has not yet been purged, visible in the TRANSACTIONS section of SHOW ENGINE INNODB STATUS and as the trx_rseg_history_len metric.

Pinned versus outrun: two different failures

Purge can only reclaim versions older than the oldest open read view on the server. One idle transaction holding a snapshot sets a floor under the history list, and purge will grind right up to that floor and stop, no matter how many threads you give it. That is the pinned case, and in my experience it accounts for the large majority of purge-lag incidents: forgotten shells, ORMs with autocommit off, report jobs, a backup tool taking its sweet time. The second case is genuinely being outrun: sustained write volume generating undo faster than the purge threads can process it. That one is rare with sensible defaults, but it happens on extreme ingest pipelines, and there is a common intermediate shape: one enormous transaction commits, and its undo alone is a mountain the purge threads must chew through while new writes keep arriving. The backlog looks identical in every case, a rising history list, but the fix is completely different, so the first job is always to tell them apart.

Symptoms of purge falling behind

The history list length climbing in the TRANSACTIONS section of SHOW ENGINE INNODB STATUS is the headline. Around it, a recognizable supporting cast: undo tablespaces growing and stubbornly refusing to truncate, reads of hot rows slowing as their version chains lengthen, secondary index range scans wading through delete-marked garbage that purge has not removed, and disk usage trending up. For a trendable counter, enable the metric and read it directly, and while you are there, look at the oldest open transaction:

SET GLOBAL innodb_monitor_enable = 'trx_rseg_history_len';

SELECT name, count
FROM information_schema.innodb_metrics
WHERE name = 'trx_rseg_history_len';

SELECT trx_id, trx_started,
       timestampdiff(SECOND, trx_started, now()) AS open_seconds,
       trx_rows_modified, trx_mysql_thread_id
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 5;

The second query is where the discrimination starts, not where it ends. An ancient transaction is the prime suspect, but age alone is not proof of a pin: under REPEATABLE READ the snapshot is taken at the first consistent read, so a transaction that has only been writing for hours holds no read view and blocks nothing. Before you convict it, look at what that session has actually run: SHOW FULL PROCESSLIST for the current statement, performance_schema.events_statements_current and its history siblings for what came before. The guilty shapes are recognizable: a report query still crawling, an ORM session with autocommit off that did one SELECT and went idle, a mysqldump --single-transaction against a big database. If it is holding a snapshot, you have your pin, and the purge threads are innocent. If the oldest transaction is young, or old but snapshot-free, and the history list still climbs, you are genuinely outrunning purge capacity, and now the tuning conversation is legitimate.

Tuning the threads themselves

innodb_purge_threads defaults to four and tops out at thirty-two. Four is enough for the overwhelming majority of workloads; I want to say that plainly because adding threads is the first thing everyone reaches for and the last thing that usually helps. When you have proven, with a young oldest transaction and a climbing history list, that purge really cannot keep pace, moving to eight or sixteen threads is reasonable on big write-heavy boxes. Past that the returns fade fast, because purge work has internal coordination costs and the threads start contending rather than cooperating. innodb_purge_batch_size, three hundred undo pages per batch by default, is a knob I have touched maybe twice in production; leave it unless you have measurements saying otherwise.

Then there is the emergency brake: innodb_max_purge_lag. When the history list exceeds it, InnoDB injects delays into DML statements, up to innodb_max_purge_lag_delay microseconds apiece, throttling writers so purge can catch up. It defaults to zero, off, and I mostly keep it there, because slowing every writer in the building to protect the engine punishes the innocent and does nothing about a pinned read view. The exception is a pure ingest box where a short delay on inserts is harmless and version-chain explosion is the real enemy; there, a generous threshold is a reasonable safety net.

Do not forget undo truncation in this conversation. With innodb_undo_log_truncate on, undo tablespaces past innodb_max_undo_log_size get marked for truncation once purge releases them, and innodb_purge_rseg_truncate_frequency controls how often purge checks. If purge is pinned, truncation silently cannot proceed, and if the truncate frequency is too low on a churn-heavy system, undo files balloon between cycles. Purge health and undo hygiene are the same system wearing two hats.

The part you cannot tune away

If the checks above convicted a session of holding an hours-old snapshot, stop tuning and go kill it. KILL the connection, watch the history list start to fall, and then fix whatever let a session hold a snapshot for hours: autocommit disabled by a connection pool, a BEGIN issued before a long-running report, a console someone left open on a jump host. The single highest-value alert in this whole area is oldest open transaction age, warn at minutes and page at an hour, because it catches the pin before the history list even looks interesting. Alerting on the history list alone means you find out after the slowdown has started.

What good looks like

On a healthy server the history list is a sawtooth: batch jobs push it up, purge pulls it down, and the peaks never last. My three standing alerts are history list trend, oldest transaction age, and undo tablespace size deviation from baseline, and between them they have caught every purge incident I have met in years. Instantaneous spikes are not the enemy; a big DELETE legitimately queues millions of undo records and purge grinds them down in minutes. Sustained growth is the signal, and the age of the oldest read view is almost always the explanation.

Alerts before dashboards

The three alerts above have caught every purge incident I have met in years, and none of them needs a dashboard empire to run. On the tooling side I will be plain: I work on MonPG, a PostgreSQL monitoring platform today that does not monitor MySQL yet; the MySQL version is in active development, and purge lag sits high on its design list precisely because it is the same disease as PostgreSQL's vacuum debt in a different dialect: history list trended as a first-class series, oldest-transaction age tied to the session that owns it, undo growth correlated against both. The MySQL monitoring (coming soon) page tracks the build. If PostgreSQL is part of your world too, that workflow already exists on the PostgreSQL monitoring side, and the blog covers the vacuum-debt version of this same incident.