<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <!-- Source: https://kernelmaker.github.io/feed.xml -->
  <generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator>
  <link href="https://siftrss.com/f/kpPjBJjzqr5" rel="self" type="application/atom+xml"/>
  <link href="https://kernelmaker.github.io/" rel="alternate" type="text/html"/>
  <updated>2026-10-20T11:40:33+00:00</updated>
  <id>https://siftrss.com/f/kpPjBJjzqr5</id>
  <title type="html">Zhao Song’s Blog</title>
  <subtitle>Database internals</subtitle>
  <author>
    <name>Zhao Song</name>
  </author>
  <entry>
    <title type="html">MySQL vs PostgreSQL Internals (Part 3) – Row Locking</title>
    <link href="https://kernelmaker.github.io/mysql-vs-pg-rowlocking" rel="alternate" type="text/html" title="MySQL vs PostgreSQL Internals (Part 3) – Row Locking"/>
    <published>2026-10-20T00:00:00+00:00</published>
    <updated>2026-10-20T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/mysql-vs-pg-rowlocking</id>
    <content type="html" xml:base="https://kernelmaker.github.io/mysql-vs-pg-rowlocking"><![CDATA[<p>As the third post in the MySQL vs PostgreSQL series, this post compares their row locks. Row locks are involved throughout transaction execution, and there is a lot to cover. It is not possible to discuss everything in a single post. This post starts with a specific query and compares how the two systems design and implement its execution.</p>

<h2 id="the-question">The Question</h2>

<p>Consider the following table:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">t</span> <span class="p">(</span><span class="n">id</span> <span class="nb">int</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span> <span class="n">v</span> <span class="nb">int</span><span class="p">);</span>
</code></pre></div></div>

<p>Insert 100,000 rows into both PostgreSQL and MySQL, with <code class="language-plaintext highlighter-rouge">id</code> ranging from 1 to 100000 and arbitrary values for <code class="language-plaintext highlighter-rouge">v</code> (for example, <code class="language-plaintext highlighter-rouge">v = id * 7919 % 1000</code>). Then execute:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">t</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">&gt;</span> <span class="mi">99990</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">v</span> <span class="k">FOR</span> <span class="k">UPDATE</span><span class="p">;</span>
</code></pre></div></div>

<p>How do the two systems execute this statement, and what are the differences?</p>

<h2 id="1-overall-execution-flow">1. Overall Execution Flow</h2>

<h3 id="postgresql">PostgreSQL</h3>

<p>PostgreSQL generates the following execution plan for this statement:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LockRows
  -&gt;  Sort
        Sort Key: t.v
        -&gt;  Index Scan using t_pkey on t
              Index Cond: (t.id &gt; 99990)
</code></pre></div></div>

<p>The executor pulls rows from the top node one at a time: whenever an upper node needs a row, it requests one from the node below. Execution of this statement has three stages:</p>

<ol>
  <li>
    <p><strong>Scan</strong>: When LockRows first requests a row from Sort, Sort needs to collect all its input first, so it repeatedly requests rows from Index Scan. Index Scan finds each index entry satisfying <code class="language-plaintext highlighter-rouge">id &gt; 99990</code> in the primary-key B+Tree index <code class="language-plaintext highlighter-rouge">t_pkey</code>, then uses its TID to fetch the version of the row visible to the current snapshot from the heap, producing <code class="language-plaintext highlighter-rouge">(id, v, ctid)</code>. Sort copies each row into its own memory. No row locks are taken at this stage.</p>
  </li>
  <li>
    <p><strong>Sort</strong>: After Index Scan has returned all rows, Sort sorts them by <code class="language-plaintext highlighter-rouge">v</code>. No row locks are taken at this stage either.</p>
  </li>
  <li>
    <p><strong>Lock and return</strong>: LockRows then fetches the next row from Sort, uses its <code class="language-plaintext highlighter-rouge">ctid</code> to access the heap again, locks the row, and returns it to the client. This continues until all rows from Sort have been consumed.</p>
  </li>
</ol>

<p>In other words, PostgreSQL does not lock rows during scanning or sorting. It locks them one by one only after sorting, in the order of <code class="language-plaintext highlighter-rouge">v</code>.</p>

<p>The following figure shows these three stages.</p>

<p><img src="/public/images/2026-10-20/1.png" alt="image-1" /></p>

<h3 id="mysql">MySQL</h3>

<p>MySQL generates the following execution plan for this statement:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-&gt; Sort: t.v
    -&gt; Filter: (t.id &gt; 99990)
        -&gt; Index range scan on t using PRIMARY over (99990 &lt; id)
</code></pre></div></div>

<p>MySQL’s executor also pulls rows one at a time: whenever an upper iterator needs a row, it calls <code class="language-plaintext highlighter-rouge">Read()</code> on the iterator below. Unlike PostgreSQL, there is no dedicated locking node in the execution plan. Locking happens when InnoDB reads the data. Execution of this statement has two stages:</p>

<ol>
  <li>
    <p><strong>Scan and lock</strong>: When SortingIterator is called for the first time, it needs to collect all its input, so it repeatedly calls IndexRangeScanIterator through FilterIterator to read rows. Each read goes through the handler interface into InnoDB’s <code class="language-plaintext highlighter-rouge">row_search_mvcc</code>: it locates the next record satisfying <code class="language-plaintext highlighter-rouge">id &gt; 99990</code> in the primary key (clustered index), locks that record, and then copies its latest version to the upper layer. Locking reads do not use a snapshot (read view); they read the latest committed version. If another transaction has locked the record, execution waits here. Under the default REPEATABLE READ isolation level, InnoDB takes a next-key lock (covering the record itself and the gap before it), and when the scan reaches the end of the index, it also locks the supremum pseudo-record. FilterIterator checks <code class="language-plaintext highlighter-rouge">id &gt; 99990</code> again for each returned row. Since the range scan has already ensured this condition, every row passes the filter. filesort copies each row into the sort buffer.</p>
  </li>
  <li>
    <p><strong>Sort and return</strong>: Once scanning is complete, filesort sorts the rows by <code class="language-plaintext highlighter-rouge">v</code>. SortingIterator then returns the next row from the sort buffer to the client each time, without accessing InnoDB again. All these rows were already locked in stage 1.</p>
  </li>
</ol>

<p>In other words, MySQL locks rows as it scans them. By the time it sorts and returns the rows, they are already locked. The locking order follows <code class="language-plaintext highlighter-rouge">id</code>.</p>

<p>The following figure shows these two stages.</p>

<p><img src="/public/images/2026-10-20/2.png" alt="image-2" /></p>

<h3 id="comparison">Comparison</h3>

<p>Looking at the overall flow, PostgreSQL scans first, sorts next, and locks last, while MySQL locks during scanning and then sorts. This leads to several differences: PostgreSQL locks each row after sorting and before returning it, so the locking order follows <code class="language-plaintext highlighter-rouge">v</code>; MySQL locks rows during scanning, so the locking order follows <code class="language-plaintext highlighter-rouge">id</code>. PostgreSQL reads the versions visible to its snapshot during scanning, while MySQL reads the latest committed versions. During sorting, PostgreSQL has not yet locked these rows, while MySQL has already locked them.</p>

<h2 id="2-index-scan">2. Index Scan</h2>

<p>Scanning is the first step in both execution flows, but their internal implementations differ.</p>

<h3 id="postgresql-1">PostgreSQL</h3>

<p>The execution function for the Index Scan node is <code class="language-plaintext highlighter-rouge">ExecIndexScan</code>. Like other scan nodes, it is driven by the general scan loop <code class="language-plaintext highlighter-rouge">ExecScanExtended</code>, while <code class="language-plaintext highlighter-rouge">IndexNext</code> implements the actual fetching of the next row. Each row is fetched in two steps:</p>

<ol>
  <li>Scan the B+Tree index. For each index tuple that satisfies the condition, use its TID to fetch the corresponding heap tuple from the heap and check its visibility against the snapshot. If it is not visible, continue to the next index tuple; if it is visible, proceed to the next step.</li>
  <li>Build the result row from the columns required by the query, and append the heap tuple’s TID as the <code class="language-plaintext highlighter-rouge">ctid</code> column. This is necessary because the row is not locked during scanning. When the row reaches the upper LockRows node, LockRows will use this TID to access the page containing the row again and lock it (by then, another transaction may have modified the row, as discussed later).</li>
</ol>

<p>The following figure shows this flow:</p>

<p><img src="/public/images/2026-10-20/3.png" alt="image-3" /></p>

<p>If we look at this scan from the data perspective, it looks like the following:</p>

<p><img src="/public/images/2026-10-20/4.png" alt="image-4" /></p>

<h3 id="mysql-1">MySQL</h3>

<p>The <code class="language-plaintext highlighter-rouge">Read()</code> method of IndexRangeScanIterator enters InnoDB through the handler interface: the first call goes through <code class="language-plaintext highlighter-rouge">read_range_first</code> to <code class="language-plaintext highlighter-rouge">ha_innobase::index_read</code>, and subsequent calls go through <code class="language-plaintext highlighter-rouge">read_range_next</code> to <code class="language-plaintext highlighter-rouge">ha_innobase::index_next</code>. Both eventually call <code class="language-plaintext highlighter-rouge">row_search_mvcc</code>. This example scans the primary key, which is the clustered index. Each call to <code class="language-plaintext highlighter-rouge">row_search_mvcc</code> involves three steps:</p>

<ol>
  <li>Move the cursor to the next record in the clustered index. The leaf nodes of an InnoDB clustered index store the entire row, so there is no need to use a TID to fetch the row from a separate structure, as PostgreSQL does.</li>
  <li>Lock the record. If another transaction has locked it, wait here. After acquiring the lock, if the record is found to be delete-marked, skip it and continue to the next record.</li>
  <li>Copy the latest version of the record into <code class="language-plaintext highlighter-rouge">table-&gt;record[0]</code> and return it to the upper layer. Locking reads do not use a snapshot, so there is no visibility check here. Since the record was already locked during scanning, the returned row does not need to carry its location as PostgreSQL’s does.</li>
</ol>

<p>When the scan reaches the end of the range (the end of the index in this example), under REPEATABLE READ it also locks the supremum pseudo-record, then returns <code class="language-plaintext highlighter-rouge">HA_ERR_END_OF_FILE</code> to end the scan.</p>

<p>The following figure shows this flow:</p>

<p><img src="/public/images/2026-10-20/5.png" alt="image-5" /></p>

<p>If we look at this scan from the data perspective, it looks like the following:</p>

<p><img src="/public/images/2026-10-20/6.png" alt="image-6" /></p>

<h3 id="comparison-1">Comparison</h3>

<p>There are two main differences in index scanning. First, MySQL can directly obtain the latest version of the entire row from the clustered index. PostgreSQL first finds a TID in the B+Tree index, then fetches the row from the heap and checks its visibility against the snapshot. Second, MySQL locks a record immediately after finding it and waits if another transaction has locked it. PostgreSQL does not lock rows during scanning. Instead, it builds the result row, attaches <code class="language-plaintext highlighter-rouge">ctid</code>, and leaves locking to the upper LockRows node.</p>

<h2 id="3-locking-flow">3. Locking Flow</h2>

<h3 id="postgresql-2">PostgreSQL</h3>

<p>This is one of the biggest differences between the two systems. In PostgreSQL, scanning and locking happen at different stages: a row read during scanning may have been locked, updated, or deleted by another transaction by the time it is locked. Therefore, every time LockRows fetches a row from Sort, it must check the row’s current state before deciding what to do: wait for another transaction to finish, lock the updated version, or skip the row. The following figure shows this process:</p>

<p><img src="/public/images/2026-10-20/7.png" alt="image-7" /></p>

<p><code class="language-plaintext highlighter-rouge">table_tuple_lock</code> is an interface of the table access method. For heap tables, it is implemented by <code class="language-plaintext highlighter-rouge">heapam_tuple_lock</code>, while the actual locking is performed by <code class="language-plaintext highlighter-rouge">heap_lock_tuple</code>. <code class="language-plaintext highlighter-rouge">heap_lock_tuple</code> locates the row using <code class="language-plaintext highlighter-rouge">ctid</code>, checks its current state, and handles the following cases:</p>

<ol>
  <li><strong>No conflict</strong>: No other transaction holds a conflicting lock, and the row has not been modified. Lock it directly (the locking implementation is discussed in the next section).</li>
  <li><strong>Another transaction holds a conflicting lock or is modifying the row</strong>: Wait for that transaction to finish, then check again.</li>
  <li><strong>The row has been updated by a committed transaction</strong>: Under READ COMMITTED, <code class="language-plaintext highlighter-rouge">heapam_tuple_lock</code> follows the update chain to find and lock the latest version, and marks it as traversed. Under REPEATABLE READ, it directly returns <code class="language-plaintext highlighter-rouge">TM_Updated</code>.</li>
  <li><strong>The row has been deleted by a committed transaction</strong>: Return <code class="language-plaintext highlighter-rouge">TM_Deleted</code>.</li>
</ol>

<p>LockRows decides how to handle the row based on the return value:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">TM_Ok</code>, not traversed: The locked version is the same one read during scanning, so return the row directly.</li>
  <li><code class="language-plaintext highlighter-rouge">TM_Ok</code>, traversed (only under READ COMMITTED): The locked version is a newer, updated version, which may no longer satisfy the query condition. LockRows re-executes the lower plan using the new version (a mechanism called EvalPlanQual) to check whether it still satisfies <code class="language-plaintext highlighter-rouge">id &gt; 99990</code>. If it does, return the values from the new version. Otherwise, skip the row, but keep it locked. Note that the rows are not sorted again. If the new version has a different <code class="language-plaintext highlighter-rouge">v</code>, the returned results may no longer be ordered by <code class="language-plaintext highlighter-rouge">v</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">TM_Deleted</code>: Under READ COMMITTED, skip the row. Under REPEATABLE READ, report an error (<code class="language-plaintext highlighter-rouge">could not serialize access due to concurrent update</code>).</li>
  <li><code class="language-plaintext highlighter-rouge">TM_Updated</code> (only under REPEATABLE READ): Report the same error.</li>
  <li><code class="language-plaintext highlighter-rouge">TM_WouldBlock</code>: This occurs only when <code class="language-plaintext highlighter-rouge">SKIP LOCKED</code> is used. Skip the row.</li>
</ul>

<p>PostgreSQL’s default isolation level is READ COMMITTED, so by default, rows modified by other transactions after scanning are rechecked through EvalPlanQual as described above.</p>

<p>Let’s use the row with <code class="language-plaintext highlighter-rouge">id = 99995</code> to see how EvalPlanQual works. The version read during scanning has <code class="language-plaintext highlighter-rouge">v = 405</code>. Before LockRows locks it, another transaction updates it to <code class="language-plaintext highlighter-rouge">v = 7</code> and commits. When LockRows tries to lock the row, it finds that the row has been updated, follows the update chain to lock the new version, and returns <code class="language-plaintext highlighter-rouge">TM_Ok</code> with the traversed flag set, triggering EvalPlanQual.</p>

<p>EvalPlanQual re-executes a separate copy of the lower plan (Sort and Index Scan), while the remaining rows in the main plan’s Sort stay unchanged. In this copy of the plan, Index Scan no longer scans the index. Instead, it returns only the newly locked version, and does so only once. It rechecks <code class="language-plaintext highlighter-rouge">id &gt; 99990</code> against the new version, and Sort only needs to sort this single row. Since the new version satisfies the condition, LockRows returns its values, <code class="language-plaintext highlighter-rouge">(99995, 7)</code>. If it did not satisfy the condition, LockRows would skip the row but keep it locked. LockRows then returns to the main plan and continues fetching the next row from Sort.</p>

<p>Because only this row is re-executed, it is not sorted again together with the other rows: it was sorted using <code class="language-plaintext highlighter-rouge">v = 405</code>, but is returned with <code class="language-plaintext highlighter-rouge">v = 7</code>, still in its original position. The following figure shows this process:</p>

<p><img src="/public/images/2026-10-20/8.png" alt="image-8" /></p>

<h3 id="mysql-2">MySQL</h3>

<p>MySQL has already completed locking during scanning, so its flow is much simpler. InnoDB locks a record immediately after reading it, and reads the latest version rather than a snapshot. Therefore, there is no window in which a record can be modified between being read and being locked. Conflicts are also handled during scanning:</p>

<ul>
  <li><strong>The record is locked by another transaction</strong>: Wait for that transaction to finish, then read the latest version of the record again.</li>
  <li><strong>The record has been updated by another transaction</strong>: After waiting for the transaction to finish, InnoDB reads the record again at the same position in the index and gets the updated version. The record is still within the scan range, so there is no need to recheck it through a mechanism like EvalPlanQual. (If the update changes the primary key, InnoDB marks the old record as deleted and inserts a new record. When the scan encounters the old record, it skips it as described next.)</li>
  <li><strong>The record has been deleted by another transaction</strong>: After acquiring the lock, InnoDB finds that the record is delete-marked, skips it, and continues scanning.</li>
</ul>

<p>Rows that are successfully locked are passed directly to the upper layer, without the need to handle the locking result as PostgreSQL does. Under REPEATABLE READ, there is also no error caused by a concurrent modification after scanning.</p>

<h3 id="comparison-2">Comparison</h3>

<p>The locking flows differ significantly. PostgreSQL reads from a snapshot during scanning and locks rows only after sorting. When locking, it must handle modifications made since the scan, resulting in several possible outcomes: waiting, locking and rechecking a new version, skipping a row, or reporting an error. It also needs a dedicated mechanism like EvalPlanQual to recheck whether the new version still satisfies the query condition. MySQL locks the latest version during scanning. When there is a conflict, it waits and then reads the latest version. There is no window between scanning and locking, which makes the flow much simpler.</p>

<h2 id="4-lock-implementation">4. Lock Implementation</h2>

<p>The two systems also implement row locks differently: PostgreSQL stores row-lock information in the row itself, while InnoDB stores row locks in its in-memory lock system, organized by page and looked up through a hash table.</p>

<h3 id="postgresql-3">PostgreSQL</h3>

<p>PostgreSQL stores row-lock information in the heap tuple header. The <code class="language-plaintext highlighter-rouge">xmin</code> and <code class="language-plaintext highlighter-rouge">xmax</code> fields in the header record the transactions that inserted and deleted (or updated) the row, respectively. Row locks reuse <code class="language-plaintext highlighter-rouge">xmax</code>: when a transaction locks a row, its transaction ID is also written into <code class="language-plaintext highlighter-rouge">xmax</code>. Flags in <code class="language-plaintext highlighter-rouge">t_infomask</code> and <code class="language-plaintext highlighter-rouge">t_infomask2</code> distinguish whether <code class="language-plaintext highlighter-rouge">xmax</code> represents a deleting transaction or only a locking transaction, as well as the lock mode. The details are as follows:</p>

<p><strong>Whether xmax represents a locker or a deleter.</strong> If <code class="language-plaintext highlighter-rouge">HEAP_XMAX_LOCK_ONLY</code> is set in <code class="language-plaintext highlighter-rouge">t_infomask</code>, the transaction in <code class="language-plaintext highlighter-rouge">xmax</code> has only locked the row, without deleting or updating it. Otherwise, <code class="language-plaintext highlighter-rouge">xmax</code> represents the transaction that deleted or updated the row.</p>

<p><strong>Lock modes.</strong> PostgreSQL has four row-lock modes, represented by different flags in <code class="language-plaintext highlighter-rouge">t_infomask</code>:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">FOR KEY SHARE</code>: <code class="language-plaintext highlighter-rouge">HEAP_XMAX_KEYSHR_LOCK</code></li>
  <li><code class="language-plaintext highlighter-rouge">FOR SHARE</code>: <code class="language-plaintext highlighter-rouge">HEAP_XMAX_SHR_LOCK</code>, which is not a separate flag but the combination of <code class="language-plaintext highlighter-rouge">HEAP_XMAX_EXCL_LOCK</code> and <code class="language-plaintext highlighter-rouge">HEAP_XMAX_KEYSHR_LOCK</code></li>
  <li><code class="language-plaintext highlighter-rouge">FOR NO KEY UPDATE</code>: <code class="language-plaintext highlighter-rouge">HEAP_XMAX_EXCL_LOCK</code></li>
  <li><code class="language-plaintext highlighter-rouge">FOR UPDATE</code>: <code class="language-plaintext highlighter-rouge">HEAP_XMAX_EXCL_LOCK</code>, together with <code class="language-plaintext highlighter-rouge">HEAP_KEYS_UPDATED</code> in <code class="language-plaintext highlighter-rouge">t_infomask2</code></li>
</ul>

<p>Both <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> and <code class="language-plaintext highlighter-rouge">FOR NO KEY UPDATE</code> are exclusive locks, so <code class="language-plaintext highlighter-rouge">HEAP_XMAX_EXCL_LOCK</code> alone cannot distinguish them. PostgreSQL therefore uses <code class="language-plaintext highlighter-rouge">HEAP_KEYS_UPDATED</code> to indicate the stronger <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> lock. This flag is also used for deletes and updates that modify key columns. In all these cases, it means that the transaction in <code class="language-plaintext highlighter-rouge">xmax</code> holds the strongest row lock.</p>

<p><strong>The example in this post.</strong> After executing <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code>, each of the 10 locked rows has the current transaction’s ID in <code class="language-plaintext highlighter-rouge">xmax</code>, <code class="language-plaintext highlighter-rouge">HEAP_XMAX_LOCK_ONLY | HEAP_XMAX_EXCL_LOCK</code> set in <code class="language-plaintext highlighter-rouge">t_infomask</code>, and <code class="language-plaintext highlighter-rouge">HEAP_KEYS_UPDATED</code> set in <code class="language-plaintext highlighter-rouge">t_infomask2</code>. Because the tuple header is modified, the pages containing these rows are marked dirty, and each row lock also generates a WAL record of type <code class="language-plaintext highlighter-rouge">XLOG_HEAP_LOCK</code>. In other words, taking a row lock in PostgreSQL is a write operation.</p>

<p><strong>Conflict detection.</strong> When another transaction wants to update or lock the row, it reads <code class="language-plaintext highlighter-rouge">xmax</code> and the flags, then checks whether the transaction recorded in <code class="language-plaintext highlighter-rouge">xmax</code> is still running. If it is still running and the lock modes conflict, it waits for that transaction to finish. This is the waiting process in <code class="language-plaintext highlighter-rouge">heap_lock_tuple</code> discussed in Section 3.</p>

<p><strong>Multiple transactions holding locks on the same row.</strong> If multiple transactions hold locks on a row at the same time (for example, they all hold <code class="language-plaintext highlighter-rouge">FOR SHARE</code>), a single <code class="language-plaintext highlighter-rouge">xmax</code> cannot store multiple transaction IDs. In this case, <code class="language-plaintext highlighter-rouge">xmax</code> stores a MultiXactId, with <code class="language-plaintext highlighter-rouge">HEAP_XMAX_IS_MULTI</code> set in <code class="language-plaintext highlighter-rouge">t_infomask</code>. Each member transaction and its lock mode are recorded in separate MultiXact storage.</p>

<p><strong>Lock release.</strong> When a transaction commits or rolls back, PostgreSQL does not modify the rows it locked. The locks simply become invalid when the transaction ends. Later, when another transaction tries to update or lock the row (or when VACUUM processes the page), it finds that the transaction in <code class="language-plaintext highlighter-rouge">xmax</code> has finished, treats that <code class="language-plaintext highlighter-rouge">xmax</code> as invalid, and sets the hint bit <code class="language-plaintext highlighter-rouge">HEAP_XMAX_INVALID</code>. The <code class="language-plaintext highlighter-rouge">xmax</code> value and other flags remain until the row is locked, updated, or deleted again, or until they are overwritten or cleared by VACUUM freezing.</p>

<h3 id="mysql-3">MySQL</h3>

<p>InnoDB does not store row locks in the rows themselves. Instead, they are maintained in the in-memory lock system, <code class="language-plaintext highlighter-rouge">lock_sys</code>. I will only give a brief overview here. For more details, see my previous posts on InnoDB locking: <a href="https://kernelmaker.github.io/MySQL-Lock-1">MySQL Lock (1)</a>, <a href="https://kernelmaker.github.io/MySQL-Lock-2">MySQL Lock (2)</a>, and <a href="https://kernelmaker.github.io/MySQL-implicit-locks">MySQL Implicit Locks</a>.</p>

<p><strong>Lock organization.</strong> Record locks are organized by page. Locks taken by the same transaction, on the same page, in the same mode share one <code class="language-plaintext highlighter-rouge">lock_t</code> structure. The <code class="language-plaintext highlighter-rouge">lock_t</code> stores the page number and is followed by a bitmap. Each bit in the bitmap corresponds to a record on that page (identified by its record number within the page, <code class="language-plaintext highlighter-rouge">heap_no</code>), and a bit set to 1 indicates that the record is locked. <code class="language-plaintext highlighter-rouge">lock_sys</code> uses a hash table keyed by page ID (tablespace number + page number) to look up these <code class="language-plaintext highlighter-rouge">lock_t</code> structures. As a result, if a transaction locks many records on the same page, it only needs one <code class="language-plaintext highlighter-rouge">lock_t</code>. These locks exist only in memory: acquiring them does not modify records or generate redo logs, unlike PostgreSQL.</p>

<p><strong>Lock types.</strong> Each record lock has a mode, <code class="language-plaintext highlighter-rouge">S</code> (shared) or <code class="language-plaintext highlighter-rouge">X</code> (exclusive), together with a range:</p>

<ul>
  <li>next-key lock (<code class="language-plaintext highlighter-rouge">LOCK_ORDINARY</code>): Locks the record itself and the gap before it.</li>
  <li><code class="language-plaintext highlighter-rouge">LOCK_REC_NOT_GAP</code>: Locks only the record itself.</li>
  <li><code class="language-plaintext highlighter-rouge">LOCK_GAP</code>: Locks only the gap before the record.</li>
  <li><code class="language-plaintext highlighter-rouge">LOCK_INSERT_INTENTION</code>: An insert intention lock taken on a gap before insertion.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> takes an <code class="language-plaintext highlighter-rouge">X</code> lock. Under REPEATABLE READ, range scans generally take next-key locks. Under READ COMMITTED, they take only <code class="language-plaintext highlighter-rouge">LOCK_REC_NOT_GAP</code> locks, without locking gaps.</p>

<p><strong>The example in this post.</strong> Under REPEATABLE READ, this statement takes an <code class="language-plaintext highlighter-rouge">X</code> next-key lock on each of the 10 records in the primary key, and also locks the supremum pseudo-record. Under READ COMMITTED, it takes only <code class="language-plaintext highlighter-rouge">X,REC_NOT_GAP</code> locks on the 10 records.</p>

<p><strong>Conflict detection.</strong> When a transaction wants to lock a record, InnoDB uses the page number to find all <code class="language-plaintext highlighter-rouge">lock_t</code> structures for that page in <code class="language-plaintext highlighter-rouge">lock_sys</code>, then checks whether any other transaction’s lock conflicts on the bit corresponding to that record. If there is a conflict, it creates a waiting <code class="language-plaintext highlighter-rouge">lock_t</code> for the requesting transaction and waits for the transaction holding the lock to release it.</p>

<p><strong>Implicit locks.</strong> There is also a type of lock that is not stored in <code class="language-plaintext highlighter-rouge">lock_sys</code>. For a newly inserted, uncommitted record, the hidden column <code class="language-plaintext highlighter-rouge">DB_TRX_ID</code> contains the ID of the transaction that inserted it. InnoDB treats this as an implicit exclusive lock held by that transaction on the record, without creating a <code class="language-plaintext highlighter-rouge">lock_t</code>. Only when another transaction tries to lock the record does InnoDB first convert the implicit lock into an explicit lock in <code class="language-plaintext highlighter-rouge">lock_sys</code> and then perform conflict detection.</p>

<p><strong>Lock release.</strong> When a transaction commits or rolls back, InnoDB removes all the locks held by that transaction from <code class="language-plaintext highlighter-rouge">lock_sys</code> and wakes up transactions waiting for them. No changes to the records themselves are needed.</p>

<h3 id="comparison-3">Comparison</h3>

<p>The main difference in lock implementation is where the locks are stored. PostgreSQL writes row locks into tuple headers, so <strong>acquiring a lock modifies data pages and generates WAL</strong>, but does not consume additional memory. There is no limit on the number of rows that can be locked. InnoDB stores row locks in the in-memory <code class="language-plaintext highlighter-rouge">lock_sys</code>, so acquiring a lock neither modifies records nor generates logs, but it consumes memory. Locked records are tracked with bitmaps organized by page.</p>

<h2 id="5-lock-waiting">5. Lock Waiting</h2>

<p>If a row has already been locked by another transaction, the current transaction must wait for the lock holder to finish before it can lock the row. But how does this waiting work? The two systems also differ significantly here.</p>

<h3 id="postgresql-4">PostgreSQL</h3>

<p>PostgreSQL knows the transaction ID of the row-lock holder from <code class="language-plaintext highlighter-rouge">xmax</code> and waits for that transaction to finish. This waiting uses PostgreSQL’s regular lock manager (the heavyweight lock manager), which maintains various locks in shared memory using a hash table. Table locks are managed by the same lock manager. When a transaction is assigned a transaction ID (when it first modifies data or first takes a row lock), it acquires an exclusive lock (<code class="language-plaintext highlighter-rouge">ExclusiveLock</code>) on its own transaction ID in the lock manager and holds it until the transaction ends. Another transaction that needs to wait for it requests a shared lock (<code class="language-plaintext highlighter-rouge">ShareLock</code>) on that transaction ID. This request blocks until the holder finishes. Once the request succeeds, the shared lock is immediately released, and the waiting transaction returns to the row to check its state again. If the row’s <code class="language-plaintext highlighter-rouge">xmax</code> contains a MultiXactId, it waits for each conflicting member transaction in turn.</p>

<p>But if multiple transactions are waiting for the same row, which one gets the row lock first? If they all just wait on the holder’s transaction ID, they will be woken up together when the holder finishes, then race to acquire the row lock. There is no guarantee of the order, and one transaction might keep losing the race.</p>

<p>To avoid this, before waiting on the transaction ID, <code class="language-plaintext highlighter-rouge">heap_lock_tuple</code> first acquires a lock on the row itself in the lock manager (a tuple lock), which determines the order of the waiters:</p>

<ol>
  <li>The first waiter acquires the tuple lock, then waits on the holder’s transaction ID.</li>
  <li>Later waiters queue on the tuple lock instead of waiting on the holder’s transaction ID.</li>
  <li>When the holder finishes, the first waiter wakes up, locks the row (by writing its own transaction ID into <code class="language-plaintext highlighter-rouge">xmax</code>), and releases the tuple lock.</li>
  <li>The next waiter acquires the tuple lock, finds that the row is now locked by the first waiter, and starts waiting on the first waiter’s transaction ID instead.</li>
</ol>

<p>In this way, transactions waiting for the same row acquire the row lock in the order in which they queued for the tuple lock. The tuple lock is held only while waiting and is released once the row has been locked. The row lock itself is still recorded only in <code class="language-plaintext highlighter-rouge">xmax</code>.</p>

<p>Let’s use an example to illustrate this process. T1 has already locked the row (<code class="language-plaintext highlighter-rouge">xmax = T1</code>). T2, T3, and T4 arrive in that order and all want to lock it with <code class="language-plaintext highlighter-rouge">FOR UPDATE</code>. Before T1 finishes, the state is shown below:</p>

<p><img src="/public/images/2026-10-20/9.png" alt="image-9" /></p>

<p>There are two relationships worth noting in the figure: the waiters know from the row’s <code class="language-plaintext highlighter-rouge">xmax</code> that the holder is T1, so they wait on the lock for transaction ID T1; the tuple lock is keyed by the row’s location (page number and offset within the page), and all transactions that want to lock the row queue on it. T2 arrives first, acquires the tuple lock, and requests a shared lock on T1’s transaction ID lock, waiting for T1 to finish. T3 and T4 queue behind T2 on the tuple lock.</p>

<p>After T1 commits, the state changes to the following:</p>

<p><img src="/public/images/2026-10-20/10.png" alt="image-10" /></p>

<p>T1’s transaction ID lock disappears when T1 finishes. T2 wakes up, writes its transaction ID into the row’s <code class="language-plaintext highlighter-rouge">xmax</code> to acquire the row lock, and then releases the tuple lock. T3, now at the front of the queue, acquires the tuple lock. It finds through <code class="language-plaintext highlighter-rouge">xmax</code> that the row is locked by T2, so it starts waiting on T2’s transaction ID lock instead. T4 remains queued on the tuple lock.</p>

<p>The process above assumes that the waiters’ lock modes conflict with one another (for example, they all use <code class="language-plaintext highlighter-rouge">FOR UPDATE</code>). Tuple locks use the same lock modes and conflict rules as table locks. Therefore, if the waiters’ lock modes are compatible (for example, they all use <code class="language-plaintext highlighter-rouge">FOR SHARE</code>), they can hold the tuple lock at the same time and wait together for the holder to finish. When the holder finishes, they can all lock the row, and <code class="language-plaintext highlighter-rouge">xmax</code> becomes a MultiXactId containing them. However, if an incompatible waiter is queued between them (for example, a <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> waiter), later waiters must queue behind that waiter even if they are compatible with the current holder, to prevent that waiter from being starved.</p>

<p>Since all waiting goes through the lock manager, PostgreSQL’s deadlock detection is also handled by the lock manager. After a transaction has been waiting longer than <code class="language-plaintext highlighter-rouge">deadlock_timeout</code> (1 second by default), the lock manager checks whether the lock-wait relationships contain a cycle. If there is one, the transaction that detects the deadlock reports an error and exits, allowing other transactions to continue.</p>

<h3 id="mysql-4">MySQL</h3>

<p>InnoDB waits on the record lock itself, rather than on the transaction holding it. When a transaction wants to lock a record and finds a conflicting lock held by another transaction in <code class="language-plaintext highlighter-rouge">lock_sys</code>, it creates a waiting <code class="language-plaintext highlighter-rouge">lock_t</code> for itself, adds it to the lock queue for the page containing that record, and suspends the current thread. The waiting <code class="language-plaintext highlighter-rouge">lock_t</code> is kept in the same structure as granted locks, so <code class="language-plaintext highlighter-rouge">lock_sys</code> directly shows which transaction is waiting for which record and which other transaction it is waiting on.</p>

<p>When the holder commits or rolls back, InnoDB removes its locks from <code class="language-plaintext highlighter-rouge">lock_sys</code>, then checks the waiting locks on those records, grants the locks that can now be granted, and wakes up the corresponding transactions. If multiple transactions are waiting for the same record, InnoDB does not grant locks strictly in arrival order. Instead, it gives priority to transactions with higher weights. A transaction’s weight roughly reflects how many other transactions it is blocking; when weights are equal, arrival order is used. This scheduling method is called CATS (Contention-Aware Transaction Scheduling).</p>

<p>Deadlock detection is performed by a background thread. Whenever a transaction starts waiting, this thread is notified to check whether the lock-wait relationships contain a cycle. If it finds a deadlock, it chooses one transaction to roll back. In addition, if the wait exceeds <code class="language-plaintext highlighter-rouge">innodb_lock_wait_timeout</code> (50 seconds by default), the waiting statement reports an error (<code class="language-plaintext highlighter-rouge">ERROR 1205: Lock wait timeout exceeded</code>).</p>

<h3 id="comparison-4">Comparison</h3>

<p>There are three main differences in lock waiting. First, they wait on different objects. PostgreSQL records row locks only in <code class="language-plaintext highlighter-rouge">xmax</code>, so there is no lock object on which waiters can queue. Instead, a waiter waits on the holder’s transaction ID, and a tuple lock is used to queue transactions waiting for the same row. InnoDB stores row locks directly in <code class="language-plaintext highlighter-rouge">lock_sys</code>, so waiters queue directly on the record’s lock queue. Second, the granting order differs. PostgreSQL follows the order of the tuple-lock queue (compatible locks can be granted together), while InnoDB uses CATS weights to prioritize transactions that block more other transactions. Third, deadlock detection happens at different times. In PostgreSQL, after a transaction has waited longer than <code class="language-plaintext highlighter-rouge">deadlock_timeout</code> (1 second by default), that transaction checks for deadlocks itself. InnoDB uses a background thread to check when a transaction starts waiting. Also, PostgreSQL does not limit lock-wait time by default (<code class="language-plaintext highlighter-rouge">lock_timeout</code> defaults to 0), while InnoDB reports an error after 50 seconds by default.</p>

<h2 id="6-repeatable-read">6. Repeatable Read</h2>

<p>The previous sections were based on the default isolation levels of the two systems: READ COMMITTED for PostgreSQL and REPEATABLE READ for MySQL. Under REPEATABLE READ, executing the same statement multiple times in one transaction should produce the same results. The two systems take completely different approaches to achieve this.</p>

<h3 id="postgresql-5">PostgreSQL</h3>

<p>Under REPEATABLE READ, PostgreSQL uses the same snapshot for the entire transaction: the snapshot taken when the first statement in the transaction starts. All subsequent statements read data according to this snapshot.</p>

<p>The effect on locking was mentioned in Section 3. When LockRows tries to lock a row and finds that the version read during scanning has been updated or deleted by another transaction, it does not lock the latest version and recheck it through EvalPlanQual as it would under READ COMMITTED. Instead, it directly reports an error (<code class="language-plaintext highlighter-rouge">could not serialize access due to concurrent update</code>), requiring the application to retry the entire transaction. This is because locking and returning the latest version would cause the transaction to see data that is not part of its own snapshot.</p>

<p>On the other hand, PostgreSQL locks only existing rows, not ranges. After a transaction executes this statement, another transaction can still insert a new row with <code class="language-plaintext highlighter-rouge">id = 100001</code> without waiting. This new row is not in the first transaction’s snapshot, so when that transaction executes the statement again, it still cannot see the new row and the result stays unchanged. But the new row is not locked by the first transaction either.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T1: BEGIN ISOLATION LEVEL REPEATABLE READ;
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Locks 10 rows
T2: INSERT INTO t VALUES (100001, 5);                         -- Succeeds immediately
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Still returns 10 rows
</code></pre></div></div>

<h3 id="mysql-5">MySQL</h3>

<p>Under MySQL’s REPEATABLE READ, a regular <code class="language-plaintext highlighter-rouge">SELECT</code> uses the read view established by the first read after the transaction starts. But <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> is a locking read: it does not use a read view and reads the latest committed version. Since a locking read always reads the latest data, to keep its results unchanged when it is executed multiple times in the same transaction, MySQL must prevent other transactions from inserting new rows into the scanned range.</p>

<p>This is the purpose of next-key locks and the lock on the supremum. In the example from Section 4, the statement takes next-key locks on 10 records and locks the supremum, covering the range (99990, +∞). When another transaction tries to insert <code class="language-plaintext highlighter-rouge">id = 100001</code>, it must acquire an insert intention lock on the gap before the supremum. This conflicts with those locks, so it waits until the first transaction finishes.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T1: SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION;
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Locks 10 records and gaps
T2: INSERT INTO t VALUES (100001, 5);                         -- Waits
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Still returns 10 rows
T1: COMMIT;                                                   -- T2's insert completes now
</code></pre></div></div>

<p>Under READ COMMITTED, InnoDB does not take gap locks, only locks on the records themselves. T2’s insert in the example above can succeed immediately. After T2 commits, when T1 executes the statement again, it sees and locks the newly inserted row (before T2 commits, T1 would wait because of T2’s implicit lock on that row).</p>

<p>When InnoDB encounters a record locked or being modified by another transaction during scanning, it handles the record under REPEATABLE READ the same way as under READ COMMITTED: it waits for the lock holder to finish, then reads the latest version, without reporting an error.</p>

<h3 id="comparison-5">Comparison</h3>

<p>The two systems ensure repeatable reads in completely different ways. PostgreSQL relies on snapshots: a transaction always reads from the same snapshot and does not lock ranges, so other transactions can insert new rows at any time. If a row to be locked has been modified since the snapshot, PostgreSQL reports an error and requires the application to retry. InnoDB relies on locks: locking reads always read the latest version, and next-key locks and the lock on the supremum prevent other transactions from inserting new rows into scanned ranges. When there is a conflict, other transactions wait instead of getting an error. The former allows higher concurrency, but the application must handle serialization errors and retry. The latter does not require retries, but locks larger ranges, makes other transactions’ inserts wait, and is also more likely to cause deadlocks.</p>

<h2 id="7-serializable">7. Serializable</h2>

<p>Under SERIALIZABLE, the difference between the two systems becomes even greater: PostgreSQL adds conflict detection on top of snapshots, while MySQL turns regular reads into locking reads as well.</p>

<h3 id="postgresql-6">PostgreSQL</h3>

<p>PostgreSQL’s REPEATABLE READ is actually Snapshot Isolation. Snapshot Isolation guarantees repeatable reads, but still allows anomalies such as write skew, so it is not truly serializable. PostgreSQL implements Serializable Snapshot Isolation (SSI) on top of Snapshot Isolation for its SERIALIZABLE level.</p>

<p>Under SERIALIZABLE, the locking flow of this statement is exactly the same as under REPEATABLE READ: the entire transaction uses the same snapshot, only existing rows are locked, and a row modified after scanning still causes an error. The difference is that, during scanning, PostgreSQL also records which data the transaction has read. These are called predicate locks (shown as <code class="language-plaintext highlighter-rouge">SIReadLock</code> in <code class="language-plaintext highlighter-rouge">pg_locks</code>). In this example, the B+Tree leaf pages accessed by Index Scan and the heap tuples it reads are recorded.</p>

<p>Predicate locks are not real locks: they do not block any transactions. They are used only to detect read-write dependencies between transactions. If another transaction writes into a range read by the current transaction (for example, inserting <code class="language-plaintext highlighter-rouge">id = 100001</code> into a leaf page that the current transaction has read), PostgreSQL records a dependency indicating that the current transaction read data that the other transaction wrote. Only when these dependencies form a structure that could violate serializability (for example, two transactions each read a range that the other transaction writes to) does PostgreSQL report an error for one of the transactions (<code class="language-plaintext highlighter-rouge">could not serialize access due to read/write dependencies among transactions</code>), requiring the application to retry.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T1: BEGIN ISOLATION LEVEL SERIALIZABLE;
T2: BEGIN ISOLATION LEVEL SERIALIZABLE;
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Locks 10 rows, records predicate locks
T2: SELECT * FROM t WHERE id &gt; 99990;                         -- Regular read, does not wait, records predicate locks
T1: INSERT INTO t VALUES (100002, 1);                         -- Succeeds immediately
T2: INSERT INTO t VALUES (100001, 1);                         -- Does not wait
T1: COMMIT;                                                   -- Succeeds
T2: COMMIT;                                                   -- T2 errors: each transaction read a range written by the other
</code></pre></div></div>

<p>No transaction needs to wait during this process. In the order shown above, T1 has not yet committed when T2 inserts its row, so PostgreSQL only records the dependency. When T1 commits, it detects the structure of mutual dependencies and marks T2 as a transaction that must fail. T2 then reports the error at commit. If T1 had already committed before T2’s insert, the error would occur directly on T2’s <code class="language-plaintext highlighter-rouge">INSERT</code>. If only T2 inserted a row and T1 did not, there would be only a one-way dependency, and both transactions could commit successfully.</p>

<h3 id="mysql-6">MySQL</h3>

<p>MySQL’s SERIALIZABLE makes just one change on top of REPEATABLE READ: inside non-autocommit transactions, a regular <code class="language-plaintext highlighter-rouge">SELECT</code> is also executed as <code class="language-plaintext highlighter-rouge">SELECT ... FOR SHARE</code>, taking <code class="language-plaintext highlighter-rouge">S</code> next-key locks on the records and gaps it reads.</p>

<p>For the statement in this post, <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> is already a locking read, so locking is exactly the same as under REPEATABLE READ: <code class="language-plaintext highlighter-rouge">X</code> next-key locks on the 10 records, plus a lock on the supremum. The difference appears in regular reads performed by other transactions. They must take <code class="language-plaintext highlighter-rouge">S</code> locks, which conflict with these <code class="language-plaintext highlighter-rouge">X</code> locks, so they have to wait.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T1: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION;
T2: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION;
T1: SELECT * FROM t WHERE id &gt; 99990 ORDER BY v FOR UPDATE;   -- Locks 10 records and gaps
T2: SELECT * FROM t WHERE id &gt; 99990;                         -- Regular read also needs S locks, waits
T1: COMMIT;                                                   -- T2's read returns only now
</code></pre></div></div>

<p>In other words, InnoDB also locks reads to place potentially conflicting transactions into a definite order. It does not need to detect conflicts at commit time, and serialization errors do not occur (although deadlocks and lock-wait timeouts can still cause errors). The cost is that reads and writes block one another, reducing concurrency.</p>

<h3 id="comparison-6">Comparison</h3>

<p>The two systems take completely different approaches to serializability. PostgreSQL uses SSI: reads do not acquire blocking locks; it only records the data that was read, and writes do not have to wait because of these records. During reads, writes, and commits, it checks for dependency structures that could violate serializability, and only then makes a transaction fail and retry. This is an optimistic approach. InnoDB uses locking: on top of REPEATABLE READ, it also makes regular reads acquire shared locks, so conflicting transactions wait directly. This is a pessimistic approach. For the <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> in this post, both systems use the same locking flow under SERIALIZABLE as under REPEATABLE READ. The difference lies in other transactions running concurrently with it: in PostgreSQL, other transactions’ reads and inserts do not need to wait, but may fail (as late as commit time); in InnoDB, other transactions’ reads and inserts must wait, but do not fail except in cases such as deadlocks or lock-wait timeouts.</p>

<h2 id="8-summary">8. Summary</h2>

<p>Row locking is one of the core components of a transactional database. The trade-offs in its design and implementation directly affect the database’s concurrency and how applications use it. By looking at a single <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code> statement, we can see several major differences between PostgreSQL and MySQL.</p>

<p>First, they differ in when they lock rows and which versions they read. PostgreSQL scans according to a snapshot and locks rows one by one only after sorting. Therefore, it must handle rows modified after scanning, with different possible outcomes: waiting, locking and rechecking a new version through EvalPlanQual, skipping a row, or reporting an error. MySQL locks the latest versions while scanning, making the flow much simpler.</p>

<p>Second, they differ in where locks are stored. PostgreSQL writes row locks into tuple headers, so locking modifies data pages and generates WAL, but does not consume additional memory. InnoDB stores row locks in the in-memory <code class="language-plaintext highlighter-rouge">lock_sys</code>, so locking neither modifies records nor generates logs.</p>

<p>Third, they differ in how they wait. PostgreSQL waits on the holder’s transaction ID and uses tuple locks to queue waiters. InnoDB waits directly on the record’s lock queue and uses CATS weights to determine granting order.</p>

<p>Fourth, they differ in how they enforce isolation levels. PostgreSQL relies on snapshots and does not lock ranges. Under REPEATABLE READ, it reports an error when it encounters a conflicting modification. Under SERIALIZABLE, it uses SSI to detect conflicts during reads, writes, and commits. In both cases, the application must retry. InnoDB relies on locks, uses next-key locks to lock ranges, and also locks regular reads under SERIALIZABLE, making conflicting transactions wait directly.</p>

<p>Behind these differences are two different approaches. PostgreSQL locks only the rows that actually need to be locked, not ranges. It mainly relies on snapshots and conflict detection to enforce isolation levels, and when a conflict cannot be handled, it reports an error and lets the application retry. InnoDB locks ranges in addition to rows, using locks to prevent potential conflicts in advance, and makes other transactions wait when conflicts occur.</p>

<p>There is much more to row locking, such as the locking flows for UPDATE and DELETE, and the details of MultiXact and implicit locks. I have not covered those topics here and will leave them for future posts.</p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[As the third post in the MySQL vs PostgreSQL series, this post compares their row locks. Row locks are involved throughout transaction execution, and there is a lot to cover. It is not possible to discuss everything in a single post. This post starts with a specific query and compares how the two systems design and implement its execution.]]></summary>
  </entry>
  <entry>
    <title type="html">InnoDB B+Tree Performance Optimization Proposal - Insert Path Improvements and Concurrent Split Handling</title>
    <link href="https://kernelmaker.github.io/MySQL-proposal-1" rel="alternate" type="text/html" title="InnoDB B+Tree Performance Optimization Proposal - Insert Path Improvements and Concurrent Split Handling"/>
    <published>2026-05-10T00:00:00+00:00</published>
    <updated>2026-05-10T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/MySQL-proposal-1</id>
    <content type="html" xml:base="https://kernelmaker.github.io/MySQL-proposal-1"><![CDATA[<p>I presented this proposal at Oracle’s <a href="https://github.com/mysql/mysql-community/wiki/MySQL-Contributor-Summit-2026-%E2%80%90-Proposed-Agenda">MySQL Contributor Summit</a> on May 26. It focuses on optimizing the InnoDB B+Tree insert path, especially structure modification operations (SMOs), by introducing a B-link-style split protocol.</p>

<p>This blog post summarizes the main ideas behind the proposal, including the current bottlenecks, the high-level design, several low-level InnoDB integration challenges, and benchmark results from my PoC based on MySQL 9.7.0.</p>

<h2 id="1-bottlenecks-in-innodb-btree-inserts">1. Bottlenecks in InnoDB B+Tree Inserts</h2>

<p>The current InnoDB B+Tree index insert path has three major performance bottlenecks:</p>

<ol>
  <li>
    <p><strong>Repeated B+Tree descent</strong></p>

    <p>A pessimistic insert may go through the B+Tree descent path multiple times:</p>

    <ol>
      <li>Optimistic insert attempt:
<code class="language-plaintext highlighter-rouge">btr_cur_search_to_nth_level(BTR_MODIFY_LEAF)</code> -&gt; <code class="language-plaintext highlighter-rouge">btr_cur_optimistic_insert</code></li>
      <li>Pessimistic insert with page split:
<code class="language-plaintext highlighter-rouge">btr_cur_search_to_nth_level(BTR_MODIFY_TREE)</code> -&gt; <code class="language-plaintext highlighter-rouge">btr_cur_optimistic_insert</code> -&gt; <code class="language-plaintext highlighter-rouge">btr_cur_pessimistic_insert</code></li>
    </ol>

    <p>Each path requires <code class="language-plaintext highlighter-rouge">btr_cur_search_to_nth_level()</code> to descend the B+Tree again.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> serializes SMO through <code class="language-plaintext highlighter-rouge">SX(dict_index_t::lock)</code></strong></p>

    <p>The pessimistic insert path acquires <code class="language-plaintext highlighter-rouge">SX(dict_index_t::lock)</code>, which means all SMO operations are effectively serialized.</p>
  </li>
  <li>
    <p><strong>The pessimistic <code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> phase holds X latches on the affected subtree</strong></p>

    <p>MySQL introduced <code class="language-plaintext highlighter-rouge">SX(dict_index_t::lock)</code> in 8.0. Although it is compatible with the <code class="language-plaintext highlighter-rouge">S(dict_index_t::lock)</code> held by readers and optimistic writers, and therefore improves concurrency between SMO and other operations to some extent, the subtree involved in the pessimistic <code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> phase is still held with X latches until the SMO completes.</p>

    <p>During this period, no other read or write operation can enter that subtree. This becomes even worse when cascading SMO reaches higher levels of the tree.</p>
  </li>
</ol>

<p>To optimize these issues, in principle we need:</p>

<ol>
  <li>A new B+Tree descent, ascent, and page-latching protocol, so that after an optimistic insert fails, we do not need to release the current leaf page latch and restart from the root. Instead, we should be able to start the pessimistic insert process in place.</li>
  <li>Pessimistic insert should no longer hold <code class="language-plaintext highlighter-rouge">SX(dict_index_t::lock)</code>. It should only need <code class="language-plaintext highlighter-rouge">S(dict_index_t::lock)</code>, so that SMO operations can run in parallel.</li>
  <li>SMO should not pre-lock the whole affected subtree. The latch granularity should be reduced. The split should proceed bottom-up, one level at a time: latch, split, and release. This allows the subtree being modified by SMO to remain readable and writable as much as possible.</li>
</ol>

<p>Combining these three points, we can borrow ideas from <a href="https://www.csd.uoc.gr/~hy460/pdf/p650-lehman.pdf">B-link Trees</a> and perform a more complete redesign.</p>

<h2 id="2-high-level-design">2. High-Level Design</h2>

<h3 id="1-introduce-a-new-b-link-style-descent-path-blink_search_to_nth_level">1. Introduce a new B-link-style descent path: <code class="language-plaintext highlighter-rouge">blink_search_to_nth_level</code></h3>

<p>The new search path works as follows:</p>

<ol>
  <li>Acquire <code class="language-plaintext highlighter-rouge">S(dict_index_t::lock)</code>.</li>
  <li>Starting from the root page, acquire an S latch on the current page, get the child page number, release the current page latch, then descend and acquire an S latch on the child page. Repeat this until reaching the level immediately above the target level. During descent, record the page numbers of the internal pages in <code class="language-plaintext highlighter-rouge">path[]</code>.</li>
  <li>After obtaining the target page number at the target level, acquire either an S latch or an X latch on the target page depending on whether the operation is a read or write.</li>
</ol>

<p>There is no longer a fundamental distinction between <code class="language-plaintext highlighter-rouge">BTR_MODIFY_LEAF</code> and <code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> in the descent protocol. The main difference is only the target level.</p>

<p>After descent completes, the operation holds the latch on the target page, and also has the page numbers of all internal pages on the path. These can later be used to optimistically locate the parent page if needed.</p>

<h3 id="2-latching-order-protocol">2. Latching-order protocol</h3>

<ol>
  <li>During descent, latch coupling is not required. We latch one level at a time: parent -&gt; child page number -&gt; release parent -&gt; latch child.</li>
  <li>During ascent, latch coupling is also not required.</li>
  <li>When moving right on the same level through the B-link high key, latch coupling is required. The protocol is current -&gt; right: acquire the right page latch while still holding the current page latch, then release the current page latch.</li>
  <li>Pages are split only to the right, which fits the B-link Tree right-move logic.</li>
</ol>

<h3 id="3-full-insert-flow">3. Full insert flow</h3>

<ol>
  <li>
    <p>Use <code class="language-plaintext highlighter-rouge">blink_search_to_nth_level()</code> to descend to the target leaf page <code class="language-plaintext highlighter-rouge">L</code>, acquire an X latch on <code class="language-plaintext highlighter-rouge">L</code>, and return the internal-page path <code class="language-plaintext highlighter-rouge">path[]</code>.</p>
  </li>
  <li>
    <p>Try an optimistic insert. If it succeeds, the insert is complete and returns.</p>
  </li>
  <li>
    <p>If the optimistic insert fails, continue holding the X latch on <code class="language-plaintext highlighter-rouge">L</code>, acquire an X latch on <code class="language-plaintext highlighter-rouge">L</code>’s right sibling <code class="language-plaintext highlighter-rouge">R</code>, allocate a new page <code class="language-plaintext highlighter-rouge">N</code>, move part of <code class="language-plaintext highlighter-rouge">L</code>’s records to <code class="language-plaintext highlighter-rouge">N</code>, and insert <code class="language-plaintext highlighter-rouge">N</code> between <code class="language-plaintext highlighter-rouge">L</code> and <code class="language-plaintext highlighter-rouge">R</code>.</p>

    <p>At the same time, set the <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag on <code class="language-plaintext highlighter-rouge">L</code>, and set <code class="language-plaintext highlighter-rouge">L</code>’s high key.</p>

    <p>The semantics of <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> are:</p>

    <ul>
      <li>concurrent readers and optimistic writers can still enter <code class="language-plaintext highlighter-rouge">L</code>;</li>
      <li>they can use the high key to decide whether they need to move right to continue the operation;</li>
      <li>concurrent pessimistic writers are blocked by this flag and return retry, waiting for the previous unfinished cascade to make progress.</li>
    </ul>

    <p>After the split is complete, obtain the <code class="language-plaintext highlighter-rouge">node_ptr</code> for <code class="language-plaintext highlighter-rouge">N</code>, then release all latches on <code class="language-plaintext highlighter-rouge">L</code>, <code class="language-plaintext highlighter-rouge">N</code>, and <code class="language-plaintext highlighter-rouge">R</code>.</p>
  </li>
  <li>
    <p>Use <code class="language-plaintext highlighter-rouge">path[]</code> to optimistically acquire an X latch on the parent page <code class="language-plaintext highlighter-rouge">P</code>. If <code class="language-plaintext highlighter-rouge">P</code> has changed, descend again to locate the correct <code class="language-plaintext highlighter-rouge">P</code> and acquire its X latch.</p>

    <p>If <code class="language-plaintext highlighter-rouge">P</code> has enough space for an optimistic insert, insert the <code class="language-plaintext highlighter-rouge">node_ptr</code>, then reacquire the latch on <code class="language-plaintext highlighter-rouge">L</code>, clear <code class="language-plaintext highlighter-rouge">L</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag, and return success.</p>
  </li>
  <li>
    <p>If <code class="language-plaintext highlighter-rouge">P</code> cannot accept the <code class="language-plaintext highlighter-rouge">node_ptr</code>, perform step 3 at <code class="language-plaintext highlighter-rouge">P</code>’s level and split <code class="language-plaintext highlighter-rouge">P</code>. Then reacquire the X latch on <code class="language-plaintext highlighter-rouge">L</code> and clear <code class="language-plaintext highlighter-rouge">L</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag.</p>
  </li>
  <li>
    <p>Perform step 4 at <code class="language-plaintext highlighter-rouge">P</code>’s level and insert the <code class="language-plaintext highlighter-rouge">node_ptr</code> of <code class="language-plaintext highlighter-rouge">P</code>’s new right page into <code class="language-plaintext highlighter-rouge">P</code>’s parent.</p>
  </li>
  <li>
    <p>Continue this process upward until the cascading split completes.</p>
  </li>
</ol>

<p>With this design, we can effectively address the three bottlenecks described above.</p>

<h3 id="4-benchmark-results">4. Benchmark results</h3>

<p>I implemented a PoC based on MySQL 9.7.0 and ran several benchmarks to evaluate the effect of the new protocol.</p>

<p><strong>1. Split-heavy insert workload</strong></p>

<p>The first benchmark is a deliberately constructed split-heavy insert workload.</p>

<p>Before the measured phase, I preloaded 2.5 million rows into the table. Each row is about 2.5 KB, so after the preload phase, most leaf pages were already close to the split threshold.</p>

<p>Then, during the measured phase, the benchmark inserted 400,000 additional rows. These rows were evenly distributed across the table, triggering a large number of page splits.</p>

<table>
  <thead>
    <tr>
      <th>Version</th>
      <th style="text-align: right">TPS</th>
      <th style="text-align: right">Avg latency</th>
      <th style="text-align: right">P95 latency</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MySQL 9.7.0</td>
      <td style="text-align: right">5,666.22</td>
      <td style="text-align: right">5.65 ms</td>
      <td style="text-align: right">17.01 ms</td>
    </tr>
    <tr>
      <td>Optimized version</td>
      <td style="text-align: right">91,523.92</td>
      <td style="text-align: right">0.35 ms</td>
      <td style="text-align: right">0.40 ms</td>
    </tr>
  </tbody>
</table>

<p>The optimized version achieved about <strong>16.2x higher TPS</strong>, <strong>16.1x lower average latency</strong>, and <strong>42.5x lower P95 latency</strong>.</p>

<p>I also collected <code class="language-plaintext highlighter-rouge">perf</code> samples on the optimized version. The hotspot shifted from index-lock contention to redo log commit wait. In particular, <code class="language-plaintext highlighter-rouge">log_wait_for_write</code> accounts for about 40% of the samples. This indicates that SMO concurrency inside the B-link-style protocol is no longer the bottleneck.
<img src="/public/images/2026-05-10/1.png" alt="image-1" /></p>

<p><img src="/public/images/2026-05-10/2.png" alt="image-2" /></p>

<p><strong>2. sysbench <code class="language-plaintext highlighter-rouge">oltp_insert</code></strong></p>

<p>I also tested the standard sysbench <code class="language-plaintext highlighter-rouge">oltp_insert</code> workload.</p>

<p>This is not a split-heavy workload because the rows are small, but the optimized version still shows clear improvements.</p>

<p>With <code class="language-plaintext highlighter-rouge">auto_inc = ON</code> , inserts concentrate on the rightmost part of the B+Tree. This creates an insert hotspot. The optimized version benefits mainly from the reduced latch scope around the hotspot.</p>

<table>
  <thead>
    <tr>
      <th>Version</th>
      <th style="text-align: right">TPS</th>
      <th style="text-align: right">P95 latency</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MySQL 9.7.0</td>
      <td style="text-align: right">80,223</td>
      <td style="text-align: right">1.64 ms</td>
    </tr>
    <tr>
      <td>Optimized version</td>
      <td style="text-align: right">104,776</td>
      <td style="text-align: right">0.90 ms</td>
    </tr>
  </tbody>
</table>

<p>The optimized version achieved about <strong>30.6% higher TPS</strong> and <strong>45.1% lower P95 latency</strong>.</p>

<p>With <code class="language-plaintext highlighter-rouge">auto_inc = OFF</code> , inserts are more evenly distributed across the B+Tree. In this case, the optimized version benefits more from parallel SMOs.</p>

<table>
  <thead>
    <tr>
      <th>Version</th>
      <th style="text-align: right">TPS</th>
      <th style="text-align: right">P95 latency</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MySQL 9.7.0</td>
      <td style="text-align: right">194,912</td>
      <td style="text-align: right">0.19 ms</td>
    </tr>
    <tr>
      <td>Optimized version</td>
      <td style="text-align: right">245,161</td>
      <td style="text-align: right">0.19 ms</td>
    </tr>
  </tbody>
</table>

<p>The optimized version achieved about <strong>25.8% higher TPS</strong>, with no P95 latency regression.</p>

<p>Overall, these benchmarks show that the B-link-style protocol improves not only artificially split-heavy workloads, but also standard insert workloads.</p>

<p>The improvement is significant, so I believe this proposal is worth further investigation.</p>

<h2 id="3-innodb-low-level-design">3. InnoDB Low-Level Design</h2>

<p>The main difficulty of this proposal is that it requires a major redesign of InnoDB core modules. There are also historical design constraints in InnoDB that conflict with this new design.</p>

<h3 id="1-add-high-keys-to-pages">1. Add high keys to pages</h3>

<p>The high key is an important part of B-link Trees. It allows concurrent threads to repair their access path when they encounter a page being split. Therefore, it must be introduced.</p>

<p>In InnoDB, for compatibility reasons, I found that the high byte of the <code class="language-plaintext highlighter-rouge">PAGE_DIRECTION</code> field in <code class="language-plaintext highlighter-rouge">PAGE_HEADER</code> is effectively unused. <code class="language-plaintext highlighter-rouge">PAGE_DIRECTION</code> is a 2-byte field, but InnoDB only uses the low byte to store the last insert direction. The high byte remains 0 before and after upgrade.</p>

<p>Therefore, in my PoC, I use it as a B-link Tree flag byte, reusing the existing page-header space without changing the file format.</p>

<p>The flags are:</p>

<ul>
  <li>bit 0: <code class="language-plaintext highlighter-rouge">PAGE_IS_BLINK</code>
Indicates whether the page is a B-link page or a normal B+Tree page. It is set when the page is first touched by SMO and is never cleared.</li>
  <li>bit 1: <code class="language-plaintext highlighter-rouge">PAGE_INCOMPLETE_SPLIT</code>
Indicates that the page’s cascade has not yet been propagated to the parent. This is an intermediate state during the B-link split process.</li>
</ul>

<p>If a page is a B-link page, the high key always exists as the record immediately before <code class="language-plaintext highlighter-rouge">supremum</code>.</p>

<p>On internal pages, the high key has the same shape as a normal <code class="language-plaintext highlighter-rouge">node_ptr</code>. The difference is that the high key’s child page number is <code class="language-plaintext highlighter-rouge">FIL_NULL</code>, meaning it points to <code class="language-plaintext highlighter-rouge">+infinity</code>.</p>

<p>This requires adding high-key checks and skip logic in code paths that fetch the next record, so that the high key is treated as an internal reserved record.</p>

<h3 id="2-split-smo-into-multiple-mtrs">2. Split SMO into multiple mtrs</h3>

<p>In the current InnoDB implementation, when <code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> descends the B+Tree, the mtr already latches all pages that may need to split along the path. These latches are held until the whole pessimistic insert finishes and the mtr commits.</p>

<p>During this time, the whole affected area cannot be accessed concurrently. Also, dirty-page unlatching in the current mtr model happens together with mtr commit.</p>

<p>For the B-link-style design, we need to split this large mtr into multiple smaller mtrs.</p>

<p><strong>mtr 1</strong></p>

<p>Acquire the index S lock, descend the B+Tree to the target leaf page <code class="language-plaintext highlighter-rouge">L</code>, and hold <code class="language-plaintext highlighter-rouge">L</code>’s X latch.</p>

<p>If an optimistic insert succeeds, commit the mtr and the insert is complete.</p>

<p>Otherwise, acquire an X latch on <code class="language-plaintext highlighter-rouge">L</code>’s right sibling <code class="language-plaintext highlighter-rouge">R</code>, allocate a new page, split <code class="language-plaintext highlighter-rouge">L</code>, set <code class="language-plaintext highlighter-rouge">L</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag and high key, and then start mtr 2.</p>

<p><strong>mtr 2</strong></p>

<p>Transfer the index S lock held by mtr 1 to mtr 2 through a new mtr interface <code class="language-plaintext highlighter-rouge">transfer_to</code>, ensuring that the index S lock is held during the entire insert operation.</p>

<p>Then mtr 1 can commit, and the split becomes visible.</p>

<p>mtr 2 continues to hold the index S lock and tries to insert the new page’s <code class="language-plaintext highlighter-rouge">node_ptr</code> into the corresponding parent page <code class="language-plaintext highlighter-rouge">P</code>.</p>

<p>The design is to locate the parent using the <code class="language-plaintext highlighter-rouge">path[]</code> collected during the mtr 1 descent. The cached information includes the parent page number and LSN. After acquiring an X latch on the parent page, we compare the LSN to determine whether the cached parent is still valid. This can avoid descending the tree again. Even when the cache is invalid, the same-level right-move logic can be used to repair the search path.</p>

<p>However, in my current PoC, each cascade step still uses <code class="language-plaintext highlighter-rouge">btr_cur_search_to_nth_level()</code> to descend again and locate <code class="language-plaintext highlighter-rouge">P</code>.</p>

<p>Then we check whether <code class="language-plaintext highlighter-rouge">P</code> can optimistically accept the <code class="language-plaintext highlighter-rouge">node_ptr</code>:</p>

<ol>
  <li>
    <p>If it can, insert the <code class="language-plaintext highlighter-rouge">node_ptr</code> directly. Then use the cached page number of <code class="language-plaintext highlighter-rouge">L</code> to acquire an X latch on <code class="language-plaintext highlighter-rouge">L</code>.</p>

    <p>According to the proposed protocol, <code class="language-plaintext highlighter-rouge">L</code> has <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> set. It can accept reads and optimistic writes, but blocks pessimistic writes. Therefore, <code class="language-plaintext highlighter-rouge">L</code> can be safely located by page number.</p>

    <p>Then clear <code class="language-plaintext highlighter-rouge">L</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag, commit mtr 2, and the insert succeeds.</p>
  </li>
  <li>
    <p>If <code class="language-plaintext highlighter-rouge">P</code> cannot accept the <code class="language-plaintext highlighter-rouge">node_ptr</code>, acquire an X latch on <code class="language-plaintext highlighter-rouge">P</code>’s right sibling, split <code class="language-plaintext highlighter-rouge">P</code>, and similarly set <code class="language-plaintext highlighter-rouge">P</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag and high key.</p>

    <p>Then obtain the <code class="language-plaintext highlighter-rouge">node_ptr</code> of the new page created by splitting <code class="language-plaintext highlighter-rouge">P</code>.</p>

    <p>After that, acquire the X latch on <code class="language-plaintext highlighter-rouge">L</code> using <code class="language-plaintext highlighter-rouge">L</code>’s page number and clear <code class="language-plaintext highlighter-rouge">L</code>’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag.</p>

    <p>Start mtr 3, transfer the index S lock to mtr 3, and then commit mtr 2. At this point, <code class="language-plaintext highlighter-rouge">P</code>’s split also becomes visible.</p>
  </li>
</ol>

<p><strong>mtr 3 and above</strong></p>

<p>mtr 3 now holds the index S lock and is ready to continue inserting the <code class="language-plaintext highlighter-rouge">node_ptr</code> into the upper level. This is the same state as mtr 2. The process repeats until the cascading split completes.</p>

<p>This is the mtr-splitting model.</p>

<p>In principle, each level modification corresponds to one mtr. The index S lock is transferred between mtrs to ensure it is held throughout the whole operation.</p>

<p>For cascading splits, the mtr that splits the parent and the mtr that clears the child’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag must be atomic with respect to each other. Therefore, they need to be in the same mtr.</p>

<p>However, before clearing <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code>, we do not need to keep holding the child page latch. We only need to reacquire the child page by page number at the end and then clear the flag.</p>

<h3 id="3-page-allocation-and-the-fsp-locking-problem">3. Page allocation and the FSP locking problem</h3>

<p>After changing SMO from holding index SX to holding index S, the bottleneck moves from the index SX lock to the locks inside FSP page allocation.</p>

<p>Specifically, when allocating a new page from FSP, InnoDB needs to hold SX on the root page and then hold SX on <code class="language-plaintext highlighter-rouge">space-&gt;lock</code>.</p>

<p>This is the biggest historical constraint I encountered in this optimization.</p>

<p>The current InnoDB <code class="language-plaintext highlighter-rouge">BTR_MODIFY_TREE</code> path holds the root page SX or X latch while descending the tree, which makes this latch order valid in the existing design.</p>

<p>However, in the new protocol, allocating a page requires holding the root page latch, which breaks the B+Tree latch-order protocol and may introduce deadlocks.</p>

<p>More seriously, even if we adjust the order to avoid deadlocks, the root SX latch would still block concurrent SMO. Also, the latch is released only when the mtr holding it commits. Even after splitting the mtr, the resulting mtr is still too large for such a global latch and becomes a performance bottleneck.</p>

<p>This is a difficult issue, and I do not currently have a perfect solution. The main problems are:</p>

<ol>
  <li>
    <p>If we remove the root page dependency from the FSP new-page allocation logic, this may involve data-file format changes and introduce compatibility issues.</p>
  </li>
  <li>
    <p>If we use a separate <code class="language-plaintext highlighter-rouge">alloc_mtr</code> to minimize the new-page allocation phase, there are two problems.</p>

    <p>First, in the new insert flow, we allocate a new page while holding a data page latch. This still creates a data-page X -&gt; root-page X order, which may cause deadlocks.</p>

    <p>To avoid that, we would need to release the held data page latch before allocation, and then reacquire it after <code class="language-plaintext highlighter-rouge">alloc_mtr</code> commits. But this workaround exists only to accommodate historical constraints, and it is not very clean from an engineering perspective. It may also hurt performance.</p>

    <p>Second, if <code class="language-plaintext highlighter-rouge">alloc_mtr</code> commits early and the later operation fails, the newly allocated page cannot be returned to FSP easily.</p>
  </li>
  <li>
    <p>Using an up-locking order is not a clean solution. In this protocol, clearing the child page’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag and inserting the new page’s <code class="language-plaintext highlighter-rouge">node_ptr</code> into the parent must be atomic with respect to crash recovery and concurrent access. This means the mtr that installs the parent <code class="language-plaintext highlighter-rouge">node_ptr</code> still needs to reacquire and modify the child page at the end.</p>

    <p>In other words, even if we try to use an up-locking order to propagate the split to the parent, the final step still requires a parent-to-child latch order. This brings us back to a down-locking pattern.</p>

    <p>Also, even if we could make this ordering work, the root page’s SX latch would still be held longer than necessary, which would again limit parallel SMOs.</p>
  </li>
</ol>

<p>Since this is not the main topic of the proposal, my current PoC uses a temporary solution: a B-link allocator thread.</p>

<p>The allocator thread pre-allocates pages from FSP into a pool. The pool stores page numbers only.</p>

<p>The allocator maintains separate watermarks for <code class="language-plaintext highlighter-rouge">FSP_LEAF</code> and <code class="language-plaintext highlighter-rouge">FSP_NON_LEAF</code>, so that the high consumption rate of leaf pages does not starve the internal-page segment.</p>

<p>During B-link split, all new pages are taken directly from this pool. The allocator monitors the pool watermarks and keeps them at a reasonable level, so that there are normally enough pre-allocated page numbers available.</p>

<p>When a split needs a new page, it simply takes one from the pool. If the pool is empty, it signals the allocator thread and returns <code class="language-plaintext highlighter-rouge">DB_BLINK_RETRY</code>. Then the row layer retries the whole insert path. This avoids blocking the latch path on an empty allocation pool.</p>

<p>This decouples the root SX latch required by page allocation from the B+Tree insert path.</p>

<p>Of course, this is only the PoC solution and still needs further refinement.</p>

<p>For example, we need to consider how to reclaim pre-allocated pages after a server crash. One possible solution is to introduce a new redo record to record the pre-allocation state, and then reclaim unconsumed pre-allocated pages during server restart. This should be solvable from an engineering perspective.</p>

<p>Another reason why I think an allocator thread is useful is that, for B-link Trees, if merge is supported in the future, pages created or removed by merge cannot be immediately freed back to FSP.</p>

<p>B-link Tree correctness requires that concurrent operations may still see intermediate SMO states. If a page is freed too early, another operation may follow a stale right-link and reach a freed page, causing correctness problems.</p>

<p>A page can be safely freed only after all mtrs that were active at the moment when the page was removed from the tree have completed. This is also implementable in engineering terms, similar to an MVCC visibility check, but much lighter than full MVCC.</p>

<p>This asynchronous safe-free work can also be handled by the allocator thread after merge support is added.</p>

<p>Merge is also an important part of the design, but I will not expand on it in this proposal.</p>

<h3 id="4-crash-recovery">4. Crash Recovery</h3>

<p>When a writer crashes in the middle of a cascading split, a lower-level page may have its <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag durably written to disk, while the corresponding parent page may not yet contain the <code class="language-plaintext highlighter-rouge">node_ptr</code> pointing to the new page.</p>

<p>In my current PoC, during restart, B-link recovery runs as the first step of <code class="language-plaintext highlighter-rouge">srv_start_threads_after_ddl_recovery</code>.</p>

<p>It scans the buffer pool in a single thread. For each page with both <code class="language-plaintext highlighter-rouge">PAGE_IS_BLINK</code> and <code class="language-plaintext highlighter-rouge">PAGE_INCOMPLETE_SPLIT</code> set, it separately acquires <code class="language-plaintext highlighter-rouge">X(index)</code>. Since this is during recovery and runs single-threaded, there is no concurrent contention.</p>

<p>Then it reuses the runtime split/root-raise primitives to complete the interrupted cascade.</p>

<p>There is an important invariant that simplifies recovery:</p>

<p>The parent <code class="language-plaintext highlighter-rouge">node_ptr</code> installation and the clearing of the child page’s <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code> flag commit in the same mtr.</p>

<p>Therefore, if a flagged page survives crash recovery, it means the corresponding parent must be missing the <code class="language-plaintext highlighter-rouge">node_ptr</code>. We do not need an idempotency check for whether the parent already contains the <code class="language-plaintext highlighter-rouge">node_ptr</code>.</p>

<p>The current implementation only scans the buffer pool.</p>

<p>There is an edge case where a flagged page has been flushed to disk, the checkpoint has advanced, and recovery misses it. In that case, the impact is mainly performance-related rather than correctness-related, because descent itself does not consume <code class="language-plaintext highlighter-rouge">INCOMPLETE_SPLIT</code>.</p>

<p>The high key and right-link can still route the operation to the correct target page.</p>

<p>A complete solution can be implemented in multiple ways, such as runtime repair, or maintaining page numbers of dirty pages that still have <code class="language-plaintext highlighter-rouge">PAGE_INCOMPLETE_SPLIT</code> set at flush time.</p>

<h2 id="5-summary-and-next-steps">5. Summary and Next Steps</h2>

<p>This is a large proposal. It involves major changes to several core InnoDB modules, so the implementation is definitely challenging.</p>

<p>Also, this proposal only covers the split path. Merge support will be needed in the future. I plan to describe the merge design in a separate follow-up proposal, or as an extension of this one.</p>

<p>That said, I think the full patch can be delivered incrementally.</p>

<p>For V1, we can focus only on parallel page splits and fall back to the existing serialized behavior for page merges. For example, we can introduce a new SMO lock to make splits and merges mutually exclusive:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Split: S(index-&gt;lock) + S(index-&gt;smo_lock)
Merge: S(index-&gt;lock) + X(index-&gt;smo_lock)
</code></pre></div></div>

<p>In this way, multiple splits can run in parallel, while merges still take exclusive access through <code class="language-plaintext highlighter-rouge">index-&gt;smo_lock</code> and follow the existing InnoDB behavior.</p>

<p>The PoC shows that this direction is feasible and has significant performance potential, so I believe it is definitely worth exploring further.</p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[I presented this proposal at Oracle’s MySQL Contributor Summit on May 26. It focuses on optimizing the InnoDB B+Tree insert path, especially structure modification operations (SMOs), by introducing a B-link-style split protocol.]]></summary>
  </entry>
  <entry>
    <title type="html">Dissecting the MySQL 8.0 Performance Regression on oltp_update_non_index</title>
    <link href="https://kernelmaker.github.io/MySQL-regression-1" rel="alternate" type="text/html" title="Dissecting the MySQL 8.0 Performance Regression on oltp_update_non_index"/>
    <published>2026-04-16T00:00:00+00:00</published>
    <updated>2026-04-16T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/MySQL-regression-1</id>
    <content type="html" xml:base="https://kernelmaker.github.io/MySQL-regression-1"><![CDATA[<p>The performance regression in MySQL 8.0 is well known, but it is still not fully understood. That is because it is not a regression caused by one obvious bottleneck. MySQL 8.0 introduced many new designs and refactored major subsystems, so the gap comes from a combination of configuration defaults, architectural trade-offs, and many small overheads spread across different layers.</p>

<p>So I picked one workload, profiled it carefully, and tried to answer a more practical question: <strong>where exactly does the regression come from, and how much does each part contribute?</strong></p>

<p>In this post, I use <code class="language-plaintext highlighter-rouge">oltp_update_non_index</code> , one of the worst regression cases between MySQL 5.7.44 and 8.0.45 as a starting point. Beginning from a <strong>-32.0% throughput gap</strong>, I narrow it to <strong>-0.2%</strong> by systematically isolating one factor at a time. As expected, the regression is not dominated by a single bottleneck. It is the combined effect of default settings, architectural changes, and dozens of small code-level costs.</p>

<h2 id="1-setup">1. Setup</h2>

<p>Both MySQL versions are compiled with GCC 8.5.0 at <code class="language-plaintext highlighter-rouge">-O3</code>. <code class="language-plaintext highlighter-rouge">mysqld</code> and <code class="language-plaintext highlighter-rouge">sysbench</code> are each pinned to 8 dedicated physical cores with no hyperthreading overlap. The buffer pool is 23 GB. The dataset is a single table with 50 million rows. <code class="language-plaintext highlighter-rouge">innodb_flush_log_at_trx_commit=2</code>, <code class="language-plaintext highlighter-rouge">sync_binlog=0</code>, and the adaptive hash index is OFF. Both versions use identical InnoDB settings wherever the options are comparable. Full configuration details are listed in the appendix.</p>

<p>The benchmark is sysbench <code class="language-plaintext highlighter-rouge">oltp_update_non_index</code>, with 8 threads and 90 seconds per run. This is one of the worst regression cases..</p>

<h2 id="2-narrowing-the-gap-step-by-step">2. Narrowing the gap step by step</h2>

<p>The method is straightforward. I start with MySQL 8.0.45 and 5.7.44 under the same default-like configuration, then use <code class="language-plaintext highlighter-rouge">perf record</code> and <code class="language-plaintext highlighter-rouge">perf report</code> to see where CPU time goes. Once a subsystem becomes the dominant bottleneck, I either adjust the configuration to remove that cost or write a small targeted patch. Then I profile again and repeat.</p>

<p>Each round removes one visible layer of overhead and exposes the next one underneath.</p>

<p>Starting from the baseline and applying changes one by one, I was able to close almost the entire gap:</p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Description</th>
      <th>5.7 TPS</th>
      <th>8.0 TPS</th>
      <th>Gap</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td> </td>
      <td>Baseline (PFS=1, bin=ON, writer=ON)</td>
      <td>69,818</td>
      <td>47,509</td>
      <td>-32.0%</td>
    </tr>
    <tr>
      <td>1</td>
      <td>innodb_log_writer_threads=OFF</td>
      <td>(69,818)</td>
      <td>52,539</td>
      <td>-24.7%</td>
    </tr>
    <tr>
      <td>2</td>
      <td>performance_schema=0</td>
      <td>72,316</td>
      <td>54,919</td>
      <td>-24.1%</td>
    </tr>
    <tr>
      <td>3</td>
      <td>skip-log-bin</td>
      <td>128,140</td>
      <td>109,044</td>
      <td>-14.9%</td>
    </tr>
    <tr>
      <td>4</td>
      <td>–db-ps-mode=auto</td>
      <td>135,664</td>
      <td>125,119</td>
      <td>-7.8%</td>
    </tr>
    <tr>
      <td>5</td>
      <td>innodb_flush_log_at_trx_commit=0</td>
      <td>149,577</td>
      <td>144,941</td>
      <td>-3.1%</td>
    </tr>
    <tr>
      <td>6</td>
      <td>5 code patches</td>
      <td>(149,577)</td>
      <td><strong>149,273</strong></td>
      <td><strong>-0.2%</strong></td>
    </tr>
  </tbody>
</table>

<p>From <strong>-32.0% to -0.2%</strong>. Below is the breakdown.</p>

<h3 id="step-1-innodb_log_writer_threadsoff">Step 1: <code class="language-plaintext highlighter-rouge">innodb_log_writer_threads=OFF</code></h3>

<p>This is an 8.0-only setting; it does not exist in 5.7.</p>

<p>With <code class="language-plaintext highlighter-rouge">innodb_log_writer_threads=ON</code>, a dedicated log writer thread is responsible for writing the log buffer to disk. The problem appears when <code class="language-plaintext highlighter-rouge">mysqld</code> is pinned to 8 cores and all 8 are already saturated by client threads. In that case, the log writer thread cannot get scheduled quickly enough. Client threads call <code class="language-plaintext highlighter-rouge">log_write_up_to()</code> and spin in <code class="language-plaintext highlighter-rouge">ut_delay()</code> while waiting for the writer to advance the write position, but the writer itself is CPU-starved. That creates a feedback loop: client threads spin longer, occupy more CPU, and make it even harder for the writer to run.</p>

<p>With innodb_log_writer_threads=OFF, no separate writer thread is needed, the calling thread takes over the writer role inside log_write_up_to, eliminating the scheduling dependency.</p>

<h3 id="step-2-performance_schema0">Step 2: <code class="language-plaintext highlighter-rouge">performance_schema=0</code></h3>

<p>Disabling Performance Schema reduces the gap further. MySQL 8.0 changed PFS significantly compared with 5.7, including v2 metadata lock instrumentation, new memory statistics layers, and allocator hook changes. However, I did not isolate the PFS internals deeply enough in this workload to say which part is the main contributor.</p>

<p>So for this step, I can say that PFS matters, but I cannot yet attribute the overhead to a specific internal component.</p>

<h3 id="step-3-skip-log-bin">Step 3: <code class="language-plaintext highlighter-rouge">skip-log-bin</code></h3>

<p>This is the single largest step in absolute TPS gain. Both versions improve dramatically when binary logging is disabled (5.7: +67%, 8.0: +86%). The workload is commit-bound, and every transaction generates one binlog event. Even with <code class="language-plaintext highlighter-rouge">sync_binlog=0</code>, the binlog still adds per-commit CPU cost for event formatting, <code class="language-plaintext highlighter-rouge">Table_map</code> construction, and memory allocation.</p>

<p>MySQL 8.0 benefits more from disabling binlog than 5.7 does (+86% vs +67%), which suggests that the 8.0 binlog path adds extra per-event overhead. Looking at the code, I found several 8.0-only additions that are plausible contributors:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">ColumnFilterOutboundFunctionalIndexes</code></strong>: <code class="language-plaintext highlighter-rouge">is_filter_needed()</code> returns <code class="language-plaintext highlighter-rouge">true</code> unconditionally, so the column filter is installed even for tables without functional indexes.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ReplicatedColumnsView</code></strong>: allocates a <code class="language-plaintext highlighter-rouge">std::vector&lt;std::unique_ptr&lt;ColumnFilter&gt;&gt;</code> for each <code class="language-plaintext highlighter-rouge">Table_map</code> event.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">init_metadata_fields()</code></strong>: new in 8.0, adding metadata serialization work to every <code class="language-plaintext highlighter-rouge">Table_map</code> event.</li>
</ul>

<p>These are reasonable suspects, but I have not profiled the binlog path in isolation, so I am not claiming that they are the confirmed dominant causes.</p>

<h3 id="step-4---db-ps-modeauto-prepared-statements">Step 4: <code class="language-plaintext highlighter-rouge">--db-ps-mode=auto</code> (prepared statements)</h3>

<p>Switching from <code class="language-plaintext highlighter-rouge">--db-ps-mode=disable</code> to <code class="language-plaintext highlighter-rouge">--db-ps-mode=auto</code> helps by avoiding full parsing on every execution. In this mode, the statement is parsed once, then re-optimized on each execution. The net effect is that prepared statements help 8.0 more than 5.7.</p>

<p>I have not fully decomposed why the text-protocol path in 8.0 is heavier for a simple <code class="language-plaintext highlighter-rouge">UPDATE</code>. It is unlikely to be the grammar itself, because a simple <code class="language-plaintext highlighter-rouge">UPDATE</code> does not exercise features such as CTEs or window functions. More likely, the extra cost comes from surrounding setup work in the lexer, resolver, or optimizer path. That still needs confirmation.</p>

<h3 id="step-5-innodb_flush_log_at_trx_commit0">Step 5: <code class="language-plaintext highlighter-rouge">innodb_flush_log_at_trx_commit=0</code></h3>

<p>Setting <code class="language-plaintext highlighter-rouge">innodb_flush_log_at_trx_commit=0</code> decouples transaction commit from the redo log write. This makes it easier to isolate the cost of 8.0’s lock-free redo log design.</p>

<p>Profiling shows that with <code class="language-plaintext highlighter-rouge">flush=2</code>, the function <code class="language-plaintext highlighter-rouge">ut_delay()</code> , the busy-wait loop inside <code class="language-plaintext highlighter-rouge">log_write_up_to()</code> , consumes <strong>9.59% of total CPU</strong> in 8.0. With <code class="language-plaintext highlighter-rouge">writer_threads=OFF</code>, each committing thread writes to the log buffer through <code class="language-plaintext highlighter-rouge">log_buffer_reserve()</code>, <code class="language-plaintext highlighter-rouge">log_buffer_write()</code>, and <code class="language-plaintext highlighter-rouge">log_buffer_write_completed()</code> and then spin-waits in <code class="language-plaintext highlighter-rouge">log_write_up_to()</code> for the write to reach disk. This lock-free coordination machinery (<code class="language-plaintext highlighter-rouge">log_buffer_reserve</code> 0.57% + <code class="language-plaintext highlighter-rouge">log_buffer_write_completed</code> 0.33% + <code class="language-plaintext highlighter-rouge">log_wait_for_space_in_log_recent_closed</code> 0.31%) has no equivalent in 5.7, which uses a simpler <code class="language-plaintext highlighter-rouge">log_sys</code> mutex-based design.</p>

<p>One interesting result is that 5.7 also spends a lot of CPU in <code class="language-plaintext highlighter-rouge">ut_delay()</code>, in fact even more than 8.0 (13.64% vs 9.59%). But its redo path per transaction is shorter: take <code class="language-plaintext highlighter-rouge">log_sys</code>, write, release. So even though it spins more, it still completes more useful work per transaction.</p>

<p>This is a real architectural trade-off. The 8.0 redo redesign favors scalability at higher concurrency and under stricter durability requirements. But for this workload, at 8 threads and <code class="language-plaintext highlighter-rouge">flush=2</code>, that trade-off is unfavorable.</p>

<h3 id="step-6-five-code-patches">Step 6: Five code patches</h3>

<p>After aligning the configuration and benchmark settings and decoupling redo with <code class="language-plaintext highlighter-rouge">flush=0</code>, the remaining gap is -3.1%. At this point, profiling (924K perf samples) shows a very flat CPU profile: no single function is above 1.8%. The remaining gap is spread across many small 8.0-specific overheads.</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>8.0 CPU%</th>
      <th>5.7 CPU%</th>
      <th>Category</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmp_dtuple_rec_with_match_low</code></td>
      <td>1.18%</td>
      <td>0.44%</td>
      <td>Inline Regression</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">buf_flush_note_modification</code></td>
      <td>0.67%</td>
      <td>0% (inlined)</td>
      <td>Inline Regression</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">THD::store_cached_properties</code></td>
      <td>0.53%</td>
      <td>0% (not present)</td>
      <td>New Overhead</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fold_condition</code></td>
      <td>0.43%</td>
      <td>0% (not present)</td>
      <td>New Overhead</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ha_innobase::info_low</code></td>
      <td>0.36%</td>
      <td>0% (lighter)</td>
      <td>Missing Fast-Path</td>
    </tr>
  </tbody>
</table>

<p>I group them into three categories:</p>

<ul>
  <li><strong>[Inline Regression]</strong>: the 8.0 version of a function grew enough that GCC no longer auto-inlines it.</li>
  <li><strong>[New Overhead]</strong>: entirely new code in 8.0 that runs unconditionally, even when the feature behind it is not needed.</li>
  <li><strong>[Missing Fast-Path]</strong>: 8.0 added support for more cases but did not keep a cheap fast path for the common case.</li>
</ul>

<p>Five small targeted patches reduce the remaining gap from -3.1% to <strong>-0.2%</strong> at <code class="language-plaintext highlighter-rouge">flush=0</code>. The patch details are listed in the appendix.</p>

<p><img src="/public/images/2026-04-16/1.png" alt="image-1" /></p>

<p>One important note: at <code class="language-plaintext highlighter-rouge">flush=2</code>, these 5 patches show <strong>no measurable TPS improvement</strong> (124,572 vs 125,119 TPS). The reason is that <code class="language-plaintext highlighter-rouge">ut_delay()</code> in the redo commit path already consumes 9.59% of CPU and acts as a throughput ceiling. The cycles freed by the patches are mostly absorbed by extra spin iterations instead of being converted into more completed transactions.</p>

<p>The patches only become visible at <code class="language-plaintext highlighter-rouge">flush=0</code>, which confirms that they are fixing real overhead that is otherwise masked by the redo bottleneck.</p>

<h2 id="4-going-deeper-how-much-does-each-factor-really-contribute">4. Going deeper: how much does each factor really contribute?</h2>

<p>After completing the step-by-step analysis, I wanted to understand the true contribution of each factor. The cumulative table above is intuitive, but it has a methodological limitation: <strong>the attribution depends on the order in which changes are applied</strong>.</p>

<p>For example, disabling PFS in step 2, after <code class="language-plaintext highlighter-rouge">writer_threads=OFF</code> but before <code class="language-plaintext highlighter-rouge">binlog=OFF</code>, appears to recover only 0.7 percentage points. But when I measure PFS independently, by changing only PFS from the original common baseline, the contribution is actually 2.3 percentage points. In the cumulative sequence, other costs were masking it.</p>

<p>So I repeated the measurements using <strong>independent ablation</strong>: each factor is changed individually from the same common baseline. That prevents one factor from hiding or amplifying another.</p>

<p><strong>Common baseline:</strong> <code class="language-plaintext highlighter-rouge">performance_schema=1</code>, <code class="language-plaintext highlighter-rouge">log-bin=mysql-bin</code>, <code class="language-plaintext highlighter-rouge">innodb_log_writer_threads=ON</code> (8.0), <code class="language-plaintext highlighter-rouge">--db-ps-mode=disable</code>, <code class="language-plaintext highlighter-rouge">innodb_flush_log_at_trx_commit=2</code>.</p>

<table>
  <thead>
    <tr>
      <th>Factor changed</th>
      <th>5.7 TPS</th>
      <th>8.0 TPS</th>
      <th>Gap</th>
      <th>Attribution</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Baseline (nothing)</td>
      <td>69,818</td>
      <td>47,509</td>
      <td>-32.0%</td>
      <td>—</td>
    </tr>
    <tr>
      <td>A. <code class="language-plaintext highlighter-rouge">writer_threads=OFF</code></td>
      <td>69,818</td>
      <td>52,539</td>
      <td>-24.7%</td>
      <td>7.2 pp</td>
    </tr>
    <tr>
      <td>B. <code class="language-plaintext highlighter-rouge">performance_schema=0</code></td>
      <td>72,316</td>
      <td>50,798</td>
      <td>-29.7%</td>
      <td>2.3 pp</td>
    </tr>
    <tr>
      <td>C. <code class="language-plaintext highlighter-rouge">skip-log-bin</code></td>
      <td>116,588</td>
      <td>88,379</td>
      <td>-24.2%</td>
      <td>7.8 pp</td>
    </tr>
    <tr>
      <td>D. <code class="language-plaintext highlighter-rouge">db-ps-mode=auto</code></td>
      <td>74,406</td>
      <td>53,838</td>
      <td>-27.7%</td>
      <td>4.3 pp</td>
    </tr>
    <tr>
      <td>E. <code class="language-plaintext highlighter-rouge">flush_log_at_trx_commit=0</code></td>
      <td>81,952</td>
      <td>60,906</td>
      <td>-25.7%</td>
      <td>6.3 pp</td>
    </tr>
  </tbody>
</table>

<p>The independent factors sum to <strong>27.9 percentage points</strong>. Adding the code-level overhead (2.9 pp, measured at <code class="language-plaintext highlighter-rouge">flush=0</code>) gives <strong>30.8 pp</strong>. The remaining <strong>1.2 pp</strong> appears to come from interaction effects between factors. For example, binlog overhead amplifies the redo commit path, so removing both together saves slightly more than the sum of removing each in isolation.</p>

<p>Comparing the cumulative and independent views gives a more accurate picture:</p>

<table>
  <thead>
    <tr>
      <th>Factor</th>
      <th>Cumulative</th>
      <th>Independent</th>
      <th>Observation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">writer_threads=OFF</code></td>
      <td>7.2 pp</td>
      <td>7.2 pp</td>
      <td>Same, measured first, so no masking</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">performance_schema=0</code></td>
      <td>0.7 pp</td>
      <td><strong>2.3 pp</strong></td>
      <td>PFS is undercounted 3x in the cumulative sequence</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">skip-log-bin</code></td>
      <td>9.2 pp</td>
      <td><strong>7.8 pp</strong></td>
      <td>Binlog is overcounted in the cumulative sequence</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">db-ps-mode=auto</code></td>
      <td>7.1 pp</td>
      <td><strong>4.3 pp</strong></td>
      <td>SQL layer overhead is overcounted in the cumulative sequence</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flush_log_at_trx_commit=0</code></td>
      <td>4.7 pp</td>
      <td><strong>6.3 pp</strong></td>
      <td>Redo overhead is undercounted in the cumulative sequence</td>
    </tr>
  </tbody>
</table>

<p>The main lesson is simple: if you want correct attribution, you need to control for interaction between variables. The same benchmarking principle applies to regression analysis itself.</p>

<h2 id="5-summary">5. Summary</h2>

<p>The <strong>-32.0%</strong> regression on <code class="language-plaintext highlighter-rouge">oltp_update_non_index</code> is not caused by one dominant bottleneck. It is the result of several layers of overhead:</p>

<table>
  <thead>
    <tr>
      <th>Factor</th>
      <th>Independent attribution</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Binary log overhead</td>
      <td>7.8 pp</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">innodb_log_writer_threads</code> CPU starvation</td>
      <td>7.2 pp</td>
    </tr>
    <tr>
      <td>Lock-free redo log architecture</td>
      <td>6.3 pp</td>
    </tr>
    <tr>
      <td>SQL text protocol / parser path</td>
      <td>4.3 pp</td>
    </tr>
    <tr>
      <td>Code-level overhead (5 patches)</td>
      <td>2.9 pp</td>
    </tr>
    <tr>
      <td>Performance Schema instrumentation</td>
      <td>2.3 pp</td>
    </tr>
    <tr>
      <td>Interaction effects</td>
      <td>1.2 pp</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>32.0 pp</strong></td>
    </tr>
  </tbody>
</table>

<p>The largest single factor is the binary log (7.8 pp), followed closely by log writer thread CPU starvation (7.2 pp) and redo log architecture (6.3 pp). Together, these three redo/binlog-related factors account for <strong>21.3 percentage points</strong>, roughly two-thirds of the total regression.</p>

<p>The code-level overhead (2.9 pp) comes from many small additions that are easy to ignore in isolation, an extra function call here, an extra pass there, but they add up. Database development is always about trade-offs. MySQL 8.0 added many useful capabilities, but on a workload running at 125,000+ queries per second, even small per-query costs accumulate quickly.</p>

<h2 id="appendix-a-patch-details">Appendix A: Patch details</h2>

<h3 id="patch-1--cmp_data-always_inline--loop-invariant-hoist">Patch 1:  <code class="language-plaintext highlighter-rouge">cmp_data</code> <code class="language-plaintext highlighter-rouge">ALWAYS_INLINE</code> + loop-invariant hoist</h3>

<ul>
  <li><strong>File:</strong> <code class="language-plaintext highlighter-rouge">storage/innobase/rem/rem0cmp.cc</code></li>
  <li><strong>Problem:</strong> In 8.0, <code class="language-plaintext highlighter-rouge">cmp_data()</code> grew past GCC’s auto-inline threshold because of added multi-value index support (<code class="language-plaintext highlighter-rouge">is_asc</code>, <code class="language-plaintext highlighter-rouge">DATA_MULTI_VALUE</code> assertions, <code class="language-plaintext highlighter-rouge">dfield_is_multi_value()</code> checks). The per-field comparison loop in <code class="language-plaintext highlighter-rouge">cmp_dtuple_rec_with_match_low()</code> also redundantly calls <code class="language-plaintext highlighter-rouge">dict_index_is_ibuf()</code> and checks <code class="language-plaintext highlighter-rouge">dfield_is_multi_value()</code> on every iteration.</li>
  <li><strong>Fix:</strong> Mark <code class="language-plaintext highlighter-rouge">cmp_data()</code> as <code class="language-plaintext highlighter-rouge">ALWAYS_INLINE</code>. Hoist <code class="language-plaintext highlighter-rouge">is_ibuf</code> and <code class="language-plaintext highlighter-rouge">is_mv_index</code> out of the loop.</li>
</ul>

<h3 id="patch-2--buf_flush_note_modification-always_inline">Patch 2:  <code class="language-plaintext highlighter-rouge">buf_flush_note_modification</code> <code class="language-plaintext highlighter-rouge">ALWAYS_INLINE</code></h3>

<ul>
  <li><strong>Files:</strong> <code class="language-plaintext highlighter-rouge">storage/innobase/include/buf0flu.h</code>, <code class="language-plaintext highlighter-rouge">buf0flu.ic</code></li>
  <li><strong>Problem:</strong> Both 5.7 and 8.0 support flush observers in <code class="language-plaintext highlighter-rouge">buf_flush_note_modification()</code>, but the 8.0 version grew enough that GCC no longer auto-inlines it. As a result, 8.0 emits a standalone function call on every dirty-page modification where 5.7 keeps it inlined.</li>
  <li><strong>Fix:</strong> Change <code class="language-plaintext highlighter-rouge">static inline</code> to <code class="language-plaintext highlighter-rouge">static ALWAYS_INLINE</code> in both the declaration and definition.</li>
</ul>

<h3 id="patch-3--server_store_cached_values-no-op">Patch 3:  <code class="language-plaintext highlighter-rouge">server_store_cached_values</code> no-op</h3>

<ul>
  <li><strong>File:</strong> <code class="language-plaintext highlighter-rouge">sql-common/net_serv.cc</code></li>
  <li><strong>Problem:</strong> 8.0 added <code class="language-plaintext highlighter-rouge">server_store_cached_values()</code>, which calls <code class="language-plaintext highlighter-rouge">THD::store_cached_properties(RW_STATUS)</code> on every network I/O path (packet reads, writes, async operations; 10 call sites in <code class="language-plaintext highlighter-rouge">net_serv.cc</code>). This refreshes cached THD properties that are rarely consumed. The mechanism does not exist in 5.7.</li>
  <li><strong>Fix:</strong> Replace the function body with an empty no-op.</li>
</ul>

<h3 id="patch-4--fold_condition-fast-path">Patch 4:  <code class="language-plaintext highlighter-rouge">fold_condition</code> fast path</h3>

<ul>
  <li><strong>File:</strong> <code class="language-plaintext highlighter-rouge">sql/sql_const_folding.cc</code></li>
  <li><strong>Problem:</strong> 8.0 introduced constant folding (<code class="language-plaintext highlighter-rouge">fold_condition</code>) during <code class="language-plaintext highlighter-rouge">JOIN::optimize()</code> → <code class="language-plaintext highlighter-rouge">optimize_cond()</code> → <code class="language-plaintext highlighter-rouge">remove_eq_conds()</code> on every execution, including every prepared statement re-execution. For the common shape <code class="language-plaintext highlighter-rouge">field OP literal_constant</code> , which matches every sysbench query here, the function does no useful work, but still walks the full folding logic. 5.7 has no such pass.</li>
  <li><strong>Fix:</strong> Add an early fast path in <code class="language-plaintext highlighter-rouge">fold_condition()</code> that detects <code class="language-plaintext highlighter-rouge">field OP basic_const</code> or <code class="language-plaintext highlighter-rouge">field OP param</code> and returns <code class="language-plaintext highlighter-rouge">false</code> immediately.</li>
</ul>

<h3 id="patch-5--info_low-fast-path">Patch 5:  <code class="language-plaintext highlighter-rouge">info_low</code> fast path</h3>

<ul>
  <li><strong>File:</strong> <code class="language-plaintext highlighter-rouge">storage/innobase/handler/ha_innodb.cc</code></li>
  <li><strong>Problem:</strong> <code class="language-plaintext highlighter-rouge">ha_innobase::info_low()</code> is called by the optimizer for cost estimation on every <code class="language-plaintext highlighter-rouge">UPDATE</code> and <code class="language-plaintext highlighter-rouge">DELETE</code>. In 8.0, the function became heavier because of additional statistics-related logic. The common case (<code class="language-plaintext highlighter-rouge">HA_STATUS_VARIABLE | HA_STATUS_NO_LOCK</code>) only needs a small amount of information, but still goes through <code class="language-plaintext highlighter-rouge">update_thd()</code>, <code class="language-plaintext highlighter-rouge">op_info</code> writes, and extra helper calls.</li>
  <li><strong>Fix:</strong> Add a fast path at the top that handles <code class="language-plaintext highlighter-rouge">HA_STATUS_VARIABLE | HA_STATUS_NO_LOCK</code> directly and returns immediately.</li>
</ul>

<h2 id="appendix-b-environment-details">Appendix B: Environment details</h2>

<ul>
  <li><strong>CPU:</strong> AMD Ryzen Threadripper PRO 3975WX, 32 cores / 64 threads, single socket</li>
  <li><strong>OS:</strong> RHEL 8.10, kernel 4.18.0-553</li>
  <li><strong>Compiler:</strong> GCC 8.5.0, <code class="language-plaintext highlighter-rouge">-O3 -g -DNDEBUG</code> (RelWithDebInfo)</li>
  <li><strong>MySQL:</strong> 5.7.44 vs 8.0.45 (KernelMaker fork)</li>
  <li><strong>CPU pinning:</strong> <code class="language-plaintext highlighter-rouge">mysqld</code> on cores 16–23, <code class="language-plaintext highlighter-rouge">sysbench</code> on cores 24–31 (physical cores, no hyperthreading overlap)</li>
  <li><strong>Data:</strong> 1 table, 50M rows (<code class="language-plaintext highlighter-rouge">sbtest1</code>), about 12 GB InnoDB tablespace per version</li>
  <li><strong>Benchmark:</strong> sysbench <code class="language-plaintext highlighter-rouge">oltp_update_non_index</code>, 8 threads, 90 seconds, <code class="language-plaintext highlighter-rouge">--report-interval=5</code></li>
</ul>

<h3 id="shared-innodb-configuration">Shared InnoDB configuration</h3>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">innodb_buffer_pool_size</span>      <span class="p">=</span> <span class="s">23G</span>
<span class="py">innodb_buffer_pool_instances</span> <span class="p">=</span> <span class="s">4</span>
<span class="py">innodb_flush_log_at_trx_commit</span> <span class="p">=</span> <span class="s">2</span>
<span class="py">innodb_flush_method</span>          <span class="p">=</span> <span class="s">O_DIRECT_NO_FSYNC</span>
<span class="py">innodb_adaptive_hash_index</span>   <span class="p">=</span> <span class="s">OFF</span>
<span class="py">innodb_io_capacity</span>           <span class="p">=</span> <span class="s">10000</span>
<span class="py">innodb_io_capacity_max</span>       <span class="p">=</span> <span class="s">20000</span>
<span class="py">innodb_page_cleaners</span>         <span class="p">=</span> <span class="s">4</span>
<span class="py">innodb_purge_threads</span>         <span class="p">=</span> <span class="s">4</span>
<span class="py">innodb_log_file_size</span>         <span class="p">=</span> <span class="s">2G</span>
<span class="py">innodb_log_files_in_group</span>    <span class="p">=</span> <span class="s">15</span>
<span class="py">innodb_log_buffer_size</span>       <span class="p">=</span> <span class="s">64M</span>
<span class="py">innodb_max_dirty_pages_pct</span>   <span class="p">=</span> <span class="s">90</span>
<span class="py">innodb_max_dirty_pages_pct_lwm</span> <span class="p">=</span> <span class="s">80</span>
<span class="py">sync_binlog</span>                  <span class="p">=</span> <span class="s">0</span>
</code></pre></div></div>

<p>8.0-only settings: <code class="language-plaintext highlighter-rouge">innodb_dedicated_server=OFF</code>, <code class="language-plaintext highlighter-rouge">innodb_idle_flush_pct=1</code>, <code class="language-plaintext highlighter-rouge">innodb_doublewrite_pages=128</code>, <code class="language-plaintext highlighter-rouge">innodb_use_fdatasync=ON</code>, <code class="language-plaintext highlighter-rouge">default_authentication_plugin=mysql_native_password</code>.</p>

<h3 id="non-patchable-80-overhead-architectural">Non-patchable 8.0 overhead (architectural)</h3>

<p>The following 8.0-specific CPU costs were visible in profiling, but they are not realistically patchable in the same way because they are either correctness-critical or fundamental to the current 8.0 architecture:</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>CPU%</th>
      <th>Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">locksys::Global_shared_latch_guard</code></td>
      <td>0.84%</td>
      <td>Lock sharding; correctness-critical for concurrent lock operations</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">log_buffer_reserve</code></td>
      <td>0.57%</td>
      <td>Lock-free redo design; replaces 5.7’s <code class="language-plaintext highlighter-rouge">log_sys</code> mutex</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">log_buffer_write</code> + <code class="language-plaintext highlighter-rouge">log_buffer_write_completed</code></td>
      <td>0.67%</td>
      <td>Lock-free redo design</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">log_wait_for_space_in_log_recent_closed</code></td>
      <td>0.31%</td>
      <td>Lock-free redo design</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PolicyMutex::enter</code> (InnoDB trx fabric)</td>
      <td>1.79%</td>
      <td>Transaction infrastructure mutexes spread across multiple internal subsystems</td>
    </tr>
    <tr>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[The performance regression in MySQL 8.0 is well known, but it is still not fully understood. That is because it is not a regression caused by one obvious bottleneck. MySQL 8.0 introduced many new designs and refactored major subsystems, so the gap comes from a combination of configuration defaults, architectural trade-offs, and many small overheads spread across different layers.]]></summary>
  </entry>
  <entry>
    <title type="html">MySQL vs PostgreSQL Internals (Part 2) – MVCC (Multi-version Concurrency Control)</title>
    <link href="https://kernelmaker.github.io/mysql-vs-pg-mvcc" rel="alternate" type="text/html" title="MySQL vs PostgreSQL Internals (Part 2) – MVCC (Multi-version Concurrency Control)"/>
    <published>2026-03-10T00:00:00+00:00</published>
    <updated>2026-03-10T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/mysql-vs-pg-mvcc</id>
    <content type="html" xml:base="https://kernelmaker.github.io/mysql-vs-pg-mvcc"><![CDATA[<p>In the previous <a href="https://kernelmaker.github.io/mysql-vs-pg-bufferpool">post</a>, I took a detailed look at how MySQL and PostgreSQL differ in their buffer pool design and implementation. In this post, I will continue with a detailed comparison of their MVCC implementations.</p>

<h2 id="the-role-of-mvcc">The Role of MVCC</h2>

<p>MVCC (Multi-version Concurrency Control) is a common mechanism used in transactional databases to resolve read–write conflicts. The core idea is that when a transaction modifies data, it does not overwrite the original data directly. Instead, it preserves the previous version while creating a new version of the record. As a result, all historical versions of a record are retained in the database.</p>

<p>The key benefit is that read and write transactions on the same record no longer need to block each other. Even if a write transaction has modified a record but not yet committed, a read transaction can still directly read the version that is visible to it from the historical versions.</p>

<p>The simplified principle is illustrated below:</p>

<p><img src="/public/images/2026-03-10/1.png" alt="image-1" /></p>

<p>For three modifications to the record with PK, each modification creates a new version, so all historical versions of the record are retained in the database.</p>

<p>So what is the benefit of retaining all historical versions? Consider the following example:</p>

<p><img src="/public/images/2026-03-10/2.png" alt="image-2" /></p>

<p>Three write transactions A, B, and C modify the same PK sequentially on the timeline, while three read transactions X, Y, and Z interleave with them and read the PK. Without MVCC, read transactions must block until write transactions commit and release locks. With MVCC, when read transaction X attempts to read the PK, the state of the PK is:</p>

<ol>
  <li>Write transaction A inserted PK: ‘aaa’ and has committed</li>
  <li>Write transaction B modified it to PK: ‘bbb’ but has not yet committed</li>
</ol>

<p>Under RC (Read Committed) and RR (Repeatable Read) isolation levels, PK: ‘bbb’ is not visible to transaction X. Because MVCC preserves the old version, transaction X can directly read the visible version PK: ‘aaa’ and ignore the currently running write transaction B. This greatly improves read–write concurrency.</p>

<p>As an essential capability of databases, MVCC is supported by both MySQL and PostgreSQL. Fundamentally, they both achieve the behavior described above, but their designs and implementations make different trade-offs.</p>

<p>In the following sections, I will compare their implementations in detail across three aspects:</p>

<ol>
  <li>Organization of multiple versions</li>
  <li>Visibility checks for multiple versions</li>
  <li>Garbage collection of old versions</li>
</ol>

<h1 id="1-organization-of-multiple-versions">1. Organization of Multiple Versions</h1>

<h2 id="postgresql">PostgreSQL</h2>

<p>In PostgreSQL, a tuple and all its historical versions reside in the heap, as shown below:</p>

<p><img src="/public/images/2026-03-10/3.png" alt="image-3" /></p>

<p>The leaf nodes of the nbtree index store the PK fields of the tuple and point to the actual location of the tuple in the heap (TID). Following the Heap TID leads to the tuple, which contains the full data including PK fields and value fields.</p>

<p>Each index tuple points to the <strong>oldest version</strong> in its corresponding HOT chain. When the tuple is modified, the old version is not changed. Instead, a new version is created with the same PK fields but different value fields. The <code class="language-plaintext highlighter-rouge">ctid</code> field of the old tuple points to the location of the next version. The latest version’s <code class="language-plaintext highlighter-rouge">ctid</code> points to itself.</p>

<p>In short, every version of a PostgreSQL tuple is a complete tuple containing all fields. Starting from the index tuple, the version chain is linked from old to new through the <code class="language-plaintext highlighter-rouge">ctid</code> field.</p>

<p>It is important to note that the version chain may become ‘<strong>broken</strong>’, as shown below:</p>

<p><img src="/public/images/2026-03-10/4.png" alt="image-4" /></p>

<p>The PK fields remain unchanged, but the value fields are modified multiple times. Each modification generates a new full version stored in the same heap page as the old version, as shown for versions 1, 2, and 3. Because they reside in the same heap page, advancing through the chain via <code class="language-plaintext highlighter-rouge">ctid</code> is very cheap (no additional heap page lock is required). This is the type of chain that can be efficiently traversed for version lookups, known as a <strong>HOT chain</strong> in PostgreSQL.</p>

<p>A HOT chain requires two conditions:</p>

<ol>
  <li>The new version can fit into the same heap page</li>
  <li>The modified columns do not include any indexed columns</li>
</ol>

<p>If either condition is not met, the HOT chain breaks.</p>

<p>As modifications continue, starting from version 4, the original heap page (heap page 1) can no longer accommodate the new tuple. The new tuple is therefore stored in another page (heap page 2). Although version 3’s <code class="language-plaintext highlighter-rouge">ctid</code> still points to version 4, the HOT chain effectively ends at version 3.</p>

<p>When traversing a HOT chain, the reader will <strong>not follow <code class="language-plaintext highlighter-rouge">ctid</code> across heap pages</strong>. Instead, it stops at the end of the chain. The reason is that both the latest and historical versions of tuples reside in heap pages. If the reader followed <code class="language-plaintext highlighter-rouge">ctid</code> from page 1 to page 2, it would hold a read lock on heap page 1 and attempt to acquire a read lock on heap page 2. Because both pages are heap pages and there is no defined lock ordering between them, another backend might hold the lock on page 2 and attempt to acquire the lock on page 1, leading to a deadlock.</p>

<p>At this point, the HOT chain is considered broken.</p>

<p>How does PostgreSQL transition from version 3 to version 4 then?</p>

<p>The implementation is to insert a new index tuple into the nbtree index with the same PK fields pointing to version 4, starting a new HOT chain. If a read operation traverses the version chain and finds versions 1, 2, and 3 all invisible, it stops the current HOT chain traversal and returns to the index layer. It then proceeds to the next index tuple (the one pointing to version 4).</p>

<p>This design leads to an interesting and somewhat counterintuitive behavior: <strong>multiple index tuples with identical PK fields may coexist in the nbtree index.</strong></p>

<h2 id="mysql">MySQL</h2>

<p>In MySQL, data is stored directly in the <strong>clustered index (B+Tree)</strong> leaf nodes. This is an important difference from PostgreSQL: MySQL does not have a heap.</p>

<p>The second difference is that the record stored in the clustered index and its historical versions reside in different places. Historical versions are not stored in the clustered index. Instead, old values are stored in <strong>undo records</strong> in the undo space. When needed, historical versions are reconstructed by applying the undo records to the current record.</p>

<p>The third difference is that an undo record does not store a full copy of the record. It only stores the <strong>old values of the columns modified in the operation</strong>.</p>

<p>The fourth difference is the direction of the version chain. In MySQL, the clustered index always stores the <strong>latest version</strong>. Before a record is modified, the old values of the columns being changed (together with the PK fields) are copied to the undo space, and the record is updated <strong>in place</strong>.</p>

<p>As shown below:</p>

<p><img src="/public/images/2026-03-10/5.png" alt="image-5" /></p>

<p>The clustered index record contains two system fields: <code class="language-plaintext highlighter-rouge">TRX_ID</code> and <code class="language-plaintext highlighter-rouge">ROLL_PTR</code>.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">TRX_ID</code> records the transaction ID that last modified the record and is used for MVCC visibility checks.</li>
  <li><code class="language-plaintext highlighter-rouge">ROLL_PTR</code> links the version chain.</li>
</ul>

<p>Similar to PostgreSQL’s <code class="language-plaintext highlighter-rouge">ctid</code>, <code class="language-plaintext highlighter-rouge">ROLL_PTR</code> links versions together, but the direction is opposite:
 <code class="language-plaintext highlighter-rouge">ctid</code> points <strong>from old to new</strong>, while <code class="language-plaintext highlighter-rouge">ROLL_PTR</code> points <strong>from new to old</strong>.</p>

<p>In the figure, the record was modified three times:</p>

<ol>
  <li>Field 2 was modified</li>
  <li>Field 2 was modified again</li>
  <li>Field 3 was modified</li>
</ol>

<p>Therefore, the clustered index record stores the latest version after the three modifications. Through <code class="language-plaintext highlighter-rouge">ROLL_PTR</code>, it points to the previous version stored in the undo space (the version before Field 3 was modified), and so on.</p>

<h2 id="summary">Summary</h2>

<p>The differences in version organization between PostgreSQL and MySQL can be summarized in three contrasts:</p>

<ol>
  <li>Versions mixed together vs latest version and historical versions stored in different spaces</li>
  <li>Old versions contain full tuples vs old versions mainly store the primary key and old values of modified columns</li>
  <li>Version chain ordered from old to new vs from new to old</li>
</ol>

<h1 id="2-visibility-checks-for-multiple-versions">2. Visibility Checks for Multiple Versions</h1>

<p>Once multiple versions exist, the next question is: <strong>how does a read transaction determine which version it should see?</strong></p>

<p>This is the core of MVCC: <strong>visibility checks</strong>.</p>

<p>To determine visibility, the database must establish an order among transactions. Taking the RR isolation level as an example, when a transaction begins, it must know which write transactions are currently active in the system. All modifications produced by those active transactions are invisible to the read transaction. Only modifications from transactions that were already committed at that moment are visible.</p>

<p>Therefore, if the database can define an order among write transactions, it becomes straightforward to perform visibility checks.</p>

<p>Most databases achieve this by using a <strong>globally increasing transaction ID</strong>. When a write transaction is created, it obtains the current maximum transaction ID plus one. This naturally orders write transactions.</p>

<p>Once transaction IDs exist, each data modification can be tagged with the transaction ID that produced it. A read transaction, when created, obtains the list of currently active write transactions. Later, when reading data, it simply compares the transaction ID recorded on the data with this list and applies the visibility rules to determine whether the data is visible.</p>

<p>Both PostgreSQL and MySQL follow this approach.</p>

<h2 id="postgresql-1">PostgreSQL</h2>

<p>As mentioned earlier, transaction IDs are critical. In PostgreSQL, the globally increasing transaction ID is called <strong><code class="language-plaintext highlighter-rouge">nextXid</code></strong>.</p>

<p>Each write transaction obtains the latest value when it starts.</p>

<p><img src="/public/images/2026-03-10/6.png" alt="image-6" /></p>

<p>Transaction A is created first and obtains xid 7. It inserts PK: ‘aaa’. The tuple records this through the <code class="language-plaintext highlighter-rouge">xmin</code> field, which stores the inserter’s transaction ID (7).</p>

<p>After transaction A commits, transaction B is created and obtains xid 11. It updates the record to ‘bbb’. Following the multi-version rule, transaction B does not overwrite the tuple inserted by A. Instead, it creates a new version. The old tuple’s <code class="language-plaintext highlighter-rouge">xmax</code> is set to 11, indicating that transaction 11 has “deleted” this tuple version. The new tuple records <code class="language-plaintext highlighter-rouge">xmin = 11</code>. The old tuple’s <code class="language-plaintext highlighter-rouge">ctid</code> points to the new tuple.</p>

<p>Transaction C proceeds similarly.</p>

<p><img src="/public/images/2026-03-10/7.png" alt="image-7" /></p>

<p>Thus, each PostgreSQL tuple contains two fields recording the related transactions:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">xmin</code> : the inserter</li>
  <li><code class="language-plaintext highlighter-rouge">xmax</code> : the deleter</li>
</ul>

<p>With the global transaction ID (<code class="language-plaintext highlighter-rouge">nextXid</code>) and the transaction tags (<code class="language-plaintext highlighter-rouge">xmin</code>, <code class="language-plaintext highlighter-rouge">xmax</code>) on each tuple, the next requirement is the <strong>snapshot</strong> used by read transactions for visibility checks.</p>

<p><img src="/public/images/2026-03-10/8.png" alt="image-8" /></p>

<p>At the top of the figure are the globally increasing transaction IDs and the currently active write transactions. The next ID to allocate is 16. Among all assigned IDs, transactions ≤7 have already committed. Between 8 and 15, some have committed, and the currently active write transactions are 8, 11, 12, and 14.</p>

<p>If a read transaction starts now, it obtains a snapshot:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">xmin</code> : the smallest active transaction ID (8)</li>
  <li><code class="language-plaintext highlighter-rouge">xmax</code> : the next transaction ID to allocate (16)</li>
  <li><code class="language-plaintext highlighter-rouge">xids[]</code> : the list of active transactions</li>
</ul>

<p>With this snapshot, it can determine whether a tuple’s transaction tag is visible. For example:</p>

<ul>
  <li>If a tuple has <code class="language-plaintext highlighter-rouge">xmin = 7</code>, it is visible to the snapshot.</li>
  <li>If a tuple has <code class="language-plaintext highlighter-rouge">xmin = 14</code>, it is not visible.</li>
</ul>

<p>Now that we know how to determine the visibility of transaction tags, the final question is how to determine whether a tuple itself is visible, given that it has both <code class="language-plaintext highlighter-rouge">xmin</code> and <code class="language-plaintext highlighter-rouge">xmax</code>.</p>

<p>The core principle is:</p>

<blockquote>
  <p>A tuple is visible to a snapshot if its inserter (<code class="language-plaintext highlighter-rouge">xmin</code>) is visible and its deleter (<code class="language-plaintext highlighter-rouge">xmax</code>) is not visible.</p>
</blockquote>

<p><img src="/public/images/2026-03-10/9.png" alt="image-9" /></p>

<p>The process is:</p>

<ol>
  <li>Check <code class="language-plaintext highlighter-rouge">xmin</code>. If <code class="language-plaintext highlighter-rouge">xmin</code> is not visible, the tuple is invisible.</li>
  <li>If <code class="language-plaintext highlighter-rouge">xmin</code> is visible, check <code class="language-plaintext highlighter-rouge">xmax</code>.</li>
  <li>If <code class="language-plaintext highlighter-rouge">xmax</code> is also visible, the tuple has been deleted in the snapshot and is therefore invisible.</li>
  <li>If <code class="language-plaintext highlighter-rouge">xmax</code> is not visible, the tuple is visible.</li>
</ol>

<p>Finally, the following figure shows the process of locating a tuple visible to a snapshot starting from the nbtree index:</p>

<p><img src="/public/images/2026-03-10/10.png" alt="image-10" /></p>

<h2 id="mysql-1">MySQL</h2>

<p>MySQL also has a globally increasing transaction ID called <strong><code class="language-plaintext highlighter-rouge">next_trx_id_or_no</code></strong>.</p>

<p><img src="/public/images/2026-03-10/11.png" alt="image-11" /></p>

<p>In the example, three transactions modify the same record three times. Both the clustered index record and the undo records contain a <code class="language-plaintext highlighter-rouge">TRX_ID</code> field. This field is the transaction tag used by MySQL. The <code class="language-plaintext highlighter-rouge">TRX_ID</code> records which transaction created that version of the record.</p>

<p>Unlike PostgreSQL, a record in MySQL has <strong>only one transaction tag</strong>, <code class="language-plaintext highlighter-rouge">TRX_ID</code>, rather than two. The reason will be explained later.</p>

<p>Next, consider the visibility check.</p>

<p><img src="/public/images/2026-03-10/12.png" alt="image-12" /></p>

<p>MySQL’s <strong>ReadView</strong> is extremely similar to PostgreSQL’s snapshot and serves the same purpose. The only difference is that MySQL’s ReadView contains an additional field: <code class="language-plaintext highlighter-rouge">m_creator_trx_id</code>.</p>

<p>This field is necessary because the transaction that creates the ReadView is itself included in <code class="language-plaintext highlighter-rouge">m_ids[]</code> (since it is an active transaction). Without <code class="language-plaintext highlighter-rouge">m_creator_trx_id</code>, the transaction would not be able to see its own modifications. It also handles cases where a read transaction is promoted to a write transaction.</p>

<p>Aside from this, the visibility rules are almost identical.</p>

<p>Given the visibility rules, determining whether a record is visible to a ReadView becomes straightforward:</p>

<p><img src="/public/images/2026-03-10/13.png" alt="image-13" /></p>

<p>The process is simple: check whether the record’s <code class="language-plaintext highlighter-rouge">TRX_ID</code> is visible to the ReadView. Unlike PostgreSQL, MySQL does not need two separate checks for <code class="language-plaintext highlighter-rouge">xmin</code> and <code class="language-plaintext highlighter-rouge">xmax</code>.</p>

<p>Finally, the process of finding a version visible to a ReadView starting from the B+Tree is shown below:</p>

<p><img src="/public/images/2026-03-10/14.png" alt="image-14" /></p>

<h2 id="summary-1">Summary</h2>

<p>PostgreSQL and MySQL use highly similar visibility mechanisms. The primary difference is that PostgreSQL stores two transaction tags (<code class="language-plaintext highlighter-rouge">xmin</code> and <code class="language-plaintext highlighter-rouge">xmax</code>) on each tuple, requiring two checks. MySQL stores only one (<code class="language-plaintext highlighter-rouge">TRX_ID</code>), requiring only one check.</p>

<p>Why is this the case? The fundamental reason is the <strong>direction of the version chain</strong>:</p>

<ol>
  <li>PostgreSQL’s version chain goes from old to new. In theory, <code class="language-plaintext highlighter-rouge">xmin</code> alone would be sufficient because it records the inserter. However, when traversing the chain, the reader cannot stop immediately after finding a visible insert because the next version might also be visible. The reader must continue until it finds the first version whose insert is invisible. The previous version is then the visible version. This means at least one extra step is required. PostgreSQL therefore stores the insert transaction of the next version as the deleter (<code class="language-plaintext highlighter-rouge">xmax</code>) of the current version, avoiding that extra traversal. Additionally, <code class="language-plaintext highlighter-rouge">xmax</code> is required for DELETE operations where no next version exists.</li>
  <li>MySQL’s version chain goes from new to old. Once the latest version is found to be invisible, the reader simply moves to the previous version until it finds the first visible one. No additional step is required.</li>
</ol>

<h1 id="3-garbage-collection-of-multiple-versions">3. Garbage Collection of Multiple Versions</h1>

<p>The next core problem in MVCC is <strong>garbage collection of historical versions</strong>. Historical versions are not always needed.</p>

<p>Because global transaction IDs advance linearly, the snapshots (or ReadViews) of read transactions also move forward. A historical version can be safely purged when:</p>

<blockquote>
  <p>No active snapshot or ReadView in the system still needs that version (i.e., all snapshots can already see the newer version that replaced it).</p>
</blockquote>

<p>This is where PostgreSQL and MySQL differ most significantly.</p>

<hr />

<h2 id="postgresql-2">PostgreSQL</h2>

<p>PostgreSQL reclaims historical versions through the <strong>Vacuum backend</strong>.</p>

<p><img src="/public/images/2026-03-10/15.png" alt="image-15" /></p>

<p>PostgreSQL uses <code class="language-plaintext highlighter-rouge">GlobalVisState</code> to track purge boundaries. It contains two variables:</p>

<p><strong>maybe_needed</strong></p>

<p>This is the minimum value among all backend transaction IDs and the <code class="language-plaintext highlighter-rouge">xmin</code> values of their snapshots. Backend transaction IDs must be considered because a backend may have started a write transaction and obtained an xid but not yet created a snapshot. That xid still forms a lower bound that cannot be crossed.</p>

<p>All tuple <code class="language-plaintext highlighter-rouge">xmax</code> values (deleters) are compared against <code class="language-plaintext highlighter-rouge">maybe_needed</code>. If <code class="language-plaintext highlighter-rouge">xmax</code> is smaller than <code class="language-plaintext highlighter-rouge">maybe_needed</code>, the deleter is visible to all backends and snapshots, meaning the tuple is globally deleted and can be safely purged.</p>

<p><strong>definitely_needed</strong></p>

<p>This is the <code class="language-plaintext highlighter-rouge">xmin</code> of the latest snapshot taken by the Vacuum backend. Any tuple whose <code class="language-plaintext highlighter-rouge">xmax</code> is greater than or equal to <code class="language-plaintext highlighter-rouge">definitely_needed</code> is invisible to the Vacuum backend and cannot be purged.</p>

<p>These two values define the continuous upper bound that can be purged and the lower bound that cannot. For tuples whose <code class="language-plaintext highlighter-rouge">xmax</code> falls between these bounds, Vacuum may need to refresh <code class="language-plaintext highlighter-rouge">maybe_needed</code> and re-evaluate, since the snapshot used by Vacuum might be outdated. Because refreshing is expensive, PostgreSQL optimizes this by checking whether <code class="language-plaintext highlighter-rouge">RecentXmin</code> has advanced. If it has not changed, refreshing is skipped.</p>

<p>With these rules, the workflow of the Vacuum backend is:</p>

<p><img src="/public/images/2026-03-10/16.png" alt="image-16" /></p>

<ol>
  <li>Scan all heap tuples and determine whether they can be purged using <code class="language-plaintext highlighter-rouge">GlobalVisState</code>. Collect purgable tuples into a set.</li>
  <li>Scan all index tuples and check whether they reference heap tuples in the purge set. If so, delete those index tuples.</li>
  <li>Scan again all pages containing dead tuples collected in step 1 and reclaim them (setting their line pointers to <code class="language-plaintext highlighter-rouge">LP_UNUSED</code>).</li>
</ol>

<p>This process involves extensive scanning of both heap and index structures, which can be expensive. PostgreSQL mitigates this cost with several optimizations:</p>

<ol>
  <li><strong>Visibility map</strong> – allows the first scan to skip pages where all tuples are visible.</li>
  <li><strong>HOT pruning</strong> – during normal reads of heap pages, PostgreSQL opportunistically removes dead tuples through <code class="language-plaintext highlighter-rouge">heap_page_prune()</code>, reducing the workload of Vacuum.</li>
  <li><strong>LP_REDIRECT</strong> – when intermediate versions in a HOT chain are removed, the head line pointer is redirected to the surviving tuple instead of being marked unused, so existing index tuples can still locate the correct tuple without index updates.</li>
</ol>

<h2 id="mysql-2">MySQL</h2>

<p>MySQL takes a different approach.</p>

<p>All undo records (historical versions) are grouped by the transactions that produced them. These transactions are then organized according to their <strong>global commit order</strong> (forming a min-heap).</p>

<p>With this ordering, MySQL can quickly identify the undo records belonging to the <strong>earliest committed transaction</strong>, which are typically the closest candidates for purging.</p>

<p>The purge thread compares the transaction number (<code class="language-plaintext highlighter-rouge">trx_no</code>) of the earliest transaction in the history list with <code class="language-plaintext highlighter-rouge">m_low_limit_no</code> from the purge view.</p>

<ul>
  <li>If <code class="language-plaintext highlighter-rouge">trx_no &lt; m_low_limit_no</code>, all active ReadViews can see this transaction’s commit, so its undo records are no longer needed and can be safely purged.</li>
  <li>Otherwise, it cannot be purged. Since it is the earliest transaction, later ones cannot be purged either, so the purge process stops and waits.</li>
</ul>

<p>An important optimization is that transactions are ordered by <strong>commit order rather than creation order</strong>.</p>

<p>Sorting by creation order would be safe because the earliest transaction must be purged first. However, it has a drawback: if the earliest transaction does not commit for a long time, later transactions that have already committed cannot be purged even if they are no longer needed.</p>

<p>For example:</p>

<ol>
  <li>Trx A is created and modifies record R1 from ‘111’ to ‘222’</li>
  <li>Trx B is created and modifies record R2 from ‘aaa’ to ‘bbb’</li>
  <li>Read-only Trx X starts. Since Trx B has not committed, X sees R2 as ‘aaa’</li>
  <li>Trx B commits</li>
  <li>Trx X commits</li>
</ol>

<p>If transactions were ordered by creation time, Trx A would come before Trx B. Because Trx A has not committed, purge would be blocked and Trx B’s undo records could not be purged, even though no ReadView needs them anymore.</p>

<p>By ordering transactions by commit time instead, MySQL can purge Trx B’s undo records immediately after its commit.</p>

<p>This is an important optimization. Notably, <code class="language-plaintext highlighter-rouge">trx_id</code> and <code class="language-plaintext highlighter-rouge">trx_no</code> both come from the same global variable: <code class="language-plaintext highlighter-rouge">next_trx_id_or_no</code>.</p>

<p>The workflow is shown below:</p>

<p><img src="/public/images/2026-03-10/17.png" alt="image-17" /></p>

<p>The purge thread first clones the oldest active ReadView in the system. The <code class="language-plaintext highlighter-rouge">m_low_limit_no</code> in this ReadView represents the smallest <code class="language-plaintext highlighter-rouge">trx_no</code> that was still committing when the view was created. All transactions with smaller <code class="language-plaintext highlighter-rouge">trx_no</code> values have already committed.</p>

<p>In the undo space, committed transactions’ undo records are linked together in the history list in commit order (ascending <code class="language-plaintext highlighter-rouge">trx_no</code>). The purge thread simply compares <code class="language-plaintext highlighter-rouge">m_low_limit_no</code> with the smallest <code class="language-plaintext highlighter-rouge">trx_no</code> in the history list to determine whether purging is possible.</p>

<h1 id="summary-2">Summary</h1>

<p>Garbage collection of historical versions is a major implementation difference between PostgreSQL and MySQL.</p>

<p>In fact, it reflects their different design philosophies. This difference was already visible in the previous post discussing buffer pools.</p>

<p>MySQL tends to favor <strong>precise control and ordered structures</strong>, such as the LRU list and flush list, which allow it to quickly identify the oldest pages that can be evicted or flushed. Similarly, undo purge maintains ordered historical versions so that the oldest purgeable undo records can be quickly located.</p>

<p>PostgreSQL, on the other hand, tends to rely more on <strong>global scanning</strong> mechanisms, both in shared buffers and in Vacuum. In the buffer pool case, the cost of global scanning is relatively low because it scans descriptor arrays in memory. However, Vacuum must scan heap and index disk pages (although visibility maps can skip many all-visible pages). For frequently updated tables, the amount of scanning can still be substantial.</p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[In the previous post, I took a detailed look at how MySQL and PostgreSQL differ in their buffer pool design and implementation. In this post, I will continue with a detailed comparison of their MVCC implementations.]]></summary>
  </entry>
  <entry>
    <title type="html">MySQL vs PostgreSQL Internals (Part 1) – Buffer Pool</title>
    <link href="https://kernelmaker.github.io/mysql-vs-pg-bufferpool" rel="alternate" type="text/html" title="MySQL vs PostgreSQL Internals (Part 1) – Buffer Pool"/>
    <published>2026-02-16T00:00:00+00:00</published>
    <updated>2026-02-16T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/mysql-vs-pg-bufferpool</id>
    <content type="html" xml:base="https://kernelmaker.github.io/mysql-vs-pg-bufferpool"><![CDATA[<p>The debate over “MySQL vs PostgreSQL, which one is better?” has been around for a long time. As two outstanding representatives of open-source OLTP databases, I personally don’t think one overwhelmingly dominates the other. Transactional database theory has been stable for decades; both systems are practical implementations built under the same theoretical framework.</p>

<p>The differences mainly come from <strong>different trade-offs made during engineering practice</strong>. I’ve always believed that database development is the art of trade-offs. So I’m planning a series that compares MySQL and PostgreSQL from the perspective of kernel design and implementation, focusing on the different trade-offs they make when pursuing similar goals.</p>

<p>As the first article in this series, I’ll start with the design and implementation differences of the <strong>Buffer Pool</strong>.</p>

<h2 id="comparison-dimensions">Comparison Dimensions</h2>

<p>The Buffer Pool in MySQL and the corresponding module in PostgreSQL (commonly referred to as <strong>Shared Buffers</strong>) are critical subsystems. Their primary job is to cache on-disk data pages in memory to minimize disk I/O as much as possible, and they are therefore a major factor in relational database performance.</p>

<p>In essence, it is a huge hash table:</p>

<ul>
  <li>The key corresponds to a specific on-disk data page.</li>
  <li>The value is a pointer (or index) to the in-memory representation of that page.</li>
</ul>

<p>In the following sections, I compare MySQL and PostgreSQL buffer pool designs from these aspects:</p>

<ol>
  <li><strong>Hash table structure and implementation</strong></li>
  <li><strong>Eviction policy for old pages and its implementation</strong></li>
  <li><strong>Dirty page flushing strategy and its implementation</strong></li>
</ol>

<h2 id="1-hash-table">1. Hash Table</h2>

<h3 id="mysql">MySQL</h3>

<p><img src="/public/images/2026-02-16/1.png" alt="image-1" /></p>

<p>MySQL’s buffer pool is not backed by a single hash table, it uses <strong>multiple</strong> hash tables. As illustrated conceptually:</p>

<ol>
  <li>
    <p>Multiple <code class="language-plaintext highlighter-rouge">buf_pool_t</code> instances shard one large buffer pool. Each <code class="language-plaintext highlighter-rouge">buf_pool_t</code> maintains its own hash table.</p>
  </li>
  <li>
    <p>The hash key is <code class="language-plaintext highlighter-rouge">(space_id, page_no)</code>, identifying a specific page within a data file (tablespace). During lookup:</p>

    <ul>
      <li>First, it computes a hash using <code class="language-plaintext highlighter-rouge">(space_id, page_no &gt;&gt; 6)</code> to locate the corresponding <code class="language-plaintext highlighter-rouge">buf_pool_t</code> instance.</li>
      <li>Why shift <code class="language-plaintext highlighter-rouge">page_no &gt;&gt; 6</code>? Because MySQL tries to place <strong>64 consecutive pages</strong> under the same <code class="language-plaintext highlighter-rouge">space_id</code> into the same <code class="language-plaintext highlighter-rouge">buf_pool_t</code>. This helps in two ways:
        <ul>
          <li>During reads, it enables read-ahead (prefetching contiguous pages).</li>
          <li>During flushing, it increases the chance to flush contiguous dirty pages together, improving I/O utilization.</li>
        </ul>
      </li>
      <li>After locating the <code class="language-plaintext highlighter-rouge">buf_pool_t</code>, it computes a hash over the full key <code class="language-plaintext highlighter-rouge">(space_id, page_no)</code> to find the target cell in that instance’s hash table.
        <ul>
          <li>Pages with the same hash value are chained in that cell.</li>
          <li>The lookup then traverses the chain and compares keys to find the target page.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>
    <p>The hash table stores only pointers to the corresponding page objects (<code class="language-plaintext highlighter-rouge">buf_page_t</code>). The actual <code class="language-plaintext highlighter-rouge">buf_block_t</code> objects and page frames live in a large memory region.</p>

    <p><img src="/public/images/2026-02-16/2.png" alt="image-1" /></p>

    <ul>
      <li>MySQL splits the page memory into multiple <strong>chunks</strong> (<code class="language-plaintext highlighter-rouge">buf_chunk_t</code>).</li>
      <li>Each chunk is a contiguous block of memory.</li>
      <li>The first part stores per-page metadata (<code class="language-plaintext highlighter-rouge">buf_block_t</code>) for the pages in that chunk.</li>
      <li>The second part stores the actual 16KB page frames.</li>
      <li>The mapping between <code class="language-plaintext highlighter-rouge">buf_block_t</code> and the actual page frame is done via the <code class="language-plaintext highlighter-rouge">frame</code> pointer in <code class="language-plaintext highlighter-rouge">buf_block_t</code>.</li>
    </ul>
  </li>
</ol>

<h3 id="postgresql">PostgreSQL</h3>

<p><img src="/public/images/2026-02-16/3.png" alt="image-1" /></p>

<p>Conceptually (as illustrated):</p>

<ol>
  <li>
    <p>PostgreSQL also shards the shared buffer mapping, with a similar idea.</p>
  </li>
  <li>
    <p>It first hashes the key <code class="language-plaintext highlighter-rouge">(tablespaceOid, dbOid, relNumber, forkNum, blockNum)</code> to obtain a <strong>bucket number</strong>.</p>
  </li>
  <li>
    <p>Then it uses <code class="language-plaintext highlighter-rouge">bucket_number &gt;&gt; 8</code> to locate the directory entry in the first-level mapping, i.e., the <strong>segment</strong> (<code class="language-plaintext highlighter-rouge">dir</code>).</p>
  </li>
  <li>
    <p>Each segment contains 256 buckets, so after finding the segment, it uses <code class="language-plaintext highlighter-rouge">bucket_number % 256</code> to locate the bucket within the segment.</p>
  </li>
  <li>
    <p>It then traverses the bucket chain, comparing keys one by one to find the page.</p>
  </li>
  <li>
    <p>All page frames are stored in one contiguous memory region, as an array: <code class="language-plaintext highlighter-rouge">BufferBlocks[]</code>.</p>

    <p><img src="/public/images/2026-02-16/4.png" alt="image-1" /></p>

    <ul>
      <li>Each page is 8KB.</li>
      <li>PostgreSQL does <strong>not</strong> split this region into chunks like MySQL does, all pages are stored together.</li>
      <li>Metadata for pages is stored separately in another array: <code class="language-plaintext highlighter-rouge">BufferDescriptors[]</code>.</li>
      <li>Both arrays have the same number of elements, equal to the total number of buffers/pages.</li>
      <li>The indices align one-to-one: it is straightforward to locate the actual page frame from the metadata by index.</li>
      <li>The hash table stores <code class="language-plaintext highlighter-rouge">buf_id</code>, which is the index into both <code class="language-plaintext highlighter-rouge">BufferDescriptors[]</code> and <code class="language-plaintext highlighter-rouge">BufferBlocks[]</code>.</li>
    </ul>
  </li>
</ol>

<p><strong>Summary:</strong> Both MySQL and PostgreSQL implement fairly standard hash-table-based page lookup; there isn’t a fundamental difference there. The biggest difference is that MySQL splits pages into chunks, which makes it easier to dynamically resize the buffer pool by adding/removing chunks.</p>

<h2 id="2-eviction-policy-for-old-pages-aging-and-implementation">2. Eviction Policy for Old Pages (Aging) and Implementation</h2>

<h3 id="mysql-1">MySQL</h3>

<p><img src="/public/images/2026-02-16/5.png" alt="image-1" /></p>

<p>MySQL maintains page aging information in a direct way: pages in the hash table are also linked into an LRU doubly-linked list. Each page’s <code class="language-plaintext highlighter-rouge">buf_page_t::LRU</code> is the list node that links the page into the LRU list.</p>

<ul>
  <li>The LRU head points to the most recently accessed page.</li>
  <li>The LRU tail points to the least recently accessed page.</li>
</ul>

<p>Each time a page is found via hash lookup, MySQL moves the page to the head of the LRU list via <code class="language-plaintext highlighter-rouge">buf_page_t::LRU</code>. Over time, pages that are not accessed drift toward the tail. When memory is insufficient and an old page must be evicted, the tail provides a fast candidate.</p>

<p>Of course, that is the conceptual LRU behavior. MySQL adds an important optimization, because the above design has a major problem: if requests perform table scans, a large number of pages enter the LRU and can overwrite/destroy the existing hot/cold information. To avoid scan workloads disrupting the LRU, MySQL splits the LRU list.</p>

<p>Roughly ~37.5% from the tail, it maintains a <strong>midpoint</strong>:</p>

<ul>
  <li>To the left is the <strong>young</strong> area: the true hot region.</li>
  <li>To the right is the <strong>old</strong> area: a screening region for newly loaded pages.</li>
</ul>

<p>All new pages loaded from disk are initially inserted at the midpoint, i.e., the head of the <strong>old</strong> list. Since it is close to the tail, such pages are more likely to be evicted quickly. If a page is accessed again before it is evicted, MySQL does <strong>not</strong> immediately promote it to the young region. Instead, it records the first access time, and the page’s position stays unchanged. Only when it is accessed again, and the elapsed time since the first access exceeds <code class="language-plaintext highlighter-rouge">innodb_old_blocks_time</code> (default 1 second), will it be promoted to the LRU head (young region). As a result, pages introduced by full table scans typically stay in the old area for less than 1 second and are evicted quickly, without polluting the hot working set in the young region.</p>

<p>When a user thread needs to read a disk page but the buffer pool is full, it evicts an old page from the LRU tail and uses that frame to load the needed page. But eviction is not that trivial. Below is the concrete eviction procedure when a user thread needs a new page:</p>

<h4 id="first-attempt-n_iterations--0">First attempt (n_iterations == 0)</h4>

<ol>
  <li>First, try the free list. If a free page is found, return it. Otherwise:</li>
  <li>If <code class="language-plaintext highlighter-rouge">try_LRU_scan == true</code>, it indicates a partial LRU scan is allowed. Scan from the tail forward, at most 100 pages.
    <ul>
      <li>If an evictable page is found, reset it and move it to the free list, then <strong>return to step 1 and retry</strong>.</li>
      <li>If no evictable page is found, set <code class="language-plaintext highlighter-rouge">try_LRU_scan = false</code> to tell other user threads that partial LRU scanning is ineffective, so they should skip partial scans and go directly to the single-page flush path.</li>
    </ul>
  </li>
  <li>Notify the page cleaner thread that free pages are insufficient and it should accelerate cleaning.</li>
  <li>Scan forward from the tail.
    <ul>
      <li>If a clean evictable page is found, evict it directly.</li>
      <li>Otherwise, locate the first dirty page that can be flushed; perform a synchronous flush of that single page; then add it to the free list and <strong>proceed to the next attempt</strong>.</li>
    </ul>
  </li>
</ol>

<h4 id="second-attempt-n_iterations--1">Second attempt (n_iterations == 1)</h4>

<ol>
  <li>Same as first attempt step 1.</li>
  <li>Perform a full LRU list scan starting from the tail, searching for an evictable page; if found, move it to the free list and <strong>return to step 1 to retry</strong>. If that fails:</li>
  <li>Same as first attempt step 3.</li>
  <li>Same as first attempt step 4.</li>
</ol>

<h4 id="third-and-subsequent-attempts-n_iterations--1">Third and subsequent attempts (n_iterations &gt; 1)</h4>

<ol>
  <li>Same as first attempt step 1.</li>
  <li>Same as second attempt step 2.</li>
  <li>Same as first attempt step 3.</li>
  <li>Sleep for 10ms.</li>
  <li>Same as first attempt step 4.</li>
</ol>

<p>One more detail worth mentioning: the LRU scan does not always start from the tail for every thread. Each <code class="language-plaintext highlighter-rouge">buf_pool_t</code> maintains a global scan cursor <code class="language-plaintext highlighter-rouge">lru_scan_itr</code> (type <code class="language-plaintext highlighter-rouge">LRUItr</code>). After a thread finishes scanning, it leaves the cursor at its current position, and the next thread continues scanning from there, avoiding multiple threads repeatedly scanning the same region. Only when the cursor is empty/invalid, or still within the old region (meaning the previous scan did not progress far enough), will it be reset back to the tail. In addition, single-page flushing (step 4) uses another independent cursor <code class="language-plaintext highlighter-rouge">single_scan_itr</code>; these two cursors do not interfere with each other.</p>

<h3 id="postgresql-1">PostgreSQL</h3>

<p><img src="/public/images/2026-02-16/6.png" alt="image-1" /></p>

<p>PostgreSQL does not maintain a global LRU list like MySQL does, but that doesn’t mean it does not perform LRU-style eviction. It simply takes another path.</p>

<p>All page metadata lives in the <code class="language-plaintext highlighter-rouge">BufferDescriptors[]</code> array. Each <code class="language-plaintext highlighter-rouge">BufferDescriptor</code> has two fields representing the current usage state of its corresponding page:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">refcount</code>: how many backends are currently using (pinning) the page</li>
  <li><code class="language-plaintext highlighter-rouge">usage_count</code>: the accumulated number of accesses to the page (capped at 5, When accessed via a ring buffer strategy, it is only incremented if it is currently 0, limiting it to 1)</li>
</ul>

<p>Whenever a backend accesses a page via the hash table, it increments both <code class="language-plaintext highlighter-rouge">refcount</code> and <code class="language-plaintext highlighter-rouge">usage_count</code>. When the backend is done with the page, it only decrements <code class="language-plaintext highlighter-rouge">refcount</code>. Therefore, <code class="language-plaintext highlighter-rouge">usage_count</code> serves as an approximate LRU weight (but not unbounded, it stops increasing once it reaches 5).</p>

<p>When a backend tries to load a page from disk but finds no free page, it starts a <strong>clock sweep</strong>: it traverses <code class="language-plaintext highlighter-rouge">BufferDescriptors</code> circularly. If a buffer is not currently used by any backend (<code class="language-plaintext highlighter-rouge">refcount == 0</code>), it decrements <code class="language-plaintext highlighter-rouge">usage_count</code> (cooling down the LRU weight) and continues sweeping. Eventually it finds a buffer where both <code class="language-plaintext highlighter-rouge">refcount == 0</code> and <code class="language-plaintext highlighter-rouge">usage_count == 0</code>, and that buffer becomes the victim for eviction.</p>

<p>Of course, this alone is still insufficient to prevent LRU pollution from one-time full scans. PostgreSQL has its own optimization: introducing a <strong>local ring buffer</strong>.</p>

<p>Each backend has its own local ring buffer: essentially a fixed-length array of buffer IDs. A buffer ID points to a page slot in the global <code class="language-plaintext highlighter-rouge">BufferDescriptors</code>. The ring buffer limits how many global buffers the backend consumes at once, so eviction is more likely to happen within the ring buffer itself, reducing pollution of the global shared buffers.</p>

<p>More concretely, suppose a backend is performing a sequential scan and the upper layer marks the operation to use the ring buffer. When reading pages via the hash table:</p>

<ul>
  <li>If the backend’s local ring buffer is not full, it stores the buffer ID into the ring buffer.</li>
  <li>As reading continues, the ring buffer becomes full.</li>
  <li>After it is full, when it needs to read the next page:
    <ul>
      <li>It checks the page at the ring buffer’s current cursor position.</li>
      <li>If that buffer is not used by other backends (<code class="language-plaintext highlighter-rouge">refcount == 0</code> and <code class="language-plaintext highlighter-rouge">usage_count &lt;= 1</code>), it reuses it directly: evict and load the next page into it.</li>
      <li>If that buffer is currently used by other backends, it falls back to searching in <code class="language-plaintext highlighter-rouge">BufferDescriptors</code> for another available buffer to load the next page, and then replaces the current ring entry with the new buffer ID.</li>
    </ul>
  </li>
</ul>

<p>Here you can see the different approaches MySQL and PostgreSQL take for the same scenario. MySQL introduces an “old/young” split in the global LRU list as a general strategy to prevent pollution. PostgreSQL’s ring buffer is essentially also an “old area”, but it relies on higher-level operation tagging: only scan-heavy operations such as VACUUM, sequential scan, bulk insert, etc., will use the ring buffer.</p>

<p>Below is the complete procedure PostgreSQL uses to find a free buffer when a backend needs one:</p>

<ol>
  <li>
    <p>Determine whether to use the ring buffer. If yes, inspect the buffer at the ring’s current cursor position:</p>

    <p>a. If it has not been used before, the ring is not full yet, go to step 2.</p>

    <p>b. Otherwise the ring is full. If the buffer is not used by any backend (<code class="language-plaintext highlighter-rouge">refcount == 0</code> and <code class="language-plaintext highlighter-rouge">usage_count &lt;= 1</code>), it can be reused immediately, return this buffer.</p>

    <p>c. If the buffer is used by other backends, fall back to step 2 to find a buffer from the global pool; after success, replace the current ring entry with the newly found buffer ID.</p>
  </li>
  <li>
    <p>Check the free list. If a buffer is available, return it.</p>
  </li>
  <li>
    <p>Start clock sweep: traverse from <code class="language-plaintext highlighter-rouge">nextVictimBuffer</code> (the current sweep cursor in <code class="language-plaintext highlighter-rouge">BufferDescriptors</code>):</p>

    <ul>
      <li>If <code class="language-plaintext highlighter-rouge">refcount != 0</code>, skip.</li>
      <li>Otherwise, if <code class="language-plaintext highlighter-rouge">usage_count != 0</code>, decrement it (cooling down) and continue.</li>
      <li>Otherwise, the buffer is evictable. If it is not dirty, return it immediately. If it is dirty, flush it and then return it.</li>
      <li>Advance <code class="language-plaintext highlighter-rouge">nextVictimBuffer</code> accordingly.</li>
    </ul>
  </li>
</ol>

<p><strong>Summary:</strong> MySQL and PostgreSQL are similar in essence: both are LRU-like. MySQL chooses to implement an explicit LRU list for more precise eviction, at the cost of additional overhead to maintain the list. PostgreSQL uses reference counting plus <code class="language-plaintext highlighter-rouge">usage_count</code> as an approximate LRU, avoiding the locking overhead of maintaining a true LRU list but losing precision. This is the result of different trade-offs. Another notable difference: when a MySQL foreground thread tries to find a free page, it tends to prefer evicting old pages that are not dirty first; PostgreSQL’s sweep does not have an explicit priority between dirty and clean pages in the same sense.</p>

<h2 id="3-dirty-page-flushing-strategy-and-implementation">3. Dirty Page Flushing Strategy and Implementation</h2>

<p>Earlier we mentioned that MySQL user threads and PostgreSQL backends may flush a single dirty page when searching for a free page (single-page flush). However, such foreground single-page flushing is only an emergency measure when no free page is available.</p>

<p>For normal bulk flushing, both MySQL and PostgreSQL have dedicated background threads/processes. The goal is to flush dirty pages in advance and evict old pages so that foreground threads can quickly find free pages.</p>

<p>Background flushing has two goals:</p>

<ol>
  <li><strong>LRU flush</strong>: flush old pages in advance based on foreground free-page pressure, reducing foreground wait time for free pages.</li>
  <li><strong>Checkpoint flush</strong>: flush dirty pages associated with the oldest WAL LSN to advance the checkpoint, purge old WAL, and reduce crash recovery time.</li>
</ol>

<h3 id="mysql-2">MySQL</h3>

<p><img src="/public/images/2026-02-16/7.png" alt="image-1" /></p>

<p><img src="/public/images/2026-02-16/8.png" alt="image-1" /></p>

<p>In MySQL (InnoDB), background flushing is performed by <strong>page cleaner</strong> threads, consisting of one coordinator and N workers.</p>

<h4 id="coordinator">Coordinator</h4>

<ol>
  <li>Sleep for ~1 second, or be woken by a foreground thread.</li>
  <li>Check whether work is needed (sync flush / adaptive / idle). If yes:</li>
  <li>Dynamically calculate the number of dirty pages to flush in the next batch: <code class="language-plaintext highlighter-rouge">n_pages</code>.</li>
  <li>Pass <code class="language-plaintext highlighter-rouge">n_pages</code> to all workers and wake them up. Each worker is responsible for one <code class="language-plaintext highlighter-rouge">buf_pool_t</code> slot. The coordinator itself also works as worker 0.</li>
  <li>Wait for all workers to finish.</li>
</ol>

<h4 id="worker">Worker</h4>

<ol>
  <li>Wait to be woken by the coordinator.</li>
  <li>Locate the assigned <code class="language-plaintext highlighter-rouge">buf_pool_t</code> slot.</li>
  <li><strong>LRU flush</strong>: scan from the LRU tail forward, scanning at most <code class="language-plaintext highlighter-rouge">srv_LRU_scan_depth</code> pages.
    <ul>
      <li>If a page is clean and not being used, move it directly from the LRU list to the free list.</li>
      <li>If a page can be flushed, initiate asynchronous I/O; after I/O completes, move it into the free list.</li>
      <li>Stop early if the free list length reaches <code class="language-plaintext highlighter-rouge">srv_LRU_scan_depth</code>.</li>
    </ul>
  </li>
  <li><strong>Checkpoint flush</strong>: scan from the flush list tail forward and flush continuously until:
    <ul>
      <li>the number of flushed pages satisfies the quota assigned by the coordinator, or</li>
      <li>the WAL LSN advances to the target LSN assigned by the coordinator.</li>
    </ul>
  </li>
  <li>Finish and report to the coordinator.</li>
</ol>

<p>Now, step 3 in the coordinator is adaptive: it calculates the flush workload and the target LSN advancement. The logic is as follows:</p>

<h4 id="a-based-on-dirty-page-percentage-get_pct_for_dirty">a. Based on dirty page percentage (<code class="language-plaintext highlighter-rouge">get_pct_for_dirty()</code>)</h4>

<p>Compute <code class="language-plaintext highlighter-rouge">dirty_pct</code>, the percentage of dirty pages in the buffer pool:</p>

<ul>
  <li>If <code class="language-plaintext highlighter-rouge">innodb_max_dirty_pages_pct_lwm</code> (low watermark) is set and <code class="language-plaintext highlighter-rouge">dirty_pct &gt;= lwm</code>, start progressive flushing and return the percentage of <code class="language-plaintext highlighter-rouge">io_capacity</code> as:
<code class="language-plaintext highlighter-rouge">dirty_pct * 100 / (max_dirty_pages_pct + 1)</code></li>
  <li>If no low watermark is set, but <code class="language-plaintext highlighter-rouge">dirty_pct &gt;= innodb_max_dirty_pages_pct</code> (high watermark), flush at 100% <code class="language-plaintext highlighter-rouge">io_capacity</code>.</li>
  <li>Otherwise, do not flush based on dirty ratio (return 0).</li>
</ul>

<h4 id="b-based-on-redo-log-age-get_pct_for_lsnage">b. Based on redo log age (<code class="language-plaintext highlighter-rouge">get_pct_for_lsn(age)</code>)</h4>

<p>Compute checkpoint age:
 <code class="language-plaintext highlighter-rouge">age = current_lsn - oldest_lsn</code></p>

<ul>
  <li>If <code class="language-plaintext highlighter-rouge">age &lt; innodb_adaptive_flushing_lwm</code> (default 10% of redo log capacity), no adaptive flushing needed (return 0).</li>
  <li>If <code class="language-plaintext highlighter-rouge">age</code> exceeds the low watermark:
<code class="language-plaintext highlighter-rouge">age_factor = age * 100 / limit_for_dirty_page_age</code>
Return the percentage of <code class="language-plaintext highlighter-rouge">io_capacity</code> as:
<code class="language-plaintext highlighter-rouge">(max_io_capacity / io_capacity) * age_factor * sqrt(age_factor) / 7.5</code></li>
</ul>

<p>This is a super-linear growth curve: as redo space approaches exhaustion, flushing ramps up aggressively.</p>

<h4 id="combined-calculation-set_flush_target_by_lsn">Combined calculation (<code class="language-plaintext highlighter-rouge">set_flush_target_by_lsn()</code>)</h4>

<p>Take:
 <code class="language-plaintext highlighter-rouge">pct_total = max(pct_for_dirty, pct_for_lsn)</code></p>

<p>Then compute the target LSN:
 <code class="language-plaintext highlighter-rouge">target_lsn = oldest_lsn + lsn_avg_rate * 3</code>
 (i.e., advance by 3× the recent average redo generation rate; <code class="language-plaintext highlighter-rouge">buf_flush_lsn_scan_factor = 3</code>)</p>

<p>Then traverse each buffer pool instance’s flush list and count the number of pages whose <code class="language-plaintext highlighter-rouge">oldest_modification &lt;= target_lsn</code>. Call this number <code class="language-plaintext highlighter-rouge">pages_for_lsn</code> (pages that must be flushed to advance checkpoint to <code class="language-plaintext highlighter-rouge">target_lsn</code>).</p>

<p>Finally, take the average of three estimates:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>n_pages = (PCT_IO(pct_total) + page_avg_rate + pages_for_lsn) / 3
</code></pre></div></div>

<p>Where:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">PCT_IO(pct_total)</code> is the I/O demand estimated from dirty ratio / redo age.</li>
  <li><code class="language-plaintext highlighter-rouge">page_avg_rate</code> is the recent actual average flushing rate (moving average across multiple iterations).</li>
  <li><code class="language-plaintext highlighter-rouge">pages_for_lsn</code> is the precise demand obtained from scanning the flush list.</li>
</ul>

<p>Averaging these three makes the flushing rate smoother and avoids abrupt oscillation. <code class="language-plaintext highlighter-rouge">n_pages</code> is capped by <code class="language-plaintext highlighter-rouge">srv_max_io_capacity</code>.</p>

<p>If redo pressure is high (<code class="language-plaintext highlighter-rouge">pct_for_lsn &gt; 30</code>), the per-instance flush quota is weighted by how many pages in each instance’s flush list need flushing; otherwise, it is evenly distributed across instances.</p>

<h4 id="sync-flush-mode">Sync Flush mode</h4>

<p>When redo log space is extremely tight (checkpoint cannot keep up with redo generation), <code class="language-plaintext highlighter-rouge">log_sync_flush_lsn()</code> returns non-zero and the coordinator enters sync flush mode:</p>

<ul>
  <li>It no longer sleeps for 1 second; it starts the next iteration immediately.</li>
  <li><code class="language-plaintext highlighter-rouge">n_pages</code> is set directly to <code class="language-plaintext highlighter-rouge">pages_for_lsn</code> (no averaging), with a lower bound of <code class="language-plaintext highlighter-rouge">srv_io_capacity</code>.</li>
  <li>It loops until redo pressure is relieved.</li>
</ul>

<h4 id="idle-flushing">Idle flushing</h4>

<p>When the server is idle (no user activity) and the 1-second sleep times out, the coordinator does not run the adaptive algorithm. Instead, it flushes in the background using <code class="language-plaintext highlighter-rouge">innodb_idle_flush_pct</code> percent of <code class="language-plaintext highlighter-rouge">innodb_io_capacity</code> (default 100%), keeping the buffer pool clean.</p>

<h3 id="postgresql-2">PostgreSQL</h3>

<p>PostgreSQL also has both LRU flush and checkpoint flush, but unlike MySQL’s unified page cleaner, PostgreSQL separates responsibilities:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">bgwriter</code> handles <strong>LRU flush</strong></li>
  <li><code class="language-plaintext highlighter-rouge">checkpointer</code> handles <strong>checkpoint flush</strong></li>
</ul>

<h4 id="1-bgwriter">1. bgwriter</h4>

<p><img src="/public/images/2026-02-16/9.png" alt="image-1" /></p>

<p>The goal of <code class="language-plaintext highlighter-rouge">bgwriter</code> is to predict the upcoming demand for free buffers based on historical and current pressure, and try to free enough buffers <strong>before</strong> backends are forced into heavy clock sweep work (i.e., flush dirty pages that are otherwise reusable victims).</p>

<p>The overall flow:</p>

<ol>
  <li>
    <p>Collect historical info from clock sweep, including:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">strategy_buf_id</code>: the current backend clock sweep position</li>
      <li><code class="language-plaintext highlighter-rouge">strategy_passes</code>: how many full sweeps have been completed</li>
      <li><code class="language-plaintext highlighter-rouge">recent_alloc</code>: how many buffers have been allocated by backends since the last bgwriter recycle</li>
    </ul>
  </li>
  <li>
    <p>Compare <code class="language-plaintext highlighter-rouge">bgwriter</code>’s current position <code class="language-plaintext highlighter-rouge">next_to_clean</code> with clock sweep’s <code class="language-plaintext highlighter-rouge">strategy_buf_id</code>, and determine how far ahead it is:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">bufs_to_lap</code>: number of buffers bgwriter must scan for <code class="language-plaintext highlighter-rouge">next_to_clean</code> to “lap” (catch up to) <code class="language-plaintext highlighter-rouge">strategy_buf_id</code>.
        <ul>
          <li>Case 1: same pass, bgwriter ahead → <code class="language-plaintext highlighter-rouge">bufs_to_lap</code> is the remaining distance to lap.</li>
          <li>Case 2: same pass, bgwriter behind → set <code class="language-plaintext highlighter-rouge">next_to_clean</code> to <code class="language-plaintext highlighter-rouge">strategy_buf_id</code>, set <code class="language-plaintext highlighter-rouge">bufs_to_lap = NBuffers</code>, effectively reset bgwriter.</li>
          <li>Case 3: bgwriter already one full pass ahead → <code class="language-plaintext highlighter-rouge">bufs_to_lap</code> may be negative, meaning bgwriter has scanned everything it can scan; no need to scan in this round.</li>
        </ul>
      </li>
      <li><code class="language-plaintext highlighter-rouge">bufs_ahead = NBuffers - bufs_to_lap</code> (how many buffers bgwriter is ahead of sweep)</li>
    </ul>
  </li>
  <li>
    <p>Based on the history above, compute how many buffers clock sweep needs to scan to find one free buffer, i.e. <code class="language-plaintext highlighter-rouge">scans_per_alloc</code>. Maintain an exponential moving average:
<code class="language-plaintext highlighter-rouge">smoothed_density += (scans_per_alloc - smoothed_density) / 16;</code></p>
  </li>
  <li>
    <p>Maintain <code class="language-plaintext highlighter-rouge">smoothed_alloc</code> similarly:</p>

    <ul>
      <li>If <code class="language-plaintext highlighter-rouge">smoothed_alloc &lt; recent_alloc</code>, set <code class="language-plaintext highlighter-rouge">smoothed_alloc = recent_alloc</code> (fast attack).</li>
      <li>Otherwise decay slowly using EMA:
<code class="language-plaintext highlighter-rouge">smoothed_alloc += (recent_alloc - smoothed_alloc) / 16;</code> (slow decay)</li>
    </ul>
  </li>
  <li>
    <p>Compute the prediction for the next round:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">upcoming_alloc_est = smoothed_alloc * bgwriter_lru_multiplier</code> (predict upcoming allocations)</li>
      <li>Estimate how many reusable buffers exist in the region bgwriter is ahead:
<code class="language-plaintext highlighter-rouge">reusable_buffers_est = bufs_ahead / smoothed_density</code></li>
      <li>Ensure minimum progress:
<code class="language-plaintext highlighter-rouge">min_scan_buffers = NBuffers / (120s / 200ms)</code>
Then:
<code class="language-plaintext highlighter-rouge">upcoming_alloc_est = max(upcoming_alloc_est, min_scan_buffers + reusable_buffers_est)</code></li>
    </ul>

    <p>This “minimum progress” ensures that even if the system is idle, bgwriter will scan the entire buffer pool in about 120 seconds, continuously cleaning dirty pages.</p>
  </li>
  <li>
    <p>Scan from <code class="language-plaintext highlighter-rouge">next_to_clean</code>. For each buffer, bgwriter only considers buffers with <code class="language-plaintext highlighter-rouge">refcount == 0</code> and <code class="language-plaintext highlighter-rouge">usage_count == 0</code> (truly reusable candidates). It skips buffers in use or recently used. If a candidate is dirty, it flushes it synchronously. Stop scanning when any of these is met:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">bufs_to_lap</code> reaches 0 (caught up to clock sweep)</li>
      <li><code class="language-plaintext highlighter-rouge">reusable_buffers</code> reaches <code class="language-plaintext highlighter-rouge">upcoming_alloc_est</code> (freed enough reusable buffers)</li>
      <li><code class="language-plaintext highlighter-rouge">num_written</code> reaches <code class="language-plaintext highlighter-rouge">bgwriter_lru_maxpages</code> (default 100) to avoid excessive I/O in one round</li>
    </ul>
  </li>
</ol>

<p>After one scan round, bgwriter sleeps for <code class="language-plaintext highlighter-rouge">bgwriter_delay</code> (default 200ms) before next iteration. If <code class="language-plaintext highlighter-rouge">bufs_to_lap == 0</code> and <code class="language-plaintext highlighter-rouge">recent_alloc == 0</code> (no allocation activity), bgwriter enters hibernation and sleeps longer, until a backend needing buffers wakes it via latch.</p>

<h4 id="2-checkpointer">2. checkpointer</h4>

<p><img src="/public/images/2026-02-16/10.png" alt="image-1" /></p>

<p>The goal of <code class="language-plaintext highlighter-rouge">checkpointer</code> is to flush all dirty pages up to a consistency point, forming a checkpoint. This advances WAL recycling and reduces how much WAL must be replayed during crash recovery. Unlike bgwriter, checkpointer does not care whether a page was recently used, it must flush all pages that were dirty at checkpoint start.</p>

<p><strong>Trigger conditions:</strong> in the main loop, checkpointer triggers a checkpoint when any of the following occurs:</p>

<ul>
  <li>Time since last checkpoint exceeds <code class="language-plaintext highlighter-rouge">checkpoint_timeout</code> (default 5 minutes)</li>
  <li>WAL volume exceeds <code class="language-plaintext highlighter-rouge">max_wal_size</code> and backends notify checkpointer</li>
  <li>User manually runs <code class="language-plaintext highlighter-rouge">CHECKPOINT</code></li>
  <li>Shutdown checkpoint during server shutdown</li>
</ul>

<p><strong>Detailed procedure:</strong></p>

<ol>
  <li>
    <p><strong>Scan and collect dirty buffers:</strong> traverse all <code class="language-plaintext highlighter-rouge">NBuffers</code> <code class="language-plaintext highlighter-rouge">BufferDescriptors</code>. For each dirty page, set the <code class="language-plaintext highlighter-rouge">BM_CHECKPOINT_NEEDED</code> flag, and collect its identity info into <code class="language-plaintext highlighter-rouge">CkptBufferIds[]</code> (tablespace OID, relation number, fork number, block number, etc.).
Note: only pages that are already dirty at checkpoint start are included. Pages that become dirty during the checkpoint are not included and will be handled in the next checkpoint.</p>
  </li>
  <li>
    <p><strong>Sort:</strong> sort <code class="language-plaintext highlighter-rouge">CkptBufferIds[]</code> by <code class="language-plaintext highlighter-rouge">(tablespace, relation, fork, block)</code>. This clusters pages from the same file and orders them by increasing block number, converting random I/O into more sequential patterns as much as possible.</p>
  </li>
  <li>
    <p><strong>Build tablespace-level progress tracking:</strong> traverse the sorted array and group by tablespace. For each tablespace, build a <code class="language-plaintext highlighter-rouge">CkptTsStatus</code> structure tracking total pages to flush and current progress. Put all tablespaces into a binary heap (min-heap), ordered by flush progress.</p>
  </li>
  <li>
    <p><strong>Balanced flushing across tablespaces:</strong> repeatedly pop the tablespace with the lowest progress from the heap, flush its next dirty page (via <code class="language-plaintext highlighter-rouge">SyncOneBuffer</code>), update its progress, then re-heapify.
The purpose is to spread writes evenly across tablespaces (possibly on different disks), instead of flushing one tablespace completely before another.
Unlike bgwriter, checkpointer calls <code class="language-plaintext highlighter-rouge">SyncOneBuffer</code> with <code class="language-plaintext highlighter-rouge">skip_recently_used = false</code>, meaning it will flush buffers with <code class="language-plaintext highlighter-rouge">BM_CHECKPOINT_NEEDED</code> regardless of recent usage.</p>
  </li>
  <li>
    <p><strong>Write throttling:</strong> after flushing each page, call <code class="language-plaintext highlighter-rouge">CheckpointWriteDelay()</code> to throttle. The goal is to finish flushing within:
<code class="language-plaintext highlighter-rouge">checkpoint_completion_target</code> (default 0.9) × <code class="language-plaintext highlighter-rouge">checkpoint_timeout</code>.
The logic compares:</p>

    <ul>
      <li>flush progress (flushed pages / total),</li>
      <li>elapsed time progress,</li>
      <li>WAL progress.</li>
      <li>If flush progress is ahead of both time progress and WAL progress (<code class="language-plaintext highlighter-rouge">IsCheckpointOnSchedule == true</code>), sleep 100ms.</li>
      <li>If lagging behind, do not sleep and flush at full speed.</li>
      <li>In IMMEDIATE mode (e.g., shutdown checkpoint) or under urgent checkpoint requests, do not throttle.</li>
    </ul>

    <p>This spreads checkpoint I/O across the entire checkpoint window and avoids I/O spikes.</p>
  </li>
  <li>
    <p><strong>Writeback coalescing:</strong> if not using <code class="language-plaintext highlighter-rouge">O_DIRECT</code>, similar to bgwriter, use <code class="language-plaintext highlighter-rouge">WritebackContext</code> to collect tags for flushed pages. After accumulating enough, batch-call <code class="language-plaintext highlighter-rouge">IssuePendingWritebacks()</code>, sort and coalesce adjacent blocks, and use <code class="language-plaintext highlighter-rouge">posix_fadvise</code> to hint the kernel to write back OS cache pages to disk. After checkpoint completion, force one more <code class="language-plaintext highlighter-rouge">IssuePendingWritebacks()</code> to ensure all pending writebacks are issued.</p>
  </li>
</ol>

<p><strong>Summary:</strong> Although the implementations differ significantly, both MySQL and PostgreSQL aim to pre-clean pages in the background so that foreground threads can quickly find free pages. PostgreSQL’s bgwriter predicts upcoming buffer allocation demand from foreground activity; MySQL’s page cleaner reacts to dirty page pressure and redo log age.</p>

<p>From an engineering perspective, their differences largely come down to the trade-off between linked lists and arrays:</p>

<ul>
  <li>With linked lists, MySQL can precisely obtain LRU ordering and dirty-page ordering from old to new. This greatly improves precision in eviction and flushing decisions. In particular, for checkpoint flushing, it can directly take the oldest dirty pages from the flush list tail to advance checkpoint quickly. The trade-off is the cost of maintaining those lists.</li>
  <li>PostgreSQL sacrifices some precision and scans arrays instead, avoiding the additional overhead of maintaining linked lists. It is also worth noting that PostgreSQL’s checkpoint flushing emphasizes balanced progress across tablespaces rather than globally prioritizing the oldest dirty pages to advance checkpoint in small steps.</li>
</ul>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[The debate over “MySQL vs PostgreSQL, which one is better?” has been around for a long time. As two outstanding representatives of open-source OLTP databases, I personally don’t think one overwhelmingly dominates the other. Transactional database theory has been stable for decades; both systems are practical implementations built under the same theoretical framework.]]></summary>
  </entry>
  <entry>
    <title type="html">Visualizing MySQL BLOB Internals Directly from MySQL Data Files (.ibd)</title>
    <link href="https://kernelmaker.github.io/blob-ibdninja" rel="alternate" type="text/html" title="Visualizing MySQL BLOB Internals Directly from MySQL Data Files (.ibd)"/>
    <published>2026-02-08T00:00:00+00:00</published>
    <updated>2026-02-08T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/blob-ibdninja</id>
    <content type="html" xml:base="https://kernelmaker.github.io/blob-ibdninja"><![CDATA[<p>In a previous <a href="https://kernelmaker.github.io/mysql-blob">post</a>, I explored how MySQL implements partial updates and multi-versioning for BLOB columns internally.</p>

<p>To better see what actually happens inside the data files, I’ve added a new feature to <a href="https://github.com/KernelMaker/ibdNinja">ibdNinja</a>, an interactive BLOB inspection mode:</p>

<p><strong>--inspect-blob</strong></p>

<p>This feature is designed as a extension of ibdNinja’s existing inspection workflow, allowing you to drill down from high-level structures to the actual BLOB data stored on disk.</p>

<h3 id="how-it-works">How it works:</h3>

<h4 id="step-1">Step 1</h4>
<p>Use ibdNinja’s existing features to parse, extract, and print information from a MySQL <code class="language-plaintext highlighter-rouge">.ibd</code> file at the table, index, page, and record levels.
Once you’ve located a record you want to dive deeper into, note its page number and record number.</p>

<h4 id="step-2">Step 2</h4>
<p>Pass those identifiers to <code class="language-plaintext highlighter-rouge">--inspect-blob</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ibdNinja -f &lt;table.ibd&gt; --inspect-blob &lt;page_no&gt;,&lt;record_no&gt;
</code></pre></div></div>

<p>to start an interactive inspection of the BLOB field in that record.</p>

<p><img src="/public/images/2026-02-08/1.png" alt="image-1" /></p>

<p>As shown above, ibdNinja will:</p>

<ol>
  <li>Traverse the external BLOB page chain</li>
  <li>Reconstruct the version chain introduced by partial updates</li>
  <li>Visualize the complete on-disk layout of the BLOB across all versions</li>
</ol>

<p>From there, you can choose <strong>any version</strong> and:</p>

<ol>
  <li>Hex-print or dump the full value for binary BLOBs (images, raw binary data, etc.)</li>
  <li>Decode JSON BLOBs (MySQL JSON is still a BLOB internally) into readable text, or inspect the raw MySQL-encoded JSON in hex</li>
</ol>

<p>If some historical versions have already been purged, ibdNinja will detect that and clearly report it.</p>

<p>If you’re into MySQL data file internals, or knee-deep in development, debugging, or production issues, give ibdNinja a try, dig under the hood — and consider bug reports part of the feature set.</p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[In a previous post, I explored how MySQL implements partial updates and multi-versioning for BLOB columns internally.]]></summary>
  </entry>
  <entry>
    <title type="html">A POC on optimizing MySQL’s unique index insertion path</title>
    <link href="https://kernelmaker.github.io/unique-index-poc" rel="alternate" type="text/html" title="A POC on optimizing MySQL’s unique index insertion path"/>
    <published>2026-01-25T00:00:00+00:00</published>
    <updated>2026-01-25T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/unique-index-poc</id>
    <content type="html" xml:base="https://kernelmaker.github.io/unique-index-poc"><![CDATA[<p>A few months ago, I wrote a post about a possible optimization in MySQL’s unique index insertion path. As illustrated there, the idea is to reduce the current 3 B+Tree searches into 1 B+Tree search plus a scan on the leaf page (or leaf level), in order to avoid the overhead of repeatedly traversing the tree.
This weekend, I implemented a quick proof-of-concept on MySQL 8.0.45 and measure the effect.</p>

<p><img src="/public/images/2026-01-25/1.png" alt="image-1" /></p>

<h3 id="1-setup">1. Setup:</h3>

<p>Table with 200K rows, a VARCHAR(700) unique key (latin1), creating a tall B-tree:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CREATE TABLE t1 (
 id INT PRIMARY KEY AUTO_INCREMENT,
 uk_col VARCHAR(700) NOT NULL,
 UNIQUE KEY uk_idx (uk_col)
) ENGINE=InnoDB CHARACTER SET latin1;
</code></pre></div></div>

<h3 id="2-test-procedure">2. Test procedure:</h3>

<ul>
  <li>Insert 100 TARGET rows with prefix “TARGET_ROW_”</li>
  <li>Start a blocker transaction (START TRANSACTION WITH CONSISTENT SNAPSHOT) to prevent purge</li>
  <li>Delete the 100 TARGET rows (creates delete-marked records)</li>
  <li>Re-insert the same 100 TARGET rows, this triggers the duplicate-check path, since delete-marked records with the same unique key exist</li>
  <li>Instrument row_ins_sec_index_entry_low() with timing around each B-tree search.</li>
  <li>Run the benchmark twice: once with the original path, reset metrics, then with the optimized path</li>
</ul>

<h3 id="3-results">3. Results:</h3>
<h4 id="original-path-3-b-tree-searches">Original path (3 B-tree searches):</h4>
<ul>
  <li>Search1: ~6,508 ns</li>
  <li>Search2: ~5,649 ns</li>
  <li>Search3: ~2,498 ns</li>
  <li>Total: ~14,656 ns
    <h4 id="optimized-path-1-b-tree-search--inline-scan">Optimized path (1 B-tree search + inline scan):</h4>
  </li>
  <li>
    <p>Search1: ~7,272 ns</p>
  </li>
  <li>
    <p>Inline: ~3,118 ns</p>
  </li>
  <li>Total: ~10,390 ns</li>
</ul>

<p><strong>Improvement: ~29.1% reduction in search-path time</strong></p>

<p>This test focuses specifically on the unique index insertion path (row_ins_sec_index_entry_low()), comparing the cost of the original three searches with the optimized “one search + inline scan” approach. In this local scope, the saving is close to 30%, which matches the intuition of collapsing three tree traversals into one.</p>

<h3 id="4-however-when-evaluating-the-overall-benefit-there-are-a-few-important-considerations">4. However, when evaluating the overall benefit, there are a few important considerations:</h3>
<ul>
  <li>
    <p>In a single-row insert, how large is this part relative to the whole insert path? If its share is small, the end-to-end gain will be diluted. In my tests, when measuring the full insert path, the improvement drops to single-digit percentages.</p>
  </li>
  <li>
    <p>Under concurrent workloads, each of the three B-tree searches holds page latches. This is one of the key factors affecting scalability. Reducing this section by ~30% also shortens latch holding time, so the benefit may be more visible in parallel scenarios.</p>
  </li>
  <li>
    <p>While implementing the POC, I also realized that this optimization is not a silver bullet. There are cases that still need to fall back to the original path, although there are ways to minimize how often that happens.</p>
  </li>
</ul>

<p>These are just the numbers from a quick POC. If this direction turns out to be meaningful, it would still require much more careful design, implementation, and testing.</p>

<p><a href="https://bugs.mysql.com/bug.php?id=118363">Bug #118363</a></p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[A few months ago, I wrote a post about a possible optimization in MySQL’s unique index insertion path. As illustrated there, the idea is to reduce the current 3 B+Tree searches into 1 B+Tree search plus a scan on the leaf page (or leaf level), in order to avoid the overhead of repeatedly traversing the tree. This weekend, I implemented a quick proof-of-concept on MySQL 8.0.45 and measure the effect.]]></summary>
  </entry>
  <entry>
    <title type="html">MySQL BLOB Internals - Partial Update Implementation and Multi-Versioning</title>
    <link href="https://kernelmaker.github.io/mysql-blob" rel="alternate" type="text/html" title="MySQL BLOB Internals - Partial Update Implementation and Multi-Versioning"/>
    <published>2025-12-01T00:00:00+00:00</published>
    <updated>2025-12-01T00:00:00+00:00</updated>
    <id>https://kernelmaker.github.io/mysql-blob</id>
    <content type="html" xml:base="https://kernelmaker.github.io/mysql-blob"><![CDATA[<p>In this blog, I would like to introduce the implementation of BLOB and BLOB partial update in MySQL, and explain how the current design works together with the MVCC module to support multi-version control for BLOB columns.</p>

<h1 id="1-background">1. Background</h1>

<p>Before going into the details, I would like to briefly introduce two important concepts that are closely related to this topic.</p>

<h2 id="1-basic-principles-of-mysql-mvcc-multi-version-concurrency-control">1. Basic Principles of MySQL MVCC (Multi-Version Concurrency Control)</h2>

<p>MySQL supports snapshot reads. Each read transaction reads data based on a certain snapshot, so even if other write transactions modify the data during the execution of a read transaction, the read transaction will always see the version it is supposed to see.</p>

<p>The underlying mechanism is that a write transaction directly updates the data in place on the primary key record. However, before the update happens, the old value of the field to be modified is copied into the undo space. At the same time, there is a ROLL_PTR field in the row that points to the exact location in the undo space where the old value (the undo log record) is stored.</p>

<p><img src="/public/images/2025-12-01/1.png" alt="image-1" /></p>

<p>As shown in the figure above, there is a row in the primary key index that contains three fields. Suppose a write transaction is modifying Field 2. It will first copy the original value of Field 2 into the undo space, and then overwrite Field 2 directly in the row. After that, two important system fields of the row are updated:</p>

<ul>
  <li>TRX_ID is set to the ID of the current write transaction and is used later by read transactions to determine visibility.</li>
  <li>ROLL_PTR points to the exact location in the undo space where the old value of the modified field is stored, and is used to reconstruct the previous version of the row when needed.</li>
</ul>

<p>After the update is finished, if a previously existing read transaction reads this row again, it will find, based on the TRX_ID, that the row has been modified by a later write transaction. Therefore, the current version of the row is not visible to this read transaction. It must roll back to the previous version. At this point, it uses the ROLL_PTR to locate the old value in the undo space, applies it to the current row, and thus reconstructs the version that it is supposed to see.</p>

<h2 id="2-basic-implementation-of-mysql-blob">2. Basic Implementation of MySQL BLOB</h2>

<p>The primary key record in MySQL contains the values of all fields and is stored in the clustered index. However, BLOB columns are an exception. Since they are usually very large, MySQL stores their data in separate data pages called external pages.</p>

<p>A BLOB value is split into multiple parts and stored sequentially across multiple external pages. These pages are linked together in order, like a linked list. So how does the primary key record locate the corresponding BLOB data stored in those external pages? For each BLOB column, the clustered record stores a reference (lob::ref_t). This ref_t contains some metadata about the column and a pointer to the first external page where the BLOB data starts.</p>

<p><img src="/public/images/2025-12-01/2.png" alt="image-2" /></p>

<p>When reading the row, MySQL first locates the row via the primary key index, then follows this reference to find the external pages and reconstructs the full BLOB value by copying the data from those pages.</p>

<p>This is a very straightforward and intuitive design, simple and sufficient. It is also exactly how BLOB was implemented in older versions of MySQL.</p>

<h2 id="3-a-thought-exercise">3. A “Thought Exercise”</h2>

<p>Based on the two points above, here is a question:</p>

<p><strong>How is MVCC implemented for BLOB in MySQL?</strong></p>

<p>The intuitive answer is as follows: the lob::ref_t stored in the primary key record follows the same MVCC rules. Every time a BLOB column is updated, the old BLOB value is read out, modified, and then the entire modified BLOB is written into newly allocated external pages. The corresponding lob::ref_t in the primary key record is overwritten with the new reference. At the same time, following the MVCC mechanism, the old lob::ref_t is copied into the undo space.</p>

<p><img src="/public/images/2025-12-01/3.png" alt="image-3" /></p>

<p>After the modification, the situation looks like this (as shown in the figure): the undo space stores the lob::ref_t that points to the old BLOB value, while the lob::ref_t in the primary key record points to the new value.</p>

<p>This is exactly how older versions of MySQL worked. The next question is:</p>

<p><strong>What are the pros and cons of this design?</strong></p>

<p>The advantage is that the undo log only needs to record the lob::ref_t, and it does not need to store the entire old BLOB value.</p>

<p>The disadvantage is that no matter how small the change to the BLOB is, even if only a single byte is modified, the entire modified BLOB still has to be written into newly allocated external pages. BLOB columns are usually very large, so if each update only changes a very small portion, this design introduces a lot of extra I/O and space overhead.</p>

<p>A typical example is JSON. Internally, MySQL stores JSON as BLOB. Usually, updates to JSON are local and small. However, with the old design, each small partial update still requires reading the entire JSON, modifying a part of it, and then inserting the whole value back again. This is obviously very heavy.</p>

<p>So how to solve this problem? MySQL introduced BLOB partial update to address it.</p>

<h1 id="2-implementation-of-blob-partial-update">2. Implementation of BLOB Partial Update</h1>

<p>MySQL optimized the format of the external pages used to store BLOB data and redesigned the original simple linked-list structure:</p>

<p><img src="/public/images/2025-12-01/4.png" alt="image-4" /></p>

<ol>
  <li>Each external page now has a corresponding index entry.</li>
  <li>These index entries are organized as a linked list and stored in the <strong>BLOB first page</strong>. (If there are too many index entries to fit, they are stored in separate BLOB index pages.)</li>
  <li>Under normal circumstances, these index entries are linked together in order, just like the external pages in the old implementation.</li>
  <li>To support partial updates, MySQL changes the granularity of BLOB updates from the whole BLOB to individual external pages. Only the external pages involved in the current modification are updated. The modified external page is copied into a new page and updated there, while the other external pages remain unchanged.</li>
</ol>

<p>Then the question becomes: <strong>how can MySQL make sure that it can read the correct new and old BLOB values?</strong> The answer is that the new external page and the old external page share the same logical position in the index entry list. In other words, at this specific position in the list, there are now two versions, version 1 and version 2. Which one is used is determined by the version number recorded in the current lob::ref_t. The idea is illustrated in the figure below.</p>

<p><img src="/public/images/2025-12-01/5.png" alt="image-5" /></p>

<p>In summary, MySQL transforms the original external-page linked list into a linked list of index entries. For each index entry in this list, if the corresponding external page is modified, a new version of the index entry is created at the same horizontal position to point to the new version of that external page. Essentially, this introduces multi-versioning for external pages.</p>

<h2 id="special-case-blob-small-changes">Special Case: BLOB Small Changes</h2>

<p>The implementation described above is not the whole story. MySQL makes a practical trade-off between creating a new index entry (which requires copying the entire external page) and copying only the modified portion into the undo space.</p>

<p>For BLOB small-change scenarios, when the modification to a blob is smaller than 100 bytes, MySQL does not create a new index entry and link it into the version chain for that page. Instead, it modifies the page in place. Following MVCC principles, the portion to be modified is first written into the undo space before the in-place update happens.</p>

<p><img src="/public/images/2025-12-01/6.png" alt="image-6" /></p>

<p>It is worth noting that in this case, the lob::ref_t stored in the primary key record does not advance its base version number. It shares the same base as the previous version. When a read transaction needs to read the previous version, it first constructs the latest BLOB value based on the lob::ref_t and the index entry list. Then, following the MVCC logic, it finds that the TRX_ID indicates that this version is not visible. At this point, it follows the ROLL_PTR to the undo space, where the old value of the modified external page is stored. By applying that old data back onto the current value, the complete and correct historical BLOB value can be reconstructed.</p>

<p>In this scenario, the recovery process is a combination of two steps:</p>

<ol>
  <li>First, the version corresponding to the lob::ref_t is reconstructed via the index entry version chain.</li>
  <li>Then, the version visible to the current transaction is reconstructed via the ROLL_PTR chain.</li>
</ol>

<h2 id="index-entry-details">Index Entry Details</h2>

<p>Index entries are the key to the implementation of BLOB partial update. To make them easier to understand, I drew the following diagram to illustrate the logical relationships among index entries. It is a two-dimensional linked list. The horizontal dimension represents the sequential position when assembling the full BLOB value. The vertical dimension represents multiple versions at the same position. Each time the page at that position is modified, a new node is added vertically.</p>

<p><img src="/public/images/2025-12-01/7.png" alt="image-7" /></p>

<p>Of course, this is only a logical model. The physical layout is not organized exactly like this. Each BLOB has a BLOB first page. This page stores a portion of the BLOB data (the initial part) and 10 index entries. Each index entry corresponds to one BLOB data page. When all 10 index entries are used up, a new BLOB index page is allocated, and additional index entries are allocated from there. In reality, the index entries distributed across the BLOB first page and the BLOB index pages are linked together to form the logical structure shown in the diagram above.</p>

<p><img src="/public/images/2025-12-01/8.png" alt="image-8" /></p>]]></content>
    <author>
      <name>Zhao Song</name>
    </author>
    <summary type="html"><![CDATA[In this blog, I would like to introduce the implementation of BLOB and BLOB partial update in MySQL, and explain how the current design works together with the MVCC module to support multi-version control for BLOB columns.]]></summary>
  </entry>
</feed>
