SQL indeks vs brute force – sådan redder du dine queries i tide

SQL indeks vs brute force - sådan redder du dine queries i tide

Første gang jeg så en side bruge 7 sekunder på at loade, var det ikke fordi serveren var lille, eller fordi nogen DDoS’ede min lille hobby-webshop. Det var én enkelt SQL-query, der fik hele lortet til at gå i stå.

Jeg havde lavet en pæn SELECT med flere JOINS, et par LIKE-filtre og lidt ORDER BY. Den virkede jo. Indtil der lå 50.000 rækker i tabellen.

Det er dér, forskellen på “ingen indeks” og “fornuftige indeks” begynder at føles meget konkret.

SQL indeks forklaret som opslag i en bog

Jeg tager den korte version: Et indeks i SQL er en ekstra datastruktur, databasen laver for hurtigt at kunne finde rækker efter en bestemt kolonne (eller flere).

Hvis hele tabellen er en bog, så er et indeks som registret bagerst: et sorteret overblik, så du ikke skal bladre alt igennem hver gang.

Uden indeks: fuld tabelscan

Forestil dig en customers-tabel:

customers
---------
id (PK)
name
email
city
created_at

Hvis du kører:

SELECT *
FROM customers
WHERE email = 'test@example.com';

og der ikke er noget indeks på email, så gør databasen typisk noget i stil med:

  • Start ved første række
  • Tjek om email matcher
  • Gå til næste række
  • Gentag, til tabellen er færdig

Det kaldes en full table scan. Det er fint ved 100 rækker. Ikke fint ved 10 millioner.

Med indeks: direkte opslag

Laver du et indeks på email:

CREATE INDEX idx_customers_email
ON customers(email);

så kan databasen i stedet:

  • Slå 'test@example.com' op i et sorteret indeks
  • Finde referencen til de rækker, der matcher
  • Hente præcis de rækker i tabellen

Det går ofte fra millisekunder-per-1000-rækker til millisekunder-per-million-rækker. Groft sagt.

Indekser er ikke gratis

Der er en pris, og det er vigtigt at have med, også som begynder:

  • Writes bliver langsommere: Hver gang du INSERT eller UPDATE, skal indekserne også opdateres.
  • Plads: Indekser fylder disk (typisk ok, men ikke 0).

Derfor giver det ikke mening bare at indeksere “alt hvad du kan se”. Vi kommer tilbage til, hvad der giver mening.

6 tegn på at din query er ved at blive langsom

Jeg har efterhånden set de samme mønstre mange gange, både i egne projekter og når jeg har hjulpet venner. Der er et par klassiske faresignaler.

1. Queryen bliver mærkbart langsommere, når data vokser

Et simpelt, men overset tegn: ting føles ok i udvikling, men bliver sløve i produktion.

Hvis din SELECT går fra 10 ms til 500 ms bare fordi tabellen vokser fra 1000 til 20.000 rækker, så mangler der ofte et indeks på de kolonner, du filtrerer eller joiner på.

2. CPU topper, selvom du rammer databasen let

Hvis din server bruger masser af CPU, mens én stor query kører, er databasen typisk i gang med at scanne og sortere mange rækker, den egentlig ikke behøvede.

3. EXPLAIN viser “ALL” som type

I MySQL/MariaDB fortæller EXPLAIN dig, hvordan queryen bliver kørt. Hvis du ser type = ALL, betyder det normalt full table scan. Ikke ulovligt, men sjældent optimalt på en stor tabel.

4. Joins på kolonner uden indeks

En klassiker:

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.email = 'test@example.com';

og så har hverken orders.customer_id eller customers.email et indeks. Så skal databasen kigge meget mere end nødvendigt.

5. LIKE med wildcard foran

Så snart du laver noget i stil med:

WHERE name LIKE '%test%'

kan et normalt indeks som regel ikke bruges effektivt, fordi databasen ikke kan starte søgningen fra begyndelsen af strengen.

6. Tung ORDER BY uden relevant indeks

Når du sorterer på en kolonne uden indeks, skal databasen tit sortere store mængder data i hukommelsen. Det kan ses, når du har mange rækker og ORDER BY kombineret med LIMIT.

EXPLAIN: læs planen uden at være DBA

Det mest nyttige værktøj, jeg har lært at bruge, er EXPLAIN. Det ser lidt skræmmende ud, men du behøver kun et par felter for at få meget ud af det.

Lidt webshop-data at lege med

Vi tager et lille setup, du kan køre fx i MySQL, MariaDB eller tilsvarende. Enkel version:

CREATE TABLE customers (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(255),
  email VARCHAR(255),
  city VARCHAR(100)
);

CREATE TABLE products (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(255),
  price DECIMAL(10, 2),
  category VARCHAR(100)
);

CREATE TABLE orders (
  id INT PRIMARY KEY AUTO_INCREMENT,
  customer_id INT,
  created_at DATETIME,
  status VARCHAR(50),
  FOREIGN KEY (customer_id) REFERENCES customers(id)
);

CREATE TABLE order_items (
  id INT PRIMARY KEY AUTO_INCREMENT,
  order_id INT,
  product_id INT,
  quantity INT,
  FOREIGN KEY (order_id) REFERENCES orders(id),
  FOREIGN KEY (product_id) REFERENCES products(id)
);

Forestil dig, at der ligger 50.000 ordrer og 200.000 order_items. Ikke urealistisk, selv på en mindre side.

Basic EXPLAIN-eksempel

Vi vil finde de seneste 20 ordrer for en bestemt kunde:

SELECT o.id, o.created_at, o.status
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.email = 'test@example.com'
ORDER BY o.created_at DESC
LIMIT 20;

Inden vi laver nogen indekser, kører vi:

EXPLAIN
SELECT o.id, o.created_at, o.status
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.email = 'test@example.com'
ORDER BY o.created_at DESC
LIMIT 20;

Hvad kigger du efter i EXPLAIN?

Output varierer lidt mellem databaser, men i MySQL/MariaDB vil du typisk se kolonner som:

  • table (hvilken tabel der nævnes i denne del af planen)
  • type (hvordan den læser rækker: ALL, ref, range osv.)
  • possible_keys (hvilke indekser bruges)
  • key (hvilket indeks der rent faktisk bruges)
  • rows (hvor mange rækker den forventer at skulle kigge på)

Som begynder kan du komme langt ved at stille to spørgsmål:

  1. Står der type = ALL og mange rows på en stor tabel?
  2. Er key tom, selvom jeg filtrerer på en bestemt kolonne?

Før og efter indeks

Hvis der ingen indekser er, udover primære nøgler, vil du ofte se noget ala:

  • table = c, type = ALL, rows = 50000
  • table = o, type = ref eller ALL afhængigt af setup

Vi tilføjer nu to indekser:

CREATE INDEX idx_customers_email
ON customers(email);

CREATE INDEX idx_orders_customer_created
ON orders(customer_id, created_at);

Kører du EXPLAIN igen, vil du forhåbentlig se:

  • table = c, type = ref, key = idx_customers_email, rows = 1 (eller meget få)
  • table = o, type = ref eller range, key = idx_orders_customer_created

Selve tallene afhænger af dine data, men pointen er: databasen kigger på langt færre rækker.

Hvilke kolonner giver mening at indeksere?

Nu til det spørgsmål, jeg selv sad med i starten: hvornår skal man lave et indeks, og på hvad?

Kolonner der typisk er gode kandidater

  • Kolonner du ofte bruger i WHERE, med =, <, >, BETWEEN.
  • Kolonner du joiner på, typisk foreign keys.
  • Kolonner du ofte sorterer på med ORDER BY, især sammen med LIMIT.

