为什么原子语句需要锁提示?

Why are lock hints needed on an atomic statement?

问题

对以下语句应用锁有什么好处?

同样,如果我们不包括这些提示,我们会​​看到什么问题?也就是说,它们是防止竞争条件、提高性能还是其他什么?询问它们可能是为了防止一些我没有考虑过的问题,而不是我假设的竞争条件。

注意:这是此处提出的问题的溢出:SQL Threadsafe UPDATE TOP 1 for FIFO Queue

有问题的声明

WITH nextRecordToProcess AS
(
    SELECT TOP(1) Id, StatusId
    FROM    DemoQueue
    WHERE   StatusId = 1 --Ready for processing
    ORDER BY DateSubmitted, Id 
)
UPDATE nextRecordToProcess
SET StatusId = 2 --Processing
OUTPUT Inserted.Id 

要求

附加 SQL 上下文:

CREATE TABLE Statuses
(
    Id SMALLINT NOT NULL PRIMARY KEY CLUSTERED
    , Name NVARCHAR(32) NOT NULL UNIQUE
)
GO
INSERT Statuses (Id, Name)
VALUES (0,'Draft')
, (1,'Ready')
, (2,'Processing')
, (3,'Processed')
, (4,'Error')
GO
CREATE TABLE DemoQueue
(
    Id BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY CLUSTERED
    , StatusId SMALLINT NOT NULL FOREIGN KEY REFERENCES Statuses(Id)
    , DateSubmitted DATETIME --will be null for all records with status 'Draft'
)
GO

建议声明

在各种讨论队列的博客中,以及引起此讨论的问题中,建议将上述语句更改为包含如下锁提示:

WITH nextRecordToProcess AS
(
    SELECT TOP(1) Id, StatusId
    FROM    DemoQueue WITH (UPDLOCK, ROWLOCK, READPAST)
    WHERE   StatusId = 1 --Ready for processing
    ORDER BY DateSubmitted, Id 
)
UPDATE nextRecordToProcess
SET StatusId = 2 --Processing
OUTPUT Inserted.Id 

我的理解

我知道需要锁定这些提示的好处是:

即如果我们 运行 下面的代码我认为这是有道理的:

DECLARE @nextRecordToProcess BIGINT

BEGIN TRANSACTION

SELECT TOP (1) @nextRecordToProcess = Id
FROM    DemoQueue WITH (UPDLOCK, ROWLOCK, READPAST)
WHERE   StatusId = 1 --Ready for processing
ORDER BY DateSubmitted, Id 

--and then in a separate statement

UPDATE DemoQueue
SET StatusId = 2 --Processing
WHERE Id = @nextRecordToProcess

COMMIT TRANSACTION

--@nextRecordToProcess is then returned either as an out parameter or by including a `select @nextRecordToProcess Id`

然而,当 select 和更新出现在同一条语句中时,我假设没有其他会话可以在我们会话的读取和更新之间读取相同的记录;所以不需要明确的锁定提示。

我是否从根本上误解了锁的工作原理?或者这些提示的建议是否与其他一些相似但不同的用例相关?

tl;博士

用于高并发专用队列table场景下的性能优化。

详细

我想我已经通过寻找 related SO answer by this quoted blog's 作者找到了答案。

这个建议似乎是针对一个非常具体的场景;其中 table 用作队列 dedicated 作为队列;即 table 不用于任何其他目的。在这种情况下,锁定提示是有意义的。它们与防止竞争条件无关;他们将通过避免(非常短期的)阻塞来提高高并发场景中的性能。

  • ReadPast锁提高了高并发场景下的性能;无需等待当前读取的记录被释放;唯一锁定它的将是另一个 "Queue Worker" 进程,因此我们可以安全地跳过知道该工作人员正在处理此记录。
  • RowLock 确保我们一次不会锁定超过一行,因此下一个请求消息的工作人员将获得下一条记录,而不是跳过几条记录,因为它们在一个锁定记录的页面。
  • UpdLock用于获取锁;即 RowLock 说要锁定什么但没有说必须有锁,并且 ReadPast 确定遇到其他锁定记录时的行为,因此不会再次导致锁定当前记录。我怀疑这不是明确需要的,因为 SQL 无论如何都会在后台获取它(事实上,在链接的 SO 答案中只指定了 ReadPast);但被包含在 post 块中是为了完整性/显式显示 SQL 无论如何都会在后台隐式导致的锁定。

但是 post 是为 专用队列 table 编写的。在 table 用于其他用途的地方(例如,在 the original question 中,它是一个 table 持有发票数据,恰好有一列用于跟踪已打印的内容),该建议可能不可取。即通过使用 ReadPast 锁,您将跳过所有锁定的记录;并且不能保证这些记录被处理您队列的另一个工作人员锁定;它们可能出于某些完全不相关的目的而被锁定。这将打破 FIFO 要求。

鉴于此,我认为 my answer 支持链接问题。即要么创建一个专用的 table 来处理队列场景,要么考虑其他选项及其在上下文或您的场景中的优缺点。

约翰是对的,因为这些都是优化,但在 SQL 世界中,这些优化可能意味着 'fast' 与 'unbearable size-of-data slow' 之间的差异 and/or 'works' 与 'unusable deadlock mess'.

过去的提示很清楚。对于另外两个,我觉得我需要添加更多的上下文:

  • ROWLOCK提示是为了防止页锁粒度扫描。锁定粒度(行与页)是在查询开始时预先确定的,并且基于对查询将扫描的页数的估计(第三个粒度,table,仅在特殊情况下使用,并且不适用于此处)。通常,出列操作永远不必扫描如此多的页面,以便引擎考虑页面粒度。但是我已经看到 'in the wild' 引擎决定使用页面锁定粒度的情况,这会导致出队中的阻塞和死锁
  • 需要UPDLOCK来防止升级锁死锁的情况。 UPDATE 语句在逻辑上分为搜索需要更新的行然后更新行。搜索需要锁定它评估的行。如果该行符合条件(满足 WHERE 条件),则更新该行,并且更新始终是独占锁。所以问题是你如何在搜索过程中锁定行?如果您使用共享锁,则两个 UPDATE 将查看同一行(它们可以,因为共享锁允许它们),两者都确定该行符合条件,并且都尝试将锁升级为独占 -> 死锁。如果在搜索过程中使用排他锁,则不会发生死锁,但是 UPDATE 将在与任何其他读取评估的所有行上发生冲突,即使该行不符合条件(更不用说排他锁不能提前释放 w/o打破 two-phase-locking)。这就是为什么有一个 U 模式锁,它与 Shared 兼容(这样候选行的 UPDATE 评估不会阻塞读取)但与另一个 U 不兼容(这样两个 UPDATE 就不会死锁)。典型的基于 CTE 的出队需要这个提示有两个原因:

    1. 因为是 CTE,查询处理并不总是理解 CTE 内的 SELECT 是 UPDATE 的目标,应该使用 U 模式锁和
    2. 出列操作将始终在更新相同的行(行是 'dequeued')之后进行,因此经常发生死锁。