История

Однажды в проде контейнеры одного из сервисов начали падать по OutOfMemory. Контейнерам было выделено по 4 ГБ оперативной памяти, нагрузка была вполне обычной: десятки страниц в секунду.

Расследование показало, что проблема была в JSON-конфиге.

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

Фактически у нас был сервер Valkey, но в коде приложения использовалась обычная Redis-библиотека.

Из-за ошибки в обработке закэшированный JSON распухал до десятков мегабайт. В кэш писались не только нужные поля, но и вложенные коллекции, история изменений, сериализованные справочники.

Казалось бы, если мы запрашиваем один и тот же JSON, размер не должен так критично влиять.

Но всё оказалось интереснее.

Каждый запрос к Redis означал:

  1. Сетевой round-trip до удалённого сервера

  2. Получение 10 MB по TCP

  3. Аллокацию 10 MB массива в managed heap

  4. Десериализацию в десятки тысяч объектов

  5. Обработку и обратную сериализацию в ответ клиенту на несколько сотен килобайт

Схематично это выглядело так:

Схема обращения к Redis
Схема обращения к Redis

Ситуацию усугубляло то, что каждая страница в браузере пользователя генерировала десяток параллельных запросов к бэкенду, и каждый из них пытался получить этот конфиг.

При сотне одновременных запросов приложение выделяло примерно гигабайт мусора в секунду. При потреблении 90% от доступных 4 ГБ сборщик мусора переключался в агрессивный режим, время обработки запросов росло, и проблема только усиливалась.


Фикс бага

Первое, что мы сделали, — конечно, поправили ошибку, из-за которой JSON распухал. Размеры JSON сократились в 10–20 раз.

Проблема стала менее «горячей», но не исчезла полностью. Программа стала потреблять примерно 2 ГБ при той же нагрузке. Основная проблема осталась в аллокациях под ответ Redis на каждый запрос.

Второе, что мы сделали, — добавили кэширование десериализованного JSON в памяти процесса на минуту. Это позволило вообще не ходить за ним в базу и в Redis хотя бы в рамках обработки одной страницы.

Стало быстрее. Памяти под нагрузкой стало заметно меньше.

После этого я решил замерить, насколько же кэширование в Redis неэффективно и что с этим можно сделать.


Нагрузочный тест

Инфраструктура была собрана через .NET Aspire:

  • PostgreSQL в контейнере;

  • Valkey в контейнере;

  • ASP.NET API.

Нагрузочный тест — сценарий в NBomber, который делает GET-запросы к API и запускается как ресурс Aspire по кнопке из Dashboard.

Параметры:

  • 100 конкурентных потоков;

  • 60 секунд на сценарий, без warmup, чтобы все честно сходили в базу хотя бы один раз;

  • 1000 объектов — заказов — в БД, распределение равномерное;

  • каждый объект в сериализованном виде занимает около 500 байт, то есть меньше порога LOH;

  • сценарии выполняются последовательно.

Код сценария:

ScenarioProps BuildScenario(string name, string path)
{
    return Scenario.Create(name, async context =>
    {
        var id = orderIds[context.Random.Next(OrderCount)];
        var request = Http.CreateRequest("GET", $"{baseUrl}{path}/{id}")
            .WithHeader("Accept", "application/json");

        return await Http.Send(httpClient, request);
    })
    .WithoutWarmUp()
    .WithLoadSimulations(
        Simulation.RampingConstant(ConcurrentCopies, TimeSpan.FromSeconds(10)),
        Simulation.KeepConstant(ConcurrentCopies, TimeSpan.FromSeconds(50)),
        Simulation.RampingConstant(0, TimeSpan.FromSeconds(3))
    );
}

Десятисекундный разгон в начале нужен, чтобы уменьшить первоначальную давку на кэш и не нарваться на ThreadPool starvation.

Эндпоинты, которые тестируем

Общие параметры

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

builder.AddNpgsqlDbContext<AppDbContext>("ordersdb");
builder.AddRedisDistributedCache("valkey");

var cacheTtl = TimeSpan.FromMinutes(5);

Эндпоинт 1: прямой поход в PostgreSQL

app.MapGet("/api/orders-direct/{id:int}", async (
    int id,
    AppDbContext db,
    CancellationToken cancellationToken) =>
{
    var order = await db.Orders.FindAsync([id], cancellationToken);
    return order is not null ? Results.Ok(order) : Results.NotFound();
})
.WithName("GetOrderDirect")
.WithTags("Orders");

Эндпоинт 2: кэш в Valkey через IDistributedCache

