Symfony Advent Calendar 2020 15日目の記事です!

N+1問題とは

DBからEntityのコレクションを取得する時、都度都度クエリを発行してしまう構造になっていて、意図せずクエリ数が膨大になってしまいパフォーマンスが悪くなる現象を指します。

どのような状況で起こるでしょうか?実際にN+1問題が起こるコードを書いてみます。

クラス構成

N+1問題が起きやすい例として、「1対多のリレーションの先に、更に1対多のリレーションがある」という構造を作ってみます。(個人的にこの構造を指して「親-子-孫3代の形」と例えたりしてます) 今回は以下のようなクラスで作ってみました。

「チーム」に対して1対多で「メンバー」が所属し、また「メンバー」はそれぞれ1対多で「投稿(Post)」を持っている、と仮定します。

Entityのコード例

<?php

namespace App\Entity;

use App\Repository\TeamRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * チーム
 * 
 * @ORM\Entity(repositoryClass=TeamRepository::class)
 */
class Team
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

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

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\Member", mappedBy="team")
     */
    private $members;

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

   /**
     * @return Collection
     */
    public function getMembers(): Collection
    {
        return $this->members;
    }
}

<?php

namespace App\Entity;

use App\Repository\MemberRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * メンバー
 * 
 * @ORM\Entity(repositoryClass=MemberRepository::class)
 */
class Member
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

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

    /**
     * @var Team
     * @ORM\ManyToOne(targetEntity="Team", inversedBy="members", fetch="LAZY")
     */
    private Team $team;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\Post", mappedBy="member")
     */
    private $posts;

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

    /**
     * @return Team
     */
    public function getTeam(): Team
    {
        return $this->team;
    }

    /**
     * @return Collection
     */
    public function getPosts(): Collection
    {
        return $this->posts;
    }
}
<?php

namespace App\Entity;

use App\Repository\PostRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * 投稿
 * 
 * @ORM\Entity(repositoryClass=PostRepository::class)
 */
class Post
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

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

    /**
     * @var Member
     * @ORM\ManyToOne(targetEntity="Member", inversedBy="posts")
     */
    private Member $member;
}

適当に省いてますが、コードはこんな感じ。

今回は実験のために、

  • チーム1つ
  • メンバー100人、全てチーム(ID:1)に所属するものとする
  • メンバー1人につき投稿が100件、計10000件の投稿がある

という状態になるよう、migrationでレコードを突っ込んでしまいます。

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20201215000000 extends AbstractMigration
{
    public function up(Schema $schema) : void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE TABLE member (id INT AUTO_INCREMENT NOT NULL, team_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_70E4FA78296CD8AE (team_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
        $this->addSql('CREATE TABLE post (id INT AUTO_INCREMENT NOT NULL, member_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, INDEX IDX_5A8A6C8D7597D3FE (member_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
        $this->addSql('CREATE TABLE team (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
        $this->addSql('ALTER TABLE member ADD CONSTRAINT FK_70E4FA78296CD8AE FOREIGN KEY (team_id) REFERENCES team (id)');
        $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D7597D3FE FOREIGN KEY (member_id) REFERENCES member (id)');

        $this->addSql('INSERT team (id, name) VALUE (1, "team1")');
        foreach (range(1, 100) as $memberId) {
            $this->addSql("INSERT member (name, team_id) VALUE (\"member$memberId\", 1)");
            foreach (range(1, 100) as $postId) {
                $this->addSql("INSERT post (title, member_id) VALUE (\"{$memberId}_post$postId\", $memberId)");
            }
        }
    }
}

Controller

さて。 ここでTeamControllerを用意し、例えばこんなアクションを作ってみました。

<?php

namespace App\Controller;

use App\Entity\Team;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/team")
 */
class TeamController extends AbstractController
{
    /**
     * @Route("/{id}", name="team_show", methods={"GET"})
     */
    public function show(Team $team): Response
    {
        // 各メンバーの投稿を1つずつ表示する
        $posts = [];
        foreach ($team->getMembers() as $member) {
            $posts[] = $member->getPosts()->first();
        }

        return $this->render('team/show.html.twig', [
            'team' => $team,
            'posts' => $posts,
        ]);
    }
}

/team/{id} でチームの情報を表示し、また所属するメンバーの投稿を1つずつViewに渡すアクションです。 /team/1にアクセスしてみて、profilerで実際に発行されたクエリを見てみます。

 2020-12-14 16 25 52

クエリ数が 102 となっていますね。

詳しく見てみると、

SELECT ... FROM post t0 WHERE t0.member_id = ?

というクエリが、メンバーのID数分(1~100まで)延々と発行されていることがわかります。これは明らかに無駄ですね。

続いてPerformanceタブを見てみます。

doctrineが実行された様子が、さながら赤い点線のようにビヨーンと伸びています。 ここからも大量のクエリが実行された様子がわかります。

今回は手っ取り早くN+1問題の状況を再現するためにControllerにロジックを書いてしまいましたが、もしView側でループを書いていたら…と考えると、いかがでしょうか😱

N+1問題の”問題”たる所以は、「ORMの特性上、知らずにコーディングしているとついつい発生させてしまう」ところにあると個人的には思っています。

そこで、DoctrineのFetch modeを少し利用してみることにします。

Fetch modeを指定してみる

Fetch modeとは。 DBからコレクションを取得する際、関連するエンティティまでSQLレイヤーで取得するかどうか?を決定するものです。

以下の3種類があり、

mode 関連エンティティ デフォルト
Eager まとめて取得しメモリに乗せる  
Lazy 必要になるまで取得しない
Extra Lazy 必要になるまで取得せず、さらに節約できる特定のケース※はSQLで済ませる  

※さらに節約できる特定のケース… Collection#contains($entity) など、コレクション全体を取得せずともSQLレイヤーで解決できる関数呼び出しの場合。詳しくはExtra Lazy Associationsをご覧下さい。

通常は Lazy モードが用いられるようにセットされています。 いちいち関連エンティティを毎回全てメモリに読み込んでいたら勿体ないのでむしろ期待した挙動ではあるのですが、先ほどの例のようにうっかり関連エンティティをループで回すと、ループ回数分のSELECT句を発行するハメになるわけです。

そこで冒頭のコードを Eager モードを使って取得するように変更してみます。指定方法はこちら

/**
 * メンバー
 */
class Member
{
    /**
-     * @ORM\OneToMany(targetEntity="App\Entity\Post", mappedBy="member")
+     * @ORM\OneToMany(targetEntity="App\Entity\Post", mappedBy="member", fetch="EAGER")
     */
    private $posts;
}

関連を定義するアノテーションに fetch を指定してあげればOKです。

先ほどのように /team/1 にアクセスしてみましょう。

おお!クエリ数が 2 に減っています。

SELECT ... FROM member t0 LEFT JOIN post t4 ON t4.member_id = t0.id WHERE t0.team_id = ?

postテーブルもjoinされ、関連エンティティのレコードがDBから一度に取得されていることがわかります。

ここで先ほど同様、Performanceタブを確認してみます。

クエリの実行回数自体はたしかに減っているのですが、全体の実行時間はむしろ伸びてしまっています。 よくよく見ると、先ほどよりピークメモリが増加している(14MiB -> 20MiB)ことがわかります。 1万件あるPostテーブルをJoinして予めDBから取得しメモリに乗せているため、このような様子が観察できたのだと考えられます。 いつでも Eager modeを使えば良いというものではなく、メリットデメリットを理解して、用途に応じて使い分けたいですね。

DQLでFetch modeを指定してみる

ちなみに、リレーションで定義する他に、DQLでFetch modeを一時的に指定して取得する方法があります。試しにこちらを使ってみます。

DQLを弄くりたいので、MemberRepositoryに findByTeamWithPostsEager というメソッドを用意します。

<?php

namespace App\Repository;

use App\Entity\Member;
use App\Entity\Team;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method Member|null find($id, $lockMode = null, $lockVersion = null)
 * @method Member|null findOneBy(array $criteria, array $orderBy = null)
 * @method Member[]    findAll()
 * @method Member[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class MemberRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Member::class);
    }

    /**
     * 各メンバーの投稿も同時に取得したい
     * @param Team $team
     * @return Collection
     */
    public function findByTeamWithPostsEager(Team $team)
    {
        $query = $this->createQueryBuilder('m')
            ->where('m.team = :team')
            ->setParameter('team', $team)
            ->getQuery()
        ;
        // Fetch modeをEAGERに変更する
        $query->setFetchMode("App\Entity\Member", "posts", ClassMetadataInfo::FETCH_EAGER);

        return $query->getResult();
    }
}

TeamControllerでさっきの findByTeamWithPostsEager を呼び出すようにしてみました。

<?php
class TeamController extends AbstractController
{
    /**
     * @Route("/{id}", name="team_show", methods={"GET"})
     * @param Team $team
     * @param MemberRepository $memberRepository
     * @return Response
     */
    public function show(Team $team, MemberRepository $memberRepository): Response
    {
        // 各メンバーの投稿を1つずつ表示する
        $posts = [];
        foreach ($memberRepository->findByTeamWithPostsEager($team) as $member) {
            $posts[] = $member->getPosts()->first();
        }

        return $this->render('team/show.html.twig', [
            'team' => $team,
            'posts' => $posts,
        ]);
    }
}

これで /team/1 にアクセスしてProfilerを見てみると…あれ? Eager でない時の挙動と同じく、100件近くのクエリが実行されています。

 2020-12-15 11 38 57

実はドキュメントにきちんと記載があるのですが、DQLで指定した場合に効果を発揮するのは OneToOne , ManyToOne の場合のみで、JOINではなく WHERE IN (:id) を発行する形になるようです。同じFetch modeに関する機能でも若干挙動の違いがあるようです。 今回は OneToMany なので、ルートエンティティ(今回の例だとメンバー)がロードされてから更にクエリを組み立てる形になり、結果的に Lazy と同じ形になってしまいました。 DQLでFetch modeを指定する際は気をつけたいですね。


以上、N+1問題が起きるコードを対象に、Fetch modeで対策できるか調べてみました。

  • リレーションを Eager にすればN+1問題を防げる場合があるが、常にJoinが用いられるために別のトレードオフが発生する
  • DQLでも Eager にできるが、OneToOne , ManyToOne の場合しか効かない

頭の片隅に置いておいていただければ幸いです。