MySQL知识汇总
1.一条SQL查询语句是如何执行的
MySQL的逻辑架构图
大体来说,MySQL可以分为Server层和存储引擎层两部分。
-
连接器 连接器负责跟客户端建立连接、获取权限、维持和管理连接.
-
查询缓存 语句及其结果可能会以key-value对的形式。查询缓存往往弊大于利。 只要有对一个表的更新,这个表上所有的查询缓存都会被清空。 MySQL 8.0版本直接将查询缓存的整块功能删掉了.
-
分析器 词法分析,语法分析
-
优化器 优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。
-
执行器 举例:select * from T where ID=10; 比如我们这个例子中的表T中,ID字段没有索引,那么执行器的执行流程是这样的:
- 调用InnoDB引擎接口取这个表的第一行,判断ID值是不是10,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。
2. 一条SQL更新语句是如何执行的?
与查询流程不一样的是,更新流程还涉及两个重要的日志模块,它们正是我们今天要讨论的主角:redo log(重做日志)和 binlog(归档日志)。
redo log
在MySQL里也有这个问题,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程IO成本、查找成本都很高。为了解决这个问题,MySQL的设计者就用了类似酒店掌柜粉板的思路来提升更新效率。
而粉板和账本配合的整个过程,其实就是MySQL里经常说到的WAL技术,WAL的全称是Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。
具体来说,当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。 InnoDB的redo log是固定大小的,比如可以配置为一组4个文件,每个文件的大小是1GB,那么这块“粉板”总共就可以记录4GB的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。
bin log
MySQL整体来看,其实就有两块:一块是Server层,它主要做的是MySQL功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的粉板redo log是InnoDB引擎特有的日志,而Server层也有自己的日志,称为binlog(归档日志)。
bin log和redo log几点区别
这两种日志有以下三点不同。
- redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以使用。
- redo log是物理日志,记录的是“在某个数据页上做了什么修改”;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这一行的c字段加1 ”。
- redo log是循环写的,空间固定会用完;binlog是可以追加写入的。“追加写”是指binlog文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
update语句时的内部流程
sql举例:update T set c=c+1 where ID=2;
- 执行器先找引擎取ID=2这一行。ID是主键,引擎直接用树搜索找到这一行。如果ID=2这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
- 执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到redo log里面,此时redo log处于prepare状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器生成这个操作的binlog,并把binlog写入磁盘。
- 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。
问题1. binlog写完,redo log还没commit前发生crash,那崩溃恢复的时候MySQL会怎么处理?
崩溃恢复时的判断规则。
- 如果redo log里面的事务是完整的,也就是已经有了commit标识,则直接提交;
- 如果redo log里面的事务只有完整的prepare,则判断对应的事务binlog是否存在并完整:
- 如果是,则提交事务;
- 否则,回滚事务。
问题2. MySQL怎么知道binlog是完整的?
一个事务的binlog是有完整格式的:
- statement格式的binlog,最后会有COMMIT;
- row格式的binlog,最后会有一个XID event。
问题3. redo log 和 binlog是怎么关联起来的?
它们有一个共同的数据字段,叫XID。崩溃恢复的时候,会按顺序扫描redo log:
- 如果碰到既有prepare、又有commit的redo log,就直接提交;
- 如果碰到只有parepare、而没有commit的redo log,就拿着XID去binlog找对应的事务。
问题4. 正常运行中的实例,数据写入后的最终落盘,是从redo log更新过来的还是从buffer pool更新过来的呢?
这里涉及到了,“redo log里面到底是什么”的问题。
实际上,redo log并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由redo log更新过去”的情况。
- 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与redo log毫无关系。
- 在崩溃恢复场景中,InnoDB如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让redo log更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。
问题5. redo log buffer是什么?是先修改内存,还是先写redo log文件?
在一个事务的更新过程中,日志是要写多次的。比如下面这个事务:
begin; insert into t1 … insert into t2 … commit; 这个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先保存起来,但又不能在还没commit的时候就直接写到redo log文件里。
所以,redo log buffer就是一块内存,用来先存redo日志的。也就是说,在执行第一个insert的时候,数据的内存被修改了,redo log buffer也写入了日志。
但是,真正把日志写到redo log文件(文件名是 ib_logfile+数字),是在执行commit语句的时候做的。
3. “快照”在MVCC里是怎么工作的?
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。
这时,你会说这看上去不太现实啊。如果一个库有100G,那么我启动一个事务,MySQL就要拷贝100G的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。
实际上,我们并不需要拷贝出这100G的数据。我们先来看看这个快照是怎么实现的。
InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。
如图2所示,就是一个记录被多个事务连续更新后的状态。
图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx_id也是25。
你可能会问,前面的文章不是说,语句更新会生成undo log(回滚日志)吗?那么,undo log在哪呢?
实际上,图2中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依次执行U3、U2算出来。
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。
当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。
在实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。
数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
而数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图的对比结果得到的。
这个视图数组把所有的row trx_id 分成了几种不同的情况。
对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:
-
如果落在绿色部分(trx_id小于低水位),表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
-
如果落在红色部分(trx_id大于高水位),表示这个版本是由将来启动的事务生成的,是肯定不可见的;
-
如果落在黄色部分(trx_id在低水位到高水位之间),那就包括两种情况
a. 若 row trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;
b. 若 row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。
对于 3.b 值得说的是,图里未提交事务集合是有序的–低水位到高水位,但是已提交事务的trx_id是有可能有大于低水位的(很好理解,低水位的trx_id虽然先启动的事务,此时还没提交,但后启动的事务即更大的trx_id先提交的,这就会出现),3.b 落在黄色部分只是说trx_id数字在这个范围。
比如,对于图2中的数据来说,如果有一个事务,它的低水位是18,那么当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行的值是11。
你看,有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。
所以你现在知道了,InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
4. 幻读是什么,幻读有什么问题?
幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
这里,我需要对“幻读”做一个说明:
- 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
- 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
幻读有什么问题?
- 首先是语义上的。session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。
- 其次,是数据一致性的问题。binlog去执行时可能导致一致性差异。
我们给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁。
也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。
到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。
接下来,我们再看看InnoDB怎么解决幻读的问题。
如何解决幻读?
举例:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock)。
顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。
当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。
跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。
Session A | Session B |
---|---|
begin; select * from t where c=7 lock in share mode; | |
begin; select * from t where c=7 for update |
这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。
间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。也就是说,我们的表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把next-key lock记为前开后闭区间。
你可能会问说,这个supremum从哪儿来的呢?
这是因为+∞是开区间。实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合我们前面说的“都是前开后闭区间”。
间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。
Session A | Session B |
---|---|
begin; select * from t where id=9 for update; | |
begin; select * from t where id=9 for update; insert into t values (9,9,9); | |
insert into t values (9,9,9); |
-
session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10);
-
session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
-
session B 试图插入一行(9,9,9),被session A的间隙锁挡住了,只好进入等待;
-
session A试图插入一行(9,9,9),被session B的间隙锁挡住了。
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。
间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。
5.加锁的规则
加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。
- 原则1:加锁的基本单位是next-key lock。next-key lock是前开后闭区间。
- 原则2:查找过程中访问到的对象才会加锁。
- 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。
- 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
- 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
依然是上边幻读的初始表。
举例:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
不等号条件里的等值查询
等值查询和“遍历”有什么区别?为什么我们文章的例子里面,where条件是不等号,这个过程里也有等值查询?
我们一起来看下这个例子,分析一下这条查询语句的加锁范围:
begin;
select * from t where id>9 and id<12 order by id desc for update;
利用上面的加锁规则,我们知道这个语句的加锁范围是主键索引上的 (0,5]、(5,10]和(10, 15)。也就是说,id=15这一行,并没有被加上行锁。为什么呢?
我们说加锁单位是next-key lock,都是前开后闭区间,但是这里用到了优化2,即索引上的等值查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁 (10, 15)。
但是,我们的查询语句中where条件是大于号和小于号,这里的“等值查询”又是从哪里来的呢?
要知道,加锁动作是发生在语句执行过程中的,所以你在分析加锁行为的时候,要从索引上的数据结构开始。这里,我再把这个过程拆解一下。
这个表的索引id的示意如下: (0,0,0)(5,5,5)(10,10,10)(15,15,15)(20,20,20)(25,25,25)
- 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个id<12的值”。
- 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只是最终没找到,但找到了(10,15)这个间隙。
- 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到id=5这一行,所以会加一个next-key lock (0,5]。
也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。
等值查询的过程
begin;
select id from t where c in(5,20,10) lock in share mode;
在查找c=5的时候,先锁住了(0,5]。但是因为c不是唯一索引,为了确认还有没有别的记录c=5,就要向右遍历,找到c=10才确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。
同样的,执行c=10这个逻辑的时候,加锁的范围是(5,10] 和 (10,15);执行c=20这个逻辑的时候,加锁的范围是(15,20] 和 (20,25)。
通过这个分析,我们可以知道,这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁。(按索引c的从小到大加锁)
你可能会说,这个加锁范围,不就是从(5,25)中去掉c=15的行锁吗?为什么这么麻烦地分段说呢?
因为我要跟你强调这个过程:这些锁是“在执行过程中一个一个加的”,而不是一次性加上去的。
理解了这个加锁过程之后,我们就可以来分析下面例子中的死锁问题了。
如果同时有另外一个语句,是这么写的:
begin;
select id from t where c in(5,20,10) order by c desc for update;
此时的加锁范围,又是什么呢?
我们现在都知道间隙锁是不互锁的,但是这两条语句都会在索引c上的c=5、10、20这三行记录上加记录锁。
这里你需要注意一下,由于语句里面是order by c desc, 这三个记录锁的加锁顺序,是先锁c=20,然后c=10,最后是c=5。
也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候,就可能出现死锁。
死锁问题查看
Session A | Session B |
---|---|
begin; select id from t where c in(5,20,10) lock in share mode; | |
begin; select id from t where c in(5,20,10) order by c desc for update; |
在出现死锁后,执行show engine innodb status命令得到的部分输出。这个命令会输出很多信息,有一节LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。
我们来看看这图中的几个关键信息。
这个结果分成三部分:
- TRANSACTION,是第一个事务的信息;
- TRANSACTION,是第二个事务的信息;
- WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
第一个事务的信息中:
- WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;
- index c of table
test
.t
,说明在等的是表t的索引c上面的锁; - lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
- Record lock说明这是一个记录锁;
- n_fields 2表示这个记录是两列,也就是字段c和主键字段id;
- 0: len 4; hex 0000000a; asc ;;是第一个字段,也就是c。值是十六进制a,也就是10;
- 1: len 4; hex 0000000a; asc ;;是第二个字段,也就是主键id,值也是10;
- 这两行里面的asc表示的是,接下来要打印出值里面的“可打印字符”,但10不是可打印字符,因此就显示空格。
- 第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。
- 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
第二个事务显示的信息要多一些:
- “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
- index c of table
test
.t
表示锁是在表t的索引c上; - hex 0000000a和hex 00000014表示这个事务持有c=10和c=20这两个记录锁;
- WAITING FOR THIS LOCK TO BE GRANTED,表示在等(c=5,id=5)这个记录锁。
从上面这些信息中,我们就知道:
- “lock in share mode”的这条语句,持有c=5的记录锁,在等c=10的锁;
- “for update”这个语句,持有c=20和c=10的记录锁,在等c=5的记录锁。
因此导致了死锁。这里,我们可以得到两个结论:
- 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
- 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以InnoDB选择了回滚成本更小的lock in share mode语句,来回滚。
锁等待问题查看
Session A | Session B |
---|---|
begin; select * from t where id>10 and id<=15 for update; | |
delete from t where id=10; (Query OK) insert into t values (10,10,10);(Blocked) |
可以看到,由于session A并没有锁住c=10这个记录,所以session B删除id=10这一行是可以的。但是之后,session B再想insert id=10这一行回去就不行了。
现在我们一起看一下此时show engine innodb status的结果,看看能不能给我们一些提示。锁信息是在这个命令输出结果的TRANSACTIONS这一节。
我们来看几个关键信息。
-
index PRIMARY of table
test
.t
,表示这个语句被锁住是因为表t主键上的某个锁。 -
lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
- insert intention表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。
- gap before rec 表示这是一个间隙锁,而不是记录锁。
-
那么这个gap是在哪个记录之前的呢?接下来的0~4这5行的内容就是这个记录的信息。
-
n_fields 5也表示了,这一个记录有5列:
- 0: len 4; hex 0000000f; asc ;;第一列是主键id字段,十六进制f就是id=15。所以,这时我们就知道了,这个间隙就是id=15之前的,因为id=10已经不存在了,它表示的就是(5,15)。
- 1: len 6; hex 000000000513; asc ;;第二列是长度为6字节的事务id,表示最后修改这一行的是trx id为1299的事务。
- 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。可以看到,这里的acs后面有显示内容(%和4),这是因为刚好这个字节是可打印字符。
- 后面两列是c和d的值,都是15。
因此,我们就知道了,由于delete操作把id=10这一行删掉了,原来的两个间隙(5,10)、(10,15)变成了一个(5,15)。
说到这里,你可以联合起来再思考一下这两个现象之间的关联:
- session A执行完select语句后,什么都没做,但它加锁的范围突然“变大”了;
- 当我们执行select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到c=10的时候,要把(5, 10]锁起来。
也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。
再看一个update的例子
Session A | Session B |
---|---|
begin; select * from t where c>5 lock in share mode; | |
update t set c=1 where c=5; (Query OK) update t set c=5 where c=1;(Blocked) |
session A的加锁范围是索引c上的 (5,10]、(10,15]、(15,20]、(20,25]和(25,supremum]。
注意:根据c>5查到的第一个记录是c=10,因此不会加(0,5]这个next-key lock。
之后session B的第一个update语句,要把c=5改成c=1,因此原来锁的(5,10],变成了 (1,10]。这样后边再把c从1改成5就被间隙锁(1,10)堵住了。
6.加锁例子
案例一:等值查询间隙锁
Session A | Session B | Session C |
---|---|---|
begin; update t set d=d+1 where id=7; | ||
insert into t values (8,8,8);(Blocked) | ||
update t set d=d+1 where id=10;(Query OK) |
由于表t中没有id=7的记录,所以用我们上面提到的加锁规则判断一下的话:
- 根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10];
- 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。
所以,session B要往这个间隙里面插入id=8的记录会被锁住,但是session C修改id=10这行是可以的。
案例二:非唯一索引等值锁
Session A | Session B | Session C |
---|---|---|
begin; select id from t where c=5 lock in share mode; | ||
update t set d=d+1 where id=5;(Query OK) | ||
insert into t values (7,7,7);(Blocked) |
这里session A要给索引c上c=5的这一行加上读锁。
- 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock。
- 要注意c是普通索引,因此仅访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock。
- 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)。
- 根据原则2 ,只有访问到的对象才会加锁 ,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。
但session C要插入一个(7,7,7)的记录,就会被session A的间隙锁(5,10)锁住。
需要注意,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update就不一样了。 执行 for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
这个例子说明,【锁是加在索引上的】 【锁是加在索引上的】 【锁是加在索引上的】;同时,它给我们的指导是,如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将session A的查询语句改成select d from t where c=5 lock in share mode。你可以自己验证一下效果。
案例三:主键索引范围锁
第三个例子是关于范围查询的。
举例之前,你可以先思考一下这个问题:对于我们这个表t,下面这两条查询语句,加锁范围相同吗?
mysql> select * from t where id=10 for update; mysql> select * from t where id>=10 and id<11 for update; 你可能会想,id定义为int类型,这两个语句就是等价的吧?其实,它们并不完全等价。
在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让session A执行第二个查询语句,来看看加锁效果。
Session A | Session B | Session C |
---|---|---|
begin; select * from t where id >=10 and id < 11 for update; | ||
insert into t values (8,8,8);(Query OK)insert into t values (13,13,13)(Blocked) | ||
update t set d=d+1 where id=15;(Blocked) |
现在我们就用前面提到的加锁规则,来分析一下session A 会加什么锁呢?
开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。 根据优化1, 主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。
范围查找就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15]。
所以,session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。这样,session B和session C的结果你就能理解了。
这里你需要注意一点,首次session A定位查找id=10的行的时候,是当做等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。
案例四:非唯一索引范围锁
接下来,我们再看两个范围查询加锁的例子,你可以对照着案例三来看。
需要注意的是,与案例三不同的是,案例四中查询语句的where部分用的是字段c。
Session A | Session B | Session C |
---|---|---|
begin; select * from t where c >=10 and c < 11 for update; | ||
insert into t values (8,8,8);(Blocked) | ||
update t set d=d+1 where id=15;(Blocked) |
这次session A用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sesion A加的锁是,索引c上的(5,10] 和(10,15] 这两个next-key lock。
所以从结果上来看,sesson B要插入(8,8,8)的这个insert语句时就被堵住了。
这里需要扫描到c=15才停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要继续往后找了。
案例五:唯一索引范围锁bug
Session A | Session B | Session C |
---|---|---|
begin; select * from t where id >10 and c <=15 for update; | ||
insert into t values (16,16,16);(Blocked) | ||
update t set d=d+1 where id=20;(Blocked) |
session A是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了。
但是实现上,InnoDB会往前扫描到第一个不满足条件的行为止,也就是id=20。而且由于这是个范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上。
所以你看到了,session B要更新id=20这一行,是会被锁住的。同样地,session C要插入id=16的一行,也会被锁住。
案例六:非唯一索引上存在"等值"的例子
接下来的例子,是为了更好地说明“间隙”这个概念。这里,我给表t插入一条新记录。
mysql> insert into t values(30,10,30); 新插入的这一行c=10,也就是说现在表里有两个c=10的行。那么,这时候索引c上的间隙是什么状态了呢?你要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。
Session A | Session B | Session C |
---|---|---|
begin; delete from t where c=10; | ||
insert into t values (12,12,12);(Blocked) | ||
update t set d=d+1 where c=15;(Query OK) |
session A在遍历的时候,先访问第一个c=10的记录。同样地,根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。
然后,session A向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。
案例七:limit 语句加锁
Session A | Session B |
---|---|
begin; delete from t where c=10 limit 2; | |
insert into t values (12,12,12);(Query OK) |
session A的delete语句加了 limit 2。你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B的insert语句执行通过了,跟案例六的结果不同。
这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。
因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间。
这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
案例八:一个死锁的例子
最后我们再看一个案例,目的是说明:next-key lock实际上是间隙锁和行锁加起来的结果。
Session A | Session B |
---|---|
begin; select id from t where c=10 lock in share mode; | |
update t set d=d+1 where c=10;(Blocked) | |
insert into t values (8,8,8); | |
Error 1213 deadlock found |
现在,我们按时间顺序来分析一下为什么是这样的结果。
session A 启动事务后执行查询语句加lock in share mode,在索引c上加了next-key lock(5,10] 和间隙锁(10,15);
session B 的update语句也要在索引c上加next-key lock(5,10] ,进入锁等待;
然后session A要再插入(8,8,8)这一行,被session B的间隙锁锁住。由于出现了死锁,InnoDB让session B回滚。
你可能会问,session B的next-key lock不是还没申请成功吗?
其实是这样的,session B的“加next-key lock(5,10] ”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。
也就是说,我们在分析加锁规则的时候可以用next-key lock来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。
7. “order by”执行流程?
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`city` varchar(16) NOT NULL,
`name` varchar(16) NOT NULL,
`age` int(11) NOT NULL,
`addr` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `city` (`city`)
) ENGINE=InnoDB;
这样的sql查询语句:
select city,name,age from t where city='杭州' order by name limit 1000;
只加city索引
mysql> explain select city,name,age from t where city='杭州' order by name limit 1000;
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+----------------+
| 1 | SIMPLE | t | NULL | ref | city | city | 66 | const | 1 | 100.00 | Using filesort |
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+----------------+
1 row in set, 1 warning (0.00 sec)
Extra字段中的“Using filesort”表示的就是需要排序,MySQL会给每个线程分配一块内存用于排序,称为sort_buffer。
通常情况下,这个语句执行流程如下所示 :
- 初始化sort_buffer,确定放入name、city、age这三个字段;
- 从索引city找到第一个满足city=‘杭州’条件的主键id,也就是图中的ID_X;
- 到主键id索引取出整行,取name、city、age三个字段的值,存入sort_buffer中;
- 从索引city取下一个记录的主键id;
- 重复步骤3、4直到city的值不满足查询条件为止,对应的主键id也就是图中的ID_Y;(所有city条件的都要查出来)
- 对sort_buffer中的数据按照字段name做快速排序;
- 按照排序结果取前1000行返回给客户端。
我们暂且把这个排序过程,称为全字段排序,
“按name排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数sort_buffer_size。
sort_buffer_size,就是MySQL为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。
内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,MySQL将需要排序的数据分成12份,每一份单独排序后存在这些临时文件中。然后把这12个有序文件再合并成一个有序的大文件。
加(city, name)联合索引
只加city索引,要把所有满足city的条件记录全查出来,排序后,再取1000。 为什么要全查出来呢,因为这些记录的name排序是无序的,所以得都查出来。
如果加(city, name)联合索引呢?从B+树索引得知,如果city相同时,name也是有序的。这样只需要按索引顺序取1000即可。
这样整个查询过程的流程就变成了:
- 从索引(city,name)找到第一个满足city=‘杭州’条件的主键id;
- 到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的一部分直接返回;
- 从索引(city,name)取下一个记录主键id;
- 重复步骤2、3,直到查到第1000条记录,或者是不满足city=‘杭州’条件时循环结束。
这里因为select还要查询age,所以还要回表。如果联合索引改成(city, name, age) 覆盖索引,查询性能会更好。
8. in 和order by 怎么导致联合索引失效的?
假设还是联合索引(city, name),问题7中的sql 改成
select * from t where city in (‘杭州’,“苏州”) order by name limit 1000;
那么,这个语句执行的时候会有排序过程吗,为什么?
其实还是会走排序过程,从索引结构中我们知道,city相同时,name是有序的,但city不同时,name就不是全局有序了。因此 in多个时,order by上的索引就失效了。
这种建议把in 拆开读。
9. group by 执行流程
create table t1(id int primary key, a int, b int, index(a));
select id%10 as m, count(*) as c from t1 group by m;
mysql> explain select id%10 as m, count(*) as c from t1 group by m;
+----+-------------+-------+-------+---------------+------+---------+------+------+----------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+---------------+------+---------+------+------+----------------------------------------------+
| 1 | SIMPLE | t1 | index | PRIMARY,a | a | 5 | NULL | 1 | Using index; Using temporary; Using filesort |
+----+-------------+-------+-------+---------------+------+---------+------+------+----------------------------------------------+
1 row in set (0.00 sec)
在Extra字段里面,我们可以看到三个信息:
- Using index,表示这个语句使用了覆盖索引,选择了索引a,不需要回表;
- Using temporary,表示使用了临时表;
- Using filesort,表示需要排序。
这个语句的执行流程是这样的:
- 创建内存临时表,表里有两个字段m和c,主键是m;
- 扫描表t1的索引a,依次取出叶子节点上的id值,计算id%10的结果,记为x;
- 如果临时表中没有主键为x的行,就插入一个记录(x,1);
- 如果表中有主键为x的行,就将x这一行的c值加1;
- 遍历完成后,再根据字段m做排序,得到结果集返回给客户端。
如果你的需求并不需要对结果进行排序,那你可以在SQL语句末尾增加order by null,也就是改成:
select id%10 as m, count(*) as c from t1 group by m order by null;
尽量让group by过程用上表的索引,确认方法是explain结果里没有Using temporary 和 Using filesort;
10. innoDB 单表能支持多少行记录
因为行记录在主键索引上,我们直接看主键索引。两个前提:
- 一行记录假设 1kb
- 3层 B+树
推演
- 主键按照bigint 占8 byte,索引上除了主键还有指针(指向下一级)占 6 byte,等于一个主键+指针 占14 byte
- innoDB 一个页通常是 16kb
- 1 和 2 得知,一个页上有 16k/14 = 1170 个索引节点
- 假设表中的一行数据占用 1kb (具体看生产环境,这里假设),那么一个页就是16 条记录
- B+ 树,我们通常 3 层,再多IO效率影响性能了
- 那么第 1层 1170个索引节点,第二层就是 1170 * 1170 个,第三层记录数就是 1170 * 1170 * 16 = 2200w 左右
11. 主从复制
MySQL是怎么保证数据不丢的
问题1:执行一个update语句以后,我再去执行hexdump命令直接查看ibd文件内容,为什么没有看到数据有改变呢?
回答:这可能是因为WAL机制的原因。update语句执行完成后,InnoDB只保证写完了redo log、内存,可能还没来得及将数据写到磁盘。
问题2:为什么binlog cache是每个线程自己维护的,而redo log buffer是全局共用的?
回答:MySQL这么设计的主要原因是,binlog是不能“被打断的”。一个事务的binlog必须连续写,因此要整个事务完成后,再一起写到文件里。
而redo log并没有这个要求,中间有生成的日志可以写到redo log buffer中。redo log buffer中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。
问题3:事务执行期间,还没到提交阶段,如果发生crash的话,redo log肯定丢了,这会不会导致主备不一致呢?
回答:不会。因为这时候binlog 也还在binlog cache里,没发给备库。crash以后redo log和binlog都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。
MySQL是怎么保证主备一致的?
主备流程图:
备库B跟主库A之间维持了一个长连接。主库A内部有一个线程,专门用于服务备库B的这个长连接。一个事务日志同步的完整过程是这样的:
- 在备库B上通过change master命令,设置主库A的IP、端口、用户名、密码,以及要从哪个位置开始请求binlog,这个位置包含文件名和日志偏移量。
- 在备库B上执行start slave命令,这时候备库会启动两个线程,就是图中的io_thread和sql_thread。其中io_thread负责与主库建立连接。
- 主库A校验完用户名、密码后,开始按照备库B传过来的位置,从本地读取binlog,发给B。
- 备库B拿到binlog后,写到本地文件,称为中转日志(relay log)。
- sql_thread读取中转日志,解析出日志里的命令,并执行。
binlog 三种格式
delete from t /*comment*/ where a>=4 and t_modified<='2018-11-10' limit 1;
-
statement 当binlog_format=statement时,binlog里面记录的就是SQL语句的原文。 当是sql原文时,有风险。比如主库执行一个修改语句走的where条件A索引,可能在备库上执行时用的B索引,那么对该delete语句就是风险的。
-
row 不同于statement格式,row模式不是记录sql语句。会记录操作的什么表,操作了什么字段,字段操作前后的变更是什么。mysql binlog同步给其他中间件时就采用的row方式。
-
mixed,其实它就是前两种格式的混合
MySQL是怎么保证高可用的?
主从延迟
“同步延迟”。与数据同步有关的时间点主要包括以下三个:
- 主库A执行完成一个事务,写入binlog,我们把这个时刻记为T1;
- 之后传给备库B,我们把备库B接收完这个binlog的时刻记为T2;
- 备库B执行完成这个事务,我们把这个时刻记为T3。
所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是T3-T1。
你可以在备库上执行show slave status命令,它的返回结果里面会显示seconds_behind_master,用于表示当前备库延迟了多少秒。
seconds_behind_master的计算方法是这样的:
- 每个事务的binlog 里面都有一个时间字段,用于记录主库上写入的时间;
- 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到seconds_behind_master。
可以看到,其实seconds_behind_master这个参数计算的就是T3-T1。所以,我们可以用seconds_behind_master来作为主备延迟的值,这个值的时间精度是秒。
主从延迟的来源
-
有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。
-
第二种常见的可能了,即备库的压力大。
- 可能备库执行了离线作业sql
-
大事务
- 大事务这种情况很好理解。因为主库上必须等事务执行完成才会写入binlog,再传给备库。所以,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟。
- 大表DDL
12. mysql同步给其他组件如redis、ES
比如怎么把mysql修改后的数据同步给redis组件呢?当然可以进程内操作完数据库,再写缓存。这样可能存在不一致的问题。怎么能保持一致或者最终一致性呢?
可以写完数据库,加一个mq,消费mq后再写缓存。这种也可能存在写mq丢失问题。
目前比较流行的方案是消费binlog,解析binlog,写变更信息。
这里需要伪装成一个MySQL的从节点,从MySQL接收Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新Redis缓存。
开源的Canal组件就是这样的实现方案。
参考
- 《MySQL45讲》
- 《MySQL技术内幕 : InnoDB存储引擎》