MBL
Go / avito / Повторение / System design: кэш, масштабирование, CAP, URL shortener
Go средний

System design: кэш, масштабирование, CAP, URL shortener

system-design

4 темы system design: базовые строительные блоки (балансировщик/кэш/очередь/CDN), масштабирование, CAP-теорема и разбор классического кейса URL shortener. Ниже — не просто определения, а конкретные сценарии "что пойдёт не так, если сделать иначе".

Балансировщик, кэш, очередь, CDN

Балансировщик распределяет входящие запросы между несколькими инстансами сервиса. Round-robin — самый простой алгоритм: запросы раздаются по кругу, инстанс 1, инстанс 2, инстанс 3, снова инстанс 1 — не требует отслеживать состояние инстансов, но и не смотрит, насколько каждый из них реально загружен прямо сейчас. Least connections — балансировщик держит счётчик активных соединений на каждый инстанс и отправляет новый запрос туда, где сейчас меньше всего активной работы — точнее отражает реальную нагрузку, но требует отслеживать и обновлять это состояние на каждый запрос.

Cache-aside (он же lazy loading) — приложение САМО решает, когда идти в кэш, а когда в БД: сначала проверяет кэш, при промахе (кэш пуст или запись устарела) идёт в БД, получает данные и КЛАДЁТ их в кэш для следующих читателей.

Ключевая опасность этого паттерна — не в чтении, а в записи: если после UPDATE users SET name = 'Новое имя' WHERE id = 42 забыть явно удалить (cache.Delete("user:42")) или обновить соответствующую запись в кэше, следующий читатель получит из кэша СТАРОЕ имя вплоть до истечения TTL — это не гипотетическая ловушка, а буквально самая частая причина бага "я обновил данные, а на сайте всё равно старые" в реальных системах.

Write-through решает именно эту проблему инвалидации иначе: каждая запись сразу идёт И в кэш, И в БД синхронно, одной операцией — кэш физически не может устареть относительно БД, потому что обновляется тем же самым действием. Цена — каждая запись становится дороже (два места записи вместо одного), и в кэш попадают данные, которые, возможно, никто никогда не прочитает (в отличие от cache-aside, где в кэш попадает только то, что реально запросили).

Очередь разрывает синхронную связь между "запрос пришёл" и "запрос обработан целиком": тяжёлая или не срочная работа (отправка email, генерация отчёта, обработка изображения) ставится в очередь, а пользователь сразу получает ответ "принято в обработку", не дожидаясь, пока вся эта работа реально закончится. CDN решает другую задачу — физическую удалённость: статический файл (картинка, JS-бандл) копируется на серверы, географически близкие к пользователю, поэтому запрос идёт не через полмира к основному серверу, а до ближайшей копии — это снижает и задержку, и нагрузку на основной сервер, который эти же файлы не отдаёт вообще.

Масштабирование: horizontal vs vertical, шардирование, репликация

Вертикальное масштабирование (scale up) — сделать одну машину мощнее: больше CPU, больше RAM. Просто настроить (обычно вообще ничего не меняется в архитектуре приложения), но упирается в физический потолок конкретного железа, и вся система по-прежнему зависит от одной машины — она же единая точка отказа.

Горизонтальное масштабирование (scale out) — добавить больше машин вместо того, чтобы делать одну мощнее. Практического потолка почти нет (добавляй ещё серверы), но требует, чтобы приложение вообще умело так работать: нельзя хранить состояние (сессии, файлы, кэш) ЛОКАЛЬНО на одном конкретном сервере, если следующий запрос того же пользователя может прийти на другой сервер за балансировщиком.

Шардирование и репликация решают принципиально разные задачи, и их легко перепутать именно из-за похожего звучания. Шардирование РАЗДЕЛЯЕТ данные: пользователи с ID 1-1000000 лежат на сервере A, с ID 1000001-2000000 — на сервере B. Ни один шард не содержит ВСЕХ данных — если сервер A упал, данные пользователей 1-1000000 недоступны, даже если сервер B прекрасно работает.

Репликация ДУБЛИРУЕТ: одни и те же данные целиком лежат на серверах A, B и C одновременно — если A упал, B и C по-прежнему содержат все те же данные, доступность сохраняется. Реальные системы почти всегда комбинируют оба: шардинг решает проблему ОБЪЁМА (данные не помещаются на одну машину), а репликация ВНУТРИ каждого шарда решает проблему ОТКАЗОУСТОЙЧИВОСТИ (один шард не становится единой точкой отказа сам по себе).

