Проектирование бэкенда
Паттерны устойчивости, стратегия кеширования, ограничение частоты запросов, идемпотентность, circuit breaker, graceful shutdown и выбор между REST, gRPC и очередями.
8 вопросов
JuniorТеорияОчень частоЧто такое cache-aside, write-through и вытеснение по TTL?
Что такое cache-aside, write-through и вытеснение по TTL?
При cache-aside приложение смотрит в кеш и при промахе само читает из базы и кладёт значение в кеш. При write-through каждая запись идёт через кеш, который синхронно обновляет базу, поэтому они не расходятся. Вытеснение по TTL выбрасывает запись по истечении фиксированного времени жизни, ограничивая устаревание значения.
Типичные ошибки
- ✗Ждать, что кеш заполнится сам при промахе
cache-aside, тогда как загружать и класть значение должен код приложения - ✗Думать, что
write-throughускоряет запись, тогда как он лишь держит кеш в согласии и всё равно ждёт базу - ✗Считать
TTLгарантией согласованности, а не границей того, насколько значение может устареть
Уточняющие вопросы
- →Что происходит при
cache-aside, когда два запроса промахиваются по одному ключу одновременно? - →Когда
write-through-кеш всё же может отдать читателю устаревшее значение?
JuniorТеорияЧастоОт какого сбоя защищает каждый из приёмов — timeout, retry и circuit breaker?
От какого сбоя защищает каждый из приёмов — timeout, retry и circuit breaker?
timeout ограничивает, сколько ждать медленный вызов, чтобы поток не блокировался вечно. retry повторяет вызов, упавший по временной причине (сетевой сбой, 503), восстанавливаясь незаметно. circuit breaker перестаёт звать уже падающий downstream — после достаточного числа ошибок он размыкается и быстро отклоняет вызовы, пока зависимость не восстановится.
Типичные ошибки
- ✗Считать
retryбезопасным для неидемпотентных вызовов, из-за чего повторённая запись применяется дважды - ✗Не ставить
timeout, из-за чего один зависший downstream занимает все рабочие потоки - ✗Думать, что
circuit breakerвосстанавливает падающую зависимость, а не просто щитит от неё вызывающих
Уточняющие вопросы
- →Чем опасен повтор неидемпотентного запроса и как сделать его безопасным?
- →Как
circuit breakerрешает, когда перейти из разомкнутого состояния обратно в замкнутое?
MiddleДизайнЧастоПрошлой ночью checkout лежал сорок минут, и причина была не в нём. Downstream-сервис цен начал отвечать за 30 секунд вместо 30 миллисекунд; все потоки checkout запарковались в ожидании, пул потоков сервлетов забился, health-проверки начали падать, и запросы, которые к ценам вообще не ходят, умерли вместе с остальными. Сервис цен был деградировавшим, а не мёртвым, и восстановился сам, когда нагрузка схлынула. Нужно, чтобы checkout пережил следующий такой случай. Ограничения: запросы, которым цены не нужны, должны обслуживаться всё это время; пока цены нездоровы, страница checkout всё равно рисуется с кешированной или дефолтной ценой, а не с ошибкой; трафик возвращается автоматически, как только цены здоровы, без переключения оператором. Как вы примените приём устойчивости circuit breaker — по чему он срабатывает, что происходит с вызовом, пока он разомкнут, и как он решает, что зависимость восстановилась?
Прошлой ночью checkout лежал сорок минут, и причина была не в нём. Downstream-сервис цен начал отвечать за 30 секунд вместо 30 миллисекунд; все потоки checkout запарковались в ожидании, пул потоков сервлетов забился, health-проверки начали падать, и запросы, которые к ценам вообще не ходят, умерли вместе с остальными. Сервис цен был деградировавшим, а не мёртвым, и восстановился сам, когда нагрузка схлынула. Нужно, чтобы checkout пережил следующий такой случай. Ограничения: запросы, которым цены не нужны, должны обслуживаться всё это время; пока цены нездоровы, страница checkout всё равно рисуется с кешированной или дефолтной ценой, а не с ошибкой; трафик возвращается автоматически, как только цены здоровы, без переключения оператором. Как вы примените приём устойчивости circuit breaker — по чему он срабатывает, что происходит с вызовом, пока он разомкнут, и как он решает, что зависимость восстановилась?
Оберните вызов цен breaker'ом: он считает ошибки и медленные вызовы в скользящем окне и размыкается, когда их доля переходит порог — таймаут обязан считаться, ведь инцидент был про задержку. Пока он разомкнут, вызов мгновенно падает в fallback с кешированной или дефолтной ценой, и поток не паркуется. После паузы он полуразмыкается, пробует пару вызовов и замыкается, только если они прошли.
Типичные ошибки
- ✗Считать только исключения и не считать таймауты, из-за чего медленная, а не падающая зависимость не размыкает breaker
- ✗Копить или повторять вызовы, пока breaker разомкнут, из-за чего потоки остаются запаркованными и быстрый отказ теряет смысл
- ✗Требовать ручного сброса вместо пробных вызовов в полуразомкнутом состоянии, из-за чего простой длится, пока кто-нибудь не заметит
Уточняющие вопросы
- →Почему таймаут на клиенте цен должен стоять раньше, чем breaker вообще сможет работать?
- →Что ломается, если полуразомкнутое состояние впускает весь трафик, а не несколько пробных вызовов?
MiddleДизайнЧастоВаш публичный REST API работает на трёх stateless-инстансах Spring Boot за балансировщиком. Один партнёр по интеграции долбит эндпоинт поиска и вытесняет остальных клиентов, поэтому нужно ограничить каждый API-ключ 100 запросами в секунду, но всё же пропускать короткий законный всплеск. Ограничения: лимит действует на ключ по всему флоту, а не на каждый инстанс, и переживает добавление или перезапуск инстанса; отклонённому клиенту нужно внятно сказать, что его придержали и когда возвращаться; сам ограничитель не должен стать тем, что кладёт API, когда его хранилище медленное или недоступно; проверка добавляет к запросу не больше пары миллисекунд. Как вы спроектируете ограничитель, где живут счётчики, какой алгоритм решает, пропускать ли вызов, и что API отвечает клиенту сверх лимита?
Ваш публичный REST API работает на трёх stateless-инстансах Spring Boot за балансировщиком. Один партнёр по интеграции долбит эндпоинт поиска и вытесняет остальных клиентов, поэтому нужно ограничить каждый API-ключ 100 запросами в секунду, но всё же пропускать короткий законный всплеск. Ограничения: лимит действует на ключ по всему флоту, а не на каждый инстанс, и переживает добавление или перезапуск инстанса; отклонённому клиенту нужно внятно сказать, что его придержали и когда возвращаться; сам ограничитель не должен стать тем, что кладёт API, когда его хранилище медленное или недоступно; проверка добавляет к запросу не больше пары миллисекунд. Как вы спроектируете ограничитель, где живут счётчики, какой алгоритм решает, пропускать ли вызов, и что API отвечает клиенту сверх лимита?
Держите счётчики в хранилище, общем для всех инстансов, например в Redis, чтобы лимит был на весь флот, а не на узел, и пропускайте вызовы по token bucket — ровная скорость пополнения плюс глубина ведра, впитывающая короткий всплеск. Клиенту сверх лимита отвечайте 429 Too Many Requests с заголовком Retry-After. Вызов в хранилище оберните жёстким таймаутом и падайте открытым.
Типичные ошибки
- ✗Считать на каждом инстансе и делить лимит на размер флота, из-за чего реальный предел плывёт при каждом добавлении или перезапуске узла
- ✗Блокировать поток запроса до конца окна вместо быстрого отказа, превращая ограничение в отказ пула потоков
- ✗Падать закрытым, когда хранилище счётчиков недоступно, из-за чего инцидент ограничителя становится полным простоем API
Уточняющие вопросы
- →Почему фиксированное односекундное окно позволяет клиенту пропихнуть двойной лимит на границе окна?
- →Что придётся поменять, чтобы у платного партнёра лимит был выше, чем у бесплатного?
MiddleДизайнЧастоВы режете монолит на три сервиса. checkout вызывает браузер на каждом просмотре страницы. pricing вызывает checkout на горячем пути — десятки миллионов вызовов в день между двумя внутренними Java-сервисами при бюджете задержки в единицы миллисекунд. invoicing обязан отработать для каждого завершённого заказа, может занимать секунды и не должен ронять заказ только потому, что он лежит. Архитектор хочет один транспорт везде «для единообразия» и выбрал REST поверх HTTP. Обоснуйте выбор под каждую связь: для каждой из трёх границ скажите, что вы возьмёте — REST поверх HTTP, бинарный RPC-фреймворк gRPC или асинхронный брокер сообщений — и защитите выбор по задержке и размеру payload, по силе связности вызывающего с вызываемым, по тому, как каждая сторона развивает контракт, и главное — по тому, что происходит с вызывающим, когда вызываемый недоступен.
Вы режете монолит на три сервиса. checkout вызывает браузер на каждом просмотре страницы. pricing вызывает checkout на горячем пути — десятки миллионов вызовов в день между двумя внутренними Java-сервисами при бюджете задержки в единицы миллисекунд. invoicing обязан отработать для каждого завершённого заказа, может занимать секунды и не должен ронять заказ только потому, что он лежит. Архитектор хочет один транспорт везде «для единообразия» и выбрал REST поверх HTTP. Обоснуйте выбор под каждую связь: для каждой из трёх границ скажите, что вы возьмёте — REST поверх HTTP, бинарный RPC-фреймворк gRPC или асинхронный брокер сообщений — и защитите выбор по задержке и размеру payload, по силе связности вызывающего с вызываемым, по тому, как каждая сторона развивает контракт, и главное — по тому, что происходит с вызывающим, когда вызываемый недоступен.
Браузер → checkout остаётся REST: он публичный, кешируемый, отлаживаемый, и его уже понимает любой клиент. checkout → pricing — горячий внутренний вызов между двумя Java-сервисами, поэтому выигрывает gRPC: компактный бинарный payload и сгенерированный контракт. invoicing асинхронен по природе — публикуйте событие в брокер, тогда заказ завершается, пока invoicing лежит.
Типичные ошибки
- ✗Выбирать один транспорт на всю систему и подгонять требования под него вместо выбора под каждую связь
- ✗Звать медленную, не имеющую права блокировать зависимость вроде
invoicingсинхронно и закрывать её простой повторами - ✗Ставить брокер на горячий путь с низкой задержкой, платя его прыжком и сложностью за ненужную этой связи расцепку
Уточняющие вопросы
- →Что асинхронная граница даёт вызывающему такого, чего не даст синхронный вызов с повтором?
- →Чем добавление поля в контракт отличается у
gRPCи у REST поверх HTTP?
MiddleДизайнИногдаКаждый деплой вашего сервиса на Spring Boot даёт всплеск 502 и несколько осиротевших задач. Оркестратор контейнеров Kubernetes шлёт поду SIGTERM и через тридцать секунд убивает его принудительно. За эти тридцать секунд ломается три вещи: балансировщик ещё секунду-две шлёт в под новые запросы после сигнала, HTTP-запросы в полёте обрываются посреди ответа, а @Scheduled-задача, прошедшая полпути своей пачки, просто исчезает. Ограничения: запрос, который под уже принял, ронять нельзя; под должен перестать получать новый трафик раньше, чем перестанет обслуживать; исполнителю фоновых задач нужно дать ограниченный шанс доделать работу или вернуть её; вся последовательность обязана уложиться в grace-период завершения, ведь после него процесс убивают в любом случае. Что должно произойти между SIGTERM и выходом процесса и в каком порядке?
Каждый деплой вашего сервиса на Spring Boot даёт всплеск 502 и несколько осиротевших задач. Оркестратор контейнеров Kubernetes шлёт поду SIGTERM и через тридцать секунд убивает его принудительно. За эти тридцать секунд ломается три вещи: балансировщик ещё секунду-две шлёт в под новые запросы после сигнала, HTTP-запросы в полёте обрываются посреди ответа, а @Scheduled-задача, прошедшая полпути своей пачки, просто исчезает. Ограничения: запрос, который под уже принял, ронять нельзя; под должен перестать получать новый трафик раньше, чем перестанет обслуживать; исполнителю фоновых задач нужно дать ограниченный шанс доделать работу или вернуть её; вся последовательность обязана уложиться в grace-период завершения, ведь после него процесс убивают в любом случае. Что должно произойти между SIGTERM и выходом процесса и в каком порядке?
По SIGTERM заваливайте readiness-пробу, продолжая обслуживать: балансировщик выводит под из ротации, а запросы в полёте дорабатывают. Только потом переставайте принимать соединения и дайте серверу слиться в пределах таймаута. Исполнителей гасите через shutdown() и awaitTermination, чтобы пачка доделалась или вернулась. Все ожидания ограничены, чтобы слив уложился в grace-период.
Типичные ошибки
- ✗Закрывать сервер раньше, чем снят трафик, из-за чего запросы, направленные мгновением ранее, обрываются посреди ответа
- ✗Звать
shutdownNow()у исполнителя, из-за чего пачка прерывается на полпути вместо того, чтобы доделаться или вернуться - ✗Ждать задачи без таймаута, из-за чего слив выходит за grace-период и процесс всё равно убивают принудительно
Уточняющие вопросы
- →Почему readiness-проба должна упасть раньше закрытия коннектора, а не позже?
- →Как выбрать таймаут слива относительно grace-периода завершения?
MiddleДизайнИногдаConsumer на Spring Boot читает события payment.captured из топика распределённого лога Kafka. На каждое событие он вставляет строку в таблицу проводок и шлёт клиенту письмо с чеком. Доставка — at-least-once: после ребаланса или если consumer умер между выполнением работы и коммитом offset, то же самое событие приходит снова. Поддержка сообщает о задвоенных проводках и клиентах, получающих один чек дважды. Ограничения: гарантию доставки брокера менять нельзя; строка проводки и закоммиченный offset живут в разных системах, атомарно вместе их не записать; повтор может прийти через минуты на другой инстанс consumer; пропускная способность остаётся в тысячах событий в секунду. Как сделать обработчик идемпотентным — что опознаёт повтор, где живёт состояние, которое его узнаёт, и что происходит с письмом при второй доставке?
Consumer на Spring Boot читает события payment.captured из топика распределённого лога Kafka. На каждое событие он вставляет строку в таблицу проводок и шлёт клиенту письмо с чеком. Доставка — at-least-once: после ребаланса или если consumer умер между выполнением работы и коммитом offset, то же самое событие приходит снова. Поддержка сообщает о задвоенных проводках и клиентах, получающих один чек дважды. Ограничения: гарантию доставки брокера менять нельзя; строка проводки и закоммиченный offset живут в разных системах, атомарно вместе их не записать; повтор может прийти через минуты на другой инстанс consumer; пропускная способность остаётся в тысячах событий в секунду. Как сделать обработчик идемпотентным — что опознаёт повтор, где живёт состояние, которое его узнаёт, и что происходит с письмом при второй доставке?
Ключуйте событие устойчивым бизнес-идентификатором — id платежа, а не offset — и пишите этот ключ в таблицу обработанных событий в той же транзакции, что и строку проводки. Уникальное ограничение делает повтор no-op, а транзакция не даёт падению записать одно без другого. Письмо уходит через outbox, дедуплицирующий по тому же ключу.
Типичные ошибки
- ✗Брать ключом дедупликации offset
Kafkaили номер попытки доставки вместо устойчивого бизнес-идентификатора - ✗Проверять факт обработки и записывать его двумя отдельными шагами, из-за чего падение между ними всё равно обрабатывает событие дважды
- ✗Дедуплицировать запись в базу, оставив письмо вне транзакции, из-за чего чек всё равно уходит дважды
Уточняющие вопросы
- →Почему коммит offset и вставку проводки нельзя сделать атомарными сразу в двух системах?
- →Как не дать таблице обработанных ключей расти без ограничений?
SeniorДизайнИногдаAPI каталога товаров отдаёт 40k чтений в секунду с двенадцати JVM, и 95% этого трафика приходится на 2% товаров. Сейчас каждое чтение идёт в PostgreSQL, а p99 равен 180 мс. Нужно спроектировать двухуровневый кеш — внутрипроцессная кеш-библиотека Caffeine перед общим хранилищем Redis, а то перед базой. Ограничения: правка товара должна быть видна на каждом инстансе за считаные секунды, поэтому просто переждать длинный TTL нельзя; двенадцать локальных кешей не должны держать разные версии одного горячего товара; failover Redis длиной в несколько секунд не должен обернуться штурмом базы или простоем; а когда горячий ключ всё же истекает, тысячи читателей, промахнувшихся по нему в один момент, не должны все пойти за ним в базу. Как вы разложите два уровня, как инвалидируется каждый и что делает конструкция, пока Redis недоступен?
API каталога товаров отдаёт 40k чтений в секунду с двенадцати JVM, и 95% этого трафика приходится на 2% товаров. Сейчас каждое чтение идёт в PostgreSQL, а p99 равен 180 мс. Нужно спроектировать двухуровневый кеш — внутрипроцессная кеш-библиотека Caffeine перед общим хранилищем Redis, а то перед базой. Ограничения: правка товара должна быть видна на каждом инстансе за считаные секунды, поэтому просто переждать длинный TTL нельзя; двенадцать локальных кешей не должны держать разные версии одного горячего товара; failover Redis длиной в несколько секунд не должен обернуться штурмом базы или простоем; а когда горячий ключ всё же истекает, тысячи читателей, промахнувшихся по нему в один момент, не должны все пойти за ним в базу. Как вы разложите два уровня, как инвалидируется каждый и что делает конструкция, пока Redis недоступен?
Caffeine держит горячие ключи на каждой JVM под коротким TTL, Redis — общий уровень за ним, и только промах в обоих идёт в базу. На запись вытесняйте ключ в Redis и публикуйте его, чтобы двенадцать инстансов сбросили локальную копию за секунду; локальный TTL — лишь подстраховка. Одновременные промахи по ключу схлопывайте в одну загрузку, а при недоступном Redis падайте открытым за breaker'ом.
Типичные ошибки
- ✗Опираться на короткий
TTLкак на механизм инвалидации, из-за чего двенадцать локальных кешей всё равно расходятся на длину этого окна - ✗Вытеснять на записи общий уровень и не вытеснять локальные, из-за чего каждая JVM отдаёт свою устаревшую копию горячего товара
- ✗Пускать в базу каждый одновременный промах по горячему ключу, из-за чего одно истечение превращается в тысячи одинаковых запросов
Уточняющие вопросы
- →Какова в вашей схеме худшая устареваемость правки товара и какой шаг её задаёт?
- →Почему одной загрузки из базы на промахнувшийся ключ самой по себе мало во время failover
Redis?