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

ДРУГИЕ ХРАНИЛИЩА Паттерны кэширования

Обязательно

Паттерны кэширования Redis: cache-aside и TTL

Java · Go · Node · Python

Как читать через кэш, сбрасывать записи при изменении и пережить штурм, лавину, пробивание и отказ Redis.

1. Cache-aside читает базу только при промахе

1 / 5

Сначала Redis, затем база и запись результата в кэш. Это основной паттерн чтения.

  • Write-through пишет в оба сразу и медленнее.
  • Write-behind асинхронен и рискует потерей.
  • Окно рассогласования существует всегда и выбирается осознанно.
запросRedisdatabaseпромах → SETПопадание не трогает базу

2. TTL ограничивает старость, инвалидация сбрасывает

2 / 5

Срок жизни защищает от вечного устаревания, DEL или CacheEvict сбрасывает конкретную запись после изменения.

  • Пачку удаляют SCAN, не KEYS.
  • Версия формата в ключе product:v3:42 убирает ручной сброс.
  • Метрики hits/misses показывают, помогает ли кэш.
UPDATE productCOMMITDELСледующее чтение получит новое

3. Штурм объединяется одним чтением

3 / 5

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

  • sync=true работает только внутри машины.
  • Распределённый замок или singleflight делят работу.
  • Фоновое раннее обновление не даёт ключу умереть.
req Areq Breq Csingleflightодин SELECT для всехБаза не получает штурм

4. Лавина и пробивание лечатся по-разному

4 / 5

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

  • Фильтр Блума отсекает несуществующее до базы.
  • Кэш отсутствия не должен жить вечно.
  • Разброс сроков защищает от массового промаха.
TTL 300 + 12TTL 300 + 47TTL 300 + 5null → short TTLнет пробивания базы

5. Отказ Redis не роняет запросы

5 / 5

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

  • Нагрузочный тест доказывает, что база выдержит отказ кэша.
  • Ошибки не превращаются в 500.
  • Кэш без метрик - вера: считают попадания и выигрыш времени.
Redis timeoutразмыкательdatabaseобычный ответНе 500 из-за кэша

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

в базу идём только тогда, когда в Redis ключа нет приложение Redis user:42ключа нет user:42живёт ещё 300 c база GET user:42 GET user:42 GET user:42 GET user:42 тяжёлый запрос тяжёлый запрос SET user:42 EX 300 SET user:42 EX 300 промах: ключа нет — читаем базу и кладём ответ в кэш попадание: ответ из памяти, база не участвует и снова попадание — пока ключ жив, база отдыхает срок жизни вышел — Redis убрал ключ следующий читатель снова платит за поход в базу походов в базу: 0 1 2

Cache-aside: приложение сначала спрашивает Redis и идёт в базу только на промахе, а результат кладёт обратно с ограниченным сроком жизни. Пока ключ жив, база не участвует вовсе; когда срок вышел, Redis убирает ключ, и следующий читатель снова оплачивает поход в базу. Отсюда две настройки, которые решают всё: что кладём в кэш и на сколько.

Зачем вообще нужен кэш

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

Короткая формула: кэш эффективен там, где одни и те же данные читают часто, а меняют редко.

Cache-aside (ленивое кэширование)

Cache-aside — самый распространённый паттерн. Приложение само управляет кэшем:

  1. Нужны данные → проверяем Redis.
  2. Попадание (cache hit): данные есть — возвращаем их, в базу не идём.
  3. Промах (cache miss): данных нет → идём в базу, кладём результат в Redis, возвращаем клиенту.

Механика видна и без Redis: карта вместо кэша, отметка времени вместо срока жизни ключа.

живой пример

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

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

    static final Map<Long, Entry> cache = new HashMap<>();
    static int dbCalls = 0;

    static String getUser(long id, long now) {
        Entry hit = cache.get(id);
        if (hit != null && hit.expiresAt() > now) {
            System.out.println(now + " мс: попадание — " + hit.value());
            return hit.value();
        }
        dbCalls++;
        String value = "Иванов";
        cache.put(id, new Entry(value, now + 300));
        System.out.println(now + " мс: промах — идём в базу и кладём ключ на 300 мс");
        return value;
    }

    public static void main(String[] args) {
        for (long now : new long[] {0, 100, 250, 400, 500}) {
            getUser(42, now);
        }
        System.out.println("походов в базу: " + dbCalls);
    }
}
Запустить

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

живой пример

package main

import "fmt"

type entry struct {
	value     string
	expiresAt int64
}

var (
	cache   = map[int64]entry{}
	dbCalls int
)

func getUser(id, now int64) string {
	if hit, ok := cache[id]; ok && hit.expiresAt > now {
		fmt.Printf("%d мс: попадание — %s\n", now, hit.value)
		return hit.value
	}
	dbCalls++
	value := "Иванов"
	cache[id] = entry{value, now + 300}
	fmt.Printf("%d мс: промах — идём в базу и кладём ключ на 300 мс\n", now)
	return value
}

func main() {
	for _, now := range []int64{0, 100, 250, 400, 500} {
		getUser(42, now)
	}
	fmt.Println("походов в базу:", dbCalls)
}
Запустить

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

живой пример

const cache = new Map();
let dbCalls = 0;

function getUser(id, now) {
  const hit = cache.get(id);
  if (hit && hit.expiresAt > now) {
    console.log(`${now} мс: попадание — ${hit.value}`);
    return hit.value;
  }
  dbCalls++;
  const value = "Иванов";
  cache.set(id, { value, expiresAt: now + 300 });
  console.log(`${now} мс: промах — идём в базу и кладём ключ на 300 мс`);
  return value;
}

for (const now of [0, 100, 250, 400, 500]) getUser(42, now);
console.log("походов в базу:", dbCalls);
Запустить

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

живой пример

cache = {}
db_calls = 0


def get_user(id, now):
    global db_calls
    hit = cache.get(id)
    if hit and hit[1] > now:
        print(f"{now} мс: попадание — {hit[0]}")
        return hit[0]
    db_calls += 1
    value = "Иванов"
    cache[id] = (value, now + 300)
    print(f"{now} мс: промах — идём в базу и кладём ключ на 300 мс")
    return value


for now in (0, 100, 250, 400, 500):
    get_user(42, now)
print("походов в базу:", db_calls)
Запустить

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

Плюс — простота: Redis не знает о базе, база не знает о Redis. Данные попадают в кэш только по реальному спросу.

Минус — холодный старт: после рестарта Redis или первой загрузки все запросы идут в базу, пока кэш не прогреется.

Cache-aside в коде приложения

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

В Spring это аннотация @Cacheable:

@Cacheable(value = "users", key = "#id")
public UserDto getUser(long id) {
    return userRepository.findById(id)
            .map(userMapper::toDto)
            .orElseThrow();
}

В Go — обобщённая функция cached из статьи про основы:

func (s *UserService) GetUser(ctx context.Context, id int64) (UserDto, error) {
	return cached(ctx, s.rdb, fmt.Sprintf("users::%d", id), 10*time.Minute, func() (UserDto, error) {
		user, err := s.users.FindByID(ctx, id)
		if err != nil {
			return UserDto{}, err
		}
		return toDto(user), nil
	})
}

В NestJS — cache.wrap из cache-manager:

getUser(id: number): Promise<UserDto> {
  return this.cache.wrap(`users::${id}`, async () => toDto(await this.users.findById(id)), 600_000);
}

В Python — декоратор @cached из aiocache:

@cached(ttl=600, alias="default", key_builder=lambda f, self, id: f"users::{id}")
async def get_user(self, id: int) -> UserDto:
    return to_dto(await self._users.find_by_id(id))

При промахе обёртка сама вызывает метод и кладёт результат в Redis. При повторном вызове с тем же id до базы дело не доходит — возвращается кэшированное значение.

Сброс отдельной записи — удаление ключа сразу после записи в базу.

В Spring это @CacheEvict:

@CacheEvict(value = "users", key = "#id")
public void updateUser(long id, UserUpdateRequest req) {
    // обновляем в базе; запись в кэше будет удалена
}
func (s *UserService) UpdateUser(ctx context.Context, id int64, req UserUpdateRequest) error {
	if err := s.users.Update(ctx, id, req); err != nil {
		return err
	}
	// запись в кэше будет удалена
	return s.rdb.Del(ctx, fmt.Sprintf("users::%d", id)).Err()
}
async updateUser(id: number, req: UserUpdateRequest): Promise<void> {
  await this.users.update(id, req);
  // запись в кэше будет удалена
  await this.cache.del(`users::${id}`);
}
async def update_user(self, id: int, req: UserUpdateRequest) -> None:
    await self._users.update(id, req)
    # запись в кэше будет удалена
    await caches.get("default").delete(f"users::{id}")

Write-through

При write-through приложение пишет в кэш, а кэш синхронно пишет в базу; для вызывающего это одна операция. Плюс: кэш всегда согласован с базой, промахов после записи не бывает. Минусы два, и оба существенные. Запись становится медленнее на время обращения к кэшу, а главное — кэш заполняется всем подряд, включая данные, которые никто не прочитает; на большом потоке записи это вытесняет из памяти действительно горячие ключи. Поэтому сквозную запись берут там, где записанное почти наверняка будет прочитано сразу: профиль пользователя, корзина, настройки.

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

Write-behind (write-back)

При write-behind приложение пишет только в кэш, а в базу изменения уходят пачкой через какое-то время. Плюс — запись становится очень быстрой и база получает укрупнённый поток вместо потока мелких операций; это единственный способ выдержать десятки тысяч записей в секунду на счётчиках. Минус ровно один и решающий: потеря данных при падении. Между записью в кэш и записью в базу данные живут только в памяти, и перезапуск Redis без сохранения на диск унесёт их. Поэтому отложенную запись применяют для того, что не жалко потерять частично: счётчики просмотров, метрики, «последний раз видели», — и никогда для денег и заказов.

Реализуют отложенную запись почти всегда не «умным кэшем», а явно: приложение пишет в Redis, отдельное задание раз в N секунд собирает накопленное (HGETALL по накопителю, INCRBY в базу) и очищает. Так видно, где данные, и понятно, что теряется при сбое.

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

TTL и протухание

TTL (Time To Live) — время жизни ключа. По истечении срока Redis сразу перестаёт отдавать ключ, а память освобождает позже: либо когда к ключу снова обратятся, либо фоновой чисткой, которая выборочно проверяет ключи со сроком.

живой пример

# установить ключ с TTL 5 минут
SET user:42 "..." EX 300

# посмотреть, сколько осталось
TTL user:42
# → 247
Запустить

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

Как выбрать TTL

  • Данные меняются редко (конфигурация, справочник) — TTL от 10 минут до нескольких часов.
  • Данные меняются часто (корзина, сессия) — TTL 1-5 минут или явный сброс через @CacheEvict.
  • Данные реального времени (баланс, статус заказа) — осторожно с кэшированием или очень короткий TTL.

Инвалидация

Инвалидация — явное удаление ключа до истечения TTL, когда данные изменились. Это надёжнее, чем ждать протухания.

живой пример

# удалить конкретный ключ
DEL user:42
Запустить

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

Сложнее, когда удалить надо пачку ключей по шаблону. Здесь легко уронить сервер:

живой пример

# так не надо: KEYS проходит всё пространство ключей за один раз
# и на это время Redis не отвечает никому
KEYS user:*

# так надо: SCAN отдаёт ключи порциями, между порциями сервер работает
SCAN 0 MATCH user:* COUNT 100
Запустить

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

В коде: удаление ключа после изменения сущности (@CacheEvict в Spring).

Штурм кэша: толпа за одним ключом

Представьте: в кэше хранится результат тяжёлого запроса (1 секунда к базе). Миллион пользователей читают его каждую минуту. TTL истёк — и одновременно тысяча запросов приходят в приложение, видят промах и бегут в базу. База падает под нагрузкой.

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

Толпу видно на восьми читателях, которые пришли за одним истёкшим ключом.

без замка ключ истёк 8 читателей 8 запросов в базу под замком ключ истёк 8 читателей 1 запрос, 7 ждут

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

живой пример

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public class Stampede {
    static final AtomicInteger dbCalls = new AtomicInteger();

    static String heavyQuery() {
        dbCalls.incrementAndGet();
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "топ товаров";
    }

    static int crowd(boolean underLock) throws InterruptedException {
        Map<String, String> cache = new ConcurrentHashMap<>();
        dbCalls.set(0);
        CountDownLatch start = new CountDownLatch(1);
        Thread[] readers = new Thread[8];
        for (int i = 0; i < readers.length; i++) {
            readers[i] = new Thread(() -> {
                try {
                    start.await();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                if (underLock) {
                    cache.computeIfAbsent("popular", key -> heavyQuery());
                } else if (cache.get("popular") == null) {
                    cache.put("popular", heavyQuery());
                }
            });
            readers[i].start();
        }
        start.countDown();
        for (Thread reader : readers) {
            reader.join();
        }
        return dbCalls.get();
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("восемь читателей приходят за одним истёкшим ключом");
        System.out.println("каждый сам идёт в базу: запросов " + crowd(false));
        System.out.println("пересчёт под замком:  запросов " + crowd(true));
    }
}
Запустить

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

живой пример

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var dbCalls atomic.Int32

func heavyQuery() string {
	dbCalls.Add(1)
	time.Sleep(50 * time.Millisecond)
	return "топ товаров"
}

func crowd(underLock bool) int32 {
	cache := map[string]string{}
	var mu sync.Mutex
	dbCalls.Store(0)
	start := make(chan struct{})
	var readers sync.WaitGroup
	for range 8 {
		readers.Go(func() {
			<-start
			if underLock {
				mu.Lock()
				defer mu.Unlock()
				if _, ok := cache["popular"]; !ok {
					cache["popular"] = heavyQuery()
				}
				return
			}
			mu.Lock()
			_, ok := cache["popular"]
			mu.Unlock()
			if !ok {
				value := heavyQuery()
				mu.Lock()
				cache["popular"] = value
				mu.Unlock()
			}
		})
	}
	close(start)
	readers.Wait()
	return dbCalls.Load()
}

func main() {
	fmt.Println("восемь читателей приходят за одним истёкшим ключом")
	fmt.Println("каждый сам идёт в базу: запросов", crowd(false))
	fmt.Println("пересчёт под замком:  запросов", crowd(true))
}
Запустить

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

живой пример

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let dbCalls = 0;

async function heavyQuery() {
  dbCalls++;
  await sleep(50);
  return "топ товаров";
}

async function crowd(underLock) {
  const cache = new Map();
  const inflight = new Map();
  dbCalls = 0;
  const reader = async () => {
    if (cache.has("popular")) return;
    if (underLock) {
      if (!inflight.has("popular")) inflight.set("popular", heavyQuery().then((v) => cache.set("popular", v)));
      await inflight.get("popular");
    } else {
      cache.set("popular", await heavyQuery());
    }
  };
  await Promise.all(Array.from({ length: 8 }, reader));
  return dbCalls;
}

async function main() {
  console.log("восемь читателей приходят за одним истёкшим ключом");
  console.log("каждый сам идёт в базу: запросов", await crowd(false));
  console.log("пересчёт под замком:  запросов", await crowd(true));
}
main();
Запустить

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

живой пример

import threading
import time

db_calls = 0
counter = threading.Lock()


def heavy_query():
    global db_calls
    with counter:
        db_calls += 1
    time.sleep(0.05)
    return "топ товаров"


def crowd(under_lock):
    global db_calls
    cache = {}
    lock = threading.Lock()
    db_calls = 0
    start = threading.Barrier(8)

    def reader():
        start.wait()
        if under_lock:
            with lock:
                if "popular" not in cache:
                    cache["popular"] = heavy_query()
        elif cache.get("popular") is None:
            cache["popular"] = heavy_query()

    readers = [threading.Thread(target=reader) for _ in range(8)]
    for t in readers:
        t.start()
    for t in readers:
        t.join()
    return db_calls


print("восемь читателей приходят за одним истёкшим ключом")
print("каждый сам идёт в базу: запросов", crowd(False))
print("пересчёт под замком:  запросов", crowd(True))
Запустить

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

Восемь запросов вместо одного; при тысяче читателей будет тысяча. Стартовый барьер здесь не для красоты (CountDownLatch в Java, закрытый канал в Go, Barrier в Python, в Node читателей разом запускает Promise.all): он отпускает всех читателей разом, иначе число зависело бы от того, как их разложил планировщик, и от запуска к запуску прыгало. computeIfAbsent сыграл роль замка, но замка внутреннего: он держит потоки только одного процесса. Процессов в проде много, поэтому замок нужен общий — его и держит Redis.

Защита: блокировка при промахе

При промахе один поток берёт блокировку (SETNX или готовый замок клиента, в Java это Redisson RLock) и пересчитывает значение. Остальные ждут или возвращают чуть устаревшее значение.

// Redisson — простой вариант с блокировкой
RLock lock = redissonClient.getLock("lock:popular:result");
if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
    try {
        // перепроверяем кэш под блокировкой — вдруг уже заполнили
        String cached = redisTemplate.opsForValue().get("popular:result");
        if (cached != null) return cached;
        String result = expensiveQuery();
        redisTemplate.opsForValue().set("popular:result", result, 5, TimeUnit.MINUTES);
        return result;
    } finally {
        lock.unlock();
    }
}
// если не получили блокировку — вернуть устаревшее значение или подождать
return redisTemplate.opsForValue().get("popular:result");
// замок через SET NX с автоснятием по сроку жизни
locked, err := rdb.SetNX(ctx, "lock:popular:result", instanceID, 5*time.Second).Result()
if err == nil && locked {
	defer rdb.Del(ctx, "lock:popular:result")
	// перепроверяем кэш под блокировкой — вдруг уже заполнили
	if cached, err := rdb.Get(ctx, "popular:result").Result(); err == nil {
		return cached, nil
	}
	result := expensiveQuery()
	rdb.Set(ctx, "popular:result", result, 5*time.Minute)
	return result, nil
}
// если не получили блокировку — вернуть устаревшее значение или подождать
return rdb.Get(ctx, "popular:result").Result()
// замок через SET NX с автоснятием по сроку жизни
const locked = await redis.set("lock:popular:result", instanceId, { NX: true, PX: 5000 });
if (locked) {
  try {
    // перепроверяем кэш под блокировкой — вдруг уже заполнили
    const cached = await redis.get("popular:result");
    if (cached !== null) return cached;
    const result = await expensiveQuery();
    await redis.set("popular:result", result, { EX: 300 });
    return result;
  } finally {
    await redis.del("lock:popular:result");
  }
}
// если не получили блокировку — вернуть устаревшее значение или подождать
return redis.get("popular:result");
# замок через SET NX с автоснятием по сроку жизни
if redis.set("lock:popular:result", instance_id, nx=True, px=5000):
    try:
        # перепроверяем кэш под блокировкой — вдруг уже заполнили
        cached = redis.get("popular:result")
        if cached is not None:
            return cached
        result = expensive_query()
        redis.set("popular:result", result, ex=300)
        return result
    finally:
        redis.delete("lock:popular:result")
# если не получили блокировку — вернуть устаревшее значение или подождать
return redis.get("popular:result")

Ветка «блокировку не дали» в этом сценарии главная: поток не встаёт в очередь к базе, а забирает из кэша то, что там лежит. Значение может оказаться чуть устаревшим, и это плата за то, что база получила один запрос вместо тысячи. Если в кэше пусто, ждут короткую паузу и перечитывают, пока держатель замка считает.

Защита: jitter в TTL

Jitter — случайный разброс срока жизни: ключи из одной пачки протухают не разом, а вразнобой.

// базовый TTL 5 минут + случайный сдвиг до 60 секунд
long ttl = 300 + ThreadLocalRandom.current().nextLong(60);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
// базовый TTL 5 минут + случайный сдвиг до 60 секунд
ttl := time.Duration(300+rand.IntN(60)) * time.Second
rdb.Set(ctx, key, value, ttl)
// базовый TTL 5 минут + случайный сдвиг до 60 секунд
const ttl = 300 + Math.floor(Math.random() * 60);
await redis.set(key, value, { EX: ttl });
# базовый TTL 5 минут + случайный сдвиг до 60 секунд
ttl = 300 + random.randrange(60)
redis.set(key, value, ex=ttl)

Когда Redis недоступен

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

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

Короткие таймауты. Обращение к кэшу должно укладываться в единицы миллисекунд; таймаут в секунду означает, что при зависшем Redis каждый запрос ждёт секунду, и сервис ложится по пулам потоков. Ставят таймаут соединения и команды в десятки миллисекунд — кэш либо отвечает быстро, либо не нужен.

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

