Wróć do bloga

Chunkowanie kampanii, by dostarczyć milion wiadomości na godzinę

Marek KoncewiczMarek Koncewicz
4 stycznia 20264 min czytania
Chunkowanie kampanii, by dostarczyć milion wiadomości na godzinę

W tym artykule przedstawiam jeden z wykorzystanych przez nas mechanizmów przy planowaniu i dostarczaniu kampanii marketingowej.

W pierwszej kolejności rekomenduję zapoznać się z naszą realizacją aplikacji SaaS, z której wywodzi się ten temat.

Kontekst

Projekt EngageMail rozwijał się stopniowo. Już w czwartym miesiącu developmentu wdrożyliśmy pierwszą wersję, gotową do wykorzystania. W tej pierwszej wersji mechanizm chunkowania jeszcze nie był wprowadzony.

Zaraz po wdrożeniu, kiedy system był wykorzystywany każdego dnia w trybie produkcyjnym, pojawiła się konieczność wprowadzenia tego mechanizmu.

Dostarczanie wiadomości e-mail przez protokół SMTP oddelegowaliśmy Postfiksowi. O tym, jak złożona jest ta domena, najlepiej świadczy sama dokumentacja Postfiksa – setki parametrów konfiguracyjnych, których możliwe kombinacje przekładają się na tysiące dedykowanych setupów.

Dlatego nie ma opcji, by samodzielnie implementować ten protokół, skoro mamy świetne rozwiązania open source, sprawdzone od lat.

Postfix jest konfigurowany pod konkretne zadanie – nie jest przystosowany do dynamicznego zarządzania ustawieniami. Pewne parametry ustawiliśmy zgodnie z naszymi potrzebami, lecz resztę pozostawiliśmy bez zmian.

Skonfigurowanego Postfiksa pod mass mailing można znaleźć w naszym repozytorium na GitHubie.

Postfix po swojej stronie kolejkuje wszystkie wiadomości i równomiernie dostarcza je do użytkowników. Przy wdrożeniu pierwszej wersji EngageMail było założenie, że ta właściwość w pełni wystarczy w kontekście balansowania dostarczalnością.

W praktyce pojawiło się kilka problemów:

  • Kampanie marketingowe były realizowane na znacznie większych zbiorach danych, niż zakładano, i mogły sięgać kilkuset tysięcy użytkowników.
  • Realizacja kampanii na więcej niż 10 tys. kontaktów powodowała nadmierne zużycie CPU na instancji z Postfiksem.

Realizacja kampanii na duże zbiory – jako pierwszy wspomniany problem – skutkowała wydłużonym czasem realizacji kampanii. Jedna instancja SMTP dostarczała do 100 tys. wiadomości na godzinę. Pewne ograniczenia po stronie serwerów odbierających wiadomości nie pozwalały na liniowe zwiększenie przepustowości z jednego adresu IP. Kampania ma zostać zrealizowana w konkretnych godzinach, a realizacja kampanii na milion wiadomości na przestrzeni dziesięciu, a czasami i więcej godzin, nie wchodziła w grę.

Nadmierne obciążenie serwera SMTP – jako drugi problem – wynikało z liniowego zapotrzebowania na zasoby podczas analizy logów. Nasz serwer SMTP nie zajmował się tylko dostarczaniem e-maili. Odpowiadał również za nadawanie statusu – czy wiadomość jest dostarczona i jaki jest status odpowiedzi serwera-odbiorcy – każdej wysłanej wiadomości. Postfix zapisuje wszystkie dane do logów, a analiza odbywała się przy użyciu wbudowanych w Linuksa narzędzi, takich jak grep czy cat. Im większy wolumen należało dostarczyć, tym większy log generował Postfix i tym większe obciążenie dostawała instancja SMTP.

Pierwszy problem rozwiązaliśmy poprzez wprowadzenie własnego mechanizmu chunkowania i balansowania kampanii. Szczegółowa realizacja znajduje się niżej.

Drugi zaś rozwiązaliśmy wydaniem kolejnej wersji serwera SMTP, w której analizę logów oddelegowaliśmy do Fluentd, i już nie mieliśmy liniowej zależności obciążenia serwera od liczby wysłanych wiadomości.

Chunkowanie

Zasadniczo jest to bardzo proste. Od strony technicznej proces polega na podzieleniu jednej kampanii na kilka mniejszych.

