Стратегии кеширования

Стратегии кеширования

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

Дальше по уроку будет сквозная аналогия, поэтому договоримся о словах сразу:

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

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

Три стратегии кеширования: cache-aside, write-through, write-behind - кто пишет в кеш и когда

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). Стратегии:

  1. TTL-based - самая простая. Ставим короткий TTL (1-10 минут), мирясь с устарелостью. Подходит, когда устарелость допустима.

  2. Explicit invalidation - при апдейте удаляем ключ. Сложно, если данные используются в нескольких ключах (user:1, user:1:posts, users:list). Нужна систематичность.

  3. 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 (см. следующий урок).

`golang.org/x/sync/singleflight` - дедупликатор. 100 горутин вызовут `Do("key", fn)` - функция выполнится один раз, остальные получат тот же результат.

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, элемента точно нет
Удаление элемента сломало бы гарантию «нет значит нет»: биты общие для разных элементов, сбросив их, ты начнёшь терять существующие. Поэтому при удалении записей фильтр либо перестраивают целиком по расписанию, либо берут вариант с подсчётом (Counting Bloom, Cuckoo filter). Если данные удаляются часто - это плохой инструмент для задачи. Внутри баз данных: PostgreSQL умеет bloom-индексы, Cassandra и HBase проверяют фильтром, стоит ли лезть в файл на диске. Браузеры так проверяют пароли на утечки, а мессенджеры - не спам ли отправитель. Всюду один сценарий: дорогая проверка, которую в большинстве случаев можно не делать.

Когда не нужен

Если ключей немного и они помещаются в обычный 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.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.