Mysql核心总结

Mysql核心总结

Mysql基本架构

如果要访问一个Mqsql数据库,那么就需要Mysql驱动才能跟Mysql数据库建立连接,执行各种各样的SQL语句。

Mysql驱动会在底层跟数据库建立网络连接,有了网络连接,才能去发送请求给数据库服务器,那么我们就可以用Java基于这个连接执行各种各样的SQL语句。

在这里插入图片描述

数据库连接池

由于开发的系统常常是多线程的,比如将Java Web系统部署在Tomcat中,那么Tomcat本身是有多个线程来并发的处理同时接收到的多个请求,那么如果多个请求都去争抢一个连接去访问数据库,那么肯定会堵塞住,效率很低。

那么可不可以每个线程在访问数据库的时候,都去基于Mysql驱动来创建一个新的数据连接,然后执行完SQL语句后,再销毁呢?

不可以的,这样确实可以做到多个线程同时访问,但是上百个线程并发的频繁创建和销毁数据库连接,是很消耗资源的,且效率也不高。

这时候就需要用到数据库连接池,也就是在数据库连接池里维持多个数据库连接,让多个线程使用里面不同的数据库连接去执行SQL语句,然后执行完之后也不用销毁,而是放回到池子中,供其他线程来使用,这样一个数据库连接池的机制,可以解决多个线程并发去使用多个数据库连接的问题,还避免了数据库频繁创建销毁,消耗资源的问题。

那么对于Mysql来说,要并发连接多个请求,Mysql本身也要建立连接,那么Mysql也要维护与系统之间的多个连接,而且建立连接时,还要验证身份和权限等。

在这里插入图片描述

Mysql架构

1.网络连接

在数据库服务器的连接池中某个连接接收到了网络请求,这个时候是一个线程去进行处理,由一个线程来监听请求以及读取请求数据。

在这里插入图片描述
2.SQL接口

SQL语句是用来简单化我们去数据库进行数据增删改查的操作,如果是由我们自己去底层数据,亲子进行数据的增删改查,那么这就是一个极度复杂的任务,所以Mysql内部提供了一个组件,就是SQL接口来完成执行SQL语句。

在这里插入图片描述
3.查询解析器

比如来了一个SQL语句:select id,name,age from users where id=1

这时我们要对这个SQL语句进行解析,判断这条语句是用来做什么的,去哪里找,有什么条件等,这就需要查询解析器了

这个查询解析器将上面的SQL语句进行拆解:

  1. 我们要从users表里进行查询数据
  2. 查询id字段等于1的那行数据
  3. 对查出来的那行数据要提取里面的id,name,age三个字段

在这里插入图片描述

4.查询优化器

解析器理解了SQL语句要干什么,那么查询这个SQL有多个途径,比如我可以先将所有的数据找出来,然后再筛选出id等于1的,或者我先将所有字段且id等于1的数据查出来,然后再抽出需要的列等等。

查询优化器就是要选择最优的查询路径,提高查询效率。

在这里插入图片描述
5.调用存储引擎接口,执行真正的SQL语句

最后异步就是把查询优化器选择的最优查询路径,然后把这个计划交给底层的存储引擎去执行,然后从内存或者磁盘中访问和存放数据,存储引擎也分为很多种,InnoDB,MyISAM,Memorry等等,现在Mysql一般是使用InnoDB存储引擎的。

6.执行器

存储引擎可以帮助我们去访问内以及磁盘上的数据,那么用来调用存储引擎的接口就是执行器。

执行器会根据优化器选择的执行方案去调用存储引擎的接口按照一定的顺序和步骤,把SQL语句执行,比如去users表的第一行,判断id是否为1,如果不是,那么久继续调用存储引擎的接口,去获取users表的下一行。

基于上面的思路,执行器会根据我们的优化器生成的一套执行计划,然后不停的调用存储引擎的各种接口去完成SQL语句的执行计划。

在这里插入图片描述

InnoDB架构

1.缓冲池

InnoDB存储引擎种有一个非常重要的放在内存的组件,就是缓冲池,这里面会缓冲很多的数据,以便于以后在查询的时候,如果内存缓冲池里有数据,就可以不用去查磁盘了,提高查询速度。

在这里插入图片描述
2.undo log日志文件

每次在更新的时候,由于可能会发生事务回滚,那么就会将更新前的值保存在一个文件中,这个就是undo log日志文件。

在这里插入图片描述
4.更新buffer pool中缓冲数据

当我们把更新的那行记录从磁盘文件加载到缓冲池,同时对它加锁之后,还把更新前的值写入undo log日志文件之后,久可以正式更新这行记录,更新的时候,先是会更新缓冲池中的记录,此时这个数据叫做脏数据。

在这里插入图片描述
5.Redo Log

按照上面更新完Buffer Pool之后,还没更新磁盘文件,这时候为了防止内存里修改过的数据丢失,需要对内存所做的修改写入到一个Redo Log Buffer里,也是内存里的一个缓冲区,用来存放redo log日志的,里面记录对数据进行了什么修改,也是一个日志,是InnoDB独有的日志。

redo log是一种偏向物理性质的重做日志,记录的是类似“对哪个数据页中的什么记录,进行了什么样的修改”

事务提交之后,才算完成了一次SQL执行,现在还没有提交事务,所有的数据修改都放在了Buffer Pool中,同时Redo log Buffer里也有redo log的数据。

那么这时mysql宕机会出现问题吗,并不会,因为你没有提交事务,代表没有执行成功,虽然内存里的数据都丢失了,但是磁盘上的数据依然还保持原样。

那么重启之后,数据没有发生变化,那么就相当于没有执行这个事务,不会发生数据不一致的问题。

都正常完成,现在要提交事务了,那么我们会根据一定的策略把redo log从redo log buffer里刷入到磁盘,这个策略可以通过innodb_flush_log_at_trx_commit来配置:

当设置为0,那么你提交事务的时候,不会把redo log buffer里的数据刷入到磁盘文件,那么mysql宕机,redo log日志也相当于不见了。

设置为1,提交事务时候必须把redo log buffer刷入到磁盘的redo log中,只要提交事务成功,redo log必然在磁盘里。

设置为2,把么会把redo log buffer刷入到os cache内存缓存中,还是没有时机刷入到磁盘,然后过段时间后刷入到磁盘上,可能是1s,2s,500ms等等。

当设置为1的时候,只要事务提交了,redo log必然刷入到磁盘上,虽然Buffer Pool的数据还没有刷入到磁盘,还是脏数据,然后这时候mysql宕机,内存数据丢失,但是mysql可以通过redo log来查找对应修改的操作进行恢复,那么数据就不会丢失了。在这里插入图片描述
所以通常也是设置为1,这样可以保证数据绝对不会因为redo log而丢失,如果选择0,那么只要宕机,BufferPool的数据没刷回磁盘,就会丢失,如果是2,那么进入os cache之后如果宕机了,还是没有进入磁盘文件,还是可能回导致redo log丢失。

binlog

binlog叫做归档日志,里面记录的是偏向逻辑性的日志,类似于“对users表中的id=1的数据做了更新操作,更新后的值是什么”,binlog并不是InnoDB独有的,而是mysql server自己的日志文件。

那么我们在提交事务的时候,会把redo log日志写入磁盘文件中,而且还会把对应的binlog日志写入到磁盘文件中去。

对于binlog日志,其实也有不同的刷盘策略,有一个参数sync_binlog参数可以控制binlog的刷盘策略:

默认值为0,这时候binlog并不是直接进入磁盘文件,而是os cache内存缓存中,那么还是会有丢失的风险。

设置为1的话,会强制在提交事务的时候,把binlog直接写入到磁盘文件中去,那么哪怕机器宕机,磁盘上的binlog也不会丢失。

在这里插入图片描述

基于redo log和binlog的两阶段提交

那么提交事务时,redo log和binlog日志的顺序为:

1.将redo log刷入磁盘,并将状态改成prepare状态
2.binlog日志写入磁盘
3.将binlog日志文件名和binlog日志在文件里的位置写入到redo log中,同时将redo log状态改为commit状态

那么这样的写入就可以防止任何一个阶段导致的数据不一致问题:

如果redo log刷完磁盘,mysql宕机,或者binlog刷入到磁盘,mysql宕机,那么由于不是commit状态,无法找到对应的binlog位置,就代表事务提交失败,只有commit状态的时候表示redo log中有更新对应的binlog日志,redo log和binlog数据是一致的。

在这里插入图片描述

后台IO线程随机将脏数据刷回磁盘

Mysql会有一个后台的IO线程,会在某个时间里,随机把内存buffer pool中的修改后的数据刷回到磁盘上的数据文件中,如果刷回之前崩溃,就用redo log来恢复之前提交事务做过的修改到内存中去,然后等待适当时机,IO线程自然会把这个脏数据刷回磁盘。

在这里插入图片描述

Buffer Pool

由于对数据库执行增删改查的时候,直接更新磁盘上的数据是进行随机读写操作,那速度是很慢的,所以大部分主要都是针对内存里的Buffer Pool中的数据进行的。

Buffer Pool内存数据结构

由于是内存数据结构,肯定是有一定的大小,不可能超过内存大小的,Buffer Pool默认情况下是128MB,还是偏小的一些,我们可以通过配置innodb_buffer_pool_size来调整大小,如果16核的,差不多可以分配个2GB内存左右,innodb_buffer_pool_size = 2147483648

我们的数据是一页一页放在Mysql中,这就是数据页的概念,然后把很多行数据放在一个数据页里,那我们要更新一行数据,此时数据库会找到这行数据所在的数据也,然后从磁盘文件里把这行数据所在的数据页直接给加载到bufer pool里去。

默认情况下,磁盘存放的数据页大小是16KB,而我们Buffer Pool存放的数据页通常叫缓存页,毕竟Buffer Pool是一个缓冲池,里面的数据都是从磁盘缓存到内存去的。

而且Buffer Pool里缓存页大小和磁盘上的一个数据页大小是一一对应起来的,都是16KB。

除此之外,每个缓存页都有对应的描述信息,可以认为是用来描述这个缓存页的,比如这个数据页所属的表空间,数据页的编号,以及缓存页在Buffer Pool中的地址等等,每个缓存页的描述数据放在最前面,然后各个缓存页放在后面。

所以如果buffer pool大小为128MB,实际上Buffer Pool真正大小会超出一些,可能有130MB多,因为要存放描述信息,大概相当于缓存页大小的5%左右。

在这里插入图片描述

free链表

从磁盘上读取数据放入Buffer Pool缓存页的时候,怎么知道哪些缓存页是空闲的呢,为了知道哪些缓存页是空闲的状态,数据库为Buffer Pool设计了一个free链表,只要你缓存页是空闲的,那么它描述数据块就会被放入到这个free链表中了。

这个free链表每个节点都会双向链接自己的前后节点,组成一个双向链表,除此之外,还有一个基础节点,会引用链表的头节点和尾节点,还存储了链表中有多少个空闲的缓存页。

那我们从磁盘上把数据页读取到Buffer Pool中的缓存页里去的步骤为:

先从free链表里获取一个描述数据块,然后可以对应的获取描述数据块对应的空闲缓存页。
接着把磁盘上的数据页读取到对应的缓存页里
最后把这个描述数据块从free链表里去除就可以了

那么如何知道对应的数据页有没有被缓存,数据库还会有一个哈希表数据结构,会用表空间号 + 数据页号,作为一个key,然后缓存页的地址作为value,也就是每次数据页缓存之后,都会在这个哈希表中写一个key-value对,下次如果再使用这个数据页,就可以直接从哈希表里弥漫读取出来已经被放入一个缓存页了。

在这里插入图片描述

flush链表

由于我们更新Buffer Pool的时候,肯定还没有写入到磁盘中,这时候Buffer Pool的数据就是脏数据,那么对应的缓存页就是脏页。

为了判断哪些是脏页,需要刷回到磁盘,就需要和free链表一样的,这就是flush链表,和free链表一样,通过缓存页的描述数据库中的两个指针,让被修改过的缓存页的描述数据块组成一个双向链表,但凡被改过的缓存页都会被加入到flush链表中,这些后续都需要flush刷新到磁盘上的。

那么当刷新回磁盘后,就会从flush链表中去除。

在这里插入图片描述

LRU链表

如果当你不停的从磁盘上将数据页加载到Buffer Pool上的空闲缓存页的时候,free链表里的空闲缓存页就会越来越少,那么总会用完所有的空闲缓存页,这时候就要去缓存页中淘汰一些缓存页来使新的数据页加载到缓存页里。

那么淘汰掉哪些缓存页呢,以什么为标准,这就要引入一个缓存命中率的概念。假如100次请求中,有50次都在查询和修改这个缓存页里的数据,那么缓存命中率就很高了,相比之下,100次请求,只有一两次是修改和查询这个缓存页的数据,那么缓存命中率就很低了,大部分还会走磁盘查询数据。

那么为了使经常访问的缓存页继续保留,不经常使用的缓存页进行淘汰,就需要一个LRU链表了,LRU代表Least Recently Used,也就是最近最少使用的意思。

这个链表的原理是:我们加载一个数据页到缓存页的时候,就把这个缓存页的描述数据块放到LRU链表头部,只要是有数据的缓存页都在LRU里,而且最近被加载数据的缓存页都会放到LRU链表的头部。

那么链表尾部的缓存页就是最少访问的,那么就会把尾部的缓存页刷入磁盘中,然后把需要的磁盘数据页加载到这个缓存页中就可以了。

由于后台会有一个线程,运行定时任务,每隔一段时间就把LRU链表的冷数据区域的尾部的一些缓存页刷入到磁盘,清空这几个缓存页,放入到free链表中。

那么对于Buffer Pool整个流程为:

Mysql在不怎么繁忙的时候,会找个时间把flush链表中的缓存页都刷入磁盘,这样修改过的数据,迟早会刷入磁盘,那么这些缓存页也会从flush链表和LRU链表中移除,然后加入到free链表中,这就是动态运行起来的效果

如果free链表都被使用了,那么就会将LRU链表的冷数据区域的尾部找到一批缓存页,因为是最不常使用的缓存页,就会被刷入磁盘和清空,然后数据页加载到这个腾出来的空闲缓存页里去。

简单的LRU链表可能导致的问题

由于Mysql的预读机制,这个预读机制,就是当你从磁盘上加载一个数据页的时候,可能会把这个数据页相邻的其他页也都加载到缓存页里。

那么可能相邻的数据页并没有被访问到,但是通过Mysql预读机制顺利的和读取的缓存页到达LRU链表的头部,那么后面两个之前加载的缓存页就会被优先淘汰掉,这并不合理,因为相邻的缓存页根本没被用到。

在这里插入图片描述

触发Mysql预读机制为:

1.参数innodb_read_ahead_threshold,默认值56,也就是如果顺序访问超过一个区里的56个数据页,那么就会把下一个相邻区中的所有数据页加载到缓存里去。

2.如果Buffer Pool里缓存了一个区连续13个数据页,而且这些数据页都比较频繁访问,那么就会触发预读机制,把这个区其他数据页都加载到缓存里去,这个是通过innodb_random_read_ahead来控制,默认是OFF

所以上述情况下,第一个规则更可能会触发预读机制,一下子多了相邻区的数据页,且全部放到LRU头部,无疑是不合理的。

还有就是如果全表扫描的情况,之后也不继续访问了,结果之前一直访问的缓存页就排在了后面,优先被淘汰,这也是不合理的。

那么为什么要有Mysql预读机制,为了提高性能,因为如果你读取了大部分数据页,那么很可能你还要接着顺序读取相邻的数据页,那么如果没有这个机制,就要再次发起一次磁盘IO,所以为了优化性能,才设计了预读机制。

冷热数据分离,优化LRU

真正Mysql在设计LRU链表的时候,采用的是冷热数据分离的思想,并不是都混在一个LRU链表里。

真正的LRU链表会被拆分成两个部分,一部分是热数据,一部分是冷数据,冷热数据的比例是由参数innodb_old_blocks_pct参数控制的,默认是37,也就是冷数据占比37%。

在这里插入图片描述

那么两个区域使用规则为:

首先数据页第一次被加载到缓存的时候,会放在冷数据区的链表头部。

根据innodb_old_blocks_time参数,默认是1000,也就是1000ms,1s
也就是1s之后,你访问这个数据页的时候才会被移到热数据区域的链表头部,因为Mysql认为,你过了这个时间后还会访问这个数据页,可能以后再次访问的几率很高。

那么之前的预读机制以及全表扫描进来的一大堆缓存页都会放在冷数据链表的头部,如果不在一定时间之后访问,那么就会判定他们不是经常访问的数据,就会慢慢被移到冷数据的尾部,最后被淘汰掉。

因为热数据区域本身就是经常被访问的缓存页,所以没有必要频繁的移动,所以说热数据区域的访问规则被优化了一下,只有在热数据区域的后四分之三部分的缓存页被访问,才会放到链表头部,如果已经在链表的前四分之一内,那么就不会移动 ,这样就可以尽可能的减少链表的节点移动了。

Mysql物理数据模型

数据页

为什么Mysql要引入数据页的概念,如果是每次修改一条数据,就要去磁盘里加载到缓存中,下一次修改其他行的时候,再去磁盘加载到缓存,这样的效率未免有点太低,所以引入数据页的概念,每次加载磁盘的数据的时候,将这个数据所在的数据页都加载到缓存,这样之后,修改和读取相关数据的时候,可能就不需要再去磁盘读取,而是从这个数据页中直接读取到。

包括刷回磁盘的时候,也是数据页进行刷回,减少刷回次数,一次性刷回整页的数据。

数据页结构

其实数据页并不是16KB全都是存放大量的数据行的,而是分为很多个部分:文件头,数据页头,最小记录和最大记录,多个数据行,空闲空间,数据页目录,文件尾部

在这里插入图片描述
其中文件头部占据38个字节,数据页头占据56个字节,最大记录和最小记录占据了26个字节,数据行区域,空闲区域,还有数据页目录是不固定的,文件尾部占据8个字节。

那么我们一开始新的数据页是没有数据的,然后往里插入数据,那么空闲区域就会相应减少,数据行区域增加一行数据,然后随着增加到满了,那么空闲区域也就没了

在这里插入图片描述

数据存储格式

我们的一行数据在磁盘上存储的时候,不仅仅包含的是那一些数据,还包括其他的信息:变长字段长度列表 NULL值列表 头信息 column1=value1 column2=value2 … columnN=valueN

那么除了字段的值外,额外的信息就是用来描述这一行数据的。

变长字段的长度列表

由于Mysql里一些字段的长度是可变的,并不是固定长度,比如varchar(10),那么它就可以在10个长度以内任意长度都可以。

由于落地到磁盘的时候,数据都是一大坨放在一个磁盘文件里,且都挨着的,那么读取的时候就比较难以读取哪些是一行,哪些是可变的数据,所以我们存储每一行的时候,都保存一下它的变长字段的长度列表,比如 hello a a,这个hello是varchar(10)类型的变长字段的值,那么hello实际长度是5,十六进制为0×05,此时,变长字段的长度列表,就会存储这个额外信息,那么这行的格式为:0x05 null值列表 数据头 hello a a。

如果这个时候还有一行数据可能是:0x02 null值列表 数据头 hi a a,两行数据放在一起存储在磁盘文件里,就是:

0x05 null值列表 数据头 hello a a 0x02 null值列表 数据头 hi a a

那么如果说有多个变长字段,比如一行数据有VARCHAR(10) VARCHAR(5) VARCHAR(20) CHAR(1) CHAR(1),一共5个字段,其中三个是变长字段,此时假设一行数据是这样的:hello hi hao a a

此时磁盘中存储的,必须在开头的变长字段长度列表中存储几个变长字段的长度,但是这里是逆序存储的。也就是先存放20长度的,然后5,然后10的,那么这行数据实际存储可能为:

0x03 0x02 0x05 null值列表 头字段 hello hi hao a a

NULL值列表

如果Null值给个字符串NULL,这种方式存储在磁盘上就很浪费空间的,因为它本身没有值,那我们只要判断它有没有值就可以了,所以NULL值是以二进制bit位来存储的,假设一行数据里有多个字段的值,都可以为null,是可以为空,而不是值就是空,就会放入到NULL值列表的,1代表是NULL,0代表不是NULL,且按照逆序存放,且起码是8个bit位的倍数,因为要以byte为单位存储,如果不足8个bit,就高位补0。

比如:“jack NULL m NULL xx_school”,其中jack是 not null字段,那么剩下的四个字段2个为null,2个不是null,那么4个bit就是1010,但由于是逆着放的,那么就应该是0101,且不足8个bit,那么高位补0,实际存放是:

0x09 0x04 00000101 头信息 column1=value1 column2=value2 … columnN=valueN

那么结合变长字段长度列表,我们可以先判断哪些字段为NULL,哪些不是NULL,然后不是NULL的字段在变长字段长度列表中一一对应,然后进行读取,这样就可以完美的把一行数据的值读取出来了。

数据头

数据头是40个bit位,用来描述这行数据的:

首先第1位和第2位bit都是预留位,没有什么意义。

第3位是delete_mask,标识的是这行数据是否被删除了,其实Mysql在删除一行数据的时候,未必是立马把他从磁盘上清理掉,而是用一个标记为来标识是否已经被删除。

第4位是min_rec_mask,这个代表是B+数里每一层的非叶子节点里最小值都有这个标记

第5位~第8位是n_owned,这个是记录了一个记录数。

第9位~第21位是heap_no,代表的是这行数据在记录堆里的位置

第22位~第24位是recored_type,标识这行数据的类型,0代表普通类型,1代表B+数非叶子节点,2代表最小值数据,3代表最大值数据

第25~第40位是next_record,指向下一条数据的指针。

总结

那么对应“jack NULL m NULL xx_school”,它真实存储大致是:

0x09 0x04 00000101 0000000000000000000010000000000000011001 jack m xx_school

但是实际上字符串这些东西是根据我们数据库指定的字符集编码,进行编码后再存储的,那么实际存储为:

0x09 0x04 00000101 0000000000000000000010000000000000011001 616161 636320 6262626262

除了这些以外,其实还有一些隐藏字段:

DB_ROW_ID,这是这一个行的唯一标识,是数据库内部给你搞的一个标识,并不是主键ID字段,如果没有指定主键的时候,回自动加一个ROW_ID作为主键。

DB_TRX_ID,这是与事务相关的,是事务ID

DB_ROLL_PTR,回滚指针,用来进行事务回滚

那么实际一行数据应该是:

0x09 0x04 00000101 0000000000000000000010000000000000011001 00000000094C(DB_ROW_ID)00000000032D(DB_TRX_ID) EA000010078E(DB_ROL_PTR) 616161 636320 6262626262

括号代表前面的意义。

但是有可能某个字段很长,比如TEXT这种数据结构,那么很可能就会超过数据页16kb的大小,那么这个时候,实际上会在那一页里存储你这行数据的一部分,然后同时20个字节的指针指向其他的一些数据页,哪些数据页用链表串联起来,存放这个超大的数据。

在这里插入图片描述
那么在Buffer Pool从磁盘上会读取这些多个数据页来加载到缓存行里。

表空间

表空间其实就是我们平时创建的那些表或者系统表在物理层面的磁盘上的数据文件。

比如磁盘上都会对应着“表明.ibd”这样的一个磁盘数据文件,系统表空间可能对应多个磁盘文件,然后表空间的磁盘文件里,会有很多的数据页,但是表空间里的数据页太多了,所以为了便于管理,在表空间里引入了一个数据区(extent)的概念。

一个数据区对应着连续的64个数据页,每个数据页是16KB,那么一个数据区是1MB,然后256个数据区被划分为一组,对于表空间而言,它的第一组数据区的第一个数据区的前三个数据页,都是固定的,里面存放了一些描述性的数据:

比如FSP_HRD这个数据页,存放的是一组数据页的所有insert buffer一些信息

INODE数据页,存放了一些特殊的信息

然后表空间里的其他数据区,每一组数据区的第一个数据区的头两个数据页都是放特殊信息的,比如XDES数据页用来存放这一组数据区相关属性,其实就是描述这组数据区的东西。

当我们执行crud操作的时候,就是从磁盘上的表空间的数据文件,去加载一些数据页到Buffer Pool的缓存页去使用。

在这里插入图片描述

redo log

redo log和缓存页刷入磁盘,都是写磁盘,但是差别就在于,缓存页刷入磁盘是随机写,而redo log写入磁盘是顺序写,也就是每次都是追加到磁盘文件末尾,速度要比随机写快很多,所以用redo log的形式记录下来修改,性能会远远超过刷缓存页的方式,这样可以让数据库的并发能力更强大。

redo log里具体记录的是:表空间号,数据页号,偏移量,修改几个字节的值,具体的值

也就是会根据你修改的数据页里几个字节的值对应划分不同的类型,比如MLOG_1BYTE类型的日志就是指修改了1个字节的值,MLOG_2BYTE就是修改了2个字节的值,依此类推,如果你一下子修改了一大串的值,那么类型就是MLOG_WRITE_STRING,标识一下子在数据页的某个偏移量的位置插入或者修改了一大串的值,这时候除了上面的记录的东西以外,还会有修改数据长度的记录,那么redo log格式为:

日志类型(就是类似MLOG_1BYTE之类的),表空间ID,数据页号,数据页中的偏移量,修改数据长度,具体修改的数据

redo log block

redo log内部也不是直接简单粗暴的写入,而是通过redo log block的结构来存放多个单行日志的,一个redo log block是512字节,redo log block分为3个部分,一个是12字节的header快头,一个是496字节的body块体,一个是4字节的trailer块尾。

在这里插入图片描述
header头又分为了四个部分:

1).包括4个字节的block no,就是块唯一编号
2).2个字节的data length ,就是block里写入了多少字节数据
3).2个字节的first record group,每个事务都会有多个redo log,是一个redo log group,即一组redo log,那么在这个block里的第一组redo log的偏移量就是这2个字节来存储的。
4).4个字节的checkpoint on

在这里插入图片描述
那么我们从内存中写入redo log的时候,会将redo log放入到redo log block数据结构中,然后等待内存里的一个redo log block的512字节都满了,再一次性把这个redo log block写入磁盘,那么磁盘里的redo log文件里就多了一个block。

在这里插入图片描述

redo log buffer

redo log buffer是在Mysql启动的时候,就跟操作系统申请的一块连续内存空间,然后i面划分出N多个空的redo log block,通过设置mysql的innodb_log_buffer_size,可以指定这个redo log buffer的大小,默认是16MB,已经挺大了,已经一个redo log block也就512KB,每一条redo log也就几个字节到几十个字节。

当你要写入一条redo log的时候,会先从redo log buffer的第一个redo log block开始写入,写满了一个redo log block 之后,继续写下一个block,直到所有的redo log block写满位置。

平时我们执行一个事务的过程,是有多个增删改的操作,那么就会有多个redo log,这多个redo log就是一组redo log,每次一组redo log都是在别的地方暂存,然后都执行完之后,再把redo log写入到redo log buffer的block里去,如果redo log太多,可能就要存放到两个redo log block里,反之,一个redo log group比较小,那么也可能多个redo log group都在一个redo log block里。在这里插入图片描述
那么什么时候要从redo log buffer刷入到磁盘文件redo log日志文件,有以下几种情况:

1.如果redo log buffer的日志已经占据了redo log buffer总容量的一半,也就是超过8MB的redo log在缓冲里,此时就会把它们刷入到磁盘文件里。

2.如果一个事务提交,那么就必须把它的那些redo log所在的redo log block都刷入到磁盘文件里去,只有这样,当事务提交之后,修改的数据才不会丢失。

3.后台线程定时刷新,有一个后台线程每隔1s,就会把redo log buffer里的redo log block刷到磁盘文件里去

4.Mysql关闭的时候,redo log block都会刷入到磁盘里去

第一种情况发生在Mysql承载高并发请求的时候,比如每秒执行上万个增删改SQL语句,每隔SQL产生的redo log假设有几百个字节,那么此时会瞬间生成超过8MB的redo log日志,必然会触发立马刷新到磁盘上。

第二种情况则是提交事务的时候,一半都是在几十毫秒到几百毫秒执行完毕,那么就会把这个事务的redo log都刷入磁盘中。

在这里插入图片描述
redo log都会写入一个目录中的文件里,这个目录可以通过show variables like 'datadir’来查看,可以通过innodb_log_group_home_dir参数来设置这个目录的。

然后redo log是有多个的,写满一个就会写下一个redo log,而且可以限制redo log文件的数量,通过innodb_log_file_size可以指定每隔redo log文件的大小,默认是48MB,通过innodb_log_files_in_group可以指定日志文件的数量,默认就2个。

两个96MB的redo log一般已经足够用了,可以存储上百万条redo log了,这时候如果写满了,那么就会继续写第一个,覆盖第一个日志文件里的原来的redo log,所以redo log本身是不具备长时间的持久化,因为会被覆盖,所以恢复数据的时候用bin log来进行恢复。

undo log

如果一个事务里的增删改执行到一半,结果就回滚事务了,但是Buffer Pool里的数据已经更改一半了,那么为了能恢复原来的数据,就需要undo log这个日志来恢复回滚事务的数据。

如果你执行了一个insert语句,那么此时在undo log日志里,就会对这个操作记录的回滚日志就必须有一个主键和对应的delete操作,能让insert操作给回退。

如果执行的是delete,那么就需要有insert操作把这条数据插入回去。

如果执行的是update语句,那么就需要把更新之前的那个值记录下来,回滚时候重新update以下,把旧值更新回去。

在这里插入图片描述那么INSERT语句的undo log日志里面包含:

这条日志的开始位置
主键的各列长度和值
表id
undo log日志编号
undo log日志类型
这条日志的结束位置

1.日志的开始位置的结束位置就是这条undo log所在的位置

2.主键的各列长度和值,由于主键有可能是联合主键,也有可能没有主键,用row_id隐藏字段,作为主键,所以就需要直到这个主键的长度为多少,具体的值是什么才能进行修改

3.表id就是插入哪一个表

4.undo log日志编号,每个undo log日志都有自己的编号,在一个事务里有多少个SQL语句,就会有多少个undo log日志,在每个事务里的undo log日志的编号都是从0开始的,然后依次递增

5.undo log类型,就是TRX_UND_INSERT_REC,带包insert语句的undo log日志类型

事务

一个事务就是要么一起成功都提交,要么有一个SQL失败,就事务回滚,所有SQL修改都撤销。

但是业务系统并不是一个单线程系统,是多线程同时并发访问的,每个SQL语句就是之前的那一套原理,如果提交了,那么redo log刷盘,然后redo log里记录事务提交标识之类的,如果宕机,就从redo log中恢复事务修改过的缓存数据,如果回滚,就把缓存页做的修改都回滚就可以了。

但是多个事务并发执行的时候,可能会同时对缓存页的一行数据进行更新,可能还有其他事务在查询这行数据,那么为了解决这些冲突问题,就需要事务的其他机制,比如事务隔离MVCC,锁机制等等。

如果多个事务对缓存页里的同一条数据同时进行更新的问题,就会发生四种情况:脏写,脏读,不可重复读,幻读。

脏读和脏写

脏写就是自己更新的值,结果却莫名其妙没了,比如:

事务A更新一条数据,会记录一条undo log,更新前的值为NULL,然后事务B更新了事务A更新之后的值,那么最后这行数据的值应该是B,但是事务A突然回滚了,那么就会用它的undo log进行回滚,结果更新回之前的NULL值,事务B发现自己更新的值变为NULL了,这就是脏写。

也就是事务B去修改事务A修改过的值,但是此时事务A还没有提交,事务A随时可能回滚,导致事务B修改的值页没了,这就是脏写的定义。

脏读就是再次查询相同行数据的值时,发现值是不一样的,比如:

事务A更新了一行数据为A值,然后事务B去查询了这行数据的值,此时是A,然后事务A突然回滚了,事务B再次查询的时候发现是NULL。这就是脏读。

无论是脏写还是脏读,都是因为一个事务去更新或者查询了另外一个还没提交的事务更新过的数据。

不可重复读

如果一个事务只能读到其他事务提交之后的数据,那么就不会发生脏读,但是会发生不可重复读。

假设事务A没有提交,读到的值是A,但是事务B修改之后提交成B,然后事务A第二次查询时候就为B,事务C提交成C,事务A再次查询时候就是C。

在这里插入图片描述
如果你希望事务A读到的值是可重复读的,也就是事务A自己读到的值不发生变化,那么这种发生不可重复读的现象就是一种问题了。

不可重复度,就是一条数据的值没办法满足多次重复读值都一样,别的事务修改后提交,就不可重复读了。

幻读

幻读指的就是你一个事务用一样的SQL多次查询,结果每次查询都会发现查到了值卡没看到过的数据。特指的是之前查询没看到过的数据。

比如执行select * from table where id > 10,查询出来10条数据,结果事务B往里面插入了2条数据,并且提交,你第二次查询时候,查出来12条数据,一模一样的SQL查出来没有看到过的数据,就像中了幻觉一样,这就是幻读。

在这里插入图片描述

隔离级别

SQL标准中规定了四种隔离级别,包括:read uncommitted(读未提交),read committed(读已提交),repeatable read(可重复读),serializable(串行化)

1.read uncommitted级别

不允许发生脏写的,也就是不可能两个事物在没提交的情况下去更新同一行数据的值,但是在这种隔离级别下,可能发生脏读,不可重复读,幻读。

因为可以读到其他事务没有提交的数据,那么肯定会发生脏读,更别说其他的情况了,所以一般来说,没有人做系统开发的时候,会把事务隔离级别设置为读未提交这个级别。

2.read committed隔离级别

这个级别下,不会发生脏写和脏读,也就是说其他事务提交后,你才可以看到修改的值,那么就会发生不可重复读和幻读的问题,简写为RC,但别的事务没有提交的时候,绝对不会读到人家修改的值。

3.repeatable read隔离级别

就是可重复度,这个隔离级别下,不会发生脏写,脏读,不可重复读的问题,因为哪怕别的事务修改提交了,也只会读到同一个值,简写RR,但是还会发生幻读,因为RR隔离级别,只不过保证对同一行数据的多次查询,不会读到不一样的值,但是不是同一行的就还是会查到,那么就会发生幻读。

4.serializable级别

这种级别,根本不允许多个事务并发执行,只能串行执行,所以不可能会有幻读,那么别的情况也不会发生,一般不会设置这种级别,那么1s并发可能也就几十个,性能是很差的。

在Mysql也是支持这四种隔离级别的,且默认隔离级别是RR,还可以避免幻读的发生,这是因为MVCC的控制。

Mysql的事务隔离级别,可以设置为不同的level:

SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;

但是一般来说,不用修改这个级别,默认的RR就特别好,但是有的业务也会有需要RC的情况。

Spring里通过@Transactional注解来做事务这块,通过isolation来更改事务隔离级别。

MVCC

MVCC,多版本并发控制

undo log版本链

每条数据都有两个隐藏字段,一个是trx_id,一个是roll_pointer,这个trx_id就是最近一次更新这条数据的事务id,roll_pointer就是指向你更新这个事务之前生成的undo log。

在这里插入图片描述

ReadView机制

ReadView,就是你执行一个事务的时候,会给你生成一个ReadView视图,这里面有4个比较关键的东西:

1.m_ids,这个就是说此时有哪些事务在Mysql里还没有提交的
2.min_trx_id,这是m_ids里最小的值
3.max_trx_id,这是mysql下一个要生成的事务id,就是最大事务id
4.creator_trx_id,就是你这个事务的id

例子:

现在有一行数据,事务id是32

接着两个事务比你高法过来,一个是事务A,id为45,一个是事务B,id为59,事务B是要更新这行数据,事务A是读取这行数据的值。

现在事务A开启一个ReadView,那么这个ReadView里的m_ids就包含了事务A和事务B的两个id,45和59,然后min_trx_id就是45,max_trx_id就是60,creator_trx_id就是45,也就是事务A自己。

那么事务A第一次查询这行数据,会判断一下当前这行数据的trx_id是否小于ReadView中的min_trx_id,发现32小于45,那么就代表开启事务之前,修改这行数据的事务早就提交了。

在这里插入图片描述

接着事务B修改为值B,那么这行数据的trx_id也为自己的id,也就是59,同时roll_pointer指向了修改之前生成的一个undo log,然后事务B提交了。

事务A再次查询的时候,发现trx_id为59,大于min_trx_id,同时小于max_trx_id,那么说明修改这条数据的事务,就是自己开启事务的时候还存在的事务,那么就会检查下是否在m_ids列表中,结果是,那么修改数据的事务就是跟自己并发执行然后提交的,那么这行数据是不能查询的。

然后就会顺着roll pointer往下查找,找到最近的一条undo log,接着和自己的min_trx_id判断,那么trx_id为32,小于min_trx_id,是可以查询到的。

在这里插入图片描述

如果自己修改的,那么自己肯定是可以看到的,这时候来个事务C,事务id为78,然后进行更改,提交,事务A查询的时候,发现大于max_trx_id,说明这条数据是被创建ReadView之后的事务进行修改并提交的,那么就不能够查询,接着会顺着roll pointer来往下继续找,下一个undo log是自己修改的,那么这个版本是可以被查看的,就会查询这个版本。

在这里插入图片描述

RC级别下ReadView实现

RC隔离级别,代表别的事务修改数据还提交了,就可以读到人家修改的数据的,那么就会发生不可重复读和幻读的问题。

RC隔离级别的核心在于,每次发起查询的时候,都会重新生成一个ReadView。

例子:

首先假设有一行数据,事务id为50,现在有一个事务A,id为60,事务B,id为70。事务B发起一次更新操作,将这个值修改为了B。

那么A发起一次查询操作,就会生成一个ReadView,此时min_trx_id = 60,max_trx_id = 71,reator_trx_id = 60,但是这条数据的trx_id为70,也就是在ReadView事务id范围之内修改,由于事务B还没有提交,那么m_ids活跃事务列表里还有70这个事务id,那么事务A是无法查看事务B修改的值的。

接着就会根据roll_pointer往下找,发现trx_id为50,那么就可以查到这个值。
在这里插入图片描述

接着事务B进行提交,那么根据RC隔离级别,事务B一旦提交事务A下次再查询,就可以读到事务B修改过的值了,那么事务A下次再发起查询时,会再次生成一个ReadView,那么新的ReadView的m_ids只有60这个活跃事务了,那么这条trx_id为70,在min_trx_id和max_trx_id范围之间,但是不在m_ids列表内,说明事务B已经提交了,那么就可以查询到这个事务B修改过的值了。

在这里插入图片描述

RR隔离级别基于ReadView实现

例子:

首先还是有一条数据,事务id为50,然后有事务A,id为60,事务B,id为70,然后事务A发起一个查询,第一次查询会生成一个ReadView,此时ReadView如图所示:

在这里插入图片描述
然后事务B修改为B,同时生成undo log,然后事务B还提交了,说明此时事务B已经结束了。

但是由于RR隔离级别下,ReadView一旦生成就不会改变,所以事务A的ReadView里还是会有60,70这两个事务id。

接着事务A去查询这条数据的值,会发现这行数据的trx_id为70,且在min_trx_id和max_trx_id的范围之间,且在m_ids列表中,那么说明事务A开启的时候,事务B还是在运行的,然后事务B更新了这条数据,所以事务A是不能查询到事务B更新的这个值,那么就会顺着roll_pointer来查找上一个undo log版本,结果找到trx_id为50,那么事务A是可以查询到这个值的。

