PHP Profi

Ускоряем запросы в БД с помощью PDO и итераторов Перевод

php iterator performance производительность итератор

Когда вы просматриваете километры кода, у вас вполне может возникнуть вопрос: "Почему все сделано так, как сделано?" Лично я особенно замечаю вещи, которые могут и должны быть улучшены, когда дело касается тяжелых запросов в БД. 

Разработка без фреймворка

При работе с фреймворком запросы в БД в основном уже оптимизированы для разработчика, а сложная логика абстрагирована, что улучшает и оптимизирует получение и дальнейшее использования данных. Но бывает, что разработчикам надо накодить что-то без использования фреймворка. При этом основные возможности PHP часто используются не самым оптимальным образом.

$pdo = new \PDO(
    $config['db']['dsn'],
    $config['db']['username'],
    $config['db']['password']
);

$sql = 'SELECT * FROM `gen_contact` ORDER BY `contact_modified` DESC';

$stmt = $pdo->prepare($sql);
$stmt->execute();
$data = $stmt->fetchAll(\PDO::FETCH_OBJ);

echo 'Getting the contacts that changed the last 3 months' . PHP_EOL;
foreach ($data as $row) {
    $dt = new \DateTime('2015-04-01 00:00:00');
    if ($dt->format('Y-m-d') . '00:00:00' < $row->contact_modified) {
        echo sprintf(
            '%s (%s)| modified %s',
            $row->contact_name,
            $row->contact_email,
            $row->contact_modified
        ) . PHP_EOL;
    }
}

Выше приведен пример кода, который наиболее часто используется для получения данных. На первый взгляд, этот код выглядит хорошо и чистенько, но, присмотревшись получше, вы увидите пару возможностей для улучшения.

  • Код не дает возможность повторного использования. Когда будет надо задействовать похожий функционал, вам придется дублировать существующий код.
  • Даже если вы для выборки используете $stmt->fetchAll(\PDO::FETCH_OBJ); у вас остается проблема, т.к. на выходе получится массив объектов. При большой выборке это пожрет море памяти.
  • Фильтрация делается с помощью функции. Это означает, в том случае, если потребуются другие условия фильтрования, вам нужно будет модифицировать текущую логику, что далеко не прибавит удобства для сопровождения и расширения функционала.

 

Итераторы

Большинство современных фреймворков для получения данных применяет итераторы, потому что они быстры и пригодны для повторного использования. А еще они позволяют задействовать другие итераторы для фильтрации и изменения возвращаемых результатов. Но и без фреймворка вы можете использовать их, т.к. итераторы стали частью PHP еще с версии 5.0.0 Beta 2.

 

Итак, давайте представим, что вы продолжаете использовать PDO для получения данных. У нас есть для варианта:

  • Использовать PDOStatement::fetchAll() для получения всех данных за один проход.
  • Использовать PDOSTatement::fetch() для получения одной строки за одну итерацию.

Даже если первый вариант вам кажется очень заманчивым, я предпочитаю и советую использовать вариант за номером два. Он позволяет мне создать один итератор для извлечения данных не ограничиваясь условиями запроса (что делает его пригодным к повторному использованию для любых извлечений).

<?php

/**
 * Class DbRowIterator
 *
 * File: Iterator/DbRowIterator.php
 */
class DbRowIterator implements Iterator
{
    /** @var \PDOStatement $pdoStatement The PDO Statement to execute */
    protected $pdoStatement;
    /** @var int $key The cursor pointer */
    protected $key;
    /** @var  bool|\stdClass The resultset for a single row */
    protected $result;
    /** @var  bool $valid Flag indicating there's a valid resource or not */
    protected $valid;

    public function __construct(\PDOStatement $PDOStatement)
    {
        $this->pdoStatement = $PDOStatement;
    }

    /**
     * @inheritDoc
     */
    public function current()
    {
        return $this->result;
    }

    /**
     * @inheritDoc
     */
    public function next()
    {
        $this->key++;
        $this->result = $this->pdoStatement->fetch(
            \PDO::FETCH_OBJ, 
            \PDO::FETCH_ORI_ABS, 
            $this->key
        );
        if (false === $this->result) {
            $this->valid = false;
            return null;
        }
    }

    /**
     * @inheritDoc
     */
    public function key()
    {
        return $this->key;
    }

    /**
     * @inheritDoc
     */
    public function valid()
    {
        return $this->valid;
    }

    /**
     * @inheritDoc
     */
    public function rewind()
    {
        $this->key = 0;
    }
}

Этот итератор всего лишь имплементирует PHP Iterator interface, но для нашего примера этого более чем достаточно для достижения цели.

Как видите, мы реализуем логику извлечения данных в методе "next", который является оператором цикла. Это наш главный метод последовательного извлечения. Обратите внимания на второй и третий аргументы PDOSTatement::fetch(): с помощью второго мы можем контролировать курсор в нашем извлечении данных, третий позиция курсора в извлечении. Установка курсора прокручиваемым делается вне итератора.

<?php
class LastPeriodIterator extends FilterIterator
{
    protected $period;

    public function __construct(\Iterator $iterator, $period = 'last week')
    {
        parent::__construct($iterator);
        $this->period = $period;
    }
    public function accept()
    {
        if (!$this->getInnerIterator()->valid()) {
            return false;
        }
        $row = $this->getInnerIterator()->current();
        $dt = new \DateTime($this->period);
        if ($dt->format('Y-m-d') . '00:00:00' < $row->contact_modified) {
            return true;
        }
        return false;
    }
}

Для фильтрации данных, расширим SPL FilterIterator. Это позволит нам мгновенно прикрутить фильтрацию к нашему DbRowIterator, делая его расширяемым и давая возможность повторного использования.

Теперь изменим наш изначальный код, добавив в него использование двух наших итераторов:

$pdo = new \PDO(
    $config['db']['dsn'],
    $config['db']['username'],
    $config['db']['password']
);

$sql = 'SELECT * FROM `gen_contact` ORDER BY `contact_modified` DESC';
$stmt = $pdo->prepare($sql, [\PDO::ATTR_CURSOR => \PDO::CURSOR_SCROLL]);
$stmt->execute();

$data = new DbRowIterator($stmt);
echo 'Getting the contacts that changed the last 3 months' . PHP_EOL;
$lastPeriod = new LastPeriodIterator($data, '2015-04-01 00:00:00');
foreach ($lastPeriod as $row) {
    echo sprintf(
        '%s (%s)| modified %s',
        $row->contact_name,
        $row->contact_email,
        $row->contact_modified
    ) . PHP_EOL;
}

Пожалуйста, обратите внимание на $pdo->prepare($sql, [\PDO::ATTR_CURSOR => \PDO::CURSOR_SCROLL]);. Теперь мы уверены, что курсор нашего извлечения данных прокручиваемый и мы можем идти от строки к строке.

Тестирование быстродействия

Я знаю, что это все требует немного "лишней" работы и вы можете удивиться, зачем же вам нужно эта "дополнительная работа", ведь цикл foreach работает так же хорошо. Но давайте посмотрим на быстродействие этих двух вариантов:

Foreach:

  • Время выборки данных для 63992 из 250000 записей: 2.14 секунды
  • Время обработки данных для 63992 из 250000 записей: 7.11 секунд
  • Общее время для 63992 из 250000 записей: 9.25 секунд
  • Использовано памяти для 63992 из 250000 записей: 217.75 Мб

Итератор:

  • Время выборки данных для 63992 из 250000 записей: 0.92 секунды
  • Время обработки данных для 63992 из 250000 записей: 5.57 секунд
  • Общее время для 63992 из 250000 записей: 6.49 секунд
  • Использовано памяти для 63992 из 250000 записей: 0.25 Мб

Тестирование проводилось на машине с ОС Ubuntu 12.04 LTS (виртуальная машина), MySQL 5.5.43 и PHP 5.5.26. Другие версии PHP, MySQL или ОС могут дать другие результаты. 250000 записей были сгенерированы с помощью fzaninotto/Faker.

Вывод

Используя простые итераторы в вашем PHP коде, вы можете ускорить получение и обработку данных, но, что важнее всего и это показали тесты, итераторы экономят кучу памяти.

 

Примечание

Итераторы наиболее эффективны при работе с большими объемами данных. Для малых выборок (менее 5000 сущностей) итераторы могут работать медленнее, но при этом вы все же экономите память.

2015-08-31 solD1Er Поделиться: оригинал