Проектируем «друзья рядом»
Проектируем «друзья рядом» в реальном времени: принимаем поток гео-пингов (write-heavy), маршрутизируем обновления через pub/sub по регионам к подписчикам по WebSocket, фанаутим лишь тем, кому надо, и делаем приватность и TTL первоклассными.
Кофейни из прошлого урока не двигаются. Ваши друзья двигаются — постоянно, все разом, по всей карте. Переверните read-heavy задачу поиска поблизости — и она станет потоком записи: каждый телефон пингует свою локацию каждые несколько секунд, и на каждый пинг вы должны в реальном времени понять, кому из друзей этого пользователя достаточно близко, чтобы их это волновало, и протолкнуть обновление на их экраны. Наивная схема — писать каждый пинг в БД, а каждый клиент опрашивает «где все?» каждую секунду — рушится дважды: записи насыщают базу, а опросы множатся в стадо. Интересная инженерия — в том, чтобы не доставлять большинство обновлений: гео-пинг должен достичь лишь горстки тех, для кого он реально что-то изменил.
К концу урока ты поймёшь, почему опрос рушится под 2 млн записей в секунду, как региональная ячейка становится ключом маршрутизации и что делает приватность структурной, а не задачей для очистки.
Требования
- Функциональные: пользователь делится локацией; друзья в радиусе (скажем, 5 км) видят его появление и движение почти в реальном времени. Когда друг выходит из радиуса — исчезает. Шаринг — по согласию и отзывной.
- Нефункциональные: write-heavy — пинг каждые несколько секунд на активного пользователя. Низкая сквозная задержка (пара секунд, не минут). Обновлениям не нужна долговечность; потерянный пинг заменяется следующим через секунды. Приватность — жёсткое требование, не фича: локации должны истекать и никогда не утекать к не-друзьям.
Определяющая инверсия относительно прошлого урока: там данные статичны и доминируют чтения. Здесь данные в постоянном движении и доминируют записи, а «запрос» (кто рядом с кем) надо отвечать непрерывно, а не по требованию.
Оценки
Пусть 100 млн пользователей, 10 млн активны и делятся локацией в любой момент, каждый пингует каждые 5 секунд. Это 10^7 / 5 = 2×10^6 записей локации в секунду — поток, который не выдержит ни один реляционный primary. Заметьте, что не нужно: долговечность или история. Локация интересна секунды, потом бесполезна. Этот единственный факт — эфемерные, высокообъёмные, малоценные за штуку данные — указывает прямо на in-memory истекающее хранилище, а не дисковую базу. Сторона чтения ограничена иначе: каждый пинг должен фанаутиться лишь онлайн-друзьям этого пользователя в радиусе, что для типичного соцграфа — единицы, не миллионы.
Высокоуровневая схема
Телефоны не опрашивают; они держат постоянное соединение (WebSocket) к шлюзу, чтобы сервер мог толкать обновления в момент события. Входящий пинг пишется в быстрое эфемерное хранилище по ключу региональной ячейки, затем публикуется в pub/sub-канал региона; подписчики, интересующиеся этим регионом, получают его, а шлюз толкает по нужным сокетам.
Два глубоких механизма — слой постоянных соединений, делающий push возможным, и pub/sub по региону, не дающий фанауту взорваться.
Глубокое погружение
WebSocket и слой соединений
Push в реальном времени требует, чтобы сервер слал без запроса клиента, что исключает опрос «запрос/ответ». WebSocket апгрейдит обычное HTTP-соединение в полнодуплексный канал, который остаётся открытым, чтобы шлюз стримил обновления без рукопожатия на сообщение. Архитектурное следствие — система теперь stateful на краю: 10 млн делящихся пользователей — это 10 млн открытых соединений, каждое привязано к конкретному процессу-шлюзу, знающему, какого пользователя и какие сокеты он держит. Масштабируется горизонтально — много узлов-шлюзов, балансировщик, осознающий соединения, и реестр пользователь → шлюз, чтобы сообщение для пользователя ушло на узел с его сокетом.
▸Почему это работает
Почему не опрашивать каждую секунду? Две причины, обе про асимметрию стоимости. Во-первых, опрос платит полный HTTP запрос/ответ (часто и возобновление TLS) за каждую проверку, даже если ничего не изменилось — а подавляющее большинство опросов возвращают «нет изменений», так что вы жжёте CPU и трафик, производя не-ответы. Во-вторых, опрос фиксирует задержку на интервал опроса: опрос раз в секунду — это до секунды устаревания, даже когда обновление было готово мгновенно, а укорачивание интервала множит нагрузку. Постоянное соединение инвертирует оба: сервер шлёт ровно когда есть новость, клиент платит за соединение один раз, а задержка падает до сетевого транзита. Цена — состояние на сервере: миллионы открытых сокетов в управлении — обмен, на который вы идёте ради push в реальном времени.
Pub/sub по региону и проблема фанаута
Дорогая ошибка — вещание. Если бы каждый пинг шёл к каждому соединению, 2 млн записей/с на миллионы соединений — квадратичный взрыв, который не выдержит кластер. Фикс — сделать локацию ключом маршрутизации. Делим мир на региональные ячейки (идея geohash/H3 из урока 01, переиспользована тут для маршрутизации, а не поиска). Каждый пинг публикуется ровно в один канал — его текущую региональную ячейку. Шлюз подписан только на ячейки, где держит делящихся или наблюдающих пользователей. Так пинг в центре Берлина доставляется только подписчикам берлинских центральных ячеек, никогда — в Токио.
Это превращает вещание всем-всем в локализованный фанаут: число получателей пинга ограничено «онлайн-друзьями сейчас рядом с этой локацией», что мало. Когда пользователь пересекает границу ячейки, его клиент (или шлюз) переподписывается на новую ячейку и отписывается от старой — та же осознанность границ, что в уроке 01, теперь к движущейся подписке, а не статичному запросу. Redis pub/sub, Kafka топик-на-регион или выделенная шина сообщений — все реализуют ту же форму.
▸Частая ошибка
Соблазнительная, но неверная оптимизация — фанаутить на запись: когда приходит пинг, найти друзей пользователя, проверить последнюю известную локацию каждого и толкнуть напрямую близким. Кажется, что это пропускает слой pub/sub. На масштабе проваливается, потому что делает дорогой join по графу + гео на самом горячем пути (2 млн раз/с), связывает сервис приёма с соцграфом и присутствием и переделывает эту работу на каждый пинг, даже когда никто значимо не сдвинулся. Pub/sub по региону развязывает поток с подбором друзей: приём просто публикует в ячейку, а относительно дешёвый фильтр «этот публикатор — мой друг и ещё в радиусе?» бежит на подписчике, на потоке, уже суженном до одного региона. Толкайте фильтрацию туда, где данные уже малы.
Приватность и TTL
Приватность не может быть слоем, который добавляют потом; она формирует модель данных. Каждая хранимая локация несёт TTL в несколько секунд-минуту, так что телефон, который замолчал — приложение закрыто, батарея села, шаринг отозван — просто истекает из хранилища без явного удаления. Это даёт семантику «видели только что» бесплатно и гарантирует, что устаревшие локации не задерживаются как угроза приватности. Поверх TTL — три правила: шаринг по согласию и по-отношению (могу делиться с Алисой, но не с Бобом); фильтр фанаута проверяет дружбу, чтобы подписчик региона никогда не получил координаты не-друга (каналы региона несут пинги, но край роняет любой, чей публикатор не авторизованный друг получателя); и сервер хранит грубую точность, где продукт позволяет, ведь «в этом районе» утекает куда меньше, чем точный GPS.
Узкие места и компромиссы
Главное узкое место — состояние соединений, не пропускная способность отдельного сообщения. Миллионы долгоживущих сокетов тяжелы по памяти и файловым дескрипторам, переживают деплои неуклюже (rolling restart роняет каждое соединение, если не сливать аккуратно) и требуют реестра маршрутизации, который сам должен масштабироваться. Главный компромисс — свежесть против стоимости: пинг раз в секунду ощущается живым, но учетверяет нагрузку записи против пинга раз в пять секунд за маржинальную выгоду, так что адаптивная частота пинга (медленнее при покое, быстрее при движении) покупает большую часть ощущения реального времени за долю записей. И глубочайший компромисс — тот, на котором держится вся схема: мы намеренно жертвуем долговечностью и историей, чтобы пережить объём; если продукт позже захочет «где был мой друг час назад», это отдельное, долговечное, пакетно-пишущее хранилище, никогда не живой путь.
Схема «друзья рядом» принимает 2 млн гео-пингов/с. Команда пишет каждый пинг в primary SQL и заставляет клиентов опрашивать «где мои друзья?» раз в секунду. Падает мгновенно. Какие два структурных фикса?
Чтобы доставить гео-пинг нужным людям, инженер предлагает: на каждый пинг загрузить список друзей публикатора, достать локацию каждого и толкнуть близким напрямую. Почему pub/sub по региону предпочтительнее при 2 млн пингов/с?
Хранимые локации несут короткий _______, так что телефон, который замолчал — приложение закрыто, шаринг отозван — просто истекает из хранилища без явного удаления, давая семантику «видели только что» и гарантируя, что устаревшие позиции не задержатся как угроза приватности.
- 01Почему «друзья рядом» — write-heavy, и какую модель хранения и доставки это навязывает?
- 02Как pub/sub по региону решает проблему фанаута?
- 03Как приватность делается структурной, и каковы узкие места и компромиссы?
«Друзья рядом» — инверсия поиска поблизости: локации постоянно движутся и доминируют записи (≈2 млн пингов/с для 10 млн делящихся), но каждая локация эфемерна — долговечность и история не нужны. Это навязывает in-memory истекающее по TTL хранилище вместо базы и push по WebSocket вместо опроса, так что сервер шлёт в момент новости ценой миллионов stateful соединений на краю. Фанаут укрощается тем, что локация становится ключом маршрутизации: каждый пинг публикуется ровно в один канал региональной ячейки, шлюзы подписаны лишь на ячейки со своими пользователями, а дешёвый фильтр «друг и в радиусе» бежит на краю-подписчике — никогда как join по графу+гео на потоке приёма (ловушка фанаута-на-запись). Приватность структурна: авто-истечение TTL, согласие по-отношению, фильтр дружбы на краю и грубая точность где можно. Узкое место — состояние соединений, не пропускная способность сообщений; ключевые компромиссы — свежесть против стоимости (адаптивная частота пинга) и намеренная жертва долговечностью и историей ради выживаемого объёма. Теперь, встретив фичу «живой локации» на ревью схемы, ты знаешь, что спросить: эфемерно ли хранилище, push или опрос, и фильтрует ли фанаут на краю-подписчике?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.