---
title: "Хэши: HSet, HGet и профили пользователей"
description: "Хэши Redis в Go: HSet, HGet, HGetAll и HIncrBy на go-redis v9. Профиль пользователя как хэш, точечные обновления полей и ловушка с пустой картой."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/08-hashi/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 8 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/08-hashi/*

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

Задача из жизни: в кэше лежит профиль пользователя — имя, город, почта, счётчик лайков. JSON-кэш из прошлого урока справляется, но с одной занозой: чтобы поменять город, приходится прочитать весь документ, распаковать его в структуру, изменить поле, собрать обратно и перезаписать целиком. Четыре шага ради одного поля — и на горячем профиле это ещё и гонка: два запроса, прочитавших старую версию, по очереди записывают свои, и последний побеждает. Redis предлагает для таких объектов отдельный тип — хэш. Хэш — это ключ, внутри которого живёт карта поле-значение: user:42:profile содержит name, city и email как независимые слоты. Обновление города — одна команда HSet по одному полю, чтение имени — HGet без распаковки остального. Это не замена JSON-кэшу, а соседний инструмент со своей зоной применимости, и границу мы проведём в конце урока. Обзор всех пяти типов данных с точки зрения задач был в уроке про [типы данных Redis](/courses/go-redis/lessons/03-tipy-dannyh-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.

> **HGetAll по несуществующему ключу — пустая карта без ошибки.** Интуиция подсказывает redis.Nil, но нет: HGetAll по отсутствующему ключу возвращает пустую map[string]string и nil-ошибку. Код, который проверяет только err, честно ответит «у пользователя пустой профиль» там, где правильный ответ — «пользователя нет». Для каталога разница терпима, для авторизации — баг безопасности. Если пустота означает отсутствие, проверяйте len(fields) == 0 явно.

### Точечные обновления: 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](/courses/go-redis/lessons/03-tipy-dannyh-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.

> **TTL ставится на весь хэш.** Expire и TTL работают с ключом целиком: нельзя дать полю email жить час, а полю name — сутки. Если поля стареют по-разному, разбейте их по разным ключам или положите в хэш отметку времени обновления и проверяйте её при чтении. Планируйте срок жизни до выбора типа, а не после.

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

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

- ✅ Пустую map и nil-ошибку
- redis.Nil как при чтении строки
- nil и ошибку подключения
- Ошибку wrong type: ключ не хэш

HGetAll по отсутствующему ключу возвращает пустую карту и nil-ошибку — это документированное поведение HGETALL. Если пустота должна означать «пользователя нет», проверяйте len(fields) == 0 или заранее вызывайте Exists.

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

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

- ✅ Создаст поле со значением 5: отсутствующее поле считается нулём
- Вернёт redis.Nil: поле должно существовать заранее
- Вернёт ошибку not an integer
- Перезапишет весь хэш единственным полем likes

HINCRBY трактует отсутствующее поле как ноль и после инкремента создаёт его со значением 5. То же верно для несуществующего ключа целиком — команда создаст хэш с одним полем. Ошибка not an integer случается только если в поле лежит нечисловая строка.

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

```go
key := "user:42:profile"

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

fmt.Println(n)
```

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

- 0
- 1
- ✅ 2
- ошибку not an integer

Первый HIncrBy создаёт отсутствующее поле likes со значением 1, второй увеличивает до 2 — и это значение вернётся в n. Каждая команда видит результат предыдущей, потому что выполняются последовательно; атомарность защищает от параллельных писателей, а не от порядка в вашем коде.

### Есть поле email?

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

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

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

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

- ✅ true
- false
- панику
- ошибку компиляции

HGet по отсутствующему полю существующего хэша возвращает тот же redis.Nil, что и чтение несуществующей строки. errors.Is распознаёт константу — напечатается true. Пустая строка здесь не подходит: пустое значение поля — легальное содержимое.

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

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

```go
rdb.___(ctx, key, map[string]any{"name": "Аня", "city": "Тверь"})

name, _ := rdb.___(ctx, key, "name").Result()

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

Подсказка: Записывает HSet, читает по полю HGet, а счётчик внутри хэша двигает HIncrBy.

Правильные ответы в пропусках: `HSet`, `HGet`, `HIncrBy`

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

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

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

1. `key := "user:42:profile"`
2. `rdb.HSet(ctx, key, "name", "Аня", "city", "Тверь")`
3. `rdb.HIncrBy(ctx, key, "likes", 1)`
4. `name, _ := rdb.HGet(ctx, key, "name").Result()`
5. `fields, _ := rdb.HGetAll(ctx, key).Result()`
6. `fmt.Println(name, fields["city"])`

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

- **HSET / HSet** — Записывает поля хэша. go-redis v9 принимает map, пары поле-значение или структуру: поля раскладываются по именам или тегам redis.
- **HGET / HGet** — Читает одно поле. Отсутствие поля и отсутствие ключа возвращают одинаковый redis.Nil — проверяется через errors.Is.
- **HGETALL / HGetAll** — Отдаёт весь хэш как map[string]string. По несуществующему ключу возвращает пустую карту и nil-ошибку, а не redis.Nil.
- **HIncrBy** — Атомарный прирост поля на целое число: отсутствующее поле считается нулём. Гонок нет — инкремент выполняет сервер Redis.
- **HDel, HLen, HKeys** — Удаление поля, подсчёт полей и список имён. Удаление несуществующего поля не ошибка — отвечает нулём.
- **Хэш или JSON** — Меняется по частям — хэш; читается и пишется целиком — JSON-строка. Вложенные структуры в хэш не кладут, TTL только на весь ключ.

Профиль собран, и на его фоне видно, куда двигаться дальше. Поле likes — первый счётчик в курсе, а standalone-счётчики с INCR и их атомарность заслуживают отдельного разговора: следующий урок — про [счётчики и INCR](/courses/go-redis/lessons/09-schetchiki-i-incr/). Хэш сессии, который мы записали под токеном, — заготовка веб-аутентификации: полный цикл входа и выхода на этой конструкции собирается в уроке про [сессии веб-приложения](/courses/go-redis/lessons/21-sessii-web/). Если объект приходит из HTTP-запроса, его удобно связывать с хэшем через биндинг — как это делается, показано в курсе по [Gin](/courses/gin/). Карточки выше — минимальный справочник команд; код урока, как и раньше, выполняется локальным Go с Redis в Docker, песочница сетевых соединений не открывает. Домашнее задание намеренно заставляет удалить ключ и увидеть пустую карту своими глазами: этот крайний случай дешевле поймать в терминале, чем в проде.

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

- Хэш — ключ с картой поле-значение внутри: 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 для случая «пользователя нет».

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

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

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

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

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

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

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