Потеря сообщений Redis Pub/Sub: причины и надежные альтернативы
Узнайте, почему Redis Pub/Sub теряет сообщения при разрывах сети или медленных потребителях, и изучите такие паттерны, как Redis Streams и очереди на основе списков, для гарантированной доставки.
Потеря сообщений Redis Pub/Sub: причины и надежные альтернативы
Я помню, как впервые обжегся на Redis Pub/Sub. Было поздно, около 11 вечера, и наша система уведомлений начала терять сообщения. Не все — ровно столько, чтобы пользователи заметили раньше нас. Дежурный инженер (я, к сожалению) потратил два часа, копаясь в логах приложения, прежде чем стала очевидна правда: Redis Pub/Sub ничего не ставит в очередь. Это не брокер сообщений. Это пожарный шланг, и если вы не стоите прямо перед ним с открытым ртом, вы что-то пропустите.
Вот что никто не говорит вам, когда вы впервые беретесь за Redis Pub/Sub. Технически это есть в документации, но это легко упустить, когда вы взволнованы простотой API. Вы публикуете на одном конце, подписываетесь на другом, и это работает. Пока не перестает.
Реальность «забыл и забыл»
Redis Pub/Sub работает по жестоко простому принципу: когда вы публикуете сообщение, Redis отправляет его каждому подключенному подписчику в этом канале в тот же момент. Если подписчик не подключен или подключен, но не успевает обрабатывать, сообщение исчезает. Нет слоя постоянства, механизма подтверждения, очереди мертвых писем. Сообщение существует только в пути.
Приведу конкретный пример. Допустим, у вас есть сервис, публикующий обновления статуса заказа, и другой сервис, подписывающийся на отправку писем с подтверждением. При нормальной нагрузке все работает гладко. Затем ваш сервис электронной почты дает сбой — возможно, SMTP-ретранслятор медленный, или произошла пауза сборки мусора. Во время этого сбоя Redis продолжает отправлять сообщения. Буфер TCP подписчика заполняется. В конце концов соединение разрывается. Когда подписчик переподключается, он начинает с сейчас, а не с того места, где остановился. Все сообщения, опубликованные во время разрыва, потеряны.
Я измерил это на практике с помощью простой тестовой установки: издатель отправляет 10 000 сообщений в секунду, а подписчик иногда блокируется на 50 миллисекунд. Даже при одной короткой паузе вы потеряете десятки сообщений. Подписчик никогда не узнает, что они были отправлены. Издатель никогда не узнает, что они потеряны. Redis совершенно счастлив — он сделал именно то, для чего был спроектирован.
Что на самом деле вызывает потерю сообщений
Есть три основных сценария, в которых Pub/Sub теряет сообщения, и все их стоит понять, потому что они проявляются по-разному.
Нестабильность сети — самый очевидный. Любой временный сетевой раздел между подписчиком и Redis разрывает соединение. Redis обнаруживает это по таймауту клиента (по умолчанию 60 секунд, но у вас может быть установлено меньше). В течение этого окна все опубликованные сообщения теряются для этого подписчика. Другие подписчики могут получить их нормально, что делает отладку особенно веселой — вы увидите несогласованное состояние между сервисами и начнете сомневаться, не сошли ли вы с ума.
Медленные потребители более коварны, потому что соединение остается открытым. Redis использует push-модель, то есть записывает в сокеты подписчиков так быстро, как производят издатели. Если подписчик не может обрабатывать сообщения достаточно быстро, буфер приема TCP ядра заполняется. Как только этот буфер заполнен, Redis не может записать больше данных, и соединение в конечном итоге разрывается. Подписчик может даже не заметить, что отстает, пока не произойдет разрыв.
Я видел это на примере подписчиков, которые выполняют синхронные записи в базу данных для каждого сообщения. При низком объеме все нормально. На пике база данных становится узким местом, подписчик отстает, и сообщения накапливаются в TCP-буфере. Когда этот буфер переполняется, соединение сбрасывается, и подписчик теряет все, что еще не прочитал из сокета.
Отключения клиентов во время развертываний или перезапусков — третья большая категория. Если вы выполняете поэтапное развертывание и экземпляр подписчика выходит из строя, он пропускает все сообщения, опубликованные во время его отсутствия. Нет механизма «догони меня». Когда он возвращается в онлайн, он начинает с чистого листа.
Одна вещь, которая меня удивила: даже чистое завершение работы не помогает. Если ваш подписчик корректно отписывается перед выходом, он все равно пропускает сообщения, опубликованные между отпиской и возвратом. Отписка происходит мгновенно — нет опции «подержи мои сообщения минутку».
Когда Pub/Sub на самом деле подходит
Я не хочу сказать, что Redis Pub/Sub бесполезен. Он отлично подходит для определенных случаев использования, и я регулярно его использую. Ключ в том, чтобы понимать, что это за случаи.
Уведомления в реальном времени, где допустима периодическая потеря, работают прекрасно. Подумайте о спортивных результатах в реальном времени, биржевых тикерах или индикаторах набора текста в чат-приложении. Если пользователь пропустит обновление счета, следующее придет через несколько секунд. Данные имеют короткий срок жизни и не требуют долговечности.
Обнаружение сервисов и рассылка конфигураций — еще одна удачная область. Когда вы меняете функциональный флаг и публикуете его во все экземпляры приложения, нормально, если экземпляр, который в данный момент перезагружается, пропустит обновление — он подхватит текущее состояние, когда вернется в онлайн, или при следующем периодическом обновлении.
Я также успешно использовал Pub/Sub для инвалидации кэша на нескольких серверах приложений. Публикуйте ключ кэша для инвалидации, и каждый сервер очищает свой локальный кэш. Если один сервер пропустит сообщение, худший случай — он будет обслуживать устаревшие данные до истечения срока действия записи кэша. Не идеально, но и не катастрофично.
Общая нить здесь: Pub/Sub работает, когда сообщения по своей природе эфемерны, когда потерю можно восстановить с помощью других механизмов и когда вам не нужны гарантии порядка или доставки ровно один раз.
Redis Streams: встроенная альтернатива
Redis Streams, представленные в Redis 5.0, — это то, к чему я теперь обращаюсь, когда мне нужна надежная доставка сообщений. Это не Pub/Sub с прикрученным постоянством — это принципиально другая модель, более близкая к распределенному журналу, такому как Kafka, чем к механизму широковещательной рассылки.
С Streams сообщения добавляются в журнал и остаются там до явного подтверждения. Потребители могут отключаться, перезапускаться, отставать и все равно догонять. Поток сохраняет сообщения на основе либо максимальной длины, либо периода хранения, так что вы контролируете, сколько истории хранить.
Вот как отличается ментальная модель. В Pub/Sub вы подписываетесь на канал, и сообщения поступают к вам. В Streams вы извлекаете сообщения в своем темпе. Группа потребителей отслеживает, какие сообщения подтвердил каждый потребитель, так что вы можете иметь несколько потребителей, читающих из одного потока без дублирования (или с намеренным дублированием, если вам нужен fan-out).
Базовая настройка Streams выглядит примерно так:
XADD orders * status confirmed order_id 12345
Это добавляет сообщение в поток orders. * указывает Redis автоматически сгенерировать ID. Затем ваш потребитель читает с помощью:
XREADGROUP GROUP email-processor worker-1 COUNT 10 STREAMS orders >
> означает «дай мне сообщения, которые еще не были доставлены ни одному потребителю в этой группе». После обработки потребитель подтверждает:
XACK orders email-processor <message-id>
Если потребитель выходит из строя до подтверждения, сообщение остается в ожидании. Другой потребитель в группе может забрать его с помощью XCLAIM после таймаута. Это механизм подтверждения и повторной доставки, которого полностью не хватает Pub/Sub.
Модель группы потребителей на практике
Группы потребителей — это то, что делает Streams действительно полезными для надежной обработки. Каждая группа поддерживает свою позицию в потоке, так что вы можете иметь одну группу для уведомлений по электронной почте, другую для аналитики и третью для журнала аудита — все они независимо читают один и тот же поток.
Внутри группы сообщения распределяются между потребителями. Это дает вам горизонтальную масштабируемость: добавьте больше экземпляров потребителей, и они разделят нагрузку. Если один экземпляр умирает, его ожидающие сообщения становятся доступными для других экземпляров.
Я обнаружил, что список ожидающих записей неоценим для мониторинга. Вы можете выполнить XPENDING, чтобы увидеть, какие сообщения не были подтверждены и как долго они остаются невыполненными. Это сразу выявляет медленных потребителей — гораздо лучше, чем обнаруживать потерю сообщений через несколько дней по жалобам пользователей.
Одна загвоздка с Streams: ID сообщений — это монотонно возрастающие временные метки, что означает, что вы не можете легко вставлять сообщения не по порядку. Если вам нужен строгий порядок в потоке, это на самом деле функция. Если вам нужно приоритизировать определенные сообщения, вам понадобятся несколько потоков или другой подход.
Очереди на основе списков для более простых нужд
До появления Streams стандартным паттерном для надежного обмена сообщениями с Redis были очереди на основе списков с блокирующими операциями pop. Этот паттерн все еще вполне жизнеспособен, особенно если вы используете более старую версию Redis или хотите что-то максимально простое.
Идея проста: производители LPUSH или RPUSH сообщения в список, а потребители выполняют BLPOP или BRPOP, чтобы блокироваться до появления сообщения. Блокирующий pop критически важен — без него вам пришлось бы опрашивать, что тратит процессор и добавляет задержку.
Надежность обеспечивается вторичным списком «обработки». Потребитель атомарно перемещает сообщение из очереди ожидания в очередь обработки с помощью BRPOPLPUSH (или LMOVE в Redis 6.2+). После обработки он удаляет сообщение из очереди обработки. Если потребитель выходит из строя, очередь обработки сохраняет сообщение, и процесс монитора может переместить устаревшие элементы обратно в очередь ожидания.
Я строил этот паттерн несколько раз, и он работает, но это больше кода, чем можно ожидать. Вам нужно обрабатывать таймауты, решать, как долго сообщение может находиться в очереди обработки, прежде чем считать его брошенным, и разбираться с пограничными случаями дублирующей обработки. Streams по сути формализуют все это, поэтому я в основном отошел от самодельных очередей на списках.
Единственное место, где я все еще использую очереди на основе списков, — это рабочие очереди, где порядок обработки не важен и мне нужно максимально простое возможное решение. Иногда список и цикл BLPOP — это все, что нужно, и добавление Streams было бы излишеством.
Шардирование Pub/Sub в Redis 7
Redis 7 представил шардированный Pub/Sub, который стоит упомянуть, потому что он решает другую проблему, а не потерю сообщений. В обычном Pub/Sub каждое сообщение рассылается на каждый узел в кластере, даже если ни один подписчик на данном узле не заинтересован в этом канале. Это тратит пропускную способность межкластерного соединения.
Шардированный Pub/Sub привязывает каналы к определенным слотам кластера, поэтому сообщения распространяются только на узлы, на которых есть подписчики для этого канала. Это оптимизация производительности, а не функция надежности. Вы все равно будете терять сообщения при отключении. Но если вы используете Pub/Sub в масштабе в кластерной среде, стоит знать об этом.
Выбор: Pub/Sub против Streams против списков
После многих лет работы с этими паттернами мой процесс принятия решений упростился до нескольких вопросов.
Первый: можете ли вы терпеть потерю сообщений? Если да, и если данные эфемерны, Pub/Sub, вероятно, подойдет. Вы получите самую низкую задержку и самую простую операционную модель.
Второй: нужна ли вам постоянство сообщений и возможность воспроизведения? Если да, ответ — Streams. Возможность повторно обработать сообщения после исправления ошибки потребителя спасала меня не раз. С Pub/Sub, если у вашего потребителя была ошибка, из-за которой он неправильно обрабатывал сообщения в течение часа, эти сообщения потеряны навсегда. С Streams вы можете сбросить позицию группы потребителей и воспроизвести их.
Третий: нужны ли вам несколько независимых групп потребителей, читающих одни и те же данные? Streams обрабатывает это нативно. В Pub/Sub каждый подписчик получает каждое сообщение, что может быть тем, что вам нужно, но нет способа иметь разные группы подписчиков, поддерживающих независимые позиции.
Четвертый: какая у вас версия Redis? Если вы застряли на версии старше 5.0, Streams недоступны, и вы смотрите на очереди на основе списков или внешний брокер сообщений. Я был в такой ситуации, и, честно говоря, если вам нужен надежный обмен сообщениями и вы не можете использовать Streams, я бы подумал, является ли Redis правильным инструментом вообще. RabbitMQ или NATS могут подойти лучше.
Операционная сторона, о которой никто не говорит
Вот что я узнал на горьком опыте: мониторинг Pub/Sub обманчиво сложен. Вы можете отслеживать количество подключений и подписок на каналы с помощью PUBSUB NUMSUB, но вы не можете увидеть, сколько сообщений теряется. Нет метрики для «сообщений опубликовано, но не получено», потому что Redis не отслеживает это.
С Streams вы получаете видимость. XINFO GROUPS показывает отставание потребителя. XPENDING показывает неподтвержденные сообщения. Вы можете настроить оповещения, когда отставание превышает порог. Эта операционная видимость сама по себе сделала Streams стоящими перехода для меня.
Управление памятью — еще одно соображение. Сообщения Pub/Sub существуют только в памяти и только во время передачи, поэтому использование памяти ограничено скоростью публикации и скоростью потребителя. Streams хранят сообщения до тех пор, пока они не будут обрезаны, поэтому вам нужно думать о политиках хранения. Обычно я устанавливаю максимальную длину потока (MAXLEN) на основе ожидаемой пропускной способности и доступной памяти, и я отслеживаю длину потока, чтобы обнаружить неожиданные накопления.
Что я делаю сейчас
В наши дни я по умолчанию использую Redis Streams для любых новых случаев обмена сообщениями, требующих надежности. API немного сложнее, чем Pub/Sub, но не намного, и гарантии надежности того стоят. Я оставляю Pub/Sub для эфемерных вещей — инвалидации кэша, присутствия в реальном времени и тому подобного.
Для особенно критичного обмена сообщениями (обработка платежей, выполнение заказов) я отошел от Redis полностью и использую выделенные брокеры сообщений. Redis отлично подходит для многих вещей, но он не оптимизирован для дискового постоянства очередей сообщений с высоким объемом. Если вам нужно, чтобы сообщения пережили полный перезапуск Redis с нулевой потерей, вам нужно настроить персистентность AOF с appendfsync always, что снижает производительность записи. В этом случае что-то вроде Kafka или Pulsar имеет больше смысла.
Но для обширной средней области — где потеря сообщений была бы раздражающей или дорогостоящей, но не катастрофической, и где вы хотите оставаться в экосистеме Redis, которую уже знаете, — Streams попадают в точку. Они были достаточно надежны для меня в продакшене, и операционная простота отсутствия нового компонента инфраструктуры имеет реальную ценность.
Первоначальная ошибка, которую я совершил с Pub/Sub, на самом деле не была связана с технологией. Это было о том, что я не прочитал мелкий шрифт, предполагая, что «обмен сообщениями» подразумевает «гарантии доставки сообщений». Redis Pub/Sub не дает таких гарантий и не притворяется. Как только вы это поймете, вы сможете использовать его правильно и обращаться к Streams, когда вам нужно больше.