Både produkteiere og utviklere kan ofte bli litt overentusiastiske når det kommer til skalering av systemer: Det blir plutselig “nødvendig” å ha muligheten til å skalere over flere maskiner, koste hva det koste vil. Noen ganger er dette fornuftig, men her i Norge føler jeg det ofte skaper mer kompleksitet og arbeid enn nødvendig. Vi er tross alt kun fem og en halv million mennesker, og de fleste tjenester som utvikles her til lands jobber med langt færre brukere og relativt lite data. Selv med mange brukere og mye data kan man fremdeles skalere langt, uten å måtte ende opp med distribuerte systemer.
Derfor slår jeg et slag for SQLite: Det blir sjelden vurdert som et databasealternativ, selv om det er gode grunner til å velge det for de fleste systemer. Mesteparten av det jeg liker med SQLite er hyppig iterasjon og utviklerglede, men i denne bloggposten skal jeg ta tak i feilaktige konklusjoner folk har rundt ytelsen.
¶Dette gir jo mening!
Før vi hopper ned i detaljene, er det jo greit å tenke litt på hvorfor SQLite burde yte bra, sånn teoretisk. Av alle SQL-alternativene jeg vet om er dette den eneste databasen som gjør så å si “ingenting”: Den gjør ingen nettverkskall, trenger ingen ekstern tjeneste, og har heller ikke behov for å spinne opp ekstra prosesser. Det “eneste” SQLite gjør er å skrive til og fra en fil. Selv om det i seg selv ikke alltid er trivielt, så er det mye mindre tidkrevende enn å kommunisere til en annen prosess som potensielt er på en annen maskin. Det er med andre ord kortere vei til dataene, med færre feilkilder.
Og for de som har brettet opp ermene oversettes dette til utrolig høy ytelse: I 2018 mekket Expensify på en SQLite-server sånn at den klarte å utføre 4 millioner spørringer i sekundet i en ytelsestest. Slike tester er jo alltid et stykke unna sannheten, men det er tydelig at du kan få utrolig god ytelse med SQLite om du bare setter deg ned med det.
Problemet er at det øyeblikket du først prøver SQLite, virker det ikke som om noen av disse fordelene kommer deg til gode og ytelsen uteblir. Hva er greia?
¶Gamle, men bakoverkompatible innstillinger
Mange som prøver ut SQLite for første gang uten å lese dokumentasjonen (som er så å si alle, la oss være ærlige) ender veldig ofte opp med dårlig ytelse og veldig mange feilmeldinger på formen
[SQLITE_BUSY] The database file is locked (database is locked)
Dette skjer av flere grunner, men hovedproblemet er at SQLite kun tillater en enkelt skrivetransaksjon om gangen. Og en skrivetransaksjon er en litt rar “mutex” på hele databasen: Når skrivetransaksjonen har begynt, må nye lesetransaksjoner vente til skrivetransaksjonen er ferdig, og skrivetransaksjonen må vente på at alle eksisterende lesetransaksjoner committer før den selv committer.
“Problemet” er at standardoppførselen i SQLite ikke er at den venter,
men heller sender tilbake en SQLITE_BUSY
-feil tilbake med en gang.
Du kan enkelt skru på ventetiden, men om du går forbi makstiden vil du
fremdeles ende opp med en SQLITE_BUSY
-feil. Og om du skal skrive noe
og du ikke manuelt starter skrivetransaksjonen som en
skrivetransaksjon, så feiler den øyeblikkelig med SQLITE_BUSY
det
øyeblikket du prøver å kjøre en UPDATE
eller DELETE
. Akkurat
hvorfor det er slik har litt med journalmodellen SQLite bruker som
standard, og om du er interessert i detaljene kan du lese en god
forklaring på modellen på SQLite sine
nettsider.
Som du sikkert skjønner, var ikke SQLite originalt designet med tanke på flere tråder og samtidighet. I gamle dager var anbefalingen for flertrådsprogrammering å gjøre en av disse tingene for å unngå feilmeldingene:
- Kun kjøre en transaksjon om gangen
- Sette en retry-løkke rundt alle transaksjoner, og prøve igjen om vi
får en
SQLITE_BUSY
-feil
Dette skalerer ikke særlig bra, og nummer to virker jo, om ikke direkte hacky, noe du må ta hensyn til i koden din. Heldigvis for oss er gamle dager lenge siden, og fra 2010 fikk SQLite støtte for WAL-journaler. WAL-journalen støtter fremdeles ikke flere samtidige skrivetransaksjoner, men du kan nå kjøre på med så mange lesetransaksjoner som du selv ønsker – uten at de interagerer med skrivetransaksjoner på noe overraskende vis. Så lenge du separerer lese- og skrivetransaksjoner kan du nå få veldig god ytelse.
Det er litt kjedelig at man må forholde seg til alt dette og at standardinnstillingene ikke er satt opp til å gjøre det mest fornuftige, men det har sin naturlige forklaring. SQLite jobber hardt med å være helt bakoverkompatibelt, til og med når det kommer til ytelse. For noen kompromisser er det jo: F.eks. er det å bruke litt mer minne mindre greit for IoT-enheter, enn på mobiler og programmer som kjører i skyen.
Jeg liker bakoverkompatibilitet i prinsippet veldig godt, men om man bare durer på uten å lese dokumentasjonen er resultatet dessverre at førsteinntrykket blir veldig dårlig.
¶Noen gode standardinnstillinger
Nesten alle endringene som gir SQLite bedre ytelse for skytjenester kan fikses med såkalte pragma-uttrykk. De viktigste er følgende:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = 100000;
PRAGMA temp_store = memory;
journal_mode
endrer, som nevnt over, hvilken journalmetode som
brukes. WAL er den som støtter lesetransaksjoner uavhengig av om det
er en skrivetransaksjon i gang.
synchronous
endrer hvor ofte ting skrives til disk, og hvor stor
sannsynlighet det er for databasekorrupsjon. For bakoverkompatibilitet
er denne satt til FULL fordi den originale journalmetoden på gamle
filsystemer kan teoretisk sett skape korrupsjon om strømmen går på
akkurat riktig tidspunkt. Med nyere filsystemer er ikke korrupsjon et
problem lengre om du setter denne til NORMAL
. Merk at i teorien blir
D-en i ACID brutt med dette pragma-flagget, men i praksis er
diskkorrupsjon og andre hardwarefeil et mye viktigere problem for D-en
i ACID.
Å sette busy_timeout
til 5 sekunder virker kanskje noe redundant om
vi i programmet garanterer at kun en skrivetransaksjon utføres om
gangen, men dette er viktig for å håndtere deploys med null nedetid.
Ved å sette dette flagget kan du nemlig starte opp den nye versjonen
av tjenesten og koble den til databasen, uten at du ender opp med
SQLITE_BUSY
-feilene.
cache_size
skrur opp antallet database-sider som holdes i minne. Med
standardstørrelse på sidene (4 KB) blir dette ca. 400 MB med data som
kan holdes i minne.
Å sette temp_store
til memory gjør det du ville tro: Midlertidig
data blir lagret i minnet framfor en midlertidig fil. Dette inkluderer
tabeller du definerer i transaksjoner, samt noen spørringer som
sorterer data via ORDER BY
, har JOIN
s på uindekserte kolonner
eller lignende.
Ingenting av dette er nytt, for det er mange bloggposter med akkurat samme konklusjon om du graver litt etter dem. Men ingen av disse konfigurasjonsendringene løser det store problemet med SQLite: At du kun har en skrivetransaksjon om gangen.
¶Et bedre API
Til tross for at SQL-spesifikasjonen er en ISO-standard, er det kun
hovedkonseptene ved SQL som har lik syntaks i de største
SQL-databasene. Utenom det er ingen av de store databasene i praksis
kompatible med hverandre: Noen raske eksempler som ofte dukker opp er
tidshåndtering, strengkonkatenering, ja, til og med det å begrense
antallet rader du skal hente ut. Er det TOP
, LIMIT
eller FETCH FIRST N ROWS ONLY
du skal bruke? Nå er det ikke rart ting er
forskjellig, for det var først i 2008 at FETCH FIRST N ROWS ONLY ble
den offisielle standarden for å kun hente N rader. Før det var det
teknisk sett ikke mulig med den offisielle SQL-standarden, men vi
hadde fremdeles behov for akkurat dette lenge før 2008. Og nettopp på
grunn av dette har vi all denne kreative variasjonen der ute i
SQL-databasene.
Derfor er det faktisk veldig imponerende at vi har såpass uniforme APIer for å snakke med dem alle. På JVMen kan du bruke JDBC for å koble til enhver SQL-database, i Go har du database/sql-pakken du benytter deg av, og så videre i andre språk. Men det er også veldig begrensende, for hva gjør du om du skal bruke noe litt utenom SQL-spesifikasjonen?
For eksempel har PostgreSQL LISTEN
og NOTIFY
som en slags billig
Pub/Sub. Å bruke det krever at du tar databasetilkoblingen, henter ut
den “rå” PostgreSQL-tilkoblingen på skittent vis, for deretter gjøre
kallene på denne. Og selv om du har muligheten til å bruke dette, er
ikke det eksponerte APIet spesielt effektivt; om du heller bruker
Notifier-mønsteret får du mye bedre
ytelse.
På samme måte som LISTEN
/NOTIFY
ikke er effektivt med det
“vanlige” APIet, er ikke SQLite effektivt med den vanlige
abstraksjonen vi er kjent med for å koble oss til SQL-databaser. Alle
andre databaser håndterer det å ha flere skrivetransaksjoner samtidig,
mens SQLite har som sagt en begrensning på en skrivetransaksjon om
gangen. For å få ytelsen dit vi ønsker, trenger vi derfor et API som
signaliserer hvorvidt en transaksjon er en skrivetransaksjon eller en
lesetransaksjon, slik at vi kan si ifra til SQLite med en gang
hvorvidt vi kommer til å skrive eller ei inne i transaksjonen. Dette
trenger ikke være veldig komplisert, grensesnittet kan for eksempel se
slik ut i Java:
public class TxPool {
public ReadTx getReadTx() throws SQLException {
// start transaksjonen som normalt
}
public ReadWriteTx getReadWriteTx() throws SQLException {
// bruk “BEGIN IMMEDIATE” for å starte opp transaksjonen i skrivemodus
}
}
public class ReadTx implements AutoCloseable {
// typiske lesemetoder her
}
public class ReadWriteTx extends ReadTx {
// typiske skrivemetoder her
}
Operasjoner som skal gjøre lesearbeid tar imot en ReadTx
, mens de
som også trenger å skrive tar imot en ReadWriteTx
. Det jeg liker med
denne modellen er at det også er mye tydeligere at en operasjon
faktisk skriver eller ei, og det gjør det lettere å forstå koden.
.. men virkelig? Bare en skrivetransaksjon om gangen?
Ok, ok, jeg har faktisk løyet litt i de forrige avsnittene: Det er
faktisk mulig å ha flere samtidige skrivetransaksjoner i SQLite. Om du
har en WAL-journal og starter en transaksjon ved å skrive BEGIN CONCURRENT
, kan du spinne opp flere skrivetransaksjoner i parallell.
Men om du begynner å bruke det så er det nesten garantert at du vil
ende opp med en SQLITE_BUSY
-feil – eller mer presist, en
SQLITE_BUSY_SNAPSHOT
-feil. Dokumentasjonen for BEGIN CONCURRENT
har en god forklaring på problemet: To transaksjoner kan kjøre
samtidig, men for at begge to skal kunne committe, må de skrive på
helt forskjellige sider (page) i databasen. Hvis du f.eks. legger inn
nye rader i samme tabell samtidig, er det nærmest garantert at
transaksjonene spenner bein på hverandre med BEGIN CONCURRENT
.
Om du virkelig trenger det kan du prøve å benytte deg av denne
funksjonaliteten. Typisk vil du sette opp en retry-løkke rundt alle
skrivetransaksjoner, og prøve igjen om du får en
SQLITE_BUSY_SNAPSHOT
-feil. Men de færreste trenger faktisk så mye
futt: Prøv uten først og se hvordan det går.
¶Ytelsen er mer enn god nok for deg
Det er mange viktige aspekter når man skal velge databaseteknologi, men ytelse er sjelden en utslagsgivende faktor. Klart det er viktig, men alle de store og kjente SQL-databasene håndterer lett trykket til en vanlig norsk bedrift. Det krever såklart fornuftig bruk: Du bør jo ha gode indekser, fornuftige spørringer og kanskje justert litt på noen databaseparametrene. I SQLite sitt tilfelle bør du også ha et fornuftig grensesnitt som tar hensyn til at du kan skrive fort, men ikke nødvendigvis samtidig.
Se heller på alle de andre aspektene av en database, som er mye viktigere. Skal du jobbe med GIS-data er det sannelig smart å ta PostGIS i bruk. Om du trenger systemversjonerte tabeller går du heller for MariaDB eller SQL Server, som har støtte for sånt. Hvor lett er det å drifte databasen? Og er det lett å lage tester som du kan kjøre lokalt og på en CI-server, og hvor raske er de?
Om du ikke allerede bruker SQLite, håper jeg dette har overbevist deg om at du kan bruke det til mer enn bare mobilapplikasjoner. Det er mer enn raskt nok for tjenester, gitt at du har et bevisst forhold til lese- og skrivetransaksjoner i applikasjonen din. Og framfor ytelse og skalering, tenk heller mer på andre ting du ønsker og trenger i en database. Det kan godt hende SQLite ikke har de tingene du trenger, men da har du i det minste ikke slått den fra deg fordi du trodde den var tregere enn den faktisk er.