MySQL分层架构与运行机制详解

一、MySQL 分层架构

​MYSQL的总体架构可以分为三层,分别是客户端层、服务层、存储引擎层(这里有的人喜欢拆分成两个单独的存储引擎层和文件系统层,但因为它们是相互交互的,各自的存储引擎有不同的交互方式,所以我划分为一层)。

连接层:

主要工作是为了建立与客户端的连接,校验用户的访问权限。

服务层:

这一层由多组件组成,如下图所示。

  • SQL Interface(sql接口):用于接受sql并返回处理结果,可以理解为接口的提供者,相当于它提供了一种规范,其它组件只要根据它提供的参数返回约定好的处理结果;
  • Parser(语法解析器):根据词法解析和语法解析把sql语句拆分层一个语法树。当mysql没有走缓存的话,就会进入词法分析器。它的作用是分析当前语句是查的操作还是增删改的操作,以及有没有语法错误,以及where,order by,group by等关键字;
  • Optimizer(优化器):将解析树进行分析,算出一份或多份执行计划,再根据底层算法选择最优的执行计划(该执行计划不一定是最优计划)。优化器顾名思义就是用来优化查询的sql的。比如,当你的sql的where中出现了多个索引字段,优化器会选择使用其中效率最高的索引进行查询或排序(因为执行一条sql是不可能走多棵B+树,只会走一棵树)。又比如多表联查的时候决定是先查A表还是B表;
  • 执行器:主要工作是校验用户的执行权限,并执行优化器提供的“最优执行计划”。执行器才是真正从存储引擎存取数据的组件。不同的存储引擎会提供不同的接口,执行器会调用这些接口来进行查或增删改。如果mysql开启了缓存,就会将查到的结果集存到缓存区里面,然后将结果集返回客户端;
  • Caches&Buffers(缓存池):当sql复用或执行相同的查询语句时(差一个空格都不行),只要表中的数据没有变动就会直接从缓存中返回结果,缓存池以key-val的形式进行存储,key为sql语句,val为结果集。注意:在MySQL8.0及之后的版本中取消了缓存池,交给了专业的ORM框架来处理,如下所示:
  • Enterprise Management Service(管理器):mysql的集成管理,例如对数据的管理,如备份,恢复,和数据库安全的管理,以及各组件之间的管理;
  • connection Pool(连接池):负责管理连接池,包括创建连接对象和管理连接对象的生命周期,以及连接对象的存放和获取。除此之外还负责校验权限,因为我们知道不是所有用户都有全部权限的,root用户是所有权限都有,而有些用户可能只有查的权限没有读的权限。一个用户的权限是放在mysql库的user表中的。当连接建立之后,mysql会查询这个用户在user表中的权限,并将该用户在user表中的行存储在一个叫做“连接管理对象”中。用户每次发送sql命令过来的时候,都会在这个连接管理对象中查询用户有没权限执行它发的sql; 

存储引擎层:

负责将数据持久化到磁盘中或者将磁盘中的数据读取出来,这里因为引擎内容太多,并且mysql是嵌入式引擎,不同引擎间的实现和细节也不一样。

二、MySQL 存储引擎类型

1、存储引擎类型

MySQL存储引擎有以下几种:

  • InnoDB:支持事务,行锁、外键、非锁定读(读数据不上锁)、使用多版本并发控制(MVCC)提高并发性、隔离级别为repeatable并使用next-key locking 策略避免幻读、采用聚集索引。需要注意:聚集索引和非聚集索引关键不在于索引和数据记录是否放在一个文件,而在于数据记录是否按照主键的顺序存放。
  • MyISAM:不支持事务、使用表锁、采用非聚集索引、一个表由MYD(存放数据记录)和MYI(存放索引)两个文件组成(frm表结构文件除外)。
  • NDB:是一个集群存储引擎,数据全部放在内存中。
  • Memory:表数据全部存放于内存,适合用于存储临时数据的临时表、使用哈希索引、只支持表锁,并发性差、不支持TEXT 和 BLOB类型,存储varchar时按照char方式存储,比较浪费内存。mysql使用memory存储引擎作为临时表存放查询的中间结果(如分组、排序等),如果中间结果集超过Memory表的容量设置或者中间结果含有TEXT或BLOB类型字段,则会将中间结果转为MyISAM表存在磁盘中导致查询性能更慢。
  • Archive:其目标是提供高速插入和压缩存储功能、只支持insert和select操作、使用zlib对数据行压缩后存储、适用于存储归档数据如日志、使用行锁,但本身不是事务安全的。
  • Federated:本身不存放数据,而是指向远程mysql服务的一个表,实现对该表的操作。
  • Maria:开发时目的是用于替代MyISAM成为默认引擎的,提供和InnoDB基本一样的功能。

2、InnoDB和MyISAM对比

  • 事务和外键

InnoDb支持事务和外键,一致性需求有保障。MyISAM不支持事务。

  • 锁机制

InnoDb支持行级锁,基于索引加锁实现行级锁。

MyISAM支持表级锁,锁定整张表。

  • 索引结构

InnoDB使用聚集索引,而且索引和记录一起存储。

MyISAM使用非聚集索引,索引和记录分开存。

  • 并发处理能力

MyISAM使用表锁,任何一个写操作都会锁住整个表,所以一张表中任何两个记录的修改无法并发进行,只能串行。读写和写写串行,读读可并发,粒度为表。

InnoDB使用行锁,并且使用MVCC优化了读写并发,因此读读,读写可并发,写写串行,粒度为行。

  • 存储文件

InnoDB表对应两个文件:.frm表结构、.ibd数据文件。

MyISAM表对应3个文件:.frm表结构、.MYD数据文件、.MYI索引文件。

  • 使用场景

MyISAM适合不需要事务支持(一致性要求不高)、并发需求低、数据修改较少(因为写写是串行的,所以写操作性能低)以读为主的场景。

InnoDB则相反。

三、Innodb 存储引擎

1、InnoDB结构

下图是官方提供的InnoDB总体结构:分为内存结构(下图左侧)和磁盘结构(右侧)两部分。

内存部分由多个缓冲区构成,分为 缓冲池(Buffer Pool,检测BP) 和 日志缓冲(Log Buffer),缓冲区的最小逻辑单位是页(page)。

页是数据库的最小操作单位,无论是在磁盘还是内存中数据库的操作单位都是页,一页等于文件系统的一个或者多个块,mysql默认页大小为16K。

磁盘部分包括各种表空间,主要有以下5种:系统表空间(System Tablespace,又称共享表空间)、独立表空间(File-Per-Table Tablespaces)、undo表空间(undo Tablespaces)、通用表空间(General Tablespaces)、临时表空间。

innodb可以选择使用系统表空间还是独立表空间存储表,如果选择前者,则所有innodb表都保存在 ibdata1 这个表文件中,选择后者则一个innodb表占据一个表文件,拥有自己独立的表文件。

innodb文件的逻辑结构:

  • Tablespace

表空间,用于存储存储一个或多个ibd数据文件(记录和索引),一个ibd文件包含多个段(segment)。

每个表空间都具有一个唯一的表空间id。

Mysql 5.6版本默认所有InnoDB的所有表数据会放在一个系统表空间 ibdata1。

5.7版本之后,每个表的数据默认单独放到一个独立表空间内。但每张表的独立表空间只存放数据页、索引页和写缓冲BitMap页,其他信息如回滚页、插入缓冲索引页、二次写缓冲仍放在系统表空间。所以即使每个表的数据单独放到自己的独立表空间,系统表空间也会不断增大。

  • Segment

段,用于管理多个区(Extent),根据段类型可以分为数据段(Leaf node segment)、索引段(Non-leaf node segment)和回滚段(Rollback segment)等。

数据段包含B+树的叶子节点页、索引段包含B+树的非叶子节点页,一个表至少会有1个数据段和1个索引段。每多创建一个索引,会多两个segment(即数据段和索引段)。

段申请空闲内存空间时会按一个区申请(1 extend = 64 pages,1extend大小为1M)。但是innodb的一个表初始大小为96K,而不是1M,因为每个段一开始不会直接申请一个区,而是先用若干个碎片页存放数据,用完这些也才按1个区64个连续页来申请。

  • Extent

区,一个区固定包含64个连续的页,大小为1M。当表文件空间不足,不会一页页的分配,而是直接分配一个区大小的空闲空间给ibd文件。

  • Page

页,用于存储多个行记录(Row),一页大小为16K。

页类型有多种,如数据页、索引页、undo页、系统页、事务数据页、大的BLOB对象页等。

我们最常提及的数据页是指B+树的叶子节点页,索引页是指B+树非叶子节点页。

  • Row

行,包含记录的字段值、事务ID(trx id)、滚动指针(roll pointer)、字段指针(field pointer)、行ID(row id)等信息。

1、Innodb缓冲池

缓冲池是mysql向操作系统申请的一片连续内存空间(实际上缓存池实例中的块(chunk)内部是连续的,但chunk之间是离散的),存储单位是页,称为缓冲页或缓存页,缓存页的大小和磁盘页一样为16K。

每一个缓存页都会有对应的控制块记录其控制信息(例如页所属的表空间号、页号、页在缓存中的地址、下一页的指针等)。

缓冲池包括:数据页(data Page)、索引页(index Page)、undo页、写缓冲区(change page,简称 CB)、自适应哈希索引(adaptive hash index)和其他信息(如锁信息,数据字典信息)等。 

1. Buffer Pool 的预读特性

磁盘IO按页读取,查询某条记录不是只读取这条记录,而是读取这条记录所在的整个页并缓存。

根据局部性原理,短期内数据读取是集中在某个小的范围之内的,所以本次读取的数据大概率和上次读取的数据在一个页内。

假设把这个页放到内存缓存,那么这个页下次被命中的可能性比较高,从而避免重复的磁盘IO。所以缓存整个页具有预读的作用,预读也是缓冲池一大作用。

例如:本次查询 id = 5 的行,系统缓存了页号为1001的页,下次查询id = 6的行(假设这两行放在同一页中),就会命中1001号页的缓存,无需进行磁盘IO。

InnoDB怎么在不查询磁盘的情况下知道 id = 6的记录也位于 1001号页呢?

很简单,因为该表的索引页缓存了起来,系统查询缓存中的索引页就能得知id=6的数据页页号。

Innodb的预读不仅只读取本次所需的一个页面,还可能读取和该页面相邻近的其他页面。

预读分为两种:线性预读 和 随机预读。

线性预读是指当某个区(extend,1 extend 包含 64 page)内被顺序访问的页面(即离散分布)超过56个时,innodb会把下一个区的全部页面异步载入buffer pool。

随机预读是指当某个区的13个连续页面在buffer pool中,而且这13个页面都在lru热区域的前 1/4 位置内时,innodb都会把本区内的所有页异步载入buffer pool。

2. Buffer Pool 的页分类

页分类:

  • free page :空闲page,未被使用过的页;
  • clean page:正在被使用的干净页,即没有被修改过的页;
  • dirty page:脏页,用户做出DML操作修改数据且这些数据刚好在缓冲池的页就是脏页,这样的页与磁盘中对应的页数据不一致;

针对这3种页,InnoDB使用3种链表维护:

  • free list:空闲页链表,管理 free page;
  • flush list:脏页链表,管理 dirty page 并在某个时刻对该链表的脏页进行刷盘,按脏页的修改时间排序,更新操作早的脏页先被刷盘;
  • lru list:正在使用的内存页链表,里面包含 clean page 和 dirty page,也就是说 lru list 中的页包含 flush list 中的所有脏页;

实际上链表中的节点不是缓存页本身,而是页对应的控制块:

链表的基节点(记录链表首尾节点地址的空间)占用的内存空间并不包含在为 Buffer Pool 申请的一大片连续内存空间之 内,而是一块单独申请的内存空间。

除了这3个列表之外,innodb的buffer pool还管理了很多其他链表如管理压缩页的链表等。

3. 改良版lru算法

Innodb的buffer pool中,lru链表遵循LRU算法管理缓存页。

刚开始lru列表是空的,所有的内存页都放在 free 列表;当数据从磁盘读到内存,系统先从free列表查找是否有可用的空闲页,有则从free 列表移除放到lru列表,没有则按照lru算法释放旧的缓存页。

注意:free list + lru list 不一定等于 Buffer Pool 的大小,因为 Buffer Pool 还存放 写缓冲区、自适应哈希索引和其他信息。

整个mysql使用的内存区可以划分为多个Buffer Pool ,一个Buffer Pool 可以分为多个块(chunk),每个chunk包含有多个page。

实际上,innodb使用的是一种改良版的LRU算法来管理缓存页,它相比于正常的LRU算法有以下优化。

优化点1:midpoint

普通LRU遵循新数据从链头加入,链表满了需要释放时从末尾弹出。改良LRU设置了一个 midpoint 点,新页(刚从磁盘读到的页或者刚进入lru list没多久的页)不放在LRU首部,而是放在 midpoint 后的第一个位置,链表满了则从末尾弹出节点。

midpoint 前的页是 热数据 列表区(new list),midpoint 后的页是 冷数据 列表区(old list)。midpoint 默认位于距离 lru 的链头的 5/8 的位置。

使用改良LRU是为了防止某些不常用的数据占用buffer pool空间,比如预读了不常用的页,或者 扫描操作(如全表扫描、索引扫描、大范围查询)查到大量数据,导致缓冲池的热数据被(部分或全部)刷走,这种情况称为缓冲池污染。使用了midpoint之后,被刷走的也只是midpoint后的cold数据。

优化点2:old_blocks_time

InnoDB规定 页读到cold区域之后 需要隔一段时间T才有资格进入到LRU列表的热端(在这段时间T内该cold页再次被访问也不会进入热端列表),这是为了防止某些不常用的页(如全表扫描的页)在短时间(如1秒内)内被多次访问,让系统误以为它是热数据从而将其放入了热端区域。

优化点3:减少热页在链表移动

我们知道热数据页会被频繁的访问,如果一个热数据页每被访问一次就被移动到 lru链表 首部,那么操作内存的开销也不小。Innodb规定热区域的前 1/4 的页被访问后不会移动位置,后 3/4 的页被访问就需要移动到头部,这样可以减少链表的指针操作。

4. 缓冲页的哈希处理

我们知道InnoDb访问某页时,不是直接从磁盘读取,而是先从缓冲池(的lru链表)读取页;如果没命中缓存,就从磁盘读取页到缓冲池缓存,下次读到相同的页则直接从缓冲池读取,从而减少磁盘IO。

问题是怎么知道我要查询的页是否在buffer pool呢,难道要对lru链表一个个页遍历?遍历是不可能遍历的,这辈子都不可能遍历的。

其实学过lru算法的小伙伴们都知道,lru算法的实现需要 链表 和 哈希表两个结构。

innodb是通过 页所在表空间号 + 页号 来定位一个页的,所以缓存一个页时,系统除了将该页的控制块链入lru链表之外,还会将 该页的表空间号 + 页号 作为key,页的控制块地址作为value写入到哈希表中。

所以当要访问某个页时,根据该 表空间号+页号 即可得知页在不在buffer pool,在buffer pool的哪个地方。

脏页刷盘

后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样可以不影响用户线程正常处理用户请求。

刷新方式主要有下面几种:

  • 从LRU 链表的冷数据区刷新部分页面到磁盘

后台线程会定时从 LRU 链表尾部开始扫描指定数量的页面(比如每次只扫描最靠近末尾的1000个页),发现脏页(控制块的某个属性记录了一个页是不是脏页)则刷新到磁盘,这种刷 新页面的方式称为BUF_FLUSH_LRU。

热数据的脏页会随着它不被访问而进入到冷数据区,从而被检测到并刷盘。如果热数据的脏页一直都在使用,不会进入到冷数据区,也可以通过第二种方式保证热数据的脏页在一定时间内刷盘。

优先刷盘冷数据而不优先刷盘热数据是因为,热数据在短时间可能被多次修改,如果优先刷盘热数据页,这个页很快又会被修改,又需要再刷盘,不如等它变成冷数据再刷盘。

  • 从 flush 链表中刷新一部分页面到磁盘

这种刷新页面的方式称为 BUF_FLUSH_LIST。flush链表包括 lru链表热数据页 和 冷数据页的脏页。

  • 主动刷盘内存池中被淘汰的脏页

如果在buffer pool已满的情况下,用户线程从磁盘读取某个页要链入lru链表,lru链表会释放尾部的一个页。

假设这个释放的页是一个脏页,那么用户线程就不得不亲自把这个脏页刷盘,因而降低用户请求的速度。这种方式称为 BUF_FLUSH_SINGLE_PAGE。

之所以需要后台线程定时刷盘脏页就是为了尽可能避免发生 BUF_FLUSH_SINGLE_PAGE。

5. 多个Buffer Pool

一个mysql实例中,缓冲池不只一个,而是有多个,所有缓存页根据哈希值平均分配到不同缓冲池实例。将内存空间分为多个缓冲池是为了增加临界资源,减少多个线程对buffer pool的竞争(毕竟访问buffer pool的各种链表都需要加锁处理),提高并发。

每个 buffer pool有自己独立的内存空间,独立的lru、free、flush链表。buffer pool的个数不是越多越好,因为管理每一个buffer pool也需要开销。

6. Buffer Pool分块

Buffer Pool 分块(chunk)是mysql 5.7.5之后的特性,该特性是指一个buffer pool实例是由多个块组成,每个块的块内空间是连续的,块与块之间是离散的。

在 mysql 5.7.5之前,为buffer pool申请内存空间是整个buffer pool 实例都是连续的。

buffer pool分块是为了方便用户可以在mysql运行期间能够调整buffer pool的大小(innodb_buffer_pool_size)。

假设,整个buffer pool都是连续的,如果用户增大buffer pool的大小,系统必须分配一个比原来 buffer pool 更大的连续空间,再将原来buffer pool的数据拷贝到新空间,这个CPU时间开销无疑是巨大的。

但是使用了 分块 存储的方式,当想要增大 buffer pool 的大小时,系统只需多申请一个块或者多个块的空间,并将这些块链入这个buffer pool实例中即可。

一个块的大小由参数innodb_buffer_pool_chunk_size控制,默认一个chunk为128M。

7. Buffer Pool 预热

Mysql重启时,BP中的热数据会清空,为此mysql提供了缓冲池预热功能,当关机时会把内存中的热数据写入到 ib_buffer_pool 文件中,保存的数据占 lru 的比例可由参数控制,mysql启动时会自动加载热数据到缓冲池。预热功能默认开启。

8. Buffer Pool配置参数

  • innodb_page_size

BP缓冲区大小(单位是页),建议将其设为总内存的 60% ~ 80%。

  • innodb_old_blocks_pct

midpoint离链尾的百分比,默认37.5%。

  • innodb_old_blocks_time 

新页需要隔多长时间才能进入lru链表的热端。

  • innodb_buffer_pool_instances  

Buffer Pool的个数,建议设为多个。

  • innodb_buffer_pool_dump_at_shutdown 

关闭服务时保存热数据。

  • innodb_buffer_pool_dump_pct

保存热数据的比例。

  • innodb_buffer_pool_load_at_startup

开机时载入热数据。

可以通过 show variables like '%...%' 查看以上配置项。

注意:Innodb的缓冲池 和 查询缓存 是不同的两个东西,前者属于存储引擎层,后者属于服务层,前者是缓存已经读取过的页,后者是缓存查询语句和查询结果的映射关系,后者想要命中缓存必须要做到下一次用相同的sql语句查询。

9. 写缓冲 Change Buffer

在进行DML操作时,系统不会直接将变更刷新到磁盘中,而是会先将变更的页写入到缓冲区,经过一系列策略同步到磁盘。

此时分为两种情况:

  1. 当更改的页存在于 Buffer Pool 的 lru 链表,则直接在缓冲池中修改这个页,这个页会变成脏页,链入到 flush list中,但并不马上刷盘;此时不涉及 change buffer 操作。
  2. 当更改的页不存在于 Buffer Pool 的 lru 链表,就要先从磁盘读取要修改的数据页到Buffer Pool后再修改(数据不可能在磁盘中直接更改,肯定要读到内存,在内存中修改)。

但为了避免修改操作引发的磁盘读IO,系统会将DML操作记录到 change buffer中,并不马上刷盘。

等下次对这些修改的页进行查询时,由于lru链表不存在该页,会从磁盘读取(磁盘页是更改前的数据),为了避免读到脏数据,该磁盘页会和 change buffer中的更改合并后才链入到 lru链表。

如果未来一段时间都不会查询到这个修改了的页,也会有 insert buffer thread 定时将change buffer 的数据合并到磁盘页中。

使用change buffer可以避免数据更改时因为隐式查询数据带来的磁盘IO,这是change buffer提升性能的地方。

如果做出的更改是对唯一键索引的值的修改,innodb要做唯一性校验,必须查询磁盘,再在lru链表上的页修改,不会在change buffer 中操作。

change buffer 默认占 Buffer Pool 的 25%,最大允许占50%。可以根据写业务的量调整,写操作越频繁,change buffer 带来的性能提升越明显。

10. 日志缓冲区 Log Buffer

log buffer 用来缓存要写入log文件的数据(redo和undo)。

这里的log文件是指Innodb引擎的日志,所以不包括什么binlog日志、慢查询日志之类的其他日志,日志缓冲区会定期刷新到磁盘的log文件中。

从日志缓冲区 log buffer 刷盘到 log文件需要经过 操作系统内核的缓冲区 os cahce(见本文第一张图中的 Operation System Cache),因为IO操作需要委托操作系统来完成。

innodb_flush_log_at_trx_commit参数控制日志刷新的行为和周期,默认为1。log日志刷盘有3种策略:

  1. 每隔1秒从 log buffer 写入OS cache,并马上刷盘,mysql服务故障或者主机宕机则丢失1秒数据。
  2. 事务提交时,立刻从 log buffer 写入 os cache, 并马上刷盘,mysql服务故障或者主机宕机不会丢失数据,但会频繁发生磁盘IO。
  3. 事务提交时,立刻从 log buffer 写入 os cache,每隔1秒刷盘,mysql服务故障不会丢失数据,因为数据已经进入操作系统缓存,与mysql进程无关了,主机宕机则丢失1秒数据。

除此之外,当redo/undo日志缓冲区满了之后,也会触发刷盘。

上面所说的刷盘是指日志数据刷盘到log文件,而不是表数据刷盘到表文件,数据刷盘到表文件是发生在redo日志刷盘到redo log文件之后才发生的,而且是从buffer pool刷盘到表文件的。

刷盘操作是异步IO,由专门的线程完成这件事,不会阻塞用户请求的处理。

11. InnoDb的线程模型

nnodb的这些线程负责的是数据在Innodb的内存和磁盘间的传输。

IO Thread:

负责读写操作,使用AIO读写(异步IO),InnoDB 1.0 版本之前一共有4个IO线程,分别是 write、read、insert buffer 和 log thread,后来将read thread 和 write thread增大到4个,一共10个IO线程。

  • read thread:将数据从磁盘加载到缓存page页;
  • write thread:将缓存脏页刷新到磁盘;
  • log thread:将日志缓冲区刷盘到log文件;
  • insert buffer thread:将写缓冲change buffer的更改内容刷新到磁盘;

Purge Thread:

事务提交之后,该事务相关的undo日志不再需要,Purge Thread负责回收已分配的undo页。默认有4个purge thread。

Page Cleaner Thread:

将脏数据刷新到磁盘(会调用 write thread 线程),脏数据刷盘后对应的redo log也就没用了,可以释放掉这部分 redo log,达到redo log 循环使用的目的。默认有1个Page Cleaner thread。

Master Thread 主线程:

负责调度其他线程,优先级最高。主要职能:脏页刷盘(调用page cleaner thread)、undo页回收(purge thread)、redo日志刷新(log thread)、合并写缓冲(insert buffer thread)。如果这些子线程通过配置关闭了,那么关闭的子线程的任务就会由master thread来做。

主线程是由多个无限循环构成的,主要有2个主处理,分别是每隔1秒和10秒的处理:

每1秒的操作(有条件的做):

  • 刷盘日志缓冲区;
  • 合并change buffer数据到磁盘的B+树中,根据IO读写压力决定是否操作;
  • 刷盘脏页到磁盘(条件是脏页比例达到75%才操作(innodb_max_dirty_pages_pct),而且不是一次性刷盘所有脏页,而是默认每次刷盘200页(innodb_io_capacity));

每10秒操作(无条件的做):

  • 刷盘脏页到磁盘;
  • 合并change buffer数据;
  • 刷盘日志缓冲区;
  • 删除无用的undo页;

2、InnoDB行结构

表的行格式决定了它的行是如何物理存储的,这会反过来影响查询和DML的性能。如果单个page页能容纳更多行,查询可以更快,缓冲区能容纳的行越多,写入更新时所需的IO更少。

常见的行格式有四种:

redundant、compact、dynamic 和 compressed 。

1. compact 行格式

a. 变长字段长度列表

它存储一条记录中所有变长字段(varchar、varbinary、text、blob类型)的长度,并且是逆序存放。

例如某一行记录,有c1/c2/c3这3个varchar(10)的字段,内容分别是 aaaa/bbb/d,那么变长字段长度列表为 01 03 04。变长字段长度列表中的每个长度可以用1字节(当变长字段值的长度小于255字节)或2字节(当变长字段值的长度大于255字节)表示。

b. NULL标志位

一条记录中的某些列的值是 NULL,row不会真的存储这些null(意思是null不占空间),而是用“NULL标志位” 把值为 NULL 的列统一管理。

系统会统计表中允许存储 NULL 的列有哪些,将每个允许 存储 NULL 的列对应一个 二进制位,二进制位按照列的顺序逆序排列,位为1表示某列的值是null,0表示不为null。

NULL标志位占用的位个数会补足到满足整数字节。

c. row 记录头信息

固定占5字节,其中比较重要的几个信息如下:

  • deleted_flag :标记该记录是否被删除;
  • n_owned(重要) :一个页的所有记录会被分为多个组,每个组有一个记录是“带头大哥”,其余的记录都是"小弟",“带头大哥"记录的 n_owned 代表该 组的记录条数,"小弟"记录的 n_owned 值都为0;
  • heap_no:表示当前记录在页面堆中的相对位置(用来表示该记录是本页的第几个记录)。之所以说是堆,是因为一个页中的记录与记录之间是紧密排列的;
  • next_record:下一条记录的相对位置(本记录指向下一条记录的链表指针,它是一个相对偏移量);
  • record_type:本记录的类型,0 是普通记录也就是页的user record区中的记录、 1是B+树非叶子节点的目录项记录、2是infimum记录、3是supremum记录;
  • min_rec_flag:表示该记录是不是B+树非叶子节点中的最小的目录项记录;

e. 隐藏列

每个记录还有一些隐藏列。

  • row_id:行id,当用户没有自己设置主键或唯一键的时候,系统会自动生成一个 6 字节的唯一行ID作为主键;如果用户设置了主键,则row_id就不会存在;
  • trx_id:事务ID;
  • roll_pointer:回滚指针;

2. redundant 行格式

字段长度偏移列表。

记录所有字段值(包括隐藏字段)的偏移量(而不是长度,但长度可以根据偏移量计算长度),逆序存放。

例如有7个字段,分别占 6 6 7 4 3 10 1个字节,那么它们的偏移量列表是 25 24 1A 17 13 0C 06。

其他信息和 compact 格式类似,不再赘述。

3. 溢出页

我们知道B+树的一个页包括多个行,如果一个行的某些字段过长(如很长的varchar、char 或者 text类型),InnoDB就会单独分配独立于B+树之外的页面来存储这个字段的信息,这样的页被称为溢出页,它既不是数据页也不是索引页。这样的字段被称为页外列。

溢出页之所以叫溢出页是因为它不再B+树上,由B+树的行保存溢出页的指针。这样的好处是让B+树的一个节点能容纳更多页,减小树的高度。

B+树上的页是类型为 B-tree node 的页,而溢出页是页类型为 Uncompress BLOB 的页。对于一个长度为48000字节的varchar字段需要3个溢出页来存储。

对于 redundant 和 compact 作为行格式的表,如果某个列值的内容很多,会将该列值的前768字节存在B+树节点的页中,其余的部分存在溢出页。

溢出页的地址用20字节表示。对于大于786字节的固定长度的字段(text 或者 很长的char类型),Innodb会转为变长字段以便在页外存储。

4. dynamic 和 compressed 行格式

dynamic和compressed这两种行格式和 compact是相同的,只不过在溢出页的处理上不同:这两种行格式会将内容很多的列值完全存到溢出页,而记录只包含指向溢出页的20字节指针。

compressd和dynamic具有相同的存储特性,而且增加了对数据的压缩支持。

mysql 5.7版本默认使用 dynamic 行格式。

REDUNDANT 是一种非紧凑的行格式,而 COMPACT、DYNAMIC 以及 COMPRESSED 行格式是紧凑的行格式,占用的存储空间更少。

3、InnoDB页结构

InnoDB的数据页 和 索引页 结构如下(注意,其他类型的页不是这样的结构):

1. 用户记录 和 User Records 堆

在页的 7 个组成部分中,用户存储的记录会按照指定的行格式存储到 User Records 部分。

一开始生成页的时候,User Records 部分为空,每当插入一条记录时都会从 Free Space 部分(也就是尚未使用的存储空间〉申请一个记录大小的空间,并将这个空间划分到User Records 部分。

当 Free Space 部分的空间全部被 User Records 部分用完之后,如果还有新的记录插入,就需要去申请新的页 。这些用户插入的记录是用户记录。

2. 用户记录的物理结构——堆

数据页 User Records 部分里的记录一条条紧密地排列着。因此User records区的数据结构是一个堆。堆里面的每一条记录在堆中的相对位置被记录在行头信息的 heap_no 中。

3. Infimum 和 Supremum记录

heap_no为0和1的记录不是用户插入的记录,而是页中本来就存在的两条虚拟记录(Infimum 和 Supremum记录),这两条记录是堆里面最靠前的记录。

innodb规定lnfimum 记录是一个页中最小的记录(逻辑上最小), Supremum 记录是一个页中最大的记录(逻辑上最大),任何用户记录都比Infimum 记录大 ,比 Supremum记录小,Infimum 和 Supremum记录是没有主键值的。

Infimum 和 Supremum记录的长度是固定的,包括5字节的记录头信息和8字节的一个固定单词组成。而用户记录长度是不固定的。

4. 软删除

页内的一条记录被删除时不会从磁盘移除,因为记录在页中时紧密排列的,如果真的删除中间的某条记录,那么后面的记录就要批量往前移动,这和删除数组中间的某个元素是一样的道理。

行的头信息有一个 delete_flag 位标识记录是否已删除。当要删除一个记录时,会做2件事情:将该记录的 deleted_flag置为1;每个页会维护一个垃圾链表,删除一个行时会将软删除的行链入垃圾链表。

所有被软删除的行所占的空间是可重用空间,之后有新记录插入该页就可以覆盖掉被删除的记录所占的空间。

5. 用户记录的逻辑结构——单向链表

在物理上,页内的每条记录是紧密排列的,而且是无序的,一个新插入的记录在物理上可能位于旧记录的前面或者后面(位于前面是因为新记录重用了被删除记录的空间)。

在逻辑上,页内的所有记录按照主键从小到大的顺序形成一个单向链表,每个记录通过一个 next_record 作为单向指针指向下一个记录。

next_record 是记录的头信息的一个 2字节的具有正负号的相对偏移量,而且 next_record指向的是下一条记录真实数据开始的地方。

不指向记录开头而指向数据开头,这是因为该位置向左就记录头信息,向右就是数据,变长字段长度列表和NULL列表中的信息都是逆序存放的, 这样可以使记录中位置靠前的字段和它对应的字段长度信息在内存中的距离更近,可能会提高CPU高速缓存的命中率,两条指令所用到的内存地址都在高速缓存L1中。

Supremum记录(next_record)是单向链表的最后一个节点,Infimum记录则是第一个节点。

6. 页目录 Page Directory

页目录的设计是为了实现在页内高效查找某个行。页目录的构建过程如下:

  1. 页内的所有未被删除的记录(包括Infimum 和 Supremum)按照索引顺序被划分位几个组,组内的记录也是按索引值升序排的。每个组的最大的记录是“组长”,组内的其他记录是“组员”,组长头信息的 n_owned 属性表示组内有多少记录。
  2. 将每个组的组长的页内地址(即组长的真实数据开始位置与页中第0个字节的距离)提取出来,按顺序存储到靠近页尾部空间的页目录。页目录在逻辑上就是一个有序数组,数组中的元素称为槽(Slot)。每个槽占2字节。

如下所示:

页目录的每一个槽本质上就指向每个分组(的组长)的指针,如下图所示,这是一个有16个用户记录的页,被分为5组: 

分组规则如下:

  1. 初始情况,页只有两个分组(分别是lnfinmum 记录和 supremum 记录),页目录只有两个槽,分别代表 lnfinmum 记录和 supremum 记录在页 面中的地址偏移量。Innodb规定 lnfinmum 所在分组只能有1条记录, Supremum 记录所在的分组的记录条数只能在1~8条之间,其余组的记录数 只能在 4~8 条之间。
  2. 之后每插入一条记录 都会从页目录中找到主键值比待插入记录的主键值大 并且差值最小的槽(用二分查找)。这个槽对应的组长的n_owned值加1。
  3. 当一个组中的记录数等于 8,再插入一条记录会将组中的记录拆分成两个组,其 中一个组 4 条记录,另一个组 5 条记录。页目录会新增一个槽。

插入的记录在物理上紧接着记录堆末尾(或堆中间某个可重用空间)放入,在逻辑上只需调整对应指针是记录链入链表中即可,复杂度为O(1)。页目录新增槽的复杂度为O(n),但页目录的元素不多可以忽略不记。

页内查找的过程如下(嫌我啰嗦的小伙伴们可以跳过)。

例如想从上面16条记录中找 id = 6的记录,上图共有 5 个槽,用二分查找需要 low,high = 0, 4 两个指针从数组两边开始:

  1. 计算中间槽的位置。(0 + 4) / 2=2。查看槽 2 对应记录的主键值为 8,又因为 8> 6. 所以设置 high=2,low 保持不变。
  2. 重新计算中间槽的位置 (0+2)/2= 1, 查看槽 1 对应记录的主键值为 4,又因为 4 < 6, 所以设置 low=1, high 保持不变。
  3. 再计算和比较一轮得知待插入的记录会插入槽2的分组中,但是槽2记录的是 id = 8 这条记录的地址,所以需要拿到槽1的值得到 id = 4 这条记录的地址,从它开始顺着链表指针往后遍历,找到id为6的记录。

组内遍历的次数最多是 8 次。

7. 页面头部 Page Header

该头部记录页的信息,如页内记录数、 Free Space 在页面中的地址偏移、页目录槽数等。

8. 文件头部 File Header

该头部通用于各种类型的页,存储了页号,页的前后指针等。

几个重要的属性如下:

a. 校验和:页面的校验和,校验和就是用某种算法将一个很长的字符串变成一个很短的校验和串,用于判断页内数据是否出错或以外丢失,比较两个校验和 比 比较两个长字符串花的时间少很多。

b. 页号:页的唯一ID;

c. 页类型(看看就好,别记):

d. 上一页和下一页指针:只有B+树的叶子和非叶子节点这种类型的页才有上一页和下一页指针。这里的指针是页号。通过这两个指针,页与页之间形成双向链表。

e. 页的LSN:页面被最后修改时对应的LSN(日志序列号)。

9. 文件尾部 File Trailer

文件尾部是为防止脏页刷盘时断电或mysql故障导致页面不完整的情况。

尾部包含2部分:页的校验和(与文件头部的校验和一致)和LSN(和头部的LSN一致)。

当页数据被修改,这个修改会先在内存中的页生效,内存中的页会重新计算校验和与LSN并把这两个属性更新到文件头部和尾部。

页刷盘时,是按照操作系统的块(block)为单位刷盘的而不是按页(page)刷盘,刷盘从页头部开始。如果发生断电,一个页只有部分块刷盘成功,那么File Header的 校验和与LSN 肯定与 File Trailer的 校验和与LSN不同。

此时就会通过 redo log 文件恢复该页。

四、MySQL 索引

数据库可能存在千万级的数据,必须将这些行数据以一定的结构组织起来做到高效的增删改查。下面将分别探索innodb和myisam两种引擎的索引方案。 

1、InnoDB索引结构

假设表初始没有记录,只有一个空页,所有记录按照主键顺序放到页中。随着记录的增长,一个页放不下所有记录,因此会分裂成多个页,每个页用双向指针链接,页与页之间形成一条双向链表。

这些页我们称为用户记录页,页内记录的字段是用户定义的字段内容。如下图所示,这是一个以 (id, key1, key2) 组成的联合索引,页10的第1条记录表示(id=1, key1=4, key2='u')。

如果Innodb表数据是按这种结构存储,那么我们只能做到“页间遍历,页内二分搜索的方式查找”。也就是说当我要查找一条id=30的数据,需要先对页10进行二分查找,没找到再从页28进行二分查找,没找到再从页9进行二分查找直到找到,效率很低。为了提高查找效率,可以为这些 用户记录页 建立 目录页。 

目录页在页结构上 和 用户记录页 一样,目录页的行记录也是以堆的形式存储,我们称目录页的行记录为目录项,每条记录只有两个列(没有其他的隐藏列),分别是下层页中最小记录的主键 和 用户记录页的页号。根据主键值(或者索引值)查找数据时,会从最顶层的目录页开始检索,找到目录页中符合的记录后,再根据记录中的最小记录的主键 和 用户记录页的页号这两个信息,找到其对应的用户页的最小记录在磁盘中的位置。

记录头信息的record_type = 0 表示该记录是用户记录,record_type = 1 是目录项记录。

需要注意的是,目录页的页内查找也是通过前面介绍的页目录(Page Directory)进行二分查找进行检索的。目录页内的行记录过多,一个页放不下的时候,目录页也会发生页分裂,而且目录页之间也有双向链接。

如下图所示:

如果目录页也有多个,那么查目录也要遵循目录页间遍历查找,目录页内二分查找,为了避免目录页间的横向遍历,可以建立多层目录,顶层目录只有一个目录页。所有目录页和用户记录页形成一棵B+树: 

这么一来,就可以把 页间查找 的次数压缩为树的层数,这意味着磁盘IO的次数被压缩到树的层数。一般而言,MySQL中一棵B+树不会超过4层。

Innodb规定,无论是目录页还是用户记录页,一个页至少容纳2条记录,这样做是为了控制树的高度,不让B+树的性能优势丧失。

主键索引(聚簇索引)和普通索引的区别

对于主键索引B+树而言,页内记录是按照主键大小排序的,同一层的双向链表目录页也是按照主键索引排序的。

对于二级索引B+树而言,假设该B+树是以c2字段为索引。那么二级索引的叶子节点页的记录只有两个列:c2列 和 主键值,不含其它列和隐藏列。(重点来了)而二级索引的非叶子节点页内的记录有3个列:c2列 、 下层页号 和 主键值。

页内记录是按照 c2列和主键值 (先按c2列排序后按主键列排序)的大小排序的,页目录(Page Directory)每个槽(Slot)也是分组中拥有最大c2列值的记录的地址,目录页之间也是按照c2列和主键大小排序的。

为什么二级索引的目录页和叶子页的记录需要保存主键索引并且在二级索引值相同的情况下按主键索引排序呢?考虑一个问题,如下图所示:c1是主键,c2是普通索引。

此时 c2 字段索引的B+树如下:

此时,如果我想插入一个 c1:9、c2:1、c3:'c' 的记录,在目录页3没有存储主键值的情况下,由于目录页3的c2全都是1,所以页3无法决定该记录应该插在页4还是页5。

但如果目录页加上主键字段就可以解决这个问题:

所以,为 c2 列建立普通索引 相当于为 c2 和 主键列建 立了一个联合索引。

对于唯一二级索引来说,也可能会出现 多条记录键值相同的情况(例如NULL,以及MVCC),所以唯一二级索引的目录项记录也会包含记录的主键值。

我们结合下面这个例子,看看innodb是如何在使用索引找到对应记录的,其中key1是普通索引。

SELECT * FROM single_table WHERE key1 = ' a ' AND id > 9000 ;

这个sql的行为如下:

  1. 在从key1字段的B+树最顶层目录页的记录查找,找到符合 key1 = 'a' 的记录后,根据下层页号找到下一层目录页,依此类推,最终找到符合 key1 = 'a'的叶子节点的第一条记录;
  2. 沿着叶子节点页的向后指针找到所有满足 key1 = 'a'  的记录,在这个过程中同时一条条的比对 id > 9000 这个条件,得到满足条件的叶子节点记录。也就是说,id > 9000 的比较是在二级索引发生的,而不是在主键索引发生的,这样就可以减少多次回表带来的磁盘IO。
  3. 根据满足条件的叶子节点记录中的主键值,到主键索引B+树中查找,查找方式和前两个步骤一致。最终找到主键B+树叶子节点对应的目标记录。

主键索引和二级索引不同的一点在于,主键索引的叶子页的记录包含用户定义的所有字段,而二级索引的叶子页的记录只包含二级索引值和主键值。

联合索引的页

对于联合索引B+树(假设联合索引的字段是 c3,c4),叶子节点页的记录会包含 c3、c4 和 主键 这3个列。

2、MyISAM索引结构

在介绍MyISAM的索引结构之前,需要先介绍MyISAM的行格式,分为3种:定长记录(static)、变长记录(Dynamic)和压缩格式(compressed)。

MyISAM的索引和表记录是分成两个文件存储的(MYD 和 MYI)。InnoDB 和 MyISAM 都是用B+树作为索引结构。

不同点在于:

InnoDB的表记录(包含用户定义的所有字段)是保存在主键的叶子页并通过一系列页内的数据结构维护(例如以数组为结构的页目录)。

而MyISAM的表记录是按照记录的插入顺序(而不是主键顺序)紧密的存储在MYD文件中,并没有将它们分成多个页。

图中是定长格式(static)的记录在 MYD 的存储方式,每一行拥有一个虚拟的逻辑行号。

MyISAM的B+树的叶子节点只存储索引字段的值 和 行号,InnoDB的B+树叶子节点存的是行的所有字段值。

MyISAM下,若要根据一个索引值查找一条或多条记录,需要先在MYI文件的B+数中,根据索引值找到行号,再根据行号到MYD文件找记录。

所以MyISAM的主键索引比InnoDB多了一次回表。

MyISAM的主键索引是一个二级索引而不是聚集索引。MyISAM的二级索引比InnoDB的二级索引快,因为前者直接根据行的地址到MYD文件回表,后者是根据主键值回表,这意味着要根据主键值逐层查到用户记录页的地址和行的页内地址。

在聚集索引页不在缓冲区的情况下InnoDB比MyISAM多了2~3次磁盘IO才能获得行内容。

另外,如果MyISAM使用变长格式的记录,那么MyISAM的叶子节点存储 索引字段值 和 行所在MYD的地址

3、索引的代价

空间上,每建立一个索引就会创建一个B+树,一个索引页就是16K。

时间上,增删改需要对所有B+树操作,由于新数据添加时发生在叶子节点,因此要从根节点找到叶子节点,会涉及多次磁盘IO,B+树越多,IO次数越多。

此外DML操作可能引起页分裂、页回收,InnoDB需要额外的时间完成这些操作以维护节点和记录的排序。

最后,如果一条查询语句涉及多个索引,生成执行计划需要计算使用不同索引执行查询的成本,并选择成本最低的哪个索引执行(因为一般情况下一个查询语句最多使用一个二级索引)。建立太多索引会导致成本分析时间过长。

4、索引条件下推

所谓的索引条件下推是指在二级索引中就尽可能根据sql条件减少在二级索引匹配到的记录数,这是一种用二级索引进行范围搜索的优化,可以有效减少回表的次数。

举个例子:

有一个联合索引 key (a, b, c),有一个sql语句:

SELECT * from t WHERE a = ' a'  AND c = 'c';

假设 满足 a = 'a' 的记录有 10 条,满足 a = 'a' and c = 'c' 的记录有5条,聚集索引 M 和联合二级索引 N 都只有3层,不考虑页缓存,请问回表次数和磁盘IO次数是多少?

查找过程如下:

  1. 从索引N的根节点(根目录页)开始,找到 a < 'a' 且离 a = 'a' 最近的目录项,进入下一层目录页;
  2. 同上操作,进入到第三层页,即叶子节点页。
  3. 在第三层叶子节点页页内找到 a < 'a' 且离 a = 'a'最近的记录,顺着指针在页内往下遍历;在页外顺着页间指针遍历;(如果 所有a='a' 的记录都在二级索引N的一个用户记录页,则第3步需要进行1次IO,如果是分布在2个用户记录页,则第3步需要2次IO,不会分布超过2个用户记录页的,因为满足 a='a' 的记录只有10条)。第3步得到了 满足 a="a" 的10个主键id;
  4. 对着10个主键id进行回表,需要回表10次;
  5. 每次回表查到了完整的表记录,查看 c字段是否满足 c = 'c';

总回表次数为10,总IO次数为(10*3 + 3~4 = 33~34)。

如果使用索引条件下推,那么在二级索引的叶子节点比对的时候,还会检查 c = 'c' 的条件,因为 N 的页的记录也包含c字段的值。所以检索完毕后可以在N的叶子节点层得到 5 个满足条件的主键,所以只会回表5次,发生 15 + 3~4 = 18~19次磁盘IO。

再例如这2个例子:

SELECT * from t WHERE a > ' a'  AND a like '%mbc';
SELECT * FROM t WHERE a = ' a ' AND id > 9000 ;

也同样会用到索引条件下推优化。

在 mysql 5.6以后,索引条件下推功能默认是开启的。

5、索引的功能

索引主要具有3个功能:高效查找、排序和分组。

在没有用到索引的情况下,只能将数据加载到内存,并在内存中排序和分组(如果中间结果太多还需要将中间结果存到磁盘中),这种情况叫做文件排序(filesort)。使用到文件排序会降低查找性能。

而使用了索引,由于记录插入B+树时本来就是按索引列的顺序插入,因此索引内的数据是有序的,查询时无需再在内存中排序或分组。

几种无法使用索引排序的情况:

  1. ASC和DESC混用,即对某个字段升序排序,对另一个字段降序排序;不过,Mysql 8.0 给出了 Descending Index 的新功能,允许联合索引的建立可以按某个字段升序,某个字段降序的方式插入。
  2. order by 包含非同一个索引的列;
  3. order by 包含多个是同一联合索引的列,但列的顺序不对(例如对字段A升序,对字段B降序);
  4. where 条件的索引列和排序的索引列不同,因为你无法保证 where a='xxx' 的情况下的记录 其排序字段 c 是有序的;
  5. order by 对列使用函数;

6、回表的代价

回表的代价就是当执行对二级索引的范围查询时,如果查到的记录数太多,每条记录都需要在主键索引进行回表,回表一次就会在主键索引发生多次随机IO。

一次sql查询中,回表的记录(或者说次数)越少,二级索引的效率越高。

例如下面的sql语句:

SELECT * from single_table WHERE key1 > ' a ' AND key1< ' c ' ;

可以选择使用 key1 索引进行查询,也可以选择不使用 key1 索引而是直接全表扫描。

这是由查询优化器决定的,而决定的标准就是 区间 ('a', 'c') 的范围有多大,也就是取决于使用二级索引会发生回表的次数所带来的磁盘IO次数 和 全表臊面发生的磁盘IO次数哪个更多。

一般情况下,对于指定了 limit 子句的查询,优化器倾向于使用二级索引 + 回表的方式。

如果一个order by 使用了二级索引作为排序列,但是记录数太多,而且没有覆盖索引,那么mysql很可能使用 全表扫描 + 文件排序的方式也不用二级索引的排序,例如:

SELECT * from single_table order by key1 ;

7、MySQL查询访问方法

mysql执行查询语句的方式叫做访问方法或访问类型,这些访问类型具体为 const、ref、range、index、all等。

同一个查询语句可以使用多种不同的访问方法来执行,虽然最后的查询结果都是一样的,但是花费的时间成本可能差距甚大。

下面对每种访问类型一一说明,假设有一个表如下图:

在介绍每种访问类型时需要用到B+树图示,下面所有的B+树都是简化后的,只展示了叶子节点的图。

1. const

通过主键或者唯一二级索引列与常数值进行等值比较来定位一条记录的访问方式就是const。

凡是单点查询(x = 'xxx')只命中一条记录才算是const类型。

使用const访问类型,MySQL 会直接在聚簇索引中利用主键值定位对应的用户记录。

例1:通过主键定位一条记录

SELECT * from single_table WHERE id = 1438;

例2:通过非主键的唯一键定位一条记录 

SELECT * from single_table WHERE key2= 3841;

通过唯一键key2定位一条记录,需要一次回表,比主键慢一点点,但也很快。

const 访问类型之所以(比ref访问类型)快是因为主键和唯一键的单值查询只会命中1条记录,最多只会发生1次回表,而二级索引的单值查询可能会命中多条,从而引发多次回表。

如果主键或者唯一二索引的索引列由多个列构成,则只有在索引列中的每 个列都与常数都进行等值比较时才算使用了 const访问方法。

对于唯一二级索引列来说,在查询列为 NULL 值时,不算是 const 而算是 ref,因为它可能命中多条null的记录。

SELECT * from single_table WHERE key2 is null;

2. ref

通过非唯一二级索引列与常数值进行等值(单点查询)比较来定位记录的访问方式就是ref。

例3:通过普通索引key1定位一个单值

SELECT * from single_table WHERE key1 = 'abc';

注意:

采用二级索引来执行查询时, 其实每获取到一条二级索引记录就会立刻对其进行回表操作,而不是将所有二级索引记录的主键值都收集起来后再统一执行回表操作。而且就算是收集起来再回表也不是总共只回一次表,依然是有几个id就回几次表。

ref 比 const 的性能差在可能需要多次回表。

二级索引列允许存储 NULL 值时,无论是普通的二级索引 ,还是唯一二级索引 ,在执行 "key IS NULL" 形式的搜索 时,最多只能使用 ref 访问方法 而不能使用 const 访问方法。

对于联合索引而言,只要满足最左前缀的单值查询才算是 ref 类型的查询。例如:

select * from single_table where key_part1='god like';
select * from single_table where key_part1='god like' and key_part2='legend';
select * from single_table where key_part1='god like' and key_part2='legend' and key_part3='penta kill';

和ref 类似的还有一种 ref_or_null 的访问类型,表示单点查询不仅要找到某个常数值,还要找到null。

例4:

SELECT * from single_table WHERE key1 = 'abc' or key1 is null;

值为 NULL 的记录会被放在索引最边的页。

3. range

使用索引执行查询时, 扫描区间为若干个单点扫 描区间 或者 范围扫描区间 的访问方法称为 range。

例5:range查询

SELECT * FROM single_table WHERE key2 IN (1438 , 6328) OR 
(key2 >= 38 AND key2 <= 79);

扫描区间 就是 [1438, 1438] 、[6328, 6328] 和 [38, 79];

4. index

如果一个范围(非单点)查询的where没命中索引,而且无需回表(这要求搜索的列全在索引列范围内),那么就是index访问方式。这种方式又叫做覆盖索引。

例6:覆盖索引

SELECT key_part1, key_part2, key_part3, id from single_table 
WHERE key_part2 = ' abc';

由于 where 条件不遵循最左前缀原则,因此 where key_part2 = 'abc' 并没有用到索引。但由于select 的字段全部都是idx_key_part这个索引所涵盖的字段,因此算用到了覆盖索引。

另外当通过全表扫描查询时 如果添加 order by id 语句,那么该语句在执行时也会被 认定为使用的是 index 访问方法。

问题1:为什么同样是扫描全部记录,但二级索引的index访问方式的性能比 all 访问方式性能高?

因为二级索引的叶子节点只存了索引字段,没有存记录上的其他列,这意味着二级索引的叶子节点能存储的记录数量比主键索引的叶子节点多。换句话说,相同记录数下,一个表的二级索引的叶子节点页数肯定比主键索引的叶子节点页数少很多。

所以二级索引扫描全部记录 执行的磁盘IO次数 比 在主键索引扫描花的IO次数少。

问题2:为什么有时候优化器选择全表扫描也不用二级索引+回表?

当搜索得到的结果范围很大的情况下,从二级索引叶子节点查到的每一个主键值都要回表,此时优化器宁愿全表扫描也不会使用索引+回表的查询方式。后者会有2个坏处:

  1. 一次回表会在主键索引(从根节点到叶子节点的过程)发生多次磁盘IO。
  2. 这些磁盘IO都是随机IO。

全表扫描则是直接从叶子节点沿着链表指针往后遍历,不用每次都从根节点到叶子节点,相比于回表节省了多次IO次数,而且沿着链表指针的磁盘IO会有很多次顺序IO(也会有随机IO),速度会比回表的随机IO快。

5. all

对主键索引全表扫描的访问方式。

注意:为了避免二级索引回表发生多次随机IO,mysql提出 MRR (多范围读取)的优化措施,该优化的做法是先读取一部分二级索引记录,将其主键排好序进行统一回表,这样可以节省一些IO次数(原理是利用相邻的ID可能位于同一个页中,这样一来多个ID统一回表就不会发生重复的磁盘IO)。

8、索引合并

如果一个sql的where条件涉及2个独立的普通索引字段,mysql一般只会使用一个索引。但使用 索引合并 技术可以同时使用多个索引B+树。

1. intersection 索引合并

intersection索引合并是指如果where条件涉及多个索引,并且多个条件间的关系为 AND,此时可以同时根据这些条件查询多个索引B+树,并对得到的id取交集回表。

例7:intersection索引合并使用多个索引

SELECT * FROM single_table where key1 = ' a ' AND key3 = ' b';

该SQL的查询方案有4种:

  1. 使用 key1 索引;
  2. 使用key3索引;
  3. 全表扫描;
  4. 同时使用key1 和 key3 索引(索引合并)。

使用索引合并的情况下,innodb会在 key1索引 的B+树查找 ['a', 'a']区间的记录对应的id,也会在 key3索引 的B+树查找 ['b', 'b']区间的记录对应的id。再将这个结果集的id求交集得到同时满足 key1='a' 和 key3='b'的id,再根据这些id回表。

使用索引合并有1个条件,它要求从二级索引获取到的二级索引记录都是按照主键记录排好序的,这意味着两个索引字段只能单点查询不能范围查询。这主要出于2方面考虑:

1、从两个有序集合取交集比从两个无需集合取交集容易的多(假设两个集合长度是m和n,如果两个集合都是有序的,那么取交集的复杂度为O(n+m),如果是无序的则复杂度为O((m+n)logm) 或 O((m+n)logn) 或 O(mlogm + nlogn)))

这里顺便说一下取交集算法。

有序集合取交集:

使用双指针i,j,比较 arr1[i] 和 arr2[j]。

如果 arr1[i] > arr2[j],则 arr2[j]不可能是结果集的元素,所以指针往后移,j++;

如果 arr1[i] < arr2[j],则 arr1[i]不可能是结果集的元素,i++;

如果 arr1[i] == arr2[j],则arr1[i]和arr2[j]就是结果集的元素,i++ ,j++;

复杂度为O(n+m)。

无序集合取交集:

如果对两个集合分别排序再求交集,则复杂度为 O(mlogm + nlogn);

如果对集合m排序,再遍历集合n,让n的每个元素对集合m二分查找,则复杂度为 O((m+n)logm);

如果对集合n排序,再遍历集合m,让m的每个元素对集合n二分查找,则复杂度为 O((m+n)logn);

因此无序集合取交集用那种方式取决于 集合m和集合n的长度谁长。

2、如果从二级索引获得的回表id是有序的,那么回表操作就不是单纯的随机IO,而可能包含顺序IO从而提高效率。

例8:不符合intersection索引合并的SQL

SELECT * FROM single_table where key1 > ' a ' AND key3 = ' b';

这个例子不符合使用索引合并的条件,因为 key1 > 'a' 的情况下不能保证记录中的id是有序的。

SELECT * FROM single_table where key1 > ' a ' AND key_part1 = ' b';

