Производительность pagination в CakePHP 1.2

Хотя использование разбивки на страницы (pagination) вполне заслуживает отдельного, обстоятельного поста, хочу остановиться лишь на паре не слишком очевидных моментов. Я предполагаю, что у читающих этот пост есть определенный навык использования CakePHP, моделей вообще и pagination в частности. Этот пост и так получается длинным.

Известно, что метод контроллера paginate() вызывает последовательно два метода модели — первый для подсчета общего числа записей, удовлетворяющих заданным условиям, и второй на выборку указанного числа записей начиная с заданной. Т.е. первый это фактически функция SQL COUNT(*), второй — SELECT … LIMIT n,m.

Именно из-за того, что вызовов методов больше одного, не срабаывает временная привязка/отвязка подчиненных моделей с помощью bindModel()/unbindModel(). Этим методам, для корректной работы с paginate() приходится передавать второй параметр, равный false. К счастью, еть ContainableBehavior, решающий эту проблему.

Эти запросы не всегда оптимальны и есть возможность улучшить производительность метода paginate() именно за счет оптимизации собственно самих запросов.

Для того, чтобы влиять на работу метода paginate(), в контроллере CakePHP предусмотрены вызовы двух специальных методов модели: Model::paginateCount() и Model::paginate(). Метод paginate() контроллера ищет у модели указанные методы и, если они определены, вызывает их. Если у модели методы не определены, вызываются стандартные Model::find(‘count’ …) и Model::find(‘all’,..), которым в качестве параметров передаются условия выборки, условия для Containable и все прочее.

Понятно, что определив эти методы в модели, все вместе или любой на выбор, можно творить все, что захочется. А сам метод Controller::paginate() (не путайте с нашим Model::paginate()!) тогда будет заниматься исключительно вспомогательной работой — опредять параметры для LIMIT и настраивать переменные вида (View). Самое главное, чтобы наш метод Model::paginateCount() возвращал (int) количество записей, а Model::paginate() — массив записей.

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

Model::paginateCount

Первым займемся методом для подсчета количества записей. Этот метод я уже описывал чуть более года назад, но то описание во-первых устарело, потому, что в итоге в CakePHP сменился синтаксис вызова COUNT(*), а во-вторых здесь я постараюсь дать более развернутое описание.

При вызове метода Model::paginateCount(), контроллер передает ему три параметра: $conditions, $recursive и $extra.

Хочу отметить, что никто не запрещает указывать в массиве Controller::paginate какие-то свои собственные ключи. Так что, если в методе нашего контроллера определить что-то типа:

$this->paginate['my-cool-param']='YES!';

то эта пара ключ-значение тоже попадет в параметр extra.

Чаще всего для разбивки на страницы используется модель, связанная с другими. Классический пример с моделью Article, которая принадлежит (belongsTo) модели Author. Для окончательного вывода нам нужны данные из обоих моделей, но для подсчета записей достаточно посчитать только записи в Article. Т.е. для подсчета строк достаточно выполнить COUNT(*) только для таблицы articles, без лишних JOIN’ов. MySQL работает гораздо веселее, когда удается обойтись без связанных таблиц. Для этого простого случая вполне можно вызывать $this->Article->unbindModel(array(‘belongsTo’=>array(‘Author’)))) и связанная модель Author не будет участвовать в подсчете, но будет в выборке результатов. Помните, что метод unbindModel() по умолчанию «отвязывает» модель только на один, следующий, запрос?

Но если связанных моделей много, что, надо помнить их все? Да и дергать unbindModel() перед Controller::paginate() что-то не улыбается. Особенно с точки зрения парадигмы «тонкий контроллер, толстая модель».

Иными словами, я считаю, что надо написать такой метод paginateCount, который будет подсчитывать записи в таблице без присоединения зависимых моделей. Сделать это можно жестко задавая параметр $recursive при вызове Model::find(‘count’…). Получится примерно такой код:

function paginateCount($conditions, $recursive, $extra)
{
    return $this->find('count',
        array(
            'conditions'=>$conditions,
            'recursive'=>-1
        )
    );
}

Теперь при вызове COUNT(*) не будет совсем никаких JOIN’ов, несмотря ни на какие отношения.

Я довольно долго пользовался этим вариантом метода и быль вполне им доволен. Но совсем недавно возникла необходимость все-таки в полном подсчете записей — из-за условия отбора по полю присоединенной модели. Совсем убирать этот метод мне не хотелось и я решил просто добавить возможность указания — надо ли использовать для подсчета запрос с присоединенными моделями или можно обойтись подсчетом записей только в одной. Если конкретнее, то для frontend метода index использую «быстрый» метод подсчета, а в админской части есть более сложные фильтры, требующие использования «медленного» метода. Чтобы указать необходимость использования «медленного» метода, с использованием присоединенных моделей, используется дополнительный ключ в массиве Controller::paginate. Что я, зря, чтоль столько букв про $extra написал? :-) Теперь если задан сложный фильтр по записям, кроме условий в Controller::paginate['conditions'], добавляю ключ Controller::paginate['count_recursive']=1. А метод стал выглядить так:

