Aksessmetoder, sortering, join-algoritmer
Hva skjer egentlig mellom SELECT … og resultatet? Vi følger en spørring fra parser (komponenten som leser SQL-teksten) til disk — og regner I/O-kost (antall blokker som må leses/skrives) for hvert valg underveis.
Fra SQL-tekst til resultat
Når du sender en SQL-spørring til databasen, går den gjennom en kjede av komponenter før den faktisk berører data på disk. Hvert ledd har en jobb — og kapittel 7 handler om de siste leddene: hvordan data hentes, sorteres og joines.
1 · Parser
SQL-tekst → algebraisk tre (et internt tre der hver node er en relasjonsalgebra-operasjon som σ, π, ⋈). Sjekker syntaks og slår opp tabeller i SQL-dictionary (databasens metadata-katalog).
2 · Optimizer
Genererer flere planer, leser statistikk, velger den med lavest forventet I/O-kost (antall blokk-aksesser).
3 · Executor
Kjører plan-treet ovenfra. Bruker aksessmetoder mot lagring.
4 · Storage
Heap-fil, B+-tre, hash-fil. Leverer blokker via buffer-poolen (RAM-cachen fra kap. 6).
Optimizeren regnes som «black art» (mørk kunst — den er notorisk vanskelig å lage god) fordi den vurderer alle joinrekkefølger, alle aksessmetoder, alle sorteringsstrategier og velger billigst plan. Vi skal forstå hvilke valg den har, og hvorfor de koster det de koster.
Alt måles i blokk-I/O (Input/Output — hver blokk som leses fra eller skrives til disk). RAM-operasjoner regnes som «gratis» sammenlignet med disk-aksess. Derfor handler så mye av kapittelet om å minimere antall blokker som leses og skrives.
Fire måter å lagre én tabell
Hvor billig en spørring kan kjøres henger på hvordan tabellen er lagret. Pensum bruker fire varianter, og vi skal gjenta det samme eksemplet på alle for å se kostnadsforskjellen.
Liten ordliste: Clustered betyr at radene er fysisk lagret i indeksens nøkkelorden — bladene er tabellen. Unclustered betyr at indeksen peker til rader som ligger spredt i en separat heap-fil. Detaljene er forklart i kap. 6, men disse to ordene dukker opp overalt under.
Eksempel: tabellen Employee
- 100 000 rader, hver er 80 bytes
- Blokkstørrelse: 4 KiB = 4096 bytes
- Skjema:
Employee(empno, name, age, depno, salary)
| Lagring | Records/blokk | Blader | Indeks-blokker | Totalt |
|---|---|---|---|---|
| Heap-fil | ⌊4096/80⌋ = 51 | ⌈100 000/51⌉ = 1961 | — | 1961 |
| Clustered B+-tre | ⌊4096·⅔/80⌋ = 34 | ⌈100 000/34⌉ = 2942 | 13 + 1 = 14 | 2956 |
| Heap + unclustered B+ | 170 (i indeks) | 589 (indeksblader) | 3 + 1 = 4 | 1961 + 593 |
| Clustered hash | ⌊4096·0.8/80⌋ = 40 | ⌈100 000/40⌉ = 2500 | — | 2500 |
⅔-faktoren for B+-tre kommer av at noder typisk er to-tredjedeler fulle (gjennomsnittlig fyllingsgrad ved tilfeldig innsetting — se kap 6). Hash bruker 80 % fyllgrad.
Aksessveiene
- Filscan / tabellscan — les alle blokker i filen. Brukes når det ikke finnes nyttig indeks, eller når vi henter «mye» av tabellen uansett.
- Indeksscan — gå sekvensielt gjennom bladnivået i en indeks. Nyttig f.eks. for
SELECT *med sortert resultat. - Range-scan — gå ned i B+-treet til startnøkkelen, og scan bladene sidelengs til endenøkkelen. Bruk pekere til å hente rader om indeksen er unclustered.
- Indeks-lookup — direkte oppslag på én nøkkel: ned i treet (eller ett hashoppslag), hent rad.
Notasjon — variabler du ser i alle formlene
Før vi regner kostnader trenger vi navn på tingene. Disse seks variablene dukker opp på tvers av hele kapittelet — alle senere formler bygger på dem. Bli komfortabel med dem nå, så slipper du å oversette på nytt under hver formel.
|R|— antall rader i tabellenRb— antall blokker tabellen tar på diskbf— blocking factor: antall rader som får plass i én blokkh— høyden til et B+-tre (antall interne nivåer over bladene)V(A, R)— antall distinkte verdier av attributtAiR(eks:V(depno, Employee)= antall ulike avdelinger)sf— selektivitetsfaktor: andel av tabellen som overlever et predikat (mellom 0 og 1)
Kostnadsformler pr. aksessvei — med utledning
Tabellen over viste tall for Employee. Formlene under regner det samme på nye tall — det er disse du faktisk bruker når eksamensoppgaven kommer med andre tabellstørrelser. Hver formel har en kort utledning slik at du forstår hvorfor den er som den er, ikke bare at den finnes.
bf = ⌊B / R⌋Hvorfor: Én blokk er B bytes (typisk 4096), hver rad er R bytes — så det får plass ⌊B/R⌋ rader, avrundet ned fordi vi ikke kan spare på en halv rad. Variant for B+-tre-blader: bruk ⌊B·⅔/R⌋ fordi noder typisk er to tredjedeler fulle (tilfeldig innsetting splitter blokker ujevnt). For hash-filer brukes ⌊B·0.8/R⌋. Antall blokker tabellen krever blir b = ⌈|R|/bf⌉ — denne sammenhengen knytter |R| og b, og du veksler mellom dem hele tiden.
h = ⌈log_d(b)⌉ (d = fan-out)Hvorfor: I et balansert tre har et nivå k over bladet kapasitet til d^k blader. Vi vil at det øverste nivået rommer minst b blader ⇒ d^h ≥ b ⇒ h ≥ log_d(b). Avrunding opp fordi vi ikke kan ha 1.6 nivå. Hvorfor er h alltid lite? Fan-out er typisk 100–300 fordi (key, blokk-peker) er små poster (~12–24 bytes). Med d = 200 rommer 4 nivå 200⁴ = 1.6 milliarder blader. Et oppslag koster h + 1 blokk-aksesser fordi vi i tillegg må lese selve bladet — det er denne +1-en som dukker opp i alle oppslagsformlene.
cost = h + 1Hvorfor: Vi traverserer h interne nivå (én blokk hver) og leser én bladblokk. I et clustered B+-tre er bladet datablokken — raden ligger der vi lander, ingen ekstra hopp. Det er nettopp denne sparsomheten som gjør clustered så attraktivt: siste blokk-aksess gir oss både nøkkelen og raden. På Employee-eksemplet med h = 2: cost = 3 blokker, mot ~981 ved en heap-scan.
cost = h + 1 + matchesHvorfor: Samme tre-traversering (h + 1) — men nå inneholder bladet (key, RID)-poster, ikke selve raden. Vi må følge hver RID til heap-filen, og hver RID kan i verste fall lande på en ny blokk fordi radene ligger spredt. Hvorfor «matches»? På unik nøkkel: én match ⇒ h + 2. På duplikatnøkler er antall matcher i snitt ⌈|R|/V(A, R)⌉ (uniform-antakelsen) — så for WHERE depno = 5 med 10 avdelinger og 100 000 ansatte: 10 000 matcher = 10 000 ekstra blokk-aksesser worst case. Det er denne kostnaden som gjør at unclustered indeks kan slå tilbake mot full scan, og som motiverer hele neste paragraf.
cost ≈ h + ⌈sf · b⌉Hvorfor: Først h for å finne første matchende blad. Deretter scan vi sidelengs gjennom de matchende bladblokkene via «neste-bladet»-pekerne. Bladene er fysisk sortert ⇒ sammenhengende sekvensiell I/O (billig pr. blokk for OS/disk). Vi leser kun andelen sf · b bladblokker, ikke hele tabellen. Sammenheng med formelen over: dette er bare punkt-oppslag (h + 1) pluss «scan til vi er forbi range-grensen» (sf·b − 1) — vi summerer og forenkler. Eksempel: range med 20 % treff på b = 2942 blader ⇒ cost ≈ 2 + 589 = 591. Til sammenligning ville full scan vært 1961 — clustered indeks vinner med faktor 3.
cost ≤ h + ⌈sf · b_indeks⌉ + sf · |R|Hvorfor: De to første leddene er identisk med clustered (traversér tre + scan indeksens blader — indeksen er fortsatt sortert). Det er det tredje leddet som ødelegger: vi følger én RID pr. matchende rad, og hver RID kan lande på en ny heap-blokk (radene er ikke sortert i fysisk lagring). Worst case: sf · |R| ekstra random I/O. Klassisk fallgruve, og koblingen til hovedinnsikten: når sf · |R| > b blir filscan (b) billigere enn å bruke indeksen — selv om indeksen «matcher» predikatet. På Employee med sf = 0.2, |R| = 100 000, b = 1961: indeks-kost ≥ 20 000 random I/O, filscan kun 1961 sekvensielle. Filscan vinner med faktor 10 — og dette er en av de viktigste grunnene til at en god optimizer noen ganger ignorerer en lovlig indeks. Det er også grunnen til at indeks-valg under databasedesign er så avgjørende.
Den samme spørringen, ulik lagring
Nå sammenligner vi: hva koster SELECT-en med predikatet WHERE key = c, WHERE key > c og full SELECT * på de fire lagringsalternativene? Tabellen er den samme Employee-eksemplet (100 000 rader, 1961 heap-blokker).
| Lagring | Strategi | Blokker lest |
|---|---|---|
| Heap | Full scan | 1961 |
| Clustered B+ | Tre nivåer ned, så scan blader | 2 + 2942 = 2944 |
| Heap + unclustered | Bare scan heap (indeksen hjelper ikke her) | 1961 |
| Clustered hash | Scan hele hash-fila | 2500 |
Heap vinner — ingen indeks-overhead, og ekstra utfylling i B+/hash blir bare ekstra arbeid når vi vil ha alt likevel.
| Lagring | Strategi | Blokker lest |
|---|---|---|
| Heap | Lineær scan, gj.snitt halvparten | ≈ 981 |
| Clustered B+ | Tre nivåer ned (+ binærsøk i blokk) | 3 |
| Heap + unclustered | 3 ned i indeks + 1 i heap | 4 |
| Clustered hash | Hash → blokk (+ litt overflow) | ≈ 1.2 |
| Lagring | Strategi | Blokker lest |
|---|---|---|
| Heap | Full scan (ingen sortering å utnytte) | 1961 |
| Clustered B+ | Ned i tre + scan 20 % av bladene | 2 + 0.2·2942 = 591 |
| Heap + unclustered | 2 + 118 + opptil 20 000 RID-er ⟶ velg heller heap-scan! | 1961 (ikke indeks) |
| Clustered hash | Hash bevarer ikke orden — full scan | 2500 |
Med unclustered indeks og bred range-treff kan optimizeren ende opp med å lese hver datablokk flere ganger. Da er en ren heap-scan nesten alltid raskere — og dette er en sentral grunn til at clustered versus unclustered betyr så mye for ytelsen.
salary, og kjører WHERE salary > 0 (alle ansatte tjener noe). Hvorfor velger optimizeren full table scan framfor indeksen?Sortering når RAM ikke holder
Mange spørringer trenger sortert resultat: ORDER BY, GROUP BY, DISTINCT, og senere sort-merge join (se §6 nedenfor). Vanlig sortering (quicksort, mergesort i RAM) forutsetter at hele lista er i minnet — men en stor tabell er per definisjon ikke det. Vi må sortere bit-for-bit og bruke disk som arbeidsplass, helst uten å lese samme blokk altfor mange ganger.
External merge sort («ekstern» fordi den går utenfor RAM, til disk) løser dette i to faser:
- Initialfase: Les så mye som får plass i bufferet, sorter internt med vanlig sort, skriv ut som en sortert «run». Gjenta til hele tabellen er prosessert.
- Mergefase: Flett flere runs om gangen ved å sammenligne front-verdiene — siden inputene er sorterte, holder det med én peker per run. Gjenta inntil vi har én sortert fil.
Du kan tenke på det som «sortér det du klarer i RAM, så flett resten på disk». Fase 1 utnytter at intern sortering er gratis (ingen disk-I/O) — men den gir oss bare biter som er sortert. Fase 2 fletter bitene parvis (eller flere på én gang) — dét er billig, fordi når begge inputene er sortert trenger vi bare se på én verdi per input om gangen for å plukke neste-i-rekka. Hver fase «rører» hele tabellen én gang for lesning og én gang for skriving — og det er presist der formelen 2b · (1 + passes) kommer fra.
Notasjon (les denne først)
b= antall datablokker i tabellenn_B= antall blokker tilgjengelig i bufferetn_R= antall sorterte «runs» etter initialfasend_M= mergegrad — hvor mange runs vi kan flette sammen i ett pass
Formler — og hvorfor de stemmer
n_R = ⌈b / n_B⌉Hvorfor: Bufferet rommer maks n_B blokker. For å sortere tabellen tar vi ett bufferet om gangen, sorterer det internt og skriver det ut. Tabellen er b blokker, så vi får akkurat ⌈b/n_B⌉ bufferets — derfor like mange runs. Avrunding opp fordi siste run kan være delvis fylt.
d_M = n_B − 1Hvorfor: For å flette k runs samtidig trenger vi én slot per run (for å holde gjeldende blokk og se neste verdi i den runen) — pluss én slot for et output-buffer (der vi samler verdier til neste blokk skal skrives). Totalt k + 1 slots, og det må være ≤ n_B. Største k blir n_B − 1. Den ene «mistede» sloten er prisen for å samle output før vi går til disk.
⌈log_(d_M)(n_R)⌉Hvorfor: Hvert mergepass slår sammen d_M runs til én — så antall runs deles på d_M hver runde. Vi er ferdige når det bare står 1 run igjen. Sekvensen blir n_R → n_R/d_M → n_R/d_M² → … → 1. Antall slike «d_M-eringer» er logaritmen i base d_M. Større buffer ⇒ større d_M ⇒ færre passer.
2b · (1 + antall mergepasser)Hvorfor: Hvert pass — initial eller merge — gjør samme ting på I/O-nivå: leser alle b blokker fra disk, og skriver alle b tilbake. Det gir 2b per pass. Initialfasen teller som ett pass (det er der 1-en kommer fra), pluss antall mergepasser. Sortering i RAM er gratis — det er disken vi betaler for, og hver blokk besøkes nøyaktig én gang per pass i hver retning.
Eksempel fra pensum
Sett n_B = 5, b = 1024 (en tabell på 1024 blokker, buffer på 5).
n_R = ⌈1024/5⌉ = 205initialruns (vi får 205 «bufferets» med data å sortere internt)d_M = 5 − 1 = 4(én slot reservert til output)- Antall mergepasser =
⌈log₄(205)⌉ = 4(205 → 52 → 13 → 4 → 1) - Total I/O =
2·1024·(1+4) =10 240 blokker (initial + 4 merge = 5 passer, hver à 2·1024)
Merk hvor mye buffer-størrelsen betyr: med n_B = 257 ville d_M = 256 og vi hadde merget alt i ett pass — total I/O 2·1024·2 = 4096. Det er én av grunnene til at databaser allokerer mest mulig RAM til sort-bufferne.
Steg-for-steg-animasjon
Et lite eksempel: b = 6 blokker, hver med 2 verdier; n_B = 3. Da er n_R = 2, d_M = 2, og vi trenger akkurat ett mergepass. Total I/O = 2·6·(1+1) = 24 blokk-aksesser. Klikk «Neste steg» for å se hva som skjer i bufferet og hvordan I/O-telleren stiger — eller «Spill auto» for å la den gå selv.
Phase 1 kjører i tre takter per batch: les blokker inn → sorter i RAM (gratis) → skriv ut som sortert run. Etter to batcher er bufferet tomt og to runs ligger på disk. Phase 2 har bufferet delt i 3 slots: én for nåværende blokk av Run A, én for Run B, og én som output-buffer. Hver merge-step sammenligner front-verdien i de to input-slotene, kopierer den minste til output-bufferet, og rykker pekeren fram. Når en input-slot er tom, leses neste blokk fra den runen. Når output-bufferet er fullt, skrives det til disk. Følg I/O-telleren — du ser tydelig at hver blokk krysser disk-grensen nøyaktig 4 ganger (én les + én skriv per pass × 2 passer).
For hver rad i ytre … for hver rad i indre
Den mest grunnleggende join-algoritmen, og — som pensum understreker — ofte den raskeste når en av tabellene er liten eller når vi har en god indeks på indre.
Block nested loop
I praksis sammenligner vi ikke én rad om gangen; vi laster en bunke blokker fra ytre tabell inn i bufferet, og scanner indre én blokk om gangen for hver bunke.
Eksempel fra pensum
Department= 5 blokker (500 rader, 100 pr. blokk)Employee= 1000 blokker- Buffer = 5 blokker (3 til ytre, 1 til indre, 1 til output)
- Spørring:
Employee ⋈ Department WHERE dname='Sales'(⋈ = naturlig join — slå sammen rader der join-attributtet, kolonnen vi joiner på, er likt)
cost = b_ytre + ⌈b_ytre / (n_B − 2)⌉ · b_indreHvorfor — ledd for ledd: Første ledd (b_ytre): vi leser ytre tabell én gang, blokk for blokk, fra start til slutt. Det er uunngåelig — vi må se hver rad i ytre minst én gang. Andre ledd: for hver «bunke» av ytre-blokker som får plass i bufferet, scanner vi indre én gang. Bufferet rommer n_B blokker totalt; vi må reservere 1 til indre-blokk (en om gangen) og 1 til output-blokk (der match-radene samles før de skrives ut). Resten — n_B − 2 — kan brukes til ytre. Antall bunker blir derfor ⌈b_ytre / (n_B − 2)⌉, og hver bunke krever én full scan av indre = b_indre. Konsekvens: indre-tabellen leses gjentatte ganger. Det er antall ganger vi må re-scanne den store siden som dominerer kostnaden — derav den klassiske heuristikken «liten tabell utenfor». Bytter vi ut en stor ytre med en liten ytre, deler vi typisk antall passes med en faktor 100 eller mer.
| Strategi | Regnestykke | Blokker |
|---|---|---|
| Department ytre, Employee indre | 5 + ⌈5/3⌉·1000 = 5 + 2·1000 | 2005 |
| Employee ytre, Department indre | 1000 + ⌈1000/3⌉·5 = 1000 + 334·5 | 2670 |
| Filtrer Department først (én rad) | 5 (lese Dept) + 1000 (Employee én gang) | ≈ 1001 |
Liten tabell utenfor, stor tabell innenfor. Antall passes over indre tabell er ⌈|ytre| / (n_B − 2)⌉ — minimere dette betyr å minimere ytre.
Animasjon — nested loop med matching
To små tabeller, R og S. Vi joiner på lik nøkkel. Klikk «Neste» og se hvordan ytre står stille mens indre scanner — og hvordan to vinduer av matchende rader fanges opp.
R (ytre)
S (indre)
Index nested loop (J2)
Hvis indre tabell har en indeks på join-attributtet, hopper vi over hele indre-scanningen og slår opp direkte for hver ytre rad. Vi bytter altså ut én scan av S pr. ytre bunke mot ett indeks-oppslag pr. ytre rad. Det blir billig når R er liten i radantall, og indeksen er klargjort.
cost = b_R + |R| · (h_S + 1)Hvorfor — bygd ledd for ledd: Det første leddet (b_R) er identisk med J1 — vi må fortsatt lese hele ytre én gang. Det er det andre leddet som endrer seg fundamentalt: i stedet for å re-scanne indre ⌈b_R/(n_B−2)⌉ ganger (J1), gjør vi ett oppslag pr. ytre rad. Hvert oppslag koster h_S + 1 blokker — det er nøyaktig formelen for «punkt-oppslag clustered» fra §02. Vi gjenbruker derfor formelen vi allerede har. Sammenheng med J1: J1 har faktor b_S i andre ledd, J2 har faktor (h_S + 1). Siden h_S + 1 typisk er 3–4 og b_S kan være tusenvis, vinner J2 så lenge |R| ikke er for stor. Hvis join-attributtet kan ha duplikater må vi legge til matches/rad − 1 ekstra bladblokker (rader med samme nøkkel som ikke får plass i én blokk).
cost = b_R + |R| · (h_S + 1 + matches_S)Hvorfor: Helt parallelt med formelen for «punkt-oppslag unclustered» (§02) — hver matchende RID krever ett ekstra hopp til heap-filen, og hver kan lande på en ny blokk. På likhet med V(A, S) distinkte verdier i join-kolonnen er antall matcher i snitt matches_S = ⌈|S| / V(A, S)⌉. Når blir J2 dårlig? Når matches_S er stor — typisk ved bredt duplikat-mønster — kan |R| · matches_S bli større enn én eller to scans av indre. Da slår både J3 (sort-merge) og J4 (hash) tilbake.
Worked example — J1 vs J2 på samme data
Samme join, samme tabeller — kun algoritmen varierer. Vi har R = 50 blokker (1000 rader), S = 500 blokker, n_B = 5, og det finnes en clustered B+-tre på S.pk med høyde h_S = 2 (3-nivåtre).
- J1 (block NL), R ytre:
50 + ⌈50/3⌉·500 = 50 + 17·500 = 8550blokker - J2 med clustered S.pk:
50 + 1000·(2 + 1) = 50 + 3000 = 3050blokker
J2 vinner med 65 % færre I/O — fordi 1000 indeks-oppslag à 3 blokker er mye billigere enn 17 fulle scans av en 500-blokkers tabell.
Men hvor er kryssingspunktet? Hvis R hadde vært 100 000 rader, ville J2 kostet 50 + 300 000 = 300 050 — verre enn J1 (som vokser sakte i ytre). Generelt vinner J2 når |R| · (h_S + 1) < ⌈b_R/(n_B − 2)⌉ · b_S. Venstre side vokser lineært med |R|; høyre side vokser med b_R men er begrenset av b_S. Når |R| er stor nok til at venstre side overstiger høyre, snur dominansforholdet — og optimizeren bytter algoritme.
Tre forutsetninger må vanligvis stemme samtidig: (1) ytre tabell er liten i radantall (ikke bare i blokker — det er |R| som er ganger-faktor i formelen), (2) indre har en brukbar indeks på join-attributtet (helst clustered, slik at vi slipper RID-hopp), og (3) join-attributtet har høy selektivitet i indre (få matcher pr. ytre rad — så matches_S er lite). Bryter en av disse, vinner ofte block nested loop, sort-merge eller hash. Pedagogisk kobling: alle tre punktene henger sammen med formler du allerede har sett — radantall (§02), oppslagskost (§02), og selektivitet (kommer i §07).
⌈10/(n_B−2)⌉ ganger. Hadde vi snudd, måtte vi scannet den lille ⌈10000/(n_B−2)⌉ ganger — flere hundre passer.Sort-merge og hash join — med formler
Pensum behandler nested loop i detalj. De to neste — sort-merge (J3) og partition-hash (J4) — nevnes ofte «konseptuelt», men eksamen kan be deg sammenligne kost mellom dem. Da trenger du formlene. Begge bygger på byggesteiner du allerede kan: J3 bruker external merge sort fra §04, og J4 minner om en grov hashing-strategi som speiler statisk hash-fil fra kap. 6.
Sort-merge join (J3)
Sorter begge tabellene på join-attributtet, og gå parallelt gjennom dem med to pekere. Når en peker leser et match, lar vi den andre stå stille til alle matchene er parret. Effektivt fordi sortert input gjør at vi aldri må gå tilbake — flettingen er én lineær gjennomgang.
cost = sortkost(R) + sortkost(S) + b_R + b_SHvorfor — ledd for ledd: De to første leddene er nøyaktig external merge sort fra §04, anvendt på hver side: sortkost(X) = 2·b_X·(1 + ⌈log_(d_M)(n_R)⌉). Vi gjenbruker den formelen direkte. Det tredje og fjerde leddet — b_R + b_S — er flettetrinnet: vi leser begge sorterte filer én gang hver, side om side, med to pekere. Ingen tilbake-skritt fordi sortert input garanterer at hvis nåværende verdi i R er mindre enn i S, vil ingen senere R-rad matche denne S-raden — så vi flytter bare R-pekeren framover. Når sortering er gratis: hvis input alt er sortert (f.eks. resultat av en clustered B+-tre-scan eller en tidligere ORDER BY), faller den respektive sortkost-termen vekk. Det er denne sammenhengen som gjør J3 ekstra attraktiv som «ledd nederst i en plan» når rotnoden uansett krever sortert output.
Eksempel — koblet til external merge sort
R = 100 blokker, S = 500 blokker, n_B = 6 ⇒ d_M = 5.
- sortkost(R): n_R = ⌈100/6⌉ = 17, passes = ⌈log₅(17)⌉ = 2 ⇒
2·100·(1+2) = 600 - sortkost(S): n_R = ⌈500/6⌉ = 84, passes = ⌈log₅(84)⌉ = 3 ⇒
2·500·(1+3) = 4000 - flettetrinnet:
100 + 500 = 600 - Total:
600 + 4000 + 600 = 5200blokker
Hvis begge alt var sortert: cost = 0 + 0 + 600 = 600. Faktor 8.7 forskjell. Det er nettopp derfor query-planlegging belønner planer som leverer sortert output billig (clustered indeks, eller en tidligere sortering som kan gjenbrukes).
Partition-hash join (J4)
Hash begge tabeller på join-attributtet — alle rader med samme hash-verdi havner garantert i samme partisjon (uansett hvilken tabell de kom fra). Innenfor en partisjon: bygg en hash-tabell i RAM på den minste siden (build-fasen), scan den andre siden og slå opp mot hash-tabellen (probe-fasen). Når en partisjon er liten nok til å få plass i RAM blir join-en innenfor den «gratis» — vi slipper både sortering og re-scan.
cost = 3 · (b_R + b_S)Hvorfor — tre passes over data: Pass 1 (partisjonering av R): les b_R blokker, hash hver rad og fordel den i en av n_B − 1 output-partisjoner, skriv b_R tilbake til disk ⇒ 2·b_R. Pass 2 (partisjonering av S): samme — 2·b_S. Pass 3 (build + probe): for hver partisjons-par leser vi begge sider én gang hver, og bygger/prober hash-tabellen i RAM ⇒ b_R + b_S. Sum: 3·(b_R + b_S). Forutsetning: minste partisjon må få plass i RAM, omtrent n_B² > b_min. Hvis ikke, må vi rekursivt partisjonere på nytt — én ekstra pass pr. nivå rekursjon. Sammenligning med J3: J4 unngår sortering, og er derfor mer robust når buffer er lite (sortering med få runs vinner; sortering med mange runs taper). Pedagogisk: J3 og J4 er to forskjellige måter å «strukturere data slik at like nøkler havner sammen» — sortering for J3, hashing for J4.
Eksempel — samme tall, ny algoritme
Samme R = 100, S = 500: cost = 3·(100 + 500) = 1800 blokker. Mye billigere enn J3 (5200) i dette scenarioet, fordi sortering var dyrt med så lite buffer (n_B = 6). Hadde n_B vært 30 ville J3 kostet bare ett pass og kommet på samme nivå som J4.
NTNUs Kjell Bratbergsengen er pioner på partition-hash join (publisert på VLDB-konferansen — Very Large Data Bases — i 1984).
Beslutningstabell — når vinner hvilken?
| Algoritme | Kostformel | Vinner når … | Faller for … |
|---|---|---|---|
| J1 block NL | b_R + ⌈b_R/(n_B−2)⌉·b_S | én side er liten (få blokker); ingen indeks | begge store; mange passes over indre |
| J2 index NL | b_R + |R|·(h_S+1[+m]) | R har få rader; S har clustered indeks på join-attr | R er stor (lineær i |R|); mange duplikater i S |
| J3 sort-merge | sortkost·2 + b_R + b_S | begge alt sortert; må sortere uansett (ORDER BY) | liten buffer ⇒ mange merge-passes ⇒ sortering eksploderer |
| J4 hash | 3·(b_R + b_S) | begge store; equi-join; partisjon får plass i RAM | data-skjevhet (én partisjon eksploderer); ikke equi-join |
Equi-join betyr join-betingelsen er likhet (R.a = S.b) — sort-merge og hash krever dette, mens nested loop tåler vilkårlige predikater (<, >, BETWEEN osv.). Pedagogisk innsikt: alle fire algoritmer har b_R + b_S som «gulv» — du kan ikke joine to tabeller uten å lese dem minst én gang hver. Forskjellen er hvor mye ekstra som legges på toppen.
De fleste DBMS-er har alle fire algoritmene tilgjengelig og bytter dynamisk basert på statistikk. MySQL har historisk vært nested-loop-orientert (med en enkel «join buffer» som tilnærmer hash); PostgreSQL og kommersielle systemer (Oracle, SQL Server) bruker alle fire flittig. Pensum forventer at du kjenner formlene og kan velge mellom dem på papir — ikke nødvendigvis at du kjenner hver enkelt implementasjon.
b_R + b_S. Hash join koster fortsatt 3·(b_R + b_S), og nested loop skanner indre tabell flere ganger. Dette er hovedårsaken til at en plan som leverer sortert output (f.eks. via clustered B+-tre) kan ha lavere totalkost enn én som ikke gjør det — selv om aksessveien isolert sett er dyrere.J3: sortkost(R) — n_R = ⌈200/11⌉ = 19, passes = ⌈log₁₀(19)⌉ = 2, cost = 2·200·3 = 1200. sortkost(S) — n_R = ⌈1000/11⌉ = 91, passes = ⌈log₁₀(91)⌉ = 2, cost = 2·1000·3 = 6000. Flettetrinn: 1200. Totalt: 8400.
J4: 3·(200+1000) = 3600.
J4 vinner — sortering på 1000 blokker krever to merge-passes selv med d_M = 10. Den faste «3 passes»-strukturen til hash join slår sortering når sorteringen ikke får gjort jobben på ett pass.
Hvordan optimizeren gjetter
Optimizeren sammenligner planer uten å kjøre dem. Den må estimere kost basert på statistikk lagret i SQL-dictionaryet (databasens metadata-katalog — der skjema, indekser og slik statistikk holdes). Denne paragrafen forklarer hvordan den gjetter — selve regnemaskineriet bak alle kostnadstallene vi har sett til nå.
Selektivitetsfaktor — andelen som overlever et predikat
Selektivitet er nøkkelen til alle kostnadsestimat. Hvert predikat (WHERE depno = 5, WHERE alder > 60) reduserer antall rader som går videre. Optimizeren tildeler hvert predikat en sf mellom 0 og 1 — andelen rader som matcher — og bruker den til å estimere både output-størrelse og hvilken aksessmetode som vinner. Hvorfor det er kritisk: alle formlene i §02–§06 har sf, |R| eller matches i seg. Disse beregnes alle fra selektivitet — det er den ene knaggen optimizeren lener seg på når den velger plan.
sf = 1 / V(A, R)Hvorfor: Hvis attributtet har V(A, R) distinkte verdier og fordelingen er uniform (alle verdier like sannsynlige), vil hver verdi forekomme i |R|/V rader. Sannsynligheten for at en tilfeldig rad har en bestemt verdi er da 1/V — som er definisjonen på selektivitet. Eksempel: |Employee| = 100 000, V(depno) = 10 ⇒ WHERE depno = 5 estimeres til 10 000 rader (sf = 0.1). Spesialtilfelle: når kolonnen er PRIMARY KEY har vi V = |R|, så sf = 1/|R| (én rad). Uniform-antakelsen er den vanligste kilden til feilestimat — i virkeligheten er fordelinger ofte skjeve (mange ansatte i salgsavdelingen, få i toppledelsen).
sf = (high(A) − c) / (high(A) − low(A)) for predikatet A > cHvorfor: Vi antar verdiene er jevnt fordelt mellom low og high (lagret i SQL-dictionaryet pr. indekserte kolonne). Andelen av aksen som er «over c» blir (high − c)/(high − low). Eksempel: low = 0, high = 100, predikat empno > 80 ⇒ sf = 20/100 = 0.2. Tilsvarende formel: for A < c bruk (c − low)/(high − low); for BETWEEN c1 AND c2 bruk (c2 − c1)/(high − low). Histogrammer (lagret som verdifordelinger pr. bucket) gir bedre estimater når fordelingen ikke er uniform — f.eks. mange ansatte har lønn 400–500k, få har over 1M. Histogrammer er essensielt en stykkevis lineær approksimasjon.
sf_total = sf_1 · sf_2 (antar uavhengighet)Hvorfor: Hvis to predikater er statistisk uavhengige (det ene sier ikke noe om sannsynligheten for det andre), er sannsynligheten for at en rad oppfyller begge produktet av sannsynlighetene — klassisk sannsynlighetsregning. Eksempel: WHERE depno = 5 AND alder > 50. Hvis 10 % er i avd. 5 og 30 % er over 50 år, gjetter optimizeren 0.10 · 0.30 = 3 % oppfyller begge. Når antakelsen feiler: hvis avd. 5 tilfeldigvis er pensjonist-avdelingen er korrelasjonen høy og det reelle tallet kanskje 25 %. Estimatet blir for lavt, optimizeren undervurderer mellomresultatets størrelse, og den valgte planen blir feil. Dette er en kjent og vanlig kilde til dårlige planer i produksjon — derfor lagrer moderne DBMS-er multi-column statistics som dekker korrelasjon mellom kolonner.
sf_total = sf_1 + sf_2 − sf_1 · sf_2Hvorfor: Inklusjon-eksklusjon — vi summerer treffene fra hvert predikat, men trekker fra de som ble talt to ganger (rader som oppfyller begge). Følger samme sannsynlighetsregel: P(A ∪ B) = P(A) + P(B) − P(A ∩ B). For små sf-verdier er sf_1 · sf_2 liten, så sf_total ≈ sf_1 + sf_2 er en rimelig hurtig-tilnærming. Sammenheng med AND: de er duale — AND multipliserer (gjør sf mindre, færre treff), OR summerer (gjør sf større, flere treff).
Selektivitet bestemmer hvor mange rader som «overlever» hvert ledd i planen — og dermed hvor stort input neste ledd må jobbe med. Den dukker opp i: range-scan-kostnad (§02: sf · b), unclustered-fellen (§02: sf · |R|), output-størrelse pr. tabell (input til join), join-output-størrelse (input til sort/projection), og final result size. En faktor 10 i feil estimat på øverste ledd kan multiplisere seg gjennom planen og gi en faktor 100–1000 i feil totalkost. Det er derfor moderne DBMS-er bruker så mye energi på histogrammer, multi-column statistics og adaptive re-planning når estimatet er åpenbart feil.
Statistikk i SQL-dictionaryet
- For hver tabell: antall rader, antall blokker.
- For hver indeks: antall distinkte nøkkelverdier, antall blokker, histogrammer (verdifordelinger).
- For hvert B+-tre: trehøyde, LowKey (laveste forekommende nøkkel), HighKey (høyeste), antall blokker.
Med disse tallene kan optimizeren estimere antall blokk-aksesser for hver kandidatplan og velge den billigste.
EXPLAIN i MySQL
Når noe går tregt, er EXPLAIN verktøyet for å se hva optimizeren faktisk har valgt — uten å kjøre spørringen.
mysql> explain select * from reg natural join loper; +----+-------------+-------+------+---------+------+----------+---------+ | id | select_type | table | type | key | rows | filtered | Extra | +----+-------------+-------+------+---------+------+----------+---------+ | 1 | SIMPLE | loper | ALL | PRIMARY | 3 | 100.00 | NULL | | 1 | SIMPLE | reg | ALL | NULL | 10 | 10.00 | Using | | | | | | | | | join | | | | | | | | | buffer | +----+-------------+-------+------+---------+------+----------+---------+
type = ALL betyr full table scan. MySQL leser tabellene i den oppgitte rekkefølgen og bruker en intern join-buffer (en enkel form for hash join) når ingen indeks er tilgjengelig.
Slik angriper du oppgaven
Eksamen tester sjelden ett enkelt konsept — den ber deg ofte regne totalkostnaden for en plan, eller velge mellom 2–3 planer. Da må du jobbe deg gjennom verktøykassen i en bestemt rekkefølge. Denne paragrafen samler alt fra §02–§07 til én oppskrift, ett komplett worked example, og et formelark for raskt oppslag.
- Skriv opp kjente størrelser:
|R|, b_R, |S|, b_S, n_B, hvilke indekser finnes og om de er clustered, høydehfor hver. Dette er bokføringen — gjør den eksplisitt, ikke i hodet. - Estimer selektivitet for hvert predikat:
sf = 1/V(likhet), range-formelen, eller AND/OR-kombinasjon. Beregn forventet output-størrelsesf · |R|. - Velg aksessvei pr. tabell: regn kost for filscan (
b), indeks-lookup, range-scan clustered/unclustered. Velg billigst — det er denne du går videre med. - Velg join-algoritme hvis det finnes en join: J1, J2 (hvis indeks finnes på indre), J3 (hvis sortering nyttig), J4. Sammenlign tallene; vinneren går inn i planen.
- Legg til sortering dersom ORDER BY/GROUP BY/DISTINCT — med mindre clustered indeks alt gir sortert output (gratis).
- Sum opp. Hver operatorkost legges sammen ⇒ totalkostnad. Skal flere planer sammenlignes: gjenta steg 3–5 for hver, sammenlign sluttsummen.
Pedagogisk poeng: oppskriften går «utenfra og inn» — først filtrerer vi (steg 2–3), så kombinerer (steg 4), så ordner (steg 5). Det speiler nøyaktig hvordan optimizeren tenker, og hvordan den fysiske planen er strukturert som et tre nedenfra og opp.
Komplett worked example
La oss kjøre sjekklisten på en realistisk oppgave — én som kombinerer aksess, join, sortering, og selektivitet. Følg med på hvert steg.
SELECT e.name FROM Employee e JOIN Department d ON e.depno = d.depno WHERE d.dname = 'Sales' ORDER BY e.name;
Kjente størrelser: Employee = 100 000 rader / 1961 blokker. Department = 50 rader / 5 blokker. n_B = 10 buffer.
Indekser: Clustered B+ på Employee.empno (h=2), unclustered B+ på Employee.depno (h=2). Ingen indeks på Department.
Statistikk: V(dname, Department) = 50 (hver avdeling har unikt navn). V(depno, Employee) = 50.
Steg 1–2: selektivitet og output-størrelser
WHERE d.dname = 'Sales': sf = 1/V(dname, Dept) = 1/50 = 0.02 ⇒ 1 rad ut av Department.- Etter join: hver Department-rad matcher i snitt
|Employee|/V(depno, Employee) = 100 000/50 = 2000Employee-rader ⇒ join-output ≈ 2000 rader.
Vi vet allerede før vi regner kost at sluttresultatet bare er 2000 rader. Det er en sterk hint om at vi vil unngå å lese hele Employee om mulig.
Steg 3: aksessvei pr. tabell
- Department: ingen indeks ⇒ filscan = 5 blokker. Vi filtrerer på
dname = 'Sales'mens vi scanner. - Employee: hvilken aksessvei vi bruker avhenger av join-algoritmen — derfor regnes den i steg 4 sammen med joinen.
Steg 4: join-algoritme
Department er liten ⇒ ytre. Etter filtrering har vi 1 rad (≈ 1 blokk i mellomresultat). Vi sammenligner alle fire algoritmer:
- J1 (block NL), filtrert Department ytre, Employee indre:
5 (les Dept) + ⌈1/8⌉·1961 = 5 + 1961 = 1966. Kun én scan av Employee fordi ytre passer i én buffer-bunke. - J2 med unclustered indeks på Employee.depno: ett oppslag pr. ytre rad. matches_S = 2000.
5 + 1·(2 + 1 + 2000) = 2008. Litt verre — fordi 2000 RID-hopp er mye når indeksen er unclustered. - J3 sort-merge: må sortere Employee. d_M = 9, n_R = ⌈1961/10⌉ = 197, passes = ⌈log₉(197)⌉ = 3 ⇒ sortkost = 2·1961·4 = 15 688. Verre med god margin — sortering er ikke verdt det her.
- J4 partition-hash:
3·(5 + 1961) = 5898. Også verre — fast 3 passes-kost slår dårlig ut når en side er bittesmå (J1 utnytter dette bedre).
Vinner: J1 med 1966 blokker. Pedagogisk poeng: når én side er bittesmå (1 rad etter filtrering!), faller alle de «smarte» algoritmene gjennom — den enkleste vinner. Liten ytre × stor indre × ingen revisning er nested loops naturlige hjem.
Steg 5: ORDER BY e.name
Output er 2000 rader. Forutsatt 100 rader pr. blokk ⇒ ~20 blokker. Sortering: n_R = ⌈20/10⌉ = 2, passes = ⌈log₉(2)⌉ = 1, sortkost = 2·20·2 = 80 blokker.
Steg 6: totalkostnad
Total = J1 + sort = 1966 + 80 ≈ 2046 blokker.
Hva-hvis-analyse: hvis Employee.depno hadde vært clustered i stedet for unclustered, ville J2 kostet 5 + 1·(2 + 1) = 8 for å finne riktig blader, pluss å lese 2000-rad-segmentet ≈ 39 sammenhengende blokker — totalt rundt 50 blokker. Det er en faktor 40 forskjell. Det er denne sensitiviteten — at clustering på riktig kolonne kan endre kostnaden så dramatisk — som gjør indeks-design så viktig under databasedesign.
Formelark — alt på én side
For repetisjon rett før eksamen — alle formler i kapittelet samlet, slik at du kan bla rett til den du trenger. Ikke en erstatning for forståelsen, men praktisk når du jobber gjennom en oppgave under tidspress.
| Operasjon | Formel |
|---|---|
| Blocking factor | bf = ⌊B/R⌋ (B+-tre-blader: ⌊B·⅔/R⌋; hash: ⌊B·0.8/R⌋) |
| Antall datablokker | b = ⌈|R|/bf⌉ |
| B+-tre-høyde | h = ⌈log_d(b)⌉ (d = fan-out) |
| Filscan | b |
| Punkt-oppslag clustered | h + 1 |
| Punkt-oppslag unclustered | h + 1 + matches |
| Range-scan clustered | h + ⌈sf·b⌉ |
| Range-scan unclustered | h + ⌈sf·b_indeks⌉ + sf·|R| |
| Hash punkt-oppslag | ≈ 1.2 (clustered hash) |
| Størrelse | Formel |
|---|---|
| Antall initialruns | n_R = ⌈b / n_B⌉ |
| Mergegrad | d_M = n_B − 1 |
| Antall merge-passes | ⌈log_(d_M)(n_R)⌉ |
| Total I/O | 2·b·(1 + #merge-passes) |
| Algoritme | Formel |
|---|---|
| J1 block NL | b_R + ⌈b_R/(n_B−2)⌉·b_S |
| J2 index NL clustered | b_R + |R|·(h_S + 1) |
| J2 index NL unclustered | b_R + |R|·(h_S + 1 + matches_S) |
| J3 sort-merge | sortkost(R) + sortkost(S) + b_R + b_S |
| J4 partition-hash | 3·(b_R + b_S) |
| Predikat | Formel |
|---|---|
Likhet A = c | sf = 1 / V(A, R) |
Range A > c | sf = (high(A) − c) / (high(A) − low(A)) |
| AND | sf_1 · sf_2 |
| OR | sf_1 + sf_2 − sf_1·sf_2 |
| Forventet output-størrelse | sf · |R| rader |
| Matches pr. ytre rad i join | |S| / V(join-attr, S) |
Test deg selv på hele kapittelet
Blandet vanskelighet. Klikk «Se svar» når du har tenkt.
empno. Hvorfor velger optimizeren full table scan for WHERE empno > 80000 (20 % treff)?2b · (1 + #merge-passes) og ikke b · #passes?⌈|ytre| / (n_B−2)⌉.S ytre: 5000 + ⌈5000/10⌉·200 = 5000 + 500·200 = 105 000. R som ytre vinner.
type = ALL. Hva betyr det?S.pk med høyde h_S = 2. n_B = 5. Hva koster J2 med R som ytre, og hvordan sammenlignes det med J1?J2: cost = b_R + |R|·(h_S + 1) = 80 + 1500·3 = 4580 blokker.
J1 (block NL): 80 + ⌈80/3⌉·600 = 80 + 27·600 = 16 280 blokker.
J2 vinner med en faktor 3.5 — fordi 1500 indeks-oppslag à 3 blokker er mye billigere enn 27 fulle scans av en 600-blokkers tabell. Men hadde |R| vært 100 000 ville J2 kostet 80 + 300 000 — verre enn J1. Kryssingspunktet ligger der antall ytre rader (i tusener) møter antall passes over indre.
WHERE land = 'Norge' AND alder > 60.sf_land = 1/V(land, R) = 1/200 = 0.005.
sf_alder = (high − c)/(high − low) = (100 − 60)/(100 − 0) = 0.4.
AND ⇒ sf_total = sf_land · sf_alder = 0.005 · 0.4 = 0.002.
Estimat: 0.002 · 1 000 000 = 2000 rader. Forutsetter at de to predikatene er uavhengige — i praksis kan korrelasjon mellom land og alder gjøre estimatet feil.
J3 (sort-merge): d_M = 5. sortkost(R): n_R = ⌈300/6⌉ = 50, passes = ⌈log₅(50)⌉ = 3, cost = 2·300·4 = 2400. sortkost(S): n_R = 250, passes = ⌈log₅(250)⌉ = 4, cost = 2·1500·5 = 15 000. Flettetrinn: 1800. Totalt: 19 200 blokker.
J4 (hash): 3·(300+1500) = 5400 blokker.
J4 vinner stort — sortering på 1500 blokker krever 4 merge-passes med så lite buffer, og hver pass leser+skriver hele datasettet. Hash join er robust mot lite buffer fordi det alltid er fast 3 passes.
alder med h = 2, b_indeks = 500 bladblokker. Range-spørringen treffer 30 % av radene. Bør optimizeren bruke indeksen?Indeks-kost: h + ⌈sf·b_indeks⌉ + sf·|R| = 2 + 150 + 60 000 = 60 152 blokker.
Filscan: b = 4000 blokker.
Filscan vinner med en faktor 15 — optimizeren ignorerer indeksen. Klassisk «brede range + unclustered = drop indeksen»-fellen, og en hovedgrunn til at unclustered indekser ofte må pares med filscan-fallback i query-planen.
SELECT * FROM Order o JOIN Customer c ON o.cid = c.cid WHERE c.country = 'NO'. Customer = 100 blokker (10 000 rader, V(country)=50), Order = 5000 blokker. n_B = 10. Skal vi joine før eller etter filtreringen — og hvorfor er forskjellen så stor?sf for country='NO' = 1/50 = 0.02 ⇒ 200 Customer-rader = 2 blokker filtrert mellomresultat.
Plan A (filtrer først, så join): filscan Customer (100) + materialiser 2 blokker. J1 med 2 blokker ytre, Order indre: 2 + ⌈2/8⌉·5000 = 5002. Totalt: 5102 blokker.
Plan B (join hele Customer, så filtrer): J1 med Customer (100) ytre, Order indre: 100 + ⌈100/8⌉·5000 = 100 + 13·5000 = 65 100 blokker. Faktor 13 forskjell.
Vinner: Plan A. «Filter pushdown» (skyv filtreringen så tidlig som mulig) er en av de viktigste og enkleste optimaliseringsteknikkene — fordi antall passes over Order er ⌈|ytre|/(n_B−2)⌉, og en mindre ytre nesten alltid betyr drastisk færre passes.
Nå er du klar for kapittel 8 — Transaksjoner. Spørringer er bare halve historien; den andre halvparten er hvordan vi kjører flere spørringer samtidig uten å trampe på hverandre.