Switch to English switch to english
Skontaktuj się +48 660 043 353 biuro@wiseweb.pl
lub skontaktuj się poprzez formularz na stronie

Optymalizacja platformy e-commerce z wykorzystaniem ElasticSearch

Na pewno każdy słyszał o ElasticSearch. Jest to nic innego jak baza danych, wykorzystująca Zend Lucene w celu jak najszybszego oraz najtrafniejszego wyszukiwania.

Alternatywą dla ElasticSearch jest m.in. Apache Solr czy Sphinx, o którym w przyszłości jeszcze napiszemy, lecz dziś skupmy się na optymalizacji platformy klienta w oparciu o silnik ElasticSearch.
ElasticSearch - agenda
1. Co to jest ElasticSearch?
2. Nasz klient oraz problem z bazą danych Key Value.
3. Rozwiązanie za pomocą Elastic Search - Case Study
Czym jest ElasticSearch? Autorzy opisują to bardzo prosto:
Elasticsearch is a distributed, RESTful search and analytics engine capable of solving a growing number of use cases.

Mówiąć krótko jest to silnik oparty o Zend Lucene, pozwalający w jak najoptymalniejszy sposób wyszukiwać rekordy w bazie za pomocą podanych kryteriów.
Więcej informacji można doczytać w wikipedii w teorii, jak to wygląda. My skupmy się na praktycznym rozwiązaniu problemu klienta.
Baza Key Value
Klient narzekał na wydajność aplikacji. Aplikacja jest typu e-commerce, gdzie użytkownicy mogą zamawiać produkty oraz usługi (sporo ofert). Baza danych, która zawiera oprogramowanie to baza Key => Value. Poniżej przedstawiamy struturę encji Doctrine 2:

/**
 * @ORM\Entity
 * @ORM\Table(name="TaxonomyItem")
 */
class TaxonomyItem
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string", length=100)
     * @Assert\NotBlank()
     */
    protected $title;

    /**
     * @Gedmo\Slug(fields={"title"}, updatable=false)
     * @ORM\Column(length=128, unique=true)
     */
    protected $slug;

    /**
     * @ORM\ManyToOne(targetEntity="TaxonomyCategory", inversedBy="taxonomyItems")
     * @ORM\JoinColumn(name="taxonomy_category_id", referencedColumnName="id")
     */
    protected $taxonomyCategory;
 
    /**
     * @ORM\OneToMany(targetEntity="TaxonomyField", mappedBy="taxonomyItem",cascade={"persist", "remove"})
     */
    protected $customFields;


Jak widzimy powyżej, problem polega na tym, iż każdy obiekt w bazie (Np. Produkt / Oferta) ma tylko tytuł i id. Reszta to relacje OneToMany do TaxonomyField, gdzie poszczególne atrybuty są przechowywane. Przykładowo dla produktu są to: description, description_en, photo, seo_slug, seo_title, itp.

Niestety wadą tego rozwiązania jest to, iż każdy element strony wykonuje dodatkowe zapytanie do bazy danych o kolejne atrybuty.

Przykład:
Mamy na stronie 30 produktów. Każdy produkt ma pola: description, description_en. Tworzy to 31 zapytań, gdyż:
1. Pobieramy listę 30 TaxonomyItem (Produktów),
2. Do każdego TaxonomyItem (Produktu) pobieramy jego atrybuty - 30 zapytań.

Tworzyło to potężny problem optymalizacyjny, w miarę gdy aplikacja się rozrastała.
Niektore podstrony sięgały nawet 3000 zapytań / request !
Rozwiązanie za pomocą Elastic Search - Case Study
Oczywiście zamiast przepisywać bazę danych (sporo pracy) postanowiliśmy w 16h zoptymalizować aplikację.

Etap 1 - określenie struktury dokumentu ElasticSearch

Wykorzystując fakt, iż będziemy nakładali warstwę danych, nie keszowaliśmy całego TaxonomyItem + TaxoanomyFields, lecz zbudowaliśmy osobny dokument (Model). Wyglądał on następująco:

class ProductService {
protected $title;
protected $title_en;
protected $description;
protected $description_en;
protected $photo;
protected $category;
protected $categoryTree;
....
}


Dokument po prostu tworzył się na podstawie TaxonomyItem + TaxonomyFields. Atrybut $categoryTree to dodatkowy atrybut, budujący (i jednocześnie cachujący) drzewo kategorii, w której znajduje się Produkt / Usługa.

Migracja starych rekordów w bazie do ES (ElasticSearch)
Odpowiada za to następująca metoda (komenda).

