Представьте: пользователь оформил заказ, и ему нужно прийти подтверждение — 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 запросов/с: нужны порционная отправка и ограничение темпа |
Из этих чисел сразу видно три вещи:
- Пики и асинхронная природа отправки — значит, между приёмом события и реальной доставкой обязана быть очередь.
- 9 ТБ истории — не для обычной таблицы. Нужно партиционирование и перенос старых данных в холодное хранилище.
- Счётчик непрочитанных — самое горячее чтение. Базе данных туда лезть не стоит, нужен кеш.
Почему именно 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 наполняется из событий доставки.
Схема: как событие превращается в уведомление
Каналы разведены по темам не для красоты: тормозящая почта не должна задерживать пуши. Настройки применяются один раз, в 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». Но письмо и пуш уходят наружу, к чужому провайдеру, и вот там откатывать уже нечего: отправленное письмо не отзовёшь. Сквозного «ровно один раз» до получателя не бывает в принципе — поэтому защищаемся сами, двумя рубежами:
- На приёме — уникальный индекс по
eventIdв базе. Второй запрос с тем жеeventIdвернёт уже существующийnotificationId. - В воркерах — перед отправкой воркер проверяет, не отправлял ли он уже пару
notificationId + канал. Держать такую отметку в памяти процесса бесполезно: воркеров в группе несколько, и после перезапуска память пуста — дубль спокойно пройдёт. Отметку кладут в общее хранилище: либо отдельной строкой с уникальным индексом по(notification_id, channel)в PostgreSQL — вторая вставка просто упадёт, и это и есть проверка, — либо ключом в Redis черезSET ... NXсо сроком жизни в несколько суток, с запасом на самые долгие повторы.
Эта пара рубежей — стандартный подход для любого пайплайна, где доставка «хотя бы один раз».
Смотрите на второй и третий ряд: повтор запроса продьюсера отсекает уникальный индекс по eventId, а повтор сообщения из очереди отсекает отметка по паре «уведомление и канал» в воркере.
Повторы, недоставленные и темп к провайдерам
«Воркер повторяет с паузами» — самая частая формулировка и самая недоговорённая. Разберём, потому что от этих чисел зависит и доставляемость, и то, не завалите ли вы провайдера.
Что повторять, а что нет. Первое решение — по коду ответа провайдера, и оно не про числа, а про смысл: временный сбой (сеть, 5xx, срок ожидания, «слишком много запросов») повторяют; окончательный отказ (неверный адрес, отписавшийся получатель, устаревший токен устройства, заблокированный номер) повторять бессмысленно и вредно — провайдеры за такое ухудшают репутацию отправителя. Значит, у ответа есть классификация, и она часть кода отправки, а не «повторим всё».
Сколько раз и с какими паузами. Для уведомлений разумная схема — пять попыток с растущей паузой и разбросом: 10 секунд, 1 минута, 5 минут, 30 минут, 2 часа. Итого около трёх часов на доставку, и это осознанная граница: пуш, доставленный через три часа, обычно уже не нужен, а письмо — ещё нужно. Отсюда важное: сроки повторов различаются по каналам — пуш перестают повторять через десятки минут, письмо повторяют до суток. Разброс (случайная добавка к паузе) обязателен: без него все сообщения, упавшие в момент отказа провайдера, повторяются одновременно и валят его снова.
Где живут отложенные повторы. В памяти потребителя их держать нельзя (перезапуск — и всё потеряно), задерживать раздел Kafka тоже нельзя (одно неудачное сообщение остановит все следующие — это классическая ошибка). Рабочие варианты два: тема повторов с задержкой (отдельная тема на каждый уровень задержки, потребитель читает её с паузой) или таблица отложенных отправок в базе, из которой фоновая задача забирает созревшие. Второй вариант проще и обычно достаточен; первый — когда объём повторов большой.
Недоставленные. После последней попытки уведомление попадает в очередь недоставленных (или таблицу с таким статусом) — не пропадает и не остаётся навсегда «в отправке». Что с ней делают: у неё есть владелец и дежурный смотрит на её размер, потому что рост очереди недоставленных — первый признак проблемы с провайдером или с данными; сообщения из неё можно переотправить после починки; и по ней считают доставляемость. Пустая очередь недоставленных, о которой никто не знает, — то же, что её отсутствие. Отдельно: у уведомления остаётся статус («не доставлено по каналу пуш»), потому что это видно пользователю в ленте и нужно поддержке.
Темп к провайдерам. Из чисел выше — 3000 запросов в секунду в пуш-провайдера, и вот здесь ограничение темпа не защита от клиента, а условие работы: у каждого провайдера свой предел, и превышение означает отказы и попадание в чёрный список. Механика — дырявое ведро на нашей стороне: отправка идёт с постоянной скоростью не выше договорной, излишек ждёт в очереди. Практически это значит три вещи. Ограничитель общий на все копии отправщика (счётчик во внешнем хранилище), иначе десять копий умножат ваш лимит на десять. Пачки, где провайдер их поддерживает: отправка списком по 500 получателей вместо 500 запросов — это и быстрее, и не съедает лимит. И ограничитель на каждого провайдера свой, потому что пределы разные, а при достижении предела правильное поведение — замедлиться, а не начать терять сообщения.
Что видно на графиках. Три метрики, без которых всё это не эксплуатируется: доля доставленных с первой попытки, средний срок от приёма события до доставки по каналам, размер очереди недоставленных и отставание потребителей. Первая падает — что-то с провайдером или с данными; вторая растёт — упёрлись в темп; третья растёт — разбираться немедленно.
Кампании на миллион получателей
Транзакционное уведомление — один пользователь. Кампания — потенциально весь маркетплейс.
Разворачивать миллион строк синхронно на приёме нельзя — это займёт минуты и заблокирует всё. Поэтому кампания — это одно событие, которое попадает в отдельный топик с меньшим приоритетом. Специальный fan-out воркер читает его и порциями создаёт индивидуальные уведомления.
Главное правило: кампании не должны задерживать транзакционные уведомления. Отдельный топик с отдельным пулом потребителей — код подтверждения заказа не ждёт, пока разошлись промо-письма.
Кампания расходится на порции внутри своей темы, а транзакционный путь внизу идёт отдельным пулом потребителей и кампанией не задерживается.
Отписка и тихие часы
Для транзакционных уведомлений это мелочь, для кампаний — обязательное условие: рассылка без работающей отписки означает жалобы, блокировку отправителя у провайдера и претензии по закону. Поэтому это часть дизайна, а не «добавим в интерфейс».
Отписка одним действием, и она глобальная. Требование простое: из любого маркетингового письма можно отписаться, не входя в личный кабинет и не заполняя форму, — то есть ссылка с подписанным одноразовым ключом, по которой отписка происходит сразу. И ключевое для архитектуры: отписка действует сразу и для всех каналов и кампаний той же категории, а не только для того письма, из которого пришли. Значит, решение о том, отправлять ли, принимается в одном месте — на шаге выбора получателей и каналов, и это тот же самый механизм предпочтений, что и в 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 | — |
| 2 | API проверяет схему события и права продьюсера | 1 мс |
| 3 | Вставка события в PostgreSQL с уникальным индексом по eventId плюс запись в таблицу исходящих сообщений — одной транзакцией | 3–8 мс |
| 4 | Ответ 202 с notificationId — сервис заказов свободен | итого 5–15 мс |
| 5 | Отправщик исходящих читает новую строку и публикует событие в Kafka с ключом userId | 10–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 дней. Вот вопрос, который обычно остаётся за кадром, и ответ на него определяет, будет ли механизм работать. Три варианта, по возрастанию удобства:
- Через аналитическое хранилище. Оно и так наполняется из событий доставки, и при выгрузке секций туда же попадают старые события. Тогда «покажи, что приходило пользователю год назад» — это запрос в аналитику, а не в базу. Годится, потому что такие запросы редкие и не требуют миллисекунд.
- Прямым чтением объектного хранилища внешним движком запросов. Дешевле, дольше по времени ответа, годится для расследований и выгрузок по запросу пользователя.
- Восстановлением секции обратно в базу, когда понадобилось надолго. Редкий случай, но путь должен быть описан, иначе в момент нужды выяснится, что выгруженное некуда вернуть.
И два решения, которые надо принять сразу, а не потом: что в 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 дней читается из аналитического хранилища или прямо из объектного, и персональные данные там требуют того же, что в базе.
Что почитать дальше
- Метод системного дизайна — пошаговый процесс, который применялся здесь.
- Строительные блоки — Kafka, Redis, партиционирование и другие компоненты из этой схемы.
- Оформление и защита дизайна — как оформить такой разбор в документ.