web-dev-qa-db-fra.com

MERGE un sous-ensemble de la table cible

J'essaie d'utiliser une instruction MERGE pour insérer ou supprimer des lignes d'une table, mais je souhaite uniquement agir sur un sous-ensemble de ces lignes. La documentation de MERGE contient un avertissement assez fort:

Il est important de spécifier uniquement les colonnes de la table cible qui sont utilisées à des fins de correspondance. Autrement dit, spécifiez des colonnes de la table cible qui sont comparées à la colonne correspondante de la table source. N'essayez pas d'améliorer les performances des requêtes en filtrant les lignes de la table cible dans la clause ON, par exemple en spécifiant AND NOT target_table.column_x = value. Cela pourrait retourner des résultats inattendus et incorrects.

mais c'est exactement ce qu'il semble que je doive faire pour que mon MERGE fonctionne.

Les données dont je dispose sont une table de jointure plusieurs à plusieurs standard des articles aux catégories (par exemple, quels articles sont inclus dans quelles catégories) comme ceci:

CategoryId   ItemId
==========   ======
1            1
1            2
1            3
2            1
2            3
3            5
3            6
4            5

Ce que je dois faire est de remplacer efficacement toutes les lignes d'une catégorie spécifique par une nouvelle liste d'éléments. Ma tentative initiale de le faire ressemble à ceci:

MERGE INTO CategoryItem AS TARGET
USING (
  SELECT ItemId FROM SomeExternalDataSource WHERE CategoryId = 2
) AS SOURCE
ON SOURCE.ItemId = TARGET.ItemId AND TARGET.CategoryId = 2
WHEN NOT MATCHED BY TARGET THEN
    INSERT ( CategoryId, ItemId )
    VALUES ( 2, ItemId )
WHEN NOT MATCHED BY SOURCE AND TARGET.CategoryId = 2 THEN
    DELETE ;

Cela apparaît pour fonctionner dans mes tests, mais je fais exactement ce que MSDN me prévient explicitement de ne pas faire. Cela m'inquiète que je vais rencontrer des problèmes inattendus plus tard, mais je ne vois pas d'autre moyen de faire en sorte que mon MERGE n'affecte que les lignes avec la valeur de champ spécifique (CategoryId = 2) et ignorer les lignes des autres catégories.

Existe-t-il un moyen "plus correct" d'obtenir ce même résultat? Et quels sont les "résultats inattendus ou incorrects" que MSDN m'avertit?

73
KutuluMike

L'instruction MERGE a une syntaxe complexe et une implémentation encore plus complexe, mais l'idée est essentiellement de joindre deux tables, de filtrer les lignes qui doivent être modifiées (insérées, mises à jour ou supprimées), puis de effectuez les modifications demandées. Étant donné les exemples de données suivants:

DECLARE @CategoryItem AS TABLE
(
    CategoryId  integer NOT NULL,
    ItemId      integer NOT NULL,

    PRIMARY KEY (CategoryId, ItemId),
    UNIQUE (ItemId, CategoryId)
);

DECLARE @DataSource AS TABLE
(
    CategoryId  integer NOT NULL,
    ItemId      integer NOT NULL

    PRIMARY KEY (CategoryId, ItemId)
);

INSERT @CategoryItem
    (CategoryId, ItemId)
VALUES
    (1, 1),
    (1, 2),
    (1, 3),
    (2, 1),
    (2, 3),
    (3, 5),
    (3, 6),
    (4, 5);

INSERT @DataSource
    (CategoryId, ItemId)
VALUES
    (2, 2);

Cible

╔════════════╦════════╗
║ CategoryId ║ ItemId ║
╠════════════╬════════╣
║          1 ║      1 ║
║          2 ║      1 ║
║          1 ║      2 ║
║          1 ║      3 ║
║          2 ║      3 ║
║          3 ║      5 ║
║          4 ║      5 ║
║          3 ║      6 ║
╚════════════╩════════╝

Source

╔════════════╦════════╗
║ CategoryId ║ ItemId ║
╠════════════╬════════╣
║          2 ║      2 ║
╚════════════╩════════╝

Le résultat souhaité est de remplacer les données de la cible par des données de la source, mais uniquement pour CategoryId = 2. En suivant la description de MERGE donnée ci-dessus, nous devons écrire une requête qui joint la source et la cible sur les clés uniquement, et filtrer les lignes uniquement dans les clauses WHEN:

MERGE INTO @CategoryItem AS TARGET
USING @DataSource AS SOURCE ON 
    SOURCE.ItemId = TARGET.ItemId 
    AND SOURCE.CategoryId = TARGET.CategoryId
WHEN NOT MATCHED BY SOURCE 
    AND TARGET.CategoryId = 2 
    THEN DELETE
WHEN NOT MATCHED BY TARGET 
    AND SOURCE.CategoryId = 2 
    THEN INSERT (CategoryId, ItemId)
        VALUES (CategoryId, ItemId)
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

Cela donne les résultats suivants:

╔═════════╦════════════╦════════╗
║ $ACTION ║ CategoryId ║ ItemId ║
╠═════════╬════════════╬════════╣
║ DELETE  ║          2 ║      1 ║
║ INSERT  ║          2 ║      2 ║
║ DELETE  ║          2 ║      3 ║
╚═════════╩════════════╩════════╝
╔════════════╦════════╗
║ CategoryId ║ ItemId ║
╠════════════╬════════╣
║          1 ║      1 ║
║          1 ║      2 ║
║          1 ║      3 ║
║          2 ║      2 ║
║          3 ║      5 ║
║          3 ║      6 ║
║          4 ║      5 ║
╚════════════╩════════╝

