在实践事务开发进程中,MySQL会因为许多原因形成死锁,本文便是根据网易云商的一次实遇死锁经验,叙述了一次因为Index Merge 优化导致的死锁事例,并分享处理本次死锁所运用的办法。死锁的本质原因还是由加锁导致,因而创立索引时要结合实践事务场景剖析。

背景

某一天正午,咱们收到了报警信息,呈现关键词 Deadlock。

nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction 显然呈现了数据库死锁。一开始咱们以为是数据库操作次序不共同引发的,可是经剖析相关事务和 SQL 后并未发现不共同的场景,局面一度陷入僵局,没办法只能寻根究底从MySQL的索引和加锁机制入手了。

先来看下MySQL的加锁机制。

MySQL的加锁机制

MySQL 索引分为主键索引(或聚簇索引)和二级索引(或非主键索引、非聚簇索引,包括各种主键索引外的其他索引)。不同存储引擎关于数据的安排办法略有不同,本文以 InnoDB 为例展开,MySQL版别 5.7。

主键索引和数据是寄存在一起的,构成一棵 B+ 树,主键坐落非叶子节点,数据寄存于叶子节点。示意图如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程
图1:图源网络

二级索引列坐落非叶子节点, 主键值坐落叶子节点,示意图如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

图2:图源网络

以select * from table where name=’ccc’为例,MySQL对 SQL 进行解析后发现 name 字段有索引可用,所以先在二级索引(图2)上依据name=’ccc’找到主键id=27,然后依据主键 27 到主键索引上(图1)上找到需求的记载。这个进程也被称作回表。

接下来言归正传看下MySQL到底是怎么给索引加锁的(本例中阻隔等级为 RC)。详细分以下 3 个场景剖析:

  • 依据主键进行更新

update table set name=’cjy’ where id=27;只需求将主键上id=27的记载加上互斥锁即可(加锁后本事务能够读和写,其他事务读和写会被堵塞)。如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

  • 依据仅有索引进行更新

update table set name=’cjy’ where name=’ccc’,InnoDB 现在仅有索引 name 上找到name=’ccc’的索引项(ccc,27)并加上加上互斥锁,然后依据id=27再到主键索引上找到对应的叶子节点并加上互斥锁。

一共两把锁,一把加在仅有索引上,一把加在主键索引上。这里需求阐明的是加锁是一步步加的,这种分步加锁的机制便是后文死锁的导火线。示意图如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

  • 依据非仅有索引进行更新

update table set name=’cjy’ where name=’ccc’。假如 name 不仅有,和上面仅有索引加锁类似,不同的是会给一切契合条件的索引加锁。如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

这里有四把锁,加锁过程如下:

  • 在非仅有索引(name)上找到(ccc,27)的索引项,加上互斥锁;
  • 依据(ccc,27)找到主键索引的(27,ccc)记载,加互斥锁;
  • 在非仅有索引(name)上找到(ccc,29)的索引项,加上互斥锁;
  • 依据(ccc,29)找到主键索引的(29,ccc)记载,加互斥锁;

从上面过程能够看出,InnoDB 关于每个契合条件的记载是分步逐条加锁的。

再来看下MySQL的事务机制。

MySQL事务机制

事务的四个特性 ACID

原子性(Atomicity) :指事务是一个不可分割的最小工作单位,事务中的操作只要都发生和都不发生两种状况。

共同性(Consistency) :事务有必要使数据库从一个共同状况变换到别的一个共同状况,举个比方,李二给王五转账 50 元,其事务便是让李二账户上减去 50 元,王五账户上加上 50 元;共同性是指其他事务看到的状况是要么李二还没有给王五转账的状况,要么王五现已成功接收到李二的 50 元转账。而关于李二少了 50 元,王五还没加上 50 元这个中间状况是不可见的。

阻隔性(Isolation) :一个事务的履行不能被其他事务干扰,即一个事务内部的操作及运用的数据对并发的其他事务是阻隔的,并发履行的各个事务之间不能互相干扰。

持久性(Durability) :一个事务一旦提交成功,它对数据库中数据的改动将是永久性的,接下来的其他操作或故障不应对其有任何影响。

事务的阻隔等级

MySQL中的四种事务阻隔等级别离如下:

  • read uncommitted(读未提交数据): 答应事务读取未被其他事务提交的改变。(脏读、不可重复读和幻读的问题都会呈现)。
  • read committed(读已提交数据) :只答应事务读取现已被其他事务提交的改变。(能够防止脏读,但不可重复读和幻读的问题依然或许呈现)。
  • repeatable read(可重复读) :保证事务能够多次从一个字段中读取相同的值,在这个事务持续期间,禁止其他事务对这个字段进行更新(update)。(能够防止脏读和不可重复读,但幻读依然存在)。
  • serializable(串行化) :保证事务能够从一个表中读取相同的行,在这个事务持续期间,禁止其他事务对该表履行刺进、更新和删去操作,一切并发问题都可防止,但功能十分低下。

其间 RR 完成可重复读的原理是:MVC 多版别并发操控。其实便是版别号概念。读取数据时大于当时事务版别号的记载不论。

一起 RR 中会运用到空隙锁。 空隙锁是采用在指定记载的前面和后面以及中间的空隙上加空隙锁的办法防止数据被刺进,然后处理幻读的问题。

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

死锁原因剖析

根据上面讲过的知识,下面就来看看本次死锁的详细原因是什么。

本次死锁发生的场景是给一批客户手机号发短信后更新记载时触发的。废话不多说,先上表结构:

(根据数据敏感和方便了解考虑,去除了和本次死锁无关的字段)。

CREATE TABLE `phone_send_record` (  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',  `task_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '发送批次id',  `phone` varchar(256) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '手机号',  `status` smallint(4) NOT NULL DEFAULT '0' COMMENT '发送状况 0未发送 1发送成功 2 发送失败',  PRIMARY KEY (`id`),  KEY `idx_taskId` (`task_id`) USING BTREE,  KEY `idx_phone` (`phone`) USING BTREE) ENGINE = InnoDB  DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '短信发送记载表'

事务场景便是:创立一个短信发送任务(task_id),一个任务里包含了一批手机号,因而数据库短信发送记载表中会有 n 行记载:phone 不同,task_id 相同。更新 SQL 如下:

update phone_send_record set status = #{status} where phone = #{phone} and task_id = #{taskId}

心路历程

咱们事务上做了手机号去重,因而理论上同一个发送任务里是不会存在相同手机号的,一起发生死锁的2行记载显现的也是不同的手机号(见下文 sql)。

按道理讲,innoDB 触发行锁,没有锁表,不同行不应该影响才对,就算锁多行那也是资源竞赛等候不应该触发死锁。咱们带着疑问继续往下看。

接着咱们考虑了空隙锁的或许,可是空隙锁是在 RR 阻隔等级下才存在,查了下咱们数据库的阻隔等级是 RC,所以空隙锁的或许也被排除了。

接着查看了 SQL 履行计划,发现运用了index_merge。index_merge是 MySQL 5.1 后引入的一项索引合并优化技能,它答应对同一个表一起运用多个索引进行查询,并对多个索引的查询结果进行合并后返回。

回到咱们的 SQL,经过剖析日志找出发生死锁的是如下 2 条:

update phone_send_record set  status = 0 where phone = '13555111111' and task_id = 123;update phone_send_record set  status = 0 where phone = '13555222222' and task_id = 123;

假如没有index_merge,要么走idx_taskId索引,要么走idx_phone索引,不会呈现两个索引一起运用的状况。而在运用index_merge技能后,会一起履行两个索引,别离查到结果后再进行合并。再结合上文对加锁机制的了解,两个索引的一起加锁就或许导致死锁。图析如下:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

上图只是其间一种形成死锁的或许途径,事实上还有其他或许(大家能够自行发挥想象)。

下表是对上图做的扼要解析:

【网易云商】记一次实遇的 MySQL--index merge 死锁历程

事务 1 等候事务 2 开释锁,事务 2 等候事务 1 开释锁,这样就形成了死锁。

处理办法

  1. 既然是index_merge导致的,那理所当然的想便是封闭index_merge。确实这能处理上述的死锁问题,可是一般状况下,数据量越大index_merge敞开的优化效果会越明显,所以没有特别要求不建议封闭(默许是敞开的)。因而此办法不引荐。

  2. 运用主键索引进行更新。先依据二级索引查询出主键 id,再拿主键 id 进行 update。这样更新的范围是仅有的,自然不会触发死锁。

  3. 删去剩余的独立索引,创立联合索引。本栗便是 KEYidx_t_p(task_id,phone)。这个就提示咱们在加索引字段时(尤其是往旧表加)需求仔细剖析下事务场景,来决定是创立独立索引还是联合索引。

小结:办法 2 是从代码层面入手从根本上躲避死锁,可是会添加一定的代码量;办法 3 是巧用联合索引完成 2 个独立索引的功能又不会触发index_merge。引荐办法 2,但办法 3 更方便些,原则上办法 2 和 3 都有用。

总结

本文仅描绘了因为index_merge优化导致的死锁,叙述了死锁发生的原因以及处理办法,并趁便介绍了MySQL加锁机制。事实上死锁的原因还有许多,比方操作数据次序不共同、长事务等,就不逐个展开了。