跳至主要内容

如何寫好一個應用程式的 API:用 Node.js + PostgreSQL(Prisma)打造能扛流量的後端

適合對象:剛開始寫後端 API、想知道「為什麼正式環境會出事」的初學者。 技術棧:Node.js + Express(或 Fastify)+ PostgreSQL + Prisma ORM


一、為什麼「能跑」跟「能用」是兩件事

很多人寫 API 的第一步都是:

app.get('/users', async (req, res) => {
const users = await prisma.user.findMany();
res.json(users);
});

在自己電腦上測試,三秒就回來,看起來完全沒問題。

但是同一支 API,如果突然有 10,000 個前端同時呼叫,或者資料庫裡的 users 表已經有 500 萬筆資料,這支「完全沒問題」的程式碼,會在幾秒內把整個系統拖垮。

這篇文章會用實際會踩到的坑,帶你一步步理解:一個「能跑」的 API,要怎麼變成一個「在世界級流量下也能撐住」的 API。


二、基本環境:Node.js + Prisma + PostgreSQL

先快速建立一下基礎,後面的坑都建立在這個架構上。

npm init -y
npm install express prisma @prisma/client
npx prisma init --datasource-provider postgresql

schema.prisma 範例:

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}

model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())

@@index([authorId])
}

記得:有外鍵關聯的欄位,幾乎都要加 @@index,這是後面效能問題的第一道防線,先記住,後面會解釋為什麼。


三、API 設計的基本原則(淺白版)

寫 API 之前,先建立幾個習慣,可以省掉未來 80% 的麻煩:

  1. 路徑用名詞、方法用動詞 GET /usersPOST /usersGET /users/:id,不要寫成 GET /getUserList

  2. 永遠加版本號 /api/v1/users,因為未來你一定會改規格,舊的前端不能說改就改。

  3. 錯誤回應要有固定格式

    { "error": { "code": "USER_NOT_FOUND", "message": "找不到使用者" } }

    不要有時候回字串、有時候回物件,前端會很想哭。

  4. 永遠不要相信前端傳來的資料 就算前端有檔,後端也要自己驗證一次(用 zodjoi)。

  5. 不要把資料庫的 model 直接丟給前端 密碼欄位、內部欄位千萬別漏出去,要自己組一層「回應格式」。

這些原則做好,API 就有了「骨架」。但接下來才是真正的硬仗:當呼叫量上升、資料量上升時,會發生什麼事


四、採坑案例一:沒有限流,API 被「打爆」

問題情境

假設你寫了一個查詢天氣的 API,前端首頁載入時呼叫一次。某天某個前端工程師寫了個 bug,按鈕被綁定成「每次滑鼠移動都呼叫一次」,或者有個機器人程式對你的 API 狂打。

結果:你的 Node.js process CPU 衝到 100%,資料庫連線全部被佔滿,連正常用戶的請求都進不來。

這種狀況俗稱被打爆,原因往往不是「流量真的很大」,而是完全沒有任何防護機制

解法 1:基本限流(Rate Limiting)

最簡單的做法,用 express-rate-limit

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 分鐘
max: 100, // 每個 IP 最多 100 次
standardHeaders: true,
legacyHeaders: false,
message: { error: { code: 'TOO_MANY_REQUESTS', message: '請求太頻繁,請稍後再試' } },
});

app.use('/api/v1/', apiLimiter);

這樣可以擋住「單一來源狂打」的狀況。但要注意:如果你的服務是跑多台機器(多個 instance),這種記憶體型的限流是各自獨立計數的,沒辦法共享狀態,所以流量大一點還是會被繞過。

解法 2:用 Redis 做「跨機器共享」的限流

當你的服務不只一台機器時,要把限流的計數放到大家都能看到的地方,例如 Redis:

npm install rate-limit-redis ioredis
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redisClient = new Redis(process.env.REDIS_URL);

const apiLimiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
}),
windowMs: 60 * 1000,
max: 100,
});

這樣不管請求被分到哪一台機器,限流計數都是「全體共享」的,才是真正有效的防護。

解法 3:分層限流——不是所有 API 都一樣重要

