9 min čteníJohnny UnarJohnny Unar

Django Postgres migrace bez downtime chtějí plán po krocích

Bezpečné změny schématu v Django monolitu nevznikají squashem migrací. Vznikají přes rollout po fázích, backfill a nudnou provozní disciplínu.

squash je divadlo

Squash migrací se často prodává jako hygiena. Jasně, z řetězu 400 migračních souborů uděláte 12, repo pak vypadá míň trapně, CI logy jsou kratší a noví vývojáři se přestanou ptát, proč python -m django migrate tráví půlku času přehráváním archeologie z roku 2021. Jenže s bezpečností v produkci to skoro nesouvisí. Risk migrace není v tom, kolik souborů leží v app/migrations, ale jaký lock si vezmete, jestli si omylem vyvoláte rewrite tabulky, jestli jste zapomněli označit index jako CONCURRENTLY, nebo jestli jste release aplikace svázali se změnou schématu, která projde jen tehdy, když se každý pod deployne v perfektním pořadí.

Vidíme to pořád dokola na velkých Django monolitech, včetně interních nástrojů a ERP systémů, které ve Steezr stavíme pro SMB firmy. Začnou jednoduše a pak se v jedné Postgres databázi potichu usadí deset let business pravidel. Týmy obsessují nad úklidem migrační historie, pak v poledne pošlou ALTER TABLE ... ADD COLUMN foo TEXT NOT NULL DEFAULT '' proti tabulce se 120 miliony řádků a diví se, proč API p95 vyskočí ze 140 ms na 12 s, zatímco Postgres narve všechny writery do fronty za ACCESS EXCLUSIVE lock. Přesně tohle je důležité.

Migrace v produkci jsou deployment plány. Mají fáze, observability, abort path, cleanup a občas i dočasný ošklivý kód, který žije dva týdny a pak se smaže. Když z toho uděláte binární přepínač, dřív nebo později si koledujete o hodně špatný večer.

Django 6.x vás téhle práce nezbaví, jen vám dá rozumné primitivy. Pořád musíte vědět, které ORM operace generují SQL s těžkými locky, kdy nastavit atomic = False, kdy jednu konceptuální změnu rozdělit do čtyř releasů a kdy je raw SQL prostě poctivější nástroj. Migrační graf je účetnictví. Rollout plán je engineering.

nejdřív expand

Vzorec, který používáme, je expand, backfill, switch, contract. Pokaždé. Přejmenování sloupce, změna enumu, přesun dat do nové tabulky, náhrada nullable foreign key, přidání uniqueness pravidla, všechno do tohohle modelu sedí, jakmile přestanete trvat na tom, že jeden migrační soubor má vyjádřit celou pravdu.

Řekněme, že chcete nahradit customer.email za customer.primary_email, udělat z něj non-null a vynutit uniqueness bez ohledu na velikost písmen. Špatná varianta všechno jedním tahem zahodí a vytvoří znovu. Bezpečná varianta začne tím, že přidá nový sloupec jako nullable, bez defaultu, a pak concurrentně vytvoří podpůrný index. V Django 6.x to typicky znamená jednu schema migraci pro field a potom neatomickou migraci pro index:

python
1from django.db import migrations, models
2
3class Migration(migrations.Migration):
4 dependencies = [(("customers", "0184_add_primary_email"))]
5 atomic = False
6
7 operations = [
8 migrations.RunSQL(
9 sql=(
10 "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "
11 "customers_customer_primary_email_uniq "
12 "ON customers_customer (lower(primary_email)) "
13 "WHERE primary_email IS NOT NULL;"
14 ),
15 reverse_sql=(
16 "DROP INDEX CONCURRENTLY IF EXISTS "
17 "customers_customer_primary_email_uniq;"
18 ),
19 ),
20 ]

To atomic = False je podstatné, protože Postgres odmítne CREATE INDEX CONCURRENTLY uvnitř transaction blocku chybou ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Django vás před tím nezachrání. Musíte to vědět.

Expand zároveň znamená vyhýbat se rewrite operacím. Přidat nullable sloupec je levné. Přidat sloupec s volatilním defaultem může být drahé. Změnit TEXT na JSONB ve velké tabulce může přepsat celý relation. Některé ALTER TYPE operace jsou skoro zadarmo, jiné jsou past. Čtěte skutečný SQL plán, koukejte v PostgreSQL dokumentaci na lock level a testujte na počtech řádků podobných produkci, protože migrace, která na stagingu doběhne za 0,8 s, může v produkci viset ve frontě na lock donekonečna, jakmile se o stejnou tabulku začne přetahovat třicet app serverů.

backfill dělejte jako operátor

Backfill nepatří do request path a většinou nepatří ani do jedné Django migrace. RunPython, který projíždí 80 milionů řádků a nad ORM volá .save(), je přesně ten způsob, jak pak zírat na statement timeout, zatímco autovacuum nestíhá a repliky mají zpoždění v minutách. Použijte background worker, dávkujte práci, checkpointujte progress a udělejte job idempotentní, protože ho budete restartovat.

Typicky napíšeme malý management command nebo jednorázový worker task a deployneme ho odděleně od schema expand kroku. Třeba takhle, chunk po primary key, update v SQL a mezi dávkami krátký sleep, abyste si z vlastní databáze neudělali benchmark:

python
1from django.core.management.base import BaseCommand
2from django.db import connection
3import time
4
5BATCH = 10_000
6SLEEP = 0.05
7
8class Command(BaseCommand):
9 def handle(self, *args, **opts):
10 last_id = 0
11 while True:
12 with connection.cursor() as cur:
13 cur.execute(
14 """
15 WITH batch AS (
16 SELECT id
17 FROM customers_customer
18 WHERE id > %s
19 AND primary_email IS NULL
20 ORDER BY id
21 LIMIT %s
22 )
23 UPDATE customers_customer c
24 SET primary_email = lower(c.email)
25 FROM batch
26 WHERE c.id = batch.id
27 RETURNING c.id
28 """,
29 [last_id, BATCH],
30 )
31 rows = cur.fetchall()
32 if not rows:
33 break
34 last_id = rows[-1][0]
35 time.sleep(SLEEP)

Ten script je nudný, což je přesně to, co chcete ve 2 ráno.

U tabulek, do kterých se během backfillu dál zapisuje, potřebujete most, aby se starý a nový kód nerozjely. Někdy stačí dual write na úrovni aplikace. U důležitých invariantů jsou bezpečnější triggery, protože nespoléhají na to, že jste správně upravili každou code path. Dočasný trigger, který během rolloutu synchronizuje primary_email z email, je levná pojistka:

sql
1CREATE OR REPLACE FUNCTION sync_primary_email() RETURNS trigger AS $$
2BEGIN
3 IF NEW.primary_email IS NULL AND NEW.email IS NOT NULL THEN
4 NEW.primary_email := lower(NEW.email);
5 END IF;
6 RETURN NEW;
7END;
8$$ LANGUAGE plpgsql;
9
10CREATE TRIGGER customers_sync_primary_email
11BEFORE INSERT OR UPDATE OF email, primary_email
12ON customers_customer
13FOR EACH ROW EXECUTE FUNCTION sync_primary_email();

Dočasný kód má svůj účel. Udržet data konzistentní ve chvíli, kdy vedle sebe běží více verzí aplikace.

switch dělejte s přesahem

Fáze switch je místo, kde týmy začnou být až moc sebejisté. Vidí, že nový sloupec je naplněný, mergnou jeden PR, který přepne reads a writes, a berou zbytek jako cleanup. Produkce takhle čistě nefunguje. Potřebujete překryv, protože část podů ještě běží na starém releasu, některé async joby pořád deserializují stará pole, jeden cron script, na který si nikdo nevzpomněl, importuje model constant z roku 2023 a repliky můžou zrovna v nejhorší chvíli krátce lagovat.

Preferujeme sekvenci releasů, která snese smíšené verze. Release jedna zapisuje do starého i nového. Release dvě čte z nového, ale pořád zapisuje do obou. Release tři teprve vynutí constrainty, jakmile metriky ukážou, že backfill doběhl a nikde už nejsou staré ready. Když aplikace přežije version skew o jednu verzi, pořadí deploymentu přestane být zdroj adrenalinu.

Tady znovu záleží na lock chování Postgresu. ALTER TABLE ... SET NOT NULL může proskenovat tabulku, aby ověřil existující řádky. Na novějších verzích PostgreSQL je to výrazně lepší než dřív, ale u obřích tabulek často nejdřív přidáme CHECK (primary_email IS NOT NULL) NOT VALID, validujeme ho online a NOT NULL nastavíme až v dalším kroku, pokud je potřeba. Totéž u foreign key, přidejte ji jako NOT VALID a validujte později:

sql
1ALTER TABLE orders_order
2 ADD CONSTRAINT orders_order_customer_id_fk
3 FOREIGN KEY (customer_id)
4 REFERENCES customers_customer(id)
5 NOT VALID;
6
7ALTER TABLE orders_order
8 VALIDATE CONSTRAINT orders_order_customer_id_fk;

Validace drží lehčí locky než vytvoření plně validovaného constraintu rovnou. Přesně v tom je rozdíl mezi klidným deployem a support kanálem plným screenshotů s timeouty.

