Я читал некоторые твиты о масштабировании и о том, что Laravel немного отстает от других фреймворков, и вспомнил, что у меня есть крутая история о масштабировании.
Джек Эллис уже написал очень интересный блог о том, как Fathom Analytics масштабировал Laravel, но моя история совсем о другом.
Сейчас вы прочтете, как мы масштабировались до более чем ста миллионов заданий и пиков в 30 000 запросов в минуту всего за двенадцать часов, используя только Laravel, MySQL и Redis.
Во-первых, я должен дать некоторый контекст: в 2019 году я присоединился к довольно крутому продукту: SaaS, который позволял компаниям отправлять маркетинговые SMS-кампании.
Приложение занималось только фактической отправкой и взимало наценку за каждое SMS — компании отвечали за обновление списков и подписание с SMS-провайдером, который мы поддерживали.
В стеке были Laravel, Vue и MongoDB.
В то время, когда я присоединился к команде, приложение обрабатывало от 1 до 2 миллионов сообщений в день.
Важно отметить кое-что: отправка сообщения не была такой уж тривиальной. На каждое отправленное SMS следовало ожидать как минимум один webhook — «отчет о доставке». Любые ответы на SMS (вообще любое сообщение, отправленное на номер, обрабатываемый нами) также приходили в виде webhook, и мы должны были внутренне привязать их к отправленному сообщению.
У платформы было два места с большим количеством трафика/обработки: загрузка списков и отправка сообщений.
Вот как выглядела схема БД:
Не спрашивайте меня, почему было 3 коллекции сообщений. Это очень скоро станет проблемой.
Загрузка списка была не такой уж тривиальной: это были очень большие CSV (например, 50M+ контактов), и работать с ними было сложно. Файл разбивался на части и отправлялось задание на загрузку записей — теперь загрузка этих записей также была сложной.
Нам нужно было получить географические данные на основе номера — для этого мы делили номер на 3 части и использовали их для получения геоданных из некоторых таблиц в нашей базе данных (или, скорее, коллекций). Это был относительно дорогой процесс, особенно потому, что он происходил для каждого загруженного контакта, поэтому для списка из 50 миллионов человек можно было ожидать не менее 50 миллионов запросов.
Работа с кампаниями также была непростой: когда кампания приостанавливалась, записи перемещались из ожидающих сообщений в приостановленные.
Когда сообщение было отправлено, оно удалялось из ожидающих сообщений и перемещалось в отправленные.
Также важно отметить, что когда кто-то отвечал на сообщение, это, возможно, вызывало новый набор сообщений на этот номер (и веб-крючки). Таким образом, одно сообщение может генерировать до 4+ записей.
Не нужно много думать, чтобы понять, что это не очень хорошо масштабируется. Очень быстро у нас возникло множество проблем:
- Загрузка списков никогда не работала корректно. Они были огромными, и чаще всего задания завершались по таймауту, пока очередь не отбрасывала их.
- Создание контакта было сложным и интенсивным: получение географических данных было довольно дорогостоящим, кроме того, были и другие запросы. Создание одного контакта требовало обращения к нескольким местам приложения.
- Когда запускалось много кампаний, система падала, потому что мы получали слишком много запросов. Поскольку система была синхронной, нашему серверу требовалось некоторое время, чтобы ответить, запросы накапливались, а затем все взрывалось.
- Mongo работал очень хорошо, пока в каждой коллекции не было несколько миллионов записей. Копировать данные из одной коллекции в другую было невероятно дорого — каждая из них имела уникальные свойства, и рефакторинг был невозможен.
- Продвигать функции, исправления и улучшения было очень сложно. До моего прихода не было тестов, и даже тогда у нас не было надежного набора. Уменьшение очереди было заботой номер 1.
- Очередь отправки фактически обрабатывалась скриптом, написанным на Go. В основном он считывал данные из ожидающих исходящих сообщений и отправлял их, но это было довольно просто — не было никакого пользовательского интерфейса, который мы могли бы проверить, а добавление новых провайдеров отправки было очень проблематичным, поскольку этот скрипт также нужно было менять.
Приложение, очевидно, было очень плохо разработано. Я не уверен, почему они решили использовать 3 коллекции для отправки сообщений, но это было огромной проблемой.
Компания попыталась нанять специалиста по Mongo, чтобы он сделал какую-то магию — я не специалист по БД, но помню, что там было много шардинга, а ежемесячный счет почти достигал пятизначной цифры.
Это позволило нам достичь ~4M/sends в день, но это все еще было очень проблематично, и данные приходилось часто очищать.
Примерно в это время было решено, что я пойду на *черную операцию* и перестрою все с нуля в качестве MVP. Нам не нужно было много функций (я не упомянул 1/3 из них — их было много) для этого — просто подтвердить, что мы сможем удобно отправлять эти сообщения.
У меня не было большого опыта работы с микросервисами и devops, поэтому я просто решил использовать то, что знал, и игнорировать новые, блестящие вещи.
Я решил использовать Laravel, MySQL и Redis. Вот и все. Ни Kubernetes, ни Terraform, ни микросервисов, ни автомасштабирования, ничего.
Новая схема БД выглядела примерно так:
Некоторые другие бизнес-правила я не упомянул:
- Во время всех отправок нам нужно было проверить, получал ли этот номер сообщение от той же компании в течение 24 часов. Это означало дополнительный запрос.
- Нам нужно было проверить, должен ли контакт получить сообщение в определенное время — законы SMS-маркетинга разрешают контактам получать сообщения только в определенный промежуток времени, поэтому проверка часового пояса была очень важна.
- В каждом входящем ответе нужно было проверить, есть ли в нем стоп-слова — они настраивались, так что это также означало дополнительный запрос. Если это так, нам нужно было заблокировать этот номер для данной компании — опять же, еще один запрос.
- Также нужно было проверить наличие ответных ключевых слов — это были слова, которые запускали новое исходящее сообщение. Опять же, дополнительный запрос и, возможно, целый процесс отправки.
- Каждая кампания была привязана к аккаунту, у которого было много номеров. Во время выполнения нужно было рассчитать, сколько сообщений может отправить аккаунт за одну минуту, не сжигая номера и не попадая под дросселирование.
Для решения этих проблем я прибегнул к помощи двух своих лучших друзей: Очереди и Redis. Для работы с очередями я использовал проверенный в боях и простой в использовании Laravel Horizon.
Новое приложение выглядело следующим образом:
- Каждое сообщение хранилось в таблице messages, с внешним ключом, указывающим на кампанию, к которой оно относилось, и полем sent_at timestamp, которое можно было обнулить. Таким образом, можно было легко (и быстро, с помощью индексов) проверить ожидающие и отправленные сообщения.
- Каждая кампания имела столбец статуса, который определял, что должно произойти: ожидание, отмена, пауза, выполнение и завершение. Ожидание означало, что сообщения все еще добавляются в таблицу.
- Ничто не обрабатывалось синхронно — все отправлялось в очередь. От веб-крючков до импорта списков, от создания контактов до отправки сообщений.
- Когда импортировался список, он обрабатывался партиями по 10 000 штук — это позволяло обрабатывать задания довольно быстро, не беспокоясь о тайм-аутах.
- Когда создавалась кампания, сообщения генерировались партиями по 10 000 — когда генерировалась последняя партия, статус кампании менялся на «приостановлена».
- Помните географические данные? Это было очень интенсивно. Представьте себе сотни миллионов контактов, ежедневно импортируемых различными компаниями.Это было отложено в Redis — чаще всего некоторые номера были общими для некоторых записей, которые мы использовали для получения географических данных, и их кэширование значительно ускоряло работу.
- Обработка сообщений оставалась сложной, но более простой: весь процесс был основан на аккаунтах, а не на кампаниях, поскольку нам нужно было соблюдать максимальную пропускную способность каждого аккаунта, а несколько кампаний могли использовать один и тот же аккаунт. Была запланирована работа, выполняемая каждую минуту, которая подсчитывала, сколько сообщений может отправить аккаунт, получала нужное количество ожидающих сообщений в случайном порядке, а затем отправляла одно задание для каждого ожидающего сообщения.
Помните ключевые слова stop и reply? Они тоже попадали в кэш.
Определение того, было ли недавно отправлено исходящее сообщение? Тоже кэш.
Horizon организовывал несколько очередей — одна для импорта CSV, другая для импорта каждого контакта, одна для диспетчеризации заданий учетной записи, одна для отправки сообщений, одна для обработки webhooks и т.д.\
Инфраструктурная часть выглядела так:
Я не могу вспомнить размер каждого сервера, но кроме MySQL и Redis, все они были довольно слабыми.
С таким стеком приложение легко справлялось с отправкой более 10 миллионов сообщений и более 100 миллионов заданий в очереди в течение 12 часов.
Оно довольно быстро перевалило за 1B записей, и все еще было гладким как масло. Я не помню финансовые показатели, но ежемесячный счет вырос с пятизначной суммы (+ консультант по БД) до менее тысячи долларов.
Никакого автомасштабирования, никаких K8s, ничего — только основы и то, что я знал относительно хорошо.
Пара мыслей по поводу общей инфраструктуры:
Правильная работа с индексами в MySQL принесла свои плоды — у нас их не было, пока в них не было необходимости.
Redis использовался очень широко и со значительными TTL — дешевле добавить больше оперативной памяти в стек, чем допустить падение системы. В целом, все работало отлично, но обработка недействительности кэша иногда была сложной.
Переписывание способа отправки сообщений значительно упростило работу, поскольку я мог инкапсулировать уникальное поведение каждого драйвера в отдельный класс и заставить их следовать контракту.
Это означало, что добавление нового отправляющего драйвера сводилось к созданию нового класса, реализации интерфейса и добавлению пары методов — которые позволяли ему отображаться в пользовательском интерфейсе, обрабатывать веб-крючки и отправлять сообщения.
Что касается Laravel Horizon, один важный момент: задания должны быть тупыми. По-настоящему тупыми.
Я привык передавать модели в качестве аргументов заданиям — Laravel справляется с этим необычным образом, десериализуя модель, передавая ее заданию, а затем сериализуя ее в рабочей очереди. Когда это происходит, запись извлекается из базы данных: выполняется запрос.
Это определенно то, чего я не хотел, поэтому задания должны быть как можно более тупыми в том, что касается базы данных — все необходимые аргументы передавались непосредственно из задания обработчика счетов, поэтому к моменту его выполнения оно уже знало все, что нужно.
Нет необходимости передавать экземпляр List в задание, если ему нужен только list_id — просто передайте «int $listId» вместо этого. 😉.
В заключение хочу сказать, что тесты сделали огромную разницу. В старом приложении не было многих тестов, кроме тех, что я написал, и оно было довольно нестабильным. Знание того, что все работает так, как я задумал, давало мне уверенность.
Если бы я делал это сегодня, я бы, вероятно, выбрал другие инструменты: Swoole и Laravel Octane, наверняка. Может быть, SingleStore для базы данных. Но в целом, я доволен тем, что выбрал тогда, и это сработало очень хорошо, хотя и оставило место для улучшения и, возможно, изменения пары вещей.
Я бы также, определенно, обратился за помощью к Аарону Фрэнсису и Джеку Эллису.
Но да, это все, конец истории. Счастливы и дальше. 😁