Брокеры сообщений и Celery #
1. Что такое Celery #
Что такое Celery #
Celery — это Python-фреймворк для выполнения задач вне основного процесса приложения: в фоне, по расписанию или на отдельных серверах.
Приложение не выполняет тяжёлую операцию непосредственно. Оно отправляет сообщение с заданием в очередь, после чего отдельный процесс Celery Worker получает и выполняет его. Celery называет такую единицу работы task.
Клиент
│
▼
FastAPI / Django
│ отправляет задачу
▼
Broker: Redis / RabbitMQ
│
▼
Celery Worker
│ выполняет задачу
▼
Result Backend — при необходимости
Пример задачи #
Пользователь регистрируется, после чего ему нужно отправить письмо.
Без Celery:
@app.post("/register")
def register(data: RegisterData):
user = create_user(data)
send_email(user.email) # запрос ждёт отправки письма
return {"user_id": user.id}
Если почтовый сервер отвечает пять секунд, пользователь будет ждать эти пять секунд.
С Celery:
@app.post("/register")
def register(data: RegisterData):
user = create_user(data)
send_email.delay(user.email)
return {"user_id": user.id}
Метод .delay() не отправляет письмо самостоятельно. Он помещает сообщение о задаче в брокер, после чего свободный Worker выполняет её отдельно.
Основные компоненты #
1. Producer #
Приложение, которое создаёт задачу:
send_email.delay("user@example.com")
Producer может быть:
FastAPI;
Django;
CLI-команда;
другой Worker;
периодический планировщик Celery Beat.
2. Broker #
Broker хранит и передаёт сообщения с задачами:
Приложение → Broker → Worker
Часто используют:
RabbitMQ;
Redis;
Amazon SQS.
RabbitMQ является полноценным брокером сообщений; Redis в Celery может использоваться и как broker, и как result backend.
Broker не выполняет задачу. Он только передаёт сообщение Worker-у.
Пример содержимого сообщения:
{
"task": "tasks.send_email",
"args": ["user@example.com"],
"kwargs": {}
}
3. Worker #
Worker — отдельный процесс, который постоянно получает задачи из очереди и выполняет их:
celery -A tasks worker --loglevel=INFO
Можно запустить несколько Worker-ов:
┌── Worker 1
Redis/RabbitMQ ───┼── Worker 2
├── Worker 3
└── Worker 4
Это позволяет распределять задачи между несколькими процессами или машинами. Celery поддерживает работу с несколькими Worker-ами и горизонтальное масштабирование.
4. Result Backend #
Result Backend хранит состояние и результат задачи:
PENDING
STARTED
SUCCESS
FAILURE
RETRY
REVOKED
Например:
result = add.delay(10, 20)
print(result.id)
print(result.status)
print(result.get())
В качестве backend могут использоваться Redis, SQL-база, RPC и другие поддерживаемые хранилища. Result Backend необязателен: если результат выполнения не нужен, его можно не настраивать.
Минимальный пример #
Установка:
pip install celery redis
Файл tasks.py:
from celery import Celery
app = Celery(
"tasks",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
@app.task
def add(left: int, right: int) -> int:
return left + right
Запуск Worker:
celery -A tasks worker --loglevel=INFO
Отправка задачи:
from tasks import add
result = add.delay(10, 20)
print(result.id)
Происходит следующее:
1. add.delay(10, 20) создаёт сообщение
2. Сообщение попадает в Redis
3. Worker получает сообщение
4. Worker вызывает add(10, 20)
5. Результат 30 сохраняется в Redis backend
Получение результата:
print(result.get(timeout=10))
Результат:
30
В веб-приложении обычно не следует вызывать result.get() прямо внутри HTTP-запроса: это снова заставит запрос ждать завершения фоновой задачи.
Что обычно выполняют через Celery #
Типичные задачи:
отправка электронной почты;
обработка загруженных файлов;
генерация PDF и отчётов;
изменение размера изображений;
импорт и экспорт данных;
обращения к медленным внешним API;
отправка уведомлений;
периодическая очистка данных;
массовая обработка записей;
повторные попытки после временных ошибок.
Например:
@app.task
def generate_report(report_id: int) -> None:
report = load_report(report_id)
file_path = create_pdf(report)
upload_to_storage(file_path)
HTTP-запрос только запускает работу:
task = generate_report.delay(report_id)
return {
"task_id": task.id,
"status": "processing",
}
Повторные попытки #
Celery позволяет повторить задачу после временной ошибки:
from celery import Celery
app = Celery("tasks")
@app.task(
autoretry_for=(ConnectionError,),
retry_backoff=True,
retry_kwargs={"max_retries": 5},
)
def call_external_api() -> None:
send_request()
Например, если внешний сервис временно недоступен, Celery повторит операцию позднее. Celery поддерживает автоматический retry через параметры декоратора задачи.
Задачи с повторными попытками желательно делать идемпотентными:
один запуск → корректный результат;
повторный запуск → тот же корректный результат,
без двойного списания или записи.
Например, вместо безусловного создания платежа:
create_payment(order_id)
нужно проверять уникальный идентификатор операции:
if payment_exists(idempotency_key):
return
create_payment(
order_id=order_id,
idempotency_key=idempotency_key,
)
Это важно, поскольку сочетание повторных попыток, позднего подтверждения сообщения и аварий Worker-а может привести к повторной обработке задачи. Документация Celery отдельно связывает acks_late с требованием идемпотентности задачи.
Периодические задачи и Celery Beat #
Celery Beat — планировщик, который отправляет задачи по расписанию:
Celery Beat → Broker → Celery Worker
Сам Beat не выполняет задачу. Её выполняет Worker.
Пример:
app.conf.beat_schedule = {
"clear-expired-tokens": {
"task": "tasks.clear_expired_tokens",
"schedule": 3600.0,
},
}
Задача будет отправляться каждый час.
Запуск:
celery -A tasks beat --loglevel=INFO
Celery Beat поддерживает интервальные и периодические расписания; в каждый момент обычно должен работать один экземпляр планировщика для конкретного расписания, иначе задачи могут создаваться повторно.
Несколько очередей #
Разные типы задач можно разделить:
emails → Worker для писем
reports → Worker для отчётов
images → Worker для изображений
Например:
app.conf.task_routes = {
"tasks.send_email": {"queue": "emails"},
"tasks.generate_report": {"queue": "reports"},
}
Запуск отдельных Worker-ов:
celery -A tasks worker -Q emails --loglevel=INFO
celery -A tasks worker -Q reports --loglevel=INFO
Это позволяет независимо масштабировать разные типы работы.
Celery не создаёт отдельный поток в FastAPI #
Важно различать:
async def
и:
Celery task
async def позволяет эффективно ожидать I/O внутри процесса приложения.
Celery передаёт работу другому процессу, Worker-у или серверу:
FastAPI process
│
▼
Broker
│
▼
Celery worker process
Поэтому Celery применяют, когда работа должна:
продолжаться после завершения HTTP-запроса;
переживать перезапуск веб-приложения при корректно настроенном брокере;
выполняться отдельными Worker-ами;
распределяться между машинами;
повторяться после ошибки;
запускаться по расписанию.
Недостатки Celery #
Celery добавляет инфраструктурную сложность:
нужен брокер;
нужно запускать Worker-ы;
нужно контролировать очереди;
нужны мониторинг и логирование;
нужно обрабатывать повторные задачи;
необходимо следить за зависшими задачами;
нужно проектировать идемпотентность;
нужно управлять результатами и сроком их хранения.
Поэтому Celery не требуется для любой маленькой фоновой операции.
Итог #
Celery — система распределённого выполнения фоновых задач.
Приложение создаёт задачу.
Broker передаёт её.
Worker выполняет.
Result Backend при необходимости сохраняет результат.
Celery Beat создаёт задачи по расписанию.
Основная схема:
FastAPI / Django
│
│ task.delay(...)
▼
Redis / RabbitMQ
│
▼
Celery Worker
│
├── отправляет письмо
├── создаёт отчёт
├── обрабатывает файл
└── вызывает внешний API
2. Для каких целей применяется Celery? #
Для каких целей применяется Celery #
Celery применяют для выполнения задач вне основного процесса веб-приложения и распределения работы между отдельными Worker-процессами или серверами.
Общая схема:
FastAPI / Django
│
│ отправляет сообщение о задаче
▼
Redis / RabbitMQ / другой broker
│
▼
Celery Worker
│
└── выполняет задачу
Приложение публикует сообщение в очередь, брокер доставляет его Worker-у, а Worker выполняет функцию, зарегистрированную как Celery task. Несколько Worker-ов позволяют распределять работу и горизонтально масштабировать обработку.
1. Длительные фоновые операции #
Celery используют, когда HTTP-запрос не должен ждать завершения операции:
@app.post("/reports")
async def create_report(report_id: int):
generate_report.delay(report_id)
return {
"report_id": report_id,
"status": "processing",
}
Типичные задачи:
генерация PDF и отчётов;
обработка изображений и видео;
импорт и экспорт данных;
архивация файлов;
массовая обработка записей;
обращение к медленным внешним API.
Пользователь получает ответ сразу, а обработка продолжается отдельным Worker-ом.
2. Отправка писем и уведомлений #
Отправка электронной почты, push-уведомлений или сообщений во внешние системы может быть медленной либо временно завершаться ошибкой:
@app.task
def send_registration_email(user_id: int) -> None:
user = load_user(user_id)
email_client.send(user.email)
Запуск:
send_registration_email.delay(user.id)
Это отделяет бизнес-операцию от доступности SMTP-сервера или стороннего провайдера.
Важно сначала сохранить основное состояние в БД, а затем надёжно опубликовать задачу. Простая последовательность:
save_user()
send_email.delay(user.id)
может потерять задачу, если приложение завершится между двумя действиями. Для критичных операций обычно применяют Transactional Outbox либо публикацию после успешного завершения транзакции.
3. Повторные попытки после временных ошибок #
Celery поддерживает повторный запуск задач:
@app.task(
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True,
retry_kwargs={"max_retries": 5},
)
def request_external_service(order_id: int) -> None:
external_client.send_order(order_id)
Повторные попытки полезны при временной недоступности:
внешнего API;
SMTP-сервера;
объектного хранилища;
сетевого ресурса;
удалённого микросервиса.
При вызове retry Celery отправляет новое сообщение с тем же идентификатором задачи и сохраняет исходную очередь; автоматический retry настраивается через autoretry_for.
Задача должна быть идемпотентной, поскольку повторное выполнение не должно создавать второй платёж, второй заказ или повторную запись:
@app.task
def process_payment(payment_id: str) -> None:
if payment_already_processed(payment_id):
return
execute_payment(payment_id)
Документация Celery прямо связывает позднее подтверждение сообщения через acks_late с требованием идемпотентности задачи.
4. Параллельная обработка больших объёмов данных #
Большой набор данных можно разделить на независимые части:
1 000 000 записей
↓
батчи по 1 000 записей
↓
1 000 Celery-задач
↓
несколько Worker-ов
Пример:
@app.task
def process_batch(record_ids: list[int]) -> int:
processed = 0
for record_id in record_ids:
process_record(record_id)
processed += 1
return processed
Celery предоставляет Canvas-примитивы для организации подобных процессов:
group — параллельный запуск задач;
chain — последовательное выполнение;
chord — группа задач с финальной задачей;
chunks — разбиение данных на части.
Эти механизмы входят в официальный Canvas API Celery.
Пример параллельной обработки:
from celery import group
workflow = group(
process_batch.s(batch)
for batch in batches
)
workflow.apply_async()
5. Выполнение цепочек задач #
Иногда следующая задача должна запускаться после предыдущей:
получить файл
↓
проверить файл
↓
обработать данные
↓
сформировать отчёт
↓
уведомить пользователя
Это можно представить через chain:
from celery import chain
workflow = chain(
download_file.s(file_id),
validate_file.s(),
process_file.s(),
generate_report.s(),
)
workflow.apply_async()
Для параллельной обработки с итоговой агрегацией используется chord:
from celery import chord
chord(
process_batch.s(batch)
for batch in batches
)(
aggregate_results.s(job_id)
)
Callback у chord запускается после завершения группы задач; для работы chord обычно требуется подходящий result backend.
6. Периодические задачи #
Celery Beat используется как планировщик:
Celery Beat
│ создаёт задачи по расписанию
▼
Broker
▼
Celery Worker
Пример:
app.conf.beat_schedule = {
"delete-expired-sessions": {
"task": "tasks.delete_expired_sessions",
"schedule": 3600.0,
},
}
Типичные периодические операции:
очистка истёкших токенов;
формирование ежедневных отчётов;
синхронизация с внешней системой;
проверка неоплаченных заказов;
удаление временных файлов;
обновление кеша.
Celery Beat только публикует задачи по расписанию; непосредственно их выполняют Worker-ы. Для одного расписания должен работать один активный scheduler, иначе возможна публикация дублирующихся задач.
7. Разделение задач по очередям #
Разные виды задач можно направлять в разные очереди:
emails → Worker-ы для писем
reports → Worker-ы для отчётов
payments → Worker-ы для платёжных операций
images → Worker-ы для обработки изображений
Пример конфигурации:
app.conf.task_routes = {
"tasks.send_email": {
"queue": "emails",
},
"tasks.generate_report": {
"queue": "reports",
},
"tasks.resize_image": {
"queue": "images",
},
}
Запуск Worker-а только для определённой очереди:
celery -A application worker -Q reports --loglevel=INFO
Celery поддерживает маршрутизацию задач в разные очереди через task_routes и конфигурацию очередей.
Это позволяет:
независимо масштабировать Worker-ы;
не блокировать быстрые задачи тяжёлыми;
задавать разную concurrency;
использовать разные лимиты ресурсов;
разделять задачи по приоритету и назначению.
8. Отложенный запуск #
Задачу можно запланировать на определённое время или через интервал:
send_reminder.apply_async(
args=[order_id],
countdown=300,
)
Либо:
send_reminder.apply_async(
args=[order_id],
eta=scheduled_datetime,
)
Это подходит для коротких отложенных операций:
отправить напоминание через пять минут;
повторить проверку позднее;
снять временную блокировку;
проверить статус внешней операции.
Для большого количества задач, запланированных далеко в будущем, зачастую надёжнее хранить расписание в БД и периодически выбирать готовые к запуску записи, а не удерживать множество ETA-задач внутри инфраструктуры Celery.
9. Сохранение статуса и результата задачи #
При настроенном result backend можно получить:
идентификатор задачи;
текущее состояние;
результат;
информацию об ошибке.
Пример:
result = generate_report.delay(report_id)
return {
"task_id": result.id,
}
Позже:
from celery.result import AsyncResult
result = AsyncResult(task_id)
return {
"status": result.status,
"result": result.result if result.successful() else None,
}
Broker и result backend выполняют разные задачи:
Broker — передаёт сообщения Worker-ам;
Result Backend — хранит состояния и результаты.
Celery поддерживает различные брокеры и result backend; например, RabbitMQ обычно используется как broker, а Redis может выступать и broker, и backend.
Для длительных бизнес-процессов часто лучше хранить пользовательский статус отдельно в своей БД:
PENDING
PROCESSING
COMPLETED
PARTIALLY_COMPLETED
FAILED
Celery-состояние описывает техническое выполнение задачи, а запись в доменной БД — состояние бизнес-операции.
Когда Celery оправдан #
Celery обычно полезен, когда необходимо хотя бы одно из следующего:
выполнить работу после завершения HTTP-запроса;
распределить обработку между процессами или серверами;
буферизовать всплески нагрузки;
повторять временно неудачные операции;
разделять задачи по очередям;
выполнять задачи по расписанию;
строить цепочки и группы фоновых операций;
масштабировать Worker-ы независимо от API.
Когда Celery может быть избыточен #
Celery не всегда нужен для:
очень короткой некритичной операции;
обычного async I/O внутри текущего запроса;
задачи, результат которой необходим до ответа клиенту;
небольшого проекта без потребности в очередях и Worker-ах.
Например, asyncio помогает конкурентно ожидать I/O внутри процесса, но не предоставляет отдельную устойчивую очередь, независимые Worker-ы, маршрутизацию и повторную доставку.
Для небольших операций FastAPI BackgroundTasks может быть проще, однако такие задачи выполняются в процессе приложения и не обладают теми же гарантиями и возможностями распределённой очереди.
Итог #
Celery применяется как инфраструктура фонового и распределённого выполнения:
веб-приложение
│
│ публикует задачу
▼
broker
│
▼
один или несколько Worker-ов
│
├── выполняют работу
├── повторяют временные ошибки
├── обрабатывают задачи параллельно
└── сохраняют результат при необходимости
Celery особенно оправдан для длительных, повторяемых, распределяемых и независимо масштабируемых задач, которые не должны выполняться внутри HTTP-запроса.
3. Для каких целей применяется RabbitMQ? #
Что такое RabbitMQ #
RabbitMQ — брокер сообщений. Он принимает сообщения от отправителей, маршрутизирует их через exchange, помещает в очереди и передаёт приложениям-получателям.
Producer
│ публикует сообщение
▼
Exchange
│ маршрутизирует
▼
Queue
│ хранит до обработки
▼
Consumer
RabbitMQ не выполняет бизнес-задачу самостоятельно. Он хранит и доставляет сообщения, а обработку выполняют Consumer-ы: Celery Worker, микросервис или отдельное приложение. RabbitMQ поддерживает очереди задач, публикацию событий нескольким получателям, маршрутизацию и модель request/reply.
1. Асинхронная обработка #
RabbitMQ позволяет отправителю не ждать завершения операции:
API
│ создаёт сообщение
▼
RabbitMQ
│
▼
Worker обрабатывает позднее
Например:
HTTP-запрос
↓
создание задания
↓
публикация сообщения
↓
ответ клиенту: 202 Accepted
↓
фоновая обработка Worker-ом
Это применяется для:
отправки писем и уведомлений;
генерации отчётов;
обработки файлов;
импорта данных;
обращения к медленным внешним системам;
массовой обработки записей.
RabbitMQ в таком сценарии отделяет создание задания от его выполнения.
2. Очередь фоновых задач #
Несколько Worker-ов могут читать одну очередь:
┌── Worker 1
Producer → Queue ──┼── Worker 2
└── Worker 3
Каждое сообщение из рабочей очереди передаётся одному Consumer-у. Это позволяет распределять длительные задачи между несколькими Worker-ами — паттерн competing consumers.
Например:
10 000 батчей
↓
batch_processing_queue
↓
20 Worker-ов обрабатывают батчи параллельно
3. Буферизация всплесков нагрузки #
Допустим, API за минуту создаёт 10 000 задач, но Worker-ы способны обрабатывать только 2 000 задач в минуту.
Без очереди приложение может перегрузить обработчики или зависимые системы:
Producer ─────────────> Worker
10 000 сообщений перегрузка
С RabbitMQ:
Producer
│ быстро публикует
▼
Queue: 10 000 сообщений
│ выдаёт постепенно
▼
Worker-ы
Очередь временно накапливает сообщения, а Consumer-ы забирают их в доступном темпе. При ручных подтверждениях параметр prefetch ограничивает количество сообщений, одновременно находящихся у Consumer-а без подтверждения, что помогает не перегружать обработчик.
RabbitMQ при этом не отменяет необходимость контролировать длину очередей, скорость Producer-ов, TTL и ресурсные лимиты.
4. Ослабление связанности компонентов #
Без брокера один сервис напрямую зависит от другого:
Order Service ──HTTP──> Notification Service
Если Notification Service недоступен, запрос может завершиться ошибкой или ожиданием тайм-аута.
Через RabbitMQ:
Order Service
│ OrderCreated
▼
RabbitMQ
│
▼
Notification Service
Order Service публикует сообщение и не обязан знать:
где запущен получатель;
сколько экземпляров Consumer-а работает;
когда именно сообщение будет обработано;
каким внутренним способом отправляется уведомление.
Это снижает временную связанность: отправитель и получатель необязательно должны быть доступны одновременно.
5. Передача событий нескольким системам #
Одно событие может понадобиться сразу нескольким сервисам:
┌──> Notification Queue
OrderCreated ───┼──> Analytics Queue
├──> Delivery Queue
└──> Audit Queue
Каждая очередь получает свою копию сообщения, а сервисы обрабатывают событие независимо.
Это паттерн publish/subscribe. В отличие от рабочей очереди, где одно сообщение получает один Worker, при publish/subscribe одно опубликованное событие может быть доставлено нескольким независимым Consumer-ам через разные очереди.
6. Маршрутизация сообщений #
Producer обычно публикует сообщение не напрямую в очередь, а в exchange:
Producer
│ routing_key = payment.succeeded
▼
Exchange
├──> notification_queue
├──> accounting_queue
└──> audit_queue
Exchange определяет, в какие очереди направить сообщение, исходя из своих привязок и routing key.
Основные варианты маршрутизации:
direct — точное совпадение routing key;
topic — совпадение с шаблоном;
fanout — отправка во все связанные очереди;
headers — маршрутизация по заголовкам.
Официальные руководства RabbitMQ отдельно рассматривают routing, topic routing и publish/subscribe как базовые модели взаимодействия.
Пример topic-маршрутизации:
payment.succeeded
payment.failed
order.created
order.cancelled
Подписка:
payment.*
получит события, связанные с платежами.
7. Подтверждение обработки сообщений #
При ручном подтверждении Consumer отправляет ack после успешной обработки:
RabbitMQ → Consumer: сообщение
Consumer выполняет операцию
Consumer → RabbitMQ: ACK
RabbitMQ удаляет сообщение из очереди
Если соединение или Consumer завершатся до подтверждения, неподтверждённое сообщение может быть повторно доставлено. Подтверждение означает, что Consumer принял ответственность за доставку.
Из этого следует важное требование:
Consumer должен быть идемпотентным.
Например:
def handle_payment(message: PaymentMessage) -> None:
if processed_messages.exists(message.id):
return
process_payment(message)
processed_messages.save(message.id)
RabbitMQ не следует воспринимать как механизм гарантированного выполнения «ровно один раз». При сбоях и повторной доставке сообщение может быть обработано повторно.
8. Подтверждение публикации #
Consumer acknowledgements подтверждают обработку со стороны получателя.
Для Producer существует отдельный механизм — publisher confirms:
Producer → RabbitMQ: сообщение
RabbitMQ → Producer: сообщение принято
Он позволяет отправителю понять, принял ли RabbitMQ публикацию. Для надёжной доставки обычно комбинируют:
publisher confirms;
durable queue;
persistent messages;
consumer acknowledgements;
идемпотентную обработку.
RabbitMQ подчёркивает, что долговечность очереди и сообщений настраивается отдельно: durable-очередь восстанавливается после перезапуска, а сохраняемые сообщения должны быть опубликованы как persistent.
При этом даже такая конфигурация требует корректной обработки ошибок и повторов на уровне приложения.
9. Повторные попытки и проблемные сообщения #
Если сообщение временно невозможно обработать, применяют:
основная очередь
↓ ошибка
retry queue
↓ задержка
основная очередь
Если сообщение окончательно не обрабатывается, его можно направить в Dead Letter Exchange:
processing_queue
│ reject / TTL / delivery limit
▼
Dead Letter Exchange
▼
dead_letter_queue
Сообщение может попасть в DLX, например, после отрицательного подтверждения без повторной постановки, истечения TTL, превышения длины очереди или лимита доставок quorum queue.
Dead-letter queue позволяет:
не блокировать основную очередь;
сохранить сведения об ошибке;
исследовать проблемное сообщение;
повторно обработать его после исправления причины.
10. Отложенная обработка и TTL #
RabbitMQ поддерживает TTL для сообщений и очередей. После истечения TTL сообщение может быть удалено или отправлено в DLX при соответствующей настройке.
Это используют для:
отложенных повторных попыток;
временных сообщений;
ограничения срока актуальности команды;
автоматической очистки временных очередей.
RabbitMQ не является полноценным бизнес-планировщиком. Для сложного расписания обычно используют Celery Beat, cron, отдельный scheduler или таблицу запланированных операций.
11. Межсервисное взаимодействие #
В микросервисной архитектуре RabbitMQ применяют для асинхронных команд и событий:
Команда:
SendNotification
ReserveProduct
GenerateReport
Событие:
OrderCreated
PaymentSucceeded
UserRegistered
Различие по смыслу:
Команда:
конкретному получателю предлагается выполнить действие.
Событие:
фиксируется уже произошедший факт;
получателей может быть несколько.
Например:
Payment Service
│ PaymentSucceeded
▼
RabbitMQ
├──> Order Service
├──> Notification Service
└──> Analytics Service
12. Работа RabbitMQ с Celery #
При использовании Celery RabbitMQ обычно выступает в роли broker:
FastAPI / Django
│ task.delay(...)
▼
RabbitMQ
│ сообщение о задаче
▼
Celery Worker
│
▼
выполнение функции
Разделение ответственности:
Celery:
определяет задачи;
запускает Worker-ы;
поддерживает retries, Canvas и расписание;
управляет выполнением задач.
RabbitMQ:
принимает сообщения;
маршрутизирует их;
хранит в очередях;
доставляет Worker-ам;
обрабатывает acknowledgements.
RabbitMQ не заменяет Celery, а Celery не является брокером сообщений.
13. Высокая доступность очередей #
Для очередей, которым требуется репликация и высокая доступность, RabbitMQ предлагает quorum queues. Они используют Raft и предназначены для надёжного хранения и быстрого выбора лидера при отказах. Официальная документация рекомендует рассматривать quorum queue как основной выбор, когда необходима реплицируемая высокодоступная очередь.
Но репликация увеличивает:
использование диска;
сетевой трафик;
задержку подтверждения;
инфраструктурную сложность.
Поэтому тип очереди выбирают исходя из требований к сохранности сообщений и доступности.
Где RabbitMQ не нужен #
RabbitMQ может быть избыточен, когда:
операция должна завершиться до ответа клиенту;
достаточно обычного вызова функции;
компоненты находятся внутри одного процесса;
нет потребности в буферизации или повторной доставке;
требуется простой синхронный запрос-ответ;
объём системы не оправдывает эксплуатацию брокера.
RabbitMQ поддерживает паттерн RPC, но для обычных синхронных межсервисных запросов HTTP или gRPC часто проще: RabbitMQ RPC требует correlation ID, reply queue, тайм-аутов, дедупликации и обработки потерянных ответов. Сам RabbitMQ приводит RPC как отдельный request/reply-паттерн, а не как основной режим работы брокера.
Итог #
RabbitMQ применяется для:
асинхронного выполнения работы;
очередей фоновых задач;
буферизации всплесков нагрузки;
распределения задач между Worker-ами;
ослабления связанности сервисов;
публикации событий нескольким системам;
маршрутизации сообщений;
повторной доставки после сбоев;
dead-letter и retry-механизмов;
горизонтального масштабирования Consumer-ов.
Основная роль RabbitMQ:
Надёжно передать сообщение
от Producer к одному или нескольким Consumer-ам,
не заставляя их работать синхронно и напрямую зависеть друг от друга.
4. Как разделить большую Celery task на несколько маленьких? | Celery chain, group, chord для разделения задач #
Основной принцип #
Большую Celery-задачу следует делить на самостоятельные идемпотентные этапы, каждый из которых можно выполнить отдельно, повторить после ошибки и распределить между Worker-ами.
Celery рекомендует использовать достаточно мелкие задачи для параллелизма, но предупреждает, что слишком мелкая гранулярность увеличивает расходы на отправку сообщений, сериализацию и получение данных. Поэтому размер задачи подбирают нагрузочным тестированием.
Большая операция
↓
разбиение данных на батчи
↓
маленькие Celery tasks
↓
параллельная или последовательная обработка
↓
финализация результата
Canvas-примитивы #
| Примитив | Назначение |
|---|---|
chain | Последовательное выполнение |
group | Параллельное выполнение независимых задач |
chord | Параллельная группа с итоговой задачей |
chunks | Разбиение большого списка аргументов на части |
Эти примитивы являются частью Celery Canvas и могут комбинироваться между собой.
chain: последовательные задачи
#
chain применяют, когда следующий этап зависит от результата предыдущего:
загрузить файл
↓
проверить файл
↓
обработать данные
↓
сохранить результат
from celery import chain
workflow = chain(
download_file.s(file_id),
validate_file.s(),
process_file.s(),
save_result.s(),
)
workflow.apply_async()
По умолчанию результат предыдущей задачи передаётся первым аргументом следующей:
@app.task
def download_file(file_id: int) -> str:
return f"/tmp/{file_id}.csv"
@app.task
def validate_file(file_path: str) -> str:
validate(file_path)
return file_path
@app.task
def process_file(file_path: str) -> dict:
return parse_file(file_path)
@app.task
def save_result(parsed_data: dict) -> int:
return repository.save(parsed_data)
download_file → "/tmp/42.csv"
↓
validate_file("/tmp/42.csv")
↓
process_file("/tmp/42.csv")
Celery определяет chain как последовательность signatures, где каждая задача вызывается после предыдущей.
Immutable signatures #
Иногда результат предыдущей задачи передавать не нужно. Тогда используется .si() вместо .s():
workflow = chain(
create_order.s(order_id),
send_notification.si(order_id),
)
В этом примере send_notification получит только order_id, а не результат create_order.
@app.task
def send_notification(order_id: int) -> None:
...
Immutable signature не принимает дополнительные аргументы, передаваемые предыдущей задачей Canvas.
group: параллельные задачи
#
group применяют, когда задачи независимы друг от друга:
┌── batch 1
├── batch 2
Обработка ───┼── batch 3
└── batch N
from celery import group
workflow = group(
process_batch.s(batch_id)
for batch_id in batch_ids
)
result = workflow.apply_async()
Все задачи группы могут выполняться параллельно разными Worker-процессами:
@app.task
def process_batch(batch_id: int) -> dict:
batch = batch_repository.get(batch_id)
processed = 0
failed = 0
for record_id in batch.record_ids:
try:
process_record(record_id)
processed += 1
except ProcessingError:
failed += 1
return {
"batch_id": batch_id,
"processed": processed,
"failed": failed,
}
Celery определяет group как набор задач, которые должны применяться параллельно.
Пример разбиения списка на батчи #
from collections.abc import Iterator, Sequence
from typing import TypeVar
T = TypeVar("T")
def split_into_batches(
values: Sequence[T],
batch_size: int,
) -> Iterator[list[T]]:
if batch_size <= 0:
raise ValueError("batch_size must be greater than zero")
for offset in range(0, len(values), batch_size):
yield list(values[offset : offset + batch_size])
Запуск:
batches = split_into_batches(record_ids, batch_size=1_000)
workflow = group(
process_records.s(batch)
for batch in batches
)
workflow.apply_async()
@app.task
def process_records(record_ids: list[int]) -> int:
processed = 0
for record_id in record_ids:
process_record(record_id)
processed += 1
return processed
Однако передавать большие массивы идентификаторов через брокер не всегда эффективно. Для крупных объёмов лучше предварительно сохранить батчи в БД или объектном хранилище и передавать только batch_id.
workflow = group(
process_batch.s(batch_id)
for batch_id in batch_ids
)
Celery рекомендует передавать идентификаторы объектов, а актуальные данные повторно загружать внутри задачи: переданные ранее объекты или данные могут устареть до момента выполнения.
chord: параллельная обработка с финализацией
#
chord состоит из:
header — группа параллельных задач
body — callback после завершения всей группы
process_batch 1 ─┐
process_batch 2 ─┤
process_batch 3 ─┼──> aggregate_results
process_batch N ─┘
from celery import chord
workflow = chord(
process_batch.s(batch_id)
for batch_id in batch_ids
)(
aggregate_results.s(job_id)
)
Результаты всех задач группы передаются в callback первым аргументом:
@app.task
def aggregate_results(
batch_results: list[dict],
job_id: int,
) -> dict:
processed = sum(
result["processed"]
for result in batch_results
)
failed = sum(
result["failed"]
for result in batch_results
)
job_repository.complete(
job_id=job_id,
processed=processed,
failed=failed,
)
return {
"job_id": job_id,
"processed": processed,
"failed": failed,
}
chord запускает callback только после завершения всех задач header-группы. Для chord требуется result backend, а задачи header и callback не должны игнорировать результаты. RPC result backend для chord не поддерживается.
Недостаток chord
#
Синхронизация chord имеет стоимость. Официальная документация рекомендует избегать chord там, где итоговая барьерная синхронизация не нужна.
Особенно осторожно следует применять chord, когда:
сотни тысяч задач;
каждая задача возвращает большой результат;
callback получает огромный список результатов;
result backend испытывает значительную нагрузку.
Не следует возвращать из каждой задачи целые обработанные объекты:
# Плохо
return processed_records
Лучше возвращать компактную статистику:
return {
"processed": 950,
"failed": 50,
}
Либо сохранять частичные результаты в БД и возвращать только идентификатор:
return batch_result_id
chunks: автоматическое разбиение аргументов
#
chunks удобен, когда одна и та же задача должна применяться к большому количеству однотипных аргументов.
@app.task
def process_record(record_id: int) -> bool:
process(record_id)
return True
Создание чанков по 100 записей:
arguments = (
(record_id,)
for record_id in record_ids
)
workflow = process_record.chunks(
arguments,
100,
)
workflow.apply_async()
Если имеется 10 000 элементов и размер чанка равен 100, Celery создаст 100 задач. Внутри каждой задачи 100 вызовов будут выполнены последовательно. chunks уменьшает количество сообщений брокера и накладные расходы по сравнению с отдельным сообщением на каждый элемент.
Важно понимать отличие:
group:
каждая signature становится отдельной задачей.
chunks:
одна задача последовательно обрабатывает
несколько наборов аргументов.
group или chunks
#
Для 100 000 записей:
# 100 000 сообщений
group(
process_record.s(record_id)
for record_id in record_ids
)
# 100 сообщений при chunk_size=1000
process_record.chunks(
((record_id,) for record_id in record_ids),
1_000,
)
chunks подходит для простой однородной обработки. Собственный process_batch удобнее, когда внутри батча нужны:
одна транзакция;
один SQL-запрос на группу ID;
bulk_createилиbulk_update;собственная статистика;
особая обработка ошибок;
контроль состояния батча.
Комбинирование chain, group и chord
#
Распространённая схема: каждый батч проходит несколько последовательных этапов, а батчи выполняются параллельно.
batch 1: validate → process → persist ─┐
batch 2: validate → process → persist ─┼──> finalize
batch 3: validate → process → persist ─┘
from celery import chain, chord
batch_workflows = [
chain(
validate_batch.s(batch_id),
process_batch.s(),
persist_batch.s(),
)
for batch_id in batch_ids
]
workflow = chord(batch_workflows)(
finalize_job.s(job_id)
)
workflow.apply_async()
Задачи:
@app.task
def validate_batch(batch_id: int) -> int:
validate_batch_data(batch_id)
return batch_id
@app.task
def process_batch(batch_id: int) -> int:
process_batch_data(batch_id)
return batch_id
@app.task
def persist_batch(batch_id: int) -> dict:
result = persist_batch_result(batch_id)
return {
"batch_id": batch_id,
"processed": result.processed,
"failed": result.failed,
}
@app.task
def finalize_job(
batch_results: list[dict],
job_id: int,
) -> None:
...
Canvas-примитивы являются signatures и могут вкладываться и объединяться в составные workflow.
Практическая архитектура большой обработки #
Для операции с миллионом записей лучше не передавать миллион ID непосредственно в Celery Canvas.
1. API создаёт Job
2. Приложение записывает Batch-строки в БД
3. В Celery передаются только batch_id
4. Worker загружает данные батча
5. Worker сохраняет результат батча
6. Финальная задача обновляет Job
Структура данных:
processing_jobs
├── id
├── status
├── total_batches
├── completed_batches
├── failed_batches
└── created_at
processing_batches
├── id
├── job_id
├── status
├── records_from
├── records_to
├── processed_count
└── failed_count
Запуск:
@app.task
def start_job(job_id: int) -> None:
batch_ids = batch_repository.list_ids(job_id)
chord(
process_batch.s(batch_id)
for batch_id in batch_ids
)(
finalize_job.s(job_id)
)
Обработка:
@app.task(
bind=True,
autoretry_for=(TemporaryProcessingError,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
acks_late=True,
)
def process_batch(
self,
batch_id: int,
) -> dict:
batch = batch_repository.lock_for_processing(batch_id)
if batch.status == "completed":
return {
"batch_id": batch.id,
"processed": batch.processed_count,
"failed": batch.failed_count,
}
result = batch_processor.process(batch)
batch_repository.mark_completed(
batch_id=batch.id,
processed=result.processed,
failed=result.failed,
)
return {
"batch_id": batch.id,
"processed": result.processed,
"failed": result.failed,
}
При acks_late=True сообщение подтверждается после выполнения задачи, поэтому после сбоя возможен повторный запуск. Задача должна быть идемпотентной.
Как обрабатывать ошибки #
Ошибка одной задачи в chain
#
Последующие задачи цепочки не выполняются:
validate → process FAILED → persist не запускается
Можно назначить errback:
workflow = chain(
validate_batch.s(batch_id),
process_batch.s(),
persist_batch.s(),
)
workflow.apply_async(
link_error=mark_batch_failed.s(batch_id),
)
Ошибка задачи внутри group
#
Остальные задачи группы продолжают выполняться независимо.
batch 1 SUCCESS
batch 2 FAILURE
batch 3 SUCCESS
Ошибка внутри chord
#
Если одна из header-задач завершилась ошибкой, callback chord обычно не выполняется как успешная финализация. Ошибку необходимо учитывать через errback и состояние общей операции.
workflow = chord(header, finalize_job.s(job_id))
workflow.apply_async(
link_error=mark_job_failed.s(job_id),
)
Для бизнес-процесса лучше хранить состояние Job в собственной БД, а не полагаться исключительно на Celery result backend.
Выбор размера батча #
Универсального размера нет.
Слишком маленький батч:
много сообщений;
нагрузка на broker;
нагрузка на result backend;
расходы на сериализацию;
большое количество task metadata.
Слишком большой батч:
долгое выполнение;
плохая балансировка Worker-ов;
дорогой повтор после ошибки;
долгое удержание транзакций;
блокировка очереди тяжёлыми задачами.
Celery рекомендует делить работу на небольшие задачи, но учитывать накладные расходы слишком мелкой гранулярности.
Практически размер подбирают по метрикам:
время обработки батча;
размер сообщения;
длина очереди;
CPU и память Worker-а;
нагрузка на БД;
стоимость повторного выполнения;
скорость Producer-а и Consumer-ов.
Обычно стремятся к задаче, которая выполняется не миллисекунды и не часы, а имеет предсказуемое ограниченное время.
Основные ошибки #
# Плохо: одна task обрабатывает миллион записей
@app.task
def process_all(ids: list[int]) -> None:
for record_id in ids:
process(record_id)
Проблемы:
огромное сообщение;
один Worker занят надолго;
нет нормального параллелизма;
при ошибке повторяется вся обработка;
сложно отслеживать прогресс.
Также не следует:
передавать ORM-объекты в task;
возвращать огромные результаты;
вызывать result.get() внутри Worker-а;
создавать задачу на каждую микроскопическую операцию;
считать повторную доставку невозможной;
использовать chord без result backend;
запускать неидемпотентные задачи с acks_late.
Какой примитив выбирать #
Этапы зависят друг от друга
→ chain
Задачи независимы и выполняются параллельно
→ group
Параллельные задачи требуют общей финализации
→ chord
Одинаковая задача применяется к большому списку аргументов
→ chunks
Несколько последовательных этапов для каждого батча,
затем общая финализация
→ chord из chain
Итоговая схема #
workflow = chord(
chain(
validate_batch.s(batch_id),
process_batch.s(),
save_batch.s(),
)
for batch_id in batch_ids
)(
finalize_job.s(job_id)
)
workflow.apply_async()
Это даёт:
параллельность между батчами;
последовательность этапов внутри батча;
независимые retry;
ограниченный объём повторной работы;
финализацию после завершения всех батчей;
возможность горизонтально масштабировать Worker-ы.
5. Преимущества очередей (Celery) #
Преимущества очередей задач в Celery #
Celery использует брокер сообщений — например, RabbitMQ или Redis — чтобы передавать задачи от приложения отдельным Worker-процессам.
FastAPI / Django
│ task.delay(...)
▼
Broker
│ очередь задач
▼
Celery Worker
│
▼
Выполнение операции
1. Быстрый ответ клиенту #
Долгая операция не выполняется внутри HTTP-запроса:
@app.post("/reports", status_code=202)
async def create_report(report_id: int):
generate_report.delay(report_id)
return {
"report_id": report_id,
"status": "processing",
}
API только ставит задачу в очередь, а Worker выполняет её отдельно. Это уменьшает время HTTP-ответа и не удерживает процесс веб-приложения занятой фоновой работой. В Celery клиент публикует сообщение в очередь, брокер передаёт его одному из Worker-ов.
2. Буферизация всплесков нагрузки #
Очередь отделяет скорость создания задач от скорости их выполнения:
API создаёт 10 000 задач
↓
очередь
↓
Worker-ы обрабатывают их
с доступной скоростью
Без очереди резкий поток запросов пришлось бы немедленно обрабатывать приложению, БД или внешнему сервису. Очередь временно накапливает работу и позволяет Worker-ам забирать её постепенно.
Но очередь не устраняет перегрузку автоматически: необходимо контролировать её длину, скорость поступления задач и производительность Worker-ов.
3. Горизонтальное масштабирование #
Несколько Worker-ов могут обрабатывать одну очередь:
┌── Worker 1
Queue ───────────┼── Worker 2
├── Worker 3
└── Worker 4
Если задач становится больше, можно добавить Worker-процессы или отдельные серверы без масштабирования HTTP-приложения. Celery рассчитан на распределение работы между потоками или машинами и поддерживает несколько Worker-ов и брокеров.
4. Независимое масштабирование разных типов работы #
Задачи можно разделить по очередям:
emails → Worker-ы для писем
reports → Worker-ы для отчётов
images → Worker-ы обработки изображений
integrations → Worker-ы внешних API
Например:
app.conf.task_routes = {
"tasks.send_email": {
"queue": "emails",
},
"tasks.generate_report": {
"queue": "reports",
},
}
Worker можно запустить только для выбранных очередей:
celery -A app worker -Q reports --loglevel=INFO
Celery поддерживает маршрутизацию задач по очередям и запуск Worker-а с ограниченным набором потребляемых очередей.
Это позволяет выделить больше ресурсов тяжёлым отчётам и не допустить, чтобы они задерживали короткие задачи отправки писем.
5. Изоляция веб-приложения от тяжёлой работы #
CPU-, I/O- и memory-intensive операции выполняются не в процессе FastAPI или Django:
Web application:
принимает запросы
Celery Worker:
генерирует PDF;
обрабатывает файл;
изменяет изображения;
вызывает внешние API.
Сбой конкретной фоновой задачи не обязан приводить к падению HTTP-запроса или всего веб-приложения.
При этом Celery не превращает CPU-bound задачу в быструю автоматически. Производительность зависит от количества Worker-процессов, оборудования и характера задачи.
6. Повторные попытки #
При временной ошибке задачу можно выполнить повторно:
@app.task(
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 5},
)
def send_to_external_api(operation_id: int) -> None:
external_client.send(operation_id)
Это полезно при временной недоступности:
внешнего API;
SMTP-сервера;
объектного хранилища;
сетевого ресурса;
другого микросервиса.
Celery поддерживает повтор задачи через retry() и автоматические retries.
Повторные попытки требуют идемпотентности:
@app.task
def process_payment(payment_id: str) -> None:
if payment_repository.is_processed(payment_id):
return
payment_service.process(payment_id)
Иначе повторная доставка может создать двойную запись, отправить два письма или дважды выполнить финансовую операцию.
7. Восстановление после падения Worker-а #
Celery и брокер поддерживают acknowledgements — подтверждения получения или обработки сообщений.
При подходящей настройке задача подтверждается после выполнения:
@app.task(
acks_late=True,
)
def process_batch(batch_id: int) -> None:
...
Если Worker аварийно завершится до подтверждения, сообщение может быть возвращено в очередь и обработано повторно. Именно поэтому задача с поздним подтверждением должна быть идемпотентной. Официальная документация отдельно связывает acks_late и возможность повторного выполнения задачи после сбоя Worker-а.
Это не гарантия «ровно один раз». В практической системе нужно исходить из модели:
Задача может быть доставлена
и выполнена более одного раза.
8. Управление справедливым распределением нагрузки #
Worker может заранее зарезервировать несколько задач. Параметры prefetch позволяют контролировать, сколько сообщений он получает заранее.
Например:
app.conf.worker_prefetch_multiplier = 1
Это особенно полезно для долгих задач: один Worker не должен заранее забрать слишком много работы, пока другие Worker-ы простаивают. Документация Celery описывает связь prefetch multiplier с количеством зарезервированных задач и рекомендует разделять долгие и короткие задачи по Worker-ам и очередям.
9. Параллельная обработка больших объёмов #
Большую операцию можно разбить на батчи:
1 000 000 записей
↓
1 000 батчей
↓
очередь Celery
↓
несколько Worker-ов
from celery import group
workflow = group(
process_batch.s(batch_id)
for batch_id in batch_ids
)
workflow.apply_async()
Преимущества:
части обрабатываются параллельно;
ошибка одного батча не требует повторять все данные;
можно отслеживать прогресс;
нагрузка распределяется между Worker-ами;
можно добавлять вычислительные ресурсы.
10. Ослабление временной связанности #
Producer и Worker не обязаны одновременно выполнять одну операцию:
API опубликовал задачу
↓
Worker может обработать её позднее
Веб-приложению не нужно знать:
какой конкретно Worker выполнит задачу;
на каком сервере он находится;
сколько экземпляров Worker-а запущено;
когда именно задача начнёт выполняться.
Это упрощает независимое масштабирование и развёртывание компонентов.
11. Управление приоритетами и классами задач #
Разные очереди позволяют отделить важные операции от менее срочных:
critical → платёжные статусы
default → обычная обработка
low → отчёты и архивирование
Даже без брокерных приоритетов раздельные очереди позволяют выделить отдельное количество Worker-ов каждому классу задач.
Например:
celery -A app worker -Q critical --concurrency=4
celery -A app worker -Q reports --concurrency=2
12. Построение сложных workflow #
Celery Canvas позволяет объединять задачи:
chain — последовательность;
group — параллельная группа;
chord — группа с общей финализацией;
chunks — пакетная обработка аргументов.
Например:
обработать батчи параллельно
↓
дождаться всех
↓
сформировать итоговый отчёт
from celery import chord
chord(
process_batch.s(batch_id)
for batch_id in batch_ids
)(
finalize_report.s(job_id)
)
13. Выполнение задач по расписанию #
С помощью Celery Beat задачи можно публиковать периодически:
app.conf.beat_schedule = {
"remove-expired-files": {
"task": "tasks.remove_expired_files",
"schedule": 3600.0,
},
}
Celery Beat
↓
Broker
↓
Celery Worker
Это применяют для очистки данных, синхронизации, формирования отчётов и регулярных проверок.
Ограничения #
Очереди Celery добавляют собственную сложность:
нужен Redis или RabbitMQ;
нужно обслуживать Worker-ы;
нужно следить за длиной очередей;
возможны повторные выполнения;
нужна идемпотентность;
нужны мониторинг и алерты;
нужно проектировать тайм-ауты и retries;
нужно контролировать размер сообщений.
Celery также не гарантирует завершение любой задачи сам по себе. Надёжность зависит от:
настроек брокера;
persistence сообщений;
acknowledgements;
обработки повторов;
идемпотентности;
мониторинга;
способа публикации задачи.
Например, операция:
save_order()
send_notification.delay(order_id)
может потерять сообщение, если процесс завершится между записью в БД и публикацией задачи. Для критичной доставки используют Transactional Outbox или другой механизм согласования БД и брокера.
Итог #
Главные преимущества очередей Celery:
1. Быстрый ответ HTTP API
2. Фоновое выполнение длительных операций
3. Буферизация всплесков нагрузки
4. Горизонтальное масштабирование Worker-ов
5. Разделение задач по очередям
6. Повторные попытки после временных ошибок
7. Возможность повторной доставки после сбоя
8. Параллельная обработка данных
9. Независимое масштабирование разных типов задач
10. Построение последовательных и параллельных workflow
11. Запуск задач по расписанию
12. Снижение прямой связанности компонентов
Очередь особенно полезна, когда работу необязательно завершать внутри текущего HTTP-запроса и её можно выполнить отдельно, повторить или распределить между несколькими Worker-ами.
6. Гарантии доставки сообщений в брокерах #
Что означают гарантии доставки #
Гарантия доставки описывает, сколько раз сообщение может быть передано и обработано при сбоях:
Producer → Broker → Consumer → БД / внешний сервис
Обычно выделяют три семантики:
| Семантика | Потеря сообщения | Повторная обработка |
|---|---|---|
at-most-once | Возможна | Нет |
at-least-once | Минимизируется | Возможна |
exactly-once | Нет | Нет видимого повторного эффекта |
Важно различать:
доставка сообщения
≠
выполнение бизнес-операции
Брокер может гарантировать передачу сообщения Consumer-у, но не способен автоматически гарантировать, что запись в PostgreSQL, платёж или HTTP-запрос во внешнюю систему произошли ровно один раз.
At-most-once — не более одного раза #
Сообщение обрабатывается максимум один раз:
сообщение потеряно
или
обработано один раз
Повторная доставка не выполняется.
Пример:
Broker передал сообщение
↓
сразу считает его обработанным
↓
Consumer упал до выполнения операции
↓
сообщение потеряно
В RabbitMQ это близко к автоматическому подтверждению:
auto_ack=True
При automatic acknowledgement RabbitMQ считает сообщение успешно доставленным после отправки его Consumer-у, не ожидая подтверждения успешной обработки. Это обеспечивает высокую скорость, но при обрыве соединения или падении Consumer-а сообщение может быть потеряно.
Такая модель подходит для некритичных данных:
метрики;
часть логов;
временные обновления интерфейса;
телеметрия, потеря которой допустима.
Она не подходит для платежей, заказов, изменения баланса и других критичных операций.
At-least-once — хотя бы один раз #
Система стремится не потерять сообщение, но допускает повторную доставку:
сообщение обработано один раз
или
сообщение обработано несколько раз
Типичный сценарий:
1. Consumer получил сообщение
2. Consumer изменил данные в БД
3. Consumer упал до отправки ACK
4. Broker не получил подтверждение
5. Broker доставил сообщение повторно
6. Операция выполняется второй раз
RabbitMQ автоматически возвращает неподтверждённые сообщения в очередь при закрытии канала или соединения. Поэтому ручные acknowledgements обеспечивают повторную доставку, но создают возможность дубликатов.
Это наиболее распространённая модель для очередей задач:
лучше получить дубликат,
чем полностью потерять задачу
Amazon SQS Standard также официально предоставляет at-least-once: копия сообщения иногда может быть получена повторно, поэтому AWS требует проектировать Consumer-ы идемпотентными.
Exactly-once — ровно один раз #
На практике термин exactly-once часто понимают неправильно.
Недостаточно сделать так, чтобы Broker отправил сообщение Consumer-у один раз. Нужно гарантировать, что весь наблюдаемый бизнес-эффект произошёл ровно один раз:
получить сообщение
+
изменить БД
+
отправить следующее событие
+
подтвердить сообщение
Все эти действия должны быть согласованы.
Проблема возникает в критическом промежутке:
Consumer изменил PostgreSQL
↓
Consumer должен отправить ACK
Если Consumer упал между этими действиями, Broker не знает, завершилась ли бизнес-операция:
БД обновлена
ACK не получен
↓
повторная доставка
Если изменить порядок:
сначала ACK
потом обновить БД
то возникает противоположная проблема:
ACK отправлен
Consumer упал
БД не обновлена
↓
сообщение потеряно
Поэтому обычная очередь плюс обычная БД не дают настоящей сквозной exactly-once семантики автоматически.
Apache Kafka поддерживает транзакционную exactly-once обработку для сценария, где приложение читает из Kafka, обрабатывает данные и записывает результат обратно в Kafka. Но сама документация Kafka подчёркивает, что подобные гарантии имеют конкретные границы и не автоматически распространяются на произвольные внешние базы и системы.
На уровне бизнес-приложения обычно реализуют не физическое «доставить один раз», а:
доставлять at-least-once
+
обрабатывать идемпотентно
=
эффект exactly-once
Гарантия состоит из нескольких участков #
Нельзя настроить только одну опцию Broker-а и получить надёжную доставку.
Полный путь:
1. Producer создал сообщение
2. Сообщение дошло до Broker-а
3. Broker сохранил сообщение
4. Broker передал его Consumer-у
5. Consumer выполнил бизнес-операцию
6. Consumer подтвердил обработку
Для каждого участка нужны отдельные механизмы.
Producer → Broker: publisher confirms #
Обычный вызов публикации ещё не означает, что Broker принял сообщение.
Producer отправил сообщение
↓
соединение оборвалось
↓
неизвестно:
Broker получил сообщение или нет
В RabbitMQ для этого используются publisher confirms:
Producer → RabbitMQ: сообщение
RabbitMQ → Producer: confirm
Confirm означает, что RabbitMQ принял ответственность за сообщение. Для persistent-сообщения в durable-очереди подтверждение отправляется после сохранения сообщения; для quorum queue — после принятия сообщения кворумом реплик.
Однако потеря самого подтверждения всё равно возможна:
RabbitMQ сохранил сообщение
RabbitMQ отправил confirm
соединение разорвалось
Producer confirm не получил
↓
Producer публикует сообщение повторно
RabbitMQ прямо указывает, что повторная публикация неподтверждённых сообщений может приводить к дубликатам.
Поэтому Producer тоже должен использовать:
message_id;
event_id;
idempotency key;
таблицу outbox.
Сохранность внутри Broker-а #
Чтобы сообщение переживало перезапуск RabbitMQ, обычно нужны одновременно:
durable queue;
persistent message;
publisher confirms.
Одна durable-очередь недостаточна: она сохраняет определение очереди, но сообщения также должны публиковаться как persistent. Publisher confirms позволяют Producer-у узнать, когда RabbitMQ принял ответственность за сообщение.
Для повышенной отказоустойчивости RabbitMQ предоставляет quorum queues. Confirm для такой очереди отправляется после репликации сообщения на кворум узлов.
Схема:
Producer
│ persistent message
▼
Quorum Queue
├── replica 1
├── replica 2
└── replica 3
│
▼
publisher confirm
Это защищает от некоторых отказов Broker-а, но не устраняет дубликаты на уровне Consumer-а.
Broker → Consumer: acknowledgements #
Consumer acknowledgements сообщают RabbitMQ, когда сообщение можно удалить.
ACK после успешной операции #
def handle(message):
process(message)
channel.basic_ack(message.delivery_tag)
Это приближает систему к at-least-once:
успех → ACK → сообщение удаляется
сбой до ACK → сообщение доставляется повторно
RabbitMQ рекомендует manual acknowledgements, когда необработанные сообщения должны возвращаться в очередь и повторно обрабатываться другим Consumer-ом.
NACK или reject #
При ошибке Consumer может:
ACK → удалить сообщение;
NACK + requeue → вернуть в очередь;
NACK без requeue → удалить или направить в DLQ.
Бесконечно возвращать неисправное сообщение в ту же очередь опасно:
получение → ошибка → requeue → получение → ошибка → ...
Поэтому обычно применяют:
retry queue;
ограниченное число повторов;
exponential backoff;
Dead Letter Queue.
Идемпотентный Consumer #
Для at-least-once Consumer должен выдерживать повторную доставку.
Пример таблицы дедупликации:
CREATE TABLE processed_messages (
consumer_name TEXT NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, message_id)
);
Обработка выполняется в одной локальной транзакции:
def handle_message(message: Message) -> None:
with database.transaction():
inserted = processed_messages.try_insert(
consumer_name="payment_consumer",
message_id=message.id,
)
if not inserted:
return
payment_service.apply_result(
payment_id=message.payment_id,
status=message.status,
)
первая доставка:
message_id отсутствует → операция выполняется
повторная доставка:
message_id уже существует → операция пропускается
Ключевой момент: запись о дедупликации и бизнес-изменение должны находиться в одной транзакции.
Плохо:
mark_as_processed(message.id)
update_payment(message)
Если процесс упадёт между вызовами, сообщение будет считаться обработанным, хотя бизнес-операция не завершилась.
Transactional Outbox на стороне Producer-а #
Проблема Producer-а:
save_order(order)
publish_event("OrderCreated", order.id)
Возможен сбой:
заказ сохранён
событие не опубликовано
Либо обратный порядок:
событие опубликовано
заказ не сохранён
Transactional Outbox решает это через одну транзакцию БД:
with database.transaction():
save_order(order)
outbox_repository.add(
event_id=uuid4(),
event_type="OrderCreated",
payload={"order_id": str(order.id)},
)
Отдельный Publisher читает outbox и отправляет события в Broker:
Бизнес-транзакция
├── orders
└── outbox
Outbox Worker
↓
RabbitMQ
Publisher может отправить одно событие повторно, например если RabbitMQ принял сообщение, но confirm потерялся. Поэтому event_id остаётся необходимым для дедупликации Consumer-а.
Celery и гарантии доставки #
По умолчанию Celery обычно подтверждает задачу до её фактического выполнения. Это снижает риск повторного выполнения, но задача может быть потеряна, если Worker упадёт после ACK и до завершения работы.
Для идемпотентных задач можно включить позднее подтверждение:
@app.task(
acks_late=True,
)
def process_order(order_id: int) -> None:
...
Тогда задача подтверждается после возврата из task, что приближает обработку к at-least-once. Документация Celery прямо требует идемпотентности для задач с acks_late.
Но есть важная особенность: даже при acks_late=True Celery по умолчанию может подтвердить задачу, если дочерний процесс Worker-а аварийно завершился или был принудительно остановлен. Для возврата такой задачи в очередь существует:
task_reject_on_worker_lost = True
Celery предупреждает, что эта настройка может вызвать бесконечные циклы повторной доставки.
Практическая конфигурация для критичных идемпотентных задач:
app.conf.update(
task_acks_late=True,
task_reject_on_worker_lost=True,
)
Но конфигурация сама по себе не заменяет:
идемпотентность;
ограничение retries;
тайм-ауты;
DLQ;
publisher confirms;
надёжный Broker;
Transactional Outbox.
Практическое сравнение #
At-most-once #
ACK до обработки
Плюсы:
нет повторного выполнения;
простая обработка;
меньше накладных расходов.
Минус:
при сбое возможна потеря сообщения.
At-least-once #
обработка → commit → ACK
Плюсы:
сообщение не теряется при обычном падении Consumer-а;
можно повторить временно неуспешную операцию.
Минусы:
возможны дубликаты;
нужна идемпотентность;
нужна дедупликация.
Exactly-once effect #
at-least-once delivery
+
transactional inbox / idempotency
+
transactional outbox
Плюс:
повторная доставка не создаёт повторного бизнес-эффекта.
Минус:
существенно сложнее;
требует хранения идентификаторов;
требует транзакционной дисциплины;
не всегда возможно для внешних систем.
Что выбирать #
Для большинства бизнес-систем разумная цель:
at-least-once доставка
+
идемпотентные Consumer-ы
+
Outbox у Producer-а
+
Inbox/deduplication у Consumer-а
Итоговая схема:
Producer DB transaction
├── business data
└── outbox event
↓
Outbox Publisher
↓ publisher confirm
RabbitMQ durable/quorum queue
↓ manual delivery
Consumer transaction
├── processed_messages
└── business changes
↓
ACK
Это не означает, что пакет физически никогда не будет отправлен повторно. Это означает, что повторная доставка не изменит бизнес-результат второй раз.
Главное правило #
At-most-once:
можно потерять, нельзя повторить.
At-least-once:
нельзя терять, можно повторить.
Exactly-once:
обычно достигается не брокером,
а идемпотентной и транзакционной обработкой
поверх at-least-once доставки.
7. Гарантии доставки в RabbitMQ #
Гарантии доставки в RabbitMQ #
RabbitMQ не предоставляет одну глобальную настройку вида «гарантировать доставку». Надёжность складывается из трёх отдельных участков:
Producer
│ 1. публикация
▼
RabbitMQ
│ 2. хранение и маршрутизация
▼
Consumer
│ 3. обработка и подтверждение
▼
БД / внешний сервис
Для каждого участка применяются собственные механизмы:
Producer → RabbitMQ:
publisher confirms
Хранение в RabbitMQ:
durable/quorum queue + persistence
RabbitMQ → Consumer:
manual acknowledgements
Бизнес-обработка:
идемпотентность и дедупликация
At-most-once #
At-most-once означает:
Сообщение будет обработано:
0 или 1 раз
Потеря возможна, повторная доставка не предполагается.
Например, Consumer использует автоматическое подтверждение:
RabbitMQ отправил сообщение
↓
считает его подтверждённым
↓
Consumer упал до обработки
↓
сообщение потеряно
В RabbitMQ отсутствие ручных подтверждений приближает доставку к at-most-once: после передачи сообщения Consumer-у Broker не знает, действительно ли бизнес-операция завершилась. Официальное руководство RabbitMQ отмечает, что без acknowledgements возможна потеря сообщений.
Такой режим подходит только там, где потеря допустима:
некритичная телеметрия;
часть метрик;
временные события интерфейса;
обновления, которые скоро будут заменены новыми.
At-least-once #
At-least-once означает:
Сообщение будет обработано:
1 или несколько раз
RabbitMQ стремится не потерять сообщение, но повторная доставка возможна.
Типичный случай:
1. Consumer получил сообщение
2. Изменил данные в PostgreSQL
3. Упал до отправки ACK
4. RabbitMQ не получил подтверждение
5. Сообщение вернулось в очередь
6. Другой Consumer получил его повторно
При закрытии канала или соединения неподтверждённые сообщения автоматически возвращаются в очередь. Поэтому ручные acknowledgements обеспечивают at-least-once, но требуют готовности к повторной обработке.
Схема Consumer-а:
def handle(message) -> None:
try:
process_message(message)
except TemporaryError:
channel.basic_nack(
delivery_tag=message.delivery_tag,
requeue=True,
)
else:
channel.basic_ack(
delivery_tag=message.delivery_tag,
)
Подтверждать сообщение следует только после завершения необходимой работы:
получить сообщение
↓
изменить БД
↓
зафиксировать транзакцию
↓
отправить ACK
После ACK RabbitMQ получает право удалить сообщение из очереди.
Exactly-once RabbitMQ не гарантирует #
RabbitMQ не обеспечивает сквозное выполнение бизнес-операции строго один раз.
Рассмотрим Consumer:
Consumer обновил БД
↓
Consumer должен отправить ACK
Если Consumer упадёт между этими действиями:
БД уже изменена
ACK не дошёл
RabbitMQ повторно доставляет сообщение
Если отправлять ACK до транзакции:
ACK отправлен
Consumer упал
БД не обновлена
сообщение уже удалено
RabbitMQ не может атомарно объединить:
COMMIT в PostgreSQL
+
ACK сообщения
Поэтому обычно строят:
at-least-once delivery
+
идемпотентный Consumer
=
бизнес-эффект, близкий к exactly-once
Publisher confirms #
Publisher должен убедиться, что RabbitMQ действительно принял сообщение.
Без подтверждений:
Producer отправил сообщение
↓
соединение оборвалось
↓
неизвестно, дошло ли сообщение
Для этого RabbitMQ предоставляет publisher confirms:
Producer ──publish──> RabbitMQ
Producer <──confirm── RabbitMQ
После включения confirm mode каждое опубликованное сообщение будет либо подтверждено, либо отклонено Broker-ом. Для persistent-сообщений, направленных в durable-очереди, confirm отправляется после сохранения; для quorum queue — после принятия сообщения кворумом реплик.
Условный пример с pika:
import pika
connection = pika.BlockingConnection(
pika.ConnectionParameters("localhost")
)
channel = connection.channel()
channel.confirm_delivery()
channel.queue_declare(
queue="orders",
durable=True,
)
confirmed = channel.basic_publish(
exchange="",
routing_key="orders",
body=b'{"order_id": 42}',
properties=pika.BasicProperties(
delivery_mode=pika.DeliveryMode.Persistent,
message_id="event-123",
),
mandatory=True,
)
if not confirmed:
raise RuntimeError("RabbitMQ did not confirm the message")
Publisher confirm означает, что RabbitMQ принял ответственность за сообщение. Он не означает, что Consumer уже обработал его.
publisher confirm:
RabbitMQ принял сообщение
consumer ACK:
Consumer завершил обработку
Это независимые механизмы.
Потерянный confirm и дубликаты #
Возможна ситуация:
1. RabbitMQ принял сообщение
2. Отправил confirm
3. Соединение оборвалось
4. Producer confirm не получил
5. Producer публикует сообщение повторно
В RabbitMQ уже может находиться первая копия, поэтому повторная публикация создаст дубликат. Официальное руководство RabbitMQ прямо предупреждает, что повторная отправка неподтверждённых публикаций может приводить к дубликатам.
Следовательно, publisher confirms не отменяют необходимость использовать:
message_id;
event_id;
idempotency_key;
дедупликацию на стороне Consumer-а.
Durable queue и persistent message #
Для переживания перезапуска Broker-а нужно различать очередь и сообщение.
Durable queue #
channel.queue_declare(
queue="orders",
durable=True,
)
durable=True означает, что определение очереди восстанавливается после перезапуска RabbitMQ.
Persistent message #
properties=pika.BasicProperties(
delivery_mode=pika.DeliveryMode.Persistent,
)
Persistent-сообщение предназначено для сохранения при перезапуске Broker-а.
Для надёжного варианта нужны вместе:
durable queue
+
persistent message
+
publisher confirms
Одного durable=True недостаточно: transient-сообщение не становится persistent только потому, что попало в durable-очередь. RabbitMQ рассматривает долговечность очереди и режим сохранения сообщения как отдельные свойства.
Quorum queues #
Обычная durable classic queue может пережить перезапуск своего узла, но не предоставляет такую же репликацию данных между узлами, как quorum queue.
Quorum queue:
┌── replica 1
Producer ─┼── replica 2
└── replica 3
Она использует Raft и хранит реплицированное состояние очереди. RabbitMQ рекомендует quorum queues для долгоживущих критичных очередей, где важны сохранность данных и высокая доступность.
Публикация считается подтверждённой, когда сообщение принято кворумом реплик:
3 реплики
↓
минимум 2 приняли сообщение
↓
Producer получает confirm
Успешно подтверждённое сообщение не должно быть потеряно, пока большинство узлов quorum queue не стало постоянно недоступно. При этом для неподтверждённых публикаций гарантий сохранности нет.
Практическая комбинация для критичной очереди:
quorum queue
+
publisher confirms
+
manual consumer acknowledgements
+
идемпотентный Consumer
Unroutable messages #
Даже подтверждённая публикация не всегда означает, что сообщение попало в очередь.
Например:
Producer → Exchange
│
└── нет подходящего binding
RabbitMQ может подтвердить сообщение после того, как Exchange определил, что оно не маршрутизируется ни в одну очередь. Чтобы Producer узнал об этом, при публикации используется флаг mandatory; Broker вернёт сообщение через basic.return.
channel.basic_publish(
exchange="orders",
routing_key="unknown.key",
body=payload,
mandatory=True,
)
Поэтому надёжный Producer должен обрабатывать:
publisher confirm;
publisher nack;
basic.return для unroutable message;
обрыв соединения;
тайм-аут ожидания confirm.
ACK, NACK и reject #
После получения сообщения Consumer может выбрать одно из действий.
ACK #
Операция выполнена успешно
→ удалить сообщение
channel.basic_ack(delivery_tag)
NACK с requeue #
Ошибка временная
→ вернуть сообщение в очередь
channel.basic_nack(
delivery_tag,
requeue=True,
)
NACK без requeue #
Сообщение невозможно обработать
→ удалить или направить в DLX
channel.basic_nack(
delivery_tag,
requeue=False,
)
RabbitMQ поддерживает basic.reject и basic.nack; последний также позволяет отрицательно подтвердить несколько сообщений. Сообщение с requeue=false может быть направлено в Dead Letter Exchange, если он настроен.
Почему нельзя бесконечно делать requeue #
Опасная схема:
Consumer получил сообщение
↓
ошибка
↓
NACK requeue=True
↓
Consumer снова получил сообщение
↓
ошибка
↓
...
Это создаёт бесконечный цикл, загружая Broker и Consumer-ы.
Обычно используют:
основная очередь
↓ временная ошибка
retry queue
↓ задержка
основная очередь
↓ превышен лимит
DLQ
Сообщение может быть dead-lettered после reject/nack с requeue=false, истечения TTL, превышения длины очереди либо превышения delivery limit для quorum queue.
DLX тоже имеет собственные гарантии #
Обычная dead-lettering-передача не обязательно является надёжной at-least-once операцией.
Для quorum queue RabbitMQ поддерживает специальный режим at-least-once dead-lettering:
Source quorum queue
↓ internal consumer
↓ publish + publisher confirms
Target queue
↓
ACK исходному сообщению
При этом исходное сообщение удаляется только после подтверждения публикации в целевую очередь. Однако повторные попытки всё равно могут создать дубликаты в целевой очереди. По умолчанию стратегия dead-lettering для quorum queues остаётся at-most-once.
Идемпотентный Consumer #
Поскольку at-least-once допускает дубликаты, Consumer должен уметь распознавать уже обработанные сообщения.
Например:
CREATE TABLE processed_messages (
consumer_name TEXT NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, message_id)
);
Обработка в одной транзакции:
def handle_message(message: Message) -> None:
with database.transaction():
inserted = processed_messages.try_insert(
consumer_name="order_consumer",
message_id=message.id,
)
if not inserted:
return
order_service.apply_event(message)
Важно, чтобы запись о дедупликации и бизнес-операция фиксировались атомарно:
одна транзакция
├── INSERT processed_messages
└── UPDATE business_data
После успешного COMMIT Consumer отправляет ACK.
COMMIT
↓
ACK
При падении до ACK сообщение придёт повторно, но уникальный message_id не позволит повторить бизнес-эффект.
Transactional Outbox для Producer-а #
Publisher confirms защищают участок:
приложение → RabbitMQ
Но не решают проблему:
save_order(order)
publish_event(order)
Если приложение упадёт после сохранения заказа, но до публикации события:
Order сохранён
OrderCreated не отправлен
Обычно применяют Transactional Outbox:
Одна транзакция PostgreSQL
├── orders
└── outbox_events
Отдельный Publisher читает outbox и отправляет события в RabbitMQ с publisher confirms.
Business transaction
↓
Outbox table
↓
Outbox Publisher
↓
RabbitMQ
Outbox Publisher может отправить событие повторно при потере confirm, поэтому Consumer всё равно должен выполнять дедупликацию.
Практические уровни RabbitMQ #
Минимальная надёжность #
auto acknowledgement
transient queue/message
без publisher confirms
Семантика близка к:
at-most-once
Потеря сообщений допустима.
Обычная надёжная обработка #
durable queue
persistent messages
publisher confirms
manual acknowledgements
идемпотентный Consumer
Семантика:
at-least-once
Сообщение может прийти повторно.
Критичная кластерная очередь #
quorum queue
publisher confirms
manual acknowledgements
идемпотентный Consumer
Transactional Outbox
Inbox / таблица дедупликации
DLQ и ограниченные retries
Результат:
at-least-once доставка
+
защита от повторного бизнес-эффекта
Итог #
RabbitMQ практически поддерживает две основные модели:
Без acknowledgements:
at-most-once
сообщение может потеряться
С acknowledgements и повторной доставкой:
at-least-once
сообщение может быть обработано повторно
Exactly-once RabbitMQ сам по себе не предоставляет. Для критичных операций обычно применяют:
Publisher:
Transactional Outbox
+ publisher confirms
RabbitMQ:
quorum queue
+ репликация
Consumer:
manual ACK
+ идемпотентность
+ Inbox/deduplication
Главная схема:
Producer DB transaction
├── бизнес-данные
└── outbox
↓
publisher confirms
↓
RabbitMQ quorum queue
↓
manual delivery
↓
Consumer DB transaction
├── processed_messages
└── бизнес-изменение
↓
ACK
Такое построение не исключает физические дубликаты сообщений, но предотвращает повторный бизнес-эффект.
8. Из каких основных компонентов состоит брокер сообщений? #
Основные компоненты брокера сообщений #
В типовой системе обмена сообщениями участвуют:
Producer
│ публикует Message
▼
Broker
│ маршрутизирует и хранит
▼
Queue / Topic
│
▼
Consumer
Сам брокер находится между отправителями и получателями: принимает сообщения от Producer-ов, определяет место назначения, сохраняет их и доставляет Consumer-ам. В RabbitMQ приложения обычно публикуют сообщения в exchange, после чего они маршрутизируются в очереди по правилам binding.
1. Producer #
Producer, или Publisher, — приложение, которое создаёт и отправляет сообщение.
Например:
Order Service
│
│ OrderCreated
▼
RabbitMQ
Producer обычно определяет:
тело сообщения;
тип события или команды;
идентификатор сообщения;
routing key;
заголовки;
параметры сохранения;
срок жизни сообщения.
Пример сообщения:
{
"message_id": "evt-125",
"event_type": "OrderCreated",
"order_id": 42,
"created_at": "2026-07-27T10:00:00Z"
}
2. Message #
Message — единица передаваемых данных.
Обычно оно состоит из двух частей:
Message
├── Headers / Properties
└── Payload
Пример:
Headers:
message_id = evt-125
content_type = application/json
routing_key = order.created
Payload:
{
"order_id": 42
}
Broker обычно не обязан понимать бизнес-смысл содержимого сообщения. Для него важнее адрес, routing key, заголовки и параметры доставки.
3. Exchange или маршрутизатор #
В RabbitMQ Producer обычно отправляет сообщение в exchange, а не напрямую Consumer-у.
Producer
│ routing_key=payment.succeeded
▼
Exchange
├──> accounting_queue
├──> notification_queue
└──> audit_queue
Exchange принимает сообщение и определяет, в какие очереди его направить. Алгоритм маршрутизации зависит от типа exchange и настроенных bindings. RabbitMQ поддерживает основные типы direct, fanout, topic и headers.
Direct exchange #
Маршрутизация по точному совпадению ключа:
routing_key = payment.succeeded
↓
binding_key = payment.succeeded
Fanout exchange #
Отправляет копию сообщения во все связанные очереди:
┌──> analytics
Event → fanout ──┼──> notifications
└──> audit
Topic exchange #
Маршрутизирует по шаблону:
События:
order.created
order.cancelled
payment.succeeded
Binding:
order.*
Headers exchange #
Использует значения заголовков вместо routing key.
Exchange — особенность модели AMQP/RabbitMQ. В других брокерах аналогичную роль могут выполнять topics, subscriptions или другие механизмы маршрутизации.
4. Binding #
Binding — правило связи между exchange и очередью.
Exchange ── binding ──> Queue
Например:
Exchange: domain_events
Queue: notification_queue
Binding key: order.*
Это означает, что подходящие события заказов будут направляться в очередь уведомлений.
Binding может содержать binding key, который служит фильтром для определённых типов exchange. У одного exchange может быть несколько bindings, а одна очередь может быть связана с несколькими exchange.
5. Queue #
Queue — хранилище сообщений, ожидающих обработки.
Queue
├── Message 1
├── Message 2
├── Message 3
└── Message 4
Очередь разделяет скорость Producer-а и Consumer-а:
Producer: создаёт 1000 сообщений/с
Consumer: обрабатывает 300 сообщений/с
↓
остаток временно хранится
в очереди
В RabbitMQ очередь представляет собой упорядоченную коллекцию сообщений, но фактический порядок обработки может изменяться при нескольких конкурирующих Consumer-ах, повторной доставке и приоритетах.
Для очереди обычно настраивают:
имя;
долговечность;
максимальную длину;
TTL;
тип очереди;
Dead Letter Exchange;
количество Consumer-ов;
политику репликации.
6. Consumer #
Consumer — приложение, которое подписывается на очередь и обрабатывает сообщения.
Queue
├──> Consumer 1
├──> Consumer 2
└──> Consumer 3
Например:
def handle_order_created(message: dict) -> None:
send_confirmation_email(
order_id=message["order_id"],
)
Если несколько Consumer-ов читают одну очередь, они обычно конкурируют за сообщения:
Message 1 → Consumer 1
Message 2 → Consumer 2
Message 3 → Consumer 1
Одно сообщение рабочей очереди обычно передаётся одному Consumer-у, а не всем сразу. Для доставки одного события нескольким системам создают несколько очередей, связанных с одним exchange.
7. Acknowledgement #
Acknowledgement, или ACK, — подтверждение от Consumer-а, что сообщение успешно обработано.
Broker → Consumer: Message
Consumer → БД: COMMIT
Consumer → Broker: ACK
После ACK брокер может удалить сообщение из очереди.
При ошибке Consumer может отправить отрицательное подтверждение:
NACK + requeue=true
→ вернуть сообщение в очередь
NACK + requeue=false
→ удалить или направить в DLQ
RabbitMQ использует Consumer acknowledgements отдельно от Publisher confirms: первые относятся к обработке сообщения Consumer-ом, вторые — к принятию публикации Broker-ом.
8. Publisher confirm #
Publisher confirm — подтверждение Producer-у, что Broker принял публикацию.
Producer ── Message ──> Broker
Producer <── Confirm ── Broker
Это не означает, что Consumer уже выполнил задачу:
Publisher confirm:
сообщение принято RabbitMQ
Consumer ACK:
сообщение обработано Consumer-ом
Эти подтверждения защищают разные участки доставки.
9. Dead Letter Queue #
Dead Letter Queue, или DLQ, хранит сообщения, которые не удалось нормально обработать.
Main Queue
│ ошибка после нескольких retries
▼
Dead Letter Exchange
▼
Dead Letter Queue
Туда могут попадать сообщения:
отклонённые без
requeue;с истёкшим TTL;
превысившие лимит доставок;
вытесненные из-за ограничения длины очереди.
DLQ позволяет не создавать бесконечный цикл обработки неисправного сообщения и сохраняет его для анализа или ручного повторного запуска.
10. Retry queue #
Retry queue используется для отложенной повторной обработки:
Main Queue
│ временная ошибка
▼
Retry Queue
│ TTL: 30 секунд
▼
Main Queue
Retry и DLQ не всегда являются отдельными встроенными сущностями брокера. Часто это обычные очереди, которым задают специальные TTL, bindings и dead-letter policies.
11. Connection #
Клиентские приложения подключаются к брокеру через сетевое соединение:
Application ── TCP/TLS connection ──> RabbitMQ
Соединение обычно является долгоживущим. Создавать новое TCP-соединение для каждого сообщения слишком дорого. RabbitMQ поддерживает несколько протоколов, а AMQP-соединения обычно сохраняются и переиспользуются приложением.
12. Channel #
В AMQP 0-9-1 внутри одного TCP-соединения могут существовать несколько логических channels:
TCP Connection
├── Channel 1 → publishing
├── Channel 2 → consuming
└── Channel 3 → topology operations
Channel — облегчённая логическая связь, разделяющая одно физическое TCP-соединение. Через channels приложение объявляет exchange и очереди, публикует сообщения и запускает Consumer-ов.
13. Virtual host #
В RabbitMQ virtual host логически изолирует ресурсы:
RabbitMQ cluster
├── vhost: production
│ ├── exchanges
│ ├── queues
│ └── bindings
│
└── vhost: testing
├── exchanges
├── queues
└── bindings
Внутри vhost находятся:
exchanges;
queues;
bindings;
permissions;
policies.
Virtual hosts обеспечивают логическое разделение, но не являются физически независимыми RabbitMQ-кластерами. Права пользователей также назначаются в контексте конкретного vhost.
14. Управление доступом #
Брокер также включает инфраструктуру безопасности:
Authentication:
кто подключается
Authorization:
что этому клиенту разрешено
TLS:
защита сетевого трафика
Для Producer-а можно разрешить только публикацию:
write → domain_events
Для Consumer-а — только чтение конкретной очереди:
read → notification_queue
Это ограничивает последствия компрометации одного приложения.
Полная схема RabbitMQ #
Producer
│
│ Message + routing_key
▼
Connection / Channel
│
▼
Exchange
│
│ Binding
▼
Queue
│
├── retry / DLQ
│
▼
Consumer
│
│ ACK / NACK
▼
Business operation
Обязательные и дополнительные компоненты #
Минимальная концептуальная схема любого брокера:
Producer
Message
Broker
Queue или Topic
Consumer
В RabbitMQ она расширяется:
Producer
↓
Exchange
↓
Binding
↓
Queue
↓
Consumer
Инфраструктурные компоненты:
Connections и channels
Acknowledgements и confirms
Retry и DLQ
Virtual hosts
Authentication и permissions
Persistence и replication
Monitoring
Итог #
Главные роли компонентов:
| Компонент | Ответственность |
|---|---|
| Producer | Создаёт и публикует сообщение |
| Message | Содержит данные и метаданные |
| Exchange | Маршрутизирует сообщения |
| Binding | Связывает exchange с очередью |
| Queue | Хранит сообщения до обработки |
| Consumer | Получает и обрабатывает сообщения |
| ACK/NACK | Подтверждает или отклоняет обработку |
| Publisher confirm | Подтверждает приём публикации |
| Retry/DLQ | Обрабатывает временные и окончательные ошибки |
| Connection/channel | Обеспечивает сетевое взаимодействие |
| Virtual host | Логически изолирует ресурсы |
В упрощённом виде:
Producer создаёт сообщение.
Exchange решает, куда его направить.
Queue хранит сообщение.
Consumer обрабатывает его.
ACK подтверждает успешную обработку.
9. Механизм доставки сообщений в RabbitMQ #
Общая схема доставки #
В RabbitMQ сообщение проходит следующий путь:
Producer
│ publish(message, exchange, routing_key)
▼
Exchange
│ проверяет bindings
▼
Queue
│ хранит сообщение
▼
Consumer
│ обрабатывает
▼
ACK / NACK
Producer обычно публикует сообщение в exchange. Exchange маршрутизирует его в одну или несколько очередей, после чего RabbitMQ доставляет сообщение подписанному Consumer-у.
1. Подключение Producer-а #
Producer устанавливает TCP-соединение с RabbitMQ и открывает внутри него AMQP-channel:
Producer
│
└── TCP connection
└── Channel
Через channel выполняются:
объявление exchange и очередей;
создание bindings;
публикация сообщений;
получение publisher confirms.
Channels позволяют выполнять несколько логических операций поверх одного соединения.
2. Публикация сообщения #
Producer указывает:
exchange;
routing_key;
payload;
properties;
headers.
Например:
channel.basic_publish(
exchange="domain_events",
routing_key="order.created",
body=b'{"order_id": 42}',
properties=properties,
)
Сообщение может содержать свойства:
message_id;
content_type;
delivery_mode;
timestamp;
type;
headers.
delivery_mode=2 обозначает persistent-сообщение, а delivery_mode=1 — transient.
3. Publisher confirm #
Запись данных в сетевой сокет ещё не означает, что RabbitMQ получил и принял сообщение. Поэтому для надёжной публикации применяется publisher confirms:
Producer ── message ──> RabbitMQ
Producer <── confirm ── RabbitMQ
После перевода channel в confirm mode RabbitMQ подтверждает каждую публикацию через basic.ack либо отклоняет её через basic.nack. Publisher confirms относятся только к участку Producer → RabbitMQ и не означают, что Consumer уже обработал сообщение.
Для persistent-сообщения, направленного в durable-очередь, подтверждение обычно отправляется после сохранения сообщения. Для quorum queue — после принятия сообщения кворумом реплик.
4. Маршрутизация через Exchange #
Exchange не хранит сообщения как обычная очередь. Он определяет, куда их направить.
Producer
│ routing_key = order.created
▼
Exchange: domain_events
├──> notification_queue
├──> analytics_queue
└──> audit_queue
Решение принимается на основе:
типа exchange;
routing key сообщения;
bindings;
binding keys;
заголовков.
Binding связывает exchange с очередью:
Exchange ── binding ──> Queue
В RabbitMQ сообщение может быть направлено сразу в несколько очередей, если оно подходит под несколько bindings.
Типы Exchange #
Direct #
Требует точного совпадения routing_key и binding_key:
routing_key: payment.succeeded
binding_key: payment.succeeded
Topic #
Поддерживает шаблоны:
order.created
order.cancelled
order.completed
Binding:
order.*
Fanout #
Отправляет сообщение во все связанные очереди, игнорируя routing key:
┌──> queue_a
Producer → fanout ┼──> queue_b
└──> queue_c
Headers #
Маршрутизирует по заголовкам сообщения.
5. Что происходит с немаршрутизируемым сообщением #
Если ни один binding не подходит, сообщение не попадает ни в одну очередь.
Producer
↓
Exchange
↓
подходящих bindings нет
↓
сообщение не маршрутизировано
Publisher confirm при этом сам по себе не гарантирует попадание в очередь: RabbitMQ может подтвердить, что exchange обработал публикацию, даже если подходящая очередь не найдена.
Для обнаружения такого случая используется mandatory=True. Тогда RabbitMQ возвращает сообщение Producer-у через basic.return до отправки publisher confirm.
channel.basic_publish(
exchange="domain_events",
routing_key="unknown.event",
body=payload,
mandatory=True,
)
6. Попадание сообщения в очередь #
После маршрутизации сообщение помещается в подходящие очереди:
Queue
├── message 1
├── message 2
├── message 3
└── message 4
Очередь хранит сообщение, пока:
Consumer не подтвердит обработку;
не истечёт TTL;
сообщение не будет удалено политикой;
оно не попадёт в Dead Letter Exchange;
очередь не будет удалена.
Для переживания перезапуска RabbitMQ обычно сочетают:
durable queue;
persistent message;
publisher confirms.
Для реплицируемых критичных очередей могут использоваться quorum queues; RabbitMQ рекомендует сочетать их с publisher confirms и ручными Consumer acknowledgements.
7. Подписка Consumer-а #
Consumer регистрируется на определённой очереди:
Consumer ── basic.consume ──> Queue
После подписки RabbitMQ обычно push-ит сообщения Consumer-у асинхронно.
Queue ── basic.deliver ──> Consumer
AMQP 0-9-1 также поддерживает одиночное получение через basic.get, но обычная длительная обработка строится через Consumer subscription.
При доставке RabbitMQ передаёт:
payload;
properties;
routing key;
exchange;
delivery tag;
redelivered flag.
delivery_tag идентифицирует конкретную доставку внутри channel. Подтвердить сообщение необходимо на том же channel, на котором оно было получено.
8. Распределение между несколькими Consumer-ами #
Если одну очередь читают несколько Consumer-ов:
┌──> Consumer 1
Queue ────────┼──> Consumer 2
└──> Consumer 3
одно сообщение передаётся одному подходящему Consumer-у. RabbitMQ распределяет сообщения между Consumer-ами с учётом их доступности и количества уже выданных, но ещё не подтверждённых сообщений.
message 1 → Consumer 1
message 2 → Consumer 2
message 3 → Consumer 3
При нескольких Consumer-ах, повторной доставке и разном времени обработки фактический порядок завершения сообщений уже не обязан соответствовать исходному порядку очереди.
9. Prefetch #
RabbitMQ может отправить Consumer-у несколько сообщений, не дожидаясь подтверждения каждого:
Consumer
├── message 1 — processing
├── message 2 — waiting
├── message 3 — waiting
└── message 4 — waiting
Количество неподтверждённых сообщений ограничивается через prefetch:
channel.basic_qos(prefetch_count=10)
При prefetch_count=10 RabbitMQ перестаёт отправлять новые сообщения этому Consumer-у после накопления десяти неподтверждённых доставок. Доставка продолжится после получения хотя бы одного ACK. Значение 0 означает отсутствие такого ограничения.
Prefetch нужен для:
защиты Consumer-а от перегрузки;
справедливого распределения задач;
ограничения потребления памяти;
управления количеством параллельной работы.
10. Обработка и ACK #
При ручных подтверждениях нормальная последовательность выглядит так:
RabbitMQ доставляет сообщение
↓
Consumer проверяет сообщение
↓
Consumer выполняет операцию
↓
COMMIT в БД
↓
Consumer отправляет ACK
↓
RabbitMQ удаляет сообщение
def handle(channel, method, properties, body):
process_message(body)
channel.basic_ack(
delivery_tag=method.delivery_tag,
)
RabbitMQ рекомендует отправлять ACK только после выполнения всех действий, за которые отвечает Consumer. После ACK брокер может удалить доставку.
11. Автоматическое подтверждение #
В автоматическом режиме RabbitMQ считает сообщение доставленным сразу после записи в сетевой сокет Consumer-а:
RabbitMQ отправил сообщение
↓
считает его обработанным
↓
Consumer упал
↓
сообщение может быть потеряно
Automatic acknowledgement даёт меньшие накладные расходы, но предоставляет наименьшие гарантии при сбоях. Официальная документация рекомендует в общем случае сначала рассматривать manual acknowledgement.
12. Ошибка обработки: NACK и Reject #
При ошибке Consumer может отрицательно подтвердить доставку.
Вернуть сообщение в очередь #
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=True,
)
временная ошибка
↓
NACK + requeue
↓
сообщение снова доступно Consumer-ам
Не возвращать в очередь #
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=False,
)
Тогда сообщение:
удаляется;
или направляется в Dead Letter Exchange,
если DLX настроен.
Бесконечный requeue=True опасен:
получение → ошибка → requeue
↑ │
└─────────────────────┘
Для временных ошибок обычно создают ограниченный retry-механизм, а окончательно неисправные сообщения отправляют в DLQ.
13. Падение Consumer-а #
При manual acknowledgement, если Consumer, channel или соединение закрывается до ACK, RabbitMQ автоматически возвращает неподтверждённое сообщение в очередь:
Consumer получил сообщение
↓
Consumer упал до ACK
↓
RabbitMQ выполняет requeue
↓
сообщение получает другой Consumer
При повторной доставке поле redelivered устанавливается в true. Consumer должен быть готов к дубликатам и проектироваться идемпотентно.
14. Почему возможны дубликаты #
Рассмотрим обработку:
1. Consumer обновил БД
2. Consumer должен отправить ACK
3. Consumer упал до ACK
4. RabbitMQ доставил сообщение повторно
RabbitMQ не знает, был ли выполнен COMMIT, поэтому дубликат является нормальной частью at-least-once доставки.
Consumer должен проверять message_id:
CREATE TABLE processed_messages (
consumer_name TEXT NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, message_id)
);
Первая доставка:
message_id отсутствует
→ выполнить операцию
Повторная доставка:
message_id уже существует
→ не повторять операцию
RabbitMQ прямо указывает, что acknowledgements обеспечивают модель at-least-once, а без них возможна потеря сообщений и семантика становится близкой к at-most-once.
Полный механизм доставки #
1. Producer открывает connection и channel
2. Producer публикует:
exchange + routing_key + payload
3. Exchange проверяет bindings
4. Сообщение направляется
в одну или несколько очередей
5. Очередь сохраняет сообщение
6. RabbitMQ выбирает подходящего Consumer-а
7. RabbitMQ отправляет сообщение
вместе с delivery_tag
8. Consumer выполняет бизнес-операцию
9. Consumer отправляет:
ACK
или NACK/reject
10. RabbitMQ:
ACK → удаляет сообщение
NACK + requeue → возвращает в очередь
NACK без requeue → удаляет или отправляет в DLQ
Два независимых подтверждения #
Важно не путать:
Publisher confirm
Producer <── RabbitMQ
и:
Consumer ACK
RabbitMQ <── Consumer
| Механизм | Что подтверждает |
|---|---|
| Publisher confirm | RabbitMQ принял публикацию |
| Consumer ACK | Consumer завершил обработку |
| Consumer NACK | Consumer не смог обработать сообщение |
mandatory + basic.return | Сообщение не удалось направить в очередь |
Publisher confirms и Consumer acknowledgements решают разные задачи и не связаны напрямую.
Итог #
Механизм RabbitMQ строится вокруг пяти операций:
Публикация
↓
Маршрутизация
↓
Хранение в очереди
↓
Доставка Consumer-у
↓
Подтверждение или повторная доставка
Для надёжной схемы обычно используют:
Producer:
publisher confirms
+ mandatory publishing
RabbitMQ:
durable или quorum queue
+ persistent messages
Consumer:
manual ACK
+ prefetch
+ ограниченные retries
+ DLQ
+ идемпотентность
Это даёт практическую семантику at-least-once: сообщение стараются не потерять, но оно может быть доставлено и обработано повторно.
10. Что делать, если очередь сообщений не гарантирует порядок? #
Сначала определить, какой порядок действительно нужен #
Глобальный порядок всех сообщений обычно не требуется. Чаще нужен порядок в рамках одной бизнес-сущности:
order_id=42:
OrderCreated
↓
OrderPaid
↓
OrderShipped
При этом события разных заказов могут обрабатываться параллельно:
order_id=42: Created → Paid → Shipped
order_id=91: Created → Cancelled
Это позволяет сохранить корректность без полной остановки параллелизма.
Почему порядок может нарушаться #
RabbitMQ хранит сообщения очереди упорядоченно, но наблюдаемый порядок обработки может измениться из-за:
нескольких конкурирующих Consumer-ов;
параллельной обработки внутри Consumer-а;
разных скоростей выполнения;
NACKи повторной постановки в очередь;падения Consumer-а и redelivery;
нескольких каналов публикации;
приоритетных очередей.
Начальные доставки одному Consumer-у выполняются в порядке постановки в очередь, но конкурентные Consumer-ы и повторная доставка меняют фактический порядок завершения обработки.
Queue:
A → B → C
Consumer 1 получает A — обрабатывает 10 секунд
Consumer 2 получает B — обрабатывает 1 секунду
Consumer 3 получает C — обрабатывает 2 секунды
Порядок завершения:
B → C → A
1. Использовать Single Active Consumer #
Когда необходим строгий порядок обработки одной очереди, можно включить Single Active Consumer, или SAC:
Queue
├── Consumer 1 — ACTIVE
├── Consumer 2 — standby
└── Consumer 3 — standby
Только один Consumer получает сообщения. Если он отключится, RabbitMQ автоматически выберет другого зарегистрированного Consumer-а. RabbitMQ рекомендует SAC для очередей, сообщения которых должны обрабатываться в порядке поступления.
При объявлении очереди:
channel.queue_declare(
queue="order_events",
durable=True,
arguments={
"x-single-active-consumer": True,
},
)
При этом нужно учитывать внутреннюю параллельность приложения. Даже если RabbitMQ использует одного Consumer-а, тот может передавать сообщения нескольким потокам или процессам:
RabbitMQ SAC
↓
один Consumer
↓
thread/process pool
├── task A
├── task B
└── task C
Тогда порядок завершения снова нарушится. Для строгой последовательности обработка должна быть однопоточной:
одна очередь
+
один активный Consumer
+
одна выполняемая операция одновременно
Например, для отдельного Celery Worker:
celery -A app worker \
-Q ordered_orders \
--concurrency=1
Недостаток очевиден: одна очередь обрабатывается последовательно, поэтому пропускная способность ограничена скоростью одного обработчика.
2. Разделить сообщения по ключу #
Обычно лучший вариант — не создавать одну глобально последовательную очередь, а партиционировать сообщения по ключу сущности:
hash(order_id) % 4
order_id=42 → Queue 2
order_id=91 → Queue 3
order_id=17 → Queue 1
order_id=66 → Queue 2
Все события одного заказа всегда попадают в одну очередь:
Queue 2:
OrderCreated(42)
OrderPaid(42)
OrderShipped(42)
Каждая очередь имеет одного активного Consumer-а:
┌── Queue 0 → SAC Consumer
Producer ────┼── Queue 1 → SAC Consumer
├── Queue 2 → SAC Consumer
└── Queue 3 → SAC Consumer
RabbitMQ предоставляет x-modulus-hash exchange, который стабильно маршрутизирует одинаковый routing key в одну очередь. В документации этот механизм прямо предлагается сочетать с SAC для сохранения порядка по конкретному ключу при параллельной обработке разных ключей.
routing_key = order_id
Преимущества:
события одной сущности последовательны;
разные сущности обрабатываются параллельно;
можно масштабироваться числом партиций;
нет глобальной блокировки системы.
Ограничение: число партиций и перераспределение ключей нужно продумывать заранее. Изменение схемы маршрутизации может направить новые события сущности в другую очередь, пока старые ещё не обработаны.
3. Добавлять номер версии или последовательности #
Каждое сообщение должно содержать монотонно увеличивающийся номер:
{
"event_id": "9548309c-2162-4d1c-999f-fc15cedb0807",
"aggregate_id": "order-42",
"aggregate_version": 7,
"event_type": "OrderPaid"
}
Следующее событие:
{
"aggregate_id": "order-42",
"aggregate_version": 8,
"event_type": "OrderShipped"
}
Consumer хранит последнюю обработанную версию:
order-42:
last_processed_version = 7
Правила обработки:
version == last_version + 1
→ обработать
version <= last_version
→ дубликат или устаревшее сообщение, пропустить
version > last_version + 1
→ обнаружен пропуск
Пример:
def handle_event(event: OrderEvent) -> None:
with database.transaction():
state = repository.lock(event.aggregate_id)
if event.version <= state.last_event_version:
return
if event.version != state.last_event_version + 1:
raise EventSequenceGap(
expected=state.last_event_version + 1,
received=event.version,
)
apply_event(state, event)
state.last_event_version = event.version
repository.save(state)
Это не заставляет брокер доставлять сообщения правильно, но позволяет Consumer-у:
обнаруживать нарушение порядка;
не применять устаревшее событие;
распознавать дубликаты;
не повреждать состояние.
4. Сделать операции устойчивыми к устаревшим сообщениям #
Иногда последовательность можно защитить условным обновлением:
UPDATE orders
SET
status = 'shipped',
version = 8
WHERE id = 42
AND version = 7;
Если обновлена одна строка, событие применено:
version 7 → version 8
Если обновлено ноль строк:
состояние уже новее;
предыдущее событие отсутствует;
событие доставлено повторно.
Такой optimistic concurrency control защищает от применения события к неправильной версии агрегата.
Для простого обновления снимка можно принимать только более новую версию:
UPDATE product_read_model
SET
name = :name,
price = :price,
source_version = :version
WHERE product_id = :product_id
AND source_version < :version;
Тогда поздно пришедшее старое сообщение не перезапишет новые данные.
5. Использовать буфер переупорядочивания #
Если сообщения могут ненадолго приходить не по порядку, Consumer может временно сохранять будущие события:
Получено:
version 5
Ожидалось:
version 4
Consumer помещает version=5 в буфер:
pending_events:
order-42:
version 5
После получения version=4:
обработать 4
обработать сохранённое 5
Упрощённая логика:
def handle(event: Event) -> None:
expected = get_expected_version(event.aggregate_id)
if event.version < expected:
return
if event.version > expected:
save_to_reorder_buffer(event)
schedule_gap_check(event.aggregate_id)
return
process_in_sequence(event)
process_buffered_contiguous_events(event.aggregate_id)
Обязательно нужны:
ограниченный размер буфера;
TTL;
тайм-аут ожидания пропущенного события;
DLQ или reconciliation при незакрытом разрыве;
метрики количества gaps.
Иначе одно потерянное событие может навсегда заблокировать обработку сущности.
6. Не возвращать сообщение бесконечно в основную очередь #
При строгом порядке возникает проблема:
A → B → C
A завершилось ошибкой:
A → NACK requeue
B → успешно
C → успешно
A → повторная доставка
Теперь порядок нарушен.
RabbitMQ предупреждает, что requeue и redelivery способны менять наблюдаемый порядок. Для сохранения порядка документация рекомендует контролировать возврат сообщений и использовать одну активную последовательность обработки.
Если порядок критичен, есть несколько вариантов.
Остановить обработку очереди до исправления A #
A failed
↓
Consumer прекращает брать новые сообщения
↓
A retry
↓
после успеха продолжаются B и C
Это сохраняет строгий порядок, но одно проблемное сообщение блокирует очередь.
Заблокировать только конкретный ключ #
order-42 заблокирован из-за ошибки
order-91 продолжает обрабатываться
order-15 продолжает обрабатываться
Для этого нужна партиционированная обработка или диспетчеризация по aggregate_id.
Переместить проблемное сообщение в DLQ #
A failed → DLQ
B и C продолжаются
Это повышает доступность, но строгий порядок уже потерян. Подходит только если B и C умеют обнаружить отсутствующую версию и не применять её автоматически.
7. Проектировать события так, чтобы порядок был менее важен #
Плохое событие:
{
"product_id": 42,
"operation": "increase_price",
"amount": 100
}
Два таких события зависят от порядка.
Иногда лучше отправлять новое состояние и его версию:
{
"product_id": 42,
"price": 1500,
"version": 12
}
Consumer применяет только более новую версию:
version 12 пришла раньше version 11
применить 12
игнорировать 11
Но snapshot-события подходят не всегда. Например, финансовые проводки нельзя произвольно заменить последним состоянием без сохранения истории операций.
8. Не использовать timestamp как единственный порядок #
Время создания сообщения не является надёжным номером последовательности:
{
"created_at": "2026-07-27T12:00:00.123Z"
}
Проблемы:
часы разных серверов могут расходиться;
два события могут иметь одинаковое время;
повторная публикация получает другое время;
сетевые задержки не связаны с временем создания.
Для порядка лучше использовать:
aggregate_version;
sequence_number;
offset;
монотонный номер из одного источника.
Timestamp можно оставить для аудита, но не как единственный механизм согласования.
9. Рассмотреть RabbitMQ Streams #
Когда нужен журнал событий с устойчивой позицией, replay и последовательным чтением, RabbitMQ предлагает Streams.
Stream — append-only log, где каждому сообщению назначается неизменяемый offset. RabbitMQ называет Streams одним из двух основных способов сохранения порядка; второй — очередь с Single Active Consumer.
offset 100: OrderCreated
offset 101: OrderPaid
offset 102: OrderShipped
Consumer хранит позицию:
last processed offset = 101
После перезапуска продолжает с 102.
Streams лучше подходят, когда нужны:
повторное чтение истории;
несколько независимых Consumer-групп;
длительное хранение событий;
offsets;
высокая пропускная способность;
партиционированный журнал.
Обычные очереди подходят лучше для модели «сообщение обработано и удалено».
Практическая схема для Celery и RabbitMQ #
Допустим, задачи должны выполняться последовательно для каждого пользователя.
def queue_for_user(user_id: int, partitions: int = 8) -> str:
partition = user_id % partitions
return f"user_tasks_{partition}"
Маршрутизация:
task.apply_async(
args=[user_id, operation],
queue=queue_for_user(user_id),
)
Очереди:
user_tasks_0
user_tasks_1
...
user_tasks_7
Для каждой партиции запускается последовательный Worker:
celery -A app worker \
-Q user_tasks_0 \
--concurrency=1
Сообщение дополнительно содержит версию:
@app.task
def process_user_operation(
user_id: int,
operation_id: str,
version: int,
) -> None:
...
Таким образом:
партиционирование
→ одинаковый user_id всегда идёт в одну очередь
concurrency=1
→ внутри партиции нет параллельного выполнения
version
→ Consumer обнаруживает дубликаты и нарушение порядка
Какой способ выбирать #
Нужен строгий глобальный порядок
→ одна очередь + Single Active Consumer
+ последовательная обработка
Нужен порядок по order_id/user_id
→ партиционирование по ключу
+ один активный Consumer на партицию
Нужно обнаруживать неправильный порядок
→ aggregate_version / sequence_number
События могут ненадолго перемешиваться
→ reorder buffer + timeout
Нужны replay и offsets
→ RabbitMQ Streams
Порядок не должен влиять на результат
→ идемпотентные операции
+ применение только новой версии
Рекомендуемый подход #
Для большинства бизнес-систем:
1. Не требовать глобального порядка.
2. Определить ключ упорядочивания: order_id, user_id, account_id.
3. Направлять одинаковый ключ в одну партицию.
4. Обрабатывать партицию последовательно.
5. Добавлять aggregate_version.
6. Делать обработчик идемпотентным.
7. Не применять устаревшие версии.
8. Обнаруживать пропуски последовательности.
9. Ограничивать retries и использовать DLQ.
10. Иметь reconciliation-процесс для восстановления состояния.
Главный принцип:
Порядок следует обеспечивать не глобально,
а в пределах бизнес-сущности.
Broker помогает направить сообщения последовательно,
но окончательную корректность должны обеспечивать
versioning, идемпотентность и проверки состояния
на стороне Consumer-а.
11. Какие сообщения и данные обычно отправляют через RabbitMQ? #
Что именно передают через RabbitMQ #
RabbitMQ не задаёт формат сообщения. Для брокера тело сообщения — это массив байтов; он обычно не понимает содержимое JSON, Protobuf или другого формата. RabbitMQ маршрутизирует сообщение по exchange, routing_key, bindings и иногда headers. Структурированные данные обычно сериализуют в JSON, Protocol Buffers, MessagePack и другие форматы.
1. Команды и фоновые задачи #
Команда сообщает потребителю, что нужно сделать:
{
"task_id": "task-7842",
"user_id": 125,
"report_type": "monthly",
"period": "2026-07"
}
Примеры:
email.send
report.generate
image.resize
payment.process
search.index_document
notification.push
Типичные задачи:
отправка email или push-уведомления;
генерация PDF;
обработка изображения;
импорт большого файла;
пересчёт статистики;
обращение к медленному внешнему API;
выполнение Celery task.
В Work Queue несколько воркеров обычно конкурируют за сообщения, поэтому каждое сообщение обрабатывается одним из них. RabbitMQ прямо описывает task queues как способ отложить ресурсоёмкую работу и распределить её между воркерами.
2. События #
Событие сообщает, что что-то уже произошло:
{
"event_id": "evt-018a",
"order_id": 8421,
"user_id": 125,
"total": "149.90",
"currency": "AZN",
"occurred_at": "2026-07-27T18:40:00Z"
}
Примеры названий:
user.registered
order.created
payment.completed
payment.failed
file.uploaded
profile.avatar.changed
На одно событие могут реагировать разные сервисы:
payment.completed
|
+--> Notification Service
+--> Analytics Service
+--> Accounting Service
+--> Loyalty Service
Для такой схемы используются fanout или topic exchange. Fanout отправляет копию сообщения во все связанные очереди, а topic позволяет сервисам подписываться только на нужные шаблоны routing key.
3. Сигналы для синхронизации сервисов #
Через RabbitMQ часто отправляют небольшие сообщения о необходимости обновить локальное состояние:
{
"product_id": 512,
"version": 7
}
Примеры:
cache.invalidate
search.product.reindex
permissions.changed
configuration.updated
inventory.changed
Здесь обычно передают идентификатор объекта и версию изменения, а не всю таблицу базы данных.
4. Уведомления #
Сообщение может содержать данные для отправки пользователю:
{
"notification_id": "ntf-281",
"user_id": 125,
"channel": "email",
"template": "payment_success",
"variables": {
"payment_id": 821,
"amount": "35.00",
"currency": "AZN"
}
}
Один сервис формирует бизнес-событие, а отдельный consumer решает, отправить email, SMS, push или WebSocket-сообщение.
5. RPC-запросы и ответы #
RabbitMQ поддерживает request/reply:
{
"user_id": 125
}
В свойствах сообщения указываются:
correlation_id = "req-7821"
reply_to = "response.queue"
Ответ содержит тот же correlation_id, чтобы клиент мог сопоставить его с запросом. RabbitMQ документирует correlation_id и reply_to как стандартные свойства для RPC-паттерна.
RPC через брокер стоит применять осторожно: он создаёт временную связанность между сервисами и может превратить асинхронную архитектуру в сложный аналог HTTP-вызовов.
6. Логи и технические события #
Через RabbitMQ могут передаваться:
{
"level": "ERROR",
"service": "payment-service",
"message": "Provider timeout",
"trace_id": "trace-721",
"timestamp": "2026-07-27T18:42:10Z"
}
Примеры:
logs.error
audit.user_login
security.access_denied
monitoring.service_unavailable
Для очень большого непрерывного потока логов и аналитических данных обычно рассматривают специализированные системы или RabbitMQ Streams, а обычные очереди оставляют для рабочих сообщений и событий.
Из чего состоит сообщение #
Практически сообщение состоит из трёх частей.
Тело — body
#
Основные данные:
{
"payment_id": 451,
"status": "completed"
}
Свойства сообщения #
content_type = application/json
content_encoding = utf-8
type = payment.completed
message_id = evt-0192
correlation_id = operation-828
timestamp = 1785177600
delivery_mode = 2
expiration = 60000
app_id = payment-service
RabbitMQ поддерживает свойства delivery_mode, type, headers, content_type, message_id, correlation_id, reply_to, expiration, timestamp, user_id и app_id. Большинство из них устанавливает publisher.
Пользовательские headers #
{
"trace_id": "trace-721",
"schema_version": 2,
"tenant_id": "company-15",
"source": "payment-service"
}
Headers удобно использовать для технической метаинформации, трассировки, версии схемы и маршрутизации. Основные бизнес-данные лучше оставлять в body.
Что обычно не следует отправлять #
Большие файлы #
Не стоит помещать в сообщение PDF, видео, архивы и крупные изображения.
Лучше:
{
"file_id": "file-129",
"storage_key": "reports/2026/07/report-129.pdf",
"bucket": "documents",
"checksum": "sha256:..."
}
Сам файл хранится в S3/MinIO, а через RabbitMQ передаётся ссылка или ключ объекта. В актуальной документации RabbitMQ значение max_message_size по умолчанию составляет 16 MiB, но даже допустимые крупные сообщения увеличивают нагрузку на сеть, память, диски и репликацию.
Объекты ORM и Python-объекты #
Не следует отправлять:
body = pickle.dumps(user_model)
Это связывает producer и consumer с конкретным языком, классом и версией кода. pickle также опасен при обработке недоверенных данных.
Лучше отправить независимый контракт:
{
"user_id": 125,
"email": "user@example.com",
"schema_version": 1
}
Чувствительные данные без необходимости #
Не следует без причины передавать:
пароли;
приватные ключи;
полные данные банковской карты;
access- и refresh-токены;
персональные данные, не нужные consumer.
Обычно передают идентификатор:
{
"payment_id": 451
}
Consumer получает необходимые данные из доступного ему защищённого источника.
Практический формат события #
{
"event_id": "0190f81a-8972-7d30-a021-72d774148f41",
"event_type": "payment.completed",
"event_version": 1,
"occurred_at": "2026-07-27T18:40:00Z",
"producer": "payment-service",
"data": {
"payment_id": 451,
"user_id": 125,
"amount": "35.00",
"currency": "AZN"
}
}
Свойства:
exchange: domain.events
routing_key: payment.completed
content_type: application/json
type: payment.completed
message_id: 0190f81a-8972-7d30-a021-72d774148f41
delivery_mode: persistent
Главный принцип: через RabbitMQ передают не произвольные выгрузки базы данных, а небольшие самостоятельные сообщения — команду, событие или уведомление — с понятным контрактом, идентификатором, типом и версией схемы.
12. Как работает consumer в RabbitMQ и как происходит доставка и подтверждение сообщений? #
Что такое consumer #
Consumer — это приложение или процесс, который подписывается на очередь RabbitMQ и обрабатывает поступающие сообщения.
Обычно consumer:
устанавливает TCP-соединение с RabbitMQ;
открывает AMQP-канал;
подписывается на очередь через
basic.consume;получает сообщения от RabbitMQ;
обрабатывает их;
отправляет подтверждение
ackлибо отказnack/reject.
Обычно используется постоянная подписка: RabbitMQ сам отправляет сообщения consumer’у через basic.deliver. Получение сообщений по одному через basic.get считается неэффективным и не рекомендуется для обычной обработки очередей.
Общая схема #
Producer
|
| basic.publish
v
Exchange
|
| routing
v
Queue
|
| basic.deliver
v
Consumer
|
+-- успех ------------------> basic.ack
|
+-- временная ошибка -------> basic.nack(requeue=True)
|
+-- постоянная ошибка ------> basic.nack(requeue=False)
|
+-- процесс упал -----------> автоматический requeue
Полный жизненный цикл сообщения #
Предположим, в очереди находится сообщение:
{
"payment_id": 451,
"operation": "process"
}
1. Consumer подписывается на очередь #
Например, через Python-библиотеку Pika:
channel.basic_qos(prefetch_count=10)
channel.basic_consume(
queue="payments",
on_message_callback=process_message,
auto_ack=False,
)
Здесь:
queue="payments"— очередь, из которой читаются сообщения;auto_ack=False— включены ручные подтверждения;prefetch_count=10— у consumer может одновременно находиться не более десяти неподтверждённых сообщений.
2. RabbitMQ выбирает consumer #
Если к одной очереди подключено несколько consumers, RabbitMQ распределяет сообщения между ними.
Queue: M1 M2 M3 M4 M5 M6
Consumer A <- M1, M3, M5
Consumer B <- M2, M4, M6
В простом случае распределение напоминает round-robin: очередное сообщение передаётся следующему consumer. Однако RabbitMQ учитывает ограничения prefetch, наличие неподтверждённых сообщений и состояние consumers.
Важно: сообщение передаётся только одному consumer данной очереди. Если нужно, чтобы несколько сервисов получили собственную копию события, каждому сервису нужна отдельная очередь:
+--> notification_queue --> Notification Consumer
payment.completed --+
+--> analytics_queue -----> Analytics Consumer
3. RabbitMQ доставляет сообщение #
RabbitMQ отправляет consumer’у:
body
properties
headers
routing_key
exchange
delivery_tag
redelivered
delivery_tag — идентификатор конкретной доставки сообщения.
Например:
delivery_tag = 42
redelivered = false
delivery_tag действует только в пределах конкретного AMQP-канала. Подтверждение должно быть отправлено через тот же канал, по которому было получено сообщение. Попытка подтвердить сообщение через другой канал приводит к ошибке unknown delivery tag и закрытию канала.
4. Сообщение переходит в состояние unacked #
При ручном подтверждении RabbitMQ не удаляет сообщение сразу после отправки consumer’у.
Условно в очереди существуют два состояния:
messages_ready
Сообщения ожидают доставки.
messages_unacknowledged
Сообщения уже доставлены consumer’ам,
но ещё не подтверждены.
Например:
Queue:
ready: 90
unacked: 10
Эти десять сообщений находятся у consumer’ов в обработке.
5. Consumer обрабатывает сообщение #
import json
import logging
logger = logging.getLogger(__name__)
def process_message(channel, method, properties, body: bytes) -> None:
try:
message = json.loads(body)
process_payment(message["payment_id"])
channel.basic_ack(
delivery_tag=method.delivery_tag,
)
except TemporaryError:
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=True,
)
except Exception:
logger.exception("Permanent message processing error")
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=False,
)
Положительное подтверждение: basic.ack
#
После успешного выполнения операции consumer отправляет:
channel.basic_ack(
delivery_tag=method.delivery_tag,
)
Это означает:
Consumer:
Сообщение успешно обработано.
RabbitMQ больше не отвечает за него.
RabbitMQ:
Удаляет сообщение из очереди.
Подтверждение нужно отправлять после фактического завершения необходимой операции:
save_to_database()
send_external_request()
update_status()
channel.basic_ack(delivery_tag)
Но порядок зависит от бизнес-логики. Обычно подтверждение отправляется после фиксации результата в базе данных или другом надёжном хранилище. После получения ack RabbitMQ имеет право окончательно удалить сообщение.
Отрицательное подтверждение: basic.nack
#
basic.nack означает, что consumer не смог нормально обработать сообщение.
Вернуть сообщение в очередь #
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=True,
)
RabbitMQ возвращает сообщение в очередь, после чего оно может быть отправлено:
тому же consumer;
другому consumer;
сразу же повторно, если очередь и
prefetchэто позволяют.
При повторной доставке RabbitMQ устанавливает:
redelivered = true
Этот флаг показывает, что сообщение уже доставлялось раньше, но не гарантирует, что предыдущий consumer действительно успел начать его обработку.
Не возвращать сообщение #
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=False,
)
Тогда сообщение:
есть Dead Letter Exchange
|
v
попадает в DLX / DLQ
нет Dead Letter Exchange
|
v
удаляется
Так обычно поступают с сообщениями, которые невозможно обработать:
повреждённый JSON;
отсутствуют обязательные поля;
неизвестная версия схемы;
операция принципиально недопустима;
превышено количество повторных попыток.
Поведение requeue=False официально определяется так: сообщение направляется в Dead Letter Exchange, если он настроен, иначе отбрасывается.
basic.reject и basic.nack
#
Оба метода позволяют отклонить сообщение:
channel.basic_reject(
delivery_tag=tag,
requeue=False,
)
channel.basic_nack(
delivery_tag=tag,
multiple=False,
requeue=False,
)
Основное различие:
basic.rejectработает с одним сообщением;basic.nackможет отклонить одно сообщение или сразу несколько черезmultiple=True.
basic.nack является расширением RabbitMQ для AMQP 0-9-1.
Пакетное подтверждение #
Можно подтвердить сразу несколько сообщений:
channel.basic_ack(
delivery_tag=42,
multiple=True,
)
Это подтвердит все неподтверждённые доставки на этом канале с тегами до 42 включительно.
Например:
Unacked delivery tags:
39
40
41
42
После:
basic_ack(delivery_tag=42, multiple=True)
будут подтверждены все четыре сообщения.
Такой подход уменьшает количество сетевых запросов, но требует осторожности при параллельной обработке: нельзя подтвердить сообщение, которое ещё не было успешно обработано.
Что происходит при падении consumer #
Предположим:
1. RabbitMQ доставил сообщение.
2. Consumer начал обработку.
3. Consumer записал результат в БД.
4. Consumer упал до отправки ack.
RabbitMQ видит, что канал или соединение закрылись, а сообщение осталось в состоянии unacked.
Тогда RabbitMQ автоматически возвращает сообщение в очередь:
unacked -> ready -> повторная доставка
Оно может быть передано другому consumer. Автоматический requeue выполняется при:
падении процесса consumer;
закрытии канала;
закрытии соединения;
потере TCP-соединения;
некоторых протокольных ошибках канала.
Почему сообщение может обработаться дважды #
Рассмотрим ситуацию:
Consumer PostgreSQL RabbitMQ
| | |
| INSERT payment_result | |
|-------------------------->| |
| COMMIT | |
|<--------------------------| |
| |
| basic.ack ------------------------------------------>|
X соединение оборвалось
Результат уже сохранён в PostgreSQL, но RabbitMQ мог не получить ack.
Брокер повторно доставит сообщение:
payment_id = 451
redelivered = true
Поэтому ручные подтверждения обеспечивают семантику at least once:
сообщение будет обработано один или более раз
Они не обеспечивают автоматически exactly once. Consumer должен быть идемпотентным. RabbitMQ рекомендует проектировать consumers так, чтобы повторная доставка не нарушала состояние системы.
Пример идемпотентности:
CREATE TABLE processed_messages (
message_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Consumer внутри транзакции проверяет message_id:
async with database.transaction():
inserted = await save_message_id_if_absent(message_id)
if not inserted:
# Сообщение уже обрабатывалось.
return
await perform_business_operation()
После успешной фиксации транзакции отправляется ack.
Автоматическое подтверждение #
При auto_ack=True RabbitMQ считает сообщение успешно доставленным сразу после его отправки consumer’у:
channel.basic_consume(
queue="payments",
on_message_callback=process_message,
auto_ack=True,
)
Схема:
RabbitMQ отправил сообщение
|
v
RabbitMQ сразу считает его обработанным
|
v
Consumer начинает работу
Если consumer упадёт во время обработки, сообщение уже не будет возвращено в очередь.
Доставка -> автоматическое удаление -> падение consumer
-> сообщение потеряно
Автоматический режим даёт высокую пропускную способность, но снижает надёжность и может перегрузить consumer большим количеством сообщений в памяти. Для важных бизнес-операций обычно используют auto_ack=False.
Как работает prefetch #
prefetch_count ограничивает количество сообщений, которые RabbitMQ может передать consumer’у без получения подтверждений.
channel.basic_qos(prefetch_count=10)
Означает:
Consumer получил 10 сообщений
|
v
RabbitMQ временно прекращает отправку этому consumer
|
v
Consumer отправляет ack
|
v
RabbitMQ отправляет следующее сообщение
Для RabbitMQ значение применяется отдельно к каждому новому consumer:
Consumer A: максимум 10 unacked
Consumer B: максимум 10 unacked
Consumer C: максимум 10 unacked
Значение 0 означает отсутствие ограничения.
prefetch_count=1
#
channel.basic_qos(prefetch_count=1)
RabbitMQ не отправит consumer’у новое сообщение, пока тот не подтвердит предыдущее.
Consumer A обрабатывает M1
Consumer B свободен
Следующее M2 -> Consumer B
Это полезно для тяжёлых задач и более равномерного распределения, но может снизить общую производительность.
Опасность бесконечного requeue #
Плохая схема:
except Exception:
channel.basic_nack(
delivery_tag=tag,
requeue=True,
)
Если ошибка постоянная, получится цикл:
получение
|
ошибка
|
requeue
|
повторное получение
|
ошибка
|
requeue
Это создаёт нагрузку на CPU и сеть. RabbitMQ отдельно предупреждает о возможности таких циклов повторной доставки.
Обычно применяют:
основная очередь
|
| ошибка
v
retry-очередь с TTL
|
| через некоторое время
v
основная очередь
|
| превышен retry limit
v
dead-letter queue
Количество попыток можно хранить в заголовке сообщения или определять через механизм dead lettering.
Тайм-аут подтверждения #
В RabbitMQ 4.3 timeout подтверждения поддерживается только quorum queues. Если consumer слишком долго не подтверждает сообщение, RabbitMQ закрывает его канал с ошибкой PRECONDITION_FAILED, а неподтверждённые сообщения этого канала возвращаются в очередь. Значение по умолчанию — 30 минут; проверка выполняется периодически.
Consumer acknowledgement и publisher confirm — не одно и то же #
Это два независимых механизма:
Producer -- publisher confirm --> RabbitMQ
Consumer -- consumer ack -------> RabbitMQ
Publisher confirm означает:
RabbitMQ принял ответственность за опубликованное сообщение.
Consumer ack означает:
Consumer принял и успешно обработал доставленное сообщение.
Подтверждение от consumer не отправляется напрямую producer’у. RabbitMQ отдельно ведёт состояние публикации и состояние доставки.
Итоговая схема надёжной обработки #
1. Consumer подписывается с auto_ack=False.
2. RabbitMQ доставляет сообщение и присваивает delivery_tag.
3. Сообщение переходит из ready в unacked.
4. Consumer валидирует данные.
5. Consumer выполняет идемпотентную бизнес-операцию.
6. После успешного commit отправляется basic.ack.
7. При временной ошибке применяется контролируемый retry.
8. При постоянной ошибке сообщение направляется в DLQ.
9. Если consumer падает до ack, RabbitMQ повторно доставляет сообщение.
Главный принцип:
ack отправляется не после получения сообщения,
а после успешного завершения всей необходимой обработки.
13. Как версионируют сообщения и поддерживают несколько версий схемы в RabbitMQ? #
Кто отвечает за версионирование #
RabbitMQ сам не версионирует и не проверяет схемы сообщений. Для брокера тело сообщения и большая часть метаданных непрозрачны: RabbitMQ маршрутизирует байты, но не проверяет, соответствует ли JSON, Protobuf или Avro определённой версии схемы. Свойства type, content_type и пользовательские headers устанавливаются producer’ом и интерпретируются приложениями.
Поэтому версионирование реализуется на уровне контракта между producer и consumer:
Producer
|
| payment.completed, schema_version=2
v
RabbitMQ
|
v
Consumer
|
+--> обработчик v1
+--> обработчик v2
+--> неизвестная версия -> DLQ
Формат сообщения с версией #
Обычно используют общий envelope:
{
"message_id": "01J4C9M7DP1BCQ6N5G6EXAMPLE",
"event_type": "payment.completed",
"event_version": 2,
"occurred_at": "2026-07-27T18:40:00Z",
"producer": "payment-service",
"data": {
"payment_id": 451,
"amount": "35.00",
"currency": "AZN",
"provider": "stripe"
}
}
В свойствах AMQP:
type: payment.completed
content_type: application/json
headers:
schema-version: 2
trace-id: trace-721
Лучше не полагаться только на RabbitMQ header. Версию полезно хранить внутри envelope, потому что тогда она остаётся частью самого сообщения при:
сохранении сообщения в БД;
повторной публикации;
записи в логи;
тестировании без RabbitMQ;
переносе в другой брокер;
ручном анализе сообщений из DLQ.
Header можно продублировать для фильтрации, мониторинга или маршрутизации.
Версия события и версия сериализации #
Это разные понятия.
{
"event_type": "payment.completed",
"event_version": 2
}
event_version описывает бизнес-контракт события.
content_type: application/json
content_encoding: gzip
content_type и content_encoding описывают способ представления payload. RabbitMQ рекомендует использовать эти свойства, чтобы consumer понимал, как декодировать сообщение, но сам брокер их не валидирует.
Например:
event_type: payment.completed
event_version: 2
content_type: application/x-protobuf
Версия Protobuf-файла и версия бизнес-события также могут не совпадать.
Основная стратегия: обратно совместимые изменения #
Лучший вариант — не создавать новую версию при каждом добавлении поля.
Допустим, первая версия была такой:
{
"payment_id": 451,
"amount": "35.00"
}
Новая версия producer’а добавила поле:
{
"payment_id": 451,
"amount": "35.00",
"currency": "AZN"
}
Изменение можно считать обратно совместимым, если старый consumer:
игнорирует неизвестные поля;
не требует строгого совпадения набора полей;
продолжает правильно понимать старые поля.
В этом случае отдельный event_version=2 может даже не понадобиться, если семантика события не изменилась.
Для JSON consumer можно сделать допускающим дополнительные поля:
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class PaymentCompleted(BaseModel):
model_config = ConfigDict(extra="ignore")
payment_id: int
amount: Decimal
currency: str = "AZN"
Такой consumer прочитает и старое сообщение без currency, и новое сообщение с дополнительными полями.
Какие изменения обычно безопасны #
Как правило, совместимыми можно сделать:
добавление необязательного поля;
добавление поля со значением по умолчанию;
добавление нового значения enum, если consumers умеют обрабатывать неизвестные значения;
добавление новой необязательной вложенной структуры;
расширение метаданных сообщения.
Пример:
{
"event_type": "payment.completed",
"event_version": 1,
"data": {
"payment_id": 451,
"amount": "35.00",
"currency": "AZN",
"metadata": {
"provider": "stripe"
}
}
}
При этом consumer не должен падать только потому, что появился metadata.
Какие изменения являются ломающими #
Ломающими обычно являются:
удаление обязательного поля;
переименование поля;
изменение типа поля;
изменение единиц измерения;
изменение смысла существующего поля;
перемещение поля в другую структуру;
изменение формата даты;
изменение значения enum с сохранением старого имени;
изменение события с факта на команду.
Например, это опасное изменение:
{
"amount": 3500
}
Раньше:
amount = сумма в манатах
Теперь:
amount = сумма в гяпиках
JSON-тип не изменился, но семантика стала другой. Такое изменение должно получить новую версию или новое название поля:
{
"amount_minor": 3500,
"currency": "AZN"
}
Когда повышать версию #
Новую major-версию стоит создавать, когда старый consumer не может правильно обработать новое сообщение.
Например:
Версия 1 #
{
"event_type": "payment.completed",
"event_version": 1,
"data": {
"payment_id": 451,
"amount": "35.00"
}
}
Версия 2 #
{
"event_type": "payment.completed",
"event_version": 2,
"data": {
"payment": {
"id": 451,
"amount_minor": 3500,
"currency": "AZN"
},
"provider": {
"name": "stripe",
"transaction_id": "txn-281"
}
}
}
Consumer должен явно выбрать подходящий обработчик:
from collections.abc import Callable
from typing import Any
class UnsupportedEventVersion(Exception):
pass
def handle_payment_completed_v1(data: dict[str, Any]) -> None:
...
def handle_payment_completed_v2(data: dict[str, Any]) -> None:
...
HANDLERS: dict[int, Callable[[dict[str, Any]], None]] = {
1: handle_payment_completed_v1,
2: handle_payment_completed_v2,
}
def consume(message: dict[str, Any]) -> None:
version = message["event_version"]
handler = HANDLERS.get(version)
if handler is None:
raise UnsupportedEventVersion(
f"Unsupported payment.completed version: {version}"
)
handler(message["data"])
Upcasting: приведение старых версий к текущей #
Часто бизнес-логика работает только с одной внутренней моделью. Старые сообщения сначала преобразуются в текущий формат.
Сообщение v1
|
v
upcast v1 -> v2
|
v
Текущая модель v2
|
v
Общий обработчик
Пример:
from decimal import Decimal
from typing import Any
CURRENT_VERSION = 2
def upcast_v1_to_v2(message: dict[str, Any]) -> dict[str, Any]:
old_data = message["data"]
return {
**message,
"event_version": 2,
"data": {
"payment": {
"id": old_data["payment_id"],
"amount": Decimal(old_data["amount"]),
"currency": old_data.get("currency", "AZN"),
},
"provider": None,
},
}
def normalize_message(message: dict[str, Any]) -> dict[str, Any]:
version = message["event_version"]
if version == 1:
return upcast_v1_to_v2(message)
if version == CURRENT_VERSION:
return message
raise UnsupportedEventVersion(
f"Unsupported version: {version}"
)
После нормализации бизнес-обработчик не содержит ветвлений:
def process_payment_completed(message: dict[str, Any]) -> None:
normalized = normalize_message(message)
payment = normalized["data"]["payment"]
update_payment_status(
payment_id=payment["id"],
amount=payment["amount"],
currency=payment["currency"],
)
Преимущество upcasting — старая совместимость сосредоточена в одном месте, а не распространяется по всей бизнес-логике.
Где указывать версию #
Есть несколько вариантов.
Версия внутри payload #
{
"event_type": "order.created",
"event_version": 2,
"data": {}
}
Это наиболее универсальный вариант.
Версия в AMQP header #
headers:
event-version: 2
Удобно для технической обработки, но лучше дублировать её в payload.
Версия в свойстве type
#
type: order.created.v2
RabbitMQ разрешает приложениям использовать произвольные значения type, но брокер сам их не проверяет.
Недостаток: логическое имя события и версия смешиваются:
order.created.v1
order.created.v2
order.created.v3
Обычно чище хранить:
type: order.created
header event-version: 2
Версия в routing key #
order.created.v1
order.created.v2
Это имеет смысл, когда разные версии должны маршрутизироваться в разные очереди:
orders exchange
|
+-- order.created.v1 --> legacy_order_queue
|
+-- order.created.v2 --> current_order_queue
Но при такой схеме новая версия может случайно не попасть в существующие очереди, если bindings не были обновлены.
Один routing key или разные #
Один routing key #
routing_key: payment.completed
Версия находится в сообщении:
{
"event_version": 2
}
Преимущества:
стабильная топология RabbitMQ;
проще добавлять совместимые версии;
consumer сам выбирает обработчик;
меньше exchanges, queues и bindings.
Это хороший вариант по умолчанию.
Версия в routing key #
payment.completed.v1
payment.completed.v2
Преимущества:
версии можно направлять разным consumers;
проще полностью изолировать старую реализацию;
consumer не получает неподдерживаемую версию.
Недостатки:
усложняются bindings;
каждую новую версию нужно подключать явно;
возможно дублирование очередей;
сложнее миграции нескольких consumers.
Такой подход полезен при действительно несовместимых поколениях системы.
Разные очереди для разных версий #
Иногда используют отдельные очереди:
payment.completed.v1.queue
payment.completed.v2.queue
Это оправдано, когда:
старая версия будет поддерживаться долго;
разные версии обрабатываются разными приложениями;
требуется независимое масштабирование;
миграция выполняется постепенно;
у версий разные SLA или политики retry.
Для небольшого изменения схемы создание новой очереди обычно избыточно. Очередь решает задачу доставки и изоляции, но не заменяет нормальную совместимость контрактов.
Dual publishing #
При крупной миграции producer временно публикует две версии:
Producer
|
+--> payment.completed.v1
|
+--> payment.completed.v2
Порядок миграции:
1. Развернуть consumers, понимающие v2.
2. Начать публиковать v1 и v2 одновременно.
3. Перевести всех consumers на v2.
4. Проверить, что старые сообщения и очереди обработаны.
5. Прекратить публикацию v1.
6. Через установленный срок удалить поддержку v1.
У dual publishing есть важный риск: две публикации могут разойтись.
v1 успешно опубликована
v2 не опубликована
Поэтому для надёжной публикации обычно применяют transactional outbox и publisher confirms, а consumers делают идемпотентными.
Также нельзя позволять одному consumer обработать обе версии одного и того же бизнес-события как две независимые операции. Обе версии должны иметь одинаковый логический идентификатор:
{
"message_id": "payment-451-completed",
"event_version": 2
}
Новая версия или новое событие #
Версию повышают, когда сохраняется тот же бизнес-факт:
payment.completed v1
payment.completed v2
Новое событие создают, когда изменился сам смысл:
payment.completed
payment.settled
payment.refunded
payment.chargeback.opened
Не стоит превращать одно событие в универсальный контейнер:
{
"event_type": "payment.changed",
"status": "something"
}
Разные бизнес-факты лучше выражать разными названиями событий.
JSON, Protobuf и Avro #
JSON #
Для JSON обычно используют:
JSON Schema;
версию в envelope;
проверку схемы producer’ом и consumer’ом;
хранение схем в отдельном репозитории или registry;
правила обратной совместимости в CI.
RabbitMQ не выполняет JSON Schema validation автоматически.
Пример структуры репозитория:
schemas/
└── payment/
└── completed/
├── v1.json
└── v2.json
Protobuf #
Protobuf предоставляет собственные правила эволюции бинарной схемы. Например, нельзя изменять номер существующего поля: для wire format это эквивалентно удалению старого поля и добавлению нового. Совместимые изменения должны следовать правилам обновления message type.
message PaymentCompleted {
int64 payment_id = 1;
string amount = 2;
string currency = 3;
}
Удалённый номер нельзя использовать повторно:
message PaymentCompleted {
reserved 2;
reserved "amount";
int64 payment_id = 1;
int64 amount_minor = 4;
string currency = 3;
}
При этом полезно всё равно иметь бизнес-версию события:
message EventEnvelope {
string event_id = 1;
string event_type = 2;
uint32 event_version = 3;
bytes payload = 4;
}
Совместимость wire format не гарантирует совместимость бизнес-смысла.
Avro #
Avro поддерживает разделение writer schema и reader schema. Consumer читает сообщение, зная схему producer’а, и сопоставляет её со своей схемой по правилам schema resolution. Это позволяет, например, добавлять поля со значениями по умолчанию.
Для RabbitMQ при использовании Avro в сообщении обычно передают идентификатор схемы:
headers:
schema-id: 842
Consumer получает соответствующую writer schema из schema registry и декодирует payload своей reader schema.
Что делать с неизвестной версией #
Consumer не должен:
получить неизвестную версию
|
v
nack(requeue=True)
|
v
снова получить то же сообщение
|
v
бесконечный цикл
Неизвестная версия обычно является постоянной, а не временной ошибкой.
Корректнее:
записать ошибку с
message_id,event_typeи версией;отправить
basic.nack(requeue=False);направить сообщение в DLQ;
поднять метрику или alert;
после обновления consumer повторно опубликовать сообщение.
При basic.reject или basic.nack с requeue=false RabbitMQ направляет сообщение в Dead Letter Exchange, если он настроен.
try:
process_message(message)
except UnsupportedEventVersion:
logger.exception(
"Unsupported message version",
extra={
"message_id": message.get("message_id"),
"event_type": message.get("event_type"),
"event_version": message.get("event_version"),
},
)
channel.basic_nack(
delivery_tag=method.delivery_tag,
requeue=False,
)
Безопасный порядок развёртывания #
При переходе с v1 на v2 порядок должен быть таким:
1. Consumer начинает понимать v1 и v2.
2. Развёртываются все обновлённые consumers.
3. Producer начинает отправлять v2.
4. Контролируются DLQ, ошибки и количество сообщений v1/v2.
5. Producer прекращает отправлять v1.
6. Ожидается обработка старых сообщений из очередей.
7. Удаляется поддержка v1.
Опасный порядок:
1. Producer начал отправлять v2.
2. Старые consumers ещё понимают только v1.
3. Сообщения уходят в ошибки или DLQ.
Consumer желательно обновлять раньше producer’а.
Практическая схема #
Для большинства систем достаточно следующего подхода:
{
"message_id": "01J4C9M7DP1BCQ6N5G6EXAMPLE",
"event_type": "payment.completed",
"event_version": 2,
"occurred_at": "2026-07-27T18:40:00Z",
"producer": "payment-service",
"data": {}
}
Правила:
1. event_type остаётся стабильным.
2. event_version повышается только при ломающем изменении.
3. Новые необязательные поля добавляются без новой major-версии.
4. Consumer некоторое время поддерживает текущую и предыдущую версии.
5. Старые версии преобразуются через upcaster.
6. Неизвестные версии отправляются в DLQ.
7. Схемы хранятся централизованно и проверяются в CI.
8. Producer обновляется после consumers.
9. Изменение бизнес-смысла оформляется новым event_type.
10. Старые версии удаляются только после гарантированного истечения backlog и retention.
Главное правило: версия нужна не потому, что изменилась структура JSON, а потому, что старый consumer больше не может корректно понять новое сообщение.
14. RabbitMQ vs Kafka #
Главное различие #
RabbitMQ — брокер сообщений, ориентированный на доставку конкретного сообщения конкретному обработчику.
Kafka — распределённый журнал событий, ориентированный на долговременное хранение потока событий, повторное чтение и обработку больших объёмов данных.
RabbitMQ:
Producer -> Exchange -> Queue -> Consumer -> ACK -> сообщение удалено
Kafka:
Producer -> Topic -> Partitioned Log
|
+-> Consumer Group A
+-> Consumer Group B
+-> повторное чтение с нужного offset
Сравнение #
| Характеристика | RabbitMQ | Kafka |
|---|---|---|
| Основная модель | Очередь сообщений | Распределённый журнал событий |
| Получение | Broker отправляет сообщения consumer’у | Consumer сам запрашивает записи |
| После обработки | Обычно удаляется после ack | Хранится до истечения retention |
| Повторное чтение | Для обычной очереди не предусмотрено | Штатная операция через offsets |
| Маршрутизация | Exchanges, routing keys, bindings, headers | Topic, partition, record key |
| Масштабирование consumers | Конкурирующие consumers одной очереди | Consumer groups и partitions |
| Порядок | Очередь FIFO, но consumers и redelivery могут его нарушить | Гарантирован внутри partition |
| Подтверждение | ack, nack, reject, requeue для сообщения | Commit offset для partition |
| Типичные задачи | Фоновые задачи, команды, RPC, уведомления | Event streaming, CDC, аналитика, журнал событий |
| Приоритеты сообщений | Поддерживаются priority queues | Нативного приоритета записей нет |
| Replay | Только через дополнительную архитектуру или RabbitMQ Streams | Встроен в основную модель |
Обычные RabbitMQ queues используют деструктивное потребление: после успешного подтверждения сообщение удаляется. Kafka сохраняет записи независимо от того, были ли они прочитаны, пока их не удалит политика retention или compaction.
RabbitMQ: сообщение должно быть выполнено #
Типичная задача:
{
"task_id": "task-125",
"type": "generate_report",
"user_id": 42
}
Схема обработки:
Producer
|
v
Exchange
|
v
report.generate.queue
|
+--> Worker 1
+--> Worker 2
+--> Worker 3
RabbitMQ выбирает одного свободного consumer’а. После успешной обработки consumer отправляет:
basic.ack
При временной ошибке:
basic.nack(requeue=true)
При постоянной ошибке:
basic.nack(requeue=false)
|
v
Dead Letter Exchange
RabbitMQ хранит состояние отдельного сообщения: готово ли оно к доставке или уже доставлено, но ещё не подтверждено. Manual acknowledgement и prefetch позволяют контролировать количество одновременно обрабатываемых сообщений.
RabbitMQ особенно удобен для:
Celery tasks;
отправки email, SMS и push;
обработки файлов;
генерации отчётов;
обработки платежной команды;
retry и DLQ;
RPC request/reply;
сложной маршрутизации по routing key;
задач с разными приоритетами.
Kafka: событие должно быть сохранено #
Типичное событие:
{
"event_id": "evt-125",
"event_type": "payment.completed",
"payment_id": 451,
"user_id": 42,
"amount": "35.00",
"currency": "AZN"
}
Событие записывается в topic:
payments
├── partition 0: [0][1][2][3][4]
├── partition 1: [0][1][2][3]
└── partition 2: [0][1][2][3][4][5]
Каждая запись имеет offset:
partition: 1
offset: 1527
Consumer читает записи и сохраняет позицию:
committed offset = 1528
Kafka не удаляет запись после commit offset. Commit означает только:
Consumer group обработала записи до этой позиции.
Другой consumer group может независимо прочитать те же события:
payment.completed
|
+--> analytics-group
+--> notifications-group
+--> fraud-detection-group
+--> accounting-group
Kafka хранит потоки событий надёжно и позволяет обрабатывать их как в реальном времени, так и повторно. Consumer сам запрашивает данные у broker, что позволяет эффективно использовать batching и регулировать скорость чтения.
Kafka подходит для:
CDC из PostgreSQL;
аудита изменений;
аналитики;
сбора логов;
потоковой обработки;
Event Sourcing;
построения data pipelines;
передачи событий между большим количеством систем;
восстановления состояния из истории событий;
обработки больших непрерывных потоков данных.
Маршрутизация #
RabbitMQ #
Producer публикует сообщение в exchange:
Exchange: domain.events
Routing key: payment.completed
Bindings определяют очереди:
payment.* -> accounting.queue
payment.completed -> notifications.queue
*.failed -> failures.queue
RabbitMQ предоставляет разные типы exchanges:
direct
topic
fanout
headers
Это позволяет гибко направлять сообщения в разные очереди.
Kafka #
В Kafka нет аналога RabbitMQ exchange. Основные средства распределения:
topic
partition
record key
consumer group
Например:
topic: payment-events
key: payment_id=451
Одинаковый key обычно направляет связанные события в одну partition:
payment 451 created
payment 451 authorized
payment 451 completed
Это позволяет сохранить их порядок внутри partition. Kafka гарантирует порядок событий в рамках конкретной topic-partition, но не глобально между всеми partitions.
Масштабирование consumers #
RabbitMQ #
К одной очереди можно подключить несколько competing consumers:
Queue
|
+--> Consumer A
+--> Consumer B
+--> Consumer C
Каждое сообщение получает только один consumer.
Увеличение количества workers обычно увеличивает параллельность обработки, пока ограничением не станут сама очередь, broker или внешние ресурсы.
Kafka #
Consumers объединяются в consumer group:
Topic: 4 partitions
Consumer group: payments
Partition 0 -> Consumer A
Partition 1 -> Consumer A
Partition 2 -> Consumer B
Partition 3 -> Consumer B
Внутри одной группы каждая partition в конкретный момент назначена одному consumer.
Если partitions четыре, а consumers восемь:
Consumer A -> partition 0
Consumer B -> partition 1
Consumer C -> partition 2
Consumer D -> partition 3
Consumer E -> простаивает
Consumer F -> простаивает
Consumer G -> простаивает
Consumer H -> простаивает
Следовательно, максимальная параллельность consumer group ограничена количеством partitions. При изменении состава группы Kafka перераспределяет partitions между consumers — выполняет rebalance.
Подтверждение обработки #
RabbitMQ #
Подтверждается конкретная доставка:
channel.basic_ack(delivery_tag=delivery_tag)
RabbitMQ знает, какие отдельные сообщения находятся в состоянии unacked.
Если consumer упал до ack, сообщение возвращается в очередь и может быть доставлено повторно.
Kafka #
Consumer подтверждает позицию в partition:
partition 2: committed offset = 120
Это означает, что записи до данной позиции считаются обработанными consumer group.
Kafka не ведёт для consumer group отдельный ack на каждую запись в модели RabbitMQ. Состояние обработки компактно представляется offset для каждой partition.
Повторное чтение #
В Kafka можно изменить offset:
Текущий offset: 10000
Новый offset: 5000
После этого consumer повторно прочитает события:
5000, 5001, 5002, ...
Это полезно, когда:
исправили ошибку consumer;
нужно пересчитать аналитику;
появился новый сервис;
нужно восстановить состояние;
изменилась бизнес-логика обработки.
В обычной RabbitMQ queue подтверждённое сообщение удаляется, поэтому повторное чтение невозможно без:
повторной публикации;
отдельного хранилища событий;
архива;
DLQ;
использования RabbitMQ Streams.
Порядок сообщений #
RabbitMQ #
Очередь логически FIFO:
M1 -> M2 -> M3
Но наблюдаемый порядок может измениться из-за:
нескольких consumers;
разных скоростей обработки;
nackи requeue;повторной доставки;
приоритетов;
пакетной обработки.
Например:
Consumer A получил M1 и обрабатывает 10 секунд
Consumer B получил M2 и обработал за 1 секунду
Результат:
M2 завершено раньше M1
Официальная документация RabbitMQ отдельно отмечает, что приоритеты, повторная постановка в очередь и несколько consumers влияют на наблюдаемый порядок.
Kafka #
Порядок гарантирован внутри partition:
Partition 0:
offset 10 -> order.created
offset 11 -> order.paid
offset 12 -> order.shipped
Между разными partitions общего порядка нет:
Partition 0: A1 A2 A3
Partition 1: B1 B2 B3
Нельзя однозначно утверждать, что A2 произошло раньше B2, только исходя из offsets.
Гарантии доставки #
Обе системы обычно используются с семантикой at least once:
Сообщение или событие может обработаться повторно.
RabbitMQ #
Повтор возникает, например, когда:
1. Consumer изменил базу данных.
2. Consumer упал до отправки ack.
3. RabbitMQ повторно доставил сообщение.
Kafka #
Повтор возникает, когда:
1. Consumer обработал записи.
2. Consumer упал до commit offset.
3. После перезапуска чтение начинается со старого offset.
Kafka поддерживает транзакции и exactly-once processing при чтении, обработке и записи обратно в Kafka, в частности через Kafka Streams. При записи во внешнюю PostgreSQL, HTTP API или другую систему всё равно требуется координация, идемпотентность или дедупликация.
Производительность #
Некорректно утверждать, что Kafka всегда быстрее RabbitMQ. Они оптимизированы под разные модели.
Kafka проектировалась вокруг:
append-only log
partitions
sequential disk I/O
batching
compression
pull consumption
Это хорошо подходит для очень больших постоянных потоков событий.
RabbitMQ проектировался вокруг:
queues
per-message delivery
ack/nack
routing
consumer prefetch
TTL
priorities
dead lettering
Это хорошо подходит для оперативной доставки команд и фоновых задач.
Для RabbitMQ максимальный throughput также можно получать через RabbitMQ Streams и Super Streams, которые используют недеструктивное чтение, retention и partitioned streams. RabbitMQ рекомендует Streams для нагрузок, где обычные очереди достигают пределов пропускной способности.
RabbitMQ Streams частично сближает системы #
Современный RabbitMQ поддерживает Streams:
RabbitMQ Stream:
Producer -> append-only stream -> Consumer
Особенности:
сообщения не удаляются после чтения;
consumer может читать их повторно;
используются offsets;
данные постоянно хранятся и реплицируются;
Super Streams позволяют разбить поток на partitions;
доступен отдельный высокопроизводительный Stream Protocol.
Таким образом, сравнивать Kafka только с обычными RabbitMQ queues не всегда корректно. RabbitMQ Streams закрывает часть streaming-сценариев, но Kafka остаётся более цельной экосистемой для распределённых event pipelines и stream processing.
Что выбрать #
RabbitMQ лучше, когда #
Сообщение — это работа, которую должен выполнить один обработчик.
Примеры:
Celery;
отправка email;
генерация PDF;
обработка изображения;
выполнение платежной команды;
retry с задержкой;
DLQ;
RPC;
разные приоритеты задач;
сложная маршрутизация;
короткоживущие очереди.
Kafka лучше, когда #
Сообщение — это событие, историю которого нужно сохранить.
Примеры:
user.registered;payment.completed;CDC из PostgreSQL;
аудит;
аналитика;
поток логов;
fraud detection;
построение витрин данных;
повторный пересчёт событий;
десятки независимых consumer groups;
Kafka Streams и stream processing.
Использование вместе #
Часто системы не заменяют друг друга:
PostgreSQL
|
| CDC
v
Kafka
|
+--> Analytics
+--> Audit
+--> Fraud Detection
|
v
Notification Service
|
| конкретная фоновая задача
v
RabbitMQ
|
+--> Email Worker
+--> Push Worker
Итог #
RabbitMQ:
«Доставь эту задачу подходящему обработчику
и удали её после успешного выполнения».
Kafka:
«Сохрани это событие в журнале
и разреши разным системам читать его независимо».
Для фоновых задач, Celery, команд, retry и гибкой маршрутизации обычно выбирают RabbitMQ. Для потоков событий, CDC, аналитики, replay и длительного хранения истории — Kafka.
15. Какой формат сообщений используется в Apache Kafka? #
Единого формата данных в Kafka нет #
Apache Kafka не требует использовать JSON, Avro, Protobuf или другой конкретный формат. Для Kafka ключ и значение сообщения — это непрозрачные массивы байтов:
key: byte[]
value: byte[]
Producer сериализует объект в байты, Kafka сохраняет и передаёт эти байты без анализа содержимого, а consumer десериализует их обратно. Выбор формата является частью контракта между producer и consumer.
Структура Kafka record #
На уровне приложения сообщение обычно представляется так:
Kafka record
├── topic
├── partition
├── offset
├── timestamp
├── key
├── value
└── headers
Пример:
topic: payment-events
partition: 3
offset: 1527
timestamp: 1785177600000
key:
"payment-451"
value:
{"payment_id":451,"status":"completed"}
headers:
event-type = payment.completed
schema-version = 2
trace-id = trace-721
В хранимом формате непосредственно record содержит timestampDelta, offsetDelta, key, value и headers. Несколько records объединяются в RecordBatch, где находятся базовый offset, временные метки, CRC, параметры транзакции и сжатия.
key
#
Ключ необязателен и может быть null.
{
"key": "payment-451"
}
Ключ обычно используется для:
выбора partition;
сохранения порядка событий одной сущности;
log compaction;
группировки связанных событий.
Например, все события платежа получают одинаковый ключ:
key = payment-451
payment.created
payment.authorized
payment.completed
payment.refunded
При стандартном partitioner одинаковый сериализованный ключ направляет записи в одну partition, пока количество partitions и логика partitioner не меняются. Внутри partition Kafka сохраняет порядок записей. Конкретный тип ключа также определяется serializer’ом producer’а.
Ключ может быть строкой:
"payment-451"
числом:
451
UUID:
0190f81a-8972-7d30-a021-72d774148f41
или сериализованной структурой.
value
#
value содержит полезную нагрузку сообщения:
{
"event_id": "evt-125",
"event_type": "payment.completed",
"event_version": 2,
"occurred_at": "2026-07-27T18:40:00Z",
"data": {
"payment_id": 451,
"amount": "35.00",
"currency": "AZN"
}
}
Kafka не знает, что внутри находится JSON. До отправки это значение превращается в byte[], например:
7b 22 65 76 65 6e 74 5f 69 64 22 3a ...
Consumer должен использовать совместимый deserializer.
headers
#
Headers — это список пар:
String -> byte[]
Например:
event-type: payment.completed
schema-version: 2
content-type: application/json
correlation-id: operation-821
trace-id: trace-721
Ключ header не может быть null, значение может быть null. Порядок headers сохраняется, причём разрешено несколько headers с одинаковым ключом.
Headers обычно используют для технических метаданных:
версия схемы;
тип события;
distributed tracing;
correlation ID;
идентификатор producer’а;
content type;
tenant ID.
Основные бизнес-данные лучше хранить в value, поскольку headers не должны превращаться во второе тело сообщения.
timestamp
#
Каждый record имеет временную метку. Она может означать:
CreateTime
Время создания сообщения producer’ом.
Или:
LogAppendTime
Время записи сообщения broker’ом.
Внутри RecordBatch хранится базовая временная метка, а отдельные records содержат смещение относительно неё.
Бизнес-время события всё равно часто сохраняют внутри payload:
{
"occurred_at": "2026-07-27T18:40:00Z"
}
Поскольку Kafka timestamp и бизнес-время могут означать разные моменты:
occurred_at — когда произошло событие;
record timestamp — когда producer создал или broker записал record.
Наиболее распространённые форматы #
JSON #
{
"event_type": "user.registered",
"event_version": 1,
"data": {
"user_id": 125,
"email": "user@example.com"
}
}
Преимущества:
легко читать;
просто отлаживать;
поддерживается почти всеми языками;
удобно просматривать через CLI.
Недостатки:
относительно большой размер;
типы данных ограничены JSON;
схема не встроена в сообщение;
без дополнительной проверки producer и consumer могут по-разному понимать структуру.
Пример сериализации:
import json
value = json.dumps(
{
"event_type": "user.registered",
"event_version": 1,
"data": {"user_id": 125},
}
).encode("utf-8")
Avro #
Avro использует бинарное представление и отдельную схему:
{
"type": "record",
"name": "PaymentCompleted",
"fields": [
{"name": "payment_id", "type": "long"},
{"name": "amount", "type": "string"},
{"name": "currency", "type": "string"}
]
}
Обычно вместе с Avro применяется Schema Registry. В Kafka record сохраняется бинарный payload и идентификатор схемы, по которому consumer находит нужную схему.
Schema Registry не является обязательной встроенной частью Kafka broker: это отдельный компонент архитектуры.
Protocol Buffers #
Пример схемы:
message PaymentCompleted {
int64 payment_id = 1;
string amount = 2;
string currency = 3;
}
Преимущества:
компактный бинарный формат;
строгие типы;
генерация классов;
поддержка эволюции схемы.
В Kafka хранится результат сериализации Protobuf-сообщения в byte[].
String #
Для простых событий можно отправлять обычную строку:
payment.completed
Или CSV:
451,35.00,AZN,completed
Это просто, но неудобно для сложных контрактов и развития схемы.
Raw bytes #
Можно отправлять произвольные байты:
PDF
изображение
архив
зашифрованный payload
собственный бинарный протокол
Технически это возможно, но крупные файлы обычно хранят в S3 или MinIO, а через Kafka передают идентификатор объекта:
{
"file_id": "file-125",
"bucket": "documents",
"object_key": "reports/2026/report-125.pdf"
}
Serializer и deserializer #
Producer использует serializer:
Python/Java object
|
v
Serializer
|
v
byte[]
|
v
Kafka
Consumer выполняет обратное преобразование:
Kafka
|
v
byte[]
|
v
Deserializer
|
v
Python/Java object
В Kafka-клиентах key и value настраиваются независимо:
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=org.apache.kafka.common.serialization.StringSerializer
Consumer:
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
Kafka предоставляет стандартные serializers/deserializers для строк, байтов, чисел, UUID и некоторых других базовых типов. Для пользовательских объектов применяется собственный serializer либо библиотека JSON, Avro или Protobuf.
Пример корректного события #
{
"event_id": "0190f81a-8972-7d30-a021-72d774148f41",
"event_type": "payment.completed",
"event_version": 2,
"occurred_at": "2026-07-27T18:40:00Z",
"producer": "payment-service",
"data": {
"payment_id": 451,
"user_id": 125,
"amount": "35.00",
"currency": "AZN"
}
}
Kafka record:
topic: payment-events
key: payment-451
value: JSON / Avro / Protobuf bytes
headers:
content-type = application/json
schema-version = 2
trace-id = trace-721
Record и RecordBatch #
Producer обычно не отправляет каждую запись отдельным сетевым запросом. Kafka-клиент накапливает records и объединяет их в batches:
RecordBatch
├── Record 1
├── Record 2
├── Record 3
└── Record 4
Сжатие применяется к batch, а не независимо к каждому отдельному record. Актуальный формат поддерживает:
none
gzip
snappy
lz4
zstd
Batch также содержит CRC, количество records, producer ID, sequence number и признаки транзакционного или управляющего batch.
Какой формат выбирать #
Для небольших внутренних сервисов:
JSON + версия схемы
Для строгих контрактов и большого количества сервисов:
Protobuf или Avro + Schema Registry
Для аналитических потоков и интеграции с data platform часто используют:
Avro
Для сервисов с уже развитой Protobuf/gRPC-инфраструктурой:
Protobuf
Главное правило:
Kafka определяет контейнер record и бинарный формат хранения,
но формат содержимого key и value определяет приложение.
16. Какие основные роли и сущности существуют в RabbitMQ? #
Общая схема RabbitMQ #
Publisher
|
| publish(message, exchange, routing_key)
v
Exchange
|
| Binding
v
Queue
|
| delivery
v
Consumer
|
| ack / nack
v
RabbitMQ
RabbitMQ принимает сообщения от publishers, маршрутизирует их через exchanges, помещает в очереди и доставляет consumers. В модели AMQP 0-9-1 producer обычно не отправляет сообщение напрямую в очередь — он публикует его в exchange.
Основные роли #
Publisher — отправитель сообщений #
Publisher, или producer, — приложение, которое создаёт и публикует сообщения.
Примеры:
API после создания заказа;
Payment Service после завершения платежа;
Celery-клиент, создающий фоновую задачу;
сервис, отправляющий доменное событие.
Publisher:
exchange = domain.events
routing_key = payment.completed
body = {...}
Publisher обычно поддерживает долговременное соединение с RabbitMQ, а не открывает новое соединение для каждого сообщения. Для надёжной публикации может использовать publisher confirms, чтобы получить подтверждение принятия сообщения брокером.
Consumer — получатель и обработчик #
Consumer — приложение или зарегистрированная подписка, которая получает сообщения из очереди.
payments.queue
|
+--> Consumer 1
+--> Consumer 2
+--> Consumer 3
Consumers читают сообщения именно из очередей. После обработки consumer обычно отправляет:
basic.ack
При ошибке:
basic.nack
basic.reject
Один и тот же процесс может одновременно выступать publisher и consumer. В RabbitMQ термин consumer также обозначает зарегистрированную подписку на доставку сообщений, а не только весь процесс приложения.
Broker #
Broker — сервер RabbitMQ, который:
принимает подключения;
аутентифицирует клиентов;
принимает сообщения;
выполняет маршрутизацию;
хранит сообщения в очередях;
доставляет сообщения consumers;
отслеживает подтверждения;
управляет topology: exchanges, queues и bindings.
Publisher -> RabbitMQ Broker -> Consumer
Брокер может состоять из одного RabbitMQ node или нескольких nodes, объединённых в cluster.
Administrator / Operator #
Это человек или автоматизированная система, которая управляет RabbitMQ:
создаёт пользователей и virtual hosts;
назначает permissions;
задаёт policies;
настраивает limits;
следит за состоянием очередей и соединений;
управляет кластером;
анализирует накопившиеся и неподтверждённые сообщения.
Это не роль AMQP-доставки сообщений, но важная эксплуатационная роль.
Основные сущности #
Message #
Message — передаваемая единица данных.
Условно сообщение состоит из:
Message
├── body
├── properties
└── headers
Пример тела:
{
"event_type": "payment.completed",
"payment_id": 451,
"amount": "35.00",
"currency": "AZN"
}
Пример свойств:
content_type = application/json
delivery_mode = persistent
message_id = evt-451
correlation_id = operation-125
type = payment.completed
RabbitMQ не обязан понимать структуру body: для брокера это полезная нагрузка, которую формирует publisher и интерпретирует consumer. Свойства и headers в основном устанавливаются publisher’ом.
Exchange #
Exchange принимает сообщения от publisher и решает, в какие очереди или другие exchanges их направить.
Publisher
|
v
Exchange
|
+--> Queue A
+--> Queue B
Exchange сам по себе обычно не является хранилищем сообщений. Его основная задача — маршрутизация на основании:
типа exchange;
routing key;
bindings;
binding arguments или headers.
RabbitMQ поддерживает основные типы exchanges:
direct
fanout
topic
headers
Direct exchange #
Сопоставляет routing key с binding key по точному совпадению.
routing_key: payment.completed
binding_key: payment.completed
direct exchange
|
+-- payment.completed --> payment.queue
Topic exchange #
Использует шаблоны routing key.
payment.*
payment.#
*.failed
Например:
payment.completed
payment.failed
order.failed
Fanout exchange #
Отправляет копию сообщения во все связанные очереди, игнорируя routing key.
fanout exchange
|
+--> notifications.queue
+--> analytics.queue
+--> audit.queue
Headers exchange #
Маршрутизирует сообщения по значениям headers, а не по обычному routing key.
Queue #
Queue хранит сообщения до момента их доставки и успешного подтверждения consumer’ом.
Queue:
[M1] [M2] [M3] [M4]
|
v
Consumer
Основные свойства очереди:
name
durable
exclusive
auto-delete
arguments
durable— очередь переживает перезапуск брокера;exclusive— очередь привязана к одному соединению;auto-delete— удаляется после исчезновения последнего consumer при выполнении условий;arguments— дополнительные настройки, например TTL, лимит длины, тип очереди или dead lettering.
Очередь должна быть объявлена до использования. Повторное объявление допустимо, только если параметры совпадают с уже существующей очередью; иначе RabbitMQ закрывает канал с ошибкой PRECONDITION_FAILED.
Binding #
Binding — правило связи между exchange и очередью.
Exchange
|
| binding_key = payment.completed
v
Queue
Binding сообщает exchange:
Какие сообщения нужно направлять в эту очередь.
У binding могут быть:
source exchange;
destination queue или другой exchange;
binding key;
дополнительные arguments.
domain.events
|
| payment.*
v
accounting.queue
Один exchange может иметь множество bindings, и одна очередь может быть связана с несколькими exchanges. RabbitMQ также поддерживает exchange-to-exchange bindings.
Routing key #
Routing key — строка, которую publisher указывает при публикации сообщения.
payment.completed
order.created
notification.email
Publisher:
exchange = domain.events
routing_key = payment.completed
Exchange использует routing key вместе с bindings для выбора очередей. Точное поведение зависит от типа exchange.
Важно различать:
routing key — у публикуемого сообщения;
binding key — у связи exchange с очередью.
Для direct exchange они должны совпасть. Для topic exchange binding key может быть шаблоном.
Default exchange #
В каждом virtual host существует специальный direct exchange с пустым именем:
exchange = ""
При объявлении очереди RabbitMQ автоматически связывает её с default exchange, используя имя очереди как routing key.
Например:
exchange: ""
routing_key: payments.queue
Сообщение будет направлено непосредственно в:
payments.queue
Это выглядит как публикация напрямую в очередь, но технически сообщение всё равно проходит через default exchange.
Virtual host #
Virtual host, или vhost, — логически изолированная область внутри RabbitMQ.
RabbitMQ cluster
├── vhost: /
├── vhost: development
├── vhost: staging
└── vhost: production
Внутри vhost находятся:
exchanges;
queues;
bindings;
permissions;
policies;
connections, работающие с этим vhost;
другие topology-ресурсы.
Соединение с одним vhost не может напрямую использовать exchanges и queues другого vhost. Права пользователей также назначаются в контексте конкретного virtual host. Это логическая, а не физическая изоляция ресурсов.
Connection #
Connection — долговременное сетевое соединение между приложением и RabbitMQ.
Для AMQP 0-9-1 обычно используется TCP:
Application ===== TCP connection ===== RabbitMQ node
Соединение содержит:
аутентифицированного пользователя;
выбранный virtual host;
один или несколько channels;
параметры heartbeat;
сетевое состояние.
Открывать новое connection для каждого сообщения не следует: RabbitMQ и поддерживаемые протоколы рассчитаны преимущественно на долгоживущие соединения. Для publisher и consumer часто используют отдельные connections, чтобы publisher flow control не мешал consumer acknowledgements.
Channel #
Channel — лёгкое логическое соединение внутри одного физического connection.
TCP Connection
├── Channel 1 — publishing
├── Channel 2 — consuming
└── Channel 3 — topology declarations
Channels позволяют не создавать большое количество TCP-соединений:
один TCP connection
|
+--> несколько AMQP channels
Channel не может существовать без connection. При закрытии connection закрываются все его channels. Большинство протокольных ошибок, связанных с queue или exchange declaration, закрывают только конкретный channel, а не всё TCP-соединение.
Consumer subscription #
Consumer в протокольном смысле — зарегистрированная подписка на очередь.
queue: payments.queue
consumer_tag: consumer-payment-1
Consumer subscription включает:
имя очереди;
consumer tag;
acknowledgement mode;
consumer arguments;
иногда признаки exclusive или single active consumer;
callback приложения для обработки доставки.
consumer_tag идентифицирует подписку в пределах канала. Одно приложение может зарегистрировать несколько consumers.
Delivery #
Delivery — конкретная передача сообщения из очереди consumer’у.
При доставке RabbitMQ добавляет метаданные:
delivery_tag
redelivered
exchange
routing_key
consumer_tag
delivery_tag нужен для ack, nack и reject. Он относится не к самому сообщению глобально, а к конкретной доставке в пределах канала. redelivered=true означает, что сообщение ранее уже доставлялось и было возвращено в очередь.
User и permissions #
User — учётная запись, через которую приложение подключается к RabbitMQ.
При установлении connection RabbitMQ:
1. Аутентифицирует пользователя.
2. Проверяет доступ к выбранному vhost.
3. Проверяет permissions на конкретные ресурсы.
Основные категории AMQP 0-9-1 permissions:
configure
write
read
Они управляют возможностью:
объявлять и изменять topology;
публиковать сообщения;
читать сообщения из очередей.
Пользователь может иметь разные права в разных virtual hosts. Production-системам не рекомендуется использовать стандартного пользователя guest; следует создавать отдельные учётные записи приложений.
Node #
Node — отдельный запущенный экземпляр RabbitMQ.
RabbitMQ Node
├── connections
├── channels
├── exchanges
├── queues
└── runtime state
Приложение подключается к конкретному node. Один node может работать самостоятельно либо входить в cluster.
Cluster #
Cluster — логическая группа RabbitMQ nodes.
RabbitMQ Cluster
├── Node 1
├── Node 2
└── Node 3
Nodes кластера совместно используют распределённые сведения о:
users;
virtual hosts;
exchanges;
queues и streams;
bindings;
policies;
runtime parameters.
При этом репликация содержимого сообщений зависит от типа очереди или stream. Сам факт нахождения nodes в кластере не означает, что содержимое любой очереди автоматически реплицируется между всеми nodes; для реплицируемых сообщений применяются, например, quorum queues или streams.
Дополнительные важные сущности #
Dead Letter Exchange #
Dead Letter Exchange — exchange, куда RabbitMQ может перенаправлять сообщения, которые:
отклонены с
requeue=false;превысили TTL;
были вытеснены из-за ограничения длины очереди;
в некоторых типах очередей превысили лимит доставок.
main.queue
|
| rejected / expired
v
dead-letter.exchange
|
v
dead-letter.queue
DLX — обычный exchange, используемый RabbitMQ для повторной маршрутизации проблемного сообщения.
Policy #
Policy — серверное правило, которое применяет настройки к группе ресурсов по шаблону имени.
Например, через policy часто задают:
dead-letter exchange
message TTL
queue limits
federation
Policies удобнее жёсткого указания части параметров в коде приложения, поскольку их можно менять операционно без повторного развёртывания всех producers и consumers.
Stream #
Stream — append-only структура RabbitMQ для длительного хранения и повторного чтения сообщений.
В отличие от обычной очереди:
Queue:
ack -> сообщение обычно удаляется
Stream:
прочтение не удаляет сообщение
Streams используются для streaming-нагрузок, retention и replay. Это отдельная модель хранения, хотя exchanges также могут маршрутизировать сообщения в streams.
Как всё связано #
RabbitMQ Cluster
│
├── Node
│ └── Connection
│ └── Channel
│
└── Virtual Host
├── Users and permissions
├── Exchanges
│ └── Bindings
├── Queues / Streams
│ └── Consumer subscriptions
└── Policies
Путь сообщения:
1. Publisher открывает connection и channel.
2. Publisher публикует message в exchange.
3. Publisher указывает routing key.
4. Exchange проверяет bindings.
5. Сообщение попадает в одну или несколько queues.
6. RabbitMQ доставляет его consumer.
7. Consumer обрабатывает сообщение.
8. Consumer отправляет ack или nack.
Ключевые сущности, которые нужно знать в первую очередь #
Роли:
Publisher
Consumer
Broker
Маршрутизация:
Exchange
Binding
Routing key
Хранение:
Queue
Stream
Message
Соединение:
Connection
Channel
Consumer subscription
Изоляция и безопасность:
Virtual host
User
Permissions
Инфраструктура:
Node
Cluster
Policy
Главная цепочка RabbitMQ:
Publisher
-> Exchange
-> Binding
-> Queue
-> Consumer
-> Acknowledgement
17. Что такое партиция (partition) в Kafka? #
Что такое partition #
Partition — это упорядоченная часть Kafka topic, представляющая собой отдельный append-only журнал записей.
Topic может состоять из одной или нескольких partitions:
Topic: orders
Partition 0: [0][1][2][3]
Partition 1: [0][1][2]
Partition 2: [0][1][2][3][4]
Kafka разделяет topic на заранее заданное количество partitions, пронумерованных от 0 до P - 1. Каждая partition хранится и обрабатывается независимо.
Записи внутри partition #
Новые записи добавляются в конец журнала:
Partition 0:
offset 0 -> order.created
offset 1 -> order.paid
offset 2 -> order.shipped
offset 3 -> order.delivered
Каждая запись получает offset — её позицию внутри конкретной partition.
Важно:
(topic, partition, offset)
вместе однозначно определяют позицию записи:
topic: orders
partition: 0
offset: 153
Offset не является глобальным идентификатором всего topic. В другой partition может существовать запись с таким же offset:
orders / partition 0 / offset 153
orders / partition 1 / offset 153
Consumer при чтении указывает broker’у partition и offset, начиная с которого нужно вернуть данные. Позицию можно переместить назад и повторно прочитать записи.
Зачем Kafka нужны partitions #
Partition решает три основные задачи:
1. Масштабирование хранения
2. Параллельная обработка
3. Сохранение порядка связанных событий
Распределение данных между brokers #
Разные partitions одного topic могут иметь лидеров на разных brokers:
Kafka cluster
Broker 1:
orders-0
payments-1
Broker 2:
orders-1
payments-2
Broker 3:
orders-2
payments-0
Это позволяет распределить дисковую, сетевую и вычислительную нагрузку по кластеру. Producer отправляет запись непосредственно broker’у, который является лидером выбранной partition.
Параллельная обработка #
Partitions распределяются между consumers одной consumer group:
Topic: orders
Partitions: 4
Consumer group: order-service
Partition 0 -> Consumer A
Partition 1 -> Consumer A
Partition 2 -> Consumer B
Partition 3 -> Consumer B
В традиционной consumer group одна partition в конкретный момент назначается только одному consumer этой группы. Благодаря этому consumer получает записи своей partition последовательно.
Количество partitions определяет верхнюю границу полезного параллелизма внутри группы:
3 partitions
5 consumers
Распределение будет примерно таким:
Partition 0 -> Consumer A
Partition 1 -> Consumer B
Partition 2 -> Consumer C
Consumer D -> без partition
Consumer E -> без partition
Дополнительные consumers не ускорят обработку этой группы, пока количество partitions не будет увеличено.
Порядок сообщений #
Kafka гарантирует порядок только внутри одной partition:
Partition 0:
offset 10 -> order.created
offset 11 -> order.paid
offset 12 -> order.shipped
Consumer прочитает их в этом порядке.
Но глобального порядка между разными partitions нет:
Partition 0: A1 -> A2 -> A3
Partition 1: B1 -> B2 -> B3
Kafka гарантирует:
A1 раньше A2 раньше A3
B1 раньше B2 раньше B3
Но из offsets нельзя сделать вывод, было ли A2 раньше B2. Официальная модель Kafka рассматривает каждую partition как отдельный полностью упорядоченный журнал.
Как producer выбирает partition #
Producer может:
явно указать номер partition;
выбрать partition по ключу;
позволить partitioner’у выбрать её автоматически.
Пример записи:
topic: orders
key: user-125
value: {...}
Обычно partition выбирается приблизительно так:
hash(serialized_key) -> partition
Поэтому записи с одинаковым ключом обычно попадают в одну partition:
key = order-451
order.created -> Partition 2
order.paid -> Partition 2
order.shipped -> Partition 2
order.delivered -> Partition 2
Это позволяет сохранить порядок событий одной сущности. Kafka официально описывает semantic partitioning как использование ключа, например user_id, чтобы связанные данные попадали в одну partition и обрабатывались одним consumer.
Пример с заказами #
Пусть topic содержит четыре partitions:
Topic: order-events
Partition 0
Partition 1
Partition 2
Partition 3
Producer публикует события с ключом order_id:
{
"key": "order-451",
"value": {
"event_type": "order.created"
}
}
Все события заказа 451 попадают, например, в partition 2:
Partition 2:
offset 100 -> order-451 created
offset 101 -> order-451 paid
offset 102 -> order-451 shipped
Другой заказ может попасть в другую partition:
Partition 0:
offset 87 -> order-782 created
offset 88 -> order-782 cancelled
Таким образом, Kafka параллельно обрабатывает разные заказы, но сохраняет последовательность событий каждого заказа.
Partition и consumer offset #
Consumer group хранит отдельный committed offset для каждой partition:
Consumer group: order-service
Partition 0 -> committed offset 150
Partition 1 -> committed offset 208
Partition 2 -> committed offset 103
Partition 3 -> committed offset 91
Это означает, что прогресс чтения каждой partition отслеживается независимо.
Consumer может отставать только по одной partition:
Partition 0 lag: 0
Partition 1 lag: 0
Partition 2 lag: 50 000
Partition 3 lag: 0
Такое часто происходит, когда в одной partition оказались особенно тяжёлые сообщения или данные распределились неравномерно.
Partition и replication #
Partition является также единицей репликации Kafka.
У partition есть:
один leader;
ноль или несколько followers;
replication factor — общее количество копий.
Partition orders-0
Broker 1 -> Leader
Broker 2 -> Follower
Broker 3 -> Follower
Producer записывает данные в leader. Followers копируют его журнал, сохраняя те же offsets и порядок записей. При отказе leader Kafka может выбрать новый leader из доступных реплик.
Важно различать:
Partition — логическая часть данных topic.
Replica — копия этой partition на конкретном broker.
Например:
Topic: orders
Partitions: 3
Replication factor: 3
Это означает:
3 логические partitions
9 физических replicas
Проблема неравномерного распределения #
Если ключи распределяются плохо, одна partition может получить большую часть сообщений:
Partition 0: 1 000 сообщений
Partition 1: 1 100 сообщений
Partition 2: 500 000 сообщений
Это называется partition skew или hot partition.
Например, если ключом является страна:
key = country
и 80% пользователей находятся в одной стране, соответствующая partition станет перегруженной.
Поэтому ключ должен одновременно:
сохранять необходимый порядок;
достаточно равномерно распределять нагрузку;
не создавать одну горячую partition.
Можно ли увеличить количество partitions #
Количество partitions topic можно увеличить:
было: 3
стало: 6
Но после увеличения результат хеширования ключей может измениться:
hash(key) % 3 != hash(key) % 6
Поэтому будущие сообщения с тем же ключом могут начать попадать в другую partition.
Старые записи физически не перераспределяются автоматически:
старые события order-451 -> Partition 1
новые события order-451 -> Partition 4
Это может нарушить ожидаемый порядок событий одной сущности. Поэтому количество partitions желательно планировать заранее, особенно когда порядок по ключу критичен.
Одна partition #
Topic с одной partition:
Topic: audit
Partition 0:
[M1][M2][M3][M4]
Преимущества:
общий порядок всех записей;
простая модель обработки.
Недостатки:
ограниченный параллелизм;
один consumer традиционной consumer group активно обрабатывает partition;
лидер partition становится точкой концентрации нагрузки.
Несколько partitions #
Topic: events
Partition 0
Partition 1
Partition 2
Partition 3
Преимущества:
параллельное чтение и запись;
распределение данных между brokers;
горизонтальное масштабирование.
Недостатки:
нет общего порядка между partitions;
сложнее выбрать правильный ключ;
возможны горячие partitions;
изменение количества partitions может повлиять на распределение ключей.
Итог #
Topic
|
+--> Partition 0: упорядоченный журнал
+--> Partition 1: упорядоченный журнал
+--> Partition 2: упорядоченный журнал
Partition — это базовая единица параллелизма, порядка, хранения и репликации Kafka.
Главные правила:
1. Порядок гарантирован только внутри partition.
2. Offset уникален только внутри partition.
3. Один ключ обычно направляет связанные события в одну partition.
4. В consumer group partition назначается одному consumer.
5. Максимальный параллелизм группы зависит от количества partitions.
6. Каждая partition может иметь несколько replicas, но одного leader.
18. Что будет, если партиция (partition) в Kafka заполнится? #
Кратко #
У Kafka partition обычно не имеет состояния «заполнена, больше записывать нельзя». Это постоянно растущий журнал, который Kafka разбивает на сегменты. При достижении настроенного срока или размера хранения старые сегменты удаляются либо уплотняются. Новые записи продолжают добавляться в конец partition.
Проблема возникает не тогда, когда достигнут retention.bytes, а когда заканчивается физическое место на диске broker’а.
1. Достигнут retention.bytes
#
Для topic можно установить максимальный объём одной partition:
retention.bytes=10737418240
Это примерно 10 GiB на каждую partition.
При политике:
cleanup.policy=delete
Kafka начинает удалять самые старые закрытые log segments:
Partition:
[segment 0] [segment 1] [segment 2] [active segment]
|
└── удаляются старые сегменты
Новые сообщения ------------------------------>
Запись producer’ов при этом не останавливается. retention.bytes определяет объём сохраняемой истории, а не жёсткую вместимость очереди. По умолчанию retention.bytes=-1, то есть ограничения по размеру нет; действует временной retention, который по умолчанию составляет семь дней.
2. Размер может временно превысить лимит #
Kafka удаляет данные не по одному сообщению, а целыми log segments:
Partition
├── 00000000000000000000.log
├── 00000000000001000000.log
├── 00000000000002000000.log
└── active.log
Поэтому retention.bytes=10 GiB не означает, что размер всегда строго ограничен десятью гибибайтами. Partition может временно быть больше лимита, потому что:
активный segment ещё нельзя удалить;
очистка выполняется периодически;
удаление производится целыми файлами;
segment сначала должен быть закрыт — rolled.
Параметр segment.bytes задаёт размер segment-файла; по умолчанию это 1 GiB. Чем больше segment, тем менее точно Kafka может соблюдать retention по размеру.
3. Что будет с медленным consumer #
Удаление старых records не зависит от того, успели ли все consumer groups их прочитать.
Например:
Partition:
offset 1000 ... 5000 ... 10000
^
consumer читает отсюда
Kafka удаляет записи до offset 7000
Consumer больше не сможет прочитать offsets 5000–6999. Его сохранённый offset больше не существует.
Дальнейшее поведение определяется auto.offset.reset:
auto.offset.reset=earliest
Consumer продолжит с самого раннего ещё существующего offset.
auto.offset.reset=latest
Consumer перейдёт в конец partition и пропустит накопленные данные.
auto.offset.reset=none
Consumer получит исключение и должен обработать ситуацию самостоятельно. В Kafka 4.3 также доступен вариант by_duration, позволяющий начать чтение с позиции, соответствующей заданной давности.
Поэтому retention.ms и retention.bytes должны учитывать максимальный ожидаемый consumer lag.
4. Что происходит при cleanup.policy=compact
#
При compaction Kafka не удаляет данные только потому, что partition достигла определённого размера. Она сохраняет последнее значение для каждого ключа:
До compaction:
user-1 -> name=A
user-2 -> name=B
user-1 -> name=C
После compaction:
user-2 -> name=B
user-1 -> name=C
cleanup.policy=compact
При большом количестве уникальных ключей partition всё равно может постоянно расти, поскольку для каждого ключа необходимо сохранить хотя бы последнее значение.
Можно совместить две политики:
cleanup.policy=compact,delete
Тогда Kafka выполняет compaction, но также удаляет старые сегменты по retention.ms и retention.bytes.
5. Что будет, если заполнится физический диск #
Это уже аварийная ситуация:
Partition продолжает расти
|
v
На broker заканчивается место
|
v
Kafka не может записывать новые log segments
Kafka может пометить соответствующий log directory как offline. Для контроля этого состояния предусмотрены метрики LogDirectoryOffline и OfflineLogDirectoryCount.
Дальнейшее зависит от репликации.
Есть синхронизированная replica #
Broker 1: Leader — диск заполнен
Broker 2: Follower — исправен
Broker 3: Follower — исправен
Kafka может перенести leadership partition на исправную replica:
Broker 2: новый Leader
Кластер обнаруживает отказ broker’а и выбирает новых leaders для его partitions, если подходящие replicas доступны.
Исправной replica нет #
Replication factor = 1
или остальные replicas сильно отстали:
Leader unavailable
No eligible replica
Partition становится недоступной для записи, а иногда и для чтения. Producer начинает получать ошибки, выполнять retries и в конечном итоге завершает отправку ошибкой после истечения настроенного timeout.
6. Почему Kafka сама не удалит всё при заполнении диска #
Retention не гарантирует мгновенного освобождения места.
Например:
cleanup.policy=delete
retention.ms=604800000
retention.bytes=-1
Kafka обязана сохранять данные семь дней. Если диск заполнится за два дня, старые segments ещё не подходят под временной retention и автоматически удалены не будут.
Аналогично при:
cleanup.policy=compact
compaction удаляет устаревшие версии ключей, но не обязана уменьшать журнал до конкретного безопасного размера.
Как предотвращают заполнение диска #
Обычно контролируют:
Свободное место на дисках broker’ов
Размер partitions и topics
Скорость записи байтов
Скорость удаления segments
Consumer lag
Offline log directories
Under-replicated partitions
И устанавливают разумные ограничения:
cleanup.policy=delete
retention.ms=259200000
retention.bytes=53687091200
segment.bytes=1073741824
Здесь:
retention.ms = 3 дня
retention.bytes = 50 GiB на partition
segment.bytes = 1 GiB
При наличии tiered storage закрытые segments можно переносить во внешнее хранилище, оставляя на локальных дисках только более свежую часть журнала. Для этого отдельно настраиваются локальные и общие ограничения retention.
Итоговая схема #
Достигнут retention.bytes
|
v
Удаляются старые закрытые segments
|
v
Запись новых сообщений продолжается
Заполнен физический диск broker’а
|
+--> есть исправная replica
| |
| v
| leadership переносится
|
+--> исправной replica нет
|
v
partition становится недоступной
Таким образом, partition не «заполняется» как обычная очередь фиксированного размера. Она либо очищает старую историю согласно retention, либо продолжает расти до тех пор, пока не закончится место на диске.
19. Что такое routing key в RabbitMQ? #
Что такое routing key #
Routing key — это строка, которую publisher указывает при публикации сообщения. Exchange использует её вместе с bindings, чтобы определить, в какие очереди направить сообщение.
Publisher
|
| exchange = domain.events
| routing_key = payment.completed
v
Exchange
|
| проверяет bindings
v
Queue
Routing key можно воспринимать как логический адрес или признак сообщения, но это не обязательно имя очереди.
Пример публикации:
channel.basic_publish(
exchange="domain.events",
routing_key="payment.completed",
body=message,
)
Здесь:
domain.events — exchange, куда публикуется сообщение
payment.completed — routing key сообщения
Routing key и binding key #
Их важно различать.
Routing key задаёт publisher при отправке сообщения:
payment.completed
Binding key задаётся при связывании очереди с exchange:
Exchange: domain.events
Queue: accounting.queue
Binding key: payment.completed
Publisher отправляет:
routing_key = payment.completed
Exchange проверяет:
binding_key = payment.completed
Дальнейшее сопоставление зависит от типа exchange.
Direct exchange #
У direct exchange routing key должен точно совпасть с binding key:
routing_key == binding_key
Например:
Exchange: payments.direct
Binding:
payment.completed -> notifications.queue
payment.failed -> retry.queue
Сообщение:
routing_key = payment.completed
попадёт в:
notifications.queue
но не попадёт в retry.queue.
payments.direct
|
| payment.completed
v
notifications.queue
Если несколько очередей связаны с direct exchange одним binding key, каждая из них получит копию сообщения.
Topic exchange #
Topic exchange сопоставляет routing key с шаблоном binding key.
Routing key обычно состоит из слов, разделённых точками:
payment.completed
payment.card.failed
order.created
user.profile.updated
В binding key доступны специальные символы:
* — ровно одно слово
# — ноль или несколько слов
Примеры:
payment.* -> ровно два слова:
payment.completed
payment.failed
payment.# -> любое количество частей после payment:
payment.completed
payment.card.failed
payment.provider.timeout
*.failed -> order.failed
payment.failed
#.failed -> payment.card.failed
order.failed
Схема:
Topic exchange: domain.events
payment.* -> accounting.queue
*.failed -> failures.queue
payment.# -> payment-audit.queue
Сообщение:
routing_key = payment.failed
попадёт во все три очереди, поскольку соответствует всем трём шаблонам. Topic exchange маршрутизирует сообщения во все очереди, чьи binding patterns совпали с routing key.
Fanout exchange #
Fanout exchange полностью игнорирует routing key:
channel.basic_publish(
exchange="logs.fanout",
routing_key="любое-значение",
body=message,
)
Сообщение будет скопировано во все очереди, связанные с exchange:
logs.fanout
|
+--> console.queue
+--> file.queue
+--> monitoring.queue
Поэтому при fanout routing key обычно передают как пустую строку:
routing_key=""
но её значение всё равно не участвует в маршрутизации.
Headers exchange #
Headers exchange маршрутизирует сообщения по AMQP headers, поэтому обычный routing key для выбора очереди не используется:
headers:
format = pdf
region = az
Binding может проверять именно эти заголовки, а не строку payment.completed.
Default exchange #
В RabbitMQ существует специальный direct exchange с пустым именем:
exchange = ""
При создании каждая очередь автоматически связывается с ним binding key, равным имени очереди.
Queue name: payments.queue
Automatic binding key: payments.queue
Поэтому можно опубликовать:
channel.basic_publish(
exchange="",
routing_key="payments.queue",
body=message,
)
Сообщение попадёт в очередь payments.queue.
Это выглядит как отправка непосредственно в очередь, но технически сообщение проходит через default exchange. Только в этом случае routing key обычно непосредственно равен имени очереди.
Что будет, если routing key не совпал #
Предположим:
Exchange: payments.direct
Bindings:
payment.completed -> completed.queue
payment.failed -> failed.queue
Publisher отправил:
routing_key = payment.refunded
Совпадающего binding нет, поэтому сообщение является unroutable — exchange не может направить его ни в одну очередь.
По умолчанию такое сообщение может быть отброшено брокером. Для контроля используют:
флаг
mandatory;обработку возврата сообщения publisher’у;
alternate exchange.
Routing key сам по себе не создаёт очередь и не сохраняет сообщение: должна существовать подходящая связь exchange с очередью.
Как выбирать routing key #
Для событий удобно использовать структуру:
<сущность>.<событие>
Примеры:
user.registered
order.created
payment.completed
payment.failed
file.uploaded
Для более детальной классификации:
<область>.<сущность>.<событие>
billing.payment.completed
billing.payment.failed
users.profile.updated
storage.file.deleted
Или:
<сущность>.<канал>.<действие>
notification.email.send
notification.sms.send
notification.push.send
Лучше описывать routing key через бизнес-смысл сообщения, а не через имя конкретного consumer:
Хорошо:
payment.completed
Сильная связанность:
send-to-accounting-service
В первом случае к событию позже можно подключить несколько независимых очередей:
payment.completed
|
+--> accounting.queue
+--> notifications.queue
+--> analytics.queue
Итог #
routing key
— задаётся publisher;
— относится к конкретной публикации;
— передаётся exchange;
— используется для выбора подходящих bindings;
— интерпретируется в зависимости от типа exchange.
Главная цепочка:
Publisher указывает routing key
|
v
Exchange сравнивает её с binding keys
|
v
Сообщение направляется в подходящие очереди
Для direct требуется точное совпадение, для topic используются шаблоны, fanout routing key игнорирует, а у default exchange она равна имени целевой очереди.
20. Как решать проблемы с нагрузкой в backend-приложении? #
Главный принцип #
Проблемы с нагрузкой решают не добавлением серверов «на глаз», а последовательностью:
Измерить
→ воспроизвести
→ найти узкое место
→ устранить его
→ повторно измерить
→ только затем масштабировать
Нагрузка может упираться не в backend-код, а в PostgreSQL, внешний API, пул соединений, очередь, диск, сеть, блокировки или слишком большие ответы.
1. Сначала определить симптомы #
Минимально нужно собирать:
Трафик:
RPS
количество одновременных запросов
Задержки:
p50
p95
p99
Ошибки:
HTTP 5xx
HTTP 429
timeouts
ошибки подключения
Ресурсы:
CPU
RAM
диск
сеть
количество открытых соединений
Зависимости:
время SQL-запросов
использование пула БД
latency внешних API
размер очередей
количество unacked-сообщений
Среднее время ответа недостаточно. Например:
average = 100 ms
p99 = 4 s
Среднее выглядит нормально, но каждый сотый запрос работает четыре секунды.
Для распределённого приложения полезны одновременно метрики, логи и traces: trace позволяет увидеть, сколько времени запрос провёл в приложении, PostgreSQL, Redis и внешних сервисах. OpenTelemetry предназначен для сбора именно таких сигналов наблюдаемости.
2. Воспроизвести нагрузку #
Нагрузочный тест должен быть похож на реальный трафик:
неправильно:
100% запросов GET /health
правильно:
50% чтение списка
20% получение объекта
15% создание
10% обновление
5% тяжёлые операции
Проверяют несколько режимов:
Load test:
ожидаемая штатная нагрузка
Stress test:
постепенное увеличение до отказа
Spike test:
резкий скачок трафика
Soak test:
длительная нагрузка для поиска утечек
Важно определить точку деградации:
100 RPS -> p95 120 ms
300 RPS -> p95 180 ms
500 RPS -> p95 900 ms
600 RPS -> timeouts
Это показывает, что система начинает насыщаться примерно между 300 и 500 RPS.
3. Найти тип узкого места #
Условная диагностика:
CPU около 100%
→ CPU-bound код или недостаточно процессов
CPU низкий, ответы медленные
→ ожидание БД, сети, блокировок или пула соединений
Пул БД полностью занят
→ слишком много запросов, медленные запросы или долгие транзакции
RAM постоянно растёт
→ утечка, неограниченный кэш, накопление объектов
RabbitMQ queue растёт
→ consumers обрабатывают медленнее, чем publishers публикуют
Ошибки появляются только при всплеске
→ нет backpressure, лимитов или запаса соединений
4. Оптимизировать базу данных #
В backend-приложениях база часто становится первым серьёзным ограничением.
Начинать нужно со статистики реальных запросов и:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...
EXPLAIN ANALYZE фактически выполняет запрос и показывает реальные времена, количество строк и работу узлов плана. PostgreSQL использует статистику ANALYZE при выборе плана запроса. (
PostgreSQL)
Проверяют:
Sequential Scan больших таблиц
неверные оценки rows
дорогие Sort
многократные Nested Loop
чтение большого количества buffers
Rows Removed by Filter
временные файлы на диске
долгое ожидание locks
Основные меры:
Устранить N+1 #
Плохо:
users = await get_users()
for user in users:
user.orders = await get_orders(user.id)
При 100 пользователях получается:
1 запрос пользователей
100 запросов заказов
--------------------
101 SQL-запрос
Нужно использовать:
JOIN
select_related
prefetch_related
selectinload
joinedload
в зависимости от ORM.
Создать подходящие индексы #
Индекс должен соответствовать реальному запросу:
SELECT *
FROM payments
WHERE user_id = 42
AND status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
Возможный индекс:
CREATE INDEX idx_payments_user_status_created
ON payments (user_id, status, created_at DESC);
Но индексы не бесплатны: они занимают место и замедляют INSERT, UPDATE и DELETE. Их использование нужно проверять через EXPLAIN и статистику PostgreSQL.
Ограничить объём данных #
Плохо:
SELECT *
FROM payments;
Лучше:
SELECT id, amount, status, created_at
FROM payments
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50;
Нужны:
pagination
фильтрация
выбор только нужных колонок
ограничение максимального limit
Сократить транзакции #
Не следует держать транзакцию открытой во время внешнего HTTP-запроса:
async with transaction():
await update_payment()
# Транзакция и locks продолжают удерживаться.
await external_provider.request()
Внешний вызов может зависнуть, пока соединение с БД и блокировки остаются занятыми.
Настроить пул соединений #
Слишком маленький пул создаёт очередь ожидания:
100 запросов
10 соединений
90 запросов ждут
Но слишком большой пул тоже опасен:
10 replicas × 50 connections = 500 connections к PostgreSQL
Пул должен рассчитываться для всей системы, а не для одного процесса.
5. Использовать кэш там, где он оправдан #
Кэш полезен для данных, которые:
часто читаются;
редко меняются;
дорого вычисляются;
допускают небольшую задержку обновления.
Request
|
v
Redis cache
|
+-- hit -> вернуть результат
|
+-- miss -> PostgreSQL -> записать в Redis
Redis может использоваться как внешний кэш, а client-side caching позволяет хранить часть данных непосредственно в процессах приложения.
Пример cache-aside:
async def get_product(product_id: int) -> dict:
key = f"product:{product_id}"
cached = await redis.get(key)
if cached is not None:
return json.loads(cached)
product = await repository.get_product(product_id)
await redis.set(
key,
json.dumps(product),
ex=60,
)
return product
Нужно заранее решить:
TTL
инвалидацию
поведение при недоступности Redis
допустимость устаревших данных
защиту от cache stampede
Cache stampede #
Если популярный ключ истёк, сотни запросов одновременно идут в БД:
cache expired
|
+--> request 1 -> DB
+--> request 2 -> DB
+--> request 3 -> DB
...
Меры:
distributed lock
single flight
TTL с небольшим случайным разбросом
обновление до фактического истечения
stale-while-revalidate
Не следует кэшировать всё подряд. Часто достаточно исправить SQL-запрос или добавить индекс.
6. Правильно использовать async #
Асинхронность помогает при ожидании I/O:
PostgreSQL
Redis
HTTP API
S3
RabbitMQ
Она не ускоряет CPU-bound вычисления:
обработка больших изображений
сжатие
шифрование
парсинг огромного файла
машинное обучение
сложные вычисления
Критическая ошибка в FastAPI:
import requests
@app.get("/data")
async def get_data():
response = requests.get("https://service")
return response.json()
requests.get() блокирует поток event loop.
Нужно использовать асинхронный клиент:
import httpx
@app.get("/data")
async def get_data():
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get("https://service")
response.raise_for_status()
return response.json()
Либо выполнять синхронную работу в thread pool, если библиотека не поддерживает async.
Также необходимо:
задавать timeouts
переиспользовать HTTP-клиенты
использовать connection pooling
ограничивать concurrency
не создавать клиент на каждый запрос
7. CPU-bound работу выносить из HTTP-запроса #
Плохо:
POST /reports
→ собрать данные
→ построить PDF
→ загрузить в S3
→ отправить email
→ через 40 секунд вернуть ответ
Лучше:
POST /reports
|
| создать job
v
202 Accepted
|
v
RabbitMQ
|
v
Worker
|
+--> построить PDF
+--> загрузить в S3
+--> обновить статус
HTTP-обработчик должен выполнять только то, что необходимо для немедленного ответа пользователю.
Для RabbitMQ важно настраивать prefetch: он ограничивает количество неподтверждённых сообщений, переданных consumer’у. Слишком большой prefetch может перегрузить worker памятью и ухудшить распределение задач; слишком маленький ограничивает throughput.
channel.basic_qos(prefetch_count=10)
Если очередь продолжает расти:
скорость публикации > скорость обработки
Варианты:
увеличить число consumers
ускорить обработку одной задачи
разделить тяжёлые задачи
использовать batching
уменьшить входной поток
проверить внешние зависимости workers
8. Масштабировать процессы и экземпляры #
Один Python-процесс обычно использует одно ядро для выполнения Python-кода в конкретный момент. Для задействования нескольких ядер запускают несколько worker-процессов:
uvicorn app.main:app --workers 4
FastAPI прямо рекомендует использовать репликацию процессов, когда нужно задействовать несколько ядер и обслуживать больше запросов. В контейнерной оркестрации часто используют несколько отдельных контейнеров приложения вместо большого количества процессов внутри одного контейнера.
Но количество workers нельзя выбирать только по формуле:
workers = CPU × 2 + 1
Нужно проводить тесты, потому что ограничением может быть:
PostgreSQL
RAM
пул соединений
внешний API
диск
лимит файловых дескрипторов
Вертикальное масштабирование #
больше CPU
больше RAM
быстрее диск
Просто, но имеет физический предел и создаёт крупную точку отказа.
Горизонтальное масштабирование #
Load Balancer
|
+--> Backend 1
+--> Backend 2
+--> Backend 3
Для него приложение должно быть максимально stateless:
сессии не в памяти процесса
файлы не только на локальном диске
общий Redis
общая БД
общее S3/MinIO
Kubernetes HPA может изменять количество replicas по CPU, памяти или пользовательским метрикам. Для backend полезнее иногда масштабироваться не по CPU, а по RPS, latency или длине очереди.
9. Добавить backpressure #
Система не должна бесконечно принимать работу, которую не способна выполнить.
100 запросов/сек входят
50 запросов/сек обрабатываются
------------------------------
очередь растёт на 50 запросов/сек
Без ограничений это заканчивается:
ростом RAM
переполнением пулов
тайм-аутами
каскадным отказом
Основные механизмы:
Rate limiting #
100 запросов в минуту на пользователя
10 тяжёлых операций одновременно
500 запросов в секунду на endpoint
При превышении возвращают:
429 Too Many Requests
Retry-After: 10
Ограничение concurrency #
semaphore = asyncio.Semaphore(20)
async def call_provider():
async with semaphore:
return await provider.request()
Даже если приложение получает тысячу запросов, к нестабильному внешнему сервису одновременно уйдёт не более двадцати.
Timeouts #
Timeout должен существовать на каждом сетевом уровне:
HTTP client timeout
DB statement timeout
pool acquisition timeout
RabbitMQ operation timeout
reverse proxy timeout
Без timeout один зависший сервис постепенно занимает все соединения и workers.
Circuit breaker #
После серии ошибок вызовы временно блокируются:
Closed
|
много ошибок
v
Open
|
через timeout
v
Half-open
Это защищает приложение от постоянного ожидания уже недоступного сервиса.
Load shedding #
При перегрузке лучше быстро отклонить часть запросов, чем позволить упасть всей системе:
503 Service Unavailable
429 Too Many Requests
10. Уменьшить стоимость одного запроса #
Нередко масштабирование не требуется, если запрос можно сделать дешевле.
Проверить:
сколько SQL-запросов выполняется
сколько данных сериализуется
какой размер JSON-ответа
сколько внешних вызовов выполняется
нет ли повторных вычислений
нет ли повторного чтения одного объекта
Примеры:
Параллельные независимые I/O-операции #
Последовательно:
user = await get_user()
payments = await get_payments()
notifications = await get_notifications()
Если операции независимы:
user, payments, notifications = await asyncio.gather(
get_user(),
get_payments(),
get_notifications(),
)
Но нельзя бесконтрольно создавать тысячи задач:
await asyncio.gather(
*(call_external_api(item) for item in million_items)
)
Здесь нужен semaphore, очередь или пакетная обработка.
Batching #
Вместо:
1000 отдельных INSERT
использовать:
bulk INSERT
COPY
executemany
Вместо 100 запросов к внешнему API — один batch-запрос, если API это поддерживает.
Уменьшение ответа #
pagination
field selection
компрессия
CDN для статических данных
S3/MinIO для файлов
Большие файлы не следует передавать через процесс приложения, если клиент может получить подписанную ссылку на объектное хранилище.
11. Профилировать код #
Если CPU загружен, нужно найти конкретные функции, а не оптимизировать случайные участки.
В Python можно начать с cProfile, который предоставляет детерминированное профилирование и показывает количество вызовов и время выполнения функций.
python -m cProfile -o profile.out app.py
Нужно искать:
горячие циклы
лишнюю сериализацию
регулярные выражения
копирование больших структур
частый JSON encode/decode
синхронные операции в async-коде
дорогую валидацию
Профилирование под искусственным единичным запросом может не показать конкуренцию, блокировки и проблемы пула, поэтому его нужно сочетать с нагрузочным тестированием.
12. Не допустить каскадного отказа #
Рассмотрим цепочку:
API
|
v
Payment Service
|
v
External Provider
Provider замедлился:
provider latency растёт
→ запросы API ждут
→ соединения заканчиваются
→ очередь запросов растёт
→ память растёт
→ API перестаёт отвечать
Защита:
короткий timeout
ограничение одновременных вызовов
circuit breaker
retry только для безопасных операций
exponential backoff
jitter
ограниченное количество retry
Нельзя делать бесконечные немедленные повторы:
while True:
try:
return await provider.request()
except Exception:
continue
Такой код усиливает нагрузку на уже падающий сервис.
Практический порядок действий #
1. Зафиксировать текущие p95, p99, RPS и error rate.
2. Найти самый медленный endpoint.
3. По trace определить:
приложение
PostgreSQL
Redis
внешний API
очередь
4. Проверить SQL через EXPLAIN ANALYZE.
5. Устранить:
N+1
отсутствие индексов
лишние данные
блокирующий I/O
долгие транзакции
6. Добавить:
timeouts
connection pools
concurrency limits
rate limiting
7. Вынести тяжёлые операции в background workers.
8. Добавить кэш для действительно горячих чтений.
9. Повторить нагрузочный тест.
10. После оптимизации масштабировать workers или replicas.
11. Проверить, выдерживают ли рост:
PostgreSQL
Redis
RabbitMQ
внешние сервисы
12. Установить alerts до достижения критического состояния.
Что обычно делают неправильно #
Добавляют workers, не проверив PostgreSQL
→ база перегружается ещё сильнее.
Добавляют Redis перед медленным запросом
→ скрывают плохой SQL и получают проблему инвалидации.
Делают всё async
→ CPU-bound код продолжает блокировать event loop.
Создают неограниченное количество asyncio tasks
→ заканчиваются память и соединения.
Увеличивают pool_size на каждой replica
→ превышают лимит соединений PostgreSQL.
Используют бесконечные retries
→ усиливают аварию.
Принимают все запросы без ограничений
→ перегрузка распространяется на всю систему.
Итоговая стратегия #
Сначала:
observability
нагрузочный тест
поиск bottleneck
Затем:
оптимизация SQL и кода
кэширование
batching
background jobs
timeouts и backpressure
После этого:
несколько workers
горизонтальное масштабирование
autoscaling
Масштабирование компенсирует рост нормальной нагрузки. Оно не исправляет медленные SQL-запросы, блокировки, утечки памяти и неограниченную конкуренцию.
21. Как сделать, чтобы два consumer-а получили одно и то же сообщение? #
Основной способ — отдельная очередь для каждого consumer #
Чтобы два consumer’а получили одно и то же сообщение, они должны читать из разных очередей, привязанных к одному exchange.
+--> queue.consumer_a --> Consumer A
Publisher --> Exchange --|
+--> queue.consumer_b --> Consumer B
RabbitMQ создаёт копию сообщения для каждой подходящей очереди. После этого каждая очередь доставляет свою копию независимо.
Почему нельзя подключить оба consumer к одной очереди #
Такая схема:
+--> Consumer A
Publisher --> Queue ---|
+--> Consumer B
создаёт competing consumers. RabbitMQ распределяет сообщения между ними:
M1 -> Consumer A
M2 -> Consumer B
M3 -> Consumer A
M4 -> Consumer B
Оба consumer не получают каждое сообщение. Такая модель используется для горизонтального масштабирования обработчиков одной задачи.
Вариант 1: fanout exchange #
fanout отправляет сообщение во все привязанные очереди и игнорирует routing_key.
+--> notification.queue
events.fanout exchange --|
+--> analytics.queue
Producer:
channel.basic_publish(
exchange="events.fanout",
routing_key="",
body=message,
)
Настройка очередей:
channel.exchange_declare(
exchange="events.fanout",
exchange_type="fanout",
durable=True,
)
channel.queue_declare(
queue="notification.queue",
durable=True,
)
channel.queue_declare(
queue="analytics.queue",
durable=True,
)
channel.queue_bind(
exchange="events.fanout",
queue="notification.queue",
)
channel.queue_bind(
exchange="events.fanout",
queue="analytics.queue",
)
Теперь одно опубликованное сообщение попадёт в обе очереди:
notification.queue -> Consumer A
analytics.queue -> Consumer B
Это классический паттерн Publish/Subscribe.
Вариант 2: topic exchange #
Если consumer’ам нужны не все сообщения, а только определённые типы событий, используют topic.
Exchange: domain.events
payment.* -> accounting.queue
payment.# -> audit.queue
*.completed -> notification.queue
Producer публикует:
channel.basic_publish(
exchange="domain.events",
routing_key="payment.completed",
body=message,
)
Сообщение попадёт во все очереди, binding которых совпал с payment.completed.
payment.completed
|
+--> accounting.queue
+--> audit.queue
+--> notification.queue
Topic exchange доставляет сообщение всем подходящим очередям, а не только первой найденной.
Вариант 3: direct exchange #
Для точного совпадения используют direct exchange:
Exchange: payments.direct
payment.completed -> accounting.queue
payment.completed -> notification.queue
Обе очереди имеют одинаковый binding key:
channel.queue_bind(
exchange="payments.direct",
queue="accounting.queue",
routing_key="payment.completed",
)
channel.queue_bind(
exchange="payments.direct",
queue="notification.queue",
routing_key="payment.completed",
)
Публикация:
channel.basic_publish(
exchange="payments.direct",
routing_key="payment.completed",
body=message,
)
Обе очереди получат копию.
Подтверждения выполняются независимо #
Каждый consumer подтверждает собственную копию сообщения:
Consumer A обработал -> ack для queue.consumer_a
Consumer B обработал -> ack для queue.consumer_b
Например:
Consumer A -> ACK
Consumer B -> упал до ACK
Результат:
queue.consumer_a:
сообщение удалено
queue.consumer_b:
сообщение возвращено в очередь
и будет доставлено повторно
Ошибка одного consumer не влияет на сообщение в другой очереди.
Постоянные и временные подписчики #
Consumer должен получить сообщение даже после перезапуска #
Нужна отдельная постоянная очередь:
channel.queue_declare(
queue="analytics.queue",
durable=True,
)
Сообщения смогут накапливаться, пока consumer отключён. Для сохранения сообщений при перезапуске брокера publisher также должен публиковать persistent-сообщения, а очередь должна быть durable.
Сообщения нужны только пока consumer подключён #
Можно создать временную эксклюзивную очередь:
result = channel.queue_declare(
queue="",
exclusive=True,
)
queue_name = result.method.queue
channel.queue_bind(
exchange="events.fanout",
queue=queue_name,
)
RabbitMQ назначит случайное имя, а очередь будет удалена после закрытия соединения. Сообщения, опубликованные до создания и привязки этой очереди, consumer не получит.
Важное различие #
Если это две реплики одного сервиса:
Email Worker 1
Email Worker 2
и письмо должен отправить только один worker, нужна одна общая очередь:
email.queue
|
+--> Worker 1
+--> Worker 2
Если это два независимых сервиса:
Notification Service
Analytics Service
и оба должны обработать событие, нужны две разные очереди:
payment.completed
|
+--> notification.queue
+--> analytics.queue
Итог #
Одна очередь + два consumer
= сообщение получает один из consumer’ов.
Две очереди + один exchange
= каждый consumer получает свою копию сообщения.
Наиболее типичная схема:
Publisher
|
v
Topic/Fanout Exchange
|
+--> service_a.queue --> Consumer A
|
+--> service_b.queue --> Consumer B
22. Что будет, если не удалось отправить сообщение в RabbitMQ? #
Что значит «не удалось отправить» #
Отправка сообщения в RabbitMQ проходит несколько этапов:
Приложение
|
| basic.publish
v
RabbitMQ node
|
v
Exchange
|
| routing
v
Queue
|
| сохранение / репликация
v
Publisher confirm
Ошибка может произойти на любом этапе. Последствия зависят от того, включены ли:
publisher confirms;флаг
mandatory;persistent-сообщения;
durable или quorum queue;
повторные попытки публикации;
transactional outbox.
1. Соединение с RabbitMQ отсутствует #
Например:
RabbitMQ не запущен;
DNS не разрешился;
сеть недоступна;
TCP-соединение разорвано;
закрыт AMQP-канал;
publisher не прошёл аутентификацию.
Application --X--> RabbitMQ
В этом случае клиентская библиотека обычно возвращает исключение. Сообщение не будет автоматически отправлено после восстановления соединения, если приложение само не реализовало буферизацию или повторную публикацию. RabbitMQ прямо отмечает, что публикации во время недоступного соединения не сохраняются клиентом для последующей доставки.
Пример обработки:
try:
await publisher.publish(message)
except ConnectionError:
logger.exception("RabbitMQ is unavailable")
raise
Одного перехвата ошибки недостаточно: сообщение нужно сохранить для последующей повторной отправки либо считать всю бизнес-операцию неуспешной.
2. Метод публикации завершился без ошибки, но confirms не используются #
Обычный вызов:
channel.basic_publish(
exchange="events",
routing_key="payment.completed",
body=body,
)
может означать только то, что клиент передал данные в сетевой буфер. Это не доказывает, что RabbitMQ:
получил сообщение;
успешно обработал его;
направил в очередь;
сохранил на диске;
реплицировал.
Для подтверждения принятия сообщения брокером используют publisher confirms. Они включаются на уровне AMQP-канала.
Publisher -- message --> RabbitMQ
Publisher <-- confirm -- RabbitMQ
Без confirms при сетевом сбое publisher может считать отправку успешной, хотя сообщение фактически было потеряно.
3. RabbitMQ прислал publisher ack
#
Publisher confirm ack означает, что RabbitMQ принял ответственность за опубликованное сообщение в соответствии с типом очереди и условиями публикации. Это не то же самое, что consumer acknowledgement: publisher confirm относится к публикации, а consumer ack — к обработке сообщения.
Producer -- publish --> RabbitMQ
Producer <-- ack ----- RabbitMQ
После получения confirm publisher обычно удаляет сообщение из своего локального списка неподтверждённых публикаций.
4. RabbitMQ прислал publisher nack
#
basic.nack со стороны RabbitMQ означает, что брокер не смог принять ответственность за сообщение.
Это может произойти, например, при внутренних проблемах брокера, потере лидера или невозможности безопасно сохранить сообщение. При сетевых разделениях confirms могут задерживаться или возвращаться как nack.
Publisher -- message --> RabbitMQ
Publisher <-- nack ---- RabbitMQ
Такое сообщение нужно считать неотправленным и обычно публиковать повторно:
try:
await publish_with_confirm(message)
except PublishNackError:
await schedule_retry(message)
Повторная публикация может привести к дубликату, поэтому сообщение должно иметь стабильный message_id, а consumers должны быть идемпотентными.
5. Confirm не пришёл из-за timeout #
Возможна неопределённая ситуация:
1. Publisher отправил сообщение.
2. RabbitMQ принял и сохранил его.
3. Confirm потерялся из-за разрыва соединения.
4. Publisher не знает результат публикации.
Publisher -- message --> RabbitMQ
Publisher <--X-- ack
Publisher не может достоверно определить, было сообщение принято или нет. Поэтому он обычно повторяет публикацию, принимая риск дубликата.
Исходное сообщение принято RabbitMQ
+
Publisher отправил его повторно
=
Consumer может получить два экземпляра
Надёжная публикация обычно обеспечивает at least once, а не автоматическое exactly once. Клиент должен отслеживать неподтверждённые публикации и повторно отправлять их после восстановления соединения.
6. Exchange существует, но сообщение не попало ни в одну очередь #
Предположим:
exchange: payments
routing_key: payment.refunded
bindings:
payment.completed -> completed.queue
payment.failed -> failed.queue
Для payment.refunded подходящего binding нет. Сообщение становится unroutable.
Без mandatory=true
#
RabbitMQ может отбросить сообщение:
Publisher -> Exchange -> нет подходящей очереди -> сообщение отброшено
С mandatory=true
#
RabbitMQ возвращает сообщение publisher’у через basic.return:
channel.basic_publish(
exchange="payments",
routing_key="payment.refunded",
body=body,
mandatory=True,
)
Publisher -- publish --------> Exchange
Publisher <-- basic.return --- Exchange
RabbitMQ возвращает mandatory-сообщение, если оно не смогло быть направлено ни в одну очередь.
Важная особенность confirms #
Unroutable-сообщение может получить publisher confirm ack, потому что broker успешно обработал публикацию и установил, что подходящих очередей нет.
При mandatory=true последовательность будет такой:
1. basic.return
2. basic.ack
Следовательно:
publisher confirm ack
≠
сообщение гарантированно попало в очередь
Для проверки обеих сторон нужно сочетать:
publisher confirms
+
mandatory=true и обработчик basic.return
Publisher confirms отвечают за принятие публикации брокером, а mandatory — за обнаружение отсутствия маршрута в очередь.
7. Можно использовать Alternate Exchange #
Вместо возврата или удаления unroutable-сообщений exchange можно настроить с alternate exchange:
main.exchange
|
| нет подходящего binding
v
alternate.exchange
|
v
unrouted.queue
Это позволяет собирать сообщения с неверными routing keys или отсутствующими bindings в отдельной очереди. Сообщение, направленное через alternate exchange, считается маршрутизированным и не возвращается publisher’у по mandatory.
8. Exchange не существует #
Если publisher публикует сообщение в несуществующий exchange, RabbitMQ закрывает AMQP-канал с протокольной ошибкой.
basic.publish
exchange = unknown.exchange
|
v
channel closed
Сообщение не будет доставлено. Publisher должен:
обработать закрытие канала;
восстановить topology;
открыть новый channel;
повторить публикацию при необходимости.
Поэтому exchanges, queues и bindings обычно объявляются при запуске приложения или управляются централизованно через инфраструктурную конфигурацию.
9. Сообщение направлено в очередь, но брокер перезапустился #
Чтобы сообщение могло пережить перезапуск брокера, обычно нужны одновременно:
durable queue
+
persistent message
properties = pika.BasicProperties(
delivery_mode=2,
)
Durable-очередь восстанавливается после перезапуска, а сообщение должно быть отмечено как persistent. RabbitMQ рекомендует сочетать durable queues и persistent messages для сохранения данных.
Но это не заменяет publisher confirms: без confirm publisher не знает, успел ли RabbitMQ сохранить сообщение.
10. Quorum queue и надёжность confirm #
Для quorum queue RabbitMQ отправляет publisher confirm после того, как сообщение успешно реплицировано на большинство реплик и считается безопасным в рамках алгоритма quorum queue.
Publisher
|
v
Leader
|
+--> Replica 2
+--> Replica 3
|
v
quorum reached
|
v
publisher confirm
Это обеспечивает более сильные гарантии, чем нереплицированная classic queue.
Для важных бизнес-сообщений обычно используют:
quorum queue
persistent message
publisher confirms
manual consumer acknowledgements
11. Бизнес-операция прошла, но публикация не удалась #
Это наиболее опасная ситуация.
Например:
1. Создали платёж в PostgreSQL.
2. Транзакция успешно завершилась.
3. Попытались отправить payment.created.
4. RabbitMQ оказался недоступен.
Результат:
PostgreSQL:
payment существует
RabbitMQ:
события нет
Другие сервисы:
ничего не знают о платеже
Простой retry внутри HTTP-запроса не решает проблему полностью: процесс может упасть после commit БД, но до сохранения информации о необходимости повторной отправки.
Transactional Outbox #
Надёжное решение — сохранять бизнес-изменение и исходящее событие в одной транзакции PostgreSQL:
BEGIN
INSERT INTO payments (...);
INSERT INTO outbox (
message_id,
event_type,
payload,
status
);
COMMIT
После этого отдельный worker публикует записи из outbox:
PostgreSQL outbox
|
v
Publisher worker
|
| publisher confirm
v
RabbitMQ
После confirm запись отмечается отправленной:
UPDATE outbox
SET status = 'published'
WHERE message_id = :message_id;
Если RabbitMQ недоступен, событие остаётся в outbox и будет отправлено позже:
RabbitMQ недоступен
|
v
outbox сохраняется
|
v
следующая попытка публикации
Из-за неопределённого результата публикации outbox-worker всё равно может отправить сообщение повторно. Поэтому consumers должны дедуплицировать сообщения по message_id.
Как правильно повторять публикацию #
Не следует выполнять бесконечные немедленные retries:
while True:
await publish(message)
Это создаёт retry storm и усиливает нагрузку на недоступный RabbitMQ.
Используют:
ограниченное число быстрых попыток
exponential backoff
jitter
локальный outbox
alerts
идемпотентные consumers
Пример задержек:
1-я попытка: сразу
2-я попытка: через 1 секунду
3-я попытка: через 2 секунды
4-я попытка: через 4 секунды
5-я попытка: через 8 секунд
Для фонового publisher процесс не должен удалять сообщение из собственного надёжного хранилища, пока не получил соответствующий confirm.
Практическая конфигурация надёжной отправки #
1. Durable или quorum queue.
2. Persistent messages.
3. Publisher confirms.
4. mandatory=true либо alternate exchange.
5. Обработка basic.return.
6. Обработка nack и confirm timeout.
7. Переподключение и повторная публикация.
8. Стабильный message_id.
9. Идемпотентные consumers.
10. Transactional outbox для связи с транзакцией БД.
Итог #
Ошибка до отправки:
сообщение не попало в RabbitMQ
Publisher nack:
broker не принял ответственность
→ нужна повторная публикация
Confirm timeout или разрыв соединения:
результат неизвестен
→ повторная публикация возможна с дубликатом
Unroutable без mandatory:
сообщение может быть отброшено
Unroutable с mandatory:
сообщение возвращается publisher’у
БД закоммичена, RabbitMQ недоступен:
возникает проблема dual write
→ transactional outbox
Главное правило:
Успешный вызов basic.publish сам по себе
не гарантирует доставку сообщения.
Для надёжности нужны publisher confirms,
контроль маршрутизации и сохранённый механизм повторной отправки.
23. Что отдавать клиенту, если задача должна быть поставлена в RabbitMQ? #
Основной вариант — 202 Accepted
#
Когда HTTP-запрос только ставит фоновую задачу в RabbitMQ, клиенту обычно возвращают:
HTTP/1.1 202 Accepted
Location: /api/tasks/01K1ABCDEF123
Content-Type: application/json
{
"task_id": "01K1ABCDEF123",
"status": "queued",
"status_url": "/api/tasks/01K1ABCDEF123"
}
202 Accepted означает, что запрос принят для последующей обработки, но обработка ещё не завершена и может завершиться ошибкой. Поэтому нельзя возвращать клиенту completed или бизнес-результат только потому, что сообщение опубликовано в RabbitMQ.
HTTP request
|
v
Создание задачи
|
v
Публикация в RabbitMQ
|
v
202 Accepted + task_id
|
v
Consumer выполняет задачу позже
Что означает статус queued
#
Статус должен отражать реальное состояние:
accepted — запрос принят системой
queued — сообщение поставлено в очередь
running — consumer начал обработку
success — задача успешно выполнена
failed — задача завершилась ошибкой
Публикация сообщения не означает, что consumer уже начал работу:
Publisher confirm
= RabbitMQ принял ответственность за сообщение
Consumer ack
= consumer успешно обработал сообщение
Это два независимых механизма RabbitMQ.
Endpoint проверки статуса #
Клиенту следует вернуть идентификатор задачи, по которому можно запросить состояние:
GET /api/tasks/01K1ABCDEF123
Пока задача ожидает:
{
"task_id": "01K1ABCDEF123",
"status": "queued",
"created_at": "2026-07-28T01:30:00+04:00",
"started_at": null,
"finished_at": null,
"result": null,
"error": null
}
Во время выполнения:
{
"task_id": "01K1ABCDEF123",
"status": "running",
"started_at": "2026-07-28T01:30:02+04:00"
}
После выполнения:
{
"task_id": "01K1ABCDEF123",
"status": "succeeded",
"finished_at": "2026-07-28T01:30:08+04:00",
"result": {
"report_url": "/api/reports/842"
}
}
При ошибке:
{
"task_id": "01K1ABCDEF123",
"status": "failed",
"error": {
"code": "REPORT_GENERATION_FAILED",
"message": "Не удалось сформировать отчёт"
}
}
Когда можно возвращать 202
#
Возвращать успех клиенту нужно только после того, как приложение надёжно приняло ответственность за задачу.
Есть два основных варианта.
Вариант 1: дождаться publisher confirm #
API
|
| publish
v
RabbitMQ
|
| publisher confirm
v
API возвращает 202
RabbitMQ предупреждает, что сам факт записи данных в TCP-сокет не доказывает, что сообщение дошло до брокера и было обработано. Для надёжной публикации применяются publisher confirms.
Также желательно использовать mandatory=true, чтобы обнаруживать ситуацию, когда exchange существует, но сообщение не попало ни в одну очередь:
Publisher confirm ack
не всегда означает,
что сообщение попало в очередь
Для unroutable-сообщения RabbitMQ может сначала выполнить basic.return, а затем отправить publisher confirm. Поэтому проверяют одновременно:
publisher confirm
+
mandatory=true
+
обработку basic.return
Логика endpoint:
@app.post("/reports", status_code=202)
async def create_report(data: ReportRequest) -> TaskResponse:
task_id = generate_task_id()
message = {
"task_id": task_id,
"report_type": data.report_type,
"user_id": data.user_id,
}
try:
await rabbitmq.publish(
message,
routing_key="report.generate",
mandatory=True,
wait_for_confirm=True,
)
except UnroutableMessageError:
raise HTTPException(
status_code=503,
detail={
"code": "TASK_QUEUE_UNAVAILABLE",
"message": "Задача не была направлена в очередь",
},
)
except PublishError:
raise HTTPException(
status_code=503,
detail={
"code": "RABBITMQ_UNAVAILABLE",
"message": "Задача не была принята системой",
},
)
return TaskResponse(
task_id=task_id,
status="queued",
status_url=f"/api/tasks/{task_id}",
)
Вариант 2: transactional outbox #
Если endpoint одновременно изменяет PostgreSQL и отправляет событие, надёжнее сначала сохранить задачу и outbox-запись в одной транзакции:
BEGIN
INSERT INTO tasks (..., status='accepted');
INSERT INTO outbox (
message_id,
routing_key,
payload,
published_at
);
COMMIT
После успешного commit можно вернуть клиенту:
HTTP/1.1 202 Accepted
Даже если RabbitMQ в данный момент недоступен, задача уже надёжно сохранена в вашей базе и будет опубликована outbox-worker’ом позже:
HTTP API
|
v
PostgreSQL transaction
|
+--> tasks
+--> outbox
|
v
202 Accepted
Outbox worker
|
v
RabbitMQ
В таком варианте первоначальный статус лучше назвать accepted или pending_publish, а не queued:
{
"task_id": "01K1ABCDEF123",
"status": "accepted",
"status_url": "/api/tasks/01K1ABCDEF123"
}
После publisher confirm worker обновляет статус:
accepted -> queued
Что вернуть, если RabbitMQ недоступен #
Если outbox отсутствует и сообщение не удалось подтвердить, нельзя возвращать 202.
Подходящий ответ:
HTTP/1.1 503 Service Unavailable
Retry-After: 5
Content-Type: application/json
{
"error": {
"code": "TASK_QUEUE_UNAVAILABLE",
"message": "Сервис временно не может принять задачу"
}
}
Retry-After может сообщить клиенту, через сколько секунд допустимо повторить запрос. RFC определяет его использование с 503 Service Unavailable.
RabbitMQ также указывает, что при разрыве соединения публикации не сохраняются автоматически клиентской библиотекой для будущей отправки. Неподтверждённые сообщения нужно считать недоставленными и самостоятельно повторно публиковать, когда это безопасно.
Неопределённый результат публикации #
Возможна ситуация:
1. RabbitMQ принял сообщение.
2. Соединение оборвалось.
3. Publisher не получил confirm.
4. Приложение не знает результат.
Повторная публикация может создать дубликат. RabbitMQ рекомендует повторять неподтверждённые сообщения после восстановления соединения, но при этом consumer должен поддерживать дедупликацию или идемпотентную обработку.
Поэтому каждому заданию нужен стабильный идентификатор:
{
"message_id": "01K1ABCDEF123",
"task_id": "01K1ABCDEF123",
"type": "report.generate"
}
Consumer сохраняет обработанные message_id либо использует уникальное ограничение:
CREATE TABLE processed_messages (
message_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Защита от повторных HTTP-запросов #
Клиент может не получить 202 из-за сетевого timeout и повторить тот же запрос. Поэтому полезен Idempotency-Key:
POST /api/reports
Idempotency-Key: report-user-42-2026-07
При повторном запросе сервер возвращает прежний task_id, а не создаёт ещё одну задачу:
{
"task_id": "01K1ABCDEF123",
"status": "queued",
"status_url": "/api/tasks/01K1ABCDEF123"
}
Когда возвращать 201 Created
#
Если HTTP-запрос немедленно создаёт основной бизнес-ресурс, а RabbitMQ используется только для последующей обработки, обычно возвращают 201 Created.
Например:
POST /files
|
+--> файл сохранён
+--> запись File создана
+--> задача антивирусной проверки поставлена в очередь
Ответ:
HTTP/1.1 201 Created
Location: /api/files/842
{
"id": 842,
"status": "processing",
"scan_status": "queued"
}
Различие:
202 Accepted
Основная операция ещё не выполнена.
201 Created
Основной ресурс уже создан,
но дополнительные действия выполняются асинхронно.
RFC определяет 201 как успешное создание нового ресурса, а 202 — как принятие запроса для незавершённой обработки.
Рекомендуемая схема #
POST /api/reports
|
v
Проверка входных данных
|
v
Создание task_id
|
v
Сохранение task + outbox
|
v
COMMIT
|
v
202 Accepted
{
task_id,
status,
status_url
}
После этого:
GET /api/tasks/{task_id}
Итог #
Для фоновой задачи стандартный ответ:
HTTP/1.1 202 Accepted
Location: /api/tasks/{task_id}
{
"task_id": "01K1ABCDEF123",
"status": "queued",
"status_url": "/api/tasks/01K1ABCDEF123"
}
Но 202 допустим только когда задача действительно принята системой:
получен publisher confirm и проверена маршрутизация
или
задача надёжно сохранена в transactional outbox
Если сообщение не удалось опубликовать и оно нигде надёжно не сохранено, корректнее вернуть 503 Service Unavailable, а не создавать у клиента ложное впечатление, что задача поставлена.
24. Что делать, если в проекте нужно заменить брокер сообщений? #
Главное #
Замену брокера нельзя рассматривать как простую замену библиотеки:
aio_pika -> kafka-python
У разных брокеров отличаются:
модель хранения;
маршрутизация;
подтверждение обработки;
повторная доставка;
порядок сообщений;
retry и DLQ;
масштабирование consumers;
транзакционные возможности.
Например, RabbitMQ отслеживает подтверждение конкретной доставки через ack/nack, а Kafka хранит позицию consumer group через offsets и позволяет повторно читать записи журнала.
1. Сначала определить причину замены #
Нужно зафиксировать, какую проблему должен решить новый брокер:
Недостаточная пропускная способность
Нужно длительное хранение событий
Нужен replay
Сложная эксплуатация
Высокая стоимость
Недостаточная отказоустойчивость
Нет подходящих client libraries
Нужна интеграция с data platform
Без этого легко заменить RabbitMQ на Kafka, а затем пытаться построить в Kafka:
приоритетные task queues;
nack(requeue=True);per-message TTL;
RPC;
сложную exchange-маршрутизацию.
Или, наоборот, заменить Kafka на RabbitMQ и потерять удобное повторное чтение истории по offsets.
2. Провести инвентаризацию текущей семантики #
Перед изменением нужно описать каждую очередь или topic.
Пример:
name: report.generate
message_type: command
producer: api-service
consumers:
- report-worker
delivery:
guarantee: at-least-once
ordering: not-required
retries: 5
retry_delay: exponential
dlq: report.generate.dlq
message:
format: json
version: 2
max_size: 64KB
processing:
average_time: 2s
maximum_time: 60s
idempotent: true
Особенно важно определить:
Кто публикует?
Кто читает?
Это команда или событие?
Нужен один обработчик или несколько?
Допустимы ли дубликаты?
Нужен ли строгий порядок?
Как долго должны храниться данные?
Можно ли повторно прочитать историю?
Что происходит при ошибке?
3. Отделить бизнес-код от API брокера #
Плохой вариант:
async def create_payment(data: PaymentData) -> None:
payment = await repository.create(data)
await rabbit_channel.default_exchange.publish(
aio_pika.Message(
body=json.dumps({
"payment_id": payment.id,
}).encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
),
routing_key="payment.created",
)
Бизнес-логика напрямую зависит от RabbitMQ:
aio_pika.Message;exchange;routing_key;delivery_mode.
Лучше определить собственный интерфейс:
from dataclasses import dataclass
from typing import Any, Protocol
@dataclass(frozen=True)
class OutgoingMessage:
message_id: str
message_type: str
payload: dict[str, Any]
headers: dict[str, str]
class MessagePublisher(Protocol):
async def publish(self, message: OutgoingMessage) -> None:
...
Бизнес-сервис:
class PaymentService:
def __init__(
self,
repository: PaymentRepository,
publisher: MessagePublisher,
) -> None:
self.repository = repository
self.publisher = publisher
async def create(self, data: PaymentData) -> Payment:
payment = await self.repository.create(data)
await self.publisher.publish(
OutgoingMessage(
message_id=str(uuid4()),
message_type="payment.created",
payload={
"payment_id": payment.id,
},
headers={
"schema_version": "1",
},
)
)
return payment
Реализации:
MessagePublisher
├── RabbitMQPublisher
├── KafkaPublisher
└── DualPublisher
Так бизнес-код не знает, используется routing_key, Kafka topic или другой механизм.
4. Не пытаться создать слишком универсальную абстракцию #
Не следует скрывать все возможности брокеров за интерфейсом вроде:
publish(
exchange=None,
topic=None,
routing_key=None,
partition=None,
ttl=None,
priority=None,
consumer_group=None,
)
Это не абстракция, а объединение API всех брокеров.
Интерфейс лучше строить вокруг потребности приложения:
await event_bus.publish(event)
await task_queue.enqueue(command)
Например, события и фоновые задачи можно разделить:
class EventPublisher(Protocol):
async def publish(self, event: DomainEvent) -> None:
...
class TaskQueue(Protocol):
async def enqueue(self, task: BackgroundTask) -> None:
...
Тогда можно оставить RabbitMQ для задач, а события перенести в Kafka.
5. Зафиксировать независимый контракт сообщений #
Сообщение не должно зависеть от внутренних классов брокера.
{
"message_id": "01K1S8Y1J2TX3M4H5K6N7P8Q9R",
"message_type": "payment.completed",
"message_version": 2,
"occurred_at": "2026-07-28T02:00:00Z",
"producer": "payment-service",
"data": {
"payment_id": 451,
"amount": "35.00",
"currency": "AZN"
}
}
Необходимо сохранить стабильными:
message_id
message_type
message_version
business key
occurred_at
correlation_id
trace_id
payload
Для унификации envelope можно использовать собственный контракт либо CloudEvents — открытую спецификацию общего формата описания событий.
6. Сопоставить семантику старого и нового брокера #
Например, при переходе RabbitMQ → Kafka:
RabbitMQ Kafka
Exchange Topic или логика producer
Queue Topic + consumer group
Routing key Topic / record key / headers
Consumer ACK Commit offset
NACK + requeue Не commit offset / retry topic
DLQ Отдельный dead-letter topic
Competing consumers Одна consumer group
Несколько независимых очередей Несколько consumer groups
Prefetch poll/batch и consumer settings
Message TTL Retention или прикладная проверка
Это приблизительное, а не точное соответствие.
Kafka consumer group распределяет partitions между consumers, а её прогресс хранится в offsets. Consumer может вернуться к предыдущему offset и повторно обработать записи.
RabbitMQ использует отдельные consumer acknowledgements и publisher confirms; неподтверждённые доставки могут быть повторно переданы consumer’у.
7. Обеспечить идемпотентность consumers #
Во время миграции дубликаты практически неизбежны:
Сообщение пришло из старого брокера
+
Сообщение пришло из нового брокера
Каждое сообщение должно иметь стабильный message_id:
{
"message_id": "evt-payment-451-completed",
"message_type": "payment.completed"
}
Consumer может использовать таблицу дедупликации:
CREATE TABLE processed_messages (
consumer_name TEXT NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (consumer_name, message_id)
);
Обработка:
BEGIN
INSERT processed_messages
ON CONFLICT DO NOTHING
Если запись уже существует:
пропустить бизнес-операцию
Иначе:
выполнить бизнес-операцию
COMMIT
Нельзя рассчитывать на абсолютное отсутствие повторов ни при сбоях подтверждения RabbitMQ, ни при повторной обработке Kafka offsets. Обе модели обычно требуют идемпотентных обработчиков для надёжной семантики at-least-once.
8. Использовать outbox вместо прямой двойной публикации #
Опасная схема:
BEGIN
Изменить PostgreSQL
COMMIT
Опубликовать в старый broker
Опубликовать в новый broker
Возможны состояния:
Старый broker получил, новый не получил
Новый получил, старый не получил
БД изменилась, оба broker не получили
Надёжнее сохранить событие в outbox в одной транзакции с бизнес-данными:
BEGIN;
INSERT INTO payments (...);
INSERT INTO outbox (
message_id,
message_type,
payload,
status
) VALUES (
:message_id,
'payment.completed',
:payload,
'pending'
);
COMMIT;
После этого relay публикует сообщение:
PostgreSQL outbox
|
+--> RabbitMQ
|
+--> Kafka
Состояние публикации можно хранить отдельно:
rabbitmq_published_at
kafka_published_at
UPDATE outbox
SET kafka_published_at = NOW()
WHERE message_id = :message_id;
9. Выполнять миграцию постепенно #
Не следует одномоментно переключать все producers и consumers.
Безопасный порядок:
1. Создать новый broker и topology.
2. Добавить adapters для нового broker.
3. Запустить тестового consumer.
4. Начать двойную публикацию.
5. Сравнивать сообщения и результаты обработки.
6. Перевести один некритичный consumer.
7. Постепенно перевести остальные consumers.
8. Переключить producers.
9. Дождаться обработки старого backlog.
10. Остановить старый broker.
Это соответствует принципу постепенного вытеснения старой системы вместо рискованного big-bang-перехода.
Возможные схемы миграции #
Dual publish #
Producer публикует в оба брокера:
+--> RabbitMQ
Application Publisher -|
+--> Kafka
Преимущества:
просто понять;
можно сравнить доставку;
быстрый rollback.
Недостатки:
публикации не атомарны;
возможны дубликаты;
усложняется publisher;
нужен outbox.
Bridge #
Отдельный сервис переносит сообщения:
Producer
|
v
RabbitMQ
|
v
Bridge
|
v
Kafka
Преимущества:
producers временно не меняются;
миграция сосредоточена в одном компоненте.
Недостатки:
bridge становится критичным;
нужно преобразование семантики;
возможны задержки и дубликаты;
нужно надёжно подтверждать сообщение только после публикации в новый broker.
Dual consume #
Consumer временно читает оба источника:
RabbitMQ ----+
+--> Idempotent Consumer
Kafka -------+
Подходит для постепенного переключения publishers, но требует обязательной дедупликации.
10. Проверить retry и DLQ #
Retry нельзя переносить механически.
RabbitMQ часто использует:
main.queue
|
| ошибка
v
retry.queue с TTL
|
v
main.queue
Kafka обычно использует отдельные topics:
main-topic
|
| ошибка
v
retry-1m-topic
|
v
retry-10m-topic
|
v
dead-letter-topic
Нужно сохранить:
максимальное количество попыток
задержку между попытками
классификацию временных и постоянных ошибок
исходный message_id
причину ошибки
историю retry
Пример headers:
{
"original_message_id": "evt-451",
"retry_count": 3,
"first_failed_at": "2026-07-28T02:10:00Z",
"last_error_code": "PROVIDER_TIMEOUT"
}
11. Провести тестирование семантики, а не только подключения #
Нужно проверить:
Падение consumer до подтверждения
Падение consumer после commit БД
Разрыв соединения publisher
Повторная публикация
Недоступность части кластера
Неверный routing key или topic
Неизвестную версию сообщения
Переполнение retry/DLQ
Нарушение порядка
Долгую обработку
Consumer lag
Contract-тест должен запускаться для обоих adapters:
@pytest.mark.parametrize(
"publisher_factory",
[
rabbitmq_publisher_factory,
kafka_publisher_factory,
],
)
async def test_message_is_delivered(publisher_factory):
...
Но отдельно нужны интеграционные тесты специфичных возможностей брокера: абстракция не должна скрыть реальные различия.
12. Добавить наблюдаемость #
Во время миграции сравнивают:
Количество опубликованных сообщений
Количество подтверждённых публикаций
Количество обработанных сообщений
Количество дубликатов
Количество ошибок
Retry rate
DLQ size
Consumer lag
Время от публикации до обработки
Полезные идентификаторы:
message_id
correlation_id
causation_id
trace_id
broker
topic / queue
partition / routing_key
consumer
Пример лога:
{
"message_id": "evt-451",
"message_type": "payment.completed",
"broker": "kafka",
"topic": "payment-events",
"consumer": "notification-service",
"status": "processed"
}
13. Подготовить rollback #
Во время перехода должен существовать способ:
Остановить публикацию в новый broker
Вернуть consumers на старый broker
Повторно обработать сообщения
Восстановить неподтверждённые публикации из outbox
Сравнить расхождения
Нельзя удалять старые queues/topics сразу после переключения.
Нужно дождаться:
нулевого или допустимого backlog;
завершения retry;
истечения максимального времени обработки;
проверки DLQ;
подтверждения всех критичных consumers.
Практическая архитектура #
Business Service
|
v
MessagePublisher interface
|
v
Transactional Outbox
|
v
Outbox Relay
|
+--> RabbitMQ Adapter
|
+--> Kafka Adapter
Consumers:
RabbitMQ Consumer Adapter --+
|
v
Message Handler
|
v
Business Logic
^
|
Kafka Consumer Adapter -----+
Внутренний обработчик получает не aio_pika.IncomingMessage и не ConsumerRecord, а собственную модель:
@dataclass(frozen=True)
class IncomingMessage:
message_id: str
message_type: str
version: int
payload: dict[str, Any]
headers: dict[str, str]
Итоговый порядок #
1. Зафиксировать требования и текущую семантику.
2. Не переносить модель одного брокера в другой буквально.
3. Вынести публикацию и получение за adapters.
4. Стабилизировать контракт сообщений.
5. Добавить message_id и идемпотентность.
6. Использовать transactional outbox.
7. Запустить новый брокер параллельно.
8. Переводить consumers по одному.
9. Сравнивать результаты и метрики.
10. Сохранить возможность rollback.
11. Отключить старый брокер только после обработки backlog.
Самая частая ошибка — сначала заменить клиентскую библиотеку, а затем обнаружить, что приложение было связано не только с API RabbitMQ или Kafka, но и с конкретной моделью доставки, подтверждений, повторов и маршрутизации.
25. Что делать, если ни один message broker не подходит под требования? #
Сначала проверить сами требования #
Если «не подходит ни один брокер», обычно возможны три причины:
Требования противоречат друг другу.
Выбран неверный класс инструмента.
Один компонент пытаются использовать для нескольких разных задач.
Например, сложно одновременно получить:
глобальный строгий порядок
+ неограниченное горизонтальное масштабирование
+ минимальную задержку
+ exactly once для внешней БД
+ длительное хранение
+ низкую стоимость
Каждое требование нужно перевести в измеряемую форму:
Нагрузка: 10 000 сообщений/с
Максимальная задержка: p99 < 100 мс
Хранение: 30 дней
Размер сообщения: до 64 КБ
Порядок: по order_id
Гарантия: at least once
RPO: 0
RTO: 5 минут
Количество consumers: 20 групп
После этого требования делят на:
обязательные;
желательные;
те, которые можно реализовать на уровне приложения;
те, которыми можно пожертвовать.
Не обязательно искать один универсальный брокер #
Часто правильное решение — использовать несколько механизмов для разных типов взаимодействия:
Команды и фоновые задачи -> очередь задач
Доменные события -> журнал событий
Длительные процессы -> workflow engine
Синхронные операции -> HTTP/gRPC
Большие файлы -> S3/MinIO
Атомарность с БД -> transactional outbox
Например:
HTTP API
|
+--> PostgreSQL + Outbox
|
+--> RabbitMQ для фоновых задач
|
+--> Kafka для аналитических событий
|
+--> Temporal для длительных бизнес-процессов
Это лучше, чем заставлять один брокер одновременно быть task queue, event store, workflow engine и базой аудита.
Вариант 1: использовать PostgreSQL как очередь #
Если нагрузка умеренная, а задачи тесно связаны с транзакциями БД, отдельный брокер может быть не нужен.
Таблица задач:
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_until TIMESTAMPTZ,
worker_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE INDEX jobs_pending_idx
ON jobs (priority DESC, run_at, created_at)
WHERE status = 'pending';
Worker выбирает задачи:
WITH selected_jobs AS (
SELECT id
FROM jobs
WHERE status = 'pending'
AND run_at <= NOW()
AND (
locked_until IS NULL
OR locked_until < NOW()
)
ORDER BY priority DESC, run_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running',
worker_id = :worker_id,
locked_until = NOW() + INTERVAL '5 minutes',
attempts = attempts + 1
FROM selected_jobs
WHERE jobs.id = selected_jobs.id
RETURNING jobs.*;
SKIP LOCKED позволяет нескольким workers пропускать уже заблокированные строки; документация PostgreSQL прямо отмечает queue-like таблицы как подходящий сценарий такого механизма.
Преимущества:
задача и бизнес-данные сохраняются в одной транзакции;
не возникает dual-write между БД и брокером;
легко делать поиск и аудит;
не нужна отдельная инфраструктура;
можно реализовать приоритеты, расписание и retry.
Недостатки:
очередь нагружает основную БД;
требуется очистка завершённых задач;
сложнее добиться очень высокой пропускной способности;
нужно самостоятельно реализовать leases, retries, DLQ и мониторинг;
длительное удержание транзакций недопустимо.
Для небольших и средних backend-систем это часто практичнее отдельного брокера.
LISTEN/NOTIFY — только как сигнал пробуждения #
Можно объединить таблицу задач и PostgreSQL NOTIFY:
INSERT job
|
+--> COMMIT
|
+--> NOTIFY jobs_created
|
v
Worker просыпается
|
v
читает таблицу jobs
NOTIFY jobs_created, 'new-job';
NOTIFY предоставляет простой IPC-механизм между процессами, работающими с одной PostgreSQL, а структурированные данные документация рекомендует хранить в таблице и передавать через уведомление только ключ. Payload по умолчанию ограничен значением менее 8000 байт.
Практический вывод: NOTIFY лучше использовать не как надёжную очередь, а как необязательный сигнал, ускоряющий polling. Источником истины остаётся таблица:
Worker получил NOTIFY
-> сразу проверил jobs
Worker пропустил NOTIFY
-> периодический polling всё равно найдёт jobs
Вариант 2: Redis Streams #
Когда полноценный Kafka слишком тяжёлый, но нужны:
упорядоченный поток;
consumer groups;
acknowledgements;
повторное чтение;
обработка зависших сообщений;
ограниченный retention,
можно использовать Redis Streams.
XADD
|
v
Redis Stream
|
+--> Consumer Group A
|
+--> Consumer Group B
Redis Streams предоставляет append-only журнал, consumer groups, XACK, pending entries и механизмы XCLAIM/XAUTOCLAIM для перехвата сообщений от упавших consumers. Также поддерживается replay по идентификаторам и ограничение размера stream.
Но Redis Streams не стоит выбирать только потому, что Redis уже используется как кэш. Нужно отдельно проверить:
режим персистентности;
репликацию;
failover;
memory policy;
допустимость потери последних записей;
поведение при полном объёме памяти.
Вариант 3: workflow engine #
Иногда проекту нужен не брокер, а управление длительными процессами.
Например:
Создать заказ
|
v
Зарезервировать товар
|
v
Списать оплату
|
+-- ошибка -> отменить резерв
|
v
Организовать доставку
|
v
Ждать подтверждение несколько дней
Обычный брокер доставляет сообщения, но сам по себе не решает полностью:
хранение состояния процесса;
таймеры на часы и дни;
последовательность шагов;
compensation;
ожидание внешнего сигнала;
возобновление после падения;
управление версиями выполняющихся процессов.
Для этого больше подходит workflow engine, например Temporal. Его модель рассчитана на возобновление выполнения после падений, сетевых ошибок и инфраструктурных сбоев, включая процессы, продолжающиеся длительное время.
Broker:
доставь сообщение обработчику
Workflow engine:
надёжно выполни многошаговый процесс
и сохрани его состояние между шагами
Вариант 4: синхронный HTTP или gRPC #
Если отправителю немедленно нужен результат, асинхронный брокер может быть неверным выбором.
Service A
|
| HTTP/gRPC request
v
Service B
|
| response
v
Service A
Использовать прямой вызов разумно, когда:
операция короткая;
клиенту нужен немедленный ответ;
вызываемый сервис обязан быть доступен сейчас;
запрос имеет естественную request/response семантику;
повторное выполнение можно контролировать через idempotency key.
Нужно добавить:
timeout
ограниченный retry
circuit breaker
concurrency limit
идемпотентность
наблюдаемость
Не следует помещать любой межсервисный вызов в брокер только ради формальной «асинхронности».
Вариант 5: object storage и передача ссылки #
Когда брокеры не подходят из-за размера сообщений, обычно не нужно искать брокер с большими лимитами.
Producer
|
| файл
v
S3 / MinIO
|
| object_key
v
Очередь или таблица задач
Сообщение:
{
"job_id": "job-842",
"bucket": "imports",
"object_key": "payments/2026/07/file-842.csv",
"checksum": "sha256:..."
}
Таким способом отдельно решаются:
хранение больших данных;
доставка небольшой команды;
повторное скачивание;
контроль целостности;
retention файлов.
Вариант 6: transactional outbox и polling #
Если основная проблема — гарантировать публикацию после изменения БД, не обязательно менять брокер.
BEGIN
Изменить бизнес-данные
Добавить событие в outbox
COMMIT
Outbox relay
|
+--> выбранный брокер
|
+--> HTTP endpoint
|
+--> внутренний worker
Outbox отделяет надёжное сохранение события от способа его доставки. Позже транспорт можно заменить, не изменяя бизнес-транзакцию.
CREATE TABLE outbox (
id UUID PRIMARY KEY,
message_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 0
);
Это особенно полезно, когда ни один транспорт не может участвовать в одной атомарной транзакции с PostgreSQL.
Можно комбинировать компоненты #
Например, требования:
Атомарность с PostgreSQL
Небольшая задержка
Replay на 24 часа
Несколько независимых consumers
Без отдельного Kafka-кластера
Возможное решение:
Business transaction
|
v
PostgreSQL outbox
|
v
Outbox relay
|
v
Redis Stream
|
+--> Consumer Group A
+--> Consumer Group B
Или:
PostgreSQL jobs
|
+--> LISTEN/NOTIFY для пробуждения
|
+--> polling как гарантия
|
+--> SKIP LOCKED для распределения задач
Когда действительно писать собственное решение #
Собственная система оправдана только при уникальных требованиях, например:
специализированное оборудование или нестандартная сеть;
крайне специфичный протокол;
необычные требования к задержке;
особая модель безопасности или изоляции;
жёсткие ограничения среды;
достаточно узкая задача, которую нельзя решить существующими средствами.
Но лучше создавать узкий доменный компонент, а не новый универсальный RabbitMQ или Kafka.
Например:
Плохо:
собственный универсальный message broker
Разумнее:
специализированный scheduler заданий
для конкретного типа вычислений
Что придётся реализовать самостоятельно #
Даже узкая очередь быстро потребует:
Надёжное хранение
WAL или append-only log
fsync
Восстановление после падения
Доставка
acknowledgements
redelivery
consumer leases
deduplication
Управление нагрузкой
backpressure
rate limiting
batching
flow control
Отказоустойчивость
репликация
leader election
quorum
failover
Эксплуатация
метрики
tracing
административный API
очистка данных
обновление без остановки
Безопасность
аутентификация
авторизация
TLS
аудит
Контракты
schema versioning
совместимость
ограничения размера
Наиболее сложные ошибки проявляются не в штатной работе, а в ситуациях:
сообщение записалось, но ответ потерялся;
consumer выполнил операцию, но не подтвердил;
leader сменился во время записи;
две реплики считают себя leader;
диск заполнен;
процесс упал между двумя обновлениями;
старый consumer получил новую схему.
Поэтому собственную реализацию следует оценивать как отдельный инфраструктурный продукт, а не как несколько таблиц и background loop.
Практический алгоритм #
1. Формализовать требования числами и SLA.
2. Отделить:
команды;
события;
фоновые задачи;
длительные workflows;
большие данные;
синхронные вызовы.
3. Проверить, действительно ли всем сценариям
нужен один и тот же транспорт.
4. Перенести часть требований на приложение:
idempotency;
deduplication;
ordering по business key;
retry policy;
schema versioning.
5. Рассмотреть:
PostgreSQL job table;
transactional outbox;
Redis Streams;
workflow engine;
HTTP/gRPC;
object storage.
6. Собрать композицию из нескольких компонентов,
если один продукт не покрывает всё.
7. Собственную систему создавать только после:
прототипа;
нагрузочных тестов;
failure-тестов;
оценки стоимости разработки и поддержки.
Итог #
Если ни один брокер не подходит полностью, правильный вывод обычно не «нужно написать свой Kafka», а:
Либо требования нужно пересмотреть,
либо задача на самом деле требует
не брокера, а другого класса системы,
либо архитектуру нужно разделить
на несколько специализированных компонентов.
На практике наиболее безопасная основа для нестандартных требований:
PostgreSQL как источник истины
+
Transactional Outbox
+
идемпотентные обработчики
+
специализированный транспорт для каждого сценария
Собственный брокер остаётся последним вариантом, когда требования действительно уникальны и команда готова постоянно поддерживать распределённую инфраструктурную систему.
26. Можно ли инкапсулировать запуск Celery-задач за собственным интерфейсом? | Почему бизнес-сервис не должен напрямую зависеть от task.delay()? #
Да, запуск Celery-задач стоит инкапсулировать #
Вместо прямой зависимости:
from app.tasks import generate_report
class ReportService:
def create_report(self, user_id: int) -> None:
...
generate_report.delay(user_id)
бизнес- или application-сервису лучше зависеть от собственного интерфейса:
ReportService
|
v
ReportTaskDispatcher
|
v
CeleryReportTaskDispatcher
|
v
Celery / RabbitMQ
Celery определяет delay() как сокращение для apply_async(). При этом delay() не позволяет задавать дополнительные параметры выполнения вроде очереди, countdown, eta, expires и собственного task_id.
Почему прямой вызов task.delay() создаёт проблему
#
Бизнес-код начинает зависеть от Celery #
generate_report.delay(report_id)
Теперь сервис знает:
что используется Celery;
где объявлена Celery task;
как она запускается;
что у task существует метод
delay;какие аргументы принимает транспортная задача.
То есть бизнес-сервис зависит не от бизнес-возможности «запустить формирование отчёта», а от конкретного инфраструктурного фреймворка.
Нужная зависимость:
ReportService -> интерфейс фоновой обработки
Фактическая зависимость:
ReportService -> Celery Task -> Celery -> broker
При переходе на RabbitMQ без Celery, Kafka, Temporal, PostgreSQL job queue или синхронное выполнение придётся изменять бизнес-сервис.
Инфраструктурные настройки распространяются по коду #
Сначала появляется:
generate_report.delay(report_id)
Позже требуется отдельная очередь:
generate_report.apply_async(
args=[report_id],
queue="reports",
)
Затем появляются:
generate_report.apply_async(
args=[report_id],
queue="reports",
priority=5,
expires=300,
retry=True,
retry_policy={
"max_retries": 3,
},
)
Celery действительно предоставляет через apply_async() параметры маршрутизации, времени выполнения, expiration и повторных попыток публикации. Поведение retry отправки также можно конфигурировать.
Если такие вызовы находятся в десятках сервисов, изменить имя очереди, retry policy или формат аргументов становится сложно.
Усложняются модульные тесты #
При прямой зависимости тест должен подменять Celery task:
@patch("app.services.reports.generate_report.delay")
def test_create_report(delay_mock):
service.create_report(user_id=10)
delay_mock.assert_called_once()
Такой тест зависит от:
пути импорта;
имени task;
метода
delay;устройства конкретного модуля.
При собственном интерфейсе используется обычный fake:
class FakeReportDispatcher:
def __init__(self) -> None:
self.report_ids: list[int] = []
def enqueue(self, report_id: int) -> None:
self.report_ids.append(report_id)
def test_create_report() -> None:
dispatcher = FakeReportDispatcher()
service = ReportService(
repository=FakeReportRepository(),
dispatcher=dispatcher,
)
report = service.create_report(user_id=10)
assert dispatcher.report_ids == [report.id]
Тест проверяет намерение бизнес-сервиса, а не внутреннее API Celery.
Интерфейс должен выражать бизнес-операцию #
Слишком общий интерфейс:
class TaskDispatcher(Protocol):
def send(
self,
task_name: str,
args: list,
queue: str,
countdown: int | None = None,
) -> str:
...
Формально Celery скрыт, но бизнес-сервис всё ещё оперирует:
именами задач;
очередями;
сериализованными аргументами;
инфраструктурными параметрами.
Лучше сделать предметный интерфейс:
from typing import Protocol
from uuid import UUID
class ReportJobDispatcher(Protocol):
def enqueue_generation(self, report_id: UUID) -> str:
...
Бизнес-сервис:
from dataclasses import dataclass
from uuid import UUID
@dataclass(frozen=True)
class CreateReportCommand:
user_id: UUID
report_type: str
class ReportService:
def __init__(
self,
repository: "ReportRepository",
jobs: ReportJobDispatcher,
) -> None:
self._repository = repository
self._jobs = jobs
def create(self, command: CreateReportCommand) -> "Report":
report = self._repository.create(
user_id=command.user_id,
report_type=command.report_type,
status="pending",
)
self._jobs.enqueue_generation(report.id)
return report
Сервис говорит:
«Поставить формирование отчёта в фоновую обработку»
а не:
«Вызвать Celery task через delay».
Celery-адаптер #
from uuid import UUID
from app.tasks.reports import generate_report_task
class CeleryReportJobDispatcher:
def enqueue_generation(self, report_id: UUID) -> str:
result = generate_report_task.apply_async(
kwargs={
"report_id": str(report_id),
},
queue="reports",
expires=3600,
retry=True,
retry_policy={
"max_retries": 3,
"interval_start": 0,
"interval_step": 1,
"interval_max": 5,
},
)
return result.id
В этом классе допустимы все детали Celery:
apply_async;очередь;
маршрутизация;
expiration;
retry публикации;
сериализация UUID;
работа с
AsyncResult.
Они локализованы в инфраструктурном слое.
Сама Celery task должна быть тонкой #
Celery task желательно рассматривать как входной адаптер:
from uuid import UUID
from app.celery_app import celery_app
from app.container import container
@celery_app.task(
bind=True,
autoretry_for=(TemporaryReportError,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
)
def generate_report_task(
self,
report_id: str,
) -> None:
service = container.report_generation_service()
service.generate(
report_id=UUID(report_id),
)
Основная бизнес-логика находится не внутри task:
class ReportGenerationService:
def __init__(
self,
reports: "ReportRepository",
storage: "ReportStorage",
) -> None:
self._reports = reports
self._storage = storage
def generate(self, report_id: UUID) -> None:
report = self._reports.get(report_id)
content = build_report(report)
object_key = self._storage.save(
report_id=report_id,
content=content,
)
self._reports.mark_completed(
report_id=report_id,
object_key=object_key,
)
Celery поддерживает Task.retry() и автоматический retry через autoretry_for, retry_backoff и другие параметры. Эти настройки относятся к выполнению фоновой задачи и поэтому логично остаются в Celery-адаптере, а не внутри независимой бизнес-логики.
Получается направление зависимостей:
Celery task
|
v
Application service
|
v
Domain / repositories
а не наоборот:
Business service
|
v
Celery task.delay()
Не возвращать AsyncResult из бизнес-интерфейса
#
Плохой интерфейс:
from celery.result import AsyncResult
class ReportJobDispatcher(Protocol):
def enqueue_generation(
self,
report_id: UUID,
) -> AsyncResult:
...
Celery снова проникает в вызывающий код.
Лучше вернуть собственную модель:
from dataclasses import dataclass
@dataclass(frozen=True)
class JobReference:
job_id: str
class ReportJobDispatcher(Protocol):
def enqueue_generation(
self,
report_id: UUID,
) -> JobReference:
...
Адаптер:
class CeleryReportJobDispatcher:
def enqueue_generation(
self,
report_id: UUID,
) -> JobReference:
result = generate_report_task.apply_async(
kwargs={"report_id": str(report_id)},
queue="reports",
)
return JobReference(job_id=result.id)
Теперь остальной проект не импортирует celery.result.AsyncResult.
Где создавать зависимость #
Например, через dependency injection:
def build_report_service() -> ReportService:
repository = SqlAlchemyReportRepository(session_factory)
dispatcher = CeleryReportJobDispatcher()
return ReportService(
repository=repository,
jobs=dispatcher,
)
В тестах:
service = ReportService(
repository=FakeReportRepository(),
jobs=FakeReportDispatcher(),
)
При замене Celery:
service = ReportService(
repository=SqlAlchemyReportRepository(session_factory),
jobs=KafkaReportJobDispatcher(),
)
Сам ReportService не меняется.
Интерфейс не решает проблему транзакционности #
Рассмотрим код:
report = repository.create(...)
jobs.enqueue_generation(report.id)
Возможен сбой:
1. Отчёт записан в PostgreSQL.
2. Транзакция закоммичена.
3. Публикация Celery-задачи не удалась.
4. Отчёт навсегда остался в pending.
Собственный интерфейс уменьшает связанность, но не делает запись в PostgreSQL и публикацию в RabbitMQ атомарными.
Для критичных задач нужен transactional outbox:
BEGIN
INSERT INTO reports (...);
INSERT INTO outbox (
message_id,
message_type,
payload
);
COMMIT
После этого отдельный relay вызывает Celery-адаптер:
PostgreSQL outbox
|
v
CeleryReportJobDispatcher
|
v
Celery / RabbitMQ
В таком варианте бизнес-сервис может зависеть не от Celery dispatcher, а от интерфейса outbox:
class Outbox(Protocol):
def add(self, message: OutboxMessage) -> None:
...
И сохранять отчёт и outbox-запись в одной транзакции.
Когда прямой delay() допустим
#
Прямой вызов не является технической ошибкой сам по себе:
send_email_task.delay(user.id)
Он допустим, когда:
проект небольшой;
Celery считается постоянной частью архитектуры;
задача вызывается из контроллера или инфраструктурного обработчика;
не требуется сложная тестируемость;
нет бизнес-транзакции, связанной с публикацией;
количество вызовов невелико.
Но даже в небольшом проекте лучше не помещать его глубоко в доменные сущности:
class Order:
def pay(self):
...
send_receipt.delay(self.id) # плохо
Допустимее держать вызов на границе приложения:
@app.post("/orders/{order_id}/pay")
def pay_order(order_id: UUID):
order_service.pay(order_id)
send_receipt_task.delay(str(order_id))
Ещё лучше — вынести в application service с интерфейсом или использовать outbox.
Итоговая структура #
application/
services/
report_service.py
ports/
report_jobs.py
infrastructure/
celery/
dispatchers.py
tasks.py
domain/
reports.py
ReportService
|
| зависит от Protocol
v
ReportJobDispatcher
^
|
CeleryReportJobDispatcher
|
v
generate_report_task.apply_async()
Основная идея:
Бизнес-сервис должен зависеть от намерения:
«запланировать формирование отчёта»
а не от способа доставки:
«вызвать Celery task через delay».
Инкапсуляция даёт локализованные настройки Celery, простые unit-тесты, возможность заменить транспорт и не позволяет инфраструктурным объектам вроде Task и AsyncResult распространяться по бизнес-коду. При этом для атомарности с базой данных дополнительно требуется outbox — один интерфейс эту проблему не решает.
27. Когда Celery-задача считается запущенной? #
Когда задача действительно считается запущенной #
Celery-задача считается фактически запущенной, когда worker передал её в исполнительный процесс или поток и начал выполнение тела задачи — фактически дошёл до вызова Task.run().
Вызов:
result = generate_report.delay(report_id)
сам по себе задачу не запускает. Он только формирует сообщение и публикует его через брокер. delay() является сокращением для apply_async().
Этапы выполнения #
delay() / apply_async()
|
v
Сообщение опубликовано в брокер
|
v
Ожидает в очереди
|
v
Worker получил сообщение
|
v
Worker зарезервировал задачу
|
v
Задача передана в execution pool
|
v
Началось выполнение Task.run()
|
v
SUCCESS / FAILURE / RETRY
1. Задача отправлена #
task.delay(...)
На этом этапе задача только опубликована:
Состояние по смыслу: sent / queued
Она может ещё находиться в RabbitMQ, а свободного worker’а может не быть.
Сигнал after_task_publish означает, что задача отправлена брокеру, но не означает, что worker уже начал её выполнять.
2. Worker получил задачу #
В логах появляется:
Task app.tasks.generate_report[task-id] received
Это ещё не обязательно означает запуск бизнес-кода. Worker мог получить задачу заранее из-за prefetch и оставить её в списке reserved. Celery определяет reserved tasks как полученные worker’ом, но всё ещё ожидающие выполнения.
received ≠ started
reserved ≠ started
Например:
Worker concurrency: 1
Prefetch: 4
Task A -> active
Task B -> reserved
Task C -> reserved
Task D -> reserved
Задачи B–D уже находятся у worker’а, но выполняется только A.
3. Задача стала active #
Когда задача передана исполнительному процессу и начинается вызов её кода, она считается запущенной:
reserved -> active
Команда:
celery -A app inspect active
показывает задачи, которые в данный момент выполняются. inspect reserved показывает полученные, но ещё не выполняющиеся задачи.
Непосредственно перед выполнением задачи Celery отправляет сигнал task_prerun; согласно документации, он возникает перед вызовом тела задачи.
Состояние STARTED
#
По умолчанию Celery обычно не сохраняет промежуточное состояние STARTED. Поэтому задача может выглядеть так:
PENDING -> SUCCESS
или:
PENDING -> FAILURE
Хотя фактически некоторое время она выполнялась. Отслеживание STARTED отключено по умолчанию и включается через task_track_started или параметр конкретной задачи.
Глобально:
app.conf.task_track_started = True
Для отдельной задачи:
@app.task(track_started=True)
def generate_report(report_id: int) -> None:
...
Тогда при наличии result backend:
result = generate_report.delay(42)
print(result.state)
можно увидеть последовательность:
PENDING
STARTED
SUCCESS
Состояние STARTED означает, что задача была запущена worker’ом. В metadata также может находиться информация о worker-процессе, выполняющем задачу.
PENDING не доказывает, что задача находится в очереди
#
Celery не записывает состояние в result backend в момент обычной отправки задачи. Поэтому PENDING может означать несколько ситуаций:
задача ожидает выполнения;
задача уже выполняется, но track_started выключен;
задача ещё не была опубликована;
указан неизвестный task_id;
result backend недоступен;
результат задачи не сохраняется.
Celery трактует task ID без сохранённой истории как PENDING, поэтому это состояние нельзя использовать как точное доказательство нахождения сообщения в RabbitMQ.
Подтверждение сообщения не равно запуску #
RabbitMQ acknowledgement и запуск Celery-задачи — разные события.
По умолчанию Celery использует раннее подтверждение: сообщение подтверждается непосредственно перед выполнением задачи. При task_acks_late=True сообщение подтверждается после выполнения.
acks_late=False:
получение -> резервирование -> ACK -> выполнение
acks_late=True:
получение -> резервирование -> выполнение -> ACK
Поэтому наличие ack не является универсальным способом определить, выполняется ли задача сейчас.
Задачи с countdown или eta
#
task.apply_async(
args=[report_id],
countdown=300,
)
Worker может заранее получить такую задачу и поместить её в список scheduled:
получена worker’ом
|
v
scheduled / ожидание ETA
|
v
active / выполнение
Она считается запущенной только после наступления времени выполнения и передачи в execution pool. Celery отдельно различает scheduled, reserved и active tasks.
Практический вывод #
delay() успешно завершился
= задача отправлена или поставлена на публикацию;
сообщение находится в RabbitMQ
= задача ожидает доставки;
worker написал received
= задача получена, но может ещё ожидать;
задача находится в inspect active
= задача выполняется;
AsyncResult.state == STARTED
= worker сообщил о начале выполнения,
если включён task_track_started.
Для собственного статуса задачи обычно используют:
accepted -> queued -> running -> succeeded
-> failed
Где running устанавливается worker’ом непосредственно перед началом бизнес-операции, а не HTTP-приложением после вызова delay().
28. Что запускается, когда Celery worker берёт задачу из очереди? #
Когда Celery worker берёт сообщение из очереди, он не запускает функцию из сообщения напрямую и обычно не создаёт новый процесс специально для этой задачи.
Worker:
1. Получает сообщение из брокера.
2. Находит зарегистрированный объект Task по имени.
3. Создаёт объект Request с аргументами и метаданными.
4. Передаёт задачу в execution pool.
5. Запускает Celery-обёртку выполнения.
6. Обёртка вызывает Task.run().
7. Task.run() выполняет исходную Python-функцию.
У класса Task метод run() является телом задачи, выполняемым worker’ом.
Что находится в сообщении #
Предположим, объявлена задача:
@app.task
def generate_report(report_id: int) -> str:
return f"report-{report_id}.pdf"
Вызов:
generate_report.delay(42)
отправляет в брокер не Python-функцию и не исполняемый код, а сообщение примерно такого содержания:
{
"task": "app.tasks.generate_report",
"id": "762e7474-7fa7-4c44-8782-b57c5d133ad7",
"args": [42],
"kwargs": {},
"retries": 0
}
Worker уже должен иметь эту задачу импортированной и зарегистрированной под именем app.tasks.generate_report. Посмотреть зарегистрированные задачи можно через celery inspect registered.
Что происходит внутри worker #
RabbitMQ
|
| сообщение с именем задачи и аргументами
v
Celery Consumer
|
| поиск в registry
v
Task instance
|
| создание Request
v
Execution Pool
|
| tracer
v
Task.run(*args, **kwargs)
1. Consumer получает сообщение #
Главный процесс worker’а содержит consumer, подписанный на одну или несколько очередей:
celery -A app worker -Q reports,emails
Когда RabbitMQ доставляет сообщение, Celery десериализует его и извлекает:
task name
task id
args
kwargs
headers
ETA
expires
retries
routing information
При получении формируется Request — внутреннее представление конкретного запуска задачи. В нём находятся идентификатор, аргументы, количество повторов, hostname worker’а, информация о доставке, callbacks, time limits и другие параметры.
2. Worker ищет задачу в registry #
Упрощённо:
task = app.tasks[message["task"]]
Например:
task = app.tasks["app.tasks.generate_report"]
Это объект, являющийся экземпляром подкласса celery.app.task.Task.
Если worker не импортировал модуль задачи и имя отсутствует в registry, задача не выполняется. Celery считает её неизвестной и отправляет сигнал task_unknown.
Типичная ошибка:
Received unregistered task of type
'app.tasks.generate_report'
3. Создаётся Task Request #
Request представляет не сам тип задачи, а конкретную попытку её выполнения:
Task:
app.tasks.generate_report
Request:
task_id = 762e7474-...
args = (42,)
kwargs = {}
retries = 0
hostname = celery@worker-1
Один зарегистрированный объект Task может последовательно обработать множество разных requests.
Внутри задачи request доступен через:
@app.task(bind=True)
def generate_report(self, report_id: int) -> None:
print(self.request.id)
print(self.request.retries)
print(self.request.delivery_info)
Task.request содержит состояние именно текущего выполнения.
4. Задача передаётся в execution pool #
Главный процесс worker’а обычно не выполняет бизнес-код самостоятельно. Он отправляет задачу в настроенный пул.
По умолчанию используется prefork:
Celery worker — главный процесс
├── child process 1
├── child process 2
├── child process 3
└── child process 4
celery -A app worker --concurrency=4
При prefork конкретная задача выполняется в одном из уже существующих дочерних процессов. Новый процесс для каждой задачи не создаётся. Celery также поддерживает threads, eventlet, gevent, solo и custom pools.
В зависимости от --pool тело задачи выполняется:
prefork -> в дочернем процессе;
threads -> в потоке;
gevent -> в greenlet;
eventlet -> в greenlet;
solo -> в главном потоке worker’а.
5. Запускается Celery tracer #
Celery не вызывает пользовательскую функцию «голым» вызовом. Сначала запускается внутренняя обёртка выполнения — tracer.
Она отвечает за:
создание контекста текущей задачи;
измерение времени;
отправку сигналов;
установку
STARTED, если включено;обработку результата;
обработку исключений;
RETRY,FAILURE,SUCCESS;callbacks и errbacks;
запись результата в result backend;
логирование.
Модуль celery.app.trace описан как центральная часть выполнения задачи: он перехватывает исключения и обновляет result backend соответствующим состоянием.
Упрощённо:
def trace_task(task, task_id, args, kwargs, request):
try:
result = task.run(*args, **kwargs)
except Retry:
set_state("RETRY")
except Exception as exc:
set_state("FAILURE")
else:
set_state("SUCCESS")
return result
Реальная реализация значительно сложнее.
6. Вызывается Task.run()
#
Для обычной задачи:
@app.task
def generate_report(report_id: int) -> str:
return f"report-{report_id}.pdf"
Celery создаёт объект задачи, чей run() вызывает исходную функцию:
Task.run(42)
|
v
generate_report(42)
Концептуально это можно представить так:
class GenerateReportTask(Task):
def run(self, report_id: int) -> str:
return generate_report(report_id)
Документация Celery прямо определяет Task.run() как тело задачи, исполняемое workers. При вызове объекта задачи применяется run(), если только пользователь не переопределил __call__().
Внутренняя оптимизированная реализация tracer может вызвать task.run() напрямую, если у задачи нет собственного переопределённого __call__():
обычная Task:
tracer -> task.run()
Task с кастомным __call__:
tracer -> task.__call__() -> task.run()
Поэтому пользовательский код обычно начинается именно внутри run().
Что происходит с bind=True
#
Задача:
@app.task(bind=True)
def generate_report(self, report_id: int) -> None:
print(self.request.id)
не превращает self в worker или Request. self — это объект Celery Task:
self
-> экземпляр Task
self.request
-> Request текущего выполнения
Фактический вызов логически выглядит так:
task.run(42)
При этом связанный метод автоматически передаёт объект task как self.
Сигналы вокруг выполнения #
Перед телом задачи отправляется:
task_prerun
После завершения попытки:
task_postrun
Также могут отправляться:
task_success
task_failure
task_retry
task_internal_error
task_prerun отправляется непосредственно перед выполнением задачи, а task_postrun — после выполнения.
Пример:
from celery.signals import task_postrun, task_prerun
@task_prerun.connect
def before_task(task_id=None, task=None, **kwargs) -> None:
print(f"Starting {task.name}: {task_id}")
@task_postrun.connect
def after_task(
task_id=None,
task=None,
state=None,
**kwargs,
) -> None:
print(f"Finished {task.name}: {state}")
Порядок можно представить так:
task_prerun
|
v
Task.run()
|
v
обработка результата или исключения
|
v
task_success / task_failure / task_retry
|
v
task_postrun
Что происходит при успешном возврате #
@app.task
def add(a: int, b: int) -> int:
return a + b
При вызове:
add.delay(2, 3)
worker выполняет:
result = task.run(2, 3)
После возврата 5 tracer:
1. Сохраняет результат в result backend, если он используется.
2. Устанавливает состояние SUCCESS.
3. Вызывает success hooks.
4. Запускает callbacks или следующую задачу chain.
5. Подтверждает сообщение в соответствии с ack-настройками.
Celery tracer сохраняет успешный результат и устанавливает состояние SUCCESS.
Что происходит при исключении #
@app.task
def generate_report(report_id: int) -> None:
raise RuntimeError("Generation failed")
Исключение перехватывается не worker-процессом вручную, а tracer:
Task.run()
|
| RuntimeError
v
Celery tracer
|
+--> FAILURE
+--> сохранение exception/traceback
+--> on_failure
+--> task_failure
Если задача вызывает:
raise self.retry(countdown=30, exc=exc)
Celery воспринимает Retry как специальный управляющий исход, переводит задачу в RETRY и публикует новую попытку. Tracer различает успешное завершение, Retry и обычное исключение.
Когда отправляется ACK RabbitMQ #
Это зависит от acks_late.
По умолчанию:
task_acks_late = False
Условная последовательность:
worker получил задачу
|
v
перед выполнением ACK
|
v
Task.run()
При:
task_acks_late = True
последовательность меняется:
worker получил задачу
|
v
Task.run()
|
v
завершение попытки
|
v
ACK
Celery указывает, что при acks_late=True сообщение подтверждается после выполнения, а не перед ним. Это повышает вероятность повторной доставки при падении процесса, поэтому задача должна быть идемпотентной.
Что именно «запускается» #
Для такой задачи:
@app.task
def send_email(user_id: int) -> None:
service = build_email_service()
service.send_to_user(user_id)
когда worker берёт её в исполнение, запускается следующая цепочка:
Celery execution tracer
|
v
объект Task
|
v
Task.run(user_id)
|
v
исходная функция send_email(user_id)
|
v
build_email_service()
|
v
service.send_to_user(user_id)
То есть непосредственно ваш код начинается с тела функции:
service = build_email_service()
Что не запускается заново для каждой задачи #
Обычно для каждой задачи не запускаются заново:
Celery worker;
новый контейнер;
новый Python-интерпретатор;
новый prefork-процесс;
весь модуль tasks.py;
FastAPI/Django-приложение с нуля.
Worker и дочерние процессы уже запущены, модули задач импортированы, а объекты Task зарегистрированы. Конкретный pool slot получает новый Request и вызывает тело зарегистрированной задачи.
Итоговая цепочка #
RabbitMQ доставляет сообщение
|
v
Celery Consumer десериализует его
|
v
По имени находится зарегистрированный Task
|
v
Создаётся Request конкретного запуска
|
v
Request передаётся в execution pool
|
v
Запускается celery.app.trace
|
v
Отправляется task_prerun
|
v
Вызывается Task.run(*args, **kwargs)
|
v
Выполняется исходная Python-функция
|
v
SUCCESS / FAILURE / RETRY
|
v
Сохраняется результат и отправляется task_postrun
|
v
Сообщение подтверждается согласно ack-настройкам
Главное различие:
Worker получил сообщение
— Celery создал Request и подготовил выполнение.
Worker начал задачу
— execution pool запустил tracer,
который вызвал Task.run() и тело вашей функции.
29. Что такое Celery app и зачем он нужен? #
Что такое Celery app #
Celery app — это экземпляр класса Celery, который является центральным объектом конфигурации и управления Celery в приложении.
from celery import Celery
celery_app = Celery(
"my_project",
broker="amqp://guest:guest@localhost:5672//",
backend="redis://localhost:6379/0",
)
Celery необходимо создать такой экземпляр перед использованием. В официальной документации он называется Celery application, или сокращённо app. Через него создаются задачи, загружается конфигурация и запускаются компоненты Celery.
Упрощённо Celery app можно представить как центральный контейнер:
Celery app
├── конфигурация
├── реестр задач
├── настройки брокера
├── настройки result backend
├── сериализация
├── маршрутизация
├── управление workers
└── служебные компоненты Celery
Celery app — не broker и не worker #
Нужно различать три сущности:
Celery app
Конфигурация и программный интерфейс Celery.
Broker
RabbitMQ, Redis или другой транспорт сообщений.
Worker
Отдельный процесс, получающий и выполняющий задачи.
Схема взаимодействия:
Web-приложение
|
| использует Celery app
v
RabbitMQ
|
| сообщение о задаче
v
Celery worker
|
| загружает тот же Celery app
v
Выполняет зарегистрированную задачу
Celery app хранит информацию о том, через какой broker отправлять задачи и где, при необходимости, хранить их результаты. Сам экземпляр Celery не является очередью и самостоятельно не выполняет фоновые задачи.
Зачем нужен Celery app #
1. Хранит конфигурацию #
Настройки доступны через:
celery_app.conf
Например:
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Asia/Baku",
enable_utc=True,
task_track_started=True,
)
Через конфигурацию задаются:
адрес broker;
result backend;
сериализаторы;
очереди и маршрутизация;
подтверждение задач;
prefetch;
time limits;
retry;
timezone;
параметры Celery Beat;
параметры результатов.
Конфигурацию можно устанавливать непосредственно на app или загружать из отдельного модуля через config_from_object(). Celery рекомендует централизовать настройки, особенно маршрутизацию и периодические задачи.
Пример:
# celery_app.py
from celery import Celery
celery_app = Celery("my_project")
celery_app.config_from_object("app.config.celery")
# app/config/celery.py
broker_url = "amqp://guest:guest@rabbitmq:5672//"
result_backend = "redis://redis:6379/0"
task_serializer = "json"
result_serializer = "json"
accept_content = ["json"]
timezone = "Asia/Baku"
enable_utc = True
2. Хранит реестр задач #
Каждая Celery-задача регистрируется в конкретном Celery app:
@celery_app.task
def add(x: int, y: int) -> int:
return x + y
После регистрации:
print(add.name)
# app.tasks.add
print(celery_app.tasks["app.tasks.add"])
# <@task: app.tasks.add>
При отправке в RabbitMQ передаётся не исходный Python-код функции, а имя задачи и её аргументы:
{
"task": "app.tasks.add",
"id": "71959f5b-...",
"args": [2, 3],
"kwargs": {}
}
Worker загружает Celery app, обращается к его registry и по имени находит соответствующий объект Task. Поэтому producer и worker должны согласованно импортировать задачи и использовать корректные имена.
Упрощённо:
Сообщение:
task = "app.tasks.add"
Celery app registry:
"app.tasks.add" -> объект Task -> функция add()
3. Превращает функцию в Celery Task #
Декоратор:
@celery_app.task
def generate_report(report_id: int) -> None:
...
создаёт не просто функцию, а объект Celery Task, связанный с конкретным app.
У него появляются методы:
generate_report.delay(...)
generate_report.apply_async(...)
generate_report.s(...)
generate_report.signature(...)
И параметры:
generate_report.name
generate_report.app
generate_report.request
generate_report.max_retries
Задача привязывается к app, чтобы получать из него значения конфигурации и настройки по умолчанию. В частности, задачи, созданные через app.task(), наследуются от базового класса Task, принадлежащего этому приложению.
4. Отправляет задачи в broker #
Когда вызывается:
generate_report.delay(42)
фактически используется app, к которому привязана задача:
Task
|
v
Celery app
|
| читает broker_url, serializer, routing
v
RabbitMQ
delay() является сокращённой формой apply_async(). App определяет:
куда подключаться;
как сериализовать аргументы;
какой exchange и routing key использовать;
в какую очередь направить задачу;
нужно ли повторять публикацию;
какой идентификатор задачи использовать.
Задачу можно отправить и без импорта объекта Task, используя app:
celery_app.send_task(
"app.tasks.generate_report",
args=[42],
queue="reports",
)
Однако вызов через зарегистрированный объект задачи обычно безопаснее, поскольку меньше вероятность ошибиться в имени и сигнатуре.
5. Сообщает worker, какие настройки использовать #
Worker запускается с указанием Celery app:
celery -A app.celery_app:celery_app worker --loglevel=INFO
Здесь:
app.celery_app
Python-модуль.
:celery_app
объект Celery внутри модуля.
Worker импортирует этот объект и получает из него:
broker URL
result backend
реестр задач
очереди
маршрутизацию
сериализаторы
ack-настройки
concurrency-настройки
task limits
В простом случае команда может выглядеть так:
celery -A app.celery_app worker -l INFO
Celery app должен находиться в импортируемом модуле, поскольку он является точкой входа для worker и других компонентов Celery.
6. Подключает result backend #
Если настроено:
celery_app = Celery(
"my_project",
broker="amqp://rabbitmq//",
backend="redis://redis:6379/0",
)
app знает, где сохранять:
состояние задачи;
результат;
исключение;
traceback;
метаданные выполнения.
result = generate_report.delay(42)
print(result.id)
print(result.state)
Result backend необязателен. Без него задачи всё равно могут выполняться, но отслеживание состояния и получение результата через AsyncResult будут ограничены.
7. Управляет маршрутизацией #
Через app можно централизованно указать, куда отправлять разные типы задач:
celery_app.conf.task_routes = {
"app.tasks.generate_report": {
"queue": "reports",
"routing_key": "reports.generate",
},
"app.tasks.send_email": {
"queue": "emails",
"routing_key": "emails.send",
},
}
Схема:
generate_report
|
v
reports queue
|
v
Report workers
send_email
|
v
emails queue
|
v
Email workers
Бизнес-коду тогда не нужно указывать имя очереди в каждом вызове:
generate_report.delay(report_id)
Маршрутизация остаётся в конфигурации Celery app.
8. Используется Celery Beat #
Celery Beat также загружает app:
celery -A app.celery_app:celery_app beat -l INFO
Расписание может храниться в его конфигурации:
from datetime import timedelta
celery_app.conf.beat_schedule = {
"cleanup-expired-files": {
"task": "app.tasks.cleanup_expired_files",
"schedule": timedelta(hours=1),
},
}
Beat использует app, чтобы определить:
какие задачи существуют;
куда их публиковать;
какой broker использовать;
когда запускать каждую задачу.
Типичная структура проекта #
app/
├── celery_app.py
├── config/
│ └── celery.py
├── tasks/
│ ├── __init__.py
│ ├── reports.py
│ └── emails.py
├── services/
│ ├── reports.py
│ └── emails.py
└── main.py
# app/celery_app.py
from celery import Celery
celery_app = Celery("my_project")
celery_app.config_from_object("app.config.celery")
celery_app.autodiscover_tasks(
[
"app.tasks",
]
)
# app/tasks/reports.py
from app.celery_app import celery_app
from app.services.reports import build_report_service
@celery_app.task(
bind=True,
autoretry_for=(ConnectionError,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
)
def generate_report(
self,
report_id: int,
) -> None:
service = build_report_service()
service.generate(report_id)
Запуск worker:
celery -A app.celery_app:celery_app worker -Q reports -l INFO
Что означает имя в Celery("my_project")
#
celery_app = Celery("my_project")
Строка "my_project" — основное имя приложения. Она помогает Celery генерировать имена задач, когда невозможно надёжно определить модуль функции, например при запуске кода как __main__. (
Документация Celery)
Например:
celery_app = Celery("my_project")
@celery_app.task
def add(x: int, y: int) -> int:
return x + y
В некоторых контекстах имя задачи может быть построено как:
my_project.add
В обычных импортируемых модулях Celery чаще использует полный путь модуля:
app.tasks.math.add
Для важных контрактов имя можно задать явно:
@celery_app.task(name="reports.generate")
def generate_report(report_id: int) -> None:
...
Можно ли иметь несколько Celery app #
Да. В одном Python-процессе могут существовать несколько экземпляров Celery с разными:
настройками;
задачами;
brokers;
result backends;
компонентами.
Celery app является thread-safe, и официальная документация допускает сосуществование нескольких приложений в одном процессе.
reports_app = Celery(
"reports",
broker="amqp://reports-rabbitmq//",
)
analytics_app = Celery(
"analytics",
broker="redis://analytics-redis/0",
)
На практике в одном сервисе обычно используют один явно созданный app, поскольку несколько apps усложняют регистрацию задач и понимание того, к какому экземпляру привязана задача.
current_app
#
Celery предоставляет proxy:
from celery import current_app
Он указывает на текущий Celery app. Но официальный подход — явно передавать экземпляр app компонентам, которым он нужен, вместо скрытой зависимости от current_app. Документация называет явную передачу app лучшей практикой.
Предпочтительнее:
class TaskInspector:
def __init__(self, app: Celery) -> None:
self._app = app
чем:
from celery import current_app
class TaskInspector:
def inspect(self):
return current_app.control.inspect()
Для прикладного бизнес-кода лучше вообще не зависеть напрямую ни от current_app, ни от Celery app, а использовать собственный интерфейс запуска фоновых операций.
Жизненный цикл #
1. Создаётся Celery app.
2. Загружается конфигурация:
broker
backend
serializer
routing
retries
3. Импортируются модули задач.
4. Декораторы @app.task регистрируют задачи.
5. Producer использует app для публикации сообщения.
6. Worker запускается с тем же app.
7. Worker находит задачу в app.tasks.
8. Worker выполняет Task.run().
9. App использует result backend для сохранения состояния,
если backend настроен.
Celery создаёт приложение лениво: при создании экземпляра формируются базовые внутренние объекты, включая registry, а окончательная обработка отложенных декораторов и привязка задач происходят при финализации app или при обращении к реестру задач.
Итог #
Celery app — центральный объект Celery,
который связывает вместе:
задачи
конфигурацию
broker
result backend
маршрутизацию
workers
Celery Beat
управление и мониторинг
Без него Celery не знает:
какие задачи зарегистрированы;
куда отправлять сообщения;
как их сериализовать;
где хранить результаты;
какие настройки применять при выполнении.
При этом Celery app следует держать в инфраструктурном слое. Бизнес-сервису лучше зависеть от собственного интерфейса вроде ReportJobDispatcher, а уже его Celery-реализация должна использовать celery_app и task.apply_async().
30. Как Celery управляет состояниями task? #
Общая модель #
Celery управляет состояниями задачи через три основных компонента:
Worker / tracer
определяет результат выполнения
|
v
Result backend
хранит последнее состояние и метаданные
|
v
AsyncResult
позволяет клиенту прочитать состояние
Отдельно существует поток Celery Events, используемый Flower и другими системами мониторинга. Events и result backend — это два разных механизма.
Где хранится состояние #
Состояние задачи не хранится в RabbitMQ как часть жизненного цикла Celery.
RabbitMQ знает только состояние доставки сообщения:
ready
unacknowledged
acknowledged
requeued
Celery-состояния:
PENDING
STARTED
RETRY
SUCCESS
FAILURE
REVOKED
обычно хранятся в result backend:
app = Celery(
"project",
broker="amqp://rabbitmq//",
backend="redis://redis:6379/0",
)
В качестве result backend можно использовать Redis, SQL-базу, RPC backend и другие поддерживаемые хранилища. По умолчанию result backend вообще не настроен.
RabbitMQ
хранит сообщение задачи
Redis result backend
хранит состояние и результат задачи
Типичный жизненный цикл #
PENDING
|
v
STARTED только если включён track_started
|
+------------------+
| |
v v
SUCCESS FAILURE
^
|
RETRY
Реальный цикл с повторными попытками может выглядеть так:
PENDING
|
STARTED
|
RETRY
|
STARTED
|
RETRY
|
STARTED
|
SUCCESS
При этом result backend обычно хранит последнее состояние, а не полноценную историю всех переходов. Когда задача переходит в новое состояние, предыдущая запись заменяется.
PENDING
#
После публикации:
result = generate_report.delay(report_id)
Celery возвращает:
AsyncResult(task_id)
Но это ещё не означает, что в result backend появилась запись:
print(result.state)
# PENDING
PENDING имеет двойной смысл:
задача ожидает выполнения
или
Celery ничего не знает о task_id
Любой неизвестный result backend идентификатор считается PENDING. Поэтому по этому состоянию нельзя точно понять:
находится ли сообщение в RabbitMQ;
было ли оно вообще опубликовано;
получил ли его worker;
уже выполняется ли задача при отключённом
track_started;был ли результат удалён по истечении срока хранения.
Например:
result = app.AsyncResult("несуществующий-id")
print(result.state)
# PENDING
RECEIVED
#
Когда worker получает сообщение от брокера, он может отправить событие:
task-received
В мониторинге появляется состояние:
RECEIVED
Но RECEIVED обычно относится к Celery Events, а не к состояниям, записанным в result backend:
RabbitMQ -> Worker
|
+--> task-received event
|
+--> задача может оставаться reserved
Полученная задача ещё может ожидать свободного процесса execution pool. Поэтому:
RECEIVED ≠ STARTED
Официальная таблица состояний прямо отмечает, что RECEIVED используется только в событиях.
STARTED
#
Непосредственно перед выполнением тела задачи worker может записать:
STARTED
Но по умолчанию это отключено:
app.conf.task_track_started = True
Либо для отдельной задачи:
@app.task(track_started=True)
def generate_report(report_id: int) -> str:
...
Тогда в result backend записывается состояние STARTED, а в метаданных могут находиться PID и hostname процесса worker:
result = generate_report.delay(42)
print(result.state)
# STARTED
print(result.info)
# {"pid": 124, "hostname": "celery@worker-1"}
Без track_started=True задача обычно переходит для наблюдателя сразу:
PENDING -> SUCCESS
или:
PENDING -> FAILURE
хотя фактически некоторое время выполнялась.
Кто обновляет состояние #
Основные состояния устанавливает внутренний Celery tracer — механизм, который оборачивает вызов Task.run().
Упрощённо:
def trace_task(task, task_id, args, kwargs):
try:
result = task.run(*args, **kwargs)
except Retry as exc:
backend.store_result(task_id, exc, "RETRY")
except Exception as exc:
backend.store_result(task_id, exc, "FAILURE")
else:
backend.store_result(task_id, result, "SUCCESS")
Реальный celery.app.trace:
перехватывает исключения;
обрабатывает
Retry;вызывает task hooks;
сохраняет результат;
обновляет result backend;
формирует события мониторинга.
SUCCESS
#
Если функция завершилась без исключения:
@app.task
def add(a: int, b: int) -> int:
return a + b
worker сохраняет:
state: SUCCESS
result: 5
Получение:
result = add.delay(2, 3)
value = result.get(timeout=10)
print(value)
# 5
print(result.state)
# SUCCESS
print(result.result)
# 5
Celery сначала сохраняет результат и состояние SUCCESS в backend, а затем вызывает on_success(). Поэтому внешний клиент уже может видеть SUCCESS, пока обработчик on_success() ещё выполняется.
FAILURE
#
Если тело задачи выбросило необработанное исключение:
@app.task
def generate_report(report_id: int) -> None:
raise RuntimeError("Generation failed")
Celery записывает:
state: FAILURE
result: RuntimeError(...)
traceback: ...
result = generate_report.delay(42)
try:
result.get(timeout=10)
except RuntimeError:
...
print(result.state)
# FAILURE
print(result.result)
# RuntimeError("Generation failed")
print(result.traceback)
Состояние записывается в backend до вызова on_failure(). Поэтому клиент может увидеть FAILURE, когда пользовательский failure hook ещё не закончил работу.
RETRY
#
При вызове:
raise self.retry(
exc=exc,
countdown=30,
)
Celery выбрасывает специальное исключение Retry. Оно не считается обычной ошибкой задачи.
Celery:
записывает состояние
RETRY;сохраняет исходное исключение и traceback;
публикует новую попытку;
использует тот же
task_id;обычно отправляет задачу в ту же очередь.
Task ID: 123
попытка 1 -> RETRY
попытка 2 -> RETRY
попытка 3 -> SUCCESS
on_retry() вызывается после обновления backend до RETRY, но до планирования следующей попытки.
Пример:
@app.task(
bind=True,
max_retries=5,
)
def call_provider(self, payment_id: int) -> None:
try:
provider.process(payment_id)
except ProviderTimeout as exc:
raise self.retry(
exc=exc,
countdown=30,
)
REVOKED
#
Задачу можно отозвать:
app.control.revoke(task_id)
или:
result.revoke()
Тогда её состояние может стать:
REVOKED
Важно: обычный revoke не обязательно прекращает уже выполняющуюся задачу. По умолчанию worker запоминает task ID и не запускает её, если она ещё не начала выполняться. Принудительное завершение является отдельным режимом и зависит от используемого execution pool. Событие task-revoked может содержать признаки terminated, сигнала завершения и истечения срока задачи.
Пользовательские состояния #
Задача может самостоятельно записывать произвольное состояние через update_state():
@app.task(bind=True)
def process_file(self, file_id: int) -> None:
total = 100
for current in range(total):
process_item(current)
self.update_state(
state="PROGRESS",
meta={
"current": current + 1,
"total": total,
"percent": current + 1,
},
)
update_state() фактически вызывает backend и сохраняет:
task_id
state
meta
По умолчанию используется ID текущей задачи.
Клиент:
result = app.AsyncResult(task_id)
print(result.state)
# PROGRESS
print(result.info)
# {
# "current": 65,
# "total": 100,
# "percent": 65
# }
После завершения Celery заменит пользовательское состояние:
PROGRESS -> SUCCESS
или:
PROGRESS -> FAILURE
Result backend хранит последнее состояние #
Условная запись в Redis может выглядеть так:
{
"status": "PROGRESS",
"result": {
"current": 65,
"total": 100
},
"traceback": null,
"children": [],
"task_id": "123"
}
Позже она заменяется:
{
"status": "SUCCESS",
"result": {
"file_url": "/files/result.csv"
},
"traceback": null,
"children": [],
"task_id": "123"
}
Celery не предоставляет через обычный AsyncResult последовательность:
PENDING -> STARTED -> PROGRESS 10 -> PROGRESS 50 -> SUCCESS
Он показывает последнее известное backend состояние. Для полноценной истории нужно отдельно сохранять переходы или обрабатывать Celery Events.
Celery Events #
Параллельно worker может публиковать события:
task-sent
task-received
task-started
task-succeeded
task-failed
task-retried
task-revoked
task-rejected
Их используют:
Flower
celery events
собственный event receiver
система мониторинга
Worker
|
+--> result backend
| последнее состояние и результат
|
+--> event stream
события жизненного цикла
Events дают более подробную картину происходящего, но сами по себе не являются постоянным хранилищем. Для истории их нужно собирать и сохранять отдельно. Официальная документация предупреждает, что даже один worker может генерировать большое количество событий.
Result backend и Events решают разные задачи #
| Механизм | Назначение |
|---|---|
| Result backend | Получить последнее состояние, результат или ошибку по task_id |
| Celery Events | Мониторить жизненный цикл workers и задач в реальном времени |
| Broker | Доставить сообщение задачи worker’у |
| Бизнес-таблица задач | Хранить надёжное прикладное состояние операции |
Для пользовательского API не всегда правильно использовать AsyncResult как единственный источник истины. Для важных длительных операций часто создают собственную таблицу:
jobs
├── id
├── celery_task_id
├── status
├── progress
├── result
├── error_code
├── created_at
├── started_at
└── finished_at
Celery task обновляет эту таблицу независимо от технического состояния Celery:
Celery STARTED -> бизнес-статус running
Celery SUCCESS -> бизнес-статус completed
Celery FAILURE -> бизнес-статус failed
Это позволяет отделить техническое состояние выполнения от состояния бизнес-операции.
Что делает task_ignore_result
#
При настройке:
app.conf.task_ignore_result = True
Celery не сохраняет успешные return values в result backend:
@app.task(ignore_result=True)
def send_email(user_id: int) -> None:
...
Тогда нельзя надёжно использовать AsyncResult для получения результата и проверки завершения.
Можно сохранить только ошибки:
app.conf.task_ignore_result = True
app.conf.task_store_errors_even_if_ignored = True
result_expires задаёт срок хранения сохранённых результатов; значение по умолчанию — один день, хотя точное удаление зависит от backend.
Практическая конфигурация #
from celery import Celery
app = Celery(
"project",
broker="amqp://rabbitmq//",
backend="redis://redis:6379/1",
)
app.conf.update(
task_track_started=True,
task_ignore_result=False,
task_store_errors_even_if_ignored=True,
result_expires=86_400,
result_extended=True,
)
Задача:
@app.task(
bind=True,
autoretry_for=(ConnectionError,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
)
def generate_report(
self,
report_id: int,
) -> dict[str, str]:
self.update_state(
state="PROGRESS",
meta={
"stage": "loading_data",
"percent": 10,
},
)
load_data(report_id)
self.update_state(
state="PROGRESS",
meta={
"stage": "building_document",
"percent": 70,
},
)
path = build_document(report_id)
return {
"path": path,
}
Наблюдаемая последовательность:
PENDING
|
STARTED
|
PROGRESS {stage: loading_data, percent: 10}
|
PROGRESS {stage: building_document, percent: 70}
|
SUCCESS {path: ...}
Важное различие #
Состояние Celery
описывает техническое выполнение task.
Состояние RabbitMQ
описывает доставку сообщения.
Бизнес-состояние
описывает результат операции для пользователя.
Например:
RabbitMQ:
сообщение уже ACK
Celery:
SUCCESS
Бизнес-объект:
report.status = completed
Эти три состояния связаны, но не являются одним и тем же.
Итоговая цепочка #
1. Producer вызывает delay().
2. Создаётся task_id и сообщение отправляется брокеру.
3. AsyncResult сначала показывает PENDING.
4. Worker получает сообщение и отправляет task-received event.
5. При task_track_started=True backend получает STARTED.
6. Task может записывать пользовательские состояния через update_state().
7. При retry backend получает RETRY, а сообщение публикуется снова.
8. При успешном возврате backend получает SUCCESS и return value.
9. При исключении backend получает FAILURE, exception и traceback.
10. AsyncResult читает последнее сохранённое состояние из backend.
11. Flower строит более подробную картину по Celery Events.
Главное: Celery не ведёт строгую долговечную историю state machine автоматически. Worker определяет очередной исход, result backend хранит последнее состояние, а event-система передаёт события жизненного цикла для мониторинга.
31. Как отменить задачу (task) в Celery? #
Основной механизм — revoke
#
В Celery отмена задачи называется revocation. Команда revoke сообщает workers, что задачу с указанным task_id не следует выполнять:
result = generate_report.delay(report_id)
result.revoke()
Эквивалентные варианты:
from celery.result import AsyncResult
AsyncResult(task_id, app=celery_app).revoke()
celery_app.control.revoke(task_id)
Через CLI:
celery -A app.celery_app control revoke <task_id>
Celery рассылает команду workers через механизм remote control. Worker сохраняет task_id в списке отозванных задач и пропускает её, когда попытается выполнить. Это не обязательно физически удаляет сообщение из очереди RabbitMQ.
Если задача ещё не начала выполняться #
Для задачи в состоянии:
queued
reserved
scheduled
обычного revoke() обычно достаточно:
celery_app.control.revoke(task_id)
Схема:
RabbitMQ
|
| сообщение доставлено worker
v
Worker проверяет task_id
|
+-- task_id revoked -> не выполнять
|
+-- task_id обычный -> передать в execution pool
В Celery 5.6 при отзыве задачи result backend сразу обновляется до состояния REVOKED. Раньше это состояние могло появляться только после того, как worker встретил отозванную задачу.
Проверка:
result = celery_app.AsyncResult(task_id)
print(result.state)
# REVOKED
Если задача уже выполняется #
Обычный revoke:
result.revoke()
не останавливает уже выполняющийся Python-код:
Задача ожидает выполнения
-> revoke предотвращает запуск
Задача уже active
-> revoke сам по себе её не прерывает
Celery прямо указывает, что worker пропустит отозванную задачу, но уже выполняющуюся задачу не завершит без terminate=True.
Принудительное завершение #
Технически можно использовать:
result.revoke(
terminate=True,
signal="SIGTERM",
)
или:
celery_app.control.revoke(
task_id,
terminate=True,
signal="SIGTERM",
)
Через CLI:
celery -A app.celery_app control revoke \
<task_id> \
--terminate \
--signal=SIGTERM
Для жёсткого завершения:
result.revoke(
terminate=True,
signal="SIGKILL",
)
Но terminate=True завершает процесс execution pool, а не аккуратно останавливает конкретную бизнес-операцию. Между обнаружением задачи и отправкой сигнала процесс теоретически уже может перейти к другой работе. Поэтому документация Celery называет этот механизм крайней административной мерой и предупреждает, что его нельзя использовать как обычный программный способ отмены задач.
Также могут остаться незавершённые побочные эффекты:
Файл записан частично
Транзакция прервана
Внешний API уже вызван
Статус в БД не обновлён
Временные ресурсы не освобождены
Поэтому схема:
result.revoke(terminate=True)
не является аналогом безопасного asyncio.Task.cancel().
Рекомендуемый способ для выполняющейся задачи #
Для долгих задач следует реализовать кооперативную отмену:
1. Клиент устанавливает флаг отмены.
2. Задача периодически проверяет флаг.
3. Задача завершает текущий безопасный этап.
4. Выполняет очистку.
5. Ставит бизнес-статус cancelled.
6. Завершается самостоятельно.
Например, состояние операции хранится в PostgreSQL:
CREATE TABLE jobs (
id UUID PRIMARY KEY,
status TEXT NOT NULL,
cancellation_requested BOOLEAN NOT NULL DEFAULT FALSE
);
Запрос отмены:
UPDATE jobs
SET cancellation_requested = TRUE
WHERE id = :job_id
AND status IN ('queued', 'running');
Celery-задача:
class JobCancelled(Exception):
pass
@celery_app.task(bind=True)
def process_file(self, job_id: str) -> None:
job_repository.mark_running(job_id)
try:
for batch in load_batches(job_id):
if job_repository.is_cancellation_requested(job_id):
raise JobCancelled
process_batch(batch)
job_repository.mark_succeeded(job_id)
except JobCancelled:
cleanup_partial_results(job_id)
job_repository.mark_cancelled(job_id)
Проверять флаг лучше между атомарными этапами, а не в произвольной точке:
Получить batch
-> проверить отмену
-> обработать batch
-> зафиксировать результат
-> проверить отмену
Так задача не будет остановлена посреди критической операции.
celery.contrib.abortable
#
Celery предоставляет экспериментально-прикладной механизм AbortableTask:
from celery.contrib.abortable import AbortableTask
@celery_app.task(bind=True, base=AbortableTask)
def long_task(self) -> None:
for item in get_items():
if self.is_aborted():
cleanup()
return
process(item)
Producer:
result = long_task.delay()
result.abort()
abort() только устанавливает состояние ABORTED; task должна самостоятельно вызывать is_aborted() и прекращать работу. Остановка не мгновенная и вообще не гарантируется, если задача не проверяет этот флаг. Кроме того, встроенная реализация AbortableTask работает только с database result backends.
Поэтому при Redis result backend обычно практичнее использовать собственный флаг отмены в Redis или PostgreSQL.
Пример с Redis:
def cancellation_key(job_id: str) -> str:
return f"jobs:{job_id}:cancelled"
def request_cancellation(job_id: str) -> None:
redis.set(
cancellation_key(job_id),
"1",
ex=86_400,
)
def is_cancelled(job_id: str) -> bool:
return bool(redis.exists(cancellation_key(job_id)))
Комбинация revoke и кооперативной отмены #
На практике неизвестно, начала задача выполняться или всё ещё ждёт. Поэтому можно применить оба механизма:
def cancel_job(job_id: str, task_id: str) -> None:
job_repository.request_cancellation(job_id)
celery_app.control.revoke(
task_id,
terminate=False,
)
Результат:
Задача ещё не запущена:
revoke предотвращает запуск
Задача уже выполняется:
revoke её не остановит,
но она заметит cancellation_requested
Задача завершилась до запроса:
отмена не должна менять completed на cancelled
Обновление состояния нужно делать условно:
UPDATE jobs
SET cancellation_requested = TRUE
WHERE id = :job_id
AND status IN ('accepted', 'queued', 'running');
Отмена нескольких задач #
Можно передать список идентификаторов:
celery_app.control.revoke([
task_id_1,
task_id_2,
task_id_3,
])
Для GroupResult:
group_result.revoke()
Celery поддерживает пакетный revoke, чтобы не отправлять отдельную управляющую команду для каждого task ID.
Отмена по stamped headers #
Если задачи принадлежат одной бизнес-операции, можно добавить stamps и отменить все задачи с определённым заголовком:
signature = process_item.s(item_id).stamp(
operation_id=operation_id,
)
signature.apply_async()
Отмена:
celery_app.control.revoke_by_stamped_headers({
"operation_id": operation_id,
})
С принудительным завершением:
celery_app.control.revoke_by_stamped_headers(
{"operation_id": operation_id},
terminate=True,
)
revoke_by_stamped_headers поддерживается начиная с Celery 5.3. Список отозванных stamps не сохраняется после перезапуска workers, а поиск активных задач с terminate=True может быть дорогим при высокой concurrency.
Сохранение revoke после перезапуска worker #
По умолчанию workers держат список отозванных task ID в памяти. Если все workers перезапустятся, список может исчезнуть.
Для сохранения используется --statedb:
celery -A app.celery_app worker \
--statedb=/var/run/celery/worker.state \
--loglevel=INFO
Для нескольких workers:
celery multi start 2 \
-A app.celery_app \
--statedb=/var/run/celery/%n.state
Celery синхронизирует revoke-списки между работающими workers, а statedb позволяет сохранить их между перезапусками. Remote control и revoke поддерживаются брокерами RabbitMQ и Redis.
Отмена по сроку действия #
Если задача не имеет смысла после определённого момента, вместо ручной отмены можно установить expires:
task.apply_async(
args=[job_id],
expires=300,
)
Или конкретное время:
from datetime import datetime, timedelta, timezone
task.apply_async(
args=[job_id],
expires=datetime.now(timezone.utc) + timedelta(minutes=5),
)
Когда worker получает просроченную задачу, Celery помечает её как REVOKED и не выполняет.
Ограничение времени выполнения #
Для защиты от зависших задач используются soft и hard time limits:
@celery_app.task(
soft_time_limit=300,
time_limit=330,
)
def generate_report(report_id: str) -> None:
...
Обработка мягкого лимита:
from celery.exceptions import SoftTimeLimitExceeded
@celery_app.task(
soft_time_limit=300,
time_limit=330,
)
def generate_report(report_id: str) -> None:
try:
build_report(report_id)
except SoftTimeLimitExceeded:
cleanup_partial_report(report_id)
raise
Soft limit выбрасывает исключение, которое task может обработать для очистки. Hard limit принудительно завершает исполнительный процесс. Это защита от зависания, а не пользовательская отмена.
Не путать технический и бизнес-статус #
Celery может показать:
REVOKED
Но клиенту обычно нужен бизнес-статус:
cancellation_requested
cancelled
too_late_to_cancel
completed
Рекомендуемая модель:
queued
|
+-- запрос отмены до старта --> cancelled
|
v
running
|
+-- cancellation_requested
| |
| v
| cancelled
|
+--> succeeded
+--> failed
Endpoint отмены:
POST /jobs/{job_id}/cancel
Пример ответа:
{
"job_id": "0190f81a-8972-7d30-a021-72d774148f41",
"status": "cancellation_requested"
}
Не следует сразу возвращать cancelled, если выполняющаяся задача ещё не дошла до точки проверки.
Практический вариант #
def cancel_job(job_id: str) -> str:
job = job_repository.get(job_id)
if job.status in {"succeeded", "failed", "cancelled"}:
return job.status
job_repository.request_cancellation(job_id)
if job.celery_task_id:
celery_app.control.revoke(
job.celery_task_id,
terminate=False,
)
return "cancellation_requested"
Task:
@celery_app.task(bind=True)
def process_job(self, job_id: str) -> None:
if job_repository.is_cancellation_requested(job_id):
job_repository.mark_cancelled(job_id)
return
job_repository.mark_running(job_id)
for step in build_steps(job_id):
if job_repository.is_cancellation_requested(job_id):
cleanup(job_id)
job_repository.mark_cancelled(job_id)
return
execute_step(step)
job_repository.mark_succeeded(job_id)
Итог #
Задача ещё не выполняется:
result.revoke()
Задача уже выполняется:
кооперативный флаг отмены в БД или Redis
Зависший процесс:
terminate=True только как крайняя административная мера
Задача потеряла актуальность:
expires
Задача превысила допустимое время:
soft_time_limit + time_limit
Наиболее безопасная схема:
request cancellation
+
revoke без terminate
+
периодическая проверка флага внутри task
+
очистка и идемпотентное обновление бизнес-статуса
32. HTTP REST vs Брокеры сообщений #
Главное различие #
HTTP/REST и брокеры сообщений решают разные задачи:
HTTP/REST:
«Выполни операцию сейчас и верни мне результат».
Message broker:
«Прими сообщение и доставь его обработчику, когда тот сможет его обработать».
HTTP — протокол взаимодействия клиента и сервера по модели запрос–ответ. REST — архитектурный стиль построения API поверх HTTP, где взаимодействие обычно организовано вокруг ресурсов, URI, HTTP-методов и представлений ресурсов. HTTP является stateless-протоколом: каждый запрос должен быть понятен независимо от предыдущих соединений и сообщений.
Брокер принимает сообщения от producers, маршрутизирует их, сохраняет в очередях или журнале и доставляет consumers. Например, RabbitMQ может хранить сообщение до появления доступного consumer, а Kafka хранит события в partitioned log независимо от факта их чтения.
Общая схема #
HTTP #
Service A
|
| HTTP request
v
Service B
|
| HTTP response
v
Service A
Service A обычно ожидает:
статус ответа;
данные;
ошибку;
timeout.
Брокер #
Service A
|
| publish
v
Message broker
|
| delivery
v
Service B
Producer обычно получает подтверждение того, что брокер принял сообщение, но не результат бизнес-обработки consumer’ом.
Сравнение #
| Характеристика | HTTP/REST | Брокер сообщений |
|---|---|---|
| Модель | Request/response | Publish/consume |
| Типичное выполнение | Синхронное | Асинхронное |
| Нужен немедленный ответ | Да | Обычно нет |
| Доступность получателя | Нужен во время запроса | Может быть временно недоступен |
| Хранение запроса | Обычно отсутствует | Сообщение может храниться |
| Повторная доставка | Реализует клиент | Часто встроена |
| Несколько получателей | Нужны отдельные вызовы | Pub/Sub и несколько очередей/groups |
| Backpressure | Ограничения, 429/503, connection pools | Очередь накапливает сообщения |
| Порядок | Определяется приложением | Возможен в очереди или partition |
| Retry | Клиент или proxy | Broker/client/consumer policy |
| Получение результата | Непосредственно в response | Отдельное хранилище, событие или callback |
| Инфраструктура | Проще | Сложнее в эксплуатации |
Синхронность и асинхронность #
При обычном HTTP-вызове вызывающий сервис ждёт ответ:
response = await http_client.post(
"http://payment-service/payments",
json={"order_id": 451},
timeout=5,
)
response.raise_for_status()
Пока Payment Service не ответит, вызывающий код не знает результат операции.
Order Service
|
| ждёт
v
Payment Service
|
| 201 / 409 / 500
v
Order Service продолжает работу
Через брокер producer публикует команду:
{
"message_id": "cmd-451",
"message_type": "payment.process",
"order_id": 451
}
Order Service
|
| publish
v
RabbitMQ
|
v
Order Service продолжает работу
Позже:
RabbitMQ -> Payment Consumer
Завершение publish не означает, что платёж уже выполнен. Publisher confirm в RabbitMQ означает, что брокер принял ответственность за сообщение, а consumer acknowledgement означает, что consumer получил или обработал доставку. Это независимые подтверждения.
Связанность по времени #
HTTP создаёт временную связанность:
Service A работает
+
Service B должен быть доступен сейчас
+
сеть между ними должна работать
Если Service B недоступен:
Service A -> timeout / 502 / 503
При временной перегрузке HTTP-сервис может вернуть 503 Service Unavailable и указать Retry-After.
Брокер уменьшает временную связанность:
Service A публикует сообщение
Service B временно выключен
Сообщение остаётся в очереди
Service B запускается
Сообщение доставляется
RabbitMQ может сохранить корректно маршрутизированное сообщение в очереди до его потребления. Для важных сообщений дополнительно нужны соответствующая durability, persistent messages и подтверждение публикации.
Но брокер не устраняет все зависимости. Producer всё равно зависит от доступности самого брокера либо должен использовать outbox для сохранения сообщения в локальной БД.
Ошибки и повторы #
HTTP #
Клиент может получить явный результат:
HTTP/1.1 409 Conflict
{
"code": "INSUFFICIENT_BALANCE"
}
Но при timeout возникает неопределённость:
1. Сервер выполнил операцию.
2. Ответ потерялся.
3. Клиент получил timeout.
4. Клиент повторил запрос.
Поэтому изменяющим операциям часто нужен Idempotency-Key или другой механизм дедупликации:
POST /payments
Idempotency-Key: order-451-payment
Брокер #
При использовании acknowledgements обычно получается at least once:
Сообщение будет обработано один или более раз.
Например:
1. Consumer изменил PostgreSQL.
2. Consumer упал до ACK.
3. Broker повторно доставил сообщение.
4. Бизнес-операция может выполниться повторно.
RabbitMQ прямо предупреждает о повторных доставках при сетевых и consumer-сбоях и рекомендует идемпотентную обработку. Kafka также использует at-least-once по умолчанию: consumer может обработать запись, упасть до сохранения offset и получить её повторно.
Следовательно, идемпотентность нужна в обоих вариантах:
CREATE TABLE processed_messages (
consumer_name TEXT NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (consumer_name, message_id)
);
Получение результата #
В HTTP результат естественно возвращается в response:
{
"payment_id": 842,
"status": "completed"
}
Через брокер результат обычно получают другим способом:
1. Consumer обновляет статус в БД.
2. Клиент запрашивает GET /jobs/{id}.
или
1. Consumer публикует payment.completed.
2. Другой сервис подписывается на событие.
или
1. Consumer вызывает webhook.
Для запуска асинхронной обработки через HTTP обычно возвращают:
HTTP/1.1 202 Accepted
Location: /jobs/job-842
{
"job_id": "job-842",
"status": "queued"
}
202 Accepted означает, что запрос принят, но обработка ещё не завершена и может в дальнейшем не состояться. HTTP сам не отправляет клиенту новый status code после завершения фоновой операции, поэтому нужен polling, webhook, WebSocket или другой механизм.
Один или несколько получателей #
При HTTP Service A должен явно вызвать каждого получателя:
Order Service
|
+--> Notification Service
+--> Analytics Service
+--> Loyalty Service
Это увеличивает связанность Order Service с количеством downstream-сервисов.
При использовании брокера producer публикует одно событие:
order.completed
|
v
Exchange / Topic
|
+--> notification.queue
+--> analytics.queue
+--> loyalty.queue
RabbitMQ маршрутизирует одно сообщение в несколько очередей. Каждая очередь получает собственную копию, поэтому её consumers подтверждают обработку независимо.
В Kafka разные consumer groups независимо читают один topic. Внутри каждой consumer group partition в конкретный момент обрабатывается одним consumer, а позиция чтения хранится как offset.
Нагрузка и backpressure #
При HTTP резкий рост запросов сразу передаётся downstream-сервису:
API получает 10 000 запросов
|
v
Payment Service получает 10 000 вызовов
Защита строится через:
rate limiting
timeouts
concurrency limits
circuit breaker
load shedding
HTTP 429 / 503
Брокер способен сгладить кратковременный всплеск:
Producer: 1000 сообщений/с
Consumer: 500 сообщений/с
Очередь растёт на 500 сообщений/с
Но брокер не создаёт дополнительную вычислительную мощность. Если producer постоянно быстрее consumers, очередь или consumer lag будут бесконечно расти:
publish rate > processing rate
→ backlog
→ рост задержки
→ исчерпание диска или retention
То есть очередь помогает пережить краткосрочную перегрузку, но не решает постоянный недостаток производительности.
Хранение и повторное чтение #
Обычный HTTP-запрос после обработки не становится долговечным журналом событий. Для повторного выполнения данные нужно отдельно сохранять.
RabbitMQ queue обычно ориентирована на доставку и удаление сообщения после подтверждения consumer’ом. Consumer acknowledgements позволяют брокеру удалить сообщение только после подтверждения обработки.
Kafka ориентирована на сохранение событий в topics. События можно читать, хранить и обрабатывать повторно; consumer group хранит offset отдельно для каждой partition. Retention определяет, как долго записи остаются доступными.
Поэтому:
RabbitMQ:
выполнить задачу
Kafka:
сохранить событие и дать системам читать его независимо
Когда лучше HTTP/REST #
HTTP подходит, когда:
клиенту немедленно нужен результат;
операция короткая;
вызывающий сервис должен знать, успешна ли операция;
это чтение актуального состояния;
взаимодействие естественно выражается через ресурс;
временная недоступность получателя должна считаться ошибкой текущей операции.
Примеры:
GET /users/42
GET /products?category=books
POST /payments/authorize
PUT /profile
DELETE /sessions/current
Особенно естественно HTTP подходит для:
frontend -> backend
mobile client -> API
внешний партнёр -> public API
простые синхронные межсервисные запросы
Когда лучше брокер #
Брокер подходит, когда:
результат не нужен немедленно;
задача может выполняться долго;
получатель может быть временно недоступен;
нужно сгладить всплеск нагрузки;
нужны retry и DLQ;
одно событие должны получить несколько систем;
нужна независимая обработка;
требуется поток или история событий;
вызывающий сервис не должен знать всех подписчиков.
Примеры:
email.send
report.generate
image.resize
file.scan
payment.completed
order.created
search.reindex
analytics.track
Обычно используются вместе #
Типичная архитектура:
Client
|
| HTTP
v
Backend API
|
| сохраняет бизнес-данные
| создаёт outbox-запись
v
PostgreSQL
|
v
Outbox relay
|
v
RabbitMQ / Kafka
|
+--> Worker
+--> Notification Service
+--> Analytics Service
Клиент получает быстрый HTTP-ответ:
HTTP/1.1 202 Accepted
{
"job_id": "job-842",
"status": "accepted",
"status_url": "/jobs/job-842"
}
А фактическая обработка выполняется асинхронно.
Пример выбора #
Допустим, пользователь загружает файл для генерации отчёта.
Плохой синхронный вариант:
POST /reports
|
+--> прочитать миллион строк
+--> выполнить запросы
+--> создать PDF
+--> загрузить в S3
+--> ждать 60 секунд
+--> вернуть ответ
Лучше:
POST /reports
|
+--> создать job
+--> поставить задачу в RabbitMQ
+--> вернуть 202
Worker
|
+--> создать PDF
+--> загрузить в S3
+--> обновить job.status
Но проверку простого баланса счёта разумнее выполнить через HTTP:
GET /accounts/42/balance
Здесь вызывающему коду нужен актуальный ответ непосредственно сейчас.
Итог #
HTTP/REST:
синхронное взаимодействие;
запрос и непосредственный ответ;
получатель должен быть доступен;
хорошо подходит для API и получения текущего состояния.
Message broker:
асинхронное взаимодействие;
отправитель и обработчик разделены во времени;
сообщение может храниться и доставляться повторно;
хорошо подходит для фоновых задач, событий и распределения нагрузки.
Они не являются взаимозаменяемыми конкурентами. Наиболее практичная схема:
HTTP — внешняя граница и синхронные операции.
RabbitMQ — команды и фоновые задачи.
Kafka — потоки и долговременная история событий.
33. Можно ли сказать, что worker — это отдельный Python-процесс? #
Да, но с уточнением #
На высоком уровне можно сказать:
Celery worker — это отдельный Python-процесс, который подключается к брокеру и получает задачи.
Например, команда:
celery -A app.celery_app worker --loglevel=INFO
запускает отдельный процесс операционной системы — главный процесс worker. Он управляет подключением к брокеру, приёмом сообщений, планированием и пулом выполнения задач.
При prefork задача выполняется не в главном процессе
#
По умолчанию Celery использует пул prefork. Главный процесс worker создаёт несколько дочерних Python-процессов и передаёт им задачи:
Celery worker — главный процесс
├── дочерний процесс 1 -> выполняет task
├── дочерний процесс 2 -> выполняет task
├── дочерний процесс 3 -> выполняет task
└── дочерний процесс 4 -> выполняет task
Например:
celery -A app.celery_app worker --concurrency=4
означает, что worker управляет пулом примерно из четырёх исполнительных процессов. Новая task обычно не создаёт новый процесс: Celery использует уже запущенные процессы пула повторно. prefork остаётся стандартным и рекомендуемым режимом для большинства сценариев.
То есть точнее говорить:
Worker — отдельный главный Python-процесс.
При prefork:
task выполняется в одном из дочерних Python-процессов worker.
Зависит от выбранного pool #
Worker не обязательно выполняет задачи через дочерние процессы:
prefork
главный процесс + дочерние процессы
threads
один процесс + несколько потоков
gevent / eventlet
один процесс + множество greenlets
solo
один процесс, задачи выполняются последовательно
в главном потоке worker
Celery официально поддерживает prefork, threads, eventlet, gevent, solo и пользовательские реализации pool.
Например:
celery -A app worker --pool=threads --concurrency=10
Здесь существует отдельный процесс worker, но задачи исполняются его потоками.
celery -A app worker --pool=solo
Здесь задачи исполняются непосредственно в главном потоке worker.
Worker — не отдельный процесс для каждой задачи #
Неправильное представление:
Пришла задача
-> Celery создал новый процесс
-> задача завершилась
-> процесс уничтожился
Обычная модель prefork:
При запуске worker
-> заранее создаётся пул дочерних процессов
Пришла задача
-> выбирается свободный процесс пула
-> выполняется Task.run()
-> процесс остаётся ждать следующую задачу
Процесс можно периодически заменять настройкой worker_max_tasks_per_child, например для ограничения последствий утечек памяти, но это не означает создание нового процесса на каждую задачу.
Несколько workers #
На одной машине можно запустить несколько независимых worker:
worker-reports
└── собственный пул процессов
worker-emails
└── собственный пул процессов
celery -A app worker \
--hostname=reports@%h \
--queues=reports \
--concurrency=4
celery -A app worker \
--hostname=emails@%h \
--queues=emails \
--concurrency=8
Каждый такой worker — отдельный главный процесс со своим соединением, настройками и пулом выполнения. Celery поддерживает запуск нескольких именованных workers на одной машине.
Итог #
Наиболее точная формулировка:
Celery worker — это отдельный запущенный экземпляр Celery,
обычно представленный главным Python-процессом.
При стандартном prefork этот процесс управляет пулом
дочерних Python-процессов, в которых непосредственно
выполняются задачи.
Поэтому выражение «worker — отдельный Python-процесс» допустимо как упрощение, но worker не всегда равен процессу, который непосредственно выполняет task.