// 若库存足够, 从商品库存减去3, 否则失败
update items set num = num - 3 where id = xxx and num > 3;
这条语句能够正确的避免超卖发生这一点是可以肯定的, 但是这条语句有一个让我非常难以理解的地方.
试想, mySQL的默认隔离级别是REPEATABLE READ可重复读, RR级别下事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。那么假设两个事务并发的按照下图执行,
上述的4个?的位置是应该是什么值呢? 可以看下实际的实验结果.
结果是通过在T2中通过SELECT
语句查询num得到的的确一直是3, 符合RR的定义, 但是在T2中试图update的时候失败了, 也就是这时候是能够看到num最新的值的. 此外这里还有一个知识点, 如果T2的update是在T1的commit前执行的, 会被阻塞(update语句的隐式锁).
为什么RR级别还是能够在事务结束前受到其他commit事务的影响呢, 这不是和RR级别的概念相悖了? 在mySQL的manual中对这个部分进行了解释, 如果你想直接看原因可以看最后一个部分一致无锁读与锁读. 但是这篇文章本着从头到尾讲清楚来龙去脉的目的, 从ACID与隔离级别, mySQL中的MVCC, mySQL中的锁几个部分进行回顾, 最后对于上述的现象进行解释.
数据库的四个特性ACID分别指的是:
这边文章要谈的问题主要就和隔离性有关. 在mySQL中, 事务的隔离级别分为四个级别:
注意, 虽然mySQL的默认事务级别是RR级别, 但它通过mvcc+next-key Lock解决了幻读的问题, 在性能和隔离性上得到平衡. 这个级别也是本文开头提到的问题所在的级别, 要弄懂为什么会出现本文开始的问题的现象, 首先要理解MVCC产生的原因和局限性.
MVCC(Multiple Version Concurrency Control), 是在mySQL的RR级别和RC级别, 用于读-写, 写-读并发的方法.
来自《高性能MySQL》中对MVCC的部分介绍:
MVCC会在每条记录中增加两个隐藏的项, 即DB_TRX_ID和DB_ROLL_PTR, 此外还有delete_bit和DB_ROW_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中的锁有很多种, 大致可以分为
s锁和x锁都是行锁的概念, 为了提高并发性能, mySQL的行锁对读写进行了分离, 多个事务可以获得s锁进行select, 而只有一个事务能够获得x锁进行update或delete的操作.
意向锁有读意向锁IS和写意向锁IX两种, 二者都是表锁. mySQL对意向锁和行锁的使用上有如下的规定:
同时, 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锁前, 它需要知道是否有行级锁的存在,.
行锁
行锁是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的条目也是会被阻塞的.
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
, 分别拿到写锁和读锁.
还是之前的例子, 如果用锁读的方式读, 可以得到下图的结果:
只有当commit或者rollback的时候, 上述select
语句获得的锁才会被释放. 另外, 对于嵌套查询, 除非里面的嵌套查询语句显式的申请锁, 否则下面的句子中只有外层查到数据行会被上锁.
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;
参考资料:
术语: