Фонові задачі й кешування
Як виконувати роботу поза HTTP-запитом і як кешувати дані та відповіді: hosted services і Worker Service, Hangfire і Quartz.NET, коректна зупинка й помилки у фоні, кеш у пам’яті та розподілений, output cache і HybridCache, cache stampede та інвалідація. На співбесіді з цієї теми перевіряють, чи розумієте ви гарантії: що переживе перезапуск, що виконається двічі і що застаріє.
Перед цією темою: Асинхронність, Впровадження залежностей.
Що таке IHostedService і BackgroundService і як їх зареєструвати?
Section titled “Що таке 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 і що таке абсолютне та ковзне закінчення терміну?
Section titled “Чим 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?
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 — ознаку розсинхрону годинників у кластері.
Підсумок: планувальник дає персистентність і повтори ціною навантаження на базу, версіонування аргументів і обов’язкової ідемпотентності.
// 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: що коли?
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 — код за ключем чи тегом.
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.
Як коректно зупиняти фонові задачі і що буде в разі необробленого винятку?
Section titled “Як коректно зупиняти фонові задачі і що буде в разі необробленого винятку?”Що це
Коректна зупинка — фонова задача завершує поточну роботу й виходить сама, а не обривається посеред операції.
Як це працює
- Застосунок отримує сигнал зупинки: 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 і як організувати інвалідацію та узгодженість кешу?
Section titled “Що таке 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.