admin 管理员组文章数量: 1184232
数据库基础知识
范式化设计
目前关系数据库有六种范式:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)、第四范式(4NF)和第五范式5NF,又称完美范式)。
满足最低要求的范式是第一范式(1NF),在第一范式的基础上进一步满足更多规范要求的称为第二范式(2NF),其余范式以次类推。一般来说,数据库只需满足第三范式(3NF)就行了。
第一范式(1NF)
定义: 属于第一范式关系的所有属性都不可再分,即数据项不可分。
理解:第一范式强调数据表的原子性,是其他范式的基础。一张表有一个name-age列,这个列具有两个属性,一个name,一个 age,所以不符合第一范式,把它拆分成两列name和age,这张表就符合第一范式关系。
关系型数据库管理系统(RDBMS),例如SQL Server,Oracle,MySQL中创建数据表的时候,1NF是所有关系型数据库设计的最基本要求。
第一范式详细的要求如下:
1、每一列属性都是不可再分的属性值,确保每一列的原子性;(核心)
2、两列的属性相近或相似或一样,尽量合并属性一样的列,确保不产生冗余数据;
3、单一属性的列为基本数据类型构成;
4、设计出来的表都是简单的二维表。
第二范式(2NF)
第二范式(2NF)是在第一范式(1NF)的基础上建立起来的,即满足第二范式(2NF)必须先满足第一范式(1NF)。
第二范式(2NF)要求实体的属性完全依赖于主关键字。
以上这张表不符合第二范式(2NF),虽然有主键,但是实体的属性不完全依赖于主关键字。
所谓完全依赖是指不能存在仅依赖主关键字一部分的属性,如果存在,那么这个属性和主关键字的这一部分应该分离出来形成一个新的实体,新实体与原实体之间是一对多的关系。
设计成两张表,主键分别是id和op_id,这样就符合第二范式(2NF)。
第三范式(3NF)
满足第三范式(3NF)必须先满足第二范式(2NF);
第三范式(3NF)要求一个数据库表中不包含已在其它表中包含的非主关键字信息,即数据不能存在传递关系,即每个属性都跟主键有直接关系而不是间接关系。
产品表
这里如果产品ID或产品名称变化会发生什么情况?所以以上不符合第三范式(3NF)
以上订单表就符合第三范式
反范式化设计
完全符合范式化的设计真的完美无缺吗?很明显在实际的业务查询中会大量存在着表的关联查询,而表设计都做成了范式化设计(甚至很高的范式),大量的表关联很多的时候非常影响查询的性能。
反范式化就是违反范式化设计:
1、为了性能和读取效率而适当的违反对数据库设计范式的要求;
2、为了查询的性能,允许存在部分(少量)冗余数据
换句话来说反范式化就是使用空间来换取时间。
范式化和反范式对比
| 范式设计 | 反范式设计 | |
|---|---|---|
| 更新操作 | 快 | 慢 |
| 数据重复度 | 低 | 高 |
| 内存占用 | 小 | 大 |
| 查询表关联 | 多 | 少 |
| 查询索引命中 | 较少命中 | 更多命中 |
1、范式化的更新操作通常比反范式化要快(字段较少)。
2、当数据较好地范式化时,就只有很少或者没有重复数据,所以只需要修改更少的数据。
3、范式化的表通常更小,所以占据的内存更少。
4、范式化设计的缺点是通常需要关联,稍微复杂一些的查询语句在符合范式的表上都可能需要至少一次关联,也许更多。
5、复杂一些的查询语句也可能使一些索引策略无效。例如,范式化可能将列存放在不同的表中,而这些列如果在一个表中本可以属于同一个索引。
项目中常见的反范式实现
范式化和反范式化的各有优劣,怎么选择最佳的设计?小孩子才做选择,我们全都要!!!
缓存与汇总数据
“缓存”来表示存储那些可以比较简单地从其他表获取数据的表。
比如从父表冗余一些数据到子表的。前面看到的分类信息放到商品表里面进行冗余存放就是典型的例子。
“汇总”则保存的是使用GROUP BY语句聚合数据的表。
如果需要显示每个用户发了多少消息,可以每次执行一个对用户发送消息进行count的子查询来计算并显示它,也可以在user表用户中建一个消息发送数目的专门列,每当用户发新消息时更新这个值。
在使用缓存表和汇总表时,有个关键点是如何维护缓存表和汇总表中的数据,常用的有两种方式,实时维护数据和定期重建,这个取决于应用程序,不过一般来说,缓存表用实时维护数据更多点,往往在一个事务中同时更新数据本表和缓存表,汇总表则用定期重建更多,使用定时任务对汇总表进行更新。
计数器表设计
计数器表在Web应用中很常见。比如网站点击数、用户的朋友数、文件下载次数等。对于高并发下的处理,首先可以创建一张独立的表存储计数器,这样可使计数器表小且快,并且可以使用一些更高级的技巧。
比如假设有一个计数器表,只有一行数据,记录网站的点击次数,网站的每次点击都会导致对计数器进行更新,问题在于,对于任何想要更新这一行的事务来说,这条记录上都有一个全局的互斥锁(mutex)。这会使得这些事务只能串行执行,会严重限制系统的并发能力。
怎么改进呢?可以将计数器保存在多行中,每次随机选择一行进行更新。在具体实现上,可以增加一个槽(slot)字段,然后预先在这张表增加100行或者更多数据,当对计数器更新时,选择一个随机的槽(slot)进行更新即可。
字段数据类型优化
MySQL支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择。
字段优化基本原则
-
更小的通常更好
比如:是有一个类型既可以用字符串也可以使用整型,优先选择整型。因为字符串牵涉到了字符集及校对规则等。 -
简单就好
简单数据类型的操作通常需要更少的CPU周期。 -
尽量避免NULL
通常情况下最好指定列为NOT NULL
如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引、索引统计和值比较都更复杂。
通常把可为NULL的列改为NOT NULL带来的性能提升比较小,所以(调优时)没有必要首先在现有schema中查找并修改掉这种情况,除非确定这会导致问题。但是,如果计划在列上建索引,就应该尽量避免设计成可为NULL的列。
命名规范
1、可读性原则
数据库、表、字段的命名要遵守可读性原则,尽可能少使用或者不使用缩写。
最好遵循“业务名称_表的作用”;对于存储过程应该能够体现存储过程的功能。库名与应用名称尽量一致。
表达是与否概念的字段,应该使用is_xxx的方式命名,数据类型是unsigned tinyint(1表示是,0表示否)。
2、表名、字段名必须使用小写字母或数字,禁止出现数字开头,禁止两个下划线中间只出现数字。数据库字段名的修改代价很大,因为无法进行预发布,所以字段名称需要慎重考虑。
说明:MySQL在Windows下不区分大小写,但在Linux下默认是区分大小写。因此,数据库名、表名、字段名,都不允许出现任何大写字母,避免节外生枝。
3、表名不使用复数名词
4、数据库、表、字段的命名禁用保留字,如desc、range、match之类
6、主键索引名为pk_字段名;唯一索引名为uk_字段名;普通索引名则为idx_字段名。
MySql的索引
MySQL官方对索引的定义为:索引(Index)是帮助MySQL 高效获取数据的数据结构。可以得到索引的本质: 索引是数据结构 。
InnoDB存储引擎支持以下几种常见的索引:B+树索引、全文索引、哈希索引,其中比较关键的是B+树索引
Hash索引的问题
1、hash表只能匹配是否相等,不能实现范围查找;
2、无法排序,当需要按照索引进行order by时,hash值没办法支持排序;
3、组合索引可以支持部分索引查询,如(a,b,c)的组合索引,查询中只用到了a和b也可以查询的,如果使用hash表,组合索引会将几个字段合并hash,没办法支持部分索引,特别是**组合索引**;
4、Hash冲突,当数据量很大时,hash冲突的概率也会加大。
B+Tree
B+树索引就是传统意义上的索引,这是目前关系型数据库系统中查找最常用和最为有效的索引。B+树索引的构造类似于二叉树,根据键值(Key Value)快速找到数据。注意B+树中的B不是代表二叉(binary),而是代表平衡(balance),因为B+树是从最早的平衡二叉树演化而来,但是B+树不是一个二叉树。
在讲二叉树之前,必须了解一下二分查找:
二分查找法(binary search) 也称为折半查找法,用来查找一组有序的记录数组中的某一记录。
在以下数组中找到数字48对应的下标
通过3次二分查找 就找到了所要的数字,而顺序查找需8次。
对于上面10个数来说,顺序查找平均查找次数为(1+2+3+4+5+6+7+8+9+10)/10=5.5次。而二分查找法为(4+3+2+4+3+1+4+3+2+3)/10=2.9次。在最坏的情况下,顺序查找的次数为10,而二分查找的次数为4。
所以为了索引查找的高效性,引入了二叉查找树。
二叉树
树(Tree)
N个结点构成的有限集合。
- 树中有一个称为”根(Root)”的特殊结点
- 其余结点可分为M个互不相交的树,称为原来结点的”子树”
树与非树
树的一些基本术语
二叉树
度为2的树(也可称之为阶):(树的度:树中所有结点中最大的度。结点的度:结点的子树个数)
子树有左右顺序之分:
二叉查找(搜索)树
二叉查找树首先肯定是个二叉树,除此之外还符合以下几点:
- 左子树的所有的值小于根节点的值
- 右子树的所有的值大于或等于根节点的值
- 左、右子树满足以上两点
但是二叉查找树,如果设计不良,完全可以变成一颗极不平衡的二叉查找树:
因此若想最大性能地构造一棵二叉查找树,需要这棵二叉查找树是平衡的,从而引出了新的定义——平衡二叉树,或称为AVL树。
平衡二叉树(AVL-树)
它是一棵二叉排序树,它的左右两个子树的高度差(平衡因子)的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
目的:使得树的高度最低,因为树查找的效率决定于树的高度
平衡二叉树的查找性能是比较高的,但是维护一棵平衡二叉树的代价是非常大的。通常来说,需要1次或多次左旋和右旋来得到插入、更新和删除后树的平衡性。
B+树
B+ 树是从平衡二叉查找树演化而来(但B+树不是二叉树,而是一个多叉查找平衡树)。
下图就是一颗平衡二叉查找树
借助网页工具:Data Structure Visualization (usfca.edu)
现在将其改造成 B+ 树
树的阶数表示一个节点最多能有多少个子节点。
每个叶子页(LeafPage)存储了实际的数据,如下图中有的叶子页就存放了3条数据记录,当然可以更多,叶子节点由小到大(有序)串联在一起,叶子页中的数据也是排好序的;
从AVL到B+树的变化可知,如果节点特别多的话,AVL树的高度远远高于B+树。
可以归纳出B+树的几个特征:
1、相同节点数量的情况下,B+树高度远低于平衡二叉树;
2、非叶子节点只保存索引信息和下一层节点的指针信息,不保存实际数据记录;
3、每个叶子页(LeafPage)存储了实际的数据,比如上图中每个叶子页就存放了3条数据记录,当然可以更多,叶子节点由小到大(有序)串联在一起,叶子页中的数据也是排好序的;
4、相邻的叶子节点之间用指针相连。
注意:叶子节点中的数据在物理存储上完全可以是无序的,仅仅是在逻辑上有序(通过指针串在一起)。
B树也B+树的差别是,B树的非叶子节点也需要存放数据,下图是B树
而B+树的话,数据只存在叶子节点上,同时相邻的叶子节点有链表的结构
同时要注意,MySQL中实现的B+树,叶子节点之间的链表是双向链表,这是一个细微的差别。
B* 树
B* 树的话,与B+树的差别就是在非叶子节点之间,也有相互的指针指向。Oracle中使用的是B 树*。
非叶子节点上也有指针互相指向
MySQL中的索引
InnoDB存储引擎支持以下几种常见的索引:B+树索引、全文索引、哈希索引,其中比较关键的是B+树索引
B+树索引
InnoDB中的索引自然也是按照B+树来组织的,B+树的叶子节点用来放数据的,但是放什么数据呢?索引自然是要放的,因为B+树的作用本来就是就是为了快速检索数据而提出的一种数据结构,不放索引放什么呢?但是数据库中的表,数据才是真正需要的数据,索引只是辅助数据,甚至于一个表可以没有自定义索引。InnoDB中的数据到底是如何组织的?
聚集索引/聚簇索引
InnoDB中使用了聚集索引,就是将表的主键用来构造一棵B+树,并且将整张表的行记录数据存放在该B+树的叶子节点中。也就是所谓的索引即数据,数据即索引。由于聚集索引是利用表的主键构建的,所以每张表只能拥有一个聚集索引。
聚集索引的叶子节点就是数据页。换句话说,数据页上存放的是完整的每行记录。因此聚集索引的一个优点就是:通过过聚集索引能获取完整的整行数据。另一个优点是:对于主键的排序查找和范围查找速度非常快。
如果没有定义主键呢?MySQL会使用唯一性索引,没有唯一性索引,MySQL也会创建一个隐含列RowID来做主键,然后用这个主键来建立聚集索引。
辅助索引/二级索引
聚簇索引只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。
如果想以别的列作为搜索条件怎么办?一般会建立多个索引,这些索引被称为辅助索引/二级索引。
(每建立一个索引,就有一颗B+树)
对于辅助索引(Secondary Index,也称二级索引、非聚集索引),叶子节点并不包含行记录的全部数据。叶子节点除了包含键值以外,每个叶子节点中的索引行中还包含了一个书签( bookmark)。该书签用来告诉InnoDB存储引擎哪里可以找到与索引相对应的行数据。因此InnoDB存储引擎的辅助索引的书签就是相应行数据的聚集索引键。
比如辅助索引index(node),那么叶子节点中包含的数据就包括了(note和主键)。
回表
辅助索引的存在并不影响数据在聚集索引中的组织,因此每张表上可以有多个辅助索引。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过主键索引(聚集索引)来找到一个完整的行记录。这个过程也被称为 回表 。也就是根据辅助索引的值查询一条完整的用户记录需要使用到2棵B+树----一次辅助索引,一次聚集索引。
为什么还需要一次回表操作呢?直接把完整的用户记录放到辅助索引d的叶子节点不就好了么?如果把完整的用户记录放到叶子节点是可以不用回表,但是太占地方了,相当于每建立一棵B+树都需要把所有的用户记录再都拷贝一遍,这就有点太浪费存储空间了。而且每次对数据的变化要在所有包含数据的索引中全部都修改一次,性能也非常低下。
很明显,回表的记录越少,性能提升就越高,需要回表的记录越多,使用二级索引的性能就越低,甚至让某些查询宁愿使用全表扫描也不使用二级索引。
那什么时候采用全表扫描的方式,什么时候使用采用二级索引 + 回表的方式去执行查询呢?这就是查询优化器做的工作,查询优化器会事先对表中的记录计算一些统计数据,然后再利用这些统计数据根据查询的条件来计算一下需要回表的记录数,需要回表的记录数越多,就越倾向于使用全表扫描,反之倾向于使用二级索引 + 回表的方式。
联合索引/复合索引
索引的描述,隐含了一个条件,那就是构建索引的字段只有一个,但实践工作中构建索引的完全可以是多个字段。所以,将表上的多个列组合起来进行索引。称之为联合索引或者复合索引,比如index(a,b)就是将a,b两个列组合起来构成一个索引。
千万要注意一点,建立联合索引只会建立1棵B+树,多个列分别建立索引会分别以每个列则建立B+树,有几个列就有几个B+树,比如,index(note)、index(b),就分别对note,b两个列各构建了一个索引。
而如果是index(note,b)在索引构建上,包含了两个意思:
1、先把各个记录按照note列进行排序。
2、在记录的note列相同的情况下,采用b列进行排序
从原理可知,为什么有最佳左前缀法则,就是这个道理
SQL优化的其中一个核心思想是减少索引的数量(联合/复合索引)
覆盖索引
既然多个列可以组合起来构建为联合索引,那么辅助索引自然也可以由多个列组成。
InnoDB存储引擎支持覆盖索引(covering index,或称索引覆盖),即从辅助索引中就可以得到查询的记录,而不需要查询聚集索引中的记录(回表)。使用覆盖索引的一个好处是辅助索引不包含整行记录的所有信息,故其大小要远小于聚集索引,因此可以减少大量的IO操作。所以记住,覆盖索引并不是索引类型的一种。
哈希索引
InnoDB存储引擎还有一种自适应哈希索引。
B+树的查找次数,取决于B+树的高度,在生产环境中,B+树的高度一般为3、4层,故需要3、4次的IO查询。
所以在InnoDB存储引擎内部自己去监控索引表,如果监控到某个索引经常用,那么就认为是热数据,然后内部自己创建一个hash索引,称之为自适应哈希索引( Adaptive Hash Index,AHI),创建以后,如果下次又查询到这个索引,那么直接通过hash算法推导出记录的地址,直接一次就能查到数据,比重复去B+tree索引中查询三四次节点的效率高了不少。
InnoDB存储引擎使用的哈希函数采用除法散列方式,其冲突机制采用链表方式。注意,对于自适应哈希索引仅是数据库自身创建并使用的,并不能对其进行干预。
show engine innodb status
哈希索引只能用来搜索等值的查询,如 SELECT* FROM table WHERE index co=xxx。而对于其他查找类型,如范围查找,是不能使用哈希索引的,
因此这里出现了non-hash searches/s的情况。通过 hash searches: non- hash searches可以大概了解使用哈希索引后的效率。
innodb_adaptive_hash_index来考虑是禁用或启动此特性,默认AHI为开启状态。
全文索引
什么是全文检索(Full-Text Search)?它是将存储于数据库中的整本书或整篇文章中的任意内容信息查找出来的技术。它可以根据需要获得全文中有关章、节、段、句、词等信息,也可以进行各种统计和分析。比较熟知的Elasticsearch、Solr等就是全文检索引擎,底层都是基于Apache Lucene的。
索引在查询中的使用
索引在查询中的作用到底是什么?在查询中发挥着什么样的作用呢?
1、 一个索引就是一个B+树,索引让查询可以快速定位和扫描到需要的数据记录上,加快查询的速度 。
2、一个select查询语句在执行过程中一般最多能使用一个二级索引,即使在where条件中用了多个二级索引。
高性能的索引创建策略
正确地创建和使用索引是实现高性能查询的基础。
索引列的类型尽量小
在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TTNYINT、NEDUMNT、INT、BIGTNT这么几种,它们占用的存储空间依次递增,这里所说的类型大小指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如能使用INT就不要使用BIGINT,能使用NEDIUMINT就不要使用INT,这是因为数据类型越小,在查询时进行的比较操作越快(CPU层次)数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘/0带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。
这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键适用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/0。
索引的选择性
创建索引应该选择选择性/离散性高的列。索引的选择性/离散性是指,不重复的索引值(也称为基数,cardinality)和数据表的记录总数(N)的比值,范围从1/N到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。
很差的索引选择性就是列中的数据重复度很高,比如性别字段,不考虑政治正确的情况下,只有两者可能,男或女。那么在查询时,即使使用这个索引,从概率的角度来说,依然可能查出一半的数据出来。
哪列做为索引字段最好?当然是姓名字段,因为里面的数据没有任何重复,性别字段是最不适合做索引的,因为数据的重复度非常高。
**怎么算索引的选择性/离散性?**比如person这个表:
SELECT count(DISTINCT name)/count() FROM person;
SELECT count(DISTINCT sex)/count() FROM person;
SELECT count(DISTINCT age)/count() FROM person;
SELECT count(DISTINCT area)/count() FROM person;
前缀索引
针对blob、text、很长的varchar字段,mysql不支持索引他们的全部长度,需建立前缀索引。
语法:Alter table tableName add key/index (column(X))
**缺点:**前缀索引是一种能使索引更小、更快的有效办法,但另一方面也有其缺点MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。
有时候后缀索引 (suffix
index)也有用途(例如,找到某个域名的所有电子邮件地址)。MySQL原生并不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。可以通过触发器或者应用程序自行处理来维护索引。
案例:
首先找到最常见的值的列表:
SELECT COUNT(DISTINCT LEFT(order_note,3))/COUNT(*) AS sel3,
COUNT(DISTINCT LEFT(order_note,4))/COUNT(*)AS sel4,
COUNT(DISTINCT LEFT(order_note,5))/COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(order_note, 6))/COUNT(*) As sel6,
COUNT(DISTINCT LEFT(order_note, 7))/COUNT(*) As sel7,
COUNT(DISTINCT LEFT(order_note, 8))/COUNT(*) As sel8,
COUNT(DISTINCT LEFT(order_note, 9))/COUNT(*) As sel9,
COUNT(DISTINCT LEFT(order_note, 10))/COUNT(*) As sel10,
COUNT(DISTINCT LEFT(order_note, 11))/COUNT(*) As sel11,
COUNT(DISTINCT LEFT(order_note, 12))/COUNT(*) As sel12,
COUNT(DISTINCT LEFT(order_note, 13))/COUNT(*) As sel13,
COUNT(DISTINCT LEFT(order_note, 14))/COUNT(*) As sel14,
COUNT(DISTINCT LEFT(order_note, 15))/COUNT(*) As sel15,
COUNT(DISTINCT order_note)/COUNT(*) As total
FROM order_exp;
可以看见,从第10个开始选择性的增加值很高,随着前缀字符的越来越多,选择度也在不断上升,但是增长到第15时,已经和第14没太大差别了,选择性提升的幅度已经很小了,都非常接近整个列的选择性了。
那么针对这个字段做前缀索引的话,从第13到第15都是不错的选择
在上面的示例中,已经找到了合适的前缀长度,如何创建前缀索引:
ALTER TABLE order_exp ADD KEY (order_note(14));
建立前缀索引后查询语句并不需要更改:
select * from order_exp where order_note = ‘xxxx’ ;
前缀索引是一种能使索引更小、更快的有效办法,但另一方面也有其缺点MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。
有时候后缀索引 (suffix index)也有用途(例如,找到某个域名的所有电子邮件地址)。MySQL原生并不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。可以通过触发器或者应用程序自行处理来维护索引。
多列索引
很多人对多列索引的理解都不够。一个常见的错误就是,为每个列创建独立的索引,或者按照错误的顺序创建多列索引。
遇到的最容易引起困惑的问题就是索引列的顺序。正确的顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要。反复强调过,在一个多列B-Tree索引中,索引列的顺序意味着索引首先按照最左列进行排序,其次是第二列,等等。所以,索引可以按照升序或者降序进行扫描,以满足精确符合列顺序的ORDER BY、GROUP BY和DISTINCT等子句的查询需求。
所以多列索引的列顺序至关重要。对于如何选择索引的列顺序有一个经验法则:将选择性最高的列放到索引最前列。当不需要考虑排序和分组时,将选择性最高的列放在前面通常是很好的。这时候索引的作用只是用于优化WHERE条件的查找。在这种情况下,这样设计的索引确实能够最快地过滤出需要的行,对于在WHERE子句中只使用了索引部分前缀列的查询来说选择性也更高。
然而,性能不只是依赖于索引列的选择性,也和查询条件的有关。可能需要根据那些运行频率最高的查询来调整索引列的顺序,比如排序和分组,让这种情况下索引的选择性最高。
同时,在优化性能的时候,可能需要使用相同的列但顺序不同的索引来满足不同类型的查询需求。
三星索引
三星索引概念
对于一个查询而言,一个三星索引,可能是其最好的索引。
满足的条件如下:
- 索引
将相关的记录放到一起则获得一星 (比重27%) - 如果索引中的
数据顺序和查找中的排列顺序一致则获得二星(排序星) (比重27%) - 如果索引中的
列包含了查询中需要的全部列则获得三星(宽索引星) (比重50%)
这三颗星,哪颗最重要?第三颗星。因为将一个列排除在索引之外可能会导致很多磁盘随机读(回表操作)。第一和第二颗星重要性差不多,可以理解为第三颗星比重是50%,第一颗星为27%,第二颗星为23%,所以在大部分的情况下,会先考虑第一颗星,但会根据业务情况调整这两颗星的优先度。
一星:
一星的意思就是:如果一个查询相关的索引行是相邻的或者至少相距足够靠近的话,必须扫描的索引片宽度就会缩至最短,也就是说,让索引片尽量变窄,也就是说索引的扫描范围越小越好。
二星(排序星) :
在满足一星的情况下,当查询需要排序,group by、 order by,如果查询所需的顺序与索引是一致的(索引本身是有序的),是不是就可以不用再另外排序了,一般来说排序可是影响性能的关键因素。
三星(宽索引星) :
在满足了二星的情况下,如果索引中所包含了这个查询所需的所有列(包括 where 子句和 select 子句中所需的列,也就是覆盖索引),这样一来,查询就不再需要回表了,减少了查询的步骤和IO请求次数,性能几乎可以提升一倍。
设计三星索引实战
现在有表,SQL如下
CREATE TABLE customer (
cno INT,
lname VARCHAR (10),
fname VARCHAR (10),
sex INT,
weight INT,
city VARCHAR (10)
);
CREATE INDEX idx_cust ON customer (city, lname, fname, cno);
对于下面的SQL而言,这是个三星索引
select cno,fname from customer where lname=’xx’ and city =’yy’ order by fname;
来评估下:
第一颗星:所有等值谓词的列,是组合索引的开头的列,可以把索引片缩得很窄,符合。
条件已经把搜索范围搜到很窄了
第二颗星:order by的fname字段在组合索引中且是索引自动排序好的,符合。
第三颗星:select中的cno字段、fname字段在组合索引中存在,符合。
现在有表,SQL如下:
CREATE TABLE `test` (
`id` INT (11) NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR (100) DEFAULT NULL,
`sex` INT (11) DEFAULT NULL,
`age` INT (11) DEFAULT NULL,
`c_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
) ENGINE = INNODB AUTO_INCREMENT = 12 DEFAULT CHARSET = utf8;
SQL语句如下:
select user_name,sex,age from test where user_name like 'test%' and sex =1 ORDER BY age
如果建立索引(user_name,sex,age):
第三颗星,满足
第一颗星,满足
第二颗星,不满足,user_name 采用了范围匹配,sex 是过滤列,此时age 列无法保证有序的。
此时索引(user_name,sex,age)并不能满足三星索引中的第二颗星(排序)。
于是改改,建立索引(sex, age,user_name):
第一颗星,不满足,只可以匹配到sex,sex选择性很差,意味着是一个宽索引片(同时因为age也会导致排序选择的碎片问题)
第二颗星,满足,等值sex 的情况下,age是有序的,
第三颗星,满足,select查询的列都在索引列中,
对于索引(sex,age,user_name)可以看到,此时无法满足第一颗星,窄索引片的需求。
以上2个索引,都是无法同时满足三星索引设计中的三个需求的,只能尽力满足2个。而在多数情况下,能够满足2颗星,已经能缩小很大的查询范围了,具体最终要保留那一颗星(排序星 or 窄索引片星),这个就需要看查询者自己的着重点了,无法给出标准答案。1.3.MySQL性能调优
MySQL调优
MySQL调优金字塔
很明显从图上可以看出,越往上走,难度越来越高,收益却是越来越小的。
对于架构调优,在系统设计时首先需要充分考虑业务的实际情况,是否可以把不适合数据库做的事情放到数据仓库、搜索引擎或者缓存中去做;然后考虑写的并发量有多大,是否需要采用分布式;最后考虑读的压力是否很大,是否需要读写分离。对于核心应用或者金融类的应用,需要额外考虑数据安全因素,数据是否不允许丢失。所以在进行优化时,首先需要关注和优化的应该是架构,如果架构不合理,即使是DBA能做的事情其实是也是比较有限的。
对于MySQL调优,需要确认业务表结构设计是否合理,SQL语句优化是否足够,该添加的索引是否都添加了,是否可以剔除多余的索引等等
比如硬件和OS调优,需要对硬件和OS有着非常深刻的了解,仅仅就磁盘一项来说,一般非DBA能想到的调整就是SSD盘比用机械硬盘更好。DBA级别考虑的至少包括了,使用什么样的磁盘阵列(RAID)级别、是否可以分散磁盘IO、是否使用裸设备存放数据,使用哪种文件系统(目前比较推荐的是XFS),操作系统的磁盘调度算法选择,是否需要调整操作系统文件管理方面比如atime属性等等。
SQL/索引调优要求对业务和数据流非常清楚。在阿里巴巴内部,有三分之二的DBA是业务DBA,从业务需求讨论到表结构审核、SQL语句审核、上线、索引更新、版本迭代升级,甚至哪些数据应该放到非关系型数据库中,哪些数据放到数据仓库、搜索引擎或者缓存中,都需要这些DBA跟踪和复审。他们甚至可以称为数据架构师(Data Architecher)。
查询性能优化
如何设计最优的库表结构、如何建立最好的索引,这些对于高性能来说是必不可少的。但这些还不够—还需要合理的设计查询。如果查询写得很糟糕,即使库表结构再合理、索引再合适,也无法实现高性能。
什么是慢查询
慢查询日志,顾名思义,就是查询花费大量时间的日志,是指mysql记录所有执行超过long_query_time参数设定的时间阈值的SQL语句的日志。该日志能为SQL语句的优化带来很好的帮助。默认情况下,慢查询日志是关闭的,要使用慢查询日志功能,首先要开启慢查询日志功能。
慢查询基础-优化数据访问
查询性能低下最基本的原因是访问的数据太多。大部分性能低下的查询都可以通过减少访问的数据量的方式进行优化。对于低效的查询,一般通过下面两个步骤来分析总是很有效:
1.确认应用程序是否在检索大量超过需要的数据。这通常意味着访问了太多的行,但有时候也可能是访问了太多的列。
2.确认MySQL服务器层是否在分析大量超过需要的数据行。
请求了不需要的数据?
有些查询会请求超过实际需要的数据,然后这些多余的数据会被应用程序丢弃。这会给MySQL服务器带来额外的负担,并增加网络开销,另外也会消耗应用服务器的CPU和内存资源。比如:
查询不需要的记录
一个常见的错误是常常会误以为MySQL会只返回需要的数据,实际上MySQL却是先返回全部结果集再进行计算。经常会看到一些了解其他数据库系统的人会设计出这类应用程序。
以上SQL你认为MySQL会执行查询,并只返回他们需要的20条数据,然后停止查询。实际情况是MySQL会查询出全部的结果集,客户端的应用程序会接收全部的结果集数据,然后抛弃其中大部分数据。
总是取出全部列
每次看到SELECT*的时候都需要用怀疑的眼光审视,是不是真的需要返回全部的列?很可能不是必需的。取出全部列,会让优化器无法完成索引覆盖扫描这类优化,还会为服务器带来额外的I/O、内存和CPU的消耗。因此,一些DBA是严格禁止SELECT *的写法的,这样做有时候还能避免某些列被修改带来的问题。
尤其是使用二级索引,使用*的方式会导致回表,导致性能低下。
什么时候可以使用SELECT*如果应用程序使用了某种缓存机制,或者有其他考虑,获取超过需要的数据也可能有其好处,但不要忘记这样做的代价是什么。获取并缓存所有的列的查询,相比多个独立的只获取部分列的查询可能就更有好处。
重复查询相同的数据
不断地重复执行相同的查询,然后每次都返回完全相同的数据。比较好的方案是,当初次查询的时候将这个数据缓存起来,需要的时候从缓存中取出,这样性能显然会更好。
是否在扫描额外的记录
在确定查询只返回需要的数据以后,接下来应该看看查询为了返回结果是否扫描了过多的数据。对于MySQL,最简单的衡量查询开销的三个指标如下:
响应时间、扫描的行数、返回的行数
没有哪个指标能够完美地衡量查询的开销,但它们大致反映了MySQL在内部执行查询时需要访问多少数据,并可以大概推算出查询运行的时间。这三个指标都会记录到MySQL的慢日志中,所以检查慢日志记录是找出扫描行数过多的查询的好办法。
响应时间
响应时间是两个部分之和:服务时间和排队时间。
服务时间是指数据库处理这个查询真正花了多长时间。
排队时间是指服务器因为等待某些资源而没有真正执行查询的时间—-可能是等I/O操作完成,也可能是等待行锁,等等。
扫描的行数和返回的行数
分析查询时,查看该查询扫描的行数是非常有帮助的。这在一定程度上能够说明该查询找到需要的数据的效率高不高。
理想情况下扫描的行数和返回的行数应该是相同的。但实际情况中这种“美事”并不多。例如在做一个关联查询时,服务器必须要扫描多行才能生成结果集中的一行。扫描的行数对返回的行数的比率通常很小,一般在1:1和10:1之间,不过有时候这个值也可能非常非常大。
扫描的行数和访问类型
在评估查询开销的时候,需要考虑一下从表中找到某一行数据的成本。MySQL有好几种访问方式可以查找并返回一行结果。有些访问方式可能需要扫描很多行才能返回一行结果,也有些访问方式可能无须扫描就能返回结果。
在EXPLAIN语句中的type列反应了访问类型。访问类型有很多种,从全表扫描到索引扫描、范围扫描、唯一索引查询、常数引用等。这里列的这些,速度是从慢到快,扫描的行数也是从小到大。你不需要记住这些访问类型,但需要明白扫描表、扫描索引、范围访问和单值访问的概念。
如果查询没有办法找到合适的访问类型,那么解决的最好办法通常就是增加一个合适的索引,为什么索引对于查询优化如此重要了。索引让 MySQL以最高效、扫描行数最少的方式找到需要的记录。
一般 MySQL能够使用如下三种方式应用WHERE条件,从好到坏依次为:
1、在索引中使用WHERE条件来过滤不匹配的记录。这是在存储引擎层完成的。
select … from where a>100 and a <200
2、使用覆盖索引扫描来返回记录,直接从索引中过滤不需要的记录并返回命中的结果。这是在 MySQL服务器层完成的,但无须再回表查询记录。
3、从数据表中返回数据(存在回表),然后过滤不满足条件的记录。这在 MySQL服务器层完成,MySQL需要先从数据表读出记录然后过滤。
好的索引可以让查询使用合适的访问类型,尽可能地只扫描需要的数据行。
如果发现查询需要扫描大量的数据但只返回少数的行,那么通常可以尝试下面的技巧去优化它:
1、使用索引覆盖扫描,把所有需要用的列都放到索引中,这样存储引擎无须回表获取对应行就可以返回结果了
2、改变库表结构。例如使用单独的汇总表。
3、重写这个复杂的查询,让MySQL优化器能够以更优化的方式执行这个查询。
慢查询
慢查询配置
慢查询日志可以帮助定位可能存在问题的SQL语句,从而进行SQL语句层面的优化。但是默认值为关闭的,需要手动开启。
show VARIABLES like 'slow_query_log';
set GLOBAL slow_query_log=1;
开启1,关闭0
但是多慢算慢?MySQL中可以设定一个阈值,将运行时间超过该值的所有SQL语句都记录到慢查询日志中。long_query_time参数就是这个阈值。默认值为10,代表10秒。
show VARIABLES like '%long_query_time%';
当然也可以设置
set global long_query_time=0;
默认10秒,这里为了演示方便设置为0
同时对于运行的SQL语句没有使用索引,则MySQL数据库也可以将这条SQL语句记录到慢查询日志文件,控制参数是:
show VARIABLES like '%log_queries_not_using_indexes%';
开启1,关闭0(默认)
show VARIABLES like '%slow_query_log_file%';
小结
l slow_query_log 启动停止慢查询日志
l slow_query_log_file 指定慢查询日志得存储路径及文件(默认和数据文件放一起)
l long_query_time 指定记录慢查询日志SQL执行时间得伐值(单位:秒,默认10秒)
l log_queries_not_using_indexes 是否记录未使用索引的SQL
l log_output 日志存放的地方可以是[TABLE][FILE][FILE,TABLE]
执行计划的语法
执行计划的语法其实非常简单: 在SQL查询的前面加上EXPLAIN关键字就行。比如:EXPLAIN select * from table1
重点的就是EXPLAIN后面你要分析的SQL语句
除了以SELECT开头的查询语句,其余的DELETE、INSERT、REPLACE以及UPOATE语句前边都可以加上EXPLAIN,用来查看这些语句的执行计划,不过这里对SELECT语句更感兴趣,所以后边只会以SELECT语句为例来描述EsxPLAIN语句的用法。
执行计划详解
为了让大家先有一个感性的认识,把EXPLAIN语句输出的各个列的作用先大致罗列一下:
explain
select * from order_exp;
id : 在一个大的查询语句中每个SELECT关键字都对应一个唯一的id
select_type : SELECT****关键字对应的那个查询的类型
table :表名
partitions :匹配的分区信息
type :针对单表的访问方法
possible_keys :可能用到的索引
key :实际上使用的索引
key_len :实际使用到的索引长度
ref :当使用索引列等值查询时,与索引列进行等值匹配的对象信息
rows :预估的需要读取的记录条数
filtered :某个表经过搜索条件过滤后剩余记录条数的百分比
Extra :—些额外的信息
id
查询语句一般都以SELECT关键字开头,比较简单的查询语句里只有一个SELECT关键字,
稍微复杂一点的连接查询中也只有一个SELECT关键字,比如:
SELECT *FROM s1
INNER J0IN s2 ON s1.id = s2.id
WHERE s1.order_status = 0 ;
但是下边两种情况下在一条查询语句中会出现多个SELECT关键字:
1、查询中包含子查询的情况
比如下边这个查询语句中就包含2个SELECT关键字:
SELECT* FROM s1 WHERE id IN ( SELECT * FROM s2);
2、查询中包含UNION语句的情况
比如下边这个查询语句中也包含2个SELECT关键字:
SELECT * FROM s1
UNION SELECT * FROM s2 ;
查询语句中每出现一个SELECT关键字,MySQL就会为它分配一个唯一的id值。这个id值就是EXPLAIN语句的第一个列。
单SELECT关键字
比如下边这个查询中只有一个SELECT关键字,所以EXPLAIN的结果中也就只有一条id列为1的记录∶
EXPLAIN SELECT * FROM s1 WHERE order_no = 'a';
连接查询
对于连接查询来说,一个SELEOT关键字后边的FROM子句中可以跟随多个表,所以在连接查询的执行计划中,每个表都会对应一条记录,但是这些记录的id值都是相同的,比如:
EXPLAIN SELECT * FROM s1 WHERE order_no = 'a';
可以看到,上述连接查询中参与连接的s1和s2表分别对应一条记录,但是这两条记录对应的id值都是1。这里需要大家记住的是,在连接查询的执行计划中,每个表都会对应一条记录,这些记录的id列的值是相同的。
包含子查询
对于包含子查询的查询语句来说,就可能涉及多个SELECT关键字,所以在包含子查询的查询语句的执行计划中,每个SELECT关键字都会对应一个唯一的id值,比如这样:
EXPLAIN
SELECT * FROM s1 WHERE id IN (SELECT id FROM s2) OR order_no = 'a';
但是这里大家需要特别注意,查询优化器可能对涉及子查询的查询语句进行重写,从而转换为连接查询。所以如果想知道查询优化器对某个包含子查询的语句是否进行了重写,直接查看执行计划就好了,比如说:
EXPLAIN
SELECT * FROM s1 WHERE id IN (SELECT id FROM s2 WHERE order_no = 'a');
可以看到,虽然查询语句是一个子查询,但是执行计划中s1和s2表对应的记录的id值全部是1,这就表明了查询优化器将子查询转换为了连接查询,
包含UNION子句
对于包含UNION子句的查询语句来说,每个SELECT关键字对应一个id值也是没错的,不过还是有点儿特别的东西,比方说下边这个查询:
EXPLAIN
SELECT * FROM s1 UNION SELECT * FROM s2;
这个语句的执行计划的第三条记录为什么这样?UNION
子句会把多个查询的结果集合并起来并对结果集中的记录进行去重,怎么去重呢? MySQL使用的是内部的临时表。正如上边的查询计划中所示,UNION 子句是为了把id为1的查询和id为2的查询的结果集合并起来并去重,所以在内部创建了一个名为<union1,2>的临时表(就是执行计划第三条记录的table列的名称),id为NULL表明这个临时表是为了合并两个查询的结果集而创建的。
跟UNION 对比起来,UNION
ALL就不需要为最终的结果集进行去重,它只是单纯的把多个查询的结果集中的记录合并成一个并返回给用户,所以也就不需要使用临时表。所以在包含UNION ALL子句的查询的执行计划中,就没有那个id为NULL的记录,如下所示:
EXPLAIN
SELECT * FROM s1 UNION ALL SELECT * FROM s2;
table
不论查询语句有多复杂,里边包含了多少个表,到最后也是需要对每个表进行单表访问的,MySQL规定EXPLAIN语句输出的每条记录都对应着某个单表的访问方法,该条记录的table列代表着该表的表名。
可以看见,只涉及对s1表的单表查询,所以EXPLAIN输出中只有一条记录,其中的table列的值是s1,而连接查询的执行计划中有两条记录,这两条记录的table列分别是s1和s2.
partitions
和分区表有关,一般情况下查询语句的执行计划的partitions列的值都是NULL。
type
执行计划的一条记录就代表着MySQL对某个表的执行查询时的访问方法/访问类型,其中的type列就表明了这个访问方法/访问类型是个什么东西,是较为重要的一个指标,结果值从最好到最坏依次是:
出现比较多的是system>const>eq_ref>ref>range>index>ALL
一般来说,得保证查询至少达到range级别,最好能达到ref。
system
当表中只有一条记录并且该表使用的存储引擎的统计数据是精确的,比如MyISAM、Memory,那么对该表的访问方法就是system。
explain select * from test_myisam;
当然,如果改成使用InnoDB存储引擎,试试看执行计划的type列的值是什么。
const
就是根据主键或者唯一二级索引列与常数进行等值匹配时,对单表的访问方法就是const。因为只匹配一行数据,所以很快。
例如将主键置于where列表中
EXPLAIN
SELECT * FROM s1 WHERE id = 716;
B+树叶子节点中的记录是按照索引列排序的,对于的聚簇索引来说,它对应的B+树叶子节点中的记录就是按照id列排序的。B+树矮胖,所以这样根据主键值定位一条记录的速度很快。类似的,根据唯一二级索引列来定位一条记录的速度也很快的,比如下边这个查询:
SELECT * FROM
order_exp WHERE insert_time=’’ and order_status=’’ and expire_time=’’ ;
这个查询的执行分两步,第一步先从u_idx_day_status对应的B+树索引中根据索引列与常数的等值比较条件定位到一条二级索引记录,然后再根据该记录的id值到聚簇索引中获取到完整的用户记录。
MySQL把这种通过主键或者唯一二级索引列来定位一条记录的访问方法定义为:const,意思是常数级别的,代价是可以忽略不计的。
不过这种const访问方法只能在主键列或者唯一二级索引列和一个常数进行等值比较时才有效,如果主键或者唯一二级索引是由多个列构成的话,组成索引的每一个列都是与常数进行等值比较时,这个const访问方法才有效。
对于唯一二级索引来说,查询该列为NULL值的情况比较特殊,因为唯一二级索引列并不限制 NULL 值的数量,所以上述语句可能访问到多条记录,也就是说is null不可以使用const访问方法来执行。
eq_ref
在连接查询时,如果被驱动表是通过主键或者唯一二级索引列等值匹配的方式进行访问的〈如果该主键或者唯一二级索引是联合索引的话,所有的索引列都必须进行等值比较),则对该被驱动表的访问方法就是eq_ref。
(驱动表与被驱动表: A表和B表join连接查询,如果通过A表的结果集作为循环基础数据,然后一条一条地通过该结果集中的数据作为过滤条件到B表中查询数据,然后合并结果。称A表为驱动表,B**表为被驱动表)
比方说:
EXPLAIN
SELECT * FROM s1 INNER JOIN s2 ON s1.id = s2.id;
从执行计划的结果中可以看出,MySQL打算将s2作为驱动表,s1作为被驱动表,重点关注s1的访问方法是eq_ref,表明在访问s1表的时候可以通过主键的等值匹配来进行访问。
ref
当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就可能是ref。
本质上也是一种索引访问,它返回所有匹配某个单独值的行,然而,它可能会找到多个符合条件的行,所以他属于查找和扫描的混合体
EXPLAIN
SELECT * FROM s1 WHERE order_no = 'a';
对于这个查询,当然可以选择全表扫描来逐一对比搜索条件是否满足要求,可以先使用二级索引找到对应记录的id值,然后再回表到聚簇索引中查找完整的用户记录。
由于普通二级索引并不限制索引列值的唯一性,所以可能找到多条对应的记录,也就是说使用二级索引来执行查询的代价取决于等值匹配到的二级索引记录条数。如果匹配的记录较少,则回表的代价还是比较低的,所以MySQL可能选择使用索引而不是全表扫描的方式来执行查询。这种搜索条件为二级索引列与常数等值比较,采用二级索引来执行查询的访问方法称为:ref。
对于普通的二级索引来说,通过索引列进行等值比较后可能匹配到多条连续的记录,而不是像主键或者唯一二级索引那样最多只能匹配1条记录,所以这种ref访问方法比const要差些,但是在二级索引等值比较时匹配的记录数较少时的效率还是很高的(如果匹配的二级索引记录太多那么回表的成本就太大了)。
range
如果使用索引获取某些范围区间的记录,那么就可能使用到range访问方法,一般就是在你的where语句中出现了between、<、>、in等的查询。
这种范围扫描索引扫描比全表扫描要好,因为它只需要开始于索引的某一点,而结束语另一点,不用扫描全部索引。
EXPLAIN
SELECT * FROM s1 WHERE order_no IN ('a', 'b', 'c');
EXPLAIN
SELECT * FROM s1 WHERE order_no > 'a' AND order_no < 'b';
这种利用索引进行范围匹配的访问方法称之为:range。
此处所说的使用索引进行范围匹配中的 索引 可以是聚簇索引,也可以是二级索引。
index
可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是index。
EXPLAIN
SELECT insert_time FROM s1 WHERE expire_time = '2021-03-22 18:36:47';
all
最熟悉的全表扫描,将遍历全表以找到匹配的行
EXPLAIN
SELECT * FROM s1;
possible_keys与key
在EXPLAIN 语句输出的执行计划中,possible_keys列表示在某个查询语句中,对某个表执行单表查询时可能用到的索引有哪些,key列表示实际用到的索引有哪些,如果为NULL,则没有使用索引。比方说下边这个查询:。
EXPLAIN SELECT order_note FROM s1 WHERE
insert_time = '2021-03-22 18:36:47';
上述执行计划的possible keys列的值表示该查询可能使用到u_idx_day_status,idx_insert_time两个索引,然后key列的值是u_idx_day_status,表示经过查询优化器计算使用不同索引的成本后,最后决定使用u_idx_day_status来执行查询比较划算。
key_len
key_len列表示当优化器决定使用某个索引执行查询时,该索引记录的最大长度,计算方式是这样的:
对于使用固定长度类型的索引列来说,它实际占用的存储空间的最大长度就是该固定值,对于指定字符集的变长类型的索引列来说,比如某个索引列的类型是VARCHAR(100),使用的字符集是utf8,那么该列实际占用的最大存储空间就是100 x 3 = 300个字节。
如果该索引列可以存储NULL值,则key_len比不可以存储NULL值时多1个字节。
对于变长字段来说,都会有2个字节的空间来存储该变长列的实际长度。
⽐如下边这个查询:
EXPLAIN
SELECT * FROM s1 WHERE id = 718;
由于id列的类型是bigint,并且不可以存储NULL值,所以在使用该列的索引时key_len大小就是8。
对于可变长度的索引列来说,比如下边这个查询:
EXPLAIN
SELECT * FROM s1 WHERE order_no = 'a';
由于order_no列的类型是VARCHAR(50),所以该列实际最多占用的存储空间就是50*3字节,又因为该列是可变长度列,所以key_len需要加2,所以最后ken_len的值就是152。
MySQL在执行计划中输出key_len列主要是为了区分某个使用联合索引的查询具体用了几个索引列(复合索引有最左前缀的特性,如果复合索引能全部使用上,则是复合索引字段的索引长度之和,这也可以用来判定复合索引是否部分使用,还是全部使用),而不是为了准确的说明针对某个具体存储引擎存储变长字段的实际长度占用的空间到底是占用1个字节还是2个字节。
rows
如果查询优化器决定使用全表扫描的方式对某个表执行查询时,执行计划的rows列就代表预计需要扫描的行数,如果使用索引来执行查询时,执行计划的rows列就代表预计扫描的索引记录行数。比如下边两个个查询:
EXPLAIN
SELECT * FROM s1 WHERE order_no > 'z';
EXPLAIN
SELECT * FROM s1 WHERE order_no > 'a';
看到执行计划的rows列的值是分别是1和10573,这意味着查询优化器在经过分析使用idx_order_no进行查询的成本之后,觉得满足order_no> ’ a '这个条件的记录只有1条,觉得满足order_no> ’ a '这个条件的记录有10573条。
filtered
查询优化器预测有多少条记录满⾜其余的搜索条件,什么意思呢?看具体的语句:
EXPLAIN SELECT *
FROM s1 WHERE id > 5890 AND order_note = 'a';
从执行计划的key列中可以看出来,该查询使用 PRIMARY索引来执行查询,从rows列可以看出满足id > 5890的记录有5286条。执行计划的filtered列就代表查询优化器预测在这5286条记录中,有多少条记录满足其余的搜索条件,也就是order_note = 'a’这个条件的百分比。此处filtered列的值是10.0,说明查询优化器预测在5286条记录中有10.00%的记录满足order_note = 'a’这个条件。
对于单表查询来说,这个filtered列的值没什么意义,更关注在连接查询中驱动表对应的执行计划记录的filtered值,比方说下边这个查询:
EXPLAIN SELECT * FROM s1 INNER JOIN s2 ON s1.order_no = s2.order_no WHERE s1.order_note > '你好,李焕英';
从执行计划中可以看出来,查询优化器打算把s
1当作驱动表,s2当作被驱动表。可以看到驱动表s1表的执行计划的rows列为10573,filtered列为33.33 ,这意味着驱动表s1的扇出值就是10573 x 33.33 % = 3524.3,这说明还要对被驱动表执行大约3524次查询。
Extra
顾名思义,Extra列是用来说明一些额外信息的,可以通过这些额外信息来更准确的理解MySQL到底将如何执行给定的查询语句
查询优化器
一条SQL语句在MySQL执行的过程如下:
1.如果是查询语句(select语句),首先会查询缓存是否已有相应结果,有则返回结果,无则进行下一步(如果不是查询语句,同样调到下一步)
2.解析查询,创建一个内部数据结构(解析树),这个解析树主要用来SQL语句的语义与语法解析;
3.优化:优化SQL语句,例如重写查询,决定表的读取顺序,以及选择需要的索引等。这一阶段用户是可以查询的,查询服务器优化器是如何进行优化的,便于用户重构查询和修改相关配置,达到最优化。这一阶段还涉及到存储引擎,优化器会询问存储引擎,比如某个操作的开销信息、是否对特定索引有查询优化等。
高性能的索引使用策略
不在索引列上做任何操作
看到一些查询不当地使用索引,或者使得MySQL无法使用已有的索引。如果查询中的列不是独立的,则 MySQL就不会使用索引。“独立的列”是指索引列不能是表达式的一部分,也不能是函数的参数。
假设id上有主键索引,但是下面这个查询无法使用主键索引:
EXPLAIN SELECT * FROM order_exp WHERE id + 1 = 17;
凭肉眼很容易看出 WHERE中的表达式其实等价于id= 16,但是MySQL无法自动解析这个方程式。这完全是用户行为。应该养成简化WHERE条件的习惯,始终将索引列单独放在比较符号的一侧。
下面是另一个常见的错误:
在索引列上使用函数,也是无法利用索引的。
EXPLAIN SELECT * from order_exp WHERE YEAR(insert_time)=YEAR(DATE_SUB(NOW(),INTERVAL 1 YEAR));
EXPLAIN SELECT * from order_exp WHERE insert_time BETWEEN str_to_date('01/01/2021', '%m/%d/%Y') and str_to_date('12/31/2021', '%m/%d/%Y');
尽量全值匹配
建立了联合索引列后,如果搜索条件中的列和索引列一致的话,这种情况就称为全值匹配,比方说下边这个查找语句:
EXPLAIN select * from order_exp where insert_time='2021-03-22 18:34:55' and order_status=0 and expire_time='2021-03-22 18:35:14';
建立的u_idx_day_statusr索引包含的3个列在这个查询语句中都展现出来了,联合索引中的三个列都可能被用到。
WHERE子句中的几个搜索条件的顺序对查询结果有啥影响么?也就是说调换 insert_time, order_status, expire_time这几个搜索列的顺序对查询的执行过程有影响么?比方说写成下边这样:
EXPLAIN select * from order_exp where order_status=0 and insert_time='2021-03-22 18:34:55' and expire_time='2021-03-22 18:35:14';
放心,MySQL没这么蠢,查询优化器会分析这些搜索条件并且按照可以使用的索引中列的顺序来决定先使用哪个搜索条件,后使用哪个搜索条件。
所以,当建立了联合索引列后,能在where条件中使用索引的尽量使用。
最佳左前缀法则
建立了联合索引列,如果搜索条件不够全值匹配怎么办?在搜索语句中也可以不用包含全部联合索引中的列,但要遵守最左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的列。
搜索条件中必须出现左边的列才可以使用到这个B+树索引
EXPLAIN select * from order_exp where insert_time='2021-03-22 18:23:42' and order_status=1;
EXPLAIN select * from order_exp where insert_time='2021-03-22 18:23:42' ;
搜索条件中没有出现左边的列不可以使用到这个B+树索引
EXPLAIN SELECT * FROM order_exp WHERE order_status=1;
EXPLAIN Select * from s1 where order_status=1 and expire_time='2021-03-22 18:35:14';
那为什么搜索条件中必须出现左边的列才可以使用到这个B+树索引呢?比如下边的语句就用不到这个B+树索引么?
因为B+树的数据页和记录先是按照insert_time列的值排序的,在insert_time列的值相同的情况下才使用order_status列进行排序,也就是说insert_time列的值不同的记录中order_status的值可能是无序的。而现在你跳过insert_time列直接根据order_status的值去查找,怎么可能呢?expire_time也是一样的道理,那如果就想在只使用expire_time的值去通过B+树索引进行查找咋办呢?这好办,你再对expire_time列建一个B+树索引就行了。
但是需要特别注意的一点是,如果想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。比方说联合索引u_idx_day_status中列的定义顺序是 insert_time, order_status, expire_time,如果搜索条件中只有insert_time和expire_time,而没有中间的order_status,
EXPLAIN select * from order_exp where insert_time='2021-03-22 18:23:42' and expire_time='2021-03-22 18:35:14';
请注意key_len,只有5,说明只有insert_time用到了,其他的没有用到。
1.3.6.4.范围条件放最后
这一点,也是针对联合索引来说的,记录都是按照索引列的值从小到大的顺序排好序的,而联合索引则是按创建索引时的顺序进行分组排序。
比如:
EXPLAIN select * from order_exp_cut where insert_time>'2021-03-22 18:23:42' and insert_time<'2021-03-22 18:35:00';
由于B+树中的数据页和记录是先按insert_time列排序的,所以上边的查询过程其实是这样的:
找到insert_time值为’2021-03-22 18:23:42’ 的记录。
找到insert_timee值为’2021-03-22 18:35:00’的记录。
由于所有记录都是由链表连起来的,所以他们之间的记录都可以很容易的取出来,找到这些记录的主键值,再到聚簇索引中回表查找完整的记录。
但是如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到B+树索引:
select * from order_exp_cut where insert_time>'2021-03-22 18:23:42' and insert_time<'2021-03-22 18:35:00' and order_status > -1;
上边这个查询可以分成两个部分:
通过条件insert_time>‘2021-03-22 18:23:42’ and insert_time<‘2021-03-22 18:35:00’ 来对insert_time进行范围,查找的结果可能有多条insert_time值不同的记录,
对这些insert_time值不同的记录继续通过order_status>-1条件继续过滤。
这样子对于联合索引u_idx_day_status来说,只能用到insert_time列的部分,而用不到order_status列的部分(这里的key_len和之前的SQL的是一样长),因为只有insert_time值相同的情况下才能用order_status列的值进行排序,而这个查询中通过insert_time进行范围查找的记录中可能并不是按照order_status列进行排序的,所以在搜索条件中继续以order_status列进行查找时是用不到这个B+树索引的。
所以对于一个联合索引来说,虽然对多个列都进行范围查找时只能用到最左边那个索引列,但是如果左边的列是精确查找,则右边的列可以进行范围查找:
EXPLAIN select * from order_exp_cut
where insert_time='2021-03-22 18:34:55' and order_status=0 and expire_time>'2021-03-22
18:23:57' and expire_time<'2021-03-22 18:35:00' ;
而中间有范围查询会导致后面的列全部失效,无法充分利用这个联合索引:
EXPLAIN select * from order_exp_cut
where insert_time='2021-03-22 18:23:42' and order_status>-1 and expire_time='2021-03-22
18:35:14';
1.3.6.5.覆盖索引尽量用
覆盖索引是非常有用的工具,能够极大地提高性能,三星索引里最重要的那颗星就是宽索引星。考虑一下如果查询只需要扫描索引而无须回表,会带来多少好处:
索引条目通常远小于数据行大小,所以如果只需要读取索引,那 MySQL就会极大地减少数据访问量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于I/O密集型的应用也有帮助,因为索引比数据更小,更容易全部放入内存中。
因为索引是按照列值顺序存储的,所以对于I/O密集型的范围查询会比随机从磁盘读取每一行数据的I/O要少得多。
由于InnoDB的聚簇索引,覆盖索引对InnoDB表特别有用。InnoDB的二级索引在叶子节点中保存了行的主键值,所以如果二级主键能够覆盖查询,则可以避免对主键索引的二次查询。
尽量使用覆盖索引(只访问索引的查询(索引列和查询列一致)),不是必要的情况下减少select*,除非是需要将表中的全部列检索后,进行缓存。
EXPLAIN select * from
order_exp_cut where insert_time='2021-03-22 18:34:55' and order_status=0 and
expire_time='2021-03-22 18:35:04' ;
使用具体名称取代*
EXPLAIN select expire_time,id from
order_exp_cut where insert_time='2021-03-22 18:34:55' and order_status=0 and
expire_time='2021-03-22 18:35:04' ;
解释一下Extra中的Using index
当查询列表以及搜索条件中只包含属于某个索引的列,也就是在可以使用索引覆盖的情况下,在Extra列将会提示该额外信息。以上的查询中只需要用到u_idx_day_status而不需要回表操作:
1.3.6.6.不等于要慎用
mysql 在使用不等于(!= 或者<>)的时候无法使用索引会导致全表扫描
EXPLAIN SELECT * FROM order_exp WHERE order_no <> 'DD00_6S';
解释一下Extra中的Using where
当使用全表扫描来执行对某个表的查询,并且该语句的WHERE子句中有针对该表的搜索条件时,在Extra列中会提示上述额外信息。
1.3.6.7.Null/Not 有影响
需要注意null/not null对索引的可能影响
表order_exp的order_no为索引列,同时不允许为null,
explain SELECT * FROM order_exp WHERE order_no is null;
explain SELECT * FROM order_exp WHERE order_no is not null;
可以看见,order_no is null的情况下,MySQL直接表示Impossible WHERE(查询语句的WHERE子句永远为FALSE时将会提示该额外信息),对于 is not null直接走的全表扫描。
表order_exp_cut的order_no为索引列,同时允许为null,
explain SELECT * FROM order_exp_cut WHERE order_no is null;
explain SELECT * FROM order_exp_cut WHERE order_no is not null;
is null会走ref类型的索引访问,is not null;依然是全表扫描。所以总结起来:
is not null容易导致索引失效,is null则会区分被检索的列是否为null,如果是null则会走ref类型的索引访问,如果不为null,也是全表扫描。
但是当联合索引上使用时覆盖索引时,情况会有一些不同(order_exp_cut表的order_no可为空):
explain SELECT order_status,expire_time FROM order_exp WHERE insert_time is null;
explain SELECT order_status,expire_time FROM order_exp WHERE insert_time is not null;
explain SELECT order_status,expire_time FROM order_exp_cut WHERE insert_time is null;
explain SELECT order_status,expire_time FROM order_exp_cut WHERE insert_time is not null;
根据system>const>eq_ref>ref>range>index>ALL 的原则,看起来在联合索引中,is not null的表现会更好(如果列可为null的话),但是key_len的长度增加了1。所以总的来说,在设计表时列尽可能的不要声明为null。
Like查询要当心
like以通配符开头(‘%abc…’),mysql索引失效会变成全表扫描的操作
explain SELECT * FROM order_exp WHERE order_no like '%_6S';
此时如果使用覆盖索引可以改善这个问题
explain SELECT order_status,expire_time FROM order_exp_cut WHERE insert_time like '%18:35:09';
字符类型加引号
字符串不加单引号索引失效
explain SELECT * FROM order_exp WHERE order_no = 6;
explain SELECT * FROM order_exp WHERE order_no = '6';
MySQL的查询优化器,会自动的进行类型转换,比如上个语句里会尝试将order_no转换为数字后和6进行比较,自然造成索引失效。
使用or关键字时要注意
explain SELECT * FROM order_exp WHERE order_no = 'DD00_6S' OR order_no = 'DD00_9S';
explain SELECT * FROM order_exp WHERE expire_time= '2021-03-22 18:35:09' OR order_note = 'abc';
表现是不一样的,第一个SQL的or是相同列,相当于产生两个扫描区间,可以使用上索引。
第二个SQL中or是不同列,并且order_note不是索引。所以只能全表扫描
当然如果两个条件都是索引列,情况会有变化:
explain SELECT * FROM order_exp WHERE expire_time= '2021-03-22 18:35:09' OR order_no = 'DD00_6S';
这也给了提示,如果将 SQL改成union all
explain SELECT * FROM order_exp WHERE expire_time= '2021-03-22 18:35:09'
union all SELECT * FROM order_exp WHERE order_note = 'abc';
当然使用覆盖扫描也可以改善这个问题:
explain SELECT order_status,id FROM order_exp_cut WHERE insert_time='2021-03-22 18:34:55' or expire_time='2021-03-22 18:28:28';
使用索引扫描来做排序和分组
MySQL有两种方式可以生成有序的结果﹔通过排序操作﹔或者按索引顺序扫描施﹔如果EXPLAIN出来的type列的值为“index”,则说明MySQL使用了索引扫描来做排序。
扫描索引本身是很快的,因为只需要从一条索引记录移动到紧接着的下一条记录。但如果索引不能覆盖查询所需的全部列,那就不得不每扫描一条索引记录就都回表查询一次对应的行。这基本上都是随机I/O,因此按索引顺序读取数据的速度通常要比顺序地全表扫描慢,尤其是在IO密集型的工作负载时。
MySQL可以使用同一个索引既满足排序,又用于查找行。因此,如果可能,设计索引时应该尽可能地同时满足这两种任务,这样是最好的。
只有当索引的列顺序和ORDER BY子句的顺序完全一致,并且所有列的排序方向(倒序或正序)都一样时,MySQL才能够使用索引来对结果做排序。如果查询需要关联多张表,则只有当0RDER BY子句引用的字段全部为第一个表时,才能使用索引做排序。
排序要当心
ASC、DESC别混用
对于使用联合索引进行排序的场景,要求各个排序列的排序顺序是一致的,也就是要么各个列都是ASC规则排序,要么都是DESC规则排序。
排序列包含非同一个索引的列
用来排序的多个列不是一个索引里的,这种情况也不能使用索引进行排序
explain
SELECT * FROM order_exp order by
order_no,insert_time;
尽可能按主键顺序插入行
最好避免随机的(不连续且值的分布范围非常大)聚簇索引,特别是对于I/O密集型的应用。例如,从性能的角度考虑,使用UUID来作为聚簇索引则会很糟糕,它使得聚簇索引的插入变得完全随机,这是最坏的情况,使得数据没有任何聚集特性。
最简单的方法是使用AUTO_INCREMENT自增列。这样可以保证数据行是按顺序写入,对于根据主键做关联操作的性能也会更好。
注意到向UUID主键插入行不仅花费的时间更长,而且索引占用的空间也更大。这一方面是由于主键字段更长﹔另一方面毫无疑问是由于页分裂和碎片导致的。
因为主键的值是顺序的,所以InnoDB把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB默认的最大填充因子是页大小的15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满,这也正是所期望的结果。
如果新行的主键值不一定比之前插入的大,所以InnoDB无法简单地总是把新行插入到索引的最后,而是需要为新的行寻找合适的位置-—通常是已有数据的中间位置——并且分配空间。这会增加很多的额外工作,并导致数据分布不够优化。下面是总结的一些缺点:
写入的目标页可能已经刷到磁盘上并从缓存中移除,或者是还没有被加载到缓存中,InnoDB在插入之前不得不先找到并从磁盘读取目标页到内存中。这将导致大量的随机IO。
因为写入是乱序的,InnoDB不得不频繁地做页分裂操作,以便为新的行分配空间。页分裂会导致移动大量数据,一次插入最少需要修改三个页而不是一个页。
所以使用InnoDB时应该尽可能地按主键顺序插入数据,并且尽可能地使用单调增加的聚簇键的值来插入新行。
优化Count查询
首先要注意,COUNT()是一个特殊的函数,有两种非常不同的作用:它可以统计某个列值的数量,也可以统计行数。
在统计列值时要求列值是非空的(不统计NULL)。
COUNT()的另一个作用是统计结果集的行数。常用的就是就是使用COUNT(*)。实际上,它会忽略所有的列而直接统计所有的行数。
select count(*) from test;
select count(c1) from test;
通常来说,COUNT()都需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。在MySQL层面能做的基本只有索引覆盖扫描了。如果这还不够,就需要考虑修改应用的架构,可以用估算值取代精确值,可以增加汇总表,或者增加类似Redis这样的外部缓存系统。
优化limit分页
在系统中需要进行分页操作的时候,通常会使用LIMIT加上偏移量的办法实现,同时加上合适的ORDER BY子句。
一个非常常见又令人头疼的问题就是,在偏移量非常大的时候,例如可能是
select * from order_exp limit 10000,10;
这样的查询,这时MySQL需要查询10010条记录然后只返回最后10条,前面10 000条记录都将被抛弃,这样的代价非常高。
优化此类分页查询的一个最简单的办法是
会先查询翻页中需要的N条数据的主键值,然后根据主键值回表查询所需要的N条数据,在此过程中查询N条数据的主键id在索引中完成,所以效率会高一些。
EXPLAIN SELECT * FROM (select id from order_exp limit 10000,10) b,order_exp
a where a.id = b.id;
从执行计划中可以看出,首先执行子查询中的order_exp表,根据主键做索引全表扫描,然后与a表通过id做主键关联查询,相比传统写法中的全表扫描效率会高一些。
从两种写法上能看出性能有一定的差距,虽然并不明显,但是随着数据量的增大,两者执行的效率便会体现出来。
上面的写法虽然可以达到一定程度的优化,但还是存在性能问题。最佳的方式是在业务上进行配合修改为以下语句:
EXPLAIN select * from order_exp where id > 67 order by id limit 10;
采用这种写法,需要前端通过点击More来获得更多数据,而不是纯粹的翻页,因此,每次查询只需要使用上次查询出的数据中的id来获取接下来的数据即可,但这种写法需要业务配合。
关于Null的特别说明
对于Null到底算什么,存在着分歧:
1、有的认为NULL值代表一个未确定的值,MySQL认为任何和NULL值做比较的表达式的值都为NULL,包括select
null=null和select null!=null;
所以每一个NULL值都是独一无二的。
2、有的认为其实NULL值在业务上就是代表没有,所有的NULL值和起来算一份;
3、有的认为这NULL完全没有意义,所以在统计数量时压根儿不能把它们算进来。
假设一个表中某个列c1的记录为(2,1000,null,null),在第一种情况下,表中c1的记录数为4,第二种表中c1的记录数为3,第三种表中c1的记录数为2。
在对统计索引列不重复值的数量时如何对待NULL值,MySQL专门提供了一个innodb_stats_method的系统变量,
https://dev.mysql/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_stats_method
这个系统变量有三个候选值:
nulls_equal:认为所有NULL值都是相等的。这个值也是innodb_stats_method的默认值。
如果某个索引列中NULL值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别多,所以倾向于不使用索引进行访问。
nulls_unequal:认为所有NULL值都是不相等的。
如果某个索引列中NULL值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别少,所以倾向于使用索引进行访问。
nulls_ignored:直接把NULL值忽略掉。
而且有迹象表明,在MySQL5.7.22以后的版本,对这个innodb_stats_method的修改不起作用,MySQL把这个值在代码里写死为nulls_equal。也就是说MySQL在进行索引列的数据统计行为又把null视为第二种情况(NULL值在业务上就是代表没有,所有的NULL值和起来算一份),看起来,MySQL中对Null值的处理也很分裂。所以总的来说,对于列的声明尽可能的不要允许为null。
事务和事务的隔离级别
事务的重要性
事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位(不可再进行分割),由一个有限的数据库操作序列构成(多个DML语句,select语句不包含事务),要不全部成功,要不全部不成功。
A 给B 要划钱,A 的账户-1000元, B 的账户就要+1000元,这两个update 语句必须作为一个整体来执行,不然A 扣钱了,B 没有加钱这种情况就是错误的。那么事务就可以保证A 、B 账户的变动要么全部一起发生,要么全部一起不发生。
事务特性
事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。
l 原子性(atomicity)
l 一致性(consistency)
l 隔离性(isolation)
l 持久性(durability)
原子性(atomicity)
一个事务必须被视为一个不可分割的最小单元,整个事务中的所有操作要么全部提交成功,要么全部失败,对于一个事务来说,不能只执行其中的一部分操作。比如:
张三借给李四1000元:
1.张三工资卡扣除1000元
2.李四工资卡增加1000元
整个事务的操作要么全部成功,要么全部失败,不能出现张三工资卡扣除,但是李四工资卡不增加的情况。如果原子性不能保证,就会很自然的出现一致性问题。
一致性(consistency)
一致性是指事务将数据库从一种一致性转换到另外一种一致性状态,在事务开始之前和事务结束之后数据库中数据的完整性没有被破坏。
张三借给李四1000元:
1.张三工资卡扣除1000元
2.李四工资卡增加1000元
扣除的钱(-500) 与增加的钱(500) 相加应该为0,或者说张三和李四的账户的钱加起来,前后应该不变。
持久性(durability)
一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,已经提交的修改数据也不会丢失。
隔离性(isolation)
一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
事务并发引发的问题
MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。
事务有一个称之为隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据,这样的话并发事务的执行就变成了串行化执行。
但是对串行化执行性能影响太大,既想保持事务的一定的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,当舍弃隔离性的时候,会带来的数据问题
1.4.3.1.脏读
当一个事务读取到了另外一个事务修改但未提交的数据,被称为脏读。
1、在事务A执⾏过程中,事务A对数据资源进⾏了修改,事务B读取了事务A修改后的数据。
2、由于某些原因,事务A并没有完成提交,发⽣了RollBack操作,则事务B读取的数据就是脏数据。
这种读取到另⼀个事务未提交的数据的现象就是脏读(Dirty Read)。
不可重复读
当事务内相同的记录被检索两次,且两次得到的结果不同时,此现象称为不可重复读。
事务B读取了两次数据资源,在这两次读取的过程中事务A修改了数据,导致事务B在这两次读取出来的
数据不⼀致。
幻读
在事务执行过程中,另一个事务将新记录添加到正在读取的事务中时,会发生幻读。
事务B前后两次读取同⼀个范围的数据,在事务B两次读取的过程中事务A新增了数据,导致事务B后⼀
次读取到前⼀次查询没有看到的⾏。
幻读和不可重复读有些类似,但是幻读重点强调了读取到了之前读取没有获取到的记录。
SQL标准的四种隔离级别
上边介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题也有轻重缓急之分,这些问题按照严重性来排一下序:
脏读 > 不可重复读 > 幻读
上边所说的舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生。有一帮人(并不是设计MySQL的大叔们)制定了一个所谓的SQL标准,在标准中设立了4个隔离级别:
READ UNCOMMITTED:未提交读。
READ COMMITTED:已提交读。
REPEATABLE READ:可重复读。
SERIALIZABLE:可串行化。
SQL标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:
也就是说:
READ UNCOMMITTED隔离级别下,可能发生脏读、不可重复读和幻读问题。
READ COMMITTED隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。
REPEATABLE READ隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。
SERIALIZABLE隔离级别下,各种问题都不可以发生。
MySQL中的隔离级别
不同的数据库厂商对SQL标准中规定的四种隔离级别支持不一样,比方说Oracle就只支持READ COMMITTED和SERIALIZABLE隔离级别。本书中所讨论的MySQL虽然支持4种隔离级别,但与SQL标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL在REPEATABLE READ隔离级别下,是可以禁止幻读问题的发生的。
MySQL的默认隔离级别为REPEATABLE READ,可以手动修改事务的隔离级别。
如何设置事务的隔离级别
可以通过下边的语句修改事务的隔离级别:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
其中的level可选值有4个:
level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}
设置事务的隔离级别的语句中,在SET关键字后可以放置GLOBAL关键字、SESSION关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:
使用GLOBAL关键字(在全局范围影响):
比方说这样:
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则: 只对执行完该语句之后产生的会话起作用。当前已经存在的会话无效。
使用SESSION关键字(在会话范围影响):
比方说这样:
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则:对当前会话的所有后续的事务有效
该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。
如果在事务之间执行,则对后续的事务有效。
上述两个关键字都不用(只对执行语句后的下一个事务产生影响):
比方说这样:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则:只对当前会话中下一个即将开启的事务有效。下一个事务执行完后,后续事务将恢复到之前的隔离级别。该语句不能在已经开启的事务中间执行,会报错的。
如果在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction-isolation的值,比方在启动服务器时指定了–transaction-isolation=SERIALIZABLE,那么事务的默认隔离级别就从原来的REPEATABLE READ变成了SERIALIZABLE。
想要查看当前会话默认的隔离级别可以通过查看系统变量transaction_isolation的值来确定:
SHOW VARIABLES LIKE 'transaction_isolation';
或者使用更简便的写法:
SELECT @@transaction_isolation;
注意:transaction_isolation是在MySQL 5.7.20的版本中引入来替换tx_isolation的,如果你使用的是之前版本的MySQL,请将上述用到系统变量transaction_isolation的地方替换为tx_isolation。
MySQL事务
事务基本语法
事务开始
1、begin
2、START TRANSACTION(推荐)
3、begin work
事务回滚
rollback
事务提交
commit
使用事务插入两行数据,commit后数据还在
使用事务插入两行数据,rollback后数据没有了
保存点
如果你开启了一个事务,执行了很多语句,忽然发现某条语句有点问题,你只好使用ROLLBACK语句来让数据库状态恢复到事务执行之前的样子,然后一切从头再来,但是可能根据业务和数据的变化,不需要全部回滚。所以MySQL里提出了一个保存点(英文:savepoint)的概念,就是在事务对应的数据库语句中打几个点,在调用ROLLBACK语句时可以指定会滚到哪个点,而不是回到最初的原点。定义保存点的语法如下:
SAVEPOINT 保存点名称;
想回滚到某个保存点时,可以使用下边这个语句(下边语句中的单词WORK和SAVEPOINT是可有可无的):
ROLLBACK TO [SAVEPOINT] 保存点名称;
不过如果ROLLBACK语句后边不跟随保存点名称的话,会直接回滚到事务执行之前的状态。
如果想删除某个保存点,可以使用这个语句:
RELEASE SAVEPOINT 保存点名称;
隐式提交
当使用START TRANSACTION或者BEGIN语句开启了一个事务,或者把系统变量autocommit的值设置为OFF时,事务就不会进行自动提交,但是如果输入了某些语句之后就会悄悄的提交掉,就像输入了COMMIT语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为隐式提交,这些会导致事务隐式提交的语句包括:
执行DDL
定义或修改数据库对象的数据定义语言(Datadefinition language,缩写为:DDL)。
所谓的数据库对象,指的就是数据库、表、视图、存储过程等等这些东西。当使用CREATE、ALTER、DROP等语句去修改这些所谓的数据库对象时,就会隐式的提交前边语句所属于的事务,就像这样:
BEGIN;
SELECT ... # 事务中的一条语句
UPDATE ... # 事务中的一条语句
... # 事务中的其它语句
CREATE TABLE ...
此语句会隐式的提交前边语句所属于的事务
隐式使用或修改mysql数据库中的表
使用ALTER USER、CREATE USER、DROP USER、GRANT、RENAME USER、REVOKE、SET PASSWORD等语句时也会隐式的提交前边语句所属于的事务。
事务控制或关于锁定的语句
在一个会话里,一个事务还没提交或者回滚时就又使用START TRANSACTION或者BEGIN语句开启了另一个事务时,会隐式的提交上一个事务,比如这样:
BEGIN;
SELECT ... # 事务中的一条语句
UPDATE ... # 事务中的一条语句
... # 事务中的其它语句
BEGIN; # 此语句会隐式的提交前边语句所属于的事务
或者当前的autocommit系统变量的值为OFF,手动把它调为ON时,也会隐式的提交前边语句所属的事务。
或者使用LOCK TABLES、UNLOCK TABLES等关于锁定的语句也会隐式的提交前边语句所属的事务。
加载数据的语句
比如使用LOAD DATA语句来批量往数据库中导入数据时,也会隐式的提交前边语句所属的事务。
关于MySQL复制的一些语句
使用START SLAVE、STOP SLAVE、RESET SLAVE、CHANGE MASTER TO等语句时也会隐式的提交前边语句所属的事务。
其它的一些语句
使用ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、 LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET等语句也会隐式的提交前边语句所属的事务。
MVCC
全称Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。
同一行数据平时发生读写请求时,会上锁阻塞住。但MVCC用更好的方式去处理读—写请求,做到在发生读—写请求冲突时不用加锁。
这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。
MVCC原理
事务隔离级别
MySQL在REPEATABLE READ隔离级别下,是可以很大程度避免幻读问题的发生的(好像解决了,但是又没完全解决),MySQL是怎么做到的?
版本链
必须要知道的概念(每个版本链针对的一条数据):
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列):
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
(补充点:undo日志:为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、…、第n号undo日志等,这个编号也被称之为undo no。)
为了说明这个问题,创建一个演示表
CREATE TABLE teacher (
number INT,
name VARCHAR(100),
domain varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
然后向这个表里插入一条数据:
INSERT INTO teacher VALUES(1, '李四', 'JVM系列');
现在表里的数据就是这样的:
假设之后两个事务id分别为80、120的事务对这条记录进行UPDATE操作,操作流程如下:
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:
对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(Mulit-Version Concurrency Control MVCC)。
ReadView
必须要知道的概念(作用于SQL查询语句)
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了(所以就会出现脏读、不可重复读、幻读)。
对于使用SERIALIZABLE隔离级别的事务来说,InnoDB使用加锁的方式来访问记录(也就是所有的事务都是串行的,当然不会出现脏读、不可重复读、幻读)。
对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:READ COMMITTED和REPEATABLE READ隔离级别在不可重复读和幻读上的区别是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本链中的哪个版本是当前事务可见的。
为此,InnoDB提出了一个ReadView的概念(作用于SQL查询语句),
这个ReadView中主要包含4个比较重要的内容:
**m_ids:**表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
**min_trx_id:**表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
**max_trx_id:**表示生成ReadView时系统中应该分配给下一个事务的id值。注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
**creator_trx_id:**表示生成该ReadView的事务的事务id。
1.5.1.4.READ COMMITTED
脏读问题的解决
READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。
还是以表teacher 为例,假设现在表teacher 中只有一条由事务id为60的事务插入的一条记录,接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。
READ COMMITTED —— 每次读取数据前都生成一个ReadView
比方说现在系统里有两个事务id分别为80、120的事务在执行:Transaction 80
UPDATE teacher SET name = '王五' WHERE number = 1;
UPDATE teacher SET name = '赵六' WHERE number = 1;
...
此刻,表teacher 中number为1的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:
使用READ COMMITTED隔离级别的事务
BEGIN;
SELECE1:Transaction 80、120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'李四'
第1次select的时间点 如下图:
这个SELECE1的执行过程如下:
在执行SELECT语句时会先生成一个ReadView:
ReadView的m_ids列表的内容就是[80, 120],min_trx_id为80,max_trx_id为121,creator_trx_id为0。
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’王五’,该版本的trx_id值为80,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列name的内容是’赵六’,该版本的trx_id值也为80,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name的内容是’李四’,该版本的trx_id值为60,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’李四’的记录。
所以有了这种机制,就不会发生脏读问题!因为会去判断活跃版本,必须是不在活跃版本的才能用,不可能读到没有 commit的记录。
不可重复读问题
然后,把事务id为80的事务提交一下,然后再到事务id为120的事务中更新一下表teacher 中number为1的记录:
Transaction120
BEGIN;
更新了一些别的表的记录
UPDATE teacher SET name = '严' WHERE number = 1;
UPDATE teacher SET name = '晁' WHERE number = 1;
此刻,表teacher 中number为1的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个number为1的记录,如下:
使用READ COMMITTED隔离级别的事务
BEGIN;
SELECE1:Transaction 80、120均未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'李四'
SELECE2:Transaction 80提交,Transaction 120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'赵六'
第2次select的时间点 如下图:
这个SELECE2的执行过程如下:
SELECT * FROM teacher WHERE number = 1;
在执行SELECT语句时会又会单独生成一个ReadView,该ReadView信息如下:
m_ids列表的内容就是[120](事务id为80的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id为120,max_trx_id为121,creator_trx_id为0。
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’小张’,该版本的trx_id值为120,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
下一个版本的列name的内容是’小明’,该版本的trx_id值为120,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name的内容是’赵六’,该版本的trx_id值为80,小于ReadView中的min_trx_id值120,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’赵六’的记录。
以此类推,如果之后事务id为120的记录也提交了,再次在使用READ COMMITTED隔离级别的事务中查询表teacher 中number值为1的记录时,得到的结果就是’小张’了,具体流程就不分析了。
但会出现不可重复读问题。
明显上面一个事务中两次
REPEATABLE READ
REPEATABLE READ解决不可重复读问题
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。用例子看一下是什么效果。
比方说现在系统里有两个事务id分别为80、120的事务在执行:Transaction 80
UPDATE teacher SET name = '王五' WHERE number = 1;
UPDATE teacher SET name = '赵六' WHERE number = 1;
...
此刻,表teacher 中number为1的记录得到的版本链表如下所示:
假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:
假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:
使用READ COMMITTED隔离级别的事务
BEGIN;
SELECE1:Transaction 80、120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'李四'
这个SELECE1的执行过程如下:
在执行SELECT语句时会先生成一个ReadView:
ReadView的m_ids列表的内容就是[80, 120],min_trx_id为80,max_trx_id为121,creator_trx_id为0。
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’赵六’,该版本的trx_id值为80,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列name的内容是’王五’,该版本的trx_id值也为80,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列name的内容是’李四’,该版本的trx_id值为60,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’李四’的记录。
之后,把事务id为80的事务提交一下,然后再到事务id为120的事务中更新一下表teacher 中number为1的记录:
Transaction120
BEGIN;
更新了一些别的表的记录
UPDATE teacher SET name = '小明' WHERE number = 1;
UPDATE teacher SET name = '小张' WHERE number = 1;
此刻,表teacher 中number为1的记录的版本链就长这样:
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,如下:
使用READ COMMITTED隔离级别的事务
BEGIN;
SELECE1:Transaction 80、120均未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'李四'
SELECE2:Transaction 80提交,Transaction 120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为'李四'
这个SELECE2的执行过程如下:
因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECE1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的m_ids列表的内容就是[80, 120],min_trx_id为80,max_trx_id为121,creator_trx_id为0。
根据前面的分析,返回的值还是’李四’。
也就是说两次SELECT查询得到的结果是重复的,记录的列name值都是’李四’,这就是可重复读的含义。
总结一下就是:
ReadView中的比较规则(前两条)
1、如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
2、如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
MVCC下的幻读解决和幻读现象
REPEATABLE READ隔离级别下MVCC可以解决不可重复读问题,那么幻读呢?MVCC是怎么解决的?幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新记录。
在REPEATABLE READ隔离级别下的事务T1先根据某个搜索条件读取到多条记录,然后事务T2插入一条符合相应搜索条件的记录并提交,然后事务T1再根据相同搜索条件执行查询。结果会是什么?按照ReadView中的比较规则(后两条):
3、如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
4、如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
不管事务T2比事务T1是否先开启,事务T1都是看不到T2的提交的。请自行按照上面介绍的版本链、ReadView以及判断可见性的规则来分析一下。
但是,在REPEATABLE READ隔离级别下InnoDB中的MVCC 可以很大程度地避免幻读现象,而不是完全禁止幻读。
首先在事务T1中:
select * from teacher where number = 30;
很明显,这个时候是找不到number = 30的记录的。
在事务T2中,执行:
insert into teacher values(30,'豹','数据湖');
通过执行insert into teacher values(30,‘豹’,‘数据湖’); 往表中插入了一条number = 30的记录。
此时回到事务T1,执行:
update teacher set domain='RocketMQ' where number=30;
select * from teacher where number = 30;
嗯,怎么回事?事务T1很明显出现了幻读现象。
在REPEATABLE READ隔离级别下,T1第一次执行普通的SELECT 语句时生成了一个ReadView(但是版本链没有),之后T2向teacher 表中新插入一条记录并提交,然后T1也进行了一个update语句。
ReadView并不能阻止T1执行UPDATE 或者DELETE 语句来改动这个新插入的记录,但是这样一来,这条新记录的trx_id隐藏列的值就变成了T1的事务id。
之后T1再使用普通的SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。因为这个特殊现象的存在,也可以认为MVCC 并不能完全禁止幻读(就是第一次读如果是空的情况,且在自己事务中进行了该条数据的修改)。
MVCC小结
从上边的描述中可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SELECT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了,从而基本上可以避免幻读现象(就是第一次读如果ReadView是空的情况中的某些情况则避免不了)。
另外,所谓的MVCC只是在进行普通的SEELCT查询时才生效,截止到目前所见的所有SELECT语句都算是普通的查询,至于什么是个不普通的查询,后面马上就会讲到(锁定读)。
MySQL中的锁
InnoDB中锁非常多,总的来说,可以如下分类:
解决并发事务问题
事务并发执行时可能带来的各种问题,最大的一个难点是:一方面要最大程度地利用数据库的并发访问,另外一方面还要确保每个用户能以一致的方式读取和修改数据,尤其是一个事务进行读取操作,另一个同时进行改动操作的情况下。
并发事务问题
一个事务进行读取操作,另一个进行改动操作,这种情况下可能发生脏读、不可重复读、幻读的问题。
怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:
方案一:读操作MVCC,写操作进行加锁
事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,也称之为快照读,但是往往读取的是历史版本数据。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读。
一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。
很明显,采用MVCC方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些情况下,要求必须采用加锁的方式执行。
方案二:读、写操作都采用加锁的方式
适用场景:
业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,
比方在银行存款的事务中,需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。
脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。
不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。
幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有不太容易了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点麻烦—— 因为并不知道给谁加锁。
锁定读(LockingReads)/LBCC
也称当前读, 读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。
哪些是当前读呢?select lock in share mode (共享锁)、select for update (排他锁)、update (排他锁)、insert (排他锁/独占锁)、delete (排他锁)、串行化事务隔离级别都是当前读。
当前读这种实现方式,也可以称之为LBCC(基于锁的并发控制,Lock-Based Concurrency Control),怎么做到?
共享锁和独占锁
在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,MySQL中的锁有好几类:
共享锁英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。
假如事务E1首先获取了一条记录的S锁之后,事务E2接着也要访问这条记录:
如果事务E2想要再获取一个记录的S锁,那么事务E2也会获得该锁,也就意味着事务E1和E2在该记录上同时持有S锁。
独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。
如果事务E2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务E1提交之后将S锁释放掉。
如果事务E1首先获取了一条记录的X锁之后,那么不管事务E2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务E1提交。
所以说S锁和S锁是兼容的,S锁和X锁是不兼容的,X锁和X锁也是不兼容的,画个表表示一下就是这样:
X 不兼容X 不兼容S
S 不兼容X 兼容S
锁定读的SELECT语句
MySQ有两种比较特殊的SELECT语句格式:
SELECT * from test LOCK IN SHARE MODE;
一个事务中开启S锁
另一个事务中开启S锁,可以读
如果另外一个事务中开启X锁,阻塞!
也就是在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的X锁(比方说使用SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。
如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。
对读取的记录加X锁:
SELECT * from test FOR UPDATE;
也就是在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT … LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比如说使用SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。
一个事务中开启X锁
另外一个事务中的X锁阻塞
除非第一个事务提交
另外一个事务才能获得X锁
同样如果另外一个事务执行X锁,使用S锁也不行
如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
写操作的锁
平常所用到的写操作无非是DELETE、UPDATE、INSERT这三种:
DELETE:
对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。
INSERT:
一般情况下,新插入一条记录的操作并不加锁,InnoDB通过一种称之为隐式锁来保护这条新插入的记录在本事务提交前不被别的事务访问。当然,在一些特殊情况下INSERT操作也是会获取锁的。
UPDATE:
在对一条记录做UPDATE操作时分为三种情况:
1、如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。
2、如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。
3、如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。
锁的粒度
前边提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S锁)和独占锁(X锁)
表锁与行锁的比较
锁定粒度:表锁 > 行锁
加锁效率:表锁 > 行锁
冲突概率:表锁 > 行锁
并发性能:表锁 < 行锁
给表加S锁
如果一个事务给表加了S锁,那么:
别的事务可以继续获得该表的S锁
别的事务可以继续获得该表中的某些记录的S锁
别的事务不可以继续获得该表的X锁
别的事务不可以继续获得该表中的某些记录的X锁
给表加X锁
如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:
别的事务不可以继续获得该表的S锁
别的事务不可以继续获得该表中的某些记录的S锁
别的事务不可以继续获得该表的X锁
别的事务不可以继续获得该表中的某些记录的X锁。
为了更好的理解这个表级别的S锁和X锁和后面的意向锁,举一个现实生活中的例子。用曾经很火爆的互联网风口项目共享Office来说明加锁:
共享Office有栋大楼,楼自然有很多层。办公室都是共享的,客户可以随便选办公室办公。每层楼可以容纳客户同时办公,每当一个客户进去办公,就相当于在每层的入口处挂了一把S锁,如果很多客户进去办公,相当于每层的入口处挂了很多把S锁(类似行级别的S锁)。
有的时候楼层会进行检修,比方说换地板,换天花板,检查水电啥的,这些维修项目并不能同时开展。如果楼层针对某个项目进行检修,就不允许客户来办公,也不允许其他维修项目进行,此时相当于楼层门口会挂一把X锁(类似行级别的X锁)。
上边提到的这两种锁都是针对楼层而言的,不过有时候会有一些特殊的需求:
A、有投资人要来考察Office的环境。
投资人和公司并不想影响客户进去办公,但是此时不能有楼层进行检修,所以可以在大楼门口放置一把S锁(类似表级别的S锁)。此时:
来办公的客户们看到大楼门口有S锁,可以继续进入大楼办公。
修理工看到大楼门口有S锁,则先在大楼门口等着,啥时候投资人走了,把大楼的S锁撤掉再进入大楼维修。
B、公司要和房东谈条件。
此时不允许大楼中有正在办公的楼层,也不允许对楼层进行维修。所以可以在大楼门口放置一把X锁(类似表级别的X锁)。此时:
来办公的客户们看到大楼门口有X锁,则需要在大楼门口等着,啥时候条件谈好,把大楼的X锁撤掉再进入大楼办公。
修理工看到大楼门口有X锁,则先在大楼门口等着,啥时候谈判结束,把大楼的X锁撤掉再进入大楼维修。
意向锁
但是在上面的例子这里头有两个问题:
如果想对大楼整体上S锁,首先需要确保大楼中的没有正在维修的楼层,如果有正在维修的楼层,需要等到维修结束才可以对大楼整体上S锁。
如果想对大楼整体上X锁,首先需要确保大楼中的没有办公的楼层以及正在维修的楼层,如果有办公的楼层或者正在维修的楼层,需要等到全部办公的同学都办公离开,以及维修工维修完楼层离开后才可以对大楼整体上X锁。
在对大楼整体上锁(表锁)时,怎么知道大楼中有没有楼层已经被上锁(行锁)了呢?依次检查每一楼层门口有没有上锁?那这效率也太慢了吧!于是InnoDB提出了一种意向锁(英文名:Intention Locks):
意向共享锁 ,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
意向独占锁 ,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
视角回到大楼和楼层上来:
如果有客户到楼层中办公,那么他先在整栋大楼门口放一把IS锁(表级锁),然后再到楼层门口放一把S锁(行锁)。
如果有维修工到楼层中维修,那么它先在整栋大楼门口放一把IX锁(表级锁),然后再到楼层门口放一把X锁(行锁)。
之后:
如果有投资人要参观大楼,也就是想在大楼门口前放S锁(表锁)时,首先要看一下大楼门口有没有IX锁,如果有,意味着有楼层在维修,需要等到维修结束把IX锁撤掉后才可以在整栋大楼上加S锁。
如果有谈条件要占用大楼,也就是想在大楼门口前放X锁(表锁)时,首先要看一下大楼门口有没有IS锁或IX锁,如果有,意味着有楼层在办公或者维修,需要等到客户们办完公以及维修结束把IS锁和IX锁撤掉后才可以在整栋大楼上加X锁。
注意: 客户在大楼门口加IS锁时,是不关心大楼门口是否有IX锁的,维修工在大楼门口加IX锁时,是不关心大楼门口是否有IS锁或者其他IX锁的。IS和IX锁只是为了判断当前时间大楼里有没有被占用的楼层用的,也就是在对大楼加S锁或者X锁时才会用到。
总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录。就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。画个表来看一下表级别的各种锁的兼容性:
| 兼容性 | X | IX | S | IS |
|---|---|---|---|---|
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
| IX | 不兼容 | 不兼容 | ||
| S | 不兼容 | 不兼容 | ||
| IS | 不兼容 |
锁的组合性:(意向锁没有行锁)
| 组合性 | X | IX | S | IS |
|---|---|---|---|---|
| 表锁 | 有 | 有 | 有 | 有 |
| 行锁 | 有 | 有 |
MySQL中的行锁和表锁
MySQL支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。当然,重点还是讨论InnoDB存储引擎中的锁,其他的存储引擎只是稍微看看。
其他存储引擎中的锁
对于MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。比方说在Session 1中对一个表执行SELECT操作,就相当于为这个表加了一个表级别的S锁,如果在SELECT操作未完成时,Session 2中对这个表执行UPDATE操作,相当于要获取表的X锁,此操作会被阻塞,直到Session 1中的SELECT操作完成,释放掉表级别的S锁后,Session 2中对这个表执行UPDATE操作才能继续获取X锁,然后执行具体的更新语句。
因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。
另外,在MyISAM存储引擎中有一个称之为Concurrent Inserts的特性,支持在对MyISAM表读取时同时插入记录,这样可以提升一些插入速度。关于更多Concurrent Inserts的细节,详情可以参考文档。
InnoDB存储引擎中的锁
InnoDB存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。
InnoDB中的表级锁
表级别的S锁、X锁、元数据锁
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。
另外,在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。
其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写:(特殊的删库跑路方式,动不动就锁表)
LOCK TABLES t
READ:InnoDB存储引擎会对表t加表级别的S锁。
LOCK TABLES t
WRITE:InnoDB存储引擎会对表t加表级别的X锁。
请尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。(基本上不会用到,只有数据库崩溃恢复过程中)
表级别的IS锁、IX锁
在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁;
在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。
IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。并不能手动添加意向锁,只能由InnoDB存储引擎自行添加。
表级别的AUTO-INC锁
在使用MySQL过程中,可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:
1、采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
如果插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用INSERT … SELECT、REPLACE … SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。
2、采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
如果插入语句在执行前就可以确定具体要插入多少条记录,比方上边举的关于表t的例子中,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。
InnoDB提供了一个称之为innodb_autoinc_lock_mode的系统变量来控制到底使用上述两种方式中的哪种来为AUTO_INCREMENT修饰的列进行赋值,当innodb_autoinc_lock_mode值为0时,一律采用AUTO-INC锁;当innodb_autoinc_lock_mode值为1时,一律采用轻量级锁;当innodb_autoinc_lock_mode值为2时,两种方式混着来(也就是在插入记录数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。
不过当innodb_autoinc_lock_mode值为2时,可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。
show variables like 'innodb_autoinc_lock_mode' ;
MySQL5.7.X中缺省为1。
InnoDB中的行级锁
行锁,也称为记录锁,顾名思义就是在记录上加的锁。但是要注意,这个记录指的是通过给索引上的索引项加锁。InnoDB 这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。
不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。
只有执行计划真正使用了索引,才能使用行锁:即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。
用范围条件而不是相等条件检索数据,并请求锁时,InnoDB会给符合条件的已有数据记录的索引项加锁。
不过即使是行锁,InnoDB里也是分成了各种类型的。换句话说即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。
使用前面的teacher,增加一个索引,并插入几条记录。
INDEX `idx_number`(`number`)
常用的行锁类型。
Record Locks
也叫记录锁,就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。比方说把number值为6的那条记录加一个记录锁的示意图如下:
记录锁是有S锁和X锁之分的,当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁;
Gap Locks
MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,也可以简称为gap锁。
间隙锁实质上是对索引前后的间隙上锁,不对索引本身上锁。
会话1开启一个事务,执行
begin;
update teacher set domain ='JVM' where number='6';
会对2~6之间和6到10之间进行上锁。
如图中为2~6和 6 ~ 10的记录加了gap锁,意味着不允许别的事务在这条记录前后间隙插入新记录。
begin;
insert into teacher value(7,'晁','docker');
为什么不能插入?因为记录(7,‘小张’,‘docker’)要 插入的话,在索引idx_number上,刚好落在6 ~ 10之间,是有锁的,当然不允许插入。
但是当SQL语句变为:insert
into teacher value(70,‘小张’,‘docker’);能插入吗?
当然能,因为70这条记录不在被锁的区间内。
死锁
概念
是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
举个例子:A和B去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,13技师擅长足底按摩,14擅长头部按摩。
这个时候A先抢到14,B先抢到13,两个人都想同时洗脚和头部按摩,于是就互不相让,扬言我死也不让你,这样的话,A抢到14,想要13,B抢到13,想要14,在这个想同时洗脚和头部按摩的事情上A和B就产生了死锁。怎么解决这个问题呢?
第一种,假如这个时候,来了个15,刚好也是擅长头部按摩的,A又没有两个脑袋,自然就归了B,于是B就美滋滋的洗脚和做头部按摩,剩下A在旁边气鼓鼓的,这个时候死锁这种情况就被打破了,不存在了。
第二种,C出场了,用武力强迫A和B,必须先做洗脚,再头部按摩,这种情况下,A和B谁先抢到13,谁就可以进行下去,另外一个没抢到的,就等着,这种情况下,也不会产生死锁。
所以总结一下:
死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有B一个去,不要2个,打十个都没问题;单资源呢?只有13,A和B也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。同时,死锁还有几个要求,1、争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁;
2、争夺者拿到资源不放手。
MySQL中的死锁
MySQL中的死锁的成因是一样的。
会话1:
begin;
select * from
teacher where number = 1 for update;
会话2:
begin;
select * from
teacher where number = 3 for update;
会话1
select * from teacher where number = 3 for
update;
可以看到这个语句的执行将会被阻塞
会话2 :
select * from
teacher where number = 1 for update;
MySQL检测到了死锁,并结束了会话2中事务的执行,此时,切回会话1,发现原本阻塞的SQL语句执行完成了。
同时通过
show engine innodb status\G
可以看见死锁的详细情况:
查看事务加锁的情况,不过一般情况下,看不到哪个事务对哪些记录加了那些锁,需要修改系统变量innodb_status_output_locks(MySQL5.6.16引入),缺省是OFF。
show
variables like 'innodb_status_output_locks';
需要设置为ON,
set global
innodb_status_output_locks = ON;
然后开启事务,并执行语句
MySQL8新特性
MySQL 8.0 全内存访问可以轻易跑到 200W QPS,I/O 极端高负载场景跑到 16W QPS,除此之外MySQL 8还新增了很多功能
账户与安全
用户创建和授权
到了MySQL8中,用户创建与授权语句必须是分开执行,之前版本是可以一起执行。
MySQL8的版本
grant all privileges on *.* to 'yuyang'@'%' identified by 'yuyang@2022';
create user 'yuyang'@'%' identified by 'yuyang@2022';
grant all privileges on *.* to 'yuyang'@'%';
MySQL5.7的版本
grant all privileges on *.* to 'yuyang'@'%' identified by 'yuyang@2022';
认证插件更新
MySQL 8.0中默认的身份认证插件是caching_sha2_password,替代了之前的mysql_native_password。
show variables like 'default_authentication%';
5.7版本
8版本
select user, host,plugin from mysql.user;
这个带来的问题就是如果客户端没有更新,就连接不上!!
当然可以通过在MySQL的服务端找到myf的文件,把相关参数进行修改(不过要MySQL重启后才能生效)
如果没办法重启服务,还有一种动态的方式:
alter user 'yuyang'@'%' identified with mysql_native_password by 'yuyang@2022';
select host,user from mysql.user;
使用老的Navicat for MySQL也能访问
密码管理
MySQL 8.0开始允许限制重复使用以前的密码(修改密码时)。
并且还加入了密码的修改管理功能
show variables like 'password%';
修改策略(全局级)
set persist password_history=3; --修改密码不能和最近3次一致
修改策略(用户级)
alter user 'yuyang'@'%' password history 3;
select user, host,Password_reuse_history from mysql.user;
使用重复密码修改用户密码(指定yuyang用户)
alter user 'yuyang'@'%' identified by 'yuyang@2022';
如果把全局的参数改为0,则对于root用户可以反复的修改密码
alter user 'root'@'localhost' identified by '789456';
password_reuse_interval 则是按照天数来限定(不允许重复的)
password_require_current 是否需要校验旧密码(off 不校验、 on校验)(针对非root用户)
set persist password_require_current=on;
索引增强
隐藏索引
MySQL 8.0开始支持隐藏索引 (invisible index),不可见索引.
隐藏索引不会被优化器使用,但仍然需要进行维护。
应用场景: 软删除、灰度发布。
软删除:就是在线上会经常删除和创建索引,如果是以前的版本,如果删除了索引,后面发现删错了,我又需要创建一个索引,这样做的话就非常影响性能。在MySQL8中可以这么操作,把一个索引变成隐藏索引(索引就不可用了,查询优化器也用不上),最后确定要进行删除这个索引才会进行删除索引操作。
灰度发布:也是类似的,想在线上进行一些测试,可以先创建一个隐藏索引,不会影响当前的生产环境,然后通过一些附加的测试,发现这个索引没问题,那么就直接把这个索引改成正式的索引,让线上环境生效。
使用案例(灰度发布):
create table t1(i int,j int); --创建一张t1表
create index i_idx on t1(i); --创建一个正常索引
create index j_idx on t1(j) invisible; --创建一个隐藏索引
show index from t1\G --查看索引信息
使用查询优化器看下:
explain select * from t1 where i=1;
explain select * from t1 where j=1;
这里可以看到隐藏索引不会用上。
这里可以通过优化器的开关,打开一个设置,方便对隐藏索引进行设置。
select @@optimizer_switch\G; --查看 各种参数
红色的部分就是默认查询优化器对隐藏索引不可见,可以通过参数进行修改。确保可以用隐藏索引进行测试。
set session optimizer_switch="use_invisible_indexes=on'; --在会话级别设置查询优化器可以看到隐藏索引
再使用查询优化器看下:
explain select * from t1 where j=1;
把隐藏索引变成可见索引(正常索引)
alter table t1 alter index j_idx visible; --变成可见
alter table t1 alter index j_idx invisible; --变成不可见(隐藏索引)
最后一点,不能把主键设置成不可见的索引(隐藏索引)(MySQL做了限制)
降序索引
MySQL 8.0开始真正支持降序索引 (descendingindex) 。只有InnoDB存储引擎支持降序索引,只支持BTREE降序索引。另外MySQL8.0不再对GROUP BY操作进行隐式排序。
在MySQL中创建一个t2表
create table t2(c1 int,c2 int,index idx1(c1 asc,c2 desc));
show create table t2\G
如果是5.7中,则没有显示升序还是降序信息
插入一些数据,演示下降序索引的使用
insert into t2(c1,c2) values(1,100),(2,200),(3,150),(4,50);
看下索引使用情况
explain select * from t2 order by c1,c2 desc;
与5.7对比一下
这里说明,这里需要一个额外的排序操作,才能把刚才的索引利用上。
把查询语句换一下
explain select * from t2 order by c1 desc,c2 ;
MySQL8中使用了
另外还有一点,就是group by语句在 8之后不再默认排序
select count(*),c2 from t2 group by c2;
在8要排序的话,就需要手动把排序语句加上
select count(*),c2 from t2 group by c2 order by c2;
函数索引
之前如果在查询中加入了函数,索引不生效,所以MySQL8引入了函数索引。
MySQL 8.0.13开始支持在索引中使用函数(表达式)的值。支持降序索引,支持JSON 数据的索引
函数索引基于虚拟列功能实现。
使用函数索引(表达式)
create table t3(c1 varchar(10),c2 varchar(10));
create index idx_c1 on t3(c1); --普通索引
create index func_idx on t3( (UPPER(c2)) ); --一个大写的函数索引
show index from t3\G
explain select * from t3 where upper(c1)='ABC' ;
explain select * from t3 where upper(c2)='ABC' ;
使用函数索引(JSON)
create table t4(data json,index((CAST(data->>'$.name' as char(25)) )));
explain select * from t4 where CAST(data->>'$.name' as char(25)) = 'yuyang';
函数索引基于虚拟列功能实现
函数索引在MySQL中相当于新增了一个列,这个列会根据你的函数来进行计算结果,然后使用函数索引的时候就会用这个计算后的列作为索引。
通用表表达式(CTE)
MySQL8.0开始支持通用表表达式(CTE)(common table expression),即WITH子句。
简单入门:
以下SQL就是一个简单的CTE表达式,类似于递归调用,这段SQL中,首先执行select 1 然后得到查询结果后把这个值n送入 union all下面的 select n+1 from cte where n <10,然后一直这样递归调用union all下面sql语句。
WITH recursive cte(n) as
( select 1
union ALL
select n+1 from cte where n<10
)
select * from cte;
案例介绍:
一个staff表,里面有id,有name还有一个 m_id,这个是对应的上级id。数据如下:
如果想查询出每一个员工的上下级关系,可以使用以下方式
递归CTE:
with recursive staff_view(id,name,m_id) as
(select id ,name ,cast(id as char(200))
from staff where m_id =0
union ALL
select s2.id ,s2.name,concat(s1.m_id,'-',s2.id)
from staff_view as s1 join staff as s2
on s1.id = s2.m_id
)
select * from staff_view order by id
使用通用表表达式的好处就是上下级层级就算有4,5,6甚至更多层,都可以遍历出来,而老的方式的写法SQL语句就要调整。
总结:
通用表表达式与派生表类似,就像语句级别的临时表或视图。CTE可以在查询中多次引用,可以引用其他CTE,可以递归。CTE支持SELECT/INSERT/UPDATE/DELETE等语句。
窗口函数
MySQL 8.0支持窗口函数(Window Function),也称分析函数。窗口函数与分组聚合函数类似,但是每一行数据都生成一个结果。聚合窗口函数: SUM /AVG / COUNT /MAX/MIN等等。
案例如下:sales表结构与数据如下:
窗口函数(计算平局值)
select year,country,product,sum,
sum(sum) over (PARTITION by country) as country_sum,
avg(sum) over (PARTITION by country) as country_avg
from sales
order by country,year,product,sum;
窗口函数(以国家汇总)
select year,country,product,sum,
sum(sum) over (PARTITION by country) as country_sum
from sales
order by country,year,product,sum;
窗口函数(计算平局值)
select year,country,product,sum,
sum(sum) over (PARTITION by country) as country_sum,
avg(sum) over (PARTITION by country) as country_avg
from sales
order by country,year,product,sum;
专用窗口函数:
- 序号函数:ROW_NUMBER()、RANK()、DENSE_RANK()
- 分布函数:PERCENT_RANK()、CUME_DIST()
- 前后函数:LAG()、LEAD()
- 头尾函数:FIRST_VALUE()、LAST_VALUE()
- 其它函数:NTH_VALUE()、NTILE()
窗口函数(排名)
用于计算分类排名的排名窗口函数,以及获取指定位置数据的取值窗口函数
SELECT
YEAR,
country,
product,
sum,
row_number() over (ORDER BY sum) AS 'rank',
rank() over (ORDER BY sum) AS 'rank_1'
FROM
sales;
SELECT
YEAR,
country,
product,
sum,
sum(sum) over (PARTITION by country order by sum rows unbounded preceding) as sum_1
FROM
sales order by country,sum;
当然可以做的操作很多,具体见官网:
https://dev.mysql/doc/refman/8.0/en/window-function-descriptions.html
原子DDL操作
MySQL 8.0 开始支持原子 DDL 操作,其中与表相关的原子 DDL 只支持 InnoDB 存储引擎。一个原子 DDL 操作内容包括:更新数据字典,存储引擎层的操作,在 binlog 中记录 DDL 操作。支持与表相关的 DDL:数据库、表空间、表、索引的 CREATE、ALTER、DROP 以及 TRUNCATE TABLE。支持的其他 DDL :存储程序、触发器、视图、UDF 的 CREATE、DROP 以及ALTER 语句。支持账户管理相关的 DDL:用户和角色的 CREATE、ALTER、DROP 以及适用的 RENAME,以及 GRANT 和 REVOKE 语句。
drop table t1,t2;
上面这个语句,如果只有t1表,没有t2表。在MySQL5.7与8 的表现是不同的。
5.7会删除t1表。而在8中因为报错了,整个是一个原子操作,所以不会删除t1表。
JSON增强
具体看官网信息,英文好的直接看,英文不好的找个翻译工具即可看懂
MySQL :: MySQL 8.0 Reference Manual :: 11.5 The JSON Data Type
InnoDB其他改进功能
自增列持久化
MySQL 5.7 以及早期版本,InnoDB 自增列计数器(AUTO_INCREMENT)的值只存储在内存中。MySQL 8.0 每次变化时将自增计数器的最大值写入 redo log,同时在每次检查点将其写入引擎私有的系统表。解决了长期以来的自增字段值可能重复的 bug。
死锁检查控制
MySQL 8.0 (MySQL 5.7.15)增加了一个新的动态变量,用于控制系统是否执行 InnoDB 死锁检查。对于高并发的系统,禁用死锁检查可能带来性能的提高。
innodb_deadlock_detect
锁定语句选项
SELECT … FOR SHARE 和 SELECT … FOR UPDATE 中支持 NOWAIT、SKIP LOCKED 选项。对于 NOWAIT,如果请求的行被其他事务锁定时,语句立即返回。对于 SKIP LOCKED,从返回的结果集中移除被锁定的行。
InnoDB 其他改进功能。
- 支持部分快速 DDL,ALTER TABLE ALGORITHM=INSTANT;
- InnoDB 临时表使用共享的临时表空间 ibtmp1。
- 新增静态变量 innodb_dedicated_server,自动配置 InnoDB 内存参数:innodb_buffer_pool_size/innodb_log_file_size 等。
- 默认创建 2 个 UNDO 表空间,不再使用系统表空间。
- 支持 ALTER TABLESPACE … RENAME TO 重命名通用表空间。
MySQL体系架构
MySQL的分支与变种
MySQL变种有好几个,主要有三个久经考验的主流变种:Percona Server,MariaDB和 Drizzle。它们都有活跃的用户社区和一些商业支持,均由独立的服务供应商支持。同时还有几个优秀的开源关系数据库,值得了解一下。
Drizzle
Drizzle是真正的MySQL分支,而且是完全开源的产品,而非只是个变种或增强版本。它并不与MySQL兼容不能简单地将MySQL后端替换为Drizzle。
Drizzle与MySQL有很大差别,进行了一些重大更改,甚至SQL语法的变化都非常大,设计目标之一是提供一种出色的解决方案来解决高可用性问题。在实现上,Drizzle清除了一些表现不佳和不必要的功能,将很多代码重写,对它们进行了优化,甚至将所用语言从C换成了C++。
此外,Drizzle另一个设计目标是能很好的适应具有大量内容的多核服务器、运行Linux的64位机器、云计算中使用的服务器、托管网站的服务器和每分钟接收数以万计点击率的服务器并且大幅度的削减服务器成本。
MariaDB
在Sun收购MySQL后,Monty Widenius,这位MySQL的创建者,因不认同MySQL开发流程而离开Sun。他成立了Monty程序公司,创立了MariaDB。MariaDB的目标是社区开发,Bug修复和许多的新特性实际上,可以将MariaDB视为MySQL的扩展集,它不仅提供MySQL提供的所有功能,还提供其他功能。MariaDB是原版MySQL的超集,因此已有的系统不需要任何修改就可以运行。
诸如Google,Facebook、维基百科等公司或者网站所使用了MariaDB。不过Monty公司不是以赢利为目的,而是由产品驱动的,这可能会带来问题,因为没有赢利的公司不一定能长久维持下去。
Percona Server
由领先的MySQL咨询公司Percona发布,Percona公司的口号就是“The Database Performance Experts”,Percona的创始人也就是《高性能MySQL》书的作者。
Percona Server是个与MySQL向后兼容的替代品,它尽可能不改变SQL语法、客户端/服务器协议和磁盘上的文件格式。任何运行在MySQL上的都可以运行在Percona Server上而不需要修改。切换到Percona Server只需要关闭MySQL和启动PerconaServer,不需要导出和重新导入数据。
Percona Server有三个主要的目标:透明,增加允许用户更紧密地查看服务器内部信息和行为的方法。比如慢查询日志中特别增加的详细信息;性能,Percona Server包含许多性能和可扩展性方面的改进,还加强了性能的可预测性和稳定性。其中主要集中于InnoDB;操作灵活性,Percona Server使操作人员和系统管理员在让MySQL作为架构的一部分而可靠并稳定运行时提供了很多便利。
一般来说,Percona Server中的许多特性会在后来的标准MySQL中出现。
国内公司阿里内部就运行了上千个Percona Server的实例。
MySQL的替代
Postgre SQL
PostgreSQL称自己是世界上最先进的开源数据库,同时也是个一专多长的全栈数据库。最初是1985年在加利福尼亚大学伯克利分校开发的。
PostgreSQL 的稳定性极强,在崩溃、断电之类的灾难场景下依然可以保证数据的正确;在高并发读写,负载逼近极限下,PostgreSQL的性能指标仍可以维持双曲线甚至对数曲线,到顶峰之后不再下降,表现的非常稳定,而 MySQL 明显出现一个波峰后下滑;
PostgreSQL多年来在GIS(地理信息)领域处于优势地位,因为它有丰富的几何类型,实际上不止几何类型,PostgreSQL有大量字典、数组、bitmap 等数据类型,相比之下mysql就差很多。所以总的来说,PostgreSQL更学术化一些,在绝对需要可靠性和数据完整性的时候,PostgreSQL是更好的选择。但是从商业支持、文档资料、易用性,第三方支持来说,MySQL无疑更好些。
SQLite
SQLite是世界上部署最广泛的数据库引擎,为物联网(IoT)下的数据库首选,并且是手机,PDA,甚至MP3播放器的下的首选。SQLite代码占用空间小,并且不需要数据库管理员的维护。SQLite没有单独的服务器进程,提供的事务也基本符合ACID。当然,简单也就意味着功能和性能受限。
MySql基础
MySQL体系架构
可以看出MySQL是由连接池、管理工具和服务、SQL接口、解析器、优化器、缓存、存储引擎、文件系统组成。
连接池
由于每次建立建立需要消耗很多时间,连接池的作用就是将这些连接缓存下来,下次可以直接用已经建立好的连接,提升服务器性能。
管理工具和服务
系统管理和控制工具,例如备份恢复、Mysql复制、集群等
SQL接口
接受用户的SQL命令,并且返回用户需要查询的结果。比如select … from就是调用SQL接口
解析器
SQL命令传递到解析器的时候会被解析器验证和解析。解析器主要功能:1、将SQL语句分解成数据结构,后续步骤的传递和处理就是基于这个结构的。2、将SQL语句分解成数据结构,后续步骤的传递和处理就是基于这个结构的。
优化器
查询优化器,SQL语句在查询之前会使用查询优化器对查询进行优化。
缓存器
查询缓存,如果查询缓存有命中的查询结果,查询语句就可以直接去查询缓存中取数据。这个缓存机制是由一系列小缓存组成的。比如表缓存,记录缓存,key缓存,权限缓存等。
存储引擎(后面会细讲)
文件系统(后面会细讲)
连接层
当MySQL启动(MySQL服务器就是一个进程),等待客户端连接,每一个客户端连接请求,服务器进程会创建一个线程专门处理与这个客户端的交互。当客户端与该服务器断开之后,不会立即撤销线程,只会把他缓存起来等待下一个客户端请求连接的时候,将其分配给该客户端。每个线程独立,拥有各自的内存处理空间。
以下命令可以查看最大的连接数:
show VARIABLES like '%max_connections%'
连接到服务器,服务器需要对其进行验证,也就是用户名、IP、密码验证,一旦连接成功,还要验证是否具有执行某个特定查询的权限(例如,是否允许客户端对某个数据库某个表的某个操作)
Server层(SQL处理层)
这一层主要功能有:SQL语句的解析、优化,缓存的查询,MySQL内置函数的实现,跨存储引擎功能(所谓跨存储引擎就是说每个引擎都需提供的功能(引擎需对外提供接口)),例如:存储过程、触发器、视图等。
当然作为一个SQL的执行流程如下:
1.如果是查询语句(select语句),首先会查询缓存是否已有相应结果,有则返回结果,无则进行下一步(如果不是查询语句,同样调到下一步)
2.解析查询,创建一个内部数据结构(解析树),这个解析树主要用来SQL语句的语义与语法解析;
3.优化:优化SQL语句,例如重写查询,决定表的读取顺序,以及选择需要的索引等。这一阶段用户是可以查询的,查询服务器优化器是如何进行优化的,便于用户重构查询和修改相关配置,达到最优化。这一阶段还涉及到存储引擎,优化器会询问存储引擎,比如某个操作的开销信息、是否对特定索引有查询优化等。
2.1.2.1.缓存(了解即可)
show variables like '%query_cache_type%' -- 默认不开启
show variables like '%query_cache_size%' --默认值1M
SET GLOBAL query_cache_type = 1; --会报错
query_cache_type只能配置在myf文件中!
缓存在生产环境建议不开启,除非经常有sql完全一模一样的查询
缓存严格要求2次SQL请求要完全一样,包括SQL语句,连接的数据库、协议版本、字符集等因素都会影响
从8.0开始,MySQL不再使用查询缓存,那么放弃它的原因是什么呢?
MySQL查询缓存是查询结果缓存。它将以SEL开头的查询与哈希表进行比较,如果匹配,则返回上一次查询的结果。进行匹配时,查询必须逐字节匹配,例如 SELECT * FROM e1; 不等于select * from e1;
此外,一些不确定的查询结果无法被缓存,任何对表的修改都会导致这些表的所有缓存无效。因此,适用于查询缓存的最理想的方案是只读,特别是需要检查数百万行后仅返回数行的复杂查询。如果你的查询符合这样一个特点,开启查询缓存会提升你的查询性能。
随着技术的进步,经过时间的考验,MySQL的工程团队发现启用缓存的好处并不多。
首先,查询缓存的效果取决于缓存的命中率,只有命中缓存的查询效果才能有改善,因此无法预测其性能。
其次,查询缓存的另一个大问题是它受到单个互斥锁的保护。在具有多个内核的服务器上,大量查询会导致大量的互斥锁争用。
通过基准测试发现,大多数工作负载最好禁用查询缓存(5.6的默认设置):按照官方所说的:造成的问题比它解决问题要多的多,弊大于利就直接砍掉了。
存储引擎层
从体系结构图中可以发现,MySQL数据库区别于其他数据库的最重要的一个特点就是其插件式的表存储引擎。MySQL插件式的存储引擎架构提供了一系列标准的管理和服务支持,这些标准与存储引擎本身无关,可能是每个数据库系统本身都必需的,如SQL分析器和优化器等,而存储引擎是底层物理结构和实际文件读写的实现,每个存储引擎开发者可以按照自己的意愿来进行开发。需要特别注意的是,存储引擎是基于表的,而不是数据库。
插件式存储引擎的好处是,每个存储引擎都有各自的特点,能够根据具体的应用建立不同存储引擎表。由于MySQL数据库的开源特性,用户可以根据MySQL预定义的存储引擎接口编写自己的存储引擎。若用户对某一种存储引擎的性能或功能不满意,可以通过修改源码来得到想要的特性,这就是开源带来的方便与力量。
由于MySQL数据库开源特性,存储引擎可以分为MySQL官方存储引擎和第三方存储引擎。有些第三方存储引擎很强大,如大名鼎鼎的InnoDB存储引擎(最早是第三方存储引擎,后被Oracle收购),其应用就极其广泛,甚至是MySQL数据库OLTP(Online Transaction Processing在线事务处理)应用中使用最广泛的存储引擎。
MySQL官方引擎概要
InnoDB存储引擎
InnoDB是MySQL的默认事务型引擎,也是最重要、使用最广泛的存储引擎。它被设计用来处理大量的短期(short-lived)事务,短期事务大部分情况是正常提交的,很少会被回滚。InnoDB的性能和自动崩溃恢复特性,使得它在非事务型存储的需求中也很流行。除非有非常特别的原因需要使用其他的存储引擎,否则应该优先考虑InnoDB引擎。如果要学习存储引擎,InnoDB也是一个非常好的值得花最多的时间去深入学习的对象,收益肯定比将时间平均花在每个存储引擎的学习上要高得多。所以InnoDB引擎是重点。
MylSAM存储引擎
在MySQL 5.1及之前的版本,MyISAM是默认的存储引擎。MyISAM提供了大量的特性,包括全文索引、压缩、空间函数(GIS)等,但MyISAM不支持事务和行级锁,而且有一个毫无疑问的缺陷就是崩溃后无法安全恢复。尽管MyISAM引擎不支持事务、不支持崩溃后的安全恢复,但它绝不是一无是处的。对于只读的数据,或者表比较小、可以忍受修复(repair)操作,则依然可以继续使用MyISAM(但请不要默认使用MyISAM,而是应当默认使用InnoDB)。但是MyISAM对整张表加锁,而不是针对行。读取时会对需要读到的所有表加共享锁,写入时则对表加排他锁。MyISAM很容易因为表锁的问题导致典型的的性能问题。
Mrg_MylSAM
Merge存储引擎,是一组MyIsam的组合,也就是说,他将MyIsam引擎的多个表聚合起来,但是他的内部没有数据,真正的数据依然是MyIsam引擎的表中,但是可以直接进行查询、删除更新等操作。
Archive引擎
Archive存储引擎只支持INSERT和SELECT操作,在MySQL 5.1之前也不支持索引。Archive引擎会缓存所有的写并利用zlib对插入的行进行压缩,所以比MyISAM表的磁盘I/O更少。但是每次SELECT查询都需要执行全表扫描。所以Archive表适合日志和数据采集类应用,这类应用做数据分析时往往需要全表扫描。或者在一些需要更快速的INSERT操作的场合下也可以使用。Archive引擎不是一个事务型的引擎,而是一个针对高速插入和压缩做了优化的简单引擎。
Blackhole引擎
Blackhole引擎没有实现任何的存储机制,它会丢弃所有插入的数据,不做任何保存。但是服务器会记录Blackhole表的日志,所以可以用于复制数据到备库,或者只是简单地记录到日志。这种特殊的存储引擎可以在一些特殊的复制架构和日志审核时发挥作用。但这种引擎在应用方式上有很多问题,因此并不推荐。
CSV引擎
CSV引擎可以将普通的CSV文件(逗号分割值的文件)作为MySQL的表来处理,但这种表不支持索引。CSV引擎可以在数据库运行时拷入或者拷出文件。可以将Excel等的数据存储为CSV文件,然后复制到MySQL数据目录下,就能在MySQL 中打开使用。同样,如果将数据写入到一个CSV引擎表,其他的外部程序也能立即从表的数据文件中读取CSV格式的数据。因此CSV引擎可以作为一种数据交换的机制,非常有用。
Federated引擎
Federated引擎是访问其他MySQL服务器的一个代理,它会创建一个到远程MySQL服务器的客户端连接,并将查询传输到远程服务器执行,然后提取或者发送需要的数据。最初设计该存储引擎是为了和企业级数据库如Microsoft SQL Server和 Oracle的类似特性竞争的,可以说更多的是一种市场行为。尽管该引擎看起来提供了一种很好的跨服务器的灵活性,但也经常带来问题,因此默认是禁用的。
Memory 引擎
如果需要快速地访问数据,并且这些数据不会被修改,重启以后丢失也没有关系,那么使用Memory表(以前也叫做HEAP表)是非常有用的。Memory表至少比MyISAM 表要快一个数量级,因为每个基于MEMORY存储引擎的表实际对应一个磁盘文件。该文件的文件名与表名相同,类型为frm类型。该文件中只存储表的结构。而其数据文件,都是存储在内存中,这样有利于数据的快速处理,提高整个表的效率,不需要进行磁盘I/O。所以Memory表的结构在重启以后还会保留,但数据会丢失。
Memory表支持 Hash索引,因此查找操作非常快。虽然Memory表的速度非常快,但还是无法取代传统的基于磁盘的表。Memroy表是表级锁,因此并发写入的性能较低。它不支持BLOB或TEXT类型的列,并且每行的长度是固定的,所以即使指定了VARCHAR 列,实际存储时也会转换成CHAR,这可能导致部分内存的浪费。
NDB集群引擎
使用MySQL服务器、NDB集群存储引擎,以及分布式的、share-nothing 的、容灾的、高可用的NDB数据库的组合,被称为MySQL集群((MySQL Cluster)。
值得了解的第三方引擎
Percona的 XtraDB存储引擎
基于InnoDB引擎的一个改进版本,已经包含在Percona Server和 MariaDB中,它的改进点主要集中在性能、可测量性和操作灵活性方面。XtraDB可以作为InnoDB的一个完全的替代产品,甚至可以兼容地读写InnoDB的数据文件,并支持InnoDB的所有查询。
TokuDB引擎
使用了一种新的叫做分形树(Fractal Trees)的索引数据结构。该结构是缓存无关的,因此即使其大小超过内存性能也不会下降,也就没有内存生命周期和碎片的问题。TokuDB是一种大数据(Big Data)存储引擎,因为其拥有很高的压缩比,可以在很大的数据量上创建大量索引。现在该引擎也被Percona公司收购。
Tips : 分形树,是一种写优化的磁盘索引数据结构。 分形树的写操作(Insert/Update/Delete)性能比较好,同时它还能保证读操作近似于B+树的读性能。据测试结果显示, TokuDB分形树的写性能优于InnoDB的B+树,读性能略低于B+树。分形树核心思想是利用节点的MessageBuffer缓存更新操作,充分利用数据局部性原理,将随机写转换为顺序写,这样极大的提高了随机写的效率。
Infobright
MySQL默认是面向行的,每一行的数据是一起存储的,服务器的查询也是以行为单位处理的。而在大数据量处理时,面向列的方式可能效率更高,比如HBASE就是面向列存储的。
Infobright是最有名的面向列的存储引擎。在非常大的数据量(数十TB)时,该引擎工作良好。Infobright是为数据分析和数据仓库应用设计的。数据高度压缩,按照块进行排序,每个块都对应有一组元数据。在处理查询时,访问元数据可决定跳过该块,甚至可能只需要元数据即可满足查询的需求。但该引擎不支持索引,不过在这么大的数据量级,即使有索引也很难发挥作用,而且块结构也是一种准索引 (quasi-index)。Infobright需要对MySQL服务器做定制,因为一些地方需要修改以适应面向列存储的需要。如果查询无法在存储层使用面向列的模式执行,则需要在服务器层转换成按行处理,这个过程会很慢。Infobright有社区版和商业版两个版本。
选择合适的引擎
这么多存储引擎,怎么选择?大部分情况下,InnoDB都是正确的选择,所以在MySQL 5.5版本将InnoDB作为默认的存储引擎了。对于如何选择存储引擎,可以简单地归纳为一句话:“除非需要用到某些InnoDB不具备的特性,并且没有其他办法可以替代,否则都应该优先选择InnoDB引擎”。比如,MySQL中只有MyISAM支持地理空间搜索。
当然,如果不需要用到InnoDB的特性,同时其他引擎的特性能够更好地满足需求,也可以考虑一下其他存储引擎。举个例子,如果不在乎可扩展能力和并发能力,也不在乎崩溃后的数据丢失问题,却对InnoDB的空间占用过多比较敏感,这种场合下选择MyISAM就比较合适。
除非万不得已,否则建议不要混合使用多种存储引擎,否则可能带来一系列复杂的问题,以及一些潜在的bug和边界问题。存储引擎层和服务器层的交互已经比较复杂,更不用说混合多个存储引擎了。至少,混合存储对一致性备份和服务器参数配置都带来了一些困难。
表引擎的转换
有很多种方法可以将表的存储引擎转换成另外一种引擎。每种方法都有其优点和缺点。常用的有三种方法
ALTER TABLE
将表从一个引擎修改为另一个引擎最简单的办法是使用ALTER TABLE 语句。下面的语句将mytable的引擎修改为InnoDB :
ALTER TABLE mytable ENGINE = InnoDB;
上述语法可以适用任何存储引擎。但需要执行很长时间,在实现上,MySQL会按行将数据从原表复制到一张新的表中,在复制期间可能会消耗系统所有的I/O能力,同时原表上会加上读锁。所以,在繁忙的表上执行此操作要特别小心。
导出与导入
还可以使用mysqldump工具将数据导出到文件,然后修改文件中CREATE TABLE语句的存储引擎选项,注意同时修改表名,因为同一个数据库中不能存在相同的表名,即使它们使用的是不同的存储引擎。
CREATE和 SELECT
先创建一个新的存储引擎的表,然后利用INSERT…SELECT语法来导数据:
CREATE TABLE innodb_table LIKE myisam_table;
ALTER TABLE innodb_table ENGINE=InnoDB;
INSERT INTO innodb_table SELECT * FROM myisam_table;
如果数据量很大,则可以考虑做分批处理,针对每一段数据执行事务提交操作。
检查MySQL的引擎
看我的MySQL现在已提供什么存储引擎:
show engines;
看我的MySQL当前默认的存储引擎:
show variables like '%storage_engine%';
MyISAM和InnoDB比较
MySQL中的目录和文件
bin目录
在MysQL的安装目录下有一个特别特别重要的bin目录,这个目录下存放着许多可执行文件。
其他系统中的可执行文件与此的类似。这些可执行文件都是与服务器程序和客户端程序相关的。
启动MySQL服务器程序
在UNIX系统中用来启动MySOL服务器程序的可执行文件有很多,大多在MySQL安装目录的bin目录下。
mysqld
mysqld这个可执行文件就代表着MySOL服务器程序,运行这个可执行文件就可以直接启动一个服务器进程。但这个命令不常用。
mysqld_safe
mysqld safe是一个启动脚本,它会间接的调用mysqld,而且还顺便启动了另外一个监控进程,这个监控进程在服务器进程挂了的时候,可以帮助重启它。另外,使用mysqld_safe启动服务器程序时,它会将服务器程序的出错信息和其他诊断信息重定向到某个文件中,产生出错日志,这样可以方便找出发生错误的原因。
mysql.server
mysql.server也是一个启动脚本,它会间接的调用mysqld_safe,在调用mysql.server时在后边指定start参数就可以启动服务器程序了
就像这样:
mysql.server start
需要注意的是,这个mysql.server文件其实是一个链接文件,它的实际文件是support-files/mysql.server,所以如果在bin目录找不到,到support-files下去找找,而且如果你愿意的话,自行用ln命令在bin创建一个链接。
另外,还可以使用mysql.server命令来关闭正在运行的服务器程序,只要把start参数换成stop就好了:
mysql.server stop
mysqld_multi
其实一台计算机上也可以运行多个服务器实例,也就是运行多个NySQL服务器进程。mysql_multi可执行文件可以对每一个服务器进程的启动或停止进行监控。
客户端程序
成功启动MysTL服务器程序后,就可以接着启动客户端程序来连接到这个服务器喽, bin目录下有许多客户端程序,比方说mysqladmin、mysqldump、mysqlcheck等等。
常用的是可执行文件mysql,通过这个可执行文件可以和服务器程序进程交互,也就是发送请求,接收服务器的处理结果。
mysqladmin执行管理操作的工具,检查服务器配置、当前运行状态,创建、删除数据库、设置新密码。
mysqldump数据库逻辑备份程序。
mysqlbackup备份数据表、整个数据库、所有数据库,一般来说mysqldump备份、mysql还原。
启动选项和参数
配置参数文件
当MySQL实例启动时,数据库会先去读一个配置参数文件,用来寻找数据库的各种文件所在位置以及指定某些初始化参数,这些参数通常定义了某种内存结构有多大等。在默认情况下,MySQL实例会按照-定的顺序在指定的位置进行读取,用户只需通过命令mysql --help|grep myf来寻找即可。
当然,也可以在启动MySQL时,指定配置文件(非yum安装):
这个时候,就会以启动时指定的配置文件为准。
MySQL数据库参数文件的作用和Oracle数据库的参数文件极其类似,不同的是,Oracle实例在启动时若找不到参数文件,是不能进行装载(mount)操作的。MySQL稍微有所不同,MySQL实例可以不需要参数文件,这时所有的参数值取决于编译MySQL时指定的默认值和源代码中指定参数的默认值。
MySQL数据库的参数文件是以文本方式进行存储的。可以直接通过一些常用的文本编辑软件进行参数的修改。
参数的查看和修改
可以通过命令show variables查看数据库中的所有参数,也可以通过LIKE来过滤参数名,前面查找数据库引擎时已经展示过了。从 MySQL 5.1版本开始,还可以通过information_schema架构下的GLOBAL_VARIABLES视图来进行查找,推荐使用命令
show variables,使用更为简单,且各版本的 MySQL数据库都支持。
参数的具体含义可以参考MySQL官方手册:
https://dev.mysql/doc/refman/5.7/en/server-system-variables.html
但是课程中遇到的参数会进行讲解。
MySQL数据库中的参数可以分为两类:动态(dynamic)参数和静态(static)参数。同时从作用范围又可以分为全局变量和会话变量。
动态参数意味着可以在 MySQL实例运行中进行更改,静态参数说明在整个实例生命周期内都不得进行更改,就好像是只读(read only)的。
全局变量(GLOBAL)影响服务器的整体操作。
会话变量(SESSION/LOCAL)影响某个客户端连接的操作。
举个例子,用default_storage_engine来说明,在服务器启动时会初始化一个名为default_storage_engine,作用范围为GLOBAL的系统变量。之后每当有一个客户端连接到该服务器时,服务器都会单独为该客户端分配一个名为default_storage_engine,作用范围为SESSION的系统变量,该作用范围为SESSION的系统变量值按照当前作用范围为GLOBAL的同名系统变量值进行初始化。
可以通过SET命令对动态的参数值进行修改。
SET的语法如下:
set [global || session ] system_var_name= expr
或者
set [@@global. || @@session.] system_var_name= expr
比如:
set read_buffer_size=524288;
set session read_buffer_size=524288;
set @@global.read_buffer_size=524288;
MySQL所有动态变量的可修改范围,可以参考MySQL官方手册的 Dynamic System Variables 的相关内容:
https://dev.mysql/doc/refman/5.7/en/dynamic-system-variables.html
对于静态变量,若对其进行修改,会得到类似如下错误:
数据目录
像InnoDB、MyIASM这样的存储引擎都是把表存储在磁盘上的,而操作系统用来管理磁盘的那个东东又被称为文件系统,所以用专业一点的话来表述就是:像InnoDB、MyISAM这样的存储引擎都是把表存储在文件系统上的。当想读取数据的时候,这些存储引擎会从文件系统中把数据读出来返回,当想写入数据的时候,这些存储引擎会把这些数据又写回文件系统。
确定MySQL中的数据目录
那说了半天,到底MySQL把数据都存到哪个路径下呢?其实数据目录对应着一个系统变量datadir,在使用客户端与服务器建立连接之后查看这个系统变量的值就可以了:
show variables like 'datadir';
当然这个目录可以通过配置文件进行修改,自己进行指定。
数据目录中放些什么?
MySOL在运行过程中都会产生哪些数据呢?当然会包含创建的数据库、表、视图和触发器等用户数据,除了这些用户数据,为了程序更好的运行,MySQL也会创建一些其他的额外数据
数据库在文件系统中的表示
create database yuyang charset=utf8;
每当使用CREATE DATABASE语句创建一个数据库的时候,在文件系统上实际发生了什么呢?其实很简单,每个数据库都对应数据目录下的一个子目录,或者说对应一个文件夹,每当新建一个数据库时,MySQL会帮做这两件事儿:
1.在数据目录下创建一个和数据库名同名的子目录(或者说是文件夹)。
2.在该与数据库名同名的子目录下创建一个名为db.opt的文件,这个文件中包含了该数据库的各种属性,比方说该数据库的字符集和比较规则是个啥。
比方说查看一下在我的计算机上当前有哪些数据库︰
可以看到在当前有5个数据库,其中mysqladv数据库是自定义的,其余4个数据库是属于MySQL自带的系统数据库。再看一下数据目录下的内容:
当然这个数据目录下的文件和子目录比较多,但是如果仔细看的话,除了information_schema这个系统数据库外,其他的数据库在数居目录下都有对应的子目录。这个information_schema比较特殊。
表在文件系统中的表示
数据其实都是以记录的形式插入到表中的,每个表的信息其实可以分为两种:
1.表结构的定义
2.表中的数据
表结构就是该表的名称是啥,表里边有多少列,每个列的数据类型是啥,有啥约束条件和索引,用的是啥字符集和比较规则各种信息,这些信息都体现在了建表语句中了。为了保存这些信息,InnoDB和MyIASM这两种存储引擎都在数据目录下对应的数据库子目录下创建了一个专门用于描述表结构的文件,文件名是这样:表名.frm
比方说假如使用了独立表空间去存储yuyang数据库下的test表的话,那么在该表所在数据库对应的yuyang目录下会为test表创建这两个文件:
那在数据库mysqladv对应的子目录下就会创建一个名为test.frm的用于描述表结构的文件。这个后缀名为.fm是以二进制格式存储的。
那表中的数据存到什么文件中了呢?在这个问题上,不同的存储引擎就产生了有所不同,下边看一下InnoDB和MyISAM是用什么文件来保存表中数据的。
lnnoDB是如何存储表数据的
InnoDB的数据会放在一个表空间或者文件空间(英文名: table space或者file space)的概念,这个表空间是一个抽象的概念,它可以对应文件系统上一个或多个真实文件〈不同表空间对应的文件数量可能不同)。每一个表空间可以被划分为很多很多很多个页,表数据就存放在某个表空间下的某些页里。表空间有好几种类型。
系统表空间(system tablespace)
这个所谓的系统表空间可以对应文件系统上一个或多个实际的文件,默认情况下,InnoDB会在数据目录下创建一个名为ibdata1(在你的数据目录下找找看有木有)、大小为12M的文件,这个文件就是对应的系纳表空间在文件系统上的表示。
这个文件是所谓的自扩展文件,也就是当不够用的时候它会自己增加文件大小,当然,如果你想让系统表空间对应文件系统上多个实际文件,或者仅仅觉得原来的ibdata1这个文件名难听,那可以在MySQL启动时配置对应的文件路径以及它们的大小,也可以把系统表空间对应的文件路径不配置到数据目录下,甚至可以配置到单独的磁盘分区上。
需要注意的一点是,在一个MySQL服务器中,系统表空间只有一份。从MySQL5.5.7到MySQL5.6.6之间的各个版本中,表中的数据都会被默认存储到这个系统表空间。
独立表空间(file-per-table tablespace)
在MySQL5.6.6以及之后的版本中,InnoB并不会默认的把各个表的数据存储到系统表空间中,而是为每一个表建立一个独立表空间,也就是说创建了多少个表,就有多少个独立表空间。使用独立表空间来存储表数据的话,会在该表所属数据库对应的子目录下创建一个表示该独立表空间的文件,文件名和表名相同,只不过添加了一个.ibd的扩展名而已,所以完整的文件名称长这样:表名.ibd。
test.frm和test.ibd
create table test(c1 int )engine=Innodb;
其中test.ibd文件就用来存储test表中的数据和索引。当然也可以自己指定使用系统表空间还是独立表空间来存储数据,这个功能由启动参数
innodb_file_per_table控制,比如说想刻意将表数据都存储到系统表空间时,可以在启动MySQL服务器的时候这样配置:
[server]
innodb_file_per_table=0
当imodb_file_per table的值为0时,代表使用系统表空间;当innodb_file_per table的值为1时,代表使用独立表空间。不过inmodb_file_per_table参数只对新建的表起作用,对于已经分配了表空间的表并不起作用。
其他类型的表空间
随着MySQL的发展,除了上述两种老牌表空间之外,现在还新提出了一些不同类型的表空间,比如通用表空间(general tablespace) ,undo表空间(undotablespace)、临时表空间〈temporary tablespace)等。
MyISAM是如何存储表数据的
在MyISAM中的数据和索引是分开存放的。所以在文件系统中也是使用不同的文件来存储数据文件和索引文件。而且和InnoDB不同的是,MyISA并没有什么所谓的表空间一说,表数据都存放到对应的数据库子目录下。
test_myisam表使用MyISAM存储引擎的话,那么在它所在数据库对应的yuyang目录下会为myisam表创建三个文件:
其中test_myisam.MYD代表表的数据文件,也就是插入的用户记录; test_myisam.MYI代表表的索引文件,为该表创建的索引都会放到这个文件中。
日志文件
在服务器运行过程中,会产生各种各样的日志,比如常规的查询日志、错误日志、二进制日志、redo日志、Undo日志等等,日志文件记录了影响MySQL数据库的各种类型活动。
常见的日志文件有:错误日志(error log)、慢查询日志(slow query log)、查询日志(query log)、二进制文件(bin log)。
错误日志
错误日志文件对MySQL的启动、运行、关闭过程进行了记录。遇到问题时应该首先查看该文件以便定位问题。该文件不仅记录了所有的错误信息,也记录一些警告信息或正确的信息
用户可以通过下面命令来查看错误日志文件的位置:
show variables like 'log_error'\G;
当MySQL不能正常启动时,第一个必须查找的文件应该就是错误日志文件,该文件记录了错误信息。
慢查询日志
慢查询日志可以帮助定位可能存在问题的SQL语句,从而进行SQL语句层面的优化。
已经知道慢查询日志可以帮助定位可能存在问题的SQL语句,从而进行SQL语句层面的优化。但是默认值为关闭的,需要手动开启。
show VARIABLES like 'slow_query_log';
set GLOBAL slow_query_log=1;
开启1,关闭0
但是多慢算慢?MySQL中可以设定一个阈值,将运行时间超过该值的所有SQL语句都记录到慢查询日志中。long_query_time参数就是这个阈值。默认值为10,代表10秒。
show VARIABLES like '%long_query_time%';
当然也可以设置
set global long_query_time=0;
默认10秒,这里为了演示方便设置为0
同时对于运行的SQL语句没有使用索引,则MySQL数据库也可以将这条SQL语句记录到慢查询日志文件,控制参数是:
show VARIABLES like '%log_queries_not_using_indexes%';
开启1,关闭0(默认)
show VARIABLES like '%slow_query_log_file%';
查询日志
查看当前的通用日志文件是否开启
show variables like '%general%'
开启通⽤⽇志查询: set global general_log = on;
关闭通⽤⽇志查询:set global general_log = off;
查询日志记录了所有对MySQL数据库请求的信息,无论这些请求是否得到了正确的执行。
默认文件名:主机名.log
二进制日志(binlog)
二进制日志记录了所有的DDL和DML语句(除了数据查询语句select),以事件形式记录,还包含语句所执⾏的消耗的时间,MySQL的⼆进制⽇志是事务安全型的
二进制日志的几种作用:
恢复(recovery):某些数据的恢复需要二进制日志,例如,在一个数据库全备文件恢复后,用户可以通过二进制文件进行point-in-time的恢复
复制(replication):其原理与恢复类似,通过复制和执行二进制日志使一台远程的MySQL数据库(一般称为slave或standby)与一台MySQL数据库(一般称为master或primary)进行实时同步
审计(audit):用户可以通过二进制日志中的信息来进行审计,判断是否有对数据库进行注入的攻击
log-bin参数该参数用来控制是否开启二进制日志,默认为关闭
如果想要开启二进制日志的功能,可以在MySQL的配置文件中指定如下的格式:
“name”为二进制日志文件的名称
如果不提供name,那么数据库会使用默认的日志文件名(文件名为主机名,后缀名为二进制日志的序列号),且文件保存在数据库所在的目录(datadir下)
–启用/设置二进制日志文件(name可省略)
log-bin=name;
配置以后,就会在数据目录下产生类似于:
bin_log.00001即为二进制日志文件;bin_log.index为二进制的索引文件,用来存储过往产生的二进制日志序号,通常情况下,不建议手动修改这个文件。
二进制日志文件在默认情况下并没有启动,需要手动指定参数来启动。开启这个选项会对MySQL的性能造成影响,但是性能损失十分有限。根据MySQL官方手册中的测试指明,开启二进制日志会使性能下降1%。
查看binlog是否开启
show variables like 'log_bin';
mysql安装目录下修改myf
log_bin=mysql-bin
binlog-format=ROW
server-id=1
expire_logs_days =30
其他的数据文件
除了上边说的这些用户自己存储的数据以外,数据文件下还包括为了更好运行程序的一些额外文件,当然这些文件不一定会放在数据目录下,而且可以在配置文件或者启动时另外指定存放目录。
主要包括这几种类型的文件:
·服务器进程文件。
知道每运行一个MySQL服务器程序,都意味着启动一个进程。MySQL服务器会把自己的进程ID写入到一个pid文件中。
socket文件
当用UNIX域套接字方式进行连接时需要的文件。
·默认/自动生成的SSL和RSA证书和密钥文件。
MySQL中的系统库
系统库简介
MySQL有几个系统数据库,这几个数据库包含了MySQL服务器运行过程中所需的一些信息以及一些运行状态信息,现在稍微了解一下。
performance_schema
这个数据库里主要保存MySQL服务器运行过程中的一些状态信息,算是对MySQL服务器的一个性能监控。包括统计最近执行了哪些语句,在执行过程的每个阶段都花费了多长时间,内存的使用情况等等信息。
information_schema
这个数据库保存着MySQL服务器维护的所有其他数据库的信息,比如有哪些表、哪些视图、哪些触发器、哪些列、哪些索引。这些是一些描述性信息,称之为元数据。
sys
这个数据库通过视图的形式把information_schema和performance_schema结合起来,让程序员可以更方便的了解MySQL服务器的一些性能信息。
mysql
主要存储了MySQL的用户账户和权限信息,还有一些存储过程、事件的定义信息,一些运行过程中产生的日志信息,一些帮助信息以及时区信息等。
performance_schema
什么是performance_schema
MySQL的performance_schema 是运行在较低级别的用于监控MySQL Server运行过程中的资源消耗、资源等待等情况的一个功能特性,它具有以下特点。
运行在较低级别: 采集的东西相对比较底层,比如磁盘文件、表I/O、表锁等等。
• performance_schema提供了一种在数据库运行时实时检查Server内部执行情况的方法。performance_schema 数据库中的表使用performance_schema存储引擎。该数据库主要关注数据库运行过程中的性能相关数据。
• performance_schema通过监视Server的事件来实现监视其内部执行情况,“事件”就是在Server内部活动中所做的任何事情以及对应的时间消耗,利用这些信息来判断Server中的相关资源被消耗在哪里。一般来说,事件可以是函数调用、操作系统的等待、SQL语句执行的阶段[如SQL语句执行过程中的parsing(解析)或sorting(排序)阶段]或者整个SQL语句的集合。采集事件可以方便地提供Server中的相关存储引擎对磁盘文件、表I/O、表锁等资源的同步调用信息。
• 当前活跃事件、历史事件和事件摘要相关表中记录的信息,能提供某个事件的执行次数、使用时长,进而可用于分析与某个特定线程、特定对象(如mutex或file)相关联的活动。
• performance_schema存储引擎使用Server源代码中的“检测点”来实现事件数据的收集。对于performance_schema实现机制本身的代码没有相关的单独线程来检测,这与其他功能(如复制或事件计划程序)不同。
收集到的事件数据被存储在performance_schema数据库的表中。对于这些表可以使用SELECT语句查询,也可以使用SQL语句更新performance_schema数据库中的表记录(比如动态修改performance_schema的以“setup_”开头的配置表,但要注意,配置表的更改会立即生效,这会影响数据收集)。
• performance_schema的表中数据不会持久化存储在磁盘中,而是保存在内存中,一旦服务器重启,这些数据就会丢失(包括配置表在内的整个performance_schema下的所有数据)。
performance_schema使用
通过上面介绍,相信你对于什么是performance_schema这个问题了解得更清晰了。下面开始介绍performance_schema的使用。
检查当前数据库版本是否支持
performance_schema被视为存储引擎,如果该引擎可用,则应该在
INFORMATION_SCHEMA.ENGINES表或show engines语句的输出中可以看到它的Support字段值为YES,如下所示。
select * from INFORMATION_SCHEMA.ENGINES;
show engines;
当看到performance_schema对应的Support字段值为YES时,就表示当前的数据库版本是支持performance_schema的。但确认了数据库实例支持performance_schema存储引擎就可以使用了吗?NO,很遗憾,performance_schema在MySQL 5.6及之前的版本中默认没有启用,在MySQL 5.7及之后的版本中才修改为默认启用。
mysqld启动之后,通过如下语句查看performance_schema启用是否生效(值为ON表示performance_schema已初始化成功且可以使用了;值为OFF表示在启用performance_schema时发生某些错误,可以查看错误日志进行排查)。
show variables like 'performance_schema';
(如果要显式启用或关闭 performance_schema ,则需要使用参数performance_schema=ON|OFF来设置,并在myf中进行配置。注意 : 该参数为只读参数,需要在实例启动之前设置才生效)
现在,可以通过查询INFORMATION_SCHEMA.TABLES表中与performance_schema存储引擎相关的元数据,或者在performance_schema库下使用show tables语句来了解其存在哪些表。
使用show tables语句来查询有哪些performance_schema引擎表。
现在,知道了在当前版本中,performance_schema库下一共有87个表,
那么这些表都用于存放什么数据呢?如何使用它们来查询数据呢?先来看看这些表是如何分类的。
performance_schema表的分类
performance_schema库下的表可以按照监视的不同维度进行分组,例如:按照不同的数据库对象进行分组、按照不同的事件类型进行分组,或者按照事件类型分组之后,再进一步按照账号、主机、程序、线程、用户等进行细分。
下面介绍按照事件类型分组记录性能事件数据的表。
• 语句事件记录表:记录语句事件信息的表,包括:events_statements_current(当前语句事件表)、events_statements_history(历史语句事件表)、events_statements_history_long(长语句历史事件表)以及一些summary表(聚合后的摘要表)。其中,summary表还可以根据账号(account)、主机(host)、程序(program)、线程(thread)、用户(user)和全局(global)再进行细分。
show tables like 'events_statement%';
• 等待事件记录表:与语句事件记录表类似。
show tables like 'events_wait%';
• 阶段事件记录表:记录语句执行阶段事件的表,与语句事件记录表类似。
show tables like 'events_stage%';
• 事务事件记录表:记录与事务相关的事件的表,与语句事件记录表类似。
show tables like 'events_transaction%';
• 监视文件系统层调用的表:
show tables like '%file%';
• 监视内存使用的表:
show tables like '%memory%';
• 动态对performance_schema进行配置的配置表:
show tables like '%setup%';
现在,已经大概知道了performance_schema中主要表的分类,但如何使用这些表来提供性能事件数据呢?
performance_schema简单配置与使用
当数据库初始化完成并启动时,并非所有的instruments(在采集配置项的配置表中,每一项都有一个开关字段,或为YES,或为NO)和consumers(与采集配置项类似,也有一个对应的事件类型保存表配置项,为YES表示对应的表保存性能数据,为NO表示对应的表不保存性能数据)都启用了,所以默认不会收集所有的事件。
可能你想检测的事件并没有打开,需要进行设置。可以使用如下两条语句打开对应的instruments和consumers,以配置监测等待事件数据为例进行说明。
打开等待事件的采集器配置项开关,需要修改setup_instruments 配置表中对应的采集器配置项。
update setup_instruments set enabled='yes',timed='yes' where name like 'wait%';
打开等待事件的保存表配置项开关,修改setup_consumers 配置表中对应的配置项。
update setup_consumers set enabled='yes' where name like 'wait%';
配置好之后,就可以查看Server当前正在做什么了。可以通过查询events_waits_current表来得知,该表中每个线程只包含一行数据,用于显示每个线程的最新监视事件(正在做的事情)。
_current表中每个线程只保留一条记录,且一旦线程完成工作,该表中就不会再记录该线程的事件信息了。_history表中记录每个线程已经执行完成的事件信息,但每个线程的事件信息只记录10条,再多就会被覆盖掉。*_history_long表中记录所有线程的事件信息,但总记录数量是10000行,超过会被覆盖掉。
summary表提供所有事件的汇总信息。该组中的表以不同的方式汇总事件数据(如:按用户、按主机、按线程等汇总)。
查看最近执行失败的SQL语句
使用代码对数据库的某些操作(比如:使用Java的ORM框架操作数据库)报出语法错误,但是代码并没有记录SQL语句文本的功能,在MySQL数据库层能否查看到具体的SQL语句文本,看看是否哪里写错了?这个时候,大多数人首先想到的就是去查看错误日志。很遗憾,对于SQL语句的语法错误,错误日志并不会记录。
实际上,在performance_schema的语句事件记录表中针对每一条语句的执行状态都记录了较为详细的信息,例如:events_statements_表和events_statements_summary_by_digest表(events_statements_表记录了语句所有的执行错误信息,而events_statements_summary_by_digest表只记录了语句在执行过程中发生错误的语句记录统计信息,不记录具体的错误类型,例如:不记录语法错误类的信息)。下面看看如何使用这两个表查询语句发生错误的语句信息。
首先,模拟一条语法错误的SQL语句,使用events_statements_history_long表或events_statements_history表查询发生语法错误的SQL语句:
然后,查询events_statements_history表中错误号为1064的记录
select * from events_statements_history where mysql_errno=1064\G
如果不知道错误号是多少,可以查询发生错误次数不为0的语句记录,在里边找到SQL_TEXT和MESSAGE_TEXT字段(提示信息为语法错误的就是它)。
查看最近的事务执行信息
可以通过慢查询日志查询到一条语句的执行总时长,但是如果数据库中存在着一些大事务在执行过程中回滚了,或者在执行过程中异常中止,这个时候慢查询日志就爱莫能助了,这可以借助performance_schema的events_transactions_*表来查看与事务相关的记录,在这些表中详细记录了是否有事务被回滚、活跃(长时间未提交的事务也属于活跃事务)或已提交等信息。
首先需要进行配置启用,事务事件默认并未启用
update setup_instruments set enabled='yes',timed='yes' where name like 'transaction%';
update setup_consumers set enabled='yes' where name like '%transaction%';
现在开启一个新会话(会话2)用于执行事务,并模拟事务回滚。
查询活跃事务,活跃事务表示当前正在执行的事务事件,需要从events_transactions_current表中查询。
下图中可以看到有一条记录,代表当前活跃的事务事件。
会话2中回滚事务:
查询事务事件当前表(events_transactions_current)和事务事件历史记录表(events_transactions_history)
可以看到在两表中都记录了一行事务事件信息,线程ID为30的线程执行了一个事务,事务状态为ROLLED BACK。
但是当关闭会话2以后,事务事件当前表中(events_transactions_current)的记录就消失了。
要查询的话需要去(events_transactions_history_long)表中查
小结
当然performance_schema的用途不止说到过的这些,它还能提供比如查看SQL语句执行阶段和进度信息、MySQL集群下复制功能查看复制报错详情等等。
具体可以参考官网:MySQL :: MySQL 5.7 Reference Manual :: 25 MySQL Performance Schema
1.3.sys系统库
1.3.1.sys使用须知
sys系统库支持MySQL 5.6或更高版本,不支持MySQL 5.5.x及以下版本。
sys系统库通常都是提供给专业的DBA人员排查一些特定问题使用的,其下所涉及的各项查询或多或少都会对性能有一定的影响。
因为sys系统库提供了一些代替直接访问performance_schema的视图,所以必须启用performance_schema(将performance_schema系统参数设置为ON),sys系统库的大部分功能才能正常使用。
同时要完全访问sys系统库,用户必须具有以下数据库的管理员权限。
如果要充分使用sys系统库的功能,则必须启用某些performance_schema的功能。比如:
启用所有的wait instruments:
CALL sys.ps_setup_enable_instrument('wait');
启用所有事件类型的current表:
CALL sys.ps_setup_enable_consumer('current');
注意: performance_schema的默认配置就可以满足sys系统库的大部分数据收集功能。启用所有需要功能会对性能产生一定的影响,因此最好仅启用所需的配置。
sys系统库使用
如果使用了USE语句切换默认数据库,那么就可以直接使用sys系统库下的视图进行查询,就像查询某个库下的表一样操作。也可以使用db_name.view_name、db_name.procedure_name、db_name.func_name等方式,在不指定默认数据库的情况下访问sys 系统库中的对象(这叫作名称限定对象引用)。
在sys系统库下包含很多视图,它们以各种方式对performance_schema表进行聚合计算展示。这些视图大部分是成对出现的,两个视图名称相同,但有一个视图是带 x 前缀的 . 前缀的. 前缀的.
host_summary_by_file_io和 x$host_summary_by_file_io
代表按照主机进行汇总统计的文件I/O性能数据,两个视图访问的数据源是相同的,但是在创建视图的语句中,不带x
前缀的视图显示的是相关数值经过单位换算后的数据(单位是毫秒、秒、分钟、小时、天等),带
x
前缀的视图显示的是相关数值经过单位换算后的数据(单位是毫秒、秒、分钟、小时、天等),带 x
前缀的视图显示的是相关数值经过单位换算后的数据(单位是毫秒、秒、分钟、小时、天等),带x 前缀的视图显示的是原始的数据(单位是皮秒)。
查看慢SQL语句慢在哪里
如果频繁地在慢查询日志中发现某个语句执行缓慢,且在表结构、索引结构、统计信息中都无法找出原因时,则可以利用sys系统库中的撒手锏:sys.session视图结合performance_schema的等待事件来找出症结所在。那么session视图有什么用呢?使用它可以查看当前用户会话的进程列表信息,看看当前进程到底再干什么,注意,这个视图在MySQL 5.7.9中才出现。
首先需要启用与等待事件相关功能:
call sys.ps_setup_enable_instrument('wait');
call sys.ps_setup_enable_consumer('wait');
然后模拟一下:
一个session中执行
select sleep(30);
另外一个session中在sys库中查询:
select * from session where command='query' and conn_id !=connection_id()\G
查询表的增、删、改、查数据量和I/O耗时统计
select * from schema_table_statistics_with_buffer\G
小结
除此之外,通过sys还可以查询查看InnoDB缓冲池中的热点数据、查看是否有事务锁等待、查看未使用的,冗余索引、查看哪些语句使用了全表扫描等等。
具体可以参考官网:MySQL :: MySQL 5.7 Reference Manual :: 26 MySQL sys Schema
information_schema
什么是information_schema
information_schema提供了对数据库元数据、统计信息以及有关MySQL Server信息的访问(例如:数据库名或表名、字段的数据类型和访问权限等)。该库中保存的信息也可以称为MySQL的数据字典或系统目录。
在每个MySQL 实例中都有一个独立的information_schema,用来存储MySQL实例中所有其他数据库的基本信息。information_schema库下包含多个只读表(非持久表),所以在磁盘中的数据目录下没有对应的关联文件,且不能对这些表设置触发器。虽然在查询时可以使用USE语句将默认数据库设置为information_schema,但该库下的所有表是只读的,不能执行INSERT、UPDATE、DELETE等数据变更操作。
针对information_schema下的表的查询操作可以替代一些SHOW查询语句(例如:SHOW DATABASES、SHOW TABLES等)。
注意:根据MySQL版本的不同,表的个数和存放是有所不同的。在MySQL 5.6版本中总共有59个表,在MySQL 5.7版本中,该schema下总共有61个表,
在MySQL 8.0版本中,该schema下的数据字典表(包含部分原Memory引擎临时表)都迁移到了mysql schema下,且在mysql schema下这些数据字典表被隐藏,无法直接访问,需要通过information_schema下的同名表进行访问。
information_schema下的所有表使用的都是Memory和InnoDB存储引擎,且都是临时表,不是持久表,在数据库重启之后这些数据会丢失。在MySQL 的4个系统库中,information_schema也是唯一一个在文件系统上没有对应库表的目录和文件的系统库。
information_schema表分类
Server层的统计信息字典表
(1)COLUMNS
• 提供查询表中的列(字段)信息。
(2)KEY_COLUMN_USAGE
• 提供查询哪些索引列存在约束条件。
• 该表中的信息包含主键、唯一索引、外键等约束信息,例如:所在的库表列名、引用的库表列名等。该表中的信息与TABLE_CONSTRAINTS表中记录的信息有些类似,但TABLE_CONSTRAINTS表中没有记录约束引用的库表列信息,而KEY_COLUMN_USAGE表中却记录了TABLE_CONSTRAINTS表中所没有的约束类型。
(3)REFERENTIAL_CONSTRAINTS
• 提供查询关于外键约束的一些信息。
(4)STATISTICS
• 提供查询关于索引的一些统计信息,一个索引对应一行记录。
(5)TABLE_CONSTRAINTS
• 提供查询与表相关的约束信息。
(6)FILES
• 提供查询与MySQL的数据表空间文件相关的信息。
(7)ENGINES
• 提供查询MySQL Server支持的引擎相关信息。
(8)TABLESPACES
• 提供查询关于活跃表空间的相关信息(主要记录的是NDB存储引擎的表空间信息)。
• 注意:该表不提供有关InnoDB存储引擎的表空间信息。对于InnoDB表空间的元数据信息,请查询INNODB_SYS_TABLESPACES表和INNODB_SYS_DATAFILES表。另外,从MySQL 5.7.8开始,INFORMATION_SCHEMA.FILES表也提供查询InnoDB表空间的元数据信息。
(9)SCHEMATA
• 提供查询MySQL Server中的数据库列表信息,一个schema就代表一个数据库。
Server层的表级别对象字典表
(1)VIEWS
• 提供查询数据库中的视图相关信息。查询该表的账户需要拥有show view权限。
(2)TRIGGERS
• 提供查询关于某个数据库下的触发器相关信息。
(3)TABLES
• 提供查询与数据库内的表相关的基本信息。
(4)ROUTINES
• 提供查询关于存储过程和存储函数的信息(不包括用户自定义函数)。该表中的信息与mysql.proc中记录的信息相对应(如果该表中有值的话)。
(5)PARTITIONS
• 提供查询关于分区表的信息。
(6)EVENTS
• 提供查询与计划任务事件相关的信息。
(7)PARAMETERS
• 提供有关存储过程和函数的参数信息,以及有关存储函数的返回值信息。这些参数信息与mysql.proc表中的param_list列记录的内容类似。
Server层的混杂信息字典表
(1)GLOBAL_STATUS、GLOBAL_VARIABLES、SESSION_STATUS、
SESSION_VARIABLES
• 提供查询全局、会话级别的状态变量与系统变量信息。
(2)OPTIMIZER_TRACE
• 提供优化程序跟踪功能产生的信息。
• 跟踪功能默认是关闭的,使用optimizer_trace系统变量启用跟踪功能。如果开启该功能,则每个会话只能跟踪它自己执行的语句,不能看到其他会话执行的语句,且每个会话只能记录最后一条跟踪的SQL语句。
(3)PLUGINS
• 提供查询关于MySQL Server支持哪些插件的信息。
(4)PROCESSLIST
• 提供查询一些关于线程运行过程中的状态信息。
(5)PROFILING
• 提供查询关于语句性能分析的信息。其记录内容对应于SHOW PROFILES和SHOW PROFILE语句产生的信息。该表只有在会话变量 profiling=1时才会记录语句性能分析信息,否则该表不记录。
• 注意:从MySQL 5.7.2开始,此表不再推荐使用,在未来的MySQL版本中删除,改用Performance Schema代替。
(6)CHARACTER_SETS
• 提供查询MySQL Server支持的可用字符集。
(7)COLLATIONS
• 提供查询MySQL Server支持的可用校对规则。
(8)COLLATION_CHARACTER_SET_APPLICABILITY
• 提供查询MySQL Server中哪种字符集适用于什么校对规则。查询结果集相当于从SHOW COLLATION获得的结果集的前两个字段值。目前并没有发现该表有太大的作用。
(9)COLUMN_PRIVILEGES
• 提供查询关于列(字段)的权限信息,表中的内容来自mysql.column_priv列权限表(需要针对一个表的列单独授权之后才会有内容)。
(10)SCHEMA_PRIVILEGES
• 提供查询关于库级别的权限信息,每种类型的库级别权限记录一行信息,该表中的信息来自mysql.db表。
(11)TABLE_PRIVILEGES
• 提供查询关于表级别的权限信息,该表中的内容来自mysql.tables_priv表。
(12)USER_PRIVILEGES
• 提供查询全局权限的信息,该表中的信息来自mysql.user表。
InnoDB层的系统字典表
(1)INNODB_SYS_DATAFILES
• 提供查询InnoDB所有表空间类型文件的元数据(内部使用的表空间ID和表空间文件的路径信息),包括独立表空间、常规表空间、系统表空间、临时表空间和undo空间(如果开启了独立undo空间的话)。
• 该表中的信息等同于InnoDB数据字典内部SYS_DATAFILES表的信息。
(2)INNODB_SYS_VIRTUAL
• 提供查询有关InnoDB虚拟生成列和与之关联的列的元数据信息,等同于InnoDB数据字典内部SYS_VIRTUAL表的信息。该表中展示的行信息是与虚拟生成列相关联列的每个列的信息。
(3)INNODB_SYS_INDEXES
• 提供查询有关InnoDB索引的元数据信息,等同于InnoDB数据字典内部SYS_INDEXES表中的信息。
(4)INNODB_SYS_TABLES
• 提供查询有关InnoDB表的元数据信息,等同于InnoDB数据字典内部SYS_TABLES表的信息。
(5)INNODB_SYS_FIELDS
• 提供查询有关InnoDB索引键列(字段)的元数据信息,等同于InnoDB数据字典内部SYS_FIELDS表的信息。
(6)INNODB_SYS_TABLESPACES
• 提供查询有关InnoDB独立表空间和普通表空间的元数据信息(也包含了全文索引表空间),等同于InnoDB数据字典内部SYS_TABLESPACES表的信息。
(7)INNODB_SYS_FOREIGN_COLS
• 提供查询有关InnoDB外键列的状态信息,等同于InnoDB数据字典内部
SYS_FOREIGN_COLS表的信息。
(8)INNODB_SYS_COLUMNS
• 提供查询有关InnoDB表列的元数据信息,等同于InnoDB数据字典内部
SYS_COLUMNS表的信息。
(9)INNODB_SYS_FOREIGN
• 提供查询有关InnoDB外键的元数据信息,等同于InnoDB数据字典内部SYS_FOREIGN表的信息。
(10)INNODB_SYS_TABLESTATS
• 提供查询有关InnoDB表的较低级别的状态信息视图。 MySQL优化器会使用这些统计信息数据来计算并确定在查询InnoDB表时要使用哪个索引。这些信息保存在内存中的数据结构中,与存储在磁盘上的数据无对应关系。在InnoDB内部也无对应的系统表。
InnoDB层的锁、事务、统计信息字典表
(1)INNODB_LOCKS
• 提供查询InnoDB引擎中事务正在请求的且同时被其他事务阻塞的锁信息(即没有发生不同事务之间锁等待的锁信息,在这里是查看不到的。例如,当只有一个事务时,无法查看到该事务所加的锁信息)。该表中的内容可用于诊断高并发下的锁争用信息。
(2)INNODB_TRX
• 提供查询当前在InnoDB引擎中执行的每个事务(不包括只读事务)的信息,包括事务是否正在等待锁、事务什么时间点开始,以及事务正在执行的SQL语句文本信息等(如果有SQL语句的话)。
(3)INNODB_BUFFER_PAGE_LRU
• 提供查询缓冲池中的页面信息。与INNODB_BUFFER_PAGE表不同,INNODB_BUFFER_PAGE_LRU表保存有关InnoDB缓冲池中的页如何进入LRU链表,以及在缓冲池不够用时确定需要从中逐出哪些页的信息。
(4)INNODB_LOCK_WAITS
• 提供查询InnoDB事务的锁等待信息。如果查询该表为空,则表示无锁等待信息;如果查询该表中有记录,则说明存在锁等待,表中的每一行记录表示一个锁等待关系。在一个锁等待关系中包含:一个等待锁(即,正在请求获得锁)的事务及其正在等待的锁等信息、一个持有锁(这里指的是发生锁等待事务正在请求的锁)的事务及其所持有的锁等信息。
(5)INNODB_TEMP_TABLE_INFO
• 提供查询有关在InnoDB实例中当前处于活动状态的用户(只对已建立连接的用户有效,断开的用户连接对应的临时表会被自动删除)创建的InnoDB临时表的信息。它不提供查询优化器使用的内部InnoDB临时表的信息。该表在首次查询时创建。
(6)INNODB_BUFFER_PAGE
• 提供查询关于缓冲池中的页相关信息。
(7)INNODB_METRICS
• 提供查询InnoDB更为详细的性能信息,是对InnoDB的performance_schema的补充。通过对该表的查询,可用于检查InnoDB的整体健康状况,也可用于诊断性能瓶颈、资源短缺和应用程序的问题等。
(8)INNODB_BUFFER_POOL_STATS
• 提供查询一些InnoDB缓冲池中的状态信息,该表中记录的信息与SHOW ENGINEINNODB STATUS语句输出的缓冲池统计部分信息类似。另外,InnoDB缓冲池的一些状态变量也提供了部分相同的值。
InnoDB层的全文索引字典表
(1)INNODB_FT_CONFIG
(2)INNODB_FT_BEING_DELETED
(3)INNODB_FT_DELETED
(4)INNODB_FT_DEFAULT_STOPWORD
(5)INNODB_FT_INDEX_TABLE
InnoDB层的压缩相关字典表
(1)INNODB_CMP和INNODB_CMP_RESET
• 这两个表中的数据包含了与压缩的InnoDB表页有关的操作状态信息。表中记录的数据为测量数据库中的InnoDB表压缩的有效性提供参考。
(2)INNODB_CMP_PER_INDEX和INNODB_CMP_PER_INDEX_RESET
• 这两个表中记录了与InnoDB压缩表数据和索引相关的操作状态信息,对数据库、表、索引的每个组合使用不同的统计信息,以便为评估特定表的压缩性能和实用性提供参考数据。
(3)INNODB_CMPMEM和INNODB_CMPMEM_RESET
• 这两个表中记录了InnoDB缓冲池中压缩页的状态信息,为测量数据库中InnoDB表压缩的有效性提供参考。
information_schema应用
查看索引列的信息
INNODB_SYS_FIELDS表提供查询有关InnoDB索引列(字段)的元数据信息,等同于InnoDB数据字典中SYS_FIELDS表的信息。
INNODB_SYS_INDEXES表提供查询有关InnoDB索引的元数据信息,等同于InnoDB数据字典内部SYS_INDEXES表中的信息。
INNODB_SYS_TABLES表提供查询有关InnoDB表的元数据信息,等同于InnoDB数据字典中SYS_TABLES表的信息。
假设需要查询该库下的InnoDB表order_exp的索引列名称、组成和索引列顺序等相关信息,
则可以使用如下SQL语句进行查询
SELECT
t. NAME AS d_t_name,
i. NAME AS i_name,
i.type AS i_type,
i.N_FIELDS AS i_column_numbers,
f. NAME AS i_column_name,
f.pos AS i_position
FROM
INNODB_SYS_TABLES AS t
JOIN INNODB_SYS_INDEXES AS i ON t.TABLE_ID = i.TABLE_ID
LEFT JOIN INNODB_SYS_FIELDS AS f ON i.INDEX_ID = f.INDEX_ID
WHERE
t. NAME = 'lijin/order_exp';
结果中的列都很好理解,唯一需要额外解释的是i_type(INNODB_SYS_INDEXES.type),它是表示索引类型的数字ID:
0 =二级索引
1=集群索引
2 =唯一索引
3 =主键索引
32 =全文索引
64 =空间索引
128 =包含虚拟生成列的二级索引。
Mysql中mysql系统库
权限系统表
• user:包含用户账户、全局权限和其他非权限列表(安全配置字段和资源控制字段)。
• db:数据库级别的权限表。该表中记录的权限信息代表用户是否可以使用这些权限来访问被授予访问的数据库下的所有对象(表或存储程序)。
• tables_priv:表级别的权限表。
• columns_priv:字段级别的权限表。
• procs_priv:存储过程和函数权限表。
• proxies_priv:代理用户权限表。
提示:
要更改权限表的内容,应该使用账号管理语句(如: CREATE USER 、 GRANT 、 REVOKE等)来间接修改,不建议直接使用DML语句修改权限表。
(grant,revoke语句执行后会变更权限表中相关记录,同时会更新内存中记录用户权限的相关对象。dml语句直接修改权限表只是修改了表中权限信息,需要执行flush privileges;来更新内存中保存用户权限的相关对象)
1.5.2.统计信息表
持久化统计功能是通过将内存中的统计数据存储到磁盘中,使其在数据库重启时可以快速重新读入这些统计信息而不用重新执行统计,从而使得查询优化器可以利用这些持久化的统计信息准确地选择执行计划(如果没有这些持久化的统计信息,那么数据库重启之后内存中的统计信息将会丢失,下一次访问到某库某表时,需要重新计算统计信息,并且重新计算可能会因为估算值的差异导致查询计划发生变更,从而导致查询性能发生变化)。
如何启用统计信息的持久化功能呢?当innodb_stats_persistent = ON时全局的开启统计信息的持久化功能,默认是开启的,
show variables like 'innodb_stats_persistent';
如果要单独关闭某个表的持久化统计功能,则可以通过ALTER TABLE tbl_name STATS_PERSISTENT = 0语句来修改。
innodb_table_stats
innodb_table_stats表提供查询与表数据相关的统计信息。
select * from innodb_table_stats where table_name = 'order_exp'\G
database_name:数据库名称。
• table_name:表名、分区名或子分区名。
• last_update:表示InnoDB上次更新统计信息行的时间。
• n_rows:表中的估算数据记录行数。
• clustered_index_size:主键索引的大小,以页为单位的估算数值。
• sum_of_other_index_sizes:其他(非主键)索引的总大小,以页为单位的估算数值。
innodb_index_stats
innodb_index_stats表提供查询与索引相关的统计信息。
select * from innodb_index_stats where table_name = 'order_exp';
表字段含义如下。
• database_name:数据库名称。
• table_name:表名、分区表名、子分区表名。
• index_name:索引名称。
• last_update:表示InnoDB上次更新统计信息行的时间。
• stat_name:统计信息名称,其对应的统计信息值保存在stat_value字段中。
• stat_value:保存统计信息名称stat_name字段对应的统计信息值。
• sample_size:stat_value字段中提供的统计信息估计值的采样页数。
• stat_description:统计信息名称stat_name字段中指定的统计信息的说明。
从表的查询数据中可以看到:
• stat_name字段一共有如下几个统计值。
■ size:当stat_name字段为size值时,stat_value字段值表示索引中的总页数量。
■ n_leaf_pages:当stat_name字段为n_leaf_pages值时,stat_value字段值表示索引叶子页的数量。
■ n_diff_pfxNN:NN代表数字(例如01、02等)。当stat_name字段为n_diff_pfxNN值时,stat_value字段值表示索引的first column(即索引的最前索引列,从索引定义顺序的第一个列开始)列的唯一值数量。例如:当NN为01时,stat_value字段值就表示索引的第一个列的唯一值数量;当NN为02时,stat_value字段值就表示索引的第一个和第二个列组合的唯一值数量,依此类推。此外,在stat_name = n_diff_pfxNN的情况下,stat_description字段显示一个以逗号分隔的计算索引统计信息字段的列表。
• 从index_name字段值为PRIMARY数据行的stat_description字段的描述信息“id”中可以看出,主键索引的统计信息只包括创建主键索引时显式指定的列。
• 从index_name字段值为u_idx_day_status数据行的stat_description字段的描述信息“insert_time,order_status,expire_time”中可以看出,唯一索引的统计信息只包括创建唯一索引时显式指定的列。
• 从index_name字段值为idx_order_no数据行的stat_description字段的描述信息“order_no,id”中可以看出,普通索引(非唯一的辅助索引)的统计信息包括了显式定义的列和主键列。
注意,上述的描述中出现的诸如叶子页,索引的最前索引列等等,这些东西在索引章节有讲解,这里不再阐述。
日志记录表
MySQL的日志系统包含:普通查询日志、慢查询日志、错误日志(记录服务器启动时、运行中、停止时的错误信息)、二进制日志(记录服务器运行过程中数据变更的逻辑日志)、中继日志(记录从库I/O线程从主库获取的主库数据变更日志)、DDL日志(记录DDL语句执行时的元数据变更信息。在MySQL 5.7中只支持写入文件中,在MySQL 8.0中支持写入innodb_ddl_log表中。在MySQL5.7中,只有普通查询日志、慢查询日志支持写入表中(也支持写入文件中),可以通过log_output=TABLE设置保存到mysql.general_log表和mysql.slow_log表中,其他日志类型在MySQL 5.7中只支持写入文件中。
general_log
general_log表提供查询普通SQL语句的执行记录信息,用于查看客户端到底在服务器上执行了什么SQL语句。
缺省不开启
show variables like 'general_log';
开启
set global log_output='TABLE'; -- 'TABLE,FILE'表示同时输出到表和文件
set global general_log=on;
show variables like 'general_log';
任意执行一个查询后
select * from mysql.general_log\G
slow_log
slow_log表提供查询执行时间超过long_query_time设置值的SQL语句、未使用索引的语句(需要开启参数log_queries_not_using_indexes=ON)或者管理语句(需要开启参数log_slow_admin_statements=ON)。
show variables like 'log_queries_not_using_indexes';
show variables like 'log_slow_admin_statements';
开启
set global log_queries_not_using_indexes=on;
set global log_slow_admin_statements=on;
show variables like 'log_queries_not_using_indexes';
show variables like 'log_slow_admin_statements';
慢查询日志可以帮助定位可能存在问题的SQL语句,从而进行SQL语句层面的优化。但是默认值为关闭的,需要手动开启。
show VARIABLES like 'slow_query_log';
set GLOBAL slow_query_log=1;
开启1,关闭0
但是多慢算慢?MySQL中可以设定一个阈值,将运行时间超过该值的所有SQL语句都记录到慢查询日志中。long_query_time参数就是这个阈值。默认值为10,代表10秒。
show VARIABLES like '%long_query_time%';
当然也可以设置
set global long_query_time=0;
默认10秒,这里为了演示方便设置为0
然后测试一把,随便写一个SQL
select * from mysql.slow_log\G
InnoDB中的统计数据
比如通过SHOW TABLE STATUS可以看到关于表的统计数据,通过SHOW INDEX可以看到关于索引的统计数据,那么这些统计数据是怎么来的呢?它们是以什么方式收集的呢?
1.5.4.1 统计数据存储方式
InnoDB提供了两种存储统计数据的方式:
永久性的统计数据,这种统计数据存储在磁盘上,也就是服务器重启之后这些统计数据还在。
非永久性的统计数据,这种统计数据存储在内存中,当服务器关闭时这些这些统计数据就都被清除掉了,等到服务器重启之后,在某些适当的场景下才会重新收集这些统计数据。
MySQL提供了系统变量innodb_stats_persistent来控制到底采用哪种方式去存储统计数据。在MySQL 5.6.6之前,innodb_stats_persistent的值默认是OFF,也就是说InnoDB的统计数据默认是存储到内存的,之后的版本中innodb_stats_persistent的值默认是ON,也就是统计数据默认被存储到磁盘中。
SHOW VARIABLES LIKE 'innodb_stats_persistent';
不过最近的MySQL版本都基本不用基于内存的非永久性统计数据了。
不过InnoDB默认是以表为单位来收集和存储统计数据的,也就是说可以把某些表的统计数据(以及该表的索引统计数据)存储在磁盘上,把另一些表的统计数据存储在内存中。怎么做到的呢?可以在创建和修改表的时候通过指定STATS_PERSISTENT属性来指明该表的统计数据存储方式:
CREATE TABLE 表名 (…)
Engine=InnoDB, STATS_PERSISTENT = (1|0);
ALTER TABLE 表名
Engine=InnoDB, STATS_PERSISTENT = (1|0);
当STATS_PERSISTENT=1时,表明想把该表的统计数据永久的存储到磁盘上,当STATS_PERSISTENT=0时,表明想把该表的统计数据临时的存储到内存中。如果在创建表时未指定STATS_PERSISTENT属性,那默认采用系统变量innodb_stats_persistent的值作为该属性的值。
基于磁盘的永久性统计数据
当选择把某个表以及该表索引的统计数据存放到磁盘上时,实际上是把这些统计数据存储到了两个表里:
SHOW TABLES FROM mysql LIKE 'innodb%';
可以看到,这两个表都位于mysql系统数据库下边,其中:
innodb_table_stats存储了关于表的统计数据,每一条记录对应着一个表的统计数据。
innodb_index_stats存储了关于索引的统计数据,每一条记录对应着一个索引的一个统计项的统计数据。
innodb_table_stats
直接看一下这个innodb_table_stats表中的各个列都是干嘛的:
database_name 数据库名
table_name 表名
last_update 本条记录最后更新时间
n_rows表中记录的条数
clustered_index_size 表的聚簇索引占用的页面数量
sum_of_other_index_sizes 表的其他索引占用的页面数量
直接看一下这个表里的内容:
SELECT * FROM mysql.innodb_table_stats;
几个重要统计信息项的值如下:
n_rows的值是10350,表明order_exp表中大约有10350条记录,注意这个数据是估计值。
clustered_index_size的值是97,表明order_exp表的聚簇索引占用97个页面,这个值是也是一个估计值。
sum_of_other_index_sizes的值是81,表明order_exp表的其他索引一共占用81个页面,这个值是也是一个估计值。
n_rows统计项的收集
InnoDB统计一个表中有多少行记录是这样的:
按照一定算法(并不是纯粹随机的)选取几个叶子节点页面,计算每个页面中主键值记录数量,然后计算平均一个页面中主键值的记录数量乘以全部叶子节点的数量就算是该表的n_rows值。
可以看出来这个n_rows值精确与否取决于统计时采样的页面数量,MySQL用名为innodb_stats_persistent_sample_pages的系统变量来控制使用永久性的统计数据时,计算统计数据时采样的页面数量。该值设置的越大,统计出的n_rows值越精确,但是统计耗时也就最久;该值设置的越小,统计出的n_rows值越不精确,但是统计耗时特别少。所以在实际使用是需要去权衡利弊,该系统变量的默认值是20。
InnoDB默认是以表为单位来收集和存储统计数据的,也可以单独设置某个表的采样页面的数量,设置方式就是在创建或修改表的时候通过指定STATS_SAMPLE_PAGES属性来指明该表的统计数据存储方式:
CREATE TABLE 表名 (…)
Engine=InnoDB, STATS_SAMPLE_PAGES = 具体的采样页面数量;
ALTER TABLE 表名
Engine=InnoDB, STATS_SAMPLE_PAGES = 具体的采样页面数量;
如果在创建表的语句中并没有指定STATS_SAMPLE_PAGES属性的话,将默认使用系统变量innodb_stats_persistent_sample_pages的值作为该属性的值。
clustered_index_size和sum_of_other_index_sizes统计项的收集牵涉到很具体的InnoDB表空间的知识和存储页面数据的细节。
innodb_index_stats
直接看一下这个innodb_index_stats表中的各个列都是干嘛的:
desc mysql.innodb_index_stats;
字段名描述
database_name 数据库名
table_name 表名
index_name 索引名
last_update 本条记录最后更新时间
stat_name 统计项的名称
stat_value 对应的统计项的值
sample_size 为生成统计数据而采样的页面数量
stat_description 对应的统计项的描述
innodb_index_stats表的每条记录代表着一个索引的一个统计项。可能这会大家有些懵逼这个统计项到底指什么,直接看一下关于order_exp表的索引统计数据都有些什么:
SELECT * FROM mysql.innodb_index_stats WHERE table_name = 'order_exp';
先查看index_name列,这个列说明该记录是哪个索引的统计信息,从结果中可以看出来,PRIMARY索引(也就是主键)占了3条记录,idx_expire_time索引占了6条记录。
针对index_name列相同的记录,stat_name表示针对该索引的统计项名称,stat_value展示的是该索引在该统计项上的值,stat_description指的是来描述该统计项的含义的。来具体看一下一个索引都有哪些统计项:
n_leaf_pages:表示该索引的叶子节点占用多少页面。
size:表示该索引共占用多少页面。
n_diff_pfxNN:表示对应的索引列不重复的值有多少。其中的NN长得有点儿怪呀,啥意思呢?
其实NN可以被替换为01、02、03… 这样的数字。比如对于u_idx_day_status来说:
n_diff_pfx01表示的是统计insert_time这单单一个列不重复的值有多少。
n_diff_pfx02表示的是统计insert_time,order_status这两个列组合起来不重复的值有多少。
n_diff_pfx03表示的是统计insert_time,order_status,expire_time这三个列组合起来不重复的值有多少。
n_diff_pfx04表示的是统计key_pare1、key_pare2、expire_time、id这四个列组合起来不重复的值有多少。
对于普通的二级索引,并不能保证它的索引列值是唯一的,比如对于idx_order_no来说,key1列就可能有很多值重复的记录。此时只有在索引列上加上主键值才可以区分两条索引列值都一样的二级索引记录。
对于主键和唯一二级索引则没有这个问题,它们本身就可以保证索引列值的不重复,所以也不需要再统计一遍在索引列后加上主键值的不重复值有多少。比如u_idx_day_statu和idx_order_no。
在计算某些索引列中包含多少不重复值时,需要对一些叶子节点页面进行采样,sample_size列就表明了采样的页面数量是多少。
对于有多个列的联合索引来说,采样的页面数量是:innodb_stats_persistent_sample_pages × 索引列的个数。
当需要采样的页面数量大于该索引的叶子节点数量的话,就直接采用全表扫描来统计索引列的不重复值数量了。所以大家可以在查询结果中看到不同索引对应的size列的值可能是不同的。
定期更新统计数据
随着不断的对表进行增删改操作,表中的数据也一直在变化,innodb_table_stats和innodb_index_stats表里的统计数据也在变化。MySQL提供了如下两种更新统计数据的方式:
开启innodb_stats_auto_recalc。
系统变量innodb_stats_auto_recalc决定着服务器是否自动重新计算统计数据,它的默认值是ON,也就是该功能默认是开启的。每个表都维护了一个变量,该变量记录着对该表进行增删改的记录条数,如果发生变动的记录数量超过了表大小的10%,并且自动重新计算统计数据的功能是打开的,那么服务器会重新进行一次统计数据的计算,并且更新innodb_table_stats和innodb_index_stats表。不过自动重新计算统计数据的过程是异步发生的,也就是即使表中变动的记录数超过了10%,自动重新计算统计数据也不会立即发生,可能会延迟几秒才会进行计算。
再一次强调,InnoDB默认是以表为单位来收集和存储统计数据的,也可以单独为某个表设置是否自动重新计算统计数的属性,设置方式就是在创建或修改表的时候通过指定STATS_AUTO_RECALC属性来指明该表的统计数据存储方式:
CREATE TABLE 表名 (…)
Engine=InnoDB, STATS_AUTO_RECALC = (1|0);
ALTER TABLE 表名
Engine=InnoDB, STATS_AUTO_RECALC = (1|0);
当STATS_AUTO_RECALC=1时,表明想让该表自动重新计算统计数据,当STATS_AUTO_RECALC=0时,表明不想让该表自动重新计算统计数据。如果在创建表时未指定STATS_AUTO_RECALC属性,那默认采用系统变量innodb_stats_auto_recalc的值作为该属性的值。
手动调用ANALYZE TABLE语句来更新统计信息
如果innodb_stats_auto_recalc系统变量的值为OFF的话,也可以手动调用ANALYZE
TABLE语句来重新计算统计数据,比如可以这样更新关于order_exp表的统计数据:
ANALYZE TABLE order_exp;
ANALYZE TABLE语句会立即重新计算统计数据,也就是这个过程是同步的,在表中索引多或者采样页面特别多时这个过程可能会特别慢最好在业务不是很繁忙的时候再运行。
手动更新innodb_table_stats和innodb_index_stats表
其实innodb_table_stats和innodb_index_stats表就相当于一个普通的表一样,能对它们做增删改查操作。这也就意味着可以手动更新某个表或者索引的统计数据。比如说想把order_exp表关于行数的统计数据更改一下可以这么做:
步骤一:更新innodb_table_stats表。
步骤二:让MySQL查询优化器重新加载更改过的数据。
更新完innodb_table_stats只是单纯的修改了一个表的数据,需要让MySQL查询优化器重新加载更改过的数据,运行下边的命令就可以了:
FLUSH TABLE order_exp;
MySQL的执行原理
单表访问之索引合并
MySQL在一般情况下执行一个查询时最多只会用到单个二级索引,但存在有特殊情况,在这些特殊情况下也可能在一个查询中使用到多个二级索引,MySQL中这种使用到多个索引来完成一次查询的执行方法称之为:索引合并/index merge,具体的索引合并算法有下边三种。
Intersection合并
Intersection翻译过来的意思是交集。这里是说某个查询可以使用多个二级索引,将从多个二级索引中查询到的结果取交集,比方说下边这个查询:
SELECT * FROM order_exp WHERE order_no = 'a' AND expire_time = 'b';
假设这个查询使用Intersection合并的方式执行的话,那这个过程就是这样的:
- 从idx_order_no二级索引对应的B+树中取出order_no='a’的相关记录。
- 从idx_expire_time二级索引对应的B+树中取出expire_time='b’的相关记录。
二级索引的记录都是由索引列 + 主键构成的,所以可以计算出这两个结果集中id值的交集。
按照上一步生成的id值列表进行回表操作,也就是从聚簇索引中把指定id值的完整用户记录取出来,返回给用户。
为啥不直接使用idx_order_no或者idx_expire_time只根据某个搜索条件去读取一个二级索引,然后回表后再过滤另外一个搜索条件呢?这里要分析一下两种查询执行方式之间需要的成本代价。
只读取一个二级索引的成本:
1.按照某个搜索条件读取一个二级索引
2.根据从该二级索引得到的主键值进行回表操作
3.然后再过滤其他的搜索条件
读取多个二级索引之后取交集成本:
1.按照不同的搜索条件分别读取不同的二级索引
2.将从多个二级索引得到的主键值取交集
3.最后根据主键值进行回表操作。
虽然读取多个二级索引比读取一个二级索引消耗性能,但是大部分情况下读取二级索引的操作是顺序I/O,而回表操作是随机I/O,所以如果只读取一个二级索引时需要回表的记录数特别多,而读取多个二级索引之后取交集的记录数非常少,当节省的因为回表而造成的性能损耗比访问多个二级索引带来的性能损耗更高时,读取多个二级索引后取交集比只读取一个二级索引的成本更低。
所以MySQL在某些特定的情况下才可能会使用到Intersection索引合并,哪些情况呢?
等值匹配
二级索引列必须是等值匹配的情况
对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只匹配部分列的情况。
而下边这两个查询就不能进行Intersection索引合并:
SELECT * FROM order_exp WHERE order_no> 'a' AND expire_time = 'a'
SELECT * FROM order_exp WHERE order_no = 'a' AND insert_time = 'a';
第一个查询是因为对order_no进行了范围匹配
第二个查询是因为insert_time使用到的联合索引u_idx_day_status中的order_status和expire_time列并没有出现在搜索条件中,所以这两个查询不能进行Intersection索引合并。
主键列可以是范围匹配
比方说下边这个查询可能用到主键和u_idx_day_status进行Intersection索引合并的操作:
SELECT * FROM order_exp WHERE id > 100 AND expire_time = 'a';
因为主键的索引是有序的,按照有序的主键值去回表取记录有个专有名词,叫:Rowid Ordered Retrieval,简称 ROR 。
而二级索引的用户记录是由索引列 + 主键构成的,所以根据范围匹配出来的主键就是乱序的,导致回表开销很大。
那为什么在二级索引列都是等值匹配的情况下也可能使用Intersection索引合并,是因为只有在这种情况下根据二级索引查询出的结果集是按照主键值排序的。
Intersection索引合并会把从多个二级索引中查询出的主键值求交集,如果从各个二级索引中查询的到的结果集本身就是已经按照主键排好序的,那么求交集的过程就很容易。
当然,上边说的两种情况只是发生Intersection索引合并的必要条件,不是充分条件。也就是说即使符合Intersection的条件,也不一定发生Intersection索引合并,这得看优化器的心情(判断)。
优化器只有在单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,而通过Intersection索引合并后需要回表的记录数大大减少时才会使用Intersection索引合并。
Union合并
在写查询语句时经常想把既符合某个搜索条件的记录取出来,也把符合另外的某个搜索条件的记录取出来,这些不同的搜索条件之间是OR关系。有时候OR关系的不同搜索条件会使用到不同的索引,比方说这样:
SELECT * FROM order_exp WHERE order_no = 'a' OR expire_time = 'b'
Intersection是交集的意思,这适用于使用不同索引的搜索条件之间使用AND连接起来的情况;Union是并集的意思,适用于使用不同索引的搜索条件之间使用OR连接起来的情况。与Intersection索引合并类似,MySQL在某些特定的情况下才可能会使用到Union索引合并:
等值匹配
分析同Intersection合并
主键列可以是范围匹配
分析同Intersection合并
使用Intersection索引合并的搜索条件
就是搜索条件的某些部分使用Intersection索引合并的方式得到的主键集合和其他方式得到的主键集合取交集,比方说这个查询:
SELECT * FROM order_exp WHERE insert_time = 'a' AND order_status = 'b' AND expire_time = 'c'
OR (order_no = 'a' AND expire_time = 'b');
优化器可能采用这样的方式来执行这个查询:
1、先按照搜索条件order_no = ‘a’ AND expire_time = 'b’从索引idx_order_no和idx_expire_time中使用Intersection索引合并的方式得到一个主键集合。
2、再按照搜索条件 insert_time =‘a’ AND order_status = ‘b’ AND expire_time = 'c’从联合索引u_idx_day_status中得到另一个主键集合。
3、采用Union索引合并的方式把上述两个主键集合取并集,然后进行回表操作,将结果返回给用户。
当然,查询条件符合了这些情况也不一定就会采用Union索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过Union索引合并后进行访问的代价比全表扫描更小时才会使用Union索引合并。
Sort-Union合并
Union索引合并的使用条件太苛刻,必须保证各个二级索引列在进行等值匹配的条件下才可能被用到,比方说下边这个查询就无法使用到Union索引合并:
SELECT * FROM order_exp WHERE order_no< 'a' OR expire_time> 'z'
这是因为根据order_no<'a’从idx_order_no索引中获取的二级索引记录的主键值不是排好序的,
同时根据expire_time> 'z’从idx_expire_time索引中获取的二级索引记录的主键值也不是排好序的,但是order_no< 'a’和expire_time> ‘z’'这两个条件又特别让动心,所以可以这样:
1、先根据order_no< 'a’条件从idx_order_no二级索引中获取记录,并按照记录的主键值进行排序
2、再根据expire_time>'z’条件从idx_expire_time二级索引中获取记录,并按照记录的主键值进行排序
3、因为上述的两个二级索引主键值都是排好序的,剩下的操作和Union索引合并方式就一样了。
上述这种先按照二级索引记录的主键值进行排序,之后按照Union索引合并方式执行的方式称之为Sort-Union索引合并,很显然,这种Sort-Union索引合并比单纯的Union索引合并多了一步对二级索引记录的主键值排序的过程。
当然,查询条件符合了这些情况也不一定就会采用Sort-Union索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过Sort-Union索引合并后进行访问的代价比全表扫描更小时才会使用Sort-Union索引合并。
联合索引替代Intersection索引合并
SELECT * FROM order_exp WHERE order_no= 'a' And expire_time= 'z';
这个查询之所以可能使用Intersection索引合并的方式执行,还不是因为idx_order_no和idx_expire_time是两个单独的B+树索引,要是把这两个列搞一个联合索引,那直接使用这个联合索引就把事情搞定了,何必用啥索引合并呢,就像这样:
ALTER TABLE order_exp drop index idx_order_no;
ALTER TABLE order_exp drop idx_expire_time;
ALTER TABLE add index idx_order_no_expire_time(order_no,expire_time);
把idx_order_no, idx_expire_time都干掉,再添加一个联合索引idx_order_no_expire_time,使用这个联合索引进行查询简直是又快又好,既不用多读一棵B+树,也不用合并结果。
连接查询
搞数据库一个避不开的概念就是Join,翻译成中文就是连接。使用的时候常常陷入下边两种误区:
**误区一:**业务至上,管他三七二十一,再复杂的查询也用在一个连接语句中搞定。
**误区二:**敬而远之,上次慢查询就是因为使用了连接导致的,以后再也不敢用了。
连接简介
连接的本质
为了方便讲述,建立两个简单的演示表并给它们写入数据:
CREATE TABLE e1 (m1 int, n1 char(1));
CREATE TABLE e2 (m2 int, n2 char(1));
INSERT INTO e1 VALUES(1, 'a'), (2, 'b'), (3, 'c');
INSERT INTO e2 VALUES(2, 'b'), (3, 'c'), (4, 'd');
连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。
所以把e1和e2两个表连接起来的过程如下图所示:
这个过程看起来就是把e1表的记录和e2的记录连起来组成新的更大的记录,所以这个查询过程称之为连接查询。连接查询的结果集中包含一个表中的每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果集就可以称之为 笛卡尔积 。
因为表e1中有3条记录,表e2中也有3条记录,所以这两个表连接之后的笛卡尔积就有3×3=9行记录。
在MySQL中,连接查询的语法很随意,只要在FROM语句后边跟多个表名就好了,比如把e1表和e2表连接起来的查询语句可以写成这样:
SELECT * FROM e1, e2;
连接过程简介
可以连接任意数量张表,但是如果没有任何限制条件的话,这些表连接起来产生的笛卡尔积可能是非常巨大的。比方说3个100行记录的表连接起来产生的笛卡尔积就有100×100×100=1000000行数据!所以在连接的时候过滤掉特定记录组合是有必要的,在连接查询中的过滤条件可以分成两种,比方说下边这个查询语句:
SELECT * FROM e1, e2 WHERE e1.m1 > 1 AND e1.m1 = e2.m2 AND e2.n2 < 'd';
涉及单表的条件
e1.m1 > 1是只针对e1表的过滤条件
e2.n2< 'd’是只针对e2表的过滤条件。
涉及两表的条件
比如类似e1.m1 = e2.m2,这些条件中涉及到了两个表。
看一下携带过滤条件的连接查询的大致执行过程在这个查询中指明了这三个过滤条件:
- e1.m1 > 1
- e1.m1 = e2.m2
- e2.n2 < ‘d’
那么这个连接查询的大致执行过程如下:
确定驱动表(t1)
首先确定第一个需要查询的表,这个表称之为 驱动表 。单表中执行查询语句只需要选取代价最小的那种访问方法去执行单表查询语句就好了(就是说之前从执行计划中找const、ref、ref_or_null、range、index、all等等这些执行方法中选取代价最小的去执行查询)。
此处假设使用e1作为驱动表,那么就需要到e1表中找满足e1.m1 > 1的记录,因为表中的数据太少,也没在表上建立二级索引,所以此处查询e1表的访问方法就设定为all,也就是采用全表扫描的方式执行单表查询。
遍历驱动表结果,到被驱动表(t2)中查找匹配记录
针对上一步骤中从驱动表产生的结果集中的每一条记录,分别需要到e2表中查找匹配的记录,所谓匹配的记录,指的是符合过滤条件的记录。
因为是根据e1表中的记录去找e2表中的记录,所以e2表也可以被称之为 被驱动表 。上一步骤从驱动表中得到了2条记录,所以需要查询2次e2表。
此时涉及两个表的列的过滤条件e1.m1 = e2.m2就派上用场了
当e1.m1 = 2时,过滤条件e1.m1 =e2.m2就相当于e2.m2 = 2,所以此时e2表相当于有了e2.m2 = 2、e2.n2 < 'd’这两个过滤条件,然后到e2表中执行单表查询。
当e1.m1 = 3时,过滤条件e1.m1 =e2.m2就相当于e2.m2 = 3,所以此时e2表相当于有了e2.m2 = 3、e2.n2 < 'd’这两个过滤条件,然后到e2表中执行单表查询。
所以整个连接查询的执行过程就如下图所示:
也就是说整个连接查询最后的结果只有两条符合过滤条件的记录:
从上边两个步骤可以看出来,这个两表连接查询共需要查询1次e1表,2次e2表。
当然这是在特定的过滤条件下的结果,如果把e1.m1 > 1这个条件去掉,那么从e1表中查出的记录就有3条,就需要查询3次e2表了。也就是说在两表连接查询中, 驱动表只需要访问一次,被驱动表可能被访问多次 。
内连接和外连接
为了大家更好理解后边内容,创建两个有现实意义的表,并插入一些数据:
CREATE TABLE student (
number INT NOT NULL AUTO_INCREMENT COMMENT '学号',
name VARCHAR(5) COMMENT '姓名',
major VARCHAR(30) COMMENT '专业',
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8 COMMENT '客户信息表';
CREATE TABLE score (
number INT COMMENT '学号',
subject VARCHAR(30) COMMENT '科目',
score TINYINT COMMENT '成绩',
PRIMARY KEY (number, subject)
) Engine=InnoDB CHARSET=utf8 COMMENT '客户成绩表';
两张表插入以下数据
现在把每个学生的考试成绩都查询出来就需要进行两表连接了(因为score中没有姓名信息,所以不能单纯只查询score表)。连接过程就是从student表中取出记录,在score表中查找number相同的成绩记录,所以过滤条件就是student.number = socre.number,整个查询语句就是这样:
SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1,score AS s2 WHERE s1.number = s2.number;
从上述查询结果中可以看到,各个同学对应的各科成绩就都被查出来了,可是有个问题,yan同学,也就是学号为20200904的同学因为某些原因没有参加考试,所以在score表中没有对应的成绩记录。
如果老师想查看所有同学的考试成绩,即使是缺考的同学也应该展示出来,但是到目前为止介绍的连接查询是无法完成这样的需求的。稍微思考一下这个需求,其本质是想: 驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集 。为了解决这个问题,就有了内连接和外连接的概念:
对于内连接的两个表,驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集,上边提到的连接都是所谓的内连接。
对于外连接的两个表,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。
在MySQL中,根据选取驱动表的不同,外连接仍然可以细分为2种:
左外连接 ,选取左侧的表为驱动表。
右外连接 ,选取右侧的表为驱动表。
可是这样仍然存在问题,即使对于外连接来说,有时候也并不想把驱动表的全部记录都加入到最后的结果集。
这就犯难了,怎么办?把过滤条件分为两种就可以就解决这个问题了,所以放在不同地方的过滤条件是有不同语义的:
WHERE子句中的过滤条件
WHERE子句中的过滤条件就是平时见的那种,不论是内连接还是外连接,凡是不符合WHERE子句中的过滤条件的记录都不会被加入最后的结果集。
ON子句中的过滤条件
对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。
需要注意的是,这个ON子句是专门为外连接驱动表中的记录在被驱动表找不到匹配记录时应不应该把该记录加入结果集这个场景下提出的,所以如果把ON子句放到内连接中,MySQL会把它和WHERE子句一样对待,也就是说:内连接中的WHERE子句和ON子句是等价的。
一般情况下,都把只涉及单表的过滤条件放到WHERE子句中,把涉及两表的过滤条件都放到ON子句中。一般把放到ON子句中的过滤条件也称之为连接条件。
左(外)连接的语法
左(外)连接的语法还是挺简单的,比如要把e1表和e2表进行左外连接查询可以这么写:
SELECT * FROM e1 LEFT [OUTER] JOIN e2 ON 连接条件 [WHERE 普通过滤条件];
其中中括号里的OUTER单词是可以省略的。
对于LEFTJOIN类型的连接来说:
把放在左边的表称之为外表或者驱动表
右边的表称之为内表或者被驱动表。
所以上述例子中e1就是外表或者驱动表,e2就是内表或者被驱动表。需要注意的是,对于左(外)连接和右(外)连接来说,必须使用ON子句来指出连接条件。了解了左(外)连接的基本语法之后,再次回到上边那个现实问题中来,看看怎样写查询语句才能把所有的客户的成绩信息都查询出来,即使是缺考的考生也应该被放到结果集中:
SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1 LEFT JOIN score AS s2 ON s1.number = s2.number;
从结果集中可以看出来,虽然yan并没有对应的成绩记录,但是由于采用的是连接类型为左(外)连接,所以仍然把她放到了结果集中,只不过在对应的成绩记录的各列使用NULL值填充而已。
右(外)连接的语法
右(外)连接和左(外)连接的原理是一样的,语法也只是把LEFT换成RIGHT而已:
SELECT * FROM e1
RIGHT [OUTER] JOIN e2 ON 连接条件 [WHERE 普通过滤条件];
只不过驱动表是右边的表e2,被驱动表是左边的表e1。
内连接的语法
内连接和外连接的根本区别就是在驱动表中的记录不符合ON子句中的连接条件时不会把该记录加入到最后的结果集,一种最简单的内连接语法,就是直接把需要连接的多个表都放到FROM子句后边。其实针对内连接,MySQL提供了好多不同的语法:
SELECT * FROM e1 [INNER | CROSS] JOIN e2 [ON 连接条件] [WHERE 普通过滤条件];
也就是说在MySQL中,下边这几种内连接的写法都是等价的:
SELECT * FROM e1 JOIN e2;
SELECT * FROM e1 INNER JOIN e2;
SELECT * FROM e1 CROSS JOIN e2;
上边的这些写法和直接把需要连接的表名放到FROM语句之后,用逗号,分隔开的写法是等价的:
SELECT * FROM e1, e2;
再说一次,由于在内连接中ON子句和WHERE子句是等价的,所以内连接中不要求强制写明ON子句。
前边说过,连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。不论哪个表作为驱动表,两表连接产生的笛卡尔积肯定是一样的。而对于内连接来说,由于凡是不符合ON子句或WHERE子句中的条件的记录都会被过滤掉,其实也就相当于从两表连接的笛卡尔积中把不符合过滤条件的记录给踢出去,所以对于内连接来说,驱动表和被驱动表是可以互换的,并不会影响最后的查询结果。
但是对于外连接来说,由于驱动表中的记录即使在被驱动表中找不到符合ON子句条件的记录时也要将其加入到结果集,所以此时驱动表和被驱动表的关系就很重要了,也就是说左外连接和右外连接的驱动表和被驱动表不能轻易互换。
MySQL对连接的执行
复习了连接、内连接、外连接这些基本概念后,需要理解MySQL怎么样来进行表与表之间的连接,才能明白有的连接查询运行的快,有的却慢。
嵌套循环连接(Nested-LoopJoin)
前边说过,对于两表连接来说,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍,具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。
对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左(外)连接的驱动表就是左边的那个表,右(外)连接的驱动表就是右边的那个表。
如果有3个表进行连接的话,那么首先两表连接得到的结果集就像是新的驱动表,然后第三个表就成为了被驱动表,可以用伪代码表示一下这个过程就是这样:
for each row in e1 { #此处表示遍历满足对e1单表查询结果集中的每一条记录,N条
for each row in e2 { #此处表示对于某条e1表的记录来说,遍历满足对e2单表查询结果集中的每一条记录,M条
for each row in t3 { #此处表示对于某条e1和e2表的记录组合来说,对t3表进行单表查询,L条
if row satisfies join conditions, send to client
}
}
}
这个过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接( Nested-Loop Join ) ,这是最简单,也是最笨拙的一种连接查询算法,时间复杂度是O(N * M * L)。
使用索引加快连接速度
知道在嵌套循环连接的步骤2中可能需要访问多次被驱动表,如果访问被驱动表的方式都是全表扫描的话,那速度肯定会很慢很慢。
但是查询e2表其实就相当于一次单表查询,可以利用索引来加快查询速度。回顾一下最开始介绍的e1表和e2表进行内连接的例子:
SELECT * FROM e1, e2 WHERE e1.m1 > 1 AND e1.m1 = e2.m2 AND e2.n2 < 'd';
使用的其实是嵌套循环连接算法执行的连接查询,再把上边那个查询执行过程表回顾一下:
查询驱动表e1后的结果集中有两条记录,嵌套循环连接算法需要对被驱动表查询2次:
当e1.m1 = 2时,去查询一遍e2表,对e2表的查询语句相当于:
SELECT * FROM e2 WHERE e2.m2 = 2 AND e2.n2 < 'd';
当e1.m1 = 3时,再去查询一遍e2表,此时对e2表的查询语句相当于:
SELECT * FROM e2 WHERE e2.m2 = 3 AND e2.n2 < 'd';
可以看到,原来的e1.m1 = e2.m2这个涉及两个表的过滤条件在针对e2表做查询时关于e1表的条件就已经确定了,所以只需要单单优化对e2表的查询了,上述两个对e2表的查询语句中利用到的列是m2和n2列,可以在e2表的m2列上建立索引。
因为对m2列的条件是等值查找,比如e2.m2= 2、e2.m2 = 3等,所以可能使用到ref的访问方法,假设使用ref的访问方法去执行对e2表的查询的话,需要回表之后再判断e2.n2 < d这个条件是否成立。
在n2列上建立索引,涉及到的条件是e2.n2 < ‘d’,可能用到range的访问方法,假设使用range的访问方法对e2表的查询的话,需要回表之后再判断在m2列上的条件是否成立。
假设m2和n2列上都存在索引的话,那么就需要从这两个里边儿挑一个代价更低的去执行对e2表的查询。当然,建立了索引不一定使用索引,只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引。
另外,有时候连接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种情况下即使不能使用eq_ref、ref、ref_or_null或者range这些访问方法执行对被驱动表的查询的话,也可以使用索引扫描,也就是index(索引覆盖)的访问方法来查询被驱动表。
基于块的嵌套循环连接(Block Nested-Loop Join)
扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。
现实生活中的表成千上万条记录都是少的,几百万、几千万甚至几亿条记录的表到处都是。内存里可能并不能完全存放的下表中所有的记录,所以在扫描表前边记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前边的记录从内存中释放掉。
而采用嵌套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以得想办法:尽量减少访问被驱动表的次数。
当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。
所以可不可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。
所以MySQL提出了一个join buffer的概念,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。使用join buffer的过程如下图所示:
最最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录。
这种加入了join buffer的嵌套循环连接算法称之为基于块的嵌套连接( Block Nested-Loop Join ) 算法。
这个join buffer的大小是可以通过启动参数或者系统变量join_buffer_size进行配置,默认大小为262144字节(也就是256KB),最小可以设置为128字节。
show variables like 'join_buffer_size' ;
当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size的值来对连接查询进行优化。
另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer中,只有查询列表中的列和过滤条件中的列才会被放到join buffer中,所以再次提醒,最好不要把*作为查询列表,只需要把关心的列放到查询列表就好了,这样还可以在join buffer中放置更多的记录。
MySQL的查询成本
什么是成本
MySQL执行一个查询可以有不同的执行方案,它会选择其中成本最低,或者说代价最低的那种方案去真正的执行查询。之前对成本的描述是非常模糊的,其实在MySQL中一条查询语句的执行成本是由下边这两个方面组成的:
I/O成本
表经常使用的MyISAM、InnoDB存储引擎都是将数据和索引都存储到磁盘上的,当想查询表中的记录时,需要先把数据或者索引加载到内存中然后再操作。这个从磁盘到内存这个加载的过程损耗的时间称之为I/O成本。
CPU成本
读取以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为CPU成本。
对于InnoDB存储引擎来说,页是磁盘和内存之间交互的基本单位。
MySQL规定读取一个页面花费的成本默认是1.0(I/O成本)
读取以及检测一条记录是否符合搜索条件的成本默认是0.2(CPU成本)
1.0、0.2这些数字称之为成本常数,这两个成本常数是最常用到的,当然还有其他的成本常数。
注意,不管读取记录时需不需要检测是否满足搜索条件,哪怕是空数据,其成本都算是0.2。
单表查询的成本
基于成本的优化步骤实战
在一条单表查询语句真正执行之前,MySQL的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案,这个成本最低的方案就是所谓的执行计划,之后才会调用存储引擎提供的接口真正的执行查询,这个过程总结一下就是这样:
1、根据搜索条件,找出所有可能使用的索引
2、计算全表扫描的代价
3、计算使用不同索引执行查询的代价
4、对比各种执行方案的代价,找出成本最低的那一个
下边就以一个实例来分析一下这些步骤,单表查询语句如下:
SELECT * FROM order_exp WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
AND expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
AND insert_time> expire_time AND order_note LIKE '%7****排1%' AND order_status = 0;
看上去有点儿复杂,一步一步分析一下。
1. 根据搜索条件,找出所有可能使用的索引
前边说过,对于B+树索引来说,只要索引列和常数使用=、<=>、IN、NOT IN、IS NULL、IS NOT NULL、>、<、>=、<=、BETWEEN、!=(不等于也可以写成<>)或者LIKE操作符连接起来,就可以产生一个所谓的范围区间(LIKE匹配字符串前缀也行),MySQL把一个查询中可能使用到的索引称之为possible keys。
分析一下上边查询中涉及到的几个搜索条件:
order_no IN (‘DD00_6S’, ‘DD00_9S’, ‘DD00_10S’) ,这个搜索条件可以使用二级索引idx_order_no。
expire_time> ‘2021-03-22 18:28:28’ AND expire_time<= ‘2021-03-22 18:35:09’,这个搜索条件可以使用二级索引idx_expire_time。
insert_time> expire_time,这个搜索条件的索引列由于没有和常数比较,所以并不能使用到索引。
order_note LIKE ‘%hello%’,order_note即使有索引,但是通过LIKE操作符和以通配符开头的字符串做比较,不可以适用索引。
order_status = 0,由于该列上只有联合索引,而且不符合最左前缀原则,所以不会用到索引。
综上所述,上边的查询语句可能用到的索引,也就是possible keys只有idx_order_no,idx_expire_time。
EXPLAIN SELECT * FROM order_exp WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
AND expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
AND insert_time> expire_time AND order_note LIKE '%7****排1%' AND order_status = 0;
2. 计算全表扫描的代价
对于InnoDB存储引擎来说,全表扫描的意思就是把聚簇索引(主键索引)中的记录都依次和给定的搜索条件做一下比较,把符合搜索条件的记录加入到结果集,所以需要将聚簇索引对应的页面加载到内存中,然后再检测记录是否符合搜索条件。由于查询成本=I/O成本+CPU成本,所以计算全表扫描的代价需要两个信息:
1、聚簇索引占用的页面数
2、该表中的记录数
这两个信息从哪来呢?MySQL为每个表维护了一系列的统计信息,关于这些统计信息是如何收集起来,放在后边再说,现在看看怎么查看这些统计信息。
MySQL提供了SHOW TABLE STATUS语句来查看表的统计信息,如果要看指定的某个表的统计信息,在该语句后加对应的LIKE语句就好了,比方说要查看order_exp这个表的统计信息可以这么写:
SHOW TABLE STATUS LIKE 'order_exp'\G
出现了很多统计选项,但目前只需要两个:
Rows
本选项表示表中的记录条数。对于使用MyISAM存储引擎的表来说,该值是准确的,对于使用InnoDB存储引擎的表来说,该值是一个估计值。从查询结果也可以看出来,由于order_exp表是使用InnoDB存储引擎的,所以虽然实际上表中有10567条记录,但是SHOW TABLE STATUS显示的Rows值只有10350条记录。但成本计算按照SHOW TABLE STATUS来计算。
Data_length
本选项表示表占用的存储空间字节数。使用MyISAM存储引擎的表来说,该值就是数据文件的大小,对于使用InnoDB存储引擎的表来说,该值就相当于聚簇索引占用的存储空间大小,也就是说可以这样计算该值的大小:
Data_length = 聚簇索引的页面数量 x 每个页面的大小
order_exp使用默认16KB的页面大小,而上边查询结果显示Data_length的值是1589248,所以可以反向来推导出聚簇索引的页面数量:
聚簇索引的页面数量 = 1589248 ÷ 16 ÷ 1024 = 97
现在已经得到了聚簇索引占用的页面数量以及该表记录数的估计值,所以就可以计算全表扫描成本了。
现在可以看一下全表扫描成本的计算过程:
I/O成本
读取一个页面花费的成本默认是1.0(I/O成本)
97 x 1.0 + 1.1 = 98.1
97指的是聚簇索引占用的页面数,1.0指的是加载一个页面的成本常数,后边的1.1是一个微调值。
关于这个微调值解释如下:
MySQL在真实计算成本时会进行一些微调,这些微调的值是直接硬编码到代码里的,没有注释而且这些微调的值十分的小,并不影响分析。
CPU成本
读取以及检测一条记录是否符合搜索条件的成本默认是0.2(CPU成本)
10350x 0.2 + 1.0 = 2071
10350指的是统计数据中表的记录数,对于InnoDB存储引擎来说是一个估计值,0.2指的是访问一条记录所需的成本常数,后边的1.0是一个微调值。
总成本:
98.1 + 2071 = 2169.1
综上所述,对于order_exp的全表扫描所需的总成本就是2169.1。
3. 计算使用不同索引执行查询的代价
从第1步分析得到,上述查询可能使用到idx_order_no,idx_expire_time这两个索引,需要分别分析单独使用这些索引执行查询的成本,最后还要分析是否可能使用到索引合并。
这里需要提一点的是,MySQL查询优化器先分析使用唯一二级索引的成本,再分析使用普通索引的成本,这里两个索引都是普通索引,先算哪个都可以。先分析idx_expire_time的成本,然后再看使用idx_order_no的成本。
3.1使用idx_expire_time执行查询的成本分析
idx_expire_time对应的搜索条件是:
expire_time>'2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
也就是说对应的范围区间就是:(‘2021-03-22 18:28:28’ , ‘2021-03-22 18:35:09’ )。
使用idx_expire_time搜索会使用用二级索引 + 回表方式的查询,MySQL计算这种查询的成本依赖两个方面的数据:
1 、范围区间数量
不论某个范围区间的二级索引到底占用了多少页面,查询优化器认为读取索引的一个范围区间的I/O成本和读取一个页面是相同的。本例中使用idx_expire_time的范围区间只有一个,所以相当于访问这个范围区间的二级索引付出的I/O成本就是:1 x 1.0 = 1.0
2 、需要回表的记录数
优化器需要计算二级索引的某个范围区间到底包含多少条记录,对于本例来说就是要计算idx_expire_time在(‘2021-03-22 18:28:28’ ,‘2021-03-22 18:35:09’)这个范围区间中包含多少二级索引记录,计算过程是这样的:
**步骤1:**先根据expire_time>‘2021-03-22 18:28:28’这个条件访问一下idx_expire_time对应的B+树索引,找到满足expire_time> ‘2021-03-22 18:28:28’这个条件的第一条记录,把这条记录称之为区间最左记录。前头说过在B+数树中定位一条记录的过程是很快的,是常数级别的,所以这个过程的性能消耗是可以忽略不计的。
**步骤2:**然后再根据expire_time<=‘2021-03-22 18:35:09’这个条件继续从idx_expire_time对应的B+树索引中找出最后一条满足这个条件的记录,把这条记录称之为区间最右记录,这个过程的性能消耗也可以忽略不计的。
**步骤3:**如果区间最左记录和区间最右记录相隔不太远(在MySQL 5.7这个版本里,只要相隔不大于10个页面即可),那就可以精确统计出满足expire_time> ‘2021-03-22 18:28:28’ AND expire_time<= ‘2021-03-22 18:35:09’条件的二级索引记录条数。否则只沿着区间最左记录向右读10个页面,计算平均每个页面中包含多少记录,然后用这个平均值乘以区间最左记录和区间最右记录之间的页面数量就可以了。
那么问题又来了,怎么估计区间最左记录和区间最右记录之间有多少个页面呢?解决这个问题还得回到B+树索引的结构中来。
假设区间最左记录在页b中,区间最右记录在页c中,那么计算区间最左记录和区间最右记录之间的页面数量就相当于计算页b和页c之间有多少页面,而它们父节点中记录的每一条目录项记录都对应一个数据页,所以计算页b和页c之间有多少页面就相当于计算它们父节点(也就是页a)中对应的目录项记录之间隔着几条记录。在一个页面中统计两条记录之间有几条记录的成本就很小了。
不过还有问题,如果页b和页c之间的页面实在太多,以至于页b和页c对应的目录项记录都不在一个父页面中怎么办?既然是树,那就继续递归,之前说过一个B+树有4层高已经很了不得了,所以这个统计过程也不是很耗费性能。
知道了如何统计二级索引某个范围区间的记录数之后,就需要回到现实问题中来,MySQL根据上述算法测得idx_expire_time在区间(‘2021-03-22 18:28:28’ ,‘2021-03-22 18:35:09’)之间大约有39条记录。
explain SELECT * FROM order_exp WHERE expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09';
读取这39条二级索引记录需要付出的CPU成本就是:
39 x 0.2 + 0.01 = 7.81
其中39是需要读取的二级索引记录条数,0.2是读取一条记录成本常数,0.01是微调。
在通过二级索引获取到记录之后,还需要干两件事儿:
1 、根据这些记录里的主键值到聚簇索引中做回表操作
MySQL评估回表操作的I/O成本依旧很简单粗暴,他们认为每次回表操作都相当于访问一个页面,也就是说二级索引范围区间有多少记录,就需要进行多少次回表操作,也就是需要进行多少次页面I/O。上边统计了使用idx_expire_time二级索引执行查询时,预计有39 条二级索引记录需要进行回表操作,所以回表操作带来的I/O成本就是:
39 x 1.0 = 39
其中39 是预计的二级索引记录数,1.0是一个页面的I/O成本常数。
2 、回表操作后得到的完整用户记录,然后再检测其他搜索条件是否成立
回表操作的本质就是通过二级索引记录的主键值到聚簇索引中找到完整的用户记录,然后再检测除expire_time> ‘2021-03-22 18:28:28’ AND expire_time<'2021-03-22 18:35:09’这个搜索条件以外的搜索条件是否成立。
因为通过范围区间获取到二级索引记录共39条,也就对应着聚簇索引中39 条完整的用户记录,读取并检测这些完整的用户记录是否符合其余的搜索条件的CPU成本如下:
39 x 0.2 =7.8
其中39 是待检测记录的条数,0.2是检测一条记录是否符合给定的搜索条件的成本常数。
所以本例中使用idx_expire_time执行查询的成本就如下所示:
I/O成本:
1.0 + 39 x 1.0 = 40 .0 (范围区间的数量 + 预估的二级索引记录条数)
CPU成本:
39 x 0.2 + 0.01+39 x 0.2 = 15.61 (读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)
综上所述,使用idx_expire_time执行查询的总成本就是:
40 .0 + 15.61 = 55.61
3.2使用idx_order_no执行查询的成本分析
idx_order_no对应的搜索条件是:
order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
也就是说相当于3个单点区间。与使用idx_expire_time的情况类似,也需要计算使用idx_order_no时需要访问的范围区间数量以及需要回表的记录数,计算过程与上面类似,不详列所有计算步骤和说明了。
范围区间数量
使用idx_order_no执行查询时很显然有3个单点区间,所以访问这3个范围区间的二级索引付出的I/O成本就是:
3 x 1.0 = 3.0
需要回表的记录数
由于使用idx_expire_time时有3个单点区间,所以每个单点区间都需要查找一遍对应的二级索引记录数,三个单点区间总共需要回表的记录数是58。
explain SELECT * FROM order_exp WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S');
读取这些二级索引记录的CPU成本就是:58 x 0.2+0.01 = 11.61
InnoDB引擎底层解析
InnoDB的三大特性:
- 双写机制
- Buffer Pool
- 自适应Hash索引
自适应Hash索引在之前的索引部分。
InnoDB的内存结构和磁盘存储结构图总结如下:
1、InnoDB还是一个黑盒,只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?
2、表中的数据以什么格式存放的?
3、InnoDB是以什么方式来访问的这些数据?
4、InnoDB中的事务、锁等的原理是怎样?
InnoDB记录存储结构和索引页结构
InnoDB是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而读写磁盘的速度非常慢,和内存读写差了几个数量级,所以想从表中获取某些记录时,InnoDB存储引擎需要一条一条的把记录从磁盘上读出来么?
InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式。
行格式
InnoDB存储引擎设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式。可以查看默认值:
show variables like 'innodb_default_row_format'
可以在创建或修改表的语句中指定行格式:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
COMPACT
create table text(c1 VARCHAR(10)) ROW_FORMAT=COMPACT;
COMPACT行格式示意图如下:
变长字段长度列表
MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、各种TEXT类型,各种BLOB类型,也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。如果该可变字段允许存储的最大字节数(M×W)超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节。
NULL值列表
表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表。每个允许存储NULL的列对应一个二进制位,二进制位的值为1时,代表该列的值为NULL。二进制位的值为0时,代表该列的值不为NULL。
记录头信息
它是由固定的5个字节组成。5个字节也就是40个二进制位,不同的位代表不同的意思。
| 二进制位数 | 解释 | |
|---|---|---|
| 预留位1 | 1 | 没有使用 |
| 预留位2 | 1 | 没有使用 |
| delete_mask | 1 | 标记该记录是否被删除 |
| min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
| n_owned | 4 | 表示当前记录拥有的记录数 |
| heap_no | 13 | 表示当前记录在页的位置信息 |
| record_type | 3 | 表示当前记录的类型, 0表示普通记录, 1表示B+树非叶子节点记录, 2表示最小记录, 3表示最大记录 |
| next_record | 16 | 表示下一条记录的相对位置 |
隐藏列信息
MySQL会为每个记录默认的添加一些列(也称为隐藏列),包括:
DB_ROW_ID(row_id):非必须,6字节,表示行ID,唯一标识一条记录
InnoDB表对主键的生成策略是:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。
DB_TRX_ID:必须,6字节,表示事务ID
DB_ROLL_PTR:必须,7字节,表示回滚指
其他的行格式和Compact行格式差别不大。
1.1.1.2.Redundant行格式
Redundant行格式是MySQL5.0之前用的一种行格式,不予深究。
1.1.1.3.Dynamic和Compressed行格式
MySQL5.7的默认行格式就是Dynamic,Dynamic和Compressed行格式和Compact行格式挺像,只不过在处理行溢出数据时有所不同。Compressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。
1.1.1.4. 数据溢出
如果定义一个表,表中只有一个VARCHAR字段,如下:
CREATE TABLE test_varchar( c VARCHAR(60000) )
然后往这个字段插入60000个字符,会发生什么?
前边说过,MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,记录都会被分配到某个页中存储。而一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的情况。
在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的该列的前768个字节的数据,然后把剩余的数据分散存储在几个其他的页中,记录的真实数据处用20个字节存储指向这些页的地址。这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。
Dynamic和Compressed行格式,不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。
1.1.2.索引页格式
前边简单提了一下页的概念,它是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。
InnoDB为了不同的目的而设计了许多种不同类型的页,存放到表中记录的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX)页,不过要理解成数据页也没问题,毕竟存在着聚簇索引这种索引和数据混合的东西。
1.1.2.1.数据页结构
一个InnoDB数据页的存储空间大致被划分成了7个部分:
| name | 名称 | 长度 | 备注 |
|---|---|---|---|
| File Header | 文件头部 | 38字节 | 页的一些通用信息 |
| Page Header | 页面头部 | 56字节 | 数据页专有的一些信息 |
| Infimum + Supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
| User Records | 用户记录 | 大小不确定 | 实际存储的行记录内容 |
| Free Space | 空闲空间 | 大小不确定 | 页中尚未使用的空间 |
| Page Directory | 页面目录 | 大小不确定 | 页中的某些记录的相对位置 |
| File Trailer | 文件尾部 | 8字节 | 校验页是否完整 |
User Records
存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
当前记录被删除时,则会修改记录头信息中的delete_mask为1,也就是说被删除的记录还在页中,还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗。
所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
同时插入的记录在会记录自己在本页中的位置,写入了记录头信息中heap_no部分。heap_no值为0和1的记录是InnoDB自动给每个页增加的两个记录,称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,这两条存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分。
记录头信息中next_record记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)
记录按照主键从小到大的顺序形成了一个单链表,记录被删除,则从这个链表上摘除。
Page Directory
Page Directory主要是解决记录链表的查找问题。如果想根据主键值查找页中的某条记录该咋办?按链表查找的办法:从Infimum记录(最小记录)开始,沿着链表一直往后找,总会找到或者找不到。
InnoDB的改进是,为页中的记录再制作了一个目录,他们的制作过程是这样的:
1、将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
2、每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
3、将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的Page Directory,也就是页目录页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。
4、每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在1到8 条之间,剩下的分组中记录的条数范围只能在是 4到8 条之间,如下图:
这样,一个数据页中查找指定主键值的记录的过程分为两步:
通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
通过记录的next_record属性遍历该槽所在的组中的各个记录。
Page Header
InnoDB为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,它是页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息。
File Header
File Header针对各种类型的页都通用,也就是说不同类型的页都会以File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说页的类型,这个页的编号是多少,它的上一个页、下一个页是谁,页的校验和等等,这个部分占用固定的38个字节。
页的类型,包括Undo日志页、段信息节点、InsertBuffer空闲列表、Insert Buffer位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、索引页,有些页会在后面的课程看到。
同时通过上一个页、下一个页建立一个双向链表把许许多多的页就串联起来,而无需这些页在物理上真正连着。但是并不是所有类型的页都有上一个和下一个页的属性,数据页是有这两个属性的,所以所有的数据页其实是一个双向链表。
File Trailer
InnoDB存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办?
为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),InnoDB每个页的尾部都加了一个File Trailer部分,这个部分由8个字节组成,可以分成2个小部分:
前4个字节代表页的校验和
这个部分是和File Header中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。
后4个字节代表页面被最后修改时对应的日志序列位置(LSN),这个也和校验页的完整性有关。
这个File Trailer与File Header类似,都是所有类型的页通用的。
InnoDB的表空间
表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文件;对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd的实际文件。大家可以把表空间想象成被切分为许许多多个页的池子,当为某个表插入一条记录的时候,就从池子中捞出一个对应的页来把数据写进去。
再回忆一次,InnoDB是以页为单位管理存储空间的,聚簇索引(也就是完整的表数据)和其他的二级索引都是以B+树的形式保存到表空间的,而B+树的节点就是数据页。
任何类型的页都有File Header这个部分,File Header中专门的地方(FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID)保存页属于哪个表空间,同时表空间中的每一个页都对应着一个页号(FIL_PAGE_OFFSET),这个页号由4个字节组成,也就是32个比特位,所以一个表空间最多可以拥有2³²个页,如果按照页的默认大小16KB来算,一个表空间最多支持64TB的数据。
独立表空间结构
区(extent)
表空间中的页可以达到2³²个页,实在是太多了,为了更好的管理这些页面,InnoDB中还有一个区(英文名:extent)的概念。对于16KB的页来说,连续的64个页就是一个区,也就是说一个区默认占用1MB空间大小。
不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每256个区又被划分成一个组。
第一个组最开始的3个页面的类型是固定的:用来登记整个表空间的一些整体属性以及本组所有的区被称为FSP_HDR,也就是extent 0 ~ extent 255这256个区,整个表空间只有一个FSP_HDR。
其余各组最开始的2个页面的类型是固定的,一个XDES类型,用来登记本组256个区的属性,FSP_HDR类型的页面其实和XDES类型的页面的作用类似,只不过FSP_HDR类型的页面还会额外存储一些表空间的属性。
引入区的主要目的是什么?
每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的B+树的节点中插入数据。而B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。
介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。
一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角度看,可以消除很多的随机I/O。
段(segment)
提到的范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。
存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。
段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。
系统表空间
整体结构
系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多出一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。
系统表空间的extent 1和extent 2这两个区,也就是页号从64~191这128个页面被称为Doublewrite buffer,也就是双写缓冲区。
双写缓冲区/双写机制
双写缓冲区/双写机制是InnoDB的三大特性之一,还有两个是Buffer Pool、自适应Hash索引。
它是一种特殊文件flush技术,带给InnoDB存储引擎的是数据页的可靠性。它的作用是,在把页写到数据文件之前,InnoDB先把它们写到一个叫doublewrite buffer(双写缓冲区)的连续区域内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的page副本用于恢复。
doublewrite buffer是InnoDB在系统表空间上的128个页(2个区,extend1和extend2),大小是2MB
所以在正常的情况下, MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中。如果发生了极端情况(断电),InnoDB再次启动后,发现了一个页数据已经损坏,那么此时就可以从doublewrite buffer中进行数据恢复了。
所以,虽然叫双写缓冲区,但是这个缓冲区不仅在内存中有,更多的是属于MySQL的系统表空间,属于磁盘文件的一部分。那为什么要引入一个双写机制呢?
InnoDB的页大小一般是16KB,其数据校验也是针对这16KB来计算的,将数据写入到磁盘是以页为单位进行操作的。而操作系统写文件是以4KB作为单位的,那么每写一个InnoDB的页到磁盘上,操作系统需要写4个块。
而计算机硬件和操作系统,在极端情况下(比如断电)往往并不能保证这一操作的原子性,16K的数据,写入4K时,发生了系统断电或系统崩溃,只有一部分写是成功的,这种情况下会产生partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。在InnoDB存储引擎未使用doublewrite技术前,曾经出现过因为部分写失效而导致数据丢失的情况。
doublewrite是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概5-10%左右。
所以,在一些情况下可以关闭doublewrite以获取更高的性能。比如在slave上可以关闭,因为即使出现了partial
page write问题,数据还是可以从中继日志中恢复。比如某些文件系统ZFS本身有些文件系统本身就提供了部分写失效的防范机制,也可以关闭。
在数据库异常关闭的情况下启动时,都会做数据库恢复(redo)操作,恢复的过程中,数据库都会检查页面是不是合法(校验等等),如果发现一个页面校验结果不一致,则此时会用到双写这个功能。
有经验的同学也许会想到,如果发生写失效,可以通过重做日志(Redo Log)进行恢复啊!但是要注意,重做日志中记录的是对页的物理操作,如偏移量800,写’ aaaa’记录,而不是页面的全量记录,而如果发生partial page write(部分页写入)问题时,出现问题的是未修改过的数据,此时重做日志(Redo Log)无能为力。写doublewrite buffer成功了,这个问题就不用担心了。
InnoDB数据字典(Data Dictionary Header)
平时使用INSERT语句向表中插入的那些记录称之为用户数据,MySQL只是作为一个软件来保管这些数据,提供方便的增删改查接口而已。但是每向一个表中插入一条记录的时候,MySQL先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的B+树中。所以说,MySQL除了保存着插入的用户数据之外,还需要保存许多额外的信息,比方说:
某个表属于哪个表空间,表里边有多少列,表对应的每一个列的类型是什么,该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间的哪个页面,该表有哪些外键,外键对应哪个表的哪些列,某个表空间对应文件系统上文件路径是什么。
上述这些数据并不是使用INSERT语句插入的用户数据,实际上是为了更好的管理这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。InnoDB存储引擎特意定义了一些列的内部系统表(internal system table)来记录这些这些元数据:
| 表名 | 描述 |
|---|---|
| SYS_TABLES | 整个InnoDB存储引擎中所有的表的信息 |
| SYS_COLUMNS | 整个InnoDB存储引擎中所有的列的信息 |
| SYS_INDEXES | 整个InnoDB存储引擎中所有的索引的信息 |
| SYS_FIELDS | 整个InnoDB存储引擎中所有的索引对应的列的信息 |
| SYS_FOREIGN | 整个InnoDB存储引擎中所有的外键的信息 |
| SYS_FOREIGN_COLS | 整个InnoDB存储引擎中所有的外键对应列的信息 |
| SYS_TABLESPACES | 整个InnoDB存储引擎中所有的表空间信息 |
| SYS_DATAFILES | 整个InnoDB存储引擎中所有的表空间对应文件系统的文件路径信息 |
| SYS_VIRTUAL | 整个InnoDB存储引擎中所有的虚拟生成列的信息 |
这些系统表也被称为数据字典,它们都是以B+树的形式保存在系统表空间的某些页面中,其中SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS这四个表尤其重要,称之为基本系统表。
用户是不能直接访问InnoDB的这些内部系统表的,除非你直接去解析系统表空间对应文件系统上的文件。不过InnoDB考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些以innodb_sys开头的表:
在information_schema数据库中的这些以INNODB_SYS开头的表并不是真正的内部系统表(内部系统表就是上边以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以INNODB_SYS开头的表中。
InnoDB的Buffer Pool
缓存的重要性
对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说数据说到底还是存储在磁盘上的。
但是磁盘的速度慢,所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。
Buffer Pool
InnoDB为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)。那它有多大呢?这个其实看机器的配置,默认情况下Buffer Pool只有8M大小,这个值其实是偏小的。
show variables like 'innodb_buffer_pool_size';
可以在启动服务器的时候配置innodb_buffer_pool_size参数的值,它表示BufferPool的大小,就像这样:
[server]
innodb_buffer_pool_size= 268435456
其中,268435456的单位是字节,也就是指定Buffer Pool的大小为256M。需要注意的是,Buffer Pool也不能太小,最小值为5M(当小于该值时会自动设置成5M)。
Buffer Pool内部组成
Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更好的管理这些在Buffer Pool中的缓存页,InnoDB为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息,当然还有一些别的控制信息。
每个缓存页对应的控制信息占用的内存大小是相同的,称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来就是这样的:
每个控制块大约占用缓存页大小的5%,而设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。
free链表的管理
最初启动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链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了。
缓存页的哈希处理
当需要访问某个页中的数据时,就会把该页从磁盘加载到Buffer Pool中,如果该页已经在Buffer Pool中的话直接使用就可以了。那么问题也就来了,怎么知道该页在不在Buffer Pool中呢?难不成需要依次遍历Buffer Pool中各个缓存页么?
其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key,缓存页就是对应的value,怎么通过一个key来快速找着一个value呢?
所以可以用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
flush链表的管理
如果修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。
但是如果不立即同步到磁盘的话,那之后再同步的时候怎么知道Buffer Pool中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如Buffer Pool被设置的很大,比方说300G,那一次性同步会非常慢。
所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多。
LRU链表的管理
缓存不够的窘境
Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,也就是free链表中已经没有多余的空闲缓存页的时候该咋办?当然是把某些旧的缓存页从Buffer Pool中移除,然后再把新的页放进来,那么问题来了,移除哪些缓存页呢?
为了回答这个问题,还需要回到设立Buffer Pool的初衷,就是想减少和磁盘的IO交互,最好每次在访问某个页的时候它都已经被缓存到Buffer Pool中了。假设一共访问了n次页,那么被访问的页已经在缓存中的次数除以n就是所谓的缓存命中率,期望就是让缓存命中率越高越好。
从这个角度出发,回想一下微信聊天列表,排在前边的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?当然是留下最近很频繁使用的了。
简单的LRU链表
管理Buffer Pool的缓存页其实也是这个道理,当Buffer Pool中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?
再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为LRU链表(LRU的英文全称:Least Recently Used)。当需要访问某个页时,可以这样处理LRU链表:
如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓存页时,就把该缓存页对应的控制块作为节点塞到LRU链表的头部。
如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。
也就是说:只要使用到某个缓存页,就把该缓存页调整到LRU链表的头部,这样LRU链表尾部就是最近最少使用的缓存页。所以当Buffer Pool中的空闲缓存页使用完时,到LRU链表的尾部找些缓存页淘汰就行了。
划分区域的LRU链表
但是这种实现存在两种比较尴尬的情况:
情况一:InnoDB提供了预读(英文名:readahead)。所谓预读,就是InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool中。根据触发方式的不同,预读又可以细分为下边两种:
线性预读
InnoDB提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求。
show variables like 'innodb_read_ahead_threshold';
这个innodb_read_ahead_threshold系统变量的值默认是56,可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值。
随机预读
如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到Buffer Pool的请求。
如果预读到Buffer Pool中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到LRU链表的头部,但是如果此时Buffer Pool的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在LRU链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。
所以InnoDB同时提供了innodb_random_read_ahead系统变量,它的默认值为OFF。
show variables like 'innodb_random_read_ahead';
情况二:应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有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链表中所占的比例,比方说这样:
SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
从结果可以看出来,默认情况下,old区域在LRU链表中所占的比例是37%,也就是说old区域大约占LRU链表的3/8。这个比例是可以设置的,可以在启动时修改innodb_old_blocks_pct参数来控制old区域在LRU链表中所占的比例。在服务器运行期间,也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量。
有了这个被划分成young和old区域的LRU链表之后,InnoDB就可以针对上边提到的两种可能降低缓存命中率的情况进行优化了:
针对预读的页面可能不进行后续访问情况的优化:
InnoDB规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问的页面就会被逐渐从old区域逐出,而不会影响young区域中被使用比较频繁的缓存页。
针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化:
在进行全表扫描时,虽然首次被加载到Buffer Pool的页被放到了old区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到young区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。
有同学会想:可不可以在第一次访问该页面时不将其从old区域移动到young区域的头部,后续访问时再将其移动到young区域的头部。回答是:行不通!因为InnoDB规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。
全表扫描有一个特点,那就是它的执行频率非常低,出现了全表扫描的语句也是应该尽快优化的对象。而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。
所以在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的:
SHOW VARIABLES LIKE 'innodb_old_blocks_time';
这个innodb_old_blocks_time的默认值是1000,它的单位是毫秒,也就意味着对于从磁盘上被加载到LRU链表的old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s),那么该页是不会被加入到young区域的,
当然,像innodb_old_blocks_pct一样,也可以在服务器启动或运行时设置innodb_old_blocks_time的值,这里需要注意的是,如果把innodb_old_blocks_time的值设置为0,那么每次访问一个页面时就会把该页面放到young区域的头部。
综上所述,正是因为将LRU链表划分为young和old区域这两个部分,又添加了innodb_old_blocks_time这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到old区域,而不影响young区域中的缓存页。
更进一步优化LRU链表
对于young区域的缓存页来说,每次访问一个缓存页就要把它移动到LRU链表的头部,这样开销是不是太大?
毕竟在young区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表进行节点移动操作也会拖慢速度?为了解决这个问题,MySQL中还有一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能。
多个Buffer Pool实例
上边说过,Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理,在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。
可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数
那每个Buffer Pool实例实际占多少内存空间呢?其实使用这个公式算出来的:
innodb_buffer_pool_size/innodb_buffer_pool_instances
也就是总共的大小除以实例的个数,结果就是每个Buffer Pool实例占用的大小。
不过也不是说Buffer Pool实例创建的越多越好,分别管理各个Buffer Pool也是需要性能开销的,InnoDB规定:当innodb_buffer_pool_size(默认128M)的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。所以Buffer
Pool大于或等于1G的时候设置应该多个Buffer Pool实例。
innodb_buffer_pool_chunk_size
在MySQL 5.7.5之前,Buffer Pool的大小只能在服务器启动时通过配置innodb_buffer_pool_size启动参数来调整大小,在服务器运行过程中是不允许调整该值的。不过MySQL在5.7.5以及之后的版本中支持了在服务器运行过程中调整Buffer Pool大小的功能,但是有一个问题,就是每次当需要重新调整Buffer Pool大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool中的内容复制到这一块新空间,这是极其耗时的。所以MySQL决定不再一次性为某Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个所谓的chunk为单位向操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的,一个chunk就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块:
正是因为发明了这个chunk的概念,在服务器运行期间调整Buffer Pool的大小时就是以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的chunk的大小是在启动操作MySQL服务器时通过innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是134217728,也就是128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。
SHOW VARIABLES LIKE 'innodb_buffer_pool_chunk_size';
Buffer Pool的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息。
1.3.5. 查看Buffer Pool的状态信息
MySQL提供了SHOW ENGINE INNODB STATUS语句来查看关于InnoDB存储引擎运行过程中的一些状态信息,其中就包括Buffer Pool的一些信息,来看一下(为了突出重点,只把输出中关于Buffer Pool的部分提取了出来):
SHOW ENGINE INNODB STATUS\G
这里边的每个值都代表什么意思如下,知道即可:
Total large memory allocated
代表Buffer Pool向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小。
Dictionary memory allocated
为数据字典信息分配的内存空间大小,注意这个内存空间和Buffer Pool没啥关系,不包括在Total memory allocated中。
Buffer pool size
代表该Buffer Pool可以容纳多少缓存页,注意,单位是页!
Free buffers
代表当前Buffer Pool还有多少空闲缓存页,也就是free链表中还有多少个节点。
Database pages
代表LRU链表中的页的数量,包含young和old两个区域的节点数量。
Old database pages
代表LRU链表old区域的节点数量。
Modified db pages
代表脏页数量,也就是flush链表中节点的数量。
Pending reads
正在等待从磁盘上加载到Buffer Pool中的页面数量。
当准备从磁盘中加载某个页面时,会先为这个页面在Buffer Pool中分配一个缓存页以及它对应的控制块,然后把这个控制块添加到LRU的old区域的头部,但是这个时候真正的磁盘页并没有被加载进来,Pending reads的值会跟着加1。
Pending writes
LRU:即将从LRU链表中刷新到磁盘中的页面数量。
Pending writes
flush list:即将从flush链表中刷新到磁盘中的页面数量。
Pending writes
single page:即将以单个页面的形式刷新到磁盘中的页面数量。
Pages made young
代表LRU链表中曾经从old区域移动到young区域头部的节点数量。
Page made not young
在将innodb_old_blocks_time设置的值大于0时,首次访问或者后续访问某个处在old区域的节点时由于不符合时间间隔的限制而不能将其移动到young区域头部时,Page made not young的值会加1。
youngs/s: 代表每秒从old区域被移动到young区域头部的节点数量。
non-youngs/s: 代表每秒由于不满足时间限制而不能从old区域移动到young区域头部的节点数量。
Pages read、created、written:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率。
Buffer pool hit rate:
表示在过去某段时间,平均访问1000次页面,有多少次该页面已经被缓存到Buffer Pool了。
young-making rate:
表示在过去某段时间,平均访问1000次页面,有多少次访问使页面移动到young区域的头部了。
not(young-making rate):
表示在过去某段时间,平均访问1000次页面,有多少次访问没有使页面移动到young区域的头部。
LRU len:代表LRU链表中节点的数量。
unzip_LRU:代表unzip_LRU链表中节点的数量。
I/O sum:最近50s读取磁盘页的总数。
I/O cur:现在正在读取的磁盘页数量。
I/O unzip sum:最近50s解压的页面数量。
I/O unzip cur:正在解压的页面数量。
InnoDB的内存结构总结
InnoDB的内存结构和磁盘存储结构图总结如下:
其中的Insert/Change Buffer主要是用于对二级索引的写入优化,Undo空间则是undo日志一般放在系统表空间,但是通过参数配置后,也可以用独立表空间存放,所以用虚线表示。
事务底层与高可用原理
事务的基础知识
mysql的事务分为显式事务和隐式事务
-
默认的事务是隐式事务
-
显式事务由自己控制事务的开启,提交,回滚等操作
show variables like 'autocommit';
事务基本语法
事务开始
1、begin
2、START TRANSACTION(推荐)
3、begin work
事务回滚
rollback
事务提交
commit
使用事务插入两行数据,commit后数据还在
使用事务插入两行数据,rollback后数据没有了
redo日志
在事务的实现机制上,MySQL采用的是WAL(Write-ahead logging,预写式日志)机制来实现的。
就是所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含redo和undo两部分信息。
redo log称为重做日志,每当有操作时,在数据变更之前将操作写入redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。
undo log称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。
MySQL中用redo log来在系统Crash重启之类的情况时修复数据(事务的持久性),而undo log来保证事务的原子性。
redo日志及作用
redo日志
MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir’查看)下默认有两个名为ib_logfile0和ib_logfile1的文件,这个就是redo日志
可以通过下边几个启动参数来调节:
innodb_log_group_home_dir,该参数指定了redo日志文件所在的目录,默认值就是当前的数据目录。
innodb_log_file_size,该参数指定了每个redo日志文件的大小,默认值为48MB,
innodb_log_files_in_group,该参数指定redo日志文件的个数,默认值为2,最大值为100。
所以磁盘上的redo日志文件可以不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字](数字可以是0、1、2…)的形式进行命名。在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。如果写到最后一个文件也慢了该咋办?那就重新转到ib_logfile0继续写(覆盖写)。
redo日志的作用
在Buffer Pool的时候说过,在真正访问MySQL数据之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。如果只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是所不能忍受的。那么如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个做法有以下问题:
- 刷新一个完整的数据页太浪费了
有时候仅仅修改了某个页面中的一个字节,但是在InnoDB中是以页为单位来进行磁盘IO的,也就是说在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘;一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。
- 随机IO刷起来比较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
怎么办呢?只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。
比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1改成2只需要记录一下:
将第0号表空间的100号页面的偏移量为1000处的值更新为2。
以上述内容也被称之为重做日志,英文名为redo log,也可以称之为redo日志。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的redo日志刷新到磁盘的好处如下:
1、redo日志占用的空间非常小
存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
2、redo日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。
redo日志格式
通过上边的内容,redo日志本质上只是记录了一下事务对数据库做了哪些修改。 InnoDB们针对事务对数据库的不同修改场景定义了多种类型的redo日志,但是绝大部分类型的redo日志都有下边这种通用的结构:
各个部分的详细释义如下:
type:该条redo日志的类型,redo日志设计大约有53种不同的类型日志。
space ID:表空间ID。
page number:页号。
data:该条redo日志的具体内容。
简单的redo日志类型
如果某张表没有主键,并且没有定义不允许存储NULL值的UNIQUE键,那么InnoDB会自动为表添加一个名为row_id的隐藏列作为主键。
为这个row_id隐藏列进行赋值的方式如下:
- 内存中维护一个全局变量,当向某个包含row_id隐藏列的表中插入一条记录时,就会把这个全局变量的值当做新记录的row_id的值,并且把这个全局变量+1;
- 每当这个全局变量的值为256的倍数时,就会将该变量的值刷新到系统表空间页号为7的页面中一个名为Max Row Id的属性中。此时需要把这次对这个页面的修改以redo日志的形式记录下来
- 当系统启动时,会将这个Max Row Id属性加载到内存中。
InnoDB把这种极其简单的redo日志称之为物理日志,并且根据在页面中写入数据的多少划分了几种不同的redo日志类型:
MLOG_1BYTE(type=1)
表示在页面的某个偏移量处写入1字节的redo日志类型。
MLOG_2BYTE(type=2)
表示在页面的某个偏移量处写入2字节的redo日志类型。
MLOG_4BYTE(type=4)
表示在页面的某个偏移量处写入4字节的redo日志类型。
MLOG_8BYTE(type=8)
表示在页面的某个偏移量处写入8字节的redo日志类型
上边提到的Max Row ID属性实际占用8个字节的存储空间,所以在修改页面中的该属性时,会记录一条类型为MLOG_8BYTE的redo日志,MLOG_8BYTE的redo日志结构如下所示:
offset代表在页面中的偏移量。
复杂的redo日志类型
有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。以一条INSERT语句为例,它除了要向B+树的页面中插入数据,也可能更新系统数据Max Row ID的值,不过对于用户来说,平时更关心的是语句对B+树所做更新:
表中包含多少个索引,一条INSERT语句就可能更新多少棵B+树。
针对某一棵B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)。
画一个复杂的redo日志的示意图就像是这样:
大家只要记住:redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。
redo日志的写入过程
redo log block和日志缓冲区
InnoDB为了更好的进行系统崩溃恢复,把生成的redo日志都放在了大小为512字节的块(block)中。
前边说过,为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区(内存),也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,可以通过启动参数innodb_log_buffer_size来指定log buffer的大小,该启动参数的默认值为16MB。
redo日志刷盘时机
可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:
一、事务提交时,为了保证持久性,必须要把修改这些页面对应的redo日志刷新到磁盘。
不过这里有一个参数 innodb_flush_log_at_trx_commit 可以控制:
该变量有3个可选的值:
0: 当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。
这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将redo日志刷新到磁盘,那么该事务对页面的修改会丢失。
1: 当该系统变量值为1时,表示在事务提交时需要将redo日志同步到磁盘,可以保证事务的持久性。1也是innodb_flush_log_at_trx_commit的默认值。
2: 当该系统变量值为2时,表示在事务提交时需要将redo日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。
这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。
二、InnoDB认为如果当前写入log buffer的redo日志量已经占满了log buffer总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
三、后台有一个线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。
四、正常关闭服务器时等等。
崩溃后的恢复
恢复机制
在服务器不挂的情况下,redo日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一数据库挂了,就可以在重启时根据redo日志中的记录就可以将页面恢复到系统崩溃前的状态。
MySQL可以根据redo日志中的各种信息,来确定恢复的起点和终点。然后将redo日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的redo日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO)。并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃恢复的速度。
崩溃后的恢复为什么不用binlog?
1、这两者使用方式不一样
binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要用于人工恢复数据,而 redo log 是不可见的,它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后MySQL崩溃的话,可以保证事务的持久性,即事务提交后其更改是永久性的。
一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用,用于保证在数据库崩溃时的事务持久性。
2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
3、redo log是物理日志,记录的是“在某个数据页上做了什么修改”,恢复的速度更快;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这的c字段加1 ”;
4、redo log是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是追加日志,保存的是全量的日志。
5、最重要的是,当数据库crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志,但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有。
比如,binlog 记录了两条日志:
记录1:给 ID=2 这一行的 c 字段加1
记录2:给 ID=2 这一行的 c 字段加1
在记录1入表后,记录2未入表时,数据库crash。重启后,只通过 binlog 数据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条都恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。
但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据库重启后,直接把 redo log 中的数据都恢复至内存就可以了。
undo日志
事务回滚的需求
事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,需要把东西改回原先的样子,这个过程就称之为回滚(英文名:rollback),这样就可以造成这个事务看起来什么都没做,所以符合原子性要求。
每当对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。比方说:
你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。
你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。
你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。
这些为了回滚而记录的这些东西称之为撤销日志,英文名为undo log/undo日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志。
当然,在真实的InnoDB中,undo日志其实并不像上边所说的那么简单,不同类型的操作产生的undo日志的格式也是不同的。
事务id
给事务分配id的时机
读写事务:
可以通过STARTTRANSACTION READ WRITE语句开启一个读写事务,或者使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。
在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。
有的时候虽然开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id。
上边描述的事务id分配策略是针对MySQL5.7来说的,前边的版本的分配方式可能不同。
事务id生成机制
这个事务id本质上就是一个数字,它的分配策略和前边提到的对隐藏列row_id(当用户没有为表创建主键和UNIQUE键时InnoDB自动创建的列)的分配策略大抵相同,具体策略如下:
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。
每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处,这个属性占用8个字节的存储空间。
当系统下一次重新启动时,会将上边提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给前边提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。
这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。
trx_id隐藏列
在InnoDB记录行格式的时候重点强调过:聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。
其中的trx_id列就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id而已(此处的改动可以是INSERT、DELETE、UPDATE操作)。
undo日志的格式
为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志。
一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、…、第n号undo日志等,这个编号也被称之为undo no。
这些undo日志是被记录到类型为FIL_PAGE_UNDO_LOG的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间,也就是所谓的undo tablespace中分配。先来看看不同操作都会产生什么样子的undo日志。
INSERT操作对应的undo日志
当向表中插入一条记录时最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。InnoDB的设计了一个类型为TRX_UNDO_INSERT_REC的undo日志。
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列):
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
(补充点:undo日志:为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、…、第n号undo日志等,这个编号也被称之为undo no。)
为了说明这个问题,创建一个演示表
CREATE TABLE teacher (
number INT,
name VARCHAR(100),
domain varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
然后向这个表里插入一条数据:
INSERT INTO teacher VALUES(1, '李四', 'JVM系列');
现在表里的数据就是这样的:
假设插入该记录的事务id为60,那么此刻该条记录的示意图如下所示:
如果记录中的主键只包含一个列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来。
roll_pointer的作用
roll_pointer本质上就是一个指向记录对应的undo日志的一个指针。比方说向表里插入了2条记录,每条记录都有与其对应的一条undo日志。记录被存储到了类型为FIL_PAGE_INDEX的页面中(就是前边一直所说的数据页),undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。roll_pointer本质就是一个指针,指向记录对应的undo日志。
DELETE操作对应的undo日志
插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,把这个链表称之为正常记录链表;
往这张表中插入多条记录。每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:
被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。
假设此刻某个页面中的记录分布情况是这样的
只把记录的delete_mask标志位展示了出来。从图中可以看出,正常记录链表中包含了3条正常记录,垃圾链表里包含了2条已删除记录。页面的Page Header部分的PAGE_FREE属性的值代表指向垃圾链表头节点的指针。
假设现在准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一:将记录的delete_mask标识位设置为1,这个阶段称之为delete mark。
可以看到,正常记录链表中的最后一条记录的delete_mask值被设置为1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。
为啥会有这种奇怪的中间状态呢?其实主要是为了实现MVCC中的事务隔离级别。
阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等。这个阶段称之为purge。
把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。
从上边的描述中可以看出来,在删除语句所在的事务提交之前,只会经历阶段一,也就是delete mark阶段(提交之后就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。InnoDB中就会产生一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志。
版本链
同时,在对一条记录进行delete mark操作前,需要把该记录的旧的trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来,就是图中显示的old trx_id和old roll_pointer属性。这样有一个好处,那就是可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比方说在一个事务中,先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:
从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表。这个链表就称之为版本链。
UPDATE操作对应的undo日志
在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。
不更新主键的情况
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。
先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
请注意一下,这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、PAGE_GARBAGE等这些信息)。由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。
这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。
针对UPDATE不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志。
更新主键的情况
在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在1 ~ 10000之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步处理:
将旧记录进行delete mark操作
也就是说在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。这里一定要和上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!
之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC。
创建一条新记录
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对UPDATE语句更新记录主键值的这种情况:
在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;
之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的undo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志
MySQL8新特性底层原理
降序索引
什么是降序索引
MySQL 8.0开始真正支持降序索引 (descendingindex) 。只有InnoDB存储引擎支持降序索引,只支持BTREE降序索引。另外MySQL8.0不再对GROUP BY操作进行隐式排序。
在MySQL中创建一个t2表
create table t2(c1 int,c2 int,index idx1(c1 asc,c2 desc));
show create table t2\G
如果是5.7中,则没有显示升序还是降序信息
插入一些数据,给大家演示下降序索引的使用
insert into t2(c1,c2) values(1,100),(2,200),(3,150),(4,50);
看下索引使用情况
explain select * from t2 order by c1,c2 desc;
在5.7对比一下
这里说明,这里需要一个额外的排序操作,才能把刚才的索引利用上。
把查询语句换一下
explain select * from t2 order by c1 desc,c2 ;
MySQL8中使用了
另外还有一点,就是group by语句在 8之后不再默认排序
select count(*),c2 from t2 group by c2;
在8要排序的话,就需要手动把排序语句加上
select count(*),c2 from t2 group by c2 order by c2;
到此为止,大家应该对升序索引和降序索引有了一个大概的了解,但并没有真正理解,因为大家并不知道升序索引与降序索引底层到底是如何实现的。
降序索引的底层实现
升序索引对应的B+树
降序索引对应的B+树
如果没有降序索引,查询的时候要实现降序的数据展示,那么就需要把原来默认是升序排序的数据处理一遍(比如利用压栈和出栈操作),而降序索引的话就不需要,所以在优化一些SQL的时候更加高效。
还有一点,现在 只有Innodb存储引擎支持降序索引 。
Doublewrite Buffer的改进
MySQL5.7
MySQL8.0
在MySQL 8.0.20 版本之前,doublewrite 存储区位于系统表空间,从 8.0.20 版本开始,doublewrite 有自己独立的表空间文件,这种变更,能够降低doublewrite的写入延迟,增加吞吐量,为设置doublewrite文件的存放位置提供了更高的灵活性。
因为系统表空间在存储中就是一个文件,那么doublewrite必然会受制于这个文件的读写效率(其他向这个文件的读写操作,比如统计、监控等数据操作)
系统表空间(system tablespace)
这个所谓的系统表空间可以对应文件系统上一个或多个实际的文件,默认情况下,InnoDB会在数据目录下创建一个名为ibdata1(在你的数据目录下找找看有木有)、大小为12M的文件,这个文件就是对应的系纳表空间在文件系统上的表示。
而单独的文件必然效率比放在系统表空间效率要高!!!
新增的参数:
innodb_doublewrite_dir
指定doublewrite文件存放的目录,如果没有指定该参数,那么与innodb数据目录一致(innodb_data_home_dir),如果这个参数也没有指定,那么默认放在数据目录下面(datadir)。
innodb_doublewrite_files
指定doublewrite文件数量,默认情况下,每个buffer pool实例,对应2个doublewrite文件。
innodb_doublewrite_pages
一次批量写入的doublewrite页数量的最大值,默认值、最小值与innodb_write_io_threads参数值相同,最大值512。
innodb_doublewrite_batch_size
一次批量写入的页数量。默认值为0,取值范围0到256。
redo log 无锁优化
MySQL :: MySQL 8.0: New Lock free, scalable WAL design
MySQL 8 中快速添加列的底层实现原理
MySQL 8 中快速添加列的底层实现原理是通过 InnoDB 存储引擎的 “Fast Index Creation” 特性实现的。该特性允许在大型表中高效地添加列,而无需重建整个表。
当向表中添加新列时,MySQL 8 使用一种称为 “in-place ALTER” 的技术,以修改表的元数据,而无需进行完整的表重建。在添加新列的情况下,InnoDB 存储引擎会创建一个 “不可见” 的表的副本,该副本包含了新列。然后,这个副本会以非阻塞的方式与原始表进行同步,使得正常的操作可以继续进行,而不会中断。
这种 “in-place ALTER” 技术利用了 InnoDB 的高效数据存储格式,该格式使用聚簇索引来物理存储数据。通过将新列作为聚簇索引的一部分添加,MySQL 8 避免了复制和重新组织整个表的需要,从而实现了更快的列添加操作。
版权声明:本文标题:MySQL性能调优与架构设计 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.roclinux.cn/b/1766105759a3437774.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论