О паттернах отказоустойчивой архитектуры написано уже немало. Но когда дело доходит до реальных кейсов, особенно в специфических отраслях вроде лотерейной — информации почти нет. А ведь здесь, как и в любой высоконагруженной системе, отказоустойчивость — не просто галочка в ТЗ, а вопрос пользовательского доверия и бизнес-репутации.
В этой статье расскажем, как мы в «Столото» подошли к проектированию Lottery Payment System. Это полностью вымышленный сервис для выплат выигрышей, построенный на опыте реальных вызовов и ограничений для того, чтобы на его примере описать, как работают ключевые паттерны отказоустойчивой архитектуры: Retry, Idempotency Key, Deadlines, Rate Limit и Circuit Breaker. Также покажем, как они применяются в контексте распределённой системы, которая должна стабильно работать, даже когда вокруг всё пошло не по плану.
«Столото» — высокотехнологичная компания. На нашей платформе ежедневно проводится около 600 лотерейных розыгрышей, а участникам выплачивается в среднем более 500 тысяч выигрышей в сутки. Лотерейный IT-ландшафт по сложности и уровню требований к надежности сравним с системами банков или страховых компаний — с усиленными мерами безопасности и строгим контролем процессов.
Более чем 10 лет опыта в индустрии развлечений и уникальная экспертиза в сегменте лотерей позволяют «Столото» уверенно удерживать лидерство на российском рынке и входить в топ-10 мировых лотерейных компаний по объему онлайн-продаж. Сегодня около 80% наших билетов покупаются в интернете — и это накладывает особую ответственность на устойчивость и отказоустойчивость всех сервисов.
В нашем вымышленном сценарии мы представили, что компания прошла через архитектурную трансформацию и решила переосмыслить доменные области. Слишком громоздкий и хрупкий процессинг тормозил развитие. В какой-то момент он стал похож на башню из кубиков, которую боишься задеть. Поэтому мы выбрали путь здорового прагматизма — выплаты вынесли в отдельный сервис, чтобы дать этому направлению и стабильность, и простор для развития. В зоне его ответственности — всё, что связано с переводами пользователям, которые хотят получать выигрыши по лотерейным билетам и исполнять свои мечты.
1. Паттерн Retry
Представим, что мы развиваем наш вымышленный сервис — Lottery Payment System. Команды договорились, что теперь транзакции выплат нужно обогащать дополнительной информацией: как был куплен билет (за рубли, бонусы или по промокоду) и сколько в итоге пользователь заплатил. Эти данные важны для аналитики, но не критичны для выплаты — если что-то не получилось, транзакция должна пройти.
Допустим, информация о покупке хранится в стороннем сервисе. Организуем запрос к нему — идём «наружу» за нужными атрибутами. С точки зрения бизнес-логики: enrich получился — здорово, не получился — тоже нормально, просто в топик аналитики попадёт не вся информация.
Такой подход выглядит безопасным, пока не замечаем: в результирующих сообщениях часто отсутствуют суммы и идентификаторы пользователей. Проблема теоретически может быть в сетевых таймаутах, дропах пакетов, перебоях на уровне L7 или в логике вызываемого сервиса. Не исключено и банальное отсутствие ответа из-за высокой задержки между кластерами или провайдерами.
Что делать
В этом месте приходит на помощь паттерн отказоустойчивой архитектуры — Retry. Если мы не получили ответ с первого раза, логично попробовать ещё. Один-два дополнительных вызова могут компенсировать временный сбой и дать системе шанс на успех.
Чтобы не перегружать внешний сервис и не создавать лишнюю нагрузку, используется exponential backoff — увеличивающийся интервал между попытками. Также стоит соблюдать стандарт http: доставать из заголовка Retry-After. Если сервер сам знает, когда сможет обслужить наш запрос, то использовать это время для слипа до следующей попытки.
Что получаем
Бизнес-процесс не блокируется: пользователь получает выплату без задержек. А при удачном ретрае в аналитические топики попадают полные данные. Если нет — тоже не трагедия, но вероятность «пустых» операций снижается.
Retry — это не о навязчивом «долбить до упора», а о разумной попытке справиться с вре́менной нестабильностью системы. В архитектуре, где много зависимостей и сетевых вызовов, это один из базовых инструментов, который сто́ит внедрять осознанно.
2. Паттерн Idempotency Key
Продолжим прокачивать отказоустойчивость нашего вымышленного сервиса Lottery Payment System. Менеджеры приходят с новой идеей: дать возможность пользователю получать выигрыш не только по одному билету, а по пачке сразу — и тиражные*, и бестиражные (моментальные)**.
* Тиражные лотереи — это классические розыгрыши, где выигрыши распределяются между участниками после проведения тиража: сначала билеты распространяются, а потом проводится единый розыгрыш.
** Бестиражные лотереи, наоборот, работают по принципу моментального выигрыша — результат закладывается в билет на стадии его изготовления, и участник узнаёт о выигрыше сразу после оплаты и проверки билета.
Пожали плечами, подумали, что идея здравая — а реализация в рамках монолита не очень. Выносим эту логику в отдельный микросервис. Вспоминая о проблемах с нашим обогащением транзакций, также реализовываем здесь Retry.
Но вот здесь начинается интересное. Представим: пришёл запрос на пакетную выплату, сервис принял его, сходил в базу, сохранил информацию и… всё. Только вот прокси, стоя́щий между пользователем и сервисом, в ответ ничего не получил — потому что сеть, как водится, подвела. Что делает прокси? Конечно же, повторяет запрос. И сервис, ничего не подозревая, снова обрабатывает операцию. Дважды. И начисляет двойной выигрыш.
Давайте разбираться, что же произошло. Проблема здесь в неустойчивости передачи ответа: запрос дошёл, а ответ — нет. И если не отличать повторный запрос от нового — последствия могут быть серьёзными. Да, наш вымышленный пользователь счастлив, пишет восторженные посты в соцсетях, но для бизнеса это прямой путь в минус.
Что делать
Здесь появляется один из базовых паттернов отказоустойчивой архитектуры — Idempotency Key. Клиент или прокси (лучше — клиент) генерирует уникальный ключ (например, UUID) и передаёт его вместе с запросом. Сервис, получив такой запрос, сохраняет не только данные, но и связку с этим ключом. И если тот же ключ приходит повторно — вместо повторной обработки сервис возвращает результат первого выполнения.
Такой подход даёт поведение «не более одного раза» (at most once delivery semantics) — что бы ни случилось с сетью, сколько бы ретраев ни прилетело, бизнес-операция произойдёт только один раз. И это именно то, что нам нужно, когда речь идёт о деньгах, правах доступа, и вообще обо всём, где «повторить» означает «потерять».
Если же сеть упала от клиента до прокси, ещё одна практика — повторять запросы от клиента с тем же ключом, до тех пор, пока не вышел дедлайн.
Что получаем
Добавив один ключ, избежали дорогостоящих дублей, сохранили доверие пользователей и оставили компанию в плюсе. А ещё — избавили разработчиков от бессонных ночей и ретроспектив с фразой «ну мы же не думали, что прокси решит повторить запрос».
3. Паттерн Deadlines
Развивая наш вымышленный Lottery Payment System, глубже погружаемся в реальность распределённых систем. И вот к уже привычным задачам добавляется новая: выплаты, которые превышают 15 000 рублей, требуют дополнительного взаимодействия с системой налогового учёта.
Задача выглядит стандартной — отправили запрос, дождались ответа, всё. Но на практике сложнее. Пользователь отправляет запрос, прокси передаёт его в наш монолит, монолит подготавливает данные, и затем стучится в стороннюю налоговую систему.
Мы учли проблемы, с которыми мы сталкивались раньше, организовали Retry и Idempotency Key, но несмотря на наши попытки мы замечаем, что в 95 перцентиль у нас попадают медленные запросы. Клиент или не может дожидаться ответа и разрывает соединение по таймауту, или просто ждёт неприлично долго.
Что идёт не так
Запрос ушёл — а ответ затерялся. То ли сеть подвела, то ли сервер на той стороне залип, то ли где-то в коде случилось зависание, а может железо сказало «устал». В этот момент пользователь сидит и ждёт. А через несколько секунд (или минут) видит: «Не удалось выполнить операцию, попробуйте позже». Классика, которую никто не любит.
Потрачено время, операция ушла в откат, пользователь недоволен, аналитика дырявая. Все грустные. И всё из-за того, что не было чётко определено, сколько времени мы готовы ждать.
Что делать
Здесь помогает один из ключевых паттернов отказоустойчивой архитектуры — Deadlines. Задаём максимально допустимое время, за которое операция должна быть завершена. И не просто на уровне одного сервиса, а на всём пути запроса — от пользователя до последнего микросервиса. Это называется Deadline Propagation — передача дедлайна по цепочке.
Каждый следующий сервис знает: у него есть столько-то миллисекунд, и не больше. Он не будет держать соединение до последнего в надежде, что «вот сейчас точно ответит». Нет ответа — отмена, переходим к следующей попытке или возвращаем ошибку. Быстро, чётко, по делу.
Как выбирать дедлайны
Важно не перегнуть. У всех сервисов разное время отклика. Кто-то отвечает за 100 мс, а кому-то 300 мс — норма. Поэтому разумно считать таймауты на основе 99-го перцентиля времени отклика — взять порог, который покрывает 99% запросов, и добавить к нему небольшой запас. Тогда подавляющее большинство запросов пройдут успешно, а единичные сбои не станут катастрофой.
Что получаем в итоге
Внедряем дедлайны. Теперь, если запрос в налоговую систему не проходит за 100 мс — мы сами закрываем соединение. Пробуем ещё раз — и вторая попытка оказывается успешной. Операция укладывается в 180 мс, и пользователь получает ответ без каких-либо «упс». Главное — есть контроль над временем, и мы не зависим от доброй воли инфраструктуры, которая может «зависнуть» на ровном месте.
Почему это важно
Deadlines не просто экономят время. Они делают систему предсказуемой, а предсказуемость — основа хорошего пользовательского опыта. И да, для отказоустойчивой архитектуры — это must-have. Без чётких границ времени никакой retry, circuit breaker или idempotency key не спасут. Добавив одну вещь, вы получаете устойчивость, производительность и доверие пользователей. Красота :)
4. Rate Limit
На этом этапе наша система уже умеет многое. Мы выплачиваем выигрыши, обогащаем транзакции, взаимодействуем с налоговой системой, умеем отлавливать дубли благодаря idempotency key и не держим запросы дольше, чем нужно благодаря deadlines. Казалось бы — живи да радуйся. Но, как это часто бывает в системах с реальными пользователями, появляется новый «интересный» запрос.
Менеджер приходит с идеей добавить в ответ пользователю индивидуальную информацию о предстоящих акциях: только то, что может быть интересно конкретному участнику — по его игровой активности. Для этого поднимаем сервис User Statistics, который под капотом интегрируется с системой лояльности.
Сценарий такой: пользователь делает запрос, запрос попадает в прокси, затем в монолит, дальше — в User Statistics, и уже он идёт в Loyalty. Всё красиво, пока… не наступает тот самый 300-й RPS.
Проблема
Система внезапно перестаёт отвечать. И речь не о том, что 300 RPS не выдерживаются. Хуже — даже те 200, с которыми раньше жили спокойно, больше не проходят. Вся система начинает «тонуть».
Что случилось?
Мы не ограничили нагрузку, которая может прилететь не только в результате линейного роста пользователей, но и из-за:
Спайков в трафике: кто-то нажал F5 слишком активно. Или много кто.
Некорректных клиентов: кто-то запустил багованный скрипт.
DDOS-подобного поведения: часто незлонамеренного.
Падения части инфраструктуры (например, недоступен один из трёх подов), и весь трафик льётся в два оставшихся.
В результате — деградация всей системы. Даже те, кто не виноват, начинают страдать.
Что делать
Чтобы система не умирала от внезапного энтузиазма пользователей, вводим паттерн Rate Limit — ограничение количества запросов, которые сервис может обработать за определённое время. Есть несколько стратегий реализации:
Fixed Window — разрешает N запросов за фиксированный интервал времени, но возможны перегрузки на границах окон. Если 1 000 запросов пришло в последнюю секунду первого окна и 1 000 в первую секунду второго окна.
Sliding Window — считает запросы в реальном времени и распределяет нагрузку равномерно.
Token Bucket — сервису выдаётся «ведро токенов», каждый запрос сжигает токен. Токены постепенно пополняются. Возможен уход в ожидание появления новых токенов.
Leaky Bucket — также пополняется в течение использования, но запрещает выходить за лимит. Всё, что свыше, будет отброшено.
Но помимо самого подхода, важен контур применения. Рекомендуем вешать rate limiting на стороне сервера. Не полагайтесь на клиента, потому что он может не знать обо всех нюансах, не учитывать деградации инфраструктуры или не синхронизироваться с другими подами. Сервер же знает своё текущее состояние и может точнее отследить перегрузку.
Также важно:
Использовать квоты и отдавать клиенту информацию через заголовки (
X-RateLimit-Limit
, X-RateLimit-Remaining,X-RateLimit-Reset
).Возвращать HTTP 429 Too Many Requests, чтобы клиент знал: запрос не обработан, но можно попробовать позже.
И, если возможно, использовать заголовок
Retry-After
— подскажите, когда можно вернуться.
Что изменилось
Теперь при превышении лимита наш User Statistics даже не пытается ходить в Loyalty. Вместо этого — возвращает заранее подготовленный fallback или пустой блок. Пользователь при этом не теряет основную функцию — выплату выигрыша — но не видит персональных акций. В худшем случае — просто не заметит, что они должны были быть. Но система продолжит работать, и главное — не уронит всех пользователей.
Rate limiting — паттерн отказоустойчивой архитектуры, который становится спасением в моменты, когда «всё идёт не по плану». Он не делает систему быстрее. Он делает её предсказуемой, а в распределённых системах это значит — устойчивой. Именно этого мы и хотим.
5. Паттерн Circuit Breaker
Когда работаешь с распределённой системой, рано или поздно начинаешь воспринимать её как живой организм. У неё есть слабости, перегрузки, моменты, когда она «приболела», и тогда ей лучше не мешать. В такие моменты — когда кто-то из соседей по микросервисной квартире приуныл — в игру должен вступить паттерн Circuit Breaker.
И нет, это не дубль retry или deadline. Это их заботливый старший брат, который приходит, когда всё пошло совсем плохо.
Проблема
Допустим, всё сделано по уму. Поставлен rate limit, отмерен deadline, подвешены ретраи. Система работает как часы — пока часы не разбились.
Пример: чтобы красиво отрендерить билет пользователю, идём за дизайном билета через Sale System. Фронт ждёт картинку. Всё подключено по схеме: прокси → монолит → Sale System. Но вот незадача — одна из наших систем, ответственная за рендеринг билета пользователю легла. Не полностью, а так, «полусидя». Вроде отвечает, но через раз, с ошибками и тайм-аутами. А мы продолжаем стучать, потому что ретрай и позволяет дедлайн. И каждый наш невинный запрос — это ещё один кирпичик в стену, которая потом упадёт на всех. Такая деградация легко приводит к каскадным сбоям — когда проблемы одного сервиса распространяются по всей системе как вирус.
И самое обидное: чтобы понять, что зависимость не работает, тратим время, которое ограничено. На каждый неуспешный запрос — +300 мс, и в итоге вся система буксует.
Что делать
Паттерн Circuit Breaker (предохранитель) — это как автомат в электрической цепи. Когда тока слишком много — он выключается, чтобы не сгорело всё. Здесь — ровно то же самое.
Circuit Breaker следит за количеством ошибок при обращении к зависимости (будь то таймауты, сетевые сбои или ошибки 500) и, если их становится слишком много, просто размыкает цепь. Перестаёт слать запросы в неработающий сервис.
Да, в этот момент пользователи не получают красивую картинку. Но! Они получают ответ быстро. Мы не стоим в очереди к сломанной кассе, чтобы узнать, что она не работает. Мы сразу идём дальше.
Как работает Circuit Breaker
У предохранителя есть три состояния:
Closed (замкнуто) — всё хорошо, запросы идут.
Open (разомкнуто) — всё плохо, запросы больше не отправляются, сразу фейлим.
Half-Open (наполовину) — проверяем, восстановилась ли зависимость. Шлём небольшой процент трафика и смотрим на результат.
Если зависимость ожила — рубильник опускается, всё снова работает.
Плюсы Circuit Breaker:
Экономия времени — не тратим ресурсы на гарантированно неудачные запросы.
Устойчивость — не усугубляем деградацию внешнего сервиса.
Прогнозируемость — пользователь быстрее получает хоть что-то, пусть и неидеальное.
Автоматическое восстановление — когда зависимость оживёт — всё заработает само, без ручного вмешательства.
На что обратить внимание:
Не держите рубильник открытым вечно. Используйте таймеры, health-check'и, пробный трафик.
Отдельные Circuit Breaker'ы для разных внешних сервисов.
Можно комбинировать с fallback'ами — отдавать дефолтный ответ, как сделали с заглушкой на дизайн билета.
Не забывайте логировать — иначе потом будете гадать, почему система «сама решила не ходить туда».
Circuit Breaker — один из самых полезных паттернов отказоустойчивой архитектуры. Он не решает проблему сломанного сервиса, но помогает системе пережить его падение. И сделать это спокойно, предсказуемо и без лишней драмы. А в большом микросервисном мире это ценится на вес золота.