MySQL并发控制(二):锁

只改一行语句,为什么锁那么多


注1:MySQL后面的版本可能会改变加锁策略, 所以这个规则只限于截止到现在的最新版本, 即5.x系列

注2:因为间隙锁在可重复读隔离级别下才有效, 所以本篇文章接下来的描述, 若没有特殊说明, 默认是可重复读隔离级别。

加锁规则

加锁规则里面, 包含了两个"原则"、 两个"优化"和一个"bug"(牢牢记住):

原则1: 加锁的基本单位是next-key lock。 希望你还记得, next-key lock是前开后闭区间。

原则2: 查找过程中访问到的对象才会加锁。

优化1: 索引上的等值查询, 给唯一索引加锁的时候, next-key lock退化为行锁。

优化2: 索引上的等值查询, 向右遍历时且最后一个值不满足等值条件的时候, next-key lock退化为间隙锁。

一个bug: 唯一索引上的范围查询会访问到不满足条件的第一个值为止。

注:上述说的等值查询是指SQL语句where条件中有等值条件,且该语句会加锁。

示例:此处体现了加锁规则1中左开右闭的本质,那就是,如果是范围查询且范围区间为左开右闭,如(5,10],那么加锁范围就是(5,10]。如果是范围查询且范围区间为左闭,如 [5,10] 或 [5, 10),则加锁区间为(0,5] 和(5,10]。(此处主要是针对原则1的总结,不涉及加锁规则中的优化)

假设有如下表结构:

sql 复制代码
CREATE TABLE `t`(
    `id` int(11) NOT NULL,
    `c` int(11) DEFAULT NULL,
    `d` int(11) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY c (`c`)
) ENGINE=InnoDB;

insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

案例一:等值查询间隙锁

等值条件操作间隙:

由于表t中没有id=7的记录, 所以用我们上面提到的加锁规则判断一下的话:

1)根据原则1, 加锁单位是next-key lock, session A加锁范围就是(5,10];

2)同时根据优化2, 这是一个等值查询(id=7), 而id=10不满足查询条件, next-key lock退化成间隙锁, 因此最终加锁的范围是(5,10)。

所以, session B要往这个间隙里面插入id=8的记录会被锁住, 但是session C修改id=10这行是可以的。

案例二: 非唯一索引等值锁

覆盖索引上的锁:

你是不是有一种"该锁的不锁, 不该锁的乱锁"的感觉? 我们来分析一下吧。这里session A要给索引c上c=5的这一行加上读锁。

1)根据原则1, 加锁单位是next-key lock, 因此会给(0,5]加上next-key lock。

2)要注意c是普通索引, 因此仅访问c=5这一条记录是不能马上停下来的, 需要向右遍历, 查到c=10才放弃。 根据原则2, 访问到的都要加锁, 因此要给(5,10]加next-key lock。

3)但是同时这个符合优化2: 等值判断, 向右遍历, 最后一个值不满足c=5这个等值条件, 因此退化成间隙锁(5,10)。

4)根据原则2 , 只有访问到的对象才会加锁, 这个查询使用覆盖索引, 并不需要访问主键索引, 所以主键索引上没有加任何锁, 这就是为什么session B的update语句可以执行完成。

但session C要插入一个(7,7,7)的记录, 就会被session A的间隙锁(5,10)锁住。

注:在这个例子中, lock in share mode只锁覆盖索引, 但是如果是for update就不一样了。 执行 for update时, 系统会认为你接下来要更新数据, 因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明, 锁是加在索引上的; 同时, 它给我们的指导是, 如果你要用lock in share mode来给行加读锁避免数据被更新的话, 就必须得绕过覆盖索引的优化, 在查询字段中加入索引中不存在的字段。 比如, 将session A的查询语句改成select d from t where c=5 lock in share mode。你可以自己验证一下效果。

案例三: 主键索引范围锁

思考:对于我们这个表t, 下面这两条查询语句, 加锁范围相同吗?

sql 复制代码
select * from t where id=10 for update;
select * from t where id>=10 and id<11 for update;

在逻辑上, 这两条查语句肯定是等价的, 但是它们的加锁规则不太一样。 现在, 我们就让session A执行第二个查询语句, 来看看加锁效果。

下面我们来分析一下session A的加锁规则:

1)开始执行的时候, 要找到第一个id=10的行, 因此本该是next-key lock(5,10]。 根据优化1,主键id上的等值条件, 退化成行锁, 只加了id=10这一行的行锁。

