草庐IT

MySQL——并发insert on duplicate key update遇见死锁

小波同学 2023-03-28 原文

前言

数据库死锁问题,是一个老生常谈且很常见的问题,网上也有非常多对于各类死锁场景的解析和复现,但凡和死锁有关,无外乎不涉及数据库隔离等级、索引、以及innodb锁等相关原因。

一、问题的背景:

我们的数据库中的发生死锁的表是具有”多列组合构建的唯一索引“(不包含自增的主键),且数据库的隔离等级为Read Committed,另外对于这个表来说是写入远大于读取的,由于业务的原因,经常会出现同一数据反复插入(同一数据指唯一索引值相同的数据,但其他非索引字段可能不同),所以为了简化代码,我们使用insert on duplicate key update来解决这种问题,当mysql检测到唯一键冲突时,仅更新特定(非索引)字段。但是问题就出现在大规模多worker并发插入的时候,会经常出现"Deadlock found when trying to get lock"。

即任务提交到线程池,多线程并发执行insert on duplicate key update,并且为长事务时,抛出下面异常:

java.lang.RuntimeException: org.springframework.dao.CannotAcquireLockException: 
### Error updating database.  Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
### The error may involve com.kfang.service.price.dict.dao.automapper.DictGardenAppraisalMapper.insertDictGardenAppraisal-Inline
### The error occurred while setting parameters
### SQL: insert into kfang_price.t_dict_garden_appraisal (             ) values(             ) 
        ON DUPLICATE KEY UPDATE     
        FTRUST_NUM = values(FTRUST_NUM),  
        FTRUST_AVG_PRICE = values(FTRUST_AVG_PRICE),             
        FTRUST_TOTAL_PRICE = values(FTRUST_TOTAL_PRICE),             
        FTRUST_TOTAL_AREA = values(FTRUST_TOTAL_AREA),             
        FTRADE_NUM = values(FTRADE_NUM),             
        FTRADE_AVG_PRICE = values(FTRADE_AVG_PRICE),             
        FTRADE_TOTAL_PRICE = values(FTRADE_TOTAL_PRICE),             
        FTRADE_TOTAL_AREA = values(FTRADE_TOTAL_AREA),             
        FOUTER_REFER_PRICE = values(FOUTER_REFER_PRICE)
### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction

二、图文说明:

一般定位死锁原因第一步就是执行”show engine innodb status“, 查看innodb Standard monitor输出结果,这里面会有数据库最后一次的死锁记录。会记录出现死锁的两个事务,它们分别在等待什么锁,并且手里持有什么锁。mysql在检测到发生死锁的时候,会随机回滚其中的一个事务,从而解开死锁。下面的截图是发生死锁的时候innodb status截图(和业务相关的数据已脱敏,这里均用column_n和value_n表示)

Transaction1:

Transaction2:

现象阐述:

从上方两个截图可以发现,死锁均发生在insert on duplicate key update语句执行的时候,并且每个insert语句均为批量插入多个数据。对于事务一,可以看到事务一在等待某个锁的获取,且这个锁是"lock_mode X locks gap before rec insert intention waiting",直接翻译过来就是插入意向锁在等待排他gap锁的释放,也就是只有排他gap锁释放后插入意向锁才能获取到(关于这些锁的含义见下一节)。对于事务二,同样可以看到相同的一句话。并且两个事务的锁冲突均发生在”唯一索引“上。再进一步观察可以看到,事务二所持有("Holds the Locks"下方展示的索引值)的排它锁所在的索引(锁均是加在索引上或者索引区间上的),与事务一等待获取锁的索引是一样的。进一步展示了的确,在同一个索引上出现了一个等待获取,一个已经获取的冲突现象。

三、相关概念:

在分析问题前,有必要概述一下(详细了解可以见附录),这里面涉及到的锁相关知识。具体可以详见Mysql手册

innodb级锁按照隔离能力,主要分为共享锁(S锁)和排他锁(X锁)。事务T1的某行上持有S锁,则另一事务T2可以在此行获取S锁,但是不能获取此行的X锁,而如果T1在某行上持有X锁,则另一事务T2,对此行既无法获取S锁,也无法获取X锁。(除了S和X锁外,还有表级锁,分别是意向共享IS锁和意向排他IX锁,这里不做深入)。

