Drupal 8: Импорт из CSV — оптимизация при больших объемах данных

Оптимизация импорта больших объёмов данных: разбиение на части для повышения производительности.

16.03.2017
10 комментариев
7 мин.

И снова возвращаемся к статьям о 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, она позволит нам разбить массив данных на несколько массивов нужной нам величины, и эти массивы и будут нашими операциями.

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

/src/Form/ImportForm.php
# Добавляем после элемента enclosure, перед return. Можете добавить куда вам удобно, на самом деле, это не так важно.
$form['additional_settings']['chunk_size'] = [
  '#type' => 'number',
  '#title' => t('Chunk size'),
  '#default_value' => $config->get('chunk_size'),
  '#required' => TRUE,
  '#min' => 1,
];

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

/src/Form/ImportForm.php
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.'));
    }
  }
}

В субмит хэндлере нам нужно добавить наше значение для сохранения в конфигурацию.

/src/Form/ImportForm.php
$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 нам необходимо передавать новый параметр на дальнеушую обработку импорта.

/src/Form/ImportForm.php
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();
}

Раз мы уже затронули конфиги, давайте попутно добавим данному конфигу значение по умолчанию при установке.

/config/install/custom_csv_import.import.yml
skip_first_line: 1
delimiter: ';'
enclosure: '"'
# new
chunk_size: 20

С формой импорта всё. Теперь нам необходимо внести корректировки в CSVBatchImport в котором происходит парсиснг файла. Здесь и будет происходить разбиение на чанки.

Первым делом надо добавить новую приватную переменную $chunk_size , а в конструкторе принимать новый параметр и записывать его в эту самую переменную.

/src/CSVBatchImport.php
# Добавляем переменную для хранение размера чанка
# к остальным.
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 , в котором находится логика парссинга файла, где нам и необходимо дробить данные на чанки.

/src/CSVBatchImport.php
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), …) и соответственно нам надо учитывать что теперь там многомерный массив, а не одномерный.

/src/Plugin/CustomCSVImport/Article.php и Page.php
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 всё работает аналогично.

Ссылки

Drupal
Drupal 8
Оптимизация
Производительность

Комментарии

xandeadx   пт, 17/03/2017 - 19:11

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

Niklan   вс, 19/03/2017 - 08:53

Походу в 8-ке он тоже умеет. Если, например, ему сделать батч в 500 строк, он, по крайней мере у меня, не успевает обработать столько строк за 1 запрос, и просто продолжает дальше. Или это какая-то иная особенность?

xandeadx   пт, 24/03/2017 - 00:43

Я про другое - операция создаётся всего одна, а прогресс высчитывается самостоятельно в $context['finished']

Niklan   пт, 24/03/2017 - 09:21

Хм. Интересно, попробую, спасибо. А как он тогда прогресс бар отображает? Висит пока эту операцию не доделает как бри больших чанках?

silverius   сб, 25/03/2017 - 21:23

Парметр "Enclosure" нигде не используется. Нужно добавить ее в функцию fgetcsv или удалить совсем.

Вадим   вт, 26/06/2018 - 08:58

Что необходимо сделать, чтобы удалить ноды? Как я это вижу: есть файл csv с одним столбцом, в котором указаны только nid. При "импорте" будут удалены те ноды, nid которых был указан в этом csv. (При условии, что до этого я уже грузил обыкновенный импорт).

Niklan   вт, 26/06/2018 - 10:46

По сути поменяется только логика. Грузим ноду и удаляем.

$node = Node::load(NID);
$node->delete();
darkdim   сб, 01/12/2018 - 11:48

В статье не акцентируется, но надо в функции public function setOperation($data), как в примере, или дата обернуть в массив, или сделать это раньше, иначе в функцию обработки прилетит число аргументов по размеру чанка(для тех, кто с пред. статьи по импорту цсв пришел)

  public function setOperation($data) {
    $this->batch['operations'][] = [
      [$this->importPlugin, 'processItem'],
      [$data]
    ];
  }