Skip to content

Repository files navigation

Seckill — 電商秒殺系統的工程實錄(.NET 10)

限時搶購 / 閃購場景的高併發微服務系統。目標不是「跑得動」,而是 零超賣、可外推的容量模型,以及每一個數字都有可重跑的量測支撐。

這是一個研究性質的專案:從功能正確性做到效能攻堅,完整記錄了七個階段的 設計取捨、實測數據,以及被自己的實驗推翻的假設。後者往往比成功的優化更有價值。

實測 說明
零超賣 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
Loading

三條主線

削峰:熱路徑只碰 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 於叢集外獨立節點。 施壓端與受測端始終分離,且每份報告都說明施壓端規格 —— 施壓端未飽和是數據可信的前提。

階段一:正確性(30 萬請求 + 混沌)

指標 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。

階段三:連線規模(單機十萬條 SSE)

單一 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。

已知限制

  1. 搶購吞吐受叢集網路上限約束(約 12k RPS)—— 是這批虛擬機的傳輸天花板, 不是應用瓶頸;程式碼再調也到不了,只有加節點或換網路有意義。
  2. pod 內 sysctl 不繼承節點值:net.core.somaxconn 是 netns 隔離的, 節點調到 65535 後 pod 內仍是 4096。
  3. 壓測「單機承載」需暫時移除 NetworkPolicy:量測必須直連 pod IP, 而策略只允許 Gateway 來源。

設計決策 FAQ

防超賣怎麼做? 三道防線:① 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

About

Simulation project of e-commerce flash sale system

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages