Hogyan hozd ki a maximumot Magento-ban az oldalbetöltési sebességből?

Webáruházak esetén ez kiemelten súlyos gond lehet, hiszen a konkurens cégek oldalai megelőzik a miénket. Hivatalos mérések szerint, ha rátalálnak is az oldalunkra a látogatók, minden egyes várakozással eltöltött másodperc miatt legalább 7% konverzió csökkenést érhetünk el. Az oldalak sebességét mérhetjük külső eszközökkel, ahol javaslatokat is kaphatunk az optimális cél eléréséhez, ilyen oldalak pl. a következők:

Személyes kedvencünk a New Relic szoftverelemző szoftver, melynek segítségével folyamatosan nyomon tudjuk követni az oldalaink sebességét (és még nagyon sok hasznos dolgot ezen kívül). A fent felsorolt eszközök nagyon sokat segítenek a lassulás tényének megállapításában, de nem látnak bele a Magento-ban zajló belső folyamatokba. Én most ezeknek a belső folyamatoknak a felderítéséről, és javításáról szeretnék írni.

A felderítés

Belső profiler

A belső folyamatok sebesség méréséhez egy belső profilert használhatunk. A profiler segítségével betekintést nyerhetünk a  controller-ek, action-ok, block-ok, observer-ek és egyebek sebesség és memória használatába. Használatához a System / Configuration / Developer / Debug / Profiler szekcióban kell a profilozást engedélyeznünk, és ha publikus oldalon mérünk, akkor a System / Configuration / Developer / Developer Client Restriction szekcióban az IP-címünket is be kell állítanunk. Ezután az index.php fájlban ki kell szednünk a kommentet a következő sor elől:

 Varien_Profiler::enable(); 
Az eredmény hasonlóan fog kinézni:

Magento Code Profiling

A saját moduljainkban a következő sorok beillesztésével használhatjuk a profiler opcióit:

Varien_Profiler::start('egyedi_profil_azonosito');
//... itt vannak a mérendő kódsorok
Varien_Profiler::stop('egyedi_profil_azonosito');

Az SQL lekérdezések profilozásához az app/etc/local.xml-ben kell elvégeznünk a beállításokat:

<default_setup>
     <connection>
         <host><![CDATA[localhost]]></host>
         <username><![CDATA[mage_user]]></username>
         <password><![CDATA[mage_password]]></password>
         <dbname><![CDATA[mage_db]]></dbname>
         <initStatements><![CDATA[SET NAMES utf8]]></initStatements>
         <model><![CDATA[mysql4]]></model>
         <type><![CDATA[pdo_mysql]]></type>
         <pdoType><![CDATA[]]></pdoType>
         <active>1</active>
         <profiler>1</profiler>
     </connection>
 </default_setup>

Ennek a beállításnak az eredménye a következő képen látható:

Magento Code Profiling 2

 

Aoe Profiler

Lássuk be, ezt elég nehéz olvasni, és nem is látszik rajta, milyen struktúrában épülnek fel ezek az adatok, emiatt egy időben használtuk az  Aoe_Profiler nevű plugint. Ez a plugin már hierarchikus struktúrában jeleníti meg az adatokat, szép, átlátható kis diagramokkal, melyek segítségével sokkal pontosabb információt nyerhetünk arról, mik azok a dolgok, amelyek rontják a teljesítményt az oldalunkon.

Aoe Profiler Magento Code

Mi ezek helyett az AionHill saját fejlesztésű modulját használjuk, amellyel még hatékonyabban kideríthetőek, mik azok a dolgok, amik rontják a teljesítményt.

Az AionHill_Profiler bemutatása

Blokkok

Hierarchikus felépítésben megjelenítjük a megjelenített blokkokat, jelezve a megjelenítésükhöz felhasznált időt grafikonon és másodpercben kiírva, listázzuk, hogy az adott blokk használ-e cache-t, és a blokk megjelenítése közben futtatott SQL lekérdezéseket.

Magento Code Profiler by AionHill

 

SQL lekérdezések

A modul megjeleníti az oldalon használt SQL lekérdezéseinket is. Itt is használunk diagramot, valamint másodpercben is kiírjuk az időt, valamint a teljes utat (stack trace), amiből megállapíthatjuk, melyik Magento osztály melyik sorából indult az SQL lekérdezés.

Magento SQL Queries

 

Ismétlődő SQL lekérdezések

A modul figyelmeztet minket, ha vannak teljesen azonos SQL lekérdezések az oldalon belül. Az ábrán jól látható, hogy hány ilyen van, ezek hányszor fordulnak elő, és összesen mennyi időt kellett erre fordítania a MySQL szervernek.

 

Magento SQL Queries Repeats

 

Ciklusba rakott SQL lekérdezések

Végül pedig a ciklusba rakott – de nem feltétlenül azonos – SQL lekérdezéseket derítjük fel:

Magento Loop SQL Queries

 

Megoldási javaslatok

És most lássunk néhány gyakorlati példát, mit tehetünk a felderített problémák javítása érdekében.

Szüntessük meg a ciklusba rakott SQL lekérdezéseket.

Bármilyen gyors is az SQL szerver, felesleges futásidőt visz el a többszöri ok nélküli meghívása. Ha csak tehetjük, kérdezzük le az adatainkat egyben, ne pedig ciklusban egyenként. Példaként mellékeltem 2 függvényt, amik a paraméterként kapott termékazonosítók átlagárával térnek vissza. Az első – hibás – módszer egy ciklusban egyenként betölti a termékeket, majd egy tömbhöz adja az árát, melyből a cikluson kívül kiszámolja az átlagárat, és visszatér vele.

/**
 * get Average Price (bad example)
 *
 * @param array $productIds product ids
 *
 * @return float
 */
public function getAveragePriceBadMethod(array $productIds)
{
 $prices = array();

 foreach ($productIds as $productId) {
 $product = Mage::getModel('catalog/product')->load($productId);
 $prices[] = $product->getPrice();
 }

 return array_sum($prices) / count($prices);
}

Egy példa az egyik lehetséges jó megoldásra: A termékeket nem egyesével, hanem az őket tartalmazó kollekciót kérdezzük le, majd ennek az elemeivel dolgozunk.


/**
 * get Average Price (good example)
 *
 * @param array $productIds product ids
 *
 * @return float
 */
public function getAveragePriceGoodMethod(array $productIds)
{
    if (empty($productIds)) {
        return 0;
    }

    $prices = array();
    $products = Mage::getResourceModel('catalog/product_collection')
        ->addAttributeToSelect('price')
        ->addAttributeToFilter('entity_id', array('in' => $productIds));

    foreach ($products as $product) {
        $prices[] = $product->getPrice();
    }

    return array_sum($prices) / count($prices);
}

Valójában ebben az esetben még ez sem a legoptimálisabb megoldás, hiszen ha csak az árra van szükségünk, felesleges a teljes kollekció betöltése. Ha csak egyetlen mező értékére van szükségünk, használhatjuk a következő módszert:


/**
 * get Average Price (good example)
 *
 * @param array $productIds product ids
 *
 * @return float
 */
public function getAveragePrice(array $productIds)
{
    if (empty($productIds)) {
        return 0;
    }

    $products = Mage::getResourceModel('catalog/product_collection')
        ->addAttributeToSelect('price')
        ->addAttributeToFilter('entity_id', array('in' => $productIds));

    $select = $products->getSelect()
        ->reset(Zend_Db_Select::COLUMNS)
        ->columns('price');

    $prices = $products->getConnection()->fetchCol($select);

    return array_sum($prices) / count($prices);
}

Gyakori probléma szokott lenni a kosárba rakott termékekkel való munka során a termék ismételt lekérdezése adatbázisból. A Quote model gondoskodik róla, hogy az elemekhez kapcsolódó termékek már be legyenek töltve, nincs szükségünk újabb model load-okra.

/**
 * get Quote Weight (bad example)
 *
 * @return float
 */
public function getQuoteWeightBadExample()
{
    $quoteItems = Mage::getSingleton('checkout/cart')->getQuote()->getAllItems();
    $quoteWeight = 0;

    /** @var Mage_Sales_Model_Quote_Item $quoteItem */
    foreach ($quoteItems as $quoteItem) {
        $product = Mage::getModel('catalog/product')->load($quoteItem->getProductId());
        $quoteWeight += $product->getWeight() * $quoteItem->getQty();
    }

    return $quoteWeight;
}

/**
 * get Quote Weight (good example)
 *
 * @return float
 */
public function getQuoteWeight()
{
    $quoteItems = Mage::getSingleton('checkout/cart')->getQuote()->getAllItems();
    $quoteWeight = 0;

    /** @var Mage_Sales_Model_Quote_Item $quoteItem */
    foreach ($quoteItems as $quoteItem) {
        $quoteWeight += $quoteItem->getProduct()->getWeight() * $quoteItem->getQty();
    }

    return $quoteWeight;
}

Szüntessük meg az ismétlődő SQL lekérdezéseket

Természetesen lehet indokolt eset arra, ha ugyanazt a lekérdezést többször kell megismételnünk – pl. módosítás utáni újra betöltés ellenőrzés céljából – de nagyon sokszor tervezési/fejlesztési hiba áll a háttérben. A leggyakoribb hibák a következők szoktak lenni. Többször használt metódus visszatérési értékét nem tároljuk el:


/**
  * get Feature Categories (bad example)
  *
  * @return Mage_Catalog_Model_Resource_Category_Collection
  * @throws Mage_Core_Exception
  */
 public function getFeatureCategoriesBadExample()
 {
     $categories = Mage::getModel('catalog/category')->getCollection()
         ->addAttributeToSelect('*')
         ->addAttributeToFilter('name', array('like' => '%feature%'))
         ->load();
 
     return $categories;
 }

Ha egy oldalon 10 különböző helyen használjuk ezt a metódust, akkor 9 esetben teljesen feleslegesen kérdezzük le újra az adatokat a mySQL szervertől. Az eredményt eltárolhatnánk egy osztályváltozóban, és ezt használhatnánk cache-elésre.


/**
  * Local cache for feature categories
  *
  * @var null|Mage_Catalog_Model_Resource_Category_Collection
  */
 protected $_featureCategories = null;
 
 /**
  * get Feature Categories (good example)
  *
  * @return Mage_Catalog_Model_Resource_Category_Collection
  * @throws Mage_Core_Exception
  */
 public function getFeatureCategories()
 {
     if (!is_null($this->_featureCategories)) {
         return $this->_featureCategories;
     }
 
     $this->_featureCategories = Mage::getModel('catalog/category')->getCollection()
         ->addAttributeToSelect('*')
         ->addAttributeToFilter('name', array('like' => '%feature%'))
         ->load();
 
     return $this->_featureCategories;
 }

Egy másik gyakori probléma, ha singleton helyett model-t használunk. Eleve az is performancia gondot okozhat, ha egy példány helyett egy osztály több példányban létezik, de ha komolyabb műveleteket is végez, akkor a baj csak még nagyobb lehet. A következő példa egy kiterjesztett kosár, melynek a konstruktorában elhelyeztem egy kategória kollekció betöltését.


/**
 * Class My_Module_Model_Checkout_Cart
 */
class My_Module_Model_Checkout_Cart extends Mage_Checkout_Model_Cart
{
    /** @var Mage_Catalog_Model_Resource_Category_Collection  */
    protected $_quoteCategories;

    /**
     * Constructor
     */
    public function __construct()
    {
        parent::__construct();

        $categoryIds = array();
        $quoteItems = $this->getQuote()->getAllItems();

        /** @var Mage_Sales_Model_Quote_Item $quoteItem */
        foreach ($quoteItems as $quoteItem) {
            $product = $quoteItem->getProduct();
            $categoryIds = array_merge($categoryIds, $product->getCategoryIds());
        }

        $this->_quoteCategories = Mage::getModel('catalog/category')->getCollection()
            ->addAttributeToSelect('*')
            ->addAttributeToFilter('entity_id', array('in' => array_unique($categoryIds)))
            ->load();
    }
}

