Späť na blog

Zero-downtime migrácie PostgreSQL: Expand/Contract, backfill a rollback stratégie

|
| postgresql, database, devops, migrations, zero-downtime

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íčinaPrečo je problémRiešenie
Table lockBlokuje všetky queriesOnline DDL
Full table rewriteDlhá exkluzívna operáciaExpand/contract
Dlhé transakcieBlokovanie inýchTimeouty, batching
Nekompatibilné zmenyApp crashFeature 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 CONCURRENTLY nefunguje 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:

  1. Vytvor index v off-peak hodinách (aj CONCURRENTLY zaťažuje DB)
  2. Nastav maintenance_work_mem vyššie (dočasne)
  3. 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:

MetrikaNormálny thresholdPočas migrácie
Query latency p99500ms200ms
Error rate1%0.1%
Replication lag30s5s
Connection count200150

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

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Zero-downtime migrácie PostgreSQL: Expand/Contract, backfill a rollback stratégie". https://www.michal-drozd.com/sk/blog/zero-downtime-postgresql-migracie/ (Publikované 29. júla 2025).