Posts tagged optymalizacja
[PL] SQL Server – Jak optymalizator robi ze mnie głupca
May 31st
Dawno, dawno temu dowiedziałem się z bloga Tibora Karasziego (SQL Server MVP), że optymalizator może wykorzystać obiekty constraint, takie jak CHECK czy FOREIGN KEY (klucz obcy) do optymalizacji określonych klas zapytań.
Jakiś czas temu próbowałem pokazać koledze z firmy, jak to działa i przy okazji dowiedziałem się ciekawej rzeczy, na którą dotąd nie natrafiłem. A w zasadzie, można powiedzieć, normalnie aż zgłupiałem :-)
Na początek weźmy tabelę:
USE tempdb; GO IF OBJECT_ID('dbo.Employee') IS NOT NULL DROP TABLE dbo.Employee; CREATE TABLE dbo.Employee ( EmployeeId int IDENTITY(1,1) NOT NULL PRIMARY KEY, FirstName varchar(50) NOT NULL, LastName varchar(50) NOT NULL, Income money NOT NULL DEFAULT (0) ); GO ALTER TABLE dbo.Employee ADD CONSTRAINT CK_Employee_Income CHECK (Income >= 0); GO INSERT INTO dbo.Employee (FirstName, LastName, Income) SELECT NEWID(), NEWID(), RAND(CHECKSUM(NEWID())) * v1.number + v2.number FROM master.dbo.spt_values AS v1, master.dbo.spt_values AS v2 WHERE v1.type = 'P' AND v2.type = 'P' AND v1.number BETWEEN 1 AND 1000 AND v2.number BETWEEN 1 AND 1000; GO
Tabela dość typowa. I zawiera milion rekordów. To, co ważne, to fakt, że założyłem w niej warunek ograniczający możliwe wartości kolumny Income (muszą być nieujemne). Świeżo założony w powyższy sposób warunek CHECK jest “trusted” (patrz wpis na blogu Tibora). Weźmy teraz zapytanie:
SELECT * FROM dbo.Employee WHERE Income < 0; GO
Plany wykonania na mojej maszynie i przy konfiguracji serwera z MAXDOP = 0 wygląda u mnie tak:
Hę? Spodziewałem się, że zobaczę operator Constant Scan i… tyle! A tymczasem jakiś skan, z jakimś parallelismem… Horror!
Pobawiłem się trochę tym zapytaniem. Pierwsza myśl – wyeliminować parallelism:
SELECT * FROM dbo.Employee WHERE Income < 0 OPTION (MAXDOP 1); GO
Plan wykonania:
Noooo, to lubimy :-) Żadnego zbędnego zaglądania do tabeli, po prostu od razu na podstawie zaufanego warunku CHECK generowana jest odpowiedź, że nie ma rekordów spełniających warunek z zapytania.
A teraz deser – przyznaję, że sam tego nie wymyśliłem (zbyt fikuśne :-)), choć testowałem różne śmieszne warianty zapytań (m.in. dodawałem grupowania po wszystkich kolumnach tabeli – pomagało i wymuszało Constant Scan). Maciek Pilecki, z którym ten problem przedyskutowałem i który, podobnie jak ja, ma wrażenie, że coś tu nie gra, wypróbował taki kwiatek:
SELECT * FROM dbo.Employee WHERE Income < 0 OPTION (MAXDOP 0); GO
Efekt? Sami sprawdźcie, ale gwarantuję pełne zaskoczenie :-) Oczywiście, Constant Scan! I o so chosi?
Wygląda na to, że optymalizator pozostawiony samemu sobie i stojący przed “trudnym” wyborem (nie wpisując hinta powodujemy, że optymalizator ma do dyspozycji wszystkie możliwe ścieżki optymalizacji zapytania) robi głupotę. A może to nowy bug? Ale plan dla zapytania z MAXDOP = 0 powoduje już moją konsternację. Trzeba będzie z kimś z grupy produktowej o tym porozmawiać, bo bardzo nie lubię tłumaczyć się przed kolegami, którym demonstruję “niby dobrze znane” triki optymalizacyjne, a tymczasem optymalizator krzyżuje moje plany i wychodzę na głupca :-)
Wszelkie przemyślenia własne na temat powyższego mile widziane. Moje przemyślenie – nigdy nie bądź niczego pewnym na 100% bez sprawdzenia organoleptycznie :-)
Dodam, że przełączenie MADXOP = 1 na poziomie serwera nie zmienia sytuacji – dalej mamy skan całej tabeli, jeśli nie dodamy hinta lub nie skomplikujemy zapytania! O co tutaj chodzi??? :-)
I na sam koniec – kupa śmiechu. Jaki jest bodaj najprostszy możliwy hint wymuszający Constant Scan w omawianym przypadku? Opadają mi ręce, gdy pomyślę o mojej SQL-owej niewiedzy…
SELECT * FROM dbo.Employee WHERE Income < 0 AND 1=1; GO
[EDIT] Jak słusznie zauważył Patrik Třeštik komentując ten wpis, problem jest najpewniej spowodowany przez parametryzację zapytania (i zarazem zjawisko parameter sniffing). Jakimś cudem zero w warunku WHERE przestaje być stałą i optymalizator zaczyna traktować je jak wartość nieznaną, a więc jednocześnie nie może porównać jej do wartości zaszytej w ograniczeniu CHECK. Tylko powstaje pytanie – po co ta parametryzacja? :-) [/EDIT]
[PL] SQL Server – Indeksy na kolumnach kluczy obcych
Sep 20th
Tytułem wstępu
Jedną z najlepszych praktyk w relacyjnych bazach danych jest utworzenie indeksów na kolumnach, które wchodzą w skład kluczy obcych. Kolumny takie są często wykorzystywane do łączenia tabel w zapytaniach. Oczywiście nie należy bezwarunkowo zakładać indeksu na każdej kombinacji kolumn, tworzącej klucz obcy, ale przynajmniej należy taką możliwość rozpatrzyć (decyzja zależy również od rozkładu danych).
SQL Server nie tworzy takich indeksów samodzielnie (niektóre systemy potrafią zakładać indeksy automatycznie przy tworzeniu kluczy obcych). Celem niniejszego artykułu jest pokazanie na przykładach, dlaczego indeksowanie kolumn kluczy obcych może być dobrym pomysłem.
| Zakładam, że wiesz, czym jest indeks i do czego służy. Jeżeli jest inaczej, zapoznaj się najpierw z tematyką indeksów i wróć do tego wpisu, gdy opanujesz niezbędne podstawy. |
| Dziękuję Pani Ewie Nowickiej, współpracującej z portalem WSS.pl, za korektę niniejszego tekstu. |
Tworzymy poligon
Posłużę się klasycznym przykładem dwóch tabel: tabeli produktów i tabeli kategorii produktów. Tabele są powiązane związkiem binarnym jeden do wielu (jedna kategoria może mieć wiele produktów). W tabeli dbo.Product (zbiór produktów) pojawia się kolumna CategoryID, wskazująca na odpowiednią kategorię w tabeli dbo.Category. Kod do wygenerowania bazy danych i jej zawartości:
CREATE DATABASE Test; GO USE Test; GO CREATE TABLE dbo.Category ( CategoryID int NOT NULL IDENTITY(1,1) PRIMARY KEY NONCLUSTERED, CategoryName varchar(50) NOT NULL UNIQUE CLUSTERED ); GO CREATE TABLE dbo.Product ( ProductID int NOT NULL IDENTITY(1,1) PRIMARY KEY NONCLUSTERED, ProductName varchar(50) NOT NULL UNIQUE CLUSTERED, CategoryID int NULL ); GO ALTER TABLE dbo.Product ADD CONSTRAINT FK_Product_Category FOREIGN KEY (CategoryID) REFERENCES dbo.Category (CategoryID); GO -- Generujemy 1000 rekordów kategorii produktów INSERT INTO dbo.Category (CategoryName) SELECT 'Category ' + CONVERT(varchar(4), number) FROM master.dbo.spt_values WHERE type = 'P' AND number BETWEEN 1 AND 1000; GO -- Generujemy około 248 tys. rekordów produktów INSERT INTO dbo.Product (ProductName, CategoryID) SELECT 'Product ' + CONVERT(varchar(7), Q.n), CASE WHEN Q.n % 1000 = 0 THEN 1 ELSE Q.n % 1000 END FROM ( SELECT DISTINCT v1.number * v2.number AS n FROM master.dbo.spt_values AS v1 CROSS JOIN master.dbo.spt_values AS v2 WHERE v1.number BETWEEN 1 AND 1000 AND v1.type = 'P' AND v2.number BETWEEN 1 AND 1000 AND v2.type = 'P' ) AS Q ORDER BY Q.n; GO
Przypadek 1. – SELECT
Rozpatrzmy następujące zapytanie SELECT:
SELECT c.CategoryName, p.ProductName FROM dbo.Category AS c LEFT JOIN dbo.Product AS p ON c.CategoryID = p.CategoryID WHERE c.CategoryName = 'Category 1000';
Ma ono za zadanie wybranie wszystkich produktów należących do kategorii o nazwie „Category 1000” (a jeżeli do tej kategorii nie należy żaden produkt, powinien pojawić się jeden rekord z pustą nazwą produktu). Takich produktów w tabeli dbo.Product nie ma. Ale mimo to zapytanie wykonywane jest w zauważalnie długim czasie, ponieważ brak indeksu na kolumnie CategoryID powoduje, że musi nastąpić przeskanowanie (odczyt wszystkich rekordów) tabeli dbo.Product. Plan zapytania wygląda następująco:
Po założeniu, za pomocą poniższego kodu, indeksu na kolumnie CategoryID, w planie nie widzimy już skanowania tabeli produktów wykonywanego w pętli, a samo zapytanie wykonuje się błyskawicznie.
CREATE INDEX IX_Product_CategoryID ON dbo.Product (CategoryID);
Przypadek 2. – UPDATE
Ale indeks może posłużyć nie tylko do optymalizacji zapytań SELECT. Dzięki niemu można także spowodować, że operacje modyfikacji danych będą wykonywać się szybciej. Weźmy takie polecenie UPDATE:
UPDATE p SET p.CategoryID = 1 FROM dbo.Category AS c INNER JOIN dbo.Product AS p ON c.CategoryID = p.CategoryID WHERE c.CategoryName = 'Category 1000';
Celem jest przypisanie produktów, dotąd należących do „Category 1000”, do nowej kategorii o identyfikatorze 1. Jeśli na kolumnie CategoryID w tabeli dbo.Product brak indeksu, przy wykynywaniu powyższego polecenia UPDATE praktycznie cały koszt jest generowany przez skanowanie tabeli dbo.Product oraz operatora złączenia typu Nested Loops.
Ale jeżeli dodany zostanie indeks (taki sam, jak w przypadku 1.), skanowanie zastępowane jest przez operację jego przeszukania. Sama modyfikacja danych będzie wykonywana szybciej, na skutek szybszego znalezienia rekordów do zmiany. Odpowiedni fragment planu zapytania po założeniu indeksu:
Przypadek 3. – DELETE
Także operacje kasowania danych mogą być optymalizowane przez założenie indeksu na kolumnie, będącej kluczem obcym. Przykładowe polecenie DELETE:
DELETE FROM dbo.Category WHERE CategoryName = 'Category 1000';
W planie wykonania, w przypadku braku indeksu na kolumnie CategoryID w tabeli dbo.Product, pojawia się skanowanie całej tabeli w poszukiwaniu rekordów produktów, należących do „Category 1000” (nawet, jeśli takich produktów nie ma, przejrzana zostaje cała tabela). Jest to spowodowane tym, że w tabeli dbo.Product istnieje klucz obcy, odwołujący się do tabeli dbo.Category i uniemożliwiający usuwanie rekordu kategorii, jeżeli ma ona przypisany choć jeden produkt. ![]()
Jakie korzyści daje założenie indeksu na kolumnie (-ach) klucza obcego w tabeli dbo.Product (takiego samego indeksu, jak w przypadkach 1. i 2.)?
Tym razem operacja wykonuje się szybciej, ponieważ zamiast skanowania tabeli dbo.Product następuje przeszukanie indeksu na kolumnie CategoryID w tej tabeli.
Wnioski daleko idące
Nie zawsze indeksy na kolumnach, będących kluczami obcymi, pozwalają na optymalizację zapytań. Ale na pewno kolumny takie powinny być uwzględniane jako propozycje kluczy indeksów. Przykłady z tego artykułu pokazują, że takim indeksem można także przyspieszyć modyfikacje danych (choć spotyka się ogólne i niezbyt trafne stwierdzenia, że indeksy zawsze powodują spowolnienie takich operacji).
[PL] SQL Server – Czy optymalizator może policzyć wiersze w zmiennej tabelarycznej?
Aug 23rd
Przyjęło się i przekazywane jest w środowisku stwierdzenie, że w przypadku, gdy w zapytaniu użyta jest zmienna tabelaryczna (zwana przez niektórych “tabelą małpkową”), optymalizator zawsze zakłada/widzi w zmiennej tabelarycznej dokładnie jeden wiersz. Z tego powodu plany wykonania są dalekie od oczekiwanych, przez co musimy sterować planami (np. jawnie określając kolejność tabel w złączeniach).
Postanowiłem troche poszperać w temacie.
Spróbujmy uruchomić taki kawałek kodu T-SQL:
DECLARE @t TABLE (c1 int PRIMARY KEY); DECLARE @x TABLE (c2 int PRIMARY KEY); INSERT INTO @t SELECT DISTINCT number FROM master.dbo.spt_values; INSERT INTO @x SELECT TOP 1 number FROM master.dbo.spt_values; SELECT * FROM @t t INNER JOIN @x x ON t.c1 = x.c2 OPTION (RECOMPILE); --(1) SELECT * FROM @t t INNER JOIN @x x ON t.c1 = x.c2; --(2)
Zapytania (1) i (2) są złączeniami dwóch zmiennych tabelarycznych – @x – zawierającej 1 wiersz oraz @t – zawierającej ponad 2000 wierszy.
Dla zapytań (1) i (2) oglądamy dwa różne plany wykonania (ale nie plany estymowane, a plany rzeczywiste) o tym samym koszcie:
Pierwsza obserwacja - w obu przypadkach kolejność zmiennych tabelarycznych w złączeniu była różna (w przypadku zapytania (1) z opcją RECOMPILE kolejność była “prawidłowa” – skanowanie zmiennej zawierającej mniejszą ilość wierszy i wyszukanie rekordów w zmiennej “większej”).
Zobaczmy w planach wykonania szczegóły operatorów użytych w stosunku do zmiennej @t.
W zapytaniu (2) w operatorze Clustered Index Scan liczba przy pozycji Actual Number of Rows (rzeczywista liczba wierszy) zgadza się z rzeczywistością (2164), zaś w przypadku zapytania (1) używającego opcji RECOMPILE (“rekompiluj zapytanie i nie używaj planu z cache’u”) w operatorze Clustered Index Seek liczba obok Acutal Number of Rows wynosi 1. Skąd w drugim planie wzięła się poprawna informacja o rzeczywistej liczbie wierszy? Odpowiedź przychodzi, gdy spojrzymy na całość planu wykonania. W takich sytuacjach liczba wierszy z tabeli “zewnętrznej” (Outer) jest równa ilości iteracji pętli Nested Loops – 2164 to właśnie ilość “obrotów pętli”. Nadal jednak estymowana ilość wierszy dla tych zmiennych wynosi 1 (słownie: jeden). A z tego wynika, że – z klauzulą OPTION (RECOMPILE) czy bez niej – optymalizator zakłada, że w zmiennej tabelarycznej jest zawsze 1 rekord. Swego rodzaju ciekawostką są różne plany wykonania obu przedstawionych zapytań, ale równie dobrze może to być przypadek lub bug (dość powtarzalny).
W tym miejscu przyznaję, że gdyby nie Maciek Pilecki, chodząca księga wiedzy o zagadnieniach optymalizacji SQL Servera, nie wpadłbym na oczywistą pomyłkę, którą popełniłem pisząc ten wpis w jego pierwotnej postaci. Co się mianowicie stało? Ano, jakimś cudem pomyliłem plany wykonania obu zapytań i tak się tym zasugerowałem, że pomyślałem, iż klauzula OPTION (RECOMPILE) umożliwia dokładne policzenie rekordów w zmiennej tabelarycznej SQL Serverowi. Oczywiście mój błąd. Dobrze, że Maciek był na posterunku i mogłem dzięki niemu poprawić ten wpis oraz swój krzywy światopogląd, a przy okazji jeszcze czegoś się nauczyć (dzięki, Maćku).
[PL] SQL Server 2008 – DMV i sztuka optymalizacji
May 4th
SQL Server od wersji 2005 to Wielki Kolekcjoner. Zbiera liczne dane, z których wprawiony DBA może odczytać naprawdę wiele. Także w zakresie trudnej sztuki optymalizacji SQL Server może być naszym przyjacielem (choć bywa też naszym zaciekłym przeciwnikiem…).
W ostatnim czasie miałem możliwość zastosowania wszechobecnych w SQL Server 2008 widoków i funkcji dynamicznych (DMV/DMF) w procesie optymalizacji bazy danych. Jednym z zadań było odnajdowanie “podejrzanych” operacji w obiektach sporej bazy danych. W tym celu napisałem kawałek kodu T-SQL, który wyglądał mniej więcej tak:
USE master;
GO
IF OBJECT_ID('dbo.sp_querystats', 'V') IS NOT NULL
DROP VIEW dbo.sp_querystats;
GO
CREATE VIEW dbo.sp_querystats
AS
SELECT
DB_NAME(st.dbid) AS [database_name],
QUOTENAME(OBJECT_SCHEMA_NAME(st.objectid, st.dbid)) + '.' +
QUOTENAME(OBJECT_NAME(st.objectid, st.dbid)) AS [object_name],
SUBSTRING(
st.text,
(qs.statement_start_offset/2)+1,
(CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2 + 1
) AS statement_text,
qs.execution_count,
qs.creation_time,
qs.last_execution_time,
(qs.total_logical_reads + qs.total_physical_reads) / qs.execution_count AS avg_reads,
qs.total_logical_reads + qs.total_physical_reads AS total_reads,
qs.max_logical_reads + qs.max_physical_reads AS max_reads,
qs.min_logical_reads + qs.min_physical_reads AS min_reads,
qs.max_logical_reads,
qs.total_logical_reads,
qs.total_physical_reads,
qs.max_physical_reads,
qs.total_elapsed_time / qs.execution_count AS avg_duration,
qs.total_elapsed_time AS total_duration,
qs.max_elapsed_time AS max_duration,
qs.min_elapsed_time AS min_duration,
qs.total_worker_time / qs.execution_count AS avg_cpu,
qs.total_worker_time AS total_cpu,
qs.max_worker_time AS max_cpu,
qs.min_worker_time AS min_cpu,
pl.query_plan
FROM sys.dm_exec_query_stats AS qs WITH (NOLOCK)
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
OUTER APPLY sys.dm_exec_query_plan(qs.plan_handle) AS pl;
GO
Widok sp_querystats posłużył mi do odpytywania danych dotyczących wydajności zapytań kolekcjonowanych przez SQL Server 2008. Krótki komentarz do kodu widoku:
- Dzięki użyciu widoku sys.dm_exec_query_stats (dane z tego widoku są wyrzucane przy każdym uruchomieniu usługi SQL Server) wydobywam informacje o czasie trwania, zużyciu procesora, operacjach I/O (logicznych i fizycznych) – to są najczęstsze kryteria wyszukiwania problemów optymalizacyjnych.
- Funkcja sys.dm_exec_sql_text umożliwia wydobycie takich informacji, jak nazwa bazy danych obiektu, z którego pochodziła składnia wychwycona przez SQL Server, nazwa samego obiektu oraz kod T-SQL fragmentu obiektu
- Funkcja sys.dm_exec_query_plan daje wgląd w graficzny plan wykonania całego obiektu (lub zapytania – jeżeli to zapytanie ad-hoc).
- Dzięki odpowiedniemu zastosowaniu funkcji systemowych OBJECT_NAME i OBJECT_SCHEMA_NAME (warto zwrócić uwagę, że funkcje te mogą przyjmować dwa parametry – drugim może być identyfikator bazy danych), uzyskuję pełne nazwy obiektów z wszystkich baz danych.
- Widok ma w nazwie prefiks sp_ i jest założony w bazie master, by można się do niego odwołać z dowolnej bazy danych na instancji SQL Server.
Co mogę osiągnąć dzięki takiemu widokowi?
- Mogę znajdować zapytania, które miały najsłabsze czasy wykonania, najwyższe zużycie procesora czy dokonały największej ilości odczytów z bazy danych. Przykład (najdłuższy średni czas wykonania):
SELECT TOP 50 * FROM sp_querystats ORDER BY avg_duration DESC;
- Mogę sprawdzić, które zapytania wykonują się z dużym rozrzutem wymienionych powyżej kryteriów, co może świadczyć na przykład o rozmnożonych planach wykonania procedury składowanej. Na przykład: gdzie minimalny czas wykonania znacznie różni się od czasu maksymalnego:
SELECT TOP 50 * FROM sp_querystats ORDER BY (max_duration - min_duration) DESC;
- Jakie składnie z danego obiektu (widoku, procedury, funkcji) siały największe spustoszenie na serwerze. Stosuję taką technikę, gdy uda mi się zgrubnie Profilerem “złapać” wywołanie felernego obiektu (zazwyczaj procedury lub funkcji). Na przykład: która składnia w procedurze spowodowała największe obciążenie procesora:
SELECT * FROM sp_querystats
WHERE st.objectid = OBJECT_ID(N'dbo.MojaProcedura')
ORDER BY avg_cpu DESC
- Mogę podejrzeć plan wykonania (dla obiektów proceduralnych wyświetlane są plany wykonania wszystkich składni zawartych w obiekcie). Wystarczy kliknąć na interesującym nas rekordzie w kolumnie query_plan (oczywiście jeżeli w Management Studio wynik wyświetlamy w siatce, a nie w postaci tekstowej).
- Mogę przeszukać plany wykonania zapisane w formacie XML w kolumnie query_plan i znaleźć niepokojące operatory (skany, niesłuszne ideowo typy złączeń, wyszukiwania z predykatami, opóźnione filtrowania, spoole itd.) czy podpowiedzi SQL Servera dotyczące brakujących indeksów (o analizie planów wykonania w formacie XML napiszę innym razem, bo to temat na spory artykuł).
- Mogę sprawdzić, ile razy każda składnia była wykorzystana (i na przykład zweryfikować, jaki przebieg w procedurach mają instrukcje sterujące IF lub ocenić, czy jakaś składnia T-SQL nie powoduje masowego wywołania funkcji skalarnej). Na wartości z kolumny execution_count warto zwracać uwagę także, gdy zabieramy się do optymalizacji – czasem nie warto brać w swoje ręce obiektów, które są używane raz na kilka miesięcy…
Widok sp_querystats daje mi pogląd na wiele spraw. Przechwytując w procesie optymalizacji informacje dostarczone przez ślady (SQL Trace i Profiler) mogę niemal “od ręki” odnaleźć składnie wykonywane w ramach podejrzanego obiektu i zobaczyć agregację wszystkich wykonań tych poleceń. Czasem jest to wystarczający bodziec do podjęcia działań mających na celu dokonanie optymalizacji konkretnego zapytania. Polecam używanie DMV jako uzupełnienia dla wspomnianego SQL Trace i ewentualnych innych narzędzi wykorzystywanych do optymalizacji. Używajmy T-SQL do monitorowania T-SQL :-)

Nazywam się Paweł Potasiński i pracuję w polskim oddziale Microsoft w dziale Small and Midmarket Solutions & Partners (SMS&P) jako Partner Technology Advisor.




