Кэш в redis работает вполсилы: как найти узкие места и снизить память и Gc

Ваш кэш в Redis работает вполсилы: как найти узкие места и что с ними делать

История из продакшена: OutOfMemory при "нормальной" нагрузке

В какой‑то момент в проде контейнеры одного из сервисов начали регулярно падать с OutOfMemory. На каждый контейнер было выделено по 4 ГБ оперативной памяти, а трафик выглядел вполне буднично - десятки страниц в секунду. То есть не было ни всплеска посещаемости, ни аномальных запросов, ни "тяжёлых" операций, которые сразу бы объяснили ситуацию.

Расследование довольно быстро вывело на неожиданного виновника: JSON‑конфиг, который мы считали "безопасным" и хорошо кэшируемым.

Как был устроен поток данных и почему это казалось правильным

Схема выглядела стандартно:

1. В базе данных лежит JSON.
2. По запросу приложение достаёт его из БД.
3. Делает дополнительную обработку: заполняет вычисляемые поля, обогащает справочниками.
4. Сохраняет итоговый результат в Redis (фактически у нас был Valkey, но в коде использовалась обычная Redis‑библиотека).
5. Большинство пользователей запрашивает один и тот же конфиг, поэтому почти все чтения идут из кэша.

На бумаге это выглядит идеально: "один и тот же JSON" → "редко меняется" → "надо кэшировать".

Баг, который раздул кэш в десятки раз

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

- вложенные коллекции, которые не требовались клиенту;
- история изменений;
- сериализованные справочники и "обогащение", которое повторялось в каждом объекте.

Интуитивно можно подумать: "Ну и что, всё равно же это один и тот же JSON - один раз положили и много раз читаем". Но именно чтение оказалось дорогим.

Почему "кэш в Redis" внезапно стал дорогим удовольствием

Каждый запрос к Redis в нашем случае означал цепочку затрат, которые плохо заметны по отдельности, но убийственны в сумме:

- сетевой round‑trip до удалённого сервера;
- скачивание условных 10 MB по TCP;
- аллокация 10 MB массива в managed heap под полученный payload;
- десериализация в десятки тысяч объектов;
- дальнейшая обработка и повторная сериализация ответа клиенту - уже на сотни килобайт.

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

Первый шаг: исправили баг - стало лучше, но не "здорово"

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

Температура упала, но проблема не исчезла. Даже на "обычной" нагрузке приложение продолжало потреблять порядка 2 ГБ памяти. Главный источник потерь остался прежним: аллокации под ответ Redis на каждый запрос и последующая десериализация.

Второй шаг: минутный in‑process кэш, чтобы не "дёргать" Redis по 10 раз на страницу

Затем добавили кэширование уже десериализованного JSON в памяти процесса на одну минуту. Смысл простой: если в рамках отрисовки одной страницы приходит серия параллельных запросов, пусть они получают объект из памяти, а не устраивают гонку в Redis и не плодят одинаковые аллокации.

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

---

Нагрузочное измерение: сравниваем PostgreSQL vs Redis/Valkey

Тестовую инфраструктуру собрали через .NET Aspire:

- PostgreSQL в контейнере;
- Valkey в контейнере;
- ASP.NET API.

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

Параметры теста:

- 100 конкурентных потоков;
- 60 секунд на сценарий, без warmup - чтобы каждый честно сходил в базу хотя бы один раз;
- 1000 объектов (заказов) в БД, распределение равномерное;
- каждый объект в сериализованном виде около 500 байт (то есть меньше порога LOH);
- сценарии выполняются последовательно.

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

Эндпоинты

- Эндпоинт 1: прямой поход в PostgreSQL.
- Эндпоинт 2: чтение из кэша в Valkey через `IDistributedCache`.

Результаты первого теста (ключевое)

- orders-direct (PostgreSQL):
- RPS: 10 085
- Mean: 8.83 ms
- P50: 7.68 ms
- P95: 16.74 ms
- P99: 30.34 ms
- Memory: 42 MB

- orders-redis (Valkey/Redis cache):
- RPS: 19 715
- Mean: 4.52 ms
- P50: 2.66 ms
- P95: 16.86 ms
- P99: 29.62 ms
- Memory: 500-700 MB

При этом объём кэша в Valkey - всего около 2 МБ.

На уровне задержек всё выглядит как победа кэша: среднее время меньше, RPS выше. Но по памяти - провал. Для небольших объектов, при относительно скромном кэше на стороне Valkey, процесс внезапно раздувается до сотен мегабайт. Это типичный признак "давки кэша": система ускоряет ответы ценой массовых аллокаций и давления на GC, а затем начинает расплачиваться паузами и деградацией.

---

Что делать, если Redis/Valkey ускоряет RPS, но "съедает" память

1) Перестаньте кэшировать "как есть": уменьшайте payload и убирайте мусор

Если в кэш уезжает всё подряд - вы экономите запросы к БД, но платите сетевым трафиком, аллокациями и десериализацией. Практика простая:

- храните только реально используемые поля;
- выносите справочники и повторяющиеся куски в отдельные ключи;
- избегайте кэширования истории/аудита вместе с "актуальным состоянием";
- контролируйте размер значения и вводите лимиты (например, не кэшировать ответы больше заданного порога).

2) Не заставляйте каждый запрос десериализовать одно и то же

Кэш "байтов" в Redis почти всегда означает: "на каждом запросе выдели буфер, распакуй, создай кучу объектов". Если данные часто запрашиваются в рамках одного короткого окна, in‑process кэш десериализованного результата (на десятки секунд или минуту) может дать эффект больше, чем сам Redis, потому что убирает самую дорогую часть - создание объектов и давление на GC.

3) Для HTTP‑ответов часто лучше работает Output Cache, чем distributed cache

Если вы кэшируете не "данные", а фактически готовый HTTP‑ответ эндпоинта, присмотритесь к ASP.NET Output Cache. Его сильная сторона в том, что он кэширует результат ближе к уровню HTTP и может быть эффективнее по CPU/памяти, чем ручная сборка ответа из Redis + десериализация.

Дополнительно Output Cache проще связать с HTTP‑семантикой: заголовками, вариациями, условиями обновления.

4) Подключите клиентское кэширование: Conditional GET снижает нагрузку без Redis

Часть запросов можно "погасить" ещё до вычислений на сервере. Механика conditional GET устроена так: клиент сохраняет ETag или дату изменения и при повторном запросе спрашивает "не изменилось ли?". Если нет - сервер отвечает коротким 304, без передачи тела.

В связке клиентское кэширование + Output Cache вы часто получаете двойную выгоду: меньше работы на сервере и меньше сетевого трафика.

5) Продумайте инвалидирование: теги, выборочная очистка и обновления

Кэширование всегда упирается в вопрос: "Как быстро и корректно сбрасывать кэш при записи?"

- Для Output Cache полезны теги: вы помечаете кэшированные ответы сущностью (например, `order:123`) и при изменении данных сбрасываете только нужное, а не всё подряд.
- Для MemoryCache внутри процесса подход аналогичный: храните ключи и связи "сущность → набор ключей", чтобы чистить точечно.

6) Эндпоинты с авторизацией: кэшируйте аккуратно, но не сдавайтесь

Кэширование на приватных данных сложнее из‑за вариативности: пользователь, роли, claims, разные наборы прав. Но даже здесь можно выиграть:

- отделяйте "публичную часть" ответа от "пользовательской";
- кэшируйте общие данные (справочники, конфиги, публичные описания) отдельно;
- используйте вариации ключа по значимым атрибутам (но не превращайте ключи в бесконечное множество).

7) Память, диск и "большие данные": выбирайте слой под размер и частоту

Redis хорош, пока payload умеренный и приносит пользу. Если данных много и они редко меняются, иногда разумнее:

- держать "горячее" в MemoryCache (быстро, без сети);
- "тёплое и большое" - в кэше на диске (если допустимы миллисекунды сверху, но важна экономия RAM);
- в Redis оставлять то, что действительно должно быть общим для нескольких инстансов и часто читается.

8) Масштабирование и несколько экземпляров: не ломайте консистентность

Как только сервисов становится больше одного, in‑process кэш начинает расходиться между экземплярами. Это не значит, что от него нужно отказаться, но надо понимать границы:

- локальный кэш - для снятия пиков и защиты от "шторма" запросов;
- distributed‑кэш - для общего слоя и согласованности;
- механика инвалидирования должна работать на все экземпляры.

9) Инвалидация "от базы": сброс при изменениях в PostgreSQL и SQL Server

Если данные меняются в БД, логично сбрасывать кэш по факту изменения:

- В PostgreSQL это можно организовать через публикацию изменений и подписку на них со стороны ASP.NET, чтобы при событии очистить связанные ключи/теги.
- В SQL Server для похожей задачи подходит Change Tracking: приложение периодически опрашивает изменения и по ним инвалидирует кэш.

Смысл один: не угадывать TTL, а реагировать на реальные изменения данных.

---

Итог: кэш не обязан быть Redis, а "быстро по RPS" не равно "эффективно"

В описанной ситуации distributed‑кэш действительно ускорял среднее время ответа и увеличивал RPS, но взамен создавал огромное давление на память процесса: аллокации, десериализация, мусор, агрессивный GC. И это при том, что общий объём кэша в Valkey был смешным - около 2 МБ.

Чтобы кэш начал экономить ресурсы, а не сжигать их, обычно нужен набор мер: уменьшение payload, локальный кэш десериализованного результата, грамотное HTTP‑кэширование (Output Cache + conditional GET), продуманная инвалидция (теги, точечная очистка), а для масштабирования - механизм сброса, синхронизированный с изменениями в БД. Тогда кэш перестаёт быть "дорогим обходным путём" и становится тем, чем должен быть: способом снижать нагрузку, а не переносить её в память и GC.

Прокрутить вверх