CAP-теорема

Три свойства распределённой системы: Consistency — все узлы в любой момент отдают одинаковые (актуальные) данные. Availability — каждый запрос к живому узлу получает ответ, пусть даже не самый свежий. Partition tolerance — система продолжает работать, даже если сеть между частью узлов разорвана и они не могут общаться друг с другом.

«Выбери любые 2 из 3» — формулировка, которая на собеседовании скорее вредит, чем помогает, если повторить её буквально. Partition tolerance на практике не факультативна — сеть между дата-центрами рвётся независимо от желания архитекторов системы, это данность физического мира, а не опция, которую можно "не выбирать". Значит, реальный содержательный выбор происходит НЕ заранее между тремя абстрактными буквами, а В КОНКРЕТНЫЙ МОМЕНТ, когда разрыв сети уже произошёл, и он всегда именно между C и A:

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

  • Выбор в пользу Availability: узел отвечает сам, используя свою локальную копию данных, даже не зная точно, не списал ли уже кто-то этот же баланс на другой стороне разрыва. Система остаётся доступной, но рискует консистентностью — возможен двойной расход одних и тех же денег.
  • Выбор в пользу Consistency: узел ОТКАЗЫВАЕТСЯ отвечать на запрос, пока не может подтвердить актуальность данных со вторым дата-центром. Данные останутся согласованными (никакого двойного списания), но конкретно этот запрос получит ошибку/таймаут вместо ответа — недоступность.

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

URL shortener — самый частый учебный кейс

Первый шаг на собеседовании ВСЕГДА — уточнение требований, а не сразу рисование схемы: сколько операций в день, каково соотношение чтение:запись (для shortener'а оно обычно СИЛЬНО смещено в сторону чтения — см. ниже почему), нужна ли аналитика переходов. Только после этого имеет смысл говорить об API и схеме данных.

Генерация короткого кода — конкретный алгоритм, не абстракция. Практический способ — взять числовой auto-increment ID из БД и закодировать его в base62 (цифры + строчные + заглавные латинские буквы, 62 символа алфавита) — это даёт компактную строку без коллизий по самому построению (два разных ID никогда не дадут одинаковый код, потому что кодирование обратимо):

на play.golang.org

Видно, что даже ID почти в триллион (999999999999) даёт код всего из 7 символов — это и есть выигрыш base62 по сравнению, например, с hex-кодированием того же числа. Альтернатива через ХЭШ URL (например, первые несколько символов от sha256(url)) детерминирована (тот же URL всегда даст тот же код, что может быть плюсом или минусом), но требует явно разрешать коллизии, потому что разные URL теоретически могут дать одинаковый укороченный хэш — обычный способ разрешения: если код уже занят другим URL, добавить к исходной строке "соль" и перехэшировать.

Почему нагрузка на чтение доминирует. Создание короткой ссылки происходит один раз, а переходов по ней — потенциально тысячи (ссылку могут репостнуть в соцсетях, разослать в рассылке). Значит, основной источник нагрузки — операция "по короткому коду найти оригинальный URL и сделать редирект", а не операция создания. Отсюда прямой практический вывод: кэш популярных коротких кодов (те самые cache-aside/write-through паттерны из первого раздела) даёт основной выигрыш в производительности именно здесь, а не в оптимизации записи.

301 vs 302 для самого редиректа — выбор с прямым влиянием на аналитику, а не просто "какой код вернуть". 301 Moved Permanently браузеры и промежуточные прокси кэшируют агрессивно и подолгу — это снижает нагрузку на сервер (повторные переходы того же пользователя могут вообще не долетать до сервера, браузер помнит редирект сам), но именно поэтому плохо подходит, если продукту нужна аналитика КАЖДОГО перехода (например, посчитать точное число кликов по ссылке) — часть переходов сервер просто не увидит.

302 Found (temporary) браузеры не кэшируют так же агрессивно, каждый переход гарантированно доходит до сервера заново — ценой чуть большей нагрузки на инфраструктуру redirect-сервиса, зато с полной и точной статистикой переходов.

Самопроверка 0 / 4
Не путаю шардирование (разделяет данные) и репликацию (дублирует данные)
Понимаю, почему "выбери 2 из 3" в CAP — упрощение, а реальный выбор — между C и A в момент разрыва сети
Знаю, почему 301 вредит аналитике переходов, а 302 — нет
Помню, что забытая инвалидация кэша после UPDATE — не гипотетическая, а самая частая практическая причина "почему у пользователя старые данные"
Как усвоено?