<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Replication on EXPLAIN ANALYZE</title><link>https://explainanalyze.com/tags/replication/</link><description>Recent content in Replication on EXPLAIN ANALYZE</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Tue, 15 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/tags/replication/index.xml" rel="self" type="application/rss+xml"/><item><title>A Billion Row Versions From One Uncommitted Read Stalled a MySQL Replica</title><link>https://explainanalyze.com/p/a-billion-row-versions-from-one-uncommitted-read-stalled-a-mysql-replica/</link><pubDate>Tue, 15 Sep 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/a-billion-row-versions-from-one-uncommitted-read-stalled-a-mysql-replica/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post A Billion Row Versions From One Uncommitted Read Stalled a MySQL Replica" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;An open transaction on a read replica holds a read view, and a read view stops InnoDB from purging old row versions anywhere on that server. The versions pile up, every read has to walk longer and longer chains to find the current row, and the replication applier is a read workload too, so it falls behind on a source whose traffic never changed. The fix is one &lt;code&gt;KILL&lt;/code&gt;, and finding out what to kill takes one query.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The replica is 260,000 seconds behind. That&amp;rsquo;s three days, and it has been climbing all week from the few minutes it sat at when someone first noticed.&lt;/p&gt;
&lt;p&gt;The source is busy, and it has always been busy. It serves about 15,000 queries a second, which is what it served last month and the month before, and the write rate inside that number is flat. Nothing has been backfilled. No schema change has run, the binary logs are rotating at the rate they have for a year, and the source&amp;rsquo;s own &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; is clean. On the replica, the data directory is 340 GB against the source&amp;rsquo;s 84 GB, holding what is supposed to be a byte-for-byte copy of the same data.&lt;/p&gt;
&lt;p&gt;Restarting the replica fixes it. Lag drops to zero in a few hours of catch-up, everyone moves on, and somewhere between a day and a week later the alert fires again.&lt;/p&gt;
&lt;h2 id="it-must-be-the-applier"&gt;It must be the applier
&lt;/h2&gt;&lt;p&gt;The first theory is throughput, and it&amp;rsquo;s the right first theory. A replica that can&amp;rsquo;t keep up with a source is usually a replica that is applying single-threaded, or applying on slower disks, or fighting the read traffic that is the whole reason it exists.&lt;/p&gt;
&lt;p&gt;So: &lt;code&gt;replica_parallel_workers&lt;/code&gt; raised, &lt;code&gt;replica_preserve_commit_order&lt;/code&gt; left on, reporting traffic moved off for an afternoon, and a bigger instance class underneath. Lag kept growing. The tell that should have ended this phase early was the shape of the graph. A replica that is throughput-bound falls behind during the busy hours and gets it back in the overnight trough, so the lag line is a sawtooth. This line had no teeth. It climbed through four o&amp;rsquo;clock on Sunday morning, the quietest hours in the source&amp;rsquo;s week, at the same slope it climbed on Tuesday at noon.&lt;/p&gt;
&lt;p&gt;Something on the replica was making every unit of work more expensive over time, and the only thing on a replica that gets monotonically more expensive over time is history.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mysql&amp;gt; SHOW ENGINE INNODB STATUS\G
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;------------
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TRANSACTIONS
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;------------
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Trx id counter 4297552210
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Purge done for trx&amp;#39;s n:o &amp;lt; 2851013964 undo n:o &amp;lt; 0 state: running but idle
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;History list length 1043382771
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A billion. The purge pointer is sitting 1.4 billion transaction IDs behind the current counter, which is the same fact stated twice.&lt;/p&gt;
&lt;h2 id="what-the-history-list-actually-counts"&gt;What the history list actually counts
&lt;/h2&gt;&lt;p&gt;InnoDB never updates a row in place for readers. An &lt;code&gt;UPDATE&lt;/code&gt; writes the new version into the clustered index and pushes the old one into an undo log, so that any transaction with an older snapshot can still be shown the row as it looked when that snapshot began. From the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html" target="_blank" rel="noopener"
 &gt;manual on multi-versioning&lt;/a&gt;:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;In the &lt;code&gt;InnoDB&lt;/code&gt; multi-versioning scheme, a row is not physically removed from the database immediately when you delete it with an SQL statement. &lt;code&gt;InnoDB&lt;/code&gt; only physically removes the corresponding row and its index records when it discards the update undo log record written for the deletion.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Discarding those records is the purge threads&amp;rsquo; job, and purge is not allowed to discard anything that any open read view might still need. The history list length is the count of undo log records waiting for purge. On a healthy server it sits in the thousands and bounces around as purge keeps up. It grows without limit exactly when something is holding a read view open, because purge is pinned to the oldest snapshot on the server and cannot advance past it.&lt;/p&gt;
