在如今这个云计算,大数据,移动互联网大行其道的时代,各种NoSQL数据库MongoDb、redis、HBase等使用的越来越广泛,大有替代关系型数据库的趋势。但是关系型数据库真的已经落伍了吗?答案是否定的。非关系型数据库不支持ACID属性,不支持事务,无法适应复杂查询的缺点。关系型数据库凭借其强一致性的特点,注定了在类似银行转账,订单支付等场景中,还是唯一的选择。众所周知,SQLSERVER通过锁来提供ACID属性,处理并发访问,本文尝试通过对锁机制的一些学习,让我们明白数据库查询超时,死锁等问题是如何产生的,及相关解决方法。

在开始谈有关锁的内容前,我们先看几个我们在日常开发中经常碰到的场景:

  1. 这个查询我索引该加的都加了,测试库刚同步的生产库,在测试环境查询秒出,为什么到了生产环境就会经常超时呢?
  2. 我的某个业务逻辑上线后一切正常,运行了几个月后,开始死锁越来越频繁了,但是数据量也不大,为什么呢?

以上两种情况或许是因为锁在其中"作怪"。下面我们就开始从一些锁的基本概念谈起,了解SQLSERVER的锁机制,死锁成因以及死锁的预防。为了让大家能对锁有一个更深入的了解,本文前半章谈的比较多的是一些基本概念。老鸟可以自动略过。

 

一.锁的一些基本概念

为什么需要锁?

在任何多用户的数据库中,必须有一套用于数据修改的一致的规则,当两个不同的进程试图同时修改同一份数据时,数据库管理系统(DBMS)负责解决它们之间潜在的冲突。任何关系数据库必须支持事务的ACID属性,所以在开始了解锁之前,首先简单了解一下数据库事务和事务的ACID属性。

  • 原子性(Atomicity):原子性意味着数据库中的事务执行是作为原子。即不可再分,整个语句要么执行,要么不执行。
  • 一致性(Consistency):在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。(唯一约束,外键约束,Check约束等)和触发器设置.这一点是由SQL SERVER进行保证的.
  • 隔离性(Isolation):事务的执行是互不干扰的,一个事务不可能看到其他事务运行时,中间某一时刻的数据。
  • 持久性(Durability): 持久性表示在某个事务的执行过程中,对数据所作的所有改动都必须在事务成功结束前保存至某种物理存储设备。这样可以保证,所作的修改在任何系统瘫痪时不至于丢失。

理论上所有的事务之间应该是完全隔离的。但是实际上,要实现完全隔离的成本实在是太高(必须是序列化的隔离等级才能完全隔离)。所以, SQL Server通过锁,就像十字路口的红绿灯那样,告诉所有并发的连接,在同一时刻上,那些资源可以读取,那些资源可以修改。当一个事务需要访问的资源加了其所不兼容的锁,SQL Server会阻塞当前的事务来达成所谓的隔离性。直到其所请求资源上的锁被释放。

为此,SQL Server在隔离和并发之间选择了Read Commited作为数据库的默认隔离级别。

多个用户同时对数据库的并发操作时会带来以下数据不一致的问题: 

脏读:一个事务读取到了另外一个事务没有提交的数据。

A修改了数据,随后B又读出该数据,但A因为某些原因取消了对数据的修改,数据恢复原值,此时B得到的数据就与数据库内的数据产生了不一致 

幻读:同一事务中,用同样的操作读取两次,得到的记录数不相同。

A读取数据,随后B又插入了数据,此时A再读数据是发现前后两次获取的数据行集不一致 

不可重复读:在同一事务中,两次读取同一数据,得到内容不同。

A用户读取数据,随后B用户读出该数据并修改,此时A用户再读取数据时发现前后两次的值不一致

     丢失更新:事务T1读取了数据,并执行了一些操作,然后更新数据。事务T2也做相同的事,则T1和T2更新数据时可能会覆盖对方的更新,从而引起错误。

A,B两个用户读同一数据并进行修改,其中一个用户的修改结果破坏了另一个修改的结果,比如订票系统 

并发控制的主要方法是通过锁,在一段时间内禁止用户做某些操作以避免产生数据不一致

 

理解SQL SERVER中的隔离级别

 

