Используем MongoDB вместо memcached: быть или не быть?

    На тему «использование MongoDB вместо memcached» гуглится немало историй успеха. Такое ощущение, что есть широкий класс задач, для которых идея работает неплохо: прежде всего это проекты, где интенсивно используется тэгирование кэша. Но если вы попробуете, то заметите, что в MongoDB не хватает функции удаления из кэша записей, которые читаются реже всего (LRU — Least Recently Used). Как поддерживать размер кэша в разумных рамках? LRU — это, кстати, «конек» memcached; вы можете писать в memcached, не задумываясь о том, что ваш кэш переполнится; но как же быть с MongoDB?

    Раздумывая над этим, я написал на Python небольшую утилиту CacheLRUd (выложена на GitHub). Это демон для поддержки LRU-удаления записей в различных СУБД (в первую очередь, конечно, в MongoDB). Ферма таких демонов (по одному на каждой MongoDB-реплике) следит за размером коллекции, периодически удаляя записи, к которым доступ на чтение производится реже всего. Отслеживание фактов чтения той или иной записи кэша происходит децентрализовано (без единой точки отказа) по протоколу, основанному на UDP (почему так? потому что «наивный» вариант — писать из приложения в мастер-базу MongoDB при каждой операции чтения — плохая идея, особенно если мастер-база окажется в другом датацентре). Читайте подробности чуть ниже.

    Но зачем?

    Зачем может потребоваться заменять memcached на MongoDB? Попробуем разобраться. Понятие «кэш» имеет два различных типа использования.

    1. Кэш применяют, чтобы снизить нагрузку на перестающую справляться базу данных (или другие подсистемы). Например, пусть у нас есть 100 запросов в секунду на чтение некоторого ресурса. Включив кэширование и выставив маленькое время устаревания кэша (например, 1 секунду), мы тем самым снижаем нагрузку на базу в 100 раз: ведь теперь до СУБД доходит только один запрос из ста. И нам почти не нужно опасаться, что пользователь увидит устаревшие данные: ведь время устаревания очень мало.
    2. Есть и другой тип кэша: это кэш более-менее статических кусков страницы (или даже всей страницы целиком), и применяют его, чтобы снизить время формирования страницы (в том числе редко посещаемой). Он отличается от первого тем, что время жизни кэшированных записей велико (часы или даже дни), а значит, во весь рост встает вопрос: как же гарантировать, что кэш содержит актуальные данные, как его чистить? Для этого применяют тэги: каждый кусочек данных в кэше, имеющий отношение к некоторому крупному ресурсу X, помечают теми или иными тэгами. При изменении ресурса X дают команду «очистить тэг X».

    Для первого варианта использования кэша ничего лучше, чем memcached, похоже, не изобретено. А вот для второго memcached буксует, и тут на помощь может прийти идея «MongoDB вместо memcached». Возможно, это как раз ваш случай, если ваш кэш:

    • Относительно невелик (верхний предел — сотни гигабайт).
    • Содержит много «долгоживущих» записей, устаревающих за часы и дни (или вообще никогда не устаревающих).
    • Вы существенно используете тэги и полагаетесь на то, что операция очистки тэга должна работать надежно.
    • Кэш хотелось бы сделать общим и одинаково легко доступным (т.е. реплицируемым) на всех машинах кластера, в том числе в нескольких датацентрах.
    • Вам не хочется беспокоиться, когда одна из машин для кэша на какое-то время перестанет быть доступной.

    MongoDB и ее репликация с автоматическим failover мастера (превращением реплики в мастера при «смерти» последнего) позволяют гарантировать надежность очистки того или иного тэга. В memcached же с этим проблема: серверы memcached независимы друг от друга, и для удаления тэга вам нужно «пойти» на каждый из них с командой очистки. Но что, если в этот момент какой-то из серверов memcached окажется недоступным? Он «потеряет» команду очистки и начнет отдавать старые данные; MongoDB данную проблему решает.

    Ну и, наконец, MongoDB очень быстра в операциях чтения, ведь она использует событийно-ориентированный механизм работы с соединениями и memory mapped files, т.е. чтение производится напрямую из оперативной памяти при достаточном ее количестве, а не с диска. (Многие пишут, что MongoDB настолько же быстра, как memcached, но я не думаю, что это так: просто разница между ними с огромным запасом тонет на фоне сетевых задержек.)

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



    Установка CacheLRUd

    ## Install the service on EACH MongoDB NODE:
    cd /opt
    git clone git@github.com:DmitryKoterov/cachelrud.git
    ln -s /opt/cachelrud/bin/cachelrud.init /etc/init.d/cachelrud

    ## Configure:
    cp /opt/cachelrud/cachelrud.conf /etc/cachelrud.conf # and then edit

    ## For RHEL (RedHat, CentOS):
    chkconfig --add cachelrud
    chkconfig cachelrud on

    ## ...or for Debian/Ubuntu:
    update-rc.d cachelrud defaults

    Как работает демон

    Чудес не бывает, и ваше приложение должно сообщать демону CacheLRUd (ферме демонов), какие записи в кэше оно читает. Приложение, очевидно, не может это делать в синхронном режиме (например, обновляя в мастер-базе MongoDB поле last_read_at в кэш-документе), потому что а) мастер-база может оказаться в другом датацентре относительно текущей веб-морды приложения, б) MongoDB использует протокол TCP, грозящий timeout-ами и «подвисанием» клиента при нестабильности связи, в) негоже выполнять запись при каждом чтении, не работает это в распределенных системах.

    Для решения задачи применяется протокол UDP: приложение посылает UDP-пакеты со списком недавно прочитанных ключей тому или иному демону CacheLRUd. Какому именно — вы можете решить самостоятельно в зависимости от нагрузки:

    • Если нагрузка сравнительно невысока, посылайте UDP-пакеты тому демону CacheLRUd, который «сидит» на текущей мастер-ноде MongoDB (остальные просто будут простаивать и ждать своей очереди). Определить, кто в текущий момент мастер, на стороне приложения очень легко: например, в PHP для этого применяют MongoClient::getConnections.
    • Если же один демон не справляется, то вы можете отправлять UDP-сообщения, например, демонам CacheLRUd в текущем датацентре.

    Подробности описаны в документации.

    Что еще есть полезного

    CacheLRUdWrapper: это простенький класс для общения с CacheLRUd из кода приложения на PHP, оборачивающий стандартный Zend_Cache_Backend (правда, этот класс для Zend Framework 1; если перепишете его для ZF2 или вообще для других языков, буду рад pull-request'ам).

    Zend_Cache_Backend_Mongo: это реализация Zend_Cache_Bachend для MongoDB из соседнего GitHub-репозитория. Оберните объект данного класса в CacheLRUdWrapper, и получите интерфейс для работы с LRU-кэшем в MongoDB в стиле ZF1:

    $collection = $mongoClient->yourDatabase->cacheCollection;
    $collection->w = 0;
    $collection->setReadPreference(MongoClient::RP_NEAREST); // allows reading from the master as well
    $primaryHost = null;
    foreach ($mongoClient->getConnections() as $info) {
        if (in_array($info['connection']['connection_type_desc'], array("STANDALONE", "PRIMARY"))) {
            $primaryHost = $info['server']['host'];
        }
    }
    $backend = new Zend_Cache_Backend_Mongo(array('collection' => $collection));
    if ($primaryHost) {
        // We have a primary (no failover in progress etc.) - use it.
        $backend = new Zend_Cache_Backend_CacheLRUdWrapper(
            $backend,
            $collection->getName(),
            $primaryHost,
            null,
            array($yourLoggerClass, 'yourLoggerFunctionName')
        );
    }
    // You may use $backend below this line.
    

    Поделитесь в комментариях: что вы думаете по поводу всего этого?
    Поделиться публикацией

    Комментарии 27

      +1
      Не хватает сравнения с Couchbase

      1. масштабирование там проще, при этом добавить сервак + ребаланс на порядки проще
      2. есть обычный интерфейс memcache (не надо переписывать приложение) + есть удаление старые записей
      3. можно использовать view\index для таггирования «кеша»
      4. нету «приятной» особенонсти mongodb, когда система синхронизирует память с диском и сервис становится недоступным на какое-то время.

      плюс у монго это конечно свобода запросов из «кеша», в кауче надо добавлять на каждый подход свой индекс, но если это все работает именно как кеш — то набор возможных запросов к кешу весьма ограничен.
        0
        www.couchbase.com/forums/thread/couchbase-bucket-and-lru-least-recently-used
        Couchebase has no LRU implementation. You can approximate it with long expiry times, but that can be problematic if you have a lot of cache which becomes invalid frequently. If you try to store items with 0 expiry they will live forever in a couchbase bucket.

        Правда, эта запись 2-летней давности: что-то поменялось с тех пор? Если ничего не поменялось, то Couchbase можно подключить к CacheLRUd для реализации стратегии очистки LRU в ней.

        Я бы хотел еще отметить, что LRU — это НЕ удаление «старых записей». Это удаление НАИБОЛЕЕ РЕДКО ЧИТАЕМЫХ записей. Т.е. запись может быть очень-очень старой (добавлена неделю назад), но ее регулярно читают, и поэтому она не должна вымываться (LRU именно для этого).
          0
          Да, именно LRU нету насколько я в курсе.
          У меня видимо таких задач не было поэтому я плохо представляю когда такой подход может быть необходим. Не проще ли поставить expire на неделю условно. Если эти данные актуальны — скрипт не найдет их в кеше и загрузит снова из базы. Если не актуальны, то через неделю они сами пропадут.

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

          И как вариант «костылей» для couchbase:

          1. в класс кеширования на get дописываем обновление записи (отмечу, что сохраняется json, те при get можно добавлять\обновлять поле условно last_access_time), само время кеширования можно поставить 0
          2. добавляем view на поле last_access_time
          3. удаляем записи старше Х в любой удобный момент времени, хоть по крону, хоть по какой-то рэндому в момент выполнения скрипта. Операция быстрая, можно делать и «на лету».

            0
            Expire на неделю? Так за неделю накопится столько, что никакого места не хватит! Весь смысл в том, чтобы держать в кэше наиболее часто востребованные данные, вытесняя те, что востребованы редко. И вот вариантов этих данных может быть очень много.

            Наглядная ситуация (хотя, возможно, не очень хорошая) — представьте, что у вас есть очень большой и «разлапистый» сайт, и вы каждую сгенерированную на нем страницу кладете в кэш, ключом которого является URL страницы. И тут пришли черви и начали перебирать URL-ы, добавляя ?a=1, ?a=2 и т.д. к адресам страниц (условно).

            Более реалистичный пример — кэш кусочков страниц, в ключ которого замешаны timestamp-ы некоторых ресурсов. Обновился ресурс, ключ изменился, начали генерироваться и читаться другие кэш-данные, а старые потихоньку устарели и «вымылись». Это довольно удобно, когда в качестве ключа можно взять md5(serialize(что-то)) и не думать о размере кэша.

            «В класс кеширования на get дописываем обновление записи» — ну так нельзя же делать, в посте про это есть. Нельзя на каждую операцию чтения генерировать операцию записи. Вернее, если все это живет на одной машине, то можно, но когда машин много, да еще и в разных датацентрах они стоят… получите неустойчивую архитектуру. Обновлять last_access_time в документах коллекции можно только асинхронно, что, собственно, CacheLRUd и делает.
              0
              Да, в вариенте кеширования всей страницы и бота подставляющего параметры — представил ситуацию переполнения кеша.
              Хотя наверно это стоит решать какой-то фильтрацией параметров либо формируя ключ кеша страницы на основе ответов блоков из которых состоит страница.

              Вариант 2 честно пытался представить но не смог. Если что-то вымывается, то оно вымоется и просто по expire time.

              Но наверно когда-то удобнее определять размер кеша, а не его время, просто я с этим не сталкивался.
                0
                Оно вымоется по expire time, весь вопрос в том, что это может произойти слишком поздно, и место на диске закончится раньше. Самое плохое в этом то, что оно может закончиться непредсказуемо раньше: вчера было все хорошо, а завтра место закончилось за 10 минут. LRU спасает именно от этого.
            0
            В couchabse можно использовать memcached bucket, где есть LRU. А масштабироваться она при этом будет как couchbase. Само собой никакой надежности, т.к. данные не имеют реплик и хранятся исключительно в памяти.
              0
              docs.couchbase.com/couchbase-manual-2.2/
              memcached Buckets
              Replication: No

              Т.е. под вопросом надежность операции очистки кэша по тэгу как минимум, плюс работает только шардинг, не репликация (в некоторых случаях это и неплохо).
                0
                Не вижу ничего страшного в потере закешированных данных.
                  0
                  Ну при чем здесь потеря закэшированных данных. :) Речь о том, что данные могут оказаться неверными. Ресурс изменился, вы очистили связанный с ним тэг, а он не очистился, и все видят старые данные.
                    0
                    Неверными они могут оказаться в случае рассинхронизации кластера. Подобные сообщения пишутся в лог. При наличии такого события бакет можно очистить и избежать неконсистентности базы. Если рассинхронизация случается слишком часто, то надо исследовать что не так.
            0
            Вот старенькая презентация сравнения cassandra, mongodb и couchbase. Couchbase фаворит.
            www.slideshare.net/altoros/using-no-sql-databases-for-interactive-applications
          +2
          В качестве «замены» memcached имеет смысл рассматривать все-таки Redis, Tarantool или Memcachedb.
            0
            Удивлён, что Redis здесь (топик и комментарии) упоминается всего 1 раз. Логичное решение. Для LRU в MongoDB имеет смысл использовать небольшую capped-коллекцию, но и там есть свои подводные камни. Троллейбус в заголовке идеально описывает данный подход.
              0
              Через capped-коллекции LRU сделать вроде как нельзя (если можно, напишите, пожалуйста, как).
                0
                Да, мой пробел, по всей видимости. Продумал ещё раз — никак что-то не получается. Даже если делать апдейт на каждое использование с гарантированным move — всё равно не получится, так как для самой монги данные окажутся там же, а не перемешёнными.
                  0
                  Я использую кэширование в Redis. Через expire управляю временем жизни отдельного ключа, исходя из здравого смысла.
                    0
                    Expire и LRU — разные вещи. Одно через другое не реализуется.
                      0
                      Using Redis as an LRU cache

                      т/е ничего не мешает отдать это на откуп самому редису

                        0
                        Очистку по тэгам Redis поддерживает?
                          0
                          не совсем понятно, что значит очистка по тегам. заводи лист с id ключей для определенного тега и чисти их по запросу.
                            0
                            Можно пример для случая, когда одним тэгом помечен, например, 100000 ключей, и нужно очистить эти ключи?
                              0
                              redis-cli KEYS «prefix:*» | xargs redis-cli DEL
                                0
                                Там же у каждого ключа может быть несколько тэгов навешано. Это не префикс ключа, это именно тэги. На одном тэге — много ключей, на одном ключе — много тэгов.
                                  0
                                  ну в сете по определенному тегу храни id ключей, что то не особо понятно в чем проблема.

                                  sadd tag:search 1 2 .. sadd tag:news 2 10 .. sinter 'tag:news' 'tag:search'
                                    0
                                    Ну да, так можно проэмулировать. Редис вообще хорош для кэширования, есть только несколько особенностей:
                                    а) не настолько автоматизированная failover-функциональность, как в монге (хотя они допилят, наверное),
                                    б) при предложенном способе работы с тэгами нужно как-то отслеживать момент, когда все ключи, помеченные тэгом, «протухли» (чтобы и сам тэг удалить, дабы он не занимал память),
                                    в) хорошо бы очистку делать в фоновом режиме, асинхронно — иначе удаление большого числа ключей, помеченных одним тэгом, затянется.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое