2021-10-29 16:25:27 浏览数 (1)

發佈於 2021-10-12

锁在并发编程中扮演着非常重要的角色,本篇,我将梳理各种锁分类的概念以及各种锁实现类之间的区别与联系。

为什么要有锁

因为在应用层面,不可避免地会出现并发操作,就会出现资源竞争,因此程序代码中就需要锁机制来进行同步。同样的,在数据库层面,不可避免地多用户同一时刻对数据进行读写操作,因此也需要锁机制保证数据准确性。

锁操作

我们对锁的操作可分为加锁(lock)或者解锁(unlock),学术一点地说法也称为获取(acquire)和释放(release)。

锁的种类

开发过程中,我们常听到以下这些名词,悲观锁乐观锁互斥锁信号量读写锁自旋锁等,我们来具体介绍一下这些基础概念。

悲观锁与乐观锁

锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁。只是在并发情况下的两种不同策略。 我们说的所有的锁均属于悲观锁或乐观锁。

  1. 悲观锁(Pessimistic Lock): 即每次读写数据时都认为数据会被修改。所以每次读写数据时都会加锁
  2. 乐观锁(Optimistic Lock): 每次读数据时都认为不会被修改。所以不会上锁,但是如果更新数据,则会在更新前检查在读取至更新这段时间数据是否被修改过。如果修改过,则重新读取,并尝试更新,循环上述步骤直到更新成功或超时放弃

一句话记忆: 悲观锁阻塞事务,乐观锁回滚重试。 这两种策略各有优劣:

  1. 乐观锁适用于写少读多的情况,即冲突极少发生,这样可以省去锁的开销,加大了系统的整个吞吐量。
  2. 悲观锁适用于冲突经常发生的情况,防止不断的进行重试,降低性能。

CAS

CAS 即 CompareAndSwap(比较并替换):

  1. Compare: 读取到了一个值 A,在将其更新为 B 之前,检查原值是否仍为 A(未被其他线程改动)
  2. Swap: 如果是,将 A 更新为 B。否则,循环重试

以上两步为一个不可分割的原子操作,即 CPU 的一条指令。

有了 CAS,就可以实现一个乐观锁,因为整个过程中并没有”加锁”、”解锁”操作,因此乐观锁策略也被称为无锁编程。

互斥锁

互斥锁(Mutex)无疑是最常见的多线程同步方式。其思想简单粗暴,多线程共享一个互斥量,然后线程之间去竞争,得到锁的线程可以进入临界区执行代码。 互斥锁是睡眠等待(sleep waiting)类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省 CPU 资源,缺点就是休眠唤醒会消耗一点时间。

读写锁

读写锁(Read-Write Lock),顾名思义”读写锁”就是对于临界区区分读和写。在读多写少的场景下,不加区分的使用互斥量显然是有点浪费的。读写锁有一个别称叫共享-独占锁,其读共享,写独占。 读写锁的特性:

  1. 当读写锁被加了写锁时,其他线程对该锁加读锁或者写锁都会阻塞(不是失败)。
  2. 当读写锁被加了读锁时,其他线程对该锁加写锁会阻塞,加读锁会成功。

自旋锁

自旋锁(SpinLock),更为通俗的一个词是忙等待(busy waiting),这是与互斥锁最大的区别。自旋锁不会引起线程休眠,当共享资源的状态不满足的时候,自旋锁会不停地循环检测状态。这既是优点也是缺点,不休眠就不会引起上下文切换,但是会比较浪费 CPU 资源。自旋锁的意义在于优化一些短时间的锁。

锁与数据库隔离级别的关系

同应用锁一样,数据库锁中最基本的也有读写锁:

  1. 共享锁: 又称 S 锁、读锁,事务 A 对一个资源加了 S 锁后其他事务仍能共享读该资源,但不能对其进行写,直到 A 释放锁为止。
  2. 排它锁: 又称 X 锁、写锁,事务 A 对一个资源加了 X 锁后只有 A 本身能对该资源进行读和写操作,其他事务对该资源的读和写操作都将被阻塞,直到 A 释放锁为止

我们之前讲过,数据库的 4 种隔离级别,分别是:

  1. READ UNCOMMITTED
  2. READ COMMITTED
  3. REPEATABLE READ
  4. 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)锁

0 人点赞