《上線前的資安檢查清單》 中,我說明了在開放給真實使用者之前需要鎖定的項目。資安是一道「及格/不及格」的門檻:你若沒做好,就會暴露風險。效能則不同。即使儀表板載入需要四秒,也不代表「壞掉」,只是默默流失使用者,而且沒人會為此開單。

這篇文章是《全端 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_idorganization_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 秒,與其在上線週追逐這個問題,不如先完成出貨,這是合理的權衡。

延伸閱讀


關於作者

嗨,我是 Aman Singh——資深全端工程師,專長於可擴展的 SaaS 產品、分散式系統、雲端架構,以及 AI 驅動的應用程式。

我撰寫關於系統設計、全端工程、分散式系統、Redis、PostgreSQL、AWS、Node.js 與 NestJS 的文章。