it-swarm.com.de

Berechnung der Bestandsmenge anhand des Änderungsprotokolls

Stellen Sie sich vor, Sie haben die folgende Tabellenstruktur:

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

FromPositionId und ToPositionId sind Aktienpositionen. Einige Positions-IDs haben eine besondere Bedeutung, zum Beispiel 0. Ein Ereignis von oder nach 0 bedeutet, dass der Bestand erstellt oder entfernt wurde. Von 0 könnte von einer Lieferung auf Lager sein und an 0 könnte eine Versandbestellung sein.

Diese Tabelle enthält derzeit rund 5,5 Millionen Zeilen. Wir berechnen den Bestandswert für jedes Produkt und die Position in einer Cache-Tabelle nach einem Zeitplan mithilfe einer Abfrage, die ungefähr so ​​aussieht:

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

Obwohl dies in angemessener Zeit (ca. 20 Sekunden) abgeschlossen ist, halte ich dies für eine ziemlich ineffiziente Methode zur Berechnung der Aktienwerte. Wir machen selten etwas anderes als INSERT: s in dieser Tabelle, aber manchmal gehen wir hinein und passen die Menge an oder entfernen eine Zeile manuell aufgrund von Fehlern der Personen, die diese Zeilen generieren.

Ich hatte die Idee, "Prüfpunkte" in einer separaten Tabelle zu erstellen, den Wert bis zu einem bestimmten Zeitpunkt zu berechnen und diesen als Startwert beim Erstellen unserer Bestandsmengen-Cache-Tabelle zu verwenden:

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

Die Tatsache, dass wir manchmal Zeilen ändern, stellt ein Problem dar. In diesem Fall müssen wir auch daran denken, alle Prüfpunkte zu entfernen, die nach der von uns geänderten Protokollzeile erstellt wurden. Dies könnte gelöst werden, indem die Kontrollpunkte bis jetzt nicht berechnet werden, sondern ein Monat zwischen jetzt und dem letzten Kontrollpunkt verbleibt (wir nehmen sehr, sehr selten Änderungen so weit zurück).

Die Tatsache, dass wir manchmal Zeilen ändern müssen, ist schwer zu vermeiden, und ich möchte dies weiterhin tun können. Dies wird in dieser Struktur nicht angezeigt, aber die Protokollereignisse sind manchmal mit anderen Datensätzen in anderen Tabellen verknüpft und fügen eine weitere Protokollzeile hinzu Die richtige Menge zu bekommen ist manchmal nicht möglich.

Die Protokolltabelle wächst, wie Sie sich vorstellen können, ziemlich schnell und die Zeit zum Berechnen nimmt nur mit der Zeit zu.

Also auf meine Frage, wie würden Sie das lösen? Gibt es eine effizientere Methode zur Berechnung des aktuellen Aktienwerts? Ist meine Vorstellung von Checkpoints gut?

Wir führen SQL Server 2014 Web (12.0.5511) aus.

Ausführungsplan: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

Ich habe oben tatsächlich die falsche Ausführungszeit angegeben. 20s war die Zeit, die die vollständige Aktualisierung des Caches in Anspruch nahm. Die Ausführung dieser Abfrage dauert ungefähr 6-10 Sekunden (8 Sekunden, als ich diesen Abfrageplan erstellt habe). Es gibt auch einen Join in dieser Abfrage, der nicht in der ursprünglichen Frage enthalten war.

10
Henrik

Manchmal können Sie die Abfrageleistung verbessern, indem Sie ein wenig optimieren, anstatt Ihre gesamte Abfrage zu ändern. Ich habe in Ihrem aktuellen Abfrageplan festgestellt, dass Ihre Abfrage an drei Stellen auf Tempdb übertragen wird. Hier ist ein Beispiel:

(tempdb spills

Das Beheben dieser Tempdb-Verschüttungen kann die Leistung verbessern. Wenn Quantity immer nicht negativ ist, können Sie UNION durch UNION ALL Ersetzen, wodurch der Hash-Union-Operator wahrscheinlich in etwas anderes geändert wird, für das keine Speicherzuweisung erforderlich ist. Ihre anderen Tempdb-Verschüttungen werden durch Probleme mit der Kardinalitätsschätzung verursacht. Sie arbeiten mit SQL Server 2014 und verwenden das neue CE. Daher kann es schwierig sein, die Kardinalitätsschätzungen zu verbessern, da das Abfrageoptimierungsprogramm keine mehrspaltigen Statistiken verwendet. Verwenden Sie als schnelle Lösung den Abfragehinweis MIN_MEMORY_GRANT, Der in SQL Server 2014 SP2 verfügbar gemacht wurde. Die Speicherzuweisung Ihrer Abfrage beträgt nur 49104 KB und die maximal verfügbare Zuweisung beträgt 5054840 KB. Hoffentlich wirkt sich eine Erhöhung nicht zu stark auf die Parallelität aus. 10% ist eine vernünftige Anfangsschätzung, aber Sie müssen sie möglicherweise anpassen und abhängig von Ihrer Hardware und Ihren Daten durchführen. Alles in allem könnte Ihre Anfrage so aussehen:

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);

Wenn Sie die Leistung weiter verbessern möchten, empfehlen wir, indizierte Ansichten auszuprobieren, anstatt eine eigene Prüfpunkttabelle zu erstellen und zu verwalten. Indizierte Ansichten sind wesentlich einfacher zu korrigieren als eine benutzerdefinierte Lösung, die Ihre eigene materialisierte Tabelle oder Trigger enthält. Sie fügen allen DML-Vorgängen einen geringen Overhead hinzu, können jedoch möglicherweise einige der derzeit nicht gruppierten Indizes entfernen. Indizierte Ansichten scheinen in der Web-Edition des Produkts nterstützt zu sein.

Es gibt einige Einschränkungen für indizierte Ansichten, daher müssen Sie ein Paar davon erstellen. Unten finden Sie eine Beispielimplementierung zusammen mit den gefälschten Daten, die ich zum Testen verwendet habe:

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  

Ohne die indizierten Ansichten dauert es ungefähr 2,7 Sekunden, bis die Abfrage auf meinem Computer abgeschlossen ist. Ich habe einen ähnlichen Plan wie Sie, außer meiner läuft in Serie:

(enter image description here

Ich glaube, dass Sie die indizierten Ansichten mit dem Hinweis NOEXPAND abfragen müssen, da Sie nicht in der Enterprise Edition sind. Hier ist eine Möglichkeit, dies zu tun:

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;

Diese Abfrage hat einen einfacheren Plan und endet auf meinem Computer in weniger als 400 ms:

(enter image description here

Das Beste daran ist, dass Sie keinen Anwendungscode ändern müssen, der Daten in die Tabelle ProductPositionLog lädt. Sie müssen lediglich überprüfen, ob der DML-Overhead des Paares indizierter Ansichten akzeptabel ist.

6
Joe Obbish

Ich denke nicht wirklich, dass Ihr aktueller Ansatz so ineffizient ist. Scheint ein ziemlich einfacher Weg zu sein. Ein anderer Ansatz könnte darin bestehen, eine UNPIVOT -Klausel zu verwenden, aber ich bin nicht sicher, ob dies eine Leistungsverbesserung wäre. Ich habe beide Ansätze mit dem folgenden Code implementiert (etwas mehr als 5 Millionen Zeilen) und jeweils in ca. 2 Sekunden auf meinem Laptop zurückgegeben. Daher bin ich mir nicht sicher, was an meinem Datensatz so anders ist als im realen. Ich habe nicht einmal Indizes hinzugefügt (außer einem Primärschlüssel für 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

Was die Checkpoints angeht, scheint es mir eine vernünftige Idee zu sein. Da Sie sagen, dass die Aktualisierungen und Löschungen wirklich selten sind, würde ich einfach einen Auslöser für ProductPositionLog hinzufügen, der beim Aktualisieren und Löschen ausgelöst wird und der die Prüfpunkttabelle entsprechend anpasst. Und um ganz sicher zu gehen, habe ich die Checkpoint- und Cache-Tabellen gelegentlich neu berechnet.

2
Scott M