普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月18日掘金专栏-得物技术

破解gh-ost变更导致MySQL表膨胀之谜|得物技术

作者 得物技术
2025年9月18日 14:09

一、问题背景

业务同学在 OneDBA 平台进行一次正常 DDL 变更完成后(变更内容跟此次问题无关),发现一些 SQL 开始出现慢查,同时变更后的表比变更前的表存储空间膨胀了几乎 100%。经过分析和流程复现完整还原了整个事件,发现了 MySQL 在平衡 B+tree 页分裂方面遇到单行记录太大时的一些缺陷,整理分享。

为了能更好的说明问题背后的机制,会进行一些关键的“MySQL原理”和“当前DDL变更流程”方面的知识铺垫,熟悉的同学可以跳过。

本次 DDL 变更后带来了如下问题:

  • 变更后,表存储空间膨胀了几乎 100%;
  • 变更后,表统计信息出现了严重偏差;
  • 变更后,部分有排序的 SQL 出现了慢查。

现在来看,表空间膨胀跟统计信息出错是同一个问题导致,而统计信息出错间接导致了部分SQL出现了慢查,下面带着这些问题开始一步步分析找根因。

二、索引结构

B+tree

InnoDB 表是索引组织表,也就是所谓的索引即数据,数据即索引。索引分为聚集索引和二级索引,所有行数据都存储在聚集索引,二级索引存储的是字段值和主键,但不管哪种索引,其结构都是 B+tree 结构。

一棵 B+tree 分为根页、非叶子节点和叶子节点,一个简单的示意图(from Jeremy Cole)如下:

由于 InnoDB B+tree 结构高扇区特性,所以每个索引高度基本在 3-5 层之间,层级(Level)从叶子节点的 0 开始编号,沿树向上递增。每层的页面节点之间使用双向链表,前一个指针和后一个指针按key升序排列。

最小存储单位是页,每个页有一个编号,页内的记录使用单向链表,按 key 升序排列。每个数据页中有两个虚拟的行记录,用来限定记录的边界;其中最小值(Infimum)表示小于页面上任何 key 的值,并且始终是单向链表记录列表中的第一个记录;最大值(Supremum)表示大于页面上任何 key 的值,并且始终是单向链表记录列表中的最后一条记录。这两个值在页创建时被建立,并且在任何情况下不会被删除。

非叶子节点页包含子页的最小 key 和子页号,称为“节点指针”。

现在我们知道了我们插入的数据最终根据主键顺序存储在叶子节点(页)里面,可以满足点查和范围查询的需求。

页(page)

默认一个页 16K 大小,且 InnoDB 规定一个页最少能够存储两行数据,这里需要注意规定一个页最少能够存储两行数据是指在空间分配上,并不是说一个页必须要存两行,也可以存一行。

怎么实现一个页必须要能够存储两行记录呢? 当一条记录 <8k 时会存储在当前页内,反之 >8k 时必须溢出存储,当前页只存储溢出页面的地址,需 20 个字节(行格式:Dynamic),这样就能保证一个页肯定能最少存储的下两条记录。

溢出页

当一个记录 >8k 时会循环查找可以溢出存储的字段,text类字段会优先溢出,没有就开始挑选 varchar 类字段,总之这是 InnoDB 内部行为,目前无法干预。

建表时无论是使用 text 类型,还是 varchar 类型,当大小 <8k 时都是存储在当前页,也就是在 B+tree 结构中,只有 >8k 时才会进行溢出存储。

页面分裂

随着表数据的变化,对记录的新增、更新、删除;那么如何在 B+tree 中高效管理动态数据也是一项核心挑战。

MySQL InnoDB 引擎通过页面分裂和页面合并两大关键机制来动态调整存储结构,不仅能确保数据的逻辑完整性和逻辑顺序正确,还能保证数据库的整体性能。这些机制发生于 InnoDB 的 B+tree 索引结构内部,其具体操作是:

  • 页面分裂:当已满的索引页无法容纳新记录时,创建新页并重新分配记录。
  • 页面合并:当页内记录因删除/更新低于阈值时,与相邻页合并以优化空间。

深入理解上述机制至关重要,因为页面的分裂与合并将直接影响存储效率、I/O模式、加锁行为及整体性能。其中页面的分裂一般分为两种:

  • 中间点(mid point)分裂:将原始页面中50%数据移动到新申请页面,这是最普通的分裂方法。
  • 插入点(insert point)分裂:判断本次插入是否递增 or 递减,如果判定为顺序插入,就在当前插入点进行分裂,这里情况细分较多,大部分情况是直接插入到新申请页面,也可能会涉及到已存在记录移动到新页面,有有些特殊情况下还会直接插入老的页面(老页面的记录被移动到新页面)。

表空间管理

InnoDB的B+tree是通过多层结构映射在磁盘上的,从它的逻辑存储结构来看,所有数据都被有逻辑地存放在一个空间中,这个空间就叫做表空间(tablespace)。表空间由段(segment)、区(extent)、页(page)组成,搞这么多手段的唯一目的就是为了降低IO的随机性,保证存储物理上尽可能是顺序的。

三、当前DDL变更机制

