Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Транзакции и скорость Doctrine

Пока записей мало, Doctrine работает незаметно. С ростом базы появляются вопросы: как выполнить несколько изменений как одно целое, как обработать миллион строк и почему страница делает двести запросов.

Транзакции

flush() сам оборачивает свои запросы в транзакцию. Когда вызовов flush() несколько или есть прямые запросы, транзакцию задают явно:

$em->wrapInTransaction(function (EntityManagerInterface $em) use ($from, $to, $amount) {
    $from->withdraw($amount);
    $to->deposit($amount);
 
    $em->persist(new Transfer($from, $to, $amount));
});

Если внутри возникнет исключение, все изменения отменятся.

Блокировка строки

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

use Doctrine\DBAL\LockMode;
 
$em->wrapInTransaction(function (EntityManagerInterface $em) use ($id, $amount) {
    $account = $em->find(Account::class, $id, LockMode::PESSIMISTIC_WRITE);
 
    $account->withdraw($amount);
});

Большие таблицы

findAll() для миллиона записей исчерпает память: Doctrine хранит каждый загруженный объект. Записи перебирают по одной и время от времени очищают память:

$query = $em->createQuery('SELECT u FROM App\Entity\User u');
 
foreach ($query->toIterable() as $index => $user) {
    $user->recalculateRating();
 
    if ($index % 200 === 0) {
        $em->flush();
        $em->clear();
    }
}
 
$em->flush();

clear() заставляет Doctrine «забыть» загруженные объекты.

Массовое изменение одним запросом

Когда объекты не нужны, быстрее изменить строки прямо в базе:

$em->createQuery('UPDATE App\Entity\Post p SET p.published = false WHERE p.createdAt < :date')
    ->setParameter('date', new \DateTimeImmutable('-2 years'))
    ->execute();

Только нужные данные

Для списков и отчётов объекты избыточны. Массивы загружаются быстрее:

$rows = $this->createQueryBuilder('p')
    ->select('p.id, p.title, COUNT(c.id) AS comments')
    ->leftJoin('p.comments', 'c')
    ->groupBy('p.id')
    ->getQuery()
    ->getArrayResult();

Индексы

Столбцы, по которым ищут и сортируют, должны иметь индекс. Его описывают атрибутом над сущностью:

#[ORM\Entity]
#[ORM\Index(name: 'post_created_idx', columns: ['created_at'])]
#[ORM\Index(name: 'post_status_date_idx', columns: ['status', 'created_at'])]
class Post
{
    // …
}

После этого создайте миграцию. Столбцы связей ManyToOne получают индекс автоматически.

Прямой SQL

Сложный отчёт проще написать на SQL:

$rows = $em->getConnection()->executeQuery(
    'SELECT DATE(created_at) AS day, COUNT(*) AS total FROM post WHERE created_at >= :from GROUP BY day',
    ['from' => '2026-01-01'],
)->fetchAllAssociative();

Значения по-прежнему передаются параметрами.

Где искать причину

Вкладка Doctrine в профилировщике показывает все запросы страницы, их время и число одинаковых. Сто одинаковых запросов — признак проблемы N+1: связь нужно загрузить через leftJoin() и addSelect().

Частая ошибка

После $em->clear() все загруженные ранее объекты становятся «отсоединёнными»: Doctrine больше не следит за ними, и их изменения не сохранятся. Если объект — например, текущий пользователь — нужен после очистки, загрузите его заново через find().

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.