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 (lazy loading) по шагам
Эта схема стоит в 90 % продакшенов: ей не нужно ничего, кроме клиента Redis.
- Чтение.
GET key. Есть значение - отдали, конец. - Промах. Клиент возвращает
redis.Nil. Идём в БД, к источнику правды. - Заполнение. Сериализуем и
SET key value EX ttl. Ошибку записи в кэш глотаем: не закэшировали - просто будет ещё один промах. - Запись. Сначала
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. Цена
прямая: подтверждённая пользователю запись может быть потеряна, если кэш умрёт до слива.
Плюс в БД временно лежит старое значение, а значит любой сторонний потребитель (аналитика,
репликация, другой сервис) читает неправду.
Спросят: «а если между кэшем и БД встанет очередь на 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, а это доли миллисекунды. Но одна гонка
остаётся, и именно её просят разобрать на доске.
Почему удалять ключ, а не перезаписывать его новым значением
Соблазн понятен: раз мы уже знаем новое значение, положим его в кэш сразу - и промаха не будет. Три причины так не делать.
- Гонка двух писателей. Порядок применения в БД и порядок записи в кэш не совпадают, потому что это две независимые операции по сети. В итоге в кэше остаётся значение проигравшего.
- Кэшируем то, что не читают. Когда пишут больше, чем читают, мы забиваем память значениями, которые могут не понадобиться ни разу.
- Значение в кэше часто не равно строке в БД. Там лежит агрегат, join, отрендеренный кусок, и писатель просто не знает, что именно туда положить. Удаление корректно всегда.
«Сначала БД, потом удалить ключ. Не обновить - удалить: 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 (lazy loading). Приложение делает
GET, приnilидёт в БД, кладёт результат в кэш. При записи пишет в БД и удаляет ключ. Кэш ничего не знает про БД, БД ничего не знает про кэш. Плюс: если Redis лёг, сервис отвечает медленнее, но работает. Минус: логика размазана по всем местам, где читают сущность; есть окно гонки при заполнении. - Read-through. Приложение обращается только к кэшу; сам кэш при промахе вызывает
зарегистрированный loader и заполняет себя. Плюс: логика загрузки в одном месте, туда
же легко навесить singleflight, метрики, отрицательное кэширование. Минус: нужен слой,
который это умеет (в Go это своя обёртка или
otterv2 с его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 не даёт атомарности: это две операции по сети, и между ними тоже бывает падение.»
Главное достоинство - деградация, а не отказ
В 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) // единственный источник правды
Три места, где ломается
- Stampede. Истёк популярный ключ - и сотни горутин разом пошли в БД за одним и тем же. Лечится singleflight или блокировкой (глава 7.5).
- Гонка заполнения. Читатель прочитал старое значение из БД, «завис», а в это время писатель закоммитил новое и удалил ключ; читатель просыпается и кладёт в кэш устаревшее. Разбор на оси времени есть выше в этой главе.
- Расползание логики. Через полгода в кодовой базе три места, которые кладут
user:42с разным TTL и разной сериализацией, и одно из них забыли обновить при смене схемы. Лечится тем, что кэш живёт только в репозитории и нигде больше.
Сказать «при ошибке Redis возвращаем ошибку пользователю». Так кэш из ускорителя превращается в новую обязательную зависимость со своей доступностью. Правильно наоборот: если фолбэк в БД под полной нагрузкой её убьёт, то защищаться надо ограничителем конкурентности и circuit breaker к БД, а не отказом в обслуживании.
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 есть у каждого ключа. Без исключений. Это страховка от «мы где-то забыли
инвалидировать» и от утечки памяти, а не механизм свежести. Значение
берут из требования к свежести: лента 30 секунд, профиль 5 минут, справочник валют час. TTL всегда с jitter:
base + rand(0..base/5), чтобы ключи, залитые одной пачкой, не истекли одновременно. - По событию инвалидируют там, где важна реакция. После успешного коммита транзакции репозиторий удаляет затронутые ключи. Именно удаляет, а не перезаписывает, и именно после коммита, а не внутри транзакции: иначе при откате в кэше окажется то, чего в БД нет.
- Когда одна запись бьёт по многим ключам, нужны ключи-теги или версия. Изменили
товар - протухли карточка, три списка, два агрегата. Перечислять их руками невозможно,
поэтому в ключ вшивают версию сущности, а инвалидируют через
INCRверсии. - Глобальная версия схемы в префиксе. Поменялся формат значения - меняем префикс
(
v3:user:42). Старые ключи никто не читает, они уходят по TTL. Заодно исчезает вечная проблема «выкатили новый код, а он читает старый формат». - Локальному слою нужен очень короткий 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
- Гонка двух писателей. B1 пишет v1, B2 пишет v2; в БД порядок один, а в кэш они
попадают по сети в другом порядке, и в кэше остаётся v1 при БД v2. С
DELтакого нет: два удаления в любом порядке дают одно и то же состояние (пусто), а следующий читатель возьмёт актуальное из БД. Весь смысл в том, что удаление идемпотентно и коммутативно. - Писатель часто не знает, что класть. В кэше лежит не строка таблицы, а агрегат, join или готовый DTO. Если собирать его в коде записи, придётся дублировать логику чтения.
- Не греем то, что не читают. Когда записей больше, чем чтений,
SETзабивает память значениями, за которыми никто не придёт.
SET вместо DEL оправдан, когда ключ очень горячий, а значение
дорого пересобирать: удалишь такой ключ и гарантированно получишь stampede на следующей же
миллисекунде. Тогда пишут новое значение, но защищаются от гонки писателей: либо
SET через Lua с проверкой версии (if new_ver > cur_ver then set),
либо кладут версию прямо в значение и отбрасывают запись более старой версии.
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: