官方文档是这样定义行格式的:表的行格式决定了其行的物理存储方式,这反过来又会影响查询和 DML 操作的性能。
MySQL数据放在哪
我们知道,MySQL架构大致分为Server层、存储引擎层,server层负责与客户端的连接、SQL语句的解析、SQL的成本计算和优化等;存储引擎层有很多实现,我们最常用的时InnoDB引擎,其他的还有MyISAMySQL、NDB、Memory、Archive、Federated、Maria等存储引擎
以linux为例,当我们建立一个数据库时,会在/var/lib/mysql目录下,新建一个以数据库命名的目录,用来存储数据库的表结构信息、表数据、还有字符串和比较规则
- db.opt文件,用于存储数据库默认的字符集和校验规则
- {表名}.frm,用来存储数据库中的表结构,记录表有哪些字段,分别是什么格式的
- {表名}.idb,用来存储表的数据, 文件也称为独立表空间
表数据可以存在共享表空间(ibdata1),可以存在独占的表空间,由参数innodb_file_per_table控制,默认时1,每张表的数据都独立放置于一个.idb文件中中
表空间结构
行(Row)
首先,InnoDB是按行进行数据存储的,最原子的存储结构就是行数据,行数据的存储结构我们后面展开。
如何指定行格式?
ini
CREATE TABLE {表名}
(列信息)
ROW_FORMAT={行格式};
页(page)
因为数据最终存储在磁盘中,不可能每次批量读写都针对行数据进行多次磁盘IO,同时我们的很多查询,都是范围查询,所以需要满足空间局部性原理
空间局部性原理:空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问
行数据,都存储在页中,每页的默认大小为16kb,还可以调整为4kb、8kb、32kb、64kb,以页的方式进行磁盘交互,同时行数据存储在连续的页空间中,读写效率比较高
页分为多种类型,存储数据的数据页、用户数据回滚到的undo log页等,B+树索引也用页做数据的记录
我们知道,聚簇索引B+树叶子结点用于存储数据,通过双向链表进行连接,非叶子结点存储索引数据,每个节点内部通过单向链表进行数据连接,叶子结点层双向链表便于进行数据范围查询,不用再从头结点向下进行查询,减少了IO次数。思考一下下面这个问题
B+树非叶子结点间,是否需要双向链表进行连接?
常见的B+树索引图,通常不关注非叶子结点之间的连接,非叶子结点间,也是通过双向链表进行连接的
篇幅有限,长话短说,下载源码,在storage/innobase/btr目录,找到btr0btr.cc,查看btr_create创建索引方法,其中有如下两行,为节点创建prev指针和next指针,并没有区分是否为叶子结点
scss
btr_page_set_next(page, page_zip, FIL_NULL, mtr);
btr_page_set_prev(page, page_zip, FIL_NULL, mtr);
非叶子结点间双向链表的作用,体现在MySQL的成本计算中
假如有一张user表,在age和score列分别建了索引,面对如下SQL,选age列的索引还是score列的索引,MySQL应该如何选择?
sql
SELECT * FROM user WHERE age > 10 and age < 50 and score > 60 and score < 90;
MySQL的成本计算,会基于CPU、IO成本,判断是否使用索引,使用哪个索引,通过非叶子结点之间的双向链表,仅通过非叶子层的遍历,就可以判断使用本索引大概需要搜索多少个列、匹配的数据有多少个
回到上面的例子,假如age索引涉及的数据在非叶子结点一个页中,且数量不多,而socre索引中满足条件的数据跨2个页,基本可以得出结论:age索引,数据少,在非叶子结点占用页少,涉及到的叶子结点一定少,IO成本低,走age索引效率更高
区(extent)
InnoDB使用B+树来管理数据,我们知道页是一块连续的存储空间,存储页中的行数据,节点(页)之间通过指针进行相互连接 如果进行跨页的范围查询,就涉及到随机IO,而随机IO的效率很低
InnoDB为了提升IO效率,在数据量大时,就不按页分配存储空间了,而是按【区】分配。每个区1MB,也是64个默认页大小的空间,连续的64个页被划为同一个区。这使得在双向链表中连续的多个页,在磁盘存储中也相邻,跨页的查询,很大一部分变为顺序IO。
区存储固定64个页,页默认大小变 -> 区大小变
ex: 页32KB -> 区2MB
段(segment)
【段】由一个或多个分区组成,在默认的表空间配置(每个表数据单独存入.idb文件)中,段分为两类:
- 数据段,表中所有数据都属于数据段
- 索引段,表中的每个索引,属于自己的段
行数据是如何存储的
InnoDB通过MVCC(多版本并发控制)通过控制生成Read View的时机实现不同隔离级别,我们知道每行中有2个一定有的隐藏字段,
- 事务id(trx_id),标识本条数据,由哪个事务生成
- 回滚指针(roll_ptr) ,记录上个版本的指针
- 如果未定义主键,还有有一个主键隐藏字段row_id
InnoDB提供4种行格式:
- Redundant
-
- 非紧凑行格式,5.0以前使用,几乎没人用了,这个不管
- Compact
-
- 紧凑的行格式,相较于Redundant有20%的存储效率提升,5.1~5.6的默认行格式,下面的行格式,基于Compact展开
- Dynamic和Compressed
-
- 基于Compact做了改进,5.7版本后,默认使用Dynamic
对于行数据存储来说,上面2个隐藏字段和表中的其他数据,统归于【真实数据】,行记录之外,还有【记录的额外信息】
变长字段长度列表
在MYSQL中,我们把VARCHAR、VHARBINARY、TEXT、BLOB这几种不确定数据长度的类型,统称为变长字段。【变长字段长度列表】用来存储本行中各变长字段的实际长度,真实的记录还是记在真实数据区的,每个可变长度列的实际长度使用1到2个字节,具体视列的最大长度而定。
【变长字段长度列表】存储位置在行记录的开头部位,各变长记录占用的字节数,逆序存放
举个简单例子
比如有一张user表,
sql
CREATE TABLE user (
`id` int(11) NOT NULL,
`name` VARCHAR(20) DEFAULT NULL,
`phone` VARCHAR(20) DEFAULT NULL
) ROW_FORMAT=COMPACT;
name、phone都为varchar类型,有如下几条记录
id | name | phone |
---|---|---|
1 | a | 11 |
2 | b | 123 |
id为1的数据,看一下在变长字段列表中的存储,id不是变长字段,所以略过
- name值为a,占用1个字节,16进制表示为0x01
- phone值为11,占用2字节,16进制表示为0x02
在变长字段长度列表中,逆序存放 (为什么逆序存放,放在记录头信息后面说)
NULL值列表
大部分的博客中,为了便于理解,都将NULL值列表单独作为一个字段来讲,但实际上,官方文档中,NULL值列表只是归属于变长字段列表的一个指针,指针初始大小为1字节。
某些列不设置非空时,可以为空,如果将对应列中填入一个NULL值,假如NULL占用1字节的空间,如果某行有很多个NULL列,也会占用相对多的空间。
NULL值列表这部分,使用类似Bitmap的思想,每个列是否为NULL,使用一个bit表示
- 0表示不为NULL,
- 1表示为NULL
NULL值列表这部分只能使用整数个字节(8bit/字节)的位表示,如果列数不是8的倍数,那么高位使用0补齐 同样的,NULL值列表的存储,也是逆序的
记录头信息
头信息包括:
- delete_mask,标识数据是否被删除
-
- MySQL删除一条数据,不会直接去磁盘里面把记录删掉,通过一种软删除手段(脏页)进行删除,所以需要标记某行数据是否已经真正被删掉了
- next_record,记录下一行记录的位置
-
- 在数据页中,记录间通过单链表进行连接,这里的next_record就是维护这层单链表结构的指针
- record_type,当前记录的类型
-
- 类型包含:0普通记录,1非叶子结点记录,2最小记录,3最大记录
- 最小记录、最大记录,用于标识单链表的头尾指针,上一个页的最大记录,指向当前页的最小记录;当前页的最大记录,指向下一个页的最小记录
- ......
为什么是变长字段列表、NULL值列表都是逆序的?
下面这张图应该可以做解释了,进行行的遍历时,行指针(next_record)放在头信息部分,位置处于额外信息和真实数据中间
当获取某个字段的真实长度或获取是否为空时,只需要从next_record位置分别向前、前后顺序遍历即可找全字段的 额外信息&真实数据
页溢出是怎么回事
TEXT、BLOB数据类型,可以视为不限制存储长度,VARCHAR类型最多存储65535字节,65535字节≈60kb,数据页默认16kb,VARCHAR拉满的情况下,存储一个VARCHAR数据甚至要几个页,一个页存不下,溢出到另外的页一起存储,这就是页溢出
InnoDB是如何处理页溢出的?
COMPACT行格式,将可变字段(VARCHAR、VARBINARY、BLOB、TEXT)的前768个字段,存储在页中,剩余的存到溢出页中,使用20字节大小的指针指向对应页地址
Dynamic行格式,和Compact的区别为Dynamic在当前行中不存数据,仅存储数据指针
如何为我所用
我们大概可以了解到几点:
- InnoDB针对每一行记录的存储,分成【真实数据】和【额外信息】两部分
- 对于NULL列、变长列,在【额外信息】中存储是否NULL、真实长度,在【真实数据】中存储实际数据,两部分数据合起来才是对应字段才完整
- 当前默认使用的行格式为Dynamic,变长字段存储可能产生页溢出(超过768字节),实际存储在其他页中
- 真实页的存储形式,会按区进行存储,相邻的多个页存储在相邻的磁盘空间中,以顺序IO优化随机IO
针对这几点,个人感觉可以转化为日常需要注意的:
- 对于字符串额存储,长度可以确定(如身份证号等)的话,使用定长类型做定义,避免使用额外的页存储动态长度数据
- 变长数据的存储,控制好长度,超过768字节(比如varchar(255),使用utf8mb4,很可能出现)会产生页溢出,实际数据存到其他页,带来更多的IO次数
其他可能需要关注的
- varchar类型,trim删除头尾空格后再存储
-
- varchar类型,尾部空格也会被记入动态长度,如果恰好处于1~2个字段标识可变长度的临界区,可能会浪费更多的存储空间
- 固定长度的字段,也需要注意存储长度
-
- 大于等于768字节的固定长度 列或可变长度列,可以存储在页外,存储实际数据时,对于定义大长度字段需要更谨慎,越多的大长度字段 -> 越多的磁盘IO次数 -> 查询效率越低,可以考虑分别存储,比如部分数据存入文件存储,用id建立关联
- 字段长度的存储长度,需要结合字符集来看
-
- 使用utfmb3,utfmb4这类可变长度字符集,会进行尾部空格的修剪,char(n)意为最多存储n个字符,如果使用utfmb3,定义char(255),此时最多占用765个字节,不会产生页外存储;如果使用utfmb4,若实际存储长度超过768,就会产生页外存储
参考:
MySQL 是怎样运行的:从根儿上理解 MySQL 掘金小册(强烈推荐读一下)
小林coding-# MySQL 一行记录是怎么存储的?
MySQL官方文档