Бытует мнение, что Битрикс прожорлив, и способен поглотить все ресурсы, которые есть на сервере. Такая проблема действительно существует, и компаниям иногда приходится рассматривать покупку другой конфигурации сервера, например, при сильном расширении ассортимента или увеличении количества задач бизнеса, решаемых обращением к БД.
Посмотрим, как можно сэкономить ресурсы сервера, чтобы таких вопросов не возникало.
Зачем это надо
Сначала приведём пример стандартной задачи и покажем, что оперативная память сервера быстро расходуется при использовании метода 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 Мб.
Увеличим выборку до 10 элементов, изменяя переменную $counter
, для регулирования количества выводимых элементов:
Увеличим выборку до 100 элементов:
Ну и увеличим до 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>";
При выборке одного элемента результат тот же что и при первом методе:
При выборке 10 элементов потребление памяти не меняется:
На 100 элементах потребление памяти также не растёт:
Ну и проведём финальный эксперимент. Сделаем выборку из 20 000 элементов. Помним, что при первом варианте такая выборка укладывала сервер.
В результате скрипт выполнялся минуту, но потребление памяти выросло совсем немного:
За счет чего мы получили такой результат? Ответ кроется в природе генераторов. При создании массива весь массив помещается в память целиком, а при использовании генератора при итерировании вы каждый раз получаете только один элемент итерируемого массива. Что и позволяет снизить потребление оперативной памяти.
В результате мы получили легкий понятный код. Не переусложнёный пагинацией обращений к БД. И то, что при первом варианте в принципе было невозможно на текущей конфигурации сервера, теперь выполняется, и мы получаем необходимый нам результат.
Где в Битрикс можно применить данный подход?
Во-первых, это всё, что связано с выгрузкой каталога интернет-магазина: формирование прайслистов, фидов и другое.
Во-вторых, обработка данных пользователей:
чистка от регистраций ботов,
изменение формата телефонов,
добавление или удаление каких-то свойств.
В-третьих, это массовое изменение свойств товаров: цен, характеристик и т.п.
А также другие задачи, для выполнения которых требуется выгрузка большого количества элементов.
P.S. Ну и напоследок скажу, что для обработки массивов и объектов PHP предлагает удобные инструменты библиотеки SPL — набор классов для итерации объектов. При их использовании у вас появляются дополнительные возможности при итерировании массивов и объектов.
Комментарии (25)
juliaaan1
16.11.2023 06:53+1Вы не используете созданный вами генератор, поэтому память была занята только ресурсами, полученными из БД. В первом примере Вы записываете все данные собранных элементов инфоблока в массив.
foreach (getProperties($elements) as $element) { $items[] = $element; }
После добавления данного кода во второй пример можно будет сказать, что примеры идентичны. Предполагаю, что "проблему" с потреблением памяти генератор не решит.
FanatPHP
16.11.2023 06:53-1Здесь "$propertyes" как раз и является заменой массиву $items. И выполняет функцию генератора. Так что в теории он тут работает. Непонятно только, зачем было переименовывать, это как раз и сбивает с толку.
Хотя для полноты картины стоило бы всё-таки перебрать массив и что-нибудь с ним поделать - там могут быть всякие побочные эффекты. Работал я с одной ORM, которая кэшировала внутри себя все объекты, чтобы потом лишний раз не лезь в базу. Даже если я исправно запрашивал по одному.
freezemage0
16.11.2023 06:53-1Вызов функции
getProperties()
не производит итераций, а только создаёт объект класса\Generator
, при этом тело генератора не будет выполнено. Это и есть причина, по которой автор наблюдает "разницу" в памяти. Сам по себе объект\Generator
не является заменой массиву, это разные по концепции вещи.Если итерировать генератор из статьи до конца, сложив результаты его итераций в массив, получится та же картина с памятью как и в классических циклах.
FanatPHP
16.11.2023 06:53Является :)
Смысл генератора в том и состоит, чтобы заменить массив с экономией памяти.
А вот складывать "результаты его итераций в массив" будет как раз феерической глупостью.
Я скорее соглашусь с тем, что сам пример в статье высосан из пальца: вместо того чтобы зачем-то сначала складывать в массив, а потом проходить по этому массиву, никто не мешает обрабатывать данные прямо на месте, внутри цикла while. Во всяком случае, в статье такое ограничение не озвучено. А это стоило бы сделать. Поскольку необходимость работы именно с массивом и является единственной причиной использования генератора (в данном контексте).
ASGAlex
16.11.2023 06:53В мире битрикса типичная ситуация - набить большой и жирный массив, а потом пустить его гулять по компоненту до самого шаблона, пока он не набъёт его нужными данными. Итого, если говорить о памяти, то у нас она занята дважды: данными, занесенными в шаблон, и теми же данными в мега-массиве.
В этом плане инкрементарное внесение данных "в шаблон" решит проблему забития памяти в том плане, что она не будет израсходована дважды на одни и те же данные. Думаю, автору показывать это в рамках данного бенчмарка было бы уже бессмысленно.
Вот другой вопрос, как этот подход натягивать на реалии фреймворка, который многими своими частями до сих пор работает по такой логике, будто у нас на дворе php 5.3, если даже не старше....
SerafimArts
16.11.2023 06:53Небольшое примечание/замечание: Вообще это довольно популярное заблуждение, что
yield
придуман для того, чтобы экономить память. С таким же успехом можно взять обычныйIterator
и в результате можем получить потребление ещё ниже, нежели в случае с генератором + выше производительность.И хоть использование этого оператора, очевидно, удобнее, нежели писать итератор руками (или лучше просто взять готовые реализации коллекций с поддержкой функции
map
), однако он в первую очередь направлен на реализацию кооперативной многозадачности, которая в современно мире уходит в сторону файберов.Поэтому, считаю, тема
сисегенераторов раскрыта не до конца)ASGAlex
16.11.2023 06:53Да постоянно надо следить, что используешь по делу, а что нет... Я как-то видел код, где разработчик вдохновился и напихал везде генераторов типа экономить память, а мог бы не экономить, собрать данные в один запрос, распихать индексы в бд и, в общем, быстрее производить вычисления))) Не модно, не молодёжно, без всяких новых фич языка, зато быстрее...Но мы зачем-то экономим память, которой у сервера и так дохрена.
SerafimArts
16.11.2023 06:53Контекст моей "претензии" был в сторону того, что статья позиционируется как "используем генераторы", но ни слова про то, что основная задача генераторов - это ставить выполнение функции на паузу для переключения контекста исполнения на выполнение других процессов.
А не то, что "инструмент выбран не правильно" ;)
Да, он экономит память, но это лишь побочный эффект того, что в коде не используются массивы в которых хранятся все данные выборки.
P.S. Оригинальная реализация с `while($cursor->getNext()) {}` работает ровно по такому же принципу - не создаёт массив, а каждый раз возвращает новые данные. Вот альтернативный пример как это можно делать, экономя память, вместо генераторов.
podshvitaly
16.11.2023 06:53-1Первое, когда разработчики в 23 году используют классы и методы из прошлого, когда уже лет 5 есть орм инфоблоков, это странно.
Второе, если второй пример складывать в массив память загрузится точно также, как выше правильно отметили.
Тут некоретно сформированно сравнение, ввиду малого проффесионализма судя по всему
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) { // работаем с каждым элементом }
"Где логика, где разум"? (с) Вовочка из анекдота.
podshvitaly
16.11.2023 06:53Когда впервом примере все складывается в массив, а во втором нет, какой смысл измерять память?
Я про то что если данные не нужно обрабатывать дальше как коллекцию, то не нужно в первом примере использовать массив, тогда потребление памяти будет одинаковое.
Повторюсь, само тестирование автором неверно посталено.
FanatPHP
16.11.2023 06:53Я про то что если данные не нужно обрабатывать дальше как коллекцию
А если нужно?
podshvitaly
16.11.2023 06:53Вы либо неправильно понимаете о чем я говорю, либо просто любите срач.
Если нам нужно обработать данные в одном месте, их нужно обработать в цикле с гетнекстэлемент, нет смысла в использовании доп прослойке из функции с возвращаемым генератором.
Если нужно допустим ниже в коде еще раз использовать данные, то складывать в массив нужно будет, только в этом случае нужно складывать в массив
FanatPHP
16.11.2023 06:53только в этом случае нужно складывать в массив
Нет :)
В этом случае ещё можно сложить в генератор. В этом и состоит смысл этой статьи. Вот только подан он на редкость коряво.Я согласен с вами в том, что тестирование в статье поставлено неверно. Но само по себе использование генератора в данном случае оправдано. И нормальное тестирование - перебором - это бы показало.
Но вот тестировать так, как это предлагаете вы - перемещением данных из генератора в массив - будет ещё большей глупостью.
podshvitaly
16.11.2023 06:53!
FanatPHP
16.11.2023 06:53Каюсь, я сначала не заметил ваше "еще раз". Согласен, при вашем сценарии, для повторного использования, действительно надо добавлять в массив. Но этот сценарий, к сожалению, не имеет отношения к описанному в статье. Что делает вашу критику неуместной.
Если подытожить:
Тут некорректно сформировано сравнение
-- здесь вы правы
если второй пример складывать в массив
А вот это уже глупость. Эта статья именно про то, чтобы не складывать в массив. Вместо которого и используется генератор.
podshvitaly
16.11.2023 06:53подытожу
- если не нужно складывать в массив, то в первом примере не нужно складывать в массив и замерять памятьсоздавать генератор в данном примере глупость, обработать все можно в цикле while ($element = $elements->GetNextElement())
FanatPHP
16.11.2023 06:53создавать генератор в данном примере глупость, обработать все можно в цикле
Верно. Это несомненный недостаток статьи, с которым не поспоришь. Если бы вы в своем исходном комментарии озвучили именно его, то вопросов бы не было, я бы горячо с вами согласился. Но вас вместо этого унесло куда-то не туда. Вы зачем-то взяли с потолка это "если второй пример складывать в массив", что попросту не имеет смысла. Второй пример не надо складывать в массив. Его надо использовать вместо массива.
если не нужно складывать в массив
А это уже ваше допущение. Стоит, все-таки, быть терпимее, и толковать неточности в пользу автора. И не додумывать ситуации, в которых код автора действительно не будет иметь смысла. Почему бы не согласиться с тем, что автору нужно складывать в массив? Ситуация ведь совершенно стандартная. Когда у нас не единая простыня, а многослойное приложение, данные не всегда можно обработать на месте, а их нужно передать куда-то ещё. И в этом случае надо использовать массив. Или генератор - для экономии памяти.
podshvitaly
16.11.2023 06:53в данном случае генератор вы не сможете перепройти как массив, из Ваших сообщений можно подумать, что вы утверждаете, что генератор это массив с экономией памяти)
данные не всегда можно обработать на месте, а их нужно передать куда-то ещё
тогда выборку из базы и ее обработку нужно делать там где и когда она нужна
FanatPHP
16.11.2023 06:53вы не сможете перепройти
Не смогу. Но повторю ещё раз, это "перепройти" вы выдумали у себя из головы. К данной статье эта ситуация не имеет отношения. Давайте вы это запомните, и не будете больше повторять.
тогда выборку из базы и ее обработку нужно делать там где и когда она нужна
Это весьма спорное утверждение. Если данные необходимо вывести в шаблоне, то, следуя вашей логике, их прямо в в коде шаблона и надо выбирать из БД?
Повторюсь, могут быть разные ситуации. Где-то можно сделать выборку там, где она нужна. А где-то нельзя. В этом случае (и при наличии большого объема данных) вполне оправдано будет использовать генератор.
podshvitaly
16.11.2023 06:53это уже другой разговор, если мы выводим в шаблоне результат и при условии что в while ($element = $elements->GetNextElement()) происходит еще обработка данных или использование методов класса в данном цикле то да логично использовать генератор... но как Вы любите говорить, это Вы выдумали и не имеет отношения к этой статье
bquadro Автор
16.11.2023 06:53Всем спасибо за уточнения про неточности в коде и нюансы тестирования. Пример в статье действительно несколько надуманный и приводился в исследовательских целях. Но тем не менее, основан на реальных событиях. Был опыт работы с проектом, где предыдущие разрабы настроили выгрузку по крону таким образом, что сервер падал как раз по упомянутым причинам.
Ну а задача статьи была больше в том, чтобы показать неопытным программистам полезность yield для использования в конкретной ситуации, а не рассмотреть тему генераторов целиком и полностью. Опять же исходя из опыта, не все начинающие разработчики битрикс в курсе возможного решения проблемы с памятью на большой выгрузке.
dude_sam
Простите, никогда не работал с Битрикс и, возможно, тупой вопрос: а что мешает использовать sql при работе с рекордсетами?
Я к чему?
С точки зрения БД, работа с данными по-строчно, мягко говоря, не очень оптимально. ¯\_(ツ)_/¯
FanatPHP
Я так понимаю, что вы имеете в виду построчное обновление. Но в статье речь идет о построчном чтении. Которое как раз является очень оптимальным с точки зрения расходования памяти.
Ну и построчное обновление тоже не так страшно.
А "использовать SQL" при работе с рекордсетами мешает наличие ORM, который предоставляет универсальные методы для работы с данными. И если все приложение построено на использовании этих методов, то "SQL" следует примерять только в крайних случайх, поскольку все эти методы (например извлечение смежных данных и создание экземпляров соответствующих объектов для них) придется писать вручную.