---
title: "Кэширование ответов"
description: "Урок fiber cache: middleware/cache v3 — TTL и Expiration, X-Cache, KeyGenerator, Cache-Control, инвалидация, Storage на Redis и запреты кэша."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/18-kechirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Кэширование ответов

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 18 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/18-kechirovanie/*

Обещание из прошлого урока исполняется: [лимитер из семнадцатого урока](/courses/fiber/lessons/17-limiter/) считает запросы, кэш запоминает ответы — вместе они составляют пару «не работать зря», и сегодня мы закрываем её вторую половину. Тема «fiber cache» — это middleware/cache из комплекта ядра Fiber v3.5.0, в ядре, а не в contrib, со всеми привычными атрибутами штатного слоя: фабрика cache.New, структура cache.Config с ConfigDefault, поле Next и Storage. Вопрос «кэш ответов go» на первый взгляд звучит абстрактно, но за ним стоит вполне осязаемая сделка: первый клиент платит за дорогой поход в базу, а сервер записывает готовый ответ в память — и каждому следующему за окном TTL отдаёт копию, не вызывая ваш хендлер вообще. Ни роут, ни валидация, ни сериализация JSON не выполняются на попадании — middleware вынимает тело из хранилища и переигрывает его целиком.

В этом уроке мы вскроем middleware/cache по живым исходникам ветки main ядра fiber (файлы middleware/cache: cache.go, config.go, keygen.go, manager.go, utils.go, cachecontrol.go, heap.go, vary.go) и разберём весь цикл жизни записи: из чего слеплен ключ KeyGenerator'а, кто на самом деле выставляет TTL — конфиг Expiration или заголовок Cache-Control самого ответа, что попадает в ответ на попадании и почему Age с Cache-Control: public, max-age=… появляются без вашего участия. Отдельный разговор — инвалидация: CacheInvalidator, ручное удаление по ключу и честный факт, что списка всех ключей у кэша нет. И главная гигиена темы — что кэшировать нельзя: ответы под Authorization и с Set-Cookie из двенадцатого урока, кэш и cookies конфликтуют фундаментально, потому что оба претендуют на память о конкретном клиенте.

### fiber cache: вторая половина пары «не работать зря»

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

По форме cache middleware устроен как всё, что вы видели со седьмого урока: cache.New(cache.Config{...}) — тот же вариадик cache.New(config ...Config) — возвращает fiber.Handler, который можно поставить третьим аргументом на маршрут, вторым на группу или в Use на весь корень. По сути — отличие от requireAPIKey из седьмого урока одно, но принципиальное: кэшу нужно состояние между запросами, причём двустороннее. Лимитер хранил счётчики и никогда не мешал ответу; кэш хранит сами ответы и на попадании подменяет собой весь остальной стек — c.Next() на ветке hit не вызывается, как не вызывался и лимитером на отказ, только теперь пропуск работает на пользу клиента, а не против него.

Второй мост из прошлого урока — хранилище. Дефолт у кэша тот же жанр, что и у лимитера: состояние в памяти процесса. Как только инстансов становится несколько, память перестаёт делиться, и одинаково сформулированный запрос на первом инстансе получает miss, хотя второй инстанс только что посчитал этот же ответ. Лечение то же самое: поле Storage, интерфейс fiber.Storage и Redis из монорепозитория gofiber/storage — контракт, который мы разобрали на лимитере, здесь исполняется байт в байт. Поэтому сегодня этот блок будет коротким: механика знакома, добавится только одна новая деталь — тело ответа кладётся во внешнее хранилище отдельно от метаданных.

### Канонический минимум: кэш списка задач на 30 секунд

```go
package main

import (
        "time"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/cache"
)

// Task — форма данных из девятого урока
type Task struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func main() {
        app := fiber.New()
        api := app.Group("/api")

        // кэш списка задач: одна запись живёт тридцать секунд
        tasksCache := cache.New(cache.Config{
                Expiration: 30 * time.Second, // TTL записи вместо дефолтных 5 * time.Minute
        })

        // слой стоит перед хендлером — тем же рисунком, что requireAPIKey из седьмого урока
        api.Get("/tasks", tasksCache, func(c fiber.Ctx) error {
                // представим здесь дорогой поход в базу за списком задач
                return c.JSON(fiber.Map{"items": []Task{
                        {ID: 1, Title: "починить шлюз"},
                }})
        })

        _ = app.Listen(":8080") // http://localhost:8080/api/tasks
}
```

Пакет живёт в ядре: github.com/gofiber/fiber/v3/middleware/cache, отдельный go get не нужен. Регистрация третьим аргументом api.Get — слой срабатывает только на этом маршруте; вешать кэш на всю группу /api через app.Group бездумно не стоит: разные маршруты имеют разную переносимость устаревания, и политику TTL выгодно назначать адресно. Единственное обязательное поле — Expiration: время, которое запись проживёт в кэше. Тридцать секунд для списка задач — разумный компромисс: пользователь видит свежие данные с задержкой не больше полминуты, а база между запросами отдыхает. Всё остальное конфиг добирает из ConfigDefault, и дефолтов там хватает — заголовок статуса, разрешённые методы, потолок памяти, состав ключа. Ими и займёмся.

### Проверьте себя: дефолты cache.New()

**Вопрос:** Коллега вызвал cache.New() без аргументов и уверяет, что кэш «по умолчанию пустой, пока не задан Storage». Что отвечает config.go?

- Он прав: без внешнего Storage кэширование не включается, каждый запрос идёт в хендлер
- ✅ Кэш работает из коробки: Expiration 5 * time.Minute, CacheHeader X-Cache, методы GET и HEAD, потолок MaxBytes 1 MiB, хранилище — память процесса
- Дефолтный Expiration — 30 * time.Second, как в каноне, а методы ограничены GET
- Без KeyGenerator middleware паникует на первом запросе

ConfigDefault из config.go: Expiration: 5 * time.Minute, CacheHeader: X-Cache, Methods: GET и HEAD, MaxBytes: 1 * 1024 * 1024 — один мебибайт суммарных тел, а Storage действительно nil, но nil здесь значит «an in-memory store for this process only» — встроенная память процесса, не отсутствие хранилища. Ровно тот же рисунок, что у лимитера: память по умолчанию, внешний бэкенд — по необходимости. KeyGenerator тоже никуда не девается: при nil пакет ставит собственный генератор ключа из пути, query и заголовков — об этом отдельный блок.

### Анатомия cache.Config: шестнадцать полей по жизненному циклу

Структура cache.Config в config.go — шестнадцать полей, и читать её удобно не по алфавиту, а по ролям. Время жизни: Expiration — «the time that a cached response will live», дефолт 5 * time.Minute, и ExpirationGenerator — функция func(fiber.Ctx, *Config) time.Duration, назначающая TTL конкретному запросу; она перебивает и конфиг, и заголовки ответа — к приоритетам вернёмся в блоке про TTL. Хранилище: Storage — интерфейс fiber.Storage, дефолт — «an in-memory store for this process only», и MaxBytes — потолок суммарного размера тел в кэше, дефолт 1 * 1024 * 1024.

Ключ: KeyGenerator — своя функция func(fiber.Ctx) string; при nil пакет собирает структурированный ключ из пути, канонического query, списка KeyHeaders и KeyCookies — этот конструктор мы разберём построчно. Ответ клиенту: CacheHeader — имя заголовка со статусом кэша, дефолт X-Cache со значениями hit, miss, unreachable; DisableCacheControl — «disables client side caching if set to true», запрет middleware писать Cache-Control на попадании; StoreResponseHeaders — «allows you to store additional headers generated by next middlewares and handlers», сохранять и переигрывать прочие заголовки ответа, по умолчанию выключено.

Управление потоком: Next — стандартный пропуск слоя для всех middleware Fiber; CacheInvalidator — «defines a function to invalidate the cache when returned true» — функция-валидатор, чьё true призывает запись просроченной, главный инструмент инвалидации. Мелочь, которая спасает логи: DisableValueRedaction — по умолчанию ключи кэша маскируются словом [redacted] в сообщениях об ошибках, как у лимитера. Наконец, три тумблера партиционирования: DisableQueryKeys выключает разделение записей по query-параметрам, DisableVaryHeaders — по заголовку Vary из ответа, KeyCookies задаёт белый список кук, влияющих на ключ. Пустые значения здесь читаются буквально, в отличие от лимитера: пустой срез Methods отключает кэширование всех методов, пустой KeyHeaders — партиционирование по заголовкам.

### Конфиг cache.Config целиком

| Поле | Дефолт | За что отвечает |
| --- | --- | --- |
| Expiration | 5 * time.Minute | TTL записи по умолчанию — «the time that a cached response will live» |
| ExpirationGenerator | nil | свой TTL на запрос, перебивает конфиг и заголовки ответа |
| CacheHeader | X-Cache | заголовок статуса: hit, miss, unreachable |
| Methods | GET, HEAD | какие методы кэшируются; пустой срез — никаких |
| MaxBytes | 1 * 1024 * 1024 | потолок суммарных байтов тел; 0 means no limit |
| Storage | nil | nil — память процесса; иначе fiber.Storage, например Redis |
| Next | nil | пропустить middleware без кэша (true от функции) |
| CacheInvalidator | nil | true — запись признаётся просроченной и удаляется |
| KeyGenerator | nil | свой ключ; дефолт — путь, query, KeyHeaders, KeyCookies |
| KeyHeaders | Accept, Accept-Encoding, Accept-Language | заголовки-разделители ключа; пустой срез — выключить |
| KeyCookies | nil | белый список кук для ключа; по умолчанию куки не участвуют |
| DisableQueryKeys | false | true — query-параметры не попадают в ключ |
| DisableVaryHeaders | false | true — не партиционировать по Vary из ответа |
| DisableCacheControl | false | true — не писать Cache-Control клиенту на hit |
| DisableValueRedaction | false | true — не маскировать ключи словом [redacted] в логах |
| StoreResponseHeaders | false | true — хранить и переигрывать прочие заголовки ответа |

### Что middleware реально делает: сохранить тело и переиграть

Внутри хендлера cache.go два маршрута, и оба стоит прочувствовать. Маршрут miss: записи по ключу нет — запрос идёт дальше через c.Next(), ваш хендлер считает ответ, и после этого middleware решает, сохранять ли его. Сохранение — это не только тело: пакет складывает в структуру item статус ответа, Content-Type, Content-Encoding, строки Cache-Control, Expires, ETag, момент истечения exp и собственный TTL. item описан в manager.go и кодируется msgp — тем же форматом, что лимитер использовал для счётчиков, — и живёт в sync.Pool, чтобы попадания не аллоцировали заново. Если задан внешний Storage, тело кладётся в него отдельным ключом с суффиксом _body — сырыми байтами, без msgp-обёртки: комментарий в коде прямо радуется — «We can store raw bytes with Storage».

Маршрут hit: запись найдена и свежа — middleware воспроизводит ответ целиком: c.Response().SetBodyRaw кладёт сохранённое тело, восстанавливает статус и Content-Type, возвращает ETag и Expires, если были, проставляет Date и заголовок Age — сколько секунд записи, — и выставляет ваш CacheHeader в значение hit. На этом пути c.Next() не вызывается вовсе: хендлер, база, валидация не видят запроса. Отсюда и главный практический вывод урока: всё, что хендлер делал на каждый запрос — проверку прав, персональные данные, свежий timestamp в теле, — на попадании не выполнится. Кэш отдаёт замороженный ответ, и ваша задача — быть уверенным, что заморозить его можно.

Если же сохранение не состоялось — ответ запретил сам себя заголовками, статус не кэшируемый, тело слишком большое — middleware помечает ответ значением unreachable в том же CacheHeader. То есть X-Cache: unreachable значит «кэш смотрел на запрос, но работать с ним не стал», и причин у этого статуса полтора десятка — от POST-метода до Set-Cookie в ответе.

### Проверьте себя: POST сквозь кэш

**Вопрос:** На группу /api повесили кэш cache.New(cache.Config{Expiration: 30 * time.Second}). Клиент отправляет POST /api/tasks. Что вернёт заголовок X-Cache?

- hit — POST тоже кэшируется, если тело совпало с ранним
- miss — POST создаёт запись, ответ сохранится на 30 секунд
- ✅ unreachable — метода POST нет в дефолтных Methods, кэш пропускает запрос и честно помечает это
- Заголовка X-Cache не будет вообще — кэш не смотрит на POST

Первым же делом хендлер проверяет slices.Contains(cfg.Methods, requestMethod): дефолт — GET и HEAD. POST в списке не состоит, поэтому middleware ставит c.Set(cfg.CacheHeader, cacheUnreachable) и идёт дальше по стеку: хендлер выполняется как обычно, ответ не сохраняется. Это важно и для инвалидации: мутации — POST, PUT, DELETE — всегда доходят до базы, но записи GET не выкидывают — список задач останется замороженным до конца TTL или до срабатывания CacheInvalidator. Стратегическая привычка: мутации не кэшируются, и это не ограничение Fiber, а здравый смысл HTTP — методы с побочными эффектами не имеют смысла в замороженном виде.

### Соберите путь запроса через cache middleware

Расставьте шаги от разбора заголовков запроса до пометки miss в правильном порядке.

Правильный порядок строк:

1. `parseRequestCacheControl разбирает Cache-Control запроса`
2. `no-store в запросе — запрос уходит стеку через c.Next() без всякого кэша`
3. `метод не из Methods — c.Set(CacheHeader, unreachable) и c.Next()`
4. `KeyGenerator собирает ключ: версия v2, метод, путь, query, KeyHeaders`
5. `manager.get достаёт запись по ключу из памяти или Storage`
6. `запись свежа — hit: SetBodyRaw, статус, Content-Type, Age, X-Cache: hit`
7. `записи нет — c.Next(): хендлер считает ответ с нуля`
8. `ответ с no-store, private, Set-Cookie или вне cacheableStatusCodes — не сохраняем`
9. `e.exp = теперь + remainingExpiration, запись уходит в manager.set`
10. `X-Cache: miss — и тот же ответ уходит первому клиенту`

### TTL: кто на самом деле выставляет срок жизни

Интуиция подсказывает: TTL записи — это Expiration из конфига. Исходник отвечает сложнее, и в этом вся соль кэширования. При сохранении ответа пакет ищет источник срока жизни по лесенке приоритетов. Первым стоит ExpirationGenerator — если вы задали функцию, решает она, и никакие заголовки её не перебьют. Дальше — сам ответ: директива s-max-age из Cache-Control (только когда ответ помечен как разделяемый), затем max-age, затем устаревший, но живой заголовок Expires с абсолютной датой. И только если ни один заголовок ответа не объявил срок — работает ваш Expiration из конфига. То есть конфиг — это не потолок, а нижний приоритет: ответ с Cache-Control: max-age=10 при Expiration: 30 * time.Second проживёт в кэше десять секунд, не тридцать.

При этом в хранилище запись кладётся с другим, увеличенным сроком: storageExpiration выбирается как максимум из «клиентского» TTL и cfg.Expiration. Логика такая: внешнее хранилище — это инфраструктура, а не обещание клиенту; пусть Redis держит запись тридцать секунд, даже если fresh-окно по max-age десять, — метаинформация о сроке всё равно в записи. Итоговая арифметика попадания: e.exp = момент ответа + remainingExpiration, где remainingExpiration — «клиентский» срок минус возраст ответа.

Два краевых случая честной симметрии с лимитером. Ноль в конфиге — значит дефолт: configDefault заменяет Expiration: 0 на пять минут, как limiter чинил Max: 0 до пяти. А вот отрицательное значение проходит нормализацию насквозь, и New() возвращает заглушку — комментарий в исходнике честен: Nothing to cache — middleware, который просто зовёт c.Next(). Итак: Expiration: -1 * time.Second — валидный способ выключить кэш, не вынимая слой из цепочки.

### Что увидит клиент на попадании

```go
tasksCache := cache.New(cache.Config{
        Expiration: 30 * time.Second,
})

api.Get("/tasks", tasksCache, func(c fiber.Ctx) error {
        // хендлер объявляет свой срок жизни — десять секунд
        c.Set("Cache-Control", "max-age=10")
        return c.JSON(fiber.Map{"items": []Task{
                {ID: 1, Title: "починить шлюз"},
        }})
})
```

**Что выведет программа?**

- Cache-Control: public, max-age=25 и Age: 5 — middleware перезаписывает срок на остаток TTL записи
- ✅ Cache-Control: max-age=10 из записи и Age: 5; запись считается свежей десять секунд — max-age победил, а Expiration: 30 стал только нижней границей срока в хранилище
- Cache-Control: max-age=10 и Age: 0; запись живёт полные тридцать секунд, потому что конфиг всегда сильнее заголовков
- Cache-Control на hit не появится вовсе — для него нужен StoreResponseHeaders: true

На попадании middleware переигрывает сохранённый Cache-Control из записи — max-age=10 возвращается как есть. Свою строку public, max-age=… слой пишет только когда у записи своего Cache-Control нет — константа publicMaxAge со значением public, max-age= и остатком секунд. Age: 5 — возраст записи: Age равен времени с момента ответа, а на первом miss он был 0, потому что пакет добавляет Age при сохранении. И главное — приоритеты: max-age=10 перебил Expiration: 30, fresh-окно десять секунд, а в хранилище запись пролежит до тридцати, потому что storageExpiration — максимум из двух сроков. Если хочется, чтобы клиент обновлялся чаще записи — уменьшайте max-age в ответе или задавайте ExpirationGenerator.

### Cache-Control со стороны клиента: no-store, no-cache и only-if-cached

Cache-Control — это разговор в обе стороны, и middleware/cache слушает не только ответы, но и запросы. Разбор запроса делает parseRequestCacheControl, и правила такие. no-store в запросе — клиент просит вообще не иметь дела с кэшем: запрос уходит стеку через c.Next(), даже заголовок X-Cache не ставится. no-cache — мягче: клиент не запрещает кэш, он требует свежести — запись достаётся, но не отдаётся, запрос идёт в хендлер, а обновлённый ответ перезаписывает старую запись. Прародитель заголовка, Pragma: no-cache, тоже распознаётся: легаси остаётся легаси, но обходится дёшево.

Дальше — арифметика свежести, знакомая по RFC 9111. max-age=N в запросе: если возраст записи больше N секунд, она переигрывается заново — запрос идёт в хендлер. min-fresh=N: клиент хочет, чтобы у записи осталось не меньше N секунд свежести, — иначе revalidation. max-stale и max-stale=N, наоборот, разрешают отдачу просроченной записи: в первом случае без ограничений, во втором — если просрочка не больше N; вместе с отдачей уходит Warning 110 - «Response is stale». А only-if-cached — самая радикальная ручка: клиент говорит, что сеть ему недоступна, и если записи нет, middleware отвечает 504 Gateway Timeout, не пытаясь сходить в хендлер.

Зачем это знать автору API? Потому что этими ручками пользуются не браузеры, а ваши собственные клиенты и прокси между вами: монитор, который сверяет два ответа, обойдёт кэш через no-cache; скрипт синхронизации заставит отработать only-if-cached, и вы узнаете об этом по 504 в логах. Поддержка директив — не украшение, а признак того, что кэш встроен в HTTP-экосистему, а не является самодельной Map рядом с хендлером.

### Keying: из чего слеплен ключ

Ключ кэша — это адрес записи, и ошибка в его конструкции стоит дороже медленного запроса: два разных ответа на одном ключе — утечка чужих данных, один ответ на двух ключах — бесполезный дубликат. Конструктор из keygen.go собирает ключ из сегментов, разделённых вертикальной чертой. Первый сегмент — версия формата, константа cacheKeyVersion, сейчас v2: она отделяет записи, оставленные прежними версиями конструктора, от новых. История этой константы — готовый учебник: версия подняли после бага, когда Authorization распознавался байт-в-байт, и под DisableHeaderNormalizing запрос с authorization:, написанным строчными буквами, считался анонимным — его ответ попадал под анонимный ключ. Отсюда и комментарий-правило: Bump this whenever what a key stands for changes; the cost is one cold cache after a deploy — цена смены правил ключа — холодный кэш после деплоя.

Дальше — метод запроса (ключ всегда партиционирован по методу: The resulting cache key is always partitioned by request method internally) и результат KeyGenerator'а по умолчанию: путь запроса с ограничением в 192 байта — сегменты длиннее хешируются sha256 с префиксом sha256:, — затем q= с каноническим query, где параметры отсортированы по именам и значениям, затем h= по заголовкам из KeyHeaders — по умолчанию Accept, Accept-Encoding, Accept-Language, отсортированные и экранированные. Разделители внутри сегментов экранируются — обратная косая черта как \, черта как \p, двоеточие как \c — чтобы ухищрённый путь не смог впрыснуть структуру в чужой ключ. Куки — только из явного белого списка KeyCookies, и дефолт пуст: Cookies are excluded by default to avoid accidental per-user cache fragmentation — куки по умолчанию не фрагментируют кэш на миллион персональных записей.

Отдельный слой — Authorization. Запрос с этим заголовком получает суффикс |auth= с sha256-хешем строк заголовка, причём каждая строка берётся с префиксом длины, чтобы разные наборы строк — две строки Bearer a и Bearer b против одной строки Bearer a,Bearer b — не дали одинаковый хеш. И на этом ключевые правила не кончаются: авторизованный запрос по умолчанию ещё и не сохраняется — об этом в блоке про запреты.

### Порядок параметров в query

```go
// кэш на маршруте: KeyGenerator дефолтный, DisableQueryKeys: false
curl 'localhost:8080/api/tasks?b=2&a=1'   // первый запрос
curl 'localhost:8080/api/tasks?a=1&b=2'   // второй запрос

// что покажет X-Cache второго запроса?
```

**Что выведет программа?**

- miss — query сравнивается байт-в-байт, порядок параметров важен
- unreachable — два разных набора параметров конфликтуют в одном ключе
- ✅ hit — канонический query сортирует параметры, ключи совпали
- hit — ключ строится только из пути, query вообще не участвует

appendCanonicalQueryString в keygen.go парсит query, сортирует имена параметров и их значения и собирает обратно в предсказуемом порядке — a=1&b=2 в обоих случаях. Поэтому второй запрос попадает в запись первого: hit. Это и есть смысл слова «канонический» в комментарии конфига: canonical query string — одна строка на одно множество параметров. Третий же запрос с ?a=1&b=3 получил бы miss: значение другое, ключ другой. Защита от злонамеренных query тоже есть: больше maxQueryParams — 128 — параметров или больше maxQueryBufferSize — 4096 — байт, и строка целиком уходит в sha256 вместо разбора. Кстати, ключ строится и из пути — вариант «query вообще не участвует» был бы правдой только с DisableQueryKeys: true.

### Инвалидация: как выкинуть запись раньше TTL

TTL — это добровольное устаревание, но бизнес иногда требует немедленного: админ починил список задач и хочет, чтобы клиент увидел правку сейчас, а не через тридцать секунд. В cache middleware для этого есть два механизма, и оба начинаются с ключа. Первый — CacheInvalidator: функция func(fiber.Ctx) bool, которую middleware вызывает на каждый запрос уже найденной записи. Вернула true — запись считается просроченной: в коде это изящный трюк entryExp = ts - 1, запись мгновенно становится старше текущей секунды, удаляется из хранилища и из кучи вытеснения, а запрос идёт в хендлер и обновляет кэш. Валидатор получает весь контекст запроса, поэтому условие может быть любым: параметр ?refresh=true, особый заголовок от доверенного клиента, время суток.

Второй механизм — ручное удаление: если вы контролируете KeyGenerator, ключ предсказуем, и вы можете удалить запись напрямую через Storage.Delete(key). Нюанс, который легко пропустить: при внешнем хранилище тело лежит под ключом с суффиксом _body, и удалять нужно оба — именно так поступает внутренний deleteKey в cache.go. Забудете _body — получите метаданные без тела: middleware отличит эту историю по errCacheMiss — ошибке cache: miss — и удалит осиротевшие метаданные сам, но лучше до этого не доводить.

Честная оговорка про пределы: у кэша нет API «перечисли все ключи по шаблону» и нет групповой инвалидации по тегам. Это не лень авторов, а следствие дизайна: интерфейс fiber.Storage — это Get, Set, Delete, Reset, Close, где Delete точечный, а Reset, очищающий всё хранилище разом, слишком тяжёл для продакшена. Поэтому ключи проектируют заранее: если завтра понадобится сбрасывать кэш задач по ID пользователя, этот ID должен уже быть сегментом ключа сегодня. Классика жанра — фраза про две по-настоящему трудные вещи компьютерных наук: инвалидация кэша и именование сущностей. Здесь они встречаются в одной точке: имя ключа и есть стратегия инвалидации.

### Соберите конфиг: кэш задач с ручной инвалидацией

Восстановите конфиг: тридцать секунд TTL, статус в X-Cache, инвалидация параметром refresh и ключ из пути плюс клиента.

```go
tasksCache := cache.New(cache.Config{
        Expiration:  ___, // тридцать секунд жизни записи
        CacheHeader: ___, // заголовок со статусом кэша, как в дефолте
        CacheInvalidator: func(c fiber.Ctx) bool {
                return ___ // ?refresh=true выкидывает запись раньше TTL
        },
        KeyGenerator: func(c fiber.Ctx) string {
                return ___ // путь плюс клиент из заголовка X-Client-ID
        },
})
```

Подсказка: Expiration — duration, CacheHeader — строка, валидатор сравнивает query-параметр со строкой true.

Правильные ответы в пропусках: `30 * time.Second`, `"X-Cache"`, `c.Query("refresh") == "true"`, `c.Path() + "|" + c.Get("X-Client-ID")`

### Storage: от памяти процесса к Redis

Дефолтное хранилище кэша — internal/memory из ядра: словарь с TTL, обслуживаемый внутри manager'а, плюс sync.Pool для структур item. Это «an in-memory store for this process only», и слово only здесь — главное: память не переживает рестарт, не делится между инстансами и не переживает даже деплой с холодным кэшем. Для маленького сервиса этого хватает с запасом — кэш списка задач в сто записей не стоит своей инфраструктуры.

Как только инстансов несколько, включается тот же ход, что и на лимитере из прошлого урока: поле Storage принимает реализацию fiber.Storage — контракт Get, Set, Delete, Reset, Close плюс WithContext-версии, девять методов, которые мы разбирали по storage_interface.go. Практический выбор — Redis из монорепозитория gofiber/storage: импорт github.com/gofiber/storage/redis/v3, redis.New с адресом и паролем, и готовый бэкенд, который переживает рестарты и делится всеми инстансами. Один кэш на несколько машин — это ещё и экономия: дорогой ответ считается один раз на всю ферму, а не на каждый инстанс.

Единственная новая деталь против лимитера — раздельное хранение. Метаданные item кодируются msgp и кладутся под ключом как есть, а тело уходит во внешнее хранилище отдельно — под ключом с суффиксом _body — сырыми байтами: тело может быть мегабайтным, и гонять его через msgp-кодирование — значит удвоить работу процессора без всякой пользы. На попадании middleware сначала достаёт метаданные, затем getRaw для тела и склеивает их в ответ. Побочный эффект раздельности уже упоминался: ручная инвалидация внешнего кэша удаляет оба ключа.

### Что НЕ кэшировать: авторизация, куки и персональные ответы

Кэширование — это публикация: одна запись обслуживает всех, кто совпал по ключу. Отсюда короткий список запретов, и каждый из них в исходнике — не рекомендация, а механика. Первое: Authorization в запросе. Авторизованный запрос получает партицию |auth= в ключе и по умолчанию не сохраняется вовсе — его ответ не отдаётся другим клиентам, если сам ответ не объявлен разделяемым: Cache-Control: public или s-max-age в ответе подтверждают, что он одинаковый для всех залогиненных. Поэтому ответы под requireAPIKey из седьмого урока без явного public в кэш не попадут — и это правильно: ключ одного клиента не должен открывать данные другому.

Второе и главное — Set-Cookie. Двенадцатый урок показал, что кука — это память о конкретном браузере: сессия, флаг авторизации, remember-me. Кэш — тоже память, но обезличенная. Конфликт фундаментальный, и исходник решает его в два слоя. Слой первый: Set-Cookie и Set-Cookie2 сидят в ignoreHeaders — списке заголовков, которые никогда не переигрываются с попадания, с беспощадным комментарием: Served to every client matching the key, so a captured Set-Cookie hands one client's session to all the others. It still reaches the client that caused the miss — запись с кукой выдаст сессию одного клиента всем остальным, кука доходит только до того, кто вызвал miss. Слой второй, строже: ответ, который ставит куку и не объявлен разделяемым через public или s-max-age, вообще не сохраняется — персонализированный для одного клиента он и остаётся персональным.

Третье — Vary: * в ответе: RFC 9111 прямо запрещает кэшировать такие ответы, и middleware выполняет запрет — unreachable. Четвёртое — всё, что меняется чаще, чем живёт TTL: ответ с ценой биржевой котировки при Expiration в минуту — это не кэш, а генератор убытков. Пятое — большие тела: тело больше MaxBytes не сохраняется, и это тоже unreachable. Общий принцип один: кэшируйте агрегированное, публичное и медленно меняющееся — списки, каталоги, конфигурации; не кэшируйте персональное, приватное и живое.

### Проверьте себя: Set-Cookie под кэшем

**Вопрос:** Хендлер под cache middleware выставляет Set-Cookie: session_id=abc123 и возвращает JSON без Cache-Control. Что произойдёт со вторым GET того же маршрута?

- Второй клиент получит тело из кэша и куку первого — сессия утекла
- ✅ Ответ не сохранится: кука персонализирует ответ для одного клиента, каждый GET пойдёт в хендлер — кэш и cookies несовместимы без явного public
- Сохранится тело без куки: ignoreHeaders вычеркнет Set-Cookie при записи
- Middleware вернёт 504 Gateway Timeout из-за конфликта заголовков

Второй слой защиты: responseSetsCookie видит куку в ответе, а разделяемости — public или s-max-age — в ответе нет, поэтому middleware пропускает сохранение и ставит X-Cache: unreachable. Вариант «тело без куки» был реальностью только в старых версиях: Set-Cookie появился в ignoreHeaders не сразу, и middleware до сих пор находит такие legacy-записи при попадании — функцией cachedHeadersSetCookie — и удаляет их целиком. 504 здесь ни при чём: это ответ only-if-cached, когда записи нет вовсе. Практический вывод: если маршрут обязан ставить куку, но ответ действительно публичный — пометьте его Cache-Control: public, тогда тело сохранится, а кука на попадании не переиграется.

### MaxBytes, вытеснение и кэшируемые статусы

Кэш без потолка — это утечка с красивым именем, поэтому MaxBytes из коробки равен 1 * 1024 * 1024 — один мебибайт суммарных тел. Документация поля формулирует политику вытеснения одной фразой: Max number of bytes of response bodies simultaneously stored in cache. When limit is reached, entries with the nearest expiration are deleted to make room for new. 0 means no limit. Реализация — индексированная куча heap.go: каждая запись зарегистрирована с моментом истечения, и вытеснение берёт записи с ближайшим истечением, а не случайные и не самые старые по попаданию. Тело, которое само больше MaxBytes, не сохраняется вовсе — ему в куче делать нечего. Ноль снимает потолок — осознанно и на свой риск: килобайтные JSON-ответы вы раздуете до гигабайт раньше, чем заметите.

Второй фильтр на сохранении — статус ответа. Набор cacheableStatusCodes из cache.go: 200, 203, 204, 300, 301, 308, 404, 405, 410, 414, 501. Логика набора поучительна: кэшируются не только успехи — 404 и 410 тоже, потому что отсутствие ресурса такое же устойчивое знание, как его наличие, и защита базы от повторных запросов к пустоте не менее ценна. Зато 500 не кэшируется никогда: ошибка сервера — событие переходящее, и замораживать её на тридцать секунд значит превращать всплеск в гарантированную недоступность. Даже 301 с Location сохраняется аккуратно: заголовок Location выписывается отдельной строкой, чтобы редирект работал и на попадании — иначе первый клиент получил бы Location, а остальные голый статус.

Мелочь, которую приятно знать: если у записи нет ни max-age, ни Expires, а запись старше суток, hit помечается Warning 113 - «Heuristic expiration» — эвристическая свежесть из RFC 9111. Fiber не только кэширует, но и объясняет в заголовках, насколько честно он это делает.

### Чек-лист: кэш ответов на проде

- Кэшируйте только GET и HEAD — дефолт Methods уже таков, не расширяйте без нужды
- Начинайте с короткого TTL: 30 * time.Second лечат 90% боли, экзотика — ExpirationGenerator
- Каждому маршруту — своя политика: app.Group собирает общее, cache.New на маршруте — частное
- Помните про приоритеты TTL: ExpirationGenerator, затем s-max-age, max-age, Expires, и только затем Expiration
- Проверяйте ключ: путь, канонический query, KeyHeaders; персональные сегменты — только осознанно через KeyCookies или свой KeyGenerator
- Ответы под Authorization не сохраняются без Cache-Control: public или s-max-age — не удивляйтесь miss
- Маршрут с Set-Cookie в кэш не попадёт без public — сессии из двенадцатого урока и кэш не смешивать
- Мутации не кэшируются; инвалидация — CacheInvalidator для мягкого сброса, Storage.Delete для точечного
- MaxBytes держите включённым и следите за метриками попаданий по X-Cache: hit в логах
- Несколько инстансов — сразу Storage: memory делит состояние только внутри процесса

### Прогон: curl-сценарии и границы кэша

Соберите канон целиком: инстанс на :8080, слой helmet, cors и logger из шестого урока, группа /api и кэш на /api/tasks с Expiration: 30 * time.Second. Клиент — curl, и сценарии короткие. Первый: два GET /api/tasks подряд с curl -i — первый ответ несёт X-Cache: miss, Age: 0 и Cache-Control: public, max-age=…, где число — остаток TTL; второй — X-Cache: hit с тем же Cache-Control и Age, подросшим на разницу секунд. Второй: POST /api/tasks — в ответе X-Cache: unreachable, а следующий GET всё ещё hit: мутация не выкидывает запись, список задач заморожен до конца TTL. Третий: GET с ?refresh=true при настроенном CacheInvalidator — запись вытеснена, запрос дошёл до хендлера и вернулся с X-Cache: miss, а следующий GET снова hit, уже со свежим телом. Четвёртый: два GET с переставленными параметрами ?b=2&a=1 и ?a=1&b=2 — одинаковые ключи, второй запрос hit.

Границы технологии полезно знать так же точно, как механику. Кэш ответов — не замена статике: [файлы из пятого урока](/courses/fiber/lessons/05-staticheskie-fajly/) app.Static отдаёт сам, со своим CacheDuration для неактивных обработчиков в десять секунд по умолчанию и опцией MaxAge для Cache-Control — дублировать это middleware/cache бессмысленно. Кэш — не замена CDN: внешний прокси делает то же самое ближе к клиенту, и заголовки, которые пишет Fiber, ему понятны без перевода. И кэш — не архив: всё, что не помещается в MaxBytes, не кэшируется, а не вытесняет соседей.

Честное слово про инструментарий: песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; кэш проверяется локально, где ваш curl видит X-Cache и Age целиком.

Материал держится на четырёх опорах: конфиг и дефолты из config.go, конструктор ключей из keygen.go, состояние и msgp из manager.go, разбор директив из cachecontrol.go — всё ветки main ядра fiber v3.5.0. Полный план курса — на странице [курса Fiber](/courses/fiber/).

Дальше — девятнадцатый урок: логирование запросов, формат логов, slog в Fiber и корреляция по RequestID. Связь с сегодняшней темой прямая: X-Cache: hit и miss — готовый маркер для лога, а Age и Date — поля, по которым по логам видно, сколько жизни у записи; кэш, который никто не измеряет, превращается в чёрный ящик с Hit Rate неизвестной полярности.

### Карточки: fiber cache

- **X-Cache: hit** — Ответ отдан из кэша: SetBodyRaw, статус и заголовки из записи; c.Next() не вызывался — хендлер и база не видели запроса.
- **X-Cache: unreachable** — Кэш отказался работать с запросом: метод вне Methods, POST, ответ с Set-Cookie без public, статус вне cacheableStatusCodes, тело больше MaxBytes.
- **Приоритет TTL в cache middleware** — ExpirationGenerator, затем s-max-age (для разделяемых), затем max-age, затем Expires, и только потом Expiration из конфига. Хранилище держит запись по максимуму из клиентского TTL и Expiration.
- **CacheInvalidator** — Функция func(fiber.Ctx) bool: true призывает запись просроченной — entryExp = ts - 1, запись удаляется, запрос обновляет кэш. Инвалидация без ожидания TTL.
- **Из чего строится ключ по умолчанию** — Версия v2, метод, путь (до 192 байт, дальше sha256), канонический отсортированный query, заголовки KeyHeaders; куки — только из KeyCookies, Authorization — хеш-партицией |auth=.
- **Почему Set-Cookie не кэшируется** — Кука персонализирует ответ для одного клиента: с попадания куки не переигрываются (ignoreHeaders), а ответ с кукой без Cache-Control: public/s-max-age вообще не сохраняется.

## Коротко: что запомнить

- cache.New(cache.Config{Expiration: ...}) возвращает fiber.Handler: на hit тело переигрывается из хранилища без вызова хендлера, на miss ответ сохраняется после c.Next().
- Статус кэша живёт в CacheHeader — по умолчанию X-Cache со значениями hit, miss и unreachable; unreachable значит «работать не стал», а не «ошибка».
- TTL записи задают по приоритету: ExpirationGenerator, s-max-age, max-age, Expires и только затем Expiration; storageExpiration — максимум из клиентского срока и конфига.
- Ключ строится из версии, метода, пути, канонического query и KeyHeaders; куки выключены по умолчанию, Authorization даёт хеш-партицию и запрет сохранения без public.
- Инвалидация: CacheInvalidator для условия на каждый запрос и Storage.Delete(key) с ключом-суффиксом _body для внешнего хранилища; списка всех ключей нет — проектируйте ключи заранее.
- Не кэшируйте персональное: Authorization без public, Set-Cookie без public, Vary: *, статусы вне белого списка и тела больше MaxBytes в кэш не попадают.

## Домашнее задание

Возьмите канон урока и дополните его тремя маршрутами с разной политикой: /api/tasks — кэш на 30 секунд с CacheInvalidator по параметру refresh; /api/stats — кэш на 5 минут с KeyGenerator, включающим заголовок Accept-Language; /api/me — без кэша, с комментарием, почему персональный ответ не кэшируется. Прогоните curl -i оба запроса на каждый маршрут и выпишите, что показал X-Cache, какие заголовки Cache-Control, Age и Date ушли клиенту на miss и на hit. Затем поднимите второй инстанс на :8081 и убедитесь, что без Storage попадания не делятся, а с Redis из gofiber/storage/redis/v3 в поле Storage — делятся.

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

### Чем кэш ответов Fiber отличается от HTTP-кэша браузера?

Fiber кэширует на сервере: попадание обслуживается до хендлера из памяти или Storage. Браузерный кэш живёт у клиента и управляется теми же заголовками Cache-Control, которые middleware пишет на ответах, — они дополняют друг друга.

### Как сбросить весь кэш целиком?

Группового сброса по тегам нет. Варианты: перезапуск процесса для memory, Reset на интерфейсе fiber.Storage, или согласованное правило в CacheInvalidator — версия в конфиге или параметр релиза в ключе.

### Почему мой авторизованный маршрут всегда показывает miss?

Запросы с Authorization не сохраняются без разделяемого Cache-Control: public или s-max-age в ответе. Если ответ действительно одинаков для всех залогиненных — добавьте public, иначе кэш и не должен запоминать персональные данные.

### Кэширует ли middleware/cache ответы 404?

Да: 404 и 410 входят в cacheableStatusCodes наряду с 200, 204, 301 и другими — устойчивое отсутствие ресурса тоже стоит кэшировать, чтобы пустота не долбила базу.
