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.
Co z tego wyniesiesz
Dział zatytułowany „Co z tego wyniesiesz”- Wielokrotnego użytku prompt expand-contract, który zamienia ryzykowny, jednorazowy
ALTERw 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ąceCONCURRENTLYi niepartiowaneUPDATE, 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
Przepływ pracy expand-contract
Dział zatytułowany „Przepływ pracy expand-contract”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 productsSET 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 backfilluasync 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:
claude mcp add --transport stdio \ --env DATABASE_URI=postgresql://readonly@localhost:5432/mydb \ postgres -- postgres-mcp --access-mode=restrictedJeś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.
Sterowanie tym w każdym z narzędzi
Dział zatytułowany „Sterowanie tym w każdym z narzędzi”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.
Wygeneruj i zweryfikuj w jednym przebiegu bezgłowym — idealne do kroku CI lub powtarzalnego skryptu. Claude Code zapisuje pliki, a potem uruchamia twój zestaw testów migracji przez narzędzie Bash:
claude -p "Generate an expand-contract migration replacing products.price (numeric) \with price_cents (integer) in db/migrations/. Three numbered files: expand, batched \backfill (runs outside a transaction), contract. Then run 'npm run db:migrate' against \the local test database and 'npm test -- migrations' and report failures." \ --allowedTools "Read" "Write" "Edit" "Bash(npm run db:migrate)" "Bash(npm test *)" \ --output-format json--allowedTools wstępnie autoryzuje dokładnie te wzorce do uruchomienia bez pytania o uprawnienia, więc w tym bezgłowym przebiegu -p agent może udowodnić, że migracja się stosuje, a zestaw testów pozostaje zielony bez nadzoru — wszystko spoza listy zatrzymałoby się na zatwierdzenie, które nigdy nie nadejdzie. (Aby twardo ograniczyć dostępny zestaw narzędzi, a nie tylko pominąć pytania, użyj zamiast tego --tools). Przekieruj --output-format json do logów CI, by mieć audytowalny zapis tego, co zostało uruchomione.
Uruchom zmianę w odizolowanym worktree, żeby nigdy nie naruszyła twojego roboczego checkoutu. W aplikacji Codex (lub CLI codex) zacznij wątek na worktree opartym na main, a potem wklej prompt expand-contract.
Do uruchomienia skryptowego użyj codex exec w trybie nieinteraktywnym:
codex exec --cd ../app-migrations \ "Add an expand-contract migration replacing products.price with price_cents. \Three numbered files, batched backfill outside a transaction, contract separate. \Run the migration test suite and summarize."Gdy diff wygląda dobrze, kliknij Create branch here na worktree, wypchnij go i otwórz pull request na GitHubie prosto z wątku. Izolacja worktree sprawia, że długi eksperyment z backfillem nigdy nie blokuje gałęzi funkcji, którą edytujesz lokalnie.
Niech agent zrecenzuje własną migrację
Dział zatytułowany „Niech agent zrecenzuje własną migrację”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 samegoCREATE INDEX. Forma współbieżna unika blokadyACCESS 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 potemALTER TABLE … VALIDATE CONSTRAINTw osobnej instrukcji. Walidacja zakłada wtedy tylko blokadęSHARE UPDATE EXCLUSIVEi 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.
Gdy to się zepsuje
Dział zatytułowany „Gdy to się zepsuje”Co dalej
Dział zatytułowany „Co dalej”- 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