Repositoryパターンの設計方針を維持しながらN+1問題を起こさないようにする方法論について

こんにちは。エンジニアの鈴木(@yamotuki)です。
今回はRepositoryパターンの設計方針を維持しながらSQLのN+1問題を起こさないようにする方法論について書いていきます。

前提

レイヤードアーキテクチャについて

弊社では、DDDの考え方を取り入れたレイヤードアーキテクチャが使われています。

GET処理に関わるコードですと、具体的には以下のようなレイヤに別れています。特に今回のお話に関わる部分は太文字にしておきます。

  • Controlller
  • Scenario Service
  • Query Service
    • 画面やコマンド出力に必要な Data Transfer Object (以下DTOと呼ぶ)を返すのが責務
    • Domain Entity (以下Entityと呼ぶ)そのまま返すので事足りるのであれば強いて新しい DTO を作らず Entity をそのまま返すこともある
    • Repository や Domain Service を呼び出す
  • Domain Service
  • Domain Repository
    • InterfaceとしてDomain層に配置される
    • Entity もしくは Entity のリストだけを返しても良い制約がある。DTO はアプリケーション層のものなので DTO は返さない
    • Repository には参照と更新の責務があるが、この記事では参照だけに焦点を当てる
  • Infra Repository
    • 実装としてInfra層に配置される

例とする Entity の切り方

Entity の切り方として、例えば以下のようにしたとします(弊社事例を使って話そうかと思ったのですが、前提知識が多くなって分かりづらくなりそうだったので汎用的な例を用います)

  • User は Entity として存在する
  • User が持っている Watch(腕時計)も User とは独立した Entity として存在する
    • Watch の中に所有者情報として User Id を持っている

例とする DTO の構造

<?php
class UserWithWatch
{
    private User $user;
    private ?Watch $watch;

    public function __construct(User $user, ?Watch $watch)
    {
        $this->user = $user;
        $this->watch = $watch;
    }

    // 略: 必要な getter など
}

これまでの実装方針

Repository が Entity か Entity List しか返せない制約により、DTO として複数の Entity にまたがる情報をまとめる場合には、QueryService において複数の Repository を呼ばなければなりません。

仮に、管理ツールのユーザ一覧で「つけている腕時計の情報も一緒にみたいよ」というニーズがあったとします。愚直にやると以下のような実装になります。この実装手法は、過去に(現在も一部?)弊社で使われていました。

  • User Repository の getUserList() から User List を返す
    • SQL発行1回
  • Query Service において、N件のUserを含む User List をループで回し、その中で Watch Repository の get() を叩く
    • SQL発行N回

この実装の問題点は、get() の内部で1回SQL実行されるため、User 件数が多くなると、Watch Repository の get() 回数もそのまま増えることです。つまり"N+1 問題"を起こしています。サービスリリース当初やlocal開発環境では数十件しかなくてSQL発行数もたかだか100件程度だったものが、仮にユーザが10000人になりそのリストを表示させたいとなると、10000回以上のSQLクエリが発行されることになります。つまりページ表示がとても重くなります。

解決方法案は何があるか?

一般的に用いられる解決策は以下のようなものがあるかと思います(※私の観測範囲のみ)。

  • キャッシュをする
  • 一覧にページネーションを追加する
  • CQRS を使う

弊社ではどれも使っています。ただし、メリットデメリットあるため、必ずしも全てのケースでぴったりの解決策とはなりませんでした。

  • キャッシュをする
    • Watch Repository の get() でキャッシュを差し込み、N件取得を早くする方法
    • プロダクト立ち上げ時点ではこの手法が採用されていました
    • メリット
      • コードを大きく変更しなくて良い。ServiceProvider により差し込む Infra Repository を Cache を使う Repository に差し替えれば良いので導入が簡単
    • デメリット
      • SQLより早いかもしれないが、キャッシュにN回問い合わせされるのは変わらないので、件数が増えるとやっぱり重い
  • 一覧にページネーションを追加する
    • メリット
      • 一回で取得するユーザ数が少なければ N+1問題も大した問題となりません
      • 件数が多いとブラウザ側でDOM表示に時間がかかるのでページネーションは入れることで、ページは軽くなる
    • デメリット
      • 用途によりますが、管理ツールで一度にたくさん見たいとなると1ページあたりの件数を強く制限するのは社内UXが下がる。弊社の管理ツールのケースだと、件数はそこまで小さくしていないところがあります
  • CQRS を使う
    • Repository を通さず、アプリケーション層で DTO を返してくれる Interface を定義し、Infra 層で実装する
    • メリット
      • 部分的に導入できる
      • Infra層実装次第だが、join を使用してSQLを1回にすることができるので高速
    • デメリット
      • 過去作ってきた Repository の実装を使用できず、新たに Infra 層を記述しなければいけず、join してフィールド全て列挙するなどの手間が少なくない。また、infra から Domain や DTO にコンバートするロジックが散らばり、保守性が悪い
      • 今まで、ドメインロジックは Domain Service や Domain オブジェクトに書いてきたが、それらを経由しないことによりロジックの置き場所がない
    • 詳細は以下の記事が詳しいです

第4の選択肢としてのHashMapAttachment法

HashMapAttachment法は広く知られている単語ではなくて、私が社内で啓蒙のために名前が欲しかったので適当につけているものです。ググっても他の記事は出ません(実は正式名称ある、などあれば連絡ください)。
Repository から取得したものを Query Service で DTO に変換する時にN+1問題とO(N2)に陥らないようにするテクニック。

この方法の詳細は以下の通りです

  1. Repository から Entity List(①とする)を取得する
  2. DTOの中に含めたい Entity List(②とする) を別にリストとして取得する(ループを回してそれぞれ取得するとN+1)。取得したリストを①に含まれるIDをKeyとするHashMapに変換する。
  3. ①をループで回して、HashMapのキーにより②から該当のものを探してDTOを作る
    • HashMap の探索のオーダはO(1)
    • 補足: HashMapを用いず、①をループで回して②を全件走査するのだと、N+1問題にはならないが、O(N2)のコードになり、将来性能の問題が起こり得るので避けた方が良い

実装イメージを掴んでもらうため、実装の一部を紹介します。

<?php
class UserQueryService
{
    // プロパティなど省略

    public function getDtoList(): UserWithWatchList
    {
        // ①のリスト取得
        $userList = $this->repository->getList();

        return $this->attachWatch($userList);
    }

    private function attachWatch(UserList $userList): UserWithWatchList
    {
        // ②のリスト取得
        $watchList = $this->watchRepository->getListByUserIds($userList->toUserIdList());

        // ②のリストをHashMapに変換
        $watchHashMap = $watchList->toUserIdHashMap();

        $resultArray = array_map(function (User $user) use ($watchHashMap) {
            // HashMap からの取得で軽量
            $watch = $watchHashMap->get($user->getId());

            return new UserWithWatch(
                $user,
                is_null($watch) ? null : $watch
            );
        }, $userList->toArray());

        return new UserWithWatchList($resultArray);
    }
}

この方法を使用すると、Entity List などにその List に対してのロジックを持たせて使用することができます。
また、今までの Repositoryパターンに沿った実装方針の資産をフル活用することができます。

終わりに

サービス成長に伴い、今までの設計が限界を迎えることがよくあります。弊社では、設計をアップデートし、ユーザに価値を届けられるコードを一緒に作ってくれるエンジニアを募集しています。

www.wantedly.com