Паттерн, которым почти любому приложению стоит пользоваться с Valkey, - cache-aside: читать из кэша, а при промахе читать из базы данных, сохранять результат с TTL и возвращать его. При записи - обновлять базу данных и удалять закэшированный ключ, а не обновлять его. Задавайте срок жизни каждому ключу, ставьте версию в каждое имя ключа и защищайте дорогие ключи от лавин - момента, когда популярный ключ истекает и сотня запросов одновременно бросается его перестраивать. Эти пять привычек покрывают почти все способы, которыми кэш может пойти не так: устаревшие данные, которые никогда не обновляются, данные, сменившие форму и ломающие читателей, и кэш, который защищает базу данных ровно до того момента, когда эта защита нужнее всего.
В статье каждая из них разобрана с кодом, а затем - как понять, оправдывает ли кэш своё существование.
Cache-aside: паттерн по умолчанию#
В cache-aside логикой владеет приложение. Кэш ничего не знает о базе данных; код проверяет одно, при неудаче обращается к другому и заполняет пробел.
async function getProduct(id) { const key = `v2:product:${id}`; const hit = await valkey.get(key); if (hit !== null) return JSON.parse(hit); const product = await db.products.findById(id); // the expensive part if (product) { await valkey.set(key, JSON.stringify(product), "EX", 600); } return product;}Его достоинства и делают его вариантом по умолчанию. Если Valkey недоступен, приложение всё равно работает, только медленнее, - при условии, что чтение из кэша обёрнуто так, что ошибка считается промахом. Кэшируются только данные, которые кто-то действительно запросил, так что память уходит на горячий набор. А кэш можно сбросить в любой момент, ничего не потеряв, потому что источник истины - база данных.
Что кэшировать, примерно в порядке отдачи:
- Дорогие запросы, которые нельзя ускорить индексом, - агрегации, отчёты, результаты поиска, всё с
GROUP BYпо большому числу строк. - Обращения к чужим API, которые медленные, ограничены по частоте и иногда оплачиваются за каждый запрос.
- Отрендеренные фрагменты - раздел страницы, сериализованный ответ API, - где построение вывода стоит дороже, чем получение данных.
- Горячие записи, которые читают гораздо чаще, чем пишут, - товар, строка конфигурации, права пользователя.
Что не кэшировать: запросы, которые и так быстры с правильным индексом. Кэширование медленного запроса, которому нужен индекс, прячет проблему, пока кэш не остынет. Как читать EXPLAIN ANALYZE - дешевле, чем слой кэша, а статья Redis: когда он нужен шире обосновывает, почему можно обойтись без него.
Другие паттерны и почему они встречаются реже#
| Паттерн | Как работает | Когда подходит |
|---|---|---|
| Cache-aside | Приложение читает кэш, при промахе идёт в базу и заполняет кэш | Почти всегда |
| Read-through | Библиотека кэша сама загружает данные из базы при промахе | То же, что cache-aside, только внутри библиотеки |
| Write-through | Каждая запись идёт в кэш и в базу одновременно | Данные, которые читают сразу после записи |
| Write-behind | Записи идут в кэш и позже сбрасываются в базу | Счётчики и метрики, где потери допустимы |
| Refresh-ahead | Горячие ключи перестраиваются до истечения срока | Небольшой набор очень горячих и дорогих ключей |
Write-through звучит привлекательно - кэш никогда не устаревает, - но он кэширует всё записанное, читает это кто-нибудь или нет, и ему всё равно нужен TTL на случай, если запись в одну из сторон не удастся. Write-behind на время делает Valkey системой учёта, что нормально для счётчиков просмотров, сбрасываемых раз в минуту, и недопустимо для заказов. Refresh-ahead ещё встретится ниже как защита от лавин.
Выбор TTL#
TTL - это верхняя граница того, насколько устаревшим может быть закэшированное значение. Выбирайте его, спрашивая, сколько времени неверный ответ допустим, а не сколько значение, скорее всего, не будет меняться.
| Данные | Типичный TTL | Логика |
|---|---|---|
| Отрендеренный фрагмент страницы для анонимных пользователей | 30-300 секунд | Кратковременное устаревание незаметно |
| Описание товаров, тексты статей | 5-60 минут плюс инвалидация при записи | Правки обрабатывает инвалидация; TTL - страховка |
| Агрегат для отчёта или дашборда | 5-15 минут | Пользователи согласны на «по состоянию на несколько минут назад» |
| Ответ стороннего API | Насколько позволяют их условия и требования к свежести | Экономит деньги и лимит запросов |
| Права и роли | 1-5 минут плюс инвалидация | Отозванное право не должно задерживаться |
| Конфигурация и справочные таблицы | 1 час и больше | Меняется редко; инвалидируйте при deploy |
Два уточнения помогают TTL лучше вести себя под нагрузкой:
- Добавляйте разброс. Если пакетная задача заполняет десять тысяч ключей разом с
EX 3600, через час все они истекут в одну и ту же секунду, и база данных получит десять тысяч промахов сразу. Добавьте несколько случайных процентов:EX 3600 + random(0, 300). - Никогда не кэшируйте без него. Ключ без срока жизни - это обещание, что инвалидация сработает всегда. Не сработает - пропущенная ветка кода, ручная правка в базе, неудавшееся удаление, - и ключ будет отдавать неверное значение, пока кто-нибудь не сбросит кэш вручную. Даже данные, которые вы тщательно инвалидируете, заслуживают TTL как страховки.
Остерегайтесь одной ловушки: обычный SET без EX для существующего ключа снимает с него срок жизни. Каждый путь записи должен передавать TTL или использовать KEEPTTL.
Инвалидация: удаляйте, а не обновляйте#
Когда меняются исходные данные, закэшированная копия должна исчезнуть. Надёжный подход - сначала обновить базу данных, затем удалить ключ, а следующее чтение пусть его перестроит.
async function updateProduct(id, changes) { await db.products.update(id, changes); await valkey.unlink(`v2:product:${id}`);}Почему удалять, а не записывать новое значение в кэш? Потому что два одновременных писателя могут завершиться в базе данных и в кэше в разном порядке, и тогда в кэше навсегда останется более старое значение. У удаления проблемы порядка нет: какое бы удаление ни пришло последним, следующее чтение загрузит актуальную строку.
Остаётся одна узкая гонка. Читатель промахивается, загружает старую строку и задерживается; писатель обновляет строку и удаляет ключ; задержавшийся читатель затем записывает в кэш старую строку. Сколько это проживёт, ограничивает TTL, и это ещё одна причина, по которой он нужен каждому ключу. Там, где такое окно недопустимо, удаляйте ключ второй раз с небольшой задержкой после записи или вообще не держите это значение в кэше.
Инвалидация групп ключей - это то место, где люди хватаются за KEYS product:* и кладут сервер. Варианты получше:
- Префиксы версий. Ставьте версию в ключ (
v2:product:...). Смена формы закэшированных данных - это deploy, повышающий версию; старые ключи просто истекают. - Счётчики поколений. Держите счётчик для каждой группы (
gen:catalogue) и включайте его значение в имена ключей. Инвалидация всей группы - одинINCR; старые ключи больше никогда не читаются и уходят по своим TTL. - Множества тегов. Добавляйте каждый ключ кэша в множество для каждого тега (
SADD tag:category:7 v2:product:42). Чтобы инвалидировать тег, прочитайте элементы множества, выполните для нихUNLINKи удалите множество. Множеству тоже задайте TTL.
Для всего крупного используйте UNLINK, а не DEL: он удаляет ключ сразу, а память освобождает в фоне, так что удаление большого значения не тормозит других клиентов.
Лавины: когда кэш отказывает именно тогда, когда он нужен#
Лавина - её ещё называют dogpile или thundering herd - случается, когда популярный ключ истекает и множество запросов промахиваются в один и тот же момент. Каждый выполняет дорогой запрос, каждый записывает результат, и база данных принимает на себя полную нагрузку всех запросов в этом окне - часто в пик трафика, ведь именно тогда ключ и был популярен. Холодный старт после сброса кэша или перезапуска - то же самое, только сразу по всем ключам.
Три вида защиты, от самого простого до самого надёжного.
Блокировка на перестройку. Первый промахнувшийся запрос берёт короткую блокировку через SET ... NX PX, перестраивает значение и снимает её. Остальные недолго ждут и перечитывают кэш или отдают устаревшую копию, если она есть.
import json, time, uuidUNLOCK = valkey.register_script("""if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1])endreturn 0""")def get_report(team_id): key, lock = f"v1:report:{team_id}", f"lock:v1:report:{team_id}" for _ in range(50): hit = valkey.get(key) if hit is not None: return json.loads(hit) token = str(uuid.uuid4()) if valkey.set(lock, token, nx=True, px=10_000): try: report = build_report(team_id) # the expensive part valkey.set(key, json.dumps(report), ex=900) return report finally: UNLOCK(keys=[lock], args=[token]) time.sleep(0.1) # someone else is rebuilding return build_report(team_id) # give up waitingУ блокировки есть срок жизни (PX 10000), чтобы упавший исполнитель не мог держать её вечно, а снимается она скриптом, проверяющим токен, чтобы медленный исполнитель, чья блокировка уже истекла, не мог удалить блокировку, которую теперь держит кто-то другой.
Stale-while-revalidate. Храните значение с длинным жёстким TTL, а внутри значения держите мягкое время истечения. Читатель, обнаруживший, что мягкое время прошло, всё равно сразу возвращает устаревшее значение и запускает перестройку в фоне - под защитой той же блокировки, чтобы выполнялась только одна. Пользователи никогда не ждут, а база данных видит одну перестройку на ключ за интервал.
Вероятностное раннее обновление. Каждый читатель при попадании бросает кубик, взвешенный по тому, насколько ключ близок к истечению и сколько занимает его перестройка, и время от времени обновляет его заранее. Обычная формула (из статьи «XFetch») перестраивает значение, когда now - rebuild_time * beta * ln(random()) >= expiry, с beta около 1. В итоге статистически один запрос обновляет ключ незадолго до его истечения, без всякой блокировки. Это подходит для небольшого набора очень горячих ключей.
Негативное кэширование и ошибки#
Если поиск ничего не нашёл - ID товара, которого не существует, пользователь без аватара, - кэшируйте и это. Иначе каждый запрос за несуществующим будет уходить в базу данных, а скрейпер, перебирающий ID, или злоумышленник, делающий это намеренно, полностью обойдёт кэш. Храните метку вроде пустой строки или {"missing":true} с TTL короче, чем у настоящих значений, чтобы только что созданная запись быстро появлялась.
С ошибками иначе. Не кэшируйте неудачное обращение к стороннему API так, будто это ответ, - а если это необходимо, чтобы не долбить упавший сервис, кэшируйте на секунды, а не минуты, и чётко отличайте это от настоящего пустого результата.
Значения: размер и сериализация#
То, как вы храните значение, влияет на память и скорость сильнее, чем принято думать.
- JSON читаем и универсален, и это правильный вариант по умолчанию. MessagePack или похожий двоичный формат компактнее и быстрее разбирается, но читать его в
valkey-cliнеудобно. - Сжимайте большие значения. JSON-документ на 200 КБ после gzip или zstd занимает лишь долю этого объёма, а процессорное время обычно меньше сэкономленного сетевого. Ниже нескольких килобайт это того не стоит.
- Избегайте огромных значений. Значение в несколько мегабайт отправляется миллисекунды, и пока сервер его отправляет, он больше никого не обслуживает. Разбейте его на части или кэшируйте только то, что действительно читают.
- Используйте хеш для записей, которые читают частично.
HGET product:42 priceчитает одно поле без передачи всего объекта; небольшие хеши к тому же хранятся компактно.
Что происходит, когда кэш заполняется, и почему политика вытеснения должна учитывать, что эти данные одноразовые, рассказано в статье память Valkey и политики вытеснения.
Как измерить, работает ли кэш#
Кэш, который не измеряют, - это догадка. Базовые счётчики Valkey ведёт сам:
$ valkey-cli -h db.example.net -p 6380 --askpass INFO stats \ | grep -E 'keyspace_hits|keyspace_misses|evicted_keys|expired_keys'keyspace_hits:1843920keyspace_misses:211347evicted_keys:0expired_keys:96112Коэффициент попаданий - это число попаданий, делённое на сумму попаданий и промахов, здесь около 90 процентов. Общесерверные числа смешивают все виды ключей, поэтому считайте попадания и промахи ещё и по префиксам ключей в приложении - там видно, что страницы товаров попадают на 98 процентов, а результаты поиска - на 40. Низкий коэффициент означает слишком короткие TTL, слишком конкретные ключи (метка времени или ID сессии в ключе, который должен быть общим) или данные, которые просто не используются повторно. Растущий evicted_keys означает, что кэш слишком мал для своего рабочего набора. Как превратить это в оповещения, рассказано в статье мониторинг, который что-то вам сообщает.
Измеряйте и базу данных. Смысл кэша - в нагрузке, которую он снимает; если после добавления кэша частота запросов и задержка не падают, он не делает своего дела.
Кэширование с Valkey на RE:NODE#
Линейка Valkey подходит именно для этой задачи. У самого маленького тарифа 256 МБ памяти, из которых, по описанию тарифа, доступно примерно 200 МБ - с запасом для кэша перед одним приложением, - а тарифы доходят до 4 ГБ. Каждый сервер защищён паролем и доступен по хосту и порту, показанным в панели; слота прокси нет, приложение подключается напрямую. Поскольку каждый сервер сохраняет данные на диск через AOF и снимки, после перезапуска кэш возвращается тёплым, а не пустым, и это убирает лавину холодного старта при плановых перезапусках. Считайте это удобством, а не гарантией: кэш по-прежнему одноразовые данные, и код должен работать, когда он пуст.
FAQ#
Обновлять кэш или удалять его, когда данные меняются?
Удалять. Обновление может оставить в кэше более старое значение, если две записи завершатся в разном порядке, а удаление всегда приводит к тому, что следующее чтение загрузит актуальные данные. Держите TTL на каждом ключе как страховку для случаев, которые инвалидация пропустит.
Какой TTL выбрать?
Самое долгое устаревание, которое пользователи готовы терпеть для этих данных, а не догадку о том, как часто они меняются. Секунды для фрагментов страниц, минуты для данных о товарах и агрегатов, дольше для конфигурации - и всегда с инвалидацией при записи для всего, что редактируют пользователи. Добавьте немного случайного разброса, чтобы ключи, заполненные вместе, не истекали вместе.
Как очистить все закэшированные ключи одной функции?
Не используйте KEYS. Ставьте в имена ключей номер версии или поколения и увеличивайте его, чтобы старые ключи больше никогда не читались и истекали сами. Если нужно удалить немедленно, держите множество ключей для каждого тега и выполняйте UNLINK для его элементов.
Что такое лавина кэша?
Множество запросов, которые в один и тот же момент промахиваются по одному истёкшему ключу и все вместе запускают дорогую перестройку. Предотвратить её можно короткой блокировкой на перестройку через SET NX PX, отдачей устаревших значений, пока один запрос обновляет данные, или обновлением горячих ключей незадолго до их истечения.
Какой коэффициент попаданий должен быть у кэша?
Зависит от данных, но для кэша общего назначения всё, что ниже примерно 80 процентов, стоит расследовать. Смотрите коэффициенты по префиксам ключей в приложении; среднее по серверу прячет тот единственный вид ключей, который никогда не попадает.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.