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/offline | Redis + TTL + WebSocket | Высокая частота записи, не нужна персистентность |
| Last seen timestamp | PostgreSQL | Нужна история, запросы по диапазону дат |
| Typing indicator | Redis key + TTL | Самоисчезающий по таймеру, без явного удаления |
| Cursor presence (Notion) | CRDT (Yjs) + WS | Конфликтующие позиции требуют merge-логики |
| Fan-out на N серверах | Redis Pub/Sub | Decoupled 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 участников?