MySQL 事物死锁解析
在使用mysql数据库中,随着我们业务与功能模块的增加、数据库的事务数量会随之上升。在开发业务功能的场景中我们会经常开启事务操作数据,或者开启分布式全局事务操作数据时、事务过多,在并发进行的场景中往往会有几率产生事务死锁问题。
为什么要聊这个问题
死锁麻烦的地方在于它不是稳定复现的 bug:测试环境单线程跑一百遍都没事,一到线上并发高峰就冒出来,日志里只留下一句 Deadlock found when trying to get lock。如果不理解它的成因,很容易把它当成偶发异常忽略掉,直到某天业务高峰期集中爆发。这篇就把死锁从产生、检测到规避完整梳理一遍。
先补一点前置背景。InnoDB 的行锁是加在索引记录上的,一个事务更新某行数据时会拿到这行的排他锁(X 锁),并且一直持有到事务提交或回滚才释放——这是两阶段锁协议的要求。锁的持有时间等于事务的存活时间,这一点是后面所有分析的基础:事务越长,锁被别人撞上的窗口就越大。
死锁是怎么产生的
那么我们来分析一下事务死锁产生的原因:
两个 session 连接:一个session持有T1事务,另外一个session持有T2事务。
1.T1事务修改rows1
2.T2事务修改rows2
3.T2事务修改rows1
4.T2等待T1释放rows1的x锁
5.T1事务修改Rows2
6.T1等待T2释放rows2的x锁
7.相互等待死锁
关键就在第 3 步和第 5 步:两个事务以相反的顺序去碰对方已经锁住的行。T1 手里攥着 rows1 等 rows2,T2 手里攥着 rows2 等 rows1,谁也不肯先松手——因为松手意味着回滚。等待关系形成了一个环,这就是死锁的本质:资源的循环等待。
死锁在没有外力的干扰下,程序本身无力解决此问题。 必须借助人为或者另外的守护线程来解决此死锁问题。
MySQL 如何选择牺牲者
解决死锁的方法也很简单:释放其中一个事务,让其回滚即可。
但回滚有一个问题,回滚哪个事务更合理,mysql采用的是根据undo log中谁的条数更多,谁的权重越大,则舍弃权重更小的事务进行回滚,可以更好解决事务死锁。让其回滚的数据量尽量减少,以减少服务的性能牺牲。
这个选择逻辑不难理解:undo log 记录的是事务已经做过的修改,条数越多说明这个事务干的活越多,回滚它的代价就越大。所以 InnoDB 倾向于回滚那个"做得少"的事务,把重做的成本压到最低。被选中的事务会收到死锁错误,应用层拿到这个错误后可以选择重试整个事务——这也是为什么写代码时要把事务设计成可重试的。
死锁概率的估算
系统中任何一个事务发生死锁的概率≈n2r4/4R2
n:事务中事务的数量n,数量越多发生死锁的概率越大(事务包裹事务数量)
r:每个事务操作的数量r,每个事务操作的数量越大,发生死锁的概率越大(事务操作的行数)
R: 操作数据的集合R,越小发生死锁的概率越大(不同事务却操作到某集合一样的数据-数据集合,修改的数据越分散,数据集合越多,x锁发生在同一条数据的机率越小)
从这个公式能读出优化的方向:r 的指数是 4,影响最剧烈——把大事务拆小、减少单个事务操作的行数,是降低死锁概率收益最高的手段。而 R 在分母上,意味着热点数据是死锁的温床:所有事务都挤着更新同一小撮行(比如一个计数器行、一个库存行),死锁概率会被急剧放大。
主动检测:wait-for graph
mysql的主动事务检测:wait-for graph
在每个session的事务开始前可以通过算法提前得知是否有事务回路(两个事务相互影响对方x锁数据而形成回路) ,提前释放其中undo log权重较小的事务。
wait-for graph 的原理是把等待关系建模成一张有向图:每个事务是一个节点,"T2 在等 T1 的锁"就画一条 T2 指向 T1 的边。每当一个事务因为拿不到锁进入等待,InnoDB 就在图里加边并检查是否出现了环——有环即死锁,立刻挑出权重小的事务回滚,不用干等。
除了主动检测,InnoDB 还有一道兜底:锁等待超时。等待行锁超过 innodb_lock_wait_timeout 设定的时间后,等待的语句会报错返回。它不区分是真死锁还是单纯的锁竞争,只是保证事务不会无限期挂死。
-- 查看最近一次死锁的详细信息(LATEST DETECTED DEADLOCK 段)
-- 包含两个事务各自持有的锁、等待的锁以及被回滚的一方
SHOW ENGINE INNODB STATUS;
-- 查看锁等待超时时间(单位:秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
但在复杂的业务场景中 mysql的主动事务检测wait-for graph 没有生效时,死锁就已经产生也是经常发生的事情。
所以我们要梳理好业务流程,以及业务影响的数据范围。缩小死锁产生概率。不然很有可能面临人工处理死锁的场景。
踩坑与注意
结合前面的原理,实际开发中有几条经验值得留意:
1)统一加锁顺序。回看死锁的产生过程,根因是两个事务以相反顺序访问同一批行。如果所有业务代码都约定按同一顺序(比如按主键升序)更新数据,循环等待的环就画不起来。
2)事务要短。不要在事务里做 RPC 调用、发消息、等用户输入这类耗时操作,锁的持有时间被拉长,等于给死锁敞开窗口。
3)留意间隙锁。在可重复读隔离级别下,范围条件的更新和删除会加间隙锁,两个事务锁住相邻间隙再互相插入,同样会死锁——这种死锁光看行数据往往查不出来,需要读 SHOW ENGINE INNODB STATUS 里的锁信息。
4)应用层做好重试。死锁被检测到后总有一方被回滚,业务代码要能捕获死锁错误并重试整个事务,而不是只重试最后一条语句。
死锁检测本身也有成本:热点行上大量事务排队时,每个新来的等待都要遍历等待图,并发越高检测开销越大。热点更新场景要从业务上想办法拆分热点,而不是指望数据库硬扛。
小结
死锁的本质是加锁顺序交错形成的循环等待,InnoDB 用 wait-for graph 主动发现环路,再按 undo log 权重挑代价小的事务回滚兜底。但检测和回滚都只是补救,真正的功夫在事前:事务拆小、加锁顺序统一、热点数据打散,死锁概率才能压到可以忽略的水平。
评论 / COMMENTS