🗜️

graphql-ruby の connection_type の N+1 解消を試みる

2024/03/19に公開

ふとしです。

Graphql は業務で使ったり使わなかったりですが、気配がしてきたので、復習をかねて気になったポイントの速度改善を試み、どの程度効果があるか簡単に測ってみました。

結果、ある条件では早いのですが、遅い条件に至ると爆発的に遅くなるようなので、変に複雑さを持ち込むよりも、提供されている実装使ったほうがいいんじゃないかな〜〜という感じでした。

なので実装よりも計測がメインみたいになっています。


入れ子のコネクションに connection_type で得られる従来の実装をそのまま使うと N+1 になるので、それを解消したいと思います。

ページネーションがなければ Graphql::Batch で容易に解消できますが、ページネーションがあるのでちょっとめんどくさいです。

前提

ruby 3.2.2p53
graphql 2.2.13
graphql-batch 0.6.0
postgresql 16.2-bookworm

これで Users を複数取得した上で Posts をページネーションで取得する場合です。

Tags は

ページネーションがなければ Graphql::Batch で容易に解消できます

の例示のために用意しました。

先にまとめ

クエリ

実際は ActiveRecord で組み立てていきますが、最終こんなクエリになります。

SELECT "posts".*
FROM "posts"
WHERE "posts"."id" IN (
    SELECT "id"
    FROM (
        SELECT "posts"."id",
               ROW_NUMBER() OVER (PARTITION BY posts.user_id) AS rn
        FROM "posts"
        WHERE "posts"."user_id" IN (
            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
        )
        ORDER BY "posts"."id" ASC
    ) notes
    WHERE (rn > 7) AND (rn <= 17)
)
ORDER BY "posts"."id" ASC;
  1. ROW_NUMBER()User 内における行番号を付与したインサイトビューを作成
  2. ページネーション情報をもとに絞り込み (この場合 after: "Nw", first: 10)
  3. 得られた posts.idPost を取得
  • 1 のインサイトビューの段階で posts.* としてしまうとかなり重くなってしまうので、サブクエリが一層分深くなりました
  • last が指定してあり before がない場合、各 Post からの末尾 last 件となるので、ROW_NUMBER() に加えて Count が必要になります

気になる速度

計測方法は以下のような感じです。Benchmark モジュールを使用しました。

  • normal 従来の実装で普通に取得。
  • normal_last 従来の実装で last のみで取得。Count(*)User ごとに発行するので発行クエリがさらに倍となる。
  • loader 今回の実装で普通に取得。
  • loader_last 今回の実装で last のみで取得。Count(*) が増える他、ROW_NUMBER に加えて COUNT(*) OVER (PARTITION BY posts.user_id) が必要になる。

User に紐づく Post 数での処理時間の変化

