Разбор задач: файлы, такси, робот, топ-K

Четыре классические задачи системного дизайна — от требований до узких мест. С цифрами, компромиссами и тем, что хочет услышать интервьюер.

  1. требования
  2. оценки
  3. API
  4. данные
  5. схема
  6. углубление
  7. узкие места

Каждая задача идёт по этой схеме. Сами приёмы разобраны в подходе, масштабировании, данных и распределённых системах; здесь — применение. Листайте стрелками ← →, свайпом или кнопками внизу.

1 · Dropbox / Google Drive

Хранилище файлов: требования и оценки

  • Функции: загрузить и скачать файл до 50 ГБ, синхронизация между устройствами, история версий, общий доступ.
  • Нефункциональные: файлы не теряются (11 девяток у объектного хранилища), докачка после обрыва, правка большого файла не гонит его целиком, изменение доезжает до других устройств за секунды.
  • Чтение и запись соизмеримы: каждое изменение скачивают остальные устройства владельца.

Посчитайте нагрузку. Главный вывод интервьюер ждёт сам: байты — дорого, метаданные — много мелких запросов. Это две разные системы.

API, данные и путь загрузки


POST /commit {path, base_rev, chunks:[хеши]}
  → 200 {rev} · 409 конфликт
  → 428 {missing:[h3,h7], upload_urls}
GET  /delta?cursor=1050   → изменения, cursor
GET  /notify?cursor=1050  # long-poll
  • Метаданные (SQL, шард по namespace_id): file(ns, path, rev, chunks[]), chunk(hash, size, refcount), журнал изменений namespace с курсором.
  • Байты — в объектном хранилище по ключу sha256. Через сервис они не идут: клиент пишет напрямую по presigned URL.

Чанки и дедупликация

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

  • Фиксированные куски (Dropbox — по 4 МБ): просто, но вставка байта в начало сдвигает все границы — меняются все куски после правки.
  • По содержимому (CDC): скользящий хеш по окну в несколько десятков байт, граница там, где хеш даёт нужные биты. Вставка двигает только соседнюю границу. Так режут rsync-подобные системы, restic, borg, Seafile.

Нажимайте правки и сравните, сколько кусков придётся загрузить.

Синхронизация и конфликты

  • Коммит условный: «запиши rev 8, если сейчас rev 7» — оптимистичная блокировка. Кто опоздал, получает 409.
  • Слить два двоичных файла сервер не может. Dropbox сохраняет обе версии: вторую как «конфликтную копию». Google Docs вместо этого правит общий документ через OT/CRDT — это другая задача.
  • Уведомление несёт только «что-то изменилось». Данные клиент забирает сам по курсору — так ничего не теряется, даже если уведомление пропало или устройство было офлайн.

Попробуйте: выключите сеть у B, поправьте файл на обоих устройствах и включите сеть.

Хранилище файлов: узкие места

  • Мелкие файлы. node_modules — 50 тысяч файлов по 2 КБ: упирается в метаданные, а не в байты. Пакетные коммиты, мелкие куски склеивают в один объект.
  • Горячий namespace — общая папка компании на 10 тысяч человек: шард с ней и рассылка уведомлений. Отдельные шарды для больших команд.
  • Стоимость. Холодные версии — в дешёвый класс хранения, исходящий трафик — через CDN. Сжатие кусков перед загрузкой.
  • Сборка мусора. Кусок удаляют, когда на него не ссылается ни одна версия, — с задержкой, чтобы не удалить загружаемый прямо сейчас.

Дедупликация общая на всех пользователей: клиент присылает хеш куска, и если такой кусок есть, загрузку пропускает. Что здесь не так?

Хеш становится паролем к содержимому: «загрузил» по хешу — и скачал файл, которого у тебя не было. А по тому, пропущена ли загрузка, видно, есть ли такой файл у кого-то ещё. Dropbox в 2011 году закрыл такую атаку (Dropship): между пользователями дедуплицирует только сервер, после реальной загрузки. Сквозное шифрование и общая дедупликация вообще несовместимы.
2 · Uber / такси

Поиск машин рядом: требования и оценки

  • Функции: водители шлют координаты, пассажир видит машины рядом и заказывает поездку, система назначает одного водителя.
  • Нефункциональные: поиск за ~100 мс, назначение за секунды, водитель не получает два заказа сразу. Точная позиция нужна только свежая — история идёт в отдельное хранилище.
  • Нагрузка — это частота записи координат, а не объём данных.

Двигайте ползунки: память почти не меняется, а записей в секунду — сотни тысяч.

Геоиндекс: ячейки и соседи

Карта делится на ячейки (geohash, S2 у Google, H3 у Uber), водитель хранится в своей. Поиск — это рассматриваемая ячейка и её соседи: точка у границы иначе не увидит машину в метре через край.

  • Крупные ячейки — мало запросов, но в центре города проверяются сотни лишних машин.
  • Мелкие — проверяется мало, но ячеек десятки.
  • Квадродерево делит пространство по плотности: в центре ячейки мелкие, на окраине крупные. Зато его нужно перестраивать, когда машины переезжают.

Нажмите на карту, чтобы поставить пассажира. Радиус поиска — 1 км.

Позиции в памяти и назначение


# водитель, раз в 4 с:
GEOADD drv:msk 37.62 55.75 d42
# пассажир ищет машины рядом:
GEOSEARCH drv:msk FROMLONLAT 37.61 55.76
  BYRADIUS 1 km ASC COUNT 20
# бронь водителя на 15 с:
SET lock:d42 ride:981 NX PX 15000
  • Redis GEO — это sorted set, где счёт — 52-битный geohash. Позиции эфемерны: потеряли — через 4 секунды водители пришлют снова.
  • Назначение: кандидаты по ETA с учётом дорог, предложение лучшему, ожидание ответа. Водитель бронируется атомарно, иначе два диспетчера отдадут его двум пассажирам.

Горячие районы

Шардируем по городу или по крупной ячейке: поиск почти всегда попадает в один шард. Цена — неравномерность. Концерт закончился — 40 тысяч человек на стадионе открывают приложение, и один шард получает поисков больше, чем весь остальной город.

  • Поиск в плотной ячейке дорогой: он возвращает сотни точек.
  • Пассажиру не нужна точность до метра и до секунды: ответ для ячейки можно отдать из кэша на 1–2 секунды.

Включите событие, затем попробуйте разные меры.

3 · Поисковый робот

Поисковый робот: требования, оценки, схема

  • Цель: 1 млрд страниц в месяц, только HTML. Вежливость к сайтам, соблюдение robots.txt, без дублей, устойчивость к ловушкам.
  • 1 млрд / 2,6 млн с ≈ 400 стр/с, в пике ~1000. При 100 КБ на страницу — ~40 МБ/с входящего трафика и 100 ТБ в месяц до сжатия.
  • Узкое место не CPU, а ожидание: DNS, TCP, медленные сайты и паузы вежливости. Нужны тысячи одновременных загрузок.
Начальные URL→ Frontierочереди по хостам→ ЗагрузчикDNS-кэш, robots.txt
↓
ХранилищеS3 + индекс← Содержимое уже было?хеш, simhash← Парсер
↓
URL уже был?фильтр Блума← Нормализовать, фильтр← Извлечь ссылки
↑ новые URL — обратно во frontier

Розовым — три места, которые интервьюер попросит раскрыть.

Frontier и вежливость

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

  • Свободный воркер берёт из кучи хост с наименьшим «можно после» — и только если время пришло.
  • Один хост — одно соединение и пауза между запросами (секунда или Crawl-delay). Перед этими очередями — очереди приоритетов: важные страницы раньше.

Добавьте 30 URL с одного сайта, потом с разных — и сравните скорость.

Дубли: URL и содержимое

  • URL. Сначала нормализация: регистр хоста, порт по умолчанию, #фрагмент, порядок и мусор в параметрах (utm_*, sessionid). Потом проверка «уже видели» — фильтр Блума: 10 млрд URL при 1% ложных срабатываний — ~12 ГБ. Ложное срабатывание — пропущенная новая страница, это приемлемо.
  • Точные копии (зеркала, http и https) ловит хеш содержимого.
  • Почти копии — та же статья с другой датой или баннером. Обычный хеш меняется целиком, а simhash — на несколько бит. Близкие страницы ищут по расстоянию Хэмминга: в Google для 64 бит брали порог 3.

Ловушки, robots.txt, распределение

  • Ловушки: бесконечный календарь, ?page=… без конца, идентификатор сессии в каждой ссылке. Защита — лимит глубины и длины URL, бюджет страниц на хост, детектор повторяющихся шаблонов пути.
  • robots.txt кэшируют на сутки для каждого хоста; пока он не скачан, хост не обходят.
  • DNS — синхронный вызов и частое узкое место: свой кэширующий резолвер, асинхронные запросы.
  • Повторный обход: новости — каждый час, справочник — раз в месяц. Частоту подстраивают по тому, как часто менялась страница.

Frontier распределён по 50 узлам. Как разбить URL между ними?

Вежливость — это состояние хоста: время последнего запроса, Crawl-delay, robots.txt, DNS. При hash(host) всё это локально на одном узле, и пауза соблюдается без координации. При hash(url) 50 узлов одновременно ходят на один сайт, и нужен распределённый лимитер. Перекос от гигантских сайтов лечат бюджетом на хост.
4 · Счётчики и топ-K

Счётчик просмотров: горячий ключ

  • 1 млрд просмотров в сутки — ~12 тыс./с в среднем, ~35 тыс./с в пике. В сумме немного, но премьера клипа даёт 50 тысяч в секунду на одну строку.
  • UPDATE … SET views = views + 1 по одной строке — это очередь на блокировке строки: несколько тысяч в секунду, и всё.
  • Решения: разбить счётчик на N строк (запись — в случайную, чтение — сумма) или копить в памяти сервера и сбрасывать раз в секунду одним += n.

Поднимите поток просмотров и посмотрите, когда растёт очередь.

Топ запросов: точно или приблизительно

