SQLInsertWhereNotExists_00

INSERT WHERE NOT EXISTS i minimalne logowanie

W ostatnim okresie w projekcie który aktualnie realizuję natrafiłem na dosyć ciekawy i specyficzny przypadek. Wiązał się on z procedurą napisaną w TSQL, która ładowała nowe rekordy do tabeli docelowej. Co w tym takiego ciekawego? A no to, że zachowywała się ona w dosyć nieprzewidywalny sposób i razem z kolegą (pozdrawiam Krzysiek!) próbowaliśmy rozszyfrować to dziwne zachowanie. O co w tym wszystkim chodzi? Zapraszam serdecznie do lektury.

Nie wchodząc w szczegółowy opis przypadku biznesowego przejdźmy bez owijania w bawełnę do praktyki. W pierwszej kolejności stworzymy sobie bazę danych o niezwykle kreatywnej nazwie tj. “TEST”. Nie będziemy szczegółowo jej konfigurować ponieważ do naszych celów wystarczą domyślne ustawienia – jedyne co ustawilimy jawnie to RECOVERY MODEL, który w tym przypadku przyjmie wartość SIMPLE:

CREATE DATABASE TEST
GO

ALTER DATABASE [TEST] SET RECOVERY SIMPLE WITH NO_WAIT
GO

Po co nam Simple? Ponieważ nasza baza danych jest bazą analityczną dla której kluczowe jest dostarczenie w jak najszybszy sposób danych, a nie wysoka dostępność i możliwość odtworzenia bazy do konkretnego punktu w czasie. W dalszej kolejności stworzymy sobie dwie tabele, które są kopią tabeli Fact.Sale z WideWorldImporters:

use TEST
GO

CREATE TABLE [dbo].[Source](
	[Sale Key] [bigint] NULL,
	[City Key] [int] NULL,
	[Customer Key] [int] NULL,
	[Bill To Customer Key] [int] NULL,
	[Stock Item Key] [int] NULL,
	[Invoice Date Key] [date] NULL,
	[Delivery Date Key] [date] NULL,
	[Salesperson Key] [int] NULL,
	[WWI Invoice ID] [int] NULL,
	[Description] [nvarchar](100) NULL,
	[Package] [nvarchar](50) NULL,
	[Quantity] [int] NULL,
	[Unit Price] [decimal](18, 2) NULL,
	[Tax Rate] [decimal](18, 3) NULL,
	[Total Excluding Tax] [decimal](18, 2) NULL,
	[Tax Amount] [decimal](18, 2) NULL,
	[Profit] [decimal](18, 2) NULL,
	[Total Including Tax] [decimal](18, 2) NULL,
	[Total Dry Items] [int] NULL,
	[Total Chiller Items] [int] NULL,
	[Lineage Key] [int] NULL
)
GO

CREATE TABLE [dbo].[Target](
	[Sale Key] [bigint] NULL,
	[City Key] [int] NULL,
	[Customer Key] [int] NULL,
	[Bill To Customer Key] [int] NULL,
	[Stock Item Key] [int] NULL,
	[Invoice Date Key] [date] NULL,
	[Delivery Date Key] [date] NULL,
	[Salesperson Key] [int] NULL,
	[WWI Invoice ID] [int] NULL,
	[Description] [nvarchar](100) NULL,
	[Package] [nvarchar](50) NULL,
	[Quantity] [int] NULL,
	[Unit Price] [decimal](18, 2) NULL,
	[Tax Rate] [decimal](18, 3) NULL,
	[Total Excluding Tax] [decimal](18, 2) NULL,
	[Tax Amount] [decimal](18, 2) NULL,
	[Profit] [decimal](18, 2) NULL,
	[Total Including Tax] [decimal](18, 2) NULL,
	[Total Dry Items] [int] NULL,
	[Total Chiller Items] [int] NULL,
	[Lineage Key] [int] NULL
)
GO

W dalszej kolejności uzupełniamy tabelę Source danymi z WideWorldImporters – robimy to dwudziestokrotnie abyśmy mieli trochę wierszy do testowania (pętla powinna wygenerować nieco ponad 4.5mln wierszy):

