Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Рецензирование работы с БД
database_review

Рецензирование работы с БД

Индексы, транзакции, ORM pitfalls, миграции

Рецензирование работы с базами данных

Половина проблем с БД не видна в диффе вообще: индекс отсутствует не в этих строках, а в схеме таблицы. Ревьюер обязан достраивать этот контекст.

#Результат урока

Вы научитесь по запросу определять, какой индекс ему нужен и почему существующий не подойдёт; находить границы транзакций, проведённые не там; замечать миграции, которые заблокируют таблицу на проде; и видеть ORM-конструкции, разворачивающиеся в тысячу запросов.

#1. Индекс подбирается под запрос, а не под колонку

Правило «индекс на поля из 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 по отдельности вместо одного составного.

#2. Границы транзакции

Транзакция должна охватывать ровно то, что обязано быть согласованным, — и ничего больше.

# ❌ транзакция слишком узкая: две операции, которые обязаны быть вместе 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-запрос или отправка письма.

Частая ошибка. Оборачивать в транзакцию весь обработчик запроса «на всякий случай». Это удерживает соединение на время всей обработки.

#3. Уровни изоляции и то, что они не гарантируют

Разработчики часто считают, что транзакция защищает от гонок. Она защищает от части гонок — от того, что видно, а не от того, что записано.

На уровне 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 как универсальное лечение. Он даёт корректность, но добавляет ошибки сериализации, которые код обязан обрабатывать повтором, — а этого почти никогда не делают.

#4. ORM-конструкции, разворачивающиеся в тысячу запросов

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 тянет данные, которые не нужны, и заменяет одну проблему другой.

#5. Миграции: то, что ломает прод в момент выкатки

Миграция проверяется по другому критерию, чем остальной код: не «правильна ли она», а «что произойдёт с работающим приложением в момент её применения».

Опасные операции на большой таблице:

ОперацияЧто происходит
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"]), ), ]

И обязательный вопрос про откат: если релиз придётся вернуть, миграция откатывается? Необратимая миграция — не запрет, но она должна быть осознанным решением, а не сюрпризом в момент инцидента.

Проверьте себя. Посмотрите последнюю миграцию в проекте и ответьте: сколько она проработает на таблице в десять миллионов строк?

Частая ошибка. Тестировать миграцию на пустой локальной базе. Все опасные свойства проявляются только на объёме.

#6. Разбор: PR «Архив заказов и поле is_archived»

Задача: скрывать заказы старше двух лет из основного списка. Дифф: миграция и правка выборки.

+# 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 · blocker RunPython обходит 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 · major o.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.

#7. Чек-лист

ИНДЕКС для каждого нового запроса назван обслуживающий индекс ПОРЯДОК в составном индексе: равенство → диапазон → сортировка ПЛАН на новых запросах приложен EXPLAIN на объёме, близком к проду ТРАНЗАКЦИИ охватывают согласованные изменения, без внешних вызовов внутри ГОНКИ инвариант защищён ограничением схемы, блокировкой или условным UPDATE ЗАПРОСЫ нет N+1; вставки и обновления пачками; агрегация в БД ОБЪЁМ выборки ограничены, пагинация есть МИГРАЦИИ не блокируют надолго; совместимы с обеими версиями кода; откатываются ДАННЫЕ заполнение больших таблиц — вне миграции, пачками

#Что дальше

Конкурентные аспекты работы с данными подробнее — Асинхронность и конкурентность. Кэш перед базой и его инвалидация — Масштабируемость. Полный курс по БД — PostgreSQL.


Ключевая мысль: инвариант, обеспеченный кодом, нарушается однажды; инвариант, обеспеченный схемой, — никогда.

Далее: Дизайн API