voidlearn
 

7.4Стратегии кэширования и инвалидация

Сравни стратегии cache-aside, read/write-through и write-behind по тому, кто читает и пишет источник истины. Разбери гонку между медленным чтением и инвалидацией и выбери способ ограничить устаревание с учётом допустимого окна.

Пять стратегий: кто ходит в источник правды

Все стратегии отличаются ровно двумя вещами: кто ходит в БД при промахе и когда запись доезжает до БД. Всё остальное из этого следует.

СтратегияЧтение при промахеЗаписьКто знает про БД Главный плюсГлавный минус
Cache-aside
lazy loading
приложение само: GET → nil → SELECT → SET приложение пишет в БД, потом удаляет ключ приложение простота, кэш падает - сервис живёт дублирование логики в каждом месте, окно гонки при заполнении
Read-through кэш сам вызывает loader и заполняет себя отдельно (обычно write-through) слой кэша / библиотека логика загрузки в одном месте, легко навесить singleflight нужен кэш-слой с поддержкой loader, кэш становится обязательным
Write-through обычно вместе с read-through синхронно: кэш → БД, ответ после обоих слой кэша кэш всегда согласован с БД, нет момента «в кэше старое» каждая запись платит латентностью двух хранилищ; кэшируется то, что могут и не прочитать
Write-behind
write-back
как read-through в кэш сразу, в БД - асинхронно пачкой слой кэша + фоновый воркер огромная пропускная способность на запись, склейка повторных апдейтов потеря данных при падении кэша, БД временно отстаёт, сложный ретрай
Refresh-ahead промаха почти нет: значение обновляют до истечения независимо фоновый обновлятор стабильная низкая латентность, нет stampede на истечении греет то, что уже не нужно; лишняя нагрузка на БД при плохом предсказании
Кто разговаривает с базой: приложение (слева) или сам кэш (справа) Cache-aside: про БД знает приложение Read-through + write-through: про БД знает кэш App Cache DB App Cache DB ЧТЕНИЕ, промах ЧТЕНИЕ, промах 1 GET user:42 2 nil - промах 3 SELECT ... WHERE id=42 4 строка 5 SET ... EX 300 1 Get(user:42) 2 loader: SELECT 3 строка 4 значение + запись в себя App вообще не знает, был промах или нет ЗАПИСЬ ЗАПИСЬ 1 UPDATE users SET ... 2 DEL user:42 Порядок обязателен: сначала БД, потом удаление. Между шагами 1 и 2 кэш отдаёт старое значение. 1 Set(user:42, v) 2 UPDATE (синхронно) write-through: ответ App только после шага 2. write-behind: ответ после шага 1, БД догоняет пачкой. Практический вывод: cache-aside переживает падение кэша (все идут в БД), write-through и write-behind - нет, для них кэш на пути записи обязателен.
Кто ходит в базу. В cache-aside приложение управляет обоими хранилищами, поэтому за порядок операций отвечает само. В read/write-through это спрятано за интерфейсом кэша: код проще, но кэш становится частью критического пути.

Cache-aside (lazy loading) по шагам

Эта схема стоит в 90 % продакшенов: ей не нужно ничего, кроме клиента Redis.

  1. Чтение. GET key. Есть значение - отдали, конец.
  2. Промах. Клиент возвращает redis.Nil. Идём в БД, к источнику правды.
  3. Заполнение. Сериализуем и SET key value EX ttl. Ошибку записи в кэш глотаем: не закэшировали - просто будет ещё один промах.
  4. Запись. Сначала UPDATE в БД. Только после успеха делаем DEL key.
func (r *UserRepo) Get(ctx context.Context, id int64) (User, error) {
    key := fmt.Sprintf("user:v3:%d", id)

    // 1. Пробуем кэш.
    b, err := r.rdb.Get(ctx, key).Bytes()
    switch {
    case err == nil:
        var u User
        if json.Unmarshal(b, &u) == nil {
            r.m.Hit()
            return u, nil                 // попадание
        }
        // Битые данные в кэше лечим как промах, а не как ошибку.
    case errors.Is(err, redis.Nil):
        r.m.Miss()                        // честный промах
    default:
        // Redis недоступен или таймаут. Это не ошибка запроса:
        // кэш лишь ускоряет, деградируем до чтения из БД.
        r.m.CacheError()
    }

    // 2. Идём в источник правды.
    u, err := r.db.LoadUser(ctx, id)
    if err != nil {
        return User{}, err                // вот это уже настоящая ошибка
    }

    // 3. Заполняем кэш. Ошибку намеренно игнорируем.
    if raw, e := json.Marshal(u); e == nil {
        _ = r.rdb.Set(ctx, key, raw, jitter(5*time.Minute)).Err()
    }
    return u, nil
}

func (r *UserRepo) Rename(ctx context.Context, id int64, name string) error {
    if err := r.db.UpdateName(ctx, id, name); err != nil {
        return err                        // 1. источник правды первым
    }
    // 2. Инвалидация. Ошибку логируем, но пользователю не возвращаем:
    // запись уже зафиксирована, откатывать нечего. Спасёт TTL.
    if err := r.rdb.Del(ctx, fmt.Sprintf("user:v3:%d", id)).Err(); err != nil {
        r.log.Warn("cache invalidate failed", "key", id, "err", err)
    }
    return nil
}
Правило разделения ошибок

Ошибку кэша на чтении считают деградацией, а ошибку БД отказом. Если у тебя Get возвращает ошибку, когда Redis недоступен, ты превратил кэш из ускорителя в новую точку отказа и потерял всю выгоду схемы. Обратная сторона: если Redis отвалился целиком, вся нагрузка мгновенно ложится на БД. На этот случай нужен локальный кэш или ограничитель конкурентности к БД, иначе кэш, падая, утащит БД за собой.

Что происходит при сбоях

СтратегияУпал кэшУпала БД
Cache-aside работает, но вся нагрузка на БД; риск обвалить БД лавиной промахов чтение горячих ключей ещё какое-то время живёт из кэша, запись падает
Read-through то же, если библиотека умеет обходить кэш; иначе - отказ чтения loader возвращает ошибку, кэш не заполняется, старые значения ещё отдаются
Write-through запись невозможна: кэш на критическом пути запись невозможна, кэш откатывать сложно (нужна компенсация)
Write-behind потеря подтверждённых записей - они были только в памяти запись принимается, очередь на слив растёт; нужен предел и backpressure
Refresh-ahead как cache-aside обновление проваливается, значение доживает до TTL и потом промах

Read-through, write-through, write-behind, refresh-ahead

Read-through

Приложение видит один метод cache.Get(key); за промах отвечает сам слой кэша, который вызывает зарегистрированный loader. Redis из коробки так не умеет, этим занимается библиотека или твоя собственная обёртка. Зато логика «промах → загрузка → запись» живёт в одном месте, и туда же удобно повесить singleflight, метрики, ограничение конкурентности и негативное кэширование.

// Минимальный read-through поверх go-redis.
type Cache[T any] struct {
    rdb    *redis.Client
    ttl    time.Duration
    group  singleflight.Group
}

func (c *Cache[T]) GetOrLoad(ctx context.Context, key string,
    load func(context.Context) (T, error)) (T, error) {

    var zero T
    if b, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
        var v T
        if json.Unmarshal(b, &v) == nil {
            return v, nil
        }
    }
    // Промах: ровно один поход в БД на ключ (см. 7.5).
    v, err, _ := c.group.Do(key, func() (any, error) {
        val, err := load(ctx)
        if err != nil {
            return zero, err
        }
        if raw, e := json.Marshal(val); e == nil {
            _ = c.rdb.Set(ctx, key, raw, jitter(c.ttl)).Err()
        }
        return val, nil
    })
    if err != nil {
        return zero, err
    }
    return v.(T), nil
}

Write-through

Запись идёт в кэш, кэш синхронно пишет в БД, и только потом клиенту отвечают «ок». Кэш и БД никогда не расходятся по содержимому, но за это платим латентностью: время записи = время Redis + время БД, и появляется вопрос атомарности (записали в Redis, БД упала - надо откатывать Redis, а транзакции между ними нет). Схема окупается там, где записанное почти наверняка сразу прочитают: профиль пользователя, корзина, настройки. На write-heavy таблицах, которые читают редко, write-through просто тратит память.

Write-behind (write-back)

Клиенту отвечают сразу после записи в кэш, а в БД изменения уезжают асинхронно - пачками и с дедупликацией. Только эта схема пишет быстрее, чем умеет сама БД: тысяча инкрементов счётчика превращается в один UPDATE ... SET n = n + 1000. Цена прямая: подтверждённая пользователю запись может быть потеряна, если кэш умрёт до слива. Плюс в БД временно лежит старое значение, а значит любой сторонний потребитель (аналитика, репликация, другой сервис) читает неправду.

Где write-behind ловит на собесе

Спросят: «а если между кэшем и БД встанет очередь на 200 тысяч записей и Redis перезапустится?» Правильно ответить, что write-behind допустим только там, где потеря части данных приемлема (счётчики просмотров, лайки, last_seen), а для «настоящих» данных вместо него берут transactional outbox: пишем в БД в одной транзакции с событием, а разгребает уже брокер. Write-behind годится для быстрой записи данных, которые не жалко, и только для них.

Refresh-ahead

Значение обновляют до истечения TTL: либо по расписанию для заранее известного списка горячих ключей, либо при обращении, по правилу «если до истечения осталось меньше 20 %, отдай текущее значение и запусти фоновое обновление». Пользователь никогда не платит за промах, а stampede на истечении просто не возникает. Платим за это прогревом того, что может больше не понадобиться, а если горячее множество угадали плохо, БД получает лишнюю постоянную нагрузку.

// Refresh-ahead поверх обычного значения: храним рядом отметку "пора обновлять".
type entry struct {
    Val       json.RawMessage `json:"v"`
    RefreshAt int64           `json:"r"` // unix-время раннего обновления
}

func (c *Cache[T]) getAhead(ctx context.Context, key string, load loader[T]) (T, error) {
    e, err := c.read(ctx, key)
    if err == nil {
        if time.Now().Unix() > e.RefreshAt {
            // Старое отдаём сразу, обновляем в фоне ровно одним потоком.
            go func() {
                bg, cancel := context.WithTimeout(context.Background(), 3*time.Second)
                defer cancel()
                _, _, _ = c.group.Do("refresh:"+key, func() (any, error) {
                    return nil, c.reload(bg, key, load)
                })
            }()
        }
        var v T
        return v, json.Unmarshal(e.Val, &v)
    }
    return c.GetOrLoad(ctx, key, load)
}

Инвалидация: TTL, событие, версия ключа

Способов инвалидации ровно три, и в живой системе обычно работают все сразу.

1. По TTL

Самый надёжный и самый тупой способ: ключ живёт N секунд и умирает. Не требует ничего от кода записи, переживает пропущенные события, чинит рассинхрон после любого сбоя. Но данные могут устаревать на весь TTL. TTL берут не «по вкусу», а из бизнес-требования: если «карточка товара может отставать на минуту», ставят TTL 60 секунд. Если допустимое отставание сформулировать не могут, кэшировать эту сущность пока рано.

2. По событию

