如何寫好一個應用程式的 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% 的麻煩:
-
路徑用名詞、方法用動詞
GET /users、POST /users、GET /users/:id,不要寫成GET /getUserList。 -
永遠加版本號
/api/v1/users,因為未來你一定會改規格,舊的前端不能說改就改。 -
錯誤回應要有固定格式
{ "error": { "code": "USER_NOT_FOUND", "message": "找不到使用者" } }不要有時候回字串、有時候回物件,前端會很想哭。
-
永遠不要相信前端傳來的資料 就算前端有檔,後端也要自己驗證一次(用
zod或joi)。 -
不要把資料庫的 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、連線池、分頁——其實都不是什麼高深的演算法,而是「養成正確的習慣」。把這些習慣內化之後,再面對更大規模的系統設計(例如分散式架構、訊息佇列、微服務)時,會發現底層的道理其實都是一樣的:不要相信「資料量永遠很小」,也不要相信「流量永遠很穩定」。