Какие обещания нельзя нарушить
Допустим, мы проектируем бронирование занятий. Клиент выбирает время, оплачивает и получает подтверждение. Важнейшая гарантия здесь вполне конкретная: два человека не должны получить одно последнее место. Из неё уже вытекают требования к данным и конкурентным операциям.
Разбираем состояния места: свободно, удерживается, оплачено, освобождено. Кто может перевести его дальше? Что делать, если оплата пришла после окончания удержания? Подтверждать бронь нельзя, если место уже занял другой человек. Значит, нужен возврат или заранее согласованный альтернативный сценарий.
Каталог может показывать немного устаревшее количество мест. Но при подтверждении нужно проверить реальную доступность и сохранить правило последнего места. У разных операций получаются разные требования. Это полезнее для выбора архитектуры, чем общее обещание сделать всю систему "строго согласованной".
Границу системы выбираем вместе с участниками процесса. Если деньги списывает провайдер, а место подтверждает наш сервис, часть результата находится снаружи. Мы не можем обещать атомарную общую транзакцию одним локальным вызовом. Нужно явно описать, как компоненты согласуют результат и кто отвечает за исключение.
Для каждого перехода задаём условие и владельца. Удержание создаётся только для свободного места, подтверждение требует действующего удержания и подходящего платежа. Отмена может потребовать возврата. Если правила разбросаны между UI, обработчиком событий и фоновой задачей, они легко начнут расходиться.
Конкуренцию разбираем на двух одновременно пришедших запросах. Проверить доступность, затем отдельно записать бронь недостаточно, если между действиями другой запрос сделал то же. Нужен механизм, который сохраняет инвариант при реальном параллельном выполнении. Конкретный способ зависит от модели данных и выбранного хранилища.
Отдельно обсуждаем административные действия. Сотрудник может закрыть занятие после начала продажи или добавить место. Эти операции должны соблюдать те же гарантии и оставлять понятный след. Панель администратора не должна быть обходом правил, иначе архитектура будет надёжной только для обычных пользователей.
Полезно сформулировать и то, что допустимо. Клиент может ждать подтверждения, каталог может кратко показывать старую доступность, отчёт может строиться позже. Такой список снимает искусственную строгость с частей, где она не нужна, и позволяет потратить сложность на действительно критичный путь бронирования.
Оцениваем нагрузку и ограничения системы
В нашем примере 100000 бронирований в сутки дают в среднем около 1,16 операции в секунду. Цифра выглядит скромно. Но открытие записи на популярное занятие может собрать сотни попыток за несколько секунд, причём все конкурируют за один объект. Средний RPS этого не показывает.
Запись размером 2 КБ даст около 200 МБ новых данных в день и 73 ГБ за год. Это только основные записи в грубой десятичной оценке. Индексы, история, WAL, бэкапы и реплики считаем отдельно. Полезно подписывать предположения прямо рядом с цифрами, чтобы оценку можно было пересобрать после измерений.
По такой прикидке должны появиться вопросы к конкретным ресурсам: сколько держим соединений, где будет горячая строка, сколько ждёт клиент, сколько стоит хранение. Произвольный запас "умножим на десять" не заменяет сценарий пика. Он может оказаться огромным для общего потока и недостаточным для одного популярного занятия.
Операции разбиваем по стоимости. Просмотр каталога может быть намного чаще бронирования, а подтверждение платежа приходит отдельным потоком. Если считать только брони, нагрузка на чтение и внешние вызовы исчезнет из оценки. Для каждого пути записываем частоту, размер данных и зависимости.
Горячий объект проверяем отдельно от общего RPS. Тысяча попыток забронировать тысячу разных занятий распределяется иначе, чем тысяча попыток занять последнее место одного занятия. Во втором случае масштабирование процессов может увеличить конкуренцию за одну запись, не повысив полезную скорость.
Для хранения учитываем срок жизни. Подробный лог события может храниться месяц, финансовая запись намного дольше. Если всё хранить одинаково, стоимость будет зависеть от случайно выбранного самого длинного срока. Архитектура данных должна учитывать нужный доступ, удаление и восстановление по каждому классу.
Пропускную способность внешней системы включаем в оценку. Если провайдер разрешает ограниченное число запросов, локальные серверы не снимут этот предел. Нужны очередь, лимит и прогноз времени ожидания либо согласованный способ увеличения квоты. Пользовательское обещание должно помещаться в эти условия.
Числа нужны для проверки решения, а не для создания иллюзии точности. Для неизвестного параметра задаём диапазон и проверяем чувствительность. Если удвоение размера ответа ничего не меняет, оценка устойчива. Если небольшое изменение частоты записи ломает схему, этот параметр надо измерить раньше остальных.
Переводим согласованность в поведение продукта
Если две части системы потеряли связь, они не могут независимо подтверждать одно последнее место без риска двойной брони. Можно временно остановить подтверждение там, где невозможно проверить право на место. Каталог и просмотр уже созданных броней при этом могут оставаться доступны.
Это конкретное решение для сетевого разделения. Оно не означает, что при любом сбое надо отключить весь продукт. Для каждой операции разбираем, можно ли показать старые данные, принять заявку в обработку или обязательно сразу подтвердить результат. Именно эти ответы определяют границы доступности.
Есть и более повседневный кейс: клиент получил "бронь создана", а на следующем экране брони нет. Даже если реплика догонит через секунду, человек начнёт повторять действие или писать в поддержку. Read-your-writes нужно учитывать в пользовательском пути отдельно от общего выбора модели согласованности.
В каталоге допустимость старых данных имеет конкретную цену. Пользователь может увидеть место, которое уже занято, и получить отказ при подтверждении. Это допустимо лишь если продукт понятно объясняет отказ и такая доля случаев приемлема. Термин eventual consistency сам по себе не определяет удобство этого опыта.
Для собственной записи можно выбрать несколько путей. Направить важное чтение на источник актуальных данных, дождаться нужной версии или вернуть подтверждённый результат прямо в следующий экран. У каждого варианта есть ограничения по задержке и отказам. Решение привязываем к последовательности действий, которую обещаем пользователю.
Распределённый кэш добавляет ещё одну копию, а поиск и аналитика часто обновляются отдельными потоками. Их отставание не обязано блокировать основную операцию, но должно быть понятно интерфейсу. Если человек ищет только что созданную бронь, пустая выдача требует объяснения или другого пути поиска.
Конфликт нельзя всегда решать правилом последней записи. Два изменения описания могут допускать такой выбор, два подтверждения последнего места нет. Для финансовых и ограниченных ресурсов нужен смысловой разбор. Протокол зависит от инварианта, а не от удобства универсальной функции слияния.
При сетевом сбое проверяем длительность и восстановление связи. После него могут прийти накопленные события в другом порядке. Компоненты должны понять, какие изменения ещё действительны и какие уже заменены. Возврат сети не означает автоматическое исчезновение всех конфликтов, поэтому завершение отказа тоже входит в дизайн.
Таймаут не говорит, чем закончилась операция
Платёжный сервис не ответил. Он мог не получить запрос, а мог списать деньги и потерять ответ по пути обратно. Повтор с новым ID рискует создать ещё одно списание. Поэтому UI должен уметь показать обработку, а бэкенд должен выяснить итог операции.
Для ретрая сохраняем ключ операции и определяем, как получить прежний результат. Если с тем же ключом пришли другие данные, это конфликт, который нельзя молча принять. Идемпотентность отдельного получателя при этом не делает запись в нашей БД и внешний платёж одной транзакцией. Между ними всё ещё возможен сбой.
Повторное событие, оплата после отмены, потерянное подтверждение: каждому кейсу нужен путь до конечного состояния. Это может быть сверка с провайдером, возврат или ручной разбор. Без этих правил стрелка "сервис вызывает оплату" на диаграмме скрывает самую сложную часть интеграции.
Неизвестный результат превращаем в состояние, которое можно проверить. У операции есть ID, время начала и текущий статус. Клиент может запросить его ещё раз, а фоновая сверка может обратиться к провайдеру. Если состояние нигде не сохранено, после рестарта останется только угадывать по логам.
Ключ идемпотентности должен жить достаточно долго для возможного повтора. Очистив его сразу после ответа, можно получить дубликат от клиента, который не увидел подтверждение. При этом вечное хранение тоже имеет цену. Срок и поведение после него выбираем по протоколу и характеру операции.
Публикация события после локальной записи имеет своё окно сбоя. Бронь сохранили, процесс упал до отправки уведомления. Можно хранить намерение отправки вместе с бизнес-изменением и затем повторять доставку. Но получатель всё равно должен учитывать повторы, а команда должна видеть застрявшие записи.
Компенсация не всегда отменяет физический результат мгновенно. Возврат денег может обрабатываться позже, письмо уже прочитано, внешняя система недоступна. Поэтому для каждого шага описываем, что означает завершение компенсации и что видит человек в промежутке. Название паттерна не заменяет эти правила.
Для необработанного исключения нужен ответственный и безопасный ручной путь. Например, сотрудник видит спорный платёж, сверяет его с провайдером и фиксирует решение. Ручная кнопка должна быть защищена от повторного выполнения и иметь аудит. Иначе редкий кейс станет источником новых нарушений инварианта.
Что означает обещание восстановиться за час
Доступность, допустимая потеря данных и время восстановления отвечают на разные вопросы. RPO задаёт, насколько старой может оказаться восстановленная копия, RTO задаёт целевое время возврата сервиса. Если потеря даже одной оплаченной брони недопустима, часовой бэкап сам по себе не выполняет это требование.
Время восстановления включает обнаружение сбоя, решение, запуск, проверку данных и возврат трафика. Быстро поднятая БД ещё не означает работающий продукт. Для бронирований потребуется сверить их с платежами, которые провайдер сохранил независимо. Поэтому RTO проверяем на всём сценарии, с реальными доступами и действиями команды.
RPO и RTO формулируем для конкретной аварии. Потеря одного процесса и потеря всего хранилища имеют разные последствия. Можно восстановить вычисления быстро, но долго возвращать данные. Если одна общая цифра включает все случаи, она обычно скрывает либо дорогую избыточность, либо невыполнимое обещание.
Список зависимостей восстановления включает доступы, конфигурацию, ключи, сеть и людей. Бэкап может быть целым, а учётная запись для его чтения недоступной. Время на получение разрешения или поиск специалиста тоже входит в фактический простой. Проверка должна проходить тем путём, которым воспользуется дежурный.
После восстановления проверяем смысл данных. Брони и платежи могли разойтись, события попасть повторно, удержания истечь за время простоя. Нужны сверка и безопасное продолжение. Отдавать трафик сразу после старта базы иногда опаснее, чем потратить несколько минут на проверку инвариантов.
Тест восстановления выбираем с контролируемым ущербом и наблюдаемым результатом. Необязательно ломать прод, чтобы узнать, что инструкция не работает. Но слишком искусственная среда не проверит размеры данных и реальные зависимости. Отмечаем, что проверили полностью, а что осталось предположением.
В эксплуатации следим, не изменилось ли обещание. Объём вырос, восстановление теперь занимает вдвое дольше, новая интеграция требует сверки. Старый результат теста не подтверждает новую систему. План восстановления пересматривается вместе с данными и существенными изменениями процесса.
Архитектуру ещё придётся поддерживать
Если одна база и несколько процессов выполняют нужные гарантии и держат нагрузку, разделение на сервисы должно решать конкретную проблему. За каждый новый компонент платим деплоем, контрактами, мониторингом и разбором частичных отказов. Размер команды и её время на эксплуатацию тоже входят в ограничения.
Фиксируем решение, альтернативы, предположения и триггер пересмотра. Затем проверяем риск: гонку за последнее место, потерю ответа оплаты, пик записи или восстановление. После этого у нас есть аргументы, почему схема подходит и когда перестанет подходить. Без таких проверок даже подробная диаграмма остаётся предположением.
Для сравнения вариантов выписываем требования одинаковым набором. Один сервис и база, несколько сервисов, отдельная очередь: как каждый вариант держит пиковую нагрузку, сохраняет инвариант, обнаруживает сбой и восстанавливается? Так видно, где сложность действительно покупает нужное свойство.
Границу сервиса удобно связывать с владением данными и самостоятельным изменением. Если два компонента постоянно требуют одной общей транзакции, разделение создаст протокол там, где раньше был локальный вызов. Иногда это оправдано независимой нагрузкой или командами, но цену надо принять явно.
Операционная сложность измеряется обычной работой. Сколько деплоев требует изменение, сколько мест нужно посмотреть при ошибке, кто может восстановить компонент ночью? Схема, которую умеет поддерживать один человек, создаёт организационную точку отказа даже при нескольких технических репликах.
Для сомнительного решения делаем небольшой проверочный прототип. Например, воспроизводим конкуренцию за последнее место или задержку апстрима на выбранной схеме. Прототип должен проверять конкретный риск, а не повторять весь будущий продукт. Его результат способен изменить архитектуру до дорогой реализации.
Триггер пересмотра формулируем наблюдаемым событием: определённый хвост задержки, доля конфликтов, время восстановления или нагрузка команды. "Когда вырастем" слишком расплывчато. С такими условиями текущая простая схема становится осознанным этапом, а не обещанием, что она подойдёт навсегда.
Проверяем архитектуру на последнем месте
Для последнего места сначала проверяем два одновременных клиента. Только один получает действующее удержание. Второй получает понятный результат без ложного подтверждения. Затем первый платит, ответ теряется. Его повтор с тем же ключом не должен создавать вторую оплату или другую бронь. UI показывает проверяемое промежуточное состояние.
После этого моделируем поздний платёж: удержание уже закончилось, место получил другой человек. По согласованному правилу запускаем возврат или иной предусмотренный путь. Получатель события допускает повтор, результат возврата сохраняется, спорный случай имеет владельца. Ни один из этих переходов не оставляется случайному порядку доставки.
На нагрузке различаем много разных занятий и одно популярное. Проверяем время ожидания, конкуренцию за запись и внешний лимит оплаты. Во время сетевого разделения сохраняем выбранную границу подтверждения, а каталог продолжает выполнять допустимое обещание. После связи сверяем накопленные изменения и окончательные состояния.
Завершаем упражнение восстановлением: поднимаем нужные данные, сверяем платежи и только затем возвращаем подтверждение трафику. Измеренное время сравниваем с RTO, фактическую точку данных с RPO. Для записи решения отмечаем проверенные сценарии и оставшиеся предположения. Такой набор объясняет возможности схемы намного точнее, чем название архитектурного стиля на диаграмме.