---
title: "Хранение данных бота: память и Redis"
description: "Хранение данных бота на Go: карта map[int64]UserData под sync.RWMutex и переезд на Redis — JSON-сериализация, TTL и redis.Nil. Примеры кода tgbotapi и go-redis."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/11-hranenie-dannyh/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 18
---

# Хранение данных бота: память и Redis

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 11 из 25. ~18 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/11-hranenie-dannyh/*

Хранение данных бота решает задачу, которую вскрыл десятый урок: анкета помнит, на каком шаге стоит пользователь, но не помнит его ответы, а перезапуск процесса стирает всё разом. Здесь данные получают постоянное место: сначала карта map[int64]UserData под sync.RWMutex, потом Redis с JSON-сериализацией и TTL — с примерами кода на tgbotapi и go-redis.

У бота-анкеты из прошлого урока есть скрытый дефект, который всплывает на первой же демоверсии заказчику. Пользователь назвал имя, ответил на вопрос о возрасте — а на шаге города бот спрашивает возраст заново: карта states хранила только номер шага, сами ответы уходили в никуда. Вторая половина дефекта неприятнее: любое обновление на сервере перезапускает процесс, и все, кто стоял посреди диалога, начинают анкету сначала. Это и есть вопрос хранения данных бота — куда девать то, что пользователь рассказал, чтобы оно пережило и рестарт, и вылет, и переход на другой сервер. В этом уроке мы построим два хранилища одно поверх другого: сначала карту со структурой UserData и мьютексом на чтение-запись, затем Redis с ключами, JSON и сроком жизни. Карта остаётся честным учебным каркасом, Redis — тем, что стоит в продакшене, и переезд между ними затронет только пару методов, а не логику диалога.

### UserData: одна запись на пользователя

Десятый урок держал в карте одну строку — имя состояния. Как только данных становится больше, строка превращается в структуру: UserData с полями Step, Name, Age, City. Шаг анкеты переезжает внутрь записи и перестаёт быть отдельной сущностью: состояние и данные живут одной жизнью, потому что меняются одновременно — ответ пользователя и есть причина перехода. Ключом остаётся chat_id типа int64: тот же, что и в NewMessage, тот же, что был в map[int64]string. Такая структура масштабируется без боли: понадобится любимый жанр — телефон или email — добавляем поле, и всё старое продолжает работать. Отдельное поле Updated со временем последней записи выглядит мелочью, но именно оно позже подскажет, какие записи устарели и подлежат чистке; дешевле класть его с первого дня, чем изобретать задним числом.

### Хранилище с мьютексом

```go
type UserData struct {
        Step    string
        Name    string
        Age     int
        City    string
        Updated time.Time
}

type Store struct {
        mu    sync.RWMutex
        users map[int64]UserData
}

func NewStore() *Store {
        return &Store{users: make(map[int64]UserData)}
}

func (s *Store) Save(chatID int64, u UserData) {
        u.Updated = time.Now()
        s.mu.Lock()
        s.users[chatID] = u
        s.mu.Unlock()
}

func (s *Store) Get(chatID int64) UserData {
        s.mu.RLock()
        defer s.mu.RUnlock()
        return s.users[chatID]
}
```

Хранилище — структура, а не глобальная карта: мьютекс и данные живут рядом, и обойти защиту случайно невозможно. Save ставит штамп времени и пишет под эксклюзивным замком, Get читает под RLock и возвращает копию записи. Чтение отсутствующего ключа без паники вернёт нулевой UserData — пустая строка в Step означает свободного пользователя, ровно как в десятой части курса.

### sync.RWMutex: читатели и писатели

В прошлом уроке хватало sync.Mutex, и честный вопрос — зачем второй тип замка. sync.RWMutex различает две операции: RLock берут многие читатели одновременно, Lock дожидается, пока все читатели отпустят, и блокирует всех. Экономика простая: если читателей много, а писателей мало, RWMutex не выстраивает их в одну очередь. У бота расклад именно такой: читают состояние на каждом апдейте, плюс горутина напоминаний из девятнадцатого урока будет сканировать всех пользователей раз в минуту, а записывает только тот, кто реально ответил на шаге. Мьютексы с гонками данных подробно разобраны в [уроке про мьютексы продвинутого курса](/courses/go-advanced/lessons/08-muteksy/) — там же про race detector, который стоит прогнать по боту под нагрузкой хотя бы раз. Спорная деталь: в однопоточном учебном боте разницы между Mutex и RWMutex вы не увидите ни в каком бенчмарке — RWMutex здесь задел на будущее, и это честная причина.

### Кейс: анкета пишет ответы

Свяжем хранилище с автоматом из десятого урока. Схема обработчика меняется минимально: вместо работы со строкой состояния мы читаем запись целиком, обновляем поле и сохраняем обратно. Чтение через Get, запись через Save — никакого прямого доступа к карте из обработчика, вся синхронизация живёт внутри Store. Обратите внимание на порядок внутри шага: сначала читаем запись, потом фиксируем ответ, потом меняем Step и сохраняем, и только затем отправляем сообщение. Если отправка упадёт — данные уже сохранены, и повторная попытка не потеряет имя. Обратный порядок — сначала ответить, потом сохранить — создаёт окно, в котором бот уже спросил возраст, но не записал имя: при рестарте пользователь окажется на шаге имени с уже данным ответом.

### Шаг анкеты с сохранением ответа

```go
case stepName:
        u := b.store.Get(chatID)
        u.Name = update.Message.Text
        u.Step = stepAge
        b.store.Save(chatID, u)
        b.reply(chatID, "Сколько вам лет?")

case stepAge:
        u := b.store.Get(chatID)
        u.Age, _ = strconv.Atoi(update.Message.Text)
        u.Step = stepCity
        b.store.Save(chatID, u)
        b.reply(chatID, "В каком городе живёте?")
```

Каждый шаг — четыре строки: прочитали, записали ответ, сдвинули Step, сохранили. Возраст парсится без обработки ошибки ради краткости — в бою проверку strconv.Atoi стоит оставить. Шаг города по той же схеме сохранит City и очистит Step записью UserData без Step: анкета завершена, но данные остались в хранилище.

> **Параллельная запись в карту убивает процесс.** Сценарий, который ждёт каждого: к боту добавляют горутину — напоминания, рассылку, статистику — и та трогает карту без замка. Go не «портит данные тихо», а роняет процесс с fatal error: concurrent map writes, без паники и recover. Ловится это race detector-ом, но лечится единственным способом: все обращения к карте — через методы Store, ни одной прямой записи снаружи.

### Перезапуск стирает всё — переезд на Redis

Карта живёт в оперативной памяти процесса, и её судьба — судьба процесса. Деплой с новой версией, обновление пакета, OOM-убийца на VPS, случайный Ctrl+C в консоли — пользователи, стоявшие посреди анкеты, теряют прогресс, а те, кто уже отвечал, теряют данные целиком. Для учебного бота это терпимо, но у границы есть чёткое правило, которое стоит запомнить сейчас: если потеря данных приводит к тикету от реального пользователя, это уже не кэш, а база, и жить ей нужно вне процесса. Анкета с именем и городом — пограничный случай: обидно, но не катастрофа. А вот заказ, оплата или расчёт кредита — уже катастрофа, и никакие оправдания про «перезапуск был короткий» не работают. Второй предел карты — масштабирование: два инстанса бота держат две разные памяти, и сообщение одного пользователя попадает в разные вселенные в зависимости от того, какой инстанс словил апдейт. Обе проблемы решает вынос хранилища наружу.

Redis — это словарь в отдельном процессе: сетевой сервер, который хранит пары ключ-значение в памяти и переживает рестарты ваших приложений. Для бота схема переезда очевидна: ключ вида bot:answer:100 — префикс проекта, сущность, chat_id — значение, JSON-сериализация UserData, срок жизни 24 часа. Клиент в Go — go-redis v9, его установка и подключение разобраны в [отдельном курсе про Redis](/courses/go-redis/lessons/02-ustanovka-i-podklyuchenie/), а JSON-сериализация структур — в [уроке про кэш структур](/courses/go-redis/lessons/07-kesh-json/). TTL в 24 часа решает заодно проблему брошенных анкет: пользователь, исчезнувший на неделю, вернётся к чистому состоянию, и бот предложит начать заново вместо странного вопроса о возрасте. Логика диалога при переезде не меняется вообще: методы Save и Get сохраняют сигнатуры, меняется их внутренность. Ровно ради этого хранилище и пряталось за интерфейсом — смена бэкенда не должна отражаться на обработчиках.

### Хранилище на Redis

```go
type RedisStore struct {
        rdb *redis.Client
}

func (s *RedisStore) Save(ctx context.Context, chatID int64, u UserData) error {
        data, err := json.Marshal(u)
        if err != nil {
                return err
        }
        key := fmt.Sprintf("bot:answer:%d", chatID)
        return s.rdb.Set(ctx, key, data, 24*time.Hour).Err()
}

func (s *RedisStore) Get(ctx context.Context, chatID int64) (UserData, error) {
        var u UserData
        key := fmt.Sprintf("bot:answer:%d", chatID)
        data, err := s.rdb.Get(ctx, key).Bytes()
        if errors.Is(err, redis.Nil) {
                return u, nil
        }
        if err != nil {
                return u, err
        }
        err = json.Unmarshal(data, &u)
        return u, err
}
```

Save сериализует UserData в JSON и кладёт командой SET с TTL 24 часа: срок обновляется при каждом ответе, пока пользователь активен. Get читает байты и разворачивает их обратно. redis.Nil — не сбой, а признак отсутствующего ключа: пользователь ещё не заполнял анкету, и это норма, возвращаем пустую структуру без ошибки. Настоящие ошибки — сеть, недоступный сервер — идут наверх честно.

> **Что тренировать в песочнице, а что локально.** Карта, RWMutex, json.Marshal и чтение отсутствующего ключа — чистый Go: всё это проигрывается в песочнице урока без сети. Полный переезд на Redis требует локального Go, запущенного Redis — docker run -p 6379:6379 redis хватает — и реального токена бота из урока про BotFather.

### Карта против Redis: где что живёт

| Критерий | map с RWMutex | Redis |
| --- | --- | --- |
| Перезапуск бота | данные потеряны | данные на месте |
| Два инстанса бота | две разные памяти | общее хранилище |
| Срок жизни | вручную, поле Updated | TTL силами Redis |
| Зависимости | нет | клиент go-redis и сервер |
| Когда уместно | учебный бот, кэш | продакшен, деньги, заказы |

### Спорное мнение: памяти хватит надолго

Контринтуитивная позиция, которую я отстаиваю: если бот учебный и живёт на вашем ноутбуке, карта с мьютексом — не временный костыль, а легитимное решение на месяцы. Пара десятков пользователей, анкета на три поля, перезапуск руками — цена потери данных ниже цены настройки Redis, и честнее признать это, чем тащить инфраструктуру ради галочки. Граница, где мнение сдаётся, конкретна: первый деплой на сервер, где перезапуски идут без вашего ведома; первая чужая оплата через бота; второй инстанс. До любого из этих событий карта — рабочий выбор. Фраза «потом перенесём» становится реальностью только при одном условии, и оно уже выполнено в этом уроке: хранилище спрятано за парой методов, и переезд не переписывает обработчики. Без такой изоляции «потом» превращается в переписывание половины бота.

### Проверка: замок для чтения

**Вопрос:** Горутина напоминаний раз в минуту читает всех пользователей Store. Какой метод замка она должна вызывать?

- Lock — эксклюзивно, надёжнее
- ✅ RLock — разрешает параллельные читатели
- Без замка: чтение карты безопасно
- Unlock — читатели только отпускают

Для читателей у sync.RWMutex есть RLock: несколько горутин могут читать одновременно. Lock нужен тем, кто пишет. Чтение без замка при живых писателях — та самая гонка данных, из-за которой бот падает с fatal error под нагрузкой.

### Проверка: отсутствующий ключ в Redis

**Вопрос:** Get вернул ошибку redis.Nil. Что это значит для бота?

- Сервер Redis недоступен
- JSON-сериализация сломалась
- ✅ Ключа нет — пользователь не заполнял анкету
- TTL истёк, нужно перезапустить бота

redis.Nil — специальное значение, обозначающее отсутствие ключа, штатная ситуация для нового пользователя. Обрабатывать её надо отдельно: вернуть пустую UserData и начать анкету, а не писать панику в логи.

### Что напечатает чтение карты

```go
type UserData struct {
        Name string
        Age  int
}

users := make(map[int64]UserData)
u := users[42]
fmt.Println(u.Name == "", u.Age, len(users))
```

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

- ✅ true 0 0
- true 0 1
- false 0 0
- Паника при чтении отсутствующего ключа

Чтение отсутствующего ключа не паникует и не создаёт запись: возвращается нулевое значение структуры — пустая строка и ноль. Карта при этом остаётся пустой, len равен 0. Именно эта механика позволяет считать пустую Step признаком свободного пользователя.

### Защитите чтение и сериализуйте запись

Впишите замок для читателей и функцию упаковки структуры в JSON.

```go
func (s *Store) Get(chatID int64) UserData {
        s.mu.___()
        defer s.mu.RUnlock()
        return s.users[chatID]
}

data, err := json.___(u)
if err != nil {
        return err
}
```

Подсказка: Читатели берут RLock и отпускают RUnlock; структура в байты превращается функцией json.Marshal.

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

### Шесть строк шага анкеты

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

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

1. `case stepName:`
2. `u := b.store.Get(chatID)`
3. `u.Name = update.Message.Text`
4. `u.Step = stepAge`
5. `b.store.Save(chatID, u)`
6. `b.reply(chatID, "Сколько вам лет?")`

### Карточки: хранение данных бота

- **UserData** — Структура записи пользователя: Step — текущий шаг анкеты, поля ответов, Updated — штамп времени последней записи.
- **sync.RWMutex** — Замок с двумя режимами: RLock — много читателей параллельно, Lock — эксклюзивная запись. Выигрывает, когда читают чаще, чем пишут.
- **Store** — Хранилище-структура, прячущее карту и мьютекс за методами Save и Get. Переезд на Redis меняет внутренности, а не обработчики.
- **redis.Nil** — Ответ go-redis на отсутствующий ключ: не ошибка, а признак нового пользователя. Проверяется через errors.Is.
- **TTL анкеты** — Срок жизни ключа, например 24 часа: брошенная анкета исчезает сама, вернувшийся пользователь начинает заново.
- **concurrent map writes** — Fatal error при параллельной записи в map: процесс умирает без recover. Лечится только мьютексом и единственной точкой записи.

Теперь у бота есть память, которая переживает диалог: шаги и ответы живут в UserData, а карта при необходимости меняется на Redis без переделки логики. Дальше бот учится говорить картинками: [следующий урок](/courses/telegram-bot/lessons/12-media-i-fajly/) отдаёт фото и документы через NewPhoto и file_id, а [итоговый проект](/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/) соберёт трекер привычек уже на постоянном хранилище — прогресс привычек будет жить в Redis по схеме из этого урока. Анкета с сохранением осталась в [десятой части курса](/courses/telegram-bot/lessons/10-sostoyanie-dialoga/), глубины по Redis — в [курсе go-redis](/courses/go-redis/), карта всех двадцати пяти уроков — на [странице курса](/courses/telegram-bot/).

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

- Данные анкеты живут в структуре UserData: шаг диалога — просто одно из её полей.
- Карта map[int64]UserData прячется за Store с sync.RWMutex: читатели берут RLock, писатели — Lock.
- Все обращения к карте — только через методы хранилища: параллельная запись вне замка роняет процесс с fatal error.
- Память процесса стирается при каждом перезапуске: потеря данных, приводящая к тикету, — сигнал выносить хранилище наружу.
- Redis-хранилище использует ключ bot:answer:chatID, JSON-сериализацию и TTL 24 часа.
- redis.Nil — не ошибка, а новый пользователь: пустая UserData и анкета с начала.
- Карта в учебном боте — легитимный выбор до первого деплоя; изоляция за методами Save и Get делает переезд дешёвым.

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

Соберите локально бота-анкету на хранилище из урока: Store с UserData и RWMutex, шаги имя-возраст-город с сохранением ответов. Проверьте три сценария: /help посреди анкеты не ломает шаги, рестарт процесса стирает карту, а после переезда на Redis — docker run redis — данные переживают рестарт, и через сутки ключ исчезает сам.

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

### Почему не sync.Map, если пользователей много?

sync.Map рассчитан на кэши, где пишут редко, а читают из многих горутин. У бота запись на каждый ответ пользователя, и обычный RWMutex с явными методами проще отлаживать. Замеры на нескольких тысячах записей разницы не показывают.

### Хранить шаг и данные в одной структуре или отдельно?

Вместе: шаг и ответы меняются атомарно, одной записью Save. Разделение заставляет держать две карты и думать об их согласованности — а несогласованность здесь и есть баг вида «бот спросил возраст, но имя потерял».

### Что выбрать вместо Redis, если данных много?

Postgres или SQLite: Redis держит горячие данные в памяти, для истории анкет и заказов нужна база. Частая схема — оба: Redis для текущего состояния диалога, база для финальных ответов. Механика Save и Get при этом не меняется.
