Ваш кэш в 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.


