秒殺售票系統:四種庫存扣減策略的設計與實測(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` |

#### 實測數據
| 版本 | 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 預扣效能是最好的,感覺不意外
## 一個秒殺級售票系統
我會想這樣做:

如果是一般的庫存系統
我認為哪個方法都行,應對狀況足以的那個就行了
未必每次都做到極致就是好,視情況而定
點擊複製文章連結