В прошлом уроке мы закешировали product как строку — и на этом месте у каждого возникает вопрос: а если у меня структура с двадцатью полями? Redis хранит байты, он не знает ничего о структурах Go, поэтому мост между моделью и ключом строит сериализация. Формат по умолчанию в экосистеме — JSON: он читается всеми, отлаживается глазами в redis-cli через GET, и стандартная библиотека encoding/json делает всю работу без единой зависимости. Альтернативы существуют и они честно быстрее и компактнее: msgpack для плотных бинарных payload-ов и protobuf, если обе стороны говорят на нём и так. Я начинаю с JSON почти всегда: разница в микросекундах редко стоит потери читаемости, а поменять формат позже — правка одного слоя. В этом уроке разберём, как структура превращается в запись Redis и обратно: куда деваются имена полей без тегов, что происходит с time.Time, почему []byte приезжает как base64 и самая коварная ловушка темы — старая запись кэша после изменения структуры возвращает нули молча, без единой ошибки. Если вы ещё не собрали cache-aside из урока про кэширование запросов, держите его перед глазами: здесь мы займёмся его внутренностями.
json.Marshal: кладём структуру в Redis#
Механика записи занимает три строки. json.Marshal принимает структуру и возвращает срез байт — JSON-документ, где ключами становятся имена полей. Срез байт — ровно то, что rdb.Set ожидает третьим аргументом: никакого промежуточного приведения к строке не нужно, []byte кладётся в Redis как есть. Ключ наследует конвенцию из прошлого урока — сущность:id — и это тем важнее, что записей у структур много: product:17 и product:18 должны сосуществовать, не путаясь. Последний аргумент Set — TTL: сериализация не отменяет срок жизни, кэш структур остаётся кэшем, а не вечным хранилищем. Если Marshal возвращает ошибку, это почти всегда означает, что в структуре затесался тип, который JSON не умеет: канал или функция. На типовых моделях из базы — строки, числа, даты, срезы, вложенные структуры — ошибка не встречается, но проверять её всё равно нужно: вернуть err вверх дешевле, чем гадать, почему кэш пуст. Обратите внимание на даты: time.Time сериализуется стандартной библиотекой в строку формата RFC3339 — 2024-05-01T12:00:00Z — и так же корректно читается обратно, специально ничего делать не надо.
Структура без тегов: куда деваются имена полей
GOtype Product struct {
ID int
Name string
Price int
}
func cacheProduct(ctx context.Context, p Product) error {
data, err := json.Marshal(p)
if err != nil {
return err
}
return rdb.Set(ctx, fmt.Sprintf("product:%d", p.ID), data, time.Hour).Err()
}Без тегов json.Marshal использует имена полей как есть: в Redis ляжет документ с ключами ID, Name и Price с заглавных букв. Для симметричного Marshal-Unmarshal в пределах Go этого достаточно, но любой внешний потребитель — фронтенд на camelCase — получит непривычные заглавные ключи. Лечение — теги структур, до них дойдём ниже.
Кэш структур и Bytes(): как читать обратно#
Чтение — зеркало записи с одним удобством. Метод Bytes() у StringCmd возвращает содержимое ключа сразу как []byte, избавляя от промежуточного Result() с приведением строк: raw, err := rdb.Get(ctx, key).Bytes() — и дальше json.Unmarshal(raw, &p) наполняет структуру. Ветка ошибок здесь двухслойная, и путать слои нельзя. redis.Nil — промах: ключа нет или истёк TTL; это штатный сценарий cache-aside, после него нужно идти в базу, а не отдавать пустую структуру. Всё остальное — сетевые проблемы или повреждённый JSON: Unmarshal вернёт синтаксическую ошибку, если байты в ключе не разбираются, и тут честнее вернуть ошибку наверх с обёрткой fmt.Errorf, чем молча отдать нулевую структуру вызывающему. Отдельный нюанс — регистр ключей JSON: Unmarshal сопоставляет поля без учёта регистра, поэтому документ с ключом id заполнит поле ID. Это спасает при миграциях, но не отменяет правила: пишите и читайте одним и тем же набором тегов. Для больших документов помните о budget: JSON-кэш на сотни килобайт на запись — повод пересмотреть, что именно вы кладёте.
Реальный сценарий: вы переименовали поле Discount в SalePrice и выкатили релиз. В Redis живут записи со старым ключом discount, json.Unmarshal его молча игнорирует — а нового не находит. Ошибки не будет: структура заполнится нулями, и скидка обнулится у всех, кто читает из кэша, до конца TTL. Защита дешёвая: версия в ключе. Пишете v2:product:17, релизите — старые записи умирают по TTL, новые не смешиваются со старыми.
Теги: переименование, omitempty и скрытые поля
GOtype User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"` // пустое поле не попадёт в JSON
Pass string `json:"-"` // никогда не сериализуем
secret string // неэкспортируемое поле encoding/json пропускает
}Тег живёт в обратных кавычках после типа поля и переопределяет имя ключа в документе: name вместо Name. Опция omitempty выкидывает пустые значения, дефис запрещает сериализацию полностью — пароль и токены обязаны стоять под ним. Неэкспортируемые поля библиотека не видит в принципе, поэтому secret в JSON не попадёт независимо от тегов.
Пять правил JSON-кэша#
- TTL обязателен: сериализация решает формат, но не отменяет срок жизни записи.
- Версия модели — в ключе: product:17:v2; поменяли структуру — подняли версию, старые записи умирают по TTL.
- Секреты под тегом json:"-": всё, что попало в Redis, читается любым, у кого есть доступ к серверу.
- Один ключ — один объект: не кладите всю таблицу в один гигантский JSON, дробите по id.
- Ошибку Unmarshal не глотать: битая запись — повод упасть в базу, а не вернуть нули вызывающему.
Даты, байты и указатели: особенности сериализации#
Три типа в Go-моделях ведут себя неочевидно, и о них стоит знать до продакшина. time.Time путешествует как строка RFC3339 с таймзоной — после Unmarshal вы получаете корректный момент времени, но монотонные часы обрезаются, а сравнение дат до и после кругооборота через == может подвести на равных моментах в разных зонах; для кэша это почти никогда не важно, для биллинга — важно. []byte сериализуется в base64-строку: аватар на пятьдесят килобайт в кэше превратится в sixty-six тысяч символов и обратно; объём вырастает на треть, и это аргумент хранить бинарь в объектном хранилище, а в кэш класть ссылку. Указатели превращаются в null при nil и в значение при ненулевом значении — после Unmarshal вы не отличите «поле было null» от «поля не было», оба дадут nil. Наконец, поля с channel, func и complex — единственный источник ошибок Marshal в типовых моделях: библиотека честно откажется их сериализовать. Всё это делает кэш структур golang-разработчика предсказуемым, если помнить правило: JSON — зеркало типов, и сюрпризы живут только в неочевидных типах, перечисленных выше.
Полный кругооборот: чтение с проверкой промаха
GOvar ErrNotCached = errors.New("not cached")
func getCachedProduct(ctx context.Context, id int) (Product, error) {
raw, err := rdb.Get(ctx, fmt.Sprintf("product:%d", id)).Bytes()
if errors.Is(err, redis.Nil) {
return Product{}, ErrNotCached // промах — дальше cache-aside
}
if err != nil {
return Product{}, err
}
var p Product
if err := json.Unmarshal(raw, &p); err != nil {
return Product{}, fmt.Errorf("битая запись кэша: %w", err)
}
return p, nil
}Bytes() забирает значение сразу срезом байт, redis.Nil отделяет промах от аварии, а собственная ErrNotCached даёт вызывающему понятную причину — он решит, идти ли в базу. Ошибка Unmarshal оборачивается контекстом: через неделю в логах вы скажете себе спасибо за слово «битая запись кэша» вместо сухого unexpected end of JSON input.
Отладка сериализации — одна команда: GET product:17 в redis-cli покажет документ целиком. Увидели заглавные ID и Price — забыли теги; увидели base64-простыню — в структуре байтовый срез; увидели пустые кавычки у даты — записали нулевой time.Time. Три минуты чтения документа экономят час гаданий по коду.
Проверка: старая запись после рефакторинга
Поле Discount переименовали в SalePrice, релиз выкачен, в Redis лежат записи со старым JSON. Что вернёт json.Unmarshal старой записи в новую структуру?
Проверка: чтение байтами
Какой вызов достанет кэш-структуру из Redis сразу срезом байт?
Какой документ окажется в Redis?
Прочитайте код и выберите, что он выведет:
type Product struct {
ID int
Name string
Price int
}
data, _ := json.Marshal(Product{ID: 17, Name: "Кофемолка", Price: 2990})
fmt.Println(string(data))Что напечатает чтение пустого кэша?
Прочитайте код и выберите, что он выведет:
// Redis пуст: ключа product:999 нет
raw, err := rdb.Get(ctx, "product:999").Bytes()
if errors.Is(err, redis.Nil) {
fmt.Println("промах")
return
}
fmt.Println(len(raw))Соберите сериализацию и чтение
Допишите упаковку структуры в JSON, чтение байтами и разворачивание обратно.
Полный кругооборот структуры
Расставьте строки чтения кэш-структуры по порядку: от запроса к Redis до готового объекта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSON-кэш
JSON-кэш структур закрывает большинство задач, но не все: когда одно поле записи меняется чаще остальных, перепаковывать весь документ расточительно. Для таких случаев в Redis есть отдельный тип — хэш, где поля живут внутри одного ключа и обновляются поодиночке; его разбирает следующий урок про HSet, HGet и профили пользователей. Выбор TTL и стратегия обновления при записи остаются на совести cache-aside из урока про кэширование запросов, а долгий разговор про устаревание подождёт вас в уроке про стратегии инвалидации. Модели для примеров урока — типовые записи из базы: если хотите повторить полный путь данных, курс по GORM покажет, откуда они берутся. Код, как и раньше, запускается локальным Go с Redis в Docker — песочница сайта сетевых соединений не открывает. И держите сериализацию слоем: если профилировщик покажет, что Marshal съедает заметный процент процессора на горячих эндпоинтах, переход на msgpack будет заменой одной пары функций без движения остального кода.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Мост между структурой Go и Redis — сериализация: json.Marshal даёт байты, Set кладёт их с TTL, Bytes() и Unmarshal читают обратно.
- Без тегов ключи документа наследуют имена полей с заглавных букв; теги json задают регистр и имена под внешних потребителей.
- redis.Nil при чтении — промах и повод идти в базу; ошибка Unmarshal — битая запись, её не глотают, а оборачивают контекстом.
- Переименование поля ломает кэш тихо: старый JSON читается без ошибки и даёт нули — лечится версией в ключе.
- time.Time едет строкой RFC3339, []byte — base64, каналы и функции Marshal не сериализует вовсе.
- Секреты под json:"-", неэкспортируемые поля библиотека не видит в принципе.
Домашнее задание
Возьмите cache-aside из прошлого урока и допишите структурную часть: тип User с тремя полями и тегами json, функции cacheUser и getCachedUser с Bytes() и проверкой redis.Nil. Запустите локально с Redis в Docker, положите структуру и прочитайте её через redis-cli командой GET — убедитесь, что ключи документа в нижнем регистре. Затем переименуйте поле, оставьте старую запись в кэше и воспроизведите ловушку с нулями; почините версию в ключе.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему JSON, а не protobuf или msgpack?
JSON читается в redis-cli без утилит, требует только стандартной библиотеки и отлаживается глазами. msgpack и protobuf компактнее и быстрее, но добавляют зависимости и шаг генерации. Для кэша на типовых моделях разница обычно не видна на фоне похода в базу.
Как безопасно менять структуру, если кэш уже наполнен?
Добавляйте версию в ключ: product:17:v1 превращается в v2, старые записи доживают свой TTL и исчезают. Альтернатива — прогрев: после релиза перезаписать ключи новыми значениями. Просто переименовывать поле и надеяться на Unmarshal нельзя — он молча вернёт нули.
Что делать с большими структурами?
Дробите: один ключ — один объект, а не вся таблица. Долгие списки кэшируйте постранично, бинари храните в объектном хранилище и кладите в JSON ссылку. Тяжёлый документ на сотни килобайт съедает и память Redis, и время Marshal на каждый запрос. Размер легко прикинуть заранее: STRLEN по ключу после записи показывает байты — счёт пошёл на сотни килобайт, значит, вы кешируете копию таблицы, и её пора дробить.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin