Сайт держит восемьдесят миллисекунд на ответ, и половина этого времени уходит на запросы к базе данных, которые повторяются по сто раз в минуту. Запрос списка категорий, счётчик просмотров, проверка сессии, лимит обращений с одного адреса. Все эти операции не меняются каждую секунду, но выполняются заново при каждом обращении. Решение лежит на поверхности, и оно называется кэш в оперативной памяти.
Redis хранит данные в памяти и потому отвечает за доли миллисекунды
Redis появился в 2009 году. Его написал разработчик, который искал способ ускорить работу своего проекта и упёрся в ограничения традиционных систем хранения. Первый прототип был готов на одном языке, затем переписан на C, а первым типом данных стал список. Днём рождения проекта считается 26 февраля 2009 года.
Название расшифровывается как удалённый сервер словарей. По сути это хранилище структур данных в оперативной памяти, которое работает по модели ключ и значение и может использоваться и как распределённый кэш, и как брокер сообщений, и как очередь задач.
Главное преимущество вытекает из места хранения. Обращение к оперативной памяти на порядки быстрее обращения к диску, поэтому типичный ответ занимает от десятков микросекунд до единиц миллисекунд. Именно эта разница превращает кэширование в самый дешёвый способ ускорить бэкенд: вместо покупки новых серверов достаточно правильно распорядиться уже имеющейся памятью.
Важно понимать, что быстрота достигается не магией, а дисциплиной. Redis однопоточный в части выполнения команд, и любая тяжёлая операция блокирует остальные. Отсюда следует главное правило оптимизации: не заставлять сервер делать работу, которую можно сделать иначе.
Кэширование начинается с выбора структуры данных под задачу
Новички часто складывают в Redis только строки, а потом удивляются, почему приложение выполняет десятки обращений там, где хватило бы одного. Между тем хранилище предоставляет богатый набор типов.
Строки подходят для простых значений: токен, счётчик, готовый результат сериализации. Хеши хранят наборы полей одного объекта, например профиль пользователя с именем, городом и настройками. Списки работают как очереди и стеки, они же удобны для лент последних событий. Множества дают быструю проверку принадлежности и операции пересечения. Упорядоченные множества добавляют к этому сортировку по числовому весу, что делает их основой рейтингов и таблиц лидеров.
Отдельная группа это вероятностные структуры. Они отвечают на вопрос приблизительно, зато занимают несопоставимо меньше памяти. Если нужно посчитать уникальных посетителей за сутки, точный список займёт мегабайты, а вероятностная оценка уложится в килобайты.
Разница в потреблении памяти выглядит наглядно. Пусть приложение хранит миллион активных сессий, в каждой десять полей средним размером тридцать байт. Если хранить каждую сессию отдельной строкой с префиксом в ключе, накладные расходы на сам ключ и служебные структуры составят заметную долю. Если сложить поля в один хеш на сессию, память экономится на десятки процентов, а число сетевых обращений сокращается в десять раз: вместо десяти команд на чтение профиля уходит одна.
Практическое правило звучит так: перед тем как писать код, стоит мысленно нарисовать структуру и посчитать, сколько обращений потребует типичная операция.
Правильный выбор стратегии вытеснения защищает от неожиданного удаления данных
Оперативная память конечна, и рано или поздно место заканчивается. Что произойдёт дальше, определяется настройкой предельного объёма и политики вытеснения.
Если предел не задан, Redis будет расти, пока не упрётся в ограничения операционной системы, и тогда процесс может быть остановлен извне. Такой сценарий в рабочий среде недопустим.
Когда предел задан, в дело вступает политика. Она отвечает на вопрос, какие ключи удалять при нехватке места. Распространённые варианты перечислены ниже:
- удаление ключей с истёкшим сроком жизни и отказ в записи при нехватке места;
- удаление наименее давно использованных ключей из всего набора;
- удаление наименее давно использованных ключей только среди тех, у кого выставлен срок жизни;
- удаление случайных ключей из всего набора или только среди временных;
- удаление ключей с наименьшей частотой обращения из всего набора или только среди временных.
Пятый вариант заслуживает отдельного разговора. Он опирается на приблизительный подсчёт частоты обращений и потому требует небольших дополнительных расходов памяти на служебные счётчики. Зато он защищает от главной беды простого алгоритма: случайный обход большого объёма данных, который приходит один раз и вытесняет действительно нужные записи.
Именно этот эффект называют загрязнением кэша. Представим, что по сайту прошёл обходчик и запросил миллион страниц, каждая из которых больше никогда не понадобится. При простом алгоритме все ценные записи будут вытеснены мусором. Политика на основе частоты обращения такому нашествию сопротивляется заметно лучше.
Второй вопрос касается разделения данных. Смешивать в одном экземпляре кэш и постоянное хранилище опасно: под давлением политика вытеснения может удалить то, что удалять нельзя. Разделение на отдельные экземпляры или хотя бы на разные базы решает эту проблему радикально.
Срок жизни ключа превращает кэш в самоочищающуюся систему
Второй инструмент управления памятью это время жизни. Ключ можно создать с указанием срока в секундах или миллисекундах, и по его истечении запись исчезнет сама.
Простой пример из практики. Справочник городов меняется раз в месяц, значит, его кэш можно смело держать сутки. Курс валют обновляется ежедневно, срок разумно ограничить часом. Список онлайн-пользователей живёт минуту. Лимит обращений к 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
Вторая команда ограничивает длину журнала, чтобы он сам не съел память. Третья выводит последние записи.
Второй инструмент это сведения о состоянии сервера. Из общей сводки видно, сколько памяти занято, сколько ключей удалено по истечении срока, сколько обращений попали в промах, какова доля попаданий. Последняя цифра особенно показательна: если она ниже девяноста процентов, кэш работает вхолостую и его настройку нужно пересматривать.
Третий инструмент это разбор распределения памяти по типам и оценка размера крупных ключей. Найти огромный ключ важно, потому что одна такая запись способна затормозить сервер и усложнить вытеснение.
Полезно наладить регулярный сбор метрик. Число команд в секунду, средняя задержка, потребление памяти, доля попаданий, количество вытесненных и истёкших ключей. Эти пять показателей дают полную картину и позволяют заметить проблему до того, как пользователи начнут жаловаться.
Ошибки в работе с кэшем обходятся дороже, чем отсутствие кэша
Разберём типичные провалы, которые встречаются в проектах.
Первая ошибка это кэширование всего подряд. Попытка сохранить каждую страницу и каждый запрос приводит к тому, что кэш заполняется редкими записями и вытесняет нужные. Лекарство: кэшировать только то, что читается часто и меняется редко.
Вторая ошибка это одинаковый срок жизни у всех ключей. Об этом уже говорилось, но масштаб проблемы недооценивают. Одновременное истечение миллионов ключей создаёт лавину запросов к базе, от которой та может не оправиться.
Третья ошибка это кэширование отрицательных результатов без ограничения. Если приложение запомнит на сутки, что товара нет, то после поступления на склад покупатели ещё сутки будут видеть отсутствие. Отрицательный ответ стоит кэшировать на минуты, а не на часы.
Четвёртая ошибка это хранение больших объектов. Сериализованный отчёт на несколько мегабайт в одном ключе замедляет сеть, память и вытеснение. Крупные данные лучше разбивать на части или хранить вне кэша.
Пятая ошибка связана с отсутствием проверки доступности. Если сервер кэша недоступен, приложение не должно падать целиком. Правильное поведение состоит в том, чтобы пропустить обращение к кэшу и пойти напрямую в базу, возможно, с ограничением скорости, чтобы не перегрузить её.
def safe_get(key, fallback):
try:
value = client.get(key)
if value is not None:
return json.loads(value)
except redis.RedisError:
pass
data = fallback()
try:
client.set(key, json.dumps(data), ex=300)
except redis.RedisError:
pass
return data
Такая обёртка гарантирует, что отказ кэша не превратится в отказ сервиса. Это принципиальный момент: кэш должен ускорять работу, а не становиться новой точкой отказа.
Разделение ролей и кластер решают задачи роста
Когда один сервер перестаёт справляться, на помощь приходят несколько. Репликация позволяет держать копии данных на других узлах и направлять туда часть читательских запросов. Разделение на части, известное как шардирование, распределяет ключи между узлами по заранее определённой схеме.
У обеих техник есть цена. Реплика даёт отставание: копия не успевает мгновенно, и чтение с неё может вернуть вчерашние данные. Для кэша это обычно допустимо, а для счётчиков денег нет. Разделение на части усложняет операции, затрагивающие несколько ключей: они должны попадать в одну часть, иначе согласованность не гарантируется.
Простое правило для кэша звучит так: сначала исчерпайте возможности одного узла. Правильно выбранные структуры, разумные сроки жизни и грамотная политика вытеснения позволяют одному серверу обслуживать десятки тысяч операций в секунду. Кластер нужен там, где данные уже не помещаются в память одного узла или где требуется отказоустойчивость.
Оптимизация кэша получилась историей про здравый смысл. Технология сама по себе быстра, и весь выигрыш определяется тем, насколько аккуратно с ней обращаются. Хорошо настроенный кэш незаметен: страницы открываются быстро, база данных дышит свободно, а графики загрузки остаются ровными даже в час пик. Именно эта незаметность и есть признак того, что работа сделана правильно.