← назад к разделу

ДРУГИЕ ХРАНИЛИЩА Redis

Обязательно

Что такое Redis: in-memory хранилище для backend

Java · Go · Node · Python

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

1. Redis держит горячие данные в памяти

1 / 5

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

  • Redis не заменяет систему записи.
  • Память конечна: кэшу задают политику вытеснения.
  • Без персистентности данные живут пока процесс.
запросRedisin-memorydatabaseпромахГорячие данные рядом с приложением

2. TTL автоматически ограничивает жизнь ключа

2 / 5

EXPIRE задаёт секунды жизни. TTL -1 означает вечный ключ, -2 - отсутствие. SET ... EX задаёт значение и срок атомарно.

  • Просроченный ключ удаляется при обращении или фоновом проходе.
  • Сессия и блокировка не должны быть вечными.
  • Случайная добавка к TTL не даёт множеству ключей истечь разом.
SET session EX 60TTL 58TTL -2нет ключаВремя жизни - часть записи

3. SET NX EX создаёт блокировку атомарно

3 / 5

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

  • Процесс упал - TTL освобождает ключ.
  • Удаляют только своё значение через Lua.
  • Две команды SETNX и EXPIRE оставляют окно смерти без TTL.
SET lock:42 owner NX EX 30второй → nilowner-uuidосвобождение через LuaНет окна между захватом и TTL

4. Один поток останавливается тяжёлой командой

4 / 5

KEYS * и большие HGETALL, SMEMBERS, LRANGE занимают сервер линейно по размеру ключа. Пока они идут, остальные ждут.

  • Обход делают SCAN и курсорами.
  • Большой ключ удаляют UNLINK.
  • SLOWLOG показывает виновные команды.
KEYS *все клиенты ждутSCAN cursorпорции по Nдругие работаютЦена команды важнее миллисекунд Redis

5. Поездки стоят больше команды

5 / 5

Сто GET по сети обходятся дороже одного MGET. Pipeline объединяет команды, Lua выполняет атомарную логику на сервере.

  • MULTI/EXEC группирует, но не откатывает ошибки.
  • Длинный Lua тоже останавливает сервер.
  • В кластере нет номеров баз: разделение через префикс или экземпляр.
100 × GET100 поездокMGET / pipelineодна поездкаLua атомарен и короткий

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

команды от разных клиентов встают в одну очередь и выполняются по одной клиенты очередь команд, один поток память: ключи и сроки клиент A клиент B клиент C SET user:42:name SET session:abc EX 300 GET user:42:name GET session:abc SET user:42:name SET session:abc EX 300 GET user:42:name GET session:abc user:42:name= Иван, без срока session:abc = user 42, TTL 300 = user 42, TTL 120 = user 42, TTL 0 session:abcсрок вышел — ключа нет проходит пять минут — Redis удаляет ключ сам что вернулось клиенту GET user:42:name → Иван GET session:abc → (nil)

Команды от всех клиентов встают в одну очередь, и выполняет их один поток — по одной, целиком. Ключи лежат в оперативной памяти, а срок жизни снимает ключ без участия приложения: следующее чтение вернёт (nil).

Почему Redis такой быстрый

Redis — это in-memory хранилище типа «ключ — значение». В отличие от PostgreSQL или MySQL, которые хранят данные на диске, Redis держит всё в оперативной памяти. Обращение к памяти быстрее дисковых операций на несколько порядков: вместо миллисекунд — микросекунды.

Вторая причина скорости — однопоточная обработка команд: Redis выполняет их одну за другой и не тратится на блокировки и согласование между потоками. Читать из сети и разбирать запросы он с шестой версии умеет несколькими потоками, но исполнение по-прежнему идёт в один — и именно это даёт предсказуемость. Каждая отдельная команда (GET, SET, ZADD) атомарна — никаких частичных состояний.

Обратная сторона одного потока

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

Классический пример — KEYS *: команда просматривает всё пространство ключей и на базе в несколько миллионов ключей занимает секунды. Секунда паузы для сервиса, который рассчитывает на доли миллисекунды, — это отказ. Поэтому KEYS в рабочей системе не используют вовсе: для обхода есть курсорный SCAN, который возвращает ключи порциями и не блокирует сервер.

0 мс A: KEYS * 0,1 мс B: GET ждёт 0,2 мс C: INCR ждёт 900 мс A: ответ 900,1 мс B: ответ через 900 мс 900,2 мс C: ответ через 900 мс

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

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

Откуда на самом деле берётся задержка

Сама работа с памятью занимает микросекунды, а ответ клиенту приходит за десятые доли миллисекунды. Разница — это сетевая поездка: запрос ушёл, ответ вернулся, и в локальной сети это порядка 0,1–0,5 мс на команду.

Следствие важнее самой цифры. Сто команд подряд — это сто поездок, то есть десятки миллисекунд, и никакая скорость Redis тут не поможет: узкое место — не сервер, а число обменов. Лечится это тремя способами: командой, которая работает сразу с многими ключами (MGET, HMGET), конвейером (несколько команд одним пакетом, ответы приходят вместе) или скриптом на Lua, который выполняет логику на сервере. Подробнее — в разделе про круговые поездки ниже.

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

Когда применять Redis

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

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

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

Счётчики — атомарная команда INCR увеличивает число без риска гонки данных. Удобно для счётчиков просмотров, лайков, рейтингов.

Ограничение числа запросов (rate limiting) — счётчик за последнюю минуту с автоматическим истечением (TTL) даёт правило «не более 100 запросов в минуту».

Очереди задач — структура списка (LIST) позволяет организовать простую очередь: один процесс добавляет задачи, другой забирает и выполняет.

Когда Redis не подходит

По умолчанию Redis хранит всё в памяти. Если процесс упал без включённой персистентности — данные пропали. Это нормально для кэша, но недопустимо для заказов, счетов или пользовательских профилей.

Не стоит хранить в Redis большие объёмы данных: оперативная память дороже диска, а сложных JOIN-запросов он не умеет вовсе.

С полнотекстовым поиском тоньше. В самом хранилище «ключ — значение» искать по словам нечем, но рядом живёт поисковый движок: раньше он подключался отдельным модулем RediSearch, а с версии 8 входит в основную поставку. Заменой Elasticsearch он не станет, а поискать по небольшому набору данных, которые и так уже лежат в Redis, позволяет.

Правило: Redis — ускоритель рядом с основной базой, не замена ей.

Redis или Memcached

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

Redis берут, когда нужно хоть что-то сверх «положил-взял»: структуры (счётчики, списки, множества, рейтинги), сохранение на диск, репликация и переключение при отказе, блокировки, очереди, ограничение частоты, публикация событий. То есть почти всегда — и поэтому Memcached сегодня выбирают редко, в узком случае «очень много простых значений, кэш чисто вспомогательный, потеря содержимого безболезненна».

Что происходит, когда память кончилась

Это первый вопрос эксплуатации, и ответ зависит от настройки maxmemory-policy. Значение по умолчанию (noeviction) означает: при достижении предела Redis перестаёт принимать запись и отвечает ошибкой, а чтение продолжает работать. Для кэша это почти всегда не то, что нужно: правильная настройка — вытеснение (allkeys-lru или allkeys-lfu), когда сервер сам выбрасывает давно не использованные ключи и продолжает принимать новые.

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

Номер базы и кластер

Мелочь, которая ломается при переезде: SELECT и настройка database: 1 в конфигурации приложения работают только на одиночном сервере. В кластере номеров баз нет вовсе — доступна только нулевая, и попытка выбрать другую отвечает ошибкой.

Поэтому разделять данные номерами баз («кэш в первой, сессии во второй») — плохая привычка: она не переживёт переезд в кластер и не даёт ни изоляции по памяти, ни отдельных настроек. Правильное разделение — префиксом в имени ключа (cache:, session:, lock:) или отдельным экземпляром Redis, если у данных разные требования к сохранности.

Redis или Valkey

Заводите кэш в облаке — и вам предлагают не Redis, а какой-то Valkey. Выглядит как опечатка, но нет.

История такая. В 2024 году Redis сменил лицензию на несвободную, и часть сообщества вместе с крупными облаками увела код в отдельный проект — Valkey. В 2025-м Redis вернулся к открытой лицензии (AGPLv3, начиная с восьмой версии), но было поздно: Valkey к тому моменту стал вариантом по умолчанию в AWS ElastiCache и Google Memorystore, а дистрибутивы Linux переключились на него.

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

Базовые команды

Прежде чем брать библиотеку, полезно поработать с Redis напрямую в клиенте redis-cli:

redis-cli

SET и GET — записать и прочитать

живой пример

# Записать строку
SET user:42:name "Иван"

# Прочитать
GET user:42:name
# → "Иван"
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Ключ — произвольная строка. Принято строить имена через двоеточие: сущность:id:поле.

EXPIRE и TTL — время жизни ключа

TTL (time to live) — сколько секунд ключ ещё будет жить. Когда время истечёт, Redis удалит его автоматически.

живой пример

# Установить TTL в 60 секунд
EXPIRE user:42:name 60

# Проверить, сколько осталось
TTL user:42:name
# → 58  (прошло 2 секунды)
# → -1  (TTL не задан, ключ живёт вечно)
# → -2  (ключа нет — истёк, был удалён или его никогда и не было)
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Можно установить ключ и TTL одной командой:

живой пример

# SET с опцией EX — истечёт через 300 секунд
SET session:abc123 "данные сессии" EX 300
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

живой пример

import java.util.HashMap;
import java.util.Map;

public class TtlDemo {
    record Entry(String value, long expiresAt) {}

    static final Map<String, Entry> keys = new HashMap<>();
    static long now = 0;

    static void set(String key, String value, long ttlSeconds) {
        long expiresAt = ttlSeconds > 0 ? now + ttlSeconds : Long.MAX_VALUE;
        keys.put(key, new Entry(value, expiresAt));
    }

    static String get(String key) {
        Entry entry = keys.get(key);
        if (entry != null && entry.expiresAt() <= now) {
            keys.remove(key);
            entry = null;
        }
        return entry == null ? "(nil)" : entry.value();
    }

