数据库死锁原理与总结

随着系统业务的不断壮大,服务的并发度也越来越高,为确保多线程高并发的情况下对相同资源操作的数据一致性,采用MySQL锁机制进行数据操作。在这样高并发、高吞吐的系统性能要求情况下,带来的是潜在的数据库死锁问题。倘若出现数据库死锁问题,则会导致资源利用率变低、服务稳定性下降。本文主要针对测试中所了解的一些数据库及死锁相关知识进行了归纳,整理和总结,并结合项目测试发现的死锁问题,描述了解决死锁的几种方法,概述了当前针对死锁测试的方法,以共同学习。

1. 背景知识

MySQL InnoDB如何进行数据读取,什么样的数据读取需要加锁,数据隔离级别是什么样的,什么情况下该使用什么类型的锁,锁定的方式又是什么,在本小节梳理了相关背景知识,解答了以上疑问,以更清晰地了解锁机制及死锁产生的原因。

1.1. MVCC:快照读(Snapshot Read)与当前读(Current Read

MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。其最大的优点是:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。

MVCC读取数据的方式分为快照读和当前读两种方式。快照读:读取指定版本,不需要加锁,如简单的select操作;当前读:读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。如插入、更新、删除操作,均需要进行加锁。之所以将这些操作归为当前读,用更新操作举例,在进行更新操作前,首先会读取当前的待更新数据库字段的值进行返回并加锁(当前读),待MySQL收到这个加锁记录后,才会发起一个update的操作,更新该记录。删除操作同理,插入操作稍有不同,简单地说在执行插入前可能会触发唯一键检查操作,也会进行一个当前读。

1.2. 事务的四种隔离级别

在数据库操作中,为了有效保证并发读取数据的正确性,提出的事务隔离级别。数据库锁也是为了构建这些隔离级别存在的。

隔离级别
脏读(Dirty Read)
不可重复读(NonRepeatable Read)
幻读(Phantom Read)
未提交读(Read uncommitted)
可能
可能
可能
已提交读(Read committed) 不可能 可能 可能
可重复读(Repeatable read) 不可能 不可能 可能
可串行化(Serializable ) 不可能 不可能 不可能

表1 事务隔离级别

  • 未提交读(Read Uncommitted):允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
  • 提交读(Read Committed):只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
  • 可重复读(Repeated Read):在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
  • 串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞

1.3. 数据库锁类型

根据对资源操作的不同,对资源锁定的级别也有所不同。MySQL InnoDB存储引擎的锁类型分为以下几种。

共享锁(S) 允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
排他锁(X) 允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
意向共享锁(IS) 事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
意向排他锁(IX) 事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
锁类型
描述

表2 MySQL InnoDB存储引擎的锁类型

上述锁类型之间的兼容关系如下

X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容
表3 各锁类型兼容关系
        如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
        意向锁是InnoDB自动加的,不需用户干预。对于当前读操作,InnoDB会自动给涉及数据集加排他锁(X);对于快照读操作,InnoDB不会加任何锁;事务可以通过以下语句显示给记录集加共享锁或排他锁。
        共享锁(S):SELECT * FROM table_name WHERE … LOCK IN SHARE MODE。
        排他锁(X):SELECT * FROM table_name WHERE … FOR UPDATE。
        用SELECT … IN SHARE MODE获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行UPDATE或者DELETE操作。但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用SELECT… FOR UPDATE方式获得排他锁。

1.4. 锁定方式

MySQL InnoDB支持三种行锁定方式:

行锁(Record Lock):锁直接加在索引记录上面,锁住的是key。

间隙锁(Gap Lock):锁定索引记录间隙,确保索引记录的间隙不变。间隙锁是针对事务隔离级别为可重复读或以上级别。

Next-Key Lock :行锁和间隙锁组合起来就叫Next-Key Lock。

默认情况下,InnoDB工作在可重复读隔离级别下,并且会以Next-Key Lock的方式对数据行进行加锁,这样可以有效防止幻读的发生。Next-Key Lock是行锁和间隙锁的组合,当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改或者插入记录。

 

2. 数据库死锁定义及其产生的必要条件

所谓数据库死锁是指两个或多个事务,各自占有对方的期望获得的资源,形成的循环等待,彼此无法继续正常执行的一种状态。

从业务层看死锁产生具有一定的概率性,当具备了以下几个必要条件时,则会出现死锁:

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用完释放。
  • 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己获得的其它资源保持不放。
  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

3. 死锁问题示例及解决方法

3.1. 示例1

        问题抽象来源于tallyserver测试中带营销的交易记账,预记账锁定资源及确认记账资源解锁时,对用户加锁顺序不一致,产生的死锁。

 

1 1
2 2
3 3
F_id
F_column_1
……

表4 t_example1表 (F_column_1 primary key, F_id)

1 1
2 2
3 3
F_id
F_column_2
……

表5 t_example2表(F_column_2 primary key, F_id)

begin transaction begin transaction
select * from t_example1 where F_column_1=1 for update
select * from t_example2 where F_column_2=3 for update
select * from t_example2 where F_column_2=3 for update
select * from t_example1 where F_column_1=1 for update
session 1
session 2
表6 示例1事务执行过程
分析:上述死锁示例是比较常见的一种死锁,由于两个事务分别持有了一个行锁,分别等待对方释放所持有的行锁,因此导致了死锁。

3.2. 示例2

        问题抽象来源于tallyserver的单账户性能优化二期,t_lock_res表由于设置索引类型不正确导致的间隙锁问题。
1 1
2 3
3 5
F_id
F_user_id
……

表7 t_example3(F_id primary key, F_user_id )建立的索引为key (F_user_id)

begin transaction begin transaction
select XX from t_example3 where F_user_id=3 for update
update t_example1 set XX where F_column_1=1
update t_example1 set XX where F_column_1=1
insert into t_example3 (F_id,F_user_id)values(4,2)
session 1
session 2

表8 示例2事务执行过程

        分析:示例2之所以会产生死锁,因为表t_example3对F_user_id创建的是一个普通索引,session1在根据F_user_id查询条件执行select for update时,锁定的是上一条记录到下一条记录之间的区间,因此,该锁锁定区间为从记录1到5之间的区间,session2事务首先持有了表example1的F_column_1的行锁,接下来想要向表三种插入F_user_id=2的记录,其需要锁定的是记录1到记录3之间的区间,而其所尝试锁定的部分区间已经被session1的事务锁定,因此进入了等待状态,此时,session1的事务等待session2的事务所持有的表t_example1的F_column_1=1的行锁的释放,因此出现了死锁。
        当出现死锁后,通常采用破坏死锁产生的四个必要条件其中的一个或多个,来解决死锁问题。
        1)确保资源按照指定的顺序推进,破坏环状等待的现状
        上述示例一解决方法:将其中一个事务的sql执行顺序调整,确保加锁的顺序一致,从而避免死锁。
        2)尽可能缩小锁定资源的区间,避免gap锁所导致的死锁问题
        上述示例二解决方法:将t_example3表的索引由普通索引改成唯一索引,缩小锁定区间,解决间隙锁导致的死锁问题。
        3)设置锁超时时间,达到指定阈值后自动释放锁,破坏请求并保持条件
        这种方式相对被动,通过判断锁持有时间,超过指定阈值后,则释放其所持有的锁定资源,交由业务自身重试处理,使等待该资源的其他事务得以推进。目前的分布式数据库死锁,则是采用的锁超时的处理方式来解决不同分片上出现的死锁问题。

打赏此文

如果您觉得本站的内容对您有所帮助,您可以扫描下面的二维码小额支付请我喝杯茶,感谢!打赏记录
支付宝
微信
承诺:凡打赏捐助的朋友,留言备注自己的邮箱,在打赏捐助时间点的6个月内,本站会每周邮件推送原创专业技术博文,供大家学习和参考!

留下评论

All fields marked (*) are required