vlambda博客
学习文章列表

MySql那些事儿(八):一篇文章搞懂事务的隔离性与锁


今天开始我们来填之前Mysql系列文章的坑,这一篇文章我们来聊一聊Mysql的事务隔离性与隔离级别,这个概念是面试基本上涉及到Mysql问题必问的问题。接下来跟我一起来梳理下相关概念,让你在面试中游刃有余,在平时工作中少踩坑。提到事务我就得碎碎念ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),而这里我们说的就是其中的“隔离性”。当我们在同一个数据库中同时执行多个事务时就会出现:脏读,不可重复读,幻读的问题,为了解决这个问题,数据库中就有了隔离级别的概念。常规的隔离级别包括:读未提交,读提交,可重复读,串行化。

读未提交:一个事务还没提交时,它做的变更就能被别的事务看到;读提交:一个事务提交之后,它做的变更才会被其他事务看到;可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时(所有已提交数据)看到的数据是一致的;串行化:就是同一行记录在被事务读写时,通过互斥方式保证同一时刻只有一个事务执行,其他事务处于阻塞中。

事务隔离的实现与MVCC

关于事务的隔离性实现,这里将通过“可重复读”展开说明。事务在启动的时候会为每一个事务创建一个read-view(也称之为“事务快照”)并且数据系统会给它分配一个全局唯一的递增ID称之:transaction id。每个事务来更新这一行数据时,都会产生一个数据版本,并且把transaction id赋值给当前数据版本的事务ID,称之:row trx_id。与之同时会保留旧版本数据日志(undo log),在新版本中的roll_pointer指向旧版本id。也就是说一行记录可能会有多个版本,每个版本都会有自己的row trx_id。这里说明一下,数据版本只是一个逻辑概念,而不是物理存在的。它可以通过当前版本的undo log依次计算出之前每个数据版本(read-view)的数据与版本信息。我们在本系列文章《MySql那些事儿(五):事务是个什么东西?》中介绍了关于read-view的一些属性说明,有了这些概念就很好判断某个版本是否可见:

  1. 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问;
  2. 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明该版本生成的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问;
  3. 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问;
  4. 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版 本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性。这里需要注意点的是,每种隔离事务隔离级别创建生成read-view的时机是有所区别的。READ COMMITTD在每次进普通SELECT操作前都会成个ReadView,REPEATABLE READ只在第次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。

总结:执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。而insert语句却不需要支持MCVV,所以在事务提交后就被释放掉了。

关于幻读

幻读解释过来就是:如果一个事务先根据某些条件查询出这些记录,之后另一个事务又按之前查询条件向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。这里我们还是要针对不同隔离级别来说明,在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。一个事务的结果被另一个事务查询到时的当前读,不能称之为幻读,幻读仅指‘新插入的行’。

关于幻读的问题

哪怕你在update时候将所有数据锁住,也会产生幻读,因为你update的只是存在的数据,而未来提交的新数据却不在你的update 范围。

幻读的解决

现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock),顾名思义,间隙锁,锁的就是两个值之间的空隙。假如我有如下这张表

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

初始化插入6条记录,就会产生7个间隙。当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。还同时加了7个间隙锁。这样就确保了无法再插入新的记录。也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。我知道行锁分读锁和写锁,也就是说跟行锁有冲突的关系是另外一个行的读或写锁。而跟间隙锁存在冲突关系的是,在这个间隙范围内的所有操作。间隙锁之间是不存在冲突关系,因为他们不属于同一块区间范围。

事务A 事务B
begin;select * from t where c =7 lock in share model; ~
~ 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 的同时也带来了更复杂的问题,那就是多个事务同时对一块没有数据的空间加间隙锁成功后,分别在自己的事务中操作这块区间的时候尤其是在并发情况下会产生死锁,A事务操作区间时发现B事务持有区间间隙锁被阻塞,而B事务同时也来操作这块区间时候同样发现A事务持有此区域的间隙锁而也被阻塞,导致最终的死锁。

Mysql锁

上面我们说到了一类锁 ,这里我们将常见的数据库锁统一讨论一下。使用锁的场景一般就是我们想让自己写的sql不管在单线程或者在并发中按照我们预想的顺序或者规则来执行。根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。

全局锁/实例锁

全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是:Flush tables with read lock。在这之后的所有写操作,包括表结构都会被阻塞。这类锁一般用于数据库备份(当然这个操作仅限于冷数据操作)。当然我们也可以将数据导出和备份当做一个事务启动(–single-transaction),前面我们说过事务的视图是由MVCC机制来保证一致性的。

设置只读大家是不是又想到了set global readonly=true;呢?确实将数据库设置成readonly方式也可以让全库进入只读模式。但是readonly这个在使用过程中容易和我们主从同步的业务逻辑产生混淆(readonly是标志主库从库的重要标识)。还有个重要的原因就是二者的机制是不同的,FTWRL命令执行之后于客户端发生异常断开,那么MySQL会自动释放这个全局锁,readonly是不会的。

表锁

MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL),也称MLD锁。

表锁

标锁的语法是:lock tables … read/write。它属于FTWRL操作,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。表锁是比较狠的角色,不但限制别人操作表,而且还限制自己后面除本锁以外的操作。如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。

元数据锁/MDL锁

MDL锁是不需要显示声明,它是访问即加锁的机制,读锁之间不互斥,读锁与写锁之间互斥,写锁与写锁之间互斥,他们用来保证表结构变更的操作安全性。这里说到表结构变更的安全性,在生产上要谨慎使用MDL写锁对热点表操作(也就是变更表结构),否则会在短时间阻塞大量的数据库请求,当有长事务执行时候先Kill掉长事务。否则可能就出现因为你对表结构略微的变更导致数据库崩溃的现象。这里建议使用‘DDL NOWAIT/WAIT n’这个语法,通过等待空闲时间来变更MDL。如果数据库在很繁忙的时候 可能这个命令可能会失败,但是不会影响正常业务。

行锁

以上的锁MyISAM和InnoDB两种引擎都是支持的,而行锁只支持InnoDB引擎。它是粒度较小的一种,也是我们常用的锁之一。在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。知道了这个规则也就是说,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。举个比较简单的例子:A,B两个人同时在超市买单,分别减A,B两个人账户的钱,然后将钱加到超市的一个账户中。这样的场景,我们是将A,B买单的场景分别放在一个事务中,那么存在并发的就是超市账户更新的时候。其他操作不存在资源竞争。那么我们就将给超时账户加余额的操作放到每个事务的最后一步,这样就能让行锁在多个事务中持续时间最短,造成的阻塞时间最短。

使用行锁也是比较容易发生死锁的场景,那我们在使用行锁的时候要做好死锁兜底策略,这里我们就先讨论两种:

1:阻塞到超时后自动释放,我们可以使用innodb_lock_wait_timeout来设置,默认50秒,在设置这个时候的时候 要能让程序明显区分出正常的锁等待和死锁,是个不断尝试与统计数据量状态而得出的结果,否则随便设置的话就会导致有些莫名的问题哦。

2:主动发起死锁检测。发现死锁后,主动回滚死锁中的某个事物。将参数innodb_deadlock_detect设置为on,表示开启死锁检测。死锁检测在高并发的场景下会因为每个线程的加入来判断是否会导致死锁,消耗大量的CPU资源。

其实想避免死锁,我们还可以从程序层面或者业务设计上避免,双管齐下才保险,不要把所有的问题都扔给数据库,因为基本上数据库是最后一层防线,要是最后一层防线轻易被击垮的,那就离跑路不远了。

总结

这一章我们讲了关于事务隔离性实现的原理与在可重复读级别下产生幻读的解决办法,顺带说了下锁的问题,以及它所带来的新的问题。在技术层面没有永远的银弹只有更适合我们业务的办法与在不断采坑中凝练出一套解决办法。今天关于事务的隔离性问题就讲到这里,欢迎大家关于此话题