Risposta diretta
Le migration Flyway applicate sono immutabili per contratto, non per suggerimento: cambiare anche un solo commento in un file già applicato in produzione fa fallire il boot con un mismatch di checksum. L'ho rotto io stesso una volta, con una modifica cosmetica. Su 189 migration in produzione, quella è stata la lezione più costosa: un editor di testo può crashare un backend Spring Boot più velocemente di un bug applicativo.
La disciplina sulle migration di database è la parte più noiosa e più sottovalutata dell'ingegneria backend, finché non ti crasha la produzione. Su questo portfolio, 189 migration Flyway (fino a V199) governano lo schema PostgreSQL di un backend con oltre 3.100 classi Java. In tre incidenti di produzione distinti, ognuno riconducibile a una violazione della stessa regola implicita "non toccare mai qualcosa che il sistema considera già scritto e verificato", ho imparato quanto sia stretto il margine tra disciplina noiosa e boot crash in produzione.
Postmortem 1: un commento ha rotto il checksum
Flyway calcola un checksum del contenuto testuale di ogni file di
migration già applicato, e lo confronta ad ogni avvio contro il valore
salvato nella tabella flyway_schema_history. Se non
combaciano, Flyway rifiuta di avviare l'applicazione. Nel 2026-06-20
ho modificato un singolo commento SQL in una migration già applicata
(V152), pensando che un commento non potesse avere effetti funzionali,
per chiarire un'intenzione poco chiara a distanza di mesi. Il backend
non è ripartito: FlywayException: Migration checksum mismatch.
Nessuna colonna, nessuna tabella, nessuna logica era cambiata: solo
testo dopo un doppio trattino --. Flyway non distingue
tra commento e statement, tratta il file come un blob di byte con un
checksum, punto.
La regola operativa che applico ora, senza eccezioni: una migration applicata è congelata per sempre, incluso ogni commento. Se serve chiarire un'intenzione, si scrive una nuova migration che documenta il perché nel messaggio di commit o in un commento nella nuova migration, mai modificando la vecchia.
Postmortem 2: la race del deploy JAR
Il deploy manuale del backend segue lo schema git pull +
mvn package + systemctl restart. Prima
della correzione, lo script copiava il nuovo JAR sopra quello in uso
(cp) mentre la JVM precedente era ancora in esecuzione e
teneva il file mappato in memoria. Il risultato osservato in
produzione: NoClassDefFoundError su classi core di Spring,
perché il classloader stava leggendo un file parzialmente sovrascritto
a metà di una cp non atomica, mentre il vecchio processo
era ancora vivo e serviva traffico. Il sintomo è particolarmente
insidioso perché non è un errore di compilazione né un errore
applicativo, è un file binario corrotto a runtime che sembra un bug
di dipendenze mancanti.
Il fix ha tre parti, e tutte e tre sono necessarie, nessuna da sola
basta: fermare il servizio prima di copiare (non dopo),
copiare il nuovo JAR con una mv atomica invece di un
cp in-place, e resettare lo stato failed di
systemd se il precedente restart era andato storto, prima di riavviare.
Saltare uno qualunque dei tre passi riproduce la race in modo
intermittente, che è il tipo di bug più costoso da diagnosticare
perché non si ripresenta ad ogni deploy.
Postmortem 3: due bean, stesso nome semplice, boot crash
Due classi @Service in package Java diversi ma con lo
stesso simple name (es. ExportService in due verticali di
business separati) fanno esplodere il boot Spring con
ConflictingBeanDefinitionException, perché Spring, senza
un nome esplicito, deriva il nome del bean dal simple class name, non
dal fully qualified name. Il punto davvero scomodo: i test unitari
non catturano questo problema, perché nessun singolo test carica
l'intero application context con entrambi i verticali insieme, solo
un test di integrazione full-context lo rivela, e nella pratica quel
test full-context viene eseguito raramente durante lo sviluppo di
routine, spesso solo prima del deploy o mai.
Con oltre 25 verticali di business separati aggiunti nel tempo, il
rischio di collisione sul simple name cresce combinatoriamente, non
linearmente, con il numero di verticali. La regola che ho adottato:
ogni nuovo @Service in un vertical business specifico
riceve un nome esplicito con prefisso (@Service("phExportService")),
non lasciato al default, e prima di ogni deploy gira almeno un test
di integrazione full-context.
| Incidente | Sintomo osservato | Regola anti-ripetizione |
|---|---|---|
| Modifica commento migration applicata (V152) | Boot crash, FlywayException checksum mismatch |
Migration applicate immutabili, sempre, anche per un commento |
| Race deploy JAR in-place | NoClassDefFoundError intermittente |
Stop-prima-di-copiare + mv atomica + reset-failed |
| Bean duplicati stesso simple name | ConflictingBeanDefinitionException al boot |
Nome bean esplicito con prefisso vertical + test full-context pre-deploy |
Il pattern comune: l'immutabilità come confine, non come stile
I tre postmortem sembrano indipendenti, ma condividono la stessa causa radice: qualcosa che il sistema tratta come già scritto, verificato, immutabile, viene modificato o sovrascritto in un momento in cui il sistema non se lo aspetta. Una migration applicata, un binario in esecuzione, un simple name che Spring assume univoco. L'immutabilità non è uno stile di programmazione elegante da seguire quando conviene, è un confine di sistema che, se violato, produce un crash deterministico o una race intermittente, mai un warning gentile.
Quando questa disciplina è overkill
Su un side project a persona singola con un solo ambiente e restart manuale poco frequente, il rischio di race sul deploy JAR è basso e il costo di uno script di deploy sofisticato con stop-prima-di-copiare non si giustifica. Il punto di rottura reale è la produzione con traffico continuo: lì anche una finestra di race di pochi secondi diventa una finestra di errore visibile agli utenti, e la disciplina smette di essere burocrazia e diventa protezione necessaria.
Domande frequenti
Perché modificare un commento in una migration Flyway già applicata rompe il boot?
Flyway calcola un checksum sul contenuto testuale completo di ogni migration già applicata e lo confronta ad ogni avvio con il valore salvato nella tabella flyway_schema_history. Non distingue tra commento e statement SQL: qualunque modifica al testo, anche cosmetica, cambia il checksum e fa fallire l'avvio con un mismatch.
Cosa causa un NoClassDefFoundError durante il deploy di un JAR Spring Boot?
Se il nuovo JAR viene copiato in-place sopra quello in uso mentre la JVM precedente è ancora in esecuzione e ha il file mappato in memoria, il classloader può leggere un file parzialmente sovrascritto a metà di una copia non atomica. Il sintomo appare come una dipendenza mancante, ma la causa reale è un file binario corrotto a runtime durante il deploy.
Come si evita la race condition nel deploy di un JAR Spring Boot?
Fermando il servizio prima di copiare il nuovo JAR (non dopo), usando una mv atomica invece di una cp in-place, e resettando lo stato failed di systemd se un restart precedente era andato storto. Tutti e tre i passi sono necessari insieme; saltarne uno riproduce la race in modo intermittente.
Perché due bean Spring con lo stesso simple name fanno crashare il boot?
Senza un nome esplicito, Spring deriva il nome di un bean @Service dal simple class name, non dal fully qualified name. Due classi in package diversi ma con lo stesso simple name generano una ConflictingBeanDefinitionException al boot, un problema che i test unitari isolati non catturano perché nessun singolo test carica entrambi i verticali nello stesso application context.
Cosa hanno in comune i tre postmortem di produzione descritti qui?
Condividono la stessa causa radice: qualcosa che il sistema tratta come già scritto, verificato o immutabile (una migration applicata, un binario in esecuzione, un simple name assunto univoco) viene modificato o sovrascritto in un momento in cui il sistema non se lo aspetta, producendo un crash deterministico o una race intermittente.
Commenti
Caricamento commenti...
Accedi per commentare