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

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

@potapov_me

Платформа

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

Контент

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

Компания

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

Аккаунт

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

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

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

Базы Данных: Продвинутые темы

Транзакции, блокировки, isolation, replicas и connection pooling

База данных: транзакции, pool, реплики и live migrations

Статус: примеры ориентированы на PostgreSQL и Django 5.2/6.0. Поддержка блокировок, isolation и online DDL различается у PostgreSQL, MySQL/MariaDB, Oracle и SQLite — код проверяют на production backend.

Производительность базы важна, но первая задача — корректность. Быстрый перевод с потерянными деньгами или read replica, которая временно «удалила» заказ из интерфейса, хуже медленного запроса.

Полезный порядок работы:

  1. закрепить инварианты constraints базы;
  2. определить транзакционную границу и модель конкуренции;
  3. измерить SQL, locks и connection saturation;
  4. добавить подходящие indexes/query changes;
  5. только затем вводить pool, replicas или sharding.

#Транзакция и инварианты

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.

#Условный UPDATE вместо read-modify-write

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

#Ошибки внутри atomic

Не скрывайте 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.

#Isolation level — не переключатель «сделать правильно»

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-выводы без проверки.

#Connection lifetime и настоящий pool

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.

#Встроенный PostgreSQL pool

С 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

Внешний 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.

#Timeouts и least privilege

Без пределов один ошибочный 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.

#Read replicas и read-after-write

Реплика снимает часть 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 остаются:

  • read-after-write redirect/confirmation;
  • permission и quota decisions;
  • checkout/inventory state;
  • чтение внутри transaction, связанной с записью.

Глобальный router «все reads случайно на replica» слишком груб: он не знает, что конкретная операция требует свежести, и не решает lag. Возможны session stickiness к primary после write, lag-aware routing или разделение read models, но каждое решение нужно тестировать.

Replica не является backup: логическое удаление и corruption реплицируются. Нужны snapshots/PITR и регулярный restore drill.

#Несколько баз и database routers

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 None

None означает «решения нет, спросить следующий 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.

#Live migrations больших таблиц

Безопасность 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:

  • маленькие batches с стабильным keyset;
  • checkpoint/progress и идемпотентный повтор;
  • ограничение нагрузки и пауза при replica lag;
  • метрика оставшихся rows;
  • код временно умеет читать старое и новое представление.

Гигантский RunPython внутри одной transaction увеличивает locks, WAL и время rollback. atomic=False само по себе не делает цикл безопасным.

Перед release смотрите sqlmigrate, lock plan и reversibility. Удаление column/index выполняют после того, как ни одна старая replica/worker его не использует.

#Sharding — последний, а не первый шаг

Shard key определяет, где живут данные; его смена и cross-shard query сложны. Database routers могут выбрать alias, но не решат автоматически:

  • глобальные unique constraints;
  • cross-shard ForeignKey/transaction;
  • rebalance горячего tenant;
  • scatter-gather pagination/aggregation;
  • миграцию всех shards и восстановление.

До sharding часто выгоднее оптимизировать SQL/indexes, архивировать данные, использовать partitioning, read replicas или вертикально масштабировать primary. Если sharding нужен, проектируйте его как архитектуру данных с directory/placement, а не подключение случайного пакета.

#Наблюдаемость базы

Следите не только за CPU:

  • p95/p99 query duration и slow query samples;
  • active/idle-in-transaction connections;
  • pool wait/timeout и connection count по service;
  • blocked queries, lock wait и deadlocks;
  • replica lag и WAL/disk growth;
  • cache hit, vacuum/autovacuum и stale statistics;
  • backup age и успешность restore drill.

EXPLAIN (ANALYZE, BUFFERS) реально выполняет query; на write или тяжёлой выборке используйте его осторожно и сначала в production-like environment.

#Legacy и исправления

  • Числовой isolation_level=1 из psycopg2-примеров заменён на IsolationLevel актуального PostgreSQL backend.
  • CONN_MAX_AGE больше не называется connection pool; показан встроенный psycopg pool Django.
  • PgBouncer transaction pooling не требует автоматически «выключить всё», но требует учёта session state и DISABLE_SERVER_SIDE_CURSORS.
  • Router со случайной read replica признан неполным из-за replication lag.
  • Sharding не сводится к user_id % N и стороннему decorator.

#Официальная документация

  • Django: transactions
  • Django: database notes and pooling
  • Django: multiple databases
  • Django: PostgreSQL concurrent indexes
  • PostgreSQL: transaction isolation

Далее: Архитектура: DDD и Clean Architecture