<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://baotiao.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="http://baotiao.github.io/" rel="alternate" type="text/html" /><updated>2026-02-08T23:36:49+00:00</updated><id>http://baotiao.github.io/feed.xml</id><title type="html">做有积累的事情</title><subtitle>MySQL数据库内核研发相关</subtitle><author><name>baotiao</name></author><entry><title type="html">Claude Code 改写 PostgreSQL 内核, Full Page Write vs Doublewrite Buffer, 性能差 3 倍</title><link href="http://baotiao.github.io/2026/02/05/fpw-dwb.html" rel="alternate" type="text/html" title="Claude Code 改写 PostgreSQL 内核, Full Page Write vs Doublewrite Buffer, 性能差 3 倍" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>http://baotiao.github.io/2026/02/05/fpw-dwb</id><content type="html" xml:base="http://baotiao.github.io/2026/02/05/fpw-dwb.html"><![CDATA[<p>2026 年了, AI 能改数据库内核代码么? 我用 Claude Code 把 PostgreSQL 的 Full Page Write 换成了 MySQL 的 Doublewrite Buffer, 跑出来性能差了 3 倍.</p>

<h3 id="起因">起因</h3>

<p>FPW 和 DWB 到底哪个更合理, 这个问题我想了很久. 之前在 pgsql-hackers 的 mailing list 上发过<a href="https://www.postgresql.org/message-id/CAGbZs7hbJeUe7xY4QD25QW6VSnNFk1e3cwbCa8_R%2B2%2BYnoYRKw%40mail.gmail.com">一个帖子</a>讨论这事, 但没讨论出什么结果.</p>

<p>到了 2026 年, 正好想试试 Claude Code 能不能改内核代码, 顺便通过实际代码对比一下两种方案. Show me the code – 于是就有了这次实践.</p>

<h3 id="torn-page-问题">Torn Page 问题</h3>

<p>数据库以页为单位管理数据, PostgreSQL 默认 8KB, MySQL 默认 16KB. 但操作系统和磁盘的原子写入单元一般是 4KB.</p>

<p>这就意味着写一个完整的数据库页到磁盘, 需要多次物理 I/O. 如果写到一半断电了或者系统挂了, 页面就只写了一部分 – <strong>Torn Page</strong>, 新旧数据混在一起, 页面就坏了.</p>

<p>PostgreSQL 和 MySQL 用了完全不同的办法来解决这个问题.</p>

<h3 id="postgresql-full-page-write">PostgreSQL: Full Page Write</h3>

<p>每次 Checkpoint 之后, 某个数据页第一次被改的时候, PostgreSQL 把整个页的内容写到 WAL 里. 崩溃恢复时从 WAL 拿到完整页面覆盖掉坏的, 再回放后面的 WAL 记录就行了.</p>

<p>但 FPW 有个很要命的问题: <strong>它让 Checkpoint 频率变成了一个两难选择.</strong></p>

<p>FPW 希望少做 Checkpoint – 因为每次 Checkpoint 之后大量页面的首次修改都要写全页, WAL 体积暴涨, 写入性能断崖下跌. PostgreSQL 的 <code>checkpoint_timeout</code> 最低也得 30 秒, 当然了 checkpoint 还有可能是超过了 max_wal_size 的大小来触发.</p>

<p>但数据库理论又希望多做 Checkpoint – Checkpoint 越频繁, crash recovery 要回放的 WAL 越少, 恢复越快.</p>

<p>这俩需求是冲突的: <strong>FPW 要少做 Checkpoint, 快速恢复要多做 Checkpoint.</strong></p>

<h3 id="mysql-doublewrite-buffer">MySQL: Doublewrite Buffer</h3>

<p>InnoDB 的思路完全不一样. 刷脏页之前, 先把多个脏页顺序写到磁盘上一块专门的 Doublewrite Buffer 区域. 写满了再做一次 <code>fsync()</code>, 然后才把这些页面离散写到各自的数据文件位置.</p>

<p>crash 了怎么办? 重启时检查 Doublewrite Buffer 里有没有完整的页面副本, 有就拿来修复损坏的数据页. Torn Page 问题就不存在了.</p>

<h3 id="为什么我觉得-doublewrite-buffer-更合理">为什么我觉得 Doublewrite Buffer 更合理</h3>

<h4 id="前台-vs-后台">前台 vs 后台</h4>

<p>不考虑数据合并的话:</p>

<ul>
  <li>FPW = 1 次 WAL 写入 + 1 次数据页写入</li>
  <li>DWB = 2 次数据页写入</li>
</ul>

<p>都是 2 次 I/O, 但 WAL 写入是在前台路径上, 直接影响用户的 SQL latency; DWB 的写入在后台刷脏路径上, 用户基本感知不到.</p>

<h4 id="batch-优化空间不同">batch 优化空间不同</h4>

<p>DWB 不用每次写入都 <code>fsync()</code>, 写满一个 Buffer 才 sync 一次. WAL 写入虽然也能做 batch, 但毕竟是前台路径, 你不能让用户等太久, batch 空间有限.</p>

<h4 id="没有-checkpoint-频率的矛盾">没有 Checkpoint 频率的矛盾</h4>

<p>DWB 不依赖 Checkpoint 来防 Torn Page, 所以你可以随便提高 Checkpoint 频率来加速 crash recovery, 不会有写放大的副作用.</p>

<h3 id="性能测试">性能测试</h3>

<p>主要配置:</p>

<pre><code>shared_buffers=4GB
wal_buffers=64MB
synchronous_commit=on
maintenance_work_mem=2GB
checkpoint_timeout=30s
</code></pre>

<p>每个场景重建数据库, 跑之前做一次 <code>VACUUM FULL</code> 加 60 秒预热, 每个负载跑 300 秒.</p>

<p>场景: io-bound, –tables=10 –table_size=10000000</p>

<p>结果如下:</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image.png" alt="image" /></p>

<table>
  <thead>
    <tr>
      <th>场景</th>
      <th>并发</th>
      <th>FPW OFF (QPS)</th>
      <th>FPW ON (QPS)</th>
      <th>DWB ON (QPS)</th>
      <th>FPW OFF (TPS)</th>
      <th>FPW ON (TPS)</th>
      <th>DWB ON (TPS)</th>
      <th>FPW OFF (ms)</th>
      <th>FPW ON (ms)</th>
      <th>DWB ON (ms)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>read_write</td>
      <td>32</td>
      <td>360,764</td>
      <td>158,865</td>
      <td>260,171</td>
      <td>18,038</td>
      <td>7,943</td>
      <td>13,009</td>
      <td>1.77</td>
      <td>4.03</td>
      <td>2.46</td>
    </tr>
    <tr>
      <td>read_write</td>
      <td>64</td>
      <td>484,988</td>
      <td>190,654</td>
      <td>307,735</td>
      <td>24,249</td>
      <td>9,533</td>
      <td>15,387</td>
      <td>2.64</td>
      <td>6.71</td>
      <td>4.16</td>
    </tr>
    <tr>
      <td>read_write</td>
      <td>128</td>
      <td>556,021</td>
      <td>194,301</td>
      <td>301,791</td>
      <td>27,801</td>
      <td>9,715</td>
      <td>15,387</td>
      <td>4.60</td>
      <td>13.17</td>
      <td>9.81</td>
    </tr>
    <tr>
      <td>write_only</td>
      <td>32</td>
      <td>318,879</td>
      <td>108,696</td>
      <td>188,760</td>
      <td>53,146</td>
      <td>18,116</td>
      <td>31,460</td>
      <td>0.60</td>
      <td>1.77</td>
      <td>1.02</td>
    </tr>
    <tr>
      <td>write_only</td>
      <td>64</td>
      <td>345,766</td>
      <td>117,533</td>
      <td>197,251</td>
      <td>57,628</td>
      <td>19,589</td>
      <td>32,875</td>
      <td>1.11</td>
      <td>3.27</td>
      <td>1.95</td>
    </tr>
    <tr>
      <td>write_only</td>
      <td>128</td>
      <td>356,725</td>
      <td>89,144</td>
      <td>202,884</td>
      <td>59,454</td>
      <td>14,857</td>
      <td>33,814</td>
      <td>2.15</td>
      <td>8.61</td>
      <td>3.78</td>
    </tr>
  </tbody>
</table>

<p>结果很明显: 关掉 FPW 性能最好作为基线, 开了 FPW 之后性能掉到基线的 ~25%, 而 DWB 能保持 ~57%. write_only 128 并发的场景下 DWB 是 FPW 吞吐的 2.3 倍, 延迟也全面优于 FPW.</p>

<h3 id="代码">代码</h3>

<p>改好的代码在这: <a href="https://github.com/baotiao/postgres">https://github.com/baotiao/postgres</a></p>

<p>整个内核改动是用 Claude Code 完成的, 算是验证了一下 AI 改数据库内核代码这事确实能干.</p>

<p>BTW: 欢迎关注 <a href="https://github.com/alibaba/AliSQL">AliSQL</a> <strong>Alibaba’s Enterprise MySQL Branch with DuckDB OLAP &amp; Native Vector Search</strong></p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[2026 年了, AI 能改数据库内核代码么? 我用 Claude Code 把 PostgreSQL 的 Full Page Write 换成了 MySQL 的 Doublewrite Buffer, 跑出来性能差了 3 倍.]]></summary></entry><entry><title type="html">为什么我认为 MySQL 比 PostgreSQL 集成 DuckDB 更加的优雅?</title><link href="http://baotiao.github.io/2026/02/04/mysql-duckdb.html" rel="alternate" type="text/html" title="为什么我认为 MySQL 比 PostgreSQL 集成 DuckDB 更加的优雅?" /><published>2026-02-04T00:00:00+00:00</published><updated>2026-02-04T00:00:00+00:00</updated><id>http://baotiao.github.io/2026/02/04/mysql-duckdb</id><content type="html" xml:base="http://baotiao.github.io/2026/02/04/mysql-duckdb.html"><![CDATA[<p>我们看到目前有 3 个主流 PostgreSQL 集成 DuckDB 的方案在运行 pg_duckdb,pg_mooncake, pg_lake.</p>

<p>pg_duckdb 是官方提供的 DuckDB 插件, 只能提供存量行存表到 DuckDB 的迁移, 无法让增量的数据同步到 DuckDB 表中, 适用场景比较受限.</p>

<p>databricks 收购的 pg_mooncake 可以支持存量和增量的数据同步, 通过额外的 pg_moonlink 进程以逻辑复制的方式把 PostgreSQL 的数据复制过来, 并且以 Iceberg 格式写入, 后续如果有复制查询, 需要走 postgresql =&gt; pg_moonlink =&gt; s3(Iceberg) 的请求方式.</p>

<p>snowflake 收购的 pg_lake 同样也不支持增量数据同步, 只支持全量的数据导入导出, 感觉更多是一个归档场景的方案.</p>

<p>我们可以看到有几个问题, 一个是 PostgreSQL 逻辑复制能力不够成熟, 远不及其原生的物理复制能力, 无法通过逻辑复制连接PostgreSQL和DuckDB只读实例.
另外一个问题是 PostgreSQL 没有支持很好的可插拔的存储引擎能力, 虽然 PostgreSQL 提供了 table access method 作为存储引擎接口, 但并没有提供主备复制, Crash Recovery等能力, 很多场景下无法保证数据一致性.</p>

<p><strong>在 MySQL 里面很好的解决了这个问题</strong></p>

<p>首先 MySQL 天然就是一个可插拔的存储引擎设计, 在早期 MySQL 的默认引擎还是 MyIsam, 后面由于 InnoDB 对行级别 MVCC 的支持, MySQL 才把默认的引擎转换成了 InnoDB 引擎. 在 MySQL 里面原先也有InfoBright 这样的列存方案, 但是没有流行起来. 所以在 MySQL 里面支持列存 DuckDB, 增加一个列存引擎是一个非常顺其自然的事情. 不需要像 PostgreSQL 一样, 需要将写入数据先写入到行存再转换成列存这样的解决方案.</p>

<p>另外是 MySQL 的 binlog 机制, MySQL 的双 log 机制有缺点也有优点, binlog/redo log 的存在肯定会对写入性能造成影响, 但是 binlog 对 MySQL 生态的上下游提供了非常好的支持, binlog 提供了完整的 SQL 语句非常方便复制给下游, 这也是为什么 MySQL 生态的 OLAP 应用这么流行的原因, 像 Clickhouse, starrocks, selectdb 等等.</p>

<p>MySQL 使用 DuckDB 作为存储引擎场景里面, MySQL 的 binlog 生态是完全兼容, 没有被破坏的. 所以它可以作为一个数仓节点, 写入到这个数仓节点的数据依然可以把 binlog 流转出来. 在作为 HTAP 的场景里面, 主节点 MySQL innodb 引擎发送 binlog 到下游的 MySQL DuckDB 引擎, 从而实现完全兼容的流转.</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[我们看到目前有 3 个主流 PostgreSQL 集成 DuckDB 的方案在运行 pg_duckdb,pg_mooncake, pg_lake.]]></summary></entry><entry><title type="html">当 MySQL 遇到 DuckDB</title><link href="http://baotiao.github.io/2026/01/24/alisql-duckdb.html" rel="alternate" type="text/html" title="当 MySQL 遇到 DuckDB" /><published>2026-01-24T00:00:00+00:00</published><updated>2026-01-24T00:00:00+00:00</updated><id>http://baotiao.github.io/2026/01/24/alisql-duckdb</id><content type="html" xml:base="http://baotiao.github.io/2026/01/24/alisql-duckdb.html"><![CDATA[<p><strong>MySQL的插件式存储引擎架构</strong></p>

<p>MySQL的核心创新之一就是其插件式存储引擎架构（Pluggable Storage Engine Architecture），这种架构使得MySQL可以通过多种不同的存储引擎来扩展自己的能力，从而支持更多的业务场景。MySQL的插件式架构如下图所示：</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/0f4ea5d6-b3ff-45b8-bdeb-60f03b56fe1e.png" alt="img" /></p>

<p>MySQL的插件式存储引擎架构可以划分为四个主要的部分：</p>

<ul>
  <li>运行层(Runtime Layer)：负责MySQL运行相关的任务，比如通讯、访问控制、系统配置、监控等信息。</li>
  <li>Binlog层(Binlog Layer): 负责Binlog的生成、复制和应用。</li>
  <li>SQL层(SQL Layer)：复制SQL的解析、优化和SQL的执行。</li>
  <li>存储引擎层(Storage Engine Layer)：负责数据的存储和访问。</li>
</ul>

<p>MySQL在SQL计算和数据存储之间设计了一套标准的数据访问控制接口(Plugable Engine Interface)，SQL层通过这个标准的接口进行数据的更新、查询和管理，存储引擎得以作为独立组件实现“热插拔”式集成。</p>

<p>目前MySQL中常用的存储引擎包括：</p>

<ul>
  <li>MyISAM：MySQL最早使用的引擎，因为不支持事务已经被InnoDB取代。但是一直到MySQL-5.7还是系统表的存储引擎。</li>
  <li>InnoDB：MySQL的默认引擎。因期对事务的支持以及优秀的性能表现，逐步替代MyISAM成为MySQL最广泛使用的引擎。</li>
  <li>CSV： CSV文件引擎，MySQL慢日志和General Log的存储引擎。</li>
  <li>Memory：内存表存储引擎，也可作为SQL执行时内部临时表的存储引擎。</li>
  <li>TempTable：MySQL-8.0引入的引擎，用于存储内部临时表。</li>
</ul>

<p>InnoDB作为引擎引入到MySQL，是MySQL插件式引擎架构的一个非常重要的里程碑。在互联网发展的初期，MyISAM因其简单高效的访问赢得了互联网业务的青睐，和Linux、Apach、PHP一起被称为LAMP架构。随着电商、社交互联网的兴起，MyIASAM的短板越来越明显。InnoDB因其对事务ACID的支持、在并发访问和性能上的优势，大大的拓展了MySQL的能力。在InnoDB的加持下，MySQL成为最流行的开源OLTP数据库。</p>

<p>随着MySQL的广泛使用，我们看到有越来越多基于TP数据的分析型查询。InnoDB的架构是天然为OLTP设计，虽然在TP业务场景下能够有非常优秀的性能表现。但InnoDB在分析型业务场景下的查询效率非常的低。这大大的限制了MySQL的使用场景。时至今日，MySQL一直欠缺一个分析型查询引擎。DuckDB的出现让我们看到了一种可能性。</p>

<p><strong>DuckDB简介</strong></p>

<p>DuckDB 是一个开源的在线分析处理（OLAP）和数据分析工作负载而设计。因其轻量、高性能、零配置和易集成的特性，正在迅速成为数据科学、BI 工具和嵌入式分析场景中的热门选择。DuckDB主要有以下几个特点：</p>

<ul>
  <li>卓越的查询性能：单机DuckDB的性能不但远高于InnoDB，甚至比ClickHouse和SelectDB的性能更好。</li>
  <li>优秀的压缩比：DuckDB采用列式存储，根据类型自动选择合适的压缩算法，具有非常高的压缩率。</li>
  <li>嵌入式设计：DuckDB是一个嵌入式的数据库系统，天然的适合被集成到MySQL中。</li>
  <li>插件化设计：DuckDB采用了插件式的设计，非常方便进行第三方的开发和功能扩展。</li>
  <li>友好的License：DuckDB的License允许任何形式的使用DuckDB的源代码，包括商业行为。</li>
</ul>

<p>基于以上的几个原因，我们认为DuckDB非常适合成为MySQL的AP存储引擎。因此我们将DuckDB集成到了AliSQL中。</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/aaa0bdec-c810-4763-9423-0c34ad6c0683.png" alt="img" /></p>

<p>DuckDB引擎的定位是实现轻量级的单机分析能力，目前基于DuckDB引擎的RDS MySQL DuckDB只读实例已经上线，欢迎试用。未来我们还会上线主备高可用的RDS MySQL DuckDB主实例，用户可以通过DTS等工具将异构数据汇聚到RDS MySQL DuckDB实例，实现数据的分析查询。</p>

<p><strong>RDS MySQL DuckDB只读实例的架构</strong></p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/a5005f18-fb41-46c5-8d11-328b4182766f.png" alt="img" /></p>

<p>DuckDB分析只读实例，采用读写分离的架构。分析型业务和主库业务分离，互不影响。和普通只读实例一样，通过Binlog复制机制从主库复制数据。DuckDB分析只读节点有以下优势：</p>

<ul>
  <li>高性能分析查询：基于DuckDB的查询能力，分析型查询性能相比InnoDB提升高达200倍（详见性能部分）。</li>
  <li>存储成本低：基于DuckDB的高压缩率，DuckDB只读实例的存储空间通常只有主库存储空间的20%。</li>
  <li>100% 兼容MySQL语法，免去学习成本。DuckDB作为引擎集成到MySQL中，因此用户查询仍然使用MySQL语法，没有任何学习成本。</li>
  <li>无额外管理成本：DuckDB只读实例仍然是RDS MySQL实例，相比普通只读实例仅仅增加了一些MySQL参数。因此DuckDB和普通RDS MySQL实例一样管理、运维、监控。监控信息、慢日志、审计日志、RDS API等无任何差异。</li>
  <li>一键创建DuckDB只读实例，数据自动从InnoDB转成DuckDB，无额外操作。</li>
</ul>

<p><strong>DuckDB 引擎的实现</strong></p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/e36e4d36-9454-4aa6-b14d-9f172a21396c.png" alt="img" /></p>

<p>DuckDB只读实例使用上可以分为查询链路和Binlog复制链路。查询链路接受用户的查询请求，执行数据查询。Binlog复制链路连接到主实例进行Binlog复制。下面会分别从这两方面介绍其技术原理。</p>

<h4 id="查询链路"><strong>查询链路</strong></h4>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/ccb31673-c5cc-429d-b8bc-e432e50a7737.png" alt="img" /></p>

<p>查询执行流程如上图所示。InnoDB仅用来保存元数据和系统信息，如账号、配置等。所有的用户数据都存在DuckDB引擎中，InnoDB仅用来保存元数据和系统信息，如账号、配置等。</p>

<p>用户通过MySQL客户端连接到实例。查询到达后，MySQL首先进行解析和必要的处理。然后将SQL发送到DuckDB引擎执行。DuckDB执行完成后，将结果返回到Server层，server层将结果集转换成MySQL的结果集返回给客户。</p>

<p>查询链路最重要的工作就是兼容性的工作。DuckDB和MySQL的数据类型基本上是兼容的，但在语法和函数的支持上都和MySQL有比较大的差异，为此我们扩展了DuckDB的语法解析器，使其兼容MySQL特有的语法；重写了大量的DuckDB函数并新增了大量的MySQL函数，让常见的MySQL函数都可以准确运行。自动化兼容性测试平台大约17万SQL测试，显示兼容率达到99%。<strong>详细的兼容性情况见链接</strong></p>

<h4 id="binlog复制链路"><strong>Binlog复制链路</strong></h4>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/79d99d71-1e2b-419d-977a-94d10faea090.png" alt="img" /></p>

<h5 id="幂等回放"><strong>幂等回放</strong></h5>

<p>由于DuckDB不支持两阶段提交，因此无法利用两阶段提交来保证Binlog GTID和数据之间的一致性，也无法保证DDL操作中InnoDB的元数据和DuckDB的一致性。因此我们对事务提交的过程和Binlog的回放过程进行了改造，从而保证实例异常宕机重启后的数据一致性。</p>

<h5 id="dml回放优化"><strong>DML回放优化</strong></h5>

<p>由于DuckDB本身的实现上，有利于大事务的执行。频繁小事务的执行效率非常低，会导致严重的复制延迟。因此我们对Binlog回放做了优化，采用攒批(Batch)的方式进行事务重放。优化后可以达到30行/s的回放能力。在Sysbench压力测试中，能够做到没有复制延迟，比InnoDB的回放性能还高。</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/5eecdaf48460cde5333ce36033390d82e62316b1219b499a75b8339e1c4c24831b75b38faadcd24bec177c308ebd53044c64b8842e4dade1ebb361c4ca8091ce9ce5127fa0dac9102ad1472afea93edbef59c944da6a964b4fb4c8ed7016461c.png" alt="img" /></p>

<h5 id="并行copy-ddl"><strong>并行Copy DDL</strong></h5>

<p>MySQL中的一少部分DDL比如修改列顺序等，DuckDB不支持。为了保证复制的正常进行，我们实现了Copy DDL机制。DuckDB原生支持的DDL，采用Inplace/Instant的方式执行。当碰到DuckDB不支持的DDL时，会采用Copy DDL的方式创建一个新表替换原表。</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/2508803a-6a12-42c9-982f-3c3e6cbc9c83.png" alt="img" /></p>

<p>Copy DDL采用多线程并行执行，执行时间缩短7倍。</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/5ddc14f2-9b8a-4a00-a346-bace639009e5.png" alt="img" /></p>

<p><strong>DuckDB只读实例的性能</strong></p>

<h5 id="测试环境"><strong>测试环境</strong></h5>

<p>ECS 实例 32Cpu、128G内存、ESSD PL1云盘 500GB</p>

<h5 id="测试类型"><strong>测试类型</strong></h5>

<p>TPC-H  SF100</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/f844ff93-34d5-4971-89f7-684bea81a001.png" alt="img" /></p>

<p>线上购买 RDS MySQL 实例就可以直接体验:</p>

<p>https://help.aliyun.com/zh/rds/apsaradb-rds-for-mysql/duckdb-based-analytical-instance/</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[MySQL的插件式存储引擎架构]]></summary></entry><entry><title type="html">DuckDB 的 MVCC 设计与 HyPer 模型</title><link href="http://baotiao.github.io/2025/06/19/duckdb-mvcc.html" rel="alternate" type="text/html" title="DuckDB 的 MVCC 设计与 HyPer 模型" /><published>2025-06-19T00:00:00+00:00</published><updated>2025-06-19T00:00:00+00:00</updated><id>http://baotiao.github.io/2025/06/19/duckdb-mvcc</id><content type="html" xml:base="http://baotiao.github.io/2025/06/19/duckdb-mvcc.html"><![CDATA[<p>简单介绍一下 DuckDB MVCC 参考的 HyPer-style 设计</p>

<p>DuckDB 的 MVCC 实现参考 <a href="https://db.in.tum.de/~muehlbau/papers/mvcc.pdf">Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems</a> 实现
本文简单介绍 DuckDB 中这一套 MVCC 机制的设计思路, 以及与 InnoDB, Oracle 等数据库在可见性判断与版本号分配上的不同.</p>

<p>在这个 MVCC 实现里面, 有三个变量:</p>

<ul>
  <li>transactionID</li>
  <li>startTime-stamps</li>
  <li>commitTime-stamps</li>
</ul>

<p>事务启动时, 系统会同时分配 transactionID 与 startTime-stamps</p>

<ul>
  <li>
    <p>transactionID 是从 2^63 次方开始增长, 是一个非常大的值</p>
  </li>
  <li>
    <p>startTime-stamps 是从 0 开始递增</p>
  </li>
  <li>
    <p>commitTime-stamps 是在提交的时候才会赋值, 来着与 startTime-stamps 相同的递增计数器.</p>
  </li>
</ul>

<p>这个设计与 InnoDB 中的 trx_id / trx_no 关系类似:</p>

<p>transactionID 对应 InnoDB 的trx_id，startTime-stamp 与 commitTime-stamp 对应 trx_no.</p>

