关于mysql事务&MVCC以及锁机制的总结

目录

  • 理解mysql事务
  • mvcc的原理
  • mysql锁机制(乐观锁、悲观锁、表锁、行锁、意向锁、GAP锁/间隙锁)

先思考个问题,锁跟事务有什么关系?为什么这篇文章要把事务、mvcc、锁这三个知识拼凑在一起?要回答这个问题,我觉得还是具体看下mvcc和锁的机制实现。

一、数据库事务

1.什么是事务

数据库事务(Database Transaction) ,是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。一个逻辑工作单元要成为事务,必须满足所谓的ACID(原子性、一致性、隔离性和持久性)属性。事务是数据库运行中的逻辑工作单位,由DBMS中的事务管理子系统负责事务的处理。—来自百度百科

2.事务隔离级别

Serializable(串行化执行,SQLite默认级别):最高隔离性级别。同时执行的两个事务完全隔离,每个事务有独立的运行空间。

Repeatable Read(可重读,MySQL默认级别): 这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。InnoDB和Falcon存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)以及gap锁(间隙锁)机制解决了该问题。

Read Committed(Oracle、PostgreSQL、SQLServer默认支持的级别):这种隔离度是在Repeatable Read的基础上,增加了一条打破事务隔离性的规则。如果事务A读取了数据D,同时事务B对数据做了修改(包括删除)后提交。事务A再次读取数据D,能感知事物B对数据D的修改。也就是说,Read Committed模式下,一个事务既可以感知另一个事务添加新数据,也能感知这个事务对数据的修改。这个模式也叫non-repeatable read。

Read uncommitted:隔离性最差的一种方式,它是在 Readcommitted的基础上又增加了一条破坏事务隔离性的规则。事务A读取了数据D,同时数据D被事务B做了修改(事务B还未提交,还在执行过程中);如果事务A再次读取数据D,它将感知数据D被修改了,然后事务B回滚,A持有的数据还是被事务B修改后的。实际数据D未被修改(因为事务B回滚了)。这种模式叫”脏读”。

mysql默认的事务隔离级别是Repeatable Read,我们生产上部署的db的事务隔离级别是Read committed。

在MySQL中,实现了这四种隔离级别,分别有可能产生问题如下所示:

注意:mysql的innodb在RR级别下,通过mvcc以及GAP锁解决了幻读问题,但其他引擎如myisam则依然存在幻读。

3.事务日志

包括:redo log、undo log、bin log
重做日志redo:在Innodb存储引擎中,事务日志是通过redo和innodb的存储引擎日志缓冲(Innodb log buffer)来实现的,当开始一个事务的时候,会记录该事务的lsn(log sequence number)号; 当事务执行时,会往InnoDB存储引擎的日志的日志缓存里面插入事务日志;当事务提交时,必须将存储引擎的日志缓冲写入磁盘(通过innodb_flush_log_at_trx_commit来控制),也就是写数据前,需要先写日志。这种方式称为“预写日志方式”,innodb通过此方式来保证事务的完整性。也就意味着磁盘上存储的数据页和内存缓冲池上面的页是不同步的,是先写入redo log,然后写入data file,因此是一种异步的方式。通过 show engine innodb status\G 来观察之间的差距。记录文件是ib_logfile0 ib_logfile1。

那么思考下为什么要有redo log,而不是直接写入数据文件?原因很简单,在机械硬盘的年代,顺序IO的性能要比随机IO的性能高上不止10倍!!!而redo log的记录就是顺序追加到日志文件的,速度非常快,而加入要直接写入到数据文件的话,每条数据所属的页,在磁盘上的位置都可能是分散不连续的,要找到某个页并写入该笔数据,需要通过随机IO的方式,如果每笔数据的写入都是直接随机io写入的话,那么数据库的性能就会受到极大的影响!!

回滚日志undo:用于事务的回滚恢复,undo的记录正好与redo的相反,insert变成delete,update变成相反的update,redo放在redo file里面。而undo放在一个内部的一个特殊segment上面,存储与共享表空间内(ibdata1或者ibdata2中)。

在回滚段中的undo logs分为: insert undo log 和 update undo log
insert undo log : 事务对insert新记录时产生的undolog, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
update undo log : 事务对记录进行delete和update操作时产生的undo log, 不仅在事务回滚时需要, 一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。

事务日志恢复事务:一般情况下,mysql在崩溃之后,重启服务,innodb通过回滚日志undo将所有已完成并写入磁盘的未完成事务进行rollback,然后redo中的事务全部重新执行一遍即可恢复数据,但是随着redo的量增加,每次从redo的第一条开始恢复就会浪费长的时间,所以引入了checkpoint机制(如果在某个时间点,脏页的数据被刷新到了磁盘,系统就把这个刷新的时间点记录到redo log的结尾位置,在进行恢复数据的时候,checkpoint时间点之前的数据就不需要进行恢复了,可以缩短时间)

mysql 二进制日志
MySQL的二进制日志可以说是MySQL最重要的日志了,它记录了所有的DDL和DML(除了数据查询语句)语句,以事件形式记录,还包含语句所执行的消耗的时间,主要是在主从复制的场景下开启二进制日志,那么二进制日志跟事务又有什么关系啊?事实上,前面说的事物完成标记是redo log持久化,指的是在没有开启二进制日志的情况下的,假如mysql开启了二进制日志,那么事务的完成标识就不再是redo log持久化了,而是该语句在二进制日志的持久化(异步复制的模式下,同步复制的时候,事务完成标志是slaver同步成功后)。mysql内部为了保证redo log与binlog之间的一致性,使用了XA协议,也就是当开启了binlog之后,mysql事务的提交变成了两阶段了(prepare&commit),XA协议是就是一个两阶段提交分布式事务的协议,事实上也可以认为mysql内部也是通过了分布式事务去保证了redo log和binlog的数据一致性了。

关于分布式事务,可以参考我之前的一篇文章:闲聊事务与分布式事务

4.事务的完成标识

事务的完成标识要分以下几种情况:
1.单数据库实例,没有开启binlog
这种场景下,事务的完成标识是redo log持久化成功。也就是当我们提交事务的时候,redo log 会被flush到磁盘上,持久化完成则返回事务完成。

2.单数据库实例,但开启了binlog
这种场景下,与上面第一种不同的是,事务提交之后,需要redo log 和 binlog持久化完成(利用了XA协议)才算是一个完整的事务完成。

3.数据库主从架构下的事务
在这种主从架构下(一主一从或者一主多从)的情况下,首先,binlog是肯定需要开启的,其次,由主从复制的机制来决定事务的完成时机。
a.异步复制模式:该模式下,事务的完成标识与上面的第二点原理一样,redo log和binlog 持久化,事务完成。
b.全同步复制模式:这种复制模式对事务的影响较大,它需要保证所有从节点的io线程都返回同步完成才返回完整的事务完成,对性能影响较大
c.半同步复制模式:跟上面的全同步类似,都是需要复制的io线程返回同步完成才返回事务完成,但是差别在于它并不需要所有从节点都必须同步完成,而是只需要其中某个从节点同步完成返回即可。但是尽管如此,一个事务的完成需要等待io线程同步到从库并返回,过程多了这个io等待,影响事务的耗时,所以并不是说半同步复制就是最好的,如果是一主一从的架构下,其实等同于全同步复制。
选用哪种复制模式,决定着对事务时效影响程度,具体还是得根据实际业务场景而定,并发一概而论。

思考一个问题,事务的耗时越大,对业务有什么影响?
可以这么想:假如这个大事务对很多资源加了锁,但很久都没完成事务,也就没办法释放锁,那么影响什么不言自明,所以在一个事务中,对某个资源的加锁操作,应该尽量放到靠近commit的地方。

二、关于MVCC多版本并发控制

多版本控制: 是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

来自《高性能MySQL》中对MVCC的部分介绍:

  • MySQL的大多数事务型存储引擎实现的其实都不是简单的行级锁。基于提升并发性能的考虑, 它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL, 包括Oracle,PostgreSQL等其他数据库系统也都实现了MVCC, 但各自的实现机制不尽相同, 因为MVCC没有一个统一的实现标准。
  • 可以认为MVCC是行级锁的一个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。虽然实现机制有所不同, 但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
  • MVCC的实现方式有多种, 典型的有乐观(optimistic)并发控制 和 悲观(pessimistic)并发控制。
  • MVCC只在 READ COMMITTEDREPEATABLE READ 两个隔离级别下工作。其他两个隔离级别够和MVCC不兼容, 因为 READ UNCOMMITTED 总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

INNODB实现MVCC的行数据结构(本图以update为例)

来自官网的介绍
“Internally, InnoDB adds three fields to each row stored in the database. A 6-byte DB_TRX_ID field indicates the transaction identifier for the last transaction that inserted or updated the row. Also, a deletion is treated internally as an update where a special bit in the row is set to mark it as deleted. Each row also contains a 7-byte DB_ROLL_PTR field called the roll pointer. The roll pointer points to an undo log record written to the rollback segment. If the row was updated, the undo log record contains the information necessary to rebuild the content of the row before it was updated. A 6-byte DB_ROW_ID field contains a row ID that increases monotonically as new rows are inserted. If InnoDB generates a clustered index automatically, the index contains row ID values. Otherwise, the DB_ROW_ID column does not appear in any index.”

上面这段就是说InnoDB存储引擎在每行记录上存有三个字段

  1. DB_TRX_ID
  2. DB_ROLL_PTR
  3. DB_ROW_ID

当然还有一个删除位。DB_TRX_ID表示最后一个事务的更新和插入。
DB_ROLL_PTR指向当前记录项的undo log信息。
DB_ROW_ID标识插入的新的数据行的id(也就是当我们建表时没有指定主键列时,mysql会自动生成DB_ROW_ID用作主键),当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。

read_view:行记录的可见性
这里有必要解释一下什么是行记录的可见性,经过上文介绍可知,MVCC实现了多个并发事务更新同一行记录会时产生多个记录版本,那问题来了,新开始的事务如果要查询这行记录,应该获取到哪个版本呢?即哪个版本对这个事务是可见的。这个问题就是行记录的可见性问题。

对于read view快照的生成时机, 也非常关键, 正是因为生成时机的不同, 造成了RC,RR两种隔离级别的不同可见性;
在innodb中RR隔离级别下, 事务在begin/start transaction之后的第一条select读操作后, 会创建一个快照(read view), 将当前系统中活跃的其他事务记录记录起来;
在innodb中RC隔离级别下, 事务中每条select语句都会创建一个快照(read view);

假设当前的活跃事务链表如下所示:current-trx —> trx4 —> trx3 —> trx2 —> trx1;

Read View中的的变量则按如下方式初始化:
read_view->creator_trx_id = current-trx; 当前的事务id
read_view->up_limit_id = trx1; 当前活跃事务的最小id
read_view->low_limit_id = trx4; 当前活跃事务的最大id
read_view->trx_ids = [trx1, trx2, trx3, trx4]; 当前活跃的事务的id列表
read_view->m_trx_ids = 4; 当前活跃的事务id列表长度

以上是read view中变量组成,下面来分析一下快照的形成过程low_limit_id,即当时活跃事务的最大id,如果读到row的db_trx_id>=low_limit_id,说明这些数据在当前事务开始时都还没有提交,如注释中的描述,这些数据都不可见。

up_limit_id,即当时活跃事务列表的最小事务id,如果row的db_trx_id<up_limit_id,说明这些数据在当前事务开始时都已经提交,如注释中的描述,这些数据均可见。

db_trx_id在up_limit_id和low_limit_id之间的row,如果这个db_trx_id在trx_ids的集合中,就说明开启当前的事务的时候,这个db_trx_id还处于活跃状态,即还未提交,那么这个row是不可见的;如果这个db_trx_id不在trx_ids的集合中,就说明开启当前的事务的时候,这个db_trx_id已经提交,那么这个row是可见的。 这样我们在要在事务中获取数据行,我们就能根据数据行的db_trx_id 和当前事务的read_view来判断此版本的数据在事务中是否可见。可见包括两层含义:记录可见,且Deleted bit = 0;当前记录是可见的有效记录。
记录可见,且Deleted bit = 1;当前记录是可见的删除记录。此记录在本事务开始之前,已经删除。 如果数据不可见我们需要去哪里找上一个版本的数据呢?通过数据行的DB_ROLL_PTR字段去undo log信息中找到上一个版本的记录,再判断这个版本的数据是否可见,以此类推。到这里,大概已经清楚了快照读中的”快照”是怎么生成的

简单来说,Read View记录读开始时,所有的活动事务(没有提交的事务),这些事务所做的修改对于Read View是不可见的。除此之外,所有其他的小于创建Read View的事务号的所有记录均可见,也就是新开启的事务,只能看到已提交的事务的记录(小于等于当前事务id的并已完成事务的记录)

RR级别的事务通过快照读的方式解决了不可重复读的问题,对于幻读,是通过gap锁机制实现的,下面在具体介绍下锁。

三、关于mysql锁机制

介绍锁之前,先了解下mysql锁的分类:

表级锁 是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MyISAM与InnoDB都支持表级锁定。表级锁分为表共享读锁与表独占写锁。

行级锁 是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁 和 排他锁。

页级锁 是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB支持页级锁。

GAP锁(间隙锁):在RR隔离级别下,为了防止幻读,于是有gap锁和next-key锁存在,除了对唯一索引的唯一搜索外都会获取gap锁或next-key锁。即锁住其扫描的范围。

意向锁:
innodb的意向锁主要用户多粒度的锁并存的情况。比如事务A要在一个表上加S锁,如果表中的一行已被事务B加了X锁,那么该锁的申请也应被阻塞。如果表中的数据很多,逐行检查锁标志的开销将很大,系统的性能将会受到影响。为了解决这个问题,可以在表级上引入新的锁类型来表示其所属行的加锁情况,这就引出了“意向锁”的概念。举个例子,如果表中记录1亿,事务A把其中有几条记录上了行锁了,这时事务B需要给这个表加表级锁,如果没有意向锁的话,那就要去表中查找这一亿条记录是否上锁了。如果存在意向锁,那么假如事务A在更新一条记录之前,先加意向锁,再加X锁,事务B先检查该表上是否存在意向锁,存在的意向锁是否与自己准备加的锁冲突,如果有冲突,则等待直到事务A释放,而无须逐条记录去检测。事务B更新表时,其实无须知道到底哪一行被锁了,它只要知道反正有一行被锁了就行了。

1.InnoDB中加锁

MyISAM 相关的锁机制我就略过不总结了。

InnoDB 实现了两种类型的行锁,共享锁(S)与排他锁(X)。然后由于 InnoDB引擎又支持表级锁,所以它内部又有意向共享锁(IS)与意向排他锁(IX)。这两种表锁,都是InnoDB内部自动处理,换句话说我们写代码是无法控制也不需要控制的。我们能控制的是S与X锁。

在日常操作中,UPDATEINSERTDELETE InnoDB会自动给涉及的数据集加排他锁,一般的 SELECT 一般是不加任何锁的。我们可以使用以下方式显示的为 SELECT 加锁。

  • 共享锁:select * from table_name where id =10 lock in share mode;
  • 排他锁:select * from table_name where id=10 for update;

排他锁是与共享锁相对应,自身加排他锁的事物能够自己发起修改操作,其它事物无法再对该数据加共享或者排他锁。
锁的生命周期是在一个事务中,从第一次加锁开始,一直到事务结束(commit)的时候释放。也就是说,假如该事务是一个大耗时的事务,那么很有可能会导致某个资源被锁住的时间太久,而阻塞其他事务对该资源的加锁。

MySQL默认操作模式是 autocommit 自动提交模式。此模式下,除非显式的声明使用事务,即:start transaction
也可以通过命令:set autocommit = 0  关闭自动提交事务

对于锁的使用,需要注意几点:
1.当事务的提交模式是autocommit 的时候,对每一个资源的加锁都会随着该sql语句的执行完毕而自动释放锁,这在绝大多数情况下,并非我们想要的效果。
2.在使用行锁的情况下,假如where条件中没有命中索引,那么锁的范围就变成了对每一条扫描的行进行加锁,也就是效果等同于表锁了,但明显比表锁还要重得多。
3.应避免不同事物对共同依赖的资源加锁顺序不同,从而导致死锁,具体死锁的原因下面会介绍。
4.在一个大事务中,应考虑将加锁操作放到靠近commit的地方,减少对资源的长时间加锁,从而减少其他事务因等待对同一个资源加锁的阻塞时间。

2.Dead lock(死锁)

在使用事务的时候,需要留意事务之间资源的竞争会导致死锁,数据库中死锁的原理很简单,如下图:

如图所示,死锁的根本原因就是多个事务对共同依赖的多个资源的加锁顺序不一样,并相互等待对方释放某个资源的锁,从而形成了循环依赖。

加锁对多个资源的加锁顺序不一样,举例:

1.事务T1依赖A、B两个资源,并分别加锁:

select * from A where id = 1 for update;
xxxx
update B set f='xxx' where id = 1

2.事务T2依赖A、B两个资源,并分别加锁:

select * from B where id = 1 for update;
xxxx
update A set name='xxx' where id = 1

上面两个事务,假如都同时执行了第一条语句,那么就会出现死锁了。这只是最容易简单最容易理解出现死锁的一种常见,事实上其他场景。

为了解决这种问题,数据库实现了各种死锁检测与死锁超时机制。越复杂的系统,比如InnoDb存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致 出现非常慢的查询。还有一种解决方式,就是 当查询的时间达到锁等待超时的阈值时放弃锁请求,这种方式通常来说不太好,因为超时 时间到底要设置多少?是个问题。InnoDB目前处理死锁的方法是,将持有最少行级锁的事务进行回滚(这是相对比较简单的死锁回滚 算法)

锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些不会。死锁的产生有双重原因:有些是因为真正的数据冲突,这种情况通常很难避免,但有些则完全是由于存储引擎的实现方式导致的。死锁发生以后,只有部分获取完全回滚其中一个事务,才能打破死锁。对于事务性的系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。

3.查看mysql锁情况

1.查看innodb的锁概况:

mysql> show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name                 | Value  |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0      |
| Innodb_row_lock_time          | 218276 |
| Innodb_row_lock_time_avg      | 18189  |
| Innodb_row_lock_time_max      | 51058  |
| Innodb_row_lock_waits         | 12     |
+-------------------------------+--------+

2.查看正在锁的事务和等待锁的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS; 
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

4.举例分析

能说下下面这条sql加了多少把锁?

select * from t1 where id = 10 for update;

我们可能第一眼看到就会说,加了一把锁,在id=10的行上。听起来好像没问题,但事实上真的就这么简单吗?其实不然。
要分析这条sql加了几把锁,还是需要知道更多的前提信息的,如:
前提一:id列是不是主键?
前提二:当前系统的隔离级别是什么?
前提三:id列如果不是主键,那么id列上有索引吗?
前提四:id列上如果有二级索引,那么这个索引是唯一索引吗?
前提五:两个SQL的执行计划是什么?索引扫描?全表扫描?
在往下说之前,提前说明一点:在mysql的RR隔离级别与RC隔离级别下加锁有个很大的区别:
RR隔离级别下,所有走非主键索引或者非唯一索引的情况下加锁,除了对行本身加锁,还会加上GAP锁(间隙锁),而RC则没有GAP锁。

所以在本例子中,仅用RR隔离级别下的加锁为例来介绍,RC级别就把下面的gap锁去掉就行了。

第一种场景:id列属于主键索引,如图:

行锁,锁定id=10的行,由于id属于主键,具有唯一性,不需要gap锁,RC隔离级别同样

第二种场景:id列属于非主键,但属于唯一索引,如图:

由于id非主键索引,索引整个搜索过程是先搜索普通索引,然后再通过普通索引叶子节点中的主键去搜索主键索引(关于mysql的索引原理,建议看下:http://blog.jobbole.com/24006/)

所以这种场景下,加了两把锁,分别是普通索引上id=10的节点加了X锁,然后再到主键索引name=d的节点加了X锁。

第三种场景:id列没有索引,如图:

惨咯惨咯,全部行都给加上锁了~~~,还有GAP锁~~~

第四种场景:id列属于普通非唯一索引,如图:

由于id是非唯一索引,那么在id=10的间隙之间还会加上GAP锁

关于GAP锁

好了,介绍到这里,应该对上面的sql加锁情况有个比较清晰的理解了,然而,上面一直说的gap锁,到底有啥作用,影响了啥?

还是用上面的图来具体说明:

如图,在id=10的行加了行锁,以及GAP锁,那么id范围[6,11]这个范围段都被锁定了,当我们要插入一条id=7、id=8、id=9或者id=10的记录,都会被阻塞,直到该事务完成。所以为什么说GAP锁是解决RR级别幻读的关键?正是它保证了同一个事务内,重复的查询,如:id=10的条件下,不会出现新的结果。

发布了87 篇原创文章 · 获赞 42 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/vipshop_fin_dev/article/details/85227296