Įvadas
Galima teigti, kad duomenų bazės yra visur. Praktiškai kiekvienas informacinės sistemos sprendimas turi turėti kažkokią tai duomenų talpyklą, kuri saugo su sistema susijusius duomenis: socialinis tinklo puslapis kažkur turi saugoti vartotojų duomenis, meteorologinės informacijos sistema turi kažkur saugoti duomenis apie temperatūras/spaudimą/kritulius, kokybės valdymo sistema turi saugoti informaciją apie detalių auditus ir t.t. Šiuo metu duomenų saugojimo rolę atlieka duomenų bazės ir populiariausios iš jų yra SQL (Structured Query Language) interpretuojančios reliacinės duomenų bazių valdymo sistemos (angl. Relational DataBase Management System - RDBMS). Tačiau, tas faktas, kad duombazės yra visur nereiškia, kad žmonės dirbantys su duombazėmis yra pilnai įvaldę šią technologiją. Daugelis duombazių/sistemų kenčia nuo blogo schemos dizaino, blogai suformuotų užklausų, blogo kodo, kuris sąveikauja su duombaze ir panašių trūkumų, kurie tiesiogiai įtakoją sistemos prižiūrimumą (angl. maintainability), suprantamumą, tvirtumą ir galiausiai NAŠUMĄ. Našumo stoka arba jo nepastovumas, yra tiesiogiai jaučiamas sistemos vartojo ir dėl to nenašiai veikiančios sistemos yra paliekamos/pamirštamos vartotojų (be abejo, jeigu egzistuoja alternatyvos). Šiame straipsnyje bus aptarti keli populiariausi būdai, kai paspartinti duomenų bazės greitaveiką bei bus pateikta patarimų į ką atkreipti dėmesį kuriant duombazės schemą, kad našumo problemos neiškiltų vėliau. Straipsnis liečia tiktai SQL tipo reliacines duombazes ir kai kurie patobulinimai veikia tik ant specifinio platintojo sprendimų.
Trūkumų nustatymas
Dažniausiai mes žinome, koks sistemos panaudos atvejas veikia lėtai ir pagal tai yra įmanoma atsekti lėtai veikiančią užklausą (arba lėtai veikiančia veiksmų seka, kurioje dalyvauja užklausa). Jeigu yra norima atlikti bendra duombazės optimizavimą greitaveikai, reikia turėti duombazės veikimo statistiką, pagal kurią įmanoma matyti, kurios užklausos trunka ilgiausiai (arba kurių yra daugiausia). Tai galima atlikti su srauto imitavimo ir profiliavimo įrankiais [8]. Srautą galima imituoti tiesiogiai:
- Super-Smack (MySQL) [1]
- SQLStress (MSSQL) [2]
- SQL Load Generator (MSSQL) [3]
netiesiogiai (generuojant užklausas į tinklapį, kuris sąveikauja su duombaze):
- ab (Apache) [4]
Bus atveju, kai dėl prasto greitaveikos yra atsakinga duombazę valdanti geležis: lėtas kietasis diskas, mažas darbinės atminties kiekis, prastas procesorius ir pan. Tokius atvejus galima patikslinti su bendriniais sistemos testavimo įrankiais:
- SysBench [5]
Egzistuoja daug daugiau srauto generavimo bei benchmark'inimo įrankių kitoms duombazių sistemoms ir skaitytojui yra patariama susirasti jam/jai patogiausią. Tarkime, kad mūsų duombazė yra apkrauta ir dabar mums belieka nustatyti, kur slypi problemos. Tai galima atlikti su profiliavimo įrankiais:
- SQL Server Profiler (MSSQL) [6]
- SHOW PROFILES komanda (MySQL) [7]
Šie ir panašūs įrankiai pateikia detalų išrašą apie užklausų vykdymo laikus, iš kurio galima lengvai atpažinti kurios užklausos lėtina sistemos darbą. Jeigu atrandame, kad kažkokia tai užklausa veikia lėtai, tačiau nevisai esame įsitikinę kas su ja blogai, galima patyrinėti jos vykdymo planą:
- EXPLAIN komanda (MySQL, postgreSQL)
- Execution Plan (MSSQL Studio)
- EXPLAIN PLAN komanda (Oracle)
Šios komandos pateikia detalų planą kaip yra vykdoma specifinė užklausą. Iš šių planų galima matyti kokia specifinė operacija(os) užklausoje užima daugiausiai laiko, ar atliekamas skanavimas ar sekimas (indeksavimas), kokie indeksai yra naudojami ir panaši vertinga informacija. Likusios straipsnio dalis darys prielaidą, kad sekcijoje sprendžiama problema jau yra atpažinta.
Indeksavimas
Duomenų bazė susideda iš lentelių, kur kiekviena iš jų gali turėti daug įrašų. Dažniausiai duomenų bazė neužtikrina, kad įrašai bus fiziškai talpinami ta seka kuria jie yra atvaizduojami, dėl to specifinių įrašų paieška, be papildomų priemonių/informacijos, yra O(N) sudėtingumo, kas stipriai atsiliepia greitaveikai, jeigu lentelėje yra labai didelis įrašų kiekis. Įrašų paieška yra spartinama indeksais, kurių veikimą galima apibūdinti telefonų knygos analogija: žmonės (įrašai) yra išsidėstę be tvarkos, tačiau telefonų knygoje (indekso struktūroje), jie yra išrikiuoti pagal pavardę (pirmą lauką), vardą (antrą lauką) ir turi telefono numerį ir adresą (fizinis lokatorius faile). Indeksai yra dviejų tipų: klasteriuoti (angl. clustered) ir neklasteriuoti (non-clustered) ir implementacijos lygyje dažniausiai būna arba dvinariai medžiai O(log n) arba hash lentelės O(1). Nuosekli paieška be indekso yra vadinama skanavimu (Scanning), su indeksu - sekimu (Seeking).
Klasteriuotas Indeksas
Klasteriuotas indeksas truputi sugriauna aukščiau paminėta analogiją, nes jis fiziškai perorganizuoja duomenis lentelėje taip, kad jų seka atitiktų indekso loginę seką (dėl šios priežasties lentelei gali būti priskirtas tiktai vienas klasteriuotas indeksas). Taip suindeksuotoje lentelėje galima labai sparčiai vykdyti užklausas kurios duomenis prieina iš eilės, arba gražina įrašų intervalą, nes disko skaitymas tokioje situacijoje irgi yra nuoseklus (nereikia šokinėti per atskirus blokus). Tarkime, kad turime lentą, kurioje saugome duomenis apie asmenis:
CREATE TABLE Person
(
Id INT NOT NULL
, FirstName VARCHAR NOT NULL
, LastName VARCHAR NOT NULL
, OtherInfo VARCHAR NULL
)
Ir žinome, kad programa/tinklapis, visuomet pateiks šiuos duomenis surikiuotus pagal pavardę ir atskiruose puslapiuose. Tokiu atveju klasteriuota indeksą yra geriau padaryti ne ant Id stulpelio, bet ant pavardės:
CREATE CLUSTERED INDEX IX_Person_LastName
ON Person (LastName)
Pagal nutylėjimą MSSQL duombazėje klasteriuotas indeksas yra priskiriamas pradiniam raktui (primary key - PK). Oracle duombazėje klasteriuotas indeksas yra vadinamas kitaip: Index-Organized Table (IOT). Neklasteriuotas indeksas veikia taip pat, tiktai neliečia fizinio įrašų išdėstymo, dėl to neklasteriuotų indeksų lentelė gali turėti daug [9, 10].
Persidengiantis indeksas
Tarkime, kad sistemoje/tinklapyje leidžiame vartotojui ieškoti asmenis pagal jų vardą ir pavardę:
SELECT Id
, FirstName
, LastName
, OtherInfo
FROM Person
WHERE FirstName = 'Vardenis'
AND LastName = 'Pavardenis'
Be indekso būtų vykdomas skanavimas, kas yra negerai. Jeigu indeksas būtų tiktai ant vieno iš laukų (tarkime - LastName), būtų geriau, tačiau duombazės varikliukas vistiek turėtų atlikti skanavimą tarp visų 'Pavardenių'. Pilnai padengti užklausą reiktų sukurti persidengiantį indeksą:
CREATE NONCLUSTERED INDEX IX_Person_LastName_FirstName
ON Person ( LastName, FirstName )
Duombazės varikliukas, naudodamasis tokiu indeksu, iš pradžių, atseka 'Pavardenis' [indekso] įrašą ir ten jis randa ne nuorodas kur slypi visi Pavardeniai, o dar vieną Pavardenių indeksą, pagal kūrį galima labai greitai atsekti 'Vardenį'.
Persidengiančiuose indeksuose yra svarbus laukų eiliškumas: IX_LastName_FirstName ≠ IX_FirstName_LastName. Taipogi jie turi tokią savybę: IX_LastName_FirstName gali būti panaudotas vietoj IX_LastName, nes tiek vieno tiek kito indekso įrašai turi informaciją apie vieną specifinę pavardę. Greitaveika su persidengiančiais indeksai galia prarasti jeigu, lentelėje yra mažai vardų po viena pavarde: apeiti indekso struktūra kurioje slypi mažai įrašų trunka daugiau laiko negu juos paprasčiausiai nuskenuoti. Netgi kai kurie užklausų optimizatoriai (programinis SQL serverio komponentas), pastebėję mažą įrašų kiekį lentelėje, suformuoja užklausos vykdymo planą, kuriame nenurodo indekso, tam kad duombazės varikliukas ją greičiau įvykdytų.
Dalinis indeksas
Tarkime, kad turime labai dažną panaudos atvejį(ų), kuriame(uose) yra dirbama tiktai su aktyviais vartotojais(UserName) (WHERE Avtive = TRUE). Tokiu atveju kurti indeksą:
CREATE NONCLUSTERED INDEX IX_User_Username
ON User ( Username )
ant visų vartotojų nėra pats efektyviausias sprendimas. Yra įmanoma sukonstruoti indeksą tiktai įrašams kurie tenkina tam tikrą sąlygą:
CREATE NONCLUSTERED INDEX IX_User_ActiveUsername
ON USER ( Username )
WHERE (Active=(TRUE))
Šis indeksas yra mažesnis ir jis nėra modifikuojamas kai yra pakeičiamas arba įterpiamas neaktyvus vartotojas. [17]
Niuansai
Indeksavimas, kaip ir daugelis optimizavimo būdų iš visų sričių, turi savo trūkumų. Kai į lentelę yra įterpiamas naujas (INSERT) arba modifikuojamas (UPDATE) įrašas, visi indeksai irgi turi būti atnaujinamas. Jeigu indeksų yra daug, įterpimo operacijos vykdymo laikas žymiai išauga. Dėl to prieš kuriant indeksus lentelėms, kurioms yra vykdoma daug rašymo operacijų, reikia nuspręsti (arba eksperimentiniu būtu patikrinti) ar kažkokios 'SELECT' užklausos paspartinimas, nepalėtins kitų programos dalių.
Kartais į lentelę reikia įkelti daug duomenų vienu metų: duomenų iš senesnės sistemos importavimas, iš produkcijos duombazės lentelės perkėlimas į darbinės lentelę ir pan. Prieš darant didelio mąsto duomenų įkėlimą yra patartina išjungti lentelės indeksus (nevisus!) ir juos įjungti, kai įkėlimas baigtas [11]. Tačiau, vėlgi, reikia būti atsargiems ir įsitikinti, kad sistemos vartotojai (jeigu tokių yra) nebus paveikti tuo metu kai vyksta įterpimas, arba dar blogiau: netyčia neišjungti reikšmės unikalumą užtikrinančio indekso, dėl kurio nebūvimo būtų galima sugadinti lentelės būsena. Indekso išjungimas:
ALTER INDEX IX_MyTableIndex ON [MyTable] DISABLE
ir įjungimas:
ALTER INDEX IX_MyTableIndex ON [MyTable] REBUILD PARTITION = ALL
Indeksus galima ir visai išmesti su DROP ir sukurti per nauja su CREATE komandomis, tačiau taip yra prarandama indeksu statistika, kuri yra atstatoma tiktai gyvai naudojantis sistema [12].
Užklausų optimizavimas
Koreliuotos užklausos
Koreliuota užklausa yra sunerta (nested) užklausą kuri, gali būti panaši į štai tokią:
SELECT prs.FirstName
, prs.LastName
, (SELECT phn.PhoneNumber
FROM PhoneNumbers phn
WHERE phn.PersonId = prs.Id
AND phn.primary = TRUE) AS PrimaryPhoneNumber
FROM Person prs
Ši užklausa gražina asmenų vardus, pavardes ir jų pagrindinį telefono numerį. Problema yra tame, kad vidinė užklausa, kuri kreipiasi į PhoneNumber lentelę yra vykdoma kiekvienam Person lentelės įrašui. Jeigu yra labai didelis kiekis asmenų, tarkime: 5 milijonai, vidinė užklausa irgi bus įvykdoma 5 milijonus kartų. Nesvarbu kad vidinė užklausa yra jau užkešuota ir/arba turi atitinkamus indeksus, papildomas laikas reikiamos jos inicijavimui ir rezultatų suformavimui susidės į didelius našumo nuostolius. Tokios užklausas dažniausiai rašo nepatyrę programuotojai, kurie vadovaujasi struktūrine mąstysena [8, 11]. Dirbant su duombaze, reikia į duomenis žiūrėti kaip į vieną esybę/duomenų setą, o ne į individualių įrašų rinkinį: operacijos turi būti taikomos vieną kartą duomenų setui, o ne daug kartų kiekvienam įrašui. Aukščiau paminėtą užklausą (ir didžiąją daugumą panašių koreliuotų užklausų) galima perrašyti pasinaudojant JOIN'u:
SELECT prs.FirstName
, prs.LastName
, phn.PhoneNumber
FROM Person prs
LEFT JOIN PhoneNumbers phn
ON phn.PersonId = prs.Id
AND phn.primary = TRUE
Select *
Dauguma programuotojų yra linkę netikslinti kokių laukų jiems reikia iš lentelės:
SELECT *
FROM Person prs
WHERE prs.LastName LIKE 'N%'
Tarkime, kad ši užklausa yra naudojama atvaizduoti asmenis sąraše, kur yra matomas vardas, pavardė ir gimimo data. Jeigu programos raidos pradžioje Person lentelėje buvo tiktai minėti 3 stulpeliai, tai 'Select *' būtų ganėtinai intuityvus sprendimas. Greitaveikos problemos prasidėtų, jeigu kas nors sumanytų pridėti papildomų stulpelių tarp kurių būtų: asmens nuotrauka (IMAGE), aprašas (TEXT) arba panašus vidutiniškai didelės apimties laukas. Tarkime kad vienas įrašas iš vardo, pavardės ir datos nesveria daugiau negu 200 baitų: parsisiuntimas susidedantis iš 200.000 tokių įrašų, jau būtų apie 40 megabaitų. Jeigu kas nors pridėtų profilio nuotrauka, kurios dydis vidutiniškai yra 24 kilobaitai, užklausos rezultato apimti jau būtų apie 4.8 gigabaitų. Reikiamų stulpelių tikslinimas:
SELECT FirstName
, LastName
, BirthDate
FROM Person prs
WHERE prs.LastName LIKE 'N%'
Be akivaizdaus užklausos paspartinimo, padaro programą tvirtesnę iš dizaino pusės: iš pačios užklausos matosi jos tikslas ir užklausos rezultatas yra stipraus tipo (strong-typed). Tai sumažina šansą, kad ateityje programoje bus padarytas potencialiai žalingas pakeitimas [9, 11, 13].
Union ir OR
Jeigu reikia išrinkti įrašus, kurie tenkina vieną arba kitą sąlygą dažniausiai matytumėme, kad buvo pritaikytas toks sprendimas:
SELECT FirstName
, LastName
, OtherInfo
FROM Person
WHERE LastName = 'Pavardenis'
OR LastName = 'Petraitis'
UNION komandos panaudojimas gali paspartinti užklausą [14, 15]:
SELECT FirstName
, LastName
, OtherInfo
FROM Person
WHERE LastName = 'Pavardenis'
UNION
SELECT FirstName
, LastName
, OtherInfo
FROM Person
WHERE LastName = 'Petraitis'
Taip yra dėl to, nes kartais, priklausomai nuo laukų kombinacijos ar reikšmių, užklausų optimizatorius gali nerasti tinkamo plano, kuris išnaudotų indeksus ir paprasčiausiai lieptų varikliukui atlikti skanavimą. Atskirdami WHERE sąlyga rankiniu būdų, į dvi (arba daugiau) paprastesnes dalis, kartais pagerėja šansai kad optimizatorius, bus užvestas ant tinkamo kelio ir išnaudos indeksą ant vieno (arba daugiau) sąlygos perskyrų. [16]
Normalizavimas
Duomenų struktūrų normalizavimas yra 30 metų senumo sąvoka, kuri padeda racionaliai identifikuoti ir išskaidyti atskirus duomenų vienetus. SQL ir kitos reliacinės duombazės yra paremtos šia sąvoka ir dėl to jos geriausiai dirba su normalizuotais duomenimis [18]. Nenormalizuotos struktūros turi paprastesnę schemą ir dėl to gali atrodyti intuityvesnės ir geresnio dizaino negu normalizuotos [17]. Tarkime, kad samdinių lentelėje saugome samdinio kontaktinę informaciją:
CREATE TABLE Employee
(
Id INT IDENTITY(1, 1)
, FirstName VARCHAR
, LastName VARCHAR
, WorkPhone VARCHAR
, HomePhone VARCHAR
, CellPhone VARCHAR
, FaxPhone VARCHAR
)
Ši lentelė yra labai paprasta, pakankamai aiški ir švari, dėl to lengva matyti, kodėl kai kurie programuotojai ją taikytų. Problemos prasideda, jeigu darbuotojas turi daugiau negu vieną mobilųjį telefoną. Jeigu sistema jau yra naudojama, tokio papildomo funkcionalumo prašymas dažnai įtakotų prastų problemos apėjimų (workaround) taikymą, pvz.: numerius saugoti atskirtus per kablelį ("86712345678, 8679874563"). Jeigu atsiranda naujo tipo telefonas kontaktas, reiktų keisti schemą; jeigu didžioji dauguma samdinių turi tiktai mobilų telefoną, lentelė turėtų daug nepanaudotos vietos. Visą tai gali būti pašalinama sumodeliavus schemą kurioje telefono tipas, telefono numeris ir samdinys yra atskiros būtybės:
CREATE TABLE Employee
(
Id INT IDENTITY(1, 1)
, FirstName VARCHAR
, LastName VARCHAR
)
CREATE TABLE PhoneNumberType
(
Id INT IDENTITY(1, 1)
, NumberType VARCHAR
)
CREATE TABLE PhoneNumber
(
Id INT IDENTITY(1, 1)
, NumberTypeId INT
, EmployeeId INT
, Number VARCHAR
)
Kadangi SQL yra sukurta dirbti su normalizuotais duomenimis, yra labai lengva susikonstruoti bet kokį įrašą iš daugelio skirtingų lentelių. Taipogi SQL geriausiai dirba su pilnomis laukų reikšmėmis: jeigu telefono lauke yra saugomi per kablelį atskirti telefonai, paieška pagal vieną specifinį telefoną tampa daug brangesnė, nes tokio lauko neįmanoma indeksuoti ir reikia naudoti LIKE arba SUBSTRING() funkcija.
Duomenų bazių normalizavimas yra skirstomas į atskiro lygio formas, kur pirmoji normalinė forma yra pati paprasčiausia ir sekančios yra sudėtingesnės. Yra rekomenduojama turėti duomenų bazę kuri atitinka bent 3-ąja normalinę formą (4-oji ir 5-oji yra naudingos bet nebūtinos)[18]. Jeigu yra matoma, kad aukštesnis normalizavimas gali sukelti greitaveikos problemų, yra vis vien geriau iš pradžių sunormalizuoti ir po to denormalizuoti tiktai ten kur reikia [8].
Kitos optimizacijos
Iš programinio kodo, kuris sąveikauja su duombaze, duomenų įterpimas per ciklą dažniausiai yra pats paprasčiausias/intuityviausias sprendimas:
foreach(var person in people)
{
sql.RunQuery(
"INSERT INTO Person (LastName, FirstName, OtherInfo) VALUES (@l, @f, @o)",
person.LastName,
person.FirstName,
person.OtherInfo );
}
Jeigu 'people' kolekcija yra labai didelė, toks įterpimas gali labai ilgai užtrukti, nes kiekvienam asmeniui turi būti pilnai praeita užklausos vykdymo grandinė (sujungimo su SQL serveriu inicijavimas, užklausos nusiuntimas ir t.t.). Tokiais atvejais yra įmanoma įterpti daugiau negu vieną įrašą su viena užklausa:
INSERT INTO Person
(LastName, FirstName, OtherInfo)
VALUES
(@l1 , @f1 , @o1),
(@l2 , @f2 , @o2),
(@l3 , @f3 , @o3),
...
Jeigu 'people' yra labai didelis (skaičiuojamas milijonais), tektų grąžinti ciklą, tiktai kiekvienu jo taktu būtų galima įterpti aprėpiama kiekį (1000 įrašu vienu metu). [11]
Jeigu reiktų atnaujinti kelių įrašų laukus su unikalia informaciją, tektų daryti kažką panašaus į tai:
foreach(var levelBaseCostMapping in levelBaseCostMappings)
{
sql.RunQuery(
"UPDATE Item SET BaseCost = @bCost WHERE Level = @lvl",
levelBaseCostMapping.BaseCost,
levelBaseCostMapping.Level );
}
Tokio tipo ciklą galima apeiti pasinaudojant SQL CASE'u:
UPDATE Item
SET BaseCost = CASE Level
WHEN @lvl1 THEN @bCost1
WHEN @lvl2 THEN @bCost2
WHEN @lvl3 THEN @bCost3
...
END
WHERE Level IN ( @lvl1, @lvl2, @lvl3 )
Literatūros sąrašas
- 1. Super-Smack - A database benchmarking tool
- 2. SQLStress - A SQL Server Stress Test Tool
- 3. SQL Load Generator
- 4. ab - Apache HTTP server benchmarking tool
- 5. SysBench
- 6. SQL Server Profiler
- 7. SHOW PROFILE Syntax
- 8. 10 Tips for Optimizing MySQL Queries (That don't suck)
- 9. Top 10 performance tuning tips for relational databases
- 10. Database performance optimization part 1 (Indexing strategies)
- 11. SQL Database Performance Tuning for Developers
- 12. Should we drop-recreate indexes when loading data (The Answer may surpirse you)
- 13. Stack Overflow - Why is SELECT * considered harmful?
- 14. 10 sql tips to speed up your database
- 15. Optimizing SQL
- 16. Stack Overflow - Why is UNION faster than an OR statement [duplicate]
- 17. Performance Tuning PostgreSQL
- 18. Ten Common Database Design Mistakes