&lt;p&gt;Peter Zaitsev&amp;rsquo;s &lt;a class="link" href="https://www.percona.com/blog/2014/10/17/innodb-transaction-history-often-hides-dangerous-debt/" target="_blank" rel="noopener"
 &gt;2014 post on transaction history&lt;/a&gt; makes the distinction that matters here: there are records that can be purged and records that can&amp;rsquo;t be purged because some active transaction needs them, and a high history list is only dangerous when it&amp;rsquo;s the second kind. This was the second kind.&lt;/p&gt;
&lt;p&gt;The cost lands on reads, and it lands in a specific place. When InnoDB walks a secondary index and finds an entry whose page has been touched by a newer transaction, it can no longer trust the index to answer the query:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;When a secondary index record is delete-marked or the secondary index page is updated by a newer transaction, &lt;code&gt;InnoDB&lt;/code&gt; looks up the database record in the clustered index. In the clustered index, the record&amp;rsquo;s &lt;code&gt;DB_TRX_ID&lt;/code&gt; is checked, and the correct version of the record is retrieved from the undo log if the record was modified after the reading transaction was initiated.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;A covering index stops covering. A single-row lookup becomes a clustered index descent plus a walk backwards down a version chain that is now hundreds of links long, and the manual&amp;rsquo;s own summary of where this ends is blunt: the purge thread starts to lag behind, the table grows bigger because of all the dead rows, &amp;ldquo;making everything disk-bound and very slow.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Here is the part that took the longest to see. The replication applier is one of those readers. Every row event in the relay log has to locate its target row before it can modify it, and on a replica whose history list is in the billions, locating a row costs a chain walk. The applier slows down in proportion to the mess, the mess grows because the applier keeps writing new versions that also can&amp;rsquo;t be purged, and the source&amp;rsquo;s write rate never has to change for any of it. A steady 15,000 queries a second is all the fuel this needs. That&amp;rsquo;s why the lag climbed through the Sunday trough at the same rate it climbed at noon on a weekday.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;What restarting was really doing&lt;/strong&gt;
 &lt;div&gt;A restart drops every session. The abandoned transaction died with its connection, purge was free to run, and the replica spent its first hours back mostly purging, then caught up. Nothing was fixed. The next application deploy left another transaction open and the clock started again. A restart that reliably fixes a problem for a variable number of days is usually clearing state that something is recreating, and it&amp;rsquo;s worth asking what state before scheduling it as a weekly job.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="ask-who-is-holding-the-oldest-snapshot"&gt;Ask who is holding the oldest snapshot
