(八)MySQL内存篇-2:MySQL是如何运用并管理内存的?

引言

提到内存架构,在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及之后的版本就已经被移除了,原因如下:

  1. 如果碰到update语句,需要不断更新缓存值,在读少写多的情况下效率更差
  2. 缓存是以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链表

通过上述转换流程可知:

  1. 一个缓冲页在同一时刻,只能只会被放在一个链表中,无法同时处在两个链表中

  2. 在回收空间的时候,只会回收LRU链表中的数据页。(Flush链表的页必须先刷盘再回收,不然数据会不一致;Free链表的页本身就是可以直接使用的,无须再回收

LRU淘汰机制介绍

当空间需要回收时,LRU链表是通过LRU算法来淘汰掉内存页的。(淘汰掉最不常用的数据页

LRU算法操作如下:

  1. 当LRU链表中的某个数据页被访问到了,便会将数据页挪到LRU链表的最前面
  2. 当需要淘汰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区域。具体判断规则如下:

  1. 当某个处在old 区域的缓存页第一次被访问到,记录下访问的时间
  2. 后续再访问到该缓存页,如果此刻的访问时间距离第一次访问时间不够长,则不动
  3. 如果时间够长了 ,则将该缓存页从old区域挪到young区域

上述的时间间隔长短由 innodb_old_blocks_time决定,如下:

默认时1000ms,即1s。即:处在old区域的缓存页,需要被访问至少两次,且在old区域呆够1s,才能迁移到young区域,判断如下:

因此,一条查询sql读到的大量数据,即使是有访问到,也会全部进入到old区域(并且需要在old区域呆够1s,且1s后再被访问到,才会晋升到young区域 ),不会将热点数据从LRU链表中移除。有效避免掉缓存失效问题。

相关推荐
大春儿的试验田3 分钟前
高并发收藏功能设计:Redis异步同步与定时补偿机制详解
java·数据库·redis·学习·缓存
Ein hübscher Kerl.35 分钟前
虚拟机上安装 MariaDB 及依赖包
数据库·mariadb
长征coder1 小时前
AWS MySQL 读写分离配置指南
mysql·云计算·aws
醇醛酸醚酮酯1 小时前
Qt项目锻炼——TODO清单(二)
开发语言·数据库·qt
ladymorgana1 小时前
【docker】修改 MySQL 密码后 Navicat 仍能用原密码连接
mysql·adb·docker
PanZonghui1 小时前
Centos项目部署之安装数据库MySQL8
linux·后端·mysql
GreatSQL社区2 小时前
用systemd管理GreatSQL服务详解
数据库·mysql·greatsql
掘根2 小时前
【MySQL进阶】错误日志,二进制日志,mysql系统库
数据库·mysql
weixin_438335402 小时前
基础知识:mysql-connector-j依赖
数据库·mysql
小明铭同学2 小时前
MySQL 八股文【持续更新ing】
数据库·mysql