2)范围查找就往后继续找, 找到id=15这一行停下来, 因此需要加next-key lock(10,15]。

所以, session A这时候锁的范围就是主键索引上, 行锁id=10和next-key lock(10,15]。 这样, session B和session C的结果你就能理解了。

注:首次session A定位查找id=10的行的时候, 是当做等值查询来判断的, 而向右扫描到id=15的时候, 用的是范围查询判断。

案例四: 非唯一索引范围锁

接下来, 我们再看两个范围查询加锁的例子:

这次session A用字段c来判断, 加锁规则跟案例三唯一的不同是: 在第一次用c=10定位记录的时候, 索引c上加了(5,10]这个next-key lock后, 由于索引c是非唯一索引, 没有优化规则, 也就是说不会蜕变为行锁, 因此最终sesion A加的锁是, 索引c上的(5,10] 和(10,15] 这两个next-key lock。

所以从结果上来看, sesson B要插入(8,8,8)的这个insert语句时就被堵住了。

这里需要扫描到c=15才停止扫描, 是合理的, 因为InnoDB要扫到c=15, 才知道不需要继续往后找了

案例五: 唯一索引范围锁bug

前面四个案例,主要用到加锁规则中的两个原则和两个优化,下面来看一下加锁规则中的bug案例:

session A是一个范围查询, 按照原则1的话, 应该是索引id上只加(10,15]这个next-key lock, 并且因为id是唯一键, 所以循环判断到id=15这一行就应该停止了。

但是实现上, InnoDB会往前扫描到第一个不满足条件的行为止, 也就是id=20。 而且由于这是个范围扫描, 因此索引id上的(15,20]这个next-key lock也会被锁上。

所以你看到了, session B要更新id=20这一行, 是会被锁住的。 同样地, session C要插入id=16的一行, 也会被锁住。

照理说, 这里锁住id=20这一行的行为, 其实是没有必要的。 因为扫描到id=15, 就可以确定不用往后再找了。 但实现上还是这么做了, 因此我认为这是个bug。(MySQL官方也将其标记为bug,但未修正)

案例六: 非唯一索引上存在"等值"的例子

给表t插入一条新记录:

sql 复制代码
insert into t values(30,10,30);

新插入的这一行c=10, 也就是说现在表里有两个c=10的行。 那么, 这时候索引c上的间隙是什么状态了呢? 你要知道, 由于非唯一索引上包含主键的值, 所以是不可能存在"相同"的两行的。

虽然有两个c=10, 但是它们的主键值id是不同的(分别是10和30) , 因此这两个c=10的记录之间, 也是有间隙的。

这次我们用delete语句来验证。 注意, delete语句加锁的逻辑, 其实跟select ... for update 是类似的, 也就是我在文章开始总结的两个"原则"、 两个"优化"和一个"bug"。

这时, session A在遍历的时候, 先访问第一个c=10的记录。 同样地, 根据原则1, 这里加的是(5,10] 这个next-key lock。

然后, session A向右查找, 直到碰到c=15这一行, 循环才结束。 根据优化2, 这是一个等值查询, 向右查找到了不满足条件的行, 所以会退化成(10,15)的间隙锁。

也就是说, 这个delete语句在索引c上的加锁范围, 就是下图中蓝色区域覆盖的部分。

这个蓝色区域左右两边都是虚线, 表示开区间, 即(5,15)这两行上都没有锁。

案例七: limit 语句加锁

案例六对照案例,场景如下所示:

这个例子里, session A的delete语句加了 limit 2。 你知道表t里c=10的记录其实只有两条, 因此加不加limit 2, 删除的效果都是一样的, 但是加锁的效果却不同。 可以看到, session B的insert语句执行通过了, 跟案例六的结果不同。

这是因为, 案例七里的delete语句明确加了limit 2的限制, 因此在遍历到(c=10, id=30)这一行之后, 满足条件的语句已经有两条, 循环就结束了。

因此, 索引c上的加锁范围就变成了从(5,10] 这个前开后闭区间, 如下图所示:

可以看到, (c=10,id=30) 之后的这个间隙并没有在加锁范围里, 因此insert语句插入c=12是可以执行成功的。

结论:在删除数据的时候尽量加limit。 这样不仅可以控制删除数据的条数, 让操作更安全, 还可以减小加锁的范围。

