Że co?
Ostatnio Brent Ozar miał bardzo ciekawy livestream odnośnie znajdowania najdłuższych wartości tekstowych w kolumnach w tabeli. W tym celu użył tabeli dbo.Users z kopii bazy StackOverflow i chciał znaleźć po dwie najdłuższe wartości z kolumn DisplayName, Location oraz WebsiteUrl. My spróbujemy zrobić to samo i porównać różne podejścia.
Testy zostaną przeprowadzone na bazie “Medium” z tego linku: >>KLIK<<
Zaczynamy!
Na start trochę informacji o środowisku:
- SQL Server 2019 Developer Edition z ustawionym “COMPATIBILITY LEVEL” na bazie na 130 (SQL 2016) jako, że jest to obecnie najpopularniejsza wersja w użyciu produkcyjnym
- 16 GB RAM do dyspozycji serwera, 4 rdzenie (8 wątków)
- domyślny Cost Threshold for Parallelism
- używam także SET STATISTICS TIME, IO ON aby mieć informację o odczytach i ich czasie
Surowe statystyki będę wrzucał na pastebin i podawał linki pod obrazkami.
Pierwsze podejście będzie bardzo toporne i niezbyt wydajne, ale zawsze jakiś start. Po prostu w WHERE użyjemy trzech różnych wariantów IN w zależności od tego jakie “topki” chcemy otrzymać:
SELECT * FROM [dbo].[Users] WHERE [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([DisplayName]) DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([Location]) DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([WebsiteUrl]) DESC)
Do parsowania statystyk użyję świetnej strony https://statisticsparser.com/
A oto plan zapytania:
Jak widać dokonaliśmy odczytu ponad 130 tysięcy stron, czytaliśmy tabelę Users trzy razy w pełni (Clustered Index Scan z prawej strony zapytania) i dodatkowo jeszcze 6 odwołań bezpośrednich do sześciu wierszy aby zaprezentować wyniki. Całość zajęła procesorowi prawie 6 sekund (5672 ms, nie licząc czasu parsowania), a w rzeczywistości minęła ponad sekunda (jakim cudem minęło mniej czasu niż czas zajęty na procesorze? Równoległość zapytania). Not good, not terrible.
Kolejne rozwiązanie to utworzenie tabeli tymczasowej, która będzie przetrzymywać tylko Id wiersza z tabeli i długości poszczególnych kolumn. Wypróbujmy!
DROP TABLE IF EXISTS #tempOne; CREATE TABLE #tempOne ([Id] INT, [DisLen] INT, [LocLen] INT, [WebLen] INT); INSERT INTO #tempOne ([Id],[DisLen],[LocLen],[WebLen]) SELECT [Id], LEN([DisplayName]) as [DisLen], LEN([Location]) as [LocLen], LEN([WebsiteUrl]) as [WebLen] FROM [dbo].[Users]; SELECT * FROM [dbo].[Users] WHERE [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [DisLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [LocLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [Weblen] DESC);
Ay, coś tu nie gra… Dlaczego insert do tabeli tymczasowej poszedł na jednym wątku? Trzeba to naprawić!
DROP TABLE IF EXISTS #tempOne; CREATE TABLE #tempOne ([Id] INT, [DisLen] INT, [LocLen] INT, [WebLen] INT); INSERT INTO #tempOne WITH (TABLOCKX) ([Id],[DisLen],[LocLen],[WebLen]) SELECT [Id], LEN([DisplayName]) as [DisLen], LEN([Location]) as [LocLen], LEN([WebsiteUrl]) as [WebLen] FROM [dbo].[Users]; SELECT * FROM [dbo].[Users] WHERE [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [DisLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [LocLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [Weblen] DESC);
Ojoj… Czas procesora uległ znacznemu wydłużeniu, bo sięgamy już prawie 8 sekund, ale za to czas wykonania jest o wiele lepszy niż z jednowątkowym insertem, ale dalej prawie sekundę dłuższy niż poprzednie rozwiązanie. Odczyty za to spadły o ponad połowę, z 135,5 tys na 68,1 tys. To jest solidny zysk.
Ale dlaczego mamy mieć skanować stertę? Może indeksy coś pomogą? Sprawdźmy!
DROP TABLE IF EXISTS #tempOne; CREATE TABLE #tempOne ([Id] INT PRIMARY KEY, [DisLen] INT, [LocLen] INT, [WebLen] INT, INDEX [IX_apathy] ([DisLen]), INDEX [IX_void] ([LocLen]), INDEX [IX_depression] ([WebLen])); INSERT INTO #tempOne WITH (TABLOCKX) ([Id],[DisLen],[LocLen],[WebLen]) SELECT [Id], LEN([DisplayName]) as [DisLen], LEN([Location]) as [LocLen], LEN([WebsiteUrl]) as [WebLen] FROM [dbo].[Users]; SELECT * FROM [dbo].[Users] WHERE [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [DisLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [LocLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [Weblen] DESC);
Auuuuuuuuuuć… Samo otrzymanie wyników końcowych było niesamowicie szybkie, ale wrzucanie danych do tabeli tymczasowej? Koszmar… PONAD 7 MLN ODCZYTÓW! Nie wracajmy do tego…
Okej, ale to były klasycznie indeksy rowstore. Co jakbyśmy użyli wspaniałego gracza jakim jest indeks typu columnstore, który pozwala na wykonywanie operacji w trybie batch, czyli na wielu wierszach na raz!
DROP TABLE IF EXISTS #tempOne; CREATE TABLE #tempOne ([Id] INT, [DisLen] INT, [LocLen] INT, [WebLen] INT, INDEX [IX_alcoholism] CLUSTERED COLUMNSTORE); INSERT INTO #tempOne WITH (TABLOCKX) ([Id],[DisLen],[LocLen],[WebLen]) SELECT [Id], LEN([DisplayName]) as [DisLen], LEN([Location]) as [LocLen], LEN([WebsiteUrl]) as [WebLen] FROM [dbo].[Users]; SELECT * FROM [dbo].[Users] WHERE [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [DisLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [LocLen] DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM #tempOne ORDER BY [Weblen] DESC);
Dalej nie jest różowo, głównie z uwagi na wrzucanie danych do tabeli tymczasowej, ale już samo otrzymanie wyników to niecałe 100 ms, a liczba odczytów znowu spadła nam do poziomu 45 tysięcy. Ale to dalej nie to czego oczekujemy… Przecież nasze pierwsze zapytanie, bez żadnych udziwnień, zajęło około sekundy, a tutaj mamy ponad dwie!
Czy da radę połączyć jednokrotny odczyt tabeli z otrzymaniem wyników dla trzech różnych podzbiorów?
Funkcje okna!
Wypróbujmy funkcję okna ROW_NUMBER i CTE! To musi się udać!
WITH CTE AS ( SELECT [Id], ROW_NUMBER() OVER (ORDER BY LEN([DisplayName]) DESC) as [DisLen], ROW_NUMBER() OVER (ORDER BY LEN([Location]) DESC) as [LocLen], ROW_NUMBER() OVER (ORDER BY LEN([WebsiteUrl]) DESC) as [WebLen] FROM dbo.Users ) SELECT b.* FROM CTE a JOIN [dbo].[Users] b on a.[Id] = b.[Id] WHERE a.[DisLen] < 3 OR a.[LocLen] < 3 OR a.[WebLen] < 3
PORAŻKA! Miało być szybko! Miało być pięknie! A co dostaliśmy? 90 tys odczytów na przestrzeni 20 sekund CPU (!!!), a całość zajęła ponad 7 sekund czasu rzeczywistego! PORAŻKA!
Ale popuśćmy wodze fantazji… Co jakby połączyć tryb batch od indeksu typu columnstore z CTE? Niestety, SQL Server 2016 nie obsługuje operacji batch na danych rowstore. Ale SQL Server 2019 już tak! Zobaczmy jakby to działało! Najpierw ustawmy kompatybilność bazy na poziomie 150 (SQL Server 2019)
ALTER DATABASE [StackOverflow2013] SET COMPATIBILITY_LEVEL = 150; GO
I teraz jeszcze raz wypróbujmy poprzednie zapytanie, bez żadnych zmian.
Miodzio! Odczyty na bardzo dobrym poziomie, czas wykonania również, tabelę w całości uderzyliśmy tylko raz. Gdyby tylko dało się taki sam wynik uzyskać w wersji 2016…
Hokus pokus, czary mary!
Wróćmy do wersji 2016.
ALTER DATABASE [StackOverflow2013] SET COMPATIBILITY_LEVEL = 130; GO
I dokonajmy jednej zmiany w naszym zapytaniu.
DROP TABLE IF EXISTS #tmpColumnstore; CREATE TABLE #tmpColumnstore ([A] INT, INDEX [IX_loneliness] CLUSTERED COLUMNSTORE); INSERT INTO #tmpColumnstore VALUES (1); ;WITH CTE AS ( SELECT [Id], ROW_NUMBER() OVER (ORDER BY LEN([DisplayName]) DESC) as [DisLen], ROW_NUMBER() OVER (ORDER BY LEN([Location]) DESC) as [LocLen], ROW_NUMBER() OVER (ORDER BY LEN([WebsiteUrl]) DESC) as [WebLen] FROM dbo.Users ) SELECT b.* FROM CTE a JOIN [dbo].[Users] b on a.[Id] = b.[Id] CROSS JOIN #tmpColumnstore WHERE a.[DisLen] < 3 OR a.[LocLen] < 3 OR a.[WebLen] < 3;
Jest lepiej niż poprzednio na wersji 2016, ale dalej nie tak super jak na wersji 2019… Dwa razy bijemy w tabelę Users i jeszcze nie korzystamy z dobrodziejstwa równoległości… Peszkie.
Zostaje nam jeszcze z ciekawości sprawdzić ostatnie rozwiązanie.
DROP TABLE IF EXISTS #tmpColumnstore; CREATE TABLE #tmpColumnstore ([A] BIGINT, INDEX [IX_loneliness] CLUSTERED COLUMNSTORE); INSERT INTO #tmpColumnstore VALUES (1); SELECT * FROM [dbo].[Users] CROSS JOIN #tmpColumnstore WHERE [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([DisplayName]) DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([Location]) DESC) OR [Id] IN (SELECT TOP(2) [Id] FROM [dbo].[Users] ORDER BY LEN([WebsiteUrl]) DESC)
Dużo odczytów, dużo. Ale prędkość wykonania?
Podsumowanie
zostawiam Tobie, czytelniku.
Oki, to pa!