實務上不會用一個規則打全部 API。常見作法:

  • 登入 / 註冊 API:限制更嚴格(例如每分鐘 5 次),防止暴力破解密碼。
  • 查詢類 API(GET):可以放寬一點,但仍要有上限。
  • 昂貴的運算類 API(例如報表產出、AI 運算):用「排隊系統」而不是直接限流,超過上限直接丟到背景工作(queue),而不是讓使用者等到逾時。

重點觀念:限流不是要「擋住用戶」,而是要保護系統,讓多數正常用戶不會因為少數異常流量而連坐受害。


五、採坑案例二:N+1 查詢,把資料庫拖慢的隱形殺手

這是 Prisma(或任何 ORM)新手最容易踩的坑,因為寫起來完全沒有錯誤訊息,只是「慢」。

問題程式碼

// 取得所有文章,並顯示作者名字
app.get('/api/v1/posts', async (req, res) => {
const posts = await prisma.post.findMany();

const result = await Promise.all(
posts.map(async (post) => {
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
return { ...post, authorName: author.name };
})
);

res.json(result);
});

看起來合理,對吧?但如果有 1000 篇文章,這段程式碼會對資料庫送出 1(查文章)+ 1000(查每個作者)= 1001 次查詢

這就是經典的 N+1 問題:原本一次查詢可以解決的事,因為迴圈裡又呼叫資料庫,變成 N 次額外查詢。在本機測試資料量小,感覺不出來;上線後資料量一大,這支 API 的回應時間會從 50ms 暴增到幾秒甚至逾時。

解法:用 Prisma 的 include 一次撈出關聯資料

app.get('/api/v1/posts', async (req, res) => {
const posts = await prisma.post.findMany({
include: {
author: { select: { id: true, name: true } },
},
});

res.json(posts);
});

這樣 Prisma 會用 一次(或極少次)的 SQL JOIN 把作者資料一起撈出來,徹底解決 N+1 問題。

口訣:在迴圈裡看到 await prisma.xxx.findXxx,就要警覺,問自己「這個資料能不能一次撈出來?」


六、採坑案例三:資料庫連線被用光(Connection Pool Exhausted)

問題情境

Node.js 是非同步的,理論上一台機器可以同時處理上千個請求。但 PostgreSQL 的連線數是有限的(預設常見上限是 100),如果每個請求都「各自建立一條新的資料庫連線」,流量一大,資料庫會直接回你:

Error: too many clients already

解法 1:用 Prisma 內建的連線池,而不是每次手動 new PrismaClient()

新手最常犯的錯:

// ❌ 錯誤示範:每個 request 都建立一個新的 PrismaClient
app.get('/api/v1/users', async (req, res) => {
const prisma = new PrismaClient(); // 每次都是新的連線池!
const users = await prisma.user.findMany();
res.json(users);
});

正確做法是全域只建立一個 PrismaClient,讓它在背後自己管理連線池:

// db.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;
// 其他檔案直接 require 這個共用的 instance
const prisma = require('./db');

app.get('/api/v1/users', async (req, res) => {
const users = await prisma.user.findMany();
res.json(users);
});

解法 2:流量真的很大時,前面加一層 PgBouncer

如果你的服務有多台機器,每台都各自維持一個連線池,加起來還是可能超過資料庫上限。這時業界常見做法是在 Node.js 和 PostgreSQL 之間,加一層 PgBouncer(連線池代理):

[Node.js 服務 x N台] → [PgBouncer] → [PostgreSQL]

PgBouncer 會把成千上萬個「應用程式端連線」,收斂成資料庫端真正需要的少量連線,大幅降低資料庫的負擔。Prisma 官方也建議搭配 PgBouncer 使用,並在連線字串加上 ?pgbouncer=true 這類參數來配合它的交易模式。


七、採坑案例四:分頁方式錯誤,資料一多就爆炸

問題程式碼:OFFSET 分頁

// 第 10000 頁,每頁 20 筆
app.get('/api/v1/posts', async (req, res) => {
const page = Number(req.query.page) || 1;
const posts = await prisma.post.findMany({
skip: (page - 1) * 20,
take: 20,
orderBy: { id: 'asc' },
});
res.json(posts);
});