案例八: 一个死锁的例子

我们再看一个案例, 目的是说明: next-key lock实际上是间隙锁和行锁加起来的结果。示例如下:

现在, 我们按时间顺序来分析一下为什么是这样的结果:

1)session A 启动事务后执行查询语句加lock in share mode, 在索引c上加了next-key lock(5,10] 和间隙锁(10,15)。

2)session B 的update语句也要在索引c上加next-key lock(5,10] , 进入锁等待。

3)然后session A要再插入(8,8,8)这一行, 被session B的间隙锁锁住。 由于出现了死锁, InnoDB让session B回滚。

问:session B的next-key lock不是还没申请成功吗?

答:session B的"加next-key lock(5,10] "操作, 实际上分成了两步, 先是加(5,10)的间隙锁, 加锁成功; 然后加c=10的行锁, 这时候才被锁住的。

也就是说, 我们在分析加锁规则的时候可以用next-key lock来分析。 但是要知道, 具体执行的时候, 是要分成间隙锁和行锁两段来执行的。

注:

  1. 1)上面的所有案例都是在可重复读隔离级别(repeatable-read)下验证的。 同时, 可重复读隔离级别遵守两阶段锁协议, 所有加锁的资源, 都是在事务提交或者回滚的时候才释放的。

  2. 2)其实读提交隔离级别在外键场景下还是有间隙锁, 相对比较复杂, 我们今天先不展开。

  3. 3)在读提交隔离级别下还有一个优化, 即: 语句执行过程中加上的行锁, 在语句执行完成后, 就要把"不满足条件的行"上的行锁直接释放了, 不需要等到事务提交。

小结:思考题

还使用上面的表结构:

sql 复制代码
CREATE TABLE `t`(
    `id` int(11) NOT NULL,
    `c` int(11) DEFAULT NULL,
    `d` int(11) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY c (`c`)
) ENGINE=InnoDB;

insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

假设有如下场景:

使用上述的加锁规则来分析一下,看看session A的select语句加了哪些锁:

1)由于是order byc desc, 第一个要定位的是索引c上"最右边的"c=20的行, 所以会加上间隙锁(20,25)和next-key lock (15,20]。

2)在索引c上向左遍历, 要扫描到c=10才停下来, 所以next-key lock会加到(5,10], 这正是阻塞session B的insert语句的原因。

3)在扫描过程中, c=20、 c=15、 c=10这三行都存在值, 由于是select *, 所以会在主键id上加三个行锁。

因此, session A 的select语句锁的范围就是:

1)索引c上 (5, 25)。

2)主键索引上id=15、 20两个行锁。

注1:锁是加在索引上的。

注2:"有行"才会加行锁。如果查询条件没有命中行,那就加next-key lock;

问:<=到底是间隙锁还是行锁?

答:要跟"执行过程"配合起来分析。 在InnoDB要去找"第一个值"的时候, 是按照等值去找的, 用的是等值判断的规则; 找到第一个值以后, 要在索引内找"下一个值", 对应于我们规则中说的范围查找。

案例九:针对思考题

下面我们使用两个案例对比分析,

场景一:

|-------------------------------------|------------------------------|
| session A | session B |
| begin; | |
| select * from t where c>=15 and c | |
| | insert into t values(6,6,6); |

在InnoDB要去找"第一个值"的时候,是按等值去找的,用的是等值判断规则。所以场景一的加锁逻辑为:

1)因为,session A中的select语句是升序查找,所以对索引c而言是从左往右扫描,那么拿到的第一个值就是15,按照原则1,则加锁范围是(10,15]。(索引c不是唯一索引,此时不会退化为行锁)

2)继续往右扫描,找到20,则加锁范围是(10,15]、(15,20]。不管是唯一索引还是非唯一索引, InnoDB都会继续往前扫描到第一个不满足条件的行为止,即25,因此(20,25]也会被锁上。

3)最终索引c加锁区间为(10,25]。同时主键索引15,20两行也会被加锁(因为在扫描到这两行记录时,需要使用主键回表,这两行的对应的主键id也会被加锁)。

场景二:

|-------------------------------------|----------------------------------------|
| session A | session B |
| begin; | |
| select * from t where c>=15 and c | |
| | insert into t values(6,6,6); (blocked) |

场景二加锁逻辑:

1)因为,session A是按降序查找,所以对索引c而言是从右往左扫描,那么拿到的第一个就是20,按照原则一,加锁范围是(20,25),此时25就相当于升序中的10的位置,所以此处是开区间。

2)继续往左扫描,找到15,则加锁范围是(15,20]、(20,25)。InnoDB继续往左扫描到第一个不满足条件的行为止,即10,因此(10,15]也会被锁上。

3)因为降序查找是向左扫描,所以不满足优化2,所以(10,15]不能退化为间隙锁,即10对应行也要被加锁。因此,为了防止c为10的行被插入,需要继续向左扫描,即(5,10]也需要被锁上。

4)最终索引c加锁区间为(5,25),所以session B中的语句会阻塞等待。

问:针对上述表结构,在下面场景中,分别执行下述两条查询,为什么一个会导致session B阻塞,另一个不会?

场景:

|-------------------------------------|----------------------------------|
| session A | session B |
| begin; | |
| select * from t where c>=15 and c | |
| | update t set d=100 where c = 25; |

SQL语句:

sql 复制代码
-- 在session A中执行该语句时,session B会阻塞
select * from t where c>=15 and c<=20 lock in share mode;

-- 在session A中执行该语句时,session B不会阻塞 
select * from t where c>=15 and c<20 lock in share mode;

执行select * from t where c>=15 and c<=20 lock in share mode语句时,session A加锁范围是(10,15]、(15,20]、(20,25],所以此时session B中的update语句会阻塞。

执行select * from t where c>=15 and c<20 lock in share mode语句时,session A加锁范围是(10,15]、(15,20],因为根据范围c>=15 and c<20查到的记录为c=15(即根据该范围查到的值一定小于20,所以间隙锁为(c,20]),也就是说会在间隙(15,20]上加锁,而不会在(20,25]上加锁,所以session B中的update语句不会阻塞。

用动态的观点看待加锁


不等号条件里的等值查询

等值查询和"遍历"有什么区别? 为什么我们文章的例子里面, where条件是不等号, 这个过程里也有等值查询?

一起来看下这个例子, 分析一下这条查询语句的加锁范围:

sql 复制代码
begin;
select * from t where id>9 and id<12 order by id desc for update;

利用上面的加锁规则, 我们知道这个语句的加锁范围是主键索引上的 (0,5]、 (5,10]和(10, 15)。

问1:id=15这一行, 并没有被加上行锁。 为什么呢?

答:加锁单位是next-key lock, 都是前开后闭区间, 但是这里用到了优化2, 即索引上的等值查询, 向右遍历的时候id=15不满足条件, 所以next-key lock退化为了间隙锁 (10, 15)。

问2:查询语句中where条件是大于号和小于号, 这里的"等值查询"又是从哪里来的呢?

要知道, 加锁动作是发生在语句执行过程中的, 所以你在分析加锁行为的时候, 要从索引上的数据结构开始。 这里, 我再把这个过程拆解一下。

索引id示意图如下:

  • 1)首先这个查询语句的语义是order by id desc, 要拿到满足条件的所有行, 优化器必须先找到"第一个id<12的值"。

  • 2)这个过程是通过索引树的搜索过程得到的, 在引擎内部, 其实是要找到id=12的这个值, 只是最终没找到, 但找到了(10,15)这个间隙。

  • 3)然后向左遍历,找到id=10的行,根据原则2,加锁(5, 10]。

  • 4)继续向左遍历,找到不满足id>9的第一行,即id=5这一行,所以会加一个next-key lock (0,5]。

  • 5)也就是说, 在执行过程中, 通过树搜索的方式定位记录的时候, 用的是"等值查询"的方法。

等值查询过程

与上面这个例子对应的, 下面这个语句的加锁范围是什么?

begin; select id from t where c in(5,20,10) lock in share mode;

这条查询语句里用的是in, 我们先来看这条语句的explain结果。

可以看到, 这条in语句使用了索引c并且rows=3, 说明这三个值都是通过B+树搜索定位的。

1)在查找c=5的时候, 先锁住了(0,5]。 但是因为c不是唯一索引, 为了确认还有没有别的记录c=5,就要向右遍历, 找到c=10才确认没有了, 这个过程满足优化2, 所以加了间隙锁(5,10)。

2)执行c=10这个逻辑的时候, 加锁的范围是(5,10] 和 (10,15)。

3) 执行c=20这个逻辑的时候, 加锁的范围是(15,20] 和 (20,25)。

