frontend · intermediate · 6d
Совместные курсоры
Показать живой курсор и выделение каждого подключённого пользователя в общем документе, без конфликтов, через WebSocket.
Результат
Страница, где две вкладки видят движение курсоров друг друга в реальном времени с именами и цветами, переживая переподключения.
Этапы
0/6 · 0%- 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-записи.
- 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) на сообщение в пределах одной комнаты, отправитель исключён, и комнаты не делят набор сокетов.
- 03Интерполяция курсора, троттлинг и коалесценция на клиенте
Сырые pointer-события летят быстрее, чем кто-либо может использовать — до 120 Гц на дисплее с высокой частотой, — и сеть это не потянет (и не должна). На отправке троттли исходящие апдейты курсора до фиксированного темпа (~50 мс / 20 Гц) и коалесцируй: если до следующего тика накопилось три движения, отправь только последнюю позицию, а не все три. На отрисовке удалённый поток приходит дёрганым и редким, поэтому интерполируй между двумя последними принятыми точками (lerp к цели каждый кадр анимации), чтобы курсор с апдейтом 20 Гц всё равно скользил на частоте дисплея. Управляй отрисовкой из requestAnimationFrame, а не из каждого входящего сообщения, чтобы всплеск не молотил layout.
Критерии готовности- Исходящие апдейты ограничены фиксированной частотой с коалесценцией, что подтверждено подсчётом messages/s при непрерывном движении мыши.
- Удалённые курсоры плавно скользят за счёт интерполяции, управляемой requestAnimationFrame, а не шагают раз на принятое сообщение.
Опирается наСамопроверка
Пусть ревьюер подвигает мышь и посмотрит на network-панель и frame-таймлайн; он проверяет, что частота отправки фиксирована независимо от частоты ввода, а отрисовка управляется rAF, а не сообщениями.
- 04Бесконфликтные общие выделения (LWW сейчас, CRDT для стретча)
Курсоры по природе last-writer-wins — новая позиция просто заменяет старую. Но общие выделения и любое общее изменяемое состояние требуют явного правила конфликтов, потому что два клиента могут менять состояние одновременно, а сообщения приходят не по порядку. Начни с LWW по логической метке времени (счётчик Лампорта, а не настенные часы, так как несинхронные часы врут): каждое presence-поле несёт счётчик, и получатель игнорирует любой апдейт со счётчиком ≤ уже имеющегося, делая слияние коммутативным и идемпотентным. Этого достаточно для состояния выделения на пользователя. Для по-настоящему общего редактируемого контента (стретч) LWW теряет записи, поэтому нужен CRDT.
Критерии готовности- Каждое presence-поле несёт логический счётчик; апдейт не по порядку или дубликат игнорируется, и ты можешь показать, что две перестановки сходятся к одному состоянию.
- Ты можешь чётко сформулировать, где LWW теряет данные и почему для общего редактируемого текста нужен CRDT.
Опирается наСамопроверка
Переставь и продублируй два конкурентных апдейта выделения вручную; ревьюер проверяет, что итоговое состояние не зависит от порядка прихода (коммутативность + идемпотентность) и что настенное время не служит тайбрейкером.
- 05Переподключение, ресинк и backpressure
Сети рвутся. Когда сокет закрывается, клиент должен переподключаться с экспоненциальным backoff + jitter (чтобы 1000 клиентов, сброшенных деплоем, не переподключались синхронным thundering herd), а при переподключении ресинкать полный снапшот комнаты, а не переигрывать пропущенный поток дельт — для эфемерного presence свежий снапшот корректен и дёшев. На сервере медленный потребитель опасен: если продолжать копить fan-out сообщения для сокета с полным буфером отправки, память растёт неограниченно. Применяй backpressure — следи за socket.bufferedAmount и за порогом отбрасывай коалесцируемые кадры курсора (они всё равно протухли), а не буферизуй их, либо отключай отстающего.
Критерии готовности- Убитое соединение переподключается с backoff+jitter и ресинкает снапшот комнаты; presence снова корректен без ручного обновления.
- Намеренно медленный потребитель вызывает backpressure (кадры отброшены или сокет закрыт), и память сервера остаётся ограниченной под нагрузкой.
Опирается наСамопроверка
Убей сокет в середине сессии и затроттли одного потребителя; ревьюер проверяет, что переподключение использует backoff с jitter (а не тугой цикл), ресинк — это снапшот, а не реплей, и медленный клиент не может неограниченно растить память сервера.
- 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-буферы) и измерь прирост пропускной способности сообщений на комнату.