活動網站搶購系統:用 Go 與 Redis 扛住瞬間高併發
後端工程師,做過多檔大型活動/搶購網站,專注於高併發與限量資源的正確扣減。
活動網站最刺激的時刻,就是整點開搶的那一秒。平常每秒幾十個請求的系統,在開搶瞬間可能衝到每秒上萬。而且搶購有個比一般流量更難的點:庫存是有限的,而且絕對不能超賣。送出 100 個名額,結果賣出 101 個,就是事故。這篇講我怎麼用 Go 加 Redis 扛住這種場景。
第一件事:別讓流量打到資料庫
最直覺的寫法是「查庫存 → 夠就扣 → 寫訂單」,全部打 DB。這在開搶瞬間會瞬間把資料庫連線池打爆,然後整站一起陪葬。
正確的心態是:把「擋掉」和「成交」分開。絕大多數請求其實是搶不到的,你要做的是用最便宜的方式把它們快速擋在外層,只讓少量可能成交的請求進到核心。
我的分層大致是:
- 最外層:CDN/靜態化頁面,純看的人根本不該打到後端
- 接著:限流與「資格」檢查,擋掉重複點擊、機器人
- 庫存判斷:在 Redis,不在 DB
- 真正的下單與扣款:非同步,排隊處理
用 Redis 做原子扣庫存
庫存扣減的核心要求是原子性:判斷「還有沒有」和「扣一個」必須是不可分割的一步,否則併發下必超賣。
在 Redis 裡,我用 Lua script 把「檢查庫存 > 0 就遞減」包成一個原子操作。Redis 是單執行緒執行命令的,一段 Lua script 在執行期間不會被其他命令插隊,這正好就是我們要的原子性。
要點:
- 庫存值預先載入 Redis(預熱),別等開搶才從 DB 拉
- 扣減回傳剩餘量,小於 0 代表搶完,直接回「售罄」
- 同一個使用者的重複請求要先擋(用 Redis 的 SETNX 之類做去重),避免一個人扣多個
這樣絕大部分的「搶」都在 Redis 的記憶體裡解決,單機就能扛數萬 QPS,DB 完全不參與這一步。
削峰:用佇列把尖峰攤平
Redis 判定「搶到」之後,我不會當場同步去寫訂單、扣款——那又會把壓力導回 DB。而是把「搶到」這個事件丟進訊息佇列,後端用固定數量的 worker 依自己的步調消費,慢慢寫訂單。
使用者端的體驗是:點下去馬上得到「搶購成功,訂單處理中」,實際的訂單在背景幾秒內建立完成。這就是削峰填谷:把一秒鐘湧入的尖峰,攤平成幾十秒的平穩寫入,DB 從頭到尾都在舒適區。
一致性:Redis 扣了,DB 一定要補上
這裡有個必須面對的問題:Redis 扣成功了,但後續寫 DB 的 worker 掛了怎麼辦?那就會「庫存扣了但訂單沒建」,等於少賣。
處理方式:
- Redis 扣減和「搶到」事件要可靠地進到佇列(別 fire-and-forget)
- Worker 消費要冪等,同一個搶購事件重放不會建出兩張單
- 收尾要對帳:活動結束後比對 Redis 扣掉的數量 vs DB 實際成立的訂單數,有落差要能查、能補
我的原則是:寧可少賣、不可超賣。所以設計上讓「Redis 扣減」作為超賣的最終防線,DB 端再加一道唯一約束(同一使用者同一活動只能一張單)當保險。
別忘了「搶不到的人」
工程上很容易只關注成交路徑,但搶購系統 99% 的請求是失敗的。這些請求的體驗一樣重要:
- 售罄要快速、明確回應,不要讓人轉圈圈
- 真正的瓶頸常常不是扣庫存,而是搶不到的人不斷重整、重試帶來的二次流量
- 前端要做好「已售罄」的狀態鎖定,減少無效請求
小結
搶購系統的設計哲學就是一句話:用便宜的資源擋掉大多數,用昂貴的資源服務少數。
- 靜態化與限流擋在最外層
- 庫存判斷用 Redis + Lua 原子扣減,DB 不參與熱路徑
- 訊息佇列削峰,把尖峰攤平成平穩寫入
- 全鏈路冪等,事後對帳,寧少賣不超賣
做過幾次活動之後我才真正體會到:高併發系統的功力,不在於讓系統「跑得多快」,而在於讓系統在最極端的那一秒,該擋的擋得住、該對的一分不差。