Databasemigreringer uden nedetid: En praktisk guide

Introduktion

“Vi er nødt til at tage siden ned til vedligeholdelse” er en sætning, der burde være uddød i 2024. Alligevel ser jeg stadig teams, der planlægger nedetid til databasemigreringer, som sagtens kunne udføres sikkert online.

Denne guide dækker de mønstre, jeg bruger til at migrere databaser uden nedetid, selv for tabeller med millioner af rækker.

Den gyldne regel

Foretag aldrig en ændring, der er inkompatibel med den applikationskode, der kører lige nu.

Det betyder, at migreringer sker i flere faser:

  1. Deploy kode, der fungerer med både det gamle og det nye skema
  2. Kør migreringen
  3. Deploy kode, der kun fungerer med det nye skema
  4. Ryd op (valgfrit)

Sikre operationer

Disse operationer er generelt sikre at køre uden nedetid:

Tilføjelse af en nullable kolonne

ALTER TABLE users ADD COLUMN middle_name VARCHAR(100);

Dette er sikkert, fordi:

  • Eksisterende kode ikke kender kolonnen (ignorerer den)
  • Ny kode kan håndtere NULL-værdier

Tilføjelse af et indeks samtidigt

CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

Nøgleordet CONCURRENTLY er afgørende - uden det låses tabellen under indeksoprettelsen.

Tilføjelse af en ny tabel

CREATE TABLE user_preferences (
  user_id INTEGER REFERENCES users(id),
  preferences JSONB
);

Nye tabeller påvirker ikke eksisterende kode.

Farlige operationer

Disse kræver omhyggelig håndtering:

Omdøbning af en kolonne

Forkert måde (forårsager nedetid):

ALTER TABLE users RENAME COLUMN name TO full_name;

Rigtig måde (ingen nedetid):

  1. Tilføj ny kolonne:
ALTER TABLE users ADD COLUMN full_name VARCHAR(200);
  1. Deploy kode, der skriver til begge kolonner:
await db.query(
  'UPDATE users SET name = $1, full_name = $1 WHERE id = $2',
  [name, userId]
);
  1. Efterudfyld eksisterende data:
UPDATE users SET full_name = name WHERE full_name IS NULL;
  1. Deploy kode, der læser fra den nye kolonne, men skriver til begge

  2. Deploy kode, der kun bruger den nye kolonne

  3. Slet den gamle kolonne:

ALTER TABLE users DROP COLUMN name;

Tilføjelse af en NOT NULL-betingelse

Forkert måde:

ALTER TABLE users ALTER COLUMN email SET NOT NULL;

Dette gennemsøger hele tabellen og låser den.

Rigtig måde:

  1. Tilføj en check-betingelse (låser ikke):
ALTER TABLE users ADD CONSTRAINT users_email_not_null 
  CHECK (email IS NOT NULL) NOT VALID;
  1. Validér betingelsen (gennemsøger, men låser ikke):
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
  1. Konvertér til NOT NULL (øjeblikkeligt, bruger eksisterende betingelse):
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_not_null;

Ændring af kolonnetype

Forkert måde:

ALTER TABLE orders ALTER COLUMN amount TYPE DECIMAL(10,2);

Rigtig måde: Samme mønster som ved omdøbning - tilføj ny kolonne, migrér data, skift over.

Migreringer af store tabeller

For tabeller med millioner af rækker kræver selv “sikre” operationer omhu.

Batchvis efterudfyldning

Opdatér aldrig millioner af rækker i én transaktion:

async function backfillInBatches(batchSize = 1000) {
  let processed = 0;
  
  while (true) {
    const result = await db.query(`
      UPDATE users 
      SET full_name = name 
      WHERE id IN (
        SELECT id FROM users 
        WHERE full_name IS NULL 
        LIMIT $1
      )
      RETURNING id
    `, [batchSize]);
    
    processed += result.rowCount;
    console.log(`Processed ${processed} rows`);
    
    if (result.rowCount < batchSize) break;
    
    // Kort forsinkelse for at reducere belastningen
    await sleep(100);
  }
}

Overvågning under migreringer

Hold øje med disse målinger:

  • Database-CPU og -I/O
  • Replikationsforsinkelse
  • Forespørgselslatens
  • Ventetider på lås

Test af migreringer

Lokal test

  1. Dump produktionsskemaet (ikke dataene)
  2. Anvend migreringen lokalt
  3. Kør applikationstests

Staging-test

  1. Gendan en nylig produktionsbackup i staging
  2. Kør migreringen
  3. Verificér, at applikationen fungerer
  4. Tjek migreringens varighed

Tør prøvekørsel i produktion

Til kritiske migreringer:

BEGIN;
-- Kør migrering
-- Tjek resultater
ROLLBACK;

Rollback-strategi

Hav altid en rollback-plan:

  1. Additive ændringer: Kræver normalt ikke rollback (nye kolonner ignoreres)
  2. Datamigreringer: Behold den gamle kolonne, indtil du er sikker
  3. Destruktive ændringer: Hav en genoprettelsesplan

Et konkret eksempel: Tilføjelse af en fremmednøgle

Vi skulle tilføje en fremmednøgle fra orders til customers på en tabel med 50 mio. rækker.

Fremgangsmåde:

  1. Tilføj betingelse uden validering:
ALTER TABLE orders 
ADD CONSTRAINT fk_orders_customer 
FOREIGN KEY (customer_id) REFERENCES customers(id) 
NOT VALID;
  1. Validér i batches i en periode med lav trafik:
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;

Resultat: Ingen nedetid, gennemført på 45 minutter under normal trafik.

Værktøjer og automatisering

Migrerings-lintere

Brug værktøjer, der fanger farlige mønstre:

Integration i deployment

Gør migreringer til en del af din deployment-pipeline:

  1. Kør migreringer, før ny kode deployes
  2. Verificér, at migreringerne lykkedes
  3. Deploy ny applikationskode

Konklusion

Migreringer uden nedetid kræver mere planlægning og flere deployments, men det er indsatsen værd. Dine brugere oplever ingen afbrydelser, og du kan deploye med tillid til enhver tid.

De centrale principper:

  • Bryd aldrig kompatibiliteten med den kørende kode
  • Foretag ændringer i små, reversible trin
  • Test grundigt før produktion
  • Overvåg under og efter migreringer

Begynd at behandle databaseændringer med samme omhu som applikationskode, og “planlagt vedligeholdelse” bliver fortid.