
Схема базы меняется постоянно: новые поля, индексы, связи, исправления. Каждая неподготовленная миграция — риск:
Миграции без простоя позволяют развивать продукт быстрее и безопаснее. Это ощутимая экономика: меньше ночных «окон», меньше ручной рутины, ниже риск инцидентов, стабильная скорость релизов.
PostgreSQL бережно относится к данным, но ряд DDL‑операций берёт тяжёлые блокировки. Главное — понимать, что именно блокирует чтение/запись и как этого избежать.
Классический шаблон без простоя:
Каждый шаг — отдельный релиз. Между ними сервис остаётся работоспособным и обратимым.
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
REINDEX INDEX CONCURRENTLY idx_users_email;
DROP INDEX CONCURRENTLY idx_users_email;
CONCURRENTLY нельзя выполнять внутри транзакции — это важно для инструментов миграций.
ALTER TABLE orders ADD COLUMN external_id text; -- NULL разрешён
ALTER TABLE orders ALTER COLUMN external_id SET NOT NULL;
Для PostgreSQL ≥ 11 установка DEFAULT константой метаданная и быстрая, но безопаснее всё равно выставлять значение приложением, пока идёт backfill.
ALTER TABLE orders
ADD CONSTRAINT orders_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;
Аналогично с CHECK:
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;
Запускайте DDL с ограничениями, чтобы миграция не зависла и не блокировала продакшен:
SET lock_timeout = '1s';
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '30s';
Django поддерживает операции для PostgreSQL, позволяющие создавать/удалять индексы конкурентно. Важно: миграция должна быть atomic = False.
# myapp/migrations/0023_add_email_index.py
from django.db import migrations, models
from django.contrib.postgres.operations import AddIndexConcurrently, RemoveIndexConcurrently
class Migration(migrations.Migration):
atomic = False # нужно для CONCURRENTLY
dependencies = [
("myapp", "0022_previous"),
]
operations = [
AddIndexConcurrently(
"user",
models.Index(fields=["email"], name="idx_user_email"),
)
]
Удаление индекса:
class Migration(migrations.Migration):
atomic = False
dependencies = [
("myapp", "0023_add_email_index"),
]
operations = [
RemoveIndexConcurrently("user", "idx_user_email"),
]
# 0024_add_external_id.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("myapp", "0023_add_email_index")]
operations = [
migrations.AddField(
model_name="order",
name="external_id",
field=models.CharField(max_length=64, null=True),
),
]
# 0025_enforce_external_id_not_null.py
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("myapp", "0024_add_external_id")]
operations = [
migrations.RunSQL(
"ALTER TABLE orders ALTER COLUMN external_id SET NOT NULL;",
reverse_sql="ALTER TABLE orders ALTER COLUMN external_id DROP NOT NULL;",
)
]
# versions/20240101_add_user_email_index.py
from alembic import op
def upgrade():
# CONCURRENTLY — вне транзакции
with op.get_context().autocommit_block():
op.create_index(
"ix_user_email",
"user",
["email"],
unique=False,
postgresql_concurrently=True,
)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index("ix_user_email", table_name="user", postgresql_concurrently=True)
from alembic import op
def upgrade():
op.execute(
"ALTER TABLE orders ADD CONSTRAINT orders_user_fk "
"FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;"
)
op.execute("ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;")
def downgrade():
op.execute("ALTER TABLE orders DROP CONSTRAINT orders_user_fk;")
Основные принципы:
-- Пример батчевого обновления 10k строк за проход
DO $$
DECLARE
batch_size integer := 10000;
updated_rows integer := 1;
BEGIN
WHILE updated_rows > 0 LOOP
WITH cte AS (
SELECT id
FROM orders
WHERE external_id IS NULL
ORDER BY id
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE orders o
SET external_id = 'legacy_' || o.id::text
FROM cte
WHERE o.id = cte.id;
GET DIAGNOSTICS updated_rows = ROW_COUNT;
PERFORM pg_sleep(0.05); -- дать продакшену подышать
END LOOP;
END$$;
import time
import psycopg2
conn = psycopg2.connect(dsn="postgresql://app:pass@db/prod")
conn.autocommit = True
BATCH = 5000
SLEEP = 0.05
query = """
WITH cte AS (
SELECT id FROM orders
WHERE external_id IS NULL
ORDER BY id
LIMIT %s
FOR UPDATE SKIP LOCKED
)
UPDATE orders o
SET external_id = 'legacy_' || o.id::text
FROM cte
WHERE o.id = cte.id;
"""
with conn.cursor() as cur:
while True:
cur.execute(query, (BATCH,))
if cur.rowcount == 0:
break
time.sleep(SLEEP)
print("done")
Смотрите, кто на кого ждёт:
SELECT a.pid, a.usename, a.state, a.query, a.wait_event_type, a.wait_event
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY a.query_start;
Сводка по блокировкам:
SELECT bl.pid AS blocked_pid,
ka.query AS blocking_query,
now() - ka.query_start AS blocking_duration,
a.query AS blocked_query
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN pg_locks kl ON kl.transactionid = bl.transactionid AND kl.pid != bl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
Прогресс индекса (PostgreSQL ≥ 12):
SELECT * FROM pg_stat_progress_create_index;
Запускайте миграции с ограничениями:
SET lock_timeout = '1s';
SET statement_timeout = '10min';
SET idle_in_transaction_session_timeout = '30s';
SET application_name = 'migrations';
Если упёрлись в блокировку — миграция быстро отвалится и не положит продакшен.
Важно: держите нумерованные, короткие миграции, не сливайте много рискованных шагов в один файл.
Поймаем опасные конструкции до продакшена:
#!/usr/bin/env bash
set -euo pipefail
grep -R "CREATE INDEX " migrations | grep -v "CONCURRENTLY" && {
echo "Найдены индексы без CONCURRENTLY"; exit 1; }
grep -R "ALTER TABLE .* ADD COLUMN .* DEFAULT" migrations && {
echo "ADD COLUMN с DEFAULT — проверьте стратегию"; exit 1; }
name: Migrations CI
on: [push]
jobs:
check-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint SQL migrations
run: |
bash ci/lint_migrations.sh
- name: Spin up Postgres
uses: harmon758/postgresql-action@v1
with:
postgresql version: '15'
postgresql db: 'test'
postgresql user: 'postgres'
postgresql password: 'postgres'
- name: Apply migrations
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
run: |
export PGPASSWORD=postgres
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "SET lock_timeout='1s'; SET statement_timeout='5min';"
# здесь запускайте ваши миграции (manage.py migrate / alembic upgrade head)
Миграции без простоя — это не магия, а набор дисциплин: правильные блокировки, разбиение на шаги, прозрачный backfill, ограничение времени операций и автоматические проверки. Применив шаблон expand–migrate–contract и техники PostgreSQL (CONCURRENTLY, NOT VALID/VALIDATE, аккуратный NOT NULL), вы снижаете риск инцидентов, ускоряете вывод изменений и экономите на инфраструктуре. Это чувствуется и в SLA, и в бюджете.