Optymalizacja synchronizacji danych w e-commerce

Użytkownicy oraz administratorzy korzystając z rozbudowanej platformy e-commerce nieustannie tworzą i modyfikują za jej pomocą informacje z wielu różnych dziedzin. Dane takie jak stany magazynowe produktów, statusy zamówień czy nawet ceny towarów mogą pod wpływem ich działań zmieniać się bardzo często.

Zarządzanie danymi może odbywać się nie tylko z poziomu systemu e-commerce, ale również za pomocą wąsko wyspecjalizowanych w swoich dziedzinach systemów ERP, PIM czy innych dedykowanych rozwiązań. Dlatego ważną częścią nowoczesnych systemów e-commerce są integracje z dostawcami zewnętrznymi. Od poprawności i aktualności danych zależy pozytywne doświadczenie użytkownika oraz poprawne działanie aplikacji. Sprawna wymiana dużych ilości danych pomiędzy systemami jest więc niezwykle istotna.

Wyzwania podczas integracji

Synchronizacja danych między dwoma systemami informatycznymi jest zagadnieniem bardzo obszernym i zróżnicowanym. Mnogość standardów jeśli chodzi o zachowania i modele stosowane w systemach, różnorodne formaty danych i protokoły, którymi te dane mogą być przekazywane – wszystko to sprawia, że do każdego przypadku trzeba podchodzić indywidualnie.

Problemy związane z synchronizacją danych różnią się w zależności od tego, czy dany system jest źródłem, czy konsumentem danych. W pierwszym przypadku, aplikacja ma zazwyczaj dużą swobodę kreowania sposobu synchronizacji w taki sposób, który jest optymalny z jej punktu widzenia i może narzucać konsumentom swoje standardy. Ciekawiej sytuacja wygląda z drugiej strony, gdzie pojawia się konieczność poradzenia sobie z odgórnie narzuconym procesem.

Wspomniana na początku szeroka gama technologii, do których trzeba się dostosować, to jednak tylko początek problemów. Jeśli na etapie kodowania programista będzie miał na uwadze jedynie formalną poprawność obróbki danych, może okazać się, że proces nie jest w stanie poradzić sobie z natłokiem danych, które zostaną mu powierzone do przetwarzania. Czas wykonania bądź wymagane do wykonania procesu zasoby mogą okazać się zbyt duże, aby kod nadawał się do produkcyjnego użytku.

Na szczęście w celu poradzenia sobie z tym problemem można zastosować szereg rozwiązań optymalizacyjnych. Usprawnienia można wdrożyć niezależnie od siebie na kilku płaszczyznach, zarówno na poziomie samej architektury procesu przeprowadzania synchronizacji, jak i na poziomie szczegółów implementacyjnych. W niniejszym artykule przedstawionych będzie kilka dość uniwersalnych w zastosowaniu metod, które niewielkim nakładem pracy umożliwiają poprawę wydajności nawet dla już istniejącego kodu.

Synchronizacja przyrostowa

Wymiana pełnego zbioru danych pomiędzy dwoma systemami podczas każdej synchronizacji jest wysoce nieefektywna. Zazwyczaj w czasie, który upłynął od poprzedniej synchronizacji jedynie bardzo niewielka część danych w systemie ulega zmianie. W takiej sytuacji dobrym rozwiązaniem jest synchronizacja przyrostowa, która polega na pobraniu jedynie informacji o zmianach jakie zaszły od poprzedniej synchronizacji. Pozwala to na znaczne zmniejszenie zbioru przetwarzanych danych, co w wymierny sposób przekłada się na zmniejszenie czasu oraz zasobów potrzebnych do wykonania operacji.

Aby przeprowadzanie synchronizacji w ten sposób było możliwe, system źródłowy, z którego dane będą pobierane, powinien spełniać odpowiednie wymagania. Muszą być w nim przechowywane informacje o zmianach, jakie zachodzą w danych, którymi ten system zarządza.

W najprostszej formie realizowane jest to poprzez zapisywanie przy każdej encji daty ostatniej modyfikacji i umożliwienie stronie integrującej się filtrowanie zbioru danych względem tej daty. W swojej czystej formie ta metoda nie pozwala jednak na przekazanie informacji o tym, że w systemie źródłowym dana encja została usunięta.

Bardziej zaawansowaną metodą jest udostępnianie przez system źródłowy kolejki zdarzeń, które zostały wykonane w systemie. Dzięki temu do systemu docelowego w jednolity sposób mogą być przekazywane informacje o wszystkich typach zdarzeń, w tym także o usunięciu danej encji. Jeśli system źródłowy oparty jest o Event Sourcing, potrzebne informacje już się w nim znajdują i udostępnienie danych w takiej postaci nie powinno stanowić kłopotu.

Niezależnie od tego, czy system źródłowy udostępnia całe encje czy tylko zdarzenia zmian, na etapie pobierania informacji proces wygląda bardzo podobnie. W abstrakcyjnej formie przykładowa synchronizacja przyrostowa z uwzględnieniem zapisywania daty może wyglądać następująco:

public function fetch(): void
{
    $dataVersion = $this->dataVersionRepository->find();
    $request = $this->requestFactory->create($dataVersion);
    $response = $this->endpoint->getResponse($request);
 
    foreach ($response->getData() as $entity) {
        $this->entityStorage->store($entity);
        $dataVersion->update($entity->getUpdatedAt());
    }
 
    $this->dataVersionStorage->store($dataVersion);
}

Przetwarzanie iteracyjne

Przetwarzanie elementów zbioru pojedynczo, zamiast całej kolekcji jednocześnie, to względnie prosta do wdrożenia metoda optymalizacji pamięciowej. Nie zawsze logika biznesowa pozwala na jej użycie, ponieważ zdarzają się takie operacje, które wymagają pracy na zbiorze jako całości. Jednak w sytuacjach, w których jest to możliwe, zastosowanie tej techniki przynosi bardzo wymierne korzyści. Z pomocą przychodzą nam tu dwie konstrukcje dostępne od kilku lat w języku PHP: pseudo-typ iterable oraz generatory.

iterable to pseudo-typ języka PHP, który zgodnie z nazwą łączy w sobie te typy danych, po których możliwe jest iterowanie, tj. typ array oraz interfejs Traversable, który jest z kolei abstrakcyjnym interfejsem łączącym interfejsy Iterator oraz IteratorAggregate. Jeśli w kodzie znajduje się zmienna tablicowa, z której wykorzystywane są jedynie elementy tej tablicy i jedynie w instrukcji foreach, można typ takiej zmiennej bez żadnych konsekwencji zmienić z array na iterable. Jeśli taka zmienna jest parametrem metody, wolno będzie po takiej zmianie przekazać w tym miejscu zamiast tablicy obiekt implementujący interfejs Traversable, czyli np. Generator, co jest wstępem do opisywanej tu techniki optymalizacyjnej.

Generator jest bardzo użyteczną i praktyczną konstrukcją, która “pod maską” ukrywa typową implementację iteratora. Uwalnia programistę od konieczności ręcznej implementacji interfejsu Iterator, co wiązałoby się z dodatkowym nakładem żmudnej pracy i możliwością popełnienia błędu. Sam fakt użycia słowa kluczowego yield w ciele danej metody czy funkcji powoduje, że zmienia się ona w generator, co całkowicie zmienia przepływ sterowania w kodzie. Typem zwracanym staje się obiekt klasy Generator, a kod metody wykonywany jest dopiero w momencie wykorzystania tego obiektu w konstrukcji foreach. Każde kolejne przejście pętli foreach powoduje kontynuację wykonania kodu generatora od poprzedniego do następnego wywołania słowa kluczowego yield.

Dobrą ilustracją obrazującą praktyczność słowa kluczowego yield może być pobieranie rekordów z bazy danych. Za przykład posłuży prosta klasa repozytorium oparta o PDO.

class ProductRepository
{
    private readonly \PDO $dbh;
 
    public function __construct(\PDO $dbh)
    {
        $this->dbh = $dbh;
    }
 
    /**
    * @return array<Product>
    */
    public function findAll(): array
    {
        $sth = $this->dbh->query('SELECT * FROM `product`');
        $sth->setFetchMode(\PDO::FETCH_CLASS, Product::class);
        return $sth->fetchAll();
    }
}

Zaprezentowany tu sposób pobierania danych w przypadku dużego zbioru powoduje bardzo duży narzut pamięciowy, ponieważ jednocześnie ładowane i przekazywane są wszystkie dane. Ponadto, dla połączenia z bazą MySQL, przy domyślnej konfiguracji PDO buforuje wyniki wszelkich zapytań i przechowuje kopię wyników w pamięci, a zatem zużycie staje się jeszcze większe. Rozwiązaniem tego problemu (oprócz zmiany wspomnianej konfiguracji) jest zastąpienie wywołania metody fetchAll metodą fetch, która operuje na jednym elemencie jednocześnie. Próba realizacji takiego rozwiązania bez użycia iteratora wymagałaby jednak złamania obecnego kontraktu metody findAll, a co za tym idzie, modyfikacji we wszystkich miejscach, w których jest ona wywoływana. Dodatkowo konieczne byłyby zmiany w klasie repozytorium, z uwagi na konieczność przechowania dodatkowego stanu wewnętrznego.

Rozwiązaniem jest tu zastosowanie jako zwracanej wartości obiektu będącego iteratorem. Ręczna implementacja interfejsu Iterator jest bardzo niepraktyczna, natomiast zmiana metody w generator z wykorzystaniem słowa kluczowego yield daje analogiczny efekt i wymaga jedynie minimalnych modyfikacji. Jeśli nie ma możliwości zmiany konfiguracji, nie zaszkodzi też upewnić się, że po przejściu generatora bufor wewnętrzny PDO zostanie wyczyszczony, co można realizować przez unset($sth). Metoda findAll może wyglądać następująco:

  /**
    * @return iterable<Product>
    */
    public function findAll(): iterable
    {
        $sth = $this->dbh->query('SELECT * FROM `product`');
        $sth->setFetchMode(\PDO::FETCH_CLASS, Product::class);
        while ($product = $sth->fetch()) {
            yield $product;
        }
        unset($sth);
    }

Taka zmiana spowoduje znaczny spadek zużycia pamięci, ponieważ załadowany będzie jedynie jeden obiekt klasy Product jednocześnie. Kod wywołujący metodę findAll nie wymaga w tym przypadku żadnej modyfikacji, co również oszczędza sporo czasu.

W przypadku, gdy w niektórych miejscach zwracana wartość musi być przetwarzana jak tablica, również możliwe jest zastosowanie powyższej modyfikacji. Konieczna jednak będzie w miejscu operowania na wyniku konwersja jego typu, np. z użyciem funkcji iterator_to_array, spowoduje to jednocześnie zniwelowanie całego zysku pamięciowego w tym miejscu. Warto więc przed zmianą zastanowić się, czy jest ona w danym wypadku opłacalna. Inną możliwością jest przygotowanie dla takich przypadków oddzielnej metody pobierającej dane. Plusem tego wariantu jest możliwość dopasowania zapytania do konkretnej sytuacji i np. zwracanie modelu tylko do odczytu, okrojonego do jedynie kilku potrzebnych w danej sytuacji pól, co także zwiększy wydajność i zmniejszy zapotrzebowanie na pamięć. Minusem jest natomiast powstanie w systemie większej ilości kodu do utrzymania. Sposób rozwiązania problemu można dopasować zależnie od potrzeb oraz dostępnych zasobów.

Analogicznie można postąpić, jeśli do połączenia z bazą danych wykorzystywany jest ORM. W takim przypadku należy jednak poświęcić dodatkową uwagę, ponieważ zmapowane przez ORM encje mogą być przechowywane w jego cache’u, co spowoduje utratę całego potencjalnego zysku pamięciowego. W celu rozwiązania tego problemu w przypadku Doctrine można posłużyć się metodą \Doctrine\ORM\EntityManager::clear.

Optymalizacje operacji na plikach

Często spotykaną sytuacją jest udostępnianie przez system źródłowy danych w postaci pliku ze skonsolidowanym zestawem zmian z całego dnia, udostępnianego np. poprzez API lub serwer wymiany FTP.

Choć klasa SimpleXMLElement oferuje prosty interfejs do operowania na plikach XML, wymaga ona załadowania do pamięci całej zawartości pliku jednocześnie. Jeśli plik jest bardzo duży, to w zależności od środowiska, taka operacja może się nawet w ogóle nie udać. Struktura danych w takim pliku jest zazwyczaj narzucona z góry, więc nierzadko spora część danych w nim zawartych okazuje się niepotrzebna, co dodatkowo podważa sens poświęcania zasobów na załadowanie takiego pliku w całości.

Za przykład niech posłuży poniższa prosta implementacja klasy do przetwarzania pliku XML z danymi:

class SimpleXmlParser
{
    private readonly string $path;
 
    public function __construct(string $path)
    {
        $this->path = $path;
    }
 
    /**
    * @return iterable<SimpleXMLElement>
    * @throws Exception
    */
    public function getModels(): iterable
    {
        $xml = new \SimpleXMLElement(file_get_contents($this->path));
        return $xml->models->model;
    }
}

Do przetestowania wydajności tego rozwiązania posłuży testowy plik XML z danymi o rozmiarze ok. 58,5 MiB oraz poniższy skrypt testowy:

$start = hrtime(true);
$parser = new SimpleXmlParser('...');
foreach ($parser->getModels() as $model) {
// ...
}
$time = (hrtime(true) - $start);
$mem = memory_get_peak_usage(true);
echo 'Time: ' . ($time / 10**9) . ' s' . PHP_EOL;
echo 'Peak mem: ' . ($mem / 2**20) . ' MiB' . PHP_EOL;

Wyniki przedstawiają się następująco:

Time: 0.467675652 s

Peak mem: 60.53125 MiB

Zużycie pamięci jest duże, ponieważ do pamięci musiał zostać załadowany cały plik jednocześnie. Rozwiązaniem w tej sytuacji jest wykorzystanie w implementacji klasy XMLReader, która pozwala na sekwencyjne przechodzenie po drzewie dokumentu XML przy użyciu kursora. Dzięki temu w pamięci przechowywany jest jedynie niewielki, obecnie analizowany obszar dokumentu, a ponadto możliwe jest całkowite pomijanie zbędnych części dokumentu.

Alternatywna implementacja klasy do przetwarzania plików z wykorzystaniem klas XMLReader oraz generatora może wyglądać na przykład w ten sposób:

class XmlReaderParser
{
    private readonly string $path;
 
    public function __construct(string $path)
    {
        $this->path = $path;
    }
 
    /**
    * @return iterable<SimpleXMLElement>
    * @throws Exception
    */
    public function getModels(): iterable
    {
        $reader = new \XMLReader();
        $reader->open($this->path);
        while ($reader->name !== 'model') {
            $reader->read();
        }
        do {
            yield new \SimpleXMLElement($reader->readOuterXml());
        } while ($reader->next('model'));
        $reader->close();
    }
}

W jej wypadku rezultat analogicznego testu prezentuje się tak:

Time: 1.332252204 s

Peak mem: 2 MiB

Zużycie pamięci przez drugą implementację jest marginalne, kilkudziesięciokrotnie mniejsze niż w przypadku pierwszego przykładu. Jednak ze względu na konieczność ciągłego doczytywania zawartości pliku wzrósł czas wykonywania, ale jedynie trzykrotnie. Przedstawiony skrypt testowy nie symuluje jednak obciążenia związanego z przetworzeniem pobranych danych, a to ten etap powoduje zazwyczaj o wiele większy narzut czasowy niż sam odczyt. Nieznaczne wydłużenie czasu odczytu kosztem znacznej oszczędności pamięci jest więc zazwyczaj sensownym rozwiązaniem, na które można sobie pozwolić.

Nic nie stoi jednak na przeszkodzie, aby obie wersje kodu stały obok siebie w repozytorium implementując wspólny interfejs. Posiadanie obu tych implementacji algorytmu daje możliwość użycia jednej z nich i wyboru aspektu optymalizacji (czas lub pamięć) w zależności od posiadanych zasobów oraz podjętych decyzji biznesowych.

W przypadku innych formatów, aby ograniczyć zużycie pamięci podczas operacji na dużych plikach, należy tam, gdzie to możliwe, unikać wykorzystania funkcji file oraz file_get_contents. Choć są bardzo proste w użyciu, to w ich przypadku również do pamięci trafia cała zawartość pliku. Jeśli dane w pliku są przekazywane w formacie CSV, sięgnąć można po funkcję fgetcsv, która przetwarza zawarte informacje rekord po rekordzie. W przypadku bardziej niestandardowych formatów, jeśli jest możliwe przetwarzanie pliku linijka po linijce, można spróbować wykorzystać funkcję fgets. Obie te funkcje wymagają przekazania jako parametru wskaźnika do pliku, który można otrzymać np. poprzez użycie funkcji fopen.

Przetwarzanie równoległe

Zmiany, jakie zachodzą w obiektach biznesowych, są bardzo często od siebie niezależne. Poprawa literówki w opisie produktu nie jest przecież w żaden sposób powiązana z aktualizacją opisu w innym produkcie, czy tym bardziej zmianą adresu dostawy u klienta. Otwiera to drogę do zrównoleglenia procesu synchronizacji danych, a co za tym idzie — skrócenia czasu potrzebnego na przeniesienie kompletu danych z jednego systemu do drugiego.

Infrastruktura oparta na chmurze daje bardzo szeroki wachlarz możliwości, jeśli chodzi o skalowanie procesów, w których możliwe jest równoległe przetwarzanie danych. Przyspieszenie procesu synchronizacji można osiągnąć w takim wypadku w prosty sposób poprzez zwiększenie liczby dostępnych instancji obliczeniowych.

Jeśli modele danych w obu systemach zostały dobrze zaprojektowane, to informacje o tego typu zdarzeniach mogą zostać przekazane z jednego systemu do drugiego w dowolnej kolejności względem siebie, bez żadnych negatywnych konsekwencji. W tym kontekście dobry model danych oznacza taki, który zawiera komplet danych potrzebnych do pracy na danym obiekcie biznesowym. Przykładowo, aby operacja złożenia zamówienia była niezależna od operacji zmiany ceny produktu, w modelu zamówienia musi znaleźć się miejsce na cenę produktu w momencie złożenia zamówienia. Wykorzystanie opisanego powyżej podejścia pozwala na wdrożenie równoległego przetwarzania synchronizowanych danych w bezpieczny sposób.

Wartym uwagi zagadnieniem jest zadbanie o to, aby przy podziale zadań między równoległe wątki obiekt nie został przetworzony kilkukrotnie, co byłoby marnotrawstwem dostępnych zasobów. Taka sytuacja może też doprowadzić do deadlocków, jeśli dwa procesy przetwarzające ten sam obiekt będą walczyły o te same zasoby. Kilkukrotne przetworzenie – równoległe bądź sekwencyjne – tych samych danych, w zależności od założeń biznesowych może dawać nieprzewidywalny rezultat, a nawet doprowadzić wprost do błędnego działania systemu. Ten problem można rozwiązać np. poprzez utworzenie jednego, nadrzędnego procesu synchronizacji, który będzie zarządzał i koordynował działania podrzędnych, równoległych procesów, np. przydzielając im odgórnie poprzez identyfikatory konkretne obiekty do przetworzenia.

Podsumowanie

Każdy system zewnętrzny, ze względu na swoją – często unikalną – architekturę będącą poza kontrolą programisty, ogranicza swobodę projektowania rozwiązania, które ma z tym systemem współpracować. Spójna synchronizacja sporych ilości danych w rozsądnym czasie, przy jednoczesnej konieczności dopasowania się do odgórnie narzuconych procedur postępowania, zdecydowanie nie jest zadaniem trywialnym. Jego realizację ułatwia szeroki wybór dostępnych technik optymalizacyjnych, których ten artykuł z pewnością nie wyczerpuje. Różnorodność możliwych podejść sprawia, że niezależnie od tego jak wygląda dany proces, choć część z zaprezentowanych modyfikacji będzie można zastosować. Każda poprawa wydajności czy zmniejszenie zużycia zasobów to pozytywne efekty w postaci krótszego czasu wykonania programu czy mniejszych kosztów infrastruktury, co w prosty sposób przekłada się na ogólne zadowolenie klienta i użytkowników.

Autorem tekstu jest Olaf Kryus