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 означает, что ключ будет лежать
вечно. Список таких ключей обычно длиннее, чем ожидаешь, — и вот
по нему и стоит пройтись с вопросом про потерю.