MySQL进阶系列(一):从SQL执行过程到深入浅出索引

1.基础架构:一条SQL查询语句是怎么执行的?

​ MySQL可以分为Server层和存储引擎层两部分。

​ Server层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖MySQL的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

​ 而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持InnoDB、MyISAM、Memory等多个存储引擎。现在最常用的存储引擎是InnoDB,它从MySQL 5.5.5版本开始成为了默认存储引擎。

img

补充:

1.数据库连接分为长连接和短连接,意思和我们在网络上学到的差不多,建议使用长连接。如果客户端和数据库之前全部使用长连接后,有时候MySQL占用内存涨的会特别快,这是因为MySQL在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是MySQL异常重启了。解决方法有两个:

  • **定期断开长连接。**使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  • 如果你用的是MySQL 5.7或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

2.之前查询过的语句会以key-value的形式缓存在内存,key是查询的语句,value是查询结果。不推荐使用查询缓存,如果对一个表更新,那么这个表上所有的查询缓存都会被清空。8.0开始彻底没有这个功能了。

3.分析器先会做“词法分析”然后是“语法分析”。词法分析主要是根据select关键字识别出查询语句,然后某个字符串识别成表名以及列名。语法分析判断SQL语句是否满足语法,比如elect少写了开头字母s报错就是语法错误。总结起来分析器就是分析你要干啥的(删除?添加?查询?),并且看看书写是否合理。

4.优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。你可以理解为确定语句的执行方案,解决该怎么做的问题。

5.执行器开始执行的时候,要先判断一下你对这个表T有没有执行查询的权限,如果没有,就会返回没有权限的错误。

​ 如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。

比如我们这个例子中的表T中,ID字段没有索引,那么执行器的执行流程是这样的:

select * from T where ID=10;
  1. 调用InnoDB引擎接口取这个表的第一行,判断ID值是不是10,如果不是则跳过,如果是则将这行存在结果集中;
  2. 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
  3. 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。

至此,这个语句就执行完成了。

2.日志系统:一条SQL更新语句是如何执行的?

​ 与查询流程不一样的是,更新流程还涉及两个重要的日志模块redo log(重做日志)和 binlog(归档日志)

redo log

​ 当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。先写日志,再写磁盘的方式叫做WAL(Write-Ahead Logging)技术。

​ InnoDB的redo log是固定大小的,从头开始写,写到末尾就又回到开头循环写。

img

​ write pos是当前记录的位置,一边写一边后移,写到第3号文件末尾后就回到0号文件开头。checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

​ write pos和checkpoint之间的空间可以用来记录新的操作。如果write pos追上checkpoint,表示没地方写了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把checkpoint推进一下。

​ 有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

binlog

​ redo log是InnoDB引擎特有的日志,而Server层也有自己的日志,称为binlog(归档日志)。

这两种日志有以下三点不同。

  1. redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以使用。
  2. redo log是物理日志,记录的是“在某个数据页上做了什么修改”;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这一行的c字段加1 ”。
  3. 两种日志与记录写入磁盘的时间点不同,binlog只在事务提交完成后进行一次写入。而innodb存储引擎的redo log在事务进行中不断地被写入,日志不是随事务提交的顺序进行写入的。
  4. redo log是循环写的,空间固定会用完;binlog是可以追加写入的。“追加写”是指binlog文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

现在有这样一张表和一条update语句:

create table T(ID int primary key, c int);
update T set c=c+1 where ID=2;

​ 给出这个update语句的执行流程图,图中浅色框表示是在InnoDB内部执行的,深色框表示是在执行器中执行的

img

3.事务隔离:为什么你改了我还看不见?

​ 简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在MySQL中,事务支持是在引擎层实现的。但并不是所有的引擎都支持事务。比如MyISAM引擎就不支持事务,这也是MyISAM被InnoDB取代的重要原因之一。

隔离性和隔离级别

​ SQL标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面逐一解释:

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。

  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。

  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。

  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

    以下面这个图为例:

mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);

img

  • 若隔离级别是“读未提交”, 则V1的值就是2。这时候事务B虽然还没有提交,但是结果已经被A看到了。因此,V2、V3也都是2。
  • 若隔离级别是“读提交”,则V1是1,V2的值是2。事务B的更新在提交后才能被A看到。所以, V3的值也是2。
  • 若隔离级别是“可重复读”,则V1、V2是1,V3是2。之所以V2还是1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
  • 若隔离级别是“串行化”,则在事务B执行“将1改成2”的时候,会被锁住。直到事务A提交后,事务B才可以继续执行。所以从A的角度看, V1、V2值是1,V3的值是2。

​ 在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

事务隔离的实现

​ 在MySQL中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

​ 假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。

img

​ 当前值是4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的read-view。如图中看到的,在视图A、B、C里面,这一个记录的值分别是1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。

事务的启动方式

​ 当系统里没有比这个回滚日志更早的read-view的时候,回滚日志就可以删除了,如果是长事务的话,那么系统里可能会有很老的事务视图,这会占据大量空间,所以不推荐使用长事务。

