写点什么

系统整容纪:用知识来"武装"自己~ 认识 MySQL 的锁与事务

  • 2024-08-05
    北京
  • 本文字数:15178 字

    阅读完需:约 50 分钟

本文通过介绍在实际工作中一次异常排查引发的自我思考与学习,来使得读者受到一定的启发,从而迸发出星星点光,扩展出自己独有的思路,进而在工作中不断的挖掘自我不足之处,同时通过学习与"锻炼"来不断地强大自己。

分享工作中的点点滴滴,贯彻千里之行,始于足下,最终以微不足道的量变引起化蝶的质变精神。以自己为例拒绝在舒适的中央区域安逸的躺着,以便在不知不觉中被社会所淘汰,也不盲目的直接跃迁进困哪区域,在受挫的同时又跌回原有的舒适区域内,造成这样一个浑浑噩噩的让人无法进步的循环怪圈内,保持在舒适边缘的拉伸区,既跳出了舒适区又具有一定的挑战性,使得自己在保持快速进步的同时还能够渐渐树立起自信心,可谓是一举多得,系统的维护改造也是如此道理。如果大家觉得有用,欢迎大家点赞、收藏+关注,哇咔咔😀!

引子

记得那是一个令我印象深刻的周五,那天我湿身了😀,所以印象比较深刻。


系统上线生命周期


需求精研除疑点,上线交付兑诺言。


​方案众评防走偏,编码低保倚规范。


​性能高开凭手段,保量保质不留憾。


​测方以身化利剑,企稳快挥斩漏点。


​功成莫急奔前线,回首剥丝慎查验。


​严谨慎行铺试点,八方云集宏图展。


​待得完验曙光现,成果之命交由天。


​用户口碑传九天,皆大欢喜乐欢颜。


好了,闲话少说,咱们回归正题。😀


那天的雨比较大,风也很大,同时不小心淋雨了,这是比较少有的事情


那天在翻看实操系统的异常日志的时候有这么一个异常日志引起了我的注意:



而这个的来源居然只是一个普通的 select 语句



然后我就尝试的搜索了下当天所有机器报这个错的日志情况,结果是虽然不是很多,但是确实有一批这种异常,而由于 traceId 的便捷性,很快我便一一确认了下所有报此异常的场景来源,都是同一个场景:内配接单。那么接下来就是要抓取出来关键数据来分析为什么会报事务超时的异常了,为了避免业务的敏感性,以下所有 sql 均非真实 sql,而是采用跟原有 sql 一致的"虚拟 sql"。


以上就是整个时间的起因了,而要弄明白问题的根因则少不了 MySQL 的一些基本知识,所以在分析问题之前,先来普及一波 MySQL 基础知识😀。


嘿嘿,这里即兴插入一个小知识问答:假设有个业务要调一些 service 方法,其中涉及到 X 表字段 a 属性值的更新,假设要更新为 5,而执行如下代码时当执行 service2.doTwo()方法报错被 catch 住的情况发生时,等整个业务处理方法 businessService()执行完后,字段 a 属性的值应该是多少,日志中又是什么现象,嘿嘿,这个结果可是很有趣的吆


public void changeA() {        //select a from table,此时a= 1        businessService();        //select a from table,此时a= ?    }
@Transactional public void businessService() {
//有@Transactional service1.doOne(); //无@Transactional service2.doTwo(); //有@Transactional service3.doThree(); } //假设此就是service2.doTwo方法 public void doTwo() { try{ //... }catch (Exception e){ log.error("XX执行系统异常",e); } }
复制代码

一、MySQL 的锁

相信大家对于锁并不陌生吧,直白点来讲,锁是一种保护临界资源的机制,就好比如找对象,常规上来讲是一对、两个人,如果分手了那就换人,总归来说不应该出现超过两个人同时一起谈的场景😀(哈哈,常规模式哈,勿多想)。那理解了锁的简单概念后,我们来看看 MySQL 中的锁是什么样的。


以下所讲皆是基于 innodb 引擎


从占有模式上来讲,MySQL 有共享锁(S:Share Lock)和排他锁(X:Exclusive Lock),顾名思义,共享锁之间可以共享、排他锁只能自己独占,两者的兼容关系如下



比如我们可以用这种方式加共享锁,sql 如下:


SELECT * FROM table WHERE id = 3 LOCK IN SHARE MODE;


而对于插入 INSERT、更新 UPDATE、删除 DELETE 以及显式带 FOR UPDATE 关键字的 SELECT 操作,则会加上排他锁,sql 如下:


SELECT * FROM table WHERE id = 3 FOR UPDATE;


INSERT INTO table (id,name) VALUES (3,'3');


UPDATE table SET name= '3' WHERE id = 3;


DELETE FROM table WHERE id = 3;

锁的类型

行锁 Record Lock

行锁是数据库管理中的一种细粒度锁,它专门用于锁定数据库中的单独记录。在行锁的机制中,我们通常区分为两种类型:共享锁和独占锁。例如,在操作数据库时,我们可以使用以下 SQL 命令来请求不同类型的行锁:


当我们希望对特定记录进行独占锁定以执行更新操作时,可以使用如下命令:


SELECT * FROM 表名 WHERE id = 3 FOR UPDATE;


若我们需要一个共享锁,使得多个事务可以同时读取同一记录,但不能进行修改,可以使用如下命令:


SELECT * FROM 表名 WHERE id = 3 LOCK IN SHARE MODE;


需要特别指出的是,行锁的实际生效与否,很大程度上取决于 SQL 语句是否利用到了索引。如果查询条件没有触及到现有的索引,系统可能会将锁的级别从行锁提升到表锁,这会影响数据库的并发性能。


在 InnoDB 存储引擎中,表的结构高度依赖于主键索引,即一级索引。因此,当通过非主键索引对记录实施行锁时,不仅会锁定该索引,还会同时对应记录的主键进行锁定。这一点对数据库操作的性能和锁冲突的可能性有重要影响,因此在设计和优化数据库系统时,应充分考虑索引策略。

间隙锁 Gap Lock

MySQL 的间隙锁是 InnoDB 存储引擎的一种锁机制,是一种范围锁定,它锁定一个范围内的空间,但不包括记录本身,是为了解决并发事务中的幻读问题而设计的。


幻读是指当一个事务在读取某个范围内的记录时,另一个事务在这个范围内插入了新的记录,导致前一个事务再次读取时会看到之前不存在的记录。


InnoDB 存储引擎使用多版本并发控制(MVCC)机制来提供高并发性能,并使用锁定来维护事务的隔离级别。在可重复读(REPEATABLE READ)和串行化(SERIALIZABLE)隔离级别下,InnoDB 会使用间隙锁来防止幻读。


当一个事务执行范围查询并进行更新或删除操作时,为了保证隔离性,InnoDB 会对查询范围内的索引记录和索引记录之间的间隙设置锁定。例如,如果有一个索引包含值 1, 2, 4,事务在查询大于 2 小于 4 的记录时,会锁定 2 和 4 之间的间隙。


间隙锁的类型


  1. 共享间隙锁(Shared Gap Lock):允许其他事务读取间隙,但不允许插入。

  2. 排他间隙锁(Exclusive Gap Lock):不允许其他事务读取或插入间隙。


举例说明


考虑以下索引记录:


... | 1 | 3 | 5 | 7 | ...


如果事务 A 想要插入值为 4 的记录,它需要检查是否存在介于 3 和 5 之间的间隙锁。如果事务 B 已经在这个间隙上设置了间隙锁,那么事务 A 必须等待,直到事务 B 提交或回滚,才能插入记录。


间隙锁的图示


| 1 |----间隙锁----| 3 |----间隙锁----| 5 |----间隙锁----| 7 |


在这个图示中,每个"----间隙锁----"代表一个间隙锁区域。事务如果需要在这些区域插入新的记录,必须等待持有间隙锁的事务结束。


注意事项


•间隙锁通常只在可重复读或更高的隔离级别下使用。


•间隙锁可能会增加锁争用,因此可能对性能产生影响。


•在读已提交(READ COMMITTED)隔离级别下,InnoDB 不会使用间隙锁来防止幻读,而是使用其他机制,如 Next-Key 锁或 MVCC。

临键锁 Next-Key Lock

MySQL 的临键锁是 InnoDB 存储引擎为了实现可重复读(REPEATABLE READ)隔离级别而设计的一种锁机制。临键锁是行锁与间隙锁的组合,它不仅锁定一条记录,同时也锁定了该记录之前到下一条记录之间的间隙。


在 InnoDB 中,所有的数据都是按照主键顺序存放在 B+树中的。每个索引记录不仅包含键值,还包含了指向实际数据行的指针。当 InnoDB 对数据行进行搜索时,它会使用 B+树的搜索算法。


  1. 行锁(Record Lock):行锁是直接作用于索引记录上的锁。当 InnoDB 对某个索引记录加锁时,它实际上是在该记录的索引项上加了锁。

  2. 间隙锁(Gap Lock):间隙锁是锁定一个范围,但不包括记录本身。它用于防止其他事务插入“间隙”范围内的值,这样可以避免幻读(Phantom Read)的问题。

  3. 临键锁(Next-Key Lock):结合了行锁和间隙锁,它锁定一个范围,并且包括了范围的起始记录。在可重复读隔离级别下,InnoDB 默认会对查询到的索引记录使用临键锁。


假设有以下索引结构,简化为一维的 B+树:


+---+---+---+---+---+---+


| 5 | 10| 15| 20| 25| 30|


+---+---+---+---+---+---+


如果事务 A 执行一个范围查询语句(比如:SELECT * FROM table WHERE key > 10 AND key < 20 FOR UPDATE;),InnoDB 会对键值在 10 到 20 之间的记录加上临键锁。


图示如下:


+---+-----+---+-----+---+---+


| 5 | 10 | 15| 20 | 25| 30|


+---+-----+---+-----+---+---+


^^^^^| |^^^^^


间隙锁 行锁 间隙锁


在这个例子中,事务 A 会对键值为 15 的记录加上行锁,并对 10 到 15 以及 15 到 20 的间隙加上间隙锁。这意味着其他事务不能插入、删除或修改键值在 10 到 20 之间的任何记录。


临键锁的影响


  1. 防止幻读:临键锁可以有效防止幻读的出现,因为它锁定了查询范围内的记录和间隙。

  2. 并发性能:由于临键锁锁定了间隙,它可能会降低数据库的并发性能,特别是在大量范围查询和更新的场景下。

  3. 死锁:临键锁可能会增加死锁的出现概率,因为它锁定了更多的资源。

表锁 Table Lock

MySQL 中的表锁是一种锁机制,用于控制多个并发事务对数据库表进行访问时的同步。表锁是锁定整个表的一种简单的锁策略,相对于行级锁来说,表锁的粒度较大,开销较小,加锁快,但并发程度较低。表锁适用于读多写少的场景,因为它不适合高并发的写操作。


表锁的类型:


  1. 读锁(共享锁):


多个事务可以同时获得同一张表的读锁。


加读锁的事务可以读表,但不能写表。


其他事务可以读表,但不能写表。


  1. 写锁(排他锁):


只有一个事务可以获得写锁。


加写锁的事务可以读写表。


其他事务既不能读也不能写。


加锁和释放锁的基本流程如下:


  1. 加读锁(LOCK TABLES table_name READ):


检查是否有写锁存在。


如果没有写锁,加读锁成功。


如果有写锁,等待写锁释放。


  1. 加写锁(LOCK TABLES table_name WRITE):


检查是否有其他读锁或写锁存在。


如果没有其他锁,加写锁成功。


如果有其他锁,等待所有锁释放。


  1. 释放锁(UNLOCK TABLES):


释放当前事务持有的所有锁。


允许其他在等待队列中的事务尝试加锁。


表锁的优缺点:


优点:


•实现简单,开销较小,加锁快。


•在查询为主的应用场景下,可以提高系统的吞吐量。


缺点:


•并发能力差,尤其是写操作较多时,会成为瓶颈。


•不能精细控制并发操作,容易产生锁争用。


•如果事务持有锁的时间过长,会导致其他事务长时间等待。

意向锁 Intention Lock

MySQL 的意向锁是 InnoDB 存储引擎实现多粒度锁定(Multiple Granularity Locking, MGL)的一部分,它用于表明事务在某一数据行上请求的锁定类型。意向锁是表级锁,分为两种:


  1. 意向共享锁(Intention Shared Lock, IS 锁):表明事务想要在某些行上设置共享锁(S 锁)。

  2. 意向排他锁(Intention Exclusive Lock, IX 锁):表明事务想要在某些行上设置排他锁(X 锁)。


意向锁的实现原理是基于两个主要目的:


允许锁定系统在不检查所有行锁的情况下快速判断是否可以获取表级锁。


提供一种机制来表明事务对行锁的意图,从而避免不必要的锁冲突检查。


通俗讲:意向锁是一种设计用来优化粗粒度锁性能的机制。它通过在实际请求锁定特定资源之前,先对其上层更大范围的资源表达锁定的意图,从而进行预先声明。这种做法有助于提高不同层级锁资源间的协作效率和整体性能。


意向锁的规则如下:


•在对任何行请求共享锁之前,事务必须先获得对应表的 IS 锁或更强的锁。


•在对任何行请求排他锁之前,事务必须先获得对应表的 IX 锁。


•如果事务已经持有了对应表的 IS 或 IX 锁,它可以请求更多的行锁而无需再次获取表锁。


•其它事务可以同时对表加 IS 锁,但是 IX 锁是不兼容的。


•行锁与表上的意向锁是兼容的,例如,一个事务可以对表加 IS 锁并对一行加 X 锁。


意向锁的锁兼容矩阵如下:



以下是一个意向锁的使用示例,展示了事务如何使用意向锁来获取行锁:


  1. 事务 A 想要读取表 T 中的一些行,它首先请求并获得表 T 的 IS 锁。

  2. 事务 A 随后在需要的行上设置共享锁(S 锁)。

  3. 同时,事务 B 想要更新表 T 中的某些不同的行,它首先请求并获得表 T 的 IX 锁。

  4. 事务 B 随后在需要更新的行上设置排他锁(X 锁)。


由于事务 A 和事务 B 操作的是不同的行,它们在表级别的意向锁(IS 和 IX)是兼容的。这样,两个事务可以同时在不同的行上进行操作,提高了并发性能。

死锁

MySQL 中的死锁是指多个事务在执行过程中因争夺资源而造成的一种相互等待的现象,每个事务都在等待其他事务释放资源,如果没有外力干涉,这些事务都无法向前推进,形成了一个闭环的等待链条。


例如,事务 A 已经锁定了资源 R1,同时试图锁定资源 R2;而事务 B 已经锁定了资源 R2,同时试图锁定资源 R1。这时,A 等待 B 释放 R2,B 等待 A 释放 R1,双方都不愿意让步,就形成了死锁。


死锁的检测与解决


MySQL 通过死锁检测算法来处理死锁问题。当事务请求的资源被另一个事务持有,并且发现等待链条中存在循环时,检测算法就会触发。MySQL 有两种方式解决死锁:


  1. 等待超时:在innodb_lock_wait_timeout设置的时间之后,如果事务还未能获取所需资源,则会被自动回滚。

  2. 死锁检测和解除:InnoDB 存储引擎会动态地检测死锁,并自动选择并回滚死锁链中的某个事务,以解除死锁状态。


下面是一个简单的死锁图示:


事务 A 事务 B


| |


|----> 锁定资源 R1


| |


| |----> 锁定资源 R2


| |


|----> 请求资源 R2


| |


| |----> 请求资源 R1


| |


|<----等待资源 R2


| |


| |<----等待资源 R1


在这个例子中,事务 A 锁定了资源 R1,然后请求资源 R2;事务 B 锁定了资源 R2,然后请求资源 R1。它们都在等待对方持有的资源,形成了死锁。


为了避免死锁,可以采取以下策略:


  1. 顺序访问资源:确保所有事务都按照相同的顺序请求资源。

  2. 超时设置:为事务设置合理的超时时间,超时后放弃等待,回滚事务。

  3. 锁粒度控制:尽量使用行锁而不是表锁,减少锁定资源的范围。

  4. 尽早释放锁:在不需要资源时尽快释放锁,减少持有时间。

  5. 减少事务大小:执行小事务,减少长事务运行时间,降低死锁发生的概率。

二、MySQL 的事务

上面呢讲解了一些锁的基本概念和一些示例,而锁又是发生在事务里的,接下来让我来了解是一下事务。


MySQL 的事务是一组操作序列,这些操作要么全部执行,要么全部不执行,是数据库管理系统执行过程中的一个逻辑单位。通过事务,MySQL 能够保证即使在系统或其他故障的情况下,数据库也不会处于不一致的状态。


事务的 ACID 特性:


  1. 原子性(Atomicity) - 事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。

  2. 一致性(Consistency) - 事务必须使数据库从一个一致性状态转换到另一个一致性状态。

  3. 隔离性(Isolation) - 并发执行的事务之间不能互相干扰,多个并发事务之间要相互隔离。

  4. 持久性(Durability) - 一旦事务提交,则其所做的修改将永久保存在数据库中,即使系统崩溃也不会丢失。


MySQL 中,事务的实现依赖于存储引擎,不同的存储引擎对事务的支持不同。以支持事务的 InnoDB 存储引擎为例,其事务的实现原理包括:


  1. Undo 日志:保证事务的原子性。当事务进行中发生错误或者用户执行回滚操作时,系统可以利用 Undo 日志撤销已经进行的修改。

  2. Redo 日志:保证事务的持久性。即使数据库发生故障,已经提交的事务也不会丢失,因为这些修改操作会记录在 Redo 日志中。

  3. 锁机制:保证事务的隔离性。通过对数据加锁,防止多个事务并发执行时互相干扰。

  4. MVCC(多版本并发控制):也是为了保证隔离性,在 InnoDB 中,通过保存数据的多个版本,来允许读写操作并发进行,提高性能。


下面是一个简化的图示,展示了事务处理的一般流程:


+-----------------+ +-------------------+


| Start | | Commit/Rollback |


| Transaction | | Transaction |


+-----------------+ +-------------------+


| ^


| |


| |


v |


+-----------------+ +-------------------+


| Execute | | Write Undo |


| SQL Queries | | Log for Rollback|


+-----------------+ +-------------------+


| ^


| |


| |


v |


+-----------------+ +-------------------+


| Write Redo | | Release Locks |


| Log for | | |


| Durability | +-------------------+


+-----------------+


|


|


v


+-----------------+


| Apply Locks |


| (if needed) |


+-----------------+


在开始一个事务时(Start Transaction),你会执行一系列的 SQL 查询(Execute SQL Queries)。在这个过程中,如果需要修改数据,会首先写入 Redo 日志(Write Redo Log for Durability),以确保即使系统崩溃,这些修改也能够被恢复。同时,如果涉及到数据的修改,还会记录 Undo 日志(Write Undo Log for Rollback),以便在事务失败时回滚这些修改。


在事务执行过程中,根据需要对涉及的数据加锁(Apply Locks),这样可以确保事务的隔离性。当事务完成所有操作并且成功时,会提交事务(Commit Transaction),这时候会释放所有的锁(Release Locks)。如果事务执行过程中遇到问题,或者用户主动回滚事务(Rollback Transaction),则会利用 Undo 日志撤销之前的操作,并释放锁。


这是一个非常高层次的概述,实际上 MySQL 的事务处理要复杂得多,涉及到诸如事务隔离级别的设置、死锁检测和解决等多个方面。


在 MySQL 中,事务的隔离级别通常有以下四种:


  1. 读未提交(READ UNCOMMITTED)

  2. 读已提交(READ COMMITTED)

  3. 可重复读(REPEATABLE READ)

  4. 串行化(SERIALIZABLE)

读未提交

读未提交(Read Uncommitted)是事务隔离的最低级别,它允许事务读取尚未被其他事务提交的数据。这种级别的隔离可能会导致一些问题,如脏读(Dirty Read)。


假设有两个事务,事务 A 和事务 B。


  1. 事务 A 开始并更新一个记录,但尚未提交。

  2. 事务 B 在事务 A 提交之前开始,并读取了相同的记录。


在读未提交的隔离级别下,事务 B 将看到事务 A 所做的未提交的更改。如果事务 A 回滚,那么事务 B 读取的数据将是不正确的,这就是脏读。


在实际应用中,通常会使用更高级别的隔离,如读提交(Read Committed)、可重复读(Repeatable Read)或串行化(Serializable),以避免脏读等问题。在 MySQL 中,默认的事务隔离级别是可重复读。

读已提交

读已提交(Read Committed)是数据库事务的四种隔离级别之一,它位于读未提交(Read Uncommitted)与可重复读(Repeatable Read)之间。在 MySQL 中,读已提交的隔离级别可以通过设置事务的隔离级别为READ COMMITTED来实现。这个隔离级别避免了脏读(Dirty Read,即读取到其他事务未提交的数据)的问题,但是它不能防止不可重复读(Non-repeatable Read)和幻读(Phantom Read)。


MySQL 中的 InnoDB 存储引擎采用了多版本并发控制(MVCC,Multi-Version Concurrency Control)机制来实现不同的隔离级别。在 MVCC 中,每当一个数据对象被更新时,都不会直接覆盖旧的数据,而是会生成一个新的版本。同时,InnoDB 会为每个事务维护一个系统版本号(transaction ID),用来标识事务开始的时间点。


当事务在读已提交模式下运行时,以下是其工作原理的简化流程:


  1. 当事务 A 开始时,会获得一个唯一的事务 ID。

  2. 当事务 A 需要读取数据时,它会读取所有版本号小于等于自己事务 ID 的数据行的最新版本(即要求这些版本是已经提交的)。

  3. 如果在事务 A 读取数据期间,事务 B 对其中一些数据进行了修改并提交,事务 B 的修改会创建新的数据版本,并拥有一个新的版本号。

  4. 如果事务 A 再次读取相同的数据,它会看到事务 B 提交的新版本,因为这些新版本现在是已提交的,并且其版本号小于等于事务 A 的事务 ID。


事务 A 开始 | 事务 B 开始 | 事务 B 修改数据 X | 事务 B 提交 | 事务 A 读取数据 X


----------|-----------|------------------|-----------|----------------


TID:1 | TID:2 | 生成数据 X 的新版本| | 读取到数据 X 的新版本


| | (版本号:2) | |


| | | 提交 B | 由于 B 已提交,A 读取到


| | | (数据 X 的 | 更新后的数据 X


| | | 版本号 2)|


读已提交的隔离级别提供了一个平衡的选择,它在并发性能和数据一致性之间取得了折中,适用于那些可以容忍不可重复读但要求避免脏读的应用场景。

可重复读

MySQL 的读可重复度事务(Repeatable Read)是指在一个事务内,多次读取同一数据的结果是一致的,即使在这些读取操作之间,其他事务对这些数据进行了修改或新增。读可重复度是 SQL 标准中定义的四个事务隔离级别之一,位于“读已提交”(Read Committed)和“可序列化”(Serializable)之间。


MySQL 默认使用 InnoDB 作为其存储引擎,InnoDB 通过以下几种机制来实现读可重复度事务隔离级别:


  1. 多版本并发控制(MVCC):


InnoDB 利用 MVCC 机制来提供读可重复度。每当数据被更新时,InnoDB 存储引擎都会为该数据生成一个新版本,并为每个版本附上一个系统版本号(事务 ID)。当事务开始时,它会获得一个唯一的事务 ID,用于版本控制。


  1. 一致性非锁定读(Consistent Nonlocking Reads):


在读可重复度隔离级别下,普通的 SELECT 操作不会加锁,而是执行一致性非锁定读。这意味着它会读取那些创建版本号小于等于事务版本号的行的最新版本(即在事务开始之前已经存在的数据版本)。


  1. Undo 日志:


当数据被修改时,InnoDB 会在 Undo 日志中保留旧的数据版本。如果一个事务需要查看之前的数据状态,它可以通过 Undo 日志来访问之前版本的数据。


  1. Next-Key Locks:


InnoDB 通过 Next-Key 锁来避免幻读(Phantom Read)。这种锁是记录锁和间隙锁的组合,它锁定一个范围,防止其他事务在这个范围内插入新的行。



事务T1 事务T2
| |
|---[开始事务] |
| |
|---[读取数据A] |
| | |
| |---[数据A的版本号:1] |
| |
| |---[开始事务]
| |
| |---[更新数据A]
| | |
| | |---[数据A的新版本号:2]
| |
|---[再次读取数据A] |
| | |
| |---[读取到的数据A版本号仍为1] |
| |
|---[提交事务] |
| |
| |---[提交事务]
复制代码


在上面的示例中,事务 T1 在读取数据 A 后,即使事务 T2 更新了数据 A 并提交了事务,事务 T1 在其自身的事务范围内再次读取数据 A 时,仍然会看到版本号为 1 的数据,这是因为 T1 的读取操作是基于事务开始时的数据快照进行的。


通过上述机制,InnoDB 存储引擎能够确保在读可重复度隔离级别下,事务可以在其整个过程中看到一个一致的数据快照,从而实现了数据的一致性和隔离性。

串行化

MySQL 的串行化事务是指在数据库管理系统中,事务被执行的方式就像它们是依次串行执行的一样,即事务之间没有任何并发执行的现象。串行化是事务隔离级别中最高的级别,它可以防止脏读、不可重复读和幻读这些并发问题。


在这个级别下,一个事务完整执行过程中,不允许其他事务进行写操作,甚至是读操作也可能被限制(取决于具体的实现),以确保不会出现并发带来的问题。


MySQL 中实现串行化事务的原理可以通过以下几种机制来完成:


  1. 行级锁定(Row-Level Locking):


在串行化级别下,MySQL 会对涉及的数据行施加锁定,当一个事务在对某些行进行操作时,其他事务必须等待前一个事务完成并释放锁定后才能对这些行进行操作。


  1. 锁定读(Locking Reads):


当进行 SELECT 操作时,可以通过使用FOR UPDATELOCK IN SHARE MODE来显式地对读取的行施加排他锁或共享锁。


  1. 隐式锁定:


在串行化级别下,即使没有显式地请求锁定,MySQL 也会自动地为事务中涉及的所有数据行施加锁定,以避免数据在事务执行期间被其他事务修改。


  1. 多版本并发控制(MVCC):


尽管在串行化级别下,MVCC 可能不是必须的,但在其他隔离级别下,MVCC 可以允许读操作不加锁,同时保证数据的一致性。在串行化级别下,MVCC 仍然可以帮助系统维护多个版本的数据,以便于实现锁定策略。



+----------------+ +----------------+ +----------------+
| 事务 A 开始 | | 事务 B 等待 | | 事务 B 开始 |
| 对数据 X 加锁 | | 事务 A 执行中 | | 对数据 X 加锁 |
| 读/写数据 X | | 数据 X 被锁定 | | 读/写数据 X |
| 提交/回滚事务 | | 事务 A 提交 | | 提交/回滚事务 |
+----------------+ +----------------+ +----------------+
复制代码


在这个示意图中,事务 A 开始执行并对数据 X 加锁后,事务 B 必须等待事务 A 完成并释放锁定,之后事务 B 才能开始执行并对数据 X 加锁。这保证了事务在数据库中的串行化执行。


在这个隔离级别下,性能通常会有所下降,因为事务必须严格地一个接一个地执行,但它可以为关键任务提供最严格的数据一致性保证。通常只有在需要绝对的数据一致性,并且可以接受较低并发性能的情况下,才会使用串行化隔离级别。

三、多版本控制(MVCC)

多版本并发控制(MVCC,Multi-Version Concurrency Control)是 MySQL 中 InnoDB 存储引擎用来实现高并发的一种技术。MVCC 允许在不加锁的情况下进行读取,从而提高了系统的并发性能。下面我会简要介绍 MVCC 的概念与实现原理。


MVCC 通过为每个事务创建一个快照,使得每个事务看到的数据是一致的,并且是在事务开始时或者读取时刻的数据状态。这种方式避免了对读取操作加锁,从而实现了非阻塞的读取,提升了并发性能。


MVCC 实现原理


MVCC 在 InnoDB 中的实现依赖于以下几个核心组件:


  1. 事务 ID(Transaction ID):每个事务开始时都会分配一个唯一的事务 ID,这个 ID 是递增的。

  2. 隐藏列:在 InnoDB 中,每行数据后面都会隐式地加上三个字段:DB_TRX_IDDB_ROLL_PTRDB_ROW_IDDB_TRX_ID记录了最后修改该行记录的事务 ID,DB_ROLL_PTR是回滚指针,指向该行记录的 undo log 记录,DB_ROW_ID是 InnoDB 自动产生的一个行 ID(当表没有主键时会使用)。

  3. Read View:当读取操作发生时,InnoDB 会为事务创建一个 Read View,其中包含了活跃事务的 ID 列表。活跃事务是指在当前事务开始时还未提交的其他事务。


在 MVCC 中,Read View 是一个逻辑上的结构,它定义了事务可以看到的数据版本。Read View 通常包含以下几个关键部分:


  1. 系统版本号(System Version Number, SVN):每当有新的事务开始时,系统版本号会递增。这个版本号用来标记事务的开始时间点。

  2. 活跃事务列表(Active Transaction List):在创建 Read View 时,系统会记录下当前所有活跃的、未提交的事务的事务 ID。这些活跃的事务可能对数据进行修改,因此它们的修改对当前事务来说是不可见的。

  3. 最小活跃事务 ID(Min Active Transaction ID):所有活跃事务中最小的事务 ID。这个 ID 之前的所有事务要么已经提交,要么已经回滚。

  4. 创建 Read View 时的事务 ID(Creator Transaction ID):创建该 Read View 的事务自身的 ID。


下面通过一个图来解释 Read View 的组成和工作原理:



|------------------------------------------------------|
| 数据库系统 |
|------------------------------------------------------|
| |-------| |-------| |-------| |-------| | |
| | TX 1 | | TX 2 | | TX 3 | | TX 4 | | |
| |-------| |-------| |-------| |-------| | |
| SVN = 10 SVN = 20 SVN = 30 SVN = 40 SVN = 50 |
|------------------------------------------------------|
复制代码


假设上述系统中有四个事务 TX 1 到 TX 4,它们的系统版本号分别是 10、20、30 和 40。当 TX 4 开始创建 Read View 时,系统版本号是 50,因此 TX 4 的 Read View 可能如下所示:



Read View for TX 4:
- System Version Number at Creation: 50
- Active Transaction List: [TX 1, TX 2, TX 3]
- Min Active Transaction ID: ID of TX 1
- Creator Transaction ID: ID of TX 4
复制代码


当 TX 4 进行数据读取时,它会根据自己的 Read View 来确定数据的可见性:


  • 它可以看到 TX 1、TX 2 和 TX 3 之前所有提交的事务所做的修改。

  • 它看不到 TX 1、TX 2 和 TX 3 的修改,因为这些事务是活跃的,并且在 TX 4 的 Read View 创建后可能会进行修改。

  • 它自己的修改对自己是可见的。


通过这种方式,MVCC 确保了事务的隔离性,使得读取操作可以在不被写入操作影响的情况下进行,并且不同的事务可以同时运行而不会相互干扰。


  1. Undo 日志:当数据被修改时,InnoDB 会记录一份修改前的数据副本到 Undo 日志中。这份数据可以用来在事务回滚时恢复数据,也用于 MVCC 中构建旧版本的数据。


当一个事务要读取一行数据时,InnoDB 会使用以下规则来判断这行数据是否对当前事务可见:


如果数据的DB_TRX_ID等于当前事务的 ID,数据对当前事务可见。


如果数据的DB_TRX_ID在 Read View 的活跃事务 ID 列表中,说明修改还在进行中,数据对当前事务不可见,需要读取 Undo 日志中的旧版本数据。


如果数据的DB_TRX_ID小于 Read View 中最小的活跃事务 ID,数据对当前事务可见。


如果数据的DB_TRX_ID大于 Read View 中最大的活跃事务 ID,说明数据是在当前事务开始后被其他已提交的事务修改的,数据对当前事务不可见。



事务A(ID=100) 事务B(ID=101)
开始时间 ------------------->
开始时间 ------------------->
Read View A: [101]
Read View B: []


行数据1: {DB_TRX_ID: 100, ...}
行数据2: {DB_TRX_ID: 101, ...}


事务A读取行数据1:可见
事务A读取行数据2:不可见,需要读取Undo日志中的旧版本数据
事务B读取行数据1:不可见,需要读取Undo日志中的旧版本数据
事务B读取行数据2:可见
复制代码


在这个例子中,事务 A 开始后,事务 B 也开始了。当事务 A 和 B 尝试读取行数据时,它们会根据自己的 Read View 来判断数据是否可见。如果不可见,它们会读取 Undo 日志中的数据。


注意:读已提交事务是在每次读取时都会生成一个新的 readView,而可重复度是在事务开始时只生成一次 readView。


MVCC 是一种复杂的机制,但它极大地提高了数据库的并发读写能力,尤其是在读多写少的情况下。通过避免加锁来提高性能,同时确保事务的隔离性,MVCC 是现代数据库管理系统中的一个关键特性。

四、加锁示例(大前提:事务隔离级别为可重复读)

通过文章上述内容的 MySQL 相关基础知识的介绍,相信大家已经对于事务和锁有了基本的了解,尤其是行锁是基于索引建立的,而索引又有唯一约束索引和普通索引,甚至是自增主键,如果命中上述不同的索引或者失效、又或者插入数据时自增主键的竞争,又或是日常的增删改查等,对于上述所说的种种场景其中的锁机制又是怎样执行的呢?来吧,上宝典😀

命中唯一约束键

•如果目标记录存在,则只会针对这一行记录加行锁


•如果目标记录不存在,则会针对所处范围加间隙锁. 如果左右有缺口,则通过 ±∞ 补齐

命中普通索引

此时除了会用行锁锁住记录本身,还会通过间隙锁锁定左右相邻空隙.


之所以需要额外施加间隙锁,就是为了避免在持有锁期间,有其他并发事务插入相同索引值对应的行记录,从而导致幻读问题的发生.

未命中任何索引

锁级别直接上升为表锁

自增键锁

MySQL 的自增主键锁(Auto-Increment Locks)是一种特殊类型的锁,用于保护自增主键在插入新记录时的唯一性和连续性。自增主键通常用于那些需要唯一标识符的表中,它允许数据库自动生成下一个序列号,省去了手动插入唯一标识符的麻烦。


在 MySQL 中,自增锁的实现原理与存储引擎有关,但以最常用的 InnoDB 存储引擎为例,自增锁的实现可以分为以下几个步骤:


  1. 锁的类型:InnoDB 实现了两种类型的自增锁:


表级自增锁:在早期版本的 MySQL 中,InnoDB 使用表级自增锁,当一个事务要插入新记录时,它会锁定整个表来获取下一个自增值。这种锁定机制保证了自增值的唯一性,但在高并发插入时会成为瓶颈。


轻量级锁(interleaved lock):从 MySQL 5.1 开始,InnoDB 引入了一种新的自增锁机制,即轻量级锁或间隙锁(interleaved lock)。在这种机制下,多个事务可以并发地向表中插入数据,只要它们不尝试生成相同的自增值。


  1. 锁的获取:当一个事务试图插入一条新记录时,它会首先获取下一个可用的自增值。在获取过程中,InnoDB 会根据需要对内部数据结构加锁,以确保自增值的唯一性和连续性。

  2. 锁的释放:在早期版本中,表级自增锁会在事务结束时释放。而在轻量级锁机制下,自增锁通常在下一个自增值被生成后立即释放,这样可以最大程度地减少锁定时间,提高并发性能。


简化流程图:



事务A 事务B 自增主键值


|请求自增值| |1|
|------------------>| |2|
|<------------------| |
|执行插入操作| |
| |请求自增值|
| |------------------>|
| |<------------------|
| |执行插入操作|
|事务提交| |
|<------------------| |
| |事务提交|
复制代码


在这个例子中,事务 A 请求一个自增值,它获得了 1,然后它开始执行插入操作。在此期间,事务 B 也请求自增值,并获得了 2。由于 InnoDB 的轻量级锁机制,事务 B 不需要等待事务 A 完成插入和提交,就可以进行它的插入操作。这样,两个事务就可以并行执行,提高了性能。


需要注意的是,上述描述是非常简化的版本,实际的并发控制会涉及更多细节,例如事务隔离级别、行锁定以及死锁检测等。此外,自增值的持久性和恢复机制也是自增锁实现中的重要部分。


注意:以上述示例为例,自增值的使用是不会返还的,即:如果事务 A 成功获取了自增值 1,但是在执行事务中由于某种原因进行了回滚,此表释放的自增值 1 是不会返还的,下次最近的新的事务获取的自增值是 3 而不是 1

insert 插入语句锁的执行流程

InnoDB 是 MySQL 默认的存储引擎,它支持事务处理、行级锁定和外键等特性。当执行一个 INSERT 语句时,InnoDB 存储引擎会进行如下的加锁流程:


  1. 解析阶段:MySQL 首先会解析 SQL 语句,检查语法是否正确。

  2. 预处理阶段:进行权限验证,检查是否有对应表的插入权限,以及表是否存在等。

  3. 优化阶段:生成执行计划,选择合适的索引进行数据插入。

  4. 加锁阶段:在执行计划确定后,InnoDB 会根据操作的类型来决定加锁的粒度和类型。对于 INSERT 语句,InnoDB 会执行以下加锁流程:


a. 意向锁(Intention Locks):在表级别加上意向锁,这是一个表明接下来会在行级别加锁的信号锁,否则阻塞等待


b. 自增锁(AUTO-INC Locks):如果插入操作涉及到自增主键,InnoDB 会对自增计数器加锁,以保证生成的主键值的唯一性和连续性。


c.唯一键冲突校验:校验插入记录是否会和已存在记录发生唯一键冲突,若没有冲突则直接进入步骤 d,否则进行加锁尝试解决冲突,解决冲突后继续步骤 d 或报错


锁定冲突行:


如果检测到冲突,并且有另一个事务正在操作冲突行,DBMS 会尝试为这个行数据加锁。


如果该行已经被另一个事务锁定,当前事务将会等待,直到锁被释放。


冲突解决:


如果当前事务能够获得锁(即没有其他事务正在操作该行),则根据操作类型解决冲突。


如果是插入操作,当前事务将会失败,因为不能违反唯一约束。


如果是更新操作,当前事务会更新这个行,只要更新后的值不违反唯一约束。


d. 行锁(Row Locks):InnoDB 实际上会在新插入的行上加上排它锁(X 锁)。这意味着其他事务不能修改或删除这个新插入的行,直到当前事务结束。


  1. 执行阶段:执行 INSERT 语句,实际将数据插入到表中。

  2. 插入缓冲(Insert Buffering):对于非唯一索引的插入操作,InnoDB 可能会使用插入缓冲来提高插入效率,减少磁盘 I/O 操作。

  3. 提交或回滚:如果 INSERT 语句执行成功,事务会被提交;如果在执行过程中发生错误或者用户主动回滚,之前加的锁会被释放。


由于这个过程中涉及到的锁类型和加锁的细节比较复杂,通常不会通过图形来表示整个加锁流程。然而,可以通过流程图来简要描述上述步骤:



+------------+ +--------------+ +-----------+
| 解析SQL语句 | ---> | 权限验证等预处理 | ---> | 生成执行计划 |
+------------+ +--------------+ +-----------+
| | |
V V V
+----------------+ +--------------------+ +------------------+
| 加意向锁(表级别)| | 加自增锁(如果有自增列)| | 加行锁(行级别) |
+----------------+ +--------------------+ +------------------+
| | |
V V V
+------------------+ +----------------------+ +---------------+
| 执行INSERT操作 | | 使用插入缓冲(如适用) | | 提交或回滚事务 |
+------------------+ +----------------------+ +---------------+
复制代码


在实际的数据库操作中,InnoDB 的加锁机制还涉及到很多其他细节,如事务隔离级别、锁的兼容性等,这些都会影响到具体的加锁行为和性能。

delete 删除语句锁的执行流程

delete 语句锁的持有情况主要看 where 条件,即看条件走不走索引,走什么索引等。

命中唯一约束键

•目标记录存在:针对单行记录加排它锁


•目标记录不存在:对所处范围加间隙锁

命中普通索引

•对行记录左右相邻空隙加间隙锁,并加行记录本身加 X Lock

未命中索引

•直接升级为表锁

五、总结

通过文章上述内容,我们对于 MySQL 锁和事务的基本知识算是有了基本的了解,那么回到文章开篇的问题上来。

起初的怀疑-被推翻

最开始的时候我是怀疑在事务中的普通 select 语句与非事务中的普通 select 语句在锁的持有上是有所区别的,认为事务中的 select 默认持有共享锁,通过查看代码了解到上述的实际业务处理逻辑部分都是同处在同一个"大事务"(因为事务里的业务处理代码实在是太长了😀)的,而报错的 select 语句后方是紧跟着一个同表的 insert 语句,同时此表是有非主键外的唯一约束键的(select 语句就是通过此唯一约束键进行的查询),于是乎认为问题出在这里


但是经过查阅相关资料(其实上文中的 MVCC 中已经也有介绍了)和实际操作验证,发现在 MySQL 数据库采用 InnoDB 并且事务隔离级别是可重复读的时候,普通 select 语句是不带锁的,有这么一个专业名词:一致性读(consistency read),即:此种模式下 select 语句读的是记录的快照,不会加锁。


那通过上述的分析与实际检验问题的根因不出在这里,不过虽然自己的推测是错的,但是此问题的排查过程中又学习到了不少知识,为以后此类问题的排查以及后续好代码的编写打下了坚实的基础。好了,吹嘘到此为止😀,我们继续回到问题上来,那既然不是出现在数据库层面那还有什么可能性,突然我想到了一个我一直忽略的一个点,spring 事务的处理机制。

第二波的怀疑-是对的

大家都知道 spring 的事务是基于代理来实现的,其中的事务传播行为有多种,而此问题的业务处理采用的默认传播机制:Propagation.REQUIRED,我现在将注意点转移到 spring 事务超时的可能性上来,又回看了一次代码,发现在业务的声明事务起始处确实设置了事务超时时间为 60 秒,如下图



通过日志又查看到报错 sql 实际执行时间为 1 毫秒,而整个"大事务"的执行确实超过了 60 秒,到此问题已经定位清楚了,那接下来治理就好办了,方法简直不要太多😁


哈哈有没有发现写了这么一大篇,其实感觉很乌龙,哇咔咔,我也发现了,不过确实有收货,那就记录下来吧😁

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
系统整容纪:用知识来"武装"自己~认识MySQL的锁与事务_京东科技开发者_InfoQ写作社区