Разделение чтения и записи по репликам в Django: разбор инцидента
Приложение на Django начало показывать пользователям устаревшие данные после переключения чтения на реплику PostgreSQL. Разбираем, почему встроенный механизм database routers не спасает от replication lag и что с этим делать.

Что произошло: свежая запись, которую никто не видит
Продакшен-сервис на Django 5.x с PostgreSQL 16. Нагрузка выросла, команда вынесла все SELECT-запросы на streaming-реплику через стандартный database router. Через два дня пришли тикеты: пользователь обновляет профиль, нажимает «Сохранить», видит старые данные. Перезагрузка страницы через пару секунд — всё на месте. Иногда задержка доходила до четырёх-пяти секунд.
Проблема не в баге Django и не в сломанной реплике. Проблема — в replication lag, который неизбежен при асинхронной потоковой репликации, и в наивном роутере, который не учитывает причинную связь между записью и последующим чтением.
Как устроен database router в Django
Django позволяет определить несколько баз данных в DATABASES и направлять запросы через классы-роутеры. Роутер — это объект с методами db_for_read, db_for_write, allow_relation и allow_migrate. ORM вызывает db_for_read при каждом SELECT и db_for_write при INSERT/UPDATE/DELETE.
Типичный «учебный» роутер выглядит так:
# routers.py — Django 5.x
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
return 'replica'
def db_for_write(self, model, **hints):
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, **hints):
return db == 'default'
Всё чтение уходит на replica, вся запись — на default (primary). Просто, понятно и опасно.
Почему это ломается
Streaming replication в PostgreSQL — асинхронная по умолчанию. Primary отправляет WAL-записи, реплика применяет их с задержкой. Обычно lag составляет десятки миллисекунд, но при всплеске записи, тяжёлых запросах на реплике или сетевых задержках он вырастает до секунд. Пользователь выполнил POST-запрос (запись на primary), Django вернул redirect, браузер сделал GET, роутер отправил его на реплику — а реплика ещё не получила коммит.
Replication lag — не аномалия, а нормальное поведение асинхронной репликации. Роутер, который этого не учитывает, создаёт гонку, которую вы проиграете при любой нагрузке.
Три подхода к решению
1. Привязка к primary после записи (session pinning)
Идея: после любой записи все запросы этого пользователя на протяжении N секунд идут на primary. Django не предоставляет этого из коробки, но реализация укладывается в middleware и доработанный роутер.
Middleware при каждом ответе, содержащем запись, ставит в сессию метку времени. Роутер проверяет метку и, если она свежее порога, возвращает 'default' вместо 'replica'.
# middleware.py — Django 5.x
import time
PINNING_SECONDS = 5 # Окно, в течение которого читаем с primary
class PrimaryPinMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Флаг выставляется в роутере при вызове db_for_write
if getattr(request, '_db_write_performed', False):
request.session['_pinned_until'] = time.time() + PINNING_SECONDS
return response
# routers.py — Django 5.x
import time
import threading
_request_local = threading.local()
def get_current_request():
return getattr(_request_local, 'request', None)
def set_current_request(request):
_request_local.request = request
class PinningRouter:
def db_for_read(self, model, **hints):
request = get_current_request()
if request is not None:
pinned_until = request.session.get('_pinned_until', 0)
if time.time() < pinned_until:
return 'default'
return 'replica'
def db_for_write(self, model, **hints):
request = get_current_request()
if request is not None:
request._db_write_performed = True
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, **hints):
return db == 'default'
Middleware, вызывающий set_current_request, нужно поставить перед PrimaryPinMiddleware. Передача request через thread-local — распространённый паттерн, но при использовании ASGI и async views он не работает: потребуется contextvars.ContextVar вместо threading.local.
Цена: при активных записях значительная часть трафика возвращается на primary, снижая эффект от реплики. Порог в 5 секунд — компромисс: уменьшите — вернётся проблема, увеличите — primary опять перегружен.
2. Синхронная репликация
PostgreSQL поддерживает synchronous_commit на уровне кластера или отдельной транзакции. При синхронном коммите primary ждёт подтверждения от реплики перед тем, как вернуть ОК клиенту. Lag для подтверждённых транзакций — ноль.
Цена: каждая запись становится медленнее на время сетевого round-trip до реплики. При потере реплики primary может зависнуть в ожидании подтверждения, если не настроен таймаут. Это решение для инфраструктуры, а не для Django-кода.
3. Явное указание базы в критичных местах
Django позволяет форсировать базу на уровне queryset через .using('default'). Вместо глобального роутера команда помечает конкретные view, где «read-your-own-writes» критичен.
# views.py — фрагмент
def profile_update(request):
# ... сохранение ...
# Читаем с primary, потому что только что записали
profile = Profile.objects.using('default').get(pk=request.user.pk)
return render(request, 'profile.html', {'profile': profile})
Цена: ручная работа, легко забыть. Подходит для нескольких точечных мест, не для десятков view.
Сравнение подходов
| Критерий | Session pinning | Синхронная репликация | Явный .using() |
|---|---|---|---|
| Где меняется код | Middleware + router | Конфиг PostgreSQL | Отдельные view/queryset |
| Снижение нагрузки на primary | Частичное: пиннинг возвращает часть чтения | Полное: чтение всегда с реплики | Полное, кроме помеченных мест |
| Риск при сбое реплики | Нулевой: всё идёт на primary | Высокий: primary может зависнуть | Нулевой |
| Гарантия «read-your-writes» | Вероятностная (зависит от порога) | Полная | Полная в помеченных местах |
| Сложность поддержки | Средняя | Низкая (инфра) | Растёт с количеством view |
Граничные случаи и где подход ломается
- Celery и фоновые задачи. Worker не имеет HTTP-сессии и не знает про пиннинг. Если задача стартует сразу после записи и читает с реплики — тот же баг. Решение: передавать в задачу флаг, заставляющий использовать
'default', или добавлять задержку. - Несколько реплик. Если реплик больше одной, их lag различается. Роутер, выбирающий реплику случайно, может отправить запрос на самую отстающую. Мониторинг
pg_stat_replicationи исключение реплик с большим lag — задача уровня PgBouncer или HAProxy, а не Django. - Миграции и DDL.
allow_migrateдолжен возвращатьTrueтолько для primary. Если по ошибке миграция уйдёт на реплику — реплика сломается. - Транзакции, охватывающие чтение и запись.
transaction.atomic()без аргументаusingиспользует'default'. Если внутри транзакции ORM выполняет SELECT, роутер может отправить его на реплику, и вы получите данные вне транзакции. В блокахatomic()чтение тоже должно идти с primary.
Как измерить replication lag
Прежде чем выбирать подход, нужно знать фактический lag. На primary выполните:
-- PostgreSQL 16
SELECT
client_addr,
state,
sent_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
replay_lag
FROM pg_stat_replication;
Столбец replay_lag показывает задержку как interval. Если он стабильно ниже 10 мс — проблема, вероятно, не в lag, а в логике приложения. Если прыгает до секунд — session pinning или синхронный коммит оправданы.
Настройте мониторинг этой метрики в Prometheus, Zabbix или другой системе. Без графика lag за неделю вы принимаете архитектурное решение вслепую.
Streaming replication — механизм PostgreSQL, при котором primary непрерывно передаёт журнал упреждающей записи (WAL) на реплику. Реплика воспроизводит WAL и становится почти актуальной копией, но «почти» — ключевое слово.
Что делать дальше
- Снимите график
replay_lagза неделю — это покажет, в каком диапазоне живёт ваш lag. - Если lag стабильно ниже 50 мс, начните с явного
.using('default')в двух-трёх критичных view. Это дешевле всего. - Если lag непредсказуем или view много — внедряйте session pinning с порогом, равным 95-му перцентилю вашего lag плюс запас.
- Проверьте фоновые задачи (Celery, cron-скрипты): они часто читают сразу после записи и не покрываются middleware.
- Документация Django по multiple databases описывает контракт роутера: docs.djangoproject.com/en/5.1/topics/db/multi-db. Перечитайте раздел «Automatic database routing» — там есть нюансы с
hints, которые помогают роутеру принимать более точные решения.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Django documentation — Multiple databases, PostgreSQL documentation — Streaming Replication
Частые вопросы
Как понять, что replication lag — причина бага, а не кеширование?
Можно ли использовать session pinning с ASGI и async views?
Что будет, если реплика упадёт, а роутер продолжит отправлять туда запросы?
Влияет ли django-debug-toolbar на работу с репликами?
Сколько секунд ставить в качестве порога для session pinning?
А если у меня другая версия Django или PostgreSQL?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.