引言
提到内存架构,在Java里存在很经典的JVM -> redis -> MySQL三层结构 。意思是在获取数据时,优先去读JVM的缓存,如果读不到的话,就去redis查找,最后实在不行才去做MySQL的IO查询。即:先去内存里找数据,实在不行才去硬盘里找 。因此,我们普遍认为:MySQL是基于磁盘工作的。
但实际上是,MySQL在底层做了相当多的优化,我们普遍认为的执行一条insert语句,就会马上去磁盘中插入一条数据(但实际上是去写内存 ),还有相当多的操作实际上是写内存,就例如, MySQL的写日志操作 :(redo log、undo log、binlog是干什么用的?)。从我个人的角度来理解,MySQL实质上是:依赖于磁盘,但基于内存来工作。
本篇的介绍内容如下:
MySQL内存结构如何划分?
以InnoDB为引擎,从MySQL的整体架构上看,可将MySQL划分为四大部件:MySQL Server组件、MySQL共享内存、MySQL线程私有内存、InnoDB-Buffer Pool。如下:
MySQL Server组件
MySQL Server组件主要包含:连接器、分析器、优化器、执行器。作用如下:
- 连接器:管理连接
- 分析器:语句、语义分析
- 优化器:确定索引,执行计划
- 执行器:执行具体sql,操作引擎
Server组件的详细作用可参考:一条sql是怎么样执行的?
MySQL共享内存
线程共享区域,类似Java中的JVM内的堆内存,所有线程均可访问。具体的结构如下;
Key Buffer:MyISAM引擎的索引缓冲区,将索引缓存到此处,提高索引的读写效率。
Query Cache :查询缓存,存放具体的行数据,当一条select语句进来时,会先去判断下是否有相关缓存,如果有的话,直接返回。但在MySQL5.7及后面的版本已被移除。
但本质上这个功能很鸡肋,在MySQL5.7及之后的版本就已经被移除了,原因如下:
- 如果碰到update语句,需要不断更新缓存值,在读少写多的情况下效率更差
- 缓存是以K-V形式存储,key是查询的sql,而value是值。 只要select后面的字段随便变一下,缓存就无法命中(select * 和 select all_columns 查询结果相同,但被视为两个不同的key)
因此在大多数情况下,MySQL的缓存功能是弊大于利的, 因此后面也被移除掉了
Thread Cache:线程缓冲区,存放工作线程在运行时,需要共享的数据。
Table Cache :表数据文件的文件描述符(FD)缓存,指向具体表的指针 。避免在执行DML语句时,要去全盘扫描共享内存找到对应的表在哪里,提升打开数据表的速度
Table Definition Cache :表结构文件的文件描述符(FD缓存)。指向具体表结构的指针 。免在执行DDL语句时,要去全盘扫描共享内存找到对应表的表结构在哪里,提升打开结构表的速度
Table Cache和Table Definition Cache分别指向数据表和数据表结构的所在地
MySQL的本地内存
本地内存是MySQL工作线程私有的,类似Java里的JVM中的私有栈,线程拥有一块自己的内存区域。详细的结构如下:
Thread_stack:栈的结构,用于存放工作线程运行的sql以及计算的结果
sort_buffer:排序缓存池,当执行的sql需要排序,且排序是无序的(无索引),用于存放排序后的结果
join_buffer:连接缓存池,当执行了join的sql,其中未用到被驱动表的索引,启动了BNL算法,将用到join_buffer存放符合连表查询的结果
read_buffer:顺序读缓存池,存放顺序I/O的结果
read_rnd_buffer:随机读缓存池,存放无序查找读到的结果
net_buffer:网络连接缓冲池,存放当前线程的客户端连接信息,客户端可以为:命令行,J2EE,navicat等
tmp_table:存放sql用到的临时表结构和数据,当sql中会创建临时表时会启动
bulk_insert_buffer:批量插入缓存池,当执行insert batch语句时,存放临时的数据
bin_log_buffer :binlog的写入日志缓存池,详细功能可参考:redo log、undo log、binlog是干什么用的?
sort_buffer和join_buffer的具体作用可参考:详解MySQL索引失效、索引实战
InnoDB-Buffer Pool
当MySQL进程启动时,MySQL会向操作系统申请一块内存,作为缓冲区(线程共享区)。该内存的默认大小为128M。由参数innodb_buffer_pool_size控制,如下:
在一开始说的,MySQL的本质是依赖于磁盘,基于内存工作,也是和Buffer Pool分不开联系。具体的结构如下:
Data Page
写入缓存区,用于缓存放于磁盘中的行数据,将写操作移到内存中进行
我们知道,MySQL进行一次IO操作的最小单位为页。在启动的时候,InnoDB会将申请到的Buffer Pool,划分为一个个缓冲页 。因此,发生一次IO操作读取的数据会直接放到一个缓冲页中存储。缓冲页即Data Page。读过程如下:
在发生读操作时,会先去判断缓冲页是否存在数据(即Data Page)。写过程如下:
在发生写操作时,会将数据直接修改在内存里的Data Page,并非写磁盘,并记录redo-log日志(保证数据不丢失)。
Index Page
索引缓冲区,用于缓存表的索引的根节点和部分热门索引的普通节点。
当执行一条查询sql时, 如果数据没有缓存在Data Page里,就需要去磁盘中查找,假设该查询sql的查询条件命中了索引。此刻面临了一个问题:索引树的根节点在磁盘中的位置不确定!可以存储在任意位置。因此会面临着通过随机IO来寻址的问题
因此引入了Index Page,将索引树的根节点都提前加载到内存中,避免随机IO问题。此刻,读过程如下:
Lock Space
锁空间,在事务的并发篇中,我们知道:可以通过锁来解决事务的并发性问题。而创建一把锁,就会生成对应的锁结构,Lock Space就是用于存储锁结构的内存区域。具体的结构如下:
rec_lock_type:行锁的具体类型,占23bit,划分规则如下:
- 临键锁:高23位全为0
- 间隙锁:高到低,第10位为1
- 记录锁:高到低,第11位为1
- 插入意向锁:高到低,第12位为1
is_waiting:表明此刻锁处于等待还是持有状态
- 持有:0
- 等待:1
lock_type:表明锁的类型,行锁 or 表锁
- 行锁:由低到高,第6位为1
- 表锁:由低到高,第5位为1
lock_mode:锁的模式
- IS锁:0000
- IX锁:0001
- S锁:0010
- X锁:0011
- 自增锁:0100
当锁的结构为:0000 0000 0000 0000 0000 0001 0010 0011,代表什么呢?
由高到低前23位均为0,表明该锁为临键锁;第24位为1,表明锁的状态处于等待状态;由低到高,第6位为1,表明锁为行锁;lock_mode为0011,表明锁为X锁,即排它锁
综上,该锁的结构为:处于等待状态的排它临键锁
Dict Info
字典数据,用于存储innoDB自带的系统表(information_schema数据库内的innodb_sys_xxx表),维护用户定义的所有表的各种信息。
可以通过如下sql查询表的索引、列、字段等详细信息,该数据即缓存在Dict Info中
javascript
show index from `tablename`;
show columns from `tablename`;
show fileds from `tablename`;
redo_log buffer && undo_log_buffer
redo_log缓存区和undo_log缓存区,用于缓存redo-log和undo-log的记录,详细的作用可参考: redo_log buffer结构详解
Adaptivity Hash
自适应哈希索引。在查找效率(单一查找)上,Hash结构可以说是最快的了,有最好的查找优势。在不发生Hash冲突的前提下,查找的时间复杂度为O(1)。
除了单一的查找,Hash结构并不适用于排序、范围查询等场景。(Hash计算无法保证有序)因此,Hash索引也没有被选为innoDB引擎的存储数据结构。
但InnoDB引擎为了提升查询的效率,也用到了hash结构。针对热点数据,额外建立一个Hash索引,来提升访问B+树的开销。该Hash索引也称为自适应哈希索引
Tips: 区别于普通的Hash索引,自适应哈希索引只会针对热点数据创建。并且,MySQL中是默认开启Adaptivity Hash的,由如下参数控制:
Insert Buffer
写入缓冲区,也称为Change Buffer 。Change Buffer中记录了数据的增删改操作
在前面介绍Data Page时,我们了解到:执行写操作时,实际上是需要先将其对应的数据页读到内存中(若内存中不存在),再将数据更新到数据页中 。虽然引入了Data Page,将原先的写磁盘变成了写内存操作,但肯定会有读磁盘 的操作(总会有一次读取)。为了避免写操作时引起的读磁盘操作,引入了Change Buffer来处理。
引入了Change Buffer的写操作过程如下:
InnoDB会将更新的操作直接缓存到Change Buffer中,随即直接返回结果,避免从磁盘去读相应的数据页 。(避免写操作时去读磁盘,减小IO开销,提升执行速度)
等到下次的读操作需要访问到涉及到Change Buffer里的数据页时,再将对应的数据页读到内存,完成merge操作,返回查询结果
Change Buffer的使用时机
关于Change Buffer的使用,在执行一条数据变更的sql时,要保证数据需要不满足唯一性原则(不存在唯一列以及主键)。
试想,如果表里有字段要求唯一,在修改一条数据时,如果将其直接写到Change Buffer 里并返回成功。在后续merge数据时,Change Buffer里的数据和表数据起了唯一性冲突 ,岂不是自相矛盾? 因此,在修改数据时,需要先判断该数据是否违反了唯一性原则 ,如果需要遵循唯一性原则,就一定要先把数据读到Data Page中进行判断,因此也用不到Change Buffer了
结合了Change Buffer,一条插入的sql的完整流程如下:
在插入数据时,如果插入的数据中存在唯一字段,就必须要把对应的数据页读到内存里。反之则不用,可通过写Change Buffer来避免读磁盘操作。
结合了Change Buffer和redo-log的写操作
结合redo-log来完整看待一条sql的过程,写操作和读操作如下:
假设存在一张表t,有字段:id,k,假设均不满足唯一性原理。执行一条insert sql,如下:
scss
insert into t(id, k) values (1,1),(10,10);
假设id = 1所在的数据页已在内存(操作别的数据读进来的);id = 10所在的数据页不在内存。因此,执行该条sql时,会同时去写redo log以及change buffer。
对于Change Buffer来说,write的数据如下:
(1,1)所在的数据页已经在内存了,因此直接将该数据更新到内存即可。
(10, 10)所在的数据页不在内存,因此直接将insert (10, 10) 写到Change Buffer中。
而对于redo-log来说,记录的数据如下:
redo log里同时记录了两条insert记录,分别插入不同的数据页里。写到change buffer中的数据也记录了,change buffer也是内存的一部分,若在merge前MySQL宕机了,也会出现数据丢失情况
在执行完上述的写操作后,紧接着执行读操作,sql如下:
csharp
select * from t where k in (1,10);
该sql要查询k为1和10的数据(刚刚插入的两条数据)。此刻,Change Buffer的内存结构变化为:
对于k=1 的行数据,由于该行数据所在的内存页在内存,直接返回内存里的结果即可。
对于k=10 的行数据,由于该行数据所在的内存页不在内存,此刻会进行一次读磁盘操作 ,将该页读到内存。又由于Change Buffer中存在该页的数据变更 ,会触发一次merge操作,将Change Buffer里的数据同步到Page 2里。最后返回结果
change buffer和redo-log对比 总结
假设没有change buffer和redo log,插入一条数据的步骤变为如下:
一条插入的sql,会经历一读一写过程。
如果没有change buffer ,每次修改数据的时候都会去读磁盘里的数据页,而有了change buffer,就可以先将数据写到其中,减少每次读磁盘的操作。
如果没有redo-log ,为了保证数据不丢失,每次修改数据的时候最后都会去写磁盘,而有了change buffer,就可以先将数据写到redo-log的尾巴处,即可。
对于redo-log来说,替代了上述的步骤4:将数据页写回磁盘 ,直接写在日志的尾巴处即可,节省了随机写磁盘的消耗,随机写转为顺序写。
对于change buffer来说,替代了上述的步骤2:将数据页加载到内存 ,更新的数据直接写在change buffer即可,节省了随机读磁盘的消耗
Buffer Pool的内存是如何管理的?
前面我们梳理了MySQL的整体架构(重点介绍了InnoDB中的Buffer Pool),既然划分了一块内存。InnoDB也有自己的内存管理方式。内容如下:
缓冲页和控制块的联系
MySQL在启动的时候,会向OS申请一块连续的内存区域作为Buffer Pool,而Buffer Pool会被划分为连续的缓冲页。 随着MySQL运行时间的变长,缓冲页不断回收,启用。也会使得整个Buffer Pool变得断断续续。如下:
在碎片化的缓冲页里,如何才能快速找到想要的数据呢?
引入控制块 ;InnoDB会为每一个缓冲页创建一个控制块,而控制块中会存储其管理的数据页的基本信息(空间,页号,地址,指针等)。控制块通过指针指向缓冲页。如下:
控制块和控制块之间也是通过指针连接的,在Buffer Pool中有专门的空间存储。在寻址的时候,只要通过遍历控制块,再通过控制块找到相应的数据即可。
空闲页的管理
在执行一条查询的sql时,若内存中没有相应数据,此刻就需要去读磁盘,然后将数据写到内存,再返回结果。 在写内存的时候,就需要Buffer Pool为其分配空闲的Data Page。
InnoDB是如何知道哪里的页是空闲的,可分配的呢? 不可能去遍历整个空间。此刻就需要对空闲页做管理了!(为了能更方便的找到空闲页分配空间 )。管理的数据结构如下:
Buffer Pool会将所有空闲页的控制块通过指针串连在一起 。在Buffer Pool存在一块Free节点管理,由三部分组成:
- 虚拟头节点,指向空闲链表的第一个控制块
- 虚拟尾节点,指向空闲链表的最后一个控制块
- count:记录空闲控制块的数量
当需要空闲页的时候,不需要去随机寻址 ,直接访问Free节点管理中的Header或者Tailer,即可。
标记页(脏页)的管理
当执行一条增删改的sql时,如果内存中存在相应数据的数据页,此刻会直接在内存中修改数据,在写完change buffer和redo-log后返回成功 ,此刻该数据的内存数据页和磁盘数据页是不一致的。我们页称该内存数据页为脏页。同样的,InnoDB也有脏页的管理,方便寻址,数据结构如下:
脏页的内存管理和空闲页的内存管理结构上是一样的,只是空闲页是Free节点管理;脏页是Flush节点管理。
通过脏页链表,当开始刷盘的时候,不需要随机寻址即可直接找到Buffer Pool中的脏页。
页淘汰机制介绍
Buffer Pool的空间也不是无限的,随着数据页的不断写入,无法容纳无限的数据。因此, Buffer Pool也存在页淘汰机制。除了Free链表(管理空闲页),Flush链表(管理脏页),还存在一个LRU链表,来管理可以被淘汰的数据页。一个缓冲页,可能会在Free链表,Flush链表,LRU链表中来回切换。三者的关系如下:
当MySQL启动的时候,此刻InnoDB刚申请了空间作为Buffer Pool。此刻还没有数据进入,所有的Data Page应当都是空闲页,都处于Free链表中。
在写入数据时,如果无法使用Change Buffer,就需要从磁盘中读对应的数据页进来(申请Free页 ),并对其在内存中修改。此刻,磁盘的数据页和内存的数据页不一致。内存的数据页变成脏页,进入Flush链表。
在MySQL刷盘的时候,Flush链表中的数据页被更新,数据和磁盘保持一致,此刻数据页会从Flush链表进入LRU链表。
在Buffer Pool空间不足时,会将已用的数据页做清空,释放LRU链表部分数据页。此刻数据页又会从LRU链表中进入Free链表。
通过上述转换流程可知:
-
一个缓冲页在同一时刻,只能只会被放在一个链表中,无法同时处在两个链表中。
-
在回收空间的时候,只会回收LRU链表中的数据页。(Flush链表的页必须先刷盘再回收,不然数据会不一致;Free链表的页本身就是可以直接使用的,无须再回收)
LRU淘汰机制介绍
当空间需要回收时,LRU链表是通过LRU算法来淘汰掉内存页的。(淘汰掉最不常用的数据页)
LRU算法操作如下:
- 当LRU链表中的某个数据页被访问到了,便会将数据页挪到LRU链表的最前面
- 当需要淘汰LRU链表中的数据页时,直接淘汰LRU链表末尾的数据页。(最不常用到)
结合LRU链表,读操作的流程如下:
LRU导致的问题
如果单纯 使用LRU链表淘汰页数据的话,会出现两个问题:预读失效和缓存污染
预读失效
MySQL在读数据的时候,会采用局部性原理(提前加载相邻的数据页,局部性原理即:程序认为相邻的数据在未来大概率也会访问到)将附近的数据一起载入到内存当中。
此外,InnoDB也有自己的预读机制,我们知道:innoDB在存储数据的时候,会将64个页划为一个区空间 。具体的划分缘由可参考: InnoDB空间结构划分。结合区空间,InnoDB存在预读性策略。
在执行一条查询sql时,如果查到的数据较多,一个区空间中读取的数据页个数达到阈值时,会触发预读。
- 将区空间中其余的数据页都加载到内存。
- 将下一个区空间提前加载到内存中。
触发预读的阈值由innodb_read_ahead_threshold控制,如下:
默认值是56,代表:当一个区空间超过56页被加载到内存时,就会触发InnoDB的预读。
当预读的数据被加载到内存时,LRU链表变化如下:
原先的LRU链表存放大量的热点数据,此刻发生了预读,将原先的热点数据都淘汰掉,替换成新的预读数据。如果被提前加载的数据页没有被访问,预读等于白做了,还白白淘汰了大量的热点数据!该情况即:预读失效。
解决方法
可以参考JVM里的堆空间老年代和新生代的划分设计。将LRU链表划分为两个区域:young区域和old区域。结构如下:
young区域在LRU链表的前半部分;old区域则在链表的后半部分。young区域内存放着热点中的热点数据!
可将预读机制读到的页先加到old区域的头部,当真正被访问的时候,才将数据页从old区域挪到young区域。即可避免由于预读失效导致的大量热点数据从LRU链表中移除
其中,LRU链表的old区域和young区域的比例由:innodb_old_blocks_pct控制
默认值为37,即:假设LRU链表中有100个数据页,young区域占据63个数据页;old区域占据37个数据页。
将LRU链表按区域划分后,此刻数据页载入内存变为如下:
被访问到的页(N)会先被加载到young区域的头部 ;而预读到的页(N+1, N+2)则会放在old区域的头部。
缓存污染
当执行一条查询sql时,该sql查询到大量数据(非预读数据),数据量足以将原有的LRU链表的数据页全部淘汰掉,如下:
此刻可能会出现两个问题,如下:
- 原有的热点数据都被置换出去了,当原有的热点数据再次访问时,就无法命中缓存,导致查询的性能下降。
- 查询出来的数据集只用这一次,后续都不用了,纯纯浪费
以上两个问题都可称为:缓存污染
解决方法
参考JVM中的新生代晋升老年代的结构,进入老年代有升级门槛,能有效保证老年代的内存较为稳定,不会被频繁回收,升级是有条件的。InnoDB中解决缓存污染的具体做法如下:
对进入LRU链表中的young区域设定门槛,只有达到了才能进入。增加停留在old区域的时间判断。当数据页进入LRU链表时,先进到old区域。具体判断规则如下:
- 当某个处在old 区域的缓存页第一次被访问到,记录下访问的时间
- 后续再访问到该缓存页,如果此刻的访问时间距离第一次访问时间不够长,则不动
- 如果时间够长了 ,则将该缓存页从old区域挪到young区域
上述的时间间隔长短由 innodb_old_blocks_time决定,如下:
默认时1000ms,即1s。即:处在old区域的缓存页,需要被访问至少两次,且在old区域呆够1s,才能迁移到young区域,判断如下:
因此,一条查询sql读到的大量数据,即使是有访问到,也会全部进入到old区域(并且需要在old区域呆够1s,且1s后再被访问到,才会晋升到young区域 ),不会将热点数据从LRU链表中移除。有效避免掉缓存失效问题。