Innodb进阶

318 阅读14分钟

Innodb存储引擎

从Mysql5.5开始InnoDB是默认的表存储引擎。从InnoDB存储引擎的逻辑存储结构看,所有数据都被逻辑的存放在一个空间中,称之为表空间。表空间又由段、区、页组成。InnoDB存储引擎的逻辑存储结构大致如下图所示:

  • 表空间:表空间可以看做是InnoDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。默认情况下InnoDB存储引擎有一个默认表空间ibdata1,即所有数据都放在这个表空间内。
  • 段:表空间是由各个段组成的,常见的段有数据段、索引段、回滚段等。
  • 区:区是由连续页组成的空间,在任何情况下每个区的大小都为1MB。在默认情况下,InnoDB存储引擎页的大小为16KB,即一个区中一共有64个连续的页。
  • 页:页是InnoDB磁盘管理的最小单位,默认每个页的大小为16KB。常见的页类型有数据页、undo页、系统页等等。
  • 行:InnoDB存储引擎的数据是按行进行存放的。每个页存放的行记录也是有硬性定义的,最多允许存放16KB(2-200)行的记录,即7992行记录。

数据页结构

InnoDB的数据页由以下7个部分组成:

其中,User Records表示实际存储的内容。User Records中可能存储了多条行记录,多条行记录以链式的数据结构存储。

Page Directory(页目录)中存放了记录的相对位置,这些记录指针称为Slots(槽)。槽实际上是一个稀疏目录,即一个槽中可能存放多个数据。槽中的记录按索引键值顺序存放,这样可以利用二分查找迅速找到要查询的记录

注:B+树索引本身并不能找到一条具体的记录,而是先找到该记录所在的页。数据库把页载入到内存,然后通过页目录的数据进行二分查找最后找到查询的数据

索引

聚集索引

按照每张表的主键构造一颗B+树,同时叶子节点中存放的即为整张表的行记录数据

辅助索引

辅助索引叶子节点并不包含行记录的全部数据。叶子节点除了包含键值以外,每个叶子节点的索引行中还包含一个书签(bookmark)。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过主键索引来找到一个完整的行记录。

InnoDB特性

1. 缓冲池

在数据库中读取页的操作时,首先将从磁盘读到页存放在缓冲池中,下一次再读相同的页时,若发现该页在缓冲池中被命中,直接读取该页;否则,读取磁盘上的页

对于数据库中的页的修改操作,则是首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上;这里是通过CheckPoint的机制刷新回磁盘。当缓冲池数据满时,通过LRU算法淘汰最少使用的页。

当事务提交时,先写redo log,再修改页。当由于发生宕机而导致数据丢失时,通过redo log完成数据的恢复。这也是事务ACID中D(Durability)的要求

插入缓冲(Insert Buffer)

通常,使用自增长id作为主键时,可以直接按照主键的顺序进行插入,因此不需要磁盘的随机读取。但是,对于非聚集索引的插入,大部分情况下需要离散的访问非聚集索引页(除用时间字段的二级索引),这时由于随机读取的存在而导致了插入性能的下降。

Innodb开创性的设计了Insert Buffer,对于非聚集索引的插入和更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓冲池中,如果在则直接插入;如果不在,则先放入一个插入缓冲区中,返回插入成功的结果。然后由master thread以一定的频率执行插入缓冲和非聚集索引叶子节点的合并操作,这时通常能将多个插入合并到一个IO操作中(因为多个插入在同一个索引叶节点中),提高了对非聚集索引的插入和更新性能,MySQL官方手册给出的优化结果,采用插入缓冲性能提升15倍

插入缓冲的使用需要满足以下条件:

  • 索引是辅助索引
  • 索引是非唯一索引(如果是唯一索引,需要有离散的读取判断插入数据的唯一性)

2. 自适应哈希索引

InnoDB存储引擎会自动根据访问的频率和模式来自动地为某些热点页建立哈希索引。这仅是数据库自身创建并使用的,不能对其进行人为干预。对这种等值查询的sql非常快速select * from table where index_col = 'xxx',但是对于范围查找就无能为力了。

3. 覆盖索引

InnoDB存储引擎支持覆盖索引,即从辅助索引中就可得到查询的记录,而不需要查询聚集索引中的记录。使用覆盖索引的一个好处是辅助索引不包含整行记录的所有信息,故其大小要远小于聚集索引,因此可以减少大量的IO操作。

例:select primary key, key2 where key1 = 'xxx'

覆盖索引的另一个优点是可以优化统计类sql。例如要进行如下的查询:select count(*) from table

InnoDB存储引擎并不会选择通过查询聚集索引来进行统计。由于table表存在辅助索引,而辅助索引远小于聚集索引,故选择辅助索引减小IO操作。

此外,有一些特殊情况,对于(a, b)的联合索引,只是用列b的查询条件时,一般是不会选择该联合索引的。但如果是统计操作,并且是覆盖索引的,则优化器会进行选择,例如:

select count(*) from table where b > xxx  

这里只根据列b进行条件查询,但由于是统计操作,并且可以用到覆盖索引信息,因此优化器会选择(a, b)的联合索引

4. 索引条件下推(ICP)

索引条件下推是MySQL5.6开始支持的一种根据索引进行查询优化的方式。在MySQL5.6之前进行索引条件查询时,首先根据索引来查找记录,然后再根据where条件来过滤记录。在支持ICP之后,MySQL数据库会在取出索引的同时,判断是否可以进行where条件的过滤,也就是将where的部分过滤操作放在了存储引擎层。在某些查询下,可以大大减少上层SQL层对记录的fetch,从而提高数据库的整体性能。

举个栗子,假设某张表有联合索引(a, b, c),并且查询语句如下:

select * from table where a = 'xxx' and b like '%xx%' and c clike '%xx%'

对于上述语句,MySQL数据库可以通过a的查询条件使用索引,而不会使用bc。若不支持ICP,则数据库需要先通过索引根据a的条件取出所有记录,然后再过滤bc的条件。若支持ICP优化,则在索引取出时,就会进行where条件的过滤,然后再去获取记录,这将极大地提高查询的效率。当然,where可以过滤的条件是要该索引可以覆盖到的范围。

当使用ICP时,MySQL的执行计划的Extra列为Using index condition

InnoDB存储引擎实现了如下两种标准的行锁

  • 共享锁(S Lock),允许事务读一行数据
  • 排它锁(X Lock),允许事务删除或更新一行数据

X锁与任何锁都不兼容,而S锁仅和S锁兼容:

X S
X 不兼容 不兼容
S 不兼容 兼容

在此之上,InnoDb还支持表级别的意向锁(Intention Lock)。意向锁是一种表锁,设计目的主要是为了在一个事务中揭示下一行将被揭示的锁类型。其支持两种类型的意向锁:

  • 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁
  • 意向排它锁(IX Lock),事务想要获得一张表中某几行的排它锁

行锁的类型

InnoDB存在3种行锁:

  • 记录锁(Record Lock):单个行记录上的锁
  • 间隙锁(Gap Lock):锁定一个范围,但不包含记录本身
  • 临键锁(Next-Key Lock):锁定一个范围,并且锁定记录本身

Record Lock总会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键进行锁定

Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,InnoDB对于行的查询都是采用这种锁定算法。例如一个索引有10,11,13和20这四个值,那么该索引可能被Next-Key Locking的区间为:(-∞, 10], (10, 11], (11, 13], (13, 20], (20, +∞)

但是,当查询的索引包含唯一属性时,InnoDB存储引擎会将Next-Key Lock降级为Record Lock,即仅锁住记录本身

锁问题

InnoDB支持以下四种事务的隔离级别:

  • Read Uncommitted:读取未提交的
  • Read Committed:读取已提交的
  • Repeatable Read:可重复读
  • Serializable:可串行化

InnoDB存储引擎默认的隔离级别是Repeatable Read。这里不对每种隔离级别做更进一步的阐述,只是介绍如没有隔离级别可能会带来的问题。

脏读

脏数据是指未提交的数据,如果一个事务在回滚前的更新被另一个事务读取到了,即发生了脏读。简单来说,脏读指的就是在不同的事务下,当前事务可以读到另外事务未提交的数据。例如,在Read Uncommitted的隔离级别下,进行以下操作:

事务A 事务B
1 begin
update table set a = 'xx' where b = 'xxx'
2 select * from table where b = 'xxx'
3 rollback

在第3步时,事务B发生了回滚,此时事务A读到了一行不存在的脏数据,即发生了脏读。脏读仅在Read Uncommitted的隔离级别下才会发生

