open atlas
↑ К треку
Разборы System Design SDC · 01 · 02

Спроектируйте сокращатель URL

Проектируем TinyURL: read-heavy система ~100:1, где вся игра — путь редиректа. Сравниваем счётчик+base62, хеширование и сервис генерации ключей; кешируем агрессивно; отдаём 301/302 за единицы миллисекунд.

SDC Senior ◷ 28 min
Уровень
ОсновыJuniorMiddleSenior

Один инженер отмахнулся от «спроектируй сокращатель URL» как от пятиминутной игрушки: таблица из короткого кода в длинный URL, что тут проектировать? Тогда интервьюер задал вопрос, ломающий игрушку: «Это ссылка в рекламе на Супербоуле. Триста миллионов человек видят её, треть тапает в одни и те же две минуты. Что произойдёт?» Игрушечная таблица за одной базой плавится — каждый тап это чтение, а один реляционный primary упирается в низкие тысячи чтений в секунду. Настоящая мысль, которую игрушка прячет: сокращатель — не задача записи, а задача чтения с прикрученной сбоку задачей записи. Код создаётся раз, а редирект отдаётся сто тысяч раз, поэтому почти каждое решение дизайна — про то, как сделать одну операцию — найти короткий код и редиректнуть — абсурдно быстрой и неубиваемой.

Требования

Функциональные

  • shorten(longURL) → shortURL — вернуть короткий код, в идеале 7 символов или меньше.
  • GET /{code} — редиректнуть браузер на исходный длинный URL.
  • Кастомные алиасы: вызывающий может попросить конкретный код (/sale), если он свободен.
  • Истечение: ссылки могут иметь TTL; истёкшие коды отдают 404.
  • Базовая аналитика: счётчики кликов, в идеале без замедления редиректа.

Нефункциональные

  • Задержка редиректа — это SLO. Редирект должен разрешаться за единицы миллисекунд — он стоит перед загрузкой чужой страницы.
  • Доступность над согласованностью на чтениях. Редирект обязан работать, даже если путь записи деградировал; недоступность совсем новой ссылки на секунду терпима, 404 на популярной — нет.
  • Уникальность. Никакие два длинных URL не должны столкнуться на один код так, чтобы увести пользователя не туда.
  • Масштаб. Миллиарды ссылок суммарно, чтения многократно превосходят записи.

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

Оценка

  • Пусть 100M новых URL/месяц. Это 10^8 / (30 × 10^5 с)~40 записей/с в среднем, ~120/с на пике 3×. Записи — мелочь.
  • При отношении 100:1 это ~4 000 чтений/с в среднем, ~12 000/с на пике. Чтения — это и есть система.
  • Хранение: 100M/месяц × 12 месяцев × 5 лет = ~6 миллиардов URL. По ~500 байт на строку (длинный URL + код + метаданные) → ~3 ТБ. Большое, но обычное; оно не влезает в RAM одного узла, поэтому кешировать надо рабочий набор, а не весь датасет.
  • Пространство ключей: при base62 (a–z A–Z 0–9) 62^73,5 триллиона кодов. Семь символов с запасом покрывают миллиарды ссылок на десятилетия — поэтому 7 — целевая длина.
  • Размер кеша (правило 80/20): если 20% ссылок дают 80% чтений, кеширование горячих 20% — скажем самых активных сотен миллионов кодов по ~500 байт — это десятки–низкие сотни ГБ, влезает в RAM небольшого Redis-кластера и обслуживает подавляющее большинство редиректов из памяти.

Итоговые числа: записи — ничто, чтения — всё, а кеш поглощает чтения. Теперь единственное реальное решение — как чеканить короткий код.

Высокоуровневый дизайн

Запись попадает на app-сервер, который генерирует код и кладёт связку code → longURL в key-value хранилище. Чтение (GET /{code}) сначала проверяет кеш; при попадании сразу возвращает редирект, при промахе читает хранилище, заполняет кеш и редиректит.

Глубокое погружение

Чеканка короткого кода

Это единственное по-настоящему интересное решение, и претендентов три.

(1) Хешировать длинный URLbase62(md5(longURL))[:7]. Заманчиво, ведь одинаковые URL естественно дедуплицируются, но коды на 62^7, взятые из хеша, будут сталкиваться по мере роста (парадокс дней рождения кусает куда раньше, чем намекает размер пространства). Каждая коллизия требует проверки в базе и пере-хеша/соли, добавляя чтение к каждой записи — и не уважает кастомные алиасы. Рабочее, но налог на обработку коллизий растёт с таблицей.

