Разбираем проектирование надёжной системы уведомлений: как разделить приём событий, учёт предпочтений и доставку, чтобы не потерять сообщения и не заспамить пользователя. Читайте и применяйте на практике.
Представьте: пользователя упомянули в комментарии. Теперь нужно отправить push-уведомление, email и внутриприложенческий значок, при этом пользователь отключил email, но хочет получать push. Умножьте это на миллионы событий в час — и наивная реализация развалится. Система уведомлений выглядит просто снаружи, но превращается в хаос, как только вы пытаетесь сделать её надёжной.
Событие попадает в очередь. Воркер определяет получателей и для каждого проверяет канальные предпочтения и тихие часы. Только после этого ставятся задачи на доставку по каналам. Разделение этапов гарантирует, что медленный SMS-шлюз не заблокирует push-уведомления.
Для каждого пользователя нужны флаги подписки по категориям и каналам, часовой пояс, тихие часы и настройка дайджеста. Категория важна: «оповещение безопасности» должно игнорировать тихие часы, а «кому-то понравился ваш пост» — нет. Эта таблица читается при каждом уведомлении, поэтому требуется агрессивное кеширование.
Очереди гарантируют доставку хотя бы один раз, что означает возможный повтор задачи. Без осторожности пользователь получит два одинаковых email. Прикрепите стабильный ID уведомления и дедуплицируйте в воркере канала, либо используйте ключ идемпотентности провайдера.
Если кто-то генерирует 50 событий в минуту, не отправляйте 50 уведомлений. Агрегируйте их в коротком окне (например, несколько минут), превращая «12 новых комментариев» в одно уведомление. Это и продуктовое, и техническое решение.
APNs возвращает ошибку неверного токена — нужно пометить устройство как мёртвое. Временная ошибка 500 от email-провайдера — повторить с экспоненциальной задержкой. Сообщение, провалившее все попытки, уходит в dead-letter очередь для инспекции.
Внутриприложенческие уведомления требуют хранения и быстрого счётчика непрочитанных. Распространённый подход — лента уведомлений на пользователя в хранилище, выдерживающем высокую нагрузку на запись, плюс кешированный счётчик, который инкрементируется при записи и сбрасывается при чтении. Не вычисляйте количество непрочитанных запросом COUNT при каждой загрузке страницы.
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional
import uuid
class Channel(Enum):
PUSH = "push"
EMAIL = "email"
SMS = "sms"
IN_APP = "in_app"
class Category(Enum):
SECURITY = "security"
SOCIAL = "social"
MARKETING = "marketing"
@dataclass
class UserPreferences:
user_id: str
channels: Dict[Category, List[Channel]] # разрешённые каналы по категориям
quiet_hours_start: Optional[int] # час начала тихих часов (0-23)
quiet_hours_end: Optional[int] # час окончания
timezone: str
digest_enabled: bool
@dataclass
class NotificationEvent:
event_id: str = None
user_id: str = None
category: Category = None
title: str = ""
body: str = ""
metadata: dict = None
def __post_init__(self):
if not self.event_id:
self.event_id = str(uuid.uuid4())
class NotificationPipeline:
def __init__(self, user_prefs_cache: Dict[str, UserPreferences]):
self.cache = user_prefs_cache
def process_event(self, event: NotificationEvent):
# 1. Получаем предпочтения пользователя
prefs = self.cache.get(event.user_id)
if not prefs:
print(f"Пользователь {event.user_id} не найден")
return
# 2. Определяем разрешённые каналы для категории
allowed_channels = prefs.channels.get(event.category, [])
# 3. Проверяем тихие часы (кроме security)
if event.category != Category.SECURITY:
import datetime
now = datetime.datetime.now()
hour = now.hour
if prefs.quiet_hours_start is not None and prefs.quiet_hours_end is not None:
if prefs.quiet_hours_start <= hour < prefs.quiet_hours_end:
# В тихие часы — только in_app, если разрешён
allowed_channels = [ch for ch in allowed_channels if ch == Channel.IN_APP]
# 4. Для каждого канала создаём задачу доставки (здесь просто логируем)
for channel in allowed_channels:
self.enqueue_delivery(event, channel)
def enqueue_delivery(self, event: NotificationEvent, channel: Channel):
# В реальной системе здесь постановка в очередь
print(f"Отправка {channel.value} для события {event.event_id}: {event.title}")
# Пример использования
cache = {
"user1": UserPreferences(
user_id="user1",
channels={
Category.SOCIAL: [Channel.PUSH, Channel.EMAIL],
Category.SECURITY: [Channel.PUSH, Channel.EMAIL, Channel.SMS],
},
quiet_hours_start=22,
quiet_hours_end=8,
timezone="Europe/Moscow",
digest_enabled=False
)
}
pipeline = NotificationPipeline(cache)
event = NotificationEvent(
user_id="user1",
category=Category.SOCIAL,
title="Новый комментарий",
body="Вам ответили в теме"
)
pipeline.process_event(event)
Крупные платформы строят конвейер поверх лога вроде Kafka. События публикуются в топик, воркеры «фан-аута» потребляют и разворачивают их в задачи на получателя, а потребители каналов отвечают за последнюю милю до APNs, FCM и email-провайдеров. Управление жизненным циклом токенов устройств — отдельная подсистема, так как токены постоянно истекают и обновляются, а отправка на мёртвые токены тратит квоту и вредит репутации отправителя. Логика дайджестов и тихих часов обычно живёт на этапе фан-аута, чтобы воркеры каналов не знали о ней.
Главная идея одна: отделите событие от доставки, сделайте каждый шаг повторяемым и идемпотентным, поставьте предпочтения пользователя в центр, а не прикручивайте их в конце. Начните с малого: реализуйте простой конвейер с очередью и дедупликацией, затем добавляйте батчинг и обработку сбоев.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →