详解 MySQL 锁

数据库锁设计的初衷是 处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则,而锁就是用来实现这些访问规则的重要数据结构。

以 InnoDB 引擎为例,根据加锁的范围,MySQL 里面的锁可以分为 行级锁、表级锁、全局锁 三类。

行级锁

行级锁是 MySQL 中锁定粒度 最细 的一种锁,表示只针对当前操作的数据行进行加锁。行级锁是 InnoDB 引擎的特性之一,能大大减少数据库操作的冲突,并发度最高;但加锁的开销也最大,可能会出现死锁

在 InnoDB 中支持三种行锁定方式:

  • 行锁(Record Lock):锁定一个或多个行记录,分为共享锁和排他锁。
  • 间隙锁(Gap Lock):锁定行记录的间隙,即锁定一个范围,但不包括记录本身。
  • Next-Key Lock:行锁和间隙锁合称为 Next-Key Lock,可以锁定一个范围,并且包括记录本身(前开后闭区间)。

注意:InnoDB 行锁是通过给索引上的索引项加锁来实现的,这意味着只有通过索引检索数据时,才可以使用行锁,否则将使用表锁。

共享锁

**共享锁(Shared Lock,S)**,又称为 读锁,可以在事务间互相共享,多个用户可以同时读取同一个资源(但无法修改),而不会造成阻塞。若事务 T 对数据对象 A 加上 S 锁,则事务 T 可以读取 A 但不能修改 A(若此时 A 上没有其他 S 锁,则 T 可以修改 A,但不推荐这样做),其他事务只能再对 A 加 S 锁,而不能加 X 锁,直到 T 释放 A 上的 S 锁。这保证了其他事务可以访问 A,但在 T 释放 A 上的 S 锁之前不能对 A 做任何修改。

语法:select ... lock in share mode

排他锁

排他锁(Exclusive Lock,X),又称为 写锁,会排斥其他所有获取锁的请求(S 锁和 X 锁),一直阻塞,直到事务操作完成。若事务 T 对数据对象 A 加上 X 锁,则事务 T 可以读取 A 也可以修改 A,其他事务不能再对 A 加任何锁(可以同时读取 A 但不能修改),直到 T 释放 A 上的 X 锁。

语法:select ... for update

注意:在 MySQL InnoDB 引擎下,每个 DML 语句都会自动给涉及到的数据加上排他锁,DQL 语句不会加任何类型的锁。所以加排他锁的数据行在其他事务中不能被修改,也不能通过 lock in share modefor update 的方式查询数据,但可以直接通过 select … from … 进行查询。

使用 DML 语句时发生的锁定也被称为 “隐式锁定”,而通过 “共享锁”、“排他锁” 等方式导致的锁定被称为 “显式锁定”。

间隙锁

我们知道,产生幻读的原因是行锁只能锁住行,但是插入新记录这个动作,要更新的是记录之间的 “间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是 间隙锁(Gap Lock)

默认情况下,InnoDB 工作在 RR 隔离级别下,会以 Next-Key Lock 的方式对数据行进行加锁。Next-Key Lock 是行锁和间隙锁的组合,即当 InnoDB 扫描索引记录的时候,会首先对索引记录加上行锁,再对索引记录的间隙加上间隙锁。这样其他事务既不能修改这个记录,也不能在这个间隙插入记录,可以有效防止幻读的发生。

例如,执行一条范围条件检索的 SQL 语句:select * from blog where id > 100 for update。此时,InnoDB 不仅会对符合条件 id > 100 的记录加锁,还会对符合 id > 100 但并不存在记录的行加锁。

表级锁

表级锁是 MySQL 中锁定粒度 较大 的一种锁,表示对当前操作的整张表加锁。表级锁的实现简单,资源消耗较少,不会出现死锁,被大部分 MySQL 引擎支持,如最常用的 MyISAM 和 InnoDB;但加锁的范围比较大,发生冲突的概率高,从而导致并发度较低。

MySQL 里面表级别的锁有两种,一种是表锁,一种是元数据锁,在 InnoDB 下还有另一种表级锁为意向锁。

表锁

表锁有两种模式,分为 表共享读锁(Table Read Lock)表独占写锁(Table Write Lock),并提供了与之对应的锁定表和解锁表的语法功能:

  • 锁定表:lock tables ... read/write,为当前会话锁定表(申请一个表级别的元数据锁);
  • 解锁表:unlock tables,释放被当前会话持有的任何锁。

元数据锁

元数据就是表的基础信息,元数据锁(Metadata Lock,MDL)的主要作用是在并发访问时维护表元数据的一致性,在表上有活动事务的时候,不可以对元数据进行修改操作。

元数据锁是在 MySQL Server 层实现的表级锁,不需要显式使用。每执行一条 DML、DDL 语句时都会申请 MDL 锁,这个加锁过程由系统自动控制,无法直接干预。在执行 DML 操作时会自动加读锁,在执行 DDL 操作时自动加写锁,读读共享、读写互斥、写写互斥,同时写锁获取的优先级要高于读锁。

一旦出现写锁等待,不仅会阻塞当前操作,同时还会阻塞后续该表的所有操作。事务一旦申请到 MDL 锁后,直到事务执行结束才会将锁释放。特殊情况是,如果事务中包含 DDL 操作,MySQL 会在 DDL 操作语句执行前 进行隐式提交,以保证该条语句作为一个单独的事务存在,同时也保证写锁的释放。

当事务等待元数据锁时,执行 show processlist,可以看到 “Waiting for table metadata lock” 字样。

当出现元数据锁导致多个会话发生阻塞不可用时,通常用 kill thread_id 的方式来结束掉 MySQL 的客户端连接,如 kill 23

意向锁

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的 意向锁(Intention Lock)

  • 意向共享锁(IS):表示事务准备给数据行加共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(IX):表示事务准备给数据行加排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

这两种意向锁都是表级锁,是 InnoDB 自动添加的,不需要用户干预。如果一个事务请求的锁模式与当前的锁兼容,InnoDB 就把请求的锁授予该事务;反之,如果两者不兼容,该事务就要进行阻塞,直到锁释放。

InnoDB 意向锁、共享锁、排他锁兼容性如下:

X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容

全局锁

全局锁是 MySQL 中锁定粒度 最大 的一种锁,会对整个 MySQL 实例加锁,加锁之后数据库整体处于 只读状态,阻止所有 DML、DDL 操作,典型使用场景是进行全库逻辑备份。命令如下:

  • 锁定表:flush tables with read lock
  • 解锁表:unlock tables

乐观锁与悲观锁

乐观锁和悲观锁是 人为定义的概念 ,可以理解为是处理并发资源的一种思想和常用手段,用于解决 第二类丢失更新问题,不要把它们与 MySQL 中提供的锁机制(表锁、行锁、共享锁、排他锁等)混为一谈。

乐观锁

乐观锁(Optimistic Lock),顾名思义就是很乐观,每次操作时都假设事务不会发生并发冲突(丢失更新),所以不会上锁。但在提交更新时会检查是否存在冲突,并且由应用程序处理。好处是不存在死锁的情况,适用于读多写少的应用场景。如果经常发生冲突,上层应用不断让用户进行重试操作,反而会降低性能,这种情况下悲观锁就比较适用。

一般来说,乐观锁是基于版本号实现的,具体又可以分为以下两种:

  • 使用数据版本机制(Version):在数据表中增加一个数字类型的 version 字段,数据每更新一次,将对应的记录的 version 值加 1。在查询记录时,同时获取 version 字段值,在提交更新时,判断此刻的 version 值是否与之前查询出来的 version 的值相等。如果相同则予以更新,否则认为是过期数据,需要重试。例如:

    1
    2
    select tokens, version from bucket where id=1;  # version=1
    update bucket set tokens=new_tokens,version=version+1 where id=1 and version=1; # new_tokens由程序计算得到
  • 使用时间戳机制(Timestamp):和上面的处理逻辑类似,在表中新增一个时间戳类型字段。在提交更新时,检查当前数据库中的时间戳和自己更新前取到的时间戳是否相同,如果相同则予以更新,否则需要重试。

注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会使用表锁。

悲观锁

悲观锁(Pessimistic Lock),顾名思义就是很悲观,每次操作时都假设事务会发生并发冲突,所以每次在拿数据时都会上锁,屏蔽一切可能违反数据完整性的操作。悲观锁的实现,通常依靠数据库提供的锁机制实现,比如 InnoDB 中的 排他锁共享锁 都属于悲观锁的范畴。

并发优化

在了解 InnoDB 锁特性后,我们可以通过设计来减少并发场景下的锁冲突和死锁,优化方式包括:

  1. 尽量使用较低的事务隔离级别,不要申请超过实际需要的锁级别;
  2. 选择合理的事务大小,小事务发生锁冲突的几率也更小;
  3. 除非必须,查询时不要显示加锁;
  4. 如果事务中需要锁定多个行,把最可能造成锁冲突、最可能影响并发度的锁往后放;
  5. 不同程序并发存取多个表时,尽量约定以相同的顺序访问各表,对一个表而言,尽可能以固定的顺序存取表中的行;
  6. 精心设计索引,尽量使用索引访问数据;
  7. 尽量用索引上的等值查询访问数据,避免间隙锁对并发插入的影响;
  8. 对于非常容易产生死锁的业务部分,可以通过表锁来减少死锁的可能。

当程序还是无可避免的进入死锁状态后,有两种策略:

  • 一种是直接进入等待,直到 超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置;
  • 另一种是 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!