INSERT INTO [dbo].[Source] WITH (TABLOCK)
SELECT 
	*
FROM
[WideWorldImportersDW].[Fact].[Sale]
GO 20

To co będzie nas interesowało w opisywanym teście to dziennik transakcyjny – chcemy aby nasze operacje były minimalnie logowane co powinno zwiększyć wydajność i zmniejszyć zapotrzebowanie na miejsce na dysku dla dziennika ( o minimalnym logowaniu pisałem już trochę tutaj).

Żeby mieć pewność co do logowania w naszym teście zrobimy shrinka dziennika bazy testowej za pomocą odpowiedniej komendy DBCC:

DBCC SHRINKFILE (TEST_log,1)

Następnie sprawdzimy sobie rozmiar dziennika komendą DBCC SQLPERF:

DBCC SQLPERF('logspace')

Otrzymany rezultat wygląda następująco:

Tak więc 3.875MB zajmuje plik na dysku z czego wewnętrznie około 8.5% jest zajęte. Pierwszym testem będzie wstawienie danych z tabeli źródłowej do tabeli docelowej z przełącznikiem TABLOCK. Ze względu na fakt, iż:

  • tabela docelowa jest bezindeksową stertą,
  • wyspecyfikowaliśmy TABLOCK,
  • nasza baza testowa ma Recovery Mode ustawiony na SIMPLE.

to nasze polecenie INSERT SELECT powinno być minimalnie logowanie – czy tak się stało? Już sprawdzamy.

INSERT INTO [dbo].[Target] WITH (TABLOCK)
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
SELECT 
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key] [int]
FROM [dbo].[Source] AS S

Dziennik troszeczkę urósł do 323MB i ze względu na specyfikę działania trybu SIMPLE zajęte miejsce po zatwierdzeniu transakcji może być natychmiast użyte przez kolejne transakcje dlatego taka niska wartość procentowego użycia. W naszym teście będziemy zatem sprawdzać przede wszystkim rozmiar loga, a nie procentową wartość “użytej przestrzeni”.

Powiedzmy, że to jest nasz “baseline” czyli wartość do której będziemy dążyć w naszych testach. Aby uzmysłowić czytelnikom jak dużo miejsca będzie zawierało pełne logowanie wykonam analogiczną operację wstawiania tylko bez wskazówki TABLOCK (oczywiście żeby nie było wątpliwości stworzyłem bazę od nowa i wykonałem DBCC SHRINKFILE, a dla czytelności pominąłem ten kod – będę to robił za każdym razem):

INSERT INTO [dbo].[Target]
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
SELECT 
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key] [int]
FROM [dbo].[Source] AS S

Wynik może niektórych zaskoczyć ponieważ dziennik urósł aż do 2499MB:

Mamy więc do czynienia z pełnym logowaniem. W dalszej kolejności przechodzimy do finalnego testu gdzie będziemy ładować tylko te wiersze, których nie ma w tabeli docelowej, a są w tabeli źródłowej. Aby to sprawdzić użyjemy konstrukcji INSERT SELECT WHERE EXISTS. Kluczem po którym będziemy sprawdzać istnienie wiersza jest Sales Key:

INSERT INTO [dbo].[Target] WITH (TABLOCK)
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
SELECT 
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key] [int]
FROM [dbo].[Source] AS S
WHERE NOT EXISTS
(
SELECT NULL
FROM [dbo].[Target] AS T
WHERE T.[Sale Key]=S.[Sale Key]
)

Oczywiście w momencie wykonywania powyższego skryptu tabela docelowa jest pusta ale przy każdym kolejnym uruchomieniu sprawdzenie będzie miało jak najbardziej sens. Po sprawdzeniu dziennika dostaliśmy dosyć zaskakującą informację:

Dostaliśmy pełne logowanie! Okazuje się, że jeżeli w użytej przez nas konstrukcji tabela docelowa istnieje w źródłowym zapytaniu SELECT to nie mamy optymalizacji w postaci minimalnego logowania. Ciekawe prawda? Gdy podejrzymy plan wykonania to wygląda on w następujący sposób:

Zgodnie z oczekiwaniami wywołany został algorytm Right Anti Semi Join przy pomocy operatora Hash Match. W rzeczywistości dbo.Target nie posiada żadnych wierszy, ale mimo to tabela hashowa do porównania została stworzona (estymowana liczba wierszy z tabeli docelowej równa była 1 dlatego też operator złączenia nie został pominięty). W tym miejscu ktoś może powiedzieć, że inna konstrukcja mogłaby być lepsza np. odfiltrowanie poprzez JOIN  – spróbujmy zatem:

FROM [dbo].[Source] AS S
LEFT JOIN [dbo].[Target] AS T
ON T.[Sale Key]=S.[Sale Key]
WHERE T.[Sale Key] IS NULL

Jednakże po planie zapytania możemy zauważyć, że jest to zdecydowanie gorsze podejście bo filtracja następuje bardzo późno tj. po wykonaniu złączenia HASH MATCH czyli po zbudowaniu tabeli hashy:

Nie dość, że straciliśmy na wydajności pod kątem algorytmu użytego do wstawienia danych to jeszcze użycie dziennika wcale nie zostało zoptymalizowane:

Podobne rezultaty otrzymamy w momencie gdy użyjemy takich konstrukcji jak NOT IN, którego opis w tym miejscu pominę. Ciekawą alternatywą może być również użycie EXCEPT do wyekstrahowania ze źródła tylko tych wierszy, których nie ma w tabeli docelowej:

INSERT INTO [dbo].[Target] WITH (TABLOCK)
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
SELECT 
	 S.[Sale Key]
	,S.[City Key]
	,S.[Customer Key]
	,S.[Bill To Customer Key]
	,S.[Stock Item Key]
	,S.[Invoice Date Key]
	,S.[Delivery Date Key]
	,S.[Salesperson Key]
	,S.[WWI Invoice ID]
	,S.[Description]
	,S.[Package]
	,S.[Quantity]
	,S.[Unit Price]
	,S.[Tax Rate]
	,S.[Total Excluding Tax]
	,S.[Tax Amount]
	,S.[Profit]
	,S.[Total Including Tax]
	,S.[Total Dry Items]
	,S.[Total Chiller Items]
	,S.[Lineage Key]
FROM [dbo].[Source] AS S
EXCEPT 
SELECT 
	 T.[Sale Key]
	,T.[City Key]
	,T.[Customer Key]
	,T.[Bill To Customer Key]
	,T.[Stock Item Key]
	,T.[Invoice Date Key]
	,T.[Delivery Date Key]
	,T.[Salesperson Key]
	,T.[WWI Invoice ID]
	,T.[Description]
	,T.[Package]
	,T.[Quantity]
	,T.[Unit Price]
	,T.[Tax Rate]
	,T.[Total Excluding Tax]
	,T.[Tax Amount]
	,T.[Profit]
	,T.[Total Including Tax]
	,T.[Total Dry Items]
	,T.[Total Chiller Items]
	,T.[Lineage Key]
FROM [dbo].[Target] AS T

Problem z tym podejściem jest taki, że nie zawsze jest tak, że chcemy sprawdzać to czy całe wiersze różnią się między sobą, a jedynie kolumny kluczowe. Dla niektórych przypadków może to jednak być jakieś wyjście – plan też wygląda interesująco:

Operacja już nieco “cięższa” bo mamy wykonywanie na wielu wątkach (widać to we właściwościom, a na załączonym obrazu można to dostrzec dzięki występowaniu operatora Parallelism i “strzałkach” przy poszczególnych operatorach). Dodatkowo widzimy, że tabelka hashy jest budowana dwukrotnie co może mieć wpływ na wydajność. Gdy spojrzymy na dziennik transakcyjny to ciekawostką jest zdecydowanie zoptymalizowane jego użycie:

Ostatnią konstrukcją jaką chciałbym przetestować jest niesławny MERGE (który często powoduje dużo problemów jednakże na ten moment pozwolę sobie ten fakt pominąć). Jego zapis wygląda następująco:

MERGE dbo.Target WITH (TABLOCK) AS Target
USING dbo.Source AS Source
ON source.[Sale Key] = target.[Sale Key]
WHEN NOT MATCHED THEN
INSERT
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
VALUES
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
);

Mimo spełnienia warunków związanych z minimalnym logowaniem to z tej perspektywy nie wygląda to najlepiej:

Plan raczej niczego nam tutaj nie rozwiąże ale gwoli ścisłości go umieszczam:

Dla nieprzekonanych mam jeszcze zapytanie odpytujące funkcję sys.fn_dblog sprawdzające jakie operacje znalazły się w dzienniku:

SELECT 
	 Operation
	,COUNT(*) 
FROM sys.fn_dblog(NULL,NULL)
WHERE AllocUnitName='dbo.Target'
GROUP BY Operation

Operacje minimalnie logowane z definicji nie powinny umieszczać w dzienniku informacji o wstawieniu wierszy, a jak widać powyżej tak właśnie się stało. Jak to możliwe, że nie mamy minimalnego logowania mimo, że scenariusz jest naprawdę prosty i spełniliśmy wszystkie wymogi? A no właśnie. W moim przekonaniu aby osiągnąć pożądany stan użycia dziennika zawsze powinniśmy testować bo nie zawsze złote zasady działają. W tym przypadku nie najgorsze rezultaty osiągnęliśmy używając EXCEPT jednak nie jest on rozwiązaniem w każdym przypadku. Oczywiście w ramach procesu ETL możemy w jakiś sposób upewnić się, że wstawiamy tylko nowe dane i całą logikę zawrzeć właśnie w narzędziu za to odpowiedzialnym. Jeśli dziennik nie jest dla nas kluczowy to możemy również wrzucać dane w batchach dzięki czemu poszczególne porcje zaczną nadpisywać go od początku i nie urośnie on do niebotycznych rozmiarów. W przypadku jednak INSERT SELECT nie mamy możliwości wyspecyfikowania rozmiaru batcha i wszystko ładowane jest niejako jako pojedynczy batch. Rozwiązań tego problemu z całą pewnością jest kilka jak np. partition switching albo użycie tabelki stagingowej – postaram się to pokazać używając tej drugiej techniki:

DROP TABLE IF EXISTS #tmpTargetKeys;

SELECT [Sale Key]
INTO #tmpTargetKeys
FROM dbo.Target;

Tabela może być standardowa lub tymczasowa – ja użyłem tej drugiej opcji. Następnie w NOT EXISTS użyłem tego właśnie obiektu tymczasowego:

INSERT INTO [dbo].[Target] WITH (TABLOCK)
(
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key]
)
SELECT 
	 [Sale Key]
	,[City Key]
	,[Customer Key]
	,[Bill To Customer Key]
	,[Stock Item Key]
	,[Invoice Date Key]
	,[Delivery Date Key]
	,[Salesperson Key]
	,[WWI Invoice ID]
	,[Description]
	,[Package]
	,[Quantity]
	,[Unit Price]
	,[Tax Rate]
	,[Total Excluding Tax]
	,[Tax Amount]
	,[Profit]
	,[Total Including Tax]
	,[Total Dry Items]
	,[Total Chiller Items]
	,[Lineage Key] [int]
FROM [dbo].[Source] AS S
WHERE NOT EXISTS
(
SELECT NULL
FROM #tmpTargetKeys AS T
WHERE T.[Sale Key]=S.[Sale Key]
)

Wiecie co? Rezultat jest dokładnie taki jakbym tego oczekiwał:

Taką tabelkę tymczasową możemy odpowiednio podrasować dodając np. indeks i kompresję jednakże to już pozostawiam poszczególnym implementacjom. Tak jak wspomniałem rozwiązań jest wiele jednakże najważniejsze dla mnie było pokazanie dosyć ciekawego przypadku który nie jest do końca oczywisty. Pozdrawiam!

1 Comment

Leave a Reply