I juli rullet vi ut et nytt cache-lag for Oppslag. Median svartid gikk fra 61 til 38 ms og p99 fra 240 til 110 ms, uten at ferskheten ble dårligere. Dette er historien om hvordan, inkludert to blindveier.
Utgangspunktet
Oppslag speiler flere offentlige registre i en Postgres-database på omtrent 90 GB. Et kall til GET /v1/virksomheter/{orgnr} uten utvidelser traff tre tabeller; med utvid=roller,regnskap traff det sju. Det er ikke mye, men ved 400 kall i sekundet på en database som også tar imot 15 endringer i sekundet fra inntaket, ble låsekonflikter og buffer-utkasting synlige i halen.
Vi hadde en enkel cache foran: hele svaret per (orgnr, utvid, versjon) i Redis med 15 minutters levetid. Treffraten var 71 prosent. Problemet var de 29 prosentene, pluss at hver registerendring ugyldiggjorde alle kombinasjoner av utvid for selskapet.
Blindvei 1: lengre levetid
Første forsøk var å øke levetiden til 60 minutter. Treffraten gikk til 84 prosent, p99 til 190 ms. Men da begynte kunder å se endringer senere enn 15 minutter etter kilden, som er et løfte vi gir. Vi rullet tilbake etter to dager. Lærdom: ferskhet er et produktkrav, ikke en teknisk innstilling.
Blindvei 2: lesereplika
Andre forsøk var å sende alle lesekall til en replika og la primæren bare ta inntak. Det hjalp på låsekonflikter, men replikaforsinkelsen varierte mellom 0 og 8 sekunder under inntakstopper, og vi fikk tilfeller der et selskap var oppdatert i en webhook men ikke i det påfølgende GET-kallet. Kunder som gjør GET etter webhook (som vi anbefaler) fikk gamle data. Det var verre enn problemet vi prøvde å løse.
Det som virket: cache per felt, ugyldiggjør per felt
Innsikten var at et virksomhetsobjekt egentlig er fem uavhengige deler som endres i forskjellig takt:
| Del | Endres |
|---|---|
| Grunndata (navn, adresse, form, status) | Sjelden, men når det skjer er det viktig |
| Roller | Noen ganger i året |
| Regnskap | Én gang i året |
| Rettighetshavere | Sjelden |
| Konsern | Sjelden |
Den nye cachen lagrer hver del for seg, med nøkkel (orgnr, del, versjon). Et kall med utvid=roller henter to nøkler og setter dem sammen. Når inntaket får en rolleendring, ugyldiggjøres bare (orgnr, roller, *). Grunndata og regnskap for samme selskap ligger urørt i cachen.
Levetiden er nå 24 timer, fordi ugyldiggjøring skjer ved endring, ikke ved tid. Treffraten er 96 prosent. De 4 prosentene er hovedsakelig selskaper ingen har slått opp det siste døgnet, pluss de første kallene etter en endring.
Inntaket skriver cachen
Den andre endringen var at inntaket, som uansett vet nøyaktig hva som endret seg, skriver den nye verdien rett inn i cachen i stedet for bare å slette. Et selskap som nettopp fikk ny daglig leder har altså ferske roller i cachen før første kunde spør. Det fjernet «første kall etter endring»-halen nesten helt, og det er grunnen til at p99 falt mer enn medianen.
Det krever at inntaket og API-et er enige om serialiseringen, som de er fordi de deler samme kodebase og samme versjonslag. Versjonen (2026-09-01 og så videre) er en del av cache-nøkkelen, så flere versjoner kan leve side om side.
Søk er annerledes
Søk (?sok=) cacher vi ikke per spørring; det er for mange varianter. I stedet ligger søkeindeksen i minnet på hver API-node, bygget fra et øyeblikksbilde som oppdateres hvert 5. minutt pluss en liten delta-indeks for endringer siden. Søk er 22 ms median, og vi er fornøyde med det foreløpig.
Tall etter tre måneder
| Før | Etter | |
|---|---|---|
| Median svartid | 61 ms | 38 ms |
| p99 | 240 ms | 110 ms |
| Treffrate cache | 71 % | 96 % |
| Databaselast (lesing) | 100 % | 11 % |
| Ferskhet (kilde → Spire, p99) | 14 min | 9 min |
Ferskheten ble faktisk bedre, fordi inntaket ikke lenger konkurrerer med lesing om databasen.
Hva vi gjør videre
Regnskapstall for alle fem år er det største objektet vi cacher, og det hentes sjelden i sin helhet. Vi vurderer å dele det per år. Og søkeindeksen skal bli mer tolerant for skrivefeil i stedsnavn, som er den vanligste klagen vi får.
Spørsmål om arkitekturen? Ta kontakt, eller søk på driftsingeniør hvis du vil jobbe med den.