SQL Server事务的隔离级别和锁

2022-09-21 00:00:00 数据 会话 隔离 死锁 级别

背景

       当用户并发尝试访问同一数据的时,SQL Server尝试用锁来隔离不一致的数据和使用隔离级别查询数据时控制一致性(数据该如何读取),说起锁就会联想到事务,事务是一个工作单元,包括查询/更新数据和数据定义。

锁类型

在SQL Server中,根据资源的不同,锁分为以下三种类型:
    行锁:是SQL Server中数据级别中粒度小的锁级别,行锁根据表是否存在聚集索引,分为键值锁和标识锁
    页锁:针对某个数据页添加的锁,在T-SQL语句中,使用了页锁就不会在使用相同类型的行锁,反之依然,在对数据页加锁后,无法在对其添加不兼容的锁
    表锁:添加表锁则无法添加与其不兼容的页å锁和行锁

锁模式

   共享锁(S):发生在数据查找之前,多个事务的共享锁之间可以共存
   排他锁(X):发生在数据更新之前,排他锁是一个独占锁,与其他锁都不兼容
   更新锁(U):发生在更新语句中,更新锁用来查找数据,当查找的数据不是要更新的数据时转化为S锁,当是要更新的数据时转化为X锁
   意向锁:发生在较低粒度级别的资源获取之前,表示对该资源下低粒度的资源添加对应的锁,意向锁有分为:意向共享锁(IS) ,意向排他锁(IX),意向更新锁(IU),共享意向排他锁(SIX),共享意向更新锁(SIU),更新意向排他锁(UIX)
   共享锁/排他锁/更新锁一般作用在较低级别上,例如数据行或数据页,意向锁一般作用在较高的级别上,例如数据表或数据。锁是有层级结构的,若在数据行上持有排他锁的时候,则会在所在的数据页上持有意向排他锁. 在一个事务中,可能由于锁持有的时间太长或个数太多,出于节约资源的考虑,会造成锁升级
   除了上述的锁之外,还有几个特殊类型的锁,例如架构锁,架构锁包含两种模式,架构稳定锁(Sch-S)和架构更新锁(Sch-M) ,架构稳定锁用来稳定架构,当查询表数据的时候,会对表添加架构稳定锁,防止架构发生改变。当执行DDL语句的时候,会使用架构更新锁,确保没有任何资源对表的占用。大数据量的表避免执行DDL操作,这样会造成架构更新锁长时间占用资源,影响其他操作,除非必要不然不要执行DDL语句,如在必要的情况下添加字段,需要先给字段初始化,在设置为非空。

锁的兼容性


如何查看一个事务中所请求的锁类型和锁的顺序,可使用SQL Profiler 查看 Mode 属性

数据准备

IF OBJECT_ID('dbo.Nums','u') IS NOT NULL
DROP TABLE dbo.Nums;
GO
CREATE TABLE dbo.Nums
(
ID INT PRIMARY KEY,
NUM INT
);
GO
IF EXISTS(SELECT * FROM SYS.SEQUENCES WHERE OBJECT_ID=OBJECT_ID('dbo.NumSequence'))
DROP SEQUENCE dbo.NumSequence;
GO
CREATE SEQUENCE dbo.NumSequence
MINVALUE 1
MAXVALUE 1000
NO CYCLE
GO
DECLARE @num AS INT = NEXT VALUE FOR dbo.NumSequence
INSERT INTO dbo.Nums VALUES(@num,@num);
GO 1

运行UPDATE dbo.Nums SET Num += 1
查看SQL Profiler 的跟踪,可以清楚的看到锁的请求顺序和类型(请自定配置跟踪模版,以便于想要看到自己想要的属性)

事务的隔离级别

事务

事务是一个工作单元,包含查询/修改数据以及修改数据定义的多个活动的组合,说起事务就需要提起事务的四个基本特性ACID:
   原子性:事务要么全部成功,要么全部失败。
   一致性:事务为提交前或者事务失败后,数据都和未开始事务之前一致
   隔离性:事务与事务之间互不干扰
   持久性:事务成功后会被保存起来,不会在被回滚

隔离级别

事务的隔离级别控制并发用户的读取和写入的行为,即不同的隔离界别对锁的控制方式不一样,隔离级别主要分为两种类型:悲观并发控制和乐观并发控制,悲观并发控制有:READ UNCPOMMITTED / READ COMMITTED (会话默认) /REPEATABLE READ / SERIALIZABLE . 乐观并发控制主要以在Tempdb中创建快照的方式来实现,有:SNAPSHOT 和 READ COMMITTED SHAPSHOT,也被称为基于行版本的控制的隔离级别。

READ UNCOMMITTED

此隔离级别的主要特点是可以读取其他事务中未提交更改的数据,该隔离级别下请求查询的数据不需要共享锁,这样对于请求的行正在被更改,不会出现阻塞,这就造成了脏读.此隔离级别是低的隔离级别,并发性良好,但是对于数据的一致性方面有缺陷,在一些不重要的查询中可以采用这种方式
以上面的表为例,开始两个会话,在会话1中运行如下代码:

BEGIN TRAN
UPDATE dbo.Nums SET NUM = 10
WHERE ID = 1

开启会话2并且运行如下代码:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
GO
SELECT * FROM dbo.Nums WHERE ID = 1

查看运行结果


在事务未提交成情况下,却读取到了数据,这就是脏读,可以通过SQL Profiler 查看具体的请求锁的类型和顺序。


如图可以看出,对于会话2只请求了架构稳定锁(Sch-S) 并未请求共享锁

READ COMMITTED

此隔离级别可以看作是对READ UMCOMMITTED 隔离级别的升级,解决带了脏读的问题,主要方式是对应查询数据的请求需要先请求共享锁定,由于锁之间的兼容性,造成阻塞,但是该模式也会带来一个问题那就是不可重复读,在同一事务中的两个相同的查询 查出来的结果不一致,主要是因为该隔离级别对应共享锁并不会一致保持,在两条查询语句之间是没有锁存在的,这样其他事务就是更新数据
以上面的表为例,开始两个会话,在会话1中运行如下代码:

BEGIN TRAN
UPDATE dbo.Nums SET NUM = 10
WHERE ID = 1

在会话2中运行如下代码,该会话会被阻塞

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO
SELECT * FROM dbo.Nums WHERE ID = 1

打开会话3运行如下语句,查看当前阻塞状态,连接信息,阻塞语句等其他信息

SELECT request_session_id,resource_type,resource_database_id,DB_NAME(resource_database_id) AS dbname,resource_associated_entity_id,request_mode,request_status FROM sys.dm_tran_locks

运行结果如图:


从图中可以看出当前,会话55的请求状态为WAIT,也就是阻塞状态,图中54为UPDATE操作的DML正在持有一个更新锁(X).进一步查看进程的相关信息,运行如下代码

SELECT session_id,most_recent_session_id,connect_time,last_read,last_write, most_recent_sql_handle FROM sys.dm_exec_connections WHERE session_id IN (54,55)


可以看到各个进程的连接时间,后一次读取时间和后一次写入时间,和对应的T-SQL语句,要想查看具体的语句信息请运行如下代码

SELECT session_id,text FROM sys.dm_exec_connections CROSS APPLY sys.dm_exec_sql_text(most_recent_sql_handle) AS A WHERE session_id IN (54,55)


可以具体的查看到执行语句,要想知道具体某个会话阻塞原因,即正在等待哪个会话的资源,运行如下语句

SELECT session_id,blocking_session_id,command,text,database_id,wait_type,wait_resource,wait_time FROM sys.dm_exec_requests cross apply sys.dm_exec_sql_text(sql_handle)
WHERE blocking_session_id > 0


从图中可以看出,会话55正在等待会话54及竞争的资源信息,等待类型和等待时间,从上述的语句可以轻松查看想要知道的信息,对于各个会话对锁的请求顺序和类型请自行查看SQL Profiler.
下面我们来说说不可重复读的问题,新建会话1运行如下代码

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO
BEGIN TRAN
SELECT * FROM dbo.Nums WHERE ID=1
WAITFOR DELAY '00:00:10'
SELECT * FROM dbo.Nums WHERE ID=1

新建会话2并运行如下代码

BEGIN TRAN
UPDATE dbo.Nums SET NUM+=1 WHERE ID = 1
COMMIT TRAN

查看会话1的运行结果如图,从图中可以看出两次读取出来的数据不一致,这就是不可重复读


REPEATABLE READ

