再学MySQL

Posted Shi Peng

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了再学MySQL相关的知识,希望对你有一定的参考价值。

一、mysql的架构


从MySQL架构来说,MySQL分为Server层和存储引擎层2层。

Server层包括:连接器、查询缓存、分析器、优化器、执行器。
内置函数、存储过程、触发器、视图等跨存储引擎的功能,都在Server层实现。

Server层通过固定的API来访问存储引擎层,各种不同的存储引擎就好比OSS,COS, MinIO等,都兼容了AWS S3的API。

二、redo Log、bin log与两阶段提交

redo log就是WAL(Write-Ahead Logging)日志,其实现是通过环形链表(这个实现与redis主从同步的binlog相同,都是用环形链表):

redo log是用于当数据库发生重启,之前收到的请求但还没有存储的记录不会丢失,这个能力叫做crash-safe,redo log是InnoDB存储引擎所特有的。

server层也有类似的日志,叫做bin log。bin log所有存储引擎都支持。

redo log和bin log的区别:
1)redo log是InnoDB存储引擎特有的;而bin log在server层实现的。
2)redo log是环形链表,而bin log是追加写方式实现

看 “update T set c=c+1 where id=2” 这条SQL做了什么?

把redo log拆成2个步骤:prepare和commit,这个过程就是两阶段提交。
为什么要两阶段提交呢?
如果不用两阶段提交,redo log和bin log在一个文件写成功,另一个文件没有写成功之前,数据库crash,这时会导致这两个文件内容不一致。通过两阶段提交来保证事务的一致性。

三、事务隔离

事务:就是要保证一组数据库操作,要么全部成功,要么全部失败。
在MySQL中,事务是在存储引擎层实现的。

事务隔离行,就是指ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)中的C。

3.1、四种事务隔离级别的区别

SQL 标准的事务隔离级别包括:

  • 读未提交(read uncommitted) :一个事务还没提交时,它做的变更就能被别的事务看到
  • 读提交(read committed) :一个事务提交之后,它做的变更才会被其他事务看到
  • 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化(serializable ):顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

下面举例说明“读提交”和“可重复读”的区别:
有一个表T,其字段C的值为1:
create table T(c int) engine=InnoDB;
insert into T© values(1);

接下来,事务A和事务B执行两个语句:

接下来,我们看下不同事务隔离级别,其事务A的V1,V2,V3分别得到什么值?
1)读未提交:V1=V2=V3=2。对于V1,虽然事务B还没有提交,但修改结果已经被事务A看到了。
2)读提交:V1=1,V2=V3=2。对于V1,在事务B提交前,事务A是看不到结果的。
3)可重复读:V1=V2=1, V3=2。可重复读遵循的原则:在事务启动到事务提交之前,看到的数据是一致的,所以V2=1。
4)串行化:V1=V2=1,V3=2。在事务执行把C的1改为2时,会被锁住,等待事务A提交,所以等事务A提交后,事务B才能把1改成2.

3.2、可重复读隔离级别

对于可重复读事务隔离级别,事务T在启动时,会创建一个试图read-view。这样,在事务执行期间,即使有其他事务修改了数据,事务T看到的数据仍然不变。但是,当事务T要更新一行,此时,另一个事务正在更新这行,即另一个事务正在持有这个行的行锁,那么事务T不得不进入等待锁的状态,那么,等到另一个事务释放行锁,而事务T读取到数据时,读到的值是什么呢?

下面举例说明:

CREATE TABLE `t` 
( `id` int(11) NOT NULL, 
`k` int(11) DEFAULT NULL, 
PRIMARY KEY (`id`)) ENGINE=InnoDB;

insert into t(id, k) values(1,1),(2,2);


这里,我们需要注意的是事务的启动时机。

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

在这个例子中,事务 C 没有显式地使用 begin/commit,表示这个 update 语句本身就是一个事务,语句完成的时候会自动提交。事务 B 在更新了行之后查询 ; 事务 A 在一个只读事务中查询,并且时间顺序上是在事务 B 的查询之后。

这时,如果我告诉你事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1,你是不是感觉有点晕呢?
A的k是1好理解,因为是可重复读;那么B为什么k是3呢?B事务开始时读到的k=1,然后他自己+1然后提交,此时k=2;然后呢,C的更改对B就可见了,因为他提交了,所以k变成了3。

3.2.1、快照读 & 当前读

对于上述过程,先要搞清“快照”在MVCC里面是怎样工作的?
在可重复读级别,事务在启动时,就相当于“拍了个快照”。这个快照是基于整个库的。

但是,如果整个库有100G,快照难到要copy出100G?肯定不是的,那么快照怎么实现的呢?

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

下图表示了一个记录被多个事务连续更新后的状态:

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

语句更新会生成 undo log(回滚日志)。那么,undo log 在哪呢?
上图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义那个“100G”的快照的。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

对于上面的例子,事务B最后k为什么是3,是因为事务C在执行k=k+1的update时,是先读了事务B的k=2, 然后才执行update。这样,我们得出结论:
更新数据都是先读后写的,而这个读,只能读当前的值,称为**“当前读”(current read)**。

再进一步,如果事务C并不是马上提交的,而是后面手动提交的,会怎样?

这时,会遵循“两阶段提交”:

事务C在执行set k=k+1时,生成了版本102,但是他没有提交。之后呢,事务B在还行set k=k+1这个update时,必须是“当前读”,而此时他需要等待事务C commit完释放行锁后,才能进行当前读。
所以,对于上述场景,事务B最终查询结果仍是3,但他的流程不同:先是等待事务C释放锁,即事务C先把k改为2,然后事务B做当前读,获得k=2后再set k=k+1, 得到k=3。

这样,我们把一致性读、当前读和行锁就串起来了:
然后我们总结可重复读是怎样实现的:
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

读提交和可重复读的区别是:
1)在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
2)在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

3.2.2、幻读

看个例子:

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);

id是表t的主键索引,c是普通索引。

接下来执行下面的语句:

begin;
select * from t where d=5 for update;
commit;

加的什么锁,然后锁怎样释放的?

在id=5这行,加的是写锁,由于两阶段提交协议,这个写锁会在commit的时候被释放。由于d字段上无索引,所以会被全表扫描,那么在d不等于5的记录,是否会被加锁呢?

带着这个问题,我们看幻读是什么?
假设我们只对d=5这行加锁,其他行不加锁,会发生什么?

session A 里执行了三次查询,分别是 Q1、Q2 和 Q3。它们的 SQL 语句相同,都是 select * from t where d=5 for update,使用的是当前读,并且加上写锁。

1)Q1:只返回 id=5 这一行;
2)Q2:在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
3)Q3:在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。

其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

幻读需要注意的是:
1)在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
2)上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”

如何解决幻读?
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。

顾名思义,间隙锁,锁的就是两个值之间的空隙。
例如,开始时我们插入了6条记录,就产生了7个空隙:

这样,当你执行 select * from t for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。

也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

间隙锁和行锁合称 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 的引入,帮我们解决了幻读的问题,但同时可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。

四、索引

4.1、hash索引和btree+索引的适用场景

hash索引:数组+链表构成,适用于等值查询。
btree+索引:适用于范围查询

4.2、主键索引和非主键索引的区别:回表

在MySQL中,索引是在存储引擎层实现。在InnoDB中,索引采用BTree+实现。

下面举例说明btree+索引:
create table T(id int primary key, k int not null, name varchar(16),index (k))engine=InnoDB;

数据库表T,其id字段为主键,并在k字段上创建了索引。
然后分别对id和k做如下写入:
(100,1)、(200,2)、(300,3)、(500,5) 、 (600,6)、 (700,7)

由于我们有2个索引:id主键索引,和字段k的索引,所以有两个bree+,分别为:

可以看出,主键索引和非主键索引,其叶子节点存储的数据不同:
1)主键索引的叶子节点,存储的是整行真正的数据,所以主键索引也被称为聚簇索引。
2)非主键索引的叶子节点,存储的是主键的内容。

那么,主键索引和非主键索引,在查询时的区别是什么?
1)如果通过主键索引查询,如select from T where ID=500,那么只需要查左边ID的这个索引树即可。
2)如果非主键索引查询,如select * from T where k=5,则先搜索右边的k索引树,查到id=500, 然后再去查左边的id索引树。这种先查k索引树,再查id索引树的方式,称为回表

从上述过程可得出结论:
主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
所以从性能和空间考虑,自增主键是个不错的选择。

什么场景业务字段适合直接做主键呢?
只有一个索引。

需要注意的一点:重建主键索引,也就是对表数据重新构建的过程,代价很高。

4.3、页分裂

从上面id索引树的叶子节点可以看到,其有两个“页”,每个“页”的容量是有最大限制的,当数据页被写满后,根据btree+的算法,需要申请一个新的数据页,然后挪动部分数据过去,这个过程叫做“页分裂”,页分裂的过程会影响性能。
当页中的数据被删除后,也会有“页合并”,是页分裂的逆过程。

4.4、覆盖索引

思考:执行 select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行?

create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

还是一个主键索引id, 一个普通索引字段k:

这条SQL “select * from T where k between 3 and 5”的执行过程如下:
Step1: 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
Step2: 再到 ID 索引树查到 ID=300 对应的 R3;
Step3:在 k 索引树取下一个值 k=5,取得 ID=500;
Step4:再回到 ID 索引树查到 ID=500 对应的 R4;
Step5:在 k 索引树取下一个值 k=6,不满足条件,循环结束。

可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有可能经过索引优化,避免回表过程呢?
答:覆盖索引。
如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。

基于覆盖索引,我们思考这样的场景:在一个市民信息表上,是否有必要将身份证号和名字建立联合索引?
市民表定义如下:

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

如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。

当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。

总结:覆盖索引,就是把要查询的结果作为索引,好处是节省了回表的步骤。缺点是增加了索引,也就增加了系统资源消耗,也不是索引占用了内存。

4.5、最左前缀原则

对于上述覆盖索引的例子,有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。如果我现在要按照市民的身份证号去查他的家庭地址呢?
btree+,可以利用索引前缀,来定位记录。
我们用(name,age)这个联合索引来分析:

可以看到,索引项是按照索引定义里面出现的字段顺序排序的。当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到 ID4,然后向后遍历得到所有需要的结果。

如果你要查的是所有名字第一个字是“张”的人,你的 SQL 语句的条件是"where name like ‘张 %’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。

4.6、索引下推

我们还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。那么,SQL 语句是这么写的:

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

在 MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

下面看5.6版本前后的对比图:
1)无索引前缀下推:需要回表

2)有索引前缀下推:

五、MySQL的锁

根据加锁的范围,MySQL的锁分为3类:全局锁、表级锁、行锁。

5.1、全局锁

全局锁就是对整个数据库实例加锁。
加全局锁命令是 Flush tables with read lock (FTWRL)
当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。

5.2、表级锁

表级锁分两类:表锁 和 元数据锁

表锁的语法是 lock tables … read/write

举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。线程A连写 t1 都不允许,自然也不能访问其他表。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

注意:不能随便给表加字段,因为会加MDL,会block所有的变更,可能会引起线上问题。所以,在加新的字段时,要加上执行的超时时间。

5.3、行锁

MySQL的行锁,是由各存储引擎层实现的。InnoDB是支持行锁的,这是InnoDB相比MyISAM强的主要原因之一。

行锁就是针对数据表中行记录的锁,如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。

从两阶段锁说起:
还是上面的例子,表t 有字段id作为主键索引,事务B执行update语句时会怎样?

这个问题的结论取决于事务 A 在执行完两条 update 语句后,持有哪些锁,以及在什么时候释放。你可以验证一下:实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。

事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

所以我们要注意:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

5.4、死锁和死锁检测

死锁的例子:

事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。

当出现死锁以后,有两种策略:
策略1:直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
策略2:发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

由于死锁超时时间默认50s, 时间太久,设置时间短了会出现误伤,因为可能不是死锁,只是普通的锁等待。所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。

以上是关于再学MySQL的主要内容,如果未能解决你的问题,请参考以下文章

再学MySQL

再学ajax--第二天 | 基于php+mysql+ajax的表单注册登录注销

mysql间隙锁

mysql间隙锁

mysql间隙锁 转

mysql 间隙锁和临键锁原理