這種寫法叫 OFFSET 分頁,當資料量小的時候完全沒問題。但當你的表有幾百萬筆資料,使用者翻到第 10000 頁時,資料庫還是得先掃過前面 199,980 筆資料,再丟掉它們,只取你要的 20 筆。資料越後面的頁碼,查詢越慢。

解法:用 Cursor 分頁(游標分頁)

不要問「第幾頁」,而是問「上一批資料的最後一筆是誰,給我接下來的 20 筆」:

app.get('/api/v1/posts', async (req, res) => {
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;

const posts = await prisma.post.findMany({
take: 20,
skip: cursor ? 1 : 0, // 跳過游標本身那一筆
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'asc' },
});

const nextCursor = posts.length === 20 ? posts[posts.length - 1].id : null;

res.json({ data: posts, nextCursor });
});

因為查詢條件是 id > 上次的最後一個 id,配合 id 上的索引,資料庫可以直接「跳」到正確的位置,不需要掃過前面所有資料。不管是第 2 頁還是第 10000 頁,查詢速度幾乎一樣快。這也是 Facebook、Twitter 這類大型服務「無限滾動」功能背後常用的分頁方式。


八、面對「世界級用戶量」該怎麼整體佈局

把前面四個坑串起來,一個能撐住高流量的後端架構大概會長這樣:

                ┌────────────┐
使用者請求 → │ CDN / WAF │ ← 擋掉靜態資源請求、明顯惡意流量
└─────┬──────┘

┌────────────┐
│ 負載平衡器 │ ← 把流量分配到多台 Node.js 服務
└─────┬──────┘

┌─────────────┴─────────────┐
▼ ▼
┌────────────┐ ┌────────────┐
│ Node.js #1 │ │ Node.js #2 │ ... 多台水平擴展
│ (含限流) │ │ (含限流) │
└─────┬──────┘ └─────┬──────┘
│ 共享 Redis(限流、快取) │
└──────────────┬───────────────┘

┌────────────┐
│ PgBouncer │ ← 收斂資料庫連線數
└─────┬──────┘

┌────────────┐
│ PostgreSQL │ ← 加好索引、必要時設唯讀複本分流查詢
└────────────┘

幾個關鍵概念,用淺白的方式理解:

  • 水平擴展(Scale out):流量大就多開幾台 Node.js 服務,而不是把一台機器升級到無限大(那樣總有極限,也很貴)。
  • 無狀態服務(Stateless):每台 Node.js 服務不應該在自己記憶體裡存「使用者狀態」,否則沒辦法隨意加開機器。狀態要放共享的地方,例如 Redis。
  • 快取(Cache):常被查詢、不常變動的資料(例如熱門文章列表),放進 Redis,不要每次都打資料庫。
  • 唯讀複本(Read Replica):流量大到一台資料庫扛不住時,可以設置「唯讀複本」,把查詢類請求導到複本,寫入類請求才打到主資料庫。
  • 索引(Index):所有「常被拿來查詢、排序、JOIN」的欄位都要有索引,這是最便宜、效益最高的效能優化,前面 schema 裡的 @@index([authorId]) 就是這個道理。

九、簡單的自我檢查清單

寫完一支 API,可以照這個清單檢查一輪:

  • 這支 API 有沒有做輸入驗證?
  • 這支 API 有沒有限流?是不是重要的 API(登入、付款)限制更嚴格?
  • 程式碼裡有沒有在迴圈中呼叫資料庫(N+1 問題)?
  • PrismaClient 是不是全域共用一個 instance?
  • 分頁邏輯,資料量大時還會不會變慢?
  • 常查詢的欄位,資料庫有沒有加索引?
  • 錯誤訊息有沒有洩漏不該洩漏的內部資訊(例如 SQL 錯誤、檔案路徑)?

十、結語

寫 API 的第一步是「讓它動起來」,但真正的工程能力,是在它被狂打、資料變多、使用者變多的時候,依然穩穩地動著

這篇文章提到的每一個坑——限流、N+1、連線池、分頁——其實都不是什麼高深的演算法,而是「養成正確的習慣」。把這些習慣內化之後,再面對更大規模的系統設計(例如分散式架構、訊息佇列、微服務)時,會發現底層的道理其實都是一樣的:不要相信「資料量永遠很小」,也不要相信「流量永遠很穩定」。