English | 繁體中文
系統設計
系統設計是將產品行為、流量、資料與故障假設轉化為可運作系統的實務。從契約與限制開始;選擇能滿足這些條件的最簡單架構;讓取捨明確且可衡量。圖表是假設,本身並不等於設計。
可重複的設計流程
- 釐清範圍: 使用者、使用情境、不在範圍內的行為、區域、多租戶、合規性,以及新鮮度期望。
- 陳述假設: 每秒請求數、物件大小、讀寫比例、尖峰倍數、保留期限、成長率與故障預算。
- 撰寫需求: 功能行為,以及可衡量的非功能目標。
- 估算容量: 儲存空間、頻寬、連線數、CPU、記憶體與成本;展示計算過程。
- 定義資料與 API 契約: 金鑰、不變量、分頁、錯誤、冪等性與一致性。
- 繪製請求路徑: 用戶端、邊緣、服務、快取、資料庫、佇列、工作程式與外部依賴。
- 演練故障與成長: 重試、過載、部分故障、復原、遷移,以及下一個瓶頸。
- 最後才選工具: 根據需求、團隊技能、營運負擔與退出成本,選擇託管或自行託管的元件。
經驗法則: 先說明正常路徑會發生什麼,再說明每個依賴變慢、不可用、重複或不一致時會發生什麼。
範例情境與假設
假設有一個多區域 URL 縮短服務。使用者建立短連結,之後透過它重新導向。
Traffic: 100 writes/s average, 1,000 reads/s average, 10x peak burst
Read/write: 10:1; redirect latency target p95 < 100 ms
Durability: created links must not be lost after acknowledgement
Availability: 99.95% monthly for redirects; writes may degrade before reads
Retention: 5 years; 1 KiB average record; 20% annual growth
Consistency: redirect must see a successful write within 5 seconds這些數字是假設,不是普遍適用的事實。在選擇容量或資料庫之前,請替換它們。
需求
功能需求
- 建立包含目的地、擁有者、到期時間,以及可選冪等性金鑰的短連結。
- 將短代碼重新導向至目前的目的地,並回傳清楚的找不到或已到期回應。
- 在授權許可下撤銷或更新連結。
- 依擁有者列出連結,使用穩定、以游標為基礎的分頁。
- 為建立、更新、撤銷及重新導向政策決策發出稽核事件。
非功能需求
| 屬性 | 範例目標 | 設計後果 |
|---|---|---|
| 可用性 | 重新導向每月 99.95% | 副本、健康檢查、優雅降級 |
| 延遲 | 重新導向 p95 < 100 ms | 邊緣/快取、有界查詢、不進行同步分析 |
| 持久性 | 不遺失任何已確認的寫入 | 複寫的持久化儲存、經測試的復原 |
| 一致性 | 5 秒內讀到寫入結果 | 工作階段路由、失效處理,或有界的複寫延遲 |
| 可擴展性 | 10 倍突發流量且不遺失資料 | 無狀態層、自動擴展、佇列、背壓 |
| 安全性 | 租戶隔離與最小權限 | 在邊緣與服務進行驗證,限定資料存取範圍 |
| 可操作性 | 可採取行動的警報與回滾 | 指標、追蹤、日誌、操作手冊、版本化變更 |
| 成本 | 符合明確的每月預算 | 託管服務、分層儲存、適當規模的容量 |
若沒有目標、工作負載與衡量方法,不要只說「快速」、「高可用」或「可擴展」。
容量估算
使用數量級估算並保留餘裕:
peak RPS = average RPS × peak multiplier
annual records = writes/s × seconds/year
raw storage = records × average record size
bandwidth = RPS × average payload size
working set = hot-record fraction × total records以此情境而言,100 writes/s × ~31.5M s/year ≈ 3.15B records/year。每筆 1 KiB 時,原始資料約為每年 3.15 TB,尚未計入索引、副本、備份與中繼資料。這項估算可能會否定最初的儲存選擇;這是有用的資訊。請納入 2–3 倍的營運餘裕,而不只是平均值。
標準請求路徑
Client
→ DNS / CDN / WAF / rate limiter
→ load balancer
→ stateless API or redirect service
→ cache (hot short codes)
→ primary data store and read replicas
→ queue / event stream
→ asynchronous analytics, audit, and cleanup workers
→ object storage for exports, backups, and cold data常見元件的職責
- DNS/CDN: 路由使用者、快取安全回應、吸收地理與頻寬負載;不要意外快取個人化或可撤銷的資料。
- 負載平衡器: 健康狀態感知的路由、適當時進行 TLS 終止、連線限制,以及部署期間的連線排空。
- 無狀態服務: 驗證、輸入檢查、授權、協調與回應塑形;將持久狀態保留在執行個體之外。
- 快取: 減少重複讀取;在加入快取之前,先定義 TTL、失效處理、快取踩踏防護與過期資料政策。
- 主要儲存: 不變量與交易的真實來源。
- 副本/搜尋索引: 最佳化讀取模式,但須記錄延遲與重建程序。
- 佇列/串流: 解耦緩慢或突發的工作;使用持久交付、有界消費者、重試政策與死信路徑。
- 物件儲存: 低成本的持久化 Blob、快照、匯出資料與可重播的原始事件。
- 可觀測性堆疊: 指標用於症狀,日誌用於細節,追蹤用於請求路徑,剖析用於資源熱點。
資料與 API 設計
先決定存取模式,再設計資料表。明確建模擁有權、唯一性、生命週期與查詢金鑰。
POST /v1/links
Idempotency-Key: 9b2d...
Content-Type: application/json
{"destination":"https://example.com/docs","expiresAt":"2027-01-01T00:00:00Z"}{
"data": {
"id": "link_123",
"code": "aB91x",
"destination": "https://example.com/docs",
"expiresAt": "2027-01-01T00:00:00Z"
},
"meta": {"requestId": "req_456"},
"error": null
}- 在邊界驗證語法、大小、允許的 scheme、到期時間與租戶擁有權。
- 使用穩定的不透明識別碼;當循序 ID 會洩露敏感的規模或租戶資訊時,絕不要公開它們。
- 在資料庫中強制執行唯一性與狀態轉換,不要只依賴應用程式碼。
- 使用冪等性金鑰讓重試安全,並將請求指紋與結果一同儲存。
- 對變動中或大型資料集,優先使用游標分頁,而非偏移量分頁。
- 有意識地進行契約版本化;記錄錯誤代碼、可重試性與棄用期間。
- 將授權條件放入每個查詢範圍,包括快取金鑰與彙總。
CAP 定理與 BASE
CAP 適用於網路分割期間的分散式資料系統:它最多只能保證 一致性(Consistency)、可用性(Availability) 與 分割容錯性(Partition tolerance) 三者中的兩項。由於真實的分散式網路可能發生分割,實務上的決策通常是系統在分割期間如何運作:
- CP: 保留單一共識值,並拒絕或延遲部分請求。適用於唯一性、餘額、鎖定或狀態轉換等不適合過期寫入的情況。
- AP: 持續從可連線的副本提供服務,之後再進行協調。適用於動態消息、反應、遙測,或其他可用性與最終收斂比立即取得全域值更重要的工作負載。
CAP 並不是指「永遠選兩項」,也不是說每個端點都必須採用相同選擇。系統可以對連結建立採用 CP 行為,對重新導向採用偏 AP 的快取。
BASE——基本可用(Basically Available)、軟狀態(Soft state)、最終一致性(Eventual consistency)——是以 AP 為導向的實用方法:
- 基本可用: 儘管發生部分故障,仍回傳有用的回應。
- 軟狀態: 狀態可能在沒有新的使用者請求時變更,因為副本、快取或背景工作會逐漸收斂。
- 最終一致性: 如果停止寫入且系統保持健康,副本最終會達成一致。
說明收斂上限與衝突規則。沒有界限的「最終」不是需求。
PACELC 定理
PACELC 將正常運作情況加入 CAP:
如果發生 P 分割,選擇 A 可用性或 C 一致性;E 否則,選擇較低的 L 延遲或較強的 C 一致性。
範例:
- 全球複寫的資料庫可能偏好低延遲,從鄰近副本讀取,但接受複寫延遲。
- 由領導者路由的資料庫可能承擔跨區域延遲,以提供更新鮮的讀取結果。
- 快取通常可能偏好延遲,而失效處理或版本檢查則保護特定操作的正確性。
請按操作記錄選擇,不要把它當作整個產品的口號。
常見設計模式
- 無狀態水平擴展: 執行個體可替換;工作階段狀態屬於持久化或共享儲存。
- Cache-aside: 讀取快取,未命中時載入,然後填入快取;定義失效處理與快取踩踏控制。
- Write-through / write-behind: 將持久化與快取寫入耦合或解耦;write-behind 需要持久化緩衝與明確的遺失語義。
- CQRS: 將寫入/不變量強制執行與讀取最佳化投影分離;接受投影延遲與重建成本。
- 事件驅動處理: 發布持久化領域事件並非同步處理;針對重複交付與排序範圍進行設計。
- Outbox 模式: 以原子方式提交業務狀態與事件記錄,然後非同步發布 Outbox。
- Saga: 在不適合採用分散式交易時,以補償動作協調多步驟工作流程。
- Bulkhead 與 circuit breaker: 隔離依賴資源池並停止故障擴散;搭配逾時與後備行為。
- Token bucket / leaky bucket: 在明確的範圍(IP、使用者、租戶或 API 金鑰)強制執行速率或突發限制。
- 分片: 依穩定且高基數的金鑰分割;事先規劃熱金鑰、重新分片、跨分片查詢與再平衡。
- 一致性雜湊: 節點變更時減少資料移動;仍須監控偏斜與熱分割。
- 領導者選舉/租約: 使用到期時間與隔離權杖協調所有權;不要假設單靠鎖就能阻止過期擁有者採取行動。
模式是工具,不是預設值。每個模式都會增加狀態、故障模式與營運工作。
一致性、重試與故障處理
- 為每個網路跳躍設定期限;重試不得超出使用者請求的生命週期,也不得使依賴過載。
- 只對暫時性且具冪等性的操作進行重試,使用有界的指數退避與抖動。
- 避免重試風暴:限制嘗試次數、遵守
Retry-After、使用斷路器,並在請求樹中分配重試預算。 - 區分逾時、取消、驗證失敗、衝突、依賴失敗與提交結果未知。
- 對佇列,除非端到端已證明恰好一次語義,否則選擇至少一次交付。使用去重金鑰讓消費者具冪等性。
- 使用單調版本、compare-and-swap 或條件寫入處理並行更新。
- 定義排序範圍:全域排序成本高;每個聚合或分割的排序通常已足夠。
- 規劃毒性訊息處理、死信、重播、回填與結構描述相容性。
工具選擇
| 需求 | 從此開始 | 依此選擇 |
|---|---|---|
| 關聯式不變量與交易 | PostgreSQL 或相當的託管服務 | JOIN、寫入競爭、複寫、營運 |
| 鍵值或超大規模的可預測存取 | 託管 NoSQL 儲存 | 分割金鑰品質、一致性模式、存取模式 |
| 全文或分面搜尋 | OpenSearch/Elasticsearch 或託管搜尋服務 | 索引新鮮度、相關性、分片操作、成本 |
| 快取/暫時性協調 | Redis 相容服務 | 驅逐、持久化、高可用性、記憶體成本、鎖語義 |
| 持久化非同步工作 | 託管佇列或 Kafka 相容串流 | 交付、排序、重播、吞吐量、保留期限 |
| Blob 與備份儲存 | 物件儲存 | 生命週期政策、取回成本、持久性、出口流量 |
| 服務通訊 | 先用 HTTP/JSON;內部契約使用 gRPC | 可除錯性、結構描述演進、期限、工具支援 |
| 部署 | 容器搭配託管編排 | 團隊營運、自動擴展、可攜性、平台成本 |
| 基礎設施 | Terraform/OpenTofu 或平台原生 IaC | 可審查性、漂移控制、狀態安全性、成熟度 |
| 可觀測性 | OpenTelemetry + 指標/日誌/追蹤後端 | 基數、保留期限、警報品質、供應商鎖定 |
工具選擇取決於工作負載與團隊能力。託管服務的單位成本通常較高,但在工程時間、待命負載、修補與復原風險方面的成本較低。只有當自行託管元件所帶來的控制力、效能、合規性或單位經濟效益超過其負擔時,才有其合理性。
可擴展性與成本考量
- 擴展瓶頸,而不是每個元件: 衡量 CPU、記憶體、鎖競爭、資料庫 I/O、快取命中率、佇列年齡與尾端延遲。
- 在讀寫需求的擴展或一致性不同時,分離讀取與寫入路徑; 不要僅因為名詞不同就拆分服務。
- 有意識地使用快取: 計算命中率、失效成本、記憶體占用與過期資料風險。快取完全中斷時,未命中路徑仍必須安全。
- 保護依賴: 准入控制、有界佇列、負載丟棄、配額與背壓,通常比過度配置更便宜。
- 使用分層儲存: 熱資料放在快速儲存,冷資料放在物件儲存,並以生命週期政策處理到期與歸檔。
- 控制出口流量與複寫: 跨區域流量與重複儲存可能主導運算成本。
- 估算總持有成本: 基礎設施、可觀測性、備份、支援方案、待命時間、遷移與事件影響。
- 優先採用可逆決策: 保持可攜式資料格式、匯出路徑、經測試的還原程序與清楚的所有權邊界。
- 對尖峰與故障模式進行負載測試: 健康的平均基準測試無法揭示佇列成長、快取踩踏或重試放大。
安全性與可操作性檢查清單
- 驗證並授權每個受保護操作;在伺服器端強制租戶隔離。
- 驗證輸入並限制大小;使用參數化查詢與輸出編碼;防護 SSRF、路徑穿越與不安全重新導向。
- 對傳輸中與靜態資料加密;將密鑰保存在密鑰管理器中、進行輪替,且絕不記錄密鑰。
- 對服務、操作人員、佇列、儲存桶與資料庫角色套用最小權限。
- 記錄稽核事件,但不要洩露憑證或敏感有效負載;定義保留期限與存取權限。
- 使用結構化日誌,包含請求/關聯 ID、遮罩處理與有界基數。
- 對使用者影響的症狀發出警報:錯誤率、尾端延遲、飽和度、佇列年齡、複寫延遲與備份失敗。
- 測試還原、容錯移轉、依賴中斷、部署回滾、結構描述遷移,以及時鐘/區域故障,而不只是單元行為。
- 在上線前定義 SLO、錯誤預算、所有權、操作手冊與溝通計畫。
可編譯的 Go 範例:有界且可取消的工作程式階段
佇列支援的服務通常需要一個有界的工作程式階段來處理非同步工作。此範例明確管理所有權與關閉流程、傳播取消訊號,並回報第一個錯誤,而不產生無界的 goroutine。將它儲存為 main.go 並執行 go run .。
package main
import (
"context"
"errors"
"fmt"
"sync"
)
type Job struct {
ID int
Value int
}
type Result struct {
ID int
Square int
Err error
}
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if job.Value < 0 {
results <- Result{ID: job.ID, Err: errors.New("negative values are invalid")}
continue
}
results <- Result{ID: job.ID, Square: job.Value * job.Value}
}
}
}
func run(ctx context.Context, jobs []Job, workerCount int) ([]Result, error) {
if workerCount < 1 {
return nil, errors.New("worker count must be positive")
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
jobCh := make(chan Job)
resultCh := make(chan Result)
var workers sync.WaitGroup
workers.Add(workerCount)
for i := 0; i < workerCount; i++ {
go worker(ctx, jobCh, resultCh, &workers)
}
go func() {
defer close(jobCh)
for _, job := range jobs {
select {
case jobCh <- job:
case <-ctx.Done():
return
}
}
}()
go func() {
workers.Wait()
close(resultCh)
}()
results := make([]Result, 0, len(jobs))
var firstErr error
for result := range resultCh {
results = append(results, result)
if result.Err != nil && firstErr == nil {
firstErr = fmt.Errorf("job %d: %w", result.ID, result.Err)
cancel()
}
}
return results, firstErr
}
func main() {
results, err := run(context.Background(), []Job{{1, 3}, {2, 4}, {3, -1}}, 2)
fmt.Printf("results=%v error=%v\n", results, err)
}這是建構元件,不是完整的佇列系統。生產環境程式碼仍需要持久化入列語義、冪等處理、指標、重試/退避政策、死信處理,以及優雅的程序關閉。
最終設計審查問題
- 確切的讀取/寫入路徑、金鑰、不變量與一致性期望是什麼?
- 哪些資料是權威來源,以及如何修復副本、快取與投影?
- 發生分割、快取耗盡、依賴緩慢、訊息重複或寫入結果未知時會怎樣?
- 在 1 倍、10 倍與 100 倍流量下,第一個瓶頸是什麼?
- 團隊能否營運、還原、遷移並除錯每個選定的元件?
- 選擇了哪一項取捨——延遲、一致性、可用性、持久性、簡潔性或成本——以及如何衡量?
最佳設計不是元件最多的設計,而是那個行為、故障模式與營運成本都被充分理解,能夠安全演進的最小系統。