Zero-downtime migrácie PostgreSQL: Expand/Contract, backfill a rollback stratégie
Zero-downtime migracie su mozne, ale treba ich brat ako engineering projekt. Piatok, 16:00. Spustil som ALTER TABLE na tabuľke s 47 miliónmi riadkov. O 3 sekundy neskôr sa rozsvietil Slack červenou - produkcia padla. Nasledujúce 4 hodiny som strávil rollbackom a vysvetľovaním, prečo sa to už nikdy nestane.
To bolo pred 6 rokmi. Odvtedy som spravil stovky migrácií na produkčných databázach bez jediného výpadku. Tieto pravidlá mám vytesané v kameni.
Testované na: PostgreSQL 14-16, tabuľky od 1M do 200M riadkov, AWS RDS aj self-hosted. Všetky príklady v tomto článku som osobne spúšťal na produkčných systémoch.
Čo znamená “zero-downtime” v praxi
Definícia: Aplikácia zostáva plne funkčná počas celej migrácie. Žiadne error stránky, žiadne timeout-y, žiadne “skúste to neskôr.”
Realita:
- Latencia môže dočasne stúpnuť (akceptovateľné: +20%)
- Niektoré operácie môžu byť pomalšie (akceptovateľné: +50%)
- Žiadne failed requests pre koncových používateľov
Najčastejšie príčiny výpadkov
| Príčina | Prečo je problém | Riešenie |
|---|---|---|
| Table lock | Blokuje všetky queries | Online DDL |
| Full table rewrite | Dlhá exkluzívna operácia | Expand/contract |
| Dlhé transakcie | Blokovanie iných | Timeouty, batching |
| Nekompatibilné zmeny | App crash | Feature flags |
Základný vzor: Expand / Contract
Toto je najdôležitejší pattern. Každá migrácia má dve fázy:
Expand (rozšírenie)
- Pridaj nové (stĺpce, tabuľky, indexy)
- Starý kód stále funguje
- Nový kód môže začať používať nové štruktúry
Contract (zúženie)
- Odstráň staré (až keď nič nepoužíva)
- Vyčisti dáta
- Finalizuj migráciu
Príklad: Premenovanie stĺpca
Zlý spôsob (výpadok):
ALTER TABLE users RENAME COLUMN name TO full_name;
-- Všetky queries zlyhajú okamžite!
Správny spôsob (zero-downtime):
-- Fáza 1: Expand
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Fáza 2: Backfill (batched) - PostgreSQL syntax
UPDATE users SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL
LIMIT 1000
);
-- (opakuj kým done)
-- Fáza 3: App update
-- Deploy kód, ktorý číta z oboch, píše do oboch
-- Fáza 4: App update 2
-- Deploy kód, ktorý číta len z full_name
-- Fáza 5: Contract
ALTER TABLE users DROP COLUMN name;
Two-phase deploy s feature flags
Aplikácia musí vedieť pracovať so starou AJ novou schémou súčasne.
# Fáza: Expand
def get_user_name(user):
if feature_flag('use_full_name'):
return user.full_name or user.name # fallback na staré
return user.name
def save_user(user, name):
user.name = name
if feature_flag('use_full_name'):
user.full_name = name # zapisuj do oboch
user.save()
Typy zmien a odporúčaný postup
Pridanie stĺpca (bezpečné)
-- Bezpečné - žiadny lock na existujúce riadky
ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255);
-- ALE POZOR: s DEFAULT je to problém!
-- PostgreSQL 11+ je OK, staršie verzie = table rewrite
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
Zmena typu stĺpca (nebezpečné)
-- NIE takto - table rewrite!
ALTER TABLE orders ALTER COLUMN amount TYPE NUMERIC(12,2);
-- ÁNO takto - expand/contract
-- 1. Pridaj nový stĺpec
ALTER TABLE orders ADD COLUMN amount_new NUMERIC(12,2);
-- 2. Backfill
UPDATE orders SET amount_new = amount WHERE amount_new IS NULL;
-- 3. Prepni app
-- 4. Drop starý
ALTER TABLE orders DROP COLUMN amount;
ALTER TABLE orders RENAME COLUMN amount_new TO amount;
Pridanie NOT NULL (nebezpečné)
-- NIE takto - full table scan s lockom
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- ÁNO takto
-- 1. Pridaj constraint ako NOT VALID
ALTER TABLE users ADD CONSTRAINT users_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
-- 2. Validuj v pozadí (bez locku)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- 3. Teraz môžeš pridať NOT NULL (rýchle, lebo constraint už existuje)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_not_null;
Pridanie UNIQUE (nebezpečné)
-- NIE takto - dlhý lock
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
-- ÁNO takto
-- 1. Vytvor index CONCURRENTLY (bez locku)
CREATE UNIQUE INDEX CONCURRENTLY users_email_unique_idx ON users(email);
-- 2. Pridaj constraint using index (rýchle)
ALTER TABLE users ADD CONSTRAINT users_email_unique
UNIQUE USING INDEX users_email_unique_idx;
Online indexy
-- VŽDY používaj CONCURRENTLY pre production
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
-- Ak zlyhá, musíš dropnúť INVALID index
DROP INDEX CONCURRENTLY idx_orders_user_id;
-- A skúsiť znova
⚠️ Dôležité:
CREATE INDEX CONCURRENTLYnefunguje v transakcii! Ak používate migračný nástroj ako Flyway alebo Liquibase, uistite sa, že migrácia s CONCURRENTLY indexom beží mimo transakcie (napr. v Flyway použite-- flyway:disableTransaction).
Stratégie pre veľké tabuľky
Pre tabuľky s miliónmi riadkov:
- Vytvor index v off-peak hodinách (aj CONCURRENTLY zaťažuje DB)
- Nastav maintenance_work_mem vyššie (dočasne)
- Monitoruj replication lag (slave môže zaostávať)
-- Dočasne zvýš pamäť pre session
SET maintenance_work_mem = '1GB';
CREATE INDEX CONCURRENTLY ...;
RESET maintenance_work_mem;
Backfill dát bez zabitia DB
Základný batching
BATCH_SIZE = 1000
SLEEP_BETWEEN = 0.1 # 100ms
def backfill_full_name():
while True:
with transaction():
# PostgreSQL nepodporuje UPDATE...LIMIT, použijeme subquery
count = db.execute("""
UPDATE users
SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL
LIMIT %s
)
""", [BATCH_SIZE])
if count == 0:
break
time.sleep(SLEEP_BETWEEN)
log(f"Migrated {count} rows")
Throttling podľa load
def backfill_with_throttle():
while True:
# Skontroluj load pred každým batchom
lag = get_replication_lag()
if lag > timedelta(seconds=5):
log("Replication lag too high, pausing...")
time.sleep(10)
continue
connections = get_active_connections()
if connections > 100:
log("Too many connections, pausing...")
time.sleep(5)
continue
# Spusti batch
migrate_batch()
Idempotencia
Migrácia MUSÍ byť reštartovateľná:
def migrate_batch():
# Idempotentný update - nevadí ak sa spustí viackrát
# PostgreSQL nepodporuje UPDATE...ORDER BY...LIMIT, použijeme subquery
db.execute("""
UPDATE users
SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL
AND id > %s
ORDER BY id
LIMIT 1000
)
""", [last_processed_id])
Rollback stratégia
Každá migrácia musí mať rollback plán PRED spustením.
Forward-compatible code
# Kód musí fungovať s oboma schémami
def get_user(id):
user = db.query("SELECT * FROM users WHERE id = %s", [id])
# Ak neexistuje nový stĺpec, použi starý
return {
'name': user.full_name if hasattr(user, 'full_name') else user.name
}
Safe rollback checklist
Pred deployom migrácie:
- Existuje rollback script?
- Je rollback testovaný na staging?
- Máme backup dát, ktoré budú zmenené?
- Je rollback časovo ohraničený? (max 15 min)
- Kto má právomoc spustiť rollback?
Rollback script template
-- rollback_001_add_full_name.sql
-- Popis: Rollback pridania stĺpca full_name
-- Prerekvizity: App musí byť na verzii < X.Y.Z
BEGIN;
-- Skontroluj, že app už nepoužíva nový stĺpec
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_stat_activity
WHERE query LIKE '%full_name%' AND state = 'active'
) THEN
RAISE EXCEPTION 'Active queries using full_name column!';
END IF;
END
$$;
-- Drop stĺpca
ALTER TABLE users DROP COLUMN IF EXISTS full_name;
COMMIT;
Observability pre migrácie
Metriky, ktoré sledovať
-- Lock wait time
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event IS NOT NULL;
-- Replication lag
SELECT client_addr,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) as lag_bytes
FROM pg_stat_replication;
-- Long running queries
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
AND now() - pg_stat_activity.query_start > interval '30 seconds';
Alerty počas migrácie
Nastavte tieto alerty na nižšie thresholdy počas migrácie:
| Metrika | Normálny threshold | Počas migrácie |
|---|---|---|
| Query latency p99 | 500ms | 200ms |
| Error rate | 1% | 0.1% |
| Replication lag | 30s | 5s |
| Connection count | 200 | 150 |
Runbook pre produkčný rollout
T-24h: Príprava
- Migrácia testovaná na staging s produkčnými dátami
- Rollback script pripravený a testovaný
- Tím informovaný o okne migrácie
- Monitoring dashboardy pripravené
T-1h: Pre-flight
- Backup spustený a overený
- Replication lag < 1s
- Žiadne dlhé transakcie
- Error rate baseline zaznamenaný
T-0: Migrácia
- Spusti migráciu
- Monitoruj metriky každých 30s
- Pri akomkoľvek alerte: STOP a vyhodnoť
Stop conditions (okamžitý rollback)
- Error rate > 1%
- Replication lag > 10s
- Query latency p99 > 2x baseline
- Akákoľvek ERROR v app logoch súvisiaca s migráciou
Post-migration
- Validuj dáta
- Zruš rollback alert
- Dokumentuj lessons learned
Tooling: Flyway/Liquibase bezpečne
Separuj DDL a DML
migrations/
V001__create_users_table.sql # DDL
V002__add_status_column.sql # DDL
R001__backfill_status.sql # DML - repeatable
Review pravidlá
Každá migrácia musí prejsť review s checklistom:
- Žiadne
ALTER TABLE ... ALTER COLUMN TYPE - Všetky indexy sú CONCURRENTLY
- NOT NULL má NOT VALID fázu
- Existuje rollback
- Odhadovaný čas behu < 5 min
Timeouty pre migrácie
-- V každej migrácii
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
-- Ak timeout, migrácia zlyhá a môžeš retry
ALTER TABLE ...;
Záver: Checklist pred každou migráciou
## Pre-migration checklist
### Plánovanie
- [ ] Rozumiem čo migrácia robí?
- [ ] Je to expand alebo contract fáza?
- [ ] Aký je očakávaný čas behu?
- [ ] Koľko riadkov/tabuliek je affected?
### Bezpečnosť
- [ ] Mám rollback script?
- [ ] Je kód forward-compatible?
- [ ] Mám backup?
- [ ] Sú nastavené timeouty?
### Execution
- [ ] Staging test prešiel?
- [ ] Monitoring je ready?
- [ ] Tím je informovaný?
- [ ] Mám stop conditions definované?
### Post-migration
- [ ] Dáta sú validné?
- [ ] Metriky sú v norme?
- [ ] Dokumentácia aktualizovaná?
Váš ďalší krok: Vezmite poslednú migráciu, ktorú ste robili. Spĺňala tento checklist? Ak nie, čo by ste spravili inak?
Často kladené otázky (FAQ)
Ako dlho trvá expand/contract migrácia?
Záleží na veľkosti tabuľky a komplexnosti zmeny. Pre tabuľku s miliónmi riadkov očakávajte dni až týždne (vrátane backfillu). Nikdy to nerobte cez víkend - robte to počas pracovných dní, keď môžete reagovať.
Môžem použiť pg_repack namiesto expand/contract?
pg_repack je užitočný pre table bloat a niektoré typy zmien, ale nie je univerzálne riešenie. Pre zmenu typu stĺpca alebo pridanie NOT NULL stále potrebujete expand/contract pattern.
Čo ak backfill trvá príliš dlho?
Zvýšte batch size (ak to DB zvláda), pridajte viac paralelných workerov, alebo zvážte pg_dump/restore pre extrémne veľké tabuľky. Vždy monitorujte replication lag.
Je bezpečné robiť migrácie počas špičky?
S correct expand/contract patternom áno. Ale odporúčam začať s nižším traffic-om, kým si nevybudujete dôveru v proces. Prvých 5 migrácií robte v off-peak hodinách.
Súvisiace články
- Architektúra ako kód: ADR, C4 diagramy a quality gates v CI - Dokumentujte databázové rozhodnutia pomocou ADR
- CI/CD pre monorepo: Rýchlosť, cache, selektívne testy a supply-chain bezpečnosť - Ako automatizovať databázové migrácie v CI/CD pipeline
Súvisiace články
PostgreSQL 'cached plan must not change result type' Počas Zero-Downtime Migrácií
Rolling deploy zlyháva s cached plan chybami po ALTER TABLE. Príčina: server-side prepared statements cachujú query plány ktoré sa rozbijú pri zmene schémy.
PostgreSQL Autovacuum SLO Tuning: Ako nastaviť vacuum pre 200M riadkov a 5k UPSERT/s
Autovacuum je buď ignorovaný alebo cargo-cult tunovaný. Ukážem ako ho premeniť na SLO-driven systém s konkrétnymi číslami, pg_stat metriky a reprodukovateľným testom.
Kubernetes rollout bez výpadku DB: Ako zastaviť PostgreSQL connection storm
Reprodukovateľný lab na demonštráciu connection stormu pri K8s rolloutoch. PgBouncer, preStop hooks a jitter - praktické riešenia s benchmarkmi.
Soft Delete past: Prečo is_deleted zabíja tvoju databázu (a čo s tým)
Praktický rozbor prečo soft delete po rokoch rozbije výkon databázy. Benchmarky, partitioning riešenie a migračný checklist.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.