<p>区别在于:</p>

<p>DuckDB 在事务运行过程中对于每一个行的修改记录的 UndoBuffer 是 transactionID, 这个 transactionID 是一个非常大的值, 那么对于这个值就只有当前事务能够看到了. 在事务提交的时候, DuckDB 会将 UndoBuffer 中这些版本的时间戳从transactionID 更新为 commitTime-stamps.</p>

<p>在可见性判断的时候的时候, 由于 transactionID 肯定比startTime-stamps 大, 那么自然未提交的事务就不会被其他事务看到, 使得可见性判断非常简单.</p>

<p><strong>Version Access 可见性判断函数</strong></p>

<p>v.pred = null ∨ v.pred.TS = T ∨ v.pred.TS &lt; T.startTime</p>

<p>即对于事务 T:</p>

<ul>
  <li>
    <p>如果某行没有旧版本</p>
  </li>
  <li>
    <p>或该行的版本时间戳等于当前事务的 transactionID</p>
  </li>
  <li>
    <p>或该行版本的时间戳小于当前事务的 startTime-stamps</p>
  </li>
</ul>

<p>则该行对事务 <code>T</code> 可见, 否则就不可见</p>

<p>以下面的例子为例.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20250616024701582.png" alt="image-20250616024701582" /></p>

<p>原本所有人的余额 Bal(balance) = 10, 发起了 3 个事务.</p>

<table>
  <thead>
    <tr>
      <th>事务</th>
      <th>操作</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>trx1</td>
      <td>T3 时刻开启, Sally → Wendy 转 1 元</td>
      <td>已经提交, 在 recentlyCommitted 事务数组</td>
    </tr>
    <tr>
      <td>trx2</td>
      <td>T5 时刻开启, Sally → Henry 转 1 元</td>
      <td>已经提交, 在 recentlyCommtted 事务数组</td>
    </tr>
    <tr>
      <td>trx3</td>
      <td>T6 时刻开启, Sally → Mike 转 1 元</td>
      <td>未提交, 在 activeTransactions 数组</td>
    </tr>
    <tr>
      <td>trx4</td>
      <td>T4 时刻开启, 统计所有人的余额</td>
      <td><code>startTime = T4, transactionID = Tx</code></td>
    </tr>
    <tr>
      <td>trx5</td>
      <td>T7 时刻开启, 再次统计余额</td>
      <td><code>starTtime = T7, transactionID = Tz</code></td>
    </tr>
  </tbody>
</table>

<p>(注意: 这里 Undobuffer timestamp 判断的是前向的 value, 如: undo buffer of Ty 对应的是 7 是否可见, undo buffer of T5 对应的是 8 是否可见)</p>

<p>当trx4 在 T4 时间点进行读取, 由于它的 startTime = T4 &gt; T3, 那么是可以看到 T3 提交的内容, Undo buffer of T3 的前向 value 是 Sally =9, Wendy = 11. 其他事务还未开始, 直接读取最新版本即可.</p>

<p>重点是trx3, 由于 trx3 还未提交, Sally 指向的第一个undo buffer 记录sally -&gt; Mike 这个操作, 但是还在进行中, Mike 还没有执行 Mike + 1 操作. 由于事务还未提交, 这个 undo buffer 时间戳就是 Ty, Ty 是trx3 的transactionID, 是一个非常大的值.</p>

<p>比如事务 trx5, 虽然 startTime = T7, 比 trx3 的startTime T6要来的大, 但是根据下面的可见性判断可以看到, 由于trx5.transactionID != undo buffer of Ty, 并且trx5.startTime &lt; undo buffer of Ty, 那么 undo buffer of Ty 对应的值 7 就是对trx5 是不可见的. 而 Undo buffer of T5 &lt; T7, 那么 Ty, Bal,8 就是 trx5 可见的了.</p>

<p>DuckDB 判断事务可见性的时候, 并没有使用类似InnoDB/PostgreSQL 活跃事务数组, 而是直接通过 start_time 就可以判断了. 类似Oracle SCN 实现方式.</p>

<p>DuckDB 没有类似 InnoDB readview 如何解决可见性判断?</p>

<p>现有的InnoDB 里面trx_id, trx_no 就类似start_ts, end_ts.</p>

<p>对于某一行的可见性本质是判断这一行的内容在当前trx1 开始的时候有没有提交了.</p>

<p>在 InnoDB 里面就是判断当前事务是 trx_id 是否大于读取到的 row 的trx_no.</p>

<p>但是这里的问题是, 在行上面记录的是 trx_id, 而不是 trx_no.</p>

<p>那么为什么在行上面只记录了 trx_id, 而不把 trx_no 也记录下来呢?</p>

<p>因为如果这样做的话, 开销会非常大.</p>

<p>InnoDB 是支持steal and no force, 也就是事务commit 之前可能对应的 record 就已经落盘了, 因此 InnoDB 现在写record 的时候是不知道trx_no 的, 需要在commit() 执行 trx_write_serialisation_history() 获得trx_no 之后, 再重新写一次record 对应的trx_no 到record 才可以实现.</p>

<p>这样的话, commit 的时候如果修改了 1000 行数据, 那么就需要重新对1000 行数据的undo log 重新进行修改, 开销非常大.</p>

<p>因为从行上只读读取到 trx_id, 所以 InnoDB 里面判断事务的可见性并没有使用 trx_no, 而是使用事务开始的事务号 trx_id. 那么通过 trx_id 来进行判断的话, 就需要结合活跃事务数组 readview 来一起进行判断可见性了.</p>

<p>所以其实如果行上面记录的是trx_no, 那么就不需要 readview, 直接拿来比较就可以了.</p>

<p>这里怎么判断呢?</p>

<p>还是一开始的需求, 当前读取到的行是否在 trx1 开始前就已经提交了, 那么通过 readview 可以获得 trx1 开启的时候有哪些事务还在运行中, 如果还在那么肯定说明还未提交. 另外就是比活跃事务数组最大的 trx_id 的事务也一定未提交, 因为事务启动的时候 copy readview 都没有, 说明事务启动的时候对应的事务肯定还未提交.</p>

<p>那么 Oracle 怎么规避这个问题呢? 以及类似的解决方案都如何解决这个问题?</p>

<p>常见的优化思路就是, 写入的时候记录一个 trx_id =&gt; trx_no 的映射关系表id_no_map. 减少commit 的时候去给每一个record 写入trx_no 的开销, 记录在id_no_map table 上.</p>

<p>id_no_map 可以是纯内存的, 也可以是持久化的. 纯内存是因为事务重启以后, 老的trx_id 对于新事物都是可见的, 所以如果这个id 小于mysql 启动的时候事务 trx id, 那么该事务肯定是可见的.</p>

<p>如果该id 大于启动的时候事务trx id, 并且在id_no_map table 上找不到, 那么是未 commit 的, 否则就获得对应的trx_no.</p>

<p>那么一个事务trx1.trx_id 就可以与读取到的某一行直接进行判断. 如果 trx1.trx_id &gt; id_no_map[trx_id],  那么该行就对该事务可见, 如果trx1.trx_id &lt; id_no_map[trx_id] 那么改行就是trx1 启动以后才commit 的, 那么就不可见了.</p>

<p>当然这里需要考虑trx_id 不断增长以后, 老的trx_id =&gt; trx_no 映射关系就要清理掉, 否则就要占用内存空间了.</p>

<p>Oracle 里面把这个信息放在了每一个 Page 上面的ITL 槽(Interested Transaction List)</p>

<p>关键信息: 每个 ITL 槽通常包含:</p>

<ul>
  <li>事务 ID (XID):唯一标识一个事务.</li>
  <li>提交 SCN (Commit SCN):当事务提交时, 这个槽位会被更新为事务的提交 SCN. 如果事务未提交或回滚, 这个值通常是 NULL或一个特殊值(如 0x0000.00000000).</li>
</ul>

<p>那么读取都 Page 里面某一个行的时候, 读取到行的 XID 信息以后, 会根据这个 Page 上面的ITL 去把 XID mapping 到 SCN 上去. 因为ITL 是 Page 级别, 而不是 Record 级别, 所以可以将需要 1000 行的 record 的修改改成只需要 1 次 Page 的修改.</p>

<p>DuckDB 在实现上进一步简化, DuckDB 这里有两个优势, 实现起来非常简单.</p>

<ol>
  <li>DuckDB 的 undo log 都是在内存里面的, 不需要持久化, 所以不存在把 id_no_mapping 持久化的这个需求, 也就不需要有清理等等一系列操作了</li>
  <li>DuckDB 的 undo log 是 chunk(2048行) 级别, 而不是行级别, 也就是修改了 2048 行, 只需要改一个 version number 就可以, 不需要改 2048 个</li>
