Kapittel 7 · Forelesning 13 · Bratsberg kap. 12–14

Aksessmetoder, sortering, join-algoritmer

Hva skjer egentlig mellom SELECT … og resultatet? Vi følger en spørring fra parser til disk — og regner I/O-kost for hvert valg underveis.

01 · Helhetsbilde

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.

Spørringspipeline

1 · Parser

SQL-tekst → algebraisk tre. Sjekker syntaks og slår opp tabeller i SQL-dictionary.

2 · Optimizer

Genererer flere planer, leser statistikk, velger den med lavest forventet I/O-kost.

3 · Executor

Kjører plan-treet rotnoden ned. Bruker aksessmetoder mot lagring.

4 · Storage

Heap-fil, B+-tre, hash-fil. Leverer blokker via buffer-poolen.

Optimizeren regnes som «black art» — den vurderer alle joinrekkefølger, alle aksessmetoder, alle sorterings­strategier og velger billigst plan. Vi skal forstå hvilke valg den har, og hvorfor de koster det de koster.

Hovedinnsikt

Alt måles i blokk-I/O. RAM-operasjoner regnes som «gratis» sammenlignet med å lese en blokk fra disk. Derfor handler så mye av kapittelet om å minimere antall blokker som leses og skrives.

Sjekk forståelsen
Hvorfor lager optimizeren flere planer for samme spørring i stedet for å bruke en fast oppskrift?
Fordi billigste plan avhenger av tabellstørrelser, indekser, fordeling av verdier og bufferstørrelse — ting som varierer mellom spørringer og over tid. En fast oppskrift ville vært god for noen tilfeller og katastrofal for andre. Optimizeren bruker statistikk fra dictionaryet til å estimere kost og velge.
02 · Aksessmetoder

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.

Eksempel: tabellen Employee

  • 100 000 rader, hver er 80 bytes
  • Blokkstørrelse: 4 KiB = 4096 bytes
  • Skjema: Employee(empno, name, age, depno, salary)
Lagringsalternativ — antall blokker
LagringRecords/blokkBladerIndeks-blokkerTotalt
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. Hash bruker 80 % fyllgrad.

De fire aksessmetodene

  • Full table scan — les alle blokker. Brukes når det ikke finnes nyttig indeks, eller når vi henter «mye» av tabellen uansett.
  • Index scan — gå ned i B+-treet til riktig blad, bruk pekere til å lese radene fra heap.
  • Index-only scan — alle kolonnene som trengs ligger i indeksen selv. Vi slipper å besøke heap-filen.
  • Bitmap scan — bygg en bitmap over RID-er fra én eller flere indekser, sorter, slå opp i datafilen i blokk-rekkefølge. Viktig for «AND»-betingelser med flere indekser.
Sjekk forståelsen
Hvorfor er en index-only scan ofte vesentlig billigere enn en vanlig index scan, selv når begge bruker samme indeks?
Fordi index-only slipper det dyre steget: å følge pekere ut til heap-filen for hver match. Hvis vi finner 20 000 treff, må en vanlig index scan i verste fall lese 20 000 ekstra blokker (én per RID). Index-only gir hele svaret fra indeksen og bruker bare bladnodene.
03 · Kostnad pr. spørringstype

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 lagrings­alternativene? Tabellen er den samme Employee-eksemplet (100 000 rader, 1961 heap-blokker).

SELECT * FROM Employee  —  alle rader
LagringStrategiBlokker lest
HeapFull scan1961
Clustered B+Tre nivåer ned, så scan blader2 + 2942 = 2944
Heap + unclusteredBare scan heap (indeksen hjelper ikke her)1961
Clustered hashScan hele hash-fila2500

Heap vinner — ingen indeks-overhead, og ekstra utfylling i B+/hash blir bare ekstra arbeid når vi vil ha alt likevel.

SELECT … WHERE empno = 42  —  punktoppslag på unik nøkkel
LagringStrategiBlokker lest
HeapLineær scan, gj.snitt halvparten≈ 981
Clustered B+Tre nivåer ned (+ binærsøk i blokk)3
Heap + unclustered3 ned i indeks + 1 i heap4
Clustered hashHash → blokk (+ litt overflow)≈ 1.2
SELECT … WHERE empno > 80000  —  range-spørring (20 % treff)
LagringStrategiBlokker lest
HeapFull scan (ingen sortering å utnytte)1961
Clustered B+Ned i tre + scan 20 % av bladene2 + 0.2·2942 = 591
Heap + unclustered2 + 118 + opptil 20 000 RID-er ⟶ velg heller heap-scan!1961 (ikke indeks)
Clustered hashHash bevarer ikke orden — full scan2500
Klassisk feilvalg

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.

Sjekk forståelsen
Du har en unclustered B+-indeks på salary, og kjører WHERE salary > 0 (alle ansatte tjener noe). Hvorfor velger optimizeren full table scan framfor indeksen?
Fordi en full match betyr at vi i verste fall følger 100 000 RID-pekere — én blokk-aksess pr. rad — totalt mye mer enn ~1961 blokker for full scan. Optimizerens kostnadsmodell ser at indeksen er verre her selv om den teknisk «matcher» predikatet.
04 · External merge sort

Sortering når RAM ikke holder

Mange spørringer trenger sortert resultat: ORDER BY, GROUP BY, DISTINCT, sort-merge join. Når tabellen er større enn RAM, bruker vi external merge sort. Idéen er to faser:

  1. Initialfase: Les inn så mye som får plass i bufferet, sorter internt, skriv ut som en sortert «run».
  2. Mergefase: Merge flere runs om gangen til vi har én sortert fil.

Formler

  • Antall initialruns: n_R = ⌈b / n_B⌉   (b = antall datablokker, n_B = antall bufferblokker)
  • Mergegrad: d_M = n_B − 1 (én buffer reserveres til output)
  • Antall mergepasser: ⌈log_(d_M)(n_R)⌉
  • Total I/O: 2b · (1 + antall mergepasser)

Eksempel fra pensum

n_B = 5, b = 1024. Da er n_R = ⌈1024/5⌉ = 205, og d_M = 4. Antall mergepasser = ⌈log₄(205)⌉ = 4. Total I/O = 2·1024·(1+4) = 10 240 blokker.

Animasjon: external merge sort, n_B = 3, 9 blokker
Klar — 9 usorterte blokker, buffer = 3
Intuisjon

Hvert pass leser og skriver hele datasettet — derfor 2b. En større buffer gir høyere mergegrad, færre passer og mindre I/O. Det er ikke RAM-tregheten som dominerer, men antall ganger vi må røre disken.

Regneoppgave
Tabellen din er 200 blokker, og du har 11 buffer­blokker. Hvor mange mergepasser trenger du?
n_R = ⌈200/11⌉ = 19 initialruns. d_M = 10. Antall mergepasser = ⌈log₁₀(19)⌉ = 2. Total I/O = 2·200·(1+2) = 1200 blokker.
05 · Nested loop join

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'
Tre scenarioer — hvilken rekkefølge?
StrategiRegnestykkeBlokker
Department ytre, Employee indre5 + ⌈5/3⌉·1000 = 5 + 2·10002005
Employee ytre, Department indre1000 + ⌈1000/3⌉·5 = 1000 + 334·52670
Filtrer Department først (én rad)5 (lese Dept) + 1000 (Employee én gang)≈ 1001
Tommelfingerregel

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.

For each block in R: for each block in S: probe

R (ytre)

S (indre)

Sammenligninger: 0 · Treff: 0
Klar

Index nested loop

Hvis indre tabell har en indeks på join-attributtet, hopper vi over hele scanningen og slår opp direkte. Da blir kostnaden ca. |R| · (kost pr. oppslag), ofte 3–4 blokker pr. rad i R. Dramatisk billigere når R er liten.

Sjekk forståelsen
Du joiner en tabell på 10 blokker med en på 10 000 blokker. Hvilken bør være ytre, og hvorfor?
Den lille (10 blokker) bør være ytre. Da scanner vi den store kun ⌈10/(n_B−2)⌉ ganger. Hadde vi snudd, måtte vi scannet den lille ⌈10000/(n_B−2)⌉ ganger — flere hundre passer.
06 · Sort-merge join

Sorter begge, gå parallelt

Når begge tabellene er store, eller allerede sortert på join-attributtet, blir to-pekere-strategien attraktiv. Vi sorterer R og S én gang (ofte med external merge sort), og går så gjennom dem samtidig.

Algoritmen

  1. Sorter R på r.k, sorter S på s.k.
  2. To pekere i på R, j på S, begge starter på 0.
  3. Hvis R[i].k < S[j].ki++. Hvis > → j++. Hvis = → emit, og avanser likt.
  4. Stopp når en av listene er tom.
To-pekere på sorterte lister

R sortert på k

S sortert på k

Match-par funnet: 0
i=0, j=0

Når lønner det seg?

  • Når begge tabellene allerede er sortert (f.eks. fra clustered indeks) — da er kostnaden bare scan.
  • Når begge er store og join-resultatet skal sorteres uansett.
  • Mindre bra hvis kun én tabell er liten — da er nested loop med indeks oftest billigere.
07 · Hash join

Bygg en hash-tabell, probe mot den

Hvis ingen er sortert og vi ikke har en god indeks på indre, kan hash join være billigste valg.

Build + probe

  1. Build phase: Velg den minste tabellen (build side). Les den én gang og bygg en hash-tabell på join-attributtet i RAM.
  2. Probe phase: Les den større tabellen (probe side) én blokk om gangen. For hver rad: hash join-verdien, slå opp i bucket, emit alle treff.
Build hash på R (liten), probe S (stor)

R (liten — build)

Hash-tabell

S (stor — probe)

Build: 0 rader · Probe-treff: 0
Fase: build

Grace hash join

Når den lille tabellen ikke får plass i RAM, partisjonerer vi begge tabeller med samme hash-funksjon i en forfase. Da er det nok å ha én partisjon i minnet om gangen — vanlig hash join på hver partisjon. I/O-kost: 3·(|R|+|S|).

Hash vs sort-merge

Hash er ofte raskere når én tabell er klart mindre, mens sort-merge skinner når begge er sortert eller når vi trenger sortert output uansett. Optimizeren regner kost for begge og velger.

Sjekk forståelsen
Hash join er ikke nyttig for ulikhets-join som R.x < S.y. Hvorfor?
Fordi hash-strukturen gir oss like verdier i samme bucket. Den sier ingenting om hvilke verdier som er mindre eller større. Sort-merge fungerer for ulikhet (med litt modifikasjon), og nested loop fungerer alltid — men koster mer.
08 · Kostnadsestimering

Hvordan optimizeren gjetter

Optimizeren sammenligner planer, men har ikke kjørt dem ennå. Den må estimere kost basert på statistikk. Hva har den å jobbe med?

Statistikk i SQL-dictionaryet

  • n(R) — antall rader i R
  • b(R) — antall blokker som inneholder R
  • V(A, R) — antall distinkte verdier av attributt A i R
  • min/max — for B+-indeksert attributt
  • histogrammer — fordeling av verdier (bin-baserte estimater)
  • Indeksinfo: høyde av B+-treet, om det er clustered, fyllgrad

Selektivitet

Selektivitet er andelen rader som passer et predikat. Optimizeren bruker enkle modeller:

  • WHERE A = c  ⇒  sel ≈ 1 / V(A, R)
  • WHERE A > c  ⇒  sel ≈ (max − c) / (max − min)
  • WHERE p₁ AND p₂  ⇒  sel ≈ sel(p₁) · sel(p₂) (antar uavhengighet)
Histogrammer

Den enkle min/max-formelen feiler når data er skjev. Histogrammer deler verdiområdet i bins og lagrer telling pr. bin — selektiviteten regnes pr. bin og summeres. Det er hovedgrunnen til at ANALYZE i moderne databaser samler histogrammer.

Sjekk forståelsen
Optimizeren estimerer 100 rader — i virkeligheten kommer det 1 million. Hva er den vanligste konsekvensen?
Den valgte planen er ofte feil — typisk en nested loop som var billig for 100 rader, men forferdelig for 1M (kvadratisk eksplosjon). Symptom: spørringen som vanligvis tar 50 ms tar plutselig minutter. Fix: ANALYZE for å oppdatere statistikk, eller sjekke om histogrammer er utdaterte.
09 · EXPLAIN

Be databasen vise planen

Når noe går tregt, er EXPLAIN det viktigste verktøyet. Den lar deg se eksakt hva optimizeren har valgt — uten å kjøre spørringen — og EXPLAIN ANALYZE kjører den i tillegg og rapporterer faktisk tid.

Eksempel-plan

HashJoin  (cost=1245.00..1899.32 rows=4500)
  Hash Cond: (e.depno = d.dno)
   SeqScan on employee e  (cost=0..961 rows=100000)
   Hash  (cost=10.50..10.50 rows=1)
         IndexScan using dept_name_idx on department d
              Index Cond: (dname = 'Sales')

Hvordan lese treet

  • Rotnode er øverst og produserer det endelige resultatet.
  • Innrykk = barn. Barna leverer rader til foreldrene.
  • cost har to tall: oppstart..total, i pseudo-units.
  • rows er optimizerens estimat — sammenlign med faktisk fra ANALYZE.
Praktisk tips

Det viktigste å sjekke i en EXPLAIN ANALYZE: store avvik mellom estimerte og faktiske rader. Et estimat på 10 rader som faktisk gir 100 000 betyr at planen er valgt på sviktende grunnlag — kjør ANALYZE på tabellen.

Sjekk forståelsen
Du ser en plan med SeqScan på en tabell med 5 millioner rader, men du har en indeks på filterkolonnen. Hva kan være årsaken?
Mange muligheter: (1) selektiviteten er for lav — predikatet matcher så mange rader at full scan er billigere, (2) indeksen er unclustered og predikatet treffer mange rader, (3) statistikken er utdatert og optimizeren tror tabellen er liten, (4) datatypen i predikatet matcher ikke indeksen (impliciit cast), (5) funksjonsbruk på kolonnen (WHERE upper(name)=…) hindrer indeksbruk.
10 · Sluttquiz

Test deg selv på hele kapittelet

Tolv blandet vanskelighetsspørsmål. Klikk «Se svar» når du har tenkt.

1 · Lett · Helhetsbilde
Hvilken komponent velger plan, og hvilken kjører den?
Optimizeren velger plan basert på statistikk og kostnadsestimater. Executoren kjører plan-treet rotnoden ned, kaller aksessmetoder mot lagring.
2 · Middels · Aksessmetoder
Hvilke fire grunnleggende aksessmetoder finnes, og hva er forskjellen mellom index scan og index-only scan?
Full table scan, index scan, index-only scan, bitmap scan. Vanlig index scan finner RID-er i indeksen og besøker heap-fila for hver match. Index-only slipper heap-besøket fordi alle nødvendige kolonner ligger i indeksen — vesentlig billigere ved mange treff.
3 · Lett · Lagring
Hvor mange blokker har en heap-fil med 100 000 rader à 80 bytes ved 4 KiB blokker?
Records pr. blokk = ⌊4096/80⌋ = 51. Antall blokker = ⌈100 000/51⌉ = 1961.
4 · Middels · Range-spørring
På samme tabell er det en unclustered B+-indeks på empno. Hvorfor velger optimizeren full table scan for WHERE empno > 80000 (20 % treff)?
Unclustered betyr at radene ligger spredt i heap. 20 % av 100 000 = 20 000 RID-pekere som hver kan kreve ett blokkoppslag. Det blir > 20 000 blokker, mot ~1961 ved full scan. Full scan vinner stort.
5 · Vanskelig · External merge sort
Hvorfor er total I/O for external merge sort 2b · (1 + #merge-passes) og ikke b · #passes?
Hvert pass både leser og skriver hele datasettet — derav 2b. Initialfasen er ett 2b-pass i seg selv. Mergefasen har k passer, hver 2b. Sum = 2b·(1+k).
6 · Middels · Sort-formel
b = 500 blokker, n_B = 6 buffer. Hva blir n_R, d_M og antall passer?
n_R = ⌈500/6⌉ = 84. d_M = 5. Antall mergepasser = ⌈log₅(84)⌉ = 3 (siden 5²=25 < 84 ≤ 5³=125). Total I/O = 2·500·(1+3) = 4000 blokker.
7 · Lett · Nested loop
Hvilken tabell skal være ytre i en block nested loop join?
Den minste. Da scanner vi indre tabell færrest ganger, fordi antall passer over indre er ⌈|ytre| / (n_B−2)⌉.
8 · Vanskelig · NL-kost
R = 200 blokker, S = 5000 blokker, buffer = 12. Block nested loop med R ytre — hva blir kostnaden? Og hvis vi snur?
R ytre: 200 + ⌈200/(12−2)⌉ · 5000 = 200 + 20·5000 = 100 200.
S ytre: 5000 + ⌈5000/10⌉·200 = 5000 + 500·200 = 105 000. R som ytre vinner.
9 · Middels · Sort-merge
Når er sort-merge join klart raskere enn hash join?
(a) Når begge tabeller allerede er sortert på join-attributtet — da unngår vi sort-fasen helt. (b) Når resultatet uansett skal sorteres (ORDER BY på join-attributtet). Hash mister disse fordelene fordi den må regenerere sortert orden.
10 · Vanskelig · Hash join
Hvorfor velger man å bygge hash-tabellen på den minste tabellen?
Hash-tabellen må holdes i minnet (i klassisk hash join). Mindre build-side ⇒ får plass i mindre buffer ⇒ unngår grace-fasen og I/O-kost ved partisjonering. Build-fasen er én scan av build-side; probe-fasen er én scan av probe-side. Strukturelt symmetrisk i I/O, men hash-tabellens minneforbruk avgjør hvilken side som passer.
11 · Middels · Selektivitet
Tabellen har V(city) = 50 distinkte byer. Estimer selektiviteten av WHERE city = 'Trondheim' uten histogram.
sel ≈ 1/50 = 0.02 (2 %). Antakelsen er uniform fordeling — den feiler om Oslo har halvparten av radene. Det er nettopp slik skjevhet histogrammer fanger opp.
12 · Vanskelig · EXPLAIN
EXPLAIN ANALYZE viser rows=12 men actual rows=180000 for et SeqScan-trinn. Hva er konsekvensen lenger oppe i planen, og hva gjør du?
Optimizeren har valgt videre planoperatorer (typisk en nested loop) basert på troen om at output er < 20 rader. Med 180 000 rader får man kvadratisk eksplosjon i øvre nivå. Tiltak: kjør ANALYZE table for å oppdatere statistikk, vurder histogram med flere bins, og se om predikatet bruker funksjon/cast som hindrer indeksbruk.
Videre lesning

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.