Расчет количества на основе журнала изменений

10

Представьте, что у вас есть следующая структура таблицы:

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и ToPositionIdпозиции на складе. Например, некоторые идентификаторы позиции имеют особое значение 0. Событие от или до 0означает, что запас был создан или удален. От 0может быть на складе от доставки и 0может быть отправлен заказ.

Эта таблица в настоящее время содержит около 5,5 миллионов строк. Мы рассчитываем стоимость акций для каждого продукта и помещаем их в кеш-таблицу по расписанию, используя запрос, который выглядит примерно так:

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

Несмотря на то, что это завершается за разумное время (около 20 секунд), я чувствую, что это довольно неэффективный способ расчета стоимости акций. Мы редко делаем что-либо, кроме INSERT: s в этой таблице, но иногда мы входим и корректируем количество или удаляем строку вручную из-за ошибок людей, генерирующих эти строки.

У меня была идея создать «контрольные точки» в отдельной таблице, рассчитать значение до определенного момента времени и использовать его в качестве начального значения при создании нашей таблицы кэша количества запаса:

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

Тот факт, что мы иногда меняем строки, представляет собой проблему, в этом случае мы также должны помнить об удалении любой контрольной точки, созданной после строки журнала, которую мы изменили. Эту проблему можно решить, не вычисляя контрольные точки до настоящего момента, а оставляя месяц между текущим и последним контрольным пунктом (мы очень редко вносим изменения так далеко назад).

Трудно избежать того факта, что нам иногда нужно менять строки, и я хотел бы иметь возможность делать это, это не показано в этой структуре, но события журнала иногда привязываются к другим записям в других таблицах и добавлению еще одной строки журнала получить нужное количество иногда невозможно.

Таблица журналов, как вы можете себе представить, растет довольно быстро, и время для расчета будет только увеличиваться со временем.

Итак, на мой вопрос, как бы вы решили это? Есть ли более эффективный способ расчета текущей стоимости акций? Хороша ли моя идея контрольно-пропускных пунктов?

Мы работаем с SQL Server 2014 Web (12.0.5511)

План выполнения: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

Я фактически указал неверное время выполнения выше, 20 с - это время, которое потребовалось для полного обновления кэша. Этот запрос занимает где-то около 6-10 секунд (8 секунд, когда я создал этот план запроса). В этом запросе также есть соединение, которого не было в исходном вопросе.

Хенрик
источник

Ответы:

6

Иногда вы можете улучшить производительность запросов, просто выполнив небольшую настройку вместо изменения всего запроса. В вашем реальном плане запросов я заметил, что ваш запрос помещается в базу данных tempdb в трех местах. Вот один пример:

разливы tempdb

Устранение этих разливов tempdb может улучшить производительность. Если Quantityвсегда неотрицательно , то вы можете заменить UNIONс UNION ALLкоторой, вероятно , изменить оператор хэша накидного на что - то другое , что не требует гранта памяти. Ваши другие разливы из базы данных вызваны проблемами с оценкой мощности. Вы работаете на SQL Server 2014 и используете новый CE, поэтому может быть сложно улучшить оценки количества элементов, поскольку оптимизатор запросов не будет использовать статистику по нескольким столбцам. В качестве быстрого исправления рассмотрите возможность использования MIN_MEMORY_GRANTподсказки запроса, сделанной в SQL Server 2014 с пакетом обновления 2 (SP2)., Объем памяти, выделенной вашему запросу, составляет всего 49104 КБ, а максимальный доступный размер - 5054840 КБ, поэтому, надеюсь, его увеличение не сильно повлияет на параллелизм. 10% - это разумное начальное предположение, но вам может потребоваться настроить его в зависимости от вашего оборудования и данных. Собрав все это вместе, ваш запрос может выглядеть так:

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

Если вы хотите еще больше повысить производительность, я рекомендую попробовать индексированные представления вместо того, чтобы создавать и поддерживать собственную таблицу контрольных точек. Индексированные представления значительно проще получить, чем пользовательское решение, включающее вашу собственную материализованную таблицу или триггеры. Они добавят небольшое количество накладных расходов ко всем операциям DML, но это может позволить вам удалить некоторые некластеризованные индексы, которые у вас есть в настоящее время. Индексированные представления поддерживаются в веб-версии продукта.

Существуют некоторые ограничения для индексированных представлений, поэтому вам нужно создать пару из них. Ниже приведен пример реализации вместе с поддельными данными, которые я использовал для тестирования:

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  

Без индексированных представлений на моем компьютере запрос занимает около 2,7 секунды. Я получаю план, похожий на ваш, за исключением моих серийных серий:

введите описание изображения здесь

Я считаю, что вам нужно будет запросить индексированные представления с NOEXPANDподсказкой, потому что вы не на корпоративной версии. Вот один из способов сделать это:

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;

Этот запрос имеет более простой план и заканчивается на моей машине менее чем за 400 мс:

введите описание изображения здесь

Самое приятное то, что вам не придется изменять код приложения, который загружает данные в ProductPositionLogтаблицу. Вам просто нужно убедиться, что накладные расходы DML пары индексированных представлений приемлемы.

Джо Оббиш
источник
2

Я не думаю, что ваш нынешний подход настолько неэффективен. Похоже, довольно простой способ сделать это. Другим подходом может быть использование UNPIVOTпредложения, но я не уверен, что это будет улучшение производительности. Я реализовал оба подхода с помощью приведенного ниже кода (чуть более 5 миллионов строк), и каждый из них возвратился примерно через 2 секунды на моем ноутбуке, поэтому я не уверен, что так сильно отличается в моем наборе данных по сравнению с реальным. Я даже не добавил никаких индексов (кроме первичного ключа на 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

Что касается контрольно-пропускных пунктов, мне кажется, что это разумная идея. Поскольку вы говорите, что обновления и удаления действительно редки, я бы просто добавил триггер, ProductPositionLogкоторый запускает обновление и удаление и который соответствующим образом корректирует таблицу контрольных точек. И просто для большей уверенности, я бы иногда пересчитывал контрольные точки и таблицы кеша с нуля.

Скотт М
источник
Спасибо за ваши тесты! Поскольку я прокомментировал мой вопрос выше, я написал неправильное время выполнения в своем вопросе (для этого конкретного запроса), оно ближе к 10 секундам. Тем не менее, это немного больше, чем в ваших тестах. Я думаю, это может быть связано с блокировкой или чем-то в этом роде. Причиной моей системы контрольных точек было бы минимизировать нагрузку на сервер, и это был бы способ убедиться, что производительность остается хорошей по мере роста журнала. Я отправил план запроса выше, если вы хотите посмотреть. Спасибо.
Хенрик