
series redb ecosystem
Про Kafka и RabbitMQ в этой серии уже было. Теперь — Amazon SQS, и вместе с ним в одном пакете SNS: два транспорта, потому что в мире AWS они ходят парой. Коннектор redb.Route.Sqs — поверх нативного AWS SDK for .NET v4 (AWSSDK.SQS, AWSSDK.SimpleNotificationService), но писать вам придётся не «клиент», а маршруты, где вся очередь задаётся одной строкой-URI:
From("sqs://orders?waitTimeSeconds=20&concurrentConsumers=4") .Log("Order in: ${body}") .To("direct://process");
Прочитали — и уже знаете, что происходит: длинный поллинг очереди orders по 20 секунд, четыре конкурирующих консьюмера, логируем и отдаём дальше. Ни «создай клиента», ни «ReceiveMessage», ни «DeleteMessage после обработки» — всё это коннектор.
Про «уходим от MassTransit» — та же оговорка, что и в прошлых статьях: дело не в том, что MassTransit чего-то не умеет. SQS/SNS у него есть давно — пакет MassTransit.AmazonSQS, и он крутит ровно ту же связку SNS→SQS. Разница в модели. MassTransit — шина с контрактами сообщений и конфигурацией автобуса. redb.Route — явные маршруты в духе Apache Camel: endpoint — строка-URI, паттерны интеграции — шаги маршрута, транспорт — абстракция под ними. Эта статья — про то, как SQS/SNS выглядит во второй модели.
Отдельно — про то, о чём сейчас спрашивают чаще всего, и здесь важна точность, а не злорадство. MassTransit с v9 перешёл на коммерческую лицензию — линия развивается под компанией Massient, Inc., пакет MassTransit.AmazonSQS 9.0.0 на NuGet уже оттуда. Но без передёргиваний: v8 остаётся под Apache 2.0 и получает security-патчи и критические фиксы как минимум до конца 2026; v9 — не закрытый бинарь, а source-available; а организациям с выручкой меньше $1 млн в год (и НКО с расходами меньше $1 млн) он даётся со 100% скидкой — то есть бесплатно, без коммерческой поддержки. Так что «MassTransit стал платным» — неправда для маленькой команды и правда для крупной, у которой это превращается в решение: платить, оставаться на v8 до конца его поддержки или искать альтернативу. redb.Route — Apache 2.0, и SQS-коннектор из этой статьи в том числе; но берите его не поэтому, а если вам ближе модель маршрутов. Лицензия — повод посмотреть по сторонам, а не аргумент в архитектурном споре.
Весь код — на английском, весь текст — по-русски. Примеры не выдуманы: они гоняются против LocalStack (docker контейнер, эндпоинт http://localhost:4566), ровно как интеграционные тесты коннектора.
Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху:
коннектор RabbitMQ — один URI на endpoint, RPC, конкурирующие консьюмеры и dead-letter (предыдущая в серии)
redb.Route — уходим от MassTransit, идём к Apache Camel: Kafka, Scatter‑Gather и транзакции
Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router
redb.Route — Apache Camel для .NET, который мы написали потому что выхода другого не было
Полный список — в профиле. Исходники: github.com/redbase-app. Про саму БД: redb.ru.
Один пакет, два транспорта
redb.Route.Sqs регистрирует две схемы, и роли у них разные:
sqs://— очередь. Работает и консьюмером (вFrom(...)), и продюсером (в.To(...)).sns://— топик. Только publisher. У SNS нет pull-консьюмера — доставка это push (в SQS / HTTP / email / SMS). Попытка сделатьFrom("sns://...")честно падает сNotSupportedException: чтобы «читать» топик, подписываете на него SQS-очередь и читаете её черезsqs://.
Почему они в одном пакете: у AWS это связка. SQS — надёжная очередь «точка-точка» (одно сообщение — одному потребителю). SNS — топик «издатель-подписчик» (одно сообщение — всем подписчикам). Классическая AWS-архитектура «fan-out» — это SNS-топик, на который подписаны несколько SQS-очередей. Поэтому и коннектор один: sqs:// + sns://.
Регистрация в DI:
services.AddRedbRoute(route => { route.Services.AddRedbRouteSqs(); // регистрирует и sqs://, и sns:// route.AddRouteBuilder<MyRoutes>(); });
Анатомия URI
Endpoint — это строка:
sqs://queue-name?region=us-east-1&waitTimeSeconds=20&concurrentConsumers=4 sns://topic-name?region=us-east-1&autoCreateTopic=true
Схема, имя очереди/топика в пути, дальше query-параметры (имена — в таблицах ниже). Флентом — то же самое: Sqs.Queue("orders").WaitTimeSeconds(20)... и Sns.Topic("events").Region("us-east-1")... собирают ту же строку с URL-энкодингом.
Три вещи, специфичные именно для AWS:
Креды не надо дублировать. Двадцать очередей на один аккаунт — заведите AwsConnectionFactory в реестре и ссылайтесь через connectionFactory=имя. Одна фабрика строит и SQS-, и SNS-клиент (креды, регион и service URL у них общие):
context.AddToRegistry("prod-aws", new AwsConnectionFactory { Region = "eu-west-1", UseDefaultCredentialsProvider = true, // IAM role на инстансе / SSO / env }); From("sqs://orders?connectionFactory=prod-aws&concurrentConsumers=4"); To("sns://events?connectionFactory=prod-aws");
LocalStack / ElasticMQ — через serviceUrl=. Явный URL перебивает региональный эндпоинт (регион остаётся только для подписи запроса). Это и есть режим для локальной разработки и тестов:
sqs://orders?serviceUrl=http://localhost:4566®ion=us-east-1&accessKey=test&secretKey=test
Очередь/топик создаются на месте. autoCreateQueue=true (или autoCreateTopic=true) создаёт ресурс на старте, если его нет. Имя с суффиксом .fifo создаётся как FIFO автоматически (с ContentBasedDeduplication=true).
Все параметры
Главная причина сохранить статью в закладки. Имена — как в URI, дефолты — из коробки.
Подключение и креды (общие для sqs:// и sns://)
Параметр | Дефолт | Зачем |
|---|---|---|
|
| AWS-регион |
| (пусто) | Явный эндпоинт (LocalStack/ElasticMQ); перебивает регион |
| (пусто) | Статические ключи |
| (пусто) | Временные (STS) креды |
| (пусто) | Именованный профиль из shared credentials |
|
| Дефолтная цепочка AWS (env / IAM role / SSO) |
| (пусто) | Имя фабрики в реестре; перекрывает креды из URI |
|
| Ретраи SDK на транзиентных ошибках |
|
| Таймаут HTTP-клиента, мс |
| — | HTTP-прокси |
Приоритет резолва кредов: дефолтная цепочка → профиль → session-token → статические ключи. Validate() требует, чтобы был хотя бы один способ: accessKey+secretKey, profileName, useDefaultCredentialsProvider=true или зарегистрированный connectionFactory — иначе исключение на старте.
SQS — консьюмер (From("sqs://..."))
Параметр | Дефолт | Зачем |
|---|---|---|
|
| Long-poll (0–20). Меньше пустых receive, меньше запросов |
|
| Сколько тянуть за один receive (1–10) |
|
| На сколько сообщение прячется на время обработки |
|
| Число конкурирующих receive-циклов (см. рецепт про многопоточность) |
|
| Продлевать visibility, пока обработчик работает (heartbeat); нужен |
|
| Удалять сообщение после успешной обработки (это и есть ack) |
|
| При ошибке сбросить visibility в 0 → немедленная переотдача |
|
| Отложить delete в транзакцию маршрута ( |
|
| Какие системные атрибуты запрашивать |
|
| Какие message-атрибуты запрашивать |
|
| Пауза (мс) после пустого receive перед следующим поллом |
|
| Задержка (мс) перед первым поллом |
SQS — продюсер (.To("sqs://..."))
Параметр | Дефолт | Зачем |
|---|---|---|
|
| Задержка доставки (0–900). FIFO-очереди per-message delay не принимают |
| — | FIFO group id. Константа или |
| — | FIFO dedup id. Опустить, если у очереди content-based dedup |
|
| Тело- |
|
| Размер батча (жёсткий лимит SQS — 10) |
|
| Создать очередь на старте (FIFO, если имя оканчивается на |
| (пусто) | Явный URL очереди вместо резолва по имени |
SNS — publisher (.To("sns://..."))
Параметр | Дефолт | Зачем |
|---|---|---|
|
| Создать топик на старте (FIFO по |
| (пусто) | Явный ARN топика вместо резолва по имени |
| (пусто) | Тема (для email-доставки). Поддерживает |
| (пусто) |
|
| — | Для FIFO-топиков |
|
| На старте подписать SQS-очередь (по ARN) на этот топик |
|
| На той подписке — отдавать голый payload (см. рецепт про Pub-Sub) |
Заголовки и трейсинг сквозь брокер
Консьюмер sqs:// кладёт в заголовки метаданные сообщения (префикс redbSqs.), а входящие message-атрибуты — под префиксом redbSqs.attr.:
Заголовок | Что значит |
|---|---|
| Имя очереди |
| Идентификатор сообщения |
| Receipt handle (нужен для delete / смены visibility) |
| Сколько раз сообщение уже отдавалось (полезно для «сколько раз ретраили») |
| FIFO group + порядковый номер |
| Когда отправили (epoch-millis) |
| Входящий пользовательский message-атрибут |
Обратно — маппинг заголовок → message-атрибут на продюсере устроен так: пользовательские заголовки уезжают атрибутами как есть; входящие redbSqs.attr.<имя> пробрасываются под именем <имя> (префикс снимается — так sqs→sqs и sqs→sns мосты сохраняют атрибуты); внутренние redbSqs.* / redbSns.* — отбрасываются, чтобы метаданные одного хопа не утекли в следующий.
Трейсинг — из коробки: перед отправкой продюсер инжектит W3C traceparent/tracestate в message-атрибуты через стандартный DistributedContextPropagator, а консьюмер на другом конце их поднимает и продолжает трейс. Через прямой SQS (sqs:// → sqs://) цепочка непрерывна без единой строчки настройки. (Про нюанс с SNS-конвертом — ниже, в рецепте про Pub-Sub.)
Честно про доставку: at-least-once, visibility, transacted
Место, где маркетинг любит сказать «exactly-once». Открываем код.
SQS-консьюмер — это at-least-once. Сообщение удаляется (DeleteMessage) только после успешного прохода по маршруту. Логика settle такая:
прошло успешно +
deleteAfterRead=true→DeleteMessage(ack);упало +
resetVisibilityOnFailure=true→ChangeMessageVisibility(0)→ сообщение сразу возвращается в очередь (быстрый ретрай);упало без этого флага → сообщение просто «протухает» по visibility timeout и отдаётся снова.
visibilityTimeout — на сколько сообщение спрятано на время обработки. Если обработчик долгий, есть extendMessageVisibility=true: фоновый heartbeat продлевает visibility каждые visibilityTimeout / 2, чтобы длинная работа не спровоцировала переотдачу.
transacted=true откладывает ack в транзакцию маршрута. Тогда delete не происходит сразу — регистрируется отложенное действие SqsAckAction, у которого Commit удаляет сообщение, а Rollback ставит visibility в 0 (немедленная переотдача). Коммит/откат едут вместе с redb-работой на границе .Transacted():
From("sqs://orders?transacted=true&visibilityTimeout=60") .Transacted() .ProcessWithRedb((redb, ex) => redb.SaveAsync(Parse(ex))) // запись в БД .EndTransaction(); // БД закоммитилась → сообщение удалилось; упала → вернулось
Чего тут нет — кросс-очередной атомарности «прочитал из A, записал в B» на уровне брокера (у SQS её и не существует). Краш ровно между «работа сделана» и «delete» на рестарте даст повтор — at-least-once, а не exactly-once. Поэтому обработчик должен быть идемпотентным; redbSqs.approximateReceiveCount в помощь. Честная формулировка — «at-least-once + транзакционный ack на уровне роута».
Порядок сохраняется только у FIFO-очереди (.fifo) и только при concurrentConsumers=1.
Рецепт 1: продюсер, консьюмер, FIFO, батч
Минимум:
// Consumer — long-poll, delete after success From(Sqs.Queue("orders").WaitTimeSeconds(20)) .Process(HandleOrder); // Producer — send the body to the queue To(Sqs.Queue("orders").Region("eu-west-1"));
FIFO — имя оканчивается на .fifo, group id обязателен (константа или выражение на каждое сообщение):
To(Sqs.Queue("orders.fifo").MessageGroupId("${header.customerId}"));
Батч — тело-IEnumerable уходит одним SendMessageBatch (чанками по batchMaxMessages, лимит SQS — 10):
From("timer://tick?period=5000") .SetBody(_ => new[] { "a", "b", "c", "d", "e" }) .To(Sqs.Queue("orders").EnableBatch()); // один вызов вместо пяти
Рецепт 2: конкурирующие консьюмеры (многопоточность)
Один параметр — concurrentConsumers:
From(Sqs.Queue("orders").ConcurrentConsumers(8).MaxNumberOfMessages(1)) .Process(HandleOrder); // до 8 сообщений обрабатываются параллельно
Это SQS-нативная модель конкуренции: коннектор поднимает N независимых receive-циклов (по задаче на цикл), каждый тянет и обрабатывает своё сообщение. maxNumberOfMessages=1 при этом заставляет каждый из N воркеров держать ровно одно сообщение — пул насыщается по-честному, а не «один цикл забрал батч из 10». Дефолт — 1 (строго серийно). Ставите N > 1 — порядок на очереди больше не гарантируется, обработчик должен быть потокобезопасным.
Насколько это реально параллелит — видно на демо SqsRpcDemo (о нём ниже): 12 запросов по ~300 мс работы каждый, пул из 4 воркеров — вся пачка отрабатывает за ~1.2 с вместо ~3.6 с серийно, а замеренный пик одновременности упирается ровно в 4.
А если параллелить надо не приём, а обработку внутри маршрута (источник серийный, работа тяжёлая) — есть ортогональный EIP-шаг .Threads(N). concurrentConsumers масштабирует чтение с очереди, .Threads(N) — обработку; про разницу — в отдельном гайде CONCURRENCY.md. Комбинируются свободно.
Рецепт 3: RPC (request/reply) — и почему SQS его не дарит
Вот тут — честный контраст с прошлой статьёй. У RabbitMQ RPC включался одним флагом replyTo=true на клиенте и нулём настроек на сервере: брокер сам умеет reply-очереди. У SQS такого нет — нет ни reply-to на уровне протокола, ни ожидания ответа. RPC поверх SQS строится руками, по классическому паттерну корреляции: временная/выделенная reply-очередь, correlationId, ожидание коррелированного ответа. Это ровно то, что показывает демо SqsRpcDemo.
Клиент к каждому запросу цепляет два message-атрибута — correlationId и replyTo (имя очереди для ответа) — и шлёт в очередь запросов. Воркер считает результат и отправляет его в очередь из replyTo, а correlationId возвращается сам (входящий redbSqs.attr.correlationId продюсер пробрасывает обратно атрибутом). Воркер — обычный консьюмер + динамический .ToD(...):
// Worker: N competing consumers on the request queue, reply to the queue named in replyTo From(Sqs.Queue("rpc-requests").ConcurrentConsumers(4).MaxNumberOfMessages(1)) .Process(async (ex, ct) => { var n = int.Parse(ex.In.Body!.ToString()!); await Task.Delay(300, ct); // simulated work ex.In.Body = (n * (long)n).ToString(); // the reply body }) // dynamic destination — the reply queue comes from the request's replyTo attribute .ToD(ex => Sqs.Queue(ReplyTo(ex)).Build());
На стороне клиента отдельный маршрут читает reply-очередь и сопоставляет correlationId ожидающему вызову:
From(Sqs.Queue("rpc-replies").MaxNumberOfMessages(10)) .Process((ex, ct) => { var id = Attr(ex, "correlationId"); if (id is not null && Pending.TryRemove(id, out var tcs)) tcs.TrySetResult(ex.In.Body?.ToString() ?? ""); return Task.CompletedTask; });
Мораль: RPC на SQS — рабочий, но это сборка из примитивов (атрибуты + .ToD + корреляция), а не флаг. Где нужен «RPC из коробки» — берут брокер с нативным reply (RabbitMQ). SQS честно про своё: он про надёжные очереди и переотдачу, а request/reply вы достраиваете сами. Полный воспроизводимый пример — в redb.Route/demos/SqsRpcDemo (гоняется против LocalStack).
Рецепт 4 (EIP): Publish-Subscribe через SNS→SQS fan-out
Вот главный паттерн, ради которого SQS ходит в паре с SNS. Publish-Subscribe Channel из книги Хопе и Вульфа: издатель публикует одно сообщение, а получает его каждый подписчик — независимо, в свою очередь, со своими ретраями. У AWS это делается не в коде, а топологией: SNS-топик, на который подписаны несколько SQS-очередей.
publish "order" ──▶ SNS topic ──┬──▶ SQS "orders-billing" ──▶ billing route └──▶ SQS "orders-shipping" ──▶ shipping route
Издатель просто публикует в топик, а получают все подписанные очереди — каждая своим независимым консьюмером:
// Publisher — publish an order event; SNS fans it out to every subscribed queue: From("timer://orders?period=5000") .SetBody(_ => BuildOrder()) .To(Sns.Topic("orders-events")); // Two independent subscribers — each its own SQS queue, each its own consumer: From(Sqs.Queue("orders-billing")).Process(Charge); From(Sqs.Queue("orders-shipping")).Process(Ship);
Сами подписки коннектор умеет завести из URI — subscribeSnsToSqs + ARN очереди; подписка выполняется на старте sns://-продюсера. Каждый sns://-endpoint подписывает одну очередь (ARN один), поэтому fan-out на две — это две подписки на один топик:
// Subscribe each queue to the topic with raw delivery (bare payload, no envelope): Sns.Topic("orders-events").SubscribeSnsToSqs(billingArn).RawMessageDelivery(); Sns.Topic("orders-events").SubscribeSnsToSqs(shippingArn).RawMessageDelivery();
Точную обвязку fan-out на две очереди (создание очередей, чтение ARN, подписка, публикация) — см. redb.Route/demos/SqsPubSubDemo.
Контраст с прямой очередью: у sqs:// одно сообщение достаётся одному потребителю (competing consumers, деление работы). Здесь — широковещание: одно событие → у каждой подписанной очереди своя копия. Добавить нового подписчика (аналитика, аудит) — это ещё одна очередь на тот же топик; издателя трогать не нужно. Полностью развязанные стороны.
⚠️ Важный нюанс: конверт vs raw
По умолчанию SNS не кладёт в очередь ваш payload как есть — он оборачивает его в JSON-конверт уведомления:
{ "Type": "Notification", "MessageId": "...", "TopicArn": "...", "Message": "<ваш payload здесь, строкой>", "MessageAttributes": { ... } }
То есть подписчик-очередь получает конверт, а не ваше сообщение, и ваши SNS-атрибуты лежат внутри него, а не как SQS-атрибуты. Это стандартное поведение AWS SNS (флаг подписки Raw Message Delivery = OFF), полезное ровно тогда, когда одна очередь слушает несколько топиков и по TopicArn из конверта надо понять источник. Для обычного fan-out это только мешает — приходится разворачивать конверт, а сквозной трейс SNS→SQS рвётся (traceparent уезжает внутрь конверта, а SQS-консьюмер ищет его в SQS-атрибутах).
Лечится флагом подписки Raw Message Delivery = true: очередь получает голый payload, а SNS-атрибуты становятся SQS-атрибутами (и трейс снова сквозной). В коннекторе это .RawMessageDelivery() — он ставит RawMessageDelivery=true на той самой авто-подписке subscribeSnsToSqs. Демо SqsPubSubDemo печатает первое доставленное тело: с .RawMessageDelivery() это {"orderId":1,"amount":100}, а не конверт.
Про
rawMessageDelivery. Опция выйдет в следующем релизе NuGet пакетаredb.Route.Sqs. Прямо сейчас её уже можно взять из исходников — коннекторredb.Route.Sqsна GitHub (папка коннектора,SnsEndpointOptions/SnsProducer/ флентRawMessageDelivery). Без него SNS→SQS тоже работает, просто в тело приходит JSON-конверт, который надо развернуть (envelope.Message).
Рецепт 5 (родня): Multicast — тот же веер, но внутри маршрута
Publish-Subscribe размножает сообщение на брокере (SNS отдаёт копию каждой подписанной очереди). Есть родственный EIP, который размножает внутри маршрута, — Multicast: ваш шаг отправляет копию exchange в несколько получателей сам.
From("sqs://orders") .Multicast() .To("sqs://orders-billing") .To("sqs://orders-shipping") .End();
Выглядит похоже, но разница принципиальная — где происходит размножение и кто про кого знает:
SNS→SQS (Pub-Sub) | Multicast | |
|---|---|---|
Где копируется | на брокере (SNS) | в маршруте (ваш процесс) |
Кто знает список получателей | SNS (подписки) | маршрут (список |
Добавить получателя | подписать ещё очередь, издателя не трогать | править код маршрута |
Развязка сторон | полная | получатели зашиты в маршрут |
Параллельность / агрегация ответов | нет (fire-and-forget по подпискам) | есть: |
Правило простое: нужно развязать издателя и подписчиков (кто угодно подпишется потом, издатель не в курсе) — SNS→SQS. Нужно тут же разослать по фиксированному списку и, возможно, собрать ответы — Multicast (или его брат Scatter-Gather из статьи про Kafka). Один — топология брокера, другой — шаг маршрута; выбираются по тому, где должна жить логика веера.
Итог
SQS/SNS в redb.Route — это маршруты, где вся очередь и весь топик заданы одним URI: From("sqs://…") / .To("sqs://…") / .To("sns://…"), а вся возня с AWS SDK, receive/delete, visibility, подписками и корреляцией спрятана за параметрами строки. Long-poll, FIFO, батч, конкурирующие консьюмеры одним параметром, at-least-once с транзакционным ack, SNS→SQS fan-out с выбором «конверт или голый payload» — набор полный, а на выходе короткая и понятная строка.
Честно про размен: exactly-once тут нет (его нет и у самого SQS) — есть at-least-once + идемпотентность; RPC не «из коробки», а собирается из атрибутов и .ToD; SNS-подписка по умолчанию заворачивает payload в конверт. Всё это — свойства AWS, а не недоделки коннектора, и коннектор про них честен.
Два воспроизводимых примера — redb.Route/demos/SqsRpcDemo (RPC + многопоточность) и redb.Route/demos/SqsPubSubDemo (SNS→SQS fan-out + raw delivery); оба поднимаются на LocalStack (http://localhost:4566, test/test) и гоняются как self-test. Ловится что-то в вашем сценарии — пишите в комментариях.
Исходники и релизы: github.com/redbase-app. Про БД redb: redb.ru. Прошлые статьи — в профиле.
If this was useful — a ⭐ on GitHub helps others find it.