    public static void main(String[] args) {
        set("session:abc", "user 42", 300);
        set("user:42:name", "Иван", 0);
        System.out.println("t=0    GET session:abc  -> " + get("session:abc"));
        now = 301;
        System.out.println("t=301  GET session:abc  -> " + get("session:abc"));
        System.out.println("t=301  GET user:42:name -> " + get("user:42:name"));
        System.out.println("ключей осталось: " + keys.size());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

type entry struct {
	value     string
	expiresAt int64
}

var (
	keys = map[string]entry{}
	now  int64
)

func set(key, value string, ttlSeconds int64) {
	expiresAt := int64(1 << 62)
	if ttlSeconds > 0 {
		expiresAt = now + ttlSeconds
	}
	keys[key] = entry{value, expiresAt}
}

func get(key string) string {
	e, ok := keys[key]
	if ok && e.expiresAt <= now {
		delete(keys, key)
		ok = false
	}
	if !ok {
		return "(nil)"
	}
	return e.value
}

func main() {
	set("session:abc", "user 42", 300)
	set("user:42:name", "Иван", 0)
	fmt.Println("t=0    GET session:abc  ->", get("session:abc"))
	now = 301
	fmt.Println("t=301  GET session:abc  ->", get("session:abc"))
	fmt.Println("t=301  GET user:42:name ->", get("user:42:name"))
	fmt.Println("ключей осталось:", len(keys))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const keys = new Map();
let now = 0;

function set(key, value, ttlSeconds) {
  const expiresAt = ttlSeconds > 0 ? now + ttlSeconds : Infinity;
  keys.set(key, { value, expiresAt });
}

function get(key) {
  const entry = keys.get(key);
  if (entry && entry.expiresAt <= now) {
    keys.delete(key);
    return "(nil)";
  }
  return entry ? entry.value : "(nil)";
}

set("session:abc", "user 42", 300);
set("user:42:name", "Иван", 0);
console.log("t=0    GET session:abc  ->", get("session:abc"));
now = 301;
console.log("t=301  GET session:abc  ->", get("session:abc"));
console.log("t=301  GET user:42:name ->", get("user:42:name"));
console.log("ключей осталось:", keys.size);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import math

keys = {}
now = 0


def set_key(key, value, ttl_seconds):
    expires_at = now + ttl_seconds if ttl_seconds > 0 else math.inf
    keys[key] = (value, expires_at)


def get(key):
    entry = keys.get(key)
    if entry and entry[1] <= now:
        del keys[key]
        entry = None
    return "(nil)" if entry is None else entry[0]


set_key("session:abc", "user 42", 300)
set_key("user:42:name", "Иван", 0)
print("t=0    GET session:abc  ->", get("session:abc"))
now = 301
print("t=301  GET session:abc  ->", get("session:abc"))
print("t=301  GET user:42:name ->", get("user:42:name"))
print("ключей осталось:", len(keys))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

DEL — удалить вручную

живой пример

DEL user:42:name
# → 1 (удалён один ключ)
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

EXISTS — проверить наличие

живой пример

EXISTS user:42:name
# → 1 (есть) или 0 (нет)
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

SET NX — записать, только если ключа нет

SET с флагом NX (от «not exists») — атомарная операция: ключ установится только тогда, когда его ещё не существует. Это основа для распределённых блокировок.

живой пример

SET lock:order:99 "worker-1" NX EX 30
# → OK    (успешно — никто не держит блокировку)
# → (nil) (ключ уже есть — кто-то другой захватил)
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

TYPE — узнать тип значения

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

живой пример

TYPE user:42:name
# → string
TYPE order:ord-03
# → hash
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

HSET и HGETALL — объект по полям

Когда значение — объект с полями, его хранят хэшем: поле и значение внутри одного ключа. Записать по полям и прочитать целиком:

живой пример

HSET order:ord-03 id 3 status NEW total 1500
HGETALL order:ord-03
# → id: "3", status: "NEW", total: "1500"
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Хэш позволяет менять одно поле, не переписывая весь объект. Обзор всех структур - строка, список, множество, сортированное множество, поток - в статье Структуры данных Redis.

Подключение из приложения

В Spring Boot подключение к Redis стоит на трёх вещах: зависимость, настройка в application.yml, и бин RedisTemplate или автоматическое кэширование через @Cacheable.

Зависимость

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

Spring Boot автоматически подтянет Lettuce — асинхронного клиента Redis.

Настройка подключения

# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379

Запись и чтение через RedisTemplate

RedisTemplate — низкоуровневый способ: полный контроль над ключами, значениями и TTL.

@Service
public class CacheService {

    private final RedisTemplate<String, String> redisTemplate;

    public CacheService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void save(String key, String value, long ttlSeconds) {
        redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));
    }

    public String load(String key) {
        return redisTemplate.opsForValue().get(key);
    }
}

Автоматическое кэширование через @Cacheable

Для кэширования результатов методов Spring предлагает аннотации — подключать Redis вручную не нужно.

@Service
public class ProductService {

    // Результат метода сохранится в Redis с ключом "products::<id>"
    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {
        // вызывается только при отсутствии в кэше
        return productRepository.findById(id).orElseThrow();
    }

    // При обновлении — сбросить кэш для этого ключа
    @CacheEvict(value = "products", key = "#product.id")
    public void update(Product product) {
        productRepository.save(product);
    }
}

Чтобы @Cacheable знал о Redis, добавьте в конфигурацию:

@Configuration
@EnableCaching
public class CacheConfig {
}

Клиент — go-redis: модуль, конструктор с адресом, команды методами, а кэширование результата — одной обобщённой функцией-обёрткой.

Зависимость

go get github.com/redis/go-redis/v9

Настройка подключения

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

Запись и чтение

Низкоуровневый способ — команды напрямую: полный контроль над ключами, значениями и TTL.

type CacheService struct {
	rdb *redis.Client
}

func (s *CacheService) Save(ctx context.Context, key, value string, ttlSeconds int64) error {
	return s.rdb.Set(ctx, key, value, time.Duration(ttlSeconds)*time.Second).Err()
}

func (s *CacheService) Load(ctx context.Context, key string) (string, error) {
	value, err := s.rdb.Get(ctx, key).Result()
	if errors.Is(err, redis.Nil) {
		return "", nil
	}
	return value, err
}

Кэширование результата метода

Аннотаций в Go нет, обёртку «проверь кэш, иначе посчитай и положи» пишут одной обобщённой функцией и зовут её из сервиса:

func cached[T any](ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, load func() (T, error)) (T, error) {
	var value T
	if raw, err := rdb.Get(ctx, key).Bytes(); err == nil {
		return value, json.Unmarshal(raw, &value)
	}
	value, err := load()
	if err != nil {
		return value, err
	}
	if raw, err := json.Marshal(value); err == nil {
		rdb.Set(ctx, key, raw, ttl)
	}
	return value, nil
}

func (s *ProductService) FindByID(ctx context.Context, id int64) (Product, error) {
	// результат сохранится в Redis с ключом "products::<id>"
	return cached(ctx, s.rdb, fmt.Sprintf("products::%d", id), 10*time.Minute, func() (Product, error) {
		return s.repo.FindByID(ctx, id)
	})
}

func (s *ProductService) Update(ctx context.Context, p Product) error {
	if err := s.repo.Save(ctx, p); err != nil {
		return err
	}
	// при обновлении — сбросить кэш для этого ключа
	return s.rdb.Del(ctx, fmt.Sprintf("products::%d", p.ID)).Err()
}

Клиент — node-redis (пакет redis); в NestJS кэширование результата метода даёт @nestjs/cache-manager с Redis-хранилищем.

Зависимость

npm install redis @nestjs/cache-manager cache-manager @keyv/redis

Настройка подключения

const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();

Запись и чтение

Низкоуровневый способ — команды напрямую: полный контроль над ключами, значениями и TTL.

@Injectable()
export class CacheService {
  constructor(@Inject(REDIS) private readonly redis: RedisClientType) {}

  save(key: string, value: string, ttlSeconds: number): Promise<string | null> {
    return this.redis.set(key, value, { EX: ttlSeconds });
  }

  load(key: string): Promise<string | null> {
    return this.redis.get(key);
  }
}

Кэширование результата метода

cache-manager прячет промах и запись в кэш за одним вызовом wrap:

@Injectable()
export class ProductService {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly products: ProductRepository,
  ) {}

  // результат сохранится в Redis с ключом "products::<id>"
  findById(id: number): Promise<Product> {
    return this.cache.wrap(`products::${id}`, () => this.products.findById(id), 600_000);
  }

  // при обновлении — сбросить кэш для этого ключа
  async update(product: Product): Promise<void> {
    await this.products.save(product);
    await this.cache.del(`products::${product.id}`);
  }
}

Чтобы кэш жил в Redis, а не в памяти процесса, модуль подключают с Redis-хранилищем:

CacheModule.registerAsync({
  isGlobal: true,
  useFactory: async () => ({ stores: [new KeyvRedis('redis://localhost:6379')] }),
})

Клиент — redis-py (пакет redis, есть синхронный и asyncio-вариант); кэширование результата функции даёт декоратор @cached из aiocache.

Зависимость

pip install redis aiocache

Настройка подключения

from redis.asyncio import Redis

redis = Redis(host="localhost", port=6379, decode_responses=True)

Запись и чтение

Низкоуровневый способ — команды напрямую: полный контроль над ключами, значениями и TTL.

class CacheService:
    def __init__(self, redis: Redis) -> None:
        self._redis = redis

    async def save(self, key: str, value: str, ttl_seconds: int) -> None:
        await self._redis.set(key, value, ex=ttl_seconds)

    async def load(self, key: str) -> str | None:
        return await self._redis.get(key)

Кэширование результата метода

Декоратор @cached прячет промах и запись в кэш:

class ProductService:
    def __init__(self, repo: ProductRepository) -> None:
        self._repo = repo

    # результат сохранится в Redis с ключом "products::<id>"
    @cached(ttl=600, alias="default", key_builder=lambda f, self, id: f"products::{id}")
    async def find_by_id(self, id: int) -> Product:
        return await self._repo.find_by_id(id)

    # при обновлении — сбросить кэш для этого ключа
    async def update(self, product: Product) -> None:
        await self._repo.save(product)
        await caches.get("default").delete(f"products::{product.id}")

Чтобы кэш жил в Redis, а не в памяти процесса, его адрес задают в конфигурации:

caches.set_config({"default": {"cache": "aiocache.RedisCache", "endpoint": "localhost", "port": 6379}})
Дополнительно: при первом чтении можно пропустить

Глубже: круговые поездки: pipeline, MULTI/EXEC и Luaрасширенное

«Redis быстрый» и «сервис тормозит на Redis» уживаются в одном проекте, и почти всегда причина не в Redis, а в числе поездок к нему. Команда исполняется за микросекунды, а поездка по сети до Redis и обратно занимает от половины миллисекунды в одной стойке до нескольких в облаке. Сервис, который для одной страницы делает сто GET подряд, тратит на них сто поездок, то есть десятки и сотни миллисекунд, и никакая скорость Redis тут не поможет.

Первый инструмент это конвейер (pipeline): клиент отправляет пачку команд, не дожидаясь ответа на каждую, а потом читает все ответы. Сто GET превращаются в одну поездку. Атомарности здесь нет: между командами пачки могут вклиниться команды других клиентов, и это нормально для чтения. В Spring это redisTemplate.executePipelined(...), а клиент Lettuce и без того умеет отправлять команды из разных потоков одним потоком соединения, поэтому явный конвейер нужен там, где одна операция сама делает много команд.

Второй, MULTI/EXEC: команды между ними исполняются подряд, и никто не вклинится. Это транзакция без отката: если одна команда внутри упала из-за неверного типа, остальные всё равно выполнятся. Условное поведение («увеличь, если значение не изменилось») делают через WATCH перед MULTI, и тогда EXEC откажется, если ключ кто-то трогал.

Третий, Lua-скрипт через EVAL или EVALSHA: он исполняется на сервере целиком, атомарно, и в нём есть ветвление, которого нет в MULTI. Именно так пишут блокировки, лимитеры и «прочитай, проверь, запиши». Плата: пока скрипт идёт, сервер не делает ничего другого. Скрипт на пять команд это микросекунды, а цикл по десяти тысячам ключей внутри скрипта остановит весь Redis на заметное время, и все остальные клиенты будут ждать.

EVAL "local v = redis.call('GET', KEYS[1]) if v == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0" 1 lock:order:42 token-abc

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

Коротко

  • Redis — in-memory хранилище «ключ — значение»; память плюс однопоточная модель дают задержку меньше миллисекунды на операцию.
  • Подходит для кэша, сессий, счётчиков, очередей и rate limiting.
  • Не заменяет основную базу данных: без персистентности данные живут, пока запущен процесс, а при исчерпании памяти Redis по умолчанию отказывает в записи — кэшу ставят политику вытеснения.
  • Основные команды: SET / GET / DEL / EXISTS / EXPIRE / TTL, а для блокировки — SET … NX EX.
  • TTL — время жизни ключа; Redis удаляет его автоматически по истечении.
  • Клиенты: Lettuce в Spring Boot, go-redis, node-redis, redis-py; кэширование результата метода — готовой обёрткой (@Cacheable в Spring, cache.wrap в NestJS, @cached в Python, обобщённая функция в Go).
  • Тормозит не Redis, а число поездок к нему (0,1–0,5 мс на команду): чтение пачкой через MGET и pipeline, атомарная запись через Lua, MULTI/EXEC без отката; длинный скрипт останавливает весь сервер.
  • Один поток означает, что одна тяжёлая команда останавливает всех: KEYS * в рабочей системе запрещён, обход делают курсорным SCAN.
  • Memcached — только строки и многопоточность; Redis берут ради структур, сохранности и всего остального.
  • Номеров баз в кластере нет: разделяют данные префиксом ключа или отдельным экземпляром, а не SELECT.

Что почитать дальше

  • Структуры данных Redis — строки, списки, множества, хэши, сортированные множества и когда что выбрать.
  • Паттерны кэширования — cache-aside, write-through, write-behind: как строить кэш правильно и когда сбрасывать.
  • Redis не только для кэша — блокировки, ограничение числа запросов, очереди и Pub/Sub на тех же командах.
  • Redis и Spring Boot — глубокое погружение в RedisTemplate, сериализацию, Pub/Sub и управление сессиями.
  • Эксплуатация Redis: персистентность и политика вытеснения, то есть что станет с данными, если процесс упал или кончилась память.