感覺有點慢
我們的產品積壓清單頁面反應遲鈍。沒有明顯崩潰 — 只是… 不對勁。那種在你從一個新鮮的示範租戶切換到一個有實際數據的真實租戶時才會注意到的緩慢感.
所以我測量了一下:
- 8 項目206毫秒 TTFB (首個字節的響應時間)
- 67 項目675毫秒 TTFB
那樣看來+469ms 差異,或大約為8ms 每項的線性退化。每一項延遲都增加了 ~8ms 到回應時間。不怎麼好.
第一個直覺是責怪前端 — 或許組件樹在過度重新渲染。但TTFB立即排除了這個可能性。瀏覽器甚至還沒開始渲染;服務器回應花了那麼長的時間。
所以瓶頸在API伺服器與資料庫之間。是時候深入挖掘了
我們的預載運作方式
我們在Cloud Run上運行Go + gqlgen + GORM,與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 預加載可能增加 64 毫秒。這只是噪音。但一旦達到 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:Theprogress這個領域仍然需要任務數據
這個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 },而且EnhanceFieldshelper 是一個無操作當前hasTasks這已經是事實了。詳細頁面沒有任何行為變化。
我會做不同的選擇
從這次除錯中得到的幾點心得:
TTFB 是你的第一個診斷。 若 TTFB (Time to First Byte) 與項目數量成線性關係,問題在於伺服器端。在你排除 API 問題之前,不要浪費時間分析 React 渲染。
片段是為了提升開發者體驗 (DX) 而設,不是數據合約。 當你在渲染不同字段集的視圖之間共享片段時,你就在這些視圖的性能表現之間建立了無形的耦合。考慮使用
SubTaskFieldsList和SubTaskFieldsDetail而不是一個共享的 Fragment."伺服器只載入您要求的部分" 只有在您正確要求時才有效. GraphQL 精確數據抓取的承諾依賴於 查詢作者 的精確性。伺服器忠實地執行它被告知的內容 — 問題始終在於您告訴它的內容.
線性退化隱藏在小數據集中。 8ms/項目在8個項目時不可見。它在100個時像一堵牆。如果你只用種子數據測試,你永遠不會抓到這類問題。用生產規模數據進行分析。
這篇文章基於一個真實的性能修復,發生在Lasimban,一個為Scrum團隊構建的任務管理SaaS。它是免費使用的 — 不需要信用卡。
你在 GraphQL 設定中是否遇到過類似的 Fragment 共享陷阱?我很想知道其他團隊如何處理清單與細節預載的拆分。












