Problème de performances des requêtes SQL récursives [fermé]

9

C'est la sixième fois que j'essaie de poser cette question et c'est aussi la plus courte. Toutes les tentatives précédentes ont abouti à quelque chose de plus similaire à un article de blog plutôt qu'à la question elle-même, mais je vous assure que mon problème est réel, c'est juste qu'il concerne un grand sujet et sans tous ces détails que cette question contient, il sera pas clair quel est mon problème. Alors voilà ...

Abstrait

J'ai une base de données, elle permet de stocker des données de manière un peu fantaisiste et fournit plusieurs fonctionnalités non standard requises par mon processus métier. Les fonctionnalités sont les suivantes:

  1. Mises à jour / suppressions non destructives et non bloquantes implémentées via une approche d'insertion uniquement, qui permet la récupération de données et la journalisation automatique (chaque modification est liée à l'utilisateur qui a effectué cette modification)
  2. Données multiversionnelles (il peut y avoir plusieurs versions des mêmes données)
  3. Autorisations au niveau de la base de données
  4. Éventuelle cohérence avec la spécification ACID et création / mise à jour / suppression de transactions sécurisées
  5. Possibilité de rembobiner ou d'avancer rapidement votre vue actuelle des données à tout moment.

Il y a peut-être d'autres fonctionnalités que j'ai oublié de mentionner.

Structure de la base de données

Toutes les données utilisateur sont stockées dans la Itemstable sous forme de chaîne codée JSON ( ntext). Toutes les opérations de base de données sont effectuées via deux procédures stockées GetLatestet InsertSnashotpermettent de fonctionner sur des données similaires à la façon dont GIT exploite les fichiers source.

Les données résultantes sont liées (JOINed) sur le frontend dans un graphique entièrement lié, il n'est donc pas nécessaire de faire des requêtes de base de données dans la plupart des cas.

Il est également possible de stocker des données dans des colonnes SQL régulières au lieu de les stocker sous forme codée Json. Cependant, cela augmente la contrainte de complexité globale.

Lecture des données

GetLatestrésultats avec des données sous forme d'instructions, considérez le diagramme suivant pour l'explication:

Diagramme de structure

