open atlas
← Все проекты

frontend · intermediate · 6d

Совместные курсоры

Показать живой курсор и выделение каждого подключённого пользователя в общем документе, без конфликтов, через WebSocket.

Реалтайм-коллаборация — это в основном задача presence и конфликтов. Начнёшь с наивного WebSocket-транспорта и модели presence, затем построишь серверный broadcast-fan-out и шардирование комнат, сгладишь и затроттлишь движение на клиенте, сделаешь общие выделения бесконфликтными, переживёшь переподключения с backpressure, масштабируешь между несколькими WS-серверами через pub/sub — и наконец обнаружишь и переживёшь broadcast-шторм. Та же схема лежит в основе мультиплеера Figma и Google Docs.

Результат

Страница, где две вкладки видят движение курсоров друг друга в реальном времени с именами и цветами, переживая переподключения.

Этапы

0/6 · 0%
  1. 01WebSocket-транспорт и модель presence

    До того как поедут курсоры, реши, что значит соединение. WebSocket — это долгоживущая stateful-труба с полным дуплексом: в отличие от HTTP, сервер теперь держит состояние на каждый сокет и должен рассуждать о его жизненном цикле (open → message → close). Смоделируй presence как эфемерное состояние по ключу session id: кто здесь, имя, цвет, последний курсор. Определи небольшой бинарный или JSON-конверт сообщения ({type, sessionId, payload}) и heartbeat (ping/pong каждые ~15 с), чтобы мёртвые сокеты обнаруживались, а не утекали. На этом этапе один апдейт курсора — одно сообщение; комната из 10 человек при 60 Гц сырого ввода — это 600 msgs/s входящих, и именно поэтому дальше будет троттлинг.

    Критерии готовности
    • Клиент открывает WebSocket, шлёт join с именем и цветом, а сервер ведёт его в per-room presence-карте по ключу session id.
    • Heartbeat обнаруживает мёртвый сокет в пределах одного интервала и удаляет его presence-запись; переходы open/message/close видны в логе.
    Самопроверка

    Покажи конверт сообщения и heartbeat; senior-ревьюер проверяет, что presence эфемерен (не персистится), ключ — сессия, а не пользователь, и закрытый сокет не может оставить утечку presence-записи.

  2. 02Broadcast-fan-out и шардирование комнат

    Каждое сообщение от одного клиента должно дойти до остальных N−1 клиентов в той же комнате — а наивный fan-out это O(N²): в комнате на 50 человек каждый из 50 отправителей даже при 20 msgs/s даёт 50×49×20 ≈ 49k отправок сообщений/с на одном сервере. Сдержи это, изолируя состояние по комнате (комната владеет своим набором сокетов и presence) и рассылая только в эту комнату, а не на весь сервер. Определи правило ретрансляции: echo-to-others (отправитель уже отрисовал локально) и пропуск инициатора. Держи цикл broadcast по комнате вне критического пути accept, чтобы горячая комната не блокировала новые соединения.

    Критерии готовности
    • Сообщение рассылается только сокетам той же комнаты, исключая отправителя, и ты можешь сформулировать формулу отправок на комнату N×(N−1)×rate.
    • Две комнаты полностью изолированы: вход или флуд в комнату A даёт ноль трафика в комнате B.
    Самопроверка

    Проведи ревьюера по пути fan-out одного сообщения; он проверяет, что это O(N) на сообщение в пределах одной комнаты, отправитель исключён, и комнаты не делят набор сокетов.

  3. 03Интерполяция курсора, троттлинг и коалесценция на клиенте

    Сырые pointer-события летят быстрее, чем кто-либо может использовать — до 120 Гц на дисплее с высокой частотой, — и сеть это не потянет (и не должна). На отправке троттли исходящие апдейты курсора до фиксированного темпа (~50 мс / 20 Гц) и коалесцируй: если до следующего тика накопилось три движения, отправь только последнюю позицию, а не все три. На отрисовке удалённый поток приходит дёрганым и редким, поэтому интерполируй между двумя последними принятыми точками (lerp к цели каждый кадр анимации), чтобы курсор с апдейтом 20 Гц всё равно скользил на частоте дисплея. Управляй отрисовкой из requestAnimationFrame, а не из каждого входящего сообщения, чтобы всплеск не молотил layout.

    Критерии готовности
    • Исходящие апдейты ограничены фиксированной частотой с коалесценцией, что подтверждено подсчётом messages/s при непрерывном движении мыши.
    • Удалённые курсоры плавно скользят за счёт интерполяции, управляемой requestAnimationFrame, а не шагают раз на принятое сообщение.
    Самопроверка

    Пусть ревьюер подвигает мышь и посмотрит на network-панель и frame-таймлайн; он проверяет, что частота отправки фиксирована независимо от частоты ввода, а отрисовка управляется rAF, а не сообщениями.

  4. 04Бесконфликтные общие выделения (LWW сейчас, CRDT для стретча)

    Курсоры по природе last-writer-wins — новая позиция просто заменяет старую. Но общие выделения и любое общее изменяемое состояние требуют явного правила конфликтов, потому что два клиента могут менять состояние одновременно, а сообщения приходят не по порядку. Начни с LWW по логической метке времени (счётчик Лампорта, а не настенные часы, так как несинхронные часы врут): каждое presence-поле несёт счётчик, и получатель игнорирует любой апдейт со счётчиком ≤ уже имеющегося, делая слияние коммутативным и идемпотентным. Этого достаточно для состояния выделения на пользователя. Для по-настоящему общего редактируемого контента (стретч) LWW теряет записи, поэтому нужен CRDT.

    Критерии готовности
    • Каждое presence-поле несёт логический счётчик; апдейт не по порядку или дубликат игнорируется, и ты можешь показать, что две перестановки сходятся к одному состоянию.
    • Ты можешь чётко сформулировать, где LWW теряет данные и почему для общего редактируемого текста нужен CRDT.
    Самопроверка

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

  5. 05Переподключение, ресинк и backpressure

    Сети рвутся. Когда сокет закрывается, клиент должен переподключаться с экспоненциальным backoff + jitter (чтобы 1000 клиентов, сброшенных деплоем, не переподключались синхронным thundering herd), а при переподключении ресинкать полный снапшот комнаты, а не переигрывать пропущенный поток дельт — для эфемерного presence свежий снапшот корректен и дёшев. На сервере медленный потребитель опасен: если продолжать копить fan-out сообщения для сокета с полным буфером отправки, память растёт неограниченно. Применяй backpressure — следи за socket.bufferedAmount и за порогом отбрасывай коалесцируемые кадры курсора (они всё равно протухли), а не буферизуй их, либо отключай отстающего.

    Критерии готовности
    • Убитое соединение переподключается с backoff+jitter и ресинкает снапшот комнаты; presence снова корректен без ручного обновления.
    • Намеренно медленный потребитель вызывает backpressure (кадры отброшены или сокет закрыт), и память сервера остаётся ограниченной под нагрузкой.
    Самопроверка

    Убей сокет в середине сессии и затроттли одного потребителя; ревьюер проверяет, что переподключение использует backoff с jitter (а не тугой цикл), ресинк — это снапшот, а не реплей, и медленный клиент не может неограниченно растить память сервера.

  6. 06Масштабируй между серверами, наблюдай и переживи broadcast-шторм

    Один WS-сервер упирается в потолок (файловые дескрипторы, CPU на fan-out) задолго до числа пользователей, поэтому поставь два и более за балансировщиком со sticky-сессиями и свяжи их pub/sub-backplane (Redis pub/sub или аналог): сообщение приходит на сервер A, публикуется в канал на комнату, и каждый сервер с участниками этой комнаты ре-фанаутит локально. Теперь сними телеметрию — RED-метрики (rate соединений, ошибки сообщений, длительность fan-out) на комнату и трейс, проброшенный через publish-переход, — потому что без чисел инцидент невидим. Затем спровоцируй его: взбесившийся клиент (или баг echo-петли) флудит комнату, backplane усиливает это на все серверы, p99 длительности fan-out взлетает и CPU насыщается. Обнаружь по дашборду, смягчи вживую (rate-limit на соединение + лимит сообщений на комнату) и найди корневую причину.

    Критерии готовности
    • Два WS-сервера делят комнаты через pub/sub-backplane: курсор на сервере A появляется у клиента, подключённого к серверу B.
    • Дашборд показывает per-room rate соединений, error rate и fan-out p99, а трейс пересекает publish-переход.
    • Ты воспроизвёл broadcast-шторм, смягчил его per-connection rate-лимитом и лимитом на комнату и показал возврат fan-out p99 к норме — твой разбор называет механизм усиления, а не просто «высокий CPU».
    Самопроверка

    Покажи путь backplane и разбор шторма; ревьюер проверяет, что pub/sub-fan-out не доставляет дважды локальным участникам, что sticky-сессии осознанны, и что корневая причина называет усиление (echo-петля / неограниченный ре-publish), а не симптом.

Рубрика

Джуниор Миддл Сеньор
Сходимость слияния и правило конфликтов Позиция курсора рассылается широковещательно, побеждает последнее прибывшее сообщение; конкурентные апдейты проблемой не признаются. Поля presence несут логический (Lamport) счётчик; апдейт не по порядку или дубликат игнорируется, и ты можешь показать, что два порядка прихода сходятся к одному состоянию. Ты можешь точно сформулировать, где LWW теряет конкурентные записи и почему для редактируемого общего текста нужен sequence CRDT (например, Yjs), — и стретч доказывает это тестом сходимости на переупорядоченных конкурентных операциях.
Отключение и переподключение с ресинком При переподключении страница перезагружается; состояние presence сессии теряется, снапшот не доставляется. Переподключение использует экспоненциальный backoff+jitter, сервер отправляет полный снапшот комнаты; presence снова корректен без ручного обновления. Ты явно выбрал снапшот вместо реплея для эфемерного presence и можешь объяснить, почему воспроизведение потока дельт было бы неверным (упорядочивание, tombstones, расход памяти). Намеренно медленный потребитель вызывает backpressure — кадры отбрасываются, а не буферизуются до бесконечности, — и память сервера остаётся плоской под нагрузкой.
WebSocket-транспорт и backpressure Сырые события mousemove пересылаются один-к-одному по сокету; частота отправки равна частоте ввода, управление потоком отсутствует. Исходящие апдейты троттлируются до ~20 Гц с коалесценцией; удалённые курсоры интерполируются через requestAnimationFrame и скользят плавно даже на редком потоке. Ты следишь за socket.bufferedAmount и отбрасываешь протухшие кадры курсора, когда потребитель отстаёт, а не буферизуешь их; broadcast-шторм (echo-петля или неограниченный ре-publish через backplane) обнаруживается по RED-метрикам, гасится per-connection rate-лимитом + потолком сообщений на комнату, и пост-мортем называет механизм усиления — а не просто «высокий CPU».
Горизонтальное масштабирование через pub/sub Все клиенты обязаны подключаться к одному серверному процессу; при двух инстансах курсоры одного невидимы в другом. Два WS-сервера делят комнаты через pub/sub-backplane: курсор на сервере A появляется у клиентов на сервере B; sticky-сессии осознанны. Fan-out с backplane никогда не доставляет дважды локальным участникам (исходящий сервер пропускает ре-publish для сокетов, которые уже обслужил); трейсы пересекают publish-переход, поэтому медленная комната указывает на span backplane, а не на агрегированный CPU.
Эталонный разбор (спойлер)

Presence как эфемерное состояние: presence ключуется по сессии, а не по пользователю, и не персистируется. Правильная стратегия ресинка при переподключении — полный снапшот комнаты, а не реплей дельт: воспроизведение потока требует упорядочивания, tombstones и памяти, пропорциональной истории, — ничего из этого не нужно для данных, валидных только пока сокет жив.

LWW против CRDT: last-writer-wins на логическом (Lamport) счётчике корректен для курсора и выделения на пользователя, потому что собственная последняя позиция пользователя — всегда правильный ответ. Он не работает для общего редактируемого текста: два конкурентных вставки символа в одну позицию — обе допустимы и обе должны выжить, что требует sequence CRDT (Yjs, Automerge) с коммутативным и ассоциативным слиянием.

Усиление при broadcast: один WS-сервер делает O(N) fan-out на сообщение в комнате — управляемо. Pub/sub-backplane добавляет межсерверный fan-out: взбесившийся клиент, флудящий комнату, усиливается на каждый сервер с участниками этой комнаты. Решение — per-connection лимит входящей частоты плюс потолок сообщений на комнату до попадания в backplane.

Backpressure на медленном потребителе: если буфер отправки сокета полон, накапливание большего количества fan-out сообщений неограниченно растит память сервера. Отбрасывай коалесцируемые кадры курсора — они к моменту отправки уже устарели, — а не буферизуй. Отключай хронически отстающего клиента как крайнюю меру, а не позволяй одному медленному сокету деградировать всю комнату.

Сделай по-сеньорски

  • Сделай совместное редактирование текста бесконфликтным через sequence-CRDT (Yjs) и докажи сходимость: примени одни и те же конкурентные операции в разном порядке между вкладками и покажи идентичные итоговые документы.
  • Маршрутизируй WebSocket через edge: терминируй соединения в ближайшем регионе и связывай комнаты глобальным pub/sub, явно рассуждая о латентности межрегионального fan-out.
  • Добавь сборку мусора presence: вычищай протухшие сессии по всему кластеру (а не только на локальном сервере), чтобы призраки упавшего узла исчезали в пределах одного окна heartbeat.
  • Вынеси горячий цикл fan-out с основного потока или в бинарный протокол (CBOR/MessagePack + transferable-буферы) и измерь прирост пропускной способности сообщений на комнату.

Навыки

WebSocketpresence protocolCRDT basicsthrottling / interpolationbroadcast fan-outreconnect / resynchorizontal scale via pub/subincident response

Рекомендуемый стек

preactwsyjs