跳至主要内容

系統設計與架構學習筆記

目標:從「寫得出一個 API」進化到「設計一套撐得住流量、好維護、好擴展的系統」。


系統設計的思考框架

正確順序:先釐清需求邊界,再談技術選型

新手常見錯誤:一聽到需求就馬上想技術方案。應該先問清楚:

第一步:Functional Requirements(功能性需求)——這個系統要做什麼(例如:註冊登入、發文、加標籤)。

第二步:Non-Functional Requirements(非功能性需求)——這才是系統設計的核心,因為同樣功能在不同規模/限制下架構完全不同:

  • 流量估算:預期同時上線使用者數?每秒請求數(QPS)?
  • 讀寫比例:讀多寫少(部落格)還是讀寫接近(聊天軟體)?
  • 一致性要求:可以延遲幾秒(按讚數)還是必須絕對即時(轉帳)?
  • 可用性要求:能接受掛幾分鐘,還是要求 99.99% 不中斷?

為什麼不能跳過:如果系統一天只有 10 個人用,根本不需要讀寫分離、Redis 快取,過早引入反而增加不必要的複雜度。答案永遠是「在這個規模、這些限制下最合理的取捨」,沒有標準答案。


案例:設計一個限流器(Rate Limiter)

釐清需求

  1. 限流規則?(例如每人每分鐘 60 次)
  2. 單機還是多機部署?(答案會完全改變技術選型)
  3. 超過限制怎麼回應?

單機版(記憶體實作,有致命限制)

// 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); // 權限檢查是業務邏輯,屬於這一層
}
// ...
};

該出現的東西

  1. 業務規則判斷(權限檢查、狀態轉換規則)
  2. 跨多個 Repository 的協調(例如用 $transaction 同時操作多張表)
  3. 資料轉換與組裝(把 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.queryres.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 這種外部共用服務,否則每台伺服器各自計數會導致規則失效。


核心心法總結

架構沒有「最好的答案」,只有「最適合當下規模與限制的答案」。單體架構不是落後技術,微服務也不是萬靈丹。過度設計(為不存在的規模先做準備)跟設計不足(撐不住才臨時抱佛腳)都是常見陷阱,好的架構判斷力,在於準確評估「現在該不該做這件事」。