Real-Time Backend

Presence

Зелёная точка рядом с именем - кажется простой деталью. За ней скрывается система, которая при 1 млн активных пользователях Slack обновляется с задержкой менее 5 секунд и при этом не кладёт базу данных.

  • Slack presence: зелёная точка обновляется менее чем за 5 секунд для 1 млн+ активных пользователей - с помощью WebSocket fan-out через Redis Pub/Sub, без polling.
  • WhatsApp typing indicator: одно событие на сессию набора + 3-секундный TTL в Redis. Без throttle при 100 млн активных чатах сервер получал бы миллиарды событий в минуту.
  • Discord heartbeat/ACK: клиент шлёт пинг каждые 41.25 секунды (нецелое число - намеренный jitter). Три пропущенных ACK - реконнект. Это обнаруживает «мёртвые» вкладки, которые TCP считает живыми.
  • Notion cursor presence: каждый курсор другого пользователя виден в реальном времени через CRDT (Yjs) + WebSocket. Позиции мёрджатся без конфликтов даже при одновременном редактировании.

Presence Concept

Presence - это слой реального времени поверх обычного REST-запроса. Вместо того чтобы клиент каждые N секунд спрашивал «а этот пользователь онлайн?», сервер сам рассылает изменения состояния подписчикам. В Slack с более чем 1 млн одновременно активных пользователей зелёная точка обновляется с задержкой менее 5 секунд - и всё это без явного polling.

Presence-состояние имеет три базовых значения: **online** (активное соединение), **away** (соединение есть, но нет активности), **offline** (соединение разорвано или истёк heartbeat). Каждое из них хранится в key-value хранилище (Redis/KeyDB) с TTL, а не в основной БД - потому что это эфемерные данные с высокой частотой записи.

Presence не должна жить в PostgreSQL. Запись «user X online» обновляется каждые 30 секунд на пользователя - при 10 000 активных юзерах это 20 000 IOPS. Redis справится; реляционная БД - нет.

Почему presence-статусы хранят в Redis с TTL, а не в PostgreSQL?

Presence Typing

Typing indicator - самый «живой» элемент присутствия. WhatsApp отправляет событие `typing` при каждом нажатии клавиши - но только **одно событие на сессию набора**, а не отдельное per-keystroke. На сервере запускается таймер: если следующего события нет 3 секунды, статус сбрасывается в `idle`. Это throttling на клиенте + TTL на сервере.

Throttle на клиенте критичен: без него при скорости 60 символов/мин на сервер летит 60 событий вместо одного. Для чата на 1000 активных участников это разница между 1 000 и 60 000 сообщений в минуту.

Notion использует cursor presence с CRDT - каждый курсор других пользователей виден в реальном времени. Здесь payload богаче: не просто `{userId}`, а `{userId, position, color, selection}`. Это тот же throttled broadcast, но с векторными часами для разрешения конфликтов позиций.

  • Простой typing indicator (WhatsApp-style) — Событие + TTL в Redis. Payload: {userId, chatId}. Latency <200ms. Реализация за 30 строк.
  • Cursor presence (Notion-style) — CRDT + OT для разрешения конфликтов. Payload: {userId, position, selection, color}. Требует CRDT-библиотеку (Yjs). Масштабируется до десятков участников на документ.

Зачем throttle на клиенте при отправке typing events?

Presence Heartbeat

Discord определяет онлайн-статус через heartbeat/ACK цикл: клиент каждые 41.25 секунды (это не круглое число - намеренно, чтобы не синхронизироваться с другими клиентами) отправляет `{op: 1, d: lastSequenceNumber}`. Сервер отвечает `{op: 11}`. Если три heartbeat подряд остались без ответа - соединение считается мёртвым.

Jitter в heartbeat интервале (например, 30s + random(0, 5s)) - стандартная практика. Без него тысячи клиентов, подключившихся одновременно, шлют heartbeat в один момент и создают spike нагрузки.

TCP keepalive - нижележащий механизм, но он работает на уровне ОС и не даёт application-level гарантий. Браузер может «заморозить» вкладку и прекратить JS-исполнение, оставив TCP-соединение живым. Поэтому application heartbeat работает поверх TCP и детектирует именно «мёртвый браузер», а не «мёртвое соединение».

Зачем добавлять случайный jitter к интервалу heartbeat?

Presence Impl

Реальная presence-система для продакшена строится из трёх слоёв: **connection layer** (Socket.IO/WS), **state layer** (Redis с Pub/Sub для fan-out между серверами), **persistence layer** (PostgreSQL только для `last_seen`, не для live-статуса). Slack с 1 млн+ активными пользователями разделяет presence по каналам - нет смысла рассылать статус всем 500 участникам большого воркспейса, если изменение касается только личного чата.

СценарийИнструментПочему
Live online/offlineRedis + TTL + WebSocketВысокая частота записи, не нужна персистентность
Last seen timestampPostgreSQLНужна история, запросы по диапазону дат
Typing indicatorRedis key + TTLСамоисчезающий по таймеру, без явного удаления
Cursor presence (Notion)CRDT (Yjs) + WSКонфликтующие позиции требуют merge-логики
Fan-out на N серверахRedis Pub/SubDecoupled broadcast без прямых вызовов между серверами

Не хранить socketId в presence после дисконнекта. При реконнекте клиент получит новый socketId, и старые записи станут «зомби». TTL решает проблему автоматически, но явная очистка при disconnect ускоряет реакцию системы.

WebSocket-соединение = пользователь онлайн. Если соединение есть, статус online; нет - offline.

Соединение может быть технически живым (TCP-уровень), но браузер «заморожен» и клиент не реагирует. Application heartbeat обнаруживает именно это.

Мобильные приложения уходят в фон, браузерные вкладки замораживаются. TCP keepalive работает на уровне ОС и не знает о логике приложения. Только application-level ping/pong надёжно детектирует «мёртвого» клиента.

Как presence-сервер рассылает статус пользователя на несколько WS-серверов без прямого вызова между ними?

Ключевые идеи

  • Presence-статусы хранятся в Redis с TTL, а не в PostgreSQL - частота записи слишком высока для реляционной БД.
  • Typing indicator = throttled клиентское событие + автоисчезающий Redis-ключ с TTL. Сервер не управляет таймером явно.
  • Heartbeat с jitter - стандарт для предотвращения synchronized thundering herd: тысячи клиентов не должны стучать в сервер одновременно.
  • Multi-server fan-out строится через Redis Pub/Sub - decoupled и масштабируется без изменения кода при добавлении серверов.
  • TCP keepalive != application presence. Замороженные браузерные вкладки держат TCP-соединение живым, но не отвечают на application heartbeat.

Связанные темы

Presence пересекается с несколькими архитектурными слоями реального времени.

  • WebSocket и Socket.IO — Транспорт для presence-событий - без persistent connection presence требует polling
  • Redis Pub/Sub и keyspace notifications — Fan-out между несколькими WS-серверами и автоматическое обнаружение истёкших TTL
  • CRDT (Conflict-free Replicated Data Types) — Cursor presence в Notion требует CRDT для merge позиций без конфликтов
  • Rate limiting и throttling — Клиентский throttle typing events - частный случай rate limiting для защиты сервера

Вопросы для размышления

  • При каком количестве активных пользователей имеет смысл перейти от TTL-based presence к event-driven (keyspace notifications)?
  • Как бы изменилась архитектура presence, если нужна история «кто был онлайн в конкретный час» для аналитики?
  • Notion показывает курсоры 50+ участников в одном документе. Какие проблемы возникают при масштабировании cursor presence до 500 участников?

Связанные уроки

  • dist-12-consistency
Presence

0

1

Войти