大促風暴眼:10萬+/秒請求下,百萬優惠券如何精準發放不超發?
每一場電商大促,都是一場沒有硝煙的技術戰爭。而“優惠券”作為刺激消費的核心武器,其發放系統的穩定性與準確性,直接關系到用戶體驗和平臺的真金白銀。想象一下這樣一個場景:平臺精心準備了100萬張優惠券,作為引爆流量的爆點。活動上線瞬間,洶涌的流量洪峰撲來,每秒超過10萬次的請求高喊著:“給我一張券!”。此時,系統后端那記錄著“庫存:1,000,000”的數據庫,成為了風暴的中心。
超發——那個不能承受之痛
所謂“超發”,就是系統發出的優惠券數量超過了預設的庫存。這不僅僅是“多發了幾張券”那么簡單,它會導致:
1. 資損風險:超發的優惠券被用戶使用,平臺需要承擔額外的成本。
2. 用戶投訴與輿情危機:搶到券的用戶發現無法使用,或訂單被取消,會引發大面積的用戶不滿和信任危機。“玩不起就別玩”的輿論會迅速發酵。
3. 平臺信譽受損:一次超發事故,足以讓平臺長期建立的公信力大打折扣。
那么,在每秒10萬次請求的沖擊下,我們如何構建一個固若金湯的防超發系統,確保發出去的每一張券都在100萬庫存之內呢?讓我們從最簡單的方案開始,逐步深入到能夠抵御洪峰的架構。
第一章:天真與陷阱 —— 為什么簡單的SQL更新會失靈?
很多開發者的第一反應可能是:這還不簡單?在發券時,先查詢一下當前庫存,如果大于0,再執行庫存扣減和發券操作。
對應的SQL偽代碼可能是這樣:
-- 1. 查詢庫存
SELECT stock FROM coupon WHERE id = #{couponId};
-- 2. 應用層判斷
if (stock > 0) {
// 3. 扣減庫存
UPDATE coupon SET stock = stock - 1 WHERE id = #{couponId};
// 4. 給用戶發券
INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId});
}這個邏輯在單線程或低并發下完美無缺。但在每秒10萬請求的并發環境下,它不堪一擊。問題就在于競態條件(Race Condition)。
并發場景模擬:
假設此時庫存只剩1張,同時有兩個用戶A和B發來了請求。
1. 請求A和請求B同時執行了 SELECT 語句,它們都讀到了 stock = 1。
2. 兩個請求在應用邏輯判斷中都順利通過 (stock > 0)。
3. 請求A先執行了 UPDATE,將庫存成功扣減為0。
4. 緊接著,請求B也執行了 UPDATE。由于它不知道庫存已經被A修改,這條語句依然會執行成功(MySQL的Update本身是原子的,stock = stock - 1 會導致 stock = -1!)。
結果: 1張庫存,發出了2張券,超發了!
問題的根源在于,“查詢”和“更新”是兩個獨立的操作,它們組成的復合邏輯在并發下不是原子性的。
第二章:數據庫的銅墻鐵壁 —— 悲觀鎖與樂觀鎖
要解決原子性問題,我們首先想到的就是求助數據庫的“鎖”。
方案一:悲觀鎖 —— “先占坑,再辦事”
悲觀鎖的思想是,我認為任何時候都會發生并發沖突,所以我在操作數據之前,先把它鎖住,讓別人無法操作。
在MySQL中,我們可以使用 SELECT ... FOR UPDATE 來實現。
BEGIN; -- 開啟事務
-- 1. 查詢并鎖定這條優惠券記錄
SELECT stock FROM coupon WHERE id = #{couponId} FOR UPDATE;
-- 2. 判斷庫存
if (stock > 0) {
// 3. 扣減庫存
UPDATE coupon SET stock = stock - 1 WHERE id = #{couponId};
// 4. 給用戶發券
INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId});
}
COMMIT; -- 提交事務,釋放鎖工作原理: 當請求A執行 SELECT ... FOR UPDATE 時,數據庫會為這條記錄加上行鎖。在事務提交前,請求B執行同樣的語句會被阻塞,直到請求A的事務結束釋放鎖。此時請求B讀到的 stock 已經是0,判斷失敗,不會發券。
優缺點:
? 優點:簡單,能有效防止超發。
? 缺點:
性能瓶頸:所有請求串行化,在高并發下,數據庫連接迅速被占滿,大量請求排隊等待,導致系統響應緩慢甚至超時。10萬QPS直接壓垮數據庫。
死鎖風險:復雜的鎖依賴可能導致死鎖。
結論:悲觀鎖適用于并發量不高的場景,在10萬QPS的洪峰下,它不是一個可行的選擇。
方案二:樂觀鎖 —— “相信美好,但驗證一下”
樂觀鎖的思想與悲觀鎖相反,我認為沖突很少發生,所以我不加鎖,直接去更新。但在更新時,我會檢查一下在我之前有沒有人修改過這個數據。
通常我們使用一個版本號(version)字段來實現。
表結構增加一列:version int。
-- 1. 查詢當前庫存和版本號
SELECT stock, version FROM coupon WHERE id = #{couponId};
-- 2. 應用層判斷庫存
if (stock > 0) {
// 3. 扣減庫存,但附加上版本號條件
UPDATE coupon SET stock = stock - 1, version = version + 1
WHERE id = #{couponId} AND version = #{oldVersion};
// 4. 判斷UPDATE是否成功
if (affected_rows > 0) {
// 更新成功,說明沒有并發沖突,發券
INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId});
} else {
// 更新失敗,說明在我查詢之后,庫存已經被別人修改。重試或返回失敗。
}
}工作原理: 請求A和B都讀到了 version = 1。請求A先執行Update,條件 version=1 成立,庫存被扣減,同時 version 變為2。請求B再執行Update時,條件 version=1 已經不成立,所以更新影響行數為0,請求B失敗。
優缺點:
? 優點:避免了悲觀鎖的巨大性能開銷,適合讀多寫少的場景。
? 缺點:
高失敗率:在極高并發下,大量請求會更新失敗,用戶體驗不佳(明明看到有券,一點就沒了)。
需要重試機制:通常需要配合重試(例如在應用層循環重試幾次),增加了復雜度。
結論:樂觀鎖比悲觀鎖性能好很多,但在瞬時10萬QPS的極端場景下,大量的失敗和重試對數據庫的沖擊依然不小,并非最優解。
第三章:邁向巔峰 —— 將庫存前置到緩存
數據庫終究是持久化存儲,其IO性能有上限。要應對10萬QPS,我們必須將主戰場轉移到更快的內存中。這就是引入緩存(如Redis)的原因。
方案三:Redis原子操作 —— “一錘定音”
Redis是單線程工作模型,所有的命令都是原子執行的。我們可以利用這個特性,將庫存扣減這個核心邏輯放在Redis中完成。
步驟:
1. 預熱:活動開始前,將庫存數量100萬寫入Redis。
SET coupon_stock:123 10000002. 扣減:用戶請求時,使用Redis的 DECR 或 DECRBY 命令。
// 偽代碼示例
public boolean tryAcquireCoupon(Long couponId, Long userId) {
// 使用 DECR 原子性扣減庫存
Long currentStock = redisTemplate.opsForValue().decrement("coupon_stock:" + couponId);
if (currentStock >= 0) {
// 扣減成功,庫存>=0,說明用戶搶到了資格
// 此時可以異步地向數據庫寫入發券記錄
asyncService.sendMessageToMQ("coupon_acquired", userId, couponId);
return true;
} else {
// 扣減后庫存小于0,說明已搶光,需要回滾剛才的扣減
redisTemplate.opsForValue().increment("coupon_stock:" + couponId);
return false;
}
}為什么是原子性的?DECR 命令在Redis內部一步完成“讀取-計算-寫入”,不存在并發干擾。即使10萬個請求同時執行 DECR,Redis也會讓它們排隊,一個一個執行。第一個請求執行后庫存變為999999,第二個變為999998...直到0,然后是-1, -2...
關鍵點:
? 判斷時機:我們通過判斷 DECR 后的結果是否 >=0 來決定是否成功。等于0是最后一張,大于0是普通情況,小于0則意味著超發(我們通過后面的 INCR 進行回滾,實際上并未超發)。
? 異步落庫:Redis只負責處理最核心的庫存扣減邏輯。真正的發券記錄(寫入用戶券表)可以通過消息隊列異步化,這樣就把數據庫的巨大寫入壓力給化解了。
這個方案已經非常強大了,但它還有一個潛在問題:在庫存為1時,瞬間有1萬個請求執行了 DECR,實際上只有1個請求會成功(結果=0),另外9999個請求都會失敗(結果<0)。雖然邏輯正確,但這9999次對Redis的寫操作其實是浪費的,因為庫存明明已經沒了。
方案四:Redis + Lua腳本 —— “終極武器”
我們可以通過Lua腳本,將“判斷庫存”和“扣減庫存”等多個操作在Redis服務端一次性、原子性地完成,從而獲得極致的性能和控制力。
Lua腳本在Redis中執行時,可以視為一個事務,不會被其他命令打斷。
-- try_acquire_coupon.lua
local stockKey = KEYS[1] -- 庫存Key
local userId = ARGV[1] -- 用戶ID
local couponId = ARGV[2] -- 券ID
-- 1. 獲取當前庫存
local stock = tonumber(redis.call('GET', stockKey))
-- 2. 庫存不足,直接返回
if stock <= 0 then
return -1 -- 庫存不足的標識
end
-- 3. 庫存充足,執行扣減
redis.call('DECR', stockKey)
-- 這里理論上還可以做更多事情,比如將用戶ID寫入一個“搶到券的用戶集合”,用于防重復搶購
-- redis.call('SADD', 'coupon_winner_set', userId)
return 1 -- 搶券成功的標識在Java應用中調用該腳本:
// 預加載腳本,獲取一個sha1標識
String script = "lua腳本內容...";
String sha = redisTemplate.scriptLoad(script);
public boolean tryAcquireCoupon(Long couponId, Long userId) {
List<String> keys = Arrays.asList("coupon_stock:" + couponId);
Object result = redisTemplate.execute(
new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
// 使用evalsha執行腳本,性能更好
return connection.evalSha(sha, ReturnType.INTEGER, 1,
keys.get(0).getBytes(),
userId.toString().getBytes(),
couponId.toString().getBytes());
}
}
);
Long res = (Long) result;
if (res == 1) {
// 成功,異步落庫
asyncService.sendMessageToMQ("coupon_acquired", userId, couponId);
return true;
} else {
// 失敗,res == -1
return false;
}
}Lua腳本方案的優勢:
1. 極致的原子性:所有邏輯在一個腳本中完成,無競態條件。
2. 極少的網絡IO:一次腳本調用代替了多次 GET/DECR 等命令的往返。
3. 可擴展性:可以在腳本內輕松實現更復雜的邏輯,如記錄用戶ID防止同一用戶重復搶購(通過Redis的Set結構)。
4. 性能巔峰:這是應對超高并發讀寫的終極方案,能夠最大限度地發揮Redis的性能。
第四章:構建完整的防御體系
單一的技術方案再強大,也需要一個完整的系統架構來支撐。一個成熟的大促發券系統,還需要考慮以下方面:
1. 網關層限流與防護:在流量入口(如API網關)就進行限流,將超過系統處理能力的請求直接拒絕掉,保護下游服務。例如,設置每秒最多通過15萬個請求。
2. 緩存集群與分片:單機Redis可能有性能瓶頸或單點故障風險。我們需要使用Redis集群,并通過合理的分片策略(例如按優惠券ID分片),將不同優惠券的請求分散到不同的Redis節點上。
3. 異步化與消息隊列:正如前面提到的,搶券資格判斷(Redis操作)和實際發券(數據庫操作)必須解耦。使用RabbitMQ、RocketMQ或Kafka,將搶券成功的消息發送到隊列,由下游的消費者服務按自己的能力從隊列中取出消息,平穩地寫入數據庫。
4. 令牌桶或漏桶算法:在應用層,可以使用令牌桶算法進一步平滑請求,防止瞬間流量沖垮Redis。例如,每秒只發放500個令牌到令牌桶,請求拿到令牌后才能去執行Lua腳本搶券。
5. 降級與熔斷:如果Redis或數據庫出現異常,系統需要有自動降級策略(如直接返回“活動太火爆”頁面)和熔斷機制,防止雪崩效應。
總結
面對“100萬庫存,10萬+/秒請求”的極端場景,我們的技術選型路徑是清晰的:
初級方案(不可行):查詢再更新 → 必然超發中級方案(不適用):數據庫悲觀/樂觀鎖 → 性能瓶頸高級方案(可行):Redis原子操作(DECR) → 性能良好,略有浪費終極方案(推薦):Redis Lua腳本 + 異步消息隊列 + 網關限流
這個終極方案的精髓在于:
? 核心邏輯原子化:利用Redis單線程和Lua腳本的原子性,在內存中完成最關鍵的庫存扣減判斷,速度快且絕不超發。
? 讀寫操作解耦:前端快速判斷資格,后端異步持久化數據,保護脆弱的關系型數據庫。
? 流量層層過濾:通過網關限流、應用層限流等手段,將超出系統設計容量的流量提前拒之門外。
通過這樣一套組合拳,我們才能在大促的流量風暴中,真正做到忙而不亂,精準發放,讓每一張優惠券都“師出有名”,守護好系統的穩定與平臺的聲譽。這正是高并發系統設計的藝術與魅力所在。

























