Utvecklarskrivbord med kodeditor på skärm och mekaniskt tangentbord i dagsljus
Så funkar det

UUID som primärnyckel i SQLite kan göra insättningar 16 gånger långsammare

Att använda UUID som primärnyckel i SQLite straffar prestandan hårt. I ett test med 100 miljoner rader tog insättningen runt 715 ms med en vanlig INTEGER-nyckel. Med UUID4 i en `WITHOUT ROWID`-tabell sköt samma operation upp till 12 586 ms, nästan 16 gånger långsammare. Orsaken är inte UUID i sig utan slumpmässigheten. Random UUIDs förstör den ordning som SQLite bygger sin lagring kring.

För de flesta hobbyprojekt och appar är INTEGER PRIMARY KEY rätt val. Behöver du globalt unika ID:n finns bättre alternativ än UUID4: tidsorienterade identifierare som UUID v7 och ULID ger unikheten utan att slå sönder prestandan.

Hur SQLite lagrar din data och varför ordning spelar roll

Varje tabell i SQLite har en dold 64-bitars heltalsnyckel som kallas `rowid`. Det är runt den all data faktiskt lagras. SQLite organiserar raderna i en B-tree-struktur sorterad efter `rowid`, vilket innebär att den fysiska ordningen på disk följer nyckelns sekvens. Detta kallas ett clustered index och det är hjärtat i hur databasen presterar.

När du använder `INTEGER PRIMARY KEY` blir den kolumnen ett alias för `rowid`. Nya rader får ett högre tal än föregående och läggs prydligt sist i trädet. Inga omflyttningar, ingen splittring.

En `WITHOUT ROWID`-tabell ändrar spelplanen. Då blir din deklarerade primärnyckel själva det clustrade indexet. Lägger du en slumpmässig UUID där bestämmer slumpen var varje rad hamnar.

Random UUIDs tvingar databasen att skriva om sig själv

Problemet med UUID4 är att värdena är helt slumpmässiga. En ny rad kan lika gärna höra hemma i början, mitten eller slutet av trädet. SQLite måste då hitta rätt position och ofta dela upp en redan full sida för att få plats. Det kallas page split och det händer hela tiden vid slumpmässiga insättningar.

Det skapar en kedjereaktion. Trädet måste ombalanseras, sidor flyttas runt och cachen blir mindre effektiv eftersom data som hör ihop hamnar utspritt. Page cache ser sämre referenslokalitet, vilket tvingar fram fler diskläsningar.

Lägger du UUID4 i en vanlig tabell med rowid blir det dubbelt straff. Då finns två index att underhålla: ett för `rowid` och ett för din UUID. Varje insättning uppdaterar båda, en effekt som brukar kallas write amplification.

Vad prestandakostnaden faktiskt blir

Siffrorna talar tydligt. Här är skillnaden mellan INTEGER-nyckel och UUID4 i `WITHOUT ROWID`-tabeller vid massinsättning:

Antal rader INTEGER PRIMARY KEY UUID4 WITHOUT ROWID
10 miljoner ~838 ms ~2 649 ms
100 miljoner ~715 ms ~12 586 ms

Notera att skillnaden växer med datamängden. Vid 10 miljoner rader är UUID-varianten ungefär tre gånger långsammare. Vid 100 miljoner rader har gapet vidgats till nästan 16 gånger. Ju mer data, desto värre blir fragmenteringen.

Det kostar även i lagring. En INTEGER-nyckel tar 8 byte, en UUID lagrad som BLOB tar 16 byte. Lagrar du UUID:n som TEXT blir indexet ännu större. I praktiken landar lagringsbehovet upp till 20 % högre med UUID jämfört med heltalsnycklar.

Varför folk ändå väljer UUID

Slumpmässiga heltal är lätta att gissa. Står ditt ID i en URL som `/order/1042` är det trivialt att räkna sig till `/order/1043`. UUID:n är till skillnad från löpande räknare i praktiken omöjliga att gissa, vilket skyddar mot enkel uppräkning.

Det finns också tekniska skäl som väger tungt i större system:

  • Distribuerade system: Flera databaser kan generera ID:n oberoende av varandra utan en central koordinator.
  • Sammanslagning av data: Två system kan slås ihop utan krockande nycklar.
  • Klientgenererade ID:n: Ett API kan låta klienten skapa ID:t innan resursen ens når servern.

Problemen syns på riktigt. E-handelsplattformar har rapporterat ökad svarstid efter en övergång från heltalsnycklar till UUID. Sjukvårdssystem som använder UUID för patientjournaler får unikhet över systemgränser men betalar med högre lagringskostnader och långsammare frågor.

UUID v7 och ULID löser problemet

Du behöver inte välja mellan unikhet och prestanda. Tidsorienterade identifierare ger båda. De inleds med ett tidsstämpelbaserat prefix, vilket gör att nya värden hamnar i ordning, precis det som SQLite vill ha.

  • UUID v7: En nyare UUID-variant som är sorterbar. Tidsstämpeln ligger först, så insättningarna blir sekventiella och B-tree-fragmenteringen försvinner. Du behåller standardformatet för UUID.
  • ULID: Står för Universally Unique Lexicographically Sortable Identifier. Den är base32-kodad, vilket gör den kortare och mer URL-vänlig än en vanlig UUID. Sorterbar av samma anledning som v7.
  • Snowflake-ID:n: Bra för distribuerad ID-generering med högre prestanda än UUID4 men kräver mer infrastruktur runtomkring.

Att UUID v7 nu är på frammarsch märks även i andra databaser. PostgreSQL 17, som släpptes i september 2024, fick en inbyggd `uuid_v7()`-funktion. Tankesättet är detsamma oavsett databas: behåll fördelarna med en UUID men gör den sorterbar så att det clustrade indexet slipper jobba i onödan.

Vill du leka med olika ID-format direkt i webbläsaren kan du testa vårt hash- och kryptoverktyg, som genererar UUID och annat på sekunden.

Så får du SQLite att prestera oavsett nyckel

Stannar du kvar vid UUID4 av andra skäl finns knep som dämpar smällen. Det första är enkelt: gruppera dina skrivningar.

Samla flera insättningar i en transaktion istället för att skriva en rad i taget. Det minskar antalet checkpoints och håller nere overhead i loggen. Aktivera samtidigt WAL-läge med `PRAGMA journal_mode=WAL`, vilket minskar konkurrensen mellan läsare och skrivare.

Tre konkreta åtgärder som hjälper en fragmenterad databas:

  • Justera sidstorleken med `page_size`, en större sida kan minska antalet page splits för breda rader.
  • Kör `VACUUM` och `ANALYZE` regelbundet för att återta ledigt utrymme och uppdatera statistiken.
  • Överväg `PRAGMA incremental_vacuum` om databasen lider av kronisk fragmentering.

Men ingen av dessa fixar grundproblemet. De lindrar symtomen av slumpmässiga nycklar. Vill du bli av med orsaken byter du till en sorterbar identifierare. Är du nyfiken på hur liknande prestandaavvägningar dyker upp i andra sammanhang kan du läsa om hur en webbläsare hanterar resurser från URL till färdig sida, principerna om ordning och lokalitet är förvånansvärt lika.

FAQ

Är UUID alltid dåligt i SQLite?

Nej. Problemet är slumpmässiga UUID:n som UUID4, inte UUID som koncept. En sorterbar variant som UUID v7 ger unikheten utan att förstöra B-tree-ordningen. För många appar är dock INTEGER PRIMARY KEY både snabbast och enklast.

Vad är skillnaden mellan UUID4 och UUID v7?

UUID4 är helt slumpmässig, medan UUID v7 inleds med en tidsstämpel och därför är sorterbar. Tidsstämpeln gör att nya rader hamnar i ordning i databasen, vilket eliminerar fragmenteringen som UUID4 orsakar. Båda är svåra att gissa.

Vad betyder WITHOUT ROWID i SQLite?

`WITHOUT ROWID` är en tabelltyp där din deklarerade primärnyckel blir det clustrade indexet i stället för den dolda `rowid`-kolumnen. Det sparar utrymme för smala tabeller men med slumpmässiga nycklar förvärrar det fragmenteringen.

Hur mycket större blir databasen med UUID?

En UUID tar 16 byte som BLOB mot 8 byte för en INTEGER-nyckel och ännu mer om den lagras som TEXT. I praktiken kan lagringsbehovet bli upp till 20 % större med UUID jämfört med heltalsnycklar.

Bör jag använda ULID eller UUID v7?

Båda är sorterbara och löser fragmenteringsproblemet. ULID är kortare och mer URL-vänlig tack vare base32-kodning, medan UUID v7 håller sig till standardformatet för UUID. Behöver du kompatibilitet med befintliga UUID-system, välj v7. Vill du ha kortare ID:n i URL:er, välj ULID.

Källor

  • `WITHOUT ROWID`-tabell sqlite.org
  • det clustrade indexet en.wikipedia.org

Kommentera artikeln

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *