こんにちは。エンジニアの鈴木(@yamotuki)です。
今回はRepositoryパターンの設計方針を維持しながらSQLのN+1問題を起こさないようにする方法論について書いていきます。
前提
レイヤードアーキテクチャについて
弊社では、DDDの考え方を取り入れたレイヤードアーキテクチャが使われています。
GET処理に関わるコードですと、具体的には以下のようなレイヤに別れています。特に今回のお話に関わる部分は太文字にしておきます。
- Controlller
- Scenario Service
- Query Service
- Domain Service
- Domain 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 を使う
弊社ではどれも使っています。ただし、メリットデメリットあるため、必ずしも全てのケースでぴったりの解決策とはなりませんでした。
- キャッシュをする
- 一覧にページネーションを追加する
- メリット
- 一回で取得するユーザ数が少なければ N+1問題も大した問題となりません
- 件数が多いとブラウザ側でDOM表示に時間がかかるのでページネーションは入れることで、ページは軽くなる
- デメリット
- 用途によりますが、管理ツールで一度にたくさん見たいとなると1ページあたりの件数を強く制限するのは社内UXが下がる。弊社の管理ツールのケースだと、件数はそこまで小さくしていないところがあります
- メリット
- CQRS を使う
第4の選択肢としてのHashMapAttachment法
HashMapAttachment法は広く知られている単語ではなくて、私が社内で啓蒙のために名前が欲しかったので適当につけているものです。ググっても他の記事は出ません(実は正式名称ある、などあれば連絡ください)。
Repository から取得したものを Query Service で DTO に変換する時にN+1問題とO(N2)に陥らないようにするテクニック。
この方法の詳細は以下の通りです
- Repository から Entity List(①とする)を取得する
- DTOの中に含めたい Entity List(②とする) を別にリストとして取得する(ループを回してそれぞれ取得するとN+1)。取得したリストを①に含まれるIDをKeyとするHashMapに変換する。
- ①をループで回して、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パターンに沿った実装方針の資産をフル活用することができます。
終わりに
サービス成長に伴い、今までの設計が限界を迎えることがよくあります。弊社では、設計をアップデートし、ユーザに価値を届けられるコードを一緒に作ってくれるエンジニアを募集しています。