Przejdź do głównej zawartości

Wzorce migracji baz danych

O 14:00 wdrożyłeś niewinnie wyglądające ALTER TABLE products ALTER COLUMN price TYPE numeric. Postgres założył blokadę ACCESS EXCLUSIVE, żeby przepisać tabelę z 40 milionami wierszy, każde zapytanie z koszyka ustawiło się w kolejce za nim, a sklep zaczął zwracać błędy 500 przez dziewięć minut, podczas gdy twój PM patrzył, jak wykres błędów pnie się w górę. Migracja była „poprawna” — po prostu nie była bezpieczna.

Rozwiązaniem nie jest pisanie SQL-a ręcznie z większą uwagą. Rozwiązaniem jest pozwolić agentowi AI wygenerować migrację, a potem sprawić, by sam zrecenzował swoją pracę pod kątem dokładnie tych zagrożeń, które wywołują incydenty: długich blokad, niepartiowanych backfillów i indeksów budowanych bez CONCURRENTLY. Ten przepis pokazuje cały przepływ pracy w Cursorze, Claude Code i Codeksie.

  • Wielokrotnego użytku prompt expand-contract, który zamienia ryzykowny, jednorazowy ALTER w trzy bezpieczne, ponumerowane migracje plus kod aplikacji z podwójnym zapisem
  • Prompt do recenzji bezpieczeństwa migracji, który zgłasza blokady ACCESS EXCLUSIVE, brakujące CONCURRENTLY i niepartiowane UPDATE, zanim cokolwiek uruchomisz
  • Podłączony serwer MCP Postgresa tylko do odczytu, dzięki któremu agent czyta twój żywy schemat, zamiast zgadywać typy kolumn
  • Przepis na rollback i runbook „Gdy to się zepsuje” na wypadek rywalizacji o blokady, niedokończonych backfillów i częściowego stanu migracji

Najużyteczniejszym pojedynczym wzorcem dla zmian bez przestojów jest expand-contract (zwany też parallel change): nigdy nie zmieniaj kolumny w miejscu. Dodaj nowy kształt, zapisuj podwójnie do obu, wypełnij dane partiami, przełącz odczyty, a dopiero w późniejszym wdrożeniu usuń stary kształt. Każdy krok da się wdrożyć i cofnąć niezależnie.

Incydent ze zmianą typu kolumny z początku staje się trzema migracjami zamiast jedną:

-- 001_expand_add_price_cents.sql (deploy 1: dodaj kolumnę nullable, bez przepisywania, bez blokady)
ALTER TABLE products ADD COLUMN price_cents integer;
-- 002_backfill_price_cents.sql (deploy 1+: uruchom POZA transakcją, partiami)
-- Powtarzaj, aż zaktualizowanych zostanie 0 wierszy. Każda instrukcja to krótka, osobna transakcja.
UPDATE products
SET price_cents = round(price * 100)
WHERE price_cents IS NULL
AND id IN (
SELECT id FROM products WHERE price_cents IS NULL ORDER BY id LIMIT 1000
);
-- 003_contract_drop_price.sql (deploy 3, dni później: dopiero gdy podwójny zapis działa)
ALTER TABLE products ALTER COLUMN price_cents SET NOT NULL;
ALTER TABLE products DROP COLUMN price;

Pomiędzy deployem 1 a deployem 3 aplikacja zapisuje podwójnie i czyta nową kolumnę z rozwiązaniem zapasowym, dzięki czemu stara i nowa wersja kodu bezpiecznie współistnieją:

// Podwójny zapis, dopóki istnieją obie kolumny (faza przejściowa)
async function setProductPrice(productId, priceCents) {
await db.query(
`UPDATE products
SET price_cents = $1, price = $1 / 100.0
WHERE id = $2`,
[priceCents, productId]
);
}
// Czytaj nową kolumnę, wróć do starej do czasu zakończenia backfillu
async function getProductPriceCents(productId) {
const { rows } = await db.query(
'SELECT price, price_cents FROM products WHERE id = $1',
[productId]
);
return rows[0].price_cents ?? Math.round(rows[0].price * 100);
}

Nie musisz pisać tego z pamięci. Podaj agentowi swój prawdziwy schemat i pozwól mu wyprodukować całą sekwencję.

Pozwól agentowi zobaczyć prawdziwy schemat (MCP Postgresa)

Dział zatytułowany „Pozwól agentowi zobaczyć prawdziwy schemat (MCP Postgresa)”

Prompt powyżej działa, ale schemat trzeba było wkleić ręcznie — a jeśli pomylisz typ kolumny, agent wygeneruje migrację względem tabeli, która nie istnieje. Serwer MCP Postgresa likwiduje tę lukę: daje agentowi połączenie, więc może obejrzeć żywe definicje tabel, liczby wierszy i istniejące indeksy, zanim napisze choćby linijkę. Użyj utrzymywanego serwera — Postgres MCP Pro (crystaldba/postgres-mcp) to aktywnie wspierany wybór, a jego flaga --access-mode=restricted wymusza dostęp tylko do odczytu na poziomie serwera. (Unikaj starego pakietu @modelcontextprotocol/server-postgres: jest przestarzały i zarchiwizowany oraz niesie znaną podatność na SQL injection, która pozwala spiętrzonej instrukcji wymknąć się z transakcji tylko do odczytu).

Bez MCP: kopiujesz wynik \d products do promptu i masz nadzieję, że jest aktualny. Z MCP: agent sam uruchamia odpowiednik SELECT … FROM information_schema.columns, widzi, że price jest typu numeric(10,2), a tabela ma 40 mln wierszy, i odpowiednio dobiera rozmiar partii oraz strategię blokad.

Postgres MCP Pro to narzędzie napisane w Pythonie — zainstaluj je przez pipx install postgres-mcp (lub uv pip install postgres-mcp). Konfiguracja jest potem identyczna we wszystkich trzech narzędziach, bo każde mówi w MCP. Dodaj serwer do swojej konfiguracji MCP, przekazując connection string przez DATABASE_URI i wymuszając tylko odczyt za pomocą --access-mode=restricted:

{
"mcpServers": {
"postgres": {
"command": "postgres-mcp",
"args": ["--access-mode=restricted"],
"env": {
"DATABASE_URI": "postgresql://readonly@localhost:5432/mydb"
}
}
}
}

W przypadku Claude Code ten sam serwer dodasz z poziomu CLI:

Okno terminala
claude mcp add --transport stdio \
--env DATABASE_URI=postgresql://readonly@localhost:5432/mydb \
postgres -- postgres-mcp --access-mode=restricted

Jeśli wszystko, czego potrzebujesz, to „pozwolić agentowi uruchamiać bezpieczne zapytania tylko do odczytu” bez trwałego połączenia, lżejszą alternatywą jest Agent Skill, taki jak supabase/postgres-best-practices, instalowany poleceniem npx skills add supabase/agent-skills. Skille to jednofunkcyjne wzmocnienie; serwer MCP sprawdza się lepiej, gdy chcesz, by agent wielokrotnie introspekował żywą bazę danych w trakcie całej sesji.

Pliki migracji i kod podwójnego zapisu produkuje wszędzie ten sam prompt — różni się jednak to, jak prowadzisz tę pętlę, recenzujesz diff i wdrażasz.

Otwórz katalog migracji i przełącz wybór modelu na Claude Opus 4.8 (lub Claude Fable 5 przy najbardziej złożonych, wieloplikowych refaktoryzacjach, gdy budżet ma mniejsze znaczenie niż poprawność). W trybie Agent wklej powyższy prompt expand-contract. Cursor proponuje trzy pliki SQL plus zmiany w podwójnym zapisie jako edycję wieloplikową; zrecenzuj je w widoku diff, zanim zaakceptujesz.

Wizualny diff jest tu sednem: przeczytaj klauzulę partiowania WHERE … LIMIT 1000 i potwierdź, że migracja contract jest w osobnym pliku niż expand, abyś mógł wdrożyć je w różnych wydaniach. Użyj checkpointu przed akceptacją, żeby móc cofnąć edycję, jeśli agent posunie się za daleko. Następnie uruchom migrację przez swoje zwykłe narzędzie (npm run db:migrate) w zintegrowanym terminalu i obserwuj oczekiwanie na blokady.

Wygenerowanie migracji to połowa roboty. Druga połowa to wyłapanie produkcyjnych zagrożeń, które ludzki recenzent prześlizguje o 14:00. Dwa tryby awarii z incydentu z początku — blokady całych tabel i niepartiowane zapisy — to dokładnie to, co LLM wykrywa świetnie, gdy poprosisz go, żeby się temu przyjrzał.

Kilka poprawek, które ta recenzja niezawodnie wydobywa:

  • Budowanie indeksów: CREATE INDEX CONCURRENTLY idx_products_sku ON products(sku); zamiast samego CREATE INDEX. Forma współbieżna unika blokady ACCESS EXCLUSIVE — kosztem tego, że nie może działać wewnątrz bloku transakcji, więc musi być osobnym krokiem migracji.
  • Nowe ograniczenia: dodaj jako NOT VALID, a potem ALTER TABLE … VALIDATE CONSTRAINT w osobnej instrukcji. Walidacja zakłada wtedy tylko blokadę SHARE UPDATE EXCLUSIVE i nie blokuje zapisów.
  • Backfille: partiowane w pętli (jak w plikach expand-contract powyżej), nigdy jeden gigantyczny UPDATE, który trzyma blokady wierszy i puchnie tabelę martwymi krotkami.

Jeśli sam opakowujesz zmiany schematu online, wybierz sprawdzone w boju narzędzie zamiast ręcznie sklejanych wyzwalaczy. Dla MySQL pt-online-schema-change prowadzi kopiowanie i podmianę za pomocą wyzwalaczy, dobierając poprawnie jawne listy kolumn tam, gdzie ręcznie pisane wyzwalacze INSERT … SELECT NEW.* zawodzą (w MySQL NEW to referencja do wiersza, a nie tabela, z której można SELECT-ować). gh-ost podchodzi do tego odwrotnie — jest bezwyzwalaczowy, śledząc binary log, żeby odtwarzać zapisy na tabeli-widmie, co przy intensywnym ruchu zapisów utrzymuje narzut wyzwalaczy z dala od źródła. Poproś agenta, żeby wywołał jedno z nich, zamiast wymyślać je od nowa.

Zwalidowana migracja danych, którą naprawdę uruchomisz

Dział zatytułowany „Zwalidowana migracja danych, którą naprawdę uruchomisz”

Gdy przekształcasz dane — a nie tylko schemat — połącz migrację z walidującym, partiowanym kopiowaniem. Oto krótka, samodzielna wersja skupiona na pętli, którą generuje agent; trzymaj ją małą i pozwól, by prompt wyprodukował transform/validate specyficzne dla tabeli:

// Partiowane, walidowane kopiowanie ze starej tabeli. db to klient w stylu node-postgres.
async function migrateUsers({ batchSize = 1000 } = {}) {
let cursor = 0;
let migrated = 0;
const errors = [];
for (;;) {
const { rows: batch } = await db.query(
'SELECT * FROM legacy_users WHERE id > $1 ORDER BY id LIMIT $2',
[cursor, batchSize]
);
if (batch.length === 0) break;
let successful = 0; // let, nie const — to jest inkrementowane poniżej
for (const row of batch) {
const email = row.email_address?.toLowerCase();
if (!email || !email.includes('@')) {
errors.push({ id: row.id, reason: 'invalid email' });
continue;
}
await db.query(
'INSERT INTO users (email, legacy_id) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING',
[email, row.id]
);
successful++;
}
migrated += successful;
cursor = batch[batch.length - 1].id;
console.info(`migrated ${migrated} (batch ${batch.length}, ${errors.length} skipped)`);
}
return { migrated, errors, ok: errors.length === 0 };
}

Zwróć uwagę na celowo nudne detale, które czynią ją bezpieczną na produkcji: paginacja keyset po id (a nie OFFSET, który zwalnia liniowo), ON CONFLICT DO NOTHING dla idempotentnych ponownych przebiegów, bramka walidacji, która zapisuje błędne wiersze zamiast się wywalać, oraz let successful = 0, aby licznik na partię faktycznie mógł rosnąć. Poproś agenta, żeby opakował każdą partię w transakcję, jeśli potrzebujesz semantyki wszystko-albo-nic dla pojedynczej partii.

  • Wzorce SQL — optymalizacja zapytań, którą agent zastosuje na twoich wolnych ścieżkach
  • Wzorce ORM — generowanie bezpiecznych migracji przez Drizzle, Prismę i Knexa
  • Wzorce NoSQL — migracje i backfille baz dokumentowych