【华为云MySQL技术专栏】InnoDB大对象存储格式解析
1. 背景
在MySQL中,大字段是经常使用到的对象,例如:字符类型,包括日志、博客内容以及二进制类型的视频文件等。在InnoDB中,大字段也叫大对象(Large Object,简称LOB),通常认为不会高频全量访问。InnoDB的数据是按照聚簇索引进行组织的,当聚簇索引的数据行中存在大对象时,InnoDB为了提升聚簇索引B+树中数据行的访问效率,会对数据行中大对象的存储格式进行优化。
本文将基于MySQL 8.0.38的代码,介绍InnoDB的DYNAMIC行格式中LOB的存储格式。
2. 大对象的存储形式
在InnoDB中,大对象的存储形式主要有两种:
1) 内联存储在InnoDB聚簇索引的行记录中;
2) 以链表的形式存在溢出页中,同时,根据不同的LOB大小,链表的格式也有差异。
2.1 大对象溢出页存储的条件
在InnoDB中,以16KB大小的页面为例,为了保证每个数据页面中至少有两条记录,每条记录的长度不能超过8126个字节。如果超过了,就需要对记录中的某些符合条件的字段采用溢出页(即数据并不是存储在聚簇索引中)的形式进行存储,这个判断过程如下:
步骤1,若主键记录的物理长度大于8126个字节,则顺序遍历主键记录中的每一个字段;
步骤2,接着找到一个新的、最长的且没有在溢出页的字段(主要判断字段类型,例如:VARCHAR, TEXT等),同时,能够满足以下条件的字段且不会存放在溢出页中:
a) 字段是固定长度;
b) 字段为NULL;
c) 字段的长度小于等于40个字节;
d) 字段为非大对象类型。例如,VARCHAR类型,且长度小于或者等于255。
步骤3,对满足条件的字段进行溢出页存储,存储后该字段在聚簇索引行记录中的长度更新为20;
步骤4,反之再次进入步骤1,直到步骤1中的条件不满足或者步骤2中无法找到可存储到溢出页的字段。
以上过程在InnoDB中对应的核心函数dtuple_convert_big_rec如下:
big_rec_t *dtuple_convert_big_rec(dict_index_t *index, upd_t *upd,
dtuple_t *entry) {
...
while (page_zip_rec_needs_ext(
rec_get_converted_size(index, entry), dict_table_is_comp(index->table),
dict_index_get_n_fields(index), dict_table_page_size(index->table))) {
...
for (ulint i = dict_index_get_n_unique_in_tree(index);
i < dtuple_get_n_fields(entry); i++) {
ulint savings;
dfield = dtuple_get_nth_field(entry, i);
ifield = index->get_field(i);
/* Skip fixed-length, NULL, externally stored,
or short columns */
if (ifield->fixed_len || dfield_is_null(dfield) ||
dfield_is_ext(dfield) || dfield_get_len(dfield) <= local_len ||
dfield_get_len(dfield) <= BTR_EXTERN_LOCAL_STORED_MAX_SIZE) {
goto skip_field;
}
savings = dfield_get_len(dfield) - local_len;
/* Check that there would be savings */
if (longest >= savings) {
goto skip_field;
}
/* In DYNAMIC format, store locally any non-BLOB columns whose maximum length does not exceed 256 bytes.*/
if (!DATA_BIG_COL(ifield->col)) {
goto skip_field;
}
longest_i = i;
longest = savings;
skip_field:
continue;
}
/* 将longest_i对应的字段进行溢出页存储. */
...
}
2.2 大对象溢出页存储示例
示例1:存在表t1,其定义如下:
CREATE TABLE `t1` (
`a` int DEFAULT NULL,
`b` blob,
`c` blob,
`d` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CASE 1:
insert into t1 values(1,repeat('a',32768),repeat('a',8000),repeat('a',32768));
b,d字段会被存储到溢出页中,c字段虽然有8000个字节,但因b,d字段优先被溢出页存储,因此,c字段不会被溢出页存储。
CASE 2:
insert into t1 values(1,repeat('a',7000),repeat('a',8000),repeat('a',7000));
b,c字段会被存储到溢出页中,d字段不会被溢出页存储。过程是这样的:首先,选择c进行溢出页存储,存储完毕后c字段长度变成20,然后选择b字段进行溢出页存储,完毕后b字段长度为20,d字段保留在主键记录中。
示例2:存在表t1,其包括一个INT列和32个VARCHAR (256)类型的列。当向该表中插入一个满行(每个列的值都达到字段定义的长度)记录时,此时所有VARCHAR类型的字段总长度为8192,但是如果一个字段被溢出页存储后,则总长度小于8126。因此,该记录中第一个VARCHAR (256)的列会被溢出页存储,其他的字段都不会被溢出页存储。
从上述分析以及示例中可以看到,定义为大字段(BLOB, TEXT等)类型列的数据不一定会被溢出成底层的大对象存储,定义为VARCHAR类型的列的数据也可能会被缓存,这主要取决于行以及某些字段大小是否符合上述InnoDB的约束。
3. 大对象溢出页存储格式
3.1 大对象引用字段(LOB reference,简称LOB ref)
在主键记录中,当某字段被溢出页存储时,则在该字段中不会存储真正的数据,而是会写入大对象的引用,InnoDB可以通过该大对象引用字段找到数据存储的真实位置。
LOB ref有20个字节大小,其包含的内容如下:
图1:LOB ref结构
• space_id(4):标识溢出页所属的表空间;
• page_no(4):标识溢出页第一个页面的page no;
• version(4):标识当前LOB字段值的版本号,从1开始累加,主要用于LOB的多版本读,后续文章再详细介绍该字段的使用场景;
• info bits: 一些标识信息,一共4个字节,目前只用了3个bit,主要是用于LOB的更新,包括:
a) BTR_EXTERN_OWNER_FLAG(128UL):标识该列的数据是否“真正”拥有溢出页,例如:在InnoDB中,一些UPDATE操作会被转换成DELETE + INSERT,即先将旧的行记录打上“删除”的标签,然后插入新的行记录,如果这个UPDATE不涉及大对象的修改,那么我们可以让新行记录“继承”旧行记录的溢出页存储内容,这样一来,旧行记录将不再保留溢出页,即便该行依然拥有LOB ref。在Purge的时候,被标记为“删除”的旧行指向的溢出页数据也不会被清理,后续文章会详细介绍该字段的使用;
b) BTR_EXTERN_INHERITED_FLAG(64UL):标识该列的数据溢出页内容,是否“继承”自其它行,如果是“继承”自其它行,则在回滚的时候不需要真正清理数据;
c) BTR_EXTERN_BEING_MODIFIED_FLAG(32UL):标识该字段是否正在被修改,这个主要用于READ UNCOMMITED隔离级别时防止读到中间状态的LOB内容;
• len:标识LOB对象的总长度。
LOB ref的初始值如下:
/** A BLOB field reference has all the bits set to zero, except the "being
* modified" bit. */
const byte field_ref_almost_zero[FIELD_REF_SIZE] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x20, 0, 0, 0, 0, 0, 0, 0,
};
0x20对应32UL,表示该LOB正在被修改。
3.2 溢出页
在InnoDB 8.0中溢出页的格式主要包括两种,如果记录的总长度小于2个页面的长度,那么只需要链表即可(这种场景比较简单,本文不介绍)。否则,InnoDB会使用更复杂的数据结构来对LOB的内容进行组织,用于实现LOB的高效更新以及多版本查询,如下图2所示:
图2:InnoDB溢出页存储格式
在这种场景中,我们称溢出页的第一个页面为first page(对应数据结构first_page_t);其他的数据页面被称为data page(对应数据结构data_page_t),管理data page的页面被称为index page(对应数据结构node_page_t),其主要是通过index entry这种结构进行管理。
3.2.1 first page
first page同正常的InnoDB数据页面一样,从FIL_PAGE_DATA(38)个字节开始写first page的真实内容,first page除了存放真实数据以外,主要包括以下两部分内容:
1)对该页面数据的描述信息(固定长度:58个字节)
first page的页面描述信息主要包括如下字段:
VERSION: 1个字节,当前LOB格式的版本号,当前是0;
FLAG: 1个字节,但是目前只有一个bit位有使用,标记该字段是否允许partial update,后续会详细介绍JSON的partial update;
LOB VERSION: 4个字节,当前页面数据的版本号,从1开始累加,和blob ref的version字段的作用类似;
LAST TRXID: 6个字节,最后一次修改这个页面数据的事务id;
LAST UNDO NO: 4个字节,最后一次修改这个页面数据的事务id的undo no;
DATA_LEN: 4个字节,描述当前页面写入的数据长度;
TRX_ID: 6个字节,创建该页面的事务id,对应于insert或者非partial update操作;
INDEX_LIST:16个字节,存放有正在使用数据页面的管理节点链表的首地址;
FREE_LIST_NODES:16个字节,存放所有未使用的管理节点链接的首地址;
2)index entry数组
在first page中一共有10个index entry,其中第一个index entry指向自身,其他9个指向9个其他的数据页面(假定数据足够大,需要使用10个以上的页面进行存储)。
图3:LOB存储示例
图片来源:https://dev.mysql.com/blog-archive/mysql-8-0-innodb-introduces-lob-index-for-faster-updates/
如图3所示,如果一个LOB字段的内容长度为81920,需要6个页面存储数据,每个数据页面均有一个index entry进行管理。因此,在first page中会有6个正在使用的index entry。
第一个index entry指向page 5,即first page本身,长度为15680个字节,故在first page中,除去10个index entry的空间,其他的15680个字节也可以存储数据。第二个到第五个index entry分别指向page 6, 7, 8, 9,将这些page依次全部装满(16327个字节)。第六个index entry指向page 10,存储剩下的932个字节。first page并不知道data page的位置,需要通过index entry进行遍历。
所有管理data page的index entry会通过双向链表串起来,其首地址存放在first page中LOB_INDEX_LIST中,所有空闲(即不存在data page)的index entry也会通过双向链表串联起来,其首地址存放在first page的LOB_INDEX_FREE_NODES。
index entry主要包括以下字段:
PREV:6个字节,前一个index_entry的表空间地址;
NEXT: 6个字节,后一个index_entry的表空间地址;
VERSION: 16个字节,当前页面的版本链双向链表,用于LOB的MVCC,后续文章中介绍partial update操作会详细描述该字段;
TRXID:6个字节,创建该index entry的事务id;
TRX_UNDO_NO:4个字节,创建该index entry的事务的undo no;
TRXID_MODIFIER:6个字节,修改该index entry的事务id;
TRX_UNDO_NO_MODIFIER: 4个字节,修改该index entry的事务的undo no;
PAGE_NO: 4个字节,该index entry对应的data page的page no;
DATA_LEN: 4个字节,该index entry对应的data page的数据长度;
LOB_VERSION:4个字节,该index entry以及data page的版本,从1开始累加,LOB的多版本的版本号指的就是这个。
TRXID与TRXID_MODIFIER的使用和LOB的update有关系后续详细介绍。
3.2.2 data page以及index page
• data page
如上所述,每个index entry管理着一个data page,data page主要作用就是存储真实数据,除此之外,其页面内容前11个字节还存储以下3个字段:
VERSION:1个字节长度,存储当前data page格式的版本号。当前是0,为未来扩展data page的格式使用;
DATA_LEN:4个字节长度,当前页面数据部分的长度;
TRX_ID:6个字节长度,标识修改以及创建该页面的事务id;
• index page
当LOB字段的长度超过10个页面时,first page的10个index entry就不够用了,此时会新分配一个新的页面,该页面中的所有内容被划分为index entry来使用,如下图4所示:
图4 index page 格式
index page只有一个额外的字段VERSION,标识当前页面格式的版本。图4中红色的index entry表示已经被使用的,绿色的表示尚未使用的。
3.2.3 小结
溢出页作为InnoDB大对象的复杂存储机制,其first page是大对象访问的入口,通过其内部的index entry以及关联的index page来快速访问、更新存储数据的data page。除此之外,为优化小数据量溢出的快速访问,first page自身也保存10个index entry,同时也能存小部分数据。
4. 总结
本文介绍了InnoDB大对象的存储格式,包括InnoDB会将数据行中的字段按照大对象格式进行存储的场景,InnoDB大对象溢出页存储常见存储格式,并详细介绍了InnoDB对大对象的常见组织管理方式。后续文章中将结合InnoDB的大对象的存储格式,介绍大对象更新和查询方式。
- 点赞
- 收藏
- 关注作者
评论(0)