Бытует мнение, что Битрикс прожорлив, и способен поглотить все ресурсы, которые есть на сервере. Такая проблема действительно существует, и компаниям иногда приходится рассматривать покупку другой конфигурации сервера, например, при сильном расширении ассортимента или увеличении количества задач бизнеса, решаемых обращением к БД.

Посмотрим, как можно сэкономить ресурсы сервера, чтобы таких вопросов не возникало.

Зачем это надо

Сначала приведём пример стандартной задачи и покажем, что оперативная память сервера быстро расходуется при использовании метода GetList. А затем разберёмся, как избежать проблемы.

Итак, у нас есть интернет-магазин на 50 000 товаров. У каждого товара есть 20 пользовательских свойств. Задача: пробежаться по всем товарам и что-то сделать, изменить какие-то свойства или выгрузить каталог.

В данном примере я показываю код в исследовательских целях. 

Итак, что обычно делает программист Битрикс, когда надо получить элементы каталога:

$elements = CIBlockElement::GetList(
   array(),
   array("IBLOCK_ID" => $iblockId),
   false,
   false,
   array("ID", "IBLOCK_ID", "NAME")
);
while ($element = $elements->GetNextElement()) {
   $el=$element->GetFields();
   $el['props’]=$element->GetProperties();
   $items[]=$resElement;
}

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

Чтобы это выяснить, используем дебаг методы Битрикс и метод PHP memory_get_usage(), который позволяет получить количество используемой оперативной памяти. 

$debuglable='main';
Bitrix\Main\Diag\Debug::startTimeLabel($debuglable);

echo  "<pre>Количество используемой оперативной памяти: ". round(memory_get_usage() / 1024 / 1024, 2) . ' MB'. PHP_EOL."</pre>";

$counter=1;

$elements = CIBlockElement::GetList(
   array(),
   array("IBLOCK_ID" => $iblockId),
   false,
   [‘nPageSize’ =>$counter],
   array("ID", "IBLOCK_ID", "NAME")
);
while ($element = $elements->GetNextElement()) {
   $el=$element->GetFields();
   $el['props’]=$element->GetProperties();
   $items[]=$resElement;
}

echo "<pre>Количество используемой оперативной памяти: ". round(memory_get_usage() / 1024 / 1024, 2) . ' MB'. PHP_EOL."</pre>";

Bitrix\Main\Diag\Debug::endTimeLabel($debuglable);
$lable= Bitrix\Main\Diag\Debug::getTimeLabels();
echo "Выборка из ".$counter." элементов  : <pre>";
echo 'Время выполнения скрипта: '. $lable[$debuglable]['time'];
echo "</pre>";

Получим такой результат (рис.1). Зафиксируем, что размер выборки одного элемента составляет около 1,5 Мб.

Рис. 1. Выборка из 1 элемента.
Рис. 1. Выборка из 1 элемента.

Увеличим выборку до 10 элементов, изменяя переменную $counter, для регулирования количества выводимых элементов:

Рис. 2. Выборка из 10 элементов.
Рис. 2. Выборка из 10 элементов.

Увеличим выборку до 100 элементов:

Рис. 3. Выборка из 100 элементов.
Рис. 3. Выборка из 100 элементов.

Ну и увеличим до 1000 элементов:

Рис. 4. Выборка из 1000 элементов.
Рис. 4. Выборка из 1000 элементов.

Итак, мы видим что при увеличении количества элементов быстро растёт потребление оперативной памяти, что в конечном счете приводит к тому, что объём данных превышает размер оперативной памяти сервера и сервер падает.

Конечно, данную проблему можно решить разбив запрос на несколько, используя параметр nOffset, запустить цикл, получить результат нескольких запросов и решить вопрос. 

Но так мы не решаем проблему, а скорее ее усугубляем. Усложняем код и получаем цикл запросов к БД.

Есть другой путь?

Можно использовать ключевое слово yield в PHP для создания функции-генератора. Какая польза от yield в PHP? 

Возможно, вы уже слышали, но на практике ещё не применяли. Обратимся к справке PHP:

Когда вызывается генератор, он возвращает объект, который можно итерировать. Когда вы итерируете этот объект (например, в цикле foreach), PHP вызывает методы итерации объекта каждый раз, когда вам нужно новое значение, после чего сохраняет состояние генератора и при следующем вызове возвращает следующее значение.

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

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

Как применить yield в нашем случае и что мы получим:

// Функция-генератор для получения свойств элемента инфоблока
function getProperties($elements)
{
   while ($element = $elements->GetNextElement()) {

       $resElement=$element->GetFields();
       $resElement['PROPS']=$element->GetProperties();

       yield $resElement;
   }
}
$iblockId=7;
$counter=1;

echo "<pre>Количество используемой оперативной памяти: ". round(memory_get_usage() / 1024 / 1024, 2) . ' MB'. PHP_EOL."</pre>";

// Получение всех элементов инфоблока
$elements = CIBlockElement::GetList(
   array(),
   array("IBLOCK_ID" => $iblockId),
   false,
   ['nTopCount' => $counter],
   array("ID", "IBLOCK_ID", "NAME")
);
$propertyes=getProperties($elements);

echo "<pre>Количество используемой оперативной памяти: ". round(memory_get_usage() / 1024 / 1024, 2) . ' MB'. PHP_EOL."</pre>";

Bitrix\Main\Diag\Debug::endTimeLabel($debuglable);

$lable= Bitrix\Main\Diag\Debug::getTimeLabels();

echo "Выборка из ".$counter." элементов  : <pre>";
echo 'Время выполнения скрипта: '. $lable[$debuglable]['time'];
echo "</pre>";

При выборке одного элемента результат тот же что и при первом методе:

Рис. 5. Использование генератора: выборка из 1 элемента.
Рис. 5. Использование генератора: выборка из 1 элемента.

При выборке 10 элементов потребление памяти не меняется:

Рис. 6. Использование генератора: выборка из 10 элементов.
Рис. 6. Использование генератора: выборка из 10 элементов.

На 100 элементах потребление памяти также не растёт:

Рис. 7. Использование генератора: выборка из 100 элементов.
Рис. 7. Использование генератора: выборка из 100 элементов.

Ну и проведём финальный эксперимент. Сделаем выборку из 20 000 элементов. Помним, что при первом варианте такая выборка укладывала сервер.

В результате скрипт выполнялся минуту, но потребление памяти выросло совсем немного:

Рис. 8. Использование генератора: выборка из 20 000 элементов.
Рис. 8. Использование генератора: выборка из 20 000 элементов.

За счет чего мы получили такой результат? Ответ кроется в природе генераторов. При создании массива весь массив помещается в память целиком, а при использовании генератора при итерировании вы каждый раз получаете только один элемент итерируемого массива. Что и позволяет снизить потребление оперативной памяти.

В результате мы получили легкий понятный код. Не переусложнёный пагинацией обращений к БД. И то, что при первом варианте в принципе было невозможно на текущей конфигурации сервера, теперь выполняется, и мы получаем необходимый нам результат.

Где в Битрикс можно применить данный подход?

Во-первых, это всё, что связано с выгрузкой каталога интернет-магазина: формирование прайслистов, фидов и другое.

Во-вторых, обработка данных пользователей:

  • чистка от регистраций ботов,

  • изменение формата телефонов,

  • добавление или удаление каких-то свойств.


В-третьих, это массовое изменение свойств товаров: цен, характеристик и т.п. 

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

P.S. Ну и напоследок скажу, что для обработки массивов и объектов PHP предлагает удобные инструменты библиотеки SPL — набор классов для итерации объектов. При их использовании у вас появляются дополнительные возможности при итерировании массивов и объектов.

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


  1. dude_sam
    16.11.2023 06:53

    Простите, никогда не работал с Битрикс и, возможно, тупой вопрос: а что мешает использовать sql при работе с рекордсетами?

    Я к чему?

    С точки зрения БД, работа с данными по-строчно, мягко говоря, не очень оптимально. ¯\_(ツ)_/¯


    1. FanatPHP
      16.11.2023 06:53

      Я так понимаю, что вы имеете в виду построчное обновление. Но в статье речь идет о построчном чтении. Которое как раз является очень оптимальным с точки зрения расходования памяти.

      Ну и построчное обновление тоже не так страшно.

      А "использовать SQL" при работе с рекордсетами мешает наличие ORM, который предоставляет универсальные методы для работы с данными. И если все приложение построено на использовании этих методов, то "SQL" следует примерять только в крайних случайх, поскольку все эти методы (например извлечение смежных данных и создание экземпляров соответствующих объектов для них) придется писать вручную.


  1. juliaaan1
    16.11.2023 06:53
    +1

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

    foreach (getProperties($elements) as $element) {
        $items[] = $element;
    }

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


    1. FanatPHP
      16.11.2023 06:53
      -1

      Здесь "$propertyes" как раз и является заменой массиву $items. И выполняет функцию генератора. Так что в теории он тут работает. Непонятно только, зачем было переименовывать, это как раз и сбивает с толку.

      Хотя для полноты картины стоило бы всё-таки перебрать массив и что-нибудь с ним поделать - там могут быть всякие побочные эффекты. Работал я с одной ORM, которая кэшировала внутри себя все объекты, чтобы потом лишний раз не лезь в базу. Даже если я исправно запрашивал по одному.


      1. freezemage0
        16.11.2023 06:53
        -1

        Вызов функции getProperties() не производит итераций, а только создаёт объект класса \Generator, при этом тело генератора не будет выполнено. Это и есть причина, по которой автор наблюдает "разницу" в памяти. Сам по себе объект \Generator не является заменой массиву, это разные по концепции вещи.

        Если итерировать генератор из статьи до конца, сложив результаты его итераций в массив, получится та же картина с памятью как и в классических циклах.


        1. FanatPHP
          16.11.2023 06:53

          Является :)
          Смысл генератора в том и состоит, чтобы заменить массив с экономией памяти.
          А вот складывать "результаты его итераций в массив" будет как раз феерической глупостью.

          Я скорее соглашусь с тем, что сам пример в статье высосан из пальца: вместо того чтобы зачем-то сначала складывать в массив, а потом проходить по этому массиву, никто не мешает обрабатывать данные прямо на месте, внутри цикла while. Во всяком случае, в статье такое ограничение не озвучено. А это стоило бы сделать. Поскольку необходимость работы именно с массивом и является единственной причиной использования генератора (в данном контексте).


    1. ASGAlex
      16.11.2023 06:53

      В мире битрикса типичная ситуация - набить большой и жирный массив, а потом пустить его гулять по компоненту до самого шаблона, пока он не набъёт его нужными данными. Итого, если говорить о памяти, то у нас она занята дважды: данными, занесенными в шаблон, и теми же данными в мега-массиве.

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

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


  1. SerafimArts
    16.11.2023 06:53

    Небольшое примечание/замечание: Вообще это довольно популярное заблуждение, что yield придуман для того, чтобы экономить память. С таким же успехом можно взять обычный Iterator и в результате можем получить потребление ещё ниже, нежели в случае с генератором + выше производительность.

    И хоть использование этого оператора, очевидно, удобнее, нежели писать итератор руками (или лучше просто взять готовые реализации коллекций с поддержкой функции map), однако он в первую очередь направлен на реализацию кооперативной многозадачности, которая в современно мире уходит в сторону файберов.

    Поэтому, считаю, тема сисе генераторов раскрыта не до конца)


    1. ASGAlex
      16.11.2023 06:53

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


      1. SerafimArts
        16.11.2023 06:53

        Контекст моей "претензии" был в сторону того, что статья позиционируется как "используем генераторы", но ни слова про то, что основная задача генераторов - это ставить выполнение функции на паузу для переключения контекста исполнения на выполнение других процессов.

        А не то, что "инструмент выбран не правильно" ;)

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

        P.S. Оригинальная реализация с `while($cursor->getNext()) {}` работает ровно по такому же принципу - не создаёт массив, а каждый раз возвращает новые данные. Вот альтернативный пример как это можно делать, экономя память, вместо генераторов.


        1. ASGAlex
          16.11.2023 06:53

          А, ну да. Согласен ????


  1. podshvitaly
    16.11.2023 06:53
    -1

    Первое, когда разработчики в 23 году используют классы и методы из прошлого, когда уже лет 5 есть орм инфоблоков, это странно.

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

    Тут некоретно сформированно сравнение, ввиду малого проффесионализма судя по всему


    1. FanatPHP
      16.11.2023 06:53

      Зачем? Зачем "второй пример складывать в массив"? Кто-нибудь может объяснить смысл этого глубокомысленного действия? :)

      Я правильно понимаю, что все советчики предлагают примерно такой код:

      $properties = getProperties($elements); // получили генератор
      $items = [];
      foreach ($properties as $item) {
          $items[] = $item; // "складывать в массив"
      }
      ...
      foreach ($items as $item) {
          // работаем с каждым элементом
      }

      вместо того, чтобы просто

      $properties=getProperties($elements); // получили генератор
      ...
      foreach ($properties as $item) {
          // работаем с каждым элементом
      }

      "Где логика, где разум"? (с) Вовочка из анекдота.


      1. podshvitaly
        16.11.2023 06:53

        Когда впервом примере все складывается в массив, а во втором нет, какой смысл измерять память?

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

        Повторюсь, само тестирование автором неверно посталено.


        1. FanatPHP
          16.11.2023 06:53

          Я про то что если данные не нужно обрабатывать дальше как коллекцию

          А если нужно?


          1. podshvitaly
            16.11.2023 06:53

            Вы либо неправильно понимаете о чем я говорю, либо просто любите срач.

            Если нам нужно обработать данные в одном месте, их нужно обработать в цикле с гетнекстэлемент, нет смысла в использовании доп прослойке из функции с возвращаемым генератором.

            Если нужно допустим ниже в коде еще раз использовать данные, то складывать в массив нужно будет, только в этом случае нужно складывать в массив


            1. FanatPHP
              16.11.2023 06:53

               только в этом случае нужно складывать в массив

              Нет :)
              В этом случае ещё можно сложить в генератор. В этом и состоит смысл этой статьи. Вот только подан он на редкость коряво.

              Я согласен с вами в том, что тестирование в статье поставлено неверно. Но само по себе использование генератора в данном случае оправдано. И нормальное тестирование - перебором - это бы показало.

              Но вот тестировать так, как это предлагаете вы - перемещением данных из генератора в массив - будет ещё большей глупостью.


              1. podshvitaly
                16.11.2023 06:53

                !


                1. FanatPHP
                  16.11.2023 06:53

                  Каюсь, я сначала не заметил ваше "еще раз". Согласен, при вашем сценарии, для повторного использования, действительно надо добавлять в массив. Но этот сценарий, к сожалению, не имеет отношения к описанному в статье. Что делает вашу критику неуместной.

                  Если подытожить:

                  Тут некорректно сформировано сравнение

                  -- здесь вы правы

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

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


                  1. podshvitaly
                    16.11.2023 06:53

                    подытожу
                    - если не нужно складывать в массив, то в первом примере не нужно складывать в массив и замерять память

                    создавать генератор в данном примере глупость, обработать все можно в цикле while ($element = $elements->GetNextElement())


                    1. FanatPHP
                      16.11.2023 06:53

                      создавать генератор в данном примере глупость, обработать все можно в цикле

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

                       если не нужно складывать в массив

                      А это уже ваше допущение. Стоит, все-таки, быть терпимее, и толковать неточности в пользу автора. И не додумывать ситуации, в которых код автора действительно не будет иметь смысла. Почему бы не согласиться с тем, что автору нужно складывать в массив? Ситуация ведь совершенно стандартная. Когда у нас не единая простыня, а многослойное приложение, данные не всегда можно обработать на месте, а их нужно передать куда-то ещё. И в этом случае надо использовать массив. Или генератор - для экономии памяти.


                      1. podshvitaly
                        16.11.2023 06:53

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

                        данные не всегда можно обработать на месте, а их нужно передать куда-то ещё

                        тогда выборку из базы и ее обработку нужно делать там где и когда она нужна


                      1. FanatPHP
                        16.11.2023 06:53

                        вы не сможете перепройти

                        Не смогу. Но повторю ещё раз, это "перепройти" вы выдумали у себя из головы. К данной статье эта ситуация не имеет отношения. Давайте вы это запомните, и не будете больше повторять.

                        тогда выборку из базы и ее обработку нужно делать там где и когда она нужна

                        Это весьма спорное утверждение. Если данные необходимо вывести в шаблоне, то, следуя вашей логике, их прямо в в коде шаблона и надо выбирать из БД?

                        Повторюсь, могут быть разные ситуации. Где-то можно сделать выборку там, где она нужна. А где-то нельзя. В этом случае (и при наличии большого объема данных) вполне оправдано будет использовать генератор.


                      1. podshvitaly
                        16.11.2023 06:53

                        это уже другой разговор, если мы выводим в шаблоне результат и при условии что в while ($element = $elements->GetNextElement()) происходит еще обработка данных или использование методов класса в данном цикле то да логично использовать генератор... но как Вы любите говорить, это Вы выдумали и не имеет отношения к этой статье


  1. bquadro Автор
    16.11.2023 06:53

    Всем спасибо за уточнения про неточности в коде и нюансы тестирования. Пример в статье  действительно несколько надуманный и приводился в исследовательских целях. Но тем не менее, основан на реальных событиях. Был опыт работы с проектом, где предыдущие разрабы настроили выгрузку по крону таким образом, что сервер падал как раз по упомянутым причинам.

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