Kolem read path u rizikových switchů navíc dáváme feature flags. Obyčejné Django settings, LaunchDarkly, klon Flipperu, to je jedno. Důležité je mít rollback cestu na jednu minutu a nezkoušet pod tlakem vracet DDL. Reverze migrací v produkci je často pohádka. V praxi se dělají forward fixy.

nástroje, které zachraňují noci

Pár nástrojů a návyků se zaplatí velmi rychle. pg_locks a pg_stat_activity mají být svalová paměť, ne pub quiz. Když se migrace sekne, pusťte okamžitě něco jako tohle:

sql
1SELECT blocked.pid AS blocked_pid,
2 blocked.query AS blocked_query,
3 blocking.pid AS blocking_pid,
4 blocking.query AS blocking_query
5FROM pg_catalog.pg_locks blocked_locks
6JOIN pg_catalog.pg_stat_activity blocked
7 ON blocked.pid = blocked_locks.pid
8JOIN pg_catalog.pg_locks blocking_locks
9 ON blocking_locks.locktype = blocked_locks.locktype
10 AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
11 AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
12 AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
13 AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
14 AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
15 AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
16 AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
17 AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
18 AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
19 AND blocking_locks.pid != blocked_locks.pid
20JOIN pg_catalog.pg_stat_activity blocking
21 ON blocking.pid = blocking_locks.pid
22WHERE NOT blocked_locks.granted;

U rizikových migrací nastavujte lock_timeout defensivně. Rychlé selhání je lepší než tiché čekání ve frontě za dlouhou transakcí z nějakého admin reportu. Raw SQL často balíme do SET lock_timeout = '2s'; SET statement_timeout = '15min'; přímo v migrační operaci, aby deploy spadl nahlas místo toho, aby se pomalu změnil v brownout.

pg_repack je další záchrana při úklidu bloatu a některých reorganizacích tabulek, hlavně po velkém churnu nebo dropu sloupců, který reálně nevrátil místo operačnímu systému. Není to magie a pořád potřebujete dost volného místa na disku, ale je to výrazně bezpečnější než předstírat, že VACUUM FULL je přijatelný nápad na hot tabulce. Mějte ho v toolboxu.

Důležité jsou i malé maintenance skripty. Jeden na kontrolu procent dokončení backfillu. Jeden na ověření přítomnosti triggerů. Jeden, který na náhodném vzorku 100k řádků porovná starý a nový sloupec a hledá drift. Jeden, který před releasem vypíše aktivní migrace a hashe verzí aplikace. Napsat je zabere odpoledne. Zachránit dokážou víkend.

contract až nakonec, a klidně mnohem později

Drop starého sloupce, odstranění triggeru, smazání dual-write kódu a squash migrační historie můžou počkat. Čekání je zdravé. Nechte nový path projít jedním plným deployment cyklem, pak ještě jedním, a uklízejte až ve chvíli, kdy vidíte úspěšné backupy, repliky drží krok, analytické joby už na staré pole nesahají a nikdo neotevřel bug, který se na něj nějak pořád odkazuje.

Právě tady začíná být squash migrací aktivně matoucí. Lidé squasují, protože chtějí, aby kód vyprávěl čistý příběh. Produkční systémy čisté příběhy nemají. Mají stopy po opatrných rolloutech a ty stopy jsou důkaz, že tým bere uptime vážně. Radši otevřu migrační adresář a uvidím 0191_add_primary_email_nullable, 0192_primary_email_index_concurrently, 0193_sync_trigger, 0194_noop_placeholder_for_backfill, 0195_enforce_primary_email_constraint, než jeden majestátní 0191_customer_email_refactor, který předstírá, že změna proběhla atomicky.

Občas squash stejně uděláme, hlavně kvůli startup času v čerstvých prostředích a aby aplikace s hodně starou historií zůstaly rozumně spravovatelné. Ale až ve chvíli, kdy je provozní riziko pryč, a až po ověření, že squashed stav přesně odpovídá realitě. Squash je údržba repa. Není to migrační strategie.

Stabilní a nenápadný způsob, jak vyvíjet Django 6.x monolit, je schválně nudný. Nejdřív expand. Backfill mimo request path. Během překryvu triggery nebo dual writes. Constrainty validovat zvlášť. Reads přepínat s rollback cestou. Contract později. A znovu. Přesně tak může jedna Postgres databáze přežít roky produktových změn, aniž by si tým vypěstoval kolektivní strach z migrate.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Bezpečné změny schématu v Django monolitu nevznikají squashem migrací. Vznikají přes rollout po fázích, backfill a nudnou provozní disciplínu.