Разбор задач: ссылки, лимиты, лента, чат

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

Серия «Системный дизайн», часть 5. Понятия из подхода, масштабирования, данных и распределённых систем здесь применяются, а не объясняются заново. Листайте ← →, свайпом или кнопками внизу.

Один и тот же порядок для любой задачи

На интервью 45 минут. Кандидаты чаще всего проваливаются не на знаниях, а на порядке: рисуют Kafka до того, как узнали, сколько будет запросов. Подробно о каждом шаге — в первой части серии.

1Требованиячто делает, какой масштаб, что вне рамок5 мин
2ОценкиQPS, объём, пики4 мин
3API3–5 методов3 мин
4Данныесущности, ключи, доступ3 мин
5Схемапростая и целиком рабочая10 мин
6Углубление2–3 самых трудных места15 мин
7Узкие местаотказы, компромиссы, что дальше5 мин

Каждая задача ниже проходит эти шаги. Цифры — порядки величин, а не точные значения: на интервью важнее, какие выводы вы из них сделали.

Задача 1 · сокращатель ссылок

Требования и оценки

  • Функции: создать короткую ссылку (можно свой алиас и срок жизни), перейти по ней, посмотреть статистику.
  • Нефункциональные: редирект за десятки миллисекунд, доступность 99,99%, ключи нельзя угадать перебором.
  • Чтений в сотни раз больше, чем записей. Ссылку не меняют и почти не удаляют.

Двигайте ползунки. Выводы из чисел: запись — копейки, чтение надо кэшировать, а общий объём через несколько лет уже не влезет в один сервер.

API и модель данных

  • Горячий запрос один — «дай long_url по ключу». Ни соединений, ни диапазонов: это key-value.
  • Свой алиас живёт в том же пространстве ключей: только вставка «если нет» (ON CONFLICT DO NOTHING, IF NOT EXISTS), иначе два пользователя получат один алиас.
  • Одинаковые длинные ссылки обычно не склеивают: у разных владельцев своя статистика и срок жизни.
  • Клики — отдельный поток событий, а не UPDATE counter: строка вирусной ссылки стала бы точкой конкуренции.

POST /api/v1/links
  {"url": "https://…", "alias": "sale",
   "expires_at": "2027-01-01"}
  → 201 {"short": "https://sho.rt/aZ3kP9q"}

GET    /{key}  → 302 Location: https://…
                 404, если нет или просрочена
GET    /api/v1/links/{key}/stats?from=…&to=…
DELETE /api/v1/links/{key}

# links (KV, шард по key)
key PK │ long_url │ owner_id │ created_at │ expires_at

# clicks: Kafka → ClickHouse
key │ ts │ country │ referrer │ user_agent

Где взять уникальный ключ

  • Хеш URL (MD5/xxhash → base62 → первые 7 символов): без координации, но каждая вставка проверяет коллизию, а одинаковые URL дают одинаковый ключ.
  • Счётчик + base62: коллизий нет, но счётчик — общая точка, и ключи идут подряд: соседние ссылки легко перебрать.
  • Диапазоны: сервер берёт у координатора блок из 1000 номеров и раздаёт их локально. Одно обращение на 1000 ссылок; упал сервер — остаток блока пропал, это не страшно.

Выберите способ и создавайте ссылки на разных серверах. Уроните сервер B.

Длина ключа и парадокс дней рождения

Base62 — это [0-9a-zA-Z]: 62⁷ ≈ 3,5 трлн ключей. Кажется, что хватит надолго. Но если ключи случайные (хеш), первая коллизия ждёт не через триллион ссылок, а примерно через √(π·N/2) — около 2,4 млн.

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

301 или 302 и где считать клики

  • 301 Moved Permanently браузер кэширует: повторные переходы не доходят до сервера. Меньше нагрузки — но вы не видите кликов и не можете поменять цель.
  • 302 Found (или 307) — каждый переход приходит к вам. Это выбор bit.ly и почти всех, кому нужна аналитика.
  • Клик пишется асинхронно: событие в Kafka, агрегаты в ClickHouse. Редирект не ждёт записи статистики.

