← назад к разделу

Представьте: пользователь оформил заказ, и ему нужно прийти подтверждение — push на телефон, письмо на почту и сообщение в ленте приложения. Кто это делает? Как оно не теряется? Как не отправляется дважды, если что-то упало? Разберём всё это на конкретной системе — маркетплейс с 10 млн пользователей.

Обязательно

Зачем вообще нужна отдельная система уведомлений

Можно ведь отправить письмо прямо из кода заказа — sendEmail(user, "Заказ принят"). Сначала так и делают. Потом появляется push, потом требование «не дублировать», потом кампании на миллион получателей, потом пользователь хочет отписаться от части категорий, потом оказывается, что почтовый провайдер лежит и заказы встают в очередь.

Вот почему уведомления выносят в отдельную платформу с единым API. Продуктовые команды говорят «отправь событие», а платформа сама решает: каким каналом, по каким предпочтениям пользователя, что делать при отказе провайдера.

Что платформа умеет

Зафиксируем задачу до того, как рисовать схемы:

  • Продуктовые команды отправляют уведомления через единый API: push, email, в приложении (колокольчик).
  • Пользователь управляет подписками — может отключить определённые категории на определённых каналах.
  • Пользователь видит ленту уведомлений и счётчик непрочитанных.
  • Продукт видит статус доставки.

Чего не делаем в первой версии: маркетинговый конструктор кампаний, SMS, гарантию порядка между каналами.

Сколько всего и почему это важно

Прежде чем рисовать схему, прикинем числа:

Что считаемСколько получается
Пользователи10 млн всего, 3 млн активны в день
Уведомления100 млн в день → примерно 1200/с в среднем, пик ×5 при кампаниях → 6000/с
История за 90 дней~1 КБ × 100 млн × 90 ≈ 9 ТБ
Лента3 млн × 8 открытий ≈ 280 запросов/с, пик ~1000
Счётчик непрочитанныхтысячи лёгких запросов/с — на каждый экран приложения
Push-провайдериз пиковых 6000/с в push уходит примерно половина — у остальных канал выключен или приложение не установлено; это допущение, которое проверяют по реальным настройкам. Итого ~3000 запросов/с: нужны порционная отправка и ограничение темпа

Из этих чисел сразу видно три вещи:

  1. Пики и асинхронная природа отправки — значит, между приёмом события и реальной доставкой обязана быть очередь.
  2. 9 ТБ истории — не для обычной таблицы. Нужно партиционирование и перенос старых данных в холодное хранилище.
  3. Счётчик непрочитанных — самое горячее чтение. Базе данных туда лезть не стоит, нужен кеш.

Почему именно Kafka, а не очередь в базе или RabbitMQ

Брокер в дизайне не появляется готовым — его выбирают, и при 6000 сообщений в секунду выбор надо обосновать. Три варианта, и у каждого своя граница.

Очередь в PostgreSQL (таблица с задачами и выборкой с блокировкой строк). Честный вариант, и его недооценивают: при сотнях сообщений в секунду он работает, не требует новой системы в эксплуатации, даёт транзакционность вместе с бизнес-данными (одна транзакция — и событие, и запись) и простую отладку обычным запросом. Ломается на двух вещах: пропускная способность записи (каждое сообщение — это вставка плюс обновление плюс уборка мусора; единицы тысяч в секунду становятся проблемой, а 6000 с пиками — это уже заметная доля ресурсов базы, которая нужна на основную работу) и отсутствие истории (прочитанное удалили — переиграть нельзя).

RabbitMQ — классический брокер с очередями. Сильные стороны: маршрутизация по правилам, приоритеты, очередь недоставленных из коробки, удобная работа «задача — исполнитель». На наших числах он справится. Границы, из-за которых здесь он не выбран: сообщение после подтверждения исчезает, то есть нельзя переиграть поток заново (а это нужно: отладка, новый потребитель для аналитики, восстановление счётчика после потери кеша); и при отставании потребителей очередь копится в памяти брокера, что при кампании на миллион получателей опасно.

