Что такое ленивая загрузка (Lazy Loading) в Doctrine ORM?

Ответ

Ленивая загрузка (Lazy Loading) в Doctrine — это стратегия загрузки связанных сущностей, при которой они не извлекаются из базы данных сразу при запросе основной сущности, а только в момент первого обращения к этому отношению. Это реализуется с помощью прокси-объектов.

Как это работает:

  1. При загрузке сущности User Doctrine создаёт для свойства $orders (коллекция) специальный прокси-объект, а не выполняет SQL-запрос.
  2. При первом вызове $user->getOrders()->count() или итерации по коллекции прокси «просыпается» и выполняет запрос к БД для загрузки реальных данных.

Пример с атрибутами в Symfony/Doctrine:

// src/Entity/User.php
#[ORMEntity]
class User
{
    #[ORMId, ORMGeneratedValue, ORMColumn]
    private ?int $id = null;

    #[ORMOneToMany(targetEntity: Order::class, mappedBy: 'user')]
    // fetch: 'LAZY' установлен по умолчанию для OneToMany и ManyToMany
    private Collection $orders;

    public function __construct()
    {
        $this->orders = new ArrayCollection();
    }

    public function getOrders(): Collection
    {
        return $this->orders;
    }
}

// В коде контроллера или сервиса
$user = $entityManager->find(User::class, 1);
// На этом этапе запроса для заказов НЕТ

foreach ($user->getOrders() as $order) {
    // ЗДЕСЬ выполняется SQL-запрос типа:
    // SELECT * FROM orders WHERE user_id = 1
    echo $order->getProductName();
}

Критически важные нюансы и проблема N+1:

  • Прокси: Doctrine использует сгенерированные классы-прокси, которые наследуют вашу сущность и перехватывают обращения к геттерам.
  • Проблема N+1: Если вы загрузите 100 пользователей в цикле и для каждого обратитесь к его заказам, Doctrine выполнит 1 запрос для пользователей + 100 отдельных запросов для заказов. Это убийственно для производительности.
  • Решение — жадная загрузка (Eager Fetching) или DQL JOIN:
    // Использование DQL с JOIN FETCH
    $dql = "SELECT u, o FROM AppEntityUser u JOIN u.orders o WHERE u.id = :id";
    $user = $entityManager->createQuery($dql)->setParameter('id', 1)->getSingleResult();
    // Или через QueryBuilder
    $user = $entityManager->getRepository(User::class)
        ->createQueryBuilder('u')
        ->leftJoin('u.orders', 'o')
        ->addSelect('o')
        ->where('u.id = :id')
        ->setParameter('id', 1)
        ->getQuery()
        ->getSingleResult();

    Теперь и пользователь, и его заказы загружены одним запросом.

Ленивая загрузка — удобная стратегия по умолчанию, но требует осознанного управления запросами в местах, где загружаются коллекции.

Ответ 18+ 🔞

Слушай, а вот про эту ленивую загрузку в Doctrine — это вообще отдельная песня, ёпта. Представь себе: ты заходишь в бар, просишь одного конкретного мужика (сущность User), а бармен тебе его выводит. И ты вроде как видишь, что у него в карманах что-то шевелится — это прокси-объекты, хитрая жопа Doctrine. Но пока ты не полезешь к нему в карман и не спросишь: «А что это у тебя там?» — он тебе свои заказы ($orders) не покажет. Запрос в базу не полетит. Умно, да?

Как эта магия работает, если без соплей:

  1. Находишь ты юзера. Doctrine тебе подсовывает куклу-прокси вместо реальных заказов. Выглядит как коллекция, пахнет как коллекция, но внутри — пустота, блядь.
  2. Ты первый раз тыкаешь в неё пальцем — $user->getOrders()->count(). И тут эта кукла оживает, ёб твою мать! Прокси «просыпается», лезет в базу и тащит оттуда реальные данные. Вот тогда-то ты и получаешь свои заказы.

Вот смотри, как в коде это выглядит, тут всё просто:

// src/Entity/User.php
#[ORMEntity]
class User
{
    #[ORMId, ORMGeneratedValue, ORMColumn]
    private ?int $id = null;

    #[ORMOneToMany(targetEntity: Order::class, mappedBy: 'user')]
    // fetch: 'LAZY' стоит по умолчанию, его можно даже не писать
    private Collection $orders;

    public function __construct()
    {
        $this->orders = new ArrayCollection();
    }

    public function getOrders(): Collection
    {
        return $this->orders;
    }
}

// Допустим, в каком-нибудь сервисе
$user = $entityManager->find(User::class, 1);
// Пока всё чисто, запроса за заказами НЕТ! Тишина.

foreach ($user->getOrders() as $order) {
    // А ВОТ ТУТ, БЛЯДЬ, ВСЁ И НАЧИНАЕТСЯ!
    // Выполняется запрос: SELECT * FROM orders WHERE user_id = 1
    echo $order->getProductName();
}

А теперь главный подводный камень, про который все забывают — проблема N+1: Это пиздец, а не проблема. Допустим, ты выгрузил 100 пользователей. А потом в цикле для каждого захотел посмотреть его заказы. Doctrine, такой довольный, выполнит один запрос за юзерами и потом по ОТДЕЛЬНОМУ ЗАПРОСУ за заказами для КАЖДОГО из них. Итого: 1 + 100 = 101 запрос. Представляешь, какая овердохуища запросов? Приложение просто ляжет и будет тихо блевать.

Что делать? Не паниковать. Решение есть — жадная загрузка или JOIN в DQL: Нужно сразу сказать Doctrine: «Мужик, не тупи, загрузи всё, что нужно, за один раз». Вот так:

// Вариант раз — DQL с JOIN FETCH. Чётко и ясно.
$dql = "SELECT u, o FROM AppEntityUser u JOIN u.orders o WHERE u.id = :id";
$user = $entityManager->createQuery($dql)->setParameter('id', 1)->getSingleResult();

// Вариант два — через QueryBuilder. Тот же принцип.
$user = $entityManager->getRepository(User::class)
    ->createQueryBuilder('u')
    ->leftJoin('u.orders', 'o')
    ->addSelect('o') // Вот это ключевой момент! Говорим: «o» тоже тащи!
    ->where('u.id = :id')
    ->setParameter('id', 1)
    ->getQuery()
    ->getSingleResult();

Теперь и юзер, и все его заказы приехали одним рейсом. Красота.

Короче, ленивая загрузка — штука удобная, но опасная. Как острый нож: в быту помогает, а если размахивать бездумно — можно по пальцам порезаться. Главное — всегда помнить про этот ебучий N+1 и вовремя использовать JOIN'ы.