这个例子也不符合使用索引合并的条件。因为key_part1 是联合索引,所以 key_part1 = ' b' 的情况下不能保证记录中的id是有序的(因为联合索引 先根据key_part1排序,再根据key_part2排,再根据key_part3排,最后才是根据id排。key_part1 = ’b'的情况下, key_part2 的值可能是多个不同的值,key_part2有不同值的情况下,id字段就可能是无序的)。

使用索引合并的优点是可以通过求交集减少满足where条件的id的个数,从而较少回表次数,即减少在主键索引发生的磁盘IO次数。缺点是需要在多个二级索引的B+树中查找,会增加在二级索引发生的磁盘IO次数。

是否使用索引合并需要衡量 减少的回表id的个数所减少的IO次数 带来的优化是否比得上 在二级索引增加的IO次数 带来的损耗。

所以如果两个集合求交集后的结果集没有比原集合少多少,那么索引合并的性能就会比较差。

2. Union索引合并

union索引合并是指如果where条件涉及多个索引,并且多个条件间的关系为 OR,此时可以同时根据这些条件查询多个索引B+树,并对得到的id取并集回表。

例9:union索引合并使用多个索引

SELECT * FROM single_table WHERE key1 = ' a ' OR key3 = 'b';

此时单独用key1 或 key3 的任何一个索引都无法得到完整的目标记录,因为 key1 不等于 'a'的记录的 key3 也可能等于 'b'。

此时可以用全表扫描 或者 union索引合并这两种查询方案。

union 索引合并是同时使用 key1 和 key3 这两个索引得到两个id集合,再将两个id集合求并集,为结果集的这些id回表。

使用Union索引合并好处是通过求并集在二级索引回表前缩小搜索范围,减少了在主键索引的IO次数;坏处是要在每个索引发生多次随机IO。

衡量是否使用Union索引合并取决于在二级索引得到多个id集合后,求id集合的并集得到的结果集能缩小多少搜索范围。

例10:同时使用intersection索引合并和union索引合并

SELECT * FROH single_table WHERE 
(key_part1 = 'a'  AND key_part2 = 'b'  AND key_part3 = 'c') 
OR (key1 = ' a' AND key3 = 'b');

该例子可以通过 key1 和 key3 执行 intersection索引合并,得到的合并结果再与 key_part 这个联合索引执行Union索引合并。

Union索引合并的使用要求和Intersection索引合并一样,各个索引中扫描到的主键值得是有序的(即只能单点查询)。

3. Sort-Union索引合并

Union索引合并要求各个索引使用单点查询才能触发Union索引合并,但这个条件未免太苛刻。因此提出了Sort-Union索引合并,它可以允许各个索引使用范围查询的时候就触发union索引合并。

例11:sort-union索引合并

SELECT * FROH single_table WHERE key1 < ' a ' OR key3 > 'z';

该例子不满足使用 Union 索引合并的条件,但可以使用Sort-Union索引合并来避免全表扫描。

Sort-Union索引合并过程如下:

  • 在 key1 索引中找到满足 key1<'a'的二级索引记录,将获得的id排序;
  • 在 key3 索引中找到满足 key3>'z'的二级索引记录,将获得的id排序;
  • 将在二级索引得到的两个主键集合求并集,再对结果集id进行回表;

Sort-Union的目的也是为了减少在主键索引的搜索范围。

4. 索引合并的使用场景

Union和Sort-Union适用于“从单个二级索引获取的记录数较少的场景”,即 key1 <'a' 和 key2 > 'z' 在各自的二级索引中命中的id数较少。

Intersection索引合并适用于“仅从单个二级索引获取的记录数太多,导致回表次数太多,因此需要使用多个二级索引”的场景。

注意:Mysql没有实现 Sort-Intersecton 索引合并,但是MariaDB却实现了Sort-Intersecton 。

凡是使用了索引合并的sql,其访问方式为:index_merge。

9、连接查询

1. 连接查询过程

我们先通过一个例子开始探究连接查询是怎么进行的。

如下所示有两个表:表t1和t2都没有建立任何索引。

现在我们执行一条关联查询:

SELECT * from t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2. m2  
AND  t2.n2 < 'd';

查询过程如下:

  1. 确认第一个要查询的表,第一个要查询的表称为驱动表,假设以 t1 为驱动表。在驱动表中查询满足 m1 > 1的记录(有m1=2和m1=3这2条);

  2. 根据从驱动表获取的每条记录,一次次的到 t2 表匹配,所谓的匹配是指符合t2搜索条件的匹配。t2就是被驱动表。由于 t1 得到 m1=2和m1=3这2条记录,因此需要在t2发生两次查询:

    第一次查询 t2.m2 = 2 and t2.n2 <'d',第二次查询 t2.m2 = 3 and t2.n2 <'d'。

    而且每次在被驱动表t2发生的查询都是全表查询(因为没建立索引),即t2发生了2次全表扫描。

因此我们得到的结论是:驱动表只需查询1次,而被驱动表需要根据从驱动表筛选出来的记录进行多次查询。

注意:并不是先将满足 t1 条件的驱动表记录先收集起来再去被驱动表中查,而是每获得一条驱动表记录就立即到被驱动表查询并将查询到的结果记录立刻发送客户端,然后重复。这样做是为了节省内存(如果满足条件的驱动表记录很多,把他们收集起来就会浪费一大片内存空间)。

2. 内连接和外连接

连接查询可分为内连接和外连接,对于内连接的两个表,若驱动表中的记录在被驱动表找不到匹配的记录,则这些驱动表的记录 不会加入到最后的结果集。前面提到的连接都是内连接。

对于外连接的两个表,即使驱动表中的记录在被驱动表中没有匹配的记录,也仍然需 要加入到结果集。

外连接可以细分为2种:

  • 左外连接(left join)选取left join左侧的表为驱动表。
  • 右外连接(right join)选取right join右侧的表为驱动表。

3. 区分过滤条件

连接查询有两种不同性质的过滤条件。

WHERE 子句中的过滤条件:

不论是内连接还是外连接 凡是不符 合 WHERE 子句中过滤条件的记录都不会被加入到最后的结果集。

ON 子句中的过滤条件:

对于外连接的驱动表中的记录来说,如果无法在被驱动表中找到匹配 ON 子句 中过滤条件的记录 那么该驱动表记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL 值填充。

ON 子句是专门为"外连接驱动表中的记录在被驱动表找不到匹配记录时 是否应该把该驱动表记录也加入结果集中"这个场景提出的。所以,如果把 ON 子句放到内连接中, MySQL 会把它像 WHERE 子句一样对待。也就是说 内连接中的 ON 子句 和 WHERE 子句是等效的,在外连接中则是不等效的。

举个例子:

student 表只有一个主键索引 number、score有一个联合主键索引 number、subject。

执行:

SELECT * FROM student, score WHERE student.number = score.number;

结果少了王五的记录,因为这是内连接。 

4. 连接查询的原理

为什么有的连接查询快如闪电,有的慢如蜗牛。其实连接查询无非是以下3种套路。

  • 嵌套循环连接

上面的例子t1和t2表的查询就是嵌套循环连接,也是最简单的连接方式。如果涉及到3个表的连接,则先把第1个表作为驱动表,第二个表作为被驱动表得到查询结果,再把第1、2个表连接查询的结果作为驱动表,第三个表作为被驱动表。

它的逻辑如下:

for each row in t1 satisfying conditions about t1{ 
  for each row in t2 satisfying condtions about t2 { 
    for each row in t3 satisfying conditions about t3 ( 
      send to client;
    }
  }
}
  • 使用索引加快连接速度

这其实是一种使用了索引的嵌套循环连接。不用索引,则每次在被驱动表的查询就是全表扫描。用了索引,每次在被驱动表的查询都会走索引。

还是之前的例子(t1作为驱动表):

SELECT * from t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2. m2  AND  
t2.n2 < 'd';

假设 t1.m1 > 1的记录有2条,那么该连接查询相当于对 t2 进行2次查询:

SELECT * from t2 WHERE t2. m2 =2  AND  t2.n2 < 'd';
SELECT * from t2 WHERE t2. m2 =3  AND  t2.n2 < 'd';

此时有2种加索引的方式:

1、给 t2.m2 加索引,那么每次对t2的每次查询都是ref访问方式的查询(如果 m2 是t2的主键或者不允许存NULL的唯一键,那么这种访问方式在连接查询叫做 eq_ref)而不是全表扫描。

2、给 t2.n2 加索引,那么每次对t2的每次查询都是range访问方式的查询而不是全表扫描。

  • 基于块的嵌套循环连接

该连接方式是对没有用到索引的嵌套循环连接的优化,减少重复磁盘IO。我们考虑一下下面的sql涉及到多少次IO:

SELECT * from t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2. m2  AND 
 t2.n2 < 'd';

假设 满足 m1 > 1有100个记录,t2的主键索引的叶子节点有1000个页,且t1和t2表没有任何索引。

因此需要 t2 发生100次全表扫描,每次全表扫描会发生1000次磁盘IO(这里不考虑 buffer pool的缓存。就算考虑缓存,buffer pool 也没办法把所有页都缓存,所以每次扫描到 t2 后面的页,t2前面的页可能就失效了,等到第二次扫描 t2的时候,又要从磁盘读取),那么总共会发生 100 * 1000次磁盘IO。

为了减少重复的磁盘IO,我们可以将驱动表的查询结果放到一个叫做 Join Buffer(连接缓冲区)的内存空间缓存起来,只在t2发生一轮全表扫描,就对 join buffer 中的所有记录的连接字段进行比较。

例如 满足 t1.m1 > 1 有100个记录,join buffer只能缓存 50条,需要进行两次t2的全表扫描,第一次从t2全表扫描过程中,t2的第一个叶子页读到内存,将 join buffer 中的 50个 m1字段和第一个叶子节点的m2字段一个个比对(这里用不了二分查找,因为m2不是索引字段),再将 50 个 m1 和第二个叶子页的m2一个个比对,依次类推。

这样只需要发生 2*1000 = 2000次磁盘IO。

加入了 join Buffer 的嵌套循环 连接算法称为基于块的嵌套循环连接 (Block Nested-Loop join)算法。

join buffer默认大小 256K,用到了索引的连接查询是不会用到 join buffer 的。

join buffer中并不会存放驱动表记录的所有列,只有查询列表(select 的字段)中t1的列 和where条件中t1的列才会被放到 Join Buffer 中。这也再次提醒我们,最好不要把*作为查询 列表,这样可以让join buffer 放更多记录。

10、计算成本

1. 什么是执行sql的成本

MySQL在执行一个查询时可以有不同的执行方案,mysql的优化器会选择其中成本最低的那种方案执行。

那么“成本”具体指什么?

包括2方面:

IO 成本:把sql涉及的所有页从磁盘到内存的加载过程花费的时间称为 IO 成本,包括IO次数和每IO的时间(即随机IO还是顺序IO,后者的每次IO比前者快很多)。

CPU成本: 页读取到内存后,从页中(包括叶子页和非叶子页)筛选记录(页内的二分查找或直接遍历)、搜索条件比对、排序和分组等操作损耗的时间称为 CPU 成本。CPU成本是发生在内存中的。

设计 MySQL 的大叔规定读取一个页面花费的成本默认是1.0;内存中读取以及检测一条记录是否符合搜索条件的成 本默认是 0.2。1 和 0.2 这种数字叫做成本常数。成本常数可以通过修改mysql配置来修改。

优化器计算sql成本的时候只会粗略的计算。

可以简单的认为:

  • IO成本 = 本次sql读取的总页数 * 1;
  • CPU成本 = 本次sql读取到内存的所有叶子页的记录数总和 * 0.2;

2. 计算成本的过程

例12:计算SQL成本

SELECT * from  single_table WHERE
key1 IN ( ' a' , 'b' , ' c ' )  AND
key2 > 10 AND key2 < 1000 AND
key3 > key2 AND
key_part1 LlKE '%hello%'  AND
corrrnon_field = '123';

假设 key1和key3是普通索引,key2是唯一索引,key_part1是联合索引的第一个列,common_field不是索引。

mysql的优化器会按照如下步骤计算执行成本。

  • 根据搜索条件,找出所有可能使用的索引

也就是 explain 命令执行结果中的 possible keys列。

本例子中 key1 和 key2是可能用到的索引。

key3 > key2 这个搜索条件的索引列由于没有与常数进 比较 因此不能产生合适的扫 锚区间,不能用到key3索引。

key_part1 的like不是以字符串开头的通配符匹配,用不到索引。

  • 计算全表扫描的代价

全表扫描查询成本 = IO 成本 + CPU 成本 = 磁盘IO读取的页数 * 1.0 + 扫描的记录数 * 0.2。

假设 single_table 的总页数为 97 页,9693条记录,那么全表扫描查询成本为:

(97 * 1.0  + 1.1) + (9693*0.2 + 1.0)= 2037.7

这里多出来的 1.1 和 1.0是微调值,可以不用理会。

上图所示,我们可以用 show table status like '表名' 得知表的总字节数(Data_length)和行数(Rows)。

页数 = Data_length / 1024 / 16 = 97页。

对于Innodb而言,Data_length 是聚簇索引占的存储空间,对于MyISAM,Data_length 是数据文件(MYD)的大小。

注意:全表扫描的页数其实只读取了所有叶子页和从根节点到最左侧叶子页之间的几个非叶子页。但设计 MySQL 的大叔在计算全表扫描成本时直接使用聚簇索引占用 的所有页数作为计算 IO 成本的依据。

  • 计算使用不同索引执行查询的成本

key1 和 key2是可能用到的索引。Mysql会分析单独使用key1和key2索引的成本以及使用索引合并的成本。

使用索引查询的总成本分为在二级索引上的成本(扫描区间的个数(IO成本) 和 回表记录数(CPU成本)) + 在聚簇索引上的成本(回表次数(IO成本) 和 叶子节点扫描的记录数(CPU成本))。

无论一个扫描区间在B+树上占用了多少个页,查询优化器粗暴地认为读取索引的 一个扫描区间的 IO 成本与读取一个页面的IO 成本是相同的。

另外mysql 在评估回表操作的 IO成本时很豪放:他们认为每次回表操作都相当于访问一个页面的耗时。

综上:

二级索引上的成本 = 扫描区间的个数 + 回表记录数 * 0.2

聚簇索引上的成本 = 回表记录数(一条记录就需要回表1次) + 扫描主键索引叶子节点页的记录数 *0.2

其中 扫描主键索引叶子节点页的记录数 可近似看做 回表记录数(但实际上前者肯定大于等于后者)。所以:

聚簇索引上的成本 = 回表记录数(一条记录就需要回表1次) + 回表记录数 *0.2

下面我们具体看一下使用key1 和 key2 的SQL成本如何计算:

a. 计算用唯一索引key2的SQL成本

key2条件是key2 > 10 and key2 < 1000;

在二级索引的成本:

key2 > 10 and key2 < 1000 这个条件只有一个区间:(10, 1000),所以在二级索引的IO成本 = 1.0

回表记录数需要计算:平均一个叶子页有多少记录数 n,以及 key2 = 10 到key2=1000之间有多少个页 p,n * p就是回表记录数。为了计算 n 和 p,就需要真的从key2索引的B+树读取页了。

简单来说,计算两点之间的记录数,需要在二级索引从根节点往下找到区间左边界(10)的叶子节点以及它对应的记录,以及从根节点往下找到区间右边界(1000)的叶子节点以及它对应的记录(在二级索引定位一条记录的时间可以忽略不计),我们简称为b记录和c记录,他们所在的页叫做页b和页c。

如果b和c记录在同一个页中,则可以直接计算出b~c间的记录数,也就是需要回表的记录数。

如果b和c记录不在同一个页中,需要沿着 b记录向右读10个页从而计算出每个页的平均记录数 n(接近于顺序IO,因此速度很快)。

另外要从b记录和c记录所对应的(父节点)目录页a得到页b和页c之间的页数 p(而不用真的沿着叶子节点从页b读到页c来计算p)。

回表的记录数 = n * p。

需要知道,为了计算成本,mysql可能会真的到B+树读取页,但会把这个损耗控制到可以忽略的范围。

假设这里得到回表记录数是95,则CPU成本 = 95*0.2 = 19。

所以在二级索引的成本为 19+1=20;

在主键索引的成本:

IO成本 = 回表记录数 = 95

CPU成本 = (10, 1000)之间的记录数 * 0.2 = 95 * 0.2 = 19

所以在主键索引的成本为 19+95=114;

综上,总成本 = 20 + 114 = 134。

b. 计算用普通索引key1的SQL成本

key1条件是 key1 in ('a', 'b', 'c');是3个单点区间。

在二级索引的成本:

扫描区间的数量为3个区间,需要回表的记录数假设为 35条a + 44条b + 39条c = 118。

在二级索引付出成本 = 3 + 118 * 0.2 = 26.6

在主键索引的成本:

在主键索引付出成本 = 118 + 118 * 0.2 = 141.6

综上,总成本 = 26.6 + 141.6= 168.2。

问题:上例中是否可能使用索引合并(Index Merge)

由于是范围查询,不满足使用 Intersection 合并的条件,所以并不会使用索引合并。

3. index dive

在评估二级索引 + 回表方式的成本时,mysql直接访问索引对应的 B+ 树来计算某个扫描区间内对应的索引记录条数(回表记录数)的方式称为 index dive。

index dive 的具体过程是在二级索引中分别从根节点往下找到区间的最左边记录和最右边记录(例如 一个区间是 ['a', 'a'],那么需要找到a的最左记录 和 a的最右记录),然后再计算这两条记录之间的记录数,得到的结果就是回表记录数。

评估成本时所发生的index dive次数取决于区间的个数,例如 key2 > 10 and key2 <1000,只有一个区间,因此只会发生1次index dive。对于 key2 in ('a', 'b', 'c'),产生了3个区间:[a, a],[b, b],[c, c],会发生3次 index dive。

索引条件中的扫描区间个数越多,优化器在评估成本时发生的IO次数越多。有零星几个单点扫描区间的话,损耗可以忽略不记。

但是很多人写sql的时候会在 in 子句塞很多值,如果in中有20000个值,就会发生20000次 index dive,这个成本甚至比直接扫描全表的成本大,而且这是在计算成本的阶段就已经有这么大的成本了,还不是在真正执行的阶段。

为了避免这个问题,mysql规定如果一个sql语句的条件区间超过系统变量 eq_range_index _ div_limit 规定的值(mysql 5.7 之前是10个区间,mysql 5.7及之后是200)就不进行index dive。而是直接根据系统表中的索引统计数据来计算成本,索引统计数据可以通过 “ SHOW INDEX FROM 表名 ”查询得到。

索引统计数据中有一个属性是索引的基数,也就是不重复值的数量,用表的行数 rows 除以基数 c 可以得到该字段的一个值平均会重复多少次。

in中的参数个数 * rows / c = 需要回表的记录数。

使用这种评估SQL成本的方式,好处是不用访问B+树,节省了评估SQL成本所带来的的耗时,坏处是计算出来的成本误差可能很大。

11、连接查询的成本

由前面内容我们知道,连接查询本质上是用驱动表的条件对驱动表查询一次得到结果集A,再根据结果A在被驱动表查询多次的过程(嵌套循环连接)。

因此连接查询成本由两部分成:

  • 单次查询驱动表的成本;
  • 多次查询被驱动表的成本;

我们把查询驱动表后得到的记录条数称为驱动表的扇出,扇出值越小,被驱动表的查询次数也就越少。

连接查询总成本 = 单次访问驱动表的SQL成本 + 驱动表扇出值 × 单次访问被驱动表的成本。

其中 单次访问表的成本 在前面已经介绍过了,所以关键在于如何计算驱动表扇出值。

扇出值的计算有时候很容易,有时候很难。

例如计算下面语句的扇出值:

SELECT * FROM s1 INNER JOIN s2 WHERE 
s1.key2 >10 AND 
s1.key2 < 1000;

那么满足 key2 > 10 且 key2 < 1000的记录数就是扇出值,而这个记录数可以在二级索引key2的B+树通过 index dive 操作得出。

又例如:

SELECT * FROM s1 INNER JOIN s2 WHERE 
s1.key2 >10 AND s1.key2 < 1000
AND s1.common_field = 'xyz';

你无法得出  key2 > 10 且 key2 < 1000 下,common_field = 'xyz'的记录数有多少,因为common_field在主键索引的叶子节点中,你需要遍历主键索引B+树的叶子节点才能知道答案,但在计算成本的阶段这么干未免小题大做,消耗过大了。

此时mysql需要更复杂的机制做大致的预估。

  • 多表连接的成本穷举分析

对于内连接,由于驱动表是不固定的,不同的表作为驱动表,查询成本也不同,也就是说优化器还需要考虑最优的表连接顺序

所以优化器会计算出所有不同表作为驱动表的成本情况,这相当于排列组合,如果对4个表进行连接,会有 4*3*2*1种成本。

假设连接的排列组合顺序有很多,为了避免计算成本花的时间太长,mysql是不会无休止的计算下去,而是有一个连接数阈值,如果一个sql的连接表数超过了这个阈值,mysql只会对数量等于该阈值的表进行穷举分析。

此外,mysql还会在穷举过程中记录当前最小成本,例如如果ABC这种连接顺序下,它的成本是穷举到目前为止最小的成本:10。在计算下一种情况BCA的成本时,如果发现B和C的连接成本已经大于10,就不会再计算 result(BC) 与 A 的连接成本了。

12、如何善用索引

1. 只为用于查询、排序和分组的列创建索引。

2. 为基数大(即重复值少)的列建立索引(因为重复值多意味着回表次数多)。

3. 索引列的类型尽量小(例如int类型,较短的varchar类型,能用int就不用bigint,能用tinyint就不用int),这决定了页内能容纳记录的个数(一次磁盘IO能将更多的目录项和记录加载到内存),也决定了树高。

这个建议尤其适用在主键上,因为所有二级索引的页都会存一份记录的主键值。

4. 为列前缀建立索引,例如不要对一个varchar(100)的字段c1建立索引,而是对 c1的前10个字节建立索引。

原因有二:一是为了让页内能容纳更多的记录和目录项,二是字符串的比较,其复杂度会随字符串长度增加而增加;

另外需要注意:为列前缀建立索引,该索引只能用于查找,无法用于排序。例如:

ALTER TABLE single_table ADD INDEX idx_key1(key1(10));
SELECT * from single_table ORDER BY key1 limit 10;

这个排序没有用到key1的索引,因为B+树只对key1的前10个字节排序了,没有给整个key1值排序。

5. 覆盖索引(即避免使用*,而是在sql中注明要查询的列):可以彻底避免回表,对于二级索引而言,覆盖索引会让优化器会选择使用二级索引而不会选择全表扫描。

6. 让索引列以列名的形式在搜索条件中单独出现(别使用函数或对列计算)。

7. 主键的插入尽可能按顺序插入:这是为了避免也分裂。对于二级索引,就没办法要求它的插入页按顺序了,这是业务需求决定的。

考虑一种情况:一个页按照id为 1、3、5、7、9、2、4、6、8、10的顺序插入,这会导致页分裂.

但是如果 1、2、3、4、5、6,我删除id=2的记录,再插入id=2的记录,是不会导致页分裂的,他会重新占用页内的已删除空间。

五、MySQL 事务

1、事务简介

1. 事务的状态

一个事务是一系列的SQL操作,我们可以把一个事务的不同阶段划分为以下状态:

  • 活动状态:事务的sql正在执行的状态;
  • 部分提交状态:事务的最后一条sql操作在内存中完成,但还未刷盘;
  • 失败状态:前两个状态下突然出现错误或故障的状态;
  • 终止状态:即回滚后的状态;
  • 提交状态:刷盘成功后的状态;

2. 开启事务

执行begin或start transaction可以开启一个事务,后者还可以修饰符控制事务的行为。

例如:

# 开启一个只读事务和一致性读
start transaction read only, with consistent snapshot;

# 开启一个读写事务和一致性读
start transaction read write, with consistent snapshot;
  • Read Only:表示这是一个只读事务,该事务内的操作只能是读操作,不能有写操作。
  • Read Write:表示读写事务,该事务内的操作可读可写(默认情况)。
  • With Consistent Snapshot:启动一致性读。

一条独立的sql也是一个事务,事务可以设置为自动提交或手动提交,手动提交的事务必须显式的使用 begin 和 commit语句,但是手动提交的模式下,某些情况即使不执行 commit 也会触发自动提交,称为隐式提交。以下情况会发生隐式提交(注意,隐式提交属于手动提交的情况,而不是自动提交的情况):

a. 执行DDL语句(即建表,删表,改表字段);

b. 上一个事务还没提交或回滚是就又使用 start transaction 或 begin 开启了另一个事务,就会隐式的提交上一个事务;

c. 执行主从复制相关的语句、或者导入数据的语句。

3. 保存点

mysql 允许在一个事务的多个语句执行过程中打点,或者说在某个位置设置保存点。如果发生错误,可以选择回滚到指定的保存点而不是回滚到事务开始的状态。

# 打点语句:
savepoint 保存点名称;

# 回滚到某个保存点:
rollback [work] to [savepoint] 保存点名称;

# 删除保存点:
release savepoint 保存点名称;

2、redo日志的格式和结构

1. 为什么需要redo日志

一个事务做出了若干个数据变更,在事务提交之前,这些变更已经写入到内存 buffer pool中,但是还未写入到数据表对应的磁盘页(一来写入磁盘开销大,二来事务提交前就写入磁盘不符合原子性)。而且也不会在事务提交的时候(执行commit的时候)写入磁盘页。之所以执行了commit时仍不刷盘原因有两点:浪费 和 随机IO

浪费:记录刷盘到表的单位是页,如果我们在事务中只修改了页面的1个字节就要对一个页刷盘,未免效率太低。还不如等多个事务对这个页发生了多处修改之后再刷盘到表。

随机IO:一个事务可能包含多条语句,就算事务里只有一条语句也可能会修改到多个页面。最可怕的是修改的这些页面可能不相邻甚至在磁盘中隔得很远。也就是说,一次事务很可能会发生多次随机IO。

这些随机IO必然是要发生的,但是每提交一次事务就发生多次随机IO,这未免也太频繁,可能阻塞用户线程对请求的处理。

所以每次提交事务就更新相应磁盘页会带来 效率低 和 单次事务因多次随机IO耗时太长 这2个问题。

为了解决这个问题,mysql在提交事务时将事务发生的变更记录到一个日志文件中,并且使用定时任务异步的将日志的变更内容刷新到表的磁盘页中。

这样的日志叫做 重做日志(redo 日志)。将更改的数据刷盘到redo日志而不是刷盘到数据表有2个好处(对应上面两个坏处):

  1. redo日志记录的变更内容少,只记录事务涉及到的数据行(具体是数据行所在的表空间ID、页号、页内偏移和更新后的值),而不是记录行所在的整个页。
  2. redo行记录是顺序写入磁盘的(顺序IO),直接按照日志产生时的顺序追加到redo日志文件的末尾即可,顺序IO比随机IO快很多。

那么到底redo日志长什么样子呢?

redo日志的行格式:

  • type:redo 日志的类型。MySQ 5.7.22 版本中,一共为 redo 日志设计了 53 种不同的类型。这里的redo日志类型,是指 redo 记录行的类型,一个redo文件内有不同类型的redo行;
  • space ID:表空间ID;
  • page number:页号;
  • data:这条 redo 日志的具体内容;

2. redo日志的类型

redo日志(行)可以分为简单redo日志和复杂的redo日志。

简单的redo日志(行)只需记录修改页面的页号、页内偏移量和修改内容,例如:

MLOG_1BYTE类型的redo行:表示在页面的某个偏移量处写入1字节的redo日志。类似的还有 MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE。

它们的格式如下所示:

 

MLOG_WRITE_STRING:表示在页面的某个偏移量处写入一个字节序列的redo日志。 

复杂的redo日志包含:

  • 插入一条使用非紧凑行格式(REDUNDANT)的记录的redo日志;
  • 插入一条使用紧凑行格式 (COMPACT、DYNAMIC、COMPRESSED) 的记录的redo日志;
  • 创建一个存储紧凑行格式记录的页面的 redo 日志;
  • 删除一条使用紧凑行格式记录的 redo 日志;
  • 从某条给定记录开始删除页面中一系列使用紧凑行格式的记录的 redo 日志等等;

3. 以组的形式写入redo日志

事务里的一条sql语句可能会修改多个页,就算只修改一个页,也可能修改一个页的多个地方,所以事务中的一条sql语句可能会产生多条redo日志。Innodb将一个sql语句产生的多条redo日志进行分组,每组包含一条或多条redo行,一个组内的redo行具有原子性,即不可分割。

分组的规则如下:

  • 向聚簇索引的B+树的一个页面插入、修改和删除一条记录所产生的的一条或多条redo日志是一组,是不可分割的;

  • 向某个二级索引对应的B+树的页插入、修改和删除一条记录产生的一条或多条redo日志是一组,是不可分割的。因此,如果一个表有3个二级索引,则插入1条记录会产生3个关于二级索引的redo日志组。

还有一些其他的不可分割组,我们不再细究,只需要知道在一个页上的一次操作就会产生一个redo日志行,而一条表记录的增删改在B+树页面上引发的多个操作会产生多个redo日志行,并且这些redo日志行是一组不可分割的redo日志组。

以插入一条记录为例,我们只关注其在主键索引产生的redo日志,假如插入的行所在的页为页A,分2种情况:

  1. 页A有空闲空间,足够容纳一条待插入记录,只会产生一条insert类型的redo日志,该情况称为乐观插入;此时该插入操作生成的redo日志组里只有一条redo日志(实际上,乐观插入也可能产生多条redo日志)。
  2. 页A没有空闲空间,插入一条记录会导致页分裂,会涉及创建新页,将旧页的部分数据拷贝到新页,在目录页的添加一个目录项的行并让该目录项指向这个新页,等操作。该情况称为悲观插入;此时插入操作生成的redo日志组里有多条redo日志。

需要注意:假如发生悲观插入,往redo日志文件只写入了一个组的部分redo行,此时mysql发生故障挂掉,那么恢复故障时,mysql是不会恢复一个不完整redo日志组内的操作的。因此,一个redo日志组内的redo日志具有原子性,当然啦,这与事务的原子性是两回事,请不要混淆。

mysql 如何保证redo日志的原子性,或者说如何将多个redo日志行归为一组?

InnoDB的设计者会在一组redo日志的最后加上一条特殊类型的redo日志行(MLOG_MULTI_REC_END),我们可以称之为end类型的redo日志。end类型的日志只有一个type字段。

 

对于一组里只有一条redo日志的情况,是不会在末尾加上 end 日志,而是直接将type字段的第一个比特位置为1,表示这是一个单条redo日志的组。

Innodb把一组不可分割的日志记录称为一个 Mini-Transaction,我们简称为MTR。系统故障恢复时不会恢复一个不完整的MTR。

一个MTR内不可能出现其他MTR的redo记录,因为一个MTR内的redo记录是不可分割的。

一个事务可以包含若干条语句,每一条语句又包含若干个 MTR.,每个 MTR 又可以包含着若干条 redo 日志(行):

 

4. redo日志页

InnoDB将MTR放在大小为512字节的页中(称为 block,你叫他日志块或日志页都行)。

 

无论是redo日志缓冲区还是redo日志文件都是以512字节的block为单位的,而且内存和磁盘中的redo block是连续的: 

图中 block body 内存放的就是MTR日志组。如果一个日志组的日志条数很多,超过了一个block大小,那么这个MTR就会跨多个block存储。

5. redo 日志文件组

redo日志文件默认有2个,存在于mysql的数据目录下。redo 文件的个数可调整,这些文件形成redo日志文件组。

在逻辑上,这些redo日志文件组是一个成环的队列,一开始向名为 ib_logfile0 的redo日志文件中写入,满了之后再向 ib_logfile1的redo文件写入,以此类推,当 ib_logfile n(最后一个redo文件)被满了就又会从ib_logfile0写入。

我们将这多个redo文件在逻辑上看做一个整体空间,下文所有提及“redo日志文件的偏移量” 或者 “redo日志文件的位置” 是指在这整个空间的偏移量,而不是在单独某一个redo文件内的偏移量。

一个ib_logfile内是由多个连续的 redo log block 组成的,每个redo文件的前4个block存储一些管理信息,后面的block才存储redo记录内存。

这样的成环链表文件组需要考虑一个问题,即后写入的 redo 日志可能覆盖前面写入的 redo日志。

为了解决这个问题,innodb提出了checkpoint的概念(后面介绍)。

redo日志写入过程如下:

1、将事务中产生的redo日志写入redo日志缓冲区(redo log buffer)。

Innodb维护了一个 buf_free 指针,该指针表示后续写入的redo日志应该记录到 日志缓冲区中的位置。在redo日志缓冲区(redo log buffer)为空的时候,buf_free指向 log buffer 的第一个block的第12个字节处,前面12个字节是这个 block 的 header信息。buf_free指针往后的区域是 redo log buffer 的空闲区域。

如下所示:

一个MTR内的多条redo日志是不可分割的,因此即使有多个事务并发的发生,这些事务产生的 redo 记录也是不会交错的写入到redo 日志缓冲(log buffer)的,一个MTR内的日志会先暂存到一个地方,直到整个MTR组内的记录完整了之后才会全部复制到 log buffer中。

MTR内的redo记录不会和其他MTR内的redo记录交错,但是一个事务的多个MTR是可以和另一个事务的多个MTR交错存储的,假设 T1、 T2 的两个事务,每个事务都 包含 2 个MTR。

事务T1 的两个 MTR 分别称为 mtr_t1_1、 mtr_t1_2;

事务T2 的两个 MTR 分别称为 mtr_t2_1、 mtr_t2_2;

他们在log buffer 中可能是这样的: 

在事务执行的过程中,除了将redo日志写入到log buffer 之外,还会将MTR执行后修改过的页加入到 buffer pool的flush链表中。

2、redo日志从log buffer刷盘到redo文件,刷盘时机有以下几个:

a. log buffer空间不足时(log buffer的空间剩余约50%左右就会刷盘);

b. 事务提交后的某个时刻刷盘,具体看刷盘策略,可能会在事务提交时马上刷盘、也可能是每秒一次的频率刷盘;

c. 关闭服务器时;

d. 做checkpoint时会把 redo文件从 checkpoint lsn开始的一部分undo日志刷盘(从flush链表刷盘脏页到数据页,然后修改 checkpoint lsn);

这里的刷盘是指日志刷盘而不是表数据刷盘,也就是说是指 redo的block 从 log buffer 刷盘到 redo 文件,而不是 buffer pool 的 page 刷盘到索引的B+树上。

redo日志在事务执行过程(而非commit时)就写入到log buffer,redo日志的刷盘也不一定是在commit一个事务的时候发生的,可能是在commit前发生的,比如log buffer不足的时候。

6. redo日志序列号 log sequence number(LSN)

LSN是innodb的一个全局变量,记录从服务开启到当前时刻,所产生的了的redo日志的总字节数。初始LSN为8704。

LSN 包含 log buffer内的 block header(12字节) 和 block tailer 字节(4字节)。

每一个MTR有属于它自己的LSN编号,从而标记一个MTR的新旧程度,一个MTR的LSN就是该MTR写入log buffer时的全局LSN号。

例如下面的一个例子,log buffer 包含 2个 MTR,横跨了 3 个block,其中MTR1占200字节,MTR2占1000字节。

一开始LSN为8704,log buffer初始化后,buf_free指针指向log buffer 的 第12个字节处,此时LSN为 8704 + 12 = 8716。

MTR1写入log buffer,MTR1的LSN就是8716。最新的LSN = 8716 + MTR1 的字节数 = 8916。 

MTR2写入log buffer,MTR2的LSN就是就是8916。最新的LSN = 8916 + MTR2 的字节数 + 跨越的block header 和 tailer的长度 = 9948。 

结论是:每一组由 MTR 生成的 redo 日志都有一个唯一的 Isn 值与其对应;Isn 值越小 ,说 redo 日志产生得越早。

7. 刷入磁盘的LSN(flushed_to_disk_lsn)

innodb 提出 flushed_to_disk_lsn 表示已经刷入磁盘的redo日志的lsn字节数,也是下一个要刷盘的lsn。

如果 flushed_to_disk_lsn 和 buf_free 重合,说明log buffer 中的所有redo日志都已经刷盘。可以轻松的计算出某个LSN对应的redo日志文件的偏移量: 

例如某个LSN为8916,我要计算这个LSN在redo日志文件的偏移量。

先计算出8916这个LSN以前的总redo日志量:8916 - LSN初始值 8704 = 216。

redo日志文件的头信息长2048,redo日志要记录在头信息之后,因此这216个字节要记录在2048之后的位置,因此它在日志文件的偏移量为:2048 + 216 = 2260。

8. flush链表中的脏页

flush链表中的脏页控制块记录了脏页的两个属性:oldest_modificaton 和 newest_modification 表示第一次修改这个页的MTR的LSN号 和 最后一次修改这个页的MTR的末尾的偏移量(也就是下一个MTR的LSN),我们可以简单的认为这两个属性是第一次修改这个页的时间和最后一次修改这个页的时间。

举个例子:

一开始LSN为8716,字节数为200的MTR1修改了数据页a,页a链入flush链表,它的 oldest_modification 是8716,newest_modification是MTR1的末尾的偏移量(8716 + 200 = 8916),它也是MTR2的LSN号。

字节数为1000的MTR2修改了数据页b 和 c,页b 和 c链入flush链表。 

MTR3修改了数据页b 和 d,页d链入flush链表,并且修改页b控制块的 newest_modification 为MTR3的末尾的偏移量。

flush 链表中的脏页按照第一次修改发生的时间顺序进行排序,也就是按照 oldest modification 代表的 LSN 值进行排序。被多次更新的页面不会重复插入到 flush 链表中,但是会更新其 newest modification 属性的值。

9. checkpoint

前面说过checkpoint用于标明redo日志文件组中可以被新日志覆盖的位置,目的是在容量有限的 redo log 日志组被写满后,防止redo文件前面的日志被覆盖。

innodb 提出了checkpoint lsn 这个全局变量表示当前系统中可以被覆盖的redo日志总量是多少,即 redo日志文件中比checkpoint lsn小的lsn的MTR日志都可以被覆盖。

checkpoint机制的原理:

redo日志文件可以被覆盖的MTR日志,就是那些已经被刷盘成功的页,也就是已经弹出flush链表的脏页。

flush链表中的脏页是按照 oldest_modification 排序的,脏页刷盘时也是按照这个顺序对flush链表刷盘的。

所以 flush 链表中第一个页(即flush链表中最早被修改的脏页)对应的 oldest_modification 的LSN值就是checkpoint lsn。

可以轻松的根据checkpoint lsn计算出该lsn在日志文件中的偏移量,这个偏移量称为 checkpoint offset。

checkpoint lsn 和 checkpoint offset 以及checkpoint no(发生checkpoint操作的次数)会被记录在redo文件的文件头(redo文件的前2048个字节,即redo文件的前4个block中),具体说应该是记录在文件头的 checkpoint1 和 checkpoint2字段,当 checkpoint no 为偶数就写到 checkpoint1,奇数则写到 checkpoint2。

checkpoint操作是指从 flush 链表得到当前checkpoint lsn, 并计算checkpoint offset,并将这些checkpoint信息写入到 redo日志文件组的头信息中。因此checkpoint操作会涉及到写入磁盘的,是有开销的。

需要注意,“脏页刷新到磁盘 " 和 "执行一次 checkpoint操作" 是两件事,由不同的线程完成。

下面我们看一个例子:

一开始,整个redo日志的全局最新lsn是最后一个脏页d对应的MTR的最后一个字节序号10000。

而flush链表的第一个脏页是页a,它的oldest_modification 是 8716,因此redo日志文件组的 checkpoint lsn 是 8716。

下一时刻,页a刷盘到数据表,因此 checkpoint lsn 会更新为下一个要刷盘的页(就是页c)的LSN 8916,如下图:

图中,checkpoint lsn之前的redo记录对应的脏页都已经刷盘成功,checkpoint lsn 到 flushed_to_disk_lsn的redo日志对应的脏页还未刷盘成功,但已经成功刷盘到redo文件中。而 flushed_to_disk_lsn 到 最新lsn 之间的部分是log buffer 上还未刷盘到 redo文件的redo日志。

问题:如果系统频繁的执行DML操作,导致脏页的刷盘慢于redo日志的增长,也就意味着最新 LSN 减去 checkpoint lsn 大于redo日志文件组的大小,那么还是会出现redo日志写满的情况,此时怎么办?

这时候 用户线程 就要被迫刷新脏页(刷新脏页这件事本来是由后台线程来完成的),刷新完脏页之后,checkpoint lsn就会增长,这些脏页对应的redo日志就可以被覆盖了。但这会阻塞用户线程处理sql请求。

3、redo日志实现崩溃恢复

崩溃恢复时会从redo文件恢复,系统需要确认redo文件的恢复起点和终点。redo文件组中,从起点到终点的这段redo日志对应的数据正是写入了 redo文件 但还未写入到表文件的数据。

1. 确认恢复起点

我们知道redo文件中 checkpoint_lsn 之前的redo日志对应的页是已刷盘了的,而checkpoint_lsn之后的redo日志可能已经刷盘,可能还没刷盘,这是因为 checkpoint操作 和 脏页刷盘操作 由不同的后台线程分开执行的,checkpoint lsn之后的部分字节也可能被写入到表空间。因此故障恢复会以redo文件的 checkpoint lsn 作为起点。

举个例子,flush链表a->b->c->d,a是第一个脏页,a刷盘,后台线程执行checkpoint,checkpoint_lsn 更新为 LSN_b。之后页b刷盘,还未来得及执行checkpoint操作就宕机,系统故障恢复的时候会以 redo文件中的checkpoint_lsn(LSN_b) 作为起点,但实际上可以以 LSN_c作为起点来恢复,原因是页b已经刷盘成功。

只要把redo文件的 checkpoint1 和 checkpoint2 这两个 block 中的checkpoint_no 值读出来比一下大小,哪个更大,就说明哪个checkpoint_lsn 是最新checkpoint_lsn,同时可以读到该 checkpoint_lsn 对应的redo文件偏移量 checkpoint_offset。

2. 确认恢复的终点

恢复的终点应该位于 flushed_to_disk_lsn,即redo日志中最后一个MTR的最后一个字节在redo文件组中的偏移量。如何定位到这个位置?

block的头部有一个属性记录了当前block使用了多少字节的空间(是LOG_BLOCK_HDR_DATA_LEN属性),对于被填满的block,该值永远为512,如果不为512则这个block就是此次崩溃恢复的最后一个block。

假设这个block的最后一个字节偏移是A,那么redo文件中从checkpoint lsn 对应的偏移量 到 位置A就是我们需要恢复的部分。

3. 如何恢复

正常的做法是从redo文件组的 起点checkpoint_lsn 开始扫描后面的redo日志,按照日志中的内容将对应的页面恢复过来。

Innodb使用了两种优化方法:

1、使用哈希表

用于恢复的多条redo日志会修改多个页面,假如有10条redo日志需要执行,第1/3/9条用于恢复页面A,第2/4/5/7条用于恢复页面B,第6/8条用于恢复页面C,这10条redo日志需要从1到10按顺序恢复。那么一共会发生20次随机IO(从磁盘读取10次页,在内存中做修改后,再写入10次页到磁盘,这还是没考虑从根节点往下寻找的过程)。

之所以会发生这么多次随机IO,是因为页面重复读取,例如执行 redo1 时读取了一次页A,执行redo3时又读取了一次页A。

为了减少redo日志执行过程的磁盘IO,Innodb维护一个hashmap,key是redo日志所记录的表空间ID 和 页号,value 是redo日志行,这些修改同一个页面的redo日志行被放到hashmap的同一个槽中组成一个链表。

崩溃恢复时,需要遍历哈希表,由于对一个页进行修改的redo日志都放在一个槽中,因此从磁盘读取一个页到内存之后,可以按按链表上节点的顺序依次在该页面执行redo日志的操作,避免对这个页面重复读取和写入磁盘。

一个链表内的redo日志必须按序执行,否则可能发生错误,例如对某个页面本意操作是先插入一条记录再删除一条记录,不按顺序就变成了先删一条记录,再插入一条记录。

2、跳过已经刷新到磁盘中的页

前面说过,checkpoint_lsn 之后的一部分redo日志对应的页可能是已经刷盘到表里了,Innodb有办法知道哪些页是已经刷盘成功,不再恢复这些页。

B+树的每一个页的头部信息记录了一个 FIL_PAGE_LSN 属性,表示最后一个修改了这个页的LSN日志序列号,也就是最后一个修改了该页的MTR的最后一个字节序号(对应flush链表节点的newest_modification)。

如果一个待修复的页面的 FIL_PAGE_LSN 属性值大于redo链表中最后一个redo日志的lsn,说明它是之前已经刷盘成功的页,无需对其进行修复,会减少一次写IO。

到这里redo日志的原理算是点到为止,其实学习类似的方法论不需要死记,也不是为了应对面试而看。

我们的目的是为了将来在设计一个需求功能相似的软件时能够想起某个开源软件用过某个设计思路能解决类似的问题,并借鉴这些方法论的思路到自己的项目中。

4、undo日志

1. 如何理解undo日志

数据库事务是mysql执行操作的最小逻辑单位,一个事务可以包含一个或者多个sql语句,这些sql要么都执行成功要么都执行失败,这就是数据库事务四大特性之一的原子性

原子性能实现的关键是在失败的时候能够发生回滚,这依赖于 undo log 日志。事务在更改数据之前会将要更改的数据备份到undo log中(undo log会保存更改前的数据,这是一个行级别的历史数据),如果发生了错误或者用户执行了rollback,就可以通过undo log将数据恢复到事务开始之前的状态。

我们可以这样理解undo日志记录了些什么:

  • 在插入一条记录时,至少要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录删掉就好了;

  • 在删除一条记录时,至少要把这条记录中的所有数据内容都记下来 ,这样之后回滚时再把这些内容组成的记录插入到表中就好了;

  • 在修改一条记录时,至少要把被更新的列的旧值记下来,这样之后回滚时再把这些列更新为旧值就好了。

2. undo日志种类和格式

undo日志分为3类:

  • insert类型的undo日志(TRX_UNDO_INSERT_REC);

  • delete操作的undo日志(TRX_UNDO_DEL_MARK_REC);

  • 更新操作的undo日志(TRX_UNDO_UPD_EXIST_REC);

和redo日志不同的是,一个redo日志记录一个页的一处修改,因此一条sql会产生多条redo日志;而一个undo日志记录一条记录的修改,因此一条sql只会产生一条undo日志(某些情况下会产生2条)。

在一个事务中,对某个表执行增删改查操作时会为这个事务分配一个事务id,如果事务中全是查询语句,那么这个事务不会被分配事务id。

之前我们说主键索引的页内记录有几个隐藏列,其中一个隐藏列就是事务id(trx_id),表示对这行记录最近是被哪一个事务所修改。

undo日志会保存到undo页中,undo日志的通用格式包含:该undo日志在undo页的页内地址、undo日志对应的记录所在的表id、undo日志编号、undo日志类型、下一条undo日志的地址。

每一条用户数据记录的一个写操作都对应一条undo记录,因此聚簇索引的每一条叶子节点记录都对应一个undo日志,那么如何知道一条数据记录的undo日志存放在哪里呢?主键B+树的页内记录有一个roll_pointer的隐藏字段,它指向对应的undo日志在undo页中的地址,如下图:

3. undo日志存了什么

下面分别介绍3中类型的日志和他们对应的行为。

1)insert操作对应的undo日志

如果事务执行了一条insert操作,想要回滚该操作,只需根据刚刚被插入的记录的主键id将记录删除即可。因此insert操作的undo日志只需记录新增行的id即可。如果主键是一个联合索引,那么需要记录联合索引中的所有字段值。

当我们向某个表插入一条记录时,需要向聚簇索引和所有二级索引都插入一条记录。但生成undo日志时,只需要针对聚簇索引的行生成一条undo日志。后面说到的delete操作和update操作也是只针对聚簇索引的行的改动来生成undo日志的。

2)delete操作对应的日志

在事务中delete一条记录会发生什么?

我们看下图,一个页面中的已删除的记录会被链入“垃圾链表”中,垃圾链表中的记录所占的空间是可重用空间,页的page header的page_free属性指向垃圾链表的头结点,deleted_flag表示该记录是否已被删除。

当我在事务中delete一条记录,会经历两个阶段:

1、将要删除的记录的 deleted_flag 置为1(其实还会修改记录的 trx_id、roll_pointer值,但这里我们不关注),该阶段称为 delete mark。此时该记录会处于一种介于删除和未删除的中间状态。

2、当事务提交时,pruge线程会把该记录从正常记录链表移到垃圾链表的头结点,也就是真正的把该记录删掉。这个阶段成为pruge。 

生成某条记录的delete undo日志时,只发生了第一阶段,如果对该操作进行回滚,只需将deleted_flag置为0即可。

下面是delete类型的undo日志格式(delete_mark类型):

相比于insert的undo日志,它多出了一下字段:旧记录的事务id 和 旧记录的undo日志指针roll_pointer、旧记录的所有索引字段值。

Q1:为什么要delete的undo日志要记下该记录的所有索引字段值?

因为删除一条记录还要从所有二级索引的B+树中删除对应的记录,记下所有索引字段值方便在事务提交时删除二级索引中对应的记录。

Q2:新记录插入页的时候,如何利用可重用空间?

插入新记录时,先判断垃圾链表的头结点对应的记录所占用的空间是否能容纳新记录,如果不能就直接向页面申请新的空间来存储这个记录(而并不会尝试遍历整个垃圾链表,以找到一个可以容纳新记录的节点),如果可以则直接重用,并让page_free指向垃圾链表的下一个节点。

如果新插入记录所占的空间小于垃圾链表头结点记录所占的空间,就会产生碎片。随着新记录越插越多,碎片就会越来越多,当碎片多到一定比例,innodb就会重新组织页内的记录,组织的过程就是申请一个临时页面,把页面的记录依次紧密的插入到这个临时页,再把临时页的内容复制到本页,这个过程会比较耗费性能。

3)update操作对应的undo日志

分为两类:更新主键的update 和 不更新主键的update。

不更新主键的update 又可以分为 就地更新 和 非就地更新。

a. 就地修改

所谓的就地更新是 被更新的所有列 和 它在更新前的列 所占的存储空间一样大

比如有一个记录是这样的:

执行下面的update语句: 

update t set key1 = "ABCD" and col = "手枪" where id = 2;

这样的话,被修改字段 key1 和 col 在占用空间上没有变化,这就是就地修改。

就地修改在底层的行为最简单,直接在页的对应记录的字段上原地修改新值即可。

b. 非就地修改

如果 被更新的所有列 和 它在更新前的列 所占的存储空间不一样(非就地修改),那么需要在主键索引中先删除旧记录,再在旧记录所在的页中插入新记录。这里所说的删除不再是假删除 delete mark,而是真的把记录移动到垃圾链表。

如果sql也修改了二级索引的值,那么也要在二级索引的页删除记录(是假删除 delete mark),并在另外一个二级索引页插入新记录。

如果新创建的记录占用的存储空间不超过旧记录占用的空间,那么可以直接重用加入到垃圾链表中的旧记录所占用的存储空间,否则需要在页面中新申请一块空间供新记录使用,如果没有可用的空间,就进行页面分裂操作,然后再插入新记录。

就地修改和非就地修改会生成一条 update_exist 类型的undo日志。

下面是不改变主键值的更新的undo日志格式(update_exist),会记录所有被更新列在更新之前的值:

c. 更新主键的update

假设更新操作把id = 5 改为 id = 1000,那么更新主键的update在底层的行为就是在主键索引的页中删除id=5的记录(假删除delete mark),并添加 id=1000的记录到另一个页。

这个过程会产生一个 delete类型的undo日志 和 一个  insert类型的undo日志。

所以更新主键的update 会产生2条undo日志。

其实还有一种名为 TRX_ UNDO_ UPD _ DEL_ REC的 undo 日志类型这里没有介绍,主要是想避免引入过多的复杂性,毕竟了解undo log日志的原理才是我们的初衷。

5、版本链和undo日志的存放

1. 版本链

update 和 delete 这两种undo日志记录了对应数据行的 旧roll_pointer ,我们假设一条记录在一次事务中经过了4次修改,那么该条记录会产生4条undo日志,每条undo日志都会记下该数据行的上一个undo日志的roll_pointer,而数据行本身记下了它最新一次undo日志的roll_pointer,那么这些undo日志相当于组织成了一条版本链。

2. undo日志的组织结构

undo日志存放在undo日志页中,而undo页面以链表的形式组织在一起,undo页分为两种:insert类型的undo日志(里面只放insert类型的undo日志) 和 update类型的undo日志(放update和delete类型的undo日志)。

这两种不同类型的undo日志页分别用 insert undo 链表 和 update undo 链表管理。

之所以把 undo 日志分成 2 个大类,是因为insert类型的 undo 日志在事务提交后可以直接删除,而其他类型的 undo 日志还需要为 MVCC(多版本并发控制)服务,

不能直接删除掉,因此对它们的处理需要区别对待。下图为一个页内的undo日志,他们是紧密相连的。

 

临时表和普通表的undo日志也会分开管理,因此一个事务最多有4条undo链表,且每创建一个事务都会创建4条这样的undo链表: 

在一个事务中,对不同表的不同行的DML操作产生的所有undo日志都存放在这4条链表中。

两个事务会创建不同的2条链表,例如事务A和事务B都对数据行X进行修改,那么这两个修改产生的undo日志会放在两个不同的update undo链表中。(而某一条记录在不同事务中的所有变更所产生的undo日志是用版本链链接,和这里的undo页链表没关系。)

其中每一个undo链表的第一个页面会放该链表的一些控制信息,比如事务id。

由此我们可以想象得出一个事务是如何回滚的,事务发生过程中会记住它对应的几条undo链表的头结点和末尾节点页号,只需要从末尾节点往前遍历这些undo页内的undo日志,并按顺序执行这些undo日志的逆操作即可实现回滚。

一个事务在一个undo链表产生的undo日志称为一组undo日志,例如上图的事务就产生了4组undo日志。某些情况下,一个事务提交之后,后续的其他事务可以重复利用这个undo页链表,而非为新事务申请创建新的undo链表,这将导致一个undo页面可能存放多组事务的undo日志(这是为了节省undo页空间)。链表头结点会记录下一组和上一组undo日志在页内的偏移量。

下面我们说说重用undo页面的事情。

3. 重用undo页

innodb会为每个事务单独分配undo页链表(最多可单独分配4个链表),这样是为了提高并发执行的多个事务写入undo日志的性能(因为每个事务把undo日志写入链表肯定要先对链表上锁)。

但这会造成浪费undo页面空间的问题。比如,一个事务可能只产生3个undo日志,这个事务的undo链表就只有1个undo页,而且这个undo页只用了一丢丢空间就不用了。而实际上新的事务可以继续往这个undo页的日志堆里继续追加undo日志,已达到多个事务重用或者说共用undo页的目的。

为此innodb会在某些情况下让不同事务重用undo页链表中的undo页。重用undo页需要满足2个条件:

  1. 该链表只包含一个undo页;
  2. 该undo页使用的空间小于整个页面的3/4,否则undo页面的空间只剩一点点的情况下重用很可能会申请新页面,导致新页面剩余很多空间造成浪费。

insert 链表 和 update链表的重用策略是不同的,由于insert undo日志在事务提交之后可以直接丢弃,因此重用insert undo页面可以直接覆盖里面的旧undo日志。而update undo页的重用不能覆盖旧undo日志只能追加,因为这些undo日志要用来做MVCC。

重用undo页的事务不能是并发的事务,必须是一个事务结束后,另一个事务才能重用上一个事务的undo页。也就是说,一个事务的undo日志在页内是紧密相连的,不会出现多个事务的undo日志在一个页内交错的情况。

4. 回滚段

所有的undo页都存放在段中管理,而且一个undo页链表对应一个undo段,申请undo页时也是由undo段向区申请的,但是这些undo段(undo log segment)不是回滚段(rollback segment)。

所有undo链表的头结点(first undo page)的页号都会保存到一个单独的页,undo链表头结点的页号称为 undo slot,我们可以把这个单独页看成是存放所有undo链表基节点的仓库。而这个单独的页会被放入到一个单独的段中,这个段就是回滚段(rollback segment)。

回滚段里只有一个页,这个页我们称为回滚段头部,或者直接叫做回滚页。通过这个页可以找到指定链表的头结点页号。

一个回滚段只有1024个 undo slot,一个slot对应一条undo链表。这意味着一个回滚段最多能支持1024个事务同时执行,为了提高并发事务数,innodb会存在128个回滚段容纳更多的undo链表的头结点,因此最多能支持1024*128个事务同时执行。

这128个回滚段的回滚页的页号会被存放到系统表空间的第5号页面:

5. undo日志在崩溃恢复的作用

如果系统崩溃时某个事务还未提交,但是该事务的部分redo日志已经刷盘,那么为了保证事务的原子性,这个事务发生的所有更改是不应该恢复的,也就是说已刷盘的这部分redo日志是不完整的,不能对这些redo日志恢复数据。然而实际上mysql会对这些不完整的redo日志进行数据恢复。

此时undo日志就会在崩溃恢复中起到作用,mysql会扫描该事务所有undo链表的第一个节点,查看里面的 TRX_UNDO_ACTIVE 属性,它表示该链表对应的事务是否活跃。如果活跃,说明在系统崩溃时该事务没有提交,那么系统就会按照该链表中的undo日志回滚刚刚恢复的redo日志数据以保证原子性。

六、事务隔离级别和MVCC原理

1、事务的隔离级别

为了保证事务与事务之间的修改操作不会互相影响,innodb希望不同的事务是隔离的执行的,互不干扰。

两个并发的事务在执行过程中有 读读、读写(一个事务在读某条数据的同时另一个事务在写这条数据)、写读 和 写写 这4种情况。

读读(相同的数据)的并发并不会带来一致性问题,而后面三种情况的并发则可能带来一致性问题。

隔离的本质就是让多个事务对相同数据的访问在 读写、写读和写写的情况下,对其排队串行执行,比如事务A修改了行1但没提交,事务B在修改行1时就会被阻塞,这通常是通过加锁实现的。

当然,在实际的数据库应用中,读写和写读不一定非要串行执行,而是可以通过MVCC来实现不同事务对同一条记录的读和写是并发进行的。

需要注意:这里的排队串行是指写相同数据的时候才需要,如果是对不同数据行的写,是可以并发执行的。

下面我们讨论,事务并发执行会遇到哪些一致性问题。

1. 并发执行的一致性问题

脏写:如果一个事务成功的修改了另一个未提交事务所修改过的数据,就是脏写。

脏读:如果一个事务成功的读到了另一个未提交事务所修改过的数据,就是脏读。

不可重复读:如果一个事务A修改了另一个事务B读取的数据(B读先发生,A写后发生),事务B第二次再读这个数据的时候发现这个数据变了,就发生了不可重复读。

幻读:如果事务A根据某条件读到了一些记录(此时事务A未提交),事务B修改了一些符合这个条件的记录(insert、update或delete),下次A再读这些记录发现结果和上次不同,就发生了幻读。

幻读和不可重复读的相同点都是两次读到的数据不同,区别是幻读强调两次读取(我们称为前读和后读)的结果集合的行数不同,不可重复读强调读取同一条记录的内容不同。

在mysql中,幻读强调的是事务的后读,读到了前读所没有读到的行,这些多出来的行可能是其他事务insert或者update产生的,多出来的这些记录称为幻影记录;不可重复读强调无法读到前读读到的行,这些消失掉的行可能是由其他事务delete或update造成的,使得后读无法复现这些行。

此外对于当前读而言,避免不可重复读和幻读的方式不同,解决不可重复读使用记录锁锁住指定行即可,解决幻读要用间隙锁和临键锁锁住行与间隙。(对于快照读,避免不可重复读和幻读都是使用MVCC)。如果对这两段话的一些名词看不懂的同学,可以先忽这些锁机制。

一致性问题的严重性:脏写>脏读>不可重复读>幻读。

为此SQL指定了几种隔离级别:

事务的四个隔离级别,级别从低到高为:

  • 读未提交【read uncommitted】(会出现脏读、不可重复读和幻读的问题);
  • 读已提交【read committed】(会出现不可重复读和幻读);
  • 可重复读【repeatable read】(会出现幻读);
  • 串行化【serializable】;

隔离级别越高,安全性越高,但是性能越低。Mysql事务的默认级别是可重复读,而oracle的事务是读已提交的级别。由于读未提交的数据安全得不到保证,而串行化这个级别下并发度低,所以大多数数据库的隔离级别都是读已提交或可重复读这两种。

串行化的隔离级别(serializable)不代表事务和事务之间是串行的(如果是的话就变成表锁了),而是指事务和事务之间如果涉及到对同一行数据的写和读需要串行。但串行化级别的两个事务对不同行的写读和写写还是可以并发的。

例如在串行化的隔离级别下,如果事务A对行1进行了update但不提交,事务B对行1进行select会阻塞,只有当A提交了事务,B才能查询成功,而且查到的是A的已提交数据;反过来A先对行1select但不提交,B事务再对行1进行update也会被阻塞。

serializable的写读和读写之所以会串行,是因为serialzable在事务A读行1的时候会对行1上读锁(而且这个读锁在commit或者rollback时才会释放),事务A提交之前,事务B对行1进行select会由于锁没有释放而阻塞。

而 可重复读和读已提交 在读的时候无需加任何锁,而是通过MVCC实现读和写并发。

这也是 serializable 和 可重复读/读已提交 的区别之一:前者的读写/写读是串行,后者的读写/写读是并发的。

当然,无论哪种隔离级别,写都是要加写锁的,因此任何隔离级别的写写都是串行的,无法并发,但也是因为这样才避免了脏写的发生。

2、MVCC原理

MVCC(多版本并发控制 ) 设计出来的目的是为了在不加锁的情况下,解决脏读和不可重复读的问题,从而一定程度提高 读写 和 写读 的并发。

MVCC的实现依赖于记录undo日志过程中,针对聚簇索引的行构建出来的版本链 和 事务查询时创建的 ReadView(一致性视图)。

1. 版本链

undo日志我们知道:一次事务中,每次对某个聚簇索引的行进行改动的时候,行的旧版本会被写入到undo日志,本次事务的事务被写入到当前行的trx_id隐藏列,undo日志的地址会被写入到行的roll_pointer隐藏列中。

这样一来,多个事务对一个记录的多次修改所产生的undo日志就会形成一个版本链,如图所示:

版本链的头结点就是B+树页面的记录。

对于串行化隔离级别的事务,innodb采用加锁的方式来读和写(串行),因此根本不会出现脏读和不可重复读,无需用到版本链。

对于读未提交 read uncommit 隔离级别,它允许出现脏读和不可重复读,所以可以当事务A写的同时,事务B可以直接读取版本链的头结点的数据,也就是最新版本的行,从而实现A和B并发读写。

对于 读已提交 隔离级别,需要保证读的是已提交的数据。

对于 可重复读 隔离级别,需要保证如果事务A在事务B提交前开始的,那么事务A读的是在A内的行1数据,即使B对行1的已提交,A也不能读到B的已提交数据,这样才能实现可重复读。

很明显,如果 读已提交 和 可重复读 隔离级别要完成自己的隔离目标 又要要求 读写并发,光靠版本链还是不够的,还需要借助一致性视图。

2. 一致性视图 ReadView

一致性视图(有些书叫做“快照”,所以从一致性视图读取数据又叫快照读) ReadView 是在事务进行过程中产生的,可以和版本链结合共同实现MVCC。每一个事务在初次尝试做读取操作时,会生成一个属于该事务自己的ReadView,ReadView是一个由下面4个重要内容组成的信息集合:

  • m_ids:在生成本 ReadView 时,当前系统未提交的读写事务的事务id列表。
  • min_trx_id:在生成本 ReadView 时,当前系统未提交的读写事务中的最小事务id,即 m_ids的最小值。
  • max_trx_id:在生成本 ReadView 时,下一个未来事务的事务id。
  • creator_trx_id:生成本ReadView的事务id。

需要提示一点:任何事务开启后,执行DML语句(增删改语句)前,该事务的id都是0,只有事务执行了第一条DML语句,才会被分配事务id。

假如当前系统有 1、2、3 这3个事务,并且事务3已经提交了,事务1和2还没提交。此时开启了新事务4,事务4的事务id是0,事务4读取记录时生成的ReadView,m_ids就是1和2,min_trx_id是1,max_trx_id是4。

3. ReadView 如何配合 版本链 完成MVCC

根据 ReadView 判断 版本链的某个版本对本事务是否可见是实现MVCC的关键。

现在我们只关注一行数据,从这行数据的版本链的头结点(最新版本)开始作为当前版本。

1、如果 当前版本的 trx_id(不是当前事务的 trx_id,别搞混了) == ReadView.creator_trx_id,说明当前事务在访问自己修改过的记录,该版本可以被访问。

2、如果 当前版本的 trx_id >= ReadView.max_trx_id,说明当前事务访问的当前版本是在当前开启之后,又有新事务开启并修改了该行而产生的版本,因此该版本对当前事务不可见,不可访问。

3、如果 当前版本的 trx_id < ReadView.min_trx_id,说明当前事务访问的当前版本是以前已提交的事务更改所生成的版本,该版本可以被访问。

4、如果 当前版本的 trx_id 在 [min_trx_id, max_trx_id]之间,则需要判断 当前版本的trx_id是否命中 m_ids列表的 trx_id,如果命中,说明当前版本是未提交事务对行1更改而产生的版本,该版本不可访问;否则,可以访问。

如果某个版本对当前事务不可见(不可访问),则顺着版本链找到下一个更早的版本,并继续执行上面的流程,直到找到可见的版本 或者 到达版本链最早的一个版本都没有找到可见的版本才结束。如果是最后一个版本都不可见,说明查询结果不包含该记录。

读已提交 和 可重复读 隔离级别之间非常大的区别就是它们生成 ReadView 的时机不同 。读已提交会在每次读取数据前都生成一个ReadView,可重复读在第一次读取数据时生成一个ReadView。

例子:假设现在表 hero 中只有一条由事务id 为 80 的事务插入的记录:

现在系统中有2个事务id为100和200的事务正在执行:

 

此时有个使用 READ COMMITED 隔离级别的新事务trx_id=300开始执行一条select:

# 使用read commited隔离级别的事务
begin;

#select1:transaction 100,200未提交
select * from hero where number=1;

在 select 语句执行前,系统就会生成一个 ReadView,m_ids是[100,200],max_trx_id=201,creator_trx_id = 0。

根据上面的规则,该事务只能读到 刘备 这条记录。

之后把 事务id = 100 的事务提交,再在 事务id = 200 的事务中修改行1:

# transaction 200
begin;
update hero set name=’赵云’ where number = 1;
update hero set name=’诸葛亮’ where number = 1; 

