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
emailmatcher - 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
INSERTellerUPDATE, 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:
- Står der
type = ALLog mangerowspå en stor tabel? - Er
keytom, 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 = 50000table = o,type = refellerALLafhæ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 = refellerrange,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
statusmed 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-indeks på
customers.id(automatisk) - Indeks på
orders.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_idiorders: høj kardinalitet (mange kunder).statusiorders: 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å
ordersfor en bestemtcustomer_id, allerede sorteret eftercreated_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 = ALLpåproducts- 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:
- Kør
EXPLAIN <min query>. - Find tabellen med størst
rowsogtype = ALL. - Overvej indeks på de kolonner, du filtrerer eller joiner på der.
- 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å.









2 comments