Push-уведомления в масштабе: инфраструктура для миллионов пользователей

Отправить одно push-уведомление — тривиально. Отправить миллион за 30 секунд — это уже инфраструктурная задача, которая ломает всё, что не было спроектировано под нагрузку. Типичная картина: маркетолог нажимает «отправить всем», и через 10 секунд падает база данных, очередь разбухает до 40 ГБ, а APNs начинает возвращать 429 на каждый второй запрос. Монолитный сервис, который отлично справлялся с 10 000 пользователями, при 1 000 000 просто умирает — не деградирует, а именно умирает.

Архитектура APNs и FCM на стороне клиента

Apple Push Notification Service и Firebase Cloud Messaging — это не просто «отправить JSON». Обе системы работают через долгоживущие TCP-соединения, и это фундаментально влияет на то, как вы проектируете серверную сторону.

APNs использует HTTP/2 с мультиплексированием: одно соединение может держать до 1500 одновременных потоков. Аутентификация либо через JWT-токен (обновляется каждые 60 минут), либо через TLS-сертификат с привязкой к bundle ID. JWT — предпочтительный вариант для масштабных систем, потому что один ключ работает для всех приложений в аккаунте разработчика.

FCM работает иначе. Legacy API официально deprecated с июня 2024 года. Актуальный вариант — HTTP v1 API с OAuth 2.0. Батчинг через FCM HTTP v1 не поддерживается напрямую: каждое уведомление — отдельный HTTP-запрос. Это архитектурное решение Google, и оно сразу диктует требования к вашему worker pool.

# Структура сервиса отправки /push-service  /workers    apns_worker.go      # HTTP/2 client с connection pool    fcm_worker.go       # HTTP/1.1 с OAuth token refresh    huawei_worker.go    # HMS Push Kit  /queue    consumer.go         # Kafka consumer group  /token_store    redis_client.go     # Горячие токены    pg_client.go        # Персистентное хранилище

Важный момент по APNs: соединение должно быть установлено заранее. Каждое новое TLS-рукопожатие — это ~200 мс задержки. При попытке открыть 500 соединений одновременно APNs начнёт их сбрасывать.

Проблема fan-out: от одного события к миллиону доставок

Именно здесь начинается настоящая боль. У вас есть одно событие — «отправить всем пользователям промо-уведомление» — и вам нужно превратить его в миллион отдельных задач доставки. Синхронный подход убивает систему мгновенно: один HTTP-запрос висит, пока не обработаются все миллион получателей.

Правильный подход — двухуровневый fan-out через очередь сообщений. Первый уровень: сервис рассылки читает сегмент пользователей пагинированно и публикует задачи в Kafka страницами по 1000-5000 записей.

kafka-topics.sh --create   --bootstrap-server kafka:9092   --topic push.delivery   --partitions 64   --replication-factor 3   --config retention.ms=3600000 kafka-consumer-groups.sh   --bootstrap-server kafka:9092   --describe   --group push-workers

64 партиции определяют максимальный параллелизм. При 50 000 RPS нужно доставить миллион уведомлений за 60 секунд — это ~16 700 сообщений в секунду. Умножьте на средний latency (~80 мс для FCM) — получите минимум ~1340 параллельных воркеров.

Второй уровень: воркеры читают из партиций и отправляют пачками. Kafka позволяет перечитать сообщения при сбое — для push это критично.

Worker pools и горизонтальное масштабирование

Воркер для APNs и воркер для FCM — это разные звери. APNs требует HTTP/2 с постоянными соединениями, FCM работает через HTTP/1.1. Разные connection pool-ы, разная логика retry, разные лимиты.

# /etc/systemd/system/push-worker-fcm@.service [Unit] Description=FCM Push Worker instance %i After=network.target [Service] Type=simple User=push WorkingDirectory=/opt/push-service ExecStart=/opt/push-service/bin/push-worker   --platform=fcm   --kafka-brokers=kafka-01:9092,kafka-02:9092,kafka-03:9092   --concurrency=200   --connection-pool-size=50 Restart=always RestartSec=5 LimitNOFILE=65536 [Install] WantedBy=multi-user.targetsystemctl enable push-worker-fcm@{1..8} systemctl start push-worker-fcm@{1..8} systemctl status 'push-worker-fcm@*'

Горизонтальное масштабирование через Kubernetes с HPA по метрике consumer lag: когда очередь растёт, контроллер добавляет поды.

Хранение токенов и сессий

Device token — 64 байта для APNs и ~150 байт для FCM. Миллион пользователей, 1.5 устройства — 1.5 миллиона записей. Токены устаревают при переустановке приложения, смене телефона, отзыве разрешения. Хранилище без инвалидации — мусорный склад.

