Database-migrationer i teams (så du slipper for ‘det virker på min maskine’)

Database-migrationer i teams (så du slipper for ‘det virker på min maskine’)

Jeg har engang trykket “kør migration” fredag eftermiddag på en delt database og tænkt: “Det går nok, det er jo bare en lille ændring”. Tre minutter senere sad en kollega og kunne ikke logge ind i sin lokale app, mine tests fejlede, og vi brugte en halv time på at gætte, hvem der havde kørt hvad, hvor og hvornår.

Fejlen? Ingen fælles arbejdsgang. Bare “den der lige er færdig, kører migrationen”. Det fungerer nogenlunde, lige indtil flere begynder at skifte på det samme schema.

I den her artikel bygger vi en simpel, men ret stram workflow omkring database migrations, som et helt team kan bruge uden at få ondt i maven hver gang nogen skal ændre en kolonne.

Hvorfor database migrations går galt i teams

De fleste problemer med database migrations handler ikke om værktøjet. De handler om mennesker, timing og mangel på aftaler.

Scenario 1: “Det virker på min DB”

En klassiker: Du laver en feature-branch, smider en migration på, kører den mod din lokale database, alt spiller. Du åbner en pull request, den bliver godkendt, I merger og deployer.

Men en anden udvikler har lavet en anden migration, i en anden branch, med ændringer i de samme tabeller. I ender med:

  • Konflikt i migrations-filerne
  • Test-databasen der ikke matcher prod
  • Nogen der manuelt retter i databasen for at “komme tilbage på sporet”

Pludselig er der tre versioner af sandheden: din lokale, CI-databasen og produktion.

Scenario 2: Destruktive ændringer direkte i produktion

En anden favorit: Nogen renamer en kolonne ved at droppe den gamle og oprette en ny i én migration. Backend-koden bliver deployet samtidig.

Hvad sker der så?

  • Requests rammer databasen midt i ændringen
  • Nogle queries forventer den gamle kolonne, andre den nye
  • Prod logger pludselig fejl på hver anden request

Hvis I er hurtige, ruller I deployet tilbage. Hvis ikke, har I lige brudt noget for brugerne.

Scenario 3: Rollback der ikke ruller noget som helst tilbage

Sidste scenarie: Migrationen fejler midtvejs. I tænker “vi ruller bare tilbage”. Men:

  • Nogle trin i migrationen har nået at køre
  • Rollback-scriptet er enten generisk eller ikke testet
  • Data er måske allerede slettet eller ændret

Så står I dér med en halvt opdateret database og et rollback-script, der mest af alt gør situationen endnu mere akavet.

Det er her, en bevidst strategi for roll-forward kontra rollback begynder at give mening.

Roll-forward som standard (og hvornår rollback faktisk giver mening)

Jeg plejer at tænke på databaseændringer som tidspunkter, ikke som ting man kan spole frem og tilbage i som en film.

Derfor giver en “roll-forward” tankegang som standard ofte mere ro i maven end “vi ruller bare tilbage”.

Hvad betyder roll-forward i praksis?

Roll-forward betyder: når noget går galt, retter du med en ny migration, i stedet for at prøve at køre en magisk omvendt version af den gamle.

Eksempel:

  • Migration 12 tilføjer en kolonne nickname til users
  • Den viser sig at være forkert (forkert type, ikke-null, hvad som helst)
  • I stedet for at rulle 12 tilbage, laver du migration 13 der retter problemet

På den måde bevarer du en lineær historik: 1, 2, 3, …, 12, 13. Databasen kender kun én vej: frem.

Hvornår giver rollback mening?

Rollback kan give mening i to situationer:

  1. Lokalt eller i testmiljøer
    Her bruger du rollback til hurtigt at nulstille databasen efter eksperimenter.
  2. Helt tidligt i et incident
    Hvis migrationen fejler meget tidligt, før den når at lave halve ændringer, og dit værktøj kan garantere transaktionel sikkerhed (alt eller intet).