</ol>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[简单介绍一下 DuckDB MVCC 参考的 HyPer-style 设计]]></summary></entry><entry><title type="html">AWS re:Invent2024 Aurora 发布了啥 – DSQL 篇</title><link href="http://baotiao.github.io/2024/12/15/aurora-2024-1.html" rel="alternate" type="text/html" title="AWS re:Invent2024 Aurora 发布了啥 – DSQL 篇" /><published>2024-12-15T00:00:00+00:00</published><updated>2024-12-15T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/12/15/aurora-2024-1</id><content type="html" xml:base="http://baotiao.github.io/2024/12/15/aurora-2024-1.html"><![CDATA[<p>这个是前年AWS re:Invent 2022 的内容, 有兴趣可以看这个链接: <a href="http://baotiao.github.io/2022/12/12/aurora-2022.html">Aurora re:Invent 2022</a></p>

<p>这个是去年AWS re:Invent 2023 的内容, 有兴趣可以看这个链接: <a href="https://baotiao.github.io/2023/12/04/aurora-2023.html">Aurora re:Invent 2023</a></p>

<p>AWS reInvent 2024 刚刚结束, 笔者作为数据库从业人员主要关注的是AWS Aurora 今年做了哪些改动, 今年最大的可能就是 Aurora DSQL 的发布了.</p>

<p>因此这个文章主要介绍 Aurora DSQL 的实现, 以及笔者的一些看法.</p>

<p>下面的内容主要分成 3 部分:</p>

<ol>
  <li>
    <p>snapshot isolation + EC2 TimeSync service + OCC</p>
  </li>
  <li>
    <p>Cross Region cache coherence</p>
  </li>
  <li>
    <p>Serverless</p>
  </li>
</ol>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20241216042217470.png" alt="image-20241216042217470" /></p>

<p>在发布会上, Matt 介绍 DSQL 将多次 commit 合并成一次 commit, 从而实现了 90% 的性能提高, 那么 DSQL 是如何实现的呢?</p>

<p>主要通过 snapshot isolation + EC2 TimeSync service + OCC 实现.</p>

<p>具体做法原先一个事务中包含 10 条 SQL, 每一条 SQL 都需要和数据库交互, 需要对某一些行就先 row lock, 避免事务执行过程中被其他事务修改. 那么如果在跨 region 场景, 延迟可能到了 100ms 以上, 一个事务包含 10 条 SQL 那么就至少需要 1s 才能 commit,  那么自然很容易出现性能问题.</p>

<p>DSQL 的做法通过 snapshot isolation + EC2 TimySync service  获取 t(start) 的版本信息,  然后在提交的时候通过 OCC(optimistic concurrency control) 进行冲突检测, 如果没有冲突, 那么就直接进行 commit, 如果有冲突, 那么就需要业务层进行回滚+重试操作了.</p>

<p>因此这次只需要在 commit 的时候, 需要和数据库交互, 10 条 SQL 执行过程中, 都读取当前 AZ 的 snapshot 出来的版本就可以了, 这就是 Matt 讲的可以优化 90% 的实现方式.</p>

<p>但是真实的场景是这样的么?</p>

<p>其实 OCC 并没有想象的那么好, 其实很早就有讨论基于 OCC 的数据库的并发控制机制实现, “<em>On Optimistic Methods for Concurrency Control</em>” in 1979 by <strong>H.T. Kung and John T. Robinson</strong>  已经介绍了. 但是一直没有大规模被使用主要由于,</p>

<p>OCC适合于交互式或系统内部组件同步延时较大的场景, 之前大部分数据库都是一体化设计, 计算, 存储, 内存等等都在本地, 因此开销并不大, OCC 冲突导致事务中止浪费计算资源的开销远大于同步操作的开销, 所以没有大规模使用.</p>

<p>那么在跨 region 类似 DSQL 这样场景可以使用吗?</p>

<p>理论上在跨 region 场景 OCC 可以比之前一体化设计数据库有更多的收益, 而且工程实现会更加的简单.</p>

<p>但是可以理解这里把处理冲突的方式交给了用户,  比如目前 DSQL 的事务的大小是有限制的, 一个事务默认最多能够支持修改 10000 行, 事务最长时间为 5 分钟.</p>

<p>用户需要知道直接的业务场景是否有明显的冲突, 做过云数据库的都知道很多时候业务根本不知道这些 SQL 是谁写的了, 另外需要用户实现类似重试逻辑, 但是 Aurora DSQL 作为云厂商售卖的数据库, 用户已经习惯使用Pessimistic Concurrency Control (PCC) 的冲突检测方式, 需要针对 Aurora DSQL 去重新修改代码, 在现在多云的背景下, 用户又不希望被厂商 Lock in, 那么就更不可能了.</p>

<p>从技术角度可以看为什么 DSQL 选择了 OCC.</p>

<p>可以猜测的原因更多是从工程的角度去考虑, 更易于实现, 减少维护成本, 减少了全局 lock service.</p>

<p>因为如果选择 PCC 的话, 那么需要为了一个全局锁服务, 写入都需要去全局 lock service 去获得, 这么首先是开销, 另外是还需要额外维护全局锁服务. 而使用 OCC 则不需要, 就像在 Matt 介绍场景一样, 在写入的时候, 对于每一行写入的数据仅仅在 commit 的时候, 在行上去判断是否有冲突即可, 确实大量减少交互次数, 对于延迟高的跨 Region 场景收益会更大.</p>

<p>另外这里有一点没有提到, 就是如何处理多个 Region cache coherence 的问题?</p>

<p>下面这个图是 Aurora 和 Aurora DSQL 的对比.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20241209050254992.png" alt="image-20241209050254992" /></p>

<p>Aurora 里面 Log &amp; blocks  是放在一起存储的, Aurora DSQL 里面将 log store 和 block store 分开存储, 因为 log 写入和 page 写入其实是两个非常不一样的 IO 方式, log 写入主要是 append only, page 写入是随机 IO, 其实大部分 Aurora 后来的云数据库都实现了类似的方式, 比如 socrate, taurusDB 等等.</p>

<p>其实早年在 PolarDB 我们也考虑将 log/page 放在不同的存储里面, 这样的实现有诸多好处, 唯独增加了复杂度.</p>

<p>比如需要考虑 log 和 page 快照一致性问题, 需要考虑维护两个存储池子, log store 使用的机型应该更好, 而 page store 使用的机型可以差一些等等, 一旦需要分池, 那么云计算最大的池化的优势也就没有了. 所以后面也就没有这样实现.</p>

<p>除了这个差异, 其实这里两个架构最大的区别是, <strong>在 Aurora DSQL 里面取消了每一个实例上面的cache</strong></p>

<p>我们知道在一写多读架构下面, 多个节点之间 cache 一致性是一个比较大的问题.</p>

<p>比如在 rw 节点写入 a = 100 (old value = 99) 以后.</p>

<p>在 ro 节点去读取 a 的值, 这个时候有两种情况:</p>

<ol>
  <li>a 不在 ro 节点的 cache 里面, 那么就需要从底下的 storage 去读取 a 的值, 这个时候会根据 ro 节点 lsn 信息, 应用到指定版本的 lsn 从而获得 a 的 value.</li>
  <li>该 ro 节点的内存中已经有 old value a = 99, 那么这个时候需要判断当前 ro 节点的 apply_lsn 信息, 如果 apply_lsn 还没到 rw 节点写入(a = 100) 的位点信息, 那么此时可以直接返回, 如果 ro apply_lsn &gt; rw (a=100) lsn, 那么就需要从底下 storage log 读取对应的 redo log 信息, 应用到指定的 lsn 然后再返回给客户.</li>
</ol>

<p>从上面的例子可以看到, 在一写多读的架构下, 需要保证 cache 中的数据是一致的, 才可以避免 ro 节点读取到错误版本的数据, 从而导致读取出错.</p>

<p>那么其实在多节点写入的架构下面其实一样存在这样的问题, 而且这个问题会更加的严重, 因为同样要解决多个节点之间的 cache 一致性问题.</p>

<p>那么 DSQL 怎么解决?</p>

<p>非常的暴力, 直接取消了这个 cache, 也就是这个 buffer pool 不存在了, 所有的读取和写入都直接到 DSQL - block store 上.</p>

<p>那么带来的问题是, 这样 block store 的性能是否可以?</p>

<p>在一写多读下, 直接读取 cache 中的内容就可以返回, 那么在 DSQL 的架构下, 需要读取的都不是本地存储, 而是远端的 block store, 本地 内存读取的延迟差不多是百 ns 级别, 而远端存储访问, 即使是 RDMA 优化过后, 也需要 100us 左右. <strong>这样的延迟几乎大了 1 千倍, 这是几乎不可能接受的事情.</strong></p>

<p>为了解决 DSQL 没有 cache 的问题, DSQL 实现了很多的计算下推操作, RW 节点和 block store 请求的不再是 Page, 而是具体的某一行, 这样可能尽可能减少需要请求的 page, 提高性能.</p>

<p>但是大部分线上 OLTP 的场景, 很多时候返回的也都是某一行, 只需要一个 page 即可, 虽然返回某一行可以减少需要返回的内容, 但是在带宽足够的情况下, 这里收益并不明显. 另外这里其实也给 block store 增加了复杂度.</p>

<p>还有一个比较差别的点可能跟Marc Brooker 有关, Marc Brooker 在 AWS 做了 10 年的 Lambda, 所以对 serverless 有执念.</p>

<p>DSQL 在 serverless 上比 Aurora 更彻底, DSQL 的实例在有请求的时候, 通过 Firecracker 启动一个实例, 在执行完请求以后, 直接就将实例释放, 由于没有 cache 的存在, 并且使用的是 OCC 计算节点几乎没有保留任何有状态的信息, 那么在连接关闭以后, 这个节点就可以直接关掉了, 所在这里可以做到秒级别的 serverless.</p>

<p>而传统 Aurora 差不多需要的时间是 5min 级别.</p>

<p>对于在 Global database 场景, Aurora DSQL 使用了和跨 AZ 场景, 几乎一样的能力. 由于跨 region 场景, 延迟更高因此 OCC 带来的性能收益也更加明显.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20241216005352899.png" alt="image-20241216005352899" /></p>

<p>笔者观点:</p>

<p>从上面的原理介绍我们可以看到 Aurora DSQL 由于减少了 cache 层, 延迟会增加, 使用 OCC 那么用户使用的复杂度会增加.
因此笔者认为Aurora DSQL 的使用场景其实是有限, 需要对延迟不敏感, 业务上很少存在热点数据, 并且业务开发人员需要有较强的开发能力, 能够实现业务层的重试机制, 业务范围很大, 需要分布多个 region 这样的场景. <strong>否则大部分情况下 AWS RDS or Aurora 就够了.</strong></p>

<p><strong>Reference:</strong></p>

<p>1: <a href="https://www.youtube.com/watch?v=LY7m5LQliAo&amp;t=4611s">AWS re:Invent 2024 - CEO Keynote with Matt Garman</a></p>

<p>2: <a href="https://brooker.co.za/blog/2024/12/03/aurora-dsql.html">DSQL Vignette: Aurora DSQL, and A Personal Story</a></p>

<p>3: <a href="https://brooker.co.za/blog/2024/12/04/inside-dsql.html">DSQL Vignette: Reads and Compute</a></p>

<p>4: <a href="https://brooker.co.za/blog/2024/12/05/inside-dsql-writes.html">DSQL Vignette: Transactions and Durability</a></p>

<p>5: <a href="https://brooker.co.za/blog/2024/12/06/inside-dsql-cap.html">DSQL Vignette: Wait! Isn’t That Impossible?</a></p>

<p>6: <a href="https://www.youtube.com/watch?v=kVVdHezNTpw">AWS re:Invent 2024 - Deep dive into Amazon Aurora and its innovations (DAT405)</a></p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[这个是前年AWS re:Invent 2022 的内容, 有兴趣可以看这个链接: Aurora re:Invent 2022]]></summary></entry><entry><title type="html">MySQL B-tree Height Issues in Large Single Tables</title><link href="http://baotiao.github.io/2024/09/04/btree-height-en.html" rel="alternate" type="text/html" title="MySQL B-tree Height Issues in Large Single Tables" /><published>2024-09-04T00:00:00+00:00</published><updated>2024-09-04T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/09/04/btree-height-en</id><content type="html" xml:base="http://baotiao.github.io/2024/09/04/btree-height-en.html"><![CDATA[<p>Some older DBAs may remember that in the past, it was recommended that a MySQL table should not exceed 5 million rows. Many DBAs worry that as tables grow larger, the B-tree height will increase dramatically, thus affecting performance.</p>

<p>In reality, the B-tree is a very flat structure. Most B-trees do not exceed 4 levels. Let’s examine this with an example of a common sysbench table:</p>

<pre><code class="language-mysql">CREATE TABLE `sbtest1` (
  `id` int NOT NULL AUTO_INCREMENT,
  `k` int NOT NULL DEFAULT '0',
  `c` char(120) NOT NULL DEFAULT '',
  `pad` char(60) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `k_1` (`k`)
) ENGINE=InnoDB AUTO_INCREMENT=10958 DEFAULT CHARSET=latin1;
</code></pre>

<p>In InnoDB, there are two main types of pages: leaf pages and non-leaf pages.</p>

<p>The format of the leaf page is as follows: each record mainly consists of a <strong>Record Header</strong> and a <strong>Record Body</strong>. The Record Header is primarily used in conjunction with DD (data dictionary) information to support the Record Body. The Record Body contains the main content of the record.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831052840072.png" alt="Leaf Page Example" />
<img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831045732006.png" alt="Non-leaf Page Example" /></p>

<p>In a 16KB page of a sysbench-like table, the approximate number of rows that can be stored in a leaf page is calculated as:</p>

<p>(16 * 1024 - 200 (for the page header, tail, and directory slot length)) / ((4 + 4 + 120 + 60) (row data length) + 5 (row header) + 6 (Transaction ID) + 7 (Roll Pointer)) = 78.5 rows</p>

<p>The format of the non-leaf page is as follows:</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831050352214.png" alt="image-20240831050352214" /></p>

<p>Since the sysbench primary key id is an integer (4 bytes), the number of rows that can be stored in a 16KB page is calculated as:</p>

<p>(16 * 1024 - 200) / (5 (row header) + 4 (cluster key) + 4 (child page number)) = 1233 rows</p>

<p>The following table shows the height and size of a B-tree at different levels:</p>

<table>
  <thead>
    <tr>
      <th>Height</th>
      <th>Non-leaf Pages</th>
      <th>Leaf Pages</th>
      <th>Rows</th>
      <th>Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>79</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>1233</td>
      <td>97,407</td>
      <td>19MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1234</td>
      <td>1,520,289</td>
      <td>120,102,831</td>
      <td>23GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>1,521,523</td>
      <td>1,874,516,337</td>
      <td>148,086,790,623</td>
      <td>27.9TB</td>
    </tr>
  </tbody>
</table>

<p>From the above, we can see that for a sysbench-like table with 140 billion rows and a size of 27.9TB, the B-tree height does not exceed 4 levels. Therefore, you do not need to worry about performance issues caused by B-tree height, even with large datasets.</p>

<h3 id="impact-of-using-bigint-as-the-primary-key">Impact of Using BIGINT as the Primary Key</h3>

<p>If the primary key is changed to BIGINT (8 bytes), the number of rows per leaf page changes slightly:</p>

<pre><code>(16 * 1024 - 200) / ((8 + 4 + 120 + 60) + 13) = 78.9 rows
</code></pre>

<p>The number of rows in non-leaf pages changes as well:</p>

<pre><code>(16 * 1024 - 200) / (5 + 8 + 4) = 952 rows
</code></pre>

<table>
  <thead>
    <tr>
      <th>Height</th>
      <th>Non-leaf Pages</th>
      <th>Leaf Pages</th>
      <th>Rows</th>
      <th>Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>79</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>952</td>
      <td>75,208</td>
      <td>15MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>953</td>
      <td>906,304</td>
      <td>71,598,016</td>
      <td>13.8GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>907,257</td>
      <td>862,801,408</td>
      <td>68,161,311,232</td>
      <td>12.8TB</td>
    </tr>
  </tbody>
</table>

<p>After switching to BIGINT, a four-level B-tree can store 60 billion rows and about 12TB of data.</p>

<h3 id="example-of-a-more-complex-table-polarbench">Example of a More Complex Table (Polarbench)</h3>

<p>For more complex tables, such as those used in SaaS scenarios, we use the following structure:</p>

<pre><code class="language-mysql">CREATE TABLE `prefix_off_saas_log_10` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `saas_type` varchar(64) DEFAULT NULL,
  `saas_currency_code` varchar(3) DEFAULT NULL,
  `saas_amount` bigint(20) DEFAULT '0',
  `saas_direction` varchar(2) DEFAULT 'NA',
  `saas_status` varchar(64) DEFAULT NULL,
  `ewallet_ref` varchar(64) DEFAULT NULL,
  `merchant_ref` varchar(64) DEFAULT NULL,
  `third_party_ref` varchar(64) DEFAULT NULL,
  `created_date_time` datetime DEFAULT NULL,
  `updated_date_time` datetime DEFAULT NULL,
  `version` int(11) DEFAULT NULL,
  `saas_date_time` datetime DEFAULT NULL,
  `original_saas_ref` varchar(64) DEFAULT NULL,
  `source_of_fund` varchar(64) DEFAULT NULL,
  `external_saas_type` varchar(64) DEFAULT NULL,
  `user_id` varchar(64) DEFAULT NULL,
  `merchant_id` varchar(64) DEFAULT NULL,
  `merchant_id_ext` varchar(64) DEFAULT NULL,
  `mfg_no` varchar(64) DEFAULT NULL,
  `rfid_tag_no` varchar(64) DEFAULT NULL,
  `admin_fee` bigint(20) DEFAULT NULL,
  `ppu_type` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`),
   KEY `saas_log_idx01` (`user_id`) USING BTREE,
  KEY `saas_log_idx02` (`saas_type`) USING BTREE,
  KEY `saas_log_idx03` (`saas_status`) USING BTREE,
  KEY `saas_log_idx04` (`merchant_ref`) USING BTREE,
  KEY `saas_log_idx05` (`third_party_ref`) USING BTREE,
  KEY `saas_log_idx08` (`mfg_no`) USING BTREE,
  KEY `saas_log_idx09` (`rfid_tag_no`) USING BTREE,
  KEY `saas_log_idx10` (`merchant_id`)
  ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8
</code></pre>

<p>Since this table contains variable-length fields, and most references are assumed to have values, let’s assume all varchar fields are fully used.</p>

<p>When we add up all these fields, including the extra space for the Record Header, it comes to approximately 974 bytes per record.</p>

<p>Therefore, the number of records that can be stored in a leaf page is:</p>

<pre><code>(16 * 1024 - 200) / 974 = 16.6 rows
</code></pre>

<p>For non-leaf pages, the capacity is similar to the sysbench table.</p>

<table>
  <thead>
    <tr>
      <th>Height</th>
      <th>Non-leaf Pages</th>
      <th>Leaf Pages</th>
      <th>Rows</th>
      <th>Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>16</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>952</td>
      <td>15,232</td>
      <td>15MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>953</td>
      <td>906,304</td>
      <td>14,500,864</td>
      <td>13.8GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>907,257</td>
      <td>862,801,408</td>
      <td>13,804,822,528</td>
      <td>12.8TB</td>
    </tr>
  </tbody>
</table>

<p>It can be seen that even for a table where each row is about 1KB, if the primary key is still BIGINT, the B-tree height remains within 4 levels for data sizes under 10TB, allowing the table to store about 13.8 billion rows.</p>

<p><strong>Thus, storing tens of billions of rows in MySQL is not an issue.</strong></p>

<p>MySQL best practices suggest avoiding UUIDs as primary keys.</p>

<p>For example, if the primary key of the prefix_off_saas_log_10 table is changed to a 32-byte UUID, the number of records that can be stored in a non-leaf page is:</p>

<pre><code>(16 * 1024 - 200) / (5 + 32 + 4) = 394 rows
</code></pre>

<table>
  <thead>
    <tr>
      <th>Height</th>
      <th>Non-leaf Pages</th>
      <th>Leaf Pages</th>
      <th>Rows</th>
      <th>Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>16</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>394</td>
      <td>6,304</td>
      <td>6MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>395</td>
      <td>155,236</td>
      <td>2,483,776</td>
      <td>2GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>155,631</td>
      <td>61,162,984</td>
      <td>978,607,744</td>
      <td>981GB</td>
    </tr>
    <tr>
      <td>5</td>
      <td>61,318,615</td>
      <td>24,098,215,696</td>
      <td>385,571,451,136</td>
      <td>386TB</td>
    </tr>
  </tbody>
</table>

<p>From the table above, we can see that if UUID is used as the primary key, the same four-level B-tree can store 970 million rows, while using BIGINT can store 13.8 billion rows. However, even if UUID is mistakenly used as the primary key, the depth of MySQL’s B-tree will not exceed five levels, capable of storing up to 3.8 trillion rows and 386TB of data. This is unrealistic, as MySQL supports a maximum of 64TB per table.</p>

<h3 id="conclusion">Conclusion</h3>

<p>In general, there’s no need to worry about increased B-tree height impacting performance as the data size grows. For tables under 10TB, the B-tree height will always be within four levels, and even above 10TB, it will remain at five levels because MySQL tables have a maximum size of 64TB.</p>

<p>PolarDB supports many large tables online, with plenty of tables exceeding 10TB. I’ve also seen real-world cases shared by DBAs from major companies, like Weibo’s “6B” brother, who talked about a single Weibo table with 6 billion rows. The founder of NineData shared examples from overseas WeChat-like businesses handling tens of billions of rows in a single table, and these run just fine. So, if the table structure is designed reasonably, large tables are completely manageable, and there’s no need to be misled by current database vendors.</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[Some older DBAs may remember that in the past, it was recommended that a MySQL table should not exceed 5 million rows. Many DBAs worry that as tables grow larger, the B-tree height will increase dramatically, thus affecting performance.]]></summary></entry><entry><title type="html">MySQL 单表大数据量下的 B-tree 高度问题</title><link href="http://baotiao.github.io/2024/08/30/btree-height.html" rel="alternate" type="text/html" title="MySQL 单表大数据量下的 B-tree 高度问题" /><published>2024-08-30T00:00:00+00:00</published><updated>2024-08-30T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/08/30/btree-height</id><content type="html" xml:base="http://baotiao.github.io/2024/08/30/btree-height.html"><![CDATA[<p>有一些老的DBA 还记得在很早的时候, 坊间流传的是在MySQL里面单表不要超过500万行，单表超过 500 万必须要做分库分表.  有很多 DBA 同学担心MySQL 表大了以后, Btree 高度会变得非常大, 从而影响实例性能.</p>

<p>其实 Btree 是一个非常扁平的 Tree, 绝大部分 Btree 不超过 4 层的, 我们看一下实际情况</p>

<p>我们以常见的 sysbench table 举例子</p>

<pre><code class="language-mysql">CREATE TABLE `sbtest1` (
  `id` int NOT NULL AUTO_INCREMENT,
  `k` int NOT NULL DEFAULT '0',
  `c` char(120) NOT NULL DEFAULT '',
  `pad` char(60) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `k_1` (`k`)
) ENGINE=InnoDB AUTO_INCREMENT=10958 DEFAULT CHARSET=latin1
</code></pre>

<p>在 InnoDB 里面主要 2 种类型 Page, leaf page and non-leaf page</p>

<p>Leaf Page 格式如下, 每一个 Record 主要由 Record Header + Record Body 组成, Record Header 主要用来配合 DD(data dictionary) 信息来接下 Record Body. Record Body 是 Record 的主要内容.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831052840072.png" alt="image-20240831052840072" /></p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831045732006.png" alt="image-20240831045732006" /></p>

<p>16KB page 里面sysbench 这样的表, Leaf Page 一个表里面可以存差不多存储的行数是:</p>

<p>(16 * 1024 - 200(Page 一些 Header, tail, Diretory slot 长度) )/ ((4 + 4 + 120 + 60)行数据长度 + 5(每行数据的 header)  + 6(Transaction ID) + 7(Roll Pointer)) = 78.5</p>

<p>Non-leaf Page 格式如下:</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240831050352214.png" alt="image-20240831050352214" /></p>

<p>因为 sysbench primary key id 是 int 是 4 个字节, 那么 16KB page 可以存的行数就是</p>

<p>(16 * 1024 - 200) / (5(每行数据 Header + 4 (Cluster Key) + 4(Child Page Number)) = 1233</p>

<p>那么不同高度的计算公式如下:</p>

<table>
  <thead>
    <tr>
      <th>高度</th>
      <th>Non-leaf pages</th>
      <th>Leaf pages</th>
      <th>行数</th>
      <th>大小</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>79</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>1233</td>
      <td>97407</td>
      <td>19MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1234</td>
      <td>1520289</td>
      <td>120102831</td>
      <td>23GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>1521523</td>
      <td>1874516337</td>
      <td>148086790623</td>
      <td>27.9TB</td>
    </tr>
  </tbody>
</table>

<p>从上面可以看到, 如果是类似 sysbench 这样的表, 那么单表 1400 亿行, 数据大小是 27.9TB 的情况下, Btree 的高度都不会超过 4 层. 所以不用担心数据量大了以后, Btree 高度增加的问题</p>

<p>这里如果 sysbench 的 primary key 是 BIGINT, 也就是 8 字节那么大概是怎样的呢?</p>

<p>leaf page 里面可以存的 record 行数就是:</p>

<p>(16 * 1024 - 200) / ((8 + 4 + 120 + 60) + 13) = 78.9</p>

<p>可以看到这个 leaf page record number 变化不大</p>

<p>non-leaf page 可以存的 record 数变化稍微大一些:</p>

<p>(16 * 1024 - 200)/(5+8+4) = 952</p>

<table>
  <thead>
    <tr>
      <th>高度</th>
      <th>Non-leaf pages</th>
      <th>Leaf pages</th>
      <th>行数</th>
      <th>大小</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>79</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>952</td>
      <td>75208</td>
      <td>15MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>953</td>
      <td>906304</td>
      <td>71598016</td>
      <td>13.8GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>907257</td>
      <td>862801408</td>
      <td>68161311232</td>
      <td>12.8TB</td>
    </tr>
  </tbody>
</table>

<p>从上面可以看到, 如果 sysbench 的 primary key 改成 BIGINT 之后, 那么 4 层的 btree 可以存 600 亿行, 大概可以存 12TB 的数据.</p>

<p>如果 Sysbench 这样的 Table 不具有代表性, 那么更复杂的一些 Table, 比如 Polarbench(用于模拟各个行业的场景数据库使用场景的工具) 里面的 SaaS 场景常用的 log 表来看</p>

<pre><code class="language-mysql">CREATE TABLE `prefix_off_saas_log_10` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `saas_type` varchar(64) DEFAULT NULL,
  `saas_currency_code` varchar(3) DEFAULT NULL,
  `saas_amount` bigint(20) DEFAULT '0',
  `saas_direction` varchar(2) DEFAULT 'NA',
  `saas_status` varchar(64) DEFAULT NULL,
  `ewallet_ref` varchar(64) DEFAULT NULL,
  `merchant_ref` varchar(64) DEFAULT NULL,
  `third_party_ref` varchar(64) DEFAULT NULL,
  `created_date_time` datetime DEFAULT NULL,
  `updated_date_time` datetime DEFAULT NULL,
  `version` int(11) DEFAULT NULL,
  `saas_date_time` datetime DEFAULT NULL,
  `original_saas_ref` varchar(64) DEFAULT NULL,
  `source_of_fund` varchar(64) DEFAULT NULL,
  `external_saas_type` varchar(64) DEFAULT NULL,
  `user_id` varchar(64) DEFAULT NULL,
  `merchant_id` varchar(64) DEFAULT NULL,
  `merchant_id_ext` varchar(64) DEFAULT NULL,
  `mfg_no` varchar(64) DEFAULT NULL,
  `rfid_tag_no` varchar(64) DEFAULT NULL,
  `admin_fee` bigint(20) DEFAULT NULL,
  `ppu_type` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`),
   KEY `saas_log_idx01` (`user_id`) USING BTREE,
  KEY `saas_log_idx02` (`saas_type`) USING BTREE,
  KEY `saas_log_idx03` (`saas_status`) USING BTREE,
  KEY `saas_log_idx04` (`merchant_ref`) USING BTREE,
  KEY `saas_log_idx05` (`third_party_ref`) USING BTREE,
  KEY `saas_log_idx08` (`mfg_no`) USING BTREE,
  KEY `saas_log_idx09` (`rfid_tag_no`) USING BTREE,
  KEY `saas_log_idx10` (`merchant_id`)
  ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8
</code></pre>

<p>因为这里面有变长字段, 不过大部分 ref 是有值的, 所以假设 varchar 字段完全被使用的情况.</p>

<p>所有这些字段加起来, 再额外计算Record Header 信息, 差不多974 bytes.</p>

<p>那么 Leaf Page 可以存的 record 数就是 (16 * 1024 - 200)/974 = 16.6</p>

<p>对于 Non-Leaf Page 那么和之前 Sysbench BIGINT 一样, 可以存的 record 是 952</p>

<table>
  <thead>
    <tr>
      <th>高度</th>
      <th>Non-leaf pages</th>
      <th>Leaf pages</th>
      <th>行数</th>
      <th>大小</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>16</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>952</td>
      <td>15232</td>
      <td>15MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>953</td>
      <td>906304</td>
      <td>14500864</td>
      <td>13.8GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>907257</td>
      <td>862801408</td>
      <td>13804822528</td>
      <td>12.8TB</td>
    </tr>
  </tbody>
</table>

<p>可以看到即使是单行差不多 1KB的 Table, 如果 primary key 还是 BIGINT 的话, 那么数据在 10T 以内, Btree 的高度也一定在 4 层之内, 同时在 4 层之内, 这个Table 大概可以存 138 亿行了.</p>

<p>所以 MySQL 存几十亿行这样的场景其实是完全没问题的.</p>

<p>MySQL 还是有一个最佳实践,  “不建议使用 uuid 作为主键”. 我们来看看为什么?</p>

<p>比如上面的 prefix_off_saas_log_10 如果把 primary key 改成 32 字节的 uuid, 那么在 Leaf Page 不变的情况下,</p>

<p>Non-Leaf Page 存的 record number:</p>

<p>(16 * 1024 - 200)/(5+32+4) = 394</p>

<table>
  <thead>
    <tr>
      <th>高度</th>
      <th>Non-leaf pages</th>
      <th>Leaf pages</th>
      <th>行数</th>
      <th>大小</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>16</td>
      <td>16KB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>394</td>
      <td>6304</td>
      <td>6MB</td>
    </tr>
    <tr>
      <td>3</td>
      <td>395</td>
      <td>155236</td>
      <td>2483776</td>
      <td>2GB</td>
    </tr>
    <tr>
      <td>4</td>
      <td>155631</td>
      <td>61162984</td>
      <td>978607744</td>
      <td>981GB</td>
    </tr>
    <tr>
      <td>5</td>
      <td>61318615</td>
      <td>24098215696</td>
      <td>385571451136</td>
      <td>386TB</td>
    </tr>
  </tbody>
</table>

<p>从上面的 Table 可以看出, 如果使用 uuid 作为主键以后, 那么同样 4 层的 Btree, 如果使用 BIGINT 那么可以存 138 亿行数据, 而使用 uuid 仅仅只能存9.7 亿行数据.</p>

<p>但是即使错误的使用 uuid 作为主键, 其实 MySQL 的 Btree 的深度也不会超过 5 层, 5 层最多可以存 3.8 千亿行了, 386TB 的数据. 其实是不可能的, 因为 MySQL 单表其实最大就支持 64TB 了.</p>

<p>整体而言MySQL 里面完全不用担心数据量大了以后, Btree 高度增加影响性能的问题, 10TB 以内的数据 Btree 高度一定在 4 层以内, 超过 10TB 以后也会停留在 5 层, 不会更高了, 因为 MySQL 单表最大就支持 64TB 了.</p>

<p>PolarDB 在线上支持了非常多的大表实例, 10+TB 的大表其实非常多, 我也看到之前很多大厂 DBA 朋友的实际分享, 比如微博6B(billion) 哥, 讲述微博的某一张单表 60 亿行数据等等, NineData 创始人斗佛公众号大圣聊数据库讲述海外类似微信业务单表几十亿都是运行的挺好的. 所以其实如果业务表结构设计合理, 其实大表是完全没问题的, 不用被现在的数据库厂商强行引导.</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[有一些老的DBA 还记得在很早的时候, 坊间流传的是在MySQL里面单表不要超过500万行，单表超过 500 万必须要做分库分表. 有很多 DBA 同学担心MySQL 表大了以后, Btree 高度会变得非常大, 从而影响实例性能.]]></summary></entry><entry><title type="html">PostgreSQL blink-tree 实现以及和 PolarDB blink-tree 对比</title><link href="http://baotiao.github.io/2024/07/26/pg-blink-tree.html" rel="alternate" type="text/html" title="PostgreSQL blink-tree 实现以及和 PolarDB blink-tree 对比" /><published>2024-07-26T00:00:00+00:00</published><updated>2024-07-26T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/07/26/pg-blink-tree</id><content type="html" xml:base="http://baotiao.github.io/2024/07/26/pg-blink-tree.html"><![CDATA[<p>PosegreSQL blink-tree 实现方式引用了两个文章</p>

<p>Lehman and Yao’s high-concurrency B-tree management algorithm</p>

<p>V. Lanin and D. Shasha, A Symmetric Concurrent B-Tree Algorithm</p>

<p>MySQL InnoDB 的 btree 实现主要参考的是</p>

<p>R. Bayer &amp; M. Schkolnick  Concurrency of operations on B-trees March 1977</p>

<p><strong>lehman blink-tree</strong></p>

<p>Blink-tree 的 2 个核心变化</p>

<ol>
  <li>
    <p>Adding a single “link” pointer field to each node.</p>

    <p>这里有一个当时时间点的背景, 我们现在见到的大部分的 Btree 实现里面, 都会有 left/right point 指向 left/right page. 但是当时对标准 Btree 的定义并没有这个要求. Btree 是非叶子节点也保存数据, B+tree 是只有叶子节点保存数据, 从而使 btree height 尽可能低. 但是并没有严格的要求把叶子节点连接到一起.</p>

    <p>但是总体而言, 对 Btree 来说, 并没有强制要求有 left/right 指针指向左右 page.</p>

    <p>像 InnoDB 里面的 btree 已经自带了 leaft page 和 right page 指针了, 同时在不同的 level 包含 leaf/non-leaf node left/right 指针都指向了自己的兄弟节点了.</p>

    <p>所以到现在这里 right page 指针就可以和 link page 指针复用.</p>
  </li>
  <li>
    <p>在每个节点内增加一个字段high key, 在查询时如果目标值超过该节点的high key, 就需要循着link pointer继续往后继节点查找</p>
  </li>
</ol>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240616062120308.png" alt="image-20240616062120308" /></p>

<p>所以目前和 PolarDB 的 blink-tree 比较大的区别是取消了 lock-coupling 的操作, search 操作不加锁</p>

<p>PolarDB blink-tree</p>

<p>search 操作是通过 lock-coupling 操作, 自上而下进行加锁放锁操作.</p>

<p>SMO 操作则没有 lock-coupling, 是先加子节点lock, 然后释放子节点, 再去加父节点.具体是:</p>

<p>给 leaf-page 加锁完成操作要插入父节点的时候, 需要把子节点 page lock 释放, 然后重新 search btree, 找到父节点加 page lock 并且修改. 当然这里也可以通过把父节点指针保存下来, 从而规避第二次 search 操作, 但这个是一个优化</p>

<p>在标准的 blink-tree 中, 也就是 PostgreSQL Blink-tree</p>

<p>search 操作并没有lock coupling. 而是只需要加当前层的 latch, 如果查找到 child page id 到获得 child page 之间, 因为没有 lock-coupling, 释放完 parent node latch, 到加上 child nodt latch 这一段时间是完全不持有 latch 的, 因此child page 发生了SMO 操作, 要查找的 record 不在 child page 了, 那么该如何处理?</p>

<p>PolarDB blink-tree 中, 通过 lock-coupling 操作保证searh 操作同时持有 parent node 和 child node latch, 从而不会发生这样的情况.</p>

<p>下面这个例子就是这样的情况:</p>

<p>search 15 操作和触发 SMO 的insert 9 操作再并发进行着</p>

<p>15 原本在 y 里面, find(15) 操作的时候 y 进行了分裂, 分裂成 y 和 y’. 15 到了新的  y’ 里面.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/btree-conc1.png" alt="B-Tree concurrent modification" /></p>

<pre><code class="language-c++"># This is not how it works in postgres. This demonstrates the problem:
"Thread A, searching for 15"   |   "Thread B, inserting 9"
                               |   node2 = read(x);
node = read(x);                |
"Examine node, 15 lies in y"   |   "Examine node2, 9 belongs in y"
                               |   node2 = y;
                               |   # 9 does not fit in y
                               |   # Split y into (8,9,10) and (12,15)
                               |   y = (8,9,10); y_prime = (12,15)
                               |   x.add_pointer(y_prime)
                               |   
"y now points to (8,9,10)!"    |
node = read(y)                 |
find(15) "15 not found in y!"  |
</code></pre>

<p>对于这个例子, 可以看到 PolarDB blink-tree 通过 lock-coupling 去解决了问题, 在 read(x) 操作之后, 同时去持有 node(y) s lock, 那么 Thread B SMO 操作的时候需要持有 node(y) x lock, 那么SMO 操作就会被阻塞, 从而避免了上述问题的发生.</p>

<p>lehman 介绍的 blink-tree 怎么解决呢?</p>

<p>在 node(y) 里面, 增加了 link-page 以及 high key 以后.</p>

<p>上述的find(15) 操作判断 15 &gt; node(y)’s high-key, 那么就去 node(y)’s link-page 去进行查找. 也就是 y’.  那么在 y’ 上就可以找到 15</p>

<p>那么 SMO 操作是如何进行的呢?</p>

<p>lehman blink-tree SMO 操作是持有子节点去加父节点的锁, 并且是自下而上的latch coupling操作, 由于 search 操作不需要 lock coupling, 那么自下而上的操作也就不会有问题. 所以可以持有 child latch 同时去申请 parent node latch.</p>

<p>这里会同时持有 child, parent 两个节点的latch.</p>

<p>如果这个时候 parent 节点也含有 link page, 也就是需要插入到 parent node -&gt; link page. 那么就需要同时持有 child, parent, parent-&gt;link page 这 3 个 page 的 latch.</p>

<p>如果在 parent-&gt;link page 依然找不到插入位置, 需要到 parent-&gt;link page-&gt;link page, 那么就可以把 parent node 放开, 再去持有 link page -&gt; link page.</p>

<p>因此同一时刻最多持有 3 个节点的 latch.</p>

<p>大部分情况下 link page 只会有一个, 很多操作可以简化.</p>

<p>这里在 Vladimir Lanin Concurrent Btree 里面会有进一步的优化.</p>

<p>按照现在PG 实现, 如果锁住子节点再向父节点进行插入, 只会出现一个 link page. 因为第一个 page 发生分裂的时候, 在分裂没有结束之前是不会放开 page lock, 那么新的插入是无法进行的.</p>

<p>只有像 PolarDB blink-tree 做法一样,插入child node完成以后, 放开child node latch, 然后再去插入parent node, 允许插入parent node过程中, link page 继续被插入才可能出现多个 link page 的情况了.</p>

<p>我理解 PG 这里也是做了权衡, 为了避免出现多个 link page 的复杂情况的.</p>

<p>这里虽然不会出现多个 Link-page, 但是有可能 search/insert 的时候需要走多个 link page 到目标 Page, 比如下面例子</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240628035441324.png" alt="image-20240628035441324" /></p>

<p>其实这里也可以使用类似 PolarDB blink-tree 的方式, 也就是插入子节点以后, 就可以把子节点的锁放开, 重新遍历 btree 去插入父节点, 从而可以进一步的让子节点的 latch 尽早放开.</p>

<p>其实 blink-tree 这个文章也讲到了 remembered list</p>

<p>We then proceed back up the tree (using our “remembered” list of nodes through which we searched)</p>

<p>Vladimir Lanin <strong>Cocurrent Btree</strong></p>

<p>一开始总结了在 Blink Tree 之前Btree 并发的实现方式.</p>

<p>search 的时候自上而下 lock coupling 加锁, SMO 的时候 lock subtree 并且自上而下加锁方式, 由于 Search and SMO 操作都是自上而下, 那么就可以避免死锁的发生.</p>

<p>该文章出来之前的并发控制方式, 缺点在哪里呢?</p>

<ol>
  <li>
    <p>很难计算清楚 lock subtree 的范围到底是多少, 这个也是在 MySQL 现有代码里面非常繁琐的一块.</p>
  </li>
  <li>
    <p>lock coupling 并发的范围还是不够. 这里强调 lock-coupling 不一定需要配合 blink-tree 使用, 配合标准的 btree 使用也是可以的. 在这个文章里面就是配合 b+tree 使用的.</p>
  </li>
</ol>

<p>这 2 种方法都是牺牲并发去获得安全性.</p>

<p>当然也有在 lock coupling + lock subtree 的优化方法, 就是通过先乐观加锁, 再悲观加锁的方法. 乐观路径的时候一路都是 S lock, 然后找到 leaf node, 仅仅对 leaf node 加 X lock, 那么在 (k-1)/k (2k 表示一个 page 里面 record 个数) 情况下, 都可以走乐观. 其实 InnoDB 就是先乐观再悲观的方式.</p>

<p>其他做法和 lehman blink-tree 类似, 只不过在SMO 的时候, 实现了 only lock one node at a time, 不过在 PostgreSQL 具体实现的时候并没有这样实现, 我理解主要为了考虑安全性.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240618203912209.png" alt="image-20240618203912209" style="zoom: 50%;" /></p>

<p>文章也提到:</p>

<p>Although it is not apparent in [Lehman, Yao 811 itself, the B-link structure allows inserts and searches to lock only one node at a time.</p>

<p>也就是可以实现 insert and search only one node, 这个也是我的想法.</p>

<blockquote>
  <p>Each action holds no more than one read lock at a time during its descent, an insertion holds no more than one write lock at a time during its ascent, and a deletion needs no more than two write locks at a time during its ascent.</p>
</blockquote>

<p>After the completion of a half-split or a half-merge, all locks are released.</p>

<p>在文章里面确实是这样, half-split 之后, 所有的 locks 都释放了, 那么插入父节点的时候就会 PolarDB 现有做法类似, 也就是释放所有的 lock 重新去插入新的一层的数据, 从而保证 SMO 操作统一时刻也仅仅只有 Lock 一层.</p>

<p>Normally, finding the node with the right coverlet for the add-link or remove-link is done as in [Lehman, Yao 811, by reaccessing the last node visited during the locate phase on the level above the current node. Sometimes (e.g. when the locate phase had started at or below the current level) this is not possibie, and the node must-be found by a new descent from the top.</p>

<p>插入父节点的时候可以通过保存的 memory-list 或者重新遍历了</p>

<p>另外, 用类似 link-page 思路补充了再 lehman 文章中没有实现的delete 操作</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240618204037534.png" alt="image-20240618204037534" style="zoom: 50%;" /></p>

<p>如果仅仅是和 MySQL 的 InnoDB 对比, PG 的 Blink-tree 实现在加锁粒度上明显更加的细致, 避免的整个 Btree 的 Index lock 的同时, 也同时规避了通过 Lock subtree 的方式进行 Search 操作和 SMO 操作的冲突问题.</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[PosegreSQL blink-tree 实现方式引用了两个文章]]></summary></entry><entry><title type="html">PolarDB 一写多读架构下读取到未来页的问题</title><link href="http://baotiao.github.io/2024/06/20/polardb-future-page.html" rel="alternate" type="text/html" title="PolarDB 一写多读架构下读取到未来页的问题" /><published>2024-06-20T00:00:00+00:00</published><updated>2024-06-20T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/06/20/polardb-future-page</id><content type="html" xml:base="http://baotiao.github.io/2024/06/20/polardb-future-page.html"><![CDATA[<p><strong>背景:</strong></p>

<p>用户使用 PolarDB/Aurora 这样基于共享存储一写多读架构的时候, 很常见的想法是, 希望使用 PolarDB rw(读写节点), ro(只读节点) 和传统的 MySQL 主备节点一样. 用户认为可以在备节点上做任何复杂操作, 即使备节点有问题, 比如因为跑了复杂查询, 从而导致 CPU 升高, 导致复制有延迟, 但是也不应该影响到主节点.</p>

<p>但是, 其实在 PolarDB 里面, 其实不是这样的, 如果 RO 节点有复杂查询, 那么其实会影响到RW 节点的, 因为访问数据一致性的约束, 如果 RO 节点复制有延迟, 那么RW 节点的刷脏是存在约束的. 会导致 RW 节点无法进行刷脏.</p>

<p>目前 PolarDB的处理方法是如果RO 节点复制延迟过高, 影响了 RW 刷脏, 那么会让 RO 节点自动 crash 重启, 从而避免 RW 节点出现问题.</p>

<p>但是还是有用户希望使用 MySQL 主备一样使用 PolarDB 的 RW 和 RO, 那么如果出现了有延迟的 RO 节点, 又不想让 RO 节点重启, 那么有办法么?</p>

<p>直观的想法是不限制 RW 节点刷脏, 那么就可能出现 RO 节点读取到 future page.</p>

<p>如果RO 节点读取到future page, 会有什么问题?</p>

<p>其实Aurora 这样的架构虽然有存储多版本的支持, 但是依然也有和 PolarDB 类似的问题, 他也要解决的.</p>

<p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/image-20240622035130600.png" alt="image-20240622035130600" style="zoom:40%;" /></p>

<p>https://repost.aws/knowledge-center/aurora-read-replica-restart</p>

<p>Aurora 回答这个问题的时候也强调, Aurora 的 RW 和 RO 架构其实是和传统 MySQL 主备架构不一样.</p>

<p>Aurora/Socrate 依赖Page server 的多版本, 那么Page server必须保留最老的版本, 这样才能保证读取到想要的版本. 因此Page server 不能随意执行redo + page =&gt; new_page 逻辑, 需要等到所有的 RO 节点都已经同步到相同的 redo log 之后, 对应的 Page 才可以更新成 new_page. 其实是和PolarDB 里面限制RW刷脏是差不多的.</p>

<p>PolarDB 也一样, 存储节点保留的是最老版本, 从而保证ro 可以读取到指定的版本.</p>

<p>其实虽然 PolarDB/Aurora 架构有所区别, 但是这个问题是都有的.</p>

<p>也都存在分险, 也就是如果RO 节点延迟太多, 那么 PolarDB 由于刷脏约束可能导致节点crash, Aurora 由于刷脏约束也会导致 Page server 无法推进.</p>

<p>所以两边都有一个逻辑, 如果有一个慢RO 延迟太大, 那么RO 节点自动重启.</p>

<p>不过 Aurora 受到的影响会小很多, 因为将这些延迟的page 打散到多个 Page Server 上, 而 PolarDB 是聚集在一个节点上.</p>

<p>要解决这个问题, 可以从两个方面来解决. RW or RO 解决</p>

<ol>
  <li>
    <p>通过 RW 节点</p>

    <p>目前 PolarDB 和 Aurora 都选择类似的做法, 都是在 RW 节点进行限制. PolarDB 叫刷脏约束, Aurora 是限制 page server 生成新版本page.</p>

    <p>但是这个方案存在2个问题.</p>

    <ol>
      <li>因为内存都有限制, 因此如果一个 RO 阶段延迟太后, 那么内存可能撑不住, 所以 PolarDB 和 Aurora 都存在自动restart 逻辑</li>
      <li>由于迟迟无法推进最新 Page, 那么读取最新 Page 需要old_page + redo =&gt; new page 那么性能可能受影响, 后面讲到的方案如果允许Redo log 放在磁盘上虽然可以规避内存问题, 但是增加了额外redo IO, 性能影响更大.</li>
    </ol>

    <p>两个方案都可以通过把redo log 持久化, PolarDB 通过刷脏的时候只写log index 但是不写Page, Aurora 可以通过Page server 内存中的redo log offload 到磁盘从而不会将内存打满.</p>

    <p>但是这样都会影响到latency.</p>

    <p>或者也可以实现类似.mibd 的解决方案, 核心还是不能对old page 原地更新, 将new_page 写入到新的文件里面, 等老 RO lsn 往前推进, 再进行把.mibd 写回到.ibd 文件中.</p>

    <p>多版本引擎实现类似方案, 但是这里问题在于page IO 写放大了 2 倍, 额外增加了一个读 Page IO 性能影响非常大.</p>
  </li>
  <li>
    <p>通过 RO 节点实现</p>

    <p>目前 Socrate 看过去是类似的做法, 不对 RW 节点刷脏进行限制, 允许 RW 节点任意刷脏, 那么就需要 RO 节点去处理不一致问题. 但是Socrates 里面提到访问到 Future Page 处理的方法非常简单, 就是一个简单的重试. 其实简单的重试是最直接的处理方法, 但是对性能有影响的. 需要有更细致的处理方法</p>

    <p>这里不一致问题主要有 2 个方面</p>

    <ol>
      <li>逻辑不一致, 也就是可见性判断问题</li>
      <li>物理不一致, 也就是 SMO 导致访问到的 Page 不一致问题.</li>
    </ol>
  </li>
</ol>

<p><strong>RO 读 Future Page</strong></p>

<p>如果希望去掉限制刷脏逻辑, 允许RO 读取到future page, 那么需要内核在这里处理两个问题</p>

<ol>
  <li>
    <p>逻辑一致性问题, 也就是可见性判断问题</p>

    <p>为什么在rw 上没有这个问题?</p>

    <p>rw 上面也会在没有事务commit 的时候, 提前就已经进行刷脏操作. 那么同样rw 也会读取到太新Page, 但是提前刷脏的page 里面的record 里面记录的trx_id 肯定在活跃事务数组里面, 那么就可以知道这个record 是不可见的, 可以通过readview 找到历史版本</p>

    <p>这个问题的本质是 rw 上更新readview 和 刷脏的先后顺序是可以保证的, 但是ro 上面不能保证. 出现了刷脏但是对应的trx_id 还没有传到ro. 导致读取到了未来 Page 的问题.</p>

    <p>为什么刷脏约束可以解决这个问题.</p>

    <p>因为刷脏约束保证了刷脏之前, 对应的redo log 已经传给ro 节点, 对应的 trx_id 也同步给 ro, 那么此刻ro 节点已经获得了正确的 readview, 那么此刻rw 再刷脏, 就和rw 的行为一致了</p>
  </li>
  <li>
    <p>物理一致性问题</p>

    <p>同样为什么rw 上没有这个问题?</p>

    <p>因为如果rw 上面发生了 SMO 操作, 如果有一个查询正在持有page s latch, 那么这个SMO 操作是无法进行的, 只有当查询操作将page s lock 释放了以后, 该 SMO 操作才可以进行.</p>

    <p>但是ro 上面的查询是无法限制SMO 的, 也就是 RO 上面的查询即使lock 了next_page, 但是这里next_page 还是有可能被更新.</p>

    <p>而如果有刷脏约束, 如何解决这个问题?</p>

    <p>有刷脏约束的情况下, 如果有SMO 情况发生, 那么根据 <a href="./PolarDB sync_counter">PolarDB sync_counter</a> 介绍, 会去持有index x lock, 从而和RO 上面的查询互斥, 实现rw 类似的效果.</p>

    <p>如果没有刷脏约束, 该如何解决?</p>

    <p>可以通过在mtr 内部重试来解决, 类似Socrate 解决方案, 从而保证访问到的是同一个版本的btree. 这里重试的开销还是有的, 需要做的更加细致一些.</p>

    <ol>
      <li>发生了 SMO, 这里也分 2 种
        <ol>
          <li>访问的 Record 还在当前 Page</li>
          <li>访问的 Record 不在当前 Page</li>
        </ol>
      </li>
    </ol>
  </li>
</ol>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[背景:]]></summary></entry><entry><title type="html">InnoDB B-tree Latch Optimization History</title><link href="http://baotiao.github.io/2024/06/09/english-btree.html" rel="alternate" type="text/html" title="InnoDB B-tree Latch Optimization History" /><published>2024-06-09T00:00:00+00:00</published><updated>2024-06-09T00:00:00+00:00</updated><id>http://baotiao.github.io/2024/06/09/english-btree</id><content type="html" xml:base="http://baotiao.github.io/2024/06/09/english-btree.html"><![CDATA[<p>In general, in a database, “latch” refers to a physical lock, while “lock” refers to a logical lock in transactions. In this article, the terms are used interchangeably.</p>

<p>In the InnoDB implementation, there are two main types of locks in the B-tree: index lock and page lock.</p>

<ul>
  <li><strong>Index lock</strong> refers to the lock on the entire index, which is represented in the code as <code>dict_index-&gt;lock</code>.</li>
  <li><strong>Page lock</strong> refers to the lock present on each page within the B-tree.</li>
</ul>

<p>When we refer to B-tree locks, we generally mean both the index lock and the page lock working together.</p>

<p>In the 5.6 implementation, the process of B-tree latching is relatively simple, as follows:</p>

<h3 id="1-for-a-query-request">1. For a query request:</h3>
<ul>
  <li>First, acquire an S LOCK on <code>btree index-&gt;lock</code>.</li>
  <li>
    <p>Then, after finding the leaf node, acquire an S LOCK on the leaf node as well, and release the <code>index-&gt;lock</code>.</p>

    <p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/7AouKrR.png" alt="Image" style="zoom:33%;" /></p>
  </li>
</ul>

<h3 id="2-for-a-leaf-page-modification-request">2. For a leaf page modification request:</h3>
<ul>
  <li>Similarly, acquire an S LOCK on <code>btree index-&gt;lock</code>.</li>
  <li>Then, after finding the leaf node, acquire an X LOCK on it because the page needs to be modified. After that, release the <code>index-&gt;lock</code>. At this point, there are two scenarios depending on whether the modification of this page will cause a change in the B-tree structure:
    <ul>
      <li>If it doesn’t, that’s good. Once the X LOCK on the leaf node is acquired, modify the data and return.</li>
      <li>
        <p>If it does, you will need to perform a pessimistic insert operation and re-traverse the B-tree. Acquire an X LOCK on the B-tree index and execute <code>btr_cur_search_to_nth_level</code> to the specified page.</p>

        <p>Since modifying the leaf node may cause changes to the B-tree all the way up to the root node, other threads must be prevented from accessing the B-tree during this time. Therefore, an X LOCK is required on the entire B-tree, meaning no other query requests can access it. Moreover, since an X LOCK is held on the index, and record insertion into the page might cause the upper-level pages to change, this process may involve disk I/O, potentially making the X LOCK last for an extended time. During this time, all read-related operations will be blocked.</p>

        <p>The specific code for this is in <code>row_ins_clust_index_entry</code>. Initially, an optimistic insert operation is attempted:</p>

        <pre><code class="language-c">err = row_ins_clust_index_entry_low(
    0, BTR_MODIFY_LEAF, index, n_uniq, entry, n_ext, thr,
    &amp;page_no, &amp;modify_clock);
</code></pre>

        <p>If the insert fails, a pessimistic insert operation is attempted:</p>

        <pre><code class="language-c">return(row_ins_clust_index_entry_low(
    0, BTR_MODIFY_TREE, index, n_uniq, entry, n_ext, thr,
    &amp;page_no, &amp;modify_clock));
</code></pre>

        <p>As you can see, the only difference here is that the <code>latch_mode</code> is either <code>BTR_MODIFY_LEAF</code> or <code>BTR_MODIFY_TREE</code>. Since <code>btr_cur_search_to_nth_level</code> is executed in the <code>row_ins_clust_index_entry_low</code> function, the B-tree is re-traversed when the pessimistic insert is retried after a failed optimistic attempt.</p>

        <p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/MZrRVA6.png" alt="Image" style="zoom:33%;" /></p>
      </li>
    </ul>
  </li>
</ul>

<p>As shown above, in 5.6, the index lock is only applied to the entire B-tree index, and the page lock is applied only to leaf node pages in the B-tree. Non-leaf node pages in the B-tree are not locked.</p>

<p>This simple implementation makes the code easy to understand, but it has obvious disadvantages. During SMO (Structure Modification Operation), read operations cannot proceed, and because SMOs may involve disk I/O, the resulting performance fluctuations are quite noticeable. We have often observed such phenomena in production.</p>

<h4 id="the-80-improvements">The 8.0 Improvements</h4>

<p>In response, official changes were introduced, starting in 5.7. Here, we’ll take 8.0 as an example. The main improvements include:</p>

<ol>
  <li>The introduction of SX LOCK.</li>
  <li>The introduction of non-leaf page locks.</li>
</ol>

<p><strong>SX LOCK Introduction</strong></p>

<p>Let’s first introduce SX LOCK. SX LOCK can be used for both index locks and page locks.</p>

<ul>
  <li>SX LOCK does not conflict with S LOCK but does conflict with X LOCK. SX LOCKs also conflict with each other.</li>
  <li>The purpose of an SX LOCK is to indicate the intention to modify the protected area, but the modification has not yet started. Therefore, the resource is still accessible, but once the modification begins, access will no longer be allowed. Since an intention to modify exists, no other modifications can occur, so it conflicts with X LOCKs.</li>
</ul>

<p><strong>The main usage now is that index SX LOCK does not conflict with S LOCK, which allows reads and optimistic writes to proceed even during pessimistic insert operations.</strong></p>

<p>SX LOCK was introduced through this work log: <a href="https://dev.mysql.com/worklog/task/?id=6363">WL#6363</a>.</p>

<p>SX LOCK was primarily introduced to optimize read operations. Since SX LOCK conflicts with X LOCK but not with S LOCK, places that previously required X LOCKs were changed to SX LOCKs, making the system more read-friendly.</p>

<p><strong>Non-leaf Page Lock Introduction</strong></p>

<p>In fact, this is how most commercial databases operate—both leaf pages and non-leaf pages have page locks.</p>

<p>The main idea is <strong>Latch Coupling</strong>, where during a top-down traversal of the B-tree, the page lock on the parent node is released only after acquiring the lock on the child node. This minimizes the lock coverage. To implement this, non-leaf pages must also have page locks.</p>

<p>However, InnoDB did not completely remove the <code>index-&gt;lock</code>, which means that only one <code>BTR_MODIFY_TREE</code> operation can occur at a time. Therefore, when B-tree structure modifications are highly concurrent, performance can degrade significantly.</p>

<p><strong>Back to the 5.6 Problem</strong></p>

<p>As we can see, in 5.6, the worst-case scenario is when modifying a B-tree leaf page triggers a change in the B-tree structure. In this case, an X LOCK on the entire index is required. However, we know that such changes may only affect the current page and the page at the next level. If we can reduce the lock scope, it will undoubtedly help improve concurrency.</p>

<h3 id="in-mysql-80"><strong>In MySQL 8.0</strong></h3>

<h4 id="1-for-a-query-request-1">1. For a query request:</h4>

<ul>
  <li>First, acquire an S LOCK on <code>btree index-&gt;lock</code>.</li>
  <li>Then, during the B-tree traversal, acquire an S LOCK on the non-leaf node pages encountered.</li>
  <li>
    <p>After reaching the leaf node, acquire an S LOCK on the leaf node page and release the <code>index-&gt;lock</code>.</p>

    <p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/AGN3ghS.png" alt="Image" style="zoom:33%;" /></p>
  </li>
</ul>

<h4 id="2-for-a-leaf-page-modification-request-1">2. For a leaf page modification request:</h4>
<ul>
  <li>Similarly, acquire an S LOCK on <code>btree index-&gt;lock</code> and S LOCKs on the non-leaf node pages.</li>
  <li>After reaching the leaf node, acquire an X LOCK on the leaf node because the page needs to be modified, and then release the <code>index-&gt;lock</code>. At this point, the situation branches into two scenarios depending on whether the page modification triggers a B-tree structure change:
    <ul>
      <li>If it doesn’t, then the X LOCK on the leaf node is sufficient. After modifying the data, return as normal.</li>
      <li>If it does, a pessimistic insert operation is performed by re-traversing the B-tree. At this point, the <code>index-&gt;lock</code> is acquired with an SX LOCK.
        <ul>
          <li><strong>Since the B-tree now has an SX LOCK, the pages along the search path do not require locks. However, the pages encountered during the search process need to be saved, and X LOCKs are applied to the pages that may undergo structural changes.</strong></li>
          <li>This ensures that read operations are minimally affected during the search process.</li>
          <li>Only after confirming the scope of the B-tree changes at the final stage, and acquiring X LOCKs on the affected pages, will the operation proceed.</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<p>In 8.0, the duration of holding the SX LOCK is as follows:</p>

<ul>
  <li>
    <p><strong>Holding the SX LOCK:</strong>
After the first <code>btr_cur_optimistic_insert</code> fails, <code>row_ins_clust_index_entry</code> calls <code>row_ins_clust_index_entry_low(flags, BTR_MODIFY_TREE ...)</code> to insert. Inside <code>row_ins_clust_index_entry_low</code>, the SX LOCK is acquired in the <code>btr_cur_search_to_nth_level</code> function. At this point, the B-tree is locked by the SX LOCK, preventing further SMO operations. An optimistic insert is still attempted at this stage, with the SX LOCK still being held. If that fails, a pessimistic insert is attempted.</p>
  </li>
  <li>
    <p><strong>Releasing the SX LOCK:</strong>
In a pessimistic insert, the SX LOCK is held until a new page (page2) is created and connected to the parent node. If the page undergoing SMO is a leaf page, the SX LOCK is released only after the SMO operation is completed, and the insert is successful.</p>

    <p><img src="https://raw.githubusercontent.com/baotiao/bb/main/uPic/ye4VVpc.png" alt="Image" style="zoom:33%;" /></p>
  </li>
</ul>

<p>The function responsible for executing the SMO and inserting is <code>btr_page_split_and_insert</code>.</p>

<p>The btr_page_split_and_insert operation consists of approximately 8 steps:</p>

<p>​	1.	Find the record to split from the page that is about to be split. Ensure the split location is at the record boundary.</p>

<p>​	2.	Allocate a new index page.</p>

<p>​	3.	Calculate the boundary record for both the original page and the new page.</p>

<p>​	4.	Add a new index entry for the new page to the parent index page. If the parent page does not have enough space, it triggers the split of the parent page.</p>

<p>​	5.	Connect the current index page, the current page’s prev_page, next_page, father_page, and the newly created page. The connection order is to first connect the parent page, then prev_page/next_page, and finally connect the current page and the new page. (At this point, the index-&gt;sx lock can be released.)</p>

<p>​	6.	Move some records from the current index page to the new index page.</p>

<p>​	7.	The SMO operation is complete, and the insertion location for the current insert operation is calculated.</p>

<p>​	8.	Perform the insert operation. If the insert fails, try reorganization of the page and attempt the insert again.</p>

<p>In the existing code, there is only one scenario where index-&gt;lock will acquire an X lock, which is:</p>

<p>if (lock_intention == BTR_INTENTION_DELETE &amp;&amp;
    trx_sys-&gt;rseg_history_len &gt; BTR_CUR_FINE_HISTORY_LENGTH &amp;&amp;
    buf_get_n_pending_read_ios()) {</p>

<p>// If the lock_intention is BTR_INTENTION_DELETE and the history list is too long, the index will acquire an X lock.</p>

<p><strong>Summary:</strong></p>

<p>Improvements in 8.0 compared to 5.6</p>

<p>In 5.6, during a write operation, if an SMO (structure modification operation) is in progress, the entire index-&gt;lock would be locked with an X lock. During this time, all read operations would be blocked.</p>

<p>In 8.0, read operations and optimistic write operations are allowed to proceed during an SMO.</p>

<p>However, in 8.0 there is still a limitation: only one SMO can occur at a time because the SX lock must be acquired during an SMO. Since SX locks conflict with other SX locks, this remains one of the main issues in 8.0.</p>

<p><strong>Optimization Points:</strong></p>

<p>Of course, there are still some optimization opportunities here.</p>

<ol>
  <li>
    <p>There is still a global <code>index-&gt;lock</code>. Although it is an SX LOCK, in theory, according to the 8.0 implementation, it is possible to fully release the index lock. However, many details need to be handled.</p>
  </li>
  <li>
    <p>During the actual split operation, can the holding of the index lock inside <code>btr_page_split_and_insert</code> be optimized further?</p>

    <ul>
      <li>
        <p>For example, based on a certain sequence, could the <code>index-&gt;lock</code> be released after connecting the newly created page to the <code>new_page</code>?</p>
      </li>
      <li>
        <p>Another consideration is the holding time of the X LOCK on the page where the SMO (structure modification operation) occurs.</p>

        <p>Currently, the X LOCK is held on all pages along the path until the SMO is completed, and the current insert operation is finished. Meanwhile, the <code>father_page</code>, <code>prev_page</code>, and <code>next_page</code> also hold X LOCKs. Could the number of locked pages be reduced? For example, this optimization is mentioned in <a href="https://bugs.mysql.com/bug.php?id=99948">BUG#99948</a>.</p>
      </li>
      <li>
        <p>In <code>btr_attach_half_pages</code>, multiple traversals of the B-tree using <code>btr_cur_search_to_nth_level</code> could be avoided.
This function is responsible for establishing links like the father link, prev link, and next link. However, it redundantly executes <code>btr_page_get_father_block</code> to traverse the B-tree to find the parent node, which internally calls <code>btr_cur_search_to_nth_level</code>. This step could be avoided since the index is already SX LOCKed, and the father node won’t change. The result from the previous <code>btr_cur_search_to_nth_level</code> call could be reused.</p>
      </li>
      <li>
        <p>Can we mark pages undergoing SMO with a state similar to a B-link tree, where the page is still readable? Although the record to be read might not exist on the current page, the reader could attempt to retrieve it from the page’s <code>next_page</code>. If the record can be found, the read operation is still valid.</p>
      </li>
    </ul>
  </li>
  <li>
    <p>Can the pages encountered during the <code>btr_cur_search_to_nth_level</code> search be preserved? This way, even for repeated searches, only the max <code>trx_id</code> of the upper-level pages needs to be checked. If unchanged, the entire search path hasn’t changed, so no full traversal is necessary.</p>
  </li>
  <li>
    <p>Is it still necessary to retain the optimistic insert followed by a pessimistic insert approach?</p>

    <p>My understanding is that this process exists because the cost of pessimistic inserts was too high in the 5.6 implementation. To minimize pessimistic inserts, this process was carried over into the current 8.0 implementation. However, multiple insert attempts require multiple B-tree traversals, leading to additional overhead.</p>
  </li>
</ol>

<p><strong>talking</strong></p>

<p>https://dom.as/2011/07/03/innodb-index-lock/</p>

<p>https://dev.mysql.com/worklog/task/?id=6326</p>]]></content><author><name>baotiao</name></author><category term="Other" /><summary type="html"><![CDATA[In general, in a database, “latch” refers to a physical lock, while “lock” refers to a logical lock in transactions. In this article, the terms are used interchangeably.]]></summary></entry></feed>