Le diagramme montre une évolution des modifications qui ont été apportées à un seul enregistrement. Les flèches sur le diagramme montrent la version en fonction de laquelle la modification a eu lieu (imaginez que l'utilisateur met à jour certaines données hors ligne, en parallèle aux mises à jour effectuées par l'utilisateur en ligne, ce cas introduirait un conflit, qui est essentiellement deux versions de données au lieu d'un).

Ainsi, l'appel GetLatestdans les délais d'entrée suivants se traduira par les versions d'enregistrement suivantes:

GetLatest 0, 15  => 1       <= The data is created upon it's first occurance
GetLatest 0, 25  => 2       <= Inserting another version on top of first one overwrites the existing version
GetLatest 0, 30  => 3       <= The overwrite takes place as soon as the data is inserted
GetLatest 0, 45  => 3, 4    <= This is where the conflict is introduced in the system
GetLatest 0, 55  => 4, 5    <= You can still edit all the versions
GetLatest 0, 65  => 4, 6    <= You can still edit all the versions
GetLatest 0, 75  => 4, 6, 7 <= You can also create additional conflicts
GetLatest 0, 85  => 4, 7, 8 <= You can still edit records
GetLatest 0, 95  => 7, 8, 9 <= You can still edit records
GetLatest 0, 105 => 7, 8    <= Inserting a record with `Json` equal to `NULL` means that the record is deleted
GetLatest 0, 115 => 8       <= Deleting the conflicting versions is the only conflict-resolution scenario
GetLatest 0, 125 => 8, X    <= The conflict can be based on the version that was already deleted.
GetLatest 0, 135 => 8, Y    <= You can delete such version too and both undelete another version on parallel within one Snapshot (or in several Snapshots).
GetLatest 0, 145 => 8       <= You can delete the undeleted versions by inserting NULL.
GetLatest 0, 155 => 8, Z    <= You can again undelete twice-deleted versions
GetLatest 0, 165 => 8       <= You can again delete three-times deleted versions
GetLatest 0, 10000 => 8     <= This means that in order to fast-forward view from moment 0 to moment `10000` you just have to expose record 8 to the user.
GetLatest 55, 115  => 8, [Remove 4], [Remove 5] <= At moment 55 there were two versions [4, 5] so in order to fast-forward to moment 115 the user has to delete versions 4 and 5 and introduce version 8. Please note that version 7 is not present in results since at moment 110 it got deleted.

Pour GetLatestde soutenir cette interface efficace chaque enregistrement doit contenir des attributs de service spécial BranchId, RecoveredOn, CreatedOn, UpdatedOnPrev, UpdatedOnCurr, UpdatedOnNext, UpdatedOnNextIdqui sont utilisés par GetLatestpour savoir si le document est suffisamment dans le laps de temps prévu des GetLatestarguments

Insérer des données

Afin de soutenir la cohérence, la sécurité et les performances des transactions, les données sont insérées dans la base de données via une procédure spéciale à plusieurs étapes.

  1. Les données sont simplement insérées dans la base de données sans pouvoir être interrogées par la GetLatestprocédure stockée.

  2. Les données sont rendues disponibles pour la GetLatestprocédure stockée, les données sont rendues disponibles dans un état normalisé (c'est-à-dire denormalized = 0). Bien que les données sont dans un état normalisé, les champs de service BranchId, RecoveredOn, CreatedOn, UpdatedOnPrev, UpdatedOnCurr, UpdatedOnNext, UpdatedOnNextIdsont calculées qui est vraiment lent.

  3. Afin d'accélérer les choses, les données sont dénormalisées dès qu'elles sont disponibles pour la GetLatestprocédure stockée.

    • Étant donné que les étapes 1, 2, 3 effectuées dans différentes transactions, il est possible qu'une défaillance matérielle se produise au milieu de chaque opération. Laisser les données dans un état intermédiaire. Une telle situation est normale et même si cela se produit, les données seront guéries dans l' InsertSnapshotappel suivant . Le code de cette partie se trouve entre les étapes 2 et 3 de la InsertSnapshotprocédure stockée.

Le problème

Une nouvelle fonctionnalité (requise par les entreprises) m'a obligé à refactoriser la Denormalizervue spéciale qui relie toutes les fonctionnalités et est utilisée à la fois pour GetLatestet InsertSnapshot. Après cela, j'ai commencé à rencontrer des problèmes de performances. S'il était initialement SELECT * FROM Denormalizerexécuté en quelques fractions de seconde, il faut maintenant près de 5 minutes pour traiter 10000 enregistrements.

Je ne suis pas un pro de la base de données et il m'a fallu près de six mois pour sortir de la structure de base de données actuelle. Et j'ai d'abord passé deux semaines à faire les refactorings puis à essayer de comprendre quelle est la cause première de mon problème de performance. Je ne le trouve pas. Je fournis la sauvegarde de la base de données (que vous pouvez trouver ici) car le schéma (avec tous les index) est assez volumineux pour tenir dans SqlFiddle, la base de données contient également des données obsolètes (10000+ enregistrements) que j'utilise à des fins de test . Je fournis également le texte à Denormalizerafficher qui a été refactorisé et est devenu douloureusement lent:

ALTER VIEW [dbo].[Denormalizer]
AS
WITH Computed AS
(
    SELECT  currItem.Id,
            nextOperation.id AS NextId,
            prevOperation.FinishedOn AS PrevComputed,
            currOperation.FinishedOn AS CurrComputed,
            nextOperation.FinishedOn AS NextComputed

    FROM Items currItem 
    INNER JOIN dbo.Operations AS currOperation ON currItem.OperationId = currOperation.Id

    LEFT OUTER JOIN dbo.Items AS prevItem ON currItem.PreviousId = prevItem.Id
    LEFT OUTER JOIN dbo.Operations AS prevOperation ON prevItem.OperationId = prevOperation.Id 
    LEFT OUTER JOIN
    (
        SELECT MIN(I.id) as id, S.PreviousId, S.FinishedOn
        FROM Items I
        INNER JOIN
        (
            SELECT I.PreviousId, MIN(nxt.FinishedOn) AS FinishedOn
            FROM dbo.Items I
            LEFT OUTER JOIN dbo.Operations AS nxt ON I.OperationId = nxt.Id
            GROUP BY I.PreviousId
        ) AS S ON I.PreviousId = S.PreviousId 
        GROUP BY S.PreviousId, S.FinishedOn
    ) AS nextOperation ON nextOperation.PreviousId = currItem.Id

    WHERE currOperation.Finished = 1 AND currItem.Denormalized = 0
),

RecursionInitialization AS
(
    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,
            currItem.Id AS BranchID,
            COALESCE (C.PrevComputed, C.CurrComputed) AS CreatedOn,
            COALESCE (C.PrevComputed, CAST(0 AS BIGINT)) AS RecoveredOn,
            COALESCE (C.PrevComputed, CAST(0 AS BIGINT)) AS UpdatedOnPrev,
            C.CurrComputed AS UpdatedOnCurr,
            COALESCE (C.NextComputed, CAST(8640000000000000 AS BIGINT)) AS UpdatedOnNext,
            C.NextId AS UpdatedOnNextId,

            0 AS RecursionLevel

    FROM Items AS currItem
    INNER JOIN Computed AS C ON currItem.Id = C.Id
    WHERE currItem.Denormalized = 0

    UNION ALL

    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,
            currItem.BranchId,
            currItem.CreatedOn,
            currItem.RecoveredOn,
            currItem.UpdatedOnPrev,
            currItem.UpdatedOnCurr,
            currItem.UpdatedOnNext,
            currItem.UpdatedOnNextId,

            0 AS RecursionLevel

    FROM Items AS currItem
    WHERE currItem.Denormalized = 1
),
Recursion AS
(
    SELECT *
    FROM RecursionInitialization AS currItem

    UNION ALL

    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,

            CASE
                WHEN prevItem.UpdatedOnNextId = currItem.Id
                THEN prevItem.BranchID
                ELSE currItem.Id
            END AS BranchID,

            prevItem.CreatedOn AS CreatedOn,

            CASE
                WHEN prevItem.Json IS NULL
                THEN CASE
                            WHEN currItem.Json IS NULL
                            THEN prevItem.RecoveredOn
                            ELSE C.CurrComputed
                        END
                ELSE prevItem.RecoveredOn
            END AS RecoveredOn,

            prevItem.UpdatedOnCurr AS UpdatedOnPrev,

            C.CurrComputed AS UpdatedOnCurr,

            COALESCE (C.NextComputed, CAST(8640000000000000 AS BIGINT)) AS UpdatedOnNext,

            C.NextId,

            prevItem.RecursionLevel + 1 AS RecursionLevel
    FROM Items currItem
    INNER JOIN Computed C ON currItem.Id = C.Id
    INNER JOIN Recursion AS prevItem ON currItem.PreviousId = prevItem.Id
    WHERE currItem.Denormalized = 0
)
SELECT  item.Id,
        item.PreviousId,
        item.UUID,
        item.Json,
        item.TableName,
        item.OperationId,
        item.PermissionId,
        item.Denormalized,
        item.BranchID,
        item.CreatedOn,
        item.RecoveredOn,
        item.UpdatedOnPrev,
        item.UpdatedOnCurr,
        item.UpdatedOnNext,
        item.UpdatedOnNextId

FROM Recursion AS item
INNER JOIN
(
    SELECT Id, MAX(RecursionLevel) AS Recursion
    FROM Recursion AS item
    GROUP BY Id
) AS nested ON item.Id = nested.Id AND item.RecursionLevel = nested.Recursion
GO

Questions)

Deux scénarios sont pris en considération, les cas dénormalisés et normalisés:

  1. En regardant la sauvegarde d'origine, ce qui rend la SELECT * FROM Denormalizertâche si lente, j'ai l'impression qu'il y a un problème avec la partie récursive de la vue Denormalizer, j'ai essayé de restreindre denormalized = 1mais aucune de mes actions n'a affecté les performances.

  2. Après avoir exécuté UPDATE Items SET Denormalized = 0il serait GetLatestet SELECT * FROM DenormalizerINTRODUISE ( d' abord pensé être) scénario lent, est - il un moyen de accélérer les choses quand nous informatique champs de service BranchId, RecoveredOn, CreatedOn, UpdatedOnPrev, UpdatedOnCurr, UpdatedOnNext,UpdatedOnNextId

Merci d'avance

PS

J'essaie de m'en tenir au SQL standard pour rendre la requête facilement portable vers d'autres bases de données telles que MySQL / Oracle / SQLite pour l'avenir, mais s'il n'y a pas de SQL standard, cela pourrait aider, je suis d'accord avec le respect des constructions spécifiques à la base de données.

Lu4
la source
1
En ce qui concerne le SQL standard et les bases de données que vous listez: vous utilisez un CTE ici et ils ne sont pas pris en charge par mySQL et il existe des variations de syntaxe entre les principales implémentations. De plus, ils constituent actuellement une barrière d'optimisation dans les postgres, ce qui pourrait être un gros problème de performance. Rien de tout cela ne devrait vous empêcher de les utiliser, avec un arbre sous forme de "liste d'adjacence", ils sont généralement le bon outil pour le travail, mais lorsque la compatibilité est une préoccupation, ces points sont des choses à savoir avant de vous mordre afin que vous puissiez être préparé pour tout travail supplémentaire nécessaire lorsque la migration vers d'autres SGBD devient réalité.
David Spillett
Merci, j'essaie de m'en tenir au SQL standard autant que possible. La raison en est que je pense que cela devrait réduire le nombre de problèmes à l'avenir, lorsqu'il sera nécessaire de migrer le code existant vers d'autres bases de données. Ce n'est pas toujours possible. Il y a aussi le facteur temps qui fait partie de l'équation. J'ai passé un demi-an à régler la structure de la base de données actuelle ... J'aimerais qu'elle contienne des constructions standard uniquement, mais si cela nécessitera 10 ans de plus, ce n'est pas la voie à suivre ... Donc, si vous voyez qu'il y a plus refactoring standard possible je serai heureux de l'accepter ...
Lu4
1
Non, cela ressemble à un moyen pragmatique de gérer les différentes priorités et complications dans des cas comme celui-ci. Je viens de jeter un œil aux problèmes qui vous sont venus à l'esprit au cas où vous ne les auriez pas encore rencontrés (mieux vaut savoir maintenant, même s'il n'est pas possible / pratique de faire quoi que ce soit pour le moment, que d'être mordu par surprise en production! ).
David Spillett

Réponses:

9

@ Lu4 .. J'ai voté pour fermer cette question en tant que "Tip of Iceberg" mais en utilisant un indice de requête, vous pourrez l'exécuter en moins de 1 seconde. Cette requête peut être refactorisée et peut être utilisée CROSS APPLY, mais ce sera un concert de consultation et non comme une réponse dans un site de questions / réponses.

Votre requête en l'état s'exécutera pendant 13+ minutes sur mon serveur avec 4 CPU et 16 Go de RAM.

entrez la description de l'image ici

J'ai changé votre requête pour l'utiliser OPTION(MERGE JOIN)et elle a fonctionné en moins de 1 seconde

set nocount on 
set statistics io on
set statistics time on
;WITH Computed AS
(
    SELECT  currItem.Id,
            nextOperation.id AS NextId,
            prevOperation.FinishedOn AS PrevComputed,
            currOperation.FinishedOn AS CurrComputed,
            nextOperation.FinishedOn AS NextComputed

    FROM Items currItem 
    INNER JOIN dbo.Operations AS currOperation ON currItem.OperationId = currOperation.Id

    LEFT OUTER JOIN dbo.Items AS prevItem ON currItem.PreviousId = prevItem.Id
    LEFT OUTER JOIN dbo.Operations AS prevOperation ON prevItem.OperationId = prevOperation.Id 
    LEFT OUTER JOIN
    (
        SELECT MIN(I.id) as id, S.PreviousId, S.FinishedOn
        FROM Items I
        INNER JOIN
        (
            SELECT I.PreviousId, MIN(nxt.FinishedOn) AS FinishedOn
            FROM dbo.Items I
            LEFT OUTER JOIN dbo.Operations AS nxt ON I.OperationId = nxt.Id
            GROUP BY I.PreviousId
        ) AS S ON I.PreviousId = S.PreviousId 
        GROUP BY S.PreviousId, S.FinishedOn
    ) AS nextOperation ON nextOperation.PreviousId = currItem.Id

    WHERE currOperation.Finished = 1 AND currItem.Denormalized = 0
),

RecursionInitialization AS
(
    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,
            currItem.Id AS BranchID,
            COALESCE (C.PrevComputed, C.CurrComputed) AS CreatedOn,
            COALESCE (C.PrevComputed, CAST(0 AS BIGINT)) AS RecoveredOn,
            COALESCE (C.PrevComputed, CAST(0 AS BIGINT)) AS UpdatedOnPrev,
            C.CurrComputed AS UpdatedOnCurr,
            COALESCE (C.NextComputed, CAST(8640000000000000 AS BIGINT)) AS UpdatedOnNext,
            C.NextId AS UpdatedOnNextId,

            0 AS RecursionLevel

    FROM Items AS currItem
    INNER JOIN Computed AS C ON currItem.Id = C.Id
    WHERE currItem.Denormalized = 0

    UNION ALL

    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,
            currItem.BranchId,
            currItem.CreatedOn,
            currItem.RecoveredOn,
            currItem.UpdatedOnPrev,
            currItem.UpdatedOnCurr,
            currItem.UpdatedOnNext,
            currItem.UpdatedOnNextId,

            0 AS RecursionLevel

    FROM Items AS currItem
    WHERE currItem.Denormalized = 1
),
Recursion AS
(
    SELECT *
    FROM RecursionInitialization AS currItem

    UNION ALL

    SELECT  currItem.Id,
            currItem.PreviousId,
            currItem.UUID,
            currItem.Json,
            currItem.TableName,
            currItem.OperationId,
            currItem.PermissionId,
            currItem.Denormalized,

            CASE
                WHEN prevItem.UpdatedOnNextId = currItem.Id
                THEN prevItem.BranchID
                ELSE currItem.Id
            END AS BranchID,

            prevItem.CreatedOn AS CreatedOn,

            CASE
                WHEN prevItem.Json IS NULL
                THEN CASE
                            WHEN currItem.Json IS NULL
                            THEN prevItem.RecoveredOn
                            ELSE C.CurrComputed
                        END
                ELSE prevItem.RecoveredOn
            END AS RecoveredOn,

            prevItem.UpdatedOnCurr AS UpdatedOnPrev,

            C.CurrComputed AS UpdatedOnCurr,

            COALESCE (C.NextComputed, CAST(8640000000000000 AS BIGINT)) AS UpdatedOnNext,

            C.NextId,

            prevItem.RecursionLevel + 1 AS RecursionLevel
    FROM Items currItem
    INNER JOIN Computed C ON currItem.Id = C.Id
    INNER JOIN Recursion AS prevItem ON currItem.PreviousId = prevItem.Id
    WHERE currItem.Denormalized = 0
)
SELECT  item.Id,
        item.PreviousId,
        item.UUID,
        item.Json,
        item.TableName,
        item.OperationId,
        item.PermissionId,
        item.Denormalized,
        item.BranchID,
        item.CreatedOn,
        item.RecoveredOn,
        item.UpdatedOnPrev,
        item.UpdatedOnCurr,
        item.UpdatedOnNext,
        item.UpdatedOnNextId

FROM Recursion AS item
INNER JOIN
(
    SELECT Id, MAX(RecursionLevel) AS Recursion
    FROM Recursion AS item
    GROUP BY Id
) AS nested ON item.Id = nested.Id AND item.RecursionLevel = nested.Recursion
OPTION (MERGE JOIN)

set nocount oFF 
set statistics io OFF
set statistics time OFF

entrez la description de l'image ici

Notez que vous ne pouvez pas utiliser d'indicateurs de requête dans une vue, vous devez donc trouver une alternative pour créer votre vue en tant que SP ou toute solution de contournement.

Kin Shah
la source
1
merci beaucoup à ce sujet, compte tenu du fait que la question est loin des standards stackoverflow cela rend vos efforts deux fois précieux pour moi. Je vais faire mes devoirs sur CROSS APPLY et essayer de comprendre l'OPTION (MERGE JOIN). Ce n'est pas évident maintenant ce qui semble être le problème avec cette requête, mais je suis sûr que je vais le comprendre, merci encore
Lu4
@ Lu4 Le problème est que l'optimiseur de requêtes ne choisit pas (ou ne génère pas) le meilleur plan d'exécution. Dans ce cas, l'indicateur de requête «encourage» l'optimiseur à utiliser une stratégie spécifique pour implémenter la jointure. Voir Join Hints (Transact-SQL) pour plus de détails.
Kenny Evitt
CROSS APPLYest génial, mais je suggère de lire les plans d'exécution et de les analyser avant d'essayer d'utiliser des conseils de requête.
Kenny Evitt