Men den plan du læner dig op ad kl. 23:41, når prod er rød, bør være “lav en ny migrations-fix” frem for “rul 17 skridt tilbage i en ukendt database-tilstand”.

Workflow: fra feature-branch til produktion

Lad os bygge en arbejdsgang, der virker både til små teams og lidt større setups. Jeg tager udgangspunkt i noget a la Prisma Migrate, Flyway eller Liquibase, men mønstret er generelt.

1. Én sandhed: migrations er den officielle datamodel

Første regel: Ingen ændrer schema direkte i databasen. Ingen “jeg lige hurtigt retter i prod”.

Alt schema styres via migrations, og migrations ligger i versionsstyring (Git). Det er her, den officielle sandhed bor.

2. Ny feature, ny branch, ny migration

Når du laver en ny feature, der kræver schema-ændringer:

  1. Opret en feature-branch
  2. Lav schema-ændringerne i din ORM / SQL / migrationsværktøj
  3. Generér en ny migration og commit den i branchen
  4. Kør migrationen mod din lokale database

Test lokalt. Både kode og database skal være i sync dér, før du overhovedet tænker på at åbne en pull request.

3. Navngivning og rækkefølge så man ikke får grå hår

Navngiv migrations så det er til at gætte, hvad de gør. Ikke bare 20240403_132501.sql.

Eksempel på navngivning:

  • 2024-04-03T13-25-add-nickname-to-users.sql
  • 2024-04-03T13-32-rename-fullname-to-name.sql

Brug timestamp + kort beskrivelse. De fleste værktøjer styrer rækkefølgen via enten nummer eller timestamp. Du skal bare sikre:

  • Ingen manuelt modificerer gamle migrations
  • Nye migrations ligger som nye filer i slutningen

Hvis to branches laver migrations parallelt, ender du typisk med merge-konflikt i versionsfilen (eller i mapperækkefølgen). Løsning:

  1. Merge main ind i din branch
  2. Generér en ny migration, der bygger videre på den nuværende state
  3. Slet den gamle, der ikke længere giver mening, inden du merger til main

Det føles irriterende den første uge, men det er markant mindre smertefuldt end at gætte på prod-state senere.

4. Pull request: migration og kode sammen

En vigtig regel: Migration og kode skal lande sammen.

Altså: den PR, der ændrer koden til at bruge en ny kolonne, skal også indeholde migrationen der tilføjer den kolonne.

Hvis I bruger noget ala CI pipelines, skal den:

  • Spinne en frisk test-database op
  • Køre alle eksisterende migrations i rækkefølge
  • Køre tests bagefter

Fejler migrations i CI, kommer den aldrig tæt på produktion, og du har reddet dig selv for en sen aftenvagt.

5. Review: hvad skal man kigge efter i en migration?

Review af migrations er lidt en overset disciplin. De fleste scroller bare forbi og tænker “ser fint ud”.

Men der er nogle helt konkrete ting, jeg altid kigger efter.

Migration review-tjekliste

  • Destruktive ændringer?
    Drop af kolonner, tabeller eller constraints. Hvis ja: er der en plan for data, og skal det gøres i flere trin?
  • Lange blokeringer?
    Store ALTER TABLE uden fornuftig strategi kan låse tabellen i lang tid. Især på store tabeller.
  • Data-migrering inline?
    Hvis der står UPDATE users SET ... på en kæmpe tabel, kan det være bedre som et batch-job i appen.
  • Default values og nullability?
    Er nye kolonner med NOT NULL dækket af default eller data backfill?
  • Expand/contract?
    Ved rename/ændring af type: er det splittet op i flere migrations, så man undgår downtime?

Hvis I arbejder med Prisma, kan det også give mening at kigge på schema-diff. Se hvordan deres Prisma Migrate beskriver de foreslåede ændringer, og om det matcher det, I forventer.

6. Deploy: migrations først, eller kode først?

Her er en simpel regel jeg selv bruger:

  • Hvis det er en udvidelse (ny kolonne, der ikke bruges endnu): kør migrationen først, deploy kode bagefter.
  • Hvis det er en kontraktion (fjerne noget kode, der holder op med at bruge en kolonne): deploy kode der ikke bruger feltet, kør migrations efter.

Hvis du vil gøre det ekstra sikkert, kan din deploy-pipeline se sådan her ud:

  1. Stop kortvarigt ny deploy-trafik (blue/green eller rolling, afhængigt af setup)
  2. Kør migrations mod databasen
  3. Rul ny kode ud

Det hænger også tæt sammen med næste mønster: zero downtime med expand → migrate → contract.

Zero downtime-mønster: expand → migrate → contract

Expand/contract er et lille mønster, som redder dig hver gang du skal lave større ændringer i databasen, uden at smadre produktion.

Case: rename af en kolonne uden at slå prod ihjel

Forestil dig, at du vil renam’e fullname til name i tabellen users.

Den naive løsning:

  1. Opret name
  2. Kopier data fra fullname til name
  3. Slet fullname
  4. Opdater kode til at bruge name

Hvis alt det ligger i én migration og sammen med én kode-deploy, har du et kort vindue hvor koden måske forventer name mens tabellen kun har fullname, eller omvendt.

Expand/contract deler det op i tre faser.

Fase 1: Expand

Du gør databasen mere tolerant, ikke mindre.

-- Migration A: expand
ALTER TABLE users ADD COLUMN name TEXT NULL;

Deploy kode der:

  • Skriver til både fullname og name
  • Læser fra name hvis ikke null, ellers falder tilbage til fullname

Systemet virker stadig, gamle data lever, nye requests lander begge steder.

Fase 2: Migrate (data)

Nu skal eksisterende data over på den nye kolonne. Det kan ske på to måder:

  • Som en database-migration med UPDATE users SET name = fullname WHERE name IS NULL;
  • Som et batch-job i applikationen (f.eks. et cron-job eller engangs-script)

Pointen er: du har tid. Du kan endda overvåge hvor mange rækker der mangler, før du går videre.

Fase 3: Contract

Først når al data er flyttet, og du har været sikker et stykke tid, laver du “opstramningen”:

-- Migration B: contract
ALTER TABLE users DROP COLUMN fullname;
ALTER TABLE users ALTER COLUMN name SET NOT NULL;

Nu kan du også forenkle koden til kun at bruge name.

Samme mønster virker til:

  • Ændring af datatyper
  • Splitting af én tabel til to
  • Indførsel af nye constraints

Det føles måske langsomt, men det er sådan du får zero downtime migrations i praksis.

CI der fanger farlige migrations automatisk

Hvis du har et CI-setup i forvejen, kan du gøre rigtig meget for at fange migrations-fejl tidligt. Især i små projekter bliver det her tit sprunget over, selvom det faktisk er nemt at få noget basalt på plads.

1. Kør migrations mod en frisk database i hver build

Det vigtigste: CI skal kunne spinne en helt frisk database op og køre alle migrations fra 0.

I pseudo-YAML kunne det ligne noget i stil med:

steps:
  - name: Start database
    run: docker run -d -p 5432:5432 postgres:16

  - name: Kør migrations
    run: npm run migrate:up

  - name: Kør tests
    run: npm test

Hvis en migration fejler, eller to migrations ikke kan leve i samme historik, opdager du det her, ikke i prod.

2. En simpel “farlig-migration” check

Du kan også lave små automatiske “lint checks” på dine migrations-filer.

Idé: skriv et lille script, der scanner migrations for bestemte mønstre:

  • DROP TABLE
  • DROP COLUMN
  • ALTER TABLE ... SET NOT NULL uden default

Hvis scriptet finder dem, kan CI:

  • Fejle buildet helt, eller
  • Bare skrive en stor advarsel og kræve manuel godkendelse fra en ekstra reviewer

Det føles lidt hardcore første gang man rammer det, men det er meget billigere end at miste data ved et uheld.

3. Migrations i multi-miljø: dev, staging, prod