Dla osoby, która planuje i zakłada kampanię marketingową, ten proces nie jest jawny. Zupełnie nie musi się zastanawiać, czy chunkowanie się powiedzie, ani spełniać żadnych dodatkowych warunków. Planner tworzy kampanię na konkretny zbiór danych, a proces cięcia kampanii na kawałki odbywa się całkowicie automatycznie.

Algorytm jest prosty: chunk nie może być większy niż 10 tys. kontaktów. Nie oznacza to jednak, że wszystkie chunki (oprócz ostatniego) mają ten rozmiar.

Niektórzy operatorzy skrzynek pocztowych nakładają na nasz system ograniczenia w postaci liczby dostarczanych wiadomości na godzinę. Rozkładamy dostarczalność per minuta i nie zawsze jest to okrągła wartość. Jeśli do konkretnego operatora możemy dostarczyć tylko 350 wiadomości na minutę z jednego adresu IP, to matematyka wygląda następująco:

rozmiar_chunka = floor(max_rozmiar / limit_operatora) × limit_operatora

Czyli: maksymalny rozmiar chunka dzielimy całkowitoliczbowo przez limit operatora (odrzucamy resztę z dzielenia), a wynik mnożymy z powrotem przez ten limit. Efektem jest największa wielokrotność limitu, która mieści się w maksymalnym rozmiarze chunka.

Priorytetyzacja

Klienci pragną dostarczyć użytkownikom swoje propozycje reklamowe w godzinach pracy i nierzadko kilkadziesiąt kampanii jest zaplanowanych na godziny poranne, między 7 a 9 rano. Biorąc pod uwagę, że każda kampania składa się z chunków, może zajść potrzeba realizacji kilkuset chunków naraz.

Jeden serwer SMTP obsługuje jeden chunk, a liczba instancji Postfiksa jest z góry zdefiniowana. Przekłada się to na tzw. konkurencję o zasoby, kiedy zapotrzebowanie znacznie przewyższa przepustowość systemu.

Przy dużej liczbie kampanii zaplanowanych na tę samą godzinę istnieje ryzyko monopolizacji (zablokowania) wszystkich dostępnych serwerów SMTP przez chunki jednej kampanii. To bardzo niepożądana sytuacja, która wymaga wprowadzenia mechanizmu priorytetyzacji.

Zbudowaliśmy ten mechanizm na poziomie zapytania SQL.

Kandydatami są tylko chunki spełniające warunki wstępne: nieanulowane, nieukończone, jeszcze niewystartowane, a ich kampania ma scheduled_for <= teraz i należy do kolejki danego operatora.

Spośród nich wybierany jest zawsze pierwszy według sortowania:

  • cnt, rosnąco – liczba serwerów SMTP aktualnie pracujących nad tą kampanią
  • scheduled_for, rosnąco – data zaplanowania kampanii
  • number, rosnąco – numer chunka w ramach kampanii

Tu można zauważyć dwa znane od lat wzorce zarządzania zasobami, połączone w jedno.

Wartość cnt reprezentuje strategię fair-share – „sprawiedliwy podział” – która zamiast kolejności przybycia patrzy na to, ile zasobów każdy uczestnik już konsumuje, i przydziela kolejny wolny zasób temu, kto ma ich najmniej.

Sortowanie po scheduled_for można określić mianem FIFO (First In, First Out) – „pierwszy na wejściu, pierwszy na wyjściu” – zadania wykonywane są dokładnie w takiej kolejności, w jakiej się pojawiły. To zwykła kolejka jak w sklepie – kto przyszedł pierwszy, ten pierwszy zostanie obsłużony.

W praktyce oba mechanizmy najczęściej się łączy, i tak właśnie jest tutaj: fair-share jest kryterium nadrzędnym (wyrównuje liczbę serwerów między kampaniami), a FIFO działa jako kryterium rozstrzygające – gdy dwie kampanie mają tyle samo przydzielonych serwerów SMTP, pierwszeństwo ma ta zaplanowana wcześniej.

Dzięki temu system jest jednocześnie sprawiedliwy (żadna kampania nie zostanie zagłodzona) i przewidywalny (przy równych szansach decyduje kolejność zgłoszenia).

Marek Koncewicz
Marek KoncewiczFullstack Engineer

Ewangelista Laravela. Zwolennik produktowego podejścia do rozwoju aplikacji webowych.