Eventual consistency: что показывать пользователю, пока данные сходятся
Eventual consistency — это не «лучшая или худшая» согласованность, это просто другая модель. Сильные гарантии, к которым все привыкли в одной базе, в распределённой системе либо стоят дорого, либо отключают часть мощности зря. Когда вы развели по разным сервисам заказы, склад и биллинг, у вас уже есть eventual consistency, даже если вы её не называли так. Вопрос только в том, заметит ли её пользователь и как.
Эта статья не про CAP-теорему и не про теоретические аспекты, а про практическое: какие приёмы я применяю, чтобы согласованность через события не превращалась в «купил товар, а в корзине пусто, и так десять секунд».
Откуда вообще берётся отставание
Чтобы говорить про UI и UX, надо понимать, где именно копится задержка.
- Между моментом записи в БД одного сервиса и публикацией события в брокер. Если используется outbox — это интервал работы фонового джоба (от секунд до десятков секунд).
- В самом брокере. На спокойном кластере Kafka или RabbitMQ это миллисекунды, под нагрузкой и при ребалансах — секунды.
- В потребителе. Время на чтение, обработку, запись в свою базу. Опять секунды на нормальной нагрузке, минуты под пиком.
- В сетевых задержках и ретраях.
В сумме типичное отставание от записи до видимости у потребителя — сотни миллисекунд в норме и десятки секунд во время инцидента. Если у пользователя есть ожидание «увидеть свои данные сразу после действия», эта задержка ему мешает.
Главное правило: не врать
Самый частый и худший подход — делать вид, что задержки нет. Пользователь оформил заказ, его перекидывает на «Мои заказы», там пусто или старая версия. Проходит две секунды, обновляется. Это вызывает ровно то ощущение, которое программисты называют «глюк» в чате поддержки.
Лучше явно показывать состояние «обновляется» или «создаётся», чем красивую несогласованность. Это не отговорка от инженерной работы, а признание того, что системе нужно время. Пользователю с этим легче, чем с молчаливо неверным интерфейсом.
Приёмы со стороны фронта
Read your writes на уровне UI
Когда пользователь сделал действие, фронт уже знает результат — он сам отправил запрос с данными. Не ждите второго round-trip-а к проекции. Можно отрисовать новый объект из тех данных, что у вас на руках, как «временный», и заменить его, когда придёт настоящий.
// Псевдокод. После успешного POST добавляем item локально и обновляем список
const tempId = crypto.randomUUID();
list.unshift({ id: tempId, ...payload, status: "pending" });
render(list);
await api.createOrder(payload);
// Дальше: периодический poll или подписка обновит status и заменит idЭто паттерн optimistic UI. Работает хорошо, когда вероятность отказа мала и есть план «что делать, если запись провалилась». Если провалилась — откатить временный объект и показать ошибку. Не пытайтесь молча оставить его, надеясь, что повторная отправка пройдёт.
Sticky session к конкретной реплике
Когда у вас несколько read-реплик базы, пользователь после записи может попасть на любую — и не увидеть свежие данные, потому что ту реплику ещё не догнал WAL. Решение — на короткий период (пару секунд после записи) посылать его на основной инстанс или специально выбранную реплику.
Реализуется на уровне gateway или клиента: после write-запроса сохраняем в куку флаг «X секунд читать с primary», после чего возвращаемся к балансировке. Простая, но эффективная штука для борьбы с replica lag.
Polling с подсветкой
Если пользователь оформил длительную операцию (загрузка файла, пакетный импорт, выгрузка отчёта), не делайте вид, что она готова. Покажите статус и опрашивайте серверу. Каждый раз, когда статус меняется, ненавязчиво подсветьте изменение. Это не «костыль», это нормальный UX для долгих операций.
Приёмы со стороны бэкенда
Возвращайте свежий ресурс в ответе на запись
В REST это значит: на POST /orders отдавайте не только id, но и весь актуальный ресурс. Тогда фронту не нужно второй раз ходить, чтобы что-то отрисовать.
{
"id": "ord_42",
"customerId": "cus_7",
"status": "created",
"items": [...],
"total": 1290,
"createdAt": "2026-05-23T10:00:00Z"
}Это бесплатный read your writes для одного объекта. На сложных сценариях с агрегацией данных нескольких сервисов так сделать сложнее, и помогает следующий приём.
Хедер с версией события
В ответе на запись передавайте версию или offset события, которое только что было опубликовано:
HTTP/1.1 201 Created
Location: /orders/ord_42
X-Event-Version: 12345
Дальше клиент в последующих запросах посылает этот хедер, и сервер на стороне читателя ждёт, пока проекция догонит указанную версию (с разумным таймаутом). Это даёт consistency прямо в одну линию пользователя, без замораживания всей системы.
Реализация требует, чтобы события несли монотонно растущую версию (или offset брокера) и чтобы потребитель отдавал «текущую обработанную». На практике достаточно простого: writer публикует событие с версией V, читатель отдаёт через эндпоинт «X-Synced-Up-To», client ждёт.
Команда + последний снимок
Для сложных случаев, когда нужно несколько чтений после команды, полезно вернуть в ответ на команду снимок данных, который понадобится сразу. Например, после оформления заказа — рядом со списком позиций сразу подсчитанная стоимость доставки. Не идеально с точки зрения чистоты слоёв, но избавляет фронт от трёх лишних запросов и ожидания проекций.
Где консистентность должна быть сильной
Не всё в системе можно отдать на eventual. Я обычно рисую короткий список мест, где сильные гарантии нужны независимо от стоимости.
- Деньги. Списание со счёта, выставление счёта, перевод. Тут eventual — это путь к двойным списаниям и нервным клиентам.
- Уникальность ресурса. Регистрация email, бронирование номера, занятие slot-а. Если два пользователя могут одновременно «получить» один ресурс — у вас не eventual consistency, у вас баг.
- Аутентификация и авторизация. Не должно быть «после revoke токен ещё работал минуту». Тут только сильные гарантии.
Для этих случаев — локальные транзакции в одной БД, distributed locks через Redis с разумной TTL, или явные synchronous-вызовы к мастер-сервису.
Где eventual — норма
- Списки, ленты, дашборды. Пользователь привык, что «обновится через секунду».
- Уведомления и аналитика. Никто не ждёт, что email уйдёт за 50ms после действия.
- Денормализованные read-модели. На то и денормализация.
- Поиск. Любой полнотекстовый поиск всегда eventual, его строят отдельно.
- Внешние интеграции. С партнёрами синхронность — это часто фантазия, которая ломается на их таймаутах.
Тут главное — заранее задуматься, что показывать пользователю в момент несогласованности, а не молча игнорировать.
Идемпотентность как страховка
Eventual consistency и retry — близнецы. Без идемпотентных операций ретраи превращают eventual в «иногда корректно, иногда нет». Ключевые точки:
- Команды на запись принимают
Idempotency-Key. Повторный вызов с тем же ключом возвращает результат первого. - Обработчики событий хранят таблицу обработанных id и пропускают дубликаты.
- В обновлениях проекций используется монотонная версия события, чтобы не откатить свежие данные старыми.
Без этих трёх вещей вы будете находить расхождения каждые несколько недель и не понимать причин. С ними — eventual работает предсказуемо.
Тестирование
Eventual consistency плохо ловится юнит-тестами. Что я обычно добавляю в интеграционных тестах:
- Включаю замедление обработчика (искусственно делаю задержку 1–2 секунды) и проверяю, что UI ведёт себя корректно: показывает прогресс, обновляется при готовности.
- Эмулирую duplicate delivery: тот же event дважды. Проекция должна оставаться корректной.
- Эмулирую out-of-order delivery: события в обратном порядке. Проекция не должна затирать новое старым.
- Эмулирую недоступность брокера на короткое время. Outbox должен дослать.
Это не покрывает всё, но ловит большинство багов, которые потом всплывают в проде в виде «почему-то иногда корзина пустая».
Мониторинг отставания
Если eventual consistency у вас живёт без мониторинга — у вас её нет, у вас есть таймбомба. Стандартный набор метрик:
- Размер outbox-таблицы и возраст самой старой непубликованной записи.
- Lag в брокере (Kafka consumer lag, depth очереди).
- Время от записи в writer до появления в проекции (можно считать через timestamp в событии и выборку проекции).
- Алерты на «возраст самой свежей записи в проекции X старше Y секунд».
Эти метрики сразу показывают, когда система перестаёт справляться. Без них вы узнаёте об отставании в момент, когда жалуется десятый клиент.
Что запомнить
Eventual consistency — нормальное состояние распределённой системы, не дефект. Задача архитектора — не «избавиться от неё», а сделать её невидимой для пользователя там, где он её не должен замечать, и явно показать там, где он её всё равно увидит. На уровне кода это правильно построенные команды (с идемпотентностью), события с версиями, проекции, которые умеют ждать догона. На уровне UX — честные индикаторы прогресса и оптимистичные обновления с откатом. На уровне эксплуатации — мониторинг lag-а как первая линия обороны.