锁
發佈於 2021-10-12
锁在并发编程中扮演着非常重要的角色,本篇,我将梳理各种锁分类的概念以及各种锁实现类之间的区别与联系。
为什么要有锁
锁操作
锁的种类
悲观锁与乐观锁
锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁。只是在并发情况下的两种不同策略。 我们说的所有的锁均属于悲观锁或乐观锁。
- 悲观锁(Pessimistic Lock): 即每次读写数据时都认为数据会被修改。所以每次读写数据时都会加锁
- 乐观锁(Optimistic Lock): 每次读数据时都认为不会被修改。所以不会上锁,但是如果更新数据,则会在更新前检查在读取至更新这段时间数据是否被修改过。如果修改过,则重新读取,并尝试更新,循环上述步骤直到更新成功或超时放弃
一句话记忆: 悲观锁阻塞事务,乐观锁回滚重试。 这两种策略各有优劣:
- 乐观锁适用于写少读多的情况,即冲突极少发生,这样可以省去锁的开销,加大了系统的整个吞吐量。
- 悲观锁适用于冲突经常发生的情况,防止不断的进行重试,降低性能。
CAS
CAS 即 CompareAndSwap(比较并替换):
- Compare: 读取到了一个值 A,在将其更新为 B 之前,检查原值是否仍为 A(未被其他线程改动)
- Swap: 如果是,将 A 更新为 B。否则,循环重试
以上两步为一个不可分割的原子操作,即 CPU 的一条指令。
有了 CAS,就可以实现一个乐观锁,因为整个过程中并没有”加锁”、”解锁”操作,因此乐观锁策略也被称为无锁编程。
互斥锁
互斥锁(Mutex)无疑是最常见的多线程同步方式。其思想简单粗暴,多线程共享一个互斥量,然后线程之间去竞争,得到锁的线程可以进入临界区执行代码。 互斥锁是睡眠等待(sleep waiting)类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省 CPU 资源,缺点就是休眠唤醒会消耗一点时间。
读写锁
读写锁(Read-Write Lock),顾名思义”读写锁”就是对于临界区区分读和写。在读多写少的场景下,不加区分的使用互斥量显然是有点浪费的。读写锁有一个别称叫共享-独占锁,其读共享,写独占。 读写锁的特性:
- 当读写锁被加了写锁时,其他线程对该锁加读锁或者写锁都会阻塞(不是失败)。
- 当读写锁被加了读锁时,其他线程对该锁加写锁会阻塞,加读锁会成功。
自旋锁
自旋锁(SpinLock),更为通俗的一个词是忙等待(busy waiting),这是与互斥锁最大的区别。自旋锁不会引起线程休眠,当共享资源的状态不满足的时候,自旋锁会不停地循环检测状态。这既是优点也是缺点,不休眠就不会引起上下文切换,但是会比较浪费 CPU 资源。自旋锁的意义在于优化一些短时间的锁。
锁与数据库隔离级别的关系
同应用锁一样,数据库锁中最基本的也有读写锁:
- 共享锁: 又称 S 锁、读锁,事务 A 对一个资源加了 S 锁后其他事务仍能共享读该资源,但不能对其进行写,直到 A 释放锁为止。
- 排它锁: 又称 X 锁、写锁,事务 A 对一个资源加了 X 锁后只有 A 本身能对该资源进行读和写操作,其他事务对该资源的读和写操作都将被阻塞,直到 A 释放锁为止
我们之前讲过,数据库的 4 种隔离级别,分别是:
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
一句话解释: 数据库事务不同的隔离级别对锁的使用是不同的,锁的应用最终导致不同事务的隔离级别。 我们还要注意,数据库不同隔离级别的加锁和释放锁的过程由数据库自身来维护,不需要我们人为干涉。 还要注意,现代数据库的对于 READ COMMITTED、REPEATABLE READ 两种隔离级别采用 MVCC 策略实现以提升效率。
MVCC
要解决冲突,可以使用完全基于锁的并发控制,比如 2PL,这种方式开销比较高,而且无法避免死锁。因此出现多版本并发控制(MVCC)。是一种用来解决读写冲突的无锁并发控制,在读操作不阻塞写操作,写操作不阻塞读操作的同时,避免了脏读和不可重复读。 对于写写冲突,我们可以使用 MVCC 2PL 进行控制。
OCC
乐观并发控制(OCC)是一种用来解决写写冲突的无锁并发控制,先进行修改,在提交事务前,检查一下事务开始后,有没有新提交改变,如果没有就提交,如果有就放弃并重试。
隔离级别的使用
对于 READ UNCOMMITTED 和 SERIALIZABLE,我们使用极少,因为对于 READ UNCOMMITTED,可以读到未提交的读,与我们应用事务的初衷不符,基本上不会使用该隔离等级。而对于 SERIALIZABLE 完全基于锁的并发控制,很少有文章会详细介绍 SERIALIZABLE 隔离级别,真相是:对很多数据库来说,Serializable就是个形象工程,在 RC、RR 隔离级别下,不带 for share (lock in share mode) / for update的 select 称为 plain select,从快照中读取数据的同时,其它线程可以并发写数据到数据库,读写不冲突。而在 SERIALIZABLE 隔离级别下,所有的 select 都会加读写锁,不存在 plain select,读写无法并行。此时数据库的吞吐量和响应时间相比其它弱隔离级别下降很多,访问延迟很难确定。
不同 SQL 语句对加锁的影响
不同的 SQL 语句当然会加不同的锁,总结起来主要分为五种情况:
- SELECT … 语句正常情况下为快照读,不加锁;
- SELECT … LOCK IN SHARE MODE 语句为当前读,加 S 锁;
- SELECT … FOR UPDATE 语句为当前读,加 X 锁;
- 常见的 DML 语句(如 INSERT、DELETE、UPDATE)为当前读,加 X 锁;
- 常见的 DDL 语句(如 ALTER、CREATE 等)加表级锁,且这些语句为隐式提交,不能回滚。
单纯使用锁实现不同隔离级别的示例
我们有如下User表,用于之后的示例: | id | age | | – | – | | 1 | 10 | | 2 | 20 | | 3 | 30 |
READ UNCOMMITTED 隔离级别锁
READ UNCOMMITTED 规定:
- 事务 A 对当前被读取的数据不加锁
- 事务 A 开始更新一行数据时,必须先对其加 X 锁,直到事务结束才释放
示例:
事务 A、B 将数据库修改隔离级别为 READ UNCOMMITTED,事务 A 查询 id 为 3 的用户的 age
代码语言:javascript复制mysql> set session transaction isolation level read uncommitted;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-UNCOMMITTED |
------------------
mysql> start transaction;
mysql> select age from `user` where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
事务 B 对 id 为 3 的用户的 age 进行更新
代码语言:javascript复制mysql> set session transaction isolation level read uncommitted;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-UNCOMMITTED |
------------------
mysql> start transaction;
mysql> update `user` set age=40 where id=3;
B 事务未提交,在 A 事务中再进行一次查询,发现结果已经被修改
代码语言:javascript复制mysql> select age from `user` where id=3;
------ -------
| id | age |
------ -------
| 3 | 40 |
------ -------
在 A 事务中再进行一次更新,发现被阻塞
代码语言:javascript复制mysql> update `user` set age=50 where id=3;
由此可以看出 READ UNCOMMITTED 隔离级别,对于读操作不加锁,写操作加 X 锁,会产生脏读问题。
READ COMMITTED 隔离级别锁
使用 MVCC 前,READ COMMITTED 隔离级别锁应用为:
- 事务 A 对当前被读取的数据加共享锁,一旦读完该行,立即释放该共享锁
- 事务 A 在更新某行数据时,必须对其加上排他锁,直到事务结束才释放
示例:
事务 A、B 将数据库修改隔离级别为 READ COMMITTED,事务 A 查询 id 为 3 的用户的 age
代码语言:javascript复制mysql> set session transaction isolation level read committed;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-COMMITTED |
------------------
mysql> start transaction;
mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
事务 B 对 id 为 3 的用户的 age 进行更新
代码语言:javascript复制mysql> set session transaction isolation level read committed;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-COMMITTED |
------------------
mysql> start transaction;
mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
mysql> update user set age=40 where id=3;
mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 40 |
------ -------
B 事务未提交,在事务 A 中再进行一次查询,发现被阻塞
代码语言:javascript复制mysql> select age from user where id=3;
B 事务提交后,在事务 A 中再进行一次查询
代码语言:javascript复制mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 40 |
------ -------
这个示例也验证了我们如上所说的加锁结果,但是我们会发现一个问题,那就是同一事务读取相同数据的值也可能不同,这个现象称为不可重复读。
REPEATABLE READ 隔离级别锁
使用 MVCC 前,REPEATABLE READ 隔离级别锁应用为:
- 事务 A 在读取某行数据时,必须对其加上共享锁,直到事务结束才释放
- 事务 A 在更新某行数据时,必须对其加上排他锁,直到事务结束才释放
事务 A 在加的是共享锁,并且要到事务结束才会释放该锁,也就意味着 A 在两次读取数据期间,事务不能对该数据进行更改,从而解决了不可重复读。
示例:
事务 A、B 将数据库修改隔离级别为 REPEATABLE READ,事务 A 查询 id 为 3 的用户的 age
代码语言:javascript复制mysql> set session transaction isolation level read committed;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-COMMITTED |
------------------
mysql> start transaction;
mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
事务 B 对 id 为 3 的用户的 age 进行更新会阻塞
代码语言:javascript复制mysql> set session transaction isolation level read committed;
mysql> select @@TX_ISOLATION
------------------
| @@TX_ISOLATION |
------------------
| READ-COMMITTED |
------------------
mysql> start transaction;
mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
mysql> update user set age=40 where id=3;
B 事务未提交,在事务 A 中再进行一次查询,发现结果未被修改
代码语言:javascript复制mysql> select age from user where id=3;
------ -------
| id | age |
------ -------
| 3 | 30 |
------ -------
我们从上例也可以看出使用锁进行隔离会影响并发效率,因此产生 MVCC 解决 REPEATABLE READ 下读写冲突问题来提升销量。根据可重复读的原理,其会引入新的问题: 幻读。
SERIALIZABLE 隔离级别锁
- 事务在读取数据时,必须先对其加表级共享锁(注意这里是表级) ,直到事务结束才释放;
- 事务在更新数据时,必须先对其加表级排他锁(注意这里是表级) ,直到事务结束才释放。
通过在一次操作中对整张表进行加锁,从而其他事务对整张表既不能 insert,也不能 delete,所以不会有行记录的增加或减少,从而保证了当前事务两次读之间数据的一致性,解决了幻读问题。然而根据串行化的原理,其会导致写冲突,因此并发度急剧下降,一般不使用该隔离级别。
锁的算法
- Record Lock: 单个行记录上的锁
- Gap Lock: 间隙锁,锁定一个范围,但不包含记录本身
- Next-Key Lock: Gap Lock Record Lock,锁定一个范围、索引之间的间隙,并且锁定记录本身,目的是为了防止幻读
MVCC 实现 READ COMMITTED 和 REPEATABLE READ
之前我们也说过,最早的数据库系统实现中,只使用了锁来实现隔离级别,因此只有读读之间可以并发,读写,写读,写写都要阻塞。 引入多 MVCC 后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了并发度。不同数据库的 MVCC 实现方式不同。
RC 隔离级别总是读取记录的最新版本,而 RR 隔离级别是读取该记录事务开始时的那个版本,虽然这两种读取的版本不同,但是都是快照数据,并不会被写操作阻塞,所以这种读操作称为快照读(Snapshot Read)。
MySQL 还提供了另一种读取方式叫当前读(Current Read),它读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁,根据语句和加锁的不同,又分成三种情况:
- 隔离级别为未提交读(RN)时读取都是当前读,默认 SELECT 为当前读
- SELECT … LOCK IN SHARE MODE: 加共享(S)锁
- SELECT … FOR UPDATE: 加排他(X)锁
- INSERT / UPDATE / DELETE: 加排他(X)锁