app.MapGet("/api/orders-redis/{id:int}", async (
    int id,
    AppDbContext db,
    IDistributedCache cache,
    CancellationToken cancellationToken) =>
{
    var cacheKey = $"order:{id}";

    var cached = await cache.GetAsync(cacheKey, cancellationToken);
    if (cached is not null)
    {
        var fromCache = JsonSerializer.Deserialize<Order>(cached, JsonSerializerOptions.Web);
        return Results.Ok(fromCache);
    }

    var order = await db.Orders.FindAsync([id], cancellationToken);
    if (order is null)
        return Results.NotFound();

    cached = JsonSerializer.SerializeToUtf8Bytes(order, JsonSerializerOptions.Web);
    await cache.SetAsync(
        cacheKey,
        cached,
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheTtl
        },
        cancellationToken);

    return Results.Ok(order);
})
.WithName("GetOrderRedis")
.WithTags("Orders");

Результаты первого теста

Метрика

orders-direct

orders-redis

RPS

10 085

19 715

Mean

8.83 ms

4.52 ms

P50

7.68 ms

2.66 ms

P95

16.74 ms

16.86 ms

P99

30.34 ms

29.62 ms

Memory

42 MB

500–700 MB

Объём кэша в Valkey — около 2 МБ.


Выводы по первому тесту

Такие тайминги и потребление памяти говорят о том, что возникла давка кэша. Когда много запросов по одному и тому же ключу приходят параллельно, каждый из них может пойти в Redis, затем в базу, а затем попытаться записать результат обратно в Redis.

После окончания давки объём памяти держался на уровне 500 МБ.

Кэширование в Redis/Valkey с походом в кэш на каждый запрос снижает нагрузку на БД, но повышает расход памяти и нагрузку на GC.

Даже без давки выделение массива байт под ответ Redis на каждый запрос создаёт дополнительную нагрузку на сборщик мусора. А если объект больше 85 000 байт, массив попадает в LOH и может оставаться там достаточно долго.

И это не проблема конкретного языка или библиотеки. В любом языке, чтобы получить ответ из Redis, нужно выделить буфер под этот ответ.

Один из способов улучшить ситуацию — не ходить в Redis на каждый запрос, а кэшировать данные в памяти сервера.


Кэш с помощью ASP.NET Output Cache

Output Cache (ASP.NET Core 7+) кэширует готовый HTTP-ответ целиком: статус-код, заголовки, сериализованное тело. При повторном запросе middleware отдаёт байты из кэша — без выполнения кода эндпоинта, без десериализации, без лишних аллокаций и без давки.

Чтобы подключить Output Cache, нужно добавить сервисы и middleware.

builder.Services.AddOutputCache(options =>    
{    
    // По умолчанию кэширование отключено
    options.AddBasePolicy(build => build.NoCache());
    
    options.AddPolicy("Orders5Min", policy => policy
        .Expire(cacheTtl)
        .Tag("orders"));
});

var app = builder.Build();

// После UseCors, UseRouting, UseAuthentication и UseAuthorization 
app.UseOutputCache(); 

Эндпоинт с Output Cache выглядит так:

app.MapGet("/api/orders-output/{id:int}", async (
    int id,
    AppDbContext db,
    CancellationToken cancellationToken) =>
{
    // Этот код выполняется только при cache miss.
    // При hit middleware отдаёт готовые байты и сюда не заходит.
    var order = await db.Orders.FindAsync([id], cancellationToken);
    return order is not null ? Results.Ok(order) : Results.NotFound();
})
.WithName("GetOrderOutputCache")
.WithTags("Orders")
.CacheOutput("Orders5Min");

Нагрузочный тест

Метрика

orders-direct

orders-redis

orders-output

RPS

3 715

22 780

34 526

Mean

33.92 ms

4.20 ms

2.71 ms

P50

7.68 ms

3.11 ms

1.64 ms

P95

30.56 ms

11.28 ms

10.36 ms

P99

305.66 ms

21.54 ms

21.07 ms

Memory

42 MB

500–700 MB

43 MB

Output Cache оказался заметно быстрее и при этом почти не увеличивал потребление памяти.
Код можно посмотреть по ссылке.

Можно сделать еще лучше…


Кэширование на клиенте

На собеседованиях я часто спрашиваю у кандидатов, что такое conditional get и как работает кэширование на клиенте. Примерно 9 из 10 не могут нормально ответить на этот вопрос.

Как работает conditional get

Сервер отправляет в ответе заголовки ETag и Last-Modified, а в Cache-Control указывает, как данные можно кэшировать.

Если Cache-Control не указан, браузеры и промежуточные кэши могут вести себя по-разному. Но в локлаьных тестах браузер отсуствие Cache-Control воспринимает как no-cache. Для продуктивного для API лучше задавать поведение кэша явно.

Важный момент: no-cache не означает «не кэшировать». Он означает: «можешь сохранить ответ в кэше, но перед использованием обязан проверить актуальность», то есть сделать conditional get.

