东青 2025-10-13 07:16 广东
破解gh-ost变更导致MySQL表膨胀之谜!
二、索引结构
1. B+tree
2. 页(page)
3. 溢出页
4. 页面分裂
三、当前DDL变更机制
四、变更后,表为什么膨胀?
1. 原因说明
2. 流程复现
3. 排查过程
五、变更后,统计信息为什么差异巨大?
六、统计信息与慢SQL之间的关联关系?
七、如何临时解决该问题?
八、如何长期解决该问题?
九、总结

🗄️ **InnoDB索引与页分裂机制是关键**:文章首先铺垫了InnoDB索引(B+tree)的基本原理,包括页(page)、溢出页和页面分裂的概念。理解InnoDB如何管理数据存储在页内以及在页满时进行分裂,是理解后续问题的基础。特别强调了当单行记录过大时(约5k),InnoDB在处理页分裂时可能存在的缺陷,导致一个16K的页仅存储一条记录,造成空间浪费。
⚙️ **特定DDL变更工具的流程缺陷是导火索**:文章详细描述了基于gh-ost的无锁DDL变更工具的工作流程,指出其在全量数据复制与Binlog增量回放并行交叉处理时,可能导致大ID记录先写入,后由全量复制写入小ID记录。当遇到大记录时,这种写入特性与页分裂机制叠加,触发了异常的页分裂,使得大量页仅包含一条记录,从而导致表空间膨胀近一倍。
📊 **统计信息失真加剧慢SQL问题**:由于大量页只存储一条记录,MySQL的索引统计信息收集算法(通过采样页并统计唯一值)产生严重偏差。特别是代码中对单个页唯一值数量的减1修正逻辑,在只有一条记录的页上会将其唯一值计数变为0,极大地低估了索引的基数(唯一值数量)。
📈 **统计信息偏差干扰SQL优化器决策**:优化器在评估SQL执行计划时,依赖索引统计信息。当统计信息(如主键的行数)严重失真时,优化器可能做出错误的成本估算。结合SQL中的`ORDER BY`和`LIMIT`子句,MySQL的`prefer_ordering_index`优化机制会倾向于使用估计成本更低的有序索引,即使该索引实际扫描的行数远超预期,最终导致慢SQL的出现。
🛠️ **解决方案:调整流程与优化工具**:临时解决方案是执行原生的`ALTER TABLE ... ENGINE=INNODB`语句来整理表空间。长期解决方案在于优化DDL变更工具的流程,使其先完成全量数据复制,再进行增量Binlog回放,从而模拟原生DDL的行为,避免因交叉并行处理大记录时的异常页分裂。同时,对MySQL统计信息收集算法的优化建议也被提出。
东青 2025-10-13 07:16 广东
破解gh-ost变更导致MySQL表膨胀之谜!
二、索引结构
1. B+tree
2. 页(page)
3. 溢出页
4. 页面分裂
三、当前DDL变更机制
四、变更后,表为什么膨胀?
1. 原因说明
2. 流程复现
3. 排查过程
五、变更后,统计信息为什么差异巨大?
六、统计信息与慢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操作后,导致统计也不准确。staticbooldict_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;)
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。# 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 时代的下一个智能信息助手,助你摆脱信息焦虑