我一直在阅读 Paul White 关于SQL Server Isolation Levels的一系列帖子,并遇到了一个短语:
为了强调这一点,用 T-SQL 编写的伪约束必须正确执行,无论可能发生什么并发修改。应用程序开发人员可能会使用 lock 语句来保护类似的敏感操作。T-SQL 程序员最接近风险存储过程和触发代码的工具是相对很少使用的
sp_getapplock
系统存储过程。这并不是说它是唯一的,甚至是首选的选择,只是它存在并且在某些情况下可能是正确的选择。
我正在使用sp_getapplock
,这让我想知道我是否正确使用它,或者有更好的方法来获得预期的效果。
我有一个 C++ 应用程序,可以 24/7 循环处理所谓的“构建服务器”。有一张表格,其中列出了这些建筑服务器(大约 200 行)。可以随时添加新行,但并不经常发生。行永远不会被删除,但它们可以被标记为非活动状态。处理一个服务器可能需要几秒到几十分钟,每个服务器都不一样,有的“小”,有的“大”。一旦服务器被处理,应用程序必须等待至少 20 分钟才能再次处理它(服务器不应该被轮询太频繁)。应用程序启动了 10 个并行执行处理的线程,但我必须保证没有两个线程尝试同时处理同一个服务器. 两台不同的服务器可以而且应该同时处理,但每台服务器的处理频率不得超过 20 分钟一次。
这是一个表的定义:
CREATE TABLE [dbo].[PortalBuildingServers](
[InternalIP] [varchar](64) NOT NULL,
[LastCheckStarted] [datetime] NOT NULL,
[LastCheckCompleted] [datetime] NOT NULL,
[IsActiveAndNotDisabled] [bit] NOT NULL,
[MaxBSMonitoringEventLogItemID] [bigint] NOT NULL,
CONSTRAINT [PK_PortalBuildingServers] PRIMARY KEY CLUSTERED
(
[InternalIP] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
CREATE NONCLUSTERED INDEX [IX_LastCheckCompleted] ON [dbo].[PortalBuildingServers]
(
[LastCheckCompleted] ASC
)
INCLUDE
(
[LastCheckStarted],
[IsActiveAndNotDisabled],
[MaxBSMonitoringEventLogItemID]
) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
应用程序中工作线程的主循环如下所示:
for(;;)
{
// Choose building server for checking
std::vector<SBuildingServer> vecBS = GetNextBSToCheck();
if (vecBS.size() == 1)
{
// do the check and don't go to sleep afterwards
SBuildingServer & bs = vecBS[0];
DoCheck(bs);
SetCheckComplete(bs);
}
else
{
// Sleep for a while
...
}
}
这里有两个函数GetNextBSToCheck
,SetCheckComplete
分别调用对应的存储过程。
GetNextBSToCheck
返回 0 或 1 行,其中包含接下来应处理的服务器的详细信息。它是一个很长时间没有被处理的服务器。如果这个“最旧的”服务器在不到 20 分钟前被处理,则不会返回任何行,线程将等待一分钟。
SetCheckComplete
设置处理完成的时间,因此可以在 20 分钟后再次选择此服务器进行处理。
最后是存储过程的代码:
GetNextToCheck
:
CREATE PROCEDURE [dbo].[GetNextToCheck]
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRANSACTION;
BEGIN TRY
DECLARE @VarInternalIP varchar(64) = NULL;
DECLARE @VarMaxBSMonitoringEventLogItemID bigint = NULL;
DECLARE @VarLockResult int;
EXEC @VarLockResult = sp_getapplock
@Resource = 'PortalBSChecking_app_lock',
@LockMode = 'Exclusive',
@LockOwner = 'Transaction',
@LockTimeout = 60000,
@DbPrincipal = 'public';
IF @VarLockResult >= 0
BEGIN
-- Acquired the lock
-- Find BS that wasn't checked for the longest period
SELECT TOP 1
@VarInternalIP = InternalIP
,@VarMaxBSMonitoringEventLogItemID = MaxBSMonitoringEventLogItemID
FROM
dbo.PortalBuildingServers
WHERE
LastCheckStarted <= LastCheckCompleted
-- this BS is not being checked right now
AND LastCheckCompleted < DATEADD(minute, -20, GETDATE())
-- last check was done more than 20 minutes ago
AND IsActiveAndNotDisabled = 1
ORDER BY LastCheckCompleted
;
-- Start checking the found BS
UPDATE dbo.PortalBuildingServers
SET LastCheckStarted = GETDATE()
WHERE InternalIP = @VarInternalIP;
-- There is no need to explicitly verify if we found anything.
-- If @VarInternalIP is null, no rows will be updated
END;
-- Return found BS,
-- or no rows if nothing was found, or failed to acquire the lock
SELECT
@VarInternalIP AS InternalIP
,@VarMaxBSMonitoringEventLogItemID AS MaxBSMonitoringEventLogItemID
WHERE
@VarInternalIP IS NOT NULL
AND @VarMaxBSMonitoringEventLogItemID IS NOT NULL
;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
END CATCH;
END
SetCheckComplete
:
CREATE PROCEDURE [dbo].[SetCheckComplete]
@ParamInternalIP varchar(64)
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRANSACTION;
BEGIN TRY
DECLARE @VarLockResult int;
EXEC @VarLockResult = sp_getapplock
@Resource = 'PortalBSChecking_app_lock',
@LockMode = 'Exclusive',
@LockOwner = 'Transaction',
@LockTimeout = 60000,
@DbPrincipal = 'public';
IF @VarLockResult >= 0
BEGIN
-- Acquired the lock
-- Completed checking the given BS
UPDATE dbo.PortalBuildingServers
SET LastCheckCompleted = GETDATE()
WHERE InternalIP = @ParamInternalIP;
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
END CATCH;
END
如您所见,我曾经sp_getapplock
保证在任何给定时间只有这两个存储过程的一个实例在运行。我想我需要sp_getapplock
在这两个过程中使用,因为选择“最旧”服务器的查询使用LastCheckCompleted
时间,该时间由SetCheckComplete
.
我认为这段代码确实保证没有两个线程同时尝试处理同一个服务器,但如果您能指出这段代码和整体方法的任何问题,我将不胜感激。那么,第一个问题:这种方法正确吗?
另外,我想知道不使用 sp_getapplock
. 第二个问题:有没有更好的方法?
是的。它满足问题中所述的所有目标。
过程中的注释以解释策略并注意相关过程名称可能有助于其他人将来的维护。
在我看来,没有。
获取单个锁是一个非常快的操作,并且逻辑非常清晰。我不清楚在第二个过程中获取锁是多余的,但即使是,省略它你真正获得了什么?您实施的简单性和安全性吸引了我。
替代方案要复杂得多,可能会让您想知道您是否真正涵盖了所有情况,或者将来内部引擎细节是否可能会发生变化,从而打破(可能是微妙和未说明的)假设。
如果您需要更传统的队列实现,以下参考非常有用:
Remus Rusanu使用表作为队列
这种情况似乎与以下问题非常相似:
“签出”记录以进行处理的策略
在我的回答中,我提倡一个类似于你在这里的模型,但
sp_applock
只有在最初的概念不是防弹的情况下才包含作为故障安全的概念。“签出”过程的主要区别在于我使用 CTE 和子句组合了
SELECT
和查询。这与 上的适当查询提示一起,允许更新用于确定行是否有资格进行处理的字段,同时返回该值,以便可以将其返回到调用进程。结合这两个步骤,没有应用锁应该没问题。反过来,摆脱应用程序锁定应该允许更大的吞吐量,因为在当前模型中执行“签出”过程的任何单个线程(如问题中所述)都会导致其余 9 个线程等待,即使它们可以几乎同时抓住下一个排队的人。UPDATE
OUTPUT
(READPAST, ROWLOCK, UPDLOCK)
SELECT
关于问题末尾的以下陈述:
我想说的是,无论您是保留当前方法还是切换到“通过 CTE + OUTPUT 子句组合 SELECT + UPDATE”(tm)方法,
sp_getapplock
在SetCheckComplete
存储过程中使用在逻辑上都是不必要的。不需要它的原因是:SetCheckComplete
意味着 的值LastCheckCompleted
可以对GetNextToCheck
, 然而:LastCheckStarted
字段将 > theLastCheckCompleted
,并且此状态会导致该记录因LastCheckStarted <= LastCheckCompleted
条件而被过滤出“GetNext”查询LastCheckStarted <= LastCheckCompleted
将不再过滤掉记录,但LastCheckCompleted < DATEADD(minute, -20, GETDATE())
条件会将其过滤掉,因为根据定义,它在查询运行前几毫秒就完成了。所以,这
SetCheckComplete
真的完全独立于GetNextToCheck
过程。只有GetNextToCheck
过程需要添加任何数量的保护措施。删除应用程序锁
SetCheckComplete
不仅应该是完全安全的,而且还会增加吞吐量,因为它会减少对任意锁的争用@Resource
(同样,无论您是保留当前模型还是切换到我建议的模型)。更新
对此答案的评论问题:
我的理解是,在单个对象(堆或索引)内这是不可能的,但在多个对象之间这并非不可能。查看架构,您确实有一个关于
LastCheckCompleted
. 所以有两件事:LastCheckCompleted
进行更新,但我认为表(聚集索引)会在非聚集索引之前首先更新,但是对于这种情况来说真的,它必须从非聚集索引中获取值,对吗?LastCheckCompleted
DATETIME
字段,NULL
用于“签出”或签入时间。然后只需检查 new_field <= 20 分钟前,NULL
(即签出)行无论如何都不会匹配(除非有人傻到转身ANSI_NULLS OFF
;-)。