Материал подготовлен в преддверии нового потока курса «Проектирование систем (System Design)».

Распределённые системы по своей природе ненадёжны.

Я всегда рекомендую изучить «Заблуждения распределённых вычислений», чтобы понять типичные ошибки и ловушки. Одна из ключевых проблем — гарантировать, что каждое сообщение обрабатывается ровно один раз. Теоретически в большинстве систем это невозможно обеспечить. Я не буду углубляться в теорему CAP или задачу двух генералов, но достаточно сказать следующее:

  • Сообщения могут приходить не по порядку

  • Сообщения могут дублироваться

  • Доставка может задерживаться

Если вы проектируете систему, исходя из предположения, что каждое сообщение будет обработано ровно один раз, вы закладываете основу для трудноуловимых ошибок и повреждения данных.

Однако систему можно спроектировать так, чтобы побочные эффекты применялись ровно один раз — с помощью паттерна «Идемпотентный потребитель».

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

Что может пойти не так при публикации

Допустим, ваш сервис публикует событие при создании новой заметки:

await publisher.PublishAsync(new NoteCreated(note.Id, note.Title, note.Content));

Нам неважна конкретная реализация publisher или брокера сообщений. Это может быть RabbitMQ, SQS, Azure Service Bus и т. д.

Теперь представим ситуацию:

  • Издатель отправляет сообщение брокеру

  • Брокер сохраняет его и отправляет подтверждение (ACK)

  • Сбой сети: подтверждение не доходит до отправителя

  • Отправитель по тайм-ауту повторяет отправку

  • В результате у брокера уже два события NoteCreated

С точки зрения отправителя он пофиксил тайм-аут. Но с точки зрения получателя он получил два события для одного и того же создания заметки.

Distributed messaging with network error causing duplicate messages.
Распределенная система обмена сообщениями с сетевой ошибкой, приводящей к дублированию сообщений.

И это лишь один из возможных сценариев сбоя. Дубликаты могут появляться и по другим причинам:

  • Повторная доставка со стороны брокера

  • Сбои у потребителя и повторные попытки обработки

Так что даже если на стороне отправителя вы всё сделали «правильно», получатель всё равно должен вести себя защитно.

Идемпотентность на стороне отправителя (пусть это делает брокер)

Многие брокеры сообщений уже поддерживают идемпотентную публикацию через дедупликацию сообщений, если вы передаёте уникальный идентификатор сообщения. Например, Azure Service Bus умеет обнаруживать дубликаты и игнорировать повторную публикацию сообщения с тем же идентификатором в пределах заданного окна времени. Amazon SQS и другие брокеры тоже предоставляют схожие гарантии.

Нет необходимости заново реализовывать эту логику в приложении. Главное — назначать каждому сообщению стабильный идентификатор, который однозначно представляет логическое событие, которое вы отправляете.

Например, при публикации события NoteCreated:

var message = new NoteCreated(note.Id, note.Title, note.Content)
{
    MessageId = Guid.NewGuid() // или можно использовать note.Id
};

await publisher.PublishAsync(message);

Если после отправки сообщения произойдёт сбой сети, приложение может выполнить повторную попытку. Но когда брокер увидит тот же MessageId, он поймёт, что это дубликат, и безопасно его отбросит. Вы получаете дедупликацию без каких-либо пользовательских таблиц отслеживания и без дополнительного состояния в вашем сервисе.

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

Но она не решает повторные попытки на стороне получателя, которые возникают, когда сообщение доставляется повторно или ваш сервис падает в середине обработки.

Именно здесь и вступает в дело паттерн «Идемпотентный потребитель».

Реализация идемпотентного потребителя в .NET

Вот пример идемпотентного потребителя для события NoteCreated:

internal sealed class NoteCreatedConsumer(
    TagsDbContext dbContext,
    HybridCache hybridCache,
    ILogger<Program> logger) : IConsumer<NoteCreated>
{
    public async Task ConsumeAsync(ConsumeContext<NoteCreated> context)
    {
        // 1. Проверяем, не обрабатывали ли мы уже это сообщение в этом потребителе
        if (await dbContext.MessageConsumers.AnyAsync(c =>
                c.MessageId == context.MessageId &&
                c.ConsumerName == nameof(NoteCreatedConsumer)))
        {
            return;
        }

        var request = new AnalyzeNoteRequest(
            context.Message.NoteId,
            context.Message.Title,
            context.Message.Content);

        try
        {
            using var transaction = await dbContext.Database.BeginTransactionAsync();

            // 2. Детерминированная обработка: получаем теги из содержимого заметки
            var tags = AnalyzeContentForTags(request.Title, request.Content);

            // 3. Сохраняем теги
            var tagEntities = tags.Select(ProjectToTagEntity(request.NoteId)).ToList();
            dbContext.Tags.AddRange(tagEntities);

            // 4. Фиксируем, что это сообщение было обработано
            dbContext.MessageConsumers.Add(new MessageConsumer
            {
                MessageId = context.MessageId,
                ConsumerName = nameof(NoteCreatedConsumer),
                ConsumedAtUtc = DateTime.UtcNow
            });

            await dbContext.SaveChangesAsync();
            await transaction.CommitAsync();

            // 5. Обновляем кэш
            await CacheNoteTags(request, tags);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error analyzing note {NoteId}", request.NoteId);
            throw;
        }
    }
}

Это типичный идемпотентный потребитель, и здесь есть несколько важных деталей.

  1. Ключ идемпотентности

if (await dbContext.MessageConsumers.AnyAsync(c =>
        c.MessageId == context.MessageId &&
        c.ConsumerName == nameof(NoteCreatedConsumer)))
{
    return;
}

Здесь используются:

  • MessageId из транспортного уровня, то есть context.MessageId

  • ConsumerName, чтобы несколько потребителей могли безопасно обрабатывать одно и то же сообщение

Если приходит дубликат сообщения, обработка досрочно завершается, и ничего не происходит.

Ещё один важный момент: в таблице MessageConsumers должно быть уникальное ограничение на (MessageId, ConsumerName), чтобы избежать состояний гонки. Тогда даже если одно и то же сообщение начнёт обрабатываться параллельно, только один процесс сможет успешно вставить запись.

2. Атомарные побочные эффекты и запись об идемпотентности

Обработка сообщения и сохранение записи о том, что оно обработано, происходят в рамках одной транзакции:

using var transaction = await dbContext.Database.BeginTransactionAsync();

// записываем теги
dbContext.Tags.AddRange(tagEntities);

// записываем отметку об обработке сообщения этим потребителем
dbContext.MessageConsumers.Add(new MessageConsumer { ... });

await dbContext.SaveChangesAsync();
await transaction.CommitAsync();

Почему это важно:

  • Если обработка завершается с ошибкой, записи в MessageConsumers не будет, а значит, сообщение можно будет обработать повторно

  • Если обработка проходит успешно, и теги, и строка в MessageConsumer фиксируются вместе

  • Вы никогда не окажетесь в состоянии, когда работа уже выполнена, но сообщение не помечено как обработанное, или наоборот

В этом и состоит суть идемпотентности:

Выполнить эту работу ровно один раз для каждого идентификатора сообщения, даже если будут повторные попытки.

3. Обработка доставки по принципу «как минимум один раз»

В большинстве реальных систем используется доставка по принципу «как минимум один раз»:

  • Получатель обрабатывает сообщение

  • Подтверждение (ACK) не отправляется или не успевает дойти

  • Брокер повторно доставляет сообщение

  • Ваш код запускается снова

С этим паттерном при втором запуске код обращается к таблице MessageConsumers и сразу выходит.

Никаких дублирующихся побочных эффектов.

Это работает, но есть одна оговорка...

Детерминированные и недетерминированные обработчики

Что происходит, когда ваш обработчик вызывает что-то за пределами базы данных? API для отправки писем, платёжный шлюз или очередь фоновых задач?

Во всех этих случаях речь идёт об обычных побочных эффектах, для которых тоже нужна идемпотентность.

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

На этом этапе вы переходите в непростую область недетерминированных обработчиков — операций, которые нельзя безопасно повторять.

Для работы с этим есть две основные стратегии.

  1. Использовать ключ идемпотентности во внешнем вызове

Если внешний сервис это поддерживает, передавайте в каждом запросе стабильный идентификатор, например MessageId сообщения. Многие API, включая платёжные сервисы и почтовые платформы, позволяют указать заголовок с ключом идемпотентности. Сервис гарантирует, что одинаковые запросы с одним и тем же ключом будут выполнены только один раз.

Например:

await emailService.SendAsync(new SendEmailRequest
{
    To = user.Email,
    Subject = "Welcome!",
    Body = "Thanks for signing up.",
    IdempotencyKey = context.MessageId
});

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

2. Локально сохранять намерение

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

Позже фоновый процесс сможет прочитать эти отложенные записи и выполнить действие один раз. Это делает процесс детерминированным, но ценой большей сложности, дополнительных таблиц и фоновых обработчиков. Нередко это уже избыточное усложнение, если только побочный эффект не является критически важным или необратимым, как в случае с платежами или созданием учётных записей.

В конечном счёте всё упирается в уверенность в результате. Если повторное выполнение действия влечёт реальные последствия, идемпотентность нужно вводить явно. Если нет, повторная попытка выполнения операции может быть вполне допустимой.

Когда идемпотентный потребитель не нужен

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

Обновление проекции, установка флага статуса или обновление кэша — всё это примеры детерминированных действий, которые можно безопасно выполнять несколько раз. Например, «установить статус пользователя в Active» или «перестроить модель чтения» — это операции, которые перезаписывают состояние, а не добавляют к нему.

Некоторые обработчики также используют проверки предусловий, чтобы избежать дублирования. Если обработчик обновляет сущность, он может сначала проверить, находится ли она уже в нужном состоянии, и завершить выполнение. Иногда такого простого защитного условия уже достаточно.

Не стоит бездумно применять паттерн «Идемпотентный потребитель» повсюду. Используйте его там, где он действительно защищает от серьёзных последствий — когда повторная обработка может привести к финансовым потерям или нарушению целостности данных.

Во всех остальных случаях лучше придерживаться более простых решений.

Какой вывод можем сделать

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

Используйте встроенную в брокер дедупликацию сообщений, чтобы предотвращать дубликаты на стороне отправителя. На стороне получателя применяйте паттерн «Идемпотентный потребитель», чтобы гарантировать выполнение побочных эффектов один раз даже при повторных попытках. Храните запись об обработанных сообщениях и сам побочный эффект в рамках одной транзакции.

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

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

Надеюсь, было полезно.

Если вам не хватает целостного понимания, как проектировать сложные и распределённые системы под реальные ограничения, у OTUS есть курс «Проектирование систем (System Design)». В нём разбирают практические архитектурные кейсы, подходы к масштабированию и надёжности, а итогом становится проект, который помогает не только систематизировать знания, но и применять их в работе. Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 30 марта в 20:00. «Основные шаблоны проектирования в системном дизайне». Записаться

  • 14 апреля в 20:00. «Влияние нефункциональных требований на архитектуру». Записаться