Din pipeline for migrations bør gå nogenlunde sådan her:

  1. Kør migrations i dev-miljøet (automatisk ved deploy)
  2. Kør de samme migrations i staging
  3. Når de har kørt fint dér, er de “godkendt” til prod

Her kan Flyway, Liquibase og lignende hjælpe med at logge præcis, hvilke migrations der er kørt hvor. Hvis du er helt ny til de tools, kan du kigge på Flyway-dokumentationen for at se, hvordan de registrerer versions-staten direkte i databasen.

Seed-data og testdatabaser uden at blande miljøer

En anden kilde til kaos er seed-scripts, der bliver kørt lidt vilkårligt i forskellige miljøer.

Hold schema og seed-data adskilt

Schema-migrations og seed-data er to forskellige ting:

  • Migrations beskriver strukturen: tabeller, kolonner, constraints
  • Seed beskriver indholdet du skal bruge: testbrugere, demo-data osv.

Jeg plejer at have to kommandoer:

npm run migrate:up   # opdaterer schema
npm run db:seed      # lægger testdata ind

Og så aldrig køre db:seed i produktion. Punktum.

Testdatabaser skal være billige at smide ud

Din testdatabase skal være noget, du ikke er bange for at droppe. I CI bliver den typisk oprettet og slettet automatisk.

Lokalt kan du gøre det nemt for dig selv:

npm run db:reset
# intern kommando der gør noget i stil med:
#   - dropper database
#   - opretter den igen
#   - kører alle migrations
#   - kører db:seed (hvis det er ønsket)

Jo nemmere det er at resette, jo mindre fristende er det at begynde at “håndlappe” fejl direkte i databasen.

Incident-plan: når migrationen fejler midt i et deploy

Nu til det ubehagelige: hvad gør du, hvis en migration fejler halvvejs i et deploy mod produktion?

Hvis du ikke har tænkt over det før, ender du ofte i en blanding af panik og manuelle rettelser, som du så skal leve med i årevis.

1. Stop deployet, frys traffik-ændringer

Første skridt: Stop med at skubbe mere kode ud. Hvis du bruger et deploy-værktøj, så lad det mislykkede deploy forblive i rød status, indtil du forstår problemet.

Hvis du kører blue/green eller rolling deploys, så sørg for at ny trafik ikke bliver skubbet over på en halv-migreret database uden at du ved hvorfor det fejler.

2. Find den præcise fejl i migrations-loggen

De fleste migrationsværktøjer har en log eller en migrations-tabel i databasen, som viser hvilken version der er sidst kørt, og hvor den fejlede.

Tjek:

  • Hvilken migration er sidst kørt helt igennem?
  • Hvilken statement fejlede i den seneste migration?
  • Har noget af den problematiske statement nået at ændre data?

Hvis værktøjet bruger transaktioner omkring hele migrationen, kan du være heldig at alt er rullet tilbage automatisk. Hvis ikke, er det her, hvor roll-forward-strategien kommer i spil.

3. Vurder: kan vi køre en lille “hotfix-migration”?

I mange tilfælde kan du løse problemet ved at:

  1. Lave en ny migration, der retter tilstanden, så den matcher det kode-release du vil have liggende i prod
  2. Køre den migration manuelt mod prod (med meget stor respekt og chat-kanalen åben)

Eksempler:

  • En kolonne blev tilføjet med forkert datatype → ny migration der tilføjer korrekt kolonne og migrerer data
  • En constraint gjorde alt for mange rækker invalide → midlertidig fjernelse af constrainten, efterfulgt af data-oprydning

Det føles lidt ubehageligt at patche på prod, men forskellen er: her gør du det via værktøjet, så det er registreret i historikken, i stedet for at skrive tilfældige SQL-kommandoer direkte i klienten.

4. Rollback af kode, ikke nødvendigvis database

Hvis du skal rulle noget tilbage i et incident, så lad det hellere være kode-deployet end databasen.

Koden er ofte nemmere at flytte frem og tilbage (nye builds, ny container-version, etc.). Databasen er mere “sticky”, fordi den rummer dine brugeres data.

Så hvis du står mellem:

  • A: køre et rollback-script på databasen og håbe det rammer rigtigt
  • B: rulle kode-deployet tilbage til en version, der stadig kan tale med den nuværende database-state

… så ville jeg næsten altid vælge B, og så bagefter rette databasen roligt med en ny migration.

5. Skriv incidentet ned (og opdater jeres workflow)

Hvis I en dag har et migrations-incident, og det kostede jer en aften, så brug lige 20 minutter bagefter på at skrive ned, hvad der skete.

Og vigtigere: opdater jeres workflow. Måske skal I:

  • Lægge en ekstra CI-check på destruktive migrations
  • Indføre expand/contract som standard, når noget renames
  • Have en regel om, at migrations ikke merges sent fredag eftermiddag

Det er kedeligt process-arbejde, men det er også forskellen på at turde lave databaseændringer og at udskyde dem i måneder, fordi ingen gider være den, der trykker på knappen.

Brug expand/contract-mønsteret: 1) Tilføj den nye kolonne nullable eller med default, 2) Backfill værdier asynkront i batches, 3) Opdatér koden til at skrive både ny og gammel kolonne, 4) Skift læsninger til den nye kolonne i en deploy, 5) Når alt er stabilt, fjern den gamle kolonne i en separat migration. Hver fase deployes separat, så der aldrig er et øjeblik hvor kodestykker forventer forskellige schemas.
Kør backfills i små batches (fx LIMIT/offset eller key-range) med pauser mellem batches, brug indeks til at målrette rækker og gør arbejdet i en baggrundsjob-queue. Alternativt kan du opbygge et nyt tabel-sæt offline og switche med en hurtig rename, så runtime-impact minimeres.
Sørg for en lineær migrations-strøm: kræv at PRs rebaser eller kører migrations imod CI-databasen med den nyeste main før merge, brug entydige timestamps eller sekventielle numre og håndhæv regler i CI (fx fejlsæt PR hvis migrations kolliderer). Overvej også et simpelt migrations-lock i CI/CD for at forhindre parallel kørsel mod shared staging.
Stop yderligere deploys og tag øjeblikkelig backup af databasen, identificer hvilke trin der er kørt, og skriv en kompensations-migration der enten fuldfører eller stabiliserer tilstand (fx genskab missing kolonner eller sæt safe default). Kommuniker til teamet, kør ændringen i kontrollerede batches og test på staging før endelig kørsel.

Ida Balslev er den type ven, der pludselig dukker op i din messenger med et link til en lille web-app, hun lige har bygget for sjov – og bagefter gerne viser dig, hvordan du selv kan lave den. Hendes passion for kodning startede med en hjemmebygget hjemmeside til en hestestald og er langsomt vokset gennem aftener med tutorials, fejlmeldinger og små, hjemmelavede projekter.

På Codingclass.dk deler Ida den viden, hun selv manglede i starten: konkrete eksempler, tydelige forklaringer og ærlige historier om, hvad der typisk går galt første, anden og tredje gang. Hun elsker at tage et abstrakt begreb som fx "API" eller "asynkron JavaScript" og koge det ned til noget, du kan se, klikke på og lege med i browseren. For hende handler kodning ikke om at være perfekt, men om at turde prøve, bryde ting og bygge dem op igen.

Ida skriver især om webudvikling med HTML, CSS og JavaScript, små Python-scripts og grundlæggende koncepter som debugging, versionsstyring og struktur i din kode. Hun tænker altid i næste skridt: når du først forstår idéen, viser hun dig, hvordan du kan udvide det med en ekstra funktion, lidt pænere styling eller en smartere måde at tænke din kode på.

Gennem sine artikler på Codingclass.dk vil Ida gerne give dig følelsen af, at du ikke sidder alene med koden – men at der faktisk er en, der har kæmpet med de samme fejlmeddelelser og nu gerne vil vise dig en vej igennem dem, i et tempo hvor alle kan være med.

Send kommentar

You May Have Missed