开发者社区 > 博文 > 系统整容纪:用知识来"武装"自己~认识MySQL的锁与事务
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

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

  • jd****
  • 2024-08-01
  • IP归属:北京
  • 100浏览

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

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

    引子

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

              系统上线生命周期

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

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

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

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

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

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

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

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

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

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

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

    而这个的来源居然只是一个普通的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),顾名思义,共享锁之间可以共享、排他锁只能自己独占,两者的兼容关系如下

    XS
    X不兼容不兼容
    S不兼容兼容

    比如我们可以用这种方式加共享锁,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. 读锁(共享锁):

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

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

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

    2. 写锁(排他锁):

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

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

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

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

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

      检查是否有写锁存在。

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

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

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

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

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

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

    3. 释放锁(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锁。

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

    XIXSIS
    X不兼容不兼容不兼容不兼容
    IX不兼容兼容不兼容兼容
    S不兼容不兼容兼容兼容
    IS不兼容兼容兼容兼容

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

    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,用于版本控制。

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

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

    3. Undo日志:

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

    4. 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会对涉及的数据行施加锁定,当一个事务在对某些行进行操作时,其他事务必须等待前一个事务完成并释放锁定后才能对这些行进行操作。

    2. 锁定读(Locking Reads):

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

    3. 隐式锁定:

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

    4. 多版本并发控制(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_ID`、`DB_ROLL_PTR`和`DB_ROW_ID`。`DB_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确保了事务的隔离性,使得读取操作可以在不被写入操作影响的情况下进行,并且不同的事务可以同时运行而不会相互干扰。

    4. 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)。在这种机制下,多个事务可以并发地向表中插入数据,只要它们不尝试生成相同的自增值。

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

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

    简化流程图:

    ```

    事务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锁)。这意味着其他事务不能修改或删除这个新插入的行,直到当前事务结束。

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

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

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

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


    ```

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

    | 解析SQL语句 |   --->   | 权限验证等预处理 |   --->   | 生成执行计划 |

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

          |                          |                        |

          V                          V                        V

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

    | 加意向锁(表级别)|       | 加自增锁(如果有自增列)|    | 加行锁(行级别)    |

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

          |                          |                        |

          V                          V                        V

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

    | 执行INSERT操作   |     | 使用插入缓冲(如适用)  |     | 提交或回滚事务  |

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

    ```

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

    delete删除语句锁的执行流程

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

    命中唯一约束键
    • 目标记录存在:针对单行记录加排它锁
    • 目标记录不存在:对所处范围加间隙锁
    命中普通索引
    • 对行记录左右相邻空隙加间隙锁,并加行记录本身加 X Lock
    未命中索引
    • 直接升级为表锁

    五、总结

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

    起初的怀疑-被推翻

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

    时刻事务A事务B
    1select * from table where unique_key='123'  lock in share mode
    持有行共享锁
    2insert into table (unique_key,othercolumns) values ('123',```)
    持有行X锁
    3select * from table where unique_key='123' lock in share mode
    阻塞,等待事务B中的行X锁

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

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

    第二波的怀疑-是对的

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

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

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