graphql-ruby の connection_type の N+1 解消を試みる
ふとしです。
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;
-
ROW_NUMBER()
でUser
内における行番号を付与したインサイトビューを作成 - ページネーション情報をもとに絞り込み (この場合
after: "Nw", first: 10
) - 得られた
posts.id
でPost
を取得
- 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 数での処理時間の変化
取得する User
は 100000
、取得する Post
は 10
固定で、それぞれの 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_last
は COUNT(*)
がある分、紐づき数に影響を受けているようです。
User 取得数での処理時間の変化
それぞれの User
に紐づく Post
は 100
と 1000
、取得する Post
は 10
固定で、取得する 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
程度でも User
を 100
件取得では N+1 の倍の時間かかって、1000
件取得でようやく勝つかトントンぐらいです。
今回の実装はかなり偏ったデータと使用法でないと勝てず、少なくとも一般的なウェブサイト構築ではあまり有用ではない気がしました。
Post 取得数での処理時間の変化
取得する User
は 100000
、それぞれの User
に紐づく Post
は 1000
固定で、
取得する 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
から使用しつつ、返す値だけ用意します。
コネクションデータの準備
まずポン付けした時に自動的に挿入される argument
や resolver
を無効化するために 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
から LIMIT
と OFFSET
を割り出してクエリを発行します。last
のみの場合は COUNT
も発行して使います。
N+1 の解消では一度に取得するため、LIMIT
と OFFSET
は使えません。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
については first
や before
がある場合のみ true
になるので、それを考慮して計算します。
hasPreviousPage
についても last
か after
がある場合のみ true
になるので、それを考慮して計算します。
?
Graphql の使用なのか graphql-ruby 独自なのかわかっていませんが、元の実装では after
に 1
以上のカーソルを渡すと必ず hasPreviousPage
が true
になり、before
に 1
以上のカーソルを渡すと必ず hasNextPage
が true
になります。
おわりに
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