Kapittel 6 · 6B · Forelesning 12

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.

01 · Hvorfor B+ over B

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.

Med fanout f og N records: høyden = ⌈logf N⌉

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:

  1. Mindre interne noder — siden de bare holder nøkler + pekere, får de plass til mer per blokk → høyere fanout → kortere tre.
  2. 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.
Tommelfingerregel

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.

Spørsmål 1 · Lett
En tabell har 10 millioner rader, og hver bladblokk i et B+-tre rommer 50 records. Indeksblokker har fanout 100. Hvor høyt blir treet?
Bladnivå: 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.
02 · Struktur

Rot, interne noder, blader

14 27 ROOT (level 1) 2 5 7 14 16 22 27 33 41 leaf-link leaf-link ← < 14 · 14–26 · 27 → Interne noder (level > 0): nøkler er separatorer; pekere går til barn-blokker. Bladnoder (level 0): alle indekserte data ligger her, sortert. Pekere går til neste blad. Bladlenker: dobbeltlenket — gjør både forover- og bakover-skanning mulig.
Et toy-B+-tre. Pilen til høyre for nøkkel «14» peker til barn-blokken med nøkler ≥ 14. Bladene er lenket sammen for sekvensielle scans.

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⌉ − 1 til d − 1 nø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.

Spørsmål 2 · Middels
I et B+-tre med orden d = 100 (maks 100 pekere per intern node), hvor mange records kan treet holde med høyde 3 (rot + 2 mellomnivå + blader), hvis hvert blad rommer 50 records og fyllingsgraden er 100 %?
Antall blader = 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.
03 · Søk

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

14 27 2 5 7 14 16 22 27 33 41 Søk: key = 16 1. I roten: 14 ≤ 16 < 27, gå til midt-barn. 2. Bladet [14, 16, 22] inneholder 16 — funnet i 2 blokk-aksesser.
Søk etter 16. Roten viser veien til midt-bladet.

Range-scan — den virkelige superkraften

For WHERE empno BETWEEN 14 AND 35:

  1. Tre-traversering ned til 14 (3 blokker).
  2. Lever ut alle records i bladet ≥ 14.
  3. 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.

Spørsmål 3 · Middels
Hvorfor er bladlenker bidireksjonale (peker både fram og tilbake)?
Slik at begge retninger av range-scan blir like billige. Med 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.
04 · Interaktivt B+-tre

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

Tomt tre. Sett inn en nøkkel for å starte. Bratsbergs sekvens: 2, 5, 14, 22, 27, 33, 3, 7, 16, 24.

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.

Spørsmål 4 · Vanskelig
Treet vokser oppover, ikke nedover. Hvorfor er det viktig at det vokser kun når roten splittes — og ikke i bladene?
Fordi det er den eneste måten å holde treet balansert på. Hvis blader kunne gå dypere mens andre forblir grunne, ville søketider variere. Ved å la splittinger forplante seg oppover og kun introdusere nytt nivå når roten må splittes, garanterer vi at alle blader alltid er på samme avstand fra roten — det er det som gjør O(log_d N) garantert, ikke bare gjennomsnittlig.
05 · Innsetting og splitt

Splitting fra blad og oppover

Algoritmen i fire trinn

  1. Søk nedover treet til riktig blad (akkurat som ved oppslag).
  2. Plass i bladet? Sett inn sortert. Ferdig.
  3. Ikke plass? Splitt bladet i to. Halvparten av nøklene flytter til ny høyre blokk. Lenk inn i bladkjeden.
  4. «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.

FØR INSERT 24 5 14 27 14, 16, 22 FULL SPLITT → ETTER SPLITT 5 14 22 27 14, 16 22, 24 Bladblokken [14, 16, 22] hadde plass for 3 nøkler. Insert 24 → splitt: [14, 16] og [22, 24]. 22 (minste i ny blokk) kopieres opp til intern node mellom 14 og 27. Hvis intern node hadde 4 nøkler nå → ville den selv splittes, og midt-nøkkelen ville flyttes ett nivå opp.
Bladsplitt — separator-nøkkelen «22» kopieres opp til foreldrenoden men finnes også i bladet.

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.
Spørsmål 5 · Middels
En insert i et stort B+-tre med 4 nivåer fyller bladet og forårsaker en splitt. Foreldrenoden har plass. Hvor mange blokker må skrives til disk i verste fall?
3 blokker: det gamle bladet (oppdatert med færre nøkler), den nye bladblokken, og foreldrenoden (med ny separator + ny barn-peker). Pluss eventuelt nabobladet hvis vi måtte oppdatere prev-pekeren der. Når foreldrenoden også må splittes, kan kostnaden vokse oppover treet — men i praksis splittes ikke alle nivåer samtidig særlig ofte.
Spørsmål 6 · Vanskelig
Hvorfor er fyllingsgraden i et B+-tre statistisk 2/3 i stedet for 100 % når data settes inn i tilfeldig rekkefølge?
Hver gang en blokk splittes, halveres den. De to halvdelene fylles etter hvert opp av nye inserts, men i snitt vil fordelingen mellom «nettopp splittet (50 % full)» og «på vei til full (95 % full)» konvergere til ca. 69 % = ln(2). Bratsberg bruker 2/3 ≈ 67 % som praktisk approksimasjon. Hvis du derimot setter inn i strengt sortert rekkefølge (auto-increment ID), vil siste blokk alltid være den som splittes, og venstresiden blir 100 % full.
06 · Sletting

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

  1. Søk til bladet, fjern nøkkelen.
  2. Hvis bladet fortsatt har ≥ ⌈d/2⌉−1 nøkler — ferdig.
  3. Ellers: underflow. To valg: låne en nøkkel fra et søsken (redistribusjon), eller slå sammen med søsken (merge).
  4. Merge fjerner en separator i foreldrenoden — som kan forårsake underflow der. Propagér oppover.
  5. 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.

Pensum

Bratsberg dekker ikke detaljert sletting i kapittel 10 — kun det konseptuelle. På eksamen er fokuset på innsetting og søk.

Spørsmål 7 · Lett
Hva betyr underflow i et B+-tre?
At en node etter sletting har færre enn minimum antall nøkler — typisk under ⌈d/2⌉−1. Da må den enten låne en nøkkel fra søsken (redistribuering) eller slås sammen med søsken (merge) for å holde balanse-invarianten. Underflow er motstykket til overflow ved insert (som krever splitt).
07 · Clustered vs unclustered

Hvor ligger dataen — i indeksen eller ved siden av?

CLUSTERED B+-TRE 14 · 27 BLAD = RAD 2,Per,30 5,Kari,25 BLAD = RAD 14,Anne,40 22,Bjørn,35 BLAD = RAD 27,Lise,28 33,Ole,45 Hele raden ligger i bladet. Maks én clustered indeks per tabell. UNCLUSTERED B+-TRE 14 · 27 BLAD = RID 2→(B12,3) 5→(B07,1) BLAD = RID 14→(B33,2) 22→(B12,1) BLAD = RID 27→(B07,3) 33→(B33,1) HEAPFIL (usortert) B07: Kari,Lise · B12: Per,Bjørn · B33: Anne,Ole Bladene har RID-pekere inn i heapen. Mange slike per tabell. Range-scan = potensielt random I/O i heapen!
Til venstre: clustered — bladene er tabellen. Til høyre: unclustered — bladene peker inn i en separat heapfil.
ClusteredUnclustered
Maks per tabell1Mange
LikhetssøkHøyde av treHøyde + 1 (heap-aksess)
Range-scanSekvensiell, super-raskRandom I/O i heap, kan være tregere enn full scan
Insert-kostnadSplitt forplanter seg gjennom hele tabellenHeap-insert er O(1), bare indeksen må oppdateres
Brukes iInnoDB primary key, MS SQL Server clustered indexMyISAM, alle sekundærindekser
Spørsmål 8 · Vanskelig
En tabell 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?
Optimizeren bruker customerId-indeksen for å finne alle RID-er for kunde 42 (raskt). For hver RID må den hente raden fra clustered tree (random aksesser, høyde + 1 per RID). Tilslutt må resultatet sorteres etter orderId. Hvis det er mange treff (f.eks. > 10 % av tabellen), kan optimizeren velge full scan av clustered tree i stedet — den er sortert på orderId fra før, så ORDER BY blir gratis, og vi unngår random I/O.
08 · Sparse vs dense

É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.

SPARSE — krever clustered data 2 → 14 → 27 → 2,5,7 B12 14,16,22 B07 27,33,41 B33 3 indeksposter for 9 records. Søk etter 16: finn høyeste oppføring ≤ 16 (=14), gå til den blokken, søk innenfor. DENSE — funker både clustered og unclustered 2 5 7 14 16 22 27 33 41 ↓ pekere til hver enkelt rad ↓ heap (kan ligge i hvilken som helst rekkefølge) 9 indeksposter for 9 records. Indeksen er større, men virker uavhengig av om dataen er sortert.
Husk

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?

Spørsmål 9 · Middels
Hvorfor er en sparse indeks alltid mindre enn en dense indeks for samme tabell?
Fordi sparse har én oppføring per blokk i stedet for én per record. Hvis hver blokk holder f.eks. 50 records, har en sparse indeks bare 1/50 så mange oppføringer. Dette gjør indeksen kompakt nok til å holdes i minnet, og treet blir lavere. Men den krever clustered fysisk lagring, så den er begrenset til primærnøkkelen i en clustered tabell.
09 · Komposittnøkler

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.

SØKEKEY (dno, age) (2, 24)(2, 35)(2, 51) (3, 28)(3, 42) (4, 30) (4, 51) (4, 60) SØKEKEY (age, dno) (24, 2) (28, 3) (30, 4) (35, 2) (42, 3) (51, 4) (51, 2) (60, 4) Spørring: dno=4 AND age>25. Treffene er gulet ut.
Med (dno, age): treffene ligger sammenhengende → ett oppslag + lokal scan. Med (age, dno): treffene er spredt → må filtreres.

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.
Spørsmål 10 · Vanskelig
Når vil man ha range-attributtet først i en komposittnøkkel?
Når spørringen er 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.
10 · Kostnadsregning

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

records per blokk = floor(4096/80) = 51
antall blokker = ceil(100 000 / 51) = 1961

2 · Clustered B+-tre

Med 2/3 fyllingsgrad:
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

Heapfilen: 1961 blokker (samme som rent heap)
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
Læring fra tabellen

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.

Spørsmål 11 · Vanskelig
Du har samme tabell men nå med 10 millioner rader (alt annet likt). Hvor mange nivåer trenger en clustered B+-tre? Vis utregningen.
Bladblokker: 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.
Spørsmål 12 · Vanskelig
For tabellen med 100 000 rader: spørringen 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?
(a) Clustered: 3 (tre-traversering) + ceil(500/34) ≈ 15 (sekvensielle blader) = 18 blokker.
(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.
Veien videre

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.
Neste kapittel

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.