При изменении сущности удаляем ключ явно: DEL user:42. Данные становятся свежими почти сразу. Сложность в полноте: изменение одной строки часто должно инвалидировать несколько производных ключей - карточку, список, агрегат, счётчик, - и забытый среди них ключ неделями живёт со старыми данными. Плюс события теряются: сеть, рестарт, ошибка в ветке кода.

Что запомнить

TTL и событие работают слоями, а не вместо друг друга. Событие даёт свежесть в нормальном режиме, а TTL ограничивает расхождение сверху, когда что-то сломалось. Поставишь ключ без TTL в расчёте только на событие - и после первого же пропущенного события в памяти навсегда останется мусор.

3. Версионирование ключей вместо удаления

Вместо того чтобы гоняться за десятком производных ключей, версию кладут в сам ключ. Есть два уровня.

  • Версия схемы в префиксе: user:v3:42. Поменяли формат структуры или добавили поле - подняли v3 на v4, и весь старый кэш стал недостижим сам собой, без единого DEL и без KEYS. Старые ключи умрут по TTL. При деплое без этого не обойтись: иначе новый код десериализует старый JSON и получит нули в новых полях.
  • Версия сущности: держим user:42:ver = счётчик, а данные - в user:42:v{N}. На запись делаем атомарный INCR user:42:ver за O(1), и все производные ключи этой сущности сразу становятся недостижимыми, даже если их сотня. На чтение нужно два обращения: имя ключа данных зависит от ответа на GET версии, и пайплайном их не склеить. В одно обращение укладывается только Lua. Так удобно закрывать «список постов пользователя», «агрегаты», «рендер страницы».
// Версия сущности: одна атомарная команда сбрасывает сколько угодно
// производных ключей.
func (r *Repo) userView(ctx context.Context, id int64) (View, error) {
    ver, err := r.rdb.Get(ctx, verKey(id)).Int64()
    switch {
    case errors.Is(err, redis.Nil):
        ver = 0 // версии ещё нет, это нормально
    case err != nil:
        return r.loadView(ctx, id) // Redis не ответил: мимо кэша, а не в v0
    }
    key := fmt.Sprintf("uview:%d:v%d", id, ver)
    // ... обычный cache-aside по key ...
}

func (r *Repo) invalidateUser(ctx context.Context, id int64) error {
    // Ни DEL, ни SCAN: старые ключи просто становятся недостижимы и уходят по TTL.
    return r.rdb.Incr(ctx, verKey(id)).Err()
}
Минус версионирования

Старые версии висят в памяти до TTL. При очень частых записях это заметный перерасход, и без maxmemory-policy с вытеснением можно упереться в память. Второе: ключ версии становится горячим (его читают при каждом чтении сущности), а если он ещё и потеряется при failover, версия откатится назад и всплывёт старый кэш. Лечится тем, что версию берут не из счётчика, а из updated_at строки в БД, если он и так читается.

Консистентность кэша и БД: порядок операций и гонки

Большинство валится на двух вопросах: что трогать первым (БД или кэш) и что делать с ключом (удалять или перезаписывать). Ответ короткий: сначала БД, потом удалить ключ. Но и при таком порядке одна дырка остаётся.

Вариант «сначала кэш, потом БД» - сломан

Удаляем ключ, потом делаем UPDATE. Между этими двумя шагами есть окно, в которое влезает читатель: он видит промах, идёт в БД, читает там ещё старое значение и кладёт его в кэш. Наш UPDATE отрабатывает позже, а устаревшее остаётся в кэше навсегда (до TTL). Окно тут широкое: оно длится весь запрос к БД, десятки миллисекунд. А если UPDATE упадёт, валидный кэш мы выбросили зря.

Вариант «сначала БД, потом удалить» - правильный, но не идеальный

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

Единственная гонка, которая переживает правильный порядок: медленный читатель обгоняет писателя t Поток A - чтение user:42 Поток B - запись user:42 состояние t1 GET user:42 --> nil, промах БД v0 · кэш пусто t2 SELECT ... прочитал v0, но ещё не записал БД v0 · кэш пусто t3 UPDATE users SET ... COMMIT (v0 --> v1) БД v1 · кэш пусто t4 DEL user:42 --> 0, удалять нечего БД v1 · кэш пусто t5 SET user:42 = v0 кладёт УСТАРЕВШЕЕ БД v1 · кэш v0 Кэш и БД разошлись, и сами уже не сойдутся: событие инвалидации прошло раньше, чем появился ключ. Расхождение живёт до истечения TTL. Вероятность мала - нужен читатель, зависший между SELECT и SET дольше всей записи. Почему вероятность мала: обычно чтение из БД быстрее, чем цикл «UPDATE + COMMIT + DEL» писателя, так что A успевает записать до t3, и тогда DEL на t4 нормально его сносит. Гонка требует долгой паузы у A ровно в этом окне: GC, вытеснение горутины, сетевой ретрай.
Гонка «инвалидация обогнала заполнение». Читатель прочитал старое значение до записи, а положил его в кэш после инвалидации. Порядок «сначала БД, потом DEL» делает её редкой, но не устраняет. Закрывает её TTL или отложенное второе удаление.

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

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

  1. Гонка двух писателей. Порядок применения в БД и порядок записи в кэш не совпадают, потому что это две независимые операции по сети. В итоге в кэше остаётся значение проигравшего.
  2. Кэшируем то, что не читают. Когда пишут больше, чем читают, мы забиваем память значениями, которые могут не понадобиться ни разу.
  3. Значение в кэше часто не равно строке в БД. Там лежит агрегат, join, отрендеренный кусок, и писатель просто не знает, что именно туда положить. Удаление корректно всегда.