事务300又执行一条select 行1的操作,事务300就会再生成一个 ReadView覆盖之前的ReadView,由于它的 m_ids 为 [200]。所以按照规则,会查询到 张飞 这条数据。

我们模拟上面一模一样的场景,但换成可重复读的隔离级别,那么可重复读的事务300只会在第一次select 时生成一个 ReadView,m_ids 是 [100, 200],min_trx_id=100, creator_trx_id = 0。

之后不会再生成新的ReadView,因此第一次select 查到刘备,事务100提交后,事务300第二次select 查到的还是刘备,因为 m_ids 还是[100, 200]。

所以能做到 重复读 就是因为可重复读隔离级别的一个事务只生成一次 ReadView。

4. 二级索引和MVCC

如果某个查询语句的查询字段只有二级索引,那么系统只会读取二级索引的页的记录,不会回表去读取聚簇索引的页记录。但是,版本链的头结点在聚簇索引中,不在二级索引中,通过二级索引的记录无法直接找到版本链。在这种情况下如何使用MVCC?

二级索引页的头部有一个 page_max_trx_id 表示修改过该页的最大事务id。

执行select时命中该页,如果 ReadView 的 min_trx_id 比该页的 page_max_trx_id 大,说明这个二级索引页修改的事务已经提交,该页的所有记录对本事务的本次查询可见。

否则,就要对“在二级索引页找到的匹配条件的记录”进行回表操作,在聚簇索引对应的记录中按照之前所说的规则找到可见版本。

考虑一种情况,如果原本有3条满足 where key1 = "a" 的二级索引记录(id分别是 1、2、3),事务1将id=1的"a"改成了"b",将一条id=4的 "c" 改成了"a",并且事务1没有提交。此时新事务查询 where key1 = "a" 应该查询到 id为 1、2、3这三条记录。问题在于id为1的行已经把索引 "a"改成"b"了,innodb怎么能根据 key1="a" 这个条件拿到 id=1 进行回表呢?

其实前面介绍undo日志时说过这种情况,如果update语句修改的是主键或者索引字段,会先在页中假删除对应的主键值或索引值的记录(记录的deleted_flag字段置为1,但不会放到垃圾链表中),再按照新值在对应页中新增一条记录。

由于是假删除,所以新事务其实仍然可以在页中找到 被未提交事务删除或更改的那条"a"记录。之所以假删除就是考虑到MVCC。

5. Purge

insert undo日志在事务提交后可以释放掉,而update undo日志需要为MVCC服务,因此不能立刻删除掉。

根据undo日志我们知道一个事务会产生至少1组undo日志(insert undo一组,update undo一组),1组undo日志对应一条undo页链表。

当事务提交后,update undo链表的头结点会被添加到一个History链表的头部,通过history链表就能找到提交事务后还未释放的undo日志链表,正式因为undo日志链表没有被释放,因此版本链中的undo日志行才会继续存在。

history链表(的基节点)存放在回滚段中,一个回滚段存放一个history链表。

问题来了:update undo日志是否会永远存在,一直不释放?如果释放,那么释放的时机是什么?答案肯定是要释放的,不然不断增长的undo日志会占用很多磁盘空间。

我们假设系统目前只有事务id为 1、2、 3、 4、 5这五个事务,隔离级别为 repeatable read。trx1修改了行1并提交,2~5在1提交之后依次同时开启,2修改了行1并提交后,3 修改了行1不提交,5修改行1不提交,4查询行1。

此时 行1 的版本链应该是 trx5(最新版本,放在数据页)->trx3(undo日志)->trx2(undo日志)->trx1(undo日志)。

由于trx2 和 trx1都已经提交,trx4生成的ReadView的m_ids列表是 [3, 5],max_trx_id = 6。

明显,trx1对应的undo日志可以删除回收,因为trx4生成的ReadView可见的最晚的版本是 trx2的版本。

所以结论是:

系统中仍处于活跃状态的最早的那个ReadView不再访问的那些update undo日志可以回收。

什么是活跃状态的ReadView?

例如,对于 read committed 级别,事务1执行了第一次查询,会生成一个ReadView1,只要该事务不执行第二次查询,那么ReadView1就是活跃的ReadView;如果执行第二次查询,就会生成ReadView2覆盖ReadView1。此时ReadView1就不是活跃的ReadView,ReadView2才是。

对于 repeatable read  级别,事务1执行了第一次查询,会生成一个ReadView1,执行第二次查询不会生成新的ReadView,因此ReadView1就是活跃的ReadView。

回到正题,一个事务提交时,会为其生成一个名为事务no的值来表示事务提交的顺序(事务id则是事务开启的顺序),事务no会记录到undo链表的头结点。已提交的undo日志组的链表头结点就是按照事务提交的顺序放入到history链表的。Readview也会包含一个事务no属性,在创建的ReadView时候会保存当前系统最大的事务no+1给这个属性。

系统中所有活跃的ReadView会按照创建时间连成一个链表。

purge线程做的事情就是遍历所有history链表的所有undo链表头结点的事务no 与 活跃的最早的ReadView的事务no对比,如果一组undo日志的事务no小于当前系统最早的活跃ReadView的事务no,就可以将这组undo日志从History链表移除并释放这些undo页的占用空间。并将这些undo日志对应的deleted_flag为1但仍在页内正常记录链表的记录移到垃圾链表中。

注意:如果某个事务是 repeatable read  级别,该事务会一直复用最初产生的ReadView,如果这个事务运行很久都没有commit,则该ReadView会一直处于活跃状态,系统中很早的update undo日志和打了删除标签的记录会越来越多不会被释放,导致表空间越来越大,版本链越来越长,影响性能。

七、MySQL 锁

1、锁在SQL并发中的作用

锁会在事务中的什么情况下被用到呢?

事务并发可以分为3种情况: 

1. 写写

写写(事务A对某条记录进行写操作的同时事务B也对该记录进行写操作)情况下会发生脏写问题,任何一种隔离级别都不允许脏写的发生。为了避免脏写,多个并发事务间的写写操作只能串行不能并发,而要使得并发事务的写写操作串行就要通过加锁实现。

锁本质上是内存中的一个结构或者对象,对一个写操作加锁本质上是让一个锁对某个事务的一条DML语句涉及到的记录进行关联。

mysql中一个锁的基本属性有:trx信息(表示该锁和哪个事务关联) 和 is_waiting(当前事务是否在等待)。

下图描绘了两个并发事务同时修改一条记录时的锁情况:

事务T1修改行1时,系统为其生成锁1并关联T1和行1,加锁成功;

事务T2修改行1,系统为其生成另一个锁2尝试关联行1,但是由于行1已被锁1关联,因此T2需要等待,is_waiting置为true。

下文中所说的加锁失败,或者获取锁失败是指生成锁结构成功,而 is_waiting被置为true。

当T1事务提交时,锁1被释放(注意,是事务提交时才释放锁,而不是执行完写操作就释放锁),系统再将锁2的is_waiting置为false,并唤醒处理事务T2的线程。 

2. 读写和写读

读写/写读((事务A对某条记录进行读操作的同时事务B对该记录进行写操作))可能会出现脏读、不可重复读和幻读这3种不一致性读。

可以使用两种方案避免这3种问题:

1、对读操作使用MVCC,对写操作加锁

事务A读记录时无需上锁,直接使用MVCC进行快照读,读到对本事务可见的历史版本行记录,事务B对该记录的写操作则是针对记录的最新版本进行修改,二者不会冲突,因此读写同一数据可以并发。

思考:为什么这样可以解决不一致读问题?

因为MVCC保证读已提交级别的事务每次select时不会读到未提交事务的更改,因此避免了脏读。MVCC保证可重复读级别的事务不会在第二次读取时读到自它第一次select后其他未提交事务的更改,因此避免了不可重复读和幻读。

而这都归功于并发事务间的读写是作用在某条数据版本链的不同版本上。

使用MVCC的读称为一致性读、无锁读或者快照读。

2、读写操作都加锁

对于一些不允许读取记录的旧版本的场景,只能对读写都加锁。例如减库存,需要应用程序先读后写(事务里包含一条select 和一条 update),如果两个事务并发,T1和T2同时快照读到库存 stock = 1,并在应用程序修改为0,写入数据库,最终数据库内库存 stock = 0,却产生了2笔订单,很明显这就出现了一致性问题。正确的情况是对读加一个排他锁,使T1和T2不能并发select,只能串行select。

总结:MVCC下的读写可并发,性能更高;加锁的读写只能串行,性能低,但某些业务场景需要。

3. 读读

读读操作(事务A对某条记录进行读操作的同时事务B也对该记录进行读操作)不会发生脏读、不可重复读和幻读,因此无需加锁。

2、Innodb中的锁

1. Innodb的意向锁

锁根据场景分为 读锁(又称为共享锁,S锁) 和 写锁(又称为排他锁,X锁),根据粒度分为行锁和表锁。

Innodb支持使用行锁和表锁,因此Innodb是一种支持多粒度锁的数据库引擎。下面我把“对行加一个X锁”简称为加“行X锁”,“对表加一个X锁”简称为加“表X锁”,其他同理。

当Innodb在上行X锁时会对整个表加一个表级的意向写锁(IX),在上行S锁时会对整个表加一个表级的意向读锁(IS)。

这里引出了意向锁这个概念。意向锁本身是一种表锁,而且是一种不会和行级锁冲突的表锁。Innodb中,意向锁和行锁可以共存。

在innodb中,当mysql要对数据加行锁的时候会先对整个表加一个意向锁,之后才往对应的行加行锁。此时这个表既加了行锁又加了表锁,所以叫做行锁和表锁共存。(意向锁是mysql自动加的,无需我们手动加。)

意向锁分为:

  • 意向写锁(IX):当需要对数据加行级写锁时,mysql 会先向整个表加意向写锁。
  • 意向读锁(IS):当需要对数据加行级读锁时,mysql 会先向整个表加意向读锁。

它的作用是什么呢?

假如Innodb需要对一个table上一个表锁(例如数据备份、alter table、drop table、 create index 之类的操作,系统会对整个表锁定,这说的表锁不是意向锁,请大家不要混淆),就必须先判断是否有某个行被上了行锁,如果有的话,说明某个或某些行正在执行读写操作,系统需要等待这个写操作完成了才能做备份之类的工作。

那么系统如何判断这个表是否被上了行锁呢?最粗暴的方法是系统需要对表的所有行进行遍历才能知道表中是否上了行锁,当然啦,遍历是不可能遍历的,这辈子都不可能遍历的。

因此意向锁就被设计了出来,如果在加行锁之前就加了意向锁,那么Innodb马上就能通过检验是否有上意向锁判断出这个表有没有上行锁。

意向锁的设计目的是为了当Innodb需要对一个表上一个表级别的S锁和X锁时,可以快速判断表是否被上行锁,以避免用遍历的方式检验是否上行锁。

下面是表级X锁、S锁 和 意向IX锁、IS锁的兼容性:

意向锁虽然是一种表锁,但和我们普通意义上说的表锁不是一回事。

意向锁和表锁的区别在于两点:

  • 一个是兼容性不同:意向IS锁和IX锁,IX锁和IX锁之间都是兼容的,而表S锁和X锁,以及表X锁与X锁之间是不兼容的。
  • 一个是用途不同:意向锁是在上行锁的时候加的,表锁是做一些需要锁整个表的操作时上的。

对于MyISAM、MEMORY、MERGE这些存储引擎而言,他们只支持表级锁,不支持行锁和事务。因此操作这些表的会话进行写写和写读/读写是串行的,读读并行。这就是为什么我们平时说,MyISAM引擎适合写少读多场景的原因。

2. Innodb的表锁

实际上除了意向锁之外,innodb也有我们普通意义上的表锁,但Innodb的表级锁非常鸡肋,基本上用不到,要用只能手动加表级锁:

lock tables t read;
lock tables t write;

Innodb的表锁不会提供什么额外保护,只会降低并发能力。

3. Innodb的auto-inc锁

如果innodb的某个列使用了auto_increment属性,那么插入数据时为插入的行生成自增的列值需要加锁,避免多个事务并发时生成相同的自增值。

为自增列加锁有2种方式:

一种是采用表级的auto-inc锁。当一个事务insert了一条或者多条记录时,会为这个表上一个表级的auto-inc锁,如果其他事务也执行插入语句会被阻塞(但是不阻塞select、update和delete以及指定了自增列值的insert)。

和innodb的行锁不同的是,auto-inc锁会在insert语句执行完之后就释放,而无需等到事务结束时才释放。

使用auto-inc锁的情况下,假如事务A和事务B同时发出的insert请求,他们的insert操作和生成自增值的操作都是串行的。

另一种方式是使用轻量级的锁,和auto-inc锁不同的是,系统会为插入语句生成自增值的过程加锁,而不是对整个插入过程加锁,因此轻量级锁的临界区更小。

auto-inc锁和轻量级锁带来的结果是,前者可以保证一个事务的一次insert语句产生的自增值是连续的(事务A是1/2/3,事务B是4/5/6),而后者会让事务A的一条insert语句产生的多个自增值和事务B的一条insert语句产生的多个自增值有交叉(例如 1/3/5是事务A的自增值,2/4/6是事务B的自增值)。

轻量级锁的性能更高,避免在生成自增列值这件事上锁定整个表。

3、innodb的行锁

innodb的行锁可以再分为 记录锁 record lock、间隙锁 gap lock 和 临键锁 next-key lock。

下面我们以一个例子来说明这些锁具体是作用在哪里,下图是主键索引的一个Page,number列是主键:

1. 记录锁

记录锁仅仅是将一条记录锁上;

2. 间隙锁

间隙锁可以对表的某一条记录加上间隙锁,但间隙锁并不会锁着这条记录,而是会锁住这条记录和上一条记录之间的空隙,使得其他事务在本事务释放该间隙锁之前不能在这个间隙之间插入数据。

如图所示就将 (3, 8)之间的间隙锁住,这是一个开区间,被锁住的不包括3和8记录本身。

gap锁(间隙锁)的目的是为了防止其他事务在上锁的区间插入幻影记录,从而避免了幻读。对某条记录上了gap锁并不影响其他事务对这条记录再上记录锁或者gap锁,也就是说gap锁可以兼容其他事务的行锁,只不过会禁止(或者说阻塞)其他事务的插入操作而已。

问题:对某条记录上gap锁是锁住这条记录和上一条记录之间的空隙,而非锁住和下一条记录之间的间隙,那要怎么禁止其他事务往 (20, +∞) 这个区间插入幻影记录呢?

答案很简单,对叶子节点最后一页的 Supremum 虚拟记录上gap锁即可。

3. 临键锁

对表的某一条记录加上临键锁,会锁着这条记录 和 这条记录与上一条记录之间的空隙。

4. 隐式锁

前面说的锁都需要在内存中生成一个锁结构,而隐式锁是一个内存中不存在的锁,其本质是事务A在修改事务Binsert生成的记录时的延迟加锁。

首先告诉大家,一个事务B在插入一个记录时是不会加锁锁住这个间隙或者锁住任何行的。也就是说事务B生成的新记录M就没有任何锁保护的,如果B没提交,事务A对M进行一个当前读(加读锁或写锁)不会阻塞,这就造成了幻读或者脏读(意思是A会读到记录M);或者对M进行修改或删除,就造成了脏写。

隐式锁可以解决这个问题。隐式锁的实现原理是利用了主键索引记录的trx_id列 和 二级索引页的最大事务id 这两个信息。事务A可能是通过二级索引或者主键索引作为条件找到记录M进行修改,在检测到记录M没有上锁的情况下,会检查记录M的trx_id列判断记录M是否是当前活跃的事务所创建的。

情景1:对于聚簇索引,页记录有一个trx_id。事务A查到M记录后检查它的trx_id是否属于当前活跃的事务的id。如果是,说明记录M是未提交的事务所创建,于是事务A的线程会帮事务B创建一个X锁(锁的trx信息是事务B的信息),is_waiting为false(相当于B插入记录M之后没有上锁,A修改或者当前读记录M的时候才补上这个锁)。再为事务A创建一个锁结构,is_waiting为true,并进入等待状态(等B提交事务释放锁)。

情景2:对于二级索引,页的头部记下了最新修改该页的事务id,事务A查到M记录后检查该事务id是否比当前最小的活跃事务id小,是则说明记录M的修改或创建是已提交的修改或创建,事务A不会被阻塞;否则需要回表,进入情景1的判断。

从上面的过程看出,隐式锁起到了延迟生成锁结构的作用,如果别的事务(事务A)在执行过程中不需要更改或加锁读事务B创建的记录M,就不会在内存中生成对事务B的锁,节省了一次加锁开销。

5. 锁合并

已知一个事务对一条记录修改或当前读,会产生一个关联该事务和该记录的锁。

问题来了,如果一个事务对多条记录加锁,是不是就要创建多个锁结构(或者说锁对象)?

例如:

select * from hero lock in share mode;

如果表里有1万条数据,会产生一万个锁吗?如果真这样就太浪费内存了。

实际上,事务对多条记录上锁也可以只生成一个锁对象或者说锁结构,但需要满足下面条件:

  1. 在同一个事务中进行加锁操作;
  2. 被加锁的记录在同一个页面;
  3. 加锁的类型相同;
  4. 等待状态is_waiting相同;

6、锁的结构

锁所在的事务信息包括生成该锁的事务的事务id等。

我们重点关注“表锁/行锁信息”,如果是行锁,那么该属性包括这个锁所锁住的记录所在的页的页号和表空间。

type_mode是锁类型和模式,它包含3个信息:锁模式(共享锁还是排他锁,还是意向锁)、锁粒度(表锁还是行锁)、锁的具体类型(记录锁、间隙锁还是临键锁)。

一堆比特的每个比特对应一个页的每一行记录,比特位为1表示对应的行被锁住了。

举个例子看看多条记录的锁是如何写入到一个锁结构的。还是这个例子:

假如事务T1对15号记录加行锁,会生成一个这样的锁结构(我们全程忽略意向锁产生的锁结构,只关注行锁产生的锁结构),is_waiting 为 false,加锁成功: 

之后事务T2对3、8、15这3条记录加X型的临键锁,T2会生成2个锁结构,其中 3、 8这两条记录的锁会放在一个is_waiting=false的锁结构,记录15的锁会放到另一个is_waiting=true的锁结构。加上T1生成的锁结构,一共就有3个锁结构了。

注意:如果T2一开始先对 记录15 加锁生成锁结构,那么T2生成锁结构后会直接进入等待状态,不再为3、 8这两条记录生成锁结构。等到T2被唤醒,对3、 8加锁时,就可以复用 记录15 的那个锁结构,变成 3、8、15复用一个锁结构。

4、Innodb对表上锁过程

Innodb对表上锁的过程,具体场景如下图所示。

在这里我们将语句分为4类:普通select(快照读)、锁定读、半一致性读 和 insert语句。

1. 普通读

普通的select在不同隔离级别下有不同的表现:

  • 在 读未提交 的级别下:不加锁,直接读取版本链最新版本,可能出现脏读、不可重复读和幻读;
  • 在 读已提交 的级别下:不加锁,每次select会生成一个ReadView配合读取版本链中该ReadView可见的版本,避免了脏读,可能出现不可重复读和幻读;
  • 在 可重复读 的级别下:不加锁,第一次执行select生成ReadView配合读取版本链中该ReadView可见的版本,避免了脏读、不可重复读和幻读;
  • 在 串行化 的级别下:如果执行 begin 语句进行手动提交事务,则select会被转为 select lock in share mode语句变成锁定读;

如果使用自动事务提交,则select会通过MVCC快照读。自动提交意味着一个事务只包含一条语句,因此不可能出现不可重复读和幻读(因为不可重复读和幻读的出现需要前读和后读两次读)。

需要注意:我们知道MVCC可以解决幻读,但实际上它不能完全解决幻读。

举个例子:

事务T1查找一条不存在的记录,在T2插入了这条不存在的记录并提交之后,T1如果不update,直接执行第二次select,那么是不会查到这条记录的,这要归功于ReadView和版本链保证数据读取时不会从最新的版本读取(想不明白可以回顾之前ReadView和版本链如何查找可见版本的过程)。

因此我们说MVCC可以解决幻读。

如果T1先update再执行第二次select(如上图所示的那样),由于update是一个当前读,因此肯定可以读到T2提交的新增记录,所以update 会成功。并且会更新版本链的最新版本,最新版本的trx_id是自己的trx_id,该最新版本对本事务可见,所以T1再select 就能查到这个最新版本记录。

因此我们说MVCC不能完全解决幻读。

此时唯有使用临键锁才能彻底解决幻读,也就是说要在T1的第一次select使用锁定读,而不能用MVCC读:select * from hero where number = 30 lock in share mode;。

2. 锁定读

