Alle ti kjører fint på utviklermaskinen med tusen rader. Det er i produksjon med ti millioner at forskjellen kommer.

1. Funksjon på kolonnen i WHERE-klausulen

Dette er den vanligste, og den dyreste:

-- Skanner hele tabellen
WHERE YEAR(opprettet) = 2025

-- Kan bruke indeks på opprettet
WHERE opprettet >= '2025-01-01'
  AND opprettet <  '2026-01-01'

Så snart du pakker kolonnen inn i en funksjon, kan ikke optimalisatoren bruke indeksen på den. Den må beregne uttrykket for hver rad, og da leser den alle radene. Samme problem gjelder UPPER(navn) = 'OLA' og CAST(kundenr AS varchar) = '1004'. Regelen er enkel: hold venstre side av sammenligningen ren, flytt beregningen over på verdien du sammenligner med.

2. Ledende jokertegn i LIKE

LIKE 'Ola%' kan bruke en indeks, fordi den kjenner starten på strengen og kan slå opp i den sorterte strukturen. LIKE '%Ola%' kan ikke. Den må sjekke hver enkelt rad. Trenger du søk midt i tekst på en stor tabell, er svaret fulltekstindeksering, ikke en større server.

3. SELECT * i alt som går videre over nettverket

Problemet er sjelden radene, det er kolonnene. Dersom spørringen bare trenger id og navn, og begge ligger i en dekkende indeks, kan databasen svare uten å røre selve tabellen. Legg til en nvarchar(max)-kolonne med fritekst i samme SELECT, og den må hente hver rad fra grunntabellen og sende innholdet over nettverket. Samme spørring, helt annen kostnad.

I tillegg gjør SELECT * koden skjør: legger noen til en kolonne i tabellen, endrer resultatformen uten at noen ba om det.

4. N+1-spørringer fra ORM-et

Denne kommer sjelden fra SQL-en, den kommer fra koden over. Du henter hundre ordre, går gjennom listen og leser ordre.Kunde.Navn inne i løkken. Resultatet er én spørring for ordrene og hundre for kundene.

// 1 + N spørringer
var ordre = db.Ordre.Where(o => o.Aktiv).ToList();
foreach (var o in ordre)
    Console.WriteLine(o.Kunde.Navn);

// 1 spørring
var ordre = db.Ordre
              .Where(o => o.Aktiv)
              .Include(o => o.Kunde)
              .ToList();

Hver enkeltspørring er rask, så ingenting ser galt ut i en logg som måler gjennomsnitt. Det er summen av rundturer som velter responstiden. Slå på logging av genererte spørringer i testmiljøet — det er den raskeste måten å avdekke dette.

5. Indekser som aldri blir brukt

Indekser er ikke gratis. Hver av dem må oppdateres ved INSERT, UPDATE og DELETE, og tar plass i minnet som ellers kunne holdt data. Tabeller som har vokst gjennom flere år med utviklere har ofte tre indekser som overlapper og to ingen spørring har brukt siden 2021.

SQL Server fører statistikk over dette i sys.dm_db_index_usage_stats. Tallene nullstilles ved omstart, så les dem etter at serveren har vært oppe en stund og gjennom en normal måned — noe brukes bare i månedsrapporten. Fjern det som beviselig ikke brukes, én indeks av gangen, med mulighet for å legge den tilbake.

6. Implisitt konvertering: feilen som ikke ser ut som en feil

Denne fortjener eget avsnitt, fordi den er usynlig i koden og brutal i produksjon. Kolonnen er varchar(20), parameteren kommer inn som nvarchar fra .NET, som er standard når du sender en C#-streng. Databasen kan ikke sammenligne de to typene direkte, så den konverterer kolonnen — hver rad — og indeksen blir ubrukelig.

-- Kolonnen kundenr er varchar(20).
-- Denne konverterer hele kolonnen for hver rad:
WHERE kundenr = @nvarcharParameter

-- I .NET: oppgi typen eksplisitt
cmd.Parameters.Add("@kundenr", SqlDbType.VarChar, 20).Value = kundenr;

Kjøreplanen viser dette som en advarsel om CONVERT_IMPLICIT. Symptomet i produksjon er en spørring som plutselig bruker sekunder i stedet for millisekunder, uten at noen har endret spørringen — den ble bare kalt fra ny kode med annen parametertype.

7. OR i WHERE-klausulen

