--- icon: logos:mysql title: MySQL 面试之事务和锁篇 cover: https://raw.githubusercontent.com/dunwu/images/master/snap/202503110803916.jpg date: 2025-03-24 22:42:57 categories: - 数据库 - 关系型数据库 - mysql tags: - 数据库 - 关系型数据库 - mysql - 面试 permalink: /pages/b69d49b5/ --- # MySQL 面试之事务和锁篇 ## MySQL 事务 > 扩展阅读: > > - [MySQL 官方文档之 InnoDB 锁和事务模型](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-transaction-model.html) ### 【简单】什么是事务,什么是 ACID? **事务指的是满足 ACID 特性的一组操作**。事务内的 SQL 语句,要么全执行成功,要么全执行失败。可以通过 `Commit` 提交一个事务,也可以使用 `Rollback` 进行回滚。通俗来说,**事务就是要保证一组数据库操作,要么全部成功,要么全部失败**。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242207831.png) ACID 是数据库事务正确执行的四个基本要素。 - **原子性(Atomicity)** - 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。 - 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。 - **一致性(Consistency)** - 数据库在事务执行前后都保持一致性状态。 - 在一致性状态下,所有事务对一个数据的读取结果都是相同的。 - **隔离性(Isolation)** - 一个事务所做的修改在最终提交以前,对其它事务是不可见的。 - **持久性(Durability)** - 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。 - 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。 一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。 ### 【中等】事务存在哪些并发一致性问题? 事务中存在的并发一致性问题有: - 丢失修改 - 脏读 - 不可重复读 - 幻读 **“丢失修改”是指一个事务的更新操作被另外一个事务的更新操作替换**。 如下图所示,T1 和 T2 两个事务对同一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242209867.png) **“脏读(dirty read)”是指当前事务可以读取其他事务未提交的数据**。 如下图所示,T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242210430.png) **“不可重复读(non-repeatable read)”是指一个事务内多次读取同一数据,过程中,该数据被其他事务所修改,导致当前事务多次读取的数据可能不一致**。 如下图所示,T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242211015.png) **“幻读(phantom read)”是指一个事务内多次读取同一范围的数据,过程中,其他事务在该数据范围新增了数据,导致当前事务未发现新增数据**。 事务 T1 读取某个范围内的记录时,事务 T2 在该范围内插入了新的记录,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242212639.png) ### 【中等】长事务可能会导致哪些问题? 长事务可能会导致以下问题: - **锁竞争与资源阻塞** - 长事务长时间持有锁,导致其他事务阻塞,增加系统等待时间,降低并发性能。 - 业务线程因数据库请求等待而阻塞,可能引发连锁反应(如服务雪崩),造成严重线上事故。 - **死锁风险增加** - 多个长事务互相等待对方释放锁,容易形成死锁,导致系统无法正常执行。 - **主从延迟问题** - 主库执行时间长,从库同步及重放耗时增加,导致主从数据长时间不一致。 - **回滚效率低下** - 长事务执行中途失败时,回滚操作会浪费已执行的资源与时间,影响系统效率。 ### 【困难】事务的二阶段提交是什么? 事务的二阶段提交确保 **redo log(重做日志)** 和 **binlog(二进制日志)** 的一致性,防止崩溃恢复时出现数据丢失或不一致。 **两阶段流程** - **Prepare 阶段(准备阶段)** - InnoDB 写入 redo log,并标记为 **prepare** 状态(事务预提交,但未最终提交)。 - **Commit 阶段(提交阶段)** - MySQL Server 写入 **binlog**(记录 DML 操作)。binlog 写入成功后,InnoDB 将 redo log 状态改为 **commit**,完成事务提交。 **binlog 和 redo log 的区别** | **特性** | **redo log** | **binlog** | | :----------- | :----------------------------- | :------------------------------ | | **所属层级** | InnoDB 引擎层 | MySQL Server 层 | | **日志类型** | 记录数据页的**物理日志** | 记录 DML/DDL 操作的**逻辑日志** | | **存储方式** | 固定大小,**环形写入** | **追加写入**,可无限增长 | | **主要用途** | **崩溃恢复**(保证数据持久性) | **主从复制、数据恢复、备份** | **为什么需要二阶段提交?** 无论是单独先写 redo log 或先写 binlog,都可能导致数据不一致: 1. **先写 redo log,后写 binlog**(宕机时 binlog 未写入) - redo log 恢复数据,但 binlog 缺失该事务 → **主从数据不一致**。 2. **先写 binlog,后写 redo log**(宕机时 redo log 未写入) - binlog 有记录,但 redo log 未提交 → **数据库实际数据丢失**,与 binlog 不一致。 为了解决以上问题,所以需要事务二阶段提交(`repare` → `commit`),以确保写入两日志的原子性。 **二阶段提交如何保证一致性?** MySQL 崩溃恢复时,检查两日志状态: - **redo log prepare,binlog 未写入** - 直接回滚(两日志均无有效记录)。 - **redo log prepare,binlog 已写入** - 对比两日志数据: - **一致**:提交事务(redo log commit)。 - **不一致**:回滚事务(保证数据一致)。 ### 【中等】有哪些事务隔离级别,分别解决了什么问题? 为了解决以上提到的并发一致性问题,SQL 标准提出了四种“事务隔离级别”来应对这些问题。事务隔离级别等级越高,越能保证数据的一致性和完整性,但是执行效率也越低。因此,设置数据库的事务隔离级别时需要做一下权衡。 事务隔离级别从低到高分别是: - **读未提交(read uncommitted)** - 是指,**事务中的修改,即使没有提交,对其它事务也是可见的**。 - **读已提交(read committed)** - 是指,**事务提交后,其他事务才能看到它的修改**。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。 - **读已提交解决了脏读的问题**。 - 读已提交是大多数数据库的默认事务隔离级别,如 Oracle。 - **可重复读(repeatable read)** - 是指:**保证在同一个事务中多次读取同样数据的结果是一样的**。 - **可重复读解决了不可重复读问题**。 - **可重复读是 InnoDB 存储引擎的默认事务隔离级别**。 - **串行化(serializable )** - 是指,**强制事务串行执行**,对于同一行记录,加读写锁,一旦出现锁冲突,必须等前面的事务释放锁。 - **串行化解决了幻读问题**。由于强制事务串行执行,自然避免了所有的并发问题。 - **串行化策略会在读取的每一行数据上都加锁**,这可能导致大量的超时和锁竞争。这对于高并发应用基本上是不可接受的,所以一般不会采用这个级别。 事务隔离级别对并发一致性问题的解决情况: | 隔离级别 | 丢失修改 | 脏读 | 不可重复读 | 幻读 | | :------: | :------: | :--: | :--------: | :--: | | 读未提交 | ✔️️️ | ❌ | ❌ | ❌ | | 读已提交 | ✔️️️ | ✔️️️ | ❌ | ❌ | | 可重复读 | ✔️️️ | ✔️️️ | ✔️️️ | ❌ | | 可串行化 | ✔️️️ | ✔️️️ | ✔️️️ | ✔️️️ | ### 【中等】MySQL 的默认事务隔离级别是什么?为什么? 事务隔离级别等级越高,越能保证数据的一致性和完整性,但是执行效率也越低。因此,设置数据库的事务隔离级别时需要做一下权衡。 MySQL 中的事务功能是在存储引擎层实现的,**并非所有存储引擎都支持事务功能**。比如 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。 **大部分数据库的默认隔离级别是“读已提交”**。然而,**InnoDB 的默认隔离级别是“可重复读”**。这是为了兼容早期 binlog 的 statement 格式问题。如果使用可重复读以下的隔离级别,使用了 statement 格式的 binlog 会产生主从数据不一致的问题。 此外,**在 InnoDB 中,可重复读隔离级别虽然不能解决幻读,但是可以很大程度的避免幻读的发生**。根据不同的查询方式,分别提出了避免幻读的方案: - 针对**快照读**(普通 `select` 语句),**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(`select ... for update` 等语句),**通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。 很大程度的避免幻读,不代表完全解决幻读问题,下面是两个示例: - 对于快照读,MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。 - 对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。 ### 【困难】MySQL 是如何实现事务的? MySQL 主要是通过 **锁**、**Redo Log** 、**Undo Log**、**MVCC** 来实现事务。 - MySQL 利用锁(行锁、间隙锁等等机制),**控制数据的并发修改**,满足事务的隔离性。 - **Redo Log(重做日志)**,它会**记录事务对数据库的所有修改**,当 MySQL 发生宕机或崩溃时,**通过重放 redo log 就可以恢复数据**,用来满足事务的持久性。 - **Undo Log(回滚日志)**,它会**记录事务的反向操作**,简单地说就是保存数据的历史版本,**用于事务的回滚**,使得事务执行失败之后可以恢复之前的样子。实现原子性和隔离性 - **MVCC(多版本并发控制)**,满足了非锁定读的需求,提高了并发度,**实现了读已提交和可重复读两种隔离级别**,实现了事务的隔离性。 事务实现了原子性、隔离性和持久性特性后,本身就达到了一致性的目的。 ### 【困难】什么是 MVCC? **MVCC 是 Multi Version Concurrency Control 的缩写,即“多版本并发控制”**。MVCC 的设计目标是提高数据库的并发性,采用非阻塞的方式去处理读/写并发冲突,可以将其看成一种乐观锁。 不仅是 MySQL,包括 Oracle、PostgreSQL 等其他关系型数据库都实现了各自的 MVCC,实现机制没有统一标准。**MVCC 是 InnoDB 存储引擎实现事务隔离级别的一种具体方式**。其主要用于实现读已提交和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。 ::: info MVCC 实现原理 ::: MVCC 的实现原理,主要基于隐式字段、UndoLog、ReadView 来实现。 #### 隐式字段 InnoDB 存储引擎中,数据表的每行记录,除了用户显示定义的字段以外,还有几个数据库隐式定义的字段: - `row_id` - **隐藏的自增 ID**。如果数据表没有指定主键,InnoDB 会自动基于 `row_id` 产生一个聚簇索引。 - `trx_id` - **最近修改的事务 ID**。事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里; - `roll_pointer` - **回滚指针**,指向这条记录的上一个版本。 #### UndoLog MVCC 的多版本指的是多个版本的快照,快照存储在 UndoLog 中。该日志通过回滚指针 `roll_pointer` 把一个数据行的所有快照链接起来,构成一个**版本链**。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242213050.png) #### ReadView **ReadView 就是事务进行快照读时产生的读视图(快照)**。 ReadView 有四个重要的字段: - `m_ids` - 指的是在创建 ReadView 时,当前数据库中“活跃事务”的事务 ID 列表。注意:这是一个列表,**“活跃事务”指的就是,启动了但还没提交的事务**。 - `min_trx_id` - 指的是在创建 ReadView 时,当前数据库中“活跃事务”中事务 id 最小的事务,也就是 `m_ids` 的最小值。 - `max_trx_id` - 这个并不是 m_ids 的最大值,而是指创建 ReadView 时当前数据库中应该给下一个事务分配的 ID 值,也就是全局事务中最大的事务 ID 值 + 1; - `creator_trx_id` - 指的是创建该 ReadView 的事务的事务 ID。 在创建 ReadView 后,我们可以将记录中的 trx_id 划分为三种情况: - 已提交事务 - 已启动但未提交的事务 - 未启动的事务 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242214504.png) > ReadView 如何判断版本链中哪个版本可见? 一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况: - `trx_id == creator_trx_id` - 表示 `trx_id` 版本记录由 ReadView 所代表的当前事务产生,当然可以访问。 - `trx_id < min_trx_id` - 表示 `trx_id` 版本记录是在创建 ReadView 之前已提交的事务生成的,当前事务可以访问。 - `trx_id >= max_trx_id` - 表示 `trx_id` 版本记录是在创建 ReadView 之后才启动的事务生成的,当前事务不可以访问。 - `min_trx_id <= trx_id < max_trx_id` - 需要判断 `trx_id` 是否在 `m_ids` 列表中 - 如果 `trx_id` 在 `m_ids` 列表中,表示生成 `trx_id` 版本记录的事务依然活跃(未提交事务),当前事务不可以访问。 - 如果 `trx_id` 不在 `m_ids` 列表中,表示生成 `trx_id` 版本记录的事务已提交,当前事务可以访问。 这种通过“版本链”来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。 ### 【困难】MVCC 实现了哪些隔离级别,如何实现的? 对于“读已提交”和“可重复读”隔离级别的事务来说,它们都是通过 MVCC 的 ReadView 机制来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。 > MVCC 如何实现可重复读隔离级别 **可重复读隔离级别只有在启动事务时才会创建 ReadView,然后整个事务期间都使用这个 ReadView**。这样就保证了在事务期间读到的数据都是事务启动前的记录。 举个例子,假设有两个事务依次执行以下操作: - 初始,表中 id = 1 的 value 列值为 100。 - 事务 2 读取数据,value 为 100; - 事务 1 将 value 设为 200; - 事务 2 读取数据,value 为 100; - 事务 1 提交事务; - 事务 2 读取数据,value 依旧为 100; 以上操作,如下图所示。T2 事务在事务过程中,是否可以看到 T1 事务的修改,可以根据 [ReadView](#ReadView) 中描述的规则去判断。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242217983.png) 从图中不难看出: - 对于 `trx_id = 100` 的版本记录,比对 T2 事务 ReadView ,`trx_id < min_trx_id`,因此在 T2 事务中的任意时刻都可见; - 对于 `trx_id = 101` 的版本记录,比对 T2 事务 ReadView ,可以看出 `min_trx_id <= trx_id < max_trx_id` ,且 `trx_id` 在 `m_ids` 中,因此 T2 事务中不可见。 综上所述,在 T2 事务中,自始至终只能看到 `trx_id = 100` 的版本记录。 > MVCC 如何实现读已提交隔离级别 **读已提交隔离级别每次读取数据时都会创建一个 ReadView**。这意味着,事务期间的多次读取同一条数据,前后读取的数据可能会出现不一致——因为,这期间可能有另外一个事务修改了该记录,并提交了事务。 举个例子,假设有两个事务依次执行以下操作: - 初始,表中 id = 1 的 value 列值为 100。 - 事务 2 读取数据(创建 ReadView),value 为 0; - 事务 1 将 value 设为 100; - 事务 2 读取数据(创建 ReadView),value 为 0; - 事务 1 提交事务; - 事务 2 读取数据(创建 ReadView),value 为 100; 以上操作,如下图所示,T2 事务在事务过程中,是否可以看到其他事务的修改,可以根据 [ReadView](#ReadView) 中描述的规则去判断。 ![](https://raw.githubusercontent.com/dunwu/images/master/snap/202503242218464.png) 从图中不难看出: - 对于 `trx_id = 100` 的版本记录,比对 T2 事务 ReadView ,`trx_id < min_trx_id`,因此在 T2 事务中的任意时刻都可见; - 对于 `trx_id = 101` 的版本记录,比对 T2 事务 ReadView ,可以看出第二次查询时(T1 更新未提交),`min_trx_id <= trx_id < max_trx_id` ,且 `trx_id` 在 `m_ids` 中,因此 T2 事务中不可见;而第三次查询时(T1 更新已提交),`trx_id < min_trx_id`,因此在 T2 事务中可见; 综上所述,在 T2 事务中,当 T1 事务提交前,可读取到的是 `trx_id = 100` 的版本记录;当 T1 事务提交后,可读取到的是 `trx_id = 101` 的版本记录。 > MVCC + Next-Key Lock 如何解决幻读 MySQL InnoDB 引擎的默认隔离级别虽然是“可重复读”,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种: - 针对**快照读**(普通 `SELECT` 语句),**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(`SELECT ... FOR UPDATE` 等语句),**通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读**,因为当执行 `SELECT ... FOR UPDATE` 语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好的避免了幻读问题。 ### 【困难】各事务隔离级别是如何实现的? 四种隔离级别具体是如何实现的呢? 以 InnoDB 的事务实现来说明: - 对于“读未提交”隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了; - 对于“串行化”隔离级别的事务来说,通过加读写锁的方式来避免并行访问; - 对于“读提交”和“可重复读”隔离级别的事务来说,它们都是通过 ReadView 来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。 - “读提交”隔离级别是在“每个语句执行前”都会重新生成一个 ReadView - “可重复读”隔离级别是在“启动事务时”生成一个 ReadView,然后整个事务期间都在用这个 ReadView。 ## MySQL 锁 ### 【中等】MySQL 中有哪些锁? 为了解决并发一致性问题,MySQL 支持了很多种锁来实现不同程度的隔离性,以保证数据的安全性。 #### 独享锁和共享锁 **独享锁(Exclusive)**:简写为 X 锁,又称为“写锁”、“排它锁”。 - 特性 - 用于写入操作 - 完全排他,同一时间仅允许一个事务持有 - 加锁 - `SELECT ... FOR UPDATE` - DML 语句(隐式加锁) **共享锁(Shared)**:简写为 S 锁,又称为“读锁”。 - 特性 - 用于读取操作 - 允许多事务并发持有,互不阻塞 - 加锁 - `SELECT ... LOCK IN SHARE MODE` - `SELECT ... FOR SHARE`(MySQL 8.0+) **存储引擎实现差异** - **MyISAM**:仅支持表级锁 - `LOCK TABLES ... READ` → S 锁 - `LOCK TABLES ... WRITE` → X 锁 - **InnoDB**:支持行级锁和表级锁 - 默认使用行锁(更细粒度) - 特殊场景退化为表锁(如无索引更新) #### 悲观锁和乐观锁 基于加锁方式分类,MySQL 可以分为悲观锁和乐观锁。 - **悲观锁** - 假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作 - 在查询完数据的时候就把事务锁起来,直到提交事务(`COMMIT`) - 实现方式:**使用数据库中的锁机制**。 - **乐观锁** - 假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。只在更新的时候,判断一下在此期间是否有其他线程更新该数据。 - 实现方式:**更新数据时,先使用版本号机制或 CAS 算法检查数据是否被修改**。 > 为什么要引入乐观锁? 乐观锁也是一种通用的锁机制,在很多软件领域,都存在乐观锁机制。 **锁,意味着互斥,意味着阻塞。在高并发场景下,锁越多,阻塞越多,势必会拉低并发性能**。那么,为了提高并发度,能不能尽量不加锁呢? 乐观锁,顾名思义,就是假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。虽然不加锁,但不意味着什么都不做,而是在更新的时候,判断一下在此期间是否有其他线程更新该数据。乐观锁最常见的实现方式,是使用版本号机制或 CAS 算法(Compare And Swap)去实现。 【示例】MySQL 乐观锁示例 假设,order 表中有一个字段 status,表示订单状态:status 为 1 代表订单未支付;status 为 2 代表订单已支付。现在,要将 id 为 1 的订单状态置为已支付,则操作如下: ```sql select status, version from order where id=#{id} update order set status=2, version=version+1 where id=#{id} and version=#{version}; ``` 乐观锁的**优点**是:减少锁竞争,提高并发度。 乐观锁的**缺点**是: - **存在 ABA 问题**。所谓的 ABA 问题是指在并发编程中,如果一个变量初次读取的时候是 A 值,它的值被改成了 B,然后又其他线程把 B 值改成了 A,而另一个早期线程在对比值时会误以为此值没有发生改变,但其实已经发生变化了 - 如果乐观锁所检查的数据存在大量锁竞争,会由于**不断循环重试,产生大量的 CPU 开销**。 #### 全局锁 全局锁是 MySQL 中一种**锁住整个数据库实例**的机制,使数据库处于**只读状态**,禁止所有数据修改操作(DML、DDL)。 **加锁、解锁** 加全局锁(Flush Tables With Read Lock, FTWRL) ```sql FLUSH TABLES WITH READ LOCK; ``` 阻塞所有 **写操作(INSERT/UPDATE/DELETE)** 和 **DDL(ALTER/DROP)**,但允许读操作(SELECT)。 释放锁 ```sql UNLOCK TABLES; ``` **应用场景** - **数据库备份**(确保备份数据一致性) - **主从同步初始化** - **防止数据修改**(维护期间禁止写入) #### 表级锁 - **表锁**:**锁定整张表**,是 MyISAM 引擎的默认锁类型,InnoDB 引擎在特定情况下也会使用。 - **表共享读锁(Table Read Lock)**:`LOCK TABLE table_name READ` - 所有会话可以**同时读取**该表 - **禁止任何会话写入**(INSERT/UPDATE/DELETE) - 读锁之间**不互斥**(多个事务可同时持有读锁) - **表独占写锁(Table Write Lock)**:`LOCK TABLE table_name WRITE` - **仅允许当前会话读写**该表 - 其他会话**无法读取或写入**(完全排他) - 写锁**与其他所有锁互斥**(包括读锁和其他写锁) - **元数据锁(Metadata Lock, MDL)**:自动加锁,保护表结构,防止在查询或修改时表结构被更改。 - 增删改查,加读锁 - 结构变更,加写锁 - **意向锁(Intention Lock)**:用于快速判断表中是否有行被锁定,从而优化锁冲突检测效率。 - **意向共享锁(IS)**:表示事务准备在表的某些行上加 S 锁(共享锁),如 `SELECT ... LOCK IN SHARE MODE`。 - **意向独享锁(IX)**:表示事务准备在表的某些行上加 X 锁(排他锁),如 `SELECT ... FOR UPDATE` 或 DML 操作。 - **自增锁(Auto Increment Lock)**:确保并发插入时自增值的正确分配。 - **确保自增值连续**:防止多个事务同时插入数据时,自增列出现重复或跳跃。 - **保证唯一性**:避免并发插入时多个事务获取相同的自增值,导致主键冲突。 - **支持事务回滚**:如果事务回滚,已分配的自增值不会回退,但后续插入仍会继续递增,确保不重复。 #### 行级锁 - **记录锁(Record Lock)**:锁定索引中的**单条记录**。 - 仅锁住符合条件的行,不影响其他行 - 如果表无索引,InnoDB 会退化为表锁 ```sql -- 对 id=1 的记录加锁 SELECT * FROM users WHERE id = 1 FOR UPDATE; ``` - **间隙锁(Gap Lock)**:锁定索引记录之间的**间隙**,防止其他事务在这个范围内插入数据。 - 仅存在于**可重复读**隔离级别,为了解决幻读问题 ```sql -- 锁定 20-30 之间的间隙 SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE; ``` - **临键锁(Next-Key Lock)**:**记录锁+间隙锁**的组合,锁定记录及前面的间隙。 - InnoDB 默认的行锁方式 - 同时防止幻读和保证当前读的一致性 ```sql -- 锁定 id>10 的记录及后面的间隙 SELECT * FROM users WHERE id > 10 FOR UPDATE; ``` - **插入意向锁(Insert Intention Lock)**:在 `INSERT` 操作前设置,表示准备插入 - 不阻塞其他插入意向锁 - 会等待间隙锁释放 ```sql -- 自动获取插入意向锁 INSERT INTO users VALUES (15, 'John'); ``` ### 【中等】死锁是如何产生的? **死锁**是指,**多个事务竞争同一资源,并请求锁定对方占用的资源,从而导致恶性循环的现象**。 **死锁产生条件(必须同时满足)** - **互斥**:资源一次只能被一个事务占用(如行锁)。 - **占有并等待**:事务持有资源的同时,等待其他事务释放资源。 - **不可强占**:事务已获得的资源不能被强制抢占,只能主动释放。 - **循环等待**:事务之间形成循环等待。 ### 【困难】如何避免死锁? **死锁的四个必要条件**:**互斥、占有且等待、不可强占、循环等待**。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。由此可知,要想避免死锁,就要从这几个必要条件上去着手: - 更新表时,**尽量使用主键更新**,减少冲突; - **避免长事务**,尽量将长事务拆解,可以降低与其它事务发生冲突的概率; - **设置合理的锁等待超时参数**,我们可以通过 `innodb_lock_wait_timeout` 设置合理的等待超时阈值,特别是在一些高并发的业务中,我们可以尽量将该值设置得小一些,避免大量事务等待,占用系统资源,造成严重的性能开销。 - 在编程中**尽量按照固定的顺序来处理数据库记录**,假设有两个更新操作,分别更新两条相同的记录,但更新顺序不一样,有可能导致死锁; - 在允许幻读和不可重复读的情况下,尽量使用读已提交事务隔离级别,可以避免 Gap Lock 导致的死锁问题; - 还可以使用其它的方式来代替数据库实现幂等性校验。例如,使用 Redis 以及 ZooKeeper 来实现,运行效率比数据库更佳。 ### 【困难】如何解决死锁? 当出现死锁以后,有两种策略: - **设置事务等待锁的超时时间**。这个超时时间可以通过参数 `innodb_lock_wait_timeout` 来设置。 - **开启死锁检测**,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 `innodb_deadlock_detect` 设置为 `on`,表示开启这个逻辑。 在 InnoDB 中,`innodb_lock_wait_timeout` 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。但是,若直接把这个时间设置成一个很小的值,比如 1s,也是不可取的。当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。 所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 `innodb_deadlock_detect` 的默认值本身就是 on。为了解决死锁问题,不同数据库实现了各自的死锁检测和超时机制。InnoDB 的处理策略是:**将持有最少行级排它锁的事务进行回滚**。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的:每当事务被锁时,就要查看它所依赖的线程是否被其他事务锁住,如此循环,来判断是否出现了循环等待,也就是死锁。因此,死锁检测可能会耗费大量的 CPU。 ## 参考资料 - [《高性能 MySQL》](https://book.douban.com/subject/23008813/) - [极客时间教程 - MySQL 实战 45 讲](https://time.geekbang.org/column/intro/139) - [图解 MySQL 介绍](https://xiaolincoding.com/mysql/)