Вводная
Привет, Хабр! Меня зовут Борис и в этом труде я поделюсь с тобой опытом проектирования и реализации сервиса массовых рассылок, как части объемлющей системы оповещения студентов преподавателями (далее также — Ада), которую тоже я осуществляю.
Ада
Нужна затем, чтобы свести на нет число прерываний учебного процесса по следующим причинам:
- Преподаватели не хотят делиться личными контактными данными;
- Студенты на самом деле тоже — у них просто выбора особо нет;
- В силу специфики моей альма-матер, многие преподаватели вынуждены или предпочитают использовать мобильные устройства без доступа к сети Интернет;
- Если передавать сообщения через старост групп, то в игру вступает эффект «испорченного телефона», а также фактор «ой, я забыл:(».
Работает примерно так:
- Преподаватель через один из доступных ему каналов связи: СМС, Telegram, SPA-приложение — передает Аде текст сообщения и список адресатов;
- Ада транслирует полученное сообщение всем заинтересованным* студентам по всевозможным каналам связи.
* Доступ к сервису предоставляется в добровольно-заявительном порядке.
Предполагается, что
- Общее число пользователей не превысит десяти тысяч;
- Соотношение студент — преподаватель / член УВП (деканаты, здравпункт, военно-учетный стол и т.д.) будет держаться на уровне 10:1;
- Оповещения текстовые по содержанию и носят преимущественно экстренный характер: «Моей пары сегодня не будет», «Тебя отчисляют))0» и т.д.
Ключевые требования к сервису рассылок
- Простота интеграции с другими информационными системами ВУЗа;
- Возможность отложенной доставки, принудительная перепланировка времени отправки сообщений, поставленных в очередь в неподобающее для приличных студентов время;
- Разделяемая между каналами связи история и ограничения на отправку;
- Достоверность и полнота обратной связи: если кому-то чего-то не дойдет, а понять это будет нельзя, то всем будет обидно.
Сие произведение состоит из пяти частей: вводной, подготовительной, концептуальной, предметной и заключительной.
Подготовительную часть можно смело пропускать, если вы знакомы с Redis интерпретацией Pub/Sub шаблона, а также механизмами событий, LUA-скриптинга и обработки устаревших ключей, кроме того, весьма желательно иметь хоть какое-то представление о микросервисной архитектуре ПО.
В предметной части обозревается код на Питоне, но я верю, что информации достаточно, чтоб вы могли написать подобное на чем угодно.
Подготовительная
С ключом может быть ассоциировано время жизни, по истечении которого он будет удален.
Значениями могут выступать строки, хэш-таблицы, списки и множества.
Любую модификацию пространства ключей можно отследить через встроенный механизм событий (отключен по умолчанию в угоду производительности).
В дополнение к транзакциям, пользователь может определять новые операции, что будут выполняться атомарно, используя языковые средства LUA 5.1.
- Pub/Sub — Redis. Пробегитесь глазами по первому параграфу, осознайте fire&forget момент, посмотрите, как работают команды
PUBLISH
,SUBSCRIBE
и их паттерн-вариации; - Redis Keyspace Notifications. Первые три параграфа;
- EXPIRE — Redis. Параграф «How Redis expires keys»;
- Redis 6.0 Default Configuration File. В дополнение к предыдущей ссылке. Строки 939:948 (The default effort of the expire cycle…);
- EVAL — Redis. Отличие
EVAL
отEVALSHA
, а также параграфы «Atomicity of scripts», «Global variables protection» и «Available libraries», в последнем нас интересует толькоcjson
; - Redis Lua Scripts Debugger. Не обязательно, но может прилично сэкономить вам слез в будущем. У меня вот кончились — пользуюсь каплями;
- Исторические аспекты появления микросервисной архитектуры. Тоже не обязательно, но весьма доходчиво и интересно.
Концептуальная
Наивный подход
Самое очевидное решение, которое можно придумать: несколько методов доставки (
send_vk
, send_telegram
и т.д.) и один обработчик, который будет вызывать их с нужными аргументами.Проблема расширяемости
Если мы захотим добавить новый метод доставки, то будем вынуждены модифицировать существующий код, а это — ограничения программной платформы.
Проблема стабильности
Сломался один из методов = сломался весь сервис.
Прикладная проблема
API разных каналов связи значительно отличаются друг от друга в плане взаимодействия. Например, ВКонтакте поддерживает массовые рассылки, но не более чем для сотни пользователей за вызов. Telegram же нет, но при этом разрешает больше вызовов в секунду.
API ВКонтакте работает только через HTTP; у Telegram есть HTTP-шлюз, но он менее стабилен, нежели MTProto и хуже документирован.
Таких различий достаточно много: максимальная длина сообщения,
random_id
, интерпретация и обработка ошибок и т.д. и т.п.Как с этим быть?
Было принято решение разделить процесс постановки сообщений в очередь и процессы отправки (далее — курьеры) на организационном уровне, причем так, чтобы первый даже не подозревал о существовании последних и наоборот, а связующим звеном между ними будет выступать Redis.
Непонятно? Закажите покушать!
А пока ждете — позвольте познакомить вас с моей интерпретацией сего благородного действа, начиная с оформления и заканчивая закрытой за курьером дверью.
- Вы нажимаете на большую желтую кнопку «Заказать»;
- Яндекс.Еда находит курьера, сообщает выбранные позиции ресторану и возвращает вам номер заказа, дабы разбавить неопределенность ожидания;
- Ресторан по завершении готовки обновляет статус заказа и отдает еду курьеру;
- Курьер, в свою очередь, отдает еду вам, после чего помечает заказ как выполненный.
Приятного аппетита!
Вернемся к проектированию
Возможно, что приведенная в параграфе ранее модель не вполне соответствует действительности, но именно она легла в основу разработанного решения.
Ассоциирующиеся с номером заказа данные будем называть историей, она позволяет в любой момент времени ответить на следующие вопросы:
- Кто отправил;
- Что отправил;
- Откуда;
- Кому;
- Кто и как получил.
История создается вместе с заказом как два отдельных Redis ключа, связанных через суффикс:
suffix={Идентификатор пользователя}:{UNIX-время в наносекундах}
История=history:{suffix}
Заказ=delivery:{suffix}
Заказ определяет — когда курьеры единожды увидят историю, чтобы, по завершении отправки, соответствующим образом изменить ответ на вопрос “Кто и как получил”.
“Зрение” курьеров работает через подписку на событие
DEL
ключей по форме delivery:*
.Когда наступает момент доставки, Redis удаляет ключ заказа, после чего курьеры приступают к его обработке.
Так как курьеров несколько — велика вероятность возникновения конкуренции на стадии изменения истории.
Избежать её можно, определив соответствующую операцию атомарно — в Redis это делается через LUA-скриптинг.
Детали реализации будут подробно рассмотрены в следующей главе. Сейчас же важно получить четкое представление о решении в целом с чем может помочь рисунок ниже.
Отслеживание статуса
Клиент может отследить статус доставки через ключ истории, который генерируется отдельным методом API разрабатываемого сервиса перед постановкой сообщения в очередь (как и номер заказа генерируется Яндекс.Едой в самом начале).
После генерации ключа, на него вешается (опционально и тоже отдельным методом) трекер с тайм-аутом, который будет следить за числом изменений истории курьерами (
SET
события). Только теперь сообщение ставится в очередь.Если курьер не находит контактов получателей в своем домене — канале связи, то он вызывает искусственное событие
SET
через команду PUBLISH
, тем самым показывая что он “в порядке” и ждать дальше не надо.Зачем мудрить с событиями в Redis, если есть RabbitMQ и Celery
На то есть как минимум пять объективных причин:
- Redis уже используется другими сервисами Ады, RabbitMQ/Celery — новая зависимость;
- Redis нужен нам, в первую очередь, как СУБД, а не средство IPC;
- Использования Redis’a как хранилища истории защищает нас от SQL-инъекций в текстах сообщений;
- Проблема масштабируемости не стоит и в обозримой перспективе не встанет. Кроме того, эта самая масштабируемость в контексте данной задачи достигается скорее за счет увеличения API-лимитов, нежели горизонтального наращивания вычислительных мощностей;
- Celery пока что не дружит с asyncio, а программный костяк проекта составляет уже реализованная с основой на asyncio библиотека.
Предметная
Система оповещения (объемлющая) исполнена в виде множества микросервисов. Удобства ради, интерфейсы, методы инициализации слоев данных, текста ошибок, а также некоторые блоки повторяющейся логики были вынесены в библиотеку
core
, которая, в свою очередь, опирается на: gino
(asyncio обертка SQLAlchemy
), aioredis
и aiohttp
.В коде можно увидеть разные сущности, например,
User
, Contact
или Allegiance
. Связи между ними представлены на диаграмме ниже, краткое описание — под спойлером.У пользователя есть роль: студент, преподаватель, деканат и т. д., а также почта и имя.
С пользователем может быть связан контакт, где провайдер: ВКонтакте, Telegram, сотовый и т. д.
Пользователи могут состоять в группах [allegiance].
Из групп можно формировать потоки [supergroup].
Группы и потоки могут принадлежать [ownership] пользователям.
Генерация ключа истории
delivery/handlers/history_key/get — GitHub
Очередь
delivery/handlers/queue/put — GitHub
Обратите внимание на:
- Комментарий 171:174;
- То, что все манипуляции с Redis’ом [164:179] завернуты в транзакцию.
Зрение курьеров [94:117]
core/delivery — GitHub
Обновление истории курьерами
core/redis_lua — GitHub
Инструкции [48:60] отменяют преобразование пустых списков в словари (
[] -> {}
), так как большинство языков программирования, и CPython в том числе, интерпретируют их иначе, нежели LUA.ISS: Allow differentiation of arrays and objects for proper empty-object serialization — GitHub
Трекер
delivery/handlers/track/post — GitHub — имплементация.
connect/telegram/handlers/select — GitHub [101:134] — пример использования в пользовательском интерфейсе.
Курьеры
Всякая доставка из
task_stream
(@Зрение курьеров) обрабатывается в отдельной asyncio-сопрограмме.Общая стратегия работы с временными ограничениями прикладных интерфейсов такова: мы не считаем RPS (requests per second), но корректно /реагируем/ на ответы по типу
http.TooManyRequests
. Если интерфейс реализует, помимо глобальных (на приложение), еще и пользовательские временные лимиты, то они обрабатываются в порядке очереди, т.е. сначала мы отправляем всем кому можем и только потом начинаем выжидать, если не очень долго.
Telegram
courier/telegram — GitHub
Как было замечено ранее, MTProto интерфейс Telegram’а выигрывает у HTTP-аналога в стабильности и размере документации. Для взаимодействия с оным мы воспользуемся готовым решением, а именно — LonamiWebs/Telethon.
ВКонтакте
courier/vk — GitHub
ВКонтакте API поддерживает массовые рассылки через передачу списка идентификаторов в метод messages.send (не более сотни), а также позволяет “склеить” до двадцати пяти
messages.send
в одном execute, что дает нам 2500 сообщений за вызов.execute
в том числе, наиболее полно описаны в русской версии документации.Заключительная
В настоящем труде предложен способ организации многоканальной системы массового оповещения. Полученное решение вполне удовлетворяет запросу (@Ключевые требования к сервису рассылок) большинства интересантов, а также предполагает возможность расширения.
Основной недостаток заключается в fire&forget эффекте Pub/Sub, т.е. если удаление ключа заказа придется на момент “болезни” одного из курьеров, то в соответствующем домене никто ничего не получит, что впрочем будет отражено в истории.
Sabubu
Честно говоря, выглядит как сильно переусложненное решение простой задачи. Представляю себе, каково будет это поддерживать. По моему, для рассылок хватило бы БД SQL на несколько таблиц, простой формочки на PHP для создания рассылки и пары крон-скриптов на PHP для собственно отправки. Redis кажется неудачным выбором, так как в обычной БД есть SQL-запросы для произвольной выборки данных, а в нем нет, данные ищутся только по ключу.
KzmnbrS Автор
Осталось только выучить PHP. Зачем-то.
Что до остального — действительно можно было решить проще через крон-скрипты и SQL.
И… я скорее согласен с тем, что сам придумал проблему ради проблемы.
В любом случае — опыт интересный и результат получился не то чтобы плохой.
avengerweb
Такой себе аргумент, вы же изучили питон — зачем то, изучили редис зачем-то. Одна из проблем поиска сейчас специалистов в том, что они настолько привязаны к инструментам с которыми привыкли работать где то там, что пишут вот такие сложные системы для простых задач.
Вы в посте заикнулась про микросервисную архитектуру, а потом снизу говорите о том, что взяли эти инструменты только потому что вы не хотите использовать другие. Один из бонусов микросервисной архитектуры это то, что вы можете использовать инструменты ПОДХОДЯЩИЕ для задачи и будучи не ограниченным стеком оставшемся от предыдущих задач.
Так же стоит задуматься почему реляционная база данных называется реляционной. У вас четко прослеживаются отношения между таблицами, вы даже нам схему начертили, но вдруг решили хранить данные в редисе?
Не нравится php или хочется реалм-тайм взаимодействия, выкините питон возьмите ГО, Котлин (если гонитесь за модой), храните данные в любой БД (хоть sqlite) и выгружайте горячии данные (те что нужно обработать) в память.
KzmnbrS Автор
В Редисе хранится лишь часть данных: коды подтверждения почты, телефонов, а также описанные в статье история и заказ. Остальное — в PostgreSQL.
Мне нравится Go, но на нём разработка заняла бы больше времени и не то чтобы в чем-то от этого выиграла в силу отсутствия проблемы масштабируемости.
Учить PHP ради PHP особого смысла не вижу.
mayorovp
Крон-скрипты можно писать на любом языке, не обязательно PHP.
DimkaVolodin
Конечно, хватило бы. Автор решил просто поэксперементировать
maxp
Более того, как раз рассылки через SQL или даже, извините, через MongoDB делаются удобнее, чем через Редис. Так как вот с этой самой «нотификацией студентов» есть еще масса проблем, которые в этой статье не рассмотрены.