mysql使用了index merge(索引合并)造成死锁分析总结

本文介绍了MySQL中由于索引合并(index_merge)引发的死锁问题,分析了死锁产生的原因及执行计划。在特定情况下,MySQL优化器选择了使用uk_accept_id和idx_status两个索引的交集,导致了死锁。解决方案包括调整代码逻辑,强制使用特定索引或关闭index_merge优化。同时,提出了避免死锁的方法,如减少长事务,设置锁超时等。
摘要由CSDN通过智能技术生成

背景

  生产环境出现数据库更新异常,查询日志发现是数据库死锁,异常信息如下:

nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction

于是在本地建了一张test_table表来复现和分析死锁问题。

表结构

CREATE TABLE `test_table` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `request_no` varchar(32) NOT NULL COMMENT '请求流水',
  `status` int(2) NOT NULL COMMENT '状态',
  `request_system` varchar(128) NOT NULL COMMENT '请求系统',
  `request_date` datetime NOT NULL COMMENT '请求时间',
  `accept_id` varchar(32) DEFAULT NULL COMMENT '受理编号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_accept_id` (`accept_id`) USING BTREE,
  KEY `idx_request` (`request_no`,`request_system`,`request_date`) USING BTREE,
  KEY `idx_status` (`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

通过表结构可以看出,id列为主键索引,accept_id 列上有一个唯一索引 uk_accept_id,status 列上有一个普通索引idx_statusidx_request是(request_no,request_system,request_date)联合索引。我们知道, InnoDB引擎中有两种索引:

聚簇索引: 将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据。
辅助索引: (非聚簇索引) 辅助索引叶子节点存储的是主键值,也就是聚簇索引的键值。

主键索引 PRIMARY 就是聚簇索引,叶子节点中会保存行数据。uk_accept_ididx_statusidx_request为辅助索引,叶子节点中保存的是主键值,也就是id列值。当我们通过辅助索引查找行数据时,先通过辅助索引找到主键id,再通过主键索引进行二次查找(也叫回表),最终找到行数据。

执行计划

在这里插入图片描述
通过查看执行计划发现,update语句用到了索引合并,也就是这条语句既用到了 uk_accept_id 索引,又用到了idx_status索引,Using intersect() 的意思是通过两个索引获取交集。

为什么会用到 index_merge(索引合并)?

1、MySQL5.0之前,一个表一次只能使用一个索引,无法同时使用多个索引分别进行条件扫描。但是从5.1开始,引入了 index merge 优化技术,对同一个表可以使用多个索引(一般是2个)分别进行条件扫描。简单的说,index merge 技术其实就是:对多个索引分别进行条件扫描,然后将它们各自的结果进行合并,合并方式分为三种:union, intersection, 以及它们的组合sort_union(先内部intersect然后在外部union);
2、在Oracle中intersect是关键字,mysql中没有intersect语法,但是explain使用到了索引交集的算法,估计是mysql优化器的一些优化手段,这个可以通过一个开关进行控制。

  • 查看数据库是否开启了index_merge:show variables like ‘%optimizer_switch%’;
  • 关闭index_merge(重启才能生效):set @@global.optimizer_switch = ‘index_merge=off’ ;
  • 开启index_merge(重启才能生效):set @@global.optimizer_switch = ‘index_merge=on’ ;

例如执行计划中的sql: UPDATE test_table SET status = 0 WHERE status = 1 AND accept_id = '1492091112255' ;
MySQL会根据 accept_id = '1492091112255' 这个条件,利用 uk_accept_id 索引找到叶子节点中保存的id值;同时会根据 status = 1 这个条件,利用 idx_status 索引找到叶子节点中保存的id值;然后将找到的两组id值取交集,最终通过交集后的id回表,也就是通过 PRIMARY 索引找到叶子节点中保存的行数据。
  这时可能很多人会有疑问了, uk_accept_id 已经是唯一索引了,通过这个索引最多只能找到一条数据,那MySQL优化器为啥还要用两个索引取交集,再回表进行查询呢,这样不是多了一次 idx_status 索引查找的过程么?接下来我们一起分析一下以下两种情况执行过程:
第一种 只用uk_accept_id索引 :

  1. 根据 accept_id = ‘1492091112255’ 查询条件,利用uk_accept_id索引找到叶子节点中保存的id值;
  2. 通过找到的id值,利用PRIMARY索引找到叶子节点中保存的行数据;
  3. 再通过 status = 1 条件对找到的行数据进行过滤。

第二种 用到索引合并 Using intersect(uk_accept_id,idx_status) :

  1. 根据 accept_id = ‘1492091112255’ 查询条件,利用uk_accept_id索引找到叶子节点中保存的id值;
  2. 根据 status = 1 查询条件,利用 idx_status 索引找到叶子节点中保存的id值;
  3. 将1、2步中找到的id值取交集,然后利用PRIMARY索引找到叶子节点中保存的行数据

上边两种情况的主要区别在于,第一种是先通过一个索引把数据找到后,再用其它查询条件进行过滤;第二种是先通过两个索引查出的id值取交集,如果取交集后还存在id值,则再去回表将数据取出来。 当优化器认为第二种情况执行成本比第一种要小时,就会出现索引合并。(我的测试表中 status = 1 的数据非常少,这也是优化器考虑用第二种情况的原因之一)。

为什么用 index_merge 就造成了死锁?

在这里插入图片描述
上面简要画了一下两个update事务加锁的过程,从图中可以看到,在 idx_status 索引和 PRIMARY (聚簇索引) 上都存在重合交叉的部分,这样就为死锁造成了条件。如,当遇到以下时序时,就会出现死锁:

事务一(update1)事务二(update2)
锁住 uk_accept_id索引中 accept_id 为111的索引项
锁住 uk_accept_id索引中 accept_id 为222的索引项
回表锁住 PRIMARY索引中 id 为1的索引项
回表锁住 PRIMARY索引中 id 为2的索引项
锁住 idx_status 索引中 status 为1的索引项
试图锁住 idx_status 索引中 status为1的索引项,发现已经被事务一锁住,等待事务一释放锁
试图回表锁住 PRIMARY 索引中 id 为1和2的索引项,发现 id 为2的索引项已经被事务二锁住,等待事务二释放锁

事务一等待事务二释放锁,事务二等待事务一释放锁,这样就造成了死锁。

注意: 如果update语句操作了非主键索引,mysql会先锁住非主键索引,再锁定主键索引,当有多个事务进行更新操作时就很容易造成死锁。MySQL检测到死锁后,会自动回滚代价更低的那个事务。

解决方案

  • 一、从代码层面
  1. where 查询条件中,只使用uk_accept_id ,将数据查询出来后,在代码层面判断 status 状态是否为1;
  2. 使用 force index(uk_accept_id) 强制查询语句使用uk_accept_id 索引;
  3. where 查询条件后边直接用 id 字段,通过主键去更新(推荐)。
  • 二、从MySQL层面
  1. 删除 idx_status 索引或者建一个包含这俩列的联合索引;
  2. 将MySQL优化器的index merge优化关闭。

注:

1、 一般情况下,数据量越大 index merge开启的优化效果会越明显,所以没有特别要求不用关闭,默认是开启。
2、MySQL官方已经确认了此bug:传送门:Bug #77209


知识拓展:MySQL 锁

mysql的锁级别

  • MySQL有三种锁的级别:页级锁、表级锁、行级锁
  • MyISAM和MEMORY存储引擎采用的是表级锁(table-level locking);BDB存储引擎采用的是页面锁(page-level locking),但也支持表级锁;InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。
  • MySQL这3种锁的特性可大致归纳如下:
    • 页级锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
    • 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
    • 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。

注意: 行级锁并不是直接锁记录,而是锁索引,如果一条SQL语句用到了主键索引,mysql会锁住主键索引;如果一条语句操作了非主键索引,mysql会先锁住非主键索引,再锁定主键索引。

innoDB的锁模式

  • 在InnoDB存储引擎中,锁的类型通常分为共享锁和排他锁。
    • 共享锁(shared lock):也叫读锁(read lock),指多个请求在同一时刻可以同时读取同一个资源,互相不干扰(其他事务可以读,但不能写)。
    • 排他锁(exclusive lock):也叫写锁(write lock),一个写锁会阻塞其他的写锁和读锁,这样能确保一定的时间内,只有一个请求能执行写入操作,防止其他请求读取或写入同一资源(其他事务不能读取,也不能写)。

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁:

  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

锁模式的兼容情况:
如果一个事务请求的锁模式与当前的锁兼容, InnoDB 就将请求的锁授予该事务; 反之, 如果两者不兼容,该事务就要等待锁释放。
在这里插入图片描述

InnoDB加锁方法

  • 意向锁是 InnoDB 自动加的, 不需用户干预。
  • 对于 UPDATE、 DELETE 和 INSERT 语句, InnoDB 会自动给涉及数据集加排他锁(X);
    -对于普通 SELECT 语句,InnoDB 不会加任何锁;
  • 事务可以通过以下语句显式给记录集加共享锁或排他锁(显式锁定):
    • 共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE。 其他 session 仍然可以查询,并也可以对该记录加 share mode 的共享锁。但是如果当前事务需要对该记录进行更新操作,则很有可能造成死锁。
    • 排他锁(X):SELECT * FROM table_name WHERE ... FOR UPDATE。其他 session 可以查询该记录,但是不能对该记录加共享锁或排他锁,而是等待获得锁。
  • InnoDB在事务执行过程中,使用两阶段锁协议(隐式锁定):
    • 随时都可以执行锁定,InnoDB会根据隔离级别在需要的时候自动加锁;
    • 锁只有在执行commit或者rollback的时候才会释放,并且所有的锁都是在同一时刻被释放。

死锁的产生和避免

  • 产生死锁的原因主要有:
    (1)系统资源不足。
    (2)进程运行推进的顺序不合适。
    (3)资源分配不当等。
    如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能产生死锁。

  • 产生死锁的四个必要条件:
    (1) 互斥条件:一个资源每次只能被一个进程使用。
    (2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    (3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
    (4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
    这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

  • 避免死锁的产生:(通过破除死锁四个必要条件之一,来防止死锁产生)
    1、避免使用子查询,尽量使用主键更新数据,防止使用非聚簇索引回表时和使用聚簇索引冲突造成死锁。
    2、避免长(大)事务,将事务拆解为短(小)事务,尽快提交事务,减少持有锁的时间。
    3、设置锁超时等待innodb_lock_wait_timeout。
    4、除非必须,否则查询时不要显示加锁。
    5、为表设计合理的索引,并尽量使用索引访问数据, 使加锁更精确,如果不走索引将会为表的每一行记录加锁,死锁的概率会大大增加。
    6、以固定的顺序访问表和行。比如两个更新数据的事务,事务A 更新的顺序 为1,2;事务B更新的顺序为2,1,这样更容易造成死锁。
    7、对于一些特定的事务,可以使用表锁来提高处理速度或减少死锁的可能。
    8、尽量使用较低的隔离级别;

高并发下如何对数据库进行更新操作

在高并发的情况下,多个请求对同一条数据进行更新操作,如果没有防重机制做拦截,就会导致数据被更新多次,也容易造成数据库死锁,从而影响更新后程序的后续操作。
常用的解决方案有以下几种:
1、在更新前,加锁(分布式系统用分布式锁、也可用数据库锁等),查询需要更新的数据是否存在且未被更新,根据查询结果做更新操作,更新后释放锁;
伪代码如下:

/**
 * 适用情况:对数据需要进行多次更新,或多个中间状态都可以对数据进行操作;
 * 方案优势:保证数据更新的一致性;
 * 方案缺点:系统性能消耗大,锁机制影响系统的效率;
 */
 
// 本示例以分布式系统为例,单机系统下直接使用Lock或synchronized锁即可
// 1.用redis加锁
redis.set(key, value); 
// 2.查询要更新的数据
Object obj = select * from table where 条件;
// 3.判断数据是否存在且已经被更新
if(obj != null && obj未被更新){
	update table set XXX=x2 where 条件; 
}
// 4.释放锁
redis.del(key);

2、 数据库层面拦截,直接在更新数据时带状态更新数据,根据更新后成功与否做后续操作;
伪代码如下:

/**
 * 适用情况:明确知道更新数据前或后的状态,且不会在更新时多次发生状态变化,保证前后一致性;
 * 方案优势:简单,保证数据更新的一致性,且对效率影响较小;
 * 方案缺点:系统性能消耗大,锁机制影响系统的效率;
 */

// 更新数据时带状态更新
int result = update table set 更新的内容 where 原数据的内容  != 更新的内容; 
//  更新成功
if(result == 1){ 
    // 执行更新成功后的操作    
} else { 
    // 执行更新失败后的操作 
}

3、推荐:根据主键做更新操作(有效预防死锁)。

返回篇头

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿钟小哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值