Torna al blog
Migrazioni di Schema Senza Downtime: Expand, Migrate, Contract

Migrazioni di Schema Senza Downtime: Expand, Migrate, Contract

9 min di lettura1 visualizzazioni
di Marco Salmaso
Cambiare lo schema di un database in produzione senza fermare il servizio è possibile, ma serve un metodo. Vediamo perché una ALTER TABLE può bloccare tutto e come applicare il pattern Expand, Migrate, Contract con esempi concreti in PostgreSQL.

Un deploy passa tutti i test in staging, viene promosso in produzione e pochi secondi dopo il servizio smette di rispondere. Il codice è lo stesso che girava poco prima, i test erano verdi. Il colpevole, molto spesso, è una singola riga di DDL: un ALTER TABLE che chiede un lock esclusivo su una tabella che in quel momento sta servendo richieste.

Lo schema di un database non è un artefatto statico. Cambia in continuazione, e ogni cambiamento deve convivere con il codice che sta girando in quel momento, su una tabella che è anche in lettura e scrittura. Il pattern Expand, Migrate, Contract esiste esattamente per questo: trasformare una modifica di schema in una sequenza di passi che restano sempre compatibili con la versione precedente dell'applicazione.

Perché una ALTER TABLE può bloccare tutto

PostgreSQL gestisce la concorrenza con i lock. Una ALTER TABLE chiede tipicamente un lock ACCESS EXCLUSIVE, che confligge con qualsiasi altro accesso alla tabella, comprese le semplici SELECT.

Il dettaglio che trasforma un problema da millisecondi a minuti è la coda dei lock. Se una DDL è in attesa del lock, tutte le query che arrivano dopo si mettono in coda dietro di lei, anche se sarebbero compatibili tra loro. Quindi una ALTER TABLE che da sola impiegherebbe pochi millisecondi può bloccare il servizio per decine di secondi, il tempo che la transazione che detiene il lock esclusivo finisca.

A questo si aggiungono le operazioni che riscrivono fisicamente la tabella. Non tutte le DDL sono uguali:

  • aggiungere una colonna nullable senza default è un'operazione sui metadati, quasi istantanea;
  • aggiungere una colonna con un default costante è veloce da PostgreSQL 11 in poi, perché il valore viene materializzato in lettura;
  • cambiare il tipo di una colonna, o aggiungere un default con un'espressione volatile, richiede una riscrittura completa della tabella e di tutti i suoi indici.

Su una tabella grande, la differenza tra questi tre casi è la differenza tra un deploy invisibile e un incidente.

Il pattern Expand, Migrate, Contract

L'idea è semplice: non si modifica mai lo schema in un colpo solo. Si aggiunge la nuova struttura (expand), si portano i dati e il codice sulla nuova struttura (migrate), e solo alla fine si rimuove quella vecchia (contract). Tra una fase e l'altra possono passare giorni, e in ogni momento il codice vecchio e quello nuovo devono funzionare.

Fase 1 — Expand

Si aggiunge la nuova colonna o la nuova tabella senza toccare nulla di esistente. Il codice attualmente in produzione continua a funzionare senza sapere che esiste.

SET lock_timeout = '3s';
SET statement_timeout = '30s';

ALTER TABLE ordini ADD COLUMN email text;

Qui il caso classico è il rinomina di colonna. Rinominare customer_email in email sembra la cosa più naturale del mondo, ma rompe istantaneamente ogni versione del codice che usa il vecchio nome. Con expand/contract si aggiunge invece una colonna nuova e si lascia in pace quella vecchia.

Fase 2 — Migrate

La nuova colonna va riempita. Il modo sbagliato è una singola UPDATE ordini SET email = customer_email; su tutta la tabella: una transazione lunga, che genera bloat, ritarda il vacuum e può mandare in ritardo le repliche.

Il modo giusto è un backfill a lotti, con commit frequenti e pause tra un lotto e l'altro:

import time
import psycopg

BATCH = 5_000

def backfill(conn, batch_size=BATCH, pausa=0.05):
    while True:
        with conn.cursor() as cur:
            cur.execute(
                """
                WITH da_migrare AS (
                    SELECT id
                    FROM ordini
                    WHERE email IS NULL
                    LIMIT %s
                    FOR UPDATE SKIP LOCKED
                )
                UPDATE ordini o
                SET email = o.customer_email
                FROM da_migrare d
                WHERE o.id = d.id
                RETURNING o.id
                """,
                (batch_size,),
            )
            righe = cur.rowcount
        conn.commit()
        if righe == 0:
            break
        time.sleep(pausa)

FOR UPDATE SKIP LOCKED evita che il backfill si scontri con le scritture applicative sulle stesse righe. Il LIMIT tiene ogni transazione breve. La pausa lascia respiro al database e alla replica.

Durante questa fase il codice deve scrivere su entrambe le colonne. Due strade:

  • far scrivere l'applicazione sia su customer_email sia su email, in un deploy intermedio;
  • usare un trigger che mantiene allineata la colonna nuova, se non si vuole toccare il codice applicativo.

La prima è più esplicita e più facile da rimuovere; la seconda è più invasiva ma non richiede un rilascio. In entrambi i casi, la lettura può passare alla colonna nuova solo quando il backfill è completo.

Fase 3 — Contract

Si rimuove la vecchia struttura. Questa è la fase che quasi tutti sbagliano, perché la si fa troppo presto: se un job batch dimenticato, una dashboard o un servizio secondario legge ancora customer_email, la rimozione rompe qualcosa che nessuno stava guardando.

Prima di cancellare, conviene verificare davvero chi usa la colonna:

SELECT calls, left(query, 120) AS query
FROM pg_stat_statements
WHERE query ILIKE '%customer_email%'
ORDER BY calls DESC
LIMIT 20;

Se il conteggio è a zero da un periodo ragionevole, si può procedere. E anche la rimozione ha le sue regole: un DROP COLUMN è un'operazione sui metadati, ma richiede comunque il lock esclusivo, quindi va fatta con gli stessi timeout della fase di expand.

Le operazioni che sembrano innocue e non lo sono

OperazioneRischio realeAlternativa
ADD COLUMN con default non costanteriscrittura completa della tabellaaggiungere nullable, backfill, poi impostare il default
ALTER COLUMN ... TYPEriscrittura + lock esclusivonuova colonna, backfill, swap
SET NOT NULLscansione completa della tabellaCHECK ... NOT VALID, poi VALIDATE CONSTRAINT, poi SET NOT NULL
CREATE INDEXblocca le scritture per tutta la durataCREATE INDEX CONCURRENTLY
ADD FOREIGN KEYverifica tutte le righe esistentiNOT VALID, poi VALIDATE CONSTRAINT
ADD CHECKscansione completaNOT VALID, poi VALIDATE CONSTRAINT
RENAME COLUMNrompe ogni codice che usa il vecchio nomeexpand/contract

Il pattern NOT VALID + VALIDATE CONSTRAINT merita una nota: la validazione prende un lock SHARE UPDATE EXCLUSIVE, che non blocca letture e scritture. È il modo standard per aggiungere vincoli su tabelle vive senza fermare nulla.

CREATE INDEX CONCURRENTLY ha invece due trappole: non può essere eseguito dentro un blocco transazionale, e se fallisce lascia un indice invalid. Vale la pena controllarlo:

SELECT indexrelid::regclass AS indice, indisvalid
FROM pg_index
WHERE NOT indisvalid;

Se compare qualcosa, l'indice va eliminato con DROP INDEX CONCURRENTLY e ricreato.

Timeout e idempotenza: le due difese di base

La prima difesa è non aspettare il lock all'infinito. Impostare lock_timeout prima di ogni DDL significa che la migrazione fallisce in fretta invece di bloccare il servizio:

SET lock_timeout = '3s';
ALTER TABLE ordini ADD COLUMN email text;
RESET lock_timeout;

Se scade, si ritenta più tardi. Un deploy fallito è molto meglio di un'applicazione che non risponde.

La seconda difesa è rendere le migrazioni idempotenti, così che un retry non si trasformi in un errore diverso:

ALTER TABLE ordini ADD COLUMN IF NOT EXISTS email text;
CREATE INDEX CONCURRENTLY IF NOT EXISTS ordini_email_idx ON ordini (email);

Un sistema di migrazioni che tiene traccia delle versioni applicate è comunque necessario (Flyway, Alembic, Liquibase, golang-migrate, dbmate sono tutte scelte ragionevoli), ma non basta: la versione registrata dice cosa è già stato eseguito, non se l'operazione è sicura mentre gira.

Il vincolo che tiene tutto insieme

La regola operativa da tenere a mente è una sola: ogni migrazione deve essere compatibile con la versione precedente del codice. In altre parole, dopo aver applicato la migrazione, il codice vecchio deve continuare a funzionare esattamente come prima.

Questo cambia l'ordine dei deploy:

  1. si applica la migrazione di expand;
  2. si rilascia il codice che scrive su entrambe le strutture;
  3. si esegue il backfill;
  4. si rilascia il codice che legge dalla struttura nuova;
  5. si applica la migrazione di contract.

Cinque passi invece di uno. Sembra più lento, ed è più lento — ma è anche l'unico modo per cui nessuno dei cinque passi può causare un'interruzione.

Testare una migrazione prima di applicarla

Una migrazione non testata è una scommessa. Le cose che vale la pena misurare, su un ambiente con dati di volume realistico:

  • durata di ogni statement, non solo del totale;
  • lock acquisiti, con pg_locks osservato durante l'esecuzione;
  • dimensione della tabella e degli indici prima e dopo, per stimare il rewrite;
  • comportamento del rollback: se la migrazione va a metà, cosa resta?

Un esercizio utile è eseguire la migrazione mentre un carico sintetico legge e scrive sulla tabella. Se il carico non se ne accorge, la migrazione è pronta.

Conclusione

I punti chiave da portarsi dietro:

  • Il lock esclusivo è il vero nemico, non la durata della DDL. Una coda di query dietro un ALTER TABLE trasforma millisecondi in minuti di indisponibilità.
  • Expand, Migrate, Contract spezza la modifica in passi sempre compatibili con il codice precedente, e ogni passo è reversibile.
  • I backfill si fanno a lotti, con commit frequenti, SKIP LOCKED e pause: una UPDATE unica è la ricetta per bloat e ritardi di replica.
  • lock_timeout sempre attivo sulle DDL: meglio un deploy fallito che un servizio fermo.
  • NOT VALID + VALIDATE CONSTRAINT è il modo per aggiungere vincoli senza bloccare letture e scritture.
  • Il contract arriva per ultimo, dopo aver verificato con i dati che nessuno usa più la vecchia struttura.
  • Una migrazione si testa su volumi realistici, misurando durata, lock e comportamento in caso di interruzione.

Il costo è qualche deploy in più e un po' di disciplina nel tenere allineate due strutture per qualche giorno. Il beneficio è non dover mai scegliere tra aggiornare il database e restare online.


Foto di copertina di Taylor Vick su Unsplash.