有过编程语言并发学习经验的同学,应该都了解过读写锁的概念。读写锁主要是为了解决多读少写条件下,程序的并发性能问题。它的特点即是:如果一个线程持有了读锁,那么其他线程也是可以继续读取它锁定的数据,但是不能进行修改,即加写锁;如果一个线程持有了写锁,那么它会阻止任何其他线程的读取和修改。在数据库领域,也有读写锁的概念,下面让我们来了解一下。
在一个事务中,查询数据并且插入或者更新相关的数据,使用常规的 SELECT 语句并不能提供足够的保护。其他事务可以更新或者删除你刚好查询的数据行。因此,InnoDB 支持了两种不同类型的锁定读来提供额外的安全性。Locking Reads
这里我把它称为锁定读,它的作用就是在查询数据时加上读锁或者写锁。
注: 我觉得 Exclusive Lock 翻译成独占锁也挺好的。
共享锁和排他锁针对的是数据行级别的锁,而不是针对的整个表的锁。不过,在某些情况下,它也会从行锁退化成表锁(这是很严重的问题,应该极力避免它的发生)。
因此在一个事务中查询数据时,需要根据需要来使用共享锁或者排他锁:
SELECT ... FOR SHARE
SELECT ... FOR UPDATE
注意:锁定读必须在事务语句中才可以生效,或者关系事务的自动提交。
SELECT ... FOR SHARE
在一个事务中,给查询的数据行设置一个共享锁。在事务提交之前,其他的事务(或者会话)可以读取这些数据行,但是不能修改它们。如果这些数据行中的任何数据被其他事务修改了并且没有提交,你的加锁查询必须等待,直到其他事务结束,并且会获取到最新的值。
SELECT ... FOR SHARE
是SELECT ... LOCK IN SHARE MODE
的替代,但是为了向后兼容后者依然可以使用,所以这两句是等价的。因为我这里使用的 MySQL 是 8.0 的版本,所以参考的文档也是 8.0 的。
SELECT ... FOR UPDATE
在一个事务中,给查询的数据行设置一个排他锁,对于搜索遇到的索引记录,它会锁定数据行和相关的索引项,这与使用 UPDATE
语句的效果是相同的。其他事务会被阻止更新(UPDATE
)这些数据行、执行查询加共享锁(SELECT ... FOR SHARE
)或者读取在某些隔离级别的数据。一致性读取(Consistent Read)会忽略在读取视图中存在的记录上设置在数据行上的任何锁定。(旧版本的记录行无法被锁定;它们是通过 undo logs 重建在记录的内存副本上的)。
注:后面这个一致性读取涉及另一个 MySQL 的重要特性:MVCC,多版本并发控制
。
所有通过 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;
这里来分别实践一下加读锁和加写锁的场景,以及这个过程。
测试表结构:现在是有一个用户账户表(t_user_account),现在再添加一个用户信息表(t_user_info),它的 account_id 字段是用户账户表的主键。
CREATE table t_user_account(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
balance INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE table t_user_info(
id INT NOT NULL AUTO_INCREMENT,
account_id INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
那么现在我需要给用户信息表添加一条记录,这样会有问题吗?这里显然是需要事务的,因为这是多条 SQL 语句了,但是这样就高枕无忧了吗?
如果在第一个事务执行查询之后,第二个事务把它查询的数据给删除了呢?这样就会失去参照完整性(referential integrity)了。所以,为了确保在第一个事务执行完成之前,它所依赖的数据不被修改,我们需要加读锁(当然可以加写锁了,但是加了写锁其他事务就无法读取了,影响系统的性能)。如下图,加了写锁之后,第二个事务尝试去删除对应的记录就会被卡住(默认的超时时间是 50s)。
这里来模拟一个并发存取钱的场景:
事务 A:查询 id = 1 的用户的余额,然后模拟存 200。
事务 B:查询 id = 1 的用户的余额,然后模拟取 400。
如果这里不加写锁,那么我们来看一下可能出现的错误:
由于可重复读的原因,第二个事务读取的是它那个时间点数据的快照(MVCC),这样就会导致最终的金额是错误的,两个事务对同一个金额进行修改。也就是说,在第一个事务读取到金额之后,应该阻止其他的事务进行读取,在它执行结束后,才能允许其他事务操作。所以这里需要加写锁,下面是在事务开始之后加写锁之后的执行效果,最终的结果是正确的。这样 id = 1 的这行记录就会被锁住,其他事务是无法对其进行查询(加锁 SELECT)和修改(UPDATE、DELETE)。