数据库的锁

数据库的锁

Oct 16, 2021
2021
DB, MySQL

数据库里的锁的范围和维度都有不同的实现和叫法,如果我这样列出来你可能会很焦虑(GAP 锁,记录锁,显式锁定,表级锁,行级锁,页级锁,咨询锁,间隙锁,共享锁,排他锁,互斥锁,自旋锁,读写锁,悲观锁,乐观锁),因为当初我也是这种感觉,现在我重新整理一遍。

锁是一种并发控制手段,相对的还有其他的并发控制手段比如:MVCC(多版本并发控制),我们以后再说。

悲观锁(Pessimistic Lock)

悲观锁,顾名思义,对数据的并发修改的一致性持悲观态度,必要时通过加锁的措施确保数据的修改不会出现问题。悲观锁是一种在内存中真实存在的锁,数据库一般有自己的实现,

这种在真实存在的锁一般分为两种:共享锁和排他锁。

共享锁(Shared Lock)

共享锁简称 S 锁,S 锁针对的是读场景,也叫读锁,当事务要读取一个记录时,要先获取记录的 S 锁。

排他锁(Exclusive Lock)

排他锁简称 X 锁,X 锁针对的是写场景,也叫写锁,当事务要更改一个记录时,要先获取记录的 X 锁。排他锁有的地方也叫互斥锁独占锁。(互斥锁经常和读写锁在一起比较,这里尽量不要叫互斥锁避免歧义)

S 锁和 X 锁的兼容关系

兼容性 X 锁 S 锁
X 锁 不兼容 不兼容
S 锁 不兼容 兼容

小结

上面的场景都是行锁,行锁有个麻烦的地方在于,数据库在遍历表的每一行之前,是不知道这行记录有没有加锁的。遍历这种事数据库当然是能不干就不干的,为了解决这个问题,引入了表级锁,但是一般也不会对表加 S 锁或 X 锁,以免影响的数据太多。所以一般是用意向锁来解决表级别的锁检查问题。

意向共享锁(Intention Shared Lock)

当事务准备在某条记录加 S 锁时,会先在表上加一个 IS 锁。

意向独占锁(Intention Shared Lock)

当事务准备在某条记录加 X 锁时,会先在表上加一个 IX 锁。

兼容性

兼容性 X IX S IS
X 不兼容 不兼容 不兼容 不兼容
IX 不兼容 兼容 不兼容 兼容
S 不兼容 不兼容 兼容 兼容
IS 不兼容 兼容 兼容 兼容

乐观锁(Optimistic Locking)

上面的都是悲观锁的实现,接下来说一下乐观锁。相对悲观锁的真实存在来说,乐观锁虽然相对地说成锁,其实它只是一种并发控制的思想,并没有在数据库里加锁,只是在数据提交更新成功时,对数据冲突语法做检测,如果冲突这返回异常,让用户决定是否回滚。

一般实现乐观锁的方式有:

  1. 版本号校验
  2. 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 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

其实也是校验的一种方式,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 锁的时候不会去等待,而是选择去读当前行的一个快照,在 RC 隔离级别下,总是读取被锁定行的最新一份快照数据,在 RR 隔离级别下,总是读取自己事务开始时的快照。可见一致性非锁定读可以显著提高数据库的并发能力,

一致性锁定读(Locking Read)

相对的,也有一致性锁定读,其实就是加 X 锁或 S 锁读了。

死锁(Dead Lock)

产生死锁的条件

既然 InnoDB 中实现的锁是悲观的,那么不同事务之间就可能会互相等待对方释放锁造成死锁。MySQL 也能在发生死锁时及时发现问题(wait-for graph 机制),并保证其中的一个事务能够正常工作。

解决死锁的方式

  1. 超时机制

InnoDB 里面有参数可以设置事务超时回滚的时间,其中一个事务回滚,另一个事务继续执行。超时机制虽然简单,但是如果并发较大,或者死锁产生的概率较大,或者事务操作过多时,超时回滚会浪费掉一些资源和时间。

  1. 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 来解决一些并发问题,同时还能一定程度解决不可重复读、幻读等问题,感兴趣可以了解一下。

上一篇:数据库的事务 下一篇:MobX 入门