Предыстория
Я — 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 проекта,. Если хотите поближе познакомиться с кодом можете найти его тут или попробовать настроить уведомления своими руками тут
ссылка на оригинал статьи https://habr.com/ru/articles/921506/
Добавить комментарий