在这里插入图片描述

如果多个事务对同一行数据进行更新操作,那么就需要用到锁来保证每次只有一个事务可以更新,要不然就会出现脏写的现象了。

一个事务更新一行数据,首先看这行数据有没有被加锁,如果没有,说明这个事务是第一个到达的,那么它就会对这行数据进行加锁,trx_id设为自己的事务id,等待状态为fasle。

在这里插入图片描述

之后有另一个事务B过来了,也要更新这一行,那么就要检查一下,发现已经被加锁了,那么事务B也进行加锁,但是需要排队等待前面执行完解锁。

在这里插入图片描述

事务A执行完之后,就会释放自己的锁,然后去查找是否别人也进行加锁了,那么就会唤醒其他加锁的事务,并把等待状态修改为false,那么事务B被唤醒,得到锁,进行修改。

在这里插入图片描述

独占锁和共享锁

独占锁就是这个事务对这行数据进行更新的时候,会加一个独占锁,表示这行数据更新是由我这个事务独占,其他事务更新时候也会加独占锁,但是只能排队等待在前面独占锁的后面。

共享锁是可以共享进行操作的锁,比如你select * from table lock in share mode,lock in share mode就是共享锁,虽然共享锁之间不是互斥的,但是独占锁和独占锁,或者独占锁和共享锁是互斥的,都要在后面进行等待。

查询的时候也可以通过加for update来加独占锁,但是一般开发的时候很少会主动加共享锁,反而会基于redis/zookeeper的分布式锁来控制业务系统的锁逻辑。

表级锁

除了行锁之外,还有对整个表进行加锁,比如在执行DDL语句的时候,会和增删改操作互斥。

表锁分为两种:一种是表锁,另一种是表级的意向锁。

表锁可以用下面的语法来加:

LOCK TABLES xxx READ:这是加表级共享锁

LOCK TABLES xxx WRITE:这是加表级独占锁

一般来讲,几乎没人会用这两个语法去加表锁。。

另一个意向锁的话:

在表里执行增删改操作,会加上独占锁,同时还会加一个表级意向独占锁

在表里进行查询操作,就会加表级意向共享锁。

锁之间的互斥关系为:

S

也就是更新数据加的意向独占锁,会跟表锁是互斥的,意向共享锁会跟表级独占锁是互斥的。

但是一般来说不会手动加表级锁,只有很少部分DDL的时候会加,所以一般来说,读写操作自动的表级意向锁,相互之间是不会互斥的。

所以行级独占锁都是互斥的,但是读操作都是不互斥的,因为读操作默认走mvcc机制读快照版本

索引

数据页物理存储结构

大量的数据页是按顺序一页一页存放的,且数据页之间会采用双向链表的格式相互引用,然后数据页内部会存储一行一行的数据,按照主键大小顺序进行排序,且每一行数据指向下一行数据的位置,组成单向链表。

每个数据页里面都会有一个页目录,里面根据数据行的主键存放了目录,同时数据行是被分散存储到不同的槽位里面去。

在这里插入图片描述
假设要根据主键查找一条时速局,那么就很到第一个数据页通过二分查找在目录里定位到主键对应的数据在哪个槽位里,然后到那个槽位离去,就能快速找到那个主键对应的数据了。

如果不是按照主键查找,那么就要从数据页内部的数据间的单向链表来遍历查找了。

如果找不到,就要找下一个数据页的缓存页,以此类推,那么上述操作,其实说白了就是全表扫描的过程,性能是非常低的,随着数据量越多,会变得越来越慢。

页分裂

数据页里面的一行一行的数据,刚开始是一行起始页,行类型为2,然后指针指向了下一行数据,每一行数据都有自己每个字段的值,然后每一行通过一个指针不停的指向下一行数据,普通的数据行类型都是0,最后一行是一个类型为3的,代表最大的一行。

但是索引运作的一个核心基础就是要求你后一个数据页的主键值大于前面一个数据页的主键值,如果是主键自增还可以,但是如果并不是自增,而是手动插入的,那么可能会出现后一个数据页的主键值,有的主键小于前一个数据页的主键值。

这时候就会出现页分裂,也就是将前一个数据页里主键值较大的,挪动到后一个数据页里,然后将后一个数据页较小的值,挪到前一个数据页中去。

比如有如图所示的两个数据页,后一个的数据页里面的数据比前面数据页的数据小,那么就要通过页分裂来挪动。
在这里插入图片描述

最后将后一个数据页的2,3的数据挪到前面,前面数据页的5,6挪到后面。

在这里插入图片描述

索引页

由于数据页的数量增多,我们不可能每次都从第一个数据页开始按照主键id二分查找,所以就需要有一个管理数据页的一个目录,里面存放了这个数据页的最小主键id和它自己的页号,并且这里也是按照最小主键id顺序排列的,那么我们可以通过主键id来对比它的最小主键id,如果大于这个最小主键id,并且小于下一个页的最小主键id,那么说明这行数据就在这个数据页中。

在这里插入图片描述

但是数据页越来越多,几百万,几千万,甚至亿级别的数据。那么就有大量的数据页,那么就不能存放在一个目录里了,所以这时候就有了索引页,那么索引页里就会存放一部分的数据页的最小主键id和页号,然后与数据页里面的排序一样,指针引用这下一行的数据页数据。

在这里插入图片描述

但是有很多个索引页,你需要知道在哪个索引页里去找主键数据,那么就需要把索引页多加一个层级出来,在更高的索引层级里,保存了每个索引页号和索引页里的最小主键id。更高层里的存放的索引页数据也是按照最小主键id顺序排序的,也是可以二分查找的

在这里插入图片描述
如果顶层的索引页里存放不下更多的下层索引页数据,那么就需要再次分裂,更加一层索引页,再存放下层索引页的数据。

在这里插入图片描述
这种层级关系,且每个层级内的顺序排序,构成了一颗B+树,当主键建立起来索引之后,这个主键的索引就是一颗B+树,然后你根据主键来查询数据的时候,直接就是从B+树的顶层开始二分查找,找到下层的索引页,再根据这里的索引页数据再次按照主键二分查找,一层一层往下定位,最终找到一个数据页,然后在数据页内部的目录里二分查找,找到那条数据。

聚簇索引和非聚簇索引

由于主键索引的最底层的索引页里会有每个数据页的数据,最后会指向数据页,那么数据页就相当于整个B+树叶子节点,那么这种B+树就可以称为聚簇索引。

如果针对其他字段建立索引,比如name,age之类的,都是一样呢的原理,那么其他的和主键id索引的一样,不一样的就是数据页里面记录的并不是整行数据,而是这个字段和对应的主键id,这就是非聚簇索引。

在这里插入图片描述

所以如果要找出了这个索引值和主键之外的列的时候,就需要根据主键值,从聚簇索引里从根节点开始,一路找到对应的完整数据行,再把要的字段值拿出来,这个叫做回表,那么这时候就要多一次查找聚簇索引。

如果是联合索引,那么就会按照放的顺序排序,比如name + age,那么就会先按name排序,如果name相等的时候,再按age排序,然后走这个索引的B+树,再搜索到主键,根据主键到聚簇索引里去搜索。

插入数据时维护不同索引的B+树

首先,刚开始一个表,就有一个数据页,这个数据页就属于聚簇索引的一部分,而且还是空的。

然后插入数据,直接就插入到这个数据页了,也没必要弄索引页

那这个数据页就是根页,每个数据页内部都有一个基于主键的页目录,所以这时候根据主键来搜索直接从页目录里找就行

数据页满了,就会多出一个新的数据页,然后拷贝一些数据到新的数据页,并根据主键值的大小进行挪动,让两个数据页根据主键值排序,让第二个数据页的主键值都大于第一个数据页的主键值。

在这里插入图片描述

此时的根页就升级为索引页,这个根页里放的是两个数据页的页号和他们最小的主键值。

随着数据页索引条目越来越多那么就需要多个索引页来存储数据页索引,那么这多个索引页就需要一个上层索引页来存放下层的索引页,此时根页就是上层的索引页。数据页越来越多,索引页也不停分裂,分裂出更多的索引页,然后又多出来上层索引页来保存这一层的索引页,那么根页再次往上提上一层。

在这里插入图片描述

但是索引也有两个缺点:

1.空间上,要创建很多的索引,那么就必须有多棵索引树,每个B+树都要占用很多的磁盘空间,那么创建太多索引,是很消耗磁盘的。

2.时间上,你在增删改的时候,需要维护各个索引的数据有序性,因为每个所以你B+树都要求页内按照值大小排序,页之间也是有序的,下一个页的所有值必须大于上一个页的所有值,如果插入的数据较小,那么就会进行数据页的挪动,维护页之间的顺序,不停的插入数据,可能会导致数据页不停的反分裂,不停的增加新的索引页,整个过程都是消耗时间的,那么就会导致增删改的速度比较差了。

所以一般都会使用联合索引,可以复用很多空间和时间上的消耗。

联合索引查询

1.等值匹配规则,也就是几个字段名称和联合索引的字段完全一样,而且都是基于等号的等值匹配,那么百分百会用上所以你,即使顺序不一样,Mysql优化器也会优化成字段顺序去找。

2.最左侧列匹配,这个意思就是我们的联合索引是KEY(class_name, student_name, subject_name),那么只要根据最左侧部分字段来查,也是可以的。

比如select * from student_score where class_name=’’ and student_name=’’",查某个学生所有科目的成绩,不一定非得要后面suvject_name。

但是不能跳过前面字段,直接查后面的,因为排序是先按照前面排,然后前面相等的时候,才按照后面排,那么也就是前面不相等的时候,后面是没有顺序的,也就用不到索引了。

3.最左前缀匹配原则,如果用like语法来查select * from student_score where class_name like ‘1%’,查找所有1打头的班级的分数,那么也是可以用到索引的。

因为你的联合索引是按照class_name排序的,那么只要确定最左前缀,那么就可以基于索引来查,但是不能基于最右,毕竟排序是从左到右依次排序的,比如a,cb,cc,d,这样的顺序排的。

4.范围查找规则,我们可以用class_name按照范围去找比如"1班"到"5班",那么索引就会先找到"1班"对应的数据页,再找到"5班"对应的数据页,两个数据页中间的那些数据页就都是你范围内的数据了。而且数据页之间使用双向链表连接的,所以可以很好的遍历出来。

5.等值匹配 + 范围匹配的规则,如果你要是用select * from student_score where class_name=‘1班’ and student_name>’’ and subject_name<’’,那么此时你首先可以用class_name在索引里精准定位到一波数据,接着这波数据里的student_name都是按照顺序排列的,所以student_name>’‘也会基于索引来查找,但是接下来的subject_name<’'是不能用索引的。

排序使用索引

如果普通的情况下,类似于select * from table order by xx1,xx2,xx3 limit 100这样的SQL,那么就需要把这些数据放到一个临时磁盘文件里,然后在通过排序算法在磁盘文件里排序,再按照指定的要求拿走limit语句,那么SQL速度简直慢到家了。

那么这种时候,我们建立了INDEX(xx1,xx2,xx3)这样的联合索引,本身是一次按照xx1,xx2,xx3三个字段的值排序,那么此时再运行上面那样的语句,就不需要再临时硬盘文件里排序了。

那么联合索引的索引树里都排序好了,那么我们就直接按照从小到大的值获取前100条就可以了,再拿到前100条数据的主键再去聚簇索引里回表查询剩余的字段。

如果都是降序,那么就要都进行xx1 desc,xx2 desc,xx3 desc,不能有升有降的,索引树里面的因为都是顺序排序的。

分组使用索引

一般做分组的时候,都会group by把数据分组,接着用count,sum之类的聚合函数,如果不用索引,那么就需要把所有的数据放到一个临时磁盘文件里还有加上部分内存,然后去弄一个分组,按照指定字段的值分成一组一组,接着对每一组都执行一个聚合函数,那么性能是极差的,毕竟要涉及大量的磁盘交互。

但是我们的索引树里默认都是按照指定的一些字段都排序好的,那么字段值相同的数据都是在一起的,假设要走索引去执行分组后,再聚合,那性能一定是要比临时磁盘文件去执行好多了。

group by和order by用索引的原理和条件都差不多,本质都是按照最左侧开始的字段顺序一致,然后利用索引树已经完成排序的特性,快速根据排序好的数据执行后续操作。

那么设计表的索引的时候,充分考虑后续你的SQL要怎么写, 然后大概会根据哪些字段进行where语句里的筛选和过滤,大概用哪些字段进行排序和分组,考虑好之后,就可以为表设计两三个常用的的索引,覆盖常见的where筛选,order by排序和group by分组的需要,保证常见的SQL语句都可以用上索引,那么查询性能就不会有太大的问题。

回表对性能的损害以及覆盖索引

一般我们自己创建的索引都是独立的索引B+树,那么仅仅包含索引里的几个字段和主键值,如果需要查找其他字段,那么就需要回表操作,跑到主键的聚簇索引里去找。

类似select * from table order by xx1,xx2,xx3的语句,相当于是得把联合索引和聚簇索引,两个索引的所有数据都扫描一遍了,那还不如就不走联合索引了,直接全表扫描得了,这样还就扫描一个索引而已。

但要是select * from table order by xx1,xx2,xx3 limit 10这样的语句,那执行引擎就会先扫描联合索引树,拿到十条数据,然后再去聚簇索引查找10次就可以了,那么还是会走联合索引的。

覆盖索引就是需要查找的字段仅仅需要联合索引或者单独字段的索引里的几个字段的值,那么只需要扫描联合索引的索引树就可以了,不需要进行回表操作,这种方式就是覆盖索引,那么我们可以用到联合索引的时候,就用联合索引,减少回表的次数,要么可能直接给你做成全表扫描,不走联合索引了。

如果真的要回表做聚簇索引的时候,也尽量用limit where等语句限定以下回表聚簇索引的次数,那么性能也会好一些,会走联合索引。

设计索引

首先设计好表结构之后,就是设计表的索引:

第一点就是未来我们对表进行查询的时候,大概会如何进行查询,如果一开始根本不知道要怎么查询表,那么我们可以先进入系统的开发,等功能差不多开发完毕了,你就可以考虑如何建立索引了,针对SQL语句的where条件,order by条件以及group by条件去设计索引。

此时可以设计一个或者两三个联合索引,每个联合索引都尽量去包含你的where,order by,group by里的字段,查看,是否都是最左侧字段开始部分字段。

如果有的字段就几个值的选择,要么0要么1,或者就几个值的范围,那么这种字段建立索引是没有太大意义的,因为根本没办法用快速的二分查找,没有什么意义,所以尽量使用那些基数比较大的字段,也就是值比较多的字段,才能发挥出B+树快速二分查找的优势。

尽量对字段的类型比较小的列来设计索引,比如tinyint之类的,那么自己本身的值占用的磁盘空间小,搜索时候性能也会比较好,如果针对varchar(255)这样的字段建立索引,可能值太大了,占用很多磁盘空间,那么可以针对这个字段的前几个字符建立索引,比如前20个字符,那么建立索引就类似于KEY my_index(name(20), age, course)这样的形式,此时你再where条件里搜索的时候,如果是根据name字段来搜索,那么就会先到索引树里根据name字段的前20个字符去搜索,然后再到聚簇索引去提取完整的name字段值进行比较就可以了。

但是对于order by ,group by这种的,前20个字符是无法使用索引了,有可能前20个字符是一种顺序,但是整个name排序并不是那个顺序。

如果你在查询的字段里使用函数,那么也不会走索引,毕竟通过函数计算之后的字段并不是索引树里的顺序。

之后插入的数据值如果不是按照顺序来的,可能就会导致索引树里的某个页自动分裂,那么页分裂就很耗费时间,因此一般设计索引别太多,建议两三个联合索引就可以覆盖掉你这个表的全部查询了,还有很关键的一点,就是建议主键id一定是自增的,别用UUID之类的,因为主键自增,起码聚簇索引不会频繁的分裂,但是如果用UUID,那么也就导致聚簇索引频繁的页分裂。

执行计划

也就是MYSQL地层,针对磁盘上的大量数据表,聚簇索引和二级索引,如何检索查询,如何筛选,如何使用函数,如何排序,如何分组等等,这个过程就是执行计划。

Mysql单表查询的执行计划

如果写一个通过主键id查询的,或者通过二级索引加上聚簇索引查询的,这种根据索引直接可以快速查找数据的过程,在执行计划里称为const,也就是性能超高的常量级。但是这里的二级索引也必须是唯一索引,也就是二级索引的每个值都是唯一的。

如果是一个普通的二级索引,那么查询速度也是很快的,只不过由于不是唯一的,查到等于或者范围的情况,还要查看下一个是否也是这个值,这时候执行计划里叫做ref,如果包含多个列的普通索引,那么最左侧开始连续多个列都是等值比较才可以是ref方式。类似于select * from table where name=x and age=x and xx=xx,然后索引可能是个KEY(name,age,xx)

另外,使用name is null这种语法,那么即便name是主键或者唯一索引还是会走ref方式,但是如果你是针对一个二级索引同时比较了一个值还有限定了IS NULL,类似于select * from table where name=x or name IS NULL,那么此时在执行计划里就叫做ref_or_null

如果你的普通索引进行范围筛选,比如age > x and age < y这种的,这种方式就是range,如果筛选的范围不是很大,性能也是很快,但如果一下子查出来几十万条数据,那么性能也不会很高了。

还有一种比较特殊的数据访问方式,就是index,比如联合索引是KEY(x1,x2,x3),好,现在我们写一个SQL语句是select x1,x2,x3 from table where x2=xxx,那么x2不是联合索引最左侧的那个字段,但是查询的值是在二级索引树内的,那么就会直接遍历KEY(x1,x2,x3)索引树的叶子节点的那些页,一个接一个的遍历,然后找到x2=xxx的那些数据,因为二级索引叶子节点除了这三个值以外,还有主键的值,但是要比聚簇索引叶子节点小多了,所以速度也快,这种只要遍历二级索引就可以拿到查询的数据,而不用回表到聚簇索引的访问方式,就是index访问方式。

那么针对上面5种访问方式,const,ref,ref_or_null和range,只要查出来的数据量不是特别大,性能都极为高效,index稍微次一点,毕竟是遍历某个二级索引,但是索引比较小,遍历性能也还可以。

最次的就是all了,all的意思就是直接全表扫描,扫描你聚簇索引的所有叶子节点,一行一行去扫描,那么有几万条,几十万条数据以上的,基本都会很慢很慢。

举例:

1.select * from table where x1 = xx or x2 > xx,这时候创建的索引只有(x1, x3)和(x2, x4),那么这时候因为等值比较,扫描的数据比较少,那么Mysql优化器可能会挑选x1的索引,做一个查找x1 = xx,之后接着回表,取出完整的数据,然后到内存里,根据每条数据x2字段的值,再进行条件筛选。

2.select * from table where x1 = xx and c1 = xx and c2 > xx and c3 is not null,这时候x1是有索引的,其他都没有索引,那么这种情况下,查询优化器生成的执行计划,就会仅仅针对x1字段走一个ref访问,然后再去聚簇索引把完整的字段查出来,加载到内存里去,接着就可以针对这波数据的c1,c2,c3字段按照条件进行筛选和过滤了,所以你的x1索引的设计,必然尽可能是让x1=xx这个条件在条件树里查找出来的数据量比较少,才能保证后续的性能比较高。

3.select * from table where x1 =xx and x2 = xx,这两个字段分别都有一个索引,那么这个时候是有可能同时查两个索引树,然后取交集,再回到聚簇索引查找,如果经过交集之后,数据量由多变少,那么这种可能性就会很高。如果不是and 是or,那么也有可能会查找两个索引树,然后并集来进行合并,intersection(交集),union(并集),但是这种情况也不一定会发生。

多表关联的执行计划

多表关联的基本原理就是,先在一个表里通过筛选条件,查出一批数据,这个表就是驱动表,然后将这一批数据去另一个表进行查找,那么另一个表就是被驱动表。

如果在驱动表里找到10条数据,那么就要到另一个被驱动表里去根据连接条件里筛选数据,那么就要去查询10次,这就是嵌套循环关联查询(nested-loop join)

所以对驱动表根据where条件进行查询的时候,要走索引来查询,并且被驱动表也一样,如果其中有一个表走全表扫描,数据还很大,那么速度就很很慢,加上嵌套循环关联查询,几十次的全表查询,速度会慢的无法接受。

成本优化

成本:分为从磁盘读数据到内存的IO成本,因为都是一页一页的读,读一页的成本约定为1.0,还有就是拿到数据之后验证是否符合搜索条件,活着排序分组之类的,这些数据CPU成本,因为消耗CPU资源,一般约定读取和检测一条数据是否符合条件的成本是0.2。

可以通过show table status like “表名”,拿到这个表的统计信息,rows就是表里的记录数,data_length就是聚簇索引的字节数大小,除以1024的就是kb,再除以16,就是数据页的数量,那么数据页数量 * 1.0 + rows * 0.2就大致总成本就出来了,当然,这是全表扫描的成本。

如果使用二级索引,且查询条件涉及到几个范围,比如name值在25~100,250 ~ 350,那么就是两个范围,否则name = xx就仅仅是一个范围区间,一般一个范围区间就简单粗暴的认为等同于一个数据页,这时候,IO成本都会估计很小,要么是1 * 1.0,要么是 n *1.0,只是个位数这个级别。

然后估算出可能拿到的数据有多少, 在 * 0.2左右,之后拿到的数据还要回表到哦聚簇索引里去查询完整时速局,这里默认一条数据回表就要查询一个数据页,如果是100条数据,那么就是100左右的IO成本。

最后再用这100条数据,查询是否符合其他查询条件,那么耗费CPU成本就是100 * 0.2,也就是20左右,那么一共成本就是 1 + 20 + 100 + 20 =141,比如全表扫描的几千来说,成本是很低的。

多表关联的成本与单表差不多,先对驱动表进行最佳访问方式,用最低成本从驱动表里查出符合条件,然后再去被驱动表查出条件,与之前一样的估算方法,然后挑选一个成本最低的方法,驱动表的估算成本 + 驱动表数据条数 * 被驱动表一次查询的估算成本,就是总成本了。

Mysql基于各种规则去优化执行计划

对于一些相对较复杂的SQL语句的时候,有时候可能会觉得你写的SQL执行计划效率不够高,就会自动进行优化。

比如Mysql可能觉得你的SQL里有很多括号,那么无关紧要的括号就会给你删除,其次比如i = 5 and j > i这样的,就会改写成 i = 5 and j > 5,做一个常量替换。

还比如b = b and a = a这种没有意义的都会直接删掉。

如果多表查询的时候,select * from t1 join t2 on t1.x1=t2.x1 and t1.id=1,这个SQL明显是针对t1表的id主键进行了查询,同时还要跟t2表进行关联,其实这个SQL语句就可能在执行前就先查询t1表的id=1的数据,然后直接做一个替换,把SQL替换为:select t1表中id=1的那行数据的各个字段的常量值, t2.* from t1 join t2 on t1表里x1字段的常量值=t2.x1

对于子查询,如果子查询的where条件依赖于外面表的字段,那么查询效率是很低的,那么就不是先执行子查询,在执行外面的查询,而变成遍历外面表里的每一条数据放到子查询里去执行,然后找到这条数据的值,在拿到外层去判断,是否符合条件,比如:select * from t1 where x1 = (select x1 from t2 where t1.x2=t2.x2)

explain

id:如果复杂的SQL里可能会很多个Select,也可能会包含多条执行计划,每条执行计划都会有一个唯一的id,如果一个select涉及到多个表,那么多条执行计划的id是一样的。

select_type:这一条执行计划对应的查询是什么查询类型,一般单表连接或者多表连接查询的select_type都是SIMPLE,然后有子查询的时候,主查询就是PRIMARY,子查询就是SUBQUERY,如果是union语句的话,第一条执行的是PRIMARY,第二条就是UNION,而且由于要合并两个查询结果,还有会第三条执行计划,这时的select_type就是union_result。

table:表名,要查询哪个表,如果有临时表,那么就会有类似< derived2 > 这样的通过临时物化表为要查询的表。

partitions:表分区的概念

type:针对当前这个表的访问方式,包括const,ref,range等等。如果被驱动表基于主键进行等值匹配,那么查询方式就是eq_ref。

possible_keys:你type确定访问方式,那么哪些索引是可供选择的。

key:在possible_keys里实际选择的那个索引

key_len:索引的长度

ref:使用某个字段的索引进行等值匹配搜索的时候,索引列进行等值匹配的那个目标值的一些信息

rows:是预估通过索引或者别的方式访问这个表的时候,大概可能会读取多少条数据。

filtered:经过搜索条件过滤之后的剩余数据的百分比。

extra:一些额外的信息,比如Unsing index就代表仅仅在二级索引里执行,没有回表操作;Using index conndition表示过滤索引后找到所有符合索引条件的数据行,然后用where语句的其他条件去过滤这些数据行;Using where表示优化器需要通过索引回表查询数据。

如果你的关联条件并不是索引,那么就会用到join buffer的内存技术来提升关联的性能

如果我们的排序没有用到索引的时候,那么就要基于内存或磁盘文件来排序,大部分都得基于磁盘文件来排序,这时候就会Using filesort来进行排序,性能是很差的

如果我们group by,union,distinct之类的语法,没有利用到索引来进行分组聚合,那么就需要基于临时表来完成,也有大量的磁盘操作,Using temporary,性能也是很低的

所以我们要尽可能合理优化索引,保证执行计划每个步骤都可以基于索引执行,避免扫描过多的数据。

例子:

EXPLAIN SELECT * FROM t1 WHERE x1 IN (SELECT x1 FROM t2) OR x3 = ‘xxxx’;

执行计划为:

在这里插入图片描述
那么从这里可以看出,主查询可能用到x3的索引,但是并没有走索引树,而是全表扫描了,那么x3 = xx可能大部分的值都是xx,成本可能还不如全表扫描,所以走了全表扫描,子查询里用到了x1的索引,但是没有什么筛选条件,所以遍历了x1的索引树,index形式。

主从复制架构