不可重复读

不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此,在第一个事务的两次读取数据之间,由于第二个事务的修改,那么第一个事务两次读取到的数据可能是不一样的,这种情况称之为不可重复读。例如,在Read Committed的隔离级别下,进行以下操作:

事务A 事务B
1 begin
update table set a = 'xx' where b = 'xxx'
2 select * from table where b = 'xxx'
3 commit
4 select * from table where b = 'xxx'

幻读

幻读又叫幻象读,是不可重复读的一种特殊场景。幻读是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。不可重复读与幻读都是读到其他事务已提交的数据,但是侧重点不同。不可重复读主要是在update场景,而幻读则是在insertdelete场景下

事务A 事务B
1 select * from table where b = 'xxx'
2 begin
insert into table(b) values('xx')
commit
3 select * from table where b = 'xxx'

MVCC

在事务隔离级别Read CommittedRepeatable Read下,InnoDB存储引擎会为每一次变更操作生成行数据的一个快照,每次读取时实际上是读取的行数据的一个快照。快照数据就是当前数据的一个历史版本,每行记录可能有多个版本。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control),即MVCC

快照数据的实现是通过undo段来完成。undo log记录了事务的行为,当我们对记录做了变更操作时就会产生undo log,undo log根据数据变更的先后顺序串成一条版本链,在进行查询操作时,会根据版本链的数据遍历,查询想要的结果。

下面通过一个例子,更清晰的表达出MVCC是如何工作的。在Repeatable Read隔离级别下,我们创建了一张表table,有两个int类型的字段(a, b),且已存在一条数据(10, 10),然后我们进行如下操作:

事务A(id=10) 事务B(id=20) 事务C(id=30)
1 begin
update table set a=20 where b=10

begin
2 begin
...
select * from table
3 commit
4 update table set a=30 where b=10
5 select * from table
6 commit

在id为10的事务A中更新该条数据为(20, 10),此时的版本链为:

此时事务B在第一次查询时会生成一个当前活跃的事务列表[10, 20],该列表在innodb中称为ReadView。每次查询会从记录的版本链的头部往后遍历,若当前查找的版本的事务id小于ReadView中最小的id(表明该版本在事务开始之前就已经提交了),则返回该版本记录,因此查询结果为(10, 10)

之后事务B再将该条数据更新为(30, 10),此时该条记录的版本链为:

事务C在时间5进行再次查询,在Repeatable Read隔离级别下,第一次查询之后的查询都会复用第一次生成的Read View,因此返回的查询结果依然是(10, 10)

若事务隔离级别为Read Committed,则事务在每次查询时都会生成一个ReadView,因此,在事务B的第二次查询时,会重新生成ReadView,此时Read View里的列表为[20],因此此时的查询结果为(20, 10)

总结一下,Read Committed隔离级别下每次查询时总是能读取到已经commit的数据,而Repeatable Read隔离级别下,会在事务第一次select时按当前时刻已提交的数据生成快照,之后的每次查询都是根据第一次查询生成的快照进行查询的,这样就避免了幻读。

锁定读和非锁定读

一致性非锁定读是指InnoDB存储引擎通过多版本并发控制(MVCC)的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB存储引擎会去读取行的一个快照数据。 在InnoDB存储引擎的默认设置下,这是默认的读取方式,即读取不会占用和等待表上的锁,这也是上文分析不可重复读和幻读问题时使用的读取方式。因此,对于非锁定读,Read Committed总数读取被锁定行的最新的快照数据,而Repeatable Read总是读取事务第一次查询时的行版本数据,

但是,在某些情况下,用户需要显示地对数据库读取操作进行加锁以保证数据逻辑的一致性,这就是一致性锁定读。InnoDB存储引擎对于select语句支持两种锁定读的操作:

  • select ... for update
  • select ... lock in share mode

select ... for update对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。select ... lock in share mode对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。此外,这两种锁定读取方式必须在事务中,当事务提交了,锁就会释放。在上文的分析中,非锁定读使用了MVCC避免幻读问题。而对于锁定读,InnoDB存储引擎采用Next-key locking的算法避免幻读问题,即对于非唯一索引,通过对范围加X锁,保证任何对于这个范围的插入都是不允许的,从而避免幻读。这里不再做进一步详细的讨论。