通过这个分析, 我们可以知道, 这条语句在索引c上加的三个记录锁的顺序是: 先加c=5的记录锁, 再加c=10的记录锁, 最后加c=20的记录锁,最终加锁范围为(0,25)。

针对上述示例,需要注意的是其加锁过程:这些锁是"在执行过程中一个一个加的", 而不是一次性加上去的。

理解了上述加锁过程,再来看一下下面例子中的死锁问题。如果同时有另一个语句,是这么写的:

sql 复制代码
select id from t where c in(5,20,10) order by c desc for update;

那么此时的加锁范围,又是什么呢?

虽然间隙锁是不互锁的,但是这两条语句都会在索引c上的c=5、 10、 20这三行记录上加记录锁。

同时由于语句里面是order by c desc, 这三个记录锁的加锁顺序, 是先锁c=20, 然后c=10, 最后是c=5。

也就是说, 这两条语句要加锁相同的资源, 但是加锁顺序相反。 当这两条语句并发执行的时候,就可能出现死锁。

怎么看死锁

下图是在出现死锁后, 执行show engine innodb status命令得到的部分输出。这个命令会输出很多信息, 有一节LATESTDETECTED DEADLOCK, 就是记录的最后一次死锁信息。

下面来看一下图中的几个关键信息:

  1. 这个结果分成三部分:

    1. TRANSACTION, 是第一个事务的信息。

    2. TRANSACTION, 是第二个事务的信息。

    3. WE ROLL BACK TRANSACTION (1), 是最终的处理结果, 表示回滚了第一个事务。

  2. 第一个事务的信息中:

  3. 第二个事务显示的信息要多一些:

从上面这些信息中, 我们就知道:

1)"lock in share mode"的这条语句, 持有c=5的记录锁, 在等c=10的锁。

2)"for update"这个语句, 持有c=20和c=10的记录锁, 在等c=5的记录锁。

因此导致了死锁。 这里, 我们可以得到两个结论:

1)由于锁是一个个加的, 要避免死锁, 对同一组资源, 要按照尽量相同的顺序访问。

2)在发生死锁的时刻, for update 这条语句占有的资源更多, 回滚成本更大, 所以InnoDB选择了回滚成本更小的lock in share mode语句, 来回滚。

怎么看待等待

看完死锁, 我们再来看一个锁等待的例子。

由于session A并没有锁住c=10这个记录, 所以session B删除id=10这一行是可以的。 但是之后, session B再想insert id=10这一行回去就不行了。

此时show engine innodb status的结果,如下:

下面来看一下图中的几个关键信息:

1)index PRIMARY of table `test`.`t` , 表示这个语句被锁住是因为表t主键上的某个锁。

2)lock_mode X locks gap before rec insert intention waiting 这里有几个信息:

insert intention表示当前线程准备插入一个记录, 这是一个插入意向锁。 为了便于理解, 你可以认为它就是这个插入动作本身。

gap before rec表示这是一个间隙锁, 而不是记录锁。

3)那么这个gap是在哪个记录之前的呢? 接下来的0~4这5行的内容就是这个记录的信息。

4)n_fields 5也表示了, 这一个记录有5列:

0: len 4; hex0000000f; asc ;;第一列是主键id字段, 十六进制f就是id=15。 所以, 这时我们就知道了, 这个间隙就是id=15之前的, 因为id=10已经不存在了, 它表示的就是(5,15)。

1: len 6; hex000000000513; asc ;;第二列是长度为6字节的事务id, 表示最后修改这一行的是trxid为1299的事务。

2: len 7; hexb0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。 可以看到, 这里的acs后面有显示内容(%和4), 这是因为刚好这个字节是可打印字符。

后面两列是c和d的值, 都是15。

因此, 我们就知道了, 由于delete操作把id=10这一行删掉了, 原来的两个间隙(5,10)、 (10,15)变成了一个(5,15)。

也就是说, 所谓"间隙", 其实根本就是由"这个间隙右边的那个记录"定义的。

注:session B中执行delete from t where id = 10;语句时,会首先找到id = 10这一行,在找的过程中,同样满足优化1,即next-key-lock退化为行锁。

update的例子

update语句案例:

session A的加锁范围是索引c上的 (5,10]、 (10,15]、 (15,20]、 (20,25]和(25,supremum]。

重点:根据c>5查到的第一个记录是c=10, 因此不会加(0,5]这个next-key lock。