为了避免上述几种事务之间的影响,SQL Server通过设置不同的隔离等级来进行不同程度的避免。 SQL Server提供了5种选项来避免不同级别的事务之间的影响。隔离等级由低到高分别为:

  • 未提交读(Read Uncommited):最高的性能,但可能出现脏读,不可重复读,幻读
  • 已提交读(Read commited):可能出现不可重复读,幻读
  • 可重复读(Repeatable Read):可能出现幻读
  • 序列化(Serializable):最低的性能,Range锁会导致并发下降
  • 快照(SNOPSHOT):这个是通过在tempDB中创建一个额外的副本来避免脏读,不可重复读,会给tempDB造成额外负担

 

锁的模式

  • 共享锁(S锁):用于读取资源所加的锁。拥有共享锁的资源不能被修改。共享锁默认情况下是读取了资源马上被释放。
  • 排他锁(X锁): 和其它任何锁都不兼容,包括其它排他锁。排它锁用于数据修改,当资源上加了排他锁时,其他请求读取或修改这个资源的事务都会被阻塞,知道排他锁被释放为止。
  • 更新锁(U锁): U锁可以看作是S锁和X锁的结合,用于更新数据,更新数据时首先需要找到被更新的数据,此时可以理解为被查找的数据上了S锁。当找到需要修改的数据时,需要对被修改的资源上X锁。SQL Server通过U锁来避免死锁问题。因为S锁和S锁是兼容的,通过U锁和S锁兼容,来使得更新查找时并不影响数据查找,而U锁和U锁之间并不兼容,从而减少了死锁可能性。
  • 意向锁(IS IX IU):意向锁与其说是锁,倒不如说更像一个指示器。在SQL Server中,资源是有层次的,一个表中可以包含N个页,而一个页中可以包含N个行。当我们在某一个行中加了锁时。可以理解成包含这个行的页,和表的一部分已经被锁定。当另一个查询需要锁定页或是表时,再一行行去看这个页和表中所包含的数据是否被锁定就有点太痛苦了。因此SQL Server锁定一个粒度比较低的资源时,会在其父资源上加上意向锁,告诉其他查询这个资源的某一部分已经上锁。比如,当我们更新一个表中的某一行时,其所在的页和表都会获得意向排他锁
  • 快照(SNOPSHOT):这个是通过在tempDB中创建一个额外的副本来避免脏读,不可重复读,会给tempDB造成额外负担
  • 键范围锁(KEY-RANGE):在使用可序列化事务隔离级别时,对于 Transact-SQL 语句读取的记录集,键范围锁可以隐式保护该记录集中包含的行范围。可序列化隔离级别要求每当在事务期间执行任一查询时,该查询都必须获取相同的行集。键范围锁可防止其他事务插入其键值位于可序列化事务读取的键值范围内的新行,从而确保满足此要求。

键范围锁可防止幻读。通过保护行之间的键范围,它还可以防止对事务访问的记录集进行幻插入。

键范围锁放置在索引上,指定开始键值和结束键值。此锁将阻止任何要插入、更新或删除任何带有该范围内的键值的行的尝试,因为这些操作会首 先获取索引上的锁。例如,可序列化事务可能发出了一个 SELECT 语句,以读取其键值介于 'AAA' 与 'CZZ' 之间的所有行。从 'AAA' 到 'CZZ' 范围内的键值上的键范围锁可阻止其他事务插入带有该范围内的键值(例如 'ADG'、'BBD' 或 'CAL')的行。

  • 架构锁: SQL Server 使用架构锁来保持表结构的完整性。不像其他提供数据隔离的锁类型,架构锁提供事务中对数据库对象如表、视图、索引的schema隔离。
  • 大容量更新锁:在向表进行大容量数据复制且指定了 TABLOCK 提示时使用

 

锁兼容性

 

锁的粒度

所谓所粒度,从本质上说就是,为了给事务提供完全的隔离和序列化,作为查询或更新的一部分被锁定的数据的总量(的大小)。Lock Manager需要在资源的并发访问与维护大量低级别锁的管理开销之间取得平衡。比如,锁的粒度越小,能够同时访问同一张表的并发用户的数量就越大,不过维护这些锁的管理开销也越大。锁的粒度越大,管理锁需要的开销就越少,而并发性也降低了。下图说明了锁的大小与并发性之间的权衡取舍。

 

SQL Server支持的锁粒度可以分为为行、页、键、键范围、索引、表或数据库获取锁

 

锁升级

锁升级是将许多较细粒度的锁转换成数量更少的较粗粒度的锁的过程,这样可以减少系统开销,但却增加了并发争用的可能性。

当 SQL Server 数据库引擎获取低级别的锁时,它还将在包含更低级别对象的对象上放置意向锁:

  1. 当锁定行或索引键范围时,数据库引擎将在包含这些行或键的页上放置意向锁。
  2. 当锁定页时,数据库引擎将在包含这些页的更高级别的对象上放置意向锁。

除了对象上的意向锁以外,以下对象上还需要意向页锁:非聚集索引的叶级页、聚集索引的数据页、堆数据页。

 

锁升级的阈值:

  • 单个 Transact-SQL 语句在单个无分区表或索引上获得至少 5,000 个锁。
  • 单个 Transact-SQL 语句在已分区表的单个分区上获得至少 5,000 个锁,并且 ALTER TABLE SET LOCK_ESCALATION 选项设为 AUTO。
  • 数据库引擎实例中的锁的数量超出了内存或配置阈值

TIPS:数据库引擎不会将行锁或键范围锁升级到页锁,而是将它们直接升级到表锁。同样,页锁始终升级到表锁。

 

如何查看锁

1、使用sys.dm_tran_locks这个DMV

2、使用Profiler来捕捉锁信息

 

二、死锁成因分析

什么是死锁

死锁的本质是一种僵持状态,是多个主体对于资源的争用而导致的。在两个或多个任务中,如果每个任务锁定了其他任务试图锁定的资源,此时会造成这些任务永久阻塞,从而出现死锁。理解死锁首先需要对死锁所涉及的相关观念有一个理解。

在上图的例子中,每队汽车都占有一条道路,但都需要另外一队汽车所占有的另一条道路,因此互相阻塞,谁都无法前行,因此造成了死锁。

 

死锁产生的原因及四个必要条件

产生死锁的原因主要是:

(1) 因为系统资源不足。

(2) 进程运行推进的顺序不合适。

(3) 资源分配不当等。

如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能产生死锁。

产生死锁的四个必要条件:

(1) 互斥条件:一个资源每次只能被一个进程使用。

(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。

(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。 

 

死锁的两种类型

1、循环死锁:两个进程请求不同资源上的锁,每一个进程都需要对方持有的该资源上的锁,这时将发生循环死锁。如下图

2、转换死锁:两个或多个进程都在事务中持有同一资源上的共享锁,并且都想把它升级为独占锁,但是,谁也没法升级直到其他的进程释放共享锁,如下图

 

SQL Server中产生死锁的一些情况

1、由书签查找产生的死锁:这类死锁产生的原因是书签查找和更新数据产生的僵持状态。简单来说,就是由于Update语句对基本表产生X锁,然后需要对表上的索引也进行更新,而表上的索引正好被另一个连接进行查找,加了S锁,此时又产生书签查找去基本表加了X锁的数据进行书签查找,此时形成死锁 

书签查找:当查询优化器使用非聚集索引进行查找时,如果所选择的列或查询条件中的列只部分包含在使用的非聚集索引和聚集索引中时,就需要一个查找(lookup)来检索其他字段来满足请求。对一个有聚簇索引的表来说是一个键查找(key lookup),对一个堆表来说是一个RID查找(RID lookup),这种查找即是——书签查找(bookmark lookup)。简单的说就是当你使用的sql查询条件和select返回的列没有完全包含在索引列中时就会发生书签查找。

解决方案:这种死锁可以通过Include列来减少书签查找,从而减少这种类型死锁发生的概率。

 

2、由外键产生的死锁: 这类死锁产生的原因来自外键约束。当主表(也就是主键是从表外键的那个表)更新数据时,需要查看从表,以确定从表的外键列满足外键约束。此时会在主表上加X锁,但这并不能阻止同一时间,另一个SPID向从表添加被修改的主表主键,为了解决这个问题,SQL Server在进行这类更新时,使用Range锁,这种锁是当隔离等级为序列化时才有的,因此在这时虽然隔离等级可能是默认的已提交读,但是行为却是序列化。这很可能就会导致死锁。

解决方案:向外键列添加索引,使得Range锁加在索引上,而不是表本身。从而降低了死锁发生的概率。

 

3、由于推进顺序不当产生的死锁:在多个事务对资源的使用顺序不当,形成死锁环路而引发的。

解决方案:尽量使资源的使用顺序一致。这也是死锁问题出现最多的一种情况。

如何查看死锁

 

三、死锁的预防与优化

预防死锁

预防死锁就是破坏四个必要条件中的某一个和几个,使其不能形成死锁。有如下几种办法:

1)破坏互斥条件

破坏互斥条件有比较严格的限制,在SQL Server中,如果业务逻辑上允许脏读,则可以通过将隔离等级改为未提交读或使用索引提示。这样使得读取不用加S锁,从而避免了和其它查询所加的与S锁不兼容的锁互斥,进而减少了死锁出现的概率。

2)破坏请求和等待条件

这点由于事务存在原子性,是不可破坏的,因为解决办法是尽量的减少事务的长度,事务内执行的越快越好。这也可以减少死锁出现的概率。

3)破坏不剥夺条件

由于事务的原子性和一致性,不剥夺条件同样不可破坏。但我们可以通过增加资源和减少资源占用两个角度来考虑。

增加资源:比如说通过建立非聚集索引,使得有了额外的资源,查询很多时候就不再索要锁基本表,转而锁非聚集索引,如果索引能够"覆盖(Cover)"查询,那更好不过。因此索引Include列不仅仅减少书签查找来提高性能,还能减少死锁。

减少资源占用:比如说查询时,能用select col1,col2这种方式,就不要用select * .这有可能带来不必要的书签查找

 

最大限度减少死锁的方法

  1. 按同一顺序访问对象: 按同一顺序访问对象也就是:第一个事务提交或回滚后,第二个事务继续进行,这样不会发生死锁。
  2. 避免事务中的用户交互: 避免编写包含用户交互的事务,因为运行没有用户交互的批处理的速度要远远快于用户手动响应查询的速度,例如答复应用程序请求参数的提示。例如,如果事务正在等待用户输入,而用户去吃午餐了或者甚至回家过周末了,则用户将此事务挂起使之不能完成。这样将降低系统的吞吐量,因为事务持有的任何锁只有在事务提交或回滚时才会释放。即使不出现死锁的情况,访问同一资源的其它事务也会被阻塞,等待该事务完成。
  3. 保持事务简短并在一个批处理中: 在同一数据库中并发执行多个需要长时间运行的事务时通常发生死锁。事务运行时间越长,其持有排它锁或更新锁的时间也就越长,从而堵塞了其它活动并可能导致死锁。 保持事务在一个批处理中,可以最小化事务的网络通信往返量,减少完成事务可能的延迟并释放锁。
  4. 使用低隔离级别: 确定事务是否能在更低的隔离级别上运行,执行提交读取允许事务读取另一个事务已读取(未修改)的数据,而不必等待第一个事务完成。使用较低的隔离级别(例如提交读取)而不使用较高的隔离级别(例如可串行读)可以缩短持有共享锁的时间,从而降低了锁定争夺。
  5. 使用绑定连接: 使用绑定连接使同一应用程序所打开的两个或多个连接可以相互合作。次级连接所获得的任何锁可以象由主连接获得的锁那样持有,反之亦然,因此不会相互阻塞。

优化死锁的一些建议

(1)对于查询频繁的表尽量使用聚集索引;

(2)设法避免一次性影响大量记录的SQL语句,特别是INSERT和UPDATE语句;

(3)设法让UPDATE和DELETE语句使用合适的索引;

(4)使用嵌套事务时,避免提交和回退冲突;

(5)对数据一致性要求不高的查询使用 WITH(NOLOCK)

(6)减小事务的体积,事务应最晚开启,最早关闭,所有不是必须使用事务的操作必须放在事务外。

(7)查询只返回你需要的列,不建议使用 SELECT * FROM 这种写法。