Оптимизация импорта больших объёмов данных: разбиение на части для повышения производительности.
И снова возвращаемся к статьям о Drupal.
В основной статье о создании CSV импорта очень верно заметил Алексей, что если файл импорта большой (очень много строк), то импорт может растянуться на очень долгий срок, хоть и выполнится.
В чем проблема?
Прежде чем приступим делать дальше, надо понять, почему так сильно идет замедление при увеличении файла?
Возьмем за пример файл с 10к строк. Код написан так, что каждая строка создает
операцию для батча, а каждая операция батча хранится отдельной строкой в
табличке queue
, так как Batch API это лишь графическая надстройка
над Queue API. При небольших объемах 100-1000, это вообще не
заметно, пролетает молниеносно, но вот при 10к уже становится заметно замедление
производительности импорта. Соответственно, при импорте 10к строк из csv, он
создает в базе строки 1 к 1, а это 10к строк в табличке. А это не очень хорошо
для производительности, т.е. Drupal на каждый импорт файла дергает таблицу
queue, что само по себе немного снижает производительность при таких
объемах, а это 10к запросов сверху чисто для получения данных, так и размер
таблицы дает о себе знать, особенно если это слабенький сервер. Соответственно,
если файл 100к строк, то и в таблице будет 100к строк, и производительность
будет все ниже и ниже.
Что делать в такой ситуации? Надо раздробить данные на кусочки далее по тексту чанки (chunks). Например, при 10к строк, если разбить данные по 100 штук, то получится уже 100 операций, вместо 10к. Разумеется и строк в таблице queue станет в 100 раз меньше. Если сервер достаточно мощный, то можно раздробить по 500 операций за раз, и в таком случае получится всего 20 операций импорта, соответственно и записей в таблице.
Оптимизируем
Оптимизацию и данный функционал я буду добавлять основываясь на модуле из статьи "Создание собственных типов плагинов" в которой модуль импорта был усовершенствован для импорта в разные типы содержимого. [Можете скачать результат в прикрепленных файлах той статьи (пример 2)]
Для достижения нашей цели, мы будем использовать обычную функцию array_chunk, она позволит нам разбить массив данных на несколько массивов нужной нам величины, и эти массивы и будут нашими операциями.
Первым делом, нам нужно добавить в форму импорта новое поле, в котором юзер сможет выбирать, на какое кол-во чанков разбивать данные.
# Добавляем после элемента enclosure, перед return. Можете добавить куда вам удобно, на самом деле, это не так важно.
$form['additional_settings']['chunk_size'] = [
'#type' => 'number',
'#title' => t('Chunk size'),
'#default_value' => $config->get('chunk_size'),
'#required' => TRUE,
'#min' => 1,
];
Далее нам надо немного переписать метод валидации формы. Раньше там было одно условие, теперь нам там потребуется новое. Мы хоть и ограничили элементу number минимальное значение в единицу, на старых браузерах это может не работать и нам надо лишнийраз подстраховаться и запрещать выбор менее единицы.
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
if ($form_state->getTriggeringElement()['#name'] == 'start_import') {
# Старая проверка теперь тут.
if (!$form_state->getValue('import_plugin')) {
$form_state->setErrorByName('import_plugin', $this->t('You must select content type to import.'));
}
# Новая проверка, запрещаем значения меньше идинцы.
if ($form_state->getValue('chunk_size') < 1) {
$form_state->setErrorByName('chunk_size', $this->t('Chunk size must be greater or equal 1.'));
}
}
}
В субмит хэндлере нам нужно добавить наше значение для сохранения в конфигурацию.
$config->set('skip_first_line', $form_state->getValue('skip_first_line'))
->set('delimiter', $form_state->getValue('delimiter'))
->set('enclosure', $form_state->getValue('enclosure'))
->set('chunk_size', $form_state->getValue('chunk_size')) # Новое значение
->save();
В последнем методе класса startImport нам необходимо передавать новый параметр на дальнеушую обработку импорта.
public function startImport(array &$form, FormStateInterface $form_state) {
$config = $this->config('custom_csv_import.import');
$fid = $config->get('fid');
$skip_first_line = $config->get('skip_first_line');
$delimiter = $config->get('delimiter');
$enclosure = $config->get('enclosure');
$chunk_size = $config->get('chunk_size'); # new
$plugin_id = $form_state->getValue('import_plugin');
# Передаем размер чанка последним параметром.
$import = new CSVBatchImport($plugin_id, $fid, $skip_first_line, $delimiter, $enclosure, $chunk_size);
$import->setBatch();
}
Раз мы уже затронули конфиги, давайте попутно добавим данному конфигу значение по умолчанию при установке.
skip_first_line: 1
delimiter: ';'
enclosure: '"'
# new
chunk_size: 20
С формой импорта всё. Теперь нам необходимо внести корректировки в CSVBatchImport в котором происходит парсиснг файла. Здесь и будет происходить разбиение на чанки.
Первым делом надо добавить новую приватную переменную $chunk_size
, а в
конструкторе принимать новый параметр и записывать его в эту самую переменную.
# Добавляем переменную для хранение размера чанка
# к остальным.
private $chunk_size;
# В конструкторе добавляем новый аргумент $chunk_size.
public function __construct($plugin_id, $fid, $skip_first_line = FALSE, $delimiter = ';', $enclosure = '"', $chunk_size = 20, $batch_name = 'Custom CSV import') {
# …
# И пишем его в переменную.
$this->chunk_size = $chunk_size;
# …
}
Теперь перейдем к методу parseCSV
, в котором находится логика парссинга файла,
где нам и необходимо дробить данные на чанки.
public function parseCSV() {
# Создаем массив для данных csv.
$items = [];
if (($handle = fopen($this->file->getFileUri(), 'r')) !== FALSE) {
if ($this->skip_first_line) {
fgetcsv($handle, 0, $this->delimiter, $this->enclosure);
}
while (($data = fgetcsv($handle, 0, $this->delimiter, $this->enclosure)) !== FALSE) {
# Данные мы теперь не устанавливаем на операцию,
# а сохраняем в массив для дальнейшего дробления.
$items[] = $data;
}
fclose($handle);
}
# После того как распарсили файл в массив, мы разбиваем его на кусочки.
$chunks = array_chunk($items, $this->chunk_size);
# Теперь каждый кусок устанавливаем на выполнение.
foreach ($chunks as $chunk) {
$this->setOperation($chunk);
}
}
В плане функционала всё готово. Но в прошлый раз мы подключили систему плагинов и добавили два для примера. Их тоже необходимо поправить, и если вы пользовались модулем как заготовкой и написали свои плагины, вам также придется внести во всех плагины изменения. Они очень мелкие.
Суть в том, что раньшме мы на операцию отправляли масив данных, например (id, title, body), теперь мы отправляем массив из этих данных (чанки), например ((id, title, body), (id, title, body), …) и соответственно нам надо учитывать что теперь там многомерный массив, а не одномерный.
public function processItem($data, &$context) {
# Прокручиваем $data, каждый элемент которого
# является массивом с данными из CSV.
foreach ($data as $item) {
# В этой строке мы лишь заменяем $data на $item,
# в соответствии с циклом выше.
list($id, $title, $body, $tags) = $item;
# … прежний код. Ничего не меняется.
# Только проверьте чтобы он был в цикле, а не за пределами.
}
}
Данные изменения в плагинах абсолютно идентичны. Мы лишь сделали обертку в foreach из-за того что данные идут в несколько слоев а не напрямую, всё. Во всех плагинах будут аналогичные изменения, ничего больше менять не потребуется.
Замер производительности
Я сделал замер скорости импорта для различных размеров чанков чтобы определить на сколько сильно влияет данные изменения в коде.
Условия следующие: файл CSV на 1000 строк, каждый импорт проводился на одном и том же компе, с одними и теме же условиями и дампом базы [каждый раз после импорта вливался дамп идентичный как у предыдущего импорта], менялась лишь настройка кол-ва чанков.
Результаты следующие:
- Размер чанка 1 (по сути как без этой оптимизации) — 1 мин. 26 с. 17 мс.
- Размер чанка 50 — 47 с. 52 мс. разница от предыдущего 45.30%
- Размер чанка 100 — 46 с. 42 мс. разница от предыдущего 2.15%
Как вы можете заметить. Разница между 1 и 50 просто коллосальная. А вот между 50 и 100 уже незначительная, это связано с тем, что уже пошел упор в настройки сервера и его мощность и прочие оптимизации. Т.е. нет смысла делать например при импорте в 1000 строк, чанки по 500, никакой погоды не сыграет.
Готовый модуль можете скачать, как всегда, в прикрепленных файлах.
P.s. В Drupal 7 всё работает аналогично.
Ссылки
Комментарии
Походу в 8-ке он тоже умеет. Если, например, ему сделать батч в 500 строк, он, по крайней мере у меня, не успевает обработать столько строк за 1 запрос, и просто продолжает дальше. Или это какая-то иная особенность?
Я про другое - операция создаётся всего одна, а прогресс высчитывается самостоятельно в $context['finished']
Хм. Интересно, попробую, спасибо. А как он тогда прогресс бар отображает? Висит пока эту операцию не доделает как бри больших чанках?
что в 'finished' укажешь, то и отобразит
Парметр "Enclosure" нигде не используется. Нужно добавить ее в функцию fgetcsv или удалить совсем.
Что необходимо сделать, чтобы удалить ноды? Как я это вижу: есть файл csv с одним столбцом, в котором указаны только nid. При "импорте" будут удалены те ноды, nid которых был указан в этом csv. (При условии, что до этого я уже грузил обыкновенный импорт).
По сути поменяется только логика. Грузим ноду и удаляем.
$node = Node::load(NID);
$node->delete();
парсиснг
В статье не акцентируется, но надо в функции public function setOperation($data), как в примере, или дата обернуть в массив, или сделать это раньше, иначе в функцию обработки прилетит число аргументов по размеру чанка(для тех, кто с пред. статьи по импорту цсв пришел)
public function setOperation($data) {
$this->batch['operations'][] = [
[$this->importPlugin, 'processItem'],
[$data]
];
}
Batch умеет работать в режиме когда общее строк неизвестно или очень большое (по крайне мере в семёрке). Мне кажется лучше использовать эту возможность, чем одномоменто инсертить в базу мегабайты данных.