Cálculo da quantidade de estoque com base no log de alterações

10

Imagine que você tenha a seguinte estrutura de tabela:

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 ToPositionIdsão posições de ações. Algumas IDs de posição: s têm um significado especial, por exemplo 0. Um evento de ou para 0significa que o estoque foi criado ou removido. De 0pode haver estoque de uma remessa e de 0um pedido enviado.

Atualmente, esta tabela possui cerca de 5,5 milhões de linhas. Calculamos o valor do estoque de cada produto e a posição em uma tabela de cache em uma programação usando uma consulta semelhante à seguinte:

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

Embora isso seja concluído em um período de tempo razoável (cerca de 20 segundos), sinto que essa é uma maneira bastante ineficiente de calcular os valores das ações. Raramente fazemos qualquer coisa, exceto INSERT: s nesta tabela, mas algumas vezes ajustamos a quantidade ou removemos uma linha manualmente devido a erros das pessoas que geram essas linhas.

Tive a ideia de criar "pontos de verificação" em uma tabela separada, calculando o valor até um momento específico e usando-o como valor inicial ao criar nossa tabela de cache de quantidade de estoque:

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

O fato de às vezes alterarmos as linhas apresenta um problema, nesse caso, também devemos lembrar de remover qualquer ponto de verificação criado após a linha de log que alteramos. Isso poderia ser resolvido não calculando os pontos de verificação até agora, mas deixe um mês entre agora e o último ponto de verificação (muito raramente fazemos alterações há muito tempo).

É difícil evitar o fato de que às vezes precisamos alterar as linhas e eu gostaria de poder fazer isso ainda, não é mostrado nessa estrutura, mas os eventos de log às vezes são vinculados a outros registros em outras tabelas e adicionando outra linha de log obter a quantidade certa às vezes não é possível.

A tabela de log é, como você pode imaginar, crescendo muito rápido e o tempo para calcular aumentará apenas com o tempo.

Então, para a minha pergunta, como você resolveria isso? Existe uma maneira mais eficiente de calcular o valor atual do estoque? A minha ideia de pontos de verificação é boa?

Estamos executando o SQL Server 2014 Web (12.0.5511)

Plano de execução: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

Na verdade, eu dei o tempo de execução errado acima, 20s foi o tempo que levou a atualização completa do cache. Essa consulta leva cerca de 6 a 10 segundos para ser executada (8 segundos quando eu criei este plano de consulta). Também há uma junção nesta consulta que não estava na pergunta original.

Henrik
fonte

Respostas:

6

Às vezes, você pode melhorar o desempenho da consulta apenas ajustando um pouco, em vez de alterar toda a consulta. Percebi no seu plano de consulta real que sua consulta se espalha para tempdb em três locais. Aqui está um exemplo:

tempdb derramamentos

A resolução desses derramamentos de tempdb pode melhorar o desempenho. Se Quantityé sempre não-negativa, então você pode substituir UNIONcom UNION ALLo que provavelmente irá mudar o operador de união hash para outra coisa que não requer uma concessão de memória. Seus outros derramamentos de tempdb são causados ​​por problemas com a estimativa de cardinalidade. Você está no SQL Server 2014 e está usando o novo CE, por isso pode ser difícil melhorar as estimativas de cardinalidade porque o otimizador de consulta não usará estatísticas de várias colunas. Como uma solução rápida, considere usar a MIN_MEMORY_GRANTdica de consulta disponibilizada no SQL Server 2014 SP2. A concessão de memória da sua consulta é de apenas 49104 KB e a concessão máxima disponível é de 5054840 KB, portanto, esperá-lo aumentá-lo não afetará muito a simultaneidade. 10% é um palpite inicial razoável, mas pode ser necessário ajustá-lo e pronto, dependendo do seu hardware e dados. Juntando tudo isso, é assim que sua consulta pode ser:

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 você deseja melhorar ainda mais o desempenho, recomendo experimentar visualizações indexadas em vez de criar e manter sua própria tabela de pontos de verificação. As visualizações indexadas são significativamente mais fáceis de acertar do que uma solução personalizada que envolve sua própria tabela ou gatilhos materializados. Eles adicionarão uma pequena quantidade de sobrecarga a todas as operações DML, mas isso poderá permitir a remoção de alguns dos índices não clusterizados que você possui atualmente. As visualizações indexadas parecem ser suportadas na edição web do produto.

Existem algumas restrições nas visualizações indexadas, portanto, você precisará criar um par delas. Abaixo está um exemplo de implementação, junto com os dados falsos que usei para testar:

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  

Sem as visualizações indexadas, a consulta leva cerca de 2,7 segundos para terminar na minha máquina. Eu recebo um plano semelhante ao seu, exceto o meu, que é executado em série:

insira a descrição da imagem aqui

Acredito que você precisará consultar as visualizações indexadas com a NOEXPANDdica, porque você não está na edição corporativa. Aqui está uma maneira de fazer isso:

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 tem um plano mais simples e termina em menos de 400 ms na minha máquina:

insira a descrição da imagem aqui

A melhor parte é que você não precisará alterar nenhum código do aplicativo que carrega dados na ProductPositionLogtabela. Você só precisa verificar se a sobrecarga de DML do par de visualizações indexadas é aceitável.

Joe Obbish
fonte
2

Eu realmente não acho que sua abordagem atual seja tão ineficiente. Parece uma maneira bem simples de fazer isso. Outra abordagem pode ser usar uma UNPIVOTcláusula, mas não tenho certeza de que seria uma melhoria de desempenho. Eu implementei as duas abordagens com o código abaixo (pouco mais de 5 milhões de linhas) e cada uma retornou em cerca de 2 segundos no meu laptop, por isso não tenho certeza do que há de tão diferente no meu conjunto de dados em comparação ao real. Eu nem adicionei nenhum índice (exceto uma chave primária 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

No que diz respeito aos pontos de verificação, parece-me uma ideia razoável. Como você diz que as atualizações e exclusões são realmente raras, eu adicionaria um gatilho ProductPositionLogque dispara na atualização e exclusão e que ajusta a tabela do ponto de verificação de maneira apropriada. E, para ter certeza, eu recalcularia o ponto de verificação e as tabelas de cache ocasionalmente.

Scott M
fonte
Obrigado por seus testes! Como comentei minha pergunta acima, escrevi o tempo de execução incorreto na minha pergunta (para essa consulta específica), está mais próximo de 10 segundos. Ainda assim, é um pouco mais do que nos seus testes. Acho que pode ser devido a bloqueio ou algo assim. O motivo do meu sistema de ponto de verificação seria minimizar a carga no servidor e seria uma maneira de garantir que o desempenho permaneça bom à medida que o log cresce. Enviei um plano de consulta acima, se você quiser dar uma olhada. Obrigado.
Henrik