Точный подсчёт — хеш-таблица на все различные запросы: сотни миллионов ключей, десятки гигабайт в узле потоковой обработки. Для топа это не нужно.

  • Count-Min Sketch: d × w счётчиков, оценка частоты только завышена, ошибка — порядка N / w.
  • Рядом — куча из K кандидатов: пришёл запрос, обновили скетч; если оценка больше минимума кучи — запрос в куче.
  • Тяжёлые запросы видны отлично, хвост — неточно. Для топа именно это и нужно.

Окна: «в тренде» за последние минуты

  • Тренд — топ за окно, а не за всё время: иначе вечно первым будет «погода».
  • Счётчики держат по корзинам — по минуте. Скользящее окно — сумма последних N корзин: пришла новая минута — прибавили, старая вышла — вычли.
  • Потоковые движки (Flink, Kafka Streams) дают такие окна готовыми, вместе с водяными знаками для опоздавших событий.

Устройте всплеск и сравните топ за 5 минут с топом за всё время.

Поток, пакеты, лямбда и каппа

  • Поток: Kafka → Flink (окна, скетчи) → Redis sorted set, откуда читает API. Секунды задержки, результат приблизительный.
  • Пакетная обработка: сырые события в S3 → Spark раз в час/сутки → точные цифры для отчётов и денег.
  • Лямбда — оба пути сразу: быстрый и точный. Минус — одна логика в двух кодовых базах.
  • Каппа — только поток, а пересчёт — повторным проигрыванием журнала Kafka новой версией задачи.
  • Отсюда давний «301 просмотр» на YouTube: счётчик замирал, пока пакетная проверка отсеивала ботов, и только потом показывал точную цифру.

События разбиты на 10 партиций по user_id. Каждая партиция считает свой топ-10 запросов, агрегатор сливает десять списков. Получится точный глобальный топ-10?

Запрос, который в каждой партиции 11-й, не попадёт ни в один локальный список, хотя в сумме может обогнать «лидеров», сосредоточенных в одной-двух партициях. Лечится разбиением по ключу — hash(query): каждый запрос считается целиком в одной партиции, и слияние локальных топов становится точным. Перед этим можно частично агрегировать на стороне продюсера.

Коротко: система уведомлений

Сервисы-источники«заказ доставлен»→ Сервис уведомленийшаблон, настройки, дедуп→ Очереди по каналамpush · email · SMS→ Воркерыретраи, лимиты→ APNs / FCM / SMTP / SMS-шлюз
  • Отдельная очередь на канал: упал SMS-провайдер — push продолжает уходить, а очередь SMS копится и дожидается ретраев.
  • Идемпотентность: доставка «хотя бы раз», поэтому ключ события и дедуп перед отправкой — иначе два одинаковых SMS.
  • Настройки и лимиты: отписки, тихие часы, не больше N push в час на человека. Проверяются до постановки в очередь.
  • Рассылка подписчикам (fan-out) — пакетами в фоне: 10 млн получателей — это задача на минуты, а не запрос.

Что повторяется из задачи в задачу

ПриёмФайлыТаксиРоботТоп-K
Разбить по естественному ключуnamespaceгород, ячейкахостзапрос
Горячий ключ: дробить и копитьобщая папкастадионгигантский сайтвирусное видео
Хеш вместо сравненияхеш кускаgeohashsimhash, БлумCount-Min
Приблизительно — достаточно—позиция 4 с назадложный «уже был»топ со скетчем
Отделить тяжёлое от лёгкогобайты ≠ метаданныепозиции ≠ поездкистраницы ≠ frontierсобытия ≠ счётчики
Сигнал + забрать по курсоруlong-poll + deltaпредложение водителю—подписка на топ

Похожие приёмы в задачах первой части — в разборе задач 1.

Что слушает интервьюер

Сильный ответ

  • Уточняет масштаб и что важнее: свежесть, точность или стоимость.«Счётчик нужен для денег или для витрины?»
  • Считает и делает из цифр вывод.«250 тыс. записей/с — значит, в памяти, шардировать по городу»
  • Сам называет узкое место своей схемы и цену решения.«Кэш ячейки на 2 с — пассажир видит машины с опозданием»

Слабый ответ

  • Сразу рисует Kafka, Redis и микросервисы без требований.
  • Оценки ради оценок: посчитал терабайты и не использовал.
  • «Возьмём Cassandra, она масштабируется» — без ключа разбиения и запросов.

Главное

  • Файлы: метаданные отдельно от байтов, куски с адресом-хешем, загрузка напрямую в S3, условный коммит, уведомление + забрать по курсору.
  • Такси: проблема — частота записи. Позиции в памяти, геоячейки с соседями, атомарная бронь водителя, кэш ячейки для горячих мест.
  • Робот: frontier с очередями по хостам и паузами, разбиение по хосту, Блум для URL, simhash для почти дублей, защита от ловушек.
  • Топ-K: горячий счётчик дробят или копят, топ считают скетчем с кучей в окнах; точные цифры — пакетно.

Сначала цифры, потом схема, потом — где она сломается.