在 《上線前的資安檢查清單》 中,我說明了在開放給真實使用者之前需要鎖定的項目。資安是一道「及格/不及格」的門檻:你若沒做好,就會暴露風險。效能則不同。即使儀表板載入需要四秒,也不代表「壞掉」,只是默默流失使用者,而且沒人會為此開單。
這篇文章是《全端 SaaS 大師班》的一部分。這是一個「真實打造」的系列,從 《選擇適合 SaaS 的技術堆疊》 開始,帶領同一個應用程式從空白資料夾一路走向生產環境。上線前效能調校是本模組結束前倒數第二站,之後就是最終的「上線就緒檢查清單」。
我先把話說在前頭:大多數 SaaS 產品在上線時,只有一小部分路徑真正緩慢,其餘則暫時無需在意。上線前的工作,就是找出這一小部分、予以修復,並且不要憑猜測就去優化那些還不重要的部分。把每毫秒都擠出來,可以等以後再做。
先量測,再動手
上線前最常見的效能錯誤,就是憑直覺而非資料來優化。工程師可能認為 ORM 太慢、前端 bundle 太大、或 Redis 應該把所有東西都快取,於是花了一週時間去做。有時他們猜對了,但更多時候真正的瓶頸其實是某張表上缺少索引,而這是沒人想到要去檢查的。
在碰程式碼之前,請先為關鍵使用者流程取得三個數字:伺服器回應時間、資料庫查詢時間,以及前端「首次有意義繪製」的時間。上線階段你不需要高級 APM,只要有帶 duration 欄位的結構化請求日誌,再搭配 PostgreSQL 的 pg_stat_statements,就能掌握幾乎所有資訊。
-- 啟用一次,之後隨時可查詢找出最慢的查詢
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode
這個單一檢視對上線前的效能改善,勝過任何憑空猜測的調校。它依據總執行時間排序查詢,能同時找出真正緩慢的查詢,以及雖然單次很快但每小時執行上萬次的查詢。請依此順序修復。
資料庫幾乎永遠是第一個瓶頸
對於以 Postgres 為後端的典型 SaaS,資料庫才是回應時間真正耗費的地方。其中大多數來自兩種模式:N+1 查詢,以及外鍵或篩選欄位缺少索引。
N+1 查詢多半透過 ORM 而非原生 SQL 悄悄混入,因為抽象層把迴圈隱藏起來。以下是多租戶 NestJS 應用搭配 TypeORM 或 Prisma 時經常出現的模式:
// 壞做法:每個組織都額外查一次擁有者
async function getOrganizationsWithOwners(orgIds: string[]) {
const orgs = await this.orgRepository.findByIds(orgIds);
return Promise.all(
orgs.map(async (org) => {
const owner = await this.userRepository.findOne({ where: { id: org.ownerId } });
return { ...org, owner };
}),
);
}
Enter fullscreen mode Exit fullscreen mode
// 較佳做法:一次查詢,使用 join 或關聯載入
async function getOrganizationsWithOwners(orgIds: string[]) {
return this.orgRepository.find({
where: { id: In(orgIds) },
relations: { owner: true },
});
}
Enter fullscreen mode Exit fullscreen mode
修復方法通常不複雜,就是「把你早已知道需要的關聯一次載入,而不是在迴圈裡重複抓取」。上線前特別值得檢查的原因是:N+1 模式在十筆資料時看不出問題,到了上萬筆就會變得很痛苦。你的測試環境資料幾乎永遠不夠多,導致這個問題往往在第一批真實客戶匯入資料後才爆發。
索引是第二個重要手段,經驗法則很簡單:凡是你會用來篩選或 join 的外鍵,以及在會成長到數千筆以上的資料表中出現在 WHERE 子句的欄位,都應該建立索引。對於多租戶結構,這幾乎總是包含 tenant_id 或 organization_id,因為應用程式幾乎所有查詢都會依此篩選。
-- 多租戶資料表中最常見查詢模式的複合索引
CREATE INDEX idx_invoices_org_created_at
ON invoices (organization_id, created_at DESC);
Enter fullscreen mode Exit fullscreen mode
上線前,請針對 pg_stat_statements 列出的前五名查詢執行 EXPLAIN ANALYZE。若在即將承載真實流量的資料表上看到 sequential scan,這就是最清楚的訊號。
這裡也值得一提連線池。它在上線週出問題的機率,高於單純的慢查詢。每個 Postgres 連線都會占用伺服器記憶體,而 NestJS 應用在負載下若每個請求都開新連線且無限制,很快就會耗盡預設的連線池。請明確設定合理的 pool size(TypeORM 與 Prisma 都有提供此設定),不要依賴預設值。當你有超過一個後端 instance 時,請在 Postgres 前方加入 PgBouncer,讓連線得以共用而非隨程序倍增。
為有理由而非習慣而快取
Redis 在 技術堆疊 中占有一席之地,正是為了這種時刻:昂貴、經常被讀取、且不需要絕對即時的查詢。這才是你應該找尋的快取對象。快取每次寫入都變動、或很少有人讀取的資料,只會增加失效複雜度,卻沒有實際好處。
cache-aside 模式足以涵蓋大多數上線前的快取需求,而且在壓力下也容易理解:
async function getDashboardSummary(orgId: string): Promise<DashboardSummary> {
const cacheKey = `dashboard:summary:${orgId}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as DashboardSummary;
}
const summary = await this.computeDashboardSummary(orgId);
await this.redis.set(cacheKey, JSON.stringify(summary), 'EX', 60);
return summary;
}
Enter fullscreen mode Exit fullscreen mode
短暫的 TTL(本例為 60 秒)就能帶來大部分效益,同時不需要嚴格的失效策略。若底層資料已變動,而儀表板只過時一分鐘,對摘要畫面來說是可接受的權衡;但對帳戶餘額來說就不可接受。
必須直接點出的陷阱是:快取不能取代修復慢查詢或缺少索引。我曾見過團隊快取一個走未索引 scan 的查詢,這樣快取命中時問題被隱藏,快取未命中或冷啟動時問題又完全暴露。請先修復查詢,再決定是否值得快取。
前端效能主要取決於你傳送什麼,而非執行多快
在 Next.js 這邊,上線前最大的贏面幾乎永遠是減少傳送到瀏覽器的內容,而不是微調渲染效能。一個儀表板若為只有三個小工具的頁面傳送 400KB 的 JavaScript,在中階筆電上就會感覺緩慢,在手機上則會更糟,無論 React 元件寫得多好都一樣。
以下是上線前值得檢查的重點:
-
預設使用 Server Components。 若使用 App Router,請盡量把互動式 client component 放在樹狀結構較低的位置,且保持精簡。不要因為一個按鈕需要
onClick,就把'use client'放在頁面最上方。 - 對沉重且非關鍵的元件使用動態 import。 圖表函式庫、富文字編輯器、PDF 產生器是常見的罪魁禍首。請按需載入,而非放在初始 bundle 中。
-
使用者面向的內容使用
next/image。 它預設處理響應式尺寸與 lazy loading,對行銷頁面與儀表板都能大幅改善感知載入時間。 - 資料允許時使用 Static 或 ISR。 行銷頁面、定價頁面與公開文件幾乎不需要每次請求都伺服器渲染。靜態生成搭配 revalidation,能從關鍵路徑移除整個請求-回應循環。
// 延後載入沉重且非關鍵的元件,而非預先打包
import dynamic from 'next/dynamic';
const BillingChart = dynamic(() => import('./billing-chart'), {
loading: () => <ChartSkeleton />,
ssr: false,
});
Enter fullscreen mode Exit fullscreen mode
這些都不需要效能專家,只需要有人真正打開網路分頁,查看真實使用者最常造訪的頁面——對 SaaS 來說,通常是登入流程、主儀表板,以及驅動核心價值的頁面。
在真實使用者之前先做負載測試
上線前是唯一能安全找到系統極限、而不讓真實客戶撞到的時機。對測試環境進行基本負載測試,針對最重要的兩、三個端點,就能知道系統在何處崩潰,以及該上限是否高於預期的上線流量。
# 使用 autocannon 對測試環境端點進行快速負載測試
npx autocannon -c 50 -d 30 -m POST \
-H "Content-Type: application/json" \
-b '{"email":"[email protected]","password":"test1234"}' \
https://staging.example.com/api/auth/login
Enter fullscreen mode Exit fullscreen mode
請關注結果的形狀,而非單純的每秒請求數。這個數字完全取決於你的基礎設施,我不會在這裡虛構一個數字。當並行度上升時,延遲是保持平穩,還是會突然斷崖式下跌?斷崖會清楚告訴你連線池、速率限制器或事件迴圈在哪裡耗盡資源。在負載測試中找到問題,遠比在第一次流量高峰中發現要便宜得多。
重點摘要
- 先量測再優化。
pg_stat_statements與基本請求計時能找到真正的瓶頸;猜測通常會找錯目標。 - N+1 查詢與缺少索引,是典型 SaaS 資料庫緩慢的主要原因,而且在真實資料量暴露出問題前都很難察覺。
- 明確設定連線池大小,當後端 instance 超過一個時,請在 Postgres 前方加入 PgBouncer。
- 使用 Redis 快取昂貴、經常讀取、且能容忍短暫過時的資料。先修復底層查詢,再決定是否快取。
- 前端方面,減少傳送的內容(動態 import、Server Components、
next/image、靜態生成)通常比微調渲染程式碼更有效。 - 上線前先對測試環境進行基本負載測試,讓你能主動找到系統上限。
常見問題
上線前如何知道 Postgres 查詢是否緩慢?
啟用 pg_stat_statements,並依總執行時間(而非平均單次時間)查詢。單次快速但頻繁執行的查詢,總成本可能高於偶爾才跑的慢查詢。針對前幾名結果執行 EXPLAIN ANALYZE,確認執行計畫是否在應該使用索引的地方進行 sequential scan。
NestJS 應用搭配 Postgres 時,連線池大小應該設多少?
沒有通用數字,因為這取決於資料庫的 max_connections 設定,以及你會啟動多少個應用程式 instance。請保守起見,明確設定 pool size 而非依賴 ORM 預設值;當你有超過一個後端 instance 時,請在 transaction mode 下加入 PgBouncer,讓連線得以共用而非隨程序倍增。
即使還不需要,是否應該在上線前就加入 Redis 快取?
不需要。請在你已經確認有昂貴、經常讀取、且能容忍短暫過時的查詢時再加入。過早加入快取只會增加失效複雜度,卻沒有可量測的效益,還可能掩蓋你仍需要修復的查詢問題。
上線前實際需要做多少前端效能工作?
請專注於傳送到瀏覽器的內容:保持 client component 精簡、動態 import 沉重且非關鍵的小工具、使用 next/image,以及對不需要每次請求資料的頁面進行靜態生成。這通常比微調元件渲染效能更重要。
沒有專門的效能團隊時,有什麼合理的方式在上線前進行負載測試?
使用 autocannon 或 k6 針對測試環境中流量最高的兩、三個端點進行測試,就足以找出延遲開始隨並行度上升而攀升的位置。你要觀察的是曲線的形狀(平緩上升 vs 斷崖式下跌),而非追求特定的數字。
上線時若仍有部分頁面較慢,是否是問題?
不一定。上線前的目標是修復真實使用者經常使用的流程,例如登入與主儀表板。一個很少使用的設定頁面載入需要 1.5 秒,與其在上線週追逐這個問題,不如先完成出貨,這是合理的權衡。
延伸閱讀
- 上一篇:《上線前的資安檢查清單》
- 下一篇:《上線就緒檢查清單》
- 系列起點:《選擇適合 SaaS 的技術堆疊》
關於作者
嗨,我是 Aman Singh——資深全端工程師,專長於可擴展的 SaaS 產品、分散式系統、雲端架構,以及 AI 驅動的應用程式。
我撰寫關於系統設計、全端工程、分散式系統、Redis、PostgreSQL、AWS、Node.js 與 NestJS 的文章。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.