Eksempler i webshoppen:

  • customers.email (unik login-identifikator)
  • orders.customer_id (join mellem kunder og ordrer)
  • orders.created_at (seneste ordrer, rapporter over tid)
  • products.category (filtrering på kategori i produktlister)

Kolonner, du ofte ikke skal indeksere

  • Små low-cardinality felter, fx status med 3 mulige værdier, alene.
  • Kolonner der sjældent bruges hverken i WHERE, JOIN eller ORDER BY.
  • Store tekstfelter (TEXT / LONGTEXT) uden meget klare søgebehov.

Low-cardinality betyder, at der er få forskellige værdier i kolonnen. Et indeks på en status med værdierne 'pending', 'paid', 'cancelled' hjælper ikke meget alene. Databasen ender stadig med at kigge på ret mange rækker per værdi.

Samlede (komposit) indekser

Du kan også lave indekser på flere kolonner:

CREATE INDEX idx_orders_customer_status_created
ON orders(customer_id, status, created_at);

Her er rækkefølgen vigtig. Som tommelfingerregel:

  • Sæt de kolonner forrest, som typisk er i WHERE med =.
  • Sæt sorterede kolonner (ORDER BY) senere i indekset.

Et indeks på (customer_id, created_at) kan fx bruges til:

WHERE customer_id = ?
ORDER BY created_at DESC

Men ikke effektivt til:

WHERE created_at = ?

JOIN performance: foreign keys og kardinalitet

JOINs kan være billige eller dyre. Forskellen afhænger ofte af indekser og hvor mange rækker, der egentlig matches.

Indeks på begge sider af joinet

Når du har:

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

så vil du normalt have:

  • Primærnøgle-indekscustomers.id (automatisk)
  • Indeksorders.customer_id (ikke altid automatisk)

Mange ORM’er laver automatisk indeks på foreign keys, men jeg har set projekter, hvor det ikke er sket. Så er det manual arbejde.

CREATE INDEX idx_orders_customer
ON orders(customer_id);

Uden det indeks kan databasen blive nødt til at scanne store dele af orders for at finde matchende kunder.

Kardinalitet: hvor mange forskellige værdier?

Kardinalitet lyder fancy, men handler bare om: hvor mange unikke værdier er der i kolonnen?

  • customer_id i orders: høj kardinalitet (mange kunder).
  • status i orders: lav kardinalitet (få statuser).

Indekser på kolonner med høj kardinalitet er ofte mere nyttige, fordi databasen kan filtrere meget hårdere.

Typisk begynderfejl med JOINs

  • JOIN på tekstfelter (fx email) i stedet for id’er.
  • Manglede indeks på foreign keys.
  • Filtrering på ikke-indekserede kolonner i den store tabel.

Hvis du vil nørde mere join-performance og SQL generelt, har jeg også lavet en artikel om SQL SELECT-øvelser med mini-dataset, hvor du kan få styr på JOIN-mønstrene først.

LIKE, ORDER BY og pagination: her går det tit galt

Tre ting dukker konstant op i langsomme logfiler: søgning, sortering og pagination.

LIKE-søgninger

LIKE 'abc%' kan ofte bruge et indeks på name.

LIKE '%abc%' kan sjældent bruge det samme indeks effektivt, fordi databasen ikke ved, hvor i strengen den skal starte.

I en lille webshop kan du slippe afsted med det. Men hvis du laver et søgefelt, der bruger % foran og bagved, så overvej:

  • Fuldsøgning (FULLTEXT) hvis databasen understøtter det.
  • Begrænse funktionen: fx kun prefix-søgning.

ORDER BY + LIMIT

Det her mønster ser uskyldigt ud:

SELECT *
FROM products
WHERE category = 'Shoes'
ORDER BY created_at DESC
LIMIT 20;

Men uden et indeks på (category, created_at) kan databasen være nødt til:

  • Slå alle category = 'Shoes' op
  • Sortere dem alle
  • Så tage de første 20

Med et passende komposit-indeks kan den i stedet hente de 20 nyeste direkte fra indekset, hvilket er markant hurtigere.

OFFSET pagination

Den klassiske:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

Her er problemet, at databasen ofte skal finde de første 10020 rækker for derefter at smide de 10000 væk.

I små projekter lever man ofte med det, men teknikker som “seek pagination” (hvor du paginerer med sidste id/timestamp) kan være værd at kigge på, hvis din side vokser. Det er et emne til en hel artikel for sig selv.

Mini-øvelse: gør 2 queries hurtigere

Nu til den del, hvor du faktisk kan se forskel. Jeg tager to konkrete queries og viser, hvordan du kan tænke.

Opgave 1: Seneste ordrer per kunde

Query:

SELECT o.id, o.created_at, o.status
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.email = 'test@example.com'
ORDER BY o.created_at DESC
LIMIT 20;

Trin 1: Kør EXPLAIN før indeks

Se hvilke tabeller der scannes, og hvor mange rækker den forventer at kigge på. Notér især:

  • Bruger den et indeks på customers.email?
  • Bruger den noget på orders.customer_id + created_at?

Trin 2: Lav 2 indekser

CREATE INDEX idx_customers_email
ON customers(email);

CREATE INDEX idx_orders_customer_created
ON orders(customer_id, created_at);

Trin 3: Kør EXPLAIN igen

Nu vil du typisk se:

  • Få rækker læst fra customers.
  • Range-scan på orders for en bestemt customer_id, allerede sorteret efter created_at.

Hvis du har adgang til at måle tidsforbrug (fx SHOW PROFILES, logfiler eller query monitor), kan du ofte se en ret markant forskel.

Opgave 2: Produkter i kategori med sortering

Query:

SELECT id, name, price
FROM products
WHERE category = 'Shoes'
ORDER BY price ASC
LIMIT 50;

Trin 1: EXPLAIN uden indeks

Her ser du typisk:

  • type = ALLproducts
  • Ingen brugt key
  • Højt rows-tal hvis tabellen er stor

Trin 2: Lav et komposit-indeks

CREATE INDEX idx_products_category_price
ON products(category, price);

Trin 3: EXPLAIN igen

Nu skulle du gerne se, at den bruger idx_products_category_price, og at antallet af læste rækker går ned, især hvis du har mange kategorier.

Det er den samme øvelse, du kan lave på dine egne queries: EXPLAIN, gæt et relevant indeks, lav det, EXPLAIN igen.

Tjekliste: indeks-regler til små webprojekter

Hvis du bare vil have et lille sæt huskeregler til dit næste projekt, så er det nogenlunde de her, jeg selv følger i små hobbyprojekter og mindre kundeprojekter.

1. Altid indeks på foreign keys

Hver gang du har en _id der peger på en anden tabel, laver jeg næsten refleksagtigt et indeks:

CREATE INDEX idx_orders_customer
ON orders(customer_id);

Det gælder også fx product_id i order_items osv.

2. Indeksér de vigtigste søgefelter

Hvis du ved, at brugere ofte søger efter noget bestemt (email, brugernavn, produkt-id, ordre-id), så skal de kolonner have indekser.

3. Kombinér WHERE og ORDER BY i ét indeks

Har du et typisk mønster som:

WHERE customer_id = ?
ORDER BY created_at DESC

så laver jeg et komposit-indeks med samme rækkefølge:

CREATE INDEX idx_orders_customer_created
ON orders(customer_id, created_at);

Det hjælper ofte mere end to separate indekser.

4. Undgå at indeksere “alt muligt løst”

Hvis du laver et indeks, så gør det fordi en konkret query eller et konkret mønster har brug for det. Ikke bare “for en sikkerheds skyld”.

Jeg plejer at starte uden ekstra indekser, se hvor flaskerne opstår, og så tilføje målrettede indekser på baggrund af EXPLAIN og reelle queries.

5. Brug EXPLAIN som standardværktøj

