November 4

Разбор задачи system design - передача местоположения курьеров.

Привет. В комментариях к посту с историями про собесы была просьба разобрать задачу. Выполняю)

Я попробовал восстановить так, как это было на самом собесе, ничего не добавляя постафктум. Решение не может отличается от того, как сделали бы вы, это нормально.

Задача была поставлена следующим образом (с учётом собранных мною требований):

Представьте, что вы проектируете бэкенд для клиентского приложения доставки. Пользователь открывает экран заказа и хочет увидеть, где сейчас находится его курьер.

На вход у нас есть два внешних API, на которые мы не можем повлиять:

  1. Order API — по order_id возвращает данные по заказу, в т.ч. и courier_id. Среднее время ответа — 1 секунда.
  2. Courier API — по courier_id возвращает текущие координаты. Среднее время ответа — ещё 1 секунда.

Наш сервис должен принимать запрос от клиентского приложения вида

GET /orders/{order_id}/location

и отвечать координатами курьера: широта и долгота.

Жёсткое требование:

время ответа сервиса не должно превышать 300 мс,

даже при высокой нагрузке и географически распределённой системе.

Дополнительные условия:

- Мы не знаем список заказов заранее — о заказе узнаём, когда клиент впервые запрашивает его позицию.

- Внешние API трогать или ускорять нельзя.

- Сервис должен быть масштабируемым, отказоустойчивым и геораспределённым. 🐱

То есть нам нужно уложиться в SLA 0.3 секунды, хотя единственное место, где есть нужные данные, отвечает ~2 секунды. 😨

Как это сделать? Чистая архитектура, никаких костылей.

Чтобы уложиться в SLA по времени отклика и при этом не зависеть от скорости внешних API, система строится вокруг нескольких ключевых принципов:

1️⃣ Асинхронное наполнение данных (warm-up).

Первый запрос по заказу не вызывает цепочку из двух медленных API. Вместо этого сервис мгновенно возвращает location = null, а фоновый воркер начинает процесс получения данных:

  • обращается к Order API, чтобы получить courier_id;
  • по этому courier_id запрашивает координаты в Courier API;
  • сохраняет результат в Cassandra.

После этого все последующие запросы по данному заказу читают данные напрямую из базы.

2️⃣ Хранение в Cassandra без промежуточных кэшей.

Сервис не использует локальные in-memory или Redis-кэши. Все данные хранятся централизованно в Apache Cassandra, которая обеспечивает:

  • быструю запись (до десятков тысяч upsert-ов в секунду на ноду),
  • горизонтальное масштабирование без единой точки отказа,
  • репликацию между регионами.

Используется стратегия NetworkTopologyStrategy с ReplicationFactor = 3 на каждый регион и уровень согласованности LOCAL_QUORUM — это позволяет читать и писать данные локально, не дожидаясь удалённых DC.

Почему Cassandra, а не Redis?

При выборе хранилища ключевым фактором стали характер нагрузки и требования к надёжности.

  • Профиль нагрузки — write-heavy
    Каждый активный курьер обновляет координаты каждые 2–5 секунд. При десятках тысяч активных курьеров это десятки тысяч UPSERT-операций в секунду.
    Такая нагрузка типична не для кэш-систем, а для постоянных write-heavy хранилищ, оптимизированных под линейное масштабирование и дешёвые вставки.
  • Геораспределённость и отказоустойчивость
    Redis, даже в кластерном режиме, не предоставляет аналогичной модели актив-актив без внешней прослойки (обычно нужен либо CRDT-слой, либо дорогая мульти-мастер-репликация).
  • Консистентность и долговечность
    Для геопозиции допустима eventual consistency, но при этом важно, чтобы данные не терялись.

Cassandra гарантирует сохранность записей после достижения кворума реплик, даже если узлы падают.

Redis, напротив, ориентирован на скорость чтения и простоту, а не на долговечность — потеря узла или выключение snapshot-механизма может привести к частичной потере данных.

3️⃣ Разделение API и воркеров

  • API-слой отвечает только за быстрые запросы клиента и формирование ответов (200 OK с координатами или warmingUp:true).
  • Воркеры асинхронно взаимодействуют с внешними сервисами, обновляют данные в Cassandra и управляют фоновыми циклами обновления координат курьеров.

Один воркер может обслуживать сотни активных курьеров, выполняя обновления каждые 2–5 секунд.

4️⃣ Схема хранения данных.

Используются две основные таблицы:

CREATE TABLE order_to_courier (
  order_id  text PRIMARY KEY,
  courier_id  text,
  assigned_ts timestamp
);
  
CREATE TABLE courier_location_current (
  courier_id  text PRIMARY KEY,
  lat double,
  lon double,
  provider_ts timestamp,
  updated_ts  timestamp
);

order_to_courier заполняется один раз на заказ, courier_location_current обновляется каждые несколько секунд.

Поведение системы

1️⃣ Клиент делает запрос GET /orders/{orderId}/location.

Сервис обращается к Cassandra:

  • если данные есть — возвращает координаты;
  • если нет — отдаёт null и ставит задачу в очередь.

2️⃣ Воркеры обрабатывают очередь:

  • получают courier_id и координаты;
  • обновляют таблицы order_to_courier и courier_location_current.

3️⃣ Следующие запросы обслуживаются из Cassandra — без дополнительных внешних вызовов.

Схема приложения
Течение данных

Что бы вы сделали иначе?

Давайте оставаться на связи - https://t.me/ask_for_oleg ☄️