Индексы, транзакции, ORM pitfalls, миграции
Половина проблем с БД не видна в диффе вообще: индекс отсутствует не в этих строках, а в схеме таблицы. Ревьюер обязан достраивать этот контекст.
Вы научитесь по запросу определять, какой индекс ему нужен и почему существующий не подойдёт; находить границы транзакций, проведённые не там; замечать миграции, которые заблокируют таблицу на проде; и видеть ORM-конструкции, разворачивающиеся в тысячу запросов.
Правило «индекс на поля из WHERE» слишком грубое и приводит к десятку бесполезных индексов, каждый из которых замедляет запись.
Составной индекс работает слева направо, и порядок колонок в нём важнее их набора. Индекс (user_id, created_at) обслуживает запрос по user_id, запрос по user_id + created_at и сортировку по created_at внутри пользователя. Тот же индекс бесполезен для запроса только по created_at.
-- запрос из PR
SELECT * FROM orders
WHERE user_id = $1 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
-- ❌ существующий индекс: (user_id) — по нему найдутся все заказы
-- пользователя, затем фильтр по статусу и сортировка в памяти
-- ✅ нужный индекс: (user_id, status, created_at DESC)Порядок здесь не случаен: сначала колонки со строгим равенством, затем та, по которой идёт сортировка. При таком индексе база не сортирует ничего — она читает первые двадцать записей в нужном порядке.
Что спрашивать в ревью:
EXPLAIN ANALYZE) на данных, сравнимых с продом.is_deleted или status с тремя значениями обычно не используется — база предпочтёт полный проход.WHERE lower(email) = $1 не использует индекс по email; нужен индекс по выражению.Проверьте себя. Возьмите самый частый запрос вашего проекта, запустите EXPLAIN ANALYZE и найдите в плане Seq Scan или Sort.
Частая ошибка. Добавлять индекс на каждую колонку из WHERE по отдельности вместо одного составного.
Транзакция должна охватывать ровно то, что обязано быть согласованным, — и ничего больше.
# ❌ транзакция слишком узкая: две операции, которые обязаны быть вместе
order = Order.objects.create(user=user, total=total)
Wallet.objects.filter(user=user).update(balance=F("balance") - total)
# сбой между строками → заказ есть, деньги не списаны# ❌ транзакция слишком широкая: внешний вызов внутри
with transaction.atomic():
order = Order.objects.create(...)
requests.post(PAYMENT_URL, json=...) # 3 секунды соединение держит транзакцию
order.status = "paid"
order.save()Второй случай коварнее. Транзакция удерживает соединение и блокировки на всё время сетевого запроса; при таймауте платёжного шлюза база держит блокировки тридцать секунд, а под нагрузкой это исчерпание пула. Плюс если запрос успел пройти, а транзакция откатилась, деньги списаны, а заказа нет.
Правило: внутри транзакции — только работа с этой базой. Внешние вызовы, отправка писем, публикация в очередь — после фиксации.
# ✅ узкая транзакция, эффекты после
with transaction.atomic():
order = Order.objects.create(...)
charged = Wallet.objects.filter(user=user, balance__gte=total).update(
balance=F("balance") - total
)
if not charged:
raise InsufficientFunds
send_confirmation_email(order) # после commitОтдельный вопрос — что делать, если письмо не отправится. Правильный ответ обычно «положить задачу в очередь в той же транзакции, отправлять воркером», но это уже разговор про надёжность, а не про транзакции.
Проверьте себя. Найдите в проекте транзакцию, внутри которой есть HTTP-запрос или отправка письма.
Частая ошибка. Оборачивать в транзакцию весь обработчик запроса «на всякий случай». Это удерживает соединение на время всей обработки.
Разработчики часто считают, что транзакция защищает от гонок. Она защищает от части гонок — от того, что видно, а не от того, что записано.
На уровне READ COMMITTED (по умолчанию в PostgreSQL) две транзакции могут прочитать одно значение и обе его перезаписать. Классическая проверка «есть ли свободное место» не работает:
# ❌ гонка сохраняется, несмотря на транзакцию
with transaction.atomic():
count = Booking.objects.filter(slot=slot).count()
if count < slot.capacity:
Booking.objects.create(slot=slot, user=user) # два запроса → переборТри рабочих решения, в порядке предпочтения:
-- ✅ 1. Ограничение в схеме: база отвергнет лишнюю запись сама
ALTER TABLE bookings ADD CONSTRAINT uniq_slot_seat UNIQUE (slot_id, seat_no);# ✅ 2. Блокировка строки-владельца
with transaction.atomic():
slot = Slot.objects.select_for_update().get(id=slot_id)
if slot.booked < slot.capacity:
slot.booked += 1
slot.save()
Booking.objects.create(slot=slot, user=user)# ✅ 3. Условное обновление — атомарно и без блокировок
updated = Slot.objects.filter(id=slot_id, booked__lt=F("capacity")).update(
booked=F("booked") + 1
)
if not updated:
raise NoSeatsLeftПервое надёжнее всех: инвариант живёт в схеме и не может быть нарушен никаким кодом, включая скрипт, который кто-то запустит руками.
Проверьте себя. Найдите в проекте проверку «если ещё не превышен лимит — записать». Чем она защищена?
Частая ошибка. Повышать уровень изоляции до SERIALIZABLE как универсальное лечение. Он даёт корректность, но добавляет ошибки сериализации, которые код обязан обрабатывать повтором, — а этого почти никогда не делают.
ORM скрывает количество запросов, и это его главная опасность в ревью.
# ❌ N+1: обращение к связанному объекту в цикле
for order in Order.objects.all():
print(order.user.email)
# ✅ 2 запроса
for order in Order.objects.select_related("user"):
...# ❌ вставка в цикле: N запросов
for row in rows:
Item.objects.create(**row)
# ✅ один запрос
Item.objects.bulk_create([Item(**row) for row in rows], batch_size=1000)# ❌ агрегация в Python: вся таблица в память приложения
total = sum(o.total for o in Order.objects.all())
# ✅ агрегация в базе
total = Order.objects.aggregate(Sum("total"))["total__sum"]# ❌ .count() внутри цикла по объектам — снова N+1
for user in users:
print(user.orders.count())
# ✅ одна аннотация
users = User.objects.annotate(orders_count=Count("orders"))Отдельно смотрите на len(queryset) — он выполняет запрос и материализует все объекты, тогда как queryset.count() считает в базе. И на .exists() вместо if queryset:.
Практический совет ревьюеру: попросите приложить количество запросов. В Django это assertNumQueries в тесте, в SQLAlchemy — счётчик на событии. Тест на число запросов — единственная защита от того, что N+1 вернётся при следующем рефакторинге.
Проверьте себя. Включите вывод SQL в тестах и посмотрите, сколько запросов делает главная страница проекта.
Частая ошибка. Чинить N+1 добавлением prefetch_related везде подряд. Лишний prefetch тянет данные, которые не нужны, и заменяет одну проблему другой.
Миграция проверяется по другому критерию, чем остальной код: не «правильна ли она», а «что произойдёт с работающим приложением в момент её применения».
Опасные операции на большой таблице:
| Операция | Что происходит |
|---|---|
ADD COLUMN NOT NULL DEFAULT ... | В старых версиях СУБД — перезапись всей таблицы под блокировкой |
CREATE INDEX без CONCURRENTLY | Блокировка записи на время построения |
ALTER COLUMN TYPE | Перезапись таблицы, длительная блокировка |
DROP COLUMN | Мгновенно, но ломает старую версию приложения, если она ещё жива |
| Изменение данных в той же миграции | Длинная транзакция, блокирующая таблицу |
Ключевой вопрос: совместима ли миграция с обеими версиями кода? Во время выкатки старые и новые экземпляры приложения работают одновременно. Значит, удаление колонки и код, перестающий её использовать, не могут ехать в одном релизе.
Безопасная схема для обязательного поля — три шага в три релиза: добавить nullable, заполнить фоном, сделать NOT NULL. Для удаления — обратный порядок: сначала код перестаёт читать, потом колонка удаляется.
# ✅ индекс без блокировки записи (Django)
class Migration(migrations.Migration):
atomic = False # обязательно для CONCURRENTLY
operations = [
AddIndexConcurrently(
model_name="order",
index=models.Index(fields=["user", "status", "-created_at"]),
),
]И обязательный вопрос про откат: если релиз придётся вернуть, миграция откатывается? Необратимая миграция — не запрет, но она должна быть осознанным решением, а не сюрпризом в момент инцидента.
Проверьте себя. Посмотрите последнюю миграцию в проекте и ответьте: сколько она проработает на таблице в десять миллионов строк?
Частая ошибка. Тестировать миграцию на пустой локальной базе. Все опасные свойства проявляются только на объёме.
Задача: скрывать заказы старше двух лет из основного списка. Дифф: миграция и правка выборки.
+# migrations/0042_add_is_archived.py
+class Migration(migrations.Migration):
+ operations = [
+ migrations.AddField(
+ "order",
+ "is_archived",
+ models.BooleanField(default=False, null=False),
+ ),
+ migrations.RunPython(archive_old_orders),
+ ]
+
+
+def archive_old_orders(apps, schema_editor):
+ Order = apps.get_model("orders", "Order")
+ cutoff = timezone.now() - timedelta(days=730)
+ for order in Order.objects.filter(created_at__lt=cutoff):
+ order.is_archived = True
+ order.save()
+
+
+# api/orders.py
+@app.get("/orders")
+def list_orders(user=Depends(current_user)):
+ orders = Order.objects.filter(user=user, is_archived=False)
+ return [
+ {"id": o.id, "total": o.total, "items": o.items.count()}
+ for o in orders
+ ]Что видит ревьюер. Таблица orders — одна из крупнейших в проекте, порядка сорока миллионов строк. Это меняет оценку каждой строки диффа.
AddField с NOT NULL и default — потенциальная перезапись всей таблицы под блокировкой. На современном PostgreSQL добавление колонки с константным default выполняется мгновенно, но это зависит от версии, и полагаться на неё без проверки нельзя.
RunPython внутри той же миграции обходит все сорок миллионов строк в цикле, сохраняя каждую отдельным UPDATE, и всё это в одной транзакции. Это часы работы с растущей блокировкой и почти гарантированный таймаут выкатки. Плюс миграция и заполнение данных в одном шаге не дают откатить одно без другого.
Выборка filter(user=user, is_archived=False) требует индекса, которого в PR нет. Существующий индекс по user_id теперь недостаточен.
o.items.count() в списочном включении — N+1 по числу заказов.
И совместимость: старая версия приложения, работающая во время выкатки, не знает про is_archived. Здесь это безопасно (колонка со значением по умолчанию), но проверить нужно явно.
Комментарии в PR:
migrations/0042:11· blockerRunPythonобходит 40 млн строк по одной в единой транзакции. Это часы работы под блокировкой и почти наверняка таймаут деплоя. Заполнение нужно вынести из миграции: отдельная management-команда пачками по 10 000 сbulk_updateи паузами, запускается после релиза.
migrations/0042:1· blocker Миграция и заполнение данных в одном шаге: откатить схему, не откатывая данные, невозможно. Разделите на две миграции — схема и данные.
api/orders.py:4· major Новый фильтр поis_archivedне обслуживается существующим индексом(user_id). Нужен(user_id, is_archived, created_at DESC), добавленныйCONCURRENTLY— обычныйCREATE INDEXна этой таблице заблокирует запись минут на десять.
api/orders.py:6· majoro.items.count()в цикле — N+1: на странице из 50 заказов это 51 запрос. Замените наannotate(items_count=Count("items")). И добавьтеassertNumQueriesв тест, иначе это вернётся.
api/orders.py:4· major Выборка без ограничения: у крупного клиента здесь десятки тысяч заказов, все материализуются в память. Нужна пагинация.
migrations/0042:4· question На какой версии PostgreSQL это поедет? ДобавлениеNOT NULLколонки сdefaultмгновенно начиная с 11-й, но если где-то в контурах есть 10-я, будет перезапись таблицы. Стоит проверить и на всякий случай сделать поле nullable.
api/orders.py:4· question Архивные заказы теперь недоступны совсем? Если пользователю нужно их посмотреть, понадобится параметр — и тогда индекс лучше сразу спроектировать под оба сценария.
Чем закончилось. Миграцию разделили на три: nullable-колонка, CREATE INDEX CONCURRENTLY, затем — уже после выкатки кода — команда заполнения пачками. Флаг сделали nullable и трактовали NULL как «не архивный», чтобы не блокировать таблицу вообще. Запрос получил аннотацию и пагинацию; в тест добавили assertNumQueries(3).
Вопрос про доступность архива оказался продуктовым: архивные заказы всё-таки нужны в отдельном разделе, и индекс спроектировали под оба запроса сразу — иначе через месяц пришлось бы строить второй на той же огромной таблице.
Главное наблюдение: ни одно из замечаний не следует из самого диффа. Все они появились из внешнего знания — размера таблицы, версии СУБД, порядка выкатки. Это и есть работа ревьюера уровня Pro.
ИНДЕКС для каждого нового запроса назван обслуживающий индекс
ПОРЯДОК в составном индексе: равенство → диапазон → сортировка
ПЛАН на новых запросах приложен EXPLAIN на объёме, близком к проду
ТРАНЗАКЦИИ охватывают согласованные изменения, без внешних вызовов внутри
ГОНКИ инвариант защищён ограничением схемы, блокировкой или условным UPDATE
ЗАПРОСЫ нет N+1; вставки и обновления пачками; агрегация в БД
ОБЪЁМ выборки ограничены, пагинация есть
МИГРАЦИИ не блокируют надолго; совместимы с обеими версиями кода; откатываются
ДАННЫЕ заполнение больших таблиц — вне миграции, пачкамиКонкурентные аспекты работы с данными подробнее — Асинхронность и конкурентность. Кэш перед базой и его инвалидация — Масштабируемость. Полный курс по БД — PostgreSQL.
Ключевая мысль: инвариант, обеспеченный кодом, нарушается однажды; инвариант, обеспеченный схемой, — никогда.
Далее: Дизайн API