Перейти до вмісту

Фонові задачі й кешування

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

Перед цією темою: Асинхронність, Впровадження залежностей.

Junior+

Що таке IHostedService і BackgroundService і як їх зареєструвати?

Section titled “Що таке 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 і що таке абсолютне та ковзне закінчення терміну?

Section titled “Чим 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?

Section titled “Коли брати 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, output caching і HybridCache: що коли?

Section titled “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 — код за ключем чи тегом.

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+

Як коректно зупиняти фонові задачі і що буде в разі необробленого винятку?

Section titled “Як коректно зупиняти фонові задачі і що буде в разі необробленого винятку?”
Відповідь

Що це

Коректна зупинка — фонова задача завершує поточну роботу й виходить сама, а не обривається посеред операції.

Як це працює

  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 і як організувати інвалідацію та узгодженість кешу?

Section titled “Що таке 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