按照锁的种类:主要有四种。

  • 1、Record锁:这种锁会在索引上加锁,比如sql为select column_1 from table where column_1=1 for update,且column_1上有索引,则会把colunm_1为1的行都加排它锁,其他事务禁止对此行读和写。

  • 2、Gap锁(间隙锁):这种锁作用在索引记录之间。目的只需要记住:他是为防止其他事务插入间隙(包括防止insert方式插入新数据到间隙,以及update方式将其他行变更到此间隙)。Gap锁可以有效的防止”幻读“(因为这些间隙都被上了锁,其他事务不可能再插入数据到这些间隙中,于是当前事务在连续进行”当前读“时,每次读到的都是相同的记录)。虽然Gap锁只作用在隔离级别为RR及以上的数据库上,但是不意味着隔离等级为RC级别的不会使用,在RC级别,在进行外键约束检测和唯一键约束检测的时候,会使用到Gap锁,而正是这个duplicate-key checking导致了上文出现的死锁发生。关于Gap锁到底是如何加锁的,可以参阅这篇文章

  • 3、Next-Key锁:本质上就是Gap锁和Record锁的结合,锁住索引外还要锁住索引的间隙。再具体一些就是,一个record锁,加上,位于此索引记录前的第一个间隙处的间隙锁。举个简单的例子就是,如果现在有一个索引包含三个值1,3,5,则next-key lock锁,可能锁住的范围就有(-∞,1],(1,3],(3,5],(5,+∞]。同样在next-key lock一般作用在RR隔离等级的数据库,但是当出现在insert时候,检测到唯一键冲突的时候,会在冲突所在唯一索引出和之前的间隙处加Next-key lock.

mysq官方手册中,对Next-key lock在innodb monitor中的打印如下图所示:

可以发现和我们在”问题的现象“一节贴的日志中事务二”Hold the Locks“处非常相似。所以可以怀疑当时死锁发生的时候,出现了排他的next-key lock。

  • 4、Insert Intention锁(插入意向锁):顾名思义,这个锁是在数据插入之前会加此锁。它是一种轻量的Gap锁,同事也是意向排他锁的一种。它的存在使得多个事务在写入不同数据到统一索引间隙的时候,不会发生锁等待。另外由于它是一种意向插入锁,所以当排他锁已经处于间隙上的时候,根据锁的兼容矩阵,可以知道,意向插入锁必须等待此间隙上的排它锁释放,才能获取。

根据上面,对锁的种类说明,其实我们已经能猜到,大概是什么锁导致了死锁的出现。(这里我要再明确一点,我们的数据库隔离等级为Read Committed级别。)本质上就是两个事务同时获取到了不同间隙的X Next-key锁,而这个两个事务又同时想要向对方已经获取了next-key锁的间隙内插入新的数据,于是乎死锁出现了。下面我们来完全复现一下。

四、问题的复现:

数据库准备:数据库中能够包含一个unique key: code

CREATE TABLE `test2` (
  `id` int(11) NOT NULL AUTO_INCREMENT, 
  `code` int(11) NOT NULL,
  `other` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB 

初始数据:insert into test2 (code, other) values(1,1),(3,3),(5,5)

复现场景:原始的code字段为1,3, 5,现在要在中间插入code为2,3,4,5的row, 如果碰到唯一键约束则更新other字段。

死锁出现后,我们查看innodb status中的死锁记录,如下:


可以发现,复现出来的结果,和上文中的案例几乎完全一致。下面我们对此结果进行分析。

五、问题的分析:

  • 1、在T1时刻Session1执行完insert操作后,由于插入的code=2已经存在于表中,发生了唯一键冲突,所以触发了duplicate-checking,导致在(1,3]这个区间加上了next-key lock。这里,我为了进一步证明确实只有(1,3]这个区间加了锁。在T1时刻执行完后,验证插入code=0/4/6的数据可以在Session2中执行成功。同时这个时候Session2中可以修改code=1的数据,如update test2 set other=0 where code=1可以执行成功(当然你不能update test2 set code=2 where code=1,因为这个操作是在向(1,3]的间隙内插入了数据,违反了gap锁的要求)。同时我们可以证明这时code=3肯定是被排他锁锁住的,由于当出现唯一键冲突时,就会执行on duplicate key update,更新other字段,所以code=3一定在更新结束后处于排它锁锁定状态(补充说明:可以证明如果是共享锁的话,session2在T2时刻执行insert into test2(code, other) values (3, 33)语句的话,一定会立刻包duplicate error而不会阻塞。但是事实上如果Session2在T2时刻执行这句sql,会一直阻塞,进一步说明code=3加的是排它锁。另外需要注意的是,其实我目前只能非常确定code = 3是有排它锁,但是(1,3)上面,到底是S gap lock 还是X gap lock无法确定,不过无论是S还是X,不影响后续的解释。)

  • 2、在T2 完成时,同理也会在(3,5]这个区间上X next-key lock (在上面的截图中也可以看到插入code=5后,正在插入code=2的时候,写着HOLD the lock hex 80000005)

  • 3、当T1和T2执行完成之后,我们可以看到(1,3] 和(3,5]分别被Session1和Session2锁定,T3时候,Session1尝试插入code=4, 由于在插入前会加插入意向锁,(对于插入意向锁的锁的范围,我目前尚无法确认在3~5的区间内加锁的时候,左右临界的开合问题)但是很明显,插入意向锁一定和(3,5]区间的next-key lock有重合,所以会出现在Session1执行T3的时候,语句被阻塞了,它在等待Session2释放(3,5]这个区间的X** next_key** lock 。可以参考下图——一个非常详细的锁兼容矩阵,理解阻塞原因(兼容矩阵图链接)。

  • 4、同理,在T4时刻Session2执行插入语句的时候,由于(1,3]被阻塞了,但是插入的时候又要请求1~3这个区间的插入意向锁,等待Session1释放X next-key lock。于是乎死锁发生,Session2被回滚。

至此:死锁的现象可以顺利的解释通。(当然,这里还有一个疑惑不是很明白,当出现唯一冲突的时候为什么要加Next-Key Lock。)

六、问题的拓展:

如果将insert on duplicate key update换成insert ignore语句,是否可以避免死锁的发生呢?答案是:否定的。其实原理都是一样的。如果我们将上述复现中的insert on duplicate key update换成insert ignore,同样会在T4时刻出现死锁。

同样,update和insert on duplicate key update组合也可以构造出死锁的出现。数据库中表结构不变,数据初始化为(1,1,1),(3,3,3),(5,5,5) 分别对应id, code,other, id是pk.

七、总结:

说了这么多,死锁的原因找到了,解决的办法其实比较简单。

  • 1、将批量insert on duplicate key update,拆分成多个语句。保证一次事务中不要插入过多值,将多个数据,变成多个sql,执行插入。可以有效的减少死锁命中的发生。
  • 2、重试:死锁不可怕,当出现死锁发生时,多执行重试操作可以有效保证插入成功,更新不丢失。
  • 3、线程池多线程并发执行改为单线程排队处理。

参考:
https://developer.aliyun.com/article/727076

https://blog.csdn.net/m0_43452671/article/details/111590466

有关MySQL——并发insert on duplicate key update遇见死锁的更多相关文章

  1. 使用canal同步MySQL数据到ES - 2

    文章目录一、概述简介原理模块二、配置Mysql使用版本环境要求1.操作系统2.mysql要求三、配置canal-server离线下载在线下载上传解压修改配置单机配置集群配置分库分表配置1.修改全局配置2.实例配置垂直分库水平分库3.修改group-instance.xml4.启动监听四、配置canal-adapter1修改启动配置2配置映射文件3启动ES数据同步查询所有订阅同步数据同步开关启动4.验证五、配置canal-admin一、概述简介canal是Alibaba旗下的一款开源项目,Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。Git地址:https://github.co

  2. ruby-on-rails - 无法安装 mysql2 0.3.14 gem - 2

    我看到其他人也遇到过类似的问题,但没有一个解决方案对我有用。0.3.14gem与其他gem文件一起存在。我已经完全按照此处指示完成了所有操作:https://github.com/brianmario/mysql2.我仍然得到以下信息。我不知道为什么安装程序指示它找不到include目录,因为我已经检查过它存在。thread.h文件存在,但不在ruby​​目录中。相反,它在这里:C:\RailsInstaller\DevKit\lib\perl5\5.8\msys\CORE\我正在运行Windows7并尝试在Aptana3中构建我的Rails项目。我的Ruby是1.9.3。$gemin

  3. ruby-on-rails - 获取并发布相同匹配项的请求 - 2

    在我的路线文件中我有:match'graphs/(:id(/:action))'=>'graphs#(:action)'如果是GET请求(工作)或POST请求(不工作),我想匹配它我知道我可以使用以下方法在资源中声明POST请求:post'/'=>:show,:on=>:member但是我怎样才能为比赛做到这一点呢?谢谢。 最佳答案 如果你同时想要POST和GETmatch'graphs/(:id(/:action))'=>'graphs#(:action)',:via=>[:get,:post]编辑默认值可以设置如下match'g

  4. ruby - 如何使用 ruby​​ mysql2 执行事务 - 2

    我已经开始使用mysql2gem。我试图弄清楚一些基本的事情——其中之一是如何明确地执行事务(对于批处理操作,比如多个INSERT/UPDATE查询)。在旧的ruby-mysql中,这是我的方法:client=Mysql.real_connect(...)inserts=["INSERTINTO...","UPDATE..WHEREid=..",#etc]client.autocommit(false)inserts.eachdo|ins|beginclient.query(ins)rescue#handleerrorsorabortentirelyendendclient.commi

  5. ruby-on-rails - 当我通过 rvm 使用 rails3 时,如何在 ubuntu 上安装 mysql2 gem? - 2

    我正在尝试绕过rails配置这个极其复杂的迷宫。到目前为止,我设法在ubuntu上设置了rvm(出于某种原因,ruby在ubuntu存储库中已经过时了)。我设法建立了一个Rails项目。我希望我的测试项目使用mysql而不是mysqlite。当我尝试“rakedb:migrate”时,出现错误:“!!!缺少mysql2gem。将其添加到您的Gemfile:gem'mysql2'”当我尝试“geminstallmysql”时,出现错误,告诉我需要为安装命令提供参数。但是,参数列表很大,我不知道该选择哪些。如何通过在ubuntu上运行的rvm和mysql获取rails3?谢谢。

  6. ruby - 使用 SizedQueue 在 ruby​​ 代码中出现死锁 - 2

    我认为我对线程在ruby​​中的工作原理存在根本性的误解,我希望获得一些见解。我想要一个简单的生产者和消费者。首先,生产者线程从文件中提取行并将它们粘贴到SizedQueue中;当那些用完时,在末端贴上一些token,让消费者知道事情已经完成。require'thread'numthreads=2filename='edition-2009-09-11.txt'bq=SizedQueue.new(4)producerthread=Thread.new(bq)do|queue|File.open(filename)do|f|f.eachdo|r|queue现在有几个消费者。为简单起见,让

  7. Ruby 并发/异步处理(简单用例) - 2

    我一直在研究ruby​​的并行/异步处理能力,并阅读了许多文章和博客文章。我查看了EventMachine、Fibers、Revactor、Reia等。不幸的是,我无法为这个非常简单的用例找到简单、有效(且非IO阻塞)的解决方案:File.open('somelogfile.txt')do|file|whileline=file.gets#(R)ReadfromIOline=process_line(line)#(P)Processthelinewrite_to_db(line)#(W)WritetheoutputtosomeIO(DBorfile)endend你看到了吗,我的小脚本正

  8. ruby-on-rails - 乘客并发连接错误 - 2

    我有生产服务器(Nginx+Passenger)。当我尝试从另一台计算机ab-n3-c3myhost.ru/时,我在我的nginxerror.log中收到此错误日志:[pid=21160thr=139775297914624file=ext/nginx/HelperAgent.cpp:584time=2011-08-3115:25:49.22]:UncaughtexceptioninPassengerServerclientthread:exception:Cannotreadresponsefrombackendprocess:Connectionresetbypeer(104)ba

  9. Centos7-yum安装mysql-修改密码-无密码登录-安全配置 - 2

    目录1、yum安装mysql修改密码(1)在mysql里面修改(2)第二种方式,利用mysqladmin修改密码2、没有密码,登录mysql修改密码3、mysql的安全设置1、yum安装mysql在CentOS中默认安装有MariaDB(MySQL的一个分支),安装完成之后可以直接覆盖MariaDB。rpm-qa|grepmariadb查询是否安装了mariadbrpm-e--nodepsmariadb-libs-5.5.60-1.el7_5.x86_64卸载mariadwgethttp://dev.mysql.com/get/mysql57-community-release-el7-11.

  10. 设计一个亿级高并发系统架构 - 12306火车票核心场景DDD领域建模 - 2

    “架设一个亿级高并发系统,是多数程序员、架构师的工作目标。许多的技术从业人员甚至有时会降薪去寻找这样的机会。但并不是所有人都有机会主导,甚至参与这样一个系统。今天我们用12306火车票购票这样一个业务场景来做DDD领域建模。”开篇要实现软件设计、软件开发在一个统一的思想、统一的节奏下进行,就应该有一个轻量级的框架对开发过程与代码编写做一定的约束。虽然DDD是一个软件开发的方法,而不是具体的技术或框架,但拥有一个轻量级的框架仍然是必要的,为了开发一个支持DDD的框架,首先需要理解DDD的基本概念和核心的组件。一.什么是领域驱动设计(DDD)首先要知道DDD是一种开发理念,核心是维护一个反应领域概

随机推荐