// бэкенд
Кэш, который не имеет права уронить прод

Самый обидный инцидент — когда из всей системы падает именно ускоритель. База жива, приложение здорово, а пятисотки летят. Потому что прилёг Redis, который ты добавлял ради скорости.
Кэш из оптимизации превратился в единственную точку отказа. Так делают многие — и зря. Я очень не хотел такой развязки в блог-сервисе, который кормит контентом два моих сайта.
Поэтому правило я зафиксировал сразу: сервис обязан работать без Redis. Совсем. Нет переменной окружения — нет Redis — сервис всё равно поднимается, отвечает и отдаёт посты. Redis тут ускоритель: его можно выдернуть на ходу. И снаружи никто не заметит.
Антипаттерн: кэш как зависимость
Обычно кэш вползает в код незаметно. Сначала клиент Redis создаётся на старте, и если коннект не поднялся — процесс падает с ошибкой. Потом в хендлере пишут const cached = await redis.get(key) без обёртки. И вот уже любой таймаут Redis прилетает пятисоткой в лицо пользователю. Кэш, который должен был спасать от нагрузки, сам стал тем, что нужно спасать.
Корень — в том, что слой кэша не отделён от слоя логики. Логика доверяет Redis так же, как базе. А это разные уровни доверия: без базы данных нет, без кэша — есть, просто чуть медленнее.
База — это источник правды, кэш — всего лишь чьё-то мнение о ней. Мнение можно проигнорировать, правду — нет.
No-op обёртка вместо if по всему коду
Решение в лоб напрашивается само: обмазать каждый вызов проверкой if (redis). Но тогда условие про кэш протекает в каждый хендлер, и рано или поздно кто-то его забудет. Я пошёл другим путём — спрятал само наличие Redis за единым интерфейсом. Логика всегда зовёт cache.get, cache.set, cache.del и понятия не имеет, есть ли там настоящий Redis или пустышка. Если REDIS_URL не задан, я возвращаю no-op реализацию того же интерфейса: get всегда отдаёт null, как промах кэша, set и del молча ничего не делают. Для вызывающего кода это неотличимо от живого Redis, в котором просто всё время холодный кэш. И это штатный, безопасный режим.
type Cache = {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
del(pattern: string): Promise<void>;
};
const noopCache: Cache = {
async get() { return null; }, // вечный промах
async set() {}, // пустышка
async del() {}, // пустышка
};
export const cache: Cache = process.env.REDIS_URL
? createRedisCache(process.env.REDIS_URL)
: noopCache;Вторая половина устойчивости — внутри живой реализации. Даже когда Redis настроен, он может моргнуть: таймаут, разрыв коннекта, OOM на своей стороне. Поэтому каждый вызов в createRedisCache завёрнут в try/catch, ошибка пишется в лог и проглатывается. get при ошибке возвращает null — честный промах, после которого код просто пойдёт в базу. set и del отступают. Падает кэш, а не запрос.
Почему KEYS в проде — это мина
При записи поста кэш надо инвалидировать. Соблазн — сделать redis.keys('posts:*') и удалить найденное. В обучающих примерах так и пишут. В проде KEYS — мина: команда блокирующая, она проходит по всему пространству ключей за один синхронный проход и держит весь Redis, пока не закончит. На большой базе ключей это фриз для всех клиентов разом — включая тех, кому кэш сейчас критичен. Я инвалидирую через SCAN — он курсорный и неблокирующий: отдаёт ключи порциями, между итерациями отпускает Redis, и остальные команды проходят между ними.
- KEYS — один блокирующий проход по всему набору ключей; на проде способен подвесить инстанс целиком.
- SCAN — курсор, идёт порциями и отпускает Redis между итерациями; пара лишних round-trip, зато без фриза.
- Любая ошибка внутри цикла инвалидации логируется и глотается — кэш просто останется грязным до следующего сброса, сервис не падает.
Полный сброс вместо точечной инвалидации
Тут можно было заумничать: посчитать, какие именно ключи затрагивает изменённый пост, и удалить точечно. Я сознательно не стал. Датасет крошечный — десятки постов, горстка ключей posts:*. На таком объёме точечная инвалидация — это сложность ради сложности: больше кода, больше мест ошибиться и реальный риск отдать устаревший срез, если в логике промахнёшься. Поэтому на любую запись я сношу весь префикс posts:* целиком. Это тупо, предсказуемо и невозможно сделать неправильно: после публикации кэш пуст, следующий запрос прогревает его заново из базы. Записи редкие — контент публикуется руками, не в цикле, — так что цена полного сброса это один холодный запрос. Простота, которая стоит дешевле, чем элегантность, которую пришлось бы отлаживать.
Честно: нужен ли тут вообще кэш
А теперь без прикрас. Сайты потребляют контент на этапе сборки: prebuild-скрипт делает один fetch, пишет снапшот в JSON и впекает его в билд. Это главный инвариант всей затеи — иначе ломается SSG и превью для ботов соцсетей. Значит, живой трафик читателей по сервису не лупит: его дёргают редко, в основном на пересборку. На таком профиле Redis честно не нужен — база с таким объёмом справится с запасом.
Так зачем он? По двум причинам, и обе я готов назвать вслух. Первая — привычка делать кэш-слой правильно на маленьком, чтобы рука была набита к большому: интерфейс, инвалидация, деградация уже на месте. Вторая — готовность к росту: если сервис однажды начнёт отдавать контент в рантайме, кэш уже встроен и не уронит прод, потому что спроектирован так, что в принципе не может. Скорость тут вторична. Кэш, которому запрещено ронять сервис, держит своё место: ускоритель остаётся ускорителем и никогда не превращается в зависимость.
P.S. Отдельно расскажу, почему для прода я взял SQLite через Turso вместо привычного Postgres — и почему на read-heavy это осознанный выбор.
Есть похожая задача?
Расскажите — вернусь с оценкой в тот же день.


