并发的任务对同一个临界资源进行操作,如果不采取措施,可能导致不一致,故必须进行并发控制(Concurrency Control)。InnoDB 存储引擎中使用的多种并发控制策略,按照锁的粒度划分,可以分成行锁和表锁。
1. 并发控制
并发控制保证数据一致性的常见手段有:锁(Locking)和数据多版本(Multi Versioning)。乐观锁和悲观锁其实都是并发控制的机制,同时它们在原理上就有着本质的差别;
- 乐观锁是一种思想,它其实并不是一种真正的『锁』,它会先尝试对资源进行修改,在写回时判断资源是否进行了改变,如果没有发生改变就会写回,否则就会进行重试,在整个的执行过程中其实都没有对数据库进行加锁;
- 悲观锁就是一种真正的锁了,它会在获取资源前对资源进行加锁,确保同一时刻只有有限的线程能够访问该资源,其他想要尝试获取资源的操作都会进入等待状态,直到该线程完成了对资源的操作并且释放了锁后,其他线程才能重新操作资源;
1.1 乐观锁和悲观锁如何选择呢?
乐观锁不会存在死锁的问题,但是由于更新后验证,所以当冲突频率和重试成本较高时更推荐使用悲观锁,而需要非常高的响应速度并且并发量非常大的时候使用乐观锁就能较好的解决问题,在这时使用悲观锁就可能出现严重的性能问题;在选择并发控制机制时,需要综合考虑上面的四个方面(冲突频率、重试成本、响应速度和并发量)进行选择。
2. 共享/排它锁
简单的锁住太过粗暴,连“读任务”也无法并行,任务执行过程本质上是串行的。
对数据的操作其实只有两种,也就是读和写,而数据库在实现锁时,也会对这两种操作使用不同的锁;InnoDB 实现了标准的行级锁,也就是共享锁(Shared Lock)和互斥锁(Exclusive Lock);共享锁和互斥锁的作用其实非常好理解:
- 共享锁(Share Locks,记为S锁),读取数据时加S锁
- 排他锁(eXclusive Locks,记为X锁),修改数据时加X锁
锁的兼容性上:
- 共享锁之间不互斥,即:读读可以并行
- 排他锁与任何锁互斥,即:写读,写写不可以并行
所以我们可以在数据库中并行读,但是只能串行写,只有这样才能保证不会发生线程竞争,实现线程安全。共享/排它锁的潜在问题是,不能充分的并行,解决思路是数据多版本。
3. 意向锁
共享/排它锁只是对某一个数据行进行加锁,InnoDB 支持多种粒度的锁,InnoDB 存储引擎引入了意向锁(Intention Lock),意向锁就是一种表级锁。它允许行级锁与表级锁共存,实际应用中,InnoDB使用的是意向锁。
意向锁是指,未来的某个时刻,事务可能要加共享/排它锁了,先提前声明一个意向。
如果没有意向锁,当已经有人使用行锁对表中的某一行进行修改时,如果另外一个请求要对全表进行修改,那么就需要对所有的行是否被锁定进行扫描,在这种情况下,效率是非常低的
意向锁也分为两种:
- 意向共享锁(intention shared lock, IS):事务想要在获得表中某些记录的共享锁,需要在表上先加意向共享锁;
- 意向互斥锁(intention exclusive lock, IX):事务想要在获得表中某些记录的互斥锁,需要在表上先加意向互斥锁;
意向锁协议(intention locking protocol)并不复杂,重点理解上面的加粗内容。由于意向锁仅仅表明意向,它其实是比较弱的锁(意向锁其实不会阻塞全表扫描之外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据),意向锁之间并不相互互斥,而是可以并行,但它会与共享锁/排它锁互斥。
3.1 插入意向锁
对已有数据行的修改与删除,必须加互斥锁X锁,那对于数据的插入,是否还需要加这么强的锁,来实施互斥呢?插入意向锁,孕育而生。
插入意向锁,是间隙锁(Gap Locks)的一种(所以,也是实施在索引上的),它是专门针对insert操作的。间隙锁是对索引记录中的一段连续区域的锁,当使用类似 SELECT * FROM users WHERE id BETWEEN 10 AND 20 FOR UPDATE;
的 SQL 语句时,就会阻止其他事务向表中插入 id = 15
的记录,因为整个范围都被间隙锁锁定了。间隙锁是存储引擎对于性能和并发做出的权衡,并且只用于某些事务隔离级别。
虽然间隙锁中也分为共享锁和互斥锁,不过它们之间并不是互斥的,也就是不同的事务可以同时持有一段相同范围的共享锁和互斥锁,它唯一阻止的就是其他事务向这个范围中添加新的记录。
//事务A先执行,在10与20两条记录中插入了一行,还未提交:
insert into t values(11, xxx);
//事务B后执行,也在10与20两条记录中插入了一行:
insert into t values(12, ooo);
/**
* 虽然事务隔离级别是RR,虽然是同一个索引,虽然是同一个区间,但插入的记录并不冲突,故这里:
* 1. 使用的是插入意向锁
* 2. 并不会阻塞事务B
**/
除了间隙锁外,InnoDB 还提供了另外两种锁算法。
记录锁(Record Lock)是加到索引记录上的锁。
如果我们使用 id 或者 last_name 作为 SQL 中 WHERE 语句的过滤条件,那么 InnoDB 就可以通过索引建立的 B+ 树找到行记录并添加索引,但是如果使用 first_name 作为过滤条件时,由于 InnoDB 不知道待修改的记录具体存放的位置,也无法对将要修改哪条记录提前做出判断就会锁定整个表。
Next-Key 锁,它是记录锁和记录前的间隙锁的结合,它作用其实是为了解决幻读的问题。
比如,有这样的数据:
| id | last_name | first_name | age | |------+-------------+--------------+-------| | 4 | stark | tony | 21 | | 1 | tom | hiddleston | 30 | | 3 | morgan | freeman | 40 | | 5 | jeff | dean | 50 | | 2 | donald | trump | 80 | //Age 的 Next-Key 锁就可以在需要的时候锁定以下的范围: (-∞, 21] (21, 30] (30, 40] (40, 50] (50, 80] (80, ∞)
当我们更新一条记录,比如
SELECT * FROM users WHERE age = 30 FOR UPDATE;
,InnoDB 不仅会在范围(21, 30]
上加 Next-Key 锁,还会在这条记录后面的范围(30, 40]
加间隙锁,所以插入(21, 40]
范围内的记录都会被锁定。
3.2 自增锁
假如我们插入的数据中有AUTO_INCREMENT列,InnoDB在RR隔离级别下,能解决幻读问题。
/**
* 数据表:t(id AUTO_INCREMENT, name);
* 数据:
* 1, shenjian
* 2, zhangsan
* 3, lisi
**/
//事务A先执行,还未提交:
insert into t(name) values(xxx);
//事务B后执行:
insert into t(name) values(ooo);
1. 事务A先执行insert,会得到一条(4, xxx)的记录,由于是自增列,InnoDB会自动增长,注意此时事务并未提交;
2. 事务B后执行insert,假设不会被阻塞,那会得到一条(5, ooo)的记录;
3. 事务A继续insert:会得到一条(6, xxoo)的记录。
4. 事务A再select:select * from t where id>3,得到(4, xxx)和(6, xxoo)
咦,这对于事务A来说,就很奇怪了,对于AUTO_INCREMENT的列,连续插入了两条记录
自增锁是一种特殊的表级别锁(table-level lock),专门针对事务插入AUTO_INCREMENT类型的列。最简单的情况,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。与此同时,InnoDB提供了innodb_autoinc_lock_mode配置,可以调节与改变该锁的模式与行为。
4. 事务隔离级别
ISO 和 ANIS SQL 标准制定了四种事务隔离级别,而 InnoDB 遵循了 SQL:1992 标准中的四种隔离级别:READ UNCOMMITED
、READ COMMITED
、REPEATABLE READ
和 SERIALIZABLE
;每个事务的隔离级别其实都比上一级多解决了一个问题:
RAED UNCOMMITED | 使用查询语句不会加锁,可能会读到未提交的行(Dirty Read) |
READ COMMITED | 只对记录加记录锁,而不会在记录之间加间隙锁,所以允许新的记录插入到被锁定记录的附近,所以再多次使用查询语句时,可能得到不同的结果(Non-Repeatable Read); |
REPEATABLE READ | 多次读取同一范围的数据会返回第一次查询的快照,不会返回不同的数据行,但是可能发生幻读(Phantom Read);它是默认的事务隔离级别 |
SERIALIZABLE | InnoDB 隐式地将全部的查询语句加上共享锁,解决了幻读的问题 |
引用资料