Hoppa till innehåll
OptimeringPostgreSQLDatabasoptimeringPrestanda6 min läsning

Database performance tuning – hitta och fixa slow queries

Från pg_stat_statements till rätt index: metodiken vi använder för att hitta och åtgärda de query:er som faktiskt kostar pengar

5 juli 2026Uppdaterad 11:00
2 48543
Database performance tuning – hitta och fixa slow queries
Database performance tuning – hitta och fixa slow queriesPhoto: Unsplash

Praktisk guide till databasoptimering 2026: hur ni hittar de tunga frågorna med pg_stat_statements, läser en EXPLAIN ANALYZE-plan, väljer rätt index utan driftstopp, och undviker N+1-fällan i ORM-lagret.

En långsam databasfråga är sällan ett mysterium — det är nästan alltid ett saknat index, en dålig JOIN-ordning eller en applikation som frågar databasen fler gånger än den behöver. Ändå är "databasen är seg" en av de vanligaste förklaringarna vi hör när vi går in i ett optimeringsuppdrag, och nästan lika ofta blir svaret "köp en större instans" i stället för att faktiskt läsa frågeplanen. Det är dyrt och löser sällan problemet — en query som skannar en miljon rader i onödan blir inte snabb för att den körs på dubbelt så mycket CPU.

Den här guiden går igenom hur vi hittar de tunga frågorna, läser en frågeplan, väljer rätt index, undviker N+1-problemet i ORM-lagret, och skalar bortom en enskild instans när det faktiskt behövs. Vi utgår från PostgreSQL eftersom det är standardvalet för de flesta svenska SaaS-bolag vi jobbar med, men metodiken är i stort sett identisk för MySQL. Precis som med Core Web Vitals handlar det inte om att gissa utan om att mäta rätt sak — och på worst case-frågor som verkligen aldrig blivit granskade ser vi rutinmässigt 10–100 gånger snabbare svarstid efter en tuning-insats.

Hitta de tunga frågorna innan du gissar

Gissa aldrig vilken fråga som är problemet — mät det. Aktivera pg_stat_statements (inbyggd extension i PostgreSQL) och du får omedelbar insyn i varje frågas totala tid, antal anrop och genomsnittlig tid:

SQL
1-- Installera extensionen en gång
2CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
3
4-- De 10 tyngsta frågorna sorterat på total exekveringstid
5SELECT query, calls, total_exec_time, mean_exec_time
6FROM pg_stat_statements
7ORDER BY total_exec_time DESC
8LIMIT 10;

Komplettera med slow query-loggning för att fånga enskilda extremfall: log_min_duration_statement = 1000 i postgresql.conf loggar varje fråga som tar över en sekund. Tillsammans ger de två en fullständig bild — pg_stat_statements visar var den ackumulerade tiden går, loggen visar de enskilt värsta fallen. Verktyg som pgBadger (logganalys) eller pganalyze (kontinuerlig övervakning) gör arbetet betydligt snabbare på en databas med tusentals distinkta frågor.

Läsa EXPLAIN ANALYZE — vad du faktiskt letar efter

När du hittat en kandidat, kör den genom EXPLAIN ANALYZE. Det kör frågan på riktigt och visar exakt vad som hände, inte bara planerarens uppskattning:

SQL
1EXPLAIN ANALYZE
2SELECT o.id, o.total, c.name
3FROM orders o
4JOIN customers c ON c.id = o.customer_id
5WHERE o.created_at > now() - interval '30 days'
6 AND o.status = 'completed';
7
8-- Seq Scan on orders o (cost=0.00..48291.00 rows=1200 width=24)
9-- (actual time=0.031..812.442 rows=1180 loops=1)
10-- Filter: (status = 'completed' AND created_at > ...)
11-- Rows Removed by Filter: 892340

Det du letar efter: Seq Scan på en stor tabell (databasen läser hela tabellen rad för rad — ett tydligt tecken på saknat index), stor diskrepans mellan uppskattade och faktiska rader (indikerar inaktuell statistik — kör ANALYZE tabellnamn), och Nested Loop med många iterationer (varje varv är en index-lookup, kan bli dyrt när ytterloopen är stor). Fältet Rows Removed by Filter ovan är en dödsdom i sig — databasen läste 892 340 rader för att hitta 1 180 den faktiskt behövde.

Rätt index — inte fler index

Lösningen på ovanstående är nästan alltid ett sammansatt index på de kolumner som filtreras och sorteras på, i rätt ordning — kolumnen med högst selektivitet (flest distinkta värden) först vid likhetsfilter, sorteringskolumnen sist om du kombinerar filter och ORDER BY. Skapa aldrig index direkt på en produktionstabell utan CONCURRENTLY — annars låser du tabellen för skrivningar under hela byggtiden, vilket på en stor tabell kan betyda minuter av stopp:

SQL
1CREATE INDEX CONCURRENTLY idx_orders_status_created
2ON orders (status, created_at)
3WHERE status = 'completed';

Notera WHERE status = 'completed' — ett partiellt index som bara omfattar de rader som faktiskt frågas efter är mindre, snabbare att underhålla och ofta helt tillräckligt om resten av statusarna sällan efterfrågas. Var återhållsam med antalet index: varje index kostar vid INSERT, UPDATE och DELETE, och ett index som ingen fråga använder är ren overhead. Vi ser regelbundet tabeller med 15–20 index där hälften aldrig träffas — pg_stat_user_indexes visar vilka som faktiskt används.

N+1-problemet gömmer sig i ORM-lagret

Den vanligaste orsaken till att en till synes enkel sida gör hundratals databasanrop är N+1: du hämtar en lista med 50 rader, och sedan gör applikationskoden ett separat anrop per rad för att hämta relaterad data. Det är sällan synligt i koden — Prisma, TypeORM och liknande ORM:er gör det enkelt att skriva loopar som ser oskyldiga ut men som i praktiken genererar 51 separata roundtrips till databasen.

Lösningen är att alltid hämta relaterad data i samma fråga (include i Prisma, relations i TypeORM) eller batcha uppslagningarna med ett DataLoader-mönster om relationen är för komplex för en enkel join. Slå på query-loggning i utvecklingsmiljön och räkna anropen per sidladdning — om en lista med 50 rader genererar mer än en handfull frågor är N+1 nästan garanterat boven.

Connection pooling — gränsen ni inte visste fanns

PostgreSQL är processbaserat: varje anslutning tar en egen OS-process och minst 10 MB minne. Över 100–200 samtidiga anslutningar försämras prestandan kraftigt, oavsett hur väloptimerade frågorna är. Serverlösa arkitekturer som öppnar en ny anslutning per invocation slår i taket förvånansvärt snabbt — ofta långt innan databasen är hårt belastad i övrigt.

PgBouncer löser det genom att multiplexera många klientanslutningar över ett litet antal faktiska databasanslutningar. Vår standardrekommendation är transaction-läge med 25–50 anslutningar i poolen för de flesta applikationer, justerat efter faktisk samtidighet uppmätt via pg_stat_activity. I en serverlös miljö är connection pooling inte en optimering för senare — det behövs praktiskt taget från dag ett.

Partitionering och skalning bortom en instans

När en enskild tabell växer förbi tiotals miljoner rader, eller när frågor konsekvent bara berör en tidsperiod (senaste månaden, senaste kvartalet), är tabellpartitionering nästa steg. Partitionering efter datum (range) är vanligast för händelseloggar och transaktionsdata — frågor som filtrerar på tidsperiod behöver då bara skanna relevanta partitioner i stället för hela tabellen, och gamla partitioner kan arkiveras eller droppas utan att låsa resten av tabellen.

För lästung belastning som inte kräver realtidskonsistens är read replicas nästa naturliga steg — rapporter, dashboards och analytics-frågor routas till en replika medan skrivningar går mot primärinstansen. Och för renodlad analys över stora datamängder är en kolumnorienterad motor som BigQuery eller ett Postgres-kompatibelt analyslager ofta ett bättre val än att pressa en transaktionsdatabas att göra jobb den inte är byggd för. Det är en avvägning vi hjälper till att göra rätt inom dataplattform-uppdrag.

VACUUM och bloat — den tysta prestandabromsen

PostgreSQL skriver aldrig över en rad direkt vid UPDATE eller DELETE — den markerar den gamla versionen som död och skapar en ny, ett resultat av MVCC-arkitekturen (Multiversion Concurrency Control) som gör att flera transaktioner kan läsa konsekvent samtidigt som andra skriver. Utan regelbunden VACUUM hopar sig de döda raderna, tabellen sväller (bloat), och index blir onödigt stora och långsammare att skanna — en tabell med kraftig skrivfrekvens kan bli dubbelt så stor på disk som den behöver vara, med motsvarande försämring i cache-effektivitet.

Autovacuum sköter det mesta automatiskt, men standardinställningarna är satta för genomsnittstabeller, inte era mest skrivintensiva. För tabeller med hög omsättning (köer, sessionsdata, händelseloggar) rekommenderar vi att sänka autovacuum_vacuum_scale_factor per tabell så att städningen sker oftare och i mindre steg, i stället för en enda tung körning som konkurrerar om I/O med produktionstrafiken. Kontrollera bloat-nivån med pg_stat_user_tables (kolumnerna n_dead_tup och n_live_tup) och agera innan förhållandet blir skevt.

Övervaka så att förbättringen håller i sig

En tuning-insats som inte följs upp av övervakning eroderar tyst — nya features lägger till nya frågor, datavolymen växer, och om sex månader är ni tillbaka på ruta ett. Sätt upp dashboards för de mått som faktiskt förutsäger problem: genomsnittlig och p95 query-tid, antal aktiva anslutningar, cache hit ratio, och lock-väntetid. Larma på avvikelser, inte bara på absoluta tröskelvärden — en fråga som plötsligt går från 20 till 200 millisekunder är värd att undersöka även om 200 ms i sig inte känns dramatiskt.

Bäst resultat ser vi hos team som kör en query-regressionskontroll i CI: en enkel EXPLAIN-diff mot en referensplan för de mest kritiska frågorna, som flaggar om en migration eller kodändring plötsligt gör att planeraren väljer en sekventiell skanning i stället för ett index.

Vill ni ha hjälp att hitta och åtgärda de query:er som faktiskt kostar er pengar? Läs mer om våra tjänster eller boka ett samtal.

En query som skannar en miljon rader i onödan blir inte snabb för att den körs på dubbelt så mycket CPU — lösningen är nästan alltid rätt index, inte en större instans.

- Simon Axelsson

Vanliga frågor

Hur vet jag vilka databasfrågor som är långsamma?
Installera pg_stat_statements-extensionen och kör SELECT query, calls, total_exec_time, mean_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10. Det ger en direkt rangordning av era tyngsta frågor. Komplettera med log_min_duration_statement för att fånga enskilda extremfall som tar över en angiven tröskel.
Räcker det att lägga till fler index för att lösa prestandaproblem?
Nej, och det kan göra saken värre. Varje index kostar vid INSERT, UPDATE och DELETE, och index som ingen fråga använder är ren overhead. Rätt angreppssätt är att läsa EXPLAIN ANALYZE, identifiera exakt vilka kolumner som filtreras och sorteras på, och skapa ett sammansatt index anpassat efter det faktiska frågemönstret — inte gissa i förväg.
Hur skapar jag index utan att låsa produktionstabellen?
Använd CREATE INDEX CONCURRENTLY i stället för vanlig CREATE INDEX. Det bygger indexet utan att blockera skrivningar mot tabellen, vilket är avgörande på tabeller som är i aktiv drift. Det tar något längre tid än en vanlig indexbyggnad, men undviker driftstopp helt.
Vad är N+1-problemet och hur upptäcker jag det?
N+1 innebär att applikationen hämtar en lista med rader och sedan gör ett separat databasanrop per rad för relaterad data, i stället för en enda samlad fråga. Slå på query-loggning i utvecklingsmiljön och räkna antalet anrop per sidladdning — om en lista med 50 rader genererar långt fler än en handfull frågor är N+1 nästan alltid orsaken.
När behöver vi read replicas eller partitionering i stället för mer tuning?
När en tabell växer förbi tiotals miljoner rader eller läslasten inte längre går att lösa med bättre index och connection pooling. Partitionering efter datum passar händelseloggar och transaktionsdata, medan read replicas passar rapporter och analytics som kan tolerera viss eftersläpning mot primärinstansen.

Om författaren

SIAX Technology
SIAX TechnologyTeknikteamet

SIAX Technologys teknikteam skriver guiderna utifrån verkliga leveranser inom molninfrastruktur, dataplattformar och AI-automation åt nordiska företag.

Fler artiklar av SIAX