简单记录原因,最近我也遇到这样的问题,涉及的知识点其实很多,我也仅仅是简单分析了一下,供参考。模拟版本8.0.28。


一、问题说明和模拟方式

就是主从一个表,主库大约600M,从库大约900M,当然主从的环境肯定是一致的,但是从库的并发比较高MTS使用了16个 worker线程,从并发来看基本都在使用。

我模拟的方法也很简单,无非就是主库开启writeset,将参数binlog_transaction_dependency_history_size调大,这样能够保证last commit尽可能降低,如下:

mysql> set global binlog_transaction_dependency_history_size=1000000;
mysql> set global binlog_transaction_dependency_tracking='writeset';
表结构
mysql> show create table mytestpri  \G
*************************** 1. row ***************************
       Table: mytestpri
Create Table: CREATE TABLE `mytestpri` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2337812 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
1 row in set (0.01 sec)

然后循环每次插入100行数据。使用语句
insert into mytestpri(name) select name from mytestpri limit 100;

这样生成的binlog的last commit大概如下:

mysql从库状态 mysql从库数据比主库多_bug

反正就是很多相同的可以在从库并发执行。这样达到的目的就是主库看起来是连续插入的,因为是自增,但是到了从库并发下则不是连续的,是可以并发的,那么比如990-1000这个数据可能在1000-1010之后才插入,虽然提交是考虑了顺序的(slave_preserve_commit_order=ON),但是执行并没有顺序而是并发的。

下面是我模拟的16线程woker的并发执行截图。

mysql从库状态 mysql从库数据比主库多_bug_02

从这个输出我们显然能看到几个状态,首先协调线程在等待worker线程处理事务,而几个worker都在处理事务插入的数据。
经过这个操作过后,发现主库的表为70M,但是从库的数据文件大小有110M,然后当我把MTS关闭后从库的数据文件大小也是70M了如下:

mysql从库状态 mysql从库数据比主库多_sql_03

其中mytestpri_mts.ibd为mts并发的,mytestpri_sql.ibd为单SQL线程执行的,我单独将他们拷贝出来了。下面是主库的数据文件大小:

mysql从库状态 mysql从库数据比主库多_mysql从库状态_04

那么说明在MTS大并发下,数据文件大了40M左右。

二、问题分析

首先我们需要明确,主从数据是通过binlog event进行传递的,也就是不是物理page级别的,而是在从库需要重新执行一遍的,这也为这种问题的出现提供的可能。

前面我们说从库并发执行了这些insert语句,那么打个比方很可能trx1和trx2交叉写入数据,trx1上次插入的值为1000,但是下次插入的值为trx2的990,再下一次插入的又是trx1的1001,当trx2插入991的时候,发现其插入点为990,而不是1001,这样带来的影响是PAGE_LAST_INSERT这个值会哦不断的前后变动,且大概率在分裂的时候,对比当前插入点并不是上一次的PAGE_LAST_INSERT,导致分裂可能选择middle分裂方式,也就是从page的中间分开,也就是pageold和pagenew各自占用了50%的空间,但是pageold剩下的50%可能用不到多少了,因为从大体来讲,一旦990-1000这个数据插入完成过后,pageold的空间就永远不会再用了,这样就浪费了大量的空间。
如果是总体按照顺序执行,比如单SQL线程,则不会有这种情况,分裂会选择right顺序分裂,且分裂点始终为最后一个记录,那么pageold为100%,pagenew为0%,这样新插入的数据在pagenew里面使用空间。

这个问题实际上只要稍微看看btr_page_split_and_insert函数的分裂方式就可以发现如下:

else if (btr_page_get_split_rec_to_right(cursor, &split_rec)) { //右分裂
    direction = FSP_UP;
    hint_page_no = page_no + 1; 

  } else if (btr_page_get_split_rec_to_left(cursor, &split_rec)) { //左分裂
    direction = FSP_DOWN;
    hint_page_no = page_no - 1;
    ut_ad(split_rec); 
  } else {
    direction = FSP_UP;
    hint_page_no = page_no + 1;

    /* If there is only one record in the index page, we
    can't split the node in the middle by default. We need
    to determine whether the new record will be inserted
    to the left or right. */

    if (page_get_n_recs(page) > 1) { 
      split_rec = page_get_middle_rec(page); //中间进行分裂 
    } else if (btr_page_tuple_smaller(cursor, tuple, offsets, n_uniq, heap)) {
      split_rec = page_rec_get_next(page_get_infimum_rec(page));
    } else {
      split_rec = nullptr;
    }
  }

