140 36 21MB
Polish Pages [464] Year 2007
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 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 = 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