&lt;/h2&gt;&lt;p&gt;The transaction pinning purge is visible, and it looks like nothing at all:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SECOND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;age_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_rows_modified&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rows_mod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;innodb_trx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processlist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_mysql_thread_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trx_started&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Reproduced on a 2-million-row table on MySQL 8.0.46, with a source feeding a replica and one forgotten snapshot open on the replica, the join returns two rows:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; trx_id: 5782
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; trx_state: RUNNING
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;trx_started: 2026-09-12 13:52:21
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; age_s: 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; rows_mod: 5570
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; command: Query
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; time: 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; trx_id: 562948500925656
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; trx_state: RUNNING
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;trx_started: 2026-09-12 13:46:13
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; age_s: 368
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; rows_mod: 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; command: Sleep
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; time: 368
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; host: localhost
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The first row is the applier, doing its job:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;---TRANSACTION 5782, ACTIVE 0 sec starting index read
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mysql tables in use 1, locked 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;15 lock struct(s), heap size 1128, 2012 row lock(s), undo log entries 2012
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MySQL thread id 11, query id 10604 Applying batch of row changes (update)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The second row is the problem: &lt;code&gt;RUNNING&lt;/code&gt;, zero rows modified, command &lt;code&gt;Sleep&lt;/code&gt;, and a start time that only gets older. Look for it in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; and it is not in the active list at all. A transaction that has only read holds no locks and has written no undo, so it appears in the session list as &lt;code&gt;not started&lt;/code&gt;, which is a phrase that means something more specific than it sounds like and has cost people hours. The only evidence there is a count:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1 read views open inside InnoDB
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One read view is all it takes. &lt;code&gt;innodb_trx&lt;/code&gt; is the view that shows it as what it is, which is why the join above is the query to run first and &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; is the one that confirms the damage.&lt;/p&gt;
&lt;p&gt;On the incident host the equivalent second row had an &lt;code&gt;age_s&lt;/code&gt; of 1,180,000 and a &lt;code&gt;host&lt;/code&gt; column pointing at an application server that had been terminated thirteen days earlier. A reporting request had opened a transaction, run one &lt;code&gt;SELECT&lt;/code&gt; under &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and gone away when its process died mid-request. The connection outlived it: the pool&amp;rsquo;s keepalive had been resetting &lt;code&gt;wait_timeout&lt;/code&gt; on a socket whose client no longer existed, so the server&amp;rsquo;s idle reaper never came for it. A transaction with nothing running in it and nothing to roll back, holding one read view, at the cost of 340 GB of undo and a replica three days behind the source it is supposed to be a copy of.&lt;/p&gt;
&lt;p&gt;One &lt;code&gt;KILL&lt;/code&gt; on that thread, and the history list started draining.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;Before killing a long transaction, check &lt;code&gt;trx_rows_modified&lt;/code&gt;. A transaction showing zero has nothing to undo and disappears instantly. One showing millions will roll back when killed, single-threaded, and can hold the table hostage for longer than the original statement took to run. The safe version of this operation is to kill snapshot-holders that are idle and have modified nothing, and to escalate anything else to whoever owns the workload. Also expect purge to take real time after the kill: on a history list of a billion it is hours of IO, during which the replica is still behind and the graph is still ugly.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-measurement-and-what-to-alert-on"&gt;The measurement, and what to alert on
&lt;/h2&gt;&lt;p&gt;The mechanism is easy enough to reproduce that it&amp;rsquo;s worth doing once, because the numbers make the argument better than the explanation does. A source and a replica on MySQL 8.0.46, a 2-million-row &lt;code&gt;orders&lt;/code&gt; table, and a write workload of batched updates replaying through the applier. Run it with nothing else connected to the replica and the history list oscillates between 3 and 56 for the entire run, which is purge keeping pace. Then open a single transaction on the replica, run one &lt;code&gt;SELECT&lt;/code&gt;, and leave it sitting there:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:48:59 history=0 undo=112MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:49:40 history=115 undo=192MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:50:21 history=228 undo=336MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:51:02 history=312 undo=432MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:51:43 history=391 undo=528MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:52:24 history=470 undo=656MB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;06:52:45 history=482 undo=672MB
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Same source, same table, same writes. The only difference between the run that stays flat and the run that climbs is one idle connection. Then stop the write load entirely, and the interesting thing is what doesn&amp;rsquo;t happen: the history list sat at 585 and the undo tablespaces at 784 MB for as long as the snapshot stayed open, with the source completely quiet. Purge had caught up to everything it was allowed to touch and was blocked on the rest. &lt;code&gt;COMMIT&lt;/code&gt; in the forgotten session, and the history list went to zero within a few minutes.&lt;/p&gt;
&lt;p&gt;Two million rows is too small to show the second half of the story. Version chains a few hundred links long are still cheap to walk, so the applier in this setup never visibly slowed down; what it was throughput-bound by was the batch size, in both runs equally. The read amplification needs production scale and production time to become the dominant cost, which is exactly why the symptom arrives as lag weeks after the cause arrives as an idle connection.&lt;/p&gt;
&lt;p&gt;The undo files are the part that surprises people who arrive at this through a disk alert rather than a lag alert. &lt;code&gt;innodb_undo_log_truncate&lt;/code&gt; is on by default in MySQL 8.0, but an undo tablespace can only be truncated after the purge system has freed every rollback segment in it, so a pinned read view means the files grow and never shrink back to their 16 MB floor. That is where the replica&amp;rsquo;s 340 GB came from, and it&amp;rsquo;s the same class of problem as &lt;a class="link" href="https://explainanalyze.com/p/a-10-gb-database-filled-a-200-gb-disk-and-du-could-not-find-the-space/" &gt;a 10 GB database filling a 200 GB disk&lt;/a&gt;: space held by a reference nobody remembers taking.&lt;/p&gt;
&lt;p&gt;Two things are worth alerting on, and neither is lag. The first is &lt;code&gt;trx_rseg_history_len&lt;/code&gt; in &lt;code&gt;information_schema.innodb_metrics&lt;/code&gt;, which is the history list length as a number you can scrape instead of a line you have to parse out of &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;. It is enabled by default, unlike a lot of that table. Pick a threshold high enough never to fire on a healthy server and low enough to land a day before the lag graph moves; ten million is a reasonable place to start arguing from. The second is the age of the oldest row in &lt;code&gt;information_schema.innodb_trx&lt;/code&gt;, with the threshold set a few minutes past the longest report the application is supposed to be able to run. Percona&amp;rsquo;s &lt;a class="link" href="https://www.percona.com/blog/chasing-a-hung-transaction-in-mysql-innodb-history-length-strikes-back/" target="_blank" rel="noopener"
 &gt;2017 write-up of a hung transaction&lt;/a&gt; describes 940 transactions sitting in &lt;code&gt;Sleep&lt;/code&gt; for 766,132 seconds and a history length past 6 million, which is the same animal at a different scale and a useful reminder that these things are usually plural.&lt;/p&gt;
