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.