mysql之锁和mvcc

Author Avatar
Sean Yu 12月 02, 2020
  • 在其它设备中阅读本文章

参考 https://draveness.me/database-concurrency-control/ https://segmentfault.com/a/1190000016956088 https://yq.aliyun.com/aracles/626848 https://zhuanlan.zhihu.com/p/73078137 https://tech.meituan.com/2014/08/20/innodb-lock.html https://www.cnblogs.com/crazylqy/p/7611069.html https://chenjiayang.me/2019/06/22/mysql-innodb-mvcc/ https://draveness.me/mysql-transaction/

乐观锁

先说乐观锁吧,因为比较好解释,而且乐观锁不属于mysql的一部分。

其实很简单,就是用版本号来实现,新加一列,用来记录版本号,我们将对数据库的操作分为三个阶段,读和修改阶段,验证阶段,提交阶段。

在读和修改阶段,数据库会执行事务中的全部读操作和写操作,并将所有写后的值存入临时变量中,并不会真正更新数据库中的内容;在这时候会进入下一个阶段,数据库程序会检查当前的改动是否合法,也就是是否有其他事务在 读和修改阶段 期间更新了数据,如果通过测试那么直接就进入 提交阶段 将所有存在临时变量中的改动全部写入数据库,没有通过测试的事务会直接被回滚。

这个验证就是通过版本号有没有被改变来实现的,每一次提交数据库都会将版本号+1,如果在提交前得到的版本号大于事务的当前版本号,就说明有其他食物最当前数据做了改动,需要回滚。

当然也会有人选择用时间戳来实现,跟版本号的道理是一样的,看时间戳有没有增加即可。

乐观锁就是在对数据库完全不加锁的情况下,实现的一种并发控制策略。

数据库隔离级别

Read Uncommited
可以读取未提交记录。出现脏读。

Read Committed (RC)
不能读取未提交记录。避免了脏读,但是可能会造成不可重复读。(在一个事务中,重新读取同一条记录时,会读到别的事物提交的修改,两次读不一致)

Repeatable Read (RR)
可以避免不可重复读。即现有事务开始时,其他的事务就不允许对这个事务拿到的数据做修改。但会出现幻读,即此时有新的数据插入并提交,被当前事务读取到。(新版本的mysql因为实现了mvcc和gap锁,而在rr级别解决了幻读问题,mvcc在快照读中解决,gap锁在当前读中解决,除非你用快照读和当前读的结果做比对,从这个角度说的话,rr解决不了幻读,但是如果同为当前读,或者同为快照读,是可以解决的)

Serializable
Serializable 是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻像读。

事务的状态

因为事务具有原子性,所以从远处看的话,事务就是密不可分的一个整体,事务的状态也只有三种:Active、Commited 和 Failed,事务要不就在执行中,要不然就是成功或者失败的状态.

但是如果放大来看,我们会发现事务不再是原子的,其中包括了很多中间状态,比如部分提交,事务的状态图也变得越来越复杂。

  • Active:事务的初始状态,表示事务正在执行;
  • Partially Commited:在最后一条语句执行之后;
  • Failed:发现事务无法正常执行之后;
  • Aborted:事务被回滚并且数据库恢复到了事务进行之前的状态之后;
  • Commited:成功执行整个事务;

undo log 和 redo log

undo log

想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,而在 MySQL 中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后在对数据库中的对应行进行写入。

回滚日志除了能够在发生错误或者用户执行 ROLLBACK 时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这也就需要回滚日志必须先于数据持久化到磁盘上,是我们需要先写日志后写数据库的主要原因。

回滚日志并不能将数据库物理地恢复到执行语句或者事务之前的样子;它是逻辑日志,当回滚日志被使用时,它只会按照日志逻辑地将数据库中的修改撤销掉看,可以理解为,我们在事务中使用的每一条 INSERT 都对应了一条 DELETE,每一条 UPDATE 也都对应一条相反的 UPDATE 语句。

redo log

与原子性一样,事务的持久性也是通过日志来实现的,MySQL 使用重做日志(redo log)实现事务的持久性,重做日志由两部分组成,一是内存中的重做日志缓冲区,因为重做日志缓冲区在内存中,所以它是易失的,另一个就是在磁盘上的重做日志文件,它是持久的。

当我们在一个事务中尝试对数据进行修改时,它会先将数据从磁盘读入内存,并更新内存中缓存的数据,然后生成一条重做日志并写入重做日志缓存,当事务真正提交时,MySQL 会将重做日志缓存中的内容刷新到重做日志文件,再将内存中的数据更新到磁盘上.

除了所有对数据库的修改会产生重做日志,因为回滚日志也是需要持久存储的,它们也会创建对应的重做日志,在发生错误后,数据库重启时会从重做日志中找出未被更新到数据库磁盘中的日志重新执行以满足事务的持久性。

回滚日志和重做日志

到现在为止我们了解了 MySQL 中的两种日志,回滚日志(undo log)和重做日志(redo log);在数据库系统中,事务的原子性和持久性是由事务日志(transaction log)保证的,在实现时也就是上面提到的两种日志,前者用于对事务的影响进行撤销,后者在错误处理时对已经提交的事务进行重做,它们能保证两点:

  • 发生错误或者需要回滚的事务能够成功回滚(原子性);
  • 在事务提交后,数据没来得及写会磁盘就宕机时,在下次重新启动后能够成功恢复数据(持久性);

在数据库中,这两种日志经常都是一起工作的,我们可以将它们整体看做一条事务日志,其中包含了事务的 ID、修改的行元素以及修改前后的值。一条事务日志同时包含了修改前后的值,能够非常简单的进行回滚和重做两种操作.

悲观锁

接着说悲观锁,首先要清楚的是,悲观锁,mvcc,和数据库隔离级别实际上是分不开的。因此,我会先试图独立的解释数据库中实现的悲观锁,然后再说一下mvcc的实现,最后在说明不同隔离界别中,锁和mvcc是怎么样组合使用的。

表锁、行锁、意向锁

首先是表锁,分为表读锁(s lock)和表写锁(x lock),myisam中只有表锁,s锁可以和s锁共享,x锁则跟s锁和x锁互斥。

如果只有表锁,锁的执行效率会十分低下,并且会锁到一些无用的行,因此便产生了行锁,innodb实现了行锁。行锁的定义跟表锁是一致的,只是一个操作在行上,一个操作在表上。

但是,行锁和表锁同时操作时,会产生问题:

事务A锁住了表中的一行,让这一行只能读,不能写。之后,事务B申请整个表的写锁。如果事务B申请成功,那么理论上它就能修改表中的任意一行,这与A持有的行锁是冲突的。数据库需要避免这种冲突,就是说要让B的申请被阻塞,直到A释放了行锁。数据库要怎么判断这个冲突呢?

  • step1:判断表是否已被其他事务用表锁锁表
  • step2:判断表中的每一行是否已被行锁锁住。

注意step2,这样的判断方法效率实在不高,因为需要遍历整个表。

于是就有了意向锁。

在意向锁存在的情况下,事务A必须先申请表的意向共享锁,成功后再申请一行的行锁。

在意向锁存在的情况下,上面的判断可以改成

  • step1:不变
  • step2:发现表上有意向共享锁,说明表中有些行被共享行锁锁住了,因此,事务B申请表的写锁会被阻塞。

所以什么是意向锁?

意向锁就是在申请一个行锁之前,必须在表的级别申请的一个标记。意向锁也分为意向读锁(is lock)和意向写锁(ix lock)。

于是便有了下面这张图:

image.png

需要注意的是,IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。行级别的X和S按照普通的共享、排他规则即可。

两段式加锁(2pl)

在一个事务里面,分为加锁(lock)阶段和解锁(unlock)阶段,也即所有的lock操作都在unlock操作之前,如下图所示:

image.png

为什么需要两阶段加锁

引入2PL是为了保证事务的隔离性,即多个事务在并发的情况下等同于串行的执行。 在数学上证明了如下的封锁定理:

如果事务是良构的且是两阶段的,那么任何一个合法的调度都是隔离的。

工程实践中的两阶段加锁-S2PL

在实际情况下,SQL是千变万化、条数不定的,数据库很难在事务中判定什么是加锁阶段,什么是解锁阶段。于是引入了S2PL(Strict-2PL),即:

在事务中只有提交(commit)或者回滚(rollback)时才是解锁阶段,其余时间为加锁阶段。

image.png

加锁规则

一条写操作的sql

如果是在rc的隔离级别下:

如果能命中索引,且不需要回表,那么只在索引中命中的行上加x锁。

如果能命中索引,但需要回表,那么既需要在索引中命中的行上加x锁,也需要在聚簇索引的行上加x锁。

如果不能命中索引,那么需要先在聚簇索引的所有行上加x锁(注意,并不是表锁),对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。这个优化违背了2PL的约束。

在rr的隔离界别下:

操作与rc基本是一致的,但有两点差别

一是如果命中的索引不是唯一的(有多个),为了实现rr级别下能防止幻读,rr多加了gap锁,下面我们就来说说gap锁和next-key锁

另一个是,RC下,扫描过但不匹配的记录不会加锁,或者是先加锁再释放,即semi-consistent read。但RR下扫描过记录都要加锁。这个差别对有全表扫描的更新的场景影响极大。

gap锁和next-key锁

什么是gap?

gap就是索引树中插入新记录的空隙。相应的gap lock就是加在gap上的锁,还有一个next-key锁,是记录+记录前面的gap的组合的锁。

gap锁或next-key锁的作用

简单讲就是防止幻读。通过锁阻止特定条件的新记录的插入,因为插入时也要获取gap锁(Insert Intention Locks)。

什么时候会取得gap lock或nextkey lock?

locking reads (SELECT with FOR UPDATE or LOCK IN SHARE MODE), UPDATE和DELETE时,除了对唯一索引的唯一搜索外都会获取gap锁或next-key锁。即锁住其扫描的范围。

mvcc

如果每个读都加s锁,势必会对性能产生比较大的影响,因此诞生了mvcc(多版本并发控制)。

MVCC的实现,是通过保存数据在某个时间点的快照来实现的。

image.png

每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回;在这时,读写操作之间的冲突就不再需要被关注,而管理和快速挑选数据的版本就成了 MVCC 需要解决的主要问题。

因为mvcc的引入,因此产生了快照读和当前读的概念。

在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

快照读:简单的select操作,属于快照读,不加锁。

  • select * from table where ?;

当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。  

  • select * from table where ? lock in share mode;
  • select * from table where ? for update;
  • insert into table values (…);
  • update table set ? where ?;
  • delete from table where ?;

所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

InnoDB MVCC 实现原理

InnoDB 中 MVCC 的实现方式为:每一行记录都有两个隐藏列:DATA_TRX_ID、DATA_ROLL_PTR(如果没有主键,则还会多一个隐藏的主键列)。

DATA_TRX_ID
记录最近更新这条行记录的事务 ID,大小为 6 个字节

DATA_ROLL_PTR
表示指向该行回滚段(rollback segment)的指针,大小为 7 个字节,InnoDB 便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在 undo 中都通过链表的形式组织。

DB_ROW_ID
行标识(隐藏单调自增 ID),大小为 6 字节,如果表没有主键,InnoDB 会自动生成一个隐藏主键,因此会出现这个列。另外,每条记录的头信息(record header)里都有一个专门的 bit(deleted_flag)来表示当前记录是否已经被删除。

在多个事务并行操作某行数据的情况下,不同事务对该行数据的 UPDATE 会产生多个版本,然后通过回滚指针组织成一条 Undo Log 链,这节我们通过一个简单的例子来看一下 Undo Log 链是如何组织的,DATA_TRX_ID 和 DATA_ROLL_PTR 两个参数在其中又起到什么样的作用。

image.png

事务 A 对值 x 进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务 ID 为 100,事务 A 的 ID 为 200,该行的隐藏主键为 1。

image.png

事务 A 的操作过程为:

  • 对 DB_ROW_ID = 1 的这行记录加排他锁
  • 把该行原本的值拷贝到 undo log 中,DB_TRX_ID 和 DB_ROLL_PTR 都不动
  • 修改该行的值,这时产生一个新版本,更新 DATA_TRX_ID 为修改记录的事务 ID,将 DATA_ROLL_PTR 指向刚刚拷贝到 undo log 链中的旧版本记录,这样就能通过 DB_ROLL_PTR 找到这条记录的历史版本。如果对同一行记录执行连续的 UPDATE,Undo Log 会组成一个链表,遍历这个链表可以看到这条记录的变迁
  • 记录 redo log,包括 undo log 中的修改
  • 那么 INSERT 和 DELETE 会怎么做呢?其实相比 UPDATE 这二者很简单,INSERT 会产生一条新纪录,它的 DATA_TRX_ID 为当前插入记录的事务 ID;DELETE 某条记录时可看成是一种特殊的 UPDATE,其实是软删,真正执行删除操作会在 commit 时,DATA_TRX_ID 则记录下删除该记录的事务 ID。

(每一行都是一个链)

关于undo_log和redo_log:https://www.cnblogs.com/wy123/p/8365234.html

如何实现一致性读-ReadView

在 RU 隔离级别下,直接读取版本的最新记录就 OK,对于 SERIALIZABLE 隔离级别,则是通过加锁互斥来访问数据,因此不需要 MVCC 的帮助。因此 MVCC 运行在 RC 和 RR这两个隔离级别下,当 InnoDB 隔离级别设置为二者其一时,在 SELECT 数据时就会用到版本链

核心问题是版本链中哪些版本对当前事务可见?

InnoDB 为了解决这个问题,设计了 ReadView(可读视图)的概念。

RR下ReadView的生成

在 RR 隔离级别下,每个事务 touch first read 时(本质上就是执行第一个 SELECT语句时,后续所有的 SELECT 都是复用这个 ReadView,其它 update, delete, insert 语句和一致性读 snapshot 的建立没有关系),会将当前系统中的所有的活跃事务拷贝到一个列表生成 ReadView。

下图中事务 A 第一条 SELECT 语句在事务 B 更新数据前,因此生成的 ReadView 在事务 A 过程中不发生变化,即使事务 B 在事务 A 之前提交,但是事务 A 第二条查询语句依旧无法读到事务 B 的修改。

image.png

下图中,事务 A 的第一条 SELECT 语句在事务 B 的修改提交之后,因此可以读到事务 B的修改。但是注意,如果事务 A 的第一条 SELECT 语句查询时,事务 B 还未提交,那么事务 A 也查不到事务 B 的修改。

image.png

RC下ReadView的生成

在 RC 隔离级别下,每个 SELECT 语句开始时,都会重新将当前系统中的所有的活跃事务拷贝到一个列表生成 ReadView。二者的区别就在于生成 ReadView 的时间点不同,一个是事务之后第一个 SELECT 语句开始、一个是事务中每条 SELECT 语句开始。

ReadView 中是当前活跃的事务 ID 列表,称之为 m_ids,其中最小值为 up_limit_id,最大值为 low_limit_id,事务 ID 是事务开启时 InnoDB 分配的,其大小决定了事务开启的先后顺序,因此我们可以通过 ID 的大小关系来决定版本记录的可见性,具体判断流程如下:

  • 如果被访问版本的 trx_id 小于 m_ids 中的最小值 up_limit_id,说明生成该版本的事务在 ReadView 生成前就已经提交了,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 大于 m_ids 列表中的最大值 low_limit_id,说明生成该版本的事务在生成 ReadView 后才生成,所以该版本不可以被当前事务访问。需要根据 Undo Log 链找到前一个版本,然后根据该版本的 DB_TRX_ID 重新判断可见性。
  • 如果被访问版本的 trx_id 属性值在 m_ids 列表中最大值和最小值之间(包含),那就需要判断一下 trx_id 的值是不是在 m_ids 列表中。如果在,说明创建 ReadView 时生成该版本所属事务还是活跃的,因此该版本不可以被访问,需要查找 Undo Log 链得到上一个版本,然后根据该版本的 DB_TRX_ID 再从头计算一次可见性;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
  • 此时经过一系列判断我们已经得到了这条记录相对 ReadView 来说的可见结果。此时,如果这条记录的 delete_flag 为 true,说明这条记录已被删除,不返回。否则说明此记录可以安全返回给客户端。

相关阅读 https://www.zhihu.com/question/66320138

见到总结下mvcc,就是每一行都会有一个版本链,查询的时候根据版本链找到合适的版本快照查询,rr是事务开始时生成快照,rc是每一从查询时都生成快照。

有一个问题,如果mvcc是不同行的不同版本拼成的数据集,那么一开始生成快照的过程走索引吗?走这个索引的过程加锁么?

个人理解肯定是要走索引的,但因为找的都是旧版本,所以也就不用加锁了。

所以个人理解下,步骤应该是:命中索引-》顺着版本链找到旧版本快照-》生成数据集

死锁

加锁可能会出现两个事务都在等待对方解除它所占用数据项上的锁(也可能是多个事务之间的循环等待),这种现象称为死锁。当死锁发生时,系统必须回滚两个事务中的一个。一旦某个事务回滚,该事务锁住的数据项就被解锁,其他事务就可以访问这些数据项,继续自己的执行例如下图所示就必须回滚:

image.png

预防死锁主要有两种思路:

一种是保证事务之间的等待不会出现环,也就是事务之间的等待图应该是一张有向无环图,没有循环等待的情况或者保证一个事务中想要获得的所有资源都在事务开始时以原子的方式被锁定,所有的资源要么被锁定要么都不被锁定。

image.png

但是这种方式有两个问题,在事务一开始时很难判断哪些资源是需要锁定的,同时因为一些很晚才会用到的数据被提前锁定,数据的利用率与事务的并发率也非常的低。

另一种预防死锁的方法就是使用抢占加事务回滚的方式预防死锁,当事务开始执行时会先获得一个时间戳,数据库程序会根据事务的时间戳决定事务应该等待还是回滚,在这时也有两种机制供我们选择,

  • wait-die机制:当事务a申请的数据项被b持有,仅当a的时间戳小于b的时间戳时,允许a等待。否则a回滚。即如老可等
  • wound-wait机制:当事务a申请的数据项被b持有,仅当a的时间戳大于b的时间戳时,允许a等待。否则b回滚。(b被a伤害)即如少可等,如老伤少

两种方法都会造成不必要的事务回滚,由此会带来一定的性能损失,更简单的解决死锁的方式就是使用超时时间,但是超时时间的设定是需要仔细考虑的,否则会造成耗时较长的事务无法正常执行,或者无法及时发现需要解决的死锁,所以它的使用还是有一定的局限性。

饥饿

饥饿(饿死):当一个事务想要对一个数据项上加排他锁,因为该数据项上已经有其他事务所加的共享锁了,因此必须等待。而在等待期间又有其他事务对该数据项加上了共享锁,之前的那个事务对一段时间后解除了共享锁,但当前事务还是要继续等待,就这样不断地出现对该数据项加共享锁的其他事务,那么该事务则会一直处于等待状态,永远不可能取得进展,这称为饥饿。

避免饥饿的方法:合适的并发控制器授权加锁的条件可以规避饥饿情况的发生。如,当事务T申请对数据项加M型锁时,并发控制管理器授权加锁的条件是

  • 不存在 已在数据项上持有与M型锁冲突的锁 的其他事务(这是必然的,冲突的话也也加不上锁)
  • 不存在 等待对数据项加锁并且先于T申请加锁 的其他事务(即共享锁中,后来的事务也得等着,事务必须一个一个来【似乎串行化了】)

在这样的授权加锁条件下,一个加锁请求就不会被其后的加锁申请阻塞。