Calcolo della quantità di scorte in base al registro delle modifiche


10

Immagina di avere la seguente struttura di tabella:

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

FromPositionIde ToPositionIdsono posizioni azionarie. Alcuni ID posizione: ad esempio hanno un significato speciale 0. Un evento da o verso 0indica che lo stock è stato creato o rimosso. Da 0potrebbe essere stock da una consegna e 0potrebbe essere un ordine spedito.

Questa tabella contiene attualmente circa 5,5 milioni di righe. Calcoliamo il valore delle scorte per ciascun prodotto e la posizione in una tabella di cache in una pianificazione utilizzando una query simile a questa:

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

Anche se questo si completa in un ragionevole lasso di tempo (circa 20 secondi), ritengo che questo sia un modo abbastanza inefficiente per calcolare i valori delle azioni. Raramente facciamo altro che INSERT: s in questa tabella, ma a volte entriamo e regoliamo la quantità o rimuoviamo una riga manualmente a causa di errori da parte delle persone che generano queste righe.

Ho avuto l'idea di creare "checkpoint" in una tabella separata, di calcolare il valore fino a un determinato momento e di usarlo come valore iniziale durante la creazione della nostra tabella cache quantità stock:

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

Il fatto che a volte cambiamo le righe pone un problema a questo, in quel caso dobbiamo anche ricordare di rimuovere qualsiasi checkpoint creato dopo la riga di registro che abbiamo modificato. Ciò potrebbe essere risolto non calcolando i punti di controllo fino ad ora, ma lasciando un mese tra ora e l'ultimo punto di controllo (molto raramente apportiamo modifiche molto indietro).

Il fatto che a volte abbiamo bisogno di cambiare le righe è difficile da evitare e vorrei poterlo fare ancora, non è mostrato in questa struttura ma gli eventi di registro sono talvolta legati ad altri record in altre tabelle e aggiungendo un'altra riga di registro per ottenere la giusta quantità a volte non è possibile.

La tabella dei registri sta crescendo piuttosto velocemente e il tempo di calcolo aumenterà solo con il tempo.

Quindi alla mia domanda, come lo risolveresti? Esiste un modo più efficiente per calcolare il valore attuale dello stock? La mia idea di checkpoint è buona?

Stiamo eseguendo SQL Server 2014 Web (12.0.5511)

Piano di esecuzione: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

In realtà ho dato il tempo di esecuzione sbagliato sopra, 20s è stato il tempo impiegato dall'aggiornamento completo della cache. L'esecuzione di questa query richiede circa 6-10 secondi (8 secondi quando ho creato questo piano di query). C'è anche un join in questa query che non era nella domanda originale.

Risposte:


6

A volte è possibile migliorare le prestazioni della query semplicemente effettuando un po 'di ottimizzazione anziché modificare l'intera query. Ho notato nel tuo piano di query effettivo che la query si riversa in tempdb in tre punti. Ecco un esempio:

fuoriuscite di tempdb

La risoluzione di tali sversamenti tempdb può migliorare le prestazioni. Se Quantityè sempre non negativo, è possibile sostituirlo UNIONcon il UNION ALLquale probabilmente cambierà l'operatore hash union in qualcos'altro che non richiede una concessione di memoria. Gli altri sversamenti di tempdb sono causati da problemi con la stima della cardinalità. Sei su SQL Server 2014 e stai utilizzando il nuovo CE, quindi potrebbe essere difficile migliorare le stime della cardinalità perché lo Strumento per ottimizzare le query non utilizzerà le statistiche multi-colonna. Come soluzione rapida, prendere in considerazione l'utilizzo del MIN_MEMORY_GRANTsuggerimento per la query reso disponibile in SQL Server 2014 SP2. La concessione di memoria della query è di soli 49104 KB e la concessione massima disponibile è di 5054840 KB, quindi speriamo che aumentarla non influisca troppo sulla concorrenza. Il 10% è un'ipotesi di partenza ragionevole, ma potrebbe essere necessario modificarlo e farlo a seconda dell'hardware e dei dati. Mettendo tutto insieme, ecco come potrebbe apparire la tua query:

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

Se desideri migliorare ulteriormente le prestazioni, ti consiglio di provare le viste indicizzate invece di costruire e mantenere la tua tabella di checkpoint. Le viste indicizzate sono significativamente più facili da ottenere rispetto a una soluzione personalizzata che coinvolge la propria tabella materializzata o i trigger. Aggiungeranno una piccola quantità di sovraccarico a tutte le operazioni DML, ma potrebbe consentire di rimuovere alcuni degli indici non cluster attualmente disponibili. Le viste indicizzate sembrano essere supportate nell'edizione Web del prodotto.

Ci sono alcune restrizioni sulle viste indicizzate, quindi dovrai crearne una coppia. Di seguito è riportato un esempio di implementazione, insieme ai dati falsi che ho usato per i test:

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  

Senza le visualizzazioni indicizzate, la query impiega circa 2,7 secondi per terminare sul mio computer. Ricevo un piano simile al tuo, tranne che per i miei percorsi in serie:

inserisci qui la descrizione dell'immagine

Credo che dovrai interrogare le viste indicizzate con il NOEXPANDsuggerimento perché non sei in edizione enterprise. Ecco un modo per farlo:

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;

Questa query ha un piano più semplice e termina in meno di 400 ms sul mio computer:

inserisci qui la descrizione dell'immagine

La parte migliore è che non dovrai modificare alcun codice dell'applicazione che carica i dati nella ProductPositionLogtabella. Devi semplicemente verificare che l'overhead DML della coppia di viste indicizzate sia accettabile.


2

Non credo proprio che il tuo attuale approccio sia così inefficiente. Sembra un modo abbastanza semplice per farlo. Un altro approccio potrebbe essere quello di utilizzare una UNPIVOTclausola, ma non sono sicuro che sarebbe un miglioramento delle prestazioni. Ho implementato entrambi gli approcci con il codice seguente (poco più di 5 milioni di righe) e ognuno è tornato in circa 2 secondi sul mio laptop, quindi non sono sicuro di cosa sia così diverso nel mio set di dati rispetto a quello reale. Non ho nemmeno aggiunto alcun indice (tranne una chiave primaria su 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

Per quanto riguarda i checkpoint, mi sembra un'idea ragionevole. Dato che dici che gli aggiornamenti e le eliminazioni sono davvero poco frequenti, aggiungerei semplicemente un trigger ProductPositionLogche si attiva all'aggiornamento e all'eliminazione e che regola la tabella del checkpoint in modo appropriato. E solo per essere più sicuro, ricalcolerei il checkpoint e le tabelle della cache da zero di tanto in tanto.


Grazie per i tuoi test! Mentre ho commentato la mia domanda sopra ho scritto un tempo di esecuzione errato nella mia domanda (per questa specifica query), è più vicino a 10 secondi. Tuttavia, è un po 'più che nei tuoi test. Immagino che potrebbe essere dovuto a blocchi o qualcosa del genere. Il motivo del mio sistema di checkpoint sarebbe minimizzare il carico sul server e sarebbe un modo per assicurarsi che le prestazioni rimangano buone man mano che il registro cresce. Ho inviato un piano di query sopra se vuoi dare un'occhiata. Grazie.
Henrik,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.