Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Рассылка через Pub/Sub
Это руководство рассылает сообщения всем работающим экземплярам сервиса через Redis Pub/Sub.
Пример - витрина интернет-магазина. Каждый экземпляр обновляет кеш цен, когда меняется цена, следит за событиями заказов через шаблон и объявляет об отправленных заказах.
Сообщение получает каждый подписчик
- Макросы
- Вручную
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "prices.changed")]
struct PriceChanged {
sku: String,
price_cents: u64,
}
#[subscriber(RedisPubSub::new("prices.changed").buffer(nonzero!(1_000)))]
async fn refresh_price(change: &PriceChanged) -> HandlerOutcome {
tracing::info!(change.sku, change.price_cents, "price cache refreshed");
HandlerOutcome::ack()
}
#[subscriber(RedisPubSub::new("prices.changed"))]
async fn reindex_price(change: &PriceChanged) -> HandlerOutcome {
tracing::info!(change.sku, "search index updated");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct PriceChanged {
sku: String,
price_cents: u64,
}
outgoing!(PriceChanged, "prices.changed");
struct RefreshPrice;
impl Handle<PriceChanged> for RefreshPrice {
fn handle(
&self,
change: &PriceChanged,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(change.sku, change.price_cents, "price cache refreshed");
ready(Ok(()))
}
}
struct ReindexPrice;
impl Handle<PriceChanged> for ReindexPrice {
fn handle(
&self,
change: &PriceChanged,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(change.sku, "search index updated");
ready(Ok(()))
}
}
RedisPubSub подписывается на один канал.
Сообщение, опубликованное в канал, получает каждая подписка, подключённая в этот момент: его получают и refresh_price, и reindex_price в каждом работающем экземпляре.
Сообщения не делятся между подписками и не подтверждаются. Исход, который возвращает обработчик, ничего не завершает.
Redis не хранит копию. Сообщение, опубликованное, пока ни одна подписка не подключена, пропадает, как и сообщение для экземпляра, который в это время перезапускается. Подписка открывается только после того, как Redis направил ей её канал, поэтому сообщение, опубликованное после запуска сервиса, не теряется.
Медленный подписчик
Redis отправляет каждое сообщение сразу после публикации, готов обработчик или нет.
Подписка держит то, что обработчик ещё не забрал, в своём буфере: 32 сообщения, если buffer не задаёт другой размер.
Когда буфер заполнен, каждое новое сообщение вытесняет самое старое, а подписка пишет в журнал предупреждение с каналом и числом потерянных сообщений.
refresh_price задаёт буфер на тысячу сообщений, поэтому всплеск изменений цен ждёт обработчика, а не обрезается.
Буфер выделяется при открытии подписки, около 600 байт на сообщение, сколько бы ни весила полезная нагрузка.
Шаблоны
- Макросы
- Вручную
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
struct OrderEvent {
order_id: u64,
}
#[subscriber(RedisPubSubPattern::new("orders.events.*"))]
async fn track_order(event: &OrderEvent, Ctx(channel): Ctx<keys::Channel>) -> HandlerOutcome {
tracing::info!(event.order_id, channel, "order event");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct OrderEvent {
order_id: u64,
}
outgoing!(OrderEvent);
struct TrackOrder;
impl Handle<OrderEvent, (), (), PubSubContext> for TrackOrder {
fn handle(
&self,
event: &OrderEvent,
_outs: &(),
ctx: &mut Context<'_, PubSubContext>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let channel = ctx.context(keys::Channel);
tracing::info!(event.order_id, channel, "order event");
ready(Ok(()))
}
}
RedisPubSubPattern подписывается на маску командой PSUBSCRIBE, здесь на все каналы с префиксом orders.events..
Обработчик узнаёт канал, по которому пришло сообщение, из контекста Pub/Sub через Ctx<keys::Channel>.
keys::FromPattern сообщает, пришло ли сообщение через шаблон.
Публикация в канал
- Макросы
- Вручную
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "shipments")]
struct Shipment {
order_id: u64,
}
#[subscriber(
RedisStream::new("shipments").group("notifier"),
reply("orders.events.shipped")
)]
async fn announce_shipment(shipment: &Shipment) -> OrderEvent {
OrderEvent {
order_id: shipment.order_id,
}
}
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Shipment {
order_id: u64,
}
outgoing!(Shipment, "shipments");
struct AnnounceShipment;
impl Handle<Shipment, OrderEvent> for AnnounceShipment {
fn handle(
&self,
shipment: &Shipment,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<OrderEvent, HandlerOutcome>> {
ready(Ok(OrderEvent {
order_id: shipment.order_id,
}))
}
}
announce_shipment читает отправки из потока и отвечает сообщением OrderEvent в канал orders.events.shipped.
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App {
RustStream::new(AppInfo::new("storefront", "0.1.0")).with_broker(
RedisBroker::standalone("redis://localhost:6379"),
|b| {
b.include(refresh_price);
b.include(reindex_price);
// A glob is no channel a publish can reach, so the registration names one for copies.
b.include(track_order)
.out_retry(pubsub::Publish::default())
.to("orders.events.retry");
b.include(announce_shipment)
.out_reply(pubsub::Publish::default());
},
)
}
fn app() -> impl App {
RustStream::new(AppInfo::new("storefront", "0.1.0")).with_broker(
RedisBroker::standalone("redis://localhost:6379"),
|b| {
b.include(
subscriber(
RedisPubSub::new("prices.changed").buffer(nonzero!(1_000)),
RefreshPrice,
)
.build(),
);
b.include(subscriber(RedisPubSub::new("prices.changed"), ReindexPrice).build());
// A glob is no channel a publish can reach, so the registration names one for copies.
b.include(subscriber(RedisPubSubPattern::new("orders.events.*"), TrackOrder).build())
.out_retry(pubsub::Publish::default())
.to("orders.events.retry");
b.include(
subscriber(
RedisStream::new("shipments").group("notifier"),
AnnounceShipment,
)
.reply()
.to("orders.events.shipped")
.build(),
)
.out_reply(pubsub::Publish::default());
},
)
}
fn main() -> ExitCode {
run_main(app)
}
Ответ уходит через pubsub::Publish, который отправляет PUBLISH.
Имя, которое не читает ни одна подписка этого сервиса, издатель брокера по умолчанию считает ключом потока и отправляет в него XADD.
Подписка по шаблону не читает orders.events.shipped по имени, поэтому регистрация указывает политику Pub/Sub.
Подписка по шаблону в том же сервисе получает это объявление.
Шаблон не называет канала, в который можно опубликовать копию.
Поэтому регистрация track_order задаёт канал через out_retry(..).to("orders.events.retry"), и без этого сервис не запускается.
Этот канал подходит под шаблон, поэтому переобработанное событие возвращается к обработчику в каждом работающем экземпляре.
Шардированные каналы в кластере
В кластере классический PUBLISH уходит на каждый узел.
PubSubMode::Sharded вместо него использует SSUBSCRIBE и SPUBLISH: канал находится на узле, которому принадлежит его хеш-слот, поэтому трафик распределяется по кластеру.
Этому режиму нужен Redis 7 или новее, и шаблонов в нём нет.
Два режима не видят друг друга, поэтому подписка и политика публикации указывают один и тот же режим: RedisPubSub::new(..).mode(PubSubMode::Sharded) и pubsub::Publish::new().mode(PubSubMode::Sharded).
Когда соединение обрывается
Брокер переподключается после обрыва соединения, перезапуска Redis или переключения при отказе, и каждая подписка Pub/Sub подписывается заново. Сообщения, опубликованные за время отсутствия, не доставляются. Шардированная подписка не восстанавливается после того, как перераспределение слотов переносит её слот на другой узел.
Когда вместо Pub/Sub нужен поток
Pub/Sub подходит для того, что полезно только сейчас: обновить кеш, показать живую панель мониторинга, уведомить каждый экземпляр. Если каждый читатель должен получить каждое сообщение, включая опубликованные, пока он не работал, используйте поток и по одной группе потребителей на читателя. Каждая группа читает каждую запись, а запись остаётся в потоке, пока её не обрежут.
Тесты в файле примера публикуют изменение цены и отправку и проверяют, что каждая подписка получила опубликованное.