SPIS TREŚCI
Wstęp
7
1. Plany strategiczne Projektowanie baz danych pod kątem wydajności
15
2. Prowadzenie wojny Wydajne wykorzystanie baz danych
51
3. Działania taktyczne Indeksowanie
87
4. Manewrowanie Projektowanie zapytań SQL
113
5. Ukształtowanie terenu Zrozumienie implementacji fizycznej
151
6. Dziewięć zmiennych Rozpoznawanie klasycznych wzorców SQL
179
7. Odmiany taktyki Obsługa danych strategicznych
231
8. Strategiczna siła wojskowa Rozpoznawanie trudnych sytuacji i postępowanie w nich
273
9. Walka na wielu frontach Wykorzystanie współbieżności
307
10. Gromadzenie sił Obsługa dużych ilości danych
337
11. Fortele Jak uratować czasy reakcji
381
12. Zatrudnianie szpiegów Monitorowanie wydajności
417
Ilustracje
451
O autorach
453
Skorowidz
455
Bacząc na moje porady, miej również wzgląd na wszelkie pomocne okoliczności związane z regułami, ale i niezależne od nich. — Sun Tzu, Sztuka wojny
WSTĘP
B
yły takie czasy, gdy dziedzinę zwaną dziś szumnie „technologią informatyczną” określano „elektronicznym przetwarzaniem danych”. I prawda jest taka, że przy całym szumie wokół modnych technologii przetwarzanie danych nadal pozostało głównym zadaniem większości systemów. Co więcej, rozmiary danych zarządzanych w sposób elektroniczny stale się zwiększają, szybciej nawet niż wzrasta moc procesorów. Najważniejsze dane korporacyjne są dziś przechowywane w relacyjnych bazach danych i można uzyskać do nich dostęp za pomocą niedoskonałego, ale za to powszechnie znanego języka SQL. Jest to kombinacja, która zaczęła przebijać się na światło dzienne w połowie lat osiemdziesiątych i do dnia dzisiejszego praktycznie zmiotła konkurencję z powierzchni ziemi. Trudno dziś przeprowadzić wywiad z młodym programistą, który nie pochwaliłby się dobrą praktyczną znajomością SQL-a, powszechnie stosowanego języka dostępu do danych. Język ten jest jedną z obowiązkowych pozycji każdego kursu wykładów na kierunkach informatycznych. Taka opinia na temat własnej wiedzy jest z reguły względnie uzasadniona, jeśli przez „wiedzę” rozumiemy umiejętność uzyskiwania (po krótszych lub dłuższych zmaganiach) funkcjonalnie poprawnych wyników. Jednakże korporacje na całym świecie doświadczają sytuacji dynamicznie zwiększających się ilości danych. W efekcie „funkcjonalnie poprawne” wyniki już nie wystarczą: wyniki te muszą przede wszystkim być uzyskiwane szybko. Wydajność baz danych stała się głównym problemem w wielu firmach. Co interesujące, choć każdy zgodzi się z faktem, iż źródło wydajności leży w kodzie, akceptuje się fakt, że najważniejszą troską programisty powinno
8
WSTĘP
być pisanie działającego kodu, co wydaje się dość rozsądnym założeniem. Z tego rozumowania wynika twierdzenie, że kod wykonywany przez bazę danych powinien być jak najprostszy, przede wszystkim ze względu na koszt utrzymania, i że „ciężkie przypadki” kodu SQL powinny być rozwiązywane przez zaawansowanych administratorów baz danych, którzy będą w stanie zoptymalizować je do szybszego działania, zapewne z użyciem „magicznych” parametrów bazy danych. Jeśli takie działania nie wystarczą, wszyscy zgadzają się z faktem, że jedynym ratunkiem jest wymiana sprzętu na mocniejszy. Często zdarza się, że tego typu podejście, oparte na zdrowym rozsądku i postawie asekuracyjnej, w ostatecznym rozrachunku okazuje się niezwykle szkodliwe. Pisanie niewydajnego kodu i poleganie na ekspertach, którzy poratują w sytuacji „ciężkiego przypadku” kodu SQL, można porównać do zamiatania brudów pod dywan. W mojej opinii to właśnie programiści powinni interesować się wydajnością, a ich potrzebę znajomości SQL-a oceniam nieco wyżej niż jedynie umiejętność napisania kilku zapytań. Wydajność widziana z perspektywy programisty jest czymś zupełnie innym od „konfigurowania” praktykowanego przez administratorów baz danych. Administrator baz danych próbuje jak najwięcej „wycisnąć” z systemu w oparciu o posiadany sprzęt, procesory i podsystem dyskowy oraz wersję systemu zarządzania bazami danych. Administrator może posiadać pewne umiejętności w używaniu SQL-a i poradzi sobie z poprawieniem wydajności szczególnie kiepsko napisanego zapytania. Ale programiści piszą kod, który będzie działał długo, nawet do pięciu czy dziesięciu lat i wielokrotnie przeżyje bieżącą wersję (niezależnie od ich marketingowych haseł, jak internet-enabled, ready-for-the-grid czy tym podobnych) użytkowanego systemu zarządzania bazami danych (Database Management System, DBMS), na której był pisany, jak również kilka generacji sprzętu. Kod musi być szybki od początku. Przykro to stwierdzić, ale z wielu programistów, którzy „znają” SQL, niewielu rozumie zasady funkcjonowania tego języka i ma opanowane podstawy teorii relacyjnej.
Po co kolejna książka o SQL-u? Książki o SQL-u dzielą się na trzy podstawowe typy: książki, które uczą logiki i składni poszczególnych dialektów SQL-a, książki, które uczą zaawansowanych technik i opierają się na rozwiązywaniu problemów,
WSTĘP
9
oraz książki o konfigurowaniu systemów pod kątem wydajności, pisane z myślą o administratorach baz danych. Z jednej strony książki prezentują sposoby pisania kodu SQL. Z drugiej strony, uczą diagnozowania błędnie napisanego kodu SQL. W tej książce starałem się nauczyć bardziej zaawansowanych czytelników pisać prawidłowy kod SQL, a co ważniejsze, uzyskać spojrzenie na kod w SQL-u wykraczające poza pojedyncze zapytania. Nauczenie korzystania z języka programowania samo w sobie jest dość trudnym zadaniem. W jaki sposób nauczyć programowania w tym języku w sposób efektywny? SQL jest językiem sprawiającym wrażenie prostego, ale to złudne wrażenie, co ujawnia się po zagłębieniu się w jego tajniki. Język ten pozwala na wręcz nieskończone ilości kombinacji. Pierwszym porównaniem, jakie mi się narzuciło, była gra w szachy, ale w pewnym momencie uświadomiłem sobie, że gra w szachy została wymyślona po to, by szkolić techniki prowadzenia wojny. Mam naturalną tendencję do traktowania każdego wyzwania jako bitwy z armią wierszy tabel bazy danych i uświadomiłem sobie, że problem uczenia programistów wydajnego wykorzystywania bazy danych jest podobny do problemu uczenia oficerów prowadzenia wojny. Potrzebna jest wiedza, umiejętności i oczywiście potrzebny jest talent. Talentu nie można nauczyć, ale można go pielęgnować. W to wierzyli wielcy dowódcy, od Sun Tzu, który dwadzieścia pięć wieków temu napisał swoją Sztukę wojny, do współczesnych generałów. Swoje doświadczenie starali się przekazać w formie prostych do zrozumienia reguł i maksym, które miały służyć jako punkty odniesienia — jak gwizdy widoczne przez kurzawę i tumult bitew. Chciałem zastosować tę samą technikę na potrzeby bardziej pokojowych celów, zastosowałem plan nauki zbliżony do przyjętego przez Sun Tzu. Od niego zapożyczyłem też tytuł. Wielu uznanych specjalistów informatyki uważa się za naukowców. Jednak do dziedziny wymagającej sprytu, doświadczenia i kreatywności, obok żelaznego rygoru i zrozumienia natury rzeczy1, moim zdaniem, jednak określenie „sztuka” pasuje bardziej od określenia „nauka”. Liczę się z tym, że moje upodobanie do nazywania tego sztuką będzie źle zrozumiane przez zwolenników nazywania tego nauką, gdyż ci twierdzą, że każdy problem z SQL-em posiada swoje optymalne rozwiązanie, które można osiągnąć drogą skrupulatnej analizy i dzięki dobrej znajomości danych. Jednak osobiście 1
Jedną z moich ulubionych książek informatycznych jest Sztuka programowania Donalda E. Knutha.
10
WSTĘP
nie uważam, że te dwa podejścia są wzajemnie sprzeczne. Rygor i podejście naukowe pomagają rozwiązać jeden problem nurtujący użytkownika w konkretnym momencie. W programowaniu w języku SQL, gdzie z reguły nie istnieje niepewność typowa dla pola bitwy przed kolejnym ruchem nieprzyjaciela, największe zagrożenia leżą w ewolucyjnych zmianach. Co się stanie, gdy nieoczekiwanie zwiększy się rozmiar bazy danych? Co będzie, gdy wskutek połączenia z inną firmą liczba użytkowników systemu się podwoi? Co się stanie, jeśli zdecydujemy się udostępniać w systemie dane historyczne pochodzące z kilku lat? Czy program zachowa się w inny sposób niż obecnie? Niektóre wybory w zakresie architektury systemu stanowią pewną formę hazardu. W tym zakresie rzeczywiście potrzebny jest rygor i solidne podłoże teoretyczne, ale te cechy są również kluczowe w przypadku każdej dziedziny sztuki. Ferdynand Foch na swoim odczycie w French Ecole Supérieure de Guerre w roku 1900 (później, w czasie pierwszej wojny światowej, został mianowany dowódcą sił sprzymierzonych) powiedział: Sztuka wojny, jak wszystkie inne dziedziny sztuki, posiada swoją teorię, swoje reguły — w przeciwnym razie nie byłaby sztuką. Ta książka nie jest książką kucharską wyliczającą problemy i zawierającą gotowe „receptury”. Jej celem jest pomoc programistom i ich menedżerom w zadawaniu właściwych pytań. Czytelnik po przeczytaniu i przeanalizowaniu tej książki nie ma żadnej gwarancji, że przestanie pisać błędny i nieoptymalny kod SQL. Czasem zdarza to się każdemu. Ale (mam nadzieję) będzie to robił z pełnym uzasadnieniem i świadomością konsekwencji.
Odbiorcy Ta książka jest napisana z myślą następujących odbiorcach: • programistach posiadających konkretne (roczne lub dłuższe) doświadczenie w programowaniu z użyciem baz danych SQL, • ich menedżerach, • architektach zajmujących się projektowaniem programów, w których baza danych stanowi kluczowy element. Choć mam nadzieję, że niektórzy administratorzy baz danych, szczególnie ci, którzy opiekują się rozwojowymi bazami danych, również znajdą w tej książce interesujące dla siebie zagadnienia. Jednak z przykrością stwierdzam, że to nie właśnie ich miałem na myśli.
WSTĘP
11
Założenia przyjęte w tej książce W tej książce przyjąłem założenie, że Czytelnik opanował już język SQL. Przez pojęcie „opanował” nie zakładam, że na studiach zaliczył semestr z języka SQL i otrzymał ocenę celującą ani, że jest uznanym guru od tego języka. Chodzi mi o to, że Czytelnik tej książki powinien mieć doświadczenie z tworzeniem aplikacji wykorzystujących bazy danych z użyciem języka SQL, że miał okazję analizować potrzeby bazy z punktu widzenia indeksowania i że tabeli zawierającej 5 tysięcy wierszy nie uważa za „wielką”. Zadaniem tej książki nie jest objaśnianie zasady działania złączenia (włączenie ze złączeniami zewnętrznymi) ani do czego służą indeksy. Choć nie uważam, żeby konieczna była umiejętność stosowania bardzo zawiłych konstrukcji w SQL-u, to w oparciu o podany zbiór tabel Czytelnik powinien umieć skonstruować funkcjonalnie poprawne zapytanie realizujące określone zadanie. Jeśli tak nie jest, to przed przeczytaniem tej książki polecam kilka innych. Zakładam również, że Czytelnik zna przynajmniej jeden język programowania oraz podstawy technik programowania. Zakładam, że Czytelnik był już w okopach i że słyszał narzekania użytkowników, że „baza danych działa wolno”.
Zawartość książki Podobieństwo między wojną a SQL-em uznałem za tak uderzające, że zastosowałem się do schematu opracowanego przez Sun Tzu, pozostawiając też wiele z jego tytułów2. Ta książka jest podzielona na dwanaście rozdziałów, każdy z nich zawiera definicje pewnych zasad i maksymy. Te zasady starałem się ilustrować przykładami, o ile to możliwe wziętymi z rzeczywistych sytuacji życiowych. Rozdział 1. „Plany strategiczne” Zawiera zasady projektowania baz danych z myślą o wydajności. Rozdział 2. „Prowadzenie wojny” Objaśnia sposoby projektowania programów, aby mogły one korzystać z baz danych w sposób efektywny. Rozdział 3. „Działania taktyczne” Wyjaśnia sposoby użycia indeksów. 2
Kilka tytułów zapożyczyłem od Clausewitza z jego rozprawy Zasady wojny.
12
WSTĘP
Rozdział 4. „Manewrowanie” Wyjaśnia sposoby projektowania zapytań SQL. Rozdział 5. „Ukształtowanie terenu” Demonstruje, w jaki sposób implementacja fizyczna może mieć wpływ na wydajność. Rozdział 6. „Dziewięć zmiennych” Omawia klasyczne wzorce sytuacji występujących w zapytaniach SQL oraz sposoby radzenia sobie z nimi. Rozdział 7. „Odmiany taktyki” Zawiera metody obsługi danych hierarchicznych. Rozdział 8. „Strategiczna siła wojskowa” Zawiera porady dotyczące rozpoznawania i obsługi skomplikowanych przypadków. Rozdział 9. „Walka na wielu frontach” Opisuje zasady obsługi współbieżności. Rozdział 10. „Gromadzenie sił” Omawia sposoby przetwarzania dużych ilości danych. Rozdział 11. „Fortele” Oferuje kilka sztuczek, które pomogą przetrwać w sytuacji złych projektów baz danych. Rozdział 12. „Zatrudnianie szpiegów” Stanowi podsumowanie książki przez omówienie wykorzystania mechanizmów monitorowania wydajności.
Konwencje zastosowane w książce W książce zostały zastosowane następujące konwencje typograficzne: Pochylenie Wyróżnia nowo wprowadzane terminy oraz tytuły książek. Stała szerokość znaków
Używana do wpisywania kodu SQL, a ogólnie: do wyróżnienia słów kluczowych języków programowania, nazw tabel, kolumn i indeksów, funkcji, kodu i wyników wywołania poleceń.
WSTĘP
13
Stała szerokość znaków, pogrubienie
Wyróżnia elementy kodu, na które należy zwrócić szczególną uwagę. Stała szerokość znaków, pochylenie
Wyróżnia fragment kodu, w którym ma znaleźć się wartość wprowadzona przez użytkownika. Taka ikona sygnalizuje maksymę podsumowującą ważną zasadę dotyczącą SQL-a.
UWAGA To jest wskazówka, sugestia lub uwaga ogólna. Zawiera cenne informacje uzupełniające na temat omawianego zagadnienia.
Podziękowania Napisanie książki w języku niebędącym Twoim językiem rodzimym ani nawet językiem kraju, w którym mieszkasz, wymaga optymizmu graniczącego z szaleństwem (patrząc na to z perspektywy czasu). Na szczęście Peter Robson, którego spotkałem na kilku konferencjach przy okazji wygłaszania odczytów, wniósł do tej książki nie tylko swoją wiedzę z zakresu języka SQL i zagadnień związanych z bazami danych, lecz również nieograniczony entuzjazm w bezlitosnym skracaniu moich za długich zdań, umieszczaniu przysłówków tam, gdzie ich miejsce czy też sugerowaniu innych słów w zastępstwie tych, które swoją karierę zakończyły w epoce, gdy Anglią rządzili Plantagenetowie3. Książka została wydana pod redakcją Jonathana Gennicka, autora bestsellerowej pozycji SQL Pocket Guide wydawnictwa O’Reilly i kilku innych znaczących o tej tematyce, co było dla mnie nieco przerażającym zaszczytem. Jak się wkrótce przekonałem, Jonathan jest redaktorem o dużym szacunku dla autorów. Jego profesjonalizm, dbałość o szczegóły i ambitne wyzwania uczyniły tę książkę znacznie lepszą, niż bylibyśmy w stanie uczynić ją sami z Peterem. Jonathan miał też swój środkowoatlantycki wkład w nastrój tej książki (jak się szybko przekonaliśmy z Peretem, ustawienie słownika na English (US) było w tym celu warunkiem koniecznym, ale niewystarczającym). 3
Dla niezorientowanych: Plantagenetowie rządzili Anglią od roku 1154 do roku 1485.
14
WSTĘP
Chcę również wyrazić wdzięczność wielu osobom z trzech różnych kontynentów, które znalazły czas, aby przeczytać części lub całość szkicu książki, i udzielały mi swoich szczerych porad: Philippe Bertolino, Rachel Carmichael, Sunil CS, Larry Elkins, Tim Gorman, Jean-Paul Martin, Sanjay Mishra, Anthony Molinaro i Tiong Soo Hua. Szczególny dług wdzięczności odczuwam wobec Larry’ego, ponieważ genezę koncepcji tej książki można z pewnością odkryć w e-mailach, jakie wymieniliśmy między sobą. Chcę również podziękować licznym osobom w Wydawnictwie O’Reilly, które pomogły tej książce przejść do świata realnego. Te osoby to przede wszystkim: Marcia Friedman, Rob Romano, Jamie Peppard, Mike Kohnke, Ron Bilodeau, Jessamyn Read i Andrew Savikas. Wielkie dzięki należą się również Nancy Reinhardt za jej doskonałą redakcję rękopisów. Specjalne podziękowania dla Yann-Arzel Durelle-Marc za użyczenie praw do ryciny ilustrującej rozdział 12. Dziękuję też Paulowi McWhorterowi za pozwolenie na wykorzystanie mapy bitwy, która stała się głównym motywem rysunku z rozdziału 6. Chcę wreszcie podziękować Rogerowi Manserowi i zespołowi Steel Business Briefing za użyczenie Peterowi i mnie biura i ogromnych ilości bardzo potrzebnej kawy podczas londyńskich sesji roboczych, w połowie drogi między naszymi miejscami stałej pracy, oraz Qian Lena (Ashley) za udostępnienie chińskiego tekstu cytatu Sun Tzu, który wykorzystaliśmy na początku książki.
ROZDZIAŁ PIERWSZY
Plany strategiczne Projektowanie baz danych pod kątem wydajności C’est le premier pas qui, dans toutes les guerres, décèle le génie. W każdej z wojen geniusz poznaje się już po pierwszym ruchu. — Joseph de Maistre (1754 – 1821) List z 27 lipca 1812 roku do Pana Hrabiego z frontu
16
W
ROZDZIAŁ PIERWSZY
ielki niemiecki dziewiętnastowieczny strateg, Clausewitz, twierdził, że wojna jest po prostu kontynuacją polityki, lecz za pomocą innych środków. Analogicznie, każdy program komputerowy jest w jakimś stopniu kontynuacją ogólnych działań w ramach organizacji, pozwalającą wykonać więcej, szybciej, lepiej lub taniej to samo, co da się wykonać innymi środkami. Głównym zadaniem programu komputerowego nie jest pobranie danych z bazy i ich przetworzenie, lecz dokonanie takiego przetworzenia danych, aby został zrealizowany odpowiedni cel. Środki służą jedynie osiągnięciu celu, lecz same w sobie celu nie stanowią. Stwierdzenie, że celem każdego programu komputerowego w pierwszej kolejności jest realizacja określonych wymagań biznesowych1 często bywa traktowane jak komunał. Nierzadko bowiem w wyniku zafascynowania wyzwaniami technologicznymi uwaga użytkowników przesuwa się z celu na środki. Z tego powodu większy nacisk kładziony jest z reguły nie na zachowanie wysokiej jakości danych rejestrujących szczegóły działalności biznesowej, lecz na tworzenie oprogramowania w ustalonym terminie i działającego w ustalony sposób. Przed rozpoczęciem budowania systemu musimy dokładnie określić jego nadrzędne cele, podobnie jak generał dowodzący armią przed rozpoczęciem ofensywy. I powinniśmy się ich trzymać nawet w sytuacji, gdy nieoczekiwane okoliczności (niekorzystne lub korzystne) zmuszą nas do zmiany oryginalnego planu. Wszędzie tam, gdzie będzie wykorzystywany język SQL, musimy walczyć o zachowanie wiarygodnych i spójnych danych przez cały okres ich wykorzystania. Zarówno wiarygodność, jak i spójność danych mają ścisły związek z jakością modelu bazy danych. Model bazy danych, który początkowo stanowił podstawę projektową języka SQL, nosi nazwę modelu relacyjnego. Prawidłowy model danych i odpowiedni dla niego projekt bazy danych to kluczowe aspekty w procesie tworzenia każdego systemu informatycznego.
Relacyjny model danych Baza danych to jedynie model niewielkiego wycinka sytuacji z rzeczywistości. I jak każda reprezentacja, baza danych zawsze będzie jedynie modelem nieprecyzyjnym i uproszczonym w porównaniu do rzeczywistości — skomplikowanej i bogatej w szczegóły. Rzadko zdarza się, że jakiejś 1
Określenie wymagania biznesowego jest bardziej ogólne: obejmuje działalność komercyjną oraz niekomercyjną.
PLANY STRATEGICZNE
17
aktywności biznesowej odpowiada tylko jeden, idealny model reprezentacji danych. Najczęściej do wyboru mamy kilka wariantów, znaczeniowo równorzędnych z technicznego punktu widzenia. Jednak dla pełnego zbioru procesów wykorzystujących dane z reguły daje się wyodrębnić jedną z dostępnych reprezentacji, która najlepiej spełnia wymagania biznesowe. Model relacyjny został nazwany w ten sposób nie dlatego, że za jego pomocą definiuje się relacje (powiązania) między tabelami, lecz dlatego, że poszczególne wartości w kolumnach tabeli występują w ścisłej relacji. Innymi słowy: dla każdego wiersza w tabeli poszczególne wartości kolumn są w relacji. Definicja tabeli polega na określeniu powiązań między kolumnami, a cała tabela to jest właśnie relacja (a dokładniej: każda tabela reprezentuje jedną relację). Wymagania biznesowe określają modelowany zakres sytuacji świata rzeczywistego. Po zdefiniowaniu zakresu należy zidentyfikować dane niezbędne do zarejestrowania działania biznesowego. Jeśli mamy zamodelować bazę dla komisu samochodowego (na przykład na potrzeby reklamy w internecie) będą potrzebne takie dane, jak marka, model, wersja, styl (sedan, coupe, kabriolet itp.), rocznik, przebieg i cena. To są te informacje, które przychodzą na myśl w pierwszej chwili, ale potencjalny klient może zechcieć zapoznać się z bardziej szczegółowymi informacjami, aby dokonać bardziej świadomego wyboru. Na przykład: • ogólny stan pojazdu (nawet w przypadku, gdy z założenia nie będziemy oferować samochodów w stanie innym niż „idealny”), • wyposażenie bezpieczeństwa, • rodzaj skrzyni biegów (manualna lub automatyczna), • kolor (karoseria i wnętrze), farba metaliczna lub nie, tapicerka, szyberdach, być może również zdjęcie samochodu, • maksymalna liczba przewożonych osób, pojemność bagażnika, liczba drzwi, • wspomaganie kierownicy, klimatyzacja, sprzęt audio, • pojemność silnika, liczba cylindrów, moc i prędkość maksymalna, hamulce (nie każdy użytkownik systemu będzie entuzjastą motoryzacji, aby móc określić wszystkie parametry wyłącznie na podstawie typu i wersji), • rodzaj paliwa, zużycie, pojemność zbiornika,
18
ROZDZIAŁ PIERWSZY
• aktualna lokalizacja samochodu (może mieć znaczenie w przypadku, gdy firma działa w różnych lokalizacjach), • i wiele innych… Gdy każdy model samochodu odwzorujemy w bazie danych, każdy wiersz tabeli będzie odzwierciedleniem pewnego stwierdzenia faktu z rzeczywistości. Na przykład jeden z wierszy może odzwierciedlać fakt dostępności w komisie różowego Cadillaca Coupe DeVille z roku 1964, który już dwudziestokrotnie przekroczył dystans równy obwodowi Ziemi. Za pomocą operacji relacyjnych, jak złączenia, a także z użyciem filtrowania, wyboru atrybutów lub obliczeń na wartościach atrybutów (na przykład w celu uzyskania informacji na temat średniego dystansu, jaki można pokonać między tankowaniami) możemy uzyskać nowe stwierdzenia faktów. Jeśli oryginalne stwierdzenie było prawdziwe, prawdziwe będą też stwierdzenia uzyskane na jego podstawie. W każdym działaniu związanym z przetwarzaniem wiedzy mamy do czynienia z faktami prawdziwymi, które nie wymagają dowodu. W matematyce takie fakty określa się terminem aksjomatów, lecz ta własność wiedzy nie ogranicza się do matematyki. W innych dziedzinach tego typu prawdy niewymagające dowodu nazywa się pryncypiami. Na bazie tych prawd można tworzyć nowe (w matematyce nazywane twierdzeniami). W ten sposób prawdy mogą służyć jako fundament, w oparciu o który tworzy się nowe prawdy. Relacyjna baza danych działa dokładnie w opisany wyżej sposób. To nie jest bowiem przypadek, że model relacyjny jest ściśle oparty na zjawiskach matematycznych. Definiowane relacje (które, jak już wiemy, w przypadku baz SQL odpowiadają tabelom) reprezentują prawdy przyjmowane a priori jako prawdziwe. Definiowane perspektywy i zapytania stanowią nowe prawdy, które mogą być udowodnione. UWAGA Spójność modelu relacyjnego to koncepcja, która odgrywa bardzo ważną rolę, dlatego warto poświęcić jej odpowiednią ilość czasu i pracy. Pryncypia, na których oparty jest model relacyjny, to reguły matematyczne, sprawdzone i stabilne, dlatego zawsze można liczyć na to, że w wyniku zapytania na prawidłowych danych uzyskamy prawidłowe wyniki. Pod warunkiem jednak, że będziemy ściśle przestrzegać reguł relacyjnych. Jedna z tego typu podstawowych reguł mówi, że relacja, z definicji, nie zawiera duplikatów, a kolejność jej wierszy jest bez znaczenia. Jak dowiemy się w rozdziale 4., język SQL dość swobodnie traktuje teorię relacyjną i tę swobodę można uznać za główną przyczynę powstawania nieoczekiwanych wyników lub problemów optymalizatora z efektywnym wykonywaniem zapytań.
PLANY STRATEGICZNE
19
Dobór prawd podstawowych (czyli relacji) jest dość dowolny. Czasem zdarza się zatem, że ta swoboda prowadzi do błędnych decyzji. Trudno oczekiwać, żeby przy sprzedaży kilku jabłek w budce z warzywami w celu ich zważenia sprzedawca był zmuszony dowodzić prawa Newtona. Podobnie można postrzegać program, który do zupełnie podstawowych operacji wymaga wykonania złączenia dwudziestu pięciu tabel. Firma często wykorzystuje te same dane, co jej dostawcy i klienci. Jednakże jeśli ci dostawcy i klienci nie prowadzą identycznej działalności, postrzeganie tych samych danych będzie nieco inne, dostosowane do potrzeb i sytuacji. Na przykład wymagania biznesowe firmy będą odmienne od analogicznych wymagań jej klientów, nawet mimo tego że obie strony będą wykorzystywać te same dane. Jeden rozmiar nie pasuje wszystkim. Dobry projekt to zatem taki, który nie zmusza do tworzenia karkołomnych zapytań. Modelowanie jest projekcją wymogów biznesowych.
Znaczenie normalności Normalizacja, szczególnie w zakresie od pierwszej do trzeciej postaci normalnej (3NF) to elementarne pojęcie teorii relacyjnej, z którym zetknął się na pewno każdy student informatyki. Jak większość rzeczy, których uczymy się w szkole (weźmy na przykład literaturę klasyczną), również normalizacja jest postrzegana jako pojęcie „zakurzone”, nudne i zupełnie oderwane od rzeczywistości. Wiele lat po zakończeniu nauki, gdy znajdzie się czas, aby spojrzeć na problem świeżym okiem wzmocnionym przez doświadczenie, okazuje się, że siła klasycznych pojęć jest wielka i nie poddaje się próbie czasu. Zasada normalizacji jest przykładem zastosowania rygoru logicznego do komponowania danych, które w wyniku normalizacji stają się strukturalizowaną informacją. Ten rygor jest wyrażany w definicjach postaci normalnych, z których najczęściej cytowane są trzy, choć puryści twierdzą, że istnieje postać normalna wykraczająca poza 3NF, której znaczenia nie należy lekceważyć. Tę postać nazywa się postacią Boyce’a-Codda (BCNF), a często
20
ROZDZIAŁ PIERWSZY
wręcz piątą postacią normalną (5NF). Bez paniki, my zajmiemy się tylko trzema pierwszymi postaciami. W ogromnej większości przypadków baza danych zaprojektowana w zgodzie z 3NF będzie również zgodna z BCNF2, czyli 5NF. Dlaczego normalizacja ma znaczenie? Jak sama nazwa wskazuje, normalizacja to proces przekształcania chaosu w porządek. Po bitwie błędy popełnione podczas niej często wydają się oczywiste, a decyzje prowadzące do sukcesu jawią się jako zupełnie naturalne i zgodne ze zdrowym rozsądkiem. Podobnie po przeprowadzeniu normalizacji na danych struktury tabel wyglądają zupełnie naturalnie, a reguły normalizacyjne często określa się (umniejszając ich znaczenie) jako przesadnie wyeksponowane zastosowanie zdrowego rozsądku. Każdy z nas z pewnością wyobraża sobie, że jest wyposażony w odpowiedni zapas zdrowego rozsądku. Jednak w zetknięciu ze skomplikowanymi danymi łatwo się pogubić. Pierwsze trzy postacie normalne są oparte na czystej logice i dzięki temu doskonale sprawdzają się jako kontrolne listy reguł ułatwiające proces wyciągania danych z chaosu. Dość marne są szanse, że w wyniku zastosowania źle zaprojektowanej, nieznormalizowanej bazy danych nasz system ulegnie katastrofie w wyniku uderzenia piorunem, który obróci go w kupkę popiołu, jak na to zasłużył. Dlatego, jak sądzę, tę sytuację należy skatalogować wśród nieudowodnionych teorii. Jednakże takie katastrofy, jak niespójność danych, trudności w projektowaniu elementów interfejsu do wprowadzania danych oraz zwiększone nakłady na rozwiązywanie błędów w nadmiernie skomplikowanych programach to dość prawdopodobne zagrożenia, przy których należy wspomnieć o obniżonej wydajności i trudnościach w rozbudowie modelu. Te zagrożenia, wynikające z niezastosowania się do reguł normalizacji, mają z kolei bardzo wysoki współczynnik prawdopodobieństwa i wkrótce dowiemy się, dlaczego tak właśnie się dzieje. W jaki sposób przekształcić dane z heterogenicznej masy nieustrukturalizowanych informacji w użyteczny model bazy danych? Sama metoda wydaje się dość prosta. Wystarczy zastosować się do kilku zaleceń, które omówimy w kolejnych punktach wraz ze stosownymi przykładami. 2
Tabela jest zgodna z 3NF, ale niezgodna z BCNF, jeśli zawiera różne zbiory nieunikalnych kolumn (kluczy kandydujących, unikalnie identyfikujących identyfikatory w wierszach) posiadających jedną kolumnę wspólną. Tego typu sytuacje nie są bardzo powszechne.
PLANY STRATEGICZNE
21
Etap 1. Zapewnienie atomowości W pierwszym kroku musimy upewnić się, że charakterystyki, czyli atrybuty, danych maja charakter atomowy. Koncepcja atomowości jest dość mało precyzyjna mimo pozornej prostoty swego założenia. Określenie atom pochodzi jeszcze z czasów starożytnej Grecji. Zostało wprowadzone przez Leucippusa, greckiego filozofa, który żył w V wieku przed naszą erą. Atom to coś, co „nie może być podzielone” (jak wiemy, zjawisko rozszczepienia jądra atomowego jest zaprzeczeniem tej definicji). Decyzja o tym, czy dane można uznać za atomowe, to, z grubsza biorąc, problem skali. Na przykład dla generała pułk może być atomową jednostką bojową, lecz zdaniem pułkownika (dowodzącego tym pułkiem) należy go podzielić na bataliony. Podobnie samochód może być jednostką atomową dla handlarza, lecz mechanikowi samochodowemu z pewnością jawi się jako bogactwo elementów i części zamiennych, które stanowią dla niego elementy atomowe. Z czysto praktycznych względów atrybutami atomowymi będziemy nazywać te atrybuty, których wartości mogą w klauzuli WHERE być wykorzystywane w całości. Atrybut można dzielić na składowe w ramach listy SELECT (czyli w definicji zwracanych elementów), lecz jeśli pojawia się potrzeba, aby odwołać się do części wartości atrybutu w ramach klauzuli WHERE, to znak, że dany atrybut pozostawia jeszcze nieco do życzenia z punktu widzenia atomowości. Warto w tym miejscu posłużyć się przykładem. W liście atrybutów na potrzeby systemu obsługi sprzedaży używanych samochodów znajdziemy pozycję „wyposażenie bezpieczeństwa”. To ogólna nazwa, w ramach której mieszczą się różne mechanizmy, jak system hamulcowy ABS, poduszki powietrzne (tylko dla pasażerów, dla pasażerów i kierowcy, przednie, boczne itd.). Do tej kategorii można również zaliczyć mechanizmy zapobiegające kradzieżom, jak blokada skrzyni biegów i centralny zamek. Możemy oczywiście zdefiniować kolumnę o nazwie safety_equipment (wyposażenie bezpieczeństwa), w której będziemy wprowadzać opis słowny zastosowanych zabezpieczeń. Należy jednak mieć świadomość, że stosując opis słowny, tracimy przynajmniej jedną z poważnych zalet baz danych: Możliwość szybkiego wyszukiwania informacji Jeśli klient uzna, że mechanizm ABS jest kluczowy, ponieważ często podróżuje po mokrych, śliskich nawierzchniach, wyszukiwanie wykorzystujące ABS jako główne kryterium będzie działało bardzo
22
ROZDZIAŁ PIERWSZY
wolno, ponieważ w celu sprawdzenia, czy podciąg znaków ABS występuje w wartości kolumny safety_equipment (wyposażenie bezpieczeństwa), muszą być odczytane wartości tego atrybutu we wszystkich wierszach tabeli. Jak o tym będę szerzej rozprawiać w rozdziale 3., standardowe indeksy wymagają wartości atomowych (w takim sensie, jaki objaśniłem wyżej) w charakterze kluczy. W niektórych silnikach baz danych można posłużyć się innymi mechanizmami przyspieszającymi działanie zapytań, jak indeksy tekstowe (ang. full text indexing), lecz tego typu mechanizmy z reguły mają wady, na przykład brak możliwości obsługi modyfikacji w czasie rzeczywistym. Wyszukiwanie tekstowe może również generować nieoczekiwane wyniki. Załóżmy, że w tabeli samochodów mamy kolumnę reprezentującą kolor pojazdu w postaci opisu koloru karoserii i wnętrza. Jeśli będziemy wyszukiwać tekstu „niebieski”, ponieważ interesuje nas taki kolor karoserii, zapytanie zwróci również szare samochody z niebieskimi obiciami foteli. Każdy z nas z pewnością doświadczył zalet i wad wyszukiwania tekstowego, w taki sposób działają wszak wyszukiwarki internetowe. Ochrona poprawności danych Wprowadzanie danych to procedura podatna na błędy. Jeżeli przy wyszukiwaniu zostanie wprowadzony tekst ASB, zamiast ABS, baza danych nie jest w stanie zweryfikować poprawności, jeśli wykorzystujemy atrybuty opisowe i wyszukiwanie pełnotekstowe. W takim przypadku użytkownik nie uzyska żadnych wyników bez sugestii o tym, że mógł popełnić błąd literowy. Co się z tym wiąże, niektóre z zapytań będą zwracały nieprawidłowe wyniki — niekompletne, a czasem zupełnie błędne, na przykład w przypadku, gdy zechcemy policzyć wszystkie dostępne samochody wyposażone w ABS. Jeśli chcemy zapewnić poprawność danych, jedynym sposobem (oprócz dokładnego sprawdzania poprawności wprowadzanych danych) jest napisanie skomplikowanej funkcji analizującej ciągi znaków i kontrolującej je przy każdym wprowadzaniu i modyfikacji. Trudno ocenić, co byłoby gorsze: poziom komplikacji takiej funkcji i koszmar związany z jej modyfikacjami i aktualizacją czy obniżenie wydajności przy wprowadzaniu danych. Z drugiej strony, jeśli zamiast ogólnego atrybutu opisowego zastosować atrybut has_ABS o wartościach prawda i fałsz, można by wprowadzić banalną regułę sprawdzającą poprawność danych już na etapie formularza użytkownika.
PLANY STRATEGICZNE
23
Częściowa zmiana ciągu znaków to kolejne zadanie wymagające od programisty wirtuozerii w funkcjach przetwarzania ciągów znaków. Z tych właśnie powodów (i wielu innych) należy unikać pakowania wielu różnych informacji w jeden ciąg znaków. Określanie, czy dane mają już cechy atomowe, to dość trudna sztuka. Najprostszym przykładem są adresy. Czy adres powinien być obsługiwany jako jeden ciąg znaków? A może lepiej zapisywać elementy adresu w osobnych atrybutach? Jeśli zdecydujemy się rozbić adres na kawałki, jak daleko warto się posunąć? Należy mieć na uwadze względność pojęcia atomowości, a przede wszystkim wymagania biznesowe w stosunku do bazy danych. Jeśli na przykład planujemy dokonywać obliczeń lub generować statystyki w rozbiciu na kody pocztowe i miasta, to kod pocztowy i miasto muszą być potraktowane jako wartości atomowe. Należy jednak zachować umiar w rozbijaniu na atomy i utrzymać właściwy poziom rozdrobnienia danych. Podstawowa zasada przy określaniu poziomu rozbicia adresu na składowe polega na dopasowaniu każdego z elementów adresu do reguł biznesowych. Trudno przewidzieć, jakie będą te atrybuty (choć oczywiście w przypadku adresu możliwości są skończone), musimy jednak unikać pokusy stosowania konkretnego schematu atrybutów adresu tylko dlatego, że taki został zastosowany przez inną firmę. Każdy zestaw atrybutów musi być skrupulatnie przetestowany pod kątem konkretnych potrzeb biznesowych. Diabeł tkwi w szczegółach. Popadając w przesadę, można z łatwością otworzyć drzwi wielu potencjalnym problemom. Gdy zdecydujemy się na osobne umieszczanie nazwy ulicy i numeru budynku, co zrobić z firmą ACME Corp, której siedziba mieści się pod adresem „Budynek ACME”? Modelując bazę danych do przechowywania zbędnych informacji, narażamy się po prostu na problemy natury projektowej. Prawidłowe zdefiniowanie poziomu szczegółowości informacji może stanowić kluczowy element mechanizmu przekazywania danych z podsystemu operacyjnego do decyzyjnego. Po zidentyfikowaniu danych atomowych oraz zdefiniowaniu ich wzajemnych powiązań powstają relacje. Następnym etapem jest identyfikacja unikalnej charakterystyki każdego wiersza, czyli klucza głównego. Na tym etapie bardzo prawdopodobne jest, że klucz główny będzie złożony z wielu atrybutów. W naszym przykładzie bazy danych używanych samochodów
24
ROZDZIAŁ PIERWSZY
z punktu widzenia klienta samochód jest identyfikowany przez kombinację marki, modelu, stylu, rocznika i przebiegu. Nie ma w tym przypadku znaczenia jego numer rejestracyjny. Nie zawsze prawidłowa definicja klucza głównego jest łatwym zadaniem. Dobrym, klasycznym przykładem analizy atrybutów jest biznesowa definicja „klienta”. Klient może być zidentyfikowany na podstawie nazwiska. Jednak nazwisko nie zawsze jest wystarczającym identyfikatorem. Jeśli klientami mogą być firmy, identyfikacja ich po nazwie może być niewystarczająca. Czy „RSI” to „Relational Software”, „Relational Software Inc” (z kropką po „Inc” lub bez niej, z przecinkiem po „Relational Software” lub bez)? Stosować wielkie litery? Małe? Inicjały wielkimi literami? Istnieje zagrożenie, że dane wpisane do bazy nie będą z niej wydobyte. Wybór nazwy klienta w charakterze identyfikatora to dość ryzykowna decyzja, ponieważ narzuca ona konieczność zachowania określonego standardu zapisu, aby uniknąć wieloznaczności. Zaleca się, by klienci byli identyfikowalni na podstawie standardowej nazwy skróconej lub wręcz unikalnego kodu. Zawsze należy brać pod uwagę zagrożenie wynikające ze zmiany nazwy z Relational Software Inc. na przykład na Oracle Corporation. Jeśli chcemy zachować historię kontaktów z klientem również po zmianie jego nazwy, musimy być w stanie zidentyfikować firmę w dowolnym punkcie czasu niezależnie od jej nazwy. Z drugiej strony zaleca się, żeby, o ile to możliwe, unikać identyfikatorów w postaci nic niemówiących liczb, a korzystać z identyfikatorów czytelnych, posiadających własne, określone znaczenie. Klucz główny powinien charakteryzować dane, co trudno powiedzieć o sekwencyjnym identyfikatorze automatycznie przypisanym każdemu wprowadzanemu wierszowi. Tego typu identyfikator można uzupełnić później, jeśli okaże się, że sztuczny atrybut company_id jest łatwiejszy w utrzymaniu od miejsca założenia i rzeczywistego numeru identyfikacyjnego nadanego firmie przez urząd. Taki sekwencyjny identyfikator można również zastosować w chlubnym charakterze klucza głównego, lecz należy pamiętać, że będzie to forma technicznego substytutu (lub skrótu) pełnoprawnego klucza. Funkcjonuje tu dokładnie ta sama zasada, jak w przypadku stosowania aliasów nazw tabel, gdy w klauzuli filtra można napisać coś takiego: where a.id = b.id
Jest to skrócona forma następującego zapisu: where tabela_z_dluga_nazwa.id = tabela_z_nazwa_jeszcze_gorsza_od_poprzedniej.id
PLANY STRATEGICZNE
25
Należy jednak zachować pełną świadomość, że identyfikator liczbowy nie stanowi rzeczywistego klucza głównego i nie należy go z nim mylić. Jeśli wszystkie identyfikatory są atomowe i zostaną zidentyfikowane klucze, dane są zgodne z pierwszą postacią normalną (1NF).
Etap 2. Sprawdzanie zależności od klucza głównego Jak zauważyłem wcześniej, część informacji przechowywanych w systemie informatycznym pośrednika handlu używanymi samochodami zapewne będzie znana lepiej zorientowanym entuzjastom motoryzacji. Co więcej, wiele z cech charakterystycznych używanych samochodów nie będzie unikalnych tylko dla jednego egzemplarza. Na przykład wszystkie samochody tej samej marki, modelu, wersji i stylu będą również cechować się taką samą ładownością niezależnie od rocznika i przebiegu. Innymi słowy, w tabeli przechowującej wszystkie informacje o samochodach będziemy mieli sporo informacji zależnych od części klucza głównego. Jakie będą skutki uboczne decyzji, by wszystkie te dane zostały zapisane w jednej tabeli o nazwie used_cars? Nadmiarowość danych Jeśli okaże się, że wystawimy na sprzedaż wiele samochodów tej samej marki, modelu, wersji i stylu (to są z reguły te cechy, które są ogólnie określane jako model samochodu), wszystkie te atrybuty, które nie będą specyficzne dla danego egzemplarza, będą zapisane w bazie wielokrotnie, a dokładniej tyle razy, ile razy wystąpi w niej dany model samochodu. Z tego typu zjawiskiem powtarzalności (nadmiarowości, redundancji) danych wiążą się dwa podstawowe zagrożenia. Po pierwsze, nadmiarowe informacje zwiększają prawdopodobieństwo wprowadzenia do bazy sprzecznych danych z powodu błędów przy wprowadzaniu (co z kolei powoduje, że poprawianie tego typu błędów jest bardzo czasochłonne). Po drugie, nadmiarowe dane to po prostu marnotrawstwo miejsca. Co prawda zasoby dyskowe są coraz mniej kosztowne, co spowodowało, że po prostu przestano obsesyjnie niepokoić się o rozmiar danych. To prawda, ale przy tym zapomina się często, że wraz ze wzrostem pojemności dysków twardych i innych nośników znacznie zwiększa się ilość zapisywanych na nich danych. Do tego dochodzi coraz powszechniejsze zjawisko dublowania danych,
26
ROZDZIAŁ PIERWSZY
wykonywania kopii zapasowych na osobnych nośnikach na wypadek awarii oraz na potrzeby tworzenia oprogramowania, gdy po prostu wykorzystuje się kopie rzeczywistych baz danych. W efekcie każdy zmarnowany bajt należy pomnożyć przez pięć i to w najbardziej optymistycznych oszacowaniach. Po obliczeniu zmarnowanych bajtów można uzyskać zupełnie zaskakujące wyniki. Oprócz oczywistego kosztu zasobów sprzętowych należy również uwzględnić koszt przywracania systemu do sprawności po sytuacji awaryjnej. Bywają bowiem sytuacje „nieoczekiwanych przestojów”, na przykład wskutek poważnej awarii sprzętowej, gdy występuje konieczność odtworzenia całej bazy danych z kopii zapasowej. Pomijając inne czynniki, nie da się zaprzeczyć, że dwa razy większa baza danych będzie się odtwarzać dwa razy dłużej. Istnieją instalacje, w których każda minuta przestoju to wymierne, bardzo wysokie koszty. W niektórych środowiskach, jak szpitale, przestój może kosztować nawet zdrowie lub życie. Wydajność operacji Tabela zawierająca dużo informacji (z dużą liczbą kolumn) wymaga więcej czasu na odczyt z dysku i wykonanie pełnego przeszukiwania niż tabela, w której kolumn jest mniej. Jak spróbuję dowieść w kolejnych rozdziałach, pełne przeszukiwanie tabeli to nie tak straszne i niepożądane działanie, jak może się wydawać początkującym programistom. W niektórych przypadkach to absolutnie najlepszy wybór. Jednakże im więcej bajtów znajduje się w każdym z wierszy, tym więcej stron będzie zajmować tabela i tym więcej czasu zajmie jej pełne przeszukiwanie. Gdy zechcemy na przykład wygenerować pełną listę dostępnych modeli samochodów, w przypadku nieznormalizowanej tabeli będziemy zmuszeni wykonać zapytanie SELECT DISTINCT przeszukujące zbiór wszystkich dostępnych samochodów. Zapytanie SELECT DISTINCT nie tylko oznacza przeszukanie całej tabeli samochodów, lecz wiąże się również z koniecznością posortowania wyników w celu eliminacji duplikatów. Gdyby dane wyodrębnić do osobnej tabeli w taki sposób, aby silnik bazy danych mógł przeszukiwać jedynie podzbiór danych, operacja zajęłaby o wiele mniej czasu niż wykonywana na całości. Aby pozbyć się zależności danych od części klucza, należy utworzyć nową tabelę (np. car_model). Klucze tych nowych tabel będą składały się z części klucza naszej tabeli wyjściowej (w tym przypadku marka, model, wersja i styl). Wszystkie atrybuty zależne od tych nowych kluczy należy przenieść
PLANY STRATEGICZNE
27
do utworzonych tabel. W oryginalnej tabeli pozostawiamy tylko atrybuty marka, model, wersja i styl. Ten proces należy powtórzyć również w przypadku silnika i jego cech, które nie są uzależnione od stylu samochodu. Po usunięciu z tabel wszystkich atrybutów uzależnionych od części klucza nasza baza będzie zgodna z drugą postacią normalną (2NF).
Etap 3. Sprawdzenie niezależności atrybutów Po przekształceniu wszystkich danych do 2NF można przejść do procesu identyfikacji trzeciej postaci normalnej (3NF). Bardzo często bywa tak, że dane zgodne z 2NF są również zgodne z 3NF, niemniej jednak należy upewnić się co do tego faktu. Wiemy już, że każdy atrybut danych jest zależny wyłącznie od unikalnego klucza. 3NF występuje wówczas, gdy wartości atrybutu nie daje się pozyskać na podstawie wartości innych atrybutów oprócz tych, wchodzących w skład unikalnego klucza. Należy tu odpowiedzieć na pytanie: „Czy znając wartość atrybutu A, jestem w stanie określić wartość atrybutu B?”. Międzynarodowe dane kontaktowe stanowią doskonały przykład sytuacji, gdzie atrybut jest zależny od innego atrybutu niebędącego kluczem. Gdy znamy kraj, nie mamy potrzeby zapisywać w numerze telefonu międzynarodowego numeru kierunkowego. Jednak odwrotna sytuacja już nie zachodzi; na przykład zarówno Kanada, jak i USA mają ten sam międzynarodowy numer kierunkowy. Jeśli w bazie potrzebne są obydwa rodzaje informacji, z adresem można na przykład zapisać kod ISO kraju (dla Polski będzie to PL), a w osobnej tabeli country_info z kodem kraju służącym jako klucz główny zapisać wszystkie istotne informacje związane z danymi adresowymi w danym kraju. W tabeli country_info można na przykład zapisać informację o numerze kierunkowym kraju (48 w przypadku Polski), walucie itp. Każda para atrybutów w danych zgodnych z 2NF powinna być przeanalizowana pod kątem tego typu powiązań. To sprawdzenie jest z reguły żmudnym procesem, lecz kluczowym, jeśli dane mają być naprawdę zgodne z 3NF. Jakie jest ryzyko związane z posiadaniem danych niezgodnych z 3NF? Praktycznie analogiczne do tego, co grozi w przypadku niezgodności z 2NF. Istnieje kilka powodów, dla których modelowanie danych w zgodzie z 3NF jest istotne. Istnieje też kilka powodów, dla których modelowane bazy danych celowo nie są zgodne z 3NF. Należy do nich modelowanie wymiarowe,
28
ROZDZIAŁ PIERWSZY
o którym pokrótce wspomnę w rozdziale 10. Lecz zanim zaczniemy celowo podważać regułę, warto znać powody jej stosowania i ryzyko związane z jej lekceważeniem. Prawidłowy znormalizowany model chroni przed zagrożeniami w wyniku ewolucji wymagań W rozdziale 10. omówię dokładnie powody, dla których stosowane są odstępstwa od normalizacji, jak na przykład modelowanie wymiarowe. Dość wspomnieć, że tego typu modele wymagają przyjęcia mnóstwa założeń dotyczących obsługi danych i zapytań. To samo można powiedzieć o fizycznych strukturach danych, które omówię w rozdziale 5. Jednak istnieje tu ważna różnica: modyfikacje w implementacjach fizycznych nie niszczą całej koncepcji, jak ma to miejsce w implementacjach modeli wymiarowych, mogą jednak mieć poważny wpływ na wydajność działań w bazie. Jeśli pewnego dnia okaże się, że przyjęte założenia dotyczące modelu wymiarowego były nieprawidłowe, jedyne rozwiązanie tej sytuacji polega na „wyrzuceniu” modelu i rozpoczęciu pracy od nowa. W przypadku danych zgodnych z 3NF model wymaga jedynie pewnych modyfikacji zapytań, lecz jest wystarczająco elastyczny, aby przystosować się do wielu typów modyfikacji logicznej interpretacji danych. Normalizacja minimalizuje duplikację danych Jak już wspominałem, duplikacja danych jest zjawiskiem kosztownym, zarówno z powodu zajętości dysków twardych, jak i mocy procesora, ale przede wszystkim prowadzi do zwiększenia prawdopodobieństwa uszkodzenia danych. Takie uszkodzenie występuje w przypadku, gdy modyfikowana jest wartość danych w jednym miejscu, lecz identyczna wartość w innym miejscu bazy nie zostaje odpowiednio (jednocześnie) zmodyfikowana. Utrata informacji nie musi zatem wiązać się z usunięciem danych: jeśli jedna część bazy danych mówi, że kolor jest „biały”, a druga, że ten sam kolor jest „czarny”, mamy do czynienia z utratą informacji. Niespójności danych można częściowo zapobiec dzięki mechanizmom wbudowanym w system DBMS, o ile pozwala na to zastosowany model. W definicji danych można bowiem zawrzeć więzy integralności i ograniczenia atrybutów. Jeśli dane nie są odpowiednio zaprojektowane, można próbować wykorzystać dodatkowe zabezpieczenia programowe. Mamy bowiem możliwość wykorzystania wyzwalaczy i procedur osadzonych. Takie mechanizmy mają jednak tendencje do
PLANY STRATEGICZNE
29
znacznego rozrastania się i wprowadzają dodatkowe obciążenia systemu oraz nadmierny poziom komplikacji programu, co powoduje, że jego rozwój jest znacznie bardziej kosztowny. Wyzwalacze i procedury wbudowane muszą być bardzo dobrze udokumentowane. Ochrona spójności danych zaimplementowana na poziomie oprogramowania powoduje przesunięcie mechanizmów kontroli poprawności z bazy danych na warstwę programową. Każdy inny program, który będzie odczytywał lub, co ważniejsze, zapisywał dane w tej samej bazie, będzie musiał mieć zaimplementowane analogiczne mechanizmy zapewniające spójność danych. W przeciwnym razie zaistnieje realne zagrożenie utraty spójności danych w bazie. Proces normalizacji polega w gruncie rzeczy na zastosowaniu na szeroką skalę pojęcia atomowości w modelowanym świecie.
Być albo nie być. Albo być NULL Powszechny błąd popełniany przy projektowaniu baz danych polega na próbie uwzględnienia dużej liczby możliwych cech w ramach jednej relacji, co skutkuje dużą liczbą kolumn. Niektóre dziedziny nauki mogą wymagać dużej szczegółowości cech analizowanych zjawisk, a co się z tym wiąże dużej liczby opisywanych atrybutów obiektów. W przypadku zastosowań biznesowych bywa tak jednak bardzo rzadko. Niechybnym sygnałem, że tabela „padła ofiarą” tego typu nadmiaru, jest sytuacja, gdy większość atrybutów wiersza jest NULL, co z kolei często ma związek z faktem, że niektóre pary atrybutów nie mogą jednocześnie przyjmować wartości: jeśli jeden atrybut ma wartość, drugi musi być NULL i vice versa. Taka sytuacja sygnalizuje również brak zgodności relacji z 2NF i 3NF. Należy przyjąć, że każdy wiersz tabeli to forma stwierdzenia na temat opisywanego zjawiska. NULL oznacza „nie wiadomo”, co z pewnością obniża wartość tabeli, której większość atrybutów zwraca uwagę, że niewiele wiadomo o opisywanym obiekcie. Jeśli dane są przechowywane jedynie w celach informacyjnych, nie ma większej szkody. Jednak w przypadku, gdy atrybuty NULL niosą zamierzoną informacje o stanie obiektu, mamy do czynienia z poważnym błędem modelowania. Wszystkie kolumny w wierszu powinny z założenia zawierać wartości, nawet jeśli
30
ROZDZIAŁ PIERWSZY
względy techniczne zakładają, że niektóre z nich mogą być uzupełniane w późniejszym czasie. Podobnie postępuje filatelista pozostawiający w klaserze wolne miejsce na egzemplarze znaczków pocztowych, których jeszcze nie zdobył (ale o których wie, że istnieją). Jednak nawet on nie dopuszcza się marnotrawstwa miejsca, pozostawiając maksymalny możliwy zapas miejsca wolnego. Dodatkowo w przypadku tego typu rozrzutności istnieje ryzyko poważnych problemów związanych z wydajnością, jeśli większość kolumn w tabeli to puste miejsca. Gdy dane zostaną w końcu wprowadzone, trafią w jakieś odległe miejsce na dysku twardym. Istnienie NULL-i stanowi poważne zagadnienie w modelowaniu relacyjnym, które z kolei stanowi podstawę optymalizatora zapytań. Kompletność modelu relacyjnego jest oparta na zastosowaniu klasycznej logiki dwuwartościowej, w której informacja istnieje lub jej nie ma. Każdy przypadek typu „nie wiadomo” to właśnie NULL, ale w klauzuli WHERE warunki nie mogą być nieokreślone. Warunek logiczny przyjmuje wartość TRUE lub FALSE, ponieważ baza danych musi mieć jasność, czy wiersz ma być uwzględniony w wyniku, czy nie. Nie można bowiem zaimplementować warunku typu „Te dane być może odpowiadają określonemu warunkowi, ale nie ma pewności”. Połączenie logiki trójwartościowej (prawda, fałsz, stan nieokreślony) z logiką dwuwartościową może dawać niebezpieczne efekty. Każdy doświadczony użytkownik SQL-a może przytoczyć wiele przypadków, gdy solidnie wyglądające zapytanie SQL dawało zupełnie nieprawidłowe wyniki tylko dlatego, że logiczny warunek musiał rozstrzygać przypadki występowania NULL-i. Załóżmy na przykład, że kolumna o nazwie color zawiera wartości RED, GREEN i BLACK. Weźmy następujący warunek: where color not in ('BLUE', 'BLACK', null)
Spowoduje on, że nie zostanie zwrócony żaden wiersz, ponieważ nie wiadomo, jaką wartość ma NULL, a silnik SQL weźmie pod uwagę, że może mieć wartość RED lub GREEN. Weźmy podobny warunek: where color in ('BLUE', 'BLACK', null)
Spowoduje on zwrócenie wszystkich wierszy z tabeli, w których w kolumnie color występuje wartość BLACK (jak pamiętamy, w tabeli nie ma wartości BLUE), lecz nie zwróci żadnych innych, ponieważ istnieje ryzyko, że NULL nie ma wartości RED lub GREEN. Jak widać, silnik SQL-a jest bardziej ostrożny niż bankier. Oczywiście jawne wystąpienie NULL w liście wartości in (...)
PLANY STRATEGICZNE
31
to raczej rzadkość, lecz zamiast jawnej listy możemy posłużyć się podzapytaniem i nie upewnimy się, że w jego wyniku znajdą się NULL-e. Dobrym przykładem trudności, jakie mogą wyniknąć z braku informacji, jest reprezentacja klientów. Każdy klient posiada adres, standardowo ten sam, który pojawia się na fakturze. Co jednak zrobić, gdy towar należy wysłać pod inny adres? Czy adres dostawy ma być cechą dostawy czy klienta? Miałoby to sens w przypadku, gdyby na każdy z adresów towar był wysyłany tylko raz. Jeśli nie prowadzimy zakładu pogrzebowego, a szczególnie jeśli zależy nam, aby często wysyłać towary na ten sam adres, przypisywanie adresu każdej dostawie nie ma sensu z biznesowego punktu widzenia. Ręczne wprowadzanie adresu za każdym razem to dość pracochłonne zadanie, a przede wszystkim może stać się ono przyczyną błędów, przez co towar może być wysłany na błędny adres, co w konsekwencji spowoduje, że klient (w tym momencie zapewne już były klient) nie będzie zadowolony. Adres dostawy jest zatem cechą klienta, nie zamówienia. To zagadnienie powinno być ustalone na etapie analizy zależności w ramach projektowania modelu danych. Może się również zdarzyć, że adres, na który ma być wystawiona faktura, znajduje się w innym miejscu niż lokalizacja klienta. Dotyczy to szczególnie większych firm z wieloma oddziałami. Z tego powodu jeden klient może mieć zdefiniowane trzy adresy: jeden „oficjalny”, jeden do wystawienia faktury i jeden do dostawy. Nie jest rzadkością spotykać tabele z danymi klientów zawierające trzy komplety kolumn na potrzeby właśnie tych trzech adresów. Jednak nie można przyjąć, że zawsze będą potrzebne wszystkie trzy adresy. Jaki przypadek będzie najbardziej powszechny? Całkiem możliwe, że w 90% przypadków będziemy mieli dostępny tylko jeden adres, ten oficjalny. Co zatem zrobić z pozostałymi kolumnami? Przychodzą na myśl dwie możliwości: Ustawienie kolumn adresów dostawy i faktury na NULL To niezbyt rozsądna strategia, ponieważ wymaga, aby w aplikacjach zaimplementować specjalne reguły obsługi typu: „Jeśli adres na fakturę jest niezdefiniowany, wyślij fakturę na adres główny”. Taka logika zaszyta w programach ma tendencję do rozrastania się i może stać się przyczyną błędów w kodzie.
32
ROZDZIAŁ PIERWSZY
Replikacja informacji: skopiowanie adresu głównego do kolumn przeznaczonych na adres do faktury oraz adres wysyłki, o ile te ostatnie nie są zdefiniowane To podejście wymaga specjalnych mechanizmów wprowadzania danych, na przykład wyzwalaczy. W takim przypadku występuje pewien, zapewne nieznaczący, narzut podczas wprowadzania danych; nie można jednak wykluczyć, że w pewnych przypadkach narzut ten może stać się znaczący. Co więcej, musimy również zająć się zmianami w replikach — każda zmiana w adresie głównym musi zostać zreplikowana w tych adresach, które są identyczne, w celu zachowania jednolitości. Obydwa opisane podejścia ujawniają podstawowy brak zrozumienia rzeczy u osób opracowujących ten model danych. Wykorzystanie NULL-i nieuchronnie wprowadza konieczność obsługi logiki trójwartościowej, co z kolei wpływa na powstawanie niespójności semantycznych. A problemów semantycznych trudno się pozbyć nawet przy zastosowaniu bardzo inteligentnych zabiegów programistycznych. Replikacja danych demonstruje natomiast konsekwencje wynikające z nieprawidłowej analizy zależności między nimi. Jedno z rozwiązań problemu z adresami polega na wyodrębnieniu adresu z tabeli klientów. Warto rozważyć taki projekt, w którym adresy są zapisywane w tabeli adresów wraz z identyfikatorem klienta oraz kolumną (na przykład w postaci maski bitowej) informującą o typie adresu. Nie jest to jednak złoty środek, ponieważ często się zdarza, że nowe, ważne fakty dotyczące modelowanych zagadnień wychodzą na światło dzienne już po oddaniu aplikacji do użytku i próby modyfikacji modelu na potrzeby następnej wersji aplikacji mogą wprowadzić nowe, trudne do uniknięcia problemy. Jak do tej pory założyliśmy, w przypadku klienta mamy do dyspozycji jeden adres dostawy, który może być identyczny z adresem głównym, ale niekoniecznie. Co jednak zrobić, jeśli faktury mają być przesyłane zawsze na jeden adres, ale dostawy będą przesyłane do różnych oddziałów, a co więcej, jedna faktura będzie opiewała na towary przesyłane na różne adresy? Tego typu sytuacja nie wydaje się zupełnie niemożliwa do zaistnienia! Nie będzie już sensu, aby stosować pojedynczy „adres dostawy” (który wszak w większości przypadków będzie NULL) reprezentowany w kilku kolumnach tabeli klientów. W tym przypadku, jak na ironię, wracamy do koncepcji „adres dostawy jest cechą zamówienia”. Oznacza to, że jeśli
PLANY STRATEGICZNE
33
zechcemy odwołać się (wielokrotnie) do adresów zamówień, będzie nam potrzebny jakiś identyfikator adresu, który pozwoli uniknąć konieczności powtarzania go w każdym z zamówień (normalizacja). Alternatywnie warto wziąć pod uwagę stworzenie osobnej tabeli adresów na potrzeby dostaw. Nigdy nie można powiedzieć, że jakiś projekt jest idealny. Taka sama sytuacja zachodzi w przypadku problemu z klientami i ich adresami. Często zdarzało mi się natrafiać na problemy tego typu i zdecydowałem się podjąć próbę naszkicowania kilku możliwych rozwiązań. Jednak zawsze może zdarzyć się, że jakieś rozwiązanie działa idealnie w jednym środowisku, podczas gdy pozostałe mogą prowadzić do ryzyka powstania niespójności. W przypadku zastosowania nieodpowiedniego rozwiązania kod może stać się bardziej skomplikowany, niż to konieczne (w najlepszym przypadku), ale też istnieje niemała szansa, że będzie działał w sposób nieoptymalny. Zagadnienie NULL-i jest zapewne jednym z najbardziej kontrowersyjnych w teorii relacyjnej. Dr E.F. Codd, twórca modelu relacyjnego, wprowadził to zjawisko na wczesnym etapie prac nad teorią relacyjną i w trzeciej z dwunastu reguł opublikowanych w 1985 roku poprosił o systematyczne traktowanie NULL-i. Te dwanaście reguł to precyzyjna definicja wymaganych właściwości relacyjnych baz danych. Jednak do tej pory nie ustała jeszcze dyskusja na ten temat wśród teoretyków. Problem polega między innymi na tym, że sytuacja typu „wartość nieznana” może wynikać z szeregu zupełnie odmiennych powodów. Załóżmy, że prowadzimy listę sławnych pisarzy, zapisując dla każdego z nich datę urodzenia i śmierci. NULL w miejscu daty urodzenia raczej jednoznacznie nasuwa interpretację „wartość nieznana”. Co jednak można powiedzieć w przypadku NULL-a w miejscu daty śmierci? Czy to oznacza, że osoba nadal żyje? Czy raczej to, że nie wiemy, czy ta sławna osoba jeszcze żyje? Nie mogę odmówić sobie bezgranicznej przyjemności zacytowania słów byłego Sekretarza Obrony USA Donalda Rumsfelda, z lutego 2002 roku, gdy odczytywał informacje ze swojego departamentu: Jak wiemy, są rzeczy znane. Rzeczy, o których wiemy, że o nich wiemy. Wiemy jednak również o znanych nam rzeczach nieznanych. Czyli wiemy, iż istnieją rzeczy, o których nie wiemy. Są jednak także nieznane nam rzeczy nieznane, to znaczy te, o których nie wiemy, że o nich nie wiemy.
34
ROZDZIAŁ PIERWSZY
Nie uważam, żeby czymś niestosownym było używanie NULL-i do oznaczania, że posłużę się cytatem, „znanych nam rzeczy nieznanych”, czyli atrybutów, o których wiemy, że istnieją, ale w danym punkcie czasu z różnych przyczyn nie jesteśmy w stanie określić ich wartości. W przypadku pozostałych sytuacji przyczyna istnienia NULL-a z reguły nie prowadzi donikąd. Istnieją dodatkowo bardzo interesujące występowania NULL-i będących efektem złączenia tabel, w których NULL-e w ogóle nie występują. NULL-e bowiem powstają w wyniku złączeń zewnętrznych (ang. outer join). Bardzo efektywne techniki weryfikujące występowanie określonych wartości w tabelach polegają właśnie na zastosowaniu złączeń zewnętrznych i sprawdzeniu, czy w ich wyniku występują NULL-e. Więcej informacji na ten temat można znaleźć w rozdziale 6. NULL-e mogą być zagrożeniem dla logiki: jeśli już muszą być stosowane, należy upewnić się, czy jesteśmy świadomi wszystkich konsekwencji ich zastosowania w określonej sytuacji.
Kolumny o wartościach boolowskich Mimo tego, że w SQL-u nie jest zdefiniowany typ boolowski, wielu użytkowników potrzebuje wykorzystać znaczniki reprezentujące jakiś stan logiczny (prawda/fałsz, na przykład zamowienie_zrealizowane). Należy zmierzać do zagęszczania danych: informacja typu zamowienie_zrealizowane jest bardzo cenna, ale w ramach zamówienia nie mniej ważne mogą być informacje, kiedy zamówienie zostało zrealizowane czy też kto je zrealizował. Chodzi o to, że zamiast kolumny typu „tak lub nie” można zastosować kolumnę data_realizacji oraz ewentualnie kolumnę realizator_zamowienia, które na pewno niosą więcej informacji. Z pewnością nie zależy nam na pojawianiu się w nich NULL-i, jeśli zamówienie nie jest jeszcze zrealizowane. Jednym ze sposobów uniknięcia tego problemu może być zastosowanie osobnej tabeli, w której byłyby śledzone poszczególne stadia „życia” zamówienia: od jego złożenia do realizacji. Jak zwykle należy przeanalizować zależności w kontekście wymogów biznesowych i wykorzystywać tylko te kolumny tabel pomocniczych, które są niezbędne do pomyślnej realizacji celów biznesowych systemu.
PLANY STRATEGICZNE
35
Czasem bywa tak, że kilka różnych atrybutów boolowskich można z powodzeniem połączyć w jeden atrybut typu status. Jeśli na przykład mamy cztery cechy o wartościach prawda lub fałsz, można zastosować bitmapę, co da wartość od 0 do 15 odpowiadającą dowolnym kombinacjom tych cech. Należy jednak zachować czujność: tego typu podejście z technicznego punktu widzenia stanowi podważenie reguły atomowości atrybutów tabeli — trzeba więc rozważyć potencjalne konsekwencje. Przechowywanie danych z myślą jedynie o danych to najkrótsza droga do katastrofy.
Rozumienie podtypów Kolejnym powodem tworzenia nadmiernie rozbudowanych tabel (to znaczy zawierających za dużą liczbę atrybutów) jest brak zrozumienia rzeczywistych związków między elementami danych. Weźmy na przykład podtypy. Firma może zatrudniać różnych pracowników, niektórzy z nich są zatrudnieni na stałe, inni na kontrakty. Wszyscy pracownicy mają pewne wspólne atrybuty (nazwisko i imię, data urodzenia, departament, numer pokoju, numer telefonu itd.), lecz istnieją takie atrybuty, które są unikalne dla pewnych grup pracowników (na przykład data zatrudnienia i płaca w przypadku pracowników stałych oraz końcowa data kontraktu w przypadku pracowników najemnych). To, w jaki sposób współdzieli się jedne atrybuty, pozostawiając inne niezależnymi, jest właśnie zagadnieniem podtypów. Sytuację tę można zaimplementować, odpowiednio definiując tabele. Po pierwsze w tabeli pracowników wpisywane są wszystkie informacje wspólne dla wszystkich pracowników, niezależnie od ich statusu. Jeden z atrybutów tej tabeli służy właśnie do definiowania statusu pracownika. Za pomocą odpowiednich kodów określa się, czy pracownik jest zatrudniony na stałe, na przykład „P” (permanent), czy pracuje na kontrakcie, czyli „C” (contract). W tej tabeli w charakterze klucza głównego stosuje się identyfikator pracownika. Następnie tworzymy dodatkowe tabele, po jednej dla każdego podtypu pracowników. W naszym przykładzie będą to dwie tabele. Nazwijmy je permanent i contract. Dzięki temu każdy pracownik część swoich atrybutów
36
ROZDZIAŁ PIERWSZY
będzie dziedziczył po tabeli pracowników, a pozostałe po tabeli specyficznej dla swojego statusu. Przyjrzyjmy się jeszcze mechanizmowi tworzenia kluczy głównych między wieloma tabelami. Jest ono decydujące dla tworzenia związków między podtypami. Unikalnym kluczem w każdej z tabel podtypu może być unikalny klucz głównej tabeli typu, w naszym przypadku tabeli pracowników, czyli identyfikator pracownika. Unia kluczy głównych tabel podtypów ma taką samą zawartość jak zbiór kluczy głównych tabeli typu, a część wspólna między zbiorami kluczy głównych tabel podtypów jest zbiorem pustym, ponieważ pracownik w danym momencie należy do jednego i tylko jednego podtypu. Klucze główne podtypów są również kluczami obcymi do głównej tabeli typu (głównej tabeli pracowników). Należy mieć świadomość, że zastosowanie niezależnych kluczy głównych w tabeli podtypu to poważny błąd. W praktyce jednak można znaleźć mnóstwo przykładów popełniania tego typu karygodnych błędów. Należy również pamiętać, że podtypy to koncepcja odmienna od związków typu tabela główna-tabela szczegółów. Można je rozróżnić po bliższej analizie kluczy głównych. Tego typu rozgraniczenia mogą w pierwszej chwili sprawiać wrażenie akademickich (przypisuję przy tym określeniu „akademicki” nieco lekceważące znaczenie). Należy jednak zauważyć, że w przypadku zastosowania w tabeli podtypu klucza głównego niebędącego kluczem głównym tabeli typu, w efekcie końcowym uzyska się żałosną wręcz wydajność zapytań. Jedna z głównych zasad, których należy przestrzegać w celu osiągnięcia wysokiej wydajności bazy danych, jest przypisywana Filipowi II Macedońskiemu, ojcu Aleksandra Wielkiego. Zasada ta brzmi: dziel i rządź. Całkiem prawdopodobne jest, że większość zapytań wykorzystywanych w Departamencie Kadr należy do jednej z dwóch kategorii: zapytania ogólne generujące listę pracowników zatrudnionych w firmie oraz zapytania szczegółowe w celu wydobycia informacji o konkretnym pracowniku. W obydwu przypadkach, o ile prawidłowo zostały zastosowane podtypy3, 3
Podtypy można bowiem zastosować w nieprawidłowy sposób. Jak zauważył jeden z redaktorów, przygotowanie jednej, „uniwersalnej” tabeli głównej, do której będą odwoływać się najbardziej podstawowe zapytania, nie jest sposobem na uzyskanie wydajności modelu. Podtypy muszą być budowane w oparciu o logiczne rozróżnienie, nie zaś z powodu błędnie pojmowanej chęci zastosowania silnej struktury dziedziczenia zainspirowanej technikami programowania zorientowanego obiektowo.
PLANY STRATEGICZNE
37
zapytanie będzie musiało przeanalizować jedynie te dane, które mają znaczenie dla wyniku, i nie będzie musiało zajmować się informacjami niezwiązanymi z zagadnieniem. Gdyby wszystkie informacje o pracownikach umieścić w pojedynczej tabeli, zapytanie większość pracy poświęciłoby na odsiewanie znacznie większej ilości informacji, które będą bezużyteczne dla wyniku. Występowanie w bazie tabel, w których większość kolumn zawiera NULL-e, zdradza potrzebę zastosowania podtypów.
Stwierdzanie oczywistości Sytuacja, w której dane są obciążone dodatkowymi, biznesowymi ograniczeniami zawsze jest niekorzystna dla modelu. Na przykład stwierdzenie typu: „Jeśli linia biznesowa to X, identyfikator musi być liczbowy (mimo tego że ogólna reguła mówi o tym, iż identyfikatory są ciągami znaków o określonej długości maksymalnej)” albo „Jeśli model ma nazwę T, to kolor musi być czarny”. Czasem takie informacje ogólne pozwalają zastosować bardzo efektywne mechanizmy filtrowania danych. Jednak w ujęciu ogólnym, jeśli wynikają z wiedzy użytkowników, a nie są zaimplementowane w systemie zarządzania bazą danych, nie mogą być wykorzystane w optymalizacji zapytań, co mogłoby wpłynąć na wydajniejsze działanie bazy danych. W najgorszym razie niejawne ograniczenia mogą doprowadzić do błędów wykonawczych. Na przykład można doprowadzić do sytuacji, gdy mechanizm bazy danych spróbuje wykonać obliczenia matematyczne na danych tekstowych. Taka sytuacja może zdarzyć się w przypadku, gdy kolumna typu tekstowego jest zastosowana wyłącznie do zapisywania danych liczbowych i nieopatrznie wkradnie się tu jakiś znak nieliczbowy. Na marginesie: przykład, w którym identyfikator tekstowy czasem zawiera dane znakowe, a czasem liczbowe, ilustruje sytuację, gdy źle opracowana została definicja dziedzin danych już na etapie projektowania bazy. Oczywiste jest, że natura danych w takim atrybucie jest różna w zależności od sytuacji, co jest absolutnie niedopuszczalne w prawidłowo zaprojektowanych bazach danych. Jeśli potrzebujemy zapisać na przykład parametry konfiguracyjne o różnych typach danych (liczbowe, boolowskie, znakowe itp.), nie należy
38
ROZDZIAŁ PIERWSZY
zapisywać ich w prostej tabeli configuration(parameter_name, parameter_value), gdzie kolumna parameter_value jest typu tekstowego z zakodowanymi odpowiednimi wartościami. Gdyby zamiast tego zastosować tabelę configuration_numeric(parameter_id, parameter_value), gdzie parameter_value jest typu liczbowego, błąd w postaci litery O zamiast liczby zero będzie wykryty przez mechanizm bazy danych już na etapie wprowadzania; nie dopuści się w ten sposób do powstania błędów wykonawczych podczas odczytu danych. W bazie danych należy zdefiniować wszystkie możliwe ograniczenia. Jednym z nich są klucze główne, podstawowa koncepcja relacyjnych baz danych. Jeśli to konieczne, należy dodatkowo stosować klucze pomocnicze, o ile pozwalają scharakteryzować dane i typy unikalnych ograniczeń. Klucze obce, które zapewniają, że dane z tabel są spójne w ramach odwzorowań na tabele główne, są również bardzo ważnym zagadnieniem nadającym danym znaczenie w ramach modelu. Nie mniej wartościowe są ograniczenia służące kontroli zakresu wartości danych. Ograniczenia odgrywają w bazach danych przede wszystkim następujące funkcje: • Pomagają zachować spójność danych, gwarantując, że dane będą spójne w zakresie definiowanym przez regułę ograniczenia. • Dostarczają mechanizmowi bazy danych ważnych informacji na temat danych, które są szczególnie cenne dla optymalizatora. Mimo tego że współczesne optymalizatory nie zawsze potrafią wykorzystać wszystkie informacje na temat danych, istnieje szansa, iż przyszłe generacje systemów baz danych będą w stanie skorzystać z tych informacji na potrzeby zaawansowanych mechanizmów przetwarzania. Przedstawiony we wcześniejszej części tego rozdziału przykład adresów dostawy i na fakturę stanowi doskonałą ilustrację sytuacji, gdy informacje semantyczne są tracone w wyniku fundamentalnego błędu w projekcie bazy danych. Kluczowe informacje semantyczne muszą w takim przypadku zostać zaimplementowane w aplikacjach klienckich, których liczbę trudno przewidzieć na etapie projektowania bazy danych. „Jeśli adres na fakturę jest NULL, należy wykorzystać adres główny”, to reguła, która nie jest znana bazie danych, i dlatego musi być obsłużona w programach. Celowo zastosowałem tu liczbę mnogą. Dlatego warto jak najwięcej ograniczeń definiować w bazie danych, ponieważ wtedy są definiowane tylko raz, co
PLANY STRATEGICZNE
39
gwarantuje, że żaden program nie będzie wykorzystywał danych w sposób niespójny. W aplikacjach muszą natomiast być zdefiniowane niejawne reguły dotyczące na przykład priorytetu adresów. Reguły niejawne mogą być zupełnie dowolne, nie ma bowiem możliwości stwierdzenia ponad wszelką wątpliwość, że w niektórych sytuacjach w przypadku braku adresu na fakturę nie będzie wykorzystany adres dostawy zamiast adresu głównego. Semantyka danych należy do systemu zarządzania bazami danych, nie do aplikacji.
Zagrożenia wynikające z nadmiaru elastyczności Jak to zwykle bywa, gdy człowiek stara się optymalizować zagadnienie do granic możliwości (a czasem nawet poza nie), jego dzieło staje się pomnikiem ludzkiego szaleństwa. Jedną z tego typu konstrukcji jest koncepcja struktury danych „elastycznej do bólu”, pozwalającej zapisać prawie dowolne dane w pojedynczej tabeli: (entity_id, attribute_id, attribute_value). W tym „projekcie” dane są zapisywane w postaci ciągów znaków w ramach atrybutu attribute_value. Taka konstrukcja z pewnością pozwala uniknąć występowania NULL-i. Jednak zwolennicy tego podejścia z reguły idą w przesadzie jeszcze dalej i zapisują w tabelach wszystkie atrybuty, również te, które nie mają zdefiniowanych wartości. Swoją decyzję motywują tym, że tego typu projekt pozwala z łatwością dodawać do bazy danych nowe atrybuty. Pominę milczeniem jakość projektu aplikacji, który zakłada konieczność niekontrolowanego rozbudowywania listy atrybutów, a skupię się na uwagach dotyczących danych. Oczywiście wygodnie jest mieć projekt bazy, który ułatwia wprowadzanie danych, lecz z reguły, pewnego dnia, nieoczekiwanie, okazuje się, że dane gromadzone tak pieczołowicie będą musiały zostać odczytane (jeśli odczyt danych z bazy nie jest założoną jej funkcją, mamy do czynienia z poważnym problemem koncepcyjnym). Dodanie nowej kolumny do tabeli w „klasycznym” projekcie bazy danych to nic w porównaniu z implementacją obsługi wszystkich nowych typów danych, które nagle pojawiają się w tym „najbardziej elastycznym z elastycznych” modelu danych (zwolennicy elastyczności języka XML wiedzą, co mam na myśli).
40
ROZDZIAŁ PIERWSZY
Koszty tego typu pseudoelastyczności są niebotyczne. Integralność bazy danych właściwie nie istnieje, a to z powodu zaniechania kontroli typów zapisywanych danych. Nie ma mowy o jakiejkolwiek integralności odwołań (bo ich nie ma). Nie ma możliwości zdefiniowania deklaratywnych ograniczeń. Najprostsze zapytanie staje się monstrualnym złączeniem, w którym „tabela wartości” jest łączona dziesięć, piętnaście, dwadzieścia razy sama ze sobą, w zależności od liczby odczytywanych atrybutów. Jak się łatwo domyślić, nawet najinteligentniejszy optymalizator nie poradzi sobie z takim zapytaniem, a jego wydajność będzie, jak nietrudno odgadnąć, żałosna. Wydajność zapytań tego typu można nieco poprawić za pomocą technik opisanych w rozdziale 11., ale kod SQL takiego zapytania nie stanowi pięknego widoku. W porównaniu z tą sytuacją nawet najbardziej nieudana kampania wojenna w historii wydaje się przykładem sprawnego planowania strategicznego. Elastyczność w ramach projektowania wynika bezpośrednio ze skutecznych praktyk modelowania danych.
Problemy z danymi historycznymi Praca z danymi historycznymi to dość powszechna sytuacja — proces ewaluacji, czyli określania ceny towarów lub usług w określonym punkcie czasu, jest oparty właśnie o dane historyczne. Jeden z najtrudniejszych problemów relacyjnego modelu danych wiąże się właśnie z obsługą danych związanych z określonym punktem czasu. Istnieje kilka metod modelowania danych historycznych. Załóżmy na przykład, że chcemy zapisać ceny towarów w punkcie czasu. Oczywisty sposób narzuca się od razu: (article_id, effective_from_date, price)
Atrybut effective_from_date określa początek okresu obowiązywania ceny, klucz główny tej tabeli to (articleid, effective_from_date). Choć poprawny logicznie, taki model danych jest dość niewygodny w użyciu w przypadku pracy z danymi bieżącymi, co wszak w większości przypadków jest głównym zadaniem bazy danych. W jaki sposób określić
PLANY STRATEGICZNE
41
bieżącą wartość? To będzie wartość price o największej wartości atrybutu effective_from_date. Można ją wydobyć na przykład w następujący sposób: select a.article_name, h.price from articles a, price_history h where a.article_name = nazwa and h.artlcle_id = a.article_id and h.effective_from_date = (select max(b.effective_from_date) from price_history b where b.article_id = h.artide_id)
Wywołanie tego zapytania wiąże się z dwukrotnym przeglądem tych samych danych: raz w celu identyfikacji najświeższej daty dla danego artykułu, drugi raz (w zewnętrznym zapytaniu) w celu odczytu ceny z wiersza znalezionego w pierwszym zapytaniu. W rozdziale 6. można znaleźć przykłady wykorzystania specjalnych funkcji SQL-a, przy użyciu których w wielu przypadkach można uniknąć konieczności wielokrotnego przeglądania tych samych danych. Wywołanie wielu zapytań w postaci takiej, jak powyższe, może wiązać się z dużym kosztem czasowym. Wybór sposobu definiowania zakresów jest jednak dość dowolny. Zamiast zapisywać datę początku okresu, można więc zapisywać datę końca (to znaczy datę, gdy cena przestaje obowiązywać), w ten sposób definiując zakresy w oparciu o ich górne, a nie dolne ograniczenie. To nowe podejście może wydać się dość interesującą alternatywą. Mamy tu bowiem dwa sposoby określania wartości bieżących: poprzez odczytywanie cen, w których daty końca terminu ważności są niezdefiniowane (czyli NULL, co na „drugi rzut oka” nie wydaje się już aż tak dobrym pomysłem), lub wstawianie z góry ustalonej daty z dalekiej przyszłości, na przykład 31 grudnia 3000. Oczywiście w celu odszukania bieżącej ceny wystarczy w takim przypadku poszukać ceny z datą ważności 31 grudnia 3000. Proste zapytanie, trafiające w odpowiednie dane w pojedynczym przebiegu. Czy to rozwiązanie idealne? Nie do końca. Istnieją pewne obawy co do efektywności działania zapytania tego typu z punktu widzenia optymalizatora, o czym szerzej opowiem w rozdziale 6. Co ważniejsze, mamy tu również poważny problem logiczny. Ceny, jak wie z pewnością większość konsumentów, rzadko pozostają niezmienne przez dłuższy okres, a ich zmiany również nie następują w dniu
42
ROZDZIAŁ PIERWSZY
ich obowiązywania, to znaczy decyzja o zmianie ceny może nastąpić z wyprzedzeniem (w środowiskach finansowych sytuacja może być jeszcze inna). Co się stanie, jeśli w październiku zapadnie decyzja o zmianie ceny produktu, która będzie obowiązywać od nowego roku, co powinno wiązać się z odpowiednimi wpisami w bazie danych? W takiej sytuacji w naszej bazie będziemy mieli dwa wpisy: jeden dotyczący bieżącej ceny o dacie ważności do 31 grudnia bieżącego roku oraz drugi z nową ceną, ważną w przyszłości. Gdy zdecydujemy się wykorzystywać datę początku okresu ważności, jedna z tych pozycji będzie miała atrybut effective_from_date o wartości wskazującej datę z przeszłości (cena bieżąca), druga z datą w przyszłości (na przykład 1 stycznia). W rezultacie cenę bieżącą będziemy musieli określać nie w oparciu o najwyższą datę, lecz o najwyższą datę mniejszą od daty bieżącej (w systemie Oracle do odczytu daty bieżącej służy funkcja sysdate()). Zaprezentowane wyżej zapytanie należy zmodyfikować, ale dość nieznacznie: select a.article_name, h.price from articles a, price_history h where a.articlejname = nazwa and h.article_id = a.article_id and h.effective_from_date = (select max(b.effective_from_date) from price_history b where b.article_id = h.article_id and b.effective_from_date <= sysdate)
Jeśli do identyfikacji okresu ważności ceny wykorzystamy datę końca, w naszej hipotetycznej sytuacji w bazie będziemy mieli dwa rekordy: jeden z datą 31 grudnia bieżącego roku, drugi z datą o wartości NULL lub z datą w dalekiej przyszłości. W celu odczytania ceny bieżącej musimy zatem odczytać cenę z najmniejszą datą większą od daty bieżącej, co niekoniecznie będzie intuicyjną modyfikacją zapytania zaprezentowanego powyżej. Jednym z potencjalnych rozwiązań jest również denormalizacja. Można wyobrazić sobie, że zapisujemy obie daty: początku obowiązywania ceny oraz jego końca. Inne podejście mogłoby polegać na zapisywaniu atrybutu effective_from_date oraz liczby dni obowiązywania ceny. W tym ostatnim modelu zamiast daty początku można również zastosować datę końca, jeśli to uprości konstruowanie naszych zapytań.
PLANY STRATEGICZNE
43
Denormalizacja zawsze jednak wnosi ryzyko utraty integralności danych. Drobna niekonsekwencja we wprowadzaniu dat może spowodować powstawanie „czarnych dziur”, w których nie będzie obowiązywała żadna cena. Można oczywiście starać się zminimalizować ryzyko, dodając do aplikacji mechanizmy kontrolujące w każdej operacji dodawania lub modyfikacji okresów, lecz takie mechanizmy zawsze wiążą się z pewną utratą wydajności. Inne rozwiązanie wykorzystuje dwie tabele: bieżącą i historyczną. W momencie wprowadzania nowej ceny w tabeli aktualnej, poprzednia wersja jest przenoszona do tabeli historycznej. Tego typu podejście może dobrze spisywać się w niektórych aplikacjach, lecz jest skomplikowane w utrzymaniu. Co więcej, nie ma tu możliwości wpisywania cen z wyprzedzeniem, jak w opisywanej wyżej sytuacji. W praktyce sprawdzają się niektóre techniki fizycznego uporządkowania danych, jak partycjonowanie, które zostaną omówione w rozdziale 5. Dzięki nim konstrukcje typu effective_from_date są mniej kosztowne w użyciu, szczególnie w przypadku przetwarzania większej liczby pozycji. Przed zaadaptowaniem jednego rozwiązania musimy przyjąć do wiadomości, że tabele cen mogą różnić się w różnych zastosowaniach. Na przykład w firmie telekomunikacyjnej tabela cen wykorzystywana w wielu milionach operacji jest dość niewielka i nie zmienia się często. Z drugiej strony w banku inwestycyjnym ceny różnych produktów finansowych i form zabezpieczeń mogą zmieniać się praktycznie na bieżąco. Doskonałe rozwiązanie w jednym przypadku może być mało dopasowane w innym. Obsługa szybko przyrastających, ulegających częstym modyfikacjom danych wymaga bardzo ostrożnego podejścia dostosowanego do tempa zmian.
Projektowanie z myślą o wydajności To dość krzepiące (choć czasem bywa przerażające), z jakim zaufaniem są przez niektórych programistów traktowani specjaliści od wydajności baz danych. Ale powtórzę to, co napisałem na wstępie książki: optymalizacja to praca prowadząca do tego, aby uzyskać jak najlepszą wydajność od razu. Tworząc aplikację, musimy przyjąć jak najbardziej optymalną postawę
44
ROZDZIAŁ PIERWSZY
i starać się unikać myślenia typu „najpierw muszę to napisać, a potem poprosi się specjalistę, żeby to zoptymalizował”. Koszt narzutu na tworzenie programu od razu na podstawie optymalnych założeń jest z bliski zeru, a w przypadku zapytań SQL również nie jest wielki pod warunkiem wyeliminowania poważnych błędów. Zagadnienie to ma dwa oblicza: • Optymalizacja to z jednej strony ulepszanie ogólnej wydajności systemu przez ustawianie parametrów stosownie do posiadanych zasobów: mocy procesora, pamięci operacyjnej i wydajności podsystemów wejścia-wyjścia, a czasem wykorzystania fizycznych możliwości implementacji systemu zarządzania bazami danych. To jest ściśle techniczne zadanie, które może wpłynąć na znaczne przyspieszenie niektórych operacji, ale przyspieszenie to rzadko przekracza 20 lub 30%, chyba że pierwotnie popełniono jakiś kardynalny błąd. • Druga strona optymalizacji polega na modyfikacji zapytań. Jest to forma ujawniająca, niestety, słabe punkty niektórych optymalizatorów zapytań, co często wynika ze zmian w optymalizatorze, zachodzących w wyniku zmiany wersji silnika zarządzania bazami danych. I to wszystko. W mojej opinii dodawanie indeksów do bazy danych nie należy do działań optymalizacyjnych (nawet w przypadku, gdy pewne działania optymalizacyjne ujawniły potrzebę wprowadzenia zmian w konfiguracji indeksów bazy danych). Większość indeksów należy zdefiniować już na etapie projektowania, a do rozstrzygania najbardziej wątpliwych przypadków służą testy wydajności. Wydajność nie jest jedynie kwestią przyspieszania kilku wybranych zapytań, tak samo jak wojna nie jest jedynie kwestią zwycięstwa w kilku bitwach. Można zwyciężyć w bitwie, ale przegrać wojnę. Można przyspieszyć zapytania, a jednak być skazanym na aplikację o żałosnej wydajności, której nikt nie chce używać. Bazy danych i aplikacje, jak również zapytania SQL, przede wszystkim muszą być prawidłowo zaprojektowane. Nie wystarczy jednak projekt prawidłowy od strony funkcjonalnej. Wydajność musi być w nim uwzględniana od początku. Optymalizacja bazy danych po zakończeniu etapu implementacji pozwala na uzyskanie zapasu w postaci tych kilku procent wydajności, co jest małym buforem bezpieczeństwa, nie zaś złotym środkiem na bolączki związane z wydajnością.
PLANY STRATEGICZNE
45
Najważniejszym czynnikiem wpływającym na słabą wydajność jest błędny projekt.
Przepływ przetwarzania Oprócz zagadnień omówionych w tym rozdziale na wydajność systemu może mieć wpływ również tryb wykonawczy. Przez pojęcie tryb wykonawczy rozumiem sposób przetwarzania danych: asynchroniczny (wykorzystywany najczęściej przy masowym, tzw. wsadowym, przetwarzaniu danych) czy synchroniczny (wykorzystywany w typowych operacjach transakcyjnych). Programy wsadowe historycznie są „przodkami” wszystkich mechanizmów związanych z przetwarzaniem danych. Nadal bywają powszechnie wykorzystywane, mimo tego że stały się dość niemodne. Przetwarzanie synchroniczne, wbrew pozorom, nie jest absolutnie konieczne. Jednak dzięki stałemu ulepszaniu sieci komputerowych i zwiększaniu ich wydajności udało się osiągnąć „globalny” zasięg coraz szerszej rzeszy aplikacji. W wyniku tego postępu całonocna przerwa w pracy serwera OLTP (online transaction processing) na zachodzie USA może stanowić problem dla oddziału korporacji w Azji Wschodniej (przez jedną część nocy) oraz w Europie (przez pozostałą jej część). Programy wsadowe nie mogą więc zakładać, że pracują w systemie, do którego w danej chwili nie jest przyłączony żaden użytkownik. Co więcej, ciągłe zwiększanie się rozmiarów zasobów danych może wpłynąć na konieczność ich przetwarzania na bieżąco — zamiast gromadzenia w wielkie i trudne w obróbce zbiory z myślą o zbiorczym przetworzeniu. Przetwarzanie takich dużych ilości danych może być łatwiejsze w trybie strumieniowym. Sposób przetwarzania danych nie jest zupełnie oderwany od sposobu ich postrzegania, szczególnie z punktu widzenia struktur fizycznych, o których wspomnę szerzej w rozdziale 5. Wykorzystując rozbudowane programy wsadowe, zainteresowani jesteśmy przepustowością: surową wydajnością, jak najbardziej intensywnym wykorzystaniem zasobów sprzętowych. W sferze przetwarzania danych zagadnienie to można przypisać do dziedziny rozwiązań siłowych. W przypadku przetwarzania danych na bieżąco większość operacji będzie wykorzystywała niewielkie zapytania
46
ROZDZIAŁ PIERWSZY
wielokrotnie wywoływane indywidualnie. W przypadku takich zapytań średnia wydajność to za mało, muszą one działać z maksymalną z dostępnych wydajności. W programach asynchronicznych łatwo jednak zauważyć, że zapytanie działa w sposób nieoptymalny (choć nie zawsze łatwo to poprawić): zadania po prostu wykonują się dłużej niż zazwyczaj. W przetwarzaniu synchronicznym sytuacja jest bardziej „subtelna”, ponieważ problemy z wydajnością z reguły ujawniają się w najmniej dogodnym momencie, to znaczy w chwilach wzmożonej aktywności. Jeśli nie uda się na czas rozpoznać słabości zastosowanego rozwiązania, system prawdopodobnie zawiedzie wówczas, gdy potrzeby biznesowe będą największe, czyli w najgorszym momencie. Model danych nie jest kompletny do czasu, gdy nie zostaną uwzględnione potrzeby wynikające z modelu przepływu danych.
Centralizacja danych Nie wdając się w szczegółową analizę siatek (ang. grid), klastrów i tym podobnych rozwiązań, można stwierdzić, że wszelkie rozwiązania pozwalające na rozproszenie obciążenia związanego z przetwarzaniem danych po prostu znacznie zwiększają poziom komplikacji systemów. A im bardziej skomplikowana jest struktura, tym mniej odporna na błędy. Przy tym postęp technologiczny stale „podnosi poprzeczkę” w dziedzinie akceptowalnego poziomu niezawodności. W osiemnastym wieku zegary wskazujące minuty były uznawane za znacznie bardziej zawodne od zegarów wskazujących tylko godziny, a jednocześnie za bardziej wiarygodne od zegarów wskazujących dzień tygodnia czy fazy księżyca. Warto zatem w swoich poczynaniach trzymać się tego, co jest absolutnie niezbędne. Wykorzystanie mechanizmów pozwalających na bezpośredni dostęp do zdalnych danych w sposób niewidoczny dla użytkownika to ryzyko dla wydajności i to z dwóch powodów. Po pierwsze, mimo tej „niewidoczności” praca na wielu warstwach modelu sieciowego wiąże się z poważnym kosztem wydajności. Aby się co do tego przekonać, wystarczy wykonać procedurę wstawiającą kilka tysięcy wierszy do bazy lokalnej oraz drugą, wstawiającą te same dane do zdalnej bazy danych albo nawet do bazy lokalnej, ale z użyciem łącza sieciowego (na przykład w bazie Oracle).
PLANY STRATEGICZNE
47
Można oczekiwać co najmniej pięciokrotnego spowolnienia, może jednak być znacznie gorzej, o czym szerzej piszę w rozdziale 8. Po drugie, łączenie danych z różnych źródeł to bardzo skomplikowane zadanie. Porównując dane ze źródła A z danymi ze źródła B, nie mamy innego wyjścia, jak po prostu skopiować dane ze źródła A do B albo odwrotnie. Transfer danych to tylko jeden z poważnych narzutów takiej konfiguracji. Dane wydobyte z własnego, skrupulatnie zoptymalizowanego środowiska będą pozbawione typowych dla niego zalet (przemyślanego układu danych, indeksów itp.). Zamiast tego trafią one do tymczasowego zasobu — jeśli ilość danych na to pozwala, na przykład do pamięci, w przeciwnym razie na dysk twardy. Zarządzanie zasobami tymczasowymi jest dodatkowym, poważnym narzutem. Jeśli najbardziej optymalnym sposobem dostępu do danych lokalnych są na przykład zagnieżdżone pętle, to w przypadku danych zdalnych optymalizator ma do dyspozycji dwie inne, o wiele mniej atrakcyjne opcje: • Wykorzystanie zagnieżdżonych pętli mimo znaczących narzutów związanych z przesyłaniem danych przy każdej iteracji. • Wczytanie całej porcji zdalnych danych i działanie z użyciem lokalnej kopii, co powoduje rezygnację ze zdalnych indeksów. W takich warunkach trudno mieć pretensję do optymalizatora, że nie działa w sposób efektywny. Jeśli chodzi o umiejscowienie podstawowych repozytoriów danych, należy po prostu zadbać o równowagę. Gdy firma jest rozproszona po całym świecie, utrzymanie wszystkich danych w jednej lokalizacji nie spotka się raczej z aprobatą pracowników zatrudnionych na antypodach. Odwiedzanie serwera WWW umieszczonego po drugiej stronie globu to zupełnie inne zagadnienie niż praca w aplikacji intensywnie korzystającej z bazy danych umieszczonej zdalnie. Nie chodzi tu wyłącznie o przepustowość, a raczej o prędkość światła, której nie da się zwiększyć mimo stałego postępu technologicznego. Niezależnie od tego, jakie działania są podejmowane, wywołanie zapytania na serwerze baz danych ulokowanym na innym kontynencie powoduje opóźnienie wynoszące ćwierć, a nawet pół sekundy i to w najlepszym razie. Jeśli potrzebujemy umożliwić odbiorcom z różnych stron świata dostęp do danych z bazy centralnej, należy wziąć pod uwagę techniki replikacji (zamiast zdalnego dostępu), czyli: każdemu z graczy dajemy osobną szachownicę i nie każemy każdemu z nich sięgać za daleko.
48
ROZDZIAŁ PIERWSZY
Im bliżej masz swoje dane, tym szybciej możesz do nich sięgnąć!
Komplikacja systemu Kolejnym zagadnieniem, które warto wziąć pod uwagę, są sytuacje awarii sprzętowej (na przykład uszkodzenie kontrolera) lub błędu ludzkiego (na przykład omyłkowego dwukrotnego wywołania programu wsadowego). Nawet w przypadku, gdy administratorzy są magikami, którzy potrafią w ciągu nocy doprowadzić do porządku system, aby o świcie wszystko działało jak poprzednio. Należy wziąć pod uwagę, że transfer danych ma swoje ograniczenia i odtworzenie kopii zapasowych po prostu trwa określony czas. Pomóc mogą bazy danych działające równolegle, zawierające dane synchronizowane w locie (lub z niewielkim opóźnieniem). Jednak zapasowe bazy danych niewiele pomogą, jeśli program modyfikujący dane został omyłkowo uruchomiony dwukrotnie, szczególnie w przypadku, gdy opóźnienie synchronizacji jest mniejsze od czasu działania programu. Zadania skomplikowane w przypadku zarządzania pojedynczą bazą danych stają się koszmarem w konfiguracji kilku replik, ponieważ przy odtwarzaniu danych musimy być zupełnie pewni, że dane we wszystkich replikach są dokładnie zsynchronizowane, aby uniknąć ryzyka uszkodzenia informacji. To zagadnienie przywracania danych jest kością niezgody między programistami a administratorami baz danych, ponieważ programiści mają tendencję do twierdzenia (zupełnie zgodnie z logiką), że wykonywanie i przywracanie kopii zapasowych baz danych to zadanie administratorów, podczas gdy administratorzy twierdzą (nie bez racji), że o ile są w stanie zapewnić, że system działa prawidłowo, to nie mogą wiele powiedzieć o tym, czy dane przez niego obsługiwane mają jakikolwiek sens. I rzeczywiście, sprawdzenie jakości danych po ich odtworzeniu z kopii to zadanie, które muszą wykonać programiści. Im bardziej skomplikowany jest cały projekt, tym ważniejsze jest, aby programiści brali pod uwagę warunki, w jakich działa system. Systemy baz danych to przedsięwzięcia, w których bierze udział wiele osób. Wymagają aktywnej i rzetelnej współpracy użytkowników, administratorów i programistów.
PLANY STRATEGICZNE
49
Plany są gotowe W tym rozdziale przeanalizowaliśmy podstawowe założenia związane ze sporządzaniem planów na potrzeby implementacji bazy danych. Omówiliśmy podstawy modelowania danych, a w szczególności etapy ich normalizacji do trzeciej postaci normalnej włącznie. Następnie rozważyliśmy kilka scenariuszy, w których już z góry można zidentyfikować błędny projekt i przewidzieć konsekwencje jego wdrożenia. Większość przykładów z tego rozdziału pochodzi z rzeczywistych sytuacji, z którymi miałem okazję się zetknąć w kilku wielkich firmach. I za każdym razem daję się zaskoczyć, gdy widzę, jak wiele energii i inteligencji potrzeba na zwalczanie problemów z wydajnością, które wynikły bezpośrednio z ignorowania podstawowych reguł projektowych. Tego typu problemów można bardzo łatwo uniknąć, jednak są one bardzo powszechne; co gorsza, w imię „usprawniania wydajności” często popełniane są kolejne błędy, jak choćby dalsza denormalizacja modelu, który już poprzednio pozostawiał wiele do życzenia. Może się bowiem okazać, że w wyniku takich działań jedno zapytanie rzeczywiście zostanie przyspieszone, za to wykonywane w nocy przetwarzanie wsadowe będzie trwało dwukrotnie dłużej. W ten sposób, zupełnie poza kontrolą, cały system informacyjny jest budowany jak wielki zamek na fundamentach z piasku. Skuteczne modelowanie danych jest wynikiem zdyscyplinowanego zastosowania podstawowych, prostych reguł projektowych.
50
ROZDZIAŁ PIERWSZY
ROZDZIAŁ DRUGI
Prowadzenie wojny Wydajne wykorzystanie baz danych Il existe un petit nombre de principes fondamentaux de la guerre, dont on ne saurait s’écarter sans danger, et dont l’application au contraire a été presque en tous temps couronnée par le succès. Istnieje niewielka liczba fundamentalnych zagadnień związanych z prowadzeniem wojny, których nie wolno lekceważyć: zaprawdę, stosowanie się do nich prawie niezawodnie prowadzi do sukcesu. — Generał Antoine-Henri de Jomini (1779 – 1869) Zarys sztuki wojennej
52
K
ROZDZIAŁ DRUGI
ażdy, kto był zaangażowany w proces przejścia projektu z fazy rozwoju w fazę produkcyjną, przyzna z pewnością, że prawie czuł tumult i wrzawę bitwy. Bardzo często zdarza się, że na kilka tygodni przed sądnym dniem przeprowadzone testy wydajności ujawniają smutny fakt: system nie będzie działał tak płynnie, jak to zakładano. Zaprasza się ekspertów, optymalizuje zapytania SQL, w kryzysowych burzach mózgów biorą udział administratorzy baz danych i systemów. W końcowym rozrachunku na sprzęcie dwukrotnie bardziej kosztownym osiąga się wydajność jedynie teoretycznie zbliżoną do zakładanej. Często w zastępstwie działań strategicznych stosuje się działania taktyczne. Strategia wymaga zastosowania architektury i modelu przystosowanych do wymagań projektu. Podstawowych zasad stosowanych w czasie wojny jest zaledwie kilka, ale zadziwiająco często bywają one ignorowane. Błędy w architekturze okazują się niezwykle kosztowne, a programista SQL-a musi wkraczać na pole bitwy dobrze przygotowany do walki, musi wiedzieć, gdzie chce dotrzeć i którą drogą. W tym rozdziale przeanalizujemy podstawowe cele zwiększające szanse na sukces w pisaniu programów w sposób efektywny wykorzystujących bazy danych.
Identyfikacja zapytań Przez stulecia jedynym sposobem, w jaki generał mógł śledzić losy bitwy, była obserwacja oddziałów na podstawie kolorów umundurowania i niesionych przez nich proporców. Gdy jakiś proces w środowisku bazy danych zużywa nadmierną ilość mocy procesora, często istnieje możliwość zidentyfikowania zapytania SQL odpowiedzialnego za to zadanie. Nierzadko jednak trudne bywa odkrycie tego, która część aplikacji wywołała problematyczne zapytanie, szczególnie w środowisku, w którym wykorzystywane są zapytania budowane w sposób dynamiczny. Mimo tego że wiele solidnych produktów wyposażonych jest w mechanizmy monitorujące, często zdumiewająco trudno jest znaleźć odniesienie między zapytaniem SQL a środowiskiem, w którym ono działa. Z tego powodu dobrze jest nabrać nawyku oznaczania programów i krytycznych modułów przez włączanie komentarzy w kodzie SQL w taki sposób, aby łatwo było zidentyfikować źródło kłopotów. Na przykład: /* REJESTRACJA KLIENTA */ select ...
PROWADZENIE WOJNY
53
Tego typu komentarze identyfikujące mogą być pomocne w śledzeniu błędnie działającego kodu. Mogą być również pomocne przy określaniu obciążenia serwera bazy danych powodowanego przez każdą korzystającą z niego aplikację, szczególnie w przypadku, gdy spodziewamy się, że wprowadzane zmiany mogą spowodować zwiększenie obciążenia, i musimy oszacować, czy posiadany sprzęt ma szansę sprostać większym wymaganiom. Niektóre produkty posiadają specjalizowane mechanizmy rejestrujące, które pozwalają uniknąć komentowania każdego wyrażenia. Na przykład pakiet dbms_application_info serwera Oracle pozwala oznakować program za pomocą 48-znakowej nazwy modułu, 32-znakowej nazwy akcji i 64-znakowego pola informacji o kliencie. Zawartość tych pól jest kontrolowana przez twórcę aplikacji. W środowisku Oracle można użyć tego pakietu do śledzenia tego, jaka aplikacja wykorzystuje bazę danych w danej chwili, jak również tego, co dana aplikacja robi. Do tego służą dynamiczne perspektywy V$, za pomocą których można śledzić stan pamięci. Identyfikowanie wyrażeń ułatwia śledzenie zależności obciążenia.
Trwałe połączenia do bazy danych Nowe połączenie do bazy danych tworzy się szybko i łatwo, lecz ta prostota czasem przesłania fakt, że szybkie, cykliczne wywołania połączeń wiążą się z konkretnym, niemałym kosztem. Dlatego połączenia z bazą danych warto traktować z rozwagą. Konsekwencje wywoływania wielu cyklicznych połączeń, być może ukrytych w ramach aplikacji, mogą być znaczące, co zademonstruje kolejny przykład. Jakiś czas temu trafiłem na aplikację, która przetwarzała dużą liczbę niewielkich plików tekstowych o rozmiarach do stu wierszy. Każdy wiersz zawierał dane oraz identyfikator bazy danych, do której dane te miały być załadowane. W tym przypadku był wykorzystywany tylko jeden serwer bazy danych, ale prezentowana zasada obowiązuje również w przypadku setek baz danych.
ROZDZIAŁ DRUGI
54
Przetwarzanie każdego z plików odbywało się zgodnie z następującą procedurą: Otwarcie pliku Aż do napotkania końca pliku Odczytaj wiersz Połącz się z serwerem zdefiniowanym w treści Wpisz dane Zamknij połączenie Zamknij plik
Opisany proces działał zadowalająco z wyjątkiem przypadków, gdy do załadowania trafiała duża liczba niewielkich plików w odstępach czasu przekraczających możliwość ich przetworzenia przez aplikację. To powodowało znaczny przyrost dziennika zaległości w bazie danych (ang. backlog), który następnie musiał być przetworzony przez bazę danych, co zajmowało dodatkowy czas. Użytkownikowi bazy danych wyjaśniłem przyczynę opóźnień, którą była nadmierna liczba otwieranych i zamykanych połączeń. Jako demonstrację problemu przygotowałem prosty program (napisany w C) emulujący aplikację oraz proponowane przeze mnie rozwiązania. Wyniki działania demonstracji przedstawia tabela 2.1. UWAGA Program generujący wyniki z tabeli 2.1 wykorzystywał proste instrukcje wstawiające wiersze do tabel. Klientowi wspomniałem również o technikach bezpośredniego ładowania danych do tabel, które działają jeszcze szybciej. TABELA 2.1. Wyniki testu nawiązywania i kończenia połączeń Test
Wynik
Nawiązanie i zakończenie połączenia dla każdego wstawianego wiersza z pliku
7,4 wiersza na sekundę
Jedno połączenie, każdy wiersz wstawiany indywidualnie
1681 wierszy na sekundę
Jedno połączenie, wiersze wstawiane w porcjach po 10
5914 wierszy na sekundę
Jedno połączenie, wiersze wstawiane w porcjach po 100
9190 wierszy na sekundę
Ta demonstracja pokazała, jak istotne jest, aby minimalizować liczbę osobnych połączeń z bazą danych. Z tego powodu kluczową rolę odgrywało tu proste sprawdzenie, czy kolejne wprowadzenie danych ma odbyć się do tej samej bazy, do której połączenie już zostało otwarte. Analizę można by
PROWADZENIE WOJNY
55
poprowadzić dalej, ponieważ liczba możliwych baz danych była oczywiście ograniczona. Teoretycznie można było osiągnąć dalszy zysk wydajności, wykorzystując tablicę uchwytów połączeń, po jednym dla każdej z możliwych baz danych, i nawiązując połączenie dopiero wtedy, gdy będzie to konieczne. W ten sposób wykorzystywanych byłoby maksymalnie tyle uchwytów, ile istnieje baz danych. Jak widać w tabeli 2.1, prosta technika pojedynczego połączenia (lub minimalizacji liczby nawiązywanych połączeń) usprawnia wydajność do dwustu razy. I to wszystko dzięki niewielkiej modyfikacji kodu. Oczywiście przy tej okazji mogłem zademonstrować również znaczące korzyści wynikające z ograniczenia liczby wywołań między aplikacją a bazą danych, wykorzystując ładowanie danych za pomocą tablic. Przez wstawianie wielu wierszy w pojedynczej operacji całe zadanie można przyspieszyć jeszcze pięciokrotnie. Wyniki z tabeli 2.1 pokazują przyspieszenie o tysiąc dwieście razy w stosunku do pierwotnej wydajności operacji. Skąd bierze się tak znaczne przyspieszenie? Przyczyną pierwszego przyspieszenia jest fakt, że nawiązanie połączenia z bazą danych jest operacją „ciężką”, to znaczy wykorzystującą dużo zasobów systemowych. We wciąż popularnym środowisku klient-serwer nawiązanie połączenia to prosta operacja, co powoduje, że niewiele osób ma świadomość kryjącego się za nią procesu. W pierwszym etapie klient musi nawiązać połączenie z modułem nasłuchującym, wchodzącym w skład serwera, po czym moduł nasłuchujący uruchamia osobny proces lub wątek serwera bazy danych albo przekazuje (bezpośrednio lub pośrednio) żądanie do istniejącego, oczekującego procesu serwera. Niezależnie od liczby operacji (wywoływania nowych procesów lub wątków i wywoływania obsługi zapytania) system bazy danych musi utworzyć nowe środowisko dla każdej sesji, dzięki czemu będzie ona mogła śledzić realizowane w niej zadania. System obsługi bazy danych musi sprawdzić poprawność hasła dla konta, za pomocą którego zostało nawiązane połączenie i utworzona nowa sesja. System obsługi bazy danych często musi również wykonać kod związany z procedurą zalogowania do bazy danych (zaimplementowany w postaci wyzwalacza). To samo dotyczy również kodu inicjalizacyjnego niezbędnego do działania procedur osadzonych i pakietów. Na tym tle standardowy
56
ROZDZIAŁ DRUGI
protokół nawiązania połączenia między klientem a serwerem ma niewielki wpływ na powstałe opóźnienia. Z powodu tej „zasobożerności” procesu nawiązania połączenia tak wielkie znaczenie z punktu widzenia wydajności mają rozmaite techniki pozwalające na utrzymanie raz nawiązanego połączenia z bazą danych, jak pule połączeń (ang. connection pooling). Druga przyczyna przyspieszenia wiąże się ze zmniejszeniem liczby cykli przesyłania danych między aplikacją a bazą danych (tzw. round-trips), co, jak widać, również ma niebanalny udział w czasochłonności operacji. Nawet w przypadku, gdy zostało nawiązane tylko jedno połączenie i jest ono wykorzystywane do realizacji wszystkich kolejnych operacji, przełączanie kontekstu między programem a jądrem systemu obsługi bazy danych również ma wpływ na dalsze opóźnienia. Jeśli zatem system obsługi bazy danych umożliwia wykorzystanie jakiegoś mechanizmu ładowania danych większymi porcjami (jak na przykład tablice), warto wziąć pod uwagę jego użycie. W rozwiązaniach, które wykorzystują rzeczywiste tablice, warto sprawdzić, jaki jest domyślny rozmiar tablicy, i dostosować go do potrzeb aplikacji. Każdy rodzaj operacji wykorzystujących przetwarzanie pojedynczymi wierszami wiąże się oczywiście z analogicznymi konsekwencjami, na co będziemy mieli sporo dowodów w tym rozdziale. Połączenia z bazami danych i przełączenia kontekstu są jak Chińskie Mury — im ich więcej, tym dłużej trwa przekazanie wiadomości.
Strategia przed taktyką To strategia definiuje taktykę, nie odwrotnie. Dobry programista nie postrzega procesu w kategoriach drobnych kroczków, lecz z perspektywy ostatecznego wyniku. Najefektywniejszy sposób uzyskania wyniku nie musi wynikać z procesów biznesowych, często sprawdza się mniej bezpośrednie podejście. Następny przykład pokaże, w jaki sposób nadmierne skupienie się na procesach proceduralnych może odwrócić uwagę od najbardziej efektywnych rozwiązań.
PROWADZENIE WOJNY
57
Kilka lat temu otrzymałem zapytanie z prośbą, abym spróbował je zoptymalizować. „Spróbował” to było słowo kluczowe tego kontraktu. Wcześniej odbyły się dwa podejścia do tej optymalizacji, pierwsze podejmowane przez autorów, drugie przez eksperta Oracle. Mimo tych prób każde wywołanie tego zapytania trwało około dwudziestu minut, co zdaniem autorów było nie do przyjęcia. Celem tej procedury było wyliczanie ilości materiałów zamawianych przez centralną jednostkę fabryki. Obliczenia wykorzystywały istniejące zapasy i zamówienia pochodzące z różnych źródeł. Dane były odczytywane z kilku identycznych tabel, a następnie agregowane w jednej tabeli zbiorczej. Procedura składała się z sekwencji wyrażeń o następującej filozofii działania: najpierw dane ze wszystkich tabel były kopiowane do tabeli zbiorczej, następnie wywoływane było zapytanie agregujące dane dotyczące materiałów, następnie z tabeli były usuwane nadmiarowe dane niemające znaczenia dla procedury. Ta sekwencja była powtarzana dla każdej tabeli źródłowej. Żadne z zapytań SQL nie było szczególnie skomplikowane, żadnego z nich z osobna nie można też było określić jako szczególnie nieefektywnego. Większą część dnia zajęło mi zrozumienie procesu, co w końcu zaowocowało postawieniem pytania: „Dlaczego procedurę wykonywano w kilku etapach?”. Wystarczyłoby podzapytanie z operatorem unii (UNION), za pomocą którego można połączyć wszystkie wyniki z tabel źródłowych. Następnie pojedyncza instrukcja SELECT pozwoliłaby za jednym zamachem zapełnić tabelę wynikową. Różnica w wydajności była imponująca: z dwudziestu minut czas wykonania zadania skrócił się do dwudziestu sekund. Skuteczność modyfikacji była tak zdumiewająca, że sporą część czasu zajęła mi weryfikacja, czy aby nowa procedura generuje dokładnie takie same wyniki jak poprzednia. Nie były tu potrzebne nadzwyczajne umiejętności, wystarczyła umiejętność myślenia poza schematami. Poprzednie próby optymalizacji procesu zakończyły się niepowodzeniem, ponieważ były za bardzo zbliżone do sedna problemu. Często warto zdobyć się na świeże spojrzenie, zrobić krok wstecz, aby poszerzyć perspektywę. Warto było zadać dwa pytania: „Co mamy dostępnego przed wykonaniem procedury?” oraz „Jakie wyniki chcemy uzyskać z procedury?”. W połączeniu ze świeżym spojrzeniem odpowiedzi na te pytania doprowadziły do tak wielkiego udoskonalenia procesu.
58
ROZDZIAŁ DRUGI
Spójrz na problem z szerszej perspektywy, zanim zagłębisz się w drobniejsze szczegóły rozwiązania.
Najpierw definicja problemu, potem jego rozwiązanie Brak wiedzy może być niebezpieczny. Często zdarza się, że ludzie słyszeli lub czytali o nowych czy nietypowych technikach, w niektórych przypadkach nawet dość ciekawych, które natychmiast chcieli wdrożyć w ramach rozwiązywania swoich problemów. Programiści i architekci systemów nader często zachwycają się tego typu „rozwiązaniami”, które w gruncie rzeczy jedynie przyczyniają się do powstawania nowych problemów. Na szczycie listy „gotowych rozwiązań” z reguły można znaleźć denormalizację. Nieświadomi praktycznych zagrożeń związanych z modyfikacją nadmiarowych danych zwolennicy denormalizacji często sugerują ją jako pierwsze podejście przy „optymalizacji” wydajności. Niestety, zdarza się to często na etapie rozwoju aplikacji, gdy jeszcze nie jest za późno na zmiany projektu (lub przynajmniej naukę konstruowania wydajnych złączeń tabel). Szczególnie popularnym panaceum na problemy wydaje się specjalna forma denormalizacji, jaką jest zmaterializowana perspektywa. Zmaterializowane perspektywy czasem określa się nazwą migawek (ang. snapshot). Jest to mniej spektakularna nazwa, ale za to bardziej zbliżona do nagiej prawdy o tym zjawisku: chodzi bowiem ni mniej, ni więcej o kopie danych wykonania w określonym punkcie czasu. Nie chcę tu sugerować, że nigdy nie zdarza się, że od czasu do czasu, przyparty do muru, nie jestem zmuszony do zastosowania kontrowersyjnych technik. Jak stwierdził Franz Kafka: „Logiki nie da się podważyć, ale nie ma ona szans w obliczu człowieka, który po prostu próbuje przetrwać”. Jednak przytłaczającą większość problemów daje się rozwiązać dzięki inteligentnemu zastosowaniu dość tradycyjnych technik. Warto nauczyć się wyciągać wszystko, co dobre, z takich prostych rozwiązań. Gdy już się je opanuje, człowiek uczy się doceniać ich ograniczenia. Dopiero potem można próbować osądzić potencjalne zalety (o ile istnieją) nowych rozwiązań technicznych.
PROWADZENIE WOJNY
59
Wszystkie technologiczne rozwiązania są zaledwie środkiem do osiągnięcia celu. Wielkim zagrożeniem dla niedoświadczonego programisty jest pociąg do najnowszej technologii, który szybko zamienia się w cel dla samego siebie. A zagrożenie jest tym większe w przypadku osób ciekawych, obdarzonych dużym entuzjazmem, o technicznym zacięciu. Fundamenty są ważniejsze od mody: naucz się podstaw fachu, zanim zaczniesz bawić się nowinkami techniki.
Stabilny schemat bazy danych Wykorzystanie języka DDL (ang. data definition language) do tworzenia, modyfikowania czy usuwania obiektów bazy danych jest bardzo naganną praktyką, która powinna zostać oficjalnie zakazana. Nie ma powodu, aby dynamicznie tworzyć, modyfikować i usuwać obiekty bazy. Z wyjątkiem partycji, o czym wspomnę w rozdziale 5., oraz tabel tymczasowych, z zaznaczeniem, że mają być zadeklarowane jako tymczasowe w systemie zarządzania bazami danych (istnieje jeszcze kilka ważnych wyjątków od tej reguły, o czym wspomnę w rozdziale 10.). Zastosowania języka DDL wykorzystują podstawowy słownik danych bazy. Ten słownik jest również centralnym obiektem wszystkich operacji w bazie danych, a wykorzystanie go prowadzi do wywoływania globalnych blokad, które powodują znaczne konsekwencje w przypadku wydajności bazy. Jedyną dopuszczalną operacją DDL jest przycinanie tabeli (TRUNCATE), które jest bardzo szybką metodą usuwania danych z tabeli (ale należy pamiętać, że w przypadku tej operacji nie mamy możliwości jej wycofania za pomocą instrukcji ROLLBACK!). Tworzenie, modyfikacja i usuwanie obiektów baz danych to zadania etapu projektowego, a nie codzienne operacje wykonywane przez aplikację kliencką.
60
ROZDZIAŁ DRUGI
Operacje na rzeczywistych danych Wielu programistów chętnie posługuje się tymczasowymi tablicami roboczymi, do których na przykład ładują dane do dalszego przetwarzania. Tego typu podejście z reguły uznaje się za niewłaściwe, ponieważ może ono prowadzić do zamknięcia percepcji w zakresie procesów biznesowych i uniemożliwić szersze spojrzenie. Należy pamiętać, że tabele tymczasowe nie dają możliwości zastosowania pewnych zaawansowanych technik dostępnych w przypadku rzeczywistych tabel (część z tego typu opcji omówię w rozdziale 5.). Indeksowanie tabel tymczasowych (o ile w ogóle jest dostępne) może na przykład być mniej optymalne. W wyniku tych ograniczeń zapytania wykorzystujące tabele tymczasowe mogą działać mniej wydajnie od prawidłowo napisanych zapytań na rzeczywistych tabelach. Wadą zastosowania tabel tymczasowych jest ponadto konieczność wykonania dodatkowego zapytania wypełniającego tabelę tymczasową danymi. Nawet w przypadku, gdy zastosowanie tabel tymczasowych jest uzasadnione, nie należy nigdy wykorzystywać w tym charakterze rzeczywistych tabel udających tabele tymczasowe, szczególnie gdy liczba zapisywanych w nich wierszy jest duża. Jeden z problemów leży tu w mechanizmie gromadzenia statystyk: jeśli statystyki dotyczące tabel nie są gromadzone w czasie rzeczywistym, to system zarządzania bazą danych wykorzystuje do tego chwile mniejszego obciążenia. Natura tabel roboczych polega na tym, że z reguły w takich przestojach bywają puste, co powoduje, że optymalizator uzyskuje zupełnie błędne informacje. W efekcie system podejmuje błędne decyzje w wyniku nieodpowiednich planów wykonawczych przygotowywanych przez optymalizator zapytań, co prowadzi bezpośrednio do obniżonej wydajności. Jeśli ktoś jest naprawdę zmuszony do użycia tabel tymczasowych, powinien używać do tego celu tabel, które system zarządzania bazami danych jest w stanie zidentyfikować jako tymczasowe. Wykorzystanie tabel tymczasowych oznacza przepychanie danych do mniej optymalnego zasobu.
PROWADZENIE WOJNY
61
Przetwarzanie zbiorów w SQL-u SQL przetwarza dane w postaci zbiorów. W przypadku operacji modyfikacji (UPDATE) lub usuwania danych (pod warunkiem, że te modyfikacje nie są dokonywane na całych tabelach) użytkownik musi zdefiniować zbiór wierszy, których dana modyfikacja dotyczy. W ten sposób definiuje się pewną granularność procesu, który można określić jako zgrubną, jeśli zmiany dotyczą większej liczby wierszy, lub drobną, gdy przetwarzamy jedynie kilka wierszy. Każda próba modyfikacji dużej liczby wierszy tabeli, ale małymi porcjami jest z reguły złym pomysłem i zwykle okazuje się bardzo niewydajna. Takie podejście jest dopuszczalne jedynie w przypadku, gdy na bazie danych będą dokonywane bardzo rozległe zmiany, które na czas transakcji mogą spowodować tymczasowe zajęcie bardzo dużej ilości zasobów oraz zajmują bardzo dużo czasu w przypadku wycofania transakcji (ROLLBACK). Istnieją również opinie, że w przypadku modyfikacji dużych porcji danych należy w ramach kodu DML (ang. data manipulation code) umieszczać co jakiś czas instrukcje zatwierdzające zmiany (COMMIT). Tego typu podejście może jednak nie sprawdzić się w przypadku, gdy dane są ładowane z pliku i procedura zostanie przerwana w trakcie. Z czysto praktycznego punktu widzenia często o wiele prościej i szybciej jest wznowić proces od początku, niż próbować zlokalizować miejsce w danych wejściowych, do którego zostały już załadowane, i od niego wznawiać proces. Natomiast biorąc pod uwagę rozmiar loga transakcji (ang. transaction log) wykorzystywanego do wycofania zmian transakcji, również istnieją opinie, że fizyczna implementacja bazy danych powinna być przygotowana do przyjmowania zmian wykonywanych przez aplikację, a aplikacja nie powinna być zmuszona do omijania ograniczeń fizycznej implementacji. Jeśli wymagana ilość zasobów niezbędnych do zapisania loga transakcji jest rzeczywiście bardzo duża, zapewne należy zastanowić się, czy częstotliwość dokonywania tego typu zmian jest odpowiednia do konstrukcji bazy. Może się bowiem okazać, że zmiana strategii z miesięcznych, gigantycznych aktualizacji danych na kilkakrotnie mniejsze, tygodniowe, albo zupełnie niewielkie, dzienne modyfikacje spowoduje, że problem zupełnie przestanie istnieć.
62
ROZDZIAŁ DRUGI
Pracowite zapytania SQL SQL nie jest językiem proceduralnym. Choć w zapytaniach SQL istnieje możliwość zastosowania logiki języków proceduralnych, należy traktować je z rozwagą. Problemy z rozróżnieniem logiki proceduralnej od przetwarzania deklaratywnego najlepiej widać w przypadkach, gdy ktoś próbuje wydobyć dane z bazy, dokonać na nich modyfikacji, po czym ponownie zapisać w bazie. Gdy program lub procedura działająca w ramach programu otrzyma dane wejściowe, często zdarza się, że są one wykorzystywane do odczytu innych danych z bazy, po czym następuje pętla lub inna forma logiki funkcyjnej (z reguły pętla if...then...else) zastosowana do wydobywania kolejnych danych z bazy. W większości przypadków jest to efekt głęboko zakorzenionych nawyków lub słabej znajomości SQL-a w połączeniu z niewolniczym oddaniem w stosunku do specyfikacji funkcjonalnej. Wiele stosunkowo skomplikowanych operacji można wykonać za pomocą pojedynczego zapytania w SQL-u. Jeśli użytkownik poda wartość, często można podać interesujący go wynik bez konieczności rozbijania logiki na poszczególne instrukcje o niewielkim związku z wynikiem końcowym. Istnieją dwa główne powody, aby unikać stosowania logiki proceduralnej w SQL-u: Każdy dostęp do bazy danych wiąże się z operacjami na wielu różnych warstwach programowych, włączając w to operacje sieciowe. Nawet w przypadku, gdy sieć nie jest wykorzystywana, wchodzą w grę operacje wymiany danych między procesami. Więcej operacji dostępu oznacza więcej wywołań funkcji, więcej przepustowości, a co się z tym wiąże, dłuższe oczekiwanie na odpowiedź. Gdy tego typu wywołania są często powtarzane, opóźnienia stają się wyraźnie zauważalne. „Proceduralny” znaczy, że wydajność i utrzymanie zależą od programu, nie od bazy danych. Większość systemów baz danych do wykonywania operacji, jak na przykład złączenia, wykorzystuje zaawansowane algorytmy przekształcające zapytania w ich inne formy tak, aby wykonywały się w wydajniejszy sposób. Optymalizatory oparte na koszcie (ang. cost-based optimizers, CBO) to skomplikowane moduły, które pokonały długą drogę. Na początku swojego istnienia mechanizmy tego typu były praktycznie
PROWADZENIE WOJNY
63
bezużyteczne, ale z czasem nabrały dojrzałości i obecnie dają doskonałe wyniki. Dobry mechanizm CBO bywa bardzo skuteczny w znajdowaniu najbardziej odpowiedniego planu wykonania. Jednakże CBO analizuje każde zapytanie SQL, nic ponad to. Wrzucając jak największą liczbę operacji w pojedyncze wyrażenie, przerzucamy na bazę danych odpowiedzialność za znalezienie najbardziej optymalnego wykonania. Dzięki temu program może wykorzystać również przyszłe usprawnienia w działaniu silnika systemu zarządzania bazą danych. W ten sposób również przyszłe usprawnienia aplikacji są częściowo przerzucane na dostawcę bazy danych. Jak to zwykle bywa, również od zasady unikania logiki proceduralnej istnieją dobrze uzasadnione wyjątki. Dotyczy to sytuacji, gdy tego typu podejście pozwala na znaczące przyspieszenie w uzyskiwaniu wyników. Rozbudowane, monstrualne zapytania SQL nie zawsze stanowią wzorzec wydajności. Jednak należy pamiętać, że proceduralny kod sklejający kolejno wykonywane zapytania SQL operujące na tych samych danych (tabelach i wierszach) to dobry materiał na pojedyncze zapytanie SQL. Mechanizm CBO analizuje pojedyncze zapytanie skonstruowane w zgodzie z regułami modelu relacyjnego jako jedną całość i jest w stanie opracować efektywny sposób jego wykonania. Jak najwięcej pracy zrzucaj na optymalizator zapytań, aby skorzystać z jego możliwości.
Maksymalne wykorzystanie dostępu do bazy danych Gdy planujemy wizytę w wielu sklepach, w pierwszym kroku musimy zdecydować się na to, co chcemy kupić w każdym z nich. Dzięki temu można zaplanować trasę podróży i zminimalizować konieczność przechodzenia między sklepami tam i z powrotem. Odwiedzamy pierwszy sklep, dokonujemy zakupów, po czym odwiedzamy kolejny sklep. To zdrowy rozsądek, a jednak zasada obowiązująca w tym przykładzie wydaje się obca wielu praktycznym zastosowaniom baz danych.
64
ROZDZIAŁ DRUGI
Gdy z pojedynczej tabeli chcemy odczytać kilka różnych informacji, nawet niezbyt powiązanych (co rzeczywiście wydaje się dość powszechnym przypadkiem), nieoptymalne jest nawiązywanie wielu połączeń, po jednym dla odczytania każdej porcji danych. Na przykład nie należy odczytywać pojedynczych kolumn, jeśli potrzebujemy wartości z kilku z nich. Należy do tego wykorzystać pojedynczą operację. Niestety, dobre praktyki programowania zorientowanego obiektowo z zasady pojedynczego odczytu wartości atrybutów zrobiły zaletę, nie wadę. Nie wolno jednak mylić metod obiektowych z przetwarzaniem danych w bazach. Mieszanie tych pojęć należy do najpoważniejszych błędów, tabel nie wolno ślepo traktować jako klas, a kolumn jako atrybutów. Każdą wizytę w bazie danych wykorzystuj do wykonania jak największej ilości pracy.
Zbliżenie do jądra systemu DBMS Im bliżej jądra systemu zarządzania bazą danych może działać kod, tym będzie działał wydajniej. To właśnie sedno siły baz danych. Na przykład niektóre systemy zarządzania bazami danych pozwalają na rozszerzanie swoich możliwości za pomocą nowych funkcji, które można pisać w niskopoziomowych językach programowania, jak na przykład C. Języki niskiego poziomu operujące na wskaźnikach mają istotną cechę negatywną: jeśli w procedurze zostanie popełniony błąd, można doprowadzić do uszkodzenia danych w pamięci. Problem byłby poważny już w przypadku, gdyby z programu korzystał tylko jeden użytkownik. Kłopot z bazami danych polega jednak na tym, że mogą obsługiwać dużą liczbę użytkowników. W przypadku uszkodzenia danych w pamięci, można uszkodzić dane innego, zupełnie „niewinnego” programu. W praktyce bywa tak, że solidne systemy zarządzania bazami danych wykonują kod w pewnej izolacji (technika określana mianem piaskownicy, ang. sandbox), dzięki czemu w przypadku awarii procesu nie pociąga on za sobą całej bazy danych. Przykładem jest tu system Oracle, który do wymiany danych między procesami a bazą danych wykorzystuje specjalny mechanizm komunikacji. Ten proces jest podobny do łączy między bazami danych pracujących na różnych serwerach. Jeśli zysk uzyskany z wydajności zewnętrznych funkcji
PROWADZENIE WOJNY
65
w C w stosunku do osadzonych procedur PL/SQL rekompensuje koszt skonstruowania zewnętrznego środowiska i przełączeń kontekstu, warto wykorzystać funkcje zewnętrzne. Jednak nie warto ich stosować w przypadku, gdy mają być wykorzystywane do wywoływania pojedynczo dla każdego wiersza tabeli. Jak to zwykle bywa, decyzja jest kwestią równowagi i znajomości wszystkich konsekwencji stosowania różnych strategii rozwiązywania tego samego problemu. Jeśli funkcje mają być jednak wykorzystane, warto w pierwszej kolejności rozważyć wykorzystanie funkcji standardowych, dostępnych w mechanizmie zarządzania bazą danych. Nie chodzi tu wyłącznie o kwestię unikania „ponownego wynajdowania koła”, lecz przede wszystkim o to, że funkcje wbudowane działają znacznie bliżej jądra systemu zarządzania bazą danych niż zewnętrzny kod, a w związku z tym są bardziej wydajne. Oto prosty przykład wykorzystania kodu SQL w bazie Oracle, za pomocą którego zademonstruję wydajność uzyskaną dzięki zastosowaniu funkcji wbudowanych. Załóżmy, że mamy dane tekstowe wprowadzone ręcznie przez użytkownika i że te dane zawierają zbędne ciągi znaku spacji. Potrzebna jest nam funkcja, która zastąpi takie ciągi wielu znaków spacji pojedynczym znakiem. Przyjmijmy, że nie będziemy korzystać z obsługi wyrażeń regularnych, dostępnej w Oracle Database 10g, a zamiast tego napiszemy własną funkcję: create or replace function squeeze1(p_string in varchar2) return varchar2 is v_string varchar2(512) := ''; c_char char(1); n_len number := length(p_string); i binary_integer := 1; j binary_integer; begin while (i <= n_len) loop c_char := substr(p_string, i, 1); v_string := v_string || c_char; if (c_char = ' ') then j := i + 1; while (substr(p_string || 'X', j, 1) = ' ') loop j := j + 1;
66
ROZDZIAŁ DRUGI
end loop; i := j; else i := i + 1; end if; end loop; return v_string; end; /
Uwaga na marginesie: na końcu ciągu znaków dopisywany jest znak X, aby uniknąć porównania wartości j z długością tego ciągu. Istnieją różne metody eliminacji ciągów spacji, w których można wykorzystać funkcje udostępniane w ramach bazy Oracle. Oto jedna z alternatyw: create or replace function squeeze2(p_string in varchar2) return varchar2 is v_string varchar2(512) := p_string; i binary_integer := l; begin i := instr(v_string, ' '); while (i > 0) loop v_string := substr(v_string, 1, i) || ltrim(substr(v_string, i + l)); i := instr(v_string, ' '); end loop; return v_string; end; /
Trzecia z metod może być następująca: create or replace function squeeze3(p_string in varchar2) return varchar2 is v_string varchar2(512) := p_string; len1 number; len2 number; begin len1 := length(p_string); v_string := replace(p_string, ' ', ' '); len2 := length(v_string); while (len2 < len1) loop len1 := len2;
PROWADZENIE WOJNY
67
v_string := replace(v_string, ' ',' '); len2 := length(v_string); end loop; return v_string; end; /
Gdy te trzy alternatywne metody zostaną przetestowane na prostym przykładzie, każda, zgodnie z oczekiwaniem, da dokładnie takie same wyniki i nie będzie znaczącej różnicy w wydajności: SQL> select squeeze1('azeryt 2 from dual 3 / azeryt hgfrdt r Elapsed: 00:00:00.00 SQL> select squeeze2('azeryt 2 from dual 3 / azeryt hgfrdt r Elapsed: 00:00:00.01 SQL> select squeeze3('azeryt 2 from dual 3 / azeryt hgfrdt r
hgfrdt
r')
hgfrdt
r')
hgfrdt
r')
Elapsed: 00:00:00.00
Załóżmy jednak, że operacja oczyszczania ciągów znaków z wielokrotnych spacji będzie wywoływana tysiące razy dziennie. Poniższy kod można zastosować do załadowania bazy danych danymi testowymi, za pomocą których można w nieco bardziej realistycznych warunkach przetestować wydajność trzech przedstawionych wyżej funkcji oczyszczających ciągi znaków z wielokrotnych spacji: create table squeezable(random_text varchar2(50)) / declare i j k v_string
binary_integer; binary_integer; binary_integer; varchar2(5O);
ROZDZIAŁ DRUGI
68
begin for i in 1 .. 10000 loop j := dbms_random.value(1, 100); v_string := dbms_random.string('U', 50); while (j < length(v_string)) loop k := dbms_random.value(l, 3); v_string := substr(substr(v_string, 1, j) || rpad(' ', k) || substr(v_string, j + 1), 1, 50); j := dbms_random.value(i, 100); end loop; insert into squeezable values(v_string); end loop; commit; end; /
Ten skrypt tworzy tabelę testową złożoną z dziesięciu tysięcy wierszy (to dość niewiele, biorąc pod uwagę średnią liczbę wywołań zapytań SQL). Test wywołuje się następująco: select squeeze_func(random_text) from squeezable;
Gdy wywoływałem ten test, wyłączyłem wyświetlanie wyników na ekranie. Dzięki temu upewniłem się, że czasy działania algorytmów oczyszczających wielokrotne spacje nie są zafałszowane przez czas niezbędny na wyświetlenie wyników. Testy były wywołane wielokrotnie, aby upewnić się, że nie wystąpił efekt przyspieszenia wykonania dzięki zbuforowaniu danych. Czasy działania tych algorytmów prezentuje tabela 2.2. TABELA 2.2. Czas wykonania funkcji oczyszczających ciągi spacji na danych testowych (10 000 wierszy) Funkcja
Mechanizm
Czas wykonania
squeeze1
PL/SQL pętla po elementach ciągu znaków
0,86 sekund
squeeze2
instr() + ltrim()
0,48 sekund
squeeze3
replace() wywołana w pętli
0,39 sekund
Choć wszystkie z tych funkcji wykonują się dziesięć tysięcy razy w czasie poniżej jednej sekundy, squeeze2() jest 1,8 razy szybsza od squeeze1(), a squeeze3() jest ponad 2,2 razy szybsza. Jak to się dzieje? Po prostu
PROWADZENIE WOJNY
69
PL/SQL nie jest tak „blisko jądra”, jak funkcja SQL-a. Różnica wydajności może wyglądać na niewielką w przypadku sporadycznego wywoływania funkcji, lecz w programie wsadowym lub na obciążonym serwerze OLTP różnica może już być poważna. Kod uwielbia jądro SQL-a — im jest bliżej, tym jest gorętszy.
Robić tylko to, co niezbędne Programiści często wykorzystują instrukcję count(*) do implementacji testu istnienia. Z reguły dochodzi do tego w celu implementacji następującej procedury: Jeśli istnieją wiersze spełniające warunek Wykonaj na nich działanie
Powyższy schemat jest implementowany za pomocą następującego kodu: select count(*) into counter from tabela where
if (counter > 0) then
Oczywiście w 90% tego typu wywołania instrukcji count(*) są zupełnie zbędne, dotyczy to również powyższego przykładu. Jeśli jakieś działanie musi być wykonane na podzbiorze wierszy tabeli, dlaczego go po prostu nie wykonać od razu? Jeśli nie zostanie zmodyfikowany ani jeden wiersz, jaki w tym problem? Nikomu nie stanie się żadna krzywda. Ponadto w sytuacji, gdy operacja wykonywana w bazie danych składa się z wielu zapytań, po wywołaniu pierwszego z nich liczbę zmodyfikowanych wierszy można odczytać ze zmiennej systemowej (@@ROWCOUNT w Transact-SQL, SOL%ROWCOUNT w PL/SQL itp.), specjalnego pola SQL Communication Area (SQLCA) w przypadku wykorzystania osadzonego SQL (embedded SQL) lub za pośrednictwem specjalizowanych API, jak na przykład funkcji mysql_affected_rows() języka PHP. Liczba przetworzonych wierszy jest czasem zwracana z funkcji, która wykonuje operację w bazie danych, jak metoda executellpdate() biblioteki JDBC. Zliczanie wierszy bardzo często
70
ROZDZIAŁ DRUGI
nie służy niczemu oprócz zwiększenia ilości pracy, jaką musi wykonać baza, ponieważ wiąże się ono z dwukrotnym przetworzeniem (a raczej: odczytaniem, a potem przetworzeniem) tych samych danych. Ponadto nie należy zapominać, że jeśli naszym celem jest aktualizacja lub wstawienie wierszy (częsty przypadek: wiersze są zliczane po to, by stwierdzić istnienie klucza), niektóre systemy zarządzania bazami danych oferują specjalne instrukcje (jak MERGE w Oracle 9i Database), które działają bardziej wydajnie niż w przypadku zastosowania osobnych zapytań zliczających. Nie ma potrzeby, aby jawnie kodować to, co baza danych wykonuje w sposób niejawny.
Instrukcje SQL-a odwzorowują logikę biznesową Większość systemów baz danych udostępnia mechanizmy monitorujące, za pomocą których można sprawdzać stan wykonywanych aktualnie instrukcji, a nawet śledzić liczbę ich wywołań. Przy okazji można uświadomić sobie liczbę przetwarzanych jednocześnie „jednostek biznesowych”: zamówień lub innych zgłoszeń, klientów z wystawionymi fakturami lub dowolnych innych zdarzeń istotnych z punktu widzenia biznesowego. Można zweryfikować, czy istnieje sensowne (a nawet absolutnie precyzyjne) przełożenie między dwoma klasami aktywności. Innymi słowy: czy dla zadanej liczby klientów liczba odwołań do bazy danych za każdym razem jest taka sama? Jeśli zapytanie do tabeli klientów jest wywoływane dwadzieścia razy częściej, niż wskazywałaby na to liczba przetwarzanych klientów, to z pewnością wskazuje na jakiś błąd. Taka sytuacja sugerowałaby, że zamiast jednorazowego odczytu danych z tabeli, program dokonuje dużej ilości zbędnych odczytów tych samych danych z tabeli. Należy sprawdzić, czy działania w bazie danych są spójne z realizowanymi funkcjami biznesowymi aplikacji.
PROWADZENIE WOJNY
71
Programowanie logiki w zapytaniach Istnieje kilka sposobów implementacji logiki biznesowej w aplikacji wykorzystującej bazę danych. Część logiki proceduralnej można zaimplementować w ramach instrukcji SQL-a (choć ze swej natury SQL mówi o tym, co należy zrobić, a nie jak). Nawet w przypadku dobrej integracji SQL-a w innych językach programowania zaleca się, aby jak najwięcej logiki biznesowej ujmować w SQL-u. Taka strategia pozwala na uzyskanie wyższej wydajności przetwarzania danych niż w przypadku implementacji logiki w aplikacji. Języki proceduralne to takie, w których można definiować iteracje (pętle) oraz stosować logikę warunkową (konstrukcje if...then...else). SQL nie potrzebuje pętli, ponieważ ze swojej natury operuje na zbiorach danych. Potrzebuje jedynie możliwości określania warunków wykonania określonych działań. Logika warunkowa wymaga obsługi dwóch elementów: IF i ELSE. Obsługa IF w SQL-u to prosta sprawa: warunek WHERE zapewnia dokładnie taką semantykę. Natomiast z obsługą logiki ELSE jest pewien problem. Na przykład mamy za zadanie pobrać z tabeli zbiór wierszy, po czym wykonać różne typy operacji, w zależności od typów zbiorów. Fragment tej logiki można zasymulować z użyciem wyrażenia CASE (Oracle od dawna obsługuje odpowiednik tej operacji w postaci funkcji decode()1). Między innymi można modyfikować w locie wartości zwracane w ramach zbioru wynikowego w zależności od spełnienia określonych warunków. W pseudokodzie można to zapisać następująco2: CASE WHEN WHEN WHEN ELSE END
warunek THEN warunek THEN warunek THEN
Porównywanie wartości liczbowych i dat to operacje intuicyjne. W przypadku ciągów znaków mogą być przydatne funkcje znakowe, jak greatest() czy least() znane z Oracle, czy strcmp() z MySQL-a. Czasem też bywa 1
Funkcja decode() jest nieco bardziej „surowa” w stosunku do konstrukcji CASE. Do uzyskania tych samych efektów może być konieczne wykorzystanie dodatkowych funkcji, na przykład sign().
2
Istnieją dwa warianty konstrukcji CASE, przedstawiona wersja jest bardziej zaawansowana.
72
ROZDZIAŁ DRUGI
możliwe zastosowanie pewnej formy logiki w instrukcjach za pomocą wielokrotnych i logicznych operacji wstawiania do tabel oraz za pomocą wstawiania łączącego3 (merge insert). Nie należy unikać takich instrukcji, o ile są dostępne w posiadanym systemie zarządzania bazami danych. Innymi słowy, polecenia SQL-a można wyposażyć w dużą ilość elementów kontrolnych. W przypadku pojedynczej operacji korzyść z tych mechanizmów być może nie jest wielka, lecz z zastosowaniem instrukcji CASE i wielu instrukcji wykonywanych warunkowo jest już o co walczyć. O ile to możliwe, warto implementować logikę aplikacji w zapytaniach SQL zamiast w wykorzystującej je aplikacji.
Jednoczesne wielokrotne modyfikacje Moje główne założenie w tym podrozdziale opiera się na stwierdzeniu, że kolejne modyfikacje danych w pojedynczej tabeli są dopuszczalne pod warunkiem że dotyczą rozłącznych zbiorów wierszy. W przeciwnym razie należy łączyć je w ramach pojedynczego zapytania. Oto przykład z rzeczywistej aplikacji4: update tbo_invoice_extractor set pga_status = 0 where pga_status in (1, 3) and inv_type = 0; update tbo_invoice_extractor set rd_status = 0 where rd_status in (1, 3) and inv_type = 0;
W tej samej tabeli dokonywane są dwie kolejne operacje modyfikujące. Czy te same wiersze będą wykorzystywane dwukrotnie? Nie ma możliwości, aby to stwierdzić. Zasadniczym pytaniem jest tu jednak, jak wydajne są kryteria wyszukiwania? Atrybuty o nazwach type (typ) lub status z dużym prawdopodobieństwem gwarantują słabą dystrybucję wartości. Jest zatem całkiem możliwe, że najefektywniejszym sposobem odczytu tych danych będzie pełne, sekwencyjne przeszukiwanie tabeli. 3
Dostępny na przykład w Oracle od wersji 9.2.
4
Nazwy tabel zostały zmienione.
PROWADZENIE WOJNY
73
Może też być tak, że jedno z zapytań wykorzysta indeks, a drugie będzie wymagało pełnego przeszukiwania. W najkorzystniejszym przypadku obydwa zapytania skorzystają z wydajnego indeksu. Niezależnie jednak od tego, nie mamy prawie nic do stracenia, aby nie spróbować połączyć obydwu zapytań w jedno: update tbo_invoice_extractor set pga_status = (case pga_status when 1 then 0 when 3 then 0 else pga_status end), rd_status = (case rd_status when 1 then 0 when 3 then 0 else rd_status end) where (pga_status in (1, 3) or rd_status in (1, 3)) and inv_type = 0;
Istnieje prawdopodobieństwo wystąpienia niewielkiego narzutu spowodowanego aktualizacją kolumn o wartości już przez nie posiadanej. Jednak w większości przypadków jedna złożona aktualizacja danych jest o wiele szybsza niż składowe wywołane osobno. Warto zauważyć zastosowanie logiki warunkowej z użyciem instrukcji CASE. Dzięki temu przetworzone zostaną tylko te wiersze, które spełniają kryteria, niezależnie od tego, jak wiele kryteriów będzie zastosowanych w zapytaniu. Operacje modyfikujące warto wykonywać w pojedynczej, złożonej operacji, aby zminimalizować wielokrotne odczyty tej samej tabeli.
Ostrożne wykorzystanie funkcji użytkownika Gdy w zapytaniu jest wykorzystana funkcja użytkownika, istnieje możliwość, że będzie wywoływana wielokrotnie. Jeśli funkcja występuje w liście SELECT, będzie wywoływana dla każdego zwróconego wiersza. Jeśli wystąpi w instrukcji WHERE, będzie wywoływana dla każdego sprawdzonego
74
ROZDZIAŁ DRUGI
wiersza, który spełnia kryteria sprawdzone wcześniej. To może oznaczać bardzo wiele wywołań w przypadku, gdy wcześniej sprawdzane kryteria nie są bardzo mocno selektywne. Warto się zastanowić, co się stanie, gdy taka funkcja wywołuje inne zapytanie. To zapytanie będzie wywoływane przy każdym wywołaniu funkcji. W praktyce jej wynik będzie taki sam jak w przypadku wywołania podzapytania, z tą różnicą, że w przypadku zapytania ukrytego w funkcji optymalizator nie ma możliwości lepszego zoptymalizowania zapytania głównego. Co więcej, procedura osadzona jest wykonywana na osobnej warstwie abstrakcji w stosunku do silnika SQL, więc będzie działać mniej wydajnie niż bezpośrednie podzapytanie. Zaprezentuję przykład demonstrujący zagrożenia wynikające z ukrywania kodu SQL w funkcjach użytkownika. Weźmy pod uwagę tabelę flights opisującą loty linii lotniczych. Tabela ta zawiera kolumny: flight_number, departure_time, arrival_time i iata_airport_codes5. Słownik kodów (około dziewięć tysięcy pozycji) jest zapisany w osobnej tabeli zawierającej nazwę miasta (lub lotniska, jeśli w jednym mieście znajduje się kilka lotnisk), nazwę kraju itp. Oczywiście każda informacja o locie wyświetlana użytkownikom powinna zawierać nazwę miasta i lotniska docelowego zamiast nic niemówiącego kodu IATA. W tym miejscu trafiamy na jedną ze sprzeczności w inżynierii nowoczesnego oprogramowania. Do „dobrych praktyk” programowania zaliczana jest między innymi modularność, polegająca w uproszczeniu na opracowaniu kilku odosobnionych warstw logiki. Ta zasada sprawdza się doskonale w ogólnym przypadku, lecz w kontekście baz danych, w których kod stanowi element wspólny między programistą a bazą danych, potrzeba zastosowania modularności kodu jest znacznie mniej wyraźna. Zastosujmy jednak zasadę modularności, tworząc niewielką funkcję zwracającą pełną nazwę lotniska na podstawie kodu IATA: create or replace function airport_city(iata_code in char) return varchar2 is city_name varchar2(50); begin select city 5
IATA: International Air Transport Association.
PROWADZENIE WOJNY
75
into city_naine from iata_airport_codes where code = iata_code; return(city_name); end; /
Dla czytelników niezaznajomionych ze składnią Oracle: wywołanie trunc(sysdate) zwraca dzisiejszą datę, godzinę 00:00, arytmetyka dat jest oparta na dniach. Warunek dotyczący czasów odlotu odnosi się zatem do czasów między 8:30 a 16:00 dnia dzisiejszego. Zapytania wykorzystujące funkcję airport_city() mogą być bardzo proste, na przykład: select flight_number, to_char(departure_time, 'HH24:MI') DEPARTURE, airport_city(arrival) "TO" from flights where departure_time between trunc(sysdate) + 17/48 and trunc(sysdate) + 16/24 order by departure_time /
To zapytanie wykonuje się z zadowalającą prędkością. Z zastosowaniem losowej próbki na mojej maszynie siedemdziesiąt siedem wierszy jest zwracanych w czasie 0,18 sekundy (średnia z kilku wywołań). Taka wydajność jest do przyjęcia. Statystyki informują jednak, że podczas wywołania zostały odczytane trzysta trzy bloki w pięćdziesięciu trzech operacjach odczytu z dysku. A należy pamiętać, że ta funkcja jest wywoływana rekurencyjnie dla każdego wiersza. Alternatywą dla funkcji pobierającej dane z tabeli (słownika) może być złączenie tabel. W tym przypadku zapytanie nieco się skomplikuje: select f.flight_number, to_char(f.departure_time, 'HH24:MI') DEPARTURE, a.city "TO" from flights f, iata_airport_codes a where a.code = f.arrival and departure_time between trunc(sysdate) + 17/48 and trunc(sysdate) + 16/24 order by departure_time /
76
ROZDZIAŁ DRUGI
To zapytanie wykonuje się w czasie 0,05 sekundy (te same statystyki, ale nie mamy do czynienia z rekurencyjnymi wywołaniami). Takie oszczędności mogą wydać się niewiele warte — trzykrotne przyspieszenie zapytania w wersji nieoptymalnej trwającego ułamek sekundy. Jednak dość powszechne jest, że w rozbudowanych systemach (między innymi na lotniskach) niektóre zapytania są wywoływane setki tysięcy razy dziennie. Załóżmy, że nasze zapytanie musi być wywoływane pięćdziesiąt tysięcy razy dziennie. Gdy zostanie użyta wersja zapytania wykorzystująca funkcję, całkowity czas wykonania tych zapytań wyniesie około dwie godziny i trzydzieści minut. W przypadku złączenia będą to czterdzieści dwie minuty. Oznacza to usprawnienie rzędu 300%, co w środowiskach o dużej liczbie zapytań oznacza znaczące przyspieszenie, które może przekładać się na konkretne oszczędności finansowe. Bardzo często zastosowanie funkcji powoduje niespodziewany spadek wydajności zapytania. Co więcej, wydłużenie czasu wykonania zapytań powoduje, że mniej użytkowników jest w stanie korzystać z bazy danych jednocześnie, o czym więcej piszę w rozdziale 9. Kod funkcji użytkownika nie jest poddawany analizie optymalizatora.
Oszczędny SQL Doświadczony programista baz danych zawsze stara się wykonać jak najwięcej pracy za pomocą jak najmniejszej liczby instrukcji SQL-a. Klasyczny programista natomiast stara się dostosować swój program do ustalonego schematu funkcyjnego. Na przykład: -- Odczyt początku okresu księgowego select closure_date into dtPerSta from tperrslt where fiscal_year=to_char(Param_dtAcc,'YYYY') and rslt_period='1' || to_char(Param_dtAcc,'MM'); -- Odczyt końca okresu na podstawie daty początku select closure_date into dtPerClosure from tperrslt where fiscal_year=to_char(Param_dtAcc,'YYYY') and rslt_period='9' || to_char(Param_dtAcc,'MM');
PROWADZENIE WOJNY
77
To jest przykład kodu o bardzo niskiej jakości, mimo tego że szybkość jego wykonania jest zadowalająca. Niestety, taka jakość jest typowa dla większości kodu, z którym muszą mierzyć się specjaliści od optymalizacji. Dlaczego dane są odczytywane z zastosowaniem dwóch osobnych zapytań? Ten przykład był uruchamiany na bazie Oracle, w której łatwo zaimplementować zapytanie zapisujące odpowiednie wartości w tabeli wynikowej. Wystarczy odpowiednio zastosować instrukcję ORDER BY na kolumnie rslt_period: select closure_date bulk collect into dtPerStaArray from tperrslt where fiscal_year=to_char(Param_dtAcc,'YYYY') and rslt_period in ('1' || to_char(Param_dtAcc,'MM'), '9' || to_char(Param_dtAcc,'MM')) order by rslt_period;
Dwie odczytane daty są zapisywane odpowiednio w pierwszej i drugiej komórce macierzy. Operacja bulk collect jest specyficzna dla języka PL/SQL, lecz w pozostałych językach obsługujących pobieranie danych do macierzy obowiązuje podobna zasada. Warto zauważyć, że macierz nie jest tu niezbędna, a te dwie wartości można pobrać do zmiennych skalarnych, wystarczy zastosować następującą sztuczkę6: select max(decode(substr(rslt_period, 1, 1), -- sprawdzenie pierwszego znaku '1', closure_date, -- jeśli to '1', zwracamy datę to_date('14/10/1066', 'DD/MM/YYYY'))), -- w przeciwnym razie max(decode(substr(rslt_period, 1, 1), '9', closuredate, -- o tę datę chodzi to_date('14/10/1066', 'DD/MM/YYYY'))), into dtPerSta, dtPerClosure from tperrslt where fiscal_year=to_char(Param_dtAcc, 'YYYY') and rslt_period in ('1' || to_char(Param_dtAcc,'MM'), '9' || to_char(Param_dtAcc,'MM'));
6
Funkcja decode() baz danych Oracle działa jak instrukcja CASE. Dane porównywane podaje się w pierwszym argumencie. Jeśli wartość jest równa drugiemu argumentowi, zwracany jest trzeci. Jeśli nie zostanie podany piąty argument, w takim przypadku czwarty jest traktowany jako wartość ELSE; w przeciwnym razie, jeśli pierwszy argument jest równy czwartemu, zwracany jest piąty i tak dalej w odpowiednich parach wartości.
78
ROZDZIAŁ DRUGI
W tym przykładzie wynik będzie dwuwierszowy, a oczekujemy wyniku jednowierszowego zawierającego dwie kolumny (tak, jak w przykładzie z macierzą). Dokonamy tego, sprawdzając za każdym razem wartość w kolumnie rozróżniającej wartości z każdego wiersza, czyli rslt_period. Jeśli odnaleziony wiersz jest tym, którego szukamy, zwracana jest odpowiednia data. W przeciwnym razie zwracana jest dowolna data (w tym przypadku data bitwy pod Hastings), znacznie starsza (z punktu widzenia porównania „mniejsza”) od jakiejkolwiek daty w tej tabeli. Wybierając maksimum, mamy pewność, że otrzymamy odpowiednią datę. Ten trik jest bardzo praktyczny i można go z powodzeniem stosować do danych znakowych lub liczbowych. Więcej tego typu technik omówię w rozdziale 11. SQL jest językiem deklaratywnym, zatem należy zachować dystans do proceduralności zastosowań biznesowych.
Ofensywne kodowanie w SQL-u Programistom często doradza się programowanie defensywne polegające między innymi na sprawdzaniu poprawności wszystkich parametrów przed ich zastosowaniem w wywołaniu. Przy korzystaniu z baz danych większe zalety ma jednak kodowanie ofensywne, polegające na wykonywaniu kilku działań równolegle. Dobrym przykładem jest mechanizm obsługi kontroli poprawności polegający na wykonywaniu serii sprawdzeń i zaprojektowany w ten sposób, że w przypadku wystąpienia choć jednego wyniku negatywnego wywoływany jest wyjątek. Załóżmy, że mamy przetworzyć płatność kartą płatniczą. Kontrola takiej transakcji składa się z kilku etapów. Należy sprawdzić, że poprawny jest identyfikator klienta i numer karty oraz że są prawidłowo ze sobą powiązane. Należy również zweryfikować datę ważności karty. No i oczywiście bieżący zakup nie może spowodować przekroczenia limitu karty. Gdy wszystkie testy zakończą się pomyślnie, może zostać przeprowadzona operacja obciążenia konta karty. Niedoświadczony programista mógłby napisać coś takiego: select count(*) from customers where customer_id = id_klienta
PROWADZENIE WOJNY
79
W tym miejscu następuje sprawdzenie wyniku, a jeśli wynik jest pomyślny, następuje wywołanie: select card_num, expiry_date, credit_limit from accounts where customer_id = id_klienta
Tutaj również nastąpi sprawdzenie wyniku, po którym (w przypadku powodzenia) wywoływana jest transakcja finansowa. Doświadczony programista zapewne napisze to nieco inaczej (zakładając, że today() to funkcja zwracająca bieżącą datę): update accounts set balance = balance – wielkosc_zamowienia where balance >= wielkosc_zamowienia and credit_limit >= wielkosc_zamowienia and expiry_date > today() and customer_id = id_klienta and card_num = numer_karty
Tutaj następuje sprawdzenie liczby zmodyfikowanych wierszy. Jeśli jest to zero, przyczynę takiej sytuacji można sprawdzić za pomocą jednego zapytania: select c.customer_id, a.card_num, a.expiry_date, a.creditlimit, a.balance from customers c left outer join accounts a on a.customer_id = c.customer_id and a.cardnum = numer_karty where c.customer_id = id_klienta
Jeśli zapytanie nie zwróci żadnego wiersza, oznacza to, że wartość customer_id jest błędna, jeśli w wyniku card_num jest NULL, oznacza to, że numer karty jest błędny itd. Jednak w większości przypadków to drugie zapytanie nie będzie nawet uruchomione. UWAGA Warto zwrócić uwagę na wywołanie count(*) w pierwszym fragmencie kodu niedoświadczonego programisty. To doskonała ilustracja błędnego użycia funkcji count(*) do sprawdzenia, czy w tabeli istnieją pozycje spełniające warunek.
Zasadniczą cechą programowania ofensywnego jest opieranie swoich założeń na rozsądnym prawdopodobieństwie. Na przykład nie ma większego sensu, by sprawdzać istnienie klienta. Jeśli nie istnieje, w bazie danych nie znajdzie się żaden dotyczący go rekord (zatem w wyniku wywołania zapytania bez wcześniejszej kontroli i tak nie zostaną zmodyfikowane
80
ROZDZIAŁ DRUGI
żadne dane)! Zakładamy, że wszystko zakończy się powodzeniem, a nawet jeśli tak się nie stanie, przygotowujemy mechanizm ratujący nas z opresji w tym jednym punkcie — i tylko w tym jednym. Co interesujące, tego typu strategia przypomina nieco „optymistyczną kontrolę współdzielenia” zastosowaną w niektórych bazach danych. Chodzi o to, że założono z góry, iż z dużym prawdopodobieństwem nie będzie sytuacji konfliktu dostępu do danych, a jeśli jednak się to zdarzy, dopiero wówczas uruchamiane są stosowne konstrukcje kontrolne. W wyniku zastosowania tej strategii wydajność systemu jest znacznie wyższa niż w przypadku systemów stosujących strategię pesymistyczną. Należy kodować w oparciu o rachunek prawdopodobieństwa. Zakłada się, że najprawdopodobniej wszystko zakończy się pomyślnie, a dopiero w przypadku niepowodzenia uruchamia się plany awaryjne.
Świadome użycie wyjątków Między odwagą a zapalczywością różnica jest dość subtelna. Gdy zalecam stosowanie agresywnych metod kodowania, nie sugeruję bynajmniej szarży w stylu Lekkiej Brygady pod Bałakławą7. Programowanie z użyciem wyjątków również może być konsekwencją brawury, gdy dumni programiści decydują się „iść na całość”. Mają bowiem przeświadczenie, że testy i możliwość obsługi wyjątków będą ich tarczą w tym boju. No tak, odważni umierają młodo! Jak sugeruje nazwa, wyjątki to zdarzenia występujące w niecodziennych sytuacjach. W programowaniu z użyciem baz danych nie wszystkie wyjątki wykorzystują te same zasoby systemowe. Należy poznać te uwarunkowania, aby korzystać z wyjątków w sposób inteligentny. Można wyróżnić dobre wyjątki, wywoływane, zanim zostaje wykonane działanie, oraz złe wyjątki, które są wywoływane dopiero po fakcie wyrządzenia poważnych zniszczeń. 7
Podczas Wojny Krymskiej, w 1854 roku, odbyła się bitwa między wojskami Anglii, Francji i Turcji a siłami Rosji. W wyniku nieprecyzyjnego rozkazu oraz osobistych animozji między niektórymi z dowódców sił sprzymierzonych doszło do szarży ponad sześciuset żołnierzy kawalerii brytyjskiej wprost na baterię rosyjskiej artylerii. Na skutek starcia zginęło około stu dwudziestu kawalerzystów oraz połowa koni, bez jakiegokolwiek dobrego rezultatu. Odwaga ludzi została wkrótce wysławiona przez wiersz Tennysona, a potem w kilku filmach hollywoodzkich, dzięki czemu zwykła głupota jednej militarnej decyzji obróciła się w mit.
PROWADZENIE WOJNY
81
Zapytanie wykorzystujące klucz główny, które nie znajdzie żadnych wierszy, wykorzystuje niewiele zasobów — sytuacja jest identyfikowana już na etapie przeszukiwania indeksu. Jeśli jednak w celu stwierdzenia, że dane spełniające warunek nie występują w tabeli, zapytanie nie może użyć indeksu, zachodzi konieczność dokonania pełnego przeszukiwania tabeli (full scan). W przypadku wielkich tabel czas potrzebny do odczytu sekwencyjnego w systemie działającym w danym monecie na granicy swojej wydajności można potraktować jako czynnik katastrofalny. Niektóre wyjątki są szczególnie kosztowne, nawet przy najbardziej sprzyjających okolicznościach. Weźmy na przykład wykrywanie duplikatów. W jaki sposób w bazie danych jest obsługiwany mechanizm unikalności? Prawie zawsze służy do tego unikalny indeks i gdy wystąpi próba wprowadzenia do tabeli wartości zawierającej klucz występujący już w indeksie, zadziała mechanizm zabezpieczający przed zduplikowaniem klucza, co efektywnie zablokuje zapis duplikatu. Jednakże zanim nastąpi próba zapisu indeksu (weryfikacji duplikatu), w tabeli musi zostać fizycznie zapisana odpowiednia wartość (do procedury indeksującej przesyłany jest fizyczny adres wiersza w tabeli). Z tego wynika, że naruszenie ograniczenia unikalności klucza następuje po fakcie zapisu w tabeli danych, które muszą być wycofane, czemu dodatkowo towarzyszy komunikat informujący o wystąpieniu błędu. Wszystkie te operacje wiążą się z określonym kosztem czasowym. Największym jednak grzechem jest podejmowanie samodzielnych prób działania na poziomie wyjątków. W takim przypadku przejmujemy od systemu zadanie obsługi operacji na poziomie wierszy, nie całych zbiorów danych, czyli sprzeciwiamy się fundamentalnej koncepcji relacyjnego modelu danych. Konsekwencją występowania częstych naruszeń ograniczeń w bazie będzie w takim przypadku stopniowa degradacja jej wydajności. Przyjrzyjmy się przykładowi opartemu na bazie Oracle. Załóżmy, że pracujemy nad integracją systemów informatycznych dwóch połączonych firm. Adres e-mail został ustandaryzowany w postaci wzorca i ma zawierać co najwyżej dwanaście znaków, wszystkie spacje i znaki specjalne są zastępowane znakami podkreślenia8.
8
Przykład nie uwzględnia obsługi polskich znaków diakrytycznych, niedozwolonych w adresach e-mail — przyp.red.
82
ROZDZIAŁ DRUGI
Załóżmy, że nową tabelę pracowników należy wypełnić trzema tysiącami wierszy z tabeli employees_old. Chcemy też, żeby każdy pracownik posiadał unikalny adres e-mail. Z tego powodu musimy zastosować określoną zasadę nazewnictwa: Jan Kowalski będzie miał e-mail o postaci jkowalski, a Józef Kowalski (żadnego pokrewieństwa) jkowalski2 itd. W naszych danych testowych znajdziemy trzydzieści trzy potencjalne pozycje konfliktowe, co przy próbie ładowania danych da następujący efekt: SQL> insert into employees(emp_num, emp_name, emp_firstname, emp_email) 2 select emp_num, 3 emp_name, 4 emp_firstname, 5 substr(substr(EMP_FIRSTNAME, 1, 1) 6 ||translate(EMP_NAME, ' ''', '_'), 1, 12) 7 from employees_old; insert into employees(emp_num, emp_name, emp_firstname, emp_email) * ERROR at line 1: ORA-0000l: unique constraint (EMP_EMAIL_UQ) violated
Elapsed: 00:00:00.85
Trzydzieści trzy duplikaty ze zbioru trzech tysięcy to trochę powyżej 1%, być może zatem warto byłoby obsłużyć te 99%, a elementy problemowe obsłużyć z użyciem wyjątków? W końcu 1% danych nie powinien powodować znacznego obciążenia bazy w wyniku procedury obsługi wyjątków. Poniżej kod realizujący ten optymistyczny scenariusz: SQL> declare 2 v_counter varchar2(l2); 3 b_ok boolean; 4 n_counter number; 5 cursor c is select emp_num, 6 emp_name, 7 emp_firstname 8 from employees_old; 9 begin 10 for rec in c 11 loop 12 begin 13 insert into employees(emp_num, emp_name, 14 emp_firstname, emp_email)
PROWADZENIE WOJNY
83
15 values (rec.emp_num, 16 rec.emp_name, 17 rec.emp_firstname, 18 substr(substr(rec.emp_firstname, 1, 1) 19 ||translate(rec.emp_name, ' ''', ' '), 1, 12)); 20 exception 21 when dup_val_on_index then 22 b_ok := FALSE; 23 n_counter := 1; 24 begin 25 v counter := ltrim(to_char(n_counter)); 26 insert into employees(emp_num, emp_name, 27 emp_firstname, emp_email) 28 values (rec.emp_num, 29 rec.emp_name, 30 rec.emp_firstname, 31 substr(substr(rec.emp_firstname, 1, 1) 32 ||translate(rec.emp_name, ' ''', '__'), 1, 33 12 - length(v_counter)) || v_counter); 34 b_ok : = TRUE; 35 exception 36 when dup_val_on_index then 37 n_counter := n_counter + 1; 38 end; 39 end; 40 end loop; 41 end; 40 / PL/SOL procedure successfully completed. Elapsed: 00:00:18.41
Jaki jest jednak rzeczywisty koszt obsługi wyjątków? Gdyby ten sam test przeprowadzić na danych pozbawionych duplikatów, okaże się, że koszt rzeczywistej obsługi wyjątków (ich wystąpień) jest pomijalny. Procedura wywołana na danych z duplikatami działa około osiemnaście sekund, podobnie jak na danych bez duplikatów. Jednak gdy wykonamy ten test (dane bez duplikatów) na naszej oryginalnej procedurze nieobsługującej wyjątków (insert...select), zauważymy, że wykona się znacznie szybciej od pętli. Przełączenie się w tryb „wiersz po wierszu” powoduje około 50-procentowy narzut czasu przetwarzania. Czy w takim razie możliwe jest uniknięcie tego trybu? To kwestia tego, czy zdecydujemy się na rezygnację z mechanizmu obsługi wyjątków, który to właśnie zmusił nas do obsługi danych w trybie wierszowym.
84
ROZDZIAŁ DRUGI
Innym sposobem mogłoby być zidentyfikowanie wierszy powodujących powstanie duplikatów i uzupełnienie w nich adresów e-mail kolejnymi liczbami. Łatwo określić liczbę problematycznych wierszy, wystarczy odpowiednio je zgrupować w zapytaniu SQL. Jednakże uzupełnienie o unikalne liczby może być trudne bez zastosowania funkcji analitycznych dostępnych w niektórych zaawansowanych systemach baz danych. Określenie „funkcje analityczne” pochodzi z nomenklatury Oracle. W DB2 funkcje te znane są jako funkcje OLAP (ang. online analytical processing), w Microsoft SQL Server jako funkcje rankingu (ang. ranking functions). Warto przyjrzeć się bliżej rozwiązaniom tego typu z punktu widzenia czystego SQL-a. Każdy adres e-mail może mieć dopisany unikalny numer przy wykorzystaniu do tego rankingu według wieku pracownika. Numer 1 otrzyma najstarszy pracownik w danej grupie duplikatów, numer 2 kolejny pod względem wieku z tej grupy itd. Umieszczając tę liczbę w podzapytaniu, mamy możliwość uniknięcia dopisania czegokolwiek do pierwszego znalezionego adresu e-mail w każdej grupie, natomiast pozostałym przypisywane są kolejne liczby sekwencji. Sposób realizacji tego zadania demonstruje poniższy kod: SQL> insert into employees(emp_num, emp_firstname, 2 emp_name, emp_email) 3 select emp_num, 4 emp_firstname, 5 emp_name, 6 decode(rn, 1, emp_email, 7 substr(emp_email, 8 1, 12 - length(ltrim(to_char(rn)))) 9 || ltrim(to_char(rn))) 10 from (select emp_num, 11 emp_firstname, 12 emp_name, 13 substr(substr(emp_firstname, 1, 1) 14 ||translate(emp_name, ' ''', '_'), 1, 12) 15 emp_email, 16 row_number() 17 over (partition by 18 substr(substr(emp_firstname, 1, 1)
PROWADZENIE WOJNY
19 20 21 22 /
85
||translate(emp_name,' ''','_'), 1, 12) order by emp_num) rn from employees_old)
3000 rows created. Elapsed: 00:00:11.68
Unikamy kosztu przetwarzania w trybie wierszowym, dzięki czemu to rozwiązanie zajmuje około 60% czasu w porównaniu z pętlą. Obsługa wyjątków zmusza do zastosowania logiki proceduralnej. Zawsze warto brać pod uwagę obsługę wyjątków, jednak w zakresie niezmuszającym do rezygnacji z deklaratywnej specyfiki SQL-a.
86
ROZDZIAŁ DRUGI
ROZDZIAŁ TRZECI
Działania taktyczne Indeksowanie Chi vuole fare tutte queste cose, conviene che tenga lo stile e modo romano: il quale fu in prima di fare le guerre, come dicano i Franciosi, corte e grosse. Każdy, kto ma zamiar brać udział w tych rzeczach, musi zastosować się do zasad i metod starożytnego Rzymu, on bowiem był pierwszym, który uczynił wojnę, jak mawiają Francuzi, szybką i ostrą. — Niccolò Machiavelli (1469 – 1527) Rozważanie nad pierwszym dziesięcioksięgiem historii Rzymu Tytusa Liwiusza, II, 6
88
P
ROZDZIAŁ TRZECI
o określeniu układu pola bitwy generał powinien umieć precyzyjnie zidentyfikować kluczowe elementy zasobów wroga, które musi zdobyć. Dokładnie taka sama zasada dotyczy systemu informatycznego. Kluczowe dane, które muszą być odczytane, determinują najbardziej wydajne ścieżki dostępu do systemu. W tym przypadku fundamentalna taktyka polega na indeksowaniu. To skomplikowane zagadnienie, w którym programista jest zmuszony do podejmowania kompromisów. W tym rozdziale omówimy różne zagadnienia związane z indeksowaniem i strategiami tworzenia indeksów, które w sumie będą stanowić ogólne zalecenia dotyczące strategii dostępu do baz danych.
Identyfikacja „punktów wejścia” Jeszcze przed rozpoczęciem pisania pierwszego zapytania SQL programista powinien mieć koncepcję kryteriów wyzyskiwania danych, które będą miały znaczenie z punktu widzenia użytkownika. Wartości zapisywane w programie oraz rozmiary podzbiorów danych odczytywanych z bazy stanowią fundament schematu indeksowania. Indeksy są przede wszystkim techniką służącą uzyskaniu jak największej szybkości dostępu do określonych danych. Podkreślam wyraz „określonych”, a to dlatego, że rola indeksu powinna być zdefiniowana jak najściślej. Indeksy nie są bowiem panaceum na niską wydajność bazy: nie zapewniają szybkiego dostępu do dowolnych danych. W rzeczywistości bywa wręcz tak, że błędna strategia indeksowania skutkuje obniżeniem szybkości dostępu do danych. Indeksy można postrzegać jako skróty do danych, lecz nie jest to taki sam skrót, jaki znamy z graficznych środowisk użytkownika. Indeksy bowiem wiążą się z poważnymi kosztami, zarówno w znaczeniu miejsca na dysku, jak i szybkości przetwarzania danych. Na przykład nie jest niczym nietypowym, że spotyka się tabele, w których objętość indeksów znacznie przekracza objętość indeksowanych danych. O indeksach można powiedzieć to samo, co o nadmiarowych danych (szczegóły w rozdziale 1.). Indeksy są zapisywane na dyskach lustrzanych (ang. mirror), w kopiach zapasowych i tak dalej, a wielkie rozmiary danych sporo kosztują. Nie chodzi tu tylko o koszt nośnika, ale raczej o czas potrzebny na ich odtworzenie z kopii w przypadku awarii. Rysunek 3.1 przedstawia przykład wzięty z życia. Jest to statystyka rozmiaru danych i indeksów w głównej tabeli kont w banku. Wszystkie indeksy i tabela zajmują łącznie 33 GB, z czego na indeksy przypada ponad 75%.
DZIAŁANIA TAKTYCZNE
89
RYSUNEK 3.1. Przypadek z życia: dane a indeks: łącznie 33 GB
Zapomnijmy jednak na moment o zajętości dysku i zastanówmy się nad przetwarzaniem. Każda operacja wstawiania lub usuwania wiersza pociąga za sobą konieczność aktualizacji wszystkich indeksów. Tego typu aktualizacja odbywa się również podczas modyfikacji indeksowanej kolumny, na przykład gdy zmienimy wartość atrybutu będącego indeksem lub wchodzącego w skład indeksu złożonego. W praktyce tego typu aktualizacja indeksów wiąże się ze znacznym wykorzystaniem zasobów procesora oraz z koniecznością dokonania wielu operacji wyszukiwania w pamięci, przesuwania bajtów i innych operacji wejścia-wyjścia na dysku (zapis danych w dziennikach oraz odczyt danych z bazy). Na końcu system musi dokonać operacji rekurencyjnych w celu realizacji zadań związanych z alokacją danych na dysku. Te wszystkie działania mają znaczący wpływ na wydajność operacji w bazie. Załóżmy na przykład, że czas jednostkowy niezbędny do wykonania operacji wstawiania danych do tabeli wynosi 100 (sekund, minut czy też godzin, to nie ma większego znaczenia w tym przykładzie). Każdy dodatkowy indeks na tej tabeli dodaje do operacji wstawiania dodatkowy czas jednostkowy o wartości od 100 do 250. Czas obsługi jednego indeksu może przekroczyć czas obsługi jednej tabeli.
90
ROZDZIAŁ TRZECI
Choć implementacja mechanizmów indeksowania różni się w różnych systemach zarządzania bazami danych, wysoki koszt utrzymania indeksów jest niezależny od produktu. Rysunki 3.2 i 3.3 przedstawiają analizę kosztu zastosowania indeksów w bazach Oracle i MySQL.
RYSUNEK 3.2. Wpływ zastosowania indeksów na wydajność zapisów w bazie Oracle
RYSUNEK 3.3. Wpływ zastosowania indeksów na wydajność zapisów w bazie MySQL
Co interesujące, narzut związany z obsługą indeksów jest porównywalny do narzutu związanego z wyzwalaczami. Utworzyłem prosty wyzwalacz, którego zadanie polegało na rejestracji w tabeli log klucza każdego wstawianego wiersza wraz z nazwą użytkownika i znacznikiem czasu — jest to typowy zapis kontrolny. Jak można się spodziewać, wydajność bazy spadła, lecz o ten sam rząd wielkości jak w przypadku zastosowania dwóch indeksów, co przedstawia rysunek 3.4. Jak pamiętamy, stosowanie
DZIAŁANIA TAKTYCZNE
91
wyzwalaczy nie jest praktyką zalecaną właśnie z powodu negatywnego wpływu na wydajność! Użytkownicy są z reguły niechętni stosowaniu wyzwalaczy, czego nie można powiedzieć o indeksach, choć ich negatywny wpływ na szybkość pracy może być bardzo podobny.
RYSUNEK 3.4. Porównanie wpływu na wydajność indeksów i wyzwalaczy
Tworzenie dużej liczby indeksów grozi obniżeniem wydajności nie tylko przy operacjach zapisu. W środowisku wyposażonym w dużą liczbę indeksów można zaobserwować zwiększoną częstotliwość występowania sytuacji konkurowania o zasoby (ang. congestion) i blokowania (ang. locking). Ze swej natury indeks jest znacznie bardziej zwartą strukturą w porównaniu do tabeli (wystarczy liczbę stron indeksu w tej książce porównać z liczbą stron tekstu). Pamiętajmy, że aktualizacja poindeksowanej tabeli wymaga wykonania dwóch działań: aktualizacji samych danych oraz aktualizacji danych indeksów. W wyniku tego równoległe modyfikacje, teoretycznie nieprzeszkadzające sobie nawzajem dzięki znacznemu rozrzutowi danych na dyskach, w przypadku indeksów nie mają tak wiele przestrzeni do dyspozycji, a to właśnie za sprawą większej „gęstości” zapisu. Należy podkreślić, że indeksy są kluczowym elementem baz danych mimo kosztów związanych z miejscem na dysku i wydajnością przetwarzania. Ich znaczenie jest szczególnie duże w transakcyjnych bazach danych (o czym szerzej wspomnę w rozdziale 6.), w których zapytania SQL zwracają lub modyfikują niewielkie wycinki ogromnych tabel. Rozdział 10. zawiera dalsze informacje o tym, jak bardzo systemy decyzyjne opierają swoją wydajność na odpowiedniej konfiguracji mechanizmu indeksującego. Jeśli tabele w bazie zostaną właściwie znormalizowane (i ponownie nie
92
ROZDZIAŁ TRZECI
zawaham się powtórzyć, do znudzenia, jak ważny jest tu etap projektowania), kolumny wymagające specjalnego indeksowania nie będą bardzo powszechne w bazach transakcyjnych. Oczywiście nie mam tu na myśli kluczy głównych (identyfikatora wiersza). Ta kolumna (lub kolumny, w przypadku kluczy złożonych) będzie indeksowana automatycznie po prostu dlatego, że została zadeklarowana jako klucz główny. Kolumny zadeklarowane jako unikalne mają podobną własność i zgodnie z prawdopodobieństwem będą zaindeksowane w ramach efektu ubocznego implementacji mechanizmu zapewniającego ich unikalność (w celu spełnienia zadeklarowanego ograniczenia integralnościowego). Warto również rozważyć indeksowanie kolumn nieunikalnych, ale o własnościach zbliżonych do unikalności, innymi słowy cechujących się dużą różnorodnością. Doświadczenie wskazuje, że w większości tabel transakcyjnych baz danych ogólnego przeznaczenia nie ma potrzeby stosowania dużej liczby indeksów, ponieważ z reguły tabele tego typu są przeszukiwane według określonych, prostych kryteriów. W przypadku systemów wspomagania decyzji może już jednak być inaczej. Jak się przekonamy w rozdziale 10., jestem bardzo sceptyczny co do zasadności tworzenia tabel z dużą liczbą indeksów, szczególnie w przypadku, gdy tabele te są bardzo duże i często modyfikowane. Duża liczba indeksów jest uzasadniona w sporadycznych przypadkach, zawsze jednak w momencie napotkania tabel wymagających dużej liczby indeksów warto ponownie rozważyć poprawność projektu. W transakcyjnej bazie danych sytuacja zbyt dużej liczby indeksów w tabeli powinna automatycznie skłaniać do zastanowienia się nad projektem.
Indeksy i listy zawartości Metafora indeksu w książce jest pomocna również w innym znaczeniu. Warto bowiem zastanowić się nad rolą indeksu w bazie danych. Należy rozróżnić rolę spisu treści oraz indeksu w książce. Obie te konstrukcje ułatwiają dostęp do danych, ale na różnych poziomach szczegółowości. Spis treści stanowi strukturalny przegląd całej książki. Jako taki powinien być traktowany jako uzupełnienie indeksu, który jest traktowany tak, jak indeks w bazie danych.
DZIAŁANIA TAKTYCZNE
93
Poszukując określonych informacji w książce, najczęściej w pierwszej kolejności zagląda się do indeksu. W takiej sytuacji czytelnik jest gotowy sprawdzić dwie do trzech pozycji, ale nie więcej. Ciągłe przerzucanie kartek między indeksem a zasadniczą treścią to mozolne i nieefektywne działanie. Podobnie sprawy się mają z indeksem w bazach danych, w których działa on najefektywniej w sytuacjach, gdy służy odszukaniu niewielkiej liczby elementów (w tym momencie pomijam kwestię wykorzystania indeksu do wyszukiwania zakresów danych). Jeśli poszukuje się w książce jakiejś kluczowej informacji, sprawdza się w indeksie pierwszy zgodny element, po czym zaczyna czytanie. Drugi sposób polega na sprawdzeniu istnienia odpowiedniego rozdziału lub podrozdziału w spisie treści. Różnica między spisem treści a indeksem jest tu kluczowa: pozycja w spisie treści kieruje czytelnika do bloku tekstu, najczęściej rozdziału lub podrozdziału. W rozdziale 5. można znaleźć kilka wskazówek informujących, w jaki sposób można w bazie danych zorganizować tabele, aby skonstruować mechanizm dostępu do treści o działaniu przypominającym spis treści. Indeks należy traktować jako sposób dostępu do danych na atomowym poziomie szczegółowości. Dokładnie wynika to z oryginalnego projektu bazy danych. Indeks nie nadaje się do wyszukiwania dużych porcji niezdefiniowanych bliżej danych. Gdy strategia indeksowania jest wykorzystywana do wydobywania dużych porcji danych, należy uznać, że projektant nie zrozumiał idei indeksów. Indeksy bywają często wykorzystywane jako rozpaczliwy środek zaradczy w sytuacjach bez wyjścia. Dowódca zaczyna panikować i wysyła do boju swoje oddziały w przypadkowych kierunkach w nadziei, że przewaga liczebna zrekompensuje brak strategii. Oczywiście to nigdy nie ma prawa się udać. Należy zawsze być pewnym, że się wie, co i dlaczego jest indeksowane.
94
ROZDZIAŁ TRZECI
Co zrobić, by indeksy rzeczywiście działały Aby użycie indeksu było uzasadnione, musi on dawać korzyści. Podobnie jak metafora indeksu w książce, indeks może być użyty do przyspieszenia wyszukiwania jednego elementu danych. Jeśli jednak interesuje nas cały obszar zagadnień, nie będziemy zajmować się indeksem, lecz spisem treści w książce. Zawsze może zdarzyć się sytuacja, gdy wybór między indeksem a spisem treści nie jest aż tak oczywisty. Właśnie w tym obszarze szczególnie sprawdzają się statystyki współczynników odczytu (ang. retrieval ratio). Tego typu statystyki zdają się mieć jakiś hipnotyczny czar przyciągający wielu praktyków związanych z IT oraz bazami danych, ponieważ są one tak oczywiste, tak proste, tak naukowe! Sensowność zastosowania indeksów była od lat oceniana w oparciu o całkowitą ilość danych pobranych z użyciem klucza jako jedynym kryterium wyszukiwania. Z reguły pułap opłacalności określa się na 10%, jest to średni procent dopasowanych wierszy. Współczynnik ten określa selektywność indeksu — im mniejsza wartość, tym bardziej selektywny jest indeks. Tego typu regułę można bardzo powszechnie spotkać w literaturze. Ten współczynnik, i inne podobne, opiera się na starym założeniu uwzględniającym zależność od czasu dostępu do dysku i pamięci. Pomijając prosty fakt, że te reguły, będące w obiegu od połowy lat osiemdziesiątych, opierały się na technologiach, które obecnie są już od dawna przestarzałe (współczynniki określane w wartościach procentowych z reguły zdradzają wyidealizowane, znacznie uproszczone podejście), przy podejmowaniu tego typu decyzji należy przede wszystkim wziąć pod uwagę znacznie więcej praktycznych czynników. W okresie, gdy powstawały tego typu „magiczne” współczynniki, jak wspomniany 10-procentowy pułap opłacalności, tabele o pięciuset tysiącach wierszy były uważane za bardzo duże. 10% z takiej tabeli z reguły oznaczało kilkadziesiąt tysięcy wierszy. Jednak przy tabelach o setkach milionów, a często miliardach wierszy, liczba wierszy zwrócona przez indeks o współczynniku selektywności na poziomie 10% wyniosłaby z pewnością więcej, niż miały w całości te wzorcowe tabele, w oparciu o które tworzono wzorcowe współczynniki selektywności.
DZIAŁANIA TAKTYCZNE
95
Weźmy pod uwagę rolę, jaką odgrywają współczesne dyski twarde wyposażone w rozbudowane pamięci podręczne (bufory). System zarządzania bazą danych wysyła do dysku żądanie operacji wejścia-wyjścia, ale ze strony dysku twardego może to oznaczać zaledwie dostęp do pamięci bufora. Co więcej, jądro systemu zarządzania bazą danych często przenosi pewne obszary danych do pamięci w zależności od typu operacji (na tabeli lub na indeksie); często zupełnie zaskakujące może się okazać, jak bardzo różni się czas dostępu do danych bez i z użyciem indeksu. Jednak nie są to jedyne zagadnienia, które warto wziąć pod uwagę. Należy również obserwować liczbę operacji wykonywanych w sposób równoległy. Należy uwzględnić, czy wiersze zwracane w ramach indeksu są fizycznie zbliżone na dysku. W przypadku indeksu na dacie wstawienia wiersza w tabeli (pomijając pewne zagadnienia związane z zarządzaniem miejscem w bazie danych, o których wspomnę w rozdziale 5.) istnieje duża szansa, że zapytanie zawierające kryterium daty wstawienia do tabeli będzie zwracać wiersze położone w sąsiedztwie na dysku twardym. Każdy blok lub strona na dysku twardym wskazane przez pierwszy klucz indeksu z określonego zakresu prawdopodobnie będą zawierały również kolejne wartości dla odpowiedniego zakresu kluczy. Dzięki temu zakres wierszy zwróconych z zapytania biorącego pod uwagę klucz na dacie wstawienia do tabeli oraz każdy blok danych wyszukany dzięki temu kluczowi będą położone na dysku w sposób mający znaczący wpływ na wydajność zapytania. Gdy wiersze odpowiadające ciągłemu zakresowi kluczy indeksu są rozrzucone po całej tabeli w sposób nieuporządkowany (na przykład będące odwołaniami do poszczególnych artykułów w tabeli zamówień), sytuacja będzie zgoła odmienna. Nawet mimo faktu że całkowita liczba wierszy w ramach zamówienia będzie niewielka w porównaniu z rozmiarem tabeli, z powodu rozrzucenia danych na dysku znaczenie indeksu będzie mniejsze. Tego typu sytuacja jest zaprezentowana na rysunku 3.5. Możemy mieć dwa indeksy działające z jednakową skutecznością w przypadku odczytu pojedynczego wiersza. Jeden z nich będzie jednak działał znacznie lepiej od drugiego, jeśli będziemy operować na zakresach kluczy indeksu, co jest sytuacją bardzo częstą we współczesnych zastosowaniach. Tego typu czynniki znacznie komplikują ocenę skuteczności i sensowności zastosowania określonego indeksu.
96
ROZDZIAŁ TRZECI
RYSUNEK 3.5. Dwa wysoko selektywne indeksy mogą działać z bardzo różną wydajnością
Fizyczne uporządkowanie wierszy zgodnie z kluczami indeksu znacznie przyspiesza odczyt zakresów danych.
Indeksy wykorzystujące funkcje i konwersje Indeksy są z reguły zaimplementowane jako struktury drzewiaste (w większości przypadków są to skomplikowane drzewa), a to w celu uniknięcia sytuacji szybkiej dewaluacji indeksu w przypadku częstych operacji wstawiania, modyfikowania i usuwania danych w tabeli. Aby odszukać fizyczne położenie danych w wierszu, czyli adres zapisany w indeksie, należy porównać wartość klucza z wartością zapisaną w bieżącym węźle drzewa, by ocenić, w której z gałęzi należy prowadzić dalsze poszukiwania (rekurencyjnie). Załóżmy teraz, że wyszukiwana wartość nie jest zapisana bezpośrednio w kolumnie tabeli, lecz jest wynikiem zastosowania określonej funkcji f() na wartości kolumny. Chodzi nam o przypadek zastosowania w zapytaniu warunku następującej postaci: where f(indeksowana_kolumna) = 'wartość'
DZIAŁANIA TAKTYCZNE
97
Tego typu zapytanie z reguły niweczy istnienie indeksu, co powoduje, że staje się on bezużyteczny. Problem polega na tym, że nic nie gwarantuje, iż funkcja f() zachowuje kolejność danych istniejącą w ramach indeksu. W rzeczywistości w większości przypadków istnieje pewność, że tak nie będzie. Załóżmy na przykład, że nasze drzewo indeksów ma postać jak na rysunku 3.6.
RYSUNEK 3.6. Uproszczona reprezentacja zapisu nazwisk w indeksie
(Tych, którym nazwiska wydają się znajome, poinformuję, że to nazwiska marszałków Napoleona). Rysunek 3.6 to oczywiście dramatycznie uproszczona reprezentacja, chodzi nam jedynie o demonstrację zasady na prostym przykładzie. Indeksy oczywiście nie mają wiele wspólnego z prostym drzewem binarnym z rysunku 3.6. Jeśli będziemy szukać klucza MASSENA, posłużymy się następującym warunkiem: where name = 'MASSENA'
W takim przypadku wyszukiwanie jest bardzo proste. U pnia drzewa trafiamy na LANNES i porównujemy MASSENA z LANNES. Stwierdzamy, że klucz MASSENA jest większy (zgodnie z kolejnością alfabetyczną). Kontynuujemy więc poszukiwanie w prawym poddrzewie, u którego pnia znajduje się klucz MORTIER. Nasz poszukiwany klucz jest mniejszy niż MORTIER, zatem kontynuujemy w lewym poddrzewie i w końcu trafiamy na klucz MASSENA. Udało się. Załóżmy jednak, że mamy następujący warunek: where substr(name, 3, 1) = 'R'
Warunek poszukuje wartości, w których trzecią literą klucza jest R, co powinno zwrócić klucze BERNADOTTE, MORTIER i MURAT. Początek wyszukiwania w indeksie nie znajdzie dopasowania, mamy tu bowiem klucz LANNES, niespełniający warunku. Co gorsza, wartość zapisana w bieżącym węźle drzewa indeksu nie daje żądnej wskazówki co do tego,
98
ROZDZIAŁ TRZECI
w której z gałęzi indeksu należy szukać. Pozostajemy z pustymi rękami: fakt, że trzecią literą klucza jest R, nie daje żadnej wskazówki co do sposobu przeszukiwania indeksu — nie wiemy, czy wartości spełniające warunek znajdują się w lewym, czy w prawym poddrzewie (w rzeczywistości znajdują się i w lewym, i w prawym). Nie mamy możliwości przeszukiwania drzewa według ustalonej logiki, czyli wybierając określoną gałąź na podstawie poszukiwanej wartości oraz wartości węzła. W przypadku posiadania jedynie indeksu o postaci przedstawionej na rysunku 3.6 poszukiwanie nazwisk o trzeciej literze równej R wymaga zastosowania przeszukiwania sekwencyjnego. Jednak w tym miejscu pojawia się kolejne pytanie. Jeśli optymalizator jest wystarczająco zaawansowany, może być w stanie określić, czy należy przeszukać całą tabelę, czy wystarczy przeszukać wszystkie węzły indeksu. W tym drugim przypadku wyszukiwanie będzie się bezpośrednio wiązało z indeksem, lecz nie dzięki zastosowaniu określonego modelu indeksowania, ponieważ indeks będzie wykorzystywany w najmniej wydajny sposób. Warto przypomnieć sobie naszą dyskusję o atomowości z rozdziału 1. Problem z wydajnością wynika tu z prostego faktu: jeśli potrzebujemy zastosować funkcję na kolumnie, oznacza to, że poziom atomowości danych w tej kolumnie jest niezgodny z potrzebami biznesowymi. Tabela nie jest zgodna z 1NF! Atomowość nie jest jednak prostym zjawiskiem. Klasycznym przykładem są tu warunki wyszukiwania wykorzystujące daty. Na przykład Oracle wykorzystuje typ daty do zapisu nie tylko informacji o datach, lecz również o czasie aż do poziomu sekund (w większości systemów baz danych taki typ danych nazywa się DATETIME). Jednak domyślny format wypisywania dat nie zdradza faktu zapisu również informacji o czasie. Na przykład: where date_entered = to_date('18-JUN-l8l5', 'DD-MON-YYYY')
Przy takim warunku zostaną uwzględnione tylko te wiersze, w których wartość w kolumnie date_entered wynosi 18 czerwca 1815 o godzinie 00:00 (o północy). Wielu użytkowników baz danych daje się złapać w tę pułapkę za pierwszym razem, gdy mają do czynienia z wykorzystaniem dat w ramach kryteriów wyszukiwania. W pierwszym odruchu, gdy zorientują się
DZIAŁANIA TAKTYCZNE
99
co do przyczyny błędu, początkujący z reguły próbują następującego wybiegu: where trunc(date_entered) = to_date('l8-JUN-l8l5', 'DD-MON-YYYY')
Zachwyceni, że zapytanie „wreszcie działa”, użytkownicy z reguły przeoczają fakt (aż do momentu, gdy pojawia się problem wydajności), iż niweczy ono możliwość wykorzystania indeksu na kolumnie date_entered (o ile taki istniał). Czy to oznacza, że tabele zawierające kolumny z datami nigdy nie mogą być zgodne z 1NF? Na szczęście nie. W rozdziale 1. atomowość atrybutu określiłem jako sytuację, w której klauzula WHERE może odwoływać się do danego atrybutu jako do całości. Do daty w całości można się odwoływać wówczas, gdy zastosuje się warunki zakresowe. Indeks na kolumnie date_entered jest w pełni użyteczny w przypadku warunku zapisanego w następujący sposób: where date_entered >= to_date('l8-JUN-l8l5', 'DD-MON-YYYY') and date_entered < to_date('l9-JUN-l8l5', 'DD-MON-YYYY')
Wyszukiwanie wierszy za pomocą warunku skonstruowanego w taki sposób powoduje, że indeks staje się w pełni użyteczny, ponieważ pierwszy warunek pozwala nam schodzić „w dół” drzewa (drzewo indeksu można wyobrazić sobie jako posortowaną listę kluczy i związanych z nimi adresów). Zatem w momencie rozstrzygnięcia pierwszego warunku „znajdujemy się” w węźle drzewa określającym dolną granicę wyszukiwania w drzewie, a jednocześnie pierwszy element interesującej nas listy wartości. Pozostaje jedynie przejrzeć tę listę (w górę drzewa) aż do momentu, gdy drugi z warunków przestanie być spełniony. Tego typu operację nazywa się zakresowym przeszukiwaniem indeksu (ang. index range scan). Pułapka w postaci funkcji zapobiegających wykorzystaniu indeksów może okazać się jeszcze groźniejsza, jeśli system zarządzania bazami danych obsługuje niejawną konwersję typów kolumn wykorzystywanych w warunkach (klauzuli WHERE). Tego typu sytuacje należą do błędów logicznych, choć są dopuszczalne w języku SQL. Ponownie doskonały przykład tego typu mechanizmu stanowi Oracle. Problem pojawia się na przykład, gdy kolumna typu znakowego jest porównywana do stałej typu
100
ROZDZIAŁ TRZECI
liczbowego. Zamiast wywołać błąd wykonawczy, Oracle dokona niejawnego przekształcenia ciągu znaków w liczbę, aby dokonać porównania. Przekształcenie może wywołać błąd wykonawczy, jeśli porównywany ciąg znaków nie reprezentuje poprawnej liczby, ale w wielu przypadkach, gdy w ciągu znaków są zapisane ciągi cyfr bez żadnego znaczenia liczbowego, przekształcenie, a w konsekwencji porównanie, „zadziała”, z tą różnicą, że potencjalny indeks na kolumnie tekstowej będzie bezużyteczny. W świetle zagrożenia utraty zalet związanych z indeksowaniem decyzja projektowa w bazie danych Oracle dokonująca niejawnej konwersji kolumny, a nie stałej liczbowej, może wydawać się zaskakująca. Jednak nie jest zupełnie pozbawiona sensu. Po pierwsze, porównywanie jabłek z gruszkami to błąd logiczny. Wykonując przekształcenie typu na całej kolumnie, silnik bazy danych ma większe szanse (w zależności od ścieżki wykonawczej) trafić na wartości, które wywołają błąd przekształcenia, a co się z tym wiąże błąd wykonawczy. Błąd na tym etapie przetwarzania stanowi sygnał ostrzegawczy dla programisty i daje mu szansę zmiany decyzji, a co ważniejsze, stanowi doskonałą okazję do zastanowienia się nad jakością danych. Po drugie, przy założeniu, że nie wygeneruje się błąd, z pewnością chcemy uniknąć otrzymania nieprawidłowego wyniku. Na przykład: where account_number = 12345
Wystąpienie w kodzie programu wywołania tego typu jest stosunkowo prawdopodobne. Osoba wpisująca ten kod miała zapewne na myśli konto o numerze 0000012345. Gdy ciąg znaków o tej wartości w kolumnie account_number przekształci się na liczbę, uzyskamy prawidłowy wynik. Jednak w przypadku przekształcenia liczby 12345 na ciąg znaków bez specjalnego formatowania, zapytanie nie znajdzie żadnych dopasowań i zwróci nieoczekiwany wynik. Mogłoby się wydawać, że niejawne przekształcenia typów to dość rzadkie sytuacje, które można porównać do wystąpienia błędów. Jest w tym ziarno prawdy (szczególnie jeśli chodzi o uznanie ich za błędy), ale, niestety, tego typu zjawisko bywa powszechne, szczególnie gdy mamy do czynienia z tabelami parameters przechowującymi dane różnych typów (przekształcone na typ znakowy) w jednej kolumnie tekstowej, nazwanej parameter_value. W takich tabelach zdarza się znaleźć liczby i daty, jak również nazwy plików i zwykłe ciągi znaków. Przekształcenia powinny zawsze być jawne i wykorzystywać funkcje przekształcające typy.
DZIAŁANIA TAKTYCZNE
101
W niektórych mechanizmach zarządzania bazami danych istnieje możliwość zaindeksowania kolumny poddanej działaniu funkcji. W różnych produktach ta możliwość jest dostępna pod różnymi nazwami (functional index, function-based index, index extension itp., a najprościej określa się je jako indeks na kolumnach wyliczanych). Moim zdaniem należy zachować czujność przy stosowaniu tego typu możliwości i wykorzystywać je jedynie wówczas, gdy musimy szybko zastosować jakiś wybieg i nie ma czasu na solidną modyfikację kodu źródłowego. Wspomniałem już o znacznym narzucie związanym z modyfikacją danych w indeksowanych kolumnach. Dodatkowe wywołanie funkcji, oprócz standardowego obciążenia związanego z obsługą indeksu z pewnością nie wpłynie na poprawę tej sytuacji i, jak się można spodziewać, powoduje dalsze spowolnienie operacji modyfikujących. Weźmy na przykład opisaną wyżej sytuację z kolumną date_entered. Stworzenie indeksu opartego na funkcji to jedynie próba wybrnięcia z konieczności prawidłowego napisania zapytania. Można powiedzieć, że mamy tu do czynienia z narzutem wydajnościowym wynikłym z lenistwa. Co więcej, nie ma gwarancji, że funkcja zastosowana na kolumnie pozwoli na uzyskanie tego samego poziomu szczegółowości, co czysta postać danych w kolumnie. Załóżmy, że w tabeli mamy zapisane pięć lat danych sprzedażowych i że indeksowana jest kolumna sales_date. Może się wydawać, że taki indeks jest wydajny. Jednak poindeksowanie danych po zastosowaniu funkcji wydobywającej z daty numer miesiąca obniży współczynnik selektywności indeksu, szczególnie w świetle faktu, że większość zakupów dokonywanych jest w okolicach Gwiazdki. Obiektywna ocena przydatności indeksu opartego na funkcji nie jest możliwa bez wnikliwej analizy. Z czysto projektowego punktu widzenia można przyjąć założenie, że konieczność zastosowania funkcji na kolumnie może wynikać z faktu, iż kolumna przechowuje dwa niezależne elementy danych. Zastosowanie indeksu funkcyjnego jest nierzadko spowodowane koniecznością częstego wydobywania informacji ukrytej w wartości kolumny. Jak wspominałem wcześniej, taka sytuacja stanowi naruszenie założeń pierwszej postaci normalnej, które opierają się na atomowości danych w kolumnach. Dodatkowo wykorzystywanie „nie do końca atomowych” danych w liście wyboru danych z tabeli to niewybaczalny grzech. Natomiast częste wykorzystanie „podatomowych” danych w kryteriach wyboru to grzech śmiertelny.
102
ROZDZIAŁ TRZECI
Istnieją jednak przypadki, gdy zastosowanie indeksu funkcyjnego może być uzasadnione. Najlepszym przykładem jest zapewne wyszukiwanie ciągów znaków bez uwzględniania wielkości liter. Dzięki poindeksowaniu ciągów znaków operacja przekształcenia oryginalnych ciągów na wielkie lub małe litery pozwala w sposób wydajny dokonywać wyszukiwania na tego typu kolumnach. Niezłym rozwiązaniem tego problemu jest też zapisanie danych w kolumnach od razu z odpowiednią wielkością liter. Jeśli jednak dane w tabeli są zapisane małymi literami, a wyszukiwanie odbywa się według ciągów znaków pisanych wielkimi literami i do tego wykorzystywany jest indeks funkcyjny, to znak, że taki projekt danych wymaga ponownego przemyślenia. Kolejnym przypadkiem jest określenie „czasu trwania”. Gdy mamy trzy kolumny: czas rozpoczęcia, czas zakończenia i czas trwania, każdą z tych wartości można uzyskać z pozostałych dwóch. Jednak w tym celu potrzebujemy indeksu funkcyjnego lub zapisu nadmiarowych danych. Niezależnie od wybranego rozwiązania w bazie pojawia się nadmiarowość. Ostateczna decyzja staje się kwestią świadomego kompromisu, należy rozważyć możliwe konsekwencje stosowania indeksów funkcyjnych. Konieczność zastosowania indeksów funkcyjnych często ujawnia fakt niedostatecznej analizy związanej z atomowością danych.
Indeksy i klucze obce Dość często zdarza się, że klucze obce tabeli bywają indeksowane, co więcej, tego typu decyzja uznawana jest za element prawidłowej strategii. Do tego niektóre narzędzia do modelowania baz danych automatycznie zakładają indeksy na kluczach obcych, podobnie działają systemy zarządzania bazami danych. Osobiście doradzam ostrożność w tej materii. Biorąc pod uwagę całkowity koszt związany z utrzymaniem indeksów, indeksowanie kluczy obcych może okazać się błędem, szczególnie w przypadku tabel zawierających większą liczbę kluczy obcych. UWAGA Oczywiście jeśli wykorzystywany system zarządzania bazami danych automatycznie indeksuje klucze obce, nie mamy większego wyboru. Jednak w sytuacji, gdy mamy wybór co do stosowania indeksu na kluczu obcym, należy podjąć świadomą decyzję.
DZIAŁANIA TAKTYCZNE
103
Zasada indeksowania kluczy obcych bierze się z obserwacji: załóżmy, że klucz obcy z tabeli A odwołuje się do klucza głównego tabeli B i klucz obcy i główny ulegają modyfikacji. Sytuację tę ilustruje prosty model z rysunku 3.7.
RYSUNEK 3.7. Prosty przykład konstrukcji typu dane główne-szczegóły
Załóżmy, że tabela A ma bardzo duże rozmiary. Jeśli użytkownik U1 zechce usunąć wiersz z tabeli B, więzy integralności klucza głównego z tabeli B (powiązanego z kluczem obcym tabeli A) spowodują, że baza danych musi upewnić się, czy to nie spowoduje niespójności w zależnościach, czyli w tym przypadku musi sprawdzić, czy istnieją w tabeli A wiersze odwołujące się do usuwanego wiersza. Gdyby klucz obcy w tabeli A był poindeksowany, tego typu sprawdzenie odbyłoby się bardzo szybko. Jeśli nie jest poindeksowany, sprawdzenie tej sytuacji może zająć dłuższy czas, ponieważ system musi przejrzeć całą tabelę A. Inny problem może wyniknąć z faktu, że rzadko zdarza się, aby baza danych była wykorzystywana przez pojedynczego użytkownika. Podczas czasochłonnego przeszukiwania tabeli A może wydarzyć się sporo sytuacji. Na przykład po wywołaniu operacji usuwającej wiersz z tabeli B przez użytkownika U1 ktoś inny, załóżmy, że to U2, może usiłować dodać nowy wiersz do tabeli A, odwołujący się do usuwanego właśnie wiersza z tabeli B. Tego typu sytuacja jest zaprezentowana na rysunku 3.8. Użytkownik U1 uzyskuje dostęp do tabeli B, aby sprawdzić identyfikator usuwanego wiersza (1), po czym poszukuje powiązanych z nim wierszy w tabeli A (2). W międzyczasie użytkownik U2 upewnia się, że w tabeli B istnieje interesujący go wiersz. Jednak w tabeli B istnieje indeks na kluczu głównym, co powoduje, że użytkownik U2 ma duże szanse wyprzedzić w działaniu użytkownika U1, który pod nieobecność indeksu na tabeli A skazany jest na przeszukiwanie sekwencyjne. Jeśli U2 doda wiersz do
104
ROZDZIAŁ TRZECI
RYSUNEK 3.8. Rywalizacja o klucz główny
tabeli A (3), może się okazać, że w międzyczasie U1 zakończy sprawdzanie tabeli A, nie znajdując w niej nowego wiersza, co spowoduje, że uzna, iż może bezpiecznie usunąć wybrany wiersz z tabeli B. Aby zapobiec takim przypadkom, konieczne jest zastosowanie mechanizmu blokad (locking), w przeciwnym razie może szybko dojść do niespójności danych. Integralność danych jest, a raczej powinna być, jednym z najważniejszych celów, jakie powinien spełniać solidny mechanizm zarządzania bazami danych. Tutaj nie dopuszcza się ryzyka. Gdy chcemy usunąć wiersz z tabeli B, na czas poszukiwania odwołań do tego wiersza z tabeli A musimy zapobiec wpisaniu do tabeli A wiersza odwołującego się do usuwanego wiersza z tabeli B. Istnieją dwa sposoby zapobiegania dopisaniu wierszy do tabel odwołujących się (może ich być kilka), czyli takich, jak tabela A z naszego przykładu: • blokada wszystkich tabel odwołujących się na czas całej operacji (podejście drastyczne), • blokada tabeli B, która skutkuje zablokowaniem wszelkich operacji zapisu do tabel odwołujących się do B (jak operacja wykonana przez U2 z naszego przykładu). To podejście jest stosowane przez większość systemów zarządzania bazami danych. Blokada jest zakładana na całej tabeli, stronie lub pojedynczym wierszu, w zależności od poziomu szczegółowości zaimplementowanego w ramach mechanizmu blokad w silniku bazy danych. Niezależnie od zastosowanego mechanizmu blokad, w przypadku, gdy klucze obce nie są poindeksowane, weryfikacja więzów integralności może być powolną operacją, co w konsekwencji spowoduje blokadę założoną na długi czas, a to z kolei może skończyć się uniemożliwieniem dokonania
DZIAŁANIA TAKTYCZNE
105
wielu wpisów. W najgorszym razie, przy zastosowaniu metody drastycznej, może dojść do zakleszczenia (deadlock), to znaczy do sytuacji, gdy dwa procesy blokują dwa różne, wzajemnie powiązane zasoby i trzymają blokady do czasu, gdy drugi z nich zwolni swoją, co oczywiście nie następuje. W takim przypadku system zarządzania bazami danych z reguły radzi sobie, unicestwiając jeden z blokujących procesów, aby uwolnić drugi z nich. Przypadek jednoczesnych modyfikacji wymaga zatem indeksu na kluczu obcym, aby zminimalizować czas istnienia blokad. Stąd właśnie wzięła się uproszczona reguła: „Klucze obce zawsze powinny mieć założone indeksy”. Zaleta z indeksowania kluczy obcych polega na tym, że czas niezbędny do realizacji każdego z zadań modyfikacji znacznie się skraca i w ten sposób zmniejsza się do minimum czas istnienia blokady zabezpieczającej integralność danych. Jednak użytkownicy często zapominają, że reguła „Klucze obce zawsze powinny mieć założone indeksy” to reguła ogólna, która zrodziła się ze szczególnego przypadku. Co ciekawe, ten przypadek szczególny często wynika z kolei z pewnej „specyfiki implementacyjnej”, jak na przykład konieczność utrzymywania podsumowań lub innej wartości zagregowanej na podstawie wartości jednostkowych w ramach powiązania dane główne-dane zagregowane. Istnieje wiele solidnych powodów utrzymywania powiązań tabel z zachowaniem więzów integralności. Istnieją jednak również przypadki, gdy powiązania między tabelami są bardzo statyczne, jak na przykład tabela słownikowa, której klucze rzadko bywają modyfikowane lub usuwane, lub są modyfikowane w ramach procedury administracyjnej na bazie danych, czyli w nocy, gdy opóźnienia raczej nikomu nie będą przeszkadzały. W takim przypadku zakładanie indeksu na kluczu obcym będzie miało uzasadnienie jedynie wówczas, gdy jest korzystne z punktu widzenia wydajności operacji na tabeli. Nie wolno zapominać o kosztach związanych z utrzymaniem indeksów. Istnieją bowiem liczne przypadki, gdy indeks na kluczu obcym nie jest konieczny. Indeksowanie musi być uzasadnione. Uzasadnienie indeksowania kluczy obcych jest dokładnie takie samo, jak w przypadku pozostałych kolumn.
106
ROZDZIAŁ TRZECI
Wielokrotne indeksowanie tej samej kolumny Systematyczne indeksowanie kluczy obcych może często prowadzić do sytuacji, w których jedna kolumna należy do wielu różnych indeksów. Rozważmy ponownie klasyczny przykład. Mamy do czynienia z systemem składania zamówień, w którym istnieje tabela szczegółów zamówień order_details zawierająca identyfikator zamówienia order_id, będący kluczem obcym do tabeli zamówień orders, identyfikator artykułu article_id, będący kluczem obcym do tabeli articles, oraz ilość danego artykułu. Mamy tu do czynienia z typową tabelą skojarzeniową (order_details), czyli realizującą związek wiele-do-wielu między tabelami orders i articles. Związki między tymi trzema tabelami przedstawia rysunek 3.9.
RYSUNEK 3.9. Przykład tabel zamówień i ich szczegółów
Klucz główny tabeli order_details, typowo dla tego typu konstrukcji, będzie kluczem złożonym z dwóch kluczy obcych. Zamówienie jest przykładem obiektu, który często bywa modyfikowany zarówno od strony tabeli odwoływanej, jak i odwołującej, i dlatego klucz obcy order_id musi być poindeksowany. Jednakże kolumna określona w tym przypadku jako klucz obcy jest już poindeksowana w ramach klucza głównego i w wielu przypadkach z tego samego indeksu może korzystać zarówno klucz główny, jak i klucz obcy tabeli. Indeks złożony jest w pełni użyteczny nawet w przypadku, gdy nie są określone niektóre kolumny klucza, pod warunkiem że są określone wszystkie kolumny z początku klucza. Schodząc w dół drzewa indeksu, jak to opisywałem wcześniej w tym rozdziale, często w celu określenia odgałęzienia, w którym należy dalej szukać, wystarczy porównać pierwsze elementy klucza z wartościami w węzłach indeksu. Z tego powodu nie ma sensu samodzielnie indeksować klucza głównego order_id, ponieważ silnik bazy danych będzie miał możliwość wykorzystania indeksu (order_id, article_id) w celu sprawdzenia wierszy związanej tabeli orders. Również nie będzie konieczności zakładania blokad
DZIAŁANIA TAKTYCZNE
107
jednocześnie na obydwu tabelach. Należy jednak stale pamiętać, że to rozumowanie ma sens wyłącznie dzięki temu, że order_id jest pierwszym elementem klucza złożonego. Gdyby klucz główny zdefiniować jako (article_id, order_id), istniałaby konieczność osobnego zdefiniowania indeksu na kolumnie order_id, ale nie byłoby konieczności tworzenia indeksu na kolumnie article_id. Indeksowanie wszystkich kluczy obcych może prowadzić do powstania nadmiarowych indeksów.
Klucze generowane automatycznie Szczególnej uwagi wymagają klucze generowane automatycznie, czyli najczęściej tworzone z użyciem kolumny specjalnego typu self-incrementing, czyli automatycznie zwiększającej kolejne wartości o jeden, jak na przykład typ sequence znany z Oracle. Niedoświadczeni projektanci baz danych uwielbiają automatyczne klucze główne, nawet jeśli w tabeli mają do dyspozycji zupełnie prawidłowe identyfikatory. Automatyczne klucze główne są z pewnością o wiele lepszym rozwiązaniem niż samodzielne pilnowanie sekwencji — przez wyszukiwanie najwyższej wartości w sekwencji i zwiększanie jej o jeden (w środowisku ze zrównoleglonym dostępem to prawie pewny sposób na sprowokowanie duplikatów) lub zachowywanie „następnej wartości” i blokowanie jej na czas wprowadzenia do tabeli nowego wiersza (taki mechanizm powoduje szeregowanie operacji dramatycznie spowalniający czas dostępu). Gdy w tabeli z automatycznym kluczem odbywa się duża liczba szybko następujących po sobie wstawień nowych wierszy, może dojść do sytuacji rywalizacji o zasoby na poziomie mechanizmu generowania indeksu klucza głównego. Celem indeksu klucza głównego jest bowiem przede wszystkim zapewnienie unikalności kolumn klucza głównego. Problem z reguły bierze się stąd, że pojedynczy generator wartości unikalnych (w przeciwieństwie do zwielokrotnienia liczby generatorów do liczby wystąpień równiej liczbie równoległych operacji, z tym że każdy generator musiałby zwracać zupełnie niezależną wartość, aby uniknąć duplikatów między generatorami) będzie generował wartości położone
108
ROZDZIAŁ TRZECI
bardzo blisko siebie. To z kolei sprawi, że każda wartość klucza głównego przy zapisie do indeksu będzie zapisywana w tej samej stronie na dysku, co natomiast spowoduje konieczność skolejkowania operacji wejścia-wyjścia w pliku indeksu czy to z użyciem blokad (ang. lock), zatrzasków (ang. latch), semaforów (ang. semaphore), czy dowolnych innych technik kontroli dostępu do zasobów. To typowy przykład rywalizacji o zasoby, która prowadzi do nieoptymalnego wykorzystania zasobów sprzętowych. Procesy, które mogą i powinny pracować równolegle, ustawiają się w kolejce i czekają na siebie nawzajem. Tego typu wąskie gardła mogą prowadzić do szczególnie poważnych strat w środowiskach wieloprocesorowych, które są szczególnie predysponowane do maksymalnego zrównoleglenia operacji. Niektóre systemy zarządzania bazami danych mają zaimplementowane mechanizmy redukujące niekorzystny wpływ na wydajność z powodu zastosowania automatycznych kluczy głównych. Na przykład Oracle pozwala definiować odwrócone indeksy, w których bity tworzące klucz są odwrócone przed zapisem do indeksu. Aby zademonstrować przybliżoną koncepcję konstrukcji indeksu tego typu, weźmy te same nazwiska marszałków co w przykładzie z rysunku 3.5 i zamieńmy litery zamiast bitów. W wyniku powstanie układ przedstawiony na rysunku 3.10.
RYSUNEK 3.10. Uproszczona reprezentacja odwróconego indeksowania
Łatwo jest zrozumieć, że nawet w przypadku wstawiania nazw znajdujących się alfabetycznie blisko siebie, w drzewie indeksów znajdą się one w oddalonych gałęziach. Zwróćmy uwagę na przykład na pozycję elementu MASSENA (po przekształceniu ANESSAM), MORTIER (REITROM) i MURAT (TARUM). Dzięki tej technice skutki rywalizacji o zasoby są znacznie mniej dotkliwe w porównaniu do klasycznie zorganizowanych indeksów. Przed wyszukiwaniem w tego typu indeksie Oracle po prostu stosuje ten sam zabieg mieszający na wyszukiwanej wartości, po czym wykorzystuje standardowy mechanizm poszukiwania wartości w drzewie indeksu.
DZIAŁANIA TAKTYCZNE
109
Oczywiście nie ma róży bez kolców: odwrócony indeks jest bezużyteczny w przypadku wyszukiwania początkowych liter ciągu znaków: where name like 'M%'
Jest to typowy przykład wyszukiwania zakresowego. Zwykły indeks pozwala szybko znaleźć zakres wartości zgodnych z warunkiem, na przykład ciągów znaków rozpoczynających się określonym podciągiem. Nieprzystosowanie indeksów odwrotnych do wyszukiwania zakresowego nie jest jednak wielkim problemem z punktu widzenia kluczy generowanych automatycznie. Klucze tego typu są bowiem z reguły ukryte przed użytkownikiem, a co się z tym wiąże, istnieją niewielkie szanse na wykonywanie wyszukiwania zakresowego z ich udziałem. Jednak w przypadku wierszy zawierających znaczniki czasu problem jest już większy. Znaczniki czasu często występują w niewielkich odstępach, co powoduje, że są doskonałymi kandydatami do odwróconego indeksowania. Jednakże znacznik czasu jest typem szczególnie atrakcyjnym z punktu widzenia wyszukiwania zakresowego. Indeks haszujący (hash index) wykorzystywany w niektórych systemach baz danych to jeszcze inne podejście do problemu rywalizacji o zasoby towarzyszącego kluczom automatycznym. Indeksowanie haszujące polega na zamianie wartości klucza na nic nieznaczący, równomiernie rozproszony liczbowy klucz wygenerowany przez system w oparciu o indeksowaną wartość kolumny. Istnieje prawdopodobieństwo, że dwie wartości będą przekształcone w podobne klucze, lecz z reguły dwa sąsiednie klucze będą miały zupełnie odmienne reprezentacje. Podobnie w tym przypadku indeksowaniu towarzyszy „wybieg” służący uniknięciu wąskich gardeł i gorących punktów w obsłudze indeksów, lecz i tym razem nie odbywa się to bez kosztu. Zastosowanie indeksu haszującego pozostawia jedynie możliwość wyszukiwania jednoznacznego (warunek równości). Innymi słowy, wyszukiwanie zakresowe, a właściwie każdy rodzaj zapytania wykorzystującego część klucza, nie będzie korzystać z indeksu. Z drugiej strony warunki równości są w indeksach haszujących rozstrzygane z bardzo dużą prędkością. Mimo tego że istnieją rozwiązania pozwalające zmniejszyć ryzyko rywalizacji o zasoby, warto rozważyć minimalizację wykorzystania identyfikatorów generowanych automatycznie. Zdarza się, że w praktycznych zastosowaniach indeksy automatyczne spotyka się dokładnie w każdej tabeli (na przykład w postaci specjalnej kolumny detail_id w tabeli order_details opisanej
110
ROZDZIAŁ TRZECI
wyżej, zamiast naturalnego klucza w postaci połączenia kolumn order_id i kolejnego numeru pozycji w ramach zamówienia). Tego typu uogólnione podejście najzwyczajniej w świecie nie jest uzasadnione, szczególnie w przypadku tabel, do których inne tabele posiadają więzy integralności. Klucze generowane automatycznie bywają korzystne w określonych sytuacjach, należy jednak wystrzegać się wykorzystywania ich w sposób bezkrytyczny!
Niejednolitość dostępu do indeksów Powszechnym mitem jest stwierdzenie, że jeśli zapytanie wykorzystuje indeks, to oznacza, że wszystko jest w najlepszym porządku. Nie jest to jednak prawda: dostęp do indeksów może mieć zupełnie niejednorodne charakterystyki. Oczywiście najwydajniejszy dostęp do indeksu wykorzystuje indeks unikalny, w którym pojedyncza wartość warunku powoduje dopasowanie co najwyżej jednego wiersza. Najczęściej tego typu sytuacja ma miejsce w przypadku klucza głównego. Jednakże, jak można się dowiedzieć w rozdziale 2., użycie klucza głównego w przypadku niektórych operacji to nieoptymalny sposób. Chodzi tu o przeszukanie wszystkich wierszy tabeli. Tego typu sytuację można porównać do użycia łyżeczki w celu przerzucenia wielkiej kupy piasku — zamiast wykorzystania łopaty w postaci pełnego przeszukiwania sekwencyjnego. Zatem na poziomie taktycznym najbardziej efektywny dostęp do indeksu polega na warunkach wydobywających unikalne wartości. Jednak szersze spojrzenie na problem ujawnia, że również i to uproszczenie może okazać się bardzo mylące. Gdy pojedynczy klucz jest dopasowany do kilku wierszy w nieunikalnym indeksie (lub gdy mamy do czynienia z wyszukiwaniem zakresu wartości w kluczu unikalnym), mamy do czynienia z przeszukiwaniem zakresowym (range scanning). W tej sytuacji możemy otrzymać serię wierszy z tabeli zawierających klucze spełniające warunek. Tego typu indeks może być prawie unikalny, to znaczy taki, w którym prawie wszystkie klucze odpowiadają pojedynczym wierszom, za wyjątkiem określonej, niewielkiej liczby kluczy, którym odpowiada kilka wierszy w tabeli. Może to również być przeciwne ekstremum tej sytuacji, to znaczy przypadek, gdy wszystkie wiersze w tabeli posiadają dokładnie taką samą wartość klucza nieunikalnego.
DZIAŁANIA TAKTYCZNE
111
Poindeksowanie w tabeli kolumny, która we wszystkich wierszach przyjmuje tę samą wartość, przywodzi na myśl niektóre programy, gdzie mamy do czynienia sytuacją, w której prawie wszystkie kolumny są poindeksowane tak „na wszelki wypadek”. Zawsze należy pamiętać, że wyszukiwanie wiersza w indeksie ma miejsce wyłącznie przy spełnieniu następujących warunków: 1. Kryteria wyboru uwzględniają wyłącznie wartości z indeksu klucza. 2. Indeks nie jest skompresowany, innymi słowy, znalezienie dopasowania w indeksie nie jest jedynie sugestią, że dokładnie ta sama wartość występuje w odpowiedniej kolumnie tabeli. We wszystkich innych przypadkach znalezienie wartości w indeksie to dopiero połowa pracy, silnik bazy danych musi jeszcze przejrzeć bloki (strony) wskazane przez wartość odnalezioną w indeksie pod kątem spełniania pozostałych kryteriów. I ponownie może się okazać, że wydajność będzie zupełnie niejednorodna — w zależności od tego, czy wyszukiwane dane znajdują się w ciągłym obszarze na dysku, czy też są rozrzucone w zupełnie nieuporządkowany sposób. Powyższy opis dotyczy „typowego” dostępu do indeksu. Jednakże inteligentny optymalizator zapytań może zdecydować, że indeks zostanie użyty na inny sposób. Może na przykład wykorzystać kilka indeksów, łącząc je i wykonując operacje wstępnego filtrowania przed rozpoczęciem odczytu wierszy. Optymalizator może też podjąć decyzję o wykonaniu pełnego sekwencyjnego przeszukiwania indeksu, na przykład w wyniku oszacowania, że taka metoda będzie najwydajniejsza ze wszystkich metod wykonania zapytania (nie będziemy się tu wdawać w dyskusję na temat tego, co dokładnie oznacza określenie „najbardziej wydajne”). Optymalizator zapytań może zdecydować, że adresy wierszy zostaną odczytane z indeksu kolejno, bez zagłębiania się w strukturę drzewa. Z tych rozważań wynika następujący wniosek: z faktu, że w planie wykonawczym zapytania pojawia się informacja o tym, iż zostanie użyty indeks, nie wynika jeszcze, że wykonanie będzie najbardziej optymalne z możliwych. Niektóre operacje wyszukiwania z użyciem indeksów rzeczywiście działają bardzo szybko, ale niektóre są bardzo powolne. Jednak nawet szybki dostęp do danych w zapytaniu nie gwarantuje jeszcze, że w połączeniu z innym zapytaniem dostęp ten nie okaże się jeszcze szybszy.
112
ROZDZIAŁ TRZECI
Dodatkowo, gdy optymalizator zapytań jest odpowiednio skuteczny i radzi sobie z decyzjami unikania wykorzystania indeksów w tych przypadkach, kiedy nie poprawią wydajności, ten sam bezużyteczny indeks musi jednak być obsługiwany przy wszelkich operacjach modyfikujących wartości w odpowiadających mu kolumnach. Obsługa indeksu jest dodatkowym narzutem obniżającym wydajność, co jest szczególnie dotkliwe przy operacjach masowych modyfikacji danych, z reguły wykonywanych przez operacje wsadowe. Niezależnie zatem od tego, czy indeks jest używany, czy nie, jego istnienie wpływa na obniżenie wydajności operacji modyfikujących w bazie danych. Indeksowanie nie jest złotym środkiem na uzyskanie wysokiej wydajności: efektywne wykorzystanie indeksów jest uzależnione od zrozumienia znaczenia posiadanych danych oraz operacji, jakim będą poddawane. Na podstawie tej wiedzy należy podjąć stosowne decyzje.
ROZDZIAŁ CZWARTY
Manewrowanie Projektowanie zapytań SQL There is only one principle of war, and that's this. Hit the other fellow, as quickly as you can, as hard as you can, where it hurts him most, when he ain't lookin'. Na wojnie istnieje tylko jedna zasada: uderz przeciwnika tak szybko, jak potrafisz, tak mocno, jak dasz radę, uderz go tam, gdzie najbardziej zaboli i wtedy, gdy się tego nie spodziewa. — Marszałek Polny Sir William Slim (1891 – 1970), cytując anonimowego majora.
114
W
ROZDZIAŁ CZWARTY
tym rozdziale przyjrzymy się bliżej zapytaniom SQL pod kątem ich tworzenia w zależności od potrzeb taktycznych, czyli dostosowania do danej sytuacji. Będziemy analizować skomplikowane zapytania i zastanawiać się, czy ma sens rozbijanie ich na mniejsze, wzajemnie powiązane części, które w całości dadzą oczekiwany wynik.
Natura SQL-a Zanim zajmiemy się szczegółową analizą zapytań, warto poznać kilka ogólnych cech samego języka SQL: w jaki sposób odwołuje się do silnika bazy danych i optymalizatora oraz jakie czynniki mogą wpłynąć niekorzystnie na wydajność optymalizatora.
SQL i bazy danych Relacyjne bazy danych zaistniały dzięki pracy E.F. Codda nad teorią relacyjną. Prace Codda zaowocowały stworzeniem bardzo silnego, matematycznego modelu zjawisk, które były od dawna znane i wykorzystywane w sposób intuicyjny. Posłużę się analogią: od tysięcy lat ludzkość budowała mosty łączące brzegi rzek, lecz często te struktury bywały konstrukcyjnie przesadzone, ponieważ architekci nie znali prawdziwych zależności między trwałością materiałów zastosowanych do budowy, samą konstrukcją a trwałością gotowej budowli. Wraz z rozwojem nauk inżynierskich i powstaniem solidnych podstaw teoretycznych opartych na wiedzy o trwałości materiałów zaczęły pojawiać się mosty o bardziej optymalnych konstrukcjach, a jednocześnie wysokich walorach bezpieczeństwa, wykorzystujące przy tym różnorodne materiały konstrukcyjne. Rzeczywiście niesamowite rozmiary niektórych mostów budowanych współcześnie można porównać z gigantycznym przyrostem ilości danych, jakie są w stanie przetwarzać nowoczesne bazy danych. Teoria relacyjna stała się dla baz danych tym, czym inżynieria dla budowy mostów. Bardzo powszechne jest zjawisko mieszania pojęć SQL-a, baz danych i modelu relacyjnego. Funkcją bazy danych jest przede wszystkim przechowywanie danych zgodnie z modelem opartym na zjawiskach świata rzeczywistego, z których dane są pozyskiwane. W związku z tym baza danych musi zapewniać solidną infrastrukturę pozwalającą na jednoczesne wykorzystywanie zapisanych w niej danych przez większą liczbę
MANEWROWANIE
115
użytkowników bez poświęcania integralności danych wskutek wprowadzanych w nich zmian. To powoduje, że baza danych powinna być w stanie obsłużyć sytuację rywalizacji o dostęp do danych ze strony wszystkich użytkowników, a w skrajnych przypadkach zapewnić, że dane w niej pozostaną spójne nawet w sytuacji, gdy operacja ich modyfikacji zostanie przerwana w trakcie realizacji. Baza danych często obsługuje również wiele innych zadań, które są jednak poza zakresem tematyki tej książki. Jak wskazuje nazwa, Structured Query Language (strukturalny język zapytań), czyli SQL, jest jedynie jednym z licznych języków programowania, choć z pewnością jego szczególną cechą jest znaczne zbliżenie do potrzeb baz danych. Postrzeganie języka SQL na równi z relacyjną bazą danych lub, co gorsza, z samą teorią relacyjną jest jednak nieporozumieniem porównywalnym z założeniem, że absolwent informatyki musi być wykwalifikowanym specjalistą od obsługi arkuszy kalkulacyjnych lub procesorów tekstu. Istnieją bowiem przypadki, gdy język SQL jest obsługiwany przez produkty niebędące serwerami baz danych1. Zanim SQL zanim stał się standardem, musiał „stoczyć bój” z alternatywnymi językami zapytań, jak RDO czy QUEL, które, na marginesie, były przez niektórych purystów uznawane za doskonalsze od SQL-a. Każda próba rozwiązania tego, co ogólnie nazwę „problemem SQL-owym”, wymaga uwzględnienia dwóch elementów: wyrażenia w języku SQL definiującego operację oraz optymalizatora zapytań. Te dwa elementy zapytania współpracują wzajemnie na trzech niezależnych płaszczyznach, co przedstawia rysunek 4.1. W środku znajduje się teoria relacyjna oparta na czystych twierdzeniach matematycznych. Nieco upraszczając całą sytuację, można by stwierdzić, że teoria relacyjna (obok wielu innych cennych funkcji) służy poinformowaniu nas o tym, że dane spełniające określone kryteria możemy odczytać z bazy z użyciem kilku operatorów relacyjnych i że ten niewielki zbiór operatorów pozwala uzyskać odpowiedź na praktycznie dowolne pytanie. Co ważniejsze, ponieważ teoria relacyjna jest tak ściśle oparta na matematyce, możemy być zupełnie pewni, że równoważne wyrażenia relacyjne zapisane w odmienny sposób zawsze muszą dać identyczny wynik. Chodzi dokładnie o spostrzeżenie, że 246/369 daje identyczny wynik jak 2/3. 1
Dobrym przykładem jest tu SQLite, unikalny mechanizm zapisu danych w pojedynczym pliku, pozwalający na wykorzystanie SQL-a do manipulacji nimi, ponad wszelką wątpliwość niebędący serwerem bazy danych.
116
ROZDZIAŁ CZWARTY
RYSUNEK 4.1. Elementy składowe systemu zarządzania bazami danych
Jednakże mimo kluczowego znaczenia teorii relacyjnej istnieją zagadnienia o znaczeniu praktycznym, o których teoria relacyjna nie wspomina. Te zagadnienia należą do dziedziny określanej przeze mnie jako „wymagania raportowe”. Najbardziej oczywisty przykład z tej dziedziny dotyczy porządkowania zbiorów rekordów. Teoria relacyjna zajmuje się wyłącznie odczytywaniem poprawnych zbiorów danych zdefiniowanych w zapytaniu. Jednak my, praktycy, nie jesteśmy teoretykami. Dla nas relacyjna faza odczytu danych z bazy kończy się na prawidłowym zidentyfikowaniu wierszy należących do zbioru wynikowego. Zagadnienie związków pewnych atrybutów (kolumn) jednego wiersza z analogicznymi atrybutami innego wiersza nie należy do tej fazy, w tym miejscu znaczenie ma wymuszenie kolejności. Teoria relacyjna nie wspomina o licznych funkcjach statystycznych (jak wyliczenia procentów itp.) powszechnie dostępnych w różnych dialektach języka SQL. Teoria relacyjna działa na zbiorach i nie zajmuje się kolejnością w tych zbiorach. Mimo tego że istnieje wiele teorii matematycznych zajmujących się porządkowaniem danych, żadna z nich nie znalazła odzwierciedlenia w teorii relacyjnej. Na tym etapie należy zaznaczyć różnicę między operacjami relacyjnymi a wspomnianymi przeze mnie wymaganiami raportowymi. Operatory relacyjne mają zastosowanie wyłącznie do zbiorów matematycznych o potencjalnie nieskończonej liczebności. Kryterium filtrowania można zastosować w dokładnie ten sam sposób na tabelach składających się z dziesięciu wierszy, a także z miliona, a nawet miliarda. Interesują nas wyłącznie dane spełniające kryteria selekcji. Etap selekcji jeszcze obejmuje działanie teorii relacyjnej. W momencie, gdy zechcemy posortować wiersze (lub wykonać na nich inne działanie, jak grupowanie i sumy częściowe, które przez większość osób są uznawane za operacje relacyjne)
MANEWROWANIE
117
nie działamy już na potencjalnie nieskończonym zbiorze, lecz, z definicji, na zbiorze skończonym. Z tego powodu wynikowy zbiór danych nie jest już relacją z matematycznego punktu widzenia. Nasze działania odbywają się w tym momencie poza zasięgiem działania teorii relacyjnej. Oczywiście nie oznacza to, że nie mamy już do dyspozycji ciekawych i użytecznych możliwości wykonywania operacji na tych danych z użyciem SQL-a. W pewnym uproszczeniu zapytanie SQL można przedstawić w postaci dwóch warstw, jak na rysunku 4.2. Najpierw rdzeń relacyjny identyfikuje zbiór danych, na którym wykonuje zadania, po czym warstwa nierelacyjna wykonuje działania na skończonym zbiorze danych, nadając mu ostateczny kształt i generując formę, jakiej oczekuje użytkownik.
RYSUNEK 4.2. Warstwy logiczne zapytania SQL
Mimo prostoty schematu przedstawionego na rysunku 4.2 zapytania SQL z reguły bywają znacznie bardziej skomplikowane. To jedynie ogólny zarys filozofii działania zapytań. Filtr relacyjny z rysunku może wystąpić w postaci wielu niezależnych filtrów połączonych w jedno za pomocą unii lub w postaci podzapytań. Poziom komplikacji niektórych struktur SQL-a potrafi być dość znaczny. Do zagadnień związanych z kodem SQL-a wrócę za chwilę. Najpierw jednak mam zamiar omówić związek między implementacją fizyczną a optymalizatorem zapytań. Nie należy mylić relacyjnej natury warstwy generowania wyników zapytań SQL z warstwą ich prezentacji.
118
ROZDZIAŁ CZWARTY
SQL i optymalizator Silnik SQL-a, otrzymując zapytanie do przetworzenia, wysyła je do optymalizatora, którego zadaniem jest znalezienie najbardziej optymalnego sposobu realizacji. W tym miejscu do głosu ponownie dochodzi teoria relacyjna, ponieważ to ona jest wykorzystywana przez optymalizator do dokonania transformacji zapytania w inne, równoważne pod względem wyników, ale skuteczniejsze z punktu widzenia wydajności. Oczywiście tak powinno się dziać z każdym semantycznie poprawnym zapytaniem, nawet napisanym w sposób niezbyt elegancki. Optymalizacja uwzględnia fizyczną implementację zasobu danych. W zależności od tego, czy istnieją indeksy i czy mają zastosowanie w danym zapytaniu, niektóre przekształcenia mogą prowadzić do znacznego przyspieszenia zapytania w porównaniu z innymi ich semantycznymi odpowiednikami. W rozdziale 5. omówię różne modele zapisu zasobów danych, niektóre z nich mogą prowadzić do jednoznacznego wyboru określonego sposobu wykonania zapytania. Optymalizator analizuje możliwość zastosowania dostępnych indeksów, fizyczne rozłożenie danych, ilość dostępnej pamięci oraz liczbę procesorów, które mogą być wykorzystane w wykonaniu zapytania. Optymalizator bierze również pod uwagę rozmiar tabel i indeksów biorących udział w zapytaniu w sposób bezpośredni lub pośredni (za pośrednictwem perspektyw). Poprzez analizę alternatyw, zgodnie z teorią relacyjną będących równoważnymi z oryginalnym zapytaniem, optymalizator wybiera najlepsze (jego zdaniem) do wykonania tego zapytania. Należy jednak mieć na uwadze, że choć optymalizator nie zawsze jest zupełnie bezbronny na nierelacyjnej warstwie SQL-a, swoją rzeczywistą wartość demonstruje z reguły dopiero na warstwie relacyjnej. Dzieje się tak właśnie dzięki precyzyjnej, matematycznej definicji teorii relacyjnej. Praktyka przekształcania zapytań SQL w inne stanowi dobitne podkreślenie ważnego faktu: tego, że SQL jest językiem deklaratywnym. Innymi słowy, w SQL-u należy wyrażać definicję oczekiwanych wyników, a nie tego, w jaki sposób mają być wydobywane. Przejście od pytania „co” do pytania „jak” jest, przynajmniej w teorii, zadaniem optymalizatora. Z rozdziałów 1. i 2. można jasno wywnioskować, że zapytania SQL są jedynie elementami układanki, lecz nawet przy działaniu na poziomie taktycznym kiepsko napisane zapytanie może utrudnić optymalizatorowi znalezienie jego optymalnego odpowiednika. Należy pamiętać, że
MANEWROWANIE
119
matematyczne podstawy teorii relacyjnej stanowią niepodważalną logikę działań optymalizatora. Z tego powodu jedną z ról SQL-a jest minimalizacja grubości warstwy nierelacyjnej, ponieważ w niej właśnie optymalizator ma mniejsze pole do popisu, gdyż nie ma tu matematycznych podstaw, których mógłby się trzymać w celu zapewnienia pełnej równoważności wyników. Inną cechą SQL-a jest to, że przy wykonywaniu operacji nierelacyjnych (które możemy w przybliżeniu określić jako te, dla których znany jest już kompletny zestaw danych) musimy być szczególnie ostrożni, aby ograniczyć się tylko do tych danych, które są absolutnie niezbędne w celu uzyskania wyniku. Skończone zbiory danych muszą zostać zapisane w jakiś sposób (w pamięci lub na dysku), zanim zostaną poddane dalszym, nierelacyjnym operacjom. Powoduje to powstanie narzutu na wydajności, spowodowanego przesyłaniem danych. Ten narzut zwiększa się znacznie wraz ze zwiększaniem się rozmiarów skończonych zbiorów danych, szczególnie w przypadku, gdy nie jest już dostępna pamięć operacyjna, w której mogłyby zostać przechowane. Taka sytuacja prowadzi do operacji zapisu na dysku części zawartości pamięci (w przestrzeni wymiany), co powoduje jeszcze większe opóźnienia. Co więcej, należy zawsze pamiętać o tym, że indeksy odnoszą się do adresów na dysku, a nie w pamięci wymiany, zatem w momencie, gdy dane znajdą się w obszarze wymiany, przestają być dostępne prawie wszystkie metody szybkiego dostępu do nich (być może za wyjątkiem haszowania). Niektóre odmiany SQL-a wprowadzają użytkownika w błąd, sugerując, że nadal działa na poziomie relacyjnym, podczas gdy w rzeczywistości już dawno działa na innej warstwie. Weźmy na przykład zapytanie: „Pięciu najlepiej zarabiających pracowników niebędących menedżerami”. Wydaje się ono dość praktyczne i ma duże szanse wystąpienia w rzeczywistym systemie informatycznym. Jednak to zapytanie ma dość mocne podłoże nierelacyjne. Identyfikacja pracowników, którzy nie są menedżerami, to pierwszy etap działania zapytania, jeszcze w warstwie relacyjnej. Z wyniku tego etapu uzyskujemy skończony zbiór, który można posortować. Niektóre dialekty SQL-a pozwalają ograniczyć liczbę zwracanych wierszy z użyciem kryteriów ograniczających w ramach instrukcji SELECT. Oczywiście zarówno sortowanie, jak i ograniczenie liczebności wyniku to operacje odbywające się poza warstwą relacyjną. Jednakże inne dialekty, Oracle jest tu doskonałym przykładem, wykorzystują inne mechanizmy.
120
ROZDZIAŁ CZWARTY
Oracle do każdego wyniku zapytania dodaje kolumnę o nazwie rownum, zawierającą kolejny numer wiersza w wyniku. Oznacza to, że numeracja wierszy jest generowana jeszcze na etapie relacyjnym. Dzięki temu możemy zastosować następujące zapytanie: select empname, from employees where status != and rownum <= order by salary
salary 'EXECUTIVE' 5 desc
W wyniku tego zapytania uzyskamy jednak niepoprawne wyniki: zamiast pięciu najlepiej opłacanych niemenedżerów dostaniemy pierwszych pięciu niemenedżerów znalezionych na etapie relacyjnym, czyli zanim zostały zastosowane kryteria sortowania. Sortowanie jest wykonywane dopiero na wyniku, czyli na tych pięciu wybranych pozycjach. To zapytanie demonstruje dość powszechny błąd popełniany przez początkujących użytkowników baz Oracle. Warto przyjrzeć się bliżej działaniu poprzedniego zapytania. Relacyjny element zapytania po prostu odczytuje pierwsze pięć wierszy (a z nich atrybuty ampname i salary) z tabeli employees, przy czym kolejność wierszy odczytanych z tabeli jest nieokreślona. Należy pamiętać, że teoria relacyjna przyjmuje założenie, iż relacja (a co się z tym wiąże tabela ją reprezentująca) nie ma zdefiniowanej kolejności występowania krotek (a co się z tym wiąże reprezentujących je wierszy w tabeli) zarówno dla operacji odczytu, jak i zapisu. W konsekwencji pracownik niebędący menedżerem o najwyższej pensji może nie zostać uwzględniony w wyniku i nie ma sposobu, aby „zmusić” to zapytanie do zwracania za każdym razem prawidłowych wyników. Chcemy po prostu uzyskać listę wszystkich niemenedżerów, posortować ich w kolejności malejącej według wysokości pensji, po czym z tego wyniku wydobyć pięć pozycji z początku listy. Możemy tego dokonać w następujący sposób: select * from (select empname, salary from employees where status != 'EXECUTIVE' order by salary desc) where rownum <= 5
MANEWROWANIE
121
Jak wygląda zatem struktura warstwowa tego zapytania? Wielu czytelników zapewne pokusi się o stwierdzenie, że po zastosowaniu filtrowania na posortowanym wyniku zapytania uzyskamy strukturę z rysunku 4.3.
RYSUNEK 4.3. Błędne rozumienie zapytania wybierającego „pięciu najlepiej zarabiających niemenedżerów”
W rzeczywistości jednak wygląda to raczej tak, jak na rysunku 4.4.
RYSUNEK 4.4. Rzeczywista struktura zapytania wybierającego „pięciu najlepiej zarabiających niemenedżerów”
122
ROZDZIAŁ CZWARTY
Zastosowanie konstrukcji przypominających operacje relacyjne nie zbliża nas wcale do świata relacyjności, ponieważ teoria relacyjna polega na stosowaniu operatorów relacyjnych na relacjach. Nasze podzapytanie wykorzystuje wymuszoną kolejność na zbiorze wartości. Po wymuszeniu kolejności nie mamy już jednak do czynienia z relacją (relacja jest zbiorem, a każdy zbiór ma niezdefiniowaną kolejność). W tym przypadku zewnętrzna selekcja wygląda jak operacja relacyjna, lecz nią nie jest w związku z tym, że jest wykonywana na wyniku wewnętrznej selekcji, niebędącym relacją (co jest spowodowane operatorem ORDER BY). Przykład z pięcioma najlepiej zarabiającymi pracownikami niebędącymi menedżerami jest oczywiście bardzo uproszczony, lecz powinien wystarczyć, aby zrozumieć, że po opuszczeniu obszaru objętego działaniem teorii relacyjnej nie ma już do niego powrotu. W najlepszym razie możemy wynik zapytania nierelacyjnego przekazać do obróbki innemu zapytaniu. Weźmy na przykład zapytanie: „W których departamentach pracuje pięciu najlepiej zarabiających pracowników niebędących menedżerami?”. Należy jednak zrozumieć, że niezależnie od tego, jak sprytnie napiszemy takie zapytanie, optymalizator nie ma żadnej możliwości połączenia tych zapytań ze sobą w jedną logiczną strukturę wykonawczą i najprawdopodobniej będą one wykonane po prostu sekwencyjnie. Co więcej, każdy zbiór wierszy powstały w wyniku etapu pośredniego będzie zapisany w tymczasowym zasobie (w pamięci lub na dysku), co jeszcze bardziej może ograniczać dostępne opcje optymalizacji. Wyjście poza warstwę relacyjną zmusza nas zatem do uważnego tworzenia zapytań, ponieważ w tym obszarze optymalizator zapytań SQL nie ma pełnych możliwości działania. Podsumowując: można powiedzieć, że najbezpieczniejsze podejście polega na jak najdłuższym utrzymaniu zapytania w warstwie relacyjnej, gdzie optymalizator osiąga swoją najwyższą wydajność. Gdy jednak zapytanie wykracza poza teorię relacyjną, należy jak najuważniej je konstruować. Zrozumienie, że SQL posiada dwie natury, jak Dr Jekyll, pozwala lepiej zrozumieć ten język i opanować go do mistrzostwa. Jeśli będziemy używać SQL-a jak miecza o pojedynczym ostrzu, pozostaniemy na etapie porad „dla opornych”, być może użytecznych, aby zaimponować niezorientowanym osobnikom płci przeciwnej (choć z własnego doświadczenia wiem, że nie na wiele to się przydaje), lecz niewiele przybliży nas to do osiągnięcia świadomości niezbędnej przy rozwiązywaniu skomplikowanych problemów z językiem SQL.
MANEWROWANIE
123
Optymalizator wynagradza tych, którzy pozostają w warstwie relacyjnej.
Ograniczenia optymalizatora Każdy porządny silnik SQL swoje działanie opiera w dużej mierze na optymalizatorze zapytań, który często sprawdza się bardzo dobrze. Jednakże istnieją pewne ograniczenia działania optymalizatora, które należy mieć na uwadze. Optymalizatory opierają swoje decyzje na danych znalezionych w bazie Te informacje są dwojakiego typu: ogólne dane statystyczne (które muszą być na bieżąco weryfikowane i aktualizowane) oraz kluczowe dane deklaratywne, wynikające z definicji danych. Jeśli kluczowe elementy semantyki danych są ukryte w wyzwalaczach lub, co gorsza, w kodzie aplikacji, te informacje nie będą dostępne dla optymalizatora. Tego typu okoliczności będą miały niewątpliwy wpływ na wydajność działania optymalizatora. Optymalizatory działają najwydajniej w tych sytuacjach, w których można zastosować przekształcenia matematyczne prowadzące do skonstruowania operacji semantycznie równoważnych Gdy optymalizator jest zmuszony do przeanalizowania elementów o naturze nierelacyjnej, istnieje mniejsza liczba zależności i możliwych przekształceń, co prowadzi do wyboru ścieżki wykonawczej dość zbliżonej do oryginalnego zapytania zasugerowanego przez programistę. Działanie optymalizatora wpływa na całkowity czas wykonania Porównanie oszacowanych czasów działania kilku różnych alternatywnych ścieżek wykonawczych zapytania może zająć chwilę. Użytkownik końcowy postrzega jedynie całkowity czas wygenerowania wyniku i nie widzi, jaka część z niego była spowodowana działaniem optymalizatora. Dobrze napisany optymalizator pozwoli sobie na więcej czasu na optymalizację zapytań, które będą potencjalnie wykonywać się dłużej, ale zawsze istnieje ograniczenie skuteczności działania tego typu mechanizmów. Problem polega na tym, że przy złączeniu dwudziestu tabel (co absolutnie nie jest niezwykłą lub nienormalną sytuacją) liczba możliwych kombinacji, jakie musi sprawdzić optymalizator, może stać się
124
ROZDZIAŁ CZWARTY
bardzo duża, nawet w przypadku, gdy istniejące indeksy upraszczają ścieżkę wykonawczą niektórych z elementów. W połączeniu ze skomplikowanymi perspektywami i podzapytaniami optymalizator musi w pewnym momencie ustąpić. Całkiem możliwa jest sytuacja, że zapytanie uruchomione samodzielnie wykona się w sposób bardzo optymalny, lecz zagrzebane w głębi innego, bardzo skomplikowanego zapytania spotka się z niewłaściwą analizą optymalizatora. Optymalizator usprawnia wykonanie pojedynczych zapytań Nie ma jednak możliwości wzajemnego powiązania poszczególnych zapytań składowych. Jeśli większość kodu w programie wykorzystuje dane wyjściowe z jednych zapytań jako dane wejściowe innych, optymalizator nie będzie w stanie wiele pomóc. Gdy optymalizator dostanie małe porcje, zoptymalizuje okruchy. Jeśli dostanie wielki kawałek kodu, zoptymalizuje całe zadanie.
Pięć czynników wpływających na skuteczność SQL-a W pierwszej części tego rozdziału można było zobaczyć, w jaki sposób SQL łączy w sobie cechy relacyjne i nierelacyjne. Można było też poznać wpływ tych dwóch cech SQL-a na wydajne (lub wręcz przeciwne) działanie optymalizatora. Od tego miejsca do końca rozdziału, opierając się na wiedzy z pierwszej jego części, będziemy skupiać się na kluczowych czynnikach wpływających na skuteczne wykorzystanie SQL-a. Moim zdaniem istnieje pięć takich czynników: • całkowita ilość danych, z których ma być uzyskany wynik, • kryteria zastosowane do zdefiniowania zbioru wynikowego, • rozmiar zbioru wynikowego, • liczba tabel przetwarzanych w celu uzyskania oczekiwanego zbioru wyników, • liczba innych użytkowników modyfikujących równolegle te same dane.
MANEWROWANIE
125
Całkowita ilość danych Wielkość danych, z których odbywa się wybór podzbioru, ma największy wpływ na wydajność zapytania. Plan wykonawczy spisujący się idealnie w tabeli pracowników (emp) zawierającej czternaście wierszy w połączeniu z czterowierszową tabelą departamentów (dept) może się zupełnie nie sprawdzić w przypadku tabeli finansowej (financial_flows) o piętnastu milionach wierszy w połączeniu z tabelą produktów (products) o pięciu milionach wierszy. Należy pamiętać, że nawet tabela zawierająca piętnaście milionów wierszy nie jest szczególnie wielka, biorąc pod uwagę standardy wielu firm. W konsekwencji trudno jest przewidzieć wydajność zapytania, zanim nie uruchomi się go na danych docelowych rzeczywistych rozmiarów.
Kryteria definiujące zbiór wynikowy Większość zapytań SQL wykorzystuje warunki filtrujące zdefiniowane w ramach klauzuli WHERE; w zapytaniu może wystąpić większa liczba klauzul WHERE, jedna w zapytaniu głównym i po jednej dla każdego podzapytania lub perspektywy (zwykłej lub osadzonej, ang. inline). Warunki filtrujące mogą być wydajne lub niewydajne. Jednak to, czy warunek jest wydajny, czy niewydajny zależy od wielu czynników, jak implementacja fizyczna (zobacz rozdział 5.) oraz, ponownie, ilość danych biorących udział w zapytaniu. Każde złożone zapytanie należy konstruować w częściach, biorąc pod uwagę filtrowanie, centralne instrukcje SQL oraz wpływ dużych zbiorów danych na wydajność zapytania. Temat filtrowania jest dość skomplikowany i warto poświęcić mu więcej czasu, dlatego wrócę do niego w dalszej części tego rozdziału, w podrozdziale zatytułowanym „Filtrowanie”.
Rozmiar zbioru wynikowego Ważnym, aczkolwiek często lekceważonym czynnikiem wpływającym na wydajność zapytań jest rozmiar zwracanych przez nie danych (lub rozmiar danych ulegających zmianie w wyniku zapytania). Na ten rozmiar mają wpływ rozmiar danych źródłowych oraz szczegóły kryteriów filtrujących, ale nie zawsze tak jest. Często jest to efekt połączenia kilku czynników, które z osobna mają niewielki wpływ na rozmiar danych wynikowych, ale razem sprawdzają się jako bardzo efektywne czynniki filtrujące.
126
ROZDZIAŁ CZWARTY
Na przykład można stwierdzić, że zapytanie zwracające nazwiska studentów posiadających zaliczenie z matematyki lub z plastyki da wynik dużych rozmiarów, ale po zastosowaniu obydwu tych kryteriów (zaliczenie z matematyki i z plastyki) może w efekcie spowodować, że rozmiar zbioru wynikowego będzie bardzo niewielki. W przypadku zapytań rozmiar wyniku ma mniejsze znaczenie z technicznego punktu widzenia, ale ma znaczenie z punktu widzenia percepcji użytkownika końcowego. Użytkownik zawęża swoją percepcję do oczekiwanego rozmiaru danych: prosząc o jedną igłę, nie zwraca się uwagi na rozmiar stogu siana. Skrajnym przypadkiem jest zapytanie zwracające pusty wynik; a dobry programista zawsze stara się pisać zapytania w taki sposób, żeby spodziewając się niewielu wierszy wyniku (lub wyniku pustego), zapytanie działało jak najkrócej. Trudno sobie wyobrazić bardziej frustrującą sytuację niż oczekiwanie przez wiele minut na komunikat „Nie znaleziono danych spełniających podane kryteria”. To jest szczególnie irytujące w przypadku błędu literowego popełnionego przy zadawaniu kryteriów — gdy spostrzeżemy go w chwili naciśnięcia klawisza Enter, a nie ma możliwości przerwania zapytania. Użytkownicy końcowi są skłonni poczekać, pod warunkiem że uzyskają dużą ilość wyników, ale z pewnością nie po to, żeby nie dostać ani kawałka danych. Jeśli każde kryterium filtrujące definiuje osobny zbiór wyników, a w naszym zapytaniu mamy uzyskać część wspólną z tych wszystkich podzbiorów (kryteria są połączone klauzulą AND), to w przypadku, gdy poszczególne kryteria tworzą niewielkie, w większości wzajemnie rozłączne podzbiory danych, istnieje duże prawdopodobieństwo, że w wyniku ich połączenia powstanie zbiór pusty. Innymi słowy: zbyt szczegółowe kryteria mogą powodować zwrócenie pustych wyników. Jeśli istnieje choćby cień podejrzeń, że zapytanie może zwrócić pusty wynik, w pierwszej kolejności należy sprawdzić kryterium o największym prawdopodobieństwie zwrócenia zbioru pustego, szczególnie jeśli można je sprawdzić bardzo szybko. Oczywiście nie ma potrzeby podkreślać, że kolejność analizy kryteriów filtrujących w dużym stopniu zależy od kontekstu, o czym szerzej w podrozdziale „Filtrowanie”. Doświadczony programista powinien starać się uzyskać czasy wykonania wprost proporcjonalne do ilości zwracanych danych.
MANEWROWANIE
127
Liczba tabel Liczba tabel biorących udział w zapytaniu również ma pewien wpływ na jego wydajność. Nie dzieje się tak jednak z tego powodu, że silnik bazy danych ma kłopot z wydajnymi złączeniami tabel, wręcz przeciwnie, operacje złączenia są bardzo dobrze zaimplementowane we współczesnych systemach zarządzania bazami danych.
Złączenia Wrażenie słabej wydajności złączeń jest jednym z mitów dotyczących relacyjnych baz danych. Istnieje przesąd, że należy unikać złączeń zbyt dużej liczby tabel; najczęściej określanym ograniczeniem jest liczba około pięciu. W rzeczywistości można z powodzeniem wykonać bardzo wydajne złączenie piętnastu tabel. Jednak złączenia większej liczby tabel wiążą się z pewnymi problemami, oto wybrane przykłady: • Jeśli w aplikacji złączenia kilkunastu tabel zdarzają się zwyczajowo, należy zastanowić się nad jakością projektu bazy danych. Należy pamiętać o tym, co stwierdziłem w rozdziale 1.: wiersz w tabeli stanowi pewną formę prawdziwego stwierdzenia i można go porównać z aksjomatem matematycznym. Łącząc tabele, pozyskujemy inne stwierdzenia prawdziwe. Istnieje jednak punkt, w którym należy zastanowić się, czy mamy do czynienia z prawdą oczywistą, którą nadal można nazwać aksjomatem, czy ta prawda jest już mniej oczywista i musimy ją uzupełniać o inne elementy, aby się taka stała. Gdy w większości przypadków prawdę trzeba uzupełniać, oznacza to, że mamy do czynienia z kiepskim doborem aksjomatów. • Dla optymalizatora poziom komplikacji zwiększa się wykładniczo wraz ze wzrostem liczby tabel. Przypomnę, że większość czasu wykonania zapytania może być udziałem analizy przeprowadzanej przez optymalizator, szczególnie w przypadku zapytania wykonywanego po raz pierwszy. W przypadku dużej liczby tabel biorących udział w zapytaniu optymalizator ma do sprawdzenia większą ilość ścieżek wykonawczych. Jeśli zapytanie jest napisane w sposób celowo upraszczający pracę optymalizatora, istnieje zasada, że im bardziej skomplikowane zapytanie, tym większa obawa, że optymalizator podejmie niewłaściwą decyzję.
128
ROZDZIAŁ CZWARTY
• Pisząc skomplikowane zapytanie wykorzystujące dużą liczbę tabel, podczas gdy złączenia są pisane na różne sposoby, istnieje duża szansa, że zasugerujemy optymalizatorowi błędną ścieżkę wykonawczą. Łącząc tabele A z B z C z D, dajemy za mało informacji o tym, że tabela A może być złączona z D w sposób bardzo wydajny, szczególnie jeśli to złączenie okaże się przypadkiem szczególnym. Mało doświadczony programista usiłujący usunąć duplikaty za pomocą klauzuli DISTINCT prawdopodobnie po prostu pominął jeden warunek złączenia.
Skomplikowane zapytania i skomplikowane perspektywy Należy mieć na uwadze, że pozorna liczba tabel biorących udział w zapytaniu może być bardzo myląca, niektóre z tabel mogą być perspektywami, często dość skomplikowanymi. Perspektywy, podobnie jak zapytania, mogą mieć różne poziomy komplikacji. Można ich używać między innymi do ukrywania przed użytkownikami kolumn i wierszy. Mogą również służyć jako alternatywne spojrzenie na dane, do budowania relacji z istniejących relacji w postaci tabel. W takich przypadkach perspektywa może być postrzegana jako skrócony zapis zapytania i takie jest najbardziej powszechne użycie perspektyw. Im zapytanie staje się bardziej skomplikowane, tym częściej pojawia się chęć zapisania go w uproszczonej postaci i zastąpienia podzapytań nazwami perspektyw. Niewielki poziom komplikacji zapytania może wprowadzać w błąd, maskując poziom komplikacji użytych w nim perspektyw.
Jak to często bywa z kategorycznymi decyzjami, absurdem byłoby całkowicie zakazywać stosowania perspektyw. Wiele z nich to dość niegroźne konstrukcje. Jednak należy wziąć pod uwagę, że jeśli perspektywa jest wykorzystywana w skomplikowanym zapytaniu, z reguły oznacza to, że jesteśmy zainteresowani zaledwie ułamkiem wyniku oryginalnej perspektywy: być może kilkoma kolumnami z o wiele większej liczby. Optymalizator może podjąć próbę połączenia prostej perspektywy z głównym zapytaniem, aby podjąć się optymalizacji całości. Jednakże w przypadku skomplikowanych perspektyw może okazać się, że poziom komplikacji takiego rozwinięcia perspektyw w głównym zapytaniu doprowadzi do powstania tworu przekraczającego możliwości skutecznego wykonania pracy optymalizatora.
MANEWROWANIE
129
W niektórych przypadkach perspektywa może być napisana w taki sposób, że skutecznie zapobiegnie możliwości połączenia jej z zapytaniem głównym w ramach działań optymalizatora. Wspominałem już o kolumnach rownum będących wynalazkiem wprowadzonym w bazach Oracle w celu numerowania wierszy wyniku. Gdy kolumny rownum są wykorzystywane w zapytaniu głównym i w perspektywie, poziom komplikacji znacznie wzrasta. Każda próba połączenia takiej perspektywy z zapytaniem głównym będzie skutkowała zakłóceniem numeracji w kolumnach rownum, a w konsekwencji spowoduje, że optymalizator odrzuci możliwość połączenia perspektywy z zapytaniem głównym. W skomplikowanych zapytaniach tego typu perspektywa musi być wygenerowana osobno od wykonania reszty zapytania. W wielu przypadkach optymalizator bazy danych potraktuje perspektywę jako niezależną instrukcję2, uruchamiając ją jako osobny etap w procedurze wykonawczej zapytania i wykorzystując tylko te elementy perspektywy, które będą potrzebne. Często zdarza się, że wiele operacji wykonywanych w ramach perspektywy (z reguły są to na przykład złączenia wydobywające opisy powiązane z kodami kategorii) jest bez znaczenia dla zapytania głównego. Może też zdarzyć się, że zapytanie zawiera specjalne kryteria filtrujące, które mogą mieć wpływ na zapytanie definiujące perspektywę. Na przykład może okazać się, że unia w ramach perspektywy będzie zupełnie zbędna (gdy sama perspektywa stanowi unię wielu tabel reprezentujących podtypy), ponieważ główne zapytanie po prostu filtruje z tej unii jeden podtyp (czyli elementy jednego z elementów unii). Istnieje również zagrożenie, że złączenia perspektywy z tabelą występującą w definicji perspektywy spowodują konieczność wykonania wielu przebiegów przeszukiwania tej tabeli i potencjalnie odczytu tego samego wiersza wielokrotnie, gdy w zupełności wystarczyłby jeden przebieg. Gdy perspektywa zwraca znacznie więcej danych, niż potrzeba w kontekście zapytania głównego, można uzyskać znaczne przyspieszenie przez eliminację perspektywy (lub zastąpienie jej prostszą). W pierwszym etapie w zapytaniu głównym wywołanie perspektywy zastępuje się jej definicją w postaci podzapytania. Mając przed oczami wszystkie elementy perspektywy, łatwiej jest usunąć z jej definicji te, które nie mają znaczenia dla wyniku 2
Optymalizator może jednak spróbować przesunąć część kryteriów zapytania głównego do perspektywy (modyfikując jej kryteria).
130
ROZDZIAŁ CZWARTY
zapytania. Często właśnie te zbędne elementy definicji perspektywy uniemożliwiają optymalizatorowi podstawienie jej definicji w kodzie zapytania. Uproszczona wersja perspektywy, ograniczona do niezbędnych elementów może dać znacznie lepsze wyniki pod kątem wydajności zapytania głównego. Po zredukowaniu zapytania do niezbędnych elementów uzyskamy znacznie szybsze jego działanie. Wielu programistów może obawiać się przesuwania kodu skomplikowanej perspektywy do już i tak skomplikowanego zapytania głównego. Nie chodzi tu wyłącznie o obawę przed komplikacją tego, co już jest wystarczająco skomplikowane. Praca nad tworzeniem i optymalizacją skomplikowanych zapytań to dość żmudne zadanie. Jest to jednak zadanie przypominające rozwiązywanie wzorów matematycznych znane ze szkoły średniej. W mojej opinii jest to ćwiczenie uczące dyscypliny w tworzeniu zapytań i warto opanować je do perfekcji. Dzięki temu można doskonale poznać wewnętrzne zasady funkcjonowania zapytań, co jest szczególnie cenne dla programistów chcących stale usprawniać swój warsztat. Często bywa również, że opanowanie tajników tworzenia optymalnych zapytań pozwala tworzyć bardzo wydajne zapytania, co z kolei pozwala oszczędzić mnóstwo czasu. Jeśli perspektywa zwraca zbędne elementy, zamiast osadzać ją jako podzapytanie w ramach zapytania głównego, można spróbować włączyć poszczególne elementy perspektywy bezpośrednio do zapytania.
Liczba równoległych użytkowników Wielodostęp jest również jednym z czynników, które warto brać pod uwagę przy pisaniu kodu SQL. Ma to szczególne znaczenie, gdy w grę wchodzą problemy związane z wielodostępem do danych, jak efekt konkurowania o zasoby, blokady (chodzi o wewnętrzne zasoby bazy systemu zarządzania bazami danych) oraz inne, mniej oczywiste efekty uboczne. Nawet spójność procedury odczytu może prowadzić do zjawiska konkurowania o zasoby. Każdy serwer, niezależnie od tego, jakie ma parametry sprzętowe, zawsze będzie cechował się skończonymi możliwościami. Idealny plan wykonawczy zapytania w systemie, w którym nie występuje wielodostęp, będzie znacząco różnił się od idealnego planu w systemie o wysokim poziomie wielodostępu. Operacje sortowania mogą oczekiwać na zwolnienie pamięci niezbędnej do ich wykonania lub, zamiast
MANEWROWANIE
131
wykorzystywać szybką pamięć operacyjną, będą zmuszone do zapisu na dysku twardym, tworząc dodatkowe źródło problemów z konkurowaniem o zasoby. Niektóre operacje intensywnie wykorzystujące procesor, jak obliczanie skomplikowanych funkcji, cykliczne przeszukiwanie tych samych bloków indeksu itp., mogą spowodować, że komputer ulegnie przeciążeniu. Widywałem przypadki, gdy większe obciążenie operacjami wejścia-wyjścia w efekcie dawało lepszą efektywność wykonania zadań. Chodziło o to, że przeciążony procesor nie był w stanie wykonać wszystkich zadań, ale gdy któreś z nich natrafiło na czasochłonną operację wejścia-wyjścia, tymczasowo zwalniało procesor, który mógł w tym czasie wykonać inne operacje. Należy myśleć w kategoriach globalnej przepustowości, nie w kategoriach indywidualnych czasów reakcji. UWAGA Bardziej szczegółową analizę zagadnień związanych z wielodostępem można znaleźć w rozdziale 9.
Filtrowanie Sposób ograniczenia zbioru wynikowego jest jednym z najbardziej krytycznych czynników pozwalających wpłynąć na taktykę tworzenia zapytania SQL. Kryteria filtrujące dane są często postrzegane jako przypadkowa zbieranina warunków zgrupowanych w ramach klauzuli WHERE. Jednak przy pisaniu kodu SQL warto bardziej skupić się na klauzuli WHERE (a przy okazji również klauzuli HAVING).
Znaczenie warunków filtrujących Biorąc pod uwagę składnię języka SQL, całkiem naturalne jest postrzeganie wszystkich wyrażeń występujących w ramach klauzuli WHERE jako zbliżonych w swej naturze. Jednak absolutnie tak nie jest. Niektóre warunki filtrujące oddziałują bezpośrednio na operator selekcji z teorii relacyjnej, gdzie następuje sprawdzenie kolumny każdego wiersza (puryści woleliby określenie: atrybutu zmiennej relacyjnej) pod kątem dopasowania (lub niedopasowania) do warunku. Jednak klauzula WHERE obsługuje również warunki implementujące operator złączenia, czyli JOIN. Wraz z opracowaniem składni SQL92 wprowadzono odmienną formę definiowania warunków złączenia, które umieszcza się między główną klauzulą FROM a klauzulą
132
ROZDZIAŁ CZWARTY
WHERE, natomiast warunki filtrowania są umieszczane w ramach tej klauzuli WHERE. Złączenie dwóch (lub większej liczby) relacji tworzy nową relację.
Weźmy pod uwagę następujący przykład złączenia: select ... from t1 inner join t2 on t1.join1 = t2.joind2 where ...
Czy warunek dotyczący kolumny c2 należącej do tabeli t2 powinien pojawić się w ramach definicji złączenia, co wskazywałoby na fakt łączenia tabeli t1 z podzbiorem tabeli t2? Czy raczej powinien pojawić się w warunku filtrującym, wraz z innymi warunkami dotyczącymi kolumn tabeli t1, co obejmowałoby wszystkie ograniczenia wyniku złączenia tabel t1 i t2? Niezależnie od tego, jaka będzie decyzja o umieszczeniu takiego warunku, nie powinna mieć ona wpływu na działanie zapytania, choć zdarza się, że w oparciu o tego typu różnice niektóre optymalizatory generują różne ścieżki wykonawcze zapytań. Istnieje również możliwość zastosowania warunków innych niż złączenia i proste filtrowanie wartości. Na przykład możemy mieć warunki ograniczające wynikowy zbiór wierszy do określonego podtypu, możemy mieć również warunki potrzebne po prostu do sprawdzenia istnienia określonej wartości w danej tabeli. Wszystkie te warunki nie muszą być semantycznie identyczne, choć składnia SQL-a powoduje, że wyglądają podobnie. W niektórych przypadkach kolejność wyliczania warunków nie ma znaczenia, w innych przypadkach jest zupełnie przeciwnie. Oto przykład, który można znaleźć w wielu komercyjnych aplikacjach. Demonstruje on znaczenie kolejności wyliczania warunków. Załóżmy, że mamy tabelę parametrów (parameters) zawierającą następujące kolumny: parameter_name, parameter_type i parameter_value, gdzie parameter_value jest tekstową reprezentacją wartości zapisanego parametru, zgodnie z typem określonym w kolumnie parameter_type. Tego typu sytuacja powinna od razu być alarmująca dla każdego logicznie rozumującego czytelnika. Wartości kolumny parameter_value są bowiem wartościami złożonymi (przekształconymi z wartości podstawowych typów na typ tekstowy), a co się z tym wiąże, stanowią naruszenie podstawowej zasady relacyjności.
MANEWROWANIE
133
Wywołamy następujące zapytanie: select * from parameters where parameter_name like '%size' and parameter_type = 'NUMBER'
W tym zapytaniu nie ma znaczenia, czy pierwszy warunek jest wyliczany przed drugim. Jednak załóżmy, że dodamy następujący warunek, gdzie int() jest funkcją przekształcającą ciąg znaków w wartość całkowitą: and int(parameter_value) > 1000
W tym przypadku kolejność wyliczania warunków ma znaczenie, ponieważ warunek związany z kolumną parameter_type musi być wyliczony w pierwszej kolejności, w przeciwnym razie wywołanie funkcji int() może spowodować błąd wykonawczy (gdy parameter_value będzie ciągiem znaków niereprezentującym poprawnej liczby, ponieważ parameter_type dla danego parametru ma wartość char). Optymalizator może mieć problem z domyśleniem się intencji twórcy tego kiepskiego projektu, czyli nie będzie w stanie odgadnąć, że warunek sprawdzający wartość kolumny parameter_type ma wyższy priorytet. Dodatkowo może być kłopot z poinformowaniem bazy danych o tym fakcie. Nie wszystkie kryteria wyszukiwania są równe, niektóre są równiejsze od innych.
Wyliczanie warunków filtrowania Rozpoczynając pracę nad zapytaniem SQL, należy odpowiedzieć na następujące pytania: • Jakie dane będą odczytywane i w których tabelach można je znaleźć? • Jakie dane wejściowe należy przekazać silnikowi bazy danych? • Jakie kryteria filtrujące pomogą usunąć z wyniku niepożądane dane? Należy jednak pamiętać, że pewne dane (w szczególności wykorzystywane do złączania tabel) mogą być zapisane nadmiarowo w większej liczbie tabel. Konieczność zwrócenia wartości będących kluczem głównym jednej tabeli wcale nie oznacza, że tabela ta będzie brała udział w zapytaniu. Jeśli
134
ROZDZIAŁ CZWARTY
do tej tabeli odwołuje się inna, to w tej drugiej również można znaleźć wartości klucza głównego pierwszej tabeli, w charakterze klucza obcego. Jeszcze przed rozpoczęciem pisania zapytania należy rozważyć niezbędne kryteria filtrowania. Najwydajniejsze z nich (których może być kilka i mogą być stosowane z różnymi tabelami) powinny być uznane za najważniejsze, które będą sterowały działaniem zapytania, a mniej wydajne powinny stanowić jedynie uzupełnienie. W jaki sposób odróżnić wydajne kryterium filtrujące od niewydajnego? Wydajne kryterium to po pierwsze takie, które pozwala w krótkim czasie zmniejszyć rozmiar odczytywanych danych. Należy mocno skupić się na sposobie definiowania kryteriów, dlatego w kolejnych punktach omówię najważniejsze zasady.
Nabywcy Batmobili Załóżmy, że mamy cztery tabele: customers, orders, orderdetail i articles, zgodnie z rysunkiem 4.5. Rozmiary prostokątów na rysunku odpowiadają proporcjom rozmiarów danych w każdej z tabel, nie chodzi tu o liczbę kolumn. Nazwy kolumn definiujących klucze główne są podkreślone.
RYSUNEK 4.5. Klasyczny schemat uporządkowania
Załóżmy, że potrzebujemy znaleźć klientów mieszkających w mieście Gotham, którzy zamówili artykuł o nazwie Batmobile w okresie ostatnich sześciu miesięcy. Oczywiście istnieje kilka sposobów zdefiniowania tego zapytania, poniżej prezentuję formę, którą zapewne zaproponowałby zwolennik standardu ANSI SQL: select distinct c.custname from customers c join orders o on o.custid = c.custid join orderdetail od on od.ordid = o.ordid
MANEWROWANIE
135
join articles a on a.artid = od.artid where c.city = 'GOTHAM' and a.artname = 'BATMOBILE' and o.ordered >= funkcja
Nazwa funkcja reprezentuje funkcję zwracającą datę o sześć miesięcy wcześniejszą od aktualnej. Warto również zwrócić uwagę na obecność klauzuli DISTINCT — ważnej, jeśli posiadamy klientów intensywnie kupujących Batmobile, którzy w ciągu ostatniego pół roku mogli nabyć kilka sztuk. Zapomnijmy na chwilę o tym, że optymalizator może zmodyfikować to zapytanie, i przyjrzyjmy się oryginalnemu planowi wykonawczemu tego zapytania. Po pierwsze, przeszukamy tabelę klientów, zachowując wyłącznie te wiersze, które zawierają nazwę GOTHAM w kolumnie city. Następnie przeszukamy tabelę orders, przy czym warto, aby kolumna custid tej tabeli była poindeksowana, ponieważ w przeciwnym razie silnik SQL w celu wydajnego wykonania tego zapytania musiałby wykonać sortowanie i łączenie lub zbudować tabelę haszującą i wykorzystać ją do przyspieszenia dostępu do danych. Na tym poziomie występuje kolejny filtr, tym razem wykorzystujący datę zakupu (ordered). Wydajny optymalizator znajdzie warunek filtrujący w klauzuli WHERE i zrozumie, że w celu zminimalizowania ilości danych musi dokonać filtrowania po dacie przed dokonaniem złączenia. Niezbyt inteligentny optymalizator może w pierwszej kolejności dokonać złączenia, dlatego lepiej jest kryteria filtrowania określić razem z kryteriami złączenia: join orders o on o.custid = c.custid and a.ordered >= funkcja
Nawet w przypadku, gdy warunek filtrujący nie ma nic wspólnego ze złączeniem, czasem trudno jest optymalizatorowi zidentyfikować taką sytuację. Gdy klucz główny tabeli orderdetail jest zdefiniowany jako (ordid, artid), to z faktu, iż ordid jest pierwszym atrybutem indeksu, wynika, że można z skorzystać z indeksu do zidentyfikowania wierszy w oparciu o sam identyfikator zamówienia. Jeśli jednak klucz główny byłby zdefiniowany jako (artid, ordid) (i należy zaznaczyć, że obie formy są absolutnie równoznaczne z punktu widzenia teorii relacyjnej), nie byłoby takiej możliwości. Niektóre bazy danych potrafią jednak wykorzystać
136
ROZDZIAŁ CZWARTY
ten indeks3, ale nie zapewniają tak samo wydajnego dostępu, jaki byłby możliwy w przypadku klucza głównego (ordid, artid). Inne bazy danych będą natomiast zupełnie „bezbronne”, to znaczy nie będą mogły wykorzystać indeksu. Jedyny ratunek w tej sytuacji to osobny indeks zdefiniowany na kolumnie ordid. Po połączeniu tabel orderdetails i orders możemy przejść do tabeli articles. Tym razem bez większych problemów, ponieważ w tabeli orderdetails mamy już zidentyfikowane wszystkie potrzebne nam identyfikatory artykułów (artid). Możemy zatem sprawdzić, czy znaleziony artykuł jest Batmobilem. Czy to już koniec? Niezupełnie. Z powodu klauzuli DISTINCT musimy teraz posortować wyniki według nazwisk klientów i wyeliminować duplikaty. Jak się okazuje, istnieje kilka alternatywnych sposobów zapisu opisanego wyżej zapytania. Jeden z przykładów wykorzystuje starą składnię złączenia: select distinct c.custname from customers c, orders o, orderdetail od, articles a where c.city = 'GOTHAM' and c.custid = o.custid and o.ordid = od.ordid and od.artid = a.artid and a.artname = 'BATMOBILE' and o.ordered >= funkcja
Być może to kwestia starych nawyków, ale preferuję właśnie tę formę. Mam jednak jeden, logiczny powód: w takim zapisie nieco bardziej oczywiste jest, że kolejność przetworzenia tabel będzie absolutnie niezwiązana z kolejnością wprowadzonych tabel. Oczywiście najważniejsza jest tu tabela customers, ponieważ stanowi źródło kluczowych informacji, ale w tym konkretnym kontekście wszystkie pozostałe tabele są wykorzystywane wyłącznie w celu odfiltrowania zbędnych informacji. Należy zrozumieć, że nie ma tutaj gotowej receptury działającej we wszystkich przypadkach. Wzorzec wykorzystany w złączeniach tabel będzie różnił się dla różnych sytuacji. Decydującym czynnikiem jest tu natura obsługiwanych danych. 3
W takim przypadku wykorzystywana jest funkcja określana jako skip-scan.
MANEWROWANIE
137
Zademonstrowane podejście do tworzenia kodu SQL może być korzystne w niektórych przypadkach, ale w innych się nie sprawdzi. Sposób pisania zapytań można bowiem porównać do lekarstwa, które całkowicie uleczy jednego pacjenta, ale innego może zabić.
Więcej zakupów Batmobili Przeanalizujmy alternatywne sposoby uzyskania listy nabywców Batmobili. W mojej opinii za wszelką cenę warto uniknąć klauzuli DISTINCT na najwyższym poziomie zapytania. Powód jest następujący: jeśli w zapytaniu przez omyłkę pominiemy jakiś warunek złączenia, DISTINCT zamaskuje problem. Oczywiście ryzyko jest większe przy zapytaniach wykorzystujących starą składnię złączeń, ale również w składni zgodnej z ANSI/SQL92 może się to zdarzyć, szczególnie gdy złączenie obejmuje kilka kolumn. Z reguły łatwiej jest zauważyć zduplikowane wiersze, niż zidentyfikować nieprawidłowe dane. Łatwo udowodnić, że bywa trudno zidentyfikować nieprawidłowe wyniki: dwa poprzednie zapytania wykorzystujące klauzulę DISTINCT rzeczywiście mogą zwracać nieprawidłowe wyniki. Załóżmy, że mamy kilku klientów o nazwisku Wayne. Nie uzyskamy tej informacji, ponieważ klauzula DISTINCT nie tylko usuwa duplikaty wynikające z wielokrotnych zamówień złożonych przez tego samego klienta, lecz również zredukuje pozorne duplikaty wynikające z występowania zamówień od osób o tym samym nazwisku. W rzeczywistości, aby zapewnić poprawność danych, oprócz nazwiska powinniśmy pobierać identyfikator klienta, co zapewniłoby, że UNIQUE nie zredukuje nadmiernie listy nabywców Batmobili. Możemy jedynie zgadnąć, ile czasu zajmie zidentyfikowanie tego błędu w systemie produkcyjnym. W jaki sposób pozbyć się klauzuli DISTINCT? Wystarczy uświadomić sobie, że poszukujemy klientów z miasta Gotham spełniających test występowania (ang. existence test), a dokładniej warunek zakupu Batmobilu w okresie ostatnich sześciu miesięcy. Większość dialektów SQL-a obsługuje następującą składnię: select c.custname from customers c where c.city = 'GOTHAM' and exists (select null from orders o, orderdetail od, articles a
138
ROZDZIAŁ CZWARTY
where and and and and
a.artname = 'BATMOBILE' a.artid = od.artid od.ordid = o.ordid o.custid = c.custid o.ordered >= funkcja)
W tego typu teście występowania nazwisko może wystąpić wielokrotnie wyłącznie w przypadku, gdy jest wspólne dla różnych klientów, ale każdy indywidualny klient wystąpi tylko raz, niezależnie od liczby złożonych zamówień. Biorąc pod uwagę powyższy przykład, można by uznać, że moja krytyka składni ANSI SQL-a była nieco niesprawiedliwa, ponieważ wyróżnienie tabeli klientów (customers) jest tutaj nie mniej wyraźne, jeśli nawet nie wyraźniejsze. Jednak w tym przypadku tabela klientów występuje jako źródło danych zwracanych z zapytania. Drugie zapytanie, zagnieżdżone w głównym, służy jako główny element filtrujący dane klientów. Wewnętrzne zapytanie w tym przykładzie jest ściśle powiązane z zewnętrznym. Jak widać w wierszu jedenastym (pogrubiony) podrzędne zapytanie odwołuje się do bieżącego wiersza w zapytaniu nadrzędnym. W ten sposób zapytanie podrzędne jest tzw. podzapytaniem skorelowanym. Problem z takim zapytaniem polega na tym, że nie można go uruchomić, zanim nie pozna się bieżącego klienta. Ponownie zakładamy, że optymalizator nie dokona modyfikacji zapytania. Musimy zatem znaleźć każdego klienta i dla każdego z nich sprawdzić, czy jest spełniony test występowania. Przy niewielkiej ilości klientów z Gotham zapytanie może działać doskonale. Może jednak działać beznadziejnie, jeśli Gotham jest miastem, w którym mieszka większość klientów (w takim przypadku optymalizator może jednak usiłować przekształcić zapytanie). Mamy jeszcze inny sposób zapisu zapytania: select custname from customers where city = 'GOTHAM' and custid in (select o.custid from orders o, orderdetail od, articles a where a.artname = 'BATMOBILE' and a.artid = od.artid and od.ordid = o.ordid and o.ordered >= funkcja)
MANEWROWANIE
139
W tym przypadku zapytanie podrzędne nie zależy już od zapytania nadrzędnego: stało się podzapytaniem nieskorelowanym (ang. uncorrelated subquery) i musi być wykonane tylko raz. Dość wyraźnie widać, że w tym przypadku odwróciliśmy przepływ wykonawczy. W poprzedniej wersji zapytania poszukiwaliśmy klientów ze wskazanej lokalizacji (to znaczy z miasta Gotham), po czym dla każdego z nich sprawdzaliśmy zamówienia. W ostatniej wersji zapytania identyfikatory klientów posiadających w swojej historii interesujące nas zamówienia są wydobywane dzięki złączeniu odbywającym się w podrzędnym zapytaniu. Gdyby przyjrzeć się nieco bliżej, istnieje więcej subtelnych różnic między ostatnim a poprzednim przykładem. W przypadku podzapytania skorelowanego kluczowe znaczenie ma istnienie indeksu na kolumnie custid tabeli orders. Przy podzapytaniu nieskorelowanym ten indeks nie ma znaczenia, ponieważ jedynym indeksem tu wykorzystywanym (o ile będzie użyty jakikolwiek indeks) jest indeks klucza głównego tabeli klientów (customers). Można zauważyć, że ostatnia wersja zapytania wykorzystuje operację DISTINCT, ale w sposób niejawny. Podzapytanie, dzięki wykonywanym w nim złączeniu, może zwracać większą liczbę wierszy dla każdego klienta. Jednak te duplikaty nie mają znaczenia, ponieważ warunek IN sprawdza jedynie występowanie wartości w liście, nie ma więc znaczenia, czy sprawdzana wartość wystąpi w niej wielokrotnie. Być może jednak w celu zachowania spójności warto zastosować do podzapytania te same reguły, co do całego zapytania, głównie w celu zaznaczenia, że w ramach podzapytania również mamy do czynienia z testem występowania: select custname from customers where city = 'GOTHAM' and custid in (select o.custid from orders o where o.ordered >= funkcja and exists (select null from orderdetail od, articles a where a.artname = 'BATMOBILE' and a.artid = od.artid and od.ordid = o.ordid))
140
ROZDZIAŁ CZWARTY
Inna wersja: select custname from customers where city = 'GOTHAM' and custid in (select custid from orders where ordered >= funkcja and ordid in (select od.ordid from orderdetail od, articles a where a.artname = 'BATMOBILE' and a.artid = od.artid)
Mimo tego że zagnieżdżenie w tych przykładach jest głębsze, przez co całe zapytanie staje się mniej czytelne, wybór między zapytaniem wykorzystującym EXISTS oraz zapytaniem wykorzystującym IN powinien się opierać na tej samej zasadzie, co zwykle: zależy od tego, czy warunek sprawdzający datę będzie wydajniejszy od warunku sprawdzającego artykuł, czy odwrotnie. O ile w ciągu ostatnich sześciu miesięcy firma nie przeżywała stagnacji, można by założyć, że najwydajniejszym warunkiem będzie ten, który sprawdza nazwę artykułu. Z tego powodu w tym konkretnym przykładzie podzapytania lepiej jest zastosować klauzulę EXISTS, ponieważ będzie ono działać szybciej, gdy najpierw zostaną odczytane zamówienia odnoszące się do Batmobili, po czym nastąpi weryfikacja tego, czy dane zamówienie wystąpiło w ciągu ostatnich sześciu miesięcy. To podejście będzie działać szybciej pod warunkiem, że tabela orderdetail jest poindeksowana po kolumnie artid. W przeciwnym razie ten zmyślny manewr taktyczny zakończy się sromotną klęską. UWAGA Zastosowanie konstrukcji IN zamiast EXISTS może się okazać dobrym pomysłem, jeśli z dużym prawdopodobieństwem będziemy mieli do czynienia z dużą liczbą wierszy definiujących test występowania.
Większość dialektów SQL-a pozwala przepisać skorelowane podzapytania w formie osadzonych perspektyw w ramach klauzuli FROM. Należy jednak zawsze pamiętać o tym, że operator IN wykonuje niejawną operację usuwania duplikatów, którą trzeba wykonać w sposób jawny, jeśli przekształcamy ją w osadzoną perspektywę w klauzuli FROM. Na przykład:
MANEWROWANIE
141
select custname from customers where city = 'GOTHAM' and custid in (select o.custid from orders o, (select distinct od.ordid from orderdetail od, articles a where a.artname = 'BATMOBILE' and a.artid = od.artid) x where o.ordered >= funkcja and x.ordid = o.ordid)
Istnieje wiele funkcjonalnie równoważnych sposobów, na jakie można przepisać zapytania (i z pewnością istnieją warianty odmienne od zaprezentowanych tu przeze mnie). Można posłużyć się analogią, że różne formy tego samego zapytania są jak synonimy w nauce o języku. W języku mówionym lub pisanym synonimy mają zbliżone znaczenie, ale każdy z nich wprowadza subtelną różnicę, dzięki czemu każde słowo jest unikalne i pasuje idealnie do danej sytuacji, do której pozostałe synonimy pasują mniej (istnieją też sytuacje, w których pewne synonimy nie pasują w ogóle). Podobnie warianty zapytań należy dopasowywać do specyfiki przetwarzanych danych szczegółów implementacyjnych systemu obsługi baz danych.
Wnioski z handlu Batmobilami Różne przykłady języka SQL poznane w poprzedniej sekcji mogą wyglądać jak mało ambitne ćwiczenie sprawności programowania. Warto jednak spojrzeć na nie nieco szerzej. Przede wszystkim demonstrują, na jak wiele różnych sposobów można podejść do danych oraz że nie ma konieczności, aby za punkt wyjścia przyjmować tabelę klientów (customers), po czym przechodzić do zamówień (orders), następnie do szczegółów zamówień (orderdetail), a w końcu do artykułów (articles), jak sugerowałaby logika czy klasyczne formy zapisu tego zapytania. Przyjmijmy, że skuteczność kryteriów wyszukiwania przedstawiamy w postaci strzałek (im bardziej skuteczne kryterium, tym większy rozmiar strzałki). Załóżmy, że firma ma w Gotham bardzo niewielu klientów, za to sprzedaje dużą liczbę Batmobili i biznes był w doskonałej kondycji w ciągu ostatnich sześciu miesięcy. Mapa bitwy będzie miała postać przedstawioną na rysunku 4.6. Choć mamy warunek sprawdzający nazwę artykułu,
142
ROZDZIAŁ CZWARTY
RYSUNEK 4.6. Przypadek, gdy główny warunek selektywności jest oparty na lokalizacji
środkowa strzałka wskazuje tabelę orderdetail, ponieważ tak naprawdę ten warunek ma rzeczywiste znaczenie. Możemy mieć bardzo mało artykułów na sprzedaż o równomiernym rozkładzie sprzedaży, ale możemy też mieć dużą liczbę artykułów, z których Batmobil jest jednym z bestsellerów. Możemy też założyć, że większość naszych klientów znajduje się w mieście Gotham, ale niewielu z nich kupuje Batmobile. W takim przypadku plan bitwy prezentuje się zgodnie z rysunkiem 4.7. Całkiem oczywiste okazuje się zatem, że najważniejszy jest dla nas odpowiedni podział tabeli zamówień, która jest największa z wszystkich. Im szybciej uda się to zrobić, tym szybciej będzie działać zapytanie.
RYSUNEK 4.7. Przypadek, gdy główny warunek selektywności jest oparty na zamówieniach
MANEWROWANIE
143
Należy również zauważyć, co jest bardzo ważne, że kryterium „w ciągu ostatnich sześciu miesięcy” nie jest szczególnie precyzyjne. Co się jednak stanie, gdy zdefiniujemy kryterium w postaci ostatnich dwóch miesięcy, posiadając w bazie dane sprzedażowe z dziesięciu lat? W takim przypadku wydajniejszy sposób mógłby polegać na wydobyciu ostatnich zamówień, które dzięki technikom klastrowania, omówionym w rozdziale 5., mogą być położone blisko siebie, po czym w oparciu o te zamówienia można z jednej strony wydobyć klientów z miasta Gotham, z drugiej zamówienia zawierające Batmobile. Innymi słowy: optymalny plan wykonawczy nie jest zależny jedynie od rozmiarów danych, lecz może ewoluować w czasie. Jakie zatem wnioski można wyciągnąć z tych obserwacji? Po pierwsze, istnieje więcej niż jeden sposób napisania prawie każdego zapytania, po drugie, wybór konkretnej formy zapytania powinien być podparty analizą dotyczącą danych, które to zapytanie będzie przetwarzało. Za pomocą każdej równoważnej postaci zapytania uzyskamy dokładnie ten sam zbiór danych, ale mogą się one znacznie różnić prędkościami wykonania. Sposób, w jaki zapisuje się zapytania, może wpływać na wybór ścieżki wykonawczej, szczególnie w przypadku zastosowania kryteriów, które nie mogą być zrealizowane z użyciem relacyjnej części środowiska wykonawczego bazy danych. Jeśli chcemy, aby optymalizator działał naprawdę efektywnie, musimy podjąć próbę zdefiniowania zapytania tak, aby w jak największym stopniu wykorzystywało mechanizmy relacyjne i aby zminimalizować jego nierelacyjną część. Na potrzeby tego rozdziału przyjęliśmy uproszczenie zakładające, że zapytania są wykonywane w sposób zgodny z ich sposobem zapisu. Należy jednak mieć na uwadze, że optymalizator ma prawo przekształcić zapytania, czasem w sposób bardzo agresywny. Można zastanawiać się, czy fakt, że optymalizator przekształca zapytania, ma jakiekolwiek znaczenie, w końcu SQL jest deklaratywnym językiem, w którym opisuje się, co ma być wykonane w bazie danych, bez definiowania sposobu, w jaki ma to być zrobione przez silnik bazy danych. Jednakże można się przekonać, że za każdym razem, gdy zapytanie jest napisane w odmienny sposób, przyjmuje się nieco inne założenia odnośnie dystrybucji danych w poszczególnych tabelach oraz co do istnienia indeksów. Dlatego bardzo ważne jest, aby współpracować z optymalizatorem w jego pracy, aby upewnić się, że jego działania są zgodne z naszymi potrzebami, a on sam ma dostęp do niezbędnych informacji. Chodzi tu o odpowiednie indeksy oraz dane statystyczne dotyczące przetwarzanych danych.
144
ROZDZIAŁ CZWARTY
Uzyskanie prawidłowego wyniku zapytania SQL to dopiero pierwszy krok do napisania tego zapytania w idealny sposób.
Wydobywanie dużych porcji danych To stwierdzenie może wydać się oczywiste: im prędzej uda się odfiltrować nadmiar danych, tym mniej czasu baza danych spędzi nad dalszymi etapami zapytania i tym będzie ono wydajniejsze. Doskonałe zastosowanie tej zasady można znaleźć w operatorach zbiorowych, z których najczęściej stosowany jest zapewne operator unii UNION. Często spotyka się średnio skomplikowane zapytania zawierające operacje unii „sklejające” w jeden wynik wyniki kilku zapytań składowych. Często spotyka się również unie w ramach bardziej skomplikowanych zapytań zawierających złączenia, w których większość złączanych tabel występuje w obydwu elementach unii, na przykład: select ... from A, B, C, D, E1 where (warunek na E1) and (złączenia i inne warunki) union select ... from A, B, C, D, E2 where (warunek na E2) and (złączenia i inne warunki)
Takie zapytanie często bywa typowym przykładem bezmyślnego programowania opartego na kopiowaniu gotowych wzorców. W wielu przypadkach bowiem bardziej wydajne okazuje się zbudowanie unii ze składowych tabel zapytania zawierających rozłączne elementy, uzupełnienie go o warunki filtrujące, po czym złączenie wyniku tego podzapytania z pozostałymi tabelami:
MANEWROWANIE
145
select ... from A, B, C, D, (select ... from E1 where (warunek na E1) union select ... from E2 where (warunek na E2)) E where (złączenia i inne warunki)
Innym klasycznym przykładem warunków zastosowanych w złym miejscu jest możliwość zastosowania filtra w zapytaniu zawierającym klauzulę grupującą GROUP BY. Można filtrować po kolumnach definiujących grupowanie lub po wyniku agregatu (na przykład w celu porównania wyniku funkcji count() z określoną wartością) albo zastosować jedno i drugie filtrowanie. SQL pozwala określić wszystkie tego typu warunki w ramach jednej klauzuli HAVING, która jest wykonywana po zakończeniu operacji grupowania (w praktyce odbywa się to w wyniku operacji sortowania, po której odbywa się agregacja). Każdy warunek wykorzystujący wynik funkcji agregującej musi wystąpić w ramach klauzuli HAVING, ponieważ wynik takiej funkcji nie jest określony przed zakończeniem operacji GROUP BY. Każdy warunek niezależny od agregatu powinien znaleźć się w klauzuli WHERE, przez co posłuży do zmniejszenia liczby wierszy, które będą sortowane w celu realizacji grupowania w ramach instrukcji GROUP BY. Wróćmy do przykładu klientów i zamówień i przyjmijmy, że nasz sposób przetwarzania zamówień jest dość skomplikowany. Zanim zamówienie jest gotowe, przetwarzanie musi przejść przez kilka etapów, których wyniki są zapisywane w tabeli orderstatus, zawierającej między innymi kolumny ordid, czyli identyfikator zamówienia, statusdate, będący znacznikiem czasu statusu zamówienia. Klucz główny tej tabeli jest złożony z kolumn (ordid, statusdate). Chcemy uzyskać listę wszystkich zamówień o statusie innym niż COMPLETE (co oznacza, że zamówienie jest zrealizowane), zawierającą identyfikator zamówienia, nazwisko klienta, ostatni status oraz datę jego ustawienia. W tym celu możemy posłużyć się następującym zapytaniem, filtrując wszystkie gotowe zamówienia i identyfikując ostatni ustawiony status:
146
ROZDZIAŁ CZWARTY
select c.custname, o.ordid, os.status, os.statusdate from customers c, orders o, orderstatus os where o.ordid = os.ordid and not exists (select null from orderstatus os2 where os2.status = 'COMPLETE' and os2.ordid = o.ordid) and os.statusdate = (select max(statusdate) from orderstatus os3 where os3.ordid = o.ordid) and o.custid = c.custid
Na pierwszy rzut oka to zapytanie wygląda rozsądnie, ale w rzeczywistości zawiera kilka bardzo niepokojących cech. Po pierwsze warto zauważyć, że mamy tu do czynienia z dwoma podzapytaniami i że nie są one wzajemnie zagnieżdżone, jak w poprzednich przykładach, lecz są wzajemnie powiązane, choć niebezpośrednio. Najbardziej niepokojące jest jednak to, że obydwa podzapytania pobierają dane z tej samej tabeli, która w dodatku była już odczytywana w zapytaniu nadrzędnym. Jakie mamy tu warunki filtrujące? Niezbyt precyzyjne, ponieważ sprawdzamy jedynie, czy status zamówień jest różny od wartości COMPLETE. W jaki sposób będzie wykonane zapytanie tego typu? Najbardziej oczywiste podejście polega na sprawdzeniu w każdym wierszu tabeli zamówień, czy dane zamówienie ma odpowiedni status (oczywiście idealnie byłoby, gdyby odpowiednia informacja znajdowała się bezpośrednio w tabeli zamówień, ale w tym przypadku jest inaczej). Następnie, dla wierszy, które nie zostały odfiltrowane w poprzednim etapie, sprawdzamy datę ostatniego statusu, wykorzystując do tego podzapytania wywoływane w kolejności, w której są zapisane. Z tym zapytaniem związany jest nieprzyjemny fakt, a mianowicie to, że podzapytania są skorelowane. Oznacza to, że dla każdego wiersza z tabeli orders musimy sprawdzić, czy ma on status COMPLETE. Podzapytanie sprawdzające status będzie wykonywało się z dużą szybkością, ale niewystarczającą, jeśli będzie wywoływane dla każdego wiersza tabeli zamówień. Gdy w pierwszym podzapytaniu nie zostanie znaleziony status COMPLETE dla danego zamówienia, wywoływane jest drugie podzapytanie. Czy można w jakiś sposób przekształcić te podzapytania w nieskorelowane?
MANEWROWANIE
147
Najłatwiej jest przekształcić drugie z zapytań. Można je zapisać w następujący sposób (konstrukcja dostępna w niektórych dialektach SQL-a): and (o.ordid, os.statusdate) = (select ordid, max(statusdate) from orderstatus group by ordid)
Podzapytanie w takiej formie wiąże się z koniecznością pełnego przeszukiwania tabeli orderstatus, nie musi to jednak być zła wiadomość, ale o szczegółach za chwilę. Można zauważyć dziwny warunek w postaci pary kolumn po lewej stronie zmodyfikowanego podzapytania. Obie kolumny pochodzą z różnych tabel. Potrzebujemy, aby identyfikator zamówienia był taki sam w zamówieniu i w statusie zamówienia, pytanie, czy optymalizator zrozumie subtelność tego wybiegu? Trudno to przyjąć za pewnik. Jeśli optymalizator nie zrozumie intencji, nadal będzie w stanie wykonać to podzapytanie, lecz w celu wykorzystania wyników podzapytania będzie musiał dokonać złączenia tabel. Gdyby jednak zapisać to zapytanie nieco inaczej, optymalizator miałby większą swobodę w podjęciu decyzji i mógłby zdecydować, że najbardziej optymalnie będzie zadziałać tak, jak tego chcemy, lub wykorzystać wynik podzapytania, a następnie dokonać złączenia tabel orders i orderstatus: and (os.ordid, os.statusdate) = (select ordid, max(statusdate) from orderstatus group by ordid)
Po lewej stronie warunku występuje odwołanie do dwóch kolumn z tej samej tabeli, które usuwa zależność od wstępnego zapytania identyfikującego najświeższy status zamówienia. Skuteczny optymalizator może dokonać tego typu modyfikacji samodzielnie, ale nie warto ryzykować, jeśli można samodzielnie wskazać w warunku obie kolumny z tej samej tabeli. Zawsze lepiej jest dać optymalizatorowi jak największą wolność wyboru. W poprzednim przykładzie mieliśmy okazję przekonać się, że nieskorelowane złączenie może być bez większych problemów włączone w zapytanie w postaci perspektywy wbudowanej (inline). Nasze zapytanie możemy zatem przepisać w następujący sposób: select c.custname, o.ordid, os.status, os.statusdate from customers c, orders o, orderstatus os, (select ordid, max(statusdate) laststatusdate
ROZDZIAŁ CZWARTY
148
where and
and and and
from orderstatus group by ordid) x o.ordid = os.ordid not exists (select null from orderstatus os2 where os2.status = 'COMPLETE' and os2.ordid = o.ordid) os.statusdate = x.laststatusdate os.ordid = x.ordid o.custid = c.custid
Jeśli jednak COMPLETE jest rzeczywiście ostatnim statusem, czy potrzebujemy podzapytania, które sprawdza, czy nie istnieje ostatni status o tej wartości? Wbudowana perspektywa pozwala zidentyfikować ostatni status niezależnie od tego, czy ma on wartość COMPLETE, czy dowolnie inną. Dzięki temu możemy zastosować uproszczony, ale zupełnie wystarczający warunek sprawdzający wartość ostatniego statusu: select c.custname, o.ordid, os.status, os.statusdate from customers c, orders o, orderstatus os, (select ordid, max(statusdate) laststatusdate from orderstatus group by ordid) x where o.ordid = os.ordid and os.statusdate = x.laststatusdate and os.ordid = x.ordid and os.status != 'COMPLETE' and o.custid = c.custid
Podwójne odwołanie do tabeli orderstatus można wyeliminować, wykorzystując funkcje OLAP lub analityczne dostępne w niektórych silnikach SQL. Zatrzymajmy się jednak na chwilę i zastanówmy, w jaki sposób zdołaliśmy do tej pory zmodyfikować zapytanie, a co ważniejsze, jego ścieżkę wykonawczą. Nasza pierwotna ścieżka polegała na sekwencyjnym przeszukaniu tabeli zamówień (orders) oraz pozyskiwaniu informacji w tabeli statusów zamówień (orderstatus) przy liczeniu na wydajność jej indeksu. W ostatniej wersji zapytania wykorzystujemy pełne przeszukiwanie tabeli orderstatus, na której wykonamy grupowanie. Jeśli policzyć wiersze, tabela orderstatus z pewnością będzie ich zawierała kilkakrotnie więcej od tabeli orders. Jednakże jeśli chodzi o rozmiar danych, możemy liczyć na to, że będzie mniejsza, być może nawet dość znacząco, co jest uzależnione od ilości informacji zapisanych w samym zamówieniu.
MANEWROWANIE
149
Nie można mieć stuprocentowej pewności, które z tych rozwiązań jest lepsze, to w pełni zależy od danych. Należy jedynie zauważyć, że pełne przeszukiwanie tabeli, która z założenia będzie zwiększać się z dużą dynamiką, to dość kiepski pomysł (ale może tu pomóc ograniczenie wyszukiwania do ostatniego miesiąca lub kilku miesięcy). Istnieje jednak szansa, że ostatnia wersja zapytania będzie działała lepiej od jego pierwotnej postaci wykorzystującej podzapytanie w ramach klauzuli WHERE. Nie można bez komentarza pozostawić zagadnienia dużych rozmiarów danych bez omówienia jednego ważnego przypadku szczególnego. Gdy zapytanie zwraca duże ilości danych, można założyć, że nie mamy do czynienia z osobą wywołującą to zapytanie z poziomu terminala. Istnieje szansa, że to zapytanie jest elementem procesu wsadowego. Nawet jeśli istnieje długi etap przygotowawczy wywołania zapytania, nie będzie to miało większego znaczenia, o ile całość zmieści się w rozsądnych granicach. Nie należy jednak zapominać, że każde działanie (przygotowawcze i nie) zajmuje zasoby: moc procesora, pamięć i potencjalnie miejsce na dysku na dane tymczasowe. Warto zatem pamiętać, że optymalizator raczej wybierze inną ścieżkę wykonawczą w przypadku wywołania zapytania zwracającego dużą liczbę wierszy niż w przypadku zapytania zwracającego ich kilka, nawet jeśli obydwa zapytania są z technicznego punktu widzenia identyczne. Należy odfiltrowywać zbędne dane tak szybko, jak to tylko możliwe.
Proporcje odczytywanych danych Typowa i często cytowana rada głosi: „Nie używaj indeksów, jeśli zapytanie zwróci więcej niż 10% wierszy z tabeli”. To oznacza, że indeks jest efektywny wyłącznie wówczas, gdy, statystycznie, każdy jego klucz odpowiada maksymalnie 10% wierszy tabeli. Jak wspomniałem w rozdziale 3., ta reguła pochodzi z czasów, gdy relacyjne bazy danych w większości firm były traktowane z pewną nieśmiałością. To były czasy, gdy tabela o stu tysiącach wierszy była uznawana za naprawdę wielką. W porównaniu z 10% z tabeli o pięciuset milionach wierszy 10% z tabeli o stu tysiącach
150
ROZDZIAŁ CZWARTY
to drobnostka. Czy naprawdę można wierzyć w to, że najlepszy plan wykonawczy w przypadku jednej z tych tabel będzie również optymalny dla drugiej? Takie jest pobożne życzenie. Oprócz tego, że od czasu powstania reguły „10% wierszy” znacznie zwiększyły się średnie rozmiary tabel, należy wziąć pod uwagę fakt, iż liczba wierszy zwracanych z zapytania nie ma większego wpływu na czas oczekiwania przez użytkownika na wynik. Jeśli obliczamy średnią wartość z miliarda wierszy, w wyniku uzyskamy jeden wiersz, co nie zmienia faktu, że system zarządzania bazami danych ma mnóstwo pracy. Nawet bez zastosowania agregacji znaczenie ma liczba stron danych, jakie musi odczytać silnik bazy w celu wygenerowania wyniku. Liczba odczytywanych stron danych jest uzależniona od istnienia indeksu, a jak mieliśmy okazję doświadczyć w rozdziale 3., związek między indeksem a fizyczną kolejnością wierszy danych może mieć kluczowy wpływ na liczbę odczytanych stron. Istotne znaczenie mają też inne zagadnienia, które omówię w rozdziale 5.: różnice w sposobie fizycznego zapisu danych mogą mieć wpływ na różnice w liczbie stron danych, jakie muszą być odczytane w celu zwrócenia tej samej liczby wierszy. Co więcej, operacje odbywające się sekwencyjnie, wykorzystujące tę samą ścieżkę wykonawczą mogą być wykonane równolegle. Nie warto dać się złapać w pułapkę uproszczeń, a do takich należy pułap 10% wierszy w przypadku indeksów. Gdy mamy zamiar odczytać dużo danych, nie zawsze chcemy korzystać z indeksu.
ROZDZIAŁ PIĄTY
Ukształtowanie terenu Zrozumienie implementacji fizycznej (...) haben Gegend und Boden eine sehr nahe (...) Beziehung zur kriegerischen Tätigkeit, namlich einen sehr entscheidenden Einfluß auf das Gefecht. (...) ukształtowanie terenu ma bardzo bliski (...) związek z zagadnieniami stanowiącymi kluczowy wpływ na losy bitwy. — Carl von Clausewitz (1780 – 1831) O Wojnie, V, 17
152
Z
ROZDZIAŁ PIĄTY
faktu, że program widzi coś jako tabelę, nie wynika, że to rzeczywiście jest tabela. Czasem jest to perspektywa, a czasem tabela, ale o parametrach zapisu ustawionych bardzo precyzyjnie w celu zoptymalizowania określonych operacji. W tym rozdziale omówię kilka różnych sposobów uporządkowania danych w tabelach oraz operacji, których udoskonalenie stanowi cel tych konfiguracji. Należy od początku podkreślić, że celem tego rozdziału nie jest omówienie układu danych na dysku ani nawet wzajemne rozlokowanie plików kroniki i samych danych. To są zagadnienia, które wprawiają w zachwyt inżynierów i administratorów baz danych, ale z reguły tylko ich. Fizyczna organizacja bazy danych to jednak znacznie więcej niż fizyczne rozproszenie bajtów na nośniku fizycznym. Chodzi tu przede wszystkim o to, że logiczna natura danych dyktuje najważniejsze decyzje podejmowane w dziedzinie ich fizycznego uporządkowania. Zarówno inżynierowie systemów, jak i administratorzy baz danych wiedzą, jak dużo wykorzystywanych jest zasobów dyskowych, znają też możliwości różnych form kontenerów danych, jak niskopoziomowe paski dyskowe lub wysokopoziomowe tabele. Nierzadko jednak bywa tak, że administratorzy baz danych mają zaledwie niewielkie pojęcie na temat zawartości kontenerów. Często korzystne jest, aby wybrać teren, na którym odbędzie się bitwa. Podobnie jak generał omawiający taktykę z dowódcami służb inżynieryjnych, tak samo architekt aplikacji powinien rozważyć najlepszą strukturę fizycznego poziomu danych z administratorami baz danych. Czasem bywa jednak tak, że jesteśmy zmuszeni do walki w terenie, nad którym nie mamy żadnej kontroli, lub, co gorsza, w strukturach zaprojektowanych do zupełnie innych celów.
Typy struktur Mimo tego że zagadnienia związane ze strukturami baz danych nie są związane bezpośrednio z językiem SQL, jednak mogą mieć wpływ na taktyczne zastosowania SQL-a. Istnieje niemałe prawdopodobieństwo, że ustabilizowana na rynku i działająca baza danych jest skonstruowana w oparciu o jedną z poniższych struktur:
UKSZTAŁTOWANIE TERENU
153
Model stały, nieelastyczny Istnieją przypadki, gdy użytkownik bazy danych nie ma żadnego wyboru. Musi korzystać z istniejących struktur baz danych niezależnie od tego, że z oczywistych powodów mogą one wpływać na problemy z wydajnością lub wręcz być ich przyczyną. Podczas tworzenia nowych aplikacji oraz udoskonalania istniejących tego typu nieelastyczne struktury danych będą wpływały na podejmowane decyzje związane z wykorzystaniem narzędzi SQL-a. Programista jest zmuszony do nauczenia się i omijania ograniczeń systemu. Model ewolucyjny Nie wszystko bywa określone raz i niezmiennie, czasem istnieje możliwość modyfikacji fizycznego uporządkowania danych (bez zmiany modelu logicznego). Należy mieć na uwadze, że taka możliwość wiąże się z ryzykiem i że niechęć administratorów baz danych do dokonywania tego typu zmian jest spowodowana czymś więcej niż tylko lenistwem. Mimo ryzyka potencjalnego zatrzymania działań biznesowych wiele osób decyduje się na tego typu reorganizację bez danych na poziomie fizycznym, licząc na rozwiązanie problemów z wydajnością. Fizyczna reorganizacja nie jest jednak sama w sobie panaceum na słabą wydajność. Należy mieć na uwadze, na co można liczyć, a czego nie należy się spodziewać wskutek tak drastycznych działań. Jeśli jesteśmy zmuszeni do pracy w oparciu o nie najlepszy projekt, żaden z tych scenariuszy nie napawa optymizmem. „Skazani na zły projekt, porzućcie wszelką nadzieję”. Być może to lekka przesada, ale na pewno nie zaszkodzi jeszcze raz podkreślić niepodważalnego znaczenia sporządzenia solidnego projektu modelu bazy danych. Wybór implementacji fizycznej struktury bazy danych można doskonale porównać z wyborem opon w wyścigach Formuły 1. Decyzję dotyczącą modelu opon należy podjąć przed wyścigiem. Błędny wybór może okazać się kosztowny, trafny natomiast może przyczynić się do zwycięstwa, ale nawet najlepszy wybór zwycięstwa nie zagwarantuje. W tym rozdziale nie będę omawiał konstrukcji języka SQL, nie będę też zagłębiał się w szczegóły poszczególnych implementacji, które w gruncie rzeczy są bardzo związane z każdym systemem zarządzania bazami danych. Jednak w praktyce trudno jest zaprojektować skuteczną architekturę bez znajomości szczegółów dotyczących mechanizmów, zarówno tych pozytywnych,
154
ROZDZIAŁ PIĄTY
jak i niekorzystnych, które mogą pomóc lub przeszkodzić systemowi w działaniu. Zrozumienie oznacza również wyczucie zakresu, w jakim implementacja fizyczna może wpłynąć na wydajność — zmienić ją na lepsze lub na gorsze. Z tych powodów postaram się przybliżyć pewne praktyczne problemy, jakie napotykali twórcy mechanizmów zarządzania bazami oraz sposoby, w jakie sobie z nimi poradzili. Z praktycznego punktu widzenia należy mieć świadomość, że nie wszystkie opisywane rozwiązania są dostępne we wszystkich systemach baz danych, a niektóre są dostępne jedynie za dodatkową opłatą licencyjną. I na koniec tego wprowadzenia chcę podkreślić jeden ważny fakt. W analizie rozwiązań komercyjnych podjąłem próbę porównania ich ze sobą. Z tego powodu w tym rozdziale można znaleźć wyniki pewnych testów. Jednak należy pamiętać, że celem tej książki absolutnie nie jest przeprowadzenie „konkursu piękności” między poszczególnymi produktami systemów baz danych, szczególnie dlatego, że istnieją poważne różnice między różnymi ich wersjami. Wartości bezwzględne nie mają znaczenia, ponieważ są mocno uzależnione od posiadanego sprzętu i samego projektu bazy danych. Z tego powodu zdecydowałem się zaprezentować jedynie wartości względne, jak również porównywać wyłącznie kilka odmian tej samej bazy danych (z jednym wyjątkiem).
Sprzeczne cele W procesie optymalizacji fizycznego układu danych bazy obsługującej większą liczbę połączeń z reguły istnieją dwa sprzeczne ze sobą cele: konieczność wydajnego obsłużenia operacji zapisu i odczytu. Jeden cel (wydajny odczyt) można osiągnąć przez zapisywanie danych w jak najbardziej zwartej formie, dzięki czemu można w krótkim czasie odczytywać ich większe porcje. Drugi cel (szybki zapis) osiąga się z reguły przez rozproszenie danych, dzięki czemu kilka zapisujących procesów nie wchodzi sobie wzajemnie w drogę i nie powoduje zjawiska konkurowania o zasoby. Nawet w sytuacjach, w których nie występuje współbieżność, zawsze istnieje potrzeba znalezienia kompromisu między wydajnością odczytów i zapisów danych. Oczywistym przykładem są tu indeksy. Użytkownicy często decydują się na stworzenie indeksu w nadziei, że przyspieszy on operacje odczytu danych. Jednak, o czym można przekonać się w rozdziale 3.,
UKSZTAŁTOWANIE TERENU
155
koszt indeksowania jest niezwykle wysoki i operacje zapisu danych znacznie się spowalniają w tabeli zawierającej indeksy w porównaniu z tabelą niepoindeksowaną. Problemy ze współdzieleniem zasobów dotyczą każdych zapisywanych danych, szczególnie w przypadku aplikacji transakcyjnych dokonujących dużych ilości zmian w bazie (określenia zmiana używam w ogólnym znaczeniu operacji INSERT, DELETE i UPDATE). Niektóre z problemów dostępu do zasobów są rozwiązywane przez zastosowanie określonych jednostek zapisu, jak również przez niższe warstwy abstrakcji systemu operacyjnego. Pliki zawierające bazy danych mogą być podzielone na plastry (ang. slice), zwielokrotnione w strukturach lustrzanych (ang. mirror) oraz rozproszone w celu zapewnienia niezawodności systemu nawet w przypadku awarii sprzętowej. Te techniki zapisu danych mają również wpływ na zmniejszenie skutków zjawiska konkurowania o zasoby. Niestety, nie wystarczy zdać się na sam system operacyjny w celu uniknięcia problemów związanych z dostępem do zasobów. Podstawowe jednostki danych obsługiwane przez silniki baz (w zależności od implementacji znane pod pojęciami stron, ang. page, lub bloków) są, nawet na najniższych poziomach abstrakcji, traktowane jako atomowe, ponieważ operacje wczytania do pamięci odbywają się całymi stronami (blokami). Nawet w przypadku, gdy architektura z punktu widzenia inżyniera systemów wydaje się idealna, system zarządzania bazami danych może mieć poważne problemy z wydajnością. Aby uzyskać optymalny czas reakcji, liczbę stron danych wykorzystywaną przez silnik bazy należy utrzymać na jak najniższym poziomie. Istnieją dwa podstawowe sposoby zmniejszania liczby stron danych w ramach zapytania: • zapewnienie dużej gęstości danych na stronie, • grupowanie na stronie tych danych, które prawdopodobnie będą odczytywane w pojedynczej operacji. Próby upakowania danych w jak najmniejszej liczbie stron nie są jednak optymalnym podejściem w przypadku, gdy ta sama strona musi być zapisywana przez kilka współbieżnych procesów, a być może dodatkowo odczytywana przez kilka innych. W sytuacji, gdy jedna strona danych jest jednocześnie zapisywana i odczytywana przez wiele procesów, pojawia się niebanalny problem z rozstrzyganiem konfliktów dostępu.
156
ROZDZIAŁ PIĄTY
Wiele osób uważa, że struktura bazy danych jest problemem wyłącznie administratora baz danych. W rzeczywistości ta, jakże ważna, osoba jest odpowiedzialna za to zagadnienie, ale problem jest bardziej ogólny. Sposób fizycznego uporządkowania danych jest w dużym stopniu uzależniony od natury danych i ich zamierzonego wykorzystania. Na przykład w optymalizacji projektu fizycznego bardzo pomocna jest technika partycjonowania, ale nie należy jej nigdy stosować w bezmyślny sposób. Ponieważ między wymaganiami procesu przetwarzani danych a projektem fizycznym istnieje tak ścisły związek, często zdarza się natrafiać na konflikt między alternatywnymi projektami uporządkowania tych samych danych, gdy są one wykorzystywane przez dwa dosyć odmienne procesy biznesowe. Można to porównać do dylematu, jaki jest udziałem generała na polu bitwy: musi on wyważyć korzyści z różnych układów taktycznych wojsk (piechoty, kawalerii czy artylerii), dopasowując je do układu terenu, na którym mają się odbyć działania wojenne. Fizyczny projekt tabel i indeksów jest jednym z tych obszarów, w których muszą współpracować administratorzy bazy danych oraz programiści, starając się w jak najlepszy sposób dopasować dostępne funkcje systemu zarządzania bazą danych do wymogów biznesowych systemu informatycznego. Kolejne podrozdziały zawierają opisy różnych strategii i przedstawiają ich wpływ na operacje odczytu i zapisu danych z perspektywy pojedynczego procesu, co w praktyce oznacza najczęściej perspektywę procesu wsadowego. Operacje odczytu i zapisu nie współistnieją w harmonii: odczyty oczekują danych w formie maksymalnie zwartej, zapisy w formie maksymalnie rozproszonej.
Rozważanie danych jako repozytoriów danych Indeksy pozwalają szybko odnaleźć adresy (odwołania do określonego zasobu w pamięci trwałej, najczęściej w postaci identyfikatorów plików i przesunięć w ramach plików) wierszy zawierających klucz o poszukiwanej wartości. Adres może być przekształcony w niskopoziomowe odwołanie systemowe, które przy odrobinie szczęścia doprowadzi nas do rzeczywistego adresu w pamięci, zawierającego poszukiwane dane. W innej implementacji
UKSZTAŁTOWANIE TERENU
157
wyszukiwanie po indeksie może skutkować serią operacji wejścia-wyjścia skutkujących ulokowaniem poszukiwanych danych w określonym miejscu pamięci. Jak wspomniałem w rozdziale 3., gdy wartość poszukiwanego klucza odnosi się do dużej liczby wierszy, często wydajniej i prościej jest po prostu przejrzeć całą tabelę od początku do końca bez wykorzystania indeksów. Z tego powodu w relacyjnych bazach danych nie ma sensu indeksować kolumny zawierającej niewielką liczbę bardzo powtarzalnych wartości (to znaczy kolumny o niewielkiej liczności), chyba że część wartości klucza jest wysoce selektywna i występuje często w klauzuli WHERE zapytań. Inne przykłady indeksów, których warto się pozbyć, obejmują indeksy jednokolumnowe na kolumnach występujących jako pierwsze elementy w wielokolumnowych indeksach: w takich przypadkach nie ma sensu wielokrotne indeksowanie kolumn. Powszechnie stosowana drzewiasta struktura indeksu pozwala na wykorzystanie go nawet w przypadku, gdy nie jest dostępna cała wartość klucza złożonego, pod warunkiem że posiadamy informacje z pierwszych elementów klucza. Wykorzystanie wiodących bajtów zamiast pełnego klucza stanowi samo w sobie interesującą formę optymalizacji. Załóżmy, że mamy indeks (c1, c2, c3). Indeks ten będzie użyteczny nawet w przypadku, gdy podane zostaną wyłącznie wartości e1. Co więcej, jeśli wartości klucza nie są skompresowane, indeks będzie zawierał dane kolumn (c1, c2, c3) stanowiące kopię rzeczywistych danych z tabeli. Jeśli określimy wartość c1 w celu odczytania z tabeli wartości c2 lub wartość c1 w celu odczytania c3, w samym indeksie znajdziemy wszystkie istotne dane, bez konieczności odczytu samej tabeli. Weźmy prostą analogię: załóżmy, że chcemy uzyskać datę urodzenia Williama Szekspira. Wpisanie w wyszukiwarce internetowej hasła William Shakespeare spowoduje wyszukanie niezbędnych informacji, jak na rysunku 5.1. Nie ma jednak potrzeby klikania tych odnośników (choć zapewne warto), ponieważ w ramach samego silnika wyszukiwarki znajdziemy istotne dla nas informacje. Szósta wyszukana pozycja zawiera bowiem datę urodzenia Szekspira: rok 1564. W przypadku, gdy indeks jest odpowiednio zasilony danymi, nie ma sensu odczytywać informacji, na które wskazuje. Ta strategia jest podstawą często stosowanej taktyki optymalizacyjnej. Prędkość działania często
158
ROZDZIAŁ PIĄTY
RYSUNEK 5.1. Poszukiwanie hasła William Shakespeare
wywoływanego zapytania można poprawić, dodając do indeksu kilka dodatkowych kolumn, które nie zawierają co prawda informacji wykorzystywanych w kryteriach wyszukiwania, ale za to stanowią informacje poszukiwane przez zapytanie. Dzięki takiemu zabiegowi wymagane dane mogą być odczytane bezpośrednio z indeksu, co całkowicie zwalnia z konieczności odczytywania oryginalnego źródła danych. Niektóre produkty, jak DB2, są wystarczająco inteligentne i pozwalają zdefiniować klucze częściowo unikalne, to znaczy unikalność klucza jest wymuszana na wybranych kolumnach klucza złożonego. Ten sam efekt można osiągnąć w bazie Oracle, ale w nieco niebezpośredni sposób, używając nieunikalnego indeksu oraz wymuszenia unikalności klucza głównego. Istnieją udokumentowane przypadki pozornie nieznacznych modyfikacji programów wsadowych, które powodowały znaczne wydłużenie czasu ich wykonania. Te modyfikacje polegały na dodaniu pojedynczej kolumny do listy kolumn zwracanych w instrukcji SELECT. Przed dodaniem kolumny całe zapytanie mogło odbyć się w samym indeksie. Dodanie nowej kolumny
UKSZTAŁTOWANIE TERENU
159
zmusiło bazę danych do odczytywania danych z tabeli, co skutkowało poważnym wydłużeniem czasu wykonania. Przyjrzyjmy się nieco bliżej wydajności operacji typu „sam indeks” oraz „indeks i tabela”. Rysunek 5.2 przedstawia efekt obniżenia wydajności wskutek dodania do listy kolumn pojedynczej kolumny, niewystępującej w indeksie. Test został wykonany w trzech popularnych systemach baz danych. Tabela użyta do testów była dokładnie taka sama we wszystkich trzech przypadkach, składała się z dwunastu kolumn i dwustu pięćdziesięciu tysięcy wierszy. Klucz główny był zdefiniowany na trzech kolumnach: kolumnie typu całkowitego o wartościach losowych z zakresu od 1 do 5000, kolumnie typu tekstowego o długości od 8 do 10 znaków oraz kolumnie typu daty i czasu. Oprócz klucza głównego tabela nie miała zdefiniowanego żadnego dodatkowego indeksu. Pierwsze zapytanie miało za zadanie odczytać wartości z drugiej i trzeciej kolumny klucza głównego w oparciu o losową liczbę z zakresu od 1 do 5000 filtrującą wartość pierwszej kolumny. Test mierzył efekt obniżenia wydajności zapytania wskutek dodania do listy kolumn dodatkowej, typu liczbowego, która nie była ujęta w indeksie. Wyniki z rysunku 5.2 zostały znormalizowane w taki sposób, że odczyt dwóch kolumn znajdujących się w indeksie stanowi 100%. Przypadek konieczności odczytania dodatkowej kolumny w tabeli oznacza mniejszą wydajność, a więc czas wykonania tego zapytania jest wyrażany w wartości mniejszej od 100%.
RYSUNEK 5.2. Obniżenie wydajności wskutek odczytu dodatkowej kolumny nieujętej w indeksie
160
ROZDZIAŁ PIĄTY
Rysunek 5.2 pokazuje, że obniżenie wydajności wskutek konieczności odczytania wartości z tabeli nie jest wielkie i wynosi około 5% do 10%. Jest jednak zauważalny i w niektórych systemach baz danych jest większy niż w innych. Jak zawsze, dokładne wartości są uzależnione od wielu czynników i wpływ na wydajność może być znacznie większy, jeśli odczyt z tabeli wiąże się z koniecznością wykonania dodatkowych operacji wejścia-wyjścia, które nie miały miejsca w tej prostej procedurze testowej. Koncepcję zapisywania danych w indeksie można doprowadzić do ekstremum. Niektóre systemy baz danych, jak Oracle, pozwalają zapisać całe dane tabeli w ramach indeksu skonstruowanego na kluczu głównym, w ten sposób pozbywając się całej struktury tabeli. To podejście pozwala zaoszczędzić miejsce na dysku, a często też i czas. Tabela jest tu indeksem, a sama koncepcja jest określana terminem index-organized table (IOT), co stanowi alternatywny sposób uporządkowania danych tabeli w stosunku do powszechnie stosowanej struktury stertowej (ang. heap). W rozdziale 3. sporo miejsca poświęciliśmy analizie kosztu wstawiania danych do indeksu; można by mieć nadzieję, że w przypadku tabeli zorganizowanej w formie indeksu czas wstawiania jest krótszy niż w przypadku zwykłej tabeli wykorzystującej klasyczny indeks na kluczu głównym. Jednak w niektórych przypadkach jest zupełnie odwrotnie, co demonstruje rysunek 5.3. Na tym rysunku porównane są wydajności wstawiania wierszy w zwykłej tabeli i w tabeli typu IOT. Testy wykorzystywały cztery tabele. Zostały stworzone dwa schematy tabel o tych samych kolumnach: raz w formie klasycznej tabeli o strukturze sterty, raz w formie IOT. Pierwszy schemat miał postać niewielkiej tabeli składającej się z klucza głównego oraz jednej dodatkowej kolumny. Drugi schemat składał się z klucza głównego oraz dziewięciu dodatkowych kolumn, wszystkich typu liczbowego. Klucz główny (złożony) w każdej z tabel składał się z kolumny typu liczbowego, 10-znakowego tekstu oraz znacznika czasu. W każdym z przypadków wykonywane były dwa testy. Pierwszy z nich wstawiał losowe wartości kluczy głównych. Drugi test polegał na wstawianiu kluczy głównych różniących się tylko na pierwszej kolumnie, w postaci sekwencji kolejnych liczb.
UKSZTAŁTOWANIE TERENU
161
RYSUNEK 5.3. Względny koszt wstawiania wierszy do tabeli zorganizowanej w postaci indeksu
Gdy tabela zawiera niewiele kolumn oprócz biorących udział w kluczu głównym, operacja wstawiania do niej jest rzeczywiście szybsza w przypadku zastosowania formatu IOT. Jednakże jeśli tabela zawiera większą liczbę kolumn nienależących do klucza głównego, one również muszą być zapisane w obszarze indeksu. Ponieważ tabela jest indeksem, zapisywanych jest w niej więcej informacji niż w zwykłej tabeli. W rozdziale 3. można przeczytać, że zapisy do indeksów są bardziej kosztowne od zapisów do zwykłej tabeli. Koszt przesuwania bajtów związany z wstawianiem większej ilości danych do bardziej skomplikowanych struktur może prowadzić do znacznych opóźnień, o ile wiersze nie są wpisywane w kolejności zgodnej z kolejnością klucza głównego. Straty są nawet gorsze w przypadku, gdy zastosowanie mają długie ciągi znaków. W wielu przypadkach dodatkowy koszt wstawiania wierszy do tabeli przewyższa korzyści uzyskiwane w wyniku odczytu danych bezpośrednio z indeksu klucza głównego1. Istnieją jednak pewne dodatkowe korzyści związane z uporządkowaniem indeksów, o czym przekonamy się w kolejnym podrozdziale. 1
Jeden z redaktorów zauważył ponadto, że z pewnych względów (których omawianie wykracza poza tematykę tej książki) obsługa indeksów pomocniczych w przypadku tabel IOT jest mniej wydajna niż w przypadku tabel klasycznych.
162
ROZDZIAŁ PIĄTY
Niektóre zapytania można rozwiązań z użyciem samych indeksów.
Wymuszanie kolejności wierszy Tabele zorganizowane w postaci indeksu mają jeszcze jedną zaletę obok przyspieszenia dzięki ujednoliceniu odczytu indeksu i danych. Tabele typu IOT są indeksami, zatem ich struktura jest mocno uporządkowana, innymi słowy, wiersze są ułożone w kolejności zgodnej z kluczem głównym. Mimo tego że pojęcie kolejności jest zupełnie obce teorii relacyjnej, z praktycznego punktu widzenia zawsze gdy zapytanie odwołuje się do zakresu wartości, łatwiej jest go odczytać, kiedy kolejne wartości są blisko siebie, niż gdyby były mocno rozproszone. Najczęściej spotykaną operacją tego typu jest wyszukiwanie według kryterium zakresu czasowego, to znaczy, gdy szukamy danych dotyczących operacji, które odbyły się między dwoma datami. Większość systemów baz danych radzi sobie z potrzebami tego typu, pozwalając wymusić kolejność wierszy w tabeli zgodną z kolejnością klucza głównego. W Microsoft SQL Server i Sybase tego typu indeks nazywa się indeksem klastrowym (ang. clustered index). W DB2 podobnie (ang. clustering index), ale tu konstrukcja tego indeksu daje efekt bardziej zbliżony do tabel IOT znanych z Oracle. Niektóre zapytania wykonują się znacznie wydajniej dzięki tego typu uporządkowaniu. Ale podobnie jak w tabelach zapisanych w ramach indeksu, również i w tabelach uporządkowanych według indeksu zapisy są obciążone dodatkowym kosztem związanym z koniecznością przesuwania danych do nowego położenia w zależności od pozycji wstawianej wartości. Kolejność wierszy nieodwołalnie faworyzuje zapytania wykorzystujące warunki zakresowe po jednym zakresie, ale wyszukiwania zakresowe z wielokrotnymi zakresami będą wykonywać się wolniej. Podobnie jak w przypadku tabel IOT zdefiniowanych z użyciem klucza głównego, przy tworzeniu indeksu klastrowego najlepiej jest użyć klucza głównego tabeli, ponieważ nie jest on nigdy aktualizowany (jeśli aplikacja potrzebuje zmodyfikować klucz główny, mamy pewność, że coś niedobrego stało się z projektem struktur danych, a co ważniejsze, mamy praktyczną pewność, że w niedługim czasie coś niedobrego stanie się z integralnością
UKSZTAŁTOWANIE TERENU
163
danych). Jednak w przeciwieństwie do tabel IOT, nic nie stoi na przeszkodzie, aby do klastrowania użyć innego indeksu. Należy jednak pamiętać, że każde uporządkowanie powoduje, że jedne procesy będą odbywały się bardziej optymalnie, ale kosztem innych. Klucz główny, o ile jest kluczem naturalnym, ma znaczenie logiczne. Dlatego indeks klucza głównego jest z wielu względów ważniejszy od innych indeksów tabeli, nawet tych unikalnych. Jeśli w implementacji fizycznej mielibyśmy wyróżnić jakieś kolumny tabeli, to powinny być to właśnie te, które tworzą klucz główny. Rysunek 5.4 ilustruje różnice, jakich można spodziewać się w praktyce między wydajnością indeksów klastrowych a nieklastrowych. Wzięliśmy tę samą tabelę, co w teście służącym za podstawę rysunku 5.3 (trójkolumnowy klucz główny oraz dziewięć dodatkowych kolumn liczbowych). Wiersze były do niej dodawane w zupełnie losowej kolejności. Jak się okazało, koszt dodawania kolumn do tabeli z klastrowym indeksem jest dość wysoki, wydajność wstawiania wierszy jest około połowę mniejsza w porównaniu ze tabelą, która miała zdefiniowany zwykły (nieklastrowy) klucz główny. Jednak gdy uruchomimy przeszukiwanie zakresowe na około pięćdziesięciu tysiącach wierszy, indeks klastrowy spowoduje doskonałą wydajność operacji. W tym przypadku indeks klastrowy pozwolił osiągnąć wydajność zapytania dwudziestokrotnie wyższą w porównaniu ze zwykłym indeksem. Oczywiście przy odczytach pojedynczych wierszy nie powinniśmy zauważyć różnicy.
RYSUNEK 5.4. Wydajność indeksu klastrowego
164
ROZDZIAŁ PIĄTY
Optymalizacje strukturalne, jak indeksy klastrowe czy IOT, mają oczywiście swoje wady. Po pierwsze, tego typu mechanizmy wymuszają silne, drzewiaste struktury porządkujące kolejność elementów w tabeli. To podejście powoduje, że do życia wracają pewne problemy znane z czasów hierarchicznych (nierelacyjnych) baz danych. Struktury hierarchiczne powodują, że preferowana jest jedna, określona wizja danych i jedna ścieżka dostępu do nich. Ta jedna ścieżka dostępu będzie miała lepsze charakterystyki wydajnościowe niż jakikolwiek sposób dostępu uzyskany w tabeli niewyposażonej w indeks klastrowy, ale większość pozostałych metod dostępu będzie miała znacznie gorsze charakterystyki. Najbardziej kosztowne mogą okazać się tu operacje modyfikujące. Początkowe, schludne ułożenie danych w plikach bazy może szybciej się zdegenerować na poziomie fizycznym z powodu niekorzystnych zjawisk, jak strony przepełnienia (ang. overflow pages) czy inne efekty uboczne, które mają negatywny wpływ na wydajność. Struktury klastrowe dają doskonałe wyniki w niektórych przypadkach, zwiększając wydajność operacji o imponujące współczynniki. Ale zawsze należy testować je z uwagą, ponieważ istnieje duże prawdopodobieństwo, że spowodują spowolnienie większości pozostałych operacji na bazie. Należy rozważyć zasadność zastosowania tego typu technik, patrząc na problem z szerszej perspektywy, nie jedynie z punktu widzenia chęci przyspieszenia pojedynczego zapytania. Przeszukiwanie zakresowe na danych uporządkowanych w klastrze cechuje się imponującą wydajnością, ale pozostałe zapytania będą wykonywane znacznie wolniej.
Automatyczne grupowanie danych Mieliśmy okazję przekonać się, że przy wyszukiwaniu adresowym bardzo korzystne dla wydajności jest ułożenie kolejnych wierszy obok siebie. Istnieją jednak inne sposoby na uzyskanie wysokiego współczynnika uporządkowania danych, choć na warunkach mniej restrykcyjnych niż w przypadku indeksów klastrowych i tabel zbudowanych na bazie indeksu. Wszystkie systemy zarządzania bazami danych pozwalają partycjonować tabele i indeksy — to jest zgodne ze starą regułą dziel i rządź. Duża tabela może być podzielona na mniejsze kawałki, którymi łatwiej zarządzać. Partycjonowanie jest ponadto korzystne z punktu widzenia polepszonych
UKSZTAŁTOWANIE TERENU
165
charakterystyk związanych z wielodostępem i równoległym wykonywaniem operacji, dzięki czemu pozwala tworzyć lepiej skalowalne rozwiązania, o czym szerzej napiszę w rozdziałach 9. i 10. Po pierwsze, należy pamiętać, że określenie partycja ma różne znaczenie w zależności od systemu zarządzania bazami danych, a czasem nawet w zależności od wersji danego systemu. Dawniej pojęcie przestrzeni tabel (ang. tablespace) znane z Oracle było rozumiane jako partycja (ang. partition).
Partycjonowanie cykliczne W niektórych przypadkach partycjonowanie jest mechanizmem w pełni wewnętrznym, niezależnym od danych. Po prostu definiuje się określoną liczbę partycji w oderwanych od siebie obszarach dysku — z reguły ma to związek z liczbą urządzeń, na których będą przechowywane dane. Jedna tabela może mieć przypisaną większą liczbę partycji. Przy wprowadzaniu danych są one zapisywane na wybranej partycji zgodnie z określoną regułą, najczęściej w sposób cykliczny. Dzięki temu operacje wejścia-wyjścia związane z wprowadzaniem danych do tabel są rozłożone równomiernie na wszystkie partycje. Rozproszenie danych na różnych partycjach może również sprzyjać wydajności wyszukiwania pojedynczych wartości. Ten mechanizm można porównać z techniką paskowania (ang. stripping) stosowaną w macierzach dyskowych. W rzeczywistości w przypadku zastosowania macierzy z paskowaniem nie ma większego sensu stosowanie partycji w bazie danych. Rozpraszanie cykliczne jest raczej zaprojektowane do rozpraszania danych w sposób losowy, niezależnie od logicznych powiązań między nimi, a nie do grupowania danych w celu wyodrębnienia tych powiązań. Jednak w przypadku niektórych produktów, jak Sybase, każda transakcja zawsze zapisuje do jednej partycji, dzięki czemu uzyskuje się grupowanie danych zgodne z regułami biznesowymi występującymi w aplikacji.
Partycjonowanie w oparciu o dane Istnieje jednak o wiele ciekawsza forma partycjonowania znana pod nazwą partycjonowania w oparciu o dane (ang. data-driven partitioning). W tego typu partycjonowaniu decyzję o wyborze partycji podejmuje się w oparciu o same dane, jak wartość określonych kolumn. Ponownie zachodzi zależność: im więcej silnik systemu wie o danych, tym lepiej.
166
ROZDZIAŁ PIĄTY
Większość naprawdę rozbudowanych tabel ma duże rozmiary, głównie dlatego, że zawierają dane historyczne. Jednak zainteresowanie nową informacją wkrótce traci na znaczeniu, ponieważ pojawiają się jeszcze nowsze i ciekawsze informacje. Dlatego dość rozsądnym założeniem jest, że z całych historycznych informacji najczęściej odczytywane są te najświeższe. Z tego powodu naturalne jest, że tabele warto partycjonować według daty, oddzielając ziarno od plew, czyli dane aktywne od historycznych. Ręczny sposób podziału na partycje według daty mógłby polegać na rozbiciu jednej dużej tabeli figures (zawierającej dane z ostatnich dwunastu miesięcy) na dwanaście tabel, po jednej na każdy miesiąc: jan_figures, feb_figures itd. aż do dec_figures. Aby dla zapytań była widoczna globalna perspektywa, tabele te należy połączyć w jedną całość za pomocą operatora UNION. Taka unia jest często ujmowana w perspektywę nazywaną perspektywą spartycjonowaną lub (w MySQL-u) złączoną tabelą (ang. merge table). W trakcie miesiąca marca dane wpisuje się do tabeli mar_figures, a po jego zakończeniu do apr_figures. Zastosowanie perspektywy jako łącznika spartycjonowanych tabel może wydać się ciekawym pomysłem, ma jednak kilka wad: • Zasadniczą wadą tego podejścia jest to, że opiera się ono na poważnym błędzie projektowym. Wiemy, że tabele wchodzące w skład perspektywy są logicznie powiązane, nie ma jednak możliwości poinformowania systemu zarządzania bazą danych, że istnieje taki związek, za wyjątkiem możliwości wskazania (w niektórych systemach), że dana perspektywa jest typu spartycjonowanego, co często jednak jest niewystarczające do skutecznego działania optymalizatora. Tego typu wielotabelowe konfiguracje uniemożliwiają skuteczną definicję ograniczeń integralności. Nie ma prostego sposobu na wymuszenie unikalności wartości w wielu powiązanych tabelach, a w konsekwencji musielibyśmy budować wiele kluczy obcych odwołujących się do zbioru tabel, co prowadziłoby do sytuacji nienaturalnej i trudnej do utrzymania. W kwestii integralności w takim przypadku jesteśmy skazani na ograniczenie definiujące warunki partycjonowania. Na przykład w ograniczeniu wykorzystującym kolumnę sales_date możemy wymusić weryfikację daty, czyli zapewnić, że w tabeli jun_sales znajdą się pozycje z datą sprzedaży od 1 do 30 czerwca.
UKSZTAŁTOWANIE TERENU
167
• Jeśli wykorzystywany system zarządzania bazami danych nie ma obsługi partycjonowania, samodzielna implementacja tego mechanizmu za pomocą ręcznie zdefiniowanego zbioru tabel jest dość niewygodna, ponieważ musimy samodzielnie napisać kod, który pilnuje, aby każda wartość była wprowadzona do odpowiedniej tabeli. W praktyce oznacza to, że zapytania zapisujące do tabel muszą być konstruowane dynamicznie. Tego typu mechanizm dynamicznego budowania zapytań wpływa na znaczne zwiększenie poziomu komplikacji programów. W naszym przypadku program musi zidentyfikować datę sprzedaży, sprawdzić jej poprawność, określić, do której tabeli należy dany zapis, i w oparciu o to skonstruować odpowiednie zapytanie SQL. W przypadku perspektyw spartycjonowanych zadanie jest znacznie prostsze, ponieważ operacje zapisu z reguły można zdefiniować bezpośrednio na perspektywie, a problem wyboru odpowiedniej tabeli rozwiązuje sam mechanizm obsługi baz danych. W każdym z tych przypadków bezpośrednią konsekwencją tego niedoskonałego projektu będzie konieczność zdefiniowania dodatkowych więzów integralności w postaci warunków przy zapisie, co następuje najczęściej w reakcji na błąd skutkujący pojawieniem się niespójności w bazie. Tego typu dodatkowe sprawdzenia zwiększą jeszcze poziom komplikacji tego nie najlepszego projektu, a wraz z tym nakład pracy na napisanie i utrzymanie programu oraz obciążenie systemu wykonującego ten kod. Takie podejście deleguje konieczność utrzymania integralności z silnika systemu baz danych na programistę, który musi zaimplementować go samodzielnie w postaci wyzwalaczy i procedur osadzonych (w najlepszym przypadku) lub samego kodu aplikacji. • Zapytania wykorzystujące perspektywy łączące spartycjonowane tabele wykonują się mniej optymalnie. Jeśli jesteśmy zainteresowani wynikami sprzedaży w jednym miesiącu, zostanie użyta pojedyncza tabela. Jeśli jednak interesują nas sprzedaże z minionych trzydziestu dni, najczęściej będą użyte dwie tabele. W przypadku operacji pozyskiwania danych najbardziej optymalnie (ze względu na ilość kodu) jest posługiwać się stosowaną perspektywą ukrywającą fakt partycjonowania. W przypadku takiej perspektywy użycie w warunku zapytania kolumny, która posłużyła do zdefiniowania reguły podziału na partycje, spowoduje, że optymalizator będzie w stanie określić zakres wartości zapytania i wykorzystać jedynie niezbędny podzbiór tabel-partycji. Jeśli jednak warunki nie będą obejmowały
168
ROZDZIAŁ PIĄTY
strategicznej kolumny, zapytania będą o wiele bardziej skomplikowane, szczególnie w przypadku zastosowania podzapytań i agregatów. Poziom komplikacji zapytania będzie wzrastał wraz ze zwiększaniem liczby tabel biorących udział w unii. Koszt wykonawczy zapytań na perspektywach definiujących unię spartycjonowanej tabeli w stosunku do oryginalnej, pojedynczej tabeli szybko się ujawni, szczególnie w przypadku sekwencji cyklicznie wywoływanych po sobie zapytań. Pierwszy krok w implementacji mechanizmu partycjonowania polega z reguły na udostępnieniu perspektyw na spartycjonowanych tabelach. Drugim, bardziej zawansowanym mechanizmem partycjonowania jest obsługa automatycznego partycjonowania w oparciu o dane. Tego typu automatyczne partycjonowanie pozwala uzyskać pojedynczą tabelę na poziomie logicznym, z rzeczywistym kluczem głównym zapewniającym integralność, do którego odwołują się pozostałe tabele. Dodatkowo definiuje się kolumny określające tak zwany klucz partycji. Ich wartości są wykorzystywane do określenia partycji, w której mają być zapisane dane. Mamy tu do dyspozycji wszystkie zalety perspektyw łączących spartycjonowane dane, czyli logiczną warstwę pozwalającą odwoływać się do pojedynczej tabeli, a jednocześnie zadania ochrony integralności danych w takiej tabeli spoczywają na systemie zarządzania bazami danych, co jest wszak jednym z głównych zadań tych systemów. Jądro systemu baz danych „rozumie” mechanizm partycjonowania, zatem optymalizator ma możliwość podejmowania świadomych decyzji wynikających z tego faktu, polegających na ograniczaniu liczby tabel uwzględnianych w odczycie (co jest znane pod pojęciem partition pruning) lub wykonywaniu wybranych operacji w sposób równoległy. Sposób implementacji partycji jest w pełni zależny od systemu. Istnieją różne sposoby partycjonowania danych, lepiej lub gorzej dopasowane do określonej sytuacji. Partycjonowanie w oparciu o hash Tego typu partycjonowanie rozprasza dane w sposób równomierny w oparciu o obliczenia sumy kontrolnej wartości klucza partycji. Ta metoda skutkuje zupełnie losowym rozmieszczeniem danych w partycjach, nie jest brana pod uwagę dystrybucja czy logiczne powiązania wartości danych. Partycjonowanie w oparciu o hash zapewnia bardzo szybki dostęp do pojedynczych wartości w ramach
UKSZTAŁTOWANIE TERENU
169
klucza partycji. Jest jednak bezużyteczne przy wyszukiwaniu zakresowym, ponieważ kolejne wartości klucza nie gwarantują zapisu danych do tej samej partycji (a wręcz przeciwnie). UWAGA W DB2 dostępny jest jeszcze jeden mechanizm, określany terminem klastrowania zakresowego (ang. range-clustering), które, choć różni się od koncepcji partycjonowania, wykorzystuje wartości klucza do określania lokalizacji fizycznej przy zapisie. Ten mechanizm, w przeciwieństwie do hashy, zapewnia fizyczną ciągłość zapisu sekwencji danych. Dzięki temu uzyskuje się dwie korzyści: wydajny dostęp do pojedynczych wartości, jak i do zakresów danych klucza.
Partycjonowanie zakresowe Gromadzi dane w niezależnych grupach w oparciu o ciągłość zakresów danych klucza. Ta technika idealnie nadaje się do gromadzenia danych historycznych. Partycjonowanie zakresowe jest najbliższe koncepcji perspektyw łączących spartycjonowane tabele, omówionej wcześniej. Partycje definiuje się w oparciu o warunki określające zakresy wartości klucza. Dodatkowo definiuje się partycję typu ELSE, to znaczy gromadzącą dane, które nie należą do żadnego z warunków. Partycje tego typu najczęściej wykorzystuje się do zapisywania danych po kluczach typu czasowego, o szczegółowości od pojedynczych godzin po lata. Jednak ten typ partycji nie jest ograniczony do obsługi danych jednego typu. Typowym przykładem partycjonowania zakresowego jest encyklopedia wielotomowa, w której partycjonowanie odbywa się wyłącznie w oparciu o pierwszą literę hasła. Partycjonowanie w oparciu o listy To najbardziej manualny typ partycjonowania i może być przydatny w sytuacjach wymagających precyzyjnego zdefiniowania kryteriów podziału na partycje. Nazwa metody wskazuje na sposób jej obsługi: definiuje się listę odwzorowań wartości klucza (z reguły z jednej kolumny) na odpowiednie partycje. Partycjonowanie w oparciu o listę może być użyteczne w przypadku nierównomiernej dystrybucji wartości klucza partycji. Proces partycjonowania może czasem być powtarzany przez zdefiniowanie podpartycji. Podpartycja jest po prostu partycją w ramach partycji, dzięki jej ustanowieniu istnieje możliwość zdefiniowania wielu poziomów partycjonowania. Można tu mieszać metody, na przykład stworzyć partycję w oparciu o hash w ramach partycji zakresowej.
170
ROZDZIAŁ PIĄTY
Partycjonowanie danych jest najskuteczniejsze, gdy jego logika oparta jest na samych wartościach danych.
Obosieczny miecz partycjonowania Mimo tego że partycjonowanie pozwala rozpraszać dane tabeli w większej liczbie w pewnym sensie niezależnych tabel fizycznych, partycjonowanie w oparciu o dane nie jest panaceum na problemy z wielodostępem. Na przykład możemy zdefiniować kryterium partycjonowania tabeli w oparciu o datę: po jednej partycji na każdy tydzień działalności. To może się wydać efektywnym sposobem podziału tabeli na pięćdziesiąt dwie partycje w ciągu roku. Problem jednak polega na tym, że w każdym tygodniu zapisy danych będą odbywały się do tej samej partycji, niezależnie od liczby równolegle wykonywanych operacji. Co gorsza, jeśli klucz partycji jest oparty na bieżącym znaczniku czasu, operacje odbywające się równolegle będą wykorzystywały tę samą stronę danych (chyba że zostanie zastosowana jakaś „sztuczka” implementacyjna, na przykład kilka list bloków lub stron do jednoczesnego wykorzystania). W efekcie uzyskamy sytuację konkurowania o zasoby. Tabela w większości swojej zawartości będzie zatem bardzo mało wykorzystanym obszarem, czyli zimnym (tzw. cold area), z gorącymi punktami (ang. hotspot) w miejscu aktualnego zapisu, w których będą skupiały się prawie wszystkie operacje. Tego typu partycjonowanie nie spełnia oczekiwań, jeśli chodzi o udoskonalenie operacji wykonywanych w sposób równoległy. UWAGA Jeśli można założyć, że wszystkie dane są wpisywane w pojedynczym procesie, co czasem się zdarza w środowiskach hurtowni danych, nie wystąpi zjawisko gorącego punktu i 52-tygodniowy schemat partycjonowania nie będzie prowadził do problemów z wielodostępem.
Załóżmy z drugiej strony, że chcemy zdefiniować partycję w oparciu o geograficzną definicję źródła zamówienia w systemie sklepu online (jeśli jednak produkty naszej firmy są bardziej popularne w jednych obszarach, a mniej w innych, należy podejść do definicji schematu partycjonowania z większą uwagą).
UKSZTAŁTOWANIE TERENU
171
W dowolnym momencie pracy systemu dane wprowadzane będą równomiernie rozproszone po wszystkich partycjach, ponieważ istnieje duże prawdopodobieństwo, że zamówienia będą spływały z zupełnie losowych miejsc. Sutek na wydajności wynikający z partycjonowania będzie wyraźny w przypadku generowania regionalnych raportów sprzedaży. Oczywiście, ponieważ partycje będziemy mieli zdefiniowane według kryteriów geograficznych, raporty okresowe nie będą odpowiednio wydajne. Jednak nawet zapytania wykorzystujące kryteria czasowe mogą w pewnym zakresie skorzystać z partycjonowania, ponieważ istnieje duże prawdopodobieństwo, że w wieloprocesorowym systemie część operacji wyszukiwania może odbywać się w sposób równoległy, po czym wyniki jednostkowe będą złączone w jedną całość. Jak wynika z powyższych rozważań, partycjonowanie danych ma dwa oblicza. Z jednej strony to doskonały sposób, aby łączyć dane w klastry w oparciu o określone kryterium, co pozwala przyspieszyć wybrane operacje pozyskiwania danych. Z drugiej strony to nie mniej doskonały sposób na rozproszenie danych, dzięki czemu można w dużym zakresie uniknąć problemów związanych ze współbieżnym dostępem w przypadku zapisów, czyli uniknąć gorących punktów. Te dwa cele mogą wzajemnie sobie przeszkadzać, zatem rozważając możliwość zastosowania partycjonowania, należy w pierwszej kolejności wziąć pod uwagę rzeczywisty problem, jaki staramy się rozwiązać. Parametry partycjonowania dobiera się bowiem w odniesieniu do określonej sytuacji. Należy jednak uwzględnić fakt, że optymalizacja w jednym zakresie wiąże się z obniżoną wydajnością w innym. W idealnym przypadku klaster danych wykorzystywany do odczytu nie przeszkadza w odpowiednim rozproszeniu operacji zapisu, lecz tego typu ideał udaje się osiągnąć stosunkowo rzadko. Partycjonowanie danych może być użyte w celu rozproszenia lub skupienia (klastrowania) danych. Wykorzystanie każdej z tych opcji powinno być dostosowane do konkretnych potrzeb.
Partycjonowanie i dystrybucja danych Kuszące może okazać się założenie, że w przypadku dużej tabeli w celu uniknięcia sytuacji konkurowania o zasoby w operacjach zapisu najlepiej jest ją spartycjonować. To jednak nie zawsze jest prawda.
172
ROZDZIAŁ PIĄTY
Załóżmy, że mamy dużą tabelę, w której zapisane są szczegóły zamówień złożonych przez klientów. Jeśli okaże się, że pojedynczy klient generuje większość aktywności firmy (co się czasem zdarza), partycjonowanie względem identyfikatora klienta może nie rozwiązać problemu. Zapytania w takiej sytuacji można, z grubsza, podzielić na dwa rodzaje: zapytania związane z dużym klientem oraz zapytania związane z wszystkimi innymi, mniejszymi klientami. Gdy odczytywane są dane dotyczące jednego, niewielkiego klienta, indeks po identyfikatorze klienta będzie bardzo selektywny, a co się z tym wiąże, bardzo wydajny, dzięki czemu nie ma bezpośredniej potrzeby zastosowania techniki partycjonowania. Skuteczny optymalizator wyposażony w odpowiednie statystyki dotyczące dystrybucji kluczy w indeksie będzie w stanie zidentyfikować tę anomalię i odpowiednio wykorzystać indeks. Zastosowanie niewielkich partycji dla pomniejszych klientów bok gigantycznej partycji dla kluczowego klienta nie ma zatem większego sensu. Z drugiej strony w przypadku zapytań dotyczących wielkiego klienta ten sam optymalizator może uznać, że najwydajniejszym sposobem pozyskania danych jest sekwencyjne przeszukiwanie tabeli. W takim przypadku partycja zawierająca wyłącznie dane jednego klienta, stanowiące, przyjmijmy, 80% wszystkich zamówień, da niewiele lepsze wyniki niż pełne przeszukiwanie kompletnej tabeli. Użytkownik końcowy wykonujący raporty dla pojedynczego klienta z pewnością nie zauważy różnicy, natomiast departament zamówień z pewnością zauważy dodatkowy koszt czasowy związany z generowaniem na przykład statystyk sprzedażowych dla wszystkich odbiorców. Największa korzyść związana z zapytaniami w partycjonowanych tabelach występuje wówczas, gdy dane są równomiernie rozproszone w ramach klucza partycjonowania.
Najlepszy sposób partycjonowania danych Nie należy zapominać o tym, jakie są najważniejsze przyczyny zastosowania niestandardowych opcji fizycznej reprezentacji danych: chodzi o globalne ulepszenie operacji biznesowych. Może to oznaczać udoskonalenie procesu biznesowego o kluczowym znaczeniu w porównaniu z innymi procesami biznesowymi. Na przykład sens ma dokonywanie optymalizacji transakcji dziennych kosztem wydajności operacji wsadowych wykonywanych w nocy.
UKSZTAŁTOWANIE TERENU
173
Może się również zdarzyć, że sens będzie miała sytuacja odwrotna, szczególnie jeśli koszt czasowy w przypadku dziennych operacji transakcyjnych będzie niewielki, a pomoże to skrócić czas kluczowej dla systemu operacji ładowania danych, która powoduje czasowe wyłączenie systemu z użytkowania. Wybór jest kwestią kompromisu. Należy jednak unikać jednostronnego faworyzowania jednych typów przetwarzania danych kosztem innych, wykonywanych w tych samych warunkach. Każdy typ fizycznej reprezentacji danych w odmienny sposób porządkujący dane, zapisując je w innych lokalizacjach w oparciu o wartości danych, (czyli zarówno indeksy klastrujące, jak i partycje) powoduje, że operacje zapisu są bardziej kosztowne. Operacja modyfikacji danych, która w zwykłej tabeli odbywa się w miejscu, co wymaga jedynie zmiany wartości lub przesunięcia kilku bajtów, jednak znajdujących się w niezmiennym adresie fizycznym, w przypadku partycjonowania może oznaczać usunięcie danych z jednego dysku fizycznego i zapisanie ich na drugim wraz z towarzyszącymi tej operacji wszystkimi działaniami, jak obsługa indeksów itp. Konieczność przenoszenia danych w przypadku modyfikacji klucza partycji może na pierwszy rzut oka wydać się wystarczającym powodem, aby unikać tego typu działań. Jednak istnieją sytuacje, gdy zastosowanie zmiennego klucza partycjonowania jest korzystniejsze od zastosowania w kryterium partycjonowania klucza niezmiennego. Załóżmy na przykład, że mamy tabelę wykorzystywaną jako kolejka usług. Niektóre procesy zapisują do tej tabeli żądania usług różnych typów (przyjmijmy, że są to typy od T do T ). Nowe żądania usługi mają początkowo przypisany status W, co oznacza żądanie oczekujące (ang. waiting). Procesy serwera od S do S regularnie przeglądają tabelę w poszukiwaniu żądań o statusie W, zmieniając go na P (przetwarzany, ang. processed), po czym po zrealizowaniu żądania status zmieniany jest na wartość D (wykonany, ang. done). 1
n
1
p
Załóżmy dodatkowo, że mamy tak wiele procesów serwera, ile jest typów żądań, i że każdy proces jest dedykowany jednemu typowi. Rysunek 5.5 przedstawia kolejkę usług oraz obsługujące ją procesy. Oczywiście nie możemy dopuścić, aby tabela puchła w wyniku pozostawienia w niej procesu żądań o statusie D (wykonane), zatem powinien być zastosowany mechanizm odśmiecający (niepokazany na rysunku), usuwający je po określonym czasie oczekiwania.
174
ROZDZIAŁ PIĄTY
RYSUNEK 5.5. Kolejka obsługi zgłoszeń
Każdy proces serwera regularnie wykonuje zapytanie (a dokładniej SELECT ... FOR UPDATE) według dwóch kryteriów: obsługiwanego przez niego typu żądania oraz następującego warunku: and status = 'W'
Weźmy pod uwagę alternatywne sposoby partycjonowania tabeli kolejki usług. Jeden ze sposobów, najbardziej oczywisty, może polegać na partycjonowaniu po typie żądania. To podejście ma wielką zaletę w przypadku, gdy jeden z procesów będzie miał większe zaległości w przetwarzaniu wskutek napotkania czasochłonnego zlecenia albo awarii. W takiej sytuacji jego kolejka będzie się wydłużała do momentu, gdy nadrobi zaległości, ale w tym czasie pozostałe procesy będą mogły działać bez przeszkód. Inna zaleta partycjonowania po typie żądania polega na tym, że w ten sposób unika się zapchania systemu procesami jednego typu. Bez partycjonowania procesy będą przeglądały kolejkę zawierającą niewielką liczbę interesujących je wierszy. Gdyby nagle okazało się, że w kolejce pojawi się dużo żądań tego samego typu, pozostałe procesy traciłyby czas związany z przeglądaniem tych bezużytecznych dla nich żądań. Jeśli tabela zostałaby spartycjonowana po typie, przetwarzanie poszczególnych typów żądań mogłoby odbywać się niezależnie. Istnieje inny sposób na spartycjonowanie tabeli żądań, opierający się na statusie. Wada tego podejścia jest oczywista: każda zmiana statusu będzie wymuszała przesunięcie z jednej partycji do drugiej. Czy są zalety tego podejścia? Jak się okazuje, tak. Wszystkie pozycje z partycji W oczekują
UKSZTAŁTOWANIE TERENU
175
na realizację. Nie ma zatem potrzeby, aby procesy oczekujące na zadanie do wykonania przeglądały pozostałe partycje, które wszak zawierają dane żądań już przetworzonych. Dzięki temu można znacznie obniżyć koszt czasowy obsługi kolejki. Inna zaleta polega na tym, że odśmiecanie będzie odbywało się na osobnej partycji, dzięki czemu nie będzie zakłócało obsługi kolejki. Nie można jednoznacznie określić, czy partycjonowanie powinno być zdefiniowane według typu, czy według statusu. Decyzja powinna zależeć od tego, ile jest wykorzystywanych procesów obsługujących, a także od ich częstotliwości sprawdzania kolejki, prędkości napływania żądań, średniego czasu ich realizacji, częstotliwości odśmiecania bazy z żądań wykonanych itp. Czasem jednak wydajne dla systemu okazuje się poświęcenie co nieco z wydajności jednej operacji, aby zyskać w przypadku innej, co w sumie spowoduje znaczne przyspieszenie, dzięki czemu zyska się korzyść z punktu widzenia działań biznesowych. Istnieje kilka sposobów partycjonowania tabel, ale najbardziej oczywisty nie zawsze jest najbardziej wydajny. Zawsze należy uwzględniać szerszy kontekst.
Wstępne złączenia tabel Jak mieliśmy okazję się przekonać, fizyczne grupowanie wierszy daje największe korzyści w przypadku zapytań pobierających zakresy danych, gdzie przetwarzane są kolejne wiersze występujące w logicznej kolejności. Jednak nasze rozważania, jak do tej pory, ograniczały się do przypadku pojedynczej tabeli. Natomiast większość zapytań wykorzystuje większą liczbę tabel (chyba że coś jest mocno nie w porządku z projektem bazy danych). Z tego powodu dość wątpliwe mogą okazać się korzyści ze zgrupowania danych w jednej tabeli, gdy dane drugiej i kolejnych tabel zapytania będą już odczytywane z zupełnie przypadkowych lokalizacji. Dlatego potrzebny jest sposób na zgrupowanie danych z przynajmniej dwóch tabel zapisanych w tej samej fizycznej lokalizacji na dysku. Odpowiedzią na te potrzeby są wstępnie złączone tabele (ang. pre joined), technika obsługiwana przez wybrane systemy baz danych. Wstępne złączanie tabel nie jest tożsame z tabelami podsumowań czy zmaterializowanymi perspektywami, które są w rzeczywistości nadmiarowymi danymi,
176
ROZDZIAŁ PIĄTY
spreparowanymi z wyprzedzeniem wynikami zapytań aktualizowanymi w sposób mniej lub bardziej automatyczny. Wstępnie złączone tabele to tabele fizycznie zapisane w ramach jednego zasobu z zastosowaniem kryterium, którym z reguły jest warunek złączenia. W bazie Oracle wstępnie złączone tabele określa się terminem cluster, co jednak nie ma nic wspólnego z klastrem indeksowym ani klastrem baz danych znanym z baz MySQL, polegającym na udostępnieniu tego samego zbioru danych przez kilka fizycznych serwerów. Gdy tabele są wstępnie złączone, w jednej jednostce zapisu (stronie lub bloku), w której standardowo zapisywane są dane jednej tabeli, tym razem zapisane są dane z dwóch lub większej liczby tabel, połączone w oparciu o wspólny klucz złączenia. Taka konfiguracja może być bardzo wydajna w przypadku konkretnych złączeń. Często jednak okazuje się, że w przypadku dowolnych innych zapytań wydajność znacznie spada. Oto kilka wad rozwiązań polegających na wstępnych złączeniach tabel: • Gdy na jednej stronie dyskowej (lub bloku) zapisane są dane kilku tabel, ilość danych pojedynczej tabeli zapisanych na tej stronie znacznie spada, ponieważ ustalona pojemność strony jest współdzielona przez dane kilku tabel. W rezultacie zwiększa się ogólna liczba stron, które muszą być odczytane w celu przejrzenia danych jednej tabeli. A to oznacza więcej operacji wejścia-wyjścia w operacji pełnego przeszukiwania tabeli. • Dane jednej tabeli są rozproszone na większej liczbie stron, ale, co gorsza, efektywny rozmiar strony dostosowany do rozmiaru tabel na etapie projektowania bazy danych powoduje, że nasilają się problemy związane z przepełnieniem (ang. overflow) i wiązaniem stron (ang. chaining). W takich sytuacjach nasila się liczba operacji wejścia-wyjścia niezbędnych do wykonania zapytania. • Co więcej, a doskonale wiedzą to wszyscy, którym zdarzyło się współużytkować mieszkanie z innymi osobami — jedna osoba często zajmuje więcej miejsca, niż to niezbędne, kosztem innych. W bazach danych jest dokładnie tak samo! Jeśli ktoś spróbuje rozwiązać problem przez statyczną alokację miejsca dla danych każdej tabeli w ramach jednej strony, w efekcie pojawia się zjawisko marnotrawstwa miejsca, a w konsekwencji dalsze zwiększenie liczby stron, czyli operacji wejścia-wyjścia.
UKSZTAŁTOWANIE TERENU
177
Tego typu technika porządkowania danych na nośniku powinna być wykorzystywana z dużą ostrożnością, jedynie w przypadkach, gdy pojawia się konieczność rozwiązania konkretnych typów problemów. Zastosowanie jej powinno ograniczać się wyłącznie do administratorów baz danych. Programiści nie powinni uwzględniać możliwości jej zastosowania. Wstępne złączenia tabel to mocno wyspecjalizowana technika optymalizacji zapytań, ale z reguły ma ona negatywny wpływ na praktycznie wszystkie pozostałe sfery działania bazy danych.
Piękno prostoty Rozsądne i bezpieczne założenie mówi, że każda technika uporządkowania danych na dysku inna od domyślnej może wprowadzić dodatkową komplikację, często przekraczającą poziom korzyści, jakie można uzyskać. W najgorszym przypadku źle dobrana opcja uporządkowania danych na nośniku może dramatycznie obniżyć wydajność bazy. Historia wojskowości pełna jest opowieści o fortecach zbudowanych w zupełnie nieodpowiednich miejscach, przez co zupełnie nie spełniały one swoich funkcji, oraz o Wielkich Murach, które nie powstrzymywały ataków, ponieważ niewdzięczny przeciwnik nie chciał zachowywać się zgodnie z założeniami. Wszystkie organizacje ulegają zmianom, jak na przykład podziały i fuzje. Plany biznesowe również mogą się zmieniać. Precyzyjne plany w takich sytuacjach często lądują w koszu i trzeba tworzyć je od nowa. Problem ze strukturalizowaniem danych w konkretny sposób wiąże się z tym, że z reguły ma ono na celu optymalizację określonego procesu. Jedną z zalet modelu relacyjnego jest jego elastyczność. Oczywiście niektóre struktury są mniej ograniczające od innych, a w przypadku ogromnych baz danych praktycznie nie da się uniknąć partycjonowania danych. Zawsze należy jednak zachować ostrożność i mieć na uwadze, że zmiana struktury fizycznej wielkich baz danych, jeśli został popełniony błąd na etapie projektowania, może zająć dni, a często nawet tygodnie. Fizyczne uporządkowanie danych na nośniku odpowiednie w pewnym momencie istnienia systemu, z upływem czasu może stracić na funkcjonalności.
178
ROZDZIAŁ PIĄTY
ROZDZIAŁ SZÓSTY
Dziewięć zmiennych Rozpoznawanie klasycznych wzorców SQL Je pense que pour conserver la clarté dans le récit d’une action de guerre, il faut se borner à… ne raconter que les faits principaux et décisifs du combat. Aby zadbać o klarowność działań militarnych, powinno wystarczyć… zgłoszenie samych faktów, które miały wpływ na decyzję. — Generał Baron de Marbot (1782 – 1854) Wspomnienia, Tom I, xxvi
180
K
ROZDZIAŁ SZÓSTY
ażde wywoływane zapytanie SQL musi przeanalizować nieco danych, zanim będzie w stanie zwrócić oczekiwany wynik albo zmodyfikować zawartość bazy. Sposób, w jaki dane będą „atakowane”, zależy od okoliczności i warunków, w jakich odbywa się „bitwa”. Jak wspominałem w rozdziale 4., atak będzie zależny od ilości danych biorących udział w przetwarzaniu oraz od sił własnych oddziałów (kryteriów filtrujących) w połączeniu z rozmiarem danych zwracanych w wyniku. Każde duże, skomplikowane zapytanie można podzielić na mniejsze, prostsze etapy, z których niektóre mogą być wykonane w sposób równoległy, podobnie jak w przypadku skomplikowanej bitwy, będącej połączeniem potyczek między różnymi jednostkami wojsk. Wyniki poszczególnych potyczek mogą być zupełnie różne. Jednak w rzeczywistości liczy się jedynie wynik ostateczny. Gdy skomplikowany proces rozłoży się na prostsze etapy, nawet jeśli nie uda się osiągnąć poziomu szczegółowości poszczególnych etapów planu wykonawczego zapytania, liczba możliwości nie jest większa od liczby możliwych ruchów w grze w szachy. Jednak, podobnie jak w szachach, potencjalne kombinacje potrafią być naprawdę skomplikowane. W tym rozdziale przeanalizuję sytuacje powszechnie spotykane w prawidłowo znormalizowanej bazie danych. Choć podczas omawiania skupię się na zapytaniach wydobywających dane, opisane zasady można zastosować również do operacji modyfikacji i usuwania, pod warunkiem że zastosowana jest w nich klauzula WHERE. Przy filtrowaniu danych, niezależnie od tego, czy mamy do czynienia z operacjami wydobywania, modyfikowania, czy usuwania danych, najczęściej spotykane są następujące sytuacje: • Niewielki zbiór wynikowy uzyskany z niewielkiej liczby tabel źródłowych w oparciu o precyzyjne kryteria zastosowane na tych samych tabelach. • Niewielki zbiór wynikowy uzyskany w oparciu o precyzyjne kryteria zastosowane na tabelach innych niż tabele źródłowe. • Niewielki zbiór wynikowy uzyskany w wyniku zastosowania części wspólnej kilku mało selektywnych kryteriów.
DZIEWIĘĆ ZMIENNYCH
181
• Niewielki zbiór wynikowy uzyskany z jednej tabeli w oparciu o mało selektywne kryteria zastosowane na dwóch lub większej liczbie dodatkowych tabel. • Wielki zbiór wynikowy. • Zbiór wynikowy uzyskany ze złączenia tabeli samej ze sobą. • Zbiór wynikowy uzyskany w oparciu o funkcje agregujące. • Zbiór wynikowy uzyskany w wyniku wyszukiwania zakresu dat. • Zbiór wynikowy uzyskany w oparciu o brak innych danych. Ten rozdział omawia kolejno każdą z tych sytuacji i ilustruje je za pomocą prostych i bardziej skomplikowanych przykładów wziętych z rzeczywistych systemów. Przykłady wzięte z życia nie zawsze są prostymi, książkowymi, jedno- lub dwutabelowymi złączeniami. Jednak w każdym z nich podstawowy wzorzec jest jasno rozpoznawalny. Praktycznie w każdym zapytaniu podstawową regułą jest odfiltrowanie zbędnych danych, które nie należą do oczekiwanego zbioru wyników. W praktyce oznacza to, że kryteria wyszukiwania muszą być zastosowane tak wcześnie, jak to tylko możliwe. Decyzja o kolejności zastosowania kryteriów filtrujących to z reguły zadanie optymalizatora. Jednak, jak stwierdziłem w rozdziale 4., optymalizator musi wziąć pod uwagę wiele czynników, od fizycznej implementacji tabel po sposób zapisania zapytania. Optymalizatory nie zawsze potrafią dobrze przeanalizować sytuację i istnieją sposoby zapewnienia optymalnej wydajności w każdej z naszych dziewięciu sytuacji.
Niewielki zbiór wynikowy, niewielka liczba tabel źródłowych, precyzyjne, bezpośrednie kryteria Typowe zapytanie transakcyjne online polega na zwracaniu niewielkich zbiorów wynikowych z niewielkiej liczby tabel w oparciu o precyzyjne kryteria zastosowane na tych samych tabelach źródłowych. Gdy chcemy uzyskać niewielki zbiór wynikowy i zastosować precyzyjne kryteria, priorytetem powinny być dla nas prawidłowe indeksy.
182
ROZDZIAŁ SZÓSTY
Trywialny przypadek pojedynczej tabeli, a nawet złączenia dwóch tabel, które zwraca kilka wierszy, rozwiązuje się dzięki zapewnieniu, że zapytanie wykorzysta indeks. Kiedy jednak złączanych jest wiele tabel, a kryteria filtrujące odwołują się do dwóch zupełnie niezależnych tabel TA i TB, możemy postarać się przejść od tabeli TA do tabeli TB albo od TB do TA. Wybór zależy od tego, jak szybko jesteśmy w stanie pozbyć się nadmiaru wierszy z jednej z tabel. Jeśli statystyki wystarczająco precyzyjnie opisują zawartości obydwu tabel, istnieje duża szansa, że optymalizator będzie w stanie podjąć właściwą decyzję odnośnie kolejności złączenia. Gdy zapytanie z założenia ma zwrócić niewielką liczbę wierszy z użyciem bezpośrednich, precyzyjnych kryteriów, należy zidentyfikować kryteria efektywnie filtrujące wiersze. Jeśli kryteria są szczególnie istotne dla zapytania, w pierwszej kolejności należy upewnić się, że kolumny wykorzystywane przez te kryteria są poindeksowane, a przede wszystkim, że te indeksy będą uwzględnione w zapytaniu.
Użyteczność indeksów W rozdziale 3. stwierdziłem, że w przypadku, gdy w kryterium wartość kolumny jest poddana funkcji, indeks na tej kolumnie nie może być użyty. Aby tego uniknąć, należy utworzyć indeks funkcyjny, co oznacza, że indeksowana jest nie oryginalna wartość, a wartość poddana działaniu funkcji. Należy również pamiętać, że nie zawsze istnieje konieczność jawnego wywołania funkcji, aby funkcja została wywołana na kolumnie. Wystarczy, że w kryterium kolumna jednego typu danych jest porównywana z wartością lub kolumna innego typu. W takim przypadku system zarządzania bazami danych dokona niejawnego wywołania funkcji przekształcającej typ, co będzie skutkowało obniżeniem wydajności zapytania. Gdy upewnimy się, że kryteria filtrujące dotyczą poindeksowanych kolumn i że zapytanie jest napisane w sposób umożliwiający wykorzystanie tych indeksów, należy rozróżnić odczyty pojedynczych wierszy z bazy z użyciem unikalnego indeksu oraz inne odczyty: z użyciem nieunikalnego indeksu lub z zakresu wartości z unikalnego indeksu.
DZIEWIĘĆ ZMIENNYCH
183
Wydajność zapytania i użycie indeksów Unikalne indeksy są doskonałą pomocą przy łączeniu tabel. Jeśli jednak dane wejściowe użyte jako parametry zapytania wykorzystują klucz główny i ten klucz główny nie jest uzyskany w wyniku wprowadzania bezpośrednio przez użytkownika lub dane są wczytane z pliku, możemy mieć do czynienia ze źle zaprojektowanym programem. Chodzi o to, że parametry wywołania zapytań powinny pochodzić bezpośrednio od użytkownika lub z innych źródeł, nie zaś z innych zapytań wywoływanych bezpośrednio wcześniej. Jeśli w programie wywoływane jest zapytanie, którego wyniki służą jako parametry wywołania innego zapytania, mamy do czynienia ze źle zaprojektowaną aplikacją. Taka sytuacja oznacza, że zamiast złączenia kilku tabel programista stosuje sekwencyjne wywołania zapytań. Nie zawsze doskonałe zapytania są wywoływane przez doskonałe programy.
Rozproszenie danych Baza danych wykonuje przeszukiwanie zakresowe, gdy indeksy nie są unikalne lub gdy warunki po kluczach unikalnych są wyrażone w postaci zakresu: where customer_id between ... and ...
Inna postać warunku zakresowego: where supplier_name like 'SOMENAME%'
Wiersze związane z kluczem mogą być rozproszone po całej tabeli i tego typu uwarunkowania są często brane pod uwagę przez optymalizator. Możliwe są zatem przypadki, gdy przeszukiwanie z użyciem indeksu zmusi jądro systemu bazy danych do kolejnego odczytania dużej liczby wzajemnie oddalonych stron danych, dlatego optymalizator może zdecydować, że w konkretnym zapytaniu lepiej jest zastosować pełne przeszukiwanie wierszy, ignorując indeks.
184
ROZDZIAŁ SZÓSTY
W rozdziale 5. dowiedzieliśmy się, że wiele systemów baz danych umożliwia partycjonowanie tabel danych lub klastrowanie indeksów w celu zoptymalizowania operacji odczytu logicznie ciągłych partii danych. Jednakże same właściwości procesu dodawania danych do tabeli mogą spowodować, że dane będą zgrupowane w logicznych i fizycznie ciągłych skupiskach. Jeśli jedną z kolumn tabeli, często uzupełnianej o nowe dane, będzie znacznik czasu, istnieje niemała szansa, że większość wierszy tej tabeli będzie fizycznie ułożona obok siebie (o ile nie zostaną zastosowane szczególne zabiegi zapobiegające zjawisku konkurencji o zasoby, o czym więcej w rozdziale 9.). Fizyczne zbliżenie wprowadzanych wierszy danych nie jest zjawiskiem wymaganym, a sama koncepcja uporządkowania kolejności danych jest czymś zupełnie obcym algebrze relacyjnej. W praktyce jednak taka właściwość danych może wystąpić. Z tego powodu przy wyszukiwaniu z użyciem indeksu na kolumnie znacznika czasu danych zbliżonych wzajemnie w czasie istnieje niemała szansa, że będą one również zbliżone fizycznie na dysku. Oczywiście tak samo będzie w przypadku zastosowania specjalnej techniki zapisu danych na dysku, wymuszającej zachowanie takiej właściwości. Gdy wartość klucza nie ma żadnego powiązania z okolicznościami wstawiania danych do tabeli i nie został użyty żaden szczególny sposób uporządkowania danych na dysku, wiersze związane z kluczem lub zakresem wartości klucza będą rozmieszczone na dysku w sposób zupełnie losowy. Klucze w indeksie są z definicji zawsze zapisywane w sposób uporządkowany. Jednak wiersze odpowiadające kolejnym kluczom będą zupełnie rozproszone w tabeli. W praktyce odczyt zakresu wierszy z takiej tabeli wiąże się z koniecznością odczytania większej liczby stron danych niż w przypadku tabeli o tej samej zawartości, ale sklastrowanej według indeksu lub spartycjonowanej. Możemy mieć zatem zdefiniowane dwa indeksy na tej samej tabeli, o identycznym poziomie selektywności, z których jeden będzie działał doskonale, a drugi znacznie gorzej. Taką sytuację omawialiśmy w rozdziale 3., a teraz nadszedł czas, żeby przeanalizować ją głębiej. Aby zilustrować tę sytuację, stworzyłem tabelę o zawartości miliona wierszy i o trzech kolumnach: c1, c2 i c3. Kolumna c1 jest wypełniona sekwencją liczb (od 1 do 1000000), c2 zupełnie losowymi liczbami z zakresu od 1 do 2000000, natomiast c3 losowymi liczbami z zakresu
DZIEWIĘĆ ZMIENNYCH
185
umożliwiającego powstawanie duplikatów. Kolumny c1 i c2 stanowią unikalne klucze kandydujące i są tak samo selektywne. W przypadku indeksu na kolumnie c1 kolejność wierszy w tabeli jest zgodna z kolejnością danych w indeksie. W rzeczywistości pewne działania w tabeli (usuwanie wierszy) mogą spowodować powstanie w niej „dziur”, które mogą następnie zostać wypełnione przez inne wiersze o indeksach spoza sekwencji. Natomiast w przypadku indeksie na kolumnie c2 nie ma zupełnie żadnego związku między kolejnością kluczy a fizyczną kolejnością wierszy w tabeli. Załóżmy, że chcemy odczytać wartość c3 w oparciu o warunek zakresowy: where nazwa_kolumny between wartość and wartość + 10
Wydajność takiego zapytania będzie się znacząco różnić w przypadku zastosowania kolumny c1 i jej indeksu (uporządkowanego, to znaczy takiego, którego kolejność jest zbliżona do fizycznej kolejności danych w tabeli), oraz kolumny c2 i jej indeksu (nieuporządkowanego), co demonstruje rysunek 6.1. Nie należy zapominać, że taka różnica występuje głównie z tego powodu, iż dostęp do poszczególnych danych wymaga stron danych tabeli, w których są zapisane wartości kolumny c3. Gdyby były zastosowane indeksy złożone (c1, c3) lub (c2, c3), czas wykonania zapytania byłby identyczny, ponieważ wszystkie dane zapytania znajdowałyby się w samym indeksie i do odczytania wartości c3 nie byłoby w ogóle konieczne odczytanie danych z fizycznej tabeli.
RYSUNEK 6.1. Różnice w wydajności zapytań, gdy kolejność kluczy indeksu odpowiada fizycznej kolejności danych na dysku
186
ROZDZIAŁ SZÓSTY
Różnica wydajności zademonstrowana na rysunku 6.1 wyjaśnia, dlaczego czasami wydajność znacznie spada z czasem, szczególnie w przypadku nowego systemu, który od początku swojego istnienia zawiera duże ilości danych załadowanych wstępnie ze starych wersji tabel. Może się zdarzyć, że kolejność danych załadowanych jednorazowo do tabeli może sprzyjać wydajności wykonywanych zapytań. Z czasem jednak, gdy do bazy są dodawane nowe wiersze w kolejności niedostosowanej do wydajności zapytań, ich wydajność może spaść nawet o 30% – 40%. Należy postawić sprawę jasno: rozwiązanie typu „silnik bazy danych od czasu do czasu samodzielnie porządkuje dane na dysku” jest w rzeczywistości unikiem, nie prawdziwym rozwiązaniem. Swego czasu rozwiązania tego typu, automatyzujące działania w bazach danych, były dość popularne. Jednak z powodu stale zwiększających się ilości danych, wymagań 99,9999-procentowej niezawodności itp. tego typu działania stały się przeszłością. Jeśli rzeczywiście okaże się, że fizyczne uporządkowanie danych w tabeli jest kluczowe, należy zastosować jedno z rozwiązań opisanych w rozdziale 5., jak klastry indeksowe czy tabele zorganizowane w postaci indeksu. Należy jednak uwzględnić fakt, że modyfikacje struktury korzystne dla operacji jednego typu z reguły mają negatywny wpływ na pozostałe operacje systemu. Nie możemy odnosić sukcesów na wszystkich frontach. Różnice wydajności między porównywalnymi indeksami mogą wynikać z fizycznego rozproszenia danych w tabeli.
Kryteria indeksowania Należy zrozumieć, że odpowiednie indeksowanie z uwzględnieniem określonych kryteriów jest kluczowym elementem definicji „niewielkich zbiorów wynikowych w oparciu o precyzyjne kryteria”. Możemy spotkać się z przypadkami niewielkich zbiorów wynikowych uzyskanych z dość selektywnych kryteriów, ale opartych na sytuacjach, gdzie zastosowanie indeksów w gruncie rzeczy nie jest wskazane. Zademonstruje to rzeczywisty przykład polegający na wyszukiwaniu różnic między wartościami operacji w systemie finansowym. Kryterium użyte w tym przypadku jest dość mocno selektywne, ale nie nadaje się do zastosowania indeksu.
DZIEWIĘĆ ZMIENNYCH
187
W tym przykładzie tabela o nazwie glreport zawiera kolumnę amount_diff, która powinna zawierać wartość zero. Celem tego zapytania jest wyśledzenie błędów księgowych polegających na tym, że wartość w kolumnie amount_diff jest różna od zera. Bezpośrednie odwzorowanie ksiąg na tabele i zastosowanie w nich logiki sięgającej czasu, gdy księgi wypełniało się gęsim piórem, to dość wątpliwej jakości logika systemu informatycznego wykorzystującego relacyjną bazę danych, ale, niestety, na co dzień spotyka się mnóstwo przykładów tego typu wątpliwej logiki. Pomijając jakość projektu tego systemu, kolumna o zawartości tego typu co amount_diff to typowy przykład kolumny, na której nie należy zakładać indeksu. W końcu kolumna amount_diff w idealnym przypadku powinna zawierać same zera; na marginesie — jest ona efektem zastosowania denormalizacji, a jej wartości są wynikiem obliczeń wykonywanych na innych wartościach tabeli. Utrzymanie indeksu na kolumnie, której wartości są wynikiem obliczeń, to jeszcze bardziej kosztowna decyzja od utrzymywania indeksu na kolumnie statycznej, ponieważ modyfikacje wartości klucza spowodują przesunięcia w ramach indeksu, co z kolei spowoduje, że indeks będzie ulegał większej liczbie zmian niż w przypadku zwykłego usuwania i dodawania węzłów. Nie wszystkie kryteria wyszukiwania dające selektywne wyniki nadają się do indeksowania. W szczególności dotyczy to kolumn, których wartości ulegają częstym modyfikacjom, co zwiększa koszt ich utrzymania.
Wracając do przykładu: programista, którego zadanie miało polegać na optymalizacji następującego zapytania w bazie Oracle, poprosił mnie o ocenę jego planu wykonawczego: select total.deptnum, total.accounting_period, total.ledger, total.cnt, error.err_cnt, cpt_error.bad_acct_count from -- pierwsza perspektywa osadzona (select deptnum, accounting_period,
188
ROZDZIAŁ SZÓSTY
ledger, count(account) cnt from glreport group by deptnum, ledger, accounting_period) total, -- druga perspektywa osadzona (select deptnum, accounting_period, ledger, count(account) err_cnt from glreport where amount diff <> 0 group by deptnum, ledger, accounting_period) error, -- trzecia perspektywa osadzona (select deptnum, accounting_period, ledger, count(distinct account) bad_acct_count from glreport where amount_diff <> 0 group by deptnum, ledger, accounting_period ) cpt_error where total.deptnum = error.deptnum(+) and total.accounting_period = error.accounting_period(+) and total.ledger = error.ledger(+) and total.deptnum = cpt_error.deptnum(+) and total.accounting_period = cpt_error.accounting_period(+) and total.ledger = cpt_error.ledger(+) order by total.deptnum, total.accounting_period, total.ledger
DZIEWIĘĆ ZMIENNYCH
Dla czytelników niezaznajomionych ze składnią Oracle: wystąpienia znaku + oznaczają złączenia zewnętrzne, innymi słowy: select cokolwiek from ta, tb where ta.id = tb.id (+)
jest równoważne z: select cokolwiek from ta outer join tb on tb.id = ta.id
Plan wykonawczy tego zapytania można sprawdzić za pomocą następującego wywołania: 10:16:57 SQL> set autotrace traceonly 10:17:02 SQL> / 37 rows selected. Elapsed: 00:30:00.06
Sam plan wykonawczy jest następujący: 0 1 2 3 4 5 6 7 8 9 10 11 12 13
SELECT STATEMENT Optimizer=CHOOSE (Cost=1779554 Card=154 Bytes=16170) 0 MERGE JOIN (OUTER) (Cost=1779554 Card=154 Bytes=16170) 1 MERGE JOIN (OUTER) (Cost=1185645 Card=154 Bytes=10780) 2 VIEW (Cost=591736 Card=154 Bytes=5390) 3 SORT (GROUP BY) (Cost=591736 Card=154 Bytes=3388) 4 TABLE ACCESS (FULL) OF 'GLREPORT' (Cost=582346 Card=4370894 Bytes=96159668) 2 SORT (DOIN) (Cost=593910 Card=154 Bytes=5390) 6 VIEW (Cost=593908 Card=154 Bytes=5390) 7 SORT (GROUP BY) (Cost=593908 Card=154 Bytes=4004) 8 TABLE ACCESS (FULL) OF 'GLREPORT' (Cost=584519 Card=4370885 Bytes=113643010) 1 SORT (JOIN) (Cost=593910 Card=154 Bytes=5390) 10 VIEW (Cost=593908 Card=154 Bytes=5390) 11 SORT (GROUP BY) (Cost=593908 Card=154 Bytes=5698) 12 TABLE ACCESS (FULL) OF 'GLREPORT' (Cost=584519 Card=4370885 Bytes=161722745)
Statistics 193 recursive calls 0 db block gets
189
190
ROZDZIAŁ SZÓSTY
3803355 3794172 1620 2219 677 4 17 0 37
consistent gets physical reads redo size bytes sent via SQL*Net to client bytes received via SQL*Net from client SQL*Net roundtrips to/from client sorts (memory) sorts (disk) rows processed
Muszę przyznać, że niewiele czasu poświęciłem temu planowi wykonawczemu, ponieważ najbardziej uderzające wnioski można było wyciągnąć z samego kodu zapytania. Wynika z niego, że tabela glreport, niewielka, zaledwie 4 – 5-milionowa, jest odczytywana trzy razy, po jednym dla każdego podzapytania, i za każdym razem odbywa się pełne jej przeszukiwanie. Zapytania zagnieżdżone są często użyteczne w przypadku skomplikowanych zapytań, szczególnie w sytuacjach, gdy każdy etap można logicznie wyodrębnić w postaci podzapytania. Zagnieżdżone zapytania nie są złotym środkiem, a w poprzednim przykładzie mamy wyraźny dowód na to, jak łatwo można ich nadużyć. Pierwsza osadzona perspektywa zapytania oblicza liczbę kont w każdym departamencie, okres księgowy oraz kartotekę rozrachunkową. W tym podzapytaniu odbywa się pełne przeszukiwanie tabeli i nie można mu zapobiec. Trzeba spojrzeć prawdzie w oczy: pełne przeszukiwanie tabeli jest niezbędne, ponieważ w celu sprawdzenia liczby kont musimy przejrzeć wszystkie wiersze. Tabelę musimy przejrzeć raz, ale czy jest absolutnie konieczne, żeby przeszukiwać ją drugi i trzeci raz? Jeśli niezbędne jest dokonanie pełnego przeszukiwania tabeli, jej indeksy przestają mieć znaczenie.
Znaczenie ma nie tylko pełny, analityczny wgląd w szczegóły przetwarzania, należy także zrobić krok do tyłu, aby spojrzeć na proces jako całość. Druga perspektywa osadzona zlicza dokładnie te same elementy co pierwsza, z tą różnicą, że na wartości amount_diff nie jest nałożony żaden warunek. Dzięki temu zamiast stosować funkcję count(), można przy okazji pierwszego zliczania dodać jeden do licznika dla wierszy, w których
DZIEWIĘĆ ZMIENNYCH
191
amount_diff ma wartość różną od zera, lub zero — w przeciwnym razie. Z użyciem funkcji decode(u, v, w, x), specyficznej dla Oracle, to zadanie jest bardzo proste. Innym sposobem jest zastosowanie bardziej standardowej konstrukcji case when u = v then w else x end.
Trzecia osadzona perspektywa filtruje te same wiersze co druga, tym razem w celu wyodrębnienia poszczególnych numerów kont. Te obliczenia trochę trudniej będzie osadzić w ramach pierwszego zapytania. Pomysł polega na zastąpieniu numerów kont (zdefiniowanych w tabeli jako typ varchar21) bezsensowną wartością w przypadku, gdy amount_diff jest równa zeru. Doskonała do tego zadania wydaje się funkcja chr(1) (jest to znak o kodzie ASCII równym 1). Przy okazji: zawsze mam wątpliwości, czy w programie napisanym w języku C, jak na przykład Oracle, warto stosować wartość chr(0), ponieważ język C używa znaku chr(0) w charakterze znaku sygnalizującego koniec ciągu znaków. Następnie możemy policzyć liczbę unikalnych numerów kont i od wyniku odjąć jeden, aby pozbyć się wystąpień kont, których numery zastąpiliśmy znakiem chr(1). Poniżej kod, jaki zasugerowałem programiście: select deptnum, accounting_period, ledger, count(account) nb, sum(decode(amount_diff, 0, 0, 1)) err_cnt, count(distinct decode(amount_diff, 0, chr(1), account)) - 1 bad_acct_count from glreport group by deptnum, ledger, accounting_period
Moja propozycja okazała się do czterech razy szybsza od oryginalnego zapytania, co nie powinno dziwić, biorąc pod uwagę, że cztery pełne przeszukiwania tabeli zostały zredukowane do jednego.
1
Dla wszystkich Czytelników niezaznajomionych z Oracle: we wszystkich praktycznych zastosowaniach typ varchar2 niczym nie różni się od typu varchar.
192
ROZDZIAŁ SZÓSTY
Warto zauważyć, że w zapytaniu nie występuje już klauzula WHERE: można powiedzieć, że warunek filtrujący wykorzystujący kolumnę amount_diff został rozproszony między logikę funkcji decode() w ramach klauzuli SELECT a agregację wykonywaną w ramach klauzuli GROUP BY. Zastąpienie specyficznego warunku funkcją agregującą dowodzi, że mieliśmy tu do czynienia z inną sytuacją, a dokładniej z przypadkiem zbioru wynikowego uzyskanego w oparciu o funkcję agregującą. Zapytania osadzone mogą uprościć zapytania, lecz zastosowane w sposób nieuważny mogą skutkować nadmierną ilością wielokrotnie powtarzanych operacji.
Niewielki zbiór wynikowy, pośrednie kryteria Weźmy pod uwagę sytuację przypominającą poprzednią: mamy do uzyskania niewielki zbiór wyników w oparciu o kryteria zastosowane na tabelach innych niż tabela główna. Potrzebne są dane z jednej tabeli, ale warunki mają być zastosowane do innej, powiązanej z główną tabelą więzami integralności, ale z niej nie chcemy zwracać żadnych danych. Typowy przykład: którzy klienci zamówili określony towar? Taki przykład analizowaliśmy pokrótce w rozdziale 4. Jak mieliśmy okazję się wtedy przekonać, ten typ zapytania można wyrazić na jeden z dwóch sposobów: • w postaci zwykłego złączenia z klauzulą DISTINCT usuwającą zduplikowane wiersze, których wystąpienie w tabeli spowodowane zostało na przykład sytuacją, gdy ten sam klient wielokrotnie zamówił ten sam towar, • z użyciem skorelowanego lub nieskorelowanego podzapytania. Jeśli mamy możliwość zastosowania szczególnie selektywnego kryterium wyboru z tabeli (lub tabel), z której chcemy wydobyć zbiór wynikowy, nie ma potrzeby stosowania żadnych technik oprócz omówionych w poprzednim podrozdziale „Niewielki zbiór wynikowy, niewielka liczba tabel źródłowych, precyzyjne, bezpośrednie kryteria”. Zapytanie w takiej sytuacji jest realizowane w oparciu o selektywne kryteria i zastosowanie mają tu te same zasady. Jeśli jednak nie ma możliwości zastosowania tego typu selektywnego kryterium, należy zachować nieco więcej ostrożności.
DZIEWIĘĆ ZMIENNYCH
193
Weźmy za przykład uproszczoną wersję zapytania z rozdziału 4. Chodzi o zidentyfikowanie klientów, którzy zamówili Batmobile. To typowe zastosowanie następującego zapytania: select distinct orders.custid from orders join orderdetail on (orderdetail.ordid = orders.ordid) join articles on (articles.artid = orderdetail.artid) where articles.artnane = 'BATMOBILE'
Moim zdaniem znacznie lepiej jest sprawdzić, czy towar istnieje wśród zamówień klienta z użyciem podzapytania — tak jest po prostu bardziej czytelnie. Jednak czy to podzapytanie powinno być skorelowane, czy nieskorelowane? Nie mamy innego kryterium, zatem odpowiedź powinna być oczywista: nieskorelowane. W przeciwnym razie wystąpiłaby konieczność przeszukania całej tabeli zamówień dla każdego wiersza tabeli klientów: taki poważny błąd często przechodzi niezauważony w przypadku tabeli zamówień niewielkich rozmiarów (w czasie testów), ale okazuje się znacznym problemem w przypadku rzeczywistej bazy. Nieskorelowane podzapytanie można napisać za pomocą klauzuli IN: select distinct orders.custid from orders where ordid in (select orderdetails.ordid from orderdetail join articles on (articles.artid = orderdetail.artid) where articles.artname = 'BATMOBILE')
Inny sposób polega na stworzeniu podzapytania w ramach klauzuli FROM: select distinct orders.custid from orders, (select orderdetails.ordid from orderdetail join articles on (articles.artid = orderdetail.artid) where articles.artname = 'BATMOBILE') as sub_q where sub_q.ordid = orders.ordid
W tym drugim przypadku zapytanie jest, moim zdaniem, bardziej czytelne, ale oczywiście to kwestia gustu. Nie należy zapominać, że w przypadku klauzuli IN() wynik podzapytania ujętego w warunku jest dodatkowo
194
ROZDZIAŁ SZÓSTY
poddawany niejawnej operacji DISTINCT, co z kolei wiąże się z koniecznością posortowania danych, a to powoduje, że działanie odbywa się poza zakresem modelu relacyjnego. W przypadku zapytań złożonych zawsze należy uważnie zastanowić się nad wyborem podzapytania skorelowanego lub nieskorelowanego.
Niewielki zbiór wynikowy, część wspólna ogólnych kryteriów W tym podrozdziale zajmiemy się generowaniem zbiorów wynikowych w wyniku zastosowania części wspólnej kilku kryteriów o szerokim zakresie wyników. Każde takie kryterium zastosowane indywidualnie spowoduje zwrócenie dużego zbioru wynikowego, ale część wspólna tych rozległych zakresów może doprowadzić do powstania niewielkiego zbioru wynikowego. Kontynuujmy przykład z poprzedniego podrozdziału. Jeśli kryterium wyboru artykułu nie byłoby selektywne, należałoby zastosować inne, dodatkowe kryteria wyboru (w przeciwnym razie zbiór wynikowy nie byłby mały). W takim przypadku wybór między klasycznym złączeniem a podzapytaniem (skorelowanym lub nieskorelowanym) będzie uzależniony od relatywnej „siły” kryteriów wyboru i istniejących indeksów. Załóżmy, że zamiast sprawdzania klientów, którzy zamówili Batmobil, będący nie najlepiej sprzedającym się towarem, będziemy poszukiwać klientów, którzy zamówili mniej unikalny towar — w tym przypadku mydło — ale dokładnie w zeszłą sobotę. Nasze zapytanie będzie miało następującą postać: select distinct orders.custid from orders join orderdetail on (orderdetail.ordid = orders.ordid) join articles on (articles.artid = orderdetail.artid) where articles.artname = 'SOAP' and
DZIEWIĘĆ ZMIENNYCH
195
Całkiem logicznie, przepływ przetwarzania będzie odwrotny niż w przypadku selektywnego kryterium po artykule: pobieramy artykuł, następnie pozycje zamówień dotyczących artykułu, a na końcu zamówienia. W aktualnie omawianym przypadku powinniśmy najpierw pobrać niewielki podzbiór zamówień złożonych w stosunkowo krótkim przedziale czasu, po czym sprawdzić, które z nich zawierają poszukiwany artykuł, czyli mydło. W praktyce będzie wykorzystywany zupełnie inny zestaw indeksów. W pierwszym przypadku najlepiej byłoby, gdyby istniał indeks po nazwie artykułu w tabeli articles oraz po identyfikatorze artykułu w tabeli orderdetail, używany byłby też indeks klucza głównego ordid w tabeli orders. W przypadku zamówień mydła przydatny byłby indeks po dacie zamówienia w tabeli orders oraz indeks po kolumnie orderid w tabeli orderdetail, za pomocą którego uzyskujemy dostęp do klucza głównego w tabeli articles, oczywiście przy założeniu, że w obydwu przypadkach optymalizator uzna, iż wykorzystanie indeksów stanowi najlepszą ścieżkę wykonawczą. Oczywistym wyborem w przypadku zapytania pobierającego klientów, którzy kupili mydło poprzedniej soboty, będzie następujące zapytanie: select distinct orders.custid from orders where and exists (select 1 from orderdetail join articles on (articles.artid = orderdetail.artid) where articles.artname = 'SOAP' and orderdetails.ordid = orders.ordid)
W tym podejściu zakładamy, że skorelowane podzapytanie wykona się bardzo szybko. Nasze założenie okaże się prawdziwe w przypadku, gdy tabela orderdetail jest poindeksowana po kolumnie ordid (sam artykuł będzie wyszukany po kluczu głównym artid). W rozdziale 3. mieliśmy okazję przekonać się, że indeksy są w pewnym sensie luksusem w transakcyjnych bazach danych — z powodu wysokiego kosztu utrzymania w sytuacji częstych operacji dodawania wierszy do tabeli, ich modyfikowania i usuwania. Ten koszt może spowodować, że nie zawsze będziemy mieli szansę korzystać z optymalnego rozwiązania. Brak indeksu kluczowego dla zapytania (czyli po ordid w tabeli orderdetail) oraz dobry powód, aby go nie zakładać, mogą spowodować, że będziemy skazani na zastosowanie następującego zapytania:
196
ROZDZIAŁ SZÓSTY
select distinct orders.custid from orders, (select orderdetails.ordid from orderdetail, articles where articles.artid = orderdetail.artid and articles.artname = 'SOAP') as sub_q where sub_q.ordid = orders.ordid and
W tym drugim podejściu wymagania w stosunku do indeksów są inne. Jeśli nie sprzedajemy dużej liczby różnych artykułów, prawdopodobne jest, że warunek wybierający artykuł jest na tyle wydajny, iż będzie działał wydajnie nawet bez indeksu po artname. Prawdopodobnie nie będziemy potrzebowali indeksu po kolumnie artid tabeli orderdetail: jeśli artykuł jest popularny i występuje w wielu zamówieniach, złączenie tabel orderdetail i articles będzie pewnie bardziej wydajne z użyciem złączenia typu hash lub merge niż z użyciem zagnieżdżonej pętli potrzebującej indeksu po kolumnie artid. W odróżnieniu od pierwszego podejścia, drugie można nazwać rozwiązaniem o niskim użyciu indeksów. Nie możemy pozwolić sobie na tworzenie indeksów dla każdej kolumny w tabeli, ponieważ z reguły w każdej aplikacji znajduje się pewna grupa „drugorzędnych” zapytań niebędących dla niej absolutnie niezbędnymi, ale wymagających akceptowalnego czasu reakcji. Podejście o niewielkim użyciu indeksów ma szansę działać na akceptowalnym poziomie wydajności. Dodanie dodatkowego kryterium wyszukiwania do istniejącego zapytania może całkowicie zmienić poprzednią konstrukcję: zmodyfikowane zapytanie to nowe zapytanie.
Niewielki zbiór wynikowy, pośrednie, uogólnione kryteria Pośrednie kryterium to takie, które odwołuje się do kolumny w tabeli biorącej udział w złączeniu wyłącznie w celu zastosowania tego kryterium. Odczyt niewielkiego zbioru wynikowego z użyciem części wspólnej dwóch lub większej liczby kryteriów, jak w poprzedniej sytuacji omówionej w podrozdziale „Niewielki zbiór wynikowy, część wspólna ogólnych kryteriów”, to często odstraszająca perspektywa. Uzyskanie części
DZIEWIĘĆ ZMIENNYCH
197
wspólnej z dużej liczby wyników pośrednich przez złączenie ich z jedną tabelą centralną albo, co gorsza, za pomocą łańcucha złączeń, może ze skomplikowanej sytuacji zrobić jeszcze bardziej zawikłaną. Ta sytuacja jest typowa dla tak zwanego „schematu gwiazdy”, który omówię szczegółowo w rozdziale 10., lecz można ją dość często spotkać w operacyjnych bazach danych. Gdy ma się do czynienia z kombinacją wielu rozłącznych warunków, można oczekiwać, że w pewnym etapie zapytania będziemy mieli do czynienia z pełnym przeszukiwaniem tabeli. Ten przypadek jest szczególnie interesujący, gdy w grę wchodzi kilka tabel. Silnik bazy danych musi zdecydować, gdzie „postawić pierwszy krok”. Nawet jeśli jest w stanie przetwarzać dane w sposób równoległy, musi w pewnym momencie zacząć od jednej tabeli, indeksu lub partycji. Nawet jeśli zbiór wynikowy zdefiniowany jako część wspólna kilku wielkich zbiorów danych ma małe rozmiary, może być potrzebne pełne przeszukiwanie tabeli, albo kilku, w ramach zagnieżdżonej pętli, złączenia typu hash lub złączenia typu merge wykonanego na wyniku. Problem polega na zidentyfikowaniu kombinacji tabel (niekoniecznie tych najmniejszych), jaka da wynik zawierający najmniejszą liczbę wierszy, z którego będą wydobywane wiersze zbioru wynikowego. Innymi słowy, musimy znaleźć najsłabszy punkt w linii obrony wroga, a po jego wyeliminowaniu skupić się na osiągnięciu zwycięstwa, czyli uzyskaniu zbioru wynikowego. W ramach przykładu posłużę się przypadkiem wziętym z życia, w oparciu o bazę danych Oracle. Oryginalne zapytanie było dość skomplikowane, dwie z tabel występowały dwukrotnie w ramach klauzuli FROM. Choć żadna z nich nie była bardzo duża (większa zawierała około siedmiuset tysięcy wierszy), problem polegał na tym, że dziewięć parametrów użytych w kryteriach wyboru zapytania było naprawdę bardzo selektywnych: select (data from ttex_a, ttexb, ttraoma, topeoma, ttypobj, ttrcap_a, ttrcap_b, trgppdt, tstg_a) from ttrcapp ttrcap_a, ttrcapp ttrcap_b, tstg tstg_a,
198
ROZDZIAŁ SZÓSTY
topeoma, ttraoma, ttex ttex_a, ttex ttexb, tbooks, tpdt, trgppdt, ttypobj where ( ttraoma.txnum = topeoma.txnum ) and ( ttraoma.bkcod = tbooks.trscod ) and ( ttex_b.trscod = tbooks.permor ) and ( ttraoma.trscod = ttrcap_a.valnumcod ) and ( ttex_a.nttcod = ttrcapjj.valnumcod ) and ( ttypobj.objtyp = ttraoma.objtyp ) and ( ttraoma.trscod = ttex_a.trscod ) and ( ttrcap_a.colcod = :0 ) -- nieselektywne and ( ttrcap_b.colcod = :1 ) -- nieselektywne and ( ttraoma.pdtcod = tpdt.pdtcod ) and ( tpdt.risktyp = trgppdt.risktyp ) and ( tpdt.riskflg = trgppdt.riskflg ) and ( tpdt.pdtcod = trgppdt.pdtcod ) and ( trgppdt.risktyp = :2 ) -- nieselektywne and ( trgppdt.riskflg = :3 ) -- nieselektywne and ( ttraoma.txnum = tstg_a.txnum ) and ( ttrcap_a.refcod = :5 ) -- nieselektywne and ( ttrcap_b.refcod = :6 ) -- nieselektywne and ( tstg_a.risktyp = :4 ) -- nieselektywne and ( tstg_a.chncod = :7) -- nieselektywne and ( tstg_a. stgnum = :8 ) -- nieselektywne
Po wywołaniu z odpowiednimi parametrami (oznaczonymi w zapytaniu jako :0 do :8) zapytanie wykonuje się dłużej niż dwadzieścia pięć sekund i zwraca poniżej dwudziestu wierszy. W tym czasie wykonuje około trzystu operacji wejścia-wyjścia, podczas których odczytuje trzy miliony bloków danych. Statystyki optymalizatora odpowiednio reprezentują dane w tabelach (w przypadku problemów z zapytaniem jest to jedna z pierwszych rzeczy, które należy sprawdzić). Tabele wykorzystywane w zapytaniu mają następujące liczby wierszy (poniższa tabela przedstawia wynik zapytania wywołanego na słowniku danych). TABLE_NAME NUM_ROWS ---------------- ---------ttypobj 186 trgppdt 366 tpdt 5370 topeoma 12118
DZIEWIĘĆ ZMIENNYCH
ttraoma tbooks ttex ttrcapp tstg
199
12118 12268 102554 187759 702403
Skrupulatna analiza tabel i ich związków pozwala narysować „plan bitwy”, przedstawiony na rysunku 6.2. Słabe kryteria są odwzorowane małymi strzałkami, tabele natomiast są prostokątami o rozmiarach proporcjonalnych do liczby zapisanych w nich wierszy. Warto zwrócić szczególną uwagę na jeden fakt: centralna pozycja, czyli tabela ttraoma, jest połączona powiązaniami z prawie każdą z pozostałych tabel. Niestety, wszystkie nasze kryteria mają zastosowanie w pozostałych tabelach, nie w tej najważniejszej. Przy okazji interesujący może wydać się fakt, że w warunku wykorzystujemy kolumny risktyp i riskflg tabeli trgppdt — te same, które (wraz z pdtcod) służą do dokonania jej złączenia z tabelą tpdt. W takim przypadku warto rozważyć możliwość odwrócenia przepływu przetwarzania, na przykład wydobyć w tabeli tpdt tylko te wartości, jakie spełniają kryteria wyboru, w oparciu o które następnie zostaną wydobyte dane z tabeli trgppdt.
RYSUNEK 6.2. Pozycje nieprzyjaciela
Większość systemów zarządzania bazami danych pozwala zapoznać się z planem wykonawczym wybranym przez optymalizator. Służy do tego polecenie explain lub bardziej bezpośrednie sprawdzenie w pamięci sekwencji wykonawczej po wywołaniu zapytania. W wersji zapytania wykonywanej w czasie dwudziestu pięciu sekund jego plan wykonawczy, niezbyt atrakcyjny, był w przeważającej mierze zbudowany na sekwencyjnym przeszukiwaniu tabeli ttraoma, po którym następowała sekwencja
200
ROZDZIAŁ SZÓSTY
zagnieżdżonych pętli dość efektywnie wykorzystujących istniejące indeksy (opisywanie wszystkich istniejących indeksów zajęłoby sporo miejsca, dość stwierdzić, że wszystkie kolumny biorące udział w złączeniach były prawidłowo poindeksowane). Czy powodem takiej powolności wykonania było owo sekwencyjne przeszukiwanie tabeli? Z pewnością nie. Aby się o tym przekonać, wystarczy wykonać prosty test: odczytać wszystkie wiersze z tabeli ttraoma (bez wyświetlania na ekranie, aby uniknąć opóźnień związanych z obsługą ekranu). Ten test dowodzi, że sekwencyjne przeszukiwanie tabeli ttraoma stanowi jedynie niewielki ułamek czasu wykonania całego zapytania. Gdy się weźmie pod uwagę słabe kryteria, można stwierdzić, że nasze siły są zbyt mizerne, abyśmy mogli pokusić się o frontalny „atak na siły wroga”, czyli tabelę tstg. Również „atak” na ttrcap nie zawiedzie nas daleko, ponieważ również w jej przypadku kryteria są słabe, a sama tabela występuje w zapytaniu dwukrotnie. Jednak powinno być oczywiste, że centralna pozycja tabeli ttraoma, która jest relatywnie niewielka, powoduje, iż „atak” na nią w pierwszej kolejności jest dość sensowny — dokładnie taką decyzję podejmuje optymalizator bez żadnych zabiegów skłaniających go do tego. Jeśli pełne przeszukiwanie tabeli nie jest powodem powolnego wykonania zapytania, gdzie leży przyczyna? Krótki rzut oka na rysunek 6.3 pozwoli lepiej rozeznać się w sytuacji planu wykonawczego zapytania.
RYSUNEK 6.3. Ścieżka wykonawcza wybrana przez optymalizator
DZIEWIĘĆ ZMIENNYCH
201
Gdy spojrzy się na kolejność operacji, wszystko staje się jasne: kryteria są tak słabe, że optymalizator zdecydował się je całkowicie zignorować. Optymalizator, dość rozsądnie, przyjął za punkt wyjścia pełne przeszukiwanie tabeli ttraoma, po którym wykonał niezbędne operacje na powiązanych z nią niewielkich tabelach, a następnie przeszedł do większych tabel, na których wykorzystał kryteria filtrujące. Błąd leży właśnie w tym podejściu. Prawdopodobnie indeksy małych tabel wyglądają dla optymalizatora o wiele atrakcyjniej z powodu mniejszej średniej liczby wierszy w tabeli na pojedynczy klucz, a być może dlatego, że były lepiej dopasowane do fizycznej kolejności wierszy w tabeli. Jednak odkładanie na później operacji zastosowania kryteriów filtrujących nie jest najlepszym sposobem, aby zmniejszyć liczbę wierszy, które muszą być następnie przetworzone i sprawdzone. Gdy już przeszukujemy tabelę ttraoma i mamy odczytaną pozycję klucza, dlaczego nie przejść bezpośrednio do tabel, dla których mamy zdefiniowane kryteria? Złączenie między tymi tabelami a ttraoma pozwoli wyeliminować zbędne wiersze z ttraoma przed złączeniem z pomniejszymi tabelami. Taka taktyka ma szansę powodzenia, ponieważ — i taką informację posiadamy my, a nie ma jej optymalizator — wiemy, że we wszystkich przypadkach powinniśmy uzyskać kilka wierszy wyniku, co oznacza, że wszystkie kryteria w połączeniu ze sobą powinny spowodować liczne straty w tabeli ttraoma. Nawet jeśli liczba wierszy wynikowych byłaby większa, taka ścieżka wykonawcza powinna nadal być stosunkowo wydajna. W jaki zatem sposób „nakłonić” silnik bazy danych do wykonania zapytania w oczekiwany sposób? To zależy od dialektu SQL. W rozdziale 11. omówię to bardziej szczegółowo, dość stwierdzić, że różne dialekty SQL-a pozwalają zastosować dyrektywy lub wskazówki dla optymalizatora. Każdy dialekt wykorzystuje odmienną składnię zapisu tych wskazówek. Można na przykład wskazać, żeby optymalizator wykorzystał tabele w kolejności ich wpisania w klauzuli FROM zapytania. Problem z tego typu wskazówkami polega na tym, że bywają bardziej kategoryczne, niż sugerowałaby to ich nazwa. Każda taka wskazówka niesie ze sobą pewne ryzyko na przyszłość: jest to bowiem założenie, że okoliczności, rozmiary danych, algorytmy baz danych, sprzęt i inne czynniki nie zmienią się na tyle, iż wymuszony plan wykonawczy nadal pozostanie optymalny lub choćby akceptowalny. Jednak w przypadku z naszego przykładu z faktu, że zagnieżdżone pętle wykorzystujące indeksy są najwydajniejszym rozwiązaniem, oraz że
202
ROZDZIAŁ SZÓSTY
zagnieżdżone pętle nie dają się przyspieszyć z użyciem przetwarzania równoległego, można wnioskować, iż podejmujemy niewielkie ryzyko, biorąc pod uwagę dalszą ewolucję tabel. Wymuszenie kolejności przetwarzania zapytania zostało również wykorzystane w przypadku rzeczywistego problemu, który posłużył mi za przykład. W efekcie zapytanie wykonywało się w czasie poniżej sekundy, mimo iż wykorzystywało niewiele mniej operacji wejścia-wyjścia (dwa tysiące trzysta czterdzieści w porównaniu z trzema tysiącami). Nie jest to niespodzianka, ponieważ za punkt wyjścia — również w zmodyfikowanej postaci zapytania — przyjęliśmy pełne przeszukiwanie tabeli. Jednak dzięki temu, że „zasugerowaliśmy” wydajniejszą ścieżkę przetwarzania, logiczna liczba operacji wejścia-wyjścia (liczba odczytywanych bloków) spadła dramatycznie — do szesnastu tysięcy pięciuset z ponad trzech milionów. I właśnie to miało tak znaczący wpływ na czas reakcji. Należy skrupulatnie dokumentować przyczyny podjęcia decyzji o wymuszeniu ścieżki wykonawczej.
Wymuszanie kolejności odczytu tabel wykorzystujące dyrektywy optymalizatora to mało eleganckie podejście. Nieco bardziej subtelny sposób na zasugerowanie optymalizatorowi, żeby zastosował określoną sekwencję, o ile nie ingeruje on brutalnie w zapytania SQL, polega na zastosowaniu zagnieżdżonych zapytań. W ten sposób można zasugerować powiązania między operacjami tak samo, jak nawiasy sugerują kolejność wykonania operacji arytmetycznych: select (lista kolumn) from (select ttraoma.txnum, ttraoma.bkcod, ttraoma.trscod, ttraoma.pdtcod, ttraoraa.objtyp, ... from ttraoma, tstg tstg_a, ttrcapp ttrcap_a where tstg_a.chncod = :7 and tstg_a.stgnum = :8 and tstg_a.risktyp = :4 and ttraoma.txnum = tstg_a.txnum and ttrcap_a.colcod = :0
DZIEWIĘĆ ZMIENNYCH
203
and ttrcap_a.refcod = :5 and ttraoma.trscod = ttrcap_a.valnumcod) a, ttex ttex_a, ttrcapp ttrcap_b, tbooks, topeoma, ttex ttex_b, ttypobj, tpdt, trgppdt where ( a.txnum = topeoma.txnum ) and ( a.bkcod = tbooks.trscod ) and ( ttexb.trscod = tbooks.permor ) and ( ttex_a.nttcod = ttrcap_b.valnumcod ) and ( ttypobj.objtyp = a.objtyp ) and ( a.trscod = ttex_a.trscod ) and ( ttrcap_b.colcod = :1 ) and ( a.pdtcod = tpdt.pdtcod ) and ( tpdt.risktyp = trgppdt.risktyp ) and ( tpdt.riskflg = trgppdt.riskflg ) and ( tpdt.pdtcod = trgppdt.pdtcod ) and ( tpdt.risktyp = :2 ) and ( tpdt.riskflg = :3 ) and ( ttrcap_b.refcod = :6 )
Nierzadko zbyteczne okazuje się bardzo dokładne określanie sposobu wykonania zapytania za pomocą serii precyzyjnych dyrektyw. Często pierwsza, solidna wskazówka wystarcza, aby naprowadzić optymalizator na właściwą ścieżkę rozumowania. Zagnieżdżone zapytania pozwalają zapisać w jawnej postaci pewne powiązania między tabelami, co ma tę dodatkową zaletę, że takie zapytanie jest znacznie bardziej czytelne dla człowieka. Zapytanie napisane w sposób nieczytelny może wprowadzić w błąd również optymalizator. Czytelność i jednoznaczność złączeń często pomagają optymalizatorowi zapewnić jak najlepszą wydajność.
Wielki zbiór wynikowy Sytuacja dużego zbioru wynikowego obejmuje wszystkie przypadki (za wyjątkiem przypadków szczególnych, omówionych w pozostałych podrozdziałach), gdy mamy do czynienia z wynikami większych rozmiarów. Tego typu zbiory wynikowe są z reguły stosowane w przetwarzaniu wsadowym — gdy potrzebujemy dużej liczby wierszy, nawet w przypadku,
204
ROZDZIAŁ SZÓSTY
gdy ta „duża liczba” stanowi zaledwie ułamek ogólnej liczby wierszy w tabelach biorących udział w zapytaniu. Kryteria filtrowania są z reguły mało selektywne, a silnik bazy danych wykonuje pełne przeszukiwanie tabeli, za wyjątkiem szczególnych przypadków hurtowni danych, które omówię w rozdziale 10. Gdy zapytanie zwraca dziesiątki tysięcy wierszy, nieważne, czy będą one wykorzystane bezpośrednio, czy jako pośredni etap większego przetwarzania, z reguły nie ma większego sensu poszukiwać subtelnych indeksów i szybkich przeskoków z indeksu do tabeli danych. W takim przypadku najczęściej godzimy się z koniecznością żmudnego przeszukiwania całych tabel, z reguły w połączeniu ze złączeniami typu hash lub merge. Jednak za tą „brutalną siłą” musi kryć się jakaś inteligencja. Zawsze należy skanować te obiekty (tabele, indeksy czy partycje tabel i indeksów), w których współczynnik ilości danych w tabeli do danych zwróconych jest jak najkorzystniejszy. Należy sekwencyjnie przeszukiwać obiekty, których filtrowanie jest najbardziej skuteczne, ponieważ najlepszym uzasadnieniem „wysiłku” włożonego w pełne przeszukiwanie tabeli jest uzyskanie jak największej korzyści na zmniejszeniu rozmiaru danych biorących udział w następnych etapach zapytania. Sytuacja pełnego przeszukiwania tabeli to typowy przykład wyjątku od reguły jak najwcześniejszego odfiltrowywania jak największej ilości danych; jednak po zakończeniu pełnego przeszukiwania należy natychmiast powrócić do tej reguły. Jak zwykle gdy przeszukiwanie zbędnych wierszy tabeli uznamy za zbędną pracę, należy zminimalizować liczbę odczytywanych bloków danych. Jak zwykle w celu minimalizacji liczby odczytywanych bloków warto do przeszukiwania wykorzystać indeksy, nie same tabele. Mimo tego że indeksy w całości zajmują z reguły więcej miejsca niż same dane, każdy pojedynczy indeks jest zwykle mniejszy od tabeli. Przy założeniu, że indeks zawiera wszystkie niezbędne informacje, wykorzystanie go zamiast tabeli ma sporo sensu. Rozwiązania polegające na dodawaniu kolumny do istniejącego indeksu w celu uniknięcia dokonywania odczytu z tabeli również często zdają egzamin. Przy przetwarzaniu dużej liczby wierszy, niezależnie od tego, czy mają być zwrócone z zapytania, czy tylko sprawdzone, należy zwrócić baczną uwagę na to, jakie działania są podejmowane przy każdym wierszu. Wywołania nieoptymalnych, zdefiniowanych przez użytkownika funkcji nie mają większego wpływu na wydajność, jeśli odbywają się w ramach listy SELECT
DZIEWIĘĆ ZMIENNYCH
205
zapytania wykorzystującego bardzo selektywne kryteria wyboru i zwracającego niewielką liczbę wierszy lub gdy za pomocą takiej funkcji definiowane są dodatkowe kryteria bardzo selektywnej klauzuli WHERE. Jeśli jednak taka funkcja będzie wywoływana setki tysięcy razy, statystyka już nie będzie nam pomocna i każda słabość kodu może doprowadzić system do granic wydajności. W takim przypadku jest czas na kod niewielki i wydajny. Szczególną uwagę należy zwrócić na podzapytania. Podzapytania skorelowane stanowią „śmiertelne zagrożenie” dla wydajności przy przetwarzaniu ogromnej liczby wierszy. Jeśli w zapytaniu uda się zidentyfikować kilka podzapytań, należy każdemu z nich pozwolić działać na wydzielonym, „samowystarczalnym” podzbiorze, usuwając zależność jednego podzapytania od wyniku innego. Zależności między różnymi zbiorami danych uzyskanych w sposób niezależny można rozwiązać na ostatnim etapie zapytania głównego za pomocą złączeń typu hash lub zbioru operatorów. Opieranie strategii na przetwarzaniu współbieżnym może być dobrym pomysłem, ale wyłącznie w przypadku, gdy w chwili wywołania nie istnieje wiele sesji działających równolegle. Takie założenie można przyjąć z reguły w przypadku zadań wsadowych. Zrównoleglenie przetwarzania w takiej formie, jaka jest zaimplementowana w systemie zarządzania bazami danych, polega na dzieleniu, w miarę możliwości, jednego zapytania na wiele podzadań, które mogą być uruchamiane równolegle i koordynowane przez dedykowane zadanie. W przypadku przetwarzania dużej liczby wierszy przetwarzanie równoległe jest czymś oczywistym, ponieważ w grę wchodzi wiele pomniejszych zadań odbywających się równolegle. Połączenie tego naturalnego mechanizmu przetwarzania równoległego z wymuszonymi mechanizmami wbudowanymi w system zarządzania bazami danych może w rzeczywistości obniżyć wydajność zapytania, zamiast ją poprawić. Przetwarzanie dużych ilości danych przy zastosowaniu dużej liczby równoległych zadań kojarzy się nieodparcie z sytuacją, gdy pozostało nam jedynie mężnie zginąć, więc wrzuca się na pole bitwy wszystko, co ma się w zanadrzu. Czasy reakcji są zależne głównie od ilości przeszukiwanych danych, pomijając czas oczekiwania na dostęp do zasobów w trakcie przetwarzania. Nie należy jednak zapominać, że, jak wspominałem w rozdziale 4., subiektywna percepcja użytkownika końcowego może znacznie odbiegać od chłodnej, obiektywnej analizy rozmiaru kopy siana: jego interesuje jedynie jedna niewielka igiełka…
206
ROZDZIAŁ SZÓSTY
Złączenia tabeli samej ze sobą W prawidłowo zaprojektowanej bazie danych (od trzeciej postaci normalnej wzwyż) wszystkie kolumny niewchodzące w skład klucza muszą dostarczać informacji na temat klucza, być identyfikowane za pomocą całego klucza i niczego więcej oprócz klucza2. Każdy wiersz jest bowiem zarówno logicznie spójny, jak i odmienny od wszystkich pozostałych wierszy tej samej tabeli. To właśnie ta cecha postaci normalnej pozwala definiować związki w ramach tej samej tabeli. W jednym zapytaniu można zatem wybrać różne (choć niekoniecznie rozłączne) zbiory wierszy z jednej tabeli i złączyć je w taki sposób, jakby pochodziły z różnych tabel. W tym podrozdziale omówię proste złączenie tabeli samej ze sobą. Pominę bardziej skomplikowane przykłady zagnieżdżonych hierarchii, do których wrócę w rozdziale 7. Złączenia tabeli samej ze sobą są znacznie bardziej powszechne, niż mogłoby się wydawać (z reguły kojarzą się one ze strukturami hierarchicznymi). W niektórych przypadkach dzieje się tak z tego powodu, że są potrzebne dwa spojrzenia na te same dane. Na przykład na liście lotów możemy dwukrotnie odwołać się do tabeli lotnisk: raz po to, żeby odczytać nazwę lotniska źródłowego, a drugi raz lotniska docelowego. Na przykład: select f.flight_number, a.airport_name departure_airport, b.airport_name arrival_airport from flights f, airports a airports b where f.dep_iata_code = a.iata_code and f.arr_iata_code = b.iata_code
W takim przypadku mają zastosowanie standardowe reguły: należy upewnić się, że w zapytaniu zastosowanie ma wydajny indeks. Co jednak zrobić, jeśli okaże się, że zastosowane kryteria uniemożliwiają wykorzystanie indeksu? Oczywiście wolelibyśmy uniknąć sytuacji, gdy wykonujemy dwa przejścia przeszukiwania tabeli, drugi raz po to, aby odczytać wiersze odrzucone w pierwszym przebiegu. W takim przypadku najlepiej byłoby poprzestać na pojedynczym przebiegu, zebrać wszystkie wiersze, które są 2
Tylko w jednym miejscu udało mi się znaleźć źródło tej formuły: pochodzi ona z artykułu Williama Kenta z 1983 roku. Można go znaleźć na stronie http://www.bkent.net.
DZIEWIĘĆ ZMIENNYCH
207
potrzebne, po czym uporządkować wyniki w taki sposób, aby wyświetlić wyniki z obydwu zbiorów wynikowych. Przykłady takiego podejścia „jednoprzebiegowego” przedstawię w rozdziale 11. Istnieją również przykłady, które tylko pozornie przypominają przypadek lotów. Załóżmy, że w jednej tabeli zapisujemy kumulatywne wartości pobierane w regularnych odstępach czasu i że chcemy wyświetlić informacje na temat tego, jak bardzo licznik zmieniał się między kolejnymi pomiarami3. W takim przypadku istnieje powiązanie między dwoma wierszami tej samej tabeli, ale zamiast silnego związku z innej tabeli, jak tabela lotów zawierająca związki między dwoma lotniskami, mamy słaby związek wewnętrzny: dwa wiersze są powiązane nie dlatego, że są powiązane za pomocą kluczy obcych zapisanych w innej tabeli, a dlatego, że znacznik czasu jednego wiersza występuje natychmiast przed znacznikiem czasu kolejnego. Jeśli założymy, że pomiary następują co pięć minut dla znacznika czasu wyrażanego w sekundach, jakie upłynęły od ostatniego odczytu, możemy zastosować następujące zapytanie: select a.timestamp, a.statistic_id, (b.counter - a.counter)/5 hits_per_minute from hit_counter a, hit_counter b where b.timestamp = a.timestamp + 300 and b.statistic_id = a.statistic_id order by a.timestamp, a.statistic_id
W tym skrypcie jest poważny błąd: jeśli drugi odczyt nie odbył się dokładnie pięć minut po pierwszym, z dokładnością do sekundy, nie będziemy mieli możliwości złączenia wierszy. Dlatego lepiej, jeśli złączenie będzie zdefiniowane warunkiem zakresowym: select a.timestamp, a.statistic_id, (b.counter - a.counter) * 60 / (b.timestamp - a.timestamp) hits_per_minute from hit_counter a, hit_counter b
3
W taki sposób działają perspektywy V$ w Oracle zawierające informacje monitorujące.
208
ROZDZIAŁ SZÓSTY
where b.timestamp between a.timestamp + 200 and a.timestamp + 400 and b.statistic_id = a.statistic_id order by a.timestamp, a.statistic_id
Jednym z efektów ubocznych tego podejścia jest to, że w przypadku większych odstępów między pomiarami (na przykład z powodu zmiany ich częstotliwości) dwa kolejne rekordy nie będą już znajdowały się w odstępie od 200 do 400 sekund. Zadanie możemy rozwiązać jeszcze inaczej, stosując funkcję OLAP działającą na podzbiorach (oknach) wierszy. W rzeczywistości trudno wyobrazić sobie coś o charakterze mniej relacyjnym, ale taka funkcja okazuje się użyteczna przy formatowaniu wyników zapytań, czasem też zdarza się, że pozwala uzyskać lepszą wydajność. Funkcje OLAP pozwalają wykonywać działania na podzbiorach zbioru wynikowego, do czego służy klauzula PARTITION. Na tych podzbiorach można wykonywać sortowania, obliczać sumy i stosować inne podobne funkcje. Możemy wykorzystać funkcję OLAP row_number(), za pomocą której stworzy się podzbiór w oparciu o statistic_id, a następnie każdemu pomiarowi nada się kolejny numer całkowity (licznik pomiarów). Po nadaniu tych numerów za pomocą funkcji OLAP można dokonać złączenia po statistic_id i dwóch numerach sekwencji, jak w poniższym przykładzie: select a.timestamp, a.statistic_id, (b.counter - a.counter) * 60 / (b.timestamp - a.timestamp) from (select timestamp, statistic_id, counter, row_number() over (partition by statistic_id order by timestamp) rn from hit_counter) a, (select timestamp, statisticid, counter, row_number() over (partition by statistic_id order by timestamp) rn from hitcounter) b where b.rn = a.rn + 1 and a.statistic_id = b.statisticid order by a.timestamp, a.statistic_id
DZIEWIĘĆ ZMIENNYCH
209
Można to zrobić jeszcze lepiej, do 25% szybciej od ostatniego wyniku, pod warunkiem że silnik baz danych obsługuje odpowiednik funkcji OLAP lag(nazwa_kolumny, n) (dostępną w Oracle), funkcję zwracającą n-tą poprzednią wartość kolumny o podanej nazwie w oparciu o określone partycjonowanie i sortowanie: select timestamp, statistic_id, (counter - prev_counter) * 60 / (timestamp - prevtimestamp) from (select timestamp, statistic_id, counter, lag(counter, 1) over (partition by statistic_id order by timestamp) prev_counter, lag(timestamp, 1) over (partition by statistic_id order by timestamp) prev_timestamp from hit_counter) a order by a.timestamp, a.statistic_id
W wielu przypadkach trudno liczyć na podobną symetrię w danych, co widać doskonale na przykładzie lotów. Z reguły zapytanie wyszukujące wszystkie dane związane z najmniejszą, największą, najstarszą lub najnowszą wartością w określonej kolumnie najpierw musi odszukać tę najmniejszą, największą, najstarszą lub najnowszą wartość (w pierwszym przebiegu porównującym wartości w wierszach), po czym przeszukać tę samą tabelę ponownie, jako kryteria wyszukiwania wykorzystując wartość odczytaną w pierwszym przebiegu. Te dwa przebiegi można połączyć w jeden (choćby pozornie), wykorzystując funkcje OLAP działające w oparciu o przesuwane okna wierszy. Zapytania zastosowane na wartościach danych związanych ze znacznikami czasu lub datami stanowią przypadki szczególne bardziej ogólnej sytuacji omówionej szczegółowo w podrozdziale „Proste i zakresowe wyszukiwanie dat”. Gdy na różnych wierszach jednej tabeli sprawdzane są wielokrotne kryteria wyszukiwania, mogą być przydatne funkcje wykorzystujące przesuwane okna.
210
ROZDZIAŁ SZÓSTY
Zbiór wynikowy uzyskany w oparciu o funkcje agregujące Bardzo często zdarza się sytuacja, gdy zbiór danych jest dynamicznie obliczonym podsumowaniem szczegółowych danych pochodzących z jednej tabeli lub większej liczby tabel. Innymi słowy, chodzi o agregację danych. Gdy dane są agregowane, rozmiar zbioru wynikowego nie jest uzależniony od precyzji kryteriów, ale od liczności kolumn, po których odbywa się grupowanie. Podobnie jak w pierwszej sytuacji niewielkiego zbioru wynikowego uzyskanego w oparciu o selektywne kryteria (o czym będę szerzej opowiadał w rozdziale 11.), funkcje agregujące (czyli agregaty) bywają użyteczne do pozyskiwania w pojedynczym przebiegu wyników niezagregowanych, do uzyskania których bez użycia agregatów potrzeba byłoby dokonać złączenia tabeli samej ze sobą lub wykonania wielu przebiegów. W rzeczywistości najciekawsze zastosowania agregatów w SQL-u to nie te, które obliczają sumy częściowe, lecz sytuacje, gdy zmyślne użycie funkcji agregujących pozwala uzyskać w czystym SQL-u alternatywę dla przetwarzania proceduralnego. W rozdziale 2. podkreśliłem, że jednym z kluczowych założeń wydajnego programowania w SQL-u jest bezkompromisowe wywoływanie zapytań. Chodzi o to, że zapytanie modyfikujące dane wywołuje się od razu, po czym sprawdza się, czy zostało wykonane skutecznie, zamiast wywoływać wcześniej zapytania weryfikujące, czy dane spełniają warunki kwalifikujące do wywołania zapytania modyfikującego. Nie da się wygrać wyścigu pływackiego, na każdym kroku ostrożnie sprawdzając, czy woda jest odpowiednio głęboka. Drugie założenie jest takie, że w pojedyncze zapytanie SQL „pakuje się” jak najwięcej operacji i właśnie ta koncepcja prowadzi nas do spostrzeżenia o szczególnej użyteczności funkcji agregujących. Większość problemów z programowaniem w SQL-u bierze się z tego, że programista zastanawia się, w jaki sposób rozbić problem na zapytania, zamiast zastanawiać się, w jaki sposób uzyskać jak najmniejszą liczbę zapytań. Jeśli w programie potrzeba dużej liczby zmiennych pośrednich przechowujących wartości uzyskane z bazy danych tylko po to, aby w oparciu o nie wywołać inne zapytania, i jeśli te zmienne są poddawane jedynie prostym testom, można z dużym prawdopodobieństwem założyć, że algorytm odczytu z bazy danych jest niewłaściwy. A uderzającą cechą kiepsko napisanego kodu SQL jest duża liczba wierszy kodu obok kodu
DZIEWIĘĆ ZMIENNYCH
211
SQL służącego do obliczania podsumowań, mnożeń, dzieleń i odejmowań w ramach pętli po wierszach danych żmudnie odczytanych z bazy danych. Taki sposób programowania to po prostu marnotrawstwo: do obliczeń na danych służą agregaty SQL-a. UWAGA Funkcje agregujące są bardzo przydatne do rozwiązywania różnych zagadnień w SQL-u (do tego tematu wrócimy w rozdziale 11., w którym będę omawiał strategie). Często jednak się zdarza, że programista wykorzystuje najmniej interesującą z funkcji agregujących: count(), której użyteczność w większości przypadków jest w najlepszym razie wątpliwa.
W rozdziale 2. mieliśmy okazję przekonać się, że marnotrawstwem jest wykorzystanie funkcji count(*) do podejmowania decyzji na temat tego, czy należy modyfikować istniejący wiersz, czy dopisać nowy. W raportach również zdarza się błędnie wykorzystywać funkcję count(*). Zdarzają się na przykład takie konstrukcje, będące swoistą parodią wyrażeń boolowskich: case count(*) when 0 then 'N' else 'Y' end
Taka implementacja odczytuje wszystkie wiersze spełniające warunek i oblicza ich liczbę, na której podstawie zwraca wynik. A przecież wystarczy znaleźć tylko jedną pozycję, aby zdecydować, że wynik brzmi 'Y'. Z reguły można napisać znacznie wydajniejsze wyrażenie, wykorzystując konstrukcję ograniczającą liczbę zwróconych wierszy albo test występowania, co spowoduje dalsze przetwarzanie w przypadku, gdy warunek zostanie spełniony. Jeśli jednak zapytanie ma odpowiedzieć na pytanie, jaki jest największy, najmniejszy, a nawet pierwszy czy ostatni element, istnieje szansa, że najlepszym rozwiązaniem jest zastosowanie funkcji agregujących (być może w formie funkcji OLAP). Jeśli jednak ktoś uznał, że funkcje agregujące służą jedynie do obliczania liczników, sum, maksimów, minimów czy wartości średnich, istnieje ryzyko, że nie będzie umiał wykorzystać ich pełnego potencjału. Co interesujące, funkcje agregujące mają bardzo wąski zakres zastosowań. Jeśli wykluczy się obliczanie maksimów i minimów, do dyspozycji pozostaje prosta arytmetyka: count() to nic innego, jak dodawanie wartości 1 przy każdym wierszu spełniającym warunek. Podobnie obliczanie wartości avg()
212
ROZDZIAŁ SZÓSTY
to z jednej strony zsumowanie wartości w określonej kolumnie, a z drugiej dodawanie jedności do licznika i dzielenie tych dwóch wartości na końcu. Ale często można się zdziwić, jak wiele można uzyskać za pomocą samych sum. Osoby z matematycznym zacięciem wiedzą, jak łatwo przejść z dodawania do mnożenia dzięki logarytmom i potęgowaniu. A osoby z zacięciem logicznym z pewnością mają świadomość, jak wiele wspólnego z dodawaniem ma operacja OR, a z mnożeniem operacja AND. Możliwości agregacji zademonstruję na prostym przykładzie. Załóżmy, że mamy liczbę dostaw i że każda dostawa składa się z określonej liczby zamówień, z których każde jest realizowane osobno. Dostawa jest realizowana dopiero po zrealizowaniu wszystkich zamówień wchodzących w jej skład. Problem polega na tym, w jaki sposób sprawdzić, czy zostały zrealizowane wszystkie zamówienia wchodzące w skład dostawy. Jak to często bywa, istnieje kilka sposobów sprawdzenia, czy dostawy są zrealizowane. Zapewne najgorszy z nich polega na przeszukaniu wszystkich dostaw: dla każdej dostawy wykonywane byłoby podzapytanie zliczające wszystkie zamówienia z wartością N w kolumnie order_complete i zwracające dostawy o liczniku równym zeru. O wiele lepsze rozwiązanie wykonywałoby test występowania wartości N w zamówieniach i z użyciem podzapytania (skorelowanego lub nieskorelowanego): select shipment_id from shipments where not exists (select null from orders where order_complete = 'N' and orders.shipment_id = shipments.shipment_id)
To podejście nie jest zbyt dobre w przypadku, gdy na tabeli dostaw nie ma żądnych dodatkowych warunków. Następne zapytanie będzie znacznie wydajniejsze w przypadku dużej tabeli dostaw i niewielu niezrealizowanych zamówień: select shipment_id from shipments where shipment_id not in (select shipment_id from orders where order_complete = 'N')
DZIEWIĘĆ ZMIENNYCH
213
To zapytanie może być również wyrażone nieco inaczej. Ten wariant może być lepiej przyjęty przez optymalizator, ale przydatny jest tu indeks na kolumnie shipment_id tabeli orders: select shipments.shipment_id from shipments left outer join orders on orders.shipment_id = shipments.shipment_id and orders.order_complete = 'N' where orders.shipmentid is null
Kolejna alternatywa wykorzystuje indeks klucza głównego tabeli shipments, a z drugiej strony pełne przeszukiwanie tabeli orders: select shipment_id from shipments except select shipment_id from orders where order_complete = 'N'
Należy mieć na uwadze, że nie wszystkie systemy baz danych obsługują operator EXCEPT, znany czasem również jako MINUS. Istnieje jeszcze jeden sposób osiągnięcia oczekiwanego wyniku. Właściwie poszukujemy tych identyfikatorów dostaw, dla których operacja AND po wszystkich statusach zamówień da wynik TRUE. Tego typu operacja jest dość powszechna również w rzeczywistości. Jak wspominałem wcześniej, operator AND jest dość mocno związany z arytmetycznym mnożeniem, podobnie jak OR z dodawaniem. Klucz do rozwiązania leży w przekształceniu wartości (znaczników) Y i N na jedynki i zera. Aby przekształcić znacznik order_complete na zero lub jeden, wykorzystamy następujące wyrażenie: select shipment_id, case when order_complete * 'Y' then 1 else 0 end flag from orders
Na razie idzie nieźle. Gdybyśmy zawsze mieli stałą liczbę zamówień w dostawie, wystarczyłoby zsumować wartości w tak przekształconej kolumnie i porównać z wzorcową liczbą zamówień. My jednak chcemy przemnożyć wartości przekształconej kolumny, aby sprawdzić, czy wynik wynosi zero, czy jeden. Ta „sztuczka” zadziała, ponieważ przynajmniej
214
ROZDZIAŁ SZÓSTY
jedno niezrealizowane zamówienie spowoduje, że wynik mnożenia znaczników wszystkich zamówień wyniesie zero. Mnożenia natomiast można dokonać z użyciem logarytmów (choć zera nie są szczególnie mile widziane w działaniach logarytmicznych). Ale w tym konkretnym przypadku nasze zadanie jest jeszcze prostsze. Potrzebne są nam dostawy, dla których pierwsze zamówienie jest zrealizowane i drugie zamówienie jest zrealizowane i…, i n-te zamówienie jest zrealizowane. Logika i prawa de Morgana4 mówią, że to jest dokładnie to samo, co stwierdzenie, że nie mamy sytuacji, w której pierwsze zamówienie jest niezrealizowane i drugie zamówienie jest niezrealizowane i…, i n-te zamówienie jest niezrealizowane. Z tego powodu oraz z faktu, iż operacje OR, zbliżone do sumowania, jest znacznie łatwiej zrealizować z użyciem agregatów niż operacje AND, sprawdzenie, że lista warunków połączonych operatorem OR ma wartość FALSE jest znacznie łatwiejsze od sprawdzenia, że lista warunków połączonych operatorem AND ma wartość TRUE. Naszym predykatem jest zatem „zamówienie jest niezrealizowane”, nie odwrotnie. Znaczniki realizacji zamówienia musimy oczywiście przekształcić odwrotnie, do pierwotnego założenia: N na 1 i Y na 0. W ten sposób możemy z łatwością sprawdzić, czy wszędzie mamy zero (czyli wartość prawdziwą) w znaczniku realizacji zamówienia. Jeśli suma znaczników wyniesie zero, oznacza to, że wszystkie zamówienia wysyłki są zrealizowane, w przeciwnym razie otrzymamy informację o liczbie niezrealizowanych zamówień. Nasze zapytanie zapiszemy następująco: select shipment_id from (select shipment_id, case when order_complete * 'N' then 1 else 0 end flag from orders) s group by shipment_id having sum(flag) = 0 4
August de Morgan (1806 – 1871) był brytyjskim matematykiem urodzonym w Indiach. Wniósł wkład do wielu dziedzin matematyki, ale najistotniejsze jego dokonania dotyczą logiki. Prawa de Morgana mówią, że dopełnieniem części wspólnej dowolnej liczby zbiorów jest unia ich dopełnień i że dopełnieniem unii dowolnej liczby zbiorów jest część wspólna ich dopełnień. Jeśli pamiętamy o tym, że SQL operuje na zbiorach i że zanegowanie warunku jest równoważne dopełnieniu zbioru wynikowego tego warunku (jeśli w wyniku nie występują NULL-e), łatwo zrozumieć, jakie znaczenie prawa de Morgana mają dla praktyków języka SQL.
DZIEWIĘĆ ZMIENNYCH
215
Powyższe można wyrazić w sposób jeszcze prostszy: select shipment_id from orders group by shipment_id having sum(case when order_complete = 'N' then 1 else 0 end) = 0
Istnieje jeszcze inny sposób zapisania tego zapytania, jeszcze prostszy, wykorzystujący inną funkcję agregującą, niewymagający przekształcania znaczników na zera i jedynki. Zważywszy, że Y występuje (alfabetycznie) po N, nietrudno zauważyć, że minimum wartości w kolumnie znacznika stanu realizacji zamówień będzie równe Y wtedy i tylko wtedy, gdy wszystkie wartości w tej kolumnie mają wartość Y. Stąd otrzymujemy: select shipment_id from orders group by shipment_id having min(order_complete) = 'Y'
Podejście wykorzystujące porównanie znaków Y i N być może nie opiera się na tak ciekawym podłożu matematycznym jak podejście wykorzystujące przekształcenie znaczników na zera i jedynki, ale jest nie mniej efektywne. Warto porównać wydajność zapytania wykorzystującego operator group by i warunek sprawdzający minimum wartości kolumny order_complete z innymi zapytaniami, wykorzystującymi podzapytania lub operator EXCEPT zamiast agregatów. To zapytanie musi posortować tabelę orders w celu zagregowania jej wartości i sprawdzenia, czy ich suma jest równa zeru. Jak wspominałem, to rozwiązanie wykorzystujące nietrywialne użycie agregatów ma szansę działać wydajniej od pozostałych, które wszak przeszukują dwie tabele (dostawy i zamówienia), potencjalnie w sposób mniej efektywny. W poprzednich przykładach intensywnie wykorzystywałem klauzulę HAVING. Jak wspominałem w rozdziale 4., powszechnym przykładem lekkomyślnych zapytań SQL są te, w których wykorzystuje się klauzulę HAVING wraz z funkcjami agregującymi. Tego typu przykładem może być następujące zapytanie (w dialekcie Oracle), które pobiera średnie tygodniowe wartości sprzedaży z okresu ostatniego miesiąca: select product_id, trunc(sale_date, 'WEEK'), sum(sold_qty)
216
ROZDZIAŁ SZÓSTY
from sales_history group by product_id, trunc(sale_date, 'WEEK') having trunc(sale_date, 'WEEK') >= add_month(sysdate, -1)
Błąd w tym zapytaniu polega na tym, że warunek w ramach klauzuli HAVING nie jest zależny od agregatu. W efekcie silnik bazy danych musi przetworzyć wszystkie dane tabeli sales_history, posortować je i zagregować, po czym odfiltrować wszystkie wartości spoza wyznaczonego przedziału czasowego. Tego typu błąd może przejść niezauważony w przypadku, gdy tabela sales_history ma niewielkie rozmiary. Prawidłowe podejście polega oczywiście na umieszczeniu warunku w ramach klauzuli WHERE, co zapewni odfiltrowanie zbędnych wartości na wczesnym etapie zapytania, dzięki czemu funkcje agregujące będą wywoływane na znacznie zredukowanym zbiorze pośrednim. Warto zauważyć, że w przypadku zastosowania kryteriów na perspektywach wykorzystujących agregaty, możemy natknąć się na dokładnie ten sam problem, chyba że optymalizator jest wystarczająco inteligentny i potrafi zastosować kryteria filtrujące przed rozpoczęciem agregacji. Przykładów zastosowania filtra w późniejszym etapie, niż powinno się to odbyć, może być znacznie więcej: select customer_id from orders where orderdate < add_months(sysdate, -1) group by customer_id having sum(amount) > 0
W tym zapytaniu warunek having sum(amount) > 0 wygląda na sensowny. Jednak zastosowanie klauzuli HAVING nie ma zbyt wielkiego sensu w przypadku, gdy wartości składowe prawie zawsze są dodatnie lub równe zeru. W takiej sytuacji o wiele lepiej jest zastosować następujący warunek: where amount > 0
Tu mamy dwie możliwości. Możemy pozostawić grupowanie: select customer_id from orders where orderdate < add_months(sysdate, -1) and amount > 0 group by customer_id
DZIEWIĘĆ ZMIENNYCH
217
Możemy też wykorzystać spostrzeżenie, że grupowanie nie jest już potrzebne do obliczenia agregatu, i zastąpić je operatorem DISTINCT, który posłuży tu jako mechanizm sortujący i eliminujący duplikaty: select distinct customer_id from orders where order_date < add_months(sysdate, -1) and amount > 0
Warunek umieszczony w klauzuli WHERE pozwala odfiltrować zbędne wiersze na wczesnym etapie, dzięki czemu zapytanie będzie działać znacznie wydajniej. Należy agregować jak najmniej danych jednocześnie.
Wyszukiwanie z zakresu dat Wśród kryteriów wyszukiwania daty (i czas) zajmują własne, eksponowane miejsce. Dane typu czasowego są bardzo powszechne i częściej niż innego typu dane wykorzystywane do wyszukiwania zakresowego ograniczonego z obydwu stron („między datą A a datą B”) lub jednostronnie („przed datą C”). Bardzo często zbiór wynikowy jest uzyskiwany za pomocą wyszukiwania w odniesieniu do daty bieżącej (np. „sześć miesięcy temu” itp.). Jeden z przykładów z poprzedniego podrozdziału „Zbiór wynikowy uzyskany w oparciu o funkcje agregujące” wykorzystywał tabelę historii sprzedaży. Zastosowaliśmy wówczas warunek wykorzystujący ilość zamówienia, ale tabele tego typu najczęściej są przeglądane z użyciem warunków wykorzystujących daty, szczególnie gdy chodzi o uzyskanie informacji o stanie w określonym punkcie czasu lub zmianach, które wystąpiły w okresie między dwoma datami. Poszukując wartości na określoną datę w danych historycznych, należy zwrócić szczególną uwagę na sposób identyfikacji danych bieżących. Sposób obsługi danych bieżących może bowiem być przypadkiem szczególnym zastosowania warunku opartego na funkcji agregującej. W rozdziale 1. zauważyłem, że projektowanie tabel na potrzeby zapisu danych historycznych to niełatwe zadanie i że nie istnieją gotowe, optymalne rozwiązania. Wiele zależy od tego, w jaki sposób planuje się
218
ROZDZIAŁ SZÓSTY
wykorzystywać dane, czy jesteśmy zainteresowani przede wszystkim obsługą danych bieżących, czy danych z określonego punktu czasu. Wybór rozwiązania zależy również od tego, jak szybko dane bieżące stają się danymi historycznymi. Jeśli baza danych ma obsługiwać system hurtowni, w którym będą zapisywane informacje na temat sprzedawanych towarów, istnieje szansa (chyba że w kraju panuje hiperinflacja), że tempo zmian danych dotyczących cen będzie stosunkowo wolne. Będzie ono jednak znacznie szybsze w przypadku systemu rejestrującego ceny na rynku finansowym lub monitorującego ruch sieciowy. Największe znaczenie w przypadku tabel historycznych ma współczynnik ilości danych historycznych w stosunku do danych bieżących. Może się zdarzyć, że wielka tabela zawiera mnóstwo danych historycznych dotyczących kilku elementów albo niewiele danych historycznych bardzo dużej liczby elementów. Chodzi o to, że selektywność każdego elementu zależy od liczby śledzonych elementów, częstotliwości próbkowania (raz dziennie lub dla każdej zmiany w ciągu dnia) oraz od okresu, za który odbywa się śledzenie zmian (bezterminowo, rocznie itp.). Z tego powodu najpierw rozważymy przypadek, gdy mamy dużo elementów i stosunkowo niewiele danych historycznych, po czym przypadek odwrotny: niewiele elementów i dużą ilość danych historycznych. Na koniec zostawię zagadnienie optymalnej reprezentacji wartości bieżących.
Wiele elementów, niewiele danych historycznych Jeśli dla każdego elementu nie przechowujemy ogromnej ilości danych historycznych, sam wybór pojedynczego elementu jest dość selektywną operacją. Określenie elementu ogranicza „zbiór roboczy” do kilku wierszy historycznych, co powoduje, że dość łatwo jest zidentyfikować odpowiednią wartość w oparciu o datę odniesienia (bieżącą lub historyczną). Wybiera się bowiem wartość o najbliższej dacie poprzedzającej datę odniesienia. W tym przypadku ponownie posłużymy się funkcjami agregującymi. O ile w tabeli nie został zastosowany sztuczny klucz zastępczy (a w przypadku danych historycznych naprawdę nie ma powodu stosować zabiegów tego typu), klucz główny będzie wygenerowany w oparciu o klucz złożony z identyfikatora elementów (item_id) oraz po dacie związanej z wartością (record_date). Istnieją dwa podstawowe sposoby identyfikacji wierszy danego elementu w odniesieniu do daty: podzapytania i funkcje OLAP.
DZIEWIĘĆ ZMIENNYCH
219
Użycie podzapytań Jeśli poszukujemy wartości jednego elementu w określonej dacie, sytuacja jest dość prosta. A właściwie: sytuacja jest tak prosta, że łatwo wpaść w pułapkę tej prostoty, usiłując odwoływać się do wartości aktualnej w danym punkcie czasu z użyciem kodu o takiej postaci: select cokolwiek from histdata as outer where outer.item_id = wartosc and outer.record_date = (select max(inner.record_date) from hist_data as inner where inner.item_id = outer.item_id and inner.record_date <= data_odniesienia)
Warto zwrócić uwagę na to, jakie są konsekwencje użycia takiej konstrukcji z punktu widzenia planu wykonawczego. Po pierwsze, wewnętrzne zapytanie jest skorelowane z zewnętrznym, ponieważ wykorzystuje item_id bieżącego wiersza zwracanego przez zapytanie zewnętrzne. Punktem wyjścia wykonania jest zatem zapytanie zewnętrzne. Z logicznego punktu widzenia kolejność kolumn w ramach klucza złożonego nie powinna mieć znaczenia. W praktyce jednak ma znaczenie krytyczne. Jeśli popełnilibyśmy błąd, tworząc klucz główny postaci (record_date, item_id) zamiast (item_id, record_date), potrzebny byłby dodatkowy klucz po kolumnie item_id na potrzeby zapytania wewnętrznego. W przeciwnym razie nie byłoby możliwości wydajnego przeszukiwania tabeli. A wiemy doskonale, jak kosztowne bywają dodatkowe indeksy. Począwszy od zewnętrznego zapytania, wyszukujemy wiersze zawierające historię elementu o identyfikatorze item_id, po czym wykorzystujemy bieżącą wartość item_id do wykonania podzapytania. Moment! Wewnętrzne zapytanie jest zależne wyłącznie od item_id, a wartość ta jest niezmienna dla wszystkich wierszy wyszukanych w zapytaniu zewnętrznym! Logiczny wniosek: dla każdego wiersza znalezionego w zapytaniu zewnętrznym będziemy w zapytaniu wewnętrznym wywoływać dokładnie to samo zapytanie, ponieważ będziemy używać tego samego item_id. Czy optymalizator zwróci uwagę na to, że zapytanie wewnętrzne za każdym razem zwraca ten sam wynik? To zależy. Zatem lepiej nie ryzykować. Nie ma sensu używać skorelowanego podzapytania, jeśli dla wszystkich wierszy zwraca te same wyniki. Z łatwością możemy je przekształcić w zapytanie nieskorelowane:
220
ROZDZIAŁ SZÓSTY
select cokolwiek from hist_data as outer where outer.item_id = wartosc and outer.record_date = (select max(inner.record_date) from hist_data as inner where inner.item_id = wartosc and inner.record_date <= data_odniesienia)
W tym przypadku zapytanie może być wywołane bez konieczności odczytu danych z tabeli: wszystkie informacje niezbędne mu do działania znajdzie w indeksie klucza głównego. Osobiście preferuję w zapytaniach podkreślać sytuację wykorzystania klucza głównego, co w tym przypadku jest możliwe pod warunkiem obsługi operacji porównywania wielokolumnowych wartości (ta funkcja nie jest dostępna w niektórych systemach baz danych), choć to zapewne kwestia gustu: select cokolwiek from hist_data as outer where (outer.itemid, outer.record_date) in (select inner.item_id, max(inner.record_date) from hist_data as inner where inner.itemid = wartosc and inner.record_date <= data_odniesienia group by inner.item_id)
Wybór podzapytania zwracającego kolumny dopasowane do złożonego klucza głównego nie jest jednak zupełnie pozbawiony kosztów. Jeśli potrzebujemy podzapytania, które dokładnie zwraca wartości listy elementów, być może w oparciu o wynik innego podzapytania, ta wersja zapytania zasugeruje dobry plan wykonawczy. W wewnętrznym zapytaniu pozycję wartość zastępujemy operatorem IN() zawierającym listę lub podzapytania i całe zapytanie nadal będzie działać wydajnie, przy założeniu, że każdy element ma relatywnie krótką historię. Warunek równości również zastąpiliśmy klauzulą IN: w większości przypadków działanie operatora równości i klauzuli IN będzie dokładnie takie samo. Jak zwykle jednak diabeł tkwi w szczegółach. Co się stanie na przykład, jeśli użytkownik błędnie wpisał identyfikator elementu? Warunek IN() po prostu zwróci komunikat, że nie znaleziono danych, natomiast test równości może zwrócić inny kod błędu.
DZIEWIĘĆ ZMIENNYCH
221
Użycie funkcji OLAP Funkcje OLAP, jak row_number() (używana już przez nas w złączeniach tabel samych ze sobą), czasem stanowią zadowalające, a często wręcz najbardziej optymalne rozwiązania problemu „jaka była bieżąca wartość elementu w określonej dacie?”. Pamiętajmy jednak, że funkcje OLAP wprowadzają do działań znaczący aspekt nierelacyjny5. UWAGA Funkcje OLAP należą do nierelacyjnej warstwy języka SQL. Stanowią końcowy lub prawie końcowy etap wykonania zapytania, ponieważ muszą być wykonywane na zbiorze wynikowym zapytania, już po etapie filtrowania danych.
Używając funkcji row_number(), możemy odczytać dane z zastosowaniem rankingu dat: select row_number() over (partition by item_id order by record_date desc) as freshness, cokolwiek from hist_data where item_id = wartosc and record_date <= data_odniesienia
Wybór najświeższej daty jest już kwestią wykorzystania wartości freshness równej jedności: select x. from (select row_number() over (partition by item_id order by record_date desc) as freshness, cokolwiek from hist_data where item_id = wartosc and record_date <= reference_date) as x where x.freshness = 1
W teorii nie powinno być znaczących różnic między podejściem wykorzystującym funkcje OLAP a podejściem wykorzystującym podzapytanie. W praktyce funkcje OLAP odczytują tabelę tylko raz, nawet mimo tego że „przy okazji” odbywa się procedura sortowania. Nie ma potrzeby odczytywać tabeli powtórnie, nawet w sposób optymalny, wykorzystujący klucz główny. Dzięki temu funkcje OLAP mogą zapewnić wydajniejszą wersję (choć z reguły różnica nie jest wielka). 5
…nawet mimo to, że pojęcie OLAP wymyślił sam dr E.F. Codd w roku 1993.
222
ROZDZIAŁ SZÓSTY
Duża ilość wartości historycznych na każdy element W przypadku dużej ilości danych historycznych sytuacja może przedstawiać się nieco inaczej. Może to być na przykład system monitorujący zapisujący wyniki pomiarów dokonywanych z dość dużą częstotliwością. Problem w tym przypadku polega na tym, że wszelkie operacje sortowania wymagane do identyfikacji wartości w pobliżu daty odniesienia mogą mieć do czynienia z dość dużą ilością danych. Sortowanie to operacja kosztowna. Jeśli w bazie danych zostały zastosowane reguły omówione w rozdziale 4., jedyny sposób na uniknięcie narzutu spowodowanego przez warstwę nierelacyjną polega na przerzuceniu większej ilości pracy na warstwę relacyjną, czyli na zwiększeniu skuteczności filtrowania. W takim przypadku bardzo ważne jest, aby zawęzić zakres przeszukiwania przez dokładniejsze wyszukanie daty (lub czasu). Jeśli dostarczymy algorytmowi tylko górną granicę czasu, zapytanie będzie musiało dokonać przeszukania i sortowania wszystkich danych historycznych zgromadzonych od początku świata do daty odniesienia. Zawężenie zakresu jest tym bardziej sensowne, jeśli dane są gromadzone z dużą częstotliwością. Gdy uda się nasz „zbiór roboczy” okroić do rozsądnych rozmiarów, okazuje się, że mamy znów do czynienia z przypadkiem niewielkiej liczby danych historycznych na element. Jeśli nie ma możliwości określenia dwóch wartości granicznych, jedyną nadzieją jest wówczas partycjonowanie tabeli w oparciu o identyfikator elementu. Praca na pojedynczej partycji w takim przypadku zbliża sytuację do opisanej w podrozdziale „Wielki zbiór wynikowy”.
Wartości bieżące Jeśli w tabeli z danymi historycznymi najczęściej odczytywane są wartości bieżące, kuszący jest pomysł skonstruowania bazy w sposób umożliwiający uniknięcie użycia zagnieżdżonych podzapytań lub funkcji OLAP (ponieważ obydwa te rozwiązania wymagają wykonanie operacji sortowania), czyli w taki sposób, aby dane dawało się odczytać bezpośrednio. W rozdziale 1. wspominałem, że jednym z rozwiązań tego problemu jest wykorzystanie „daty ważności” (jak na kubeczkach jogurtu) i ustawienie w bieżących wartościach określonej z góry daty w dalekiej przyszłości (na przykład 31
DZIEWIĘĆ ZMIENNYCH
223
grudnia 2999 roku). Wspominałem również, że istnieją pewne względy praktyczne związane z takim projektem i właśnie nadszedł czas, aby się nimi zająć. Wykorzystanie stałej (magicznej) daty dla oznaczenia bieżących wartości z pewnością pozwala szybko znaleźć interesujące nas pozycje, na przykład: select cokolwiek from hist_data where item_id = wartosc and record_date = ustalona_data_w_przyszlosci
Właściwy wiersz danych znajdujemy natychmiast z użyciem klucza głównego tabeli. Oczywiście nic nie stoi na przeszkodzie, aby nadal używać podzapytania i funkcji OLAP, gdy tylko nadarzy się potrzeba wyszukania danej historycznej (czyli o dacie ważności innej od bieżącej). Takie podejście ma jednak dwie poważne wady, jedna jest oczywista, druga nieco subtelniejszej natury: • Oczywista wada polega na tym, że wstawianie do tabeli nowej danej bieżącej wymaga dokonania modyfikacji poprzedniej danej bieżącej, na przykład przez wpisanie daty tej modyfikacji, co sugeruje, że ważność poprzedniej wartości właśnie dobiegła końca. Następnie należy wpisać nową wartość, z datą ważności w dalekiej przyszłości, co oznacza, że wartość ta jest ważna aż do odwołania. Ta procedura wiąże się z podwojeniem liczby operacji, co samo w sobie powinno stanowić sygnał ostrzegawczy. Co więcej, w teorii relacyjnej klucz główny służy do identyfikacji wiersza; i choć kombinacja (item_id, record_date) jest, co prawda, unikalna, nie może jednak być kluczem głównym, ponieważ z założenia ulega częściowej modyfikacji. Z tego powodu tabela musi zawierać sztuczny klucz główny (w postaci sekwencji lub kolumny identyfikującej, tzw. ID wiersza) wykorzystywany przez klucze obce innych tabel, co znacznie komplikuje programy. Problem z wielkimi tabelami historycznymi polega na tym, że z reguły ich typową cechą jest znaczne tempo dodawania wierszy. Czy szybsze wyszukiwanie w tabeli jest wystarczającym uzasadnieniem znacznego spowolnienia operacji wstawiania? Trudno odpowiedzieć na to pytanie, ale z pewnością warto je zadać. • Subtelna wada ma związek z optymalizatorem. Optymalizator opiera swoje działanie na statystykach wartości, ale często zdarza się, że w ramach tych statystyk zapisywane są wartości brzegowe, w oparciu o które
224
ROZDZIAŁ SZÓSTY
optymalizator może wyliczać średnie rozproszenie wartości. Załóżmy, że nasze dane historyczne sięgają daty 1 stycznia 2000 roku i zawierają 99,9% danych historycznych rozproszonych równomiernie na przestrzeni kilku lat. Pozostały 0,1% to dane bieżące z datą ważności ustawioną na 31 grudnia 2999 roku. W oparciu o te skrajne daty optymalizator może uznać, że dane są rozproszone na przestrzeni tysiąca lat. Błędna ocena danych przez optymalizator może być spotęgowana przez warunek filtrowania (and record_date = stala_data_w_przyszlosci). Ta sytuacja może doprowadzić do tego, że w przypadku operacji wyszukiwania wartości innych od bieżącej (na przykład w celu obliczenia statystycznych odchyleń wartości) optymalizator może „pomyśleć”, że ma do czynienia z niewielkim zakresem danych i warto zastosować indeks, podczas gdy w rzeczywistości będzie miał do czynienia z 99,9% danych w tabeli, przez co optymalne będzie zastosowanie pełnego przeszukiwania tabeli. Błędna ocena danych statystycznych może doprowadzić do zupełnie nieoptymalnych planów wykonawczych, które trudno będzie poprawić. Należy zrozumieć dane i charakterystykę ich rozproszenia, aby pojąć sposób, w jaki optymalizator może postrzegać system.
Zbiór wynikowy uzyskany w oparciu o brak zdefiniowanych danych Często zdarza się, że w tabeli wyszukujemy wiersze, które nie mają odpowiedników w innych tabelach, najczęściej w celu zidentyfikowania wyjątków. Istnieją dwa rozwiązania, jakie najczęściej przychodzą do głowy w takiej sytuacji: konstrukcja NOT IN() wykorzystująca podzapytanie nieskorelowane oraz NOT EXISTS() z podzapytaniem skorelowanym. Popularny przesąd mówi, że najlepiej jest używać konstrukcji NOT EXISTS(). Ponieważ podzapytanie skorelowane jest wydajne w przypadku, gdy znaczna większość zbędnych danych została już odfiltrowana (na przykład w klauzuli WHERE), zatem popularny przesąd sprawdza się, gdy skorelowane podzapytanie jest wsparte znacznymi siłami efektywnych kryteriów filtrujących, natomiast zupełnie nie sprawdza się w przypadku, gdy ma być jedynym kryterium.
DZIEWIĘĆ ZMIENNYCH
225
Zdarza się również spotkać bardziej egzotyczne rozwiązania problemu wyszukiwania w jednej tabeli wierszy, które nie mają odpowiedników w innej. Poniższy przykład stanowi przypadek z życia, który dowodzi, że tego typu monitorowanie wartości potrafi być jedną z najbardziej kosztownych operacji wykonywanych w bazie danych (znaki zapytania stanowią miejsca podstawienia parametrów wywołania zapytania): insert into ttmpout(custcode, suistrcod, cempdtcod, bkgareacod, mgtareacod, risktyp, riskflg, usr, seq, country, rating, sigsecsui) select distinct custcode, ?, ?, ?, mgtareacod, ?, ?, usr, seq, country, rating, sigsecsui from ttmpout a where a.seq = ? and 0 = (select count(*) from ttmpout b where b.suistrcod = ? and b.cempdtcod = ? and b.bkgareacod = ? and b.risktyp = ? and b.riskflg = ? and b.seq = ?)
Tego przykładu absolutnie nie należy postrzegać jako aprobaty wykorzystania tabel tymczasowych! Przy okazji: instrukcja INSERT wygląda, jakby została zaprojektowana do wywoływania w ramach pętli. Prawidłowym kierunkiem usprawnienia tego zapytania byłaby z pewnością próba rezygnacji z pętli.
226
ROZDZIAŁ SZÓSTY
Wstawianie do tabeli danych uzyskanych z odczytu z tej samej tabeli to jeden z licznych przypadków odwołania tabeli do siebie samej: jest to operacja wstawiania wykorzystująca dane odczytane z istniejących wierszy i uwarunkowana brakiem w tabeli wstawianego właśnie wiersza. Wykorzystanie funkcji count(*) do sprawdzenia istnienia w tabeli określonych danych to zły pomysł: aby zliczyć wiersze, silnik bazy danych musi wyszukać wszystkie wiersze spełniające warunek. W takim przypadku lepiej zastosować instrukcję EXISTS, która przerywa swoje działanie, gdy tylko znajdzie pierwsze dopasowanie. Choć nie ma to większego znaczenia w przypadku, gdy główne kryterium filtrujące wykorzystuje klucz główny. Jednak we wszystkich innych przypadkach różnica może być niemała. Niemniej nie ma większego sensu stosować konstrukcji tego typu: and 0 = (select count(*) ...)
gdy mamy na myśli raczej coś takiego: and not exists (select 1 ...)
Używając funkcji count(*) w charakterze testu występowania, możemy nieświadomie skorzystać z pomocy „niewidzialnej ręki” optymalizatora, który przekształci to zapytanie w coś nieco bardziej sensownego. Jednak nie mamy na to żadnej gwarancji, a już na pewno nie poszczęści nam się w przypadku, gdy liczba wierszy jest zapisywana w zmiennej w osobnym etapie zapytania, ponieważ w takim przypadku nawet najinteligentniejszy optymalizator nie odgadnie celu tego zliczania: wynik funkcji count() może być wszak kluczową wartością, na przykład w celu zaprezentowania użytkownikowi! W takim przypadku, gdy chcemy utworzyć nowe, unikalne wiersze o wartościach uzyskanych w wyniku odczytu danych istniejących już w tabeli, prawidłową konstrukcją będzie prawdopodobnie operator zbiorowy, na przykład EXCEPT (znany również jako MINUS): insert into ttmpout(custcode, suistrcod, cempdtcod, bkgareacod, mgtareacod, risktyp, riskflg, usr, seq,
DZIEWIĘĆ ZMIENNYCH
227
country, rating, sigsecsui) select distinct custcode, ?, ?, ?, mgtareacod, ?, ?, usr, seq, country, rating, sigsecsui from ttmpout where seq = ? except select custcode, ?, ?, ?, mgtareacod, ?, ?, usr, seq, country, rating, sigsecsui from ttmpout where suistrcod = ? and cempdtcod = ? and bkgareacod = ? and risktyp = ? and riskflg = ? and seq = ?)
Wielką zaletą operatorów zbiorowych jest to, że wyłamują się ze schematu przetwarzania podzapytań skorelowanych i nieskorelowanych. Co mam na myśli, używając określenia „wyłamują się ze schematu”? Przy przetwarzaniu zapytań skorelowanych najpierw wykonywane jest zapytanie zewnętrzne, po czym uzyskane w nim dane, które spełniają wszystkie kryteria filtrujące, są wykorzystywane do wywołania zapytania wewnętrznego. Obydwa zapytania są wzajemnie zależne, ponieważ zapytanie zewnętrzne zasila danymi zapytanie wewnętrzne.
228
ROZDZIAŁ SZÓSTY
W przypadku zapytań nieskorelowanych jest nieco lepiej, ale nadal niezbyt różowo: zapytanie wewnętrzne musi być wykonane i ukończone, zanim do akcji wejdzie kolejny etap przetwarzania (ta zasada pozostaje w mocy nawet w przypadku, gdy optymalizator wykona główne zapytanie jako złączenie typu hash, co jest dobrą strategią, ponieważ w celu wywołania złączenia typu hash silnik SQL-a w pierwszym etapie wykonuje przeszukiwanie jednej z tabel w celu zbudowania macierzy hash). Przy użyciu operatorów zbiorowych: UNION, INTERSECT czy też EXCEPT żaden z elementów zapytania nie zależy od pozostałych. W efekcie różne części zapytania mogą działać równolegle. Oczywiście w tym przypadku niewielki jest pożytek ze zrównoleglenia operacji, gdy jeden element jest bardzo wolny, a pozostałe bardzo szybkie, i żadna korzyść z tego, gdy większa część jednego elementu będzie znacznie zduplikowana w innym — ponieważ w takiej sytuacji pracę się duplikuje, a nie dzieli na części. Jednak w korzystnym układzie opłaca się uruchamiać wszystkie elementy zapytania w sposób równoległy przed połączeniem ich wyników w jeden w ostatnim etapie zapytania. To idealne zastosowanie reguły „dziel i zwyciężaj”. Operatory zbiorowe mają jedną ważną cechę: wymagają, aby każdy element zapytania zwracał kompatybilne kolumny — identyczną liczbę kolumn identycznych typów. Oto typowe zastosowanie operatorów zbiorowych (jeszcze jeden przykład z programu bilingującego): select cokolwiek, sum(d.tax) from invoice_detail d, invoice_extractor e where (e.pga_status = 0 or e.rd_status = 0) and warunek_zlaczenia and (d.type_code in (3, 7, 2) or (d.type_code = 4 and d.subtype_code not in (select trans_code from trans_description where trans_category in (6, 7)))) group by kryteria_grupowania having sum(d.tax) != 0
Zawsze ulegam urokowi ostatniego z zastosowanych warunków: sum(d.tax) != 0
DZIEWIĘĆ ZMIENNYCH
229
Popuszczam wówczas wodze fantazji i wyobrażam sobie życie w cudownym świecie, w którym podatki mogą być ujemne. W klauzuli WHERE można również zastosować następujący warunek: and d.tax > 0
O ile to możliwe, należy go zastosować zamiast powyższego, ponieważ daje lepsze wyniki, o czym już wspominałem w tym rozdziale. W takim przypadku operator zbiorowy może wyglądać, jakby był nieco nie na miejscu, ponieważ tabelę invoice_detail odczytujemy kilkakrotnie, a, jak się można domyślić, raczej nie należy ona do najmniejszych. Jednak w zależności od selektywności kryteriów (w szczególności gdy type_code=4 jest rzadkim przypadkiem, zatem stanowi selektywny warunek) operator EXISTS może okazać się skuteczniejszy niż NOT IN(). Jeśli jednak trans_description jest stosunkowo niewielką tabelą, nie ma wątpliwości, że próba udoskonalenia zapytania za pomocą samego testu występowania okaże się raczej ślepą uliczką. Inny, dość skuteczny, a często również wydajny sposób wykonywania testu istnienia polega na wykonaniu złączeń zewnętrznych (outer join). Celem złączenia zewnętrznego jest zwrócenie wszystkich informacji z jednej tabeli, również wierszy, dla których nie znaleziono dopasowania w drugiej z tabel złączenia. Często się jednak zdarza, że interesują nas właśnie te wiersze, których nie udało się znaleźć w drugiej tabeli. W jaki sposób je zidentyfikować? Sprawdzając kolumny złączonej tabeli: gdy nie ma dopasowania, wartości kolumn są zastąpione przez NULL-e. Weźmy następujące zapytanie: select cokolwiek from invoice_detail where type_code = 4 and subtype_code not in (select trans_code from trans_description where trans_category in (6, 7))
Można je zapisać w sposób następujący: select cokolwiek from invoice_detail outer join trans_description on trans_description.trans_category in (6, 7) and trans_description.trans_code = invoice_detail.subtype_code where trans_description.trans_code is null
230
ROZDZIAŁ SZÓSTY
Celowo warunki dotyczące trans_category ująłem w klauzuli złączenia. Kwestia, czy powinny pojawić się tu, czy w klauzuli WHERE, jest sporna, ale filtrowanie danych przed lub po złączeniu nie ma wpływu na wynik (oczywiście z punktu widzenia wydajności różnica może wystąpić — w zależności od względnej selektywności warunku filtrującego i samego warunku złączenia). W przypadku warunku sprawdzającego czy kolumna jest NULL, takiego wyboru nie mamy, ponieważ to może być sprawdzone wyłącznie po dokonaniu złączenia. Oprócz faktu, że złączenie zewnętrzne może w niektórych przypadkach wymagać klauzuli DISTINCT, w praktyce nie powinno być większych różnic w wydajności między użyciem złączenia zewnętrznego a zastosowaniem warunku NOT IN() z nieskorelowanym podzapytaniem, ponieważ kolumna użyta do złączenia jest tą samą, która jest użyta w warunku NOT IN(). Język SQL jest jednak znany z tego, że konstrukcja zapytania ma znaczący wpływ na jego plan wykonawczy, nawet jeśli teoria mówi inaczej. Wszystko zależy od stopnia zaawansowania optymalizatora i od tego, czy w równie skuteczny sposób potrafi on przetwarzać obydwie formy takiego zapytania. Innymi słowy, SQL nie jest w pełni deklaratywnym językiem, mimo tego że udoskonalenia optymalizatorów z każdą wersją podnoszą ich skuteczność. Przed zamknięciem tego tematu warto na chwilę zatrzymać się przy odwiecznym problemie języka SQL: NULL-ach. Choć gdy w warunku IN() występują wartości NULL, nie będzie to miało niekorzystnego wpływu na zapytanie zewnętrzne, lecz w warunku NOT IN() takie NULL-e zawsze powodują zwrócenie wartości FALSE. Niewielkim wysiłkiem można wykluczyć NULL-e z wyniku podzapytania, ale jeśli ktoś o tym zapomni, może spodziewać się chwil stresu spowodowanych zwracaniem błędnego wyniku. Zbiory danych można porównywać z użyciem różnych technik, ale istnieje szansa, że dobre wyniki dadzą tu złączenia zewnętrzne i operatory zbiorowe.
ROZDZIAŁ SIÓDMY
Odmiany taktyki Obsługa danych strategicznych The golden rule is that there are no golden rules. Złota reguła mówi, że nie ma żadnych złotych reguł. — George Bernard Shaw (1856 – 1950) Człowiek i nadczłowiek/Maksymy dla rewolucjonistów
232
W
ROZDZIAŁ SIÓDMY
poprzednim rozdziale mieliśmy okazję przekonać się, że zapytania czasem wielokrotnie odwołują się do tej samej tabeli i że wyniki zapytań można uzyskać przez złączenie jednego wiersza tabeli z innym wierszem tej samej tabeli. Istnieje jednak bardzo ważny przypadek, w którym wiersz jest nie tylko powiązany z innym wierszem, ale również od niego zależny. Ten drugi wiersz jest powiązany z innym wierszem i tak dalej. Mam na myśli reprezentację hierarchii.
Struktury drzewiaste Teoria relacyjna stała się decydującym „ciosem”, który obalił dominację baz hierarchicznych w roli repozytoriów ustrukturalizowanych danych. Hierarchiczne bazy danych były pierwszym podejściem do strukturalizacji danych, które dotychczas były zapisywane w postaci rekordów w plikach. Zamiast liniowych sekwencji identycznych rekordów zapisywane były logiczne zagnieżdżenia różnych rekordów. Hierarchiczne bazy danych spisywały się doskonale przy niektórych zapytaniach, ale ich silne ustrukturalizowanie okazało się ograniczeniem i powodowało, że nawigowanie w nich sprawiało problemy. W oparciu o nie powstały bazy danych typu CODASYL, w których nawigacja nadal była kłopotliwa, ale charakteryzowały się nieco większą elastycznością. Wreszcie powstała teoria relacyjna, która dowiodła, że projekt baz danych powinien mieć podłoże naukowe, a nie tylko ściśle techniczne. Jednakże hierarchie, a przynajmniej hierarchiczne reprezentacje danych, nadal są bardzo powszechne, co z pewnością jest główną przyczyną przetrwania modelu hierarchicznego, wszechobecnego i dziś pod wieloma nazwami, jak Lightweight Directory Access Protocol (LDAP) czy XML. Obsługa danych hierarchicznych znana powszechnie jako BOM (Bill of Materials, czyli lista materiałowa) nie jest prosta do zrozumienia. Hierarchie to struktury skomplikowane nie tylko dlatego, że reprezentują powiązania między elementami, ale przede wszystkim z powodu sposobu przeszukiwania drzewa. Przeszukiwanie drzewa oznacza odwiedzenie wszystkich lub wybranych węzłów i zwrócenie ich wartości w określonym porządku. Przeszukiwanie drzewa jest z reguły implementowane (o ile w ogóle) w silnikach baz danych w formie proceduralnej, a, jak już doskonale wiemy, podejście proceduralne to jedno z największych wykroczeń przeciwko relacyjności.
ODMIANY TAKTYKI
233
Struktury drzewiaste a związki typu ogół-szczegół Wielu projektantów ma skłonność do zakładania, że związek typu rodzic-potomek niczym nie różni się od związku typu ogół-szczegół. Mamy tu do czynienia z klasycznym związkiem zamówienia-pozycje zamówień, w którym tabela order_detail przechowuje (w ramach swojego klucza) odwołania do odpowiednich wierszy tabeli orders. Istnieją jednak co najmniej cztery główne różnice między związkami typu rodzic-potomek a związkami typu ogół-szczegół. Pojedyncza tabela Pierwsza różnica polega na tym, że w przypadku drzewa reprezentującego hierarchię wszystkie węzły są tej samej natury. Węzły liści, czyli nieposiadające potomków, są czasem różne, jak jest na przykład w przypadku hierarchii systemu plików, gdzie pliki (liście) są innej natury niż katalogi (zwykłe węzły). Tego typu przypadki odłóżmy jednak na później. Skoro wszystkie węzły są tej samej natury, opisujemy je w ten sam sposób i będą reprezentowane przez wiersze tej samej tabeli. Innymi słowy, mamy tu do czynienia z pewną formą związku ogół-szczegół, ale nie między dwoma różnymi tabelami przechowującymi wiersze o odmiennej naturze, lecz między wierszami tej samej tabeli. Głębokość Druga różnica polega na tym, że w przypadku hierarchii odległość od szczytu jest często znaczącą informacją. W związku ogół-szczegół zawsze znajdujemy się na poziomie ogółu lub szczegółu. Własność Trzecia różnica polega na tym, że w związku ogół-szczegół można zdefiniować jednoznaczne ograniczenie integralności w oparciu o klucz, czyli na przykład każdy identyfikator zamówienia w tabeli order_detail musi odpowiadać istniejącemu zamówieniu w tabeli orders. W przypadku danych hierarchicznych taka możliwość nie występuje. Można na przykład stwierdzić, że identyfikator menedżera musi odpowiadać istniejącemu numerowi pracownika. W takim przypadku będziemy jednak mieli problem z dyrektorem generalnym, który co prawda ma „nad sobą” zgromadzenie akcjonariuszy, nie są oni jednak pracownikami i nie występują w tabeli employee. W tym przypadku samo narzuca się
234
ROZDZIAŁ SIÓDMY
rozwiązanie, które jest przyczyną niezliczonych problemów: NULL-e. A w niektórych hierarchiach tego typu „przypadków szczególnych” może być więcej, ponieważ nie ma problemu, aby w jednej tabeli reprezentować kilka niezależnych drzew, z których każde będzie miało własny korzeń. W ten sposób powstają lasy (hierarchii). Wielu rodziców Powiązanie „potomka” z identyfikatorem „rodzica” powoduje założenie, że jeden potomek może posiadać tylko jednego rodzica. W rzeczywistości istnieje wiele sytuacji życiowych, gdy nie można przyjąć tego typu założenia: w inwestycjach, składnikach formuły lub śrubach łączących jednocześnie wiele części mechanicznych. Przypadek, gdy potomek posiada wielu rodziców, z pewnością nie jest drzewem z matematycznego punktu widzenia. Niestety, wiele drzew, w tym drzewa genealogiczne, to struktury bardziej skomplikowane od prostych związków rodzic-potomek i często wymagają obsługi przypadków szczególnych (co już wybiega poza tematykę tej książki), jak cykle (zapętlenia) w linii powiązań. Fabian Pascal w swojej doskonałej książce Practical Issues in Database Management (Addison Wesley) wyjaśnia, że właściwe, relacyjne spojrzenie na drzewo polega na zrozumieniu dwóch typów encji: węzłów (w przypadku których może istnieć specjalny podtyp w postaci liści, zawierający więcej informacji) oraz powiązań między węzłami. Należy podkreślić, że to podejście projektowe rozwiązuje problem więzów integralności, ponieważ opisywane są jedynie te powiązania, które rzeczywiście istnieją. Podejście Pascala rozwiązuje również problem „potomka” posiadającego wielu „rodziców”. Ten typ powiązań jest dość powszechnie spotykany w przemyśle, a mimo to tak rzadko opisywany w podręcznikach, które z reguły ograniczają się do przykładu powiązań pracownik-menedżer. Pascal, wierny ideom przedstawionym przez Chrisa Date’a, sugeruje, że powinien istnieć operator explode() służący do spłaszczania hierarchii w locie, dzięki czemu niejawne związki między węzłami stałyby się jawnymi. Jedyny problem z tą metodyką polega na tym, że operator tego typu nigdy nie został zaimplementowany. Dostawcy systemów zarządzania bazami danych często implementują specjalizowane procesy, jak choćby mechanizmy obsługi danych przestrzennych czy indeksowanie pełnotekstowe. Implementacja danych hierarchicznych jednak zawsze oscylowała między nieistniejącą a nędzną, co w konsekwencji cały ciężar obsługi tego typu danych zrzucało na osobę najmniej winną: programistę.
ODMIANY TAKTYKI
235
Jak już sugerowałem, główny problem z obsługą danych hierarchicznych polega na przeszukiwaniu drzewa. Oczywiście jeśli komuś zależy jedynie na wyświetleniu struktury drzewa w postaci graficznego interfejsu użytkownika z samodzielnie rozwijanymi węzłami, nie ma większego problemu. Odczytanie poddrzewa, począwszy od wybranego węzła, to bardzo proste zadanie.
Praktyczne przykłady hierarchii W rzeczywistości hierarchie spotyka się bardzo często, ale zadania im powierzane są z reguły bardzo proste. Oto trzy przykłady życiowych sytuacji wykorzystujących hierarchie, z trzech różnych dziedzin biznesowych. Współczynnik ryzyka Obliczanie poziomu ryzyka inwestycyjnego w strukturze finansowej funduszy hedgingowych jest typowym problemem hierarchicznym. Struktury finansowe funduszy mogą inwestować w fundusze wchodzące w skład innych funduszy. Lokalizacja danych w archiwum Jeśli posiadamy duży bank detaliczny, czasem zdarza się nam natrafić na nietrywialny problem: znajdujemy w archiwach dane dotyczące kredytów Jana Kowalskiego sprzed siedmiu lat, ponieważ dokumenty te wpięte są w skoroszyty, które są spakowane w pudła, które są ułożone na półkach, które są zamocowane w ruchomych regałach w określonej alejce na określonym piętrze określonego budynku. Zagnieżdżone „kontenery” (skoroszyty, półki, regały itd.) tworzą hierarchię. Wykorzystanie składników Dokładnie ten sam rodzaj wyzwania dla języka SQL stanowi identyfikacja wszystkich leków zawierających składnik, którego znacznie tańszy odpowiednik został właśnie zaakceptowany do produkcji w firmie farmaceutycznej. Kluczowe jest, aby zrozumieć, że te problemy hierarchiczne są znacząco różne u podstaw swoich charakterystyk. Zadanie polegające na znalezieniu lokalizacji dokumentu w archiwum oznacza konieczność przeszukania drzewa od dołu (liści) do góry (korzenia), czyli w kolejności od największej szczegółowości do największej agregacji, ponieważ poszukiwanie
236
ROZDZIAŁ SIÓDMY
rozpoczynamy od określonego dokumentu (katalogu), który zawiera informację na temat identyfikacji pudełka i tak dalej — aż do pomieszczenia w budynku, w ten sposób identyfikując dokładną lokalizację dokumentu. Odnajdywanie wszystkich produktów zawierających określony składnik również jest przeszukiwaniem od szczegółów do ogółu, choć w tym przypadku liczba punktów wejścia może być znacznie większa: przeszukiwanie musimy powtarzać tyle razy, ile razy znajdziemy poszukiwany składnik. Analiza ryzyka inwestycyjnego jest natomiast związana z przeszukiwaniem drzewa od ogółu do szczegółu i z powrotem: najpierw wyszukujemy wszystkie fundusze, w które planujemy inwestować, a następnie obliczamy ryzyko z nimi związane w oparciu o poszczególne współczynniki składowe. To jest pewna forma agregacji, tylko bardziej skomplikowanej. Liczba poziomów w drzewach jest z reguły dość niewielka. Na tym właściwie polega główna zaleta drzew, dzięki której daje się je efektywnie przeszukiwać. Jeśli liczba poziomów jest stała, pozostaje jedynie złączyć tabelę zawierającą definicję drzewa z samą sobą tyle razy, ile mamy poziomów. Weźmy przykład archiwum i załóżmy, że informacja na temat lokalizacji dokumentu znajduje się w tabeli inventory. Identyfikator skoroszytu zaprowadzi nas do tabeli location, która wskaże identyfikator pudła zawierającego skoroszyt, półki, na której leży pudło, regału, w którym zamocowana jest półka, alejki, w której stoi regał, pokoju, w którym znajduje się ta alejka, piętra, na którym znajduje się pokój, a na końcu budynku archiwum. Jeśli tabela location skoroszyty, pudła, półki itd. traktuje po prostu jako pewne uogólnione „położenia”, zapytanie zwracające interesującą nas informację będzie miało następującą składnię: select building.name building, floor.name floor, room.name room, alley.name alley, cabinet.name cabinet, shelf.name shelf, box.name box, folder.name folder from inventory, location folder, location box, location shelf, location cabinet, location alley,
ODMIANY TAKTYKI
237
location room, location floor, location building where inventory.id = 'AZE087564609' and inventory.folder = folder.id and folder.located_in = box.id and box.located_in = shelf.id and shelf.located_in = cabinet.id and cabinet.located_in = alley.id and alley.located_in = room.id and room.located_in = floor.id and floor.located_in = building.id
Ten typ zapytania mimo sporej liczby złączeń powinien działać szybko dzięki temu, że każde złączenie będzie wykorzystywało unikalny indeks tabeli location (a dokładniej indeks po kolumnie id), prawdopodobnie będący indeksem klucza głównego. Oczywiście jest tu ukryty pewien haczyk: liczba poziomów hierarchii rzadko bywa stała. Nawet w bardzo statycznym świecie archiwów pudła bywają czasem przenoszone do innych kontenerów (o bardziej zwartej strukturze, przez to zapewniającej niższe koszty przechowywania). Tego typu działania mogą spowodować połączenie dwóch poziomów hierarchii w jeden, ponieważ nowa forma kontenera połączy cechy pudła i półki. Co począć, gdy liczba poziomów nie jest stała? W jaki sposób reprezentować taką hierarchię? Czy używać unii, czy złączeń zewnętrznych? Powiązania między obiektami tej samej natury powinny być zamodelowane w postaci drzew, gdy tylko okaże się, że liczba poziomów między dwoma równorzędnymi obiektami nie jest stała.
Reprezentowanie drzew w bazach danych SQL Drzewa są reprezentowane w bazach danych SQL za pomocą jednego z następujących modeli: Model sąsiedztwa Model sąsiedztwa został nazwany w ten sposób, ponieważ jednym z atrybutów wiersza potomnego jest identyfikator najbliższego przodka w hierarchii (wiersza nadrzędnego). Dwa sąsiednie węzły drzewa są zatem powiązane w sposób bezpośredni. Model sąsiedztwa jest często
238
ROZDZIAŁ SIÓDMY
ilustrowany następującym przykładem: rekord z danymi pracownika zawiera wśród swoich atrybutów identyfikator szefa. Bezpośrednie powiązanie szefa z pracownikiem nie jest jednak najlepszym przykładem projektu hierarchii pracowników, ponieważ identyfikacja hierarchii powinna być realizowana w definicji struktury firmy. Nie ma bowiem powodu, aby w przypadku zmiany szefa departamentu modyfikować wszystkie rekordy pracowników. Niektóre systemy baz danych mają zaimplementowane specjalne operatory do obsługi hierarchii tego typu, na przykład w Oracle mamy do dyspozycji operator CONNECT BY (wprowadzony w Oracle w wersji 4 w połowie lat osiemdziesiątych) czy też rekurencyjny operator WITH obsługiwane przez DB2 i Microsoft SQL Server. Bez tego typu operatora obsługa modelu sąsiedztwa jest dość skomplikowana. Model zmaterializowanej ścieżki W tym modelu drzewo jest definiowane za pomocą reprezentacji pozycji każdego węzła w drzewie. Ta reprezentacja najczęściej przyjmuje formę tekstowej listy identyfikatorów wszystkich przodków węzła, od korzenia do bezpośredniego przodka, lub postać listy liczb reprezentujących ranking wśród potomków danego przodka w tej samej generacji (metoda stosowana wśród genealogów). Listy te są najczęściej zapisywane w postaci ciągów znaków z separatorami. Na przykład '1.2.3.2' oznacza (od lewej do prawej), że węzeł jest drugim potomkiem swojego rodzica (którego ścieżka jest zdefiniowana jako '1.2.3'), który z kolei jest trzecim potomkiem dziadka ('1.2') i tak dalej. Model zagnieżdżonych zbiorów W tym modelu wynalezionym przez Joe Celko1 z każdym węzłem związana jest para liczb (określanych lewą liczbą i prawą liczbą). Liczby te określają interwał zawierający interwał wszystkich potomków. Szczegóły działania tego modelu są opisane w podrozdziale „Praktyczne implementacje drzew” w punkcie „Model zagnieżdżonych zbiorów (według Celko)”.
1
Model ten został po raz pierwszy opisany w „DBMS Magazine” (około 1996 roku), a później rozwinięty w książce Trees and Hierarchies in SQL for Smarties (Morgan-Kauffman).
ODMIANY TAKTYKI
239
Istnieje jeszcze czwarty, mniej znany model, nazwany modelem zagnieżdżonych interwałów, zaprezentowany przez autora Vadima Tropashko w serii bardzo interesujących opracowań2. Koncepcja tego modelu polega na tym, że ścieżkę każdego węzła koduje się z użyciem dwóch liczb, które są interpretowane jako licznik i mianownik ułamka zwykłego. Niestety, obsługa tego modelu wymaga dużej ilości obliczeń i procedur osadzonych i choć wygląda na obiecującą z punktu widzenia specjalnych implementacji obsługi drzew (być może z użyciem operatora explode()), w praktyce jest trudna w implementacji i nie najszybsza, dlatego w analizie skupię się na poprzednich trzech. Aby utrzymać nastrój, a jednocześnie posłużyć się konkretnymi danymi o rozsądnych rozmiarach, stworzyłem testową bazę danych reprezentującą organizację armii biorących udział w bitwie pod Waterloo w 1815 roku3. Posłużę się strukturą armii angielsko-duńskiej, pruskiej i francuskiej na poziomie korpusów, dywizji i brygad aż do poziomu pułku. Te dane, zawierające opisy jednostek wojskowych i nazwiska ich dowódców, wykorzystuję do demonstracji zagadnień zawartych w tym rozdziale. Muszę podkreślić, że przykłady przeglądania drzew zademonstrowane w tym rozdziale obnażają kiepską jakość przyjętego projektu bazy danych. Z reguły prawidłowym kluczem głównym tabeli definiującej jednostki wojskowe powinien być ujednolicony i zrozumiały kod, nie opis z natury podatny na błędy wprowadzania danych. Dlatego proszę o zrozumienie — wykorzystanie kluczy sztucznych w moich przykładach stanowi jedynie uproszczenie rzeczywistych, solidnych kluczy głównych. Największy problem z hierarchiami polega na tym, że nie istnieje ich „najlepsza reprezentacja”. Gdy jesteśmy zainteresowani odczytaniem przodków kilku elementów (przeglądanie wstępujące), w zupełności wystarczą operatory CONNECT BY lub WITH (przynajmniej z punktu widzenia funkcjonalności i wydajności). Jednak głębsze spojrzenie ujawnia, że szczególnie CONNECT BY stanowi nieelegancką, nierelacyjną, proceduralną implementację charakteryzującą się tym, że przemieszczanie się między elementami jest możliwe pojedynczo, wiersz po wierszu. To rozwiązanie 2
Pierwotnie opublikowane na http://www.dbazine.com.
3
Wykorzystałem dane Petera Kesslera, http://www.kessler-web.co.uk, za pozwoleniem autora.
240
ROZDZIAŁ SIÓDMY
jest znacznie mniej zadowalające w przypadku, gdy zechcemy dokonać wstępującego przeglądania drzewa dla dużej liczby elementów lub gdy potrzebujemy dokonać przeglądania zstępującego bardzo dużej liczby elementów. I jak to często ma miejsce w SQL-u, „brzydota”, która nie ujawnia się przy kilkunastowierszowej tabeli, z pewnością będzie wyraźna przy milionach, nie mówiąc już o miliardach, wierszy. W takich przypadkach przyjemnie wyglądająca sztuczka z SQL-em obnaży swoje braki pod względem wydajności. Moja przykładowa tabela zawiera nieco ponad osiemset wierszy i jest nieco większa od typowych przykładów, choć z pewnością nie równa się temu, z czym z reguły ma się do czynienia w zastosowaniach przemysłowych. Jest jednak wystarczająco duża, aby zademonstrować silne i słabe strony poszczególnych modeli. Implementacja drzew w SQL-u jest w znacznym stopniu zależna od implementacji systemu zarządzania bazami danych, dlatego warto korzystać z tego, co daje system.
Praktyczne implementacje drzew Kolejne punkty omawiają przykłady zastosowania poszczególnych modeli reprezentacji drzew. W każdym przypadku wiersze były wstawiane do przykładowych tabel w tej samej kolejności (posortowane według nazwiska dowódcy jednostki), aby w jak największym stopniu uniezależnić wyniki od fizycznego uporządkowania danych. Należy mieć stale na uwadze, że projekt tych baz jest daleki od ideału i że moim celem było jedynie zademonstrować zasadę działania modeli w oparciu o proste i zrozumiałe przykłady.
Model sąsiedztwa Poniższa tabela opisuje hierarchiczną organizację armii wykorzystującą model sąsiedztwa. Dla tej tabeli przyjąłem nazwę ADJACENCY_MODEL. Każdy wiersz tabeli opisuje jednostkę wojskową. Atrybut PARENT_ID odwołuje się do wiersza definiującego jednostkę nadrzędną.
ODMIANY TAKTYKI
241
TABELA 7.1. Definicja tabeli ADJACENCY_MODEL Nazwa
NULL?
Typ
ID
NOT NULL
NUMBER
PARENT_ID
NUMBER
DESCRIPTION
NOT NULL
VARCHAR2(l20)
COMMANDER
VARCHAR2(l20)
Tabela ADJACENCY_MODEL posiada trzy indeksy: na kolumnie ID (klucz główny), PARENT_ID i COMMANDER. Oto kilka przykładowych wierszy z tabeli ADJACENCY_MODEL: ID PARENT_ID DESCRIPTION --- ----------- --------------------------435 0 French Armée du Nord of 1815 619 435 III Corps 620
619 8th Infantry Division
621
620 1st Brigade
622 623 624
621 15th Rgmt Léger 621 23rd Rgmt de Ligne 620 2nd Brigade
625 626 627
624 37th Rgmt de Ligne 620 Division Artillery 626 7/6th Foot Artillery
COMMANDER ----------------------------Emperor Napoleon Bonaparte Général de Division Dominique Vandamme Général de Division Baron Etienne-Nicolas Lefol Général de Brigade Billard (d.15th) Colonel Brice Colonel Baron Vernier Général de Brigade Baron Corsin Colonel Cornebise Captain Chauveau
Model zmaterializowanej ścieżki Tabela MATERIALIZED_PATH_MODEL zawiera tę samą hierarchię co tabela ADJACENCY_MODEL, ale o odmiennej reprezentacji. Para kolumn (id, parent_id) definiujących powiązanie między potomkiem a przodkiem została zastąpiona kolumną materialized_path określającą „ścieżkę pochodzenia” bieżącego wiersza: TABELA 7.2. Definicja tabeli MATERIALIZED_PATH_MODEL Nazwa
NULL?
Typ
MATERIALIZED_PATH
NOT NULL
VARCHAR2(25)
DESCRIPTION
NOT NULL
VARCHAR2(l20)
COMMANDER
VARCHAR2(l20)
242
ROZDZIAŁ SIÓDMY
Tabela MATERIALIZED_PATH_MODEL posiada dwa indeksy: unikalny indeks na kolumnie materialized_path (klucz główny) oraz po kolumnie commander. W rzeczywistym przypadku wybór ścieżki w charakterze klucza głównego nie byłby uznany za najszczęśliwszy, ponieważ ludzie lub obiekty rzadko posiadają jako podstawowy wyróżnik swoją pozycję w hierarchii. Lepiej byłoby zastosować przynajmniej identyfikator, jak kolumna id w tabeli ADJACENCY_MODEL. Zdecydowałem się zrezygnować z tego typu identyfikatora, ponieważ w moich prostych testach nie było dla niego żadnego zastosowania. Jednakże mój wybór kolumny materialized_path w charakterze klucza głównego był dodatkowo podyktowany chęcią weryfikacji wpływu szczególnych implementacji (omówionych w rozdziale 5.) na wydajność zapytań. A dokładniej interesowało mnie, co się stanie, gdy tabela opisująca strukturę drzewiastą będzie zawierała w indeksie cały opis struktury drzewa? W tym konkretnym przypadku tego typu odwzorowanie nie sprawiło żadnej różnicy. Poniższy listing przedstawia te same przykładowe wiersze co w modelu sąsiedztwa, ale przekształcone na wiersze modelu zmaterializowanej ścieżki. MATERIALIZED_PATH DESCRIPTION COMMANDER ----------------- --------------------------- --------------------------F French Armée du Nord of 1815 Emperor Napoleon Bonaparte F.3 III Corps Général de Division Dominique Vandamme F.3.1 8th Infantry Division Général de Division Baron Etienne-Nicolas Lefol F.3.1.1 1st Brigade Général de Brigade Billard (d.15th) F.3.1.1.1 15th Rgmt Léger Colonel Brice F.3.1.1.2 23rd Rgmt de Ligne Colonel Baron Vernier F.3.1.2 2nd Brigade Général de Brigade Baron Corsin F.3.1.2.1 37th Rgmt de Ligne Colonel Cornebise F.3.1.3 Division Artillery F.3.1.3.1 7/6th Foot Artillery Captain Chauveau
Model zagnieżdżonych zbiorów (według Celko) W modelu zagnieżdżonych zbiorów mamy dwie dodatkowe kolumny: left_num i right_num opisujące sposób powiązania z innymi wierszami w hierarchii. Pokrótce omówię sposób wykorzystania tych dwóch liczb do opisu pozycji w hierarchii.
ODMIANY TAKTYKI
243
TABELA 7.3. Definicja tabeli NESTED_SETS_MODEL Nazwa
NULL?
Typ
DESCRIPTION
NOT NULL
VARCHAR2(l20)
COMMANDER
VARCHAR2(l20)
LEFT_NUM
NOT NULL
NUMBER
RIGHT_NUM
NOT NULL
NUMBER
Tabela NESTED_SETS_MODEL posiada złożony klucz główny (left_num, right_num) oraz dodatkowo indeks po kolumnie commander. Podobnie jak w przypadku modelu zmaterializowanej ścieżki, to nie jest najlepszy projekt, ale na potrzeby testów w zupełności wystarczający. Najwyższy czas wyjaśnić, w jaki sposób dobiera się dwie tajemnicze liczby: left_num i right_num. Kolumna left_num w węźle głównym przyjmuje wartość 1. Następnie odwiedzane są wszystkie węzły potomne, zgodnie z zasadą przedstawioną na rysunku 7.1, przy czym przy każdej pozycji wartość jest zwiększana o jeden.
RYSUNEK 7.1. Schemat nadawania numeracji w modelu zagnieżdżonych zbiorów
Załóżmy, że odwiedzamy węzeł po raz pierwszy. W przykładzie z rysunku 7.1 po przypisaniu wartości 1 atrybutowi left_num w węźle lst Corps napotykamy (po raz pierwszy) węzeł 1st British Guards Division. Zwiększamy licznik o jeden i wynik wpisujemy do left_num. Następnie odwiedzamy potomków tego węzła, trafiamy po raz pierwszy na 1st Guards Brigade i wpisujemy w left_num wartość 3. Ten węzeł jednak nie ma potomków. Z tego powodu zwiększamy licznik o jeden i wynik wpisujemy do atrybutu right_num, który w tym przypadku przyjmie wartość 4. Następnie przechodzimy do węzłów na tym samym poziomie
244
ROZDZIAŁ SIÓDMY
(rodzeństwa), 2nd Guards Brigade. W tym przypadku powtarzamy procedurę. Na końcu trafiamy (z drugą wizytą) do węzła nadrzędnego 1st British Guards Division, przypisując atrybutowi right_num tego wiersza wartość 7. Następnie przechodzimy do kolejnego węzła tego samego poziomu, czyli 3rd Anglo-German Division, i tak dalej. Jak wspominałem wcześniej, wartości left_num i right_num każdego węzła zawierają się w przedziale left_num i right_num każdego z jego węzłów nadrzędnych, stąd nazwa: model zbiorów zagnieżdżonych. W naszym przykładzie mamy jednak trzy niezależne drzewa (po jednym dla każdej armii: angielsko-holenderskiej, pruskiej i francuskiej), co jest określane mianem lasu. Z tego powodu należy utworzyć sztuczną podstawę drzewa, której nadałem nazwę Armies of 1815. Tego typu sztuczna podstawa nie jest wymagana w innych modelach. Po obliczeniu wszystkich pozycji w naszym przykładowym wycinku bazy uzyskamy następujące wartości: DESCRIPTION ---------------------------Armies of 1815 French Armée du Nord of 1815 III Corps 8th Infantry Division 1st Brigade 15th Rgmt Léger 23rd Rgmt de Ligne 2nd Brigade 37th Rgmt de Ligne Division Artillery 7/6th Foot Artillery
COMMANDER LEFT_NUM RICHT_NUM -------------------------- -------- --------1 1622 Emperor Napoleon Bonaparte 870 1621 Général de Division 1237 1316 Dominique Vandamme Général de Division Baron 1238 1253 Etienne-Nicolas Lefol Général de Brigade Billard 1239 1244 (d.15th) Colonel Brice 1240 1241 Colonel Baron Vernier 1242 1243 Général de Brigade Baron 1245 1248 Corsin Colonel Cornebise 1246 1247 1249 1252 Captain Chauveau 1250 1251
Na najniższym poziomie hierarchii w wierszach w naszej próbce można zauważyć, że wartość right_num jest równa left_num + 1. Autor tej sprytnej metody twierdzi, że jest znacznie lepsza od modelu sąsiedztwa, ponieważ działa na zbiorach, co jest wszak specjalnością SQL-a. To prawda, z jedną wszak poprawką: SQL jest opracowany z myślą o zbiorach nieograniczonych, natomiast metoda ta działa na zbiorach ograniczonych, to znaczy wszystkie węzły muszą być policzone przed przypisaniem w korzeniu
ODMIANY TAKTYKI
245
wartości atrybutu right_num. Oczywiście przy każdym wstawieniu węzła należy odpowiednio zmodyfikować wartości atrybutów left_num i right_num dla węzłów potomnych oraz right_num dla węzłów nadrzędnych. Konieczność modyfikowania wielu innych elementów przy wstawianiu nowego wiersza odpowiada dokładnie obsłudze listy sortowanej w tablicy: wstawienie wiersza do takiej posortowanej listy wiąże się z koniecznością przesunięcia średnio połowy elementów. Model zbiorów zagnieżdżonych jest bez wątpienia pomysłowy, ale z punktu widzenia relacyjności to koszmar. Trudno też wyobrazić sobie gorsze rozwiązanie z punktu widzenia denormalizacji. W rzeczywistości model zbiorów zagnieżdżonych wywodzi się z technik wykorzystujących wskaźniki, czyli dokładnie z tego typu mechanizmów, od których teoria relacyjna z założenia miała być ucieczką.
Przeszukiwanie drzewa z użyciem SQL-a Aby sprawdzić wydajność modeli, posłużyłem się testami rozwiązań następujących dwóch problemów: 1. Odszukać wszystkie jednostki pod dowództwem generała Dominique’a Vandamme’a (przeszukiwanie zstępujące), o ile to możliwe w postaci raportu z wcięciami (który wymaga śledzenia poziomu zagnieżdżenia elementu w drzewie) lub w postaci prostej listy. Przypominam, że we wszystkich modelach tabela jest poindeksowana po nazwisku dowódcy. W dalszej części rozdziału ten problem będę określał jako zapytanie Vandamme’a. 2. Dla wszystkich pułków Scottish Highlanders (szkockich górali) wyszukać jednostki nadrzędne, ponownie z odpowiednimi wcięciami i w postaci prostej listy (przeszukiwanie wstępujące). Nie mamy indeksu po nazwach jednostek (kolumna description w tabelach) i jedyny sposób odszukania pułków Scottish Highlanders polega na wyszukaniu ciągu znaków Highland w nazwie jednostki, co oczywiście oznacza pełne przeszukiwanie tabeli (zakładamy brak mechanizmu indeksowania pełnotekstowego). W dalszej części rozdziału ten problem będę określał jako zapytanie o górali. Aby zapewnić porównywalność modeli, wszystkie zapytania będą wykonywane na tej samej bazie danych, czyli Oracle.
246
ROZDZIAŁ SIÓDMY
Przeszukiwanie zstępujące: zapytanie Vandamme’a W zapytaniu Vandamme’a jako punkt wyjścia przyjmiemy dowódcę Trzeciego Korpusu Francuskiego generała Vandamme’a — chcemy wyszukać wszystkie podległe mu jednostki. Nie chcemy jednak prostej listy — struktura jednostek armii musi być przejrzysta: korpusy i dywizje są złożone z brygad, które z kolei są złożone z reguły z dwóch pułków.
Model sąsiedztwa Napisanie zapytania Vandamme’a w modelu sąsiedztwa jest względnie prostym zadaniem, gdy ma się do dyspozycji operator CONNECT BY dostępny w Oracle. Wystarczy określić węzeł początkowy (START WITH) oraz sposób powiązania kolejnych węzłów (connect by = prior lub connect by = prior , w zależności od kierunku przeszukiwania drzewa). Na potrzeby wcięć wykorzystamy specjalną kolumnę odsługiwaną przez Oracle o nazwie level, informującą o poziomie zagnieżdżenia w stosunku do poziomu początkowego. Wykorzystam wartość tej pseudokolumny wraz z funkcją lpad() (wstawiającą spacje z lewej strony ciągu znaków). Zapytanie jest bardzo krótkie: select lpad(description, length(description) + level) description, commander from adjacency-model connect by parent_id = prior id start with commander = 'Général de Division Dominique Vandamme'
A oto jego wynik: DESCRIPTION -----------------------------III Corps 8th Infantry Division 2nd Brigade 37th Rgmt de Ligne 1st Brigade 23rd Rgmt de Ligne 15th Rgmt Leger 10th Infantry Division 2nd Brigade 70th Rgmt de Ligne 22nd Rgmt de Ligne 2nd (Swiss) Infantry Rgmt
COMMANDER ----------------------------------------------Général de Division Dominique Vandamme Général de Division Baron Etienne-Nicolas Lefol Général de Brigade Baron Corsin Colonel Cornebise Général de Brigade Billard (d.15th) Colonel Baron Vernier Colonel Brice Général de Division Baron Pierre-Joseph Habert Général de Brigade Baron Dupeyroux Colonel Baron Maury Colonel Fantin des Odoards Colonel Stoffel
ODMIANY TAKTYKI
1st Brigade 88th Rgmt de Ligne 34th Rgmt de Ligne Division Artillery l8/2nd Foot Artillery
247
Général de Brigade Baron Gengoult Colonel Baillon Colonel Mouton Captain Guerin
40 rows selected.
W jaki sposób wykorzystać to tego samego celu rekurencyjny operator WITH4? W tym modelu zdefiniowane jest wyrażenie rekurencyjne skonstruowane w oparciu o unię (a dokładniej klauzulę UNION ALL) dwóch zapytań: • Zapytania definiującego punkt wyjścia, a w tym konkretnym przypadku: select 1 level, id, description, commander from adjacency_model where commander = 'Général de Division Dominique Vandamme'
Do czego służy ta samotna jedynka? Reprezentuje głębokość zagnieżdżenia w drzewie. W przeciwieństwie do implementacji operatora CONNECT BY znanej z baz Oracle implementacja w DB2 nie obsługuje systemowej pseudokolumny informującej o zagnieżdżeniu w drzewie. Nasz poziom możemy jednak obliczyć, więcej szczegółów zdradzę za chwilę. • Zapytania definiującego sposób powiązania każdego potomka z jego przodkiem, co jest zwracane z zapytania rekurencyjnego nazwanego, niezbyt oryginalnie, recursive_query: select parent.level + 1, child.id, child.description, child.comander from recursive_query parent, adjacency_model child where parent.id = child.parent_id
W tym zapytaniu do parent.level dodajemy wartość 1. Każde wywołanie tego zapytania przechodzi jeden poziom w dół drzewa, dlatego dla każdego zejścia do poprzedniego numeru poziomu dodajemy 1, śledząc w ten sposób głębokość ścieżki. 4
Tym razem wykorzystując obsługującą go bazę DB2.
248
ROZDZIAŁ SIÓDMY
Pozostało jedynie kilka zabiegów z użyciem funkcji formatujących teksty, aby zrealizować wcięcia opisów, i oto ostateczna postać zapytania: with recursive_query(level, id, description, commander) as (select 1 level, id, description, commander from adjacency_model where commander = 'Général de Division Dominique Vandamme' union all select parent.level + 1, child.id, child.description, child.commander from recursive_query parent, adjacency_model child where parent.id = child.parent_id) select char(concat(repeat(' ', level), description), 60) description, commander from recursive_query
Oczywiście jedynie zapalony pasjonat instrukcji WITH jest w stanie bez zakłopotania twierdzić, że ta składania jest przejrzysta i elegancka. Jednak po chwili analizy nie jest trudno zrozumieć ten kod i można go uznać za zadowalający. Jedyny poważny problem polega tu jednak na tym, że w wyniku uzyskuje się generała Vandamme’a, po którym występują wszyscy bezpośrednio podlegli mu oficerowie, po czym wszyscy podlegli im oficerowie itd. Nie uzyskujemy tu eleganckiej struktury drzewiastej, jasno reprezentującej poszczególne zależności między oficerami. Można by zaryzykować stwierdzenie, że skoro kolejność elementów nie jest cechą obsługiwaną w teorii relacyjnej, nie powinna ona mieć tu znaczenia. Jednak ten problem nasuwa inne, ważniejsze pytanie: w jaki sposób uporządkować wiersze zapytania hierarchicznego? Uporządkowanie wierszy zapytania wykorzystującego instrukcję WITH jest możliwe przy dwóch założeniach (dość rozsądnych): każdy rodzic nie może mieć więcej niż dziewięćdziesiąt dziewięć bezpośrednich potomków i drzewo nie może być monstrualnie głębokie. Przy spełnieniu tych warunków z każdym z węzłów można powiązać liczbę określającą jej położenie w hierarchii. Na przykład 1.030801 oznacza, że to pierwszy potomek (dwie cyfry od prawej) ósmego potomka (kolejne dwie cyfry od prawej)
ODMIANY TAKTYKI
249
trzeciego potomka węzła głównego (korzenia). Oczywiście przy założeniu, że jesteśmy w stanie określić kolejność potomków, co nie zawsze musi być wykonalne. Czasem pojawia się konieczność nadania elementom sztucznej kolejności, często do tego celu wykorzystywana jest funkcja OLAP, na przykład row_number(). Nasze poprzednie zapytanie możemy zatem delikatnie zmodyfikować, aby przypisać porządek węzłom i wykorzystać go do sortowania wierszy wykonywanych: with recursive_query(level, id, rank, description, commander) as (select 1, id, cast(i as double), description, commander from adjacency_model where commander = 'Général de Division Dominique Vandamme' union all select parent.level + 1, child.id, parent.rank + ranking.sn / power(100.0, parent.level), child.description, child.commander from recursive_query parent, (select id, row_number() over (partition by parent_id order by description) sn from adjacency_model) ranking, adjacency_model child where parent.id =child.parent_id and child.id = ranking.id) select char(concat(repeat(' ', level), description), 60) description, commander from recursive_query order by rank
Można by się obawiać, że zapytanie nadające ranking wywoływane dla elementu rekurencyjnego będzie wywoływane dla każdego węzła indywidualnie, tym samym zwracając za każdym razem ten sam wynik. Na szczęście tak nie jest. Optymalizator jest wystarczająco inteligentny, aby podjąć odpowiednie decyzje. W wyniku otrzymujemy:
250
ROZDZIAŁ SIÓDMY
DESCRIPTION -----------------------------III Corps 10th Infantry Division 1st Brigade 34th Rgmt de Ligne 88th Rgmt de Ligne 2nd Brigade 22nd Rgmt de Ligne 2nd (Swiss) Infantry Rgmt 70th Rgmt de Ligne Division Artillery l8/2nd Foot Artillery llth Infantry Division ... 23rd Rgmt de Ligne 2nd Brigade 37th Rgmt de Ligne Division Artillery 7/6th Foot Artillery Reserve Artillery 1/2nd Foot Artillery 2/2nd Rgmt du Génie
COMMANDER ---------------------------------------------Général de Division Dominique Vandamme Général de Division Baron Pierre-Joseph Habert Général de Brigade Baron Gengoult Colonel Mouton Colonel Baillon Général de Brigade Baron Dupeyroux Colonel Fantin des Odoards Colonel Stoffel Colonel Baron Maury Captain Guerin Général de Division Baron Pierre Berthézène Colonel Baron Vernier Général de Brigade Baron Corsin Colonel Cornebise Captain Chauveau Général de Division Baron Jérôme Doguereau Captain Vollée
Wynik nie jest tu identyczny z uzyskanym za pomocą instrukcji CONNECT BY, ponieważ węzły posortowaliśmy alfabetycznie po kolumnie description, a w przypadku CONNECT BY nie stosowaliśmy żadnego sortowania (oczywiście istnieje taka możliwość). Jednak sama hierarchia jest wyświetlana prawidłowo. Wynik uzyskany z zapytania WITH jest logicznie równoważny wynikowi zapytania CONNECT BY, co nie zmienia faktu, że to idealny przykład koszmarnego, zagmatwanego kodu w SQL-u, przy którym pięciowierszowe zapytanie z użyciem CONNECT BY wygląda jak wzór elegancji i prostoty. I mimo tego że w tym konkretnym przypadku wydajność zapytania jest raczej zadowalająca, można by zastanawiać się, jak będzie się ono sprawować na wielkich tabelach. Czy rekurencyjny operator WITH należy uznać za kiepską implementację znacznie lepszej formy CONNECT BY? Wnioski odłóżmy na koniec rozdziału. Wartość ranking zbudowana w zapytaniu rekurencyjnym nie jest niczym innym, jak tylko reprezentacją zmaterializowanej ścieżki. Z tego powodu nadszedł najwyższy czas na zweryfikowanie, w jaki sposób armia generała Vandamme’a będzie spisywała się w implementacji zmaterializowanej ścieżki.
ODMIANY TAKTYKI
251
Model zmaterializowanej ścieżki Nasze zapytanie w przypadku modelu zmaterializowanej ścieżki jest niewiele bardziej skomplikowane, z wyjątkiem poziomu zagnieżdżenia, który wynika z samej definicji ścieżki. Załóżmy, że mamy zdefiniowaną funkcję mp_depth(), która zwraca liczbę poziomów hierarchii między bieżącym węzłem a szczytem drzewa. Możemy wykorzystać następujące zapytanie: select lpad(a.description, length(a.description) + mp_depth(...)) description, a.commander from materialized_path_model a, materialized_path_model b where a.materialized_path like b.materialized_path || '%' and b.commander = 'Général de Division Dominique Vandamme') order by a.materialized_path
Zanim zajmiemy się szczegółowo funkcją mp_depth(), należy zwrócić uwagę na kilka pułapek. W moim przykładzie zdecydowałem się zastosować w ścieżce przedrostek A dla armii angielsko-holenderskiej, P dla armii pruskiej i F dla francuskiej. Po tej pierwszej literze następują cyfry oddzielone kropkami. W ten sposób 12th Dutch line battalion pod dowództwem pułkownika Bagelaara będzie miał ścieżkę w postaci A.1.4.2.3, natomiast 11th Régiment of Cuirassiers pod dowództwem pułkownika Courtiera to F.9.1.2.2. Sortowanie bezpośrednio po zmaterializowanej ścieżce prowadzi do problemów z sortowaniem ciągów znaków zawierających liczby i kropki, czyli wartość 10.2 wystąpi przed 2.3. Należy jednak podkreślić, że ponieważ separator ma niższy kod (ASCII) niż 0, zachowana będzie przynajmniej kolejność poziomów. Sortowanie może jednak nie uwzględniać kolejności elementów występujących na tym samym poziomie w ścieżce. Czy to ma znaczenie? Nie sądzę: kolejność wśród elementów tego samego poziomu w hierarchii to informacja dodatkowa, która powinna wynikać z innych ich cech, nie z samej ścieżki zmaterializowanej (na przykład rodzeństwo można uporządkować względem daty urodzenia, ścieżka nie ma tu nic do rzeczy). Należy zatem zachować ostrożność, wykorzystując zaproponowane przeze mnie podejście. Kodowanie znaków wykorzystywane przez bazę danych może spowodować nieoczekiwane efekty uboczne.
252
ROZDZIAŁ SIÓDMY
Wróćmy teraz do naszej tajemniczej funkcji mp_depth(). Różnica w hierarchii między dowódcą podległym generałowi Vandamme’owi a samym generałem Vandamme’em może być zdefiniowana jako różnica między poziomami bezwzględnymi (liczonymi od podstawy drzewa) jednostki dowodzonej przez generała Vandamme’a oraz jednostki mu podległej. W jaki sposób jednak określić poziom bezwzględny? Wystarczy po prostu policzyć kropki. Aby policzyć kropki, najprościej jest chyba porównać długość ciągu znaków ścieżki zmaterializowanej z długością tego ciągu po usunięciu z niego kropek. W tym celu można posłużyć się funkcją replace() obsługiwaną przez większość dialektów SQL-a. Wystarczy odjąć długość ciągu znaków z usuniętymi kropkami od długości oryginalnego ciągu znaków: length((materialized_path) - length(replace(materialized_path, '.', ''))
Sprawdźmy algorytm liczenia kropek na nazwisku autora cytatu z rozdziału 6. (w czasie bitwy pod Waterloo był on pułkownikiem kawalerii). Zastosujemy następujące zapytanie: SQL> 2 3 4 5 6 7 8 9
select materialized_path, length(materialized_path) len_w_dots, length(replace(materialized_path, '.', ")) len_wo_dots, length(materialized_path) length(replace(materialized_path, '.', '')) depth, commander from materialized_path_model where commander = 'Colonel de Marbot' /
MATERIALIZED_PATH LEN_W_DOTS LEN_WO_DOTS ---------------------------- ----------F.1.5.1.1 9 5
DEPTH COMMANDER -------- -----------------+ 4 Colonel de Marbot
To działa!
Model zagnieżdżonych zbiorów Odszukanie wszystkich jednostek pod dowództwem generała Vandamme’a jest bardzo prostym zadaniem w modelu zagnieżdżonych zbiorów, ponieważ model ten wymaga, aby każdy węzeł miał nadane wartości atrybutów left_num i right_num w taki sposób, że obejmują wszystkie wartości left_num i right_num potomków. Dzięki temu wystarczy napisać:
ODMIANY TAKTYKI
253
select a.description, a.commander from nested_sets_model a, nested_sets_model b where a.left_num between b.left_num and b.right_num and b.commander = 'General de Division Dominique Vandamme'
I to wszystko? Niezupełnie, brakuje tu wcięć. W jaki sposób uzyskać numer poziomu? Niestety, jedyny sposób uzyskania zagłębienia węzła (w oparciu o który generujemy wcięcia) polega na zliczeniu węzłów pośrednich między zadanym węzłem a podstawą drzewa. Zagłębienia nie da się w żaden sposób odczytać z wartości left_num i right_num (w przeciwieństwie do modelu zmaterializowanej ścieżki). Aby wyświetlić strukturę drzewa z wcięciami w modelu zbiorów zagnieżdżonych, musimy dokonać trzeciego złączenia z tabelą nested_sets_model tylko po to, by policzyć zagłębienie: select lpad(description, length(description) + depth) description, commander from (select count(c.left_num) depth, a.description, a.commander, a.left_num from nested_sets_model a, nested_sets_model b, nèsted_sets_model c where a.left_num between c.left_num and c.right_num and c.left_num between b.left_num and b.right_num and b.commander = 'Général de Division Dominique Vandamme' group by a.description, a.commander, a.left_num) order by left_num
Prosty wymóg przygotowania listingu z wcięciami powoduje, że zapytanie staje się znacznie mniej czytelne, podobnie jak w przypadku zapytania z użyciem WITH().
Porównanie wydajności zapytania Vandamme’a z zastosowaniem różnych modeli Po upewnieniu się, że wszystkie zapytania zwracają te same czterdzieści wierszy wynikowych z odpowiednimi wcięciami, zdecydowałem się przeprowadzić test wywołujący każde z zapytań w pętli pięć tysięcy razy
254
ROZDZIAŁ SIÓDMY
(co daje łącznie dwieście tysięcy wierszy). Przygotowałem zestawienie wykorzystujące współczynnik liczby wierszy na sekundę, wynik modelu sąsiedztwa przyjmując jako punkt odniesienia o wartości sto. Wyniki przedstawiłem w postaci wykresu na rysunku 7.2.
RYSUNEK 7.2. Porównanie wydajności poszczególnych modeli w zapytaniu Vandamme’a
Jak widać na rysunku 7.2, w zapytaniu Vandamme’a model sąsiedztwa, w którym zapytanie wykorzystuje instrukcję CONNECT BY, działa najwydajniej mimo proceduralnej natury instrukcji CONNECT BY. Ścieżka zmaterializowana również spisuje się nie najgorzej, ale z pewnością część strat spowodowanych jest w niej wywołaniami funkcji obliczających zagłębienie w strukturze, czyli wykorzystywanych wyłącznie na potrzeby wcięć. Koszt estetyki wyniku jest jeszcze bardziej wyraźny przy modelu zbiorów zagnieżdżonych, gdzie obliczanie zagłębienia w strukturze wykorzystujące dodatkowe złączenie i operację GROUP BY jest jednoznacznie najbardziej kosztowną operacją. Można by cynicznie zauważyć, że ten model jest zupełnie niezgodny z regułami relacyjnymi, więc można zupełnie porzucić projekt relacyjny i w każdym węźle po prostu zapisać dodatkową informację dotyczącą zagłębienia w stosunku do podstawy drzewa. Taka decyzja projektowa z pewnością wpłynęłaby na wydajność zapytania Vandamme’a, ale po dramatycznym koszcie zwiększenia komplikacji modelu.
Przeszukiwanie wstępujące: zapytanie górali Jak wspomniałem wyżej, wyszukiwanie ciągu znaków Highland w opisach będzie nieodwołalnie skutkowało wykonaniem pełnego przeszukiwania tabeli. Jednak w pierwszej kolejności napiszmy zapytania implementujące odpowiednie przeszukiwania w każdym z modeli, a potem rozważymy stosowne implikacje z punktu widzenia wydajności.
ODMIANY TAKTYKI
255
Model sąsiedztwa Zapytanie górali jest bardzo proste do napisania z użyciem klauzuli CONNECT BY i ponownie posłużymy się pseudokolumną level do realizacji eleganckich wcięć w wyniku. Należy zauważyć, że wartości w kolumnie level w poprzednim zapytaniu określały zagłębienie w strukturze, a tym razem określają wysokość, ponieważ wartość ta jest obliczana od punktu początkowego, a tym razem wydobywamy rodzica w oparciu o potomka. select lpad(description, length(description) + level) description, commander from adjacency_model connect by id = prior parent_id start with description like '%Highland%'
Wynik tego zapytania będzie następujący: DESCRIPTION --------------------------------2/73rd (Highland) Rgmt of Foot 5th British Brigade 3rd Anglo-German Division I Corps The Anglo-Allied Army of 1815
COMMANDER --------------------------------------Lt-Colonel William George Harris Major-General Sir Colin Halkett Lt-General Count Charles von Alten Prince William of Orange Field Marshal Arthur Wellesley, Duke of Wellington 1/71st (Highland) Rgmt of Foot Lt-Colonel Thomas Reynell British Light Brigade Major-General Frederick Adam 2nd Anglo-German Division Lt-General Sir Henry Clinton II Corps Lieutenant-General Lord Rowland Hill The Anglo-Allied Army of 1815 Field Marshal Arthur Wellesley, Duke of Wellington 1/79th (Highland) Rgmt of Foot Lt-Colonel Neil Douglas 8th British Brigade Lt-General Sir James Kempt 5th Anglo-German Division Lt-General Sir Thomas Picton (d.18th) General Reserve Duke of Wellington The Anglo-Allied Army of 1815 Field Marshal Arthur Wellesley, Duke of Wellington 1/42nd (Highland) Rgmt of Foot Colonel Sir Robert Macara (d.16th) 9th British Brigade Major-General Sir Denis Pack 5th Anglo-German Division Lt-General Sir Thomas Picton (d.18th) General Reserve Duke of Wellington The Anglo-Allied Army of 1815 Field Marshal Arthur Wellesley, Duke of Wellington 1/92nd (Highland) Rgmt of Foot Lt-Colonel John Cameron 9th British Brigade Major-General Sir Denis Pack
256
ROZDZIAŁ SIÓDMY
5th Anglo-German Division Lt-General Sir Thomas Picton (d.18th) General Reserve Duke of Wellington The Anglo-Allied Army of 1815 Field Marshal Arthur Wellesley, Duke of Wellington 25 rows selected.
Nierelacyjna natura klauzuli CONNECT BY ujawnia się z cała mocą: wynik nie jest relacją, ponieważ zawiera duplikaty. Nazwisko Księcia Wellingtona pojawia się osiem razy, ale w dwóch różnych rolach: pięć razy (tyle, ile mamy pułków górali szkockich) w roli commander-in-chief i trzy razy jako General Reserve. W zupełności wystarczyłoby, gdyby wystąpił dwa razy — po jednym dla każdej roli. Czy możemy usunąć duplikaty? Nie za bardzo, w każdym razie nie jest to łatwe. Gdyby zastosować klauzulę DISTINCT, baza danych posortuje wyniki w celu usunięcia duplikatów, psując w ten sposób kolejność niezbędną do reprezentacji hierarchii. Osiągnęliśmy wynik odpowiadający na zadane pytanie. To, czy jest wystarczający, jest uzależnione wyłącznie od konkretnych wymogów biznesowych.
Model zmaterializowanej ścieżki Zapytanie o górali jest nieco trudniejsze do zapisania w przypadku modelu zmaterializowanej ścieżki. Sama identyfikacja odpowiednich wierszy oraz ich odpowiednie sformatowanie nie przysparzają problemów: select lpad(a.description, length(a.description) + mp_depth(b.materialized_path) - mp_depth(a.materialized_path)) description, a.commander from materialized_path_model a, materialized_path_model b where b.materialized_path like a.materialized_path |[ '%' and b.description like '%Highland%')
Jednak w tym przypadku mamy dwa problemy: • uzyskujemy duplikaty, identycznie, jak w modelu sąsiedztwa, • kolejność wierszy jest nieodpowiednia. Paradoksalnie drugi z tych problemów stanowi uproszczenie rozwiązania pierwszego z nich. Skoro i tak mamy błędną kolejność, dodanie klauzuli DISTINCT niczego nie popsuje! W jaki jednak sposób przywrócić właściwą kolejność? Jak zwykle, wykorzystując jako klucz sortowania samą
ODMIANY TAKTYKI
257
zmaterializowaną ścieżkę. Po połączeniu tych dwóch pomysłów i zastosowaniu podzapytania w klauzuli FROM otrzymamy następujące zapytanie: select description, commander from (select distinct lpad(a.description, length(a.description) + mp_depth(b.materialized_path) - mp_depth(a.materialized_path)) description, a.commander, a.materializedpath from materialized_path_model a, materialized_path_model b where b.materializedpath like a.materialized_path || '%' and b.description like '%Highland%') order by materialized_path desc
Wynik zapytania jest następujący: DESCRIPTION ---------------------------------1/92nd (Highland) Rgmt of Foot 1/42nd (Highland) Rgmt of Foot 9th British Brigade 1/79th (Highland) Rgmt of Foot 8th British Brigade 5th Anglo-German Division General Reserve 1/71st (Highland) Rgmt of Foot British Light Brigade 2nd Anglo-German Division II Corps 2/73rd (Highland) Rgmt of Foot 5th British Brigade 3rd Anglo-German Division I Corps The Anglo-Allied Army of 1815
COMMANDER --------------------------------------Lt-Colonel John Cameron Colonel Sir Robert Macara (d.16th) Major-Ceneral Sir Denis Pack Lt-Colonel Neil Douglas Lt-General Sir James Kempt Lt-General Sir Thomas Picton (d.18th) Duke of Wellington Lt-Colonel Thomas Reynell Major-General Frederick Adam Lt-General Sir Henry Clinton Lieutenant-General Lord Rowland Hill Lt-Colonel William George Harris Major-General Sir Colin Halkett Lt-General Count Charles von Alten Prince William of Orange Field Marshal Arthur Wellesley, Duke of Wellington
16 rows selected.
Taki wynik jest znacznie bardziej elegancki i zwarty niż w przypadku modelu sąsiedztwa. Należy jednak zwrócić uwagę na następujący warunek: where b.materialized_path like a.materialized_path || '%'
Ten warunek w sytuacji, gdy wyszukiwane są wiersze w tabeli a, a znane są wiersze z tabeli b, będzie działał wolno, ponieważ nie ma możliwości wykorzystania indeksu na kolumnie. Aby wykorzystać indeks w sposób wydajny, należałoby wyszukiwać wiersze b.materialized_path, znając
258
ROZDZIAŁ SIÓDMY
wartość a.materialized_path. Istnieją sposoby rozłożenia zmaterializowanej ścieżki na listę zmaterializowanych ścieżek przodków węzła (zobacz rozdział 11.), ale wykonanie takiej operacji również wiąże się z określonym kosztem. W przypadku naszych przykładowych danych zaprezentowane zapytanie będzie miało lepszą wydajność niż w przypadku rozłożenia zmaterializowanej ścieżki każdego z przodków. Jednak w przypadku tabel zawierających miliony wierszy sytuacja może być zupełnie inna.
Model zagnieżdżonych zbiorów W tym przypadku również pojawia się problem z koniecznością dynamicznego obliczenia poziomu zagłębienia, a także kwestia, że to obliczenie jest dość kosztowną operacją. Zapytanie o górali jest zapytaniem wstępującym, musimy zatem zadbać, aby nie wyświetlić nieznaczącego węzła głównego (korzenia), który łatwo zidentyfikować za pomocą warunku left_num = l. Co więcej, musiałem sztucznie zakodować wartość maksymalnego zagłębienia (6), aby prawidłowo sformatować wynik. W wynikach tego zapytania poziomy nadrzędne mają większe wcięcia niż poziomy podrzędne, zatem rozmiar wcięcia jest odwrotnie proporcjonalny do zagłębienia. Ponieważ w modelu zagnieżdżonych zbiorów trudno obliczyć zagłębienie, sztuczka w postaci obleczenia 6 – depth stanowi rozsądny kompromis. Podobnie jak w modelu zmaterializowanej ścieżki, musimy wykonać sortowanie wyników, nie mamy więc skrupułów przed wykorzystaniem klauzuli DISTINCT. Oto nasze zapytanie: select lpad(description, length(description) + 6 - depth) description, commander from (select distinct b.description, b.commander, b.left_num, (select count(c.left_num) from nested_sets_model c where b.left_num between c.left_num and c.right_num) depth from nested_sets_model a, nested_sets_model b where a.description like '%Highland%' and a.left_num between b.left_num and b.right_num and b.left_num > l) order by left_num desc
To zapytanie wyświetli dokładnie takie same wyniki, jak w przypadku modelu zmaterializowanej ścieżki.
ODMIANY TAKTYKI
259
Porównanie wydajności działania zapytania o górali dla różnych modeli W przypadku zapytania o górali zastosowałem analogiczny test, jaki wykonałem dla zapytania Vandamme’a: każde zapytanie było wykonane pięćset razy. Mamy do czynienia z jedną różnicą: model sąsiedztwa zwraca duplikaty, których nie możemy się pozbyć. Z tego powodu test zwraca pięć tysięcy razy po dwadzieścia pięć wierszy w przypadku modelu sąsiedztwa i pięć tysięcy razy po szesnaście wierszy w przypadku pozostałych modeli, ponieważ tylko te wiersze nas interesują. Gdyby wydajność wyrazić po prostu jako liczbę wierszy zwracanych w jednostce czasu, w modelu sąsiedztwa liczymy zbędne (nadmiarowe) wiersze, co fałszowałoby wynik. Dlatego w przypadku modelu sąsiedztwa pod uwagę biorę tylko tych szesnaście wierszy, które mają znaczenie dla zapytania, tak jak w pozostałych modelach. Wyniki pomiarów są przedstawione na rysunku 7.3.
RYSUNEK 7.3. Porównanie wydajności zapytania o górali dla różnych modeli
Z rysunku 7.3 dość jasno wynika, że model sąsiedztwa przed korektą znacznie wyprzedza pozostałe modele, a po korekcie jego przewaga nadal jest znacząca. Należy również zauważyć, że model ścieżki zmaterializowanej jest szybszy od modelu zagnieżdżonych zbiorów, ale w tym przypadku dość marginalnie. Z tego powodu widać, że mimo proceduralnej natury implementacja klauzuli CONNECT BY sprawdza się dość dobrze zarówno w przypadku zstępującego, jak i wstępującego przeszukiwania drzew, oczywiście pod warunkiem że kolumny są odpowiednio poindeksowane. Jednak fakt istnienia problemu z duplikatami w metodzie wstępującej w przypadku
260
ROZDZIAŁ SIÓDMY
kilku punktów startowych przeszukiwania może stanowić sporą niedogodność z praktycznego punktu widzenia. Gdy nie jest dostępna klauzula CONNECT BY albo rekurencyjna klauzula WITH, dobrym wyborem jest model zmaterializowanej ścieżki. Ciekawą jego cechą jest to, że działa wydajniej niż w pełni statyczny (niewymagający dodatkowych obliczeń przy zapytaniach odczytujących) model zagnieżdżonych zbiorów. Przy projektowaniu tabel na potrzeby zapisu danych hierarchicznych należy wystrzegać się kilku błędów, które pozwoliłem sobie popełnić w przedstawionym przykładzie: Zmaterializowana ścieżka nie powinna być kluczem nawet w przypadku, gdy istnieje pewność, że jest unikalna To prawda, że w przypadku silnych hierarchii nie mamy z reguły do czynienia z dynamicznym środowiskiem, ale w rzeczywistości rzadko zdarza się, że obiekt jest zdefiniowany swoim miejscem w hierarchii. Zmaterializowana ścieżka nie powinna określać kolejności elementów tego samego poziomu Uporządkowanie elementów nie jest cechą zbiorów obsługiwaną przez model relacyjny, który skupia się na występowaniu danych, a nie na ich ułożeniu. Usuwanie wiersza czy dodawanie nowego nie powinno zmuszać do modyfikacji innych wierszy w tabeli (co przy okazji jest najważniejszym praktycznym powodem, aby unikać modelu zagnieżdżonych zbiorów). Łatwo jest dopisać węzeł jako ostatniego potomka węzła nadrzędnego. Węzły można posortować po zmaterializowanej ścieżce przodka, a następnie po innym atrybucie, który najlepiej nadaje się do uporządkowania obiektów tego samego poziomu hierarchii. Wybór kodowania nie jest całkowicie bez znaczenia Wybór kodowania ma znaczenie, ponieważ zawsze w przypadku konieczności posortowania węzłów po ścieżce zmaterializowanej lub po ścieżce przodka zapis ścieżki musi pozwalać na sortowanie bez zakłócenia porządku przodków i potomków. Najbezpieczniej jest zapisywać ścieżkę z użyciem liczb uzupełnionych zerami, na przykład 001.003.004.005 (warto zauważyć, że jeśli do zapisu poszczególnych poziomów ścieżki zawsze używamy tej samej liczby cyfr, można
ODMIANY TAKTYKI
261
zrezygnować z separatorów). Można by się obawiać o długość ścieżki, ale na przykład przy założeniu, że każdy węzeł może mieć maksymalnie sto potomków o numerach od 00 do 99, dwadzieścia znaków wystarcza na zapisanie do dziesięciu poziomów hierarchii, co z kolei oznacza drzewa o maksymalnie 10010 węzłach, co z reguły jest wartością znacznie na wyrost. Przeszukiwanie drzew, niezależnie od tego, czy wstępujące, czy zstępujące, to z natury operacje sekwencyjne, a co się z tym wiąże, powolne.
Agregacja wartości z drzewa Gdy wiemy już, w jaki sposób obsługiwać operacje przeszukiwania drzew, warto dowiedzieć się, w jaki sposób agregować dane zapisane w strukturach drzewiastych. W większości przypadków operacje agregacji wartości zapisanych w drzewach należą do jednej z dwóch kategorii: agregacji wartości zapisanych w liściach i propagacji wartości procentowych na różne poziomy hierarchii.
Agregacja wartości zapisanych w liściach W bardziej realistycznych przypadkach niż zapytanie Vandamme’a i zapytanie o górali węzły zawierają dane, szczególnie dotyczy to węzłów nieposiadających potomków (liście). Na przykład węzły reprezentujące pułki powinny zawierać informację o ich liczebności, co pozwoli na obliczenie siły każdej jednostki militarnej.
Modelowanie liczebności Jeśli weźmiemy ten sam przykład, co poprzednio, i ograniczymy się do podzbioru French Third Corps pod dowództwem generała Vandamme’a jedynie do poziomu brygady, prawidłowa reprezentacja będzie się składać się z następujących tabel: UNITS. Każdy wiersz tabeli units opisuje poszczególne poziomy agregacji
(korpus, dywizja, brygada), podobnie jak w tabelach adjacency_model, materialized_path_model i nested_sets_model, ale bez specjalnego atrybutu definiującego związek jednostki z jej jednostką nadrzędną:
262
ID -1 2 3 4 5 6 7 8 9 10 11 12 13 14
ROZDZIAŁ SIÓDMY
NAME -------------------------III Corps 8th Infantry Division 1st Brigade 2nd Brígade 10th Tnfantry Division 1st Brigade 2nd Brigade 11th Infantry Division 1st Brigade 2nd Brigade 3rd Light Cavalry Division 1st Brigade 2nd Brigade Reserve Artillery
COMMANDER ----------------------------------------------Général de Division Dominique Vandamme Général de 0ivision Baron Etienne Nicolas Lefol Général de Brigade Billard Général de Brigade Baron Corsin Général de Division Baron Pierre-Joseph Habert Général de Brigade Baron Gengoult Général de Brigade Baron Dupeyroux Général de Division Baron Píerre Berthézčne Général de Brigade Baron Dufour Général de Brigade Baron Logarde Général de Divisíon Baron Jean-Simon Domont Général de Brigade Baron Dommanget Général de Brigade Baron Vinot Général de Division Baron Jérôme Doguereau
Ponieważ powiązanie między jednostkami nie jest już zapisane w tej tabeli, potrzebujemy dodatkowej tabeli opisującej sposób wzajemnego powiązania węzłów w tabeli. UNIT_LINKS_ADJACENCY. Ponownie posłużymy się modelem sąsiedztwa, lecz
tym razem powiązania między jednostkami będą zapisane osobno od innych atrybutów, w liście sąsiedztwa, innymi słowy, w tabeli definiującej powiązanie identyfikatorów jednostek, czyli ID, z identyfikatorem wiersza nadrzędnego. Tego typu lista pozwala na izolację danych od ich struktury. Nasza przykładowa tabela unit_links_adjacency ma następującą zawartość: ID PARENT_ID -------- ----------2 1 3 2 4 2 5 1 6 5 7 5 8 1 9 8 10 8 11 1 12 11 13 11 14 1
ODMIANY TAKTYKI
263
UNIT_LINKS_PATH. Jak widzieliśmy, lista sąsiedztwa to nie jedyny sposób,
w jaki można opisać strukturę hierarchii węzłów w drzewie. Alternatywnie możemy wykorzystać listę zmaterializowanych ścieżek, umieszczając je w tabeli unit_links_path: ID ------1 2 3 4 5 6 7 8 9 10 11 12 13 14
PATH ---------1 1.1 1.1.1 1.1.2 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.4 1.4.1 1.4.2 1.5
UNIT_STRENGTH. Ze źródeł historycznych uzyskaliśmy informację o liczebności
każdej z brygad, najniższego poziomu szczegółowości naszego przykładowego modelu. Informację tę umieścimy w tabeli unit_strength: ID MEN -------- ------3 2952 4 2107 6 2761 7 2823 9 2488 10 2050 12 699 13 318 14 152
Obliczanie liczebności na wszystkich poziomach W modelu sąsiedztwa dość łatwo jest uzyskać liczebność oddziałów, jeśli znamy identyfikator oddziału nadrzędnego: select sum(men) from unit_strength where id in (select id from unit_links_adjacency connect by prior id = parent_id start with parent_id = l)
264
ROZDZIAŁ SIÓDMY
Czy jednak mamy możliwość uzyskania liczebności każdego poziomu, na przykład każdej dywizji (jednostka bojowa złożona z dwóch brygad)? Oczywiście możemy to zrobić w ten sam sposób, wystarczy podać id odpowiedniego punktu wyjścia, czyli w tym przypadku dywizji. W tym miejscu musimy podjąć decyzję: albo napisać procedurę w samej aplikacji, przechodzącą kolejno po poszczególnych identyfikatorach jednostek bojowych i sumującą wyniki, albo zdecydować się na rozwiązanie w czystym SQL-u, wywołujące zapytanie obliczające liczebności dla każdego napotkanego wiersza. Nasze zapytanie musimy nieco zmodyfikować, aby zwracało precyzyjne liczebności od razu, gdy są znane, czyli dla najniższego poziomu, w naszym przypadku brygady. Na przykład: select u.name, u.commander, (select sum(men) from unit_strength where id in (select id from unit_links_adjacency connect by parent_id = prior id start with parent_id = u.id) or id = u.id) men from units u
Nietrudno jest zdać sobie sprawę z faktu, że te same wiersze tabeli liczebności będą odczytywane wielokrotnie, ponieważ do tego samego miejsca będziemy dochodzić z różnych punktów wyjścia. Oczywiście przy dużych ilościach danych takie podejście będzie znacznie obniżało wydajność. Taki jest właśnie koszt proceduralnej natury operatora CONNECT BY, który nie pozwala wykorzystać klucza (zwracałem na to uwagę przy okazji problemu z usuwaniem duplikatów, które było niemożliwe bez zniszczenia uporządkowania hierarchii przy przeszukiwaniu wstępującym struktury). To powoduje, że przy problemach z wydajnością jedynym wyjściem pozostaje podejście proceduralne (implementacja pętli w programie) — „Kto walczy procedurą, od procedury ginie”. Nieco lepsza sytuacja występuje w przypadku modelu zmaterializowanej ścieżki. W tym przypadku musimy zdecydować się, aby użyć nieco „czarnej magii”, którą precyzyjnie omówię w rozdziale 11. Technika ta określana jest nazwą rozwijania powiązań (ang. exploding of links) i w naszym przypadku możemy rozwinąć powiązania zapisane w unit_links_path (choć ten widok może okazać się niezbyt przyjemny). W tym celu utworzyłem perspektywę exploded_links_path o następującej zawartości:
ODMIANY TAKTYKI
265
SQL> select * from exploded_links_path; ID ANCESTOR DEPTH ---------- ----------- --------14 1 1 13 1 2 12 1 2 11 1 1 10 1 2 9 1 2 8 1 1 7 1 2 6 1 2 5 1 1 4 1 2 3 1 2 2 1 1 4 2 1 3 2 1 7 5 1 6 5 1 10 8 1 9 8 1 13 11 1 12 11 1
Atrybut depth określa odstęp między pozycjami id a ancestor. Wyposażeni w tę perspektywę nie będziemy mieli żadnego problemu ze zsumowaniem wszystkich poziomów hierarchii (oprócz najniższego): select u.name, u.commander, suin(s.men) men from units u, exploded_links_path el, unit_strength s where u.id = el.ancestor and el.id = s.id group by u.name, u.commander
To zapytanie daje następujący wynik: NAME ------------------------III Corps 8th Infantry Division
COMMANDER MEN -------------------------------------- ----Général de Division Dominique Vandamme 16350 Général de Division Baron Etienne5059 Nicolas Lefol 10th Infantry Division Général de Division Baron Pierre 5584 Joseph Habert 11th Infantry Division Général de Division Baron Pierre 4538 Bérthezène 3rd Light Cavalry Division Général de Division Baron Jean-Simon 1017 Domont
266
ROZDZIAŁ SIÓDMY
Do tego wyniku możemy przyłączyć unię liczebności jednostek najniższego poziomu, które są znane bezpośrednio z tabeli unit_strength. Powyższe zapytanie wywołałem pięć tysięcy razy, po czym porównałem liczbę zwróconych wierszy w jednostce czasu. Jak można było oczekiwać, wynik pokazał, iż model sąsiedztwa, który dotychczas sprawował się dzielnie, tym razem poniósł sromotną klęskę, co demonstruje rysunek 7.4.
RYSUNEK 7.4. Porównanie wydajności zapytań wyznaczających liczebności hierarchii
Prostsze implementacje drzew czasem działają wydajnie w operacjach agregacji.
Propagacja obliczeń procentowych pomiędzy poziomami Mogłoby się wydawać, że w modelu zmaterializowanej ścieżki z niewielką pomocą perspektywy exploded_links_path (w rzeczywistości definiującej model sąsiedztwa) problem ten da się rozwiązać w sposób wydajny i elegancki. Niestety, to nieprawda i ten przykład obnaża ograniczenia implementacji SQL-a, w każdym razie w zakresie obsługi drzew. W tym przypadku posłużę się zupełnie innym przykładem. Załóżmy, że piszemy system informatyczny do obsługi firmy handlującej naparami, eliksirami, zaklęciami i wróżbami. Każdy z tych artykułów jest zbudowany z pewnych składników, a każda receptura jest zdefiniowana w postaci listy składników i ich procentowych proporcji. Gdzie tu jednak miejsce na
ODMIANY TAKTYKI
267
hierarchię? Niektóre z receptur stanowią definicje składników złożonych, wykorzystywanych przez inne, jak na rysunku 7.5.
RYSUNEK 7.5. Nie próbujcie tego w domu!
Nasze zadanie polega na tym, aby na opakowaniu Eliksiru #5 zapisać nazwy i proporcje podstawowych składników. W pierwszym kroku należy zdecydować, w jaki sposób opisać tego typu hierarchię. Wydaje się, że model zmaterializowanej ścieżki nie będzie tu odpowiedni. W przeciwieństwie do przypadku jednostek bojowych, które mają ustaloną pozycję w hierarchii armii, każdy składnik może wystąpić w dowolnych kombinacjach i nawet tak skomplikowane elementy, jak Napar #9 mogą wchodzić w skład wielu innych preparatów. Z powodu takiej konstrukcji ścieżka nie może być atrybutem składnika. Gdyby „spłaszczyć” strukturę i utworzyć nową tabelę zawierającą zmaterializowaną ścieżkę każdego podstawowego elementu preparatu, każda zmiana w definicji Naparu #9 musiałaby zostać uwzględniona we wszystkich preparatach, w których skład on wchodzi, co stanowiłoby niedopuszczalne ryzyko wystąpienia błędów. Najbardziej naturalny sposób reprezentacji tego typu struktury polega na tym, że dla Eliksiru #5 definiujemy proporcje składników: tyle i tyle rogu jednorożca, tyle i tyle złocienia, tyle i tyle Naparu #9 itd. Osobno zdefiniowana będzie receptura Naparu #9. Rysunek 7.6 demonstruje sposób skonstruowania naszej bazy danych. Mamy tabelę składników components z dwoma podtypami, recipes (receptury) i basic_ingredients (składniki podstawowe), oraz tabelę composition, w której zapisane są proporcje każdego składnika (receptury lub składnika podstawowego) pojawiającego się w recepturze.
268
ROZDZIAŁ SIÓDMY
RYSUNEK 7.6. Model bazy receptur
Projekt przedstawiony na rysunku 7.6 to dokładnie ten przypadek, w którym zastosowanie operatora CONNECT BY staje się bardzo niewygodne. Z powodu proceduralnej natury operatora CONNECT BY możemy uwzględnić tylko dwa poziomy hierarchii, co wystarczy dla przykładu z rysunku 7.5, ale może nie wystarczyć dla przypadku ogólnego. W przypadku operatora CONNECT BY mamy dostęp jednocześnie do dwóch poziomów hierarchii: bieżącego i przodka z możliwością wykluczenia podstawy hierarchii. Na przykład: SQL> 2 3 4 5 6 7 8
select connect_by_root recipe_id root_recipe, recipe_id, prior pct, pct component_id from composition connect by recipe id = prior component_id /
ROOT_RECIPE ----------14 14 14 14 14 14 15 15 15 15 15 ...
RECIPE_ID PRIORPCT PCT COMPONENT_ID --------- ---------- ---------- ----------14 5 3 14 20 7 14 15 8 14 30 9 14 20 10 14 10 2 15 30 14 14 30 5 3 14 30 20 7 14 30 15 8 14 30 30 9
ODMIANY TAKTYKI
269
W tym przykładzie root_recipe oznacza podstawę drzewa. Mamy jednoczesny dostęp do proporcji procentowej wiersza bieżącego i poprzedniego, w kolejności zgodnej z kolejnością przeglądania drzewa, nie mamy jednak sposobu podsumowania, a w tym konkretnym przypadku przemnożenia wartości od góry do dołu drzewa. Sytuacja, gdy proporcje procentowe mają propagować się przez wszystkie poziomy, stanowi idealne pole do popisu dla rekurencyjnej klauzuli WITH. Dlaczego? Jak pamiętamy, gdy usiłowaliśmy wygenerować listę podwładnych generała Vandamme’a, musieliśmy obliczyć poziom zagłębienia w drzewie, przenosząc tę wartość między poziomami. W tamtym przypadku ta metoda wydawała się dość toporna. Ale tym razem ta technika pozwoli nam zastosować ważny zabieg. Wielkim ograniczeniem klauzuli CONNECT BY jest fakt, że w każdym momencie zapytania znane są tylko dwie wartości: bieżący wiersz (potomek) i jego rodzic. Napar #9 zawiera 15% mandragory a Eliksir #5 zawiera 30% Naparu #9. Jeśli mielibyśmy tylko dwa poziomy, uzyskując jednoczesny dostęp do potomka (Napar #9) i rodzica (Eliksir #5), widzimy, że mamy odpowiednio 15% i 30%, czyli innymi słowy, w Eliksirze #5 mamy 4,5% mandragory. Co się jednak stanie, gdy będzie więcej poziomów zagłębienia? Musimy znaleźć sposób na obliczenie dokładnych proporcji każdego składnika w produkcie końcowym. Można tego dokonać proceduralnie w programie wykorzystującym bazę danych lub wywołując funkcje osadzone obliczające wyniki pośrednie. Czy jednak nie ma żadnego sposobu na napisanie rozwiązania w samym SQL-u? „Jakie proporcje składników zawiera formuła?” — to skomplikowane pytanie. Rekurencyjna klauzula WITH powoduje jednak, że znalezienie odpowiedzi jest bardzo proste. Zamiast obliczać bieżący poziom, stanowiący poziom rodzica plus jeden, musimy przemnożyć procent aktualnego składnika (ile mandragory znajduje się w Naparze #9) przez procent rodzica (ile Naparu #9 znajduje się w Eliksirze #5). Przy założeniu, że nazwy składników są zapisane w tabeli components, nasze zapytanie będzie następujące: with recursive_composition(actual_pct, component_id) as (select a.pct, a.component_id from composition a, components b
270
ROZDZIAŁ SIÓDMY
where b.component_id = a.recipe_id and b.component_name = 'Eliksir #5' union all select parent.pct * child.pct, child.component_id from recursive_composition parent, composition child where child.recipe_id = parent.component_id)
Załóżmy, że tabela components zawiera dodatkową kolumnę component_type o wartości I dla składnika podstawowego i R dla receptury. Wystarczy odfiltrować receptury i korzystając z tego, że składniki podstawowe mogą wystąpić na różnych poziomach hierarchii, zagregować wyniki po składniku: select x.component_name, sum(y.actual_pct) from recursive_composition y, components x where x.component_id = y.component_id and x. component_type = 'I' group by x.component_name
Jak się okazuje, mimo tego iż model sąsiedztwa wygląda jak naturalny sposób reprezentacji hierarchii, jego dwie implementacje nie są w żadnym razie rozwiązaniami analogicznymi, raczej można je określić jako wzajemnie uzupełniające się. Operator CONNECT BY wygląda na łatwiejszy w zastosowaniu (gdy zrozumie się, co oznacza prior) i wygodniejszy do wyświetlania elegancko sformatowanych hierarchii, jednak stosunkowo trudniejsza w użyciu rekurencyjna klauzula WITH pozwala w łatwy sposób konstruować znacznie bardziej skomplikowane zapytania, a jak wiadomo, w rzeczywistości częściej zdarza się trafiać na skomplikowane problemy. Wystarczy spojrzeć na opis składników na paczce płatków śniadaniowych lub na tubce pasty do zębów, aby od razu zauważyć analogię do ostatniego przykładu. W innych przypadkach, również w bazach danych, które implementują operator CONNECT BY, jedyna szansa na rozwiązanie tego problemu w „pojedynczej instrukcji” (bez stosowania procedury i pętli w programie użytkownika) polega na napisaniu procedury osadzonej, która dodatkowo musi być rekurencyjna, jeśli system bazy danych nie potrafi wykonać hierarchicznego przeszukiwania drzewa.
ODMIANY TAKTYKI
271
Bardziej skomplikowane składnie implementacji przeszukiwania drzew pozwalają z łatwością zdefiniować zapytania odpowiadające w czystym SQL-u na bardziej skomplikowane pytania.
Metody opisywane w tym rozdziale będą dawały zadowalające wyniki przy małej liczbie danych, ale w przypadku bardzo dużej liczby danych zapytanie stosujące tę samą technikę może działać bardzo wolno. Wówczas należy rozważyć denormalizację modelu oraz wykorzystanie wyzwalaczy do dynamicznego „spłaszczania” danych. Wielu specjalistów, włącznie ze mną, postrzega denormalizację jako coś niepożądanego. W tym przypadku jednak ustępuję, jednak nie dlatego, że jest tak często postrzegana jako lekarstwo na „wrodzoną powolność” działania modelu relacyjnego (co w praktyce oznacza kamuflowanie błędów popełnionych na etapie projektowania bazy danych), ale dlatego, że SQL nie posiada odpowiednich narzędzi do przetwarzania struktur drzewiastych.
272
ROZDZIAŁ SIÓDMY
ROZDZIAŁ ÓSMY
Strategiczna siła wojskowa Rozpoznawanie trudnych sytuacji i postępowanie w nich No one can guarantee success in war, but only deserve it. Nikt nie ma zagwarantowanego sukcesu na wojnie, niektórzy na niego zasługują. — Sir Winston Churchill (1874 – 1965)
274
Z
ROZDZIAŁ ÓSMY
darzają się sytuacje, gdy jesteśmy zmuszeni do walki w niekorzystnym układzie terenu albo do ataku wobec przytłaczających ilości danych przy wykorzystaniu nieodpowiedniego oręża. W tym rozdziale opiszę kilka z tego typu trudnych sytuacji. W pierwszej kolejności nakreślę pewne zasady taktyczne, pozwalające wybrnąć z sytuacji zagrożenia z honorem oraz, co ważniejsze, pozwalające w porę rozpoznać symptomy zmierzania w pułapkę. Tego typu obserwacje mają zastosowanie również w przypadku skomplikowanych architektur. Niestety, nazbyt często zdarza się, że nowoczesne, ekscytujące techniki, albo wręcz przeciwnie, stare, ograne rozwiązania, odwracają naszą uwagę od podstawowej zasady: należy cenić prostotę. Prostsze często oznacza również szybsze, zawsze zaś oznacza: bardziej trwałe. Jednak w przypadku baz danych prostsze nie zawsze oznacza „prostsze dla programisty”, a prostsze rozwiązania często wymagają znacznie większego doświadczenia niż rozwiązania skomplikowane. W tym rozdziale w pierwszej kolejności weźmiemy pod uwagę przypadek, w którym kryteria wyglądające na wydajne okazują się słabe, lecz mogą zostać wzmocnione za pomocą dodatkowych zabiegów. Następnie rozważymy zagrożenia wynikające z różnych abstrakcyjnych warstw zapewniających trwałość (ang. persistency) na potrzeby systemów rozproszonych. Dość szczegółowo przeanalizujemy również przykład systemu opartego na technikach PHP/MySQL, w którym skupię się na zademonstrowaniu połączenia elastyczności z wydajnością w sytuacji, gdy użytkownik programu ma dużą swobodę doboru kryteriów wyszukiwania.
Zwodnicze kryteria W rozdziale 6. wspominałem o zapytaniach, w których kryteria są w sumie bardzo selektywne, ale każde z nich osobno selektywne już nie jest. Zauważyłem wówczas, że w takiej sytuacji niełatwo uzyskać wysoką wydajność. Innym interesującym przypadkiem, w którym nie jesteśmy już zupełnie pozbawieni nadziei, jest kryterium na pierwszy rzut oka wyglądające na wydajne, które ma potencjał stania się wydajnym, lecz wymagające pewnych zabiegów, jakie pomogą mu wykorzystać ten potencjał. Doskonały przykład takiego kryterium stanowią procedury weryfikacji kart kredytowych. Jak wiadomo, numer karty kredytowej składa się z zakodowanych kilku typów informacji, takich jak między innymi typ karty kredytowej,
STRATEGICZNA SIŁA WOJSKOWA
275
wystawca itp. Jako przykład niech posłuży problem pierwszego poziomu kontroli prawidłowości numerów kart kredytowych na płatnej autostradzie w jednym z najczęściej odwiedzanych państw Europy Zachodniej. Ta sytuacja wiąże się ze sprawdzaniem dużej liczby kart kredytowych wydawanych przez różnych wystawców międzynarodowych, z których każdy wykorzystuje własną, unikalną metodę kodowania. Numery kart kredytowych składają się maksymalnie z dziewiętnastu cyfr, z pewnymi wyjątkami, między innymi numery kart MasterCard składają się z szesnastu cyfr, Visa z szesnastu lub trzynastu, a American Express z piętnastu. Pierwsze sześć cyfr we wszystkich przypadkach określa wystawcę, ostatni znak stanowi sumę kontrolną pozwalającą z łatwością wykryć błąd przy wprowadzaniu numeru. Pierwszy, zgrubny poziom kontroli mógłby polegać na sprawdzeniu, czy znany jest wystawca i czy suma kontrolna jest prawidłowa. Jednak algorytm obliczania sumy kontrolnej jest powszechnie znany (można go znaleźć w internecie) i można go użyć do wygenerowania fałszywego numeru karty z prawidłową sumą kontrolną. Bardziej finezyjny poziom kontroli można uzyskać, sprawdzając, czy pierwsze znaki numeru karty odpowiadają zakresowi numerów specyficznemu dla danego wystawcy w połączeniu z kontrolą poprawności cyfr w numerze. Załóżmy, że mamy do dyspozycji około dwustu tysięcy prawidłowych prefiksów numerów o różnych długościach. W jaki sposób napisać zapytanie sprawdzające poprawność zakresu cyfr w numerze w zależności od wystawcy karty? Od razu narzuca się następujący fragment kodu: select count(*) from credit_card_check where ? like prefix + '%'
Fragment where ? określa warunek biorący numer karty, znak + określa operację łączenia ciągów znaków (konkatenacji), często również reprezentowaną przez mnemonik || lub funkcję concat(). Wystarczy indeks po kolumnie prefix, ale i tak każda weryfikacja numeru będzie wiązała się z wykonaniem pełnego przeszukiwania tabeli. Dlaczego odbywa się pełne przeszukiwanie? Przecież indeks jest użyteczny również w przypadku częściowego dopasowania z jego lewej strony. To prawda, ale stwierdzenie, że wyszukiwana wartość stanowi lewostronny fragment klucza to nie to samo, co stwierdzenie, że pełny klucz jest lewostronnym
276
ROZDZIAŁ ÓSMY
fragmentem weryfikowanej wartości. Różnica wydaje się subtelna, ale w rzeczywistości te dwie sytuacje stanowią lustrzane odbicie problemu. Załóżmy, ze weryfikowanym numerem karty kredytowej jest 4000 0012 3456 78991. Załóżmy też, że w naszej tabeli credit_card_checks znajdują się takie wartości, jak 312345, 3456 i 40001. Możemy użyć tych wartości w charakterze prefiksów, możemy również przyjąć, że widzimy je w kolejności posortowanej. Po pierwsze, będą występowały w kolejności rosnącej, jeśli są zapisane w postaci ciągów znaków, ale nie wówczas, gdy są zapisane jako liczby. Jednak to jeszcze nie koniec niezbędnych założeń. Przy przeszukiwaniu drzewa (naszego indeksu) mamy pewną wartość, którą musimy porównać z wartościami zapisanymi w tym drzewie. Jeśli ta wartość jest równa kluczowi zapisanemu w bieżącym węźle, wyszukiwanie kończy się. W przeciwnym razie musimy przeszukać poddrzewo, którego wybór jest uzależniony od tego, czy nasza wartość jest mniejsza, czy też większa od wartości zapisanej w węźle. Gdybyśmy mieli prefiksy o stałej długości, nie byłoby problemu: wystarczyłoby ze sprawdzanego numeru karty odciąć odpowiednią liczbę znaków (odpowiedni prefiks) i porównać je z prefiksami zapisanymi w indeksie. Jeśli jednak długość prefiksu jest zmienna, co ma miejsce w naszym przypadku, musimy za każdym razem porównywać inną liczbę znaków. To nie jest zadanie odpowiednie dla typowego wyszukiwania z użyciem indeksu w SQL-u. Czy istnieje jakieś wyjście z sytuacji? Na szczęście jest jedno. Operator LIKE wybiera zakres wartości. Jeśli chcemy sprawdzić na przykład 16-znakowy numer karty Visa, wyszukiwany ciąg znaków ma stały przedrostek 4000%, co oznacza, że wynik znajdzie się w przedziale od 4000000000000000 do 400099999999999. Jeśli będziemy mieli indeks złożony po dolnym i górnym zakresie wyszukiwania, wyszukiwanie zakresowe mogłoby wykorzystywać ten indeks — pod warunkiem że wszystkie numery kart składałyby się z szesnastu znaków. Jednak również zmienna liczba znaków to problem, który łatwo rozwiązać. Wszystkie numery kart mają ograniczenie długości do dziewiętnastu znaków. Jeśli numery kart Visa uzupełnimy zerami z prawej strony, sprowadzając je do „standardowych” 19-znakowych, możemy zweryfikować, czy poszukiwany numer 4000001234567899000 mieści się w zakresie od 4000000000000000000 do 400099999999999999. 1
Gdyby ktoś się zastanawiał, ten numer jest nieprawidłowy…
STRATEGICZNA SIŁA WOJSKOWA
277
Zamiast zapisywać prefiksy, będziemy zapisywać dwie wartości: lower_bound, czyli ograniczenie dolne, i upper_bound, czyli ograniczenie górne. Pierwszą z tych wartości uzyskamy, wypełniając znaki prefiksu zerami do długości dziewiętnastu znaków, drugą natomiast, uzupełniając prefiks dziewiątkami. Przyznaję, że to jest pewna forma denormalizacji. Jednak w przypadku tabeli referencyjnej używanej wyłącznie do odczytu, można uznać, że to dopuszczalny kompromis. Teraz wystarczy poindeksować tabelę po kolumnach (lower_bound, upper_bound) i zapisać warunek w następujący sposób, żeby sprawdzić, jak spisze się nasze zapytanie: where substring(? + '0000000000000000000', 1, 19) between lower_bound and upper_bound
Wiele produktów baz danych obsługuje funkcję rpad() uzupełniającą ciągi do określonej długości określonymi znakami. Gdy musimy sprawdzać przedrostki zmiennej długości, rozwiązanie problemu polega na zastosowaniu sprawdzonej techniki wyszukiwania: przeszukiwania zakresowego z użyciem indeksu. Niecodzienne warunki, jak porównanie przedrostka czy części klucza indeksu, warto spróbować zaimplementować w formie przeszukiwania zakresowego. O ile to możliwe, warto opisać problem w postaci ograniczenia dolnego i górnego wyznaczonego w oparciu o weryfikowany numer.
Warstwy abstrakcji Powszechną praktyką jest tworzenie warstw abstrakcji w oparciu o zestaw prymitywów programowych, pozornie w celu zmniejszenia kosztów utrzymania i umożliwienia ponownego użycia komponentów programistycznych. Taka praktyka jest opłacalna i dostarcza mnóstwo pasjonujących przykładów na potrzeby prezentacji na posiedzeniach zarządu. Niestety, takie podejście (określane mianem hermetyzacji) może z łatwością prowadzić do przesady, szczególnie w przypadkach, gdy prymitywy programowe obsługują operacje dostępu do baz danych. Oczywiście przemysłowy aspekt inżynierii oprogramowania jest z reguły związany z nowoczesnymi językami zorientowanymi obiektowo.
278
ROZDZIAŁ ÓSMY
Jako ilustrację błędnego sposobu hermetyzacji dostępów do bazy danych wykorzystam fragment rzeczywistego programu. Jak na ironię (w książce o sztuce używania SQL-a) poniższy kod w języku C# (którego „ostrość” jest dość kontrowersyjna) zawiera jedynie szczątki kodu w SQL-u. Jest on jednak niezwykle cenny przy prezentacji zjawisk, które chcę omówić: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
public string Info_ReturnValueUS(DataTable dt, string codeForm, string infoTxt) { string returnValue = String.Empty ; try { infoTxt = infoTxt. Replace("'",'"'"); string expression = ComparisonDataSet.FRM_CD +"='"+ codeForm + '" and "+ ComparisonDataSet.TXT_US +"='"+ infoTxt + "'" ; DataRow[] drsAttr = dt.Select(expression); foreach (DataRow dr in drsAttr) { if (dr[ComparisonDataSet.VALUE US].ToString().ToUpper().Trim() != String.Empty) { returnValue = dr[ComparisonDataSet.VALUE_US].ToString() ; break; } } } catch (MyException myex) { throw myex ; } catch (Exception ex) { throw new MyException("Info_ReturnValueUS " + ex.Message) ; } return returnValue ; }
Nie trzeba być ekspertem od języka C#, aby zrozumieć cel powyższej funkcji, przynajmniej w zarysie. Chodzi o odczytanie tekstu odpowiadającego podanemu jako argument kodowi komunikatu. Tekst ma być zwrócony w podanym języku (w tym przypadku w języku angielskim, narzeczu amerykańskim, jak sugeruje kod US w nazwie funkcji). Kod jest podany
STRATEGICZNA SIŁA WOJSKOWA
279
w międzynarodowym systemie. W aplikacji występują też inne funkcje o identycznym działaniu, w których litery US są zastąpione odpowiednimi innymi kodami. Gdy do systemu będą dodane nowe języki, ten fragment kodu zostanie skopiowany i zmodyfikowany w nieznacznym stopniu — kod US zostanie zastąpiony kodem odpowiednim dla dodanego języka. Łatwo wyobrazić sobie, jaki będzie koszt dowolnej modyfikacji procedury, gdy ten (a być może wiele innych) fragment programu będzie zduplikowany dziesiątki razy (z wyjątkiem kodu ISO języka). Przeanalizujmy ten kod nieco dogłębniej. Wyrażenie z wierszy 9 – 12 jest przykładem nieeleganckiej techniki tworzenia zapytań; te następnie, w wierszu 13., są przekazywane do funkcji Select(), która, jak się można domyślić, wywołuje zapytanie. Jak się wydaje, w kodzie programu na sztywno zapisane są dwa typy elementów: nazwy kolumn (zapisane w wartościach atrybutów ComparisonDataSet.FRM_CD i ComparisonDataSet.TXT_US; jak się można domyślić, każdy język posiada osobną kolumnę, co jest kolejnym przykładem słabości tego projektu) oraz wartości przekazywane do zapytania (codeForm i infoTxt). Nie ma rozsądnej możliwości uniknięcia zakodowania na sztywno nazw kolumn, ale ponieważ liczba kombinacji nazw kolumn przy tym konkretnym zadaniu jest ograniczona, nie ma się czym przejmować. Jednak o samych wartościach trudno powiedzieć to samo: w zapytaniu może wystąpić konieczność podania dowolnych parametrów, co najmniej tylu, ile istnieje wierszy w tabeli, a nawet więcej; ponieważ zapytanie wykona się dla dowolnego warunku, w niektórych przypadkach po prostu może zwrócić pusty zbiór wynikowy. Decyzja o zakodowaniu wartości parametrów codeForm i infoTxt w kodzie SQL jest poważnym błędem, ponieważ zapytania typu „podaj mi tekst związany z tym kodem” są z reguły wykonywane bardzo często. W takiej postaci, jak w powyższym przykładzie, każde wywołanie zapytania zainicjuje pełny proces analizy leksykalnej określania najlepszego planu wykonawczego itd. bez żadnej dodatkowej korzyści. Wartości powinny być przekazywane do zapytania jako zmienne związane, podobnie jak parametry wywołania funkcji. Pętla z wierszy 15 – 23 jest nie mniej interesująca. Program poszukuje listy niepustych wartości w zwróconym właśnie zbiorze wynikowym — można założyć, że po prostu pierwszej wartości innej niż NULL. Po co kodować w aplikacji zewnętrznej logikę, którą SQL potrafi obsłużyć nie gorzej? Po co przesyłać z serwera bazy danych nadmierną liczbę wierszy tylko po to,
280
ROZDZIAŁ ÓSMY
by za chwilę odrzucić niepotrzebne z nich? To zbędna praca. Serwer bazy danych musi wykonać jej więcej, ponieważ nawet w przypadku, gdy aplikacja zatrzyma przetwarzanie przy pierwszym trafieniu, często przesyłane są większe partie wierszy, aby zoptymalizować ruch sieciowy. Serwer aplikacji mógł z powodzeniem zwrócić setki lub tysiące wierszy, zanim aplikacja rozpocznie przetwarzanie pętli. Serwer aplikacji ma również więcej pracy, ponieważ musi filtrować dane przesłane przez serwer bazy danych. Oczywiście nie trzeba dodawać, że programista napisał więcej kodu, niż było konieczne. Dodanie odpowiedniego warunku w kodzie SQL jest bardzo proste, dzięki czemu w sieci nie będą przesyłane zbędne wiersze. W związku z tym, że logika aplikacji jest zapisana w kodzie C#, serwer bazy danych nie wie, że nas interesuje wyłącznie pierwszy wiersz, w którym wartość nie jest NULL — nie dziwne więc, że działa zgodnie z instrukcją. Gdyby po stronie serwera poszukiwać wskazówki niepoprawnie napisanego kodu aplikacji, jedyną byłaby ogromna liczba prawie identycznych zapytań. Ta anomalia to jednak jedynie wierzchołek góry lodowej. Słabej jakości kod można napisać w zupełnie dowolnym języku programowania, od stareńkiego COBOL-a po najnowocześniejszy obiektowy język programowania, ale im większy jest zakres swobody między poszczególnymi warstwami oprogramowania, tym lepiej te warstwy powinny być napisane. Problem polega na tym, że występuje tu zjawisko dziedziczenia. Nieważne jak doświadczony i skuteczny jest programista, jego kod będzie działał z taką skutecznością, jak skuteczne jest najsłabsze ogniwo. Problem z warstwami abstrakcji słabej jakości jest szczególnie wyraźny, gdy w programie dziedziczy się słabe biblioteki. A gdy geny są złe, niewiele można poradzić. Przepisywanie na nowo słabej jakości niskopoziomowej warstwy jest praktyką, na którą rzadko można sobie pozwolić przy krótkich terminach i niskich budżetach. Kiedyś usłyszałem o przypadku, w którym podstawowy operator w języku programowania został przesłonięty (przedefiniowany) w taki sposób, że przy każdym użyciu (przez niczego niepodejrzewającego programistę) było nawiązywane połączenie z bazą danych! Naprawienie sytuacji tego typu jest tym bardziej skomplikowane, że z punktu widzenia bazy danych wywoływane są wówczas proste i krótkie zapytania, które wyglądają jak miliony innych prostych i krótkich
STRATEGICZNA SIŁA WOJSKOWA
281
zapytań, niczym się nienarzucające, jak choćby źle napisane zapytania SQL przeglądające miliony wierszy tabel i od razu zwracające na siebie uwagę administratorów. Biblioteki dostępu do baz danych oferujące ciekawe i nowoczesne mechanizmy nie zawsze bywają wydajne.
Systemy rozproszone Gdy mówi się o serwerze związanym, serwerze połączonym czy powiązaniu serwerów, chodzi z reguły o tę samą zasadę: rozproszone przetwarzanie zapytań. Zapytania w takich konfiguracjach nie są z reguły przetwarzane na serwerze (lub dla użytkowników Oracle: bazie danych), do którego jest przyłączony użytkownik. Rozproszone zapytania są wywoływane z użyciem skomplikowanego mechanizmu, szczególnie skomplikowanego przy operacjach modyfikujących, zapewniającego integralność transakcji. Tego typu poziom komplikacji wiąże się jednak z dość poważnym kosztem, z którego większość użytkowników nie zdaje sobie sprawy. Posłużę się przykładem. Wywołałem szereg zapytań na bazie danych Oracle, wykonujących duże operacje wstawiania i wydobywania danych na bardzo prostej tabeli lokalnej, a następnie tworzących powiązania z bazami danych i wykonujących te same operacje w każdym z powiązań. Wykorzystałem trzy różne powiązania z bazą danych: Inter-process Jest to powiązanie wykorzystujące mechanizmy komunikacji między procesami (ang. inter-process communications, w skrócie IPC). Tego typu powiązanie jest wykorzystywane do odczytania danych zlokalizowanych w innej bazie danych2, ale w tym samym systemie (hoście). Nie jest tu wykorzystywana sieć. Loop-back Powiązanie wykorzystujące stos TCP, ale działające na lokalnym interfejsie wirtualnym (o adresie 127.0.0.1), dzięki czemu omija mechanizmy związane z kontrolą dostępu sieciowego itp. 2
Pamiętajmy, że to, co w nomenklaturze Oracle określa się mianem bazy danych (database), w innych produktach nazywa się serwerem.
282
ROZDZIAŁ ÓSMY
Adres IP Powiązanie wykorzystujące konkretny adres IP systemu. W tym przypadku również nie wykorzystywałem sieci, zatem w pomiarach nie występuje przekłamanie wynikające z opóźnień w sieci. Wynik moich testów jest przedstawiony na rysunku 8.1. Jak się okazało, nie ma większych różnic między metodą wykorzystującą IPC oraz TCP z użyciem mechanizmu loop-back czy klasycznego interfejsu. Jednak poważne obniżenie wydajności wynika z samego użycia powiązania z bazą danych. W przypadku operacji wstawiania wierszy wykorzystanie powiązania powoduje pięciokrotne obniżenie wydajności operacji, zaś przy zapytaniach wydobywających dane wydajność obniża się o współczynnik 2,5 (w każdym przypadku operacje były wykonywane w trybie wiersz-po-wierszu).
RYSUNEK 8.1. Koszt stosowania zdalnego powiązania z bazą
Gdy transakcje muszą być wykonywane w środowisku heterogenicznym, nie ma innej możliwości niż użycie powiązań z bazami danych lub ich odpowiedników. Jeśli zależy nam na integralności danych, za wszelką cenę muszą być zastosowane mechanizmy ją zapewniające. Istnieją jednak przypadki, że w ramach architektury systemu przewidziane jest zastosowanie serwera dedykowanego, na przykład do obsługi danych wzorcowych. W takim przypadku koszt czasowy może być akceptowalny. Całkiem możliwe, że jeśli w momencie nawiązania połączenia z bazą lokalną dane uwierzytelniające użytkownika są weryfikowane w systemie zdalnym,
STRATEGICZNA SIŁA WOJSKOWA
283
nikt tego nie zauważy, pod warunkiem że ten zdalny system działa. Jeśli jednak do bazy lokalnej ładowane są duże ilości danych i muszą być przeprowadzone działania weryfikujące ich poprawność z użyciem powiązania ze zdalnym serwerem, wydajność operacji spadnie znacząco. Weryfikowanie poprawności danych wiersz po wierszu to niezalecana technika (w prawidłowo zaprojektowanej bazie danych wszelkie działania związane z weryfikacją poprawności danych powinny odbywać się z użyciem ograniczeń integralności). Weryfikacja danych z użyciem zdalnej bazy danych może spowodować dwukrotne, a często i większe spowolnienie operacji niż jej wykonanie wyłącznie na serwerze lokalnym. Zapytania rozproszone wykorzystujące dane z kilku różnych serwerów również bywają dość kosztowne. Przede wszystkim przy wysłaniu zapytania do jądra systemu obsługi baz danych kluczową rolę odgrywa optymalizator. Decyduje on, w jaki sposób podzielić zapytanie na składowe wysyłane do różnych podsystemów, i koordynuje ich działania, a na końcu składa w jedną całość wszystkie elementy pośrednie. Znalezienie optymalnej ścieżki wykonawczej to wystarczająco skomplikowane zadanie już w przypadku serwera lokalnego. Należy zauważyć, że pojęcie „rozproszenia” jest bardziej logiczne niż fizyczne: spora strata wydajności wynika z niedostępności danych statystycznych optymalizatora w zdalnym systemie. Koszt rozproszenia będzie znacznie wyższy w przypadku dwóch niezależnych baz danych działających na tej samej maszynie niż w przypadku jednej bazy danych zduplikowanej na dwa systemy, wykorzystującej wspólne słowniki i dane statystyczne optymalizatora. Istnieje sporo cech wspólnych między zapytaniami rozproszonymi a wykonywanymi w trybie równoległym (to znaczy wówczas, gdy zapytanie jest rozbite na niezależne elementy wykonywane równolegle), z tym, że w przypadku zapytań rozproszonych dochodzą jeszcze komplikacje wynikające z obsługi sieci, ponieważ obsługa warstw sieciowych znacząco spowalnia niektóre operacje, a także z braku dostępności w jednym miejscu wszystkich informacji niezbędnych optymalizatorowi do podjęcia prawidłowych decyzji. Sytuacja rozproszenia zapytania ma jeszcze jedną, znaczącą cechę: gdy źródła danych są heterogeniczne, na przykład w przypadku, gdy zapytanie wiąże się z pobieraniem danych z bazy Oracle oraz na przykład z bazy MS SQL Server, nie będą dostępne wszystkie informacje potrzebne do pracy optymalizatorowi. Oczywiście każdy z systemów
284
ROZDZIAŁ ÓSMY
zarządzania bazami danych gromadzi statystyki, które służą do optymalizacji zapytań. Jednak z różnych przyczyn mechanizmy optymalizatora nie działają w sposób kooperatywny. Po pierwsze, dokładny mechanizm działania optymalizatora to jedna z najściślej chronionych tajemnic producentów systemów zarządzania bazami danych. Po drugie, każdy optymalizator różni się w swoim działaniu z wersji na wersję. W końcu optymalizator Oracle nie będzie w stanie wydajnie wykorzystać specyficznych rozwiązań bazy MS SQL Server i vice versa. W najlepszym razie wydajnie wykorzystany może być tylko wspólny podzbiór cech każdego z produktów biorących udział w zapytaniu. Nawet w przypadku homogenicznych źródeł danych ilość możliwości jest znacznie ograniczona. Jak mieliśmy okazję się przekonać, pobieranie pojedynczego wiersza w sieci kosztuje znacznie więcej niż w przypadku procesu wykonywanego lokalnie. Logiczny problem z serwera polega nie tylko na tym, że musi uwzględnić ścieżkę wykonawczą wymagającą przesyłania danych między serwerami, ale przede wszystkim na tym, żeby operacje filtrujące były wykonywane tam, gdzie znajdują się filtrowane dane. Po odfiltrowaniu danych silnik SQL-a powinien przesłać lub pobrać dane w celu dalszego przetwarzania. Jak mieliśmy okazję zaobserwować w rozdziałach 4. i 6., skorelowane podzapytania stanowią bardzo zły sposób na sprawdzenie występowania danych, gdy nie ma możliwości zastosowania innego kryterium sprawdzającego. Przykładem może być następujące zapytanie: select customer_name from customers where exists (select null from orders, orderdetails where orders.customer_id = customers.customer_id and orderdetails.order_id = orders.order_id and orderdetails.article_id = 'ANVIL023')
Każdy przeszukiwany wiersz tabeli customers uruchamia podzapytanie na tabelach orders i orderdetails. Oczywiście sytuacja będzie jeszcze gorsza, gdy tabela customers znajdzie się na innej maszynie niż tabele orders i orderdetails. W takim przypadku najlepiej będzie przekształcić (w idealnej sytuacji automatycznie przez optymalizator) podzapytanie skorelowane w nieskorelowane:
STRATEGICZNA SIŁA WOJSKOWA
285
select customer_name from customers where customer_id in (select orders.customer_id from orders, orderdetails where orderdetails.article_id = 'ANVIL023' and orderdetails.order_id = orders.order_id)
Co więcej, podzapytanie powinno być wykonywane w zdalnym systemie. Ta sama uwaga dotyczy również następującego zapytania: select distinct customer_name from customers orders orderdetails where orders.customer_id = customers.customer_id and orderdetails.article_id = 'ANVIL023' and orders.order_id = orderdetails.order_id
Jaka jest jednak gwarancja, że optymalizator podejmie właściwą decyzję? To pytanie, na które niełatwo odpowiedzieć, lepiej też nie podejmować ryzyka. Jednak z oczywistych powodów wprowadzenie zdalnych źródeł danych również ogranicza programiście możliwości opracowania najbardziej optymalnego zapytania. Należy pamiętać o tym, że podzapytanie musi być wykonane w całości i muszą być przesłane wszystkie dane, zanim może zacząć działać zapytanie zewnętrzne. Czasy wykonania będą się sumowały, ponieważ w takim przypadku nie ma możliwości jednoczesnego wykonania tych operacji. Najbezpieczniejszy sposób zapewnienia, że złączenie dwóch zdalnych tabel rzeczywiście jest realizowane na zdalnym serwerze, polega na stworzeniu na tym zdalnym serwerze perspektywy zdefiniowanej w oparciu o to złączenie i na jej wykorzystaniu w zapytaniach. Na przykład w poprzednim przypadku dobrze byłoby zdefiniować perspektywę o nazwie vorders w oparciu o następujące zapytanie: select orders.customer_id, orderdetails.article_id from orders, orderdetails where orderdetails.order_id = orders.order_id
Wykorzystując perspektywę vorders zamiast podzapytania, redukujemy zagrożenie polegające na odczytaniu zawartości tabel przez lokalny serwer baz danych i wykonaniu złączenia lokalnie. Nie muszę chyba nadmieniać, że, gdyby tabele customers i orderdetails były zapisane na jednym serwerze, a tabela orders na innym, sytuacja rzeczywiście byłaby nie do pozazdroszczenia.
286
ROZDZIAŁ ÓSMY
Optymalizator działa dobrze tam, gdzie dobrze zna środowisko: na lokalnych danych. Intensywne wykorzystanie danych zdalnych zawsze będzie miało negatywny wpływ na wydajność.
Dynamicznie definiowane kryteria wyszukiwania Jedną z najczęściej spotykanych przyczyn widocznego spadku wydajności (w odróżnieniu od niewidocznego spadku wydajności w przypadku zapytań wsadowych, który można ukryć przed użytkownikami) jest zastosowanie dynamicznych kryteriów wyszukiwania. W praktyce kryteria tego typu wynikają z lekkomyślnego zastosowania wymogu: „ Dajmy możliwość wpisania kryteriów wyszukiwania oraz możliwość określenia porządku sortowania w ramach interfejsu użytkownika”. W tego typu aplikacji większość zapytań będzie działać zadowalająco, ale od czasu do czasu pojawi się zapytanie pozornie wykorzystujące prawie identyczne z innymi kryteria wyszukiwania, które jednak będzie wykonywało się bardzo powoli. Oczywiście problem tego typu bardzo trudno jest usunąć, ponieważ wszystko działa w dynamiczny sposób. Dynamiczne aplikacje wyszukujące dane są najczęściej implementowane w postaci dwuetapowego zapytania drążącego (ang. drill-down, zobacz rysunek 8.2). Na pierwszym ekranie wyświetlane są kryteria do wyboru oraz możliwe do zastosowania warunki, jak wykluczenie czy data w przedziale. Te kryteria są wykorzystywane do dynamicznego budowania zapytania zwracającego listę składającą się z identyfikatora i opisu, w której można wybrać jedną pozycję i pogłębić informacje na jej temat.
RYSUNEK 8.2. Typowe wyszukiwanie z zastosowaniem wielu kryteriów
STRATEGICZNA SIŁA WOJSKOWA
287
Gdy przeszukiwane są te same kolumny z tych samych tabel z użyciem różnych kryteriów, klucz do sukcesu z reguły leży w inteligentnym, dynamicznym konstruowaniu zapytania SQL. Swoje porady zilustruję prostym przykładem wykorzystującym bazę filmów, skupimy się jedynie na zwracaniu listy filmów spełniających określone kryteria. Środowisko użyte w tym przykładzie to bardzo popularne połączenie języka PHP z bazą danych MySQL. Oczywiście prezentowane techniki nie są w żadnym stopniu ograniczone do PHP i MySQL-a ani oczywiście do bazy filmów.
Projekt prostej bazy filmów i zapytanie główne Nasza podstawowa tabela będzie miała następującą definicję: Table MOVIES movie_id movie_title movie_country movie_year movie_category movie_summary
int(l0) (auto-increment) varchar(so) char(2) year(4) int(10) varchar(250)
Potrzebujemy również tabeli category (do której odwołuje się klucz obcy movie_category) do przechowywania gatunków filmowych, jak sensacja, dramat, komedia, musical itp. Można oczywiście upierać się, że niektóre filmy należy zaliczyć do kilku kategorii, ale lepszy projekt będzie wymagał zastosowania dodatkowej tabeli implementującej związek „wiele do wielu” (co oznacza, że każdy gatunek filmowy może dotyczyć wielu filmów i każdy film może mieć zdefiniowanych wiele gatunków filmowych); jednak dla uproszczenia możemy umówić się na zastosowanie w naszym przykładzie pojedynczego określenia kategorii. Czy potrzebujemy osobnej tabeli dla aktorów i osobnej dla reżyserów? Użycie dwóch tabel byłoby błędem, ponieważ często spotyka się aktorów, którzy zdecydowali się spróbować sił jako reżyserzy, dlatego nie ma sensu duplikować danych z nazwiskami. Często zdarza się nawet napotkać film reżyserowany przez jednego z występujących w nim aktorów. Potrzebujemy zatem trzech dodatkowych tabel: people, w której będą zapisane dane osób, jak imię, nazwisko, płeć, data urodzenia itp., roles, zawierającą funkcje, jakie osoba może spełniać w filmie (aktor, reżyser,
288
ROZDZIAŁ ÓSMY
kompozytor, kierownik zdjęć itp.) oraz movie_credits, zawierającą informacje o tym, co każda osoba robiła w danym filmie. Rysunek 8.3 przedstawia pełny schemat bazy filmów.
RYSUNEK 8.3. Schemat bazy filmów
Załóżmy, że chcemy umożliwić użytkownikom wyszukiwanie w naszej bazie danych z użyciem następujących kryteriów: słów występujących w tytule, nazwiska reżysera oraz do trzech nazwisk aktorów. Poniżej przedstawiam źródło prototypu strony głównej, zbudowanej w HTML-u i służącej jako interfejs użytkownika naszej aplikacji: Baza filmów
Prosimy wypełnić formularz wyszukiwania, po czym kliknąć przycisk Szukaj...
STRATEGICZNA SIŁA WOJSKOWA
289
Ta strona prototypowa jest wyświetlana na ekranie w sposób przedstawiony na rysunku 8.4. Kilka uwag: • Mimo tego że imię i nazwisko chcemy zapisywać w bazie danych osobno (zdecydowanie wygodniejsze rozwiązanie, gdy chce się sortować pozycje po nazwisku), nie chcemy, żeby nasz formularz wyglądał jak formularz paszportowy: na imię i nazwisko przeznaczone jest pojedyncze pole tekstowe. • Wartości wprowadzane do wyszukiwania będą analizowane bez uwzględniania wielkości liter.
290
ROZDZIAŁ ÓSMY
RYSUNEK 8.4. Ekran wyszukiwania bazy filmów
Z pewnością nie warto tworzyć zapytania wykorzystującego następujące kryterium wyszukiwania: and upper() = concat(upper(people_firstname), ' ', upper(people_name))
Jak wspominałem w rozdziale 3., tak skonstruowane kryterium jest idealnym sposobem zapobiegającym wykorzystaniu indeksu na kolumnie nazwiska. Niektóre produkty pozwalają na zastosowanie indeksów funkcyjnych (czyli na indeksowanie wartości przetworzonych przez funkcje), ale najprostsze, a co za tym idzie zapewne najlepsze rozwiązanie jest następujące: 1. Wszystkie dane, które będą poddawane przeszukiwaniu są zapisywane wielkimi literami (zawsze możemy napisać funkcję „upiększającą” wyniki zapytań). 2. Wartość wprowadzona w polu formularza jest dzielona na imię i nazwisko, które następnie są przekazywane do zapytania.
STRATEGICZNA SIŁA WOJSKOWA
291
Pierwszy punkt oznacza po prostu, że do tabeli zamiast ciag_znakow wprowadzane są wartości upper(ciag_znakow). Do drugiego punktu wrócę za chwilę, na razie przyjmijmy go na wiarę. Jeśli użytkownik miałby zawsze wypełniać wszystkie pola, zapytanie mogłoby mieć następującą postać. select movie_title, movie_year from movies inner join movie_credits mc1 on mc1.movie_id = movies.movie_id inner join people actor1 on mc1.people_id = actor1.people_id inner join roles actor_role on mc1.role_id = actor_role.role_id and mc2.role_id = actor_role.role_id and mc3.role_id = actor_role.role_id inner join movie_credits mc2 on mc2.movie_id = movies.movie_id inner join people actor2 on mc2.people_id = actor2.people_id inner join movie_credits mc3 on mc3.movie_id = movies.movie_id inner join people actor3 on mc3.people_id = actor3.people_id inner join movie_credits mc4 on mc4.movie_id = movies.movie_id inner join people director on mc4.people_id = directer.people_id inner join roles director_role on mc4.role_id = director_role.role_id where actor_role.role_name = 'ACTOR' and director_role.role_name = 'DIRECTOR' and movies.movie_title like 'CHARULATA%' and actor1.people_firstname = 'SOUMITRA' and actor1.people_name = 'CHATTERJEE' and actor2.people_firstname = 'MADHABI' and actor2.people_name = 'MUKHERJEE' and actor3.people_firstname = 'SAILEN' and actor3.people_name = 'MUKHERJEE' and director.people_name = 'RAY' and director.people_firstname = 'SATYAJIT'
Jednak czy ktoś, kto potrafi podać tytuł, nazwiska reżysera i trzech głównych aktorów (czyli maniak kina), rzeczywiście potrzebuje naszej bazy? To bardzo wątpliwe. Najbardziej prawdopodobne jest natomiast to, że będzie znał wartość jednego pola, być może dwóch. Z tego powodu
292
ROZDZIAŁ ÓSMY
należy spodziewać się, że większość pól w formularzu będzie pusta, co każe zadać następujące pytanie: co zrobić, gdy nie zostaną przekazane żadne dane? Najpopularniejszy sposób poradzenia sobie z taką sytuacją polega na utrzymaniu listy warunków zapytania bez zmian, złączeniu wszystkich tabel wykorzystywanych w zapytaniu z użyciem odpowiednich warunków złączenia i zastąpieniu bezpośrednich warunków z poprzedniego przykładu serią następujących: and nazwa_kolumny = coalesce(?, nazwa_kolumny)
Za znak ? w powyższym fragmencie będzie podstawiona wartość pola formularza, funkcja coalesce() zwraca pierwszą wartość z listy argumentów niebędącą NULL-em. Jeśli zatem w formularzu zostanie podana wartość, zostanie zastosowany filtr, jeśli nie, wszystkie wartości kolumny przejdą test. Wszystkie wartości? Niezupełnie. Jeśli kolumna zawiera NULL-e, warunek na tej kolumnie zawsze zwróci wartość FALSE. Nie można wszak powiedzieć, że coś, czego nie znamy, jest równe czemuś, co znamy, nawet jeśli to jest to samo coś (albo nic). Jeśli jeden warunek w naszej długiej serii warunków zwróci wartość FALSE, całe zapytanie zwróci pusty zbiór wynikowy, a z pewnością tego byśmy nie chcieli. Istnieje jednak rozwiązanie: and coalesce(nazwa_kolumny, stała) = coalesce(?, nazwa_kolumny, stała)
To rozwiązanie byłoby absolutnie idealne, gdyby tylko nie zapobiegało użyciu indeksu po kolumnie nazwa_kolumny, gdy w polu formularza zostanie wpisana wartość. Czy poprawność wyników musi być okupiona zmniejszeniem wydajności? Drugie z przedstawionych rozwiązań wygląda na bardziej atrakcyjne, ale obydwa z nich mogą doprowadzić do problemów z użytecznością aplikacji, co jest mało ciekawą perspektywą. Zapytanie działające we wszystkich możliwych przypadkach to ideał trudny do osiągnięcia. Powszechnie przyjęte rozwiązanie polega na dynamicznym budowaniu zapytania. W tym konkretnym przypadku możemy zapisać statyczny ciąg znaków zawierający treść zapytania aż do klauzuli WHERE, po czym połączyć go z ciągami znaków definiującymi warunki wprowadzone przez użytkownika — i tylko te warunki.
STRATEGICZNA SIŁA WOJSKOWA
293
Zmienna ilość kryteriów wyszukiwania wymaga zastosowania dynamicznego sposobu budowania zapytań.
Przy założeniu, że użytkownik wyszukiwał w bazie danych wszystkich filmów, w których występował Amitabh Bachchan, otrzymamy następujące, dynamicznie zbudowane zapytanie: select distinct movie_title, movie_year from movies inner join movie_credits mc1 on mc1.movie_id = movies.movie_id inner join people actor1 on mc1.people_id = actor1.people_id inner join roles actor_role on mc1.role_id = actor_role.role_id and mc2.role_id = actor_role.role_id and mc3.role_id = actor_role.role_id inner join movie_credits mc2 on mc2.movie_id = movies.movie_id inner join people actor2 on mc2.people_id = actor2.people_id inner join movie_credits mc3 on mc3.movie_id = movies.movie_id inner join people actor3 on mc3.people_id = actor3.people_id inner join movie_credits mc4 on mc4.movie_id = movies.movie_id inner join people director on mc4.people_id = director.people_id inner join roles director_role on mc4.role_id = director_role.role_id where actor_role.role_name = 'ACTOR' and director_role.role_name = 'DIRECTOR' and actor1.people_firstname = 'AMITABH' and actor1.people_name = 'BACHCHAN' order by movie_title, movie_year
Dwie uwagi na temat tego kodu: • W zapytaniu należy zastosować klauzulę DISTINCT. Powodem tego jest występowanie złączenia bez warunków. W przeciwnym razie zostałoby zwróconych tyle duplikatów filmu, ilu aktorów i reżyserów jest zdefiniowanych w bazie dla tego filmu.
294
ROZDZIAŁ ÓSMY
• Kuszącą koncepcją jest budowanie zapytań z fragmentów w taki sposób, że w wyniku powstaje gotowy tekst zapytania, czyli tak, jak to opisałem wyżej. Jednak nie powinniśmy tego robić w ten sposób. Wspominałem o tym już przy okazji zmiennych wiązanych, teraz nadszedł czas, aby wyjaśnić zasadę ich działania. Prawidłowy sposób konstruowania zapytań polega na zbudowaniu zapytania z miejscami podstawienia wartości parametrów oznaczonymi znakiem ? (lub innym, w zależności od języka programowania), a następnie na wywołaniu funkcji, która zwiąże wartości z tymi miejscami wstawienia. Dla programisty ta technika może wydać się bardziej pracochłonna, ale dla silnika bazy danych pracy jest tu znacznie mniej. Nawet w przypadku, gdy zapytanie jest budowane od nowa za każdym razem, silnik bazy danych z reguły buforuje wykonywane instrukcje w ramach standardowych zadań optymalizatora. Jeśli silnik SQL-a otrzyma do przetwarzania zapytanie, które znajdzie w buforze, nie musi już dokonywać analizy leksykalnej kodu SQL, a co więcej, optymalizator z tym kodem ma już skojarzoną optymalną ścieżkę wykonawczą. Gdy użyjemy punktów podstawiania, wszystkie zapytania zbudowane w oparciu o ten wzorzec (jak wyszukiwanie filmów, w których występuje wskazany aktor) będą wykorzystywać ten sam kod SQL (mimo odmiennych parametrów, jak nazwisko aktora). W takim przypadku zapytanie może być wykonane natychmiast po jego otrzymaniu, dzięki czemu baza danych zwraca wyniki szybciej. Oprócz wydajności zapytania budowane w całości wiążą się z innym, nawet ważniejszym problemem: obniżeniem bezpieczeństwa. Tego typu technika budowania zapytań stanowi bowiem idealne pole do działania dla znanej techniki włamań, określanych jako wymuszanie kodu SQL (ang. SQL injection). Na czym polega taki atak? Załóżmy, że nasza komercyjna aplikacja WWW może być używana w pełnej funkcjonalności wyłącznie przez zarejestrowanych użytkowników, natomiast niezarejestrowani użytkownicy w ramach demonstracji mogą wykonywać zapytania wyłącznie na podzbiorze filmów sprzed roku 1960. Załóżmy, że osoba niezarejestrowana wprowadzi tytuł filmu w następującej postaci: X' or 1=1 or 'X' like 'X
STRATEGICZNA SIŁA WOJSKOWA
295
Gdy tę wartość połączy się bezpośrednio w kod SQL, otrzymamy następujący warunek: where movie_title like 'X' or 1=1 or 'X' like 'X%' and movie_year < 1960
Warunek ten zawsze zwraca wartość TRUE, co spowoduje, że nie będzie wykonywane żadne filtrowanie! Połączenie kodu SQL bezpośrednio z ciągu znaków wpisanego przez użytkownika oznacza, że prawie każdy użytkownik będzie w stanie pobrać pełną bazę filmów bez konieczności rejestrowania się w systemie. Oczywiście istnieją również scenariusze, w których może dojść do wykorzystania danych znacznie poufniejszych niż baza filmów. Zmienne wiązane chronią kod SQL przed atakami wymuszenia. Wymuszenia kodu SQL stanowią bardzo poważny problem dla bezpieczeństwa każdego systemu online wykorzystującego bazę danych i należy poświęcić odpowiednią ilość uwagi zabezpieczeniu bazy przed zagrożeniami tego typu. Wykorzystując zapytania budowane w sposób dynamiczny, należy wykorzystywać znaczniki punktów podstawiania i stosować zmienne wiązane, co jest rozwiązaniem wydajniejszym i bezpieczniejszym (chroni przed atakami wymuszenia kodu SQL).
Zapytanie z wstępnie przygotowanymi złączeniami i połączonymi w jeden ciąg znaków warunkami filtrującymi wykona się szybko, o ile tabele są prawidłowo poindeksowane. Jednak jedna rzecz pozostaje niepokojąca. Poprzednie przykładowe zapytanie jest dość skomplikowane, szczególnie jeśli weźmiemy pod uwagę prostotę wyniku oraz danych wejściowych zapytania.
Odpowiednie rozmiary zapytań W rzeczywistości poziom komplikacji zapytania to zaledwie część problemu. Co się stanie w przypadku ostatniego zapytania z poprzedniego podrozdziału, jeśli nie wypełnimy nazwiska reżysera lub gdy znamy tylko dwa nazwiska aktorów? Zapytanie zwróci pusty zbiór wierszy. Czy jednak nie możemy wykorzystać złączenia zewnętrznego, które zwróci wszystkie dopasowane wartości, a w przypadku braku dopasowania zwróci NULL?
296
ROZDZIAŁ ÓSMY
Użycie złączeń zewnętrznych może być dobrym rozwiązaniem, ale problem w tym, że nie wiemy z góry, jakie będą kryteria zapytania. Co się stanie, gdy w bazie danych znajdzie się wyłącznie nazwisko reżysera? Biorąc pod uwagę tego typu możliwości, zmuszeni bylibyśmy do użycia złączeń zewnętrznych praktycznie w przypadku każdego złączenia; oczywiście logicznie jest to niemożliwe. Mamy zatem interesujący przypadek, w którym użytkownik spotyka się z irytującymi sytuacjami braku danych nawet wówczas, gdy wszystkie pola formularza są obowiązkowe, a w bazie danych nie ma NULL-i, ponieważ zapytanie zmusza bazę do dokonania złączeń, które nie zwracają wyników. W przypadku, gdy w kryteriach wyszukiwania jest podane tylko jedno nazwisko aktora, będziemy potrzebowali zapytania o takim poziomie komplikacji: select movie_title, movie_year from movies inner join moviecredits mc1 on mc1.movie_id = movies.movie_id inner join people actor1 on mc1.people_id = actor1.people_id inner join roles actor_role on mc1.role_id = actor_role.role_id where actor_role.role_name = 'ACTOR' and actor1.people_firstname = 'AMITABH' and actor1.people_name = 'BACHCHAN' order by movie_title, movie_year
Tego typu „dopasowane” zapytanie nie zakłada żadnych informacji o reżyserze, jak również o liczbie aktorów, i dlatego nie ma konieczności zastosowania tu złączeń zewnętrznych. Skoro zaczęliśmy dynamicznie budować nasze zapytanie, dlaczego nie użyć nieco więcej inteligencji, aby skonstruować zapytanie dedykowane do zestawu parametrów? Oczywiście kod programu znacznie się skomplikuje. Czy ten zwiększony poziom komplikacji jest uzasadniony biznesowo? Sądzę, że odpowiedź brzmi „tak”, co wynika z prostego faktu, iż jesteśmy w stanie zwrócić wszelkie dostępne informacje na podstawie nazwiska jednego aktora, nawet jeśli nie znamy reżysera. Również względy wydajności uzasadniają taką decyzję. Nic tak nie przekonuje, jak wywołanie zapytania w pętli odpowiednią ilość razy w celu pokazania różnicy między zapytaniem „dopasowanym” a zapytaniem uogólnionym. Jaka różnica, czy zapytanie wykonuje się
STRATEGICZNA SIŁA WOJSKOWA
297
w 0,001 sekundy, czy w 0,005 sekundy? Niewielka, jeśli zapytanie jest wywoływane od czasu do czasu. Ale jeśli kolejne zapytania napływają do bazy danych z częstotliwością większą, niż jest je ona w stanie obsłużyć, taka różnica może być znacząca. Zapytania muszą być kolejkowane, a długość kolejki będzie się zwiększać w szybkim — prawie tak szybko, jak szybko będzie się zwiększać liczba skarg na słabą wydajność bazy danych. W skrócie: pięć razy szybsze zapytanie pozwala w tej samej jednostce czasu na tym samym sprzęcie przetworzyć pięć razy więcej zapytań (więcej informacji na ten temat można znaleźć w rozdziale 9.). Dopasowanie kryteriów w dynamicznie budowanych zapytaniach poprawia wydajność dzięki zmniejszeniu liczby zapytań i wyeliminowaniu przetwarzania parametrów bez przypisanej wartości.
Opakowanie SQL-a w PHP Zacznijmy od prostej strony w PHP, składającej się na razie wyłącznie z kodu HTML: Wynik zapytania Tytuł | Rok |
---|
Na marginesie: strona będzie wyglądała znacznie bardziej elegancko w przypadku zastosowania arkuszy stylów… Po uzyskaniu uchwytu połączenia z bazą danych w pierwszej kolejności należy uzyskać dostęp do wartości wprowadzonych na stronie formularza. W naszej bazie danych wszystkie dane są wprowadzone wielkimi literami; na tym etapie możemy od razu zamienić wszystkie teksty na wielkie litery. Oczywiście można tego dokonać w kodzie SQL-a, ale nic nas nie kosztuje, żeby wykorzystać do tego kod PHP:
298
ROZDZIAŁ ÓSMY
$title=strtoupper($_POST['title']); $director=strtoupper($_POST['director']); $actori=strtoupper($_POST['actor1']); $actor2=strtoupper($_POST['actor2']); $actor3=strtoupper($_POST['actor3']);
W tym miejscu pojawia się problem techniczny: implementacja techniki wiązania zmiennych w PHP. Proces wiązania zmiennych w PHP wygląda następująco: 1. W miejscu każdego z parametrów w zapytaniu SQL wprowadza się znak ?. 2. Wywołuje się funkcję bind_param(), w której pierwszym argumencie podaje się ciąg znaków o ich liczbie zgodnej z liczbą parametrów zapytania. Każdy znak tego ciągu określa typ parametru (w naszym przypadku wszystkie parametry są ciągami znaków). W następnych parametrach wywołania funkcji bind_param() podaje się wartości wiązane z parametrami zapytania. Wszystkie parametry są identyfikowane w oparciu o kolejność (podobna zasada obowiązuje w JDBC, ale w niektórych technikach programowania jest inaczej, na przykład w SQLJ parametry dowiązane identyfikuje się po nazwie). W naszym przypadku problem stanowi samo wywołanie funkcji bind_param(), które jest dość wygodne, gdy mamy do czynienia ze stałą liczbą wiązanych parametrów, ale my nie będziemy z góry znali ich liczby. W tej sytuacji lepsza będzie metoda pozwalająca wiązać zmienne w pętli, jedna po drugiej. Jeden ze sposobów związania zmiennej liczby parametrów nie jest najbardziej elegancki i polega na wykorzystaniu tablicy, gdzie kolejno są zapisywane zmienne, w których w formularzu zostały wprowadzone dane. W naszym przykładzie jest nieco prościej, bo wszystkie parametry są typu tekstowego; w przypadku innych typów danych, jak choćby rok premiery filmu, najrozsądniej byłoby traktować je jako ciągi znaków w kodzie PHP i przekształcać do odpowiednich typów (w przykładzie: liczby lub daty) w kodzie SQL. Do zapisu liczby parametrów zawierających wartości można użyć zmiennej $paramcount, natomiast same zmienne zapiszemy w tablicy $params:
STRATEGICZNA SIŁA WOJSKOWA
299
$paramcnt=0; if ($title != "") { $params[$paramcnt] = $title; $paramcnt++; }
W przypadku nazwisk sprawa nieco się komplikuje. Jak pamiętamy, zdecydowaliśmy się wykorzystać jedno pole formularza do wprowadzania imienia i nazwiska, ponieważ to wygodniejsze dla użytkownika niż osobne pola do wprowadzania imienia i nazwiska. Jednak wykorzystanie warunku po połączonych kolumnach tabeli (gdzie imię i nazwisko przechowywane są osobno) spowodowałoby, że nie będzie używany indeks po nazwiskach, a, co gorsza, mogą pojawić się błędne wyniki: jeśli użytkownik przez pomyłkę wprowadzi dwie spacje między imieniem i nazwiskiem, pozycja nie zostanie odszukana. Z tych powodów należy podzielić dane wprowadzone w polu nazwiska na imię i nazwisko, przy założeniu, że nazwisko jest ostatnim wyrazem w polu, natomiast przed nim występuje imię, które może składać się z zera, jednego lub większej liczby słów. W PHP to zadanie można z łatwością zrealizować z użyciem funkcji ustawiającej wartości dwóch zmiennych przekazanych jako parametry funkcji przez referencję: function split_name($string, &$firstname, &$lastname) { /* * Zakładamy, że nazwisko jest ostatnim elementem ciągu znaków * i że może wystąpić większa liczba imion */ $pieces = explode(" *, $string); $parts = count($pieces); $firstnames = array_slice($pieces, 0, $parts - 1); $firstname = implode(" ", $firstnames); $lastname = $pieces[$parts - 1]; }
Ta funkcja posłuży do rozbicia zmiennej $director na $dfn i $dn, $actor1 na $aifn i $ain itd., w oparciu o ten sam mechanizm: if ($director != "") { /* Imię i nazwisko zapisujemy w osobnych zmiennych */ split_name($director, $dfn, $dln); if ($dfn != "") { $params[$paramcnt] = $dfn; $paramcnt++;
300
ROZDZIAŁ ÓSMY
} $params[$paramcnt] = $dln; $paramcnt++; }
Po przejrzeniu parametrów wystarczy zbudować zapytanie, uważając, aby znaczniki dowiązanych parametrów wprowadzić w odpowiedniej kolejności, zgodnie z ich kolejnością w tablicy $params: $query = "select movie_title, movie_year " "from movies"; /* Czy zostało wpisane nazwisko reżysera? */ if ($director 1 = "") { $query = $query . " inner join movie_credits mcd" . " on mcd.movie_id = movies.movie_id" . " inner join people director" . " on mcd.people_id = director.people_id" . " inner join roles director_role" . " on mcd.role_id = director_role.role_id"; } /* Czy zostało wpisane przynajmniej jedno nazwisko aktora? */ if ($actor1 . $actor2 . $actor3 != "") { /* * Złączenie z tabelą ROLES */ $query = $query . " inner join roles actor_role"; /* * Nawet w przypadku, gdy zostało podane nazwisko tylko jednego aktora, * nie ma pewności, że znajdzie się w zmiennej $actor1, należy to sprawdzić */ $actcnt == 0; if ($acton != "") { if ($actcnt == 0) { $query = $query . " on"; } else { $query = $query . " and"; } $query = $query . " mc1.role_id = actor_role.role_id"; } if ($actor2 != "") { ...
STRATEGICZNA SIŁA WOJSKOWA
} if ($actor3 ! = "") { ... } /* * Następnie łączymy z tabelami MOVIE_CREDITS i PEOPLE */ if ($actori !- "") { $query = $query . " inner join movie_credits mc1" . " on mc1.movie_id = movies.movie_id" . " inner join people actor1" . " on actor1.people_id = mc1.people_id"; } if ($actor2 != "") { ... }" if ($actor3 != "") { ... } } /* * Koniec klauzuli FROM. Zastosujemy starą sztuczkę 1=1, * aby uniknąć konieczności sprawdzania za każdym razem, * czy mamy do czynienia z pierwszym warunkiem z listy warunków. */ $query = $query . " where 1=1"; /* * Należy zadbać o to, aby kolejność dodawania parametrów była zgodna * z ich kolejnością w tablicy $params */ if ($title != "") { $query = $query . " and movies.movie_title like concat(?, '%')"; } /* Czy reżyser został określony? */ if ($director !- "") { $query = $query . " and director_role.role_name = 'DIRECTOR'"; if ($dfn ! = "") { /* * Zamiast porównania użyjemy operatora LIKE, dzięki temu * zadziała ze skrótami i inicjałami. */
301
302
ROZDZIAŁ ÓSMY
$query = $query ." and director.people_firstname like concat(?, '%')"; } $query = $query . " and director.peoplejname = ?"; } if ($actor1 . $actor2 . $actor3 != "") { $query = $query . " and actor_role.role_name = 'ACTOR'"; if ($actor1 ! = "") { ... } if ($actor2 1= "") { ... }" if ($actor3 != "") { ... }" }
Po przygotowaniu kodu zapytania należy wywołać funkcję prepare(), po czym dowiązać nasze zmienne. W tym miejscu kod wygląda mało elegancko, ponieważ należy uwzględnić od jednej do dziewięciu zmiennych i każdą z nich musimy obsłużyć niezależnie: /* uchwyt wywołania zapytania */ if ($stmt = $mysqli->prepare($query)) { /* * Dowiązywanie parametrów * Najmniej elegancki fragment. * Możemy mieć od 1 do 9 parametrów (ciągi znaków) */ switch ($paramcnt) { case 1 : $stmt->bind_param("s", $params[0]); break; case ... ... break; case 9 : $stmt->bind_param("sssssssss", $params[0], $params[1], $params[2], $params[3], $params[4],
STRATEGICZNA SIŁA WOJSKOWA
303
$params[5], $params[6], $params[7], $params[8]); break; default : break; }
Prawie gotowe. Pozostało jedynie wywołanie zapytania i wypisanie wyników: /* wywołanie zapytania */ $stmt->execute(); /* pobranie wartości */ $stmt->bind_result($mt, $my); while ($row = $stnrt->fetch()) { printf ("|
%s | %d |
\n", $mt, $my); } /* zamknięcie uchwytu wywołania */ $stmt->close(); } else { printf("Error: %s\n", $mysqli->sqlstate); } ?>
Oczywiście kod PHP jest znacznie bardziej skomplikowany w przypadku dynamicznego budowania zapytania niż w przypadku zapytania statycznego. Zastanawiające może wydać się to, że zachęcam do tworzenia tak skomplikowanego kodu po stronie aplikacji, gdy wcześniej wielokrotnie namawiałem do przerzucania jak największego zakresu pracy na silnik SQL. Taka strategia ma sens w przypadku, gdy silnik SQL ma wykonywać rzeczywistą pracę. Jednak złączenie trzykrotnie większej liczby tabel, niż jest to niezbędne do uzyskania wyników zapytania, podczas gdy dodatkowo część z tych złączeń z założenia będzie dość niewydajna (szczególnie gdy są w nich wykorzystywane bardziej skomplikowane perspektywy), z pewnością ma znacznie mniej sensu.
304
ROZDZIAŁ ÓSMY
Dzięki inteligentnemu skonstruowaniu zapytania mamy możliwość ścisłego kontrolowania jego bezpieczeństwa, a także poprawności wyniku i wydajności. Każde inne rozwiązanie upraszczające życie programisty niesie ze sobą ryzyko poświęcenia jednego z tych ważnych czynników. Podsumowując, można wyróżnić trzy podstawowe błędy popełniane bardzo powszechnie w zapytaniach wykorzystujących zmienną liczbę kryteriów wyszukiwania: • Przede wszystkim nader powszechnie zdarza się łączyć dynamiczne wartości używane w warunkach filtrowania z samym kodem zapytania, przez co zapytanie jest w pełni statyczne. Nawet w środowiskach, w których wartości zmiennych są nieprzewidywalne, z reguły same zapytania mają identyczną konstrukcję i są bardzo powtarzalne, za wyjątkiem wartości zmiennych. Niektóre elementy są zmienne w dość dużym zakresie (jak identyfikatory elementów), inne są niezmienne, jak formaty dat, a nawet kody statusów. Znacznie lepsze rozwiązanie polega na zastąpieniu tych wartości znacznikiem parametru (zależnym od produktu, często stosowany jest znak ?) i wykorzystaniu zmiennych dowiązanych. Takie podejście znacznie zmniejsza ilość pracy po stronie serwera, ponieważ nie musi on za każdym razem dokonywać analizy leksykalnej zapytania, nie musi też przygotowywać od nowa planu wykonawczego, który zawsze będzie taki sam. Ponadto nie ma możliwości obejścia ograniczeń użytkownika z zastosowaniem technik wymuszania kodu SQL, co stanowi znaczące poprawienie bezpieczeństwa. • Poważnym błędem jest umieszczanie w zapytaniu wszystkich elementów, na zapas. Dzieje się tak z tego powodu, że potencjalne kryteria wyszukiwania mogą brać pod uwagę dane zapisane w różnych tabelach, dlatego istnieje tendencja uwzględniania wszystkich potencjalnych tabel w klauzuli FROM. Wspominałem już o tej zasadzie w poprzednich rozdziałach, ale przypomnę: w klauzuli FROM należy umieszczać jedynie te tabele, w których znajdują się odczytywane dane, oraz tabele pozwalające połączyć te dane w całość. Jak zademonstrowałem w rozdziale 6., testy występowania powinny być rozstrzygane w ramach podzapytań. Dynamiczne generowanie niezbędnych podzapytań nie jest trudniejsze od dynamicznego skonstruowania filtru w ramach klauzuli WHERE.
STRATEGICZNA SIŁA WOJSKOWA
305
• Największym błędem jest jednak filozofia „uniwersalnego zapytania”. Każde zapytanie skonstruowane z myślą o uogólnionym zastosowaniu z reguły łączy w sobie dwa lub trzy zapytania składowe. Zwykle dane wejściowe składają się z identyfikatorów, wartości statusów oraz zakresów dat. Dane wejściowe mogą definiować silne, efektywne kryteria wyboru lub też słabe, często też trudno je zakwalifikować do którejkolwiek z tych skrajności (czasem słabe kryterium może być wzmocnione dodatkowym, które zawęzi zakres zwracanych danych). Wykorzystanie w inteligentny sposób kilku alternatywnych zapytań, jak to zademonstrowałem w rozdziale 6., jest najlepszą drogą wybrnięcia z kłopotu, choć na pierwszy rzut oka może wyglądać dość skomplikowanie. Więcej inteligencji w algorytmie konstruującym dynamiczne zapytanie w efekcie daje większą wydajność zapytania SQL.
306
ROZDZIAŁ ÓSMY
ROZDZIAŁ DZIEWIĄTY
Walka na wielu frontach Wykorzystanie współbieżności Yet to their General’s Voice they soon obey’d Innumerable. Lecz na głos swego generała wstali posłusznym mrowiem. — John Milton (1608 – 1674) Raj utracony, Księga I
308
G
ROZDZIAŁ DZIEWIĄTY
dy jednocześnie działa wiele sesji odwołujących się do jednej bazy danych, możemy spodziewać się kłopotów, które nie ujawnią się przy testach wykonywanych przez jednego użytkownika. Pojawia się zjawisko konkurencji o zasoby skutkujące blokadami zasobów trwającymi nieprzewidywalnie długo. Ten rozdział omawia sposoby radzenia sobie z tego typu sytuacjami, gdy z bazy danych korzystają ogromne ilości użytkowników. Z sytuacją wielodostępu wiąże się kilka zagadnień. Najważniejszym z nich jest zjawisko konkurowania o zasoby przy operacjach zapisu (a czasem odczytu) oraz wiążąca się z nim bezpośrednio konieczność zastosowania blokad na różnych poziomach abstrakcji. Jednak użytkownicy nie tylko walczą o prawo do modyfikowania bajtów w określonym miejscu systemu, muszą też rywalizować o moc procesora, dostęp do dysku twardego, miejsce w pamięci czy przepustowość sieci. Bardzo często kłopoty ledwo zauważalne przy niewielkiej liczbie jednocześnie pracujących użytkowników przy większym oblężeniu bazy danych stają się nie do zniesienia. Przyrost liczby jednocześnie pracujących użytkowników rzadko powoduje liniowy przyrost obciążenia systemu, jak często błędnie się zakłada. Nagły przyrost użytkowników może oznaczać znaczący sukces dla firmy, ale częściej tego typu przyrosty biorą się z faktu etapowego wdrażania poszczególnych modułów aplikacji. Często też wynikają z biznesowych przejęć innych firm lub połączenia kilku firm w jedną.
Silnik bazy danych jako dostawca usług Można zaryzykować stwierdzenie, że silnik bazy danych to inteligentny i oddany sługa, który natychmiast spieszy z pomocą na każdą prośbę o dostarczenie danych. Rzeczywistość jest jednak nieco mniej romantyczna i silnik bazy danych częściej niż wiernego sługę przypomina kelnera w bardzo zatłoczonej restauracji. Gdy zdecydujemy się dłużej grymasić przy menu, kelner często reaguje na tę sytuację słowami: „Proszę wybrać, a ja za chwilę przyjdę przyjąć zamówienie”, po czym znika na dłuższy czas. System zarządzania bazami danych to dostawca usług, a dokładniej — zbiór dostawców usług. Usługa polega na wykonywaniu na danych określonych operacji, jak odczyt lub modyfikacja. Usługa może być wykorzystywana przez wielu klientów jednocześnie. W takiej sytuacji silnik bazy danych będzie działał wydajnie wyłącznie pod warunkiem że wydajnie działają wszystkie jednocześnie wykonywane operacje.
WALKA NA WIELU FRONTACH
309
Zalety stosowania indeksów Wywołajmy test na prostej tabeli składającej się z trzech kolumn. Dwie pierwsze są kolumnami typu całkowitego (w każdej z nich znajdują się unikalne wartości z przedziału od 1 do 50 000), jedna jest zadeklarowana jako klucz główny, a druga nie ma określonego indeksu. Trzecia kolumna (o nazwie label) jest typu tekstowego i zawiera losowe ciągi o długości od 30 do 40 znaków. W teście będziemy losować liczby z przedziału od 1 do 50 000 i wykorzystywać je w charakterze identyfikatorów etykiet (label) do odczytania. Można się przekonać, zapewne ze zdziwieniem, że na dość wydajnej maszynie poniższe dwa zapytania dają praktycznie natychmiastowe wyniki: select label from test_table where indexed column = wartość_losowa
Drugie zapytanie: select label from test_table where unindexed_column = wartość_losowa
Jak to jest możliwe? Przecież zapytanie wykorzystujące niepoindeksowaną kolumnę powinno być znacznie wolniejsze. W rzeczywistości tabela składającą się z pięćdziesięciu tysięcy wierszy należy do stosunkowo niewielkich, a w przypadku niewielkiej liczby kolumn, jak w naszym przykładzie, liczba odczytywanych bajtów nie jest przytłaczająca i nowoczesny komputer powinien bardzo szybko poradzić sobie z takim zadaniem. W rzeczywistości z jednej strony mamy przeszukiwanie tabeli po kluczu głównym, z drugiej strony pełne przeszukiwanie tabeli. Wniosek jest taki, że w takich warunkach różnica między przeszukiwaniem z użyciem indeksu i pełnym przeszukiwaniem tabeli jest tak niewielka, iż praktycznie niezauważalna dla człowieka. Aby rzeczywiście przetestować zalety posiadania indeksów, należy wywoływać to samo zapytanie synchronicznie przez minutę i porównać liczbę wywołań zapytania w przypadku użycia indeksów i bez nich. Wynik takiego testu będzie już bardziej zgodny z oczekiwaniami: wyszukiwanie z użyciem indeksu wykonało się pięć tysięcy razy na sekundę, natomiast wersja bez indeksu jedynie dwadzieścia pięć razy na sekundę. Programista samodzielnie wykorzystujący bazę danych może nie zauważyć różnicy, ale ta jednak występuje i w dodatku jest dość znacząca.
310
ROZDZIAŁ DZIEWIĄTY
Nawet różnice wielkości ułamków sekundy mogą powodować znaczące problemy z wydajnością. Nie należy ufać testom jednostkowym.
Życiowa historia Kontynuując przykład z poprzedniego podrozdziału, sprawdźmy, jakiego zachowania bazy danych możemy spodziewać się w praktyce. Załóżmy, że zamiast liczb kluczem głównym w naszej tabeli jest ciąg znaków. W trakcie prac programistycznych ktoś zauważył, że zapytanie nieoczekiwanie zwróciło błędny wynik. Szybkie dochodzenie pozwoliło stwierdzić, że kolumna klucza głównego zawiera zarówno wielkie, jak i małe litery. Pod presją konieczności szybkiego rozwiązania problemu programista zdecydował się w zapytaniu wykorzystać klauzulę where w warunku wywołania funkcji upper(), poprzez co zniweczył możliwość wykorzystania indeksu. Programista wywołuje zapytanie, zwrócony zostaje prawidłowy wynik i nikt nie jest w stanie zauważyć różnicy w prędkości jego zwrócenia. Wszystko wydaje się pozostawać w najlepszym porządku, można zatem oddać kod do użytku produkcyjnego. W praktyce mamy do czynienia z hordą użytkowników wywołujących to samo zapytanie często i w kółko. W rozdziale 2. zwracałem uwagę, że nie należy uruchamiać zapytań w pętlach programu, niezależnie od tego, czy są to pętle wykorzystujące kursor bazy danych, czy też tradycyjne konstrukcje for lub while. Niestety, nader często zdarza się napotykać zapytania osadzone w pętlach sterowanych wynikami innych zapytań. W efekcie takie zapytanie często jest wywoływane z dużą częstotliwością, nawet mimo tego że system nie ma setek tysięcy jednocześnie pracujących użytkowników. Sprawdźmy, co się stanie, gdy będziemy wykonywać nasze testowe zapytanie z dużą częstotliwością, seriami, z ustaloną liczbą wywołań w jednostce czasu, w losowych odstępach. Gdy zapytanie zostanie wywołane z dość niską częstotliwością pięćset razy na minutę, baza wydaje się działać prawidłowo niezależnie od tego, czy jest użyty indeks, czy też nie, co można zaobserwować na rysunku 9.1. Wszystkie zapytania wykonują się w czasie poniżej 0,2 sekundy i nikt nie ma powodów do narzekania.
WALKA NA WIELU FRONTACH
311
RYSUNEK 9.1. Czas reakcji w prostym zapytaniu na 50 000-wierszowej tabeli, niska częstotliwość wywołań
Aby zauważyć różnicę, należy dziesięciokrotnie zwiększyć częstotliwość wywołań zapytania, czyli do pięciu tysięcy wywołań na minutę. Wyniki pomiarów przedstawia rysunek 9.2. Zauważa się już spowolnienie reakcji w przypadku wywołań bez użycia indeksów. Jednak nadal opóźnienia występują w niewielkim odsetku zapytań. 97% z nich nadal wykonuje się w czasie poniżej 0,3 sekundy. Jednak przy częstotliwości pięć tysięcy zapytań na minutę nic nie zdradza faktu, że znajdujemy się na krawędzi katastrofy. Jeśli podniesiemy częstotliwość wywołań do dziesięciu tysięcy na minutę, otrzymamy sytuację przedstawioną na rysunku 9.3. Znacząca większość zapytań będzie wykonywała się dużo wolniej, niektóre z nich potrzebują na wykonanie ponad 4 sekund. Jeśli ten sam test wykonać z zapytaniem wykorzystującym indeks, przy tej częstotliwości zapytania będą niezmiennie potrzebowały poniżej 0,1 sekundy.
312
ROZDZIAŁ DZIEWIĄTY
RYSUNEK 9.2. Czas reakcji w prostym zapytaniu na 50 000-wierszowej tabeli, wysoka częstotliwość wywołań
RYSUNEK 9.3. Czas reakcji w prostym zapytaniu na 50 000-wierszowej tabeli, bardzo wysoka częstotliwość wywołań
WALKA NA WIELU FRONTACH
313
Oczywiście w przypadku, gdy znacznemu spowolnieniu ulegają zapytania, które przy małej częstotliwości wywołań działały znacznie wydajniej, niektórzy użytkownicy z pewnością zaczną się skarżyć, a inni, którzy nie zauważyli różnicy, z pewnością również będą narzekali, choćby z czystego współczucia dla tych pierwszych. Baza danych jest powolna, czy można coś na to poradzić? Administrator bazy danych i inżynierowie systemu będą modyfikować parametry, osiągając stan ulgi na kilka tygodni, aż do momentu, gdy fakty doprowadzą do smutnej konkluzji: potrzebujemy potężniejszego serwera. Zwiększające się obciążenie systemu czasem nie doprowadza do powstania problemów, ale może je ujawnić, sugerując tym samym modyfikację programu jako alternatywę dla zakupu silniejszego sprzętu.
Do kolejki Jedną z dość wiernych alegorii systemu zarządzania bazami danych jest urząd pocztowy z zatrudnioną określoną liczbą urzędników obsługujących klientów o dość szerokim zakresie żądań — w przypadku baz danych są to zapytania. Oczywiście duże urzędy pocztowe posiadają większą liczbę jednocześnie czynnych okienek, w których może być załatwianych kilku klientów. Możemy również wyobrazić sobie, że młodzi, pobudzeni kofeiną urzędnicy pracują wydajniej od starszych osobników, pijących jedynie herbatki ziołowe. Wiemy jednak doskonale, że szczególnie w godzinach szczytu na wydajność największy wpływ będą miały typy żądań zgłaszanych przez klientów. Żądania te będą różne, od zakupu znaczków za odliczoną kwotę po czasochłonne operacje, jak wysyłka wartościowej paczki za granicę, co wymaga wypełnienia deklaracji wartości, formularzy celnych itp. Najbardziej irytujące są oczywiście przypadki, gdy klientka dokonująca szybkiej transakcji marnuje trzy razy więcej czasu na poszukiwanie portmonetki niż obsługujący ją urzędnik poświęcił na obsługę jej żądania. Na szczęście jednak w prawdziwych urzędach pocztowych niezmiernie rzadko zdarza się sytuacja dość powszechna w przypadku kiepsko napisanych aplikacji korzystających z baz danych: że klient wysyłający dwadzieścia listów staje w kolejce dwadzieścia razy, za każdym razem kupując tylko jeden znaczek na jeden list. Należy pamiętać, że prędkość obsługi klienta w okienku jest uzależniona od kilku czynników:
314
ROZDZIAŁ DZIEWIĄTY
• wydajności pracy urzędnika; a w przypadku bazy danych od połączenia wydajności silnika bazy, sprzętu i podsystemu wejścia-wyjścia. • poziomu komplikacji samego żądania, a w szczególności od sposobu zaprezentowania żądania, jego jednoznaczności i czytelności — tak aby urzędnik szybko zrozumiał, o co chodzi, i był w stanie dostarczyć szybką i kompletną usługę. W bazach danych pierwszy z czynników jest uzależniony od inżynierów systemu i administratorów bazy danych. Drugi czynnik jest natomiast uzależniony wyłącznie od zdefiniowanych wymogów biznesowych oraz od programistów. Im bardziej skomplikowany będzie system jako całość, tym ważniejsza staje się współpraca między jego poszczególnymi uczestnikami, aby wykorzystanie sprzętu i oprogramowania było jak najbardziej efektywne. Mając stale na uwadze przykład urzędu pocztowego, możemy zrozumieć przyczynę takiego, a nie innego przebiegu naszych testów. Na wydajność systemu ma wpływ częstotliwość pojawiania się klientów (częstotliwość wywołania zapytań) w stosunku do średniego czasu realizacji zapytania. Dopóki częstotliwość pojawiania się nowych żądań jest wystarczająco niska, aby każdy nowy klient miał możliwość znalezienia wolnego okienka, nie będzie tworzyła się kolejka i nikt nie będzie narzekał na obsługę. Jednak gdy tylko klienci zaczną napływać szybciej, niż będą mogli zostać obsłużeni, kolejka zacznie się wydłużać, zarówno dla szybko obsługiwanych żądań, jak i dla tych bardziej czasochłonnych. Istnieje również efekt progu, bardzo podobny do opisanego przez Charlesa Dickensa w powieści David Copperfield: Roczny przychód: dwadzieścia funtów, roczne wydatki: dziewiętnaście funtów sześć pensów, wynik: szczęście. Roczny przychód: dwadzieścia funtów, roczne wydatki: dwadzieścia funtów i sześć pensów, wynik: żałoba. Działanie tego efektu można z łatwością zademonstrować, wywołując jednocześnie nasze dwa zapytania, czyli zapytanie wykorzystujące indeksy i zapytanie niewykorzystujące indeksów, z częstotliwością pięć tysięcy razy na sekundę. Wynik został przedstawiony na rysunku 9.4 i jest znacząco różny od wyniku z rysunku 9.2, który przedstawia wyniki w sytuacji, gdy obydwa zapytania były wywoływane niezależnie, nierównocześnie. Jak jasno wynika z rysunku 9.4, wydajność szybkiego zapytania znacznie spadła z powodu równoległego wykonywania powolnych zapytań.
WALKA NA WIELU FRONTACH
315
RYSUNEK 9.4. Szybkie i powolne zapytania uruchamiane równolegle z dużą częstotliwością
Wydajność systemu spada znacząco w sytuacji, gdy żądania napływają szybciej, niż mogą być obsłużone. Spadek wydajności widać w przypadku wszystkich zapytań, nie tylko tych powolnych.
Równoległe modyfikacje danych W przypadku modyfikacji danych zadanie zapewnienia wysokiej wydajności w przypadku zwiększenia aktywności staje się znacznie trudniejsze niż w przypadku operacji odczytu. Z jednej strony każda zmiana jest ze swej natury operacją bardziej kosztowną od zwykłego odczytu, ponieważ wymaga zarówno odczytania danych, jak i ich zapisania w bazie (w przypadku wstawiania wierszy występuje tylko operacja zapisu). Z tego powodu modyfikacja danych, jak i aktualizacja czy wstawianie wierszy, wiążą się z dłuższym czasem obsługi w porównaniu z operacją odczytu danych z bazy. Tego typu wydłużenie czasu obsługi jest spowodowane z reguły jednym mechanizmem i jedną sytuacją, które z reguły są błędne interpretowane. Chodzi o mechanizm zwany blokadą zasobów (ang. locking) i o sytuację określaną mianem konkurowania o zasoby (ang. contention).
316
ROZDZIAŁ DZIEWIĄTY
Blokady Gdy kilku użytkowników chce jednocześnie modyfikować te same dane, na przykład zarezerwować ostatni bilet na lot, w bazach danych wykorzystywany jest prosty mechanizm: blokuje się możliwość działania wszystkim użytkownikom prócz pierwszego, który zażądał modyfikacji zasobu. Konieczność sekwencyjnej obsługi żądań dostępu do zasobów krytycznych dla danego problemu jest zjawiskiem tak starym, jak stare jest zjawisko wielodostępu. Istniało w czasach papierowych kartotek i rekordów, jeszcze zanim wymyślono komputerowe systemy zarządzania bazami danych. Jeden użytkownik żąda blokady zasobu, a inni użytkownicy, którzy również żądają blokady tego zasobu, muszą zaczekać na zwolnienie blokady albo obsłużyć kod błędu, który otrzymają z podsystemu obsługi zasobu. Ta sytuacja również jest bardzo podobna do opisywanego urzędu pocztowego, na przykład gdy kilku klientów zechce skorzystać z fotokopiarki, ludzie muszą zaczekać cierpliwie w kolejce, aż osoba korzystająca z urządzenia w danej chwili zakończy swoją pracę. Można też zrezygnować z oczekiwania w kolejce i wrócić później, licząc na mniejszą zajętość urządzenia.
Szczegółowość systemu blokad Jednym z ważniejszych czynników, jaki należy brać pod uwagę przy rozważaniu konsekwencji wielodostępu, jest poziom stosowania blokad. Blokady mogą być zakładane na następujących poziomach: • cała baza danych, • fizyczny podzbiór bazy danych, w którym jest fizycznie zapisana tabela, • tabela wskazana do modyfikacji, • określony blok lub strona (jednostka zapisu) zawierające modyfikowane dane, • wiersz tabeli zawierający modyfikowane dane, • wybrane kolumny wiersza. Jak widać, skutek wpływu użytkowników na pracę innych jest uzależniony od tego, z jaką szczegółowością działają procedury blokowania zasobów. Typ blokad jest uzależniony od implementacji każdego systemu zarządzania bazami danych. To zagadnienie stanowi jedną z ważniejszych różnic między „wielkimi produktami”, przeznaczonymi dla rozbudowanych systemów, a „małymi produktami” o mniejszych ambicjach.
WALKA NA WIELU FRONTACH
317
Gdy procedury blokad działają na fragmencie tabeli, dane w tej samej tabeli mogą być bez przeszkód modyfikowane przez kilka niezależnych procesów bez wzajemnego wpływu. Zamiast czekać na zakończenie operacji innego procesu, który rozpoczął transakcję i uaktywnił blokadę, drugi proces może wykonywać swoje operacje równolegle. Tutaj wprawdzie pojawia się kwestia zasobów sprzętowych, ale w przypadku posiadania kilku procesorów w serwerze wykorzystanie sprzętu będzie znacznie efektywniejsze. Zaleta bardziej precyzyjnego mechanizmu blokad została przedstawiona na rysunku 9.5, który demonstruje różnicę całkowitej przepustowości równolegle wykonywanych operacji modyfikujących tabelę. Jeden z wykresów ilustruje wydajność w przypadku blokad na poziomie tabeli, drugi na poziomie wiersza. Obydwa pomiary były wykonywane w tym samym systemie zarządzania bazami danych.
RYSUNEK 9.5. Wydajność modyfikacji danych w tabeli z zastosowaniem blokad na całej tabeli i na poziomie wierszy.
W przypadku blokad na poziomie tabeli przy zastosowaniu dwóch równoległych sesji wydajność wzrosła nieznacznie, ponieważ serwer (w sensie dostawcy usług) nie był jeszcze „wysycony”. Jednak dwie sesje wykorzystują już jego pełną wydajność w zakresie sekwencyjnych wywołań operacji na sekundę i od tego miejsca wykres wydajności spłaszcza się, a właściwie wydajność stopniowo spada, ponieważ na potrzeby modyfikacji wykorzystywanych jest coraz więcej zasobów systemu. Sytuacja blokowania
318
ROZDZIAŁ DZIEWIĄTY
na poziomie tabel przedstawia się nieco inaczej od sytuacji blokowania na poziomie wierszy. Co prawda i tu system w pewnym momencie dociera do granic możliwości zwiększania zrównoleglenia działań, ale występuje to przy znacznie większej liczbie jednoczesnych operacji modyfikujących. Jeśli system zarządzania bazami danych jest mało elastyczny z punktu widzenia strategii blokowania zasobów, jedynym rozwiązaniem w przypadku znacznego przyrostu jednoczesnych operacji (gdy zostały już zastosowane wszystkie inne sposoby, jak optymalizacja zapytań) jest wymiana sprzętu na wydajniejszy. Wydajniejszy sprzęt oznacza oczywiście taki, który jest dobrany pod kątem potrzeb wynikających z systemu. Jeśli wąskim gardłem jest mechanizm blokad, zwiększanie liczby procesorów w niczym nie pomoże, ponieważ krytycznym zasobem jest tu dostęp do danych. Jednak szybsze procesory mogą skrócić czas wykonywania zapytań, zredukować czas obowiązywania blokad, a dzięki temu pozwolić na przetwarzanie większej liczby operacji w jednostce czasu. Przetwarzanie nadal pozostanie czysto sekwencyjne i zastosowanie będzie miała taka sama liczba blokad.
Korzystanie z blokad Mechanizmy blokujące są integralnym elementem każdej implementacji silników baz danych i niewiele da się w nich zmienić. Możliwości ograniczają się jedynie do właściwego korzystania z blokad: Unikać blokowania tabel na dłuższy czas Oczywiście nie trzeba tłumaczyć, dlaczego niekorzystne jest blokowanie tabeli na potrzeby modyfikacji wielu milionów wierszy w czasie, gdy wiele procesów potrzebuje na bieżąco wykonywać odczyty pojedynczych wierszy z tej samej tabeli. Starać się minimalizować czas funkcjonowania blokady W sytuacji, gdy w tym samym czasie wielu użytkowników próbuje uzyskać dostęp do tego samego zasobu w sposób uniemożliwiający współdzielenie, prędkość wykonywania operacji ma znaczenie nie dla jednej, ale dla wszystkich transakcji. Niewielką zaletę będzie miał fakt, że operacja modyfikująca jest bardzo szybka, jeśli musi czekać na zakończenie działania bardzo powolnej, trzymającej blokadę na zasobach potrzebnych jej do działania. Wszystkie operacje muszą odbywać się jak najszybciej albo wszystkie bez wyjątku będą sprawiały wrażenie, że działają wolno. Każdy łańcuch jest tak mocny, jak jego najsłabsze ogniwo.
WALKA NA WIELU FRONTACH
319
Znaczna większość instrukcji modyfikujących i usuwających dane zawiera klauzulę WHERE, zatem każda modyfikacja klauzuli WHERE zmierzająca do przyspieszenia operacji odczytu wykorzystującej tę samą klauzulę WHERE będzie miała wpływ również na przyspieszenie tych operacji modyfikowania i usuwania. Jeśli instrukcja DELETE nie wykorzystuje klauzuli WHERE (co spowoduje usunięcie wszystkich danych w tabeli!), z pewnością lepiej jest użyć instrukcji TRUNCATE, która po prostu opróżnia tabelę (lub partycję) z danych w sposób znacznie wydajniejszy. Nie wolno zapominać, że w operacjach zapisu i usuwania danych mamy do czynienia ze znaczącym kosztem utrzymania indeksów i że zawsze należy zrównoważyć korzyść z przyspieszenia odczytów z kosztem spowolnienia zapisów. Indeks pomocny przy przyspieszeniu klauzuli WHERE może spowodować, że każda modyfikacja wiersza stanie się udręką. W przypadku instrukcji INSERT często mamy do czynienia z konstrukcjami typu INSERT...SELECT, w których zależność między wydajnością operacji odczytu (SELECT) a wydajnością operacji zapisu (INSERT) jest już oczywista. W rozdziale 2. mieliśmy okazję przekonać się, że wydajność zapytań nie zawsze idzie w parze z wydajnością programów. Przy modyfikacji danych interesuje nas jedynie ograniczony zakres: transakcja, a innymi słowy, czas trwania zamkniętego etapu pracy. W większości, a może we wszystkich transakcjach musimy zakładać blokady. Każda operacja, która nie musi być wykonana w transakcji, szczególnie w przypadku, gdy zajmuje więcej czasu, musi być z niej wykluczona. Początek transakcji czasem jest niejawny, wywoływany w ramach instrukcji języka manipulacji danymi (data manipulation language, DML). Koniec transakcji również jest jawny, ponieważ jest oznaczony wywołaniem instrukcji COMMIT lub ROLLBACK. Niektóre zalecenia dotyczące operacji w ramach transakcji wynikają ze zdrowego rozsądku: • Należy unikać wykonywania w programie pętli z użyciem instrukcji SQL. • Należy zredukować do minimum konieczność przekazywania kontroli między bazą danych a programem, ponieważ takie przełączenie kontekstu powoduje opóźnienie i dodatkowy czas działania transakcji. • Należy maksymalnie wykorzystać możliwości silnika bazy danych w celu zminimalizowania liczby przełączeń kontekstu między bazą danych a programem (na przykład wykorzystać zalety procedur składowanych i przesyłania danych w postaci tablic).
320
ROZDZIAŁ DZIEWIĄTY
• Wszelkie nieistotne instrukcje SQL-a, które nie są niezbędne w ramach transakcji, należy z niej wyłączyć. Na przykład dość często zdarza się pobierać z tabel komunikaty programu, ta technika jest szczególnie powszechna w aplikacjach wielojęzycznych; gdy program natrafi na błąd, należy najpierw wycofać transakcję, wykonując operację ROLLBACK, a następnie pobrać stosowny komunikat z tabeli komunikatów o błędach — ale czynności te należy wykonać właśnie w takiej kolejności. Dzięki temu blokady zostaną zwolnione wcześniej, co przyspieszy możliwość wykonania operacji oczekujących w kolejce i zwiększy wydajność. Nawet tak prosta transakcja, jak na przykład wstawianie nowego wiersza w tabeli głównej i w tabeli szczegółów, może stać się źródłem znaczących błędów. Przykładem tego typu transakcji jest stworzenie nowego zamówienia w tabeli głównej oraz pierwszego elementu w koszyku zakupowym (który jest zapisany w tabeli order_detail). Problem z reguły wynika z użycia identyfikatorów zamówień generowanych automatycznie przez system. Pierwszy i najważniejszy błąd polega tu na zapisywaniu w tabeli „następnej wolnej wartości”. Tego typu tabela jest bezlitośnie blokowana przez każdą transakcję wstawiającą dane do tabeli zamówień, co powoduje, że szybko staje się wąskim gardłem aplikacji. W zależności od wykorzystywanego sytemu zarządzania bazami danych identyfikator generowany przez system jest wartością kolumny typu autoincrement (samodzielnie zwiększającej o jeden kolejne wpisywane wartości) lub kolejną wartością specjalnego obiektu bazy danych, na przykład sekwencji, która stanowi odpowiednik kolumny typu autoincrement, jednak bez jawnego powiązania z konkretną kolumną istniejącej tabeli. Dzięki tego typu mechanizmom programista nie musi wykonywać żadnych dodatkowych działań w celu utworzenia identyfikatora nowo wpisywanego zamówienia. Jednak musi go odczytać, aby w tabeli szczegółów zamówienia przypisać je do odpowiedniego identyfikatora tabeli głównej. Innymi słowy, do tabeli order_details musi być wprowadzona taka sama wartość, jak do tabeli orders. Niektóre produkty baz danych oferują specjalne mechanizmy zwracające taki identyfikator w zmiennej systemowej (jak @@IDENTITY w Transact-SQL) czy też funkcji (jak last_insert_ id() w MySQL-u). Rezygnacja (lub brak wyboru) z takich mechanizmów wiąże się z koniecznością wykonywania dodatkowych działań w ramach transakcji, co stanowi marnotrawstwo
WALKA NA WIELU FRONTACH
321
zasobów i spowalnia jej wykonanie. Wykorzystanie funkcji i zmiennych odwołujących się do ostatnio wygenerowanej wartości typu autoincrement wymaga nieco dyscypliny polegającej na konieczności uruchamiania zapytań w ustalonej kolejności, szczególnie w sytuacjach, gdy w jednej transakcji wykorzystywanych będzie kilka wartości typu autoincrement. Z nieznanych mi powodów wśród programistów wykorzystujących sekwencje istnieje tendencja do wywoływania .nextval() w celu uzyskania nowej wartości sekwencji, po czym zapisywania jej w zmiennej programu w celu jej wykorzystania w kolejnych zapytaniach. Istnieje jednak wywołanie .curval() (w DB2 to previous value for ), które, jak wskazuje sama nazwa, służy do odczytu ostatniej wartości wygenerowanej dla danej sekwencji. W większości przypadków nie ma potrzeby zapisywania tej wartości w zmiennej programu, a nawet wykonywania jakichkolwiek specjalnych działań w celu jej wygenerowania. W najgorszych przypadkach przydatne mogą być specjalne rozszerzenia systemów baz danych. Na przykład w Oracle (i języku PL/SQL) użytkownicy w zapytaniach typu INSERT i UPDATE mogą posłużyć się konstrukcją RETURNING ... INTO ..., dzięki czemu nie ma konieczności przełączania kontekstu między serwerem baz danych a aplikacją. Wykorzystanie jednej, specjalnej instrukcji zwracającej następną wartość w sekwencji i zapisanie jej w zmiennej programu (czyli wykorzystanie przełączenia kontekstu między aplikacją i bazą danych) może spowodować znaczący narzut na wydajności, stanowiący sporą część całkowitego czasu wykonania prostych i często wywoływanych transakcji. Gdy mamy do czynienia z dużą aktywnością transakcji, kluczowe jest, aby blokady zasobów nie były utrzymywane w przypadku operacji, które tego nie wymagają.
Blokady i operacje COMMIT Jeśli chcemy zminimalizować czas utrzymania blokady, będziemy zmuszeni do częstego wykonywania instrukcji COMMIT. Operacja ta, zatwierdzająca zmiany wprowadzone w transakcji, jest bardzo kosztowna, ponieważ wymaga zapisania w pamięci trwałej (dzienniku transakcji), co wiąże się z wykonaniem fizycznej operacji wejścia-wyjścia. Jeśli operacje COMMIT będą wykonywane po zakończeniu każdego logicznie niezależnego
322
ROZDZIAŁ DZIEWIĄTY
fragmentu pracy, może to spowodować znaczny narzut wydajności, co przedstawia rysunek 9.6. Rysunek przedstawia obniżenie wydajności wskutek wykonywania operacji COMMIT co 1, 2, 3… 12 wierszy wstawianych do tabeli, w przypadku bardzo szybkiej operacji wstawiania wierszy do tabeli przez pojedynczy proces użytkownika działający na zupełnie nieobciążonej maszynie. W zależności od rodzaju zapytania i liczby użytych w nim wierszy, wyniki mogą oczywiście być gorsze lub lepsze, ale tendencja pozostanie niezmienna. W programach aktualizujących duże porcje danych (z reguły wykonywanych wsadowo) czas wykonania procedury może być nawet trzykrotnie dłuższy niż w przypadku rzadszego wykonywania instrukcji COMMIT.
RYSUNEK 9.6. Wpływ częstotliwości operacji COMMIT na wydajność
W programach wsadowych, w których troska o równolegle wykonywane procesy nie ma większego znaczenia, nie ma większego sensu, by za często wykonywać operację COMMIT. Kłopot z transakcją składającą się z bardzo dużej liczby zmian, oprócz długotrwałego utrzymywania blokad, polega na konieczności zachowania przez cały czas starej wersji danych na wypadek wycofania zmian (ROLLBACK), co znacznie obciąża zasoby. Jeśli z jakiegokolwiek powodu transakcja zostanie przerwana, wycofanie zmian może trwać dłuższy czas. Istnieją dwie szkoły postępowania w tego typu sytuacji. Jedna z nich nakazuje wykonywanie operacji COMMIT co ustaloną liczbę wierszy, co skutkuje ustalonym, stałym kosztem z punktu widzenia zasobów systemowych i czasu niezbędnego na wycofanie zmian w przypadku błędu. Druga szkoła jest nieco bardziej „buńczuczna” i twierdzi, nie bez cienia
WALKA NA WIELU FRONTACH
323
racji, że zasoby systemowe służą jednemu celowi: realizacji wymogów biznesowych, nie odwrotnie. Zatem z faktu, że dzięki rzadszym operacjom COMMIT można uzyskać większą wydajność operacji modyfikujących — a ponadto jeśli sytuacje wycofania transakcji wskutek błędu są stosunkowo rzadkie i ich duża czasochłonność nie stanowi znaczących konsekwencji w systemie — wynika, iż warto zaryzykować, o ile wpłynie to na zwiększenie ogólnej wydajności. To podejście jest dodatkowo popierane takimi funkcjami silników baz danych, jak tryb „pass or break”, dzięki któremu unika się tworzenia danych zapasowych na wypadek wycofania transakcji. Ten tryb opiera się na założeniu, że w przypadku, gdy uruchomienie transakcji od początku nie stanowi większego problemu, nie ma sensu dbać o naprawianie tego, co się udało jedynie częściowo. Obydwa podejścia mają wady i zalety, a wybór odpowiedniego powinien być podejmowany w oparciu o wymogi odnośnie konkretnej sytuacji, a często wręcz ogólnych zasad przyjętych na potrzeby tworzonej aplikacji. Program wsadowy wykonujący operacje COMMIT w dużych odstępach czasu może powodować opóźnienia w wykonywaniu operacji przez interaktywnych użytkowników. Oczywiście interaktywni użytkownicy mogą również blokować wykonanie kolejnych etapów pracy programu wsadowego. Nawet w przypadku szczegółowości blokowania na poziomie wierszy w niektórych systemach baz danych stosowany jest mechanizm eskalacji blokad (w którym duża liczba blokad o większej precyzji jest zastępowana jedną blokadą na wyższym poziomie), co może doprowadzić do zawieszenia systemu. Nawet bez zastosowania mechanizmu eskalacji blokad pojedyncza, niezatwierdzona zmiana w ramach transakcji może zablokować pojedynczą operację modyfikującą dużą liczbę wierszy. Jedno jest pewne: współbieżność i programy wsadowe nie stanowią dobrej mieszanki i projektowanie obsługi transakcji należy rozważyć niezależnie dla procesów interaktywnych i wsadowych. Im większa jest liczba jednocześnie pracujących użytkowników, tym krótsze powinny być odstępy między operacjami COMMIT.
Blokady a skalowalność Przy porównaniu konsekwencji stosowania blokad na tabelach i pojedynczych wierszach mieliśmy okazję przekonać się, że blokady na poziomie wierszy
324
ROZDZIAŁ DZIEWIĄTY
zapewniają lepszą wydajność operacji. Jednak podobnie jak w przypadku blokowania całych tabel, krzywa wydajności przy pewnym poziomie współbieżności osiąga swój limit (to znaczy pułap, powyżej którego nie występuje dalsze zwiększenie wydajności), powyżej którego staje się zupełnie płaska. Czy wszystkie produkty zachowują się tak samo? Niezupełnie, co demonstruje rysunek 9.7.
RYSUNEK 9.7. Blokowanie na poziomie wierszy i współbieżność w trzech różnych systemach zarządzania bazami danych
Aby wiarygodnie porównać zachowanie różnych systemów wskutek zwiększenia poziomu współbieżności, niezależnie od prędkości pojedynczej operacji modyfikującej, wykonałem dwie serie aktualizacji danych w dużej tabeli: pierwsza seria dotyczyła szybkich aktualizacji z warunkiem wyboru wykorzystującym klucz główny, druga seria wykorzystywała powolne modyfikacje z warunkiem wyboru po nieindeksowanej kolumnie. Te modyfikacje były powtarzane w różnorodnych warunkach współbieżności i zliczana była całkowita liczba operacji wykonanych w jednostce czasu. W przypadku szybkich aktualizacji żaden z produktów nie wykazywał silnej zależności od liczby współbieżnych operacji podczas szybkich modyfikacji — zapewne dzięki nasyceniu wykorzystania zasobów sprzętowych. Jednak w takiej sytuacji warto zastanowić się nad tym, czy istnieje zaleta równoległego uruchamiania większej liczby operacji. Czy zwiększona
WALKA NA WIELU FRONTACH
325
współbieżność rekompensuje ograniczenie prędkości w jednostce czasu? Ten problem należy do tej samej kategorii, co pytanie: „Czy lepiej jest mieć serwer z jednym bardzo szybkim procesorem, czy z dużą liczbą stosunkowo powolnych procesorów”?. Rysunek 9.7 przedstawia współczynnik liczby powolnych modyfikacji do liczby szybkich modyfikacji wraz ze zwiększającą się liczbą jednocześnie wykonywanych operacji. Produkt DBMS1 wyróżnia się w tym porównaniu i to z dwóch powodów: • powolne operacje modyfikujące nie są aż tak powolne w porównaniu z powolnością „szybkich” operacji (stąd stosunkowo wysoki współczynnik w porównaniu z pozostałymi produktami), • jak pokazuje znacząca różnica między jedną a trzema współbieżnymi sesjami, powolne modyfikacje tracą na wydajności bardziej niż modyfikacje szybkie. Jednak nie na produkt DBMS1w rzeczywistości warto zwrócić uwagę. Nawet w przypadku blokowania wierszy we wszystkich systemach widzimy, że jeden z produktów, czyli DBMS3, skaluje się znacznie lepiej od pozostałych, ponieważ współczynnik znacznie się poprawia wraz ze wzrostem poziomu współbieżności. Ta obserwacja może mieć znaczący wpływ na dobór sprzętu i architektury. Produkty DBKS1 i DBMS2 zapewne większą korzyść uzyskają z szybszych procesorów, nie z ich większej liczby. Z punktu widzenia programu będą się lepiej zachowywać w przypadku wykorzystania niewielkiej puli sesji. Produkt DBMS3 będzie zachowywał się lepiej przy większej liczbie procesorów działających z tą samą prędkością i (do pewnego stopnia) z większej liczby współbieżnych sesji. W jaki sposób wyjaśnić tak spore różnice przy porównaniu systemu DBMS3 z pozostałymi? Najważniejsze są tu dwa czynniki: Nasycenie zasobów sprzętowych Ten czynnik prawdopodobnie wyjaśnia sytuację DBMS1, który działa doskonale z punktu widzenia bezwzględnej wydajności, ale po prostu nie jest w stanie „wycisnąć” niczego więcej z tego konkretnego sprzętu. Konkurowanie o zasoby Jak pamiętamy, we wszystkich porównywanych produktach mieliśmy do czynienia z tym samym poziomem szczegółowości mechanizmu
326
ROZDZIAŁ DZIEWIĄTY
blokad (na poziomie wierszy). Wykonywane były te same zapytania, a operacje COMMIT były wykonywane w tym samym momencie. Zatem należy stwierdzić, że wpływ na różnice wydajności współbieżnych operacji mają inne czynniki, nie tylko mechanizm blokad. Stosując analogię mechaniczną, można powiedzieć, że w przypadku DBMS1 i DBMS2 współczynnik tarcia jest większy. Tym tarciem jest właśnie konkurencja o zasoby. Współbieżność zależy od zabezpieczeń integralności danych, do których należą blokady oraz inne mechanizmy zależne od systemu zarządzania bazą danych.
Konkurowanie o zasoby Wiersze tabeli to nie jedyne zasoby, które nie mogą być współdzielone. Na przykład w przypadku modyfikacji wartości poprzednia (oryginalna) wartość musi zostać zapisana na wypadek konieczności wycofania operacji. W obciążonym systemie często występuje pewna forma współzawodnictwa między dwoma lub większą liczbą procesów próbujących do tej samej lokalizacji zapisać dane niezbędne do wycofania transakcji — nawet w przypadku, gdy obydwa procesy wykonują modyfikacje w zupełnie niezależnych obszarach różnych tabel. Tego typu sytuacja wymaga zastosowania jakiejś formy serializacji. Gdy zmiany są zatwierdzone (COMMIT) i zapisane do dziennika transakcji lub do buforów w pamięci przed fizycznym zapisem do pliku, musi istnieć proces kontrolujący, zapobiegający zapisaniu danych jednej transakcji „na wierzchu” danych innej. Opisane wyżej przykłady to wybrane zagadnienia konkurowania o zasoby. Mechanizm blokad, który stanowi sposób na kontrolę w sytuacjach konkurowania o zasoby, jest jednym z głównych czynników wyróżniających poszczególne architektury systemów zarządzania bazami danych, zatem nie ma ogólnej zasady oprócz tej, żeby blokady utrzymywać przez jak najkrótszy czas. Konkurowanie o zasoby to jednak problem powiązany bezpośrednio z implementacją na niskim poziomie i w celu zapobiegania temu zjawisku można podjąć szereg działań związanych z konfiguracją. Niektóre z tych działań mogą być wykonane przez inżynierów systemowych, na przykład rozkład plików tabel i dzienników transakcyjnych na różnych dyskach fizycznych. Administratorzy baz danych mogą być również
WALKA NA WIELU FRONTACH
327
pomocni w poprawianiu tej sytuacji, na przykład dzięki modyfikacji parametrów działania bazy danych i opcji fizycznego zapisu na dyskach. Również programiści mogą zarządzać problemem konkurowania o zasoby przez odpowiednie rozwiązania w aplikacjach. Aby zademonstrować, na jak wiele sposobów można radzić sobie z tym zjawiskiem, omówię jedną sytuację, najczęściej zauważaną z punktu widzenia problemu konkurowania o zasoby: wielokrotne, współbieżne operacje wstawiania danych do tabeli.
Wstawianie danych a konkurowanie o zasoby Za przykład weźmiemy 14-kolumnową tabelę z dwoma indeksami unikalnymi. Klucz główny jest zdefiniowany na kluczu sztucznym, generowanym systemowo, dodatkowo „naturalny” klucz złożony jest poddany ograniczeniu unikalności, oczywiście za pomocą unikalnego indeksu. Ten indeks składa się z krótkiego ciągu znaków i wartości typu znacznik czasu. Na takiej tabeli wykonam serię testów wykorzystujących operacje wstawiania przy zwiększającej się liczbie współbieżnych sesji. Rysunek 9.8 pokazuje, że mimo pracy w trybie blokowania wierszy zwiększanie liczby procesów wstawiających dane równolegle niewiele poprawia wydajność wstawiania danych w jednostce czasu. Rysunek pokazuje medianę oraz minimalne i maksymalne wartości dla dziesięciu jednominutowych przebiegów testowych dla każdego poziomu współbieżności. Jak widać, wyniki charakteryzują się dużymi rozbieżnościami, jednak zdecydowanie najlepsze zostały uzyskane przy czterech współbieżnie wykonywanych procesach (co, nieprzypadkowo, jest zbieżne z liczbą procesorów zainstalowanych w tym komputerze). Nie trzeba chyba podkreślać, że ten test w pełni wykorzystuje zasoby komputera. Jednak właściwie należy się zastanowić nad inną kwestią: czy zasoby sprzętowe są wykorzystane w sposób optymalny? W takim przypadku jak ten nie mamy za dużych możliwości wpłynięcia na funkcjonowanie mechanizmu blokowania, ponieważ nie ma tu sytuacji, gdy dwa różne procesy próbują uzyskać dostęp do tego samego wiersza. Jednak mimo to mamy do czynienia z konkurowaniem o dostęp do zasobów w postaci kontenerów danych. W tej konkretnej sytuacji
328
ROZDZIAŁ DZIEWIĄTY
RYSUNEK 9.8. Współbieżne sesje wstawiające dane do jednej tabeli
konkurowanie o zasoby może wystąpić w dwóch miejscach: w samej tabeli i w indeksie. Na poziomie systemowym mogą również wystąpić zdarzenia konkurowania o zasoby, ale one z reguły również wynikają z decyzji podjętych w ramach bazy danych. Konkurencja powoduje zużywanie mocy procesora, ponieważ każdy taki przypadek wymaga wykonania określonego kodu obsługi konfliktu wraz z pętlą oczekującą na zwolnienie zasobu zajętego przez inny proces. Czy istnieje możliwość zmniejszenia skutków zjawiska konkurowania o zasoby, a w konsekwencji zwolnienia kilku cykli procesora, które mogą być wykorzystane na obsłużenie większej liczby operacji wstawiania do tabeli? Test, który posłużył do przygotowania wykresu z rysunku 9.8, wykonałem w bazie Oracle, jednym z produktów oferujących bardzo szeroki wybór opcji pozwalających zmniejszyć skutki konkurowania o zasoby. Rozwiązanie tego typu problemów na poziomie bazy danych można podzielić na następujące kategorie: • rozwiązania na poziomie administracji, • rozwiązania na poziomie architektury, • rozwiązania na poziomie oprogramowania. W kolejnych punktach kolejno omówię każde z nich.
WALKA NA WIELU FRONTACH
329
Rozwiązania na poziomie administracji Administrator baz danych często posiada dość zdawkową wiedzę na temat procesów biznesowych. Rozwiązania na poziomie administracji bazą danych (DBA solutions) polegają na modyfikowaniu parametrów funkcjonowania samych kontenerów danych. Zmiany te są neutralne dla warstwy aplikacji (jednak ponieważ uzyskanie takiej neutralności w stu procentach jest niemożliwe, lepiej byłoby stwierdzić, że skutek tych zmian na warstwie aplikacji jest minimalny, z wyjątkiem przyspieszenia wydajności, które chcemy dzięki nim uzyskać). Istnieją dwa podstawowe obszary, w których administratorzy baz Oracle mają możliwość zmniejszenia uciążliwości zjawiska konkurowania procesów o zasoby: Przestrzeń transakcji Pierwsze rozwiązanie polega na modyfikowaniu liczby uchwytów dla transakcji (ang. transaction entry slot) zaalokowanych w blokach fizycznego zasobu dyskowego, w którym zapisana są tabele i ich indeksy. Taki uchwyt dla transakcji można postrzegać jako fizyczny zapis blokady na niskim poziomie. Bez wnikania w skomplikowane szczegóły dość stwierdzić, że „walka” właśnie o te uchwyty stanowi największą liczbę przypadków konkurowania o zasoby między współbieżnymi sesjami próbującymi zapisać dane w tym samym bloku. Administrator może usprawnić działanie tego typu operacji, zwiększając obszar przeznaczony na zarządzanie transakcjami. Jedyny skutek z punktu widzenia reszty systemu jest taki, że w ramach bloku zmniejsza się wówczas ilość miejsca na same dane i indeksy, co może prowadzić bezpośrednio do konieczności odczytania większej liczby bloków w celu uzyskania tych samych wyników, szczególnie widocznej w przypadku pełnego przeszukiwania tabeli, ale również (w mniejszym stopniu) przy przeszukiwaniu z użyciem indeksów. Listy wolnych bloków Drugie rozwiązanie pozostające w gestii administratora polega na zmuszeniu operacji zapisu do wykorzystania różnych bloków, co stanowi pewną formę kontroli nad mechanizmem zarządzania fizycznymi kontenerami danych. W przypadku każdej tabeli baza danych Oracle przechowuje jedną lub większą liczbę list bloków, do
330
ROZDZIAŁ DZIEWIĄTY
których mogą być wstawiane nowe wiersze. W domyślnej konfiguracji dla każdej tabeli zdefiniowana jest tylko jedna lista, ale jeśli zostanie określona większa ich liczba, każdy wstawiany wiersz będzie zapisywany w bloku wskazanym przez kolejną listę, a są one przełączane cyklicznie. To rozwiązanie nie jest tak neutralne, jak zwiększenie przestrzeni na zarządzanie transakcjami, ponieważ wpływa bezpośrednio na pogorszenie uporządkowania danych, a jak wiemy, najlepsze efekty przy przeszukiwaniu zakresowym uzyskuje się z tabeli klastrowanej z użyciem indeksu. W ten sposób, usprawniając wydajność operacji zapisu, możemy znacznie stracić na wydajności operacji odczytu.
Rozwiązania na poziomie architektury Rozwiązania na poziomie architektury opierają się na modyfikacji fizycznego uporządkowania danych z użyciem mechanizmów silnika bazy danych. Te zmiany mogą mieć znaczący wpływ na wydajność innych operacji wykonywanych w bazie. Najczęściej stosowane są trzy rozwiązania na poziomie architektury: Partycjonowanie Partycjonowanie zakresowe jest w tym przypadku bezużyteczne, ponieważ naszym celem jest rozproszenie działań związanych z modyfikowaniem danych w tabeli, chyba że każdy proces aktualizuje dane z innego zakresu danych (np. miesiąca). W takim przypadku można przydzielić każdy proces do jednej partycji, ale w naszym przykładzie taka sytuacja nie ma miejsca. Może jednak się sprawdzić partycjonowanie typu hash. Jeśli wartość hash obliczymy na podstawie wartości klucza głównego (sekwencji), kolejne wartości będą zapisywane w sposób zupełnie losowy w różnych partycjach danych. Niestety partycjonowanie tego typu narzuca pewne ograniczenia odnośnie indeksu używanego do partycjonowania, zatem w tym przypadku możemy liczyć jedynie na zmniejszenie konkurencji na poziomie tabeli. Co więcej, tego typu podejście powoduje, że dane są uporządkowane w sposób zupełnie losowy, co stanowi przeciwieństwo klastrowania, zatem z pewnością należy liczyć na obniżenie wydajności innych zapytań.
WALKA NA WIELU FRONTACH
331
Odwrócony indeks W rozdziale 3. mieliśmy okazję przekonać się, że odwrócenie kolejności bajtów w kluczu indeksu powoduje rozproszenie w obrębie drzewa indeksu tych elementów, które w zwykłym indeksie wypadają blisko siebie. To jest dobry sposób na uniknięcie konkurowania o zasoby na poziomie indeksu (ale nie ma żadnego wpływu na konkurowanie na poziomie tabeli). Wada tego podejścia polega na tym, że użycie indeksu odwróconego zapobiega wykorzystaniu indeksu przy przeszukiwaniu zakresowym. Tabela zorganizowana w formie indeksu Uporządkowanie tabeli w formie indeksu eliminuje zupełnie jedno źródło zjawiska konkurowania o zasoby. Nie rozwiązuje jednak w ogóle problemu z drugim źródłem, ale dzięki temu podejściu zamiast dwóch punktów zapalnych, gdzie procesy konkurują o zasoby na poziomie tabeli i na poziomie indeksu, mamy do opanowania tylko jeden.
Rozwiązania na poziomie oprogramowania Rozwiązania na poziomie oprogramowania są jedyną drogą do radzenia sobie z problemem konkurowania o zasoby, dostępną programiście, i nie wymagają modyfikacji fizycznej struktury bazy danych. Programista ma dostępne dwa rozwiązania: Udoskonalenie współbieżności Próba zmiany liczby współbieżnych procesów dowodzi, że przy czterech procesach system osiąga optymalną wydajność, a dalsze zwiększanie liczby sesji nie poprawia wydajności. Nie ma sensu przydzielać dziesięciu osobom zadania, które cztery osoby wykonają w sposób idealny, co więcej, koordynacja stanie się bardziej skomplikowana, a niektóre proste zadania składowe będą wykonane szybciej, niż się tego oczekuje. Rysunek 9.8 pokazał, że zwiększanie liczby sesji poza możliwości sprzętowe jest w większości przypadków bezcelowe. Dlatego zoptymalizowanie ich liczby w rzeczywistości zmniejszy obciążenie systemu. Unikanie wartości generowanych przez system Czy rzeczywiście potrzebujemy sztucznych wartości klucza głównego? Nie zawsze. Wartości sekwencyjne są przydatne wówczas, gdy planujemy wykonywać operacje zakresowe z użyciem zakresu wartości klucza,
332
ROZDZIAŁ DZIEWIĄTY
ponieważ będziemy mieli możliwość zastosowania operatora > lub between. Jeśli jednak potrzebujemy jedynie unikalnego identyfikatora, który może być użyty jako klucz obcy w tabelach podrzędnych, po co nam operacje zakresowe? Rozważmy alternatywę: po prostu zastosujmy liczby losowe z możliwością ponownego wygenerowania w przypadku natrafienia na już zajętą.
Efekty Na rysunku 9.9 można zaobserwować zmiany wydajności operacji wstawiania uzyskane dzięki zastosowaniu opisanych wyżej metod.
RYSUNEK 9.9. Efekty zastosowania różnych taktyk ograniczenia zjawiska konkurowania o zasoby
W tym przypadku ponownie mamy do czynienia z dużą różnorodnością wyników (każdy test został wywołany dziesięć razy). Nie można stwierdzić ponad wszelką wątpliwość, że technika, która szczególnie dobrze sprawdza się w tym przypadku, będzie idealna w innym, ani odwrotnie: że technika, która daje niezadowalające efekty w tej sytuacji, może sprawdzić się w innych okolicznościach. Niemniej jednak wyniki są interesujące. Techniki administratorskie dały pozytywne rezultaty, ale niezbyt spektakularne. Modyfikacje na poziomie architektury w tym konkretnym przypadku okazały się zupełnie nieefektywne. Warto wspomnieć, że nasze dwa indeksy wymuszają unikalność kluczy, co znacznie zmniejsza ilość dostępnych opcji ich modyfikacji. Z tego powodu niektóre techniki
WALKA NA WIELU FRONTACH
333
minimalizacji zjawiska konkurencji na poziomie tabeli nie będą poprawiały sytuacji na poziomie indeksów, gdzie można się spodziewać większości przypadków jego występowania. Tego typu sytuacja jest typowa w tabelach zorganizowanych w postaci indeksu, w których zjawisko konkurencji na poziomie tabeli jest zupełnie wyeliminowane dzięki wyeliminowaniu istnienia tabeli, ale, niestety, fakt zapisu wszelkich danych w samym indeksie powoduje, że zjawisko konkurencji na poziomie indeksu jest większe i może przesłonić korzyści uzyskane z wyeliminowania tabeli. W tej sytuacji mamy okazję przekonać się, że najbardziej pożądanym zasobem sprzętowym jest procesor. To spostrzeżenie od razu sugeruje, że nieskuteczne będą rozwiązania powodujące wzmożone wykorzystanie procesora, jak obliczanie wartości hash czy odwracanie bajtów w kluczach indeksu. Natomiast wartości losowe dają zarówno najlepsze, jak i najgorsze wyniki. W najgorszym przypadku losowe wartości klucza (liczby całkowite) były generowane w przedziale od 1 do dwukrotnej liczby wierszy wstawianych do tabeli w ramach testu. W efekcie okazywało się, że wiele generowanych wartości klucza już występowało w tabeli, co powodowało błąd ładowania wartości do tabeli i konieczność powtórzenia losowania klucza i próby zapisu wiersza. To było oczywiste marnotrawstwo czasu, które w efekcie powodowało nadmierne użycie zasobów. Dodatkowo z faktu, że duplikat klucza głównego jest wykrywany przy próbie wpisania klucza do indeksu, oraz z faktu, że w indeksie zapisywany jest fizyczny adres wiersza, wynika, iż wykrycie duplikatu następuje po zapisaniu wiersza w tabeli, co zmusza system do wycofania operacji, stąd dodatkowy koszt. Najlepszy przypadek wykorzystywał losowy klucz główny — jednak z zakresu stukrotnie większego od zastosowanego w najgorszym przypadku. Przyrost wydajności jest zdumiewający. Jednak z faktu, że dziesięć współbieżnych sesji nie daje lepszych rezultatów niż cztery, nie wynika, jakie będą wyniki testów dla tych czterech sesji? Wyniki przedstawia rysunek 9.10. Co ciekawe, wszystkie prezentowane techniki dają znacząco lepsze efekty, choć ranking wypada identycznie (względne różnice wydajności są praktycznie takie same). Porównanie wyników przedstawionych na rysunkach 9.9 i 9.10 nasuwa kilka interesujących spostrzeżeń:
334
ROZDZIAŁ DZIEWIĄTY
RYSUNEK 9.10. Efekty zastosowania taktyk ograniczenia zjawiska konkurowania o zasoby przy mniejszej liczbie współbieżnych sesji
• W naszym studium przypadku wąskim gardłem jest indeks klucza głównego tabeli. Techniki zmierzające do zmniejszenia skutków konkurowania o zasoby tabeli (partycjonowanie typu hash, implementacja tabeli w formie indeksu) nie dają efektów, co więcej, zapis tabeli w formie indeksu powoduje zmniejszenie wydajności w porównaniu do zastosowania zwykłej tabeli i zwykłego klucza głównego. Z kolei techniki redukujące zjawisko konkurowania o zasoby tabeli i indeksu jednocześnie (jak zwiększenie przestrzeni na potrzeby zarządzania transakcjami) lub do usprawnienia sytuacji wyłącznie na poziomie indeksu (odwrócone indeksy, losowy klucz sztuczny) dają pozytywne efekty. • Porównanie dziesięciu sesji z czterema sesjami pokazuje, że niektóre z demonstrowanych technik potrzebują większej ilości zasobów procesora, co w przypadku maszyny pracującej na granicy wydajności nie daje żadnego usprawnienia. • Najlepszy sposób na uniknięcie zjawiska konkurowania o zasoby polega na uniknięciu stosowania sztucznego klucza głównego! Zamiast zastanawiać się nad sposobem zwiększenia wydajności z użyciem różnych „sztuczek”, warto zastanowić się, jak wiele na wydajności traci się przez samo zastosowanie sekwencyjnego klucza głównego. Sam fakt występowania zjawiska konkurowania o zasoby klucza głównego
WALKA NA WIELU FRONTACH
335
powoduje, że wydajność ze stu osiemdziesięciu wstawień na minutę spada do stu, czyli prawie o połowę! Wniosek jest tu czytelny: lepiej unikać sztucznych kluczy głównych typu autoincrement tam, gdzie nie są potrzebne, jak na przykład w tabelach, do których odwołują się inne tabele lub w tabelach nieposiadających odpowiedniego naturalnego klucza głównego. Czy zatem można polecić stosowanie losowych sztucznych kluczy głównych? Różnica wydajności między kluczem wygenerowanym z odpowiednio dużego zakresu a kluczem wygenerowanym ze zbyt wąskiego zakresu demonstruje, że przy chwili nieuwagi z bardzo wydajnej ta technika może szybko przeobrazić się w bardzo mało wydajną przy liczbie wierszy w tabeli przekraczającej jedną setną zakresu, z którego losowany jest klucz. Generowanie wartości całkowitych z zakresu od miliarda do dwóch (typowy zakres wartości typu integer) może zatem stanowić zagrożenie dla tabel o dużej liczbie wierszy. Niestety, tabele, do których wiersze są dodawane w dużym tempie, mają skłonność do szybkiego zwiększania swoich rozmiarów. Jednak w przypadku, gdy system obsługuje 64-bitowe liczby całkowite, wybór losowego klucza głównego może być bardzo atrakcyjny, o ile rzeczywiście potrzebuje się sztucznego klucza głównego. W przeciwieństwie do blokad zasobów, zjawisko konkurowania o zasoby można „okiełznać” dzięki zastosowaniu szeregu technik. Architekci, programiści i administratorzy baz danych mają do dyspozycji szereg narzędzi zmniejszających skutki konkurowania o zasoby.
336
ROZDZIAŁ DZIEWIĄTY
ROZDZIAŁ DZIESIĄTY
Gromadzenie sił Obsługa dużych ilości danych Thenne entryd in to the bataylle lubance a geaunt and fought and slewe doune ryght and distressyd many of our knyghtes. Wtedy do bitwy włączył się wielkolud Jubance, ciął i rżnął od ucha i położył wielu naszych rycerzy — Sir Thomas Malory (d. 1471) Śmierć Artura, V, 11
338
T
ROZDZIAŁ DZIESIĄTY
en rozdział omawia problemy wynikające ze znacznego zwiększania się rozmiarów danych. Do tego typu wyzwań należy efektywne przeszukiwanie wielkich tabel, ale również unikanie znacznego zmniejszania wydajności wynikającego z niezbyt dramatycznego zwiększenia rozmiaru danych. Na początek przyjrzymy się uogólnionym skutkom zwiększania rozmiaru danych i liczby wierszy. Następnie przeanalizujemy sposoby obsługi dużej ilości danych w specjalizowanych środowiskach, jak hurtownie danych i systemy wspierania decyzji.
Zwiększanie się ilości danych W niektórych aplikacjach rozmiar danych zwiększa się z czasem w dość poważnych proporcjach. W szczególności dotyczy to aplikacji, w przypadku których istnieje konieczność przechowywania i bezpośredniego dostępu do danych historycznych z kilku miesięcy, a nawet lat. Dane historyczne są nieaktywne, to znaczy służą jedynie celom informacyjnym, ale mogą powodować problemy z obsługą codziennych czynności wykonywanych przez użytkowników. W przypadku zupełnie nowego projektu rozmiar danych z reguły zmienia się zgodnie z wykresem z rysunku 10.1. Na początku w bazie danych znajdują się jedynie dane referencyjne w niewielkich ilościach. W momencie, gdy nowy system zastępuje poprzedni, dane odziedziczone po poprzednim są ładowane do nowego, nierzadko jest to dość problematyczny proces. Powodem tych kłopotów są często radykalne zmiany w koncepcji działania systemu, co powoduje, że konwersja danych ze starego formatu do nowego nie zawsze bywa bezbolesna. W sytuacji permanentnego niedoczasu, kiedy terminy gonią i dochodzi do sytuacji, gdy mniej krytyczne zadania są odkładane na później, stare dane często stają się pierwszą „ofiarą”. W wyniku takich decyzji odtworzenie starych danych odbywa się jakiś czas po premierze systemu, gdy wszystko już działa w trybie produkcyjnym i gdy uznano, że wszelkie problemy zostały rozwiązane. Druga przyczyna problemów ze starymi danymi bierze się stąd, że poprzedni system z reguły posiadał znacznie mniej funkcji od nowego (w przeciwnym razie koszt powstania nowego systemu mógłby być uznany za nieuzasadniony). W praktyce oznacza to, że rozmiar danych w starym systemie jest dość niewielki w porównaniu z danymi obsługiwanymi przez nowy system, a obejmujące wiele miesięcy stare dane mogą mieć rozmiar równy nowym danym z kilku tygodni, gromadzonym przez zaimplementowany system.
GROMADZENIE SIŁ
339
RYSUNEK 10.1. Ewolucja rozmiarów danych w nowym systemie
W międzyczasie dane operacyjne puchną. Z reguły pierwszy poważny problem z wydajnością pojawia się mniej więcej w połowie rozmiaru uznawanego za optymalny do pracy z nowym systemem. Błędne zapytania i nieoptymalne algorytmy są najtrudniejsze do wychwycenia przez użytkownika w okresie, gdy rozmiary danych są stosunkowo niewielkie. Surowa moc sprzętu maskuje gigantyczne błędy i może zapewniać komfort czasów reakcji poniżej sekundy nawet przy pełnym przeszukiwaniu tabel zawierających do kilkuset tysięcy wierszy danych. W takim przypadku sprzęt jest niewłaściwie wykorzystywany, ponieważ błędy programistyczne są rekompensowane przez brutalną siłę, ale nikt tego nie zauważy do momentu, gdy zgromadzone dane osiągną solidne rozmiary. W pierwszej sytuacji kryzysowej istnienia nowego systemu z reguły dochodzi do „precyzyjnego konfigurowania” systemu z pomocą ekspertów, co polega najczęściej na dodaniu kilku indeksów, które w rzeczywistości powinny znaleźć się na swoim miejscu już na etapie projektowania. Następnie system działa zadowalająco aż do momentu, gdy dane osiągną oczekiwane rozmiary. Z reguły określone są dwa „rozmiary oczekiwane”: nominalny (znacznie przeszacowany i określający oficjalny limit wydajności
340
ROZDZIAŁ DZIESIĄTY
systemu) oraz rzeczywisty (to znaczy taki, jaki jest rzeczywiście obsługiwany przez system, ale prędko ulega przekroczeniu z powodu danych historycznych pochodzących ze starego systemu, które nie zostały uwzględnione w pierwszych etapach projektu). Drugi i znacznie poważniejszy kryzys nadchodzi wówczas, gdy dane osiągają rzeczywisty rozmiar oczekiwany. Gdy dane archiwalne zostały załadowane do systemu, zostały usunięte słabe punkty architektury, a niektóre kluczowe procesy napisane od nowa — wówczas następuje typowy etap życia projektu: naturalny przyrost danych związany ze wzrostem biznesu. Wzrostem, który może być linowy i spokojny, ale czasem bywa też wykładniczy i nieokiełznany. Opisane pierwsze chwile życia systemu są w pewnym sensie karykaturą, ale podejrzewam, że mają więcej wspólnego z rzeczywistością, niż moglibyśmy sądzić, ponieważ często zdarza się popełniać błędy prowadzące do tej karykatury. Niezależnie od tego, jak skrupulatnie będziemy pracować, nie da się uniknąć wszystkich błędów — z powodu presji czasu, braku możliwości przeprowadzenia wszystkich niezbędnych testów i niejednoznacznych specyfikacji. Jedynym typem błędów prowadzących do sytuacji bez wyjścia są te popełnione na etapie projektowania bazy danych i globalnej architektury aplikacji. Te dwa zagadnienia są bezpośrednio powiązane i stanowią fundament systemu. Jeśli fundament nie okaże się wystarczająco trwały, przebudowa musiałaby się zacząć od zburzenia całości. Inne błędy wiążą się z dokonywaniem poprawek (mniej lub bardziej dogłębnych) w obrębie tego, co istnieje, bez konieczności dokonywania większych zniszczeń. Większości kryzysów można jednak uniknąć. Przy tworzeniu systemu należy spodziewać się zwiększania rozmiarów danych. Należy też szybko zidentyfikować i przepisać te zapytania, których wydajność będzie podatna na obniżenie w przypadku zwiększenia rozmiarów danych.
Wpływ zwiększania rozmiarów danych na wydajność działań Wszystkie operacje w SQL-u są w różnym stopniu podatne na zwiększanie liczby przetwarzanych wierszy. Niektóre działania w SQL-u nie są w ogóle podatne na skutki zwiększania rozmiaru danych, w innych wydajność będzie zmniejszać się liniowo, a w innych spadnie dramatycznie.
GROMADZENIE SIŁ
341
Brak podatności na skutki zwiększenia danych Z reguły w operacjach wyszukiwania z użyciem klucza głównego nie powinno być większych konsekwencji w wyniku zwiększania rozmiaru danych. Gdy wyszukujemy jedną pozycję z tysiąca, wydajność powinna być porównywalna do wyszukiwania jednej pozycji z miliona. Typowe indeksy typu B-tree to dość płaskie i wydajne struktury i rozmiar tabeli nie ma większego znaczenia w operacji wyszukiwania pojedynczego wiersza z użyciem klucza głównego. Jednak wyszukiwanie pojedynczych elementów to nie jedyne operacje wykonywane z użyciem języka SQL. Przeszukiwanie zakresowe zwracające większą liczbę wierszy powoduje, że transakcyjne, jednowierszowe operacje nie są najwydajniejszym sposobem realizacji tego zadania. Weźmy pod uwagę następujące (dość sztuczne) przykłady z użyciem bazy danych Oracle, demonstrujące przeszukiwanie zakresowe z użyciem klucza głównego opartego na sekwencji wartości: SQL> declare 2 n_id number; 3 cursor c is select customer_id 4 from orders 5 where order_id between 10000 and 20000; 6 begin 7 open c; 8 loop 9 fetch c into n_id; 10 exit when c%notfound; 11 end loop; 12 close c; 13 end; 14 / PL/SOL procedure successfully completed. Elapsed: 00:00:00.27 SQL> declare 2 n_id number; 3 begin 4 for i in loooo .. 20000 5 loop 6 select customer_id 7 into n_id 8 from orders 9 where order_id = i;
342
ROZDZIAŁ DZIESIĄTY
10 end loop; 11 end; 12 / PL/SOL procedure successfully completed. Elapsed: 00:00:00.63
Kursor z pierwszego przykładu, wykonujący jawne przeszukiwanie zakresowe, będzie działał dwukrotnie szybciej niż iteracja wiersz po wierszu. Dlaczego? Istnieje wiele powodów natury technicznej (jedną z nich jest „miękka analiza leksykalna”, czyli fakt rozpoznania zapytania przez silnik bazy danych, który stwierdza, że już wie, co z nim począć), ale najważniejszą przyczyną jest fakt, że w pierwszym przykładzie indeks B-tree jest przeszukiwany tylko raz, po czym wykorzystywana jest uporządkowana lista indeksów zakwalifikowanych przez zapytanie, za pomocą których są pobierane wartości z tabeli. W drugim przykładzie indeks B-tree jest przeszukiwany dla każdej poszukiwanej wartości z kolumny order_id. Jak zatem widać, najwydajniejszy sposób przetwarzania większej liczby wierszy nie polega na sekwencyjnym wykonywaniu serii operacji na pojedynczych wierszach.
Liniowa podatność na skutki zwiększenia danych Użytkownicy z reguły są w stanie zaakceptować sytuację, gdy dwukrotnie większa liczba wierszy wynikowych jest okupiona dwukrotnie dłuższym czasem oczekiwania — wiele operacji SQL będzie wykonywało się w dwukrotnie dłuższym czasie, jednak bez jasno widocznych dla użytkownika przyczyn takiego stanu (jak w przypadku pełnego przeszukiwania tabeli zwracającego wiersze jeden po drugim). Weźmy pod uwagę funkcje agregujące. Obliczenie agregatu max() zwracającego pojedynczy wiersz wynikowy wymaga od bazy danych wykonania liczby operacji odpowiedniej dla rozmiaru danych w tabeli i może być różne w różnych etapach życia aplikacji. To oczywiste, ale użytkownik końcowy zawsze widzi jeden wiersz wyniku, zatem będzie skłonny do narzekania w sytuacji wydłużania się czasu oczekiwania na ów wynik. Jedyny sposób zadbania o to, żeby sytuacja z nie najlepszej nie zmieniła się w jeszcze gorszą, polega na górnym ograniczeniu liczby przetwarzanych wierszy z użyciem innych kryteriów, jak zakres dat. W przypadku funkcji max() pomysł może polegać na określeniu daty początkowej poszukiwanej wartości maksymalnej,
GROMADZENIE SIŁ
343
niekoniecznie zbieżnej z początkową datą danych zapisanych w bazie. Dodatkowe kryterium zapytania to nie tylko zagadnienie techniczne, musi być ono oczywiście zależne od wymogów biznesowych. Z tego powodu tego typu ograniczenie zakresu przeszukiwania danych powinno być uzgodnione z osobami opracowującymi specyfikacje aplikacji.
Nieliniowa podatność na skutki zwiększenia danych Zwiększenie rozmiarów danych w większym stopniu wpływa na obniżenie wydajności operacji wykorzystujących sortowanie niż operacji, które po prostu przeszukują tabele. Sortowanie to skomplikowana operacja i wymaga większej liczby przebiegów. Sortowanie stu losowo rozrzuconych danych nie jest dziesięć razy bardziej kosztowne niż sortowanie dziesięciu wartości, jak można by się naiwnie spodziewać, ale około dwadzieścia razy, natomiast sortowanie tysiąca wierszy jest około trzysta razy bardziej kosztowne od sortowania dziesięciu wierszy. W rzeczywistości jednak wiersze rzadko bywają rozrzucone w sposób zupełnie losowy, nawet w przypadku, gdy nie są zastosowane techniki uporządkowania danych zgodnie z indeksami, jak klastrowanie (zobacz rozdział 5.). System zarządzania bazami danych może czasem używać posortowanych indeksów do odczytywania danych sortowanych od razu w odpowiedniej kolejności, zamiast sortować je po ich pełnym odczytaniu, co powoduje, że przy większych zbiorach danych obniżenie wydajności ich sortowania nie jest szokujące, choć z pewnością wyraźne. Należy jednak zachować ostrożność. Utrata wydajności zapytań z winy operacji sortowania z reguły jest drastyczna, ponieważ niewielkie zbiory wynikowe są sortowane w pamięci, natomiast przy większych porcjach danych zbiór wynikowy tworzy się z kilku podzbiorów wstępnie sortowanych w pamięci, po czym zapisanych w tymczasowym zasobie pamięci nieulotnej, a na końcu łączonych w całość. Z tego powodu przejście z szybkiego sortowania w pamięci do sortowania w pamięci z użyciem tymczasowego zasobu dyskowego będzie dość dotkliwe z punktu widzenia wydajności. W sytuacji, gdy zbiory danych znajdują się „na styku” rozmiaru kwalifikującego się do sortowania w pamięci lub już do wykorzystania tymczasowego zapisu na dysku, można zastosować modyfikacje parametrów odpowiedzialnych za ten limit, dzięki czemu można nieco oddalić widmo znacznego spowolnienia operacji.
344
ROZDZIAŁ DZIESIĄTY
Rysunek 10.2 przedstawia, w jaki sposób wydajność odczytu danych (liczba wierszy odczytanych w jednostce czasu) zmienia się wraz ze zwiększaniem się rozmiarów tabeli. Tabela użyta w testach to prosta tabela orders zdefiniowana następująco: order_id customer_id order_date order_shipping order_comment
bigint(20) (klucz główny) bigint(20) datetime char(1) varchar(50)
RYSUNEK 10.2. Wpływ zwiększania się tabeli źródłowej na wydajność prostych zapytań
Zapytania składają się z prostej selekcji z użyciem klucza głównego: select order_date from orders where order_id = ?
po czym dane są sortowane w prosty sposób: select customer_id from orders order by order_date
GROMADZENIE SIŁ
345
następnie grupowane: select customer_id, count(*) from orders group by customer_id having count(*) > 3
po czym następuje wybór wartości maksymalnej z nieindeksowanej kolumny: select max(order_date) from orders
a na końcu następuje wybór „najlepszych pięciu” klientów w oparciu o liczbę zamówień: select customer_id from (select customer_id, count(*) from orders group by customer_id order by 2 desc) as sorted_customers limit 5
(w przypadku bazy MS SQL Server zamiast limit 5 na końcu zapytania należy wpisać select top 5 na jego początku, natomiast w Oracle zamiast limit 5 należy wpisać where rownum <= 5). W testach liczba wierszy w tabeli zmienia się w granicach od ośmiu tysięcy do miliona, natomiast liczba unikalnych wartości customer_id jest stała i wynosi około trzech tysięcy. Jak widać na rysunku 10.2, wyszukiwanie po kluczu głównym działa porównywalnie zarówno dla ośmiu tysięcy, jak i dla miliona pozycji. Przy większej liczbie wierszy pojawia się co prawda niewielkie obniżenie wydajności, lecz zapytanie jest na tyle szybkie, że ten efekt jest ledwo zauważalny. Jednak operacja sortowania to już inna sprawa. Wydajność (mierzona w liczbie wierszy zwróconych z zapytania w jednostce czasu, a przez to niezależna od rzeczywistej liczby wierszy w tabeli) podzapytania sortującego spada o 40% przy milionie pozycji (w stosunku do tabeli liczącej osiem tysięcy wierszy). Obniżenie wydajności jest jeszcze bardziej zauważalne w przypadku wszystkich zapytań, które zwracają tę samą liczbę zagregowanych wierszy, ale po drodze muszą odczytać zupełnie różne liczby wierszy, z których jest wyliczany wynik. Tego typu zapytania z reguły najczęściej stają się przyczyną niezadowolenia użytkowników z obniżającej się wydajności systemu. Należy jednak zauważyć, że w naszym teście system zarządzania bazami danych nie spisuje się najgorzej: obniżenie wydajności jest prawie
346
ROZDZIAŁ DZIESIĄTY
proporcjonalne do liczby wierszy, nawet w przypadku zapytań wymagających sortowania (zapytania zatytułowane GROUP BY i TOP na rysunku 10.2). Użytkownicy końcowi jednak widzą tylko to, że zapytanie nadal zwraca tyle samo danych, a jedyne, co zmieniło się w ich percepcji, to fakt, że teraz dzieje się to znacznie wolniej niż kiedyś. Nie wszystkie operacje na bazie danych są tak samo czułe na zwiększanie się rozmiarów danych. W projektowaniu systemu należy przyjąć założenie dotyczące wydajności zapytań na docelowych rozmiarach danych.
Wszystko naraz Główny problem z oszacowaniem zachowania zapytania przy zwiększonych rozmiarach danych polega na tym, że zwiększona podatność na obniżenie wydajności może być „zagrzebana” głęboko w zapytaniu. Zapytanie, które wyszukuje „aktualną wartość” elementu, wykonując podzapytanie, które poszukuje ostatniej zmiany ceny, po czym wykonuje agregację max() na historii cen, można uznać za bardzo podatne na obniżenie wydajności wskutek zwiększania rozmiarów danych. Jeśli będzie akumulowana znaczna ilość zmian cen, prawdopodobne obniżenie wydajności wystąpi w podzapytaniu, jak również w zapytaniu zewnętrznym. Obniżenie wydajności będzie znacznie mniej intensywne w przypadku podzapytania nieskorelowanego, wykonywanego tylko raz, niż w przypadku podzapytania skorelowanego, wykonywanego dla każdego wiersza biorącego udział w zapytaniu głównym. Tego typu obniżenie wydajności może być niezauważalne w operacji jednorazowej, ale w programach wsadowych różnica czasu wykonania może okazać się znaczna. UWAGA Sytuacja będzie zupełnie inna w przypadku, gdy na przykład zechcemy śledzić bieżący stan zamówień w systemie, ponieważ agregat max() będzie wykonany na niewielkiej liczbie możliwych stanów. Nawet w przypadku, gdy liczba zamówień zostanie podwojona, funkcja agregująca max() będzie za każdym razem wykonywana na podobnej liczbie wierszy w zamach zamówienia.
Kolejnym problemem jest sortowanie. Jak widzieliśmy, zwiększenie liczby sortowanych wierszy może spowodować dość nieoczekiwanie drastyczne obniżenie wydajności. W rzeczywistości przyczyna tego stanu nie wynika bezpośrednio z liczby sortowanych wierszy, a raczej z ich rozmiaru w bajtach,
GROMADZENIE SIŁ
347
innymi słowy, z wielkości sortowanych danych. Z tego powodu złączenia z danymi czysto informacyjnymi, jak czytelne dla użytkownika etykiety objaśniające znaczenie nieczytelnych danych (w przeciwieństwie do danych wykorzystywanych jako warunki filtrowania), powinny odbywać się w ostatnich etapach działania zapytania. Weźmy prosty przykład demonstrujący, dlaczego niektóre złączenia należy odkładać do jak najpóźniejszych faz działania zapytania. Odczytanie nazwisk i adresów dziesięciu najlepszych klientów z ostatniego roku wymaga złączenia tabel orders i order_details, za pomocą którego zgromadzimy informacje o liczbie zamówionych artykułów; niezbędne jest również złączenie z tabelą customers, skąd odczytamy dane osobowe. Jeśli chcemy uzyskać informacje o dziesięciu najlepszych klientach, musimy przejrzeć wszystkie rekordy zawierające informacje o zakupach (z zeszłego roku), posortować je malejąco po liczbie zamówień i ograniczyć wynik do pierwszych dziesięciu wierszy. Jeśli już na początku zapytania dokonamy pełnego złączenia danych, wraz z informacją o liczbie zamówień będziemy sortować nazwiska i adresy wszystkich klientów, którzy kupili cokolwiek w zeszłym roku. Nie chcemy jednak pracować na tak wielkich ilościach danych. Zatem powinniśmy zadbać o to, aby zminimalizować ilość sortowanych danych — pozostawimy więc identyfikator i ilość zamówień. Po posortowaniu tych danych i odcięciu dziesięciu pozycji z tabeli customer_ids możemy dokonać ostatniego złączenia z tabelą customers, dzięki czemu uzyskamy interesujące nas informacje. Innymi słowy, unikamy zapytania o następującej składni: select * from (select c.customer_name, c.customer_address, c.customer_postal_code, c.customer_state, c.customer_country sum(d.amount) from customers c, orders o, order_detail d where c.customer_id = o.customer_id and o.order_date >= wyrażenie and o.order_id = d.order_id group by c.customer_name, c.customer_address, c.customer_postal_code,
348
ROZDZIAŁ DZIESIĄTY
c.customer_state, c.customer_country order by 6 desc) as A limit 10
Interesuje nas natomiast zapytanie tego typu: select c.customer_name, c.customer_address, c.customer_postal_code, c.customer_state, c.customer_country b.amount from (select a.customer_id, a.amount from (select o.customer_id, sum(d.amount) as amount from orders o, order_detail d where o.order_date >= wyrażenie określające czas and o.order_id = d.order_id group by o.customer_id order by 2 desc) as a limit 10) as b, customers c where c.customer_id = b.customer_id order by b.amount desc
Drugie sortowanie jest wykonywane z uwagi na to, że złączenie może zmienić kolejność wierszy z wewnętrznego podzapytania (jak pamiętamy, teoria relacyjna nie zajmuje się zagadnieniami uporządkowania elementów, zatem silnik bazy danych nie ma obowiązku zachowywać porządku danych, o ile optymalizator uzna, że zmiana kolejności danych pozwoli mu na szybsze uzyskanie wyników). Zamiast jednego sortowania mamy dwa, ale wewnętrzne sortowanie odbywa się na „węższych” danych (dwie kolumny), natomiast zewnętrzne będzie wykonane tylko na dziesięciu wierszach. Jak pamiętamy z rozdziału 4., zawsze należy starać się minimalizować „grubość” nierelacyjnej warstwy zapytania SQL. Ta „grubość” zależy od liczby i poziomu komplikacji operacji, ale również od ilości danych biorących udział w zapytaniu. Sortowanie i ograniczanie liczby zwracanych wierszy to właśnie operacje nierelacyjne, optymalizator prawdopodobnie nie będzie w stanie zoptymalizować zapytania w taki sposób, aby drugie złączenie było wykonane po ograniczeniu liczby wierszy do niezbędnego minimum. Choć skrupulatna analiza tych dwóch zapytań wykazuje, że
GROMADZENIE SIŁ
349
zwrócą identyczne wyniki, jednak matematyczny dowód ich równoważności jest dość trudnym zadaniem. Optymalizator zawsze działa w zakresie bezpiecznych ocen, silnik baz danych nie może bowiem ryzykować możliwości zwrócenia błędnych wyników nawet za cenę bardzo dużych optymalizacji działania, szczególnie w sytuacji, gdy semantyka działań nie jest mu znana. Sortowanie i agregacje powodują, że optymalizator traci rozeznanie, przez co można założyć, że zapytanie będzie wykonane w sposób zbliżony do tego, w jaki zostało napisane. Zapytanie, które sortuje dane przed dokonaniem złączenia, jest bez wątpienia niezbyt piękne. Ale ten „brzydki” kod w SQL-u stanowi jednak mniejsze zło, ponieważ sugeruje silnikowi SQL kolejność wykonania działań składowych i pozwoli uniknąć znacznych kosztów czasowych w przypadku zwiększenia się liczby zamówień i klientów. Aby zredukować podatność zapytań na obniżenie wydajności wskutek zwieszania się ilości danych w podzapytaniach, należy wykonywać operacje wyłącznie na tych danych, które są absolutnie niezbędne. Złączenia uzupełniające dane o informacje dodatkowe (niebiorące udziału w filtrowaniu, agregacji itp.) należy wykonać na końcu zapytania.
Rozwijanie podzapytań Jak wspominałem już wielokrotnie, skorelowane podzapytania są wykonywane dla każdego wiersza biorącego udział w zapytaniu. Ta ich cecha często stanowi poważny problem w sytuacji, gdy zwiększenie ilości danych powoduje, że kilka wywołań podzapytań zmienia się w dużą liczbę. W tym punkcie na przykładzie z życia pokażę, jak błędne użycie skorelowanych podzapytań może praktycznie unieruchomić proces biznesowy, oraz doradzę, jak można rozwiązywać tego typu problemy. Ten problem pojawił się w bazie danych Oracle. Omawiane zapytanie było wykonywane raz na godzinę, a jego zadanie polegało na aktualizacji danych tabeli zarządzania systemem bezpieczeństwa. Warto zauważyć, że ten mechanizm działał jako swego rodzaju wytrych, służący przyspieszeniu operacji weryfikowania dostępów w systemie bezpieczeństwa. Z czasem proces ten zajmował coraz więcej czasu aż do momentu, gdy na serwerze produkcyjnym jego wykonanie trwało ponad piętnaście minut, co w przypadku
350
ROZDZIAŁ DZIESIĄTY
procesu wykonywanego co godzinę dość znacznie obniżało jego użyteczność. Ta sytuacja sama w sobie powinna wywołać krytyczny alarm w systemie bezpieczeństwa! Problem można zawęzić do następującego fragmentu procedury: insert /*+ append */ into fast_scrty ( emplid, rowsecclass, access_cd, empl_rcd, name, last_name_srch, setid_dept, deptid, name_ac, per_status, scrty_ovrd_type) select distinct emplid, rowsecclass, access_cd, empl_rcd, name, last_name_srch, setid_dept, deptid, name_ac, per_status, 'N' from pers_search_fast
Statystyki optymalizatora są aktualne, zatem przyjrzyjmy się bliżej samemu zapytaniu. Tabela pers_search_fast (jak się okazało, nazwa ta jest dość myląca) jest perspektywą zdefiniowaną w oparciu o następujące zapytanie: 1 select a.emplid, 2 sec.rowsecclass, 3 sec.access_cd, 4 job.empl_rcd, 5 b.name, 6 b.last_name_srch, 7 job.setid_dept, 8 job.deptid, 9 b.name_ac, 10 a.per_status 11 from person a, 12 person_name b,
GROMADZENIE SIŁ
351
13 job, 14 scrty_tbl_dept sec 15 where a.emplid = b.emplid 16 and b.emplid = job.emplid 17 and (job.effdt= 18 ( select max(job2.effdt) 19 from job job2 20 where job.emplid = job2.emplid 21 and job.empl-rcd = job2.empl_rcd 22 and job2.effdt <= to_date(to_char(sysdate, 23 'YYYY-MM-DD'),'YYYY-MM-DD')) 24 and job.effseq = 25 ( select max(job3.effseq) 26 from job job3 27 where job.emplid = job3.emplid 28 and job.empl_rcd = job3.empl_rcd 29 and job.effdt = job3.effdt ) ) 30 and sec.access_cd = 'Y' 31 and exists 32 ( select 'X' 33 from treenode tn 34 where tn.setid = sec.setid 35 and tn.setid = job.setid_dept 36 and tn.tree_name = 'DEPT_SECURITY' 37 and tn.effdt = sec.tree_effdt 38 and tn.tree_node = job.deptid 39 and tn.tree_node_num between sec.tree_node_num 40 and sec.tree_node_num_end 41 and not exists 42 ( select 'X' 43 from scrty_tbl_dept sec2 44 where sec.rowsecclass = sec2.rowsecclass 45 and sec.setid = sec2.setid 46 and sec.tree_node_num <> sec2.tree_node_num 47 and tn.tree_node_num between sec2.tree_node_num 48 and sec2.tree_node_num_end 49 and sec2.tree_node_num between sec.tree_node_num 50 and sec.tree_node_num_end ))
Tego typu „śmiertelne zapytanie” jest oczywiście zbyt skomplikowane, aby zrozumieć je na pierwszy rzut oka. W ramach ćwiczenia proponuję jednak zatrzymać się na chwilę i dokładnie je przeanalizować, aby zrozumieć zasadę działania i spróbować zidentyfikować przyczynę znacznego obniżenia wydajności.
352
ROZDZIAŁ DZIESIĄTY
Po zakończeniu analizy porównajmy spostrzeżenia. Być może udało się Czytelnikowi zauważyć kilka bardzo interesujących cech tego zapytania: • Duża liczba podzapytań. Jedno podzapytanie jest nawet zagnieżdżone i wszystkie są skorelowane. • Brak wysoce selektywnego kryterium. Jedyne wyrażenia porównawcze wykorzystują bieżącą datę (wiersz 22), co daje niewielkie szanse na odfiltrowanie czegokolwiek, wartość Y lub N (wiersz 30) oraz wartość kolumny tree_name (wiersz 36), co wygląda na element kategoryzacji. A z faktu, że instrukcja INSERT nie zawiera warunku WHERE, można wnioskować, iż możemy spodziewać się dużej liczby wierszy przetwarzanych w tym zapytaniu. • Wyrażenia typu between sec.tree_node_num and sec.tree_node_num_end wyglądają niepokojąco znajomo. Przypomina się hierarchia zagnieżdżonych zbiorów według Celko poznana w rozdziale 7. Znalezienie tego typu konstrukcji w bazie danych Oracle to dość nieoczekiwana sytuacja, ale w przypadku gotowych systemów (w odróżnieniu od systemów tworzonych na zamówienie) często zdarza się znajdować rozwiązania przenośne, niewykorzystujące cech szczególnych określonych systemów zarządzania bazami danych. • Z czterech tabel (a właściwie jedna z nich, person_name, jest perspektywą) w zewnętrznej klauzuli FROM tylko trzy: person, person_name i job są złączone w sposób czysty. Istnieje warunek wykorzystujący kolumnę scrtytbldept, ale złączenie jest niebezpośrednie i ukryte w jednym z podzapytań (wiersze 34-38). To nie jest technika zapewniająca wysoką wydajność. W pierwszej kolejności warto zorientować się, jakie ilości danych biorą udział w zapytaniu. Najpierw person_name — to perspektywa, ale testowe wywoływania definiującego ją zapytania nie wykazały problemów z wydajnością. Oto informacje na temat liczności tabel biorących udział w zapytaniu: TABLE_NAME NUM_ROW5 ------------------- -------TREENODE 107831 JOB 67660 PERSON 13884 SCRTY_TBL_DEPT 568
GROMADZENIE SIŁ
353
Jak się okazuje, żadna z tabel nie przeraża swoimi rozmiarami. Warto zatem zauważyć, że nie potrzeba milionów wierszy, aby poczuć skutki obniżenia wydajności w wyniku zwiększenia ilości danych i błędnie skonstruowanego zapytania. Elementem wpływającym destrukcyjnie na wydajność zapytania jest zatem nie ilość danych, lecz sposób wykorzystania tabel. Do dyspozycji otrzymałem serwer roboczy (oczywiście nie tak szybki jak produkcyjny), na którym zdecydowałem się odczytać liczność perspektywy wynikowej. Zadanie nietrudne, ale wymagające stalowych nerwów: SQL> select count(*) from PERS_SEARCH_FAST; COUNT(*) ------264185 Elapsed: 01:35:36.88
Szybki rzut oka na indeksy pokazuje, że tabele tree_node i job mają zdefiniowaną za dużą liczbę indeksów: grzech powszechny w gotowych produktach. Niestety, okazało się, że nie mamy w tym przypadku do czynienia z problemem typu „brakujący indeks”. Od czego zacząć, aby znaleźć przyczynę tak powolnego wykonania zapytania? W pierwszej kolejności należy uważnie przyjrzeć się „śmiertelnej kombinacji” dość dużej liczby wierszy w tabeli treenode w połączeniu ze skorelowanymi podzapytaniami. Szczególnie podejrzanie wygląda tu kaskadowo wykonywany warunek EXISTS/NOT EXISTS. UWAGA W rzeczywistości odszukanie przyczyny problemu zajęło mi znacznie więcej czasu, niż zajmie przeczytanie tego opisu. Warto uświadomić sobie, że kilka następnych akapitów stanowi wynik wielu godzin skrupulatnej pracy i że rozwiązanie nie spłynęło na mnie nagle, w świetle niebiańskiej iluminacji.
Przyjrzyjmy się zatem wspomnianym wyrażeniom wykorzystującym klauzule EXISTS i NOT EXISTS. Podzapytanie na pierwszym poziomie wykorzystuje tabelę treenode. Podzapytanie na drugim poziomie odczytuje dane z tabeli scrty_tbl_dept, która występuje również w zapytaniu głównym i wykonuje na nich filtr z użyciem wartości z aktualnego wiersza z podzapytania poziomu pierwszego (wiersze 47 i 48) oraz bieżącego wiersza zapytania głównego (wiersze 44, 45, 46, 49 i 50). Jeśli chcemy uzyskać akceptowalną wydajność, musimy bezwzględnie rozwikłać węzeł gordyjski tych zapytań.
354
ROZDZIAŁ DZIESIĄTY
Czy uda się zrozumieć, o co chodzi w tym zapytaniu? Jak się okazało, tabela treenode, wbrew swojej nazwie, nie zawiera definicji hierarchii w stylu „zagnieżdżonych zbiorów”. Odwołania do zakresu liczb związane z tą tabelą odnoszą się do scrty_tbl_dept. Tabela treenode wygląda raczej jak zdenormalizowana, spłaszczona lista (co zawsze jest kiepską nowiną w kontekście z założenia relacyjnym) „węzłów” zdefiniowanych w tabeli scrty_tbl_dept. Jak pamiętamy, w implementacji hierarchii wykorzystującej koncepcję zagnieżdżonych zbiorów z każdym węzłem były związane dwie wartości obliczane w taki sposób, że wartości węzłów potomnych znajdują się zawsze w zakresie zdefiniowanym przez wartości węzła nadrzędnego. Jeśli te dwie wartości występują bezpośrednio obok siebie, mamy niezawodnie do czynienia z węzłem końcowym (liściem), ale odwrotne stwierdzenie nie jest prawdziwe, to znaczy sytuacja, gdy wartości węzła nie są kolejnymi liczbami, nie oznacza, że węzeł nie jest liściem, ponieważ może wynikać z faktu usunięcia wszystkich węzłów potomnych danego węzła i zaniechania ponownego ustalenia tych wartości, co jest często praktykowane ze względów wydajnościowych. Spróbujmy przetłumaczyć kod z wierszy 31 – 50 na język polski (no, w każdym razie mniej więcej język polski): W tabeli treenode istnieje wiersz o określonej wartości kolumny tree_name, dla którego wartości kolumn setid_dept i deptid są dopasowane do analogicznych kolumn tabeli job, jak również wartości kolumn setid i tree_effdt są dopasowane do kolumn tabeli scrty_tbl_dept. Ten wiersz wskazuje na bieżący „węzeł” tabeli scrty_tbl_dept lub na jeden z jego potomków. Nie istnieje inny węzeł w tabeli scrty_tbl_dept, na który wskazuje ten wiersz tabeli treenode, pasujący do setid i rowsecclass, a będący potomkiem tego węzła. Niezbyt zrozumiałe, szczególnie w sytuacji, gdy nie wiadomo dokładnie, o co chodzi w danych. Czy możemy opisać to samo w sposób nieco bardziej zrozumiały w nadziei, że na podstawie tego opisu uda się napisać zrozumiały i skuteczny kod SQL? Kluczowym punktem może być fakt, że nie istnieje inny wiersz spełniający określony warunek. Jeśli nie istnieje węzeł potomny, to znaczy, że znajdujemy się na dole drzewa z punktu widzenia węzła zidentyfikowanego przez wartość tree_node_num tabeli treenode. Podzapytania są, niestety, bardzo przemieszane z ich zapytaniami zewnętrznymi. Jednak możemy napisać pojedyncze zapytanie wewnętrzne, które na chwilę „zapomina” o powiązaniu między tabelami treenode i job,
GROMADZENIE SIŁ
355
a które dla każdego węzła z tabeli scrtytbldept (niewielka tabela, około sześciuset wierszy) spełniającego nasze warunki oblicza liczbę potomków o pasujących atrybutach setid i rowsecclass: select s1.rowsecclass, s1.setid, s1.tree_node_num, tn.tree_node, count(*) - 1 children from scrty_tbl_dept s1, scrty_tbl_dept s2, treenode tn where s1.rowsecclass = s2.rowsecclass and s1.setid = s2.setid and sl.access_cd = 'Y' and tn.tree_name = 'DEPT_SECURITY' and tn.setid = s1.setid and tn.effdt = sl.tree_effdt and s2.tree_node_num between s1.tree_node_num and s1.tree_node_num_end and tn.tree_node_num between s2.tree_node_num and s2.tree_node_num_end group by s1.rowsecclass, s1.setid, s1.tree_node_num, tn.tree_node
(fragment count(*) - 1 służy do wykluczenia bieżącego wiersza z obliczeń). Wynikowy zbiór będzie oczywiście niewielki, co najwyżej kilkaset wierszy. Następnie odfiltrujemy węzły niebędące liśćmi (w naszym kontekście), wykorzystując poprzednie podzapytanie jako perspektywę osadzoną oraz stosując filtr: and children = 0
W tym momencie (i dopiero teraz) możemy dokonać złączenia z tabelą job i wygenerować prawidłowy zbiór wynikowy. Nie będę przedstawiał dokładnej definicji perspektywy po wszystkich zmianach, nie jest to szczególnie ciekawe. Natomiast warto skupić się na tym fragmencie (po pierwszej klauzuli EXISTS): and (job.effdt= ( select max(job2.effdt) from job job2 where job.emplid = job2.emplid
356
ROZDZIAŁ DZIESIĄTY
and job.empl_rcd = job2.empl_rcd and job2.effdt <= to_date(to_char(sysdate,'YYYY-MM-DD'), 'YYYY-MM-DD')) and job.effseq = ( select max(job3.effseq) from job job3 where job.emplid = job3.emplid and job.empl_rcd = job3.empl_rcd and job.effdt = job3.effdt))
Ten fragment ma za zadanie dla ostatniej wartości effdt bieżącej pary (emplid, empl_rcd) odszukać wiersz o najwyższej wartości effseq. Ten warunek nie jest aż tak przerażający w porównaniu z drugim zagnieżdżonym podzapytaniem. Jednak tego typu przypadek wartości „największej z najświeższych” doskonale obsługują funkcje OLAP (a raczej funkcje analityczne, w końcu mamy do czynienia z bazą Oracle), o ile są dostępne w ramach danego produktu. Weźmy następujące zapytanie: select emplid, empl_rcd, effdt, effseq from (select emplid, empl_rcd, effdt, effseq row_number() over (partition by emplid, empl_rcd order by effdt desc, effseq desc) rn from job where effdt <= to_date(to_char(sysdate,'YYYY-MM-DD'),'YYYY-MM-DD')) where rn = 1
Bez trudu wyszuka ono interesujące nas wartości (emplid, empl_rcd) i można je włączyć do zapytania głównego jako osadzoną perspektywę, złączoną z wynikami pozostałych podzapytań. Po przepisaniu tego zapytania cogodzinny proces, który w oryginalnej jego wersji trwał piętnaście minut, skrócił się do dwóch minut. Należy minimalizować zależności skorelowanych podzapytań od elementów z zapytań zewnętrznych.
GROMADZENIE SIŁ
357
Zbawienne partycjonowanie Gdy liczba przetwarzanych wierszy stale się zwiększa, wyszukiwanie indeksowe działające doskonale z mniejszymi ilościami danych przestaje być takie skuteczne. Typowe wyszukiwanie z użyciem indeksu wymaga od silnika bazy danych odwiedzenia 3 – 4 stron w ramach przeszukiwania indeksu, a następnie odczytania danych z odpowiedniej strony danych tabeli. Przeszukiwanie zakresowe będzie dość wydajne, szczególnie w przypadku, gdy zastosowanie ma klastrowanie z użyciem indeksu, dzięki czemu kolejność danych w tabeli jest zgodna z kolejnością elementów w indeksie. Jednak w pewnym momencie przeskakiwanie między stronami indeksu a stronami tabeli staje się bardziej kosztowne od zwykłego, liniowego przeszukiwania tabeli. Tego typu liniowe przeszukiwanie może wykorzystać zalety współbieżności procesów i odczytów z wyprzedzeniem (read-ahead) obsługiwanych przez sprzęt i system operacyjny. Wyszukiwania z użyciem indeksu wykorzystujące porównanie wartości klucza są z natury bardziej sekwencyjne. Duża liczba sprawdzanych wierszy stanowi przykład sytuacji, gdy mniej kosztowne jest przeglądanie sekwencyjne, a nie osobne wyszukiwanie pojedynczych elementów, oraz złączenia realizowane z użyciem sum kontrolnych hash i scalania (ang. merge), a nie z użyciem pętli (co zostało omówione w rozdziale 6.). Przeszukiwanie tabel jest tym wydajniejsze, im większy jest współczynnik liczby zakwalifikowanych wierszy do ich liczby całkowitej. Jeśli tabelę można podzielić, używając partycjonowania opartego na wartościach danych (zobacz rozdział 5.) w taki sposób, że kryteria wyszukiwania będą zgodne z kryteriami partycjonowania, wydajność tego typu zapytania wzrośnie jeszcze bardziej. W takim kontekście operacje na dobrze zdefiniowanym zakresie wartości będą znacznie wydajniejsze, gdy zostaną wykonane na wyizolowanym podzbiorze danych, niż w przypadku, gdy muszą zostać ustalone granice warunku z użyciem indeksu. Oczywiście partycjonowanie w oparciu o wartości danych nie rozwiązuje wszystkich problemów z dużymi ilościami danych: • Po pierwsze, rozkład kluczy użytych do partycjonowania musi być dość równomierny. Jeśli pojedyncza wartość klucza wyodrębnia 90% wierszy z tabeli, to partycja skonstruowana w oparciu o ten klucz w przypadku tej konkretnej wartości nie spowoduje znaczącego przyspieszenia operacji,
358
ROZDZIAŁ DZIESIĄTY
lecz w przypadku pozostałych wartości tego klucza zapewne większą wydajność można będzie osiągnąć, korzystając z indeksu. W przypadku wartości selektywnych korzystanie z indeksu do odczytu spartycjonowanych danych nie daje większych korzyści. Równomierność rozkładu wartości klucza partycjonowania jest główną przyczyną faktu, że partycjonowanie zakresowe z użyciem dat jest najpopularniejszą formą partycjonowania. • Po drugie, zapewne mniej oczywiste, ale nie mniej istotne, granice zakresów muszą być dobrze zdefiniowane od dołu, jak również od góry. Ten wymóg nie jest szczególną cechą wykorzystania spartycjonowanych tabel, to samo można powiedzieć na temat przeszukiwania zakresowego z użyciem indeksów. Zakres ograniczony z jednej strony, o ile nie poszukujemy wartości zbliżonych do maksimum lub minimum wartości w kolumnie, nie pomoże zbyt wiele w ograniczeniu liczby wierszy, które będą przeglądane w zapytaniu. Podobna uwaga dotyczy zakresów zdefiniowanych w następujący sposób: where date_column_1 >= wartosc and date_column_2 <= inna_wartosc
Taki warunek nie upraszcza korzystania z partycji lub indeksu i nie spowoduje przyspieszenia działania zapytania w porównaniu z przypadkiem podania tylko jednego warunku (ograniczenia zakresu). Wyłącznie określenie typu BETWEEN (lub jego semantyczny odpowiednik) może skutecznie ograniczyć liczbę wykorzystanych partycji, pozwalając na optymalne wykorzystanie partycjonowania. Zakresy zdefiniowane w sposób otwarty (jednostronny) nie wykorzystują indeksów ani partycji w sposób optymalny.
Czyszczenie danych Archiwizacja i oczyszczanie danych są często postrzegane jako działania dodatkowe aż do momentu, gdy okażą się ostatnią deską ratunku, dającą nadzieję na odzyskanie wydajności sprzed kilku miesięcy. Jednak to bardzo poważne operacje i wykonane niedbale mogą znacznie obciążyć system i przysłużyć się do pogorszenia zamiast polepszenia sytuacji.
GROMADZENIE SIŁ
359
W idealnym przypadku tabele powinny być spartycjonowane (rzeczywiste partycjonowanie lub spartycjonowana perspektywa) i archiwizacja oraz oczyszczanie danych są wykonywane na partycjach. Jeśli partycje można odłączyć w prosty sposób, operacja archiwizacji (lub oczyszczania) staje się trywialnie prosta: partycja jest archiwizowana, a na jej miejsce ewentualnie zakłada się pustą. Jeśli partycji nie daje się odłączyć, i tak sytuacja jest bardzo korzystna: zapytanie wykonujące kopię danych jest proste, a po jego zakończeniu tabelę można po prostu wyczyścić. Operacja TRUNCATE jest szczególnie skuteczna w przypadku tego zadania, ponieważ z reguły w jej wyniku nie są uruchamiane mechanizmy zwykle towarzyszące operacjom usuwania wierszy, dzięki czemu jest bardzo szybka. UWAGA Operacja TRUNCATE wykonuje się w bezwzględnie szybki sposób, dlatego przy jej używaniu należy zachować ostrożność. Użycie operacji TRUNCATE może mieć wpływ na kopie zapasowe, może też powodować usunięcie niektórych obiektów, jak indeksy. Każde użycie operacji TRUNCATE powinno być przedyskutowane z administratorem bazy danych.
W niezbyt idealnym, ale za to bardzo powszechnym przypadku archiwizacja danych jest wykonywana w oparciu o wiek danych, czasem operacja archiwizacji jest wspomagana przez inne warunki. Na przykład w księgowości raczej nie archiwizuje się niezapłaconych faktur, nawet jeśli są bardzo stare. Takie reguły biznesowe powodują, że proste i eleganckie operacje wykorzystujące zalety partycji staja się toporne i brutalne. Czy to zatem oznacza, że jesteśmy skazani na starą i nudną, za to godną zaufania operację DELETE? W tym momencie bardzo interesujące będzie dokonanie rankingu operacji modyfikujących dane (wstawianie, modyfikowanie i usuwanie) z punktu widzenia kosztu całkowitego. Jak mieliśmy okazję wielokrotnie się przekonać, wstawianie wierszy to dość kosztowna operacja, przede wszystkim dlatego, że przy wstawianiu wiersza muszą być odpowiednio zmodyfikowane wszystkie indeksy tabeli. Modyfikacje istniejących wierszy wymagają jedynie modyfikacji tych indeksów, które dotyczą modyfikowanych kolumn. Słabość modyfikacji w porównaniu z wstawianiem polega na tym, że po pierwsze wykorzystują one przeszukiwanie (klauzulę WHERE), co może prowadzić do podobnych kosztów jak w przypadku operacji odczytu (SELECT), szczególnie w niekorzystnych sytuacjach, jak założone blokady. Po drugie, w przypadku modyfikacji poprzednia wartość musi być
360
ROZDZIAŁ DZIESIĄTY
zachowana na wypadek wycofania zmiany, co nie ma miejsca w operacji wstawiania nowych wierszy. Operacje usuwania mają wszystkie wady operacji wstawiania i modyfikowania: wymagają aktualizacji wszystkich indeksów, są z reguły powiązane z klauzulą WHERE, która może być powolna, i wymagają zachowania wartości na wypadek wycofania operacji. Usuwanie jest operacją, która może potencjalnie przysporzyć najwięcej kłopotów ze wszystkich operacji modyfikujących dane.
Jeśli zatem możemy zaoszczędzić nieco czasu na operacji usuwania, nawet za cenę konieczności wykonania innych operacji, warto zdecydować się na tego typu ustępstwo. Gdy tabela jest spartycjonowana, a operacje archiwizacji i oczyszczania opierają się głównie na kryterium zakresu czasowego z dodatkowymi kryteriami pomocniczymi, możemy zastosować trzyetapową operację oczyszczania: 1. Skopiować do tabeli tymczasowej wiersze, które mają pozostać. 2. Przyciąć partycję. 3. Skopiować wiersze z tabel tymczasowych z powrotem do partycji. Bez partycjonowania sytuacja jest znacznie bardziej skomplikowana. Aby zminimalizować okres założenia blokady, przy założeniu, że po uzyskaniu statusu „do archiwizacji” wiersz nie ma możliwości przejść do stanu „zmiana planów”, operację możemy wykonać w dwóch etapach. Ta operacja jest o tyle ryzykowna, że zapytanie identyfikujące wiersze do archiwizacji jest powolne w działaniu. Operację można wykonać w następujący sposób: 1. Zbudować listę identyfikatorów wierszy do archiwizacji. 2. Zamiast dwukrotnie wykonywać to samo powolne zapytanie z klauzulą WHERE: raz dla instrukcji SELECT, raz dla DELETE, dokonać złączenia tej listy w operacji archiwizacji i oczyszczania. Najważniejsze zastosowanie tabel tymczasowych to duże operacje na całych tabelach lub ich dużych częściach, które w takich porcjach są wydajniejsze od klasycznych operacji na pojedynczych wierszach.
GROMADZENIE SIŁ
361
Hurtownie danych Tematyka tej książki nie pozwala mi poświęcić połowy rozdziału na skomplikowane zagadnienia związane z hurtowniami danych. Zostało napisanych wiele książek poruszających tego typu tematykę, niektóre bardziej ogólne (bardzo znanymi tytułami są The Data Warehouse Toolkit Ralpha Kimballa czy też Building the Data Warehouse Billa Inmona, obie opublikowane przez wydawnictwo John Wiley & Sons), niektóre poświęcone określonym silnikom baz danych. Istnieją wręcz fanatyczni zwolennicy opinii Inmona, który zaleca stosowanie czystych form zgodnych z 3NF dla wielkich repozytoriów danych wykorzystywanych w systemach wspomagania decyzji. Istnieją też zwolennicy Kimballa, którzy wierzą, że hurtownie danych to zupełnie odmienny świat o zupełnie odmiennych potrzebach od relacyjnych baz danych, dlatego model 3NF, mimo niepodważalnych zalet w relacyjnych bazach danych, powinien zostać zastąpiony modelem wymiarowym (nazywanym też wielowymiarowym), w którym dane są w znacznym stopniu zdenormalizowane. W tej książce w większości wykorzystywane są (i zalecane) projekty baz danych zgodne z 3NF, zatem skupię się w tym momencie na modelu wymiarowym, aby przeanalizować jego zalety i przyczyny popularności, ale oczywiście również i wady. W szczególności przeanalizuję powiązania między operacyjnymi magazynami danych (dla niewtajemniczonych — „produkcyjnymi bazami danych”) a systemami wspierania decyzji, ponieważ dane nie spadają z nieba, chyba że ktoś pracuje dla NASA lub firmy obsługującej systemy satelitarne, a to, co można załadować do modelu wymiarowego, musi w końcu mieć jakieś źródło. Należy pamiętać, że z faktu, iż język SQL wykorzystuje się do pobierania danych z „tabel”, nie wynika, że jest on skazany na funkcjonowanie wyłącznie w świecie relacyjnym.
Fakty i wymiary: schemat gwieździsty Podstawowa zasada modelowania polega na zapisywaniu w wielkich tabelach faktów wymiarów danych: ilości, liczności czy dowolnych innych jednostek. Dane są zapisywane w mocno zdenormalizowanej postaci w tabelach wymiarowych posiadających czytelne etykiety. Najczęściej stosuje się od 5 do 15 wymiarów, każdy z systemowo wygenerowanym kluczem głównym,
362
ROZDZIAŁ DZIESIĄTY
a tabela faktów zawiera wszystkie klucze obce. Z reguły data związana z serią miar (wierszy) w tabeli faktów nie jest zapisywana jako kolumna typu data w tabeli faktów, lecz jako identyfikator wskazujący odpowiedni wiersz w tabeli date_dimension, w której data będzie zapisana we wszelkich możliwych formatach. Weźmy na przykład tradycyjną datę „początku ery Uniksa”: 1 stycznia 1970 roku, w tabeli date_dimension zostanie zapisana w następujący sposób: date_key date_value date_description
day
year
quarter holiday
12345
Thursday January 1970
Ql 1970 Holiday
01/01/1970 January 1,1970
month
Każdy wiersz w tabeli faktów zawierający dane dotyczące 1 stycznia 1970 roku będzie zawierał klucz główny o wartości 12345. Uzasadnieniem tego typu sposobu zapisu dat (z oczywistych względów mocno zdenormalizowanego) jest to, że normalizacja ma znaczenie w systemach, w których dane ulegają modyfikacjom, ponieważ to jest jedyny sposób pozwalający na zachowanie integralności danych; ale w hurtowniach danych narzut rozmiaru danych wynikający z nadmiarowości jest akceptowalny, ponieważ tabele wymiarów zawierają niewiele wierszy w porównaniu z tabelą faktów. Na przykład wymiar dat z jednego stulecia będzie składał się z 36526 wierszy. Co więcej, zdaniem dr Kimballa, sytuacja, gdy w hurtowni danych znajduje się tylko jedna tabela faktów otoczona tabelami wymiarów (stąd nazwa „schematu gwiaździstego”), powoduje, że wykonywanie zapytań na danych tego typu jest bardzo łatwe. Zapytania te wymagają niewielkiej liczby złączeń, dzięki czemu wykonują się bardzo szybko.
RYSUNEK 10.3. Prosty schemat gwiaździsty zawierający klucze główne (PK) i klucze obce (FK)
Każdy, kto posiada choćby niewielkie doświadczenie z SQL-em, z pewnością zmarszczył czoło na myśl o założeniu mówiącym, iż niewielka liczba złączeń gwarantuje dużą szybkość zapytań. Oczywiście nie zależy mi na tym, żeby
GROMADZENIE SIŁ
363
przekonać czytelników do bezkrytycznego dokonywania złączeń kilkunastu tabel, ale z pewnością każdy, kto nie sparzył się na zagnieżdżonych pętlach przeszukujących wielkie, niepoindeksowane tabele, nie zaryzykuje stwierdzenia, że złączenia są przyczyną wolnego działania zapytań. Spowolnienie zapytań wynika ze sposobu ich pisania — w takim kontekście modelowanie wymiarowe ma sporo sensu, a uzasadnienie tej teorii Czytelnik znajdzie w dalszej części tego rozdziału. Wzorce projektowe modelowania wymiarowego są jak najbardziej celowo zorientowane na odczyty, przez co zupełnie świadomie rezygnują z pryncypiów teorii relacyjnej.
Narzędzia do wydobywania danych Problem z systemami wspierania decyzji polega na tym, że ich podstawowi użytkownicy nie mają zielonego pojęcia, w jaki sposób pisze się zapytania SQL. Z tego powodu są zmuszeni do używania narzędzi do modelowania zapytań wyposażonych w graficzny interfejs użytkownika jak najbardziej upraszczający to zadanie. Jak mieliśmy okazję przekonać się w rozdziale 8., dynamiczne konstruowanie zapytań w oparciu o ustalony zbiór kryteriów to niełatwe zadanie wymagające solidnego przemyślenia strategii i jeszcze solidniejszego programowania. Łatwo zrozumieć, że w systemie wspomagania decyzji zapytanie wynikowe może być zupełnie dowolne, zatem narzędzie graficzne może generować przyzwoite zapytania wyłącznie w przypadku, gdy ich poziom komplikacji jest niewielki. Poniższy kod stanowi przykład wyniku działania jednego z narzędzi do budowania zapytań (niektóre z kolumn w klauzuli WHERE są zwrócone przez podzapytanie): ... FROM (SELECT ((((((((((((t2."FOREIGN_CURRENCY" || CASE WHEN 'tfp' = 'div' THEN t2."CODDIV" WHEN 'tfp' = 'ac' THEN t2."CODACT" WHEN 'tfp' = 'gsd' THEN t2."GSD_MNE" WHEN 'tfp' = 'tfp' THEN t2."TFP_MNE" ELSE NULL END ) || CASE
364
ROZDZIAŁ DZIESIĄTY
WHEN 'Y' = 'Y' THEN TO_CHAR ( TRUNC ( t2."ACC_PCI" ) ) ELSE NULL END ) || CASE WHEN 'N' = 'Y' THEN t2."ACC_E2K" ELSE NULL END ) || CASE WHEN 'N' = 'Y' THEN t2."ACC_EXT" ELSE NULL END ) || CASE ...
Wydaje się, że w narzędziach typu „business intelligence” tak dużo inteligencji wkłada się w biznes, że nie wystarcza jej już na stworzenie prostego zapytania SQL. W momencie, gdy klauzula WHERE przestaje być trywialna, zaczynają się poważne kłopoty. W tym kontekście przesąd, że ze względów wydajności należy unikać złączeń, staje się bardzo uzasadniony. W rzeczywistości sprawdza się tu idea jak najprostszego „tekstowego wyszukiwania w pliku” (znanego jako narzędzie grep). Można też już zrozumieć, dlaczego „wymiar dat” ma sens — ponieważ kolumna daty w tabeli faktów i konieczność przekształcania w zapytaniu odwołania do Q1 jako „okresu między 1 stycznia a 31 marca” w celu wykonania wyszukiwania zakresowego powodują, że nagle przestaje się wierzyć w skuteczność tego rozwiązania. Jeśli jednak wszystkie wariacje formatu daty umieści się w tabeli wymiarów, przez poindeksowanie wszystkich z nich eliminuje się ryzyko i konieczność wykonywania przekształceń. Denormalizacja wymiarów, proste złączenia i indeksy „po prawie wszystkim” zwiększają szanse, że większość zapytań będzie wykonywać się w zadowalającym tempie, co z reguły sprawdza się w praktyce. Słabo zaprojektowane zapytania mają szanse na wydajne wykonanie, ponieważ modele wymiarowe posiadają z reguły mniejszy poziom komplikacji niż typowy model transakcyjny.
GROMADZENIE SIŁ
365
Wydobywanie, transformacja i ładowanie Aby użytkownicy biznesowi mieli możliwość skutecznego generowania strategicznych kosztów (jeśli wierzyć literaturze na temat hurtowni danych), ktoś musi poświęcić się żmudnemu zadaniu załadowania danymi systemu wspierania decyzji. I nawet w przypadku, gdy dostępne są dedykowane narzędzia, to ładowanie danych nie jest łatwym zadaniem.
Wydobywanie danych Wydobywanie danych rzadko odbywa się z użyciem zapytań SQL. Najczęściej stosowane są specjalizowane narzędzia: dostarczone przez producenta systemu zarządzania bazami danych, ale stworzone przez innych dostawców. Mało prawdopodobne jest, że do wydobywania danych przeznaczonych do załadowania do hurtowni danych będziemy zmuszeni używać własnych zapytań SQL, ale jeśli tak się zdarzy, z pewnością będziemy mieli do czynienia z przypadkiem dużych ilości informacji, w którym najskuteczniejsza technika opiera się na pełnym przeszukiwaniu tabel. W takich sytuacjach warto wykorzystać tablicowe przesyłanie danych (jeśli wykorzystywany silnik baz danych obsługuje interfejs tablicowy, to znaczy pobieranie wyników zapytań do tablic lub przesyłanie wielu wartości w postaci tablic) oraz zminimalizować liczbę przełączeń kontekstu między jądrem bazy danych a programem wydobywającym dane.
Transformacja W zależności od umiejętności z zakresu SQL-a, źródła danych, znaczenia systemu produkcyjnego i wymaganego poziomu transformacji, można posłużyć się SQL-em do skonstruowania dość skomplikowanych zapytań generujących dane w postaci gotowej do załadowania, można też użyć SQL-a do zmodyfikowania danych w przejściowym obszarze ich zapisu oraz do przekształcenia danych na etapie ładowania ich do hurtowni. Transformacje danych przeznaczonych dla hurtowni z reguły obejmują agregacje, ponieważ szczegółowość danych wymagana przez systemy wspierania decyzji jest zwykle mniejsza od poziomu szczegółowości w ramach produkcyjnej bazy danych. Wartości mogą być agregowane na przykład w ujęciu dziennym. Jeśli transformacje danych nie wybiegają znacznie poza wykonanie agregacji, nie ma sensu wykonywać ich w osobnych etapach. Zapisywanie danych jest z reguły bardziej kosztowne niż ich odczyt, zatem modyfikowanie danych i ich zapis w zasobach pośrednich,
366
ROZDZIAŁ DZIESIĄTY
skąd są następnie odczytywane w celu zapisu do hurtowni danych, to zbyteczny, dodatkowy koszt czasowy. Jednak w niektórych przypadkach nie uda się uniknąć tego etapu, gdy dane są konstruowane z kilku niezależnych systemów. Może być kilka powodów konieczności składania danych z kilku różnych źródeł: • Konieczność „zgromadzenia” w jednym miejscu kontroli nad różnymi aspektami działania organizacji. • Niedawno wchłonięta firma nadal korzysta ze swojego starego systemu informatycznego. • Trwająca migracja między systemami, to znaczy sytuacja, gdy operacje są częściowo kontynuowane w starym systemie informatycznym, podczas gdy system decyzyjny jest już oparty na nowym, który z czasem zostanie wdrożony również w pozostałych sferach działania. Łączenie danych z różnych źródeł powinno odbywać się w jednym etapie, przy wykorzystaniu kombinacji operacji zbiorowych, jak unie i perspektywy osadzone (podzapytania w ramach klauzuli WHERE). Wielokrotne przebiegi wywołań wiążą się z ryzykiem i nie należy ich wykonywać na docelowym systemie hurtowni danych. Wieloetapowe modyfikacje tabel, w których NULL-e są zastępowane rzeczywistymi wartościami, to doskonały sposób na wprowadzenie zamieszania na poziomie fizycznym. Gdy dane są zapisane w kolumnach o zmiennej długości, co jest bardzo częste w przypadku danych tekstowych, a często również danych liczbowych (jednym z przykładów takiej strategii jest Oracle), zastąpienie NULL-i rzeczywistymi wartościami będzie wiązało się z koniecznością przeniesienia danych do nowych stron, co spowoduje obniżenie wydajności zapytań wykorzystujących pełne przeszukiwanie oraz dostępy z użyciem indeksów, ponieważ indeksy z reguły wskazują na początek wiersza. Każdy wskaźnik na obszar przepełnienia oznacza kolejność odczytania większej liczby stron danych, niż powinno wystarczyć do uzyskania odpowiedzi na zapytanie, operacja będzie zatem bardziej kosztowna. Jeśli jednak dane przygotowane wcześniej zostaną po prostu wstawione do docelowych tabel hurtowni danych, w trakcie procesu ładowania zostaną zorganizowane prawidłowo. Dość powszechna technika polega na modyfikowaniu różnych kolumn jednej tabeli przez różne zapytania. O ile to możliwe, należy starać się zaktualizować jak najwięcej danych każdego wiersza w pojedynczej operacji, na przykład z użyciem konstrukcji CASE.
GROMADZENIE SIŁ
367
Wielokrotne operacje modyfikacji wykonywanych na tabeli często powodują zamieszanie na poziomie fizycznym.
Ładowanie W trakcie konstruowania hurtowni danych zgodnie z regułami modelowania wymiarowego wszystkie wymiary wykorzystują sztuczne (wygenerowane przez system) klucze, które służą do śledzenia elementów różnych z technicznego punktu widzenia, ale identycznych z logicznego. Na przykład w firmie produkującej elektronikę nowy model z nowym symbolem może zastępować stary model, którego produkcja została już zakończona. Używając specjalnego, sztucznego klucza, identycznego dla obydwu, łatwiej jest przeprowadzić analizę logiczną traktującą w pewnych ujęciach obydwa modele jako ten sam. Problem z tą techniką polega na tym, że operacyjna baza danych może mieć wartości identyfikatorów różne od identyfikatorów wymiarów wykorzystywanych w systemie decyzyjnym, co może stanowić problem nie z punktu widzenia tabel wymiarów, lecz z punktu widzenia tabel faktów. Nie ma powodu używać sztucznych kluczy dla dat w systemie operacyjnym. Z tego powodu system operacyjny nie musi przechowywać informacji o tym, które urządzenia są następcami ich starszych modeli. Tabele wymiarów są najczęściej ładowane jednorazowo i bywają modyfikowane bardzo rzadko. Modelowanie wymiarowe opiera się częściowo na założeniu, że zmienne wartości są zapisywane w tabelach faktów. W efekcie dla każdego wiersza zapisanego w tabeli faktów należy odczytać (w oparciu o klucz główny tabeli operacyjnej) odpowiednią wartość sztucznego klucza każdego wymiaru, co oznacza w praktyce konieczność dokonania tylu złączeń, ile istnieje różnych wymiarów. Zapytania w systemie wspomagania decyzji mogą wykorzystywać mniejszą liczbę złączeń, ale ładowanie danych do bazy danych systemu wspierania decyzji wymaga znacznie większej liczby złączeń z powodu konieczności odwzorowania między kluczami operacyjnymi a wymiarowymi. Prostota zapytań w modelu wymiarowym jest okupiona komplikacją procesu przygotowania danych dla tego systemu.
368
ROZDZIAŁ DZIESIĄTY
Więzy integralności i indeksy Gdy system zarządzania bazami danych implementuje mechanizm kontroli więzów integralności, warto wyłączyć tę kontrolę na etapie ładowania danych. Jeśli silnik bazy danych musi sprawdzić przy każdym wierszu istnienie kluczy obcych, będzie miał podwójną robotę, ponieważ przy każdej instrukcji ładującej wiersz do tabeli faktów będzie sprawdzać istnienie odpowiednich kluczy sztucznych w tabelach wymiarów. Dla przyspieszenia operacji można też usunąć indeksy i utworzyć je ponownie po zakończeniu operacji, chyba że ładowane wiersze stanowią niewielki ułamek zawartości tabeli faktów, ponieważ w takim przypadku odtworzenie indeksów może zająć więcej czasu niż ładowanie danych przy kompletnych indeksach. Jednak wyłączenie wszystkich mechanizmów kontroli spójności danych może okazać się bardzo niebezpieczne, w szczególności chodzi tu o klucze główne. Nawet w przypadku, gdy ładowane dane zostały oczyszczone i nie można się do czegokolwiek przyczepić, bardzo łatwo popełnić błąd i załadować te same dane dwukrotnie — o wiele łatwiej niż później usunąć duplikaty. Masowa operacja ładowania danych do systemu wspomagania decyzji to jeden z przypadków, gdy są tolerowalne tymczasowe zmiany schematu bazy danych.
Zapytania na wymiarach i faktach: raporty typu ad hoc W przypadku, gdy narzędzia do konstruowania zapytań pozwalają uniknąć różnego rodzaju utrudnień, jak złączenia czy zaawansowane podzapytania, z reguły nadchodzi taki dzień, w którym użytkownicy biznesowi zaczynają zadawać pytania, na które te narzędzia po prostu nie potrafią znaleźć odpowiedzi. Z tego powodu model wymiarowy jest często wzbogacany o różnego rodzaju wynalazki, jak mini-wymiary, outriggers, tabele łączące (ang. bridge table) i inne mechanizmy, umiejscawiające go zdumiewająco blisko modelu relacyjnego zgodnego z 3NF, co powoduje, że graficzne narzędzia konstruowania zapytań przestają sobie radzić. Pewnego dnia użytkownik narzędzia zadaje mu skomplikowane zapytanie, a następnego dnia problem jest już na biurku programisty (zapytanie ciągle się wykonuje). Nadchodzi czas na raporty typu ad hoc i terapię wstrząsową: powrót do SQL-a.
GROMADZENIE SIŁ
369
Konieczność tworzenia jednorazowych zapytań stanowi dobry moment na rozważenie skutków zastosowania modelu wymiarowego z punktu widzenia wykorzystania SQL-a. Wymiary określają z reguły jednostkowe elementy raportu. Użytkownik często chce odczytać wyniki sprzedaży po produkcie, po lokalizacji i dla każdego miesiąca z osobna — mamy zatem do czynienia z trzema wymiarami. Wymiar czasu już omówiliśmy, mamy tu dodatkowo wymiar produktu i wymiar lokalizacji. Produkt i lokalizację można zdenormalizować do poziomu linii produktowej, marki i kategorii, natomiast lokalizację na region, terytorium itp., w zależności od wymogów biznesowych. Z kolei wielkości sprzedaży stanowią oczywiście nasze fakty. Kluczową cechą modelu gwieździstego jest to, że tabela faktów jest odczytywana z punktu wyjścia jej wymiarów, jak to przedstawia rysunek 10.4. W przedstawionym wyżej przykładzie możemy poznać w oparciu o produkt, lokalizację i miesiąc sprzedaże dzienne produktów mlecznych sprzedawanych w województwach zachodnich w trzecim kwartale ubiegłego roku. W przeciwieństwie do jednej z podstawowych reguł teorii relacyjnej, bazy w modelu wymiarowym są nie tylko zdenormalizowane, ale też zawierają bardzo dużo indeksów. Indeksowanie wszystkich kolumn oznacza, że w zależności od poziomu szczegółowości (kolumny w wymiarze lokalizacji, jak miasto, wojewodztwo, terytorium, region można postrzegać jak poszczególne poziomy szczegółowości, podobnie jak w wymiarach czasu) użytkownik uruchamia zapytanie korzystające z odpowiedniego indeksu. Należy pamiętać, że wymiary są tabelami referencyjnymi, które rzadko ulegają modyfikacjom, dlatego nie ma sensu oszczędzać na koszcie utrzymania dużej liczby indeksów. Jeśli wszystkie kryteria odwołują się do tabel wymiarów, z założenia solidnie poindeksowanych, a więc pozwalających na bardzo szybkie wyszukiwanie, logicznym rozumowaniem jest wyjść w zapytaniu od tabel wymiarów, zlokalizować w nich wszystkie niezbędne wartości, po czym złączyć ten wynik z tabelą faktów. Odczytanie w pierwszej kolejności wymiarów ma bardzo ważne znaczenie z punktu widzenia SQL-a, z czego należy zdać sobie sprawę. W normalnej sytuacji poszczególne wiersze odczytuje się z użyciem kryteriów wyszukiwania, wyszukuje klucze obce w tych wierszach i wykorzystuje te klucze obce do wydobycia informacji z wskazywanych przez nie tabel. Prosty przykład: jeśli chcemy znaleźć numer telefonu asystenta w departamencie, w którym pracuje Nowak, wykorzystamy zapytanie przeszukujące tabelę pracowników w poszukiwaniu nazwiska Nowak, aby znaleźć identyfikator departamentu
370
ROZDZIAŁ DZIESIĄTY
RYSUNEK 10.4. Standardowy sposób konstruowania zapytań w modelu wymiarowym
i użyć klucza głównego w tabeli departamentów w celu znalezienia numeru telefonu. To klasyczny przykład zastosowania złączenia w zagnieżdżonej pętli (nested loop). W modelowaniu wymiarowym sytuacja zmienia się znacząco. Zamiast rozpocząć zapytanie od tabeli referencyjnej (opisującej pracowników), przejść do tabeli przez nią wskazywanej (opisującej departamenty), wykorzystując klucze obce, rozpoczniemy od tabel wskazywanych, czyli wymiarów. W jaki sposób przejdziemy do tabeli faktów? W tabeli wymiarów nie mamy klucza obcego, który można by wykorzystać do wyszukania wierszy w tabeli faktów, mamy wszak sytuację dokładnie odwrotną. Przypomina ona wyszukiwanie nazwiska w książce telefonicznej departamentów, gdy znamy tylko numer telefonu asystenta. Łącząc tabelę faktów z tabelą wymiarów, silnik bazy danych musi wykorzystać inny mechanizm niż klasyczną zagnieżdżoną pętlę. Często jest to złączenie typu hash. Inną cechą szczególną zapytań w modelu wymiarowym jest częste wykorzystanie nieprecyzyjnych kryteriów w połączeniu z dość wąskim zakresem danych, co w efekcie daje niezbyt rozbudowany zbiór wyników. Do obsługi tych zapytań optymalizator może wykorzystać kilka specyficznych technik, na przykład:
GROMADZENIE SIŁ
371
Określić najbardziej selektywne kryterium z tych niezbyt selektywnych, złączyć związane z nim wymiary z tabelą faktów, po czym na takim wyniku sprawdzić pozostałe wymiary. Tego typu technika jest najeżona trudnościami. Po pierwsze, sposób skonstruowania wymiarów może dawać optymalizatorowi błędne sugestie dotyczące selektywności kryteriów. Załóżmy, że mamy wymiar czasowy wykorzystywany przez wiele różnych dat: datę sprzedaży, ale również datę rozpoczęcia funkcjonowania danej lokalizacji. Wymiar czasu nie będzie nigdy wielką tabelą, zdecydowaliśmy się wypełnić ją od razu datami na siedemdziesiąt lat z góry. Siedemdziesiąt lat to wystarczający zakres danych historycznych, aby uwzględnić dane sprzedaży w sklepiku założonym przez dziadka dzisiejszego prezesa, a z drugiej strony wystarczająco dużo dat w przyszłości, żeby przez dłuższy czas nie martwić się o utrzymanie tego wymiaru. Jednak odwołanie do sprzedaży z trzeciego kwartału ubiegłego roku będzie przez optymalizator postrzegane jako operacja bardziej selektywna, niż jest w rzeczywistości. Problem polega na tym, że gdybyśmy w tabeli faktów mieli zapisaną rzeczywistą datę sprzedaży, bez przeszkód można by zastosować wyszukiwanie zakresowe. Wykorzystanie „odwołania do daty” wskazującego na tabele wymiarów czasu powoduje, że punktem początkowym wyszukiwania musi stać się tabela wymiarów, nie faktów. Pełne przeszukiwanie tabeli faktów i odrzucanie wierszy niespełniających kryteriów Tabele faktów zawierają dane dla wszystkich jednostek danych, zatem w efekcie są bardzo duże. Pomogłoby tu partycjonowane, najlepiej według jednego z wymiarów (lub dwóch, jeśli istnieje możliwość partycjonowania wielopoziomowego). Zapytanie wykorzystujące dwa lub trzy wymiary wymaga wykonania pełnego przeszukiwania tabeli zawierającej miliony wierszy. Nietrudno zatem stwierdzić, że pełne przeszukiwanie tabeli faktów to niezbyt atrakcyjna opcja. W takim przypadku złączenie tabeli faktów po odfiltrowaniu pierwszego wymiaru może być nie najlepszym pomysłem. Niektóre produkty, jak Oracle, implementują interesujący algorytm, w Oracle nazwany „transformacją gwieździstą” (ang. star transformation). Temu mechanizmowi przyjrzymy się bardziej szczegółowo, uwzględniając pewne cechy szczególne baz Oracle, następnie zastanowimy się, na ile uda się odtworzyć ten algorytm w innych środowiskach baz danych.
372
ROZDZIAŁ DZIESIĄTY
Modelowanie wymiarowe jest skonstruowanie w oparciu o założenie, że punktami wyjścia do zapytań są wymiary. Fakty muszą być odczytywane na końcu.
Transformacja gwieździsta Transformacja gwieździsta w pierwszym etapie wykorzystuje złączenie tabeli faktów osobno z każdym z wymiarów, w których są określone kryteria filtrujące. Transformacja ta wygląda jak zwykłe złączenie kilku tabel z tabelą faktów, ale to wrażenie mylące. Nas interesują jedynie adresy wierszy z tabeli faktów pasujące do kryteriów każdego z wymiarów. Tego typu adres, znany również jako rowid (dostępny jako pseudokolumna w Oracle, baza PostgreSQl wykorzystuje podobną funkcjonalność w postaci kolumny oid), jest zapisywany w indeksach. Dzięki temu musimy dokonać złączenia trzech obiektów: • Indeksu kolumny z tabeli wymiarów wykorzystywanej w kryterium filtrowania, na przykład kolumny quarters w tabeli date_dimension. • Tabeli date_dimension, w której znajdziemy sztuczny klucz główny date_key. • Indeksu kolumny w tabeli faktów zdefiniowanej jako klucz obcy wskazujący na date_key (transformacja gwieździsta działa najwydajniej w przypadku, gdy klucze obce w tabeli faktów są poindeksowane). Mimo tego że tabela faktów pojawia się w zapytaniu wielokrotnie, nie będziemy odczytywać wielokrotnie tych samych stron danych i indeksów. Wszystkie złączenia będą odwoływały się do innych indeksów, co prawda zawierają te same rowid tabeli faktów, ale w rzeczywistości są zupełnie niezależnymi obiektami. Gdy uzyskamy wyniki dwóch złączeń, możemy połączyć zbiory wartości rowid (dla warunku OR) lub wyznaczyć ich część wspólną (dla warunku AND). Ten etap będzie dodatkowo uproszczony w przypadku wykorzystania indeksów bitmapowych, w których wystarczy wykonać proste operacje bitowe dla wyznaczenia końcowego zbioru wartości rowid spełniającego warunki. Po uzyskaniu ostatecznego, stosunkowo niewielkiego zbioru wartości rowid możemy z tabeli faktów odczytać odpowiednie wiersze. Jak łatwo zauważyć, tabelę faktów odczytujemy po raz pierwszy na samym końcu operacji.
GROMADZENIE SIŁ
373
Indeksy bitmapowe, jak wskazuje ich nazwa, indeksują wartości przez skonstruowanie mapy bitowej informującej, które wiersze zawierają daną wartość (bit 1), a które nie. Indeksy bitmapowe są szczególnie użyteczne w kolumnach o niewielkiej liczności (liczbie unikalnych wartości), nawet w przypadku niewielkiego rozproszenia tych wartości. W poprzednich rozdziałach nie wspominałem o indeksach bitmapowych z prostego powodu: nie nadają się one zupełnie do klasycznych działań w bazach danych. Istnieje poważny powód do unikania indeksów bitmapowych w bazach danych wykazujących standardową aktywność modyfikacji: modyfikacja bitmapy wymaga jej zablokowania. Ten rodzaj indeksu został zaprojektowany z myślą o kolumnach zawierających niewielką różnorodność wartości, a zablokowanie indeksu może zablokować dostęp do wielu wierszy, co powoduje znaczące obniżenie wydajności między blokowaniem na poziomie tabeli a blokowaniem na poziomie strony, ale znacznie bliżej mniej korzystnego końca tego zakresu. W przypadku baz danych wykorzystywanych głównie do odczytu indeksy bitmapowe mogą się jednak okazać użyteczne. Indeksy bitmapowe buduje się szybko w operacjach masowego ładowania danych i wymagają one znacznie mniej miejsca na dysku w porównaniu z klasycznymi indeksami.
Emulowanie transformacji gwieździstej Choć zautomatyzowane transformacje gwieździste są zaprojektowane z myślą o znacznym przyspieszeniu nawet nie najlepiej zaprojektowanych zapytań, istnieje możliwość takiego przygotowania zapytania SQL, aby silnik baz danych wykonał je tak samo wydajnie, jeśli nie szybciej. W tym miejscu muszę wyznać, że prezentowane zapytania będą skonstruowane w taki sposób, aby realizowały tylko jeden cel. Z relacyjnego punktu widzenia powinienem zostać napiętnowany. Z drugiej strony jednak model wymiarowy nie ma wiele wspólnego z teorią relacyjną. Dlatego właśnie będę prezentował użycie SQL-a w zupełnie nierelacyjny sposób. Załóżmy, że mamy kilka tabel wymiarów nazwanych dim1, dim2, …, dimn. Te tabele wymiarów otaczają tabelę faktów o nazwie facts. Każdy wiersz w tabeli facts jest złożony z kolumn kluczy obcych key1, key2, …, keyn oraz z kilku wartości (faktów) val1, val2, …, valp. Klucz główny tabeli facts jest złożony z wszystkich kluczy obcych key1, key2, …, keyn.
374
ROZDZIAŁ DZIESIĄTY
Załóżmy, że musimy wywołać zapytanie spełniające warunek na kolumnach z wymiarów dim1, dim2 i dim3 (mogą na przykład określać klasę produktów, lokalizację i czas). Dla uproszczenia załóżmy, że mamy serię warunków wykorzystujących col1 z tabeli dim1, col2 z tabeli dim2 i col3 z dim3. Nie będziemy zwracali uwagi na transformacje, jak agregacja itp. i ograniczymy się do samego zwrócenia wszystkich wierszy spełniających kryteria w jak najwydajniejszy sposób. Transformacja gwieździsta skupia się na uzyskaniu wydajności dzięki odczytaniu spełniających warunki identyfikatorów wierszy w tabeli facts. Identyfikatory te mogą wskazywać wiersze zbioru wynikowego, ale też mogą stanowić etap pośredni w generowaniu wyników wskutek dalszych operacji (jak agregacje). Zacznijmy od złączenia tabel dim2 i facts: select ... from dim2, facts where dim2.key2 = facts.key2 and dim2.col2 = wartosc
W tym momencie, jeśli nie używamy bazy Oracle, nie mamy do dyspozycji wartości rowid, a wszak chcemy uzyskać identyfikatory faktów spełniających kryteria zdefiniowane przez wymiar. Czy zatem zwrócić klucze główne tabeli facts identyfikujące wiersze? Gdybyśmy tak zrobili, musielibyśmy odczytać indeks na kolumnie facts(key2), ale też samą tabelę facts, co niweczy nasz podstawowy cel (odczyt tabeli facts na samym końcu operacji). Pamiętajmy, że często wykorzystywana technika unikania wielokrotnego odczytywania tabeli polega na wstawieniu interesujących nas kolumn do indeksu. Czy zatem indeks (facts(key2) musimy przekształcić w indeks facts(key2, key3, ..., keyn)? Gdybyśmy tak zrobili, tę samą zasadę musielibyśmy zastosować do wszystkich kluczy obcych tabeli! Skończy się na tym, że będziemy mieli n indeksów o rozmiarach rzędu wielkości samej tabeli facts! Taka sytuacja jest trudna do przyjęcia, ponadto każdy odczyt danych z tych indeksów wiązałby się z koniecznością przerzucenia dużych ilości danych, co znacznie obniżałoby wydajność. W naszej tabeli faktów potrzebujemy po prostu niewielkiego identyfikatora wiersza, czyli klucza sztucznego, który nazwiemy fact_id. Choć nasza tabela faktów posiada zupełnie porządny klucz główny (niebędący jednak kluczem obcym w żadnej z innych tabel), taki identyfikator jest nadal potrzebny. Chodzi tu jedynie o techniczny identyfikator, nie do wykorzystania
GROMADZENIE SIŁ
375
w kluczach obcych innych tabel, ale jedynie do skonstruowania unikalnego indeksu. Dzięki takiej kolumnie fact_id wygenerowanej przez system możemy skonstruować indeksy (keyl, fact_id), (key2, fact_id) … (keyn, fact_id) zamiast samych kluczy obcych. W tym momencie nasze poprzednie zapytanie możemy zapisać w następujący sposób: select facts.fact_id from dim2, facts where dim2.key2 = facts.key2 and dim2.col2 = wartosc
Ta wersja zapytania nie wymaga już odczytania czegokolwiek oprócz indeksu po col2, tabeli wymiaru dim2 i indeksu w tabeli faktów (key2, fact_id). Należy zauważyć, że po zastosowaniu tej samej techniki, polegającej na dodawaniu klucza do indeksów każdej kolumny, na tabeli dim2 (oraz innych tabelach wymiarów) zapytanie nie będzie wymagało odczytania również tabel wymiarów, wystarczą jedynie indeksy. Dla kolumn dim1 i dim3 podzapytanie będzie miało analogiczną formę. W ten sposób uzyskamy identyfikatory faktów spełniających kryteria dotyczące wszystkich trzech wymiarów. Ostateczny zbiór identyfikatorów spełniających wszystkie kryteria uzyskamy, złączając te zapytania: select facts1.fact_id from (select facts.fact_id from dim1, facts where dim1.key1 = facts.key1 and dim1.col1 = wartosc1) facts1, (select facts.fact_id from dim2, facts where dim2.key2 = facts.key2 and dim2.col2 = wartosc2) facts2, (select facts.fact_id from dim3, facts where dim3.key3 = facts.key3 and dim3.col3 = wartosc3) facts3 where facts1.fact_id = facts2.fact_id and facts2.fact_id = facts3.fact_id
376
ROZDZIAŁ DZIESIĄTY
Po uzyskaniu identyfikatorów za pomocą powyższego zapytania na ich podstawie wydobywamy same fakty z tabeli facts. Opisana tu technika nie jest oczywiście ograniczona wyłącznie do systemów wspomagania decyzji. Należy jednak zaznaczyć, że jest dedykowana do modeli baz danych z dość rozbudowanymi indeksami, co w przypadku hurtowni danych nie jest niczym niezwykłym i jest do przyjęcia wyłącznie w bazach danych przeznaczonych głównie do odczytu. W takim kontekście lokowanie w indeksie większej ilości informacji i dodanie sztucznej kolumny identyfikatora można uznać za zmiany „o marginalnym koszcie”. W „normalnych” warunkach zawsze należy unikać modyfikowania schematów baz danych w celu przyspieszenia jakiegoś zapytania. Jeśli jednak wszystkie niezbędne elementy są już w tabelach i mamy do czynienia z systemem hurtowni danych, z pewnością warto wykorzystać je jak najlepiej.
Wykonywanie zapytań na schemacie gwieździstym w sposób, do jakiego nie został on przewidziany Jak mieliśmy okazję zauważyć, model wymiarowy został zaprojektowany w celu wykonywania zapytań na wymiarach. Co się jednak stanie w sytuacji przedstawionej na rysunku 10.5, gdy kryteria wyboru, oprócz wymiarów, będą oparte na faktach (na przykład wielkość sprzedaży przekracza określony pułap)?
RYSUNEK 10.5. Nietypowe użycie modelu wymiarowego
GROMADZENIE SIŁ
377
Tego typu użycie można porównać do wykorzystania operatora GROUP BY. Jeśli warunek w tabeli facts będzie wykorzystywał wynik funkcji agregującej (najczęściej sum() lub average()), znajdziemy się w tej samej sytuacji, co w przypadku klauzuli HAVING: wyniku nie da się określić przed przetworzeniem wszystkich danych, a warunek na tabeli facts jest po prostu dodatkowym etapem zapytania, które może do tego miejsca zostać wykonane zgodnie z typowymi regułami zapytań w modelu wymiarowym. Sytuacja wygląda inaczej, ale w rzeczywistości nie różni się od sytuacji klasycznej. Jeśli, z drugiej strony, warunek zostanie zastosowany na pojedynczych wierszach tabeli facts, należy rozważyć, czy wydajniej będzie odrzucić niepożądane fakty na wcześniejszym etapie procesu — według tej samej zasady, że w zapytaniu należy jak najszybciej pozbyć się zbędnych wartości, zanim zostanie zastosowana instrukcja GROUP BY — nie pozostawiając tej funkcji klauzuli HAVING, która jest wykonywana na końcu zapytania. W takim przypadku należy uważnie zastanowić się nad strategią wykonania zapytania. O ile kolumna tabeli facts, będąca obiektem warunku, nie jest poindeksowana (sytuacja mało prawdopodobna i niezbyt zalecana), nadal punktem wyjścia w zapytaniu pozostanie jeden z wymiarów. Wybór odpowiedniego wymiaru jest uzależniony od kilku czynników — jednym z nich, lecz z pewnością nie najważniejszym, jest selektywność. Należy pamiętać o wpływie klastrowania na indeksy oraz o tym, że indeks odpowiadający fizycznej kolejności danych w tabeli będzie działał wydajniej od innych (mniejsze znaczenie ma tu, czy zgodność tej kolejności wynika ze ślepego przypadku związanego z kolejnością ładowania danych do tabeli, czy też z faktu zastosowania klastra w oparciu o dany indeks). To samo zjawisko zachodzi między tabelą faktów a wymiarami. Kolejność wierszy w tabeli facts może być zgodna z kolejnością dat po prostu dlatego, że wiersze są dodawane do tej tabeli w cyklu dziennym — mają one kolejność zgodną z kolejnością wierszy w tabeli wymiaru czasowego. Kolejność wierszy może być również skorelowana z wymiarem lokalizacji, ponieważ dane mogą być ładowane z różnych źródeł po kolei. Schemat gwieździsty ma cechę charakterystyczną: wygląda symetrycznie, podobnie jak model relacyjny nie uwzględnia kolejności wyników. Jednak kwestie implementacyjne i procesy operacyjne stosowane na modelu relacyjnym mogą psuć tę teoretyczną symetrię schematu gwiazdy. Warto w jak najszerszym zakresie korzystać tej ukrytej asymetrii.
378
ROZDZIAŁ DZIESIĄTY
Szczególnie warto wykorzystać powiązania między wybranymi wymiarami, po których jest określony filtr selekcji, a tabelą faktów. W takim przypadku najczęściej warto po prostu wykonać złączenie tej tabeli wymiaru z tabelą faktów, co da szczególnie dobre rezultaty, gdy kryterium wykorzystujące tę tabelę wymiaru będzie bardzo selektywne. Należy zwrócić uwagę, że w tym konkretnym przypadku musimy dokonać złączenia z samą tabelą faktów, nie wystarczy zabieg z jej indeksem klucza głównego. Dzięki temu uzyskamy nadzbiór wynikowego zbioru wierszy po minimalnym koszcie odwiedzonych stron danych i z wczesnym zastosowaniem najbardziej selektywnego kryterium. Pozostałe kryteria są sprawdzane w kolejnych etapach. Sposób ładowania danych do schematu gwieździstego może spowodować, że niektóre wymiary będą generowały skuteczniejsze kryteria od innych.
Kilka słów przestrogi Modelowanie wymiarowe jest techniką, nie teorią, i jest popularne, ponieważ dobrze spisuje się w połączeniu z niedoskonałymi1 narzędziami wykorzystywanymi w systemach wspomagania decyzji. To dopasowanie jest o tyle kompletne, że spełnione są też inne warunki wydajnego działania modelu wymiarowego: wymagane w nim „indeksowanie dywanowe” (pojęcie ukute na wzór nalotu dywanowego) jest akceptowalne wyłącznie w systemach ze znacznie przeważającą liczbą operacji odczytu (oczywiście z pominięciem operacji ładowania danych do tabel). Problem jednak polega na tym, że w przypadku 10 – 15 wymiarów w tabeli faktów będziemy mieli 10 – 15 kluczy głównych, które muszą być poindeksowane, jeśli zależy nam na akceptowalnej wydajności zapytań. Jak wiemy, wymiary są dość statycznymi obiektami i nie mają wielkich rozmiarów, zatem indeksowanie w samych wymiarach nie stanowi większego kłopotu. Jednak w przypadku tabeli faktów indeksy to już zupełnie inna historia: można spodziewać się, że osiągną ogromne rozmiary — wyobraźmy sobie po prostu sieć sklepów spożywczych rejestrujących w hurtowni danych fakt sprzedaży każdego artykułu. Do tabeli faktów nowe wiersze będą 1
Ich niedoskonałość nie powinna dziwić biorąc pod uwagę, że sam SQL również święty nie jest.
GROMADZENIE SIŁ
379
dodawane w dużym tempie i właściwie na bieżąco. W rozdziale 3. mieliśmy okazję przekonać się, że w operacjach wstawiania największy koszt z punktu widzenia wydajności stanowi właśnie utrzymanie indeksów. Powszechnie stosowana jest technika przyspieszająca ładowanie większych porcji danych polegająca na usunięciu wszystkich indeksów, załadowaniu danych i ponownym odbudowaniu indeksów (w najlepszym razie w sposób jak najbardziej współbieżny). Ta technika może działać jakiś czas, ale ponowne indeksowanie będzie z pewnością zajmować coraz więcej czasu wraz ze zwiększaniem się rozmiarów tabeli. Indeksowanie wymaga sortowania, a sortowanie (o czym dowiedzieliśmy się na początku tego rozdziału) należy do operacji, które najdotkliwiej odczuwają skutki zwiększania się rozmiarów danych. Prędzej czy później okaże się, że odtworzenie indeksów zajmuje za dużo czasu; może również okazać się, że pojawili się nowi użytkownicy systemu pracujący na innym kontynencie, którzy chcą korzystać z niego w nocy, czyli wtedy, gdy mieliśmy zaplanowaną operację ładowania danych z usunięciem indeksów. Użytkownicy korzystający z systemu w różnych porach dnia i nocy wymuszają maksymalne skrócenie okresu, gdy baza danych jest poddana operacjom związanym z utrzymaniem, na przykład ładowaniem nowych danych. Tymczasem z faktu, że odtworzenie indeksów zajmuje więcej czasu z powodu zwiększania się ilości danych w tabelach systemu wspomagania decyzji, wynika, iż ładowanie danych będzie miało tendencję do wydłużania się. Zamiast ładować dane w wielkich porcjach co noc, korzystniejsze może okazać się zastosowanie zasady ładowania danych na bieżąco, w momencie ich wpłynięcia do systemu hurtowni danych. W tym przypadku problemem może okazać się denormalizacja, ponieważ im bardziej zbliżymy się do modelu ładowania danych na bieżąco, tym bardziej system będzie przypominał model transakcyjny, w którym ochrona integralności jest najskuteczniejsza w przypadku pełnej normalizacji. Ustępstwa poczynione w zakresie normalizacji są do przyjęcia w skrupulatnie kontrolowanym, sterylnym środowisku: wieży z kości słoniowej. Im sztab wojenny znajduje się bliżej pola bitwy, tym bardziej przenikają do niego rządzące bitwą reguły wojny.
380
ROZDZIAŁ DZIESIĄTY
ROZDZIAŁ JEDENASTY
Fortele Jak uratować czasy reakcji But my doctrines and I begin to part company. Moje doktryny i ja zaczynamy się rozmijać. — Thomas Hardy (1840 – 1928) Juda nieznany, IV, ii
382
M
ROZDZIAŁ JEDENASTY
am nadzieję, że w rozdziałach 1. i 2. udało mi się przekonać Czytelników, że prawdziwymi czynnikami wpływającymi na wydajność baz danych są: po pierwsze, projekt bazy danych, a po drugie, przejrzysta strategia i dobrze zaprojektowane programy. Smutna prawda jest taka, że jeśli ktoś zostanie uznany za skutecznego „konfiguratora” baz danych, ludzie nie zgłoszą się do niego po poradę aż do momentu, gdy pojawią się problemy z wydajnością. A to się przydarza — w najlepszym razie — w końcowych etapach testów, po wielomiesięcznym procesie pracy programistycznej. Taki fachowiec od konfiguracji baz danych jest wówczas proszony o dokonanie cudu, przy czym projekt bazy danych, architektura programu, a czasem nawet wymagania biznesowe stojące u podstaw systemu są po prostu błędnie opracowane. Niektóre z najbardziej kluczowych obszarów są związane z połączeniem ze starymi systemami, czyli wynikają z konieczności zapisywania danych lub odczytywania ich ze starych systemów. Jeśli miałbym wybrać jeden rozdział tej książki, którego zapamiętanie mógłbym wybaczyć Czytelnikowi, to byłby to właśnie ten. Jeśli Czytelnik chce cokolwiek zapamiętać, mam nadzieję, że nie będą to gotowe receptury (które często są raczej sztuczkami, a czasem mają wręcz rozrywkowy charakter), ale rozumowanie, które wpłynęło na zbudowanie receptur, ponieważ za każdym razem starałem się jak najwyraźniej opisać moje intencje. Nie ma lepszej strategii niż usiłowanie wykonania zadania jak najlepiej od samego początku, ale w próbach wybrnięcia z pozornie beznadziejnej sytuacji jest coś rycerskiego. W tym rozdziale Czytelnik znajdzie kilka technik, które często prowadzą programistów do wdrożenia dość mało eleganckich rozwiązań. Te rozwiązania nie tylko okazują się znacznie mniej efektywne, lecz nader często również o wiele mniej zrozumiałe i trudniejsze w utrzymaniu niż nawet najbardziej skomplikowane instrukcje w SQL-u. Na końcu tego rozdziału przekażę kilka uwag na temat powszechnie używanych forteli pośrednio wpływających na decyzje optymalizatora. Witamy w strefie mroku.
FORTELE
383
Przewracanie danych Najpowszechniejszym błędem, z jakim można się spotkać przy rozwiązywaniu problemów z zapytaniami SQL, jest sytuacja łagodnie określana „niekonwencjonalnym” projektem bazy danych. W takim przypadku samo napisanie zapytania działającego poprawnie to pierwsze, bardzo widoczne wyzwanie. Jednak należy podkreślić, że skomplikowane zapytania pisane przez programistów zmuszonych do pracy przy kiepskim projekcie bazy stanowią jedynie odzwierciedlenie skomplikowania programów (wykorzystujących wyzwalacze i procedury osadzone) wymuszonego przez kiepski projekt w celu wykonania podstawowych czynności bazy danych, jak kontrola integralności. Prawidłowy projekt pozwala natomiast zadeklarować ograniczenia, dzięki czemu sam silnik bazy danych będzie zajmował się kontrolą, redukując ryzyko związane ze wzrostem poziomu komplikacji programów. Zapewnianie integralności danych to wszak jeden z celów tworzenia systemów zarządzania bazami danych. Niestety, nieprawidłowe projekty zmuszają do poświęcania mnóstwa czasu na kontrolę integralności danych w ramach samej aplikacji. Jako dodatek (gratis!) dostajemy duże prawdopodobieństwo powstania poważnych błędów w tak skomplikowanych programach. W przeciwieństwie do popularnego oprogramowania wykorzystywanego na co dzień przez miliony użytkowników, gdzie błędy są szybko wykrywane i usuwane, aplikacja używana w ramach jednej firmy może zawierać błędy przez wiele miesięcy, zanim się ujawnią.
Wiersze, które powinny być kolumnami Wiersze, które powinny być zdefiniowane jako kolumny, to jeden z najczęściej spotykanych błędów projektowych. Tego typu „atrakcyjny” projekt daje duże możliwości: prosta, czterokolumnowa tabela: entity_id, attribute_name, attribute_type, attribute_value sprawia wrażenie bardzo elastycznej, pozwalającej rozwiązać wiele przyszłych problemów z ewolucją wymagań biznesowych. Co przerażające, wielu zwolenników tego typu modelu danych jest święcie przekonanych co do nowoczesności i zaawansowania tej techniki, również z punktu widzenia normalizacji! Ten model można znaleźć pod wieloma, często bardzo szumnymi nazwami: meta-design lub fact dimension — ta nazwa jest szczególnie popularna wśród projektantów hurtowni danych.
384
ROZDZIAŁ JEDENASTY
Zwolennicy tego czterokolumnowego projektu wychwalają jego bezgraniczną „elastyczność”. Jednak uważam, że zachodzi tu pewne nieporozumienie między pojęciami „elastyczność” a „brak kręgosłupa”. Możliwość dodawania atrybutów „w locie” nie ma wiele wspólnego z elastycznością, w końcu z takimi atrybutami coś trzeba później zrobić. Wątpliwa korzyść z możliwości wstawienia wiersza do tabeli zamiast przeprowadzenia solidnej pracy nad projektem schematu bazy danych jest absolutnie nieistotna, biorąc pod uwagę czas niezbędny na zaprogramowanie po pierwsze: całej logiki każdego z atrybutów zapisanych w takiej tabeli, a po drugie i trudniejsze: choćby najmniejszego zakresu integralności zapisanych w ten sposób danych. Prawidłowy sposób obsługi zmiennej liczby atrybutów polega na definiowaniu podtypów, co zostało opisane w rozdziale 1. Podtypy pozwalają definiować więzy integralności, czyli warunki, które muszą być spełnione, aby dane zostały zapisane w bazie. Więzów tego typu nie trzeba programować ani utrzymywać w ramach zmiennych wymogów biznesowych aplikacji. Baza danych nie powinna być jedynie pojemnikiem na dane wrzucane „luzem” w oderwaniu od jakiejkolwiek ich semantyki. Główną cechą zapytań na tabelach typu meta-design, czyli zdefiniowanych na „magicznych”, wszechmocnych czterech atrybutach, jest to, że w jednym zapytaniu nazwa tabeli pojawia się bardzo wiele razy w klauzuli FROM. Typowe zapytanie tego typu wygląda następująco: select emp_last_name.entity_id employee_id, emp_last_name.attribute_value last_name, emp_first_name.attribute_value first_name, emp_job.attribute_value job_description, emp_dept.attribute_value department, emp_sal.attribute_value salary from employee_attributes emp_last_name, employee_attributes emp_first_name, employee_attributes emp_job, employee_attributes emp_dept, employee_attributes emp_sal where emp_last_name.entity_id = emp_first_name.entity_id and emp_last_name.entity_id = emp_job.entity_id and emp_last_name.entity_id = emp_dept.entity_id and emp_last_name.entity_id = emp_sal.entity_id and emp_last_name.attribute_name = 'LASTNAME' and emp_first_name.attribute_name = 'FIRSTNAME' and emp_job.attribute_name = 'JOB' and emp_dept.attribute_name = 'DEPARTMENT' and emp_sal.attribute_sal = 'SALARY' order by emp_last_name.attribute_value
FORTELE
385
Warto zwrócić uwagę na to, że ta sama tabela jest wymieniana pięciokrotnie w klauzuli FROM. Liczba złączeń tabeli z samą sobą w typowych zastosowaniach bywa znacznie większa niż w tym uproszczonym przykładzie. Co więcej, tego typu zapytania są często dodatkowo „przyprawione” kilkoma złączeniami zewnętrznymi (OUTER JOIN). Zapytanie zawierające dużą liczbę złączeń tabeli z samą sobą ma tendencję do bardzo powolnego działania przy dużej ilości danych. Z drugiej strony widać jasno, że jedynym powodem wykonywania tak wielkiej liczby złączeń jest połączenie większej liczby atrybutów w jedną całość. Gdyby ta tabela została zaprojektowana w bardziej logiczny sposób: employees(employee_id, last_name, first_name, job_description, department, salary), powyższe zapytanie odzyskałoby swoje piękno
i prostotę: select * from employees order by last_name
Najlepszy sposób wykonania tego zapytania to oczywiście zwykłe przeszukiwanie sekwencyjne. Wielokrotne złączenia i związany z tym faktem wielokrotny dostęp do indeksów tabeli employee_attributes to oczywiste przyczyny znacznego obniżenia wydajności działania takiego modelu. Nigdy nie uda się zmusić zapytania do wydajnego działania w beznadziejnie zaprojektowanej bazie z wydajnością taką jak w przypadku bazy zaprojektowanej wzorcowo. Każde zmyślne przepisanie zapytania SQL w błędnie zaprojektowanych tabelach nie będzie czymkolwiek innym jak drewnianą nogą, czyli protezą dla w tak oczywisty sposób kalekiego modelu. Jednak czasem udaje się osiągnąć zupełnie spektakularne wyniki, w każdym razie w porównaniu z zapytaniem z wielokrotnymi samozłączeniami, dzięki uzyskaniu jednoprzebiegowego odczytu tabeli atrybutów. W wyniku chcemy po prostu uzyskać pojedynczy wiersz zawierający wiele atrybutów (odzwierciedlający, a raczej symulujący wynik, jaki można by uzyskać bez żadnych forteli dzięki zastosowaniu klasycznego projektu tabeli) zamiast wielu wierszy zawierających po jednym atrybucie. Konsolidacja wielowierszowego wyniku w pojedynczy wiersz to dość łatwa sztuczka i znamy ją doskonale: dokładnie tak działają funkcje agregujące. Pomysł zatem polega na dwóch etapach, przedstawionych na rysunku 11.1:
386
ROZDZIAŁ JEDENASTY
1. Każdy wiersz wynikowy w zapytaniu uzupełniamy o puste wartości w liczbie zgodnej z docelową liczbą atrybutów. 2. Agregujemy poszczególne wiersze w taki sposób, aby z każdego uzyskać pojedynczą wartość (jedną wartość w każdej z kolumn). Doskonale nadaje się do tego funkcja max(), która ma tę zaletę, że można ją wykonać na danych większości typów.
RYSUNEK 11.1. Przekształcenie wielu wierszy w jeden
Aby upewnić się, że max() zwróci jedynie znaczące wartości, musimy zastosować wartości „puste”, jednak mniejsze od dowolnej wartości, która może być przypisana atrybutowi. Z pewnością lepiej jest użyć rzeczywistej wartości zamiast NULL, mimo tego że zgodnie z definicją standardu max() ignoruje NULL-e. Jeśli ta „receptura” (przedstawiona na rysunku 11.1) zostanie zastosowana w naszym przykładzie, możemy pozbyć się wielokrotnych złączeń i napisać coś takiego: select employee_id, max(last_name) last_name, max(first_name) first_name, max(job_description) job_desription, max(department) department, max(salary) salary from -- wybranie wszystkich wierszy i zwrócenie ich -- w postaci kolumn odpowiadających -- poszczególnym wierszom z użyciem wartości -- "mniejszych" od dowolnej poprawnej wartości
FORTELE
387
(select entity_id employee_id, case attribute_name when 'LASTNAME' then attribute_value else " end last_name, case attribute_name when 'FIRSTNAME' then attribute_value else '' end first_name, case attribute_name when 'JOB' then attribute_value else '' end job_description, case attribute_name when 'DEPARTMENT' then attribute_value else -1 end department, case attribute_name when 'SALARY' then attribute_value else -1 end salary from employee_attributes where attribute_name in ('LASTNAME', 'FIRSTNAME', 'JOB', 'DEPARTMENT', 'SALARY')) as inner group by inner.employee_id order by 2
Wewnętrzne zapytanie nie jest niezbędne, możemy wykorzystać serię operacji max(case when ... end), ale tak skonstruowane zapytanie wyraźniej zaznacza konieczność wykonania dwóch etapów. Jak można się domyślić, agregat nie jest najwydajniejszym sposobem na realizację tego zadania. Ale w królestwie ślepców jednooki jest królem, a zapytanie tego typu z reguły nie charakteryzuje się wielkimi problemami z wydajnością, z pewnością zaś jest znacznie wydajniejsze od monstrualnego wielokrotnego samozłączenia. Należy się jednak słowo przestrogi: aby zabezpieczyć się na wypadek atrybutów o znacznych długościach, kolumna attribute_value jest z reguły definiowana jako ciąg znaków o znacznej długości maksymalnej. W efekcie proces agregacji może wykorzystywać sporą ilość pamięci, a w skrajnych przypadkach, przy kilkudziesięciu atrybutach, zapytanie może po prostu się nie wykonać.
388
ROZDZIAŁ JEDENASTY
Wielokrotnych złączeń tabeli z samą sobą można uniknąć, pobierając wszystkie wiersze w pojedynczym przebiegu, ładując wszystkie wartości do kolejnych kolumn i wykorzystując funkcję agregującą do połączenia ich w jeden wiersz.
Kolumny, które powinny być wierszami W odróżnieniu od poprzedniego projektu, w którym każdy atrybut był zapisywany w osobnym wierszu, kolejnym przykładem złego projektu bazy danych jest taki, gdzie tworzone są kolumny zamiast zapisywania danych w wierszach. Klasyczny błąd popełniany przez wielu początkujących projektantów baz danych polega na zdefiniowaniu ustalonej liczby kolumn na wartościach zmiennych, z założeniem, że w przypadku braku zmiennej odpowiedniej kolumnie będzie przypisany NULL. Typowy przykład takiej sytuacji przedstawia rysunek 11.2. Mamy tu źle zaprojektowaną bazę filmów (warto porównać ją z poprawnym projektem omówionym w rozdziale 8., a zaprezentowanym na rysunku 8.3).
RYSUNEK 11.2. Źle zaprojektowana baza filmów
Zamiast zapisywać nazwiska osób w wierszach tabeli movie_credits (jak to zrobiliśmy w rozdziale 8.) z zastosowaniem dodatkowej kolumny określającej funkcję osoby w filmie, zdecydowano, że odpowiednie informacje będą zapisywane w dodatkowych kolumnach tabeli głównej, przez co na nazwiska aktorów i reżysera jest dedykowana stała liczba kolumn. Pierwsze założenie (liczba aktorów) jest błędne, ale błędne jest też drugie, ponieważ wiele filmów miało dwóch, a czasem więcej reżyserów. Ten model jest niedoskonały jako reprezentacja rzeczywistości, co powinno być wystarczającym powodem do jego odrzucenia. Aby jeszcze pogorszyć sprawę, w systemach wykorzystujących kolumny do zapisywania
FORTELE
389
danych, które powinny być zapisane w wierszach, często zdarza się, że raporty są generowane w ujęciu wierszowym, co zmusza programistów do pisania nieczytelnych zapytań. Niestety, pisanie zapytań w źle zaprojektowanej bazie danych wydaje się przykrą koniecznością w świecie SQL-a, jak śmierć i podatki. Gdy jesteśmy zmuszeni do zaprezentowania w postaci raportu wierszowego danych zapisanych w kolumnach tabeli, wykorzystuje się tabele przestawne (ang. pivot tables). Tabele przestawne służą do przewracania danych „na bok”: kolumny stają się wierszami, a wiersze kolumnami. Takie tabele (w kontekście SQL-a) są tabelami pomocniczymi składającymi się z pojedynczej kolumny wypełnionej wartościami od 1 do n. Tabela przestawna może być właściwą tabelą lub perspektywą, a nawet zapytaniem osadzonym w ramach klauzuli FROM zapytania. Wykorzystanie takich tabel pomocniczych jest ulubionym fortelem doświadczonych programistów SQL-a. W kolejnych punktach podrozdziału wyjaśnię, w jaki sposób je konstruować i ich używać.
Tworzenie tabeli przestawnej Do generowania tabel przestawnych bardzo wygodne są konstrukcje definiowania drzew omówione w rozdziale 7. Do tego celu możemy wykorzystać rekurencyjną klauzulę WITH (w tych systemach, gdzie jest dostępna). Oto przykład dla bazy danych DB2, generujący liczby z przedziału od 1 do 50: with pivot(row_num) -- Generuje 50 wartości -- od 1 do 50, jedna wartość w wierszu as (select 1 row_num from sysibm.sysdummy1 union all select row_num + 1 from pivot where row_num < 50) select row_num from pivot;
Podobne mechanizmy są oczywiście dostępne również w Oracle z użyciem instrukcji CONNECT BY, na przykład1: select level from dual connect by level <= 50 1
Taka konstrukcja może nie działać w starszych wersjach Oracle.
390
ROZDZIAŁ JEDENASTY
Używanie jednej z tych konstrukcji w ramach klauzuli FROM zapytania może spowodować znaczne obniżenie jego czytelności, dlatego często zaleca się wykorzystanie zwykłej tabeli w charakterze tabeli przestawnej. Jednak takie rekurencyjne zapytanie może być użyteczne choćby do wypełnienia tabeli wartościami (inny sposób polega na użyciu iloczynu kartezjańskiego istniejących tabel). Z reguły wystarczy, aby tabela przestawna zawierała około tysiąc wierszy.
Rozmnażanie wierszy z użyciem tabeli przestawnej Gdy już mamy tabelę przestawną, co możemy z nią zrobić? Jednym ze sposobów użycia tabeli przestawnej jest rozmnażanie wierszy. Przez odpowiednie złączenie tabeli przestawnej z tabelą, której wiersze chcemy rozmnożyć, spowodujemy zwielokrotnienie wierszy w tabeli wynikowej. Określenie liczby duplikatów następuje przez ograniczenie liczby wierszy tabeli przestawnej wchodzących do złączenia: where pivot.row_num <= liczba_powtorek
W ten sposób można w prosty sposób zwielokrotnić wiersze tabeli pracowników. Oto zawartość tabeli: SQL> select name, job 2 from employees; NAME --------Tomasz Ryszard Henryk
JOB ----------------------------Manager Inżynier oprogramowania Inżynier oprogramowania
Zwielokrotnienia dokonamy w następujący sposób: SQL> select e.name, e.job, p.row_num 2 from employees e, 3 pivot p 4 where p.row_num <= 3; NAME --------Tomasz Ryszard Henryk Tomasz Ryszard
JOB ROW_NUM --------------------------- ------Manager l Inżynier oprogramowania 1 Inżynier oprogramowania 1 Manager 2 Inżynier oprogramowania 2
FORTELE
Henryk Tomasz Ryszard Henryk
Inżynier oprogramowania Manager Inżynier oprogramowania Inżynier oprogramowania
391
2 3 3 3
9 rows selected.
Dobrym pomysłem jest poindeksowanie jedynej kolumny tabeli przestawnej, aby uniknąć pełnego przeszukiwania tej tabeli, gdy potrzebujemy tylko kilku jej wierszy.
Wykorzystanie wartości z tabeli przestawnej Oprócz prostego efektu zwielokrotnienia, iloczyn kartezjański z tabelą przestawną pozwala przypisać unikalne liczby różnym wartościom z tabeli, którą chcemy obrócić. Wartości te pochodzą po prostu z kolumny row_num pochodzącej z tabeli przestawnej. Ta technika pozwoli wybrać z każdej kopii wiersza tylko określone dane. Pełny proces zwielokrotnienia wierszy źródłowych został przedstawiony na rysunku 11.3. Jeśli chcemy, aby pierwszy wiersz wystąpił w wyniku jako pojedyncza kolumna (co z kolei wymaga, aby typy danych w col1, …, coln były spójne), z każdego wiersza iloczynu kartezjańskiego tych tabel musimy wybrać wartość z odpowiedniej kolumny. Sprawdzając liczbę pochodzącą z tabeli przestawnej, za pomocą operatora CASE możemy określić kolumnę, która ma być wyświetlona w każdym wierszu wynikowym. Na przykład wyświetlimy kolumnę col1, gdy numer wiersza z tabeli przestawnej jest równy 1, col2, gdy numer wiersza jest równy 2 i tak dalej.
RYSUNEK 11.3. Obracanie wiersza
392
ROZDZIAŁ JEDENASTY
Nie muszę dodawać, że zwielokrotnianie wierszy i odrzucanie większości danych to nie najwydajniejszy sposób przetwarzania danych. Należy także wziąć pod uwagę, że w tym przypadku wiosłujemy pod prąd. W idealnej bazie danych nie powinno być konieczności wykonywania tego typu operacji zwielokrotniania i odrzucania. Co ciekawe, przy założeniu pracy w słabo (delikatnie mówiąc) zaprojektowanej bazie danych użycie tabel przestawnych może stanowić technikę poprawiającą wydajność. Załóżmy, że w naszej źle zaprojektowanej bazie danych filmów chcemy policzyć liczbę zarejestrowanych aktorów (przypominam, że żadna z kolumn actor_... nie jest poindeksowana, co powoduje, że musimy odczytać wszystkie wartości w tabeli). Jeden ze sposobów realizacji tego zadania polega na użyciu operatora UNION: select count(*) from (select actor_i from movies union select actor_2 from movies union select actor_3 from movies) as m
'
Można jednak użyć tabeli przestawnej, co da wynik zbliżony do tabeli movie_credits z poprawnego projektu tej bazy danych: select count(distinct actor_id) from -- Wykorzystanie 3-wierszowej tabeli przestawnej, -- która posłuży do potrojenia liczby wierszy -- actor_1 będzie zapisany w pierwszym wierszu, -- actor_2 w drugim, -- a actor_3 w trzecim wierszu wynikowym (select case pv.row_num when 1 then actor_1 when 2 then actor_2 else actor_3 end actor_id from movies as m, pivot as pv where pv.row_num <= 3) as m
Druga wersja zapytania działa około dwukrotnie szybciej, co stanowi znaczące przyspieszenie.
FORTELE
393
Operacje odwrócenia i przywrócenia Smutnym dowodem na powszechność kiepskich projektów baz danych jest wprowadzenie w produkcie MS SQL Server 2005 dwóch operatorów o nazwach PIVOT i UNPIVOT, których zadanie polega na zamienianiu wierszy w kolumny i vice versa. Poprzedni przykład z tabelą employee_attributes można dzięki tym operatorom zapisać następująco: select entity_id as employee_id, [lastname], [firstname], [job], [department], [salary] from employee_attributes as employees pivot (max(attribute_value) for attribute_name in ([lastname], [firstname], [job], [department], [salary]) as pivoted_employees order by 2
Wartości w kolumnie attribute_name są wymienione w klauzuli FOR ... IN z użyciem składni przekształcającej dane tekstowe w identyfikatory kolumn. Wykorzystywana jest tu niejawna operacja GROUP BY wykonywana na kolumnach tabeli employee_attributes niewymienionych w klauzuli PIVOT. Jeśli zatem w tabeli oprócz kolumny entity_id występują inne (na przykład attribute_type), może być konieczne zastosowanie dodatkowej warstwy agregującej. Operator UNPIVOT wykonuje operację odwrotną i pozwala na znalezienie powiązania między filmem a aktorami w bardziej logicznej sekwencji par (movie_id, actor_id): select movie_id, actor_type, actor_id from movies unpivot (actor_id for actor_type in ([actor_1], [actor_2], [actor_3])) as movie_actors
Warto zwrócić uwagę, że to zapytanie nie generuje dokładnie takiego wyniku, o jaki nam chodzi, ponieważ wprowadza nazwę oryginalnej kolumny jako dodatkową, wirtualną kolumnę actor_type. Nie ma jednak
394
ROZDZIAŁ JEDENASTY
konieczności odwoływać się do aktorów z użyciem kolumn actor_1, actor_2 i actor_3. Również to zapytanie można opakować w inne, które zwróci jedynie interesujące nas kolumny movie_id i actor_id. Tabela przestawna oraz operatory PIVOT i UNPIVOT stanowią ciekawe przykłady technik pomocnych w wielu pułapkach wynikających z nieodpowiedniego projektu danych. Wsparcie ze strony poważnych systemów baz danych w postaci operatorów przekształcających wiersze w kolumny nie jest oczywiście wyrazem poparcia dla kiepskiego projektowania baz danych, ale jest z pewnością odpowiedzią na potrzeby rynku. Tabele i operatory przestawne stanowią interesujące techniki przetwarzania danych, ale nie powinny być używane do „nakładania lukru” na niekompetentnie zaprojektowanym projekcie danych.
Pojedyncze kolumny, które powinny być czymś innym Projektanci naszej bazy filmów (tej kiepskiej wersji) mogą z czasem zauważyć problem z ograniczeniem liczby aktorów. Próbując rozwiązać ten problem za pomocą nieodpowiednich technik, ktoś może wpaść na genialny pomysł: przecież identyfikatory aktorów można zapisać w postaci ciągu znaków w formacie przecinkowym. Na przykład: id_pierwszego_aktora, id_drugiego_aktora, ...
I to by było na tyle z pierwszą postacią normalną… Wielki błąd projektowy polega tu na zapisaniu wielu informacji w pojedynczej kolumnie. Nie byłoby problemu, gdyby zapisać ciąg znaków na przykład w formacie XML — jednak pod warunkiem że przez bazę danych jest on postrzegany po prostu jako obiekt, wartość atomowa. W tym przypadku jednak tak nie jest. Mamy tu do czynienia z jawnym zapisem kilku wartości w jednej kolumnie, co więcej, istnieje założenie, że będziemy chcieli korzystać z każdej z nich indywidualnie. No i mamy kłopot. Istnieją tylko dwa rozsądne rozwiązania w przypadku tego typu błędu projektowego:
FORTELE
395
• Wyrzucić projekt i zacząć od nowa. To jest oczywiście rozwiązanie idealne. • Gdy opóźnienia w realizacji projektu, koszty i „względy polityczne” wymagają zastosowania szybkiego rozwiązania, jedyny sposób może polegać na użyciu kreatywnego rozwiązania opartego na SQL-u. Ponownie podkreślę, że „rozwiązanie” to prawdopodobnie nie najlepsze określenie. Lepiej byłoby użyć określenia „łata” lub „prowizorka”. Warto również wspomnieć, że bardziej „zaawansowane” rozwiązanie tego problemu mogłoby polegać na użyciu wspomnianego wcześniej formatu XML. W tym przykładzie, dla uproszczenia, posłużę się prostymi funkcjami obróbki tekstów, ale równie dobrze mogłyby to być funkcje manipulujące danymi w formacie XML. UWAGA Należy pamiętać, że „kreatywne użycie SQL-a” to eufemizm określenia „nieeleganckie użycie SQL-a”.
Normalizacja „w locie” Nasz problem polega na wydobyciu poszczególnych elementów z ciągu znaków i zwróceniu ich w osobnych wierszach. W niektórych systemach baz danych zadanie to jest proste (na przykład Oracle oferuje bogaty wybór funkcji przetwarzających ciągi znaków, które znacznie ułatwiłyby pracę). Zastosowanie konwencji stosowania znaku sygnalizującego koniec lub początek ciągu znaków znacznie uprościłoby sprawę. Nie jesteśmy jednak mięczakami, lecz poważnymi programistami SQL-a, bez lęku zatem weźmiemy na cel najsilniejszego wroga, czyli zabezpieczymy się przed najgorszym: Po pierwsze, przyjmiemy założenie, że lista identyfikatorów jest następującej postaci: id1,id2,id3, ..., idn
Po drugie, założymy, że mamy do dyspozycji jedynie podzbiór zupełnie podstawowych funkcji obróbki ciągów znaków dostępnych w większości baz danych. W naszym przykładzie posłużę się narzeczem Transact-SQL i wykorzystam jedynie podstawowe funkcje. Jak będzie można zaobserwować, dobrze napisana funkcja użytkownika może uprościć tworzenie, jak i zapewnić wydajność zapytania, w którym jest użyta.
396
ROZDZIAŁ JEDENASTY
Na początek zapoznajmy się z bardzo małą bazą filmów, w której identyfikatory aktorów są zapisane (w sposób niezgodny z zasadami normalizacji) w postaci ciągu znaków w formacie przecinkowym: 1> select movie_id, actors 2> -from movies 3> go movie_id actors --------------------- ----------------------------------1 123,456,78,96 2 23,67,97 3 67,456 (3 rows affected)
W pierwszym kroku utworzymy tabelę przestawną o liczbie wierszy odpowiadającej maksymalnemu rozmiarowi kolumny actors (pięćdziesiąt znaków). Wiersze w tabeli movies zwielokrotnimy o tę liczbę, czyli pięćdziesiąt. Oczywiście w przypadku tabeli zawierającej miliony wierszy należałoby się zastanowić przed wykonaniem operacji tego typu (na marginesie: gdyby była dostępna funkcja zwracająca n-ty znak ciągu, tabelę movies wystarczyłoby zwielokrotnić o maksymalną liczbę identyfikatorów, jakie można zapisać w kolumnie actors, nie o maksymalną liczbę znaków). W następnym etapie wykorzystamy funkcję substring(), za pomocą której odczytamy kolejne podciągi (może być również pusty) ciągu znaków actors, rozpoczynając od pierwszego znaku, następnie przechodząc do drugiego i tak dalej aż do ostatniego znaku (maksymalnie pięćdziesiątego). Aby uzyskać każdy podciąg, wystarczy użyć wartości row_num uzyskanej z tabeli przestawnej. Weźmy na przykład ciąg znaków z kolumny actors wiersza tabeli movies identyfikowanego przez movie_id = 1, otrzymamy następującą sekwencję działań: 123,456,78,96 23,456,78,96 3,456,78,96 ,456,78,96 456,78,96 56,78,96 6,78,96 ,78,96 78,96 ...
dla row_num = 1 dla row_num = 2 dla row_num = 3
FORTELE
397
Te podzbiory zapiszemy w wirtualnej kolumnie o nazwie substring1. Po znalezieniu tych podciągów możemy sprawdzić pozycję pierwszego przecinka. W następnym etapie zwrócimy kolumnę o nazwie substring2 zawierającą zawartość kolumny substring1 z obciętym jednym znakiem z lewej strony. Również w substring2 wyszukamy pozycję pierwszego przecinka. Operacje zostały przedstawione na rysunku 11.4. Spośród wielu wierszy wynikowych interesują nas tylko te, które zawierają znak sygnalizujący początek nowego identyfikatora, czyli pierwszy wiersz w substring1 (o wartości row_num = 1) oraz wszystkie zawierające znak przecinka na początku podciągu. W tych ciągach znaków pozycja przecinka w substring2 informuje nas o długości identyfikatora.
RYSUNEK 11.4. Rozdzielanie elementów listy w formacie przecinkowym
W przetłumaczeniu na SQL ten algorytm będzie miał następującą postać: l> select row_num, 2> movie_id, 3> actors, 4> first_sep, 5> next_sep 6> from (select row_num, 7> movie_id, 8> actors, 9> charindex(',' > substring(actors, row_num, 10> char_length(actors))) first_sep, 11> charindex(',', substring(actors, row_num + 1, 12> char_length(actors))) + 1 next_sep 13> from movies,
398
ROZDZIAŁ JEDENASTY
14> pivot 15> where row_num <= 50) as q 16> where row_num = 1 17> or first_sep = 1 18> go row_num movie_id actors first_sep next_sep --------------- ------------- ------------------ ---------- -----------1 1 123,456,78,96 4 4 4 1 123,456,78,96 1 5 8 1 123,456,78,96 1 4 11 1 123,456,78,96 1 1 1 2 23,67,97 3 3 3 2 23,67,97 1 4 6 2 23,67,97 1 1 1 3 67,456 3 3 3 3 67,456 1 1 (9 rows affected)
Po uzupełnieniu kodu usuwającego przecinki (uwzględniającego szczególne przypadki pierwszej i ostatniej pozycji na liście) odczytanie identyfikatorów staje się łatwym zadaniem, choć trudno stwierdzić, żeby kod wynikowy był szczególnie elegancki: 1> select movie_id, 2> actors, 3> substring(actors, 4> case row_num 5> when 1 then 1 6> else row_num + 1 7> end, 8> case next_sep 9> when 1 then char_length(actors) 10> else 11> case row_num 12> when 1 then next_sep - 1 13> else next_sep - 2 14> end 15> end) as id 16> from (select row_num, 17> movie_id, 18> actors, 19> first_sep, 20> next_sep 21> from (select row_num, 22> movie_id, 23> actors, 24> charindex(',', substring(actors, row_num,
FORTELE
399
25> char_length(actors))) first_sep, 26> charindex(',', substring(actors, row_num + 1, 27> char_length(actors))) + 1 next_sep 28> from movies, 29> pivot 30> where row_num <= 50) as q 3i> where row_num = 1 32> or -first_sep = 1) as q2 33> go movie_id actors id ------------------- ---------------------- ---------------1 123,456,78,96 123 1 123,456,78,96 456 1 123,456,78,96 78 1 123,456,78,96 96 2 23,67,97 23 2 23,67,97 67 2 23,67,97 97 3 67,456 67 3 67,456 456 (9 rows affected)
Kod byłby znacznie prostszy, gdyby przecinek znalazł się przed pierwszym identyfikatorem i po ostatnim. Odpowiednią modyfikację pozostawiam jako ćwiczenie dla Czytelnika. Należy zwrócić uwagę, że uzyskana kolumna id jest typu tekstowego, zatem przed dokonaniem złączenia z tabelą actors (zawierającą nazwiska aktorów) należy przekształcić ją na liczbę. Poprzedni przypadek oprócz tego, że stanowi interesujący sposób rozwiązania problemu przez kolejne opakowywanie zapytań innymi, dokonującymi dalszych przekształceń, stanowi też wyraźne ostrzeżenie przed niebezpieczeństwami (związanymi z ogromem pracy), jakie czyhają na programistę w źle zaprojektowanych baziach danych.
Rozwiązanie tajemnicy z rozdziału 7.: rozwinięcie ścieżki W rozdziale 7. przy okazji modelu zmaterializowanej ścieżki (reprezentacja struktur drzewiastych) pisałem o tym, że bardzo wygodnie byłoby uzyskać rozwinięcie zmaterializowanej ścieżki w taki sposób, aby uzyskać dostęp do zmaterializowanych ścieżek wszystkich przodków węzła. Taka funkcja jest bardzo przydatna przy przeglądaniu hierarchii metodą wstępującą, ponieważ możemy efektywnie wykorzystać indeks, który powinien być zdefiniowany na zmaterializowanej ścieżce. Jeśli nie rozwiniemy tej ścieżki, jedyny sposób znalezienia przodków węzła polega na następującym wywołaniu:
400
ROZDZIAŁ JEDENASTY
and offspring.materialized_path like concat(ancestor.materialized_path, '%')
Niestety, taka konstrukcja nie pozwala na wykorzystanie indeksu (z powodów bardzo zbliżonych do problemu prefiksów numerów kart kredytowych opisanego w rozdziale 8.). W jaki sposób rozwinąć zmaterializowaną ścieżkę? Nadszedł czas, aby wyciągnąć królika z kapelusza. Ponieważ w ogólnym przypadku nasz węzeł będzie miał kilku przodków, w pierwszej kolejności należy zwielokrotnić wiersz przez liczbę poprzedzających go generacji. W ten sposób będziemy mogli wydobyć zmaterializowane ścieżki przodków ze ścieżki zmaterializowanej wiersza wyjściowego (na przykład wiersz reprezentujący pułk Husarów pod dowództwem Colonela de Marbota). Jak zawsze w przypadku operacji zwielokrotniania wierszy posłużymy się tabelą przestawną. Załóżmy, że tym razem posłużymy się bazą danych MySQL. Mamy tu dostępną funkcję substring_index() zwracającą podciąg od początku do wystąpienia drugiego argumentu, gdzie trzeci argument wywołania wskazuje, o które wystąpienie nam chodzi (mam nadzieję, że przykład wyjaśni wszelki wątpliwości). Sprawdzenia liczby generacji można dokonać, zliczając elementy zmaterializowanej ścieżki, technika została omówiona w rozdziale 7.: porównujemy długość zmaterializowanej ścieżki z długością ciągu znaków powstałego po usunięciu znaków separatora ze ścieżki. Zapytanie realizujące to zadanie będzie miało następującą postać: mysql> select mp.materialized_path, -> substring_index(mp.materialized_path, '.', p.row_num) -> as ancestor_path -> from materialized_path_model as mp, -> pivot as p -> where mp.commander = 'Colonel de Marbot' -> and p.row_num <= 1 + length( mp.materialized_path) -> - length(replace(mp.materialized_path, '.', ")); +-------------------+---------------+ | materialized_path | ancestor_path | +-------------------+---------------+ | F.1.5.1.1 | F | | F.1.5.1.1 | F.1 | | F.1.5.1.1 | F.1.5 | | F.1.5.1.1 | F.1.5.1 | | F.1.5.1.1 | F.1.5.1.1 | +-------------------+---------------+ 5 rows in set (0.00 sec)
FORTELE
401
Zapytania ze zmienną listą parametrów Istnieje jeszcze jedno dość istotne zastosowanie tabel przestawnych. W poprzednim rozdziale starałem się podkreślić wagę stosowania zmiennych wiązanych, czyli przekazywania parametrów do zapytań SQL (zamiast dynamicznie składanych zapytań). Wiązanie zmiennych pozwala na pominięcie etapu analizy leksykalnej (czyli po prostu kompilacji) zapytania w przypadku, gdy silnik SQL-a już wykonywał takie zapytanie (różniące się jedynie parametrami). Należy pamiętać, że analiza leksykalna zapytania może być dość kosztowną operacją, porównywalną z operacją poszukiwania najlepszej ścieżki wykonawczej. Nawet w przypadku, gdy instrukcja SQL jest konstruowana w sposób dynamiczny, jest całkiem możliwe, że kolejne zapytania będą różniły się jedynie wartościami parametrów, które można przekazać do zapytania, o czym wspominałem w rozdziale 8. Istnieje jednak trudność: w przypadku, gdy użytkownik może wybrać wiele opcji wyszukiwania (na przykład z list rozwijanych), przez co zapytanie ma zmienną liczbę parametrów. Zmienna liczba parametrów wiąże się z następującymi problemami: • Wykorzystanie zmiennej liczby parametrów wywołania może nie być możliwe w niektórych językach programowania (często istnieje konieczność wiązania wszystkich zmiennych jednocześnie, nie jedna po drugiej), a jeśli jest możliwe, wiąże się z dość dużą uciążliwością. • Jeśli liczba parametrów jest odmienna za każdym razem, dwa zapytania różniące się jedynie liczbą dowiązanych zmiennych będą przez silnik SQL-a uznane za różne, przez co tracimy korzyści wynikające z wiązania zmiennych. Możliwość rozdzielania ciągów znaków udostępniona przez tabele przestawne umożliwia przekazanie listy wartości w postaci pojedynczego ciągu znaków, niezależnie od rzeczywistej liczby wartości. W tym podrozdziale zademonstruję tę technikę z użyciem bazy danych Oracle. Poniższy przykład przedstawia sposób przekazania listy parametrów do zapytania za pomocą pojedynczego ciągu znaków, jaki wybrałaby znakomita większość programistów SQL-a. W naszym przypadku ciąg znaków zapisany jest w zmiennej v_list. Większość programistów połączyłaby poszczególne elementy zapytania z listą v_list, tworząc w ten sposób gotowe zapytanie:
402
ROZDZIAŁ JEDENASTY
v_statement := 'select count(order_id)' || ' from order_detail' || ' where article_id in (' || v_list || ')'; execute immediate v_statement into n_count;
Ten przykład wygląda na zastosowanie dynamicznej metody budowania zapytań, lecz w rzeczywistości dla silnika bazy danych wygląda jak zapytanie statyczne. Dwa kolejne wywołania z nieco odmienną listą parametrów muszą zatem zostać poddane analizie leksykalnej przed wykonaniem. Czy można więc przekazać zmienną v_list jako parametr zapytania, zamiast łączyć go bezpośrednio z kodem zapytania? Można, wykorzystując dokładnie te same techniki, jakie wykorzystywaliśmy przy okazji listy identyfikatorów oddzielonych przecinkami (przy okazji bazy filmów i normalizacji „w locie”). Tabela przestawna umożliwia napisanie zapytania SQL następującej postaci: select count(od.order_id) into n_count from order_detail od, ( -- Zwraca taką liczbę wierszy, ile występuje elementów w liście, -- wykorzystuje funkcje znakowe do odczytu n-tego elementu -- z n-tego wiersza select to_number(substr(v_list, case row_num when 1 then 1 else 1 + instr(v_list, ',', 1, row_num – 1) end, case instr(v_list, ',', 1, row_num) when 0 then length(v_list) else case row_num when 1 then instr(v_list, ',', 1, row_num) - 1 else instr(v_list, ',', 1, row_num) – 1 - instr(v_list, ',', 1, row_num - 1) end end)) article_id from pivot where instr(v_list||',', ',', 1, row_num) > 0 and row_num <= 250) x where od.article_id = x.article_id;
FORTELE
403
Dokładne zrozumienie zasady działania tego zapytania może wymagać chwili analizy. Zastosowany mechanizm opiera się na wielokrotnym użyciu funkcji instr() dostępnej w Oracle. Wspomnę tylko, że wywołanie instr(stog_siana, igla, od_znaku, liczba) zwraca bieżące wystąpienie igły w stogu_siana, począwszy od pozycji od_znaku, a gdy igła nie zostanie znaleziona, funkcja zwraca zero. Logika zapytania jest zupełnie taka sama, jak w poprzednich przykładach. Wersję z tabelą przestawną oraz „sklejaną” wywołałem kolejno jeden, dziesięć, sto, tysiąc, dziesięć tysięcy i sto tysięcy razy. Za każdym razem generowałem w sposób losowy listę od 1 do 250 wartości zmiennej v_list. Wyniki pomiarów przedstawia rysunek 11.5. Przy cyklicznym wywoływaniu wersja z użyciem tabeli przestawnej jest o 30% szybsza od wersji „sklejanej”.
RYSUNEK 11.5. Wydajność dynamicznego zapytania w postaci „sklejonej” i w postaci wykorzystującej tabelę przestawną
Należy pamiętać, że każde wywołanie zapytania sklejanego wymaga przeprowadzenia jego analizy leksykalnej, podczas gdy zapytanie z użyciem tabeli przestawnej (i zmiennymi wiązanymi) w przypadku każdego kolejnego wywołania nie wymaga tego kosztownego etapu, dzięki czemu możemy oszczędzić spory ułamek czasu jego wykonania. Mimo tego że wersja z tabelą przestawną jest nieco bardziej skomplikowana, jej większa wydajność rekompensuje tę wadę, co jednoznacznie widać w testach.
404
ROZDZIAŁ JEDENASTY
Na rysunku 11.5 nie widać dwóch innych zalet metody z tabelą przestawną: • Analiza leksykalna jest operacją bardzo obciążającą procesor. Jeśli procesor okazuje się wąskim gardłem w systemie bazy danych, wykorzystywanie dynamicznych zapytań w postaci „sklejanej” może być bardzo szkodliwe dla innych zapytań. • Zapytania SQL są buforowane niezależnie od tego, czy zawierają parametry dowiązane, czy są w pełni statyczne — ponieważ silnik bazy danych nie może stwierdzić, czy statyczne zapytanie nie zostanie wywołane ponownie w krótkim czasie, dlatego stara się zabezpieczyć na tę sytuację. Weźmy ponownie przykład bazy filmów. Nawet w przypadku, gdy w wywołaniu nazwiska aktorów są osadzone w sposób statyczny, zapytanie odwołujące się do bardzo popularnego aktora lub aktorki może pojawić się wielokrotnie2. Silnik SQL-a przechowa w buforze statyczne zapytania tak samo, jak wszystkie inne. Niestety, często wywoływane zapytanie jest wyjątkiem, nie regułą. W efekcie w wyniku kolejnego wykonywania wielu statycznie skonstruowanych zapytań o różnych listach parametrów będą one przechowywane w buforze silnika SQL-a. Zarządzanie pamięcią bufora staje się w tym przypadku dodatkowym kosztem ponoszonym w wyniku stosowania „statycznych zapytań budowanych dynamicznie”.
Agregacja wielozakresowa Niektórym programistom sprawia problem pisanie agregujących zapytań SQL z wykorzystaniem wielu zakresów jednocześnie. Tego typu zapytania są stosunkowo łatwe do napisania z użyciem konstrukcji CASE. W ramach przykładu przyjrzyjmy się raportowi dotyczącemu tabel w bazie danych. Na przykład spróbujmy odpowiedzieć na pytanie, jak wiele tabel zawiera mniej niż sto wierszy, ile zawiera od 100 do 10 000 wierszy, ile od 10 000 do miliona oraz ile powyżej miliona. Informacje o tabelach są z reguły dostępne za pośrednictwem perspektyw słownika bazy danych. W różnych bazach danych są różne, na przykład INFORMATION_SCHEMA.TABLES, pg_statistic i pg_tables, dba_tables, syscat.tables, 2
W takiej sytuacji o wiele wydajniej byłoby jednak zbuforować wynik zapytania, a nie samo zapytanie.
FORTELE
405
sysobjects i systabstats i tak dalej. W przykładach posłużę się perspektywą
o uogólnionej nazwie table_info, zawierającą między innymi kolumny table_name i row_count. Z użyciem tych tabel proste zastosowanie instrukcji CASE i GROUP BY pozwoli na odpowiednie grupowanie i uzyskanie interesujących nas informacji: select case when row_count < 100 then 'Poniżej 100 wierszy' when row_count >= 100 and row_count < 10000 then '100 do 10000' when row_count >= 10000 and row_count < 1000000 then '10000 do 1000000' else 'Powyżej 1000000 wierszy' end as range, count(*) as table_count from table_info where row_count is not null group by case when row_count < 100 then 'Poniżej 100 wierszy' when row_count >= 100 and row_count < 10000 then '100 do 10000' when row_count >= 10000 and row_count < 1000000 then '10000 do 1000000' else 'Powyżej 1000000 wierszy' end
To zapytanie ma jedną wadę: operacja GROUP BY przed zagregowaniem danych wykonuje ich sortowanie. Każdemu z agregatów przypisujemy etykietę, w efekcie otrzymujemy wynik posortowany alfabetycznie, nie logicznie: RANGE TABLE_COUNT ---------------- ----------100 do 10000 18 10000 do 1000000 15 Poniżej 100 wierszy 24 Powyżej 1000000 wierszy 6
Bardziej logiczną kolejnością byłaby arytmetyczna, to znaczy na początku powinna pojawić się pozycja Poniżej 100 wierszy, następnie odpowiednie przedziały, a na końcu Powyżej 1000000 wierszy (co przypadkowo się udało).
406
ROZDZIAŁ JEDENASTY
Zamiast kombinować z etykietami, można zastosować prosty fortel składający się z dwóch etapów: 1. Grupowanie wykonać na dwóch kolumnach zamiast jednej, łącząc z etykietą fałszywą kolumnę definiującą kolejność sortowania. 2. Opakować zapytanie innym, pomijającym w klauzuli FROM tę dodatkową, zbędną kolumnę. Zapytanie wykorzystujące opisaną metodę będzie następujące: select row_range, table_count from ( -- Zbudowanie klucza sortowania, wymuszającego odpowiednią kolejność zakresów -- i ukrycie go w zapytaniu select case when row_count < 100 then 1 when row_count >= 100 and row_count < 10000 then 2 when row_count >= 10000 and row_count < 1000000 then 3 else 4 end as sortkey, case when row_count < 100 then 'Poniżej 100 wierszy' when row_count >= 100 and row_count < 10000 then '100 do 10000' when row_count >= 10000 and row_count < 1000000 then '10000 do 1000000' else 'Powyżej 1000000 wierszy' end as row_range, count(*) as table_count from table_info where row_count is not null group by case when row_count < 100 then 'Poniżej 100 wierszy' when row_count >= 100 and row_count < 10000 then '100 do 10000' when row_count >= 10000 and row_count < 1000000 then '10000 do 1000000' else 'Powyżej 1000000 wierszy' end, case
FORTELE
407
when row_count < 100 then 1 when row_count >= 100 and row_count < 10000 then 2 when row_count >= 10000 and row_count < 1000000 then 3 else 4 end) dummy order by sortkey;
Wynik tego zapytania będzie następujący: RANGE TABLE_COUNT ---------------- ----------Poniżej 100 wierszy 24 100 do 10000 18 10000 do 1000000 15 Powyżej 1000000 wierszy 6
Agregacja wielozakresowa wymaga zbudowania sztucznego klucza sortowania pozwalającego wyświetlić wyniki w odpowiedniej kolejności.
Przesłanianie przypadku ogólnego Technika ukrywania klucza sortowania w zapytaniu przez pominięcie go w klauzuli FROM użyta do sortowania wyniku agregacji wielozakresowej może być również użyteczna i w innych sytuacjach. Szczególnie częsty przypadek zastosowania tej techniki związany jest z sytuacją, gdy w jednej tabeli zapisane są dane definiujące określoną regułę, która bywa przesłaniana przez przypadki szczególne z innej tabeli. Posłużę się przykładem. W rozdziale 1. wspominałem, że obsługa różnych adresów jest dość skomplikowanym zagadnieniem. Weźmy na przykład przypadek sklepu online, który wymaga śledzenia dwóch adresów dla każdego klienta: adresu na fakturę oraz adresu dostawy. W większości przypadków adres jest ten sam. Projektant bazy danych zdecydował, że adres na fakturę jest ważniejszy i będzie przechowywany w tabeli customers, natomiast identyfikator customer_id wraz z poszczególnymi elementami adresu (line_i, line_2, city, state, postal_code, country) będą dodatkowo dostępne w tabeli shipping_addresses na potrzeby tych niewielu przypadków, gdy adres dostawy różni się od adresu na fakturę.
408
ROZDZIAŁ JEDENASTY
Nieprawidłowy sposób odczytywania adresu dostawy dla znanego customer_id wykorzystuje następujące dwa zapytania: 1. Wyszukanie odpowiedniego wiersza w tabeli shipping_addresses. 2. Jeśli nie zostanie odszukana żadna pozycja, adres jest odczytywany z tabeli customers. Alternatywny sposób rozwiązania tego problemu polega na wykonaniu złączenia zewnętrznego tabel shipping_addresses i customers. Otrzymamy dwa adresy, z których jeden w wielu przypadkach będzie zawierał NULL-e. Weryfikacji tego, czy istnieje adres dostawy, można dokonać w programie, co jest nie najlepszym rozwiązaniem, można też wykorzystać funkcję coalesce(), która zwraca pierwszą napotkaną wartość NOT NULL: select ... coalesce(shipping_address.line_1, customers.line_1), ...
Tego typu użycie funkcji coalesce() może być bardzo niebezpieczne, ponieważ opiera się na założeniu, że wszystkie adresy zawierają dokładnie taką samą liczbę elementów niebędących NULL. Załóżmy, że mamy osobny adres dostawy zawierający wartość w line_1, ale line_2 jest NULL. Zastosowanie powyższej konstrukcji spowoduje, że adres dostawy będzie nieprawidłowy (będzie zawierał elementy z adresu na fakturę). Prawidłowy sposób polega na sprawdzeniu istnienia obowiązkowych elementów adresu i zastosowaniu stosownej logiki w zależności od wyniku tego sprawdzenia. To podejście z pewnością doprowadzi do powstania nieczytelnego zapytania. Jeszcze lepsze rozwiązanie mogłoby wykorzystywać „ukryty klucz sortowania” w połączeniu z ograniczeniem na liczbie zwróconych wierszy (SELECT TOP l ..., LIMIT l, WHERE rownum = 1 lub podobnie, w zależności od systemu bazy danych). Zapytanie będzie następujące: select * from (select 1 as sortkey, line_1, line_2, city, state, postal_code, country from shipping_addresses where customer_id = ? union select 2 as sortkey, line_1, line_2,
FORTELE
409
city, state, postal_code, country from customers where customer_id = ? order by 1) actual_shipping_address limit 1
Pomysł polega na użyciu klucza sortowania w charakterze wagi preferencji. Ograniczenie na liczbie zwracanych wierszy spowoduje, że zawsze otrzymamy „najlepszego kandydata”. Analogiczny pomysł mógłby wykorzystywać funkcję OLAP row_number(). Takie techniki znacznie upraszczają programowanie po stronie aplikacji, ponieważ istnieje pewność, że baza danych zwraca od razu prawidłowe dane. Opisana przeze mnie technika może również zostać użyta do obsługi wielojęzycznych aplikacji, gdy nie wszystkie komunikaty zostały przetłumaczone na inne języki. Przy próbie odczytania komunikatu w oparciu o jego identyfikator wykorzystuje się domyślny język, dla którego na pewno są zdefiniowane wszystkie komunikaty oraz język preferowany. Opisana technika gwarantuje, że zawsze zostanie zwrócony jakiś komunikat, dzięki czemu nie ma potrzeby programowania dodatkowej logiki po stronie aplikacji.
Wybieranie wierszy dopasowanych do kilku kryteriów z listy Innym interesującym problemem jest wyszukiwanie w oparciu o kryteria odnoszące się do listy wartości. Ten przypadek najlepiej zobrazować poprzez wyszukiwanie pracowników posiadających określone umiejętności z użyciem tabel przedstawionych na rysunku 11.6. Tabela skillset zawiera powiązania pracowników z umiejętnościami, dodatkowo zdefiniowany jest tu współczynnik umiejętności w skali od 1 do 3 — między podstawową kompetencją, dużym doświadczeniem i granicą magii.
RYSUNEK 11.6. Tabele użyte w wyszukiwaniu umiejętności pracowników
410
ROZDZIAŁ JEDENASTY
Znalezienie pracowników z umiejętnościami na poziomie 2 lub 3 jest łatwym zadaniem: select e.employee_name from employees e where e.employee_id in (select ss.employee_id from skillset ss, skills s where s.skill_id = ss.skill_id and s.skill_name = 'SQL' and ss.skill_level >= 2) order by e.employee_name
Zapytanie to można napisać z użyciem zwykłego złączenia. Jeśli chcemy znaleźć pracowników, którzy mają umiejętności z zakresu baz danych Oracle lub DB2, musimy zastosować następujące zapytanie: select e.employee_name, s.skill_name, ss.skill_level from employees e, skillset ss, skills s where e.employee_id = ss.employee_id and s.skill_id = ss.skill_id and s.skill_name in ('ORACLE', 'DB2') order by e.employee_name
W tym przypadku nie ma potrzeby sprawdzania poziomu umiejętności, ponieważ interesuje nas dowolny poziom, jednak musimy wyświetlić nazwę umiejętności, ponieważ w przeciwnym razie nie mielibyśmy pewności, dlaczego pracownik został uwzględniony w zapytaniu. Napotykamy też pierwszą przeszkodę: osoby znające Oracle i DB2 znajdą się w wyniku dwukrotnie. Możemy spróbować zagregować umiejętności po pracowniku. Jednak, niestety, nie wszystkie dialekty SQL-a pozwalają wykorzystywać funkcje agregujące na połączonych ciągach znaków (w niektórych jednak istnieje możliwość wykorzystania samodzielnie napisanej funkcji agregującej). Agregację możemy jednak osiągnąć, wykorzystując znany nam już fortel z podwójnym przekształceniem. W pierwszej kolejności przekształcamy wartość z ciągu znaków na liczbę, następnie po agregacji z liczby na ciąg znaków. Poziomy umiejętności należą do zakresu od 1 do 3. Dzięki temu dowolną kombinację umiejętności w Oracle i DB2 możemy zapisać jako liczbę dwucyfrową, pierwszą cyfrę przypisując DB2, a drugą Oracle. Możemy tego dokonać za pomocą następującego kodu:
FORTELE
411
select e.employee-name, (case s.skill_name when 'DB2' then 10 else 1 end) * ss.skill_level as computed_skill_level from employees e, skillset ss, skills s where e.employee_id = ss.employee_id and s.skill_id = ss.skill_id and s.skill_name in ('ORACLE', 'DB2')
Obliczony poziom umiejętności (computed_skill_level) będzie wynosił 10, 20 lub 30 dla umiejętności w DB2, zaś dla umiejętności w Oracle będą to wartości 1, 2 lub 3. W tym momencie umiejętności możemy agregować z łatwością, po czym przekształcimy je z powrotem na czytelne opisy: select employee_name, -- Dekoduje liczbowo zakodowane wartości skill + skill -- Dziesiątki to poziom umiejętności w DB2, jedności to poziom umiejętności w Oracle case when aggr_skill_level >= 10 then 'DB2:' + str(round(aggr_skill_level/1O,0)) + ' ' end + case when aggr_skill_level % 10 > 0 then 'Oracle:' + str(aggr_skill_level % 10) end as skills from (select e.employee_name, -- Koduje kombinację encode skill + skill level w postaci liczbowej -- w celu wykonania agregacji sum((case s.skill_name when 'DB2' then 10 else 1 end) * ss.skill_level) as aggr_skill_level from employees e, skillset ss, skills s where e.employee_id = ss.employee_id and s.skill_id = ss.skill_id and s.skill-name in ('ORACLE', 'DB2') group by e.employee_name) as encoded_skills order by anployee_name
Spróbujmy teraz odpowiedzieć na bardziej skomplikowane pytanie. Załóżmy, że chcemy znaleźć skład zespołu do projektu związanego z migracją z jednego systemu baz danych do drugiego. Zamiast szukać
412
ROZDZIAŁ JEDENASTY
osób znających Oracle lub DB2, poszukujemy tylko tych, którzy znają obydwa te systemy. Istnieje kilka sposobów na odczytanie takich informacji. Jednym z nich jest operacja części wspólnej (INTERSECT), o ile obsługuje ją silnik bazy danych. Możemy znaleźć osoby posiadające umiejętności pracy w Oracle oraz osoby posiadające umiejętności pracy w DB2, po czym znajdujemy część wspólną tych zbiorów. Tę samą koncepcję możemy zrealizować za pomocą operatora IN(): select e.employee_name from employees e, skillset ss, skills s where s.skill_name = 'ORACLE' and s.skill_id = ss.skill_id and ss.employee_id = e.enployee_id and e.employee_id in (select ss2.employee_id from skillset ss2, skills s2 where s2.skill_name = 'DB2' and s2.skill_id = ss2.skill_id)
Możemy również posłużyć się rozwiązaniem podwójnego przekształcenia i filtrować wyniki w oparciu o agregat liczbowy z użyciem tych samych wyrażeń, co w powyższym przypadku kodowania i dekodowania kolumny encoded_skills. Metoda wykorzystująca podwójne przekształcenie ma jeszcze kilka zalet: • Odczytuje zawartości tabel tylko raz. • Ułatwia obsługę bardziej skomplikowanych zapytań, jak „osoby znające Oracle i Javę albo MySQL i PHP”. • W związku z tym, że umiejętności mamy w postaci listy, możemy posłużyć się tabelą przestawną, przyspieszając w ten sposób często wykonywane zapytania. Kolumna row_num tabeli przestawnej może ułatwić przekodowywanie, pod warunkiem że lista nie jest długa; wartość w kolumnie skill_level możemy mnożyć przez dziesięć do potęgi (row_num – l). Jeśli nie jesteśmy zainteresowani dokładnymi wartościami poziomu umiejętności, możemy również zbudować mapę bitową, o ile taka operacja jest obsługiwana przez silnik bazy danych.
FORTELE
413
Znajdowanie najlepszego dopasowania Jako podsumowanie naszych przygód w dzikich zakątkach SQL-a połączmy techniki poznane w tym rozdziale i spróbujemy wyszukać pracowników w oparciu o dość skomplikowane kryteria. Chcemy znaleźć tego jednego spośród pracowników, który jest najlepszym kandydatem do wzięcia udziału w projekcie wymagającym umiejętności w wielu różnych środowiskach (na przykład Java, .NET, PHP i SQL Server). Idealny kandydat byłby „guru” we wszystkich wymaganych środowiskach, ale istnieje prawdopodobieństwo, że zapytanie wyszukujące osobę o umiejętnościach na najwyższym poziomie we wszystkich środowiskach prawdopodobnie zwróci pusty wynik. Przy braku idealnego kandydata pozostaje z reguły wybór najlepszego spośród nieidealnych i potrzebujemy zidentyfikować kogoś, kto ma najlepsze kompetencje w jak największej liczbie środowisk, dzięki czemu będzie najodpowiedniejszy do danego projektu. Na przykład jeśli nasz „guru” od Javy jest ekspertem na skalę światową, ale nie wie niczego na temat PHP, ma niewielkie szanse powodzenia. Wyznaczenie „najlepiej dopasowanej” pozycji w bazie wiąże się z porównaniem różnych pracowników, innymi słowy, z zaawansowaną operacją sortowania wyłaniającą zwycięzcę. Potrzebujemy tylko jednego zwycięzcy, będziemy zatem musieli ograniczyć wyniki z naszej listy kandydatów do pierwszego zwracanego wiersza. Czytelnik zapewne już zaczął sobie wyobrażać to zapytanie jako select ... from (select ... order by) limit l lub odpowiednik w używanym dialekcie SQL-a. Zasadnicze pytanie jednak dotyczy sposobu sortowania pracowników. Kto uzyska wyższą pozycję w rankingu: czy ten, kto ma przeciętne umiejętności w trzech z określonych zagadnień, czy ten, kto jest „guru” w dwóch z nich? W tego typu przypadkach często zakres wiedzy (szerokość) ma większe znaczenie od jej poziomu (głębokości). Możemy zatem zastosować pierwsze sortowanie po liczbie posiadanych umiejętności zgodnych z listą wymagań oraz drugie po sumie poziomu umiejętności (wartości skill_level). Zapytanie wewnętrzne będzie dość intuicyjne: select e.employee_name., count(ss.skill_id) as major_key, sum(ss.skill_level) as minor_key from employees e,
414
ROZDZIAŁ JEDENASTY
skillset ss, skills s where s.skill_name in ('JAVA', '.NET', and s.skill_id = ss.skill_id and ss.employee_id = e.employee_id group by e.employee_name order by 2, 3
'PHP', 'SQL SERVER')
To zapytanie jednak nie zawiera żadnych informacji o poziomie umiejętności naszego kandydata. Z tego powodu powinniśmy je połączyć z operacją podwójnego przekształcenia w celu zakodowania umiejętności. Pozostawiam to jako ćwiczenie dla Czytelnika. Z punktu widzenia wydajności należy również zauważyć, że w wewnętrznym zapytaniu nie ma potrzeby odwoływać się do tabeli pracowników. Nazwisko pracownika jest informacją potrzebną jedynie przy prezentacji wyników. Dlatego będziemy posługiwali się tylko wartościami employee_id, a większość operacji wykona się z użyciem tabel skills i skillset. Należy także rozważyć rzadką sytuację, gdy dwóch kandydatów posiada dokładnie takie same umiejętności: czy w takim przypadku rzeczywiście warto ograniczać wynik do pojedynczego wiersza? UWAGA Parafrazując Generała Roberta E. Lee: „To się dobrze składa, że SQL jest taki okropny, inaczej mógłby się nam za bardzo spodobać”.
Dyrektywy optymalizatora Rozdział ten zakończę kilkoma uwagami dotyczącymi dyrektyw optymalizatora. Optymalizator SQL-a można porównać do programu obliczającego prędkość migawki i czas ekspozycji w aparacie fotograficznym z automatyką. Istnieją sytuacje, kiedy tryb „auto” nie jest użyteczny, na przykład w przypadku, gdy obiekt jest fotografowany pod światło lub gdy ilość światła jest niedostateczna (na przykład w nocy). Wszystkie systemy zarządzania bazami danych udostępniają sposoby na wymuszenie niektórych decyzji podejmowanych przez optymalizator. Do dyspozycji mamy dwie techniki wpływające na działanie optymalizatora: • Specjalne ustawienia środowiska sesji, które mają zastosowanie dla wszystkich uruchamianych w niej zapytań. • Lokalne dyrektywy o działaniu ograniczonym do pojedynczych instrukcji.
FORTELE
415
W drugim przypadku składnia różni się między różnymi systemami baz danych, ponieważ dyrektywy tego typu są integralną częścią zapytań SQL (na przykład FORCE INDEX(...) w MySQL-u czy LOOP JOIN w TransactSQL); w niektórych systemach do wymuszenia ścieżki wykonawczej używane są specjalne składnie komentarzy (jak /*+ all_rows */ w Oracle). Dyrektywy optymalizatora nie były używane w przykładach w tej książce i nie bez przyczyny. Cykliczne wykonywanie tego samego zapytania na żywych danych jest w pewnym stopniu porównywalne do wykonywania fotografii tego samego obiektu w różnych porach dnia: obiekt oświetlony od tyłu o poranku będzie w pełni oświetlony po południu. Dyrektywy służą wymuszaniu określonych ścieżek wykonawczych, dlatego lepiej jest ich nie używać. Najbardziej dopuszczalne są te dyrektywy, które sugerują rozmiar wyniku, jak sql_small_result czy sqlbigresult w MySQL-u, lub wskazanie, że zależy nam na szybkim uzyskaniu wyniku częściowego, co z reguły zachodzi przy przetwarzaniu transakcyjnym z użyciem fast 100 MS SQL Servera lub /*+ first_rows(100) */ w Oracle. Te dyrektywy, które można porównać do trybów „pejzaż” lub „sport” w aparacie fotograficznym, udzielają optymalizatorowi informacji, jakich nie jest w stanie zdobyć na podstawie innych przesłanek. Nie są one uzależnione od rozmiaru czy rozkładu danych na nośnikach, dzięki czemu są stabilne w czasie i rzeczywiście stanowią wartość dodaną. Jednakże nawet dyrektywy stanowiące wartości dodane nie powinny być używane, o ile nie są niezbędne. Optymalizator w znacznym stopniu radzi sobie z określeniem sposobu przetwarzania zapytania, pod warunkiem że jest ono prawidłowo napisane. Najlepszy i najprostszy przykład poprowadzenia zapytania polega na użyciu skorelowanych lub nieskorelowanych podzapytań. Te dwie formy służą uzyskaniu identycznych wyników, ale w różnych warunkach wykonawczych. Jedną z najciekawszych cech optymalizatora baz danych jest jego umiejętność przystosowania się do zmiennych warunków. Zahamowanie tej funkcjonalności wskutek zastosowania dyrektyw jest dowodem krótkowzroczności programisty i może potencjalnie stać się przyczyną nieoptymalnej wydajności działania optymalizatora w przyszłości. Niektóre dyrektywy są prawdziwymi „bombami zegarowymi”, na przykład te, które wymuszają użycie określonych indeksów. Jeśli z jakiegoś powodu administrator bazy danych zdecyduje się zmodyfikować nazwę indeksu użytego w dyrektywie, efekty mogą być dramatyczne. Podobnie
416
ROZDZIAŁ JEDENASTY
katastrofalne efekty można osiągnąć w przypadku, gdy dyrektywa wymusza użycie indeksu złożonego, a ten indeks zostanie pewnego dnia wygenerowany od nowa z inną kolejnością kolumn. UWAGA Dyrektywy optymalizatora należy uznać za prywatne terytorium administratora baz danych. DBA powinien używać ich w charakterze mechanizmu rozwiązującego problemy wynikające z niedociągnięć systemu baz danych z potencjalnym planem ich usunięcia po dokonaniu aktualizacji wersji, gdy już okażą się zbędne.
Częstą praktyką wśród początkujących programistów jest przerabianie istniejących zapytań SQL na nowe potrzeby. Gdy oryginalne zapytanie zawiera dyrektywy, początkujący rzadko zastanawiają się, czy są one odpowiednie w danym przypadku. Z reguły adaptacja zapytań polega na niewielkich zmianach, na przykład w liście SELECT czy w kryteriach filtrowania. W efekcie powstaje zapytanie dopieszczone pod kątem określonej ścieżki wykonawczej, ale zupełnie innej niż odpowiednia w danej sytuacji. Dobry plan wykonawczy wymuszony dziś, jutro może stać się przyczyną katastrofy.
ROZDZIAŁ DWUNASTY
Zatrudnianie szpiegów Monitorowanie wydajności And he that walketh in darkness knoweth not whither he goeth. A kto chodzi w ciemności, nie wie, dokąd idzie. Ewangelia według Świętego Jana, 12:35.
418
O
ROZDZIAŁ DWUNASTY
peracje wywiadowcze zawsze były kluczowym elementem każdej wojny. Wszystkie systemy zarządzania bazami danych zawierają mechanizmy monitorujące o różnych poziomach zaawansowania. W niektórych przypadkach dostępne są dodatkowo produkty uzupełniające firm zewnętrznych. Wszystkie te funkcje monitorujące są tworzone z myślą o administratorach baz danych. Te, które pozwalają zorientować się w procesach zachodzących w silniku baz danych, stają się rzeczywistymi szpiegami w służbie programisty zdecydowanego tworzyć rozwiązania na wysokim poziomie wydajności. Należy podkreślić, że jeśli mechanizmy monitorujące nie posiadają odpowiedniego do naszego celu poziomu szczegółowości, z reguły możliwe jest uzyskanie dodatkowych informacji dzięki funkcji zapisu dzienników (logging) lub śledzenia (tracing). Dzienniki i funkcje śledzenia z oczywistych względów wprowadzają znaczący narzut, który może oznaczać niezbyt mile widziane zwiększenie obciążenia serwera produkcyjnego, działającego już na granicy swoich możliwości. Jednak na etapie testów wydajności dzienniki systemu bazy danych mogą dostarczać cennych informacji na temat tego, jakiego zachowania bazy możemy oczekiwać w trakcie jej produkcyjnego wykorzystania. Szczegółowe omówienie wszystkich dostępnych mechanizmów monitorujących byłoby z konieczności bardzo trudne oraz musiałoby dotyczyć określonych produktów. Ponadto taki opis szybko stałby się nieaktualny. Z tego powodu skupię się na tym, co można monitorować i w jakim celu. Skorzystam przy tym z doskonałej okazji, aby dokonać ostatecznego podsumowania najważniejszych zagadnień wprowadzonych w poprzednich rozdziałach.
Baza danych działa za wolno Spróbujmy zdefiniować najważniejsze kategorie przyczyn problemów z wydajnością, na jakie możemy natknąć się w produkcji. Jednym z naszych zadań, jako programistów, jest przewidzenie tych sytuacji i uniknięcie ich w miarę możliwości. Jednym z pierwszych objawów problemów z wydajnością jest telefon od użytkownika do administratora bazy danych ze słowami: „Baza danych działa za wolno” (na marginesie: jest to bardzo cenna informacja, szczególnie w sytuacji, gdy administrator ma pod swoją opieką kilkanaście baz danych). W dobrze zorganizowanej firmie administrator
ZATRUDNIANIE SZPIEGÓW
419
szybko rzuci okiem na statystyki działania systemu, aby sprawdzić, czy narzędzia monitorujące rzeczywiście zarejestrowały coś niespotykanego i odpowie: „Wiem, właśnie nad tym pracujemy”. W źle zorganizowanej firmie administrator zapewne odpowie to samo, kłamiąc dyplomatycznie. Niezależnie jednak od tego, czy administrator skłamał, czy mówił prawdę, po zakończeniu tej rozmowy rozpocznie się nerwowe poszukiwanie poszlak. Informacja typu „baza danych działa za wolno” jest z reguły spowodowana jedną z pięciu przyczyn: Wina nie leży po stronie bazy danych Sieć jest zapchana albo serwer jest przeciążony innymi zadaniami. Dziękujemy za informację. Nagłe ogólne spowolnienie działania Wszystkie zadania zaczęły wolno działać jednocześnie dla wszystkich użytkowników. W tym przypadku należy rozważyć dwa przypadki: • Obniżenie wydajności było nagłe, co mogło wiązać się ze znaczącą zmianą w systemie lub bazie danych (aktualizacja wersji oprogramowania, zmiana parametru lub modyfikacja konfiguracji sprzętowej). • Zwiększenie liczby wykonywanych zapytań. Pierwszy z opisanych problemów nie ma związku z programowaniem, jest to po prostu jedno z zagrożeń czyhających na inżynierów i administratorów baz danych. Drugi przypadek ma już jednak związek z oprogramowaniem, a dokładniej ze specyfikacją wymagań biznesowych. Przypomnijmy sobie urząd pocztowy z rozdziału 9.: gdy klienci przybywają do urzędu szybciej, niż mogą być obsłużeni, wydłuża się kolejka, a wydajność spada nagle i znacząco. Oznacza to, że być może specyfikacja obciążenia systemu była zaniżona i system stał się obiektem obciążenia przewyższającego jego projektowaną wydajność lub aplikacje nie zostały odpowiednio przetestowane pod kątem obciążenia. W wielu przypadkach udoskonalenie kluczowych zapytań może znacząco obniżyć średni czas obsługi i poprawić sytuację. Taka modyfikacja w sprzyjających okolicznościach może oznaczać koszt rzędu ułamków kosztu wymiany sprzętu. Nagłe globalne spowolnienie działania można zidentyfikować po tym, że po pierwszym telefonie od niezadowolonego użytkownika nadchodzą kolejne od innych.
420
ROZDZIAŁ DWUNASTY
Nagłe lokalne spowolnienie Jeśli nagle spowalnia się wykonanie jednego zadania, a pozostałe działają tak, jak dotychczas, przyczyną może być problem z blokadami. Administratorzy baz danych mogą monitorować blokady i stwierdzić, że kilka procesów rywalizuje o te same zasoby. Tę sytuację można próbować rozwiązać przez modyfikację zapytań oraz inne techniki zmierzające do szybszego zwalniania blokad przez zadania. Stopniowe spowolnienie wydajności osiągające próg krytyczny Osiągnięcie niepokojącego stopnia obniżenia wydajności może zostać wykryte przez wyczulonego użytkownika. Jeśli obciążenie spowalniało się stopniowo, przekroczenie pewnego progu może zwiastować zbliżającą się katastrofę i wiązać się z wydłużaniem czasu obsługi oraz chwilowym obniżaniem się wydajności na poziomie globalnym. Przekroczenie poziomu może wynikać ze zwiększania się nieodpowiednio poindeksowanych tabel albo z obniżeniem parametrów wydajnościowych fizycznego nośnika danych w wyniku licznych operacji usuwania lub modyfikowania (gdy tabela ma duże rozmiary fizyczne wskutek „spuchnięcia”, gdy została w niej zapisana duża liczba wierszy, które następnie zostały usunięte, co pozostawiło ją w stanie przypominającym ser szwajcarski, gdy tabela zajmuje znacznie większą liczbę stron danych, niż wynikałoby to z ilości zapisanych w niej danych, albo gdy mamy do czynienia z nadmiernym wykorzystaniem obszarów przepełnienia). Jeśli problem leży po stronie niewłaściwego indeksowania lub fizycznego uporządkowania danych na nośniku (lub z nieaktualnych statystyk, przez co optymalizator podejmuje niewłaściwe decyzje), bardzo pomóc może administrator baz danych. Jednak konieczność podejmowania tego typu akcji ratunkowych w ramach „standardowych czynności administratorskich” może być oznaką błędnego projektu procesów wykorzystujących bazę. Jedno szczególnie powolne zapytanie Jeśli aplikacja jest właściwie przetestowana, przyczyny należy szukać w dynamicznie konstruowanych zapytaniach, które mogły otrzymać niestandardowy zestaw parametrów. Ten problem może wynikać z przyczyn typowo programistycznych.
ZATRUDNIANIE SZPIEGÓW
421
Wiele z wymienionych wyżej przyczyn można przewidzieć i można im zapobiec. Jeśli da się zidentyfikować przyczynę przeciążenia serwera i znaleźć związek między aktywnością bazy danych a działaniami biznesowymi, nie powinno być większych problemów ze znalezieniem słabych punktów w aplikacji. W takim przypadku można skupić się na tych słabych punktach na etapie testów wydajności i postarać się je wyeliminować, udoskonalając zapytania. Aby przewidzieć rzeczywistą wydajność aplikacji, należy monitorować działania bazy danych podczas testów obciążeniowych i użytkowych.
Składowe obciążenia serwera Obciążenie, w technologii informatycznej, sprowadza się do połączenia następujących sytuacji: nadmiernego wykorzystania procesora, za dużej liczby operacji wejścia-wyjścia i niedostatecznej przepustowości lub prędkości sieci. Ta sytuacja przypomina „ścieżkę krytyczną” w zarządzaniu projektami, gdzie jedno wąskie gardło może spowodować znaczące spowolnienie działania całego projektu. Jeśli procesy gotowe do uruchomienia muszą czekać, aż inne procesy zwolnią zajmowany przez nie procesor, system będzie przeciążony. Jeśli procesor nie jest wykorzystany, ale są przygotowane dane czekające na przesłanie w przeciążonej sieci, system również będzie przeciążony. Określenia „przeciążony” nie należy jednak interpretować w sensie bezwzględnym. Systemy można porównać do istot ludzkich w takim sensie, że poziom obciążenia nie zawsze jest zgodny z ilością wykonywanej pracy. W swoim prawie (zwanym prawem Parkinsona) C. Northcote Parkinson zawarł następujące, osławione satyryczne stwierdzenie dotyczące biurokracji: Kobieta w średnim wieku może spędzić cały dzień na napisaniu i wysłaniu kartki pocztowej (…). Całkowity efekt działania trwającego trzy minuty, wykonywanego przez jednego pracowitego człowieka może być dokładnie taki sam, jaki jest w stanie osiągnąć inna osoba po całym dniu zwątpienia, zdenerwowania i trudu.
422
ROZDZIAŁ DWUNASTY
Źle napisana aplikacja wykorzystująca język SQL może obciążyć serwer do granic jego możliwości, nie osiągając w ten sposób żadnych znaczących wyników. Oto kilka przykładów (istnieje wiele innych) ilustrujących różne sposoby na zwiększenie obciążenia serwera bez realizacji użytecznych zadań. Statyczne kodowanie wszystkich zapytań Ta „technika” zmuszą silnik SQL-a do uruchamiania procesu analizy leksykalnej każdego wywoływanego zapytania, zanim dojdzie do odczytu danych. Jest ona szczególnie skuteczna, jeśli chodzi o przeciążenie procesora. Wykonywanie bezużytecznych zapytań Ta sytuacja jest znacznie powszechniejsza, niż mogłoby się wydawać. Można tu wymienić zapytania absolutnie bezużyteczne, jak wywoływanie zapytania „zwiadowczego” w celu sprawdzenia, czy serwer bazy danych działa i jest gotowy do wykonywania rzeczywistych zapytań (ta historia jest wzięta z życia), lub wykonywanie operacji count(*) w celu sprawdzenia, czy w tabeli istnieją wiersze spełniające warunki i czy warto wykonać zapytanie modyfikujące lub wstawiające wiersze wykorzystujące te same warunki. Inne bezużyteczne zapytania dotyczą wielokrotnego odczytywania informacji, która jest niezmienna przez cały czas trwania sesji, lub wykonywania czterysta tysięcy razy dziennie zapytania odczytującego kurs wymiany walut zmieniany raz dziennie. Zwielokrotnienie cykli przełączenia kontekstu Wykonywanie operacji w trybie wierszowym z wykorzystaniem pętli operującej na kursorach przy unikaniu procedur osadzonych to doskonałe sposoby na zwiększenie ilości wymiany komunikacji między aplikacją a silnikiem SQL, powodujące marnowanie czasu na kwestie związane z protokołem sieciowym, zwiększające liczbę pakietów przesyłanych w sieci, a za dodatkową „korzyść” należy uznać tu brak możliwości pełnego zoptymalizowania zapytań przez optymalizator bazy danych, ponieważ tajniki związane ze sposobem obsługi danych są zaszyte w aplikacji, poza jego dostępem. Należy podkreślić, że te przykłady błędów nie oznaczają sytuacji „błędnie napisanych zapytań SQL”, co jest przez większość osób postrzegane jako główna przyczyna złej wydajność SQL-a. Zapytania opisywane w powyższych przykładach z reguły działają szybko. Lecz nawet w przypadku, gdyby działały z prędkością zbliżoną do prędkości światła, zbędne zapytania
ZATRUDNIANIE SZPIEGÓW
423
zawsze będą za wolne: ponieważ najzwyczajniej w świecie marnują zasoby, a w okresie znaczącego obciążenia bazy danych mogą stanowić kroplę przepełniającą czarę. Na obciążenie serwera baz danych mają wpływ jeszcze dwa czynniki. Widoczny element jest związany z „błędnie napisanymi zapytaniami SQL” będącymi w takiej sytuacji pierwszym (a często jedynym) miejscem, w którym usiłuje się naprawić taką sytuację. Niewidoczny element to szum tła pochodzący z zapytań o zadowalającej prędkości działania, a często nawet działających bardzo szybko, wykonywanych bardzo często. Skumulowany koszt tych operacji składających się na wspomniany „szum tła” ma wpływ na znaczące zwiększenie kosztu wykonania wielkich i ciężkich zapytań. Jak to powiedział Sir Arthur Conan Doyle ustami Sherlocka Holmesa: Od dawna twierdziłem, że zdecydowanie najważniejsze są rzeczy małe. W związku z tym, że szum tła jest rozłożony w czasie, nie kumuluje się w jednym momencie skoku obciążenia, zatem z reguły przechodzi niezauważony. Jednak może wpływać na znaczną redukcję „rezerwy mody”, która będzie przydatna w okresach zwiększonej aktywności. Powtarzalne, krótkotrwałe operacje na niewielkich ilościach danych często obciążają serwer bardziej niż źle napisane zapytania SQL zajmujące silnikowi bazy danych dużo czasu wykonawczego.
Definicja dobrej wydajności Obciążenie to jedna strona medalu, po drugiej stronie znajduje się wydajność. Dobra wydajność to pojęcie bardzo trudne do zdefiniowania. Wykorzystanie mocy procesora lub dużej liczby operacji wejścia-wyjścia nie jest samo w sobie niczym złym, ponieważ można się domyślić, że firma nie zakupiła kosztownego serwera po to, aby stał niewykorzystany. Gdy przychodzi chwila zastanowienia nad wydajnością, bazy danych zdumiewająco przypominają sferę finansów korporacyjnych. W obydwu tych zagadnieniach doszukujemy się „kluczowych wskaźników wydajności” i magicznych współczynników i w obydwu tych przypadkach tego typu globalne współczynniki i wskaźniki potrafią być bardzo mylące.
424
ROZDZIAŁ DWUNASTY
Najdokładniej wyliczona średnia obciążenia ukrywa szokujące poziomy w trakcie zwiększonego obciążenia, a znacząca cześć obciążenia może być efektem działania nieoptymalnego programu wsadowego, ale działającego w nocy, w czasie, gdy nikt nie korzysta z bazy. Aby dokładnie zrozumieć stan rzeczy, należy zagłębić się na niższy poziom szczegółowości. Zagłębianie się w szczegółach jest zadaniem zbliżonym do zagadnień zarządczych z dziedziny „kosztów związanych z aktywnościami”. W firmie zorientowanie w kosztach generowanych przez każde stanowisko pracy jest dość łatwym zadaniem. Jednakże powiązanie kosztów z korzyściami jest już problematyczne, szczególnie w przypadku operacji rozciągających się w wielu obszarach, na przykład realizowanych przez technikę informatyczną. Określenie, czy operacjom dedykowano odpowiednią ilość sprzętu, oprogramowania i ludzi, jak również gumek recepturek i taśmy klejącej spajających wszystko w jedną całość, jest niezwykle skomplikowanym zadaniem, szczególnie w sytuacji, gdy na firmę zarabiają użytkownicy będący „klientami” departamentu IT. Oszacowanie tego, czy rzeczywiście nakłady odpowiadają korzyściom, wymaga spełnienia trzech warunków: • Wiedzy na temat tego, ile nakładów zostało poświęconych zadaniu. • Wiedzy na temat tego, jakie korzyści wynikają z tych nakładów. • Wiedzy na temat tego, czy zwrot z inwestycji odpowiada założonym standardom w tym zakresie. W kolejnych podrozdziałach omówię te zagadnienia z punktu widzenia systemu bazy danych.
Ile nakładów zostało poświęconych zadaniu W przypadku wydajności baz danych nakłady wiążą się przede wszystkim z liczbą stron danych wykorzystywanych w wykonaniu zapytania. Fizyczne operacje wejścia-wyjścia, na których skupia się część użytkowników, mają znaczenie drugorzędne. Jeśli zapytanie musi odczytać dużą liczbę stron danych, będzie prawdopodobnie wykorzystywać pojedyncze, długotrwałe operacje wejścia-wyjścia, chyba że cała baza danych mieści się w pamięci operacyjnej. Obciążenie procesora również często bywa konsekwencją wielokrotnego odczytu tych samych stron danych w pamięci.
ZATRUDNIANIE SZPIEGÓW
425
Zredukowanie liczby odczytywanych stron danych nie jest lekiem na całe zło, ale istnieją przypadki, gdy globalna przepustowość jest wyższa, gdy wybrane zapytania muszą odczytać nieco większą liczbę stron, niż to jest ściśle wymagane. Jednak w przypadku szczegółowych wskaźników liczba odczytywanych stron danych jest prawdopodobnie najważniejszym współczynnikiem wydajności. Inne koszty, które warto obserwować, to nadmierna ilość operacji analizy leksykalnej zapytań SQL, ponieważ to działanie intensywnie zużywa moc procesora (duża liczba zmiennych, statycznie zakodowanych zapytań SQL wywoływanych w krótkich odstępach czasu jest w stanie zużyć 75% mocy procesora na samą analizę leksykalną). Najważniejsze wskaźniki obciążenia bazy danych to ilość czasu poświęconego przez procesor na analizę leksykalną i liczba stron danych odczytywanych w ramach zapytania.
Jakie korzyści wynikają z poświęconych nakładów Istnieje cytat ulubiony wśród marketingowców, przypisywany Johnowi Wanamakerowi, dziewiętnastowiecznemu amerykańskiemu handlowcowi: Połowa pieniędzy poświęconych na marketing idzie na marne. Kłopot w tym, że nie wiem, która. W przypadku baz danych sytuacja wydaje się nieco lepsza, ale, niestety, to tylko pozory. W zapytaniu możemy określić liczbę wierszy, które mają być zwrócone w wyniku instrukcji SELECT, podobnie w przypadku operacji modyfikujących. Jednak takie wskazanie liczby wierszy rzadko ma jakikolwiek wpływ na rzeczywistą ilość pracy, jaką silnik SQL-a musi włożyć w realizację zapytań. Istnieje kilka powodów takiego stanu rzeczy: • Po pierwsze i najważniejsze z praktycznego punktu widzenia: nie wszystkie produkty dają takie możliwości. • Po drugie, nakład pracy poświęcony na uzyskanie wyniku może nie mieć przełożenia na rozmiar wyniku. Można wręcz przyjąć takie założenie, że w przypadku kilku zwracanych wierszy wynikowych liczba odczytywanych stron danych będzie bardzo duża. Jednak w przypadku
426
ROZDZIAŁ DWUNASTY
agregacji danych można przyjąć założenie o proporcji między liczbą zwracanych wierszy a ilością przetworzonych danych. W tym zakresie trudno przyjąć jedną, uniwersalną zasadę. • Po trzecie, czy dane zwracane z bazy danych tylko po to, aby użyć ich jako dane wejściowe innych zapytań, należy uznać za użyteczne wykorzystanie pracy? Co można powiedzieć o modyfikacji wartości jednej z kolumn, ustawionej na N bez użycia klauzuli WHERE, gdy okazuje się, że w większości wierszy ta kolumna ma właśnie wartość N? W obydwu przypadkach silnik bazy danych wykonuje pracę, którą można zmierzyć liczbą bajtów: zwracanych lub modyfikowanych. Niestety, większości pracy realizowanej w tych przykładach można uniknąć. Istnieją przypadki, gdy przeszukiwanie tabel o dużych rozmiarach lub wykonywanie długotrwałych zapytań jest absolutnie uzasadnione (lub wręcz nieuniknione). Na przykład w przypadku raportu podsumowującego wszystkie wiersze dużej tabeli nie należy oczekiwać szybkiego zwrócenia wyniku. Jeśli wymagane jest uzyskanie natychmiastowego wyniku, może okazać się, że model danych (reprezentacja rzeczywistości w postaci bazy danych) jest nieodpowiedni dla pytania, na które chcemy uzyskać odpowiedź. To jest typowy przypadek sytuacji, gdy zastosowanie mają systemy wspomagania decyzji niekoniecznie o takim poziomie szczegółowości, jaki ma operacyjna baza danych. Jak pamiętamy z rozdziału 1., prawidłowe modelowanie baz danych zależy w równym stopniu od danych i od tego, co chcemy z nimi zrobić. Firma i jej dostawcy często mają w swoich bazach danych te same informacje, lecz są one zapisane z użyciem zupełnie odmiennych modeli. Oczywiście zasilanie systemu wspomagania decyzji wymaga długotrwałych i kosztownych operacji zarówno po stronie źródłowej bazy operacyjnej, jak i po stronie bazy danych obsługującej sam system decyzyjny. Ponieważ to, co robimy z danymi ma aż tak duże znaczenie, obciążenia bazy danych nie uda się właściwie ocenić, jeśli nie zastosuje się odniesienia do poszczególnych zapytań SQL. Za pomocą narzędzi monitorujących (dostarczających z reguły skumulowane liczniki globalne) udaje się uzyskać spojrzenie ogólne, lecz nie ma to większego znaczenia, jeśli nie uda się określić procentowego udziału w całkowitym koszcie każdego zapytania biorącego w nim udział.
ZATRUDNIANIE SZPIEGÓW
427
Na pierwszym etapie analizy obciążenia należy przechwycić i zgromadzić wszystkie zapytania SQL i spróbować określić udział każdego z nich w całkowitym koszcie. Nie zawsze przechwycenie dokładnie wszystkich zapytań ma kluczowe znaczenie. Aktywność w ramach bazy danych, jak wiele innych zagadnień, również daje się dość dobrze zobrazować regułą 80/20, czyli osławioną zasadą, w świetle której 80% konsekwencji wynika z 20% przyczyn. Z reguły bowiem większość obciążenia pochodzi z niewielkiego ułamka zapytań SQL. Należy zauważyć, że statycznie zakodowane dynamiczne zapytania mogą nieco zniekształcać obraz. Takie zapytania mogą w narzędziach monitorujących być zarejestrowane jako setki tysięcy zapytań, natomiast gdyby były zakodowane prawidłowo, zostałyby policzone tylko raz, nawet mimo wywoływania setki tysięcy razy, za każdym razem z zupełnie innymi parametrami. Takie sytuacje można zauważyć dzięki uwzględnieniu wielkiej liczby zapytań, a czasem również w statystykach na poziomie globalnym. Na przykład procedura sp_trace_setevent języka Transact-SQL pozwala uzyskać dokładną liczbę wywoływanych kursorów, ponownych wykorzystań przygotowanych kursorów itp. Jeśli nie ma innych możliwości, ale istnieje dostęp do bufora silnika SQL, często bardzo użyteczne informacje może przynieść wykonanie co kilka minut migawki tego bufora i sporządzenie statystyki na tej podstawie. Wielkie zapytania trudno pominąć, podobnie jak zapytania wykonywane dziesiątki razy na minutę. Należy również ustalić globalne koszty, aby zweryfikować hipotezę, że pominięte zapytania stanowią jedynie margines obciążenia. Jeśli większość wykonywanych zapytań to statycznie zapisane dynamiczne zapytania, tego typu migawki mogą dać mniej użyteczne dane — w takim przypadku jesteśmy zmuszeni do uzyskania nieco pełniejszego spojrzenia z użyciem dzienników (o czym wspominałem przy okazji rozwiązań dotyczących znacznych narzutów na wydajności systemu) lub narzędzi nasłuchujących (sniffer). Należy zaznaczyć, że nawet gdy wszystkie statycznie zakodowane zapytania dynamiczne zostaną przechwycone, muszą zostać „wstecznie przekodowane” w taki sposób, aby uzyskać ich zapytania wzorcowe, w oparciu o które będą grupowane w celu obliczenia obciążenia — w tym przypadku nie każdego zapytania SQL, lecz każdego „wzorca” zapytań.
428
ROZDZIAŁ DWUNASTY
Identyfikacja zapytań najbardziej obciążających bazę danych jest jednak dopiero początkiem drogi. Obraz nie będzie kompletny, jeśli aktywności SQL-a nie zostaną przyporządkowane określonym aktywnościom biznesowym wspomaganym przez aplikację i bazę danych. Uzyskanie pojęcia na temat tego, które zapytania SQL są wykonywane za każdym razem, gdy klient składa zamówienie, jest ważniejsze w oszacowywaniu wydajności silnika SQL niż znajomość prędkości transferu z dysku twardego lub prędkości procesora w warunkach standardowej temperatury i ciśnienia. Z jednej strony pozwoli to ocenić wpływ następnej kampanii marketingowej na obciążenie systemu. I jeśli zapytań SQL towarzyszących tego typu operacjom są za każdym razem setki, można zadać kilka interesujących pytań na temat samego programu (czy zapytania SQL są w nim wywoływane w pętli i wykorzystują wynik jednego zapytania w charakterze parametrów wywołania innych?). Podobnie bardzo liczne modyfikacje danych o dużym koszcie czasowym odbywające się na pojedynczej kolumnie, którym towarzyszą niemal identyczne, równie liczne operacje modyfikujące inne kolumny tej samej tabeli, w dodatku wykorzystujące dokładnie taką samą klauzulę WHERE, powinny nasunąć pytanie, czy nie warto zastąpić tych kilku zapytań pojedynczym, modyfikującym wszystkie niezbędne kolumny. Parametry obciążenia bazy danych powinny być ściśle powiązane z zapytaniami SQL, a zapytania SQL z aktywnościami biznesowymi. Aktywności biznesowe natomiast powinny być powiązane z wymogami biznesowymi.
Czy zwrot z inwestycji odpowiada założonym standardom Zgromadzenie wykonywanych zapytań SQL, oszacowanie ich kosztów i zidentyfikowanie ich powiązania z kluczowymi procesami biznesowymi z reguły prowadzą wprost do zidentyfikowania części kodu, które należy ponownie przeanalizować i ewentualnie poprawić. Tym problematycznym kodem mogą być zapytania SQL, algorytmy aplikacji lub jedne i drugie. Określenie oczekiwań z zakresu usprawnień, na jakie można liczyć, należy jednak do najtrudniejszych zadań eksperta od języka SQL. W tym zadaniu znacznie pomaga doświadczenie, ale nawet najbardziej obyty praktyk często nie jest w stanie pozbyć się pewnego poziomu niepewności.
ZATRUDNIANIE SZPIEGÓW
429
Często użyteczną techniką jest ustalenie punktu odniesienia, na przykład wykonania prostych operacji wstawiających dane i oszacowanie prędkości, z jaką baza danych jest w stanie wykonywać tego typu operacje na określonym sprzęcie. Należy również sprawdzić wydajność operacji odczytu w przypadku operacji pełnego przeszukiwania największych tabel w systemie. Porównanie wydajności tego typu operacji jednostkowych z wydajnościami osiąganymi przez aplikację często prowadzi do olśnienia: może okazać się, że te różnice są na poziomie rzędów wielkości. Należy znać ograniczenia wykorzystywanego środowiska. Należy wykonać pomiary wydajności odczytu, zapisu i usuwania pojedynczych wierszy w jednostce czasu.
Po wyznaczeniu kilku punktów odniesienia można zidentyfikować miejsca, w których „zwrot z inwestycji” jest najbardziej korzystny, zarówno z punktu widzenia aktywności biznesowych, jak i możliwości technicznych. Dzięki temu łatwo skupić się na tych częściach programów i szukać optymalizacji tam, gdzie mają największe znaczenie. Niektórzy praktycy przyjmują założenie, że o ile użytkownicy nie narzekają na niską wydajność, problem nie istnieje i nie warto marnować czasu na poszukiwania miejsc do potencjalnych optymalizacji. W tej postawie jest zawarta pewna mądrość, lecz jednocześnie i pewna krótkowzroczność. I to z dwóch powodów: • Po pierwsze, użytkownicy często cechują się zdumiewająco wysoką tolerancją na obniżenie wydajności, a raczej, jak się wydaje, ich postrzeganie zjawiska spowolnienia jest nieco inne niż w przypadku osoby, która doskonale zdaje sobie sprawę z tego, co się dzieje „w środku”. Użytkownicy końcowi mogą narzekać na obniżenie wydajności tych procesów, których obiektywnie nie da się już usprawnić, natomiast w przypadku procesów z rzeczywiście obniżoną wydajnością, gdy na przykład ja straciłbym cierpliwość, oni wykazują znacznie większą wyrozumiałość. Niski poziom niezadowolenia nie oznacza, że wszystko jest w najlepszym porządku, z drugiej strony wyraźne niezadowolenie nie oznacza wcale, że cokolwiek jest nie w porządku z bazą danych lub z aplikacją, z wyjątkiem być może tego, że stara się ona zrobić więcej, niż jest w stanie.
430
ROZDZIAŁ DWUNASTY
• Po drugie, niewielkie zwiększenie obciążenia na serwerze może stanowić sygnał, że w bardzo krótkim czasie wydajność zmieni się z akceptowalnej w nieakceptowalną. Jeśli środowisko jest całkowicie stabilne, nie ma żadnego powodu, aby obawiać się skutków niewielkiego zwiększenia obciążenia. Jeśli jednak w działaniach ujawniają się oznaki chwilowych, lecz znaczących skoków obciążenia, ten sam program, który działał zadowalająco przez jedenaście miesięcy, może nagle stać się przyczyną rozruchów. W tym przypadku wielkie znaczenie ma szum tła. W przeciążonej maszynie trudno zapewnić ten sam poziom usług, gdy obciążenie jeszcze wzrasta. Często istnieje poziom obciążenia, którego przekroczenie przeciętną wydajność systemu nagle przeistacza w katastrofę. Z tego powodu należy przestudiować system w całym jego zakresie, zanim napotka zwiększone nasilenie aktywności, i zastanowić się, czy nie uda się zredukować obciążenia przez udoskonalenia w kodzie. Jeśli modyfikacja kodu nie wystarczy, aby zapewnić akceptowalną wydajność, być może nadszedł czas na wymianę sprzętu. Nie należy zapominać, że zwrot z inwestycji nie jest jedynie zagadnieniem technicznym. Percepcja użytkowników końcowych powinna stanowić najwyższy priorytet działań, nawet jeśli jest nieobiektywna i zupełnie oderwana od kwestii technicznych. Użytkownicy pracują z programem i należy brać pod uwagę ergonomię ich pracy. Nie jest niczym niezwykłym spotkać programistów, często działających w dobrej wierze, którzy poświęcają mnóstwo czasu na poprawianie statystyk, zamiast skupić się na udoskonalaniu wydajności działania programu, nie mówiąc już o zadowoleniu użytkowników. Inżynierowie często czują się sfrustrowani i niezrozumiani przez użytkowników, którzy dostrzegają wyłącznie lokalne udoskonalenia, natomiast na informacje o znaczących technicznych dokonaniach reagują zdawkowym zainteresowaniem. Pewien osiemnastowieczny autor napisał kiedyś doskonałą anegdotę o lekarzu, który został skrytykowany: „Pan X umarł mimo tego że obiecał go pan wyleczyć”. Lekarz na ten zarzut odpowiedział przytomnie: „Niestety, nie było pana przy tym, terapia miała spektakularny przebieg. W rzeczywistości ten pacjent umarł wyleczony”. Baza danych z doskonałymi statystykami i niezadowalającą wydajnością jest z punktu widzenia użytkownika postrzegana jako pacjent wyleczony z jednej choroby, ale umierający na inną. Poprawianie wydajności z punktu widzenia użytkownika z reguły jest postrzegane jako dostarczenie znaczących ulepszeń, nawet jeśli skutkuje spowolnieniem innych,
ZATRUDNIANIE SZPIEGÓW
431
krytycznych biznesowo zapytań, wykonywanych raz w miesiącu. Dlatego warto podjąć systematyczną, rozłożoną w czasie pracę nad przyspieszaniem programów przez eliminację szumu tła, co zapewni wolne moce przerobowe serwerom na te momenty, gdy będą im potrzebne. Poprawianie wydajności jest postrzegane przez użytkowników jako zagadnienie najwyższej wagi, ale nie należy zapominać, że granica między tolerowanym a nietolerowanym poziomem wydajności w obciążonym środowisku jest bardzo cienka.
Definiowanie wydajności docelowej Cele z punktu widzenia wydajności są często definiowane w odniesieniu do czasu, jaki musi upłynąć do momentu zrealizowania zadań, na przykład „program musi wykonać się w czasie poniżej dwóch godzin”. O wiele lepiej jednak definiować te cele jako liczbę jednostek biznesowych aktywności wykonywanych w jednostce czasu, na przykład „pięćdziesiąt tysięcy faktur na godzinę” lub „sto pożyczek na minutę”. Istnieje ku temu kilka powodów: • Taka forma daje lepsze rozeznanie w tym, jakie usługi oferuje program. • Obniżenie wydajności staje się bardziej zrozumiałe dla użytkowników końcowych i można je w prosty sposób powiązać ze zwiększeniem liczby aktywności. Dzięki temu spotkania będą mniej burzliwe. • Z psychologicznego punktu widzenia nieco ciekawszym zadaniem jest ulepszać proces w celu zwiększenia przepustowości niż skrócenia czasu wykonania. Innymi słowy, krzywa rosnąca jest z punktu widzenia zarządzania lepsza od krzywej malejącej. Zwiększenie wydajności oznacza po pierwsze możliwość wykonania większej ilości pracy w tym samym czasie, a po drugie wykonanie tej samej pracy w krótszym czasie.
Zadania biznesowe Zanim skupimy się na konkretnych zapytaniach, warto wspomnieć o kontekście ich działania. Zapytania wykonywane w ramach pętli stanowią złe świadectwo jakości kodu, podobnie jak zmienne programu
432
ROZDZIAŁ DWUNASTY
służące jedynie do tymczasowego zachowywania danych z bazy danych i przekazania ich do innego zapytania. Każde połączenie z bazą danych jest operacją kosztowną i należy maksymalnie ograniczyć liczbę takich połączeń. Gdy przeanalizuje się sposób napisania niektórych programów, trudno oprzeć się wrażeniu, że gdy ich autorzy idą na zakupy, wsiadają do samochodu, jadą do supermarketu, parkują samochód, idą do alejki z artykułami mleczarskimi, wkładają do koszyka jedną butelkę mleka, stają w kolejce do kasy, wkładają butelkę mleka do samochodu, jadą do domu, wkładają butelkę mleka do lodówki, po czym sprawdzają kolejną pozycję na liście zakupów, aby ponownie udać się do supermarketu. A gdy małżonka narzeka na ilość czasu spędzonego na zakupach, z reguły najważniejszym wytłumaczeniem są korki na drogach, kiepskie oznakowanie kategorii artykułów w sklepie i niedostateczna liczba czynnych kas. Te przyczyny niewydajnego procesu robienia zakupów są oczywiście ważne, ale same w sobie nie stanowią zasadniczej przyczyny niezadowalającej wydajności i nie od nich należy zacząć prace nad optymalizacją. Spotykałem programistów, którzy dawali się przekonać, że z punktu widzenia wydajności wykonywanie wielkiej liczby prostych zapytań nie ma sensu i że warto grupować jak największą liczbę operacji w pojedynczych zapytaniach. Słyszałem też opinie stwierdzające, że strategia unikania skomplikowanych złączeń i wykonywania w ich miejsce większej liczby prostych zapytań znacznie upraszcza utrzymanie oprogramowania. Prawda jest jednak taka, że bardzo uproszczone zapytania SQL bywają łatwiejsze w utrzymaniu dla mniej doświadczonych (czytaj: tańszych) programistów, lecz to jedyny argument, jaki można znaleźć na obronę takiej strategii programowania. Wykorzystując jedynie najbardziej podstawowe mechanizmy SQL-a, wypełniamy programy wielką liczbą zapytań, które, każde z osobna, działają dość wydajnie, z wyjątkiem niewielkiej garstki działających poniżej oczekiwań, które w komentarzu do kodu otrzymują etykietę „wymaga optymalizacji”. Jednak bardzo często te zapytania, zidentyfikowane jako zbyt wolne, są odpowiedzialne jedynie za część problemów z wydajnością. Genialnie udoskonalone zapytania wykorzystywane w źle napisanym programie współpracującym ze źle skonstruowaną bazą danych nie są bardziej efektywne niż genialna taktyka w służbach beznadziejnej strategii. Obydwa te zjawiska mogą się jedynie przyczynić do opóźnienia chwili klęski.
ZATRUDNIANIE SZPIEGÓW
433
Nie można pisać wydajnych zapytań SQL, jeśli nie zdaje się sobie sprawy z tego, że język SQL ma zastosowanie do całego podsystemu zarządzania danymi i że jest on prostym zbiorem prymitywnych mechanizmów przesuwających dane między pamięcią krótkotrwałą a pamięcią długotrwałą. Dostępy do bazy danych to elementy programu często najbardziej krytyczne z punktu widzenia wydajności i dlatego należy je uwzględnić w ogólnym projekcie aplikacji. W próbach uproszczenia programów za pomocą upraszczania zapytań SQL, a w efekcie zwiększania ich liczby, dajemy się zwieść niebezpiecznej iluzji. Poziom komplikacji nie wynika z zastosowanych języków programowania, lecz z wymogów biznesowych. Gdy wykorzystujemy jedynie proste zapytania SQL, komplikacja nie staje się mniejsza, jest jedynie „przesuwana” z SQL-a na stronę aplikacji. Zwiększa się przy tym ryzyko wprowadzenia niespójności w danych, gdy logika, rozwiązywana w sposób automatyczny na poziomie silnika bazy danych, jest zaimplementowana w aplikacji w sposób niedoskonały. Co więcej, znacząca ilość operacji przetwarzania danych odbywa się poza zasięgiem optymalizatora bazy danych. Nie sugeruję bezkrytycznego wykorzystywania rozbudowanych, skomplikowanych zapytań SQL lub stosowania polityki „pojedynczych zapytań”. Na przykład poniższe zapytanie jest doskonałym kandydatem do zastosowania kilku prostszych zapytań zamiast jednego wielkiego: insert into custdet (custcode, custcodedet, usr, seq, inddet) select case ? when 'GRP' then b.codgrp when 'GSR' then b.codgsr when 'NIT' then b.codnit when 'GLB' then 'GLOBAL' else b.codetb end, b.custcode, ?, ?, '0' from edic00 a, clidet bT where ((b.codgrp - a.custcode and ? = 'GRP') or (b.codgsr = a.custcode and ? = 'GSR') or (b.codnit = a.custcode
434
ROZDZIAŁ DWUNASTY
and ? = 'NIT') or (a.custcode = 'GLOBAL' and ? = 'GLB')) and a.seq = ? and b.custlvl = ? and b.histdat = ?
Zapytanie, w którym parametr wykonawczy jest porównywany ze stałą, to doskonały kandydat do rozłożenia na mniejsze i prostsze zapytania. W powyższym przykładzie wartość pojawiająca się w konstrukcji CASE jest tą samą, która jest porównywana do stałych GRP, GSR, NIT i GLB w klauzuli WHERE. Nie ma sensu wymuszać na silniku SQL wykonywania dużej serii wzajemnie wykluczających się testów i rozstrzygania sytuacji, które można znacznie prościej rozwiązać po stronie aplikacji. W takim przypadku prosta struktura if ... elsif ... elsif (najlepiej w kolejności zmniejszającego się prawdopodobieństwa wystąpienia danej wartości) i cztery osobne zapytania INSERT ... SELECT (z których wszak będzie wykonane tylko jedno) spiszą się znacznie lepiej. Sytuacja jest diametralnie inna, gdy skomplikowane zapytanie SQL pozwala szybciej uzyskać potrzebne dane, z użyciem mniejszej liczby przełączeń kontekstu między bazą danych a aplikacją. Duże, skomplikowane zapytania nie muszą być powolne, wszystko zależy od sposobu, w jaki są skonstruowane. Programista oczywiście nie powinien zapuszczać się w obszary znacznie wykraczające poza jego umiejętności w zakresie SQL-a, nie powinien też decydować się na napisanie zapytania składającego się z trzystu wierszy kodu. Jednak zawsze celem powinno być umieszczenie w pojedynczym zapytaniu tak dużej ilości operacji, jaka tylko ma sens. Udoskonalanie zapytań SQL, zanim zostanie udoskonalony program i zminimalizowana liczba odwołań do bazy danych, oznacza, że omija się wiele znaczących punktów potencjalnej poprawy wydajności.
Plany wykonawcze Gdy nasi szpiedzy (użytkownicy lub mechanizmy monitorujące) sprowadzą naszą uwagę na określone, podejrzane zapytania SQL, musimy przyjrzeć się im nieco bliżej. Analiza planów wykonawczych to ulubione zadanie wielu specjalistów od optymalizacji zapytań SQL, co łatwo udowodnić,
ZATRUDNIANIE SZPIEGÓW
435
zaglądając na fora i listy dyskusyjne związane z językiem SQL. Często można tam znaleźć dyskusje rozpoczynające się słowami: „Mam zapytanie w SQL-u, które wykonuje się bardzo wolno. Oto jego plan wykonawczy…”. Plany wykonawcze z reguły wyświetla się w postaci listy wykonywanych etapów, z wcięciami symbolizującymi ich hierarchię w problematycznych (często bardzo skomplikowanych) zapytaniach SQL. Spotykana jest również graficzna forma planów wykonawczych, których przykład przedstawia rysunek 12.1. Ten rysunek przedstawia plan wykonawczy jednego z zapytań przedstawionych w rozdziale 7. Plany wykonawcze w formacie tekstowym są stanowczo mniej atrakcyjne wizualnie, za to bardzo łatwo można je opublikować na forum dyskusyjnym, co z pewnością znacząco wpłynęło na niezmiennie trwającą ich popularność. Umiejętność odczytywania i wyciągania wniosków z planów wykonawczych, niezależnie od formy, graficznej czy tekstowej, jest bardzo cenna.
RYSUNEK 12.1. Plan wykonawczy w systemie DB2
436
ROZDZIAŁ DWUNASTY
Jak do tej pory, nie zajmowałem się w tej książce szczegółami planów wykonawczych oprócz kilku przykładów zaprezentowanych przy różnych okazjach, jednak bez szczegółowego komentarza. Plany wykonawcze są narzędziami, a różne osoby mają różne preferencje w stosunku do używanych narzędzi. Czytelnicy mogą oczywiście mieć odmienne opinie, ale osobiście planom wykonawczym poświęcam z reguły niewiele uwagi. Niektórzy programiści uważają analizę planów wykonawczych za kluczowy czynnik w zrozumieniu zagadnień związanych z wydajnością. Przedstawione przeze mnie przykłady z życia pokażą niektóre z powodów, dla których nie jestem bardzo zażartym zwolennikiem stosowania planów wykonawczych jako kluczowego narzędzia w pracy nad wydajnością zapytań.
Identyfikacja najwydajniejszego planu wykonawczego W tym punkcie przetestuję predyspozycje Czytelnika do interpretowania planów wykonawczych. Zademonstruję trzy plany wykonawcze i poproszę o wybór najszybszego z nich. Gotowy? A więc do dzieła i powodzenia!
Nasi kandydaci Poniższy plan wykonawczy przedstawia trzy sposoby wykonania zapytania: Plan 1: Execution Plan ---------------------------------------------------------0 SELECT STATEMENT 1 0 SORT (ORDER BY) 2 1 CONCATENATION 3 2 NESTED LOOPS 4 3 HASH JOIN 5 4 HASH JOIN 6 5 TABLE ACCESS (FULL) OF 'TCTRP' 7 5 TABLE ACCESS (BY INDEX ROWID) OF 'TTRAN' 8 7 INDEX (RANGE SCAN) OF 'TTRANTRADE_DATE' (NON-UNIQUE) 9 4 TABLE ACCESS (BY INDEX ROWID) OF 'TMMKT' 10 9 INDEX (RANGE SCAN) OF 'TMMKTCCY_NAME' (NON-UNIQUE) ... 11 3 TABLE ACCESS (BY INDEX ROWID) OF 'TFLOW' 12 11 INDEX (RANGE SCAN) OF 'TFLOWMAIN' (UNIQUE) 13 2 NESTED LOOPS 14 13 HASH JOIN 15 14 HASH JOIN
ZATRUDNIANIE SZPIEGÓW
16 17 18 19 20 21 22
15 15 17 14 19 13 21
437
TABLE ACCESS (FULL) OF 'TCTRP' TABLE ACCESS (BY INDEX ROWID) OF 'TTRAN' INDEX (RANGE SCAN) OF 'TTRANLAST_UPDATED' (NON-UNIQUE) TABLE ACCESS (BY INDEX ROWID) OF 'TMMKT' INDEX (RANGE SCAN) OF 'TMMKTCCY_NAME' (NON-UNIQUE) TABLE ACCESS (BY INDEX ROWID) OF 'TFLOW' INDEX (RANGE SCAN) OF 'TFLOWMAIN' (UNIQUE)
Plan 2: Execution Plan ---------------------------------------------------------0 SELECT STATEMENT 1 0 SORT (ORDER BY) 2 1 CONCATENATION 3 2 NESTED LOOPS 4 3 NESTED LOOPS 5 4 NESTED LOOPS 6 5 TABLE ACCESS (BY INDEX ROWID) OF 'TTRAN' 7 6 INDEX (RANGE SCAN) OF 'TTRANTRADE_DATE' (NON-UNIQUE) 8 5 TABLE ACCESS (BY INDEX ROWID) OF 'TMMKT' 9 8 INDEX (UNIQUE SCAN) OF 'TMMKTMAIN1 (UNIQUE) 10 4 TABLE ACCESS (BY INDEX ROWID) OF 'TFLOW' 11 10 INDEX (RANGE SCAN) OF 'TFLOWMAIN' (UNIQUE) 12 3 TABLE ACCESS (BY INDEX ROWID) OF 'TCTRP' 13 12 INDEX (UNIQUE SCAN) OF 'TCTRPMAIN' (UNIQUE) 14 2 NESTED LOOPS 15 14 NESTED LOOPS 16 15 NESTED LOOPS 17 16 TABLE ACCESS (BY INDEX ROWID) OF 'TTRAN' 18 17 INDEX (RANGE SCAN) OF 'TTRANLAST_UPDATED' (NON-UNIQUE) 19 16 TABLE ACCESS (BY INDEX ROWID) OF 'TMMKT' 20 19 INDEX (UNIQUE SCAN) OF 'TMMKTMAIN' (UNIQUE) 21 15 TABLE ACCESS (BY INDEX ROWID) OF 'TFLOW' 22 21 INDEX (RANGE SCAN) OF 'TFLOWMAIN' (UNIQUE) 23 14 TABLE ACCESS (BY INDEX ROWID) OF 'TCTRP' 24 23 INDEX (UNIQUE SCAN) OF 'TCTRPMAIN' (UNIQUE)
Plan3: Execution Plan ---------------------------------------------------------0 SELECT STATEMENT 1 0 SORT (ORDER BY) 2 1 NESTED LOOPS 3 2 NESTED LOOPS 4 3 NESTED LOOPS 5 4 TABLE ACCESS (BY INDEX ROWID) OF 'TMMKT'
438
6 7 8 9 10 11 12
ROZDZIAŁ DWUNASTY
5 4 7 8 9 2 11
INDEX (RANGE SCAN) OF 'TMMKTCCY_NAME' (NON-UNIQUE) TABLE ACCESS (BY INDEX ROWID) OF 'TTRAN' INDEX (UNIQUE SCAN) OF 'TTRANMAIN' (UNIQUE) TABLE ACCESS (BY INDEX ROWID) OF 'TCTRP' INDEX (UNIQUE SCAN) OF 'TCTRPMAIN' (UNIQUE) TABLE ACCESS (BY INDEX ROWID) OF 'TFLOW' INDEX (RANGE SCAN) OF 'TFLOWMAIN' (UNIQUE)
Pole bitwy Wynik zapytania składa się z ośmiuset sześćdziesięciu wierszy, a w samym zapytaniu biorą udział następujące tabele: Nazwa tabeli
Liczba wierszy (przybliżona)
tctrp
18000
ttran
1500000
tmmkt
1400000
tflow
5400000
Wszystkie te tabele są mocno poindeksowane, przy każdym planie wykonawczym nie był tworzony żaden dodatkowy indeks, nie był też usuwany ani przebudowywany, i nie były wykonywane jakiekolwiek zmiany w strukturach danych. Między poszczególnymi planami wykonawczymi zmieniany był jedynie tekst zapytania, w niektórych przypadkach były stosowane specjalne dyrektywy optymalizatora. Rozważmy te trzy plany wykonawcze i spróbujmy ułożyć je w kolejności oczekiwanej prędkości działania. Można również pokusić się o zasugerowanie jakichś metod udoskonalenia każdego z nich.
A zwycięzcą jest… Plan 1 zajął dwadzieścia siedem sekund, Plan 2 jedną sekundę, a Plan 3 (pierwotna postać tego zapytania) jedną minutę i dwanaście sekund. Jeśli ktoś niewłaściwie ocenił czasochłonność tych planów, nie powinien czuć się winny. W rzeczywistości w oparciu o informacje, jakich udzieliłem, wybór właściwego planu byłby jedynie kwestią szczęścia (lub wynikiem słusznego wrażenia, że gdzieś tu jest ukryty jakiś haczyk). Warto zauważyć, że najwolniejszy plan wykonawczy jest znacząco krótszy od pozostałych i że zawiera jedynie odwołania do wykorzystania indeksów. Z kolei Plan 1
ZATRUDNIANIE SZPIEGÓW
439
demonstruje, że można wykorzystać dwa pełne przeszukiwania tabel w jednym zapytaniu, a i tak zapytanie będzie prawie trzykrotnie szybsze od znacznie krótszego Planu 3, wykorzystującego wyłącznie indeksy. Celem tego ćwiczenia było zademonstrowanie faktu, iż długość planu wykonawczego nie ma większego znaczenia i że dostęp do tabel z użyciem indeksów nie gwarantuje najlepszej z możliwych wydajności. Oczywiście w przypadku 300-wierszowego planu wykonawczego dla zapytania zwracającego dziewiętnaście wierszy danych można spodziewać się niezbyt wydajnego wykonania, jednak nie wolno zakładać, że im krótszy jest plan wykonawczy, tym lepsze będą jego osiągi.
Wymuszanie odpowiedniego planu wykonawczego Drugim przykładem będzie dziwaczne zachowanie jednego zapytania wygenerowanego przez komercyjny program. Po uruchomieniu go na jednej bazie danych zapytanie wykonuje się około czterech minut i zwraca czterdzieści tysięcy wierszy. W innej bazie danych, obsługiwanej przez tę samą wersję silnika baz danych, na porównywalnym sprzęcie i znacząco mniejszych tabelach to samo zapytanie wykonuje się jedenaście minut. Plany wykonawcze obydwu wywołań tego samego zapytania są zupełnie inne. W obydwu bazach danych statystyki są aktualne, a optymalizator jest skonfigurowany w taki sposób, aby wykorzystywał wszelkie dostępne mu informacje. Podstawowe pytanie w tym przypadku brzmi: w jaki sposób zmusić optymalizator, aby wybrał właściwy plan wykonawczy na mniejszej bazie danych. Administratorzy baz danych zostali poproszeni, aby uzyskać te same plany wykonawcze w obydwu systemach. Zespół techniczny dostawcy systemu ściśle współpracuje z zespołem klienta nad rozwiązaniem tego problemu.
Uparte zapytanie Tekst zapytania prezentuję poniżej1, a zaraz po nim jego plan wykonawczy w szybszej wersji. Należy zauważyć, że właściwy plan wykorzystuje jedynie indeksy, nie tabele.
1
Nazwy obiektów zostały zmienione, dla ochrony zarówno niewinnych, jak i oskarżonych.
440
ROZDZIAŁ DWUNASTY
select o.id_outstanding, ap.cde_portfolio, ap.cde_expense, ap.branch_code, to_char(sum(ap.amt_book_round + ap.amt_book_acr_ad - ap.amt_acr_nt_pst)), to_char(sum(ap.amt_mnl_bk_adj)), o, cde_outstd_typ from accrual_port ap, accfual_cycle ac, outstanding o, deal d, facility f, branch b where ac.id_owner = o.id_outstandng and ac_id_acr_cycle = ap.id_owner and o,cde_outstd_typ in ('LOAN', 'DCTLN', 'ITRLN', 'DEPOS', 'SLOAN', 'REPOL') and d.id_deal = o.id_deal and d.acct_enabl_ind = 'Y' and (o.cde_ob_st_ctg = 'ACTUA' or o.id_outstanding in (select id_owner from subledger)) and o.id_facility = f.id_facility and f.branch_code = b.branch_code and b.cde_tme_region = 'Z0NE2' group by o.id_outstanding, ap.cde_portfolio, ap.cde_expense, ap.branch_code, o.cde_outstd_typ haying sum(ap.amt_book_round + ap.amt_book_acr_ad - ap.amt_acr_nt_pst) <> 0 or (sum(ap.amt_mnl_bk_adj) is not null and sum(ap.amt_mnl_bk_adj) <> 0)
Execution Plan --------------------------------------------------------0 SELECT STATEMENT Optimizer=CHOOSE 1 0 FILTER 2 1 SORT (GROUP BY) 3 2 FILTER 4 3 HASH JOIN 5 4 HASH JOIN 6 5 HASH JOIN 7 6 INDEX (FAST FULL SCAN) OF 'XDEAUN08' (UNIQUE)
ZATRUDNIANIE SZPIEGÓW
8 9 10 11 12 13 14 15
6 8 9 9 8 5 4 3
441
HASH JOIN NESTED LOOPS INDEX (FAST FULL SCAN) OF 'XBRNNN02' (NON-UNIQUE) INDEX (RANGE SCAN) OF 'XFACNN05' (NON-UNIQUE) INDEX (FAST FULL SCAN) OF 'XOSTNN06' (NON-UNIQUE) INDEX (FAST FULL SCAN) OF 'XACCNN05' (NON-UNIQUE) INDEX (FAST FULL SCAN) OF 'XAP0NN05' (NON-UNIQUE) INDEX (SKIP SCAN) OF 'XBSGNN03' (NON-UNIQUE)
Dodanie indeksów do mniejszej bazy danych nie daje efektu. Istniejące indeksy były początkowo identyczne w obydwu bazach danych i utworzenie dodatkowych indeksów na mniejszej bazie danych nie spowodowało zmiany w planie wykonawczym. Trzy tygodnie po pierwszym spostrzeżeniu problemu zainteresowano się mechanizmem disk stripping, ale bez większych nadziei. Wykorzystanie dyrektyw optymalizatora zaczęło nieprzyjemnie jawić się jako jedyna nadzieja na ratunek. Przed zastosowaniem dyrektyw warto rozeznać się choćby pobieżnie, jaki powinien być obszar ataku. Znalezienie odpowiedniej taktyki, jak mieliśmy okazję przekonać się w rozdziałach 4. i 6., wymaga oszacowania, choćby pobieżnego, różnych kryteriów wejściowych, nawet mimo tego że dość duży zbiór wynikowy (około czterdzieści tysięcy wierszy w większej bazie danych i nieco ponad trzy tysiące w mniejszej) daje niewielką nadzieję na znalezienie jednego kryterium, które okaże się kluczowe.
Analiza kryteriów wyszukiwania Gdy jako jedynym kryterium wyszukiwania posłużylibyśmy się określeniem strefy czasowej, zapytanie zwróciłoby około 17% więcej wierszy wynikowych niż w przypadku zastosowania wszystkich kryteriów. Jednak w tym przypadku zapytanie działa zdumiewająco szybko: SQL> select count(*) "FAC" 2 from outstanding 3 where id_facility in (select f.id_facility 4 from facility f, 5 branch b 6 where f.branch_code = b.branch_code 7 and b.cde_tme_region = 'ZONE2'); FAC ---------55797 Elapsed: 00:00:00.66
442
ROZDZIAŁ DWUNASTY
Sam warunek wykorzystujący znacznik (Y lub N) filtruje około trzykrotnie więcej wierszy, niż zostaje zwróconych w wyniku, lecz również działa bardzo szybko: SQL> select count(*) "DEA" 2 from outstanding 3 where id_deal in (select id_deal 4 from deal 5 where acct_enabl_ind = 'Y'); DEA --------123970 Elapsed: 00:00:00.63
A co z warunkiem wykorzystującym tabelę outstanding? Oto wynik zapytania z użyciem tego warunku: SOL> select count(*) "ACTUA/SUBLEDGER" 2 from outstanding 3 where (cde_ob_st_ctg = 'ACTUA' 4 or id_outstanding in (select id_owner 5 from subledger)); ACTUA/SUBLEDCER --------------32757 Elapsed: 00:15:00.64
Od razu widać, że udało się znaleźć przyczynę problemu. To warunek OR powoduje znaczący przyrost czasu wykonania zapytania. Plan wykonawczy tego zapytania pokazuje, że wykorzystane zostaną jedynie indeksy: Execution Plan -----------------------------------------------0 SELECT STATEMENT Optimizer=CHOOSE 1 0 SORT (AGGREGATE) 2 1 FILTER 3 2 INDEX (FAST FULL SCAN) OF 'X0STNN06' (NON-UNIQUE) 4 2 INDEX (SKIP SCAN) OF 'XBSGNN03' (NON-UNIQUE)
Warto zwrócić uwagę, że obydwa użycia indeksu nie są standardowe. Nie ma sensu wnikać w szczegóły techniczne, dość stwierdzić, że FAST FULL SCAN oznacza zastosowanie indeksu zamiast tabeli do operacji pełnego
ZATRUDNIANIE SZPIEGÓW
443
przeszukiwania, SKIP SCAN oznacza operację zbliżoną z technicznego punktu widzenia. Innymi słowy, metody uzyskania dostępu do danych nie wynikają tu z wyboru idealnej ścieżki, ale z decyzji typu „tak powinno być lepiej”, podjętej przez optymalizator. Jeśli jednak wziąć pod uwagę czas wykonania tego zapytania, strategia SKIP SCAN nie jest dobrym wyborem. Przyjrzyjmy się indeksom tabeli outstanding (liczba unikalnych kluczy indeksów i unikalnych wartości kolumn to oszacowania, co jest powodem drobnych niespójności w prezentowanych wartościach). Indeksy wypisane pogrubioną czcionką to te, które biorą udział w naszym planie wykonawczym: INDEX_NAME DIST KEYS COLUMN_NAME DIST VAL ------------------- --------- -------------------- -------XOSTNC03 25378 ID_DEAL 1253 ID_FACILITY 1507 XOSTNN05 134875 ID_OUTSTANDINC 126657 ID_DEAL 1253 IND_AUTO_EXTND 2 CDE_OUTSTD_TYP 5 ID_FACILITY 1507 UID_REC_CREATE 161 NME_ALIAS 126657 XOSTNN06 ID_OUTSTANDING 126657 CDE_OUTSTD_TYP 5 ID_DEAL 1253 CDE_OB_ST_CTG 3 ID_FACILITY 1507 XOSTUN01 (U) 121939 ID_OUTSTANDING 126657 XOSTUN02 (U) 111055 NME_ALIAS 126657
Kolejny indeks (xbsgnn03) jest związany z tabelą subledger: INDEX_NAME DIST KEYS COLUMN_NAME DIST VAL ------------------- --------- -------------------- -------XBSGNN03 101298 BRANCH CODE 8 CDE_PORTFOLIO 5 CDE_EXPENSE 56 ID_OWNER 52664 CID CUSTOMER 171 XBSGNN04 59542 ID_DEAL 4205 ID_FACILITY 4608 ID_OWNER 52664 XBSCNN05 49694 BRANCH_CODE 8 ID_FACILITY 4608 ID_OWNER 52664
444
ROZDZIAŁ DWUNASTY
XBSGUC02 (U)
XBSGUN01 (U)
147034 CDE_GL_ACCOUNT CDE_GL_SHTNAME BRANCH_CODE CDE_PORTFOLIO CDE_EXPENSE ID_OWNER CID_CUSTOMER 134581 ID_SUBLEDGER
9 9 8 5 56 52664 171 154362
Jak to ma często miejsce w przypadku programów kupowanych „z półki”, mamy tu do czynienia z praktyką „indeksowania dywanowego”. Indeksy tabeli outstanding nasuwają kilka pytań: • Dlaczego id_outstanding, klucz główny tabeli outstanding, pojawia się jako pierwsza kolumna dwóch innych indeksów? Taka dziwna decyzja wymaga jakiegoś uzasadnienia i to bardzo solidnego. Nawet jeśli te indeksy zostały zbudowane z myślą o odczytywaniu wartości bezpośrednio z nich, bez konieczności odczytu tabel, kolumna id_outstanding powinna pojawić się na innej pozycji indeksu. Z drugiej strony, ponieważ niewiele kolumn w tej tabeli zawiera bardzo zróżnicowane wartości, warto zastanowić się, czy w ogóle ma sens tworzenie tych indeksów. • Nie wszystko jest również jasne po stronie tabeli subledger. Jedną z najbardziej selektywnych wartości jest id_owner. Dlaczego ta kolumna występuje w czterech z pięciu indeksów tej tabeli, ale nigdzie nie występuje na wiodącej pozycji? Tego typu sytuacja jest zastanawiająca w przypadku najbardziej selektywnej kolumny tabeli. Co więcej, występowanie kolumny id_owner na wiodącej pozycji indeksu znacznie pomogłoby w przypadku naszego problematycznego zapytania. Modyfikowanie indeksów to dość delikatne zadanie wymagające rozważenia wszystkich efektów ubocznych. W tym przypadku mamy do czynienia z dużą liczbą indeksów o wątpliwym przeznaczeniu, mamy jednak również do rozwiązania ważny problem. Odstąpmy zatem od wprowadzania zmian w istniejących indeksach i skupmy swoją uwagę na kodzie SQL. Liczba unikalnych kluczy w naszych unikalnych indeksach pokazuje, że nie mamy do czynienia z dużymi tabelami. W rzeczywistości pozostałe dwa kryteria wykonywane na tabeli outstanding, których wydajność sprawdzaliśmy, działały szybko, mimo tego że były relatywnie słabe.
ZATRUDNIANIE SZPIEGÓW
445
Uzyskany żałosny wynik całkowity jest efektem próby złączenia danych żmudnie wydobywanych z dwóch indeksów. Spróbujmy czegoś innego: SQL> select count(*) "ACTUA/SUBLEDGER" 2 from (select id_outstanding 3 from outstanding 4 where cde_ob_st_ctg = "ACTUA1 5 union 6 select o.id_outstanding 7 from outstanding o, 8 subledger sl 9 where o.id outstanding= sl.id_owner) 10 / ACTUA/SUBLEDCER --------------32757 Elapsed: 00:00:01.82
Nie ruszaliśmy indeksów, a i tak optymalizator był w stanie znaleźć drogę, mimo tego że tabela outstanding była odczytywana dwukrotnie. Wykonanie zapytania jest też znacznie szybsze. Zastąpienie problematycznego warunku drobnymi modyfikacjami w pozostałych warunkach spowodowało, że zapytanie wykonało się w trzynaście sekund zamiast, jak wcześniej, czterech minut (dotyczy to przypadku „dobrego”) oraz w jedynie 3 – 4 sekundy przy trzech tysiącach dwustu wierszach — zamiast jedenastu minut potrzebnych mu do wykonania na „problematycznej” instalacji.
Morał z tej historii Bardzo prawdopodobne jest, że skrupulatna analiza i nieco pracy na poziomie indeksów byłyby w stanie jeszcze bardziej przyspieszyć działanie tego zapytania. Z drugiej strony, ponieważ prawie każdy był zupełnie zadowolony z czterech minut, trzynaście sekund jest prawdopodobnie wystarczająco dużym osiągnięciem. Fascynujące w tej prawdziwej historii (jak wiele przykładów z tej książki naprawdę pochodzi ona z rzeczywistej sytuacji, jaką napotkałem w życiu) jest to, jak wiele osób poświęcało swoją uwagę (przez wiele tygodni) zupełnie niewłaściwemu zagadnieniu. W rzeczywistości problem leżał w mniejszej bazie danych. Porównanie tych dwóch, zupełnie odmiennych
446
ROZDZIAŁ DWUNASTY
planów wykonawczych prowadziło do konkluzji, że plan wykonawczy związany z powolnym wykonaniem był błędny (co okazało się prawdą), co z kolei skutkowało wnioskowaniem, iż plan wykonawczy związany z szybszym wykonaniem był optymalny (co z kolei okazało się nieprawdą). To był poważny błąd logiczny i wywiódł na manowce wiele osób, skłaniając je do skupienia się na odtworzeniu błędnego planu wykonawczego, podczas gdy od początku trzeba było zająć się udoskonaleniem samego zapytania. Na zakończenie chciałbym przekazać jedną uwagę jako podsumowanie tej historii. Po przepisaniu zapytania plan wykonawczy w obydwu bazach nadal był odmienny, ale ta sytuacja, biorąc pod uwagę różnice rozmiarów danych, stanowi dowód na to, że optymalizator po prostu wykonuje swoje zadania. Jedynym wyznacznikiem wydajności zapytania jest czas jego wykonania, nie zaś to, czy jego plan wykonawczy jest zgodny z oczekiwaniami.
Właściwe wykorzystanie planów wykonawczych Plany wykonawcze są użyteczne, ale przede wszystkim służą do sprawdzenia, czy silnik bazy danych rzeczywiście pracuje zgodnie z oczekiwaniami. Raport na poziomie abstrakcji planu wykonawczego jest doskonałym narzędziem porównawczym w dziedzinie realizacji założonej taktyki i może ujawnić błędy taktyczne lub przeoczone szczegóły.
W jaki sposób nie wykonywać zapytań Plany wykonawcze mogą być użyteczne nawet wówczas, gdy nie mamy pomysłu na to, jaki powinien być plan wykonawczy. Rozumowanie jest takie: jeśli zapytanie sprawia problemy, jego plan wykonawczy jest niewłaściwy, nawet w przypadku, gdy nie „wygląda” aż tak źle. Wiedza o tym, że plan jest błędny, pozwala odkryć sposoby udoskonalenia zapytania z użyciem jednej z najbardziej zaawansowanych form logiki, to znaczy sylogizmu, składającego się z dwóch przesłanek i konkluzji.
ZATRUDNIANIE SZPIEGÓW
447
Rozumowanie jest następujące: (przesłanka 1) Zapytanie działa bardzo wolno. (przesłanka 2) Plan wykonawczy zawiera działania głównie jednego typu, na przykład pełne przeszukiwania tabel, złączenia typu hash, dostępy z użyciem indeksów, zagnieżdżone pętle itp. (wniosek) Należy przepisać zapytanie i (lub) zmienić schemat indeksowania, aby zasugerować optymalizatorowi inną ścieżkę wykonawczą. Nakłonienie optymalizatora do wyboru zupełnie innej ścieżki wykonawczej może odbyć się z użyciem następujących środków: • Gdy w wyniku oczekujemy niewielkiej liczby wierszy, może wystarczyć dodanie odpowiedniego indeksu lub przebudowanie indeksu złożonego poprzez przestawienie kolejności jego kolumn. Pomocne może być również przekształcenie skorelowanych zapytań w nieskorelowane. • Gdy mamy dużą liczbę wierszy, możemy zrobić coś odwrotnego, dodatkowo stosując nawiasy i podzapytania w klauzuli FROM i sugerując inną kolejność złączeń tabel. • W przypadku wątpliwości oprócz przekształcania skorelowanych podzapytań w nieskorelowane i vice versa mamy dość sporo możliwości. Możemy wziąć pod uwagę operacje faktoryzacji zapytań z użyciem klauzul UNION lub WITH. Unia dwóch skomplikowanych zapytań może w niektórych przypadkach zostać przekształcona w prostszą unię ukrytą w klauzuli FROM. Często przydaje się również rozwikłanie warunków (starając się, aby każdy warunek był zależny od jak najmniejszej liczby innych warunków). W ogólnym ujęciu: zanim postaramy się narzucić optymalizatorowi konkretną kolejność działań, należy pozbyć się w jak największym stopniu elementów wymuszających kolejność przetwarzania, dając mu w ten sposób jak najwięcej swobody. Dopiero jeśli takie nieskrępowane zapytania nie dadzą rezultatu, można zdecydować się na narzucanie swojej woli optymalizatorowi. • Jako ostatnią deską ratunku można posłużyć się dyrektywami optymalizatora, ale należy stosować je z dużą ostrożnością.
448
ROZDZIAŁ DWUNASTY
Ukryta komplikacja Plany wykonawcze również mogą służyć jako wartościowi szpiedzy w poszukiwaniu ukrytych komplikacji. Zapytania nie zawsze są tym, na co wskazuje pobieżna analiza. Udział w zapytaniu jakiegoś obiektu bazy danych może skutkować dodatkową pracą dla optymalizatora, którą uda się ujawnić właśnie dzięki planowi wykonawczemu. Tego typu niebezpiecznymi obiektami są przede wszystkim: Perspektywy Zapytania mogą wyglądać prosto, ale czasem bywa to złudne. Obiekt wyglądający jak prosta tabela może okazać się perspektywą zdefiniowaną w oparciu o bardzo skomplikowane zapytanie wykorzystujące inne perspektywy. Nazwy perspektyw nie zawsze są tworzone w taki sposób, że łatwo zorientować się co do ich natury, a nawet jeśli tak nie jest, sama nazwa nie daje informacji na temat poziomu jej komplikacji. Plan wykonawczy potrafi zwrócić uwagę na to, czego nie widać przy pobieżnej analizie kodu SQL, a co ważniejsze, pokaże, czy jakieś tabele nie są odczytywane wielokrotnie. Wyzwalacze Zmiany w tabelach mogą zajmować dużo czasu tylko z jednego powodu: zdefiniowania wyzwalaczy. Wyzwalacze potrafią czasem działać bardzo wolno, mogą też być przyczyną poważnych problemów z wydajnością. Wyzwalacze łatwo jest przeoczyć, natomiast plany wykonawcze ujawnią ich istnienie. Podstawową wartością planów wykonawczych jest możliwość wykorzystania ich jako punktu wyjścia w działaniach zmierzających do optymalizacji wydajności i do wykrywania operacji baz danych zamaskowanych przez użycie perspektyw i wyzwalaczy.
Co ma rzeczywiste znaczenie? Zagadnienia o podstawowym znaczeniu w procesie udoskonalania zapytań zostały omówione szczegółowo w poprzednich rozdziałach. Należy przede wszystkim zwrócić uwagę na następujące z nich: • liczba wierszy w tabelach biorących udział w zapytaniach, • istniejące indeksy w tych tabelach,
ZATRUDNIANIE SZPIEGÓW
449
• cechy szczególne fizycznego zapisu, jak partycjonowanie, które mogą mieć na wydajność wpływ porównywalny do skutków zastosowania indeksów, • jakość zastosowanych kryteriów, • rozmiar zbioru wynikowego. Te informacje dostarczają solidnego fundamentu, w oparciu o który można zbadać wydajność zapytania, i są o wiele cenniejsze niż sam plan wykonawczy. Gdy już wiemy, na czym stoimy i z czym mamy „walczyć”, wtedy możemy zacząć się poruszać i „atakować” tabele, starając się pozbyć zbytecznych danych tak szybko, jak to tylko możliwe. Zawsze musimy starać się pozostawić jak najwięcej wolności optymalizatorowi, unikając zależności między podzapytaniami, które mogłyby wpłynąć na kolejność przetwarzania tabel w zapytaniu. W podsumowaniu chcę przypomnieć, że optymalizatory, które z reguły wykonują swoją pracę bardzo efektywnie, nie będą pracować wydajnie w następujących okolicznościach: • Jeśli dane dla programu są gromadzone porcja po porcji za pomocą osobnych zapytań jednostkowych. Aplikacja wysyła do silnika SQL swoje zapytania w określonym celu. Jednak ten silnik nie ma szans „domyślić się” powiązania między tymi zapytaniami, a przez to nie jest w stanie zoptymalizować zapytań. Silnik SQL-a zoptymalizuje oczywiście poszczególne zapytania, ale nie zoptymalizuje całego procesu. • Jeśli w zapytaniach są stosowane różne nierelacyjne zabiegi (często bardzo użyteczne) obsługiwane przez różne dialekty SQL-a. Należy pamiętać, że nierelacyjne mechanizmy należy stosować na końcu, gdy większość danych jest już odfiltrowana (a w szerszym kontekście: dane muszą być odfiltrowane, zanim mogą być zapisane lub usuwane). Nierelacyjne funkcje działają na skończonych zbiorach danych (czyli na tablicach), a nie na teoretycznie nieskończonych relacjach (reprezentowanych przez tabele). Dawniej można było zdobyć reputację eksperta od SQL-a, jeżeli umiało się zidentyfikować brakujący indeks lub przepisać zapytanie w taki sposób, żeby uniknąć stosowania w warunkach funkcji na indeksowanych kolumnach.
450
ROZDZIAŁ DWUNASTY
Te czasy jednak już minęły. Większość baz danych posiada nadmierną liczbę indeksów, choć często istniejące indeksy nie są optymalne. Funkcje stosowane na indeksowanych kolumnach nadal się spotyka, ale ratunek w tego typu sytuacjach niosą indeksy funkcyjne. Jednak przepisanie źle napisanego zapytania oznacza w dzisiejszych czasach więcej niż tylko żonglowanie warunkami lub dokonywanie kosmetycznych zmian. Prawdziwym wyzwaniem jest myślenie w sposób globalny i przyjęcie do wiadomości, że obsługa danych to krytyczne zadanie w świecie, w którym ilości przechowywanych danych zwiększają się w szybszym tempie, niż zwiększa się wydajność sprzętu. Na szczęście, lub nieszczęście, mówi się „obsługa danych”, ale pisze się S-Q-L. Jak wszystkie języki programowania, SQL ma swoje cechy szczególne, zalety, ale też liczne wady. Jak w przypadku wszystkich języków, opanowanie SQL-a wymaga czasu i doświadczenia oraz predyspozycji uczącego się. Mam nadzieję, że niniejsza książka okaże się jednym z cennych przystanków na tej długiej drodze. Budowanie optymalnie działającego kodu SQL może być źródłem wielkiej satysfakcji, czego życzę wszystkim!
ILUSTRACJE
Niemal wszystkie ilustracje tytułowe rozdziałów zostały zeskanowane z książki Mémorial de Saunte-Hélène autorstwa Comte Emmanuel de Las Cases (wydanej nakładem Ernest Bourdin Editeur, Paryż 1842). Ilustracje są autorstwa Charlet. Trzy pozostałe pochodzą z innych źródeł: • Ilustracja z rozdziału 6. została wykonana w oparciu o mapę bitwy pod Fredericksburgiem. Mapę znalazłem na stronie http://www.sonofthesouth.net i wykorzystałem za pozwoleniem Paula McWorthera, który prowadzi tę bardzo zasobną stronę o Amerykańskiej Wojnie Domowej. • Ilustracja z rozdziału 9. pochodzi z książki Notre Armée, autorstwa de Lonlay, ilustrowanej przez autora (Garnier Frères, Paryż 1890, s. 931). • Ilustracja z rozdziału 12. pochodzi z publikacji Les Guerres de la Révolution, autorstwa Camille Pelletan (Société d’Éditions d’Art, Paryż, brak daty — koniec XIX lub początek XX wieku; pierwsze wydanie drukiem: Colas, Paryż 1884, s. 95 wydania X). Autor ilustracji: pułkownik Durelle-Marc, ilustracja wykorzystana za pozwoleniem Centre d’Histoire du Droit de l’Universite Rennes 1 (http://www.chd.univ-rennes1.fr/Icono/Pelletan/Pelletan.htm).
452
ILUSTRACJE
O AUTORACH
Stéphane Faroult relacyjne bazy danych odkrył w roku 1983. Zatrudnił się w Oracle France we wczesnym okresie istnienia tego oddziału (po krótkim okresie pracy w firmie IBM i wykładania na uniwersytecie w Ottawie) i szybko zainteresował się zagadnieniami wydajności i konfiguracji. Po opuszczeniu firmy Oracle w 1988 roku zdecydował się na zmianę i przez krótki czas zajmował się badaniami operacyjnymi, ale po roku ponownie powrócił do relacyjnych baz danych. Od tamtej pory stale zajmuje się konsultacjami z dziedziny baz danych, a w roku 1998 założył firmę RoughSea Ltd (http://www.roughsea.com). Stéphane Faroult napisał (w języku francuskim, wraz z Didier Simonem) Fortran Structuré et Méthodes Numériques (Dunod, 1986) oraz kilka artykułów w języku angielskim opublikowanych w czasopismach „Oracle Scene” (magazyn brytyjskiej grupy użytkowników bazy danych Oracle) oraz „Select” (magazyn północnoamerykańskiej grupy użytkowników bazy danych Oracle), a także w różnych serwisach WWW (w tym również „Oracle Magazine” w wydaniu online). Prowadził również odczyty na konferencjach organizowanych przez grupy użytkowników związane z bazami danych w USA, Wielkiej Brytanii i Norwegii. Peter Robson jest absolwentem geologii w Durham University (1968), wykładał na uniwersytecie w Edynburgu, uzyskał tytuł magistra geologii w 1975 roku. Jakiś czas pracował w Grecji jako geolog, po czym wyspecjalizował się w geologicznych i medycznych bazach danych na uniwersytecie w Newcastle.
454
O AUTORACH
Pracuje z bazami danych od 1977 roku, z relacyjnymi bazami danych od roku 1981, a z bazami Oracle od 1985. Sprawował różne funkcje: programista, architekt danych i administrator baz danych. W 1980 roku Peter wstąpił do British Geological Survey i miał znaczący wpływ na wykorzystanie relacyjnych baz danych w pracy w tym stowarzyszeniu. Specjalizuje się w zagadnieniach języka SQL oraz w modelowaniu danych od poziomu korporacyjnego do poziomu departamentów. Peter prowadził odczyty na konferencjach dotyczących baz danych Oracle w Wielkiej Brytanii, Europie i Ameryce Północnej oraz publikował w specjalistycznych periodykach poświęconych tematyce baz danych. Obecnie jest członkiem zarządu UK Oracle User Group. Można uzyskać z nim kontakt pod adresem [email protected].
SKOROWIDZ
@@IDENTITY, 320 @@ROWCOUNT, 69 10% wierszy, 149 1NF, 25, 98 2NF, 27 3NF, 19, 20, 27, 361 5NF, 20
A ADJACENCY_MODEL, 240 adres IP, 282 agregacja wartości z drzewa, 261 liczebność na wszystkich poziomach, 263 modelowanie liczebności, 261 propagacja obliczeń procentowych pomiędzy poziomami, 266 wartości zapisane w liściach, 261 agregacja wielozakresowa, 404, 407 agregaty, 210 aksjomat, 18 analiza leksykalna, 404 AND, 212, 213 archiwizacja, 358, 360 atom, 21 atomowość, 21, 98 atrybuty atomowe, 21 boolowskie, 35 NULL, 29 autoincrement, 320
automatyczne grupowanie danych, 164 avg(), 212 awaria sprzętowa, 48
B backlog, 54 baza danych, 16, 114, 281 CODASYL, 232 relacyjna, 18 baza filmów, 287 BCNF, 19, 20 biblioteki dostępu do baz danych, 280 Bill of Materials, 232 bind_param(), 298 blokada, 104, 108, 316 czas funkcjonowania, 318 minimalizacja czasu utrzymania, 321 operacje COMMIT, 321 poziom stosowania, 316 skalowalność, 323 transakcje, 319 wydajność modyfikacji danych w tabeli, 317 zasady korzystania, 318 zasoby, 315 blokowanie, 91 poziom wierszy, 324 błędna hermetyzacja dostępów do bazy danych, 278 BOM, 232 brak podatności na skutki zwiększenia danych, 341
456
SKOROWIDZ
brak zdefiniowanych danych, 224 bridge table, 368 B-tree, 342 buforowanie zapytań, 404 bulk collect, 77 business intelligence, 364
C CASE, 71, 366, 405, 434 CBO, 62 Celko, Joe, 238 centralizacja danych, 46 chaining, 176 ciężkie przypadki kodu SQL, 8 cluster, 176 clustered index, 162 coalesce(), 292 CODASYL, 232 cold area, 170 COMMIT, 61, 321 concat(), 275 congestion, 91 CONNECT BY, 238, 239, 254, 260, 389 connection pooling, 56 contention, 315 cost-based optimizers, 62 count(), 211 count(*), 69, 79, 211, 226 cykle przesyłania danych między aplikacją a bazą danych, 56 czas, 217 czas reakcji, 155, 381 częstotliwość operacji COMMIT, 322 częstotliwość zapytań, 311 część wspólna, 412 czyszczenie danych, 358
D dane atomowe, 23 bieżące, 40 hierarchiczne, 232 historyczne, 40, 218 strategiczne, 231 data definition language, 59 data manipulation code, 61
data-driven partitioning, 165 daty, 217 DBA solutions, 329 DBMS, 28 DDL, 59 de Morgan, August, 214 deadlock, 105 decode(), 71, 77 definicja dobrej wydajności, 423 definicja problemu, 58 definiowanie wydajności docelowej, 431 DELETE, 359 DISTINCT, 137, 139, 192, 230, 256, 293 DML, 61, 319 dobra wydajność, 423 dostawca usług, 308 dostęp do indeksu, 111 dostęp do zdalnych danych, 46 drill-down, 286 druga postać normalna, 27 drzewa, 232, 233 agregacja wartości, 261 implementacje, 240 model sąsiedztwa, 240 model zagnieżdżonych zbiorów, 242 model zmaterializowanej ścieżki, 241 przeszukiwanie, 245 przeszukiwanie wstępujące, 254 przeszukiwanie zstępujące, 246 reprezentacja w bazach danych, 237 duplikacja danych, 28 duża ilość wartości historycznych na każdy element, 222 duże ilości danych, 337 czyszczenie danych, 358 hurtownie danych, 361 kryteria partycjonowania, 357 liniowa podatność na skutki zwiększenia danych, 342 max(), 346 nieliniowa podatność na skutki zwiększenia danych, 343 oszacowanie zachowania zapytania, 346 partycjonowanie, 357 rozwijanie podzapytań, 349 sortowanie, 346, 348 tabele tymczasowe, 360
SKOROWIDZ
wydajność działań, 340 wyszukiwanie, 341 złączenia, 347 zwiększanie się rozmiarów danych, 338 duży zbiór wynikowy, 203 dynamiczne aplikacje wyszukujące, 286 dynamicznie definiowane kryteria wyszukiwania, 286 dyrektywy optymalizatora, 414 dysk lustrzany, 88 dystrybucja danych, 171 dziel i rządź, 36, 164 dziennik zaległości w bazie danych, 54
E elektroniczne przetwarzanie danych, 7 eliminacja ciągów spacji, 66 ELSE, 71 emulowanie transformacji gwieździstej, 373 ewolucja rozmiarów danych, 339 ewolucja wymagań, 28 EXCEPT, 213, 215, 228 executellpdate(), 69 existence test, 137 EXISTS, 140, 226, 353 explain, 199 exploding of links, 264
F fact dimension, 383 FALSE, 30, 214 FAST FULL SCAN, 442 filozofia uniwersalnego zapytania, 305 filtrowanie, 131, 180 fizyczne uporządkowanie danych na nośniku, 177 FORCE INDEX, 415 FROM, 140, 193 full scan, 81 full text indexing, 22 functional index, 101 function-based index, 101 funkcje agregujące, 210 analityczne, 84
457
OLAP, 84, 208, 221 rankingu, 84 użytkownika, 73 wbudowane, 65
G gorące punkty, 170 obsługa indeksów, 109 gotowe rozwiązania, 58 granularność procesu, 61 greatest(), 71 grid, 46 GROUP BY, 145, 215, 377, 393, 405 grupowanie danych, 164
H hash index, 109 HAVING, 131, 145, 215, 216, 377 heap, 160 hermetyzacja dostępu do bazy danych, 278 hierarchia, 232, 235 hotspot, 170 hurtownie danych, 361 indeksy, 368 ładowanie danych, 365, 367 łączenie danych z różnych źródeł, 366 narzędzia do wydobywania danych, 363 raporty typu ad hoc, 368 schemat gwieździsty, 361 tabele wymiarowe, 361 transformacja danych, 365 transformacja gwieździsta, 372 więzy integralności, 368 wydobywanie danych, 363, 365 wymiary, 361 zapytania na wymiarach i faktach, 368
I IATA, 74 identyfikacja najwydajniejszy plan wykonawczy, 436 punkt wejścia, 88 zapytania, 52
458
SKOROWIDZ
identyfikatory, 24 IF, 71 if...then...else, 71 implementacja drzewa, 240 fizyczna, 151 mechanizm indeksowania, 90 IN, 140, 193 indeks, 44, 88, 159 bitmapowy, 372 blokada, 104 blokowanie, 91 B-tree, 342 dostęp do indeksu, 111 dysk lustrzany, 88 funkcyjne, 96, 102 gorące punkty, 109 haszujący, 109 hurtownie danych, 368 klastrowy, 162 klucze generowane automatycznie, 107 klucze obce, 102 kolumny wyliczane, 101 konkurowanie o zasoby, 91 konwersje, 96 listy zawartości, 92 modyfikacja danych w kolumnach, 101 narzut obsługi, 90 niejawna konwersja typów kolumn, 99 niejednolitość dostępu, 110 odwrócony, 108, 331 pomocniczy, 161 przeszukiwanie zakresowe, 110 rywalizacja o zasoby, 109 selektywność, 94 statystyki współczynników odczytu, 94 tekstowy, 22 użyteczność, 182 WHERE, 99 wielokrotne indeksowanie tej samej kolumny, 106 wydajność, 89 wydajność zapisów, 90 wydajność zapytania, 183 zajętość dysku, 89 zakresowe przeszukiwanie, 99 zalety stosowania, 309
indeksowanie, 87 haszujące, 109 implementacja mechanizmu, 90 klucze obce, 106 kryteria, 186 odwrócone, 108 index extension, 101 index range scan, 99 index-organized table, 160 informacje o tabelach, 404 instr(), 403 inter-process, 281 inter-process communications, 281 INTERSECT, 228, 412 IOT, 160 IPC, 281, 282 iteracje, 71
J jądro systemu DBMS, 64 JDBC, 298 jedno szczególnie powolne zapytanie, 420 jednoczesne wielokrotne modyfikacje, 72 język DDL, 59 SQL, 7, 114, 115
K klaster, 46 klastrowanie zakresowe, 169 klient-serwer, 55 klucz główny, 24 między wieloma tabelami, 36 klucze generowane automatycznie, 107 obce, 102 partycji, 168 kluczowe dane, 88 kod DML, 61 IATA, 74 o bardzo niskiej jakości, 77 SQL, 8, 9 kodowanie, 260 kolejka obsługi zgłoszeń, 174 kolejki, 313
SKOROWIDZ
kolejność kluczy indeksu, 185 kolejność wierszy odczytanych z tabeli, 120 kolumny autoincrement, 320 kolumny o niewielkiej liczności, 157 kolumny o wartościach boolowskich, 34 kolumny, które powinny być wierszami, 388 komentarze, 53 kompletność modelu relacyjnego, 30 komplikacja systemu, 48 konkurowanie o zasoby, 91, 308, 315, 325, 326 listy wolnych bloków, 329 odwrócony indeks, 331 partycjonowanie, 330 przestrzeń transakcji, 329 rozwiązania na poziomie administracji, 329 rozwiązania na poziomie architektury, 330 rozwiązania na poziomie oprogramowania, 331 tabela zorganizowana w formie indeksu, 331 udoskonalanie współbieżności, 331 wartości generowane przez system, 331 wstawianie danych, 327 zmiana wydajności operacji wstawiania, 332 kontrola poprawności, 78 korzyści z poświęconych nakładów, 425 koszt obsługa wyjątków, 83 stosowanie zdalnego powiązania z bazą, 282 wstawianie wierszy do tabeli zorganizowanej w postaci indeksu, 161 związany z aktywnościami, 424 kryteria, 181, 274 definiujące zbiór wynikowy, 125 filtrujące, 133 indeksowanie, 186 partycjonowanie, 357 wyszukiwanie daty, 217
459
L lasy, 234 latch, 108 LDAP, 232 least(), 71 liczba równoległych użytkowników, 130 liczba tabel, 127 liczba zmodyfikowanych wierszy, 69 Lightweight Directory Access Protocol, 232 LIKE, 276 liniowa podatność na skutki zwiększenia danych, 342 lista materiałowa, 232 listy sąsiedztwa, 262 listy wolnych bloków, 329 listy zawartości, 92 lock, 108 locking, 91, 104 log transakcji, 61 logika biznesowa, 70 dwuwartościowa, 30 proceduralna, 62 trójwartościowa, 30 warunkowa, 71 lokalizacja danych w archiwum, 235 LOOP JOIN, 415 loop-back, 281, 282
Ł ładowanie danych, 367 schemat gwieździsty, 378 łączenie danych z różnych źródeł, 47
M magiczna data, 223 maksymalne wykorzystanie dostępu do bazy danych, 63 MATERIALIZED_PATH_MODEL, 241 max(), 346, 386, 387 MERGE, 70 merge table, 166 meta-design, 383, 384 miękka analiza leksykalna, 342
460
SKOROWIDZ
migawka, 58 minimalizacja czas utrzymania blokady, 321 duplikacja danych, 28 mini-wymiary, 368 MINUS, 213 mirror, 88, 155 model ewolucyjny, 153 gwieździsty, 369 hierarchiczny, 232 relacyjny, 16, 114 samochód, 25 sąsiedztwa, 237, 240 stały, nieelastyczny, 153 wielowymiarowy, 361 wymiarowy, 361 zagnieżdżone zbiory, 238, 242 zmaterializowana ścieżka, 238, 241 modelowanie danych, 49 modelowanie wymiarowe, 27, 372, 378 modularność, 74 modyfikacja dane, 173 indeksy, 444 monitorowanie wydajności, 417 definiowanie wydajności docelowej, 431 dobra wydajność, 423 plan wykonawczy, 434 składowe obciążenia serwera, 421 wolne działanie bazy danych, 418 zadania biznesowe, 431 mp_depth(), 251 mysql_affected_rows(), 69
N nadmiar elastyczności, 39 nadmiarowość danych, 25 nadmiernie rozbudowane tabele, 35 nagłe lokalne spowolnienie, 420 nagłe ogólne spowolnienie działania, 419 najlepsze dopasowanie, 413 nakłady zadania, 424 narzędzia business intelligence, 364 wydobywanie danych, 363
nasycenie zasobów sprzętowych, 325 nawiązanie połączenia z bazą danych, 55 nested loop, 370 NESTED_SETS_MODEL, 243 niejawna konwersja typów kolumn wykorzystywanych w warunkach, 99 niejednolitość dostępu do indeksów, 110 niekonwencjonalny projekt bazy danych, 383 nieliniowa podatność na skutki zwiększenia danych, 343 nieskorelowane podzapytanie, 193 niewielka liczba tabel źródłowych, 181 niewielki zbiór wynikowy, część wspólna ogólnych kryteriów, 194 niewielki zbiór wynikowy, pośrednie kryteria, 192 niewielki zbiór wynikowy, pośrednie, uogólnione kryteria, 196 niewielki zbiór wynikowy, precyzyjne kryteria, 181 niewielkie użycie indeksów, 196 niezależność atrybutów, 27 niskie użycie indeksów, 196 normalizacja, 19, 20 w locie, 395 NOT EXISTS(), 224, 353 NOT IN(), 224, 229, 230 NULL, 29, 31, 33, 279 numery kart kredytowych, 275
O obciążenie procesor, 424 serwer, 421 obsługa dane strategiczne, 231 duże ilości danych, 337 kontrola poprawności, 78 wyjątki, 81, 83 zmienna liczba atrybutów, 384 ochrona danych poprawność danych, 22 spójność danych, 29 oczyszczanie ciągów znaków, 67 odczyt dodatkowej kolumny nieujętej w indeksie, 159
SKOROWIDZ
odczyty z wyprzedzeniem, 357 odwrócony indeks, 108, 331 odwzorowanie logiki biznesowej, 70 ofensywne kodowanie w SQL-u, 78 ogół-szczegół, 233 ograniczanie zbioru wynikowego, 119, 131 ograniczanie zjawiska konkurowania o zasoby, 334 ograniczenia, 38 OLAP, 84, 148, 208 OLTP, 45 online analytical processing, 84 online transaction processing, 45 opakowanie SQL-a w PHP, 297 operacje nierelacyjne, 119 rzeczywiste dane, 60 sam indeks, 159 zbiorowe, 229 operacyjny magazyn danych, 361 operatory relacyjne, 116 zbiorowe, 228 optymalizacja, 43, 44 fizyczny układ dany bazy, 154 zapytania, 448 optymalizator, 118 całkowity czas wykonania, 123 dyrektywy, 414 kosztowy, 62 najwydajniejsze sytuacje, 123 ograniczenia, 123 plan wykonawczy, 199 statystyki wartości, 223 wykonanie pojedynczych zapytań, 124 optymalny czas reakcji, 155 optymistyczna kontrola współdzielenia, 80 OR, 213, 214 ORDER BY, 77, 122 oszczędny SQL, 76 outer join, 34, 229 OUTER JOIN, 385 outriggers, 368 overflow, 176 overflow pages, 164
461
P page, 155 partition, 165 PARTITION, 208 partition pruning, 168 partycja, 165 partycjonowanie, 156, 164, 170, 330, 357 cykliczne, 165 w oparciu o dane, 165 w oparciu o hash, 168 w oparciu o listy, 169 zakresowe, 169 paskowanie, 165 pełne przeszukiwanie tabeli, 81 perspektywy, 125, 128, 448 spartycjonowane, 166 wbudowane, 147 zmaterializowane, 175 pętle, 71 PHP, 297 SQL, 297 wiązanie zmiennej liczby parametrów, 298 wiązanie zmiennych, 298 piaskownica, 64 piąta postać normalna, 20 pierwsza postać normalna, 25 PIVOT, 393, 394 pivot tables, 389 plan wykonawczy, 189, 199, 304, 434, 435 analiza kryteriów wyszukiwania, 441 identyfikacja najwydajniejszego planu, 436 perspektywy, 448 ukryta komplikacja, 448 właściwe wykorzystanie, 446 wymuszanie planu, 439 wyzwalacze, 448 plastry, 155 podtypy, 35, 36 podwójne przekształcenie, 410, 412 podzapytania, 125, 193, 219 nieskorelowane, 139, 193 skorelowane, 138, 205 pojedyncze kolumny, które powinny być czymś innym, 394
462
SKOROWIDZ
połączenie do bazy danych, 53 poprawianie wydajności, 431 porównanie przedrostka, 277 postaci normalne, 19 1NF, 25 2NF, 27 3NF, 19, 27 5NF, 20 BCNF, 19 Boyce’a-Codda, 19 pośrednie kryterium, 192, 196 powiązania, 264 powiązanie serwerów, 281 pracowite zapytania SQL, 62 prawa de Morgana, 214 pre joined, 175 prepare(), 302 problemy, 58 hierarchiczne, 235 SQL-owe, 115 produkcyjna baza danych, 361 program wsadowy, 45, 322, 323 programowanie defensywne, 78 logika w zapytaniach, 71 ofensywne, 78, 79 projektowanie pod kątem wydajności, 15, 43 zapytania SQL, 113 proporcje odczytywanych danych, 149 pryncypia, 18 przeciążony system, 421 przekształcenie wielu wierszy w jeden, 386 przełączenie kontekstu, 56 przepełnienie, 176 przepływ przetwarzania, 45 przepustowość, 45 przesłanianie przypadku ogólnego, 407 przestrzeń tabel, 165 przestrzeń transakcji, 329 przeszukiwanie drzewa, 232 model sąsiedztwa, 246, 255 model zagnieżdżonych zbiorów, 252, 258 model zmaterializowanej ścieżki, 251, 256 SQL, 245
wstępujące, 254 zapytanie o górali, 254 zapytanie Vandamme’a, 246 zstępujące, 246 przeszukiwanie zakresowe, 110, 183 przetwarzanie dane, 45 synchroniczne, 46 współbieżne, 205 zbiory, 61 przewracanie danych, 383, 389 przycinanie tabeli, 59 przywracanie danych, 48 pseudoelastyczność, 40 puchnięcie danych, 339 pule połączeń, 56 punkty wejścia, 88
R range scanning, 110 range-clustering, 169 ranking dat, 221 ranking functions, 84 raporty typu ad hoc, 368 read-ahead, 357 reguła 10% wierszy, 149 relacje, 17 relacyjna baza danych, 18, 114 relacyjny model danych, 16 replace(), 252 replikacja informacji, 32 repozytoria danych, 47, 156 reprezentacja drzew w bazach danych, 237 retrieval ratio, 94 ROLLBACK, 59, 61 round-trips, 56 row_number(), 208, 221 rozmiar danych, 338 rozmiar loga transakcji, 61 rozmiar zapytań, 295 rozmiar zbioru wynikowego, 125 rozmnażanie wierszy, 390 rozpoznawanie trudnych sytuacji, 273 rozproszenie danych, 183 rozproszone przetwarzanie zapytań, 281 rozproszone zapytania, 281
SKOROWIDZ
rozwiązania na poziomie administracji, 329 rozwiązanie problemu, 58 rozwijanie podzapytań, 349 rozwijanie powiązań, 264 rozwinięcie ścieżki, 399 równoległe modyfikacje danych, 315 rywalizacja o klucz główny, 104
S sandbox, 64 schemat bazy danych, 59 schemat gwiazdy, 197, 361 SELECT, 21, 119 SELECT DISTINCT, 26 selektywność indeksu, 94 self-incrementing, 107 semafor, 108 semantyka danych, 39 semaphore, 108 serializacja, 326 serwer, 281 OLTP, 45 połączony, 281 związany, 281 siatka, 46 silnik bazy danych, 308 SKIP SCAN, 443 skip-scan, 136 składowe obciążenia serwera, 421 skomplikowane perspektywy, 128 skomplikowane zapytania, 128 skorelowane podzapytania, 140 skuteczność SQL-a, 124 slice, 155 snapshot, 58 sniffer, 427 SOL%ROWCOUNT, 69 sortowanie, 119, 222 sp_trace_setevent, 427 specjalne indeksowanie, 92 spis treści, 92 sposoby partycjonowania, 172 sposoby przetwarzania danych, 45 spójność dane, 29 model relacyjny, 18
463
sprawdzanie niezależność atrybutów, 27 zależność od klucza głównego, 25 SQL, 7, 114, 115 skuteczność, 124 SQL injection, 294 sql_small_result, 415 sqlbigresult, 415 SQLCA, 69 SQLite, 115 SQLJ, 298 squeeze1(), 68 squeeze2(), 68 squeeze3(), 68 stabilny schemat bazy danych, 59 star transformation, 371 statyczne kodowanie zapytań, 422 statystyki współczynników odczytu, 94 stopniowe spowolnienie wydajności osiągające próg krytyczny, 420 stosowanie logiki proceduralnej w SQL-u, 62 strategia, 52, 56 strcmp(), 71 stripping, 165 strona, 155 strona przepełnienia, 164 Structured Query Language, 115 struktura bazy danych, 152, 156 struktura zapytania wybierającego, 121 struktury drzewiaste, 232 agregacja wartości z drzewa, 261 cykle, 234 głębokość, 233 implementacje drzew, 240 lasy, 234 liczba poziomów, 236 lokalizacja danych w archiwum, 235 model sąsiedztwa, 237, 240 model zagnieżdżonych zbiorów, 238, 242 model zmaterializowanej ścieżki, 238, 241 najlepsza reprezentacja, 239 pojedyncza tabela, 233 problem potomka, 234 przeszukiwanie drzewa, 245 przeszukiwanie wstępujące, 254
464
SKOROWIDZ
struktury drzewiaste przeszukiwanie zstępujące, 246 reprezentacja drzew w bazach danych, 237 wielu rodziców, 234 własność, 233 współczynnik ryzyka, 235 wykorzystanie składników, 235 zapętlenia, 234 zapytanie o górali, 245, 254 zapytanie Vandamme’a, 245, 246 związki typu ogół-szczegół, 233 struktury lustrzane, 155 struktury stertowe, 160 stwierdzanie oczywistości, 37 substring(), 396 substring_index(), 400 sum(), 216 surowa wydajność, 45 system blokad, 316 system rozproszony, 274, 281 system zarządzania bazami danych, 116
Ś śledzenie błędnie działającego kodu, 53
T tabele, 152 dane hierarchiczne, 260 dystrybucja danych, 171 gorące punkty, 170 historyczne, 218 indeks, 160 indeks klastrowy, 163 IOT, 160 łączące, 368 meta-design, 384 partycjonowanie, 164, 170 perspektywy, 166 podsumowania, 175 przestrzeń, 165 transakcyjne, 92 tymczasowe, 60, 360 wstępne złączenia, 175 wymiarowe, 361 wymuszanie kolejności wierszy, 162 zimny obszar, 170
złączenie tabeli ze sobą, 206 złączone, 166 tabele przestawne, 389, 404 iloczyn kartezjański, 391 odwrócenie, 393 PIVOT, 393 przywrócenie, 393 rozmnażanie wierszy, 390 tworzenie, 389 UNPIVOT, 393 wykorzystanie wartości, 391 tablespace, 165 tablice robocze, 60 taktyka, 56 TCP, 282 technologia informatyczna, 7 teoria relacyjna, 114 test nawiązywania i kończenia połączeń, 54 test występowania, 137, 139, 229 transaction entry slot, 329 transaction log, 61 transakcje, 61, 282, 319 transformacja danych, 365 transformacja gwieździsta, 371, 372, 374 emulowanie, 373 trudne sytuacje, 273 TRUE, 30, 213 TRUNCATE, 59, 359 trwałe połączenia do bazy danych, 53 trwałość, 274 tryb wykonawczy, 45 trzecia postać normalna, 19, 27 twierdzenia, 18 tworzenie tabele przestawne, 389 warstwy abstrakcji, 277 zapytania w modelu wymiarowym, 370 typy self-incrementing, 107 typy struktur, 152
U uchwyty połączenie z bazą danych, 297 transakcja, 329
SKOROWIDZ
udoskonalanie współbieżność, 331 zapytania, 448 ukrywanie klucz sortowania, 407 kod SQL w funkcjach użytkownika, 74 umieszczanie w zapytaniu wszystkich elementów na zapas, 304 uncorrelated subquery, 139 unia kluczy głównych tabel podtypów, 36 unikanie logiki proceduralnej, 63 UNION, 57, 144, 228, 392 UNIQUE, 137 uniwersalne zapytanie, 305 UNPIVOT, 393, 394 uogólnione kryteria, 196 uporządkowanie danych dysk, 177 tabela, 152 uruchamianie zapytań w pętli, 310 urząd pocztowy, 314 usuwanie duplikatów, 140 użycie wyjątków, 80
W warstwy abstrakcji, 277 słaba jakość, 280 warstwy logiczne zapytania SQL, 117 wartości bieżące, 222 boolowskie, 34 warunki filtrujące, 131 weryfikacja poprawności danych numer karty kredytowej, 276 wiersz po wierszu, 283 WHERE, 21, 71, 125, 131, 180, 192, 319 wiązanie stron, 176 wiele elementów, niewiele danych historycznych, 218 wielki zbiór wynikowy, 203 wielodostęp, 130, 308, 316 wielokrotne indeksowanie tej samej kolumny, 106 wielokrotne modyfikacje, 72
465
wiersze, które powinny być kolumnami, 383 więzy integralności, 368 wiodące bajty, 157 właściwe wykorzystanie planów wykonawczych, 446 wolne działanie bazy danych, 418 wskaźniki obciążenia bazy, 425 współbieżność, 154, 205, 307 blokada, 316 blokowanie na poziomie wierszy, 324 indeksy, 309 kolejki, 313 konkurowanie o zasoby, 308, 326 równoległe modyfikacje danych, 315 silnik bazy danych, 308 wstawianie danych, 328 współczynnik ryzyka, 235 współdzielenie zasobów, 155 wstawianie danych, 327 wstawianie łączące, 72 wstępne złączenia tabel, 175 wybór kodowania, 260 wybór wierszy dopasowanych do kilku kryteriów z listy, 409 wycofanie transakcji, 61 wydajne wykorzystanie bazy danych, 51 wydajność, 26, 43, 52 częstotliwość operacji COMMIT, 322 docelowa, 431 indeks, 89, 91 indeks klastrowy, 163 kolejność kluczy indeksu, 185 nakłady zadania, 424 plan wykonawczy, 436 projektowanie, 43 wyzwalacze, 91 zapytania SQL, 183 zapytanie o górali, 259 zapytanie Vandamme’a, 253 zwiększanie rozmiarów danych, 340 wydobywanie danych, 365 duże porcje danych, 144 wyjątki, 80 koszt obsługi, 83 wykonywanie zapytań bezużyteczne zapytania, 422 schemat gwieździsty, 376
466
SKOROWIDZ
wykorzystanie funkcji użytkownika, 73 wykorzystanie składników, 235 wyliczanie warunków filtrowania, 133 wymagania biznesowe, 16, 17 raportowe, 116 wymuszanie kod SQL, 294 kolejność odczytu tabel, 202 kolejność wierszy, 162 plan wykonawczy, 439 ścieżka wykonawcza, 202 wysoka wydajność bazy danych, 36 wyszukiwanie, 21 zakres dat, 217 zakresowe, 109 wyzwalacze, 91, 448 wzorce projektowe modelowania wymiarowego, 363 wzorce SQL, 179
Z zadania biznesowe, 431 zagrożenia nadmiaru elastyczności, 39 zakleszczenie, 105 zakresowe przeszukiwanie indeksu, 99 zależności od klucza głównego, 25 zapewnienie atomowości, 21 zapisywanie danych w indeksie, 160 zapytania drążące, 286 osadzone, 192 równoległe wykonywanie, 283 transakcyjne online, 181 zagnieżdżone, 190 zapytania rozproszone, 283 filtrowanie, 284 koszt, 283 optymalizator, 285 złączenie zdalnych tabel, 285 zapytania SQL, 52, 62 buforowanie, 404 całkowita ilość danych, 125 DISTINCT, 137, 139 EXISTS, 140 filtrowanie, 125, 131 FROM, 140
funkcje agregujące, 210 funkcje użytkownika, 73 GROUP BY, 145 HAVING, 145 IN, 140 kolejność kluczy indeksu, 185 kolejność wierszy odczytanych z tabeli, 120 kryteria definiujące zbiór wynikowy, 125 kryteria filtrujące, 133 liczba równoległych użytkowników, 130 liczba tabel, 127 logika proceduralna, 62 model wymiarowy, 370 ograniczanie liczby zwracanych wierszy, 119 ograniczanie zbioru wynikowego, 131 ograniczenia optymalizatora, 123 optymalizator, 118 partycjonowanie tabel, 172 podzapytania nieskorelowane, 139 podzapytania skorelowane, 138 programowanie logiki, 71 projektowanie, 113 proporcje odczytywanych danych, 149 reguła 10% wierszy, 149 rozmiar zbioru wynikowego, 125 skomplikowane zapytania, 128 skuteczność, 124 struktura warstwowa, 121 test występowania, 137 UNION, 144 UNIQUE, 137 usuwanie duplikatów, 140 warstwy logiczne, 117 warunki filtrujące, 125, 131 WHERE, 125, 131 wybierające, 121 wydajność, 183 wydobywanie dużych porcji danych, 144 wyliczanie warunków filtrowania, 133 wymagania raportowe, 116 złączenia, 127 zmienna lista parametrów, 401
SKOROWIDZ
zapytanie o górali, 245, 254 model sąsiedztwa, 255 model zagnieżdżonych zbiorów, 258 model zmaterializowanej ścieżki, 256 wydajność, 259 zapytanie Vandamme’a, 245, 246 model sąsiedztwa, 246 model zagnieżdżonych zbiorów, 252 model zmaterializowanej ścieżki, 251 wydajność, 253 zatrzask, 108 zatwierdzanie transakcji, 61 zbiory, 61 zagnieżdżone, 244 zbiór wynikowy uzyskany w oparciu o brak zdefiniowanych danych, 224 uzyskany w oparciu o funkcje agregujące, 210 zdalne powiązanie z bazą, 282 zestaw prymitywów programowych, 277 zimny obszar, 170 złączenie, 75, 127 tabela sama ze sobą, 206 w zagnieżdżonej pętli, 370 zewnętrzne, 34, 189, 229, 296
467
złączone tabele, 166 zmaterializowana ścieżka, 260 zmaterializowane perspektywy, 175 zmiany w replikach, 32 zmienna liczba atrybutów, 384 zmienna liczba kryteriów wyszukiwania, 293 zmienna lista parametrów, 401 zmienne związane, 279, 304, 401 znajdowanie najlepszego dopasowania, 413 związki między podtypami, 36 ogół-szczegół, 233 zwielokrotnienie cykli przełączenia kontekstu, 422 zwiększanie się rozmiaru danych, 338, 340 brak podatności na skutki zwiększenia, 341 liniowa podatność na skutki zwiększenia, 342 nieliniowa podatność na skutki zwiększenia, 343 wydajność działań, 340 wyszukiwanie, 341 zwodnicze kryteria, 274 zwrot z inwestycji, 428, 429