https://baijiahao.baidu.com/s?id=1709263187856706948&wfr=spider&for=pc
MySQL InnoDB 引擎现在广为使用,它提供了事务,行锁,日志等一系列特性,本文分析下 InnoDB的内部实现机制,MySQL 版本为 5.7.24,操作系统为 Debian 9。MySQL InnoDB 的实现非常复杂,本文只是总结了一些皮毛,希望以后能够研究的更加深入些。
Innodb架构图
InnoDB 的架构分为两块:内存中的结构和磁盘上的结构。InnoDB 使用日志先行策略,将数据修改先在内存中完成,并且将事务记录成重做日志(Redo Log),转换为顺序IO高效的提交事务。这里日志先行,说的是日志记录到数据库以后,对应的事务就可以返回给用户,表示事务完成。但是实际上,这个数据可能还只在内存中修改完,并没有刷到磁盘上去。内存是易失的,如果在数据落地前,机器挂了,那么这部分数据就丢失了。
InnoDB 通过 redo 日志来保证数据的一致性。如果保存所有的重做日志,显然可以在系统崩溃时根据日志重建数据。当然记录所有的重做日志不太现实,所以 InnoDB 引入了检查点机制。即定期检查,保证检查点之前的日志都已经写到磁盘,则下次恢复只需要从检查点开始。
内存中的结构主要包括 Buffer Pool,Change Buffer、Adaptive Hash Index以及 Log Buffer 四部分。如果从内存上来看,Change Buffer 和 Adaptive Hash Index 占用的内存都属于 Buffer Pool,Log Buffer占用的内存与 Buffer Pool独立。
缓冲池缓存的数据包括Page Cache、Change Buffer、Data Dictionary Cache等,通常 MySQL 服务器的 80% 的物理内存会分配给 Buffer Pool。
基于效率考虑,InnoDB中数据管理的最小单位为页,默认每页大小为16KB,每页包含若干行数据。为了提高缓存管理效率,InnoDB的缓存池通过一个页链表实现,很少访问的页会通过缓存池的 LRU 算法淘汰出去。InnoDB 的缓冲池页链表分为两部分:New sublist(默认占5/8缓存池) 和 Old sublist(默认占3/8缓存池,可以通过 innodb_old_blocks_pct修改,默认值为 37),其中新读取的页会加入到 Old sublist的头部,而 Old sublist中的页如果被访问,则会移到 New sublist的头部。缓冲池的使用情况可以通过 show engine innodb status
命令查看。其中一些主要信息如下:
----------------------
BUFFER POOL AND MEMORY
Total large memory allocated 137428992 # 分配给InnoDB缓存池的内存(字节)
Dictionary memory allocated 102398 # 分配给InnoDB数据字典的内存(字节)
Buffer pool size 8191 # 缓存池的页数目
Free buffers 7893 # 缓存池空闲链表的页数目
Database pages 298 # 缓存池LRU链表的页数目
Modified db pages 0 # 修改过的页数目
......
通常来说,InnoDB辅助索引不同于聚集索引的顺序插入,如果每次修改二级索引都直接写入磁盘,则会有大量频繁的随机IO。Change buffer 的主要目的是将对 非唯一 辅助索引页的操作缓存下来,以此减少辅助索引的随机IO,并达到操作合并的效果。它会占用部分Buffer Pool 的内存空间。在 MySQL5.5 之前 Change Buffer其实叫 Insert Buffer,最初只支持 insert 操作的缓存,随着支持操作类型的增加,改名为 Change Buffer。如果辅助索引页已经在缓冲区了,则直接修改即可;如果不在,则先将修改保存到 Change Buffer。Change Buffer的数据在对应辅助索引页读取到缓冲区时合并到真正的辅助索引页中。Change Buffer 内部实现也是使用的 B+ 树。
可以通过 innodb_change_buffering
配置是否缓存辅助索引页的修改,默认为 all,即缓存 insert/delete-mark/purge 操作(注:MySQL 删除数据通常分为两步,第一步是delete-mark,即只标记,而purge才是真正的删除数据)。
Change Buffer
查看Change Buffer信息也可以通过 命令。更多信息见
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
insert 0, delete mark 0, delete 0
discarded operations:
insert 0, delete mark 0, delete 0
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
Hash table size 34673, node heap has 0 buffer(s)
自适应哈希索引(AHI)查询非常快,一般时间复杂度为 O(1),相比 B+ 树通常要查询 3~4次,效率会有很大提升。innodb 通过观察索引页上的查询次数,如果发现建立哈希索引可以提升查询效率,则会自动建立哈希索引,称之为自适应哈希索引,不需要人工干预,可以通过 innodb_adaptive_hash_index
开启,MySQL5.7 默认开启。
考虑到不同系统的差异,有些系统开启自适应哈希索引可能会导致性能提升不明显,而且为监控索引页查询次数增加了多余的性能损耗, MySQL5.7 更改了 AHI 实现机制,每个 AHI 都分配了专门分区,通过 innodb_adaptive_hash_index_parts
配置分区数目,默认是8个,如前一节命令列出所示。
Log Buffer是 重做日志在内存中的缓冲区,大小由 innodb_log_buffer_size
定义,默认是 16M。一个大的 Log Buffer可以让大事务在提交前不必将日志中途刷到磁盘,可以提高效率。如果你的系统有很多修改很多行记录的大事务,可以增大该值。
配置项 innodb_flush_log_at_trx_commit
用于控制 Log Buffer 如何写入和刷到磁盘。注意,除了 MySQL 的缓冲区,操作系统本身也有内核缓冲区。
默认为1,表示每次事务提交都会将 Log Buffer 写入操作系统缓存,并调用配置的 "flush" 方法将数据写到磁盘。设置为 1 因为频繁刷磁盘效率会偏低,但是安全性高,最多丢失 1个 事务数据。而设置为 0 和 2 则可能丢失 1秒以上 的事务数据。
为 0 则表示每秒才将 Log Buffer 写入内核缓冲区并调用 "flush" 方法将数据写到磁盘。
为 2 则是每次事务提交都将 Log Buffer写入内核缓冲区,但是每秒才调用 "flush" 将内核缓冲区的数据刷到磁盘。
配置不同的值效果如下图所示:
innodb_flush_log_at_trx_commit配置项图示
innodb_flush_log_at_timeout
可以配置刷新日志缓存到磁盘的频率,默认是1秒。注意刷磁盘的频率并不保证就正好是这个时间,可能因为MySQL的一些操作导致推迟或提前。而这个 "flush" 方法并不是C标准库的 fflush 方法(fflush是将C标准库的缓冲写到内核缓冲区,并不保证刷到磁盘),它通过 innodb_flush_method
配置的,默认是 fsync,即日志和数据都通过 fsync 系统调用刷到磁盘。
可以发现,InnoDB 基本每秒都会将 Log buffer落盘。而InnoDB中使用的 redo log 和 undo log,它们是分开存储的。redo log在内存中有log buffer,在磁盘对应ib_logfile文件。而undo log是记录在表空间ibd文件中的,InnoDB为undo log会生成undo页,对undo log本身的操作(比如向undo log插入一条记录),也会记录redo log,因此undo log并不需要马上落盘。而 redo log则通常会分配一块连续的磁盘空间,然后先写到log buffer,并每秒刷一次磁盘。redo log必须在数据落盘前先落盘(Write Ahead Log),从而保证数据持久性和一致性。而数据本身的修改可以先驻留在内存缓冲池中,再根据特定的策略定期刷到磁盘。
磁盘中的结构分为两大类:表空间和重做日志。
表空间:分为系统表空间(MySQL 目录的 ibdata1 文件),临时表空间,常规表空间,Undo 表空间以及 file-per-table 表空间(MySQL5.7默认打开file_per_table 配置)。系统表空间又包括了InnoDB数据字典,双写缓冲区(Doublewrite Buffer),修改缓存(Change Buffer),Undo日志等。
Redo日志:存储的就是 Log Buffer 刷到磁盘的数据。
为了后面测试方便,我们先建立一个测试数据库 test,然后建立一个测试表 t。
mysql> create database test;
mysql> use test;
mysql> create table t (id int auto_increment primary key, ch varchar(5000));
mysql> insert into t (ch) values('abc');
mysql> insert into t (ch) values('defgh');
建立完成后,可以在 MySQL 目录中看到 test 数据库目录,然后里面有 db.opt, t.frm 和 t.ibd 3个文件。其中 db.opt 保存了数据库test的默认字符集 utf8mb4 和校验方法 utf8mb4_general_ci,t.frm 是表的数据字典信息(InnoDB数据字典信息主要是存储在系统表空间ibdata1文件中,由于历史原因才在 t.frm 多保留了一份),t.ibd是表的数据和索引。
InnoDB 与 MyISAM 不同,它在系统表空间存储数据字典信息,因此它的表不能像 MyISAM 那样直接拷贝数据表文件移动。MySQL5.7 采用的文件格式是 Barracuda,它支持 COMPACT 和 DYNAMIC 这两种新的行记录格式。创建表时可以通过 ROW_FORMAT
指定行记录格式,默认是 DYNAMIC。可以通过命令 SHOW TABLE STATUS
查看表信息,此外,也可使用 SELECT * FROM INFORMATION_SCHEMA.INNODB_SYS_TABLES WHERE NAME='test/t'
查看。
mysql> SHOW TABLE STATUS FROM test LIKE 't' \G
*************************** 1. row ***************************
Name: t
Engine: InnoDB
Version: 10
Row_format: Dynamic
Rows: 2
Avg_row_length: 8192
Data_length: 16384
Max_data_length: 0
Index_length: 0
Data_free: 0
Auto_increment: 3
Create_time: 2019-01-13 02:24:52
Update_time: 2019-01-13 02:28:16
Check_time: NULL
Collation: utf8mb4_general_ci
Checksum: NULL
Create_options:
Comment:
1 row in set (0.00 sec)
InnoDB表使用上有一些限制,如一个表最多只能有64个辅助索引,一行大小不能超过65535等,组合索引不能超过16个字段等,一般应该不会突破限制,详细见
表空间根据类型可以分为系统表空间,File-Per-Table 表空间,常规表空间,Undo表空间,临时表空间等。本节分析 File-Per-Table 表空间。
系统表空间:包含内容有数据字典,双写缓冲,修改缓冲以及undo日志,以及在系统表空间创建的表的数据和索引。
常规表空间:类似系统表空间,也是一种共享的表空间,可以通过 CREATE TABLESPACE
创建常规表空间,多个表可共享一个常规表空间,也可以修改表的表空间。注意:必须删除常规表空间中的表后才能删除常规表空间。
CREATE TABLESPACE `ts1` ADD DATAFILE 'ts1.ibd' Engine=InnoDB;
CREATE TABLE t1 (c1 INT PRIMARY KEY) TABLESPACE ts1;
CREATE TABLE t2 (c2 INT PRIMARY KEY) TABLESPACE ts1;
ALTER TABLE t2 TABLESPACE=innodb_file_per_table;
DROP TABLE t1;
DROP TABLESPACE ts1;
File-Per-Table表空间:MySQL InnoDB新版本提供了 选项,每个表可以有单独的表空间数据文件(.ibd),而不是全部放到系统表空间数据文件 ibdata1 中。在 MySQL5.7 中该选项默认开启。
其他表空间:其他表空间中Undo表空间存储的是Undo日志。除了存储在系统表空间外,Undo日志也可以存储在单独的Undo表空间中。临时表空间则是非压缩的临时表的存储空间,默认是数据目录的 ibtmp1 文件,所有临时表共享,压缩的临时表用的是 File-Per-Table 表空间。
表空间文件结构上分为段、区、页。
表空间文件逻辑结构
段(Segment)分为索引段,数据段,回滚段等。其中索引段就是非叶子结点部分,而数据段就是叶子结点部分,回滚段用于数据的回滚和多版本控制。一个段包含256个区(256M大小)。
区是页的集合,一个区包含64个连续的页,默认大小为 1MB (64*16K)。
页是 InnoDB 管理的最小单位,常见的有 FSP_HDR
,INODE
, INDEX
等类型。所有页的结构都是一样的,分为文件头(前38字节),页数据和文件尾(后8字节)。页数据根据页的类型不同而不一样。
FILE_SPACE_HEADER 页:用于存储区的元信息。ibd文件的第一页 页通常就用于存储区的元信息,里面的256个 XDES(extent descriptors) 项存储了256个区的元信息,包括区的使用情况和区里面页的使用情况。
IBUF_BITMAP 页:用于记录 change buffer的使用情况。
INODE 页:用于记录文件段(FSEG)的信息,每页有85个INODE entry,每个INODE entry占用192字节,用于描述一个文件段。每个INODE entry包括文件段ID、属于该段的区的信息以及碎片页数组。区信息包括 FREE(完全空闲的区), NOT_FULL(至少使用了一个页的区), FULL(没空闲页的区)三种类型的区的List Base Node(包含链表长度和头尾页号和偏移的结构体)。碎片页数组则是不同于分配整个区的单独分配的32个页。
INDEX 页:索引页的叶子结点的data就是数据,如聚集索引存储的行数据,辅助索引存储的主键值。
采用 File-Per-Table 的优缺点如下:
优点:可以方便回收删除表所占的磁盘空间。如果使用系统表空间的话,删除表后空闲空间只能被 InnoDB 数据使用。TRUNCATE TABLE 操作会更快。可以单独拷贝表空间数据到其他数据库(使用 transportable tablespace 特性),可以更方便的观测每个表空间数据的大小。
缺点:fsync 操作需要作用的多个表空间文件,比只对系统表空间这一个文件进行fsync操作会多一些 IO 操作。此外,mysqld需要维护更多的文件描述符。
InnoDB 表空间文件 .ibd 初始大小为 96K,而InnoDB默认页大小为 16K,页大小也可以通过 innodb_page_size
配置为 4K, 8K...64K 等。在ibd文件中,0-16KB偏移量即为0号数据页,16KB-32KB的为1号数据页,以此类推。页的头尾除了一些元信息外,还有Checksum校验值,这些校验值在写入磁盘前计算得到,当从磁盘中读取时,重新计算校验值并与数据页中存储的对比,如果发现不同,则会导致 MySQL 崩溃。
ibd文件存储结构如下所示:
ibd文件存储结构
InnoDB页分为INDEX页、Undo页、系统页,IBUF_BITMAP页, INODE页等多种。
第0页是 页,主要用于跟踪表空间,空闲链表、碎片页以及区等信息。
第1页是 IBUF_BITMAP
页,保存Change Buffer的位图。
第2页是 页,用于存储区和单独分配的碎片页信息,包括FULL、FREE、NOT_FULL 等页列表的基础结点信息(基础结点信息记录了列表的起始和结束页号和偏移等),这些结点指向的是 FSP_HDR 页中的项,用于记录页的使用情况,它们之间关系如下图所示。
第3页开始是索引页 INDEX(B-tree node)
,从 0xc000(每页16K) 开始,后面还有些分配的未使用的页。
可以在 innodb_sys_tables
表中查到表t的表空间ID为28,然后可以在 innodb_buffer_page
查到所有页信息,一共4个页。分别是 , , , 。
select * from information_schema. where name='test/t';
select * from . where SPACE=28;
InnoDB引擎索引页的结构如下图,可以用 hexdump查看 t.ibd 文件,然后对照InnoDB页的结构分析下各个页的字段。
索引页结构
# hexdump -C t.ibd
0000c000 95 45 82 8a 00 00 00 03 ff ff ff ff ff ff ff ff |.E..............|
0000c010 00 00 00 00 00 28 85 7c 45 bf 00 00 00 00 00 00 |.....(.|E.......|
0000c020 00 00 00 00 00 1c 00 02 00 b0 80 04 00 00 00 00 |................|
0000c030 00 9a 00 02 00 01 00 02 00 00 00 00 00 00 00 00 |................|
0000c040 00 00 00 00 00 00 00 00 00 2f 00 00 00 1c 00 00 |........./......|
0000c050 00 02 00 f2 00 00 00 1c 00 00 00 02 00 32 01 00 |.............2..|
0000c060 02 00