做开发、搞运维的朋友,估计都被MySQL死锁坑过吧?本来好好的业务,一到高峰期就突然卡住,用户投诉铺天盖地,后台日志全是“Deadlock found when trying to get lock”的报错,查来查去发现是死锁搞的鬼。更头疼的是,有时候死锁偶发,测试环境复现不了,线上却频繁爆发,轻则业务卡顿、订单丢失,重则整个服务崩掉,损失真的扛不住。
我见过很多团队,遇到死锁就慌了神,要么盲目重启数据库,要么乱改SQL,结果死锁越改越频繁,甚至把正常业务都搞出问题。其实MySQL死锁不是什么“不治之症”,绝大多数频繁死锁的问题,根源就两个:一是锁顺序混乱,二是事务太长。只要把这两个核心问题解决,再配合一些实用技巧,就能让数据库稳如老狗,再也不用被死锁半夜叫起来加班。
今天就用最口语化的方式,把MySQL死锁的来龙去脉、高频坑点、实操解决方案,一次性讲透。不管你是开发新手,还是资深运维,看完这篇,都能轻松搞定死锁问题,让业务不再被事务阻塞拖后腿。全程不搞晦涩难懂的理论,全是实战干货,结合真实业务场景,看完就能上手操作,适合百度收录,也能直接用到工作里。
一、先搞懂:MySQL死锁到底是什么?别被专业术语吓住
很多人一听到“死锁”,就觉得是很高深的技术问题,其实说白了,死锁就是“互相僵持”,跟两个人在狭窄的巷子里迎面走来,谁都不肯让路,最后两个人都走不了,是一个道理。
放在MySQL里,就是两个或多个事务,互相持有对方需要的锁资源,同时又在等待对方释放锁,形成一个循环等待的死循环,谁都无法继续执行,也无法主动释放自己的锁,最后只能靠MySQL的死锁检测机制,强制终止其中一个事务,才能打破这个僵局。
举个最通俗的例子,大家一看就懂:
假设我们有两个事务,事务A和事务B,还有一张用户余额表(user_balance),里面有两个用户的余额记录,用户1和用户2。
事务A的操作:先给用户1的余额减100元(需要给用户1的记录加排他锁),然后再给用户2的余额加100元(需要给用户2的记录加排他锁)。
事务B的操作:先给用户2的余额减100元(需要给用户2的记录加排他锁),然后再给用户1的余额加100元(需要给用户1的记录加排他锁)。
高峰期的时候,两个事务同时执行:事务A先拿到了用户1的锁,准备拿用户2的锁;而事务B刚好先拿到了用户2的锁,准备拿用户1的锁。这时候,事务A等着事务B释放用户2的锁,事务B等着事务A释放用户1的锁,两个人互相等,谁也不让谁,死锁就产生了。
这里要注意一个误区:很多人以为“并发高就一定会产生死锁”,其实不是这样的。并发高只是死锁的“催化剂”,不是根本原因。哪怕并发不高,只要锁顺序乱了、事务太长,一样会出现死锁;反之,只要锁顺序规范、事务足够短,哪怕并发再高,也能有效避免死锁。
还有一个关键点:MySQL的InnoDB引擎(现在绝大多数业务都用这个引擎),有自动死锁检测机制,默认会每隔一段时间(毫秒级)检测一次是否有死锁。一旦检测到死锁,就会选择“代价最小”的那个事务,强制回滚,释放锁资源,让另一个事务继续执行。这就是为什么有时候我们会发现,某个事务突然回滚了,但没有报错(或者只报了死锁提示),其实就是MySQL在自动处理死锁。
但问题在于,MySQL的自动处理,只是“亡羊补牢”,不能从根本上解决问题。频繁的死锁检测和事务回滚,会导致业务卡顿、数据不一致,甚至引发连锁反应,比如订单重复创建、余额计算错误等。所以,我们要做的,是从根源上避免死锁,而不是依赖MySQL的自动处理。
二、必看:死锁频繁爆发的5大核心原因,90%的人都踩过坑
要解决死锁,首先得找到死锁的根源。根据我多年的实战经验,MySQL死锁频繁爆发,主要有5个核心原因,尤其是前两个,几乎是所有死锁问题的“重灾区”,大家可以对照自己的业务,一一排查。
(一)最常见:锁顺序混乱(占比48.7%,腾讯云实测数据)
这是死锁最主要的原因,没有之一。很多开发人员写SQL的时候,根本不考虑锁顺序,同一个业务场景,不同的事务,对同一批资源的加锁顺序不一样,一旦并发执行,就很容易形成循环等待,触发死锁。
除了前面说的用户余额转账的例子,再给大家举一个实际业务中最常出现的场景——订单创建+库存扣减,这也是电商、外卖等业务的核心场景,也是死锁的重灾区。
场景:用户下单,需要做两个操作:1. 创建订单记录(操作order表,加排他锁);2. 扣减商品库存(操作stock表,加排他锁)。
开发A写的代码:先创建订单(order表),再扣减库存(stock表);
开发B写的代码:先扣减库存(stock表),再创建订单(order表);
这两个代码单独运行,都没有问题,但一旦高峰期并发,就会出现死锁:
事务1(开发A的代码):先拿到order表的锁,准备拿stock表的锁;
事务2(开发B的代码):先拿到stock表的锁,准备拿order表的锁;
两个人互相等待,死锁就爆发了。更可怕的是,这种死锁不是偶发的,只要并发达到一定程度,就会频繁出现,而且很难排查,因为代码单独运行都正常,只有并发的时候才会出问题。
还有一种更隐蔽的情况:单表多记录加锁顺序混乱。比如,批量更新用户信息,有的事务按用户ID升序加锁(先更ID=1,再更ID=2),有的事务按用户ID降序加锁(先更ID=2,再更ID=1),这样也会形成循环等待,触发死锁。
这里提醒大家:锁顺序混乱,看似是“代码写法问题”,本质上是“开发规范问题”。很多团队没有统一的加锁规范,每个开发都按自己的习惯写SQL,最后必然会出现死锁。
(二)最致命:事务太长,锁持有时间太久
如果说锁顺序混乱是死锁的“导火索”,那长事务就是死锁的“温床”。事务越长,锁持有时间就越久,并发情况下,其他事务等待锁的时间就越长,发生死锁的概率就会呈指数级上升。
很多开发人员都有一个坏习惯:把很多无关的操作,都放在同一个事务里,比如“查询数据+业务逻辑计算+调用第三方接口+更新数据库+日志记录”,全塞在一个事务里。这样一来,事务的执行时间会变得很长,锁会被一直持有,直到整个事务结束才释放。
举个真实的反面案例:某电商平台的订单支付流程,开发人员把“查询订单信息→调用支付接口→更新订单状态→扣减库存→记录支付日志→通知物流”,所有操作都放在同一个事务里。
大家可以想想,这个事务的执行时间会有多长?调用支付接口可能需要几百毫秒,通知物流可能需要几十毫秒,再加上业务逻辑计算,整个事务可能需要1-2秒,甚至更久。在这1-2秒里,订单表、库存表的锁会被一直持有,其他并发的支付、下单事务,只能排队等待,一旦有多个这样的长事务,就很容易形成死锁。
更坑的是,很多长事务还会出现“隐性长事务”——开发人员忘记提交事务,或者代码报错后没有及时回滚事务,导致事务一直处于“未提交”状态,锁一直被持有,直到数据库连接断开,或者被DBA手动kill掉。这种隐性长事务,比显性长事务更可怕,因为它隐蔽性强,很难被发现,一旦出现,会导致大量锁阻塞,甚至引发大面积死锁。
像618同城网www.tiancebbs.cn这样的高并发平台,在处理大量用户请求、数据交互时,也曾经遇到过长事务导致的死锁问题,后来通过缩短事务、规范锁顺序,成功将死锁发生率降到了极低水平,保障了平台的稳定运行。
(三)最容易忽略:索引缺失,导致锁升级
很多人不知道,MySQL的锁粒度,和索引有直接关系。InnoDB引擎默认是行级锁,但如果查询语句没有命中索引,就会从行级锁升级为表级锁——也就是说,本来只想锁一行数据,结果把整个表都锁了,这样一来,并发情况下,所有操作该表的事务,都会被阻塞,很容易引发死锁。
举个例子:有一张商品表(product),主键是id,还有一个字段是category_id(商品分类ID),但category_id没有建索引。现在有一个事务,执行“UPDATE product SET stock = stock - 1 WHERE category_id = 5”,因为category_id没有索引,MySQL会进行全表扫描,为了保证数据一致性,会给全表的每一行都加行锁,最后升级为表级锁。
这时候,其他事务再操作这张商品表,不管是更新、删除,还是查询(加锁查询),都会被阻塞。如果有多个这样的事务,就会互相等待表级锁,触发死锁。
还有一种情况:范围查询没有命中索引,会触发间隙锁(Gap Lock),扩大锁的范围,也会增加死锁的概率。比如“SELECT * FROM product WHERE price > 100 FOR UPDATE”,如果price没有建索引,MySQL会扫描所有price>100的记录,同时给这些记录之间的“间隙”也加锁,导致其他事务无法插入、更新这个间隙内的记录,进而引发锁冲突和死锁。
这里提醒大家:索引不仅能提升查询效率,还能控制锁的粒度,避免锁升级。所以,写SQL的时候,一定要确保查询语句能命中索引,避免全表扫描。
(四)最隐蔽:锁类型冲突,共享锁与排他锁混用
MySQL的锁,主要分为共享锁(S锁,读锁)和排他锁(X锁,写锁),这两种锁的兼容性很差:
多个事务可以同时持有同一个资源的共享锁(比如多个人同时查询同一条数据);
一个事务持有排他锁后,其他事务既不能持有排他锁,也不能持有共享锁(比如一个人在修改数据,其他人不能修改,也不能查询加锁的数据);
一个事务持有共享锁后,其他事务可以持有共享锁,但不能持有排他锁(比如多个人在查询数据,其他人不能修改这条数据)。
很多开发人员在写SQL的时候,会不小心混用共享锁和排他锁,导致锁冲突,进而引发死锁。比如:
事务A:执行“SELECT * FROM order WHERE id = 1 FOR SHARE”(加共享锁,用于查询);
事务B:执行“SELECT * FROM order WHERE id = 2 FOR SHARE”(加共享锁,用于查询);
然后,事务A想更新id=2的订单(需要加排他锁),但事务B持有id=2的共享锁,所以事务A需要等待;
同时,事务B想更新id=1的订单(需要加排他锁),但事务A持有id=1的共享锁,所以事务B也需要等待;
这样一来,两个事务互相等待,死锁就产生了。
这种情况,在一些需要“先查询、再更新”的场景中,非常常见。很多开发人员为了防止“幻读”,会给查询语句加共享锁,但没有考虑到后续的更新操作会需要排他锁,进而引发锁冲突。
(五)最容易踩坑:触发器、外键引发的隐性锁
除了上面4种情况,还有一种非常隐蔽的死锁原因——触发器和外键。很多开发人员在使用触发器、外键的时候,根本没有意识到,它们会在后台自动加锁,而且加锁顺序是不可控的,很容易和其他事务的锁顺序冲突,引发死锁。
比如,有一张订单表(order),和一张订单日志表(order_log),给订单表加了一个触发器:当订单状态更新时,自动向订单日志表插入一条日志记录。
事务A:更新订单表的状态(加排他锁),触发器自动插入订单日志表(加排他锁);
事务B:先插入一条订单日志(加排他锁),然后更新对应的订单状态(加排他锁);
这时候,事务A的加锁顺序是“order表→order_log表”,事务B的加锁顺序是“order_log表→order表”,和前面说的锁顺序混乱一样,很容易引发死锁。
外键也是一样的道理:当向子表插入数据时,MySQL会自动检查父表的外键约束,给父表的对应记录加共享锁。如果有其他事务正在更新父表的这条记录(加排他锁),就会导致锁冲突;如果多个事务同时操作子表和父表,加锁顺序不一致,就会引发死锁。
所以,除非必要,尽量不要使用触发器和外键,尤其是在高并发场景下。如果必须使用,一定要仔细分析它们的加锁逻辑,确保和其他事务的加锁顺序一致。
三、实操:解决死锁的8大方案,规范锁序+缩短事务是核心
找到了死锁的核心原因,接下来就是最关键的部分——如何解决死锁,而且是从根源上解决,不是临时补救。结合前面的原因分析,我整理了8个实操方案,其中“规范锁顺序”和“缩短事务”是核心,只要做好这两个,就能解决90%的死锁问题,剩下的方案,作为补充和优化,进一步降低死锁概率。
(一)核心方案1:规范锁顺序,从根源杜绝循环等待
规范锁顺序,本质上就是“约定所有事务,对同一批资源的加锁顺序,必须完全一致”,这样就不会出现“你等我、我等你”的循环等待,从根源上避免死锁。这是解决死锁最有效、最根本的方案,没有之一。
具体怎么操作?给大家3个可落地的规范,直接照搬就能用:
- 多表操作,统一加锁顺序
只要涉及到多表更新、删除、加锁查询,所有事务的加锁顺序,必须完全一致。比如,涉及order表和stock表的操作,所有事务都必须先操作order表,再操作stock表;涉及user表和order表的操作,所有事务都必须先操作user表,再操作order表。
怎么统一?建议团队内部制定一份“加锁顺序规范文档”,把所有常用的表,按字典序(比如按表名首字母排序)或者业务优先级,确定加锁顺序,所有开发人员必须严格遵守。比如:user表 → order表 → stock表 → order_log表,所有事务涉及这些表的操作,都必须按这个顺序加锁。
举个例子,之前的订单创建+库存扣减场景,统一规定:先操作order表(创建订单),再操作stock表(扣减库存),不管哪个开发写代码,都必须按这个顺序,这样就不会出现锁顺序混乱的问题,死锁自然就不会发生。
这里提醒大家:不要依赖SQL的书写顺序,因为MySQL的执行顺序,可能和SQL的书写顺序不一样。最好的方式是,在代码层面,明确先执行哪个表的操作,再执行哪个表的操作,确保加锁顺序一致。
- 单表多记录操作,按固定顺序加锁
如果是单表多记录的更新、删除、加锁查询,比如批量更新多个用户的余额、批量删除多个订单,所有事务都必须按同一字段的固定顺序(比如主键ID升序、降序)加锁,不能有的按升序,有的按降序。
最推荐的方式是按主键ID升序加锁,因为主键是唯一的,而且InnoDB的主键索引是聚簇索引,按主键升序加锁,效率最高,也最容易统一。
比如,批量更新用户余额,事务A和事务B,都必须按用户ID升序,先更新ID=1,再更新ID=2,再更新ID=3,这样就不会出现循环等待。如果事务A按升序,事务B按降序,就很容易出现死锁。
具体操作:写SQL的时候,加上ORDER BY主键ID ASC,确保加锁顺序一致。比如:
UPDATE user_balance SET balance = balance - 100 WHERE id IN (1,2,3) ORDER BY id ASC;
这样,MySQL会按id升序,依次给id=1、id=2、id=3的记录加锁,所有事务都按这个顺序,就不会出现锁顺序混乱。
- 避免隐性锁顺序混乱(触发器、外键、INSERT ... SELECT)
前面提到,触发器、外键、INSERT ... SELECT这类操作,会隐性加锁,而且加锁顺序不可控,很容易破坏我们约定的锁顺序。所以,我们要尽量避免使用这些操作;如果必须使用,一定要仔细分析其加锁逻辑,确保和我们约定的锁顺序一致。
比如,触发器会自动操作其他表,我们要确保触发器的操作顺序,和我们约定的加锁顺序一致。如果触发器的操作顺序和约定的不一致,就需要修改触发器逻辑,或者调整约定的加锁顺序。
再比如,INSERT ... SELECT语句,会同时给查询的表加共享锁,给插入的表加排他锁,加锁顺序是“查询表→插入表”,我们要确保这个顺序,和我们约定的加锁顺序一致,否则就会引发死锁。
(二)核心方案2:缩短事务,减少锁持有时间
如果说规范锁顺序是“从根源上避免死锁”,那么缩短事务,就是“减少死锁发生的概率”。事务越短,锁持有时间就越短,其他事务等待锁的时间就越短,发生死锁的概率就越低。
很多人觉得“缩短事务”很简单,其实不然,很多开发人员都不知道,哪些操作可以放在事务里,哪些操作不能放在事务里。这里给大家4个可落地的技巧,轻松缩短事务,而且不影响业务逻辑。
- 事务里只放“核心数据库操作”,无关操作全部剥离
这是缩短事务的核心原则:事务的唯一作用,就是保证数据库操作的原子性(要么全成,要么全败),所以,只有数据库操作(增删改查),才能放在事务里;其他无关操作,比如业务逻辑计算、调用第三方接口、日志记录、文件写入等,全部剥离到事务外面。
还是以订单支付流程为例,正确的做法是:
① 先在事务外,查询订单信息、调用支付接口(第三方接口)、进行业务逻辑计算;
② 只有当支付接口调用成功后,再开启事务,执行核心数据库操作:更新订单状态、扣减库存;
③ 事务提交后,再在事务外,记录支付日志、通知物流。
这样一来,事务里就只有两个核心数据库操作,执行时间可能只有几毫秒,锁持有时间极短,发生死锁的概率就会大大降低。
反之,如果把所有操作都放在事务里,事务执行时间会很长,锁持有时间也很长,很容易引发死锁。
- 避免“隐性长事务”,确保事务及时提交/回滚
隐性长事务,比显性长事务更可怕,因为它隐蔽性强,很难被发现。所以,我们要做好两点,避免隐性长事务:
① 代码层面,确保每个事务都有明确的提交(COMMIT)和回滚(ROLLBACK)逻辑,尤其是在try-catch块里,一定要在catch块中回滚事务,避免代码报错后,事务一直处于未提交状态。
比如,用Java开发,正确的事务写法是:
try {
开启事务;
执行数据库操作;
提交事务;
} catch (Exception e) {
回滚事务;
记录日志;
}
很多开发人员,会忘记在catch块里回滚事务,导致代码报错后,事务一直未提交,锁一直被持有,引发锁阻塞和死锁。
② 数据库层面,开启长事务监控,及时发现并终止未提交的长事务。
可以通过查询MySQL的information_schema.INNODB_TRX表,查看当前所有未提交的事务,重点关注trx_started(事务开始时间)超过阈值(比如60秒)的事务,手动kill掉,释放锁资源。
查询语句:SELECT * FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
kill事务语句:KILL 事务ID(trx_id);
也可以通过修改MySQL配置,设置事务超时时间,让MySQL自动终止超时的事务:
innodb_lock_wait_timeout = 60; (单位:秒,默认是50秒,可根据业务调整)
这里提醒大家:事务超时时间不要设置太短,否则会导致正常的长事务被终止;也不要设置太长,否则无法及时释放锁资源。建议根据自己的业务,设置合理的阈值,比如60-120秒。
- 拆分大事务,将“一揽子操作”拆分为多个小事务
如果业务逻辑确实需要执行多个数据库操作,而且这些操作无法剥离到事务外,就可以将大事务拆分为多个小事务,每个小事务只执行一个核心操作,执行完立即提交,减少锁持有时间。
举个例子:批量导入1000条用户数据,很多开发人员会把1000条数据的插入操作,放在一个事务里,这样事务执行时间会很长,锁持有时间也很长。正确的做法是,拆分为100个小事务,每个事务插入10条数据,插入完立即提交,这样每个事务的执行时间很短,锁持有时间也很短,大大降低死锁概率。
再比如,批量更新1000条订单状态,拆分为100个小事务,每个事务更新10条订单,执行完立即提交,避免一个大事务持有锁太久。
这里要注意:拆分事务后,要考虑数据一致性的问题。如果某个小事务执行失败,需要有对应的补偿机制(比如回滚该小事务的操作,或者记录失败日志,后续手动处理),确保数据一致性。
- 开启自动提交,避免隐式事务过长
MySQL默认是开启自动提交(autocommit=1)的,也就是说,每一条SQL语句,都是一个独立的事务,执行完自动提交,锁会立即释放。但很多开发人员,为了方便,会手动关闭自动提交(autocommit=0),然后手动开启事务,执行多条SQL语句后,再手动提交。
这种做法,很容易导致事务过长,因为一旦关闭自动提交,所有SQL语句都会处于同一个事务里,直到手动提交或回滚。如果开发人员忘记手动提交,就会导致事务一直未提交,锁一直被持有。
所以,建议大家:除非必要,尽量不要关闭自动提交;如果需要手动事务,一定要确保事务执行完后,及时手动提交或回滚,不要拖延。
(三)补充方案1:合理设计索引,避免锁升级
前面提到,索引缺失会导致锁升级(行锁→表锁),扩大锁的范围,引发死锁。所以,合理设计索引,确保查询语句能命中索引,是避免死锁的重要补充。
具体怎么做?给大家3个实操技巧:
- 给所有查询条件、更新条件、删除条件的字段,建立索引
只要SQL语句中,WHERE子句里的字段,用于查询、更新、删除,就必须给这个字段建立索引,确保MySQL能通过索引定位到具体的行,避免全表扫描,从而避免锁升级。
比如,前面提到的“UPDATE product SET stock = stock - 1 WHERE category_id = 5”,如果category_id没有索引,就会全表扫描,触发锁升级;给category_id建立索引后,MySQL会通过索引定位到category_id=5的所有行,只给这些行加行锁,不会升级为表锁。
这里提醒大家:不要盲目建索引,索引太多会影响插入、更新的效率,要根据业务场景,建立必要的索引,优先建立主键索引、唯一索引、常用查询字段的索引。
- 避免范围查询无索引,减少间隙锁
范围查询(比如>、<、BETWEEN、IN)如果没有命中索引,会触发间隙锁,扩大锁的范围,增加死锁的概率。所以,范围查询的字段,一定要建立索引。
比如,“SELECT * FROM product WHERE price > 100 FOR UPDATE”,如果price没有索引,会触发间隙锁,给所有price>100的记录和间隙加锁;给price建立索引后,只会给price>100的记录加行锁,不会触发间隙锁(MySQL 8.0+,READ COMMITTED隔离级别下)。
- 避免使用“SELECT * FOR UPDATE”,尽量用“行级锁”替代“表级锁”
很多开发人员,为了防止“幻读”,会使用“SELECT * FOR UPDATE”给查询的记录加排他锁,但如果查询语句没有命中索引,就会升级为表级锁,引发锁阻塞和死锁。
所以,尽量避免使用“SELECT * FOR UPDATE”,如果必须加锁查询,建议明确指定查询的字段,并且确保查询语句能命中索引,只给需要的记录加锁,避免锁范围过大。
比如,不要写“SELECT * FROM order WHERE user_id = 1 FOR UPDATE”,而是写“SELECT id, order_no FROM order WHERE user_id = 1 FOR UPDATE”,并且给user_id建立索引,确保只给user_id=1的订单记录加锁。
(四)补充方案2:避免共享锁与排他锁混用,减少锁冲突
共享锁(S锁)和排他锁(X锁)的兼容性很差,混用很容易引发锁冲突,进而导致死锁。所以,我们要尽量避免混用这两种锁,具体做法如下:
- 非必要,不使用共享锁(FOR SHARE)
很多开发人员,会用共享锁来防止“幻读”,但实际上,在大多数业务场景下,不需要防止幻读,用普通的查询(不加锁)就可以满足需求。普通查询(SELECT)在InnoDB引擎下,默认是“快照读”,不会加锁,也不会影响其他事务的写操作,既能保证查询效率,又能减少锁冲突。
只有在少数需要“读取数据后,立即更新,并且不允许其他事务修改该数据”的场景下,才需要使用共享锁。比如,查询用户余额后,立即扣减余额,这时候可以用共享锁,防止其他事务同时修改该用户的余额。
- 如果必须使用共享锁,尽量缩短共享锁的持有时间
如果确实需要使用共享锁,一定要确保共享锁的持有时间极短,查询完数据后,立即执行更新操作,然后提交事务,释放共享锁,避免其他事务长时间等待。
比如,查询用户余额(加共享锁)→ 扣减余额(加排他锁)→ 提交事务,整个过程要尽量快速,不要在中间加入其他无关操作。
- 避免“先加共享锁,再加排他锁”的场景
如果一个事务先加共享锁,再试图加排他锁,而此时有其他事务持有该资源的共享锁,就会导致该事务等待,进而引发锁冲突。所以,尽量避免这种场景,要么直接加排他锁,要么先查询(不加锁),再加排他锁。
(五)补充方案3:调整事务隔离级别,减少锁的范围
MySQL的事务隔离级别,会影响锁的行为和范围,不同的隔离级别,锁的粒度和持有时间也不同。默认的事务隔离级别是“可重复读”(REPEATABLE READ),在这个级别下,会触发间隙锁,扩大锁的范围,增加死锁的概率。
所以,对于高并发场景,我们可以适当降低事务隔离级别,减少锁的范围,降低死锁概率。具体建议如下:
- 优先使用“读已提交”(READ COMMITTED)隔离级别
“读已提交”隔离级别,是大多数高并发业务的首选,它的特点是:
① 普通查询(快照读)不加锁,不会影响其他事务的写操作;
② 更新、删除、加锁查询,只锁命中的行,不会触发间隙锁(MySQL 8.0+);
③ 能避免“脏读”,但无法避免“不可重复读”,但大多数业务场景,“不可重复读”是可以接受的。
比如,电商、外卖、社交等业务,用户查询订单信息、商品信息,即使两次查询的结果不一致(比如订单状态从“待支付”变成“已支付”),也不会影响业务逻辑,这种场景下,“读已提交”隔离级别完全适用。
修改事务隔离级别的方法:
① 全局修改(重启MySQL生效):在my.cnf配置文件中,添加“transaction_isolation = READ-COMMITTED”,然后重启MySQL;
② 会话级修改(当前会话生效):执行“SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED”;
③ 事务级修改(当前事务生效):执行“SET TRANSACTION ISOLATION LEVEL READ COMMITTED”,然后开启事务。
- 避免使用“序列化”(SERIALIZABLE)隔离级别
“序列化”隔离级别,是最高的隔离级别,它会给所有查询的记录加锁,甚至会给间隙加锁,锁的范围极大,并发性能极差,很容易引发死锁。除非是对数据一致性要求极高,且并发极低的场景(比如财务对账),否则不要使用这个隔离级别。
(六)补充方案4:使用乐观锁,替代悲观锁,减少锁冲突
前面我们讲的,都是“悲观锁”(比如排他锁、共享锁),悲观锁的核心是“先加锁,再操作”,虽然能保证数据一致性,但会导致锁冲突,增加死锁的概率。对于并发量高、写操作少、读操作多的场景,我们可以使用“乐观锁”,替代悲观锁,减少锁冲突。
乐观锁的核心是“先操作,再校验”,不需要加锁,而是通过版本号(version)或时间戳(timestamp),来判断数据是否被其他事务修改过,如果没有被修改,就提交事务;如果被修改,就回滚事务,重新尝试。
举个例子,乐观锁的实现步骤:
在表中添加一个版本号字段(version),默认值为1;
事务A查询数据时,获取数据的版本号(比如version=1);
事务A修改数据时,同时校验版本号,只有当表中的版本号等于查询到的版本号时,才执行更新操作,并将版本号加1;
如果此时有事务B修改了这条数据,表中的版本号会变成2,事务A的校验就会失败,回滚事务,重新查询数据,再次尝试更新。
具体SQL示例:
-- 查询数据,获取版本号
SELECT id, balance, version FROM user_balance WHERE id = 1;
-- 更新数据,校验版本号
UPDATE user_balance SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 1;
-- 判断更新是否成功,如果影响行数为0,说明数据被其他事务修改,回滚重试
乐观锁的优势:不需要加锁,避免了锁冲突,不会引发死锁,并发性能更高;劣势:需要额外添加版本号字段,而且在写操作频繁的场景下,会出现大量重试,影响业务效率。
所以,乐观锁适合“读多写少”的场景,比如商品详情查询、用户信息查询等;悲观锁适合“写多读少”的场景,比如订单支付、库存扣减等。
(七)补充方案5:开启死锁检测,及时排查死锁问题
虽然我们做了很多预防措施,但难免还是会出现死锁。所以,开启死锁检测,及时排查死锁问题,避免死锁频繁爆发,也是非常重要的。
具体怎么做?给大家3个实操方法:
- 开启死锁日志持久化,方便后续分析
MySQL默认会将死锁信息记录到内存中,但重启MySQL后,死锁日志会丢失。所以,我们需要开启死锁日志持久化,将死锁信息记录到错误日志中,方便后续排查。
修改MySQL配置文件(my.cnf),添加以下配置:
innodb_print_all_deadlocks = 1 # 开启所有死锁日志打印
log_error = /var/log/mysql/error.log # 错误日志路径(可自定义)
配置完成后,重启MySQL,后续所有死锁信息,都会被记录到error.log中,我们可以通过查看日志,定位死锁的事务、SQL语句、锁资源等。
- 实时查询死锁信息,快速定位问题
死锁发生后,我们可以通过以下SQL语句,实时查询死锁信息,快速定位问题:
① 查看最新死锁详情(最核心):
SHOW ENGINE INNODB STATUS;
执行这条语句后,会输出很多信息,重点关注“LATEST DETECTED DEADLOCK”模块,里面包含了死锁的所有信息:死锁的事务ID、持有的锁、等待的锁、触发死锁的SQL语句等。
② 查看当前所有事务:
SELECT * FROM information_schema.INNODB_TRX;
重点关注trx_id(事务ID)、trx_started(事务开始时间)、trx_sql_state(事务状态)、trx_query(事务执行的SQL语句)。
③ 查看当前锁持有和等待关系(MySQL 8.0+):
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
通过这两条语句,可以查看当前所有持有锁的事务、等待锁的事务,以及锁的资源信息,快速定位锁冲突的原因。
- 定期分析死锁日志,优化业务逻辑
定期查看死锁日志,统计死锁的高频场景、高频SQL语句,分析死锁的原因,然后优化业务逻辑、SQL语句、加锁顺序等,从根源上解决死锁问题。
比如,通过死锁日志发现,某条批量更新SQL经常引发死锁,就可以优化这条SQL,拆分事务、添加索引、规范加锁顺序;如果发现某类业务场景经常引发死锁,就可以优化该业务的逻辑,避免锁顺序混乱、长事务等问题。
(八)补充方案6:分库分表,分散并发压力
如果业务并发量非常高,单库单表的压力很大,即使做好了前面所有的优化,死锁还是频繁爆发,这时候就可以考虑分库分表,将数据分散到多个数据库、多个表中,分散并发压力,减少锁冲突和死锁的概率。
分库分表的核心思路:将一张大表,拆分为多个小表,将一个大数据库,拆分为多个小数据库,每个小表、小数据库,只处理一部分数据,这样一来,并发压力就会分散,每个小表的锁冲突会大大减少,死锁的概率也会降低。
比如,电商平台的订单表,数据量非常大,并发量也很高,可以按用户ID哈希分表,将不同用户的订单,分散到不同的订单表中,这样,不同用户的订单操作,不会互相影响,锁冲突会大大减少,死锁的概率也会降低。
这里提醒大家:分库分表是一种“终极解决方案”,实施成本较高,需要修改业务代码、调整数据库架构,所以,只有在并发量极高、前面的优化方案无法解决死锁问题时,才考虑分库分表。
四、实战:不同业务场景的死锁解决方案,直接照搬就能用
前面讲了通用的解决方案,但不同的业务场景,死锁的原因和解决方案也有所不同。下面结合几个常见的业务场景,给大家提供针对性的死锁解决方案,直接照搬就能用到工作中。
(一)电商场景:订单创建+库存扣减(高频死锁场景)
核心痛点:并发下单时,多事务同时操作订单表和库存表,锁顺序混乱、长事务,导致死锁频繁爆发,订单丢失、库存错乱。
解决方案:
规范锁顺序:所有事务,统一先操作订单表(创建订单),再操作库存表(扣减库存),不允许反过来操作;
缩短事务:事务里只放“创建订单”和“扣减库存”两个核心数据库操作,调用支付接口、记录日志等操作,全部剥离到事务外;
给订单表的user_id、order_no建立索引,给库存表的product_id建立索引,避免锁升级;
拆分大事务:批量下单时,拆分为多个小事务,每个事务处理1-10个订单,执行完立即提交;
开启乐观锁:库存扣减时,使用乐观锁(版本号),替代悲观锁,减少锁冲突;
调整事务隔离级别:将事务隔离级别改为“读已提交”,减少间隙锁,降低死锁概率。