Multi-DB, read replicas, connection pooling, transactions, isolation levels
На junior-уровне БД — это «место, куда ORM что-то пишет». На middle/senior БД становится узким местом и зоной ответственности: транзакции, блокировки, реплики, пулы соединений, миграции на живой таблице. Эта тема — про то, что отличает «работает на ноутбуке» от «работает под нагрузкой и не теряет деньги».
💡 Правило: база данных — почти всегда самый дефицитный ресурс системы. Прежде чем масштабировать приложение, убедитесь, что вы выжали из БД корректные транзакции, индексы и connection pooling.
Транзакция гарантирует, что группа операций выполнится целиком или никак. Классический пример — перевод денег: списание и зачисление обязаны произойти вместе.
from django.db import transaction
@transaction.atomic
def transfer(from_id, to_id, amount):
# select_for_update блокирует строки до конца транзакции —
# защита от гонки, когда два перевода читают один баланс
from_acc = Account.objects.select_for_update().get(pk=from_id)
to_acc = Account.objects.select_for_update().get(pk=to_id)
if from_acc.balance < amount:
raise ValueError('Недостаточно средств')
from_acc.balance -= amount
to_acc.balance += amount
from_acc.save(update_fields=['balance'])
to_acc.save(update_fields=['balance'])Любое исключение внутри atomic-блока откатывает все изменения. Без транзакции деньги могли бы списаться, а зачисление — упасть.
⚠️
ATOMIC_REQUESTSоборачивает каждый HTTP-запрос в транзакцию. Удобно и безопасно, но долгие запросы держат транзакцию открытой → растут блокировки. Альтернатива — оборачивать только критичные участки явнымatomic.
select_for_update() ставит блокировку на прочитанные строки до конца транзакции. Это превращает «прочитал-посчитал-записал» в безопасную операцию. Обязательно внутри atomic, иначе DRF/Django бросит ошибку.
with transaction.atomic():
item = Inventory.objects.select_for_update().get(pk=item_id)
item.quantity -= 1 # никто другой не изменит между чтением и записью
item.save()Изоляция определяет, какие «чужие» незавершённые изменения видит транзакция. Это компромисс между корректностью и параллелизмом.
| Уровень | Защищает от | Цена |
|---|---|---|
| READ UNCOMMITTED | — | Грязное чтение возможно |
| READ COMMITTED | Грязного чтения | Дефолт PostgreSQL; non-repeatable read возможен |
| REPEATABLE READ | Повторного чтения с разными результатами | Дефолт MySQL; возможны phantom |
| SERIALIZABLE | Всего | Максимальная корректность, больше откатов/конфликтов |
# settings.py — поднять уровень изоляции для PostgreSQL
DATABASES['default']['OPTIONS'] = {
'isolation_level': 1, # READ COMMITTED (psycopg2.extensions)
}💡 Для финансовых операций часто берут
SERIALIZABLE+ ретраи на ошибку сериализации. Для большинства приложенийREAD COMMITTED(дефолт PostgreSQL) — правильный выбор.
Установка нового соединения с PostgreSQL — дорогая операция (TCP + аутентификация + форк процесса на стороне БД). На высокой нагрузке открывать соединение на каждый запрос — расточительно.
# Вариант 1: переиспользование соединений самим Django
DATABASES['default']['CONN_MAX_AGE'] = 600 # держать соединение до 10 минут
DATABASES['default']['CONN_HEALTH_CHECKS'] = True # проверять живость перед использованием⚠️
CONN_MAX_AGEпереиспользует соединение в рамках одного процесса. При большом числе воркеров Gunicorn количество соединений к БД = воркеры ×CONN_MAX_AGE-соединения, и можно упереться вmax_connectionsPostgreSQL. Для серьёзной нагрузки ставят внешний пулер PgBouncer в режиме transaction pooling.
# pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20⚠️ PgBouncer в режиме
transactionнесовместим с server-side cursors и сессионными настройками. Приtransactionpooling выключайтеCONN_MAX_AGE(ставьте 0) и отключайте named cursors.
Django умеет работать с несколькими БД — например, разделить оперативную (OLTP) и аналитическую (OLAP) нагрузку.
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'main_db', 'HOST': 'primary',
},
'replica': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'main_db', 'HOST': 'replica', # реплика только для чтения
},
}
DATABASE_ROUTERS = ['myproject.routers.PrimaryReplicaRouter']# myproject/routers.py
import random
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
return 'replica' # все SELECT — на реплику
def db_for_write(self, model, **hints):
return 'default' # все INSERT/UPDATE/DELETE — в primary
def allow_relation(self, obj1, obj2, **hints):
return True # primary и реплика — одна БД, связи разрешены⚠️ Replication lag: реплика отстаёт от primary на десятки-сотни миллисекунд. Классический баг: создали объект (запись в primary), сразу читаете (с реплики) — и его там ещё нет. Для read-after-write критичных мест читайте из primary явно:
Model.objects.using('default').get(...).
# Явный выбор БД, минуя роутер
report_data = Sale.objects.using('replica').filter(...) # тяжёлая аналитика — на реплику
fresh = Order.objects.using('default').get(pk=order_id) # только что записанное — из primaryНа таблице в десятки миллионов строк наивная миграция может заблокировать таблицу и положить продакшен.
| Опасная операция | Проблема | Безопасный подход |
|---|---|---|
ADD COLUMN ... DEFAULT (старый PG) | Переписывает всю таблицу под блокировкой | PG 11+ делает это мгновенно; иначе — добавить nullable, потом заполнить батчами |
CREATE INDEX | Блокирует запись на время построения | CREATE INDEX CONCURRENTLY (AddIndexConcurrently в Django) |
ALTER COLUMN TYPE | Блокировка + переписывание | Новая колонка + бэкфилл + переключение |
Data migration в одном RunPython | Долгая транзакция, рост блокировок | Батчами по N тысяч строк |
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False # CONCURRENTLY нельзя внутри транзакции
operations = [
AddIndexConcurrently(
'order',
models.Index(fields=['-created_at'], name='order_created_idx'),
),
]💡 Бэкфилл данных делайте отдельной миграцией батчами и идемпотентно, чтобы её можно было перезапустить после сбоя.
# Массовая вставка одним запросом вместо N
Product.objects.bulk_create(products, batch_size=1000)
# Массовое обновление
Product.objects.bulk_update(products, ['price'], batch_size=1000)
# Итерация по огромной выборке без загрузки всего в память
for row in Order.objects.filter(year=2025).iterator(chunk_size=2000):
process(row)
# Атомарное обновление на стороне БД (без гонки read-modify-write)
from django.db.models import F
Post.objects.filter(pk=pk).update(views=F('views') + 1)💡
F()-выражения переносят вычисление в БД:views = F('views') + 1исключает гонку, в отличие отobj.views += 1; obj.save(), где два параллельных запроса затрут друг друга.
transaction.atomic()select_for_update() против гонок read-modify-writeCONN_MAX_AGE или PgBouncer и понимаете лимит max_connectionsCONCURRENTLY, батчи)bulk_create/bulk_update, iterator(), F() под нагрузкойВопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.
Далее: Архитектура: DDD и Clean Architecture