这里几个函数分别对应了响应的分裂点选择方式,

  • btr_page_get_split_rec_to_right
  • btr_page_get_split_rec_to_left
  • page_get_middle_rec

而原则就是上次插入点PAGE_LAST_INSERT是否和本次插入点的选择相同,如果相同则可能是顺序插入,不同则可能是乱序插入,如下:

page_header_get_ptr(page, PAGE_LAST_INSERT) == insert_point

这个函数后面还有移动page的方式,就不过多解释了。

并发写入,除了引发大量的middle分裂外,从算法来看,乱序的方式即便是right分裂也可能不会选择让pageold存在100%的数据,因为这个选择就是看当前记录插入的点是否和上一次插入的记录点相同,如果相同则选择当前点的下一条记录作为分裂点,比如trx1 插入了1000-1100数据,然后trx2插入990-1000的数据,他们是顺序执行的,当trx2执行到995的时候需要分裂,那么可能995之前的数据在一个oldpage,而995之后的在一个newpage,这样分裂其实2个page也都是有数据的。

总的来说乱序插入,不管是middle分裂还是right分裂都可能导致page出现大量的碎片,而且这些碎片空间不一定都能用到,导致了大量的空间损耗。

三、问题验证

要验证这个问题,因为page太多了,不可能去一个一个page的查看,只能在关键位置加输出,我加入的输出为:

  • 分裂方式
  • 分裂点选择的offset

如果MTS下大量的选择了middle分裂方式则可能浪费大量的空间,且分裂次数更多,而分裂点为page的逻辑链表,可能是一种乱序的方式,因为插入的顺序不规定,那么小的记录可能在物理空间(heap no)的后面,但是实际上在逻辑链表的中间。

  • 单SQL线程执行

mysql从库状态 mysql从库数据比主库多_mysql从库状态_05

这里只截取的一部分输出,其实都是一样的,其中112 就是sup伪列的offset,且几乎所有的分裂都是right分裂,且选择的点都是sup伪列,那么pageold会存放当前100%的数据,papenew 就存放新的数据。

  • MTS并发执行

mysql从库状态 mysql从库数据比主库多_mysql从库状态_06

反正差不多就是惨不忍睹,又是middle分裂,又是right分裂,而且逻辑链表的offset基本是乱的,那么这种情况下肯定有大量的page碎片出现。

  • 对比分裂次数

mysql从库状态 mysql从库数据比主库多_数据库_07

其中new1.err为MTS并发下的分裂输出,new2.err为 单SQL线程下的输出,可以看到分裂次数多很多,middle分裂mts下很多,但是单SQL线程下几乎没有。很显然分裂次数越多也测面体现page碎片比较多。当然也可以借助一些工具来查看page的碎片程度,可以自行搜索一下,我用得不多吧。

当然MTS作为很好的特性,并且作为新版本默认的特性,不管是主从还是MGR都大量使用。当主从空间不一致的情况下,我们不需要太惊讶,知道原因就可以了,这也是当前版本无法避免的。最后也可以试试重组空间alter table engine=innodb,来释放这些碎片,不过大表的话代价实在有点高。

四、其他可能的原因

  • 除了这种碎片导致,还遇到过del flag 在从库不清理的案例,这可能导致主从之间的空间相差N倍,参考:
    MySQL:主从表大小相差巨大和一个BUG - 简书 MySQL:主从表大小相差巨大和一个BUG
  • 叶老师的叶问16期,也说明一下其他可能的情况,这里就不涉及了。

在主从服务器上,同一个表的表空间文件大小相差特别大,可能原因是什么,怎么解决?可能的原因:
1、MySQL表默认是InnoDB引擎且目前索引只支持B+树索引,在数据的增删改过程中,会导致表产生碎片,主从服务器上同张表的碎片率不同也会导致表空间相差很大(这个应该就是我们这里说的问题了)
2、主库整理过碎片,从库是从原先的未整理的物理备份中恢复出来的
3、主从表结构不一致,如从库可能比主库多索引
4、主从表的行格式不一致,如主库为dynamic,从库为compressed
5、个别云数据库在从库上可能采用特殊的并行复制技术,导致在从库上有更高的碎片率(有个极端的案例,同一个表在主库只有6G,从库上则有将近150G)

解决方式:
1、保证主从表结构一致(包括page大小、索引、行格式等)
2、在业务低峰期使用pt-osc或gh-ost通过alter table xxx engine=innodb;重整表空间,消除碎片(切记:执行前要先检查有无未结束事务或其他未释放锁)

  • GreatSQL社区,最近一篇类似问题的解析,可以自行参考

MySQL批量导入数据时,为何表空间膨胀了N倍