Le plan d'exécution est le suivant: Merge plan

Notez que les deux tableaux sont entièrement analysés. Nous pourrions penser que cela est inefficace, car seules les lignes où CategoryId = 2 sera affecté dans la table cible. C'est là qu'interviennent les avertissements dans Books Online. Une tentative malencontreuse d'optimiser pour toucher uniquement les lignes nécessaires dans la cible est:

MERGE INTO @CategoryItem AS TARGET
USING 
(
    SELECT CategoryId, ItemId
    FROM @DataSource AS ds 
    WHERE CategoryId = 2
) AS SOURCE ON
    SOURCE.ItemId = TARGET.ItemId
    AND TARGET.CategoryId = 2
WHEN NOT MATCHED BY TARGET THEN
    INSERT (CategoryId, ItemId)
    VALUES (CategoryId, ItemId)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

La logique de la clause ON est appliquée dans le cadre de la jointure. Dans ce cas, la jointure est une jointure externe complète (voir cette entrée de documentation en ligne pour savoir pourquoi). L'application de la vérification de la catégorie 2 sur les lignes cibles dans le cadre d'une jointure externe entraîne finalement la suppression des lignes avec une valeur différente (car elles ne correspondent pas à la source):

╔═════════╦════════════╦════════╗
║ $ACTION ║ CategoryId ║ ItemId ║
╠═════════╬════════════╬════════╣
║ DELETE  ║          1 ║      1 ║
║ DELETE  ║          1 ║      2 ║
║ DELETE  ║          1 ║      3 ║
║ DELETE  ║          2 ║      1 ║
║ INSERT  ║          2 ║      2 ║
║ DELETE  ║          2 ║      3 ║
║ DELETE  ║          3 ║      5 ║
║ DELETE  ║          3 ║      6 ║
║ DELETE  ║          4 ║      5 ║
╚═════════╩════════════╩════════╝

╔════════════╦════════╗
║ CategoryId ║ ItemId ║
╠════════════╬════════╣
║          2 ║      2 ║
╚════════════╩════════╝

La cause première est la même raison pour laquelle les prédicats se comportent différemment dans une clause de jointure externe ON que s'ils sont spécifiés dans la clause WHERE. La syntaxe MERGE (et l'implémentation de jointure selon les clauses spécifiées) rend juste plus difficile de voir qu'il en est ainsi.

Le conseils dans la documentation en ligne (développé dans l'entrée Optimizing Performance ) offre des conseils qui garantiront que la sémantique correcte est exprimée à l'aide de la syntaxe MERGE, sans que l'utilisateur soit nécessairement avoir à comprendre tous les détails de l'implémentation, ou à expliquer comment l'optimiseur peut légitimement réorganiser les choses pour des raisons d'efficacité d'exécution.

La documentation propose trois façons possibles d'implémenter un filtrage précoce:

La spécification d'une condition de filtrage dans la clause WHEN garantit des résultats corrects, mais peut signifier que davantage de lignes sont lues et traitées à partir de la source et de la cible tables que ce qui est strictement nécessaire (comme le montre le premier exemple).

La mise à jour via une vue qui contient la condition de filtrage garantit également des résultats corrects (puisque les lignes modifiées doivent être accessibles pour une mise à jour via la vue) mais cela nécessite un dédié vue, et qui suit les conditions étranges pour la mise à jour des vues.

L'utilisation d'une expression de table commune comporte des risques similaires à l'ajout de prédicats à la clause ON, mais pour des raisons légèrement différentes. Dans de nombreux cas, il sera sûr, mais il nécessite une analyse experte du plan d'exécution pour le confirmer (et des tests pratiques approfondis). Par exemple:

WITH TARGET AS 
(
    SELECT * 
    FROM @CategoryItem
    WHERE CategoryId = 2
)
MERGE INTO TARGET
USING 
(
    SELECT CategoryId, ItemId
    FROM @DataSource
    WHERE CategoryId = 2
) AS SOURCE ON
    SOURCE.ItemId = TARGET.ItemId
    AND SOURCE.CategoryId = TARGET.CategoryId
WHEN NOT MATCHED BY TARGET THEN
    INSERT (CategoryId, ItemId)
    VALUES (CategoryId, ItemId)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

Cela produit des résultats corrects (non répétés) avec un plan plus optimal:

Merge plan 2

Le plan lit uniquement les lignes de la catégorie 2 de la table cible. Cela peut être un facteur de performances important si la table cible est grande, mais il est trop facile de se tromper en utilisant la syntaxe MERGE.

Parfois, il est plus facile d'écrire le MERGE en tant qu'opérations DML distinctes. Cette approche peut même donner de meilleurs résultats qu'un seul MERGE, ce qui surprend souvent les gens.

DELETE ci
FROM @CategoryItem AS ci
WHERE ci.CategoryId = 2
AND NOT EXISTS 
(
    SELECT 1 
    FROM @DataSource AS ds 
    WHERE 
        ds.ItemId = ci.ItemId
        AND ds.CategoryId = ci.CategoryId
);

INSERT @CategoryItem
SELECT 
    ds.CategoryId, 
    ds.ItemId
FROM @DataSource AS ds
WHERE
    ds.CategoryId = 2;
105
Paul White 9