Calcul de la quantité de stock en fonction du journal des modifications


10

Imaginez que vous ayez la structure de table suivante:

LogId | ProductId | FromPositionId | ToPositionId | Date                 | Quantity
-----------------------------------------------------------------------------------
1     | 123       | 0              | 10002        | 2018-01-01 08:10:22  | 5
2     | 123       | 0              | 10003        | 2018-01-03 15:15:10  | 9
3     | 123       | 10002          | 10004        | 2018-01-07 21:08:56  | 3
4     | 123       | 10004          | 0            | 2018-02-09 10:03:23  | 1

FromPositionIdet ToPositionIdsont des positions de stock. Certains ID de poste ont une signification particulière, par exemple 0. Un événement de ou vers 0signifie que le stock a été créé ou supprimé. De 0pourrait être stocké à partir d'une livraison et 0pourrait être une commande expédiée.

Ce tableau contient actuellement environ 5,5 millions de lignes. Nous calculons la valeur des stocks pour chaque produit et nous positionnons dans une table de cache selon un calendrier à l'aide d'une requête qui ressemble à ceci:

WITH t AS
(
    SELECT ToPositionId AS PositionId, SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY ToPositionId, ProductId
    UNION
    SELECT FromPositionId AS PositionId, -SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY FromPositionId, ProductId
)

SELECT t.ProductId, t.PositionId, SUM(t.Quantity) AS Quantity
FROM t
WHERE NOT t.PositionId = 0
GROUP BY t.ProductId, t.PositionId
HAVING SUM(t.Quantity) > 0

Même si cela se termine dans un délai raisonnable (environ 20 secondes), j'ai l'impression que c'est une façon assez inefficace de calculer les valeurs des actions. Nous faisons rarement autre chose que INSERT: s dans ce tableau, mais parfois nous entrons et ajustons la quantité ou supprimons une ligne manuellement en raison d'erreurs des personnes générant ces lignes.

J'ai eu l'idée de créer des «points de contrôle» dans une table séparée, de calculer la valeur jusqu'à un moment précis et de l'utiliser comme valeur de départ lors de la création de notre table de cache de quantité de stock:

ProductId | PositionId | Date                | Quantity
-------------------------------------------------------
123       | 10002      | 2018-01-07 21:08:56 | 2

Le fait que nous changions parfois des lignes pose un problème, dans ce cas, nous devons également nous rappeler de supprimer tout point de contrôle créé après la ligne de journal que nous avons modifiée. Cela pourrait être résolu en ne calculant pas les points de contrôle jusqu'à présent, mais en laissant un mois entre maintenant et le dernier point de contrôle (nous apportons très très rarement des modifications aussi lointaines).

Le fait que nous ayons parfois besoin de modifier des lignes est difficile à éviter et j'aimerais pouvoir continuer à le faire, ce n'est pas affiché dans cette structure mais les événements de journal sont parfois liés à d'autres enregistrements dans d'autres tables, et en ajoutant une autre ligne de journal obtenir la bonne quantité n'est parfois pas possible.

Comme vous pouvez l'imaginer, la table des journaux croît assez rapidement et le temps de calcul ne fera qu'augmenter avec le temps.

Donc, à ma question, comment résoudriez-vous cela? Existe-t-il un moyen plus efficace de calculer la valeur actuelle du stock? Mon idée des points de contrôle est-elle bonne?

Nous exécutons SQL Server 2014 Web (12.0.5511)

Plan d'exécution: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

En fait, j'ai donné le mauvais temps d'exécution ci-dessus, 20 secondes était le temps nécessaire à la mise à jour complète du cache. Cette requête prend environ 6 à 10 secondes pour s'exécuter (8 secondes lorsque j'ai créé ce plan de requête). Il y a aussi une jointure dans cette requête qui n'était pas dans la question d'origine.

Réponses:


6

Parfois, vous pouvez améliorer les performances des requêtes simplement en faisant un peu de réglage au lieu de changer votre requête entière. J'ai remarqué dans votre plan de requête réel que votre requête déborde de tempdb à trois endroits. Voici un exemple:

déversements de tempdb

La résolution de ces déversements tempdb peut améliorer les performances. Si Quantityest toujours non négatif, vous pouvez le remplacer UNIONpar UNION ALLce qui changera probablement l'opérateur d'union de hachage en quelque chose d'autre qui ne nécessite pas d'allocation de mémoire. Vos autres déversements de tempdb sont causés par des problèmes d'estimation de cardinalité. Vous utilisez SQL Server 2014 et utilisez le nouveau CE, il peut donc être difficile d'améliorer les estimations de cardinalité car l'optimiseur de requête n'utilisera pas de statistiques multi-colonnes. Comme solution rapide, envisagez d'utiliser l' MIN_MEMORY_GRANTindicateur de requête mis à disposition dans SQL Server 2014 SP2. L'allocation de mémoire de votre requête n'est que de 49104 Ko et l'allocation maximale disponible est de 5054840 Ko. 10% est une estimation de départ raisonnable, mais vous devrez peut-être l'ajuster et le faire en fonction de votre matériel et de vos données. En mettant tout cela ensemble, voici à quoi pourrait ressembler votre requête:

