Перейти к основному содержимому
Нормативная версия документации - английская

Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.

Типы подписки и порядок

Это руководство выбирает, как консьюмеры подписки Pulsar делят её между собой, сохраняет порядок каждого ключа и размещает сообщения с ключом по партициям.

Пример - сервис доставки. Он упаковывает заказы, отслеживает отправления, проводит платежи и выставляет счета по заказам, и каждая из этих задач читает через подписку своего типа.

Четыре типа​

Каждый консьюмер подписки - это обработчик в одном из экземпляров сервиса. Тип подписки решает, как эти консьюмеры делят сообщения:

ТипКто получает сообщениеПорядок
Shared (по умолчанию)любой консьюмер, по очередине сохраняется
KeySharedконсьюмер, которому принадлежит ключ сообщениядля каждого ключа
Exclusiveединственный консьюмер; второй ждёт его уходапорядок топика
Failoverактивный консьюмер; остальные в резервепорядок топика

Тип задаёт subscription_type в дескрипторе. Все консьюмеры одной подписки называют один и тот же тип.

Shared: любой консьюмер​

src/main.rs
/// One step in the life of a shipment.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "shipments")]
struct Shipment {
order: u64,
status: String,
}

#[derive(OutSlot)]
#[publishes(Shipment)]
struct Shipments;

/// Packs an order. Every instance of the service shares the subscription `packing`.
#[subscriber(PulsarSubscription::new("orders", "packing"))]
async fn pack(
order: &Order,
Out(shipments): Out<impl Publisher<Options = PulsarPublishOptions>, Shipments>,
) -> HandlerOutcome {
let packed = Shipment {
order: order.id,
status: "packed".to_owned(),
};
// The order's id keys the event, so every event of one order keeps its place in line.
let sent = shipments
.message(&packed)
.partition_key(order.id.to_string())
.publish()
.await;
if sent.is_err() {
return HandlerOutcome::retry();
}
tracing::info!(order.id, "order packed");
HandlerOutcome::ack()
}

Shared отдаёт каждый заказ одному из консьюмеров packing, тому, чья очередь. Чем больше экземпляров сервиса, тем больше заказов упаковывается одновременно, и два заказа могут завершиться в любом порядке. Этот тип подходит для работы, сообщения которой не зависят друг от друга.

pack публикует событие отправления для каждого заказа. Шаг partition_key задаёт ключом события номер заказа. Этот шаг берётся из прелюдии брокера, поэтому слот ограничен Publisher<Options = PulsarPublishOptions>, чтобы шаг был доступен. Публикация возвращает управление, когда брокер сохранил сообщение.

KeyShared: по порядку для каждого ключа​

src/main.rs
/// Follows each shipment. Events of one order arrive in the order they were published.
#[subscriber(
PulsarSubscription::new("shipments", "tracking")
.subscription_type(SubscriptionType::KeyShared),
workers(8, by_key)
)]
async fn track(event: &Shipment) -> HandlerOutcome {
tracing::info!(event.order, event.status, "shipment tracked");
HandlerOutcome::ack()
}

KeyShared отдаёт все сообщения одного ключа одному консьюмеру, поэтому события одного заказа приходят в track в том порядке, в каком их опубликовал pack. Разные ключи распределяются по консьюмерам, поэтому экземпляры делят работу. Сервер назначает ключи и переносит часть из них, когда консьюмер подключается или уходит.

Внутри одного экземпляра workers(8, by_key) запускает восемь партиций воркеров, и доставки одного ключа выполняются друг за другом на одной партиции воркеров. Партицию воркеров выбирает ключ партиционирования сообщения, поэтому порядок каждого ключа сохраняется и внутри процесса. В ручной форме та же настройка - workers_by_key. Ключ приходит в обработчик заголовком partition-key.

Exclusive: один консьюмер​

src/main.rs
/// Books payments one after another, in the order they were taken.
#[subscriber(
PulsarSubscription::new("payments", "ledger").subscription_type(SubscriptionType::Exclusive)
)]
async fn book(payment: &Payment) -> HandlerOutcome {
tracing::info!(payment.order, payment.amount_cents, "payment booked");
HandlerOutcome::ack()
}

Exclusive даёт читать подписку одному консьюмеру, поэтому book видит платежи в том порядке, в каком их приняли. Второму консьюмеру сервер отказывает как занятой, а клиент повторяет запрос, пока первый не уйдёт или не кончатся попытки OperationRetries, а по умолчанию они не кончаются никогда. Поэтому второй экземпляр сервиса не запускается и ничего не сообщает, пока ждёт. Подписка Exclusive принадлежит сервису, который работает в одном экземпляре.

Failover: один активный консьюмер​

src/main.rs
/// Invoices orders on one instance at a time; the others stand by.
#[subscriber(
PulsarSubscription::new("orders", "invoicing").subscription_type(SubscriptionType::Failover)
)]
async fn invoice(order: &Order) -> HandlerOutcome {
tracing::info!(order.id, "order invoiced");
HandlerOutcome::ack()
}

Failover принимает всех консьюмеров, но доставляет одному из них. Остальные находятся в горячем резерве: когда активный отключается, сервер передаёт его сообщения следующему. Несколько экземпляров сохраняют порядок подписки Exclusive, но без её единой точки отказа. На партиционированном топике у каждой партиции свой активный консьюмер, поэтому экземпляры делят партиции между собой.

Приложение​

src/main.rs
#[ruststream::app]
fn app() -> impl App {
// A second ledger consumer reports the refusal after three tries instead of waiting.
let retries = OperationRetries::attempts(nonzero!(3u32));
RustStream::new(AppInfo::new("shipping", "0.1.0")).with_broker(
PulsarBroker::new("pulsar://localhost:6650").operation_retries(retries),
|b| {
b.include(pack).out(Shipments, Publish).build();
b.include(track);
b.include(book);
b.include(invoice);
},
)
}

OperationRetries::attempts ограничивает, сколько раз клиент повторяет операцию, которую сервер не принял. С тремя попытками второй консьюмер ledger останавливает сервис при запуске с ошибкой, которая называет топик. Ограничение действует на каждую операцию соединения, включая переподключение после перезапуска брокера, поэтому сервис получает раннюю ошибку вместо того, чтобы переждать сбой.

Партиционированные топики​

Партиционированный топик - это несколько топиков под одним именем, и каждая партиция обслуживается отдельно. Администратор создаёт его с заданным числом партиций:

docker compose exec pulsar bin/pulsar-admin topics create-partitioned-topic \
persistent://public/default/shipments -p 3

Подписка читает все партиции топика, а сервис читает его тем же дескриптором, что и любой другой топик. Сообщение с ключом попадает в партицию, на которую указывает хеш его ключа, поэтому сообщения одного ключа остаются в одной партиции и по порядку. Сообщения без ключа распределяются по партициям по очереди.

Тестирование типов​

Тесты в файле примера запускают приложение внутри процесса. Там каждая подписка получает каждое сообщение своего топика один раз, а её тип выбирает консьюмера. Pulsar внутри процесса сразу отказывает второму консьюмеру Exclusive, поэтому тест сразу не проходит, а не ждёт. Партиции, диапазоны ключей KeyShared и выбор консьюмера принадлежат серверу, и их проверяют живые тесты крейта на работающем Pulsar.