Jeg opdagede først pagination-fejlene, da brugerne begyndte at mangle data

Jeg opdagede først pagination-fejlene, da brugerne begyndte at mangle data

Det er lidt som at bygge en bogreol: du kan godt bare stille bøgerne tilfældigt ind, indtil du skal finde den samme bog to gange i træk, eller opdager at der mangler en midt i rækken. Pagination i et API føles også nemt, lige indtil nogen scroller, går tilbage og pludselig får dubletter eller huller.

I den her artikel fokuserer vi på tre modeller: offset, cursor og keyset pagination. Målet er, at du kan vælge en model til dit API med åbne øjne, og teste den, så du ikke opdager fejlene i produktion.

Problemet pagination egentlig skal løse

Pagination handler om to ting: performance og konsistens.

Performance: du vil ikke sende 50.000 rækker i ét svar. Database og netværk dør langsomt, og brugerens browser dør hurtigt.

Konsistens: en bruger, der bladrer gennem en liste, forventer ikke at få den samme række to gange, eller miste noget midt i forløbet, bare fordi andre brugere opretter eller sletter imens.

De tre klassiske krav er:

  • Hver side har en afgrænset mængde data (limit)
  • Klient kan hoppe videre (og nogle gange tilbage) forudsigeligt
  • Resultaterne er så stabile som muligt, selvom data ændrer sig

Offset, cursor og keyset løser de her krav forskelligt. Forskellen er især tydelig, når data ikke står stille.

Offset pagination – nem at bygge, følsom for ændringer

Offset er den klassiske: ?page=3&limit=20 eller ?offset=40&limit=20.

SQL ser typisk sådan her ud:

SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;

API-request:

GET /api/posts?limit=20&offset=40

Fordele:

  • Nemt at forstå for både dig og frontend
  • Nemt at debugge: du kan selv kalde side 5 direkte
  • Fungerer fint på små tabeller og admin-interfaces

Problemerne starter når:

  • Tabellen bliver stor (mange tusind rækker og op)
  • Brugerne opretter/sletter imens andre bladrer
  • Sorteringen ikke er stabil (mere om det senere)

Hvor offset stadig er ok

Jeg vælger typisk offset når:

  • Der maksimalt er nogle få tusind rækker i tabellen
  • Det er et internt admin-UI, hvor små inkonsistenser er acceptable
  • Brugeren kan søge og filtrere sig mere præcist i stedet for at bladre 100 sider

Her er performance-kravene ofte lavere, og det vigtigste er at komme hurtigt i mål med noget, der er til at overskue.

Offset og performance: hvorfor det bliver tungt

Databasen skal stadig tælle sig frem til OFFSET 100000, selvom den kun returnerer 20 rækker. Med mange rækker kan det koste dyrt.

En løsning er at have et passende indeks til din ORDER BY:

CREATE INDEX idx_posts_created_at_desc
ON posts (created_at DESC);

Det hjælper, men offset bliver stadig tungt ved meget store tabeller. Tænk på det som at bladre til side 5000 i en bog ved at læse hver eneste side på vej derhen.

Offset og konsistens: dubletter og huller

Forestil dig:

  1. Bruger A henter side 1 (offset 0, limit 20)
  2. Bruger B opretter 3 nye poster, som havner forrest
  3. Bruger A henter side 2 (offset 20, limit 20)

Side 2 er nu forskudt, fordi 3 rækker er blevet presset ind foran. Bruger A får muligvis ikke de poster, der lå lige efter side 1, da de nu er skubbet ned.

Det modsatte kan ske ved sletning: du kan få dubletter, fordi noget forsvinder foran dig, så nye rækker ryger frem til en tidligere offset.

Det er her cursor/keyset modeller vinder.

Cursor og keyset pagination – du bladrer efter værdier, ikke tal

Cursor og keyset pagination bygger på samme idé: i stedet for at sige “giv mig side 3” siger du “giv mig de næste 20 efter det her element”.

Du bruger altså værdier (typisk fra den sidste række på forrige side) som reference.

Mental model: fortsæt fra sidste række

Hvis du sorterer efter created_at DESC, så kan du gemme created_at fra sidste række på siden og bruge den som cursor:

SELECT *
FROM posts
WHERE created_at < :cursor_created_at
ORDER BY created_at DESC
LIMIT :limit;

Her betyder cursoren “fortsæt efter det her tidspunkt”. Ingen offset, kun “større end” eller “mindre end” sammenligninger.

Keyset pagination: når cursoren består af flere felter

Hvis flere rækker kan have samme created_at, skal du have en tie-breaker (mere om det under stabil sortering). Et klassisk mønster er at kombinere created_at og id i cursoren.

SQL kan se sådan ud i PostgreSQL:

SELECT *
FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

Her er cursoren egentlig et sæt værdier, men du sender den typisk som én kodet streng til klienten.

Hvornår cursor/keyset vinder over offset

Jeg vælger cursor/keyset når:

  • Listen kan blive meget stor (hundredtusinder eller millioner af rækker)
  • Brugeren bladrer meget (infinite scroll, feed, historik)
  • Det er vigtigt at undgå dubletter og huller

Performancemæssigt er det databasen, der vinder her: den kan bruge indeks til at slå op “start her” og scanne fremad, uden at tælle sig gennem alt det før.

Stabil sortering – uden det er alle modeller skrøbelige

Pagination uden stabil sortering er som at sortere bøger efter farve én dag og højde den næste. Du kan ikke forvente, at samme side viser det samme over tid.

Stabil sortering betyder: hver række har en entydig position i rækkefølgen. Ingen rækker er “lige gode” uden flere felter.

ORDER BY + tie-breaker

Et klassisk mønster:

SELECT *
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT :limit;

created_at bestemmer overordnet rækkefølgen, id afgør rækkefølgen for rækker med samme created_at.

Det giver to gevinster:

  • Offset bliver en smule mindre ustabil
  • Cursor/keyset kan bruge begge felter og altid fortsætte et entydigt sted fra

Typisk fejl: ORDER BY på et felt uden indeks, eller uden tie-breaker. Så står databasen friere til at beslutte rækkefølgen, og du får underlige effekter.

API-design – hvordan ser responses ud i praksis?

Lad os kigge på konkrete API-kontrakter. Jeg holder dem simple, så du kan kopiere mønstrene.

Offset-baseret API-response

{
  "items": [ /* array af posts */ ],
  "limit": 20,
  "offset": 40,
  "total": 1234,
  "hasMore": true
}

Her er total valgfri. Den kan være dyr at beregne på meget store tabeller, så overvej om din klient egentlig har brug for den.

Request:

GET /api/posts?limit=20&offset=40

Cursor-baseret API-response

Her bruger vi en opaque (uglæselig) cursor, som klienten bare sender videre uden at kende indholdet.

{
  "items": [ /* array af posts */ ],
  "limit": 20,
  "nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNFQxMTo0NToyMiIsImlkIjoiMTIzNCJ9",
  "hasMore": true
}

Cursoren kan være f.eks. en base64-encodet JSON med created_at og id. Klienten skal ikke pille i den.

Request til næste side:

GET /api/posts?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNFQxMTo0NToyMiIsImlkIjoiMTIzNCJ9

Det er samme mønster du ser i mange større APIer, f.eks. Slack og GitHub.

Keyset i APIet – samme idé, lidt mere eksplicit

Hvis du ikke gider encode cursoren, kan du også gøre den mere eksplicit:

GET /api/posts?limit=20&created_before=2024-03-14T11:45:22Z&id_lt=1234

Og så returnere værdierne igen i response:

{
  "items": [ /* ... */ ],
  "limit": 20,
  "next": {
    "created_before": "2024-03-14T10:02:11Z",
    "id_lt": 1010
  },
  "hasMore": true
}

Fordel: nemt at debugge i browseren. Ulempe: lidt mere støj i URLen og APIet.

Edge cases – hvad sker der når data ændrer sig mellem kald?

Det er her forskellen på cursor pagination vs offset bare står og lyser.

Nye rækker ind imellem siderne

Scenario:

  1. Bruger A henter første side
  2. Bruger B opretter 5 nye rækker, der ligger “foran”
  3. Bruger A henter næste side

Med offset:

  • Anden side springer potentielt over 5 rækker, fordi de er skubbet til en tidligere offset

Med cursor/keyset:

  • Anden side fortsætter fra sidste synlige række, uanset hvad der er kommet til foran

Brugeren vil typisk ikke savne de helt nye rækker, for de står “før” der hvor vedkommende var nået til. Det føles naturligt.

Slettede rækker midt i forløbet

Scenario:

  1. Bruger A henter første side
  2. En række midt i resultatet bliver slettet
  3. Bruger A henter næste side

Offset:

  • Du risikerer dubletter, fordi offset nu peger tidligere i rækken

Cursor/keyset:

  • Du fortsætter stadig fra sidste synlige række, den slettede bliver bare aldrig vist

Cursor/keyset håndterer altså både inserts og deletes mere roligt.

Opdaterede rækker, der ændrer sorteringsfelt

Hvis du sorterer efter et felt, som brugeren kan ændre (f.eks. titel eller rating), kan rækkens position hoppe rundt midt i et forløb.

To løsninger jeg ofte ender på:

  • Sortér efter et felt, der ikke ændrer sig (f.eks. created_at)
  • Accepter, at rækken kan dukke op to gange i et forløb og dokumentér det

Hvis du vil nørde sortering og indekser mere, så er Use the Index, Luke stadig noget af det bedste at læse.

Indeksering – uden de rigtige indeks er pagination bare langsom

Pagination-performance er i høj grad et spørgsmål om, om databasen kan gøre arbejdet med et indeks i hånden i stedet for full table scan.

Indeks til offset pagination

Du skal have et indeks, der matcher din ORDER BY. Eksempel:

SELECT *
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset;

CREATE INDEX idx_posts_created_at_id_desc
ON posts (created_at DESC, id DESC);

Det hjælper både offset og cursor, så det er et fint sted at starte.

Typisk fejl: have et indeks på id, men sortere efter created_at, eller sortere efter noget beregnet (f.eks. LOWER(title)) uden et tilsvarende indeks.

Indeks til cursor/keyset pagination

Med cursor/keyset er mønsteret ofte:

SELECT *
FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

Her spiller samme indeks ind:

CREATE INDEX idx_posts_created_at_id_desc
ON posts (created_at DESC, id DESC);

Hvis du bruger andre sorteringer, så lav tilsvarende sammensatte indeks. Databasedokumentationen (f.eks. PostgreSQLs kapitel om indeks) er guld værd at have åben, mens du designer det.

Testplan – 6 cases der afslører pagination-bugs hurtigt

Du kan fange ret mange pagination-fejl med nogle få simple, automatiserede tests. Tænk dem som små boulders du lige skal bestige én for én.

1. Ingen dubletter på tværs af sider

Hent f.eks. 5 sider i træk med samme parametre. Saml alle ider og tjek at der ikke er nogen, der går igen.

const allIds = new Set();
for (const item of allItemsFrom5Pages) {
  if (allIds.has(item.id)) throw new Error("Duplicate id " + item.id);
  allIds.add(item.id);
}

2. Ingen huller ved statisk datasæt

Frys data (ingen inserts/deletes) og hent alle sider. Sammenlign rækkefølgen med en direkte query i databasen uden pagination.

De to lister skal være identiske, både hvad angår antal og rækkefølge.

3. Stabil rækkefølge ved små ændringer

Lav en test der:

  1. Henter side 1
  2. Indsætter nogle nye rækker, der hører til “foran” side 1
  3. Henter side 2

Med cursor/keyset skal side 2 stadig starte der, hvor side 1 slap, selvom der er kommet noget nyt foran.

4. Sletning midt i forløbet

Test:

  1. Hent side 1
  2. Slet en række fra resultatet på side 1
  3. Hent side 2

Bekræft at der ikke kommer dubletter i side 2, og at du stadig får lige så mange rækker retur som forventet (så vidt muligt).

5. Sorteringsfelt ændres

Hvis dit sorteringsfelt kan ændres, så test at:

  • En opdateret række ikke knækker din pagination
  • Klienten kan tåle at rækken måske flytter sig lidt

For eksempel: opdater en posts created_at eller sorteringsfelt mellem to kald, og se om paginationen stadig holder sig nogenlunde fornuftig.

6. Performance ved høj offset eller dybe cursors

Kør load-tests mod:

  • Offset pagination med høj offset (f.eks. 100.000)
  • Cursor/keyset pagination med tilsvarende dybde

Mål responstid og CPU. Det giver en meget konkret oplevelse af forskellen, og kan være det, der afgør valget i et rigtigt projekt.

Beslutningstabel – hvornår vælger du hvad?

Nu kommer den del, jeg selv savnede første gang jeg stod med valget: en direkte sammenligning.

Use cases og anbefalinger

  • Lille admin-liste med få hundrede rækker
    Vælg: offset
    Begrundelse: nemt at bygge, nemt for frontend, performance ok.
  • Stort feed (f.eks. aktiviteter, notifikationer)
    Vælg: cursor eller keyset
    Begrundelse: brugeren scroller meget, data ændrer sig konstant, du vil undgå dubletter og huller.
  • Rapporter / eksport-funktion
    Vælg: cursor/keyset eller batch i baggrunden
    Begrundelse: store datamængder, fokus på performance og stabilitet.
  • Public API med ukendt belastning
    Vælg: cursor som primær, evt. offset som “simple” mode til små lister
    Begrundelse: bedre performance og kontrol på lang sigt. Dokumentér kontrakten tydeligt, f.eks. med eksempler som i andre backend-artikler på Coding Class.
  • Søgeresultater med stærk filtrering
    Vælg: ofte offset i starten, men overvej cursor hvis du kan definere et stabilt sorteringsfelt
    Begrundelse: mange filtre kan gøre keyset mere besværligt, men det kan stadig betale sig ved store datamængder.

Små beslutningsspørgsmål før du vælger

Du kan bruge de her spørgsmål som tjekliste, lidt som i artiklen om offset vs cursor (hvis du har læst den, er du allerede foran):

  • Hvor mange rækker kan der maksimalt være?
  • Hvor ofte ændrer listen sig, mens brugeren kigger på den?
  • Er der et naturligt, stabilt sorteringsfelt, der ikke ændrer sig?
  • Skal brugeren kunne hoppe til “side 10” direkte, eller er “næste/forrige” nok?
  • Er det her projekt et øveprojekt, hvor enkel implementation vægter højere end perfekt konsistens?

Jo flere “stor”, “ofte” og “vigtigt” du svarer, jo mere giver cursor/keyset mening. Jo mere “lille”, “sjældent” og “det er bare admin” du svarer, jo mere ok er offset.

Sådan kan du bygge videre herfra

Hvis du vil prøve det af i et lille projekt, så er et godt næste skridt at tage en simpel liste-endpoint og implementere både offset og cursor side om side. Mål performance forskellen, og prøv bevidst at lave de edge cases jeg beskrev.

Når du har gjort det én gang, er pagination ikke længere en abstrakt “best practice”-ting; det er bare endnu et mønster i værktøjskassen. Lidt ligesom første gang man opdager, at man kan automatisere kedelige opgaver med et lille Python-script.

Det vigtigste råd at tage med: vælg én pagination-model pr. endpoint, design en stabil sortering, og skriv et par tests der angriber de grimme cases, før dine brugere gør det.

Gør cursoren til en opaque streng (fx base64 af en lille JSON) der indeholder de sorteringsværdier du bruger plus et tie-breaker id. Sørg for deterministisk ordering ved altid at inkludere en unik kolonne (primærnøgle) i ORDER BY, valider cursors server-side og forkast eller giv klar fejl ved forældede eller manipulerede cursors.
Du kan implementere 'forrige' ved at lave en cursor for den omvendte ordering, men keyset/cursor understøtter ikke effektivt hop til en vilkårlig side nummer. Hvis brugere ofte skal springe til side N, overvej et separat offset-endpoint, bookmarks (gemte cursors) eller en søge-/filteroplevelse i stedet for rå side-nummer navigation.
Tilføj altid et deterministic tie-breaker - normalt den primære nøgle - i ORDER BY og i din cursor. I SQL betyder det fx ORDER BY created_at DESC, id DESC og i WHERE-klasulen sammenligner du tuple-værdier (created_at, id) for at undgå dubletter og huller.
Lav integrationstests der simulerer concurrent inserts/updates/deletes mens en klient bladrer, og assertions for ingen dubletter og ingen manglende rækker. Suppler med loadtests og små fuzz-tests der sender tilfældige opsætninger af limit/cursor/offset, og kør reproducible scenarier mod en seeded testdatabase.

Sara Vestergaard er selvlært kode-nørd, der stille og roligt er gået fra at rode med en enkelt HTML-side til at bygge små værktøjer, scripts og hjemmesider til sig selv og vennerne. Hun startede med at lave en simpel band-hjemmeside som teenager og opdagede, hvor tilfredsstillende det er, når noget, du har skrevet, pludselig lever på skærmen.

For Sara handler kodning ikke om store ord eller imponerende titler, men om meget konkrete problemer: den kedelige opgave, der tager for lang tid, den ven der mangler en lille porteføljeside, eller den liste, der burde sortere sig selv. Hun elsker at pille ting fra hinanden – også kode – for at se, hvad der egentlig foregår, og hun har brugt utallige aftener på at google fejlbeskeder, teste små eksempler og langsomt bygge sin forståelse op.

På Coding Class deler hun den tilgang videre. Hun skriver til dig, der gerne vil lære at kode ved at gøre det i praksis: små projekter, korte kodebidder og forklaringer, der hænger sammen med det, du faktisk sidder med på skærmen. Hun skærer ind til benet, viser typiske fejl og deres løsninger og giver altid et forslag til, hvordan du kan bygge en tand videre, når grundideen først virker.

Når hun ikke skriver til Coding Class eller nørkler med nye små projekter, hænger Sara på klatrevæggen, vander sine altanplanter eller spiller gamle Nintendo-spil. Men hun ender næsten altid tilbage ved tasterne – for der er altid endnu en lille ting, der kunne være smartere, hurtigere eller bare lidt sjovere at bruge.

Send kommentar

You May Have Missed