COLOPL Tech Blog

コロプラのエンジニアブログです

DataMapperを利用した場合におけるN+1問題の解決と処理の分離

こんにちは。サーバーエンジニアの佐藤です。

今回は、DataMapperを利用するプロジェクトが抱えていた「N+1問題の解決と処理の分離」に関連する問題についてお話させていただきます。

対象プロジェクトでは、N+1問題によりパフォーマンスが落ちるのを防ぐため、事前のクエリ用パラメータの集計処理や一括データ取得処理を書いていましたが、凝縮度が落ち、保守が困難になるなどの問題を抱えていました。

今回の記事では、これらの問題を解決するために問題の深堀りを行っていきます。

また、問題の解決にも成功しており、以下の記事にて解決を実現するデータを遅延一括取得する機能について紹介しています。ぜひ本記事のあとにお読みください。
blog.colopl.dev

背景

問題の前提となるモバイルゲーム領域やデザインパターン、N+1問題に関して説明します。

データの種類

モバイルゲームでは様々なデータが用いられます。これらのデータを大雑把に分類するとマスターデータとユーザデータがあります。

マスターデータは「全ユーザ共通となるゲームの構成要素となるデータ」です。具体的にはキャラ、武器、スキル、クエストといったものです。これらのデータは運用中に追加されることはあれど、既存のデータが編集されることは少なく、サーバーにキャッシュすることでデータベースへのアクセスを削減します。*1

ユーザデータは文字通りユーザ固有のデータです。ユーザのランク、所持しているキャラや武器のレベルといったデータがこれに当てはまります。基本的にはユーザは自身のユーザデータのみを更新します。リクエストをライフタイムとするキャッシュを用いてアクセスを削減しています。

DataMapperとActiveRecord

ここからは、1プレイの単位であるクエストを表すテーブルQuestsを用いて説明します。

Questsはクリア条件を示すMissionsテーブルのIDであるmissionIdと、サブミッション(達成すると追加報酬が手に入る)の条件を示すSubMissionsのIDであるsubMissionId1,2,3を持ちます。

Questsテーブル

DataMapperとはデータベースのあるテーブルに対する操作を集めたクラスであり、データベースと対応するテーブルの1レコードを示すモデルと呼ばれるクラスを分離します。対象プロジェクトではこのDataMapperを利用しています。

DataMapperによるデータとDB操作の分離

ActiveRecordはDataMapperに類似するパターンです。

ActiveRecordもテーブルの1レコードに対応するクラスを持ちます。しかし、DataMapperとは違い、同じクラスにデータベースへの操作も持つことになります。PHPフレームワークのLaravelにはActiveRecordのORMであるEloquentがあります。

データとDB操作を持つActiveRecord

両者を比較してみるとDataMapperの方がよりシンプルであることがわかります。ActiveRecordは多くのメソッドを持つことになり複雑化しますが、それによるメリットも多くあります。

N+1問題

N+1問題とは、ループの内部で都度クエリを発行し、パフォーマンスが低下するという問題です。以下の例では、クエストに対応するサブミッションのデータを都度取得することによりパフォーマンスが低下します。*2

<?php
foreach ($quests as $quest) {
    // missionIdに対応するmissionデータを逐次取得
    $mission = $missionMapper->find($quest->getMissionId());
    // $missionを利用した個別処理
}

DataMapperにおいて、これを解決するには「クエリに利用するパラメータの事前集計」、「データの一括取得」、「データから対象のみを抽出」という一連の処理が必要です。

<?php
/** クエストのレスポンスモデル配列生成メソッド */
private function createQuestResponseModels(array $quests): array
{
    // クエリのために必要なidを集計
    $missionIds = [];
    foreach ($quests as $quest) {
        $missionIds[] = $quest->getMissionId();
    }

    // 集計したidを利用して一括取得
    // findMapByIdsはidをキーとする連想配列を返す
    $missionMap = $this->missionMapper->findMapByIds($missionIds);

    // レスポンス生成
    foreach ($quests as $quest) {
        // 対象のmissionIdのMissionデータを抽出
        $mission = $missionMap[$quest->getMissionId()];
        // レスポンスオブジェクト生成
        $questResponses[] = new QuestResponse($quest, $mission);
    }
    return $questResponses;
}

ActiveRecordであるLaravelのEloquentを用いた場合には、Eager Loadingという事前にリレーションするデータを取得する機能を用いることでN+1問題を解決することができます。Eagerとは「前のめり」という意味であり、アクセス前に取得する様を表していると思われます。

以下はEager Loadingを用いてN+1問題を解決した例です。

<?php
// Questの全件取得と共にリレーションするMissionも一括で取得
$quests = Quest::with(‘mission’)->get();
foreach ($quests as $quest) {
    $quest->mission; // リレーション先データはプロパティとしてアクセス可能
}

Eloquentでは、綺麗にN+1問題を解決できています。コロプラの最新プロジェクトではLaravelのEloquentを用いています。