При следующем запросе браузер автоматически добавляет заголовок If-None-Match со значением ETag или If-Modified-Since со значением Last-Modified. Сервер проверяет, актуален ли кэш. Если актуален, он возвращает 304 Not Modified без тела, а браузер использует закэшированный ответ.

Пример:

-- Первый запрос --
GET /api/orders/42 HTTP/1.1

HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Last-Modified: Tue, 22 Jul 2026 10:30:00 GMT
Content-Type: application/json

{"id":"42","status":"shipped",...}   ← 2.4 KB

-- Повторный запрос (данные не изменились) --
GET /api/orders/42 HTTP/1.1
If-None-Match: "a1b2c3d4"
If-Modified-Since: Tue, 22 Jul 2026 10:30:00 GMT

HTTP/1.1 304 Not Modified

                                    ← 0 байт тела

Клиентское кэширование вместе с Output Cache

Достаточно добавить несколько строк, чтобы клиент мог использовать кэширование на уровне браузера. Output Cache сохранит заголовки и обработает conditional GET.

app.MapGet("/api/orders-output-client/{id:int}", async (
    int id,
    AppDbContext db,
    HttpContext context,
    CancellationToken cancellationToken) =>
{
    var order = await db.Orders.FindAsync([id], cancellationToken);
    if (order is null)
        return Results.NotFound();

    context.Response.GetTypedHeaders().ETag = new($"\"{order.Version}\"");

    return Results.Ok(order);
})
.WithName("GetOrderOutputCacheWithClientCache")
.WithTags("Orders")
.CacheOutput("Orders5Min");

Если вы используете PostgreSQL и EF Core, у вас есть версия строки почти бесплатно. Можно использовать системную колонку xmin как concurrency token.

Например:

public class Order
{
    public int Id { get; set; }

    // другие свойства

    [JsonIgnore]
    [Timestamp]
    public uint Version { get; set; }
}

Проверьте в миграции, что это свойство действительно сопоставлено с xmin, а не с обычной колонкой в таблице.

Нагрузочный тест с клиентским кэшом

Метрика

orders-redis

orders-output

orders-output-client

RPS

22 293

33 744

34 526

Mean

4.20 ms

2.77 ms

2.71 ms

P50

3.11 ms

1.66 ms

1.64 ms

P95

11.28 ms

10.61 ms

10.36 ms

P99

21.54 ms

21.79 ms

21.07 ms

Memory

500–700 MB

43 MB

44 MB

Трафик на запрос

0.520 KB

0.502 KB

0.255 KB

Трафик всего

770 MB

1 126 MB

584 MB

В этом тесте orders-output-client вернул 100 000 ответов 200 OK и 2 247 768 раз вернул 304 Not Modified, то есть примерно в 95,7% случаев клиент получил бы ответ из локального кэша.

С точки зрения пропускной способности сервера разница между Output Cache и Output Cache + client cache почти незаметна. Но трафика становится примерно в два раза меньше.

В реальном приложении, когда клиент находится далеко и имеет ограниченный канал, разница будет очень ощутимой.

Я люблю повторять формулу высокого быстродействия веб-приложения:

  1. минимум 90% GET-запросов должно возвращаться из клиентского кэша;

  2. из оставшихся 90% — из кэша сервера;

  3. из оставшихся 90% — из страничного кэша базы данных;

  4. то, что осталось, может вызывать чтение с диска в БД.

Это, конечно, шутливая формула, но в каждой шутке лишь доля шутки.
Код можно посмотреть по ссылке.

Output Cache победил, можно расходиться?

К сожалению, нет.

У Output Cache есть несколько неприятных ограничений:

  1. его нужно как-то инвалидировать при изменении данных;

  2. по умолчанию он не кэширует аутентифицированные запросы;

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

Разберём по порядку.


Инвалидация Output Cache при записи

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

С Redis это обычно решается просто: после изменения данных мы записываем в кэш новые значения. С Output Cache так сделать нельзя: нельзя напрямую записать HTTP-ответ в кэш. Но можно сбросить закэшированный ответ по тегу.

Теги для Output Cache

Теги можно задать на уровне политики или атрибута, но не всегда удобно делать это только декларативно. Иногда хочется добавить теги прямо из кода эндпоинта. Для этого можно использовать кастомную политику.

public static class CacheExtensions
{
    internal const string OutputCacheTagsKey = "OutputCacheTags";

    public static void AddCacheTag(this HttpContext context, string tag)
    {
        if (context.Items.TryGetValue(OutputCacheTagsKey, out var value) && value is List<string> tags)
        {
            tags.Add(tag);
        }
        else
        {
            context.Items[OutputCacheTagsKey] = new List<string> { tag };
        }
    }
}

