从场景出发谈谈MySQL INSERT语句如何加锁

2024年 3月 11日 131.2k 0

写在前面

Mysql作为关系型数据库,在各位程序员从SQL Boy到架构师的打怪升级之路上如影随形。但它的加锁规则,一直都是面试官的心头好,候选宝宝们的老大难。网上有很多关于Mysql的资料文档,只给结论但很少从场景化的角度出发去整理Mysql的锁机制。尤其是对于INSERT语句更是如此。一个INSERT语句中除了innodb存储引擎的行级锁外还涉及到了 隐式锁 插入意向锁

今天我们从场景视角切入结合实验测试,谈一谈其中的弯弯绕绕。

以下所有测试所用的Mysql版本为8.0.36,事务隔离级别为默认的可重复读。另外对于一些基本的事务与锁的相关概念,还请大家先行了解,这里不做过多介绍。

那么让我们开始吧。

执行INSERT会遭遇的插入场景

先给出INSERT语句的加锁规则:

  • 有间隙锁,无主键索引或者唯一二级索引冲突:当前事务要插入新记录,但它的下一条记录已经被其他正在运行的事务添加了间隙锁,那么当前事务的INSERT语句会被阻塞,并试图获取意向插入锁。
  • 无间隙锁,有主键索引或者唯一二级索引冲突:当前事务要插入新记录,但待插入记录中的索引字段的值和已有记录中主键索引或唯一索引值冲突:如果主键索引冲突,那么待插入记录的事务会给被冲突的主键所在的聚簇索引中的记录加上S型记录锁;如果是唯一二级索引冲突,那么待插入记录的事务会给被冲突的记录所在的唯一二级索引中的记录加上S型next-key锁

在涉及到待插入记录与已存在记录的索引值冲突时,容易忽视一个问题(这也是很多Mysql相关文章中没有着重提到的一个点,没有把这个点写清楚,对于INSERT语句到底如何加锁只能是看得云里雾里),就是待插入记录和已有记录的索引值冲突中,这个所谓的“已有记录”还要分两种情况讨论:

  • “已有记录”是在当前事务启动前就已经由更早执行完成的事务提交写入到表中的
  • “已有记录”是由和当前事务一同活跃的其他事务写入到表中的,这个“已有记录”其实尚未完成提交,这种场景下就涉及到了INSERT操作中隐藏的隐式锁

因此综上所述,在事务执行INSERT的时候,有三个因素会影响当前执行INSERT语句的事务的加锁方式:

  • 间隙锁
  • 索引值冲突
  • 其他的活跃事务是否在执行相同的INSERT语句

进一步的,我们根据这三个因素可以抽象出如下四个场景:

  • 场景0: 插入区间未被间隙锁覆盖,待插入记录与已有记录中的主键索引或唯一索引值不冲突,其他正在运行的事务中(未提交事务)没有执行相同的插入语句
  • 场景1: 插入区间已经被间隙锁覆盖,待插入记录与已有记录中的主键索引或唯一索引值不冲突,其他正在运行的事务中(未提交事务)没有执行相同的插入语句
  • 场景2: 待插入记录与已有记录(插入记录已经由事务提交并写入表中)中的主键索引或唯一索引值有冲突,其他正在运行的事务(未提交事务)中没有执行相同的插入语句
  • 场景3: 待插入记录与已有记录(插入记录尚未被事务提交)中的主键索引或唯一索引值有冲突,这个已有记录是由其他正在运行的事务(未提交事务)插入

场景0是最简单也最为常见的场景,互联网应用服务大部分以读多写少为主,比如新建一条用户信息,一次请求从事务执行插入再到提交丝滑完成。
我们需要着重分析的是另外三种场景下的加锁规则。

INSERT插入场景分析

开始实验前,我们先构建好测试数据。

CREATE TABLE test (
    id INT AUTO_INCREMENT,
    uid VARCHAR(100),
  username VARCHAR(100),
    PRIMARY KEY (id),
    UNIQUE KEY uk_uid (uid)
) Engine=InnoDB CHARSET=utf8;


INSERT INTO test VALUES
    (1, 'aaa', 'usr01'),
    (10, 'fff', 'usr02'),
    (20, 'lll', 'usr03'),
    (30, 'ooo', 'usr04'),
    (40, 'ttt', 'usr05'),
    (50, 'uuu', 'usr06');

场景1

事务A(事务ID:1841)

for update当前读命令结尾运行一条查询语句,该语句会为符合查询条件的记录添加X型next-key锁
企业微信截图_fcaad374-fc93-4e2f-97e0-3b8dab36be86.png

通过查看performance_schema.data_locks表,我们可以看到事务A对唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'fff'的这条记录加上了X型next-key锁,锁状态为GRANTED
企业微信截图_8a5cc461-270f-417c-9952-9d48f1a3088f.png

事务B(事务ID:1842)

开启事务B,并在uid值为['aaa'-'fff']这两条记录中间,插入一条uid值为'ccc'的新记录,该记录在uk_uid唯一索引结构中的位置就是位于['aaa'-'fff']两条记录中间

企业微信截图_73ae3fb9-9328-4443-883f-411045a4efe9.png
从插入结果可以看到,事务B的插入操作因为锁等待超时,而插入失败。

通过查看performance_schema.data_locks表,我们看到事务B为了给唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'fff'的记录加上意向插入锁而进入阻塞等待,锁状态是WAITING

造成事务B锁阻塞等待的原因是,事务A已经给唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'fff'的记录上加上了X型next-key锁
企业微信截图_5b5eb864-6348-4cb6-bb1f-d6e1da0923ec.png
结论:当执行待插入记录的事务发现待插入记录的下一条记录(在本例中就是uid值为'fff')有间隙锁后,就会试图获取意向插入锁意向插入锁可以用来防止幻读问题,因为不同的两个事务不能
一个拥有一个区间的间隙锁但另一个拥有相同区间的插入意向锁

场景2

事务A(事务ID:1844)

回滚 场景1 中的两个事务。根据 场景2 的描述,我们向表中插入一条的新记录,其uid字段的值为'fff'。这样一来就会和表中已存在的uid字段值为'fff'记录造成唯一索引冲突。事务A报出错误Duplicate entry 'fff' for key 'test.uk_uid'

此时先不急着 提交/回滚 事务A,我们看下事务A的加锁情况。
企业微信截图_98408bc4-5176-4a59-ac31-762f6a7bec65.png
通过查看performance_schema.data_locks表,我们看到事务A已经给唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'fff'的记录上加上了S型next-key锁
企业微信截图_8c834fc0-676c-45da-ad76-24d9f9588cfa.png
或许在这里大家都有个疑问:既然已经因为唯一索引冲突而插入失败了,为什么事务A在尚未提交/回滚之前,还是要持有'fff'记录上的S型next-key锁,这样做的意义是什么?

我们知道,Mysql遵循事务的ACID原则,这里A是指原子性。即一个事务要么成功,要么失败
如果我们暂时忽视这些原则,带入到下面的假设中:

  • 事务A从抛出 Duplicate entry 'fff' for key 'test.uk_uid' 到 提交/回滚 的这段时间内不为冲突记录加上锁
  • 此时恰好有其他的活跃事务执行DELETE语句删除了uid值为'fff'的这条记录并执行提交
  • 事务A在这条记录被执行删除后再去查询uid值为'fff'的记录就会发现,明明在 步骤1 中因为表中有索引值重复的记录存在而导致了插入失败,但现在查询却并没有查到那条记录,这明显不符合事务的原子性,同样也不符合事务的一致性。
  • 结论:在INSERT插入时遇到主键索引或唯一二级索引值冲突,必须要为冲突记录上锁,防止其他事务无意中“篡改”冲突记录,造成当前事务执行的异常。

    场景3

    回滚 场景2 中的事务,我们通过模拟 场景3,看下加锁情况。场景2 中的“已有记录”是在当前事务执行插入前已经由其他事务插入并完成了提交。而 场景3 更加复杂,”已有记录“是由其他活跃事务插入的,但并未提交。

    事务A(事务ID:1857)

    首先插入一条uid值为'ccc'的新记录
    企业微信截图_3e2e7a82-f8a3-437c-b9e2-8c69fa2d1b3b.png
    通过查看performance_schema.data_locks表,我们看到事务A只获取了表级别的X型意向锁(表级别的意向锁,与行级锁不冲突。表级意向锁不在本篇讨论范围,请大家自行查阅相关资料),事务A并不持有任何行级锁。
    企业微信截图_2178163a-44a0-4df3-9733-cc16dc9de5a6.png
    此时我们暂不提交事务A,开始执行事务B

    事务B(事务ID:1862)

    事务B同样插入一条uid值为'ccc'的新记录,这条记录中的唯一二级索引键uk_uid对应的键值和事务A中已经插入但尚未提交的那条记录uk_uid对应的键值产生了冲突。

    但此刻抛出的错误,就不再是类似场景2中的Duplicate entry xxxxx for key 'test.uk_uid' 错误。而是事务B为了获取锁,而产生了锁等待。
    企业微信截图_f073bd02-1c89-4db4-b0b5-ecc4a13f8cdf.png

    通过查看performance_schema.data_locks表,我们看到,事务B正在等待获取唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'ccc'的记录的S型next-key锁

    而造成事务B锁阻塞等待的原因是,事务A已经给唯一索引(INDEX_NAME字段显示为uk_uid)上uid值为'ccc'的记录上加上了X型记录锁
    企业微信截图_20fd3fcb-0a3a-4749-a346-d0a1e2220770.png
    在事务B没有插入记录之前,事务A中没有获取任何行级锁,为什么事务B在企图插入一个唯一索引键值冲突的记录后,事务A突然拥有了X型记录锁

    首先,在执行INSERT语句时,都会有聚簇索引自带的trx_id隐藏列来作为隐式锁来保护记录,这个隐式锁的保护机制在执行插入的事务A进行 提交/回滚 前都生效(这也是为什么在 场景2 中并没有触发这个隐式锁的原因,因为 场景2 中的已存在记录是提已经由别的事务提交并写入表中的) 。所以大部分情况下INSERT语句并不会生成锁,这里所说的大部分情况,符合前文所述 场景0 中的场景。

    但是当事务B在事务A还没 提交/回滚 前就企图插入索引冲突的语句,此时事务A的这个隐式锁就生效了,随即生成了一个X型记录锁来保护这条尚未提交的记录,以保证事务A自己的ACID特性。而对于事务B,根据INSERT语句的加锁规则,在发现了已存在记录和待插入记录的唯一二级索引冲突后,会试图获取记录上的S型next-key锁,进而造成了事务B的阻塞。

    结论:无论是事务A由隐式锁生效而来的X型记录锁,还是事务B尝试获取的S型next-key锁。其目的都是保证各自事务的ACID特性。在此场景中,如果事务A没有锁保护机制,那么也会可能出现与 场景2 假设中相似的那类异常。

    结语

    行文至此,自己也没想到聊一个INSERT语句加锁规则,能耗费这么大的篇幅。上述结论中的观点是根据实验还有自己的理解提出的。欢迎各位掘友补充讨论,提出自己的看法。

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论