Calcular la cantidad de existencias en función del registro de cambios

10

Imagine que tiene la siguiente estructura de tabla:

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

FromPositionIdy ToPositionIdson posiciones de stock. Algunas ID de posición tienen un significado especial, por ejemplo 0. Un evento desde o hacia 0significa que el stock fue creado o eliminado. De 0podría haber stock de una entrega y 0podría ser un pedido enviado.

Actualmente, esta tabla contiene alrededor de 5,5 millones de filas. Calculamos el valor de stock para cada producto y lo posicionamos en una tabla de caché en un horario usando una consulta que se ve así:

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

Aunque esto se completa en un período de tiempo razonable (alrededor de 20 segundos), creo que esta es una forma bastante ineficiente de calcular los valores de las acciones. Raramente hacemos algo más que INSERT: s en esta tabla, pero a veces entramos y ajustamos la cantidad o eliminamos una fila manualmente debido a errores de las personas que generan estas filas.

Tuve una idea de crear "puntos de control" en una tabla separada, calcular el valor hasta un punto específico en el tiempo y usarlo como valor inicial al crear nuestra tabla de caché de cantidad de stock:

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

El hecho de que a veces cambiemos filas plantea un problema para esto, en ese caso también debemos recordar eliminar cualquier punto de control creado después de la fila de registro que cambiamos. Esto podría resolverse no calculando los puntos de control hasta ahora, pero deja un mes entre ahora y el último punto de control (muy raramente hacemos cambios tan atrás).

Es difícil evitar el hecho de que a veces necesitamos cambiar las filas y me gustaría poder hacerlo aún, no se muestra en esta estructura, pero los eventos de registro a veces están vinculados a otros registros en otras tablas, y agregando otra fila de registro a veces no es posible obtener la cantidad correcta.

La tabla de registro está, como puede imaginar, creciendo bastante rápido y el tiempo para calcular solo aumentará con el tiempo.

Entonces, a mi pregunta, ¿cómo resolverías esto? ¿Existe una forma más eficiente de calcular el valor actual de las acciones? ¿Es buena mi idea de los puntos de control?

Estamos ejecutando SQL Server 2014 Web (12.0.5511)

Plan de ejecución: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

De hecho, di el tiempo de ejecución incorrecto anterior, 20 años fue el tiempo que tomó la actualización completa de la caché. Esta consulta tarda entre 6 y 10 segundos en ejecutarse (8 segundos cuando creé este plan de consulta). También hay una combinación en esta consulta que no estaba en la pregunta original.

Henrik
fuente

Respuestas:

6

A veces puede mejorar el rendimiento de la consulta simplemente haciendo un pequeño ajuste en lugar de cambiar toda la consulta. Noté en su plan de consulta real que su consulta se derrama a tempdb en tres lugares. Aquí hay un ejemplo:

derrames tempdb

Resolver esos derrames tempdb puede mejorar el rendimiento. Si Quantitysiempre es no negativo, puede reemplazarlo UNIONcon UNION ALLlo que probablemente cambiará el operador de unión hash a otra cosa que no requiera una concesión de memoria. Sus otros derrames tempdb son causados ​​por problemas con la estimación de cardinalidad. Está utilizando SQL Server 2014 y está utilizando el nuevo CE, por lo que puede ser difícil mejorar las estimaciones de cardinalidad porque el optimizador de consultas no utilizará estadísticas de varias columnas. Como solución rápida, considere usar la MIN_MEMORY_GRANTsugerencia de consulta disponible en SQL Server 2014 SP2. La concesión de memoria de su consulta es de solo 49104 KB y la concesión máxima disponible es de 5054840 KB, por lo que esperamos que aumentarla no afecte demasiado la concurrencia. El 10% es una suposición inicial razonable, pero es posible que deba ajustarlo y hacerlo según su hardware y datos. En conjunto, así es como se vería su consulta:

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 desea mejorar aún más el rendimiento, le recomiendo probar vistas indizadas en lugar de crear y mantener su propia tabla de puntos de control. Las vistas indexadas son significativamente más fáciles de obtener que una solución personalizada que involucra su propia tabla materializada o disparadores. Agregarán una pequeña cantidad de sobrecarga a todas las operaciones DML, pero puede permitirle eliminar algunos de los índices no agrupados que tiene actualmente. Las vistas indexadas parecen ser compatibles con la edición web del producto.

Existen algunas restricciones en las vistas indexadas, por lo que deberá crear un par de ellas. A continuación se muestra un ejemplo de implementación, junto con los datos falsos que utilicé para las pruebas:

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  

Sin las vistas indexadas, la consulta tarda aproximadamente 2,7 segundos en finalizar en mi máquina. Tengo un plan similar al tuyo, excepto que el mío se ejecuta en serie:

ingrese la descripción de la imagen aquí

Creo que deberá consultar las vistas indexadas con la NOEXPANDsugerencia porque no está en la edición Enterprise. Aquí hay una manera de hacer eso:

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;

Esta consulta tiene un plan más simple y termina en menos de 400 ms en mi máquina:

ingrese la descripción de la imagen aquí

La mejor parte es que no tendrá que cambiar el código de la aplicación que carga los datos en la ProductPositionLogtabla. Simplemente necesita verificar que la sobrecarga DML del par de vistas indexadas sea aceptable.

Joe Obbish
fuente
2

Realmente no creo que su enfoque actual sea tan ineficiente. Parece una forma bastante directa de hacerlo. Otro enfoque podría ser utilizar una UNPIVOTcláusula, pero no estoy seguro de que sea una mejora del rendimiento. Implementé ambos enfoques con el siguiente código (poco más de 5 millones de filas), y cada uno regresó en aproximadamente 2 segundos en mi computadora portátil, por lo que no estoy seguro de qué es tan diferente de mi conjunto de datos en comparación con el real. Ni siquiera agregué ningún índice (aparte de una clave principal activada 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 cuanto a los puntos de control, me parece una idea razonable. Como usted dice que las actualizaciones y las eliminaciones son muy poco frecuentes, simplemente agregaría un activador ProductPositionLogque se activará en la actualización y la eliminación y que ajustará la tabla de puntos de control adecuadamente. Y solo para estar más seguro, ocasionalmente volvería a calcular el punto de control y las tablas de caché desde cero.

Scott M
fuente
¡Gracias por tus pruebas! Como comenté sobre mi pregunta anterior, escribí el tiempo de ejecución incorrecto en mi pregunta (para esta consulta específica), está más cerca de 10 segundos. Aún así, es un poco más que en sus pruebas. Supongo que podría deberse a un bloqueo o algo así. La razón de mi sistema de punto de control sería minimizar la carga en el servidor, y sería una forma de asegurarme de que el rendimiento se mantenga bien a medida que crece el registro. Envié un plan de consulta arriba si quieres echar un vistazo. Gracias.
Henrik