本文假定读者已经有一部分 自旋锁 和 闩锁的知识。读者可以翻阅Nikola Dimitrijevic的文章 All about Latches in SQL Server 和 All about SQL Server spinlocks 了解更多。下面是一些关于自旋锁和闩锁的知识点总结。
- 闩锁 是SQL SERVER引擎所使用的轻量同步原语,以保障内存中数据结构的一致性。
- 自旋锁 和 闩锁类似,他们之间的主要区别是:如果一个线程没能立即获取闩锁,它则降级,使CPU用于其它事情(退出当前CPU的使用权)。如果一个线程无法获取自旋锁,线程则开始循环(轮询),重复检查资源,并期望它将不久变为可用。
- 自旋锁 和 闩锁 都是SQL SERVER引擎内部使用的对象,开发人员不能影响自旋锁 和 闩锁的行为,并且也不能影响当一个目标资源暂不可用时对线程使用自旋锁还是闩锁。
接下来, 本文会列举四个自旋锁和闩锁争用的示例。示例借鉴于《Professional SQL Server 2012 Internals and Troubleshooting》一书,在此感谢作者。
当聚集索引是ID字段时的插入操作
许多建议都说用一个ID字段作为表的聚集索引。当然,这么做是有好处的。一个ID字段通常是int或者bigint数据类型,使用它与其它一些用于主键的候选列比较起来相对比较小,尤其是和uniqueidentifier列相比,uniqueidentifier会导致频繁的分页操作(可以使用NEWSEQUENTIALID()来解决这个问题,移步【SqlServer】 理解数据库中的数据页结构 查看详情),尤其是聚集索引键也会出现在非聚集索引中,使得表过于庞大。
然而,对于表来说,使用ID字段作为聚集索引,当插入操作的数据量增加时,最后的数据页会变成“热点”,争用就会发现。
考虑如下场景:许多处理器内核正试图将数据插入同一个数据页中。第一个会话为了达到页面,将获得一个PAGELATCH_EX闩锁,但与此同时,大量其它线程可能也试图获取一个PAGELATCH_EX闩锁。也将在更高的索引级别中获取PAGELATCH_SH闩锁,来允许这些页面被检索。如果插入操作需要写入一个新页面,那么在下一个更高的索引级别将获取一个PAGELATCH_EX闩锁。
如果在大量写入操作的期间查询sys.dm_os_waiting_tasks,可能显示PAGELATCH_EX等待,同时resource_description列显示页面的注释。页面可以用DBCC PAGE命令进行查询,并可以识别出处于压力之下的表。(sys.dm_os_waiting_tasks会提供一个有用的session_id,可以用该Id和其它动态管理视图管理关联,移步sys.dm_os_waiting_tasks (Transact-SQL)查看详情)
这里的关键点并不是想要列举一个反对使用一个ID字段作为聚集索引的例子。在许多系统中,这任然是一个很好的主意。但是,如果在频繁地插入到这样一张表期间,你看到有大量地闩锁争用,那么这样地设计当然是可能导致困境的原因。
解决方案必需将活动从插入的热点中移出。虽然这可以通过简单地将ID字段替换成一个新地用newid()来填充uniqueidentifier列来做到,但同样的目标也可用其它的方法达到。一种可以充分地将负荷摊开而不损失小聚集索引的好处,能很好地将数据安排到B树中的方法是采用分区。这样,表就分布在多个B树结构上,而非只分布在一个上。对于每个分区可能仍然是一个热点,但是这个方法对于很好地减轻问题数据页地压力已经足够了。
接下来的例子假设共需要八个分区,但你可以选择符合需要地任意数量。所有分区可以放到同一个文件组中,这个练习并不是设计来使用分区将表跨过多个文件组展开,仅是采用额外的B树结构来存储表。
CREATE PARTITION FUNCTION pf_spread (TINYINT) AS RANGE LEFT FOR VALUES(0,1,2,3,4,5,6); CREATE PARTITION SCHEME ps_spread AS PARTITION pf_spread ALL TO (PRIMARY);
为将数据分布到多个分区中,只需采用表中的一列来分布数据。在这个例子中,ID对8取模恰好能满足要求。(PartID是一个计算列,请移步【SqlServer】计算列(Computed Columns)使用案例 获取详细信息)
ALTER TABLE MYStressedTable ADD PartID As CAST(ID % 8 AS TINYINT) PERSISTEND NOT NULL;
一旦这个语句完成,聚集索引只需要创建在分区上。
CREATE UNIQUE CLUSTERED INDEX cixMyStressedTable (ID, PartID) ON ps_spread(PartID);
现在,插入操作将在8个分区中循环,这些分区使得在闩锁发现之前,完成更多的插入操作成为可能。回到使用聚集索引中的例子,把每个分区比作一把椅子,七个分区就是七把椅子,每把椅子发生争用的概率也就小了,线程发生争用的可能性就大大降低了。
当然,增加分区可能在使用ID字段查找数据时会转化为更多的工作。尽管你能看到ID和分区之间的关联,但一个只在ID字段上过滤的查询将需要搜索所有8个分区,为了避免跨越所有分区搜索,类型的代码
SELECT * FROM dbo.MYStressedTable WHERE ID = @id;
应该改为:
SELECT * FROM dbo.MyStressedTable WHERE ID = @id AND PartID = CAST(@id % 8 As TINYINT)
队列
另一个可以展示大量闩锁争用的场景是一个设计成允许使用队列的系统,虽然已稍有已稍有不同的方式展现,但出于与上一个例子相同的原因,当然采用不同的方式解决这种争用。
大多数队列使用一个表处理,有大量的插入被用于将项目推送到队列中,使用TOP来删除,使得可以快速定位到表中的最前的行中。比如使用OUTPUT子句这样的技术有助于并发处理,除此之外,随着负荷的增加,这种设计还可以避免显示闩锁争用问题。(见下面的代码逻辑)
当然,和上面的例子一样,在叶级别会有PAGELATCH_EX闩锁 等待,但有时在页级别的活动会导致类似通过B树的更高级别,甚至根的活动,这意味着在插入和删除之前有潜在的争用,即使他们在B树中的相对两侧也同样如此。这种情况如下图所示。
此时需要注意,在执行一些插入和删除操作时需要在更高级别的B树上做的一些变化。在执行更新时根本不需要。除非更新导致由于页面比之前的页面更大而引起页面拆分,并且假如被更新的聚集索引键值没有变化,更新命令应该根本不会影响聚簇索引更高的级别,这类似于改变一本书中一页的信息。如果只有特定段落的信息被更新,目录不需要改变,也没有多余的页面被加入。
为此,一种避免这种闩锁争用方法是使用一些固定长度的列来填充一张表,然后循环更新它们。使用两个序列来帮助排队的存储过程知道队列顶部和底部的值。测量该队列的最大长度是很重要的。执行插入对B树产生的影响是十分显著的,应该使用一个小的规划来避免。
比如这样的做法就可以很好的工作(参考 Sequence Numbers):
-- dbo.seqQueuePush 和 dbo.seqQueuePop队列会在后面用到 CREATE SEQUENCE dbo.seqQueuePush start with 1 CACHE 1000; CREATE SEQUENCE dbo.seqQueuePop start with 1 CACHE 1000;
除非指定,否则使用bigint数据类型来创建序列,并且以尽可能低的数值开始。由于bigint的数字是非常大的,它可能从1开始逐步上升会更好点。无论哪种方式,重要的是队列开始是空的,两个序列中有相同的数字。生成下一个数字的瓶颈通过使用缓存来避免,你应该进行试验找到特定排队系统适合的缓存大小。
与用来表示队列的开头和结尾位置的标记一样,你需要一个表结构来保存它。例如,如果预期需要能够处理队列中的10 000条数据,你应该使用占位符创建10 000个位置。这能够在系统处于负荷之前,使B树增加到合适的大小。
下面的代码将创建队列,并用10 000个占位符条目填充它。
CREATE TABLE dbo.MyQueue (ID int, Avialable BIT, Message Char(7000)) --这里查sys.all_columns对象进行仅仅是为了凑数据量(达到10 000) Insert dbo.MyQueue select top (10000) Row_number() over(order by (select 1))-1, 1, '' FROM sys.all_columns t1,sys.all_columns t2
Message 已被选择为7000个字符,因为它很适合用在单个页面中。请注意这是CHAR(7000),而不是VARCHAR(7000),该行应该是固定长度(如果Message的长度不足7000, SQL Server会自动将其长度填充为7000,参见 【SqlServer】Sql Server 支持的数据类型)。你不想在此时施行压缩。使用BIT列指示位置在队列中的是否被占用,以防止队列被完全填满。
这些10 000个插槽的编号从0到9999。不断增加的序列将远远超出这个范围,但模函数将提供一个映射,使序列号从每1万条记录开始周而复始。(具体的映射见下面的UPDATE逻辑)
当第3 549 232条消息到达队列时,它将会被放入到插槽9232中(3 549 232 取余10 000得到9323)。如果在那个时候,第9 549 019条消息从队列中弹出,它会在插槽9019中被发现。这两个操作后,序列将准备告诉系统,用于放入的在下一个插槽3 549 233,用于弹出的在下一个插槽位于9 549 020,只要队列的大小不超过10 000,任何处理正在从队列中弹出消息的延迟都是没有问题的。
放入到队列中的消息仅递增序列,将序列号与10 000进行取模运算,得到消息应该放入的插槽位置,并运行一个UPDATE命令将消息放入适当插槽中:
DECLARE @pushpos INT = NEXT VALUE FOR dbo.seqQueuePush % 10000; UPDATE dbo.MyQueue SET Message = ' ', Available = 0 WHERE ID = @pushpos;
为从队列中弹出一条消息,可使用如下代码
DECLARE @poppos INT = NEXT VALUE FOR dbo.seqQueuePop % 10000; UPDATE dbo.Queue SET Message = '', Available = 1 OUTPUT deleted.Message WHERE ID = @poppos;
可进行一些测试,以确保队列不是空的,但这种技术一定能使在任何时间队列中多达10 000条信息成为可能,并将大量负荷分布到大量页面中。最重要的是,可以避免由执行插入和删除造成的在B树更高层级的负面影响。
本章前面提到过利用更新效率的环境。有需要很快被更新的数据,是使用更新而不是插入,如下图的动态管理视图 sys.dm_os_latch_stats(参考 sys.dm_os_latch_stats (Transact-SQL)):
这个动态管理不包含任何形式的ID字段,唯一的字段是latch_class, waiting_requests_counts, wait_time_ms 和 max_wait_time_ms,然而数据总是按顺序返回的,这个顺序是有意义的。BUFFER类总是排在第28行,ACCESS_METHODS_HOBT_VIRTUAL_ROOT始终是第5行(这是一个non_buffersh闩锁,展示了当根节点需要分割时的等待,在已经实施一个传统的队列删除/插入时将会发生)。
你可能已经注意到,当查询这个动态管理视图时,许多条目是零,但条目仍然存在。比方说,这与sys.dm_db_index_usage_stats不同,sys.dm_db_index_usage_stats一次只包括一行索引用于扫描(SCAN),检索(SEEK),查找(LOOKUP) 或 更新(UPDATE)操作。
动态管理视图sys.dm_os_latch_stats与队列结构相似。它需要许多像SQL Server内部机制做的那样,能够非常快速地响应。为了达到这个目的,设置BIT位比删除他们要更快。如果记录数据地速度是恒定不变地,递增一个已经占好空间的计数器是一个更好的选择,而不是直到需要的时候才试图去保留空间。
tempdb中的更新闩锁
请求正在等待的资源可能存在于tempdb中,而不是你设计的数据库中。可以通过查看sys.dm_exec_requests中的wait_resource字段看到这个情况,第一个数字表示数据库。数字2则表示tempdb有问题。(请移步sys.dm_exec_requests (Transact-SQL)获取详细的信息)
如果在tempdb中的任意文件的第一页上看到PAGELATCH_UP,也就是说,也 2:1:1 或 2:4:1 (本质上说,是2:N:1,N为任意数字),这就表明页可用空间页(Page Free Space, PFS)正在展示闩锁争用(请移步 SQL Server:Understanding The Page Free Space (PFS) Page 获取PFS详细信息)。可以通过查看sys.dm_os_buffer_descriptions来确定:
SELECT page_type FROM sys.dm_os_buffer_descriptors WHERE database_id = 2 AND page_id = 1;
对于tempdb中的任何类型的争用的常见反应是增加tempdb的文件数量。在多线程环境中有多个tempdb数据文件是很好的做法,但不断增加新的文件未必是解决这个文件的最佳方法。
无论何时数据插入到没有聚集索引的表(也就是堆)中,都必需更新PFS_PAGE资源。这并不意味着堆必然是坏事,在B树之外存储数据也有很多积极的方面。然而,无论何时插入一个操作完成,都必需访问PFS_PAGE,以便定位到有足够的自由空间可以插入的页面。
此时你可能会想,“但这是tempdb”,但你并没有为tempdb设计你的数据库,它被用来服务应用程序,并且你已经确定在自己数据库中没有闩锁争用。
这种类型的争用常见的一个原因是使用多语句表值函数。(请参见 多语句表值函数)
多语句表值函数声明了一个表变量,表变量由函数定义的代码所填充。最后,发出RETURN命令,将填充后的表变量返回给用户。这方面的例子可以在SQL Server联机丛书中看到。
与其相反的是一个内联表值函数(参考 内联表值函数),它的处理方式非常不同。
与标量函数(参考 SQL Server标量函数)相似,多语句表值函数在一个独立上下文中执行。这两种方法都使用BEGIN和END并非巧合,在很多方面都更类似于存储过程。内联函数不适用BEGIN和END,而更类似于一个在被提取到外部查询中(而不只是结果)的子查询中的视图。使用tempdb数据库存储多语句表值函数的结果,正是在这里可能会发生争用。
想象一个多语句表值函数用于关联子查询的情况,比如一个EXISTS子句,或在SELECT子句中。如果没有对函数进行简化的能力,查询优化器可能需要多次调用该函数。这通常出现在WHERE子句中使用的标量函数中,但当在FROM子句之外使用多语句表值函数时,也可以看到。
对多语句表值函数的结果使用tempdb的存储必须进行管理,这涉及PFS_PAGE资源(使用(UP)闩锁,因为正在更新的信息不是表中的数据,表中的数据将需要一个排他(EX)闩锁),它决定了新记录可以放在什么位置,并且一旦结果已经被外部查询锁使用,它将页面标志为空闲。甚至单个语句能够被多次调用的这样的函数结束,甚至是在单个查询中导致争用。
我敢肯定你能想到一些方式以避免这一争用。内联表值是有用的,重组查询以避免在EXISTS或SELECT子句使用函数也可以有效的。这是因为闩锁争用不但与数据库设计有关,也与查询被写入的方式相关。
名称解析中的自旋锁争用
遗憾的是,开发人员并不总是在查询中限定对象名称。在最初些SQL Server 2006或更早版本的早期应用程序中这是特别常见的,之后引入了架构(Schema),它也发生在其它许多系统中。很容易认为dbo是唯一使用的架构,并省略表名中的dbo.前缀,例如使用
SELECT * FROM Customers;
代替
SELECT * FROM dbo.Customers;
这是一个简单的错误,你可能没有注意到在系统中有任何明显的影响,直到它需要扩展时才会显示出来。但如果不指定架构,系统需要做一对快速检查。它必需确定默认的架构,并检查默认架构中是否有一个该名称的表。如果没有,它不得不检查dbo架构,看看是否这就是你所表达的意思。
所有的这一切都发生得很快,如此之快以至于使用了一个自旋锁。极少发现在这样的操作中不能立即获得一个自旋锁,但你可能会在负荷繁忙的系统看到发生这样的情况。这种争用在SOS_CACHESTORE自旋锁类型上出现。幸运的是,解决方法很简单;只要确保完全限定你的表名即可。
小结
闩锁争用并不能以锁同样的方式通过提升进行控制。闩锁被设计用于保护在SQL Server中保存数据的内部结构,闩锁是绝对必要的。
随着数据需求量的增加,以及越来越来多的处理器线程需要访问,甚至闩锁都开始争夺资源。良好的设计决策通常可以防止这些问题,但是,你应该能够通过适当的规划和认识来避免大多数的闩锁争用问题