B+-trær og indekser
Bratsbergs notater kap 9–11. Hvorfor B+-treet er det udiskutable valget for sekundærlagring (alt som ligger på disk, ikke i RAM) — og hvordan å regne kostnader på det.
Notatmapping → §01–06 = Bratsberg-kap 9 · §07–09 = kap 10 (tyngst på eksamen) · §10 = kap 11. Full mapping →
Bredde-først, ikke dybde-først
Et binærtre (hver node har 2 barn) i RAM har log₂ N nivåer — 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.
(⌈x⌉ betyr «rund opp til nærmeste hele tall»)
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 (ingen rad-data), får de plass til mer per blokk → høyere fanout → kortere tre.
- Bladene (de nederste nodene, der dataene faktisk ligger) kan lenkes sammen i en 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 = maks antall pekere (barn) hver node kan ha. Hver node må holde minst halve kapasiteten:
⌈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 (når storage-motoren InnoDB brukes — standard) og Microsoft SQL Server (når en primærnøkkel er definert).
Unclustered B+-tre
Bladene inneholder (nøkkel, RID) — pekere inn i en separat heapfil. Brukes i MyISAM (eldre MySQL-engine), PostgreSQL, og som sekundærindeks (en ekstra indeks i tillegg til primærnøkkelen) 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 (halverende søk i sortert array — log2-tid) 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 BETWEEN 14 AND 35 som krever 3 blokker for tre-traversering ned til 14: hva skjer videre, og hvorfor blir totalkostnaden minimal?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?
Notatene påpeker en nyanse (se kompendium-kap. 10): 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 en 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 (loggen brukt til å gjenopprette etter krasj — kap 8) kan da ha én post for splitten og én for innsettingen — separat redo og atomicitet.
Verdt å huske: rekkefølgen påvirker hvilken nøkkel som propageres
Når den nye nøkkelen havner i midten av bladet, gir konvensjonen «splitt først, sett inn etterpå» et annet resultat enn «sett inn virtuelt, splitt midt på de 7». Eksempel: full løvblokk [10, 20, 30, 40, 50, 60] (kapasitet 6), insert 35.
- Bratsberg-konvensjon (splitt først): halvparten flyttes →
[10, 20, 30]og[40, 50, 60]. 35 settes inn i venstre (35 < 40) →[10, 20, 30, 35]og[40, 50, 60]. Minste i høyre = 40, kopieres opp. - Alternativ (sett inn først, splitt midt på 7): de 7 nøklene blir
[10, 20, 30, 35, 40, 50, 60], splittet midt på →[10, 20, 30]og[35, 40, 50, 60]. Minste i høyre = 35, kopieres opp.
Begge er gyldige B+-trær, men de er ikke samme algoritme. På denne kursen følger vi Bratsberg — så 40 er det som propageres opp i eksempelet over. Innsetting-før-splitt-i-midten er en vanlig fallgrop fordi mange engelskspråklige lærebøker bruker den varianten.
Bygg ditt eget B+-tre
Nå som du kjenner splitt-algoritmen, prøv den selv. Sett inn nøkler og se hvordan blader fylles, splittes og hvordan separator-nøkler propagerer oppover. Treet har orden 4 — hver blokk holder maks 3 nøkler.
Visualisering — orden 4
Tips: Kjør eksempel-sekvensen fra notatene 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 kompendiet viser.
Verifiser: stemmer animasjonen med Bratsberg-algoritmen?
Demo-sekvensen 2, 5, 14, 22, 27, 33, 3, 7, 16, 24 trigger fire bladsplitter (ved insert 22, 33, 7, 24). Tabellen sporer hver splitt både med Bratsberg («splitt først, sett inn etterpå») og med animasjons-koden («sett inn virtuelt, splitt midt på de 4 nøklene»):
| Insert | Full løv | Bratsberg-trinn | Animasjons-trinn | Resultat | Promote |
|---|---|---|---|---|---|
| 22 | [2,5,14] | splitt → [2,5] og [14]; sett inn 22 (22>14) | virtuelt [2,5,14,22]; splitt midt → [2,5] og [14,22] | [2,5] og [14,22] | 14 |
| 33 | [14,22,27] | splitt → [14,22] og [27]; sett inn 33 (33>27) | virtuelt [14,22,27,33]; splitt midt → [14,22] og [27,33] | [14,22] og [27,33] | 27 |
| 7 | [2,3,5] | splitt → [2,3] og [5]; sett inn 7 (7>5) | virtuelt [2,3,5,7]; splitt midt → [2,3] og [5,7] | [2,3] og [5,7] | 5 |
| 24 | [14,16,22] | splitt → [14,16] og [22]; sett inn 24 (24>22) | virtuelt [14,16,22,24]; splitt midt → [14,16] og [22,24] | [14,16] og [22,24] | 22 |
Alle fire splitter gir identisk resultat under begge konvensjoner. Grunnen: i denne sekvensen lander hver nye nøkkel som størst i bladet sitt — da spiller det ingen rolle om man splitter før eller etter. Forskjellen mellom konvensjonene synes først når en ny nøkkel lander mellom eksisterende nøkler i et fullt blad (jf. eksempelet med 35 inn i [10, 20, 30, 40, 50, 60] over).
Konklusjon: animasjonen viser Bratsberg-trinnene korrekt for hele demo-sekvensen, og de fire bladsplittene matcher kompendiet 1:1.
Praktisk håndtering av sletting
Pensum behandler ikke detaljert sletting — notatene skriver eksplisitt at sletting ikke vises i detalj («we do not show deletions in B+-trees»). Du skal kjenne hovedideen om hvordan slettinger håndteres i praksis.
- Compaction-tråd: Vanligst i praksis. En bakgrunnsprosess kompaktérer nivåer i treet og fjerner tomme blokker. Bruker-spørringer fortsetter mens dette skjer.
- Delete-at-empty-block: Forskning har vist at det er nok å slette blokken først når den er helt tom — du trenger ikke en egen management-tråd. Forenkler implementasjon, gir litt dårligere fyllingsgrad.
Notatene dekker ikke detaljert sletting i kompendium-kap. 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 oppover i treet (kan gjøre treet høyere) | 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år nøkkelen består av flere attributter
En sammensatt nøkkel som (dno, age) sorterer leksikografisk (som ord i en ordbok — 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 sekundærindeks når mange rader matcher. 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.
Når skriveytelse betyr alt
B+-trær er kongen for blandet read/write. Men når data strømmer inn raskere enn B+-treet klarer å absorbere — typisk i Big Data-systemer og NoSQL-databaser (ikke-relasjonelle datalagre, som Cassandra og MongoDB) — bruker man LSM-trær (Log-Structured Merge tree).
Hovedidé: skriv stort, sjeldent
- Memtable i RAM — alle nye inserts/updates havner først her, sortert (typisk implementert som en skiplist, en sortert datastruktur).
- WAL (Write-Ahead Log) — hver endring skrives også til en sekvensiell logg på disk for durability (varig lagring — endringer overlever krasj).
- Når memtable blir full: flush til disk som en SST-fil (Sorted String Table). SST-filer er immutable (uforanderlige — skrives én gang, slettes hele).
- Compaction (samling) i bakgrunnen: SST-filer flettes nedover i nivåer (L0 = nyeste/minste filer rett under memtable, L1 = nivået under, osv.) og dupliserte/slettede entries fjernes.
Resultatet er at de fleste skrivinger først treffer RAM, og disk-skrivinger skjer i store, sekvensielle bolker. Lav write amplification + høyere komprimering sammenlignet med B+-trær.
B+-tre vs LSM-tre
B+-tre
Best på read — ett oppslag = treets høyde i I/O. Range-scans er superraske via bladlenker. Skriving er litt dyrere fordi blokken kan splittes og oppdateres in-place.
LSM-tre
Best på write — store sekvensielle skrivinger. Reads kan være tregere fordi nøkkelen kan ligge i memtable, L0, L1 … og må sjekkes flere steder (et Bloom filter — en kompakt sannsynlighets-datastruktur som raskt svarer «sikkert ikke her» — hjelper på read-ytelsen).
Brukt i Google BigTable / LevelDB, Apache HBase, RocksDB, MyRocks (MySQL-storage engine hos Facebook), Apache Cassandra, MongoDB WiredTiger, og SQLite (utvidelse).
Det studenter blander sammen i B+-tre-kapittelet
Disse feilene koster nesten alltid 5 % på eksamen. Les listen etter du har lest 01–10 over; hver punkt nedenfor svarer til ett konkret eksamen-traff fra de siste fem år.
- 2/3 fyllgrad gjelder også interne noder. Mange bruker 2/3 på blader men 100 % på interne. Riktig:
fanout = floor(blokkstr · 2/3 / indekspost-størrelse). Bruk samme regel på alle nivåer. - Indekspost-størrelse: nøkkel + RID i unclustered, nøkkel + BlockId i interne noder. Bladene i en unclustered B+ inneholder
(nøkkel, RID). Interne noder (clustered eller unclustered) inneholder(nøkkel, BlockId)— pekere til underliggende blokker, ikke til records. RID er typisk 12 byte; BlockId 8. Bruk feil og du svarer 5 % feil. - Clustered er ikke raskere på
SELECT *. Et clustered B+-tre har 2/3 fyllgrad og treet legger overhead på topp; flere blokker enn et heap-fil. Clustered shines påWHERE-,BETWEEN- ogORDER BY-spørringer, ikke på fullt scan. - Unclustered + range = potensiell katastrofe. Hvert indeks-treff peker til en tilfeldig blokk i heapen. Hvis 20 % av radene matcher → 20 % × N random aksesser. Ofte er full scan billigere — DBMS-en velger derfor å ignorere indeksen. Husk: clustered koster
høyde + ⌈treff/leafcap⌉, unclustered kosterhøyde + treff. - Index-only scan: når kolonnen er indeksnøkkelen.
SELECT navn ... ORDER BY navnmed unclustered B+ på navn: optimal er å skanne kun bladnivået i indeksen (data ligger der).SELECT epost ... ORDER BY navn: må også til heapen for hver record. Denne distinksjonen er ren eksamenkruttkjegle. - Antall nivåer = antall blokker for likhetsoppslag i clustered. Hvis treet har 3 nivåer, koster ett likhetsoppslag i clustered B+ 3 blokker (ikke 4). Bladet inneholder selve recorden. I unclustered koster det 3 + 1 = 4 (en ekstra heap-aksess).
- Komposittnøkkel-rekkefølge er prefiks-stiv. Indeks
(A, B)støtterWHERE A=?,WHERE A=? AND B=?, ogORDER BY A, B— men ikkeWHERE B=?alene. Det er som ordboka: sortert på første bokstav først. - Range-spørring: hvilken kolonne skal komme sist? For
WHERE customerId = ? AND date BETWEEN ? AND ?, bygg indeksen som(customerId, date)— likhets-attributtet først, range-attributtet sist. Da kan B+-treet finne kunden (et punkt i treet) og scanne sidelengs i bladet for date-rangen. Bytt om → må scanne alle datoer. - Splitt forplanter seg oppover. Når en bladblokk splittes ved insert, oppdateres foreldrenoden med en ny separator + ny barn-peker. Hvis foreldrenoden også blir full, splittes den, og det forplanter seg helt opp til rota i verste fall. Dette er hvorfor en insert kan være dyr (men sjeldent er det).
- Bladlenker er encrypted to leaf level only. Range-scan etter tre-traversering går sidelengs via leaf-link-pekere. Vi går ikke opp i treet igjen mellom blader. Dette er en kjernegrunn til at B+ slår vanlig B-tre på range — i et B-tre må du gå opp og ned for hver blad-overgang.
For hver av de ti — kan du regne et lite eksempel uten å se på løsningen? Hvis ja, du er trygg på 6B-delen. Hvis nei, bruk 09 · Kostnadsregning som drilløvelse: prøv å reprodusere alle utregningene fra hukommelsen.
MCQ — i samme stil som vår 2025
Eksamen i 2026 er kun flervalg, og oppgavene rundt B+-trær og access paths utgjør den største regnedelen av eksamen. Disse 8 spørsmålene har distraktorer som matcher de vanligste regnefeilene fra Bratsberg-eksamenene. Svar — og bli en sikker B+-regner.
Felles scenario for spørsmål 1–6
Tabellen Ansatt(AnsattId, ForNavn, EtterNavn, Lønn) har ansattposter på 120 byte. AnsattId er 4 byte. RID = 12 byte, BlockId = 8 byte. Blokk = 4096 byte. Det finnes en clustered B+ på AnsattId med 1000 bladblokker og 3 nivåer, og en unclustered B+ på EtterNavn med 500 bladblokker og 3 nivåer. 2/3 fyllgrad antas overalt.
floor(4096 · 2/3 / 120) = floor(22.76) = 22. Distraktor 34 er svaret hvis du glemmer 2/3 (floor(4096/120)). 227 er fanout for interne noder med 12-byte indeksposter.SELECT * FROM Ansatt WHERE AnsattId = 12001; — hvor mange blokker aksesseres optimalt?SELECT ForNavn FROM Ansatt WHERE EtterNavn = 'Hansen'; — anta én treff. Hvor mange blokker aksesseres optimalt?SELECT EtterNavn FROM Ansatt ORDER BY EtterNavn DESC; — hvor mange blokker aksesseres optimalt?SELECT * FROM Ansatt WHERE AnsattId BETWEEN 5000 AND 7000; — anta dette gir ~2200 treff. Hvor mange blokker aksesseres optimalt?SELECT * FROM Ansatt WHERE EtterNavn BETWEEN 'A' AND 'E'; — anta 4000 treff. Hvor mange blokker aksesseres optimalt?WHERE customerId = ? AND date BETWEEN ? AND ?. Hvilken sammensatt indeks gir best ytelse?Denne MCQ-banken er kalibrert mot vår 2024 og vår 2025-eksamen. Hvis du klarer alle åtte uten å snuble — du er klar for del 2 av eksamen i lagring og indekser.
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.
- Designe en komposittnøkkel ut fra hvilke spørringer som skal være raske.
- Forklare når LSM-trær slår B+-trær — og hvorfor.
Kapittel 7 bruker disse strukturene til å bygge en spørringsmotor (executor): hvordan optimalisatoren velger mellom B+-tre-traversering, nested loop join og full scan basert på statistikk.