[Symfony][Doctrine] EntityTypeでよく起こるN+1問題の原因と対処方法
まずは具体例
こんな感じのエンティティがあるとしましょう。
-
User
がOneToOne
でProfile
を持っている(Owning Side) -
Profile
はUser
への参照を持っていない(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
のレコード数だけ発行されています。どうやら User
と Profile
が 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.
要約すると
-
OneToOne
のInverse Side
のエンティティへのアクセスを検知したら、Doctrineは自動でそのエンティティを別クエリで取得しようとする - なぜなら、そこにエンティティが存在しない(参照が
null
)かもしれないし、proxyをセットしないといけないかもしれないし、そのproxyのidも分からないので - 現状、この問題は実際にクエリを実行してみないと分からない
といった感じで読めます。
とにかく、「 OneToOne
の Inverse 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
Discussion