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

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

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

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

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

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

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

У бота-анкеты из прошлого урока есть скрытый дефект, который всплывает на первой же демоверсии заказчику. Пользователь назвал имя, ответил на вопрос о возрасте — а на шаге города бот спрашивает возраст заново: карта 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 не выстраивает их в одну очередь. У бота расклад именно такой: читают состояние на каждом апдейте, плюс горутина напоминаний из девятнадцатого урока будет сканировать всех пользователей раз в минуту, а записывает только тот, кто реально ответил на шаге. Мьютексы с гонками данных подробно разобраны в уроке про мьютексы продвинутого курса — там же про 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: анкета завершена, но данные остались в хранилище.

Перезапуск стирает всё — переезд на 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

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 — не сбой, а признак отсутствующего ключа: пользователь ещё не заполнял анкету, и это норма, возвращаем пустую структуру без ошибки. Настоящие ошибки — сеть, недоступный сервер — идут наверх честно.

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

Критерийmap с RWMutexRedis
Перезапуск ботаданные потеряныданные на месте
Два инстанса ботадве разные памятиобщее хранилище
Срок жизнивручную, поле UpdatedTTL силами 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.

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 }

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

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

Строки

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

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

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

Термин

UserData

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

Структура записи пользователя: Step — текущий шаг анкеты, поля ответов, Updated — штамп времени последней записи.

1 / 6

Теперь у бота есть память, которая переживает диалог: шаги и ответы живут в 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 — данные переживают рестарт, и через сутки ключ исчезает сам.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Хранение данных бота: память и Redis)

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

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

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

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

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

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

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

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

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

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

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99