高并发场景下的MySQL几类死锁事故案例分析
作者:数据库 来源:数据库 浏览: 【大中小】 发布时间:2025-11-05 07:09:38 评论数:
本文主要讲解MySQL中出现死锁的高并故案应用案例,以及相关的发场业务场景,不会纯讲理论,景下希望对这块感兴趣的类死例分朋友可以有所帮助。
什么是锁事死锁
多个线程在访问某些资源的时候,需要等待对方释放彼此所需资源,高并故案而进入了等待互斥的发场状态。
通俗一些来说,景下A线程持有B锁,类死例分然后想要访问A锁,锁事此时B线程持有A锁,高并故案想要访问B锁,发场这种情况下就容易出现死锁。景下
MySQL中锁的类死例分类型有哪些?
下边我们以用户消息表案例来进行说明:
复制CREATE TABLE `t_user_message` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL DEFAULT 0 COMMENT 发信方id, `object_id` int unsigned NOT NULL DEFAULT 0 COMMENT 收信方id, `relation_id` int unsigned NOT NULL DEFAULT 0 COMMENT 关联id, `is_read` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 是否已读(0未读,1已读),锁事 `sid` int unsigned NOT NULL DEFAULT 0 COMMENT 消息条数, `status` tinyint unsigned NOT NULL DEFAULT 1 COMMENT 状态(0无效 1有效), `content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT COMMENT 消息内容, `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 类型(0文本,1语音,2图片,3视频,4表情,5分享链接), `ext_json` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT COMMENT 扩展字段, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) USING BTREE COMMENT 发信方id索引, KEY `idx_object_id` (`object_id`) USING BTREE COMMENT 收信方id索引, KEY `idx_relation_id` (`relation_id`) USING BTREE COMMENT 关联id索引 ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb3 COLLATE=utf8_bin COMMENT=用户消息表;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.按照锁的粒度来区分,可以分为以下两种:
行锁
只会锁住特定的行记录,例如下边这条sql:
复制select * from t_user_message where user_id=1001 for update;1.表锁
会把整个表的数据给锁住,性能较差,例如下边这条sql:
复制select * from t_user_message for update;1.排它锁和共享锁的区别
共享锁
在多个事务里面都可以读取共享锁所锁住的b2b信息网内容。
排它锁
只能在一个事务里对同样的数据进行加锁,假设A事务对某行数据加入了排它锁之后,其他事务就无法再对该行记录加入排它锁。
关于排它锁和共享锁的使用
看到这里,你可能对共享锁和排它锁并不是理解得很彻底,那么先别着急,我们先从实战来加深下你对它的理解。
排它锁
在Innodb存储引擎中,常见的update,insert,delete这些sql都会默认加入上排他锁,而我们的select语句如果没有加入特殊关键字(下边会讲是什么样的特殊关键字) ,是不会加入排他锁的。
如果select语句希望加入排它锁,那么可以尝试以下方式:
使用 for update 关键字
复制select * from t_user_message for update;1.共享锁
在正常的select语句中,是不会有加锁的,例如下边这条sql:
复制select * from t_user_message;1.这条sql在innodb中,默认是不会锁表,WordPress模板也不会锁行记录。如果你希望加上一把共享锁,那么可以尝试以下的这种写法:
使用 lock in share mode 关键字
复制select * from t_user_message lock in share mode;1.lock in share mode 和 for update使用起来有什么区别?
来看看这个案例,我们准备了两个MySQL的会话窗口。
lock in share mode 测试
先来看会话A:会话A中,关闭了自动提交功能,然后执行这个lock in share mode的锁,此时它使用了共享锁锁住了全表的内容。
图片
再来看会话B:会话B中也是相同的,关闭自动提交后,执行lock in share mode的共享锁,发现依然可以正常查询,没有堵塞行为。
图片
这时候我们将会话B的当前事务先提交,然后在会话B中继续执行一条update语句(非事务状态下) ,要知道update是默认带了拍它锁的,此时因为我们的会话A没有commit,所以会话B的这条update操作会进入堵塞的状态,如下图:
图片
只有当会话A的事务执行完毕了,源码库将lock in share mode的锁给释放掉,会话B才会继续执行。
for update测试
下边让我们来看看 for update 加锁的影响,会话A关闭了自动提交,然后执行了一条for update的sql,但是没有commit;此时我们的会话B也开始了同样的步骤,但是却卡住了。
图片
假如事务A一直都不提交的话,那么事务B最终会报出以下异常:
图片
再来看看for update锁住的数据,对于其他会话的写操作有何影响。
如下图所示,我们的会话A依旧没有commit,但是此时会话B中尝试执行一次update操作,由于update默认带了排他锁,这条sql会锁表,所以和会话A中的for update锁出现了冲突,导致会话B一直处于堵塞状态。
图片
小总结
通过上述的几个测试,大家应该也有深刻的体会了,那么我们就来进行下总结,加深下印象。
lock in share mode 锁
for update 锁
多session读
不会堵塞,多个session可以读共同锁住的记录。
会堵塞,只能有一个session读取到锁住的记录,其他session的访问得等待。
多session写
会堵塞,任何写相关的操作都不行
会堵塞,任何写相关的操作都不行
看到这里,你应该对lock in share mode 和 for update 有一定了解了吧,但是这两种锁,光了解理论,其实还是不够的,需要有实战才能让你对它理解更加深刻,来看下边的案例。
lock in share mode使用不当,导致死锁
来看下边的这个业务场景:
假设我们有一个账户表,表结构如下:
复制CREATE TABLE `t_account` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `user_id` int DEFAULT NULL, `coin` int DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3 COLLATE=utf8_bin;1.2.3.4.5.6.然后在业务操作上,我们的账户扣款和增款逻辑上的设计如下:
复制//开启一个事务操作 set autocommit=0; begin; //如果账户存在,才进行update,如果账户不存在,就得先insert select * from t_account where user_id=111 lock in share mode; //这里我们假设账户是存在的,那么就直接选择打款入账 update t_account set coin=coin+100 where user_id=111; //记录到账户流水表中 INSERT INTO `transaction_log` (`id`, `business`, `foreign_key`) VALUES (1, X6F726465722D62697A, X3234303938413535333031324444443044444137363036333744434233373834333643313138423441414332323236454644463430303034); commit;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.这里为了保证将账户流水记录和打款两个操作保证一致性,得加入一个本地事务去修饰。但是这段代码中使用了一个lock in share mode的关键字,这个关键字是为了避免在并发的情况下,对账户记录进行读的过程中,有其他地方对账户的coin值进行写的修改。
之所以可能会有其他地方对coin值进行额外的写操作,主要原因是因为系统业务中的老旧代码存在,重复造轮子,本来A服务中只有一处地方对账户进行修改操作,结果在B服务里,也有一段类似的代码修改,直接操作了数据库表,但是由于不好去调整那个服务的代码,所以暂时只能用 lock in share mode 操作去加锁。相比于for update锁来说,使用lock in share mode加锁,对于读的影响不大,所以早期设计的时候,没有考虑那么多,就直接用了它上线。并且上线之后并发度不高,暂时就没有发现什么问题。
看到这里,你可能感觉似乎这种设计没有什么问题,那么我们来看看下边的这个场景:
随着并发度的增加,我们将修改余额的这个操作,在A服务里面封装成为了一个方法,并且供各个地方进行调用。但是有一天,出现了这么一个业务场景:
在RocketMQ的消费方,会对用户的账户进行打款操作。在这个消费方的代码中,同一个userId的消息会有许多条,而且是同一时刻的大量并发消费,这就意味着,同一时刻会有大量的请求调用这个打款的操作,而且是并发,同一个userId。那么这种情况下,我们的 lock in share mode会发生什么样的情况呢 -- 死锁。
来看下图:
图片
由于我们的线程A持有了锁,线程B也持有了锁,但是它们接下来的update操作,都是得等对方将共享锁释放后才可以继续执行,所以就发生了死锁的场景。
图片
如何解决上述的lock in share mode死锁
那么我们如何却避免上边的场景发生呢,这里我给出以下两种思路。
不用锁,提升事务隔离级别为读已提交 复制//开启一个事务操作 set autocommit=0; //如果账户存在,才进行update,如果账户不存在,就得先insert select * from t_account where user_id=111; //这里我们假设账户是存在的,那么就直接选择打款入账 update t_account set coin=coin+100,version=version+1 where user_id=111; //记录到账户流水表中 INSERT INTO `transaction_log` (`id`, `business`, `foreign_key`) VALUES (1, X6F726465722D62697A, X3234303938413535333031324444443044444137363036333744434233373834333643313138423441414332323236454644463430303034); commit;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18. 去掉使用lock in share mode,使用乐观锁。例如加入一个version字段,那么我们在执行账户扣款的时候,加入version的判断。例如:
复制//开启一个事务操作 set autocommit=0; //如果账户存在,才进行update,如果账户不存在,就得先insert select * from t_account where user_id=111 and version=#{version}; //这里我们假设账户是存在的,那么就直接选择打款入账 update t_account set coin=coin+100,version=version+1 where user_id=111 and version=#{version}; //记录到账户流水表中 INSERT INTO `transaction_log` (`id`, `business`, `foreign_key`) VALUES (1, X6F726465722D62697A, X3234303938413535333031324444443044444137363036333744434233373834333643313138423441414332323236454644463430303034); commit;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.这里要注意,当同时两个会话针对同一行数据执行上述更新操作的时候,可能会导致同一行的记录被锁,所以我们在进行update的时候,可以用一个version字段去管理。但是这种设计,可能会导致一次更新失败,需要进行重试,因此并发量高的情况下,容易对MySQL造成较大的压力。
引入分布式锁直接在业务层引入一把分布式锁,这种思路比较暴力,但是确实有效。其实只要我们的select类型的sql中进行显示加锁,就有可能会有死锁情况发生,所以建议大家使用的时候谨慎。
行锁的几种类型
Record Lock(记录锁):单个行记录上的锁。这个也是我们日常认为的行锁。Gap Lock(间隙锁):间隙锁,锁定一个范围,但不包括记录本身(只不过它的锁粒度比记录锁的锁整行更大一些,他是锁住了某个范围内的多个行,包括根本不存在的数据)。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。该锁只会在隔离级别是RR或者以上的级别内存在。间隙锁的目的是为了让其他事务无法在间隙中新增数据。Next-Key Lock(临键锁):它是记录锁和间隙锁的结合,锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。next-key锁是InnoDB默认的锁,该锁也只会在隔离级别是RR或者以上的级别内存在。行锁的事故案例
消息数据更新设计不当,导致出现Record Lock死锁
这里我们需要先了解下消息记录表的结构;
复制CREATE TABLE `t_user_message` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL DEFAULT 0 COMMENT 发信方id, `object_id` int unsigned NOT NULL DEFAULT 0 COMMENT 收信方id, `relation_id` int unsigned NOT NULL DEFAULT 0 COMMENT 关联id, `is_read` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 是否已读(0未读,1已读), `sid` int unsigned NOT NULL DEFAULT 0 COMMENT 消息条数, `status` tinyint unsigned NOT NULL DEFAULT 1 COMMENT 状态(0未审核 1审核失败 2审核通过), `content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT COMMENT 消息内容, `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 类型(0文本,1语音,2图片,3视频,4表情,5分享链接), `ext_json` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT COMMENT 扩展字段, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) USING BTREE COMMENT 发信方id索引, KEY `idx_object_id` (`object_id`) USING BTREE COMMENT 收信方id索引, KEY `idx_relation_id` (`relation_id`) USING BTREE COMMENT 关联id索引 ) ENGINE=InnoDB AUTO_INCREMENT=100015 DEFAULT CHARSET=utf8mb3 COLLATE=utf8_bin COMMENT=用户消息表;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.假设我们的会话A执行了以下事务操作:
复制START TRANSACTION; //更新用户的消息状态,从未读变为已读 update t_user_message set is_read=1 where user_id=1003 and object_id=1004; //...中间有些别的业务操作 update t_user_message set is_read=1 where user_id=1001 and object_id=1002; commit;1.2.3.4.5.6.7.8.9.10.而此时我们的会话B在执行一个异步的消息是否合法的检测工作,具体操作如下:
复制set autocommit=0; START TRANSACTION; //定时任务更新用户的消息审核状态,从未审核变为审核通过 update t_user_message set status=2 where user_id=1001 and object_id=1002; //...中间有些别的业务操作 update t_user_message set status=2 where user_id=1003 and object_id=1004; commit;1.2.3.4.5.6.7.8.9.10.11.这两个事务如果并发执行,并发度高的情况下,可能会出现死锁情况,死锁产生的步骤如下图所示:
图片
一般遇到这类情况,我们都会推荐在进行更新的时候,尽可能的避免死锁条件发生,例如调整sql的执行顺序。例如变更为如下操作:
图片
另外,调整顺序后,尽量将本地事务的颗粒度控制到最小,减少因为加锁堵塞带来的性能问题。
间隙锁堵塞案例分析
首先我们要将当前会话的事务隔离级别设置为可重复读:
复制set SESSION transaction ISOLATION LEVEL REPEATABLE READ;1.如果你想确认当前的会话的事务隔离级别,那么可以使用以下命令去查询:
复制SELECT @@transaction_isolation; (mysql8.0语法) SELECT @@tx_isolation; (mysql5.7语法)1.2.还是针对我们的消息表t_user_message,在某些高并发场景下,如果使用可重复读的话,尤其是事务场景中,出现死锁的概率会加大。例如下边这个场景:
事务1中,对消息表的可读状态进行修改,修改的是记录表中的前3条数据,由于是可重复读,以及非唯一索引user_id和object_id所以这里会锁住的是(0,100011]这个区间的id记录,也就是说只要我们更新的行是超过了100011 id的都没问题。
图片
但是假设此时有个插入请求,打算往100009之前写入一条记录的话,就会出现间隙锁堵塞的问题,例如下图所示:
图片
产生间隙锁的原因
1.使用了update,delete,selecct... for update相关操作
2.使用了可重复读的隔离级别
3.在执行update/delete/select ... for update操作之后,在对应的间隙中插入了新的数据(注意是insert了新的数据才会有间隙锁问题产生)。
MySQL中的死锁检测
在mysql5.7、mysql5.8等5系版本中
查看死锁代码是
复制select * from information_schema.innodb_locks;1.查看等待锁的代码
复制select * from information_schema.innodb_lock_waits1.但是要注意,在mysql 8.0中查看死锁代码变了,如果继续用5.7的代码会提示报错
复制Unknown table ‘INNODB_LOCKS’ in information_schema1.所以在8.0使用以下代码
查看死锁
复制select * from performance_schema.data_locks;1.查看死锁等待时间
复制select * from performance_schema.data_lock_waits;1.