今回対象となるプロジェクトはベースのフレームワークとしてLaravelを利用しておらず、独自のDataMapperを利用していることからEager Loadingを利用できず、N+1問題を中心にいくつかの問題を抱える状況でした。

問題と目標

問題を具体的に見ていくために先程のDataMapperにおいてN+1問題を解決するサンプルコードを再掲します。

<?php
/** クエストのレスポンスモデル配列生成メソッド */
private function createQuestResponseModels(array $quests): array
{
    // クエリのために必要なidを集計
    $missionIds = [];
    foreach ($quests as $quest) {
        $missionIds[] = $quest->getMissionId();
    }

    // 集計したidを利用して一括取得
    // findMapByIdsはidをキーとする連想配列を返す
    $missionMap = $this->missionMapper->findMapByIds($missionIds);

    // レスポンス生成
    foreach ($quests as $quest) {
        // 対象のmissionIdのMissionデータを抽出
        $mission = $missionMap[$quest->getMissionId()];
        // レスポンスオブジェクト生成
        $questResponses[] = new QuestResponse($quest, $mission);
    }
    return $questResponses;
}

DataMapperにおけるN+1問題の解決では、「クエリに利用するパラメータの事前集計」、「データの一括取得」、「データから対象のみを抽出」の3フェーズ構成を避けられないことについてお話しました。

サンプルコードではミッションのみでしたが他の複数のテーブルからデータを得なければならなくなったらどうでしょうか?

例えば、クエストに紐づく情報として、サブミッション、挑戦条件、クリアで得られる報酬、サポートしてくれるNPCなどが考えられます。

データの種類の増加に伴い、クエリの実行に用いるパラメータ変数(サンプルにおける$missionIds)やデータを格納する変数($missionMap)の種類が増えます。

これらの変数を管理するために、それらをメンバとして含む巨大なクラスが作られることもあります。

また、各フェーズでの処理も多くなるため、別のメソッドに切り出す必要性に迫られるでしょう。

その結果、以下のようになります。

<?php
// クエリ集計フェーズ(あらゆるクエリパラメータの塊生成)
$queryParamChunk = $this->summarizeParams($quests);

// クエリ実行フェーズ(パラメータを使い関連データを一括取得)
$modelChunk = $this->bulkFetch($queryParamChunk);

// データ抽出フェーズ
$questResponses = [];
foreach ($quests as $quest) {
  // レスポンスオブジェクト生成
  $questResponses[] = new QuestResponse(
    $modelChunk->getByQuest($quest) // 塊からデータ抽出
  );
}

一見綺麗に見えるかもしれませんが、クエストに対して新しいデータ取得が必要になった際には、大きく分離された各フェーズのメソッド(summarizeParams、bulkFetch、getByQuest)に実装を行う必要があります。

また、$modelChunk->getByQuest()内では対象ではないクエストの関連データに容易くアクセス可能です。

本来、あるクエストに対して処理を行う場合には、そのクエストに関連するデータにしかアクセスできないのが理想です。

関連するデータだけが集まった状態は凝縮度が高い状態であると言え、可読性や堅牢性が高くなります。

N+1問題を考慮しない場合には簡単に凝縮度を高くすることができます。

<?php
private function createQuestResponses($quests): array
{
    $questResponses = [];
    foreach ($quests as $quest) {
        $questResponses[] = $this->createQuestResponse($quest);
    }
    return $questResponses;
}

private function createQuestResponse($quest): QuestResponse
{
    // N+1問題はあるもののメソッド内ではあるクエストに関連するデータしか扱えない
    $mission = $this->missionMapper->find($quest->getMissionId());
    return new QuestResponse($quest, $mission);
}

この場合、createQuestResponse()の内、またはQuestResponseクラス内にあるのは、あるクエストに関連するデータのみになります。

これが本来あるべき、処理を関連性のあるメソッドやクラスで分離できている状態です。

N+1問題の解決前後のサンプルコードから、DataMapperに関するN+1問題の解決はトレードオフの関係にある処理の分離を犠牲にクエリのパフォーマンスを取ることであることがわかります。

トレードオフを打ち壊し、処理を分離した状態を保ちながらN+1問題を解決することが理想です。

まとめ

DataMapperにおけるN+1問題の解決に関連する問題について具体的に説明いたしました。

N+1問題の解決と処理の分離がトレードオフの関係にあること、そのトレードオフを打ち壊すものがプロジェクトにおいて必要とされていることがおわかりいただけたかと思います。

このトレードオフを打ち壊すものがLazyMapperと呼んでいる機能です。LazyMapperはデータ取得クエリを一つに纏めた上で遅延させて実行します。

以下の記事にて紹介しておりますので、こちらもご確認いただければ幸いです。
blog.colopl.dev

以上でN+1問題に関連する問題のお話とさせていただきます。

お読みいただきありがとうございました。

*1:コロプラではアプリによって Apache mod_php もしくは php-fpm を使い分けており、ローカルキャッシュとして APCu 拡張を利用しています

*2:対象プロジェクトではサービスでテーブルのリレーションを表現しているため、このようになっています