@Configuration
class CacheConfig implements CachingConfigurer {
    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
                log.warn("кэш {} недоступен, идём в базу: {}", cache.getName(), e.toString());
            }
            @Override
            public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
                log.warn("кэш {} не принял запись: {}", cache.getName(), e.toString());
            }
        };
    }
}
func (c *Cache) Get(ctx context.Context, key string) (string, bool) {
	value, err := c.rdb.Get(ctx, key).Result()
	if err != nil {
		if !errors.Is(err, redis.Nil) {
			slog.Warn("кэш недоступен, идём в базу", "key", key, "err", err)
		}
		return "", false
	}
	return value, true
}

func (c *Cache) Put(ctx context.Context, key, value string, ttl time.Duration) {
	if err := c.rdb.Set(ctx, key, value, ttl).Err(); err != nil {
		slog.Warn("кэш не принял запись", "key", key, "err", err)
	}
}
async function cacheGet(key) {
  try {
    return await redis.get(key);
  } catch (e) {
    logger.warn(`кэш недоступен, идём в базу: ${e}`);
    return null;
  }
}

async function cachePut(key, value, ttlSeconds) {
  try {
    await redis.set(key, value, { EX: ttlSeconds });
  } catch (e) {
    logger.warn(`кэш не принял запись: ${e}`);
  }
}
def cache_get(key):
    try:
        return redis.get(key)
    except RedisError as e:
        log.warning("кэш недоступен, идём в базу: %s", e)
        return None


def cache_put(key, value, ttl_seconds):
    try:
        redis.set(key, value, ex=ttl_seconds)
    except RedisError as e:
        log.warning("кэш не принял запись: %s", e)

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

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

Два дополнения к этому разделу, которые закрывают тему.

Готовый ответ в Spring. @Cacheable(sync = true) делает ровно то, что код выше: при промахе значение вычисляет один поток, остальные ждут его результата. Оговорка важная: синхронизация работает внутри одной виртуальной машины — на трёх экземплярах сервиса вычислений будет три, а не одно. Для полной защиты нужен распределённый замок в самом Redis (SET key NX EX), и его берут, когда вычисление действительно дорогое, — разбор в статье про Redis за пределами кэша.

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

Практический вариант того же приёма попроще — «отдать устаревшее и обновить в фоне»: значение хранится с запасом по сроку жизни (скажем, срок 10 минут, а считается устаревшим через 8); запрос, попавший в интервал между «устарело» и «истекло», отдаёт старое значение и запускает фоновое обновление. Пользователь не ждёт никогда, база получает один запрос вместо толпы.

Пробивание кэша: запросы к несуществующим данным

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

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

Что кэшировать, а что — нет

Кэшировать стоит:

  • Редко меняющиеся справочники (категории, настройки).
  • Результаты дорогих запросов (агрегации, JOIN нескольких таблиц).
  • Профили пользователей, которые читают тысячи раз между обновлениями.

Кэшировать не стоит:

  • Данные, критичные к точности в реальном времени (финансовый баланс, медицинские показатели).
  • Уникальные данные на каждого пользователя при большом числе пользователей — кэш не успеет заполниться, а памяти уйдёт много.
  • Мелкие запросы, которые база и так отдаёт за доли миллисекунды — накладные расходы на Redis превысят выигрыш.
Дополнительно: при первом чтении можно пропустить

Глубже: согласованность кэша и базы данныхрасширенное

Кэш — это копия данных из базы. Копии расходятся. Вопрос не «расходятся ли» — а «насколько долго допустимо расхождение».

Три уровня:

СтратегияСогласованностьСкоростьСложность
Только TTLслабаямаксимальнаяминимальная
TTL + @CacheEvict при записипочти сразу, но с окномхорошаясредняя
Write-throughпочти сразу, но с окномнижесредняя

Для большинства задач достаточно cache-aside + @CacheEvict: данные актуальны почти сразу после изменения, кэш читается быстро.

Слово «почти» здесь важное. Между тем, как из кэша удалили ключ, и тем, как изменение зафиксировалось в базе, есть короткое окно. Если в него влезет чтение, оно возьмёт из базы ещё старое значение и положит его в кэш — на весь срок жизни. Поэтому чистить кэш надёжнее не внутри транзакции, а после её фиксации: Spring умеет это через обёртку TransactionAwareCacheManagerProxy.

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

10:00.000 запись: DEL ключа 10:00.001 чтение: промах 10:00.002 чтение: старое из базы 10:00.003 запись: COMMIT 10:00.004 чтение: SET старого

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

Полная строгая согласованность (без любого окна рассогласования) требует транзакций между Redis и базой — это сложно и редко оправдано.

Глубже: имена ключей и их версиирасширенное

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

Форма. Ключ строят из частей через двоеточие, от общего к частному: shop:product:42:card, shop:user:17:cart. Первая часть — пространство имён сервиса (чтобы в общем Redis не пересечься с соседями), дальше сущность, идентификатор и, если нужно, вид представления. По такому ключу видно, чьи это данные и что в них лежит, а обслуживающий скрипт может обойти нужный префикс курсорным SCAN.

Целиком или по полям. Объект кладут целиком (одна строка с сериализованным значением), когда его всегда читают полностью, — это одна команда и один разбор. По полям (хэш) — когда читают и обновляют части: счётчик просмотров у товара меняется постоянно, а описание нет, и перезаписывать из-за счётчика весь документ незачем.

Версия формата в ключе. Самое ценное правило и самое часто пропущенное. Когда меняется структура значения (добавили поле, сменили сериализацию), старые записи в кэше становятся несовместимыми с новым кодом — и это выстреливает на выкате, когда новая версия читает старый формат и падает на разборе. Лечится версией в ключе: shop:product:v3:42. Новая версия приложения пишет и читает v3, старые ключи v2 никто не трогает, и они уходят сами по сроку жизни. Никакого «сбросить весь кэш» при выкате не нужно — а вот «сбросить весь кэш» командой FLUSHDB на живой системе означает лавину промахов и поход всех запросов в базу разом.

Глубже: как измерить, что кэш работаетрасширенное

Кэш без метрик — это вера. Минимальный набор, который ставят сразу.

Коэффициент попаданий. В Redis это keyspace_hits и keyspace_misses из INFO stats, отношение считают за интервал: hits / (hits + misses). Что считать нормой, зависит от сценария: для кэша горячих карточек 90–95 % и выше — хорошо, 60–70 % — сигнал, что либо срок жизни слишком короткий, либо кэшируется не то, либо ключей слишком много и они вытесняются. Низкий коэффициент при высоком evicted_keys — это прямой диагноз: не хватает памяти.

Промахи на стороне приложения. Счётчики Redis считают все обращения вместе; чтобы понять, какой именно кэш плох, нужны метрики на уровне приложения. В Spring это готовые метрики кэша (cache.gets с тегом результата) — они разложены по именам кэшей, и в них сразу видно, какой из них не работает.

Время ответа с кэшем и без. Смысл кэша — не в проценте попаданий, а в сэкономленном времени. Если запрос к базе стоит 3 мс, а обращение к Redis по сети — 0,5 мс, выигрыш есть, но небольшой; кэшировать имеет смысл то, что стоит десятки и сотни миллисекунд.

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

Коротко

  • Cache-aside — основной паттерн: проверяем Redis, при промахе идём в базу и кладём результат в кэш.
  • Write-through — запись сразу в оба хранилища; актуальность гарантирована, но запись медленнее.
  • Write-behind — запись асинхронная; быстро, но с риском потери при сбое.
  • TTL задаёт время жизни ключа; инвалидация (DEL / @CacheEvict) сбрасывает его явно при изменении данных. Пачку ключей вычищают через SCAN, а не KEYS.
  • Штурм кэша — толпа за одним истёкшим горячим ключом; лечится @Cacheable(sync = true) (только внутри одной машины), распределённым замком или ранним обновлением в фоне с отдачей ещё живого значения. Лавина — много ключей истекли разом; лечится случайной добавкой к сроку жизни.
  • Пробивание — запросы к тому, чего в базе нет; спасает кэш отрицательного ответа или фильтр Блума.
  • Кэш не заменяет транзакции — данные в Redis и базе всегда расходятся хотя бы на мгновение; выбирайте стратегию под допустимое окно рассогласования.
  • Недоступный Redis не должен ронять запросы: короткие таймауты, CacheErrorHandler на проглатывание ошибок, размыкатель — и проверка нагрузочным тестом, что база переживёт работу без кэша.
  • Ключи строят из частей через двоеточие и с версией формата (product:v3:42) — тогда выкат нового формата не требует сброса кэша, который сам по себе кладёт базу лавиной промахов.
  • Кэш без метрик — вера: считают коэффициент попаданий (keyspace_hits/keyspace_misses, метрики кэшей в приложении) и выигрыш во времени; ноль попаданий обычно означает уникальный ключ или самовызов мимо прокси.

Что пощупать

Кэш карточки товара со сбросом при каждом изменении стоит в стартовом сервисе каталога практикума remodov/marketplace-system: три подряд запроса карточки дают одно обращение к базе, а смена цены или резерв сбрасывают запись. Тесты идут на кэше в памяти, Redis нужен только для стенда.

Код: product, application.yml.

Код на Go: cache, service.go.

Код на Node: cache, product.service.ts.

Код на Python: cache.py, service.py.

Сделаем сами

Ветка step-06-search-and-cache — кэша нет, ProductCacheTest красный.

На Go: ветка step-06-search-and-cache.

На Node: ветка step-06-search-and-cache.

На Python: ветка step-06-search-and-cache.

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