Проектирование системы отложенных уведомлений со склейкой: от требований до продакшена
- суббота, 21 февраля 2026 г. в 00:00:15
Всем привет! Меня зовут Евгений Федосеев, я инженер-программист в компании iSpring.
В рамках развития платформы дистанционного обучения iSpring Learn перед нами стояла задача реализовать систему уведомлений для модуля «Планы развития». Это инструмент для составления и контроля индивидуальных траекторий роста сотрудников. Такой модуль по своей природе генерирует множество событий: назначение планов, комментарии к задачам, дедлайны. Без продуманной системы уведомлений пользователи (сотрудники, наставники, администраторы) рискуют утонуть в потоке сообщений, пропуская важную информацию.
Нам нужна была система, которая:
Не теряет и не дублирует уведомления, даже, если какой-то сервис упал.
Группирует сообщения интеллектуально (по задачам, по веткам комментариев, по пользователям).
Доставляет их с разными интервалами (комментарии — через 5 минут, просрочки — через 15).
Обеспечивает наблюдаемость.
Позволит в будущем быстро внедрять новые виды уведомлений.
Мы столкнулись с классической дилеммой: как обеспечить своевременное информирование, не превратив его в спам? Решением стала система отложенных уведомлений со склейкой (batching), которая группирует однотипные события в течение заданного интервала и отправляет их одним сводным сообщением.
В этой статье я расскажу, как мы проектировали эту систему, какие архитектурные решения приняли для обеспечения гарантии доставки и наблюдаемости, и как этот механизм успешно работает в продакшене.
В модуле «Планы развития» мы выделили несколько ключевых событий, о которых необходимо уведомлять пользователей. Для каждого типа были определены свои правила доставки и, что критически важно, логика группировки (склейки).
В таблице ниже представлена сводная информация:

Такое представление позволило нам представить модель следующим образом.
Модель назвали PlannedTask и выделили в ней следующие поля:
GroupID (UUID) — ключ группировки (самый важный элемент). Определяет, какие события должны быть объединены в одно уведомление. Значение зависит от бизнес-логики
TaskType (ENUM) — тип задачи, по которому определяется логика склейки и интервал ожидания.
Payload (JSON) содержит все данные, необходимые для формирования финального уведомления и его склейки. Это сериализованный объект, структура которого зависит от TaskType.
PlannedTime: TIMESTAMP — планируемое время отправки. Вычисляется как время_создания_записи + интервал_склейки. Это не жесткий дедлайн, а правило «не ранее чем».
Механика: Планировщик выбирает для обработки только те записи, у которых planned_time <= текущее_время. Это гарантирует, что события будут «выдерживать нужный интервал для возможности склейки.
Система периодически выбирает все записи с одинаковым group_id и объединяет их.
Помимо бизнес-логики (что склеивать и кому отправлять), перед нами стояли ключевые инженерные вызовы. Их решение определило архитектуру и выбор технологий.
Система должна минимизировать потери уведомлений. Одно событие, требующее уведомления, не должно быть безвозвратно утеряно из-за сбоев в работе приложения, БД или сети.
Критерии выполнения:
Сохранение состояния. Каждое событие сначала должно быть персистентно сохранено до подтверждения его финальной доставки.
Идемпотентность обработки. Повторная обработка одного и того же события (из-за ретрая) не должна приводить к дублирующим уведомлениям для пользователя. Склейка по group_id частично решает эту проблему.
Механизм повторных попыток (Retry). Автоматические повторные попытки отправки при временных сбоях (недоступность сервиса нотификаций, сетевые ошибки).
Система должна быть прозрачна для разработчиков и команды эксплуатации. Мы должны знать её состояние в реальном времени и иметь возможность быстро диагностировать проблемы. Также мы должны иметь систему оповещений о некорректном поведении системы.
Архитектура должна позволять проводить надежное и изолированное тестирование всех сценариев, особенно связанных с таймингами и конкурентностью. Возможность покрыть юнит- и интеграционными тестами ключевую логику склейки, обработки ошибок и работы планировщика.
Необходимо было решить, как мы будем организовывать очередь уведомлений. Нужно было понять, где будут храниться записи и как будет происходить отправка уведомлений.
Было принято решение хранить записи об уведомлениях в базе данных, чтобы обеспечить надежность хранения данных до отправки уведомлений.
Сценарии в нашей системой завершаются отправкой доменного события.

Обработчик вызывает сервис, который формирует groupID, обогащает данными сообщения и сохранения в БД.

Для того, чтобы сгруппировать сообщения и отправить их в сервис был выделен отдельный планировщик.

В данной схеме, все хорошо до момента, когда произойдет отказ сервиса Notification.
Тогда мы задумались как сделать повтор отправки. Можно было пойти путем добавления служебных записей в таблицу planned_process. В таком случае пришлось бы поддерживать переход из состояний в приложении. Также было бы сложно организовать мониторинг.
Мы пошли другим путем. Мы завели очереди в Rabbit для каждого вида задач и отправляли события туда. Таким образом Rabbit обеспечил для нас гарантию доставки at-least-once, а идемпотентность мы достигаем за счет наличия groupID. Данный подход также позволил нам из коробки получить мониторинг за состоянием очереди, так как у нас уже настроены алерты на сбои в очередях Rabbit.
Асинхронный планировщик собирает данные за период, склеивает и отправляет в отдельную очередь.
Сообщения из очереди слушает отдельный обработчик, который формирует финальное уведомление и передаёт его в сервис уведомлений.

На уровне приложения был реализован базовый отправитель, который получает от провайдера сообщения за необходимый период, вызывает функцию склейки и отправляет результат в интерфейс транспорта, за которым скрывает Rabbit. После подтверждения от транспорта об успешной доставке

Для того, чтобы обеспечить расширяемость необходимо было продумать как отделить логику группировки конкретных уведомлений от общей логики отправки. Для отправителя была сделана следующая структура.
type spliceSender struct { plannedProcessService service.PlannedProcessService plannedProcessProvider provider.PlannedProcessProvider transport Transport spliceProcessesFunc func(processes []model.PlannedProcess) model.PlannedProcess sendingDelay time.Duration currentDateProvider domainservice.CurrentDateProvider } func (p *spliceSender) send(ctx context.Context, processType model.ProcessType) error
Для всех видов уведомлений был сделан один общий отправитель. Для кастомизации под конкретную реализацию выделена функция spliceProcessesFunc, в рамках которой склеиваются объекты.
CurrentDateProvider — обертка над пакетом time, позволяющая подменять время на моковое в рамках функционального тестирования.
func (p *spliceSender) send( ctx context.Context, processType model.ProcessType, ) error { processes, err := p.plannedProcessProvider.ListPlannedProcesses( ctx, provider.ListProcessesSpec{ ProcessType: maybe.NewJust(int(processType)), BeforeTime: maybe.NewJust(p.currentDateProvider.GetCurrentDate()), }) if err != nil || len(processes) == 0 { return err } return p.sendToTransport(ctx, processes) }
В функции send выбираются все уведомления заданного типа с планируемым временем не позже текущего момента, после чего они передаются на транспортный слой.
func (p *spliceSender) sendToTransport( ctx context.Context, processes []model.PlannedProcess, ) error { groupedProcesses := slices.GroupBy( processes, func(process model.PlannedProcess) string { return process.GroupID }) spliceProcesses := p.spliceReadyProcesses(groupedProcesses) confirmedIDs, err := p.transport.Send(ctx, spliceProcesses) if err != nil { return err } return p.removeProcesses(ctx, confirmedIDs, spliceProcesses, groupedProcesses) }
Перед отправкой в транспортный слой сообщения группируются по ключу идентичности. После получения подтверждения об успешной обработке соответствующие записи удаляются.
func (p *spliceSender) spliceReadyProcesses( groupedProcesses map[string][]model.PlannedProcess, ) []model.PlannedProcess { sendingProcesses := make([]model.PlannedProcess, 0, len(groupedProcesses)) for _, processesGroup := range groupedProcesses { if !p.readyToSend(processesGroup) { continue } sendingProcesses = append(sendingProcesses, p.spliceProcessesFunc(processesGroup)) } return sendingProcesses } func (p *spliceSender) readyToSend(processes []model.PlannedProcess) bool { minProcess := stdslices.MinFunc(processes, func(a, b model.PlannedProcess) int { return int(a.PlannedTime.UnixMilli() - b.PlannedTime.UnixMilli()) }) return p.currentDateProvider.GetCurrentDate().Sub(minProcess.PlannedTime) >= p.sendingDelay }
Для каждой группы мы проверяем готовность к отправке: с момента сохранения самого раннего элемента должно пройти больше времени, чем заданный интервал склейки.
После того, как были отсеяны группы, не готовые к отправке, вызываем функцию склейки элементов.
Для запуска запланированных отправок был реализован внутренний шедулер, работающий по тикеру.
func (planner *basePlanner) Start() { ticker := time.NewTicker(planner.dispatchDelay) var ctx context.Context ctx, planner.cancelFunc = stdcontext.WithCancel(stdcontext.Background()) planner.waitGroup.Add(1) go func() { for { select { case <-ticker.C: err := planner.sendFunc(ctx) if err != nil { planner.errorsChan <- err } case <-ctx.Done(): planner.waitGroup.Done() return } } }() } func (planner *basePlanner) Stop() { planner.cancelFunc() planner.waitGroup.Wait() }
Разработанная нами система отложенных уведомлений со склейкой успешно решает классическую проблему информационного шума в корпоративных продуктах. Реализация в рамках модуля «Планы развития» для iSpring Learn показала, что даже такая, казалось бы, простая задача требует тщательного проектирования с учётом ключевых нефункциональных требований.
Интеллектуальную доставку уведомлений — пользователи получают сводные сообщения вместо потока однотипных алертов, что повышает удобство работы с системой.
Надёжную архитектуру — комбинация реляционной БД для хранения состояния, внутреннего планировщика и RabbitMQ для гарантированной доставки обеспечила выполнение требования «at-least-once» и отказоустойчивость системы.
Полную наблюдаемость — благодаря структурированным логам, метрикам в Prometheus и алертингу через Zabbix мы получили прозрачную систему, состояние которой легко мониторить и диагностировать.
Гибкую и расширяемую модель — добавление нового типа уведомления свелось к определению его бизнес-логики, интервала склейки и правил группировки через group_id. Архитектура с абстракциями и Dependency Injection позволила легко тестировать все сценарии.
Система работает в продакшене более полугода, обрабатывая тысячи событий ежедневно. За это время мы не столкнулись с потерянными уведомлениями или ложными срабатываниями алертов. Главный показатель успеха — отсутствие жалоб от пользователей на спам, при этом все важные события доходят вовремя.
Предложенная архитектура — это практический пример того, как можно решить распространённую проблему с помощью проверенных технологий, не прибегая к излишне сложным решениям. Мы надеемся, что наш опыт поможет другим командам в реализации подобных систем.