CREATE TABLE device_tokens (    id          BIGSERIAL PRIMARY KEY,    user_id     BIGINT NOT NULL,    platform    VARCHAR(10) NOT NULL,    token       VARCHAR(256) NOT NULL,    created_at  TIMESTAMPTZ DEFAULT NOW(),    last_seen   TIMESTAMPTZ DEFAULT NOW(),    is_active   BOOLEAN DEFAULT TRUE ) PARTITION BY HASH (user_id); CREATE TABLE device_tokens_0 PARTITION OF device_tokens    FOR VALUES WITH (MODULUS 16, REMAINDER 0); CREATE INDEX CONCURRENTLY idx_tokens_user_active    ON device_tokens (user_id, is_active)    WHERE is_active = TRUE;

Redis для горячего кеша активных токенов — только активные за последние 30 дней. Это сокращает объём Redis в 3-5 раз. При fan-out на миллион пользователей чтение из PostgreSQL убьёт базу — предзагружайте токены батчами заранее.

redis-cli SET user:12345:tokens:fcm "token_value" EX 2592000 redis-cli SET user:12345:tokens:apns "token_value" EX 2592000

Delivery guarantees и dead letter queues

At-least-once или at-most-once — выбор зависит от типа уведомления. Транзакционное («ваш платёж прошёл») — at-least-once. Маркетинговое промо — at-most-once. Это архитектурное решение, которое нужно принять явно.

PUSH_RETRY_MAX_ATTEMPTS=3 PUSH_RETRY_INITIAL_DELAY=1s PUSH_RETRY_MAX_DELAY=30s kafka-topics.sh --create   --bootstrap-server kafka:9092   --topic push.delivery.dlq   --partitions 8   --replication-factor 3   --config retention.ms=86400000

Dead letter queue — обязательный элемент. Сообщения попадают туда при невалидном токене (FCM 404 UNREGISTERED, APNs 410 BadDeviceToken), отзыве разрешения или краше воркера. DLQ — не для повторной отправки, а для инвалидации мёртвых токенов.

kafka-console-consumer.sh   --bootstrap-server kafka:9092   --topic push.delivery.dlq   --group dlq-processor   --from-beginning |   jq -r '.token' |   xargs -I{} psql -c "UPDATE device_tokens SET is_active=FALSE WHERE token='{}'"

Мониторинг доставки

Метрики, без которых вы слепы:

  • delivery_rate — процент успешных доставок. Ниже 85% — тревога. Норма — 92-96%
  • queue_depth по топикам Kafka — линейный рост значит нехватку воркеров
  • p99 latency — под 5 секунд для транзакционных уведомлений
  • fcm_error_rate по типам — UNREGISTERED, INVALID_ARGUMENT, QUOTA_EXCEEDED отдельно
  • apns_connection_pool_exhausted — если ненулевой, пул слишком мал
  • token_cache_hit_rate — выше 80%

groups:  - name: push_delivery    rules:      - alert: PushDeliveryRateLow        expr: |          (sum(rate(push_delivered_total[5m])) /           sum(rate(push_sent_total[5m]))) < 0.85        for: 2m        labels:          severity: critical      - alert: PushQueueDepthHigh        expr: kafka_consumer_lag_sum{group="push-workers-fcm"} > 500000        for: 5m        labels:          severity: warning      - alert: PushLatencyP99High        expr: histogram_quantile(0.99, rate(push_e2e_latency_seconds_bucket[5m])) > 10        for: 3m        labels:          severity: warning

Grafana дашборд: throughput, error breakdown по платформам, consumer lag по партициям. Без breakdown по типам ошибок не поймёте причину падения delivery rate.

Типичные ошибки

  • Синхронная отправка в HTTP-обработчике. Запрос висит — thread pool исчерпан — 502 на весь сервис. Правильно: кладём в очередь, возвращаем 202 Accepted
  • Игнорирование FCM 400 INVALID_ARGUMENT. Payload невалиден. Retry бессмыслен, но системы тратят квоту FCM на три попытки
  • Один batch вместо пагинации. SELECT без LIMIT на миллион строк — sequential scan, блокирует autovacuum
  • Отсутствие rate limiting. FCM даёт 600 000 сообщений в минуту. Без контроля — 429 на 58 секунд при первой массовой рассылке
  • Игнорировать Retry-After из APNs. При 429 клиент продолжает долбить сервис, усугубляя ситуацию
  • Токены без TTL. База без очистки за год вырастает в 5-10 раз, 30-40% — мёртвые токены

Последнее: тестируйте под нагрузкой до продакшна. Стенд с реальным Kafka, Redis и mock APNs/FCM позволяет найти узкие места заранее. Особенно важно проверять поведение при частичном отказе — если один Kafka-брокер недоступен в момент fan-out.