因为磁盘随机读写的性能是最差的,所以直接更新磁盘文件,必然导致我们的数据库完全无法抗下任何一点点稍微高 并发一点的场景
所以MySQL才设计了如此复杂的一套机制,通过内存里更新数据,然后写redo log以及事务提交,后台线程不定时刷 新内存里的数据到磁盘文件里 通过这种方式保证,你每个更新请求,尽量就是更新内存,然后顺序写日志文件。 更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是比较高的,因为顺序写磁盘文件,他的性能要远 高于随机读写磁盘文件。
这样每次都是一条数据一条数据的加载到内存里去更新,大家觉得效率 很明显是不高的 所以innodb存储引擎在这里引入了一个数据页的概念,也就是把数据组织成一页一页的概念,每一页有16kb,然后每 次加载磁盘的数据到内存里的时候,是至少加载一页数据进去,甚至是多页数据进去
在建表的时候,就指定一个行存储的格式,也可以后续修改行存储的格式。这里指定了一个COMPACT行存储 格式,在这种格式下,每一行数据他实际存储的时候,大概格式类似下面这样: 变长字段的长度列表,null值列表,数据头,column01的值,column02的值,column0n的值…
每一行数据,他其实存储的时候都会有一些头字段对这行数据进行一定的描述,然后再放上他这一行数据每一列 的具体的值,这就是所谓的行格式。除了COMPACT以外,还有其他几种行存储格式,基本都基本都大同小异
来假设⼀下,现在有⼀⾏数据,他的⼏个字段的类型为VRACHAR(10),CHAR(1),CHAR(1),那么 他第⼀个字段是VARCHAR(10),这个长度是可能变化的,所以这⼀⾏数据可能就是类似于:hello a a,这样⼦,第⼀ 个字段的值是“hello”,后⾯两个字段的值都是⼀个字符,就是⼀个a 然后另外⼀⾏数据,同样也是这⼏个字段,他的第⼀个字段的值可能是“hi”,后⾯两个字段也是“a”,所以这⼀⾏数据可 能是类似于:hi a a。⼀共三个字段,第⼀个字段的长度是是不固定的,后⾯两个字段的长度都是固定的1个字符。
那么现在,我们来假设你把上述两条数据写⼊了⼀个磁盘⽂件⾥,两⾏数据是挨在⼀起的,那么这个时候在⼀个磁盘 ⽂件⾥可能有下⾯的两⾏数据:
hello a a hi a a
最终落地到磁盘⾥的时候,都是上⾯那种样⼦的,⼀⼤坨数据放在⼀个 磁盘⽂件⾥都挨着存储的。
假如现在你要读取hello a a这⾏数据,第⼀个问题就是,从这个磁盘⽂件⾥读取的时候,到底哪些内容是⼀⾏数据?
我不知道啊! 因为这个表⾥的第⼀个字段是VARCHAR(10)类型的,第⼀个字段的长度是多少我们是不知道的! 所以有可能你读取出来“hello a a hi”是⼀⾏数据,也可能是你读取出来“hello a”是⼀⾏数据,你在不知道⼀⾏数据的每 个字段到底是多少长度的情况下,胡乱的去读取是不现实的,根本不知道磁盘⽂件⾥混成⼀坨的数据⾥,哪些数据是 你要读取的⼀⾏?
要在存储每⼀⾏数据的时候,都保存⼀下他的变长字段的长度列表,这样才能解决⼀⾏数据的读取问题。 也就是说,你在存储“hello a a”这⾏数据的时候,要带上⼀些额外的附加信息,⽐如第⼀块就是他⾥⾯的变长字段的长 度列表
也就是说,这个hello是VARCHAR(10)类型的变长字段的值,那么这个“hello”字段值的长度到底是多少? 我们看到“hello”的长度是5,⼗六进制就是0x05,所以此时会在“hello a a”前⾯补充⼀些额外信息,⾸先就是变长字段 的长度列表,你会看到这⾏数据在磁盘⽂件⾥存储的时候,其实是类似如下的格式:0x05 null值列表 数据头 hello a a。 你这⾏数据存储的时候应该是如上所⽰的! 这个时候假设你有两⾏数据,还有⼀⾏数据可能就是:0x02 null值列表 数据头 hi a a,两⾏数据放在⼀起存储在磁盘 ⽂件⾥,看起来是如下所⽰的: 0x05 null值列表 数据头 hello a a 0x02 null值列表 数据头 hi a a
假设此时你要读取“hello a a”这⾏数据,你⾸先会知道这个表⾥的三个字段的类型是VARCHAR(10) CHAR(1) CHAR(1),那么此时你先要
读取第⼀个字段的值,那么第⼀个字段是变长的,到底他的实际长度是多少呢?
此时你会发现第⼀⾏数据的开头有⼀个变长字段的长度列表,⾥⾯会读取到⼀个0x05这个⼗六进制的数字,发现第⼀ 个变长字段的长度是5,于是按照长度为5,读取出来第⼀个字段的值,就是“hello” 接着你知道后续两个字段都是CHAR(1),长度都是固定的1个字符,于是此时就依次按照长度为1读取出来后续两个字 段的值,分别是“a”“a”,于是最终你会读取出来“hello a a”这⼀⾏数据!
⽐如⼀⾏数据有VARCHAR(10) VARCHAR(5) VARCHAR(20) CHAR(1) CHAR(1)
此时在磁盘中存储的,必须在他开头的变长字段长度列表中存储⼏个变长字段的长度,⼀定要注意⼀点,他这⾥是逆 序存储的! 也就是说先存放VARCHAR(20)这个字段的长度,然后存放VARCHAR(5)这个字段的长度,最后存放VARCHAR(10)这 个字段的长度。 现在hello hi hao三个字段的长度分别是0x05 0x02 0x03,但是实际存放在变长字段长度列表的时候,是逆序放的,所 以⼀⾏数据实际存储可能是下⾯这样的: 0x03 0x02 0x05 null值列表 头字段 hello hi hao a a
实际在磁盘上存储数据的时候,⼀⾏数据⾥的NULL值是肯定不会直接按照字符串的⽅式存放在磁盘上浪费空间 的。
⼀个假想出来的客户表,⾥⾯有5个字段,分别为name、address、genderjob、school,就代表了客 户的姓名、地址、性别、⼯作以及学校。 其中有4个变长字段,还有⼀个定长字段,然后第⼀个name字段是声明了NOT NULL的,就是不能为NULL,其他4个 字段都可能是NULL的。 那么现在我们来假设这个表⾥有如下⼀⾏数据,现在来看看,他在磁盘上是怎么来存储的:“jack NULL m NULL xx_school”,
⽐如上⾯4个字段都允许为NULL,每个⼈都会有⼀个bit位,这⼀⾏数据的值是“jack NULL m NULL xx_school”,然后 其中2个字段是null,2个字段不是null,所以4个bit位应该是:1010 但是实际放在NULL值列表的时候,他是按逆序放的,所以在NULL值列表⾥,放的是:0101,整体这⼀⾏数据看着是 下⾯这样的 0x09 0x04 0101 头信息 column1=value1 column2=value2 … columnN=valueN 另外就是他实际NULL值列表存放的时候,不会说仅仅是4个bit位,他⼀般起码是8个bit位的倍数,如果不⾜8个bit位就 ⾼位补0,所以实际存放看起来是如下的: 0x09 0x04 00000101 头信息 column1=value1 column2=value2 … columnN=valueN
磁盘数据存储格式: 0x09 0x04 00000101 头信息 column1=value1 column2=value2 … columnN=valueN ⾸先他必然要把变长字段长度列表和NULL值列表读取出来,通过综合分析⼀下,就知道有⼏个变长字段,哪⼏个变长 字段是NULL,因为NULL值列表⾥谁是NULL谁不是NULL都⼀清⼆楚。
此时就可以从变长字段长度列表中解析出来不为NULL的变长字段的值长度,然后也知道哪⼏个字段是NULL的,此时 根据这些信息,就可以从实际的列值存储区域⾥,把你每个字段的值读取出来了。 如果是变长字段的值,就按照他的值长度来读取,如果是NULL,就知道他是个NULL,没有值存储,如果是定长字 段,就按照定长长度来读取,这样就可以完美的把你⼀⾏数据的值都读取出来了!
每⼀⾏数据都有⼀些数据头,不同的数据头 都是⽤来描述这⾏数据的⼀些状态和附加信息的
有⼀个bit位是delete_mask,他标识的是这⾏数据是否被删除了,其实看到这个bit位,很多⼈可能已经反 映过来了,这么说在MySQL⾥删除⼀⾏数据的时候,未必是⽴马把他从磁盘上清理掉,
下⼀个bit位是min_rec_mask,这他的含义说在B+树⾥每⼀层的⾮叶⼦节点⾥的最⼩值都有这个标记。
有4个bit位是n_owned,他其实就是记录了⼀个记录数,这个记录数的作⽤
接着有13个bit位是heap_no,他代表的是当前这⾏数据在记录堆⾥的位置,
然后是3个bit位的record_type,这就是说这⾏数据的类型,0代表的是普通类型,1代表的是B+树⾮叶⼦节点,2代表的是最⼩值数据,3代表的是最⼤值数据
最后一个16位bit,指向下一跳数据的指向
⽐如我们之前说了⼀个例⼦,有⼀⾏数据是“jack NULL m NULL xx_school”,那么他真实存储⼤致如下所⽰: 0x09 0x04 00000101 0000000000000000000010000000000000011001 jack m xx_school 刚开始先是他的变长字段的长度,⽤⼗六进制来存储,然后是NULL值列表,指出了谁是NULL,接着是40个bit位的数据头,然 后是真实的数据值,就放在后⾯。 在读取这个数据的时候,他会根据变长字段的长度,先读取出来jack这个值,因为他的长度是4,就读取4个长度的数据,jack 就出来了; 然后发现第⼆个字段是NULL,就不⽤读取了; 第三个字段是定长字段,直接读取1个字符就可以了,就是m这个值; 第四个字段是NULL,不⽤读取了; 第五个字段是变长字段长度是9,读取出来xx_school就可以了。
实际上字符串这些东西都是根据我们数据库指定的字符集编码,进⾏编码之后再存储的,所以⼤致看起来⼀⾏数据是如下所⽰ 的: 0x09 0x04 00000101 0000000000000000000010000000000000011001 616161 636320 6262626262 ⼤家会看到上⾯,我们的字符串和其他类型的数值最终都会根据字符集编码,搞成⼀些数字和符号存储在磁盘上 所以其实⼀⾏数据是如何存储的,我相信⼤家就都已经了解的很清晰了,
⾏溢出,就是说⼀⾏数据存储的内容太多了,⼀个数据页都放不下了,此时只能溢出这个数据 页,把数据溢出存放到其他数据页⾥去,那些数据页就叫做溢出页。 包括其他的⼀些字段类型都是⼀样的,⽐如TEXT、BLOB这种类型的字段,都有可能出现溢出,然后⼀⾏数据就会存储在多个 数据页⾥。
当我们在数据库⾥插⼊⼀⾏数据的时候,实际上是在内存⾥插⼊⼀个有复杂存储结构的⼀⾏ 数据,然后随着⼀些条件的发⽣,这⾏数据会被刷到磁盘⽂件⾥去。 在磁盘⽂件⾥存储的时候,这⾏数据也是按照复杂的存储结构去存放的。 ⽽且每⼀⾏数据都是放在数据页⾥的,如果⼀⾏数据太⼤了,就会产⽣⾏溢出问题,导致⼀⾏数据溢出到多个数据页⾥去,那 么这⾏数据在Buffer Pool可能就是存在于多个缓存页⾥的,刷⼊到磁盘的时候,也是⽤磁盘上的多个数据页来存放这⾏数据 的。
实⼀个数据页拆分成了很多个部分,⼤体上来说包含了⽂件头、数据页头、最⼩记录和最⼤记录、多 个数据⾏、空闲空间、数据页⽬录、⽂件尾部。
假设我们现在要插⼊⼀⾏数据,此时数据库⾥可是⼀⾏数据都没有的,那么此时是不是应该先 是从磁盘上加载⼀个空的数据页到缓存页⾥去?
所以此时在缓存页⾥插⼊⼀条数据,实际上就是在数据⾏那个区域⾥插⼊⼀⾏数据,然后空闲区域的空间会减少⼀ 些,此时当缓存页⾥插⼊了⼀⾏数据之后
接着你就可以不停的插⼊数据到这个缓存页⾥去,直到他的空闲区域都耗尽了,就是这个页满了,此时数据⾏区域内 可能有很多⾏数据,空闲区域就没了。
在更新缓存页的时候,其实在lru不停的变换位置,肯定也会在flush链表,最终通过一定通过io线程定时将lru链表跟flush链表刷到磁盘里面
有的表空间,⽐如系统表空间可能对应的是多个磁盘⽂件,有的我们⾃⼰创建的表对应的表空间可能就是对应了⼀个 “表名.ibd”数据⽂件。 然后在表空间的磁盘⽂件⾥,其实会有很多很多的数据页,因为⼤家都知道⼀个数据页不过就是16kb⽽已,总不可能 ⼀个数据页就是⼀个磁盘⽂件吧。
我们平时创建的那些表都是有对 应的表空间的,每个表空间就是对应了磁盘上的数据⽂件,在表空间⾥有很多组数据区,⼀组数据区是256个数据区, 每个数据区包含了64个数据页,是1mb. 然后表空间的第⼀组数据区的第⼀个数据区的头三个数据页,都是存放特殊信息的; 表空间的其他组数据区的第⼀个数据区的头两个数据页,也都是存放特殊信息的。
所以磁盘上的各个表空间的数据⽂件⾥是通过数据区的概念,划分了很多很多的数据页的,因此当我们需要执⾏crud 操作的时候,说⽩了,就是从磁盘上的表空间的数据⽂件⾥,去加载⼀些数据页出来到Buffer Pool的缓存页⾥去使 ⽤。 我下⾯给出了⼀张图,图⾥就给出了⼀个表空间内部的存储结构,包括⼀组⼀组的数据区,每⼀组数据区是256个数据 区,然后⼀个数据区是64个数据页。
当我们在数据库中执⾏crud的时候,你必须先把磁盘⽂件⾥的⼀个数据页加载到内 存的Buffer Pool的⼀个缓存页⾥去,然后我们增删改查都是针对缓存页⾥的数据来执⾏的! 所以假设此时我们要插⼊⼀条数据,那么是选择磁盘⽂件⾥的哪个数据页加载到缓存页⾥去呢?
其实这个时候会看看你往哪个表⾥插⼊数据?然后肯定得根据表找到⼀个表空间啊!找到表空间之后,就可以定位到对应的磁盘⽂件啊!有了磁盘⽂件之后,就可以从⾥⾯找⼀个extent组,找⼀个 extent,接着从⾥⾯找⼀个数据页出来!这个数据也可能是空的,也可能已经放了⼀些数据⾏了! 然后就可以把这个数据页从磁盘⾥完整加载出来,放⼊Buffer Pool的缓存页⾥了!
在读取⼀个数据页的时候,你就可以通过随机读写的⽅式来了
MySQL在工作的时候,尤其是执行增删改操作的时候,肯定会先从表空间的磁盘文件里读取数据页出来,这个过程其实就是典型的磁盘随机读操作
有一个磁盘文件,里面有很多数据页,然后你可能需要在一个随机的位置读取一个数据页到缓存,这就是磁盘随机读
因为你要读取的这个数据页可能在磁盘的任意一个位置,所以你在读取磁盘里的数据页的时候只能是用随机读的这种方式。
磁盘随机读的性能是比较差的,所以不可能每次更新数据都进行磁盘随机读,必须是读取一个数据页之后放到Buffer Pool的缓存里去,下次要更新的时候直接更新Buffer Pool里的缓存页。对于磁盘随机读来说,主要关注的性能指标是IOPS和响应延迟
OPS就是说底层的存储系统每秒可以执行多少次磁盘读写操作,比如你底层磁盘支持每秒执行1000个磁盘随机读写操作和每秒执行200个磁盘随机读写操作,对你的数据库的性能影响其实是非常大的。
这个IOPS指标如何观察,之前也讲过了,大家在压测的时候可以观察一下。这个指标实际上对数据库的crud操作的QPS影响是非常大的,因为他在某种程度上几乎决定了你每秒能执行多少个SQL语句,底层存储的IOPS越高,你的数据库的并发能力就越高。
另外一个就是磁盘随机读写操作的响应延迟,也是对数据库的性能有很大的影响。因为假设你的底层磁盘支持你每秒执行200个随机读写操作,但是每个操作是耗费10ms完成呢,还是耗费1ms完成呢,这个其实也是有很大的影响的,决定了你对数据库执行的单个crud SQL语句的性能。
比如你一个SQL语句发送过去,他磁盘要执行随机读操作加载多个数据页,此时每个磁盘随机读响应时间是50ms,那么此时可能你的SQL语句要执行几百ms,但是如果每个磁盘随机读仅仅耗费10ms,可能你的SQL就执行100ms就行了。
所以其实一般对于核心业务的数据库的生产环境机器规划,我们都是推荐用SSD固态硬盘的,而不是机械硬盘,因为SSD固态硬盘的随机读写并发能力和响应延迟要比机械硬盘好的多,可以大幅度提升数据库的QPS和性能
所谓顺序写,就是说在一个磁盘日志文件里,一直在末尾追加日志,
写redo log日志的时候,其实是不停的在一个日志文件末尾追加日志的,这就是磁盘顺序写。
磁盘顺序写的性能其实是很高的,某种程度上来说,几乎可以跟内存随机读写的性能差不多,尤其是在数据库里其实也用了os cache机制,就是redo log顺序写入磁盘之前,先是进入os cache,就是操作系统管理的内存缓存里。
所以对于这个写磁盘日志文件而言,最核心关注的是磁盘每秒读写多少数据量的吞吐量指标,就是说每秒可以写入磁盘100MB数据和每秒可以写入磁盘200MB数据,对数据库的并发能力影响也是极大的。
因为数据库的每一次更新SQL语句,都必然涉及到多个磁盘随机读取数据页的操作,也会涉及到一条redo log日志文件顺序写的操作。所以磁盘读写的IOPS指标,就是每秒可以执行多少个随机读写操作,以及每秒可以读写磁盘的数据量的吞吐量指标,就是每秒可以写入多少redo log日志,整体决定了数据库的并发能力和性能。
包括你磁盘日志文件的顺序读写的响应延迟,也决定了数据库的性能,因为你写redo log日志文件越快,那么你的SQL语句性能就越高。
是redo log⾥本质上记录的就是在对某个表空间的某个数据页的某个偏移量的地⽅修改了⼏ 个字节的值,具体修改的值是什么,他⾥⾯需要记录的就是表空间号+数据页号+偏移量+修改⼏个字节的值+具体的值
平时我们执⾏CRUD的时候,从磁盘加载数据页到buffer pool的缓存页⾥去,然后对缓存页执⾏增删改,同时还会写 redo log到⽇志⽂件⾥去,后续不定时把缓存页刷回磁盘⽂件⾥去,
redo log到底是如何通过内存缓冲之后,再进⼊磁盘⽂件⾥去的,这就涉及到了⼀个新的组件, redo log buffer,他就是MySQL专门设计了⽤来缓冲redo log写⼊的
这个redo log buffer其实就是MySQL在启动的时候,就跟操作系统申请的⼀块连续内存空间,⼤概可以认为相当于是buffer pool吧。那个buffer pool是申请之后划分了N多个空的缓存页和⼀些链表结构,让你把磁盘上的数据页加载到内存⾥来的。 redo log buffer也是类似的,他是申请出来的⼀⽚连续内存,然后⾥⾯划分出了N多个空的redo log block
(1)如果写⼊redo log buffer的⽇志已经占据了redo log buffer总容量的⼀半了,也就是超过了8MB的redo log在缓冲 ⾥了,此时就会把他们刷⼊到磁盘⽂件⾥去
(2)⼀个事务提交的时候,必须把他的那些redo log所在的redo log block都刷⼊到磁盘⽂件⾥去,只有这样,当事务 提交之后,他修改的数据绝对不会丢失,因为redo log⾥有重做⽇志,随时可以恢复事务做的修改
(3)后台线程定时刷新,有⼀个后台线程每隔1秒就会把redo log buffer⾥的redo log block刷到磁盘⽂件⾥去
(4)MySQL关闭的时候,redo log block都会刷⼊到磁盘⾥去
实际上默认情况下,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个。 所以默认情况下,⽬录⾥就两个⽇志⽂件,分别为ib_logfile0和ib_logfile1,每个48MB,最多就这2个⽇志⽂件,就是 先写第⼀个,写满了写第⼆个。那么如果第⼆个也写满了呢?别担⼼,继续写第⼀个,覆盖第⼀个⽇志⽂件⾥原来的 redo log就可以了。 所以最多这个redo log,mysql就给你保留了最近的96MB的redo log⽽已,不过这其实已经很多了,毕竟redo log真的 很⼩,⼀条通常就⼏个字节到⼏⼗个字节不等,96MB⾜够你存储上百万条redo log了!
万⼀要是你提交事务了,结果事务修改的缓存页还没来得及刷⼊磁盘上的数据⽂件,此时你MySQL关闭了或者是 宕机了,那么buffer pool⾥被事务修改过的数据就全部都丢失了!
只要有redo log,你重启MySQL之后完全是可以把那些修改了缓存页,但是缓存页还没来得及刷⼊磁盘的事务, 他们所对应的redo log都加载出来,在buffer pool的缓存页⾥重做⼀遍,就可以保证事务提交之后,修改的数据绝对不 会丢!
万⼀要是⼀个事务⾥的⼀通增删改操作执⾏到了⼀半,结果就回滚事务了呢?
所以在执⾏事务的时候,才必须引⼊另外⼀种⽇志,就是undo log回滚⽇志
这个回滚⽇志,他记录的东西其实⾮常简单,⽐如你要是在缓存页⾥执⾏了⼀个insert语句,那么此时你在undo log⽇ 志⾥,对这个操作记录的回滚⽇志就必须是有⼀个主键和⼀个对应的delete操作,要能让你把这次insert操作给回退 了。 那么⽐如说你要是执⾏的是delete语句,那么起码你要把你删除的那条数据记录下来,如果要回滚,就应该执⾏⼀个 insert操作把那条数据插⼊回去。 如果你要是执⾏的是update语句,那么起码你要把你更新之前的那个值记录下来,回滚的时候重新update⼀下,把你 之前更新前的旧值给他更新回去。 如果你要是执⾏的是select语句呢?不好意思,select语句压根⼉没有在buffer pool⾥执⾏任何修改,所以根本不需要 undo log!
INSERT语句的undo log的类型是TRX_UNDO_INSERT_REC,这个undo log⾥包含了以下⼀些东西:
会涉及到脏写、脏读、不可重复读、幻读,
脏写
脏写,就是我刚才明明写了⼀个数据值,结果过了⼀会⼉却没了!真是莫名其妙。 ⽽他的本质就是事务B去修改了事务A修改过的值,但是此时事务A还没提交,所以事务A随时会回滚,导致事务B修改 的值也没了,这就是脏写的定义
脏读
脏读,他的本质其实就是事务B去查询了事务A修改过的数据,但是此时事务A还没提交,所以事务A 随时会回滚导致事务B再次查询就读不到刚才事务A修改的数据了!这就是脏读。
⽆论是脏写还是脏读,都是因为⼀个事务去更新或者查询了另外⼀个还没提交的事务更新过的数 据。 因为另外⼀个事务还没提交,所以他随时可能会反悔会回滚,那么必然导致你更新的数据就没了,或者你之前查询到 的数据就没了,这就是脏写和脏读两种坑爹场景。
不可重复读
⼀个事务多次查询⼀条数据,结果每次读到 的值都不⼀样,这个过程中可能别的事务会修改这条数据的值,⽽且修改值之后事务都提交了,结果导致⼈家每次查 到的值都不⼀样,都查到了提交事务修改过的值,这就是所谓的不可重复读
幻读
幻读特指的是你查询到了之前查询没看到过的数据!此时就说你是幻读了
数据库的多事务并发问题,那么为了解决多事务并发问题,数据库才设计了事务隔离机 制、MVCC多版本隔离机制、锁机制,⽤⼀整套机制来解决多事务并发问题
注意的⼀点是,MySQL默认设置的事务隔离级别,都是RR级别的,⽽且MySQL的RR级别是可以避免幻读发⽣ 的。 这点是MySQL的RR级别的语义跟SQL标准的RR级别不同的,毕竟SQL标准⾥规定RR级别是可以发⽣幻读的,但是 MySQL的RR级别避免了! 也就是说,MySQL⾥执⾏的事务,默认情况下不会发⽣脏写、脏读、不可重复读和幻读的问题,事务的执⾏都是并⾏ 的,⼤家互相不会影响,我不会读到你没提交事务修改的值,即使你修改了值还提交了,我也不会读到的,即使你插 ⼊了⼀⾏值还提交了,我也不会读到的,总之,事务之间互相都完全不影响! 当然,要做到这么神奇和⽜叉的效果,MySQL是下了苦功夫的, 多版本并发控制隔离机制,依托这个MVCC机制,就能让RR级别避免不可重复读和幻读的问题
在@Transactional注解⾥是有⼀个isolation参数的,⾥⾯是可以设置事务隔离级别的,具体的设置⽅式如下: @Transactional(isolation=Isolation.DEFAULT),然后默认的就是DEFAULT值,这个就是MySQL默认⽀持什么隔离级 别就是什么隔离级别。 那MySQL默认是RR级别,⾃然你开发的业务系统的事务也都是RR级别的了。 但是你可以⼿动改成Isolation.READ_UNCOMMITTED级别,此时你就可以读到⼈家没提交事务修改的值了
我们每条数据其实都有两个隐藏字段,⼀个是trx_id,⼀个是roll_pointer,这个trx_id就是最近⼀次更新这 条数据的事务id,roll_pointer就是指向你了你更新这个事务之前⽣成的undo log。
多个事务 串⾏执⾏的时候,每个⼈修改了⼀⾏数据,都会更新隐藏字段txr_id和roll_pointer,同时之前多个数据快照对应的undo log,会通过roll_pinter指针串联起来,形成⼀个重要的版本链!
ReadView简单来说,就是你执⾏⼀个事务的时候,就给你⽣成⼀个ReadView,⾥⾯⽐较关键的东西有4个
通过这套ReadView+undo log⽇志链条的机 制,就可以保证事务A不会读到并发执⾏的事务B更新的值,只会读到之前最早的值。
如果被访问版本的trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
如果被访问版本的trx_id
属性值小于ReadView
中的min_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。
如果被访问版本的trx_id
属性值大于ReadView
中的max_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。
如果被访问版本的trx_id
属性值在ReadView
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
关键点在于每次查询都⽣成新的ReadView,那么如果 在你这次查询之前,有事务修改了数据还提交了,你这次查询⽣成的ReadView⾥,那个m_ids列表当然不包含这个已 经提交的事务了,既然不包含已经提交的事务了,那么当然可以读到⼈家修改过的值了。 这就是基于ReadView实现RC隔离级别的原理,希望⼤家好好仔细去体会,实际上,基于undo log多版本链条以及 ReadView机制实现的多事务并发执⾏的RC隔离级别、RR隔离级别,就是数据库的MVCC多版本并发控制机制。
RR级别下,你这个事务读⼀条数据,⽆论读多少次,都是⼀个值,别的事务修改数据之后哪 怕提交了,你也是看不到⼈家修改的值的,这就避免了不可重复读的问题。 同时如果别的事务插⼊了⼀些新的数据,你也是读不到的,这样你就可以避免幻读的问题
Buffer Pool就是数据库的⼀个内存组件,⾥⾯缓存了磁盘上的真实数据,然后我们的Java系统对数据库执⾏的增删改操 作,其实主要就是对这个内存数据结构中的缓存数据执⾏的。
InnoDB
的为了缓存磁盘中的页,在MySQL
服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool
(中文名是缓冲池
)。 默认情况下Buffer Pool
只有128M
大小。当然如果你嫌弃这个128M
太大或者太小,可以在启动服务器的时候配置innodb_buffer_pool_size
参数的值,它表示Buffer Pool
的大小,就像这样:
[server]
innodb_buffer_pool_size = 268435456
其中,268435456
的单位是字节,也就是我指定Buffer Pool
的大小为256M
。需要注意的是,Buffer Pool
也不能太小,最小值为5M
(当小于该值时会自动设置成5M
)。
Buffer Pool中存放的⼀个⼀个的数据页,我们通常叫做缓存页,因为毕竟Buffer Pool是⼀个缓冲池,⾥⾯的数据都是从磁盘 缓存到内存去的。 ⽽Buffer Pool中默认情况下,⼀个缓存页的⼤⼩和磁盘上的⼀个数据页的⼤⼩是⼀⼀对应起来的,都是16KB。
Buffer Pool
中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB
。为了更好的管理这些在Buffer Pool
中的缓存页,设计InnoDB
的大叔为每一个缓存页都创建了一些所谓的控制信息
,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool
中的地址、链表节点信息、一些锁信息以及LSN
信息,。
当我们最初启动MySQL
服务器的时候,需要完成对Buffer Pool
的初始化过程,就是先向操作系统申请Buffer Pool
的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool
中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool
中。那么问题来了,从磁盘上读取一个页到Buffer Pool
中的时候该放到哪个缓存页的位置呢?或者说怎么区分Buffer Pool
中哪些缓存页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的,这个时候缓存页对应的控制块
就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作**free链表
(或者说空闲链表)**。刚刚完成初始化的Buffer Pool
中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表
中,假设该Buffer Pool
中可容纳的缓存页数量为n
,那增加了free链表
的效果图就是这样的:
从图中可以看出,我们为了管理好这个free链表
,特意为这个链表定义了一个基节点
,里边儿包含着链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为Buffer Pool
申请的一大片连续内存空间之内,而是单独申请的一块内存空间。
有了这个free链表
之后事儿就好办了,每当需要从磁盘中加载一个页到Buffer Pool
中时,就从free链表
中取一个空闲的缓存页,并且把该缓存页对应的控制块
的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表
节点从链表中移除,表示该缓存页已经被使用了~
我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool
中,如果该页已经在Buffer Pool
中的话直接使用就可以了。
我们怎么知道该页在不在Buffer Pool
中呢?
所以我们可以用表空间号 + 页号
作为key
,缓存页
作为value
创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号
看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表
中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
如果我们修改了Buffer Pool
中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页
(英文名:dirty page
)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,
我们怎么知道Buffer Pool
中哪些页是脏页
,哪些页从来没被修改过呢?
创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表
。链表的构造和free链表
差不多,假设某个时间点Buffer Pool
中的脏页数量为n
,那么对应的flush链表
就长这样:
Buffer Pool
对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool
大小,也就是free链表
中已经没有多余的空闲缓存页的时候岂不是很尴尬,发生了这样的事儿该咋办?
当然是把某些旧的缓存页从Buffer Pool
中移除,然后再把新的页放进来喽
你必须把⼀个缓存页⾥被修改过的数据,给他刷到磁盘上的数据页⾥去,然后这个缓存页就可以清空了, 让他重新变成⼀个空闲的缓存页。
LRU就是Least Recently Used,最近最少使⽤的意思。 通过这个LRU链表,我们可以知道哪些缓存页是最近最少被使⽤的,那么当你缓存页需要腾出来⼀个刷⼊磁盘的时 候,不就可以选择那个LRU链表中最近最少被使⽤的缓存页了么?
LRU链表的机制也很简单,只要是刚从磁盘上加载数据到缓存页⾥去,这个缓存页就放⼊LRU链表的头部,后续如 果对任何⼀个缓存页访问了,也把缓存页从LRU链表中移动到头部去。
LRU机制在实际运⾏过程中,是会存在巨⼤的隐患的。
情况一:InnoDB
提供了一个看起来比较贴心的服务——预读
(英文名:read ahead
)。所谓预读
,就是InnoDB
认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool
中。
说的就是当你从磁盘上加载⼀个数据页的时候,他可 能会连带着把这个数据页相邻的其他数据页,也加载到缓存⾥去!,会大大降低缓存命中率。
情况二:有的小伙伴可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有WHERE子句的查询)。
扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页
,当需要访问这些页时,会把它们统统都加载到Buffer Pool
中,这也就意味着吧唧一下,Buffer Pool
中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool
的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool
中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool
的使用,从而大大降低了缓存命中率。
总结一下上边说的可能降低Buffer Pool
的两种情况:
Buffer Pool
中的页不一定被用到。Buffer Pool
时,可能会把那些使用频率非常高的页从Buffer Pool
中淘汰掉。因为有这两种情况的存在,所以设计InnoDB
的大叔把这个LRU链表
按照一定比例分成两截,分别是:
一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据
,或者称young区域
。
另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据
,或者称old区域
。
大家要特别注意一个事儿:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于InnoDB
存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct
的值来确定old
区域在LRU链表
中所占的比例
实际上这个时候,缓存页会被放在冷数据区域的链表头部,也就是第⼀次把⼀个数据页加载到缓存 页之后,这个缓存页实际上是被放也就是冷数据区域的链表头部位置。
MySQL设定了⼀个规则,他设计了⼀个innodb_old_blocks_time参数,默认值1000,也就是1000毫秒 也就是说,必须是⼀个数据页被加载到缓存页之后,在1s之后,你访问这个缓存页,他才会被挪动到热数据区域的链 表头部去。 因为假设你加载了⼀个数据页到缓存去,然后过了1s之后你还访问了这个缓存页,说明你后续很可能会经常要访问 它,这个时间限制就是1s,因此只有1s后你访问了这个缓存页,他才会给你把缓存页放到热数据区域的链表头部去。 所以我们看下⾯的图,⽂字说明做了⼀点改动,是数据加载到缓存页之后过了1s,你再访问这个缓存页,他就会被放 ⼊热数据区域的链表头部,如果是你数据刚加载到缓存页,在1s内你就访问缓存页,此时他是不会把这个缓存页放⼊ 热数据区域的头部的。
有了这个被划分成young
和old
区域的LRU
链表之后,设计InnoDB
的大叔就可以针对我们上边提到的两种可能降低缓存命中率的情况进行优化了:
针对预读的页面可能不进行后续访情况的优化
设计InnoDB
的大叔规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool
却不进行后续访问的页面就会被逐渐从old
区域逐出,而不会影响young
区域中被使用比较频繁的缓存页。
针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化
在对某个处在old
区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time
控制的,
这个innodb_old_blocks_time
的默认值是1000
,它的单位是毫秒,也就意味着对于从磁盘上被加载到LRU
链表的old
区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s
(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s
),那么该页是不会被加入到young
区域的, 这里需要注意的是,如果我们把innodb_old_blocks_time
的值设置为0
,那么每次我们访问一个页面时就会把该页面放到young
区域的头部。
综上所述,正是因为将LRU
链表划分为young
和old
区域这两个部分,又添加了innodb_old_blocks_time
这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old
区域,而不影响young
区域中的缓存页。
LRU链表
这就说完了么?没有,早着呢~ 对于young
区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表
的头部,这样开销是不是太大啦,毕竟在young
区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表
进行节点移动操作是不是不太好啊?是的,为了解决这个问题其实我们还可以提出一些优化策略,比如只有被访问的缓存页位于young
区域的1/4
的后边,才会被移动到LRU链表
头部,这样就可以降低调整LRU链表
的频率,从而提升性能(也就是说如果某个缓存页对应的节点在young
区域的1/4
中,再次访问该缓存页时也不会将其移动到LRU
链表头部)。
后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:
从LRU链表
的冷数据中刷新一部分页面到磁盘。
后台线程会定时从LRU链表
尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth
来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为BUF_FLUSH_LRU
。
从flush链表
中刷新一部分页面到磁盘。
后台线程也会定时从flush链表
中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为BUF_FLUSH_LIST
。
有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool
时没有可用的缓存页,这时就会尝试看看LRU链表
尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表
尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE
。
当然,有时候系统特别繁忙时,也可能出现用户线程批量的从flush链表
中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为(毕竟磁盘的速度满的要死),这属于一种迫不得已的情况,
这个时候如果要从磁盘加载数据页到⼀个空闲缓存页中,此时就会从LRU链表的冷数据区域的尾部找到⼀个缓存页, 他⼀定是最不经常使⽤的缓存页!然后把他刷⼊磁盘和清空,然后把数据页加载到这个腾出来的空闲缓存页⾥去!
多个线程并发访问一个Buffer pool必然是要加锁的,让一个线程完成一系列操作,比如加载数据页到缓存,更新free链表,更新lru链表,释放锁,然后接着下一个线程在执行一系列操作
通过多个Buffer Pool实例,使多线程并发访问的时候性能得到提升,因为多个线程可以在不同的buffer pool加锁和执行自己的操作,并发只想
我们上边说过,Buffer Pool
本质是InnoDB
向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool
中的各种链表都需要加锁处理啥的,在Buffer Pool
特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool
可能会影响请求的处理速度。所以在Buffer Pool
特别大的时候,我们可以把它们拆分成若干个小的Buffer Pool
,每个Buffer Pool
都称为一个实例
,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,独立的吧啦吧啦,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instances
的值来修改Buffer Pool
实例的个数,比方说这样:
[server]
innodb_buffer_pool_instances = 2
这样就表明我们要创建2个Buffer Pool
实例,示意图就是这样:
设计InnoDB
的大叔们规定:当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。而我们鼓励在Buffer Pool
大小或等于1G的时候设置多个Buffer Pool
实例。
比如buffer pool原来是8g的,现在运行期间调整到16g,是怎么实现的?
需要想操作系统申请一块16g的连续内存,把现在所有的缓存页,描述数据,各种链表,都拷贝到16g的内存里面,这个过程是极为耗时的, 性能很低,不可以接受
mysql设计了一种chunk机制,buffer pool由很多chunk组成,大小通过参数设置,默认128m,比如我们buffer pool总大小是8g,有4个buffer pool,那么每个buffer pool是2g,此时米格buffer pool是由128m的chunk组成,也就是每个buffer pool会有16个chunk,每个buffer pool的每个chunk会有一系列的描述数据和缓存页,每个buffer pool的多个chunk共享一套free,flush,lru这些链表,
居于chunk机制,就可以动态的调整buffer pool大小了,比如现在是8g的,需要调整到16g,这个时候只要申请一系列的128mb大小的chunk就可以了,只要每个chunk是连续的128g的内存就可以了,有了这个chunk就不需要额外申请16g的内存
结合buffer pool 的运行原理,要避免这些问题,说白了就是避免缓存页频繁的被使用完毕,实际上使用缓存页的时候,会有一个后台线程定时的把l ru的链表的冷数据缓存页刷到磁盘中,本质上缓存页一边使用,一边会被后台定时任务释放掉一批
所以如果你的缓存页使⽤的很快,然后后台线程释放缓存页的速度很慢,那么必然导致你频繁发现缓存页被使⽤完 了。但是缓存页被使⽤的速度你是没法控制的,因为那是由你的Java系统访问数据库的并发程度来决定的,你⾼并发 访问数据库,缓存页必然使⽤的很快了! 然后你后台线程定时释放⼀批缓存页,这个过程也很难去优化,因为你要是释放的过于频繁了,那么后台线程执⾏磁 盘IO过于频繁,也会影响数据库的性能。 所以这⾥的关键点就在于,你的buffer pool有多⼤! 如果你的数据库要抗⾼并发的访问,那么你的机器必然要配置很⼤的内存空间,起码是32GB以上的,甚⾄64GB或者 128GB。此时你就可以给你的buffer pool设置很⼤的内存空间,⽐如20GB,48GB,甚⾄80GB。 这样的话,你会发现⾼并发场景下,数据库的buffer pool缓存页频繁的被使⽤,但是你后台线程也在定时释放⼀些缓存 页,那么综合下来,空闲的缓存页还是会以⼀定的速率逐步逐步的减少。 因为你的buffer pool内存很⼤,所以空闲缓存页是很多很多的,即使你的空闲缓存页逐步的减少,也可能需要较长时间 才会发现缓存页⽤完了,此时才会出现⼀次crud操作执⾏的时候,先刷缓存页到磁盘,再读取数据页到缓存页来,这 种情况是不会出现的太频繁的! ⽽⼀旦你的数据库⾼峰过去,此时缓存页被使⽤的速率下降了很多很多,然后后台线程会定是基于flush链表和lru链表 不停的释放缓存页,那么你的空闲缓存页的数量⼜会在数据库低峰的时候慢慢的增加了。 所以线上的MySQL在⽣产环境中,buffer pool的⼤⼩、buffer pool的数量,这都是要⽤⼼设置和优化的,因为对 MySQL的性能和并发能⼒,都会有较⼤的影响。
所以通常来说,我们建议⼀个⽐较合理的、健康的⽐例,是给buffer pool设置你的机器内存的50%~60%左右 ⽐如你有32GB的机器,那么给buffer设置个20GB的内存,剩下的留给OS和其他⼈来⽤,这样⽐较合理⼀些。
buffer pool总⼤⼩=(chunk⼤⼩ * buffer pool数量)的2倍数
⽐如默认的chunk⼤⼩是128MB,那么此时如果你的机器的内存是32GB,你打算给buffer pool总⼤⼩在20GB左右,那 么你得算⼀下,此时你的buffer pool的数量应该是多少个呢? 假设你的buffer pool的数量是16个,这是没问题的,那么此时chunk⼤⼩ * buffer pool的数量 = 16 * 128MB = 2048MB,然后buffer pool总⼤⼩如果是20GB,此时buffer pool总⼤⼩就是2048MB的10倍,这就符合规则了。
当然,此时你可以设置多⼀些buffer pool数量,⽐如设置32个buffer pool,那么此时buffer pool总⼤⼩(20GB)就是 (chunk⼤⼩128MB * 32个buffer pool)的5倍,也是可以的。 那么此时你的buffer pool⼤⼩就是20GB,然后buffer pool数量是32个,每个buffer pool的⼤⼩是640MB,然后每个 buffer pool包含5个128MB的chunk,算下来就是这么⼀个结果了。
总结,就是说你的数据库在⽣产环境运⾏的时候,你必须根据机器的内存设置合理的buffer pool的⼤ ⼩,然后设置buffer pool的数量,这样的话,可以尽可能的保证你的数据库的⾼性能和⾼并发能⼒。 然后在线上运⾏的时候,buffer pool是有多个的,每个buffer pool⾥多个chunk但是共⽤⼀套链表数据结构,然后执⾏ crud的时候,就会不停的加载磁盘上的数据页到缓存页⾥来,然后会查询和更新缓存页⾥的数据,同时维护⼀系列的 链表结构。 然后后台线程定时根据lru链表和flush链表,去把⼀批缓存页刷⼊磁盘释放掉这些缓存页,同时更新free链表。 如果执⾏crud的时候发现缓存页都满了,没法加载⾃⼰需要的数据页进缓存,此时就会把lru链表冷数据区域的缓存页 刷⼊磁盘,然后加载⾃⼰需要的数据页进来。 整个buffer pool的结构设计以及⼯作原理,就是上⾯我们总结的这套东西了,⼤家只要理解了这个,⾸先你对MySQL 执⾏crud的时候,是如何在内存⾥查询和更新数据的,你就彻底明⽩了。
去查看当前innodb⾥的⼀些具体情况,执⾏SHOW ENGINE INNODB STATUS就可以了。
磁盘太慢,用内存作为缓存很有必要。
Buffer Pool
本质上是InnoDB
向操作系统申请的一段连续的内存空间,可以通过innodb_buffer_pool_size
来调整它的大小。
Buffer Pool
向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓存页都是一一对应的,在填充足够多的控制块和缓存页的组合后,Buffer Pool
剩余的空间可能产生不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为碎片
。
InnoDB
使用了许多链表
来管理Buffer Pool
。
free链表
中每一个节点都代表一个空闲的缓存页,在将磁盘中的页加载到Buffer Pool
时,会从free链表
中寻找空闲的缓存页。
为了快速定位某个页是否被加载到Buffer Pool
,使用表空间号 + 页号
作为key
,缓存页作为value
,建立哈希表。
在Buffer Pool
中被修改的页称为脏页
,脏页并不是立即刷新,而是被加入到flush链表
中,待之后的某个时刻同步到磁盘上。
LRU链表
分为young
和old
两个区域,可以通过innodb_old_blocks_pct
来调节old
区域所占的比例。首次从磁盘上加载到Buffer Pool
的页会被放到old
区域的头部,在innodb_old_blocks_time
间隔时间内访问该页不会把它移动到young
区域头部。在Buffer Pool
没有可用的空闲缓存页时,会首先淘汰掉old
区域的一些页。
我们可以通过指定innodb_buffer_pool_instances
来控制Buffer Pool
实例的个数,每个Buffer Pool
实例中都有各自独立的链表,互不干扰。
X锁,也就是Exclude独占锁,当有⼀个事务加了独占锁之后,此时其他事务再要更新这⾏数据,都是要加独占 锁的,但是只能⽣成独占锁在后⾯等待。
当有⼈在更新数据的时候,其他的事务可以读取这⾏数据吗?
默认情况下需要加 锁吗? 答案是:不⽤ 因为默认情况下,有⼈在更新数据的时候,然后你要去读取这⾏数据,直接默认就是开启mvcc机制的。 也就是说,此时对⼀⾏数据的读和写两个操作默认是不会加锁互斥的,因为MySQL设计mvcc机制就是为了解决这个问 题,避免频繁加锁互斥。 此时你读取数据,完全可以根据你的ReadView,去在undo log版本链条⾥找⼀个你能读取的版本,完全不⽤去顾虑别 ⼈在不在更新。
MySQL⾸先⽀持⼀种共享锁,就是S锁,这个共享锁的语法如下:select * from table lock in share mode,你在⼀个查询语句后⾯加上lock in share mode,意思就是查询的时候对⼀⾏数据加共享锁
共享锁
,英文名:Shared Locks
,简称S锁
。在事务要读取一条记录时,需要先获取该记录的S锁
。
独占锁
,也常称排他锁
,英文名:Exclusive Locks
,简称X锁
。在事务要改动一条记录时,需要先获取该记录的X锁
。
所以我们说S锁
和S锁
是兼容的,S锁
和X锁
是不兼容的,X锁
和X锁
也是不兼容的,画个表表示一下就是这样:
兼容性 | X |
S |
---|---|---|
X |
不兼容 | 不兼容 |
S |
不兼容 | 兼容 |
有些人可能会以为当你执行增删改的时候默认加行锁,然后执行DDL语句的时候,比如alter table之类的语句,会默认在表级别加表锁。这么说也不太正确,但是也有一定的道理,因为确实你执行DDL的时候,会阻塞所有增删改操作;执行增删改的时候,会阻塞DDL操作。
但这是通过MySQL通用的元数据锁实现的,也就是Metadata Locks,但这还不是表锁的概念。因为表锁其实是InnoDB存储引擎的概念,InnoDB存储引擎提供了自己的表级锁,跟这里DDL语句用的元数据锁还不是一个概念。
只不过DDL语句和增删改操作,确实是互斥的,大家要知道这一点。
更新了Buffer Pool⾥的缓存页,缓存页就会变成脏页,之所以说他是脏页,就是因为缓存 页⾥的数据⽬前跟磁盘⽂件⾥的数据页的数据是不⼀样的,所以此时叫缓存页是脏页。
既然是脏页,那么就必然得有⼀个合适的时机要把那脏页给刷⼊到磁盘⽂件⾥去,之前我们其实就仔细分析过这个脏 页刷⼊磁盘的机制,他是维护了⼀个lru链表来实现的,通过lru链表,他知道哪些缓存页是最近经常被使⽤的。 那么后续如果你要加载磁盘⽂件的数据页到buffer pool⾥去了,但是此时并没有空闲的缓存页了,此时就必须要把部分 脏缓存页刷⼊到磁盘⾥去,此时就会根据lru链表找那些最近最少被访问的缓存页去刷⼊磁盘,
第一个可能:可能buffer pool的缓存页都满了,此时你执⾏⼀个SQL查询很多数据,⼀下⼦要把很多缓存页flush到磁盘上 去,刷磁盘太慢了,就会导致你的查询语句执⾏的很慢。 因为你必须等很多缓存页都flush到磁盘了,你才能执⾏查询从磁盘把你需要的数据页加载到buffer pool的缓存页⾥来
另外还有⼀种:可能你执⾏更新语句的时候,redo log在磁盘上的所有⽂件都写满了,此时需要回到第⼀个redo log⽂件覆盖 写,覆盖写的时候可能就涉及到第⼀个redo log⽂件⾥有很多redo log⽇志对应的更新操作改动了缓存页,那些缓存页 还没flush到磁盘,此时就必须把那些缓存页flush到磁盘,才能执⾏后续的更新语句,那你这么⼀等待,必然会导致更 新执⾏的很慢了
**尤其是在这⼀种刷脏页的情况下,因为redo log所有⽇志⽂件都写满了,此时会导致数据库直接hang死,**⽆法处理任何 更新请求,因为执⾏任何⼀个更新请求都必须要写redo log,此时你需要刷新⼀些脏页到磁盘,然后才能继续执⾏更新 语句,把更新语句的redo log从第⼀个⽇志⽂件开始覆盖写。 所以此时假设你在执⾏⼤量的更新语句,可能你突然发现线上数据库莫名其妙的很多更新语句短时间内性能都抖动 了,可能很多更新语句平时就⼏毫秒就执⾏好了,这次要等待1秒才能执⾏完毕。 因此遇到这种情况,你必须要等待第⼀个⽇志⽂件⾥部分redo log对应的脏页都刷⼊磁盘了,才能继续执⾏更新语句, 此时必然会导致更新语句的性能很差
就是如何尽量提升缓存页flush到磁盘的速度
给⼤家举个例⼦,假设你现在要执⾏⼀个SQL查询语句,此时需要等待flush⼀批缓存页到磁盘,接着才能加载查询出 来的数据到缓存页。 那么如果flush那批缓存页到磁盘需要1s,然后SQL查询语句⾃⼰执⾏的时间是200ms,此时你这条SQL执⾏完毕的总 时间就需要1.2s了。 但是如果你把那批缓存页flush到磁盘的时间优化到100ms,然后加上SQL查询⾃⼰执⾏的200ms,这条SQL的总执⾏ 时间就只要300ms了,性能就提升了很多。 所以这⾥⼀个关键之⼀,就是要尽可能减少flush缓存页到磁盘的时间开销到最⼩。 如果要做到这⼀点,通常给⼤家的⼀个建议就是对于数据库部署的机器,⼀定要采⽤SSD固态硬盘,⽽不要使⽤机械 硬盘,因为SSD固态硬盘最强⼤的地⽅,就是他的随机IO性能⾮常⾼。 ⽽flush缓存页到磁盘,就是典型的随机IO,需要在磁盘上找到各个缓存页所在的随机位置,把数据写⼊到磁盘⾥去。 所以如果你采⽤的是SSD固态硬盘,那么你flush缓存页到磁盘的性能⾸先就会提⾼不少。 其次,光是⽤SSD还不够,因为你还得设置⼀个很关键的参数,就是数据库的innodb_io_capacity,这个参数是告诉数 据库采⽤多⼤的IO速率把缓存页flush到磁盘⾥去的。
举个例⼦,假设你SSD能承载的每秒随机IO次数是600次,结果呢,你把数据库的innodb_io_capacity就设置为了 300,也就是flush缓存页到磁盘的时候,每秒最多执⾏300次随机IO,那你不是速度很慢么,⽽且根本没把你的SSD固 态硬盘的随机IO性能发挥出来! 所以通常都会建议⼤家对数据库部署机器的SSD固态硬盘能承载的最⼤随机IO速率做⼀个测试,这个可以使⽤fio⼯具 来测试 fio⼯具是⼀种⽤于测试磁盘最⼤随机IO速率的linux上的⼯具,如何使⽤,⼤家可以⽹上搜⼀下,⾮常的简单
还有⼀个参数,是innodb_flush_neighbors,他意思是说,在flush缓存页到磁盘的时候,可能会控制把缓存页临近 的其他缓存页也刷到磁盘,但是这样有时候会导致flush的缓存页太多了。 实际上如果你⽤的是SSD固态硬盘,并没有必要让他同时刷邻近的缓存页,可以把innodb_flush_neighbors参数设置为 0,禁⽌刷临近缓存页,这样就把每次刷新的缓存页数量降低到最少了。 所以呢,针对这次讲的这个案例,就是MySQL性能随机抖动的问题,
最核⼼的就是把innodb_io_capacity设置为SSD 固态硬盘的IOPS,让他刷缓存页尽量快,同时设置innodb_flush_neighbors为0,让他每次别刷临近缓存页,减少要刷 缓存页的数量,这样就可以把刷缓存页的性能提升到最⾼
数据页之间是组成双向链表的, 然后数据页内部的数据⾏是组成单向链表的,⽽且数据⾏是根据主键从⼩到⼤排序的。
假设你要是没有建⽴任何索引, 那么⽆论是根据主键查询,还是根据其他字段来条件查询,实际上都没有什么取巧的办法。 你⼀个表⾥所有数据页都是组成双向链表的吧?好,有链表就好办了,直接从第⼀个数据页开始遍历所有数据页,从 第⼀个数据页开始,你得先把第⼀个数据页从磁盘上读取到内存buffer pool的缓存页⾥来。 然后你就在第⼀个数据页对应的缓存页⾥,按照上述办法查找,
假设是根据主键查找的,你可以在数据页的页⽬录⾥ ⼆分查找,假设你要是根据其他字段查找的,只能是根据数据页内部的单向链表来遍历查找
其实上述操作过程,就是全表扫描,在你没有任何索引数据结构的时候,⽆论如何查找数据,说⽩了都是⼀个 全表扫描的过程,就是根据双向链表依次把磁盘上的数据页加载到缓存页⾥去,然后在⼀个缓存页内部来查找那条数 据。 最坏的情况下,你就得把所有数据页⾥的每条数据都得遍历⼀遍,才能找到你需要的那条数据,这就是全表扫描!
如果你的主键是⾃增的,那还可以保证这⼀点,因为你新插⼊后⼀个数据页 的主键值⼀定都⼤于前⼀个数据页的主键值。 但是有时候你的主键并不是⾃增长的,所以可能会出现你后⼀个数据页的主键值⾥,有的主键是⼩于前⼀个数据页的 主键值的。
⽐如在第⼀个数据页⾥有⼀条数据的主键是10,第⼆个数据页⾥居然有⼀条数据的主键值是8,那此时肯定有问题了。 所以此时就会出现⼀个过程,叫做页分裂,就是万⼀你的主键值都是你⾃⼰设置的,那么在增加⼀个新的数据页的时 候,实际上会把前⼀个数据页⾥主键值较⼤的,挪动到新的数据页⾥来,然后把你新插⼊的主键值较⼩的数据挪动到 上⼀个数据页⾥去,保证新数据页⾥的主键值⼀定都⽐上⼀个数据页⾥的主键值⼤。
针对主键设计一个索引了,针对主键的索引实际上就是主键目录,这个主键目录呢,就是把每个数据页的页号,还有数据页里最小的主键 值放在一起,组成一个索引的目录
假设你有很多的数据页,在主键目录里就会有很多的数据页和最小主键值,此时你完全可以根据二分查找的方式来找你要找的id到底在哪个数据页里!
所以这个效率是非常之高的,而类似上图的主键目录,就可以认为是主键索引。
B+
树本身就是一个目录,或者说本身就是一个索引。它有两个特点:
使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:
页内的记录是按照主键的大小顺序排成一个单向链表。
各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。
存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。
B+
树的叶子节点存储的是完整的用户记录。
所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。
我们把具有这两种特性的B+
树称为聚簇索引
,所有完整的用户记录都存放在这个聚簇索引
的叶子节点处。
B+
树的叶子节点存储的并不是完整的用户记录,而只是c2列+主键
这两个列的值。
目录项记录中不再是主键+页号
的搭配,而变成了c2列+页号
的搭配。
由于c2
列并没有唯一性约束,所以c2
列值为4
的记录可能分布在多个数据页中,又因为2 < 4 ≤ 4
,所以确定实际存储用户记录的页在页34
和页35
中。
这个以c2
列大小排序的B+
树只能确定我们要查找记录的主键值,所以如果我们想根据c2
列的值查找到完整的用户记录的话,仍然需要到聚簇索引
中再查一遍,这个过程也被称为回表
。也就是根据c2
列的值查询一条完整的用户记录需要使用到2
棵B+
树!!!
我们也可以同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,比方说我们想让B+
树按照c2
和c3
列的大小进行排序,这个包含两层含义:
c2
列进行排序。c2
列相同的情况下,采用c3
列进行排序,每条目录项记录
都由c2
、c3
、页号
这三个部分组成,各条记录先按照c2
列的值进行排序,如果记录的c2
列相同,则按照c3
列的值进行排序我们前边介绍B+
树索引的时候,为了大家理解上的方便,先把存储用户记录的叶子节点都画出来,然后接着画存储目录项记录的内节点,实际上B+
树的形成过程是这样的:
每当为某个表创建一个B+
树索引(聚簇索引不是人为创建的,默认就有)的时候,都会为这个索引创建一个根节点
页面。最开始表中没有数据的时候,每个B+
树索引对应的根节点
中既没有用户记录,也没有目录项目录。
随后向表中插入用户记录时,先把用户记录存储到这个根节点
中。
当根节点
中的可用空间用完时继续插入记录,此时会将根节点
中的所有记录复制到一个新分配的页,比如页a
中,然后对这个新页进行页分裂
的操作,得到另一个新页,比如页b
。这时新插入的记录根据键值(也就是聚簇索引中的主键值,二级索引中对应的索引列的值)的大小就会被分配到页a
或者页b
中,而根节点
便升级为存储目录项记录的页。
这个过程需要大家特别注意的是:一个B+树索引的根节点自诞生之日起,便不会再移动。这样只要我们对某个表建立一个索引,那么它的根节点
的页号便会被记录到某个地方,然后凡是InnoDB
存储引擎需要用到这个索引的时候,都会从那个固定的地方取出根节点
的页号,从而来访问这个索引。
MySQL给我们建⽴的聚簇索引都是基于主键的值来组织索引的,聚簇索引的叶⼦节点都是数据页,⾥⾯放 的就是我们插⼊的⼀⾏⼀⾏的完整的数据了!
在⼀个索引B+树中,他有⼀些特性,那就是数据页/索引页⾥⾯的记录都是组成⼀个单向链表的,⽽且是按照数据⼤⼩ 有序排列的;然后数据页/索引页互相之间都是组成双向链表的,⽽且也都是按照数据⼤⼩有序排列的,所以其实B+树 索引是⼀个完全有序的数据结构,⽆论是页内还是页之间。 正是因为这个有序的B+树索引结构,才能让我们查找数据的时候,直接从根节点开始按照数据值⼤⼩⼀层⼀层往下 找,这个效率是⾮常⾼的。
然后如果是针对主键之外的字段建⽴索引的话,实际上本质就是为那个字段的值重新建⽴另外⼀颗B+树索引,那个索 引B+树的叶⼦节点,存放的都是数据页,⾥⾯放的都是你字段的值和主键值,然后每⼀层索引页⾥存放的都是下层页 的引⽤,包括页内的排序规则,页之间的排序规则,B+树索引的搜索规则,都是⼀样的。 但是唯⼀要清晰记住的⼀点是,假设我们要根据其他字段的索引来搜索,那么只能基于其他字段的索引B+树快速查找 到那个值所对应的主键,接着再次做回表查询,基于主键在聚簇索引的B+树⾥,重新从根节点开始查找那个主键值, 找到主键值对应的完整数据。
索引当然有缺点了,主要是两个缺点,⼀个是空间上的,⼀个是时间上的。
不停的插⼊数据,各个索引的数据页就要不停的分裂,不停的增加新的索引页,这个过程都是耗费时间的。 所以你要是⼀个表⾥搞的索引太多了,很可能就会导致你的增删改的速度就⽐较差了,也许查询速度确实是可以提 ⾼,但是增删改就会受到影响,因此通常来说,我们是不建议⼀个表⾥搞的索引太多的!
就是你where语句中的⼏个字段名称和联合索引的字段完全⼀样,⽽且都是基于等号的 等值匹配,那百分百会⽤上我们的索引,这个⼤家是没有问题的,即使你where语句⾥写的字段的顺序和联合索引⾥的 字段顺序不⼀致,也没关系,MySQL会⾃动优化为按联合索引的字段顺序去找
这个意思就是假设我们联合索引是KEY(class_name, student_name, subject_name),那么不⼀定必须要在where语句⾥根据三个字段来查,其实只要根据最左侧的部分字段来查,也是可 以的。
⽐如你可以写select * from student_score where class_name=’’ and student_name=’’,就查某个学⽣所有科⽬的成 绩,这都是没有问题的。
但是假设你写⼀个select * from student_score where subject_name=’’,那就不⾏了,因为联合索引的B+树⾥,是必须 先按class_name查,再按student_name查,不能跳过前⾯两个字段,直接按最后⼀个subject_name查的。
另外,假设你写⼀个select * from student_score where class_name=’’ and subject_name=’’,那么只有class_name的 值可以在索引⾥搜索,剩下的subject_name是没法在索引⾥找的,道理同上。
即如果你要⽤like语法来查,⽐如select * from student_score where class_name like ‘1%’,查找所有1打头的班级的分数,那么也是可以⽤到索引的。 因为你的联合索引的B+树⾥,都是按照class_name排序的,所以你要是给出class_name的确定的最左前缀就是1,然 后后⾯的给⼀个模糊匹配符号,那也是可以基于索引来查找的,这是没问题的。 但是你如果写class_name like ‘%班’,在左侧⽤⼀个模糊匹配符,那他就没法⽤索引了,因为不知道你最左前缀是什 么,怎么去索引⾥找啊?
我们可以⽤select * from student_score where class_name>‘1班’ and class_name<'5班’这样的语句来范围查找某⼏个班级的分数。 这个时候也是会⽤到索引的,因为我们的索引的最下层的数据页都是按顺序组成双向链表的,所以完全可以先找到’1 班’对应的数据页,再找到’5班’对应的数据页,两个数据页中间的那些数据页,就全都是在你范围内的数据了!
但是如果你要是写select * from student_score where class_name>‘1班’ and class_name<‘5班’ and student_name>’’, 这⾥只有class_name是可以基于索引来找的,student_name的范围查询是没法⽤到索引的! 这也是⼀条规则,就是你的where语句⾥如果有范围查询,那只有对联合索引⾥最左侧的列进⾏范围查询才能⽤到索 引!
如果你要是⽤select * from student_score where class_name=‘1班’ and student_name>’’ and subject_name<’’,那么此时你⾸先可以⽤class_name在索引⾥精准定位到⼀波数据,接着这波 数据⾥的student_name都是按照顺序排列的,所以student_name>’‘也会基于索引来查找,但是接下来的 subject_name<’'是不能⽤索引的。
在你的SQL语句⾥,应该尽量最好是按照联合索引的字段顺序去进⾏order by排序,这样就可以直接利⽤联合 索引树⾥的数据有序性,到索引树⾥直接按照字段值的顺序去获取你需要的数据了。 但是这⾥有⼀些限定规则,因为联合索引⾥的字段值在索引树⾥都是从⼩到⼤依次排列的 ,所以你在order by⾥要不 然就是每个字段后⾯什么都不加,直接就是order by xx1,xx2,xx3,要不然就都加DESC降序排列,就是order by xx1 DESC,xx2 DESC,xx3 DESC。 如果都是升序排列,直接就从索引树⾥最⼩的开始读取⼀定条数就可以了,要是都是降序排列,就是从索引树⾥最⼤ 的数据开始读取⼀定的条数就可以了,但是你不能order by语句⾥有的字段升序有的字段降序,那是不能⽤索引的。 另外,要是你order by语句⾥有的字段不在联合索引⾥,或者是你对order by语句⾥的字段⽤了复杂的函数,这些也不 能使⽤索引去进⾏排序了。
如果不是根据索引排序,就是纯粹把⼀坨数据放到⼀个临时磁盘⽂件⾥,然后直接硬上各种排序算法在磁盘⽂件⾥搞⼀通排序,接着按照你 指定的要求⾛limit语句拿到指定分⻚的数据,这简直会让SQL的速度慢到家了!
假设你要是⾛⼀个类似select count(*) from table group by xx的SQL语句**,似乎看起来必须把你所有的数据放到⼀个 临时磁盘⽂件⾥还有加上部分内存,去搞⼀个分组,按照指定字段的值分成⼀组⼀组的,接着对每⼀组都执⾏⼀个聚 合函数,这个性能也是极差的,因为毕竟涉及⼤量的磁盘交互**。 因为在我们的索引树⾥默认都是按照指定的⼀些字段都排序好的,其实字段值相同的数据都是在⼀起的,假设要是⾛ 索引去执⾏分组后再聚合,那性能⼀定是⽐临时磁盘⽂件去执⾏好多了。 所以通常⽽⾔,对于group by后的字段,最好也是按照联合索引⾥的最左侧的字段开始,按顺序排列开来,这样的 话,其实就可以完美的运⽤上索引来直接提取⼀组⼀组的数据,然后针对每⼀组的数据执⾏聚合函数就可以了。 其实⼤家会发现,这个group by和order by⽤上索引的原理和条件都是差不多的,本质都是在group by和order by之后 的字段顺序和联合索引中的从最左侧开始的字段顺序⼀致,然后就可以充分利⽤索引树⾥已经完成排序的特性,快速 的根据排序好的数据执⾏后续操作了。 这样就不再需要针对杂乱⽆章的数据利⽤临时磁盘⽂件加上部分内存数据结构进⾏耗时耗⼒的现场排序和分组,那真 是速度极慢,性能极差的。
需要的字段值直接在索引树⾥就能提取出来,不需要回表到聚簇索引,这种查询⽅式就是覆盖索引。
举个例⼦,有⼀个字段他⼀共在10万⾏数据⾥ 有10万个值对吧?结果呢?这个10万值,要不然就是0,要不然就是1,那么他的基数就是2,为什么?因为这个字段的值就俩 选择,0和1。 假设你要是针对上⾯说的这种字段建⽴索引的话,那就还不如全表扫描了,因为你的索引树⾥就仅仅包含0和1两种值,根本没 法进⾏快速的⼆分查找,也根本就没有太⼤的意义了,所以这种时候,选⽤这种基数很低的字段放索引⾥意义就不⼤了
到底什么情况下,会直接对两个字段的两个索引⼀起查,然后取交集再回表到聚簇索引呢?也就是什么情况下可能会对⼀ 个SQL执⾏的时候,⼀下⼦查多个索引树呢?
假设就上⾯那个SQL语句吧,⽐如你x1和x2两个字段,如果你先查x1字段的索引,⼀下⼦弄出来上万条数据,这上万条数据都 回表到聚簇索引查完整数据,再根据x2来过滤,你有没有觉得效果不是太好? 那如果说同时从x2的索引树⾥也查⼀波数据出来,做⼀个交集,⼀下⼦就可以让交集的数据量变成⼏⼗条,再回表查询速度就 很快了。⼀般来说,查索引树速度都⽐较快,但是到聚簇索引回表查询会慢⼀些。所以如果同时查两个索引树取⼀个交集后,数据量很⼩,然后再回表到聚簇索引去查,此时会提升性能。
举个例⼦,假设t1表有10条数据,t2表有5条数据,那么此时select * from t1,t2,其实会查出来50条数据,因为t1表⾥的每条数 据都会跟t2表⾥的每条数据连接起来返回给你,那么不就是会查出来10 * 5 = 50条数据吗?这就是笛卡尔积 不过通常⼀般没⼈会傻到写类似这样的SQL语句,因为查出来这种数据实在是没什么意义。所以通常都会在多表关联语句中的 WHERE⼦句⾥引⼊⼀些关联条件,那么我们回头看看之前的SQL语句⾥的WHERE⼦句:where t1.x1=xxx and t1.x2=t2.x2 and t2.x3=xxx
这个SQL执⾏的过程可能是这样的,⾸先根据t1.x1=xxx这个筛选条件,去t1表⾥查出来⼀批数据,此时可能是const、 ref,也可能是index或者all,都有可能,具体看你的索引如何建的,他会挑⼀种执⾏计划访问⽅式。 然后假设从t1表⾥按照t1.x1=xxx条件筛选出2条数据,接着对这两条数据,根据每条数据的x2字段的值,以及t2.x3=xxx这个条 件,去t2表⾥找x2字段值和x3字段值都匹配的数据,⽐如说t1表第⼀条数据的x2字段的值是265,此时就根据t2.x2=265和 t2.x3=xxx这俩条件,找出来⼀波数据,⽐如找出来2条吧。 此时就把t1表⾥x2字段为265的那个数据跟t2表⾥t2.x2=265和t2.x3=xxx的两条数据,关联起来,就可以了,t1表⾥另外⼀条数 据也是如法炮制⽽已,这就是多表关联最最基本的原理。 记住,他可能是先从⼀个表⾥查⼀波数据,这个表叫做“驱动表”,再根据这波数据去另外⼀个表⾥查⼀波数据进⾏关联,另外 ⼀个表叫做“被驱动表”
outer join分为左外连接和右外连接,左外连接的意思就是,在左侧的表⾥的 某条数据,如果在右侧的表⾥关联不到任何数据,也得把左侧表这个数据给返回出来,右外连接反之,在右侧的表⾥如果关联 不到左侧表⾥的任何数据,得把右侧表的数据返回出来
假设有两个表要⼀起执⾏关联,此时会先在⼀个驱动表⾥根据他的where筛选条件找出⼀波数据,⽐如说找出10条 数据吧 接着呢,就对这10条数据⾛⼀个循环,⽤每条数据都到另外⼀个被驱动表⾥去根据ON连接条件和WHERE⾥的被驱动表筛选条 件去查找数据,找出来的数据就进⾏关联。 依次类推,假设驱动表⾥找出来10条数据,那么就要到被驱动表⾥去查询10次!
假设你好不容易从驱动表⾥扫出来⼀波数据,接着⼜来⼀个for循环⼀条⼀条去被驱动表⾥根据ON连接条件和WHERE 筛选条件去查,万⼀你对被驱动表⼜没加索引,难道⼜来⼏⼗次或者⼏百次全表扫描?那速度岂不是慢的跟蜗⽜⼀样了! 所以说,通常⽽⾔,针对多表查询的语句,我们要尽量给两个表都加上索引,索引要确保从驱动表⾥查询也是通过索引去查 找,接着对被驱动表查询也通过索引去查找。如果能做到这⼀点,你的多表关联语句性能就会很⾼!
MySQL⾥的成本是什么意思?
跑⼀个SQL语句,⼀般成本是两块,⾸先是那些数据如果在磁盘⾥, 你要不要从磁盘⾥把数据读出来?这个从磁盘读数据到内存就是IO成本,⽽且MySQL⾥都是⼀页⼀页读的,读⼀页的成本的 约定为1.0。 然后呢,还有⼀个成本,那就是说你拿到数据之后,是不是要对数据做⼀些运算?⽐如验证他是否符合搜索条件了,或者是搞 ⼀些排序分组之类的事,这些都是耗费CPU资源的,属于CPU成本,⼀般约定读取和检测⼀条数据是否符合条件的成本是0.2
show table status like “表名” 可以拿到你的表的统计信息,你在对表进⾏增删改的时候,MySQL会给你维护这个表的⼀些统计信息,⽐如这⾥可以看到 rows和data_length两个信息,不过对于innodb来说,这个rows是估计值。 rows就是表⾥的记录数,data_length就是表的聚簇索引的字节数⼤⼩,此时⽤data_length除以1024就是kb为单位的⼤⼩,然 后再除以16kb(默认⼀页的⼤⼩),就是有多少页,此时知道数据页的数量和rows记录数,就可以计算全表扫描的成本了。 IO成本就是:数据页数量 * 1.0 + 微调值,CPU成本就是:⾏记录数 * 0.2 + 微调值,他们俩相加,就是⼀个总的成本值,⽐如 你有数据页100个,记录数有2万条,此时总成本值⼤致就是100 + 4000 = 4100,在这个左右。
⼦查询是如何执⾏的,以及他的执⾏计划是如何优化的。
其实会被拆分为两个步骤:第⼀个步骤先执⾏⼦查询,也就是:select x1 from t2 where id=xxx,直接根据主键定位出⼀条数据的x1字段的值。接着再执⾏select * from t1 where x1=⼦查询的结果值,这个 SQL语句。 这个第⼆个SQL执⾏,其实也⽆⾮就是跟之前讲的单表查询的⽅式是⼀样的,其实⼤家看到最后会发现,这个SQL语句最核⼼ 的就是单表查询的⼏种执⾏⽅式,其他的多表关联,⼦查询,这些都是差不多这个意思。 最多就是在排序、分组聚合的时候,可能有的时候会直接⽤上索引,有的时候⽤不上索引就会基于内存或者临时磁盘⽂件执 ⾏。
一条查询语句在经过MySQL
查询优化器的各种基于成本和规则的优化会后生成一个所谓的执行计划
,这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么,对于每个表采用什么访问方法来具体执行查询等等。如果我们想看看某个查询的执行计划的话,可以在具体的查询语句前边加一个EXPLAIN
,就像这样:
mysql> EXPLAIN SELECT 1;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| 1 | SIMPLE | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | No tables used |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
1 row in set, 1 warning (0.01 sec)
然后这输出的一大坨东西就是所谓的执行计划
,我的任务就是带领大家看懂这一大坨东西里边的每个列都是干啥用的,以及在这个执行计划
的辅助下,我们应该怎样改进自己的查询语句以使查询执行起来更高效。把EXPLAIN
语句输出的各个列的作用先大致罗列一下:
列名 | 描述 |
---|---|
id |
在一个大的查询语句中每个SELECT 关键字都对应一个唯一的id |
select_type |
SELECT 关键字对应的那个查询的类型 |
table |
表名 |
partitions |
匹配的分区信息 |
type |
针对单表的访问方法 |
possible_keys |
可能用到的索引 |
key |
实际上使用的索引 |
key_len |
实际使用到的索引长度 |
ref |
当使用索引列等值查询时,与索引列进行等值匹配的对象信息 |
rows |
预估的需要读取的记录条数 |
filtered |
某个表经过搜索条件过滤后剩余记录条数的百分比 |
Extra |
一些额外的信息 |
我们仍然假设有两个和single_table
表构造一模一样的s1
、s2
表,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。为了让大家有比较好的阅读体验,我们下边并不准备严格按照EXPLAIN
输出列的顺序来介绍这些列分别是干嘛的,大家注意一下就好了。
不论我们的查询语句有多复杂,里边儿包含了多少个表,到最后也是需要对每个表进行单表访问的,所以设计MySQL
的大叔规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名。
对于连接查询来说,一个SELECT
关键字后边的FROM
子句中可以跟随多个表,所以在连接查询的执行计划中,每个表都会对应一条记录,但是这些记录的id值都是相同的
在连接查询的执行计划中,每个表都会对应一条记录,这些记录的id列的值是相同的,出现在前边的表表示驱动表,出现在后边的表表示被驱动表。所以从上边的EXPLAIN
输出中我们可以看出,查询优化器准备让s1
表作为驱动表,让s2
表作为被驱动表来执行查询。
但是这里大家需要特别注意,查询优化器可能对涉及子查询的查询语句进行重写,从而转换为连接查询。所以如果我们想知道查询优化器对某个包含子查询的语句是否进行了重写,直接查看执行计划就好了,比如说:
mysql> EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key3 FROM s2 WHERE common_field = 'a');
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+
| 1 | SIMPLE | s2 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9954 | 10.00 | Using where; Start temporary |
| 1 | SIMPLE | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s2.key3 | 1 | 100.00 | End temporary |
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+------------------------------+
2 rows in set, 1 warning (0.00 sec)
可以看到,虽然我们的查询语句是一个子查询,但是执行计划中s1
和s2
表对应的记录的id
值全部是1
,这就表明了查询优化器将子查询转换为了连接查询。
对于包含UNION
子句的查询语句来说,每个SELECT
关键字对应一个id
值也是没错的,不过还是有点儿特别的东西,比方说下边这个查询:
MySQL
使用的是内部的临时表。正如上边的查询计划中所示,UNION
子句是为了把id
为1
的查询和id
为2
的查询的结果集合并起来并去重,所以在内部创建了一个名为
的临时表(就是执行计划第三条记录的table
列的名称),id
为NULL
表明这个临时表是为了合并两个查询的结果集而创建的。
跟UNION
对比起来,UNION ALL
就不需要为最终的结果集进行去重,它只是单纯的把多个查询的结果集中的记录合并成一个并返回给用户,所以也就不需要使用临时表。所以在包含UNION ALL
子句的查询的执行计划中,就没有那个id
为NULL
的记录
一条大的查询语句里边可以包含若干个SELECT
关键字,每个SELECT
关键字代表着一个小的查询语句,而每个SELECT
关键字的FROM
子句中都可以包含若干张表(这些表用来做连接查询),每一张表都对应着执行计划输出中的一条记录,对于在同一个SELECT
关键字中的表来说,它们的id
值是相同的。
SIMPLE
查询语句中不包含UNION
或者子查询的查询都算作是SIMPLE
类型
PRIMARY
对于包含UNION
、UNION ALL
或者子查询的大查询来说,它是由几个小查询组成的,其中最左边的那个查询的select_type
值就是PRIMARY
,比方说:
mysql> EXPLAIN SELECT * FROM s1 UNION SELECT * FROM s2;
+----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
| 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL |
| 2 | UNION | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 100.00 | NULL |
| NULL | UNION RESULT | | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary |
+----+--------------+------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
3 rows in set, 1 warning (0.00 sec)
从结果中可以看到,最左边的小查询SELECT * FROM s1
对应的是执行计划中的第一条记录,它的select_type
值就是PRIMARY
。
UNION
对于包含UNION
或者UNION ALL
的大查询来说,它是由几个小查询组成的,其中除了最左边的那个小查询以外,其余的小查询的select_type
值就是UNION
,可以对比上一个例子的效果,这就不多举例子了。
UNION RESULT
MySQL
选择使用临时表来完成UNION
查询的去重工作,针对该临时表的查询的select_type
就是UNION RESULT
SUBQUERY
如果包含子查询的查询语句不能够转为对应的semi-join
的形式,并且该子查询是不相关子查询,并且查询优化器决定采用将该子查询物化的方案来执行该子查询时,该子查询的第一个SELECT
关键字代表的那个查询的select_type
就是SUBQUERY
,比如下边这个查询:
mysql> EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2) OR key3 = 'a';
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where |
| 2 | SUBQUERY | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
可以看到,外层查询的select_type
就是PRIMARY
,子查询的select_type
就是SUBQUERY
。需要大家注意的是,由于select_type为SUBQUERY的子查询由于会被物化,所以只需要执行一遍。
DEPENDENT SUBQUERY
如果包含子查询的查询语句不能够转为对应的semi-join
的形式,并且该子查询是相关子查询,则该子查询的第一个SELECT
关键字代表的那个查询的select_type
就是DEPENDENT SUBQUERY
,比如下边这个查询:
mysql> EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE s1.key2 = s2.key2) OR key3 = 'a';
+----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+
| 1 | PRIMARY | s1 | NULL | ALL | idx_key3 | NULL | NULL | NULL | 9688 | 100.00 | Using where |
| 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key2,idx_key1 | idx_key2 | 5 | xiaohaizi.s1.key2 | 1 | 10.00 | Using where |
+----+--------------------+-------+------------+------+-------------------+----------+---------+-------------------+------+----------+-------------+
2 rows in set, 2 warnings (0.00 sec)
需要大家注意的是,select_type为DEPENDENT SUBQUERY的查询可能会被执行多次。
DEPENDENT UNION
在包含UNION
或者UNION ALL
的大查询中,如果各个小查询都依赖于外层查询的话,那除了最左边的那个小查询之外,其余的小查询的select_type
的值就是DEPENDENT UNION
。说的有些绕哈,比方说下边这个查询:
mysql> EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2 WHERE key1 = 'a' UNION SELECT key1 FROM s1 WHERE key1 = 'b');
+----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+
| 1 | PRIMARY | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using where |
| 2 | DEPENDENT SUBQUERY | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 12 | 100.00 | Using where; Using index |
| 3 | DEPENDENT UNION | s1 | NULL | ref | idx_key1 | idx_key1 | 303 | const | 8 | 100.00 | Using where; Using index |
| NULL | UNION RESULT | | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary |
+----+--------------------+------------+------------+------+---------------+----------+---------+-------+------+----------+--------------------------+
4 rows in set, 1 warning (0.03 sec)
这个查询比较复杂啊,大查询里包含了一个子查询,子查询里又是由UNION
连起来的两个小查询。从执行计划中可以看出来,SELECT key1 FROM s2 WHERE key1 = 'a'
这个小查询由于是子查询中第一个查询,所以它的select_type
是DEPENDENT SUBQUERY
,而SELECT key1 FROM s1 WHERE key1 = 'b'
这个查询的select_type
就是DEPENDENT UNION
。
DERIVED
对于采用物化的方式执行的包含派生表的查询,该派生表对应的子查询的select_type
就是DERIVED
,比方说下边这个查询:
mysql> EXPLAIN SELECT * FROM (SELECT key1, count(*) as c FROM s1 GROUP BY key1) AS derived_s1 where c > 1;
+----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| 1 | PRIMARY | | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 33.33 | Using where |
| 2 | DERIVED | s1 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9688 | 100.00 | Using index |
+----+-------------+------------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
从执行计划中可以看出,id
为2
的记录就代表子查询的执行方式,它的select_type
是DERIVED
,说明该子查询是以物化的方式执行的。id
为1
的记录代表外层查询,大家注意看它的table
列显示的是
,表示该查询是针对将派生表物化之后的表进行查询的。
小贴士:
如果派生表可以通过和外层查询合并的方式执行的话,执行计划又是另一番景象,大家可以试试哈~
MATERIALIZED
当查询优化器在执行包含子查询的语句时,选择将子查询物化之后与外层查询进行连接查询时,该子查询对应的select_type
属性就是MATERIALIZED
,比如下边这个查询:
mysql> EXPLAIN SELECT * FROM s1 WHERE key1 IN (SELECT key1 FROM s2);
+----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+
| 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 100.00 | Using where |
| 1 | SIMPLE | | NULL | eq_ref | | | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL |
| 2 | MATERIALIZED | s2 | NULL | index | idx_key1 | idx_key1 | 303 | NULL | 9954 | 100.00 | Using index |
+----+--------------+-------------+------------+--------+---------------+------------+---------+-------------------+------+----------+-------------+
3 rows in set, 1 warning (0.01 sec)
执行计划的第三条记录的id
值为2
,说明该条记录对应的是一个单表查询,从它的select_type
值为MATERIALIZED
可以看出,查询优化器是要把子查询先转换成物化表。然后看执行计划的前两条记录的id
值都为1
,说明这两条记录对应的表进行连接查询,需要注意的是第二条记录的table
列的值是
,说明该表其实就是id
为2
对应的子查询执行之后产生的物化表,然后将s1
和该物化表进行连接查询。
我们前边说过执行计划的一条记录就代表着MySQL
对某个表的执行查询时的访问方法,其中的type
列就表明了这个访问方法是个啥
可以看到type
列的值是ref
,表明MySQL
即将使用ref
访问方法来执行对s1
表的查询。
system
,const
,eq_ref
,ref
,fulltext
,ref_or_null
,index_merge
,unique_subquery
,index_subquery
,range
,index
,ALL
。当然我们还要详细唠叨一下哈:
system
当表中只有一条记录并且该表使用的存储引擎的统计数据是精确的,比如MyISAM、Memory,那么对该表的访问方法就是system
。
const
当我们根据主键或者唯一二级索引列与常数进行等值匹配时,对单表的访问方法就是const
eq_ref
在连接查询时,如果被驱动表是通过主键或者唯一二级索引列等值匹配的方式进行访问的(如果该主键或者唯一二级索引是联合索引的话,所有的索引列都必须进行等值比较),则对该被驱动表的访问方法就是eq_ref
ref
当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就可能是ref
fulltext
全文索引
ref_or_null
当对普通二级索引进行等值匹配查询,该索引列的值也可以是NULL
值时,那么对该表的访问方法就可能是ref_or_null
index_merge
一般情况下对于某个表的查询只能使用到一个索引,但我们唠叨单表访问方法时特意强调了在某些场景下可以使用Intersection
、Union
、Sort-Union
这三种索引合并的方式来执行查询,忘掉的回去补一下哈,我们看一下执行计划中是怎么体现MySQL
使用索引合并的方式来对某个表执行查询的:
unique_subquery
类似于两表连接中被驱动表的eq_ref
访问方法,unique_subquery
是针对在一些包含IN
子查询的查询语句中,如果查询优化器决定将IN
子查询转换为EXISTS
子查询,而且子查询可以使用到主键进行等值匹配的话,那么该子查询执行计划的type
列的值就是unique_subquery
,比如下边的这个查询语句:
可以看到执行计划的第二条记录的type
值就是unique_subquery
,说明在执行子查询时会使用到id
列的索引。
index_subquery
index_subquery
与unique_subquery
类似,只不过访问子查询中的表时使用的是普通的索引,比如这样:
range
如果使用索引获取某些范围区间
的记录,那么就可能使用到range
访问方法,比如下边的这个查询:
index
当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是index
,比如这样:
ALL
一般来说,这些访问方法按照我们介绍它们的顺序性能依次变差。其中除了All
这个访问方法外,其余的访问方法都能用到索引,除了index_merge
访问方法外,其余的访问方法都最多只能用到一个索引。
在EXPLAIN
语句输出的执行计划中,possible_keys
列表示在某个查询语句中,对某个表执行单表查询时可能用到的索引有哪些,key
列表示实际用到的索引有哪些
另外需要注意的一点是,possible_keys列中的值并不是越多越好,可能使用的索引越多,查询优化器计算查询成本时就得花费更长时间,所以如果可以的话,尽量删除那些用不到的索引。
key_len
列表示当优化器决定使用某个索引执行查询时,该索引记录的最大长度,它是由这三个部分构成的:
对于使用固定长度类型的索引列来说,它实际占用的存储空间的最大长度就是该固定值,对于指定字符集的变长类型的索引列来说,比如某个索引列的类型是VARCHAR(100)
,使用的字符集是utf8
,那么该列实际占用的最大存储空间就是100 × 3 = 300
个字节。
如果该索引列可以存储NULL
值,则key_len
比不可以存储NULL
值时多1个字节。
对于变长字段来说,都会有2个字节的空间来存储该变长列的实际长度。
当使用索引列等值匹配的条件去执行查询时,也就是在访问方法是const
、eq_ref
、ref
、ref_or_null
、unique_subquery
、index_subquery
其中之一时,ref
列展示的就是与索引列作等值匹配的东东是个啥,比如只是一个常数或者是某个列。
如果查询优化器决定使用全表扫描的方式对某个表执行查询时,执行计划的rows
列就代表预计需要扫描的行数,如果使用索引来执行查询时,执行计划的rows
列就代表预计扫描的索引记录行数。
之前在分析连接查询的成本时提出过一个condition filtering
的概念,就是MySQL
在计算驱动表扇出时采用的一个策略:
如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要估计出满足搜索条件的记录到底有多少条。
如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要估计出满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条。
对于单表查询来说,这个filtered
列的值没什么意义,我们更关注在连接查询中驱动表对应的执行计划记录的filtered
值,比方说下边这个查询:
mysql> EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.key1 = s2.key1 WHERE s1.common_field = 'a';
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+
| 1 | SIMPLE | s1 | NULL | ALL | idx_key1 | NULL | NULL | NULL | 9688 | 10.00 | Using where |
| 1 | SIMPLE | s2 | NULL | ref | idx_key1 | idx_key1 | 303 | xiaohaizi.s1.key1 | 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+---------------+----------+---------+-------------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
从执行计划中可以看出来,查询优化器打算把s1
当作驱动表,s2
当作被驱动表。我们可以看到驱动表s1
表的执行计划的rows
列为9688
, filtered
列为10.00
,这意味着驱动表s1
的扇出值就是9688 × 10.00% = 968.8
,这说明还要对被驱动表执行大约968
次查询。
顾名思义,Extra
列是用来说明一些额外信息的,我们可以通过这些额外信息来更准确的理解MySQL
到底将如何执行给定的查询语句。
Impossible WHERE
查询语句的WHERE
子句永远为FALSE
时将会提示该额外信息
No matching min/max row
当查询列表处有MIN
或者MAX
聚集函数,但是并没有符合WHERE
子句中的搜索条件的记录时,将会提示该额外信息
Using index
当我们的查询列表以及搜索条件中只包含属于某个索引的列,也就是在可以使用索引覆盖的情况下,在Extra
列将会提示该额外信息。
Using index condition
有些搜索条件中虽然出现了索引列,但却不能使用到索引,比如下边这个查询:
SELECT * FROM s1 WHERE key1 > 'z' AND key1 LIKE '%a';
MySQL
把上边的步骤改进了一下:
先根据key1 > 'z'
这个条件,定位到二级索引idx_key1
中对应的二级索引记录。
对于指定的二级索引记录,先不着急回表,而是先检测一下该记录是否满足key1 LIKE '%a'
这个条件,如果这个条件不满足,则该二级索引记录压根儿就没必要回表。
对于满足key1 LIKE '%a'
这个条件的二级索引记录执行回表操作。
我们说回表操作其实是一个随机IO
,比较耗时,所以上述修改虽然只改进了一点点,但是可以省去好多回表操作的成本。设计MySQL
的大叔们把他们的这个改进称之为索引条件下推
(英文名:Index Condition Pushdown
)。
Using where
当我们使用全表扫描来执行对某个表的查询,并且该语句的WHERE
子句中有针对该表的搜索条件时,在Extra
列中会提示上述额外信息。
Using join buffer (Block Nested Loop)
在连接查询执行过程中,当被驱动表不能有效的利用索引加快访问速度,MySQL
一般会为其分配一块名叫join buffer
的内存块来加快查询速度,也就是我们所讲的基于块的嵌套循环算法
,比如下边这个查询语句:
mysql> EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.common_field = s2.common_field;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+
| 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | NULL |
| 1 | SIMPLE | s2 | NULL | ALL | NULL | NULL | NULL | NULL | 9954 | 10.00 | Using where; Using join buffer (Block Nested Loop) |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------------------------------------------+
2 rows in set, 1 warning (0.03 sec)
可以在对s2
表的执行计划的Extra
列显示了两个提示:
Using join buffer (Block Nested Loop)
:这是因为对表s2
的访问不能有效利用索引,只好退而求其次,使用join buffer
来减少对s2
表的访问次数,从而提高性能。
Using where
:可以看到查询语句中有一个s1.common_field = s2.common_field
条件,因为s1
是驱动表,s2
是被驱动表,所以在访问s2
表时,s1.common_field
的值已经确定下来了,所以实际上查询s2
表的条件就是s2.common_field = 一个常数
,所以提示了Using where
额外信息。
Not exists
当我们使用左(外)连接时,如果WHERE
子句中包含要求被驱动表的某个列等于NULL
值的搜索条件,而且那个列又是不允许存储NULL
值的,那么在该表的执行计划的Extra
列就会提示Not exists
额外信息,
Using intersect(...)
、Using union(...)
和Using sort_union(...)
如果执行计划的Extra
列出现了Using intersect(...)
提示,说明准备使用Intersect
索引合并的方式执行查询,括号中的...
表示需要进行索引合并的索引名称;如果出现了Using union(...)
提示,说明准备使用Union
索引合并的方式执行查询;出现了Using sort_union(...)
提示,说明准备使用Sort-Union
索引合并的方式执行查询。比如这个查询的执行计划:
Zero limit
当我们的LIMIT
子句的参数为0
时,表示压根儿不打算从表中读出任何记录,将会提示该额外信息,比如这样:
Using filesort
这个查询语句可以利用idx_key1
索引直接取出key1
列的10条记录,然后再进行回表操作就好了。但是很多情况下排序操作无法使用到索引,只能在内存中(记录较少的时候)或者磁盘中(记录较多的时候)进行排序,设计MySQL
的大叔把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort
)。如果某个查询需要使用文件排序的方式执行查询,就会在执行计划的Extra
列中显示Using filesort
提示,比如这样:
mysql> EXPLAIN SELECT * FROM s1 ORDER BY common_field LIMIT 10;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| 1 | SIMPLE | s1 | NULL | ALL | NULL | NULL | NULL | NULL | 9688 | 100.00 | Using filesort |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
1 row in set, 1 warning (0.00 sec)
需要注意的是,如果查询中需要使用filesort
的方式进行排序的记录非常多,那么这个过程是很耗费性能的,我们最好想办法将使用文件排序
的执行方式改为使用索引进行排序。
Using temporary
在许多查询的执行过程中,MySQL
可能会借助临时表来完成一些功能,比如去重、排序之类的,比如我们在执行许多包含DISTINCT
、GROUP BY
、UNION
等子句的查询过程中,如果不能有效利用索引来完成查询,MySQL
很有可能寻求通过建立内部的临时表来执行查询。如果查询中使用到了内部的临时表,在执行计划的Extra
列将会显示Using temporary
提示,
另外,执行计划中出现Using temporary
并不是一个好的征兆,因为建立与维护临时表要付出很大成本的,所以我们最好能使用索引来替代掉使用临时表,
某⼀天晚上,我们突然收到了线上数据库的频繁报警,这个报警的意 思⼤致就是说,数据库突然涌现出了⼤量的慢查询,⽽且因为⼤量的慢查询,导致每⼀个数据库连接执⾏⼀个慢查询都要耗费很 久。 那这样的话,必然会导致突然过来的很多查询需要让数据库开辟出来更多的连接,因此这个时候报警也告诉我们,数据库的连接 突然也暴增了,⽽且每个连接都打满,每个连接都要执⾏⼀个慢查询,慢查询还跑的特别慢。 接着引发的问题,就是数据库的连接全部打满,没法开辟新的连接了,但是还持续的有新的查询发送过来,导致数据库没法处理 新的查询,很多查询发到数据库直接就阻塞然后超时了,这也直接导致线上的商品系统频繁的报警,出现了⼤量的数据库查询超 时报错的异常!
那么慢查询的都是⼀些什么语句呢?其实主要就是下⾯这条语句,⼤家可以看⼀下,我们做了⼀个简化: select * from products where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx
那么针对这⾥简化后的SQL语句,你可以认为如下的⼀个索引,**KEY index_category(catetory,sub_category)**肯 定是存在的,所以基本可以确认上⾯的SQL绝对是可以⽤上索引的。 因为如果你⼀旦⽤上了品类的那个索引,那么按品类和⼦类去在索引⾥筛选,其实第⼀,筛选很快速,第⼆,筛出来的数据是不 多的,按说这个语句应该执⾏的速度是很快的,即使表有亿级数据,但是执⾏时间也最多不应该超过1s。 但是现在这个SQL语句跑了⼏⼗秒,那说明他肯定就没⽤我们建⽴的那个索引,所以才会这么慢,那么他到底是怎么执⾏的呢? 我们来看⼀下他的执⾏计划:
explain select * from products where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx
我就说这⾥最核⼼的信息,他的 possible_keys⾥是有我们的index_category的,结果实际⽤的key不是这个索引,⽽是PRIMARY!!⽽且Extra⾥清晰写了Using where 到此为⽌,这个SQL语句为什么性能这么差,就真相⼤⽩了,他其实本质上就是在主键的聚簇索引上进⾏扫描,⼀边扫描,⼀边 还⽤了where条件⾥的两个字段去进⾏筛选,所以这么扫描的话,那必然就是会耗费⼏⼗秒了! 因此此时为了快速解决这个问题,就需要强制性的改变MySQL⾃动选择这个不合适的聚簇索引进⾏扫描的⾏为 那么怎么改变呢?交给⼤家⼀个办法,就是使⽤force index语法,如下: select * from products force index(index_category) where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx 使⽤上述语法过后,强制让SQL语句使⽤了你指定的索引,此时再次执⾏这个SQL语句,会发现他仅仅耗费100多毫秒⽽已!性能 瞬间就提升上来了!
select * from products where category=‘xx’ and sub_category=‘xx’ order by id desc limit xx,xx
这个表是⼀个亿级数据量的⼤表,那么对于他来说, index_category这个⼆级索引也是⽐较⼤的 所以此时对于MySQL来说,他有这么⼀个判断,他觉得如果*要是从index_category⼆级索引⾥来查找到符合where条件的⼀波数 据,接着还得回表,回到聚簇索引⾥去。 因为SQL语句是要select 的,所以这⾥必然涉及到⼀次回表操作,回到聚簇索引⾥去把所有字段的数据都查出来,但是在回表之 前,他必然要做完order by id desc limit xx,xx这个操作 举个例⼦吧,⽐如他根据where category=‘xx’ and sub_category=‘xx’,从index_category⼆级索引⾥查找出了⼀⼤波数据。 ⽐如从⼆级索引⾥假设搂出来了⼏万条数据,接着因为⼆级索引⾥是包含主键id值的,所以此时他就得按照order by id desc这个 排序语法,对这⼏万条数据基于临时磁盘⽂件进⾏filesort磁盘排序,排序完了之后,再按照limit xx,xx语法,把指定位置的⼏条数 据拿出来,假设就是limit 0,10,那么就是把10条数据拿出来,拿出来10条数据之后,再回到聚簇索引⾥去根据id查找,把这10条数据的完整字段都查出来,这就是MySQL认为如果你使⽤ index_category的话,可能会发⽣的⼀个情况。
所以他担⼼的是,你根据where category=‘xx’ and sub_category=‘xx’,从index_category⼆级索引⾥查出来的数据太多了,还得 在临时磁盘⾥排序,可能性能会很差,因此MySQL就把这种⽅式判定为⼀种不太好的⽅式。
因此他才会选择换⼀种⽅式,也就是说,直接扫描主键的聚簇索引,因为聚簇索引都是按照id值有序的,所以扫描的时候,直接 按order by id desc这个倒序顺序扫描过去就可以了,然后因为他知道你是limit 0,10的,也就知道你仅仅只要拿到10条数据就⾏ 了。 所以他在按顺序扫描聚簇索引的时候,就会对每⼀条数据都采⽤Using where的⽅式,跟where category=‘xx’ and sub_category='xx’条件进⾏⽐对,符合条件的就直接放⼊结果集⾥去,最多就是放10条数据进去就可以返回了。 此时MySQL认为,按顺序扫描聚簇索引,拿到10条符合where条件的数据,应该速度是很快的,很可能⽐使⽤index_category⼆ 级索引那个⽅案更快,因此此时他就采⽤了扫描聚簇索引的这种⽅式!
原因也很简单,其实就是因为之前的时候,where category=‘xx’ and sub_category='xx’这个条件通常都是有返回值的,就是说根 据条件⾥的取值,扫描聚簇索引的时候,通常都是很快就能找到符合条件的值以及返回的,所以之前其实性能也没什么问题。 但是后来可能是商品系统⾥的运营⼈员,在商品管理的时候加了⼏种商品分类和⼦类,但是这⼏种分类和⼦类的组合其实没有对 应的商品 也就是说,那⼀天晚上,很多⽤户使⽤这种分类和⼦类去筛选商品,where category=‘新分类’ and sub_category='新⼦类’这个条 件实际上是查不到任何数据的! 所以说,底层在扫描聚簇索引的时候,扫来扫去都扫不到符合where条件的结果,⼀下⼦就把聚簇索引全部扫了⼀遍,等于是上 亿数据全表扫描了⼀遍,都没找到符合where category=‘新分类’ and sub_category='新⼦类’这个条件的数据。 也正是因为如此,才导致这个SQL语句频繁的出现⼏⼗秒的慢查询,进⽽导致MySQL连接资源打满,商品系统崩溃!
先来看看⼀个经过我们简化后的对评论表进⾏分⻚查询的SQL语句:
SELECT * FROM comments WHERE product_id =‘xx’ and is_good_comment=‘1’ ORDER BY id desc LIMIT 100000,20 这个SQL语句想必⼤家都知道是怎么回事。 其实他的意思就是,⽐如⽤户选择了查看某个商品的评论,因此必须限定Product_id,同时还选了只看好评,所以 is_good_commit也要限定⼀下 接着他要看第5001⻚评论,那么此时limit的offset就会是(5001 - 1) * 20,其中20就是每⼀⻚的数量,此时起始offset就是 100000,所以limit后100000,20
最核⼼的索引就是⼀个,那就是index_product_id,所以对上述SQL语句,正常情况下,肯定是会⾛这个索引 的,也就是说,会通过index_product_id索引,根据product_id ='xx’这个条件从表⾥先删选出来这个表⾥指定商品的评论数据。 那么接下来第⼆步呢?当然是得按照 is_good_comment=‘1’ 条件,筛选出这个商品评论数据⾥的所有好评了!但是问题来了,这 个index_product_id的索引数据⾥,并没有is_good_commet字段的值,所以此时只能很尴尬的进⾏回表了。 也就是说,对这个商品的每⼀条评论,都要进⾏⼀次回表操作,回到聚簇索引⾥,根据id找到那条数据,取出来 is_good_comment字段的值,接着对is_good_comment='1’条件做⼀个⽐对,筛选符合条件的数据。 那么假设这个商品的评论有⼏⼗万条,岂不是要做⼏⼗万次回表操作?虽然每次回表都是根据id在聚簇索引⾥快速查找的,但还 是架不住你每条数据都回表啊!!!
接着对于筛选完毕的所有符合WHERE product_id =‘xx’ and is_good_comment='1’条件的数据,假设有⼗多万条吧,接着就是按 照id做⼀个倒序排序,此时还得基于临时磁盘⽂件进⾏倒序排序,⼜得耗时很久。 排序完毕了,就得基于limit 100000,20获取第5001⻚的20条数据,最后返回
这个过程,因为有⼏⼗万次回表查询,还有⼗多万条数据的磁盘⽂件排序,所以当时发现,这条SQL语句基本要跑个1秒~2秒。
因为WHERE product_id =‘xx’ and is_good_comment='1’这两个条件,不是⼀个联合 索引,所以必须会出现⼤量的回表操作,这个耗时是极⾼的。 因此对于这个案例,我们通常会采取如下⽅式改造分⻚查询语句:
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 上⾯那个SQL语句的执⾏计划就会彻底改变他的执⾏⽅式,他通常会先执⾏括号⾥的⼦查询,⼦查询反⽽会使⽤PRIMARY聚簇索 引,按照聚簇索引的id值的倒序⽅向进⾏扫描,扫描过程中就把符合WHERE product_id =‘xx’ and is_good_comment='1’条件的 数据给筛选出来。
⽐如这⾥就筛选出了⼗万多条的数据,并不需要把符合条件的数据都找到,因为limit后跟的是100000,20,理论上,只要有 100000+20条符合条件的数据,⽽且是按照id有序的,此时就可以执⾏根据limit 100000,20提取到5001⻚的这20条数据了。 接着你会看到执⾏计划⾥会针对这个⼦查询的结果集,⼀个临时表,进⾏全表扫描,拿到20条数据,接着对20条数据 遍历,每⼀条数据都按照id去聚簇索引⾥查找⼀下完整数据,就可以了。
搭建⼀套MySQL主从复制架构之后,可以实现⼀个⾼可⽤的效果,也就就是说主节点宕机,可 以切换去读写从节点,因为主从节点数据基本是⼀致的
读写分离架构
这个读写分离架构,也是依赖于MySQL的主从复制架构的。 读写分离架构的意思就是,你的Java业务系统可以往主节点写⼊数据,但是从从节点去查询数据,把读写操作做⼀个 分离,分离到两台MySQL服务器上去,⼀台服务器专门让你写⼊数据,然后复制数据到从节点,另外⼀台服务器专门 让你查询数据
因为假设我们的MySQL单机服务器配置是8核16GB,然后每秒最多能抗4000读写请求,现在假设你真实 的业务负载已经达到了,每秒有2500写请求+2500读请求,也就是每秒5000读写请求了,那么你觉得如果都放⼀台 MySQL服务器,能抗的住吗? 必然不⾏啊!所以此时如果你可以利⽤主从复制架构,搭建起来读写分离架构,就可以让每秒2500写请求落到主节点 那台服务器,2500读请求落到从节点那台服务器,⽤2台服务器来抗下你每秒5000的读写请求
接着现在问题来了,⼤家都知道,其他⼤部分Java业务系统都是读多写少,读请求远远多于写请求,那么接着发现随 着系统⽇益发展,读请求越来越多,每秒可能有6000读请求了,此时⼀个从节点服务器也抗不下来啊,那怎么办呢?
简单!因为MySQL的主从复制架构,是⽀持⼀主多从的,所以此时你可以再在⼀台服务器上部署⼀个从节点,去从主 节点复制数据过来,此时你就有2个从节点了,然后你每秒6000读请求不就可以落到2个从节点上去了,每台服务器主 要接受每秒3000的读请求,
还可以融合⾼可⽤架构进去,因为你有多个从库,所以当你主库宕机的时候,可以通过中间件 把⼀个从库切换为主库,此时你的Java业务系统可以继续运⾏,在实现读写分离的场景下,还可以同时实现⾼可⽤
除此之外,这个从库其实还有很多其他的应⽤场景,⽐如你可以挂⼀个从库,专门⽤来跑⼀些报表SQL语句,那种 SQL语句往往是上百⾏之多,运⾏要好⼏秒,所以可以专门给他⼀个从库来跑。也可以是专门部署⼀个从库,让你去 进⾏数据同步之类的操作。
MySQL⾃⼰在执⾏增删改的时候会记录binlog⽇志,这个binlog⽇志⾥就记录了所有数据增删改的操作. 然后从库上有⼀个IO线程,这个IO线程会负责跟主库建⽴⼀个TCP连接,接着请求主库传输binlog⽇志给⾃⼰,这个时 候主库上有⼀个IO dump线程,就会负责通过这个TCP连接把binlog⽇志传输给从库的IO线程,接着从库的IO线程会把读取到的binlog⽇志数据写⼊到⾃⼰本地的relay⽇志⽂件中去,然后从库上另外有⼀个SQL线 程会读取relay⽇志⾥的内容,进⾏⽇志重做,把所有在主库执⾏过的增删改操作,在从库上做⼀遍,达到⼀个还原数 据的过程,
想必⼤家对MySQL主从复制的原理也就有⼀个基本的了解了,简单来说,你只要给主节点挂上⼀个从节 点,从节点的IO线程就会跟主节点建⽴⽹络连接,然后请求主节点传输binl
我们都建议MySQL单表数据量不要超过1000万,最好是在500万以内,如果能控制在100万以内,那是 最佳的选择了,基本单表100万以内的数据,性能上不会有太⼤的问题,前提是,只要你建好索引就⾏,其实保证 MySQL⾼性能通常没什么特别⾼深的技巧,就是控制数据量不要太⼤,另外就是保证你的查询⽤上了索引,⼀般就没 问题。
⼀个背景是⼀个中型的电商公司,不 是那种顶级电商巨头,就算是⼀个垂直领域的中型电商公司吧,那么他覆盖的⽤户⼤概算他有1亿以内,⼤概⼏千万的 样⼦
可以选择把这个⽤户⼤表拆分为⽐如100张表,那么此时⼏千万 数据瞬间分散到100个表⾥去,类似user_001、user_002、user_100这样的100个表,每个表也就⼏⼗万数据⽽已
可以把这100个表分散到多台数据库服务器上去,此时要分散到⼏台服务器呢?你要考虑两个点,**⼀个是数据量 有多少个GB/TB,⼀个是针对⽤户中⼼的并发压⼒有多⾼。**实际上⼀般互联⽹公司对⽤户中⼼的压⼒不会⾼的太离 谱,因为⼀般不会有很多⼈同时注册/登录,或者是同时修改⾃⼰的个⼈信息,所以并发这块不是太⼤问题
⾄于数据量层⾯的话,我可以给⼤家⼀个经验值,⼀般1亿⾏数据,⼤致在1GB到⼏个GB之间的范围,这个跟具体你 ⼀⾏数据有多少字段也有关系,⼤致⼤致就是这么个范围,所以说你⼏千万的⽤户数据,往多了说也就⼏个GB⽽已
这点数据量,对于服务器的存储空间来说,完全没压⼒,不是问题。 所以综上所述,此时你完全可以给他分配两台数据库服务器,放两个库,然后100张表均匀分散在2台服务器上就可以 了,分的时候需要指定⼀个字段来分,⼀般来说会指定userid,根据⽤户id进⾏hash后,对表进⾏取模,路由到⼀个表 ⾥去,这样可以让数据均匀分散。
到此就搞定了⽤户表的分库分表,你只要给系统加上数据库中间件技术,设置好路由规则,就可以轻松的对2个分库上 的100张表进⾏增删改查的操作了。平时针对某个⽤户增删改查,直接对他的userid进⾏hash,然后对表取模,做⼀个 路由,就知道到哪个表⾥去找这个⽤户的数据了。
但是这⾥可能会出现⼀些问题,⼀个是说,⽤户在登录的时候,可能不是根据userid登陆的,可能是根据username之 类的⽤户名,⼿机号之类的来登录的,此时你⼜没有userid,怎么知道去哪个表⾥找这个⽤户的数据判断是否能登录 呢?
关于这个问题,⼀般来说常规⽅案是建⽴⼀个索引映射表,就是说搞⼀个表结构为(username, userid)的索引映射 表,把username和userid⼀⼀映射,然后针对username再做⼀次分库分表,把这个索引映射表可以拆分为⽐如100个 表分散在两台服务器⾥。 然后⽤户登录的时候,就可以根据username先去索引映射表⾥查找对应的userid,⽐如对username进⾏hash然后取模 路由到⼀个表⾥去,找到username对应的userid,接着根据userid进⾏hash再取模,然后路由到按照userid分库分表的 ⼀个表⾥去,找到⽤户的完整数据即可。
但是这种⽅式会把⼀次查询转化为两个表的两次查询,先查索引映射表,再根据userid去查具体的数据,性能上是有⼀ 定的损耗的,不过有时候为了解决分库分表的问题,也只能⽤这种类似的办法。
另外就是如果在公司运营团队⾥,有⼀个⽤户管理模块,需要对公司的⽤户按照⼿机号、住址、年龄、性别、职业等 各种条件进⾏极为复杂的搜索,这怎么办呢?其实没太多的好办法,基本上就是要对你的⽤户数据表进⾏binlog监听, 把你要搜索的所有字段同步到Elasticsearch⾥去,建⽴好搜索的索引。 然后你的运营系统就可以通过Elasticsearch去进⾏复杂的多条件搜索,ES是适合⼲这个事⼉的,然后定位到⼀批 userid,通过userid回到分库分表环境⾥去找出具体的⽤户数据,在页⾯上展⽰出来即可
⼀般互联⽹公司的订单系统是如何做分库分表的,既然要聊订单系统的分库分 表,那么就得先说说为什么订单需要分库分表,其实最关键的⼀点就是要分析⼀下订单系统的数据量,那么订单系统的数据量 有多⼤?这个就得看具体公司的情况了。
⽐如说⼀个⼩型互联⽹公司,如果是涉及到电商交易的,那么肯定每天都会有⼀些订单进来的,那么⽐如⼩型互联⽹公司假设 有500万的注册⽤户,每天⽇活的⽤户会有多少⼈?意思就是说,你500万的注册⽤户,并不是每个⼈每天都来光顾你这⾥ 的!
即使按照28法则,你5000万的注册⽤户,每天最多是20%的⽤户会过来光顾你这⾥,也就是会来访问你的APP/ ⼩程序/⽹站,也就是1000万的⽇活⽤户,但是这个⽇活⽐例恐怕很多公司都达不到,所以⼀般靠谱点就算他是10%的⽤户每天 会来光顾你,算下来就是平均每个注册⽤户10天会来光顾你⼀次,这就是100万的⽇活⽤户
但是这50万的⽇活⽤户仅仅是来看看⽽已,那么有多少⼈会来买你的东西呢?这个购买⽐例可就更低了,基本上很可能这种⼩ 型互联⽹公司每天就做个1w订单,或者⼏万订单,这就已经相当的不错了,咱们就以保守点按3w订单来算吧。
这个互联⽹公司的订单表每天新增数据⼤概是3w左右,每个⽉是新增90w数据,每年是新增1080w数据。⼤家 对这个数据量感觉如何?看着不⼤是吧,但是按照我们上次说的,⼀般建议单表控制在千万以内,尽量是100w到500w之间, 如果控制在⼏⼗万是最好了!
所以说这个订单表,即使你按⼀年1080w数据增长来计算,最多3年就到千万级⼤表了,这个就绝对会导致你涉及订单的操作, 速度挺慢的。
所以说,基本上个这类订单表,哪怕是个⼩互联⽹公司,按分库分表⼏乎是必须得做的,那么怎么做呢?订单表,⼀般在拆分 的时候,往往要考虑到三个维度,⼀个是必然要按照订单id为粒度去分库分表,也就是把订单id进⾏hash后,对表数量进⾏取 模然后把订单数据均匀分散到100~1000个表⾥去,再把这些表分散在多台服务器上。
但是这⾥有个问题,另外两个维度是⽤户端和运营端,⽤户端,就是⽤户可能要查⾃⼰的订单,运营端就是公司可能要查所有 订单,那么怎么解决这类问题呢?其实就跟上次的差不多,基本上针对⽤户端,你就需要按照(userid, orderid)这个表结 构,去做⼀个索引映射表。
userid和orderid的⼀⼀对应映射关系要放在这个表⾥,然后针对userid为粒度去进⾏分库分表,也就是对userid进⾏hash后取 模,然后把数据均匀分散在很多索引映射表⾥,再把表放在很多数据库⾥。
然后每次⽤户端拿出APP查询⾃⼰的订单,直接根据userid去hash然后取模路由到⼀个索引映射表,找到这个⽤户的orderid, 这⾥当然可以做⼀个分页了,因为⼀般订单都是⽀持分页的,此时可以允许⽤于户分页查询orderid,然后拿到⼀堆orderid了, 再根据orderid去按照orderid粒度分库分表的表⾥提取订单完整数据
⾄于运营端,⼀般都是要根据N多条件对订单进⾏搜索的,此时跟上次讲的⼀样,可以把订单数据的搜索条件都同步到ES⾥, 然后⽤ES来进⾏复杂搜索,找出来⼀波orderid,再根据orderid去分库分表⾥找订单完整数据。 其实⼤家到最后会发现,分库分表的玩法基本都是这套思路,
按业务id分库分表,建⽴索引映射表同时进⾏分库分表,数据同 步到ES做复杂搜索,基本这套玩法就可以保证你的分库分表场景下,各种业务功能都可以⽀撑了
基本上你只要按照userid先去分库分表的(userid, orderid)索引映射表⾥查找到你的那些 orderid,然后搞⼀个分页就可以了,对分页内的orderid,每个orderid都得去按orderid分库分表的数据⾥查找完整的订 单数据,这就可以搞定分库分表环境的下分页问题了。
告诉你的是,如果要在分库分表环境下搞分页,最好是保证你的⼀个主数据粒度(⽐如userid)是 你的分库分表的粒度,你可以根据⼀个业务id路由到⼀个表找到他的全部数据,这就可以做分页了。
但是此时可能有⼈会提出⼀个疑问了,那如果说现在我想要对⽤户下的订单做分页,但是同时还能⽀持指定⼀些查询 条件呢?对了,这其实也是很多APP⾥都⽀持的,就是对⾃⼰的订单查询,有的APP是⽀持指定⼀些条件的,甚⾄是 排序规则,⽐如订单名称模糊搜索,或者是别的条件,⽐如说订单状态。
举个例⼦吧,⽐如说最经典的某个电商APP,⼤家平时都玩⼉的⼀个,在我的订单界⾯,可以按照订单状态来搜索, 分别是全部、待付款、待收货、已完成、已取消⼏个状态,同时就是对订单购买的商品标题进⾏模糊搜索。 那么此时你怎么玩⼉分页呢?因为毕竟你的索引映射表⾥,只有(userid, orderid)啊!可是这⼜如何呢?你完全可以 在这个索引映射表⾥加⼊更多的数据,⽐如(userid, orderid, order_status, product_description),加上订单所处的状 态,以及商品的标题、副标题等⽂本。
对我的订单进⾏分页的 时候,直接就可以根据userid去索引映射表⾥找到⽤户的所有订单,然后按照订单状 态、商品描述⽂本模糊匹配去搜索,完了再分页,分页拿到的orderid,再去获取订单需要展⽰的数据,⽐如说订单⾥ 包含的商品列表,每个商品的缩略图、名称、价格以及所属店铺。
是针对运营端的分页查询需求呢?这还⽤说?上次都提过了,数据直接进⼊ES⾥,通过ES就可以对多条件进⾏ 搜索同时再进⾏分页了,这很好搞定!
当然,⽹上是有⼈说过⼀些所谓的跨库的分页⽅案,⽐如说⼀定要针对跨多个库和多个表的数据搞查询和分页,那这 种如果你⼀定要做,基本上只能是⾃⼰从各个库表拉数据到内存,⾃⼰内存⾥做筛选和分页了,或者是基于数据库中 间件去做,那数据库中间件本质也是⼲这个,把各个库表的数据拉到内存做筛选和分页。
实际上我是绝对反对这种⽅案的,因为效率和性能极差,基本都是⼏秒级别的速度。 所以当你觉得似乎必须要跨库和表查询和分页的时候,我建议你,第⼀,你考虑⼀下是不是可以把你查询⾥按照某个 主要的业务id进⾏分库分表建⽴⼀个索引映射表,第⼆是不是可以可以把这个查询⾥要的条件都放到索引映射表⾥去, 第三,是不是可以通过ES来搞定这个需求。 尽可能还是按照上述思路去做分库分表下的分页,⽽不要去搞跨库/表的分页查询。
从⼀开始,你的表数量宁愿多⼀些,也别太少了,最好是计算⼀下数据增量,让⾃⼰永远不⽤增加更多的表
其次,万⼀是过了⼏年后,你的每⼀台服务器上的存储空间要耗尽了呢?或者是写并发压⼒太⼤,每个服务器的并发压⼒都到 瓶颈了呢?此时还⽤说么,当然要增加更多的数据库服务器了!但是增加服务器之后,那么你的表怎么办呢? 简单,此时你就得把你的表均匀分散迁移到新增加的数据库服务器上去,然后再修改⼀下系统⾥的路由规则就可以了,⽤新的 路由规则保证你能正确的把数据路由到指定表以及指定库上去就没问题了。 因此关于数据库扩容这块,虽然⽹上有很多⽅案,但是我们建议的就是,刚开始拆分,表数量可以多⼀些,避免后续要增加 表。然后数据库服务器要扩容是没问题的,直接把表做⼀下迁移就⾏了,然后修改路由规则。