(2) Счётчик + base62 — держать глобальный монотонный счётчик, и короткий код — это значение счётчика в base62. Коллизий никогда (каждое значение уникально по построению), нет чтения-перед-записью, и коды коротки, пока счёт мал. Проблема в том, что счётчик — глобальная точка сериализации: один центральный счётчик это узкое место и SPOF. Починка — схема выделения диапазонов: координатор раздаёт каждому app-серверу блок ID (скажем миллион) на локальную трату, так что серверы координируются раз на блок, а не на запись. (Это ровно пререквизит про генерацию уникальных ID, применённый к дружелюбному короткому алфавиту base62.)

(3) Сервис генерации ключей (KGS) — отдельный сервис заранее генерирует случайные уникальные 7-символьные коды офлайн и хранит их в «БД ключей» с двумя таблицами: доступные и использованные. App-серверы берут пачку ключей, раздают их и помечают как использованные. Раз ключи чеканятся заранее, а уникальность проверяется один раз при генерации, путь записи никогда не сталкивается и не блокируется — он просто потребляет проверенный ключ. Цена — сам KGS (нельзя выдать один ключ дважды, нужна репликация, чтобы он не был SPOF), но он даёт непоследовательные, негадаемые коды и чистую поддержку кастомных алиасов.

Выбери лучший вариант

Сокращатель URL должен генерировать 7-символьные коды, которые нельзя угадать (последовательные коды позволят перебрать приватные ссылки), поддерживать кастомные алиасы вроде /sale и никогда не давать коллизий — даже на миллиардах ссылок. Какая схема генерации кодов подходит?

Почему это работает

Почему предпочесть счётчик или KGS хешированию, когда хеширование кажется проще? Потому что хеширование платит налог на каждой записи вечно: хеш, влезающий в 7 символов base62, куда меньше полного вывода хеша, поэтому ты усекаешь, а усечение сталкивается — значит каждая запись обязана читать таблицу для проверки, а при попадании солить и повторять. По мере роста таблицы к миллиардам строк частота коллизий лезет вверх, так что среднее число обращений к базе на запись подползает. Счётчик обходит это целиком (уникальность структурна, ноль чтений), а KGS переносит проверку уникальности в одноразовый офлайн-шаг. Глубокий принцип: в системе записал-раз/читай-много нельзя платить повторяющуюся цену на пути чтения или записи за проблему, решаемую один раз на этапе генерации.

Тонкая протечка: чистый счётчик делает коды последовательными и гадаемыми — кто угодно может перебрать /1, /2, /3 и выскрести все ссылки, проблема приватности для личных шарингов. Кодирование счётчика в base62 всё равно сохраняет порядок. Если негадаемость важна, либо используй KGS (случайные ключи), либо прогоняй счётчик через обратимую перетасовку (например перестановку Фейстеля/умножения) перед кодированием, чтобы соседние ID отображались в разбросанные коды.

Путь редиректа и кеширование

Редирект — горячий путь, поэтому он cache-aside над key-value хранилищем: проверить кеш, отдать при попадании, а при промахе прочитать хранилище, дозаполнить кеш и редиректнуть. При паттерне доступа 80/20 доля попаданий в устойчивом состоянии очень высока, так что база видит ручеёк чтений холодных ссылок, пока кеш поглощает поток. Само хранилище — это key-value поиск (code → longURL), поэтому и партиционированное KV-хранилище, и шардированная реляционная таблица по code оба работают — нет джойнов, которые надо сохранить.

301 против 302 — статус редиректа, решающий всё

HTTP-статус на редиректе — настоящий рычаг дизайна, а не деталь:

  • 301 (Permanent): браузеры и прокси кешируют связку, так что последующие визиты на короткую ссылку полностью минуют твой сервер и идут прямо на длинный URL. Это срезает нагрузку чтения — но также значит, что ты перестаёшь видеть эти клики (сломанная аналитика) и никогда не сможешь переменить, куда указывает ссылка.
  • 302 (Found / временный): браузер не кеширует связку, так что каждый визит возвращается через твой сервер. Ты хранишь полную аналитику и сохраняешь возможность перенаправить или истечь ссылку — ценой обслуживания каждого клика.

