Транзакции, блокировки, isolation, replicas и connection pooling
Статус: примеры ориентированы на PostgreSQL и Django 5.2/6.0. Поддержка блокировок, isolation и online DDL различается у PostgreSQL, MySQL/MariaDB, Oracle и SQLite — код проверяют на production backend.
Производительность базы важна, но первая задача — корректность. Быстрый перевод с потерянными деньгами или read replica, которая временно «удалила» заказ из интерфейса, хуже медленного запроса.
Полезный порядок работы:
transaction.atomic() объединяет DB-операции: внешний блок фиксируется целиком или откатывается, вложенные блоки обычно создают savepoints.
from decimal import Decimal
from django.db import transaction
@transaction.atomic
def transfer(*, source_id, target_id, amount):
if source_id == target_id:
raise ValueError("Счета должны различаться.")
if amount <= Decimal("0"):
raise ValueError("Сумма должна быть положительной.")
account_ids = sorted([source_id, target_id])
accounts = {
account.pk: account
for account in (
Account.objects.select_for_update()
.filter(pk__in=account_ids)
.order_by("pk")
)
}
if len(accounts) != 2:
raise Account.DoesNotExist
source = accounts[source_id]
target = accounts[target_id]
if source.balance < amount:
raise InsufficientFunds
source.balance -= amount
target.balance += amount
source.save(update_fields=["balance"])
target.save(update_fields=["balance"])Строки блокируются в одинаковом порядке pk, чтобы два встречных перевода реже образовывали deadlock. Полностью исключить deadlock в большой системе трудно; приложение должно распознавать transient database error и повторять всю транзакцию с ограничением/backoff, если операция безопасно повторяема.
select_for_update() работает при вычислении QuerySet и удерживает lock до конца транзакции. Не вызывайте внешний API или медленную отправку email, пока lock удерживается. Подготовьте вход до atomic(), а побочный эффект планируйте через transaction.on_commit().
Constraints остаются последней линией защиты: CheckConstraint(balance__gte=0) или ledger-модель могут быть надёжнее одной проверки Python. Выбор зависит от предметной модели — например, банковский ledger не сводится к двум mutable balances.
Для простого остатка блокировка и загрузка instance не обязательны:
from django.db.models import F
def reserve(*, product_id, quantity):
if quantity <= 0:
raise ValueError("Количество должно быть положительным.")
updated = Product.objects.filter(
pk=product_id,
stock__gte=quantity,
).update(stock=F("stock") - quantity)
if updated != 1:
raise InsufficientStockУсловие и изменение выполняются одним SQL statement, поэтому два запроса не прочитают один старый остаток. Если операция затрагивает несколько строк/таблиц, возвращаемся к транзакции и locks/constraints.
После сохранения F() на instance его Python-значение может оставаться expression; используйте refresh_from_db() перед возвратом клиенту, если нужен новый результат.
Не скрывайте DatabaseError внутри того же atomic() и не продолжайте запросы: transaction может быть помечена broken. Ловите исключение вокруг блока:
from django.db import IntegrityError, transaction
try:
with transaction.atomic():
create_order_rows(command)
except IntegrityError as exc:
raise OrderConflict from excПосле выхода Django выполнит rollback/savepoint rollback, и код снаружи работает в понятном состоянии.
ATOMIC_REQUESTS=True оборачивает выполнение view в transaction, но middleware и template rendering находятся вне неё; генерация streaming response тоже выполняется после выхода. Кроме того, долгий request удерживает transaction. В нагруженном приложении явные короткие atomic() обычно прозрачнее.
select_for_update() — варианты и тестыНа поддерживаемых backends:
nowait=True сразу поднимает ошибку при конфликте;skip_locked=True пропускает занятые строки и полезен для конкурирующих consumers;of=("self",) сужает PostgreSQL lock при joins;no_key=True даёт более слабый PostgreSQL lock.Возможности backend различаются; неподдерживаемый флаг вызывает NotSupportedError. SQLite не добавляет FOR UPDATE вовсе.
На настоящем backend вызов в autocommit обычно вызывает TransactionManagementError. Но django.test.TestCase сам оборачивает тест и может скрыть отсутствие atomic(). Конкурентное поведение и locks тестируют через TransactionTestCase и отдельные database connections.
PostgreSQL и Django по умолчанию используют READ COMMITTED: каждый statement видит зафиксированный snapshot на начало statement. Два чтения в одной transaction могут увидеть разные commits.
REPEATABLE READ удерживает snapshot транзакции, а SERIALIZABLE обнаруживает исполнения, неэквивалентные некоторому последовательному порядку, и отменяет одну transaction. Приложение обязано повторить её. Более высокий уровень не заменяет constraints, lock order и idempotency.
Глобальная PostgreSQL-настройка Django 5.2/6.0:
from django.db.backends.postgresql.psycopg_any import IsolationLevel
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# NAME/USER/HOST задаются окружением.
"OPTIONS": {
"isolation_level": IsolationLevel.SERIALIZABLE,
},
},
}Это advanced setting на соединение. Не делайте весь сайт serializable ради одной операции без нагрузочных тестов: вырастут serialization failures и retries. Для конкретного workflow нередко достаточно READ COMMITTED плюс правильный SELECT FOR UPDATE или условный UPDATE.
У MySQL Django тоже настраивает isolation через OPTIONS, но поведение и дефолты server отличаются. Не переносите PostgreSQL-выводы без проверки.
CONN_MAX_AGE переиспользует connection в рамках thread/process lifecycle, но не является общим ограниченным pool:
DATABASES["default"]["CONN_MAX_AGE"] = 60
DATABASES["default"]["CONN_HEALTH_CHECKS"] = TrueКоличество connections оценивают по web processes × threads, Celery concurrency, management jobs и replicas. CONN_HEALTH_CHECKS проверяет переиспользуемое соединение один раз за request при первом обращении, но не создаёт pool.
При ASGI Django рекомендует CONN_MAX_AGE=0 и pool database backend.
С psycopg 3 и psycopg[pool]/psycopg-pool:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# ...
"CONN_MAX_AGE": 0,
"OPTIONS": {
"pool": {
"min_size": 1,
"max_size": 8,
"timeout": 5,
},
},
},
}"pool": True выбирает defaults. Настройка игнорируется с psycopg2. Pool существует в каждом application process, поэтому общий максимум — не просто 8: умножьте его на число web/worker processes и оставьте connections для migrations, admin и monitoring.
Внешний PgBouncer объединяет client connections разных processes. Transaction pooling выдаёт server connection только на transaction, поэтому session state и server-side cursors могут ломаться. Django требует DISABLE_SERVER_SIDE_CURSORS=True для соединения через transaction pool либо отдельный session-pooled/direct alias для iterator().
Не складывайте встроенный большой pool поверх большого PgBouncer client pool без расчёта: получится много ожидающих connections и скрытая очередь. Метрики pool wait/timeout важнее одной цифры max_connections.
Без пределов один ошибочный query способен удерживать connection и locks минуты. Настройте PostgreSQL statement_timeout, lock_timeout и idle transaction policy на уровне role/database или проверенным connection option. Значения для web request, worker и migration могут отличаться.
Application role не должна создавать extension/drop table. Migration job использует отдельную роль с более широкими DDL permissions; runtime получает только нужные таблицы/операции. Backup role и monitoring role тоже разделяются.
Timeout создаёт ошибку, а не успешный fallback. Код должен закрыть transaction, вернуть безопасный ответ и оставить observable signal.
Реплика снимает часть stale-tolerant чтений с primary, но приносит lag. Записали заказ в primary и сразу прочитали из replica — заказ может временно отсутствовать.
from django.db.models import Sum
analytics = (
Sale.objects.using("replica")
.filter(created_at__gte=period_start)
.values("category_id")
.annotate(total=Sum("amount"))
)
fresh_order = Order.objects.using("default").get(pk=order_id)Явный alias делает требование свежести видимым. На primary остаются:
Глобальный router «все reads случайно на replica» слишком груб: он не знает, что конкретная операция требует свежести, и не решает lag. Возможны session stickiness к primary после write, lag-aware routing или разделение read models, но каждое решение нужно тестировать.
Replica не является backup: логическое удаление и corruption реплицируются. Нужны snapshots/PITR и регулярный restore drill.
class AnalyticsRouter:
route_app_labels = {"analytics"}
def db_for_read(self, model, **hints):
if model._meta.app_label in self.route_app_labels:
return "analytics"
return None
def db_for_write(self, model, **hints):
if model._meta.app_label in self.route_app_labels:
return "analytics"
return None
def allow_migrate(self, db, app_label, **hints):
if app_label in self.route_app_labels:
return db == "analytics"
return NoneNone означает «решения нет, спросить следующий router/default policy». allow_migrate() критичен: иначе таблица может появиться не там.
Django не поддерживает ForeignKey/ManyToMany между разными databases с database-enforced integrity. transaction.atomic(using="default") не делает атомарной запись ещё и в analytics. Для межсистемной согласованности нужны outbox/events/reconciliation, а не надежда на общий decorator.
Admin, auth, content types и migrations при multi-DB требуют отдельной политики и тестов для каждого alias.
Безопасность DDL зависит от версии СУБД, размера/формы данных и текущей нагрузки. Проверяйте с production-like статистикой и lock timeout.
Для PostgreSQL concurrent index:
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [("orders", "0041_previous")]
operations = [
AddIndexConcurrently(
model_name="order",
index=models.Index(
fields=["tenant", "-created_at"],
name="order_tenant_created_idx",
),
),
]CREATE INDEX CONCURRENTLY не блокирует обычные writes как простой CREATE INDEX, но дольше строится, потребляет I/O, ждёт старые transactions и при сбое может оставить invalid index. Мониторьте и имейте runbook очистки.
Большой data backfill лучше вынести из schema migration в resumable deployment job:
Гигантский RunPython внутри одной transaction увеличивает locks, WAL и время rollback. atomic=False само по себе не делает цикл безопасным.
Перед release смотрите sqlmigrate, lock plan и reversibility. Удаление column/index выполняют после того, как ни одна старая replica/worker его не использует.
Shard key определяет, где живут данные; его смена и cross-shard query сложны. Database routers могут выбрать alias, но не решат автоматически:
До sharding часто выгоднее оптимизировать SQL/indexes, архивировать данные, использовать partitioning, read replicas или вертикально масштабировать primary. Если sharding нужен, проектируйте его как архитектуру данных с directory/placement, а не подключение случайного пакета.
Следите не только за CPU:
EXPLAIN (ANALYZE, BUFFERS) реально выполняет query; на write или тяжёлой выборке используйте его осторожно и сначала в production-like environment.
isolation_level=1 из psycopg2-примеров заменён на IsolationLevel актуального PostgreSQL backend.CONN_MAX_AGE больше не называется connection pool; показан встроенный psycopg pool Django.DISABLE_SERVER_SIDE_CURSORS.user_id % N и стороннему decorator.Далее: Архитектура: DDD и Clean Architecture