编辑
2024-02-15
服务端
0
请注意,本文编写于 245 天前,最后修改于 77 天前,其中某些信息可能已经过时。

目录

背景
锁的分类
按模式
乐观锁
悲观锁
按粒度
表级锁
行级锁
页级锁
按属性
共享锁
排他锁
按状态
意向共享锁(IS)
意向排他锁(IX)
按算法
记录锁
间隙锁
临键锁
事务
隔离级别
读未提交(Read Uncommitted)
读已提交(Read Committed)
可重复读(Repeatable Read)
可序列化(Serializable)
隔离级别和锁的关系
SQL与锁
死锁
死锁检测机制
死锁检测过程
死锁检测和处理
减少死锁:
放在最后

背景

数据库的锁定机制是为了确保数据一致性而设计的重要规则。通过锁定机制,数据库管理系统能够管理并发访问数据时可能产生的冲突,确保数据的正确性和完整性。另一方面,MySQL也存在多种数据存储引擎,每种存储引擎的锁机制都是为各自的特定场景而优化设计,所以各存储引擎的锁机制也有较大区别。

锁的分类

我们可能听过Mysql各种叫法的锁,他们大部分是从不同维度来去划分而命名的,如下图。

按模式

乐观锁

乐观锁就是持比较乐观态度的锁。在操作数据时非常乐观,认为别的线程不会同时修改数据,所以不会上锁,但是在更新的时候会判断在此期间别的线程有没有更新过这个数据。

实现方式:乐观锁不会使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。一般是通过在数据表中增加一个版本号或时间戳字段来实现。在更新数据时,检查版本号或时间戳是否与读取时一致,如果一致则更新,否则返回异常信息,让应用层决定需要如何处理(比如重试)。

应用场景:这是一种假设冲突很少发生的并发控制机制。它适用于读多写少的场景,比如,在读操作远多于写操作的场景中,乐观锁的性能优势明显。

悲观锁

悲观锁就是持比较悲观态度的锁。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。在对一条数据修改的时候,为了避免同时被其他人修改,在修改数据之前先锁定再修改。这种锁具有强烈的独占和排它特性。在Mysql中,要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性。因为 MySQL 默认使用 autocommit 模式,也就是说,当执行一个更新操作后,MySQL 会立刻将结果进行提交。

实现方式:共享锁和排他锁是悲观锁的不同实现,但都属于悲观锁范畴。悲观锁的实现,往往依靠数据库提供的锁机制。

应用场景:悲观锁是一种假设冲突经常发生的并发控制机制。它适用于需要强一致性和高冲突概率的场景或者高并发环境中多事务频繁读取修改数据。比如,银行账户余额更新、库存管理系统中的库存更新等。

按粒度

MySQL 中的锁可以按照粒度分为锁定整个表的表级锁(table-level locking)和锁定数据行的行级锁(row-level locking)。InnoDB 存储引擎同时支持行级锁(row-level locking)和表级锁(table-level locking),默认情况下采用行级锁。

表级锁

表级锁是指对整个表进行锁定,防止其他事务对该表进行任何写操作(写锁)或读操作(读锁)。MyISAM、InnoDB 等存储引擎都支持。 特点

  • 获取锁和释放锁的开销小,不会出现死锁。
  • 锁的粒度大,发生锁冲突的概率最高,并发度最低。

示例

sql
LOCK TABLES users WRITE; -- 事务A持有了users表的写锁

行级锁

行级锁是指对特定的行(记录)进行锁定,防止其他事务对该行进行并发操作。

特点

  • 加锁开销加大,容易出现死锁。
  • 锁的粒度细,只锁定特定的行,允许其他事务对未锁定的行进行并发操作,并发度高。
sql
SELECT * FROM users WHERE user_id = 1 FOR UPDATE; -- 事务A持有了user_id为1的记录锁

行级锁的实现 InnoDB通过在数据页上维护一个位图(BitMap)来管理行级锁。位图中的每一位对应于数据页中的一行,这种设计可以显著降低锁的管理成本。

  • 位图结构:位图用于表示数据页中每一行的锁定状态。锁定操作只需在位图中进行位操作,而不需要为每一行单独分配锁对象。
  • 内存消耗:由于位图的管理成本较低,即使需要锁定大量行,内存消耗也相对较小。例如,锁住300万个数据页,每个数据页100条数据,总共300,000,000条数据,锁住全部数据需要3,000,000个锁,每个锁假设30个字节,总共约需90MB的内存。

页级锁

每次锁定相邻的一组记录,页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。

按属性

共享锁

共享锁(S):也称为读锁。当一个事务获取了共享锁后,其他事务也可以获取相同的共享锁,允许它们同时读取同一资源,但不允许任何事务修改该资源。在Mysql中,可以使用以下语句获取该锁。

比如,事务A对记录添加了S锁,可以对记录进行读操作,但不能修改,其他事务可以对该记录追加S锁,但是不能追加X锁,如果需要追加,需要等记录的S锁全部释放。

sql
SELECT ... LOCK IN SHARE MODE; START TRANSACTION; SELECT ... FOR SHARE;

排他锁

排他锁(X):也称为写锁。当一个事务获取了排他锁后,其他事务不能获取相同的排他锁或共享锁,也不能修改该资源,确保了资源的独占性,但允许持有锁的事务更新/删除行 。在Mysql中,可以使用以下语句获取该锁。

比如,事务A对记录添加了X锁,可以对记录进行读和修改操作,其他事务不能对记录做读和修改操作。

sql
SELECT ... FOR UPDATE; START TRANSACTION; SELECT ... FOR UPDATE;

关系

兼容性SX
S兼容不兼容
X不兼容不兼容

按状态

在MySQL中,除了行级锁和表级锁之外,还引入了意向锁(Intention Lock),这种锁定机制用于更细粒度地控制事务对数据的访问。意向锁是一种表级别的锁,用来表示一个事务准备获取何种类型的行级锁。它并不实际限制其他事务对行的访问,而是指示了事务打算在表的哪些部分(例如某些行)获取共享锁(S锁)或排他锁(X锁)。

特点

  • 意向锁是一种不与行级锁冲突的表级锁。
  • 意向锁是 InnoDB 自动加的,不需用户干预。

InnoDB引擎 vs MyISAM引擎

  • InnoDB 存在意向锁的概念,这是因为它支持行级锁定和多版本并发控制(MVCC),需要这种方式来协调不同事务的锁定需求。
  • MyISAM 不支持行级锁定,因此在 MyISAM 中并不存在意向锁的概念。MyISAM 使用的是表级锁,因此不需要考虑意向锁。

意向共享锁(IS)

它表示一个事务打算在表的某些行上获取共享锁(S锁)。意向共享锁不是真正的锁,不会阻止其他事务获取共享锁或意向排他锁,但是它会阻止其他事务获取排他锁(X锁),因为排他锁要求表中没有任何共享锁或意向排他锁。已加S锁的表肯定会有IS锁,反之,有IS锁的表不一定会有S锁。

意向排他锁(IX)

它表示一个事务打算在表的某些行上获取排他锁(X锁)。意向排他锁同样也不是真正的锁,会阻止其他事务获取任何类型的锁,因为排他锁要求表中没有任何共享锁或排他锁。已加X锁的表肯定会有IX锁,反之有IX锁的表,不一定会有X锁。 意向锁的原则:

  • 事务想要获取一行的S锁,则必须先申请表级的IS锁或者更强的锁。
  • 事务想要获取一行的X锁,则必须先申请表级的IX锁。 | 兼容性 | IS | IX | S | X | | --- | --- | --- | --- | --- | | IS | 兼容 | 兼容 | 兼容 | 不兼容 | | IX | 兼容 | 兼容 | 不兼容 | 不兼容 | | S | 兼容 | 不兼容 | 兼容 | 不兼容 | | X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |

按算法

InnoDB 引擎行锁是通过对索引数据页上的记录加锁实现的,主要实现算法有 3 种:记录锁(Record Lock)、间隙锁(Gap Lock)和 临键锁(Next-key Lock)。

记录锁

