数据库的锁 Recommended
10月 16, 2021
数据库里的锁根据范围和维度都有不同的实现和叫法,如果我这样列出来你可能会很焦虑:GAP 锁,记录锁,显式锁定,表级锁,行级锁,页级锁,咨询锁,间隙锁,共享锁,排他锁,互斥锁,自旋锁,读写锁,悲观锁,乐观锁,因为当初我也是这种感觉,现在我重新整理一遍,希望对你也有帮助。
锁是一种并发控制手段,相对的还有其他的并发控制手段比如:MVCC(多版本并发控制),这个以后再说。
悲观锁(Pessimistic Lock) #
悲观锁,顾名思义,对数据的并发修改的一致性持悲观态度,必要时通过加锁的措施确保数据的修改不会出现问题。悲观锁是一种在内存中真实存在的锁,数据库一般有自己的实现,这种在真实存在的锁一般分为两种:共享锁和排他锁。
共享锁(S 锁) #
共享锁(Shared Lock)简称 S 锁,S 锁针对的是读场景,也叫读锁,当事务要读取一个记录时,要先获取记录的 S 锁。
排他锁(X 锁) #
排他锁(Exclusive Lock)简称 X 锁,X 锁针对的是写场景,也叫写锁,当事务要更改一个记录时,要先获取记录的 X 锁。排他锁有的地方也叫互斥锁、独占锁。(互斥锁经常和读写锁在一起比较,这里尽量不要叫互斥锁避免歧义)
S 锁和 X 锁的兼容关系
兼容性 | X 锁 | S 锁 |
---|---|---|
X 锁 | 不兼容 | 不兼容 |
S 锁 | 不兼容 | 兼容 |
小结 #
上面的场景都是行锁,行锁有个麻烦的地方在于,数据库在遍历表的每一行之前,是不知道这行记录有没有加锁的。遍历这种事数据库当然是能不干就不干的,为了解决这个问题,引入了表级锁,但是一般也不会对表加 S 锁或 X 锁,以免影响的数据太多。所以一般是用意向锁来解决表级别的锁检查问题。
意向共享锁(IS 锁) #
当事务准备在某条记录加 S 锁时,会先在表上加一个 IS 锁(Intention Shared Lock)。
意向独占锁(IX 锁) #
当事务准备在某条记录加 X 锁时,会先在表上加一个 IX 锁(Intention Exclusive Lock)。
兼容性 #
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
乐观锁 #
上面的都是悲观锁的实现,接下来说一下乐观锁(Optimistic Locking)。相对悲观锁的真实存在来说,乐观锁虽然相对地说成锁,其实它只是一种并发控制的思想,并没有在数据库里加锁,只是在数据提交更新成功时,对数据冲突语法做检测,如果冲突这返回异常,让用户决定是否回滚。
一般实现乐观锁的方式有:
- 版本号校验
- CAS实现(compare and swap)
版本号校验 #
先取出要更新记录的 version,在更新时将 version + 1,如果更新失败就会返回错误。
SQL 例子:
update table set name = 'xxx', version = version + 1 where id = 1 and version = version;
CAS 实现 #
CAS
操作中包含三个操作数 :
- 需要读写的内存位置
V
- 进行比较的预期原值
A
- 拟写入的新值
B
如果内存位置 V
的值与预期原值 A
相匹配,那么处理器会自动将该位置值更新为新值 B
。否则处理器不做任何操作。无论哪种情况,它都会在 CAS
指令之前返回该位置的值(在 CAS
的一些特殊情况下将仅返回 CAS
是否成功,而不提取当前值)。许多 CAS
的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
其实也是校验的一种方式,CAS 在各种语言中都有实现,可以自行查阅相关资料。
InnoDB 里的行级锁 #
前面的 S 锁和 X 锁等都是一些通用的概念,在 InnoDB 里面,正经实现的行级锁是记录锁、间隙锁和临键锁。
记录锁(Record Lock) #
同样的,记录锁分 S 型记录锁和 X 型记录锁。
在 MySQL 里使用共享锁:
select * from table where id = xxx lock in share mode;
这允许别的事务获取该记录的 S 锁,获取该记录的 X 锁会被阻塞。
在 MySQL 里使用排他锁:
select * from table where id = xxx for update;
其他事务获取该记录的 X 锁或 S 锁都会被阻塞。
间隙锁(Gap Lock) #
我们知道可重复读隔离级别的事务可能有幻读问题,对于一条事务开始时还没产生的新增记录,是无法上锁的。这个时候要想给某个范围的记录加锁,就是间隙锁本锁,所以间隙锁也是排他锁了。
在 MySQL 中使用 gap lock:
SELECT * FROM table WHERE id BETWEEN 1 AND 20 FOR UPDATE;
临键锁(Next-Key Lock) #
临键锁是间隙锁和记录锁集合,它可以同时锁定一个范围和一行记录。
插入意向锁(Insert Intension Lock) #
如果有事务遇到间隙锁或临键锁而处于等待状态,也会在内存生成一个锁结构,把这种锁叫做插入意向锁。
隐式锁 #
上面四种行级锁都是显示加锁,相对的还有一种是隐式锁。
一个事务在执行 insert 操作时,其他事务如果想对该记录加 S 锁或者 X 锁时,会先检查当前事务是否是活跃的事务,如果不是就可以正常加锁,如果是活跃事务,其他事务就帮助当前事务创建一个 X 锁,然后自己进入等待状态。隐式锁是 InnoDB 实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。另外,隐式锁是针对被修改的 B+ Tree 记录,因此都是记录类型的锁,不可能是间隙锁或 Next-Key 类型。
InnoDB 里的表级锁 #
AUTO-INC 锁 #
MySQL 在执行 id 为 AUTO_INCREMENT
的表的插入语句时,这时系统会自动为他赋予递增的值。一个事务执行插入时会持有 AUTO-INC 锁,其他插入事务需要等待锁的释放而处于阻塞,从而保证递增值是连续的。
一致性读 #
一致性非锁定读(Consistent Read) #
也叫快照读,InnoDB 利用 MVCC 在读取一行有 X 锁的时候不会去等待,而是选择去读当前行的一个快照,这些快照数据是由 undo log 组成的版本链。在 RC 隔离级别下,总是读取被锁定行的最新一份快照数据,在 RR 隔离级别下,总是读取自己事务开始时的快照,由此可见一致性非锁定读可以显著提高数据库的并发能力。
一致性锁定读(Locking Read) #
相对的,也有一致性锁定读,其实就是加 X 锁或 S 锁读了。
死锁(Dead Lock) #
产生死锁的条件 #
既然 InnoDB 中实现的锁是悲观的,那么不同事务之间就可能会互相等待对方释放锁造成死锁。MySQL 也能在发生死锁时及时发现问题(wait-for graph 机制),并保证其中的一个事务能够正常工作。
解决死锁的方式 #
超时机制 #
InnoDB 里面有参数可以设置事务超时回滚的时间,其中一个事务回滚,另一个事务继续执行。超时机制虽然简单,但是如果并发较大,或者死锁产生的概率较大,或者事务操作过多时,超时回滚会浪费掉一些资源和时间。
wait-for graph 机制 #
此机制需要两个信息:锁的信息链表和事务等待链表。每个事务在等待时会判断是否存在回路,若存在则有死锁,此时 InnoDB 会回滚 undo 量最小的事务。
latch 锁 #
有人可能看到一些文章在数据库里提到互斥锁(mutex)、自旋锁(spin lock)、读写锁(rwlock)这三种锁的时候可能会不太理解(比如我)。后来我看了《MySQL 技术内幕:InnoDB 存储引擎》的第六章就大概明白了,这里也顺便提一下这三种锁与上面其他锁的关系。
这里还要区分锁中容易令人混淆的概念 lock 与 latch。在数据库中,lock 与 latch 都可以被称为“锁”。但是两者有着截然不同的含义,本章主要关注的是 lock。
latch 一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在InnoDB 存储引擎中,latch 又可以分为 mutex(互斥量)和 rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。
lock 的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般 lock 的对象仅在事务 commit 或 rollback 后进行释放(不同事务隔离级别释放的时间可能不同)。此外,lock,正如在大多数数据库中一样,是有死锁机制的。
我总结下,大概就是,虽然理念、模型有类似的地方,但是当我们谈数据库的锁时,一般指的是 lock 锁。在谈到线程并发、操作系统资源调度时,一般指的是 latch 锁,这也就是为什么上面我建议用“排他锁”而不用“互斥锁”的原因,可能这也是我最初对一些锁的概念上很模糊的原因吧。
互斥锁 #
互斥锁没有读写之分,在操作系统里,只有是否在占用 CPU 资源之分,在这一点上,很容易和读写锁做区别。
自旋锁 #
自旋锁和互斥锁具有一样的资源占用特点,但是也有一点区别:
- 互斥锁加锁失败后,线程会进入阻塞状态,释放 CPU 给其他线程;
- 自旋锁加锁失败后,线程会进入忙等待状态,直到它拿到锁;
自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。
读写锁 #
顾名思义,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。所以,读写锁适用于能明确区分读操作和写操作的场景。
最后 #
我们可以发现,一般事务隔离级别越高,需要加的锁就越严格,对并发事务的处理的性能影响就越高,所以加锁本身也是件矛盾的事情:加锁是为了解决并发带来的问题,但是锁越加越会影响并发能力。因此在数据库领域里,大佬们就开了新坑:用 MVCC 来解决一些并发问题,同时还能一定程度解决不可重复读、幻读等问题,感兴趣可以了解一下,也可以参考我的下一篇文章:数据库的事务。