Транзакции, блокировки и индексы
Деньги списались, но заказ не создался — это про транзакции. Два процесса испортили один счётчик — это про блокировки. Запрос тормозит на большой таблице — это про индексы.
Транзакция — группа операций, которые применяются к базе целиком или не применяются вовсе; промежуточного состояния снаружи не видно.
До сих пор мы думали про чтение и его скорость. Теперь — про целостность записи при сбоях и конкуренции, и про то, как сделать тяжёлые запросы быстрыми за счёт индексов. Это та часть 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-инъекция.