之后session B的第一个update语句, 要把c=5改成c=1, 你可以理解为两步:

1)插入(c=1, id=5)这个记录。

2)删除(c=5, id=5)这个记录。

索引c上(5,10)间隙是由这个间隙右边的记录, 也就是c=10定义的。 所以通过这个操作, session A的加锁范围变成了图7所示的样子:

接下来session B要执行 update t set c = 5 where c = 1这个语句了, 一样地可以拆成两步:

1)插入(c=5, id=5)这个记录。

2)删除(c=1, id=5)这个记录。

第一步试图在已经加了间隙锁的(1,10)中插入数据, 所以就被堵住了。

小结:思考题

所谓"间隙", 其实根本就是由"这个间隙右边的那个记录"定义的。

那么, 一个空表有间隙吗? 这个间隙是由谁定义的? 你怎么验证这个结论呢?

答:一个空表就只有一个间隙。比如,在空表上执行如下语句,加锁范围就是next-key lock (-∞, supremum]。

sql 复制代码
begin;
select* from t where id>1 for update;

验证场景:

此时show engine innodb status的结果,如下:

insert语句的锁为什么这么多


先说结论:insert......select语句,在可重复读隔离级别下,会给select的表中扫描到的记录和间隙加读锁。

insert......select语句


假设有如下表结构:

sql 复制代码
-- 创建表t
CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `c` (`c`)
)ENGINE=InnoDB;

-- 插入4条数据
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);

-- 创建表t2
create table t2 like t

-- binlog_format=statement时执行
insert into t2(c,d) select c,d from t;

思考:为什么在可重复读隔离级别下,binlog_format=statement时,执行如下语句,需要对表t的所有行和间隙加锁?

sql 复制代码
insert into t2(c,d) select c,d from t;

答:需要考虑日志和数据的一致性。

执行如下序列:

如果session B先执行, 由于这个语句对表t主键索引加了(-∞,1]这个next-key lock, 会在语句执行完成后, 才允许session A的insert语句执行。

如果没有锁的话,可能出现session B的insert语句先执行,但是后写入binlog的情况。

于是,在binlog_format=statement的情况下, binlog里面就记录了这样的语句序列:

sql 复制代码
insert into t values(-1,-1,-1); 
insert into t2(c,d) select c,d from t;

这个语句到了备库执行, 就会把id=-1这一行也写到表t2中, 出现主备不一致。

insert循环写入


执行insert ...select 的时候, 对目标表也不是锁全表, 而是只锁住需要访问的资源。

假设现在有一个需求:要往表t2中插入一行数据, 这一行的c值是表t中c值的最大值加1。

对应SQL语句如下:

insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1);

这个语句的加锁范围, 就是表t索引c上的(3,4]和(4,supremum]这两个next-key lock, 以及主键索引上id=4这一行。

该语句慢查询日志:

通过这个慢查询日志, 我们看到Rows_examined=1, 即执行这条语句的扫描行数为1。

问1:如果把这样一行数据插入到表t中,语句的执行流程是怎样的?扫描行数又是多少?

sql 复制代码
insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1);

该语句慢查询日志:

这时候的Rows_examined的值是5。

该语句执行计划:

从Extra字段可以看到"Using temporary"字样, 表示这个语句用到了临时表。

InnoDB扫描行数:

这个语句执行前后, Innodb_rows_read的值增加了4。 因为默认临时表是使用Memory引擎的, 所以这4行查的都是表t, 也就是说对表t做了全表扫描。

该语句整个执行过程:

创建临时表, 表里有两个字段c和d。

按照索引c扫描表t, 依次取c=4、 3、 2、 1, 然后回表, 读到c和d的值写入临时表。 这时, Rows_examined=4。

由于语义里面有limit 1, 所以只取了临时表的第一行, 再插入到表t中。 这时, Rows_examined的值加1, 变成了5。

也就是说, 这个语句会导致在表t上做全表扫描, 并且会给索引c上的所有间隙都加上共享的next-key lock。 所以, 这个语句执行期间, 其他事务不能在这个表上插入数据。

问2:这个语句的执行为什么需要临时表?

答:该语句是一边遍历数据,一边更新数据,如果读出来的数据直接写回原表,则有可能在遍历过程中,读到刚刚插入的记录。而新插入的记录如果参与计算逻辑,就跟语义不符了。

问3:这个语句的执行为什么走全表扫描?

