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


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

FromPositionIdToPositionId在庫位置です。一部のポジションID:には特別な意味があります0。イベントの開始または終了0は、ストックが作成または削除されたことを意味します。From 0は配送からの在庫で、to 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秒)。このクエリには、元の質問にはなかった結合もあります。

回答:


6

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

tempdbの流出

tempdbの流出を解決すると、パフォーマンスが向上する場合があります。場合はQuantity、常に非負である、あなたは置き換えることができUNIONUNION ALLいる可能性の高いメモリ許可を必要としない何か他のものにハッシュunion演算子を変更します。その他の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エディションでサポートされているようです。

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

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エンタープライズ版ではないので、ヒント付きのインデックス付きビューをクエリする必要があると思います。これを行う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ミリ秒未満で終了します。

ここに画像の説明を入力してください

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


2

私はあなたの現在のアプローチがそれほど非効率的だとは本当に思っていません。それを行うにはかなり簡単な方法のようです。別のアプローチとして、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、チェックポイントテーブルを適切に調整するトリガーを追加します。念のため、チェックポイントとキャッシュテーブルを最初から再計算することもあります。


テストありがとうございます!上記の質問にコメントしたので、質問に間違った実行時間を記述しました(この特定のクエリの場合)、10秒に近いです。それでも、テストよりも少し多いですが、それはブロッキングなどが原因である可能性があります。私のチェックポイントシステムの理由は、サーバーへの負荷を最小限に抑えることであり、ログが大きくなるにつれてパフォーマンスを良好に保つ方法です。ご覧になりたい方は、上のクエリプランを提出しました。ありがとう。
Henrik
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.