何かが遅かった
私たちの製品のバックログリストページが重かった。劇的に壊れてはいない——ただ…違う。新鮮なデモテナントから実際のデータがあるリアルなテナントに切り替えたときに気づくような遅さ。
なので、それを測定した:
- 8件 : 206ms TTFB(最初のバイトまでの時間)
- 67件 : 675ms TTFB
それは+469ミリ秒の差異、またはおおよそ8ミリ秒ごとの線形劣化です。バックログの項目を一つ追加するごとに約8ミリ秒がレスポンス時間に加わりました。あまり良くありませんでした
。最初の直感はフロントエンドを責めることでした——コンポーネントツリーがあまりに積極的に再レンダリングされていないかも。しかしTTFBはそれをすぐに否定しました。ブラウザはまだレンダリングを開始していなかった;サーバーがそれだけの時間かけてレスポンスするのだった。
APIサーバーとデータベースの間にボトルネックがあった。もっと深く掘り下げる時だ。
プリロードの仕組み
私たちはGo + gqlgen + GORMをCloud Runで実行し、Cloud SQL(PostgreSQL)と通信している。GraphQLのレイヤーは、単純な原則の周りに設計されている:
クライアントは必要なフィールドのみを要求します。サーバーは要求された内容のみを事前にロードします。
実際には、私たちのリゾルバーは受信するGraphQLのセレクションセットを検査し、それをGORMのPreload()コールに翻訳します。フロントエンドがtasks { assignees { user } }を要求すると、バックエンドは忠実にTasks → Assignees → Userを事前にロードします。それがtasks { backlogStatus }のみを要求する場合、私たちはTasks → BacklogStatusのみを事前にロードします。
これは「正しい」設計です。バックエンドはフロントエンドがデータの必要性を正確に宣言することを信頼し、その正確な内容で応答する——それ以上、それ以下ではありません.
理論上.
実際には何が起こっていたか
リストページが発行していたGraphQLクエリは次の通り(簡略化):
query GetProductBacklogItems($projectId: ID!) {
productBacklogItems(projectId: $projectId) {
edges {
node {
id
name
storyPoint
priority
progress
backlogStatus { id name status }
tasks {
...SubTaskFields
}
}
}
}
}
そしてSubTaskFields はこのようでした:
fragment SubTaskFields on Task {
id
name
backlogStatus { id name status }
assignees {
user { id givenName familyName }
}
}
問題は見つけられますか?リストページは tasks { ...SubTaskFields } をリクエストしており、SubTaskFields には assignees { user { ... } } が含まれています。
リストページではタスクの割り当て先を表示しません。 そのページ上のどのコンポーネントも読み取っていませんtask.assignees しかしクエリはそれを求めていたようです。
バックエンドでは、これが4段階のプレロードチェーンをトリガーしました — 各バックログアイテムごとに一度実行されます:
| レベル | プレロード |
|---|---|
| 1 | Tasks |
| 2 | Tasks.BacklogStatus |
| 3 | Tasks.Assignees |
| 4 | Tasks.Assignees.User |
67件に対しては、67 × 4の連鎖データベース検索です。それぞれがネットワーク経由でCloud SQLにヒットしています。もっともなことに、線形だったのです。
なぜ誰も見つけなかったのか
これがイライラする部分です:Fragmentはまさにそれがやりたかったことを行っていました—異なる文脈で.
バックログアイテムについて、私たちは二つの見方があります:
- リストビュー — 名前、ステータス、ストーリーポイント、進捗を表示します。タスクレベルの割り当て情報は表示されません.
- 詳細表示 — すべてを表示します。サブタスクごとの割り当て者も含まれます.
詳細表示は正当にassignees { user }をSubTaskFields内に必要としています。そのFragmentは詳細表示のために書かれ、そこでは正しかったです.
リストビューはただ…それを借りただけです。同じFragmentで異なるコンテキストでは、性能特性が大きく異なります。
小規模では誰も気づきません。8アイテム × 4事前ロードで64ms追加されるだけです。それはノイズです。しかし50件以上になると、線形コストが痛くて目立ちます。
別の要因もあります:ローカル開発マシンはあまりにも速すぎます。 開発時には、データベースはローカルか近くのDockerコンテナにある——ネットワークの往復時間は基本的にゼロである。各プレロードのコストはマイクロ秒単位であり、したがって4レベルでさえ即時的に感じられる。本番時には、各プレロードはCloud RunとCloud SQLの間のネットワークホップとなる。各ホップの遅延時間を4レベル×Nアイテムで掛け算したものが、見えないオーバーヘッドを実際のボトルネックに変えたものである.
講習: GraphQLのフラグメントは開発者にとって便利なものだが、データの必要に関する契約ではない。表示要件が異なる複数のビューでフラグメントを共有すると、必要のないプレロードを黙って選択することになる。
対処法
ステップ1:不要なフィールドをリストクエリから削除
明らかな手順 — リストクエリでtasks { ...SubTaskFields }を要求しないようにする:
query GetProductBacklogItems($projectId: ID!) {
productBacklogItems(projectId: $projectId) {
edges {
node {
id
name
storyPoint
priority
progress
backlogStatus { id name status }
# tasks removed — list page doesn't render subtask details
}
}
}
}
しかし、注意点があります
ステップ2: progressフィールドにはまだタスクデータが必要です
各バックログアイテムのprogressの割合はサーバーサイドで計算されます からその子タスクを区別します。具体的には、完了したタスクの数と合計数をカウントします。それを行うために、サーバーは Tasks と Tasks.BacklogStatus を読み込む必要があります。
フロントエンドが tasks のリクエストを停止すると、バックエンドはそれらを事前読み込みを停止し、progress はすべてに対して静かに 0 を返します。それは遅いよりも悪いです。
ステップ 3: EnhanceFields パターン
このためには既に確立されたパターンがあります:フィールドインジェクションヘルパー これにより、フロントエンドが何を要求するかに関わらず、必要なプレロードが存在することを保証します。プロジェクト統計とチーム統計のためにも同じアプローチを使用します。
考え方は単純です:リクエストされたフィールドをプレロード層に渡す前に、サーバーサイドの計算に必要なフィールドが存在するか確認します。存在しない場合は、それらをインジェクトします。
func enhanceFields(fields []string) []string {
// If the client didn't ask for "tasks" at all,
// inject the minimum needed for progress calculation.
if !contains(fields, "tasks") {
fields = append(fields, "tasks", "tasks.backlogStatus")
}
return fields
}
リゾルバー内で、このヘルパーは選択セットパーサーとプリロードビルダーの間に位置しています:
func (r *resolver) ListItems(ctx context.Context, ...) {
fields := enhanceFields(getRequestedFields(ctx))
// fields is now guaranteed to include "tasks" + "tasks.backlogStatus",
// but NOT "tasks.assignees" or "tasks.assignees.user"
items := r.repo.FindAll(ctx, filters, fields)
// ...
}
主要な洞察は:リストと詳細ビューには現在非対称なプリロードの深さがあります はまだすべての4レベルを取得しています(フロントエンドがそれを要求するため)。リストビューは2のみを取得します—進捗を計算するのに十分ですが、割り当てデータを引き込むことはなく、表示されません
結果
| 前 | 後 | |
|---|---|---|
| フロントエンドが要求 | tasks { backlogStatus, assignees { user } } |
(タスクを要求しない) |
| バックエンドが事前にロード | タスク → バックログステータス → 担当者 → ユーザー | タスク → バックログステータス |
| プレロードの深さ | 4 | 2 |
| TTFB(67項目) | 675ミリ秒 | 135ミリ秒 |
5倍高速化. ~8ミリ秒/項目の線形劣化は効果的に消えました.
そして一番いいのは:詳細ビューは全く影響を受けません。tasks { ...SubTaskFields }をまだリクエストし、EnhanceFieldsヘルパーはhasTasksが既にtrueである場合にはノーオペレーションです。詳細ページの挙動は全く変わらず。
異なる方法で行うべきこと
このデバッグから得られるいくつかの要点:
TTFBは最初の診断です。 TTFBが項目数に比例して増加する場合、問題はサーバー側です。APIを除外するまでReactのレンダリングをプロファイリングする時間を無駄にしないでください。
FragmentsはUXの便利な機能であり、データ契約ではありません。 Fragmentを異なるフィールドセットをレンダリングする複数のビュー間で共有する瞬間、そのビュー間のパフォーマンスプロファイルの間接的な結合を作成します。
SubTaskFieldsListを検討してください。SubTaskFieldsDetailは一つの共有 Fragment の代わりに使用されます。「サーバーはあなたが要求するものだけをロードする」は、正しく要求すれば機能します。 GraphQL の正確なデータ取得の約束は、クエリ作成者 が正確であることに依存しています。サーバーは忠実に指示されたことを行っています——問題は、あなたが伝えているものにあります。
線形の劣化は小さなデータセットに隠れています。 8ms/アイテムは8件のとき非表示です。100件で壁になります。シードデータのみでテストすると、この種類の問題に気づかないでしょう。本番規模のデータでプロファイルするとカウントされます。
本記事は、タスク管理SaaSのLasimban(Lasimban)における実際のパフォーマンス修正に基づいています。これはスクラムチーム向けに構築されたサービスです。無料で利用できます — クレジットカードは不要です。
Lasimbanを無料で試してみる→lasimban.team
Lasimban - スクラムに焦点を当てたタスク管理ツール
スクラム開発をより直感的で楽しくする。Lasimbanは、スクラムに焦点を当てたタスク管理ツールで、あなたのチームに正しい方向性を示す。
lasimban.team
GraphQLの設定で似たFragment共有の罠に遭遇しましたか?他のチームがリストと詳細の事前ロードの分割をどのように処理しているかについて、聞いてみたいです。












