todoly.pl Zaloguj się Załóż workspace
postgres docker infra

Optymalizacja migracji bazy Postgres w środowisku Docker

AUTHOR: Kamil M. | DATE: 2026-08-07 | READ_TIME: 6 min | STATUS: wdrożone

Migracja zakładająca indeks na tabeli faktur w ksefv.pl wywracała się na produkcji za każdym podejściem. Osiemnaście milionów wierszy, kontener z limitem pamięci i domyślny lock_timeout ustawiony na zero — to przepis na wstrzymanie zapisu na kilka minut w środku dnia roboczego.

Poniżej opisuję, co dokładnie blokowało wdrożenie, jak wygląda bezpieczna wersja tej migracji i jakie parametry kontenera trzeba było zmienić, żeby budowanie indeksu nie kończyło się zabiciem procesu przez OOM killera.

Skąd brała się blokada

Zwykłe CREATE INDEX zakłada blokadę SHARE na tabeli. Odczyty przechodzą, ale każdy INSERT z kolejki wysyłki do KSeF czeka. Przy tej wielkości tabeli budowanie indeksu zajmowało prawie cztery minuty, więc kolejka rosła szybciej, niż zdążyła się rozładować.

Migracja, która działa na kopii z dziesięcioma tysiącami wierszy, nie mówi nic o produkcji. Mierzalny jest tylko czas blokady, nie czas zapytania.

— notatka z retrospektywy, 22.07.2026

Pomiar przed zmianą

Pierwsze, co trzeba było ustalić, to ile realnie trwa blokada, a nie ile trwa całe wdrożenie. Widok pg_locks w połączeniu z pg_stat_activity pokazuje to bez zgadywania.

psql · pg_locks
-- kto blokuje kolejkę wysyłkiSELECT a.pid, a.state, l.mode, l.granted,
       now() - a.query_start AS waiting
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation = 'invoices'::regclass
  AND NOT l.granted
ORDER BY waiting DESC;

 pid  | state  | mode          | granted | waiting
------+--------+---------------+---------+----------
 4821 | active | RowExclusive  | f       | 00:03:47

Wersja, która przeszła bez blokady

Rozwiązanie to CONCURRENTLY plus wyjście z transakcji. Doctrine domyślnie owija migrację w transakcję, a Postgres nie pozwala budować indeksu współbieżnie w jej wnętrzu — dlatego migracja musi jawnie zadeklarować, że transakcji nie chce.

migrations/Version20260722_InvoiceIndex.php
final class Version20260722_InvoiceIndex extends AbstractMigration
{
    public function isTransactional(): bool
    {
        // CONCURRENTLY nie działa w transakcji
        return false;
    }

    public function up(Schema $schema): void
    {
        $this->addSql('SET lock_timeout = 3000');
        $this->addSql('SET maintenance_work_mem = 512MB');
        $this->addSql(
            'CREATE INDEX CONCURRENTLY IF NOT EXISTS             idx_invoices_nip_issued             ON invoices (nip, issued_at DESC)'
        );
    }
}

Limity kontenera

Podniesienie maintenance_work_mem do 512 MB skróciło budowanie indeksu o połowę, ale przy limicie 512 MB na kontener proces natychmiast padał. Docker liczy pamięć współdzieloną razem z resztą, więc shm_size trzeba podnieść osobno.

docker-compose.yml
db:
  image: postgres:16-alpine
  shm_size: 1gb
  command: >
    postgres
    -c maintenance_work_mem=512MB
    -c max_parallel_maintenance_workers=2
  deploy:
    resources:
      limits:
        memory: 2g   # było 512m → OOM

Wynik

Migracja przeszła w okno wdrożeniowe bez ani jednej odrzuconej wysyłki. Czas budowania indeksu wydłużył się względem wersji blokującej, co przy CONCURRENTLY jest normalne — baza wykonuje dwa przejścia po tabeli zamiast jednego.

178 ms p95 listy faktur (było 4 108 ms) 0 ms czas blokady zapisu 7 min 12 s budowanie indeksu (było 3 min 51 s)

Czego nie robić

Indeks budowany współbieżnie może zostać oznaczony jako nieprawidłowy, jeśli operacja przerwie się w połowie. Wtedy trzeba go usunąć i powtórzyć — sam się nie naprawi, a planer go ignoruje, więc łatwo tego nie zauważyć.

sprawdź Po każdym takim wdrożeniu warto odpytać pg_index o pozycje z indisvalid = false. Trzy linijki w skrypcie poddeployowym, oszczędzają tydzień szukania powodu wolnych zapytań.
udostępnij Wróć do changelogu