Два писателя: SET в кэш ломается, DEL - нет t Писатель B1 - ставит v1 Писатель B2 - ставит v2 t1 UPDATE ... COMMIT в БД теперь v1 t2 UPDATE ... COMMIT в БД теперь v2 t3 SET user:42 = v2 t4 SET user:42 = v1 пакет B1 пришёл позже Обновляем кэш (SET) БД: v2 · кэш: v1. Порядок в сети не совпал с порядком коммитов. Расхождение до TTL, и никакой ретрай его не заметит. Удаляем ключ (DEL) Оба сделали DEL - итог один и тот же: ключа нет. Следующее чтение поднимет v2 из БД. DEL идемпотентен и коммутативен.
DEL против SET. Для удаления порядок не важен: два удаления в любом порядке дают одно состояние. Для записи значения порядок важен, поэтому двум конкурирующим писателям без внешней синхронизации её делать нельзя.
Формулировка на собес

«Сначала БД, потом удалить ключ. Не обновить - удалить: DEL идемпотентен и не зависит от порядка, а SET зависит, и два конкурирующих писателя оставят в кэше значение того, чей пакет пришёл позже, а не того, кто позже закоммитил. Полностью гонку это не убирает: остаётся окно, когда медленный читатель кладёт значение, прочитанное до записи, уже после инвалидации. Сверху расхождение ограничено TTL».

Delayed double delete и его ограничения

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

func (r *Repo) Update(ctx context.Context, id int64, v Value) error {
    if err := r.db.Update(ctx, id, v); err != nil {
        return err
    }
    key := keyOf(id)
    _ = r.rdb.Del(ctx, key).Err()          // 1. немедленно

    // 2. Второе удаление через задержку снесёт значение, которое медленный
    //    читатель успел положить уже после первого DEL.
    //    Задержку берут больше p99 времени чтения из БД, обычно 300-800 мс.
    r.delayed.After(500*time.Millisecond, func(bg context.Context) {
        _ = r.rdb.Del(bg, key).Err()
    })
    return nil
}

Приём работает - но только вероятностно, гарантии он не даёт. На собесе стоит назвать все четыре ограничения:

  • Задержку невозможно выбрать корректно. Она должна быть больше самого медленного чтения из БД, а хвост распределения не ограничен: GC-пауза, ретрай, залипшая реплика. Ставим больше - дольше держим лишний промах и грузим БД.
  • Второе удаление само может не выполниться. Под упал, Redis моргнул, таймер жил в памяти процесса - и всё. Для надёжности нужна очередь или отдельный планировщик, то есть новая инфраструктура ради заплатки на гонку.
  • Не спасает от репликации БД. Если читатель ходит на реплику, старое значение он может прочитать и через секунду после коммита. Тогда задержку надо брать больше лага репликации, а он не ограничен.
  • Второй DEL бьёт по свежему значению. Между первым и вторым удалением ключ мог быть корректно заполнен уже новыми данными - а мы его выбросим и получим лишний промах на горячем ключе. На пике это ощутимо.
Когда нужна настоящая гарантия

Если расхождение недопустимо в принципе, кэш не латают, а меняют схему: событие инвалидации пишут в одной транзакции с данными (transactional outbox) и разгребают отдельным воркером с ретраями, либо снимают изменения из WAL через CDC (Debezium) и инвалидируют по факту применённого изменения. Так получается «удалим ключ хотя бы раз после коммита», то есть at-least-once, а с идемпотентным DEL этого достаточно. Можно и вовсе не кэшировать эту сущность, а читать её из БД: не всё обязано лежать в кэше.

Cache penetration и кэширование отрицательных ответов

Три похожих слова, которые постоянно путают:

ТерминЧто происходитЛечение
Penetration
проникновение
запрашивают ключ, которого нет и в БД. Кэш не заполняется никогда, каждый запрос доходит до БД. Классическая атака: перебор несуществующих id. кэшировать «не найдено», Bloom-фильтр, валидация формата id
Stampede
dog-pile
истёк один популярный ключ, и сотни запросов одновременно полезли его перестраивать singleflight, блокировка, раннее обновление (глава 7.5)
Avalanche
лавина
истекло или пропало много ключей сразу: одинаковый TTL после массового прогрева, рестарт Redis, деплой с новым префиксом версии jitter в TTL, прогрев, ограничитель конкурентности к БД

Отрицательное кэширование

Ответ БД «такой сущности нет» тоже результат, и его надо запомнить. Кладём маркер-заглушку с коротким TTL: 10–60 секунд вместо обычных пяти минут.

const missMarker = "__miss__" // маркер отрицательного результата

func (r *Repo) Get(ctx context.Context, id int64) (User, error) {
    key := keyOf(id)
    b, err := r.rdb.Get(ctx, key).Bytes()
    switch {
    case err == nil && string(b) == missMarker:
        return User{}, ErrNotFound          // попадание в "отрицательный" кэш
    case err == nil:
        return decode(b)
    }

    u, err := r.db.LoadUser(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        // Короткий TTL: если сущность появится, мы отдадим 404 максимум 30 секунд.
        _ = r.rdb.Set(ctx, key, missMarker, 30*time.Second).Err()
        return User{}, ErrNotFound
    }
    if err != nil {
        return User{}, err                  // ошибку БД не кэшируем никогда
    }
    ...
}
Две ошибки в негативном кэшировании

Во-первых, нельзя кэшировать ошибку БД как «не найдено». Таймаут и ErrNoRows означают разное: закэшировав таймаут, ты превратишь секундную аварию в пятиминутный 404 для всех. Во-вторых, опасен длинный TTL у заглушки: пользователь зарегистрировался, а сервис ещё пять минут говорит «нет такого». Поэтому при создании сущности её negative-ключ удаляют явно, а TTL заглушки всё равно держат коротким.

