InnoDB
简介
InnoDB引擎设计上借鉴了Oracle数据库的架构,是一个事务安全的存储引擎,其最早有Innobase Oy公司研发,从Mysql 5.5开始就是默认的存储引擎。
Mysql支持行锁,MVCC,一致性非锁定读等特性,是mysql在OLTP应用中首选的存储引擎。
InnoDB引擎的整体架构可以分为几个模块:
- 线程模型,InnoDB的线程模型较为复杂,有处理请求的IO线程,还有大量定时执行的线程。
- 内存模型,InnoDB引擎主要的内存区域是Buffer Pool,Buffer Pool同时承担Buffer和Cache的功能,即为读请求提供缓冲,也为写请求提供缓冲区。
- 存储模型,InnoDB引擎提供了B+树等数据结构作为索引。
- 事务模型,数据库事务的ACID,InnoDB引擎分别通过以下能力实现:
- 原子性,通过UndoLog回滚操作实现原子性。
- 一致性,提供唯一索引,外健等机制进行数据约束。
- 隔离性,不同的隔离级别实现的方式不同,对于默认的可重复读隔离级别通过MVCC、间隙锁等实现。
- 持久性,通过RedoLog等机制实现。
原理
线程模型
线程类型 | 主要功能 |
---|---|
Master线程 | 根据InnoDB引擎的当前状态执行不同的循环,loop/background loop/flush loop/suspend loop。
这些循环的主要功能包括: 将日志缓冲/脏页刷新到磁盘,合并插入缓冲,产生checkpoint,清理无用的undo页,table cache等 |
IO线程 | IO线程主要分为4类:
read线程处理读请求, 并负责将数据页从磁盘上读取出来, 可以通过参数设置线程数量。 write线程负责将数据页从缓冲区写⼊磁盘, 也可以通过参数设置线程数量, page_cleaner线程发起刷脏页操作后,write_thread 就开始执行。 redo_log_thread负责把⽇志缓冲中的内容刷新到 Redo log ⽂件中。 change buffer thread把插入缓存(change buffer)中的内容合并刷新到磁盘中。 |
page cleaner线程 | 负责脏页刷新的线程,可以通过参数设置。 |
purge线程 | 删除无用的undo页 |
checkpoint线程 | 缓冲区的页面定时刷新到磁盘的过程称为checkpoint。
日期刷新数据到磁盘可以缩短出现故障时候数据库的恢复时间,缓冲区的数据空间也可以得到限制。redo log发生切换时,也会执行checkpoint。 为了避免内存数据的丢失,数据库普遍采用WAL(Write Ahead Log)的策略,即先写日志,然后再写缓冲区的页。 |
内存模型
InnoDB存储引擎的持久化数据存在磁盘上,磁盘上记录的数据按照页的方式进行管理,出于性能考虑,InnoDB引擎设计了Buffer Pool(缓冲池),用于缓存磁盘上的数据,同时用于写操作的buffer。
InnoDB引擎对数据库的读写操作,都会首先从磁盘上加载数据到Buffer Pool,对于写操作会先修改Buffer Pool的数据,然后再以一定频率刷到磁盘上。
通常情况下,Cache和Buffer两个词的语义是有差别的,Cache一般是指数据缓存,主要解决的频繁读取热点数据的性能问题,Buffer一般是指缓冲,是指写请求首先写入缓冲区,之后再批量更新到持久化存储,解决的是写操作的性能问题,但是InnoDB引擎的Buffer Pool兼顾了二者的作用。
Buffer Pool默认的空间是128MB,这个值可以通过变量innodb_buffer_pool_size进行查询,在启动的时候也可以在配置文件中修改该变量的值。
SHOW VARIABLES LIKE 'innodb_buffer_pool_size'
Buffer Pool以页(Page)为单位,页的大小一般为16K,和磁盘存储的页大小保持一致。数据库启动的时候会按照Chunk为单位申请Buffer Pool的内存空间,一个 chunk 默认就是128M,一个Chunk是一片连续的空间,这些空间按照16K一页的方式组成连续的缓存页。
除了缓存页之外,每个页还会有一个控制块,控制块记录了缓存页的表空间、页号、缓存页地址、链表节点。
在InnoDB运行过程中,缓存页的状态分为三种:
- free page:未被使用的空闲page
- clean page:被使用的page,但是数据未被修改。
- dirty page:被修改过的数据,和磁盘数据相比已经发生变化。
在需要缓存新数据的时候,Buffer Pool需要可以获取哪些缓存页还未使用,可以从这些缓存页中获取一个作为缓存。所以Buffer Pool设计了Free链表,在初始化的时候,所有的缓存页都在Free链表中,一旦这个缓存页缓存了数据,就会从缓存链表中移除,缓存链表的节点就是控制块。
被修改过的数据,需要刷新到持久化存储,为了提升效率,避免低效的轮训,Buffer Pool还设计了Flush链表,原理和Free链表基本一致。
在数据库查询过程中,InnoDB引擎需要知道持久化存储上的数据页是否已经缓存在Buffer Pool,InnoDB用Hash表存储这个映射关系,Hash表的Key就是表空间+持久化存储的页号,value是缓存页的地址。
因为Buffer Pool的空间有限,一般要比持久化存储的空间低一个数量级以上,所以Buffer Pool需要管理缓存数据,定期或者在需要的时候需要将Buffer Pool中的缓存页转换到持久化存储,从而可以缓存新的页面。
InnoDB使用LRU算法来管理缓存的更新以及淘汰。
但是相较于传统的LRU算法,InnoDB做了以下有化:
LRU链表分为两个部分,分别是冷数据区和热数据区,当数据第一次访问被缓存到缓存页的时候,只会进入冷数据区,只有此后一段时间内(默认1s)再次被访问,才会被移动到热数据区。这个优化可以避免临时读入的数据影响索引数据等真正热点的数据。LRU链表中冷热区域的分界点称为midpoint位置,默认情况下,midpoint位置位于LRU链表67%的位置,该位置之前数据为热点区。在缓存页不足需要淘汰一些缓存页时,就是从LRU链表中的冷数据区域的尾部开始将缓存页刷入磁盘,提供给加载新的数据页使用。
一般热数据区域里的缓存页是被经常访问的,所以频繁移动会带来一定的性能损耗, InnoDB引擎只有热数据区域的后3/4部分的缓存页被访问了,才会给你移动到链表头部。假设热数据区域的链表里有100个缓存页,那么排在前面的25个缓存页,即使被访问了,也不会移动到链表头部去的。但是对于排在后面的75个缓存页只要被访问,就会移动到链表头部去。
除了常规16K大小的缓存页,InnoDB引擎1.0之后就支持压缩页的功能,将默认16K的页压缩为1KB,2KB,4KB和8KB等。对于非16K的页面,通过unzip_LRU列表进行管理的,申请新页面的时候通过伙伴算法进行分配。例如申请4KB的页面,先在4k的unzip_LRU查找,如果有直接使用,否则的话在8KB的unzip_LRU列表中查询,如果可以找到空闲页,则存放在2个4KB页面到unzip_LRU中,如果8KB没有空闲页,则查找16KB的空闲页,分为1个8KB,2个4KB的页面放到对应的unzip_LRU列表。
redo log日志也会先放入缓冲区,成为redolog缓冲,但是redolog刷新频率较高,一般情况每秒都会刷新,所以该缓冲区空间不会特别大,默认8MB。
Buffer Pool中保存的数据主要包括索引页,数据页,传入缓冲,锁信息等。
- 索引页
索引是数据库进行数据查询的辅助结构,对查询性能的影响非常密切。
假如数据库的主键是bigint(8个字节),索引中指向下一层节点的指针为6个字节,那么索引中一条记录是14字节,一个缓存页16K可以保存的记录数是16*1024/14=1170条。
所以对于一个表,如果是两层B+树索引占用内存一共会有(1+1170)*16K≈20M。
三层B+树索引占用的内存大约是(1+1170+1170*11170)*16K≈20G。
数据库中一般有多张表,而且除了主键索引之外,还会有其他的辅助索引,为了使索引页常驻内存,要求B+树的高度不要超过2,所以常见的B+树加上叶子层的数据,一般是3层。
- 插入缓冲
因为非聚簇索引是随机写,为了提高插入的性能,会把数据先存储在Insert Buffer对象中,定时将Insert Buffer和辅助索引的子节点合并,但是只有非唯一的辅助索引才可以使用插入缓冲,因为在Insert Buffer中没有办法校验索引的唯一性。
除了Insert buffer,目前InnoDB引擎还有Update buffer等。统称change buffer,change Buffer也使用B+树存储。
- 锁信息
InnoDB引擎使用的锁都在Buffer Pool的内存区进行分配。
除了Buffer Pool之外,InnoDB模型还保存其他的内存区域,包括redolog缓冲区,double write区域。
innodb的页面大小一般是4kb,8kb,16kb,32kb或者64kb,而通用的磁盘院子写入的单位是sector size,一般是512字节,所以数据页写入的过程中,如果出现宕机会出现部分数据写入成功的场景,违反了数据页原子性的要求。所以写入数据的时候,会首先写入double write专门的区域,然后再写数据页。
数据模型
缓冲区的页面定时刷新到磁盘的过程称为checkpoint,为了避免内存数据的丢失,数据库普遍采用WAL(Write Ahead Log)的策略,即先写日志,然后再写缓冲区的页。
事务支持
原子性:undo log
持久性:redo log
一致性:undo log + redo log
隔离性:锁 + MVCC
数据库是通过redolog支持持久性的。InnoDB引擎支持插入更新的过程中,在写入缓冲区的时候会通过WAL的方式首先记录redo日志。
InnoDB在崩溃的时候,redo日志没有提交标志,会进行对应的回滚操作。
在事务提交的过程,引擎会多次写入redolog,但是binlog会在事务提交成功之后一次写入,提前保存在binlog cache中。
事务的原子性是通过undo日志实现的,每次数据修改之前,程序会把修改之前的数据保存到undo log。
除了原子性之外,undolog还用来实现MVCC。
锁
Mysql中的锁分为多种类型。
为了提高并发性能,InnoDB引擎引入了多版本控制协议(MVCC),如果读取数据的行正在执行写操作,读操作不需要等待行锁的释放,可以通过去undo日志查询快找进行。
共享锁和排他锁
InnoDB引擎实现了行级的共享锁以及排他锁,简称为S锁和X锁。其中S锁运行持有锁的事务执行查询操作,同一行记录可以同时有多个S锁,X锁运行持有锁的事务进行记录的更新/删除等。
意向锁
意向锁是表级锁,表达事务想要的锁类型。意向锁可以提升数据库的性能,例如事务1使用排他锁锁定了数据表的一行记录,如果没有意向锁,事务2使用排他表锁锁定整个数据表的时候,没办法方便的判断数据表中的记录是否有被其他事务锁定。
有两种类型的意向锁
IS,共享意向锁,当事务希望获取某一行的S锁,则必须先申请表级别的IS锁。
IX,共享排他锁,当事务想要获取某一行的X锁,则必须先申请表级别的IX锁。
场景1
A使用排他行锁锁住了user表的id=5的这行数据,并对user表加了意向排他表锁。
B使用排它/共享表锁(比如上面说的lock tables ...read/write)去锁user表,因为A表存在意向排他表锁,则加锁失败。
即IX和X与S都冲突
场景2
若A使用共享锁锁住了user表的id=5的这行数据,并对user表加了意向共享表锁。
若B使用共享表锁(比如上面说的lock tables ...read)锁user表,而共享锁与意向共享锁是兼容的,所以B加锁成功。
若B使用排他表锁(比如上面说的lock tables ...write)锁user表,而排他表锁与意向共享表锁是冲突的,所以B加锁失败。
即IS与X冲突,与S兼容。
A申请user表的意向共享表锁,并锁住id=5这行数据
B申请user表的意向共享表锁,并锁住id=6这行数据
B申请user表的意向排他表锁,并锁住id=7这行数据
也就是因为意向锁之间相互兼容,所以意向锁对行级锁之间是不冲突的。
X | IX | S | IS | |
X | Confict | Confict | Confict | Confict |
IX | Confict | Compatible | Confict | Compatible |
S | Confict | Confict | Compatible | Compatible |
IS | Confict | Compatible | Compatible | Compatible |
间隙锁
间隙锁是封锁索引记录中的间隔,或者第一条索引记录之前的范围,又或者最后一条索引记录之后的范围。
产生间隙锁的条件(RR事务隔离级别下;):
- 使用普通索引锁定;
- 使用多列唯一索引;
- 使用唯一索引锁定多行记录。
对于唯一索引而言:
- 对于指定查询某一条记录的加锁语句,如果该记录不存在,会产生记录锁和间隙锁,如果记录存在,则只会产生记录锁,如:WHERE `id` = 5 FOR UPDATE;
- 对于查找某一范围内的查询语句,会产生间隙锁,如:WHERE `id` BETWEEN 5 AND 7 FOR UPDATE;
对于非唯一索引:
- 在普通索引列上,不管是何种查询,只要加锁,都会产生间隙锁,这跟唯一索引不一样;
- 在普通索引跟唯一索引中,数据间隙的分析,数据行是优先根据普通索引排序,再根据唯一索引排序。