Migrazioni di Schema Senza Downtime: Expand, Migrate, Contract
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_emailsia suemail, 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
| Operazione | Rischio reale | Alternativa |
|---|---|---|
ADD COLUMN con default non costante | riscrittura completa della tabella | aggiungere nullable, backfill, poi impostare il default |
ALTER COLUMN ... TYPE | riscrittura + lock esclusivo | nuova colonna, backfill, swap |
SET NOT NULL | scansione completa della tabella | CHECK ... NOT VALID, poi VALIDATE CONSTRAINT, poi SET NOT NULL |
CREATE INDEX | blocca le scritture per tutta la durata | CREATE INDEX CONCURRENTLY |
ADD FOREIGN KEY | verifica tutte le righe esistenti | NOT VALID, poi VALIDATE CONSTRAINT |
ADD CHECK | scansione completa | NOT VALID, poi VALIDATE CONSTRAINT |
RENAME COLUMN | rompe ogni codice che usa il vecchio nome | expand/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:
- si applica la migrazione di expand;
- si rilascia il codice che scrive su entrambe le strutture;
- si esegue il backfill;
- si rilascia il codice che legge dalla struttura nuova;
- 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_locksosservato 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 TABLEtrasforma 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 LOCKEDe pause: unaUPDATEunica è la ricetta per bloat e ritardi di replica. lock_timeoutsempre 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.