Репликация чтения в Django: подводные камни
Подключить реплику для чтения в Django — работа на пару часов. А потом начинается: ORM отправляет запрос на реплику, которая ещё не получила свежую запись, и пользователь видит пустую страницу вместо только что созданного заказа. Разбираем механику отставания реплик, поведение роутеров и конкретные ловушки, в которые попадают даже опытные команды.

Зачем вообще читать с реплики
Типичная схема: одна primary-база принимает все записи, одна или несколько реплик обслуживают SELECT-запросы. В Django это реализуется через настройку DATABASES с несколькими алиасами и кастомный database router. На бумаге всё просто — документация укладывает это в один экран. На практике подводные камни начинаются, когда трафик вырастает, а отставание реплики перестаёт быть «незаметным».
Как Django выбирает базу: роутер изнутри
ORM вызывает четыре метода роутера: db_for_read, db_for_write, allow_relation и allow_migrate. Первые два определяют, на какой алиас отправить запрос. Django перебирает роутеры из списка DATABASE_ROUTERS по порядку и берёт первый непустой ответ. Если все вернули None, используется алиас default.
Минимальный роутер, который отправляет чтение на реплику:
# routers.py — Django 5.x, PostgreSQL 16
class PrimaryReplicaRouter:
"""Читаем с реплики, пишем в primary."""
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, model_name=None, **hints):
return db == 'default'
Выглядит безобидно, но именно эта конфигурация порождает большинство проблем, описанных ниже.
Подводный камень 1: replication lag и «исчезающие» записи
Самая болезненная ситуация — read-after-write inconsistency. Пользователь создаёт объект (INSERT идёт в primary), а на следующей странице Django делает SELECT через реплику, которая ещё не получила эту строку. Результат: «Ваш заказ не найден». Отставание реплики в PostgreSQL при стриминговой репликации обычно составляет единицы миллисекунд, но под нагрузкой на запись или при сетевых задержках может вырастать до секунд.
Что с этим делать
- Sticky-read на уровне сессии. После любого POST-запроса выставляйте в сессии метку с временем записи. Роутер проверяет метку и, если с момента записи прошло меньше порогового окна (например, 5 секунд), возвращает
'default'вместо'replica'. Это решает 90% случаев за счёт минимальной дополнительной нагрузки на primary. - Хинт
instance. Django передаёт вhintsключinstance, когда запрос связан с конкретным объектом. Если объект только что создан (instance._state.adding), роутер может отправить чтение на primary. - Явный
.using('default'). Грубый, но надёжный способ для критических мест:Order.objects.using('default').get(pk=order_id). Минус — бизнес-логика начинает знать про инфраструктуру.
Подводный камень 2: транзакции и роутер
Django оборачивает view в транзакцию, если включён ATOMIC_REQUESTS. Но транзакция открывается на конкретном алиасе. Если внутри одного запроса вы пишете в default, а читаете из replica, чтение не видит незакоммиченные изменения — оно идёт в другую физическую базу. Это не баг Django, это свойство архитектуры, но забывают о нём регулярно.
Частный случай: select_for_update(). Этот вызов всегда требует запись, и Django поднимет ошибку, если запрос уйдёт на реплику. Роутер должен возвращать 'default', когда в хинтах есть select_for_update=True — но не все кастомные роутеры это проверяют.
Подводный камень 3: миграции и DDL
Метод allow_migrate определяет, на каком алиасе выполняются миграции. Если вы вернёте True для реплики, Django попытается выполнить DDL-запрос на read-only-соединение — и получит ошибку вида cannot execute CREATE TABLE in a read-only transaction. Исправить просто, но диагностика занимает время, потому что ошибка всплывает только при migrate, а не при повседневной работе.
Подводный камень 4: агрегаты и отчёты на устаревших данных
Для аналитических запросов реплика — идеальное место: тяжёлый GROUP BY не нагружает primary. Но если отчёт формируется в момент, когда реплика отстала на несколько секунд, цифры будут некорректными. Для внутренних дашбордов это терпимо, для финансовых отчётов — нет.
Отставание реплики — не константа. Оно растёт под нагрузкой и после долгих транзакций на primary. Прежде чем отправлять критический отчёт на реплику, проверьте текущий lag программно — например, через pg_stat_replication на стороне PostgreSQL.
Подводный камень 5: сторонние библиотеки игнорируют роутер
Не весь код в проекте проходит через ORM. Библиотеки, которые используют raw SQL или django.db.connection напрямую, обращаются к default-алиасу, если автор не предусмотрел поддержку роутеров. Celery-таски, кеш-бекенды на базе БД, Django-сессии в базе — все они могут молча читать из primary, обесценивая вашу схему, или, наоборот, читать из реплики, когда не должны.
Сравнение стратегий борьбы с replication lag
| Стратегия | Сложность внедрения | Доп. нагрузка на primary | Гарантия консистентности |
|---|---|---|---|
| Sticky-read по сессии | Низкая | Умеренная (на время окна) | Вероятностная — зависит от размера окна |
Явный .using('default') | Низкая, но засоряет код | Зависит от количества точек | Полная — для конкретного запроса |
| Проверка lag перед запросом | Средняя (нужен доступ к pg_stat_replication) | Минимальная | Зависит от порога |
| Синхронная репликация | Конфигурация PostgreSQL | Высокая — primary ждёт подтверждения | Полная |
Синхронная репликация убирает lag, но убивает главное преимущество схемы — разгрузку primary. Её стоит рассматривать только для небольших кластеров с жёсткими требованиями к консистентности.
Как мониторить отставание
Без мониторинга вы не узнаете о проблеме, пока пользователь не пожалуется. Минимальный набор:
- Снимайте
replay_lagиз представленияpg_stat_replicationна primary. Это время, на которое реплика отстаёт от текущей позиции WAL. - Настройте алерт на превышение порога — для большинства OLTP-приложений разумный предел составляет 1–2 секунды.
- Логируйте на стороне Django, когда роутер переключает чтение на primary из-за sticky-read. Если таких переключений больше 30–40% от общего трафика на чтение, схема работает не так, как задумано.
Где подход ломается окончательно
Реплика для чтения плохо работает в сценариях, где write и read идут в рамках одной бизнес-транзакции: оплата, бронирование, любой двухфазный процесс. Если приложение после записи немедленно читает результат и принимает на его основе следующее решение, любая задержка на реплике — это не UI-неудобство, а логическая ошибка. Для таких путей единственный надёжный вариант — читать из primary, а реплику использовать для всего остального: каталоги, поиск, отчёты, фиды.
Отдельная тема — шардирование и мульти-мастер. Здесь описана только классическая single-primary-репликация, которую поддерживает стандартный роутер Django.
Что делать дальше
Первый шаг после настройки реплики — не оптимизация, а инструментирование. Подключите мониторинг replay_lag, добавьте в роутер логирование переключений и прогоните критические пользовательские сценарии (создание заказа, регистрация, оплата) под нагрузкой с искусственной задержкой реплики. Это покажет, какие пути требуют sticky-read или явного .using(), до того как это покажет прод.
Сноска: replication lag (отставание реплики) — время между записью данных в primary-базу и моментом, когда те же данные становятся доступны на реплике. Определяется скоростью передачи и воспроизведения WAL-записей.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: Django Documentation — Multiple databases, PostgreSQL Documentation — Monitoring Replication
Частые вопросы
Может ли реплика отставать настолько, что пользователь увидит данные часовой давности?
Нужно ли дублировать настройки DATABASES для каждой реплики?
А если у меня другая версия Django — роутеры работают так же?
Что будет, если реплика упадёт — Django начнёт возвращать 500?
Стоит ли использовать синхронную репликацию, чтобы не думать о lag?
Работает ли django-cachalot или другой кеш-инвалидатор с репликами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.