MySQL事务隔离性与隔离级别原理

前言

不管我们学习哪一门面向对象语言,在多线程并发环境下,多个线程共同对同一共享资源操作,从而导致资源出现数据错误的问题称为线程安全问题。通常情况下加锁能够很好的处理线程安全问题。

不知你有没有思考过,MySQL也是一个支持多线程访问的软件,但是我们再日常开发中好像并没有过多的关注过线程安全问题?其实并不是说MySQL不会发生线程安全问题,而是它太优秀了很多地方都帮我们解决了。

事务的隔离性与隔离级别

事务的隔离性是指在并发环境中,并发的事务是相互隔离的,一个事务的执行不能被其他事务干扰。也就是说,不同的事务并发操作相同数据的时候,每个事务都有各自完整的数据空间,既一个事务内部的操作及使用的数据对其他并发事务是隔离的,并发执行各个事务之间不能相互干扰。数据库上有多个事务同时执行的时候,可能会出现脏读(dirty read)、不可重复读(non repeatable read)、幻读(phantom read)的为了,为了解决这些问题就引出了“隔离级别”的概念。

SQL标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。要清楚的是隔离级别越高,数据一致性就越好,但是效率也会越低。最低的隔离级别可能会出现脏读,但是如果我们的系统就算出现了脏读也不会造成什么影响,那么我们不妨就允许脏读。事务的隔离级别越高,就越能保证数据的完整性和一直性,但同时对并发性能的影响也就越大。因此我们需要在二者之间寻找一个平衡点。

锁机制

行锁的3种算法

InnoDB存储引擎有3种行锁的算法,其中分别是

  • Record Lock:单个记录上的锁
  • Gap Lock:间隙锁,锁定一个范围但不包含记录本身
  • Next-Key Lock:GapLock + Record Lock,锁定一个范围,并且锁定本身

RecordLock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会采用隐式主键来进行锁定。

Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下。InnoDB对于行的查询都是采用这种锁定算法。例如有一个索引有10,11,13,20和20这四个值,那么该索引可能被Next-Key Locking的区间为:(-∞, 10]、(10, 11]、(11, 13]、(13, 20]、(20,+∞]

采用Next-Key Lock的目的是为了解决幻读,若事务T1已经通过next-key locking锁定了如下范围

(10, 11]、(11, 13]

当插入新纪录12时,锁定范围就变成了

(10, 11]、(11, 12]、(12, 13]

传统的辅助索引使用的是Next-Key Locking技术加锁,然而当查询索引含有唯一索引时,InnoDB存储引擎会对Next-Key Lock进行优化,降级为Record Lock只锁定本身

读锁(S锁,共享锁)select k from t where id=1 lock in share mode;写锁(X锁,排他锁)select k from t where id=1 for update;

通过锁机制可以实现事务隔离性的要求,使得事务可以并发地工作。锁提高了并发,但是会带来潜在问题。不过好在因为事务隔离性的要求,锁只会带来三种问题,如果可以防止这三种问题的发生,那将不会产生并发异常

脏读

脏读指的是在不同事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。所谓的脏数据实际上指的是事务对缓冲池中的行记录的修改,并且还没有被提交的数据。如果事务中读取到脏数据,很明显违反了数据库的隔离性。

脏读在现在生产环境中并不常发生,脏读发生的条件是需要事务隔离级别为“read uncommitted”。

不可重复读

不可重复读是指一个事务内多次读取同一数据集合,在这个事务还没有结束时,因为另一个事务也访问该同一数据集合,并做了一些DML操作。从而导致第一个事务中两次读取数据之间,由于第二个事务的修改,造成了第一个事务两次读到的数据可能是不一样的。这样就发生了再一个事务内两次读取到的数据是不一样的情况。

幻读问题

幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行

需要对幻读再做一个说明:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入数据的。因此,幻读在“当前读”(for update)下才会出现
  2. 幻读仅仅指的是事务查询相同范围,后一次查询到了“新插入的行”,而已存在的数据第二次查询又看到了不算幻读

行锁只能锁住行,但是“新插入记录”的这个动作,是更新记录之间的“间隙”。所以为了解决幻读,只有锁定一个区间。锁表的话相当于锁定了整个区间,当然可以,但是成本太大。于是InnoDB引入了新的锁,也就是间隙锁(Gap Lock)。

间隙锁,顾名思义锁的就是两个值之间的空隙。如下表,当你插入了5条记录之后,此时表就产生了6个间隙。

图片2.png

所以当你执行select * from t where d=5 for update的时候,不仅对数据库已有记录加上了行锁,同时记录量变的间隙也加上了间隙锁,这样就确保了无法再插入新的记录。也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

间隙锁带来的死锁困扰

我们项目中通常会有这样的需求:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:

begin;
select * from t where id=N for update;

/* 如果行不存在 */
insert into t values(N, N, N);
/* 如果行存在 */
update t set d=N set id=N;

commit;

这样的逻辑看起来没任何毛病,我每次操作前都用for update锁起来了。但是只要一旦遇到并发,就会死锁,具体场景如下:

select A

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

(blocked)

insert into t values(9, 9, 9);

(ERROR 1213(40001): Deadlock found)

我们可以看到,都不用走到后面update语句,就已经形成了死锁。我们按语句执行顺序来分析:

  1. selecn A执行select ... for update语句,由于id=9这一行不存在,因此会加上间隙锁(10, 15);
  2. select B执行select...for update,由于id=9这一行也不存在,同样会加上间隙锁(10,15),间隙锁之间不会冲突,因此这个语句是可以执行成功的。
  3. select B试图插图(9,9,9),被select A的间隙锁挡住,只好进入等待
  4. select A试图插入一行(9,9,9),被select B的间隙锁挡住

因此两个session进入相互等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回。所以如果使用“当前读”去对唯一索引加锁,当需要被锁定的值不存在会使用间隙锁,此时的间隙锁是一个读锁,意思就是说间隙锁只能保护你这个间隙内不能插入值

使用可重复读的情况下,会产生间隙锁。间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。

关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁,只锁定行),这个选择是合理的。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row

加锁规则

间隙锁只有可重复读隔离级别下才有效,没特殊说明,默认可重复读

  1. 原则1:加锁的基本单位是next-key lock。希望你还记得,next-key lock是前开后闭区间。
  2. 原则2:查找过程中访问到的对象才会加锁。
  3. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁
  4. 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
  5. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

丢失更新

丢失更新是另一个锁导致的问题,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。例如:

  1. 事务T1将行记录r更新为v1,但是事务T1并不提交
  2. 与此同时,事务T2将行记录r更新为v2,事务T2未提交
  3. 事务T1提交
  4. 事务T2提交

但是在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。这是因为,对于DML的操作,需要对行或其他粒度级别的对象加锁。因此在上述步骤2中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。

虽然数据库能阻止数据层面丢失更新问题的产生,但是无法阻止生产应用中逻辑意义上的丢失更新问题,而导致该问题的并不是因为数据库本身的问题。

  1. 事务T1查询一行数据,放入本地内存,并显示给终端用户User1
  2. 事务T1也查询该行数据,并将取得的数据显示给终端用户User2
  3. User1修改该行记录,更新数据库并提交
  4. User2修改该行记录,更新数据库并提交

显然这个过程中,用户User1的修改更新操作“修饰”了,从而可能导致一个一些很恐怖的结果。要想避免丢失更新的发生也比较简单,只需要让事务在这种情况下变成串行化,而不是并行的操作即可。

MVCC

可重复读隔离级别下,事务在启动的时候对数据库“拍了个快照”。这个“快照”是基于整个数据库,后续的所有增删改查操作都是在这个快照上操作的。这样每个事务都有自己独立的私有快照数据库,相当于在自己独享的快照内操作,自然而然其他事务的更新就影响不到我了。我们把使用了MVCC的查询称为一致性读,这样不仅仅满足了可重复读,甚至幻读都解决了。

之前你肯定觉得不现实:如果一个库有100G,那么我启动一个事务,MySQL就要拷贝100G的数据出来,这个过程得多慢啊。可是,我平时事务执行起来是很快的啊!

有了上面的基础,我想你就不会觉得惊奇了,其实我们不需要将这么多数据拷贝出来。而是事务启动的时候做一个声明:以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,必须找到它的上一个版本为止。所以我们利用了“所有数据都有多个版本”这个特性,实现了“实现了秒级创建快照”的能力。

另外使用begin/start transaction可以开启一个事务,但是它并不是一个事务的起点。只有在执行到他们之后的第一个操作InnoDB表的语句,事务才算真正启动。所以快照的创建时间也需要注意区分一下。如果你想立即开启一个事务而不是等到第一个语句执行,就可以使用start transaction with consistent snapshot命令;

undo log

redo log是为了当数据库崩溃的时候对已提交的数据进行恢复,那么undo log做的事情刚好是和redo log相反,可以理解为我们"ctrl+z"撤销功能。InnoDB在对数据库进行修改的时候,不但会产生redo log同时还会产生undo log。

  • 对于一个insert操作,InnoDB会产生一个delete;
  • 对于每个delete,InnoDB会产生一个insert;
  • 对于每个update,InnoDB会产生一个相反的update将其修改前的值放回去。

所以undo log是逻辑日志,就是为了再事务因为某种原因失败了,或者是用户主动执行了rollback语句。那么undo log就可以把事务内做的操作“撤销”回到最开始的样子。

快照在MVCC中的工作逻辑

我们知道了undo log可以把数据恢复到事务最开始的样子,所以它就记录数据的每次变动,具体的形式如下:

图片3.png

InnoDB里面每个事务都有一个唯一的事务ID,叫做transaction id,它是在事务开始的时候向InnoDB事务系统申请,并且它是按照申请顺序严格递增的。上图中transaction id有15、20、25说明id=1的这一行数据经历了3个事务连续更新后变成了3、5、10这3个不同的值。绿色椭圆是每一代事务的undo log,而v1、v2、v3这物理上并不是真正存在的,而是每次需要的时候根据当前版本计算出来的。例如当你需要v2的时候,通过v3的undo log就可以推出v2的值;当你需要v1的时候v3回推v2,然后v2回推v1就可以获得了。所以多版本并发控制协议(MVCC)其中的多版本,就是指的被事务id标记的undo log日志

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。InnoDB的行数据有多个版本,每个数据版本有自己的row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据row trx_id和一致性视图确定数据版本的可见性。

  • 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
  • 对于读提交,查询只承认在语句启动前就已经提交完成的数据;

而当前读,总是读取已经提交完成的最新版本。

猜你喜欢

转载自blog.csdn.net/qq_25448409/article/details/113129651