image

Однажды столкнулся с задачей: mongoDb использовался как кэш/буфер между backend на Java и frontend на node.js. Все было хорошо, пока не появилось бизнес требование перебрасывать большие объемы за короткое время через mongoDb (до 200 тыс. записей не более чем за пару минут). Для чего не так важно, важна что задача такая появилась. И вот тут уж пришлось разбираться во внутренностях монги…


Раунд 0:
Просто пишем в монгу с Write Concern = Acknowledged. Самый банальный и простой способ в лоб. В этом случае монго гарантирует что все записалось без ошибок и вообще все будет хорошо. Все отлично пишется, но… при 200 тыс. умирает на двадцать и более минут. Не подходит. Путь в лоб вычеркиваем.

image
Раунд 1:
Пробуем Bulk write operations с тем же Write Concern = Acknowledged. Стало лучше, но не сильно. Пишет минут за десять-пятнадцать. Странно, вообще-то ожидалось большее ускорение. Ладно, идем дальше.

image
Раунд 2:
Пробуем поменять Write Concern на Unacknowledged и до кучи использовать Bulk write operations. Вообще, не лучшее решение, так как если что-то в монге пойдет что не так, мы никогда об этом не узнаем, так как она сообщит только что данные до неё дошли, а вот записались ли они в базу или нет неизвестно. С другой стороны, по бизнес требованиям данные это не банковские транзакции, единичная потеря не так критична, а если в монге все будет плохо, мы и так узнаем из мониторинга. Пробуем. С одной стороны записалась за всего минуту это хорошо (без Bulk write operations полторы минуты тоже неплохо), с другой стороны возникла проблема: сразу после записи java дает отмашку node.js и когда она начинает читать, данные приходят то целиком, то вообще не приходят, то половина читается, половина нет. Виной тут асинхронность — при таком Write Concern, монга ещё пишет, а node.js уже читает, соответственно клиент успевает прочитать раньше, чем запись гарантировано закончится. Плохо.


Раунд 3:
Начали думать, идея писать Thread.sleep(60 секунд) или записывать какой-нибудь контрольный объект в монгу, который показывал что все данные загрузились, выглядит очень криво. Решили посмотреть почему Bulk write operations ускоряют так плохо, ведь по идее Write Concern должен замедлять последнею запись при Bulk write operations, а не вообще все. Как-то нелогично получается, что ожидание записи последней порции требует столько времени. Смотрим код драйвера монги на Java, натыкаемся на то пакеты bulk операций ограничены неким параметром maxBatchWriteSize. Debug показывает что этот параметр у нас всего 500, то есть на самом деле весь bulk режется запросами только по 500 записей, поэтому и такие результаты, Acknowledged каждый раз ждет полной записи этих 500 записей, прежде чем послать новый запрос и так четыре тысячи раз при максимальном объеме, а это дико тормозит.


Раунд 4
Пытаемся понять откуда берется этот параметр maxBatchWriteSize, находим что драйвер монги делает запрос getMaxWriteBatchSize() к серверу монги. Возникла мысль увеличить этот параметр в конфиге монги и обойти это ограничение. Попытки найти этот параметр или запрос в спецификации дали нулевой результат. Ладно, ищем в инете, находим исходные коды на C++. Этот параметр — банальная константа, зашитая жестко в коде исходников, то есть увеличить его никак не возможно. Тупик.


Раунд 5
Ищем в инете ещё варианты. Вариант заливать через сотню параллельных потоков решили не пробовать, банально можно за DDos'ить собственный сервер с монгой (тем более что монга и сама умеет параллелить приходящие запросы). И тут нашли такую команду как getLastError, суть его ждать пока все операции сохраняться в базу и вернуть код ошибки или успешного окончания. Спецификация усилено пытается убедить, что метод устарел и его использовать не нужно, в драйвере монги он отмечен как depricated. Но пробуем отправляем запросы с Write Concern = Unacknowledged и Bulk write в ordered режиме, а потом вызываем getLastError() и да, за полторы минуты записали все записи синхронно, теперь клиент начинает чтение именно после полной записи всех объектов, так как getLastError() ждет окончания последней записи, при этом пакеты не тормозят друг друга. Ко всему прочему, если произойдет ошибка, мы об этом узнаем с помощью getLastError(). То есть мы получили именно быстрый Bulk write с Acknowledged, но ожиданием только последнего пакета (ну или почти, обработка ошибок вероятно будет хуже, чем у настоящего режима Acknowledged, вероятно эта команда не покажет ошибку произошедшую только в первых пакетах, с другой стороны вероятность что первый пакет упадет с ошибкой, а последний пройдет успешно — не так велика).

