Как MongoDB помогает MySQL считать
24 ноября 2013 MongoDB 6 комментариев 9545 просмотров
Начиная разрабатывать приложение в качестве хранилища данных вы выбираете MySQL. И круто! MySQL есть и будет хорошей , а главное, стабильной СУБД еще долгие годы. Но вот ваше приложение растет, безобидные SELECT COUNT уже заметно тормозят систему. Но не стоит сразу же переписывать систему на NoSQL, поверьте, будет не лучше. Давайте лучше при помощи NoSQL (в этой статье я буду использовать MongoDB) поможем разгрузить кое-какие части системы. Итак, сказ о том, как помочь MySQL считать.

Исходные данные

Начну с маленького примечания к статье. Данный метод тестировался на highload-БД, с примерным количеством записей в таблицах > 100 тысяч в каждой. Я опять призываю целесообразно использовать оптимизацию, так как для небольших объемов данных это может быть иррационально.

Счетчики

Распространенной функцией большинства приложений является подсчет количества строк по определенным параметрам. Это может быть как количество новых уведомлений пользователя, количество новых новостей и т.д. Не так важно, важнее слово “количество”. В примере будем рассматривать подсчет новых сообщений, отправленных пользователю. В MySQL мы делаем примерно так:
SELECT COUNT(1) FROM `message` WHERE `user` = 'USER_ID' AND `status` = 'UNREAD';

И это работает хорошо, однако при большом объеме данных это не так уж и быстро. Какое решение я предлагаю: сохранять отдельным числом это количество в отдельном месте. И обновлять это число при операциях. В нашем случае, при отправке сообщения пользователю мы должны увеличить это число, когда пользователь прочел - уменьшить. Также это число должно быть легкодоступным. Поэтому для хранения счетчиков я предпочитаю MongoDB. Вы можете использовать Memcached, хоть он и не персистентный, или что-то другое, это ваш выбор. Итак, напишем небольшой класс для счетчиков. Мы учтем тот случай, когда MongoDB может быть недоступен, тогда будем использовать старый добрый SQL вариант.
<?php
class Counters
{
    /**
     * @var Mongo
     */
    private $_connection;

    /**
     * @var MongoDB
     */
    private $_db;

    const DB_NAME         = 'test_db';
    const COLLECTION_NAME = 'counters';

    public function __construct()
    {
        $this->_connection = new Mongo();
        $this->_db         = $this->_connection->{self::DB_NAME};
    }

    /**
     * Увеличивает счетчик на 1 или на заданный промежуток
     * @param int $userId
     * @param int $count
     */
    public function newCountIncrement($userId, $count = 1)
    {
        $collection = $this->_db->{self::COLLECTION_NAME};

        // Если нет записи, то вставляем ее, иначе увеличиваем
        $collection->update(array('_id' => $userId), array('$inc' => array('new_count' => $count)), array(
            'upsert' => true
        ));
    }

    /**
     * Уменьшаем счетчик на 1 или на заданный промежуток
     * @param int $userId
     * @param int $count
     */
    public function newCountDecrement($userId, $count = 1)
    {
        $collection = $this->_db->{self::COLLECTION_NAME};

        // Если нет записи, то вставляем ее, иначе уменьшаем
        $collection->update(array('_id' => $userId), array('$inc' => array('new_count' => -$count)), array(
            'upsert' => true
        ));
    }

    /**
     * Возвращает количество новых сообщений
     * @param int $userId
     * @return int
     */
    public function getNewCount($userId)
    {
        $collection = $this->_db->{self::COLLECTION_NAME};

        $row = $collection->findOne(array('_id' => $userId));

        // Если не найдена запись, делаем обычный SQL запрос. Иначе возвращаем количество
        if (is_null($row)) {
            // Вы знаете о чем я, mysql_query и т.д. :)
            // $count = N
            $this->newCountIncrement($userId, $count);
        } else {
            $count = $row['new_count'];
        }

        return $count;
    }
}

Думаю, тут не было ничего сложного. А сейчас проведем один нехитрый тест. Таблица message имеет ровно 100 тысяч записей, тип таблицы InnoDB, индексы расставлены верно, дамп таблицы занимает 71MB. Количество экспериментов - 10. Среднее время выполнения вышеприведенного запроса - 0.19 секунд. Теперь запустим метод getNewCount. Первый раз этот метод сохранит количество в MongoDB, время будет примерно такое же как у предыдущего запроса. А теперь весь профит статьи: время выполнения повторного запроса - 0.0012 секунд. Прирост почти в 20 раз.
6 комментариев

Как я понял, речь идёт о промежуточном кэшировании в не реляционную БД? Если да, то для этого можно и SQLite использовать. В целом всё зависит от степени динамичности данных. Для приведённого примера это действительно актуально, а вот скажем для рубрицированных данных и количества записей в них проще добавить дополнительное поле в таблицу с категориями. Ведь обновление количества записей производится редко, а данные по категориям считываются так или иначе.

@Константин. Согласен, но я же и писал с упором на то, что не важен инструмент, важен подход. Инструмент всегда вторичен, вы согласны?

@ПЛЮТОВ АЛЕКСАНДР мне сложно судить о том, чего не пробовал. Просто SQLite реляционная БД, а MongoDB, как я понял, основана на json. Впрочем, в рамках её применения это может быть даже оправдано. Я вообще в большей степени оставляю комментарии как дополнение к статье тех моментов, что были опущены :-) wmas это я :-)

Я считаю, что в вашем случае использование MongoDB неоправдано. Тот же (или почти такой) прирост производительности можно получить создав такое же хранилище в MySQL. И это сделать ещё проще, потому, что можно использовать тригер на вставку и обновление записи, который будет обновлять другую таблицу со счётчиком. Это позволит избежать возможной рассинхронизации данных, поскольку всё будет делаться внутри одного MySQL. Скорость выборки из соседней таблицы счётчиков будет сравнима с выборкой из MongoDB и NoSQL решение тут особенно ничего не решает, кроме того факта, что в, собственно, SQL решениях имеются некоторые накладные расходы на разбор SQL, которые, однако, заметны только на очень большом количестве запросов в единицу времени.

Всё верно, @Ярослав. Во-первых, Вы тоже говорите об инструменте. Но даже если спорить об инструментах и начинать холивар, то решение MongoDB будет всегда быстрее MySQL процедур и триггеров. А Memcached еще быстрее MongoDB. Но MongoDB я выбрал из-за относительной стабильности.

Alex Kur

Насчёт стабильности MongoDB шутка удалась. Стабильностью в плане production-server там и не пахнет. Не надо приводить примеры лидеров рынков кто доверил свои данные MongoDB. Это их личная проблема. Серьёзно, стабильность ещё очень далека от минимума, когда можно будет серьёзно рассматривать MongoDB в качестве продакшн решения для бизнес-логики приносящей деньги. Данные, которые можно легко восстановить или потерять не жалко или они никак не скажутся на валютном потоке компании огромное количество, такие можно доверить.

Однако комментаторы тут указывают на сам выбор монго для задачи, и тут не важен тезис "не важен инструмент". Всё же важен. Для хранения счётчиков есть инструменты по проще. Смысл монго в хранении сложных документов как объектов. Большие объёмы, быстрый доступ, обновление на месте, мастабирование... полнотекстовый поиск, map-reduce и aggregation framework в конце концов. Вообще задуман срьёзный инструмент и идея хранить в нём счётчики выглядит очень забавной. Отсюда и комментарии такие. )

Не по теме, но добавлю. Давайте уже избавляться от лидирующего undescore в начале имён приватных полей. Этот анахронизм достался нам в наследство от PHP 4, когда не было другого способа указать, что поле приаватное.