原创 东青 2025-09-17 18:31 上海
表空间膨胀跟统计信息出错是同一个问题导致,而统计信息出错间接导致了部分SQL出现了慢查,下面带着这些问题开始一步步分析找根因。

💡 **表空间膨胀根源:DDL变更机制与大记录页分裂瑕疵**:文章指出,表空间膨胀近100%的主要原因是MySQL的DDL变更工具在处理全量数据复制与增量binlog回放时,可能导致主键ID大的记录先写入,而主键ID小的记录后写入。当业务表单行记录大小接近或超过8KB时,会触发InnoDB页分裂机制的一个特定场景,导致一个16KB的页仅存储一条记录,浪费大量空间,从而引起存储膨胀。
📊 **统计信息严重偏差:单页记录过少的影响**:由于上述页分裂异常,大量数据页中仅存储一条记录,这直接影响了InnoDB统计信息的准确性。在收集索引基数时,MySQL的统计算法会进行一个“减1”的修正操作,当页面仅有一条记录时,此操作会导致统计值变为0,从而产生巨大的统计信息偏差,尤其影响主键索引的行数统计。
📈 **慢SQL产生:统计信息错误误导优化器**:统计信息的严重偏差会直接影响MySQL查询优化器对SQL的成本估算。在某些带有排序(ORDER BY)和限制(LIMIT)的SQL语句中,优化器会利用统计信息来评估使用有序索引或无序索引的成本。当表总行数统计值偏低时,优化器可能倾向于选择有序索引,但由于实际数据分布并非预期,可能导致需要扫描大量数据才能满足LIMIT条件,最终引发慢SQL问题。
🛠️ **解决方案:原生DDL或优化变更流程**:临时解决方案是通过执行原生`ALTER TABLE xxx ENGINE=INNODB`语句来重新整理表空间数据,恢复正常。长期解决方案在于优化DDL变更工具,调整其执行流程,使其先完成全量数据复制,再进行增量binlog回放,从而避免异常的页分裂,或者在统计信息收集算法中针对单页记录过少的情况进行优化,避免不必要的“减1”操作。
原创 东青 2025-09-17 18:31 上海
表空间膨胀跟统计信息出错是同一个问题导致,而统计信息出错间接导致了部分SQL出现了慢查,下面带着这些问题开始一步步分析找根因。
然后插入两行 5k 大小的大主键记录(模拟变更时 binlog 回放先插入数据):CREATE TABLE `sbtest` (`id` int(11) NOT NULL AUTO_INCREMENT,`pad` varchar(12000),PRIMARY KEY (`id`)) ENGINE=InnoDB;
这里写了一个小工具打印记录对应的 page 号和 heap 号。insert into sbtest values (10000, repeat('a',5120));insert into sbtest values (10001, repeat('a',5120));
可以看到两条记录都存在 3 号页,此时表只有这一个页。继续开始顺序插入数据(模拟变更时 copy 全量数据过程),插入 rec-1:[] page: 3 -> heap: 2[] page: 3 -> heap: 3
insert into sbtest values (1, repeat('a',5120));插入 rec-2:[] page: 3 -> heap: 4[] page: 3 -> heap: 2[] page: 3 -> heap: 3
insert into sbtest values (2, repeat('a',5120));可以看到开始分裂了,page 3 被提升为根节点了,同时分裂出两个叶子节点,各自存了两条数据。此时已经形成了一棵 2 层高的树,还是用图表示吧,比较直观,如下:插入 rec-3:[] page: 4 -> heap: 2[] page: 4 -> heap: 3[] page: 5 -> heap: 2[] page: 5 -> heap: 3
insert into sbtest values (3, repeat('a',5120));示意图如下:插入 rec-4:[] page: 4 -> heap: 2[] page: 4 -> heap: 3[] page: 5 -> heap: 4[] page: 5 -> heap: 2[] page: 5 -> heap: 3
insert into sbtest values (4, repeat('a',5120));这里开始分裂一个新页 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 函数。[] page: 4 -> heap: 2[] page: 4 -> heap: 3[] page: 5 -> heap: 4[] page: 5 -> heap: 3[] page: 5 -> heap: 2[] page: 6 -> heap: 2
插入 rec-5:/* 这里返回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_pointpage = 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;}
insert into sbtest values (5, repeat('a',5120));开始分裂一个新页 page 7,新的组织结构方式如下图:此时是一个正常的插入点右分裂机制,把老的 page 5 中的记录 10000 都移动到了 page 7,并且新插入的 rec-5 也写入到了 page 7 中。到此时看上去一切正常,接下来再插入记录在当前这种结构下就会产生异常。插入 rec-6:[] page: 4 -> heap: 2[] page: 4 -> heap: 3[] page: 5 -> heap: 4[] page: 5 -> heap: 3[] page: 7 -> heap: 3[] page: 7 -> heap: 2[] page: 6 -> heap: 2
insert into sbtest values (5, repeat('a',5120));此时也是一个正常的插入点右分裂机制,把老的 page 7 中的记录 10000 都移动到了 page 8,并且新插入的 rec-6 也写入到了 page 8 中,但是我们可以发现 page 7 中只有一条孤零零的 rec-5 了,一个页只存储了一条记录。按照代码中正常的插入点右分裂机制,继续插入 rec-7 会导致 rec-6 成为一个单页、插入 rec-8 又会导致 rec-7 成为一个单页,一直这样循环下去。目前来看就是在插入 rec-4,触发了一个内部优化策略(具体优化没太去研究),进行了一些特殊的记录迁移和插入动作,当然跟记录过大也有很大关系。[] page: 4 -> heap: 2[] page: 4 -> heap: 3[] page: 5 -> heap: 4[] page: 5 -> heap: 3[] page: 7 -> heap: 3[] page: 8 -> heap: 3[] page: 8 -> heap: 2[] page: 6 -> heap: 2
可以看到PRIMARY主键异常情况下统计数据只有 20 万,表有 400 百万数据。正常情况下主键统计数据有 200 百万,也与表实际行数差异较大,同样是因为单个页面行数太少(正常情况大部分也只有2条数据),再进行减1操作后,导致统计也不准确。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;)
优化为了避免相邻两次dive统计到连续的相同的两个数据,因此减1进行修正。这里应该是可以优化的,对于主键来说是不是可以判断只有一个字段时不需要进行减1操作,会导致表行数统计非常不准确,毕竟相邻页不会数据重叠。最低限度也需要判断单个页只有一条数据时不需要减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)
正常走 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。# 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`)
alter table xxx engine=innodb变更流程 | 当前工具结构变更流程 |
建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。 拷贝全量数据:将目标表中的全量数据同步至临时表。 增量DML临时存储在一个缓冲区内。 全量数据复制完成后,开始应用增量DML日志。 切换新旧表:重命名原表作为备份,再用临时表替换原表。 变更完成 | 创建临时表:在目标数据库中创建与原表结构相同的临时表用于数据拷贝。 拷贝全量数据:将目标表中的全量数据同步至临时表。 解析Binlog并同步增量数据: 将目标表中的增量数据同步至临时表。 切换新旧表:重命名原表作为备份,再用临时表替换原表。 变更完成 |
AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