WeHelp
秒殺售票系統:四種庫存扣減策略的設計與實測(ORM / Raw SQL / ExecuteUpdate / Redis)
2026-06-27 16:25:43
我想知道...,一般的售票網站,當他們遇到熱門歌手演唱會售票的時候,假設不是抽籤制度,而是搶票制度,該怎麼承受,大量正常請求,不讓商品賣超 ## 先來談談不賣超這件事情: 我們要先知道樂觀鎖與悲觀鎖 > 樂觀鎖 (Optimistic Lock):假設衝突很少。不先上鎖,等到要寫入時才檢查「我讀到之後有沒有人動過」(常用版本號/RowVersion),有就放棄重來。衝突少時最快,衝突多時會狂報錯、狂重試。 > 悲觀鎖 (Pessimistic Lock):假設衝突很多。先把資料鎖住再動手,別人得排隊。正確性穩,但鎖一旦變熱點就是吞吐天花板。 因為想要測試高頻,所以這裡不想用悲觀鎖 正常情境下,樂觀鎖其實在大多情境都適用 但萬一高頻率的時候會發生什麼事情呢? 假設 100 個 request 進來,那麼就只有一個人成功,其他 99 人因為第 1 個成功更新的人,RowVersion 換了,系統認為資料被更新而噴掉 還有其他方法可以確保更新庫存的時候,不會賣超嗎? Raw SQL 的 Update 條件式更新可以做到 ``` Update 庫存 From 商品 Where 庫存 > 0 ``` 有資料被更新,代表他成功拿到庫存 再來是 Redis,先從 Redis 取庫存,有取到就可以進行下一步 單論效能我認為是 ``` Redis -> RawSql -> ORM ``` # 測試方式 我先依序寫好每個版本的 EndPoint 架構是: ``` v1 : ORM v2 : RawSql v3 : 語法糖 ( ExecuteUpdate ) 是否與 RawSql 等價測試 v4 : Redis ``` 在 AWS 開設兩台 EC2 一台是 Server ,一台是 K6 測試,用來 Post Server ## 方案一、v1:ORM + 樂觀鎖架構 V1 EndPoint 正常的使用 ORM 寫法 ```csharp var stock = await db.Stocks.FirstOrDefaultAsync(s => s.EventId == req.EventId); if (stock is null) { reason = Reasons.Error; break; } if (stock.Qty <= 0) { reason = Reasons.SoldOut; break; } stock.Qty -= 1; var order = new Order { EventId = req.EventId, UserId = req.UserId, Status = OrderStatus.Paid, CreatedAt = DateTime.UtcNow }; ...略 ``` ORM 轉譯完的 SQL,其實是「先 SELECT 讀、再 UPDATE 寫」兩段: ```sql -- ① 先讀(FirstOrDefaultAsync) SELECT TOP(1) [EventId], [Qty], [RowVersion] FROM [Stock] WHERE [EventId] = @eventId; -- ② 後寫(SaveChanges)。注意 WHERE 多帶了 RowVersion ← 這就是樂觀鎖 UPDATE [Stock] SET [Qty] = @newQty WHERE [EventId] = @eventId AND [RowVersion] = @original; -- 命中 0 筆 = 這期間有人改過 → EF 丟 DbUpdateConcurrencyException ``` 讀和寫之間隔了一次來回,100 個人讀到同一版 RowVersion,只有第一個寫得進去,其餘 99 個全部命中 0 筆 → 大量衝突。這就是 v1 在熱點下幾乎癱瘓的原因(實際數字見文末結論表),效能也相對最差。 ### 方案一-b、v1 加上 retry 機制 這還是同一個 v1 EndPoint,只是打開 `?retry=true` 開關。ORM 更新失敗(撞到上面的衝突)的時候,加上 Retry 重讀重試 ```csharp catch (DbUpdateConcurrencyException) { db.ChangeTracker.Clear(); // 清掉本次失敗的追蹤狀態 if (retry) continue; // retry :重讀重試直到成功或售完 reason = Reasons.Conflict; break; } ``` ## 方案二、v2:Raw SQL 條件式 Update 語法 Raw SQL 更新語法,不用 ORM 轉譯,利用條件式 Update ,有 Update 到資料代表成功搶到庫存 ```csharp var affected = await db.Database.ExecuteSqlRawAsync( "UPDATE Stock SET Qty = Qty - 1 WHERE EventId = {0} AND Qty > 0", req.EventId); if (affected == 1) { var order = new Order { EventId = req.EventId, UserId = req.UserId, Status = OrderStatus.Paid, CreatedAt = DateTime.UtcNow }; ...略 } ``` ## 方案三、v3:EF Core ExecuteUpdateAsync 是否與 Raw SQL 條件式 Update 等價 這單純只是我想知道,這個語法糖是否就是方案三的寫法 ```csharp var affected = await db.Stocks .Where(s => s.EventId == req.EventId && s.Qty > 0) .ExecuteUpdateAsync(s => s.SetProperty(x => x.Qty, x => x.Qty - 1)); if (affected == 1) { var order = new Order { EventId = req.EventId, UserId = req.UserId, Status = OrderStatus.Paid, CreatedAt = DateTime.UtcNow }; ...略 } ``` ## 方案四、v4:Redis 預扣 直接進 Redis 扣庫存,搶到庫存在去建立訂單 ```csharp var decr = (long)await redis.GetDatabase().ScriptEvaluateAsync( RedisScripts.DecrIfAvailable, new RedisKey[] { $"stock:{req.EventId}" }); if (decr < 0) reason = Reasons.SoldOut; // 不碰 DB else { var order = new Order { EventId = req.EventId, UserId = req.UserId, Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow, ExpireAt = DateTime.UtcNow.AddMinutes(10) }; ...略 } ``` # 最終結果比較 ### 結論表(2026-06 第一次真機壓測 ; ramping 50→400 VU , 庫存 5,000,000 不賣完) > **看吞吐請看 `OK/s`(成功/s),不是總 RPS。** > OK / SOLD_OUT / CONFLICT 都回 HTTP 200,總 RPS 會把大量「又便宜又多的 CONFLICT」也算進去——例如 v1 總 RPS 看起來有 788,但有效吞吐其實只有 33。 **測試環境:** | 角色 | 規格 | 說明 | |---|---|---| | 區域 | ap-northeast-1(東京) | — | | API Server | EC2 `t3.large` | api + redis 容器同機 | | 資料庫 | RDS `db.m5.large`(sqlserver-web) | 不對公網 | | 壓測機 | EC2 `t3.medium`(k6) | 同 VPC 內網打 `API:8080` | ![image](wehelp-storage://57c740978a590d6b1effc5e49ea635a9) #### 實測數據 | 版本 | OK/s(有效吞吐) | 總RPS(含失敗) | p95 (ms) | p99 (ms) | OK | CONFLICT | SOLD_OUT | |------|-----|-----|----------|----------|----|----------|----------| | v1(不retry) | **33** | 788 | 420 | 480 | 2,156 | 49,269 | 0 | | v1(retry) | 58 | 58 | 10,453 | **18,728** | 4,034 | 0 | 0 | | v2 raw SQL | **950** | 950 | 382 | 680 | 62,035 | 0 | 0 | | v3 ExecuteUpdate | **947** | 947 | 392 | 440 | 61,788 | 0 | 0 | | v4 Redis 預扣 | **1,818** | 1,818 | 214 | **248** | 118,394 | 0 | 0 | (全是乾淨 HTTP 200。) 1. 用 ORM,有效吞吐量是最低的 2. 用 Raw SQL 在效能上表現不錯,與 ORM 相比快了快 30 倍(950 ÷ 33 ≈ 29 倍) 3. ExecuteUpdate 寫法與 Raw SQL 差異不大,我想他轉譯出來的結果應該就是 條件式 Update 4. Redis 預扣效能是最好的,感覺不意外 ## 一個秒殺級售票系統 我會想這樣做: ![image](wehelp-storage://983997b9a78722b944e9b082810ce2df) 如果是一般的庫存系統 我認為哪個方法都行,應對狀況足以的那個就行了 未必每次都做到極致就是好,視情況而定