系統設計與架構學習筆記
目標:從「寫得出一個 API」進化到「設計一套撐得住流量、好維護、好擴展的系統」。
系統設計的思考框架
正確順序:先釐清需求邊界,再談技術選型
新手常見錯誤:一聽到需求就馬上想技術方案。應該先問清楚:
第一步:Functional Requirements(功能性需求)——這個系統要做什麼(例如:註冊登入、發文、加標籤)。
第二步:Non-Functional Requirements(非功能性需求)——這才是系統設計的核心,因為同樣功能在不同規模/限制下架構完全不同:
- 流量估算:預期同時上線使用者數?每秒請求數(QPS)?
- 讀寫比例:讀多寫少(部落格)還是讀寫接近(聊天軟體)?
- 一致性要求:可以延遲幾秒(按讚數)還是必須絕對即時(轉帳)?
- 可用性要求:能接受掛幾分鐘,還是要求 99.99% 不中斷?
為什麼不能跳過:如果系統一天只有 10 個人用,根本不需要讀寫分離、Redis 快取,過早引入反而增加不必要的複雜度。答案永遠是「在這個規模、這些限制下最合理的取捨」,沒有標準答案。
案例:設計一個限流器(Rate Limiter)
釐清需求
- 限流規則?(例如每人每分鐘 60 次)
- 單機還是多機部署?(答案會完全改變技術選型)
- 超過限制怎麼回應?
單機版(記憶體實作,有致命限制)
// middlewares/rateLimiter.js —— 用記憶體存計數,只適合單機環境
const requestCounts = new Map(); // key: userId, value: { count, resetAt }
const rateLimiter = (maxRequests, windowMs) => {
return (req, res, next) => {
const userId = req.userId || req.ip;
const now = Date.now();
const record = requestCounts.get(userId);
if (!record || now > record.resetAt) {
requestCounts.set(userId, { count: 1, resetAt: now + windowMs });
return next();
}
if (record.count >= maxRequests) {
return res.status(429).json({ status: 'error', message: '請求太頻繁,請稍後再試' });
}
record.count++;
next();
};
};
致命限制:如果服務跑了兩台以上伺服器,每台記憶體各自獨立,請求被隨機分配到不同伺服器,限流規則完全失效。這正是「非功能性需求(會不會水平擴展)直接改變技術選型」的例子。
多機版(Redis 共用狀態)
import redisClient from '../lib/redis.js';
const rateLimiter = (maxRequests, windowMs) => {
return async (req, res, next) => {
const userId = req.userId || req.ip;
const key = `ratelimit:${userId}`;
// INCR 是原子操作:不管幾台伺服器同時呼叫,都不會互相蓋掉結果
const count = await redisClient.incr(key);
if (count === 1) {
await redisClient.expire(key, windowMs / 1000); // 第一次請求,設定時間窗口
}
if (count > maxRequests) {
return res.status(429).json({ status: 'error', message: '請求太頻繁,請稍後再試' });
}
next();
};
};
核心原則:不管幾台伺服器,只要都問同一個 Redis,規則就能保持一致——用「共用的外部狀態」取代「各自獨立的記憶體狀態」,這個模式會在很多系統設計情境反覆出現。
分層架構(Layered Architecture):Controller / Service / Repository
為什麼要拆
原本 route 檔案裡混雜了「解析請求、業務邏輯、資料庫查詢、HTTP 回應」全部擠在一起,導致:
- 沒辦法脫離 Express 單獨測試業務邏輯
- 同樣邏輯若要被排程工作或 CLI 呼叫,得整段複製貼上
- 功能變複雜時,route 函式越寫越肥大
三層職責與依賴方向
依賴方向永遠是「上層呼叫下層」,下層完全不知道上層存在:
Router → Controller → Service → Repository → 資料庫
Repository:只做資料庫存取,不做業務判斷
// repositories/postRepository.js
const postWithRelations = {
include: { author: { select: safeUserSelect }, tags: true },
};
export const findManyPosts = ({ skip, take }) => {
return prisma.post.findMany({ skip, take, orderBy: { id: 'asc' }, ...postWithRelations });
};
export const findPostById = (id) => prisma.post.findUnique({ where: { id }, ...postWithRelations });
export const createPost = ({ title, content, authorId, tagIds }) => { /* ... */ };
原則:
- 函式命名要精準對應查詢意圖(
findPostsByAuthorId),不要無腦通用(findPosts(id, authorId, isPublished, ...)) - 回傳結果永遠是「資料庫查詢的直接結果」,不該有 if/else 二次加工判斷——那屬於 Service
- 把常用的
select/include組合抽出來重複使用 - 複雜原生 SQL(
$queryRaw)也該包在這一層,不能讓 SQL 字串出現在 Service/Controller - 檢查清單:這個函式的參數是查詢條件而非業務規則?回傳沒有夾帶額外判斷?抽到別的專案還能直接用?
Service:業務邏輯與規則判斷
// services/postService.js
export const updateExistingPost = async ({ postId, userId, data }) => {
const post = await postRepository.findPostById(postId);
if (!post) throw createAppError(`找不到 id 為 ${postId} 的文章`, 404);
if (post.authorId !== userId) {
throw createAppError('你沒有權限編輯這篇文章', 403); // 權限檢查是業務邏輯,屬於這一層
}
// ...
};
該出現的東西:
- 業務規則判斷(權限檢查、狀態轉換規則)
- 跨多個 Repository 的協調(例如用
$transaction同時操作多張表) - 資料轉換與組裝(把 Repository 拿回的原始資料加工成業務需要的形狀)
組織原則:
- 一個 Service 檔案對應一個「業務領域」,不是對應一張資料表(例如訂閱功能可能橫跨 User/Subscription/Payment,仍屬同一個 service)
- 函式名稱要讀出「業務動作」,不是資料庫操作(
updateExistingPost而非updatePost) - 檔案肥大時用子資料夾拆分,依據是「這些函式改動的原因是否相同」(單一職責原則)
容易犯的錯:Service 不該洩漏 HTTP 痕跡(不該回傳 { status: 404 } 這種格式),該用 throw createAppError(...) 拋出語意化錯誤,讓外層決定怎麼轉成 HTTP 回應。
Controller:只處理 HTTP 相關的事
// controllers/postController.js
export const updatePostController = catchAsync(async (req, res, next) => {
const postId = Number(req.params.id); // 1. 拆解 req
const updatedPost = await postService.updateExistingPost({ // 2. 呼叫 Service
postId, userId: req.userId, data: req.body,
});
successResponse(res, 200, updatedPost); // 3. 包裝 HTTP 回應
});
三個步驟:拆解 req → 呼叫 Service → 包裝回應。不該出現:prisma.xxx、業務規則判斷、複雜資料轉換邏輯。
判斷測試:「如果把 Express 整個拔掉,這行程式碼還有意義嗎?」有意義(如權限檢查)→ 該搬去 Service;沒意義(如 req.query、res.json)→ 才屬於 Controller。
Router:只管路由配置
// routes/posts.js
router.get('/', getPostsController);
router.post('/', authenticate, validate(createPostSchema), createPostController);
router.patch('/:id', authenticate, updatePostController);
router.delete('/:id', authenticate, deletePostController);
職責:定義 URL/method 對應、決定 middleware 掛載順序、交給哪個 controller。不該有 try/catch、業務判斷,也不該直接呼叫 Repository。
可用 routes/index.js 統一管理所有 router 掛載,方便統一加上全域 middleware(如速率限制)。
訊息佇列(Message Queue)
適用情境
使用者不需要「同步等待」完成的任務:寄送歡迎信、記錄分析事件、通知 CRM 等。同步執行會讓使用者等待這些慢動作,甚至因某個外部服務逾時而整體失敗。
核心概念
- Producer(生產者):API 伺服器,負責把任務丟進佇列
- Worker(工作者):獨立背景程式,負責從佇列取出任務執行
好處:API 回應變快、失敗不影響主流程(可重試)、解耦(Worker 可獨立開發/部署/擴展)。
動手:BullMQ(基於 Redis)
npm install bullmq
// queues/emailQueue.js
import { Queue } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
export const emailQueue = new Queue('email', { connection });
// Service 層:把「寄信」改成「丟進佇列」,add() 幾乎瞬間完成
await emailQueue.add('send-welcome-email', { to: newUser.email, name: newUser.name });
// queues/emailWorker.js —— 獨立 process,需另開終端機執行 node queues/emailWorker.js
import { Worker } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const emailWorker = new Worker('email', async (job) => {
if (job.name === 'send-welcome-email') {
const { to, name } = job.data;
// 實際寄信邏輯(SendGrid、AWS SES 等)
}
}, { connection });
emailWorker.on('completed', (job) => console.log(`任務 ${job.id} 完成`));
emailWorker.on('failed', (job, err) => console.error(`任務 ${job.id} 失敗:`, err.message));
失敗重試
await emailQueue.add('send-welcome-email', data, {
attempts: 3, // 最多重試 3 次
backoff: { type: 'exponential', delay: 1000 }, // 指數退避,避免立刻重試又立刻失敗
});
適合 vs 不適合丟進佇列
| 適合 | 不適合 |
|---|---|
| 寄送 email/簡訊 | 使用者需要立刻看到結果(查詢、登入) |
| 產生報表、匯出大量資料 | 需要絕對即時、絕對成功才能繼續(付款扣款) |
| 呼叫不需立即得知結果的第三方服務 | |
| 圖片/影片轉檔等耗時運算 |
核心心法:把「使用者在乎的事」跟「不需要等待的事」解耦,讓主流程更快回應、不被慢速周邊服務拖垮。
單體架構 vs 微服務架構
單體架構(Monolith)
所有功能在同一個專案、同一個資料庫、打包成一個服務部署。
優點:開發簡單直覺、部署容易、debug 容易(所有邏輯在同一 process)。
缺點:任何小改動要重新部署整個應用;沒辦法針對單一功能獨立擴展;技術選型被綁死。
微服務架構(Microservices)
拆成多個獨立服務(例如 User Service、Post Service、Notification Service),各自有自己的資料庫、部署流程,透過網路(HTTP/訊息佇列)互相溝通。
優點:可針對流量大的服務單獨擴展;團隊可獨立開發部署;單一服務掛掉理論上不拖垮全部。
缺點(常被忽略):
- 分散式系統複雜度暴增:網路呼叫可能斷線、逾時、部分失敗,這在單體架構裡不存在
- 資料一致性困難:無法用單一資料庫交易保證多服務間的原子性,需要更複雜機制(如 Saga Pattern)
- 維運成本大幅提升:監控、部署、除錯的服務數從 1 變 N
該怎麼選:Monolith First
多數新創或中小型專案,一開始就選微服務是過度工程(Over-engineering)。業界共識路徑:
先用單體架構把產品做出來 → 隨著團隊規模/流量真的成長到單體撐不住 → 再有計畫地把最需要獨立的部分拆出來
過早引入微服務複雜度,往往拖垮的是還在摸索方向、資源有限的早期團隊。
API Gateway
微服務架構下的「統一入口」,前端只需打一個網址,Gateway 負責:
- 路由轉發:依請求路徑轉發給對應內部服務(
/users/*→ User Service) - 統一處理橫切關注點:身分驗證、速率限制通常放在這一層,而非每個服務各自實作
- 隱藏內部架構:外部不需知道背後有幾個微服務
負載平衡(Load Balancer)
當服務跑了多個實例,需要機制決定「請求該分配給哪一台」:
- Round Robin(輪詢):依序輪流分配
- Least Connections(最少連線數):分配給處理中請求最少的一台,適合請求處理時間差異大的情境
- 健康檢查(Health Check):定期偵測伺服器是否存活,故障時自動導向其他存活伺服器
與 Rate Limiter 呼應:流量被負載平衡分散到多台伺服器時,限流狀態必須放在 Redis 這種外部共用服務,否則每台伺服器各自計數會導致規則失效。
核心心法總結
架構沒有「最好的答案」,只有「最適合當下規模與限制的答案」。單體架構不是落後技術,微服務也不是萬靈丹。過度設計(為不存在的規模先做準備)跟設計不足(撐不住才臨時抱佛腳)都是常見陷阱,好的架構判斷力,在於準確評估「現在該不該做這件事」。