【工程師的系統設計】#04 擴展性設計:從單機到分散式

測驗:擴展性設計 — 從單機到分散式

共 5 題,點選答案後會立即顯示結果

1. 垂直擴展(Scale Up)和水平擴展(Scale Out)的主要差異是什麼?

  • A. 垂直擴展是增加伺服器數量,水平擴展是升級單台伺服器硬體
  • B. 垂直擴展是升級單台伺服器硬體(加 CPU、記憶體),水平擴展是增加伺服器數量
  • C. 垂直擴展沒有天花板,水平擴展有硬體極限
  • D. 兩者完全一樣,只是叫法不同

2. 在 Nginx 負載平衡設定中,least_conn 演算法的分配邏輯是什麼?

upstream backend_servers { least_conn; server 10.0.0.1:8000; server 10.0.0.2:8000; server 10.0.0.3:8000; }
  • A. 根據用戶 IP 的雜湊值,讓同一個用戶永遠被分配到同一台伺服器
  • B. 依序輪流分配,每台伺服器各分到一個請求
  • C. 將新請求分配給目前連線數最少的伺服器
  • D. 隨機選一台可用的伺服器

3. 為什麼水平擴展時,不應該將 Session 存在伺服器的記憶體裡?

  • A. 記憶體存取速度太慢,會拖累系統效能
  • B. 用戶的下一個請求可能被分配到不同伺服器,導致找不到 Session 而被要求重新登入
  • C. 伺服器記憶體無法儲存 Session 這種資料格式
  • D. 記憶體中的 Session 無法被加密,有安全性風險

4. 以下 Celery 程式碼中,.delay() 的作用是什麼?

send_welcome_email.delay(user.email) generate_thumbnail.delay(user.avatar_path)
  • A. 延遲 5 秒後才執行該函式
  • B. 同步呼叫該函式,並等待回傳結果
  • C. 取消之前排入佇列的相同任務
  • D. 將任務丟到訊息佇列中背景執行,不等待它完成

5. 一個 5 人團隊正在開發一個新的電商平台,目前還沒上線,AI 建議將系統拆成 8 個微服務。根據文章的建議,這個做法合理嗎?

  • A. 不合理。團隊規模小且專案剛起步,應先用單體架構,遇到瓶頸再拆分
  • B. 合理。微服務是業界最佳實踐,越早採用越好
  • C. 不合理。微服務只適用於超過 100 人的大型團隊
  • D. 合理。電商平台的功能多,天生就適合微服務架構

一句話說明

當一台伺服器撐不住流量時,你需要知道怎麼「加機器」而不是「加班除錯」。

前言:為什麼這篇跟你有關

你用 AI 產生了一個很棒的應用程式,本地跑得好好的。有一天你把它部署上線,突然湧入大量用戶 — 然後伺服器就掛了。

這篇文章不是要教你從頭設計分散式系統,而是讓你看懂那些擴展性相關的程式碼和架構圖在說什麼。當 AI 幫你產出 Nginx 設定、Celery 任務、或是資料庫分片邏輯時,你能判斷它寫得對不對。

前置知識

  • 第 1-3 篇的所有內容(HTTP、API 設計、快取與資料庫基礎)
  • 基本的命令列操作經驗
  • 了解 Docker 基本概念(有幫助但非必要)

1. 為什麼需要擴展

先看一個典型場景。這是第 1 篇的短網址服務,跑在一台伺服器上:

用戶 ──→ [單一伺服器] ──→ [單一資料庫]
              │
        CPU: 95%
        RAM: 7.8 / 8 GB
        回應時間: 3200ms

翻譯:一台機器同時處理所有請求、跑所有程式、連同一個資料庫。當用戶變多,CPU 和記憶體爆掉,回應時間從 50ms 飆到 3200ms。

這就是需要「擴展」的時刻。擴展的核心問題只有一個:

怎麼讓系統在用戶增加時,依然維持可接受的回應速度?


2. 垂直擴展 vs 水平擴展

面對「撐不住」,有兩種思路:

垂直擴展(Scale Up):把機器變大

之前:2 CPU / 4GB RAM / 50GB SSD
之後:16 CPU / 64GB RAM / 1TB NVMe

翻譯:就是「換一台更強的電腦」。

水平擴展(Scale Out):加更多機器

之前:[伺服器 A]
之後:[伺服器 A] + [伺服器 B] + [伺服器 C][負載平衡器] ←── 用戶
Code language: CSS (css)

翻譯:不換電腦,而是「多買幾台一起扛」。

對照比較

比較項目 垂直擴展(Scale Up) 水平擴展(Scale Out)
做法 升級 CPU、記憶體、硬碟 增加伺服器數量
天花板 有(硬體有極限) 理論上無限
成本曲線 指數成長(愈高階愈貴) 線性成長(每台一樣價)
複雜度 低(不用改程式) 高(需要負載平衡、狀態管理)
停機時間 通常需要停機升級 可以逐台加入,不中斷
適合場景 初期、資料庫、快速應急 長期、Web 伺服器、大規模

Vibe Coder 檢查點

看到擴展方案時確認:

  • [ ] 目前的瓶頸是 CPU、記憶體、還是網路 I/O?
  • [ ] 垂直擴展還能往上加嗎?(雲端通常有上限)
  • [ ] 如果選水平擴展,程式碼有沒有「狀態」綁在單一機器上?

3. 負載平衡(Load Balancing)

水平擴展的第一步:你有多台伺服器了,誰來決定「這個請求送去哪台」?

答案是負載平衡器(Load Balancer)

用戶請求 ──→ [負載平衡器] ──→ [伺服器 A]
                    ├──→ [伺服器 B]
                    └──→ [伺服器 C]
Code language: CSS (css)

L4 vs L7 負載平衡

類型 運作層級 看得到什麼 適合場景
L4 傳輸層(TCP/UDP) IP 位址、Port 快速轉發、資料庫連線
L7 應用層(HTTP) URL 路徑、Header、Cookie Web 應用、API 路由

翻譯:L4 只看「這個封包要去哪」,像快遞分揀員;L7 會拆開封包看內容,像能讀信的郵差。

常見演算法

Round Robin(輪流分配)

請求 1 → 伺服器 A
請求 2 → 伺服器 B
請求 3 → 伺服器 C
請求 4 → 伺服器 A  ← 輪回來

翻譯:「排隊輪流,一人一個」。最簡單,但不考慮伺服器忙不忙。

Least Connections(最少連線)

伺服器 A: 15 個連線中
伺服器 B: 3 個連線中   ← 新請求送這裡
伺服器 C: 12 個連線中

翻譯:「誰最閒就給誰」。適合請求處理時間差異大的場景。

IP Hash(IP 雜湊)

用戶 IP: 192.168.1.100
hash(192.168.1.100) % 3 = 1  → 永遠送到伺服器 B

翻譯:「同一個用戶永遠去同一台」。解決 Session 問題(但不是最佳解)。

Nginx 負載平衡設定範例

這是你最常在專案裡看到的 Nginx 設定:

# /etc/nginx/conf.d/load-balancer.conf

upstream backend_servers {           # 定義一組後端伺服器
    least_conn;                      # 使用「最少連線」演算法
    server 10.0.0.1:8000 weight=3;  # 伺服器 A,權重 3(分到 3 倍流量)
    server 10.0.0.2:8000 weight=1;  # 伺服器 B,權重 1
    server 10.0.0.3:8000 backup;    # 伺服器 C,備用(其他都掛了才用)
}

server {
    listen 80;                       # 監聽 80 埠

    location / {                     # 所有請求
        proxy_pass http://backend_servers;   # 轉發到上面定義的伺服器群
        proxy_set_header Host $host;         # 保留原始的 Host 標頭
        proxy_set_header X-Real-IP $remote_addr;  # 傳遞用戶真實 IP
    }
}
Code language: PHP (php)

逐行翻譯

設定 翻譯
upstream backend_servers 「定義一群叫 backend_servers 的伺服器」
least_conn 「用最少連線數的方式分配」
weight=3 「這台分到 3 倍的請求量」
backup 「平常不用,其他都掛了才啟動」
proxy_pass 「把請求轉送到那群伺服器」

健康檢查

負載平衡器怎麼知道某台伺服器掛了?靠健康檢查

upstream backend_servers {
    server 10.0.0.1:8000 max_fails=3 fail_timeout=30s;
    # ↑ 連續失敗 3 次後,標記為不可用,30 秒後再試
}
Code language: PHP (php)

翻譯:「每隔一段時間戳一下伺服器,連續三次沒回應就跳過它,30 秒後再看看有沒有活過來」。

Vibe Coder 檢查點

看到 Nginx/負載平衡設定時確認:

  • [ ] 用的是哪種演算法?(least_connip_hash、還是預設的 round robin)
  • [ ] 有沒有設定健康檢查?(max_failsfail_timeout
  • [ ] 有沒有備用伺服器?(backup

4. 無狀態設計原則

水平擴展有個大前提:伺服器不能把狀態存在自己身上

有狀態的問題

請求 1 → 伺服器 A → 登入成功,Session 存在 A 的記憶體裡
請求 2 → 伺服器 B → 找不到 Session,要求重新登入!

翻譯:用戶登入後,下一個請求被分配到不同伺服器,結果又被踢回登入頁。

解決方案 1:JWT(無狀態 Token)

import jwt
from datetime import datetime, timedelta

# 登入時:產生 Token 給用戶
def create_token(user_id: int) -> str:
    payload = {
        "user_id": user_id,                              # 用戶 ID 直接寫在 Token 裡
        "exp": datetime.utcnow() + timedelta(hours=24),  # 24 小時後過期
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
    # ↑ 用密鑰簽名,防止竄改

# 驗證時:任何伺服器都能驗,不需要查資料庫
def verify_token(token: str) -> dict:
    return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
    # ↑ 只要有 SECRET_KEY,任何伺服器都能解開
Code language: PHP (php)

翻譯:不在伺服器存 Session,而是把「你是誰」這個資訊加密後放在 Token 裡,用戶每次請求都帶著它。任何伺服器收到都能驗證,不需要「記住」用戶。

解決方案 2:Redis Session Store(集中存放)

import redis

r = redis.Redis(host="redis-server", port=6379)  # 所有伺服器都連同一個 Redis

# 登入時:Session 存到 Redis
def save_session(session_id: str, user_data: dict):
    r.setex(session_id, 3600, json.dumps(user_data))
    # ↑ 存到 Redis,3600 秒(1 小時)後自動過期

# 驗證時:從 Redis 查
def get_session(session_id: str) -> dict:
    data = r.get(session_id)      # 從 Redis 讀取
    return json.loads(data) if data else None
Code language: PHP (php)

翻譯:Session 不存在任何一台伺服器上,而是存在「大家都能存取的共用 Redis」裡。不管請求被分到哪台伺服器,都能找到同一份 Session。

兩種方案對照

比較 JWT Redis Session
狀態存放 用戶端(Token) 集中式(Redis)
伺服器負擔 低(只需驗證簽名) 中(需查 Redis)
可即時撤銷 難(Token 發出去就收不回) 容易(刪掉 Redis 的紀錄)
適合場景 API、微服務 需要即時登出的場景

Vibe Coder 檢查點

看到認證相關程式碼時確認:

  • [ ] 是用 JWT 還是 Session?
  • [ ] 如果是 Session,存在哪裡?(記憶體?Redis?資料庫?)
  • [ ] 記憶體裡的 Session 會讓水平擴展出問題嗎?

5. 資料庫擴展

伺服器加機器了,但資料庫還是只有一台 — 這就變成新的瓶頸。

讀寫分離(Primary-Replica)

寫入請求 ──→ [Primary 主庫]
                  │
                  │ 同步複製
                  ├──→ [Replica 1] ←── 讀取請求
                  └──→ [Replica 2] ←── 讀取請求
Code language: CSS (css)

翻譯:一台負責寫,多台負責讀。大多數應用程式「讀多寫少」(例如社群媒體 90% 是在刷貼文),所以這招很有效。

程式碼中的讀寫分離長這樣:

from sqlalchemy import create_engine

# 寫入用主庫
primary_engine = create_engine("postgresql://primary-db:5432/mydb")

# 讀取用副本
replica_engine = create_engine("postgresql://replica-db:5432/mydb")

def get_user(user_id: int):
    with replica_engine.connect() as conn:       # 讀取 → 用 replica
        return conn.execute(
            text("SELECT * FROM users WHERE id = :id"),
            {"id": user_id}
        ).fetchone()

def create_user(name: str):
    with primary_engine.connect() as conn:       # 寫入 → 用 primary
        conn.execute(
            text("INSERT INTO users (name) VALUES (:name)"),
            {"name": name}
        )
        conn.commit()
Code language: PHP (php)

翻譯:程式裡建兩個資料庫連線,讀的時候用 replica_engine,寫的時候用 primary_engine

分庫分表(Sharding)

當單一資料庫連讀寫分離都撐不住時,把資料「切開」放到不同資料庫:

用戶 ID 0-999999[資料庫 Shard 1]
用戶 ID 1000000-1999999[資料庫 Shard 2]
用戶 ID 2000000-2999999[資料庫 Shard 3]
Code language: CSS (css)

程式碼裡會看到這種邏輯:

def get_shard(user_id: int) -> str:
    shard_number = user_id % 3           # 用 ID 取餘數決定去哪個分片
    return f"postgresql://shard-{shard_number}:5432/mydb"

# 翻譯:user_id 除以 3 的餘數決定資料在哪台資料庫
# user_id=100 → 100 % 3 = 1 → shard-1
# user_id=101 → 101 % 3 = 2 → shard-2
# user_id=102 → 102 % 3 = 0 → shard-0
Code language: PHP (php)

翻譯:資料不再放在同一台,而是根據某個規則(通常是 ID)分散到多台資料庫。

重要提醒:Sharding 的複雜度非常高(跨分片查詢、資料遷移都是大問題)。除非用戶量達到千萬等級,否則先用讀寫分離 + 快取就夠了。

Vibe Coder 檢查點

看到資料庫相關架構時確認:

  • [ ] 有沒有讀寫分離?寫入和讀取用的是不是不同連線?
  • [ ] 如果有 Sharding,分片的 key 是什麼?(user_id?region?)
  • [ ] Replica 的資料同步有沒有延遲?(會影響「剛寫入就讀取」的場景)

6. 非同步處理與訊息佇列

有些操作不需要(也不該)讓用戶等著。

同步 vs 非同步

同步(用戶等著):
用戶 → 註冊 → 寫入資料庫 → 寄歡迎信 → 產生頭像 → 回傳「註冊成功」
                                                      ↑ 用戶等了 8 秒

非同步(用戶不等):
用戶 → 註冊 → 寫入資料庫 → 回傳「註冊成功」 → 用戶等了 0.5 秒
                    └→ 背景:寄歡迎信
                    └→ 背景:產生頭像
Code language: CSS (css)

翻譯:「需要立刻回覆用戶的」同步做,「可以晚點做的」丟到背景。

訊息佇列(Message Queue)

訊息佇列是實現非同步的核心元件:

生產者 ──→ [訊息佇列] ──→ 消費者
(API)      (Redis/RabbitMQ)   (背景 Worker)

範例:
用戶註冊 ──→ 佇列放入「寄信」任務 ──→ 寄信 Worker 取出並處理

翻譯:就像餐廳的「出菜口」。廚師(生產者)把做好的菜放上去,服務生(消費者)自己去取。廚師不用等服務生,服務生不用等廚師。

Python + Celery + Redis 實作

這是你在 Python 專案中最常看到的非同步任務實作:

# tasks.py -- 定義背景任務

from celery import Celery

app = Celery("tasks", broker="redis://localhost:6379/0")
# ↑ 建立 Celery 應用,用 Redis 當訊息佇列

@app.task                        # 這個裝飾器讓函式變成「可以背景執行的任務」
def send_welcome_email(user_email: str):
    # 寄信的邏輯(可能要 2-3 秒)
    print(f"寄送歡迎信到 {user_email}")

@app.task
def generate_thumbnail(image_path: str):
    # 圖片處理的邏輯(可能要 5-10 秒)
    print(f"處理圖片 {image_path}")
Code language: PHP (php)
# api.py -- API 端呼叫背景任務

from tasks import send_welcome_email, generate_thumbnail

@app.post("/register")
async def register(user: UserCreate):
    # 1. 同步:寫入資料庫(必須馬上完成)
    db_user = create_user_in_db(user)

    # 2. 非同步:丟到佇列,不等結果
    send_welcome_email.delay(user.email)
    #                  ↑ .delay() 表示「丟到佇列背景執行,不要等」

    generate_thumbnail.delay(user.avatar_path)

    # 3. 立刻回覆用戶
    return {"message": "註冊成功", "user_id": db_user.id}
Code language: PHP (php)

逐行翻譯

代碼 翻譯
Celery("tasks", broker="redis://...") 「用 Redis 當任務佇列」
@app.task 「這個函式可以被放到背景執行」
send_welcome_email.delay(email) 「把寄信任務丟到佇列,不等它完成」

啟動 Worker 的指令:

celery -A tasks worker --loglevel=info
# ↑ 啟動一個 Worker,它會不斷從佇列取出任務並執行
# -A tasks 表示「去 tasks.py 找任務定義」
Code language: PHP (php)

訊息佇列解決的兩大問題

問題 1:耦合 — 服務之間不直接呼叫,透過佇列溝通

緊耦合(直接呼叫):
註冊服務 → 直接呼叫寄信服務 → 寄信服務掛了,註冊也失敗

鬆耦合(透過佇列):
註冊服務 → 丟任務到佇列 → 寄信服務掛了?任務留在佇列,等它恢復再處理

問題 2:峰值 — 用佇列「削峰填谷」

沒有佇列:
湧入 10000 請求 → 伺服器同時處理 → 爆炸

有佇列:
湧入 10000 請求 → 全部放進佇列 → Worker 每秒處理 100 個 → 穩穩消化

Vibe Coder 檢查點

看到非同步任務程式碼時確認:

  • [ ] 哪些操作是同步的(用戶在等)?哪些是非同步的(背景執行)?
  • [ ] .delay() 的任務失敗了怎麼辦?有沒有重試機制?
  • [ ] 佇列的 broker 是什麼?(Redis、RabbitMQ、SQS?)

7. 微服務 vs 單體架構

「要不要把系統拆成微服務?」是最常見的架構決策之一。

單體架構(Monolith)

[一個應用程式]
├── 用戶模組
├── 訂單模組
├── 支付模組
└── 通知模組
     ↓
[一個資料庫]
Code language: CSS (css)

翻譯:所有功能在同一個程式裡,部署時整包一起部署。

微服務架構(Microservices)

[用戶服務] ──→ [用戶 DB]
[訂單服務] ──→ [訂單 DB]
[支付服務] ──→ [支付 DB]
[通知服務] ──→ [通知 DB][訊息佇列 / API Gateway]
Code language: CSS (css)

翻譯:每個功能是獨立的小程式,有自己的資料庫,透過 API 或訊息佇列溝通。

什麼時候該拆、什麼時候不該拆

情境 建議
團隊 < 10 人 單體。拆了反而增加溝通成本
部署頻率不同 可以拆。支付模組每月更新一次,用戶模組每天更新
技術棧不同 可以拆。推薦引擎用 Python,API 用 Go
擴展需求不同 可以拆。搜尋服務需要 20 台,用戶服務只要 2 台
剛起步的新專案 單體。先跑起來再說

**Conway’s Law**:「組織的系統設計會反映組織的溝通結構。」
白話翻譯:如果你的團隊是一個人,硬拆微服務只是在折磨自己。三個團隊各管一塊功能?那自然就會長成三個微服務。

Vibe Coder 檢查點

看到架構討論時確認:

  • [ ] 現在是單體還是微服務?
  • [ ] 如果 AI 建議拆微服務,團隊規模撐得起來嗎?
  • [ ] 服務之間怎麼通訊?(HTTP API?gRPC?訊息佇列?)

8. 監控與可觀測性

系統從 1 台變成 10 台後,出了問題你要怎麼找到是哪台、哪個環節出錯?

可觀測性三大支柱

[Metrics]     [Logs]        [Traces]
 數字指標      文字紀錄       請求追蹤
 「多少」      「發生什麼」    「經過哪裡」
Code language: CSS (css)

Metrics(指標):系統的健康數字

# 用 prometheus_client 暴露指標
from prometheus_client import Counter, Histogram

request_count = Counter(
    "http_requests_total",          # 指標名稱
    "Total HTTP requests",          # 說明
    ["method", "endpoint", "status"]  # 標籤(可以分類統計)
)

request_duration = Histogram(
    "http_request_duration_seconds",  # 請求耗時
    "HTTP request duration",
)

@app.middleware("http")
async def metrics_middleware(request, call_next):
    start = time.time()
    response = await call_next(request)
    duration = time.time() - start

    request_count.labels(
        method=request.method,
        endpoint=request.url.path,
        status=response.status_code
    ).inc()                              # 計數 +1

    request_duration.observe(duration)   # 記錄耗時
    return response
Code language: PHP (php)

翻譯:每個請求進來時自動記錄「總數」和「花了多久」,讓監控系統(如 Grafana)可以畫成圖表。

Logs(日誌):發生了什麼事

import logging
import json

logger = logging.getLogger(__name__)

# 結構化日誌(方便搜尋和分析)
logger.info(json.dumps({
    "event": "user_registered",
    "user_id": 12345,
    "email": "alice@example.com",
    "duration_ms": 234,
    "server": "web-03"          # 標記是哪台伺服器
}))
Code language: PHP (php)

翻譯:不是隨便 print(),而是用結構化的格式記錄事件,讓你可以在多台伺服器的日誌裡搜尋「所有來自 web-03 且耗時超過 1000ms 的註冊事件」。

Traces(追蹤):一個請求的完整旅程

[請求 abc-123]
├── API Gateway        (2ms)
├── 用戶服務           (15ms)
│   ├── Redis 查快取   (1ms) - MISS
│   └── PostgreSQL 查詢 (12ms)
├── 訂單服務           (45ms)  ← 瓶頸在這
│   └── MongoDB 查詢    (40ms)
└── 回應用戶           (total: 62ms)
Code language: CSS (css)

翻譯:追蹤一個請求「經過了哪些服務、每一站花了多久」。當系統變慢時,你一眼就能看到瓶頸在哪。

為什麼擴展後監控更重要

單機時代 分散式時代
tail -f app.log 就能看日誌 10 台伺服器,日誌散落各處
CPU 高就是你的程式有問題 可能是 A 服務打爆了 B 服務
重啟就好 重啟哪台?會不會影響其他服務?

Vibe Coder 檢查點

看到監控相關程式碼時確認:

  • [ ] 有沒有 Metrics?(能看到系統的即時數字)
  • [ ] 日誌是不是結構化的?(JSON 格式比純文字好搜尋)
  • [ ] 有沒有 Tracing?(分散式系統幾乎必備)
  • [ ] 監控資料存在哪裡?(Prometheus、ELK、Datadog?)

9. 綜合範例:短網址服務的擴展之路

讓我們把第 1 篇的短網址服務,從「一台伺服器」擴展為「支撐百萬用戶」的架構。

階段 1:單機版(0-1000 用戶)

用戶 ──→ [FastAPI 伺服器] ──→ [PostgreSQL]
Code language: CSS (css)
# 最簡單的短網址服務
@app.post("/shorten")
async def shorten(url: str):
    short_code = generate_code()
    db.execute("INSERT INTO urls (code, original) VALUES (?, ?)",
               (short_code, url))
    return {"short_url": f"https://sho.rt/{short_code}"}

@app.get("/{code}")
async def redirect(code: str):
    result = db.execute("SELECT original FROM urls WHERE code = ?", (code,))
    return RedirectResponse(result["original"])
Code language: PHP (php)

階段 2:加快取 + 讀寫分離(1000-10 萬用戶)

用戶 ──→ [FastAPI] ──→ [Redis 快取]
              │              ↓ miss[Replica DB] (讀取)
              └──→ [Primary DB] (寫入)
Code language: CSS (css)
import redis

cache = redis.Redis(host="redis-server")

@app.get("/{code}")
async def redirect(code: str):
    # 1. 先查快取
    cached = cache.get(f"url:{code}")
    if cached:
        return RedirectResponse(cached.decode())

    # 2. 快取沒有,查 Replica 資料庫
    result = replica_db.execute(
        "SELECT original FROM urls WHERE code = ?", (code,)
    )

    # 3. 寫入快取,下次就不用查資料庫了
    cache.setex(f"url:{code}", 3600, result["original"])

    return RedirectResponse(result["original"])
Code language: PHP (php)

階段 3:水平擴展 + 負載平衡(10 萬-100 萬用戶)

用戶 ──→ [Nginx 負載平衡器]
              ├──→ [FastAPI 伺服器 1]
              ├──→ [FastAPI 伺服器 2] ──→ [Redis 快取叢集]
              └──→ [FastAPI 伺服器 3]miss
                                         [DB Primary]
                                         [DB Replica 1]
                                         [DB Replica 2]
Code language: CSS (css)

Nginx 設定:

upstream shorturl_api {
    least_conn;
    server 10.0.0.1:8000;
    server 10.0.0.2:8000;
    server 10.0.0.3:8000;
}

server {
    listen 80;
    location / {
        proxy_pass http://shorturl_api;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
Code language: PHP (php)

階段 4:非同步處理 + 監控(100 萬+ 用戶)

用戶 ──→ [CDN / Nginx]
              ├──→ [API 伺服器群]
              │         ├──→ [Redis 快取叢集]
              │         ├──→ [DB Primary + Replicas]
              │         └──→ [訊息佇列 (Redis)]
              │                    ↓
              │              [Celery Workers]
              │              ├── 統計分析
              │              └── 通知服務
              │
              └──→ [Prometheus + Grafana] (監控)
Code language: CSS (css)
# 點擊統計改為非同步
@app.get("/{code}")
async def redirect(code: str):
    url = cache.get(f"url:{code}")
    if not url:
        url = await get_from_db(code)
        cache.setex(f"url:{code}", 3600, url)

    # 統計點擊次數 -- 非同步處理,不讓用戶等
    track_click.delay(code, request.client.host)

    return RedirectResponse(url)

@celery_app.task
def track_click(code: str, ip: str):
    # 這個在背景慢慢做,不影響用戶體驗
    db.execute(
        "INSERT INTO clicks (code, ip, clicked_at) VALUES (?, ?, NOW())",
        (code, ip)
    )
Code language: PHP (php)

擴展路線圖總結

階段 用戶量 關鍵技術 複雜度
1 0-1K 單機、單資料庫
2 1K-100K Redis 快取、讀寫分離
3 100K-1M 負載平衡、水平擴展、無狀態 中高
4 1M+ 非同步處理、監控、CDN

重要原則:**不要過早優化**。先用最簡單的架構上線,遇到瓶頸再解決。90% 的應用在階段 2 就夠用了。


10. 必看懂 vs 知道就好

必看懂(會一直出現)

  • 垂直擴展 vs 水平擴展的差異
  • Nginx 負載平衡基本設定(upstreamproxy_pass
  • 無狀態設計原則(JWT、Redis Session)
  • 非同步任務的 .delay() 呼叫
  • 讀寫分離的程式碼模式(兩個 engine)

知道就好(遇到再查)

  • 一致性雜湊(Consistent Hashing):更智慧的分片方式,加減節點時不用大搬家
  • 服務網格(Service Mesh):微服務之間通訊的基礎設施,如 Istio
  • CQRS:Command Query Responsibility Segregation,讀寫用不同的資料模型
  • Saga Pattern:微服務之間的分散式交易管理
  • CAP 定理:分散式系統的一致性、可用性、分區容忍性三選二

本篇總結

概念 一句話翻譯
垂直擴展 換更大的機器
水平擴展 加更多的機器
負載平衡 決定請求送去哪台伺服器
無狀態設計 別把資料存在單一伺服器的記憶體裡
讀寫分離 一台寫、多台讀
Sharding 資料依規則切開,分散到不同資料庫
訊息佇列 任務排隊,背景慢慢做
微服務 功能拆成獨立的小程式
監控三支柱 Metrics(數字)、Logs(紀錄)、Traces(追蹤)

系列總回顧

這是【工程師的系統設計】系列的最後一篇。四篇文章帶你走過了:

  1. #01 基礎概念:HTTP、API、Client-Server 架構
  2. #02 API 設計:RESTful、狀態碼、版本管理
  3. #03 快取與資料庫:Redis、SQL vs NoSQL、快取策略
  4. #04 擴展性設計:負載平衡、非同步處理、微服務(本篇)

作為 Vibe Coder,你不需要從頭設計這些系統,但你需要看懂 AI 產生的架構和程式碼。當你讀到 Nginx 設定、Celery 任務、或資料庫分片邏輯時,希望你不再覺得陌生,而是能自信地判斷:「這寫得合理」或「這裡有問題」。

系統設計沒有完美的解答,只有在當下情境中最合適的取捨。記住那個最重要的原則:先讓它跑起來,再讓它跑得快

進階測驗:擴展性設計 — 從單機到分散式

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你的短網址服務目前有 5 萬用戶,部署在一台伺服器上。最近回應時間從 80ms 升到 600ms,監控顯示 90% 的請求是讀取(重定向),10% 是寫入(建立短網址)。資料庫 CPU 使用率達 85%。你的第一步應該怎麼做? 情境題

  • A. 立刻將系統拆成微服務架構,讀取服務和寫入服務分開部署
  • B. 加入 Redis 快取熱門短網址,並將資料庫做讀寫分離(Primary 寫入、Replica 讀取)
  • C. 立刻做資料庫 Sharding,將短網址資料依 ID 分散到多台資料庫
  • D. 增加三台 API 伺服器並設定 Nginx 負載平衡

2. 你的電商平台用戶註冊流程目前是同步的:寫入資料庫 -> 寄歡迎信 -> 產生推薦列表 -> 回傳結果。用戶反映註冊要等 10 秒太久。你決定引入非同步處理,以下哪個做法最正確? 情境題

# 目前的同步寫法 @app.post(“/register”) async def register(user: UserCreate): db_user = create_user_in_db(user) # 0.3 秒 send_welcome_email(user.email) # 3 秒 generate_recommendations(db_user.id) # 6 秒 return {“user_id”: db_user.id} # 總共 ~10 秒
  • A. 把三個操作全部改成 .delay() 丟到佇列,API 立即回傳空結果
  • B. 把所有操作都放在同一個 Celery 任務中,用 .delay() 呼叫
  • C. 保持「寫入資料庫」同步執行,將「寄信」和「產生推薦」用 .delay() 丟到佇列背景處理
  • D. 用 asyncio.gather() 同時執行三個操作,全部完成後再回傳

3. 你的 API 服務已經水平擴展到 4 台伺服器,使用 Nginx Round Robin 分配。QA 團隊回報:部分用戶登入後偶爾會被踢回登入頁面,但重新整理後又正常了。目前的認證方式是將 Session 存在每台伺服器的記憶體中。最佳的修復方案是什麼? 情境題

  • A. 將 Nginx 演算法從 Round Robin 改成 IP Hash,讓同一用戶固定到同一台
  • B. 增加伺服器數量到 8 台,降低每台的負載
  • C. 在每台伺服器之間同步 Session 資料
  • D. 改用 JWT 或將 Session 存到 Redis Session Store,實現無狀態設計

4. 團隊在資料庫讀寫分離後,收到用戶回報:「我剛發了一篇貼文,但重新整理頁面後看不到,要等幾秒才會出現。」以下是相關程式碼,問題最可能出在哪裡? 錯誤診斷

# 寫入:用 Primary def create_post(content: str): with primary_engine.connect() as conn: conn.execute( text(“INSERT INTO posts (content) VALUES (:content)”), {“content”: content} ) conn.commit() # 讀取:用 Replica def get_latest_posts(): with replica_engine.connect() as conn: return conn.execute( text(“SELECT * FROM posts ORDER BY created_at DESC LIMIT 20”) ).fetchall()
  • A. primary_enginereplica_engine 的連線字串寫錯了
  • B. Primary 到 Replica 的資料同步有延遲,用戶寫入後立刻從 Replica 讀取時資料尚未同步過去
  • C. SQL 語法中的 ORDER BY 有誤,應該用 ASC 而非 DESC
  • D. conn.commit() 放錯位置,寫入並未真正儲存到資料庫

5. 你的同事設定了 Nginx 負載平衡,但上線後發現:伺服器 C 從來沒有收到任何請求,即使伺服器 A 和 B 都很忙。問題出在哪裡? 錯誤診斷

upstream backend_servers { least_conn; server 10.0.0.1:8000 weight=3; server 10.0.0.2:8000 weight=1; server 10.0.0.3:8000 backup; }
  • A. least_conn 演算法有 bug,無法正確分配到第三台
  • B. 伺服器 C 的 IP 位址設定錯誤,連不上
  • C. 伺服器 C 被標記為 backup,只有在其他伺服器都不可用時才會收到請求
  • D. weight 設定不均,導致所有流量只分配給前兩台

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *