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

Redis хранит данные в памяти и потому отвечает за доли миллисекунды

Redis появился в 2009 году. Его написал разработчик, искавший способ ускорить свой проект и упёршийся в ограничения традиционных систем хранения: первый прототип был переписан на C, а первым типом данных стал список. Днём рождения проекта считается 26 февраля 2009 года.

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

Главное преимущество вытекает из места хранения: обращение к оперативной памяти на порядки быстрее обращения к диску, типичный ответ занимает от десятков микросекунд до единиц миллисекунд. Эта разница делает кэширование самым дешёвым способом ускорить бэкенд без покупки новых серверов.

Быстрота достигается не магией, а дисциплиной. Redis однопоточный в части выполнения команд, и любая тяжёлая операция блокирует остальные. Отсюда главное правило оптимизации: не заставлять сервер делать работу, которую можно сделать иначе.

Кэширование начинается с выбора структуры данных под задачу

Новички часто складывают в Redis только строки, а потом удивляются, почему приложение выполняет десятки обращений там, где хватило бы одного, хотя хранилище предоставляет богатый набор типов.

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

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

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

Практическое правило: перед написанием кода мысленно нарисовать структуру и посчитать, сколько обращений потребует типичная операция.

Правильный выбор стратегии вытеснения защищает от неожиданного удаления данных

Оперативная память конечна, и рано или поздно место заканчивается. Что произойдёт дальше, определяется настройкой предельного объёма и политики вытеснения.

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

Когда предел задан, в дело вступает политика. Она отвечает на вопрос, какие ключи удалять при нехватке места. Распространённые варианты перечислены ниже:

  1. удаление ключей с истёкшим сроком жизни и отказ в записи при нехватке места;
  2. удаление наименее давно использованных ключей из всего набора;
  3. удаление наименее давно использованных ключей только среди тех, у кого выставлен срок жизни;
  4. удаление случайных ключей из всего набора или только среди временных;
  5. удаление ключей с наименьшей частотой обращения из всего набора или только среди временных.

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

Этот эффект называют загрязнением кэша: обходчик запросил миллион страниц, которые больше никогда не понадобятся, и при простом алгоритме ценные записи вытесняются мусором. Политика на основе частоты обращения такому нашествию сопротивляется заметно лучше.

Смешивать в одном экземпляре кэш и постоянное хранилище опасно: под давлением политика вытеснения может удалить то, что удалять нельзя. Разделение на отдельные экземпляры или хотя бы разные базы решает эту проблему радикально.

Срок жизни ключа превращает кэш в самоочищающуюся систему

Второй инструмент управления памятью это время жизни: ключ создаётся со сроком в секундах или миллисекундах, и по истечении запись исчезает сама.

Справочник городов меняется раз в месяц, его кэш можно держать сутки. Курс валют обновляется ежедневно, срок разумно ограничить часом. Список онлайн-пользователей живёт минуту, лимит обращений к API с одного адреса измеряется секундами.

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

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

Полезный приём это случайный разброс срока: тысяча ключей, созданных одновременно с одинаковым сроком, истекут одновременно и устроят всплеск нагрузки на базу. Разброс в пределах десяти процентов размазывает пик во времени и стоит одной строки кода.

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

Шаблоны инвалидации определяют, увидит ли пользователь свежие данные

Кэш ускоряет чтение, но создаёт новую проблему: как сообщить ему об изменении данных. От решения этой задачи зависит, будут ли пользователи видеть устаревшие сведения.

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

Более сложный подход предполагает, что приложение пишет в кэш первым, а база догоняет, и это опасно: при сбое базы данные теряются безвозвратно.

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

import json
import redis

client = redis.Redis(host="cache.internal", port=6379, decode_responses=True)

def get_catalog(section_id):
    key = "catalog:section:" + str(section_id)
    cached = client.get(key)
    if cached is not None:
        return json.loads(cached)

    data = load_from_database(section_id)
    client.set(key, json.dumps(data), ex=3600)
    return data

def update_catalog(section_id, payload):
    save_to_database(section_id, payload)
    client.delete("catalog:section:" + str(section_id))

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

Второй приём это групповая инвалидация через версионирование: если справочник меняется целиком, в ключ добавляется номер версии, а изменение сводится к увеличению счётчика, старые ключи тихо истекут сами.

def versioned_key(name, version):
    return name + ":v" + str(version)

def bump_version(name):
    return client.incr("version:" + name)

Приём требует осторожности и обязательного срока жизни, иначе старые версии накопятся и съедят память.

Конвейер и пакетные операции сокращают число сетевых обходов

Каждая команда в Redis это обращение по сети: даже при ответе за микросекунды дорога туда и обратно занимает время, и тысяча последовательных команд складывается в секунды задержки.

Решение в конвейере: клиент отправляет несколько команд подряд, не дожидаясь ответа, и читает все ответы сразу, число сетевых обходов сокращается с тысячи до одного, время падает в разы.

pipe = client.pipeline(transaction=False)
for user_id in range(1, 1001):
    pipe.get("profile:" + str(user_id))
results = pipe.execute()

Конвейер не транзакция: он не гарантирует выполнения команд как единого целого и не даёт изоляции, при необходимости атомарности применяются транзакции или сценарии на стороне сервера.

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

Третий способ это сценарии на стороне сервера: несколько связанных операций оформляются одним скриптом, клиент платит за одну передачу, сервер не тратит время на переключения.

Сценарий блокирует сервер на время выполнения: тысяча итераций с тяжёлыми операциями заставит остальных клиентов ждать, поэтому правило простое, сценарий должен быть коротким и предсказуемым по времени.

Мониторинг и разбор медленных операций показывают, где теряется скорость

Оптимизация без измерений превращается в гадание, и Redis предоставляет инструменты, дающие точные ответы.

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

CONFIG SET slowlog-log-slower-than 10000
CONFIG SET slowlog-max-len 128
SLOWLOG GET 10

Вторая команда ограничивает длину журнала, третья выводит последние записи.

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

Третий инструмент это разбор распределения памяти по типам и оценка крупных ключей: одна огромная запись способна затормозить сервер и усложнить вытеснение.

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

Ошибки в работе с кэшем обходятся дороже, чем отсутствие кэша

Типичные провалы встречаются из проекта в проект.

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

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

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

Четвёртая ошибка это хранение больших объектов: сериализованный отчёт на несколько мегабайт замедляет сеть, память и вытеснение, крупные данные лучше разбивать на части или держать вне кэша.

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

Обёртка вокруг каждого обращения с перехватом ошибок гарантирует, что отказ кэша не превратится в отказ сервиса: кэш должен ускорять работу, а не становиться новой точкой отказа.

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