パフォーマンス課題との戦い 〜GraphQLはそう簡単じゃない〜

2024/12/25に公開

はじめに

こんにちは!株式会社ブロードエッジ・ウェアリンク CTOの高丸です。
今回は、Qiita Advent Calendar 2024の16日目の記事です。

15日目の記事で紹介したリプレイスプロジェクトの第2弾として、診断・カルテ機能のリリースについてお伝えしました。

リリース時にバックエンドAPIでパフォーマンスの問題が発生し、どう対応していったかを書いていきます。

バックエンドAPIで起きたパフォーマンス問題

初歩的なN+1

我々は今回の開発において、GraphQLによるバックエンドAPIの実装を行いましたが、そこでいくつかの重要なパフォーマンス問題に直面することになりました。

最初に直面したのは、初歩的な問題とも言える N+1問題でした。
診断・カルテ機能の大幅な進化に伴い、DBテーブルの変更や既存ロジックの書き換えなど、多くの変更が必要となりました。
既存のDB設計自体にもパフォーマンス面での課題があり、既存診断・カルテロジックの書き換えに時間を取られた結果、パフォーマンス面での考慮が後回しになってしまいました。

N+1問題といえば、RubyではbulletというGemを入れると、アプリケーション実行中にN+1のクエリが発生すると警告を出してくれますが、
Python(特にFastAPI)では、それといったツールはなく、私が本番監視用に導入していたSentryで気づくことができました。

最近のSentryはエラー監視だけでなく、APMの面でも機能が提供されており、低価格なコストで非常に便利なツールになりました。
https://sentry.io/

とはいえ、本来であれば、開発者が開発中にSQLログを確認する習慣があれば防げた問題でもありました。
開発環境やコードレビュー時のチェックポイントとして、SQLログの確認を明確に位置づける必要性を強く感じています。

GraphQLに起因するN+1

もう一つは、GraphQLの特性に起因する問題も発生しました。
GraphQLは本来、オーバーフェッチや不要なAPIコールを防ぐための強力な仕組みですが、それを実現するための工夫はバックエンド側で行う必要があります。
スキーマで定義された親子関係のオブジェクトに対して、クライアント側は深い階層のデータを自由にリクエストできます。これは便利な反面、適切な対策を施さないとN+1問題を引き起こしやすい状況を生み出してしまいます

この問題に対しては、GraphQLのDataLoaderという仕組みを使った解決策が存在します。
https://github.com/graphql/dataloader

我々が採用しているstrawberryというGraphQLライブラリにもDataLoader機能が実装されており、バッチ処理によるN+1問題の解決が可能です。
https://strawberry.rocks/docs/guides/dataloaders

しかし、リプレイス第2弾のリリース時点では、ORMが発行するSQLを適切に制御するEager loadingやDataLoaderの活用といった観点が十分に考慮されていませんでした。

GraphQLを導入してみて

最近、多くの開発者がGraphQLの導入を検討していると感じます。
多くの場合、プロジェクト立ち上げ時にはRESTfulなAPIで十分対応できるものの、データ構造が複雑化するにつれ、GraphQLの柔軟性に魅力を感じるようになるものです。

しかし、我々の経験から言えるのは、GraphQLのメリットだけを鵜呑みにして導入するのではなく、その仕組みやSQLの発行パターンについての深い理解が必要不可欠だということです。

この教訓から、弊社ではチーム内でGraphQLの勉強会を開催し、知識の共有を図ることにしました。
フロントエンドとバックエンドが分離された構成では、APIのパフォーマンスが極めて重要になります。

以下に、FastAPIとstrawberryを組み合わせた実装例を示します。

from typing import List, Optional
import strawberry
from fastapi import FastAPI, Depends
from strawberry.fastapi import GraphQLRouter
from sqlalchemy.orm import Session, selectinload
from dataloader import DataLoader

@strawberry.type
class WineDiagnosis:
    id: int
    user_id: int
    diagnosed_at: str

@strawberry.type
class UserKarte:
    id: int
    user_id: int
    created_at: str
    updated_at: str

@strawberry.type
class User:
    id: int
    name: str
    diagnoses: List[WineDiagnosis]
    karte: Optional[Karte]
    
class Query:
    @strawberry.field
    async def get_user_data(self, id: int, info) -> User:
        # コンテキストからDataLoaderを取得
        context = info.context
        user_loader = context["user_loader"]
        diagnosis_loader = context["diagnosis_loader"]
        karte_loader = context["karte_loader"]

        # ユーザー情報を取得
        user = await user_loader.load(id)
        
        # 診断履歴とカルテ情報を並行して取得
        diagnoses = await diagnosis_loader.load_many([id])
        karte = await karte_loader.load(id) 
        
        # ユーザーオブジェクトに関連データを設定
        user.diagnoses = diagnoses
        user.karte = karte
        
        return user

async def batch_load_users(keys: List[int]) -> List[User]:
    # 一括でユーザー情報を取得
    users = db.query(UserModel).filter(
        UserModel.id.in_(keys)
    ).all()
    # ...(省略)...

async def batch_load_diagnoses(user_ids: List[int]) -> List[List[WineDiagnosis]]:
    # 一括で診断履歴を取得
    diagnoses = db.query(DiagnosisModel).filter(
        DiagnosisModel.user_id.in_(user_ids)
    ).all()
    
async def batch_load_kartes(user_ids: List[int]) -> List[Karte]:
    # 一括でカルテ情報を取得
    kartes = db.query(KarteModel).filter(
        KarteModel.user_id.in_(user_ids)
    ).all()
    # ...(省略)...

async def get_context(db: Session = Depends(get_db)):
    return {
        "db": db,
        "user_loader": DataLoader(load_fn=batch_load_users),
        "diagnosis_loader": DataLoader(load_fn=batch_load_diagnoses),
        "karte_loader": DataLoader(load_fn=batch_load_kartes)
    }

schema = strawberry.Schema(query=Query)
graphql_app = GraphQLRouter(
    schema,
    context_getter=get_context
)

app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")

このコードの重要なポイントは以下の通りです。

ユーザーのデータが取れるGraphQLのqueryのエンドポイントがあったと仮定します。

そのqueryが呼ばれると、get_context関数でDataLoaderのインスタンス群を作成し、コンテキストとして提供しています。これにより、リクエストごとにDataLoaderを使い回すことができ、効率的なバッチ処理が可能になります。それぞれのデータ取得は非同期で実行されます。

また、batch_{データ名}関数では、関連するデータを一括で取得しています。これにより、N+1問題を効果的に回避できます。

このような実装により、GraphQLの柔軟性を活かしながら、パフォーマンスを最適化することが可能になります。

さいごに

今回の経験を通じて、GraphQLの導入においては事前の十分な準備と、継続的なパフォーマンスモニタリングの重要性を改めて実感することとなりました。

特に気にするべきポイントは、技術選定の際に想定したメリットと、実際の運用で直面する課題との間にあるギャップです。

GraphQLは確かに強力なツールですが、その力を最大限に活かすためには、フレームワークやORMの深い理解、そして実装時の細かな配慮が必要不可欠です。

我々の経験が、GraphQLの導入を検討されている方々の参考になれば幸いです。

Discussion