До этого урока бот был реактивным: пришёл апдейт — отреагировал, тишина — сидит ждёт. Напоминание ломает модель: сообщение должно прийти тогда, когда пользователь ничего боту не пишет, а значит источника события нужно два. Первый — цикл GetUpdates или webhook, из уроков про polling и webhook, он работает как работал. Второй — собственная горутина с таймером, запущенная рядом в main: она не слушает Telegram, она слушает часы. Каждые десять-пятнадцать секунд она сверяет хранилище напоминаний с текущим временем и отправляет всё, что созрело. Архитектура примитивная и именно поэтому живучая: две горутины, одна общая структура данных и один мьютекс. В уроке пройдём от структуры Reminder до тайм-зон, а по пути разберём две ошибки, которые на этом пути делают почти все — рассылку под мьютексом и тикер без Stop.
Хранилище напоминаний: map[int64][]Reminder с мьютексом#
Напоминание — это получатель, текст и момент срабатывания: структура Reminder с полями ChatID int64, Text string и At time.Time. Хранить удобнее картой чатов: map[int64][]Reminder — на каждый chat_id свой срез, добавление копеечное, чистка сработавших не трогает чужие чаты. Карта общая для двух горутин: цикл апдейтов пишет в неё команду /remind, тикерная горутина читает и удаляет. Голая карта в Go так не умеет: одновременные запись и чтение дают фатальную ошибку concurrent map read and map write, которая убивает весь процесс и не ловится recover — бот падает целиком, а не отдельная функция. Поэтому структура Reminders оборачивает карту в sync.Mutex: методы Add, Due и любые будущие операции захватывают лок на всю работу с картой. RWMutex здесь не спасёт: тикерная горутина и так каждый тик модифицирует карту, вырезая сработавшее, — читателей без записи почти нет.
Структура хранилища и добавление
GOtype Reminder struct {
ChatID int64
Text string
At time.Time
}
type Reminders struct {
mu sync.Mutex
byChat map[int64][]Reminder
}
func NewReminders() *Reminders {
return &Reminders{byChat: make(map[int64][]Reminder)}
}
func (r *Reminders) Add(chatID int64, text string, at time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
r.byChat[chatID] = append(r.byChat[chatID],
Reminder{ChatID: chatID, Text: text, At: at})
}Мьютекс лежит рядом с данными, которые защищает, — так невозможно забыть, к чему он относится. Add захватывает лок и defer-ом отпускает: паника при вычислении не оставит замок захваченным. Внешний код не видит карту вообще — весь доступ идёт через методы, и гонки исключены по построению, а не по дисциплине вызывающего.
time.Ticker в фоновой горутине#
Тикер — это таймер с повторением: time.NewTicker(interval) возвращает указатель на Ticker с каналом C, в который раз в interval прилетает текущее время. Читаем канал в select вместе с ctx.Done — так горутина одновременно ждёт и тика, и команды завершиться, а по закрытию контекста просто выходит. Классическая ошибка вместо тикера — цикл со time.Sleep: он не отменяем посреди паузы, и сигнал завершения придётся ждать до конца сна, а его интервал дрейфует — к длительности паузы прибавляется время обработки, и тики постепенно съезжают. Тикер держит ритм независимо от того, сколько занимает рассылка. Частота выбирается по желаемой точности: напоминания с точностью до минуты не требуют тика чаще, чем раз в десять-пятнадцать секунд, — и экономят запросы. defer ticker.Stop() ставится сразу после создания: пропущенный Stop в долгоживущем процессе — это таймер, который продолжает тикать в канал, который никто не читает.
Цикл напоминаний в горутине
GOfunc reminderLoop(ctx context.Context, bot *tgbotapi.BotAPI, store *Reminders) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, rem := range store.Due(time.Now()) {
msg := tgbotapi.NewMessage(rem.ChatID,
"Напоминание: "+rem.Text)
_, _ = bot.Send(msg)
}
}
}
}
// в main:
go reminderLoop(ctx, bot, reminders)Горутина запускается одним словом go рядом с созданием бота и живёт своей жизнью, пока контекст не отменят. Select на двух каналах делает выход мгновенным: сигнал в ctx.Done выбирается даже посреди ожидания тика. Send идёт после выхода из store.Due — по уже отпущенному замку; ошибка отправки логируется или молча глотается, но не паникует: паника в горутине убивает весь процесс.
Ключевой метод — Due(now time.Time) []Reminder: он возвращает всё, чей момент наступил, и вырезает это из карты. Под локом один проход по карте: у каждого чата срез делится на сработавшее и оставшееся, пустые чаты удаляются delete-ом, чтобы карта не разбухала навсегда. Лок отпускается, и только потом начинается отправка. Разделение «собрать под замком — отправить без замка» — самое важное правило урока, и следующая врезка объясняет, что происходит, когда его нарушают. Здесь же живёт политика повторов: если bot.Send упал из-за сети, простое решение — вернуть напоминание в карту с небольшим сдвигом вперёд, а не терять его молча. Проверка !rem.At.After(now) срабатывает на секунду или позже — при тикере в пятнадцать секунд напоминание опоздает максимум на пятнадцать, и для «позвонить маме» это приемлемая цена.
Выборка сработавших под замком
GOfunc (r *Reminders) Due(now time.Time) []Reminder {
r.mu.Lock()
defer r.mu.Unlock()
var due []Reminder
for chatID, rems := range r.byChat {
var kept []Reminder
for _, rem := range rems {
if !rem.At.After(now) {
due = append(due, rem)
continue
}
kept = append(kept, rem)
}
if len(kept) == 0 {
delete(r.byChat, chatID)
continue
}
r.byChat[chatID] = kept
}
return due
}Проход по карте собирает два результата сразу: срез к отправке и вычищенная версия хранилища. Все изменения происходят под замком, наружу возвращается обычный срез — с ним горутина отправки работает без всякой синхронизации, потому что данные уже никому не принадлежат внутри хранилища. Итерация по map в Go случайна по порядку — для напоминаний это неважно.
Вариант с минимальными правками, который делает почти каждый: вызвать bot.Send прямо внутри Due, под локом. Пока идёт HTTP-запрос к Telegram API — сотни миллисекунд в норме, десятки секунд при их лагах, — замок держится, и каждый входящий апдейт в основном цикле висит на mu.Lock. Бот не падает, он замерзает: сообщения отвечают с задержкой, команда /remind не принимается, а при недоступности Telegram замирает целиком. Вторая вариация — паника в тикерной горутине: она не ловится в основном цикле и убивает процесс целиком. Лечение одной строкой: собирать сработавшее под замком, отправлять после — и держать в цикле recover, если текст напоминания приходит от пользователей и ему нельзя доверять.
Тайм-зона пользователя: чьих девять утра#
Пользователь пишет «напомни в 9:00» — но его девять и ваши девять не обязаны совпадать, и сервер вообще может стоять в UTC. Хранить момент нужно одним эталоном: At в UTC, а рядом с пользователем — его зона: поле в профиле, явная команда /tz или значение из настроек клиента, если вы делаете Mini App. Парсинг ввода идёт в зоне пользователя: time.ParseInLocation с шаблоном 15:04 и локацией строит момент уже с учётом смещения, после чего хранится в UTC. Загрузка зоны функцией time.LoadLocation с именем вроде Europe/Moscow читает системную базу IANA, которой нет в пустых контейнерах и scratch-образах; решение — импорт пакета time/tzdata с пустым именем в main, он вшивает копию базы в бинарник. Осторожно с переводом часов: напоминание «ежедневно в 9» честнее пересчитывать в момент каждого срабатывания из зоны пользователя, чем вычислять раз и навсегда, — иначе после смены смещения оно уползёт на час.
Карта в памяти честно умирает вместе с процессом: перезапустили бота — все напоминания испарились, и пользователи узнают об этом из собственных обид. Поэтому хранилище делает выгружаемым: при добавлении записать в Redis или базу, при старте прочитать всё обратно и наполнить карту — схема перехода от карты к Redis разобрана в уроке про хранение данных бота. Тикерная рассылка подчиняется тем же лимитам Telegram, что и ручная: сотни чатов, сработавших в один тик, нельзя лить без пауз — API ответит 429 с RetryAfter, и вежливая очередь с задержками из уроков про рассылки и про лимиты переносится сюда как есть. Команда /remind при этом остаётся тривиальной: CommandArgs достаёт строку вида 18:30, ParseInLocation строит момент, Add кладёт в хранилище, бот подтверждает ответом — весь диалог умещается в двадцать строк.
Чем будит время: четыре подхода#
| Подход | Как работает | Когда брать |
|---|---|---|
| time.Sleep в цикле | Пауза после каждой итерации | Скрипты; для бота плохо: не отменяем посреди сна, интервал дрейфует |
| time.Ticker | Канал с регулярными тиками, select с ctx.Done | Наш выбор: точный ритм, мгновенная остановка, одна горутина |
| time.AfterFunc | Одноразовый таймер на каждое напоминание | Десятки разовых напоминаний; на тысячах — сотни живых таймеров в памяти |
| Внешний планировщик | cron или очередь с отложенными задачами | Расписания, повторения и несколько инстансов бота |
Понадобится локальный Go и токен бота из BotFather в переменной BOT_TOKEN. Поставьте интервал тикера в три секунды и добавьте напоминание через полминуты: /remind через команду с моментом time.Now().Add(30 * time.Second). Сообщение придёт без единого действия с вашей стороны — лучший способ почувствовать разницу между реактивным ботом и сервисом, который сам помнит. Потом верните пятнадцать секунд и добавьте загрузку из Redis.
Проверка: общая карта
Цикл апдейтов пишет в map[int64][]Reminder, тикерная горутина пишет в неё же. Мьютекса нет. Что случится?
Проверка: что делает Stop
Что делает ticker.Stop() — и чего он не делает?
Медленная рассылка против тикера
Прочитайте код и выберите, что он выведет:
ticker := time.NewTicker(1 * time.Second)
go func() {
for range ticker.C {
fmt.Println("тик")
time.Sleep(5 * time.Second) // долгая рассылка
}
}()
time.Sleep(12 * time.Second)Соберите цикл напоминаний
Впишите конструктор тикера, остановку и ветки select.
Соберите тик тикера
Расставьте строки: от создания тикера до отправки сообщения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: напоминания
Бот научился действовать сам, без апдейта — это водораздел между скриптом и сервисом. Дальше маршрут такой: следующий урок — обзорная карта платежей и Telegram Stars, если боту пора зарабатывать; урок про деплой — systemd и автозапуск, после которого вопрос выживания карты напоминаний при рестарте станет практическим. Фундамент этого урока лежит в одиннадцатом уроке про хранение данных — карта с мьютексом оттуда, — а вежливые паузы при массовой отправке описаны в уроке про рассылки и уроке про лимиты. Механику горутин и каналов, на которой всё держится, с нуля разбирает курс go-advanced — горутины, каналы и time с таймерами. Полный маршрут — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Напоминания — первая фоновая задача бота: горутина с time.Ticker живёт рядом с циклом апдейтов и слушает часы.
- Хранилище — map[int64][]Reminder под sync.Mutex: гонка на общей карте — фатальная ошибка всего процесса.
- Select на ticker.C и ctx.Done даёт точный ритм и мгновенную остановку; defer ticker.Stop() — сразу после создания.
- Due собирает сработавшее под замком и вырезает из карты; отправка — строго после выхода из лока.
- Время хранится в UTC, ввод парсится в зоне пользователя; в контейнерах база зон вшивается импортом time/tzdata.
- Карта умирает при рестарте: под неё закладывается загрузка из Redis, а рассылка подчиняется лимитам Telegram.
Домашнее задание
Соберите бот-будильник: хранилище Reminders с Add и Due, горутину reminderLoop с тикером в пятнадцать секунд и контекстом, команду /remind 18:30 текст с ParseInLocation в вашей зоне. Убедитесь, что бот шлёт напоминание, когда вы ничего не пишете. Затем сломайте его осознанно: перенесите bot.Send внутрь Due под локом и добавьте искусственную задержку — посмотрите, как замрут ответы на команды. Верните отправку после лока, добавьте загрузку напоминаний из Redis при старте и проверьте, что рестарт процесса не теряет записи.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему не time.AfterFunc на каждое напоминание?
AfterFunc отлично работает для десятков разовых таймеров: не нужен ни тикер, ни сверка времени. Но таймеры живут в памяти и не видны при отладке, на тысячах напоминаний их количество перестаёт быть бесплатным, а общий тикер даёт одно место для логов, повторов и лимитов. Переход между схемами дешёвый, пока Due и Add вынесены в хранилище.
Как быть, если напоминание должно сработать через месяц?
Так же: момент хранится в UTC, тикер сверяет его каждые пятнадцать секунд — долго живущие записи просто дольше ждут в карте. Реальный риск не в ожидании, а в рестарте процесса: месяц — это гарантированный перезапуск, поэтому под хранилище сразу закладывается Redis или база.
Что делать с напоминанием, если Telegram был недоступен и Send упал?
Вернуть напоминание в хранилище со сдвигом на интервал тикера или чуть больше — оно уйдёт со следующим тиком. Потеря напоминания для пользователя незаметна, а вот дубль — заметен, поэтому повтор без подтверждения доставки лучше одной честной попытки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Платежи: инвойсы и Telegram Stars
- Mini Apps: веб-интерфейс внутри Telegram
- Реакции, стикеры и живой тон бота
- Тестирование бота: хендлеры без Telegram
- Webhook: бот как HTTP-сервер
- Ошибки и лимиты Telegram: 429 и RetryAfter
- Редактирование сообщений и многостраничное меню
- Inline-режим: бот в поле поиска
- Все курсы по Go
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99