记录锁是加在索引上的(RC、RR隔离级别都支持),一种锁定特定行记录的锁,持有记录锁之后,其他事务无法插入/更新/删除该索引对应的行。它用于确保在多事务环境中对特定行的并发访问不会导致数据不一致。

特点

  • 粒度较细:记录锁只锁定特定的行,而不是整个表或整个索引。
  • 并发控制:在持有记录锁的情况下,其他事务无法对被锁定的行进行插入、更新或删除操作。
  • 依赖于索引:记录锁通常依赖于索引来定位需要锁定的行。

记录锁锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,锁住的就是这个隐藏的聚集主键索引。

示例

sql
SELECT * FROM users WHERE user_id = 1 FOR UPDATE;

在上面这个示例中,FOR UPDATE语句会对user_id为1的行加上记录锁,防止其他事务对这行数据进行修改。但需要注意的是,user_id 列必须为唯一索引列或主键列,否则上述语句加的锁就会变成临键锁。同时查询语句必须为精准匹配(=),不能为 >、<、like 等,否则也会退化成临键锁。 另外,在通过主键索引或唯一索引对数据行进行 UPDATE 操作时,也会对该行数据加记录锁。

sql
UPDATE SET sex = 1 WHERE user_id = 1 FOR UPDATE;

其它注意事项

  • 如果要锁的列没有索引,进行全表记录加锁。
  • 记录锁也是排它 (X) 锁,所以会阻塞其他事务对其插入、更新、删除。

间隙锁

间隙锁加在索引之间的空隙上,仅仅锁住一个索引区间(开区间)。 间隙锁的目的主要是为了防止幻读,保证索引间的不会被插入数据。间隙锁需要在性能和一致性上去做取舍,并非所有的事务隔离级别都有间隙锁,需要可重复读或更高的级别。

特点

  • 防止幻读(Phantom Read):防止其他事务在这些间隙中插入新记录,从而避免幻读现象。

幻读是指一个事务在读取某个范围内的记录后,另一个事务在该范围内插入了新的记录,导致前一个事务再次读取时,发现了“幻影”般的新记录。

  • 确保范围操作的一致性:在范围查询或范围更新操作中,间隙锁可以确保在操作期间,其他事务不会在查询范围内插入新记录,从而保证数据的一致性。
  • 提高并发控制的精细度:与表锁和记录锁相比,间隙锁提供了更精细的并发控制。它不仅锁定特定的记录,还锁定了记录之间的“间隙”,从而防止其他事务在这些间隙中插入新记录。

示例

sql
SELECT c1 FROM t WHERE c1 BETWEEN 10 AND 20 FOR UPDATE SELECT * FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-01-31' FOR UPDATE

第一条sql会阻止其他事务插入 c1 = 15,无论之前DB里有没有15这条记录。 第二条sql持有了订单日期在2023年1月1日至2023年1月31日之间的所有记录的间隙锁。 间隙锁的作用就是防止其他事物插入数据,间隙锁可以共存,允许在相同的间隙上加多个锁。类似于S锁。

临键锁

临键锁是行锁和间隙锁的结合体(左开右闭区间),锁住当前索引本身以及它之前的间隙。用于防止幻读现象,确保事务的隔离性和一致性。InnoDB 中行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁。

默认情况下,innodb使用临键锁来锁定记录。但当查询的索引含有唯一属性的时候,临键锁会进行优化,将其降级为记录锁,即仅锁住索引本身,不是范围。
假设有如下表:id 主键,age 普通索引。 示例

idnameage
1zhangsan10
2lisi20
3wangwu32

表中 age 列潜在的临键锁有:(-∞, 10],(10, 20],(20, 32],(32, +∞]。

sql
UPDATE SET sex = 1 WHERE age = 10

根据非唯一索引列 UPDATE 某条记录 。