​ MySQL事务启动方式:

  1. 显式启动事务语句, begin 或 start transaction。配套的提交语句是commit,回滚语句是rollback。

  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个select语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行commit 或 rollback 语句,或者断开连接。

    建议你总是使用set autocommit=1, 通过显式语句的方式来启动事务。

4.深入浅出索引(上)

索引的常见模型

​ 3种比较常见且简单的数据结构是哈希表、有序数组的搜索树。

  • 哈希表适用于只有等值查询的场景,比如Memcached及其他一些NoSQL引擎。如果做区间查询,那速度很慢,因为 k e y key key的存储不是顺序递增的。
  • 有序数组在等值查询和范围查询场景中的性能就都非常优秀,但是只适合静态存储引擎,比如保存一些不会再修改的数据,因为更新数据(在中间插入一个记录)成本太高。
  • 二叉搜索树的平均复杂度是 O ( l o g ( N ) ) O(log(N)) O(log(N)),如果要维护这个复杂度,就得保持这是个平衡二叉树,因此付出的更新代价(时间复杂度)也是 O ( l o g ( N ) ) O(log(N)) O(log(N)),那这就得不停维护了,显然磁盘经不起这么折腾。可以使用N叉树,“N叉”树中的“N”取决于数据块的大小。以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

InnoDB 的索引模型

​ 在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面我们提到的,InnoDB使用了B+树索引模型,所以数据都是存储在B+树中的。

​ 根据叶子节点的内容,索引类型分为主键索引和非主键索引。主键索引的叶子节点存的是整行数据。在InnoDB里,主键索引也被称为聚簇索引(clustered index)。非主键索引的叶子节点内容是主键的值。在InnoDB里,非主键索引也被称为二级索引(secondary index)。

img

mysql> create table T(
id int primary key, 
k int not null, 
name varchar(16),
index (k))engine=InnoDB;

​ R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6),上面这幅图中,左侧是主键索引,右侧是非主键索引。

基于主键索引和普通索引的查询有什么区别?

  • 如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树,搜索一次
  • 如果语句是select * from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。搜索两次

索引维护

​ 新值的插入/删除可能会涉及到页的分裂或合并,具体的分裂/合并算法可以参考《MySQL技术内幕:InnoDB存储引擎》的第五章。

​ 分裂/合并影响数据页的利用率和性能,如果使用自增主键的插入数据模式,每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。

​ 有时候我们选择业务逻辑字段做主键(无法保证有序插入),这时候成本可能较高,比如说身份证做主键,那么非聚集索引的叶子节点约占用20字节(叶子节点存储的是主键),但如果是整型做主键只需要4字节就完事了,显然整型做主键会节约一些空间。所以一般来说自增主键往往比较合理。

​ 如果业务要求只有一个索引且必须是唯一索引的情况下,那就用业务字段做主键。

5.深入浅出索引(下)

覆盖索引

​ 之前我们提到过,如果根据非聚集索引来查找信息的话,会发生回表事件(回到主键索引树搜索的过程就是回表),因为查询结果所需要的数据只在主键索引(叶子节点存储一行的数据)上有,所以必须得回表,那么我们使用覆盖索引,可以直接提供查询结果,那就不需要回表了。比如说

select ID from T where k between 3 and 5;
#建表语句同上一节的例子

​ 索引 k k k已经覆盖了我们的查询需求,称之为覆盖索引,因为id的值已经在 k k k索引树上了。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

​ 不过需要注意的是,使用覆盖索引和不使用覆盖索引其实都是读了三个记录,k=3、k=5和k=6的情况,不过最终Server层拿到的记录只有两条,所以扫描行数是2。

img

最左前缀原则

​ **B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。**假设目前我们有张市民表:

CREATE TABLE `tuser` (
  `id` int(11) NOT NULL,
  `id_card` varchar(32) DEFAULT NULL,
  `name` varchar(32) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `ismale` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `id_card` (`id_card`),
  KEY `name_age` (`name`,`age`)#联合索引
) ENGINE=InnoDB

​ 用(name,age)这个联合索引来分析。

img

​ 可以看到,索引项是按照索引定义里面出现的字段顺序排序的。

​ 当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。如果你要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是"where name like ‘张%’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。

​ 可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符

在建立联合索引的时候,如何安排索引内的字段顺序:

  • 第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
  • 其次需要考虑空间。

索引下推

​ 如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。那么,SQL语句是这么写的:

mysql> select * from tuser where name like '张%' and age=10 and ismale=1;

​ 在MySQL 5.6之前,只能从ID3开始一个个回表。到主键索引上找出数据行,再对比字段值。

img

​ 在这个图中没有标出来 a g e age age的原因是在于,本过程中InnoDB根本不会去看 a g e age age的值,知识按顺序取出来name第一个字是’张’的一条条记录然后回表,所以需要回表4次。

​ 而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

img

​ 很显然,索引下推时是考虑了 a g e age age的,对于不符合 a g e = 10 age=10 age=10的记录全部跳过了,对于符合的才进行回表,一共回表了2次。

参考

1.《MySQL技术内幕:InnoDB存储引擎》

2.MySQL实战45讲

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值