主从复制架构欧,就是部署两台服务器,每台服务器上都得有一个Mysql,其中一个Mysql是master(主节点),另外一个Mysql是slave(从节点)。

然后我们的系统平时连接到master节点写入数据,也可以从里面查询数据,然后master节点会把写入的数据自动复制到slave节点去,让slave节点可以跟master节点有一抹一样的数据。

在这里插入图片描述

那么这种架构的意义就在于,如果主节点宕机了,那么就可以到从节点写入数据和查询数据,因为主从数据是一致的。那么就自燃实现了Mysql的高可用了,如果只有一个Mysql服务器,如果这个服务器宕机,那么就会导致无法访问数据库,整个系统就会崩溃。

在这里插入图片描述

除了高可用之外,还有读写分离架构,也就是主节点写入数据,从节点去查询数据,如果请求过多,那么就可以加从节点服务器,分摊读请求,这就是一主多从架构。

在这里插入图片描述

大多数公司来说,读请求并没有那么高,所以并不是非得要做读写请求,但是高可用是必须要做的。

除此之外,还可以挂一个从库,专门用来跑一些报表SQL语句,防止和其他从库争抢资源,因为报表SQL,往往要运行好几秒。

主从复制的基本原理:

从库有一个IO线程,跟主库建立一个TCP连接,请求主库传输binlog日志给自己,然后主库上有一个IO dump线程,就会负责通过这个TCP连接把binlog日志传输给从库的IO线程。

接着从库的IO线程把读取到的binlog日志数据写入到自己本地的relay日志文件中去,然后从库上另外有一个SQL线程会服务relay日志里的内容,进行日志重做,把所有在主库执行过的增删改操作,在从库上重新做一边,达到一个 还原数据的过程。

在这里插入图片描述

如何为Mysql搭建一套主从复制架构

主从复制配置:

首先要却把主库和从库的server -id是不同的,其次就是主库必须打开binlog功能,才会写binlog到本地磁盘。

1.主库上要创建一个用于主从复制的账号:
create user ‘backup_user’@‘192.168.31.%’ identified by ‘backup_123’;
grant replication slave on . to ‘backup_user’@‘192.168.31.%’;
flush privileges;

使用mysqldump工具把主库在这个时刻的数据做一个全量备份,此时一定是不能允许系统操作主库了,主库数据不能有变动。

/usr/local/mysql/bin/mysqldump --single-transaction -uroot -proot --master-data=2 -A > backup.sql

master-data=2就是备份SQL文件里,要记录一下此时主库的binlog文件和position号,为主从复制做准备的,且这些在backup.sql里就有。

接着可以通过scp之类的命令把这个backup.sql文件拷贝到你的从库服务器上去。

接着把backup.sql文件里的语句都执行一遍,包括database,table以及数据。

CHANGE MASTER TO MASTER_HOST=‘192.168.31.229’, MASTER_USER=‘backup_user’,MASTER_PASSWORD=‘backup_123’,MASTER_LOG_FILE=‘mysql -bin.000015’,MASTER_LOG_POS=1689;

接着执行一个开始进行主从复制的命令:start slave,再用show slave status查看一下主从复制的状态,如果看到Slave_IO_Running和Slave_SQL_Running都是Yes就是一切正常,主从开始复制了。

问题所在:

现在搭建出来的主从复制架构是一种异步的方式,它不会管从库到底有没有收到日志。

那么这时候如果还没有同步到从库,结果主库宕机了,此时数据就会丢失了,所以要将复制方式采取半同步,这就是你主库写入数据,日志进入binlog之后,可以确保binlog日志复制到从库了,再告诉客户端本次写入事务是否成功。

半同步有两种方式,第一种是AFTER_COMMIT方式,意思是主库写入日志到binlog,等待binlog复制到从库了,主库就提交自己的本地事务,接着等待从库返回给自己一个成功的响应,然后主库提交事务成功的响应给客户端。

另外一种是Mysql 5.7默认的方式,主库把日志写入binlog,并且复制给从库,等待从库的响应,从库返回成功后,主库再提交,接着再返回事务成功的响应给客户端。

搭建版同步只需要安装一下版同步复制插件就可以,现在主库中安装半同步复制插件,同时开启半同步复制:

install plugin rpl_semi_sync_master soname ‘semisync_master.so’;
set global rpl_semi_sync_master_enabled=on;
show plugins;

然后从库也安装这个插件以及开启半同步复制:

install plugin rpl_semi_sync_slave soname ‘semisync_slave.so’;
set global rpl_semi_sync_slave_enabled=on;
show plugins;

接着要重启从库的IO线程:stop slave io_thread; start slave io_thread;

然后在主库上检查一下半同步复制是否正常运行:show global status like ‘%semi%’;,如果看到了Rpl_semi_sync_master_status的状态是ON,那么就可以了。

GTID搭建方式:

除了传统搭建方式外,还有GTID搭建方式,首先在主库配置:

gtid_mode=on
enforce_gtid_consistency=on
log_bin=on

server_id=单独设置一个
binlog_format=row

接着在从库进行配置:

gtid_mode=on
enforce_gtid_consistency=on
log_slave_updates=1

server_id=单独设置一个

接着按照之前的讲解步骤在主库创建好复制的账号之后,就可以之前一样进行操作了,比如在主库dump出来一份数据,在从库里导入这份数据,备份违建里会有SET @@GLOBAL.GTID_PURGED=***一类的字样,可以照着执行一下就可以了。

最后执行一下show master status,可以看到executed_gtid_set,里面记录的是执行过的GTID,接着执行一下SQL:select * from gtid_executed,可以查询到,对比一下,就会发现对应上了。

主从复制延迟问题:

由于主库写入的数据很快,是并发写入的,但是从库是单个线程缓慢拉取数据,那么就会导致从库复制数据的速度比较慢,那么半自动返回的时间也就会更长一些。

可以使用percona-toolkit工具集里的pt-heartbeat工具,他会在主库里创建一个hearbeat表,然后会有一个线程定时更新这个表里的时间戳字段,从库上就有一个monitor线程会负责检查从库同步过来的heartbeat表里的时间戳,把时间戳跟当前的时间戳比较一下就知道同步落后了多长时间。

如果有延迟的话,如果做了读写分离,那么写入的数据不能立即在从库里读取到,还没有同步过去,所以为了加快复制速度,5.7版本已经支持并行复制了,可以在从库里设置slave_parallel_workers > 0,然后把slave_parallel_type设置为LOGICAL_CLOCK,就可以了。

如果要求立马强制读取到,可以在类似MyCat或者Sharding-Sphere之类的中间件里设置强制读写都从主库走,这样你写入的数据,强制从主库里读取,就一定可以读取到了。

基于主从复制实现故障转移

一般生产环境里用于进行数据库高可用架构管理的工具是MHA,用perl脚本写的一个工具,这个工具专门用于监控主库的状态,如果感觉不对劲,就赶紧把从库切换成主库。

这个MHA自己也是需要单独部署的,分为两种节点,一个是Manager节点,一个是Node节点,Manager节点一般是单独部署一台机器的,Node节点一般是部署在每台Mysql机器上的,因为Node节点得通过解析各个Mysql的日志来进行一些操作。

Manager节点会通过探测集群里的Node节点去判断各个Node所在机器上的Mysql运行是否正常,如果发现某个Master故障了,就直接把他的Slave提升为Master,然后让其他Slave都挂到新的Master上去。

生产配置经验

数据库机器配置

一般数据库机器配置最低在8核16G,正常是16核32G.

因为相对于Java程序的机器配置,数据库的机器需要执行大量的磁盘IO操作,所以每个请求都比较耗时,所以机器的配置要高一些才能更快的反应请求。

对于8核16G的机器,每秒大概可以抗1,2千并发请求,如果再高一点,16核32G的机器每秒可以抗2,3k,甚至4k的并发也是可以的,如果达到上万,那么数据库也是扛不住宕机的。

对于数据库而言,如果可以,最好采用SSD固态硬盘,因为SSD读写的性能要高于机械硬盘很多,那么抗住的并发量就会更多一些。

数据库如果进行性能测试

首先要利用一些工具每秒秒发出1k个冰球,查看它的CPU负载,磁盘IO负载,网络IO负载,内存负载等,然后查看数据库能否每秒处理掉这些请求。

根据逐步的测试,大致在一个负载压力下,可以每秒抗多少请求。

QPS:表示每秒可以处理多少的请求。

TPS:每秒会处理多少次事务提交或者回滚。

所以TPS往往是指多少个事务执行完毕,是每秒处理完事务的数量。

IO相关的压测性能指标:

1.IOPS
这个指的是机器随机IO并发处理的能力,这个能力就是后台IO将数据刷回磁盘里的能力,如果太低,那么就会导致刷回磁盘的效率不高。

2.吞吐量
每秒可以读写多少字节的数据量,一般普通磁盘的顺序写入的吞吐量每秒都可以达到200MB左右

3.latency
往磁盘里写入一条数据的延迟,那么延迟越低,速度就越快。

4.CPU负载
CPU负载过高,说明数据库不能继续往下压测更高的QPS,否则CPU是吃不消的

5.网络负载
压测到一定的QPS和TPS的时候,每秒钟机器的网卡会输入多少MB,输出多少MB,网卡满了也不能继续压测了

6.内存负载
压测到一定情况,查看机器内存耗费了多少,耗费过高,也不能继续压测

数据库压测工具

sysbench,这个工具可以自动在数据库里构造出来大量的数据,接着可以模拟几千个线程并发的访问你的数据库,模拟出来各种事务提交到你的数据库里去。

1.安装

curl -s https://packagecloud.io/install/repositories/akopytov/sysbench/script.rpm.sh | sudo bash

sudo yum -y install sysbench

sysbench --version

如果能看到sysbench版本号,就代表安装成功了

2.执行压测

sysbench --db-driver=mysql --time=300 --threads=10 --report-interval=1 --mysql-host=127.0.0.1 --mysql-port=3306 --mysql-user=test_user --mysql-password=test_user --mysql-db=test_db --tables=20 --table_size=1000000 oltp_read_write --db-ps-mode=disable prepare

在这里插入图片描述最后的prepare表示准备测试用的测试表和测试数据。

如果是run,则代表运行测试

3.全方位测试

通过更改模式来进行不同的压测,比如综合读写TPS,使用oltp_read_write模式:

sysbench --db-driver=mysql --time=300 --threads=10 --report-interval=1 --mysql-host=127.0.0.1 --mysql-port=3306 --mysql-user=test_user --mysql-password=test_user --mysql-db=test_db --tables=20 --table_size=1000000 oltp_read_write --db-ps-mode=disable run

其他还有只读性能,oltp_read_only

测试数据库的删除性能,使用的是oltp_delete模式

测试数据库的更新索引字段的性能,使用的是oltp_update_index模式

测试数据库的更新非索引字段的性能,使用的是oltp_update_non_index模式

测试数据库的插入性能,使用的是oltp_insert模式

测试数据库的写入性能,使用的是oltp_write_only模式

最后压测完之后,使用cleanup命令,清理数据:

sysbench --db-driver=mysql --time=300 --threads=10 --report-interval=1 --mysql-host=127.0.0.1 --mysql-port=3306 --mysql-user=test_user --mysql-password=test_user --mysql-db=test_db --tables=20 --table_size=1000000 oltp_read_write --db-ps-mode=disable cleanup

4.压测报告

类似会出现这样的东西:[ 22s ] thds: 10 tps: 380.99 qps: 7312.66 (r/w/o: 5132.99/1155.86/1321.35) lat (ms, 95%): 21.33 err/s: 0.00 reconn/s: 0.00

在这里插入图片描述
另外会有一个总的压测报告:

在这里插入图片描述

压测过程观察机器性能

当你不停的增加线程数量,发现在数据库下一个QPS的数值的同时,机器CPU,内存,网络和磁盘的负载已经非常高了,到了有一定风险的临界值,此时就不能继续增加线程数量和提高数据库抗下QPS了

1.观察机器的CPU负载

最常用的linux机器性能的命令,就是top命令,比如我们会看到如下这一行:

top - 15:52:00 up 42:35, 1 user, load average: 0.15, 0.05, 0.01

先来解释一下这行信息,这行信息是最直观可以看到机器的cpu负载情况的,首先15:52:00指的是当前时间,up 42:35指的是机器已经运行了多长时间,1 user就是说当前机器有1个用户在使用。

最重要的是load average: 0.15, 0.05, 0.01这行信息,他说的是CPU在1分钟、5分钟、15分钟内的负载情况。

如果一个4核的CPU,出现了3.5,4,那么说明几个CPU都跑满了,就不能再往上提高了

2.观察机器的内存负载

使用top命令之后可以看到:

Mem: 33554432k total, 20971520k used, 12268339 free, 307200k buffers

这里说的就是当前机器的内存使用情况,这个其实很简单,明显可以看出来就是总内存大概有32GB,已经使用了20GB左右的内存,还有10多G的内存是空闲的,然后有大概300MB左右的内存用作OS内核的缓冲区了。

一般来说内存的使用率到了70%~80%,就有点危险了,就不能再继续增加压测的线程数量核QPS了

3.磁盘IO情况

使用dstat -d命令可以看到:

在这里插入图片描述

在上面可以清晰看到,存储的IO吞吐量是每秒钟读取103kb的数据,每秒写入211kb的数据,像这个存储IO吞吐量基本上都不算多的,因为普通的机械硬盘都可以做到每秒钟上百MB的读写数据量。

使用dstat -r命令可以看到:

在这里插入图片描述

他的这个意思就是读IOPS和写IOPS分别是多少,也就是说随机磁盘读取每秒钟多少次,随机磁盘写入每秒钟执行多少次,大概就是这个意思,一般来说,随机磁盘读写每秒在两三百次都是可以承受的。

如果磁盘IO吞吐量达到上百MB或者读写次数为2,3百了,那么就不要继续增加了

4.观察网卡的流量情况

接着我们可以使用dstat -n命令,可以看到如下的信息:

在这里插入图片描述

这个说的就是每秒钟网卡接收到流量有多少kb,每秒钟通过网卡发送出去的流量有多少kb,通常来说,如果你的机器使用的是千兆网卡,那么每秒钟网卡的总流量也就在100MB左右,甚至更低一些。

那么总的来说,在硬件的一定合理的负载范围内,把数据库的QPS提高到最大,就是数据库压测的时候最合理的一个极限QPS值

部署监控系统

要针对数据库搭建一个统一的可视化监控平台,虽然是DBA负责,但是我们也要对这个数据库可视化监控的技术有一定的了解

简单来说,Prometheus其实就是一个监控数据采集和存储系统,他可以利用监控数据采集组件(比如mysql_exporter)从你指定的MySQL数据库中采集他需要的监控数据,然后他自己有一个时序数据库,他会把采集到的监控数据放入自己的时序数据库中,其实本质就是存储在磁盘文件里。

我们采集到了MySQL的监控数据还不够,现在我们还要去看这些数据组成的一些报表,所以此时就需要使用Grafana了,Grafana就是一个可视化的监控数据展示系统,他可以把Prometheus采集到的大量的MySQL监控数据展示成各种精美的报表,让我们可以直观的看到MySQL的监控情况。

通过多个Buffer Pool来提高性能

如果是并发访问这个Buffer Pool,且都在访问内存里一些共享的数据结构,比如缓存页,各种链表之类的,那么就需要对此进行加锁,比如说加载数据页到缓存页,更新free链表,更新lru链表,然后释放锁,接着下一个线程再执行一系列操作。

那么我们可以设置多个Buffer Pool来优化并发能力,一般来说Mysql默认的规则是,如果你给Buffer Pool分配的内存小于1GB,那么最多就只会给你一个Buffer Pool,但是如果你的机器内存很大,那么就会给Buffer Pool分配较大的内存,那么此时你是可以同时设置多个Buffer Pool的,比如;

innodb_buffer_pool_size = 8589934592
innodb_buffer_pool_instances = 4

我们给Buffer Pool设置了8GB内存,然后设置了4个Buffer Pool,那么每个Buffer Pool的大小就是2GB,那么每个Buffer Pool负责管理一部分的缓存页和描述数据块,有自己独立的free,flush,lru链表等,那么并发的时候就可以并行执行多个Buffer Pool,可以在不同的Buffer Pool中加锁和执行自己的操作,提高数倍的并发性能提升。

在这里插入图片描述

通过chunk来支持数据库运行期间的调整

Buffer Pool的大小一般是不能变的,如果你运行期间调整大了1倍,那要怎么实现呢。

但是Mysql总会想一些办法来进行优化,实际上,它涉及了一个chunk机制,也就是Buffer Pool是由多个chunk组成的,它的大小是innodb_buffer_pool_chunk_size参数,默认值是128MB。

那么我们如果这时候想要扩大一倍,那就申请一系列的128MB大小的chunk就可以了,只要每个chunk是连续的128MB内存就可以了,然后把申请到的chunk内存分配给Buffer Pool就行了。

那么Buffer Pool的结构为:

在这里插入图片描述

如何基于机器配置合理的Buffer Pool

由于内存里除了给mysql的Buffer Pool以外,还有操作系统等等其他的内存,那么通常Buffer Pool设置机器内存的50%~60%是比较合理的。

Buffer Pool的总大小 = (chunk大小 * Buffer Pool数量)的两倍数

也就上面公式的2的倍数。

通过SHOW ENGINE INNODB STATUS命令,可以查看innodb里的具体情况,可以看到如下的东西:

S

主要讲解这里跟Buffer Pool相关的东西:

在这里插入图片描述

Linux操作系统的存储系统软件层原理以及IO调度优化原理

Linux操作系统分为VFS层,文件系统层,Page Cache缓存层,通用Block层,IO调度层,Block设备驱动层,Block设备层

在这里插入图片描述
当Mysql发起一次数据页的随机读写时,实际上会把磁盘IO请求交给LInux操作系统的VFS层,然后层层传递:

1.VFS层的作用是根据你是对哪个目录下的文件发起的读写IO请求,并把请求转交给对应的文件系统。

2.接着文件系统现在Page Cache这个基于内存的缓存里找你要的数据,如果有,则基于内存缓存来执行读写,如果没有就继续往下一层走。

3.这时候就会交给Block层,在这一层会把你对文件的IO请求转换为Block IO请求。

4.之后会把这个Block IO请求交给IO调度层,这一层默认是用CFQ公平调度算法,也就是优先执行需要大量数据的IO操作,那么耗时短的就一直等待,得不到机会,所以一般情况下,需要调整为deadline调度算法,也就是任何一个IO操作都不能一直等待,在指定时间范围内,必须让他去执行。

5.IO调度完成之后,就会决定哪个IO请求先执行,哪个后执行,此时可以把执行的IO请求交给Block设备驱动层,最后在发送给真正的存储硬件,Block设备层。

6.完成读写操作之后,就要把相应经过反向依此返回,最终Mysql就可以得到本次IO读写操作的结果。

数据库服务器使用的RAID存储架构

Mysql数据库就是一套数据库管理软件而已,底层是磁盘来存储数据,基于内存来提升数据读写性能,然后设计复杂的数据模型,帮助我们高效的存储和管理数据。然后通过Linux操作系统提供的接口,来运行负责操作底层的硬件。

在这里插入图片描述
一般来说,很多数据库部署在机器上的时候,存储都是搭建的RAID存储架构,RAID就是一个磁盘冗余阵列。

一般磁盘不够的时候就需要多加几个磁盘,但是这样我们就不知道要放在哪个磁盘上,从哪个磁盘取出数据,所以RAID技术就可以帮助我们选择一块磁盘写入,读取数据,除此之外,RAID技术很重要的作用就是他还可以实现数据冗余机制,也就是可以把写入的同一份数据,在两块磁盘上都写入,这样当一块磁盘坏掉的时候,就可以从另一块磁盘读取冗余数据出来,这一切都是RAID技术自动帮你管理的。

在这里插入图片描述

RAID存储架构的电池充放电原理

使用RAID阵列的时候,一般会有一个RAID卡,这个卡是带有一个缓存的,然后我们把RAID的缓存模式设置为write back,那么所有写入到磁盘阵列的数据,先会缓存在RAID卡的缓存里,然后慢慢再写入到磁盘阵列去,这样就可以大幅度提升我们数据库磁盘写的性能。

但是如果突然断电,那么可能缓存里的数据就会丢失,所以RAID卡一般都配置有独立的锂电池或者电容,如果服务器突然断电,那么RAID卡自己基于锂电池来供电运行,就会赶紧把缓存里的数据写入到阵列中的磁盘上。

在这里插入图片描述
但是锂电池是存在性能衰减的问题,所以一般来说锂电池都要配置定时充放电的,也就是每隔30~90填,就会自动对锂电池充放电依此,这样可以延长锂电池的寿命和校准电池容量。

但是锂电池在充放电的过程中,RAID的缓存级别会从write back变成write through,通过RAID写数据的时候,就直接变成磁盘写了,那么性能就会退化10倍以上,这时候往往会导致你的数据库服务器的RAID存储定期的性能出现几十倍的抖动,间接导致你的数据库每隔一段时间就会出现性能几十倍的抖动。

案例实战

数据库无法连接故障的定位,Too many connections

数据库无法连接的问题,“ERROR 1040(HY000): Too many connections”,由于Mysql自己也有Socket连接池,所以当你配置的max_connections过低,就可能会导致这个问题,超过一定数量的连接,那我们调大就一定可以连接更多的么,并不是。

通过show cariable like ‘max_connections’,可以看到连接可能要少于你设置的max_connections很多,这个原因是因为我们的Linux操作系统把进行可以打开的文件句柄限制为1024,那么会导致Mysql最大连接数只能为214。

在这里插入图片描述
由于Linux上一个进程占用过多的资源的话,其他进程可能会有使用受限,所以进行了限制,那么我们通过以下方式进行修改:

1.ulimit -HSn 65535,修改句柄为65535

2.然后通过以下命令查看最大文件句柄数是否倍修改:

cat /etc/security/limits.conf

cat /etc/rc.local

如果都修改生效了,那么我们重启服务器,然后重启Mysql,最大连接数也就可以生效了。由于我们在生产环境部署一个系统,比如数据库系统,消息中间件系统,缓存系统等等,都需要这个进程为主来运行,那么通常句柄数都会设置为65535,要不然像kafka这样的消息中间件,可能无法创建足够的线程,是无法运行的。

我们可以通过ulimit命令来设置每个进程被限制使用的资源量,用ulimit -a就可以看到进程被限制使用的各种资源的量

比如core file size代表的进程崩溃时候的转储文件的大小限制,max locked memory就是最大锁定内存大小,open file就是最大可以打开的文件句柄数量,max user processes就是最多可以拥有的子进程数量,设置之后,要确保变更落地到/etc/security/limits.conf文件里,永久性的设置进程的资源限制,所以要用第二步的命令检查是否落地到配置文件中去了。

数据库抖动优化

第一种情况:Buffer Pool里的缓存页,如果进行更新,就会变为脏页,但如果Buffer Pool里缓存页满了,那么就会根据LRU链表找最近最少访问的缓存页去刷入磁盘。

万一你要执行的一个查询语句,需要查询大量的数据到缓存页里,那么就会导致内存里大量的脏页需要淘汰出去刷入磁盘,才能腾出足够的空间来执行这条查询语句。那么可能平时几十毫秒的查询语句,由于要等待大量脏页flush,可能要几秒才能执行。

在这里插入图片描述

第二种情况:

如果redo log buffer里的redo log本身也会刷入到磁盘上的日志文件,那么磁盘上的redo log文件写满了,就会重新回到第一个日志文件再次写入,这时候如果第一个日志之文件里的redo log对应的内存里的缓存页的数据都没被刷新到磁盘上的数据页,那么为了防止数据丢失,就要将这些数据对应的缓存页刷入到磁盘中,那么这时候数据库就会hang住,因为任何一个更新请求都要写redo log,redo log这时候正在将缓存页刷入磁盘,为了可以覆盖原来的数据。那么必然要等待一定的时间才能完成,性能是很差的。

在这里插入图片描述

这两种情况都会出现抖动,但是第一种情况很难进行优化,因为Buffer Pool的大小就那么些,并不是无限大的,那么只能采用大内存机器,给Buffer Pool分配的内存空间大一些,那么缓存页填满的速度也就低一些,flush磁盘的频率也就更低点。

第二个的问题,就是要提升缓存页flush到磁盘的速度,刷入的越快,那么执行时间也就越短。

由于SSD固态硬盘的随机IO性能很高,所以选择使用SSD固态硬盘,还有一个很关键的参数,就是innodb_io_capacity,这个参数是数据库采用多大的IO速率把缓存页flush到磁盘的,如果你SSD能承载600次每秒IO,结果你设置为300,那么并没有把SSD固态硬盘的随机IO性能发挥出来。

这里可以使用fio工具测试磁盘最大随机IO速率,然后通过这个数值给innodb_io_capacity设置,尽可能的全速率去flush缓存页到磁盘。

另外一个参数就是innodb_flush_neighbors,这个参数是flush缓存页到磁盘的时候,可能会控制把缓存页临近的其他缓存页也刷到磁盘,那么flush到磁盘的缓存页太多了,我们用SSD固态硬盘的时候,没有必要同时刷临近的缓存页,这时候设置为0,禁止刷临近缓存页,那么就把每次刷新缓存页数量降低到最少了。

陌生人社交APP的Mysql索引设计实战

针对社交APP,主要是用户信息表,可以叫做user_info这个表,这个表里大概会有你的地区(你在哪个省份、哪个城市,这个很关键,否则不在一个城市,可能线上聊的好,线下见面的机会都没有),性别,年龄,身高,体重,兴趣爱好,性格特点,还有照片,当然肯定还有最近一次在线时间(否则半年都不上线APP了,你把他搜出来干什么呢?)

针对这个用户表进行搜索,不仅仅是筛选,还得支持分页,所以肯定得跟上limit xx, xx的分页语句,并且根据筛选出来的结果进行一个排序,那么最终SQL语句可能类似于:select xx from user_info where xx=xx order by xx limit xx,xx。

但是类似下面:select xx from user_info where age between 20 and 25 order by score,那么要么基于age创建索引,但是score就用不到索引了,相反score用上索引,那么age就用不到索引了。

那么这种情况下,一般都会让where条件去使用索引来快速筛选出来一部分指定数据,然后再进行排序,因为筛选出来的数据比较少,那么后续排序和分页的成本不会很大。

