Cache-aside, write-through, write-back и инвалидация

О лекции

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 — страховка, а не единственная стратегия. Списки и агрегаты инвалидируются хуже карточки одной сущности.