Спроектируйте сокращатель URL
Проектируем TinyURL: read-heavy система ~100:1, где вся игра — путь редиректа. Сравниваем счётчик+base62, хеширование и сервис генерации ключей; кешируем агрессивно; отдаём 301/302 за единицы миллисекунд.
Один инженер отмахнулся от «спроектируй сокращатель 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^7≈ 3,5 триллиона кодов. Семь символов с запасом покрывают миллиарды ссылок на десятилетия — поэтому 7 — целевая длина. - Размер кеша (правило 80/20): если 20% ссылок дают 80% чтений, кеширование горячих 20% — скажем самых активных сотен миллионов кодов по ~500 байт — это десятки–низкие сотни ГБ, влезает в RAM небольшого Redis-кластера и обслуживает подавляющее большинство редиректов из памяти.
Итоговые числа: записи — ничто, чтения — всё, а кеш поглощает чтения. Теперь единственное реальное решение — как чеканить короткий код.
Высокоуровневый дизайн
Запись попадает на app-сервер, который генерирует код и кладёт связку code → longURL в key-value хранилище. Чтение (GET /{code}) сначала проверяет кеш; при попадании сразу возвращает редирект, при промахе читает хранилище, заполняет кеш и редиректит.
Глубокое погружение
Чеканка короткого кода
Это единственное по-настоящему интересное решение, и претендентов три.
(1) Хешировать длинный URL — base62(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-лимиты, влияющие на доступность вещи, которые игрушечная версия игнорирует.
- 01Почему отношение чтений к записям — определяющее свойство и что подразумевает оценка?
- 02Сравни три схемы коротких кодов и скажи, когда какую.
- 03В чём компромисс 301-против-302 и где место аналитики?
Сокращатель URL выглядит тривиальным и является обратным: его определяющее свойство — отношение чтений к записям ~100:1, поэтому весь дизайн оптимизирует одну операцию — найти короткий код и редиректнуть — до единиц миллисекунд и делает её неубиваемой. Оценка подтверждает: ~40 записей/с — ничто, ~12K чтений/с на пике — всё, а ~3 ТБ за пять лет значат, что кешируешь горячие 20%, а не весь датасет. Единственное реальное решение — чеканка кода: хеширование URL сталкивается на усечении и облагает каждую запись; счётчик + base62 уникален по построению, но глобальное узкое место (чини выделением диапазонов) и даёт гадаемые последовательные коды; сервис генерации ключей заранее чеканит случайные уникальные коды офлайн, так что путь записи никогда не сталкивается, а алиасы остаются чистыми. Путь редиректа — cache-aside над key-value хранилищем, а выбор 301-против-302 — реальный рычаг: 301 кеширует в браузерах ради срезания нагрузки, но убивает аналитику и фиксирует назначение, 302 хранит и то и другое ценой обслуживания каждого клика. Уведи аналитику с горячего пути в очередь, скоалесь stampede (одновременный шквал запросов при промахе кеша) на свежесозданной горячей ссылке и поставь rate-лимиты на злоупотребление, о котором игрушечная версия и не думала. Теперь, когда видишь задачу «спроектируй сокращатель URL», первое, что произносишь — отношение чтений к записям: это число, и только оно, решает, куда идёт настоящая работа.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.