В пятницу менеджер поменял цену на витрине, в понедельник клиенты требуют скидку, которой нет, — а сайт показывает старую цифру: кэш товарной карточки живёт час по TTL, и до истечения срока никому не пришло в голову сказать ему, что данные поменялись. Зеркальная история: настройки клиента выставлены на пять минут TTL, а обновляются раз в сутки — весь день сервис честно перечитывает одно и то же и тратит на это поездки в Redis. Обе — про выбор стратегии инвалидации кэша: не про то, как хранить значения, а про то, когда их выбрасывать и обновлять. В уроке разбираем три стратегии — TTL со случайным сдвигом, инвалидацию по событию и write-through — и правила, по которым в Go-сервисе выбирают между ними.
Инвалидация кэша в Redis: TTL против событий#
Три стратегии различаются моментом, когда кэш узнаёт об изменении. TTL — пассивная: значение живёт до срока годности, устаревает максимум на TTL и не требует никакой связи между записью в базу и кэшем. Инвалидация по событию — активная: запись в базу сопровождается удалением ключа, следующий читатель получает свежие данные немедленно. Write-through — наступательная: запись идёт и в базу, и в кэш одновременно, кэш всегда тёплый, чтение не знает, что такое промах. Стратегии не исключают друг друга: в реальном сервисе событие и TTL живут парой, а write-through прикручивают к самым горячим ключам. Дальше — по каждой в отдельности, с ценой и границами применимости.
Инвалидация кэша в Redis: когда достаточно одного TTL#
TTL — стратегия по умолчанию, и большинство кэшей честно живут только с ней. Правило выбора срока: TTL равен максимально терпимому устареванию данных, а не «какому-нибудь разумному числу». Витрина отзывов со сроком в сутки — норма; цена товара со сроком в сутки — спорно; курсы валют живут секунды, конфиг сервиса — минуты, если рядом нет механизма перезагрузки. Число подбирается от бизнеса, а не от бенчмарка. Второе правило — сдвиг: если тысяча ключей записаны в одну секунду, они умрут в одну секунду, и в этот момент база ловит лавину промахов — cache stampede. Джиттер в несколько процентов от TTL раскатывает истечения по времени и стоит одну строку. На самых горячих ключах к джиттеру добавляют блокировку пересчёта через SETNX из урока про распределённые блокировки: значение считает один воркер, остальные ждут или берут старое.
TTL с джиттером
GOfunc cacheTTL(base time.Duration) time.Duration {
jitter := time.Duration(rand.Int63n(int64(base) / 10))
return base + jitter
}
rdb.Set(ctx, "product:"+id, payload, cacheTTL(time.Hour))База плюс случайные десять процентов: срок каждого ключа свой, истечения не совпадают в одну секунду. Час становится часом плюс-минус три минуты — для витрины незаметно, для базы — разница между всплеском промахов и ровным фоном. Для самых горячих ключей к джиттеру добавляют замок на пересчёт: SETNX на ключ-блокировку не даёт тысяче горутин считать одно значение одновременно.
Инвалидация по событию: удалять, а не обновлять#
Событийная инвалидация ставит удаление ключа рядом с записью в базу: обновили строку — Del по кэш-ключу, следующий читатель найдёт промах, сходит в базу и положит свежие данные. Схема точнее TTL там, где данные меняются редко, а читать их надо свежими: профиль, настройки, права доступа. Слабое место — атомарность пары «база плюс Del»: между коммитом и удалением читатель успеет положить в кэш старые данные, и они зависнут до конца TTL. Поэтому событие и TTL — всегда пара: удаление даёт свежесть обычно, срок годности ограничивает катастрофу в редкой гонке. Второй соблазн — инвалидация по шаблону: KEYS product:* в проде означает остановку однопоточного сервера на время прохода всей базы; SCAN с курсором из урока про кэширование запросов легален, но спроектированный список ключей для удаления почти всегда дешевле прогона по всему пространству.
Запись с инвалидацией
GOfunc updateProduct(ctx context.Context, db *sql.DB, rdb *redis.Client, p Product) error {
if _, err := db.ExecContext(ctx,
"UPDATE products SET title = ?, price = ? WHERE id = ?",
p.Title, p.Price, p.ID); err != nil {
return err
}
if err := rdb.Del(ctx, "product:"+strconv.Itoa(p.ID)).Err(); err != nil {
log.Printf("invalidate product:%d failed: %v", p.ID, err)
return nil // база уже обновлена, TTL добьёт старый кэш
}
return nil
}Порядок жёсткий: сначала база, потом удаление. Если наоборот, промах между записью в базу и Del вернёт читателю старое значение и перезапишет им кэш — гонка на ровном месте. Сбой Del не отменяет коммит: лог и страховочный TTL честнее ошибки 500, потому что данные в базе уже новые. Ключ строится одной функцией, как и у лимитера: рассыпанные строки product: по разным файлам — источник мёртвой инвалидации.
«Раз база обновилась — обновлю и кэш, читатель сэкономит промах» — схема, которая рано или поздно врёт. Два писателя: первый вычислил значение и завис перед записью, второй обновил базу и записал свежий кэш, потом проснулся первый и перезаписал свежее значение старым — кэш теперь врёт до конца TTL. Удаление не вычисляет ничего и потому не может записать устаревшее: следующий читатель соберёт значение из актуальной базы.
Write-through: писать в кэш и базу за один ход#
Write-through переворачивает схему: запись идёт параллельно в базу и в кэш, чтение всегда попадает в тёплый ключ и не знает, что такое промах. Это не мода, а рабочий приём для данных, которые читаются на порядок чаще, чем пишутся: конфиги, фиче-флаги, справочники, профиль после входа. Выигрыш против событийной инвалидации — отсутствие лавины пересчёта после удаления: значение пишется один раз тем, кто его уже вычислил, и тысяча читателей не строится в очередь на базу. Цена — записи платят за всех читателей: обновление редкого поля тянет сериализацию и сетевой вызов, даже если этот ключ никто не читал месяц.
Write-through на настройках профиля
GOfunc saveSettings(ctx context.Context, db *sql.DB, rdb *redis.Client, uid int64, s Settings) error {
if _, err := db.ExecContext(ctx,
"UPDATE settings SET theme = ?, locale = ? WHERE user_id = ?",
s.Theme, s.Locale, uid); err != nil {
return err
}
payload, err := json.Marshal(s)
if err != nil {
return err
}
// write-through: свежее значение сразу в кэш, TTL как страховка
return rdb.Set(ctx, fmt.Sprintf("settings:%d", uid), payload, time.Hour).Err()
}Сначала база, потом Set с полным значением и сроком годности: даже если кэш вычистил eviction или Del из другой ветки, следующий промах пересоберёт данные. TTL остаётся и в write-through — это предохранитель от разъехавшихся записей, а не основной механизм. JSON в строке здесь уместнее хэша: структура читается целиком и целиком же перезаписывается, как в уроке про JSON-кэш структур.
Моё спорное мнение про write-through: в типичном веб-сервисе он переоценён. Платят за него записи, а читатели большинства ключей размазаны по времени так, что событийная инвалидация закрывает вопрос дешевле: удалили — один промах — пересчёт. Write-through оправдан на узком наборе ключей с известным профилем: очень горячие, очень дорогие в пересчёте, редко меняющиеся. На практике работает комбинация: событие Del на запись, TTL со сдвигом как страховка, прогрев горячих ключей после деплоя — и никакой магии. Проверка правильности выбора — метрика промахов из урока про ошибки go-redis: если после внедрения событий доля cache_miss не упала, вы инвалидируете не те ключи, и это диагностируется за день по числам, а не обсуждается неделями.
Три стратегии и их цена#
| Стратегия | Свежесть данных | Цена | Когда выбирать |
|---|---|---|---|
| TTL | Устаревает до срока годности | Нулевая на записи | Данные, где устаревание терпимо: витрины, агрегаты, ленты |
| Событие (Del) | Свежо сразу после записи | Лишний промах и пересчёт | Профили, права, настройки: менять надо немедленно |
| Write-through | Свежо всегда, кэш тёплый | Каждая запись платит сериализацией и вызовом | Очень горячие ключи с редкими записями: конфиги, фиче-флаги |
Отдельная жизнь кэша начинается после деплоя: процесс перезапустился, а Redis жив — кэш тёплый, и релиз незаметен для базы. Плохой сценарий — выкладка с чисткой Redis или первый запуск кэша на холодную базу: все ключи пустые, и первые секунды трафик целиком уходит в базу, как при лавине истечений, только хуже. Прогрев лечит это: после старта сервис сам читает список горячих ключей из метрик или ZSET популярности и наполняет кэш до открытия трафика. Для сотен ключей прогрев — это пайплайн и пара секунд, для миллионов — постепенный перевод трафика на новую версию. Дешёвая страховка на переходный период — ограниченный параллелизм загрузки промахов: база отвечает медленнее, зато не ловит полный шторм одновременно.
Инвалидация «всех ключей пользователя» через KEYS settings:42:* — классический способ уронить Redis: KEYS сканирует всё пространство ключей и блокирует однопоточный сервер на время прохода. В проде — только SCAN с курсором или, лучше, конструируемый список ключей: реестр в хэше или set, из которого удаляют по элементам, не перебирая базу.
Проверка: сбой удаления
Запись в базу прошла, Del по кэш-ключу упал с сетевой ошибкой. Что покажет кэш дальше?
Проверка: удалить или обновить
Почему на запись кэш удаляют, а не обновляют новым значением?
Что вернёт первый читатель после удаления?
Прочитайте код и выберите, что он выведет:
rdb.Set(ctx, "cfg:mode", "old", time.Hour)
rdb.Del(ctx, "cfg:mode")
v, err := rdb.Get(ctx, "cfg:mode").Result()
fmt.Println(v, errors.Is(err, redis.Nil))Соберите write-through
Допишите запись: сериализация структуры, запись свежего значения в кэш и страховочный срок.
Порядок шагов при обновлении
Расставьте шаги обновления с инвалидацией по событию: коммит в базу раньше удаления ключа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: инвалидация кэша
Стратегии разложены по полкам: TTL для терпимых данных, событие для немедленной свежести, write-through для горстки горячих ключей — и комбинация вместо выбора одного. Фундамент схемы — cache-aside из урока про кэширование запросов, выбор срока — из урока про TTL и истечение, а лавину промахов на горячих ключах гасит блокировка пересчёта из урока про распределённые блокировки. Проверять стратегию вы научитесь в уроке про miniredis, а применять всё вместе — в итоговом проекте, где TTL ссылок и счётчики работают в паре. Полный план — на странице курса по Redis. Домашнее задание ниже заставит стратегии подраться: один ключ, два писателя и секундомер.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- TTL — стратегия по умолчанию: срок равен терпимому устареванию данных, джиттер в несколько процентов раскатывает истечения.
- Инвалидация по событию — Del после коммита: свежесть немедленно, страховка от гонки — TTL, который всегда живёт парой с событием.
- На запись кэш удаляют, а не обновляют: обновление после гонки писателей кладёт устаревшее значение поверх нового.
- Write-through держит кэш тёплым и оправдан на горячих, дорогих в пересчёте, редко меняющихся ключах; записи платят за всех читателей.
- KEYS в инвалидации — остановка однопоточного Redis: в проде SCAN с курсором или конструируемый список ключей.
- Проверка выбора — метрика промахов: если cache_miss не упал после внедрения событий, инвалидируются не те ключи.
Домашнее задание
Соберите updateProduct с Del-инвалидацией на локальном Go с Redis в Docker и уроните Redis между коммитом и удалением: убедитесь, что кэш отдаёт старое значение ровно до конца TTL. Затем добавьте джиттер к TTL ста ключам и посмотрите в redis-cli, как растянулись истечения. В конце попробуйте гонку двух писателей на обновлении кэша и зафиксируйте результат в логе.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы просто не ставить короткий TTL на всё и не заниматься событиями?
Короткий TTL означает постоянные промахи и нагрузку на базу: при TTL в десять секунд и тысячах запросов в минуту база читается чаще, чем без кэша. События дают свежесть там, где нужна немедленность, а длинный TTL со сдвигом — спокойную жизнь остальным данным.
Как инвалидировать связанные ключи, например карточку и её список отзывов?
Либо конструктируйте список зависимостей: функция построения ключей знает все ключи сущности и возвращает их срез для одного Del-вызова — redis.Del принимает несколько ключей. Либо храните реестр зависимостей в set и чистите по элементам. SCAN по шаблону — последний вариант, когда другого не осталось.
Куда ставить инвалидацию: в сервисный слой или в репозиторий?
Туда, где известен смысл изменения: сервис знает, что «поменялась карточка», и умеет перечислить затронутые ключи; репозиторий знает только таблицу. Практика — метод сервиса, который оборачивает транзакцию и инвалидацию одной функцией, чтобы их нельзя было разъединить.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin