Транзакции, блокировки и индексы

Деньги списались, но заказ не создался — это про транзакции. Два процесса испортили один счётчик — это про блокировки. Запрос тормозит на большой таблице — это про индексы.

Транзакция — группа операций, которые применяются к базе целиком или не применяются вовсе; промежуточного состояния снаружи не видно.

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

atomic: всё или ничего

Представьте перевод денег: списать с одного счёта и зачислить на другой. Если между этими шагами упадёт исключение, деньги «испарятся». transaction.atomic гарантирует, что либо обе операции применятся, либо ни одна — при ошибке всё откатывается (ROLLBACK).

from django.db import transaction

with transaction.atomic():
    Account.objects.filter(pk=src).update(balance=F("balance") - amount)
    Account.objects.filter(pk=dst).update(balance=F("balance") + amount)
    # если здесь возникнет исключение — оба UPDATE откатятся

Можно использовать и как декоратор на всю функцию-обработчик: @transaction.atomic. Вложенные atomic создают точки сохранения (savepoint): внутренний блок может откатиться, не отменяя внешний. Полезная привычка для критичных действий с деньгами/заказами — оборачивать связанные записи в один atomic.

Тонкий момент: код, который должен сработать только после успешного коммита (отправить письмо, поставить задачу в очередь), вешают на transaction.on_commit(...) — иначе при откате вы отправите письмо о заказе, которого не существует.

select_for_update: блокировка строк против гонок

Транзакция спасает от частичной записи, но не от конкуренции. Если два процесса одновременно читают остаток товара, оба видят «1 шт» и оба продают — получится продажа двух единиц при одной на складе. Решение — пессимистичная блокировка строки на время транзакции через select_for_update(): СУБД ставит на выбранные строки замок (SELECT ... FOR UPDATE), и второй процесс ждёт, пока первый завершит транзакцию.

from django.db import transaction

with transaction.atomic():
    product = Product.objects.select_for_update().get(pk=pid)  # строка заблокирована
    if product.stock > 0:
        product.stock -= 1
        product.save()
    # замок снимается на выходе из atomic (COMMIT/ROLLBACK)

select_for_update() работает только внутри atomic — вне транзакции Django выбросит ошибку, ведь блокировку нечем удерживать. Чтобы не ждать вечно занятую строку, есть опции: select_for_update(nowait=True) сразу падает с ошибкой, если строка занята, а select_for_update(skip_locked=True) пропускает занятые строки (удобно для очередей задач).

Когда нужны индексы

Индекс — это отдельная отсортированная структура (обычно B-дерево), которая позволяет СУБД находить строки без полного перебора таблицы. Без индекса запрос с WHERE на большой таблице делает seq scan — читает все строки подряд. С индексом — спускается по дереву за логарифмическое время.

Индекс оправдан, когда по полю часто:

  • фильтруютWHERE status = 'paid';
  • сортируютORDER BY created_at;
  • соединяют — поля внешних ключей (Django индексирует ForeignKey автоматически).

Но индексы не бесплатны: каждый замедляет INSERT/UPDATE/DELETE (его тоже надо обновлять) и занимает место. Поэтому индексируют выборочно — по реальным запросам, а не «на всякий случай на каждое поле».

db_index и Meta.indexes

Простой индекс по одному полю — флаг db_index=True:

class Order(models.Model):
    status = models.CharField(max_length=20, db_index=True)   # индекс по статусу
    created_at = models.DateTimeField(auto_now_add=True)

Составные индексы (по нескольким полям сразу) и уникальные ограничения задают в Meta.indexes и Meta.constraints. Составной индекс ускоряет запросы, фильтрующие/сортирующие по префиксу его полей:

class Order(models.Model):
    user = models.ForeignKey("User", on_delete=models.CASCADE)
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["user", "created_at"]),       # частые заказы юзера по дате
            models.Index(fields=["status"], name="order_status_idx"),
        ]

Индекс ["user", "created_at"] поможет запросу «заказы пользователя, отсортированные по дате», но не ускорит фильтр только по created_at без user — порядок полей в составном индексе важен. Любое изменение indexes требует makemigrations + migrate.

raw() и сырой SQL — крайняя мера

ORM покрывает почти всё, но иногда нужен запрос, который он не выражает: хитрая оконная функция, специфичная для СУБД конструкция, агрессивно оптимизированный отчёт. На этот случай есть «аварийные выходы». Model.objects.raw() выполняет сырой SQL и возвращает объекты модели:

