限時搶購 / 閃購場景的高併發微服務系統。目標不是「跑得動」,而是 零超賣、可外推的容量模型,以及每一個數字都有可重跑的量測支撐。
這是一個研究性質的專案:從功能正確性做到效能攻堅,完整記錄了七個階段的 設計取捨、實測數據,以及被自己的實驗推翻的假設。後者往往比成功的優化更有價值。
| 實測 | 說明 | |
|---|---|---|
| 零超賣 | 30 萬請求 + 隨機殺 pod,受理數 = 庫存,一筆不多 | HTTP / DB / 帳目三層交叉驗證 |
| 吞吐 | 950 → 4,164 RPS(4.4×),p99 24.2s → 4.5s | 火焰圖驅動的三輪優化 |
| 連線承載 | 單機 108,001 條同時 SSE 連線 | 36.8 KB/連線、2.7 核 |
| 准入控制 | etcd 改值 1,671 ms 生效 | 聚合放行速率精確等於設定值 |
| 測試 | 單元 59 + 整合 29(Testcontainers) | dotnet build -warnaserror 零警告 |
量級目標的誠實說明:ROADMAP 原訂 M6 達成 30,000 RPS。實測發現本叢集 (5 台 4 vCPU PVE 虛擬機)的網路傳輸上限約 12k RPS,是硬體天花板而非應用瓶頸。 經專案決議,容量口徑改為「每 vCPU RPS + N 節點外推」並附實測驗證點 —— 詳見 docs/perf-tuning.md 第十一節。 零超賣為硬性要求,全程未放寬。
flowchart LR
C[Client / k6] -->|HTTP| T[Traefik Ingress]
T --> G["Gateway(YARP)<br/>JWT + 兩層令牌桶限流<br/>etcd watch 動態路由<br/>無通行 token → 302 排隊"]
G --> Q["WaitingRoom ×2<br/>SSE 長連線<br/>ZSET 排隊 / 每輪一次 ZRANGE 算位置<br/>准入發放(etcd 鎖單例)"]
G --> A["Seckill.Api ×2-8(HPA)<br/>活動閘門(etcd watch)<br/>冪等 SET NX<br/>Lua 分片扣減<br/>本地售罄快取"]
A -->|"produce OrderCreated<br/>(key=userId)"| K[("Kafka<br/>order.created ×12p")]
K --> W["OrderWorker ×1-6(KEDA)<br/>bounded Channel 背壓<br/>批次聚合 → COPY 落庫"]
W --> P[("PostgreSQL<br/>UNIQUE 冪等")]
A <--> R[("Redis<br/>stock:{a}:{shard}<br/>idem / result / queue / pub-sub")]
Q <--> R
W --> R
PM["Payment<br/>mock 支付<br/>逾時掃描(etcd 鎖)<br/>對帳 job(etcd 鎖)"] --> P
PM -->|"OrderCompensate"| K2[("Kafka<br/>order.compensate")]
K2 --> W
E[("etcd<br/>活動配置 / 服務註冊<br/>准入速率 / 分散式鎖")] -.watch.-> A
E -.watch.-> G
E -.watch.-> Q
PM --> E
削峰:熱路徑只碰 Redis 與 Kafka produce,202 立即返回,不同步寫 DB。
訂單經 bounded Channel 背壓 + 批次聚合,由 worker 以 COPY 落庫。
分層:資料平面用 Redis(分片庫存、冪等鍵、排隊佇列 —— 盡力而為,錯帳由對帳補救); 控制平面用 etcd(活動配置、實例 lease、單例 job 鎖 —— 錯了會製造新錯誤的事實,需要 Raft 一致性)。 兩者故障域互不牽連。
排隊:開賣期間未持通行 token 的搶購,在 Gateway 就被 302 導向虛擬排隊。
准入發放器(etcd 鎖選出的單一 leader)依速率從隊首批次發放一次性 token,
速率可經 etcd 熱調整。後端因此只需處理已放行的流量。
容器指令使用 podman;solution 為
.slnx新格式。
# 本機開發:etcd + Kafka(Redis / PostgreSQL 連外部節點,連線與金鑰放 .env,範本見 .env.example)
podman compose up -d
dotnet test # 單元 59 + 整合 29(Testcontainers 經 podman)
# 叢集部署(RKE2;前置與細節見 deploy/k8s/README.md)
./deploy/k8s/build-push.ps1 # 建置並推送六個服務映像
kubectl apply -k deploy/k8s/ # 全部 Ready 即可服務
# 壓測
k6 run -e BASE_URL=http://<node-ip> -e ACTIVITY_ID=9001 tests/Seckill.LoadTests/seckill.js
bash tests/Seckill.LoadTests/chaos-test.sh 9001 1000 120 15 # 壓測中隨機殺 pod
bash tests/Seckill.LoadTests/distributed/run-sse-flood.sh 9200 6 18000 300 500 # 十萬條 SSE環境:RKE2 5 節點(3 server HA + kube-vip、2 agent),每節點 4 vCPU / 8 GB(PVE 虛擬機, CPU 型別 host),Debian 13 + RKE2 v1.35.6。Redis 與 PostgreSQL 於叢集外獨立節點。 施壓端與受測端始終分離,且每份報告都說明施壓端規格 —— 施壓端未飽和是數據可信的前提。
| 指標 | Run 1(2000 VU) | Run 2(1000 VU + 隨機殺 pod) |
|---|---|---|
| 總請求數 | 303,669 | 263,225 |
| 202 受理(= 總庫存) | 1,000 / 1,000 | 1,000 / 1,000 |
| 409 售罄(本地快取短路) | 257,595 | 174,853 |
| 200 重複(冪等吸收重放) | 22,536 | 60,782 |
| 非預期回應(5xx / 斷線) | 0 | 32(0.012%,殺 pod 在途請求) |
零超賣的三層交叉驗證:HTTP 層恰 1,000 筆 202;DB 層 orders 恰 1,000 列且
COUNT(DISTINCT user_id) = 1000;帳目層 seckill_reconcile_diff = 0。
不變式「Redis 已扣 == DB 有效訂單」在滿載 → 混沌 → 大規模補償 → 歸零全程成立。
| before | after | |
|---|---|---|
| RPS | 950 | 4,164(4.4×) |
| p99 | 24.2 s | 4.5 s |
| 錯誤率 | 22% | 0 |
三項有效優化:請求合併(coalescing)、本地配額租約(帶 lease_id 且 fail-closed)、
pod 攤平約束。每項獨立 commit 並附同一腳本的 before/after。
單請求成本:2.56 毫核心-秒,即 390 RPS/vCPU。N 節點外推公式與實測驗證點見 docs/perf-tuning.md。
單一 waiting-room 副本(4 vCPU / 8 GB),施壓端 6 個 pod 散在其餘 4 節點:
| 指標 | 實測 |
|---|---|
| 同時 SSE 連線 | 108,001(施壓端 6 × 18,000,失敗 0) |
| 每連線記憶體 | 36.8 KB → 每 GiB 約 27,000 條 |
| CPU | 2.7 核 @ 108k → 約 40,000 條/核心 |
| 持續 | 190 秒無斷線、無重啟 |
線性驗證:6 萬條 1,659 m CPU、10.8 萬條 2,700 m —— 比值 1.80 對連線比 1.80。
- HPA(API,CPU 70%):30 秒內 2 → 4 → 8 副本,退載後回縮
- KEDA(worker,Kafka lag):灌 10 萬筆積壓 → 10 秒內 1 → 4、30 秒到上限 6, 40 秒清空(聚合 ≈15k msg/s)
這個專案最值得記錄的,是那些看起來合理、但被實驗推翻的假設。
| 假設 | 實驗 | 結果 |
|---|---|---|
| 跨網段路由是瓶頸 | 改在同網段施壓 | 無增益 —— 不是路由問題 |
| 負載不均是吞吐槓桿 | 加 topologySpreadConstraints 攤平 |
分布修正了,吞吐完全沒動 |
| Kafka ack 是牆 | 改 acks=0 |
更糟 —— 不是 ack 等待 |
| CPU 超額配置在傷害我們 | 量 CPU steal | 0% —— 超額配置無害 |
| Workstation GC 能省記憶體 | 十萬連線 A/B | 每連線 36.8→20.7 KB,但 GC 執行緒失守、行程停擺 60 秒被殺 |
| 基礎設施排擠了應用 CPU | 先量再動 | Longhorn+Prometheus 僅佔 4.4%,不值得動 —— 量完否決 |
用傳輸層二分法才定位到真正的天花板:k8s 路徑約 12k RPS、裸節點約 14.3k —— 是這批虛擬機的網路上限,不是應用瓶頸。先證偽,再優化,省下的是往錯誤方向重構的時間。
建置腳本靜默跳過 — build-push.ps1 的服務清單漏了新服務,-Only 匹配不到就
整個跳過並 exit 0。連續數輪「改 code → 重建 → 重啟」全是空轉,而三個關於程式碼的
假設全都合理,只是都建立在「新 code 已在跑」這個錯誤前提上。
戳破它的是 pod imageID digest 始終不變 —— 除錯前先驗「我改的東西真的在跑嗎」。
liveness 探針把尖峰變成停機 — 十萬連線時 pod 被 exit 137 殺掉,直覺猜 OOM,
但記憶體只用了 4.8/6 GiB。真因是 CPU 逼近 limit 時預設 timeoutSeconds: 1 的探針
拿不到回應,kubelet 於是重啟一台「忙但正常」的伺服器。
liveness 該偵測的是行程死了,不是行程很忙。
取樣率讓一整段鏈路消失 — 落庫 span 查不到,一度以為 trace 傳播壞了。
真因是為省 CPU 設的 TraceSampleRatio=0.01:請求 span 繼承 client 的 sampled 旗標
受 ParentBased 保護而留下,root span 不受保護,99% 被丟掉。
先入隊、後標記活躍 — 順序寫反會讓發放 leader 在兩步之間巡到空佇列、 把該活動從活躍集合清掉,而之後沒有任何路徑會加回去 —— 該活動的排隊者從此永遠等不到放行。
完整踩坑錄(18 條,含症狀 → 誤判過程 → 根因 → 解法)見 docs/troubleshooting.md。
- 搶購吞吐受叢集網路上限約束(約 12k RPS)—— 是這批虛擬機的傳輸天花板, 不是應用瓶頸;程式碼再調也到不了,只有加節點或換網路有意義。
- pod 內 sysctl 不繼承節點值:
net.core.somaxconn是 netns 隔離的, 節點調到 65535 後 pod 內仍是 4096。 - 壓測「單機承載」需暫時移除 NetworkPolicy:量測必須直連 pod IP, 而策略只允許 Gateway 來源。
防超賣怎麼做?
三道防線:① Redis Lua 原子扣減(單分片 GET+DECR 不可分割,全空回售罄);
② 訊息層 at-least-once,由 ③ DB UNIQUE(activity_id, user_id) + ON CONFLICT DO NOTHING 兜底。
30 萬請求 + 混沌殺 pod,受理數與庫存分毫不差。
冪等怎麼做?
三層:HTTP 層 SET seckill:idem:{a}:{u} NX EX 300(重複回原 requestId);
消費層 DB UNIQUE 吸收重投遞;補償層「DB 狀態轉移 WHERE status='created' + Redis SET NX 標記」
雙閘 —— 重複補償與崩潰重投遞都恰好生效一次。
削峰怎麼做?
熱路徑只碰 Redis 與 Kafka produce(202 立即返回);worker 以 bounded Channel
(容量 10000,滿則暫停拉取形成背壓)+「湊滿 BatchSize 或逾時先到」聚合,COPY 批次落庫;
售罄後本地快取短路,25 萬筆售罄流量零 Redis 往返。
Saga 補償怎麼做?
訂單 5 分鐘未支付 → 掃描 job(etcd 鎖 leader 模式)發 OrderCompensate →
worker 取消訂單 + 回補原分片 + 清冪等鍵(可重搶)+ 廣播清售罄快取。
掃描只發訊息不改狀態,重發由冪等吸收(自癒);對帳 job 持續核對
「Redis 已扣 == DB 有效訂單」,差異出 metric + error log。
排隊與准入怎麼做?
Redis ZSET 以進隊時間為 score,每 2 秒批次推播一輪(不是每人每秒)。
位置的取得方式改過一次:原本每輪對每條連線各一次 ZRANK,十萬連線下是
每秒 5 萬次 Redis 操作,實測 Redis 會先於應用飽和(RedisTimeoutException)。
改為每輪對每個活動一次 ZRANGE、在行程內算出全部名次後,降到每秒數百以下 ——
等於把成本從全叢集共用的單點,搬到可以水平擴的應用端。
准入發放器由 etcd 鎖選出單一 leader 並持鎖不放:每輪重新搶鎖會讓副本輪流 當 leader,聚合速率變成「設定值 × 副本數」。速率在 etcd 查不到時 fail-closed (完全不放行)—— 若改成沿用預設值兜底,控制面失聯或活動漏設時閘門會自動全開, 那正是最需要它的時刻。
擴縮策略?
API 用 HPA(CPU,秒級擴、5 分鐘穩定窗回縮 —— 秒殺流量來得快去得慢);
worker 用 KEDA(consumer lag —— 消費型負載的正確指標,CPU 會騙人),
maxReplica ≤ partition 數(超過只是空轉);Gateway / API 皆多副本 + PDB。
為什麼同時要 Redis 和 etcd? 資料平面 vs 控制平面。Redis 扛熱路徑(可補償的帳);etcd 管「誰持有鎖、誰活著、 配置是什麼」(錯了會製造新錯誤的事實)—— watch 可靠回放(revision 接續)、 lease 綁生命週期、Raft 一致性,故障域互不牽連。
認證放哪一層?
邊緣終結:Gateway 驗 JWT 並注入身分標頭,後端信任該標頭。
這讓熱路徑省下每請求一次的簽章驗證,代價是後端必須在網路層封閉 ——
NetworkPolicy 限定只收 Gateway 流量,否則叢集內任何 pod 都能冒用任意身分。
兩者缺一不可。
src/ 六個服務(Gateway / Api / Activity / OrderWorker / Payment / WaitingRoom)
+ Shared(契約、Redis 鍵、etcd 抽象、認證、遙測)
tests/ 單元 / 整合(Testcontainers)/ 壓測(k6 + 自製 SSE 施壓工具)
deploy/k8s/ kustomize:應用、基礎設施、設定、NetworkPolicy、節點調優腳本
docs/ 效能量測、踩坑錄、測試報告、火焰圖
| 文件 | 內容 |
|---|---|
| ROADMAP.md | 七階段規格與驗收標準(實作的唯一依據) |
| docs/perf-tuning.md | 12 節效能量測:火焰圖、CPU 歸因、容量外推、連線規模調優 |
| docs/troubleshooting.md | 18 條踩坑錄,每條含症狀 → 誤判 → 根因 → 解法 |
| docs/test-reports/ | 各階段完整測試報告 |
| CLAUDE.md | 開發守則(一次一階段、驗收逐項執行、效能變更必附 before/after) |
.NET 10 Minimal API · YARP · StackExchange.Redis · Confluent.Kafka · Npgsql(COPY,熱路徑無 ORM)
· dotnet-etcd · OpenTelemetry + Serilog + Tempo · System.Text.Json source generator
· RKE2 + Traefik + Longhorn + KEDA + kube-prometheus-stack · k6
| 階段 | 完成日 | 關鍵成果 |
|---|---|---|
| M0 骨架 | 2026-07-22 | solution / 六專案 / 共用件;compose 起 etcd + Kafka 自動建 topic |
| M1 熱路徑 | 2026-07-23 | Lua 分片扣減、冪等、售罄快取;1000 併發打 100 庫存恰 100 筆 |
| M2 批次落庫 | 2026-07-23 | 手動 commit → Channel 背壓 → COPY;優雅關機 1000 筆零丟單 |
| M3 配置/路由 | 2026-07-23 | 配置熱更新 < 2s;kill 實例 < 10s 摘除路由;etcd 鎖初始化恰一次 |
| M4 Saga/對帳 | 2026-07-23 | 逾時 100% 取消回補、重複補償零副作用;reconcile diff = 0 |
| M5 壓測/部署 | 2026-07-24 | 30 萬請求零超賣;混沌 ×2 PASS;HPA 2→8、KEDA 1→6 |
| M6 吞吐攻堅 | 2026-07-25 | 950 → 4,164 RPS、p99 24.2s → 4.5s、錯誤歸零 |
| M7 十萬併發 | 2026-07-26 | 單機 108,001 條 SSE;准入 1,671 ms 生效;全鏈路 trace |