Переключите код ответа и покликайте одним и тем же пользователем. Потом поменяйте цель ссылки.

Горячие ссылки и выбор базы

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

  • Кэш: Redis/Memcached перед базой, LRU, плюс CDN или кэш на балансировщике для самых горячих ключей.
  • База: миллиарды строк, доступ только по ключу — DynamoDB, Cassandra или шардированный Postgres с шардом по хешу ключа. Подробнее о шардировании.
  • Узкое место — не база, а вирусная ссылка, которую разом открыл миллион человек: её спасает кэш на краю, а не шардинг.

Проверьте себя

Ключи выдаёт счётчик: base62(id). Безопасник говорит: «Я открыл sho.rt/aZ3kP9q, потом aZ3kP9r — и попал в чужой приватный документ». Что делать?

Биективная перестановка сохраняет уникальность счётчика — коллизий по-прежнему нет, проверка не нужна, — но соседние номера дают непохожие ключи. 41 бит ≈ 2,2 трлн номеров — меньше 62⁷, так что это всё ещё 7 символов base62. Хеш вернул бы проверку коллизий, время в ключе — та же предсказуемость, а длина без перемешивания ничего не меняет: …0001, …0002. Для приватных ссылок ещё и длиннее ключ, и проверка прав.
Задача 2 · распределённый rate limiter

Требования и алгоритмы

Лимиты по ключу (пользователь, API-ключ, IP) и правилу — «100 в минуту и 1000 в час». Общие для всех N шлюзов, накладные расходы меньше миллисекунды, ответ 429 с Retry-After и X-RateLimit-Remaining.

Лимит здесь — 10 запросов за 10 секунд. Выберите поток запросов и алгоритм: зелёные пропущены, красные получили 429. Особенно интересен всплеск на границе окна.

Token bucket в Redis: только атомарно


-- KEYS[1] = rl:{user}:api
-- ARGV: ёмкость, жетонов в секунду, цена
local cap, rate = tonumber(ARGV[1]), tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')      -- часы Redis
local now = t[1] * 1000 + t[2] / 1000
local b = redis.call('HMGET', KEYS[1], 'tok', 'ts')
local tok = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
tok = math.min(cap, tok + (now - ts) * rate / 1000)
local ok = tok >= cost
if ok then tok = tok - cost end
redis.call('HSET', KEYS[1], 'tok', tok, 'ts', now)
redis.call('PEXPIRE', KEYS[1],
           math.ceil(cap / rate * 1000))
return ok and 1 or 0

Скрипт выполняется в Redis целиком, другие команды между его строками не вклиниваются. {user} — хеш-тег: в Redis Cluster все ключи пользователя попадут в один слот.

Локальный или глобальный лимит и что, если Redis упал

  • Глобальный через Redis точен, но добавляет поход по сети на каждый запрос и делает Redis критичным.
  • Локальный — лимит/N на каждом шлюзе: без сети, но точен только при ровной балансировке и меняется при автомасштабировании.
  • При отказе: fail open — пропускать всех (защита от злоупотреблений пропала), fail closed — отказывать всем (сами устроили простой). Практичный вариант — откатиться на локальные лимиты.

Клиент с лимитом 100 запросов в секунду. Меняйте число шлюзов, балансировку и режим, затем уроните Redis.

Проверьте себя

Лимит «100 запросов в минуту» сделан фиксированным окном: INCR rl:{user}:{минута} + EXPIRE 60. Сколько запросов клиент может гарантированно провести за любые 60 секунд подряд?

100 запросов в последнюю секунду одной минуты и ещё 100 в первую секунду следующей: два окна, каждое в пределах лимита, а за две секунды прошло 200. Скользящий счётчик (взвешенная сумма текущего и прошлого окна) снимает проблему почти бесплатно: два числа вместо одного. А разные шлюзы ничего не меняют — INCR атомарен и счётчик общий.
Задача 3 · лента новостей