此隔离级别可以看作的是READ COMMITTED 的升级,该模式可以解决READ COMMITTED 的不可重复读的问题,主要是因为该级别下对共享锁的占用时间较长,会一直持续到事务的结束。但是该模式也会存在一个叫做幻读的缺陷,幻读指的是在查找一定范围内的数据时,其他事务对该范围的数据进行INSERT操作,导致再次执行相同的查询语句,查询的结果可能多或者是和句不一致,造成幻读的原因是因为被锁定的数据行是在次查询数据时确定的,对未来的数据并没有锁。此隔离级别不建议在更新频率较高的环境下使用,会造成性能不佳
以上面的表为例,打开两个会话,在会话1中运行下面的代码:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
GO
BEGIN TRAN
SELECT * FROM dbo.Nums WHERE ID=1
WAITFOR DELAY '00:00:10'
SELECT * FROM dbo.Nums WHERE ID=1

打开会话2并且运行如下代码

BEGIN TRAN
UPDATE dbo.Nums SET NUM+=1 WHERE ID = 1
COMMIT TRAN

查看结果:


运行过程中可以发现UPDATE的DML会一直等待会话1中事务的提交,并不会造成不可重复读,下面来演示下幻读的问题,重新打开两个会话,在会话1中运行下面的代码:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
GO
BEGIN TRAN
SELECT * FROM dbo.Nums
WAITFOR DELAY '00:00:10'
SELECT * FROM dbo.Nums
COMMIT TRAN

打开会话2运行如下代码:


BEGIN TRAN
INSERT INTO dbo.Nums VALUES(2,2)
COMMIT TRAN

运行结果:


会话2并没有被阻塞,这次查看下会话1的运行结果可以看到,读取出了2行数据,被称为幻读,关于锁的请求类型和顺序请打开SQL Profiler 自行查看.

SERIALIZABLE

此隔离级别可以看作是 REPEADTABLE READ 的升级,解决了幻读的问题,因为该模式下不仅可以锁定次查询的数据行,还可以锁定未来满足条件的数据行,是一个区间锁的概念,该级别不会出现上述的问题,但是相对的代价就是一致性强牺牲了并发性
以上表为例,修改会话1的隔离级别为 SERIALIZABLE,代码如下:


SET TRANSACTION ISOLATION LEVEL SERIALIZABLE

从结果可以看到会话2一直在等待会话1的完成,关于锁的请求类型和顺序请打开SQL Profiler 自行查看.

SNAPSHOT

当前隔离级别和接下来要介绍的隔离级别都是乐观并发控制的两种模式,又称行版本控制的隔离级别,在tempdb中存储事务未提交之前的数据行,使用基于行版本的控制隔离级别不会请求共享锁,对于查询数据的请求直接从快照读取,但是这种快照方式还是很消耗性能的,尤其是对于更新或删除操作,仍然会出现阻塞. SNAPSHOT级别对快照的读取是以事务为单位的。同一个事务中的读取操作都会读取同一快照,无论其他事务是否更新了快照。在 READ COMMITTED 的隔离级别下还是会从快照读取,但是其他模式就按照本身的控制方式进行控制,目标是源表,只有SNAPSHOT隔离级别可以检测冲突。
要使用该隔离级别需要在数据库中打开任意会话执行如下代码:

ALTER DATABASE TEST SET ALLOW_SNAPSHOT_ISOLATION ON

以上面的表为例,打开两个会话,在会话1中运行如下代码:

BEGIN TRAN
UPDATE dbo.Nums set NUM +=1
WHERE ID = 1

打开会话2并运行如下代码:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT
GO
BEGIN TRAN
SELECT * FROM dbo.Nums
WHERE ID = 1

此时会话2并没有被阻塞,而是返回了之前的版本,结果如下:


切换会会话1运行 COMMIT TRAN ,紧接着继续在会话2中在执行一遍相同的查询,执行结果如下



发现与上次的结果相同,但是会话1明明已经提交了,为什么还是原来的数据呢,这是因为该模式的特点,要是想读取新的数据需要,需要提交本次事务,继续在会话2中运行如下代码:

COMMIT TRAN
BEGIN TRAN
SELECT * FROM dbo.Nums
WHERE ID = 1
COMMIT TRAN

结果如图所示:


下面看一个冲突检测的例子
重新打开两个会话,在会话1中运行如下代码:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT
GO
BEGIN TRAN
SELECT * FROM dbo.Nums
WHERE ID = 1

打开会话2运行如下代码:


BEGIN TRAN
UPDATE dbo.Nums SET NUM =10000
WHERE ID =1

回到会话1,继续运行如下代码:

UPDATE dbo.Nums SET NUM =100
WHERE ID =1

此时会话1出现阻塞,可以通过执行如下语句:

SELECT session_id,blocking_session_id,command,text,database_id,wait_type,wait_resource,wait_time FROM sys.dm_exec_requests cross apply sys.dm_exec_sql_text(sql_handle) WHERE blocking_session_id > 0

结果如图所示:


从图中可以看出竞争的资源是源表的数据行,并不是快照的,这就说明对于UPDATE 或者是DELETE 终的目标是源表,切换会话2 运行 COMMIT TRAN 发现会话1中出现了错误:



READ COMMITTED SNAPSHOT 模式对于冲突检测这一案例结果是不支持,会话1中的更新操作会成功,读者可以自行实验。

READ COMMITTED SNAPSHOT

同SNAPSHOT很像,但对于快照的读取是以语句为单位的,同一个事务中的查询数据的语句每次都读取快照的新版
要使用该隔离级别需要在数据库中打开任意会话执行如下代码:

ALTER DATABASE TEST SET READ_COMMITTED_SNAPSHOT ON

以上表为例,打开2个会话,在会话1运行如下代码:

BEGIN TRAN
UPDATE dbo.Nums SET NUM +=1
WHERE ID =1

打开会话2,并运行如下代码:

BEGIN TRAN
SELECT * FROM dbo.Nums
WHERE ID =1

运行结果为:


是从快照中读取出来的,继续在会话1中运行 COMMIT TRAN ,之后在会话2中的当前事务中继续执行相同的查询,结果如下:


这就是之前所说的语句为单位的读取快照,在这里有一个很有趣的现象就是,在会话2中并未设置隔离级别,这是因为默认情况下的隔离级别为 READ COMMITTED 由于运行了如上语句修改数据库标记,故,会话的默认的隔离级别变成了 READ COMMITTED SNAPSHOT,当显示修改为其他隔离级别是,则会按照修改后的隔离级别运行。若修改会话2的隔离级别为 READ UNCOMMITTED 时,并不会进行快照查询,仍然出现了脏读。

对于解决脏读/不可重复读/幻读等问题,可以通过升级隔离级别的方式解决问题。

死锁

说起锁的问题,那当然少不了谈起死锁这种现象,主要发生于两个或多个事务之间存在相互阻塞,造成死锁,在SQL Server 中会牺牲工作少的事务,SQL Server 可以设置一个DEADLOCK_PRIORITY的会话选项设置事务的在发生死锁的情况下牺牲的顺序,值在-10~10之间,在发生死锁的情况下,会优先牺牲数值低的事务,不管其做的工作有多么的重要,当存在平级的时候,将根据工作数量进行牺牲。
下面来演示一个死锁的例子,以上面的表为例,并创建一个Nums副本表取名CopyNums,并添加(1,1)记录,打开两个会话,在会话1中执行如下代码:

SET DEADLOCK_PRIORITY 0
BEGIN TRAN
UPDATE dbo.Nums SET NUM=100
WHERE ID = 1

打开会话2运行如下代码:

SET DEADLOCK_PRIORITY 1
BEGIN TRAN
UPDATE dbo.CopyNums SET NUM = 100
WHERE ID = 1

切换回会话1 继续运行如下代码:

SELECT * FROM dbo.CopyNums
WHERE ID = 1

此时会发生阻塞,等待排他锁(X)释放,切换会话2运行如下代码:

SELECT * FROM dbo.Nums
WHERE ID = 1

此次也会发生阻塞,但是阻塞一会你就会发现,会话1终止了,并出现如下错误:


为什么会终止的是会话1呢?可以发现在会话中我们设置了 DEADLOCK_PRIORITY,会牺牲数值低的那个会话事务,查看SQL Profiler 可以发现,确实有死锁现象发生(为了清晰仅显示死锁)


那么既然死锁会发生,就要有对应的避免死锁的对策:
   1. 事务时间越长,保持锁的时间就越长,造成死锁的可能性就越大,检查事务中是否放置了过多的不应该属于同一工作单元的逻辑,有的话请移除到,从而缩短事务的时间
   2. 上述死锁发生的关键在于访问顺序的问题,将两个会话中的语句变成一个顺序(都先操作Nums 或者 CopyNums ),就没有了死锁现象,所以在没有逻辑的单元中,调换顺序也会减少死锁的发生
   3. 考虑选择隔离级别,不同隔离级别对锁的控制方式不一样,例如:行版本控制就不会请求共享锁(S)



作者:挚诚v8

出处:http://www.cnblogs.com/retop/

相关文章