Предыстория
Я — Web разработчик в команде CodeX @e11sy
Я работал над переписыванием системы уведомлений open-source трекера ошибок Хоук. Он отлавливает ошибки в ПО и присылает уведомления разработчикам. Исходная реализация была простой и не масштабируемой, что приводило к задержкам получения уведомлений.

Статья — про то, как я решал эту проблему. Как определял, когда действительно стоит отвлекать разработчика уведомлениями, как обрабатывать большие объемы событий и как построить воркера, который не съест все ресурсы.
Сразу поясню, что в статье будут опущены моменты, связанные с доставкой сообщений, только разработка архитектуры.
Проблемы изначальной реализации
Хоук — это трекер ошибок с миллионами обрабатываемых событий в час. Каждое событие может быть важным. О таких событиях надо уведомлять разработчиков.
Сначала система поддерживала два типа уведомлений:
- Уведомления обо всех событиях 
- Уведомления о новых ошибках 
Никакой гибкости. Это или постоянный спам, или отсутствие уведомлений, когда приходят уже встречавшиеся ошибки.

Помимо продуктовых проблем существовала и проблема производительности, так как микросервис уведомлений (воркер) не масштабировался горизонтально. Причина этому — реализация группировки сообщений в памяти одного процесса. Она нужна, чтобы вместо сотни уведомлений о ста событиях прислать одно общее.
Новый подход
Я ввел два типа уведомлений:
- О новых ошибках 
- О критических событиях 
Главное изменение — это возможность пользователю самому задавать параметры критичности:
«Считай ошибку критичной, если она случилась 10 000 раз за минуту»
Количество событий — threshold, а период времени — thresholdPeriod
Такую конфигурацию можно задать на уровне проекта, и она применяется для всех входящих событий.
Система сценариев
Для каждого проекта конфигурируется система правил, по которым уведомления можно группировать и распределять по каналам связи, например новые уведомления со словами database, 500, internal — в телеграм чат, а со словом authorization на почту

Сценарий включает
- Канал связи (email, Telegram или Slack webhook) 
- Тип уведомления (новое / критическое) 
- Фильтры (ошибки с разными словами можно разделять по сценариям) 
Таким образом можно распределять уведомления по каналам в зависимости от критичности или содержания.
Использование сценариев:
- Воркер получает событие → определяет проект → достает все сценарии из базы. 
- Считает количество повторений события для каждого сценария. 
- Если счетчик превышает - threshold— отправка уведомления.
Архитектура и стек
Существенно менять технологии не пришлось.
Текущий стек:
- TypeScript — воркеры и логика 
- RabbitMQ — брокер очередей 
- Redis — агрегатор временных данных 
- MongoDB — постоянное хранилище 
Для масштабируемости все воркеры должны обращаться к общему хранилищу. Если воркер будет агрегировать данные в своей памяти, то ни один воркер не будет видеть общую картину для проекта.
Redis оказался отличным выбором для подсчета количества событий:
Я выбрал структуру хэш, потому что все нужные операции (hset, hget и hincrby) выполняются за O(1). Также хэши позволяют хранить структуру в качестве value, мне это нужно для хранения счетчика и точки отсчета для одного ключа.
- Ключ — определяет принадлежность к проекту (project), сценарию (rule), событию (event) и промежутку времени (thresholdPeriod) - ${projectId}:${ruleId}:${eventId}:${thresholdPeriod}:times
Также мы заложили фундамент под разные типы счетчиков. У ключа есть суффикс :times или в будущем :users. Он определяет, что мы считаем — количество событий, или количество затронутых пользователей.
- Значение — счётчик событий за промежуток времени + время начала отсчета 
{
  // count in period
  eventsCount: number, 
  // UNIX timestamp
  timestamp: number
}Так выглядит функция обновления счетчика и точки отсчета в redis:
async function updateEventCountForPeriod(
  projectId: number,
  ruleId: number,
  eventId: number,
  thresholdPeriod
): Promise<number>{
  const key = ${projectId}:${ruleId}:${eventId}:${thresholdPeriod}:times;
  const now = Date.now();
  const data = await this.redisClient.hGetAll(key);
  const storedTimestamp = data?.timestamp ? Number(data.timestamp) : 0;
  const storedCount = data?.eventsCount ? Number(data.eventsCount) : 0;
  if (storedTimestamp + thresholdPeriod < now) {
    // Период подсчета истёк — сбрасываем счётчик
    await this.redisClient.hSet(key, {
      timestamp: now.toString(),
      eventsCount: '1'
    });
   return 1;
 }
  // Период подсчета еще идет — увеличиваем счётчик
  const newCount = await this.redisClient.hIncrBy(key, 'eventsCount', 1);
  return newCount;
}- Используем TTL, чтобы не хранить лишние ключи. 
После того, как thresholdPeriod пройдет, со следующим событием мы устанавливаем eventsCount в 1. Такое же действие мы делаем если ключа не существовало. Таким образом можно просто удалять ключ по истечении thresholdPeriod, используя TTL — это уменьшит объем данных, которые мы храним.
await this.redisClient.expire(key, Math.ceil(thresholdPeriod / 1000));* TTL в секундах, а timestamp в миллисекундах, поэтому делим на 1000.
Race Condition и Lua
На высоких нагрузках проявилась гонка. Если два воркера одновременно проверяли счетчик события, они оба видели пороговое значение и оба принимали решение об отправке уведомления.
В итоге одно и то же уведомление приходило дважды, а иногда — и чаще.
Решение: Lua-скрипты в Redis — такой скрипт выполняется атомарно: то есть получение значения счетчика и его изменение происходит последовательно, блокируя доступ для других воркеров. Следовательно каждый воркер получит уникальное значение счетчика и одинаковых уведомлений не будет
- Инкрементируют счётчик 
- Сравнивают его с порогом 
- Возвращают флаг, стоит ли отправлять уведомления 
async function computeEventCountForPeriod(
  projectId: string,
  ruleId: string,
  groupHash: NotifierEvent['groupHash'],
  thresholdPeriod: Rule['thresholdPeriod']
): Promise<number> {
  const script = `
    local key = KEYS[1]
    local currentTimestamp = tonumber(ARGV[1])
    local thresholdPeriod = tonumber(ARGV[2])
    local ttl = tonumber(ARGV[3])
    local startPeriodTimestamp = tonumber(redis.call("HGET", key, "timestamp"))
    if ((startPeriodTimestamp == nil) or (currentTimestamp >= startPeriodTimestamp + thresholdPeriod)) then
        redis.call("HSET", key, "timestamp", currentTimestamp)
        redis.call("HSET", key, "eventsCount", 0)
        redis.call("EXPIRE", key, ttl)
    end
    local newCounter = redis.call("HINCRBY", key, "eventsCount", 1)
    return newCounter
  `;
  const key = ${projectId}:${ruleId}:${groupHash}:${thresholdPeriod}:times;
  /**
   * Treshold period is in milliseconds, but redis expects ttl in seconds
   */
  const ttl = Math.floor(thresholdPeriod / MS_IN_SEC);
  const currentTimestamp = Date.now();
  const currentEventCount = await this.redisClient.eval(script, {
    keys: [ key ],
    arguments: [currentTimestamp.toString(), thresholdPeriod.toString(), ttl.toString()],
  }) as number;
  return (currentEventCount !== null) ? currentEventCount : 0;
}Механизм доставки
Каналы уведомлений — стандартные:
- Email 
- 
Telegram Slack 
На email письма отправляются с помощью библиотеки Nodemailer, для slack и telegram используются вебхуки ботов.
Особенность email — его сложнее всего тестировать. Очень легко попасть в спам, и одна ошибка может похоронить доверие к системе. Так что работая с почтой стоит проверять все дважды.
Финальный результат
Сейчас система справляется с миллионами событий в час.
Она:
- Дает пользователю гибкую настройку критичности 
- Масштабируется горизонтально так как воркеры имеют общее хранилище с решенной проблемой Race condition 
- 
Имеет стабильное по размеру хранилище (Redis как агрегатор + TTL)  
Что бы я сделал иначе
Если бы начал с нуля:
- Я бы подумал над реализацией «Скользящего окна» обновления счетчика. Сейчас счетчик сбрасывается после прошествия thresholdPeriod, это может привести к потере уведомления. 
 Представим правило:
 "Отправь уведомление если пришло 1000 событий за 2 минуты."
 И такой порядок событий:
 1 минута - 1 событие (инициализируем счетчик) eventsCount: 1
 2 минута - 900 событий (инкремент счетчика) eventsCount: 901
 3 минута - 900 событий (сброс счетчика и инкремент) eventsCount: 900
 Не смотря на то, что за 2 и 3 минуты было 1800 событий, уведомление не будет отправлено
- Переписал бы логику обновления счетчиков в Lua скрипте. Удаление ключа через thresholdPeriod после его создания позволяет убрать проверку на то, что tresholdPeriod + timestamp < now. 
И напоследок
Надеюсь, этот рассказ поможет вам, если вы проектируете масштабируемую систему уведомлений для highload проекта,. Если хотите поближе познакомиться с кодом можете найти его тут или попробовать настроить уведомления своими руками тут
Комментарии (4)
 - AlexSpaizNet25.06.2025 16:03- Может я что-то упускаю, но где здесь про масштабирование?  - e11sy25.06.2025 16:03- Спасибо за вопрос! - Под этим я имел в виду, что система может быть масштабирована горизонтально, без потери данных и дублирования уведомлений. Раньше вся логика обработки событий была завязана на один процесс, а теперь она распределена и использует общее хранилище. Это позволяет нескольким воркерам работать одновременно и эффективно справляться с большим количеством событий. - Распределение событий по воркерам происходит с помощью RabbitMQ 
- Вся агрегация вынесена в общее хранилище (Redis), доступное для всех воркеров. 
- Используются Lua-скрипты для атомарной работы с данными и устранения гонок. 
- Поддерживается независимая работа нескольких воркеров без риска дублирования уведомлений. 
 - Теперь система может обрабатывать миллионы событий в час и не упирается в ограничения одного инстанса воркера — это и есть масштабирование в контексте highload-сервиса. 
 
 
           
 
Maxim-MA
Крутая работа, очень здорово описан подход, особенно понравилось, как через Redis и Lua аккуратно обошли race condition и сохранили горизонтальную масштабируемость, читалось как хороший разбор продакшен-кейса )
Думаю, стоит подумать о реализации скользящего окна с помощью Redis Sorted Set + ZREMRANGEBYSCORE Это может дать более точный контроль над событиями в интервале времени, особенно в сценариях, где счётчик «обнуляется» слишком резко
e11sy
У меня были мысли в эту сторону, но пока в приоритете минимальное потребление памяти и производительность под пиковую нагрузку. Но если требования к точности вырастут —
Sorted Setс таймстемпами точно будет следующим шагом.Спасибо за идею!