Response Cache: HTTP-кешування через Cache-Control
Response Cache: HTTP-кешування через Cache-Control
Response Cache — це кешування на рівні HTTP-протоколу. Замість збереження даних у RAM сервера чи Redis — ми додаємо до HTTP-відповіді спеціальні заголовки, і браузер або проміжний CDN (Cloudflare, CloudFront, Nginx) кешують відповідь на своєму боці.
Це означає: при cache hit запит взагалі може не дійти до вашого сервера — браузер відповідає сам. Або навіть не покидає CDN edge-server у найближчому до користувача дата-центрі. Нульове навантаження на ваш сервер.
Client CDN Server
│──── GET /products ──────►│ │
│ │── GET /products ──────►│
│ │◄── 200 OK (fresh) ─────│
│◄── 200 OK (cached) ──────│ Cache-Control: max-age=3600
│ │
│──── GET /products ──────►│ (1 годину потому)
│◄── 200 OK (from cache) ──│ ← Сервер не отримав жодного запиту!
Cache-Control: директиви та їх значення
Cache-Control — головний HTTP-заголовок керування кешуванням. Він може містити кілька директив через кому.
Директиви видимості
Cache-Control: public
Відповідь може кешуватися будь-яким кешем: і браузером клієнта, і проміжними CDN, і корпоративними proxy. Використовуйте для публічних даних, не прив'язаних до конкретного користувача.
Cache-Control: private
Кешується тільки браузером конкретного клієнта. CDN і proxy не мають права кешувати. Використовуйте для персональних відповідей (профіль користувача, особисті налаштування). ASP.NET Core автоматично додає private якщо є заголовок Authorization або Set-Cookie.
Cache-Control: no-store
Заборона кешування взагалі де-небудь і будь-коли. Найсуворіша директива. Для дуже чутливих даних (банківські транзакції, медичні дані), що не можна зберігати навіть тимчасово.
Cache-Control: no-cache
Небезпечно імення: не означає "не кешувати". Означає: "можна кешувати, але перед використанням кешу — завжди перевіряти актуальність на сервері" (умовний запит з ETag або Last-Modified). Якщо сервер підтверджує актуальність (304 Not Modified) — браузер використовує кеш без завантаження тіла.
Директиви часу
Cache-Control: max-age=3600
Максимальний вік відповіді у секундах. Після 3600 секунд (1 година) запис вважається "stale" (застарілим). Застосовується і до браузера, і до CDN.
Cache-Control: s-maxage=86400
s-maxage ("shared maxage") — аналог max-age, але тільки для "shared caches" (CDN, proxy). Якщо обидва присутні — CDN використовує s-maxage, браузер — max-age.
Cache-Control: max-age=300, s-maxage=86400
Браузер кешує 5 хвилин, CDN — 24 години. Класична конфігурація для API: CDN обслуговує більшість запитів, браузер перевіряє відносно часто.
Cache-Control: max-age=31536000, immutable
immutable (стійкий): вміст ніколи не зміниться. Браузер не виконує умовний запит навіть після закінчення TTL. Використовується для статичних assets з хешем у URL (main.a3f9bc.js). Далеко не всі CDN підтримують.
Директиви валідації
Cache-Control: must-revalidate
Після закінчення max-age браузер зобов'язаний перевірити актуальність на сервері — не може використовувати "stale" відповідь навіть якщо сервер недоступний.
Cache-Control: stale-while-revalidate=60
Дозволяє повертати застарілу відповідь ще 60 секунд поки у фоні виконується оновлення. Покращує сприйняту швидкість: користувач отримує відповідь миттєво, кеш оновлюється паралельно.
Cache-Control: stale-if-error=86400
Якщо сервер повертає 5xx — дозволяє використовувати застарілу відповідь ще 24 години. Підвищує resilience при збоях.
ETag і умовні запити
ETag (Entity Tag) — унікальний ідентифікатор версії ресурсу. Це може бути хеш вмісту, timestamp, версійний номер — будь-що, що змінюється при зміні даних.
Сценарій умовного запиту
Перший запит:
GET /products HTTP/1.1
HTTP/1.1 200 OK
ETag: "d9a2e1b3"
Cache-Control: no-cache
Content-Length: 1024
[body: JSON з продуктами]
Другий запит (браузер перевіряє актуальність):
GET /products HTTP/1.1
If-None-Match: "d9a2e1b3" ← браузер надсилає ETag
Відповідь якщо дані не змінились:
HTTP/1.1 304 Not Modified ← Тіло відповіді ВІДСУТНЄ (0 байтів!)
ETag: "d9a2e1b3"
← Браузер використовує кеш
Відповідь якщо дані змінились:
HTTP/1.1 200 OK
ETag: "f7c1a4d8" ← Новий ETag
[body: оновлений JSON]
304 Not Modified — ключова відповідь: сервер підтверджує актуальність без передачі тіла. Для великих відповідей це значна економія трафіку.
Last-Modified і If-Modified-Since
Альтернатива ETag — timestamp останньої зміни:
GET /products HTTP/1.1
HTTP/1.1 200 OK
Last-Modified: Tue, 18 Mar 2024 10:00:00 GMT
GET /products HTTP/1.1
If-Modified-Since: Tue, 18 Mar 2024 10:00:00 GMT
HTTP/1.1 304 Not Modified ← Не змінилось після 10:00
ETag точніший (може відрізнятись при однакових timestamps), Last-Modified простіший у реалізації. На практиці часто обидва разом.
ResponseCachingMiddleware в ASP.NET Core
ASP.NET Core надає вбудований middleware для Server-side кешування відповідей з підтримкою Cache-Control:
builder.Services.AddResponseCaching(options =>
{
// Максимальний розмір тіла відповіді що кешується (64 МБ за замовчуванням)
options.MaximumBodySize = 64 * 1024 * 1024; // 64 MB
// Як трактувати URL при пошуку в кеші
options.UseCaseSensitivePaths = false; // /Products == /products
// Загальний розмір кешу (100 МБ за замовчуванням)
options.SizeLimit = 100 * 1024 * 1024;
});
var app = builder.Build();
// ВАЖЛИВО: UseResponseCaching ПЕРЕД маппінгом ендпоінтів
app.UseResponseCaching();
ResponseCache атрибут і WithMetadata
Для Minimal API — параметри кешування через WithMetadata або власні методи:
// Варіант 1: Через WithMetadata (низькорівнево)
app.MapGet("/products", async (ProductService svc) =>
Results.Ok(await svc.GetAllAsync()))
.WithMetadata(new ResponseCacheAttribute
{
Duration = 60, // Кешувати 60 секунд
Location = ResponseCacheLocation.Any, // public (браузер + CDN)
VaryByQueryKeys = ["page", "pageSize"] // Різний кеш для різних параметрів
});
// Варіант 2: Додати заголовки вручну (більш гнучко)
app.MapGet("/products/featured", async (HttpContext ctx, ProductService svc) =>
{
// Встановлюємо Cache-Control заголовок вручну
ctx.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromHours(1),
SharedMaxAge = TimeSpan.FromHours(24) // s-maxage для CDN
};
var products = await svc.GetFeaturedAsync();
return Results.Ok(products);
});
Extension method для зручного налаштування
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
public static class ResponseCacheExtensions
{
// Публічний кеш — для CDN і браузера
public static RouteHandlerBuilder CachePublic(
this RouteHandlerBuilder builder,
int browserSeconds = 300,
int cdnSeconds = 3600)
{
return builder.WithMetadata(new ResponseCacheAttribute
{
Duration = cdnSeconds,
Location = ResponseCacheLocation.Any,
VaryByHeader = "Accept-Encoding"
});
}
// Приватний кеш — тільки браузер
public static RouteHandlerBuilder CachePrivate(
this RouteHandlerBuilder builder,
int seconds = 60)
{
return builder.WithMetadata(new ResponseCacheAttribute
{
Duration = seconds,
Location = ResponseCacheLocation.Client
});
}
// Без кешу — для мутуючих ендпоінтів
public static RouteHandlerBuilder NoCache(
this RouteHandlerBuilder builder)
{
return builder.WithMetadata(new ResponseCacheAttribute
{
NoStore = true
});
}
}
Використання:
app.MapGet("/products", GetAll).CachePublic(browserSeconds: 60, cdnSeconds: 3600);
app.MapGet("/profile", GetProfile).CachePrivate(seconds: 60);
app.MapPost("/products", Create).NoCache();
app.MapPut("/products/{id}", Update).NoCache();
Ручне керування Cache-Control заголовками
Для повного контролю — встановлюємо заголовки вручну через HttpContext.Response:
app.MapGet("/products/{id:int}", async (int id, HttpContext ctx, ProductService svc) =>
{
var product = await svc.GetByIdAsync(id);
if (product is null) return Results.NotFound();
// ETag на основі останньої зміни (або хешу)
var etag = $"\"{product.UpdatedAt.Ticks}\"";
var lastModified = product.UpdatedAt;
// Перевіряємо умовні заголовки ПЕРЕД формуванням відповіді
if (ctx.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)
&& ifNoneMatch.ToString() == etag)
{
// Клієнт має актуальну версію — повертаємо 304 без тіла
return Results.StatusCode(StatusCodes.Status304NotModified);
}
if (ctx.Request.Headers.TryGetValue("If-Modified-Since", out var ifModifiedSince)
&& DateTimeOffset.TryParse(ifModifiedSince, out var ifModifiedDate)
&& lastModified <= ifModifiedDate)
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
// Встановлюємо Cache-Control заголовки
ctx.Response.Headers[HeaderNames.CacheControl] =
"public, max-age=300, s-maxage=3600, must-revalidate";
ctx.Response.Headers[HeaderNames.ETag] = etag;
ctx.Response.Headers[HeaderNames.LastModified] =
lastModified.ToString("R"); // RFC 1123 format
return Results.Ok(product);
});
Middleware для автоматичних ETag
Щоб не дублювати логіку ETag у кожному ендпоінті — можна написати middleware:
public class ETagMiddleware
{
private readonly RequestDelegate _next;
public ETagMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
// Тільки для GET/HEAD запитів
if (ctx.Request.Method is not (HttpMethods.Get or HttpMethods.Head))
{
await _next(ctx);
return;
}
// Буферуємо відповідь для обчислення ETag
var originalBody = ctx.Response.Body;
using var buffer = new MemoryStream();
ctx.Response.Body = buffer;
await _next(ctx);
// Обчислюємо ETag з тіла відповіді
buffer.Seek(0, SeekOrigin.Begin);
var content = await new StreamReader(buffer).ReadToEndAsync();
buffer.Seek(0, SeekOrigin.Begin);
if (ctx.Response.StatusCode == 200)
{
// MD5 або SHA1 хеш вмісту
var hash = Convert.ToHexString(
System.Security.Cryptography.MD5.HashData(
System.Text.Encoding.UTF8.GetBytes(content)));
var etag = $"\"{hash}\"";
ctx.Response.Headers[HeaderNames.ETag] = etag;
// Перевіряємо If-None-Match
if (ctx.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var ifNoneMatch)
&& ifNoneMatch.ToString() == etag)
{
ctx.Response.StatusCode = 304;
ctx.Response.Headers.ContentLength = 0;
ctx.Response.Body = originalBody;
return; // Не пишемо тіло
}
}
// Копіюємо тіло у оригінальний stream
await buffer.CopyToAsync(originalBody);
ctx.Response.Body = originalBody;
}
}
Vary: різні кеші для різних клієнтів
Vary заголовок вказує: «кешувати окремо для кожного унікального значення цих заголовків запиту».
Cache-Control: public, max-age=3600
Vary: Accept-Language
Це означає: CDN зберігає окремі версії відповіді для Accept-Language: uk-UA і Accept-Language: en-US. Кожна мовна версія — свій запис кешу.
// Vary по мові — для локалізованих API
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Language";
// Vary по стисненню — обов'язково якщо є gzip/br
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Encoding";
// Vary по кількох заголовках
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Language, Accept-Encoding";
// Vary: * — унікально для КОЖНОГО запиту (фактично вимикає CDN-кешування)
ctx.Response.Headers[HeaderNames.Vary] = "*"; // Не робіть так без причини!
Vary: Authorization означає: для кожного унікального Bearer токена — окремий запис кешу. При тисячах користувачів — тисячі записів, що ніколи не будуть перевикористані. CDN fill up — виснаження кешу CDN. Якщо треба кешувати авторизовані запити — використовуйте Output Cache з VaryByHeader("Authorization").Cache Busting: примусове оновлення
Cache Busting — техніка гарантованого оновлення кешу клієнтів при зміні вмісту:
1. Версія у URL (для assets)
<script src="/js/app.js?v=2024031801"></script>
<link rel="stylesheet" href="/css/styles.css?v=a3f9bc12">
При кожному деплої — змінюється версія у URL → браузер робить нові запити.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// Для файлів з хешем у назві (main.a3f9bc.js) — aggressively кешуємо
if (ctx.File.Name.Contains('.', StringSplitOptions.RemoveEmptyEntries))
{
ctx.Context.Response.Headers[HeaderNames.CacheControl] =
"public, max-age=31536000, immutable";
}
}
});
2. Інвалідація через CDN API
Cloudflare, CloudFront та інші CDN мають API для примусового видалення записів кешу:
public class CloudflareCachePurge
{
private readonly HttpClient _http;
private readonly string _zoneId;
private readonly string _apiToken;
public CloudflareCachePurge(HttpClient http, IConfiguration config)
{
_http = http;
_zoneId = config["Cloudflare:ZoneId"]!;
_apiToken = config["Cloudflare:ApiToken"]!;
}
// Видаляємо конкретні URL з CDN кешу після оновлення даних
public async Task PurgeUrlsAsync(IEnumerable<string> urls)
{
var response = await _http.PostAsJsonAsync(
$"https://api.cloudflare.com/client/v4/zones/{_zoneId}/purge_cache",
new { files = urls.ToArray() },
headers: request => request.Headers.Add("Authorization", $"Bearer {_apiToken}"));
response.EnsureSuccessStatusCode();
}
// Очистити весь кеш зони (обережно!)
public async Task PurgeEverythingAsync()
{
await _http.PostAsJsonAsync(
$"https://api.cloudflare.com/client/v4/zones/{_zoneId}/purge_cache",
new { purge_everything = true });
}
}
Використання при оновленні продукту:
public async Task UpdateAsync(int id, UpdateProductRequest req)
{
// 1. Оновлюємо БД
var product = await _db.Products.FindAsync(id);
product!.Name = req.Name;
await _db.SaveChangesAsync();
// 2. Інвалідуємо CDN кеш для змінених URL
await _cfCache.PurgeUrlsAsync([
$"https://api.myshop.com/products",
$"https://api.myshop.com/products/{id}"
]);
}
Обмеження Response Cache
Request Caching Middleware в ASP.NET Core НЕ кешує відповіді якщо:
| Умова | Причина |
|---|---|
Запит має Authorization | Відповідь може містити приватні дані |
Відповідь має Set-Cookie | Кожен клієнт має унікальну сесію |
Метод не GET або HEAD | POST, PUT, DELETE — завжди мутуючі |
Статус не 200 OK | Помилки не кешуються |
Cache-Control: no-store | Явна заборона |
Немає Cache-Control | Невизначена поведінка = не кешуємо |
Для кешування авторизованих запитів — використовуйте Output Cache (наступна стаття).
Практичні завдання
Рівень 1 — Базовий
Завдання 1.1. Додайте до GET /products заголовки Cache-Control: public, max-age=300, s-maxage=3600 та Vary: Accept-Language, Accept-Encoding. Через DevTools браузера або Httpie перевірте заголовки відповіді. Зробіть два запити підряд і переконайтесь що другий повертає 200 from disk cache у DevTools.
Завдання 1.2. Реалізуйте повноцінний ETag для GET /products/{id}: обчислюйте ETag як MD5-хеш JSON-серіалізації товару. При PUT /products/{id} — ETag змінюється автоматично. Перевірте через .http файл: після отримання ETag надішліть If-None-Match: "{etag}" і отримайте 304.
Рівень 2 — Логіка
Завдання 2.1. Реалізуйте ETagMiddleware з наданого прикладу. Зареєструйте через app.UseMiddleware<ETagMiddleware>() тільки для /products/* шляхів (через app.Map("/products", subApp => subApp.UseMiddleware<ETagMiddleware>())). Переконайтесь що POST/PUT/DELETE не проходять через ETag middleware.
Завдання 2.2. Реалізуйте stale-while-revalidate: endpoint GET /products/catalog повертає застарілі дані в межах 60 секунд після закінчення TTL, поки у фоні (через Task.Run або Hangfire) оновлюється кеш. Заголовок: Cache-Control: public, max-age=300, stale-while-revalidate=60.
Рівень 3 — Архітектура
Завдання 3.1. Реалізуйте CloudflareCachePurge (або аналогічний для іншого CDN). При кожному PUT/DELETE /products/{id} — надсилайте запит до CDN API для видалення кешованих URL. Використайте IHttpClientFactory з retry-policy через Polly. Додайте Feature Flag: якщо CdnPurge:Enabled = false — CDN purge пропускається.
IDistributedCache і Redis: розподілений кеш
Детальний розгляд IDistributedCache в ASP.NET Core з Redis через StackExchange.Redis: реєстрація, серіалізація, GetOrCreateAsync-обгортка, теги через Redis Sets, Lua-скрипти, двох'ярусний кеш L1+L2, паблікація повідомлень (Pub/Sub) для інвалідації, rate limiting.
Output Cache: серверний кеш HTTP-відповідей (.NET 7+)
Детальний розгляд Output Caching у ASP.NET Core Minimal API (.NET 7+): реєстрація, іменовані та inline-політики, SetVaryByQuery/RouteValue/Header, теги та EvictByTagAsync, кешування авторизованих запитів, власні IOutputCachePolicy, Redis store для multi-instance, блокування (locking), моніторинг.