WITH t AS
(
    SELECT ToPositionId AS PositionId, SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY ToPositionId, ProductId
    UNION ALL
    SELECT FromPositionId AS PositionId, -SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY FromPositionId, ProductId
)

SELECT t.ProductId, t.PositionId, SUM(t.Quantity) AS Quantity
FROM t
WHERE NOT t.PositionId = 0
GROUP BY t.ProductId, t.PositionId
HAVING SUM(t.Quantity) > 0
OPTION (MIN_GRANT_PERCENT = 10);

Si vous souhaitez améliorer davantage les performances, je vous recommande d'essayer les vues indexées au lieu de créer et de gérer votre propre table de points de contrôle. Les vues indexées sont beaucoup plus faciles à obtenir correctement qu'une solution personnalisée impliquant votre propre table ou déclencheurs matérialisés. Ils ajouteront une petite quantité de surcharge à toutes les opérations DML mais cela peut vous permettre de supprimer certains des index non cluster que vous avez actuellement. Les vues indexées semblent être prises en charge dans l'édition Web du produit.

Il existe certaines restrictions sur les vues indexées, vous devrez donc en créer une paire. Voici un exemple d'implémentation, ainsi que les fausses données que j'ai utilisées pour les tests:

CREATE TABLE dbo.ProductPositionLog (
    LogId BIGINT NOT NULL,
    ProductId BIGINT NOT NULL,
    FromPositionId BIGINT NOT NULL,
    ToPositionId BIGINT NOT NULL,
    Quantity INT NOT NULL,
    FILLER VARCHAR(20),
    PRIMARY KEY (LogId)
);

INSERT INTO dbo.ProductPositionLog WITH (TABLOCK)
SELECT RN, RN % 100, RN % 3999, 3998 - (RN % 3999), RN % 10, REPLICATE('Z', 20)
FROM (
    SELECT ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) RN
    FROM master..spt_values t1
    CROSS JOIN master..spt_values t2
) q;

CREATE INDEX NCI1 ON dbo.ProductPositionLog (ToPositionId, ProductId) INCLUDE (Quantity);
CREATE INDEX NCI2 ON dbo.ProductPositionLog (FromPositionId, ProductId) INCLUDE (Quantity);

GO    

CREATE VIEW ProductPositionLog_1
WITH SCHEMABINDING  
AS  
   SELECT ToPositionId AS PositionId, SUM(Quantity) AS Quantity, ProductId, COUNT_BIG(*) CNT
    FROM dbo.ProductPositionLog
    WHERE ToPositionId <> 0
    GROUP BY ToPositionId, ProductId
GO  

CREATE UNIQUE CLUSTERED INDEX IDX_V1   
    ON ProductPositionLog_1 (PositionId, ProductId);  
GO  

CREATE VIEW ProductPositionLog_2
WITH SCHEMABINDING  
AS  
   SELECT FromPositionId AS PositionId, SUM(Quantity) AS Quantity, ProductId, COUNT_BIG(*) CNT
    FROM dbo.ProductPositionLog
    WHERE FromPositionId <> 0
    GROUP BY FromPositionId, ProductId
GO  

CREATE UNIQUE CLUSTERED INDEX IDX_V2   
    ON ProductPositionLog_2 (PositionId, ProductId);  
GO  

Sans les vues indexées, la requête prend environ 2,7 secondes pour se terminer sur ma machine. Je reçois un plan similaire au vôtre, sauf le mien en série:

entrez la description de l'image ici

Je pense que vous devrez interroger les vues indexées avec l' NOEXPANDindice, car vous n'êtes pas sur l'édition entreprise. Voici une façon de procéder:

WITH t AS
(
    SELECT PositionId, Quantity, ProductId 
    FROM ProductPositionLog_1 WITH (NOEXPAND)
    UNION ALL
    SELECT PositionId, Quantity, ProductId 
    FROM ProductPositionLog_2 WITH (NOEXPAND)
)
SELECT t.ProductId, t.PositionId, SUM(t.Quantity) AS Quantity
FROM t
GROUP BY t.ProductId, t.PositionId
HAVING SUM(t.Quantity) > 0;

Cette requête a un plan plus simple et se termine en moins de 400 ms sur ma machine:

entrez la description de l'image ici

La meilleure partie est que vous n'aurez pas à modifier le code d'application qui charge les données dans la ProductPositionLogtable. Il vous suffit de vérifier que la surcharge DML de la paire de vues indexées est acceptable.


2

Je ne pense pas vraiment que votre approche actuelle soit si inefficace. Cela semble être une façon assez simple de le faire. Une autre approche pourrait être d'utiliser une UNPIVOTclause, mais je ne suis pas sûr que ce serait une amélioration des performances. J'ai implémenté les deux approches avec le code ci-dessous (un peu plus de 5 millions de lignes), et chacune est revenue en environ 2 secondes sur mon ordinateur portable, donc je ne suis pas sûr de ce qui est si différent de mon ensemble de données par rapport au vrai. Je n'ai même pas ajouté d'index (autre qu'une clé primaire LogId).

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[ProductPositionLog]') AND type in (N'U'))
BEGIN
CREATE TABLE [dbo].[ProductPositionLog] (
[LogId] int IDENTITY(1, 1) NOT NULL PRIMARY KEY,
[ProductId] int NULL,
[FromPositionId] int NULL,
[ToPositionId] int NULL,
[Date] datetime NULL,
[Quantity] int NULL
)
END;
GO

SET IDENTITY_INSERT [ProductPositionLog] ON

INSERT INTO [ProductPositionLog] ([LogId], [ProductId], [FromPositionId], [ToPositionId], [Date], [Quantity])
VALUES (1, 123, 0, 1, '2018-01-01 08:10:22', 5)
INSERT INTO [ProductPositionLog] ([LogId], [ProductId], [FromPositionId], [ToPositionId], [Date], [Quantity])
VALUES (2, 123, 0, 2, '2018-01-03 15:15:10', 9)
INSERT INTO [ProductPositionLog] ([LogId], [ProductId], [FromPositionId], [ToPositionId], [Date], [Quantity])
VALUES (3, 123, 1, 3, '2018-01-07 21:08:56', 3)
INSERT INTO [ProductPositionLog] ([LogId], [ProductId], [FromPositionId], [ToPositionId], [Date], [Quantity])
VALUES (4, 123, 3, 0, '2018-02-09 10:03:23', 2)
INSERT INTO [ProductPositionLog] ([LogId], [ProductId], [FromPositionId], [ToPositionId], [Date], [Quantity])
VALUES (5, 123, 2, 3, '2018-02-09 10:03:23', 4)
SET IDENTITY_INSERT [ProductPositionLog] OFF

GO

INSERT INTO ProductPositionLog
SELECT ProductId + 1,
  FromPositionId + CASE WHEN FromPositionId = 0 THEN 0 ELSE 1 END,
  ToPositionId + CASE WHEN ToPositionId = 0 THEN 0 ELSE 1 END,
  [Date], Quantity
FROM ProductPositionLog
GO 20

-- Henrik's original solution.
WITH t AS
(
    SELECT ToPositionId AS PositionId, SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY ToPositionId, ProductId
    UNION
    SELECT FromPositionId AS PositionId, -SUM(Quantity) AS Quantity, ProductId 
    FROM ProductPositionLog
    GROUP BY FromPositionId, ProductId
)
SELECT t.ProductId, t.PositionId, SUM(t.Quantity) AS Quantity
FROM t
WHERE NOT t.PositionId = 0
GROUP BY t.ProductId, t.PositionId
HAVING SUM(t.Quantity) > 0
GO

-- Same results via unpivot
SELECT ProductId, PositionId,
  SUM(CAST(TransferType AS INT) * Quantity) AS Quantity
FROM   
   (SELECT ProductId, Quantity, FromPositionId AS [-1], ToPositionId AS [1]
   FROM ProductPositionLog) p  
  UNPIVOT  
     (PositionId FOR TransferType IN 
        ([-1], [1])
  ) AS unpvt
WHERE PositionId <> 0
GROUP BY ProductId, PositionId

En ce qui concerne les points de contrôle, cela me semble une idée raisonnable. Puisque vous dites que les mises à jour et les suppressions sont vraiment peu fréquentes, j'ajouterais simplement un déclencheur ProductPositionLogqui se déclenche lors de la mise à jour et de la suppression et qui ajuste la table de point de contrôle de manière appropriée. Et juste pour être encore plus sûr, je recalculerais occasionnellement les tables de points de contrôle et de cache.


Merci pour vos tests! Comme j'ai commenté ma question ci-dessus, j'ai écrit le mauvais temps d'exécution dans ma question (pour cette requête spécifique), c'est plus proche de 10 secondes. Pourtant, c'est un peu plus que dans vos tests, je suppose que cela pourrait être dû à un blocage ou quelque chose comme ça. La raison de mon système de point de contrôle serait de minimiser la charge sur le serveur, et ce serait un moyen de s'assurer que les performances restent bonnes à mesure que le journal augmente. J'ai soumis un plan de requête ci-dessus si vous voulez y jeter un œil. Merci.
Henrik
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.