【InnoDB存储引擎4】行结构
目录
一. InnoDB支持的数据行格式都有哪些?
二 . 数据区是怎么存储真实数据的?
2.1.主键值
2.2.DB_TX_ID和 DB_ROLL_PTR
2.3.真实数据区剩余部分
三.额外(管理)信息区包含了关于⾏的哪些信息?
四.头信息区域包含了哪些信息?
五.Null列表有啥作用?列表中的值是什么?
六. 变长字段列表有啥作用?列表中的值是什么?
6.1.为什么要记录变长字段中数据的真实长度?
6.2.如何记录变长字段的实际长度?
6.3.读取长度时如何处理粘包问题?
七. 其他的⾏格式与DYNAMIC有什么区别?
八.总结
真实的数据在表空间以数据⾏的形式存储,也就是说每⼀条数据都对应着表中的⼀⾏,数据⾏在⻚ 中的位置如下图所⽰:
接下来我们介绍关于行结构的内容
一. InnoDB支持的数据行格式都有哪些?
InnoDB支持四种行格式,不同的行格式数据的存储上有所不同.,分别是:
- REDUNDANT 冗余格式
- COMPACT 紧凑格式
- DYNAMIC 动态格式
- COMPRESSED 压缩格式
InnoDB存储引擎默认是 DYNAMIC 格式。所以我们上面说的那种行结构就是DYNAMIC 格式,我们接下来没有特殊说明的话,也还是讲DYNAMIC 格式
如何查看当前数据库或表应用了哪种行格式?
可以使用以下SQL查看行格式:
- 查看系统变量中设置的行格式
SHOW VARIABLES LIKE 'innodb_default_row_format';
- 使⽤SHOW table STATUS查看数据库中的所有存储引擎是innoDB的表
SHOW TABLE STATUS IN 数据库名 where engine = 'innoDB'\G
我们看只需要看存储引擎是innodb存储引擎的
- 通过查询INFORMATION_SCHEMA.INNODB_TABLES表查看指定表的⾏格式
SELECT NAME, ROW_FORMAT FROM INFORMATION_SCHEMA.INNODB_TABLES WHERE NAME='db1/t_innode';
如何指定行格式?
可以通过全局变量设置行格式,也可以在创建表中通过 **ROW_FORMAT** 子句指定行格式:
- 通过全局变量设置
SET GLOBAL innode_default_row_format=DYNAMIC;
- 在创建表时明确的指定行格式
CREATE TABLE tl (cl INT) ROM_FORMAT=DYNAMIC;
DYNAMIC 格式由哪些部分组成?
一个 DYNAMIC 格式的数据行会被分为两部分,一个都是存储真实数据的区域,一部分是存储额外信息的区域,也就是我们开头看到的那部分
我们在最开头部分已经对一个 DYNAMIC 格式的数据行里面的额外信息做了简单介绍,下面来详细讲解一下行的组成结构
二 . 数据区是怎么存储真实数据的?
我们在前面已经讲解过了额外信息的组成,那么现在我们来讲讲它的真实数据部分。
数据区在数据⾏中的位置如下图所⽰:
接下来我将依次讲解主键值, DB_TX_ID,DB_ROLL_PTR,还有后面的
2.1.主键值
从分隔线向右第一个字段存储真实数据的主键值,对于主键值有以下几种情况:
- 如果表中定义了主键,则直接存储主键的值;
比如说我插入了一条(1,'张三')那么主键区存的就是1
- 如果是复合主键会根据列定义的顺序依次排列在这里;
假如说插入一条数据(1,10,12,99),那么主键区应该存储1,10,12这么一个复合主键。
- 如果没有主键,会优先使用第一个不允许为NULL的 UNIQUE 唯一列作为主键;
假如说插入一条数据('s3002','张三',1),那么主键值应该存s3002
- 如果既没有主键也没有唯一键,那么InnoDB会构建一个6字节的字段 DB_ROM_ID 作为行的唯一标识,存储在真实数据的头部
DB_ROM_ID是InnoDB内部维护的东西,能作为行标识,这个是查不出来的。
2.2.DB_TX_ID和 DB_ROLL_PTR
紧接着是在事务运行中两个非常重要的固定字段
- 6字节的事务ID字段 DB_TX_ID,记录创建或最后一次修改该记录的事务ID(每个DML(增删改都会包含在一个事务中)
- 7字节的回滚指针字段 DB_ROLL_PTR,如果在事务中这条记录被修改,指向这条记录的上一个版本。指向该行记录的 上一个版本 在 Undo Log 中的位置信息,用于构建记录的 版本链,是 MVCC 回溯历史版本和事务回滚的核心。
咱们用图书馆借书的例子,来通俗地解释 DB_TRX_ID
和 DB_ROLL_PTR
这两个字段是干嘛的,以及它们怎么让数据库支持“多人同时看书还不乱套”。
想象场景:
-
图书馆 = 数据库
-
一本书 = 数据库里的一行记录
-
读者 = 正在运行的事务(比如你执行的一个查询或修改操作)
-
管理员 = InnoDB 存储引擎
1. DB_TRX_ID
(6字节的事务ID): 这本书最后被谁动过?
-
通俗解释: 想象每本书都贴着一张小小的便利贴。每当有人借走这本书(修改数据),管理员就会在便利贴上写下这个人的借书证号码(事务ID)和借书日期(相当于事务开始时间)。
-
作用:
-
记录这本书最近一次是被谁(哪个事务)借走修改的。
-
管理员(InnoDB)就靠这个便利贴上的信息,来决定你现在能不能看这本书(这条记录)的最新内容。
-
-
例子:
-
小明(事务ID 100)在上午10点借走了《三国演义》修改了页码。管理员在书的便利贴上写:
最后修改人:100
。 -
小红(事务ID 101)在上午10:05也想看这本书。管理员一看便利贴:“最后修改人是100”。管理员偷偷去查小明的借阅状态(事务是否提交):
-
如果小明已经还书了(事务提交了),那小红看到的就是小明修改后的新页码。
-
如果小明还在看书没还(事务未提交),那管理员就不让小红直接看小明正在改的这本。管理员会想办法给小红找一个小明改之前的版本(这就是
DB_ROLL_PTR
的作用了)。
-
-
2. DB_ROLL_PTR
(7字节的回滚指针): 这本书的“历史存档”在哪?
-
通俗解释: 图书馆有个超大的历史档案室(Undo Log)。每当有人要修改一本书的内容(比如修改错别字、增加注释),管理员不会直接在原书上涂改!他会:
-
去档案室复印一份这本书修改前的样子(保存旧版本数据)。
-
把这份复印件编号归档。
-
在原书上贴一个新的便利贴(
DB_TRX_ID
),同时记下刚才那份复印件的编号(这就是DB_ROLL_PTR
),然后才让读者在原书上修改。
-
-
作用:
-
指向这本书修改前的样子在“历史档案室”里的位置。
-
形成了书的版本链:最新的书 -> (指向) -> 上一次修改的存档 -> (指向) -> 再上一次的存档 -> ... -> 最原始的存档。
-
-
例子:
-
小明(事务ID 100)要修改《三国演义》第5页的一个错字。
-
管理员动作:
-
复印第5页修改前的样子,存到档案室,编号
Archive#123
。 -
在《三国演义》书上:
-
更新便利贴:
最后修改人:100
(DB_TRX_ID
) -
记下:
历史存档位置:Archive#123
(DB_ROLL_PTR
)
-
-
然后让小明在书上修改错字。
-
-
如果小明改完后悔了(事务回滚),管理员就根据
Archive#123
找到存档,把第5页恢复成修改前的样子。 -
如果小红(事务ID 101)在小明修改时想看这本书,但小明还没提交(还书),管理员就根据书上的
DB_ROLL_PTR
(指向Archive#123
),去档案室把存档的第5页拿给小红看。小红看到的就是修改前的正确内容,她完全不知道小明正在改错字呢!
-
它们俩怎么一起工作?(MVCC - 多版本并发控制)
-
同时看书不打架: 多个读者(事务)可以同时看同一本书(同一行数据)!小红看她的存档(旧版本),小明改他的原书(新版本),互不干扰。管理员靠
DB_TRX_ID
判断谁该看哪个版本,靠DB_ROLL_PTR
找到旧版本。 -
回滚(撤销修改): 如果读者改书改错了(事务出错),管理员就根据
DB_ROLL_PTR
找到最近的存档,把书恢复成修改前的样子。 -
读一致: 在你开始“看书”(开始一个事务)的那一刻,管理员就根据所有书的
DB_TRX_ID
便利贴和当前其他读者的状态,决定给你看哪一版的书。保证你在这个事务里,每次看到这本书的内容都是一致的,即使别人在你读的过程中修改并提交了这本书!你看的还是你开始读时的那个版本(快照)。
总结:
-
DB_TRX_ID
(便利贴): 回答“这本书最后被谁动过?” 用于判断你现在能不能看最新版。 -
DB_ROLL_PTR
(存档编号): 回答“这本书修改前的样子存在档案室哪个位置?” 用于:-
给不能看最新版的人看旧版(MVCC)。
-
如果修改错了,恢复成旧版(回滚)。
-
-
它们俩 + 档案室(Undo Log): 共同实现了数据库的“魔法”——让很多人能同时安全地读写同一份数据,互不干扰,还能随时撤销操作,并且保证每个人看到的数据在某个时间点是一致的! 这就是 InnoDB 多版本并发控制 (MVCC) 的核心。
2.3.真实数据区剩余部分
接下来就是除了主键和值为NULL的列之外,其他列的真实数据,按照顺序从左到右依次排列
在真实数据区不存值为NULL的列,只存有真实值的列
至于为什么不存储NULL值,原因很简单
这个问题核心在于理解 InnoDB 如何高效地存储“空值”(NULL)。我们可以从空间节省、存储结构和查找机制三个方面来通俗地解释:
核心思想:用极小的代价标记“空”,而不是浪费空间存储“空”。
想象一下你有一张表格(表结构),有些格子是必填的(NOT NULL
),有些格子是选填的(允许 NULL
)。InnoDB 存储一行数据时,就像在填写这张表格的纸质副本。
1. 空间节省:不存“无意义”的数据
-
问题: 如果一个选填的格子没有填(值是
NULL
),你在纸质表格上会怎么做?你会不会特意在那个格子里写上“空”或者“未填写”这几个字?通常不会!因为写这些字本身也要占用纸张的空间,而且你知道那个格子是允许为空的,看到它空白自然就明白是NULL
。 -
InnoDB 的做法: 同理。在存储真实数据时(“数据区”),如果一个列的值是
NULL
,InnoDB 认为存储任何实际数据(哪怕是零或空字符串)都是浪费空间。所以它干脆不把这个 NULL 值存到 “数据区”。 -
好处: 节省了大量空间。尤其是当表中有很多允许为
NULL
的列,并且很多行的这些列确实为NULL
时,节省的空间非常可观。
2. NULL 值列表:高效的“空白格”地图
-
问题: 如果不存
NULL
,怎么知道哪个格子是空的呢?在纸质表格上,你可能会在表格顶部或底部做个备注:“第X列未填”。 -
InnoDB 的解决方案: 它在“额外信息区”专门开辟了一个小区域,叫做 NULL 值列表(NULL bitmap)。
-
工作原理:
-
按列标记: 只为表中那些允许为
NULL
的列在这个列表里预留位置(1 bit 一位)。 -
二进制标记: 对每一行数据:
-
如果某个允许为
NULL
的列的值是NULL
,那么它在 NULL 值列表中对应的那一位就被设置为1
。 -
如果该列有实际值(非
NULL
),那么对应的位就被设置为0
。
-
-
逆序排列: 这些 bit 位的顺序是按照列在表中定义的序号,但是从右向左排列(逆序)。例如,表中定义的第一个允许为
NULL
的列,对应 NULL 值列表最右边的那个 bit;第二个允许为NULL
的列,对应右边第二个 bit,以此类推。 -
按需扩展: NULL 值列表的大小是动态的,以字节为单位(1字节 = 8 bit)。如果有1-8个允许为
NULL
的列,就用1个字节;如果有9-16个,就用2个字节,依此类推。没用到的 bit 位补0
。
-
3. 数据区:只存“实在货”
-
内容: “数据区” 只存储那些有实际值的列的数据。这包括:
-
主键值(或者 DB_ROW_ID)
-
事务 ID (DB_TRX_ID)
-
回滚指针 (DB_ROLL_PTR)
-
所有定义了
NOT NULL
约束的列的值(不管是不是NULL
,它们都必须有值)。 -
所有允许为
NULL
但实际值不是NULL
的列的值。
-
-
顺序: 这些有实际值的列,按照它们在表中定义的顺序,从左到右依次存储在数据区。
4. 配合工作:如何知道某列是不是 NULL?
当 InnoDB 需要读取一行数据,或者判断某一列的值时,它会结合“数据区”和“NULL 值列表”:
-
定位列: 首先知道要查找的列在表中的序号。
-
检查是否允许 NULL: 如果该列本身就不允许为
NULL
(有NOT NULL
约束):-
它的值一定存储在数据区(并且不能是
NULL
)。 -
直接去数据区对应位置读取即可。
-
-
允许 NULL 时:
-
查 NULL 列表: 找到该列在 NULL 值列表中对应的那个 bit 位(根据列序号和逆序规则计算位置)。
-
Bit = 1: 如果这个 bit 是
1
,说明该列的值是NULL
。不需要去数据区找这个列的数据。 -
Bit = 0: 如果这个 bit 是
0
,说明该列有实际值。这时再去数据区中存放非 NULL 值的区域,按照列定义顺序(跳过那些在 NULL 列表中被标记为1
的列),找到该列对应的实际值进行读取。
-
图解简化:
假设一个表有 5 列: id
(主键, NOT NULL), name
(NOT NULL), age
(INT, 允许 NULL), email
(VARCHAR, 允许 NULL), phone
(CHAR, 允许 NULL)。
-
某一行的实际数据:
id=101
,name='Alice'
,age=NULL
,email='alice@example.com'
,phone=NULL
存储方式:
-
NULL 值列表 (额外信息区):
-
允许 NULL 的列:
age
(第3列),email
(第4列),phone
(第5列) -> 需要 3 个 bit。 -
按逆序排列:
phone
(最右),email
(中间),age
(最左)。 -
值标记:
age=NULL
-> bit=1,email=有值
-> bit=0,phone=NULL
-> bit=1。 -
存储:
101
(二进制,表示phone=1, email=0, age=1
)。由于不足8bit,高位补0,实际存储可能是一个字节0b00000101
(十进制 5)。
-
-
数据区 (真实数据):
-
只存非 NULL 和有值的列:
id=101
,name='Alice'
,email='alice@example.com'
。 -
存储顺序:
101
(id) ->'Alice'
(name) ->'alice@example.com'
(email)。age
和phone
因为都是 NULL,不存储。
-
总结:
InnoDB 通过在“额外信息区”设置一个紧凑高效的 NULL 值列表(位图),用单个 bit 位来标记一行中哪些允许为 NULL
的列的值确实是 NULL
。在存储真实数据的“数据区”,它只存储那些有实际值(非 NULL)的列。这样做的最大好处就是极大地节省了存储空间,特别是对于包含大量可为 NULL
列且这些列经常为 NULL
的表。读取数据时,通过查询 NULL 值列表,就能迅速知道哪些列是 NULL
而无需访问数据区,哪些列有值需要去数据区读取。这是一种非常精妙的空间换时间(更准确地说,是用极小的额外空间换取大量的数据空间节省)的设计。
以上就是数据对真实数据的存储方式。
DB_TX_ID 和 DB_ROLL_PTR 这两个字段的作⽤是什么?
在事务章节将会对这两上字段作详细介绍
三.额外(管理)信息区包含了关于⾏的哪些信息?
额外信息区从右向左分别为:头信息,Null值列表,变长字段列表。
我们可以先大概的讲讲这3个区域的作用啊
这额外信息是 InnoDB 为了高效管理和操作数据行而添加的,不包含用户的实际数据。它又细分为三个区域:
-
变长字段长度列表 (Variable-Length Field Length List):
-
作用: 存储该行中所有变长字段(如
VARCHAR
,VARBINARY
,TEXT
,BLOB
等,以及NULL
值但不存储长度)的实际数据长度。 -
举例:因为我们在MySQL里面其实有很多长度不定的数据类型,比如说varchar(20),varchar(30),那么我们就需要去记录这个varchar的长度,这就需要变长字段长度列表
-
特点:
-
大小不确定: 列表的长度取决于该行包含多少个变长字段以及这些字段值的实际长度。字段越多、值越长,这个列表占用的字节就越多。
-
逆序存储: 列表中各字段的长度信息是按照列定义的逆序存放的(例如,如果表有
col1(VARCHAR)
,col2(VARCHAR)
,col3(VARCHAR)
,那么列表中存储的顺序是col3长度
,col2长度
,col1长度
)。
-
-
重要性: 让 InnoDB 能快速定位变长字段数据的起始位置,因为它们在磁盘上存储的空间不是固定的。
-
-
NULL 值列表 (NULL Value List / NULL Bitmap):
-
作用: 用一个位图 (bitmap) 标记该行中哪些列的值是
NULL
。 -
特点:
-
大小不确定: 列表的长度(字节数)取决于表中允许为 NULL 的列的总数。每 8 个可为 NULL 的列占用大约 1 个字节(不够 8 个也占 1 字节)。例如,表有 9 个可为 NULL 的列,这个列表就需要 2 个字节(9/8 向上取整)。
-
位图表示: 每个可为 NULL 的列对应列表中的一个 bit 位。如果 bit=1,表示该列的值为 NULL;如果 bit=0,表示该列的值不为 NULL。
-
逆序存储: 位图中列的排列顺序也是按照列定义的逆序。
-
-
重要性: 避免在真实数据区为
NULL
值存储实际数据(0字节),节省空间。通过这个列表就能知道哪些列是NULL
。
-
-
头信息 (Record Header):
-
作用: 存储关于该行记录的核心元数据和控制信息。
-
大小: 固定 5 字节 (40 bits)。
-
包含的关键信息 (40 bits 的具体分配):
-
删除标记 (Delete Flag - 1 bit): 标记该行是否已被删除(逻辑删除)。InnoDB 的删除操作是先标记,实际空间可能在后续的
PURGE
操作或新行插入覆盖时才会被真正回收。 -
非叶子节点标记 (Min Rec Flag / B+Tree Node Level - 1 bit): 仅在 B+树非叶子节点(索引记录)中使用。标记该行记录是否是 B+树非叶子节点层级的最小记录。对于叶子节点(用户数据记录),此位通常为 0。
-
目录组内行数 (n_owned - 4 bits): 记录该行所在槽(分组)内包含的数据行数量。页目录(Page Directory)利用这个信息快速定位槽和进行二分查找。一个分组(槽)最多包含 8 条记录,所以 4 bits (最大值 15) 足够。
-
行在页内的位置 (heap_no - 13 bits): 表示该行记录在当前数据页的堆(Heap)中的相对位置编号(序号)。前面提到,Infimum 行的
heap_no
固定为 0,Supremum 行的heap_no
固定为 1,用户记录从 2 开始递增。heap_no
的值在一定程度上反映了记录插入的先后顺序(但不绝对,因为删除后复用空间可能导致heap_no
不连续)。 -
行类型 (record_type - 3 bits): 标识该行记录的类型:
-
0
:普通用户记录(存储在叶子节点)。 -
1
:B+树非叶子节点的索引目录记录。 -
2
:Infimum 记录(页内最小虚拟行)。 -
3
:Supremum 记录(页内最大虚拟行)。
-
-
下一行的地址偏移量 (next_record - 16 bits / 2 bytes): 这是组织行记录成单链表的关键! 它存储的是从当前行记录的头信息开始,到下一行记录的真实数据开始位置之间的字节偏移量。
-
正偏移量: 表示下一行在当前行的后面。
-
负偏移量: 表示下一行在当前行的前面(链表的遍历方向总是向后,但物理位置可能在前,因为删除导致碎片复用)。
-
巧妙设计: 指向“下一行真实数据的开始位置”意味着:
-
从
next_record
指向的位置向右读取就是下一行的真实数据。 -
从
next_record
指向的位置向左读取就是下一行的头信息。这种设计避免了存储绝对指针或额外的长度信息来计算头信息位置,非常高效。
-
-
Infimum 的
next_record
指向第一条用户记录(或 Supremum)。Supremum 的next_record
为 0(NULL)。
-
-
-
重要性: 头信息是 InnoDB 管理行记录的基础,包含了状态、位置、类型、链表连接等最核心的元数据。
-
衍生问题
更具体地来说,头信息,Null值列表,变长字段列表分别存储了哪些信息?
接下来我们分别来介绍这三个部分。
四.头信息区域包含了哪些信息?
分隔线向左是额外信息区,第⼀个是固定占5Byte即40个Bit的头信息区域,头信息区由右向左主要 包含以下信息:
下一行地址偏移量(next_record): 占16bit,通过这个信息将所有的行链接成一个单向链表
行类型(record_type): 占3bit,包括四种类型:
- 0:普通数据行
- 1:索引目录行
- 2:页内最小行infimum
- 3:页内最大行supremum
行在整个页中的位置(heap_no): 占13bit;
分组的行数(n_owned ):占4bit,只在该行是分组最后一行才有值,这样就可以快速查询行数,而不用一条条的累加了
B+树索引树每层最小值标记(min_rec_flag): 占1bit,如果当前行的类型是目录行也就是record_type=1,同时也是B+索引树某层的最小值,则会置为1,会在索引查询时用到,后面我们讲索引时再介绍
删除标记(delete_mask): 占1bit,从页中删除数据行时,并不会直接移除,而是修改这个删除标记为1
预留区:占2bit
我们把 InnoDB 行格式中那个神秘的头信息区(Header),想象成贴在每本书(每行数据)书脊上的一个多功能电子标签。这个标签很小(只有 40 位/5 字节),但功能强大,管理着图书馆(数据页)里书籍的摆放和状态。
头信息区电子标签详解:
-
下一本书的位置 (
next_record
- 16位):-
通俗解释: 想象图书馆的书架是按顺序放的,但管理员为了快速找到某类书,用绳子把同类的书串了起来。
next_record
就是这个绳子连接的下本书的位置编号(偏移量)。 -
作用: 把同一个数据页里的所有数据行(普通书),像串珠子一样串成一个单向链表。管理员(InnoDB)可以顺着这根绳子(链表)快速找到下一页相关的书(行)。书架的首尾(
infimum
和supremum
)也在这个链表中,标记着起点和终点。
-
-
书的类型 (
record_type
- 3位):-
通俗解释: 标签上有个颜色指示灯,告诉你这是本什么性质的书。
-
指示灯含义:
-
白色 (0):普通书: 就是存放你实际数据的普通行。
-
黄色 (1):目录书: 这不是放故事的书,而是图书馆的“索引目录页”(B+树非叶子节点)。它记录的是书架分区信息和关键书的编号。
-
红色 (2):书架起点标记: 一本特殊的“书”,固定在书架的最左边(起点)。它上书脊写着“本书架第一本书在此之前 (
infimum
)”。所有真正的书都排在它后面。它是链表真正的起点。 -
蓝色 (3):书架终点标记: 一本特殊的“书”,固定在书架的最右边(终点)。它上书脊写着“本书架最后一本书在此之后 (
supremum
)”。它是链表真正的终点。
-
-
-
书架上的位置 (
heap_no
- 13位):-
通俗解释: 这是本书在当前这个书架(数据页)里的唯一编号(槽位号)。想象管理员给书架上的每个放书的位置都编了号(1, 2, 3...)。
-
作用: 不管书在物理上怎么移动(比如中间的书被拿走了,后面的书往前挪),管理员通过这个编号总能快速定位到这本书在书架上的具体“槽位”。
infimum
通常是 0,supremum
通常是 1,普通书从 2 开始编号。
-
-
小组长计数徽章 (
n_owned
- 4位):-
通俗解释: 管理员把书架上的书分成了几个小组(比如每组 4-8 本)。只有每个小组的最后一本书(小组长) 的书签上会戴一个特殊的计数徽章。
-
作用: 这个徽章上写着“本小组有 X 本书”。当管理员想快速知道这个书架上总共有多少本书时,他不需要一本本数,只需要把所有小组长徽章上的数字加起来就行了!这比挨个数快多了。普通组员书的这个徽章位置是 0 或 1(没有实际计数意义)。
-
-
重要目录书标记 (
min_rec_flag
- 1位):-
通俗解释: 这个标记只对“目录书”(
record_type=1
)有意义。想象图书馆有多层楼(B+树的不同层级),每层楼都有很多书架区域。在某个楼层的某个书架区域里,管理员会把最重要、代表本区域最小编号的那本目录书,贴上一个小小的金星星。 -
作用: 当读者(查询)要找某个编号范围的书时,管理员可以快速根据这些贴了金星星的目录书定位到具体楼层和区域。普通书和书架标记书没有这个小星星。
-
-
下架待处理标记 (
delete_mask
- 1位):-
通俗解释: 当有读者说“这本书我不要了”(执行
DELETE
语句),管理员不会立刻把书粉碎或移走!他只是在书的电子标签上贴了一个醒目的红色待处理叉叉 (1
)。 -
作用: 标记这本书逻辑上已经被“删除”了。但书还留在书架上(数据页中):
-
绳子(
next_record
)被剪断并重新接好(链表指针更新),这本书暂时从链表中隔离。 -
如果借书操作取消(事务回滚),管理员可以轻松撕掉这个叉叉,书又恢复原状。
-
等后台清洁工(Purge 线程)确认没人需要这本书了,才会真正把它清理掉(空间回收)。
-
-
-
预留区 (2位):
-
通俗解释: 电子标签上还有两个空白的小格子。
-
作用: 这是管理员预留的,暂时没用上。可能是为了以后图书馆升级系统(新功能),或者保持标签大小整齐。现在空着,写个“0”占位。
-
总结:
这个小小的头信息区标签,就像每本书的“智能身份证”,它记录了:
-
位置信息: 下一本书在哪 (
next_record
),自己在哪 (heap_no
)。 -
身份信息: 我是普通书、目录书还是书架标记 (
record_type
)?我是不是重要目录 (min_rec_flag
)? -
管理信息: 我是不是小组长?我管几本书 (
n_owned
)?我是不是被标记下架了 (delete_mask
)? -
预留空间: 留点空位以后用。
管理员(InnoDB)就是通过这些标签,高效地组织书架(数据页)、查找图书(行数据)、管理借还(事务)、以及清理旧书(空间回收)的。它让图书馆(数据库)运行得既有序又高效!
删除一行记录时在InnoDB内部执行了哪些操作?
从页中删除数据行时,并不会直接移除,而是修改 delete_mask 这个删除标记为 1 ,并将
next_record 改为 0 ,同时将上一行的 next_record 指向后续的行,从而把该行从链表中
断开,如果执行事务提交后,则将这行的 next_record 指向一个被称为垃圾链表的区域,这个
链表会被用在事务回滚中,后续在事务中详细介绍
五.Null列表有啥作用?列表中的值是什么?
头信息区再向右就是NULL值列表的可变区域,用来存储数据行中所有列允许为Null的值从而节省空间,具体的实现方式是,用1BIT的大小来表示行中某一列是否为空,这样空列就不需要记录在真实数据区域中了
为每个没有定义 NOT NULL 约束也就是可以为NULL的列在NULL值列表中都安排了一个bit位,按列序号从小到大的顺序从右至左依次排列,这就是常说的逆序排列。NULL值列表最小1字节即8bit,如果没有那么多可以为NULL的列,则会用0补满8bit,如果为值为NULL的列超过8个,则新开辟1字节的空间,依此类推;
如果某列为空,则NULL值列表中对应的bit设置为1,这样只用了一bit就存储了NULL列,非常节省空间
咱们用超市货架和告示板来比喻,秒懂 NULL 值列表 的作用和工作原理!
想象场景:
-
整个超市货架 = 数据库的一行数据
-
货架上的一个格子 = 一行中的一个列
-
格子里放的商品 = 列的真实数据值
-
超市入口的告示板 = NULL 值列表
问题:有些格子允许“缺货”(允许为NULL),怎么高效记录“缺货”信息?
-
笨方法(不推荐):
-
在每个“允许缺货”的格子里,如果没货,就放一张大大的纸条写着“此处无货(NULL)”。
-
缺点: 非常浪费空间!纸条本身占了格子的地方,导致能放真货的空间变少了。而且管理员检查也很麻烦,得一个个格子看纸条。
-
-
InnoDB的聪明方法(NULL值列表):
-
在超市入口处挂一个大告示板(NULL值列表)。
-
告示板只为那些“允许缺货”的格子预留位置(每个位置就是一个开关/指示灯)。
-
一个开关对应一个格子: 告示板上有多少个“允许缺货”的格子,就有多少个开关。
-
开关状态表示缺货:
-
如果某个“允许缺货”的格子没货(值是NULL),管理员就把告示板上对应这个格子的开关打开(设为1)。
-
如果某个“允许缺货”的格子有货(值不是NULL),管理员就把告示板上对应这个格子的开关关闭(设为0)。
-
-
货架本身(数据区)只放真货: 管理员在整理货架时,只把那些真正有货的商品(非NULL的值)以及永远必须有货的格子(NOT NULL列) 里的商品,按照顺序摆上货架。对于告示板上标记为“缺货”(开关=1)的格子,货架上的位置直接空着不放任何东西(不存储NULL值)!
-
查看商品(查询数据):
-
顾客(查询请求)想找某个格子里的商品。
-
管理员先看这个格子是不是“允许缺货”。
-
如果不允许缺货(NOT NULL): 直接去货架对应位置拿商品就行。
-
如果允许缺货:
-
管理员先跑到入口告示板,找到对应这个格子的开关。
-
开关开着(1) -> 大喊:“这个格子没货(NULL)!” 不用去货架看了。
-
开关关着(0) -> 管理员再去货架上存放非NULL商品的区域,按照顺序找到这个格子的商品(因为跳过没货的格子了,顺序可能和格子定义顺序稍有不同)。
-
-
-
-
此外,对于这个告示板(NULL列表)还有一些规定需要解释:
-
指示灯排列有讲究(逆序排列):
-
告示板上的指示灯不是简单地按照货架格子从左到右(1, 2, 3...)的顺序排列。
-
经理采用了一种“从右向左” 的排列方式:
-
想象一下,超市有3个“允许缺货”的格子,按定义顺序是:格子A(第1个定义),格子B(第2个定义),格子C(第3个定义)。
-
经理在告示板上这样安排指示灯:
-
告示板最右边的指示灯 -> 对应格子C (第3个定义)
-
告示板中间的指示灯 -> 对应格子B (第2个定义)
-
告示板最左边的指示灯 -> 对应格子A (第1个定义)
-
-
-
这种“逆序排列”是InnoDB内部处理的一种优化方式。
-
-
告示板可以灵活变大(按需扩展):
-
告示板的最小单元是一块能插8个指示灯的小面板(1字节 = 8 bit)。
-
开关少时: 如果超市只有3个“允许缺货”的格子,经理也拿出一块8指示灯的面板。他只用其中3个(比如最右边3个,对应定义顺序最新的3个格子),左边5个指示灯位置空着不用(可以想象成装了灯座但没灯泡,或者灯座标记为无效)。
-
开关多时: 如果超市有10个“允许缺货”的格子(超过了8个),经理就再拿一块新的8指示灯面板,钉在第一块面板的左边。现在总共有16个指示灯位置。他用掉10个(从定义顺序最新的格子开始,由右向左填满面板),剩下6个空位。
-
依此类推: 9-16个格子需要2块面板(2字节),17-24个需要3块面板(3字节)... 告示板可以按需“扩容”。
-
我们举个例子
假如 我往t5里面插入数据(1,张三,zs@qq.com,NULL),那么它的NULL列表应该是下面这样子
假如 我往t5里面插入数据(2,李四,NULL,NULL),那么它的NULL列表应该是下面这样子 (注意每一行数据行都有NULL值列表)
六. 变长字段列表有啥作用?列表中的值是什么?
首先我们需要明白,哪些因素可以影响一个列的实际长度?
- 列的类型是一个可变长度类型(比如说varchar(M),text,blob)
- 建表时指定的字符编码集合可以影响实际长度
查看编码集所占的字节数
show charset;
使用utf8mb4编码的话,最多使用4个字节表示一个字符
- 英文只占一个字节
- 大部分中文占3个字节
- 复杂的符号和表情占4个字节
比如说'张三'占6个字节,'张三1'占7个字节,建表的时候我们都会指定编码集。
6.1.为什么要记录变长字段中数据的真实长度?
行结构的最左侧是变长字段列表,也叫可变字段长度列表,在这个列表中记录了数据行中所有变长字段的实际长度,这样做的目的,是为了在真实数据区域,可以根据列的长度进行列与列之间的分割;
怎么理解呢?
我们已经知道主键值占8字节或者6字节,事务ID字段 DB_TX_ID占6字节,回滚指针字段 DB_ROLL_PTR占7字节,再往右就是列的值,假如我里面存储的是变长字段,那我要读取多少字节?
这个其实取决于我们填入的数据,但是我们填入的数据的长度又不一定是一样的,比如说我们存的变长字段是varchar(20),我们可以插入'小明','张三丰',根据utf8mb4编码大部分中文占3个字节,那么读取小明这一行的时候需要读6个字节,读取张三丰这一行的时候需要读取9个字节,这就导致了一定的不确定性。
MySQL怎么知道读多少呢?这就需要记录变长字段中数据的真实长度。
需要记录的变长字段类型常见的有varchar、varbinary、text、blob,以及当使用了例如utf-8、gbk等变长字符集的char类型,当char类型的字节数可能超过768个字节时,比如使用utf8mb4字符集时定义了char(255),这个字段的最大字节数是4*255=1020
每个变长字段分配1~2个字节来存放这些字段的真实大小,放置顺序也是按表中字段的顺序从右至左逆序排列;
假如我们分配2个字节,那么,实际上我们能存储的最大字符数其实是65536/4=16383,小于20000,所以下面这个会报错
--看这个创建表的例子
CREATE TABLE test_utf8mb4 (id BIGINT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(20000) NULL
) CHARACTER SET = utf8mb4;
2个字节最大可以表示65535个字节,按照最大长度字符串,比如 utf8mb4,一个字符占用最多4个字节计算,2个字节最多可以表示65535/4=16383个字符,列数据类型varchar的长度上限16383就是根据这个计算来的;
InnoDB规定,一个数据页至少要放2个数据行,默认16KB的页大小中,一个数据行中最大为8KB
需要特别说明的是,如果text、blob存储的内容过大,一个页已经不够放了,就会把这个列放入一个叫“溢出页”的独立空间中,在这个数据行对应的真实数据处,只使用20个字节来标记这个溢出页的位置信息。
6.2.如何记录变长字段的实际长度?
不同的字符集在处理字符对应的最大字节长度不同,以如 ascii 最大1个字节,utf8mb3 最大3个字节,utf8mb4 最大4个字节,如下所示
当使用varchar(M)指定一个字段的最大字符数时,该字段真实使用的字节数与建表时指定的字符集有关,如果指定的字符集中某个字符最大占 N 个字节,从理论上讲,该列最多使用的字节数 M * N,如果 M * N <= 255 则用一个字节记录这个变长字段的长度就足够了
如果 M * N > 255 可能分为两种情况,假设当前变长字段实际占用了 L 个字节:
- L <= 127 用一个字节表示长度
- L > 127 用两个字节表示长度
我们可以举例说明一下
变长字段列表就是贴在箱子外面的尺寸标签,告诉快递员每个箱子实际多大。
标签大小由箱子的最大容量和实际装了多少共同决定:
箱子类型 | 最大容量 | 实际装了多少 | 标签写法 | 原因 |
---|---|---|---|---|
小箱子 | ≤255字节 | 任意数量 | 永远用 1个数字 | 小箱子最大只能装255 |
大箱子 | >255字节 | ≤127字节 | 用 1个数字 | 实际装得少,小标签就够了 |
大箱子 | >255字节 | >127字节 | 用 2个数字(16进制) | 装得多,需要大标签 |
📌 注意:标签写的是实际字节数(不是字符数)
变长字段列表的值的填写顺序:从右向左!
必须按照字段定义的逆序贴标签:和建表顺序相反
比如说:
- 建表顺序:
name
→mail
→remark
- 贴标签顺序:
remark
→mail
→name
具体的我们看个例子
CREATE TABLE test_student ('id' bigint NOT NULL AUTO_INCREMENT,'sn' char(10) NOT NULL,'name' varchar(50) NOT NULL,'age' int NOT NULL,'mail' varchar(100) NOT NULL,'remark' varchar(255) NULL,PRIMARY KEY ('id')
)CHARACTER SET = utf8mb4;
各字段最大字节数计算:
字段 | 声明长度 | 最大字节数 | 长度标识规则 |
---|---|---|---|
name | 50 | 50×4=200 | ≤255 → 固定1字节 |
100 | 100×4=400 | >255 → 动态1/2字节 | |
remark | 255 | 255×4=1020 | >255 → 动态1/2字节 |
数据示例分析
示例1:所有字段非NULL,短文本
INSERT INTO test_student VALUES (1, -- id'2023ABC', -- sn (7字符)'张三', -- name (2字符)20, -- age'a@b.com', -- mail (7字符)'新生' -- remark (2字符)
);
实际长度计算(utf8mb4):
-
name:
张三
= 2字符 × 3字节(因为是中文) = 6字节 -
mail:
a@b.com
= 7字符 × 1字节(因为是英文) = 7字节 -
remark:
新生
= 2字符 × 3字节(因为是中文) = 6字节
长度标识位决策:
-
name:200>255? ❌ → 固定1字节(值=06)
-
mail:400>255? ✅ → 实际7≤127 → 用1字节(值=07)
-
remark:1020>255? ✅ → 实际6≤127 → 用1字节(值=06)
长度标识位存储顺序(和建表时的顺序相反):
remark长度 → mail长度 → name长度
= 06 → 07 → 06
共 3字节
示例2:长文本+NULL值
INSERT INTO test_student VALUES (2, '2023XYZ', '李四', 22,REPEAT('a', 150), -- 150字符NULL -- NULL值
);
实际长度计算:
-
name:
李四
= 2×3 = 6字节 -
mail:150×1 = 150字节
-
remark:NULL(不存储)
长度标识位决策:
-
name:200>255? ❌ → 固定1字节(值=06)
-
mail:400>255? ✅ → 实际150>127 → 用2字节(值=00 96)[150的十六进制]
-
remark:NULL → 不存储
长度标识位存储顺序(和建表时的顺序相反):
mail长度 → name长度
= 00 96 → 06
共 3字节(2+1)
示例3:超长文本
INSERT INTO test_student VALUES (3,'2024ABCD','王小明',21,REPEAT('b', 200), -- 200字符REPEAT('备注', 100) -- 100字符(中文字)
);
实际长度计算:
-
name:
王小明
= 3×3 = 9字节 -
mail:200×1 = 200字节
-
remark:100×3 = 300字节(中文字)
长度标识位决策:
-
name:200>255? ❌ → 固定1字节(值=09)
-
mail:400>255? ✅ → 实际200>127 → 用2字节(值=00 C8)[200的十六进制]
-
remark:1020>255? ✅ → 实际300>127 → 用2字节(值=01 2C)[300的十六进制]
长度标识位存储顺序(和建表时的顺序相反):
remark长度 → mail长度 → name长度
= 01 2C → 00 C8 → 09
共 5字节(2+2+1)
6.3.读取长度时如何处理粘包问题?
在读取变长字段长度时,如何确定读取一个字节还是两个字节?
- 在任何时候都是先读一个字节,然后判断这个字节的高位是否为0,
- 如果是0则表示当前用一个字节表示长度,如果是1则表示当前用两个字节表示长度
- 为1时再读一个字节,然后合并在一起进行解析得到该字段真实使用的字节数,而且第二个BIT位表示是否使用溢出页
- 默认数据页大小为16KB,数据页中一个数据行的大小最大为8KB
数据库引擎采用了一种巧妙的设计来解决这个"粘包"问题。
它总是先读取第一个字节,然后观察这个字节的最高位是0还是1。
这个最高位就像一盏信号灯,指示了后续的处理方式。
如果第一个字节的最高位是0,那么事情就很简单。这表示当前字段的长度标识只占这一个字节。此时,数据库直接取这个字节剩余的低7位作为字段的真实长度值。比如读到二进制00110011
,最高位0表示单字节长度,低7位0110011
换算成十进制是51,说明该字段数据占51字节。
如果第一个字节的最高位是1,情况就稍复杂些。这相当于一个信号,告诉数据库:"这个字段的长度需要两个字节表示"。这时数据库会继续读取紧跟着的第二个字节。得到两个字节后,数据库将它们组合起来计算真实长度:先将第一个字节的低7位左移8位,再与第二个字节进行或运算。例如读到11000011
和00101010
,计算过程是(0000011 << 8) | 00101010 = 768 + 42 = 810
字节。
在读取第二个字节时,数据库还会额外关注它的最高位。这个位专门用来标记数据是否使用了溢出页。当第二个字节的最高位为1时,表示该字段的实际数据量太大,超出了单个数据页的存储能力。这时虽然计算出的长度值很大,但主数据页中实际只存储了前768字节的内容。剩下的数据存放在专门的溢出页中,主数据页只保留指向溢出页的指针。这种设计既保证了大字段的存储可能,又避免了单个数据页被超大行占满的问题。
案例1:短文本(单字节)
存储数据:3字节内容
长度标识: 00000011 (0x03)↑最高位=0 → 单字节
读取过程:读第一个字节:0x03最高位=0 → 长度=3字节
案例2:长文本(双字节无溢出)
存储数据:300字节内容
长度标识: B1 = 10000001 (0x81) → 最高位=1B2 = 00101100 (0x2C) → 最高位=0(无溢出)真实长度 = (0x81低7位=01) << 8 | 0x2C = 0x0100 | 0x2C = 256 + 44 = 300
案例3:超大文本(双字节有溢出)
存储数据:8000字节内容
(超过8KB)
长度标识:B1 = 10011100 (0x9C) B2 = 10100000 (0xA0) → 最高位=1(有溢出!)真实长度 = (0x9C低7位=1C) << 8 | 0xA0= 0x1C00 | 0xA0= 7168 + 160 = 7328但B2最高位=1 → 实际数据有部分在溢出页
七. 其他的⾏格式与DYNAMIC有什么区别?
InnoDB支持四种行格式,不同的行格式数据的存储上有所不同.,分别是:
- REDUNDANT 冗余格式
- COMPACT 紧凑格式
- DYNAMIC 动态格式(默认)
- COMPRESSED 压缩格式
REDUNDANT冗余格式
已被淘汰,之所以存在是为了与旧版本MySQL兼容,不建议使⽤,这⾥不再讨论。
COMPRESSED压缩格式
⾏结构与 DYNAMIC 完全相同,只是会对数据进⾏压缩,以减少对空间的占⽤。
COMPACT紧凑格式
在结构上与 DYNAMIC 相同,只是对超⻓字段的处理上有些区别,它不会把所有超⻓数据都放在溢出⻚中,⽽是会在本⾏中保留前768个字节的数据,多出的部分放在溢出⻚中,溢出⻚的地址额外⽤20个 字节表⽰,那么在本⾏的列中就会占⽤768+20个字节。
如何指定行格式?
可以通过全局变量设置行格式,也可以在创建表中通过 **ROW_FORMAT** 子句指定行格式:
- 通过全局变量设置
SET GLOBAL innode_default_row_format=DYNAMIC;
- 在创建表时明确的指定行格式
CREATE TABLE tl (cl INT) ROM_FORMAT=DYNAMIC;
我们推荐第2种格式。
八.总结
⾄此InnoDB表涉及的所有存储结构都介绍完了,下⾯⽤⼀张图把知识点串联⼀下,帮助⼤家复习总结,如图所⽰: