Алексей Меленчук
вся записка
дата
объём 8 мин чтения

Redis: кеш или база, и как инвалидировать кеш

Зачем нужен Redis, чем кеширование отличается от хранения, как работают TTL и инвалидация кеша, что такое лавина промахов и когда вместо Redis хватит Memcached.

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

Redis заводят ради одной задачи — обычно ускорить страницу, которая грузится секунду. Через полгода в нём лежат сессии, корзины, счётчики, результат тяжёлого отчёта и пара блокировок. И если спросить, что из этого можно потерять при перезапуске, внятного ответа не будет ни у кого.

Вот это и есть вопрос. Не «кеш или база».

Признак, по которому это решается

Переживёт ли система потерю этих данных.

Кеш — данные, у которых источник истины лежит в другом месте. Потеряли, пересчитали, несколько минут отвечали медленнее; заметил это график нагрузки на базу, а не пользователь.

База — данные, которых больше нигде нет.

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

Что придётся настроить, если Redis всё-таки база

Сохранность на диск бывает двух видов: снимок по расписанию и журнал команд. Журнал по умолчанию выключен — appendonly no, — и свежий Redis сохраняет только снимками, где окно потери не секунда, а интервал между точками сохранения, то есть минуты. У журнала с режимом appendfsync everysec окно потери — примерно секунда записей, о чём прямо сказано в документации Redis по персистентности. Для счётчика просмотров секунда — ничто. Для списания средств — всё.

Вторая настройка опаснее, потому что о ней вспоминают реже. Начинается она не с политики, а с maxmemory: по умолчанию на 64 битах там ноль, то есть лимита нет и никакая политика не включается — Redis растёт, пока его не убьёт OOM killer. Заданный лимит включает maxmemory-policy, которая решает, что делать, когда память кончилась. Политики allkeys-* выкидывают любой ключ, включая тот, который вы считали хранилищем. Политики volatile-* трогают только ключи со сроком жизни. По умолчанию стоит noeviction: запись отвечает ошибкой вместо того, чтобы что-нибудь удалить (перечень политик в документации).

Отсюда прямой конфликт. Кешу нужна политика, которая вытесняет; базе — которая не вытесняет. Держа в одном экземпляре и то и другое, вы заранее выбираете, кем пожертвовать. Разнести по разным экземплярам дешевле, чем узнать ответ в аварию.

TTL или явная инвалидация

Под словом «инвалидация» прячутся два разных механизма.

Срок жизни. Запись живёт N секунд и исчезает сама. Настраивается один раз, не забывается никогда и ошибается ровно на N секунд.

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

Порядок разумный такой: TTL по умолчанию, явное удаление только там, где устаревшее значение меняет поведение: остаток на складе и право доступа — случаи, где протухшее число не просто врёт, а разрешает то, что разрешать нельзя.

Есть и третий способ, который обсуждают реже: версия внутри ключа. Не удалять product:42, а писать product:42:v7 и повышать номер при изменении. Старый ключ никто не читает, он уходит по сроку жизни сам. Инвалидации не существует — существует новый адрес. Платится памятью, зато исчезает целый класс гонок: тот, где запрос уже считает старое значение и записывает его в кеш сразу после того, как этот ключ удалили.

Лавина промахов

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

Это не редкость, а свойство горячего ключа: чем он популярнее, тем больше запросов придётся на окно пересчёта.

Лечится замком: первый промах считает, остальные ждут готовый результат. Или досрочным обновлением: ключ пересчитывается до истечения срока, с вероятностью, растущей к концу этого срока, — тогда в базу идёт один случайный запрос, а не все сразу. И дешёвая мера, которая помогает всегда: случайная добавка к TTL. Тысяча ключей, созданных одним прогревом с одинаковым сроком, протухнет одновременно.

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

Кеш перед медленным запросом

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

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

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

Отличаются эти два случая одним вопросом: вы знаете, почему запрос медленный? Знаете, и он медленный по существу — кешируйте спокойно. Не смотрели в план выполнения — вы не кешируете, а прячете. Планировщик умеет объяснить, что он делает, и разговор с ним короче, чем кажется.

Redis или Memcached

Memcached умеет ключ, строку, срок жизни, вытеснение и поверх этого — атомарный инкремент и compare-and-swap. Структур, диска и репликации нет. Он многопоточный, память раздаёт слоями фиксированных размеров и на простом «положил — взял» ведёт себя предсказуемо.

Redis умеет структуры, сохранение на диск, репликацию и атомарные сценарии на стороне сервера.

Ответ: если нужен ключ в строку и больше ничего, Memcached проще, и это настоящий довод. Решает он редко — Redis к этому моменту обычно уже стоит под очередь, блокировки или счётчики частоты, и заводить вторую систему ради упрощения первой невыгодно. Отдельно брать Memcached стоит там, где кеш большой, горячий и по-настоящему одноразовый: тогда его бедность — достоинство.

Кеш — это зависимость

Поставленный ради скорости Redis становится тем, без чего приложение не отвечает вовсе. Если клиент ходит в него без ограничения времени, недоступный Redis не замедляет сервис, а останавливает: потоки уходят в ожидание и не возвращаются. Единого поведения у клиентов здесь нет, и это хуже, чем единое плохое: redis-py по умолчанию не ставит таймаут вовсе (socket_timeout=None), другие клиенты ставят секунды. Значение по умолчанию надо смотреть у своего, а не предполагать.

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

Условие, при котором я неправ

Признак «переживёт ли потерю» ломается там, где данные формально восстановимы, а практически — нет. Сессии: источник истины как бы есть, можно всех перелогинить. Технически потеря пережита. Фактически вы разом выкинули всех пользователей, и разговор с поддержкой пойдёт не про теорию кеширования. То же с корзиной, черновиком и любым состоянием, которое человек набирал руками.

Значит, признак читается чуть шире: переживёт ли потерю система и человек по ту сторону экрана. Если второй ответ «нет», данные требуют сохранности, как бы они ни назывались в коде.

Проверить своё хозяйство можно сегодня: обойти ключи через SCAN и спросить у каждого TTL. Ответ -1 означает, что ключ будет лежать вечно. Список таких ключей обычно длиннее, чем ожидаешь, — и вот по нему и стоит пройтись с вопросом про потерю.