🎻

[Symfony][Doctrine] EntityTypeでよく起こるN+1問題の原因と対処方法

2020/06/17に公開

まずは具体例

こんな感じのエンティティがあるとしましょう。

  • UserOneToOneProfile を持っている(Owning Side
  • ProfileUser への参照を持っていない(Inverse Side なし)
  • User__toString()Profile のプロパティを参照している

一例です。例えば Profile から User への参照(Inverse Side)ありでも結果はまったく同じになります。

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\OneToOne(targetEntity="App\Entity\Profile")
     * @ORM\JoinColumn(nullable=false)
     */
    private $profile;

    // ...
    
    public function __toString(): string
    {
        return $this->profile->name;
    }
}
/**
 * @ORM\Entity(repositoryClass="App\Repository\ProfileRepository")
 */
class Profile
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    // ...
}

そして、 User のEntityTypeを持つFormTypeがあるとしましょう。

class FooType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('user', EntityType::class, [
                'class' => User::class,
            ])
        ;
    }
}

このフォームをレンダリングすると、 User のレコード数だけクエリが走ってしまいます。

プロファイルを見てみると、

SELECT
  t0.id AS id_1,
  :
  :
FROM
  profile t0
WHERE
  t0.id = ?

こんなクエリが User のレコード数だけ発行されています。どうやら UserProfile が JOINされておらず、別々に取得されているようです。いわゆる N+1問題 というやつですね。

試しに User__toString()Profile を参照しないように修正してみると、

    public function __toString(): string
    {
-       return $this->profile->name;
+       return $this->id;
    }

この問題は発生しなくなります。

ちなみに、この問題が発生する状況においても、コントローラから UserRepository::findAll() した場合は普通にJOINして1クエリで取得されます。

解決方法

ググると以下の情報などが見つかると思います。

php - Symfony queryBuilder: too many queries - Stack Overflow
https://stackoverflow.com/questions/45739810/symfony-querybuilder-too-many-queries#answer-45740886

Frequently Asked Questions - Doctrine Object Relational Mapper (ORM)
https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/faq.html#why-is-an-extra-sql-query-executed-every-time-i-fetch-an-entity-with-a-one-to-one-relation

下のDoctrineのFAQには以下のように書かれています。

Why is an extra SQL query executed every time I fetch an entity with a one-to-one relation?

If Doctrine detects that you are fetching an inverse side one-to-one association it has to execute an additional query to load this object, because it cannot know if there is no such object (setting null) or if it should set a proxy and which id this proxy has.

To solve this problem currently a query has to be executed to find out this information.

要約すると

  • OneToOneInverse Side のエンティティへのアクセスを検知したら、Doctrineは自動でそのエンティティを別クエリで取得しようとする
  • なぜなら、そこにエンティティが存在しない(参照が null )かもしれないし、proxyをセットしないといけないかもしれないし、そのproxyのidも分からないので
  • 現状、この問題は実際にクエリを実行してみないと分からない

といった感じで読めます。

とにかく、「 OneToOneInverse Side のエンティティにアクセスしようとすると追加のクエリが発行される可能性がある」というのがDoctrineの既知の問題だということは分かりました。

なので解決策としては、Doctrineの自動的な処理にすべてを委ねずに、自分でクエリを指定してあげればいいということになります。

具体的には、今回のケースならEntityTypeの query_builder オプションを使って明示的にJOINさせればよいでしょう。

class FooType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('user', EntityType::class, [
                'class' => User::class,
                'query_builder' => function(UserRepository $repository) {
                    return $repository->createQueryBuilder('u')
                        ->select('u, p')
                        ->leftJoin('u.profile', 'p')
                    ;
                },
            ])
        ;
    }
}

これで、無事無駄なクエリをなくすことができました👌

まとめ

  • Symfonyで、 OneToOne を持ったエンティティに対してEntityTypeを使うと、JOINされず大量のクエリが発行されることがある(N+1問題)
  • これはDoctrineにおいて 既知の問題 である
  • これを解決するには、EntityTypeの query_builder オプションを使って明示的にJOINさせればOK
GitHubで編集を提案

Discussion