Перейти к содержимому

Фоновые задачи и кеширование

Как выполнять работу вне HTTP-запроса и как кешировать данные и ответы: hosted services и Worker Service, Hangfire и Quartz.NET, корректная остановка и ошибки в фоне, кеш в памяти и распределённый, output cache и HybridCache, cache stampede и инвалидация. На собеседовании по этой теме проверяют, понимаете ли вы гарантии: что переживёт перезапуск, что выполнится дважды и что устареет.

Перед этой темой: Асинхронность, Внедрение зависимостей.

Junior+

Что такое IHostedService и BackgroundService и как их зарегистрировать?

Заголовок раздела «Что такое IHostedService и BackgroundService и как их зарегистрировать?»
Ответ

Что это

  • IHostedService — служба с двумя методами: StartAsync хост вызывает при запуске приложения, StopAsync — при остановке.
  • BackgroundService — базовый класс поверх него. Достаточно переопределить ExecuteAsync(CancellationToken stoppingToken) — метод с циклом фоновой работы.

Зачем

Чтобы делать работу вне HTTP-запросов: читать очередь, синхронизировать данные по таймеру, прогревать кеш.

Как пользоваться

  1. Унаследуйтесь от BackgroundService и крутите цикл, пока не отменён stoppingToken.
  2. Зарегистрируйте службу: builder.Services.AddHostedService<Worker>().
  3. Для процесса без HTTP возьмите шаблон dotnet new worker. Такой воркер запускается как служба Windows (AddWindowsService()) или systemd (AddSystemd()).

Частая ошибка

Не передать stoppingToken в Task.Delay и другие вызовы. Тогда остановка приложения ждёт таймаута.

Как устроено

  1. AddHostedService<T>() регистрирует службу синглтоном IHostedService.
  2. При старте хост по очереди вызывает StartAsync всех служб в порядке регистрации. В ASP.NET Core это происходит до запуска сервера.
  3. BackgroundService.StartAsync запускает ExecuteAsync и сразу возвращает управление. Задача живёт, пока не отменят stoppingToken.
  4. При остановке хост вызывает StopAsync в обратном порядке. BackgroundService отменяет токен и ждёт ExecuteAsync, но не дольше таймаута остановки.

Когда что выбирать

Задача Решение
Бесконечный цикл: очередь, таймер BackgroundService
Короткая инициализация до приёма запросов IHostedService с работой в StartAsync
Фоновый процесс без HTTP Worker Service: Host.CreateApplicationBuilder

Ловушки

  • Hosted service — синглтон, поэтому DbContext и другие scoped-сервисы в конструктор не внедрить. На каждую итерацию создают scope через IServiceScopeFactory.CreateAsyncScope() — подробнее в теме Внедрение зависимостей.
  • Для периодической работы берут PeriodicTimer (.NET 6+). В отличие от System.Threading.Timer, его итерации не накладываются друг на друга.
  • Каждый экземпляр приложения запускает свою копию воркера. Координации между ними нет.

Правило

Долгий цикл — в ExecuteAsync, короткая инициализация — в StartAsync, новый scope — на каждую единицу работы.

Под капотом

  • AddHostedService регистрирует службу через TryAddEnumerable: повторная регистрация того же типа не создаёт второй экземпляр.
  • До .NET 10 синхронная часть ExecuteAsync до первого await выполнялась внутри StartAsync и задерживала старт остальных служб и сервера. С .NET 10 BackgroundService запускает весь метод через Task.Run.
  • AddWindowsService() и AddSystemd() не трогают код служб, а подменяют IHostLifetime. Служба Windows ещё получает content root AppContext.BaseDirectory, потому что её текущий каталог — System32.

Цена

  • Нет персистентности: работа в памяти теряется при деплое, перезапуске или падении.
  • При масштабировании каждый экземпляр делает ту же работу: два пода — два ночных отчёта.
  • Фоновая работа делит пул потоков и CPU с запросами.

Компромиссы

Вариант Плюс Минус
Воркер в веб-процессе один деплой перезапуск веба рвёт задачи
Отдельный Worker Service свои релизы и масштабирование ещё один процесс

Единственный исполнитель на всю ферму требует блокировки в общем хранилище или планировщика с кластером.

Как проверить в проде

Логируйте начало и конец итерации и отдавайте время последней итерации в health check. Зависший воркер виден по тому, что эта метка перестала обновляться.

Итог: hosted service — простой механизм внутри процесса; гарантий доставки, расписания и единственности в ферме он не даёт.

Пример
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHostedService<HeartbeatWorker>(); // синглтон, стартует вместе с хостом
var app = builder.Build();
app.MapGet("/", () => "OK");
app.Run();
public sealed class HeartbeatWorker(ILogger<HeartbeatWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Тики не накладываются, даже если итерация длиннее периода
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken)) // при остановке — отмена
{
logger.LogInformation("Heartbeat at {Time}", DateTimeOffset.Now);
}
}
}
Junior+

Чем IMemoryCache отличается от IDistributedCache и что такое абсолютное и скользящее истечение?

Заголовок раздела «Чем IMemoryCache отличается от IDistributedCache и что такое абсолютное и скользящее истечение?»
Ответ

Что это

IMemoryCache IDistributedCache
Где хранит в памяти процесса Redis, SQL Server, Postgres
Что хранит любой объект byte[] и строки
После перезапуска пусто данные на месте

Как пользоваться

  1. Зарегистрируйте кеш: AddMemoryCache() или, например, AddStackExchangeRedisCache(...) из пакета Microsoft.Extensions.Caching.StackExchangeRedis.
  2. Внедрите интерфейс и читайте через GetOrCreateAsync или GetStringAsync и SetStringAsync.
  3. Задайте срок жизни:
    • абсолютный (AbsoluteExpirationRelativeToNow) — запись живёт не дольше N минут;
    • скользящий (SlidingExpiration) — запись удаляется, если её не читали N минут, и каждое чтение продлевает срок.

Частая ошибка

Ставить только скользящее истечение. Популярную запись читают постоянно, и она годами отдаёт устаревшие данные.

Как устроено

  • MemoryCache — потокобезопасный словарь в процессе. Значение хранится по ссылке: изменили объект после чтения — изменили его для всех.
  • IDistributedCache знает только byte[], сериализация — на вас. Refresh продлевает скользящий срок без чтения значения.
  • AddDistributedMemoryCache() — реализация IDistributedCache в памяти процесса. Она для разработки и тестов, распределённой она не является.

Когда что выбирать

Ситуация Кеш
Один экземпляр, справочники, настройки IMemoryCache
Ферма, данные должны совпадать на узлах IDistributedCache (Redis)
Нужны и скорость памяти, и общий кеш L1 в памяти поверх L2 в Redis

Ловушки

  • IMemoryCache не чистится при нехватке памяти. Ограничивайте его SizeLimit на отдельном экземпляре MemoryCache: при лимите размер обязан указывать каждый, кто пишет в кеш.
  • Изменение закешированного объекта меняет его для всех читателей. Кешируйте неизменяемые данные.
  • Ключи из пользовательского ввода раздувают кеш — это вектор DoS.

Правило

Код должен работать и без кеша: кеш — оптимизация, а не хранилище данных.

Под капотом

  • Истечение ленивое. Просроченная запись не отдаётся при чтении, а удаляется при обращениях к кешу и фоновых проходах не чаще ExpirationScanFrequency (по умолчанию раз в минуту).
  • При заданном SizeLimit запись без Size вызывает InvalidOperationException. При переполнении кеш сжимается: сначала просроченное, затем по приоритету и давности использования.
  • IDistributedCache хранит байты вместе со сроками. Скользящий срок продлевается при чтении, а это запись в хранилище на каждое попадание.

Цена

  • L1 умножает память на число узлов. Большие долгоживущие объекты оседают в поколении 2 и в LOH.
  • L2 — сетевой вызов и сериализация на каждое попадание. Сбой Redis не должен ронять приложение: короткие таймауты и чтение из источника.

Компромиссы

  • L1 на разных узлах расходится на время жизни записи. Короткий срок L1 или сообщения об инвалидации сужают это окно.
  • Двухуровневая схема даёт скорость памяти и общий L2, но усложняет инвалидацию.

Как проверить в проде

  • MemoryCacheOptions.TrackStatistics = true и GetCurrentStatistics() (.NET 7+) показывают попадания, промахи и число записей.
  • Рост памяти виден в dotnet-counters по размеру кучи, а что именно держит кеш — в dotnet-gcdump.

Итог: локальный кеш — скорость без согласованности, распределённый — согласованность ценой сети; размер и сроки всегда задаёт разработчик.

Пример
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddDistributedMemoryCache(); // в проде: AddStackExchangeRedisCache(...)
var app = builder.Build();
app.MapGet("/rates", async (IMemoryCache cache) =>
await cache.GetOrCreateAsync("rates", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); // не дольше 10 минут
entry.SlidingExpiration = TimeSpan.FromMinutes(2); // или 2 минуты без чтений
return Task.FromResult(new[] { 41.5m, 44.9m }); // объект хранится как есть
}));
app.MapGet("/users/{id:int}/name", async (int id, IDistributedCache cache) =>
{
var key = $"user-name:{id}";
var name = await cache.GetStringAsync(key); // внутри — byte[]
if (name is null)
{
name = $"User {id}"; // «загрузка из базы»
await cache.SetStringAsync(key, name, new DistributedCacheEntryOptions
{ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
}
return name;
});
app.Run();
Middle+

Когда брать Hangfire или Quartz.NET вместо BackgroundService?

Заголовок раздела «Когда брать Hangfire или Quartz.NET вместо BackgroundService?»
Ответ

Что это

  • Hangfire запускает обычные методы в фоне: BackgroundJob.Enqueue(() => SendEmail(id)). Задача сохраняется в базе, выполняется, при ошибке повторяется, и всё это видно в веб-дашборде.
  • Quartz.NET — планировщик: задание (IJob) плюс триггер, который решает, когда его запускать, например по cron.

Зачем

BackgroundService живёт в памяти процесса. Перезапустили приложение — потеряли задачи из очереди и пропустили запуск по расписанию.

Как пользоваться

Нужно Инструмент
Цикл, опрос, таймер BackgroundService
Письмо в фоне с повторами Hangfire
Отчёт каждую ночь в 02:00 Hangfire RecurringJob или Quartz

Частая ошибка

Передавать в задачу Hangfire сущность EF или большой объект. Аргументы сериализуются в JSON и лежат в базе, поэтому передают идентификатор.

Как устроено

  • Hangfire. Клиент сериализует вызов метода — тип, метод, аргументы — в хранилище. Сервер Hangfire (hosted service с пулом воркеров) забирает задачи из очереди. Фильтр AutomaticRetry по умолчанию повторяет упавшую задачу 10 раз с растущей задержкой.
  • Quartz.NET. Задания и триггеры лежат в JobStore: по умолчанию RAMJobStore в памяти, для надёжности — AdoJobStore в базе. Пропущенные запуски, например пока приложение было выключено, обрабатывает misfire-политика триггера.

Когда что выбирать

Критерий Hangfire Quartz.NET
Модель очередь вызовов методов задания и триггеры
Хранение всегда в хранилище в памяти или AdoJobStore
Повторы AutomaticRetry из коробки вручную
Расписание cron cron с секундами, календари
Несколько узлов общее хранилище AdoJobStore с кластером
Дашборд встроен пакет Quartz.Dashboard

Ловушки

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

Правило

Персистентность, повторы или «один исполнитель на ферму» — внешний планировщик. Остальное — BackgroundService.

Под капотом

  • Hangfire с SQL Server забирает задачу так, что другие серверы её не видят. Если сервер умер посреди работы, задача по таймауту вернётся в очередь и выполнится снова — отсюда гарантия «как минимум один раз».
  • Узлы кластера Quartz конкурируют за триггеры через блокировки строк в базе и отмечаются в ней каждые 7,5 секунды. Узел, чьи часы спешат, может объявить живого соседа упавшим, поэтому часы синхронизируют.

Цена

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

Компромиссы

  • Хранилище Redis и пакеты задач (batches) у Hangfire — в платной версии Pro.
  • Quartz 4.0 (сентябрь 2026) работает только на .NET 10 и сменил API: IJob.Execute возвращает ValueTask и принимает CancellationToken, схема базы требует миграции. Ветка 3.x для .NET Framework поддерживается.
  • Обмен задачами между сервисами — брокер сообщений. Периодический запуск отдельного процесса — Kubernetes CronJob.

Как проверить в проде

Следите в дашборде за числом задач в состояниях Failed и Retrying и за длиной очередей. В Quartz алертят на сообщение is still active but was recovered by another instance — признак рассинхрона часов в кластере.

Итог: планировщик даёт персистентность и повторы ценой нагрузки на базу, версионирования аргументов и обязательной идемпотентности.

Hangfire
// NuGet: Hangfire.AspNetCore, Hangfire.SqlServer
using Hangfire;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHangfire(c => c.UseSqlServerStorage(
builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer(); // обработчик задач — тоже hosted service
builder.Services.AddScoped<ReceiptService>();
var app = builder.Build();
app.MapHangfireDashboard("/jobs"); // по умолчанию — только локальные запросы
// Вызов сериализуется в базу; при ошибке — до 10 повторов
app.MapPost("/orders/{id:int}/receipt", (int id, IBackgroundJobClient jobs) =>
Results.Accepted(value: jobs.Enqueue<ReceiptService>(s => s.SendAsync(id))));
// Каждый день в 02:00 (UTC по умолчанию)
app.Services.GetRequiredService<IRecurringJobManager>()
.AddOrUpdate<ReceiptService>("cleanup", s => s.CleanupAsync(), Cron.Daily(2));
app.Run();
public class ReceiptService
{
public Task SendAsync(int orderId) => Task.CompletedTask; // id, а не сущность
public Task CleanupAsync() => Task.CompletedTask;
}
Quartz.NET 4.x
// NuGet: Quartz
using Quartz;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddQuartz(q =>
{
// Cron в Quartz начинается с секунд: каждый день в 02:00:00
q.ScheduleJob<NightlyReportJob>(t => t.WithIdentity("nightly-report").WithCronSchedule("0 0 2 * * ?"));
q.UsePersistentStore(s =>
{
s.UseSqlServer(builder.Configuration.GetConnectionString("Quartz")!);
s.UseClustering(); // триггер сработает на одном узле
s.UseSystemTextJsonSerializer();
});
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
builder.Build().Run();
[DisallowConcurrentExecution] // одно выполнение на ключ задания во всём кластере
public sealed class NightlyReportJob : IJob
{
// В 3.x сигнатура другая: Task Execute(IJobExecutionContext context)
public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default;
}
Middle+
Ответ

Что это

Механизм Что кеширует Где хранит
Response caching HTTP-ответ браузер, прокси, CDN, middleware
Output caching (.NET 7+) HTTP-ответ сервер: память или Redis
HybridCache (.NET 9+) данные в коде память плюс распределённый кеш

Как пользоваться

  • Response caching: [ResponseCache(Duration = 60)] выставляет Cache-Control: public,max-age=60. Серверный кеш добавляют AddResponseCaching() и UseResponseCaching().
  • Output caching: AddOutputCache(), UseOutputCache() и .CacheOutput() на эндпоинте.
  • HybridCache: пакет Microsoft.Extensions.Caching.Hybrid, AddHybridCache() и GetOrCreateAsync(key, factory).

Частая ошибка

Кешировать ответы для авторизованных пользователей: один получит данные другого. Output cache по умолчанию такие ответы не кеширует — не отключайте это правило без варьирования ключа по пользователю.

Как устроено

  • Response caching middleware следует RFC 9111: кеширует GET и HEAD с кодом 200, без Authorization и Set-Cookie, и уважает Cache-Control запроса. Браузер с max-age=0 обходит кеш.
  • Output caching игнорирует заголовки клиента. По умолчанию кеширует только 200 на GET и HEAD, без cookie и без аутентификации, на 60 секунд. Ключ — весь URL, его настраивают SetVaryByQuery и SetVaryByHeader.
  • HybridCache: GetOrCreateAsync проверяет L1 (MemoryCache), затем L2 (зарегистрированный IDistributedCache) и только потом вызывает фабрику — одну на ключ в экземпляре приложения. Значения сериализуются через System.Text.Json.

Когда что выбирать

Задача Выбор
Статика и публичные GET для CDN Cache-Control, [ResponseCache]
Страницы и ответы API целиком output cache
Результаты запросов к базе и внешним API HybridCache

Ловушки

  • UseOutputCache ставят после UseCors, UseAuthentication и UseAuthorization, иначе можно отдать ответ, закешированный для другого пользователя.
  • Для output cache на нескольких узлах берут AddStackExchangeRedisOutputCache (.NET 8+), а не IDistributedCache: тегам нужна атомарность.
  • HybridCache по умолчанию десериализует новую копию на каждое чтение. Переиспользование экземпляра включают sealed и [ImmutableObject(true)] на типе.

Правило

Сначала решите, что кешируете — ответ или данные, — потом где хранится копия и кто её сбрасывает.

Под капотом

  • Output cache хранит ответы в IOutputCacheStore: по умолчанию в памяти с лимитом 100 МБ, а Redis-хранилище поддерживает теги атомарно. Повторная проверка по ETag и If-Modified-Since отдаёт 304 без тела.
  • HybridCache объединяет одновременные вызовы по ключу внутри экземпляра. Токен фабрики отменяется, только если отменили все ожидающие.
  • Теги HybridCache — метки времени инвалидации: при чтении запись старше метки считается промахом.

Цена

  • HybridCache: сериализация на каждую запись и чтение L2. Записи больше MaximumPayloadBytes (по умолчанию 1 МБ) не кешируются.
  • Output cache без Redis держит отдельную копию на каждом узле. SetVaryByQuery("*") умножает число записей на число комбинаций параметров.

Компромиссы

  • Свежесть против нагрузки: max-age в CDN не отозвать. Обычно дают короткий max-age клиентам, а на сервере держат output cache с вытеснением по тегам.
  • У HybridCache нет встроенного механизма рассылки инвалидаций между узлами (backplane). Его даёт FusionCache, который реализует ту же абстракцию.

Как проверить в проде

Смотрите заголовки Age и Cache-Control в ответах, считайте вызовы фабрик и запросы к базе, сравнивайте задержку P95 до и после включения кеша.

Итог: выбирайте слой по тому, кто должен уметь сбросить копию: CDN — никто, output cache — сервер по тегу, HybridCache — код по ключу или тегу.

Output cache и Cache-Control
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(o =>
o.AddPolicy("products", p => p.Expire(TimeSpan.FromMinutes(5)).Tag("products")));
var app = builder.Build();
app.UseOutputCache();
// Ответ целиком хранится на сервере, Cache-Control клиента не влияет
app.MapGet("/products", () => new[] { "apple", "pear" }).CacheOutput("products");
app.MapPost("/products", async (IOutputCacheStore store, CancellationToken ct) =>
{
await store.EvictByTagAsync("products", ct); // сброс всех ответов с тегом
return Results.NoContent();
});
// Response caching: только заголовок для браузера и CDN
app.MapGet("/logo", (HttpContext ctx) =>
{
ctx.Response.Headers.CacheControl = "public,max-age=86400";
return "logo";
});
app.Run();
HybridCache
Microsoft.Extensions.Caching.Hybrid
using Microsoft.Extensions.Caching.Hybrid;
// Регистрация: builder.Services.AddHybridCache() из пакета Microsoft.Extensions.Caching.Hybrid.
// L2 — зарегистрированный IDistributedCache, например Redis.
public sealed class PriceService(HybridCache cache)
{
private static readonly HybridCacheEntryOptions Options = new()
{
Expiration = TimeSpan.FromMinutes(10), // общий срок, в том числе L2
LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 короче: другие узлы не узнают о сбросе
};
// Одновременные промахи по ключу в этом процессе ждут одну загрузку
public ValueTask<decimal> GetAsync(int id, CancellationToken ct) =>
cache.GetOrCreateAsync($"price:{id}", async token =>
{
await Task.Delay(100, token); // «запрос в базу»
return id * 10m;
}, Options, tags: ["prices"], cancellationToken: ct);
// Логический сброс: записи старше этого момента считаются промахом
public ValueTask InvalidateAllAsync(CancellationToken ct) => cache.RemoveByTagAsync("prices", ct);
}
Senior+

Как корректно останавливать фоновые задачи и что будет при необработанном исключении?

Заголовок раздела «Как корректно останавливать фоновые задачи и что будет при необработанном исключении?»
Ответ

Что это

Корректная остановка — фоновая задача завершает текущую работу и выходит сама, а не обрывается посреди операции.

Как это работает

  1. Приложение получает сигнал остановки: Ctrl+C, остановку службы, docker stop.
  2. Хост отменяет stoppingToken, который получил ExecuteAsync.
  3. Код замечает отмену — через await с этим токеном или проверку IsCancellationRequested — и выходит.
  4. Хост ждёт завершения, но не дольше таймаута: по умолчанию 30 секунд.

Что с исключениями

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

Частая ошибка

while (true) и Task.Delay без токена. Приложение висит до таймаута, а задача обрывается.

Как устроено

  1. Сигнал остановки поднимает ApplicationStopping, и хост вызывает StopAsync служб в обратном порядке регистрации.
  2. BackgroundService.StopAsync отменяет токен и ждёт ExecuteAsync или срабатывания токена остановки — что наступит раньше. Этот токен срабатывает через ShutdownTimeout.
  3. По истечении времени хост продолжает выключение, и незавершённая работа гибнет вместе с процессом.
  4. Исключение из ExecuteAsync хост логирует как BackgroundService failed. OperationCanceledException во время остановки ошибкой не считается.

Когда что выбирать

Ситуация Решение
Итерация может упасть, служба должна жить try/catch внутри цикла
Без службы приложение бессмысленно исключение останавливает хост
Остановке нужно больше времени HostOptions.ShutdownTimeout

Ловушки

  • BackgroundServiceExceptionBehavior.Ignore превращает упавшую службу в зомби: процесс жив, работа не идёт.
  • StartAsync выполняется последовательно и до запуска сервера, поэтому тяжёлая работа в нём задерживает старт.
  • Таймер, который стартует новую итерацию, не дожидаясь предыдущей, при остановке оставляет хвост незавершённых операций.

Правило

Каждый await в фоновой задаче получает stoppingToken, ошибки итерации ловятся в цикле, а падение службы целиком — сигнал остановить хост.

Под капотом

  • Хост отдельно ждёт задачу каждого BackgroundService. При ошибке он пишет лог и с StopHost вызывает StopApplication().
  • В .NET 11 (выходит в ноябре 2026) RunAsync и StopAsync после такой остановки выбрасывают исключение службы, и код выхода становится ненулевым. До этого приложение завершалось с кодом 0.
  • IHostedLifecycleService (.NET 8) добавляет StartingAsync, StartedAsync, StoppingAsync и StoppedAsync. Остановочные методы входят в тот же ShutdownTimeout.

Цена

  • ShutdownTimeout — один бюджет на всех: Kestrel дренирует запросы, а службы останавливаются по очереди, если не включён ServicesStopConcurrently.
  • Окно оркестратора жёстче: Kubernetes даёт 30 секунд до SIGKILL, docker stop — 10, systemd — 90.

Компромиссы

  • Очередь на Channel<T> теряет элементы при остановке и падении. Варианты — дочитать её в пределах бюджета или хранить работу вне процесса (Hangfire, брокер, outbox).
  • Ограниченный канал даёт обратное давление: при переполнении WriteAsync ждёт, и запросы замедляются вместо роста памяти.
  • В службе Windows документация советует Environment.Exit(1) после фатальной ошибки, чтобы сработало восстановление службы. Цена — finally и корректная остановка не выполнятся.

Как проверить в проде

Ищите в логах BackgroundService failed и длительность от Application is shutting down до выхода. На стенде отправьте SIGTERM под нагрузкой и проверьте, что начатая работа завершилась.

Итог: корректная остановка — это токен в каждом вызове, бюджет меньше окна оркестратора и работа, которая переживёт обрыв.

Пример
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(20); // меньше окна оркестратора (K8s — 30 с)
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost; // по умолчанию
});
builder.Services.AddSingleton(Channel.CreateBounded<string>(100)); // переполнен — WriteAsync ждёт
builder.Services.AddHostedService<EmailSender>();
var app = builder.Build();
app.MapPost("/emails", async (string to, Channel<string> queue, CancellationToken ct) =>
{
await queue.Writer.WriteAsync(to, ct);
return Results.Accepted();
});
app.Run();
public sealed class EmailSender(Channel<string> queue, ILogger<EmailSender> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// При остановке чтение прервётся, непрочитанные адреса потеряются
await foreach (var to in queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await Task.Delay(200, CancellationToken.None); // начатое письмо дописываем
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email to {To}", to); // одна ошибка не роняет хост
}
}
}
}
Senior+

Что такое cache stampede и как организовать инвалидацию и согласованность кеша?

Заголовок раздела «Что такое cache stampede и как организовать инвалидацию и согласованность кеша?»
Ответ

Что это

Cache stampede — массовый промах: запись истекла, и все запросы одновременно идут в базу. База получает в сотни раз больше запросов, чем обычно, и может не выдержать.

Как защититься

  • Один запрос на ключ, остальные ждут его результата. Так работают HybridCache.GetOrCreateAsync и output cache.
  • Разные сроки: к сроку жизни добавляют случайные секунды, чтобы ключи не истекали одновременно.

Как обновлять данные

Изменили данные в базе — удалите ключ из кеша (RemoveAsync). Следующее чтение загрузит свежую версию.

Частая ошибка

Думать, что IMemoryCache.GetOrCreateAsync вызывает фабрику один раз. При одновременных промахах её вызовут несколько запросов.

Как устроено

  1. Cache-aside: читаем кеш, на промахе грузим из источника и кладём со сроком жизни.
  2. Запись: коммитим в базу, затем удаляем ключ. Удаление идемпотентно и не кладёт в кеш значение, проигравшее гонку.
  3. Объединение запросов (single-flight): по ключу хранится задача текущей загрузки, и остальные промахи ждут её.

Когда что выбирать

Паттерн Когда
Cache-aside по умолчанию: кеш необязателен, грузим лениво
Write-through запись идёт в кеш и базу синхронно, чтение сразу после записи частое
Write-behind запись в кеш, в базу — позже пачками; риск потерь допустим

Ловушки

  • Данные, загруженные при старте с одинаковым сроком, истекают одновременно. Добавляйте разброс.
  • Удаление ключа до коммита транзакции: параллельное чтение снова положит старое значение.
  • Несуществующие ключи бьют в базу каждый раз. Кешируйте и «не найдено» с коротким сроком.

Правило

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

Под капотом

  • HybridCache объединяет вызовы только внутри экземпляра: на пяти узлах будет до пяти загрузок. Блокировка ресурса в output cache тоже локальна.
  • Распределённая блокировка (например, SET NX PX в Redis) сводит загрузки к одной, но требует таймаута: держатель может умереть, а блокировка — истечь посреди работы.
  • Обновление заранее (refresh-ahead) и отдача устаревшего значения на время перезагрузки (stale-while-revalidate) убирают промахи с горячего пути. В FusionCache это eager refresh и fail-safe.

Цена

Ожидающие разделяют задержку одной загрузки. Если она упала, ошибку получают все — либо, с fail-safe, устаревшее значение.

Компромиссы

  • Гонка: запрос A промахнулся и прочитал старое из базы, B записал новое и удалил ключ, A положил старое в кеш. Окно ограничивает срок жизни; сужают его короткие сроки, ключи с версией (product:42:v7) и повторное удаление с задержкой.
  • L1 на других узлах сбрасывают событием (Redis pub/sub, шина, CDC или outbox) или коротким LocalCacheExpiration.
  • Группы записей сбрасывают тегами: RemoveByTagAsync в HybridCache, EvictByTagAsync в output cache.

Как проверить в проде

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

Итог: stampede гасят объединением запросов и разбросом сроков, а согласованность ограничивают сроком жизни, удалением после коммита и событиями инвалидации.

Пример
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
var cache = new MemoryCache(new MemoryCacheOptions());
var inFlight = new ConcurrentDictionary<int, Lazy<Task<decimal>>>();
int dbCalls = 0;
async Task<decimal> GetPriceAsync(int id)
{
if (cache.TryGetValue(id, out decimal price)) return price; // попадание
// Single-flight: все промахи по ключу ждут одну загрузку
var load = inFlight.GetOrAdd(id, key => new Lazy<Task<decimal>>(async () =>
{
Interlocked.Increment(ref dbCalls);
await Task.Delay(100); // «запрос в базу»
var ttl = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(Random.Shared.Next(60)); // разброс
return cache.Set(key, key * 10m, ttl);
}));
try { return await load.Value; }
finally { inFlight.TryRemove(KeyValuePair.Create(id, load)); } // убираем только свою загрузку
}
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => GetPriceAsync(42)));
Console.WriteLine(dbCalls); // 1