mySQL中的锁与事务模型

文章目录

    • ACID与隔离级别
    • MVCC
    • mySQL中的锁
      • 共享锁和互斥锁
      • 意向锁
      • 行锁 Gap锁, Next-key锁
    • 一致无锁读和锁读
      • 一致性无锁读
      • 锁读

今天的文章先从一条SQL语句讲起, 在商超系统或者购物系统的下单部分, 可以通过在事务中执行以下这条语句, 来避免商品的超卖

// 若库存足够, 从商品库存减去3, 否则失败
update items set num = num - 3 where id = xxx and num > 3; 

这条语句能够正确的避免超卖发生这一点是可以肯定的, 但是这条语句有一个让我非常难以理解的地方.

试想, mySQL的默认隔离级别是REPEATABLE READ可重复读, RR级别下事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。那么假设两个事务并发的按照下图执行,
mySQL中的锁与事务模型_第1张图片
上述的4个?的位置是应该是什么值呢? 可以看下实际的实验结果.
mySQL中的锁与事务模型_第2张图片
结果是通过在T2中通过SELECT语句查询num得到的的确一直是3, 符合RR的定义, 但是在T2中试图update的时候失败了, 也就是这时候是能够看到num最新的值的. 此外这里还有一个知识点, 如果T2的update是在T1的commit前执行的, 会被阻塞(update语句的隐式锁).

为什么RR级别还是能够在事务结束前受到其他commit事务的影响呢, 这不是和RR级别的概念相悖了? 在mySQL的manual中对这个部分进行了解释, 如果你想直接看原因可以看最后一个部分一致无锁读与锁读. 但是这篇文章本着从头到尾讲清楚来龙去脉的目的, 从ACID与隔离级别, mySQL中的MVCC, mySQL中的锁几个部分进行回顾, 最后对于上述的现象进行解释.

ACID与隔离级别

数据库的四个特性ACID分别指的是:

  • A: 原子性, 一个事务的执行要么全部执行, 要么完全不执行, 没有中间状态
  • C: 一致性, 一致性是原子性和隔离性的结果, 数据库的状态总是从一个一致状态转移到另一个一致状态
  • I: 隔离性, 并发进行的事务相互之间不会感知也不会影响.
  • D: 持久性, 一个数据一旦写入到数据库中, 即使数据库崩溃, 断电等情况发生, 也不会导致数据的丢失.

这边文章要谈的问题主要就和隔离性有关. 在mySQL中, 事务的隔离级别分为四个级别:

  • Read Uncommitted: 这个级别很少被使用, 其他事务的中间状态的数据会被当前事务读到, 这个隔离级别会引发的问题是脏读问题. 例如, 当b事务将id=1的记录修改了num=3, 这是a事务读到的id=1的num就是3, 而b事务之后进行了回滚, 则a事务读到的就是一个没有存在过的状态.
  • Read Committed: 这个级别是Oracle数据库等常用的级别, 这个级别只有其他事务已经提交的数据才能被当前事务读到, 因此读到的至少是一个一致性状态的数据, 解决了脏读的问题, 但是这个级别会引发的问题是不可重复读问题. 例如: 事务a第一次读到的id=1的num=5, 这时事务b修改num为3并提交, 而事务a再次读时会读到num=3, 前后的数据不一致.
  • Repeatable Read: 这个级别是mySQL的默认事务隔离级别, 这个级别事务多次读同一数据的结果相同, 不会收到其他事务修改的影响, 解决了不可重复读的问题. 但这个级别可能引发的问题是幻读的问题. 例如: 事务a统计了id在3-5之间的记录有1条, 这时, 事务b插入了一条id=4的数据, 导致下一次事务a读的时候发现这个范围的数据变成了两行, 多出来的一条幻影行.
  • Serializable: 这个级别是最高的隔离级别, 所有事务都按照序列执行, 没有并发产生, 因此安全级别最高但是性能也最差. 这个级别不存在上述的三种问题的任何一种

注意, 虽然mySQL的默认事务级别是RR级别, 但它通过mvcc+next-key Lock解决了幻读的问题, 在性能和隔离性上得到平衡. 这个级别也是本文开头提到的问题所在的级别, 要弄懂为什么会出现本文开始的问题的现象, 首先要理解MVCC产生的原因和局限性.

MVCC

MVCC(Multiple Version Concurrency Control), 是在mySQL的RR级别和RC级别, 用于读-写, 写-读并发的方法.

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

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

MVCC会在每条记录中增加两个隐藏的项, 即DB_TRX_ID和DB_ROLL_PTR, 此外还有delete_bit和DB_ROW_ID. 它们在表中的表现如下图:

  • DB_TRX_ID记录了最后update或insert当前条目的事务的id,
  • DB_ROLL_PTR指向了存放在rollback_segment上的undo log的当前条目的上一个版本的条目.
  • DB_ROW_ID标识插入的新的数据行的id(也就是当我们建表时没有指定主键列时,mysql会自动生成DB_ROW_ID用作主键),当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中

rollback_segment实际上是存放在系统表空间的的一块特殊区域, 在5.7中它被创建在全局临时表空间中

undo log

上文中提到的undo log在之后会详细说, 它可以分为insert undo log和update undo log两种, 前者在事务提交之后就可以被删除了, 而后者需要被用于一致读, 只有当不再有新事务创建时innodb创建的read view需要这依赖这条log来构建更早版本时, 才可以被丢弃. 并且, 在mySQL中, delete操作也是一种特殊的update操作, 因此delete操作并不会立刻删除相应行, 只有当对应的update undo log被丢弃时, 这行才真正会被删除.

聚簇索引和辅助索引的更新

当记录更新时, MVCC对于聚集索引和辅助索引的操作是不一样的. 聚集索引的update操作是in-place操作, 并且聚集索引是带了隐藏列的, 所以隐藏列也会被立刻更新, 但是在辅助索引中, 节点会被标记为deleted, 然后插入一个新的节点. 如果一个辅助索引记录被标记为deleted或者辅助索引的page被更新的事务更新过, 则覆盖索引会失效, innodb会从聚簇索引返回数据.

然而, 当索引下推时, parts of the WHERE condition can be evaluated using only fields from the index, the MySQL server still pushes this part of the WHERE condition down to the storage engine where it is evaluated using the index. If no matching records are found, the clustered index lookup is avoided. If matching records are found, even among delete-marked records, InnoDB looks up the record in the clustered index.

mySQL中的锁

mySQL中的锁有很多种, 大致可以分为

  • 共享锁与互斥锁
  • 意向锁
  • 行锁, Gap锁, Next-key 锁
  • 插入意向锁
  • auto-inc锁

共享锁和互斥锁

  • 共享锁, s锁, s锁的特点是读并发, 当两个事务对同一行加s锁是不会有冲突的
  • 排斥锁, x锁, x锁的特点是写-写互斥和读-写互斥

s锁和x锁都是行锁的概念, 为了提高并发性能, mySQL的行锁对读写进行了分离, 多个事务可以获得s锁进行select, 而只有一个事务能够获得x锁进行update或delete的操作.

意向锁

意向锁有读意向锁IS和写意向锁IX两种, 二者都是表锁. mySQL对意向锁和行锁的使用上有如下的规定:

  • 一个事务在对记录加S锁之前, 首先要获得表的IS锁
  • 一个事务在对表的某条记录加x锁之前, 首先要获得IX锁

同时, Innodb支持多粒度的并发控制, 例如可以通过LOCK TABLES ... WRITE语句对特定的表加上表级的x锁. 其他时候, 当操纵一个行级锁时都需要使用意向锁.

不同的表级锁之间的互斥相容关系, x表示冲突, o表示相容.

IS IX S X
IS o o o x
IX o o x x
S o x o x
X x x x x

意向锁的目的是当有互斥行为发生时, 如果没有意向锁, 则需要全表进行遍历, 而有了表级的意向锁, 这种检测就变得非常简单, 比如要对一个表加表级的X锁前, 它需要知道是否有行级锁的存在,.

行锁 Gap锁, Next-key锁

行锁

行锁是mySQL中粒度最小的锁, 并发性能最好, 但是资源开销最大. 可以通过下面的语句分别对行加共享锁和排它锁

  • SELECT * FROM T1 WHERE id = 1 LOCK IN SHARE MODE;添加共享锁
  • SELECT * FROM T1 WHERE id = 1 FOR UPDATE;添加排它锁

注意, 行锁总是对索引记录进行锁定, 当没有指定索引时, 会使用innodb自己创建的隐藏列DB_ROW_ID作为索引, 当查找没有走索引而变成全表遍历时, 行锁会对每一条记录加锁, 相当于加了表锁, 但是开销比表锁大很多.

Manual:

InnoDB performs row-level locking in such a way that when it searches or scans a table index, it sets shared or exclusive locks on the index records it encounters

Gap锁

gap锁是锁定两个索引记录之间空隙, 或第一个记录之前或最后一个记录之后的空隙的锁. 当对于唯一索引的唯一行进行查询的时候不会使用gap锁, 但是其他情况都会使用gap锁.

注意两个索引记录之间空隙的定义, 假设当前的表结构如下:

create table T2 (
id int not null,
name char(20) not null,
key idx_id(id)
);
insert into T2 values(2, 'hello');
insert into T2 values(10, 'world');

上面建了一张表, 并且添加了一个普通索引(非唯一索引), 因此如果锁定是会锁定gap的, 我们来测试下下面的查询.

对于t1事务, 执行select * from T2 where id between 4 and 6;, 保持不提交. 此时t2事务执行insert into T2 values(5, 'foo');肯定是被阻塞的, 然而, insert into T2 values(8, 'foo');insert into T2 values(3, 'foo');同样也会被阻塞, 这是因为gap锁是加在id=2和id=10之间的, 而不只是id=4到id=6之间. 但是insert into T2 values(10, 'foo');这条语句是可以执行的.

Next-key锁

Next-key锁是gap锁和行锁的结合. 可以理解为gap锁是一个左开右开的区间, 而next-key是一个左开右闭的区间. Innodb在RR级别下在查询和扫描时使用的就是NK锁来避免幻读的产生.

注意这个next-key的概念其实是很形象的, 例如还是上面的那张表, 当select * FROM T2 where id > 4;时, 搜索到的记录是id=10, 10就是next-key, 它和附带的前面的间隙就全被锁住了. 这时,要想插入id=3的条目也是会被阻塞的.
mySQL中的锁与事务模型_第3张图片

Auto-Inc锁

AUTO-INC锁是一种特殊的表级锁,由事务插入具有AUTO_INCREMENT列的表中获得。 在最简单的情况下,如果一个事务正在向表中插入值,则任何其他事务都必须等待自己在该表中进行插入,以便第一个事务插入的行接收连续的主键值。

一致无锁读和锁读

一致性无锁读

回到最开始的问题, mySQL的并发控制可以大致分为MVCC(multi-versioned concurrency control)和锁读(locking Reads)两种协议, 从悲观锁乐观锁的角度可以大致看作是乐观锁和悲观锁. 一致无锁读基于MVCC的这种方式, 在事务开始后首次查询某条数据时得到这条数据的一个快照, 快照的版本不晚于当前事务开始之前最后一个完成的事务, 而事务开始时还没有提交的事务以及之后开始事务都不会影响到当前事务的数据, 除非在当前这个事务本条语句之前对数据进行了修改, 否则数据都不会变, 正是这一机制能够避免脏读和不可重复读的发生.

在RC级别上, 一致无锁读同样能够起作用, 对于当前事务的select查询, 查询的是当前事务版本所能够看到的快照.

一致无锁读因为不需要加锁, 因此提供了很好的并发性能, 多个事务能够同时访问具有一致性的数据并且彼此不冲突, 保证了数据的一致性. 一致无锁读是RR和RC级别的select语句的默认读方式.

我们已经知道在RR级别, 当其他事务并发的修改(update, delete, lock)本事务查询的数据并提交后, 本事务通过select无法感知., 但是update这样的语句是能够感知的, 这也是例如本文开头提出的超卖的情景所需要的, 那么这是通过什么机制实现的, 接下来就是要回答文首提出的问题.

首先明确一点, MVCC中的这个快照, 只会对普通select语句(普通, 后面会有特例) 起作用, 而对DML(Data manipulation language)语句是无效的. 即使在RR级别下, 本事务对数据的UPDATE,DELETE语句依然会受到在事务开始之后提交的其他事务对数据的影响, 简单说就是这些DML语句是能看到最新的一致性状态的. 它们看到最新的一致性状态的方式是不走一致性无锁读而走锁读.

其他一致性无锁读无效的场景:

  • 遇到其他事务进行DROP TABLE. 表都没了, 也不存在多版本的undo log.
  • 遇到其他事务进行ALTER TABLE. 该语句会复制一个临时表出去, 然后删除当前表, 新建的临时表时间戳在当前事务之后, 因此当前事务在快照中看不到该表.

锁读

锁读顾名思义就是加锁对数据进行读取, 在RR下, 无论是显式加锁还是隐式枷锁, 都能读到最新的一致性数据, 因为commit或rollback才会触发锁的释放, 下一个请求锁的事务才能拿到锁. 可以明确的是UPDATE,DELETE语句都会获得至少行锁, SELECT语句要想走锁读, 可以采用SELECT ... FOR UPDATE,SELECT ... LOCK IN SHARE MODE, 分别拿到写锁和读锁.

还是之前的例子, 如果用锁读的方式读, 可以得到下图的结果:
mySQL中的锁与事务模型_第4张图片
只有当commit或者rollback的时候, 上述select语句获得的锁才会被释放. 另外, 对于嵌套查询, 除非里面的嵌套查询语句显式的申请锁, 否则下面的句子中只有外层查到数据行会被上锁.

SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;

参考资料:

  • MySQL 5.7 Reference Manual
  • Concurrency Control
  • 探索Mysql锁机制(一)——乐观锁&悲观锁
  • 关于mysql事务&MVCC以及锁机制的总结
  • MySQL 加锁处理分析
  • InnoDB存储引擎MVCC的工作原理

术语:

  • DML: a set of SQL statements for performing INSERT, UPDATE, and DELETE operations. The SELECT statement is sometimes considered as a DML statement, because the SELECT … FOR UPDATE form is subject to the same considerations for locking as INSERT, UPDATE, and DELETE.

你可能感兴趣的:(mySQL基础与数据库调优)