function paginateCount($conditions, $recursive, $extra)
{
    if (@$extra['count_recursive']) {
        unset($extra['count_recursive']);
    } else {
        $recursive=-1;
    }
 
    return $this->find('count', 
        array_merge(
            array(
                'conditions'=>$conditions,
                'recursive'=>$recursive),
                $extra
            )
        );
}

Получилось, на мой взгляд вполне неплохо.

Model::paginate

После доработки подсчета я решил посмотреть, что можно сделать с самим запросом на выборку. По сути, find(‘all’, …) он и есть find(‘all’, …) чего там еще с ним сделаешь? Тем не менее, из метода контроллера Controller::paginate(), одноименный метод модели Model::paginate() вызывается с множеством параметров.

function paginate($conditions, $fields, $order, $limit, $page = 1, $recursive = null, $extra = array())

Для простых запросов тут не стоило бы и напрягаться. Зато для сложных запросов, которые CakePHP не может выполнить сам, или выполняет неоптимально, вызов этого метода — золотое дно. Ведь здесь можно манипулировать результирующими данными.

В качестве примера, простая ситуация с цепочкой из трех моделей. Таблица с продуктами, у которых есть много цен (розничная, опт1, и т.д.) в разных валютах (их всего три, так получилось). Для работы только с розничными ценами мы перепривязали с помощью unbindModel() и bindModel() модель с ценами как hasOne с условием Price.price_type=’retail’. Но цепочка из трех моделей осталась:

Обычный Model::find(‘all’…) поступит просто. Выберет все записи Product-JOIN-Price, а потом для каждого элемента массива выберет Currency. С учетом того, что в самом худшем варианте, на странице будут цены в трех валютах, двадцать запросов ‘SELECT Price … ‘ все равно выглядят удручающе. В похожей ситуации я поступил так:

Получилось всего три запроса, включая запрос на подсчет!

Возможность оптимизации менее сложных запросов при вызове Controller::paginate() тоже есть.

Когда я сделал свой метод Model::paginateCount() для общей и админской части, обратил внимание, что возможен вариант, когда в админской части заданы такие условия выборки, под которые не попадает ни одной записи. Например, в указанном разделе автор не опубликовал ни одной записи. В этом случае возвращается, как нетрудно догадаться, пустой массив. Поскольку меня уже было не остановить :-) , я задался вопросом — а если нет ни одной записи, и paginateCount() это уже определил, то зачем вообще выполнять запрос к БД на выборку? Можно же просто вернуть пустой массив! Так и поступил.

В модель добавил переменную, в которую Model::paginateCount() записывал полученное количество записей.

private $_lastPaginationCountResult = 0;
 
function paginateCount($conditions, $recursive, $extra)
{
    if (@$extra['count_recursive']) {
        unset($extra['count_recursive']);
    } else {
        $recursive=-1;
    }
 
    $this->_lastPaginationCountResult = $this->find('count', 
        array_merge(
            array(
                'conditions'=>$conditions,
                'recursive'=>$recursive),
                $extra
            )
        );
 
    return $this->_lastPaginationCountResult;
}

И написал маленький метод Model::paginate(). Мною собственноручно написана только одна строка, проверка переменной с результатами paginateCount(), которая была вызвана до того. Весь остальной код просто скопировал из соответствующего метода Controller::paginate().

function paginate($conditions, $fields, $order, $limit, $page = 1, $recursive = null, $extra = array()) {
 
    if ($this->_lastPaginationCountResult === 0) return array();
 
    $parameters = compact('conditions', 'fields', 'order', 'limit', 'page');
 
    if ($recursive != $this->recursive) {
        $parameters['recursive'] = $recursive;
    }
 
    if (array_key_exists('type', $extra)) {
        $type = $extra['type'];
        unset($extra['type']);
    }
    else $type='all';
 
    return $this->find($type, array_merge($parameters, $extra));
}

Кстати, обратите внимание, на параметр type, это не я придумал, это в оригинале так было. Он позволяет вместо find(‘all’,…) использовать для выборки любой другой тип запроса. Даже самостоятельно определенный, например, find(‘recent’,…), если, конечно, поддержку этого метода кто-нибудь заранее напишет ;-)

Related Posts with Thumbnails
blog comments powered by Disqus