Estive lendo uma série de posts de Paul White sobre SQL Server Isolation Levels e me deparei com uma frase :
Para enfatizar o ponto, as pseudo-restrições escritas em T-SQL devem ser executadas corretamente, independentemente das modificações simultâneas que possam estar ocorrendo. Um desenvolvedor de aplicativo pode proteger uma operação confidencial como essa com uma instrução de bloqueio. A coisa mais próxima que os programadores T-SQL têm dessa facilidade para procedimento armazenado em risco e código de gatilho é o
sp_getapplock
procedimento armazenado do sistema comparativamente raramente usado. Isso não quer dizer que seja a única opção, ou mesmo preferida, apenas que existe e pode ser a escolha certa em algumas circunstâncias.
Estou usando sp_getapplock
e isso me fez pensar se estou usando corretamente ou se existe uma maneira melhor de obter o efeito desejado.
Eu tenho um aplicativo C++ que processa os chamados "servidores de construção" em um loop 24 horas por dia, 7 dias por semana. Existe uma tabela com a lista desses Servidores do Edifício (cerca de 200 linhas). Novas linhas podem ser adicionadas a qualquer momento, mas isso não acontece com frequência. As linhas nunca são excluídas, mas podem ser marcadas como inativas. O processamento de um servidor pode levar de alguns segundos a dezenas de minutos, cada servidor é diferente, alguns são "pequenos", alguns são "grandes". Depois que um servidor é processado, o aplicativo precisa esperar pelo menos 20 minutos antes de processá-lo novamente (os servidores não devem ser pesquisados com muita frequência). O aplicativo inicia 10 threads que executam o processamento em paralelo, mas devo garantir que dois threads não tentem processar o mesmo servidor ao mesmo tempo. Dois servidores diferentes podem e devem ser processados simultaneamente, mas cada servidor pode ser processado não mais do que uma vez a cada 20 minutos.
Aqui está a definição de uma tabela:
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]
O loop principal de um thread de trabalho em um aplicativo se parece com isso:
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
...
}
}
Duas funções aqui GetNextBSToCheck
e SetCheckComplete
estão chamando procedimentos armazenados correspondentes.
GetNextBSToCheck
retorna 0 ou 1 linha com detalhes do servidor que deve ser processado a seguir. É um servidor que não é processado há muito tempo. Se este servidor "mais antigo" tiver sido processado há menos de 20 minutos, nenhuma linha será retornada e o encadeamento aguardará um minuto.
SetCheckComplete
define a hora em que o processamento foi concluído, tornando possível escolher este servidor para processamento novamente após 20 minutos.
Finalmente, o código de stored procedures:
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
Como você pode ver, eu uso sp_getapplock
para garantir que apenas uma instância de ambos os procedimentos armazenados esteja em execução a qualquer momento. Acho que preciso usar sp_getapplock
nas duas procedures, pois a consulta que escolhe o servidor "mais antigo" usa o LastCheckCompleted
horário, que é atualizado por SetCheckComplete
.
Acho que esse código garante que dois threads não tentem processar o mesmo servidor ao mesmo tempo, mas ficaria grato se você pudesse apontar algum problema com esse código e a abordagem geral. Então, a primeira pergunta: esta abordagem está correta?
Além disso, gostaria de saber se o mesmo efeito poderia ser alcançado sem usar sp_getapplock
. A segunda pergunta: Existe uma maneira melhor?
Sim. Atende a todos os objetivos declarados na pergunta.
Um comentário nos procedimentos para explicar a estratégia e observar o nome do procedimento relacionado pode ser útil para futuras manutenções por outros.
Na minha opinião, não.
Tirar um único bloqueio é uma operação extremamente rápida e resulta em uma lógica muito clara. Não está claro para mim que pegar o bloqueio no segundo procedimento seja redundante, mas mesmo que seja, o que você realmente ganha ao omiti-lo? A simplicidade e segurança de sua implementação me atraem.
As alternativas são muito mais complexas e podem deixar você se perguntando se realmente cobriu todos os casos ou se pode haver uma alteração nos detalhes internos do mecanismo no futuro que quebraria suposições (talvez sutis e não declaradas).
Se você precisar de uma implementação de enfileiramento mais tradicional, a seguinte referência é muito útil:
Usando tabelas como filas por Remus Rusanu
Este cenário parece muito semelhante à seguinte pergunta:
Estratégias para “check-out” de registros para processamento
Na minha resposta, defendi um modelo semelhante ao que você tem aqui, mas com a noção de incluir
sp_applock
como um fail-safe apenas se o conceito inicial não fosse à prova de balas.A principal diferença no processo de "check-out" foi que combinei as consultas
SELECT
eUPDATE
usando um CTE e aOUTPUT
cláusula. Isso, com as dicas de consulta apropriadas(READPAST, ROWLOCK, UPDLOCK)
noSELECT
, permite atualizar o campo usado para determinar se uma linha é elegível para processamento e, ao mesmo tempo, retornar esse valor para que possa ser retornado ao processo de chamada. Com essas duas etapas combinadas, não há problema em fazer sem o bloqueio do aplicativo. E livrar-se do bloqueio do aplicativo deve, por sua vez, permitir maior rendimento, pois qualquer thread individual fazendo o processo de "check-out" no modelo atual (conforme postado na pergunta) faz com que os 9 threads restantes esperem, mesmo que eles possam estar agarrando o(s) próximo(s) na fila praticamente ao mesmo tempo.Sobre a seguinte declaração no final da pergunta:
Eu diria que se você mantiver a abordagem atual ou alternar para a abordagem "SELECT + UPDATE combinada via cláusula CTE + OUTPUT" (tm), o uso de
sp_getapplock
noSetCheckComplete
procedimento armazenado é logicamente desnecessário. A razão pela qual não é necessário é:SetCheckComplete
implica que o valor deLastCheckCompleted
pode ter um efeito determinante noGetNextToCheck
, ainda:LastCheckStarted
campo será > theLastCheckCompleted
, e esse estado faz com que o registro seja filtrado da consulta "GetNext" devido àLastCheckStarted <= LastCheckCompleted
condiçãoLastCheckStarted <= LastCheckCompleted
não filtrará mais o registro, mas aLastCheckCompleted < DATEADD(minute, -20, GETDATE())
condição o filtrará, pois, por definição, foi concluído meros milissegundos antes da execução dessa consulta.Então, o
SetCheckComplete
é realmente completamente independente doGetNextToCheck
processo. É apenas oGetNextToCheck
processo que precisa de qualquer quantidade de salvaguardas adicionadas a ele.A remoção do bloqueio do aplicativo
SetCheckComplete
não deve ser apenas completamente segura, mas também aumentaria a taxa de transferência, pois haveria menos contenção nesse bloqueio arbitrário@Resource
(novamente, mantendo ou não o modelo atual ou mudando para o que sugeri).ATUALIZAR
Pergunta do comentário sobre esta resposta:
Meu entendimento é que dentro de um único objeto (heap ou índice) isso não seria possível, mas entre vários objetos isso não é impossível . E olhando para o esquema, você tem um índice em
LastCheckCompleted
. Então duas coisas:LastCheckCompleted
ser atualizado após a verificação da primeira condição, mas acho que a tabela (Índice Clusterizado) seria atualizada primeiro, antes do Índice NonClustered, mas ainda para esse cenário por vir true teria que pegar o valor doLastCheckCompleted
NonClustered Index, certo?DATETIME
campo que sejaNULL
para "check-out" ou check-in. Em seguida, basta verificar new_field <= 20 minutos atrás e asNULL
linhas (ou seja, com check-out) não corresponderão de qualquer maneira (a menos que alguém seja bobo o suficiente para virarANSI_NULLS OFF
;-).