答:因为实现上这个语句没有在子查询中直接使用limit 1。

可以使用如下方法对其进行优化(优化后可以避免全表扫描):

由于这个语句涉及的数据量很小, 你可以考虑使用内存临时表来做这个优化。 使用内存临时表优化时, 语句序列的写法如下:

sql 复制代码
create temporary table temp_t(c int,d int) engine=memory; 
insert into temp_t (select c+1, d from t force index(c) order by c desc limit 1); 
insert into t select * from temp_t; 
drop table temp_t;

insert唯一键冲突


唯一键冲突序列:

这个例子也是在可重复读(repeatable read) 隔离级别下执行的。 可以看到, session B要执行的insert语句进入了锁等待状态。

也就是说, session A执行的insert语句, 发生唯一键冲突的时候, 并不只是简单地报错返回, 还在冲突的索引上加了锁。 我们前面说过, 一个next-key lock就是由它右边界的值定义的。 这时候, session A持有索引c上的(5,10]共享next-key lock。

注:官方文档有一个描述错误, 认为如果冲突的是主键索引, 就加记录锁, 唯一索引才加next-key lock。 但实际上, 这两类索引冲突加的都是next-key lock。(官方已修正)

问:为什么要加这个next-key lock?

答:防止这一行被别的事务干掉。

经典死锁场景:

死锁逻辑如下:

  1. 在T1时刻, 启动session A, 并执行insert语句, 此时在索引c的c=5上加了记录锁。 注意, 这个索引是唯一索引, 因此退化为记录锁。

  2. 在T2时刻, session B要执行相同的insert语句, 发现了唯一键冲突, 加上读锁; 同样地, session C也在索引c上, c=5这一个记录上, 加了读锁。

流程状态变化:

insert into ... on duplicate key update


在插入数据时,如果出现主键冲突,则直接报错。若把语句改写为如下形式,会给索引c上(5,10]加一个排他的next-key lock(写锁) 。

sql 复制代码
insert into t values(5,4,4) on duplicate key update d=100;

insert into ...on duplicate key update 这个语义的逻辑是, 插入一行数据, 如果碰到唯一键约束, 就执行后面的更新语句。

语句执行效果1:

语句执行效果2:假设现在表t里面只有(1,1,1)和(2,2,2)这两行。

可以看到, 主键id是先判断的, MySQL认为这个语句跟id=2这一行冲突, 所以修改的是id=2的行。

注:执行这条语句的affected rows返回的是2, 很容易造成误解。 实际上, 真正更新的只有一行, 只是在代码实现上, insert和update都认为自己成功了, update计数加了1, insert计数也加了1。

小结:思考题


小结如下:

  1. insert ...select 是很常见的在两个表之间拷贝数据的方法。 在可重复读隔离级别下, 这个语句会给select的表里扫描到的记录和间隙加next-key lock。

  2. 如果insert和select的对象是同一个表, 则有可能会造成循环写入。 这种情况下, 我们需要引入用户临时表来做优化。

  3. insert 语句如果出现唯一键冲突, 会在冲突的唯一值上加共享的next-key lock(S锁)。 因此, 碰到由于唯一键约束导致报错后, 要尽快提交或回滚事务, 避免加锁时间过长。

相关推荐
岁岁种桃花儿15 分钟前
MySQL从入门到精通系列:InnoDB记录存储结构
数据库·mysql
jiunian_cn1 小时前
【Redis】hash数据类型相关指令
数据库·redis·哈希算法
冉冰学姐2 小时前
SSM在线影评网站平台82ap4(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·ssm框架·在线影评平台·影片分类
Exquisite.3 小时前
企业高性能web服务器(4)
运维·服务器·前端·网络·mysql
知识分享小能手3 小时前
SQL Server 2019入门学习教程,从入门到精通,SQL Server 2019数据库的操作(2)
数据库·学习·sqlserver
踩坑小念4 小时前
秒杀场景下如何处理redis扣除状态不一致问题
数据库·redis·分布式·缓存·秒杀
萧曵 丶4 小时前
MySQL 语句书写顺序与执行顺序对比速记表
数据库·mysql
Wiktok5 小时前
MySQL的常用数据类型
数据库·mysql
曹牧5 小时前
Oracle 表闪回(Flashback Table)
数据库·oracle
J_liaty5 小时前
Redis 超详细入门教程:从零基础到实战精通
数据库·redis·缓存