说明
在之前的博文《MySQL学习(三): 一致性非锁定读和 MVCC》 中,我通过翻译官方文档简单介绍了 MySQL 的一致性非锁定读和 MVCC 的基本知识,在本篇博文中我将继续通过官方文档来介绍 InnoDB 事务模型中关于锁定读的相关内容,对应《Locking Reads》章节。
通过本篇博文,我们应该了解到在什么情况下应该使用锁定读,并且锁定读有哪些实现方式,不同方式之间有什么区别。
正文
锁定读
在同一个事务中,你如果在查询数据之后,再对相关数据进行插入或更新时,常规的 SELECT 语句无法给这些操作足够的安全保障,这是因为其他事务可能在你刚读取数据之后就对相同数据进行了更新或删除操作。对此,InnoDB 支持两种类型的锁定读来提供额外的安全保障:
SELECT … FOR SHARE
该语句对读取的行设置了一个共享锁。其他事务可以读取锁定的行,但是无法修改直到你的事务提交。如果要读取行已经被其他未提交的事务修改,你的查询将会一直等待直到事务结束后读取到最新值。
以下示例演示了在一个事务中进行数据的修改,其他事务对相同行的锁定读会被阻塞的情况:
注意
SELECT … FOR SHARE 语句是 SELECT … LOCK IN SHARE MODE 的替代品,两者完全一致,但后者为了向后兼容性被保留了下来。但前者支持新的特性,支持 table_name, NOWAIT, SKIP LOCKED 等操作。
在 MySQL 8.0.22 之前,SELECT … FOR SHARE 要求 SELECT 权限和 DELETE, LOCK TABLES 或 UPDATE 三种操作中至少一个权限,但在 8.0.22 版本中,只需要 SELECT 权限。
同时在 8.0.22 版本中。SELECT … FOR SHARE 语句不需要获取 MySQL grant 表的读锁。
SELECT … FOR UPDATE
该语句在对索引记录进行查找时,会对行记录和索引相关部分进行加锁,这如同你对这些行使用了 UPDATE 语句。其他事务的一些操作,如更新这些行记录,进行 SELECT … FOR SHARE 语句,或者在某事务隔离级别下读数据都会被阻塞。一致性读忽略在读视图中的行设置的锁。(老版本的记录不能被锁定,它们可以在内存中通过 undo logs 进行重建)
SELECT … FOR UPDATE 语句要求 SELECT 权限和 DELETE, LOCK TABLES 或 UPDATE 三种操作中至少一个权限。
注意
当事务提交或回滚时,通过 FOR SHARE 和 FOR UPDATE 语句设置的锁都会被释放。
锁定读只有在禁止自动提交后才可以进行。
外围语句的锁定读子句并不会影响其内部查询语句的行,除非内部查询也设置了锁定读子句。例如,以下语句将不会锁定表 t2 的行记录:
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;
要锁定 t2 的行记录,可以在内部查询语句中添加锁定读子句:
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2 FOR UPDATE) FOR UPDATE;
示例
假设你想要在表 child 中新插入一行数据,同时保证在表 parent 中有一行数据与之关联。你的应用程序要确保整个操作的引用完整性。
首先,在 PARENT 表使用一致性读来确保关联行数据的存在。但这不能保证可以安全地在表 CHILD 进行数据插入,这是因为其他事务可能在你的查询与插入操作之间,删除了 PRAENT 表中与之关联的数据。
为了避免这种情况,使用 FOR SHARE 进行锁定读:
SELECT * FROM parent WHERE NAME = 'Jones' FOR SHARE;
在该语句执行后,你可以安全地在表 CHILD 插入数据并提交事务。任何其他事务希望在表 PARENT 获取该行的排他锁都必须等待你的事务结束,以获得表中数据处于一致状态。
另一个例子,在表 CHILD_CODES 中存在一个 INTGER 类型的作为计数器的列,用来给表 CHILD 的每行数据设置一个唯一标识。这里,就不能使用一致性读或者共享读的方式来读取计数器的值,这是因为不同的事务可能同时看见相同的值,在插入时会出现 重复键 的错误。
什么不能使用 FOR SHARE ?
这是因为如果两个用户同时进行数据读取,在行上设置共享锁,在更新数据时有一个肯定会出现死锁。
为了实现计数器的读取并增长,首先要使用 FOR UPDATE 进行数据的锁定读,之后再进行数据的更新:
SELECT counter_field FROM child_codes FOR UPDATE;
UPDATE child_codes SET counter_field = counter_field + 1;
SELECT … FOR UPDATE 语句读书最新的数据,并且为读取的每一行都设置一个排他锁,这与 UPDATE 语句一样。
前面的示例仅仅为了介绍 SELECT … FOR UPDATE 如何工作,在 MySQL 中,可以通过另一种方式来生成唯一标识,完成查询增长只需要一次表的访问:
UPDATE child_codes SET counter_field = LAST_INSERT_ID(counter_field + 1);
SELECT LAST_INSERT_ID();
以上的 SELECT 语句只检索标识符信息,不访问表。
使用 NOWAIT 和 SKIP LOCKED 的并发锁定读
如果某行数据被一个事务锁定,那么其他事务使用 SELECT … FOR UPDATE 或 SELECT … FOR SHARE 语句请求相同行的锁,就必须阻塞等待前面锁的释放。这会阻止其他事务对这些行的更新或删除。然而,如果你希望在查询行被锁定时查询可以立即返回,这时等待锁的释放就不必要了。
为了避免等待锁的释放,对于 SELECT … FOR UPDATE 或 SELECT … FOR SHARE 的锁定读语句,可以使用 NOWAIT 和 SKIP LOCKED 子句来实现。
-
NOWAIT
对于使用了该子句的锁定读语句,不会等待任何锁的释放,如果请求行已被锁定,该语句会立即执行并返回一个 ERROR。
-
SKIP LOCKED
对于使用了该子句的锁定读语句,也同样不会等待锁的释放,查询会立即执行,并在结果集中移除被锁定的行。
注意,查询跳过锁定行返回的数据可能会不一致,因此该子句不适用于一般的事务操作。但是可以在类似队列的表中使用来避免锁竞争。
NOWAIT 和 SKIP LOCKED 都只适用于行级别的锁。
总结
通过以上介绍,我们已经了解了 InnoDB 提供了两种类型的锁定读,分别为 SELECT … FOR SHARE 和 SELECT … FOR UPDATE,两者的区别在于设置锁的类型不同,分为共享锁和排他锁。这两种方式的使用需要结合实际情况,如在上面的例子提到若存在并发更新的情况,那就需要使用 SELECT … FOR UPDATE;仅为阻止其他事务对当前数据的更新删除,就可以使用 SELECT … FOR SHARE 等。
至此,关于 MySQL 的 InnoDB 引擎的事务模型中的锁定读已经介绍完毕,了解清楚什么是锁定读后,如何使用需要在具体场景具体分析。接下来,我将继续通过官方文档来进行学习介绍关于 InnoDB 事务模型的其他内容。
参考资料
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html