.NET Tutorial
Middle6 мин чтенияВопрос 3 из 9

At-least-once vs At-most-once vs Exactly-once delivery?

Где теряются и дублируются сообщения

1. отправка:
брокер подтвердил?

2. хранение:
на диске? реплики?

3. доставка и обработка:
когда подтверждаем?

Producer

Брокер

Consumer

Побочный эффект:
БД, email, платёж

1. отправка:
брокер подтвердил?

2. хранение:
на диске? реплики?

3. доставка и обработка:
когда подтверждаем?

Producer

Брокер

Consumer

Побочный эффект:
БД, email, платёж

Три участка цепочки доставки

Каждый участок нужно настроить: подтверждения публикации (publisher confirms / acks=all), устойчивое хранение (durable очереди, persistent сообщения, репликация), момент подтверждения потребителем.

At-most-once

ConsumerБрокерm1 удаленоm1 потеряно навсегдасообщение m1ack (сразу)падение во времяобработки
ConsumerБрокерm1 удаленоm1 потеряно навсегдасообщение m1ack (сразу)падение во времяобработки
Подтверждение до обработки: возможна потеря
C#
// RabbitMQ: autoAck = true — брокер считает сообщение доставленным при отправке
await channel.BasicConsumeAsync("metrics", autoAck: true, consumer);

Уместно: телеметрия, метрики, некритичные уведомления, где потеря дешевле дубля или сложности.

At-least-once

БДConsumerБрокерm1 неподтверждено →вернуть в очередьсообщение m1обработать (записатьплатёж)падение ДО ackm1 повторно (redelivered)обработать ещё раз —ДУБЛЬ, если нет защитыack
БДConsumerБрокерm1 неподтверждено →вернуть в очередьсообщение m1обработать (записатьплатёж)падение ДО ackm1 повторно (redelivered)обработать ещё раз —ДУБЛЬ, если нет защитыack
Подтверждение после обработки: возможен дубль
C#
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 20, global: false);
consumer.ReceivedAsync += async (_, ea) =>
{
    try
    {
        await HandleAsync(ea.Body, ea.BasicProperties.MessageId);
        await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);          // ПОСЛЕ обработки
    }
    catch (Exception)
    {
        await channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false);   // → DLX после исчерпания ретраев
    }
};
await channel.BasicConsumeAsync("payments", autoAck: false, consumer);

Источники дублей: падение потребителя после обработки до ack, таймаут подтверждения, ребалансировка партиций в Kafka, повторная публикация продюсером (не получил подтверждение, хотя брокер записал), outbox relay после сбоя.

Exactly-once: почему «доставки» не бывает

Отправитель не может отличить «сообщение потерялось» от «потерялось подтверждение» (проблема двух генералов). Поэтому он либо повторяет (дубли → at-least-once), либо нет (потери → at-most-once).

Что реально достижимо — exactly-once processing (effectively-once):

MessageId уже в Inbox

новое сообщение

At-least-once доставка
(ничего не теряем)

Идемпотентный
обработчик

Пропустить, ack

Обработать + записать MessageId
в одной транзакции

Эффект ровно один раз

MessageId уже в Inbox

новое сообщение

At-least-once доставка
(ничего не теряем)

Идемпотентный
обработчик

Пропустить, ack

Обработать + записать MessageId
в одной транзакции

Эффект ровно один раз

Эффект ровно один раз = at-least-once + идемпотентность
Inbox: дедупликация в той же транзакции, что и эффект
public async Task HandleAsync(PaymentRequested msg, Guid messageId, CancellationToken ct)
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);
    var isNew = await db.Database.ExecuteSqlAsync(
        $"INSERT INTO inbox (message_id, consumer) VALUES ({messageId}, 'payments') ON CONFLICT DO NOTHING", ct) == 1;
    if (isNew)
    {
        db.Payments.Add(Payment.For(msg.OrderId, msg.Amount));
        await db.SaveChangesAsync(ct);
    }
    await tx.CommitAsync(ct);
}

Kafka: exactly-once semantics

  • Идемпотентный продюсер (enable.idempotence = true): брокер отбрасывает дубли повторных отправок (по producer id + sequence number).
  • Транзакции Kafka: чтение из топика, запись в выходной топик и коммит offset — атомарно (read-process-write). Работает, пока все эффекты внутри Kafka (Kafka Streams).
  • Как только эффект выходит за пределы Kafka (запись в PostgreSQL, вызов платёжного API) — снова нужна идемпотентность на стороне эффекта.

Сравнение

At-most-once At-least-once Exactly-once (эффект)
Потери Возможны Нет Нет
Дубли Нет Возможны Нет (устранены обработчиком)
Когда ack До обработки После обработки После обработки + дедупликация
Сложность Минимальная Низкая Выше: Inbox, ключи идемпотентности, транзакции
Применение Метрики, логи Большинство интеграций Платежи, биллинг, остатки

Производитель тоже важен

RabbitMQ: publisher confirms — брокер подтверждает запись
var channel = await connection.CreateChannelAsync(new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true));
await channel.BasicPublishAsync("events", "order.placed", mandatory: true, props, body);   // бросит исключение, если брокер не подтвердил
Kafka producer
acks=all                      # подтверждение от всех in-sync реплик
enable.idempotence=true       # без дублей при повторной отправке
min.insync.replicas=2         # на стороне топика

И конечно — Outbox: без него сообщение может потеряться ещё до брокера (сбой между коммитом БД и публикацией).

Вопросы на засыпку

Что выбрать по умолчанию?

At-least-once + идемпотентные потребители. Потеря сообщения обычно дороже дубля, а дубль можно сделать безвредным. At-most-once — только для данных, потеря которых допустима.

Как сделать обработку идемпотентной без таблицы Inbox?

Использовать естественную идемпотентность: UPSERT по бизнес-ключу, UPDATE ... SET status = 'paid' WHERE id = @id AND status = 'pending', уникальный индекс на (order_id, operation), idempotency key во внешнем API (платёжные системы поддерживают). Подробнее — в вопросе про идемпотентность.

Чем redelivery отличается от retry?

Redelivery — брокер повторно доставляет неподтверждённое сообщение (сбой, nack с requeue, таймаут). Retry — потребитель сам повторяет обработку (Polly/MassTransit retry) до подтверждения. Оба источника повторов требуют идемпотентности.

Гарантирует ли Kafka exactly-once до базы данных?

Нет. Транзакции Kafka покрывают только операции внутри Kafka. Для записи в внешнюю БД — хранить offset в той же БД в одной транзакции с данными, либо идемпотентные записи (upsert по ключу).

Итог

At-most-once теряет, at-least-once дублирует, а exactly-once как доставка невозможен — возможен только эффект ровно одного раза: надёжная доставка (outbox, подтверждения, durable-хранение) плюс идемпотентная обработка (Inbox, upsert, ключи идемпотентности). Это стандартный рецепт для любых важных бизнес-сообщений.