Kafka — журнал с хранением. Почему он здесь: сообщения остаются в теме заданный срок, и поэтому появляются три возможности, которые нужны именно этой системе. Первая — несколько независимых потребителей одного потока: отправка в каналы, запись в ленту, наполнение аналитики читают одно и то же событие, каждый со своей скоростью, и добавление четвёртого не требует менять producer. Вторая — переигрывание: потребителя можно вернуть назад и пересчитать (восстановление счётчика непрочитанных, перезаливка аналитики, разбор инцидента). Третья — дешёвое отставание: миллион сообщений кампании лежит на диске, а не в памяти, и потребители догоняют со своей скоростью. Плюс горизонтальное масштабирование потребления по разделам, что на 6000 в секунду уже имеет значение.

Цена названа вслух: ещё одна система в эксплуатации (кластер, мониторинг отставания, дежурство), отсутствие приоритетов и адресной маршрутизации (поэтому кампании приходится разводить отдельной темой, а не приоритетом), и ручная работа с повторами и недоставленными — в Kafka это ваша ответственность, а не встроенный механизм.

Граница выбора одной фразой: до сотен сообщений в секунду — таблица в базе; если нужны маршрутизация, приоритеты и «задача-исполнитель» — RabbitMQ; если нужны несколько потребителей, переигрывание и тысячи в секунду — Kafka. Разбор различий подробно — в статье про сравнение брокеров.

Ключ раздела и порядок внутри канала

У Kafka порядок гарантирован внутри раздела, а не внутри темы, и поэтому выбор ключа раздела — часть дизайна, а не настройка по умолчанию.

Здесь ключ — идентификатор пользователя. Из этого следует ровно то, что нужно: все уведомления одного пользователя попадают в один раздел, а значит, обрабатываются по порядку одним потребителем. Почему это важно: два уведомления по одному заказу («заказ оплачен», затем «заказ отменён») не должны прийти в обратном порядке; и лента пользователя собирается без гонок, потому что её пишет один потребитель.

Что при этом не гарантируется и записано в анти-требованиях: порядок между каналами (письмо может уйти раньше пуша) и порядок между разными пользователями (он никому не нужен). Про порядок внутри канала стоит сказать отдельно: он гарантирован до попадания к провайдеру — дальше очередь провайдера и повторы могут его нарушить, и полагаться на порядок доставки писем нельзя в принципе. Поэтому в самих уведомлениях, где порядок важен для смысла, несут время события, а получатель ориентируется на него, а не на порядок получения.

Две ловушки этого выбора, которые надо назвать:

  • Перекос. Пользователь-гигант (или технический аккаунт, на который валится всё) делает свой раздел горячим. В системе уведомлений это редкость, но в кампаниях — нет: если кампания идёт с ключом кампании, весь миллион попадает в один раздел. Поэтому у кампаний ключ — тот же идентификатор получателя, а не идентификатор кампании.
  • Число разделов задаёт потолок параллелизма. Потребителей в группе не может быть больше, чем разделов: 12 разделов — максимум 12 потребителей. Увеличить число разделов позже можно, но это ломает привязку ключа к разделу (тот же пользователь уедет в другой раздел), то есть на границе изменения порядок не гарантирован. Отсюда практика: разделов сразу с запасом (для 6000 в секунду — десятки, а не три), потому что увеличивать их больно.

API: принимаем событие, а не «письмо»

Ключевое решение: API принимает событие, а не готовое сообщение. Продьюсер говорит «произошло вот это для пользователя X», а платформа сама выбирает каналы по предпочтениям пользователя и формирует текст по шаблону.

POST /v1/notifications
  тело: { eventId, userId, category, payload }
  ответ: 202 { notificationId }

Чтение и настройки:

GET  /v1/users/{id}/feed?cursor   — лента с постраничной навигацией
GET  /v1/users/{id}/unread-count  — счётчик непрочитанных
PUT  /v1/users/{id}/preferences   — подписки по категориям и каналам
GET  /v1/notifications/{id}/status — статус доставки по каналам

Ответ 202 (принято, но не обработано) — прямое следствие чисел: обработка асинхронная, синхронно мы только сохраняем событие.

eventId — идентификатор от продьюсера. Если он пришлёт то же событие дважды (например, при повторе после ошибки сети), мы вернём тот же notificationId и не отправим второе уведомление. Это называется идемпотентность — «один и тот же запрос несколько раз даёт один и тот же результат».

Где что хранится

ЧтоГдеПочему
Событие и статус доставкиPostgreSQL, партиции по месяцуЗапись потоком, точечное чтение по id
Лента пользователяPostgreSQL, ключ (user_id, created_at)Постраничное чтение последних N записей
Счётчик непрочитанныхRedisТысячи чтений в секунду, инкремент и сброс
Предпочтения пользователяPostgreSQLРедкое чтение по user_id
Аналитика доставкиClickHouseАгрегаты по кампаниям, каналам, датам

PostgreSQL — источник правды. Redis-счётчик можно восстановить пересчётом. ClickHouse наполняется из событий доставки.

Схема: как событие превращается в уведомление

продьюсер событие о заказе API-сервис проверка, идемпотентность по eventId, база и outbox Kafka: notifications Resolver читает настройки, выбирает каналы, заполняет шаблон Kafka: push / email / feed по теме на канал воркеры каналов FCM и APNs, почта, лента в PostgreSQL и Redis статусы доставки PostgreSQL и ClickHouse

Каналы разведены по темам не для красоты: тормозящая почта не должна задерживать пуши. Настройки применяются один раз, в Resolver, — иначе эта логика размазывается по всем воркерам.

Зачем отдельные топики для каждого канала? Если почтовый провайдер лежит — очередь email копится, но push-уведомления уходят без задержки. Каналы изолированы друг от друга.

Outbox на приёме — это таблица в той же БД, куда вместе с событием атомарно пишется задача «переложить в Kafka». Отдельный процесс читает её и публикует в Kafka. Если приложение упадёт между сохранением и публикацией — задача останется в outbox и будет опубликована при восстановлении. Это единственное место, где «не потерять» обеспечивается транзакционно.

Шаблоны: откуда берётся текст

API принимает событие, а текст формирует платформа — значит, шаблоны это ядро системы, и у них своя модель данных и свой жизненный цикл.

Что такое шаблон здесь. Тройка «категория события + канал + язык» → текст с подстановками. У одного события order.shipped будет шаблон для пуша (короткий, до 100 символов, с заголовком), для письма (тема плюс тело с разметкой), для сообщения в мессенджере (свои ограничения). Плюс параметры: сколько подстановок, какие обязательны.

order.shipped / push / ru:
  title: Заказ №{orderNumber} в пути
  body:  Ожидаемая доставка — {deliveryDate}

order.shipped / email / ru:
  subject: Заказ №{orderNumber} отправлен
  body:    <шаблон с разметкой, ссылка на отслеживание {trackingUrl}>

Где они лежат. Три варианта, и выбор влияет на то, кто их правит. В коде сервиса — просто, версионируется, но правка текста требует выката, и текстами занимаются разработчики. В базе с интерфейсом редактирования — текст правит маркетинг без выката, и это обычно то, чего хотят; цена — нужен интерфейс, права и история изменений. В отдельном репозитории с выкатом как конфигурации — середина: ревью и история есть, выкат отдельный от кода.

Практический выбор для такой платформы: в базе, с обязательной историей версий, потому что правят текст часто и не разработчики. И обязательное следствие: у отправленного уведомления надо сохранить версию шаблона, иначе через месяц не понять, что именно получил пользователь.

Подстановка и её ловушки. Текст собирается из шаблона и полей события, и здесь три вещи, которые ломают систему в проде:

  • Отсутствующее поле. Шаблон ждёт {deliveryDate}, а в событии его нет. Варианты: отправить с пустым местом (плохо, пользователь видит дыру), не отправить вовсе (лучше, но тихо), или не пустить такой шаблон в работу — то есть проверять совместимость шаблона и схемы события при сохранении шаблона. Последнее и есть правильный ответ: проверка на входе вместо сюрприза при отправке.
  • Разметка и подстановка чужого текста. В уведомление попадает имя, название товара, комментарий — то есть данные пользователя. В письме с разметкой это означает необходимость экранирования, иначе получаем и испорченную вёрстку, и уязвимость. Правило: подставляемые значения экранируются по правилам канала, а не «мы же контролируем данные».
  • Длина. У пуша жёсткий предел, у мессенджеров свои. Обрезать надо осмысленно (по словам, с многоточием) и проверять при сохранении шаблона на реальных примерах подстановок, а не на пустых.

Локализация. Язык выбирается по настройке пользователя, а не по языку отправителя события. Что нужно решить: что делать, если шаблона на нужном языке нет — откат на язык по умолчанию (обычный ответ) или не отправлять; как быть с языками, где меняется форма слова от числа («1 заказ», «2 заказа», «5 заказов») — это не подстановка, а правило множественного числа, и его надо поддержать в движке шаблонов; и часовой пояс — время доставки в тексте показывают в поясе пользователя, иначе «доставка в 09:00» значит разное. Плюс валюта и формат даты — из настроек пользователя, а не из настроек сервера.

Кто проверяет, что не стыдно отправить. Шаблон, попавший к миллиону получателей с ошибкой, — это инцидент. Поэтому в процесс встраивают предпросмотр на тестовых данных, отправку себе перед публикацией и права: кто может править и кто публикует. Это не бюрократия — это ровно та проверка, которой не хватает в момент «быстро поправим текст».

Дедупликация: не отправить дважды

У Kafka по умолчанию доставка «хотя бы один раз»: воркер может получить одно и то же сообщение дважды. Внутри самой Kafka это лечится — есть идемпотентный продюсер и транзакции, которые дают «ровно один раз» на участке «прочитал из темы → обработал → записал обратно в Kafka». Но письмо и пуш уходят наружу, к чужому провайдеру, и вот там откатывать уже нечего: отправленное письмо не отзовёшь. Сквозного «ровно один раз» до получателя не бывает в принципе — поэтому защищаемся сами, двумя рубежами:

  1. На приёме — уникальный индекс по eventId в базе. Второй запрос с тем же eventId вернёт уже существующий notificationId.
  2. В воркерах — перед отправкой воркер проверяет, не отправлял ли он уже пару notificationId + канал. Держать такую отметку в памяти процесса бесполезно: воркеров в группе несколько, и после перезапуска память пуста — дубль спокойно пройдёт. Отметку кладут в общее хранилище: либо отдельной строкой с уникальным индексом по (notification_id, channel) в PostgreSQL — вторая вставка просто упадёт, и это и есть проверка, — либо ключом в Redis через SET ... NX со сроком жизни в несколько суток, с запасом на самые долгие повторы.

Эта пара рубежей — стандартный подход для любого пайплайна, где доставка «хотя бы один раз».

первый раз событие пришло индекс: вставка метки нет письмо ушло повтор запроса тот же eventId индекс: занят тот же ответ не отправлено повтор из Kafka то же сообщение рубеж 1 позади метка уже есть не отправлено

Смотрите на второй и третий ряд: повтор запроса продьюсера отсекает уникальный индекс по eventId, а повтор сообщения из очереди отсекает отметка по паре «уведомление и канал» в воркере.

Повторы, недоставленные и темп к провайдерам

«Воркер повторяет с паузами» — самая частая формулировка и самая недоговорённая. Разберём, потому что от этих чисел зависит и доставляемость, и то, не завалите ли вы провайдера.

Что повторять, а что нет. Первое решение — по коду ответа провайдера, и оно не про числа, а про смысл: временный сбой (сеть, 5xx, срок ожидания, «слишком много запросов») повторяют; окончательный отказ (неверный адрес, отписавшийся получатель, устаревший токен устройства, заблокированный номер) повторять бессмысленно и вредно — провайдеры за такое ухудшают репутацию отправителя. Значит, у ответа есть классификация, и она часть кода отправки, а не «повторим всё».

Сколько раз и с какими паузами. Для уведомлений разумная схема — пять попыток с растущей паузой и разбросом: 10 секунд, 1 минута, 5 минут, 30 минут, 2 часа. Итого около трёх часов на доставку, и это осознанная граница: пуш, доставленный через три часа, обычно уже не нужен, а письмо — ещё нужно. Отсюда важное: сроки повторов различаются по каналам — пуш перестают повторять через десятки минут, письмо повторяют до суток. Разброс (случайная добавка к паузе) обязателен: без него все сообщения, упавшие в момент отказа провайдера, повторяются одновременно и валят его снова.

Где живут отложенные повторы. В памяти потребителя их держать нельзя (перезапуск — и всё потеряно), задерживать раздел Kafka тоже нельзя (одно неудачное сообщение остановит все следующие — это классическая ошибка). Рабочие варианты два: тема повторов с задержкой (отдельная тема на каждый уровень задержки, потребитель читает её с паузой) или таблица отложенных отправок в базе, из которой фоновая задача забирает созревшие. Второй вариант проще и обычно достаточен; первый — когда объём повторов большой.

