Procedimento armazenado do banco de dados com um "modo de visualização"

15

Um padrão bastante comum no aplicativo de banco de dados com o qual trabalho é a necessidade de criar um procedimento armazenado para um relatório ou utilitário que tenha um "modo de visualização". Quando esse procedimento faz atualizações, esse parâmetro indica que os resultados da ação devem ser retornados, mas o procedimento não deve realmente executar as atualizações no banco de dados.

Uma maneira de conseguir isso é simplesmente escrever uma ifinstrução para o parâmetro e ter dois blocos de código completos; um deles atualiza e retorna dados e o outro apenas retorna os dados. Mas isso é indesejável devido à duplicação de código e a um nível relativamente baixo de confiança de que os dados de visualização são na verdade um reflexo preciso do que aconteceria com uma atualização.

O exemplo a seguir tenta aproveitar pontos de salvamento e variáveis ​​de transação (que não são afetados por transações, em contraste com as tabelas temporárias que são) para usar apenas um único bloco de código para o modo de visualização como o modo de atualização ao vivo.

Nota: Reversões de transação não são uma opção, pois essa chamada de procedimento pode ser aninhada em uma transação. Isso é testado no SQL Server 2012.

CREATE TABLE dbo.user_table (a int);
GO

CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE] (
  @preview char(1) = 'Y'
) AS

CREATE TABLE #dataset_to_return (a int);

BEGIN TRANSACTION; -- preview mode required infrastructure
  DECLARE @output_to_return TABLE (a int);
  SAVE TRANSACTION savepoint;

  -- do stuff here
  INSERT INTO dbo.user_table (a)
    OUTPUT inserted.a INTO @output_to_return (a)
    VALUES (42);

  -- catch preview mode
  IF @preview = 'Y'
    ROLLBACK TRANSACTION savepoint;

  -- save output to temp table if used for return data
  INSERT INTO #dataset_to_return (a)
  SELECT a FROM @output_to_return;
COMMIT TRANSACTION;

SELECT a AS proc_return_data FROM #dataset_to_return;
RETURN 0;
GO

-- Examples
EXEC dbo.PREVIEW_EXAMPLE @preview = 'Y';
SELECT a AS user_table_after_preview_mode FROM user_table;

EXEC dbo.PREVIEW_EXAMPLE @preview = 'N';
SELECT a AS user_table_after_live_mode FROM user_table;

-- Cleanup
DROP TABLE dbo.user_table;
DROP PROCEDURE dbo.PREVIEW_EXAMPLE;
GO

Estou procurando feedback sobre esse código e padrão de design e / ou se existem outras soluções para o mesmo problema em diferentes formatos.

NReilingh
fonte

Respostas:

12

Existem várias falhas nessa abordagem:

  1. O termo "visualização" pode ser bastante enganador na maioria dos casos, dependendo da natureza dos dados em operação (e que muda de operação para operação). O que é garantir que os dados atuais em operação estejam no mesmo estado entre o momento em que os dados de "visualização" são coletados e quando o usuário volta 15 minutos depois - depois de pegar um café, sair para fumar, caminhar ao redor do quarteirão, voltando e verificando algo no eBay - e percebe que eles não clicaram no botão "OK" para realmente executar a operação e, finalmente, clicam no botão?

    Você tem um prazo para prosseguir com a operação após a geração da visualização? Ou, possivelmente, uma maneira de determinar se os dados estão no mesmo estado no momento da modificação e no SELECTtempo inicial ?

  2. Esse é um ponto secundário, pois o código de exemplo poderia ter sido feito às pressas e não representar um caso de uso real, mas por que haveria uma "Visualização" para uma INSERToperação? Isso pode fazer sentido ao inserir várias linhas por meio de algo como INSERT...SELECTe pode haver um número variável de linhas inseridas, mas isso não faz muito sentido para uma operação singleton.

  3. isso é indesejável por causa de ... um grau relativamente baixo de confiança de que os dados da visualização são na verdade um reflexo preciso do que aconteceria com uma atualização.

    De onde exatamente esse "baixo grau de confiança" vem? Embora seja possível atualizar um número diferente de linhas do que é exibido SELECTquando várias tabelas são JOINed e há duplicação de linhas em um conjunto de resultados, isso não deve ser um problema aqui. Quaisquer linhas que devem ser afetadas por um UPDATEsão selecionáveis ​​por conta própria. Se houver uma incompatibilidade, você está fazendo a consulta incorretamente.

    E aquelas situações em que há duplicação devido a uma tabela JOINed que corresponde a várias linhas na tabela que serão atualizadas não são situações em que uma "Visualização" seria gerada. E se houver uma ocasião em que esse for o caso, será necessário explicar ao usuário que eles atualizam um subconjunto do relatório que é repetido no relatório para que não pareça ser um erro se alguém estiver apenas olhando para o número de linhas afetadas.

  4. Por uma questão de integridade (mesmo que as outras respostas mencionem isso), você não está usando a TRY...CATCHconstrução, por isso pode facilmente encontrar problemas ao aninhar essas chamadas (mesmo que não esteja usando Save Points e mesmo que não esteja usando Transações). Por favor, veja minha resposta para a seguinte pergunta, aqui no DBA.SE, para um modelo que manipula transações entre chamadas aninhadas de procedimento armazenado:

    Somos obrigados a lidar com a transação no código C #, bem como no procedimento armazenado

  5. MESMO, se os problemas mencionados acima foram considerados, ainda há uma falha crítica: durante o curto período de tempo em que a operação está sendo executada (ou seja, antes da ROLLBACK), qualquer consulta de leitura suja (consulta usando WITH (NOLOCK)ou SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED) pode obter dados que não existe um momento depois. Enquanto qualquer pessoa que use consultas de leitura suja já deve estar ciente disso e ter aceito essa possibilidade, operações como essa aumentam muito as chances de introduzir anomalias de dados que são muito difíceis de depurar (ou seja: quanto tempo você deseja gastar tentando encontrar um problema que não tem causa direta aparente?).

  6. Um padrão como esse também prejudica o desempenho do sistema, aumentando o bloqueio, eliminando mais bloqueios e gerando mais atividade no log de transações. (Vejo agora que o @MartinSmith também mencionou esses dois problemas em um comentário sobre a questão.)

    Além disso, se houver Triggers nas tabelas que estão sendo modificadas, isso poderá exigir bastante processamento adicional (CPU e leituras físicas / lógicas). Os gatilhos também aumentariam ainda mais as chances de anomalias de dados resultantes de leituras sujas.

  7. Relacionado ao ponto observado diretamente acima - aumento de bloqueios - o uso da Transação aumenta a probabilidade de ocorrência de conflitos, especialmente se houver acionadores.

  8. Um problema menos grave que deve se relacionar apenas ao cenário menos provável das INSERToperações: os dados "Visualizar" podem não ser iguais aos inseridos com relação aos valores da coluna determinados por DEFAULTRestrições (Sequences / NEWID()/ NEWSEQUENTIALID()) e IDENTITY.

  9. Não há necessidade de sobrecarga adicional para gravar o conteúdo da variável de tabela na tabela temporária. oROLLBACK não afetaria os dados no Quadro Variável (que é por isso que você disse que estava usando variáveis de tabela em primeiro lugar), por isso faria mais sentido simplesmente SELECT FROM @output_to_return;no final, e, em seguida, não se incomodam mesmo a criação Temporária Mesa.

  10. Caso essa nuance de Save Points não seja conhecida (é difícil saber pelo código de exemplo, pois mostra apenas um único procedimento armazenado): você precisa usar nomes exclusivos de Save Point para que a ROLLBACK {save_point_name}operação se comporte da maneira esperada. Se você reutilizar os nomes, um ROLLBACK reverterá o Save Point mais recente desse nome, que pode não estar no mesmo nível de aninhamento de onde ROLLBACKestá sendo chamado. Consulte o primeiro bloco de código de exemplo na resposta a seguir para ver esse comportamento em ação: Transação em um procedimento armazenado

O que isso se resume é:

  • Fazer uma "visualização" não faz muito sentido para operações voltadas para o usuário. Faço isso com frequência para operações de manutenção, para que eu possa ver o que será excluído / Garbage Collected se prosseguir com a operação. Eu adiciono um parâmetro opcional chamado @TestModee faço uma IFdeclaração que faz um SELECTquando@TestMode = 1 mais ele faz o DELETE. Às vezes, adiciono o @TestModeparâmetro aos procedimentos armazenados chamados pelo aplicativo para que eu (e outros) possam realizar testes simples sem afetar o estado dos dados, mas esse parâmetro nunca é usado pelo aplicativo.

  • Caso isso não fique claro na seção superior de "problemas":

    Se você precisa / deseja um modo "Visualizar" / "Testar" para ver o que deve ser afetado se a instrução DML for executada, NÃO use Transações (isto é, o BEGIN TRAN...ROLLBACKpadrão) para fazer isso. É um padrão que, na melhor das hipóteses, só funciona realmente em um sistema de usuário único e nem sequer é uma boa ideia nessa situação.

  • Repetindo a maior parte da consulta entre os dois ramos do IF instrução apresenta um problema em potencial de precisar atualizar os dois sempre que houver uma alteração a ser feita. No entanto, as diferenças entre as duas consultas geralmente são fáceis de serem detectadas em uma revisão de código e fáceis de corrigir. Por outro lado, problemas como diferenças de estado e leituras sujas são muito mais difíceis de encontrar e corrigir. E o problema da diminuição do desempenho do sistema é impossível de resolver. Precisamos reconhecer e aceitar que o SQL não é uma linguagem orientada a objetos, e o encapsulamento / redução de código duplicado não era um objetivo de design do SQL, como era com muitas outras linguagens.

    Se a consulta for longa / complexa o suficiente, você poderá encapsulá-la em uma função com valor de tabela embutido. Depois, você pode fazer um simples SELECT * FROM dbo.MyTVF(params);no modo "Visualizar" e JUNTAR-SE aos valores-chave do modo "fazê-lo". Por exemplo:

    UPDATE tab
    SET    tab.Col2 = tvf.ColB
           ...
    FROM   dbo.Table tab
    INNER JOIN dbo.MyTVF(params) tvf
            ON tvf.ColA = tab.Col1;
  • Se esse é um cenário de relatório, como você mencionou, a execução do relatório inicial é a "Visualização". Se alguém quiser alterar algo que vê no relatório (talvez um status), isso não requer uma visualização adicional, pois a expectativa é alterar os dados exibidos no momento.

    Se a operação talvez alterar um valor de lance em uma determinada% ou regra de negócios, isso poderá ser tratado na camada de apresentação (JavaScript?).

  • Se você realmente precisar fazer uma "Visualização" para uma operação voltada ao usuário final , precisará capturar o estado dos dados primeiro (talvez um hash de todos os campos no conjunto de resultados para UPDATEoperações ou os valores-chave para DELETEoperações) e, antes de executar a operação, compare as informações do estado capturado com as informações atuais - dentro de uma Transação fazendo um HOLDbloqueio na tabela para que nada seja alterado depois de fazer essa comparação - e se houver QUALQUER diferença, lance um erro e, em ROLLBACKvez de prosseguir com o UPDATEouDELETE .

    Para detectar diferenças nas UPDATEoperações, uma alternativa para calcular um hash nos campos relevantes seria adicionar uma coluna do tipo ROWVERSION . O valor de um ROWVERSIONtipo de dados é alterado automaticamente sempre que houver uma alteração nessa linha. Se você tivesse uma coluna desse tipo, faria SELECTisso com os outros dados de "Visualização" e os passaria para a etapa "claro, vá em frente e faça a atualização", juntamente com o (s) valor (es) da chave e valor (es) mudar. Você compararia esses ROWVERSIONvalores passados ​​na "Visualização" com os valores atuais (por cada chave) e continuaria apenas com o UPDATEparâmetro ALLdos valores correspondentes. O benefício aqui é que você não precisa calcular um hash com potencial, mesmo que improvável, para falsos negativos, e leva algum tempo a valor é incrementado automaticamente somente quando alterado, para que você não precise se preocupar com nada. No entanto, o tipo é de 8 bytes, que pode ser adicionado ao lidar com muitas tabelas e / ou várias linhas.Cada vez que você faz o SELECT. Por outro lado, oROWVERSIONROWVERSION

    Há prós e contras em cada um desses dois métodos para lidar com a detecção de estado inconsistente relacionado às UPDATEoperações, portanto, você precisa determinar qual método tem mais "pró" do que "contra" para o seu sistema. Mas, em ambos os casos, é possível evitar um atraso entre a geração da Visualização e a execução, causando comportamento fora das expectativas do usuário final.

  • Se você estiver executando um modo de "Visualização" voltado para o usuário final, além de capturar o estado dos registros no momento da seleção, passando adiante e verificando no momento da modificação, inclua um DATETIMEpara SelectTimee preencha via GETDATE()ou algo semelhante. Passe isso para a camada de aplicativo, para que possa ser transmitido de volta ao procedimento armazenado (provavelmente como um único parâmetro de entrada), para que possa ser verificado no Procedimento Armazenado. Em seguida, você pode determinar que, se a operação não for o modo "Visualização", o @SelectTimevalor não precisará ser superior a X minutos antes do valor atual de GETDATE(). Talvez 2 minutos? 5 minutos? Provavelmente não mais que 10 minutos. Lance um erro se o valor DATEDIFFem MINUTES estiver acima desse limite.

