Стратегии кеширования
Стратегии кеширования
Кеш - компромисс между скоростью и актуальностью данных. Выбор стратегии зависит от характера данных, допустимой устарелости и нагрузки. Кеш не лечит кривые запросы: прежде чем ставить Redis перед базой, проверь, что там есть нужные индексы. См. также HTTP-кеш для кеширования на уровне клиента.
Дальше по уроку будет сквозная аналогия, поэтому договоримся о словах сразу:
| В жизни | В коде |
|---|---|
| холодильник | кеш: близко, быстро, места мало |
| магазин | база данных: далеко, медленно, есть всё |
| срок годности на упаковке | TTL |
| протухший творог | устаревшие данные, которые кеш всё ещё отдаёт |
Дальше вопросы стратегий звучат уже по-человечески: кто ходит в магазин и когда, что делать, если за молоком одновременно собрались все соседи, и можно ли есть просрочку, пока один сходил за свежим.
Cache-Aside (Lazy Loading)
Самый распространённый паттерн. Приложение само управляет кешем: проверяет, читает БД при miss, записывает в кеш.
func GetUser(ctx context.Context, rdb *redis.Client, db *sql.DB, id string) (*User, error) {
// 1. Проверяем кеш
cached, err := rdb.Get(ctx, "user:"+id).Bytes()
if err == nil {
var user User
if err := json.Unmarshal(cached, &user); err == nil {
return &user, nil
}
}
// 2. Cache miss - БД
user, err := getUserFromDB(db, id)
if err != nil {
return nil, err
}
// 3. Записываем в кеш
data, _ := json.Marshal(user)
rdb.Set(ctx, "user:"+id, data, 10*time.Minute)
return user, nil
}
<?php
declare(strict_types=1);
use Predis\Client;
final readonly class UserRepository
{
public function __construct(
private Client $redis,
private UserDatabase $db,
) {}
public function get(string $id): User
{
// 1. Проверяем кеш
$cached = $this->redis->get('user:' . $id);
if ($cached !== null) {
return User::fromArray(json_decode($cached, associative: true, flags: JSON_THROW_ON_ERROR));
}
// 2. Cache miss - БД
$user = $this->db->findById($id);
// 3. Записываем в кеш
$this->redis->setex(
'user:' . $id,
600, // 10 минут
json_encode($user, JSON_THROW_ON_ERROR),
);
return $user;
}
}
Плюсы: устойчив к сбоям кеша (fallback в БД), кеш заполняется только реально запрашиваемыми данными. Минусы: при первом обращении всегда cache miss; нужно явно инвалидировать при апдейте.
Write-Through
При обновлении пишем сначала в БД, потом в кеш. Кеш всегда актуален, но запись медленнее.
func UpdateUser(ctx context.Context, rdb *redis.Client, db *sql.DB, user *User) error {
if err := updateUserInDB(db, user); err != nil {
return err
}
data, _ := json.Marshal(user)
return rdb.Set(ctx, "user:"+user.ID, data, 10*time.Minute).Err()
}
<?php
declare(strict_types=1);
final readonly class UserRepository
{
public function update(User $user): void
{
// 1. Источник правды - БД
$this->db->save($user);
// 2. Обновляем кеш (write-through)
$this->redis->setex(
'user:' . $user->id,
600,
json_encode($user, JSON_THROW_ON_ERROR),
);
}
}
Альтернатива через Symfony Cache - инвалидация по тегам:
<?php
declare(strict_types=1);
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final readonly class UserRepository
{
public function __construct(
private TagAwareCacheInterface $cache,
private UserDatabase $db,
) {}
public function update(User $user): void
{
$this->db->save($user);
// Удаляем все кеши с тегом - простая стратегия
$this->cache->invalidateTags(['user-' . $user->id]);
}
}
Внимание: если БД успешна, а кеш упал, останется устаревшая копия. Решения:
- Удалять кеш вместо записи (
DEL user:id) - следующий GET перезагрузит из БД - Принимать риск устарелости и полагаться на короткий TTL
Write-Behind (Write-Back)
Запись идёт в кеш сразу, в БД - асинхронно через очередь. Высокая throughput на запись, но риск потерь при крахе.
Применяется редко: метрики, аналитика, аппроксимирующие счётчики. Для критичных данных опасен.
Cache invalidation
«Инвалидация кеша - одна из двух самых сложных задач в CS» (Phil Karlton). Стратегии:
-
TTL-based - самая простая. Ставим короткий TTL (1-10 минут), мирясь с устарелостью. Подходит, когда устарелость допустима.
-
Explicit invalidation - при апдейте удаляем ключ. Сложно, если данные используются в нескольких ключах (
user:1,user:1:posts,users:list). Нужна систематичность. -
Cache tags - группируем ключи под тегами, инвалидируем все по тегу. Реализуется через множества (Set с именами ключей под тегом).
// Инвалидация при обновлении пользователя
rdb.Del(ctx, "user:"+id, "user:"+id+":posts", "users:list")
<?php
declare(strict_types=1);
// Инвалидация по списку ключей
$redis->del(['user:' . $id, 'user:' . $id . ':posts', 'users:list']);
// Или через Symfony Cache с тегами - системнее:
$cache->invalidateTags(['user-' . $id, 'users-list']);
В Symfony Cache теги работают так: CacheItem::tag(['user-42']) при записи, $cache->invalidateTags(['user-42']) при инвалидации. Все элементы с тегом помечаются как устаревшие за одну операцию - удобно для группового сброса.
Thundering Herd / Cache Stampede
Когда популярный ключ истекает и одновременно 1000 запросов идут в БД - типичная катастрофа высоконагруженных сервисов.
По-бытовому: молоко кончилось ровно в тот момент, когда за ним пришли все соседи сразу. И теперь весь подъезд идёт в магазин за одним и тем же пакетом. Магазину плохо.
Решения:
singleflight (дедупликация в одном инстансе)
import "golang.org/x/sync/singleflight"
var group singleflight.Group
func GetUserSafe(ctx context.Context, id string) (*User, error) {
val, err, _ := group.Do("user:"+id, func() (any, error) {
// Только ОДИН запрос реально пойдёт в БД
return fetchAndCacheUser(ctx, id)
})
if err != nil {
return nil, err
}
return val.(*User), nil
}
singleflight.Group.Do дедуплицирует одновременные запросы по ключу: один реально выполнит функцию, остальные ждут результат.
<?php
declare(strict_types=1);
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final readonly class UserRepository
{
public function __construct(
private CacheInterface $cache,
private UserDatabase $db,
) {}
public function get(string $id): User
{
return $this->cache->get(
'user.' . $id,
function (ItemInterface $item) use ($id): User {
$item->expiresAfter(600);
// Symfony Cache из коробки даёт stampede protection через beta-value.
// Внутри одного процесса callback выполняется один раз на ключ.
return $this->db->findById($id);
},
beta: 1.0, // probabilistic early expiration
);
}
}
В PHP-FPM каждый воркер - свой процесс, поэтому in-process singleflight как в Go не работает между запросами. Защита идёт через probabilistic early expiration (beta в CacheInterface::get()) - часть воркеров обновит кеш чуть раньше TTL, остальные получат свежее значение из Redis. Для координации между воркерами нужен distributed lock (см. следующий урок).
Distributed lock через Redis
Singleflight работает только в рамках одного процесса. Если 10 инстансов сервиса, нужна координация через Redis:
// Только один инстанс из всего кластера выполняет heavy-операцию
token, err := acquireLock(ctx, rdb, "lock:user:"+id, 5*time.Second)
if err == nil {
defer releaseLock(ctx, rdb, "lock:user:"+id, token)
return fetchAndCacheUser(ctx, id)
}
// Остальные ждут или возвращают stale data
<?php
declare(strict_types=1);
use Symfony\Component\Lock\LockFactory;
final readonly class UserRepository
{
public function __construct(
private LockFactory $lockFactory, // RedisStore внутри
private UserDatabase $db,
private Predis\Client $redis,
) {}
public function getWithLock(string $id): User
{
$lock = $this->lockFactory->createLock('user-fetch-' . $id, ttl: 5.0);
if ($lock->acquire(blocking: false)) {
try {
$user = $this->db->findById($id);
$this->redis->setex('user:' . $id, 600, json_encode($user, JSON_THROW_ON_ERROR));
return $user;
} finally {
$lock->release();
}
}
// Не смогли взять lock - вернём stale или подождём
$cached = $this->redis->get('user:' . $id);
return $cached !== null ? User::fromJson($cached) : $this->db->findById($id);
}
}
symfony/lock с RedisStore инкапсулирует SET NX EX + token + Lua release. Не пиши блокировки руками - возьми готовое.
Probabilistic early expiration (XFetch)
Перед истечением TTL вероятностно обновляем кеш заранее, рассеивая нагрузку во времени.
delta := time.Until(expirationTime)
if rand.Float64() < math.Exp(-float64(delta)) {
go refreshCacheAsync(id) // обновляем заранее
}
return cachedValue
<?php
declare(strict_types=1);
// Symfony Cache делает это автоматически через beta-value в CacheInterface::get().
// Ручная реализация для понимания механизма:
$ttlRemaining = $expiresAt - time();
$beta = 1.0;
$xfetchProb = -log(mt_rand() / mt_getrandmax()) * $beta;
if ($xfetchProb >= $ttlRemaining) {
// Probabilistic refresh: чем ближе истечение, тем вероятнее
$messageBus->dispatch(new RefreshCacheCommand($id));
}
return $cachedValue;
Асинхронный рефреш в PHP делается через Symfony Messenger (или другую очередь). В Go это go func(); здесь - dispatched command в RabbitMQ/Redis-транспорте, который обработает фоновый воркер messenger:consume.
Stale-While-Revalidate
Возвращаем устаревшие данные, асинхронно обновляя кеш. Пользователь видит мгновенный ответ, актуальность улучшится при следующем запросе.
Если singleflight - это «в магазин идёт один, остальные ждут у холодильника», то stale-while-revalidate - «в магазин идёт один, остальные пока едят просрочку». Разница в том, кто платит временем: при singleflight ждут все, при SWR не ждёт никто, но какое-то время все получают устаревшее.
func Get(ctx context.Context, id string) (*User, error) {
cached := tryGetFromCache(ctx, id)
if cached != nil {
if cached.IsStale() {
go refreshCache(id) // не блокируя пользователя
}
return cached, nil
}
return fetchAndCache(ctx, id)
}
<?php
declare(strict_types=1);
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class UserRepository
{
public function get(string $id): User
{
$cached = $this->cache->getItem('user.' . $id);
if ($cached->isHit()) {
$user = $cached->get();
if ($this->isStale($cached)) {
// Не блокируем юзера - кидаем команду рефреша в очередь
$this->bus->dispatch(new RefreshUserCacheCommand($id));
}
return $user;
}
return $this->fetchAndCache($id);
}
}
В Go go refreshCache(id) - это goroutine: лёгкий поток в том же процессе. В PHP-FPM запрос завершается, процесс возвращается в пул - background-работы внутри запроса не делают (она убьёт latency). Решение - вынести в очередь: MessageBusInterface::dispatch() пишет команду в Redis/RabbitMQ, messenger:consume-воркер забирает её отдельно.
Особенно полезно для контента, где небольшое отставание допустимо (профили, каталоги, статистика).
Negative caching
Кешируем «нет данных» - иначе при запросах несуществующих ID каждый раз ходим в БД.
// Записываем sentinel при miss
if err := errors.Is(err, ErrNotFound); err {
rdb.Set(ctx, "user:"+id, "__nil__", 1*time.Minute)
return nil, ErrNotFound
}
<?php
declare(strict_types=1);
final readonly class UserRepository
{
public function get(string $id): User
{
$cached = $this->redis->get('user:' . $id);
if ($cached === '__nil__') {
throw new UserNotFoundException($id);
}
if ($cached !== null) {
return User::fromJson($cached);
}
try {
$user = $this->db->findById($id);
$this->redis->setex('user:' . $id, 600, json_encode($user, JSON_THROW_ON_ERROR));
return $user;
} catch (UserNotFoundException $e) {
// Кешируем «нет данных» на короткий TTL - защита от cache-penetration
$this->redis->setex('user:' . $id, 60, '__nil__');
throw $e;
}
}
}
Защищает от cache-penetration атак: злоумышленник перебирает несуществующие ID, заваливая БД.
Но у negative caching есть предел: чтобы записать __nil__, надо сначала сходить в БД и убедиться, что записи нет. При переборе миллиона случайных ID это миллион походов. Первый раз за каждый ID мы всё равно платим.
Фильтр Блума: список на дверце холодильника
Представь список продуктов, приклеенный магнитом к дверце. Ты не заглядываешь внутрь - сначала читаешь список.
- Молока в списке нет → молока в холодильнике точно нет. Дверь можно не открывать.
- Молоко в списке есть → возможно, оно там. А возможно, кто-то уже выпил и не вычеркнул. Надо открыть и проверить.
Это и есть фильтр Блума. Асимметрия ответов - главное, что нужно про него понять:
| Ответ фильтра | Что это значит | Можно доверять? |
|---|---|---|
| «нет» | элемента точно нет | да, всегда |
| «есть» | элемент может быть | нет, надо проверить |
Ложноположительные срабатывания возможны, ложноотрицательные - никогда. Поэтому фильтр ставят перед кешем и БД: он не отвечает на запрос, он отсекает заведомо бессмысленные походы.
Зачем это в кешировании
Negative caching платит одним запросом в БД за каждый новый несуществующий ID. Фильтр Блума не платит ничего: «нет в списке» - и запрос умирает, не дойдя ни до кеша, ни до базы.
import "github.com/bits-and-blooms/bloom/v3"
// 1 млн элементов, вероятность ложного срабатывания 1%
filter := bloom.NewWithEstimates(1_000_000, 0.01)
// Наполняем при старте: все существующие ID
for _, id := range repo.AllUserIDs(ctx) {
filter.AddString(id)
}
func Get(ctx context.Context, id string) (*User, error) {
// Фильтр говорит «нет» - значит нет. В БД не идём вообще.
if !filter.TestString(id) {
return nil, ErrNotFound
}
// Фильтр говорит «может быть» - дальше обычный cache-aside
return getWithCache(ctx, id)
}
Миллион элементов при 1% ошибок занимает около 1.2 МБ - независимо от того, насколько длинные сами ID. В этом весь смысл: фильтр хранит не значения, а следы от них.
Для нескольких инстансов свой фильтр в памяти у каждого не годится - расползутся. Тогда берут RedisBloom, модуль на стороне Redis:
BF.RESERVE users:filter 0.01 1000000
BF.ADD users:filter 42
BF.EXISTS users:filter 999999 → 0, элемента точно нет
Когда не нужен
Если ключей немного и они помещаются в обычный map - бери map, он даёт точный ответ и проще. Фильтр Блума окупается там, где множество слишком велико, чтобы держать его целиком, а промахов при этом много. Устройство битового массива и хеш-функций под ним - в уроке про хеш-таблицы.
Типичные ошибки
- Cache invalidation через
DELпослеUPDATEв разных транзакциях - race: между обновлением БД иDELдругой запрос успевает прочитать старое значение и положить его обратно в кеш. Лекарство -DELдо записи в БД + повторныйDELпосле, либо write-through. - Никогда не истекающий «горячий» ключ - TTL=0 + миллион запросов: кеш не обновится, пока ты не задеплоишь invalidation. Всегда ставь верхний потолок TTL (даже 24h), даже если ожидаешь invalidate.
- Cache-Aside без singleflight против thundering herd - TTL истёк → 1000 одновременных запросов идут в БД, каждый делает тот же запрос. Лекарство -
golang.org/x/sync/singleflight+ jitter в TTL (ttl + rand(10%)), чтобы соседи не истекали одновременно. - Negative cache без короткого TTL - записал
__nil__на час, потом юзер реально создал запись с тем же id, кеш ещё час говорит «нет». Negative TTL = 30s-2min, не дольше. - Доверие фильтру Блума в положительную сторону -
filter.Test(id) == trueне значит «элемент есть», это значит «может быть». Отдать ответ на основании положительного срабатывания, не сходив в БД, - гарантированный баг на ложных срабатываниях. Фильтру можно верить только когда он говорит «нет». - Кеширование запросов с пользовательскими данными по общему ключу -
key="products:list"для всех - но фильтрация по правам делается в БД. Кешируй per-user (products:list:user:42) или per-role, либо вытаскивай фильтрацию до кеша. - Write-Behind без durability-стратегии при креше - данные в Redis-буфере, инстанс перезагрузился - данные пропали. Write-Behind применим только для некритичных данных (счётчики просмотров) или поверх Redis AOF + replica.
- Hot key - один ключ обрабатывает 30% RPS - Redis single-threaded, этот ключ становится бутылочным горлышком. Шарди:
counter:{user_id % 16}вместо одногоcounter, агрегируй при чтении.
Мини-практика
Реализуй cache-aside для товаров с singleflight против thundering herd. Добавь Prometheus метрики: cache_hits, cache_misses, cache_errors. Реализуй negative caching для несуществующих ID. Поставь перед кешем фильтр Блума и сравни число походов в БД при переборе случайных ID - до и после. Проверь поведение под нагрузкой через wrk -t10 -c200 -d10s, посмотри cache hit rate.