Долговечность: durable и persistent

Перезапустили брокер — и очередь с сорока тысячами заказов исчезла бесследно. Разбираем два флага, которые работают только вместе.

durable — свойство очереди (и exchange): пережить перезапуск брокера. persistent (delivery_mode=2) — свойство сообщения: быть записанным на диск. Одно без другого не спасает.

Начинающий разработчик обычно узнаёт про эти флаги в момент, когда админ обновляет RabbitMQ на проде. Локально всё работало годами — потому что локально брокер не перезапускался. Стоит контейнеру уехать в рестарт, как выясняется, что по умолчанию в RabbitMQ ничего не долговечно: и очередь, и сообщения живут в памяти, и оба флага надо просить явно.

Два независимых флага

Флаги независимы, поэтому у нас четыре комбинации — и три из них ничего не гарантируют:

ОчередьСообщениеЧто останется после рестарта брокера
transient (по умолчанию)transient (по умолчанию)Ничего: ни очереди, ни сообщений
transientpersistentНичего. Очередь исчезла — сообщения ушли вместе с ней, persistent сам по себе бесполезен
durabletransientОчередь на месте, но пустая. Классическое «куда делись заказы?»
durablepersistentОчередь на месте, сообщения на месте

Логика простая, если помнить, что это про разные сущности. durable у очереди — это про метаданные: имя, аргументы, привязки. Брокер записывает их в свою служебную базу схемы и восстанавливает при старте. Про содержимое очереди durable не говорит вообще ничего — за содержимое отвечает флаг на каждом отдельном сообщении.

Как объявить правильно

Полная «цепочка долговечности» на стороне продюсера:

import pika

connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()

channel.exchange_declare(exchange="orders", exchange_type="direct", durable=True)
channel.queue_declare(queue="orders.new", durable=True)
channel.queue_bind(queue="orders.new", exchange="orders", routing_key="new")

channel.confirm_delivery()   # publisher confirms: ждём подтверждения от брокера

channel.basic_publish(
    exchange="orders",
    routing_key="new",
    body=b'{"order_id": 42, "sum": 1990}',
    properties=pika.BasicProperties(
        delivery_mode=pika.DeliveryMode.Persistent,   # то же самое, что delivery_mode=2
        message_id="order-42",
        content_type="application/json",
    ),
    mandatory=True,
)
print("брокер подтвердил, что сообщение записано")

Что здесь важно и почему:

  • exchange_declare(..., durable=True). Про exchange забывают чаще всего. Если он транзиентный, после рестарта его просто не будет, и продюсер начнёт публиковать в несуществующий exchange — а брокер, если не попросить иначе, молча выбросит такие сообщения.
  • queue_declare(..., durable=True). Очередь и её привязки переживут перезапуск.
  • delivery_mode=pika.DeliveryMode.Persistent — это константа со значением 2 (1 — Transient). Она говорит брокеру: тело сообщения обязано лечь на диск.
  • confirm_delivery() включает на канале режим publisher confirms. С ним basic_publish в BlockingConnection блокируется до ответа брокера и бросает исключение, если сообщение не принято или некуда маршрутизировать. Для persistent-сообщения в durable-очереди брокер присылает confirm после того, как реально сохранил сообщение.
  • mandatory=True — если по routing key не подошла ни одна очередь, брокер вернёт сообщение продюсеру, а не выбросит. Опечатка в routing key перестаёт быть невидимой.

Почему одного persistent мало

Полезно держать в голове всю цепочку, по которой идёт сообщение: продюсер → сеть → exchange → очередь → диск → консьюмер. Долговечность — это свойство самого слабого звена. Транзиентный exchange, транзиентная очередь, публикация без confirms, auto_ack у консьюмера — каждый из этих пунктов в одиночку обнуляет все остальные усилия. «Мы поставили delivery_mode=2, а сообщения всё равно теряются» почти всегда означает, что где-то в цепочке дыра.

Реплики: кворумные очереди

Диск одного узла тоже смертен. Если брокер собран в кластер, вместо классической очереди берут quorum queue — реплицированную очередь на алгоритме Raft:

channel.queue_declare(
    queue="orders.new",
    durable=True,
    arguments={"x-queue-type": "quorum"},   # реплика на большинстве узлов кластера
)

Кворумная очередь всегда durable и всегда пишет на диск, а подтверждение продюсеру уходит только после записи на большинство узлов. delivery_mode для неё теряет смысл — она хранит всё. Плата — больше дисков, больше сети и меньше пропускная способность. Классические очереди остаются там, где важна скорость, а не сохранность.

Как это работает

Persistent-сообщение попадает в message store — общий журнал на диске, а ссылка на него пишется в индекс очереди. При старте брокер читает индекс и восстанавливает очередь. Здесь есть три неочевидных момента.

Запись не равна fsync. Брокер копит записи и сбрасывает их на диск батчами — раз в несколько сотен миллисекунд. Между «данные ушли в страничный кэш ОС» и «данные физически на диске» есть окно. Выдернули питание в это окно — persistent-сообщение всё-таки может пропасть. Именно эту дыру закрывают publisher confirms: confirm приходит после fsync, поэтому «продюсер получил подтверждение» и есть настоящая граница долговечности.

Транзиентное сообщение тоже может оказаться на диске. Под давлением памяти брокер выгружает тела сообщений на диск (page out), чтобы не словить memory alarm. Это своп, а не долговечность: при рестарте такие сообщения не восстановятся. Не обманывайтесь, увидев рост дискового I/O на транзиентной очереди.

Флаги очереди неизменяемы. Нельзя «дообъявить» существующую transient-очередь как durable. Попытка объявить очередь с параметрами, отличными от текущих, закрывает канал:

Channel closed by broker: 406 (PRECONDITION_FAILED) - inequivalent arg 'durable'
for queue 'orders.new' in vhost '/': received 'true' but current is 'false'

Варианта два: удалить очередь (разобравшись, что делать с её содержимым) или завести новую под другим именем и переключить консьюмеров. На проде это отдельная мини-операция, поэтому durable-флаги стоит ставить сразу, а не «потом, когда пойдёт нагрузка».

Цена в производительности

Долговечность оплачивается диском. Порядок величин такой: классическая очередь с транзиентными сообщениями легко держит десятки тысяч сообщений в секунду, та же очередь с persistent — обычно единицы тысяч. Точные числа зависят от размера сообщения, диска и того, ждёте ли вы confirm на каждое сообщение, — меряйте у себя (в поставке есть rabbitmq-perf-test). Что помогает не платить лишнего:

  • Не ждать confirm на каждое сообщение синхронно. Это добавляет полный round-trip до брокера к каждой публикации. Для потоков публикуйте пачками и подтверждайте пачками (в pika — асинхронный SelectConnection с колбэками confirm).
  • Не ставить persistent «на всякий случай» везде. Лог кликов и метрики отлично живут транзиентными.
  • Держать очереди короткими. Очередь-свалка на миллионы сообщений заставляет брокер работать с диском, а не с памятью, и деградирует всё. Быстрый консьюмер надёжнее любых флагов.

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

  • Поставили durable=True у очереди и забыли delivery_mode. Самый популярный сценарий «очередь есть, сообщений нет».
  • Забыли durable=True у exchange. После рестарта продюсер публикует в пустоту, а без mandatory=True — ещё и молча.
  • Переобъявили очередь с новыми флагами. Получили 406 PRECONDITION_FAILED и закрытый канал. Параметры очереди на лету не меняются.
  • auto_delete=True или exclusive=True на «важной» очереди. Такая очередь исчезнет, как только отключится последний консьюмер, — durable ей не поможет.
  • Полагаться на persistent без publisher confirms. Успешный basic_publish сам по себе означает лишь «данные записаны в сокет». Брокер мог не принять сообщение, а вы об этом не узнаете.
  • Забыть про свободное место на диске. Когда его остаётся меньше disk_free_limit (по умолчанию 50 МБ), брокер поднимает alarm и блокирует всех продюсеров — приложение намертво зависает на basic_publish. Свободное место на диске брокера — обязательная метрика в мониторинге.
  • Считать, что durable = «ничего никогда не потеряется». Долговечность — это цепочка: durable exchange, durable queue, persistent message, publisher confirms, ручной ack на стороне консьюмера. И реплики, если не готовы терять данные вместе с диском одного узла.

Итоги

  • По умолчанию в RabbitMQ ничего не переживает рестарт: и очереди, и сообщения транзиентны.
  • durable=True у очереди и exchange сохраняет метаданные; delivery_mode=2 (Persistent) сохраняет тело сообщения. Нужны оба — по отдельности они бесполезны.
  • Publisher confirms (confirm_delivery()) — граница, после которой сообщение действительно на диске. Без них persistent даёт лишь надежду.
  • Флаги очереди нельзя изменить после создания: неверное объявление приводит к 406 PRECONDITION_FAILED.
  • Нужна устойчивость к смерти узла — берите quorum queue: реплика на большинстве узлов кластера.
  • Долговечность стоит производительности. Включайте её осознанно и там, где потеря сообщения дороже задержки.
Проверьте себя
1. Очередь объявлена с durable=True, а сообщения публикуются без указания delivery_mode. Что останется после перезапуска брокера?
AОчередь и все сообщения: durable покрывает и то, и другое
BПустая очередь: сама очередь восстановится, а сообщения пропадут
CНичего: без persistent-сообщений очередь тоже считается транзиентной
DСообщения останутся, но привязки придётся создавать заново
2. Зачем нужны publisher confirms, если сообщение и так persistent, а очередь durable?
AБез confirms брокер не станет записывать сообщение на диск
BУспешный basic_publish означает лишь запись в сокет; confirm приходит после того, как брокер действительно сохранил сообщение
CConfirms ускоряют запись на диск, объединяя её в батчи
DБез confirms сообщение невозможно маршрутизировать по routing key