Podsumowanie artykułu
- Generuj kody asynchronicznie — przeniesienie procesu generowania kodów do kolejki (np. RabbitMQ) odciąża system i pozwala utrzymać wysoką responsywność aplikacji.
- Unikaj długotrwałych operacji w kontrolerach — umieszczanie ciężkich zadań bezpośrednio w akcjach HTTP blokuje aplikację i prowadzi do spadku wydajności.
- Wykorzystuj możliwości bazy danych — deduplikacja i batchowe zapisy zwiększają wydajność oraz zmniejszają liczbę zapytań do bazy.
- Projektuj z myślą o skalowalności — nawet jeśli dziś generujesz tysiąc kodów, przygotuj system tak, by jutro poradził sobie z milionem.
Systemy lojalnościowe występują w coraz większej ilości serwisów internetowych. Podstawowym przypadkiem są oczywiście aplikacje e-commerce, jednak coraz więcej systemów udostępnia użytkownikom model subskrypcyjny. Również w takim modelu jest miejsce na przyznawanie rabatów.
Wyróżniamy dwa podstawowe podejścia tworzenia kodów rabatowych. Pierwsze z nich, to wspólny kod rabatowy wielokrotnego użytku. Może być on używany w globalnych akcjach promocyjnych, takich jak wyprzedaż sezonowa. Drugie podejście to generowanie indywidualnych kodów rabatowych i rozsyłanie ich do konkretnych klientów. Działamy tutaj psychologicznie — jasno sygnalizujemy użytkownikowi, że jest indywidualnym odbiorcą kodu rabatowego, przez co czuje się on doceniony i chętniej dokona zakupu.
Z oczywistych względów podejście pierwsze jest zdecydowanie prostsze i nie wymaga wielkiego obciążenia systemu. Z drugim podejściem jest trochę więcej zabawy i to na nim skupimy się w tym wpisie.
Dlaczego jest więcej zabawy? Ponieważ akcje promocyjne nigdy nie są kierowane do pojedynczych użytkowników. Zawsze jest to określona grupa, której wielkość może być bardzo duża. Wyobraźmy sobie generowanie tysiąca, setek tysięcy czy milionów kodów rabatowych. Taka funkcjonalność zrobiona w nieodpowiedni sposób mogłaby „zamrozić” system na długi czas.
W ramach wpisu utworzymy przykładową aplikację. Przejdziemy przez generowanie kodów od prostego, mało wydajnego sposobu, po pełne rozwiązanie wykorzystujące asynchroniczną kolejkę.
W ramach wpisu powstało repozytorium w serwisie Github. W repozytorium znajduje się pełny kod przedstawiony na listingach.
Zanim zaczniemy, czyli założenia aplikacji
Celem wpisu nie jest dostarczenie rozwiązania gotowego do wdrożenia. Chcę tu raczej wskazać możliwości, nakierować na sposób rozwiązania podobnego problemu. Należy też pamiętać, że każdy system jest inny i musimy potraktować go indywidualnie.
Aplikację przygotujemy z wykorzystaniem frameworka Symfony w wersji 6.4, czyli najnowszej wersji LTS w momencie przygotowania wpisu. O tym, dlaczego powinniśmy używać wersji LTS wspomniałem w innym wpisie powięconym dobrym praktykom przy budowaniu aplikacji w Symfony.
Skorzystamy z konteneryzacji przy użyciu Docker. Jako szablonu użyjemy Symfony Docker. Skupimy się wyłącznie na części administracyjnej. Do łatwego wygenerowania panelu administracyjnego użyjemy biblioteki EasyAdminBundle.
Przygotowanie encji
Kod z tego podrozdziału znajdziesz na gałęzi feature/basic-discount-objects
Przygotowania do działania musimy zacząć od encji. Encje mogą, a wręcz powinny, różnić się między systemami. W końcu każdy klient ma prawo wymyślić własny sposób zarządzania rabatami. w naszym przykładzie posłużymy się wersją uproszczoną.
Utworzyłem dwie encje: Discount oraz DiscountCode. Pierwsza z nich opisuje obiekty rabatów. To tam będą zapisane szczegóły, takie jak wartość rabatu. Druga z nich jest już samym kodem rabatowym, czyli obiektem, który zostanie wygenerowany przez nasz system.
Przyjrzyjmy się fragmentom kodu. W poniższych fragmentach pominąłem gettery i settery. Pełny kod znajdziesz w repozytorium:
*/
#[ORM\OneToMany(targetEntity: DiscountCode::class, mappedBy: 'discount', orphanRemoval: true)]
private Collection $discountCodes;
#[ORM\Column]
#[Assert\Range(min: 1, max: 1000000)]
private ?int $numberOfCodes = 1;
}
Fragment kodu 1. Encja Discount
Fragment kodu 2. Encja DiscountCode
Poniższa tabela opisuje pola umieszczone w encjach:
| Pole | Opis |
| id | Identyfikator rabatu. Wartość generowana automatycznie. |
| name | Nazwa rabatu wewnątrz systemu |
| percentAmount | Wartość procentowa rabatu. Dla uproszczenia przyjąłem, że system obsługuje wyłącznie rabatowanie procentowe. Ograniczyłem walidatorem wartości na liczby z przedziału <1:100> |
| codePrefix | Prefix kodu rabatowego. Unikalna wartość w ramach rabatów. Prefix zostanie doklejony do wygenerowanego kodu rabatowego. Ułatwi to uzyskanie niepowtarzalności. |
| discountCodes | Kolekcja pozwalająca na dostęp do kodów z poziomu rabatu (będzie przydatna w panelu administracyjnym). |
| numberOfCodes | Pole określa, ile kodów powinno zostać wygenerowanych. Ograniczyłem wartości na liczby z zakresu <1:1000000>, zatem w ramach jednej kampanii promocyjnej będziemy mogli wygenerować maksymalnie milion kodów rabatowych. |
Tab. 1. Opis pól encji Discount
| Pole | Opis |
| id | Identyfikator rabatu. Wartość generowana automatycznie. |
| code | Wygenerowany kod rabatowy. Wartość musi być unikalna. |
| discount | Odwołanie relacyjne do obiektu rabatu. Takie powiązanie jest niezbędne do poprawnego działania kodu rabatowego. |
| used | Wartość logiczna określająca czy dany kod został użyty, czy też nie. |
Tab. 2. Opis pól encji DiscountCode
Tak jak wspominałem wcześniej , w tym wpisie skupiam się na bardzo uproszczonej wersji, stąd brak zarządzania takimi elementami jak chociażby długość kodu rabatowego, co może się pojawić w produkcyjnych systemach.
Podstawowy panel administracyjny
Kod z tego podrozdziału znajdziesz na gałęzi feature/basic-admin-panel
Wygenerowanie podstawowego panelu przy użyciu EasyAdminBundle i MakerBundle sprowadza się do użycia dwóch komend.
Komenda
bin/console make:admin:dashboard
utworzy nam podstawowy dashboard, czyli punkt wejścia dla kontrolerów CRUD. Kolejną komendą, której musimy użyć jest
bin/console make:admin:crud
W ramach działania kreatora wybieramy encję, dla której będziemy tworzyć kontroler.
Pozostaje nam tylko przypisać kontroler CRUD jako domyślne przekierowanie dla panelu administracyjnego (bo w końcu nic innego tam nie mamy) i ustawić link w menu. Pełny kod kontrolera został przedstawiony na listingu.
redirect(
$this->adminUrlGenerator->setController(DiscountCrudController::class)->generateUrl()
);
}
public function configureDashboard(): Dashboard
{
return Dashboard::new()
->setTitle('App');
}
public function configureMenuItems(): iterable
{
yield MenuItem::linkToCrud('Discount', 'fas fa-percent', Discount::class);
}
}
Fragment kodu 3. DashboardController
Wewnątrz CRUD kontrolera zadeklarujemy nową akcję — generowanie kodów rabatowych. Na ten moment przygotujemy wyłącznie zaślepkę metody, która wyłącznie sprawdzi, czy używamy w kontekście instancji klasy Discount. Akcję wyświetlimy na liście rabatów.
linkToCrudAction('generateCodes')
;
return $actions
->add(Action::INDEX, $generateCodesAction)
;
}
#[AdminAction(routePath: '{entityId}/generate-codes', routeName: 'generate_codes')]
public function generateCodes(AdminContext $context): RedirectResponse
{
$entityFqcn = self::getEntityFqcn();
/** @var Discount $instance */
$instance = $context->getEntity()->getInstance();
if (!$instance instanceof $entityFqcn) {
throw new RuntimeException("Entity is not an instance of {$entityFqcn}");
}
return $this->redirect($this->adminUrlGenerator->setAction(Action::INDEX)->generateUrl());
}
Fragment kodu 4. Utworzone metody DiscountCrudController
Proste generowania kodów rabatowych
Kod z tego podrozdziału znajdziesz na gałęzi generate-codes-sync
Zanim zrobimy wersję finalną przejdźmy przez proste, synchroniczne generowanie kodów w kontrolerze.
Przygotujmy zatem funkcję do generowania kodów rabatowych.
private static function generateSingleCode(string $prefix): string
{
$chars = array_flip(
array_merge(range(0, 9), range('A', 'Z'))
);
$randomString = '';
while (strlen($randomString) < 10) {
$randomString .= array_rand($chars);
}
return (str_ends_with($prefix, '_') ? $prefix : ($prefix . '_')) . $randomString;
}
Fragment kodu 5. Funkcja do generowania pojedynczego kodu
Kod powstaje poprzez połączenie prefixu i ciągu 10 losowych znaków alfanumerycznych (bierzemy pod uwagę wielkie litery oraz cyfry od 0 do 9).
Czym tak na prawdę jest 10-cio znakowy kod? Ile daje nam to możliwości?
Rozpisując to matematycznie, korzystamy z wariacji z powtórzeniami. Liczba wszystkich k-wyrazowych wariacji z powtórzeniami zbioru n-elementowego jest równa
Podstawiając nasze dane:
n – 36 (10 cyfr i 26 liter)
k – 10 (długość generowanego kodu)
Daje nam to więc 36^10 możliwych wariacji. To całkiem sporo i wystarczająco dla naszych ograniczeń (w końcu wybraliśmy maksymalnie milion generowanych kodów).
Potrafimy już wygenerować losowy ciąg znaków. Przejdźmy zatem do generowania kodów rabatowych. Na początku podejdziemy do tematu „po juniorsku”. Chcemy unikalności, więc za każdym razem zapytamy bazę danych czy czasem nie istnieje identyczny kod rabatowy. Jeżeli nie, to dodamy go do bazy. Nasza metoda generateCodes będzie wyglądać następująco:
#[AdminAction(routePath: '{entityId}/generate-codes', routeName: 'generate_codes')]
public function generateCodes(
AdminContext $context,
EntityManagerInterface $em,
): RedirectResponse
{
$entityFqcn = self::getEntityFqcn();
/** @var Discount $instance */
$instance = $context->getEntity()->getInstance();
if (!$instance instanceof $entityFqcn) {
throw new RuntimeException("Entity is not an instance of {$entityFqcn}");
}
for ($i = 0; $i < $instance->getNumberOfCodes(); ++$i) {
do {
$code = self::generateSingleCode($instance->getCodePrefix());
} while ($em->getRepository(DiscountCode::class)->findOneBy(['code' => $code]));
$codeObj = (new DiscountCode())
->setCode($code)
->setDiscount($instance)
;
$em->persist($codeObj);
$em->flush();
}
return $this->redirect($this->adminUrlGenerator->setAction(Action::INDEX)->generateUrl());
}
Fragment kodu 6. Pierwsza wersja funkcji generującej kody rabatowe
Już na pierwszy rzut oka widać tutaj brak optymalności. Dla generowania n kodów rabatowych odpytamy bazę danych o istnienie kodu minimum n razy!
Zobaczmy jednak, jak taki kod wpłynie na czas wykonywania. W panelu administracyjnym wytworzyłem 5 rabatów. Mają one kolejno: 100, 1000, 10 000, 100 000 oraz 1 000 000 kodów do wygenerowania.
Rys. 1. Testowe dane do generowania kodów
Skorzystam z metryk dostępnych w Symfony Profiler. Tabela poniżej przedstawia czas operacji na bazie danych (Doctrine), ogólny czas działania i maksymalne wykorzystanie pamięci operacyjnej dla konkretnego zestawu danych.
Przy 10 000 rekordów musiałem dodać fragment kodu znoszący limity czasowe i pamięciowe generowania danych.
ini_set("memory_limit", -1);
set_time_limit(0);
Fragment kodu 7. Linijki dodane w celu wygenerowania danych od 10 000 rekordów
| Ilość generowanych kodów | Czas operacji na bazie danych | Czas działania | Maksymalne użycie pamięci operacyjnej |
| 100 | 350,9ms | 797 ms | 112 MiB |
| 1000 | 3375,7ms | 5047 ms | 78 MiB |
| 10 000 | 41606,6ms | 173309 ms | 468 MiB |
| 100 000 | Zbyt duży do uwzględnienia przez Symfony Profiler | 12806929 ms | 4783 MiB |
| 1 000 000 | Test nie został przeprowadzony. | Test nie został przeprowadzony. | Test nie został przeprowadzony. |
Tab. 3. Wydajność rozwiązania podstawowego
Jak widać rozwiązanie jest bardzo niewydajne. Już przy generowaniu 1000 kodów rabatowych użytkownik musi czekać 5 sekund na załadowanie przeglądarki. To zdecydowanie zbyt długi czas.
Nasz kod ma też inną wadę — występuje bezpośrednio w akcji kontrolera, zatem może być użyty wyłącznie z poziomu zapytania HTTP.
Wyniesienie generowania poza kontroler
Kod z tego podrozdziału znajdziesz na gałęzi feature/messenger-sync-code-generator
Tak jak wspomniałem wcześniej, generowanie kodów w kontrolerze jest problemem, ponieważ uniemożliwia wywoływanie zadania inaczej niż za pomocą HTTP. Wynieśmy więc zadanie do oddzielnej klasy. Idealnie sprawdzi się tutaj komponent Symfony Messenger
Na początku skonfigurujmy warstwę transportu. Na początku wystarczy nam transport synchroniczny.
framework:
messenger:
transports:
sync: 'sync://'
Fragment kodu 8. Konfiguracja messenger.yaml
Po konfiguracji Messengera musimy utworzyć dwie klasy: klasę wiadomości (ang. message) i klasę obsługi wiadomości (ang. message handler).
Fragment kodu 9. Klasa wiadomości
W klasie wiadomości deklarujemy publiczne pole discountId, które będzie potrzebne do ponownego pobrania rabatu z bazy danych wewnątrz klasy obsługi. Moglibyśmy również przekazać do wiadomości pełny obiekt, jednak minimalizujemy przesyłane dane ograniczając się tylko do identyfikatora.
discountRepository->find($command->discountId);
if (!$discount) {
return;
}
for ($i = 0; $i < $discount->getNumberOfCodes(); ++$i) {
do {
$code = self::generateSingleCode($discount->getCodePrefix());
} while ($this->discountCodeRepository->findOneBy(['code' => $code]));
$codeObj = (new DiscountCode())
->setCode($code)
->setDiscount($discount)
;
$this->em->persist($codeObj);
$this->em->flush();
}
}
private static function generateSingleCode(string $prefix): string
{
$chars = array_flip(
array_merge(range(0, 9), range('A', 'Z'))
);
$randomString = '';
while (strlen($randomString) < 10) {
$randomString .= array_rand($chars);
}
return (str_ends_with($prefix, '_') ? $prefix : ($prefix.'_')).$randomString;
}
}
Fragment kodu 10. Klasa obsługi wiadomości
W klasie obsługi wiadomości kluczowym elementem jest atrybut #[AsMessageHandler]. To dzięki niemu Messenger „wie”, że do tej klasy będą kierowane wiadomości wysłane na szynę wiadomości (ang. message bus). Wewnątrz magicznej metody __invoke() wykonujemy całą logikę generowania kodów rabatowych. Na ten moment znalazł się w niej delikatnie przerobiony kod z kontrolera. Warto również zwrócić uwagę na nagłówek metody __invoke(). Jej argumentem jest obiekt klasy wiadomości.
Do zmiany pozostał jeszcze kod w kontrolerze. Metoda generateSingleCode została przeniesiona do klasy obsługi wiadomości, zatem może zostać usunięta z kontrolera. Z metody generateCodes usuwamy logikę generowania kodów. Zastępujemy ją wywołaniem komponentu messenger. Zaktualizowana metoda została przedstawiona na fragmencie kodu.
#[AdminAction(routePath: '{entityId}/generate-codes', routeName: 'generate_codes')]
public function generateCodes(
AdminContext $context,
MessageBusInterface $messageBus,
): RedirectResponse {
$entityFqcn = self::getEntityFqcn();
/** @var Discount $instance */
$instance = $context->getEntity()->getInstance();
if (!$instance instanceof $entityFqcn) {
throw new RuntimeException("Entity is not an instance of {$entityFqcn}");
}
$messageBus->dispatch(
new GenerateDiscountCodesCommand(
discountId: $instance->getId()
)
);
return $this->redirect($this->adminUrlGenerator->setAction(Action::INDEX)->generateUrl());
}
Fragment kodu 11. Zaktualizowana metoda generateCodes
Nasz kod wygląda już zdecydowanie lepiej, ale jego wydajność się nie zmieniła. Czas poprawić wydajność!
Rozwiązywanie duplikatów na poziomie bazy danych
Kod z tego podrozdziału znajdziesz na gałęzi feature/db-dedupe
Systemy zarządzania bazą danych dostarczają narzędzia, które umożliwiają radzenie sobie z konfliktami na unikalnych polach.
PostgtreSQL dostarcza nam składnię INSERT INTO …. ON CONFLICT ….
MySQL czy MariaDB mają składnię INSERT IGNORE INTO
Możemy więc w naszym kodzie „zrzucić odpowiedzialność” na bazę danych. Dzięki temu będziemy mogli zapisać w bazie kilka kodów na raz. Zacznijmy od dodania odpowiedniej metody w klasie repozytorium.
public function insertInBatch(int $discountId, array $codes): void
{
$values = implode(
separator: ',',
array: array_map(
callback: static fn (string $code) => sprintf(
"(nextval('discount_code_id_seq'), '%s', '%s', false)",
$discountId,
$code
),
array: $codes
)
);
$this->getEntityManager()->getConnection()->executeQuery(
"INSERT INTO discount_code (id, discount_id, code, used) VALUES {$values} ON CONFLICT DO NOTHING"
);
}
Fragment kodu 12. Metoda do wstawiania danych w klasie repozytorium
Następnie musimy zmodyfikować klasę obsługi wiadomości. Baza danych zajmie się deduplikacją za nas. Naszym zadaniem jest wyłącznie wygenerowanie odpowiedniej ilości kodów i przekazywanie ich w paczkach do klasy repozytorium.
Zaczniemy od metody, która zwróci ilość potrzebnych do wygenerowania kodów.
private function codesNeeded(Discount $discount): int
{
// forcing reload of entity from db
$discount = $this->discountRepository->find($discount->getId());
$codesCount = $this->discountCodeRepository->countByDiscount($discount);
return $discount->getNumberOfCodes() - $codesCount;
}
Fragment kodu 13. Metoda zwracająca ilość potrzebnych kodów
Po dodaniu metody wprowadzamy zmiany w metodie __invoke.
public function __invoke(GenerateDiscountCodesCommand $command): void
{
$discount = $this->discountRepository->find($command->discountId);
if (!$discount) {
return;
}
$codesNeeded = $this->codesNeeded($discount);
while ($codesNeeded > 0) {
$codes = [];
for ($i = 0; $i < $codesNeeded && $i < self::BATCH_INSERT_SIZE; ++$i) {
$codes[] = self::generateSingleCode(prefix: $discount->getCodePrefix());
}
$this->discountCodeRepository->insertInBatch(
discountId: $discount->getId(),
codes: $codes
);
$codesNeeded = $this->codesNeeded($discount);
}
}
Fragment kodu 14. zaktualizowana metoda __invoke
Zobaczmy, jak zmiany w kodzie wpłynęły na wydajność. Tabela przedstawia czas operacji na bazie danych (Doctrine), ogólny czas działania i maksymalne wykorzystanie pamięci operacyjnej dla konkretnego zestawu danych.
| Ilość generowanych kodów | Czas operacji na bazie danych | Czas działania | Maksymalne użycie pamięci operacyjnej |
| 100 | 24 ms | 434 ms | 22 MiB |
| 1000 | 131 ms | 146 ms | 22 MiB |
| 10 000 | 1445 ms | 1545 ms | 22 MiB |
| 100 000 | 25789 ms | 27296 ms | 110 MiB |
| 1 000 000 | 1150083 ms | 1166607 ms | 1064,5 MiB |
Tab. 4. Wydajność rozwiązania z użyciem mechanizmów bazy danych
W porównaniu z poprzednim wynikiem widzimy znaczącą poprawę. Zaryzykowałbym nawet stwierdzenie, że generowanie kodów do 10 000 jest nieodczuwalne dla użytkownika. Niemniej wynik bliski 30 sekund w przypadku 100 000 kodów nie jest już przyjemnym czasem oczekiwania.
Zadanie należy wykonać, ale nie chcemy czekać na jego wynik. Z pomocą przychodzi nam wykorzystanie komend asynchronicznych dostarczanych przez Symfony Messenger.
Idziemy w asynchroniczność!
Kod z tego podrozdziału znajdziesz na gałęzi feature/async-messenger
Zadania asynchroniczne są bardzo istotne. Dzięki nim nie blokujemy przeglądarki użytkownika na czas wykonywania długiego zadania.
Przy użyciu Symfony Messenger możemy skorzystać z różnych mechanizmów kolejkowania. w ramach tego poradnika skorzystamy z protokołu amqp dostarczanego przez RabbitMQ.
Zacznijmy od wprowadzenia zmian w konfiguracji Messengera.
framework:
messenger:
transports:
sync: 'sync://'
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Messenger\Command\GenerateDiscountCodesCommand': async
Fragment kodu 15. Zmodyfikowana konfiguracja w pliku messenger.yaml
Dodaliśmy nowy typ transportu i nazwaliśmy go async. Następnie wskazaliśmy w routingu, że nasza klasa ma być obsługiwana przez transport asynchroniczny.
Czy coś jeszcze powinniśmy zmienić? Nie! Wszystko dalej dzieje się automatycznie. Zobaczmy zatem jak zmieniły się czasy odpowiedzi.
|
Ilość generowanych kodów |
Czas operacji na bazie danych |
Czas działania |
Maksymalne użycie pamięci operacyjnej |
|
100 |
2,1 ms |
376 ms |
12 MiB |
|
1000 |
3,2 ms |
155 ms |
10 MiB |
|
10 000 |
< 1 ms |
9 ms |
16 MiB |
|
100 000 |
1,2 ms |
11 ms |
18 MiB |
|
1 000 000 |
< 1 ms |
7 ms |
20 MiB |
Tab. 5. Wydajność rozwiązania z kolejki asynchronicznej
Oczywiście muszę tutaj zaznaczyć, że to wyłącznie czasy odpowiedzi po przekierowaniu. Generowanie kodów dzieje się poza głównym wątkiem aplikacji. Musimy pamiętać, że worker powinien otrzymać odpowiednią ilość pamięci operacyjnej. Dobrym pomysłem może być też wyniesienie workera do osobnego kontenera.
Podsumowanie
Jak widać, nawet tak pozornie proste zadanie jak generowanie kodów rabatowych może stać się sporym wyzwaniem, gdy w grę wchodzą setki tysięcy rekordów. Dzięki wykorzystaniu komponentu Messenger i kolejek asynchronicznych w Symfony udało się jednak zachować wysoką wydajność i uniknąć „zawieszania” systemu przy dużych akcjach promocyjnych.
Cały proces pokazał, jak ważne jest myślenie o skalowalności już na etapie projektowania rozwiązania. Wystarczyło kilka zmian w architekturze, by z prostego, mało wydajnego podejścia przejść do nowoczesnego, odpornego na obciążenia systemu. To dowód na to, że dobre praktyki i odpowiednie wykorzystanie narzędzi potrafią zrobić ogromną różnicę i uratować niejeden system przed „ubićiem”.