Недоставленные. После последней попытки уведомление попадает в очередь недоставленных (или таблицу с таким статусом) — не пропадает и не остаётся навсегда «в отправке». Что с ней делают: у неё есть владелец и дежурный смотрит на её размер, потому что рост очереди недоставленных — первый признак проблемы с провайдером или с данными; сообщения из неё можно переотправить после починки; и по ней считают доставляемость. Пустая очередь недоставленных, о которой никто не знает, — то же, что её отсутствие. Отдельно: у уведомления остаётся статус («не доставлено по каналу пуш»), потому что это видно пользователю в ленте и нужно поддержке.

Темп к провайдерам. Из чисел выше — 3000 запросов в секунду в пуш-провайдера, и вот здесь ограничение темпа не защита от клиента, а условие работы: у каждого провайдера свой предел, и превышение означает отказы и попадание в чёрный список. Механика — дырявое ведро на нашей стороне: отправка идёт с постоянной скоростью не выше договорной, излишек ждёт в очереди. Практически это значит три вещи. Ограничитель общий на все копии отправщика (счётчик во внешнем хранилище), иначе десять копий умножат ваш лимит на десять. Пачки, где провайдер их поддерживает: отправка списком по 500 получателей вместо 500 запросов — это и быстрее, и не съедает лимит. И ограничитель на каждого провайдера свой, потому что пределы разные, а при достижении предела правильное поведение — замедлиться, а не начать терять сообщения.

Что видно на графиках. Три метрики, без которых всё это не эксплуатируется: доля доставленных с первой попытки, средний срок от приёма события до доставки по каналам, размер очереди недоставленных и отставание потребителей. Первая падает — что-то с провайдером или с данными; вторая растёт — упёрлись в темп; третья растёт — разбираться немедленно.

Кампании на миллион получателей

Транзакционное уведомление — один пользователь. Кампания — потенциально весь маркетплейс.

Разворачивать миллион строк синхронно на приёме нельзя — это займёт минуты и заблокирует всё. Поэтому кампания — это одно событие, которое попадает в отдельный топик с меньшим приоритетом. Специальный fan-out воркер читает его и порциями создаёт индивидуальные уведомления.

Главное правило: кампании не должны задерживать транзакционные уведомления. Отдельный топик с отдельным пулом потребителей — код подтверждения заказа не ждёт, пока разошлись промо-письма.

событие кампании fan-out воркер тема campaigns порция 1: 1000 в темы каналов порция 2: 1000 следом порция N пока не кончатся событие заказа тема notifications доставка сразу

Кампания расходится на порции внутри своей темы, а транзакционный путь внизу идёт отдельным пулом потребителей и кампанией не задерживается.

Отписка и тихие часы

Для транзакционных уведомлений это мелочь, для кампаний — обязательное условие: рассылка без работающей отписки означает жалобы, блокировку отправителя у провайдера и претензии по закону. Поэтому это часть дизайна, а не «добавим в интерфейс».

Отписка одним действием, и она глобальная. Требование простое: из любого маркетингового письма можно отписаться, не входя в личный кабинет и не заполняя форму, — то есть ссылка с подписанным одноразовым ключом, по которой отписка происходит сразу. И ключевое для архитектуры: отписка действует сразу и для всех каналов и кампаний той же категории, а не только для того письма, из которого пришли. Значит, решение о том, отправлять ли, принимается в одном месте — на шаге выбора получателей и каналов, и это тот же самый механизм предпочтений, что и в API.

Разделение по категориям и границы отказа. Отписаться можно от маркетинга, но не от того, что нужно самому пользователю: код подтверждения входа, статус заказа, сообщение о платеже. Поэтому у категории есть признак «обязательная», и она игнорирует общую отписку. Это надо решить явно и записать, потому что иначе получается одно из двух: либо отписавшийся перестаёт получать код входа, либо отписка ничего не значит.

Тихие часы и частота. Пуш в три ночи — худшее, что может сделать платформа уведомлений. Правило: у пользователя есть часовой пояс, и маркетинговые уведомления не отправляются вне разрешённого окна (скажем, с 9 до 21 по его времени) — отложенные до утра, а не отброшенные. Плюс ограничение частоты на получателя: не больше N маркетинговых в сутки, чтобы двенадцать кампаний не превратились в двенадцать пушей. Оба правила применяются там же, где и предпочтения, и оба должны иметь исключение для обязательных категорий (код входа приходит в три ночи, если пользователь входит в три ночи).

Что хранить, чтобы доказать. Согласие и отписка — это факты с датой и источником: когда, откуда (галочка при регистрации, ссылка из письма, обращение в поддержку), какая версия текста согласия. Хранят историю, а не текущее состояние: на претензию «мне писали без согласия» отвечают записью, а не словом. Сюда же — журнал того, что отправлено конкретному человеку, потому что второй частый вопрос от поддержки звучит «что ему вообще приходило».

Как это ложится на схему. Никакого нового компонента: отписка, тихие часы и частота — это три проверки в том же шаге, где выбираются каналы по предпочтениям. Важно лишь, что этот шаг один и его нельзя обойти: кампания, которая шлёт напрямую в отправщик мимо проверки предпочтений, — самая дорогая ошибка в такой системе, и защищаются от неё тем, что отправщик принимает задание только с отметкой «проверено», а не сырой список адресов.

Счётчик непрочитанных

Приложение показывает число непрочитанных на каждом экране — это тысячи запросов в секунду. Лезть в PostgreSQL при каждом открытии экрана — слишком дорого.

Схема простая:

  • feed-worker при добавлении записи в ленту делает INCR user:{id}:unread в Redis;
  • при прочтении — не SET user:{id}:unread 0, а DECRBY user:{id}:unread N на число реально прочитанных записей. Разница вылезает на гонке: между тем, как пользователь открыл ленту, и тем, как приложение сбросило счётчик, успевают прийти новые уведомления — SET 0 затрёт их вместе со старыми, и значок покажет ноль там, где непрочитанное есть. Тот же результат даёт второй способ: хранить курсор последнего прочитанного и считать непрочитанные от него;
  • раз в несколько минут фоновая задача сверяет Redis с PostgreSQL (Redis может потерять данные при перезапуске — счётчик должен уметь пересчитаться).

Чтение всегда из Redis — за счётчиком в PostgreSQL не ходят ни на одном экране. База остаётся источником правды и участвует только в той самой фоновой сверке.

Сквозной путь: что происходит за 40 миллисекунд и за 3 секунды

Метод требует прогонять сценарии по схеме пальцем — сделаем это на главном сценарии целиком, с временами. Пользователь оформил заказ, сервис заказов отправил событие.

ШагЧто происходитПорядок времени
1Сервис заказов вызывает POST /v1/notifications с eventId—
2API проверяет схему события и права продьюсера1 мс
3Вставка события в PostgreSQL с уникальным индексом по eventId плюс запись в таблицу исходящих сообщений — одной транзакцией3–8 мс
4Ответ 202 с notificationId — сервис заказов свободенитого 5–15 мс
5Отправщик исходящих читает новую строку и публикует событие в Kafka с ключом userId10–200 мс после шага 3
6Потребитель предпочтений читает событие, берёт настройки пользователя (кеш, промах — база), решает: пуш и письмо, категория разрешена, тихие часы не мешают2–5 мс
7Потребитель шаблонов собирает текст по тройке «категория, канал, язык», подставляет поля, экранирует1–3 мс
8Запись в ленту в PostgreSQL и INCR счётчика в кеше3–8 мс
9Задание на отправку в пуш и в письмо уходит в темы каналов1–2 мс
10Отправщик пуша проверяет отметку «уже отправляли» по паре (notificationId, канал), берёт токен из ограничителя темпа, отправляет пачкой провайдеру50–300 мс, при упоре в темп — секунды
11Провайдер подтверждает приём; статус доставки пишется в PostgreSQL, событие доставки уходит в аналитику10–50 мс
12Телефон пользователя показывает уведомление0,5–3 с от шага 1

Что видно из этой таблицы и не видно из схемы:

  • Продьюсер ждёт только шаги 2–4 — 5–15 миллисекунд. Всё остальное происходит без него, и это главное следствие ответа 202.
  • Задержка до пользователя — доли секунды в норме, и складывается она в основном из ожидания в очередях и из ответа провайдера, а не из нашей работы. Значит, оптимизировать надо не код, а отставание потребителей и темп к провайдеру.
  • Где именно теряются секунды. При кампании потребители канала отстают (шаг 10 упирается в ограничитель темпа), и задержка транзакционных уведомлений растёт — ровно поэтому кампании идут отдельной темой с отдельным пулом потребителей.
  • Лента появляется раньше пуша (шаг 8 до шага 10), и это правильный порядок: если провайдер недоступен, пользователь всё равно увидит уведомление, открыв приложение.
  • Два места, где сценарий может остановиться навсегда, если не предусмотреть: строка в таблице исходящих, которую никто не забрал (нужна метрика возраста самой старой необработанной строки), и задание на отправку, которое кончилось недоставленным (нужна очередь недоставленных с владельцем).

Куда уезжает история старше 90 дней

Девять терабайт посчитаны ради одного решения, и его стоит довести до конца: «партиции по месяцу и перенос в холодное хранилище» — это два разных механизма и два разных пути чтения.

Секции по месяцу в PostgreSQL. Таблица событий разбита по месяцу создания. Что это даёт: удаление старого — это отсоединение секции, мгновенная операция вместо удаления миллионов строк с мусором; запросы за последние дни читают одну-две секции вместо всей таблицы; обслуживание (уборка, перестроение индексов) идёт по секциям. Секцию за прошедший месяц можно перевести в режим только для чтения и сжать — она больше не меняется.

Что считается холодным хранилищем. Отсоединённую секцию выгружают в объектное хранилище в колоночном формате (тот же, что читают аналитические движки) — это в разы дешевле дисков базы и хранится годами. Выгрузка — обычная задача по расписанию: отсоединить секцию, выгрузить, проверить, что выгруженное читается и число строк совпало, удалить секцию.

Как читается история старше 90 дней. Вот вопрос, который обычно остаётся за кадром, и ответ на него определяет, будет ли механизм работать. Три варианта, по возрастанию удобства:

  1. Через аналитическое хранилище. Оно и так наполняется из событий доставки, и при выгрузке секций туда же попадают старые события. Тогда «покажи, что приходило пользователю год назад» — это запрос в аналитику, а не в базу. Годится, потому что такие запросы редкие и не требуют миллисекунд.
  2. Прямым чтением объектного хранилища внешним движком запросов. Дешевле, дольше по времени ответа, годится для расследований и выгрузок по запросу пользователя.
  3. Восстановлением секции обратно в базу, когда понадобилось надолго. Редкий случай, но путь должен быть описан, иначе в момент нужды выяснится, что выгруженное некуда вернуть.

И два решения, которые надо принять сразу, а не потом: что в API — публичная лента показывает 90 дней, а запрос старше отвечает «доступно по запросу» или уходит в медленный путь с другим временем ответа (и это записано в контракте); и где живут персональные данные — выгруженная история содержит их, значит, у объектного хранилища те же требования к доступу, шифрованию и удалению по запросу субъекта, что и у базы. Последнее особенно важно: удалить данные человека из объектного хранилища сложнее, чем из таблицы, и способ надо предусмотреть заранее — обычно это перезапись затронутой выгрузки.

Что будет при отказах

Что сломалосьЧто происходит
Push-провайдер недоступенВоркер повторяет с паузами, при длительном отказе отключает попытки (circuit breaker); уведомление пользователь всё равно увидит в ленте — она идёт отдельной темой и от пуш-провайдера не зависит
Kafka недоступнаAPI принимает события, outbox копит; когда Kafka поднялась — события доливаются
Redis потерянСчётчик не показывается (или показывается без числа), лента работает — PostgreSQL жива
Аналитика отсталаСтатистика кампании запаздывает — это задекларировано в контракте; операционные статусы из PostgreSQL в порядке
Пик в 10× (инцидент у продьюсера)Ограничение темпа по продьюсеру на API; очередь сглаживает пик; кампании тормозятся первыми
Дополнительно: при первом чтении можно пропустить

Глубже: живой экран: опрос, SSE, WebSocket и pushрасширенное

Счётчик непрочитанных выше обновляется, когда пользователь обновил страницу. Для живого экрана, где уведомление появляется само, нужен транспорт, и выбор между четырьмя это половина вопросов к этой статье.

