B+-trær og indekser
Bratsbergs notater kap 9–11. Hvorfor B+-treet er det udiskutable valget for sekundærlagring — og hvordan å regne kostnader på det.
Bredde-først, ikke dybde-først
Et binærtre i RAM har O(log₂ N) dybde — flott. Men på disk er hver tre-traversering en ny diskaksess, og log₂(1 000 000) = 20 disk-aksesser per oppslag er uakseptabelt. Løsningen: lag en node som er stor nok til å fylle en hel blokk, slik at den kan ha mange barn.
f = 200, N = 1 000 000 → høyde ≈ 2.6 → 3 disk-aksesser
Hva skiller B+ fra et vanlig B-tre?
- I et B-tre kan en intern node holde data. Søket terminerer når det treffer nøkkelen, hvor som helst i treet.
- I et B+-tre ligger alle data i bladene. Interne noder er bare separatorer — guide nedover. Hver søk går alltid til bladet.
Hvorfor er det bedre? To grunner:
- Mindre interne noder — siden de bare holder nøkler + pekere, får de plass til mer per blokk → høyere fanout → kortere tre.
- Bladene kan lenkes sammen — sortert sekvens av alle records. Etter ett tre-oppslag kan vi følge bladlenkene sidelengs for å hente et range billig. Det kan ikke et B-tre gjøre.
Et typisk B+-tre har 3 eller 4 nivåer. Selv på milliarder av rader er treet sjelden dypere enn 5. Det er hele poenget.
ceil(10 000 000 / 50) = 200 000 blader. Et nivå over: ceil(200 000/100) = 2 000. Neste: ceil(2000/100) = 20. Neste: ceil(20/100) = 1 rot. Totalt 4 nivåer (level 0–3). Et likhetssøk koster 4 disk-aksesser.Rot, interne noder, blader
Reglene som holder treet balansert
- Hver node er én blokk (én disk-aksess å lese).
- Orden d (= max antall pekere):
⌈d/2⌉ ≤ antall pekere ≤ d. - Dermed har hver intern node
⌈d/2⌉ − 1tild − 1nøkler. - Roten er unntak: kan ha så lite som 2 pekere (ellers ville ikke et tomt tre vært lov).
- Alle blader er på samme nivå — det gjør treet selvbalanserende.
Hva ligger i bladene? Avhenger av cluster-typen
Clustered B+-tre
Bladene er tabellen — de inneholder hele radene sortert etter nøkkelen. Brukes i MySQL InnoDB og Microsoft SQL Server (når en primary key er definert).
Unclustered B+-tre
Bladene inneholder (nøkkel, RID) — pekere inn i en separat heapfil. Brukes i MyISAM, Postgres, og som «sekundærindeks» i alle DBMS-er.
100 × 100 = 10 000 (rot peker til 100 noder, hver til 100 blader). Records per blad = 50 → 500 000 records. Med realistisk 2/3 fyllingsgrad: ca. 500 000 × 2/3 ≈ 333 000.Topp ned, alltid til bladet
Søkalgoritmen er ren rekursjon: i hver node, sammenlign nøkkelen med separator-nøklene og gå til riktig barn. Når du når bladet, gjør binærsøk innenfor blokken.
function search(node, key):
if node er blad:
return binarySearch(node.keys, key) // finn key eller posisjon
else:
i ← første index hvor key < node.keys[i] // (eller node.keys.length)
return search(node.children[i], key)
Tre eksempler — på samme tre
Range-scan — den virkelige superkraften
For WHERE empno BETWEEN 14 AND 35:
- Tre-traversering ned til 14 (3 blokker).
- Lever ut alle records i bladet ≥ 14.
- Følg leaf-link til neste blad. Lever ut. Stopp når en nøkkel > 35 møtes.
Total kostnad: 3 + ⌈(antall treff)/(records per blad)⌉ blokker. Helt minimal — vi leser bare blader som inneholder treff.
WHERE empno < 14 traverserer vi ned til 14 og går så bakover via prev-pekerne. Også ORDER BY ... DESC blir nesten gratis — bare gå til høyre ende av treet og følg prev-lenkene.Bygg ditt eget B+-tre
Sett inn nøkler og søk i treet under. Treet har orden 4 — hver blokk holder maks 3 nøkler. Når en blokk fylles, splittes den; hvis dette propagerer opp til roten, vokser treet med ett nivå.
Visualisering — orden 4
Tips: Kjør Bratsbergs eksempel-sekvens trinnvis (start med tilbakestilling, deretter sett inn 2, 5, 14, 22, 27, 33, 3, 7, 16, 24 i denne rekkefølgen) — du vil se nøyaktig de samme trinnene som notatene viser.
Splitting fra blad og oppover
Algoritmen i fire trinn
- Søk nedover treet til riktig blad (akkurat som ved oppslag).
- Plass i bladet? Sett inn sortert. Ferdig.
- Ikke plass? Splitt bladet i to. Halvparten av nøklene flytter til ny høyre blokk. Lenk inn i bladkjeden.
- «Promote» den minste nøkkelen i den nye blokken til foreldrenoden. Hvis foreldrenoden også blir full → splitt rekursivt. Hvis roten splittes → nytt rotnivå.
Det viktige skillet: copy vs move
Når en bladnode splittes, kopieres separator-nøkkelen oppover — den må fortsatt finnes i bladet (det er der dataene ligger).
Når en intern node splittes, flyttes den midterste nøkkelen oppover — den var bare en separator likevel, og det er ingen vits å duplisere den.
Hvorfor splittes blokken før innsettingen, ikke etter?
Bratsberg påpeker en nyanse: ved en split flyttes halvparten av nøklene til den nye blokken, deretter settes den nye nøkkelen inn i den blokken som har plass. Dette virker rart — hvorfor ikke bare splitte de 4 nøklene jevnt etterpå?
Grunnen er recovery og update-håndtering:
- Splittet er en strukturell endring, uavhengig av selve innsettingen.
- Hvis ein VARCHAR-update gjør en eksisterende record større slik at blokken blir for liten, må vi splitte uten at det er en ny nøkkel involvert.
- Recovery-loggen kan da ha ett record for splitten og ett for innsettingen — separat redo og atomicitet.
Underflow → låne eller slå sammen
Sletting er teorien speilbildet av innsetting. I praksis tar de fleste DBMS-er en enklere vei.
Den teoretiske algoritmen
- Søk til bladet, fjern nøkkelen.
- Hvis bladet fortsatt har ≥ ⌈d/2⌉−1 nøkler — ferdig.
- Ellers: underflow. To valg: låne en nøkkel fra et søsken (redistribusjon), eller slå sammen med søsken (merge).
- Merge fjerner en separator i foreldrenoden — som kan forårsake underflow der. Propagér oppover.
- Hvis roten ender med kun ett barn — den nye roten er det barnet. Treet krymper.
Det praktiske kompromisset
Mange DBMS-er hopper over hele underflow-logikken og bare lar slettede slots være tomme. En egen compaction-tråd kjører i bakgrunnen og slår sammen blokker når de blir altfor tomme. Andre DBMS-er bruker delete-at-empty-block: blokken slettes kun når den er helt tom. Forenkler ekstremt mye men gir litt dårligere fyllingsgrad.
Bratsberg dekker ikke detaljert sletting i kapittel 10 — kun det konseptuelle. På eksamen er fokuset på innsetting og søk.
Hvor ligger dataen — i indeksen eller ved siden av?
| Clustered | Unclustered | |
|---|---|---|
| Maks per tabell | 1 | Mange |
| Likhetssøk | Høyde av tre | Høyde + 1 (heap-aksess) |
| Range-scan | Sekvensiell, super-rask | Random I/O i heap, kan være tregere enn full scan |
| Insert-kostnad | Splitt forplanter seg gjennom hele tabellen | Heap-insert er O(1), bare indeksen må oppdateres |
| Brukes i | InnoDB primary key, MS SQL Server clustered index | MyISAM, alle sekundærindekser |
Order har clustered indeks på orderId (primary key) og en unclustered indeks på customerId. For spørringen SELECT * FROM Order WHERE customerId = 42 ORDER BY orderId — hva skjer?Én oppføring per record, eller per blokk
To begreper som ofte forveksles, men dekker forskjellige ting:
Dense indeks
Én indekspost per record i tabellen. Den er ofte stor, men nødvendig hvis indeksen ikke er på den fysiske sorteringsnøkkelen — vi kan ikke spare på posten siden vi ikke kan «hoppe inn i blokken» på en gjettet plass.
Sparse indeks
Én indekspost per blokk. Indeksposten har minste nøkkelen i blokken. Bare mulig hvis dataen er fysisk sortert — vi finner riktig blokk, leser den, og søker innenfor.
Sekundærindekser er alltid dense. Hvorfor? Fordi de ikke styrer den fysiske sorteringen — det gjør clustered indeksen. En sparse sekundærindeks ville vært ubrukelig: hvilken blokk skulle den peke til, når dataen er sortert etter en annen nøkkel?
Når nøkkelen består av flere attributter
En sammensatt nøkkel som (dno, age) sorterer leksikografisk: først på dno, deretter på age innenfor samme dno. Dette har enorm konsekvens for hvilke spørringer som er raske.
Regelen
Plasser det mest selektive attributtet først — eller mer presist: det som har likhets-betingelse i WHERE må komme før det som har range-betingelse.
For WHERE dno = 4 AND age > 50:
- Med
(dno, age): traverser ned til (4, 50), skann sidelengs så lenge dno=4 og age stiger. Lite I/O. - Med
(age, dno): traverser ned til (51, …), skann sidelengs alle records med age > 50, filtrer ut de som har dno=4. Mye mer I/O.
WHERE age BETWEEN 30 AND 40 ORDER BY dno — med (age, dno) får vi range-en effektivt og dno er sortert innenfor. Eller når age er mye mer selektiv enn dno (f.eks. age er en tidsstempel og dno har kun 5 verdier). Generelt: indeks-design krever kjennskap til hvilke spørringer som faktisk kjører ofte, ikke bare til skjemaet.Eksempelet du må kunne på eksamen
Dette er Bratsbergs kanoniske eksempel. Lær å regne det — alle eksamensoppgaver om B+-trær følger samme mønster.
Tabellen
Employee(empno, name, age, depno, salary) — 100 000 rader, 80 byte per record, 4 KiB blokker.
1 · Heapfil
antall blokker = ceil(100 000 / 51) = 1961
2 · Clustered B+-tre
records per bladblokk = floor(4096 · 2/3 / 80) = floor(34.13) = 34
antall bladblokker = ceil(100 000 / 34) = 2942
Indekspost (empno + BlockId) ≈ 16 byte
indeksposter per blokk = floor(4096 · 2/3 / 16) = 170
Nivå 1: ceil(2942/170) = 18 blokker
Nivå 2: ceil(18/170) = 1 blokk (rot)
Tre med 3 nivåer (level 0, 1, 2)
3 · Heap + unclustered B+-tre
Indekspost: (empno, RID) ≈ 16 byte
indeksposter per bladblokk = floor(4096 · 2/3 / 16) = 170
Nivå 0: ceil(100 000/170) = 589 blokker
Nivå 1: ceil(589/170) = 4
Nivå 2: 1 (rot)
Tre med 3 nivåer + heapfil
Kostnader for ulike spørringer
| Spørring | Heap | Clustered B+ | Heap + B+ | Hash |
|---|---|---|---|---|
SELECT * |
1961 | 2942 | 1961 | 2500 |
WHERE empno = c |
981 (snitt) | 3 | 3 + 1 = 4 | 1.2 |
WHERE empno > c (20 % treff) |
1961 | 3 + 588 = 591 | 3 + 118 + 20 000 = 20 121 | 2500 |
Legg merke til 20 121 blokker for unclustered range-spørringen. Dette er hvorfor optimizere ofte velger full table scan i stedet for å bruke en sekundærindeks når selektiviteten er lav. Clustered-tre er 34× raskere for samme range-spørring.
ceil(10 000 000 / 34) = 294 118.Nivå 1:
ceil(294 118 / 170) = 1731.Nivå 2:
ceil(1731 / 170) = 11.Nivå 3:
ceil(11/170) = 1 (rot).4 nivåer (level 0–3). Selv med 100× så mye data går vi bare fra 3 til 4 nivåer — det er styrken i høy fanout.
SELECT * FROM Employee WHERE empno BETWEEN 50000 AND 50500 — som returnerer ~500 rader. Hvor mange blokker leses med (a) clustered B+, (b) unclustered B+ + heap?(b) Unclustered + heap: 3 (tre) + ceil(500/170) ≈ 3 (indeks-blader for rangen) + 500 (én random aksess per record) = 506 blokker.
Forskjellen: 18 vs 506 — 28× raskere på clustered. Generelt: jo større range, jo bedre står clustered seg mot unclustered.
Du kan nå:
- Tegne et B+-tre etter en gitt innsettingssekvens, med splittinger.
- Regne høyde, antall blokker og forventet I/O for likhets- og range-spørringer.
- Velge mellom clustered og unclustered indeks ut fra spørringsmønstre.
- Forklare når sparse indeks er mulig og når den ikke er det.
- Designe en komposittnøkkel ut fra hvilke spørringer som skal være raske.
Kapittel 7 bruker disse strukturene til å bygge en query-kjøremotor: hvordan optimalisatoren velger mellom B+-tre-traversering, hash join, og full scan basert på selektivitet og statistikk.