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


























