Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~18 мин +20 XP

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

Инвалидация кэша в Redis — то, что отличает кэш, который ускоряет, от кэша, который врёт: цена поменялась в базе, а сайт показывает старую до конца срока годности ключа. Разбираем стратегии кэша в Go — TTL со случайным сдвигом, удаление по событию и write-through — и правила выбора между ними с честной ценой каждой.

Содержание урока · 8 разделов

В пятницу менеджер поменял цену на витрине, в понедельник клиенты требуют скидку, которой нет, — а сайт показывает старую цифру: кэш товарной карточки живёт час по 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 с джиттером

GO
func 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 с курсором из урока про кэширование запросов легален, но спроектированный список ключей для удаления почти всегда дешевле прогона по всему пространству.

Запись с инвалидацией

GO
func 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: по разным файлам — источник мёртвой инвалидации.

Write-through: писать в кэш и базу за один ход#

Write-through переворачивает схему: запись идёт параллельно в базу и в кэш, чтение всегда попадает в тёплый ключ и не знает, что такое промах. Это не мода, а рабочий приём для данных, которые читаются на порядок чаще, чем пишутся: конфиги, фиче-флаги, справочники, профиль после входа. Выигрыш против событийной инвалидации — отсутствие лавины пересчёта после удаления: значение пишется один раз тем, кто его уже вычислил, и тысяча читателей не строится в очередь на базу. Цена — записи платят за всех читателей: обновление редкого поля тянет сериализацию и сетевой вызов, даже если этот ключ никто не читал месяц.

Write-through на настройках профиля

GO
func 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 популярности и наполняет кэш до открытия трафика. Для сотен ключей прогрев — это пайплайн и пара секунд, для миллионов — постепенный перевод трафика на новую версию. Дешёвая страховка на переходный период — ограниченный параллелизм загрузки промахов: база отвечает медленнее, зато не ловит полный шторм одновременно.

Проверка: сбой удаления

Запись в базу прошла, 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

Допишите запись: сериализация структуры, запись свежего значения в кэш и страховочный срок.

if _, err := db.ExecContext(ctx, "UPDATE ...", args...); err != nil { return err } payload, err := json.(s) if err != nil { return err } return rdb.(ctx, key, payload, time.)

Порядок шагов при обновлении

Расставьте шаги обновления с инвалидацией по событию: коммит в базу раньше удаления ключа.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: инвалидация кэша

Термин

TTL со сдвигом

нажмите, чтобы перевернуть
Определение

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

1 / 5

Стратегии разложены по полкам: 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, как растянулись истечения. В конце попробуйте гонку двух писателей на обновлении кэша и зафиксируйте результат в логе.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Стратегии инвалидации кэша)

Вопросы и ответы по уроку

Почему бы просто не ставить короткий TTL на всё и не заниматься событиями?

Короткий TTL означает постоянные промахи и нагрузку на базу: при TTL в десять секунд и тысячах запросов в минуту база читается чаще, чем без кэша. События дают свежесть там, где нужна немедленность, а длинный TTL со сдвигом — спокойную жизнь остальным данным.

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

Либо конструктируйте список зависимостей: функция построения ключей знает все ключи сущности и возвращает их срез для одного Del-вызова — redis.Del принимает несколько ключей. Либо храните реестр зависимостей в set и чистите по элементам. SCAN по шаблону — последний вариант, когда другого не осталось.

Куда ставить инвалидацию: в сервисный слой или в репозиторий?

Туда, где известен смысл изменения: сервис знает, что «поменялась карточка», и умеет перечислить затронутые ключи; репозиторий знает только таблицу. Практика — метод сервиса, который оборачивает транзакцию и инвалидацию одной функцией, чтобы их нельзя было разъединить.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin