MySQL InnoDB引擎是如何平衡并发时的性能和数据安全性的

前言

自从处理器从单核向多核扩展,如何利用所有的核心一起工作就成了关键。如果程序还是单线程,就会造成非常大的资源浪费,假如一个CPU有100个核心,就浪费掉了99%的资源,所以现代程序必须是多线程并行的。凡事有利必有弊,使用多线程就会导致非常复杂的并发安全性问题,在数据库中就体现为脏读不可重复读幻读。在了解这些问题的实质之前,我们需要先了解事务,因为Innodb引擎的基本执行单元是事务,这些问题大多都与事务有关。

同时,解决并发问题势必会引入一些串行流程,根据阿姆达尔定律,程序必须能使串行部分最小化,才能让程序更具有可伸缩性,所以Innodb不仅处理了并发问题,还考虑了性能问题。

一、什么是事务?

Innodb是支持事务的。可能大家会觉得,数据库不都是支持事务的吗?其实不然,因为MySQL默认使用的是Innodb引擎,很久之前的MyISAM引擎就不支持。事务的出现主要也是因为程序执行的正确性问题,我们考虑程序能否正确运行,不仅仅是考虑在正常情况是否正常,还有异常情况下,比如服务器崩溃、机房断网等等。特别是金融行业,如果出现这种情况导致银行凭空消失了一笔钱,那将是致命缺陷,考虑下面的例子:

现在举一个银行最常见的例子:客户转账,客户A想给客户B转1000块钱,正常的流程是这样的,先从客户A的账户中扣除1000元,然后再给客户B增加1000元,代码如下:

1 UPDATE account SET balance = balance - 1000 WHERE user_name = "A";
2 UPDATE account SET balance = balance + 1000 WHERE user_name = "B";

正常情况下是没问题的,但假如刚执行完第1句数据库突然崩溃了,然后经过机房人员的努力再重启时,发现A账户莫名少了1000元,但B账户并没有增加1000元,这就很尴尬了。
上面这种情况只是事务要解决的问题之一,还有各种各样的问题,所以如果一个数据库或者存储引擎支持事务,那么我们肯定会优先选择有事务的。

1. InnoDB的基本执行单元是事务

大家可能会疑问,平时执行SQL语句不都是一条一条执行的吗,没有显示的开启事务啊。这是因为Innodb有一个自动事务机制,默认给每条SQL语句都套上了一个事务,执行成功就提交了,这个对于我们来说是透明的,所以造成了大家以为没有开启事务的假象。
还有一种显示事务就是用户手动开启、提交或回滚,如下所示:

BEGIN;
UPDATE tableA SET name = "B" WHERE name = "A";
UPDATE tableB SET name = "A" WHERE name = "B";
COMMIT; 
#或者
ROLLBACK; 

一般在实际开发中我们都会使用Spring的声明式事务注解@Transactional来使用事务。

关于事务的4大特征和4种隔离级别可能广大的程序员朋友已经记得滚瓜烂熟了,那这里我们会一起复习一下,同时还会介绍4种隔离级别中可能还会伴随的并发问题。

2. 事务的4大特征

2.1 原子性

原子性在操作系统指令中指的是一条指令要么执行成功要么不成功,不会出现成功一半失败一半的情况。但是只保证单个指令的原子性是不够的,只要一个过程使用了两条及以上的指令,就没法保证原子性了。

而事务的原子性指的是在事务中的每条语句是一个整体,事务的成功和每条语句都成功是充要条件,事务的失败和至少一条语句失败是充要条件。

2.2 一致性

一致性也是说事务操作的数据要么是失败引起回滚而回到受此事务影响的之前的状态,要么是成功变成受此事务影响的之后的状态。而永远不会是中间的状态,比如下面这个事务:

1 BEGIN;
2 UPDATE tableA SET status = 0 WHERE id BETWEEN 0 AND 100;
3 COMMIT;

假如这个事务执行前id为[0,100]的记录中的status为1,那么即使第2行执行到一半程序宕机了,再重启时就会回到这个事务之前的状态,而不是0-50变成0,50-100还是1。如果执行成功,那么就都会变成0,事务中有多条语句时,也同样如此。

由此看来一致性跟原子性是兮兮相关的,如果没有原子性,一致性就得不到保障,如果没有一致性,原子性也得不到保障。

2.3 持久性

持久性就是指被事务影响的数据在提交后,即使突然宕机,也能够持久化到磁盘里,而不是丢掉。这个就是Innodb的redo log保障的了,虽然Innodb不是实时落盘而是先写入到内存中,然后定期刷盘的。但在事务中执行的每个操作都会先写入redo log,并且是顺序写入的,性能非常高,所以即使中途宕机,重启时也能够从redo log中恢复出来。请注意,redo log并不能保证100%地恢复所有数据,因为内存刷入磁盘默认是1秒钟一次,那么在宕机前一秒地数据还是有可能丢失的。

像MyISAM引擎就没有那么好了,它重启恢复需要非常久的时间,或者直接无法恢复。

2.4 隔离性

隔离性是指事务跟事务之间没有关联,互不影响,都是独立的。当然有时候两个事务可能会修改一块共同的数据,那么这时候锁机制就起作用了。在读未提交以及读已提交隔离级别中,一个事务在执行过程中可能会看到其他事务修改的数据,所以隔离性并不是绝对的。

3. 事务的4种隔离级别以及伴随的并发问题

3.1 读未提交

读未提交指的是,一个事务能够读到其他事务正在修改还没提交的数据。这个隔离级别下会导致脏读问题,脏读是指当前事务读取到了其他事务修改后还没有提交的数据,这里的脏就是见不得台面的意思。这种情形的例子如下图所示:
在这里插入图片描述
在时间处于2时,事务A读到的name还是赵四,结果到4时,name变成李三,这是因为事务B在3时修改了这条记录,即使在5时事务B回滚了这条操作,但还是影响到了事务A。

3.1 读已提交

很多人可能会觉得读未提交导致的脏读问题无法忍受,所以就会有个读已提交级别。这个级别不会再读到其他事务未提交的赃数据,但是同样会导致另一个并发问题:不可重复读。不可重复读是指在当前事务中,第一次读取和第二次读取到的数据不一致,原因是读到了其他事务提交的数据。这跟脏读有点类似,但至少是已提交的数据。这种情形如下图所示:
在这里插入图片描述

3.1 可重复读

可重复读是指每次读取到的数据都是一致的,不会出现读未提交和读已提交那样的情况。但是也不是百分百的,Innodb实现可重复读是由MVCC机制和锁机制来解决的,其中锁机制如果控制不当也会产生另一个不可重复读问题,即幻读问题,幻读是指当一个事务在查询某个范围的数据时,另一个事务往这个范围里插入了新的数据。

可重复读的性能低于读已提交,所以有些互联网公司觉得可以忍受不可重复读问题,就直接使用了读已提交级别。

3.1 串行

串行级别就是指事务不能并发执行,只能串行(读写都加锁),这种级别没有并发安全问题,故而性能也最低,一般情况下不会使用。

4. MVCC

在事务的篇章中有提到MVCC,MVCC的全称是多版本并发控制(Multi-version concurrency control)。这是一种在不可重复读级别和读已提交级别下能部分解决不可重复读问题,又能提升并发读取性能的机制,其思想类似于写时复制。它的基本思想如下:每个事务都能读取到小于等于当前事务的数据,并且是不加锁的读取。

我们可以针对以下4种DML语句来分析其实现原理:

  • UPDATE
    修改在直观上理解,肯定是直接修改原来的记录,但是在MVCC里,每次修改都是新增一条记录,而不是修改原来的记录。每个事务都有一个相对应的事务版本号,这个事务版本号永远是递增的,新创建的事务肯定比以前的事务的版本号要大。MVCC给每行记录都隐式地增加了一个事务版本号和一个删除的事务版本号,修改的时候把新记录的事务版本号定为当前事务的版本号,把老记录的删除版本号也定义为当前事务的版本号。

  • INSERT
    增加因为没有以前的记录的概念,所以就是简单地新加一条记录,并且事务版本号是当前事务的版本号。

  • DELETE
    删除时会把记录的删除版本号定为当前事务版本号。

  • SELECT
    MVCC在UPDATE、INSERT、DELETE上的操作都是为SELECT语句服务的。在执行SELECT语句时,读取到的是满足下面两个条件的记录:
    1.行事务版本号小于等于当前事务的事务版本号,这代表着,这一行要么是之前添加的,要么是当前事务添加的。
    2.删除事务版本号要么为空,要么大于当前事务的事务版本号,因为为空代表着未删除,大于当前事务代表着是后面的事务删除的。

4.1 快照读和当前读

我们平时所使用的普通的SELECT语句叫做快照读,什么意思呢,就像上面所定义的那样,快照读读到的都是一个快照,并不是“最新的”数据,这样就会导致一些问题,如果只有快照读的话,像UPDATE语句,修改的是快照,那么其实对于后面的事务来说,是不可见的,因为后面的事务读取的是最新的数据。

所以相对于快照读,还有一个当前读的概念,UPDATE、INSERT、DELETE就是当前读,也就是说,它们是对最新的数据的更改,而不是快照。那么这就回到了之前的问题,在一个事务的两次UPDATE中,其他事务对同样的记录进行了更改,那么就会导致不可重复读问题,UPDATE可能就会产生不可预知的结果,这个时候就需要锁机制来解决了。

5. 锁机制

Innodb的锁对于我们来说是透明的,在平常开发过程中可能注意不到,但是这个精密且复杂的锁机制帮我们解决了很多的并发问题,它的加锁跟查询条件有关,跟索引有关,甚至跟Mysql版本也有关。接下来让我们一起来分析一下Innodb各种锁的类型和加锁的时机。

5.1 基本分类-共享锁和排他锁、隐式锁和显示锁

最上层的分类是共享锁和排他锁

  • 共享锁
    共享锁必须显示地使用,关键字是 SELECT * FROM tableA FOR SHARE MODE,共享锁和共享锁之间是不排斥的。
  • 排他锁
    排他锁是一般的锁类型,从字面意思可以理解,排他锁是独占的意思,排他锁比共享锁有更高的优先级,当前面有很多共享锁时,非常后面的一个排他锁也会插队到最前面来。显示地加排他锁的关键字是SELECT * FROM tableA FOR UPDATE,下面列出了排他锁和共享锁的排斥关系:
共享锁排他锁
共享锁X
排他锁XX

通过关键字主动加锁的叫显示锁,没有显示地加锁的就是隐式锁。下面即将要讲的锁都是隐式锁,对用户是透明的。

5.2 锁的粒度

为了在加锁解决并发安全的同时还考虑性能问题,Innodb将锁分成了很多粒度,以尽可能地减少导致数据库可伸缩性差的串行部分。

5.2.1 表锁

表锁是锁的粒度中最大的一个,也是性能最差的一个。当一个表被加上了表锁时,除了快照读,其他的DDL语句、修改语句都将被阻塞。

表锁一般是在进行DDL语句时被持有的,比如在ALTER TABLE时。ALTER TABLE通常占用时间比较长,如果当前表是多写热点表,那么在用户访问高峰期时就会导致大量超时发生,应当避免在高峰期使这种表发生长时间持有表锁的情况。

还有一种情况会导致持有表锁(这是一种假表锁,实际上是给每条记录以及间隙加上了下面即将要将的行锁和间隙锁),那就是SQL语句的查询条件没有匹配到任何索引的时候,MySQL5.6以及更小的版本会出现表锁的情况,在5.7以上就只会锁查询条件匹配的记录了,即下面即将介绍的行锁。

5.2.2 行锁

行锁(Record Lock)是锁的粒度中最小的一个,也是性能最好的一个。当一条记录被加了行锁时,这意味着除了当前事务,其他事务在当前事务未提交前不能获得这条记录的行锁。这里引申出来一个要点:锁的生命周期是跟事务绑定的,在事务中的某条语句获取锁之后,只有在事务提交或回滚时才会释放

既然还存在更粗粒度的锁,那么只想使用到行锁肯定是有一些限制条件的,前面说过,使用锁的时机跟索引、查询条件有关,那么现在我们就基于这两个条件来分析只想使用行锁的必要条件。

  1. SQL语句中的查询条件必须是等值查询,如=IN
    样例如下:
    SELECT * FROM tableA WHERE id = 3;
    SELECT * FROM tableA WHERE id IN(1,2,3);
    
  2. 查询条件所匹配的字段必须有索引,并且是聚簇索引或者唯一索引
    下面分情况来讨论下不满足上述条件会发生什么。
    • 没有匹配到索引
      就像在介绍表锁时说的,如果没有匹配到索引,可能会将表中所有的记录加行锁以及间隙锁,造成一种加了表锁的假象,也可能是锁住查询条件匹配的记录。为什么能够锁住查询条件匹配的记录呢?因为每个表即使不创建索引也会有一个聚簇索引存在,如果有id列,那么InnoDB就把这个id列设为主键来创建聚簇索引,如果没有id,就隐式地创建一个id。就像这条SQL语句一样SELECT * FROM tableA WHERE name = '张三';,假如name列没有索引,并且name=‘张三’的记录有N行,那么这N行就会依据聚簇索引而锁住。
    • 非唯一索引
      这种情况留到下面来讨论

5.2.3 间隙锁加行锁

行锁能够解决当前读中一部分不可重复读的问题,但没有解决幻读的问题,因此仅仅加行锁是不够的,还要有一个间隙锁(Gap Lock)存在。当SQL语句中有范围条件时,就需要使用到间隙锁。上面说过只想使用行锁时,查询条件必须是等值条件,但是行锁不仅仅可以在等值条件下去使用,还可以在范围条件下使用,它可以锁住N条记录,只要这些记录满足SQL语句定义的条件。

所以很多时候行锁跟间隙锁是成对出现的,比如下面这个例子:
tableA表的id列是主键,name列上没有索引,有如下这些记录:

idname
1张三
2李四
3赵五
4刘六
6邓七

执行查询语句:SELECT * FROM tableA WHERE id >= 4;时,会得到这个结果:

idname
4刘六
6邓七

假如现在只加行锁,那么id = 4或6的两条记录就会被加锁,因为它们满足id>=4的条件。但这样会出现一个问题,另一个事务可以往4和6的中间插入一个id=5的记录,那么当另一个事务插入完成当前事务再执行这条查询语句时,会出现下面这种结果:

idname
4刘六
5杨五
6邓七

这里就导致幻读的发生,所以间隙锁就是解决这个问题的,间隙锁会给(4,6)、(6, ∞ \infty )两个区间加上间隙锁,当其他事务尝试在这个区间内插入记录时会被阻塞,这样就解决掉了幻读问题。

间隙锁和行锁的组合有一个专门的名字叫Next-Key Lock(中文不知道怎么翻译,下一键锁?)。Next-Key Lock在不同的条件下会加不同的锁,现在我们给tableA表增加一个group_id字段,并且加上一个非唯一索引,记录如下:

idgroup_idname
11张三
21李四
32赵五
43刘六
63邓七

当有查询语句:SELECT * FROM tableA WHERE group_id >= 1;时,除了现有表中的每条记录被加行锁,还会给这些区间加间隙锁:(- ∞ \infty ,1]、(1,2]、(2,3]、(3,+ ∞ \infty ]。注意看,这里的左边是闭区间,因为非唯一索引是可以重复的。

5.3 意向锁

意向锁跟行锁和间隙锁的目的是不同的,它主要是为了提升效率而生。我们考虑没有意向锁的情况下,获取一个表的表锁是怎么样的:

  1. 判断这个表有没有被加上表锁,如果有就阻塞,否则进入下一步。
  2. 遍历每条记录和间隙,看是否有某条记录有行锁或者某个间隙有间隙锁,如果有,则代表着不能加表锁,现在需要阻塞,如果没有,才最终获得表锁。

从第2步大家可以看出来,效率是比较低的,于是InnoDB增加了一个意向锁,工作原理是这样的:当某个事务要加行锁或者间隙锁时,会给这个表加上一个意向表锁,并且给意向表锁的数值+1,释放行锁或者间隙锁的时候意向表锁会-1,当数值-1后为0时就释放意向表锁。这个时候上面的第2步就变成了,判断这个表有没有意向表锁,如果有则阻塞。从这可以看出来,意向锁大大提升了加锁的效率。

6. 普遍存在的两种并发问题

竞争条件问题和死锁问题不仅是MySQL独有的,是一般的并发程序都会碰到的问题。

1. 竞争条件-经典的并发问题

竞争条件问题是指需要用快照读的结果来进行某些操作时引起的并发问题。这么说可能有些抽象,请看一个典型的的业务场景,有一个用户去银行取款机取钱,一看余额还有5000元,他打算把这笔钱取出来,与此同时,他老婆也登录了这个银行卡的网银账号,看到余额也是5000元,然后几乎在同时他按了取款按钮,她老婆也按了转账按钮,结果就是每个人都取到了5000元,一共10000元,但实际上银行卡里只有5000元,银行奇怪地损失了5000元,这就是竞争条件问题。其实两个人看到余额是5000元没关系,关键是取款动作在同时发生时有问题,取款动作如下图所示:
在这里插入图片描述
图1

取款动作有两个,一个是查询余额是否充足,一个是扣除余额,如果是同时发生的,那么两个事务就都认为自己可以扣款,而导致多扣除的情况发生,要解决这个问题有两种方案,一种是悲观锁、一种是乐观锁。所谓悲观锁,就是认为每次扣款的时候余额可能都不足,所以在步骤2时就给这个账户加锁,让其他事务被阻塞,等当前事务扣除完成后,其他事务发现余额已经是0了,就导致扣款失败。其过程如下:
在这里插入图片描述
图2

但是悲观锁可能会导致一些性能问题,比如在查询余额是否充足到扣款中间有很多耗时的业务操作,就会让这个账户加锁时间过长,于是就有另外一个方案:乐观锁。

乐观锁的基本思想是:发现并发扣钱的情况很少,我可以先假装余额是充足的,如果发现余额不足,再重新尝试扣款。
在这里例子中的修改就是,把上图1中的第3步改为UPDATE account SET banlance = balance-5000 WHERE id = 664801923 AND balance = 5000;,关键在于balance = 5000这个条件,如果扣款时发现余额已经改变了,就说明有其他事务进行了扣款操作,然后再重新进行这个事务就行了,直到成功为止。这里可能会引起ABA问题,但是并不重要,所以可以不用对ABA问题进行处理。

乐观锁相对悲观锁来说,持锁时间更短,性能可能会更高,但是要看业务场景,并不是乐观锁就一定更好,如果并发扣款的情况很多,乐观锁不断尝试会占用CPU时间,所以要权衡实际业务。

2. 死锁

死锁是指两个事务因为不当的拿锁顺序导致僵持的局面,这个时候两个事务就像宕机了一样。发生死锁的必要条件是下面4个:

  1. 互斥性
    互斥性是MySQL中每个排他锁所共有的特征,这里就不再赘述了。
  2. 不可抢占
    不可抢占是指一个事务持有了一个锁,除非事务自己释放,其他事务是没有办法获取的。
  3. 占有并等待
    占有并等待的意思是当前事务已经获得了一个锁A,然后现在还需要获取另外一个锁B,但锁B被其他事务获取了,导致当前事务处于阻塞状态。
  4. 循环依赖
    有两个事务,一个是事务A,一个是事务B,然后事务A尝试获取一个没有被任何事务占用的锁C,获取成功了。事务B尝试获取一个没有被任何事务占用的锁D,也获取成功了。这个时候事务A又想去获取锁D,但是发现锁D已经被事务B占有了,于是只能等待。于此同时,事务B想去获取锁C,显然,锁C被事务A占用了,所以事务B需要等待事务A释放锁C。这样就造成了僵局。

循环依赖条件可能通过文字不太好理解,现在请看下面这张图:
在这里插入图片描述

死锁问题通常是很难发现和很诡异的,所以要时时记得产生死锁的这4个条件,预防发生死锁。

Innodb是如何解决死锁的

解决死锁只需要破坏4个条件中的一个就可以了,InnoDB会破坏条件3,如果一个事务在获取了一个锁之后,尝试获取第二个锁,尝试时间超过了一个限度,那么它就会看当前占有第二个锁的事务中谁占有的锁最少,把少的那一个直接回滚掉,这样就破坏了占有并等待条件。

总结

InnoDB引擎是MySQL在生产实践中用的最多,检验的最多的存储引擎,学习它是怎么控制并发,以及尽可能提升性能的,对自己编写并发程序很有帮助,在并发更高的程序中,很多会选择去用Redis缓存来支撑,但是最终持久化还是要用MySQL的,所以懂得优化MySQL是很重要的,其中就有一项是优化行锁的,比如库存分段锁。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值