Ezzel még nem is lenne nagy baj, ha megfelelően kezeljük ezt a kiterjesztett osztályt.


// hibás példa 
$productIds = Mage::getModel('my_module/checkout_cart')->getProductIds();
$itemsQty = Mage::getModel('my_module/checkout_cart')->getItemsQty();

// helyes példa
$productIds = Mage::getSingleton('my_module/checkout_cart')->getProductIds();
$itemsQty = Mage::getSingleton('my_module/checkout_cart')->getItemsQty();

A fenti hibás példában több példányban jön létre az osztályunk, és a konstruktorban levő kategória lekérdezés minden esetben le fog futni. Ugyanez a helyzet, ha különböző metódusokban vannak erőforrásigényes dolgok. Ebben az esetben, még ha a korábban látott példa alapján egy osztályváltozót használunk cachelésre, akkor is újra végrehajtódnak az időigényes kódsorok, hiszen a korábbi számításainkat az osztály egy másik példányában tároltuk el. Az alsó, helyes példa esetén az objektum egy példányban jön létre, és nem végzünk felesleges műveleteket. Ha valamilyen okból nem tudunk az singletont használni, akkor használhatjuk a Magento Helper-eket – ezek singleton osztályok -, vagy esetleg a Mage::registry-t átmeneti adatok tárolására. Ezek nagyon egyszerű tippek, de ha nem figyelünk oda rájuk, nagyon könnyen sokszorosára nőhet egy oldalon az SQL lekérdezések száma.

A hosszabb futás-idejű SQL lekérdezések javítása

Megfelelő tábla indexek létrehozása

Sokszor az állhat a háttérben, hogy a kérdéses tábla megfelelő mezői nincsenek indexelve. Ezzel óvatosan kell bánni, mert minél több indexet használunk a tábláinknál, annál lassabb lesz az írás ezekbe a táblákba, a keresés és rendezés viszont lényegesen gyorsabb lesz. Nagyon fontos, hogy optimálisan határozzuk meg a tábla szerkezetet és az indexeket. A tábláinkra a modulban elhelyezett installer segítségével tehetünk indexeket.


$installer = $this;

$installer->startSetup();

$tableName = $installer->getTable('my_module/model');

if ($installer->getConnection()->isTableExists($tableName)) {
    $table = $installer->getConnection();

    try {
        $table->addIndex(
            $installer->getIdxName(
                'my_module/model',
                array(
                    'column1',
                    'column2',
                ),
                Varien_Db_Adapter_Interface::INDEX_TYPE_INDEX
            ),
            array(
                'column1',
                'column2',
            ),
            array('type' => Varien_Db_Adapter_Interface::INDEX_TYPE_INDEX)
        );
    } catch (Exception $e) {
        Mage::logException($e);
    }
}

$installer->endSetup();

A termék flat táblák indexelésének kiterjesztése

Sok termék esetén még a product flat táblákból is lassabbak lehetnek a lekérdezések, ha olyan szűrést vagy rendezést használunk, amire a Magento nem tesz mező indexet. A flat táblákat nem tudjuk installerből indexelni, hiszen ezeket a táblákat eldobja és újra létrehozza indexeléskor a Magento, viszont egy observerrel módosíthatunk a flat tábla alapértelmezett indexein. Ennek a megvalósításához a catalog_product_add_indexes eseményre kell ráakasztanunk egy observert.


<events>
    <catalog_product_flat_prepare_indexes>
        <observers>
            <my_module_catalog_product_flat_prepare_indexes>
                <type>singleton</type>
                <class>my_module/observer</class>
                <method>catalogProductFlatPrepareIndexes</method>
            </my_module_catalog_product_flat_prepare_indexes>
        </observers>
    </catalog_product_flat_prepare_indexes>
</events>

/**
 * Add indexes to product flat table
 *
 * @param Varien_Event_Observer $observer observer
 *
 * @return void
 */
public function catalogProductFlatPrepareIndexes(Varien_Event_Observer $observer)
{
    /** @var Varien_Object $indexesObject */
    $indexesObject = $observer->getIndexes();
    /** @var array $indexes */
    $indexes = $indexesObject->getIndexes();

    $indexes['IDX_MY_ATTRIBUTE'] = array(
        'type' => Varien_Db_Adapter_Interface::INDEX_TYPE_INDEX,
        'fields' => array('my_attribute')
    );

    $indexesObject->setIndexes($indexes);
}

A fenti metódus mindig lefut, mikor újra-indexelés miatt a Magento újra létrehozza a flat táblát.

Nagy erőforrásigényű SQL join-ok mellőzése

Vannak olyan esetek, amikor már nem lehet szimplán indexekkel kezelni egy lassú lekérdezést, mert több nagy táblát kapcsolunk össze, és mindenképp hatalmas adatmennységgel kell megküzdenie a MySQL szervernek. Tételezzük fel pl. a következő esetet: szeretnénk a terméklista oldalon készlet mennyiség, és értékelés szerint rendezést végrehajtani. Ebben az esetben a következő módszert szoktuk használni:


$collection->joinField(
    'quantity',
    'cataloginventory/stock_item',
    'qty',
    'product_id=entity_id',
    '{{table}}.stock_id=1',
    'left'
);

$collection->joinField(
    'rating_summary',
    'review_entity_summary',
    'rating_summary',
    'entity_pk_value=entity_id',
    array(
        'entity_type' => 1,
        'store_id' => Mage::app()->getStore()->getId()
    ),
    'left'
);

$collection->setOrder($attribute, $direction);

Itt a termékek és az értékelések mennyiségétől függően hatalmas mennyiségű adat is összegyűlhet, és ebben a rendezés iszonyú lassúra sikerülhet. Rengeteg praktikát lehet elsajátítani a MySQL lekérdezésekkel kapcsolatban, én itt arra az egyszerű esetre szeretnék rávilágítani, hogy nincs is a join-ra minden esetben szükség, csak amikor tényleg használnánk őket.


if ($attribute == 'quantity') {
    $collection->joinField(
        'quantity',
        'cataloginventory/stock_item',
        'qty',
        'product_id=entity_id',
        '{{table}}.stock_id=1',
        'left'
    );
}

if ($attribute == 'rating_summary') {
    $collection->joinField(
        'rating_summary',
        'review_entity_summary',
        'rating_summary',
        'entity_pk_value=entity_id',
        array(
            'entity_type' => 1,
            'store_id' => Mage::app()->getStore()->getId()
        ),
        'left'
    );
}

$collection->setOrder($attribute, $direction);

Ezzel az apró trükkel máris megakadályoztuk 2 nagy tábla hozzákapcsolását a termék kollekcióhoz, mindig csak annak a táblának a kapcsolása történik meg, amire valóban szükségünk van.

Magento blokkok teljesítményjavítása

Amikor csak lehetséges, használjuk a Magento blokkok cachelését. Ha szükséges, szegmentálhatjuk ezeket a cache adatokat felhasználói csoportonként, és több szegmentációt is kombinálhatunk.


/**
 * construct
 *
 * @return void
 */
protected function _construct()
{
    $this->addData(
        array(
            'cache_lifetime' => 3600,
            'cache_key'      => 'MY_MODULE_' . $this->getExampleModel()->getId(),
            'cache_tags'     => array(My_Module_Model_Example::CACHE_TAG)
        )
    );
}

Használjunk ún. objetum cache-t. Itt azokra a metódusokra gondolok, amelyeket többször is meghívunk, és nem feltétlenül szükséges mindig futtatnunk a benne levő kódokat.


/**
 * get Category Collection
 *
 * @return Mage_Catalog_Model_Resource_Category_Collection|mixed
 * @throws Mage_Core_Exception
 */
public function getCategoryCollection()
{
    if ($this->hasData('category_collection')) {
        return $this->getData('category_collection');
    }

    $collection = Mage::getModel('catalog/category')->getCollection()
        ->addAttributeToSelect('*')
        ->addAttributeToFilter('parent_id', array('eq' => Mage::app()->getStore()->getRootCategoryId()));

    $this->setData('category_collection', $collection);
    return $collection;
}