Когда ключей слишком много и заглушками их не закрыть (например, поиск по произвольным строкам), перед кэшем ставят Bloom-фильтр, который отвечает «точно нет» или «возможно есть». На «точно нет» отказываем сразу, не трогая ни кэш, ни БД. Ложноположительные срабатывания просто пропускают запрос дальше, и это безопасно. Правда, фильтр надо строить и поддерживать, а удалить из классического Bloom ничего нельзя (нужен counting или периодическая перестройка). В Redis 8 Bloom-фильтр встроен (BF.ADD, BF.EXISTS), в старых версиях это модуль RedisBloom либо своя реализация на SETBIT.

Глубже: инвалидация и локальный кэш

Как только над Redis появляется in-process кэш, инвалидация усложняется вдвое: DEL в Redis ничего не знает про копии в памяти двадцати подов. Помогает очень короткий TTL локального слоя (1–3 секунды, «сходится за секунду и хватит»), широковещательный сигнал через pub/sub канал cache:invalidate (доставка at-most-once, поэтому только как ускорение поверх TTL) либо client-side caching Redis 6+ (CLIENT TRACKING, протокол RESP3): сервер сам присылает инвалидации по ключам, которые этот клиент читал. Последний вариант называют редко, и это козырь.

Вопросы

7
Суть: стратегии различаются двумя вещами - кто ходит в БД при промахе и когда запись доезжает до БД. Cache-aside: ходит приложение, запись сразу в БД. Read/write-through: ходит сам слой кэша. Write-behind: запись в БД откладывается. В 95 % Go-сервисов правильный ответ - cache-aside.

Коротко по каждой

  • Cache-aside (lazy loading). Приложение делает GET, при nil идёт в БД, кладёт результат в кэш. При записи пишет в БД и удаляет ключ. Кэш ничего не знает про БД, БД ничего не знает про кэш. Плюс: если Redis лёг, сервис отвечает медленнее, но работает. Минус: логика размазана по всем местам, где читают сущность; есть окно гонки при заполнении.
  • Read-through. Приложение обращается только к кэшу; сам кэш при промахе вызывает зарегистрированный loader и заполняет себя. Плюс: логика загрузки в одном месте, туда же легко навесить singleflight, метрики, отрицательное кэширование. Минус: нужен слой, который это умеет (в Go это своя обёртка или otter v2 с его Get(ctx, key, loader); «голый» Redis так не умеет), и кэш становится обязательным звеном.
  • Write-through. Запись идёт синхронно в кэш и в БД, клиент получает ответ только после обеих записей. Плюс: кэш и БД согласованы, момента «в кэше устаревшее» нет. Минус: каждая запись платит латентностью двух хранилищ, а кэш забивается тем, что могут ни разу не прочитать. И это не атомарно: если БД записалась, а кэш - нет, ты снова в мире гонок.
  • Write-behind (write-back). Запись подтверждают сразу после кэша, а в БД сбрасывают асинхронно пачкой. Плюс: запись проходит очень быстро, а повторные апдейты одной строки склеиваются в один UPDATE. Минус: вместе с кэшем теряются подтверждённые данные, БД временно отстаёт, а ретраи и порядок применения становятся твоей проблемой.
  • Refresh-ahead. Значение обновляют фоном до истечения TTL, поэтому промаха почти нет, латентность ровная, stampede на истечении не случается. Цена: греется то, что уже никому не нужно, а на БД висит постоянный фон нагрузки.

Как выбирать

СитуацияСтратегияПочему
Обычный CRUD-сервис, чтений сильно больше записейCache-aside проще всего, отказ кэша не роняет сервис
Много разных мест читают одну сущностьRead-through убирает копипасту и даёт одну точку для singleflight
Нельзя показывать устаревшее сразу после записиWrite-through после ответа клиенту в кэше уже новое значение
Счётчики, просмотры, лайки, метрикиWrite-behind потеря последних секунд допустима, а нагрузка на БД падает на порядки
Дорогой отчёт, который смотрят постоянноRefresh-ahead пересчитать заранее дешевле, чем ловить промах в пике
Чем добить ответ

«На практике я почти всегда беру cache-aside и держу его строго в одном слое, в репозитории, чтобы стратегия не размазывалась. Write-behind допускаю только там, где потеря последних секунд не критична: счётчики просмотров, ленты активности. И отдельно проговариваю, что write-through не даёт атомарности: это две операции по сети, и между ними тоже бывает падение.»

Суть: потому что она не делает кэш обязательным. Ломается в трёх местах: stampede на промахе популярного ключа, гонка «читатель обогнал писателя», и расползание логики кэширования по кодовой базе.

Главное достоинство - деградация, а не отказ

В cache-aside путь «мимо кэша» совпадает с обычным путём к БД, который и так есть в коде. Значит, при недоступном Redis сервис продолжает отвечать: медленнее и с большей нагрузкой на БД, но отвечает. Для этого промах и ошибку кэша обрабатывают одинаково:

b, err := r.rdb.Get(ctx, key).Bytes()
switch {
case err == nil:
    return decode(b)
case errors.Is(err, redis.Nil):
    // промах, идём в БД
case err != nil:
    // ошибка кэша тоже ведёт в БД, а не наверх
    r.metrics.CacheErrors.Inc()
    slog.Warn("cache down, falling back to db", "err", err)
}
return r.loadFromDB(ctx, id)   // единственный источник правды

Три места, где ломается

  1. Stampede. Истёк популярный ключ - и сотни горутин разом пошли в БД за одним и тем же. Лечится singleflight или блокировкой (глава 7.5).
  2. Гонка заполнения. Читатель прочитал старое значение из БД, «завис», а в это время писатель закоммитил новое и удалил ключ; читатель просыпается и кладёт в кэш устаревшее. Разбор на оси времени есть выше в этой главе.
  3. Расползание логики. Через полгода в кодовой базе три места, которые кладут user:42 с разным TTL и разной сериализацией, и одно из них забыли обновить при смене схемы. Лечится тем, что кэш живёт только в репозитории и нигде больше.
Типичная ошибка на собеседовании

Сказать «при ошибке Redis возвращаем ошибку пользователю». Так кэш из ускорителя превращается в новую обязательную зависимость со своей доступностью. Правильно наоборот: если фолбэк в БД под полной нагрузкой её убьёт, то защищаться надо ограничителем конкурентности и circuit breaker к БД, а не отказом в обслуживании.

Суть: write-through берут, когда сразу после записи нельзя показать старое, и готовы платить латентностью. Write-behind - когда запись слишком частая для БД и потеря последних секунд допустима. Риск write-behind - потеря подтверждённых данных.

Write-through

Профиль: «пользователь поменял настройку и тут же перечитывает её». Мы платим за каждую запись латентностью двух хранилищ, зато после 200 OK в кэше уже новое значение. Проговорить стоит вот что:

  • Порядок всё равно БД, потом кэш: иначе при падении БД в кэше окажется значение, которого нет в источнике правды.
  • Если запись в кэш упала после успешной записи в БД, ничего не откатываем, просто удаляем ключ (лучше промах, чем расхождение) и логируем.
  • Write-through разумно совмещать с TTL: он остаётся страховкой от расхождения, которое мы не заметили.

Write-behind

Профиль: счётчики просмотров, лайки, «последняя активность пользователя», агрегаты. Тысяча инкрементов в секунду по одной строке убивает БД блокировками, а в Redis это одна команда INCR. Раз в 5–30 секунд воркер снимает накопленное и пишет в БД пачкой.

// Инкремент в Redis и запись id в set "грязных" ключей.
pipe := rdb.TxPipeline()
pipe.HIncrBy(ctx, "views", postID, 1)
pipe.SAdd(ctx, "views:dirty", postID)
_, err := pipe.Exec(ctx)

// Флашер раз в 10 секунд: забрать грязные id, прочитать значения, записать в БД.
// SPOP/HGETDEL атомарно "вынимает" накопленное, чтобы не потерять инкременты,
// пришедшие во время флаша.
Что спросят следом
  • «Что будет, если Redis перезапустится?» - потеряешь всё, что не успело доехать до БД. Отсюда правило: write-behind только для данных, которые не жалко потерять частично, и с включённым AOF, если жалко хоть немного.
  • «А если флашер упал между чтением и записью в БД?» - данные уже вынуты из Redis и ещё не в БД. Поэтому вынимать надо так, чтобы при падении потерялась только одна пачка, а лучше писать в БД идемпотентно (UPSERT ... SET v = v + delta с ключом пачки от дублей).
  • «Читатель увидит несогласованное?» - да: в БД одно число, в кэше другое. Флашер вынимает накопленное, и в Redis остаётся только несброшенная дельта. Значит, показывать надо сумму «БД плюс дельта из Redis» или держать в Redis отдельный полный счётчик, а БД - как долговременное хранилище и источник для аналитики.
Суть: ответ должен быть про сочетание: TTL как страховка всегда, событийная инвалидация как ускорение, версия в ключе там, где инвалидировать надо пачками. И честное признание: инвалидация по событию - at-most-once, поэтому TTL снимать нельзя.

Это вопрос-ловушка: интервьюер ждёт не определения, а твоей практики. Отвечать надо конкретно и по слоям.

Как выглядит хороший ответ

  1. TTL есть у каждого ключа. Без исключений. Это страховка от «мы где-то забыли инвалидировать» и от утечки памяти, а не механизм свежести. Значение берут из требования к свежести: лента 30 секунд, профиль 5 минут, справочник валют час. TTL всегда с jitter: base + rand(0..base/5), чтобы ключи, залитые одной пачкой, не истекли одновременно.
  2. По событию инвалидируют там, где важна реакция. После успешного коммита транзакции репозиторий удаляет затронутые ключи. Именно удаляет, а не перезаписывает, и именно после коммита, а не внутри транзакции: иначе при откате в кэше окажется то, чего в БД нет.
  3. Когда одна запись бьёт по многим ключам, нужны ключи-теги или версия. Изменили товар - протухли карточка, три списка, два агрегата. Перечислять их руками невозможно, поэтому в ключ вшивают версию сущности, а инвалидируют через INCR версии.
  4. Глобальная версия схемы в префиксе. Поменялся формат значения - меняем префикс (v3:user:42). Старые ключи никто не читает, они уходят по TTL. Заодно исчезает вечная проблема «выкатили новый код, а он читает старый формат».
  5. Локальному слою нужен очень короткий TTL. Инвалидировать копии в памяти двадцати подов надёжно нельзя, поэтому ставят 1–3 секунды, а pub/sub-сигнал используют как ускорение, но не как гарантию.
Фраза, которая закрывает вопрос

«Я исхожу из того, что любая событийная инвалидация может не сработать: сеть, рестарт пода между коммитом и DEL, недоступный Redis. Поэтому событие только ускоряет схождение, а гарантию даёт TTL. Если расхождение недопустимо в принципе, я не кэширую эту сущность или беру transactional outbox / CDC, где событие инвалидации доставляется at-least-once.»

Суть: сначала БД (коммит), потом DEL ключа. Обратный порядок ломается всегда, когда БД откатилась. А DEL вместо SET - потому что при SET два параллельных писателя могут оставить в кэше значение проигравшего, и оно там залипнет до TTL.

Порядок

ПорядокЧто ломаетсяВердикт
Кэш → БД если БД упала или транзакция откатилась, в кэше лежит значение, которого нет нигде. Расхождение гарантированное, а не вероятностное. никогда
БД → DEL если DEL не выполнился, старое значение живёт до TTL. Плюс редкая гонка «медленный читатель кладёт устаревшее уже после DEL». стандарт
DEL → БД → DEL delayed double delete: закрывает ту редкую гонку вероятностно, ценой лишнего промаха и невозможности корректно выбрать задержку. по необходимости

Тонкость, о которой забывают

DEL идёт после COMMIT, а не внутри транзакции. Если удалить ключ внутри транзакции, то между DEL и коммитом читатель успеет прочитать из БД ещё старое значение и положить его в кэш - выходит, ты своими руками сделал ту самую гонку детерминированной. В Go это значит: инвалидацию вызывают явно после успешного tx.Commit(). defer в той же функции сработает уже после коммита, но только если коммит в ней и происходит: когда транзакцию коммитят выше по стеку, отложенный DEL успеет раньше.

func (r *Repo) Update(ctx context.Context, u User) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()

    if err := updateUserTx(ctx, tx, u); err != nil { return err }
    if err := tx.Commit(); err != nil { return err }   // только после успешного коммита

    // Инвалидация вне транзакции. Ошибку не возвращаем наверх: данные уже записаны,
    // операция успешна. Логируем и полагаемся на TTL.
    if err := r.rdb.Del(ctx, keyOf(u.ID)).Err(); err != nil {
        slog.Warn("cache invalidation failed", "key", keyOf(u.ID), "err", err)
        r.metrics.InvalidateErrors.Inc()
    }
    return nil
}

Почему DEL, а не SET

  1. Гонка двух писателей. B1 пишет v1, B2 пишет v2; в БД порядок один, а в кэш они попадают по сети в другом порядке, и в кэше остаётся v1 при БД v2. С DEL такого нет: два удаления в любом порядке дают одно и то же состояние (пусто), а следующий читатель возьмёт актуальное из БД. Весь смысл в том, что удаление идемпотентно и коммутативно.
  2. Писатель часто не знает, что класть. В кэше лежит не строка таблицы, а агрегат, join или готовый DTO. Если собирать его в коде записи, придётся дублировать логику чтения.
  3. Не греем то, что не читают. Когда записей больше, чем чтений, SET забивает память значениями, за которыми никто не придёт.
Глубже: единственное исключение

SET вместо DEL оправдан, когда ключ очень горячий, а значение дорого пересобирать: удалишь такой ключ и гарантированно получишь stampede на следующей же миллисекунде. Тогда пишут новое значение, но защищаются от гонки писателей: либо SET через Lua с проверкой версии (if new_ver > cur_ver then set), либо кладут версию прямо в значение и отбрасывают запись более старой версии.

Суть: три классические. (1) Читатель обогнал писателя и положил устаревшее уже после инвалидации. (2) Два писателя переставили местами SET в кэш. (3) Инвалидация не доехала вовсе - падение пода между коммитом и DEL. Первые две вероятностные, третья - обычный отказ.

Гонка 1: «инвалидация обогнала заполнение»

Это единственная гонка, которую не убирает правильный порядок «БД → DEL». Читатель A на t2 прочитал из БД v0 и завис (GC, вытеснение горутины, ретрай). Писатель B на t3 коммитит v1, на t4 делает DEL, но удалять нечего - ключа нет. На t5 просыпается A и кладёт в кэш v0. Кэш и БД разошлись до истечения TTL.

Почему это редко: нужно, чтобы пауза читателя ровно в окне между SELECT и SET оказалась длиннее, чем весь цикл писателя «UPDATE + COMMIT + DEL». Обычно наоборот. Чем закрывают: коротким TTL (главный ответ), delayed double delete (вероятностно), заполнением кэша через SET ... NX плюс версия значения. Если гонка недопустима, эту сущность просто не кэшируют.

Гонка 2: «два писателя переставились»

Возникает, только если инвалидация делает SET. B1 и B2 пишут в БД в порядке v1, v2, а в кэш их SET приходят в порядке v2, v1, ведь сеть не обязана сохранять порядок независимых операций. В кэше остаётся v1 при БД v2, и это залипает до TTL. Лечится инвалидацией через DEL.

Гонка 3: «инвалидация не доехала»

Это не гонка, а обычный сбой, но на собеседовании его ждут в том же списке. Между COMMIT и DEL процесс убили, Redis моргнул, сеть отвалилась. Ретрай в том же процессе не поможет - процесса уже нет. Варианты по возрастанию надёжности:

  • TTL ограничивает расхождение своим сроком. Дёшево, работает всегда, покрывает 99 % практических требований.
  • С transactional outbox событие инвалидации пишется в ту же транзакцию, что и данные, а отдельный воркер читает outbox и удаляет ключи с ретраями. Даёт at-least-once, а DEL идемпотентен, так что повторы безопасны.
  • CDC (Debezium и подобные) снимает изменения прямо с WAL, и инвалидация идёт по факту применённого изменения. Так ловятся даже записи в обход сервиса (миграция, ручной UPDATE в консоли), и это отдельный сильный аргумент.
Как звучит сильный ответ

«Правильный порядок - БД, потом DEL - убирает детерминированные расхождения и оставляет одно вероятностное окно: медленный читатель. Я не пытаюсь закрыть его на 100 % внутри кэша, потому что это в принципе не решается двумя независимыми хранилищами без распределённой транзакции. Я ограничиваю ущерб TTL, а там, где ущерб недопустим, либо не кэширую, либо перехожу на outbox/CDC.»

Суть: перечислять зависимые ключи руками нельзя - забудете. Работают три приёма: версия в ключе (инвалидация = INCR, старые ключи умирают по TTL), теги через set имён ключей, и смена префикса для массового сброса. KEYS pattern + DEL - неправильный ответ.

1. Версия в ключе - основной приём

Вместо удаления N ключей делаем один INCR, и все старые ключи просто перестают быть адресуемыми.

// Ключи собираются из версии сущности:
//   v = GET product:7:ver          -> "12"
//   ключи: p:7:v12:card, p:7:v12:list:top, p:7:v12:reviews:page1
//
// Всё, что зависит от товара 7, инвалидирует одна команда:
func (r *Repo) BumpProduct(ctx context.Context, id int64) error {
    return r.rdb.Incr(ctx, fmt.Sprintf("product:%d:ver", id)).Err()
}
  • Плюсы: O(1) независимо от числа зависимых ключей, атомарно, не бьёт по другим сущностям, гонок нет (новую версию читатели увидят сразу).
  • Минусы: старые значения остаются в памяти до истечения TTL - за удобство платим памятью. А каждое чтение теперь делает два обращения (версия + данные), и пайплайном их не склеить: имя ключа данных зависит от версии. Решают Lua-скриптом (в Cluster оба ключа кладут в один слот через hash tag) или локальным кэшем версии на пару секунд.

2. Теги

Для каждого тега держим set с именами ключей: SADD tag:product:7 p:7:card p:7:list. Инвалидируют так: SMEMBERS + UNLINK пачкой + DEL самого тега. Гибче версии (один ключ может висеть на нескольких тегах), но дороже: set надо поддерживать, он растёт, а между SMEMBERS и UNLINK есть окно, в котором добавились новые ключи. Удаляют через UNLINK, а не DEL: попадётся среди ключей большой список или хеш - DEL будет освобождать его память в главном потоке, а UNLINK отдаст это фоновому.

3. Префикс версии схемы - для массового сброса

Если поменялся формат значений или нужно сбросить весь кэш раздела, инкрементим глобальный префикс: v3: → v4:. Мгновенно, без единой команды удаления. Платить придётся холодным кэшем и, скорее всего, stampede, поэтому такой сброс делают вместе с прогревом и singleflight.

Чего делать нельзя

KEYS user:* + DEL блокирует Redis на полный проход по пространству ключей - на миллионе ключей сервер встаёт на сотни миллисекунд. Даже SCAN + DEL выливается в тысячи команд и гонку с параллельными записями. Если тянет написать SCAN для инвалидации, значит, схема ключей спроектирована неправильно: нужна версия или тег.

Что стоит запомнить

  • В cache-aside приложение читает кэш, при промахе загружает источник и заполняет кэш.
  • При update обычно сначала фиксируют БД, затем удаляют ключ; обратный порядок может вернуть старое значение в кэш.
  • Двойное удаление после задержки закрывает часть гонки с медленным читателем, но требует обоснованного интервала и устойчивого планировщика.

Частые ошибки

  • Не возвращай ошибку Redis после уже успешного commit БД так, будто сама запись не состоялась; иначе клиент может повторить операцию.
  • Write-behind требует durable очереди/журнала, иначе сбой до flush теряет данные.
  • Фоновое обновление с request context может отмениться вместе с клиентом; дай ему контролируемый срок жизни и owner.

Самопроверка

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

Чтение кода

1. Разбери гонку чтения и обновления

Порядок: R читает старую версию из БД; W фиксирует новую версию и удаляет ключ; R записывает старую версию в Redis. Какой порядок операций привёл к устаревшему значению?

v, _ := db.Load(ctx, id)       // slow reader
_ = db.Update(ctx, id, newer)  // writer commits
_ = rdb.Del(ctx, key).Err()    // invalidation
_ = rdb.Set(ctx, key, v, ttl).Err() // stale fill
Ответ и разбор

Медленный читатель записал значение после инвалидации: он начал до commit, но закончил после DEL. Первое удаление не помогает, потому что старого ключа тогда ещё не было. Задержанное второе удаление может стереть его, если задержка больше времени чтения; для строгой свежести нужны версии/контроль версии, поскольку задержка оставляет вероятностное окно.

Код-ревью

2. Исправь неограниченный fallback

При ошибке Redis код напрямую вызывает loadFromDB для каждого запроса. Во время отказа Redis поток 8 000 rps обрушился на базу. Какой минимум защиты добавить?

b, err := rdb.Get(ctx, key).Bytes()
if err != nil {
    return loadFromDB(ctx, key) // every request fans out
}
return decode(b)
Ответ и разбор

Добавь ограничение параллельных загрузок/семафор или singleflight по ключу, таймаут источника, circuit breaker либо controlled fail-fast и лимит общего потока к БД. Для массового промаха не запускай одну нагрузку на каждый pod и каждый запрос. Метрики должны различать cache miss и cache error, а stale value можно отдавать только если бизнес допускает это.

Применение

3. Выбери стратегию для аналитики просмотров

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

Ответ и разбор

Можно инкрементировать в Redis и периодически сбрасывать накопленное в БД как write-behind, если потеря до 10 секунд действительно принята. Сбой Redis или флашера до переноса теряет накопленное, а повторы flush могут задвоить запись. Сделай перенос атомарным относительно новых инкрементов и используй идемпотентную/дельта-запись с checkpoint; мониторь возраст грязных ключей.

Применение

4. Спроектируй версионные ключи

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

Ответ и разбор

Храни product::ver, считывай версию и включай её во все производные ключи, например p:7:v12:card и p:7:v12:reviews:page1. После commit меняй версию атомарным INCR; новые чтения используют новое пространство ключей, старое исчезнет по TTL. При ошибке чтения версии нельзя молча принимать 0, иначе устаревшие ключи снова станут видимыми.