取得する User100000、取得する Post10 固定で、それぞれの User に紐づく Post 数だけ変化させています。(# 10 とかが数)

今回の実装では、一度全ての行番号を取得するため、 (取得する Post ではなく) User に紐づく Post が多ければ多いほど遅くなるのでは、と思っていたので、まずはこの条件からです。

取得する User が多ければ多いほど従来の実装はクエリが増えて遅くなるので、従来の実装が一番不利な形です。

# User に紐づく Post 数での処理時間の変化を見る

                  user     system       total       real 
# 10 posts
normal      11.151498   0.250244  11.401742 ( 12.840901)
loader       3.472369   0.028149   3.500518 (  3.889228)
normal_last 17.618209   0.506112  18.124321 ( 21.562802)
loader_last  4.608345   0.056252   4.664597 (  5.042672)

# 100 posts
normal      12.046320   0.190160  12.236480 ( 14.939543)
loader       4.278293   0.056317   4.334610 (  5.049563)
normal_last 17.295912   0.501572  17.797484 ( 21.510645)
loader_last  4.548490   0.055473   4.603963 (  5.708016)

# 1000 posts
normal      13.365343   0.317498  13.682841 ( 16.810394)
loader       4.466419   0.052214   4.518633 (  8.325117)
normal_last 20.644921   0.600395  21.245316 ( 28.419820)
loader_last  4.620647   0.027643   4.648290 ( 12.650978)

# 10000 posts
normal      13.141197   0.212301  13.353498 ( 14.986057)
loader       4.133976   0.059635   4.193611 ( 50.229902)
normal_last 26.918369   0.866733  27.785102 ( 62.902430)
loader_last  4.675246   0.025074   4.700320 (102.836095)

# 100000 posts
normal      14.086178   0.268153  14.354331 (  18.148563)
loader       4.703988   0.076547   4.780535 ( 712.970506)
normal_last 31.000704   0.919046  31.919750 ( 331.698359)
loader_last  4.683513   0.119215   4.802728 (2117.663274)

今回の実装は紐づき数が増えると予測どおり遅くなりましたが、思った以上に遅くなりますね。Ruby 以外の処理時間がやたら長いということで、やはり今回実装したクエリが気まずい感じになっているようです。紐づき数が 1000 程度までなら今回の実装が早いと言えそうです。

クエリの発行が少ない分、発行と処理のオーバーヘッドが減り、user 時間は全体的に短めです。

従来の実装の普通の取得は紐づき数に影響を受けませんでした。それはそう。normal_lastCOUNT(*) がある分、紐づき数に影響を受けているようです。

User 取得数での処理時間の変化

それぞれの User に紐づく Post1001000、取得する Post10 固定で、取得する User 数だけを変化させています。

上では従来の実装が一番不利な形で計測していましたが、今回はクエリ発行数に増減があります。User が少ないうちは従来の実装が早いのではないかなと思い測定しました。

# User 取得数での処理時間の変化を見る (紐づく数 100)

                  user     system       total       real 
# 10 users
normal        0.022915   0.000173   0.023088 (  0.025544)
loader        0.011035   0.000075   0.011110 (  0.164605)
normal_last   0.017548   0.000040   0.017588 (  0.024258)
loader_last   0.009764   0.000044   0.009808 (  0.162695)

# 100 users
normal        0.104061   0.000290   0.104351 (  0.124617)
loader        0.049732   0.003939   0.053671 (  0.219136)
normal_last   0.201478   0.007632   0.209110 (  0.244122)
loader_last   0.047022   0.000076   0.047098 (  0.214915)

# 1000 users
normal        1.122192   0.023472   1.145664 (  1.291780)
loader        0.437586   0.007654   0.445240 (  0.679065)
normal_last   1.862192   0.032353   1.894545 (  2.265365)
loader_last   0.536199   0.003994   0.540193 (  0.808535)

# 10000 users
normal       12.594478   0.264662  12.859140 ( 14.522574)
loader        4.874568   0.016679   4.891247 (  5.598203)
normal_last  17.396304   0.501366  17.897670 ( 21.649344)
loader_last   4.639126   0.044529   4.683655 (  5.812808)
# User 取得数での処理時間の変化を見る (紐づく数 1000)

                  user     system       total       real 
# 10 users
normal        0.013615   0.000000   0.013615 (  0.016574)
loader        0.012801   0.000120   0.012921 (  0.135233)
normal_last   0.017665   0.000248   0.017913 (  0.023972)
loader_last   0.008426   0.003803   0.012229 (  0.161238)

# 100 users
normal        0.105074   0.000258   0.105332 (  0.124622)
loader        0.070090   0.000000   0.070090 (  0.256210)
normal_last   0.257800   0.008551   0.266351 (  0.342686)
loader_last   0.070460   0.000000   0.070460 (  0.258588)

# 1000 users
normal        1.168992   0.034887   1.203879 (  1.372850)
loader        0.377655   0.004044   0.381699 (  1.090115)
normal_last   2.379705   0.054176   2.433881 (  3.197248)
loader_last   0.485731   0.000073   0.485804 (  1.646885)

# 10000 users
normal       12.931684   0.360589  13.292273 ( 15.056855)
loader        4.461398   0.051692   4.513090 (  8.262533)
normal_last  20.567104   0.565169  21.132273 ( 27.716262)
loader_last   4.314121   0.040029   4.354150 ( 12.586279)

User 取得数が少ないと従来の実装の方が大分速いですね。紐づく数が 100 程度でも User100 件取得では N+1 の倍の時間かかって、1000 件取得でようやく勝つかトントンぐらいです。

今回の実装はかなり偏ったデータと使用法でないと勝てず、少なくとも一般的なウェブサイト構築ではあまり有用ではない気がしました。

Post 取得数での処理時間の変化

取得する User100000、それぞれの User に紐づく Post1000 固定で、
取得する Post 数だけ変化させています。

これはあまり勝敗に影響がないんじゃないかな〜と思いながら測定しました。

# Post 取得数での処理時間の変化を見る

                    user     system       total         real 
# 10 posts
normal         13.023019   0.346838   13.369857 (  15.212993)
loader          4.836473   0.028155    4.864628 (   8.626848)
normal_last    22.801044   0.641714   23.442758 (  31.001771)
loader_last     4.509486   0.091955    4.601441 (  12.838326)

# 100 posts
normal         46.725479   0.432508   47.157987 (  49.533016)
loader         50.854561   1.188467   52.043028 (  56.229225)
normal_last    51.793456   1.186148   52.979604 (  60.944300)
loader_last    56.962933   1.267957   58.230890 (  66.177070)

# 1000 posts
normal       1232.463730   6.672946 1239.136676 (1250.520244)
loader       1025.877777   5.741389 1031.619166 (1040.636896)
normal_last  1256.782708   7.725039 1264.507747 (1279.625013)
loader_last  1047.096122   8.936709 1056.032831 (1065.126479)

今回の実装のほうが速い組み合わせですが、取得数を増やしてもそれは特に変わりませんでした。

まとめのまとめ

というわけで、取得 User 数がかなり多く紐づき Post 数が少ない状況では今回の実装が何とか速いが、全体を見た感じでは安定した速度の出る従来の実装を使うのが良いんじゃないかなと思いました。複雑さも責任も持ち込まずに済みますし。

というわけで、後は蛇足です。

以下実装

劇的な結果を得られなかったのですが、以下にコードを記載します。

何種類かの query を用意して、connection_type で得られるリゾルバーの結果と比較して一致するかどうかでテストを行いました。

自分のおさらいも兼ねていたので、本丸で書いた部分以外もメモっておきます。

まず単純な belongs_to の N+1

まず Graphql::Batch のおさらいを兼ねて Post#user の N+1 を解消します。これは Post を複数取得して user を利用する場合に発生します。

field :user_loader, Types::UserType, null: false

class UserLoader < GraphQL::Batch::Loader
  def perform(user_ids)
    User
      .where(id: user_ids)
      .each { |user| fulfill(user.id, user) }
  end
end

def user_loader
  UserLoader
    .for(User)
    .load(object.user_id)
end

load で収集した post.id を元に perform が最後の方で一気に User を取得します。includes などを使わずに住むため、呼び出し側でケアをしなくて良いのが便利ですね。

ページネーションなしのコネクション

配列状データ構造は統一しつつもページネーションはいらんという場合は、型情報はポン付け connection_type から使用しつつ、返す値だけ用意します。

コネクションデータの準備

まずポン付けした時に自動的に挿入される argumentresolver を無効化するために connection: false を設定します。

field :tags_mine, Types::TagType.connection_type, null: false, connection: false

コネクションは edge やら node やらが含まれるデータ構造を使うので、配列ママ返すと怒られます。自分で組み立てるのは大変なので、特に Array をいい感じにやってくれる GraphQL::Pagination::ArrayConnection が用意されていますから、それでラップしましょう。

def tags_mine
  GraphQL::Pagination::ArrayConnection
    .new(
      object.tags,
      context:
    )
end

N+1 の解消

このままでは N+1 なので Graphql::Batch で解決します。

perform では Post.tags が 0 件の場合に fullfill されない ID がありうるので、最後にあぶれた ID に空のコネクションを割り当ててるのがミソといえばミソです。

field :tags_loader, Types::TagType.connection_type, null: false, connection: false

class TagsLoader < GraphQL::Batch::Loader
  def initialize(context)
    @context = context
  end

  def perform(post_ids)
    Tag
      .joins(:post_tags)
      .select('tags.*', 'post_tags.post_id')
      .where(post_tags: { post_id: post_ids })
      .group_by(&:post_id)
      .each { |post_id, tags| fulfill(post_id, GraphQL::Pagination::ArrayConnection.new(tags, context: @context)) }

    # あぶれた post_id には空のコネクションを設定しておく。
    post_ids
      .reject(&method(:fulfilled?))
      .each { |id| fulfill(id, GraphQL::Pagination::ArrayConnection.new([], context: @context)) }
  end
end

def tags_loader
  TagsLoader
    .for(context)
    .load(object.id)
end

ページネーションありコネクションの N+1 解消コード

ベンチで使用したコードです。

field :posts_loader, Types::PostType.connection_type, null: true, connection: false do
  argument :first, Integer, required: false
  argument :last, Integer, required: false
  argument :before, String, required: false
  argument :after, String, required: false
end

class PostConnection < GraphQL::Pagination::Connection
  def initialize(
    items: [],
    context:,
    first: nil,
    last: nil,
    before: nil,
    after: nil,
    total: nil
  )
    @items = items
    @edge_class = self.class::Edge
    @context = context
    @first = first
    @last = last
    @before = before
    @after = after
    @total = total.to_i
  end

  def cursor_for(item) = cursor_map[item].to_s.then(&method(:encode))
  def nodes = @items
  def has_next_page = (!!@before && @before > 0) || (!!@first && @total - @first - count_start >= 0)
  def has_previous_page = (!!@after && @after > 0) || (!!@last && tail - @last > 0)
  def start_cursor = nodes.first && cursor_for(nodes.first)
  def end_cursor = nodes.last && cursor_for(nodes.last)

  private

  def cursor_map
    @index_map ||=
      items
        .each_with_object({})
        .with_index { |(v, a), i| a[v] = i + count_start }
  end

  def count_start
    @count_start ||=
      case
      when @after
        @after + 1
      when @last && @before
        [@before - @last, 1].max
      when @last
        [@total - @last + 1, 1].max
      else
        1
      end
  end

  def tail = @tail ||= @before ? @before - 1 : @total
end

class PostsLoader < GraphQL::Batch::Loader
  def initialize(
    first: nil,
    last: nil,
    before: nil,
    after: nil,
    context:
  )
    @first = first ? first.to_i : nil
    @last = last ? last.to_i : nil
    @before = before ? Base64.decode64(before).to_i : nil
    @after = after ? Base64.decode64(after).to_i : nil
    @context = context
  end

  def first? = @first.present?
  # first と last が両方指定されている場合、last が優先される。
  def last? = @is_last ||= @first.blank? && @last.present?
  def before? = @before.present?
  def after? = @after.present?
  # last かつ before がない場合は総件数位置から last 件となるので、
  # 行を特定するのに総件数が必要になる。
  def need_total? = last? && !before?
  def from_tail? = need_total?

  def perform(user_ids)
    totals = first? || last? ? Post.where(user_id: user_ids).group(:user_id).count : {}

    # id と何行目に出現するかのみを持ったサブクエリ
    # これを元に必要な notes.id を取得する。
    #
    # この位置で Post カラムを全て取得すると劇的に遅くなるようだ。
    notes_inline_view =
      Post
        .select(
          :id,
          'ROW_NUMBER() OVER (PARTITION BY posts.user_id) AS rn',

        )
        .then { |ar|
          if need_total?
            # 末尾からの相対位置で where できるように、総件数を含めておく。
            ar.select('COUNT(*) OVER (PARTITION BY posts.user_id) AS total')
          else
            ar
          end
        }
        .where(user_id: user_ids)
        .order(id: :asc)
    # first last before after に応じて、取得する notes.id を絞り込む。
    note_ids =
      Post
        .select(:id)
        # 範囲指定がある場合は、その範囲を指定する。
        .then { |a| after? ? a.where('? < rn', @after) : a }
        .then { |a| before? ? a.where('rn < ?', @before) : a }
        .then { |a|
          # 件数指定がある場合は、その範囲を指定する。
          case
          when first?
            # after がある場合は after を起点とした first 件を取得する。
            up_to = @after ? @after + @first : @first
            a.where('rn <= ?', up_to)
          when last?
            # before がない場合、last は特定位置からではなく、それぞれの末尾から last 件となるので、総件数から引く。
            before? ? a.where('? <= rn', @before - @last) : a.where('total - ? < rn', @last)
          else
            a
          end
        }
        .from(notes_inline_view, :notes)

    # Post を取得して、ユーザーごとにグループ化して返す。
    Post
      .where(id: note_ids)
      .order(id: :asc)
      .group_by(&:user_id)
      .each { |user_id, notes|
        fulfill(
          user_id,
          new_connection(notes, totals[user_id])
        )
      }

    # あぶれた user_id には空のコネクションを設定しておく。
    user_ids
      .reject(&method(:fulfilled?))
      .each { |user_id| fulfill(user_id, new_connection([], totals[user_id])) }
  end

  def new_connection(notes, total)
    PostConnection::new(
      items: notes,
      context: @context,
      first: @first,
      last: @last,
      before: @before,
      after: @after,
      total: total,
    )
  end
end

def posts_loader(
  first: nil,
  last: nil,
  before: nil,
  after: nil
)
  PostsLoader
    .for(
      first:,
      last:,
      before:,
      after:,
      context:
    )
    .load(object.id)
end

ROW_NUMBER() OVER (PARTITION BY posts.user_id)

ポン付けの実装では first last before after から LIMITOFFSET を割り出してクエリを発行します。last のみの場合は COUNT も発行して使います。

N+1 の解消では一度に取得するため、LIMITOFFSET は使えません。WHERE で絞り込むために、各 User が持つ posts 内の位置が必要になるので、これで挿入します。

first last before after から位置を特定

ROW_NUMBER() さえ入ってしまえば、あとは WHERE で絞り込むだけです。

last だけは注意

引数 last のみを指定した場合は、各 User が持つ posts の末尾から last 件となります。これは ROW_NUMBER() のみでは実現できないので、計算に使用できるように COUNT(*) OVER (PARTITION BY posts.user_id) AS total で各行に挿入します。

デフォルトの実装では COUNT(*) を発行した上で Post を絞り込むので、クエリ発行量は 2 倍になっています。

before がある場合は必ず before から last 件となるので、total は必要ありません。位置指定をした場合は、すべての posts について同じ位置からの取得となります。

pageInfo

start_cursor end_cursor については結果から取得結果から得られるのでそのままです。

hasNextPage hasPreviousPage については多少分岐があります。

hasNextPage については firstbefore がある場合のみ true になるので、それを考慮して計算します。

hasPreviousPage についても lastafter がある場合のみ true になるので、それを考慮して計算します。

Graphql の使用なのか graphql-ruby 独自なのかわかっていませんが、元の実装では after1 以上のカーソルを渡すと必ず hasPreviousPagetrue になり、before1 以上のカーソルを渡すと必ず hasNextPagetrue になります。

おわりに

graphql-ruby を触るついでに軽くコードをいじくる予定が、割と時間を使ってしまいました。時間を使った割に結果は芳しくなかったのですが、graphql-ruby の各実装に触れられたり、遅くなる局面とその理由などがわかってためになりました。

おまけ: cursor を含めると遅くなる

従来の実装で edges { cursor } を含めると、edges 件数分だけ Array#index が走る ので、取得する数が 1000 を超えたぐらいでめちゃくちゃに遅くなってきます。

全 edge に cursor が必要な局面というのがあるのかわかりませんが、お気をつけください。

おまけ: MySQL での計測結果

紐づき数のみを変化させる条件で計測しました。

                  user     system      total        real

# 10 posts
normal       11.586509   0.225090  11.811599 ( 13.925536)
loader        3.637428   0.044008   3.681436 (  7.792499)
normal_last  17.635297   0.459949  18.095246 ( 22.467251)
loader_last   4.546574   0.031883   4.578457 (  5.152592)

# 100 posts
normal       13.249013   0.232724  13.481737 ( 16.811813)
loader        4.580440   0.020142   4.600582 (  7.736526)
normal_last  22.402983   0.688297  23.091280 ( 30.790837)
loader_last   4.244865   0.023775   4.268640 ( 10.677775)

# 1000 posts
normal       14.597821   0.305652  14.903473 ( 19.694515)
loader        4.706114   0.004836   4.710950 ( 51.546018)
normal_last  27.007243   0.717249  27.724492 ( 65.353023)
loader_last   4.592707   0.019722   4.612429 ( 76.484493)

# 10000 posts
normal       17.329285   0.415464  17.744749 ( 32.462601)
loader        4.544893   0.032850   4.577743 (255.435054)
normal_last  31.606142   0.958925  32.565067 (228.741088)
loader_last   4.527856   0.023851   4.551707 (711.344883)

# 100000 posts
normal       15.861532   0.491088  16.352620 (  35.320562)
loader        4.546865   0.032224   4.579089 (2851.705441)
normal_last  33.789901   1.083515  34.873416 (1747.727369)
loader_last   4.739909   0.019638   4.759547 (8974.644960)

PostgreSQL では 1000 までは早かったのですが、MySQL ではすでに従来の実装より大分遅いですね。量が増えてくると大体 3-4 倍前後時間がかかるようです。

ハートレイルズ

Discussion