Требования, оценки, данные

  • Публикация поста, подписки, домашняя лента: посты тех, на кого подписан, новые сверху, с ранжированием и бесконечной прокруткой.
  • Лента открывается за 200 мс (p99). Новый пост может появиться у подписчиков через несколько секунд — eventual consistency здесь допустима.
  • Посты: post_id — 64-битный ID с временем внутри (Snowflake), сортируется по времени. Подписки — граф в обе стороны: «на кого я» и «кто на меня».
  • Медиа — в S3 за CDN, в ленте только ссылки.
200 млнактивных пользователей в день
1 200/сновых постов (100 млн/день), пик ×3
46 000/соткрытий ленты (20 на человека в день)
200подписок у типичного пользователя
100 млнподписчиков у крупнейших аккаунтов
~1 КБпост без медиа → 100 ГБ в день

POST /api/v1/posts  {"text", "media"}
GET  /api/v1/feed?cursor=…&limit=20
     → {"items": […], "next_cursor": "…"}
PUT  /api/v1/users/{id}/follow

Fan-out: при записи, при чтении или гибрид

  • При записи (push): пост сразу кладут в готовую ленту каждого подписчика. Чтение — один запрос, но пост знаменитости — 100 млн записей.
  • При чтении (pull): лента собирается при открытии из постов всех, на кого подписан. Записей нет, зато каждое чтение — сотни запросов и медленный хвост.
  • Гибрид: push для обычных авторов, pull для аккаунтов выше порога. Так делали Twitter и Instagram.

Выберите стратегию и двигайте порог. Столбики — сколько постов в секунду публикуют авторы с таким числом подписчиков; закрашенные идут через push.

Хранение ленты, ранжирование, курсоры


# fan-out: каждому подписчику автора
ZADD feed:{uid} {post_id} {post_id}
ZREMRANGEBYRANK feed:{uid} 0 -801   # 800 последних
# страница после курсора
ZREVRANGEBYSCORE feed:{uid} ({cursor} -inf LIMIT 0 20
  • В ленте только ID, тексты — пачкой из кэша постов. 800 ID × 8 Б × 200 млн = 1,3 ТБ, с накладными Redis в разы больше: это кластер. Неактивным ленту не строим.
  • Ранжирование: ~500 кандидатов → скор моделью → первые 20. Курсор тогда — непрозрачный токен.

Листайте ленту и добавляйте новые посты между страницами — сравните offset и курсор.

Задача 4 · мессенджер

Требования и оценки

  • Личные и групповые чаты (до ~1000 участников), история, несколько устройств, статус «в сети», квитанции «доставлено» и «прочитано», пуши.
  • Задержка доставки — сотни миллисекунд. Порядок внутри чата один для всех участников. Ни одно подтверждённое сообщение не теряется.
  • Доставка как минимум один раз + дедупликация по client_msg_id: клиент повторяет отправку, пока не получил ack. См. идемпотентность.
500 млнDAU, 40 сообщений в день
230 тыс/ссообщений в среднем, пик ~700 тыс
200 млноткрытых WebSocket в пике

→ send      {chat: 42, cid: "8f1e", body: "…"}
← ack       {cid: "8f1e", seq: 1041}
← msg       {chat: 42, seq: 1041, from: 7, body: "…"}
→ delivered {chat: 42, seq: 1041}
→ read      {chat: 42, seq: 1041}
→ sync      {chats: {42: 1037, 77: 310}}

Один шлюз держит 0,5–1 млн простаивающих соединений (десятки КБ памяти на каждое) → 200–400 серверов. Хранить историю — 20 млрд × ~200 Б = 4 ТБ в день до репликации. WhatsApp хранит сообщение только до доставки, Telegram — всегда: это первый вопрос к интервьюеру.

Шлюзы и реестр соединений

  • Клиент держит WebSocket с одним из шлюзов. Шлюз ничего не решает: принимает кадры и отдаёт их.
  • Реестр в Redis: user → gateway с TTL, который продлевает heartbeat. По нему сервис чата находит, куда отдать сообщение.
  • Сервис чата назначает seq, пишет в хранилище, только потом шлёт ack отправителю. Нет соединения — пуш через APNs/FCM, а сообщение заберут синхронизацией.

Отправляйте сообщения и смотрите путь по шагам. Отключите D, напишите ему, затем подключите снова.

Порядок и квитанции: номер в чате

  • Каждому сообщению сервис назначает seq — следующий номер в этом чате. Клиент показывает сообщения по seq, а не по времени прихода.
  • Дыра в номерах — сигнал: что-то потерялось или едет медленнее. Клиент ждёт, а потом просит sync after=N.
  • «Доставлено» и «прочитано» — это указатели «до какого seq», а не флаг на каждом сообщении: одна запись вместо тысячи.

Отправьте пачку сообщений: сеть доставит их вперемешку. Включите потери и посмотрите, как клиент обнаруживает дыру.

Хранилище, группы и пуши


-- Cassandra / ScyllaDB
CREATE TABLE messages (
  chat_id   bigint,
  bucket    int,     -- окно в 10 дней
  seq       bigint,
  sender_id bigint,
  cid       uuid,    -- для дедупликации
  body      blob,
  PRIMARY KEY ((chat_id, bucket), seq)
) WITH CLUSTERING ORDER BY (seq DESC);

Так хранит сообщения Discord: «последние 50 сообщений чата» — одно чтение подряд из одной партиции, а окно не даёт партиции расти вечно. Запись только дописывается — LSM-движку это идеально.

  • Откуда seq. Все сообщения чата обрабатывает один владелец — шард сервиса по chat_id — и считает номера в памяти, восстанавливая счётчик из базы при переезде. Глобальный счётчик или LWT на каждое сообщение слишком дороги.
  • Группы. Маленькая группа — fan-out при записи: сообщение уходит каждому онлайн-участнику, у офлайн растёт счётчик непрочитанного. Канал на 100 тыс. — fan-out при чтении, как знаменитости в ленте.
  • Пуши. Нет соединения — событие в очередь пушей. Пачкуем и схлопываем («5 новых сообщений»), с шифрованием end-to-end текст в пуш не кладём.
  • Узкие места: переподключение миллионов клиентов после падения шлюза (джиттер в повторах), огромные группы, горячие чаты.

Проверьте себя

В группе три участника пишут одновременно с разных шлюзов. Как сделать, чтобы все увидели сообщения в одном и том же порядке?

Часы телефонов врут на минуты, часы серверов расходятся на миллисекунды — и при одинаковых метках порядок не определён. Единственный источник порядка на чат даёт полный порядок дёшево: один чат — одна точка, а чатов миллионы, и они спокойно распределяются по шардам. Глобальный порядок между чатами никому не нужен.

Чего ждёт интервьюер

  • Уточнили требования и масштаб до первого квадратика. Назвали, что вне рамок.
  • Посчитали QPS, объём и пики — и сделали из чисел выводы: «чтение надо кэшировать», «в один сервер не влезет».
  • API и модель данных до схемы; ключ партиционирования выбран по тому, как данные читают.
  • Сначала простая и целиком рабочая схема, потом углубление в 2–3 трудных места: генерация ключей, атомарность лимита, fan-out, порядок сообщений.
  • Каждое решение — с альтернативой и ценой: 301/302, push/pull, fail open/closed, хранить историю или нет.
  • Отказы и узкие места: что будет, если упадёт Redis, шлюз, шард; вирусная ссылка, знаменитость, огромная группа.
  • Вы ведёте разговор, следите за временем и слышите подсказки интервьюера.

Правильного ответа нет. Есть обоснованные компромиссы.