一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

GraphQLのN+1問題を解決する DataLoaderの使い方

f:id:ryo-utsunomiya:20200128121121p:plain

こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLサーバ実装時に遭遇するN+1問題と、その解決のために使えるライブラリを紹介します。

フィールド単位でresolverを用意する

GraphQLでは、クライアントのクエリに応じてオンデマンドに結果を取得できます。

たとえば、以下のクエリを投げると…

{
  accommodation(accommodationId: "00001050") {
    name
  }
}

以下のようなレスポンスが取得できます。

{
  "data": {
    "accommodation": {
      "name": "マンダリン オリエンタル 東京"
    }
  }
}

ここで、施設のクチコミレーティングを取得したい場合、以下のようなクエリを投げることができます。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
  }
}

このとき、サーバサイドではクエリによって必要なカラム(場合によっては、JOINするテーブル)が動的に変わります。バックエンドで動的にSQLを組み立てるのも1つの方法でしょう。しかし、SQLの組み立てロジックが複雑になったり、生成されるSQLが巨大でパフォーマンスの悪いものになったりするといった懸念点があります。

別のアプローチとして、追加のJOINが必要になるフィールドには GraphQL resolverを別に用意して、GraphQLサーバにレスポンスの組み立てを任せる、というものもあります。このようにすると、各resolverの実装をシンプルに保ちつつ、複雑なクエリに応答することができます。

一休.comでも使用している gqlgen というGoのGraphQLライブラリでは、以下の手順でフィールド単位のresolverを用意できます。

(1) GraphQLのスキーマと、gqlgenの設定ファイルを用意する

# schema.graphql

type Accommodation {
    name: String!
    rating: Float!
}
# gqlgen.yml

models:
  Accommodation:
    fields:
      rating:
        resolver: true # この設定がキモ

(2) go generate して、インタフェースを満たす

Resolverのインタフェースは以下のようになります。

// generated.go
type AccommodationResolver interface {
    Rating(ctx context.Context, obj *Accommodation) (float64, error)
}

これを満たす実装は以下のように書けます。

// resolver.go

func (r *accommodationResolver) Rating(ctx context.Context, obj *Accommodation) (float64, error) {
    summary, err := appcontext.From(ctx).Loader.ReviewSummary.LoadByAccommodationID(ctx, obj.AccommodationID)
    if err != nil {
        return 0, err
    }
    return summary.Rating, nil
}

N+1問題

このようにすると、無駄なデータの取得を避けつつ、resolverの実装がシンプルに保つことができます。しかし、以下のようなクエリを処理する際には問題が発生します。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
    neighborhoods {
      name
      rating
    }
  }
}

ここでは、ある施設の近隣施設を取得して、それらのratingを取得しています。仮に、クチコミのレーティング取得が select rating from review_summary where accommodation_id = ? のようなクエリで実装されていると、このクエリが近隣施設の数だけ実行されることになります。このように、関連レコードの件数の分、追加データ取得用のクエリが発生する状態をN+1問題と呼びます。

このときのSQLの流れは以下のようになります。

-- 親の accommodation と rating を取得
select name from accommodation where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- 近隣施設を取得
select accommodation_id, name from neighborhood_accommodation where accommodation_id = ?;

-- 近隣施設の数だけ rating を取得するクエリが発行される。。。
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- ↑ではなく、↓のように一括で取ってほしい
select rating, accommodation_id from review_summary where accommodation_id in (?, ?, ?, ?, ?);

このような場合、RailsなどではORMの 一括読み込み 機能を利用します。

一方、gqlgenでは、各resolverは平行に実行されるので、ORMによる一括読み込みは利用できません。このような場合に利用可能な、データ取得をバッチ化する仕組みが DataLoader です。DataLoaderのオリジナルはJavaScript実装の graphql/dataloader ですが、様々な言語のDataLoader実装が公開されています。また、DataLoaderはGraphQLサーバで使うために作られたライブラリですが、GraphQLとは関係なく、REST APIなどでも利用できます。

GoのDataLoaderライブラリ

Go製の有力なDataLoaderライブラリは、私が把握している範囲では以下の2つです。

前者は graph-gophers/graphql-go 、後者は gqlgen の作者によるライブラリです。

一休.comではgqlgenを使っているため、当初は dataloaden の方を試しました。dataloadenはgqlgenと同じくgo generateによるコード生成ライブラリとなっており、型安全なDataLoaderを生成できるという特長があります。しかし、モデルの配置方法などに制約が強く、私たちの用途には合いませんでした。

そこで、今は graph-gophers/dataloader を使っています。

DataLoaderの仕組み

サンプルコードに入る前に、DataLoaderの仕組みについて解説します。DataLoaderは前述したようにデータ取得をバッチ化するためのライブラリですが、そのための仕組みとしては「一定時間待って、その間に実行されたデータ取得リクエストをバッチ化する」というアプローチを取っています。

「一定時間」は、1msや16msなどといった値になります。この値が大きくなるとバッチ化できる範囲が広がりますが、その分レスポンスタイムが遅くなるおそれがあります。

graph-gophers/dataloader では、dataloader.Loader の Load() メソッドを呼び出すと、 Thunk という型の関数が返ってきます。この関数はJavaScriptのPromiseのようなもので、一定時間待った後で値が取得できます。

thunk := dataloader.Load(ctx, key)

実際のサーバでは、 Load() は平行して呼ばれるため、各goroutineが Thunk を受け取ります。

// goroutine A
thunk := dataloader.Load(ctx, key)

// goroutine B
thunk := dataloader.Load(ctx, key)

// goroutine C
thunk := dataloader.Load(ctx, key)

このthunkを呼び出すと、結果を取得できます。

thunk := dataloader.Load(ctx, key)
result, err := thunk()

一定の待ち時間の間に呼び出された Load() のkeyを覚えておいて、一括でデータ取得を行うのがDataLoaderの仕組みです。

// ここで 1ms のタイマースタート
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000001")

// 0.5ms経過…

// この呼び出しは↑と一緒にバッチ化される
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000002")

// 1ms 経過:↑の2件をまとめて、以下のクエリを発行し、結果を返す
// select accommodation_id, rating from review_summary where accommodation_id in ('00000001', '00000002')

// この呼び出しは別のバッチになる
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000003")

DataLoaderのサンプルコード

完全な形のサンプルコードとしては、 hatena/go-Intern-Bookmark がオススメです。ここでは、このサンプルコードを題材に graph-gophers/dataloader の使い方を解説します。

DataLoaderを使うには、まず以下のようにバッチでデータ取得を行う関数を用意します(コードは簡略化しています)。

// loader/entry.go
func newEntryLoader(app service.BookmarkApp) dataloader.BatchFunc {
    return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
        entryIDs := keysToEntryIDs(keys)
        entrys, _ := app.ListEntriesByIDs(entryIDs) // ここがキモ。 select * from entry where id in (...) を投げる
        return entrysToResults(entrys)
    }
}

次に、この関数を context に保持させます。なぜ context に保持させるのかというと、DataLoaderのキャッシュ機能はリクエスト単位のデータのキャッシュを意図しているからです。リクエスト毎に内容が空になる context は、DataLoaderを保持させる場所にぴったりです。これによって、バッチ化の対象は同一リクエスト内の Load() の呼び出しに限定されます。

contextへの追加はミドルウェアで行います。

// web/server.go

func (s *server) attatchLoaderMiddleware(next http.Handler) http.Handler {
    loaders := loader.New(s.app)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r.WithContext(loaders.Attach(r.Context())))
    })
}

このようにしてcontextに登録したDataLoaderは、以下のようにして呼び出せます。

// resolver/bookmark_resolver.go

// hatena/go-Intern-Bookmark は graph-gophers/graphql-go を使っているため、
// resolverの書き方がgqlgenとは異なる
func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error) {
    // LoadEntry は context から DataLoader を取得し、Load() を呼び出して、結果を Entry 構造体にして返す
    entry, err := loader.LoadEntry(ctx, b.bookmark.EntryID)
    if err != nil {
        return nil, err
    }
    return &entryResolver{entry: entry}, nil
}

DataLoaderとDataDog APM

一休で使っているDataDogのAPM(Application Performance Monitoring)だと、以下のようなトレースが見えます。resolverが平行に実行されている様子が分かりやすいです。

f:id:ryo-utsunomiya:20200128145750p:plain

紫色がDB問い合わせで、Ratingの取得は1回のDB問い合わせにまとめられていることがわかります。また、APMを活用すると、「待ち時間が短すぎて、複数のバッチに分かれてしまっていないか?」といった調査も簡単にできます 👍

むすび

今回はGoのDataLoaderライブラリの使い方を紹介しました。DataLoaderはややトリッキーですが、ハイパフォーマンスなGraphQLサーバの実装には欠かせないライブラリだと思います。

採用情報

一休では、GoやGraphQLに強みのあるエンジニアを募集しています! 一休.comのバックエンドは .NET Framework から Go への移行を進めていて、バックエンドでGoを書く割合が少しずつ増えているところです。

hrmos.co