Optimalisatoren håndterer OR dårligere enn folk regner med. En spørring som skal treffe på enten e-post eller telefonnummer, ender ofte med å skanne fordi ingen enkelt indeks dekker begge alternativene. To spørringer forent med UNION ALL kan bruke én indeks hver og bli dramatisk raskere:

-- Ofte én skanning
WHERE epost = @verdi OR telefon = @verdi

-- To seek-operasjoner
SELECT ... FROM kunde WHERE epost   = @verdi
UNION ALL
SELECT ... FROM kunde WHERE telefon = @verdi;

Dette er ikke en regel om at OR alltid er galt. Det er en påminnelse om å se på kjøreplanen når en OR-spørring er treg, før du konkluderer med at tabellen er for stor.

8. Paginering med OFFSET på dype sider

OFFSET 100000 ROWS FETCH NEXT 20 ROWS må hente og forkaste hundre tusen rader for å gi deg tjue. Det er billig på side 2 og dyrt på side 5000. I et administrasjonsgrensesnitt der ingen går forbi side tre, er det greit. I et API som lar en klient paginere gjennom hele tabellen, er det en garantert ytelsesklage.

Alternativet er nøkkelbasert paginering: husk siste id fra forrige side og hent videre derfra med WHERE id > @sisteId ORDER BY id. Da koster side 5000 det samme som side 1. Ulempen er at du mister muligheten til å hoppe direkte til en vilkårlig side, noe som sjelden er et reelt behov i et API.

9. Indeksrekkefølge betyr noe

En sammensatt indeks på (kunde_id, opprettet) hjelper en spørring som filtrerer på kunde og sorterer på dato. Den hjelper ikke en spørring som bare filtrerer på dato, for da må den lese hele indeksen likevel — kolonnene er sortert etter kunde først. Dette er den vanligste grunnen til at en tabell har «indeks på feltet» og likevel skanner.

Tenk på det som en telefonkatalog sortert på etternavn og deretter fornavn. Du finner alle som heter Hansen raskt. Du finner ikke alle som heter Ola uten å lese hele boka. Rekkefølgen i indeksdefinisjonen er derfor et designvalg, ikke en detalj — sett kolonnen du filtrerer på med likhet først, og kolonnen du sorterer eller søker på i intervall etterpå.

10. Statistikk og parameterfølsomhet

Optimalisatoren velger plan ut fra statistikk over datafordelingen, og gjenbruker planen for senere kall med andre parameterverdier. Dersom kundenummer 1 har tre ordre og kundenummer 2 har to millioner, er den optimale planen ulik for de to, men den som kjørte først bestemmer. Symptomet er en lagret prosedyre som er rask for de fleste og uforklarlig treg for noen få.

Det finnes flere motgifter — oppdatert statistikk, OPTION (RECOMPILE) på spørringen der det er verdt kompileringskostnaden, eller å dele opp prosedyren. Poenget her er å kjenne igjen mønsteret: når ytelsen avhenger av hvem som spør og ikke av hva som spørres om, er det plangjenbruk du ser på, ikke en treg spørring.

Les kjøreplanen, ikke gjett

Alle mønstrene over er nettopp det — mønstre, ikke lover. Den eneste pålitelige måten å vite hva som skjer, er å se på den faktiske kjøreplanen for spørringen med produksjonslignende datamengde. Se etter table scan der du forventet index seek, og etter store avvik mellom estimert og faktisk radantall — det siste betyr som regel at statistikken er utdatert, og da tar optimalisatoren gode avgjørelser på feil grunnlag.

En kjøreplan leses nedenfra og innover: operatorene lengst inne kjører først. Se etter tre ting. Først operatortypen — Clustered Index Scan på en tabell med millioner rader, der du forventet Index Seek, betyr at ingen brukbar indeks ble funnet eller at predikatet ikke kunne brukes. Deretter radanslagene: står det estimert 40 rader og faktisk 2,4 millioner, har optimalisatoren valgt strategi ut fra feil forutsetning, og alt den valgte etterpå er sannsynligvis feil også. Til sist advarslene — det gule utropstegnet betyr som regel implisitt konvertering eller søl til disk under sortering, og begge er verdt å rydde.

En praktisk arbeidsrekkefølge

Når du får en treghetsklage: finn den faktiske spørringen som er treg, ikke den du tror det er. Hent kjøreplanen med produksjonslignende datamengde. Se på operatortype, radanslag og advarsler i den rekkefølgen. Rett én ting. Mål igjen. Det kjedelige med ytelsesarbeid er at det ikke finnes noen snarvei rundt målingen — men det gode er at de ti mønstrene over dekker de aller fleste tilfellene du kommer til å møte.