У бота-анкеты из прошлого урока есть скрытый дефект, который всплывает на первой же демоверсии заказчику. Пользователь назвал имя, ответил на вопрос о возрасте — а на шаге города бот спрашивает возраст заново: карта states хранила только номер шага, сами ответы уходили в никуда. Вторая половина дефекта неприятнее: любое обновление на сервере перезапускает процесс, и все, кто стоял посреди диалога, начинают анкету сначала. Это и есть вопрос хранения данных бота — куда девать то, что пользователь рассказал, чтобы оно пережило и рестарт, и вылет, и переход на другой сервер. В этом уроке мы построим два хранилища одно поверх другого: сначала карту со структурой UserData и мьютексом на чтение-запись, затем Redis с ключами, JSON и сроком жизни. Карта остаётся честным учебным каркасом, Redis — тем, что стоит в продакшене, и переезд между ними затронет только пару методов, а не логику диалога.
UserData: одна запись на пользователя#
Десятый урок держал в карте одну строку — имя состояния. Как только данных становится больше, строка превращается в структуру: UserData с полями Step, Name, Age, City. Шаг анкеты переезжает внутрь записи и перестаёт быть отдельной сущностью: состояние и данные живут одной жизнью, потому что меняются одновременно — ответ пользователя и есть причина перехода. Ключом остаётся chat_id типа int64: тот же, что и в NewMessage, тот же, что был в map[int64]string. Такая структура масштабируется без боли: понадобится любимый жанр — телефон или email — добавляем поле, и всё старое продолжает работать. Отдельное поле Updated со временем последней записи выглядит мелочью, но именно оно позже подскажет, какие записи устарели и подлежат чистке; дешевле класть его с первого дня, чем изобретать задним числом.
Хранилище с мьютексом
GOtype 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 не выстраивает их в одну очередь. У бота расклад именно такой: читают состояние на каждом апдейте, плюс горутина напоминаний из девятнадцатого урока будет сканировать всех пользователей раз в минуту, а записывает только тот, кто реально ответил на шаге. Мьютексы с гонками данных подробно разобраны в уроке про мьютексы продвинутого курса — там же про race detector, который стоит прогнать по боту под нагрузкой хотя бы раз. Спорная деталь: в однопоточном учебном боте разницы между Mutex и RWMutex вы не увидите ни в каком бенчмарке — RWMutex здесь задел на будущее, и это честная причина.
Кейс: анкета пишет ответы#
Свяжем хранилище с автоматом из десятого урока. Схема обработчика меняется минимально: вместо работы со строкой состояния мы читаем запись целиком, обновляем поле и сохраняем обратно. Чтение через Get, запись через Save — никакого прямого доступа к карте из обработчика, вся синхронизация живёт внутри Store. Обратите внимание на порядок внутри шага: сначала читаем запись, потом фиксируем ответ, потом меняем Step и сохраняем, и только затем отправляем сообщение. Если отправка упадёт — данные уже сохранены, и повторная попытка не потеряет имя. Обратный порядок — сначала ответить, потом сохранить — создаёт окно, в котором бот уже спросил возраст, но не записал имя: при рестарте пользователь окажется на шаге имени с уже данным ответом.
Шаг анкеты с сохранением ответа
GOcase 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, а JSON-сериализация структур — в уроке про кэш структур. TTL в 24 часа решает заодно проблему брошенных анкет: пользователь, исчезнувший на неделю, вернётся к чистому состоянию, и бот предложит начать заново вместо странного вопроса о возрасте. Логика диалога при переезде не меняется вообще: методы Save и Get сохраняют сигнатуры, меняется их внутренность. Ровно ради этого хранилище и пряталось за интерфейсом — смена бэкенда не должна отражаться на обработчиках.
Хранилище на Redis
GOtype 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. Какой метод замка она должна вызывать?
Проверка: отсутствующий ключ в Redis
Get вернул ошибку redis.Nil. Что это значит для бота?
Что напечатает чтение карты
Прочитайте код и выберите, что он выведет:
type UserData struct {
Name string
Age int
}
users := make(map[int64]UserData)
u := users[42]
fmt.Println(u.Name == "", u.Age, len(users))Защитите чтение и сериализуйте запись
Впишите замок для читателей и функцию упаковки структуры в JSON.
Шесть строк шага анкеты
Расставьте обработчик шага имени: от чтения записи до ответа пользователю.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: хранение данных бота
Теперь у бота есть память, которая переживает диалог: шаги и ответы живут в UserData, а карта при необходимости меняется на Redis без переделки логики. Дальше бот учится говорить картинками: следующий урок отдаёт фото и документы через NewPhoto и file_id, а итоговый проект соберёт трекер привычек уже на постоянном хранилище — прогресс привычек будет жить в Redis по схеме из этого урока. Анкета с сохранением осталась в десятой части курса, глубины по Redis — в курсе go-redis, карта всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Данные анкеты живут в структуре 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 при этом не меняется.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99