Читаем с реплики и видим вчерашние данные: где это выстрелит
Реплика для чтения — это почти всегда правильное архитектурное решение: снимает нагрузку с мастера, даёт запас на рост. Но у неё есть побочный эффект, который редко обсуждают до того, как он ударит по продакшену: реплика показывает прошлое, а не настоящее. Разберём конкретные сценарии, где эта разница между «сейчас» и «секунду назад» превращается в реальную проблему для пользователя или для бизнеса — и что с этим делать на практике, а не в теории.
Содержание
Почему вообще читают с реплики
Классическая схема нагруженного приложения: один сервер принимает записи (мастер), одна или несколько реплик обслуживают чтения. Мотивация понятная — чтения обычно составляют 80-95% всей нагрузки на базу, и размазать их по нескольким серверам куда дешевле, чем вертикально растить один мастер. Плюс реплика — естественный источник для аналитики и тяжёлых отчётов, которые иначе положили бы прод своими JOIN на миллионы строк.
Проблема в том, что реплика не является копией мастера в реальном времени. Между тем, как транзакция зафиксировалась на мастере, и тем, как она применилась на реплике, всегда проходит время — механику этого отставания и то, откуда оно берётся физически, мы разбирали в статье про репликацию и отставание реплики. Здесь нас интересует не механика, а последствия: что видит пользователь в тот момент, когда лаг не ноль.
В спокойное время отставание может быть несколько миллисекунд — незаметно для человека. Но лаг растёт скачками: длинная транзакция на мастере, всплеск записи при распродаже, тяжёлый запрос на самой реплике, сетевая задержка между дата-центрами, VACUUM в неудачный момент. И вот уже не миллисекунды, а секунды или десятки секунд — и ровно в этот момент приложение показывает пользователю не то, что произошло на самом деле.
Сценарий первый: «спасибо за заказ» смотрит в прошлое
Классическая цепочка в интернет-магазине: пользователь нажимает «Оплатить», запрос идёт на бэкенд, бэкенд пишет заказ и статус оплаты в базу (на мастер — иначе никак, запись всегда идёт на мастер), возвращает пользователю редирект на страницу /order/12345/success. Эта страница обращается к базе за данными заказа — и вот тут решение «читать с реплики, чтобы не грузить мастер» вылезает боком.
Если запрос на чтение страницы успеха попадает на реплику раньше, чем на неё докатилась только что записанная транзакция, пользователь видит одно из:
- пустую страницу или «заказ не найден» — если заказ создавался в этом же запросе;
- заказ есть, но статус ещё «ожидает оплаты», хотя оплата только что прошла;
- сумма или состав заказа не совпадают с тем, что человек только что видел в корзине.
Это не гипотетический край. Такая последовательность — запись, затем немедленное чтение того же самого объекта — происходит в вебе постоянно: сохранил профиль и тут же открыл его же, отправил комментарий и обновил ленту, оплатил — и увидел подтверждение. В индустрии это называют read-your-writes consistency: минимальное ожидание пользователя — «я только что это сделал, покажи мне то, что я сделал», а не «покажи мне то, что было секунду назад».
Хуже всего то, что баг воспроизводится не всегда. При лаге в 5 мс он проскакивает почти всегда; при лаге в 300 мс — уже заметная доля запросов; при просадке репликации до нескольких секунд (например, во время нагрузочного пика — того же чёрной пятницы) — становится массовым именно тогда, когда цена ошибки для бизнеса максимальна: саппорт заваливают тикетами «деньги списали, заказа нет».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверСценарий второй: остаток на складе, которого уже нет
Второй классический удар — витрина или карточка товара с остатком «в наличии: 3 шт.», которая на самом деле читает устаревшие данные с реплики. Механика проблемы:
- Покупатель A оформляет последнюю единицу товара — на мастере остаток становится 0, транзакция коммитится.
- В этот момент реплика ещё не применила это изменение (лаг — сотни миллисекунд, а под нагрузкой и больше).
- Покупатель B в этот же промежуток открывает карточку товара, чтение идёт с реплики — видит «в наличии», добавляет в корзину, оплачивает.
- Система приняла деньги за товар, которого физически уже нет.
Дальше — либо отмена заказа и возврат денег (репутационные издержки, недовольный клиент), либо попытка докупить у поставщика в спешке, либо обычный оверселлинг с ручным разгребанием. При высоком трафике на популярный товар (распродажа, ограниченная партия) окно гонки в сотни миллисекунд легко ловится десятками одновременных покупателей — не единичный инцидент, а систематическая утечка.
Та же проблема существует не только с остатком склада. Она бьёт по любому счётчику или лимиту, где важна свежесть: доступные места на рейс или в отеле, число оставшихся промокодов, лимит бесплатных попыток в тарифе, баланс бонусных баллов перед списанием. Везде, где «прочитать → проверить условие → записать» разнесено во времени, а чтение идёт со отстающего источника, — открыта дверь для гонки состояний.
Другие места, где та же грабля срабатывает тише
Первые два сценария — самые заметные, потому что бьют по деньгам напрямую. Но отставание реплики протекает и в менее очевидные места:
- Права доступа и авторизация. Администратор только что забрал у сотрудника доступ к разделу — а бэкенд ещё несколько секунд проверяет права по реплике, которая этого изменения не видела. Формально — дыра в безопасности с окном в размере лага репликации.
- Своя же лента действий. Пользователь оставил комментарий или лайк — обновил страницу — комментария нет. Не критично для бизнеса, но чувствуется как баг интерфейса и снижает доверие к продукту.
- Идемпотентные повторные запросы. Клиент не получил ответ на «Оплатить» (таймаут сети), повторяет запрос. Бэкенд идёт проверять на реплике, был ли уже такой заказ, — не находит (реплика ещё не в курсе) — создаёт дубликат заказа или повторное списание.
- Каскад из нескольких сервисов. Микросервис A записал сущность и синхронно дёрнул микросервис B, который читает эту сущность из своей реплики базы A (через API или CDC) — и не находит. Проблема та же самая, просто размазана по границе сервисов, и искать её сложнее: выглядит как «гонка между сервисами», а на деле — банальный лаг репликации.
- Панель администратора сразу после массового изменения. Импортировали прайс, обновили тысячи товаров — админ тут же открывает список проверить результат, видит частично старые данные, решает, что импорт «не сработал», и повторяет операцию.
Общий паттерн для всех этих случаев один: разрыв во времени между записью и последующим чтением той же (или логически связанной) сущности тем же пользователем или тем же процессом.
Практика: где читать нужно строго с мастера
Хорошая новость — решать проблему массовым отказом от реплик не нужно. Достаточно точечно направить на мастер именно те чтения, где свежесть данных критична, оставив остальное на реплике. Ориентир простой: если следующий шаг сценария — это чтение того, что только что записал этот же пользователь или этот же процесс, читайте с мастера.
Практические паттерны, от простого к более гибкому:
1. Читать с мастера сразу после записи в рамках того же запроса. Самый частый случай: создали заказ → тут же собрали ответ клиенту (номер заказа, сумма, статус) — просто не уходите за этими данными на реплику, возьмите их из той же транзакции или явно смените соединение на мастер для этого конкретного запроса.
2. «Липкая» маршрутизация на мастер на короткое окно после записи. Для случаев, где чтение происходит не в том же HTTP-запросе, а на следующей странице (редирект после оплаты) — после успешной записи ставите пользователю метку (в сессии, в cookie, во временном ключе Redis с TTL 3-5 секунд) «читать с мастера следующие N секунд». Все чтения этого пользователя в это окно идут на мастер, дальше — снова на реплику. Стоимость невысокая: мастер получает чуть больше чтений точечно от активных писателей, а не от всего трафика.
3. Версия/LSN-барьер вместо тайминга. Более строгий вариант второго пункта: после записи фиксируете позицию в журнале (LSN в PostgreSQL, GTID в MySQL), и последующее чтение с реплики ждёт, пока реплика догонит эту позицию, либо падает обратно на мастер, если не догнала за разумный таймаут. В PostgreSQL это можно организовать через pg_wal_lsn_diff и сверку pg_last_wal_replay_lsn() на реплике с сохранённым LSN записи:
-- на мастере после commit: сохранить текущую позицию
SELECT pg_current_wal_lsn();
-- на реплике перед чтением: сравнить с сохранённой позицией
SELECT pg_last_wal_replay_lsn() >= 'сохранённый_LSN'::pg_lsn;
Это точнее тайминга, но добавляет сложности — обычно оправдано только для по-настоящему критичных путей (финансовые операции, юридически значимые действия), а не повсеместно.
4. Явный роутинг по типу операции на уровне приложения. Большинство фреймворков умеют разделять чтение и запись на уровне конфигурации подключения. Пример для Laravel (config/database.php):
'mysql' => [
'read' => [
'host' => ['10.0.0.11', '10.0.0.12'], // реплики
],
'write' => [
'host' => ['10.0.0.10'], // мастер
],
'sticky' => true, // после записи следующее чтение в этом же запросе — с мастера
'driver' => 'mysql',
// ...
],
Опция sticky в Laravel закрывает как раз первый паттерн — чтение в рамках того же запроса после записи. Django решается через DATABASE_ROUTERS с explicit .using('default') для критичных чтений после .save(). В Rails — ActiveRecord::Base.connected_to(role: :writing) вокруг блока, где нужна гарантированная свежесть.
5. Для склада и лимитов — читать остаток прямо из транзакции записи. Проверку «есть ли ещё товар» и списание остатка стоит делать одной атомарной операцией на мастере (UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0, с проверкой числа затронутых строк), а не отдельным чтением с реплики перед записью на мастер. Это не только устраняет лаг репликации как источник проблемы, но и закрывает более широкий класс гонок — параллельные покупатели на самом мастере тоже могут столкнуться без такой атомарности.
Что оставить на реплике — и не бояться устаревания
Обратная сторона медали: не всё нужно тащить на мастер. Разгон каждого чтения на основной сервер убивает саму идею масштабирования чтением, ради которой реплика заводилась. Реплика — правильный источник, когда устаревание на доли секунды или единицы секунд не меняет решение пользователя или бизнес-логику:
- Каталог товаров, описания, характеристики, отзывы — почти всегда можно и нужно читать с реплики (и часто ещё и через кеш поверх неё — здесь уместно вспомнить про синхронную репликацию и её цену: если задача — не потерять ни одной транзакции при отказе мастера, а не убрать лаг чтения, синхронная репликация не тот инструмент).
- Аналитика, дашборды, отчёты за прошлые периоды. Отчёт за вчера не изменится от того, что вы прочитали его с задержкой в секунду.
- Списки и ленты не персонального контекста — каталог, поиск, публичные страницы без привязки к «моему» только что произошедшему действию.
- История заказов, кроме самого последнего. Список из десяти прошлых заказов пользователя — с реплики; страница только что созданного — с мастера или по sticky-правилу.
- Всё, что и так идёт через отдельный кеш (Redis, CDN) с собственным TTL в секунды или минуты — там устаревание уже заложено в архитектуру, лишняя секунда лага репликации погоды не делает.
Ориентир для принятия решения по каждому конкретному чтению — простой вопрос: «Если пользователь увидит здесь состояние секундной давности вместо текущего, кто-то потеряет деньги, данные или доверие прямо сейчас?» Если да — читайте с мастера для этого запроса. Если нет — реплика делает свою работу, ради которой её и разворачивали.
Проверять реальный лаг репликации на своём сервере стоит не «на глаз», а метрикой: в PostgreSQL — через pg_stat_replication на мастере или функцию pg_last_xact_replay_timestamp() на реплике, в MySQL — через SHOW REPLICA STATUS и поле Seconds_Behind_Source. Если такие проверки не выведены в мониторинг постоянно, о реальном масштабе проблемы вы узнаете уже из тикетов саппорта — подробнее о том, как строить наблюдение за базой на постоянной основе, в статье про мониторинг баз данных через Grafana.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли просто всегда читать с мастера и не думать про эту проблему?
Технически можно, но тогда реплика для чтения не даёт никакого выигрыша — вы держите её только ради отказоустойчивости и бэкапов, а всю нагрузку по чтению несёт один сервер. Для небольшого трафика это нормальный и даже более простой вариант. Для нагруженного проекта — обратно к той же проблеме, ради которой реплику и заводили: мастер не тянет.
Насколько обычно велик лаг репликации в реальности?
Сильно зависит от нагрузки, сети между серверами и размера транзакций — от единиц миллисекунд в спокойное время до секунд и больше под пиковой нагрузкой или при длинных транзакциях на мастере. Точных цифр без измерения на вашей конкретной паре серверов не будет — это ориентир, чтобы понимать порядок величины, а не гарантия.
Что если у меня нет отдельного слоя маршрутизации между приложением и базой — только один connection string?
Тогда либо добавляете разделение на уровне приложения (большинство ORM умеют read/write split «из коробки», см. раздел выше), либо ставите прокси-слой вроде PgBouncer (для маршрутизации по пулам) или ProxySQL для MySQL, который умеет различать read/write запросы по правилам и направлять их на разные бэкенды.
А если использовать синхронную репликацию — проблема исчезнет?
Синхронная репликация решает другую задачу — гарантирует, что подтверждённая клиенту транзакция не потеряется при падении мастера. Она не убирает лаг для уже запущенных, независимых чтений с реплики, если только вы не читаете именно ту реплику, которая синхронно подтвердила запись, и именно сразу после неё — а это на практике редко так организовано без дополнительной логики.
Стоит ли просто добавлять искусственную задержку перед показом страницы после записи, чтобы реплика точно успела?
Не стоит — это хрупкое решение (лаг может внезапно вырасти больше вашей паузы под нагрузкой, как раз в момент пика) и ухудшает отклик для всех пользователей, а не только для тех, кому это нужно. Явная маршрутизация на мастер для конкретного чтения — надёжнее и дешевле по факту.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →