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

Что такое Backpressure?

Проблема

через 10 минут:
480 000 сообщений в памяти,
задержка — минуты, затем OOM

Producer
1000 сообщений/с

Неограниченный буфер
растёт на 800 сообщений/с

Consumer
200 сообщений/с

Сбой

через 10 минут:
480 000 сообщений в памяти,
задержка — минуты, затем OOM

Producer
1000 сообщений/с

Неограниченный буфер
растёт на 800 сообщений/с

Consumer
200 сообщений/с

Сбой

Производитель быстрее потребителя

Очередь сама по себе не решает проблему — она лишь откладывает её. Если средняя скорость производства выше средней скорости потребления, любая конечная очередь переполнится, а бесконечная — съест память.

Стратегии

Буфер заполнен

Блокировать производителя
(ждать места)

Отбросить сообщения
(новые / старые / низкоприоритетные)

Отказать клиенту
429 / 503 + Retry-After

Буферизовать на диск
(брокер, журнал)

Агрегировать / семплировать
(метрики, телеметрия)

Масштабировать потребителей
(autoscaling по длине очереди)

Буфер заполнен

Блокировать производителя
(ждать места)

Отбросить сообщения
(новые / старые / низкоприоритетные)

Отказать клиенту
429 / 503 + Retry-After

Буферизовать на диск
(брокер, журнал)

Агрегировать / семплировать
(метрики, телеметрия)

Масштабировать потребителей
(autoscaling по длине очереди)

Что делать, когда потребитель не успевает
Стратегия Плюсы Минусы Где уместна
Блокировка производителя Нет потерь Производитель замедляется (цепочкой до клиента) Внутренние конвейеры, импорт данных
Drop newest / oldest Система стабильна Потеря данных Телеметрия, котировки (важна последняя)
Отказ клиенту (load shedding) Быстрый отказ вместо деградации всех Клиенты получают ошибки Публичные API под перегрузкой
Диск / брокер Переживает всплески Задержка растёт, место конечно Бизнес-события
Масштабирование Решает причину Не мгновенно, стоит денег Облачные воркеры

Channels: bounded buffer

C#
var channel = Channel.CreateBounded<ImageJob>(new BoundedChannelOptions(capacity: 100)
{
    FullMode = BoundedChannelFullMode.Wait,      // Wait | DropNewest | DropOldest | DropWrite
    SingleReader = false,
    SingleWriter = false
});

// Производитель: WriteAsync «ждёт», пока в канале не освободится место — это и есть backpressure
async Task ProduceAsync(IAsyncEnumerable<ImageJob> source, CancellationToken ct)
{
    await foreach (var job in source.WithCancellation(ct))
        await channel.Writer.WriteAsync(job, ct);
    channel.Writer.Complete();
}

// Потребители: 4 параллельных обработчика
var consumers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
    await foreach (var job in channel.Reader.ReadAllAsync(ct))
        await ResizeAsync(job, ct);
}, ct));
ConsumersChannel (capacity100)Producerскорость производителя автоматическиподстраивается под скорость потребителейWriteAsync ×100 — буферполонWriteAsync — ждёт (безблокировки потока)ReadAsync — освободилосьместоWriteAsync завершился
ConsumersChannel (capacity100)Producerскорость производителя автоматическиподстраивается под скорость потребителейWriteAsync ×100 — буферполонWriteAsync — ждёт (безблокировки потока)ReadAsync — освободилосьместоWriteAsync завершился
Bounded channel останавливает производителя

Брокеры сообщений

  • RabbitMQ: prefetchCount — сколько неподтверждённых сообщений брокер отдаёт потребителю; больше не пришлёт, пока нет ack. Без ограничения брокер «вывалит» в потребителя всю очередь. Также flow control на уровне соединения при нехватке памяти/диска у брокера (публикация блокируется).
  • Kafka: pull-модель — потребитель сам запрашивает данные (max.poll.records), темп задаёт потребитель; брокер хранит журнал на диске.
  • Azure Service Bus: MaxConcurrentCalls, PrefetchCount.
  • Длина очереди — ключевая метрика: рост очереди = потребители не успевают. Масштабирование по ней (KEDA в Kubernetes).
KEDA: масштабировать воркеры по длине очереди RabbitMQ
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: image-worker }
spec:
  scaleTargetRef: { name: image-worker }
  minReplicaCount: 1
  maxReplicaCount: 20
  triggers:
    - type: rabbitmq
      metadata: { queueName: images, mode: QueueLength, value: "50" }   # ~50 сообщений на под

HTTP и ASP.NET Core

Защита сервиса от перегрузки: ограничить параллельность и быстро отказывать
builder.Services.AddRateLimiter(o =>
{
    o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
        RateLimitPartition.GetConcurrencyLimiter("global", _ => new ConcurrencyLimiterOptions
        {
            PermitLimit = 200,           // одновременно в обработке
            QueueLimit = 100,            // ещё столько ждут
            QueueProcessingOrder = QueueProcessingOrder.OldestFirst
        }));
    o.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;   // остальным — быстрый отказ
});

Лучше быстро отказать части запросов, чем принять все и обслужить всех за 60 секунд (когда клиенты уже ушли по таймауту — «работа впустую»).

Другие уровни: TCP (окно получателя), HTTP/2 flow control, Kestrel (MaxRequestBodySize, лимиты соединений), System.IO.Pipelines (PauseWriterThreshold / ResumeWriterThreshold — Kestrel перестаёт читать из сокета, когда приложение не успевает обрабатывать тело).

Сквозной backpressure

медленные ответы

ack медленнее,
prefetch исчерпан

метрика длины очереди

брокер flow control
или 503 от API

429 / 503 + Retry-After

БД перегружена

Воркеры: ограничение
параллельности к БД

Очередь растёт

Autoscaling / алерт

API публикации

Клиенты замедляются
(ретраи с backoff)

медленные ответы

ack медленнее,
prefetch исчерпан

метрика длины очереди

брокер flow control
или 503 от API

429 / 503 + Retry-After

БД перегружена

Воркеры: ограничение
параллельности к БД

Очередь растёт

Autoscaling / алерт

API публикации

Клиенты замедляются
(ретраи с backoff)

Сигнал распространяется назад по цепочке

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

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

Почему неограниченная очередь в памяти — плохая идея?

Она маскирует проблему: при устойчивом перевесе производства память растёт до OutOfMemory, а задержка обработки — до бесконечности (сообщения в конце очереди уже никому не нужны). Bounded-буфер делает перегрузку видимой и управляемой.

Чем backpressure отличается от rate limiting?

Rate limiting — фиксированное ограничение скорости (квота), задаётся заранее. Backpressure — динамическая обратная связь от фактической пропускной способности потребителя. Rate limiter может быть механизмом реализации backpressure на границе.

Как IAsyncEnumerable связан с backpressure?

Это pull-модель: производитель выполняет следующий шаг только когда потребитель запросил следующий элемент (MoveNextAsync). Потребитель сам задаёт темп — backpressure встроен в модель. В push-модели (события, IObservable) его нужно добавлять отдельно.

Что такое load shedding?

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

Итог

Backpressure — обратная связь от медленного потребителя к быстрому производителю. Без неё очереди растут до отказа. Используйте ограниченные буферы (bounded channels, prefetch), pull-модели, ограничение параллельности и быстрые отказы (429/503), следите за длиной очередей и масштабируйте потребителей по ней.