Опрос. Клиент спрашивает сервер раз в N секунд. Проще всего, работает через любой прокси, и нагрузка это число клиентов делить на интервал независимо от того, есть ли новости: миллион вкладок с опросом раз в 10 секунд это сто тысяч запросов в секунду впустую. Длинный опрос держит запрос открытым, пока не появятся новости или не истечёт таймаут, и снимает пустые запросы ценой висящих соединений.

SSE. Одно HTTP-соединение, по которому сервер шлёт события текстом; односторонний, автоматическое переподключение в браузере с передачей идентификатора последнего события, через который сервер досылает пропущенное. Проходит через прокси как обычный HTTP, и для уведомлений «сервер к клиенту» это выбор по умолчанию.

WebSocket. Двусторонний канал поверх одного соединения для чата, совместного редактирования, игр. Требует, чтобы каждый узел знал, кому что отправить, а значит шины между узлами и реестра соединений; за корпоративными прокси иногда не проходит, и клиент обязан уметь откатиться на SSE или опрос. Досылка пропущенного после обрыва в протокол не входит и делается самим приложением по идентификатору последнего события.

Push. Уведомление через платформу устройства (APNs, FCM, веб-push) приходит, когда приложение закрыто, и это единственный транспорт для закрытого приложения; доставка не гарантирована, задержка от секунд до минут, содержимое ограничено. Для открытого экрана push дополняет SSE или WebSocket, а не заменяет.

Цена постоянного соединения. Каждое открытое соединение это память и дескриптор на сервере, десятки тысяч на экземпляр, и балансировщик, который умеет долгие соединения; при выкате все соединения рвутся разом и переподключаются, и без разброса это шторм, о чём статья про масштабирование WebSocket. Для системы уведомлений это означает: SSE для открытых вкладок с досылкой по идентификатору, push для закрытых приложений, опрос как запасной путь, и один канал на пользователя, а не на вкладку.

Коротко

  • Уведомления выносят в отдельную платформу, чтобы продуктовые команды не думали о каналах, предпочтениях и отказах провайдеров. API принимает событие, а не «письмо» — канал и текст выбирает платформа по предпочтениям пользователя.
  • Между приёмом и доставкой всегда очередь (Kafka) — пики, асинхронность, изоляция каналов. Outbox на приёме — единственное место, где «не потерять» гарантируется транзакционно.
  • Два рубежа дедупликации: уникальный индекс по eventId на входе + проверка в каждом воркере. Кампании на миллион — в отдельный топик с меньшим приоритетом, чтобы не задерживать транзакционные уведомления.
  • Счётчик непрочитанных — только Redis: тысячи запросов в секунду, пересчёт из PostgreSQL при восстановлении. При отказе провайдера: повторы с паузой → circuit breaker → деградация (без числа на значке), но ничего не теряется.
  • Живой экран: опрос прост и дорог, SSE это односторонний HTTP с переподключением и досылкой по идентификатору, WebSocket для двусторонних сценариев с реестром соединений, push для закрытых приложений; постоянные соединения стоят память и требуют разброса при выкате.
  • Брокер выбирают по границе: до сотен в секунду — таблица в базе, нужны маршрутизация и приоритеты — RabbitMQ, нужны несколько потребителей, переигрывание и тысячи в секунду — Kafka; ключ раздела — идентификатор получателя, и он же даёт порядок внутри пользователя.
  • Шаблоны — ядро платформы: тройка «категория, канал, язык», проверка совместимости со схемой события при сохранении, экранирование подставляемого текста, множественное число и часовой пояс, версия шаблона у отправленного уведомления.
  • Повторы: классификация ответа провайдера, пять попыток с растущей паузой и разбросом, разные сроки по каналам, отложенные повторы в таблице или теме задержки, очередь недоставленных с владельцем; темп к провайдеру — общий ограничитель на все копии и пачки.
  • Отписка глобальная и одним действием, обязательные категории её игнорируют, тихие часы по поясу пользователя и предел частоты — всё в одном шаге проверки предпочтений, который нельзя обойти.
  • Сквозной путь: продьюсер ждёт 5–15 мс, до телефона доли секунды, лента появляется раньше пуша; история старше 90 дней читается из аналитического хранилища или прямо из объектного, и персональные данные там требуют того же, что в базе.

Что почитать дальше