《MySQL是怎样运行的》总结笔记
内容太多,主要总结一些自己认为重要的,另外太基础常见可能不会总结上。
字符集和比较规则
MySQL会通过把字符串编码后再进行比较大小并排序,有一些很早的字符集可能会不支持中文,比如ASCII、ISO 8859-1,现在最常用的是:
- utf8mb3:“阉割”过的UTF-8字符集,只使用1~3字节表示字符,存储和性能会稍微好点。
- utf8mb4:正宗的UTF-8字符集,使用1~4字节表示字符。
UTF-8字符集是变长编码的,比如'L'需要1字节,'啊'需要3字节。
在MySQL8.0中,utf8mb4的性能被优化,并用作默认字符集;另外由于utf8mb3最多只支持3字节,如果有使用4字节编码一个字符的情况,比如存储一些emoji表情,就需要使用utf8mb4。
每种字符集还会对应若干种比较规则,比如utf8(utf8mb3的缩写)默认的比较规则utf8_general_ci,不区分大小写,因此很多人会发现MySQL的查询语句基本是不区分英文大小写的。
InnoDB数据页结构
由于InnoDB是存储在磁盘上的,读写磁盘的速度比读写内存差了好几个数量级,因此InnoDB读取数据的方式是将数据划分为若干个页,页的大小一般为16KB,这个大小只能在第一次初始化MySQL数据目录时指定,也就是服务器运行过程中无法更改。
每个页中会有若干个槽(slot),每个槽会对应一个数据组,槽中存放对应的数据组中最大的那条记录在页面中的地址偏移量。各个槽之间是挨着的,他们代表的记录的主键值都是从小到大排序的,因此根据主键查找记录时,会先通过二分法确定该记录所在分组对应的槽,然后找到该分组中主键值最小的那条记录,再根据记录的next_record属性遍历这个组中的所有记录,由于一个组中包含的记录条数最多是8条,所以遍历一个组的代价是很小的。
每个数据页的File Header部分都有上一个页和下一个页的编号,所有数据组会组成一个双向链表。
不过,主键的顺序只是在每个数据页中是有序的,如果表数据比较多,分成了多个数据页,他们之间是没有根据主键排序的(因为页是数据交互的基本单位)。也就是说,在一个页中用主键作为搜索条件时,可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组的记录;以其他列作为搜索条件, 因为数据页中没有为非主键列建立页目录,所以只能从Infimum记录开始依次遍历单向链表中的每条记录,也就是全文检索。很多页时,只能先从第一页沿着双向链表一直往下找,这个效率是非常低的,所以索引就登场了。
B+树索引结构
假设所有存放用户记录的叶子结点所代表的数据页可以存放100条用户记录,所有存放目录项记录的内节点所代表的数据页可以存放1000条目录项记录,那么:
- 如果B+树只有1层,也就是只有1个用于存放用户记录的节点,则最多能存放100条用户记录;
- 如果B+树有2层,最多能存放1000 x 100 = 100000条用户记录
- 如果B+树有4层,最多能存放1000 x 1000 x 1000 x 100 = 10^11 条用户记录。
所以在一般情况下,我们用到的B+树都不会超过4层,因此在通过主键值去查找某条记录时,最多只需要进行4个页面内的查找(3次目录项记录和1个存储用户记录-也就是叶子结点的页)。又因为在每个页面内存在页目录,所以在页面内也可以根据二分法快速定位记录。
聚簇索引
- 使用主键值的大小进行记录和页的排序:
- 页内的记录按照主键的大小顺序排成一个单向链表,页内划分成多个组,每个组中主键最大的记录的偏移量会被当做槽,可以在页目录中通过二分法快速定位到主键列等于某个值的记录;
- 存放用户记录(也就是叶子结点)的各个页之间也会根据页中的主键值大小顺序排成一个双向链表;
- 存放目录项记录(也就是非叶子结点)有不同的层级,同一层级中的页也是根据目录项的主键大小顺序排成一个双向链表。
- 叶子结点存储的是完成的用户记录,也就是存储了所有列的值(包括隐藏列)。
InnoDB会自动创建有且仅有一个。
二级索引(稀疏索引)
聚簇索引是一棵B+树,当不能使用主键时,可以多建几棵B+树,比如用c2列作为索引,它和聚簇索引有以下几处不同:
- 使用主键值的大小进行记录和页的排序:
- 页内记录根据c2列的值的大小顺序排成单向链表,分成若干个组、槽,在页目录内通过二分法定位到某个值的记录;
- 叶子结点的页之间、非叶子结点的页之间,也都是根据c2列的值进行排序创建双向链表。
- 树的叶子结点中只存储c2列+主键两个列的值。
- 目录项记录中不再是主键+页号的搭配,而是c2列+页号。
因此,使用二级索引查询,且没有覆盖索引时,会把查询到的记录再通过主键索引到聚簇索引中进行一次回表查询。
值得一提的是,需要执行回表操作的记录越多,使用二级索引进行查询的性能也就越低,某些查询宁愿用全表扫描也不用二级索引。比如有些扫描区间由于不是主键排序的,那么如果读取二级索引记录的主键id对应的聚簇索引的记录所在的页面不在内存中,就需要将该页面从磁盘加载到内存中。由于要取很多id值并不连续的聚簇索引记录,他们分布在不同的数据页中,这些数据页的页号也毫无规律,就会造成大量的随机IO。
一般情况下,可以通过添加 limit 10 来控制回表操作的条数,从而优化查询性能。
联合索引
多个列加起来合成一个索引,假设根据c2列+c3列创建联合索引,其特点:
- 根据c2列+c3列的大小进行排序
- 先把各个记录和页根据c2列进行排序;
- c2列相同的情况下,再采用c3列进行排序。
也正因为如此,才会有最左匹配原则的出现。
MyISAM的索引简介
MyISAM每个B+树会有2个文件,一个是只存储索引信息的索引文件,和存储用户记录的文件,也就相当于MyISAM全都是二级索引。
另外,InnoDB每个表会有2个文件,分别是:表名.frm(表结构的定义)、表名.ibd(表中的数据);MyISAM会有3个文件,分别是:表名.frm(表结构的定义)、表名.MYD(表中的数据)、表名.MYI(表的索引文件)。
另外,每个ibd文件都称为一个表空间,由于表空间大中的页实在是太多了,为了更好地管理这些页面,又提出了区的概念。连续的64个页就是一个区,也就是默认1MB的大小,每256个区被划分成一组。如果双向链表中相邻的两个页的物理位置不连续,对于传统的机械硬盘来说,需要重新定位磁头位置,也就是会产生随机IO,这样会影响磁盘的性能。所以我们应该尽量让页面链表中相邻的页的物理位置页相邻,这样在扫描叶子结点中大量的记录时才可以使用顺序IO,所以才引入了区的概念。
B+树索引的使用
执行查询时索引是如何工作的
因为可以说很熟了就先不总结了,有兴趣也可以看看这个:
较为深入的解析联合索引最左匹配原则_oracle联合索引-CSDN博客
更好的创建索引
- 只为用于搜索、排序或分组的列创建索引。
- 只为出现在where子句中的列、连接子句中的列,或者出现在order by、group by子句中的列创建索引。
- 考虑索引列中不重复值的个数
- 在通过二级索引+回表的方式执行查询时,某个扫描区间中包含的二级索引记录数量越多,回表操作的代价就会越大。如果某个列不重复值的个数比例太低,可能会执行太多回表操作。
- 索引列的类型尽量小
- 能用INT就不要使用BIGINT,能用MEDIUMINT就不要使用INT。数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以存放更多的记录,一次页面IO可以将更多的记录加载到内存中,读写效率就会更高。
- 为列前缀建立索引
- 如果某个列的字符串比较长,我们可以通过: ADD INDEX idx_key1(key1(10));来只对字符串的前10个字符创建索引,可以减少索引大小。
- 但这种索引无法支持比如order by key1 limit 10; 这样的操作,因为二级索引中不包含key1的完整信息,这个排序只能使用全表扫描来排序了。
- 覆盖索引
- 如果可以的话,建议查询时只查询索引列,这样可以告别回表操作带来的性能损耗,但还是以业务为重。
- 让索引列以列明的形式在搜索条件中单独出现
- 比如 where key2 * 2 < 4; 可以改为 where key2 < 4/2; MySQL并不会尝试简化key2 * 2 < 4 表达式,而是直接认为这个搜索条件不能形成合适的扫描区间。
- 新插入记录时主键大小对效率的影响
- 如果新插入记录的主键值是依次增大的话,则每插满一个数据页就换到下一个数据页继续插入。如果新插入记录的主键值忽大忽小,就比较麻烦了。比如某个数据页已经满了,主键值在1~100之间,此时又插入一条主键值为9的记录,则它插入时就会把本页的一些数据移动到新创建的页中,就意味着性能损耗。
- 因此最好让插入记录的主键值依次递增,比如设置主键列AUTO_INCREMENT。
- 冗余和重复索引
- 简单来说就是不要创建在逻辑上重复的索引,比如已有c1、c2列的联合索引,又根据c1列单独创建索引,是没必要的。
单表访问方法
访问方法
当我们使用explain解析查询方法时,可以看到各种访问方法:
- const
- 通过主键或者唯一二级索引列与常数的等值比较来定位一条记录是最快的,定义为const(意思是常数级别的)。
- 如果主键或者唯一二级索引的索引列由多个列构成,则只有每一个列都与常数进行等值比较时,才是const。
- 另外查询 where key2 is null; 时,因为唯一二级索引列并不限制NULL值的数量,所以不是const访问方法。
- ref
- 普通的二级索引列与常数进行等值比较,比如 where key1 = 'abc'
- ref_or_null
- 比如 where key1 = 'abc' or key1 is null;
- range
- 使用索引执行查询时,对应的扫描区间为若干个单点扫描区间或者范围扫描区间。区间为(-无穷,+无穷)的不能称为range。
- index
- 扫描全部的二级索引记录,但不用回表操作。
- 比如 select key1,key2,key3 where key2 = 'abc'; 并且有一个(key1,key2,key3)的联合索引。
- 另外order by主键也被认为是index。
- all
- 即全表扫描
索引合并
MySQL一般情况下只会为单个索引生成扫描区间,也就是一个SQL语句对应一个索引;但特殊情况下可能会为多个索引生成扫描区间,称为index merge(索引合并)。
- Intersection 索引合并
- 交集。指的是从不同索引中扫描到的记录的id值取交集,只为这些id值进行回表操作。比如:
- select * from single_table where key1 = 'a' and key3 = 'b'; key1和key3分别是两个单列二级索引。
- Union 索引合并
- 并集。需要每个索引中获取到的二级索引记录都是按照主键值排序的,比如:
- select * from single_table where key1 = 'a' or key3 = 'b'; 它们在各自的['a','a']和['b','b']区间中都是按照主键值排序的。
- 不可以的查询:select * from single_table where key1 > 'a' or key3 = 'b'; 由于key1的索引扫描区间('a',+无穷)是根据key1的值排序的,因此不能使用Union索引合并。
- Sort-Union 索引合并
- 基于Union索引合并,先分别查询出两个扫描区间的列的主键值,然后根据主键值合并起来排序,最后再进行Union索引合并。
表连接
where和on:
where子句中的过滤条件会作用到最终的结果集;
on子句中的过滤条件在内连接中和where是等价的,在外连接中会作用到被驱动表,如果无法在被驱动表中找到匹配on子句过滤条件的记录,该驱动表记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。
查询过程:
由于驱动表结果集中有多少条记录,就可能把被驱动表从磁盘中加载到内存中多少次。我们可以把被驱动表中的记录加载到内存时,一次性地与驱动表中的多条记录进行匹配,所以MySQL提出了一个Join Buffer(连接缓冲区)的概念。
Join Buffer中并不会存放驱动表记录的所有列,只有查询列表中的列和过滤条件中的列才会被放到Join Buffer中,所以这也再次提醒我们,最好不要把 * 作为查询列表,只需要把关心的列放到查询列表就好了,这样还可以在Join Buffer中放置更多的记录。
MySQL自带的查询优化
基于成本的优化
我们老说MySQL在执行查询时会选择成本最低或代价最低的方案,但这个成本应该如何定义?
- I/O成本:MyISAM和InnoDB都是将数据和索引存储到磁盘上,从磁盘到内存的加载过程损耗的时间称为IO成本。
- CPU成本:读取记录以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称为CPU成本。
MySQL官方规定:读取一个页面花费的成本是1.0;读取以及检测一条记录是否符合搜索条件的成本默认是0.2。这两个常数最常用到。
优化器生成制定计划的步骤一般如下:
- 根据搜索条件,找出所有可能使用的索引。
- 计算全表扫描的代价。
- 计算使用不同索引执行查询的代价。
- 对比各种执行方案的代价,找出成本最低的那个方案。
在优化器生成执行计划的过程中,需要依赖一些数据,这些数据可能是使用下面两种方式得到的:
- index dive:通过直接访问索引对应的B+树来获取数据。
- 索引统计数据:直接依赖对表或者索引的统计数据。
对于内连接来说,为了生成成本最低的执行计划,需要考虑两方面的事情:
- 选择最优的表连接顺序;
- 为驱动表和被驱动表选择成本最低的访问方法。
我们可以通过手动修改MySQL数据库下engine_cost表或者server_cost表中的某些成本常数,更精确地控制在生成执行计划时的成本计算过程。
基于规则的优化
MySQL会对用户编写的查询语句执行一些重写操作,比如:
- 移除不必要的括号;
- 常量传递;
- 移除没用的条件;
- 表达式计算;
- HAVING子句和WHERE子句的合并;
- 常量表检测。
在被驱动表的WHERE子句符合空值拒绝的条件时,外连接和内连接可以相互转换。
子查询可以按照不同的维度进行不同的分类,比如按照子查询返回的结果及分类:
- 标量子查询;
- 行子查询;
- 列子查询;
- 表子查询。
按照与外层查询的关系来分类:
- 不相关子查询;
- 相关子查询。
MySQL对in子查询进行了很多优化,如果in子查询符合转换为半连接的条件,查询优化器会优先把该子查询转换为半连接,然后再考虑下面5种执行半连接查询的策略中哪个成本最低,并执行子查询:
- Table pullout
- Duplicate Weedout
- LooseScan
- Semi-jion Materialization
- FirstMatch
如果in子查询不符合转换为半连接的条件,查询优化器会从下面两种策略中找出一种成本更低的方式执行子查询:
- 先将子查询物化,再执行查询;
- 执行IN到EXISTS的转换。
MySQL在处理带有派生表的语句时,优先尝试把派生表和外层查询进行合并;如果不行,再把派生表物化掉,然后执行查询。
InnoDB的Buffer Pool
磁盘太慢,用内存作为缓冲区很有必要。
Buffer Pool本质上是InnoDB向操作系统申请的一段连续的内存空间,可以通过innodb_buffer_pool_size来调整它的大小。
InnoDB使用了许多链表来管理Buffer Pool,比如free链表、LRU链表,他们共同作用进行管理。
自MySQL5.7.5版本之后,可以在服务器运行过程中调整Buffer Pool的大小。
Undo、Redo、binLog日志
事务隔离级别和MVCC
版本链
InnoDB的聚簇索引记录中会包含两个必要的隐藏列:
- trx_id:一个事务每次对某条聚簇索引记录进行该懂事,都会把该事务的事务id赋值给trx_id。
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中。这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息。
没对记录进行一次改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(insert操作对应的undo日志没有这个属性,因为insert的记录没有更早的版本),通过这个属性可以将这些undo日志穿成一个链表,称为版本链。
通过这个记录的版本链来控制并发事务访问相同记录时的行为,这种机制称为多版本并发控制(Multi-Version Concurrenry Control)
ReadView
对于第一隔离级别,直接可以读到未提交的记录,就直接读即可;对于第四隔离级别,由于是串行,无法读取修改中未提交的记录。
对于第2、3隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录,核心问题就是:需要判断版本链中的哪个版本是当前事务可见的。为此InnoDB提出了ReadView的概念。
ReadView中主要包含4个重要的内容:
- m_ids:在生成ReadView时,当前系统中活跃的读写事务的事务id列表。
- min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id;也就是m_ids的最小值。
- max_trx_id:在生成ReadView时,系统应该分配给下一个事务的事务id值。
- creator_trx_id:生成该ReadView的事务的事务id。
对于Read Committed,每次读取数据前都生成一个ReadView;
对于RepeatTable Read,只在第一次读取数据时生成一个ReadView。
二级索引的MVCC
因为只有聚簇索引才有trc_id和roll_pointer隐藏列,当使用二级索引查询时,首先会看一下对应的ReadView的min_trx_id是否大于该页面的PAGE_MAX_TRX_ID属性值,如果是,说明该页面中的所有记录都对该ReadView可见;否则就得执行下一步,在回表之后再判断可见性。
锁
读锁和写锁
读锁
锁的出现就是为了解决并发场景下的问题
首先一致性读(也叫快照读)是不会加锁的,而是利用MVCC解决并发场景的问题:
select * from t;
select * from t1 inner join t2 on t1.col1 = t2.col2;
锁定读,也叫当前读,这个操作是在读数据的同时加锁,防止并发状态下数据不一致的情况:
加S锁(共享锁)
select ... lock in share mode;
加X锁(独占锁、排它锁)
select ... for update;
兼容性 | X锁 | S锁 |
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
写锁
- DELETE:由于deleted的步骤是先定位到某行,然后获取到这条数据的X锁,再执行deleted mark操作,因此可以看做是加X锁。
- UPDATE:
- 如果没有实际修改,且存储空间没有发生变化,则可以看做是加X锁
- 如果没有实际修改,且至少有一个列占用的存储空间发生变化,则先定位到为止,然后获取X锁,之后再将该记录彻底删掉,最后再插入一条记录。可以看做是先加X锁,然后彻底删除的记录的锁也会被转移到新纪录上
- 如果修改了记录的值,就相当于先DELETE在INSERT
- INSERT:受隐式锁保护,不需要生成上述的锁结构。
多粒度锁
意向锁
除了行粒度的锁,MySQL还有表级别的锁。(另外MyISAM只有表级锁),表级锁也就是等同于整张表的数据都被加锁,也是分为S锁和X锁。比如当某表被加上表级锁时,想在对某行加上X锁会被阻塞,但此时后者是如何知道这张表被上了锁呢?不可能把整张表都遍历一遍,此时又引出了意向锁。
- 意向共享锁:简称IS锁,当事务准备在某条记录上加S锁时,需要先对表级别加一个IS锁。
- 意向独占锁:简称IX锁,当事务准备在某条记录上加X锁时,需要先对表级别加一个IX锁。
不过意向锁之间是可以兼容的:
兼容性 | X | IX | S | IS |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
表锁
表级别的S、X锁
在对某个表执行select、insert、delete、update语句时,InnoDB不会对表添加表级别的锁,不过这些语句会和表的DDL语句互斥。这是通过server层使用元数据锁来实现的,一般情况下不会用到表级别的锁,不过可以通过以下语句实现:
lock tables t read;
lock tables t write;
IS、IX锁
已在前文有过说明
AUTO-INC锁
我们在为某个列添加AUTO_INCREMENT属性之后,插入数据时可以不指定该列的值,由系统自动赋予递增的值。实现这个锁的方式主要有两个:
AUTO-INC锁、和一种轻量级锁。可以通过innodb_autoinc_lock_mode系统变量来控制,后者的实现会让并发高一点,但可能会造成不同事务中AUTO的列是交叉的,在主从复制的场景中是不安全的。
行级锁
Record Lock
普通的行级锁,也叫记录锁,分为S锁和X锁。
Gap Lock
间隙锁,大致流程是对当前行的前一个间隙加gap锁。
Next-Key Lock
其实就是记录锁+间隙锁,既能保护该条记录,又能阻止别的事务将新纪录插入到被保护记录前面的间隙中。
之前说过MVCC在快照读下可以解决幻读问题,而Next-Key则可以在当前读(锁定读)时解决幻读。
Insert Intention Lock
一个事务在插入一条记录时,需要判断插入位置是否已被别的事务加了gap锁(Next-Key锁中也包含gap锁),此时事务在等待时也会在内存中生成一个锁结构,称为插入意向锁。
对执行语句加锁的情况会受到所在事务的隔离级别、索引类型(聚簇索引、二级索引)、是否精确匹配、是否是唯一性搜索、具体执行的语句类型等情况的制约,简单了解可以看我以前写过的一篇:MySQL for update 用法解析_mysql for update nowait-CSDN博客
死锁
死锁的出现是因为两个事务争抢同一份锁资源,同时又互相占有锁,比如:
发生时间编号 | T1 | T2 |
1 | begin; | |
2 | begin; | |
3 | select * from t where number=1 for update; | |
4 | select * from t where number=3 for update; | |
5 | select * from t where number=3 for update; | |
6 | select * from t where number=1 for update; |
InnoDB有一个死锁检测机制,检测到死锁发生时,会选择一个较小的事务进行回滚(指影响行数较少的),并向客户端发送一条消息。
我们可以通过执行 show engine InnoDB status 语句来查看最近发生的一次死锁信息,之后再考虑如何修复,比如在业务代码中更改T2加锁的顺序,先查询1再查询3。