Generowanie kodów rabatowych przy użyciu Symfony — jak „nie ubić” systemu

Spis treści
  • 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:

				
					<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\DiscountRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: DiscountRepository::class)]
#[UniqueEntity('code_prefix')]
class Discount
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\Length(min: 1, max: 255)]
    private ?string $name = null;

    #[ORM\Column]
    #[Assert\Range(min: 1, max: 100)]
    private ?int $percentAmount = null;

    #[ORM\Column(name: 'code_prefix', length: 15, unique: true)]
    #[Assert\Length(min: 1, max: 15)]
    private ?string $codePrefix = null;

    /** @var Collection<int, DiscountCode> */
    #[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

				
					<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\DiscountCodeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: DiscountCodeRepository::class)]
#[UniqueEntity('code')]
class DiscountCode
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(name: 'code', length: 255, unique: true)]
    private ?string $code = null;

    #[ORM\ManyToOne(inversedBy: 'discountCodes')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Discount $discount = null;

    #[ORM\Column]
    private ?bool $used = false;
}

				
			

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 EasyAdminBundleMakerBundle 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.

				
					<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\Discount;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;

#[AdminDashboard(routePath: '/', routeName: 'admin')]
final class DashboardController extends AbstractDashboardController
{
    public function __construct(
        private readonly AdminUrlGenerator $adminUrlGenerator,
    )
    {
    }

    public function index(): Response
    {
        return $this->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.

				
					<?php

// ...

public function configureActions(Actions $actions): Actions
{
    $generateCodesAction = Action::new(
        name: 'generate-codes',
        label: 'Generate codes',
        icon: 'fas fa-random',
    )
        ->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

W n k = n k

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ówCzas operacji na bazie danychCzas działaniaMaksymalne użycie pamięci operacyjnej
100350,9ms797 ms112 MiB
10003375,7ms5047 ms78 MiB
10 00041606,6ms173309 ms468 MiB
100 000Zbyt duży do uwzględnienia przez Symfony Profiler12806929 ms4783 MiB
1 000 000Test 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).

				
					<?php

declare(strict_types=1);

namespace App\Messenger\Command;

final readonly class GenerateDiscountCodesCommand
{
    public function __construct(public int $discountId) {}
}

				
			

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.

				
					<?php

declare(strict_types=1);

namespace App\Messenger\Handler;

use App\Entity\DiscountCode;
use App\Messenger\Command\GenerateDiscountCodesCommand;
use App\Repository\DiscountCodeRepository;
use App\Repository\DiscountRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class GenerateDiscountCodesHandler
{
    public function __construct(
        private readonly DiscountRepository $discountRepository,
        private readonly DiscountCodeRepository $discountCodeRepository,
        private readonly EntityManagerInterface $em
    ) {}

    public function __invoke(GenerateDiscountCodesCommand $command): void
    {
        ini_set('memory_limit', -1);
        set_time_limit(0);

        $discount = $this->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ówCzas operacji na bazie danychCzas działaniaMaksymalne użycie pamięci operacyjnej
10024 ms434 ms22 MiB
1000131 ms146 ms22 MiB
10 0001445 ms1545 ms22 MiB
100 00025789 ms27296 ms110 MiB
1 000 0001150083 ms1166607 ms1064,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”.

Porozmawiajmy

Potrzebujesz partnera, który zna specyfikę Twojej branży i pomoże Ci rozwinąć Twój biznes? Napisz do nas. Postaramy się pomóc.

Administratorami danych osobowych są spółki z Grupy FABRITY (dalej „Fabrity”), ze spółką matką Fabrity S.A. z siedzibą w Warszawie, Polska, wpisaną do Krajowego Rejestru Sądowego pod numerem 0000059690. Dane są przetwarzane w celach marketingowych dotyczących produktów lub usług Fabrity. Podstawą prawną przetwarzania jest prawnie uzasadniony interes administratora. Osoby, których dane są przetwarzane, mają następujące prawa: prawo dostępu do treści swoich danych, prawo do ich sprostowania, usunięcia, ograniczenia przetwarzania, prawo do wniesienia sprzeciwu wobec przetwarzania danych osobowych, jeśli odbywa się ono na podstawie zgody, oraz prawo do przenoszenia danych. Przysługuje również prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (PUODO). Dane osobowe podane w tym formularzu będą przetwarzane zgodnie z naszą polityką prywatności.

Możesz również wysłać e-mail na adres digital@fabrity.pl

Zaufali nam

To również może Cię zainteresować:

Porozmawiajmy

Potrzebujesz partnera, który zna specyfikę Twojej branży i pomoże Ci rozwinąć Twój biznes? Napisz do nas. Postaramy się pomóc.

Administratorami danych osobowych są spółki z Grupy FABRITY (dalej „Fabrity”), ze spółką matką Fabrity S.A. z siedzibą w Warszawie, Polska, wpisaną do Krajowego Rejestru Sądowego pod numerem 0000059690. Dane są przetwarzane w celach marketingowych dotyczących produktów lub usług Fabrity. Podstawą prawną przetwarzania jest prawnie uzasadniony interes administratora. Osoby, których dane są przetwarzane, mają następujące prawa: prawo dostępu do treści swoich danych, prawo do ich sprostowania, usunięcia, ograniczenia przetwarzania, prawo do wniesienia sprzeciwu wobec przetwarzania danych osobowych, jeśli odbywa się ono na podstawie zgody, oraz prawo do przenoszenia danych. Przysługuje również prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (PUODO). Dane osobowe podane w tym formularzu będą przetwarzane zgodnie z naszą polityką prywatności.

Możesz również wysłać e-mail na adres digital@fabrity.pl

Zaufali nam

Logo Fabrity Digital – napis „FABRITY” w czerwonej ramce i „Digital” poniżej
Przegląd prywatności

Ta strona używa plików cookie, abyśmy mogli zapewnić Ci jak najlepsze wrażenia z użytkowania. Informacje z plików cookie są przechowywane w przeglądarce użytkownika i pełnią takie funkcje, jak rozpoznawanie użytkownika przy ponownym wejściu na naszą stronę internetową oraz pomagają naszemu zespołowi zrozumieć, które sekcje strony internetowej są dla użytkownika najbardziej interesujące i przydatne.