class SetCacheTagsPolicy : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellation)
        => ValueTask.CompletedTask;

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellation)
        => ValueTask.CompletedTask;

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellation)
    {
        if (context.HttpContext.Items.TryGetValue(CacheExtensions.OutputCacheTagsKey, out var value)
            && value is List<string> tags)
        {
            foreach (var tag in tags)
                context.Tags.Add(tag);
        }

        return ValueTask.CompletedTask;
    }
}

Подключаем политику:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(build => build
        .NoCache()
        .AddPolicy<SetCacheTagsPolicy>()
    );

    options.AddPolicy("Orders5Min", policy => policy
        .Expire(cacheTtl)
        .Tag("orders"));
});

Теперь в эндпоинте можно добавить тег:

app.MapGet("/api/orders-output-client/{id:int}", async (
    int id,
    AppDbContext db,
    HttpContext context,
    CancellationToken cancellationToken) =>
{
    var order = await db.Orders.FindAsync([id], cancellationToken);
    if (order is null)
        return Results.NotFound();

    context.AddCacheTag($"order:{id}");
    context.Response.GetTypedHeaders().ETag = new($"\"{order.Version}\"");

    return Results.Ok(order);
})
.WithName("GetOrderOutputCacheWithClientCache")
.WithTags("Orders")
.CacheOutput("Orders5Min");

И обязательно сбрасываем кэш при обновлении данных:

app.MapPut("/api/orders/{id:int}", async (
    int id,
    AppDbContext db,
    IOutputCacheStore cacheStore,
    IDistributedCache distributedCache,
    CancellationToken cancellationToken) =>
{
    var order = await db.Orders.FindAsync([id], cancellationToken);
    if (order is null)
        return Results.NotFound();

    var statuses = new[] { "pending", "processing", "shipped", "delivered", "cancelled" };
    order.Status = statuses[Random.Shared.Next(statuses.Length)];

    await db.SaveChangesAsync(cancellationToken);

    var tag = $"order:{id}";

    // Инвалидируем Output Cache
    await cacheStore.EvictByTagAsync(tag, cancellationToken);

    // При желании обновляем Redis
    var cached = JsonSerializer.SerializeToUtf8Bytes(order, JsonSerializerOptions.Web);
    await distributedCache.SetAsync(
        tag,
        cached,
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheTtl
        },
        cancellationToken);

    return Results.NoContent();
})
.WithName("UpdateOrder")
.WithTags("Orders");

Redis в этом случае сразу пишет новое значение в кэш и не требует похода в базу при последующем чтении.

Нагрузочный тест с обновлением

Параллельно с запросами к тем же эндпоинтам запускается ещё один виртуальный пользователь, который обновляет один случайный заказ через описанный выше эндпоинт.

Метрика

orders-redis

orders-output

orders-output-client

RPS

24 351

40 778

39 965

Mean

3.66 ms

2.18 ms

2.23 ms

P50

2.77 ms

1.39 ms

1.38 ms

P95

10.43 ms

9.85 ms

10.37 ms

P99

20.02 ms

19.07 ms

20.96 ms

Memory

900 MB

43 MB

44 MB

Трафик всего

779 MB

1 259 MB

696 MB

Эндпоинт с клиентским кэшем вернул 309 639 ответов 200 OK и 2 208 187 раз вернул 304 Not Modified.

Cache hit ratio получился: 2 208 187 / (309 639 + 2 208 187) = 87.7%

Если сделать не одного, а 10 виртуальных пользователей, обновляющих записи в базе, cache hit ratio падает до 48,14%

Метрика

orders-redis

orders-output

orders-output-client

RPS

18 720

32 989

31 659

Mean

4.76 ms

2.70 ms

2.81 ms

P50

3.41 ms

1.71 ms

1.72 ms

P95

12.75 ms

11.29 ms

12.57 ms

P99

26.22 ms

20.54 ms

23.14 ms

Трафик всего

599 MB

1 018 MB

785 MB

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

Код можно посмотреть по ссылке.


Output Cache для эндпоинтов с аутентификацией

По умолчанию Output Cache кэширует только безопасные с его точки зрения ответы:

  • только GET или HEAD;

  • только ответы с кодом 200;

  • не кэшируются ответы, которые устанавливают cookie через Set-Cookie;

  • не кэшируются аутентифицированные запросы.

Но можно написать свою политику, которая разрешит кэширование и добавит вариативность кэша в зависимости от пользователя.

class AllowCaching : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellation)
    {
        context.AllowCacheLookup = true;
        context.AllowCacheStorage = true;
        return ValueTask.CompletedTask;
    }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellation)
        => ValueTask.CompletedTask;

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellation)
    {
        context.AllowCacheStorage = true;
        return ValueTask.CompletedTask;
    }
}

Регистрируем политику:

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("CachePrivate", build =>
        build.AddPolicy<AllowCaching>()
             .VaryByValue(ctx => new("user", ctx.User?.Identity?.Name ?? "anonymous")));
});

Теперь на сервере будет кэшироваться отдельный ответ для каждого пользователя.

Но что делать, если данные вроде бы одни и те же, эндпоинт с аутентификацией, но ответ может отличаться для разных пользователей?

Тут нужно быть очень аккуратным. Если ответ зависит от прав пользователя, его настроек, роли, теннанта или чего-то ещё, это обязательно должно учитываться в ключе кэша или в ETag. Иначе можно отдать чужие данные или некорректно вернуть 304.

Если ответ действительно персональный, часто лучше кэшировать не целый HTTP-ответ, а отдельные объекты в памяти.


Memory cache для кэширования данных

Когда мы не можем кэшировать ответ целиком, можно кэшировать объекты в памяти с помощью MemoryCache.

Подключается он одной строкой:

builder.Services.AddMemoryCache();

В коде эндпоинта conditional get нужно обрабатывать самостоятельно:

app.MapGet("/api/orders-inmem-client/{id:int}", async (
    int id,
    AppDbContext db,
    HttpContext context,
    IMemoryCache cache,
    CancellationToken cancellationToken) =>
{
    var cacheKey = $"order:{id}";

    if (cache.TryGetValue<Order>(cacheKey, out var order))
    {
        var tag = $"\"{order!.Version}\"";

        if (context.Request.GetTypedHeaders().IfNoneMatch.Any(t => t.Tag == tag))
        {
            context.Response.GetTypedHeaders().ETag = new(tag);
            return Results.StatusCode(304);
        }
    }
    else
    {
        order = await db.Orders.FindAsync([id], cancellationToken);
        if (order is null)
            return Results.NotFound();

        cache.Set(cacheKey, order, cacheTtl);
    }

    context.Response.GetTypedHeaders().ETag = new($"\"{order.Version}\"");
    return Results.Ok(order);
})
.WithName("GetOrderWithMemoryCache")
.WithTags("Orders");

Нагрузочный тест с Memory Cache

Запускаю тест с изменением данных.

Метрика

orders-output-client

orders-inmem-client

RPS

38 125

40 066

Mean

2.33 ms

2.22 ms

P50

1.46 ms

1.41 ms

P95

10.27 ms

10.24 ms

P99

20.38 ms

19.92 ms

Трафик всего

671.1 MB

704.6 MB

200 OK

321 388

322 564

304 Not Modified

2 080 478

2 201 620

При повторном запуске чуть лучше был вариант с Output Cache. По сути, разница между Output Cache и Memory Cache в этом сценарии отсутствует.

Код можно посмотреть по ссылке.


Теги для Memory Cache

В тесте кэш сбрасывался просто по ключу. Но на практике это неудобно. Нужно держать карту: какие ключи сбрасывать при обновлении каких данных. Поддерживать такую карту в актуальном состоянии может быть сложнее, чем само приложение.

Поэтому хочется прикрутить к Memory Cache теги, как в Output Cache. В самом IMemoryCache тегов нет, но есть возможность привязать ключ к IChangeToken.

Нужен словарь string => IChangeToken. Но необходимо учитывать, что элемент может быть удалён из кэша, и тогда IChangeToken и сам тег станут неактуальными. Их надо удалять, чтобы память не утекала.

Ниже — пример менеджера токенов. Это не production-ready библиотека, но идею он передаёт хорошо.

class ChangeTokenManager : IDisposable
{
    private readonly ConcurrentDictionary<object, WeakReference<Token>> tokens = new();

    public IChangeToken GetChangeToken(object key)
    {
        if (tokens.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var changeToken))
        {
            return changeToken; // живой токен
        }

        changeToken = new Token(this, key);

        // Ссылки не было + TryAdd → добавляем новый токен
        if (weakRef is null && tokens.TryAdd(key, new WeakReference<Token>(changeToken)))
        {
            return changeToken; // возвращаем добавленный токен
        }

        // Ссылка было мертвая + TryUpdate → обновляем токен
        if (weakRef is not null && tokens.TryUpdate(key, new WeakReference<Token>(changeToken), weakRef))
        {
            return changeToken; // возвращаем обновленный токен
        }

        // Не смогли добавить или обновить ключ (кто-то другой уже сделал) - выбросить созданный токен и повторить
        changeToken.Dispose();
        return GetChangeToken(key);
    }

    public async ValueTask OnChange(object key)
    {
        if (tokens.Remove(key, out var weakRef) && weakRef.TryGetTarget(out var changeToken))
        {
            await changeToken.CancelAsync();
            changeToken.Dispose();
        }
    }

    public void Dispose()
    {
        foreach (var (_, weakRef) in tokens)
        {
            if (weakRef.TryGetTarget(out var token))
            {
                token.Dispose();
            }
        }

        tokens.Clear();
    }
}

Сам токен:

sealed class Token(ChangeTokenManager manager, object key) : CancellationTokenSource, IChangeToken
{
    public bool ActiveChangeCallbacks => !IsCancellationRequested;

    public bool HasChanged => IsCancellationRequested;

    public IDisposable RegisterChangeCallback(Action<object?> callback, object? state)
        => base.Token.Register(callback, state);

    ~Token()
    {
        manager.tokens.Remove(key, out _);
    }
}

Использование:

builder.Services.AddSingleton<ChangeTokenManager>();
app.MapGet("/api/orders-inmem-client/{id:int}", async (
    int id,
    AppDbContext db,
    HttpContext context,
    IMemoryCache cache,
    ChangeTokenManager changeTokens,
    CancellationToken cancellationToken) =>
{
    var cacheKey = $"order:{id}";

    if (!cache.TryGetValue<Order>(cacheKey, out var order))
    {
        order = await db.Orders.FindAsync([id], cancellationToken);
        if (order is null)
            return Results.NotFound();

        using var entry = cache.CreateEntry(cacheKey);
        entry.SetValue(order);
        entry.SetSlidingExpiration(cacheTtl);
        entry.AddExpirationToken(changeTokens.GetChangeToken(cacheKey));
    }

    var tag = $"\"{order!.Version}\"";

    if (context.Request.GetTypedHeaders().IfNoneMatch.Any(t => t.Tag == tag))
    {
        context.Response.GetTypedHeaders().ETag = new(tag);
        return Results.StatusCode(304);
    }

    context.Response.GetTypedHeaders().ETag = new(tag);
    return Results.Ok(order);
});

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

Код можно посмотреть по ссылке.


Кэш на диске для больших объёмов данных

Оперативная память на веб-сервере — ресурс ограниченный и ценный. Если нужно кэшировать большие объёмы данных, лучше использовать диск. В сочетании с Memory Cache можно хранить ключи и метаданные в памяти, а сами данные — на диске.

Сравним несколько вариантов:

  • прямой запрос к базе за всеми заказами;

  • Redis-кэш со всеми заказами;

  • отдача файла с диска без клиентского кэша;

  • отдача файла с диска с клиентским кэшем.

Базовый вариант:

app.MapGet("/api/orders-direct", AppDbContext db =>
{
    return Results.Ok(db.Orders);
})
.WithName("GetOrdersDirect")
.WithTags("Orders");

Кэш в Redis:

app.MapGet("/api/orders-redis", async (
    AppDbContext db,
    IDistributedCache cache,
    CancellationToken cancellationToken) =>
{
    var cacheKey = "orders";

    var cached = await cache.GetAsync(cacheKey, token: cancellationToken);
    if (cached is not null)
    {
        var fromCache = JsonSerializer.Deserialize<List<Order>>(cached, JsonSerializerOptions.Web);
        return Results.Ok(fromCache);
    }

    var orders = await db.Orders.ToListAsync(cancellationToken);

    cached = JsonSerializer.SerializeToUtf8Bytes(orders, JsonSerializerOptions.Web);
    await cache.SetAsync(
        cacheKey,
        cached,
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheTtl
        },
        cancellationToken);

    return Results.Ok(orders);
})
.WithName("GetOrdersRedis")
.WithTags("Orders");

Кэш на диске:

app.MapGet("/api/orders-disk", async (
    AppDbContext db,
    IMemoryCache cache,
    CancellationToken cancellationToken) =>
{
    var cached = await cache.GetOrCreateAsync("orders", async entry =>
    {
        var file = new FileInfo(Path.GetTempFileName());
        await using var stream = file.OpenWrite();

        long version = -1;

        var orders = db.Orders.AsAsyncEnumerable().Select(x =>
        {
            version = Math.Max(version, x.Version);
            return x;
        });

        await JsonSerializer.SerializeAsync(stream, orders, JsonSerializerOptions.Web, cancellationToken);

        var result = new CachedFile(file.FullName, "application/json", EntityTag: new($"\"{version}\""));

        entry.SetSlidingExpiration(cacheTtl);
        entry.RegisterPostEvictionCallback(static (_, value, _, _) =>
        {
            if (value is CachedFile cachedFile) File.Delete(cachedFile.Path);
        });

        return result;
    });

    return TypedResults.PhysicalFile(
        cached!.Path,
        cached.ContentType,
        lastModified: cached.LastModified,
        entityTag: cached.EntityTag);
})
.WithName("GetOrdersDisk")
.WithTags("Orders");

record CachedFile(
    string Path,
    string ContentType,
    DateTimeOffset? LastModified = null,
    EntityTagHeaderValue? EntityTag = null);

Тут есть нюанс: если кэш-запись будет удалена в момент отправки файла, возможна гонка. В коммит я добавил сериализацию вызовов, но сюда писать не стал, кода в статье и так много.

Результаты теста с большими данными

Метрика

all-orders-direct

all-orders-redis

all-orders-disk-no-cache

all-orders-disk

RPS

510

531

3 678

11 471

Mean

175.46 ms

168.54 ms

24.22 ms

7.77 ms

P50

154.50 ms

162.69 ms

24.70 ms

7.70 ms

P95

297.47 ms

248.19 ms

37.06 ms

11.85 ms

P99

739.33 ms

574.98 ms

47.26 ms

19.98 ms

Трафик на запрос

253.77 KB

253.77 KB

253.79 KB

0.313 KB

Трафик всего

7 962 MB

8 288 MB

57 433 MB

222 MB

Тесты запускались с прогревом, во время работы данные не менялись.

На достаточно больших объёмах данных — примерно 252 КБ JSON — Redis не выигрывает даже у прямого похода в базу, при этом потребляя больше памяти. Отдача с диска оказывается в несколько раз эффективнее. А если ещё и клиент использует conditional GET, разница по трафику становится совсем неприличной.

Код можно посмотреть по ссылке.


Как это масштабировать

Одна из причин использовать Redis — данные, записанные с одного сервера, будут прочитаны на другом. В отличие от кэша в памяти процесса, не нужно самому синхронизировать кэш между инстансами.

Но «частично» эта проблема всё равно остаётся. При массовом обновлении данных в базе или при кэшировании объектов, собранных из множества строк, неизбежно приходится писать дополнительный код для инвалидации кэша.

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


Сброс кэша при изменении данных в Postgres

Для PostgreSQL это можно сделать через логическую репликацию. Я показывал этов докладе на конференции PgBootcamp https://youtu.be/Z2U7UgiKK2o

Настройка PostgreSQL

Один раз нужно включить логическую репликацию в конфиге или параметрах запуска контейнера:

wal_level = logical

Дать права REPLICATION пользователю, под которым приложение будет подключаться к базе, и разрешить подключение в pg_hba.conf.

Настройка публикации

В миграции для каждой таблицы:

ALTER TABLE orders REPLICA IDENTITY FULL;

Создаём публикацию для нужных таблиц:

CREATE PUBLICATION app_changes FOR TABLE orders;

Если таблиц несколько, перечисляем их через запятую.

Подписка на публикацию из ASP.NET

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

class PgOutputCacheInvalidation(
    IConfiguration config,
    IOutputCacheStore outputCache,
    ChangeTokenManager tokenManager) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        NpgsqlTypes.NpgsqlLogSequenceNumber? walLocation = null;

        while (!stoppingToken.IsCancellationRequested)
        {
            await using var conn = new LogicalReplicationConnection(config.GetConnectionString("ordersdb"));
            await conn.Open(stoppingToken);

            var slot = await conn.CreatePgOutputReplicationSlot(
                "tx_slot",
                temporarySlot: true,
                cancellationToken: stoppingToken);

            var replicationStream = conn.StartReplication(
                slot,
                new PgOutputReplicationOptions("app_changes", PgOutputProtocolVersion.V4, binary: true),
                stoppingToken,
                walLocation);

            await foreach (var message in replicationStream)
            {
                await ProcessMessage(message, stoppingToken);
                walLocation = message.WalEnd;
            }
        }
    }

    private async Task ProcessMessage(PgOutputReplicationMessage message, CancellationToken stoppingToken)
    {
        switch (message)
        {
            case FullUpdateMessage m:
                await RemoveCache(m.Relation.RelationName, m.OldRow, stoppingToken);
                await RemoveCache(m.Relation.RelationName, m.NewRow, stoppingToken);
                break;

            case InsertMessage m:
                await RemoveCache(m.Relation.RelationName, m.NewRow, stoppingToken);
                break;

            case FullDeleteMessage m:
                await RemoveCache(m.Relation.RelationName, m.OldRow, stoppingToken);
                break;
        }
    }

    private async Task RemoveCache(string tableName, ReplicationTuple row, CancellationToken stoppingToken)
    {
        await foreach (var val in row.WithCancellation(stoppingToken))
        {
            var columnName = val.GetFieldName();

            switch (columnName)
            {
                case "id":
                    var id = await val.Get<int>(stoppingToken);
                    var key = $"{tableName}:{id}";

                    await outputCache.EvictByTagAsync(key, stoppingToken);
                    await tokenManager.OnChange(key);
                    break;

                // другие поля можно обработать аналогично
            }
        }
    }
}

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

Код можно посмотреть по ссылке.


Сброс кэша при изменении данных в SQL Server

В SQL Server нет такой же удобной логической репликации, как в PostgreSQL, но можно использовать Change Tracking.

Включение Change Tracking

В миграции для все базы один раз:

ALTER DATABASE YourDatabaseName
SET CHANGE_TRACKING = ON
(CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON);

Для каждой таблицы:

ALTER TABLE dbo.Orders
ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = ON);

Опрос изменений из ASP.NET

Идея простая:

  1. получаем текущую версию change tracking;

  2. сравниваем её с последней обработанной;

  3. если версия изменилась, читаем изменения через CHANGETABLE;

  4. сбрасываем кэш.

public class SqlServerChangePoller(
    IConfiguration config,
    IOutputCacheStore outputCache,
    ChangeTokenManager tokenManager) : BackgroundService
{
    private static readonly string[] tables = ["Orders"];

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        long lastSyncVersion = long.MaxValue;

        while (!stoppingToken.IsCancellationRequested)
        {
            await using var conn = new SqlConnection(config.GetConnectionString("SqlServer"));
            await conn.OpenAsync(stoppingToken);

            try
            {
                lastSyncVersion = await PollChanges(conn, lastSyncVersion, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // логируем и продолжаем
            }
        }
    }

    private async Task<long> PollChanges(SqlConnection conn, long lastSyncVersion, CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var currentVersionCmd = new SqlCommand("SELECT CHANGE_TRACKING_CURRENT_VERSION()", conn);
            var result = await currentVersionCmd.ExecuteScalarAsync(stoppingToken);

            if (result is null || result is DBNull)
            {
                await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
                continue;
            }

            var currentVersion = (long)result;

            if (currentVersion <= lastSyncVersion)
            {
                lastSyncVersion = currentVersion;
                await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
                continue;
            }

            await ProcessChanges(conn, lastSyncVersion, stoppingToken);
            lastSyncVersion = currentVersion;
        }

        return lastSyncVersion;
    }

    private async Task ProcessChanges(SqlConnection conn, long lastSyncVersion, CancellationToken stoppingToken)
    {
        foreach (var table in tables)
        {
            await using var changesCmd = new SqlCommand(
                $"SELECT * FROM CHANGETABLE(CHANGES {table}, @fromVersion) AS ct",
                conn);

            changesCmd.Parameters.AddWithValue("@fromVersion", lastSyncVersion);

            await using var reader = await changesCmd.ExecuteReaderAsync(stoppingToken);

            while (await reader.ReadAsync(stoppingToken))
            {
                var id = reader.GetInt32("Id");
                var key = $"{table.ToLower()}:{id}";

                await outputCache.EvictByTagAsync(key, stoppingToken);
                await tokenManager.OnChange(key);
            }
        }
    }
}

Можно не опрашивать базу каждую секунду, а использовать SqlDependency или Query Notifications, но это сильно усложнит код. Опрос раз в секунду для одной таблицы обычно не создаёт заметной нагрузки.


Что будет когда экземпляров больше одного

Если на каждом экземпляре приложения запустить фоновую задачу сброса кэша при изменении данных, то ничего плохого не случится. Оба варианта — и репликация в PostgreSQL, и опрос в SQL Server — будут работать даже на нескольких серверах. Но если запустить десятки экземпляров приложения, каждый из них будет создавать свою нагрузку на базу.

В этом случае выгодно вынести механизм отслеживания изменений в отдельный сервис, а остальные приложения будут подключаться к нему по gRPC, SSE или через другой транспорт. Так вы получаете один источник событий инвалидации и много потребителей, которые сбрасывают свои локальные кэши.

Как сделать приложением модульным, чтобы сохранить удобство локальной разработки и отладки, и легко перейти к отдельным сервисам я буду рассказывать на коференции DotNext 2026. Обращайтесь в личку за промокодом на скидку для регистрации на конференцию.


Итоги

Redis или Valkey хорошо показывают себя, когда используются как хранилище списков, множеств, стримов, вероятностных структур, счётчиков, очередей и другого распределённого состояния.

Но как горячий кэш для HTTP-ответов Redis часто оказывается неэффективным: каждый запрос платит за сеть, получение байт, десериализацию и нагрузку на GC.

Если вам нужна высокая производительность, лучше выстроить кэширование по уровням:

  1. клиентский кэш и 304 Not Modified;

  2. Output Cache на сервере;

  3. Memory Cache для объектов;

  4. диск или потоковая отдача для больших объёмов;

  5. Redis только там, где действительно нужны общие данные.

И помните: самый быстрый запрос — тот, который не нужно выполнять.

Спасибо всем, кто дочитал до этого места. Надеюсь, было интересно.

Репозиторий с исходным кодом: https://github.com/gandjustas/habr-cache-benchmarks.