那么对于社交APP系统来说,联合索引里先要放省份,城市,性别这三个字段在最左侧,虽然这几个字段的基数小,但是实际查询的时候都要基于这几个字段,再加上其他字段进行查询,那么还不如直接放在最左侧,这样跟其他字段组成联合索引后,大部分的查询都可以通过索引树可以把where条件指定的数据筛选出来。

那么除了这三个以外,还加了一个年龄,但是where后面加上年龄的范围之后,前面的性别没有用上怎么办,这个时候可以写成:where province=xx and city=xx and sex in (‘female’, ‘male’) and age >=xx and age<=xx。那么整个where条件就都可以在索引树里进行筛选和搜索了,除了这些以外,还有兴趣,性格这些字段,那我们可以设计成(province, city, sex, hobby, character, age)这样的一个联合索引,那么还是按照之前的思路,即使不需要按性别和爱好进行筛选,也可以将这两个字段用in语句,把所有的枚举类值都放进去,那么就顺利的让所有的字段都可以用上索引。所以搜索的大部分枚举有限的值索引放在前面,像age,name这种的不确定的值放在后面,那么大部分查询语句里,都可以用in枚举值的方式,使用一个联合索引了。

假设条件里还有一个登陆时间小于7天的语句,那么就没办法用上索引了,毕竟在age前进行范围查找的话,age就不能用到索引了,这时候可以将这种几天内登陆过APP设置成一个true,false,也就是0,1字段值,登陆过就是1,没登陆就是0,那么就可以放在age前,用等于1或0,或者in(1,0)的方式走索引,使后面的age可以用上索引。

那么最终这个联合索引就是(province, city, sex, hobby, character, does_login_in_latest_7_days, age)这样的索引。这种索引差不多可以满足80%的查询要求。

那么剩下的要求呢,如果有对基数低的字段进行查找,然后还要进行排序,比如查找性别男,然后按照评分排序,这一下子筛选出所有的男性,可能有上百万用户数据,还有磁盘文件进行排序再分页,那么性能是很差的,所以这里要对sex和score都要走索引才行,那么就可以设计联合索引(sex,score),由于sex是基数低的字段,那么sex相等的情况下,都是按照score进行排序的,那么就可以sex = 男,然后将性别确定后,里面都是按照score顺序排序的,那就可以order by score走索引了,然后去limit语句指定的数据分页出来。

那么通过对查询场景的分析,用(province, city, sex, hobby, character, does_login_in_latest_7_days, age)这样的联合索引去抗下复杂的where条件筛选的查询,那么筛选出的数据量较少,接着进行排序和limit分页,同时针对低基数字段筛选 + 评分排序的场景,可以设计(sex,score)的辅助索引来应对,定位一大片低基数字段对应的数据,然后按照索引顺序走limit、语句获取指定分页的数据,速度同样会很快。

千万级用户场景下运营系统SQL调优

一般存储用户数据的表分为两张表,一个是用来存储用户的核心数据,比如id,name,昵称,手机号之类的信息,也就是users表,另一个表可能会存储用户的一些拓展信息,比如家庭住址,兴趣爱好,最近一次登录时间,也就是users_extent_info表。

SELECT COUNT(id) FROM users WHERE id IN (SELECT user_id FROM users_extent_info WHERE latest_login_time < xxxxx)

系统运行时候,这个SQL在千万级大表的场景下,要花几十秒才能跑出来,所以必须进行优化,通过explian来得到这条sql语句的执行计划。

在这里插入图片描述
首先针对子查询,是执行计划里的第三行实现的,针对users_extent_info使用了idx_login_time这个索引,做了range类型的索引范围扫描,查出来4561条数据,没有额外筛选,所以filtered是100%。

接着他这里的METERIALIZED,表示这里把子查询的这些数据代表的结果集进行了物化,物化成了一个临时表,这个临时表物化一定会临时落到磁盘文件里去,那么过程就很慢了。

第二条执行计划表示,对usrs表做了全表扫描,而且有Using join buffer,有join操作。

执行计划第一条表示,这里针对子查询的产出的临时物化表,也就是< subquery2 >,做了一个全表查询,把里面的数据都扫描了一遍,因为就是让users表的每一条数据,都要去物化临时表里的数据进行join,所以users表里的每一条数据只能去全表扫描一遍物化临时表,结果也就有49651条数据的10%左右被筛选出来。

所以整个过程,不仅要做一次物化临时表,落地磁盘,接着还全表扫描了users表的所有数据,每一条数据还要去没有索引的物化临时表里在做一次全表扫描找匹配数据,那么整个过程就很耗时了。

这里执行完SQL的explain命令之后,看到执行计划后,可以执行show warnning命令,此时显示出来的内容为:/* select#1 */ select count(`d2.`users`.`user_id``) AS `COUNT(users.user_id)`。

这就显而易见了,在生成执行计划的时候,自动把一个普通的IN子句优化成了semi join来进行IN + 子查询的操作,semi join简单来说就是对users表里的每一条数据,去对物化临时表全表扫描做semi join,不需要把users表里的数据真的跟物化临时表里的数据join上,只要users表里的一条数据,在物化临时表里可以找到匹配的数据,那么users表里的数据就会返回,这就叫做semi join,用来筛选的。

所以慢,那么既然知道了semi join和物化临时表导致的,那么我们先关掉半连接优化,执行SET optimizer_switch=‘semijoin=off’,这时候就会发现,性能提升了几十倍,变成了100多ms,由于自动执行semi join半连接优化,一旦禁止掉semi join自动优化,用正常的方式让他基于索引去执行,性能都很高的,但是一般生产环境是不能随意更改这些设置的。

那么不音响语义的情况下, 尽可能去改变SQL语句的结构和格式,最终变为:

SELECT COUNT(id) FROM users WHERE ( id IN (SELECT user_id FROM users_extent_info WHERE latest_login_time < xxxxx) OR id IN (SELECT user_id FROM users_extent_info WHERE latest_login_time < -1))

由于后面的第二个子查询,根本不可能成立,没有小于-1的,但是我们发现改变了SQL写法之后,执行计划也随之改变,并没有再进行semi join优化,而是正常用了子查询,主查询也是基于索引去执行的,那么我们在线上SQL语句性能提升至几百毫秒。

亿级数据量商品系统的SQL调优实战

突然一个SQL语句导致慢查询,结果就导致上千个请求等待,然后商品系统本身也大量的报警说查询数据超时异常。

select * from products where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx

当时有所以KEY index_category(category, sub_category),所以上面的绝对会走索引才对的。

通过explain发现,possible_keys里是由index_category的,但是实际用的key不是这个索引,而是PRIMARY,聚簇索引,而且Extra里清晰谢了Using where,所以性能才会慢。

那么结果也就是Mysql使用了错误的执行计划,这时候,就可以使用force index语法,强行使用index_category,变成:

select * from products force index(index_category) where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx

但是为什么这个SQL会选择对主键的聚簇索引进行扫描,没有使用我们的二级索引,以前没有问题,现在为什么突然有走聚簇索引。

因为Mysql认为你用索引之后,查出来的数据太多,还得在临时磁盘里排序,因此性能可能会很差,不如直接扫描主键的聚簇索引,因为聚簇索引的都是按照id值排序的,所以扫描的时候,直接按主键id倒序扫描过去就可以了,那么就会导致在聚簇索引进行全表扫描的操作,就会出现几十秒的情况。然后这一次可能多了一些新的商品类和子类,然后数据库里还没有这类的商品,之前是找到返回值后立马进行返回的,这回由于没有找到,就全表扫描了,从头到尾,所以才会出现慢查询的情况。

商品几十万评论的深分页问题

评论表的分页查询的SQL语句:

SELECT * FROM comments WHERE product_id =‘xx’ and is_good_comment=‘1’ ORDER BY id desc LIMIT 100000,20

由于要看第5000页评论,那么此时limit的offset就会是(5001 - 1) * 20,20就是每一页的数量,所以offset就是100000,那么最核心的索引就是index_product_id,那么正常情况下,肯定会走这个索引的,然后从表里筛选出来指定商品的评论数据。

第二部就是按照is_good_comment = '1’条件,筛选出这个商品评论的所有好评,但是索引并没有这个字段,那么就只能进行回表操作,也就是说,每一条评论都要回表一次,然后根据id找到那条数据,取出is_good_comment字段的值和1条件做比对,筛选出符合条件的数据。

虽然都是根据id在聚簇索引里回表,但是有几十万条,性能很差的,之后还要根据id进行倒序排序,还得基于临时硬盘文件进行倒序排序,又得耗时很久,最后再获取第5001页的20条数据,结果这条SQL基本就要跑1~2s。

我们可以改造成:SELECT * from comments a,(SELECT id FROM comments WHERE product_id =‘xx’ and is_good_comment=‘1’ ORDER BY id desc LIMIT 100000,20) b WHERE a.id=b.id

这样就会先执行子查询,反而会使用聚簇索引,按照id的倒序方向进行扫描,并把符合where要求的条件数据给筛选出来,筛选出10w条数据后,再加20条数据就可以取到了符合要求的20条的主键id,然后再到主查询的时候,通过这主键id回表查到完整数据就可以了。

千万级数据删除导致的慢查询

当时的情况基本上就是单行查询,应该不会出现按查询,都是根据索引查找的,性能应该很高的, 那么可能是另一种情况,不是SQL的问题,而是Mysql生产服务器的问题,由于Mysql服务器自己的负载过高,导致SQL语句执行很慢。

比如现在Mysql服务器的磁盘IO负载特别高,每秒执行大量的高负载的随机IO,但是磁盘本身每秒能执行的随机IO是有限的。结果就是你正常的SQL语句去执行的时候,磁盘太繁忙,顾不上你这个SQL,本来很快执行完的,也可能变成慢查询,还有就是网络负载很高,等待Mysql连接就要很久,宽带打满了,返回数据结果都发布出去,也会变成慢查询,另外就是CPU负载过高,导致去执行别的任务,没时间执行SQL语句。

这时候应该排查当时Mysql服务器的负载,看看磁盘,网络以及CPU的负载是否正常。

加入某个离线作业瞬间大批量的把数据往Mysql里灌入的时候,一瞬间服务器磁盘,网络以及CPU负载就会超高,这时候正常的SQL也会变成慢查询,如果发现一切正常,SQL本身也没问题,看执行计划也正常,Mysql服务器的负载也正常,那么就需要第三种,用MySQL profiling工具去细致的分析SQL语句的执行过程和耗时。

首先打开profiling,使用set profiling = 1这个命令,接着Mysql就会自动记录查询语句的profiling信息了,这时候执行show profiles命令,就会列出各种查询语句的profiling信息,会记录下来每个查询语句的query id,所以你要针对你需要分析的query,找到他的query id,查看他的profiling具体信息,使用show profile cpu,block io for query xx,这里的xx是数字,这时就可以看到具体的profile信息了。

除了cpu和block io之外,还可以看其他的各项,仔细检查了这个SQL语句的profiling信息,发现它的Sending Data耗时是最高的,几乎使用了1s,Mysql官方解释为:为一个Select语句读取和处理数据行,同时发送数据给客户端的过程。

这时候又用一个show engine innodb status,看一下innodb存储引擎的一些状态发现一个奇怪的指标,就是history list length这个指标,非常高,达到了上万的级别,这个指标就是数据undo log多版本快照链表长度,一般会根据多版本快照链条的自动purge清理机制,所以不会很高,如果过高,很可能是有的事务长时间运行,不能被purge清理,才导致这个history list length个值过高。

结果是因为后台跑了一个定时任务,开了一个事务,然后一个事务里删除上千万数据,导致这个事务一直在运行,因为删除的时候,仅仅加了一个删除标记,这时候同事运行的其他事务里,查询的时候可能要把千万条数据都要扫描一遍,因为发现是删除,然后继续往下扫描,所以才会导致这么慢。

大型电商网站的上亿数据量的用户表进行水平拆分

一般Mysql单表数据量不要超过1000w,最好是在500w以内,如果能控制在100w以内,性能基本不会有太大的问题。

那么如果有几千万数据量,那么就要把这个表拆分成比如100张表,那么每张表也就几十万数据而已,其次可以把这100个表分散到多台数据库服务器上去,一般一亿行数据,大致在1GB到几个GB之间的范围。

所以综上所述,可以完全分配两个数据库服务器,放两个库,然后100张表均匀分散在2台服务器上就可以了,分的时候需要指定一个字段来分,一般来说指定userid,根据用户id进行hash之后,对表取模,路由到一个表里去,这样就可以让数据均匀分散。

如果登陆的时候,没有userid,而是根据username,那么可以建立一个索引映射表,也就是搞一个表结构为(username,userid)的索引映射表,把username和userid一一映射,然后针对username再做一次分库分表。

那么用户登录的时候,就可以根据username先去索引映射表里查找对应的userid,然后按照userid分库分表到一个表里去,找到完整数据即可,虽然性能上有所损耗,但是比放在一个表里的性能要高得多。

如果要针对不同的字段,手机号,住址,年龄,性别等,那么就要对你的用户数据表进行binlog监听,把搜索的所有字段同步到ElasticSearch里,建立好搜索的索引,然后在定位到一批userid。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值