总结

  • 行锁依赖于索引:InnoDB中的行锁实现依赖于索引。如果某个操作没有使用索引,那么锁可能会退化为表锁,影响并发性能。
  • 记录锁:存在于包括主键索引在内的唯一索引中,用于锁定单条索引记录。这保证了对于唯一索引中的每条记录,只能有一个事务可以进行修改。
  • 间隙锁:存在于非唯一索引中,用于锁定开区间范围内的一段间隔。这种锁是为了防止其他事务在间隙内插入新记录,确保并发事务操作的一致性。
  • 临键锁:存在于非唯一索引中,每条记录的索引上都可能存在这种锁。它是一种特殊的间隙锁,锁定一个左开右闭的索引区间,主要用于确保范围查询不会受到并发事务的干扰。

事务

事务(Transaction)是指一组操作,这些操作要么全部成功,要么全部失败。事务的主要目的是确保数据库在并发环境下的数据一致性和完整性。事务具有以下四个关键属性,这些属性统称为ACID特性:

  • 原子性(Atomicity):原子性确保事务中的所有操作要么全部执行成功,要么全部回滚失败。即事务是一个不可分割的工作单元。
  • 一致性(Consistency):一致性确保事务执行前后,数据库从一个一致性状态转换到另一个一致性状态。所有的约束条件(如外键约束、唯一性约束等)在事务执行前后都必须得到满足。
  • 隔离性(Isolation):隔离性确保事务在执行过程中,不受其他并发事务的影响。不同的隔离级别提供了不同程度的隔离性(如读未提交、读已提交、可重复读、可序列化)。
  • 持久性(Durability):持久性确保事务一旦提交,其结果将永久保存在数据库中,即使系统发生故障也不会丢失。

隔离级别

读未提交(Read Uncommitted)

在读未提交隔离级别下,一个事务可以读取其他事务尚未提交的数据。这意味着一个事务可能会读取到其他事务正在修改但尚未提交的“脏数据”。

问题

  • 可能出现脏读(Dirty Read):一个事务读取到另一个事务尚未提交的数据。

读已提交(Read Committed)

在读已提交隔离级别下,一个事务只能读取到其他事务已经提交的数据。这意味着一个事务不会读取到未提交的数据,但可能会读取到其他事务在本事务执行期间提交的数据。

问题

  • 防止脏读:一个事务不会读取到未提交的数据。
  • 可能出现不可重复读(Non-repeatable Read):一个事务在两次读取同一数据时,可能会读取到不同的值,因为其他事务可能在两次读取之间提交了修改。

可重复读(Repeatable Read)

在可重复读隔离级别下,一个事务在开始时看到的数据,在整个事务过程中都不会改变。其他事务在本事务执行期间,不能修改或删除本事务已经读取的数据。

问题

  • 防止脏读和不可重复读:一个事务不会读取到未提交的数据,也不会在同一事务中看到不同的值。
  • 可能出现幻读(Phantom Read):一个事务在执行范围查询时,可能会看到其他事务在该范围内插入的新记录。

可序列化(Serializable)

在可序列化隔离级别下,事务完全隔离,仿佛每个事务是按顺序执行的,而不是并发执行的。任何事务的执行结果与其独占执行的结果相同。 问题 最高隔离级别:提供了最严格的数据一致性保证,防止脏读、不可重复读和幻读。 对比

隔离级别脏读(Dirty Read)不可重复读(Non-repeatable Read)幻读(Phantom Read)性能
读未提交(Read Uncommitted)可能可能可能最高
读已提交(Read Committed)不可能可能可能较高
可重复读(Repeatable Read)不可能不可能可能较低
可序列化(Serializable)不可能不可能不可能最低

隔离级别和锁的关系

事务隔离级别相当于事务并发控制的整体解决方案,本质上是对锁和MVCC使用的封装,隐藏了底层细节。 锁是数据库实现并发控制的基础,事务隔离性是采用锁来实现,对相应操作加不同的锁,就可以防止其他事务同时对数据进行读写操作。

对开发者来说,首先选择使用隔离级别,当选用的隔离级别不能解决并发问题或需求时,才有必要在开发中手动的设置锁。

SQL与锁

在MySQL中,不同的SQL操作会触发不同类型的锁,以确保数据的一致性和并发控制。类似的sql可能会锁行,也可能会锁Gap/Next-Key,取决于事务隔离级别以及后续的where条件能否命中唯一索引 。以下是常见的各种操作和锁类型的详细解释:

  • SELECT ... FROM:语句在查询数据时,不同的隔离级别(RC和RR)会影响读取的数据版本。MySQL通过多版本并发控制(MVCC)来实现这一点,即使在读取数据时,其他事务正在修改数据,也不会影响当前事务读取的数据版本。
  • SELECT ... FOR UPDATE:X锁(排他锁)。语句会对所读取的行加上排他锁,防止其他事务对这些行进行修改。
  • SELECT ... LOCK IN SHARE MODE:S锁(共享锁)。语句会对所读取的行加上共享锁,允许其他事务读取这些行,但不允许修改。
  • DELETE:X锁(排他锁)。语句会对所删除的行加上排他锁,防止其他事务对这些行进行并发操作。
  • UPDATE:X锁(排他锁)。语句会对所更新的行加上排他锁。如果 UPDATE 语句更新了聚簇索引,还会隐式地对相关的辅助索引加上共享锁(S锁)。
  • INSERT:
    • X锁(排他锁)。语句会对所插入的行加上排他锁。
    • 插入意向间隙锁(Insert Intention Gap Lock):在 Repeatable Read 隔离级别下,INSERT 语句还会在插入之前加一个插入意向间隙锁,允许多个事务在同一个间隙内插入不同的行。
    • S锁(共享锁):如果插入操作遇到重复键错误(duplicate-key error),事务会对记录加一个共享锁,这可能会引发死锁。

死锁

死锁DeadLock是指两个或两个以上的进程在执行过程中, 因争夺资源而造成的一种互相等待的现象, 若无外力作用,它们都将无法推进下去.此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

InnoDB存储引擎具有内置的死锁检测机制,可以检测并处理死锁,以确保数据库系统的正常运行。以下是MySQL如何判断和处理死锁的详细解释:

死锁检测机制

  1. 等待图(Wait-for Graph) MySQL使用等待图(Wait-for Graph)来检测死锁。等待图是一种有向图,其中每个节点表示一个事务,每条边表示一个事务等待另一个事务释放锁。
  2. 死锁检测算法 MySQL使用一种基于等待图的死锁检测算法,定期检查是否存在循环依赖(即环)。如果检测到环,说明存在死锁。

死锁检测过程

  • 事务请求锁:当一个事务请求一个锁,但该锁已被其他事务持有时,请求的事务会进入等待状态。
  • 更新等待图:MySQL会更新等待图,将请求锁的事务和持有锁的事务之间的依赖关系添加到等待图中。
  • 检测循环依赖:MySQL定期检查等待图,寻找是否存在循环依赖(环)。如果找到环,则说明存在死锁。
  • 处理死锁:一旦检测到死锁,MySQL会选择一个事务进行回滚,以打破死锁。通常,MySQL会选择回滚代价最小的事务(如持有最少锁的事务)。

示例 假设有两个事务A和B,分别执行以下操作: 事务A

sql
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; -- 事务A持有account_id=1的X锁 UPDATE accounts SET balance = balance + 100 WHERE account_id = 2; -- 事务A等待account_id=2的X锁

事务B

sql
BEGIN; UPDATE accounts SET balance = balance - 50 WHERE account_id = 2; -- 事务B持有account_id=2的X锁 UPDATE accounts SET balance = balance + 50 WHERE account_id = 1; -- 事务B等待account_id=1的X锁

在这个示例中,事务A和事务B相互等待对方持有的锁,形成了一个死锁。

死锁检测和处理

更新等待图:事务A等待事务B释放account_id=2的锁。事务B等待事务A释放account_id=1的

减少死锁:

Section 14.7.5.3, “How to Minimize and Handle Deadlocks”

  1. 尽可能避免使用表锁。
  2. 事务保持轻量,在一个事务内操作的数据应当尽可能的少,保证事务能很快的结束。
  3. 事务操作多个表或多行数据时,使用相同的加锁顺序。
  4. Update / Select for update 语句一定要命中索引。

下文会进一步讨论三条insert引发死锁的问题。

放在最后

本文作者:sora

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!