---
title: "Pub/Sub: рассылка событий в реальном времени"
description: "Pub/Sub на Redis в Go: Publish, Subscribe и чтение сообщений на go-redis v9. Подписка в горутине, ReceiveMessage и честные границы модели без гарантий."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/14-pub-sub/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Pub/Sub: рассылка событий в реальном времени

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 14 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/14-pub-sub/*

Pub/Sub в Redis — это рассылка событий в реальном времени: издатель кладёт сообщение в канал, и его тут же получают все, кто подписан, без опроса и без хранения. Соберём издателя и подписчика на go-redis v9 — Publish, Subscribe и канал сообщений в горутине — и честно проговорим, где эта модель не выдержит нагрузки ответственности.

Сценарий: сервис обновил профиль пользователя, и об этом должны узнать три инстанса с локальными кэшами, панель мониторинга и модуль уведомлений. Опрашивать всех по HTTP — писать опрос за опросом, и всё равно запаздывать. Redis решает это встроенной моделью издатель-подписчик: команда Publish кладёт сообщение в именованный канал, и сервер немедленно доставляет его копию каждому, кто в этот момент подписан командой Subscribe. Хендлер не знает ни одного адресата, адресаты не знают издателя — знание о событии живёт только в названии канала. В этом уроке мы подключаем подписку в горутине, читаем сообщения двумя способами, собираем реальный сценарий инвалидации кэша и разбираем главную ловушку модели: то, что не было услышано, не было сохранено. Место Pub/Sub среди структур было в обзоре [типов данных Redis](/courses/go-redis/lessons/03-tipy-dannyh-redis/) — здесь живём в нём целиком.

### Каналы Redis: издатель не знает подписчиков

Канал — это просто строка: events:orders, events:users, cache:invalidate. Издатель вызывает Publish с именем канала и сообщением; сервер находит всех подписчиков этого канала и отправляет им копию — без очередей, без подтверждений, без истории. Если подписчиков нет, сообщение исчезает мгновенно, и Publish честно вернёт ноль доставленных. Подписка бывает точной и шаблонной: Subscribe слушает конкретные имена, PSubscribe — паттерны вроде events:*, и одно сообщение падает в несколько подписок сразу. Принципиально понимать границу с очередью из урока про [списки](/courses/go-redis/lessons/11-spiski/): там задача уходит одному воркеру и делится между потребителями, здесь копия уходит каждому подписчику. Разные модели для разных задач: очереди распределяют работу, Pub/Sub оповещает о факте. И ни одна из них не хранит то, что никого не застало — за хранение отвечает третий тип, потоки, им посвящён следующий урок.

### Издатель и подписчик на Publish и Subscribe

```go
func publishOrderCreated(ctx context.Context, id int64) error {
        payload := "created:" + strconv.FormatInt(id, 10)

        return rdb.Publish(ctx, "events:orders", payload).Err()
}

func startSubscriber(ctx context.Context) {
        sub := rdb.Subscribe(ctx, "events:orders")
        defer sub.Close()

        for msg := range sub.Channel() { // <-chan *redis.Msg
                fmt.Println(msg.Channel, msg.Payload)
        }
}
```

Subscribe переводит выделенное соединение в режим подписки и возвращает объект PubSub — его держат открытым всю жизнь процесса, а не на один запрос. Channel() отдаёт Go-канал с сообщениями, из которого удобно читать range-ом: у каждого сообщения есть поля Channel и Payload. Пинг-понг, переподключения и восстановление подписок go-redis делает сам; Close возвращает соединение в пул. Для запуска нужен локальный Go и Redis в Docker — песочница сайта сетевые соединения не открывает.

### Подписка в горутине и остановка без утечек

Подписка — долгоживущий ресурс, и место ей в горутине, которая запускается при старте сервиса рядом с HTTP-сервером. Чтение через Channel() блокируется до появления сообщения — это нормально: горутина спит, процессор не тратится. Вопрос в остановке: при выключении сервиса канал Go должен закрыться, а соединение — вернуться. Существует два пути: закрыть подписку через Close — range по Channel() завершится, и горутина выйдет; либо завершить контекст, который передан Subscribe, — go-redis закроет канал сообщений сам. Буфер канала настраивается опцией WithChannelSize: если читатель слишком медленный, а события сыплются, переполненный буфер приводит к разрыву соединения — go-redis считает, что подписчик не справляется, и честно отвалится, чтобы переподключиться заново. Второй способ чтения — ReceiveMessage: синхронное ожидание одного сообщения, полезное в утилитах и тестах, где горутина лишняя. Отдельно проговорите владение: один объект подписки — один читатель, две горутины на одном Channel() будут рвать поток пополам, потому что каждый элемент канала Go достаётся ровно одному получателю. Если работа над сообщением тяжёлая, разгружайте цикл: читатель принимает сообщение и передаёт в рабочий канал, обработчики трудятся параллельно, а подписка продолжает слушать без задержек.

### ReceiveMessage: одно сообщение с таймаутом

```go
func waitForEvent(ctx context.Context, sub *redis.PubSub) (string, error) {
        ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
        defer cancel()

        msg, err := sub.ReceiveMessage(ctx) // блокируется до события
        if err != nil {
                return "", err // в том числе таймаут контекста
        }
        return msg.Payload, nil
}
```

ReceiveMessage возвращает ровно одно сообщение и блокируется до его появления или ошибки. Таймаут контекста развязывает ожидание: без событий через десять секунд вернётся ошибка контекста, а не вечное зависание утилиты. Для постоянного чтения в сервисе удобнее Channel, а ReceiveMessage — инструмент для CLI-команд, скриптов и тестов, где подписка нужна на короткую жизнь.

> **Pub/Sub ничего не хранит: офлайн-подписчик теряет события молча.** Сервис перезапустился на пять секунд, сетевые качели оборвали соединение — и все события за это время исчезли навсегда: ни алерта, ни уведомления, ни строки в логе. Типичная ошибка — строить на Pub/Sub очередь задач или доставку платежных уведомлений. Если потеря недопустима, берите потоки из следующего урока: у них есть история, подтверждения и дочитывание после офлайна. Число из Publish равное нулю — верный признак, что событие никого не застало.

### Инвалидация кэша событием: сценарий, где Pub/Sub уместна

Самый частый честный сценарий Pub/Sub в Go-сервисах — чистка локальных кэшей на нескольких инстансах. Схема: каждый инстанс держит горячий слой в памяти процесса, а общий кэш — в Redis; при записи в базу издатель публикует сообщение invalidated:user:42, все инстансы, подписанные на канал cache:invalidate, выкидывают этот ключ из локального слоя немедленно. Без событий каждый инстанс ждал бы собственного TTL — до десяти минут расхождения между репликами, и пользователь, обновивший аватар, видел бы старый на каждой второй странице. Событие дешевле даже короткого TTL и гарантированно синхронизирует инстансы. Обратите внимание на конструкцию: модель Pub/Sub применима именно потому, что потеря события не катастрофична — худший случай это устаревший локальный ключ до следующего TTL, а страховка остаётся на месте. Границу между стратегиями TTL, событием и write-through мы проводим в уроке про [инвалидацию кэша](/courses/go-redis/lessons/23-strategii-invali-dacii/).

### Pub/Sub рядом с очередью и журналом

| Критерий | Pub/Sub | Список (очередь) | Streams |
| --- | --- | --- | --- |
| Кому доставляется | Каждому подписчику копия | Ровно одному воркеру | Одному потребителю группы |
| Хранение | Нет: услышанное ушло, остальное исчезло | Задача вынимается из списка | Журнал остаётся после чтения |
| Подтверждения | Нет | Нет | XAck и pending-список |
| Чтение истории | Невозможно | Только ещё не вынутое | С любой отметки, включая начало |
| Типовая роль | События и инвалидация в реальном времени | Фоновые задачи и буфер пиков | Журнал с гарантиями и дочитыванием |

Пара вариаций, которые встречаются сразу после первого рабочего канала. Сообщения в Pub/Sub — строки без сжатия и без метаданных: не тащите в них объекты целиком, отправляйте идентификатор, а данные читайте из кэша или базы, иначе каждый инстанс получит тяжёлую копию каждого события. Паттерн-подписка PSubscribe экономит имена каналов: события сущностей уходят в events:orders, events:users, а аудит-модуль слушает events:* одним подписчиком. Права доступа не забывайте: любой клиент с доступом к Redis подписывается на любой канал, поэтому секретные вещи через Pub/Sub не рассылают. И держите в голове цену широковещательности: сто инстансов с подпиской — сто копий каждого сообщения по сети; это модель для оповещений, а не для передачи больших данных, где транспорт выбирают иначе. Ещё одна издержка — соединения: каждая подписка занимает отдельное соединение из пула, и сотня подписчиков в одном процессе выедет лимиты. Группируйте темы в несколько каналов и маршрутизируйте сообщения внутри процесса по имени канала, а не плодите подписку на каждый чих.

### Отладка в терминале: SUBSCRIBE и PUBLISH без Go

Прежде чем отлаживать подписку кодом, отладьте её руками: redis-cli умеет и то, и другое. В первом окне команда SUBSCRIBE events:orders переводит клиента в режим подписки, и терминал подтверждает её тремя строками. Во втором окне PUBLISH events:orders test-42 отправляет сообщение — и в первом окне мгновенно появляется тройка: тип message, имя канала, payload. Это пять секунд, которые заменяют половину отладочных логов: сразу видно, доходят ли события вообще, как выглядит payload и совпадают ли имена каналов — опечатка в имени канала самая частая причина «подписчик молчит». В Go-коде та же история проверяется проще: логируйте каждое принятое сообщение с именем канала, а для издателя — число из Publish. Ноль в логе издателя мгновенно объясняет тишину подписчика: событие отправилось, но никого не застало, и искать проблему в коде подписчика бессмысленно.

### Проверка: сообщение в пустой комнате

**Вопрос:** Издатель отправил сообщение, когда ни одного подписчика не было. Что увидит подписчик, подключившийся через минуту?

- ✅ Ничего: сообщение не сохраняется, истории каналов нет
- Сообщение с меткой об опоздании
- redis.Nil как при чтении пустой очереди
- Список пропущенных сообщений после подключения

Pub/Sub — чистый реалтайм: сервер доставляет сообщение только тем, кто подписан в момент публикации. Накопления, журнала и догоняющих доставки здесь нет по дизайну; для истории и дочитывания нужны Streams из следующего урока курса.

### Проверка: что возвращает Publish

**Вопрос:** Что означает число, которое возвращает rdb.Publish(ctx, channel, msg).Result()?

- ✅ Число подписчиков, которым сообщение доставлено прямо сейчас
- Общее число подписок на канал за всё время
- Длину очереди сообщений канала
- Количество байтов в сообщении

Publish отвечает числом клиентов, получивших сообщение в этот момент. Ноль — честный сигнал, что никто не услышал: полезно логировать или алертить, потому что модель сама о потере не сообщит больше нигде.

### Сколько подписчиков услышит?

```go
// горутина A подписана на "events:orders",
// горутина B подписана на "events:users",
// горутина C подписана на "events:orders"

n, _ := rdb.Publish(ctx, "events:orders", "created:42").Result()
fmt.Println(n)
```

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

- 0
- 1
- ✅ 2
- 3

Канал не нужно создавать отдельно: Publish доставил сообщение обоим подписчикам events:orders — горутинам A и C — и вернул 2. Горутина B слушает другой канал и в это число не входит; паттерны PSubscribe считаются отдельно по совпадению.

### Соберите подписку и публикацию

Допишите подписку на канал, отправку сообщения и синхронное чтение одного сообщения.

```go
sub := rdb.___(ctx, "events:orders")

_, _ = rdb.___(ctx, "events:orders", "created:42")

msg, _ := sub.___(ctx)
```

Подсказка: Слушает Subscribe, публикует Publish, а одно сообщение синхронно отдаёт ReceiveMessage у объекта подписки.

Правильные ответы в пропусках: `Subscribe`, `Publish`, `ReceiveMessage`

### Соберите подписчика-горутину

Расставьте строки по порядку: подписка на канал, запуск горутины, цикл чтения и печать сообщения.

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

1. `sub := rdb.Subscribe(ctx, "events:orders")`
2. `go func() {`
3. `    for msg := range sub.Channel() {`
4. `        fmt.Println(msg.Channel, msg.Payload)`
5. `    }`
6. `}()`

### Карточки: Pub/Sub

- **Publish** — Кладёт сообщение в канал и возвращает число подписчиков, получивших его сейчас. Ноль — событие никого не застало и исчезло навсегда.
- **Subscribe** — Подписка на конкретные каналы: переводит выделенное соединение в режим подписки и возвращает объект PubSub, который живёт всю жизнь процесса.
- **Channel()** — Go-канал с сообщениями от подписки: range по нему в горутине — типовой цикл чтения. Буфер настраивается WithChannelSize, медленный читатель приводит к разрыву соединения.
- **ReceiveMessage** — Синхронное чтение одного сообщения с блокировкой. Таймаут контекста прекращает ожидание — удобно в утилитах и тестах, где горутина не нужна.
- **PSubscribe** — Подписка на шаблон: events:* ловит все события сущностей одним подписчиком. Одно сообщение может прийти в несколько подписок сразу.
- **Главное ограничение Pub/Sub** — Никакого хранения: офлайн-подписчик теряет события молча. Для гарантий доставки — Streams с журналом и XAck, для распределения задач — список-очередь.

Модель издатель-подписчик встала на место: подписка в горутине живёт со стартом сервиса, события расходятся мгновенно, а границы применения очерчены честно — оповещение да, гарантия доставки нет. Дальше по курсу две соседние темы: урок про [Streams](/courses/go-redis/lessons/15-potoki-streams/) добавляет к событиям журнал, идентификаторы и потребительские группы, а контекст и таймауты из урока про [время ожидания Redis](/courses/go-redis/lessons/18-rabota-s-kontekstom/) защищают длинные подписки от зависаний. Доставку событий до браузера поверх такой рассылки удобно строить через WebSocket — там подписка превращается в push клиентам, как показано в курсе про [WebSocket на Go](/courses/websocket/). Полный план — на странице [курса по Redis](/courses/go-redis/); домашнее задание ниже соберёт три инстанса-подписчика в трёх терминалах, и одно из событий вы намеренно отправите в пустую комнату. Когда подписка заработает, добавьте к ней корректную остановку по сигналу: graceful shutdown подписчика — половина честной архитектуры событий, и проверить её стоит до продакшена, а не после первого инцидента деплоя.

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

- Pub/Sub рассылает копию сообщения всем подписчикам канала в момент публикации: издатель не знает адресатов, канал — просто имя.
- Subscribe возвращает объект PubSub на выделенном соединении; Channel() отдаёт Go-канал для горутины, Close возвращает соединение.
- ReceiveMessage читает одно сообщение синхронно с таймаутом контекста — способ для утилит и тестов, а не для сервисов.
- Модель ничего не хранит: офлайн-подписчик теряет события молча, а Publish вернёт ноль доставленных как единственный признак.
- Честный сценарий — инвалидация локальных кэшей нескольких инстансов: потеря события некритична, страховкой остаётся TTL.
- В сообщениях только идентификаторы, не объекты: каждая копия падает в каждый инстанс, и тяжёлые данные дешевле читать по id.

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

Соберите издателя и подписчика из урока: publishOrderCreated и startSubscriber с чтением в горутине. Запустите два подписчика в отдельных терминалах локальным Go с Redis в Docker, отправьте событие — убедитесь, что оба увидели его и Publish вернул два. Затем остановите один подписчик, отправьте ещё одно событие и перезапустите его: пропущенное сообщение не должно догнать — прочувствуйте это. В конце добавьте логирование нуля из Publish и проверьте сценарий без подписчиков.

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

### Pub/Sub или очередь на списках — что выбрать для задач?

Определите судьбу сообщения: если обработать должен один воркер — очередь на списке, каждый элемент уйдёт одному потребителю. Если узнать должны все инстансы — Pub/Sub, каждый получит копию. Задачи через Pub/Sub не распределяют: вместо одной обработки вы получите их по числу подписчиков.

### Как не терять события при рестарте сервиса?

Только сменой модели: потоки Redis хранят журнал после чтения, а потребительские группы с XAck помнят, что обработано. С Pub/Sub рестарт всегда означает потерю, и это нужно принимать конструктивно — либо как допустимую цену для оповещений, либо как сигнал выбрать Streams.

### Почему подписчик разрывается, если я не успеваю читать сообщения?

go-redis держит буфер канала с ограниченным размером и таймаутом отправки. Когда читатель отстаёт и буфер переполняется, клиент разрывает соединение и переподключается заново — пропущенные сообщения при этом не возвращаются. Увеличьте WithChannelSize, ускорьте обработку или вынесите тяжёлую работу из цикла чтения в отдельную горутину.
