Careeroza
Zero-Downtime Migrations
Old and New Code Run Together
With rolling deploys, version N and version N+1 of the app run at the same time against the same schema for a few minutes. So every migration must work with both the old and the new code. The pattern is expand → migrate → contract:
Rename products.title → products.name without downtime Release 1 (expand) ADD COLUMN name; app writes BOTH title and name; reads title Backfill job UPDATE products SET name = title WHERE name IS NULL (in chunks) Release 2 app reads name, still writes both Release 3 (contract) app stops using title; migration DROPs title
Safe vs Risky Changes
| Change | Safe in one step? | How to do it |
|---|---|---|
| Add nullable column | yes | just add it |
| Add NOT NULL column | no | add nullable → backfill → add constraint |
| Rename column/table | no | expand/contract as above |
| Drop column | only after no code uses it | remove from entity first, drop in a later release |
| Add index on a big table | locks writes | CREATE INDEX CONCURRENTLY (PostgreSQL), ALGORITHM=INPLACE, LOCK=NONE (MySQL) |
| Change column type | no | new column + backfill + switch |
Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction. In Flyway, put it in its own script and allow non-transactional statements (Flyway detects this for PostgreSQL), or use -- flyway:executeInTransaction=false in a script config file.
Exercise
Exercise: You need to make users.phone NOT NULL. 5% of 3 million rows have no phone. Write the release plan.
Show answer
Release 1: app requires a phone for new sign-ups and on profile save (validation).
Migration A: nothing yet (column stays nullable).
Backfill: ask users to add a phone at next login, or set a placeholder value
in chunks of 10,000 rows (UPDATE ... WHERE id BETWEEN ? AND ? AND phone IS NULL).
Release 2 migration: when "SELECT count(*) FROM users WHERE phone IS NULL" = 0,
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
then mark the entity field @Column(nullable = false).