Solomon Rutzky
fonte
4

A abordagem mais simples geralmente é a melhor e eu realmente não tenho muito problema com a duplicação de código no SQL, especialmente não no mesmo módulo. Afinal, as duas consultas estão fazendo coisas diferentes. Então, por que não usar 'Route 1' ou Keep It Simple e ter apenas duas seções no processo armazenado, uma para simular o trabalho que você precisa fazer e outra para fazê-lo, por exemplo, algo como isto:

CREATE TABLE dbo.user_table ( rowId INT IDENTITY PRIMARY KEY, a INT NOT NULL, someGuid UNIQUEIDENTIFIER DEFAULT NEWID() );
GO
CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE2]

    @preview CHAR(1) = 'Y'

AS

    SET NOCOUNT ON

    --!!TODO add error handling

    IF @preview = 'Y'

        -- Simulate INSERT; could be more complex
        SELECT 
            ISNULL( ( SELECT MAX(rowId) FROM dbo.user_table ), 0 ) + 1 AS rowId,
            42 AS a,
            NEWID() AS someGuid

    ELSE

        -- Actually do the INSERT, return inserted values
        INSERT INTO dbo.user_table ( a )
        OUTPUT inserted.rowId, inserted.a, inserted.someGuid
        VALUES ( 42 )

    RETURN

GO

Isso tem a vantagem de ser auto-documentado (isto IF ... ELSEé, é fácil de seguir), de baixa complexidade (em comparação com o ponto de salvamento com a abordagem de variável de tabela IMO), portanto, é menos provável que haja erros (excelente ponto do @Cody).

Quanto ao seu ponto de vista sobre baixa confiança, não sei se entendi. Logicamente, duas consultas com o mesmo critério devem fazer a mesma coisa. Existe a possibilidade de incompatibilidade de cardinalidade entre an UPDATEe a SELECT, mas seria um recurso de suas junções e critérios. Você pode explicar mais?

Como um aparte, você deve definir a propriedade NULL/ NOT NULLe suas tabelas e variáveis ​​de tabela, considere definir uma chave primária.

Sua abordagem original parece um pouco complicada demais e pode ser mais propensa a conflitos, já que as operações INSERT/ UPDATE/ DELETErequerem níveis de bloqueio mais altos que o normal SELECTs.

Eu suspeito que seus processos do mundo real são mais complicados; portanto, se você achar que a abordagem acima não funcionará para eles, poste novamente com mais alguns exemplos.

wBob
fonte
3

Minhas preocupações são as seguintes.

  • A manipulação de transações não segue o padrão de aninhamento em um bloco Begin Try / Begin Catch. Se este for um modelo, em um procedimento armazenado com mais algumas etapas, você poderá sair desta transação no modo de visualização com os dados ainda modificados.

  • Seguir o formato aumenta o trabalho do desenvolvedor. Se eles mudarem as colunas internas, eles também precisam modificar a definição da variável da tabela, modificar a definição da tabela temporária e modificar as colunas de inserção no final. Não vai ser popular.

  • Alguns procedimentos armazenados não retornam o mesmo formato de dados todas as vezes; pense em sp_WhoIsActive como um exemplo comum.

Não forneci uma maneira melhor de fazê-lo, mas não acho que o que você tem seja um bom padrão.

Cody Konior
fonte