在 MySQL 中,大字段是经常使用到的对象,例如:字符类型,包括日志、博客内容以及二进制类型的视频文件等。在 InnoDB 中,大字段也叫大对象(Large Object,简称 LOB),通常认为不会高频全量访问。InnoDB 的数据是按照聚簇索引
进行组织的,当聚簇索引的数据行中存在大对象时,InnoDB 为了提升聚簇索引 B+ 树中数据行的访问效率,会对数据行中大对象的存储格式进行优化。
本文将基于 MySQL 8.0.38 的代码,介绍 InnoDB 的 DYNAMIC 行格式中 LOB 的存储格式。
在 InnoDB 中,大对象的存储形式主要有两种:
1) 内联存储在 InnoDB 聚簇索引的行记录中;
2) 以链表
的形式存在溢出页中,同时,根据不同的 LOB 大小,链表的格式也有差异。
在 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对应的字段进行溢出页存储. */
...
}
示例 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 的约束。
在主键记录中,当某字段被溢出页存储时,则在该字段中不会存储真正的数据,而是会写入大对象的引用,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 正在被修改。
在 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;
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。