---
title: "Presence: кто сейчас онлайн"
description: "Presence websocket на Go: карта статусов по userID, away по активности, heartbeat как отметка жизни и события онлайна только подписчикам. Код и кейс мигания."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/19-onlajn-statusy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Presence: кто сейчас онлайн

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 19 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/19-onlajn-statusy/*

Presence — это ответ websocket-сервера на продуктовый вопрос «кто сейчас онлайн»: карта статусов по userID, которая живёт в хабе и обновляется событиями. В этом уроке достроим каркас двенадцатого урока до полноценной системы: уровни online, away и offline, автоматическое угасание по активности и события, которые не мигают при каждом переподключении.

Зелёная точка рядом с аватаром кажется мелочью, пока не посчитаешь, что стоит за ней. В двенадцатом уроке presence родился как производное карты users: запись есть — в сети, запись удалилась — не в сети, и для учебного чата этого достаточно. Живой мессенджер спрашивает больше: человек открыл вкладку и ушёл за кофе — он онлайн или нет? Отвлёкся на звонок? Сидит с телефона в фоне? Один бит «в сети или нет» на эти вопросы не отвечает, и продуктовые команды добавляют уровни. Заодно всплывает цена: при тысячах пользователей каждое подключение и каждый уход — это потенциальная рассылка, и рассылать её всему серверу нельзя. Presence онлайн статусы — это три вещи: карта с уровнями, механика их смены без ручного труда и круг подписчиков, который делает рассылку дешёвой. Кто онлайн websocket сообщает не списком раз в час, а потоком событий — и весь поток должен родиться в одном владельце.

### Presence онлайн статусы: карта userID и уровни

Хаб получает карту presence map[string]Status: ключ — userID, значение — один из уровней. Три уровня покрывают большинство продуктов: online — есть живые устройства, away — устройства есть, но активности давно не было, offline — устройств нет. Уровень dnd, «не беспокоить», в эту тройку не входит принципиально: его знает только человек, и серверу остаётся принимать его как клиентское состояние, а не угадывать. Смена уровней — обязанность горутины run: тот же владелец карт, что и в восьмом уроке, потому что presence — производное от rooms и users, и вторая горутина, читающая их, сломала бы дисциплину. Кадр смены — обычный конверт из десятого урока с типом presence, полем User и полем Status. Отдельное правило, которое экономит трафик потом: запись в карту меняется только тогда, когда уровень действительно сменился — переход online в online не порождает ни кадра, ни записи.

### setStatus: смена уровня и рассылка в комнату

```go
func (h *Hub) setStatus(c *Client, s Status) {
        if h.presence[c.userID] == s {
                return // без изменения уровня нет и кадра
        }
        h.presence[c.userID] = s
        env := &Envelope{Type: "presence", User: c.userID, Status: string(s)}
        for client := range h.rooms[c.room] {
                select {
                case client.send <- env:
                default:
                        close(client.send)
                }
        }
}
```

Метод живёт в владельце: дедупликация, запись в карту presence и рассылка конверта тем, кто сидит в комнате пользователя. Select с default — тот же предохранитель медленных клиентов, что и в обычной рассылке: presence lossy по природе, и потерянное событие чинится запросом снапшота. Круг адресатов — комната из одиннадцатого урока: в большой системе подписки сузят его до контактов.

Уровни online и offline server ставит сам, в тех же ветках register и unregister, где двенадцатый урок уже рассылал системные конверты: подключилось первое устройство — online, ушло последнее — offline. С away интереснее: сервер не спрашивает человека, отвлёкся ли тот, — он измеряет. В readPump на каждом входящем кадре обновляется отметка lastActivity; в select хаба живёт тикер присутствия, который раз в минуту понижает online в away тем, кто молчит дольше порога. Порог — продуктовое решение с границей: два-три минуты для живого чата, десять — для тикетов поддержки. И приятная экономия: lastPong из восемнадцатого урока — уже готовая отметка жизни, heartbeat обновляет её автоматически, и for многих клиентов отдельно трогать lastActivity не нужно — достаточно брать максимум из двух отметок.

### Тикер присутствия: понижение до away

```go
case <-h.presenceTick.C: // Ticker раз в минуту
        now := time.Now().UnixNano()
        for c := range h.clients {
                if h.presence[c.userID] != StatusOnline {
                        continue
                }
                idle := now - atomic.LoadInt64(&c.lastActivity)
                if idle > int64(5*time.Minute) {
                        h.setStatus(c, StatusAway)
                }
        }
```

Раз в минуту владелец обходит клиентов, смотрит только на тех, кто числится online, и понижает замолчавших. Атомарная отметка lastActivity обновляется в readPump на каждом кадре, включая явные команды активности от клиента — мобильные приложения шлют их при выходе из фона. Смена уровня идёт через setStatus, поэтому кадр уйдёт ровно один раз, а обратный переход в online поднимет любое движение.

Круг рассылки — то место, где presence обычно и разваливается. Наивный вариант — broadcast всем: при десяти тысячах пользователей каждый вход — это десять тысяч конвертов, и чат превращается в генератор присутствия. Лечение то же, что в двенадцатом уроке: сузить круг. Минимум — комната пользователя: статус видят те, кто сидит с ним рядом, и это почти бесплатно, потому что карта rooms уже есть. Дальше — подписки на контакты: map userID на множество наблюдателей, построенная по явным запросам клиента, — тот же механизм комнат, только круг задаёт не сервер, а человек. Для интерфейса важен ещё снапшот: клиент при входе шлёт команду who и получает текущий список с уровнями, а дальше живут только дельты. Без снапшота подписчик, подключившийся позже события, никогда не узнает, кто онлайн: события потока не восстанавливают прошлое.

> **Мигающий статус при переподключении.** Клиент семнадцатого урока пережил рестарт сервера: disconnect, три секунды, reconnect — а подписчики увидели offline и тут же online, и аватар мигнул. На мобильных сетях такое происходит раз в несколько минут и превращает список контактов в ёлочную гирлянду. Лечение — гистерезис: offline публикуем не сразу, а через тридцать секунд после ухода последнего устройства, и если клиент вернулся раньше — кадра нет вовсе. Задержку держит владелец: отложенная ветка в run или отметка времени с проверкой на тикере присутствия.

Два устройства одного человека — второй источник сюрпризов. Пользователь сидит в браузере и с телефона: браузер заснул, телефон в фоне, — по отметкам активности один ушёл в away, второй молчит дольше, и агрегировать их надо в один статус на userID. Правило честной агрегации — лучший из уровней: online, пока живо хоть одно активное устройство, offline, когда ушли все. Вложенная карта устройств из двенадцатого урока даёт это почти даром: статус меняется на границах пустого и непустого множества, и отдельной синхронизации не требует. Заодно решается и обратный вопрос: что показывать, когда устройства расходятся, — пользователь явно выставил dnd на телефоне; если dnd хранить как клиентский уровень с приоритетом, сервер перестаёт угадывать и просто публикует то, что человек сказал о себе. Граница: dnd с таймером «на час» уже требует серверного состояния, и оно живёт в той же карте presence, только источник у него другой.

### Кто имеет право видеть статус

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

### Уровни и их источники

| Уровень | Кто решает | Как определяется |
| --- | --- | --- |
| online | сервер | есть живые устройства, карта users непуста |
| away | сервер | активности нет дольше порога: пять минут по умолчанию |
| dnd | клиент | явный уровень от человека, сервер публикует без споров |
| offline | сервер | последнее устройство ушло плюс гистерезис в тридцать секунд |

### Проверка: откуда берётся away

**Вопрос:** Как сервер узнаёт, что пользователь отошёл?

- Клиент обязан прислать команду away перед уходом от компьютера
- ✅ Сервер меряет тишину: нет входящих кадров и активности дольше порога
- Операционная система клиента сообщает это в заголовках
- Presence-тикер спрашивает у клиента каждые десять секунд

Away — производное активности: каждый входящий кадр обновляет lastActivity, pong из heartbeat тоже годится, и тикер понижает замолчавших. Клиентская команда away нужна для явных случаев вроде dnd, но требовать её от вкладки, которую просто закрыли крышкой ноутбука, нельзя — она не сможет.

### Проверка: круг рассылки

**Вопрос:** Пользователь сменил статус на away. Кому уходит presence-кадр?

- Всем подключённым клиентам сервера
- ✅ Только тем, кто сидит в комнате пользователя или подписан на него
- Самому пользователю для подтверждения
- Никому: уровень виден только по запросу who

Broadcast всем — путь к десяти тысячам конвертов на каждый вход. Кадр получают подписчики: минимум — комната из карты rooms, в зрелой системе — явные подписки на контакты. Остальные узнают статус снапшотом who при входе: события не восстанавливают прошлое, снапшот — восстанавливает.

### Дедупликация в setStatus

```go
// u42 уже числится в away, тикер присутствия тикнул дважды,
// кадров от клиента за это время не было
h.setStatus(c, StatusAway)
h.setStatus(c, StatusAway)
```

**Что выведет программа?**

- Два presence-кадра уйдут в комнату
- Один кадр: первое присваивание сработало
- ✅ Ни одного: оба вызова отброшены проверкой уровня
- Зависит от буфера send клиентов

Обе попытки сталкиваются с первой же проверкой: presence уже хранит away, уровень не изменился, метод выходит до записи и до рассылки. Дедупликация в setStatus — это то, что отличает события присутствия от потока мусора: без неё тикер раз в минуту рассылал бы каждому онлайн-пользователю собственный статус бесконечно.

> **Проверьте статусы локально.** Соберите presence поверх хаба с картой устройств на локальном Go и откройте две вкладки с одним userID. Закройте одну — статус должен остаться online, пока жива вторая; закройте обе — через тридцать секунд гистерезиса придёт offline. Затем временно поставьте порог away в одну минуту и перестаньте трогать вкладку: тикер понижает её сам, а любое движение возвращает online. Три вкладки и таймер закрывают весь урок за десять минут.

### Соберите setStatus

Впишите три пропуска: сравнение текущего уровня, запись нового и круг адресатов рассылки.

```go
func (h *Hub) setStatus(c *Client, s Status) {
        if h.presence[c.userID] == ___ {
                return
        }
        h.presence[c.userID] = ___
        env := &Envelope{Type: "presence", User: c.userID, Status: string(s)}
        for client := range h.rooms[___] {
                select {
                case client.send <- env:
                default:
                        close(client.send)
                }
        }
}
```

Подсказка: Сравнение и запись используют параметр уровня s, а рассылка уходит в комнату пользователя — карту по имени c.room.

Правильные ответы в пропусках: `s`, `s`, `c.room`

### Соберите каркас setStatus

Расставьте строки метода: проверка уровня, запись и возврат без действий.

Правильный порядок строк:

1. `func (h *Hub) setStatus(c *Client, s Status) {`
2. `if h.presence[c.userID] == s {`
3. `return`
4. `} // статус не менялся — выходим`
5. `h.presence[c.userID] = s`
6. `}`

### Карточки: presence

- **presence map[string]Status** — Карта userID на уровень: online, away, offline. Живёт в владельце run, кадр уходит только при смене уровня.
- **Away по тишине** — Сервер меряет lastActivity: нет кадров и активности дольше порога — тикер понижает online в away. Порог два-десять минут по продукту.
- **lastPong как lastSeen** — Heartbeat из восемнадцатого урока обновляет отметку жизни: для presence достаточно максимума из lastPong и lastActivity.
- **Гистерезис offline** — Offline публикуется через тридцать секунд после ухода последнего устройства; вернулся раньше — кадра нет. Лечит мигание при переподключениях.
- **Снапшот who** — Команда при входе возвращает текущий список с уровнями, дальше идут только дельты. Без снапшота новый подписчик не знает прошлого.

Presence готов: карта уровней, автоматическое угасание, гистерезис и круг подписчиков. Кадры статуса пойдут чаще, чем сообщения чата, — и их тоже стоит собирать пачками, чем займётся [двадцатый урок про батчинг](/courses/websocket/lessons/20-batching/). Каркас присутствия и очередь оффлайн-сообщений описаны в [двенадцатом уроке](/courses/websocket/lessons/12-lichnye-soobscheniya/), отметка lastPong и конвейер смерти — в [восемнадцатом про отвалы](/courses/websocket/lessons/18-otvaly-i-tajmauty/), а переподключения, из-за которых мигают статусы, — в [семнадцатом про reconnect](/courses/websocket/lessons/17-reconnect/). Тикеры, отметки времени и пороги на duration разобраны в [продвинутом курсе Go](/courses/go-advanced/) в [теме про time и таймеры](/courses/go-advanced/lessons/23-time/). Когда инстансов станет два, карта presence разъедется по процессам — мост между ними построит [двадцать третий урок про масштабирование](/courses/websocket/lessons/23-masshtabirovanie/). Проверьте гистерезис на локальном Go с двумя вкладками — мигание видно сразу. План курса — на странице [курса WebSocket](/courses/websocket/).

## Коротко: что запомнить

- Presence — карта userID на уровень в хабе: online и offline ставит сам сервер в ветках входа и ухода.
- Away рождается из тишины: lastActivity в readPump плюс тикер присутствия, порог два-десять минут.
- lastPong из heartbeat — готовая отметка жизни: для presence хватает максимума двух отметок.
- setStatus дедуплицирует: смена уровня без изменения не пишет карту и не шлёт кадр.
- Круг рассылки сужается до комнаты и подписок: broadcast всем превращает вход в десять тысяч конвертов.
- Гистерезис в тридцать секунд убирает мигание статуса при переподключениях и деплоях.

## Домашнее задание

Достройте presence поверх хаба с картой устройств: setStatus с дедупликацией, тикер присутствия с понижением до away и гистерезис offline в тридцать секунд. Добавьте команду who со снапшотом статусов комнаты при входе клиента. Проверьте локально с двумя вкладками одного пользователя: уход одной не гасит статус, уход обеих публикует offline один раз, а возврат до тридцати секунд не даёт ни одного кадра.

## Вопросы и ответы по уроку

### Почему away нельзя узнать от клиента?

Вкладку закрывают крышкой ноутбука, телефон уходит в сон, и ни один клиент не обязан успеть сообщить об уходе — сеть уже может лежать. Серверное измерение тишины работает всегда, потому что опирается на то, что уже есть: heartbeat и входящие кадры. Клиентская команда остаётся для явных уровней вроде dnd.

### Что делать со статусами при нескольких инстансах?

Одна карта presence живёт внутри одного процесса, и её события не пересекают границу. Связку строят через шину событий: каждый инстанс публикует свои смены статусов, слушает чужие и держит копию карты. Детали — в двадцать третьем уроке про Redis Pub/Sub; до масштабирования карту можно не трогать.

### Как часто можно опрашивать статусы через who?

Снапшот рассчитан на вход и на восстановление после потери событий: раз при подключении и после заметного зазора. Постоянный поллинг who каждую секунду возвращает архитектуру к тому, от чего websocket уходил, — к вопросу «есть ли новое». Дельты и гистерезис существуют именно чтобы поллинг не понадобился.
