Предыстория

Я — Web разработчик в команде CodeX @e11sy

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

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

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


Проблемы изначальной реализации

Хоук — это трекер ошибок с миллионами обрабатываемых событий в час. Каждое событие может быть важным. О таких событиях надо уведомлять разработчиков.

Сначала система поддерживала два типа уведомлений:

  • Уведомления обо всех событиях

  • Уведомления о новых ошибках

Никакой гибкости. Это или постоянный спам, или отсутствие уведомлений, когда приходят уже встречавшиеся ошибки.

Помимо продуктовых проблем существовала и проблема производительности, так как микросервис уведомлений (воркер) не масштабировался горизонтально. Причина этому — реализация группировки сообщений в памяти одного процесса. Она нужна, чтобы вместо сотни уведомлений о ста событиях прислать одно общее.


Новый подход 

Я ввел два типа уведомлений:

  1. О новых ошибках

  2. О критических событиях

Главное изменение — это возможность пользователю самому задавать параметры критичности:

«Считай ошибку критичной, если она случилась 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 проекта,. Если хотите поближе познакомиться с кодом можете найти его тут или попробовать настроить уведомления своими руками тут

Комментарии (1)


  1. Maxim-MA
    25.06.2025 16:03

    Крутая работа, очень здорово описан подход, особенно понравилось, как через Redis и Lua аккуратно обошли race condition и сохранили горизонтальную масштабируемость, читалось как хороший разбор продакшен-кейса )

    Думаю, стоит подумать о реализации скользящего окна с помощью Redis Sorted Set + ZREMRANGEBYSCORE Это может дать более точный контроль над событиями в интервале времени, особенно в сценариях, где счётчик «обнуляется» слишком резко