Предыстория
Я — 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 проекта,. Если хотите поближе познакомиться с кодом можете найти его тут или попробовать настроить уведомления своими руками тут
Maxim-MA
Крутая работа, очень здорово описан подход, особенно понравилось, как через Redis и Lua аккуратно обошли race condition и сохранили горизонтальную масштабируемость, читалось как хороший разбор продакшен-кейса )
Думаю, стоит подумать о реализации скользящего окна с помощью Redis Sorted Set + ZREMRANGEBYSCORE Это может дать более точный контроль над событиями в интервале времени, особенно в сценариях, где счётчик «обнуляется» слишком резко