Содержание урока · 16 разделов
- fiber cache: вторая половина пары «не работать зря»
- Анатомия cache.Config: шестнадцать полей по жизненному циклу
- Конфиг cache.Config целиком
- Что middleware реально делает: сохранить тело и переиграть
- TTL: кто на самом деле выставляет срок жизни
- Cache-Control со стороны клиента: no-store, no-cache и only-if-cached
- Keying: из чего слеплен ключ
- Инвалидация: как выкинуть запись раньше TTL
- Storage: от памяти процесса к Redis
- Что НЕ кэшировать: авторизация, куки и персональные ответы
- MaxBytes, вытеснение и кэшируемые статусы
- Чек-лист: кэш ответов на проде
- Прогон: curl-сценарии и границы кэша
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 секунд
GOpackage 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?
Анатомия 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?
Соберите путь запроса через cache middleware
Расставьте шаги от разбора заголовков запроса до пометки 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 — валидный способ выключить кэш, не вынимая слой из цепочки.
Что увидит клиент на попадании
Прочитайте код и выберите, что он выведет:
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 со стороны клиента: 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
Прочитайте код и выберите, что он выведет:
// кэш на маршруте: KeyGenerator дефолтный, DisableQueryKeys: false
curl 'localhost:8080/api/tasks?b=2&a=1' // первый запрос
curl 'localhost:8080/api/tasks?a=1&b=2' // второй запрос
// что покажет X-Cache второго запроса?Инвалидация: как выкинуть запись раньше 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 и ключ из пути плюс клиента.
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 того же маршрута?
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.
Границы технологии полезно знать так же точно, как механику. Кэш ответов — не замена статике: файлы из пятого урока 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.
Дальше — девятнадцатый урок: логирование запросов, формат логов, slog в Fiber и корреляция по RequestID. Связь с сегодняшней темой прямая: X-Cache: hit и miss — готовый маркер для лога, а Age и Date — поля, по которым по логам видно, сколько жизни у записи; кэш, который никто не измеряет, превращается в чёрный ящик с Hit Rate неизвестной полярности.
Карточки: fiber cache
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 и другими — устойчивое отсутствие ресурса тоже стоит кэшировать, чтобы пустота не долбила базу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT