Фоновые задачи и кеширование
Как выполнять работу вне HTTP-запроса и как кешировать данные и ответы: hosted services и Worker Service, Hangfire и Quartz.NET, корректная остановка и ошибки в фоне, кеш в памяти и распределённый, output cache и HybridCache, cache stampede и инвалидация. На собеседовании по этой теме проверяют, понимаете ли вы гарантии: что переживёт перезапуск, что выполнится дважды и что устареет.
Перед этой темой: Асинхронность, Внедрение зависимостей.
Что такое IHostedService и BackgroundService и как их зарегистрировать?
Заголовок раздела «Что такое IHostedService и BackgroundService и как их зарегистрировать?»Что это
IHostedService— служба с двумя методами:StartAsyncхост вызывает при запуске приложения,StopAsync— при остановке.BackgroundService— базовый класс поверх него. Достаточно переопределитьExecuteAsync(CancellationToken stoppingToken)— метод с циклом фоновой работы.
Зачем
Чтобы делать работу вне HTTP-запросов: читать очередь, синхронизировать данные по таймеру, прогревать кеш.
Как пользоваться
- Унаследуйтесь от
BackgroundServiceи крутите цикл, пока не отменёнstoppingToken. - Зарегистрируйте службу:
builder.Services.AddHostedService<Worker>(). - Для процесса без HTTP возьмите шаблон
dotnet new worker. Такой воркер запускается как служба Windows (AddWindowsService()) или systemd (AddSystemd()).
Частая ошибка
Не передать stoppingToken в Task.Delay и другие вызовы. Тогда остановка приложения ждёт таймаута.
Как устроено
AddHostedService<T>()регистрирует службу синглтономIHostedService.- При старте хост по очереди вызывает
StartAsyncвсех служб в порядке регистрации. В ASP.NET Core это происходит до запуска сервера. BackgroundService.StartAsyncзапускаетExecuteAsyncи сразу возвращает управление. Задача живёт, пока не отменятstoppingToken.- При остановке хост вызывает
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 10BackgroundServiceзапускает весь метод черезTask.Run. AddWindowsService()иAddSystemd()не трогают код служб, а подменяютIHostLifetime. Служба Windows ещё получает content rootAppContext.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); } }}- Чем
BackgroundServiceотличается отIHostedService? — Это базовый класс: сам запускаетExecuteAsync, а вStopAsyncотменяет токен и ждёт задачу. - Можно ли внедрить
DbContextв конструктор воркера? — Нет, воркер — синглтон; scope создают черезIServiceScopeFactoryна каждую итерацию. - Когда выполняется
StartAsyncотносительно Kestrel? — До запуска сервера и последовательно для всех служб. - Как запустить воркер службой Windows? — Пакет
Microsoft.Extensions.Hosting.WindowsServices,AddWindowsService()и регистрация черезsc.exe create. - Что будет при двух экземплярах приложения? — Два воркера делают одну работу; дубли убирают блокировкой или планировщиком с кластером.
Чем IMemoryCache отличается от IDistributedCache и что такое абсолютное и скользящее истечение?
Заголовок раздела «Чем IMemoryCache отличается от IDistributedCache и что такое абсолютное и скользящее истечение?»Что это
IMemoryCache |
IDistributedCache |
|
|---|---|---|
| Где хранит | в памяти процесса | Redis, SQL Server, Postgres |
| Что хранит | любой объект | byte[] и строки |
| После перезапуска | пусто | данные на месте |
Как пользоваться
- Зарегистрируйте кеш:
AddMemoryCache()или, например,AddStackExchangeRedisCache(...)из пакетаMicrosoft.Extensions.Caching.StackExchangeRedis. - Внедрите интерфейс и читайте через
GetOrCreateAsyncилиGetStringAsyncиSetStringAsync. - Задайте срок жизни:
- абсолютный (
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();- Что сделает
IMemoryCacheпри нехватке памяти? — Ничего: сам он не чистится, рост ограничиваютSizeLimitи сроки. - Почему
AddDistributedMemoryCacheне распределённый? — Данные лежат в памяти процесса; это заглушка для разработки и одного сервера. - Зачем совмещать скользящее и абсолютное истечение? — Иначе популярная запись не устареет никогда.
- Что класть в Redis: объект или JSON? — Только байты; формат сериализации, версия схемы и размер — на вас.
- Как кеш связан с балансировкой? — Без общего кеша узлы отвечают по-разному; нужен распределённый кеш или sticky sessions.
Когда брать 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 — признак рассинхрона часов в кластере.
Итог: планировщик даёт персистентность и повторы ценой нагрузки на базу, версионирования аргументов и обязательной идемпотентности.
// NuGet: Hangfire.AspNetCore, Hangfire.SqlServerusing Hangfire;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHangfire(c => c.UseSqlServerStorage( builder.Configuration.GetConnectionString("Hangfire")));builder.Services.AddHangfireServer(); // обработчик задач — тоже hosted servicebuilder.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;}// NuGet: Quartzusing 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;}- Что будет, если сервер Hangfire упадёт посреди задачи? — По таймауту она вернётся в очередь и выполнится снова; отсюда идемпотентность.
- Почему в задачу передают id, а не сущность? — Аргументы сериализуются в базу: объект устареет и раздует хранилище.
- Как Quartz не запускает задание дважды в кластере? —
AdoJobStoreс блокировками в базе и[DisallowConcurrentExecution]на ключ задания. - Что такое misfire? — Пропущенный запуск триггера; политика решает, выполнить его сразу, один раз или пропустить.
- Когда не нужен ни Hangfire, ни Quartz? — Для простого цикла внутри процесса и для обмена между сервисами, где нужен брокер.
Response caching, output caching и HybridCache: что когда?
Заголовок раздела «Response caching, output caching и HybridCache: что когда?»Что это
| Механизм | Что кеширует | Где хранит |
|---|---|---|
| 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 — код по ключу или тегу.
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: только заголовок для браузера и CDNapp.MapGet("/logo", (HttpContext ctx) =>{ ctx.Response.Headers.CacheControl = "public,max-age=86400"; return "logo";});
app.Run();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);}- Почему браузер обходит response caching? — Он шлёт
Cache-Control: max-age=0, а middleware обязан это уважать. - Чем тег в output cache отличается от тега в
HybridCache? — Первый вытесняет записи из хранилища, второй логически помечает их устаревшими. - Как output cache защищает от наплыва запросов на промахе? — Блокировкой ресурса: обработчик выполняет один запрос, остальные ждут.
- Можно ли хранить output cache в
IDistributedCache? — Не рекомендуется: тегам нужна атомарность; естьAddStackExchangeRedisOutputCache(.NET 8+). - Нужен ли .NET 9 для
HybridCache? — Нет: абстракция появилась в .NET 9, а пакет работает даже на .NET Framework 4.7.2.
Как корректно останавливать фоновые задачи и что будет при необработанном исключении?
Заголовок раздела «Как корректно останавливать фоновые задачи и что будет при необработанном исключении?»Что это
Корректная остановка — фоновая задача завершает текущую работу и выходит сама, а не обрывается посреди операции.
Как это работает
- Приложение получает сигнал остановки: Ctrl+C, остановку службы,
docker stop. - Хост отменяет
stoppingToken, который получилExecuteAsync. - Код замечает отмену — через
awaitс этим токеном или проверкуIsCancellationRequested— и выходит. - Хост ждёт завершения, но не дольше таймаута: по умолчанию 30 секунд.
Что с исключениями
Если ExecuteAsync выбросит исключение, хост запишет его в лог и остановит приложение. Поэтому ошибку одной итерации ловят внутри цикла, логируют и продолжают.
Частая ошибка
while (true) и Task.Delay без токена. Приложение висит до таймаута, а задача обрывается.
Как устроено
- Сигнал остановки поднимает
ApplicationStopping, и хост вызываетStopAsyncслужб в обратном порядке регистрации. BackgroundService.StopAsyncотменяет токен и ждётExecuteAsyncили срабатывания токена остановки — что наступит раньше. Этот токен срабатывает черезShutdownTimeout.- По истечении времени хост продолжает выключение, и незавершённая работа гибнет вместе с процессом.
- Исключение из
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); // одна ошибка не роняет хост } } }}- Сколько хост ждёт фоновые службы по умолчанию? — 30 секунд с .NET 6, раньше 5; настраивается
HostOptions.ShutdownTimeout. - Почему служба Windows не перезапускается после падения воркера? —
StopHostостанавливает хост чисто, с кодом выхода 0; нуженEnvironment.Exit(1)или .NET 11. - Что будет с элементами в
Channel<T>при остановке? — Необработанные потеряются; дочитывайте в пределах бюджета или храните очередь вне процесса. - Зачем
IHostedLifecycleService? — Код до и послеStartAsyncиStopAsyncвсех служб: миграции до старта, дренирование до остановки. - Обработает ли SIGTERM консольное приложение без хоста на .NET 10? — Нет: рантайм больше не ставит свой обработчик, а хост ставит его через
ConsoleLifetime.
Что такое cache stampede и как организовать инвалидацию и согласованность кеша?
Заголовок раздела «Что такое cache stampede и как организовать инвалидацию и согласованность кеша?»Что это
Cache stampede — массовый промах: запись истекла, и все запросы одновременно идут в базу. База получает в сотни раз больше запросов, чем обычно, и может не выдержать.
Как защититься
- Один запрос на ключ, остальные ждут его результата. Так работают
HybridCache.GetOrCreateAsyncи output cache. - Разные сроки: к сроку жизни добавляют случайные секунды, чтобы ключи не истекали одновременно.
Как обновлять данные
Изменили данные в базе — удалите ключ из кеша (RemoveAsync). Следующее чтение загрузит свежую версию.
Частая ошибка
Думать, что IMemoryCache.GetOrCreateAsync вызывает фабрику один раз. При одновременных промахах её вызовут несколько запросов.
Как устроено
- Cache-aside: читаем кеш, на промахе грузим из источника и кладём со сроком жизни.
- Запись: коммитим в базу, затем удаляем ключ. Удаление идемпотентно и не кладёт в кеш значение, проигравшее гонку.
- Объединение запросов (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- Защищает ли
IMemoryCache.GetOrCreateAsyncот stampede? — Нет: при одновременных промахах фабрика выполнится несколько раз. - Почему при записи ключ удаляют, а не обновляют? — Удаление идемпотентно и не кладёт в кеш значение, проигравшее гонку.
- Что даёт разброс сроков жизни? — Ключи, загруженные одновременно, истекают в разное время, и нагрузка не приходит пиком.
- Сколько загрузок будет у
HybridCacheна пяти узлах? — До пяти: объединение работает внутри экземпляра. - Как сбросить L1 на всех узлах? — Событием через pub/sub или шину либо коротким сроком L1.