Всех приветствую!

Это продолжение серии статей, где мы рассмотрим еще несколько методов, которые помогут улучшить производительность приложения. Мы поговорим о том, как использовать entity manager, unit of work, bulk inserts и batching processing для более эффективной работы с базой данных.

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

В предыдущей статье мы говорили о проблеме n+1, видах пагинации и индексах. Там же Вы можете найти описание приложения, репозиторий проекта и схему данных.

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

Немного о Unit of Work и EntityManager

Когда мы работаем с базой данных через объекты сущностей ORM, важно следить за всеми действиями над этими объектами, обеспечить целостность, параллелизм и синхронизировать объекты с информацией в базе данных. В Doctrine этим занимается EntityManager.

EntityManager (em) - это основной компонент ORM, который отвечает за управление жизненным циклом объектов сущностей и их сохранение в базе данных. EntityManager предоставляет публичный интерфейс к Doctrine и к множеству подсистем. Мы рассмотрим те, с которыми столкнемся далее, а именно: Unit of Work, Identity Map и Transactional write-behind.

Unit of Work (uow) - это шаблон проектирования, который используется в ORM для отслеживания изменений и координации объектов сущностей в рамках единицы действия (логическая транзакция), обеспечивая целостность данных и предотвращая ошибки при сохранении. Когда происходит действие над объектом (получение, создание, удаление, изменение и др.), они помечаются (NEW,MANAGED,DETACHED, REMOVED) и добавляются или актуализируются в Unit of Work. Затем при вызове метода flush() объекты синхронизируются с базой данных.

IdentityMap хранит все сущности связанные с ORM, и гарантирует, что единица информации из базы данных представлена ровно одним экземпляром объекта. Это позволяет избежать конфликтов изменений, так как не допускает ситуации, когда два объекта, представляющих один и тот же элемент данных в хранилище, изменены по-разному.

Transactional write-behind - суть паттерна в том, что изменения, которые произошли с объектами во время выполнения логической транзакции, не сразу записываются в базу данных, а откладываются до завершения логической транзакции (вызова метода flush()).

Кратко рассмотрим частые случаи использования em.

Создание нового объекта

При создании нового объекта сущности, объект не имеет постоянной идентичности и еще не связан с EntityManager и UnitOfWork, состояние объекта будет "New" . После вызова метода persist(), Doctrine помечает этот объект "Managed", добавляет его в список ожидающих вставки (ScheduledEntityInsertions) и в IdentityMap. При вызове метода flush() все объекты из списка ожидающих сохранения будут сохранены в базе данных:

//Создаем новый обьект
$category = new Category();
$category->setTitle('Тестовая категория 1');
// Экземпляр новой сущности не имеет постоянной идентичности и еще не связан с EntityManager и UnitOfWork 
dump('IdentityMap до persist', $em->getUnitOfWork()->getIdentityMap());
// Обьъект попадает в UnitOfWork (IdentityMap и ScheduledEntityInsertions)
$em->persist($category);
dump('IdentityMap после persist', $em->getUnitOfWork()->getIdentityMap());
dump('ScheduledEntityInsertions после persist', $em->getUnitOfWork()->getScheduledEntityInsertions());
// Объект сохраняется в бд, актуализируется в UnitOfWork, удаляется из ScheduledEntityInsertions
$em->flush();
dump('IdentityMap после flush', $em->getUnitOfWork()->getIdentityMap());
dump('ScheduledEntityInsertions после flush', $em->getUnitOfWork()->getScheduledEntityInsertions());
"IdentityMap до persist":
[]
"IdentityMap после persist"
array:1 [▼
  "App\Entity\Category" => array:1 [▶]
]
"ScheduledEntityInsertions после persist"
array:1 [▼
  1325 => App\Entity\Category {#1325 ▶}
]
"IdentityMap после flush"
array:1 [▼
  "App\Entity\Category" => array:1 [▶]
]
"ScheduledEntityInsertions после flush"
[]
"SQL запрос"
"START TRANSACTION"
INSERT INTO category (id, title) VALUES (?, ?)
Parameters:
[▼
  2510
  "Тестовая категория 1"
]
"COMMIT"

Пример ниже это применение Transactional write-behind. Мы накопили изменения двух объектов и записали их в одной транзакции.

// Создаем новые категории 
$cat2 = new Category();
$cat2->setTitle('Тестовая категория 2');
$cat3 = new Category();
$cat3->setTitle('Тестовая категория 3');
// Накапливаем изменения
$em->persist($cat2);
$em->persist($cat3);
// Сохраням все сущности в одной транзакции
$em->flush();
"SQL запрос"
"START TRANSACTION"
INSERT INTO category (id, title) VALUES (?, ?)
Parameters:
[▼
  2512
  "Тестовая категория 2"
]
INSERT INTO category (id, title) VALUES (?, ?)
Parameters:
[▼
  2513
  "Тестовая категория 3"
]
"COMMIT"

Но мы можем записывать изменения в бд для каждого объекта (или группы объектов) в отдельности:

$cat2 = new Category();
$cat2->setTitle('Тестовая категория 2');
$cat3 = new Category();
$cat3->setTitle('Тестовая категория 3');
$em->persist($cat2);
$em->flush();
$em->persist($cat3);
$em->flush();
"START TRANSACTION"
INSERT INTO category (id, title) VALUES (?, ?)
Parameters:
[▼
  2521
  "Тестовая категория 2"
]
"COMMIT"
"START TRANSACTION"
INSERT INTO category (id, title) VALUES (?, ?)
Parameters:
[▼
  2522
  "Тестовая категория 3"
]
"COMMIT"

Получение объектов

Когда запрашивается объект сущности из базы данных, Doctrine создает новый объект и помещает его в IdentityMap, состояние "Managed" получают все сущности, которые получены из бд . Если запрашивается этот же объект сущности еще раз, Doctrine не будет создавать новый объект, а вернет уже существующий объект из IdentityMap.

dump('Размер Unit of Work до запросов', $em->getUnitOfWork()->size());
// Запрашиваем сущность
$category1 = $em->getRepository(Category::class)
->findOneBy(['title'=>'Тестовая категория 2']);
// Повторно запрашиваем сущность
$category2 = $em->getRepository(Category::class)
->findOneBy(['title'=>'Тестовая категория 2']);
dump('IdentityMap после запросов', $em->getUnitOfWork()->getIdentityMap());
dump('Размер Unit of Work после запросов', $em->getUnitOfWork()->size());
dump('$category1 === $category2', $category1 === $category2);
"Размер Unit of Work до запросов"
0
"IdentityMap после запросов"
array:1 [▼
  "App\Entity\Category" => array:1 [▼
    5 => App\Entity\Category {#1286 ▼
      -id: 5
      -title: "Тестовая категория 2"
      -places: Doctrine\ORM\PersistentCollection {#1059 ▶}
    }
  ]
]
"Размер Unit of Work после запросов"
1
"$category1 === $category2"
true
"SQL запрос"
SELECT t0.id AS id_1, t0.title AS title_2 FROM category t0 WHERE t0.title = ? LIMIT 1
Parameters:
[▼
  "Тестовая категория 2"
]
View formatted query    View runnable query    Explain query
2	0.87 ms	
SELECT t0.id AS id_1, t0.title AS title_2 FROM category t0 WHERE t0.title = ? LIMIT 1
Parameters:
[▼
  "Тестовая категория 2"
]

Если объект уже находится в IdentityMap, то при последующем вызове метода find() Doctrine не выполняет запрос к базе данных (однако, если используется запрос с атрибутами, например findBy, будет выполнен запрос к базе данных), а вместо этого производит поиск объекта в IdentityMap.

// Запрашиваем сущность
$category1 = $em->getRepository(Category::class)
->findOneBy(['title'=>'Тестовая категория 2']);
// Повторно запрашиваем сущность методом find
$category2 = $em->getRepository(Category::class)
->find(5);
dump('IdentityMap после запросов', $em->getUnitOfWork()->getIdentityMap());
dump('$category1 === $category2', $category1 === $category2);
"IdentityMap после запросов"
array:1 [▼
  "App\Entity\Category" => array:1 [▼
    5 => App\Entity\Category {#1268 ▼
      -id: 5
      -title: "Тестовая категория 2"
      -places: Doctrine\ORM\PersistentCollection {#1061 ▶}
    }
  ]
]
"$category1 === $category2"
true

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

Обновление

Для обновления сущности необходимо:

  1. Получение объекта сущности, который нужно обновить (объект должен быть в IdentityMap).

  2. Изменение свойств объекта (После изменения, объект обновляется в IdentityMap и попадает в массив запланированных для обновления (ScheduledEntityUpdates)).

  3. Вызов метода EntityManager::flush() для сохранения изменений в базе данных.

$category = $em
            ->getRepository(Category::class)
            ->findOneBy(['title'=>'Тестовая категория']);
dump('IdentityMap до update', $em->getUnitOfWork()->getIdentityMap());
$category->setTitle('1234');
dump('IdentityMap после update', $em->getUnitOfWork()->getIdentityMap());
dump('ScheduledEntityUpdates после update', $em->getUnitOfWork()->getScheduledEntityUpdates());
$em->flush();
"IdentityMap до update"
array:1 [▼
  "App\Entity\Category" => array:1 [▼
    92087 => App\Entity\Category {#1268 ▼
      -id: 92087
      -title: "Тестовая категория"
      -places: Doctrine\ORM\PersistentCollection {#1061 ▶}
    }
  ]
]
"IdentityMap после update"
array:1 [▼
  "App\Entity\Category" => array:1 [▼
    92087 => App\Entity\Category {#1268 ▼
      -id: 92087
      -title: "1234"
      -places: Doctrine\ORM\PersistentCollection {#1061 ▶}
    }
  ]
]
"ScheduledEntityUpdates после update"
array:1 [▼
  1268 => App\Entity\Category {#1268 ▼
    -id: 92087
    -title: "1234"
    -places: Doctrine\ORM\PersistentCollection {#1061 ▶}
  }
]

Удаление

  1. Получение объекта сущности, который нужно удалить (объект должен быть в IdentityMap).

  2. Вызов метода EntityManager::remove() для удаления объекта из базы данных (remove отвязывает объект от IdentityMap, объект попадает массив запланированных для удаления (ScheduledEntityDeletions)).

  3. Вызов метода EntityManager::flush() для сохранения изменений в базе данных.

$someCat = $em
            ->getRepository(Category::class)
            ->findOneBy(['title'=>'Тестовая категория']);
dump('IdentityMap до remove', $em->getUnitOfWork()->getIdentityMap());
$em->remove($someCat);
dump('IdentityMap после remove', $em->getUnitOfWork()->getIdentityMap());
dump('ScheduledEntityDeletions после remove', $em->getUnitOfWork()->getScheduledEntityDeletions());
$someCat->setTitle('123');
dump('IdentityMap после попытки обновить', $em->getUnitOfWork()->getIdentityMap());
dump('ScheduledEntityUpdates после попытки обновить', $em->getUnitOfWork()->getScheduledEntityUpdates());
$em->flush();
"IdentityMap до remove"
array:1 [▼
  "App\Entity\Category" => array:1 [▼
    92087 => App\Entity\Category {#1268 ▶}
  ]
]
"IdentityMap после remove"
array:1 [▼
  "App\Entity\Category" => []
]
"ScheduledEntityDeletions после remove"
array:1 [▼
  1268 => App\Entity\Category {#1268 ▼
    -id: 92087
    -title: "Тестовая категория"
    -places: Doctrine\ORM\PersistentCollection {#1061 ▶}
  }
]
"IdentityMap после попытки обновить"
array:1 [▼
  "App\Entity\Category" => []
]
"ScheduledEntityUpdates после попытки обновить"
[]

Как можно видеть, попытки изменить объект после remove() не отменяют удаление, так как объект отвязан от IdentityMap .

EntityManager->clear()

Во время рабочей сессии, EntityManager хранит все загруженные объекты. Если объектов становится очень много, то может случится переполнение памяти.

EntityManager->clear() используется для очистки EntityManager от всех сущностей, которые были загружены ранее. Это полезно, если необходимо освободить память или начать работу с чистым EntityManager. При этом, все изменения, которые были внесены в объекты, будут потеряны, и объекты будут снова загружены из базы данных при следующем запросе к ним.

// Создаем сущности
dump('Потребление памяти в начале скрипта', memory_get_usage());
for ($i=0; $i < 10000; $i++) { 
    $newCategory = new Category();
    $newCategory->setTitle($i . 'title');
    $em->persist($newCategory);
}
$em->flush();
//$em->clear();
dump('UnitOfWork после создания категорий', $em->getUnitOfWork()->size());
// ... Иная логика
$someCat = new Category();
$someCat->setTitle('Тестовая категория 2');
$em->persist($someCat);
$em->flush();
dump('UnitOfWork в конце работы скрипта', $em->getUnitOfWork()->size());
dump('Потребление памяти в конце скрипта', memory_get_usage());
"Потребление памяти в начале скрипта"
25104248
"UnitOfWork после создания категорий"
10000
"UnitOfWork в конце работы скрипта"
10001
"Потребление памяти в конце скрипта"
99924992

Раскомментируем $em->clear() на 9 строке в коде выше

"Потребление памяти в начале скрипта"
25104248
"UnitOfWork после создания категорий"
0
"UnitOfWork в конце работы скрипта"
1
"Потребление памяти в конце скрипта"
89784832

Потребление памяти снизилось, так как мы больше не храним 10000 объектов в UnitOfWork.

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

Приступим к практике.

Поиск проблем

Начнем с изучения страницы загрузки /upload. На этой странице пользователь может загрузить свой журнал посещений в формате XML через форму на странице.

Загрузим файл (data.xml).

Описание файла data.xml

Файл содержит 1247 мест, 797 пользователей, 20 категорий. Все сущности уникальные.

Пример структуры элемента:

<item key="2">
  <title>Trantow-Schuster</title>
  <category>eaque</category>
  <users>Keshawn Rosenbaum| Hillary Feil| Prof. Zena Kub PhD| </users>
</item>

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

Это метод контроллера (uploadXmlForm), отвечающий за запрос к странице /upload и обработку формы:

    #[Route('/upload', name: 'app_index_uploadForm', methods: ['GET', 'POST'])]
    public function uploadXmlForm(Request $request, IndexImportService $indexImportService): Response
    {    
        $form = $this->createFormBuilder()
        ->add('file', FileType::class)
        ->add('submit', SubmitType::class, ['label' => 'Upload'])
        ->getForm();    
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $file = $form->get('file')->getData();
            // Обработка файла
            $indexImportService->importFromXml($file);

            return $this->render('index/uploadXML.html.twig', [
                'form' => $form->createView(),
                'message' => 'Upload completed'
            ]); 
        }
    
        return $this->render('index/uploadXML.html.twig', [
            'form' => $form->createView(),
            'message' => ''
        ]);    
    
    }

Опять все интересное находится в сервисе IndexImportService, который отвечает за обработку файла.

Это код IndexImportService:

   public function importFromXml(mixed $file):void {
        // Truncate таблиц Category, User, Place и их связей
        $this->clearTableService->clearAllIndexTable();
        // Получение содержимого файла
        $xmlData = simplexml_load_file($file);
        // Преобразование в содержимого в массивы
        $categoriesFromFile = array_unique($xmlData->xpath('//category'));
        $usersFromFile = array_unique($xmlData->xpath('//users'));
        $placesFromFile = $this->fromXmlToArr($xmlData);
        // Обрабатываем информацию, заполняем базу данных
        $this->processCategory($categoriesFromFile);
        $this->processUser($this->getUsersFromFile($usersFromFile));
        $this->processPlace($placesFromFile);
    }

    private function processCategory(array $categories):void {

        foreach($categories as $cat) {
            // Заполняем DTO
            $categoryDTO = new CategoryDTO($cat->__toString());
            // Создаем новый объект из DTO, делаем persist
            $category = new Category();
            $category->setTitle($categoryDTO->title);
            $this->em->persist($category);
            // Синхронизируем изменения с бд
            $this->em->flush(); 
        }

    }

    private function processUser(array $users):void {

        foreach($users as $user) {
            // Заполняем DTO
            $userDTO = new UserDTO($user,true);
            // Создаем новый объект из DTO, делаем persist
            $newUser = new User();
            $newUser->setName($userDTO->name);
            $newUser->setIsActive($userDTO->isActive);
            $this->em->persist($newUser);
            // Синхронизируем изменения с бд
            $this->em->flush(); 
        }

    }

    private function processPlace(array $array):void {
        foreach ($array as $item) {
            // Поиск связанной категории по title
            $categoryDTO = new CategoryDTO($item['category']);
            $category = $this->em
            ->getRepository(Category::class)
            ->findOneBy(['title'=>$categoryDTO->title]);
            // Создаем новый объект из DTO
            $placeDTO = new PlaceDTO($item['title']);
            $place = new Place();
            $place->setTitle($placeDTO->title);
            $place->setCategory($category);
            // Поиск пользователей
            $users = $this->getUsersFromItem($item['users']);
            foreach ($users as $user) { 
                $user = $this->em
                ->getRepository(User::class)
                ->findOneBy(['name'=>$user]);

                $place->addUser($user);
            }
            $this->em->persist($place);
            // Синхронизируем изменения с бд
            $this->em->flush();
        }
    }

Первое, что бросается в глаза, это вызов flush() для каждого объекта, начнем с этого.

Bulk Inserts

Для массовой вставки в базу данных с использованием em, можно использовать метод flush() не для каждого объекта, а для группы объектов.

Перепишем код IndexImportService под массовую вставку и добавим логирование состояния UnitOfWork и потребляемой памяти:

IndexImportService с массовой вставкой
 public function importFromXml(mixed $file):void {
        // Truncate таблиц Category, User, Place и их связей
        $this->clearTableService->clearAllIndexTable();
        // Получение содержимого файла
        $xmlData = simplexml_load_file($file);
        // Преобразование в содержимого в массивы
        $categoriesFromFile = array_unique($xmlData->xpath('//category'));
        $usersFromFile = array_unique($xmlData->xpath('//users'));
        $placesFromFile = $this->fromXmlToArr($xmlData);

        // Обрабатываем информацию, заполняем базу данных
        dump('Потребление памяти до обработки', memory_get_usage());
        $this->processCategory($categoriesFromFile);
        dump('Потребление памяти после обработки категорий', memory_get_usage());
        dump('UnitOfWork после обработки категорий', $this->em->getUnitOfWork()->size());
        $this->processUser($this->getUsersFromFile($usersFromFile));
        dump('Потребление памяти после обработки пользователей', memory_get_usage());
        dump('UnitOfWork после обработки пользователей', $this->em->getUnitOfWork()->size());
        $this->processPlace($placesFromFile);
        dump('UnitOfWork в конце работы скрипта', $this->em->getUnitOfWork()->size());
        dump('Потребление памяти в конце работы скрипта', memory_get_usage());

    }

    private function processCategory(array $categories):void {

        foreach($categories as $cat) {
            // Заполняем DTO
            $categoryDTO = new CategoryDTO($cat->__toString());
            // Создаем новый объект из DTO, делаем persist
            $category = new Category();
            $category->setTitle($categoryDTO->title);
            $this->em->persist($category);
        }
           // Синхронизируем все категории
           $this->em->flush(); 
    }

    private function processUser(array $users):void {

        foreach($users as $user) {
            // Заполняем DTO
            $userDTO = new UserDTO($user,true);
            // Создаем новый объект из DTO, делаем persist
            $newUser = new User();
            $newUser->setName($userDTO->name);
            $newUser->setIsActive($userDTO->isActive);
            $this->em->persist($newUser);
        }
        // Синхронизируем всех пользователей
        $this->em->flush(); 
    }

    private function processPlace(array $array):void {
        foreach ($array as $item) {
            // Поиск связанной категории по title
            $categoryDTO = new CategoryDTO($item['category']);
            $category = $this->em
            ->getRepository(Category::class)
            ->findOneBy(['title'=>$categoryDTO->title]);
            // Создаем новый объект из DTO
            $placeDTO = new PlaceDTO($item['title']);
            $place = new Place();
            $place->setTitle($placeDTO->title);
            $place->setCategory($category);
            // Поиск пользователей
            $users = $this->getUsersFromItem($item['users']);
            foreach ($users as $user) { 
                $user = $this->em
                ->getRepository(User::class)
                ->findOneBy(['name'=>$user]);

                $place->addUser($user);
            }
            $this->em->persist($place);
        }
        // Синхронизируем все места
        $this->em->flush();
    }

Снова загрузим файл и посмотрим результат:

"Потребление памяти до обработки"
31627920
"Потребление памяти после обработки категорий"
33904120
"UnitOfWork после обработки категорий"
20
"Потребление памяти после обработки пользователей"
39620368
"UnitOfWork после обработки пользователей"
817
"UnitOfWork в конце работы скрипта"
2064
"Потребление памяти в конце работы скрипта"
67502952

В результате применения массовой вставки, удалось сократить количество запросов с 13 до 9 тысяч и увеличить скорость выполнения с 20 до 3.4 сек.

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

Batch Processing

В документации приведены отличные примеры применения (обратите внимание на SQLLogger). Если кратко, то процесс схож с пагинацией, мы делим данные на части и выполняем запись в бд (flush()) только для текущей части записей, после чего вызываем метод clear(), чтобы очистить Unit of work, тем самым освободить память.

Перепишем код IndexImportService под пакетную вставку:

IndexImportService с пакетной вставкой
public function importFromXml(mixed $file):void {
        // Truncate таблиц Category, User, Place и их связей
        $this->clearTableService->clearAllIndexTable();
        // Получение содержимого файла
        $xmlData = simplexml_load_file($file);
        // Преобразование в содержимого в массивы
        $categoriesFromFile = array_unique($xmlData->xpath('//category'));
        $usersFromFile = array_unique($xmlData->xpath('//users'));
        $placesFromFile = $this->fromXmlToArr($xmlData);

        // Обрабатываем информацию, заполняем базу данных
        dump('Потребление памяти до обработки', memory_get_usage());
        $this->processCategory($categoriesFromFile);
        dump('Потребление памяти после обработки категорий', memory_get_usage());
        dump('UnitOfWork после обработки категорий', $this->em->getUnitOfWork()->size());
        $this->processUser($this->getUsersFromFile($usersFromFile));
        dump('Потребление памяти после обработки пользователей', memory_get_usage());
        dump('UnitOfWork после обработки пользователей', $this->em->getUnitOfWork()->size());
        $this->processPlace($placesFromFile);
        dump('UnitOfWork в конце работы скрипта', $this->em->getUnitOfWork()->size());
        dump('Потребление памяти в конце работы скрипта', memory_get_usage());

    }

    private function processCategory(array $categories):void {
        // Количество записей, которые будут вставлены за один запрос
        $count     = 1;
        $batchSize = 250;
        foreach($categories as $cat) {
            // Заполняем DTO
            $categoryDTO = new CategoryDTO($cat->__toString());
            // Создаем новый объект из DTO, делаем persist
            $category = new Category();
            $category->setTitle($categoryDTO->title);
            $this->em->persist($category);

            if ($count % $batchSize === 0) {
                $this->em->flush();
                // очистить EntityManager для уменьшения использования памяти
                $this->em->clear();
                $count = 1;
            } else {
                $count++;
            }
        }
           $this->em->flush();
           $this->em->clear();
    }

    private function processUser(array $users):void {
        // Количество записей, которые будут вставлены за один запрос
        $count     = 1;
        $batchSize = 250;
        foreach($users as $user) {
            // Заполняем DTO
            $userDTO = new UserDTO($user,true);
            // Создаем новый объект из DTO, делаем persist
            $newUser = new User();
            $newUser->setName($userDTO->name);
            $newUser->setIsActive($userDTO->isActive);
            $this->em->persist($newUser);
            if ($count % $batchSize === 0) {
                $this->em->flush();
                // очистить EntityManager для уменьшения использования памяти
                $this->em->clear();
                $count = 1;
            } else {
                $count++;
            }
        }
           $this->em->flush();
           $this->em->clear();
    }

    private function processPlace(array $array):void {
        // Количество записей, которые будут вставлены за один запрос
        $count     = 1;
        $batchSize = 250;
        foreach ($array as $item) {
            // Поиск связанной категории по title
            $categoryDTO = new CategoryDTO($item['category']);
            $category = $this->em
            ->getRepository(Category::class)
            ->findOneBy(['title'=>$categoryDTO->title]);
            // Создаем новый объект из DTO
            $placeDTO = new PlaceDTO($item['title']);
            $place = new Place();
            $place->setTitle($placeDTO->title);
            $place->setCategory($category);
            // Поиск пользователей
            $users = $this->getUsersFromItem($item['users']);
            foreach ($users as $user) { 
                $user = $this->em
                ->getRepository(User::class)
                ->findOneBy(['name'=>$user]);

                $place->addUser($user);
            }
            $this->em->persist($place);
            if ($count % $batchSize === 0) {
                $this->em->flush();
                // очистить EntityManager для уменьшения использования памяти
                $this->em->clear();
                $count = 1;
            } else {
                $count++;
            }
        }
           $this->em->flush();
           $this->em->clear();
    }

Результат:

"Потребление памяти до обработки"
31630568
"Потребление памяти после обработки категорий"
33886008
"UnitOfWork после обработки категорий"
0
"Потребление памяти после обработки пользователей"
38852552
"UnitOfWork после обработки пользователей"
0
"UnitOfWork в конце работы скрипта"
0
"Потребление памяти в конце работы скрипта"
64735376

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

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

Используемый в коде simpleXml — создает объектную модель документа и полностью грузит её в память, также создание структурированных представлений из xml в массивы не эффективно сказываются на потребление памяти и не совместимы с высокой нагрузкой.

Комбинация ORM и UnitOfWork не являются оптимальным выбором для выполнения больших объемов операций вставки, обновления или удаления данных. Вместо этого стоит использовать более эффективные инструменты СУБД. Посмотрим на SQL, который генерируется при выполнении метода processCategory():

SQL при выполнении метода processCategory():
doctrine.DEBUG: Beginning transaction [] []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92038,"2":"enim"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92038,"2":"enim"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92039,"2":"delectus"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92039,"2":"delectus"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92040,"2":"eaque"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92040,"2":"eaque"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92041,"2":"est"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92041,"2":"est"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92042,"2":"tempore"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92042,"2":"tempore"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92043,"2":"quia"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92043,"2":"quia"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92044,"2":"quo"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92044,"2":"quo"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92045,"2":"sequi"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92045,"2":"sequi"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92046,"2":"vero"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92046,"2":"vero"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92047,"2":"adipisci"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92047,"2":"adipisci"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92048,"2":"quae"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92048,"2":"quae"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92049,"2":"rerum"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92049,"2":"rerum"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92050,"2":"fuga"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92050,"2":"fuga"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92051,"2":"consequatur"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92051,"2":"consequatur"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92052,"2":"dicta"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92052,"2":"dicta"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92053,"2":"unde"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92053,"2":"unde"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92054,"2":"dolor"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92054,"2":"dolor"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92055,"2":"provident"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92055,"2":"provident"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92056,"2":"possimus"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92056,"2":"possimus"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Executing statement: INSERT INTO category (id, title) VALUES (?, ?) (parameters: array{"1":92057,"2":"quidem"}, types: array{"1":1,"2":2}) {"sql":"INSERT INTO category (id, title) VALUES (?, ?)","params":{"1":92057,"2":"quidem"},"types":{"1":1,"2":2}} []
doctrine.DEBUG: Committing transaction [] []

Для вставки 20 категорий выполняется 20 запросов insert. Используя $entityManager->getConnection()->insert('table', $data) или нативный SQL можно вставить все категории за 1 запрос insert.

Вместо итогов

Мы реализовали Bulk inserts и Batch processing с использованием Doctrine, нам удалось оптимизировать скорость выполнения и потребление памяти для тестового приложения. Но текущее решение пригодно только для небольших файлов. В следующих статьях, переделаем решение под высокую нагрузку и рассмотрим темы: потоковое чтение файла, генераторы и итераторы, задачи и очереди, массовая запись в бд с использованием нативных средств. Если было полезно, ставьте классы. Всем добра!

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