Cache-aside держит кэш рядом с базой: приложение само читает и пишет. Инвалидация сложнее, чем SET. Write-back быстрее на записи и опаснее для durability.
Кэш и Redis redis собеседование
Кэш не заменяет источник истины, пока вы явно не выбрали write-back. В типичном бэкенде истина в PostgreSQL, Redis ускоряет чтение. Паттерн задаёт, кто при промахе ходит в БД, кто обновляет кэш при записи и что происходит, когда два процесса гоняются. «Мы кладём в Redis» — ещё не архитектура.
Инвалидация — настоящая сложность. Прочитать и положить легко. Решить, когда ключ врёт после UPDATE в другой таблице, после админки, после фоновой джобы — нет. TTL страхует, но не заменяет стратегию: десять минут stale профиля могут быть приемлемы, десять минут stale баланса — нет.
Три паттерна записи
Cache-aside (lazy loading). Чтение: GET из кэша, miss → БД → SET. Запись: обычно сначала БД, потом DELETE ключа, а не UPDATE кэша. Удаление проще точечного обновления всех представлений (объект, список, агрегат) и меньше шансов записать в кэш устаревшее значение в гонке.
Write-through. Пишем в кэш и в БД синхронно. Кэш тёплый после записи, латентность записи выше, нужна обработка частичного отказа (БД записалась, Redis нет — и наоборот).
Write-back (write-behind). Пишем в кэш, базу сбрасываем асинхронно. Запись быстрая. Если Redis умер до flush — данные пропали. Сложный flush, сложный rebuild. Спорная идея для денег и заказов.
Пример. Cache-aside: GET user:42, miss, SELECT из Postgres, SET в Redis. После UPDATE в Postgres ключ удаляют, а не перезаписывают. Следующий читатель сам загрузит свежее.
func getUser(ctx context.Context, id int64) (User, error) {
key := fmt.Sprintf("user:%d", id)
if u, ok := rdb.Get(ctx, key); ok {
return u, nil
}
u, err := db.GetUser(ctx, id)
if err != nil {
return User{}, err
}
_ = rdb.SetEX(ctx, key, u, ttlWithJitter())
return u, nil
}
func updateUser(ctx context.Context, u User) error {
if err := db.Update(ctx, u); err != nil {
return err
}
return rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID))
}
Cache-aside: miss загружает строку из БД. После UPDATE ключ удаляют, а не перезаписывают, чтобы не затереть более новое значение в гонке.
Инвалидация и гонки
Обновили строку в Postgres — ключ user:42 может жить ещё десять минут. Delete-after-write проще, чем точечный SET всех ключей, которые эту строку содержат. Список «все товары категории» точечно инвалидировать почти нельзя: слишком много представлений. Чаще TTL и грубая инвалидация префикса (дорого и опасно в Redis) или версия категории в ключе.
Гонка stale SET: процесс A промахнулся и читает старое из БД; процесс B уже закоммитил новое и удалил ключ; A делает SET старого значения — stale на весь TTL. Лечение: предпочитать DELETE, короче TTL, версии/CAS, «не писать кэш, если ключ успели удалить». Два слоя кэша (Redis + CDN) удваивают задачу: инвалидировать нужно оба.
Пример. A начал GET, ключа нет, пошёл в БД и прочитал имя «Анна». B успел UPDATE на «Анна Петрова» и DELETE ключа. A делает SET «Анна». Кэш снова врёт до конца TTL. Поэтому после записи безопаснее удалить ключ, чем SET.
Типичная ошибка
После UPDATE делать SET нового значения в кэш и считать задачу закрытой. Параллельный читатель, который начал раньше, затрёт ключ старым. Вторая ошибка — считать write-back «просто быстрее write-through» без разговора, что будет, если Redis умрёт до flush в БД.
На собеседовании
Назовите cache-aside, write-through и write-back и где в каждом теряется durability. Рабочая фраза: после записи в БД инвалидирую ключ, а не делаю SET. Отдельно расскажите гонку stale SET и почему TTL — страховка, а не единственная стратегия. Списки и агрегаты инвалидируются хуже карточки одной сущности.