- 关键词
- InnoDB架构图
- 表空间
- 数据页
- 顺序下数据页的存储
- 页分裂
- 页合并
- 高水位
- 排序索引构建
- img_v2_455d98d3-a67a-47ef-b15a-c1798de6f56g.jpg
- 索引优化
- 模糊查询打断最左匹配?-索引下推
- 仅能使用一个索引?-索引合并
- 自适应Hash索引
- AUTO_INCREMENT
- 计数器
- 新增语句的两种形式
- InnoDB的自增锁模式
- 三大日志
- binlog
- redolog
- undolog
- 双写缓冲区
- 缓冲池
- 标准LRU
- InnoDB的LRU
- redis的LRU
- 更改缓冲池
关键词
MySql默认引擎,支持事务,行级锁,支持外键。
InnoDB架构图
表空间
- File-Per-Table Tablespaces: 此空间内每个表都有自己单独的文件,默认为开。关闭后表数据放到系统空间。
- General Tablespaces:需要手动创建,并在建表时指定表空间。多个表共用一个空间, 一个idb文件可能含有多个表。
- Temporary Tablespaces: 分为会话临时表空间和全局临时表空间。会话临时表空间存储用户创建的临时表和优化器在InnoDB为磁盘内部临时表的存储引擎时创建的内部临时表。磁盘上的内部临时表使用InnoDB存储引擎。全局临时表空间目前用来存储临时表的undolog日志。
删除在单表文件表空间中创建的表,磁盘空间将返回给操作系统。但是未使用空间仅能自己使用。删除存储在通用表空间中的表只是在通用表空间数据文件中创建可用空间,该空间只能用于数据 InnoDB并不会释放给操作系统。
数据页
InnoDB存储数据的物理文件是.ibd文件,每个表都有自己的.ibd文件。idb文件由多个段(segments)组成,段的下一级单位是区,区的下一级单位是页,数据行就在数据页里面。数据页默认16KB。
InnoDB操作的最小粒度是数据页,而不是数据行,即使只需要其中的一个行,也需要将整个页读取出来。然后再获取行记录。因此如果多行数据聚集在一个页内,那么仅需一次磁盘IO就能完成获取,如果分布在多个页内就需要多次磁盘IO。
因此如果相关数据存储在一起,就会操作时就会涉及更少的数据页,从而提升性能。
但是我们在日常场景设计中,除了特殊的场景(如数据相对固化,且查多写少的场景)外,都不会额外的设计聚簇索引,而是采用自增的。
顺序下数据页的存储
假设使用AUTO_INCREMENT作为聚簇索引,顺序插入聚簇索引列值为1,2,3的三行数据,数据页就是这样:
随着数据的顺序插入,数据页会慢慢变满,当达到页的最大填充因子时(InnoDB默认为最大页大小的15/16,留出空间用于增长),下一条记录就会写入新的页中。每个页中都会存着指向前一页和后一页的指针。
页分裂
假设聚簇索引列不是顺序插入的,而是随机的,这里假设顺序是:6,2,4,7,1,8…5…3:
插到8的过程是下图这样的,假设此时已达到最大填充因子,8到5之间的数据会新开页并插入到后续的页中:
然后插入5,因为装载因子的预留空间,所以5还能插入,该页变成这样:
彻底填满了数据页,此时插入3,但是该页已经没有足够的空间了,为了保证聚簇索引的顺序性,就需要进行页分裂,也就是生成一个新页来承载该页的数据,新页也不再是仅仅迁移一个8了,会迁移更多数据保证均衡,整个过程会耗费更多的资源,同时也会造成空洞,这就会导致数据库占有空间比实际空间更大的情况。
这也是为什么大多数情况下我们使用自增主键的原因。
页合并
了解了页分裂,页合并也就更好理解的,当删除一行数据时,实际上记录并没有被物理删除,而是仅仅打上一个标记声明可以被其他记录使用,当页中剩余的记录不足MERGE_THRESHOLD(默认为页体积的50%)时,InnoDB会探查这个页前面或者后面的页是否有足够的空间,如果有,就会把这个页的数据迁移过去,完成页合并,这个动作同样相当耗费资源。
高水位
页分裂导致空洞以及记录被逻辑删除,而又没有达到页合并的阈值导致空洞的情况会使得数据库占有空间比实际空间更大,使得数据库处于高水位,此时可以通过alter table tableName engine = innodb;或者optimize table tableName(只对MyISAM, BDB和InnoDB表起作用) 来进行表重组,但是重组操作会锁表,这是需要重组时需要考虑到的。
排序索引构建
如果采用常规的B+树构建逻辑,在创建或者重建索引时,批量加载数据,使用插入 API 一次将一条记录插入到B+树,这就涉及打开 B+ 树 游标来查找插入位置,然后使用将条目插入到 B 树页面中。如果页面已满依然要进行页分裂。这种“自上而下”的索引方式弊端在于搜索插入位置的成本和B+树节点的不断分裂和合并。
InnoDB采用一种称为排序索引构建的方式自下向上的构建索引树。
构建共分为三个阶段。在第一阶段, 扫描索引记录,生成索引条目并将其添加到排序缓冲区中。当排序缓冲区已满时,条目将被排序并写入临时中间文件。这个过程也称为 “运行”。在第二阶段,将一次或多次运行写入临时中间文件,对文件中的所有条目执行合并排序。在第三个也是最后一个阶段,将排序后的条目按照先构建叶节点的方式自下而上的插入到 索引树中。
索引优化
模糊查询打断最左匹配?-索引下推
假设一个表包含有关人员及其地址的信息,并且该表的索引定义为 INDEX (zipcode, lastname, firstname)。如果知道一个人的zipcode但不确定姓氏,可以这样搜索:
SELECT * FROM people
WHERE zipcode='95054'
AND lastname LIKE '%jack%'
AND address LIKE '%Main Street%';
按照正常索引的最左匹配原则,执行过程应该是:
- 获取一行,回表查询读取整行数据。
- WHERE条件测试此行的条件部分,根据测试结果决定接受还是抛弃该行。
索引条件下推下的扫描过程:
- 获取一行的索引数据。
- WHERE条件测试用于索引数据,如果不满足条件,则继续查找下一个索引数据。
- 如果满足条件,回表查询读取整行。
- WHERE条件测试此行的条件部分,根据测试结果决定接受还是抛弃该行。
通过这种方式,索引下推可以避免大量的回表。
仅能使用一个索引?-索引合并
多列索引是在多列建立一个索引,而不是为每一列建立单独的索引,在多个列建立独立索引并进行多列组合查询大多数情况下并不能提高性能。MySQL5.0之前最好的情况下也只能使用最左的一个索引。
MySQL5.0引入“索引合并(index merge)”策略,理念是通过取交集,并集的方式将同一个表多个索引的范围查询合并成一个索引。
如以下语句,分别在key1和key2上建立索引。
SELECT * FROM tbl_name WHERE key1 = 10 OR key2 = 20;
索引合并通过将key1和key2的结果取并集来获得结果。 索引合并仅支持单个表的索引扫描,不支持跨多个表的扫描。
索引合并默认开启,但是尽量避免在需要当前读的语句上采用索引合并,他可能会导致死锁的发生。
自适应Hash索引
Innodb存储引擎会监控对索引的查找,如果发现某个索引值被频繁访问,那么InnoDB会根据索引值自动构建一个Hash索引来提升性能。
AUTO_INCREMENT
计数器
如果为InnoDB表指定AUTO_INCREMENT列,则会在内存中为表生成一个称为自动增量计数器的特殊计数器,并使用该计数器为该列分配新值。
该计数器每次改变都会写入同步写入redolog,并在每个checkpoint刷入磁盘;这使得当前最大自动增量计数器值在服务器重新启动后保持不变。
MySql正常重启时,计数器会从磁盘读取值并初始化内存中的增量计数器。崩溃恢复时,计数器会以磁盘中读取到的值进行增量计数器初始化,并扫描redolog的值,取两者的大值进行恢复。
如果生成自增值的事务回滚,则自增值会丢失,并不会回滚。
新增语句的两种形式
- 可预知插入,比如单行和多行的普通Insert。这种插入可以预知到具体要插入多少行。
- 不可预知插入,比如INSERT … SELECT,这种语句在插入前具体要插入多少行是不可预知的。
InnoDB的自增锁模式
- 0: 传统锁模式,此模式下,任何INSERT语句都会获得一个特殊的表级AUTO-INC锁,用于插入包含 AUTO_INCREMENT列的表。该锁通常保持到语句末尾(而不是事务结束),以保证能可预测且可重复为指定的语句序列分配自动递增值,并确保自动递增值对给定语句分配的值都是连续的。
- 1: 连续锁模式,8.0之前的默认模式。此模式下,对于可预知的插入操作,会一次申请足够的数量,AUTO-INC仅在申请期间持有。对于不可预知的插入,则和传统模式一样。此中模式同一个语句生产的值都是连续的。
- 2:交错锁模式,8.0之后的默认模式。此模式下,没有了AUTO-INC的概念。速度大大提升,代价却是对于不可预知插入生成的值存在间隙。对于可预知的,依然是一次申请,不存在这这种问题,而不可预知插入因为是一个个申请的,这就意味着,同一个SQL语句申请到的值不是连续的。除此之外还带来另外一个问题,那便是数据不一致,同一批插入语句,即使执行顺序相同,但是因为调度的关系,依然可能生成不同的结果。这种问题在主从同步时就会受到影响。这也是为什么8.0将binlog默认格式设置为ROW的原因。
三大日志
binlog
binlog用于记录数据库执行的修改性操作,以二进制的形式保存在磁盘中。binlog是MySQL的逻辑日志,由服务层进行记录,也就是说无论使用任何存储引擎的MySQL都会记录,binlog主要应用于数据的备份(包括主从复制)与数据恢复。
binlog日志的记录形式有三种:STATEMENT,ROW,MIXED。通过binlog-format指定。
- STATEMENT:基于语句,直接记录执行SQL。优点是占用空间少,缺点是可能导致数据不一致。
- ROW:基于行,记录对行的影响。 可以简单的理解为变为对目标行的set值操作。 8.0后的默认格式。 优点是不会导致数据不一致,缺点是可能会占用较多的空间。
- MIXED:混合模式,MySql识别那些可能导致数据不一致的语句使用ROW。否则使用STATEMENT。
redolog
redolog归属于InnoDB存储引擎,属于物理日志。事务有持久化的特性,而要实现持久化,仅仅录入内存是不够的,势必要与磁盘进行交互,而InnoDB与磁盘的交互是以数据页为单位的,如果一个事务只修改了一个数据行内的某几个字段,此时就进行一次完整的数据页刷入的话,不仅影响性能,还导致资源浪费。而如果一个事务修改了了多个数据页,如果对每个数据页都进行磁盘操作,那么随机IO的情况下,这个事务的性能也会变得很差。redolog是InnoDB在保证性能的情况下实现持久性的关键,它记录事务对数据页进行了哪些修改,然后异步合并刷入磁盘,不仅减小了与磁盘的交互数据量大小,而且是顺序IO。
redolog不像binlog,无需长久保存,毕竟数据页持久化后他就没有存在的意义了,因此redolog采用的是大小固定,循环写入的方式,日志写到结尾是,会回到开头循环写,如下图:
redo文件记录有两个LSN(逻辑序列号)位置,分别是write pos和check point, check point表示当前数据页刷盘进度,write pos表示当前redolog 文件写入的位置。
如果应用故障或宕机,MySQL重启后就会先探查check point的位置,从check point开始进行恢复(当然实际不是这一句话这么简单)。
redolog和binlog各司其职,binlog进行数据备份, redolog的存在使得InnoDB一定程度上具有crash-safe的能力。为什么说一定程度,那是因为只有redolog是不够的。
undolog
undolog记录着数据的逻辑变化历程,属于InnoDB逻辑日志。 可以简单的理解为行数据的每次修改,undolog都会记录下修改前的快照,并根据事务执行顺序组成一个链表。 这样一个事务需要查看原始数据达成一致读取操作时就可以从undolog中检索未修改的数据。
undolog清除时机。 数据执行delete语句时逻辑删除,在undolog日志清除时执行物理清除。
- 插入undolog: 事务提交后清除
- 更新undolog: 不存在其作为快照的事务时间
双写缓冲区
MySQL的数据页大小默认是16KB, 但是OS数据页大小却不一定相同,例如Linux文件系统页(OS Page)的大小默认是4KB。 一般情况下应用程序的页都是OS Page的整数倍。
这就意味着要将一个InnoDB数据页写入磁盘,要写4个OS Page,但是这个操作并非原子操作,比如InnoDB在写到第二个页的时候,Linux机器断电了,这时候就会出现问题。造成”页数据损坏“。
这种损坏redolog日志是无法解决的,因为redolog记录的修改数据的物理操作,但是如果发生部分页写入,损坏的可能是未修改的数据。
为了解决这种问题,InnoDB设计了Doublewrite Buffer,
当有页数据刷盘时,页数据先刷入双写缓冲区,然后在刷入数据文件中。如果OS在将数据页写入磁盘的过程中发生了崩溃,那么恢复时,InnoDB存储引擎可以从Doublewrite Buffer中找到数据页的一个完好的副本,将其复制到表空间数据文件,再执行redlog重放进行恢复。
Doublewrite Buffer虽然要写到磁盘,但其和redolog 一样采用顺序写的方式,因此性能上不会有太大的损耗。
缓冲池
数据是要落盘的,而磁盘的读写操作相较于内存慢的多,因此为了避免每次都操作磁盘,InnoDB设计了基于内存的缓冲池来提高执行效率。
一条数据执行时,如果是读操作,要查找的数据所在的数据页在内存中时,则将结果返回,否则会先将其所在的数据页加载到内存中,然后再返回结果。如果是写操作,要修改的数据所在的数据页在内存中时,则修改后返回对应的结果(后续再刷入磁盘中),否则会先将其所在的数据页加载到内存中,再进行修改。
基于内存的操作会让执行效率大大提升,因此为了尽可能的让数据所在的数据页都在内存中,InnoDB具有预读的特性,预读简单的来说就是读取数据页时,会将相邻的数个数据页也提前读入内存。
因为缓冲池大小的限制,缓冲池会采用LRU算法来淘汰不常用的数据页以避免溢出。
标准LRU
LRU(Least Recently Used),即最近最少使用,选择最近最久未使用的页面予以淘汰。
对于InnoDB来说,标准LRU会带来两个问题:
- 预读失效:因为InnoDB的预读机制,可能会一次加载多个数据页,而这些数据页并不一定是全都有用的,如果此时采用标准LRU,将这些数据页都放到链表头部,淘汰已有的尾部,那就可能出现无用的数据页被放入缓冲池,而常用的数据被移除。这就属于预读失效。
- 缓冲池污染:如果执行一条需要全表扫描或者扫描了大量数据的SQL,那么缓冲池会加载大量的数据页,如果采用标准LRU算法,会使得常用数据页都被替换出去,导致MySQL性能下降。这就是缓冲池污染。
InnoDB的LRU
InnoDB采用了类似GC的分代策略,将缓冲池分为Old和New两部分。
- 缓冲池的 3/8 用于存储Old页面。
- 列表的中间点是New页面列表的尾部与Old页面列表的头部相交的边界。
- 当InnoDB将页面读入缓冲池时,因为页面被读取要么是因为这个页面上用户所需要的,要么是预读的一部分。因此会将其先插入到中点即Old页面子列表的头部。 New列表不受影响,一定程度的解决了预读失效的问题。
- 如果1S后缓冲页再次被访问,会将其移动到New列表的头部。1S的延迟是为了尽量保证这个数据页不是一个临时读取。另外,对于New区域来说,可能内部的数据页会被频繁读取,因此为了避免每次读取都维护链表,同时又能保证LRU算法的诉求,MySQL对New数据区的维护做了优化,只有后3/4的数据页被访问时才移动到头部,前1/4的数据页被访问了也不会移动。
- 缓冲池中未访问的页面会通过向列表尾部移动而“老化” 。最终,未使用的页面到达列表的尾部并被驱逐。
redis的LRU
为了避免标准LRU需要付出的大量内存,Redis一样采用一种近似LRU算法。
Redis为每个key增加了一个24bit的额外小字段用来存储最近访问的时间戳。当内存不足需要执行LRU时,redis会根据策略从设置了过期时间的key或者所有key中随机采样5(可配置)个key,然后淘汰掉最旧的key,如果内存依然不足,那么就继续采样淘汰。设置的采样样本量越大,就越接近严格的LRU算法。
在Redis3.0中还新增了淘汰池,进一步提升近似LRU算法,淘汰池是一个数组,大小和采样大小一样,每一次采样淘汰时,先将选出来的key与淘汰池中的key结合,淘汰掉最旧的的一个key,然后将剩下的key整合到淘汰池等待下一个循环。
更改缓冲池
对于非唯一的二级索引,InnoDB设计了专门的缓冲池-更改缓冲池以避免执行变更操作时可能出现的大量磁盘交互。
上面提到,内存和磁盘交互的最小单位是数据页,当执行变更操作时,如果数据页不存在于Buffer Pool中,需要先将数据页读入Buffer Pool更新数据页之后再刷入磁盘。
而二级索引与一级索引存在一些差别,不像自增场景下聚簇索引的相邻性,连续插入的两条数据在二级索引上可能在两个数据页。同样的,删除和更新操作所影响的二级索引可能也不是相邻的。而且我们可能会建立多个二级索引。悲观场景下,两次相邻的insert操作会产生1(聚簇索引的数据页IO)+2*N(N为二级索引数量)磁盘交互,不仅交互变慢,同时还会占据bufferPool的空间。但是二级索引并没有使用。
对于数据页存在于BufferPool的,ChangeBuffer不会发生作用。但对于非唯一二级索引页不存在BufferPool的,则会将对二级索引的更改先缓存到ChangeBuffer中,并在未来特定条件下其合并到Buffer Pool中。
- 二级索引被使用时,会先将其读出,并将Change Buffer中的内容进行合并,然后放入Buffer Pool中进行使用。
- 系统空闲或关闭时
- Change Buffer空间耗尽时
- Redo Log日志空间耗尽时
Change Buffer仅针对非唯一二级索引生效,这是因为对于唯一索引,总是要读出来进行唯一性判定的。
Change Buffer默认占据Buffer Pool空间到25%, 当数据库有较多的非唯一二级索引,同时有大量的写入操作而又不会立即使用这些写入数据的场景下,可以适当的调大Change Buffer来提升性能。
参考资料:
MySQL官方文档
MySQL各种“Buffer”之Change Buffer
PS:
【JAVA核心知识】系列导航 [持续更新中…]
关联导航:MySQL架构基础
关联导航:MySQL数据类型选择与设计
关联导航:创建高性能的索引
关联导航:查询性能优化
关联导航:EXPLAIN的使用
欢迎关注…