&lt;p&gt;Neither of those needs to page anyone. This is a one-lever problem with an exact signature and no judgment in it, which puts it squarely in the set that a health check bound to a watch handler can close on its own: the check goes critical on the history length, the handler runs the &lt;code&gt;innodb_trx&lt;/code&gt; lookup, confirms &lt;code&gt;trx_rows_modified&lt;/code&gt; is near zero, kills the thread, and files a ticket saying what it killed and how old it was. The &lt;a class="link" href="https://explainanalyze.com/p/self-healing-databases-with-a-health-check-and-a-watch-script/" &gt;handler pattern and the guards it needs&lt;/a&gt; are worth reading before wiring one, because the ticket is the load-bearing half. A handler that silently kills an abandoned transaction every few days has converted a defect in the application into a quiet maintenance routine, and the version of this incident where nobody ever finds out is the one that runs for a year.&lt;/p&gt;
&lt;p&gt;Which is where it actually gets fixed. A transaction that begins on one request and is expected to end on the same request should be bounded by the framework, not by hope, and a connection pool that health-checks idle connections should be asking whether the session has an open transaction rather than only whether the socket answers. Neither of those is a DBA change, which is why this kind of incident tends to get fixed twice: once with a &lt;code&gt;KILL&lt;/code&gt; at 3 a.m., and once properly, a quarter later, by whoever owns the reporting endpoint.&lt;/p&gt;</description></item></channel></rss>