锁定读包括下面4种语句:

a. select ... lock in share mode;

b. select ... for update;

c. update ... ;

d. delete ... ;

修改和删除也需要先查到指定记录才能修改和删除,所以也会涉及到读,而且修改和删除的读是锁定读(当前读)而不是快照读(其实也不一定百分百是当前读,有可能是半一致性读,后面会再说)。

而且需要注意,update 和 delete 会加锁,是在更改前的当前读之前就加了锁,而不是在真的修改和删除时才加锁的。

3. 匹配模式

在开始介绍锁定读之前先引入几个概念。

精确匹配:如果扫描的区间是一个单点扫描区间,则称为精确匹配。

例子:有一个联合索引 (a, b)。

where a=1 是精确匹配,扫描区间为 [1, 1];

where a=1 and b=1 是精确匹配,扫描区间为 ([1, 1], [1, 1]);

where a=1 and b>1 是非精确匹配,扫描区间为 ([1, 1], [1, +∞]);

唯一性搜索:如果能确定扫描区间只包含一条记录,那么这种搜索是唯一性搜索。

唯一性搜索需要满足一下几个条件:使用的是唯一索引,且必须是单点查询,且索引列条件不能包含null。

4. 加锁过程

下面重点介绍加锁的过程。

0、假设有一条查询语句的扫描区间(可能是二级索引或主键索引的扫描区间)为 (x, y),接下来会发生这些事情。

1、快速在B+树叶子节点定位到该扫描区间的第一条记录,作为当前记录。

2、为当前记录加锁。

如果是 读已提交 和 读未提交 的级别,则会为当前记录加一个记录锁,如果是 可重复读 和 串行化 级别,则为当前记录加临键锁(临键锁解决幻读)。

3、判断索引条件下推是否成立。

前面说过 索引条件下推 是在二级索引通过where中可以利用到的条件在二级索引就减少记录数以减少回表次数的一种机制(从而减少随机IO次数)。

索引条件下推只在二级索引会用到,所以如果是在聚簇索引中则忽略步骤3。

如果满足索引条件下推则跳到步骤4,否则就沿着单向链表往后找到下一条记录作为新的当前记录,回到步骤2。

需要注意,在二级索引加了锁的记录,在回表的过程中不会释放锁。

4、执行回表操作。

在聚簇索引找到对应记录,对聚簇索引上的这些记录加记录锁。

5、判断当前记录是否满足where中主键的区间边界条件和其他字段的条件,不满足则根据隔离级别选择是否释放该记录上的锁(读未提交和读已提交可以释放,可重复读和串行化不可释放),满足则将该行返回给客户端(但不释放锁),并在二级索引(如果没用到二级索引,那就沿主键索引的链表找下一条记录)获取记录单向链表的下一条记录作为新的当前记录,跳回第2步。

上述过程需要执行 y-x 次。对于每条记录,innodb是先加锁再判断区间条件和其他条件是否满足,然后再决定是否释放锁。

下面是一些例子。

例子1:

SELECT * FROM hero WHERE number > 1 AND number <= 15 AND country = 
'魏' LOCK IN SHARE MODE;

下面以读已提交级别为准描述加锁过程:

0、访问方式为range,生成的主键扫描区间是 (1, 15]。number=3是该区间内第一条记录。

1、为number=3的主键索引记录加一个S记录锁。

2、由于number=3 满足主键条件,但不满足其他条件,因此释放锁。

3、寻找下一个记录8,为其加S记录锁,由于number=8 满足其他条件因此返回给客户端,在找到 下一条 number = 15的记录,操作同上。

4、再找到下一条记录 number=20,对其加锁,由于number=20不满足条件,因此释放锁。

查询完成。

需要注意,对于每条记录,innodb是先加锁再判断区间条件和其他条件,所以 number=20和number=3也会被上锁,然后再解锁。

下面以可重复读级别为准描述加锁过程:

1、主键扫描区间是 (1, 15]。number=3是该区间内第一条记录,为number=3的主键索引记录加一个S临键锁。

2、number=3 满足主键条件,但不满足其他条件,不过不会释放锁。

3、寻找下一个记录8,为其加S临键锁,由于number=8 满足其他条件因此返回给客户端,在找到 下一条 number = 15的记录,操作同上。

4、再找到下一条记录 number=20,对其加临键锁,number=20不满足条件,但不会释放锁。

查询完成。

例子2:

SELECT * FROM hero FORCE INDEX(idx_name) WHERE name > 'c曹操' AND
 name <= 'x荀彧' AND country !='吴' LOCK IN SHARE MODE;

该sql强制使用 name 字段索引(idx_name),区间范围是 ('c曹操' , 'x荀彧']。explain的 Using index condition表示使用索引条件下推。

下面以读已提交级别为准描述加锁过程:

1、二级索引中找到第一条满足区间范围的记录“l刘备”,对该二级索引记录上记录锁,并判断“l刘备”是否满足区间范围和能在二级索引判断的所有条件,满足;

回表,在主键索引记录上记录锁,判断其他条件,发现满足所有其他条件,将该记录返回客户端。在二级索引中沿链表找到下一条"s孙权"。

2、对该二级索引记录“s孙权”上锁,“s孙权”满足区间范围和能在二级索引判断的所有条件;回表,在主键索引记录上记录锁,判断其他条件,发现不满足所有其他条件,因此释放主键索引和二级索引对应的记录的锁。在二级索引中沿链表找到下一条"x荀彧"。

3、"x荀彧"操作同上,不再复述,会对其主键索引和二级索引上锁,将记录返回客户端。在二级索引中沿链表找到下一条"z诸葛亮"。

4、对该二级索引记录“z诸葛亮”上锁,“z诸葛亮”不满足区间范围,不再回表,因此查询至此结束(z诸葛亮记录此时不会释放锁);

查询结束,图中置灰的部分是被加锁了的记录。

需要注意:在 读已提交和读未提交的级别,在二级索引中,如果一条记录不满足索引条件下推的条件,它是不会被释放锁的,例如例子中的 z诸葛亮 记录就是这种情况(s孙权之所以能释放锁是因为他在主键索引检测出不满足 country 条件,它是满足索引条件下推的条件的(即索引区间范围条件))。

以可重复读级别为准的加锁过程和上面类似,只不过是在二级索引记录上加临键锁,在主键索引记录上加记录锁,而且不满足条件也不会释放锁。加锁情况如下图:

可能大家有点疑惑,上面的两个例子,有的是在主键索引加临键锁,有的时候是在二级索引加临键锁,到底什么时候用临键锁,什么时候用记录锁?

首先,临键锁是用来解决幻读的,因此只有在可重复读和串行化级别才会出现;第二,where使用哪个索引就对哪个索引的记录加临键锁,例如例1是 where number > 1 AND number <= 15,因此临键锁加到了主键索引上,例2是where name > 'c曹操' AND name <= 'x荀彧',因此临键锁加到了二级索引,没有加到主键索引。

下面我们再看一下 update 和 delete 的例子。

update 的加锁过程和上面的过程没有区别,只不过是把S锁改为X锁。不过稍微注意一下这种情况:

如果 update 的where 条件不涉及二级索引列,按理说是不会对二级索引加锁,只会对主键索引加锁,但如果修改的列是索引列,那么即使where 条件不涉及二级索引列也会对二级索引记录加锁。

例子3:

UPDATE hero SET name = 'cao曹操' where number > 1 AND number <= 15 
AND country = "魏";

读已提交/读未提交的加锁情况如下:

可重复读/串行化的加锁情况如下: 

例子4:精确匹配(单点查询,如 =,in)

SELECT * FROM hero WHERE name = 'c曹操' FOR UPDATE;

读已提交/读未提交 级别的加锁情况。

加锁情况如下,为曹操记录加了一个记录锁。

5. 可重复读/串行化的加锁情况​​​​​​​

如果是 可重复读/串行化 则会为扫描区间后面的下一条记录加gap锁,扫描区间是哪个索引的扫描区间,就在哪个索引上加。在这里是name这个二级索引的扫描区间['c曹操', 'c曹操']。

加锁情况如下,二级索引上,为曹操加了一个临键锁,并在曹操和刘备之间加了一个间隙锁。

如果单点查询的扫描区间没有记录,也要为这个区间加一个gap锁。

例子5:单点查询的扫描区间没有找到记录

SELECT * FROM hero WHERE name = 'g关羽' FOR UPDATE;

加锁情况如下,没有记录被锁住,但"c 曹操"和 "l 刘备"之间的间隙被加了一个间隙锁。 

例子6:可重复读/串行化 下,非精确匹配,没找到记录,会为区间范围的下一条记录加 临键锁。 

SELECT * FROM hero WHERE name > 'd' AND name < 'l' FOR UPDATE;

加锁情况:为刘备这条记录加临键锁。

例子7:可重复读/串行化 下,条件是主键索引,非精确匹配,区间范围的左区间是闭区间,且左边界刚好存在记录,则该记录加的是记录锁。 

SELεCT * FROM hero WHERE nurnber >= 8 FOR UPDATE;

加锁情况:为number为8的记录加了记录锁,为扫描到的其他记录加临键锁。 

例子8:唯一性搜索(主键和唯一索引)加的是记录锁。 

SELECT * FROH hero WHERE number = 8 FOR UPOATE;

5、半一致性读

半一致性读是一种介于一致性读和锁定读之间的读取方式。半一致性读只用于 读已提交/读未提交 的update语句

我们知道,在前面介绍update和delete的时候说过,update 和 delete 在更改之前需要先定位到索引的记录位置才能更改,因此更改前需要读,而且绝大部分情况下是当前读。

实际上,在 读已提交/读未提交 级别下,如果事务A的update语句要修改的记录已经被其他事务加了X锁,事务A就会读取该记录版本链的最新已提交版本,并判断该版本是否与update语句中的搜索条件相匹配,如果不匹配则不对其加锁(不对其修改,跳到下一条记录),匹配则对其加锁(然后陷入阻塞,待其他事务解锁后对该记录进行修改),这就是半一致性读。

半一致性读可以避免update读到where不匹配的记录时被阻塞的情况,从而提高写写之间的效率。

这样说可能很抽象,下面看一个例子帮助理解。

例子9:有两个读已提交的事务T1、T2

T1执行了当前读,未提交:

SELECT * FROM hero where number = 8 FOR UPDATE;

此时聚簇索引的记录8被加了X记录锁。

T2执行update语句: 

UPDATE hero SET name = 'cao曹操' where number >= 8 AND number < 20 AND country != '魏';

扫描区间在[8, 20),T2不会先对记录8加锁,而是先查记录8的最新已提交版本到server层,该版本的country是'魏',不满足T2的update条件,因此server层会放弃让事务T2对记录8上锁也不会修改记录8。如此一来,T2就避免被阻塞从而提高了并发效率。

换做是 可重复读和串行化 的情况下,无论如何T2都会尝试对曹操记录加锁因而被阻塞。

半一致性读让写写在某些特殊场景下可以并发进行,虽然没有产生脏写,但相当于打了擦边球。

最终提醒大家要不忘初心,加锁的目的是为了保证事务的隔离性,具体说是为了避免事务并发引起的脏读、脏写、不可重复读和幻读问题。这些例子只是为了帮助大家了解Mysql的加锁机制,实际工作中我们几乎不会涉及到如此微观的层面,因此我们也不需要专门去记住什么时候加什么锁、锁住哪几条记录之类的事情,仅做了解即可。

6、加锁语句分析

一个事务插入一条记录会先检查该位置是否被其他事务上了gap锁,如果有则会加一个插入意向锁再进入阻塞状态;如果没有则不会生成插入意向锁,而且插入时也不会生成显式锁。

然而如果遇到下面这两种情况,insert就会生成显式锁。

1. 插入重复键(duplicate key)

如果insert时发现主键将出现重复值(例如已经有一条记录A,后来插入一条和A有主键冲突的记录B),在报错之前会先为该重复记录(记录A)加一个S锁(如果是读已提交/读未提交 则上一个S记录锁,如果是可重复读/串行化 则上一个S临键锁)。

如果是非主键的唯一二级索引出现重复值,则不论什么隔离级别都是要在二级索引的那条重复记录上加一个临键锁。

如果使用 insert ... on duplicate key... 来插入发生了唯一键字段重复,则在重复记录上加X锁而不是S锁(因为 on duplicate key语法在唯一键重复时会转insert为update,update就必须上锁)。

2. 外键检查

如果一个表的某个字段A指向另一个表的主键,那么这个字段A就是外键。

外键所在的表是从表,被指向的表是主表。在具有外键的从表中插入一条记录,系统会对主表加锁。

外键的约束如下(这是些关于外键的前置知识,知道的可以跳过蛤):

如果主表没有某个主键的记录,从表就不能插入这个主键对应的外键记录。因此必须先插入主表才能插入从表。

在修改和删除上也有类似的约束和对应的级联操作。

可选的级联操作如下:

  • cascade:关联操作,如果主表的行被更新或删除,从表也会执行相应的操作;
  • set null:不关联任何操作;
  • restrict:拒绝主表的相关操作;

例如:

alter table blogs add foreign key fk_tid (tid) references category (id) on delete set null on update set null;

外键和普通的表与表连接字段的区别在于,前者有强约束,使一致性更容易得到保障,但实际应用中由于约束较强,很少使用。

回到insert加锁的问题上,假设hero表是主表,horse表是从表,外键是horse.hero_id,如果在从表插入一个记录,插入的hero_id在主表中找得到(假设hid=8),那么需要对主表中id为8的记录加S记录锁。

如果插入的hid在主表中找不到(假设hid=5),那么对于 读未提交和读已提交级别 无需加锁,对 可重复读和串行化则需要对主表中id为5的间隙加S间隙锁。

查看加锁情况:

SELECT * FROM information_schema.INNODB_TRX

INNODB_TRX表:该表存储了 lnnoDB 存储引擎当前正在执行的事务信息,包括事务 id、事务状态(比如事务是正在运行还是在等待获取某个锁、事务正在执行的语句、事务是何时开启的〉等。

其中包含以下重要字段:

  • trx_tables_locked :该事务加了多少表级锁;
  • trx_rows_locked :该事务加了多少行级锁;
  • trx_lock_struct :该事务生成了多少个锁结构;

INNODB_LOCKS表:记录锁信息,包括一个事务尝试获取某个锁但没能获取到的信息 和 一个事务获取到了锁但阻塞了别的事务的信息。如果没有阻塞,则该表没有记录。

INNODB_LOCK_WAITS 表:记录更多锁和阻塞的信息。

其中,requesting_trx_id是被阻塞的事务id,blocking_trx_id 是导致 requesting_trx_id这个事务被阻塞的事务id。

如果想看更详细的事务和锁信息,可以执行。

show engine innodb status;

只看其中的transactions信息即可。

下面我们看一个结果返回的例子:

TABLE LOCK table 'xiaobaizi'.'hero' trx id 46688 lock mode IX

事务 id为 46688 的事务对xiaohaizi 数据库下 hero 表加了表级别的意向独占锁。 

RECORD LOCKS space id 203 page no 4 n bits 72 index idx_name of table 'xiaohaizi'.'hero' trx id 46688 lock_mode X locks gap before rec
0: len 10; hex 7ae8afb8e8919be4; asc z ;  ;; # 7ae8afb8e8919be4 是 'z诸葛亮'的utf8编码1: len 4; hex 80000003; asc;      ;; # 80000003代表主键值为3

表示一个锁结构,这个锁结构对应 表空间号203 ,页号5,n_bits 属性值为 72(约等于该页中的记录数)。

对应的索引是 idx_name,锁类型是 gap 间隙锁(Iock_ mode X locks gap before rec 代表的就是 gap 锁)。

后面那两串内容是锁结构的详细信息,包括锁住的记录的字段。

RECORD LOCKS space id 203 page no 4 n b its 72 index idx_name of table 'xiaohaizi'.'hero' trx id 46688 lock mode X

锁类型是 next-key 临键锁(Iock_ mode X 代表的就是 next-key 锁),锁住的是二级索引 idx_name的记录。 

RECORD LOCKS space id 203 page no 3 n bits 72 index PRIMARY of table 'xiaohaizi. hero' trx id 46688 lock_mode X locks rec but not gap

锁类型是 记录锁(Iock_ mode X locks rec but not gap代表的就是记录锁),这里锁住的是主键索引 idx_name的记录。

最后需要注意的是,一个事务是在执行了第一条更改语句后才被分配事务id,如果事务只执行了 锁定读/当前读 就结束事务,那么这个事务不会有事务id,使用 show engine innodb status 也不会看到该事务过程整产生的锁(因为它没有被分配事务id)。

八、MySQL 分区策略

先复习一下Myisam和InnoDB这两种引擎的文件存储格式。

Myisam:

  • frm 存表结构的文件;
  • MYD 存表数据的文件;
  • MYI 存索引的文件;

Innodb:

  • frm 存表结构的文件;
  • ibd 存表数据的文件;

Innodb又分为两种:共享表空间和独享表空间。

共享表空间就是一个库的所有表都放在一个文件中,如ibddata1这个文件就是共享表空间。

独享表空间就是一个表单独放在一个文件中。很明显,独享表空间的性能更高。

show variables like "%innodb_file_per_table%";

这个参数为ON表示使用独享表空间。

分表的情况是:

tb_1    对应      文件1(包含结构文件和数据文件)
tb_2    对应      文件2(包含结构文件和数据文件)
tb_3    对应      文件3(包含结构文件和数据文件)

数据插入哪个表或者数据查询从哪个表查,我们要在业务层自己写算法来判断。

分区的情况是(假如分区为3个区):

tb 对应 文件1
文件2
文件3

分成几个区,就会生成几个文件,但是表还是1个表,而算法和策略不需要再业务层自己实现,而是mysql内部实现。

常见分区的策略有4种:

  • Range:如id为1~100的数据存放到第一个分区,101~200放到第二个分区,...依次类推(以连续型数据字段作为分区的字段);
  • List:如分类A的数据放到第一个分区,B分类的数据放第二个分区,...以此类推(以离散型数据字段作为分区字段);
  • Hash:如对字段取模,将模相同的数据放到相同分区;
  • Key:和Hash类似,可以对某字段使用表达式作为分区标准,这个和Hash本质还是一样的;

重点是前3个。

PS:建立分区的字段必须是主键字段或者被包含在主键字段(如复合主键)中。

在test库先建立一个普通的表:

create table t_base(id int primary key auto_increment,name varchar(10))engine=innodb;
insert into t_base (name) values ("zbp");
insert into t_base (name) select name from t_base;    # 蠕虫复制400多万条数据。
select count(*) from t_base;    # 4194304
# 更改他们的name字段为随机的数字
update t_base set name=ceil(rand()*5000000);

现在mysql存放数据的目录的test目录中,可以看到t_baes.ibd(290M)和t_base.frm文件。

创建range分区的表:

create table t_range(
    id int primary key auto_increment,
    name varchar(10)
)engine=innodb partition by range(id)(
    partition p0 values less than (1000000),       # p0是分区名
    partition p1 values less than (2000000),
    partition p2 values less than (3000000),
    partition p3 values less than (4000000),
    partition p4 values less than (5000000)  #可使用maxvalue关键字表示最大的id数
);

此时会出现t_range#p#p0~4.ibd这4个表数据文件和一个t_range.frm表结构文件。

将t_base数据写入t_range中:

insert into t_range select * from t_base;

如果删除某个分区,该分区下的数据会被删掉。

alter table t_range drop partition p4;

查看分区情况:

show create table t_range;

创建List分区,List分区字段必须是int型,不能是字符串型。

create table t_list(
    id int auto_increment,
    type_id int ,
    name varchar(20),
    primary key (id,type_id)
)engine=innodb partition by list(type_id)(
    partition p0 values in (1,2,3),
    partition p1 values in (4),
    partition p2 values in (5,6)
);

往t_list中插入500万数据:

insert into t_list (id,type_id,name) select id,ceil(rand()*6) type_id,name from t_base;

创建hash分区,要指定分区个数,生成的4个表数据文件是一样大的,因为hash分区方式会将数据平均分配到每个分区,其实是取模算法。

create table t_hash(
    id int primary key auto_increment,
    name varchar(10)
)engine=innodb partition by hash(id) partitions 4;

指定创建4个分区,对id进行4的取模。

select * from t_hash limit 10;  # 从第一个分区获取10条数据,第一个分区是模为0的分区,故得到的id都是4的倍数

如果只想移除分区但不想删除数据可以使用:

alter table xxx remove partitioning;

但会删除所有分区,不推荐使用分区,因为mysql 5.7版本以前的分区功能有比较大不稳定性,可能造成比较严重的性能问题。

猜你喜欢

转载自blog.csdn.net/qq_35029061/article/details/128641686