在整个数据库平台(OneDBA)构建过程中,MySQL 结构变更模块是核心基础能力,也是研发同学在日常业务迭代过程中使用频率较高的功能之一。

主要围绕对表加字段、加索引、改属性等操作,为了减少这些操作对线上数据库或业务的影响,早期便为 MySQL 结构变更开发了一套基于容器运行的无锁变更程序,核心采用的是全量数据复制+增量 binlog 回放来进行变更,也是业界通用做法(内部代号:dw-osc,基于 GitHub 开源的 ghost 工具二次开发),主要解决的核心问题:

  • 实现无锁化的结构变更,变更过程中不会阻挡业务对表的读写操作。
  • 实现变更不会导致较大主从数据延迟,避免业务从库读取不到数据导致业务故障。
  • 实现同时支持大规模任务变更,使用容器实现使用完即销毁,无变更任务时不占用资源。

变更工具工作原理简单描述 (重要)

重点:

简单理解工具进行 DDL 变更过程中为了保证数据一致性,对于全量数据的复制与 binlog 回放是并行交叉处理,这种机制它有一个特点就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录后写入表)。

这里顺便说一下当前得物结构变更整体架构:由于变更工具的工作原理需消费大量 binlog 日志保证数据一致性,会导致在变更过程中会有大量的带宽占用问题,为了消除带宽占用问题,开发了 Proxy 代理程序,在此基础之上支持了多云商、多区域本地化变更。

目前整体架构图如下:

四、变更后,表为什么膨胀?

原因说明

上面几个关键点铺垫完了,回到第一个问题,这里先直接说明根本原因,后面会阐述一下排查过程(有同学感兴趣所以分享一下,整个过程还是耗费不少时间)。

在『结构变更机制』介绍中,我们发现这种变更机制它有一个特点,就是【第三步】会导致新插入的记录可能会先写入到表中(主键 ID 大的记录先写入到了表),然后【第二步】中复制数据后写入到表中(主键 ID 小的记录)。这种写入特性叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵(暂且称之为瑕疵,或许是一个 Bug),导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),放大了存储问题。

流程复现

下面直接复现一下这种现象下导致异常页分裂的过程:

CREATE TABLE `sbtest` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pad` varchar(12000),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

然后插入两行 5k 大小的大主键记录(模拟变更时 binlog 回放先插入数据):

insert into sbtest values (10000, repeat('a',5120));
insert into sbtest values (10001, repeat('a',5120));

这里写了一个小工具打印记录对应的 page 号和 heap 号。

# ./peng
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

可以看到两条记录都存在 3 号页,此时表只有这一个页。

继续开始顺序插入数据(模拟变更时 copy 全量数据过程),插入 rec-1:

insert into sbtest values (1, repeat('a',5120));
# ./peng
[pk:1] page: 3 -> heap: 4
[pk:10000] page: 3 -> heap: 2
[pk:10001] page: 3 -> heap: 3

插入 rec-2:

insert into sbtest values (2, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

可以看到开始分裂了,page 3 被提升为根节点了,同时分裂出两个叶子节点,各自存了两条数据。此时已经形成了一棵 2 层高的树,还是用图表示吧,比较直观,如下:

插入 rec-3:

insert into sbtest values (3, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 5 -> heap: 3

示意图如下:

插入 rec-4:

insert into sbtest values (4, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:10000] page: 5 -> heap: 2
[pk:10001] page: 6 -> heap: 2

这里开始分裂一个新页 page 6,开始出现比较复杂的情况,同时也为后面分裂导致一个页只有 1 条数据埋下伏笔:

这里可以看到把 10001 这条记录从 page 5 上面迁移到了新建的 page 6 上面(老的 page 5 中会删除 10001 这条记录,并放入到删除链表中),而把当前插入的 rec-4 插入到了原来的 page 5 上面,这个处理逻辑在代码中是一个特殊处理,向右分裂时,当插入点页面前面有大于等于两条记录时,会设置分裂记录为 10001,所以把它迁移到了 page 6,同时会把当前插入记录插入到原 page 5。具体可以看 btr_page_get_split_rec_to_right 函数。

/* 这里返回true表示将行记录向右分裂:即分配的新page的hint_page_no为原page+1 */
ibool btr_page_get_split_rec_to_right(
/*============================*/
        btr_cur_t*        cursor,
        rec_t**           split_rec)
{
  page_t*        page;
  rec_t*        insert_point;
  
  // 获取当前游标页和insert_point
  page = btr_cur_get_page(cursor);
  insert_point = btr_cur_get_rec(cursor);
  
  /* 使用启发式方法:如果新的插入操作紧跟在同一页面上的前一个插入操作之后,
     我们假设这里存在一个顺序插入的模式。 */
  
  // PAGE_LAST_INSERT代表上次插入位置,insert_point代表小于等于待插入目标记录的最大记录位置
  // 如果PAGE_LAST_INSERT=insert_point意味着本次待插入的记录是紧接着上次已插入的记录,
  // 这是一种顺序插入模式,一旦判定是顺序插入,必然反回true,向右分裂
  if (page_header_get_ptr(page, PAGE_LAST_INSERT) == insert_point) {
    // 1. 获取当前insert_point的page内的下一条记录,并判断是否是supremum记录
    // 2. 如果不是,继续判断当前insert_point的下下条记录是否是supremum记录
    // 也就是说,会向后看两条记录,这两条记录有一条为supremum记录,
    // split_rec都会被设置为NULL,向右分裂
    rec_t*        next_rec;
    next_rec = page_rec_get_next(insert_point);
    
    if (page_rec_is_supremum(next_rec)) {
    split_at_new:
      /* split_rec为NULL表示从新插入的记录开始分裂,插入到新页 */
      *split_rec = nullptr;
    } else {
      rec_t* next_next_rec = page_rec_get_next(next_rec);
      if (page_rec_is_supremum(next_next_rec)) {
        goto split_at_new;
      }
      
      /* 如果不是supremum记录,则设置拆分记录为下下条记录 */


      /* 这样做的目的是,如果从插入点开始向上有 >= 2 条用户记录,
         我们在该页上保留 1 条记录,因为这样后面的顺序插入就可以使用
         自适应哈希索引,因为它们只需查看此页面上的记录即可对正确的
         搜索位置进行必要的检查 */
      
      *split_rec = next_next_rec;
    }
    
    return true;
  }
  
  return false;
}

插入 rec-5:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:10000] page: 7 -> heap: 2
[pk:10001] page: 6 -> heap: 2

开始分裂一个新页 page 7,新的组织结构方式如下图:

此时是一个正常的插入点右分裂机制,把老的 page 5 中的记录 10000 都移动到了 page 7,并且新插入的 rec-5 也写入到了 page 7 中。到此时看上去一切正常,接下来再插入记录在当前这种结构下就会产生异常。

插入 rec-6:

insert into sbtest values (5, repeat('a',5120));
# ./peng
[pk:1] page: 4 -> heap: 2
[pk:2] page: 4 -> heap: 3
[pk:3] page: 5 -> heap: 4
[pk:4] page: 5 -> heap: 3
[pk:5] page: 7 -> heap: 3
[pk:6] page: 8 -> heap: 3
[pk:10000] page: 8 -> heap: 2
[pk:10001] page: 6 -> heap: 2

此时也是一个正常的插入点右分裂机制,把老的 page 7 中的记录 10000 都移动到了 page 8,并且新插入的 rec-6 也写入到了 page 8 中,但是我们可以发现 page 7 中只有一条孤零零的 rec-5 了,一个页只存储了一条记录。

按照代码中正常的插入点右分裂机制,继续插入 rec-7 会导致 rec-6 成为一个单页、插入 rec-8 又会导致 rec-7 成为一个单页,一直这样循环下去。

目前来看就是在插入 rec-4,触发了一个内部优化策略(具体优化没太去研究),进行了一些特殊的记录迁移和插入动作,当然跟记录过大也有很大关系。

排查过程

有同学对这个问题排查过程比较感兴趣,所以这里也整理分享一下,简化了一些无用信息,仅供参考。

表总行数在 400 百万,正常情况下的大小在 33G 左右,变更之后的大小在 67G 左右。

  • 首先根据备份恢复了一个数据库现场出来。
  • 统计了业务表行大小,发现行基本偏大,在 4-7k 之间(一个页只存了2行,浪费1/3空间)。
  • 分析了变更前后的表数据页,以及每个页存储多少行数据。
    • 发现变更之前数据页大概 200 百万,变更之后 400 百万,解释了存储翻倍。
    • 发现变更之前存储 1 行的页基本没有,变更之后存储 1 行的页接近 400 百万。

基于现在这些信息我们知道了存储翻倍的根本原因,就是之前一个页存储 2 条记录,现在一个页只存储了 1 条记录,新的问题来了,为什么变更后会存储 1 条记录,继续寻找答案。

  • 我们首先在备份恢复的实例上面进行了一次静态变更,就是变更期间没有新的 DML 操作,没有复现。但说明了一个问题,异常跟增量有关,此时大概知道跟变更过程中的 binlog 回放特性有关【上面说的回放会导致主键 ID 大的记录先写入表中】。
  • 写了个工具把 400 百万数据每条记录分布在哪个页里面,以及页里面的记录对应的 heap 是什么都记录到数据库表中分析,慢长等待跑数据。

  • 数据分析完后通过分析发现存储一条数据的页对应的记录的 heap 值基本都是 3,正常应该是 2,意味着这些页并不是一开始就存一条数据,而是产生了页分裂导致的。
  • 开始继续再看页分裂相关的资料和代码,列出页分裂的各种情况,结合上面的信息构建了一个复现环境。插入数据页分裂核心函数。
    • btr_cur_optimistic_insert:乐观插入数据,当前页直接存储
    • btr_cur_pessimistic_insert:悲观插入数据,开始分裂页
    • btr_root_raise_and_insert:单独处理根节点的分裂
    • btr_page_split_and_insert:分裂普通页,所有流程都在这个函数
    • btr_page_get_split_rec_to_right:判断是否是向右分裂
    • btr_page_get_split_rec_to_left:判断是否是向左分裂

heap

heap 是页里面的一个概念,用来标记记录在页里面的相对位置,页里面的第一条用户记录一般是 2,而 0 和 1 默认分配给了最大最小虚拟记录,在页面创建的时候就初始化好了,最大最小记录上面有简单介绍。

解析 ibd 文件

更快的方式还是应该分析物理 ibd 文件,能够解析出页的具体数据,以及被分裂删除的数据,分裂就是把一个页里面的部分记录移动到新的页,然后删除老的记录,但不会真正删除,而是移动到页里面的一个删除链表,后面可以复用。

五、变更后,统计信息为什么差异巨大?

表统计信息主要涉及索引基数统计(也就是唯一值的数量),主键索引的基数统计也就是表行数,在优化器进行成本估算时有些 SQL 条件会使用索引基数进行抉择索引选择(大部分情况是 index dive 方式估算扫描行数)。

InnoDB 统计信息收集算法简单理解就是采样叶子节点 N 个页(默认 20 个页),扫描统计每个页的唯一值数量,N 个页的唯一值数量累加,然后除以N得到单个页平均唯一值数量,再乘以表的总页面数量就估算出了索引总的唯一值数量。

但是当一个页只有 1 条数据的时候统计信息会产生严重偏差(上面已经分析出了表膨胀的原因就是一个页只存储了 1 条记录),主要是代码里面有个优化逻辑,对单个页的唯一值进行了减 1 操作,具体描述如下注释。本来一个页面就只有 1 条记录,再进行减 1 操作就变成 0 了,根据上面的公式得到的索引总唯一值就偏差非常大了。

static bool dict_stats_analyze_index_for_n_prefix(
    ...
    // 记录页唯一key数量
    uint64_t n_diff_on_leaf_page;
    
    // 开始进行dive,获取n_diff_on_leaf_page的值
    dict_stats_analyze_index_below_cur(pcur.get_btr_cur(), n_prefix,
                                       &n_diff_on_leaf_page, &n_external_pages);
    
    /* 为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。
    一次是某个页面的最后一个值,一次是另一个页面的第一个值。请考虑以下示例:
    Leaf level:
    page: (2,2,2,2,3,3)
    ... 许多页面类似于 (3,3,3,3,3,3)...
    page: (3,3,3,3,5,5)
    ... 许多页面类似于 (5,5,5,5,5,5)...
    page: (5,5,5,5,8,8)
    page: (8,8,8,8,9,9)
    我们的算法会(正确地)估计平均每页有 2 条不同的记录。
    由于有 4 页 non-boring 记录,它会(错误地)将不同记录的数量估计为 8 条
    */ 
    if (n_diff_on_leaf_page > 0) {
      n_diff_on_leaf_page--;
    }
    
    // 更新数据,在所有分析的页面上发现的不同键值数量的累计总和
    n_diff_data->n_diff_all_analyzed_pages += n_diff_on_leaf_page;
)

可以看到PRIMARY主键异常情况下统计数据只有 20 万,表有 400 百万数据。正常情况下主键统计数据有 200 百万,也与表实际行数差异较大,同样是因为单个页面行数太少(正常情况大部分也只有2条数据),再进行减1操作后,导致统计也不准确。

MySQL> select table_name,index_name,stat_value,sample_size from mysql.innodb_index_stats where database_name like 'sbtest' and TABLE_NAME like 'table_1' and stat_name='n_diff_pfx01';
+-------------------+--------------------------------------------+------------+-------------+
| table_name        | index_name                                 | stat_value | sample_size |
+-------------------+--------------------------------------------+------------+-------------+
| table_1           | PRIMARY                                    |     206508 |          20 |
+-------------------+--------------------------------------------+------------+-------------+
11 rows in set (0.00 sec)

优化

为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。

这里应该是可以优化的,对于主键来说是不是可以判断只有一个字段时不需要进行减1操作,会导致表行数统计非常不准确,毕竟相邻页不会数据重叠。

最低限度也需要判断单个页只有一条数据时不需要减1操作。

六、统计信息与慢SQL之间的关联关系?

当前 MySQL 对大部分 SQL 在评估扫描行数时都不再依赖统计信息数据,而是通过一种 index dive 采样算法实时获取大概需要扫描的数据,这种方式的缺点就是成本略高,所以也提供有参数来控制某些 SQL 是走 index dive 还是直接使用统计数据。

另外在SQL带有 order by field limit 时会触发MySQL内部的一个关于 prefer_ordering_index 的 ORDER BY 优化,在该优化中,会比较使用有序索引和无序索引的代价,谁低用谁。

当时业务有问题的慢 SQL 就是被这个优化干扰了。

# where条件
user_id = ? and biz = ? and is_del = ? and status in (?) ORDER BY modify_time limit 5


# 表索引
idx_modify_time(`modify_time`)
idx_user_biz_del(`user_id`,`biz`, `is_del`)

正常走 idx_user_biz_del 索引为过滤性最好,但需要对 modify_time 字段进行排序。

这个优化机制就是想尝试走 idx_modify_time 索引,走有序索引想避免排序,然后套了一个公式来预估如果走 idx_modify_time 有序索引大概需要扫描多少行?公式非常简单直接:表总行数 / 最优索引的扫描行数 * limit。

  • 表总行数:也就是统计信息里面主键的 n_rows
  • 最优索引的扫描行数:也就是走 idx_user_biz_del 索引需要扫描的行数
  • limit:也就是 SQL 语句里面的 limit 值

使用有序索引预估的行数对比最优索引的扫描行数来决定使用谁,在这种改变索引的策略下,如果表的总行数估计较低(就是上面主键的统计值),会导致更倾向于选择有序索引。

但一个最重要的因素被 MySQL 忽略了,就是实际业务数据分布并不是按它给的这种公式来,往往需要扫描很多数据才能满足 limit 值,造成慢 SQL。

七、如何临时解决该问题?

发现问题后,可控的情况下选择在低峰期对表执行原生 alter table xxx engine=innodb 语句, MySQL 内部重新整理了表空间数据,相关问题恢复正常。但这个原生 DDL 语句,虽然变更不会产生锁表,但该语句无法限速,同时也会导致主从数据较大延迟。

为什么原生 DDL 语句可以解决该问题?看两者在流程上的对比区别。

alter table xxx engine=innodb变更流程 当前工具结构变更流程
1. 建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  1. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  2. 增量DML临时存储在一个缓冲区内。
  3. 全量数据复制完成后,开始应用增量DML日志。
  4. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  5. 变更完成 | 1. 创建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。
  6. 拷贝全量数据:将目标表中的全量数据同步至临时表。
  7. 解析Binlog并同步增量数据: 将目标表中的增量数据同步至临时表。
  8. 切换新旧表:重命名原表作为备份,再用临时表替换原表。
  9. 变更完成 |

可以看出结构变更唯一不同的就是增量 DML 语句是等全量数据复制完成后才开始应用,所以能修复表空间,没有导致表膨胀。

八、如何长期解决该问题?

关于业务侧的改造这里不做过多说明,我们看看从变更流程上面是否可以避免这个问题。

既然在变更过程中复制全量数据和 binlog 增量数据回放存在交叉并行执行的可能,那么如果我们先执行全量数据复制,然后再进行增量 binlog 回放是不是就可以绕过这个页分裂问题(就变成了跟 MySQL 原生 DDL 一样的流程)。

变更工具实际改动如下图:

这样就不存在最大记录先插入到表中的问题,丢弃的记录后续全量复制也同样会把记录复制到临时表中。并且这个优化还能解决需要大量回放 binlog 问题,细节可以看看 gh-ost 的 PR-1378。

九、总结

本文先介绍了一些关于 InnoDB 索引机制和页溢出、页分裂方面的知识;介绍了业界通用的 DDL 变更工具流程原理。

随后详细分析了变更后表空间膨胀问题根因,主要是当前变更流程机制叠加单行记录过大的时候(业务表单行记录大小 5k 左右),会碰到 MySQL 页分裂的一个瑕疵,导致了一个页只存储了 1 条记录(16k 的页只存储了 5k,浪费 2/3 空间),导致存储空间膨胀问题。

最后分析了统计信息出错的原因和统计信息出错与慢 SQL 之间的关联关系,以及解决方案。

全文完,感谢阅读。

往期回顾

  1. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

  2. 0基础带你精通Java对象序列化--以Hessian为例|得物技术

  3. 前端日志回捞系统的性能优化实践|得物技术

  4. 得物灵犀搜索推荐词分发平台演进3.0

  5. R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

文 / 东青

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

昨天以前掘金专栏-得物技术

MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术

作者 得物技术
2025年9月16日 14:17

一、前 言

本文核心介绍,为何业界会有这样的说法?—— “MySQL单表存储数据量最好别超过千万级别”

当然这里是有前提条件的,也是我们最常使用到的:

  • InnoDB存储引擎;
  • 使用的是默认索引数据结构——B+树;
  • 正常普通表数据(列数量控制在几个到一二十个,普通字段类型及长度)。

接下来咱们就探究一下原因,逐步揭开答案。

二、MySQL是如何存储数据的?

核心结构:B+树 + 16KB数据页

这里如下,建一张普通表user:

CREATE TABLE `user` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(100NOT NULL DEFAULT '' COMMENT '名字',
  `age` int(11NOT NULL DEFAULT '0' COMMENT '年龄',
  PRIMARY KEY (`id`),
  KEY `idx_age` (`age`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

数据页(Page)

介绍

InnoDB存储的最小单位,固定为16KB 。每页存储表数据(行记录)、索引、元信息等。数据加载到内存时以页为单位,减少磁盘I/O次数。

页的结构

假设我们有这么一张user数据表。其中id是唯一主键。这看起来的一行行数据,为了方便,我们后面就叫它们record吧。这张表看起来就跟个excel表格一样。excel的数据在硬盘上是一个xx.excel的文件。而上面user表数据,在硬盘上其实也是类似,放在了user.ibd文件下。含义是user表的innodb data文件,又叫表空间。虽然在数据表里,它们看起来是挨在一起的。但实际上在user.ibd里他们被分成很多小份的数据页,每份大小16k。类似于下面这样。

ibd文件内部有大量的页,我们把视角聚焦一下,放到页上面。整个页16k,不大,但record这么多,一页肯定放不下,所以会分开放到很多页里。并且这16k,也不可能全用来放record对吧。因为record们被分成好多份,放到好多页里了,为了唯一标识具体是哪一页,那就需要引入页号(其实是一个表空间的地址偏移量)。同时为了把这些数据页给关联起来,于是引入了前后指针,用于指向前后的页。这些都被加到了页头里。页是需要读写的,16k说小也不小,写一半电源线被拔了也是有可能发生的,所以为了保证数据页的正确性,还引入了校验码。这个被加到了页尾。那剩下的空间,才是用来放我们的record的。而record如果行数特别多的话,进入到页内时挨个遍历,效率也不太行,所以为这些数据生成了一个页目录,具体实现细节不重要。只需要知道,它可以通过二分查找的方式将查找效率从O(n) 变成O(lgn)

 

从页到索引—B+树索引

如果想查一条record,我们可以把表空间里每一页都捞出来(全表扫描),再把里面的record捞出来挨个判断是不是我们要找的。行数量小的时候,这么操作也没啥问题。行数量大了,性能就慢了,于是为了加速搜索,我们可以在每个数据页里选出主键id最小的record,而且只需要它们的主键id和所在页的页号。组成新的record,放入到一个新生成的一个数据页中,这个新数据页跟之前的页结构没啥区别,而且大小还是16k。但为了跟之前的数据页进行区分。数据页里加入了页层级(page level) 的信息,从0开始往上算。于是页与页之间就有了上下层级的概念,就像下面这样。

突然页跟页之间看起来就像是一棵倒过来的树了。也就是我们常说的B+ 树索引。最下面那一层,page level 为0,也就是所谓的叶子结点,其余都叫非叶子结点。上面展示的是两层的树,如果数据变多了,我们还可以再通过类似的方法,再往上构建一层。就成了三层的树。

  • 聚簇索引:数据按主键组织成一棵B+树。叶子节点存储完整行数据 ,非叶子节点存储主键值+指向子页的指针(类似目录)。
  • 二级索引:叶子节点存储主键值,查询时需回表(根据主键回聚簇索引查数据)。
  • 行格式:如COMPACT格式,行数据包含事务ID、回滚指针、列值等信息。行大小影响单页存储的行数

存入数据如下

比如表数据已存在id为1-10的数据存储,简单比方如下:

然后需要插入id=11的数据:

  • 加载1号数据页入内存,分析判定;
  • id=11的数据大于id=10,那么锁定页号5,判定5号页是否还可以存下数据11;
  • 可以存下,将id=11的数据写入到5号页中。

关键原理总结

所有数据通过B+树有序组织,数据存储在数据页上,页与页之间以双向链表连接,非叶子节点提供快速定位路径,叶子节点存储实际的数据。 

三、MySQL是如何查询到数据的?

上面我们已经介绍了MySQL中使用页存储数据,以及B+树索引数据的结构,那现在我们就可以通过这样一棵B+树加速查询。

**举个例子:select ***

from table where id = 5

比方说我们想要查找行数据5。会先从顶层页的record们入手。record里包含了主键id和页号(页地址)

如下图所示,左边2号页最小id是1,向右3号页最小id是4,然后4号页最小是7,最后5号页最小是10。

那id=5的数据如果存在,5大于4小于7,那必定在3号页里面。于是顺着的record的页地址就到了3号数据页里,于是加载3号数据页到内存。在数据页里找到id=5的数据行,完成查询。

另外需要注意的是,上面的页的页号并不是连续的,它们在磁盘里也不一定是挨在一起的。这个过程中查询了2个页(1号跟3号),如果这三个页都在磁盘中(没有被提前加载到内存中),那么最多需要经历两次磁盘IO查询,它们才能被加载到内存中。(如果考虑1号如果是root常驻内存,那么需要磁盘IO一次即可定位到)。

查询步骤总结

以聚簇索引搜索为例(假设id是主键):

  • 从根页开始搜索 :

加载根页(常驻内存)到Buffer Pool,根据指针找到下一层节点。

  • 逐层定位叶子节点 :

在非叶子节点页(存储主键+指针)中二分查找 ,定位id=5所在范围的子页(如页A)。

重复此过程,直到叶子节点页。

  • 叶子节点二分查找 :

在叶子页内通过主键二分查找定位到行记录,返回完整数据。

I/O次数分析 :

  • 树高为3时:根页 + 中间页 + 叶子页 = 3次磁盘I/O (若页不在内存中)。
  • B+树矮胖特性 :3层即可支撑千万级数据(接下来分析),是高效查询的基础。

四、2000万这个上限值如何算出来的?

在我们清楚了MySQL是如何存储及查询数据后,那么2000万这个数值又是如何得来的呢?超过2000万比如存储一亿数据会如何?

B+树承载的记录数量

从上面的结构里可以看出B+树的最末级叶子结点里放了record数据。而非叶子结点里则放了用来加速查询的索引数据。也就是说同样一个16k的页,非叶子节点里每一条数据都指向一个新的页,而新的页有两种可能。

  • 如果是末级叶子节点的话,那么里面放的就是一行行record数据。
  • 如果是非叶子节点,那么就会循环继续指向新的数据页。

假设

  • 非叶子节点内指向其他内存页的指针数量为x(非叶子节点指针扇出值)
  • 叶子节点内能容纳的record数量为y(叶子节点单页行数)
  • B+树的层数为z(树高)

那这棵B+树放的行数据总量等于 (x ^ (z-1)) * y

核心公式:单表最大行数 = 非叶节点扇出指针数 ^ (树高-1) × 单页行数

非叶子节点指针扇出值—x 怎么算?

我们回去看数据页的结构。

非叶子节点里主要放索引查询相关的数据,放的是主键和指向页号。

  • 主键假设是bigint(8Byte),而页号在源码里叫FIL_PAGE_OFFSET(4Byte),那么非叶子节点里的一条数据是12Byte左右。
  • 整个数据页16k, 页头页尾那部分数据全加起来大概128Byte,加上页目录毛估占1k吧。那剩下的15k除以12Byte,等于1280,也就是可以指向x=1280页。

我们常说的二叉树指的是一个结点可以发散出两个新的结点。m叉树一个节点能指向m个新的结点。这个指向新节点的操作就叫扇出(fanout) 。而上面的B+树,它能指向1280个新的节点,恐怖如斯,可以说扇出非常高了。

单页行数—y的计算

叶子节点和非叶子节点的数据结构是一样的,所以也假设剩下15kb可以发挥。

叶子节点里放的是真正的行数据。假设一条行数据1kb,所以一页里能放y=15行

行总数计算

回到 (x ^ (z-1)) * y 这个公式。

已知x=1280,y=15。

假设B+树是两层,那z=2。则是(1280 ^ (2-1)) * 15 ≈ 2w

假设B+树是三层,那z=3。则是 (1280 ^ (3-1)) * 15 ≈ 2.5kw

这个2.5kw,就是我们常说的单表建议最大行数2kw的由来。 毕竟再加一层,数据就大得有点离谱了。三层数据页对应最多三次磁盘IO,也比较合理。

  • 临界点 :当行数突破约2000万时,树高可能从3层变为4层:
  • 树高=4时:最大行数 ≈ 1280^3 × 15 结果已超过百亿(远大于2000万)
  • 性能断崖 :树高从3→4,查询I/O次数从3次增至4次 (多一次磁盘寻址),尤其在回表查询、高并发、深分页时性能骤降。

行数超一亿就慢了吗?

上面假设单行数据用了1kb,所以一个数据页能放个15行数据。

如果我单行数据用不了这么多,比如只用了250byte。那么单个数据页能放60行数据。

那同样是三层B+树,单表支持的行数就是 (1280 ^ (3-1)) * 60 ≈ 1个亿。

你看我一个亿的数据,其实也就三层B+树,在这个B+树里要查到某行数据,最多也是三次磁盘IO。所以并不慢。

B树承载的记录数量

我们都知道,现在MySQL的索引都是B+树,而有一种树,跟B+树很像,叫B树,也叫B-树

它跟B+树最大的区别在于,B+树只在末级叶子结点处放数据表行数据,而B树则会在叶子和非叶子结点上都放。

于是,B树的结构就类似这样:

B树将行数据都存在非叶子节点上,假设每个数据页还是16kb,掐头去尾每页剩15kb,并且一条数据表行数据还是占1kb,就算不考虑各种页指针的情况下,也只能放个15条数据。数据页扇出明显变少了

计算可承载的总行数的公式也变成了一个等比数列

15 + 15^2 +15^3 + ... + 15^z

其中z还是层数的意思。

为了能放2kw左右的数据,需要z>=6。也就是树需要有6层,查一次要访问6个页。假设这6个页并不连续,为了查询其中一条数据,最坏情况需要进行6次磁盘IO

而B+树同样情况下放2kw数据左右,查一次最多是3次磁盘IO

磁盘IO越多则越慢,这两者在性能上差距略大。

为此,B+树比B树更适合成为MySQL的索引

五、总结:生死博弈的核心

B+树叶子和非叶子结点的数据页都是16k,且数据结构一致,区别在于叶子节点放的是真实的行数据,而非叶子结点放的是主键和下一个页的地址。

B+树一般有两到三层,由于其高扇出,三层就能支持2kw以上的数据,且一次查询最多1~3次磁盘IO,性能也还行。

存储同样量级的数据,B树比B+树层级更高,因此磁盘IO也更多,所以B+树更适合成为MySQL索引。

索引结构不会影响单表最大行数,2kw也只是推荐值,超过了这个值可能会导致B+树层级更高,影响查询性能。

单表最大值还受主键大小和磁盘大小限制。

16KB页与B+树的平衡 :页大小限制了单页行数和指针数,B+树通过多阶平衡确保低树高。

2000万不是绝对 :若行小于1KB(如只存ID),上限可到5000万+;若行较大(如含大字段),可能500万就性能下降。

优化建议:

  • 控制单行大小(避免TEXT/BLOB直接入表)。
  • 分库分表:单表接近千万级时提前规划。
  • 冷热分离:历史数据归档。

本质:通过页大小和B+树结构,MySQL在磁盘I/O和内存效率之间取得平衡。超出平衡点时,性能从“平缓下降”变为“断崖下跌”。

六、拓展问题

为啥设计单页大小16k?

MySQL索引采用的是B+树数据结构,每个叶子节点(叶子块)存储一个索引条目的信息。而MySQL使用的是页式存储(Paged storage)技术,将磁盘上的数据划分为一个个固定大小的页面,每个页面包含若干个索引条目。

为了提高索引查询效率和降低磁盘I/O的频率,MySQL设置了16KB的单页大小。这是因为在MySQL中:

  • 内存大小限制:MySQL的索引需要放在内存中进行查询,如果页面过大,将导致索引无法完全加载到内存中,从而影响查询效率。
  • 磁盘I/O限制: 当需要查询一个索引时,MySQL需要把相关的页面加载到内存中进行处理,如果页面过大,将增加磁盘I/O的开销,降低查询效率。
  • 索引效率限制:在B+树数据结构中,每个叶子节点存储着一个索引条目,因此如果每个页面能够存放更多索引条目,就可以减少B+树结构的深度,从而提高索引查询效率。

综上所述,MySQL索引单页大小设置为16KB可以兼顾内存大小、磁盘I/O和索引查询效率等多方面因素,是一种比较优化的方案。需要注意的是,对于某些特殊的应用场景,可能需要根据实际情况对单页大小进行调整。

字符串怎么做索引?

在MySQL中,可以通过B+树索引结构对字符串类型的列进行排序。具体来说,当使用B+树索引进行排序时,MySQL会根据字符串的字典序(Lexicographic Order)进行排序。

字典序是指将字符串中的每个字符依次比较,直到找到不同的字符为止。如果两个字符串在相同的位置上具有不同的字符,则以这两个字符的ASCII码值比较大小,并按照升序或降序排列。例如,字符串"abc"和"def"比较大小时,先比较'a'和'd'的ASCII码,因为'd'的ASCII码大于'a',所以"def"大于"abc"。

需要注意的是,如果对长字符串进行排序,可能会影响索引查询的性能,因此可以考虑使用前缀索引或全文索引来优化。同时,在实际开发中,还需要注意选择适当的字符集和排序规则,以确保排序结果正确和稳定。

中文字符串怎么做索引?

中文字符串排序在MySQL中可以使用多种方式,最常见的有以下两种:

  • 按拼音排序:对于中文字符串,可以按照拼音进行排序。可以使用拼音排序插件,如pinyin或zhuyin插件,来实现中文字符串按照拼音进行排序。这些插件会将中文字符串转换为拼音或注音后,再进行排序。

例如,先安装pinyin插件:

INSTALL PLUGIN pinyin SONAME 'ha_pinyin.so';

然后创建对应的索引并按拼音排序:

CREATE INDEX idx_name_pinyin ON mytable(name) USING BTREE WITH PARSER pinyin;
SELECT * FROM mytable ORDER BY name COLLATE pinyin;
  • 按Unicode码点排序:可以使用UTF-8字符集,并选择utf8mb4_unicode_ci排序规则,在使用此排序规则时,MySQL会按照Unicode码点进行排序,适合于较为通用的中文字符串排序需求。

例如:

CREATE INDEX idx_name_unicode ON mytable(name) USING BTREE;
SELECT * FROM mytable ORDER BY name COLLATE utf8mb4_unicode_ci;

需要注意的是,不同的排序方式可能会对性能产生影响,因此需要根据具体需求选择合适的排序方式,并进行必要的测试和验证。同时,在进行中文字符串排序时,还需要考虑到中文字符的复杂性,例如同音字、繁简体等问题,以确保排序结果正确和稳定。

索引字段的长度有限制吗?

在MySQL中,索引的长度通常是由三个因素决定的:数据类型、字符集和存储引擎。不同的数据类型、字符集和存储引擎所支持的最大索引长度也有所不同。

一般情况下,索引的长度不应该超过存储引擎所支持的最大索引长度。在InnoDB存储引擎中,单个索引所能包含的最大字节数为767个字节(前缀索引除外)。如果索引的长度超过了最大长度,则会导致创建索引失败。因此,在设计表结构时,需要根据索引列的数据类型和字符集等因素,合理设置索引长度,以充分利用索引的优势。

对于字符串类型的索引,还需要注意以下几点:

  • 对于UTF-8字符集,每个字符占用1-4个字节,因此索引长度需要根据实际情况进行计算。例如,一个VARCHAR(255)类型的列在utf8mb4字符集下的最大长度为255*4=1020个字节。
  • 可以使用前缀索引来减少索引的大小,提高索引查询效率。在创建前缀索引时需要指定前缀长度。例如,可以在创建索引时使用name(10)来指定name列的前10个字符作为索引。
  • 在使用全文索引对字符串进行搜索时,MySQL会将文本内容分割成单个词汇后建立倒排索引。在建立索引时需要考虑到中英文分词的问题,以确保全文索引的准确性和查询效率。

综上所述,索引的长度需要根据数据类型、字符集和存储引擎等多个因素进行综合考虑,并合理设置索引长度,以提高索引查询效率和利用率。

往期回顾

  1. 0基础带你精通Java对象序列化--以Hessian为例|得物技术

  2. 前端日志回捞系统的性能优化实践|得物技术

  3. 得物灵犀搜索推荐词分发平台演进3.0

  4. R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃|得物技术

  5. 可扩展系统设计的黄金法则与Go语言实践|得物技术

文 / 太空

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