Procédure stockée de base de données avec un «mode de prévisualisation»

15

Un modèle assez courant dans l'application de base de données avec laquelle je travaille est la nécessité de créer une procédure stockée pour un rapport ou un utilitaire doté d'un "mode de prévisualisation". Lorsqu'une telle procédure effectue des mises à jour, ce paramètre indique que les résultats de l'action doivent être renvoyés, mais la procédure ne doit pas réellement effectuer les mises à jour de la base de données.

Une façon d'y parvenir est d'écrire simplement une ifinstruction pour le paramètre et d'avoir deux blocs de code complets; dont l'un met à jour et renvoie les données et l'autre renvoie simplement les données. Mais cela n'est pas souhaitable en raison de la duplication de code et d'un degré relativement faible de confiance dans le fait que les données d'aperçu reflètent réellement ce qui se passerait avec une mise à jour.

L'exemple suivant tente de tirer parti des points de sauvegarde et des variables de transaction (qui ne sont pas affectés par les transactions, contrairement aux tables temporaires qui le sont) pour utiliser un seul bloc de code pour le mode d'aperçu comme mode de mise à jour en direct.

Remarque: les annulations de transaction ne sont pas une option car cet appel de procédure peut lui-même être imbriqué dans une transaction. Ceci est testé sur 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

Je recherche des commentaires sur ce code et ce modèle de conception, et / ou si d'autres solutions au même problème existent dans différents formats.

NReilingh
la source

Réponses:

12

Il y a plusieurs défauts à cette approche:

  1. Le terme «aperçu» peut être assez trompeur dans la plupart des cas, selon la nature des données utilisées (et qui change d'une opération à l'autre). Ce qui doit garantir que les données actuelles utilisées seront dans le même état entre le moment où les données de «prévisualisation» sont collectées et lorsque l'utilisateur revient 15 minutes plus tard - après avoir pris du café, être sorti pour fumer, marcher autour du bloc, en revenant et en vérifiant quelque chose sur eBay - et se rend compte qu'ils n'ont pas cliqué sur le bouton "OK" pour effectuer réellement l'opération et qu'ils cliquent donc finalement sur le bouton?

    Avez-vous un délai pour poursuivre l'opération après la génération de l'aperçu? Ou peut-être un moyen de déterminer que les données sont dans le même état au moment de la modification qu'au SELECTmoment initial ?

  2. C'est un point mineur car l'exemple de code aurait pu être fait à la hâte et ne pas représenter un vrai cas d'utilisation, mais pourquoi y aurait-il un "Aperçu" pour une INSERTopération? Cela pourrait être logique lors de l'insertion de plusieurs lignes via quelque chose comme INSERT...SELECTet il pourrait y avoir un nombre variable de lignes insérées, mais cela n'a pas beaucoup de sens pour une opération singleton.

  3. cela n'est pas souhaitable en raison de ... un degré de confiance relativement faible que les données d'aperçu reflètent réellement ce qui se passerait avec une mise à jour.

    D'où vient exactement ce «faible degré de confiance»? Bien qu'il soit possible de mettre à jour un nombre de lignes différent de celui SELECTaffiché pour un lorsque plusieurs tables sont jointes et qu'il y a duplication de lignes dans un jeu de résultats, cela ne devrait pas être un problème ici. Toutes les lignes qui doivent être affectées par un UPDATEsont sélectionnables par elles-mêmes. En cas de non-concordance, la requête est incorrecte.

    Et les situations où il y a duplication due à une table JOINed qui correspond à plusieurs lignes dans la table qui sera mise à jour ne sont pas des situations où un "Aperçu" serait généré. Et s'il y a une occasion où c'est le cas, alors il faut expliquer à l'utilisateur qu'il est mis à jour un sous-ensemble du rapport qui est répété dans le rapport afin qu'il ne semble pas être une erreur si quelqu'un est seulement en regardant le nombre de lignes affectées.

  4. Dans un souci d'exhaustivité (même si les autres réponses l'ont mentionné), vous n'utilisez pas la TRY...CATCHconstruction, vous pourriez donc facilement rencontrer des problèmes lors de l'imbrication de ces appels (même si vous n'utilisez pas Save Points et même si vous n'utilisez pas Transactions). Veuillez consulter ma réponse à la question suivante, ici sur DBA.SE, pour un modèle qui gère les transactions entre les appels de procédure stockée imbriqués:

    Sommes-nous tenus de gérer la transaction en code C # ainsi qu'en procédure stockée

  5. MÊME SI les problèmes mentionnés ci-dessus ont été pris en compte, il existe toujours une faille critique: pour la courte période de temps pendant laquelle l'opération est effectuée (c'est-à-dire avant le ROLLBACK), toutes les requêtes de lecture incorrecte (les requêtes utilisant WITH (NOLOCK)ou SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED) peuvent récupérer des données qui n'est pas là un instant plus tard. Alors que toute personne utilisant des requêtes de lecture sale devrait déjà être consciente de cela et avoir accepté cette possibilité, des opérations comme celle-ci augmentent considérablement les chances d'introduire des anomalies de données très difficiles à déboguer (ce qui signifie: combien de temps voulez-vous passer à essayer de trouver un problème qui n'a pas de cause directe apparente?).

  6. Un modèle comme celui-ci dégrade également les performances du système en augmentant à la fois le blocage en supprimant plus de verrous et en générant plus d'activité dans le journal des transactions. (Je vois maintenant que @MartinSmith a également mentionné ces 2 problèmes dans un commentaire sur la question.)

    De plus, s'il y a des déclencheurs sur les tables en cours de modification, cela pourrait être un peu de traitement supplémentaire (lectures CPU et physiques / logiques) qui n'est pas nécessaire. Les déclencheurs augmenteraient également davantage les risques d'anomalies de données résultant de lectures incorrectes.

  7. En lien avec le point noté directement ci-dessus - augmentation des verrous - l'utilisation de la transaction augmente la probabilité de tomber dans des blocages, en particulier si des déclencheurs sont impliqués.

  8. Un problème moins grave qui ne devrait concerner que le scénario d' INSERTopérations le moins probable : les données "Aperçu" peuvent ne pas être les mêmes que celles insérées en ce qui concerne les valeurs de colonne déterminées par les DEFAULTcontraintes ( Sequences/ NEWID()/ NEWSEQUENTIALID()) et IDENTITY.

  9. Il n'est pas nécessaire de surcharger l'écriture du contenu de la variable de table dans la table temporaire. Le ROLLBACKsans incidence sur les données de la variable de table ( ce qui est la raison pour laquelle vous avez dit que vous utilisiez les variables de table en premier lieu), il serait plus logique de simplement SELECT FROM @output_to_return;à la fin, puis ne se soucient même pas créer le temporaire Table.

  10. Juste au cas où cette nuance de Save Points n'est pas connue (difficile à dire à partir de l'exemple de code car il ne montre qu'une seule procédure stockée): vous devez utiliser des noms de Save Point uniques afin que l' ROLLBACK {save_point_name}opération se comporte comme vous vous y attendez. Si vous réutilisez les noms, un ROLLBACK annulera le point d'enregistrement le plus récent de ce nom, qui peut ne pas être au même niveau d'imbrication d'où le ROLLBACKest appelé. Veuillez voir le premier exemple de bloc de code dans la réponse suivante pour voir ce comportement en action: Transaction dans une procédure stockée

Cela se résume à:

  • Faire un "Aperçu" n'a pas beaucoup de sens pour les opérations face à l'utilisateur. Je le fais fréquemment pour les opérations de maintenance afin de voir ce qui sera supprimé / Garbage Collected si je continue l'opération. J'ajoute un paramètre facultatif appelé @TestModeet je fais une IFinstruction qui fait une SELECTfois @TestMode = 1sinon elle fait la DELETE. J'ajoute parfois le @TestModeparamètre aux procédures stockées appelées par l'application afin que moi (et d'autres) puisse effectuer des tests simples sans affecter l'état des données, mais ce paramètre n'est jamais utilisé par l'application.

  • Juste au cas où cela ne serait pas clair dans la section supérieure des "problèmes":

    Si vous avez besoin / souhaitez un mode "Aperçu" / "Test" pour voir ce qui devrait être affecté si l'instruction DML devait être exécutée, n'utilisez PAS de transactions (c'est-à-dire le BEGIN TRAN...ROLLBACKmodèle) pour accomplir cela. C'est un modèle qui, au mieux, ne fonctionne vraiment que sur un système mono-utilisateur, et même pas une bonne idée dans cette situation.

  • La répétition de la majeure partie de la requête entre les deux branches de l' IFinstruction présente un problème potentiel de besoin de mettre à jour les deux à chaque fois qu'il y a un changement à effectuer. Cependant, les différences entre les deux requêtes sont généralement assez faciles à détecter dans une revue de code et faciles à corriger. D'un autre côté, les problèmes tels que les différences d'état et les lectures sales sont beaucoup plus difficiles à trouver et à résoudre. Et le problème de la baisse des performances du système est impossible à résoudre. Nous devons reconnaître et accepter que SQL n'est pas un langage orienté objet, et l'encapsulation / réduction du code dupliqué n'était pas un objectif de conception de SQL comme c'était le cas avec de nombreux autres langages.

    Si la requête est suffisamment longue / complexe, vous pouvez l'encapsuler dans une fonction de valeur de table en ligne. Ensuite, vous pouvez faire un simple SELECT * FROM dbo.MyTVF(params);pour le mode "Aperçu" et vous JOINDRE à la ou aux valeurs de clé pour le mode "do it". Par exemple:

    UPDATE tab
    SET    tab.Col2 = tvf.ColB
           ...
    FROM   dbo.Table tab
    INNER JOIN dbo.MyTVF(params) tvf
            ON tvf.ColA = tab.Col1;
  • S'il s'agit d'un scénario de rapport tel que vous l'avez mentionné, alors l'exécution du rapport initial est l '"Aperçu". Si quelqu'un souhaite modifier quelque chose qu'il voit sur le rapport (un état peut-être), cela ne nécessite pas d'aperçu supplémentaire, car il est prévu de modifier les données actuellement affichées.

    Si l'opération consiste peut-être à modifier le montant d'une enchère d'un certain% ou d'une certaine règle commerciale, cela peut être géré dans la couche de présentation (JavaScript?).

  • Si vous avez vraiment besoin de faire un "Aperçu" pour une opération destinée à l'utilisateur final , vous devez d'abord capturer l'état des données (peut-être un hachage de tous les champs du jeu de résultats pour les UPDATEopérations ou les valeurs clés pour DELETEopérations), puis, avant d'effectuer l'opération, comparez les informations d'état capturées avec les informations actuelles - dans une transaction faisant un HOLDverrou sur la table afin que rien ne change après avoir fait cette comparaison - et s'il y a une différence, lancez un erreur et faire un ROLLBACKplutôt que de poursuivre avec UPDATEou DELETE.

    Pour détecter les différences pour les UPDATEopérations, une alternative au calcul d'un hachage sur les champs concernés serait d'ajouter une colonne de type ROWVERSION . La valeur d'un ROWVERSIONtype de données change automatiquement à chaque modification de cette ligne. Si vous aviez une telle colonne, vous la feriez SELECTavec les autres données "Aperçu", puis la transmettriez à l'étape "Bien sûr, allez-y et faites la mise à jour" avec les valeurs clés et les valeurs clés. changer. Vous devez ensuite comparer ces ROWVERSIONvaleurs transmises à partir de la «prévisualisation» avec les valeurs actuelles (pour chaque clé), et ne procéder avec le UPDATEsi TOUSdes valeurs correspondantes. L'avantage ici est que vous n'avez pas besoin de calculer un hachage qui a le potentiel, même s'il est peu probable, de faux négatifs, et prend un certain temps à chaque fois que vous faites le SELECT. D'un autre côté, la ROWVERSIONvaleur n'est incrémentée automatiquement que lorsqu'elle est modifiée, vous n'avez donc jamais à vous soucier de rien. Cependant, le ROWVERSIONtype est de 8 octets, ce qui peut s'additionner lorsqu'il s'agit de plusieurs tables et / ou de nombreuses lignes.

    Il y a des avantages et des inconvénients à chacune de ces deux méthodes pour gérer la détection d'un état incohérent lié aux UPDATEopérations, vous devez donc déterminer quelle méthode a plus de «pour» que de «contre» pour votre système. Mais dans les deux cas, vous pouvez éviter un délai entre la génération de l'aperçu et l'exécution de l'opération en provoquant un comportement en dehors des attentes de l'utilisateur final.

  • Si vous utilisez un mode "Aperçu" pour l'utilisateur final, en plus de capturer l'état des enregistrements au moment de la sélection, de les transmettre et de les vérifier au moment de la modification, incluez un DATETIMEfor SelectTimeet remplissez via GETDATE()ou quelque chose de similaire. Transmettez-le à la couche d'application afin qu'il puisse être renvoyé à la procédure stockée (principalement en tant que paramètre d'entrée unique) afin qu'il puisse être vérifié dans la procédure stockée. Ensuite, vous pouvez déterminer que SI l'opération n'est pas le mode "Aperçu", la @SelectTimevaleur ne doit pas dépasser X minutes avant la valeur actuelle de GETDATE(). Peut-être 2 minutes? 5 minutes? Probablement pas plus de 10 minutes. Lance une erreur si le DATEDIFFdans MINUTES dépasse ce seuil.

Solomon Rutzky
la source
4

L'approche la plus simple est souvent la meilleure et je n'ai pas vraiment de problème avec la duplication de code en SQL, surtout pas dans le même module. Après tout, les deux requêtes font des choses différentes. Alors pourquoi ne pas prendre «Route 1» ou Keep It Simple et avoir seulement deux sections dans le proc stocké, une pour simuler le travail que vous devez faire et une pour le faire, par exemple quelque chose comme ceci:

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

Cela a l'avantage d'être auto-documenté (c'est-à-dire qu'il IF ... ELSEest facile à suivre), de faible complexité (par rapport au point de sauvegarde avec une approche de table variable IMO), donc moins susceptible d'avoir des bugs (super spot de @Cody).

En ce qui concerne votre point sur la faible confiance, je ne suis pas sûr de comprendre. Logiquement, deux requêtes avec les mêmes critères devraient faire la même chose. Il existe une possibilité de non-correspondance de cardinalité entre un UPDATEet un SELECT, mais ce serait une caractéristique de vos jointures et de vos critères. Pouvez-vous expliquer davantage?

En passant, vous devez définir la propriété NULL/ NOT NULLet vos tables et variables de table, pensez à définir une clé primaire.

Votre approche originale semble un peu trop compliquée et pourrait être plus sujette aux blocages, car les opérations INSERT/ UPDATE/ DELETEnécessitent des niveaux de verrouillage plus élevés que plain SELECTs.

Je soupçonne que vos procs du monde réel sont plus compliqués, donc si vous pensez que l'approche ci-dessus ne fonctionnera pas pour eux, publiez-en plus avec d'autres exemples.

wBob
la source
3

Mes préoccupations sont les suivantes.

  • La gestion des transactions ne suit pas le modèle standard d'imbrication dans un bloc Begin Try / Begin Catch. S'il s'agit d'un modèle, dans une procédure stockée avec quelques étapes supplémentaires, vous pouvez quitter cette transaction en mode aperçu avec des données toujours modifiées.

  • Suivre le format augmente le travail du développeur. S'ils changent les colonnes internes, ils doivent également modifier la définition de la variable de table, puis modifier la définition de la table temporaire, puis modifier les colonnes d'insertion à la fin. Ça ne va pas être populaire.

  • Certaines procédures stockées ne renvoient pas le même format de données à chaque fois; pensez à sp_WhoIsActive comme un exemple courant.

Je n'ai pas fourni de meilleure façon de le faire, mais je ne pense pas que ce que vous avez soit un bon modèle.

Cody Konior
la source