Розрахунок кількості запасів на основі журналу змін


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є біржовими позиціями. Наприклад, деякі позиції ID: s мають особливе значення, наприклад 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 (12.0.5511)

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

Я фактично вказав неправильний час виконання вище, 20-ті - це час повного оновлення кешу. Цей запит займає десь 6-10 секунд (8 секунд, коли я створив цей план запитів). У цьому запиті також є приєднання, якого не було в первісному питанні.

Відповіді:


6

Іноді ви можете покращити ефективність запиту, лише зробивши трохи налаштування замість того, щоб змінити весь запит. Я помітив у вашому фактичному плані запитів, що ваш запит розливається до tempdb у трьох місцях. Ось один приклад:

tempdb розливається

Вирішення цих розливів tempdb може підвищити продуктивність. Якщо Quantityзавжди неотрицательно , то ви можете замінити UNIONз UNION ALLякої, ймовірно , змінити оператор хеша накидного на що - то інше , що не вимагає гранту пам'яті. Ваші інші розлиття tempdb викликані проблемами з оцінкою кардинальності. Ви на SQL Server 2014 і використовуєте новий CE, тому може бути важко поліпшити оцінки кардинальності, оскільки оптимізатор запитів не буде використовувати статистику багато стовпців. Для швидкого виправлення розглянемо використання MIN_MEMORY_GRANTпідказки для запитів, доступної в SQL Server 2014 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 секунд. Все-таки це трохи більше, ніж у ваших тестах. Я думаю, це може бути через блокування чи щось подібне. Причиною моєї системи контрольних точок було б мінімізувати навантаження на сервер, і це був би спосіб переконатися, що продуктивність залишається хорошою в міру зростання журналу. Я подав план запитів вище, якщо ви хочете подивитися. Дякую.
Генрік
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.