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

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

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

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

Хэши: HSet, HGet и профили пользователей

Хэши Redis — тип, который go-разработчик выбирает, когда объект состоит из полей и эти поля меняются поодиночке: профиль пользователя, сессия, настройка сервиса. В уроке разбираем HSet, HGet, HGetAll и HIncrBy на go-redis v9: соберём профиль, научимся обновлять одно поле без перезаписи всего объекта и поймаем ловушку с пустой картой.

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

Задача из жизни: в кэше лежит профиль пользователя — имя, город, почта, счётчик лайков. JSON-кэш из прошлого урока справляется, но с одной занозой: чтобы поменять город, приходится прочитать весь документ, распаковать его в структуру, изменить поле, собрать обратно и перезаписать целиком. Четыре шага ради одного поля — и на горячем профиле это ещё и гонка: два запроса, прочитавших старую версию, по очереди записывают свои, и последний побеждает. Redis предлагает для таких объектов отдельный тип — хэш. Хэш — это ключ, внутри которого живёт карта поле-значение: user:42:profile содержит name, city и email как независимые слоты. Обновление города — одна команда HSet по одному полю, чтение имени — HGet без распаковки остального. Это не замена JSON-кэшу, а соседний инструмент со своей зоной применимости, и границу мы проведём в конце урока. Обзор всех пяти типов данных с точки зрения задач был в уроке про типы данных Redis — здесь берём один тип и живём в нём неделю. Профиль из примеров будет ходить с нами до конца курса: на нём же соберутся сессии, счётчики и рейтинги, поэтому схема ключей ниже окупится неоднократно.

Хэш Redis: поля внутри одного ключа#

Ментальная модель хэша — map[string]string, живущий под одним ключом Redis. Снаружи это обычное имя вроде user:42:profile, изнутри — набор полей со значениями-строками. Записывает поля команда HSET: go-redis v9 принимает map с любыми простыми значениями и сам конвертирует числа и булевы в строки. Читает целиком HGETALL, по одному полю — HGET, удаляет поле HDel, считает поля HLen, отдаёт список имён HKeys. Для record-структур у HSet есть бонус: он принимает структуру Go напрямую и раскладывает поля по именам — их можно переопределить тегом redis, как json-тегом в сериализации. Я всё же чаще передаю map: имена полей видны прямо в месте вызова, и правка модели не меняет запись в Redis за моей спиной. Важная граница типа: значение поля — строка. Вложенную структуру или срез в поле не положить — только сериализовать отдельно; числа и булевы клиент конвертирует, но дальше строковой плоскости хэш не уходит. TTL при этом ставится на весь ключ целиком — отдельному полю срок жизни задать нельзя, к этому вернёмся ниже.

Профиль: запись и чтение целиком

GO
func createProfile(ctx context.Context, id int) error {
        key := fmt.Sprintf("user:%d:profile", id)

        return rdb.HSet(ctx, key, map[string]any{
                "name":  "Аня",
                "city":  "Тверь",
                "email": "anya@go.dev",
        }).Err()
}

func readProfile(ctx context.Context, id int) (map[string]string, error) {
        key := fmt.Sprintf("user:%d:profile", id)

        return rdb.HGetAll(ctx, key).Result()
}

HSet принимает карту: каждое поле становится самостоятельным слотом внутри ключа, числа конвертируются в строки автоматически. HGetAll разворачивает хэш обратно в map[string]string — типизация значений остаётся строковой, и если в поле жило число, приводить его обратно придётся strconv-ом. Ключ с суффиксом profile оставляет пространство имён свободным для других данных пользователя.

HGet и redis.Nil: поля нет, а ключ есть#

У хэшей два уровня отсутствия, и ошибка новичка — свалить их в один. Ключ может не существовать целиком: тогда и Get, и HGet, и HGetAll работают с пустотой. А может существовать, но без нужного поля: user:42:profile живёт, email в него не записывали. HGet по такому полю возвращает ровно ту же константу redis.Nil, что и чтение несуществующей строки — проверка через errors.Is идентична строковому кэшу, новая семантики не заводится. Обратно-симметричное правило защищает HDel: удаление несуществующего поля не ошибка, команда просто отвечает нулём удалённых записей. Практическое следствие двух уровней: код, который различает «пользователя нет» и «поля нет», обязан сначала проверить существование ключа — через Exists или по содержимому HGetAll. Для профилей это обычно неважно: если ключа нет, вернётся пустая карта, и логика «сходить в базу» отработает одинаково. Но для авторизационных структур, где пустая сессия и отсутствующая сессия ведут на разные экраны, различие становится архитектурным — и там лучше явная проверка Exists.

Точечные обновления: HSet по полю и HIncrBy#

Главный выигрыш хэша проявляется в записи. HSet умеет обновлять одно поле, не трогая остальные: переименование пользователя — одна команда, и параллельный запрос, читающий город, видит целостные данные, а не полдокумента между перезаписями. Гонки из JSON-мира исчезают конструктивно: нет цикла прочитать-изменить-записать, значит, нет окна, в котором два писателя затирают друг друга. Счётчики внутри хэша — отдельная гордость: HIncrBy увеличивает поле на заданное число атомарно, на стороне сервера, без чтения текущего значения. Профиль с полем likes растёт одной командой, и сто одновременных лайков дадут сто прироста, а не пятьдесят, как случилось бы с read-modify-write из двух шагов. Отсутствующее поле считается нулём: первый HIncrBy создаст его со значением инкремента. Вокруг ядра HSet и HGet собран полный набор утилит: HDel удаляет поля, HLen считает их, HKeys отдаёт имена без значений, HMGet читает несколько полей одним рейсом. Полный справочник не нужен — эти команды встречаются по мере задач, а базовая четвёрка покрывает профильные сценарии целиком.

Одно поле и счётчик лайков

GO
func renameUser(ctx context.Context, id int, name string) error {
        key := fmt.Sprintf("user:%d:profile", id)

        return rdb.HSet(ctx, key, "name", name).Err()
}

func likeUser(ctx context.Context, id int) (int64, error) {
        key := fmt.Sprintf("user:%d:profile", id)

        return rdb.HIncrBy(ctx, key, "likes", 1).Result()
}

Первый вызов обновляет единственное поле name — остальной хэш не читается и не перезаписывается. Второй возвращает новое значение счётчика: HIncrBy растёт атомарно на сервере, поле likes создаётся с единицы, если его ещё не было. Именно из-за такой формы атомарности счётчики в хэшах не требуют ни транзакций, ни блокировок.

Хэш против JSON-строки#

КритерийJSON-строкаХэш
Обновить одно полеПрочитать, распаковать, изменить, собрать, записатьHSet одного поля — без чтения остального
Прочитать весь объектGET и Unmarshal в структуру с типамиHGetAll — карта строк, типы приводите сами
Вложенные структуры и срезыПоддерживаются естественноНет: значение поля — строка, сериализуйте отдельно
Счётчик внутри объектаТолько цикл чтения и записиHIncrBy — атомарный прирост поля
TTLНа весь ключНа весь ключ; по полям нельзя

Когда хэш, а когда JSON-строка#

Правило выбора у меня простое: читается целиком и пишется целиком — JSON; меняется по частям — хэш. Каталог товаров, результат отчёта, страница ленты — это один объект, который живёт и умирает целиком, и JSON-строка с Unmarshal даёт типы бесплатно. Профиль, сессия, конфигурация сервиса — набор независимых полей с разной частотой изменений, и здесь хэш экономит и трафик, и код. Ограничение, которое ломает планы чаще всего — строковые значения: как только внутри объекта появляется вложенный массив тегов или список заказов, хэш перестаёт вмещать его без повторной сериализации, и удобство испаряется. Второе ограничение — TTL только на весь ключ: если email должен жить час, а имя — сутки, вам нужны разные ключи или отдельная схема. Третье — распаковка типов: HGetAll вернёт строки, и каждое число придётся приводить strconv-ом, тогда как Unmarshal делает это сам. В реальных сервисах типы живут вместе: профиль — хэш, его заказы — JSON-строки, и это нормально, потому что типы данных Redis — инструменты, а не религия.

Сессия как хэш: структура вместо map

GO
type Session struct {
        UserID    int64
        UserAgent string
        LoggedIn  bool
}

func saveSession(ctx context.Context, token string, s Session) error {
        return rdb.HSet(ctx, "session:"+token, s).Err()
}

go-redis v9 принимает структуру напрямую: поля раскладываются по именам — имя можно переопределить тегом redis, аналог json-тега. Числа и булевы конвертируются в строки и обратно читаются строками. Вложенные структуры и срезы в поля не превратятся — их придётся сериализовать отдельно, так что для сессий с корзиной товаров хэш придётся комбинировать с JSON.

Проверка: HGetAll по пустоте

Ключ user:404:profile не существует. Что вернёт rdb.HGetAll(ctx, "user:404:profile").Result()?

Проверка: HIncrBy на новом поле

Что сделает rdb.HIncrBy(ctx, key, "likes", 5), если поля likes в хэше нет?

Что напечатает счётчик?

Прочитайте код и выберите, что он выведет:

key := "user:42:profile"

rdb.HIncrBy(ctx, key, "likes", 1)
n, _ := rdb.HIncrBy(ctx, key, "likes", 1)

fmt.Println(n)

Есть поле email?

Прочитайте код и выберите, что он выведет:

// ключ user:42:profile существует, в нём поля name и city,
// поля email нет

_, err := rdb.HGet(ctx, "user:42:profile", "email").Result()

fmt.Println(errors.Is(err, redis.Nil))

Соберите операции с хэшем

Допишите запись полей, чтение одного поля и атомарный прирост счётчика.

rdb.(ctx, key, map[string]any{"name": "Аня", "city": "Тверь"}) name, _ := rdb.(ctx, key, "name").Result() n, _ := rdb.(ctx, key, "likes", 1)

Сценарий профиля по шагам

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

Строки

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

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

Карточки: хэши

Термин

HSET / HSet

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

Записывает поля хэша. go-redis v9 принимает map, пары поле-значение или структуру: поля раскладываются по именам или тегам redis.

1 / 6

Профиль собран, и на его фоне видно, куда двигаться дальше. Поле likes — первый счётчик в курсе, а standalone-счётчики с INCR и их атомарность заслуживают отдельного разговора: следующий урок — про счётчики и INCR. Хэш сессии, который мы записали под токеном, — заготовка веб-аутентификации: полный цикл входа и выхода на этой конструкции собирается в уроке про сессии веб-приложения. Если объект приходит из HTTP-запроса, его удобно связывать с хэшем через биндинг — как это делается, показано в курсе по Gin. Карточки выше — минимальный справочник команд; код урока, как и раньше, выполняется локальным Go с Redis в Docker, песочница сетевых соединений не открывает. Домашнее задание намеренно заставляет удалить ключ и увидеть пустую карту своими глазами: этот крайний случай дешевле поймать в терминале, чем в проде.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Хэш — ключ с картой поле-значение внутри: HSet пишет поля, HGet читает одно, HGetAll отдаёт всё как map[string]string.
  • HGet по отсутствующему полю возвращает redis.Nil, а HGetAll по отсутствующему ключу — пустую карту без ошибки.
  • HSet одного поля обновляет его без чтения и перезаписи остального объекта — гонки read-modify-write исчезают.
  • HIncrBy растёт атомарно на сервере, отсутствующее поле считает нулём и создаёт со значением инкремента.
  • Значения полей — строки: вложенные структуры и срезы требуют отдельной сериализации, числа и булевы конвертируются.
  • TTL ставится на весь хэш; выбор между хэшем и JSON — это выбор между частичными и цельными обновлениями.

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

Соберите профиль пользователя на хэше: createProfile с тремя полями, renameUser для обновления имени и likeUser с HIncrBy. Запустите локально с Redis в Docker, вызовите likeUser трижды и проверьте значение поля в redis-cli через HGET user:42:profile likes. Затем удалите ключ DEL-ом и вызовите readProfile — убедитесь, что получили пустую карту без ошибки, и добавьте проверку len для случая «пользователя нет».

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Хэши: HSet, HGet и профили пользователей)

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

Можно ли хранить структуру с вложенными полями в хэше?

Только сериализуя вложенную часть отдельно: значение поля хэша — строка. Плоский объект с числами и строками раскладывается естественно, а список тегов или адрес из нескольких полей лучше положить JSON-ом в одно поле хэша или вынести в отдельный ключ.

Почему HGetAll не возвращает redis.Nil?

Так работает сама команда HGETALL: чтение несуществующего хэша считается валидным и отвечает пустым набором, поэтому клиент возвращает пустую карту и nil. Это поведение Redis, а не go-redis. Если нужно различать отсутствие, проверяйте длину карты или вызывайте Exists заранее.

Как задать срок жизни отдельному полю хэша?

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

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

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

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

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