Estratégias para "fazer check-out" de registros para processamento

10

Não tenho certeza se existe um padrão nomeado para isso ou se não existe porque é uma péssima ideia. Mas preciso que meu serviço opere em um ambiente de carga ativa / ativa. Este é apenas o servidor do aplicativo. O banco de dados estará em um servidor separado. Eu tenho um serviço que precisará executar um processo para cada registro em uma tabela. Esse processo pode levar um ou dois minutos e se repetirá a cada n minutos (configurável, geralmente 15 minutos).

Com uma tabela de 1000 registros que precisa desse processamento e dois serviços executados nesse mesmo conjunto de dados, eu gostaria que cada serviço "fizesse check-out" de um registro para processar. Preciso garantir que apenas um serviço / thread esteja processando cada registro por vez.

Eu tenho colegas que usaram uma "tabela de bloqueio" no passado. Onde um registro é gravado nessa tabela para bloquear logicamente o registro na outra tabela (essa outra tabela é bastante estática, e com um novo registro muito ocasional adicionado) e, em seguida, excluída para liberar o bloqueio.

Gostaria de saber se não seria melhor para a nova tabela ter uma coluna que indica quando foi bloqueada e que está atualmente bloqueada, em vez de inserir uma exclusão constantemente.

Alguém tem dicas para esse tipo de coisa? Existe um padrão estabelecido para o bloqueio lógico de longo prazo (ish)? Alguma dica de como garantir que apenas um serviço agarre a fechadura por vez? (Meu colega usa TABLOCKX para bloquear a tabela inteira.)

reitor
fonte

Respostas:

12

Eu não sou um grande fã da mesa extra "lock" ou da idéia de bloquear a mesa inteira para pegar o próximo disco. Entendo por que isso está sendo feito, mas isso também prejudica a simultaneidade das operações que estão sendo atualizadas para liberar um registro bloqueado (certamente dois processos não podem estar lutando contra isso quando não é possível que dois processos tenham bloqueado o mesmo registro no mesmo tempo).

Minha preferência seria adicionar uma coluna ProcessStatusID (normalmente TINYINT) à tabela com os dados sendo processados. E existe um campo para LastModifiedDate? Caso contrário, deve ser adicionado. Se sim, esses registros são atualizados fora desse processamento? Se os registros puderem ser atualizados fora desse processo específico, outro campo deverá ser adicionado para rastrear StatusModifiedDate (ou algo assim). No restante desta resposta, usarei apenas "StatusModifiedDate", pois é claro em seu significado (e, de fato, poderia ser usado como o nome do campo, mesmo que atualmente não exista campo "LastModifiedDate").

Os valores para ProcessStatusID (que devem ser colocados em uma nova tabela de pesquisa chamada "ProcessStatus" e Foreign Keyed para esta tabela) podem ser:

  1. Concluído (ou mesmo "Pendente", neste caso, pois ambos significam "pronto para ser processado")
  2. Em processo (ou "Processando")
  3. Erro (ou "WTF?")

Neste ponto, parece seguro supor que, a partir do aplicativo, ele só queira pegar o próximo registro para processar e não passará nada para ajudar a tomar essa decisão. Portanto, queremos pegar o registro mais antigo (pelo menos em termos de StatusModifiedDate) definido como "Concluído" / "Pendente". Algo ao longo das linhas de:

SELECT TOP 1 pt.RecordID
FROM   ProcessTable pt
WHERE  pt.StatusID = 1
ORDER BY pt.StatusModifiedDate ASC;

Também queremos atualizar esse registro para "Em processo" ao mesmo tempo para impedir que o outro processo o pegue. Poderíamos usar a OUTPUTcláusula para nos permitir fazer o UPDATE e SELECT na mesma transação:

UPDATE TOP (1) pt
SET    pt.StatusID = 2,
       pt.StatusModifiedDate = GETDATE() -- or GETUTCDATE()
OUTPUT INSERTED.RecordID
FROM   ProcessTable pt
WHERE  pt.StatusID = 1;

O principal problema aqui é que, embora possamos fazer um TOP (1)em uma UPDATEoperação, não há como fazê-lo ORDER BY. Mas, podemos envolvê-lo em um CTE para combinar esses dois conceitos:

;WITH cte AS
(
   SELECT TOP 1 pt.RecordID
   FROM   ProcessTable pt (READPAST, ROWLOCK, UPDLOCK)
   WHERE  pt.StatusID = 1
   ORDER BY pt.StatusModifiedDate ASC;
)
UPDATE cte
SET    cte.StatusID = 2,
       cte.StatusModifiedDate = GETDATE() -- or GETUTCDATE()
OUTPUT INSERTED.RecordID;

A questão óbvia é se dois processos que executam o SELECT ao mesmo tempo podem pegar o mesmo registro. Tenho certeza de que a cláusula UPDATE with OUTPUT, especialmente combinada com as dicas READPAST e UPDLOCK (veja abaixo para obter mais detalhes), ficará bem. No entanto, eu não testei esse cenário exato. Se, por algum motivo, a consulta acima não atender à condição de corrida, adicione o seguinte: bloqueios de aplicativo.

A consulta CTE acima pode ser agrupada em sp_getapplock e sp_releaseapplock para criar um " gatekeeper " para o processo. Ao fazer isso, apenas um processo de cada vez poderá entrar para executar a consulta acima. Os outros processos serão bloqueados até que o processo com o applock o libere. E como essa etapa do processo geral é apenas para capturar o RecordID, é bastante rápida e não bloqueará os outros processos por muito tempo. E, assim como com a consulta CTE, estamos não bloquear a tabela inteira, permitindo assim que outras atualizações para outras linhas (para definir seu status a qualquer "Concluído" ou "Erro"). Essencialmente:

BEGIN TRANSACTION;
EXEC sp_getapplock @Resource = 'GetNextRecordToProcess', @LockMode = 'Exclusive';

   {CTE UPDATE query shown above}

EXEC sp_releaseapplock @Resource = 'GetNextRecordToProcess';
COMMIT TRANSACTION;

Os bloqueios de aplicativos são muito bons, mas devem ser usados ​​com moderação.

Por fim, você só precisa de um procedimento armazenado para lidar com a configuração do status como "Concluído" ou "Erro". E isso pode ser simples:

CREATE PROCEDURE ProcessTable_SetProcessStatusID
(
   @RecordID INT,
   @ProcessStatusID TINYINT
)
AS
SET NOCOUNT ON;

UPDATE pt
SET    pt.ProcessStatusID = @ProcessStatusID,
       pt.StatusModifiedDate = GETDATE() -- or GETUTCDATE()
FROM   ProcessTable pt
WHERE  pt.RecordID = @RecordID;

Dicas de tabela (encontradas em Dicas (Transact-SQL) - Tabela ):

  • READPAST (parece se encaixar nesse cenário exato)

    Especifica que o Mecanismo de Banco de Dados não lê linhas bloqueadas por outras transações. Quando READPAST é especificado, os bloqueios no nível da linha são ignorados. Ou seja, o Mecanismo de Banco de Dados ignora as linhas em vez de bloquear a transação atual até que os bloqueios sejam liberados ... READPAST é usado principalmente para reduzir a contenção de bloqueio ao implementar uma fila de trabalho que usa uma tabela do SQL Server. Um leitor de fila que usa READPAST ignora as entradas da fila passadas bloqueadas por outras transações para a próxima entrada da fila disponível, sem ter que esperar até que as outras transações liberem seus bloqueios.

  • ROWLOCK (apenas por segurança)

    Especifica que os bloqueios de linha são executados quando bloqueios de página ou tabela são normalmente executados.

  • UPDLOCK

    Especifica que os bloqueios de atualização devem ser obtidos e retidos até a transação ser concluída. UPDLOCK recebe bloqueios de atualização para operações de leitura apenas no nível da linha ou da página.

Solomon Rutzky
fonte
1

Fiz algo semelhante (sem aplicativos, puramente dentro do DB) usando filas do Service Broker. Leve, totalmente compatível com ACID, pode ser ampliado quase infinitamente. O bloqueio de linha transparente (ou "oculto") é incorporado. Disponível a partir da versão 2005.

No seu caso, a arquitetura geral pode ser assim: alguns processos enviam mensagens para as caixas de diálogo do Service Broker, de acordo com seus planejamentos, e os ouvintes as buscam na fila do lado do destino. Além de criar tipos de mensagens separados, você pode incluir praticamente qualquer coisa no corpo da mensagem - tempo limite, por exemplo, e quaisquer parâmetros que a tarefa possa ter.

Não é a coisa mais fácil de entender, com certeza, mas quando você conseguir, suas vantagens se tornarão aparentes.

Roger Wolf
fonte