# параметры передаём вторым аргументом — НИКОГДА не форматируйте строкой
for a in Author.objects.raw(
    "SELECT id, name FROM author WHERE name LIKE %s", ["А%"]
):
    print(a.name)

Если результат не ложится на модель (произвольные колонки, агрегаты), берут совсем низкий уровень — connection.cursor():

from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("SELECT status, COUNT(*) FROM \"order\" GROUP BY status")
    rows = cursor.fetchall()   # [("paid", 1200), ("new", 80)]

Главное правило безопасности: параметры — только через %s и список, который драйвер подставит и экранирует. Никогда не вклеивайте пользовательский ввод в строку SQL через f-строки или конкатенацию — это прямая дорога к SQL-инъекции. И помните: raw — это последний рубеж. Сначала ищите решение штатным ORM (annotate, F, Q, Subquery) — оно переносимо между СУБД и безопаснее.

Как это работает под капотом

atomic на входе открывает транзакцию (или ставит savepoint, если уже внутри другой), а на выходе шлёт COMMIT; если блок завершился исключением — ROLLBACK до начала блока. select_for_update добавляет к SELECT предложение FOR UPDATE, и СУБД держит на строках блокировку до конца транзакции — поэтому метод обязателен внутри atomic. Индекс физически — это B-дерево (для обычных индексов): СУБД хранит отсортированные ключи со ссылками на строки, и планировщик сам решает, использовать индекс или seq scan, исходя из статистики таблицы; увидеть его решение можно через EXPLAIN. raw() и cursor идут в обход построителя запросов прямо к драйверу БД, поэтому теряют переносимость и защиту, которую ORM даёт по умолчанию — экранирование параметров приходится держать в голове самому (через плейсхолдеры).

Частые ошибки

  • Связанные записи без atomic. Если списание и зачисление не в одной транзакции, сбой между ними оставит данные в неконсистентном состоянии.
  • Слать письмо/задачу до коммита. Действие в теле atomic выполнится даже при последующем откате; побочные эффекты вешайте на transaction.on_commit.
  • Использовать select_for_update вне atomic. Django выбросит ошибку — блокировку нечем удерживать без открытой транзакции.
  • Индексировать всё подряд. Лишние индексы замедляют запись и едят место; индексируйте поля реальных WHERE/ORDER BY/JOIN, а не каждое.
  • Вклеивать ввод в SQL строкой. f-строка или конкатенация в raw/cursor — это SQL-инъекция; передавайте значения только параметрами через %s.

Итоги

  • transaction.atomic делает группу операций «всё или ничего»; при ошибке — откат, вложенность даёт savepoint'ы.
  • Побочные эффекты после успеха вешают на transaction.on_commit, чтобы не сработали при откате.
  • select_for_update() блокирует строки внутри atomic и защищает от гонок при конкурентной записи; есть nowait и skip_locked.
  • Индексы (db_index=True, Meta.indexes) ускоряют фильтры/сортировки/JOIN, но замедляют запись — ставьте их по реальным запросам; в составном индексе важен порядок полей.
  • raw() и connection.cursor() — крайняя мера для сырого SQL; параметры всегда через плейсхолдеры, иначе SQL-инъекция.
Проверьте себя
1. Что гарантирует блок with transaction.atomic()?
AЧто запрос выполнится быстрее за счёт кэширования
BЧто все операции внутри применятся к базе целиком, а при возникновении исключения откатятся все вместе (ROLLBACK)
CЧто строки будут заблокированы для других процессов
DЧто Django автоматически создаст индексы для затронутых таблиц
2. Почему select_for_update() нужно вызывать внутри transaction.atomic()?
AЭто просто рекомендация для читаемости кода
BБлокировка строк держится до конца транзакции, поэтому без открытой транзакции удерживать замок нечем — Django выбросит ошибку
CИначе запрос вернёт пустой результат
DПотому что atomic ускоряет блокировку
3. Когда оправдано добавить индекс (db_index или Meta.indexes) на поле?
AНа каждое поле модели — это всегда ускоряет работу
BКогда по полю часто фильтруют (WHERE), сортируют (ORDER BY) или соединяют таблицы; индексы замедляют запись, поэтому добавляют их выборочно
CТолько на поля типа CharField
DНикогда — ORM сам решает, где нужны индексы
4. Как безопасно передать пользовательский ввод в Model.objects.raw()?
AПодставить значение в строку SQL через f-строку
BПередать значение параметром через плейсхолдер %s вторым аргументом, чтобы драйвер сам его экранировал
CКонкатенацией строк через +
DЭто невозможно, raw() не принимает параметры