Pub/Sub против Streams: что выбрать

В Redis есть два способа передавать события между сервисами — лёгкий и быстрый Pub/Sub без памяти и надёжный Streams с историей. Разберём, чем они отличаются и когда какой выбрать.

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

Зачем на практике

Передача событий — обыденная задача: уведомить другие сервисы о новом заказе, разослать обновление в реальном времени, поставить задачу в очередь на обработку. Redis предлагает два инструмента с противоположными приоритетами. Pub/Sub оптимизирован под минимальную задержку и простоту, но ничего не гарантирует. Streams оптимизирован под надёжность: ни одно событие не потеряется, его можно переобработать. Выбор между ними — это выбор между «быстро и эфемерно» и «надёжно и с историей».

Pub/Sub: fire-and-forget

Модель проста: издатель шлёт сообщение в канал командой PUBLISH, а все, кто подписан на канал через SUBSCRIBE в этот момент, его получают. Сообщение нигде не сохраняется — отдал подписчикам и забыл.

# подписчик (одно соединение, слушает канал)
127.0.0.1:6379> SUBSCRIBE news
Reading messages... (press Ctrl-C to quit)

# издатель (другое соединение)
127.0.0.1:6379> PUBLISH news "Вышла новая статья"
(integer) 1     # число подписчиков, получивших сообщение

Число в ответе PUBLISH — это сколько подписчиков получили сообщение прямо сейчас. Если подписчиков нет, сообщение просто исчезает: PUBLISH вернёт 0, и никто никогда его не увидит. Подписчик, который подключится через секунду, пропущенные сообщения уже не получит — истории нет.

Когда Pub/Sub — правильный выбор

Pub/Sub идеален там, где потеря отдельного сообщения не критична, а важна скорость и простота: живые уведомления в интерфейсе, обновление онлайн-дашборда, рассылка «инвалидируй кеш» по сервисам, чат-комнаты «здесь и сейчас». Если подписчик на мгновение отвалился и пропустил пару апдейтов — ничего страшного, следующий придёт.

Streams: надёжность и переигрывание

Stream — это упорядоченный журнал записей с уникальными ID (по времени). События добавляются командой XADD и остаются в потоке, пока вы их не удалите. Читать можно как угодно: с начала, с конкретного ID, только новые — и сколько угодно раз.

127.0.0.1:6379> XADD orders * sku A-100 qty 2
"1718900000000-0"      # автоматически присвоенный ID события
127.0.0.1:6379> XADD orders * sku B-200 qty 1
"1718900000050-0"
# прочитать всю историю потока с самого начала
127.0.0.1:6379> XRANGE orders - +
1) 1) "1718900000000-0"
   2) 1) "sku" 2) "A-100" 3) "qty" 4) "2"
2) 1) "1718900000050-0"
   2) 1) "sku" 2) "B-200" 3) "qty" 4) "1"

В отличие от Pub/Sub, потребитель, который подключился позже, спокойно прочитает все накопленные события — они никуда не делись. Это и есть переигрывание (replay).

Группы потребителей и подтверждения

Главная сила Streams — consumer groups. Группа позволяет нескольким воркерам делить поток: каждое событие уходит ровно одному потребителю в группе, а после обработки воркер подтверждает его командой XACK. Неподтверждённые события остаются в «списке ожидания» (PEL) и при сбое воркера могут быть переданы другому — гарантия «хотя бы один раз».

# создать группу, читающую orders с самого начала
XGROUP CREATE orders workers 0
# воркер забирает новые сообщения для себя (имя consumer = w1)
XREADGROUP GROUP workers w1 COUNT 10 STREAMS orders >
# обработали — подтверждаем по ID
XACK orders workers 1718900000000-0

Смоделируем разницу в доставке: Stream хранит все события и отдаёт их позднему потребителю, а Pub/Sub доставляет только тем, кто был онлайн.

events = []
def stream_publish(msg): events.append(msg)            # XADD: остаётся в потоке
def pubsub_publish(msg, online): return list(online)   # доставлено лишь онлайн

stream_publish("e1"); stream_publish("e2"); stream_publish("e3")
print("Stream хранит историю:", events)
print("Поздний потребитель переиграет:", events)       # доступны все три

online_at_e2 = ["A"]                                    # подписчик B офлайн
delivered = pubsub_publish("e2", online_at_e2)
print("Pub/Sub доставил e2 только:", delivered, "(B потерял сообщение навсегда)")

Вывод:

Stream хранит историю: ['e1', 'e2', 'e3']
Поздний потребитель переиграет: ['e1', 'e2', 'e3']
Pub/Sub доставил e2 только: ['A'] (B потерял сообщение навсегда)

Что выбрать: сравнение

СвойствоPub/SubStreams
История сообщенийнетда, хранится в потоке
Доставка офлайн-подписчикусообщение теряетсяпрочитает позже
Переигрывание (replay)невозможнос любого ID, многократно
Подтверждения (ack)нетда (XACK, PEL)
Несколько воркеров делят нагрузкунет (все получают всё)да (consumer groups)
Памятьпочти не растётрастёт, нужна обрезка (MAXLEN)
Когда братьживые уведомления, инвалидация кешаочереди задач, событийные конвейеры, надёжность

Как это работает под капотом

В Pub/Sub сервер держит таблицу «канал → список подписанных соединений». При PUBLISH он проходит этот список и пишет сообщение в выходной буфер каждого подписчика — и тут же забывает о сообщении, нигде его не сохраняя. Нет подписчиков — некуда писать, сообщение исчезает. Stream же — полноценная структура данных в keyspace: записи лежат в памяти (и попадают в RDB/AOF при включённой персистентности), у каждой свой монотонный ID. Группа потребителей хранит «последний выданный ID» и PEL — список выданных, но ещё не подтверждённых записей по каждому потребителю. Именно PEL и XACK дают гарантию обработки: пока запись не подтверждена, она числится незавершённой и может быть перевыдана через XCLAIM. Поскольку история копится, поток обрезают по длине (XADD ... MAXLEN ...) или по времени, иначе память будет расти бесконечно.

Частые ошибки

  • Очередь задач на Pub/Sub. Упал воркер — задачи, пришедшие в это время, потеряны навсегда. Для очередей берите Streams.
  • Ждать доставку офлайн-подписчику в Pub/Sub. Кто не подключён в момент PUBLISH, сообщение не получит — истории нет.
  • Streams без обрезки. Без MAXLEN/обрезки по времени поток растёт неограниченно и съедает память.
  • Забыть XACK. Без подтверждения записи копятся в PEL и считаются необработанными; кажется, что «всё зависло».
  • Брать Streams ради простого уведомления. Где потеря сообщения не страшна и важна задержка — Pub/Sub проще и легче.

Итоги

  • Pub/Sub — «опубликовал и забыл»: быстро, просто, без истории; доходит только до тех, кто онлайн.
  • Streams — журнал событий с историей, подтверждениями и группами потребителей: ничего не теряется.
  • Только Streams умеют переигрывание и распределение нагрузки между воркерами (consumer groups + XACK).
  • Pub/Sub почти не ест память; Streams растут — обрезайте поток через MAXLEN или по времени.
  • Выбор: живые уведомления и инвалидация кеша — Pub/Sub; очереди задач и надёжные событийные конвейеры — Streams.
Проверьте себя
1. Что произойдёт с сообщением Pub/Sub, если в момент PUBLISH ни один подписчик не подключён?
AСообщение сохранится и будет доставлено следующему подписавшемуся
BСообщение исчезнет навсегда — Pub/Sub не хранит историю (fire-and-forget)
CRedis повторит PUBLISH через несколько секунд
DСообщение запишется в Stream автоматически
2. Какой механизм Redis выбрать для надёжной очереди задач, где ни одно событие нельзя потерять и нужно распределять работу между несколькими воркерами?
APub/Sub — он быстрее и проще
BStreams с consumer groups: события хранятся, подтверждаются через XACK, а группа делит их между воркерами
CОбычный SET с TTL
DMULTI/EXEC поверх Pub/Sub