Выбор — прямой компромисс: 301 ради нагрузки, 302 ради контроля. Сокращатель, монетизирующий аналитику кликов, использует 302 и ест трафик чтения (ровно поэтому кеш так важен); чистый редиректор, которому нужна минимальная инфраструктура, может использовать 301 и дать браузерам кешировать.

Частая ошибка

Частая ошибка — отгрузить 301, потому что он «снижает нагрузку», а потом обнаружить, что дашборд аналитики пуст, а опечатанное назначение теперь навсегда закешировано в миллионах браузеров без способа починить. Урок: выбирай статус из продукта, а не из графика нагрузки. Если клики — это продукт (большинство коммерческих сокращателей), 302 обязателен, и ты размеряешь кеш и слой чтения, чтобы поглотить каждый клик. Если ссылка — статичная пермассылка без аналитики и без нужды когда-либо перенаправлять, 301 годится и даёт бесплатное снижение нагрузки. Выбор 301 по умолчанию ради «производительности» тихо закрывает две фичи, которые тебе, вероятно, были нужны.

Викторина

Сокращатель отдаёт ~12 000 редиректов/с на пике, но лишь ~40 новых ссылок/с. Новый инженер предлагает масштабировать путь записи большим числом app-серверов и мощным счётчиком. В чём недочтение?

Закончи аналогию

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

Узкие места и компромиссы

  • Счётчик как узкое место/SPOF. Один глобальный счётчик сериализует каждую запись и умирает со своим узлом. Выделение диапазонов (раздавать блоки) снимает координацию на запись ценой непоследовательных ID, если сервер упал посреди блока (эти ID просто сгорают — нормально, пространство ключей огромно).
  • Cache stampede на горячей новой ссылке. Когда та ссылка с Супербоула создана, она ещё не в кеше, так что первый всплеск весь промахивается и одновременно молотит хранилище. Митигация: коалесинг запросов (single-flight: один промах фетчит, остальные ждут) или прогрев известно-горячих ссылок.
  • Аналитика на горячем пути. Считать клики синхронно на каждом редиректе — добавить запись в путь чтения, худшее место для работы. Развяжи: отправь клик в очередь сообщений и агрегируй асинхронно, так редирект остаётся чистым чтением кеша, а аналитика отстаёт на секунды, что нормально.
  • Конкуренция и злоупотребление кастомными алиасами. Кастомные алиасы требуют проверки уникальности (реальное чтение-перед-записью, но лишь для этого пути), а весь сервис — мечта спамера (сокращать ссылки с малварью) — так что реальному сокращателю нужны сканирование URL/блоклисты и rate-лимиты, влияющие на доступность вещи, которые игрушечная версия игнорирует.
Вспомните перед уходом
  1. 01
    Почему отношение чтений к записям — определяющее свойство и что подразумевает оценка?
  2. 02
    Сравни три схемы коротких кодов и скажи, когда какую.
  3. 03
    В чём компромисс 301-против-302 и где место аналитики?
Итог

Сокращатель URL выглядит тривиальным и является обратным: его определяющее свойство — отношение чтений к записям ~100:1, поэтому весь дизайн оптимизирует одну операцию — найти короткий код и редиректнуть — до единиц миллисекунд и делает её неубиваемой. Оценка подтверждает: ~40 записей/с — ничто, ~12K чтений/с на пике — всё, а ~3 ТБ за пять лет значат, что кешируешь горячие 20%, а не весь датасет. Единственное реальное решение — чеканка кода: хеширование URL сталкивается на усечении и облагает каждую запись; счётчик + base62 уникален по построению, но глобальное узкое место (чини выделением диапазонов) и даёт гадаемые последовательные коды; сервис генерации ключей заранее чеканит случайные уникальные коды офлайн, так что путь записи никогда не сталкивается, а алиасы остаются чистыми. Путь редиректа — cache-aside над key-value хранилищем, а выбор 301-против-302 — реальный рычаг: 301 кеширует в браузерах ради срезания нагрузки, но убивает аналитику и фиксирует назначение, 302 хранит и то и другое ценой обслуживания каждого клика. Уведи аналитику с горячего пути в очередь, скоалесь stampede (одновременный шквал запросов при промахе кеша) на свежесозданной горячей ссылке и поставь rate-лимиты на злоупотребление, о котором игрушечная версия и не думала. Теперь, когда видишь задачу «спроектируй сокращатель URL», первое, что произносишь — отношение чтений к записям: это число, и только оно, решает, куда идёт настоящая работа.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.