web-dev-qa-db-ja.com

変更ログに基づく在庫数量の計算

次のテーブル構造があるとします。

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

FromPositionIdToPositionIdは在庫ポジションです。一部のポジションID:は特別な意味を持っています(例:0)。 0との間のイベントは、在庫が作成または削除されたことを意味します。から0は配達からの在庫であり、0は出荷された注文である可能性があります。

このテーブルは現在約550万行を保持しています。次のようなクエリを使用して、各製品の在庫値を計算し、スケジュールに基づいてキャッシュテーブルに配置します。

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

行を変更することがあるという事実はこれに問題を引き起こします。その場合、変更したログ行の後に作成されたチェックポイントも削除することを忘れないでください。これは、これまでチェックポイントを計算せずに、現在と最後のチェックポイントの間に1か月を空けることで解決できます(これほど前に変更を加えることはほとんどありません)。

行を変更する必要があるという事実を回避するのは難しいため、これを引き続き実行できるようにしたいのですが、この構造には示されていませんが、ログイベントは他のテーブルの他のレコードに関連付けられており、別のログ行を追加しています適切な量​​を得ることは時々不可能です。

ログテーブルは、ご想像のとおり、かなり急速に成長しており、計算時間は時間とともに増加します。

だから私の質問に、これをどのように解決しますか?現在の株価を計算するより効率的な方法はありますか?チェックポイントの私の考えは良いものですか?

SQL Server 2014 Web(12.0.5511)を実行しています

実行計画: https://www.brentozar.com/pastetheplan/?id=Bk8gyc68Q

上記で実際に間違った実行時間を指定しました。キャッシュの完全な更新にかかった時間は20秒でした。このクエリの実行には、約6〜10秒かかります(このクエリプランを作成したときは8秒)。このクエリには、元の質問にはなかった結合もあります。

10
Henrik

クエリ全体を変更する代わりに、チューニングを少し行うだけでクエリのパフォーマンスを向上できる場合があります。実際のクエリプランで、クエリがtempdbに3か所で溢れることに気付きました。以下はその一例です。

tempdb spills

Tempdbの流出を解決すると、パフォーマンスが向上する場合があります。 Quantityが常に負でない場合は、UNIONUNION ALLで置き換えることができます。これにより、ハッシュ結合演算子がメモリの許可を必要としない別の演算子に変更される可能性があります。その他のtempdbの流出は、カーディナリティの推定に関する問題が原因です。 SQL Server 2014を使用していて、新しいCEを使用しているため、クエリオプティマイザーが複数列の統計を使用しないため、カーディナリティの見積もりを改善することが難しい場合があります。迅速な解決策として、 SQL Server 2014 SP2 で利用可能になったMIN_MEMORY_GRANTクエリヒントの使用を検討してください。クエリのメモリ許可は49104 KBのみで、使用可能な最大許可は5054840 KBであるため、増加しても同時実行性にあまり影響を与えないことを願っています。 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操作にわずかなオーバーヘッドを追加しますが、現在持っている非クラスター化インデックスの一部を削除できる場合があります。インデックス付きビューは、製品のWebエディションで supported のように見えます。

インデックス付きビューにはいくつかの制限があるため、それらのペアを作成する必要があります。以下は、実装例と、テストに使用した偽のデータです。

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秒かかります。私があなたと同じような計画を立てていますが、私のものはシリアルで実行されます:

enter image description here

エンタープライズ版を使用していないため、NOEXPANDヒントを使用してインデックス付きビューをクエリする必要があると思います。これを行う1つの方法を次に示します。

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ミリ秒未満で終了します。

enter image description here

最良の部分は、ProductPositionLogテーブルにデータをロードするアプリケーションコードを変更する必要がないことです。インデックス付きビューのペアのDMLオーバーヘッドが許容範囲内であることを確認する必要があるだけです。

6
Joe Obbish

私はあなたの現在のアプローチがそれほど非効率的だとは本当に思っていません。それを行うにはかなり簡単な方法のようです。 UNPIVOT句を使用することもできますが、パフォーマンスが向上するかどうかはわかりません。私は両方のアプローチを以下のコード(500万行をわずかに超える)で実装し、それぞれがラップトップで約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に追加します。念のため、チェックポイントとキャッシュテーブルをゼロから再計算することもあります。

2
Scott M