Berechnung der Bestandsmenge anhand des Änderungsprotokolls

10

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

FromPositionIdund ToPositionIdsind Aktienpositionen. Einige Positions-IDs haben beispielsweise eine besondere Bedeutung 0. Ein Ereignis von oder bis 0bedeutet, dass ein Bestand erstellt oder entfernt wurde. Von 0könnte Lager von einer Lieferung sein und 0könnte eine versendete Bestellung 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 wird nur mit der Zeit zunehmen.

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.

Henrik
quelle

Antworten:

6

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 verschüttet

Das Beheben dieser Tempdb-Verschüttungen kann die Leistung verbessern. Wenn Quantityimmer nicht-negativ , dann können Sie ersetzen UNIONmit UNION ALLdem wahrscheinlich dem Hash - Operator union etwas ändert anderes , das keinen Speicher Zuschuss erfordert. 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 MIN_MEMORY_GRANTin SQL Server 2014 SP2 bereitgestellten Abfragehinweis. Die Speicherzuweisung Ihrer Abfrage beträgt nur 49104 KB, und die maximal verfügbare Zuweisung beträgt 5054840 KB. Wenn Sie sie also hoffentlich erhöhen, wirkt sich dies 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 Sichten erscheinen werden unterstützt in der Web Edition des Produkts.

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 die Abfrage auf meinem Computer etwa 2,7 Sekunden. Ich habe einen ähnlichen Plan wie Sie, außer meiner läuft in Serie:

Geben Sie hier die Bildbeschreibung ein

Ich glaube, dass Sie die indizierten Ansichten mit dem NOEXPANDHinweis 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:

Geben Sie hier die Bildbeschreibung ein

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

Joe Obbish
quelle
2

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 UNPIVOTKlausel 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 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 Kontrollpunkte 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 nur einen Auslöser hinzufügen ProductPositionLog, 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.

Scott M.
quelle
Vielen Dank für Ihre Tests! Als ich meine Frage oben kommentierte, schrieb ich die falsche Ausführungszeit in meine Frage (für diese spezielle Abfrage), sie liegt näher bei 10 Sekunden. Trotzdem ist es ein bisschen mehr als in Ihren Tests. Ich denke, es könnte an Blockierungen oder Ähnlichem liegen. Der Grund für mein Checkpoint-System wäre, die Belastung des Servers zu minimieren und auf diese Weise sicherzustellen, dass die Leistung bei wachsendem Protokoll gut bleibt. Ich habe oben einen Abfrageplan eingereicht, wenn Sie einen Blick darauf werfen möchten. Vielen Dank.
Henrik