Egyéb hasznos fejlesztői javaslatok a jobb teljesítmény érdekében

Egyszerűbb SQL lekérdezések

Ha csak pl. azonosítókat szeretnénk egy kollekcióból összegyűjteni, oldjuk meg ciklus nélkül:


// rossz példa
$ids = array();

$products = Mage::getModel('catalog/product')->getCollection()
    ->addAttributeToFilter('sku', array('like' => 'test-%'));

foreach ($products as $product) {
    $ids[] = $product->getId();
}

// helyes példa
$ids = Mage::getModel('catalog/product')->getCollection()
    ->addAttributeToFilter('sku', array('like' => 'test-%'))
    ->getAllIds();

A getAllIds metódust minden Magento kollekció tartalmazza. Ha nem az azonosítókra van szükségünk, hanem másik mezőre, de csak arra az egyre, akkor használhatjuk a következő módszert:


// rossz példa
$result = array();

$products = Mage::getModel('catalog/product')->getCollection()
    ->addAttributeToSelect('my_attribute')
    ->addAttributeToFilter('sku', array('like' => 'test-%'));

foreach ($products as $product) {
    $result[] = $product->getData('my_attribute');
}

// helyes példa
$collection = Mage::getResourceModel('catalog/product_collection')
    ->addAttributeToSelect('test')
    ->addAttributeToFilter('sku', array('like' => 'test-%'));

$select = $collection->getSelect()
    ->reset(Zend_Db_Select::COLUMNS)
    ->columns('test')
    ->group('test');

$result =  $collection->getConnection()->fetchCol($select);

Ha csak ellenőrizni szeretnénk, hogy egy érték létezik-e a táblában:


// hibás példa
$firstItem = Mage::getModel('catalog/product')->getCollection()
    ->addAttributeToFilter('hello', array('gt' => 3))
    ->getFirstItem();

$hasData = $firstItem->getId() != null;

// helyes példa
$size = Mage::getResourceModel('catalog/product_collection')
    ->addAttributeToFilter('hello', array('gt' => 3))
    ->getSize();
$hasData = $size > 0;

Ahol csak tudunk, egyszerűsítsünk

Ezek ugyan csak apróságok, de vihetnek el futásidőt azon túl, hogy a rövidebb kód számunkra is kezelhetőbb. Ha pl. a bejelentkezett felhasználónak csak az azonosítójára van szükségünk:


// kevésbé hatékony
$customerId = Mage::getSingleton('customer/session')->getCustomer()->getId();
// picit rövidebb
$customerId = Mage::getSingleton('customer/session')->getCustomerId();

Hasonlóan a kosárban levő termékek, és azok azonosítói


$quoteItems = Mage::getSingleton('checkout/cart')->getQuote()->getAllItems();

foreach ($quoteItems as $item) {
    // ha csak a termék id-ra van szükségünk
    // ez picit hosszabb
    $productId = $item->getProduct()->getId();

    // ez hatékonyabb
    $productId = $item->getProductId();


    // ha a termékre van szükségünk
    // ez kimondottan rossz megoldás
    $product = Mage::getModel('catalog/product')->load($item->getProductId());

    // ez pedig a megfelelő
    $product = $item->getProduct();
}
Összegzés

Láthattunk néhány hasznos tippet a Magento oldalunk sebességoptimalizálására, és még közel nem mutattunk be minden lehetőséget. Ne felejtsük el, hogy a látogatóink potenciális ügyfelek, és könnyen elveszíthetjük őket, ha nem találják az oldalt használhatónak.  Hiába a szép design, és az ergonómia szempontjából tökéletes elrendezés, egy lassú weboldal alaposan le tudja rombolni a kezdetben pozitív felhasználói élményt. Ma már nem szabad elválasztanunk ezt a fontos kritériumot a többi lényeges szemponttól.

 

0 válaszok

Hagyjon egy választ

Want to join the discussion?
Feel free to contribute!

Vélemény, hozzászólás?

Az email címet nem tesszük közzé.