Når en query føles langsom, er min rutine:

  1. Kør EXPLAIN <min query>.
  2. Find tabellen med størst rows og type = ALL.
  3. Overvej indeks på de kolonner, du filtrerer eller joiner på der.
  4. Lav indeks, kør EXPLAIN igen.

Samme tilgang kan du bruge på de øvelser, du finder i fx SQL SELECT-opgaverne for begyndere eller i dine egne små projekter.

6. Tænk en smule frem, men ikke for langt

Du behøver ikke designe en enterprise-database til en lille hobby-blog. Men hvis du forventer, at en tabel kan vokse til hundredetusinder af rækker (fx logins, pageloads, ordrelinjer), så giv de vigtigste queries en chance fra start med få, men velvalgte indekser.

Hvis du vil bygge videre på SQL-delen, kan du også kigge på mere strukturerede øvelser, fx lave dine egne queries på et test-dataset og så måle forskellen med og uden indeks. Det er noget nær den mest håndgribelige måde at lære databasen at kende på.

Lav et sammensat indeks når dine queries ofte filtrerer eller sorterer på flere kolonner samtidig. Rækkefølgen i indekset betyder noget: et indeks på (a,b) kan bruges til WHERE a=... og WHERE a=... AND b=..., men ikke effektivt til WHERE b=... alene. Brug sammensatte indeks til at matche præcis de kolonner og den rækkefølge dine mest kritiske queries bruger.
B-tree-indekser kan bruges ved præfiks-søgninger som WHERE col LIKE 'prefix%'. Hvis du har wildcard i starten ( '%foo'), kan B-tree ikke hjælpe; overvej full-text-søgning (MySQL FULLTEXT eller PostgreSQL tsvector), trigram-indeks eller en søgemotor som Elasticsearch for fritekstsøgning. Vælg løsning ud fra krav til relevans, sprogunderstøttelse og opdateringsmønster.
Kontrollér indeksbrug med database-specifikke statistikker: MySQLs performance_schema eller værktøjer som pt-index-usage, og PostgreSQLs pg_stat_user_indexes viser læseadfærd per indeks. Test at fjerne et indeks i staging og mål performance og write-latency før du sletter i produktion; lav backup og vær parat til at genskabe indeks hvis en query forværres.

Jonas Kirkeby har skrevet kode siden han som teenager forsøgte at lave en helt simpel hjemmeside til sin fars lille vvs-firma – og endte med at sidde oppe hele natten for at få en knap til at skifte farve. Siden da har han lært sig det meste ved at prøve sig frem, kopiere andres eksempler, ødelægge dem og langsomt forstå, hvorfor tingene virker, som de gør.

Til daglig arbejder han slet ikke med IT, men bruger aftener og morgener på små projekter: en lille side til en forening, et simpelt værktøj til at holde styr på familiens madplan eller et Python-script, der rydder op i rodede filer. Det er den slags konkrete hverdags-behov, der har formet hans måde at tænke kodning på – hvad kan jeg bygge nu, som faktisk hjælper mig eller nogen, jeg kender?

På Coding Class deler Jonas de guides, han selv ville ønske, han havde haft: korte, konkrete forløb, hvor du kan se noget på skærmen efter få minutters læsning. Han viser hele vejen fra idé til færdig løsning, inklusive de typiske fejl og små snubletråde på vejen, så du ikke kun får den pæne, polerede version.

Hans mål er, at du som begynder eller let øvet hurtigt får følelsen af: “Det her kan jeg faktisk selv finde ud af” – uanset om du vil bygge din første lille hjemmeside, forstå JavaScript-funktioner eller bruge Python til at automatisere en kedelig opgave.

2 comments

comments user
Maja P.

Gemt til senere,
virker smart med index-eksemplet til fodboldholdets side?

comments user
Frederik

Vent, betyder det at jeg også skal forstå DOM og event listeners til min datters hjemmeside? Forvirret haha

Send kommentar

You May Have Missed