image
Итак о чем молчит спецификация монги:
1. Bulk write операция не очень-то bulk и жестко ограничена потолком 500-1000 запросов в пакете. Update: на самом деле, как я сейчас обнаружил, все-таки упоминание о потолке в 1000 операций появилось, не было упоминания о магической константе больше года назад в версии 2.4, когда и проводился анализ,

2. Увы, но механизм с getLastError был в чем-то более успешным и новый механизм Write Concern пока не полностью его заменяет, точнее можно использовать устаревшую команду для ускорения работы, так логичное поведение «ждать успешную запись только последнего пакет из большого ordered bulk запроса» в монге так и не реализовано,

3. Проблема Write Concern=Unacknowledged даже не в том что данные могут потеряться и ошибка не возвращается, а в том что данные записываются совсем асинхронно и попытка клиента сразу обратиться к данным легко может привести к тому что он не получит данных или получит лишь часть их (важно, если команду на чтение отдавать сразу после записи).

4. У монги производительность запросов сильно страдает от такого ограниченного bulk'а и Acknowledged Write Concern реализован не совсем правильно, правильно ждать окончания записи именно последнего из пакетов.

P.S. В целом, получился интересный опыт оптимизации нестандартными методами, когда в спеках нет всей информации.

Комментарии (14)


  1. outcoldman
    25.06.2015 01:39
    +1

    Пару советов:
    *Убедитесь, что journaling точно нигде не выставлен. Посмотрите в логах mongod (можно выставить verbosity level в 2)
    * Если journaling включен, то вот это может очень сильно тормозить весь процесс docs.mongodb.org/manual/reference/program/mongod/#cmdoption--journalCommitInterval
    * Как вариант — может быть еще и баг в драйвере, что bulk операция не верно реализована. В C Driver, например была такая проблема.
    * Можно сравнить производительность с mongoimport чтобы посмотреть чего стоит ожидать.


    1. vedenin1980 Автор
      25.06.2015 01:45

      Нет, journaling, естественно, был выключен и баг почти наверняка не в драйвере, он вполне стандартно слал запросы, просто в случае Acknowledged он шлет 1 пакет из 500-1000 операций, ждет подтверждение записи, второй пакет и так 2-4 тысячи раз. В случае, Unacknowledged и getLastError он выполняется все запросы ассинхронно просто ставя из в очередь, а потом ждет последний из них, используя getLastError. Попробуйте, сделать у себя эксперимент с замером производительности может быть у вас будут другие результаты и я действительно где-то просто ошибся, ничего исключать нельзя (либо в новой версии монги это уже исправлено).


      1. outcoldman
        25.06.2015 02:06
        +1

        Понятно. 1000 — это стандартное ограничение во всех драйверах, в смысле они его читают теперь из показаний сервера. Меньше может быть если в результате весь bson получается больше BSON MaxSize (который 16 мегабайт на данный момент).

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


      1. outcoldman
        25.06.2015 03:18
        +1

        Можете попробовать вот этот test для c driver gist.github.com/outcoldman/48369bff9347fb61a739
        В connection string можете поменять w=0 на w=1, в общем мои результаты w=0 занимает около 17 секунд, w=1 занимает около 10 секунд. Должно быть, вроде, наоборот, с учетом ваших наблюдений.
        Можете попробовать сами поиграться, скачайте c driver github.com/mongodb/mongo-c-driver, замените ./examples/example-client.c на мой пример, сделайте билд github.com/mongodb/mongo-c-driver#building-from-git (можно просто make, не нужно make install) и попробуйте запустить ./example-client после того как запустите монгу на стандартном порту.


  1. maxp
    25.06.2015 09:02
    +1

    Вообще-то, про размер bulk пакета однозначно написано в документации —

    """
    Each group of operations can have at most 1000 operations. If a group exceeds this limit, MongoDB will divide the group into smaller groups of 1000 or less. For example, if the bulk operations list consists of 2000 insert operations, MongoDB creates 2 groups, each with 1000 operations.
    """

    Не пробовали вместо getLastError просто ставить acknowledge на последний bulk-пакет?
    Насколько я понимаю ваш getLastError именно так и срабатывает.


    1. vedenin1980 Автор
      25.06.2015 10:15

      Вообще-то, про размер bulk пакета однозначно написано в документации

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

      Не пробовали вместо getLastError просто ставить acknowledge на последний bulk-пакет?

      Нет, не пробовал, не уверен что монга не может в таком случае поменять приоритеты обработки пакетов и обработать пакет с acknowledge перед пакетами с unacknowledged. В принципе, её в этом ничто не ограничивает, так время обработки unacknowledged не гарантируется, а вполне логично для ускорения производительности обработать пакет, требующий ответа, раньше, чем unacknowledged пакеты. А при getLastError монга должна гарантировано ждать записи всех пакетов.


      1. maxp
        25.06.2015 10:41
        +1

        «при getLastError монга должна гарантировано ждать записи всех пакетов» — а это где-то написано для случая unacknowledged bulk? Как-то это мало вероятно, особенно если там пакеты кто-то реордерит внутри. Просто юзкейс не очень понятен.

        Вот что они внесли getLastError внутрь вызова — это вполне понятно и обосновано, как наиболее используемая практика.


        1. vedenin1980 Автор
          25.06.2015 11:35

          а это где-то написано для случая unacknowledged bulk?

          Да, в описании getLastError в спеке: «Returns the error status of the preceding write operation on the current connection.In previous versions, clients typically used the getLastError in combination with a write operation to verify that the write succeeded.». Не могу найти, но раньше в спеке был прямо пример такого использования getLastError, там ещё специально указывалось что все запросы должны идти по одному логическому каналу, что гарантировало что getLastError всегда дождется завершения всех записей.

          Просто юзкейс не очень понятен.

          Хорошо, юзкейс:
          1. При acknowledge, происходит ожидание ответа о записи каждого пакета, что очень уменьшает производительность при большом кол-ве пакетов,
          2. При unacknowledged невозможно понять когда можно начинать безопасно читать записанное,
          3. При unacknowledged + getLastError можно понять когда можно начинать безопасно читать записанное и работает относительно быстро,


          1. maxp
            25.06.2015 12:04
            +2

            Из этого описания ничего не следует про неподтвержденную пакетную запись, lastError просто статус последней операции по данному коннекшену.

            Cейчас они просто унесли эту функциональность внутрь вызова write, так как пользователей задолбало каждый раз самим звать getLastError —
            «A new protocol for write operations integrates write concerns with the write operations, eliminating the need for a separate getLastError».


            1. vedenin1980 Автор
              25.06.2015 12:33

              Тем не менее, практика показывает что getLastError именно ждет окончания всех пакетных записей, так как чтобы вернуть статус последней операции, нужно дождаться её окончания. Раньше lastError именно использовался как ручной аналог acknowledge.

              пользователей задолбало каждый раз самим звать getLastError

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


  1. vedenin1980 Автор
    25.06.2015 10:16

    Вообще-то, про размер bulk пакета однозначно написано в документации

    Да, я писал в статье что это появилось позже, мы работали больше года назад и если не ошибаюсь с версией 2.4, где ограничение было 500 операций, в спеке 2.4 тогда оно не упоминалось, поэтому пришлось искать его методом анализа драйверов.

    Не пробовали вместо getLastError просто ставить acknowledge на последний bulk-пакет?

    Нет, не пробовал, не уверен что монга не может в таком случае поменять приоритеты обработки пакетов и обработать пакет с acknowledge перед пакетами с unacknowledged. В принципе, её в этом ничто не ограничивает, так время обработки unacknowledged не гарантируется, а вполне логично для ускорения производительности обработать пакет, требующий ответа, раньше, чем unacknowledged пакеты. А при getLastError монга должна гарантировано ждать записи всех пакетов.


  1. AlexeyShurygin
    25.06.2015 22:40

    Ну не знаю, в Cassandra пишется 200000 записей за 6 минут включая некоторую обработку и с гарантией записи.


    1. vedenin1980 Автор
      25.06.2015 22:42

      Да, но требовалось-то записать 200 тыс. не более чем за 2 минуты, плюс база монги стояла на удаленном сервере, там многое «съедала» передача по сети.


      1. AlexeyShurygin
        25.06.2015 22:43

        ОК.