namespace AppBundle\Command;

use AppBundle\Entity\User;
use AppBundle\Services\ElasticSearchService;
use AppBundle\Services\TaxonomyManager;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\Output;
use Symfony\Component\Console\Output\OutputInterface;

use League\Csv\Reader;
use League\Csv\Statement;

class BuildProductIndexCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this
            ->setName('index:products')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        define('tradeSubdomain', 'hagebau');

        $container = $this->getContainer();
        $output->writeln('Building index...');

        /** @var TaxonomyManager $taxonomyManager */
        $taxonomyManager = $container->get('manager.taxonomy');

        /** @var ElasticSearchService $elasticSearchService */
        $elasticSearchService = $container->get('service.elastic_search');
        $products = $taxonomyManager->findAllTaxonomyItems('products');

        foreach ($products as $product) {
            if ((String) $product->lookup('root_product') != '') {
                continue;
            }

            if ((String) $product->lookup('category') == '') {
                continue;
            }

            $elasticSearchService->buildProductIndex($product);
            $output->writeln(sprintf('Product with id: %s has been indexed.', $product->getId()));
        }
    }
}


Jak widać kluczowa jest tutaj metoda buildProductIndex($product), która to zapisuje dane do bazy danych ElasticSearch-a. Poniżej prosta implementacja

public function buildProductIndex(TaxonomyItem $product)
    {
        $this->elasticSearchClient->setType('product');

        $fieldsToIndex = [
            'slug', 'title', 'title_en', 'long_description', 'long_description_en', 'important_information', 'important_information_en',
            'industry', 'agb', 'agb_en', 'photo', 'uncountable', 'responsible_person', 'custom_template', 'category', 'thumbnail',
        ];

        $index = [
            'id' => $product->getId(),
            'position' => $product->lookup('position') ? (Integer) $product->lookup('position')->getValue() : null,
            'invoice_include' => (Boolean) $product->lookup('invoice_include'),
            'price' => unserialize((String) $product->lookup('price')),
            'price_en' => unserialize((String) $product->lookup('price_en')),
        ];

        foreach ($fieldsToIndex as $field) {
            $index[$field] = (String) $product->lookup($field);
        }

        $categoryTree = [];
        $parent = $product->lookup('category');

        do {
            $c = $this->taxonomyManager->findOneTaxonomyItem('categories', ['slug' => $parent]);
            if (!$c) {
                break;
            }

            $category = $this->taxonomyManager->build($c);

            array_unshift($categoryTree, $category);

            $parent = $category->lookup('subcategory')->getValue();
        } while ($parent != '');

        $tree = [];
        foreach ($categoryTree as $t) {
            $tree[] = [
                'slug' => (String) $t->lookup('slug'),
                'title' => (String) $t->lookup('title'),
                'title_en' => (String) $t->lookup('title_en'),
            ];
        }

        $index['category_path'] = $tree;

        if (count($tree) == 0) {
            return;
        }

        return $this->elasticSearchClient->index($index, $product->getId());
    }


Zasoby serwera zostały zmniejszone o 40%.
Ilość zapytań do baz danych została również zredukowana o 80-90%.

Strona Główna (80 zapytań) -> po optymalizacji 3,
Wyszukiwarka wyników (140 zapytań) -> po optymaliacji 14,
Podstrona kategorii (75 zapytań) -> po optymalizacji 7,

Oczywiście po optymalizacji kodu, również klient zauważył diametralną różnicę w samym użytkowaniu.

Efektem naszej współpracy jest długoterminowa umowa rozwijania aplikacji. Mamy jeszcze sporo do zaoferowania naszemu klientowi ;-)

Jeżeli chciałbyś również zostać naszym klientem, zapraszamy Cię do kontaktu z nami
Dedykowane oprogramowanie - Wiseweb - Mateusz
Mateusz Nowak, Software Architect & CEO

Programista PHP oraz Java z ponad 8 letnim doświadczeniem

W swojej karierze realizował projekty dla firm w Polsce oraz za granicą. Współpracował w zakresie tworzenia systemów e-commerce dla niemieckiej firmy organizyjącej targi ogólnoświatowe, czy pracował jako solution architect tworząc aplikacje rządowe dla klienta z Wielkiej Brytanii.

Główne tematy zainteresowań to architektura aplikacji, szukanie integracji systemów 3rd party oraz budowanie niestandardowych interfejsów API.

Dziękujemy, że nas odwiedźiłeś/aś i poświęciłeś trochę swojego czasu na nasz blog ;-)
W razie jakichkolwiek pytań skontaktuj się z nami