購系統(tǒng)面試突擊:從入門到精通,3招搞定秒殺并發(fā)難題)
京東團(tuán)購系統(tǒng)面試突擊:從入門到精通,3招搞定秒殺并發(fā)難題
面試被問“高并發(fā)場景下如何保證庫存不超賣”,你腦子里是不是只剩一片空白?別慌,這種題目在電商、O2O領(lǐng)域幾乎是必考題。很多求職者覺得京東團(tuán)購這種業(yè)務(wù)離自己很遠(yuǎn),其實(shí)底層邏輯相通。今天這篇文章,就是帶你從入門到精通,把這塊硬骨頭啃下來。
考點(diǎn)梳理:面試官到底在考什么?
很多人一聽到“團(tuán)購”,腦子里想的是優(yōu)惠券、是價格戰(zhàn)。但在技術(shù)面試中,面試官關(guān)注的是高并發(fā)下的數(shù)據(jù)一致性和系統(tǒng)吞吐量。
京東團(tuán)購這類業(yè)務(wù)有幾個典型特征:瞬時流量大:開團(tuán)瞬間,成千上萬的用戶同時請求。
資源有限:商品庫存是有限的,賣完即止。
邏輯復(fù)雜:涉及庫存扣減、訂單創(chuàng)建、支付回調(diào)、超時取消等多個環(huán)節(jié)。面試官通過這道題,主要考察三個維度:基礎(chǔ)扎實(shí)度:你懂不懂?dāng)?shù)據(jù)庫事務(wù)、鎖機(jī)制(行鎖、表鎖、樂觀鎖、悲觀鎖)。
架構(gòu)設(shè)計能力:你能不能設(shè)計出削峰填谷的方案,比如消息隊列、緩存預(yù)熱。
異常處理能力:支付失敗怎么辦?訂單超時怎么辦?怎么保證最終一致性?如果只回答“用Redis預(yù)扣減庫存”,而不談后續(xù)數(shù)據(jù)庫落庫的一致性,基本就被Pass了。這就是典型的“答非所問”,只知其一不知其二。
標(biāo)準(zhǔn)答法:分層回答,體現(xiàn)深度
回答這類問題,切忌一上來就堆砌技術(shù)名詞。建議采用**“分層架構(gòu)”**的思路,從接入層到服務(wù)層,再到數(shù)據(jù)層,層層遞進(jìn)。
第一層:接入層(流量清洗與限流)
首先,流量不能直接打到業(yè)務(wù)服務(wù)。前端要加驗(yàn)證碼,防止惡意腳本攻擊。網(wǎng)關(guān)層要做限流,比如使用令牌桶算法,限制單IP或單用戶的請求頻率。這一步能把90%的無效流量擋在門外。
第二層:緩存層(熱點(diǎn)數(shù)據(jù)加速)
庫存是熱點(diǎn)數(shù)據(jù),頻繁讀寫數(shù)據(jù)庫會導(dǎo)致DB壓力過大。所以,必須將庫存放入Redis。用戶請求先查Redis,判斷庫存是否充足。如果不足,直接返回“已售罄”,不再走后續(xù)邏輯。這一步將數(shù)據(jù)庫的壓力轉(zhuǎn)移到了內(nèi)存上,速度提升幾個數(shù)量級。
第三層:業(yè)務(wù)層(異步解耦)
這是最關(guān)鍵的一步。不要同步扣減庫存和創(chuàng)建訂單。用戶請求到Redis扣減成功(原子操作 decr)后,立即返回前端“搶購成功,請支付”。同時,發(fā)送一條消息到消息隊列(如Kafka或RocketMQ)。消費(fèi)者異步處理訂單創(chuàng)建、數(shù)據(jù)庫庫存扣減、通知物流等邏輯。
第四層:數(shù)據(jù)層(最終一致性)
數(shù)據(jù)庫層面,使用樂觀鎖或悲觀鎖保證數(shù)據(jù)準(zhǔn)確。考慮到高并發(fā),樂觀鎖(版本號機(jī)制)通常性能更好。但要注意,如果消息隊列消費(fèi)失敗,要有重試機(jī)制和死信隊列,保證數(shù)據(jù)最終一致。
記住這個公式:前端限流 + Redis預(yù)扣減 + MQ異步削峰 + DB樂觀鎖兜底。這四步走下來,面試官會覺得你不僅懂技術(shù),還懂業(yè)務(wù)場景。
代碼實(shí)現(xiàn):Redis預(yù)扣減與Lua腳本
光說不練假把式。下面給出一段核心代碼,展示如何用Redis + Lua腳本實(shí)現(xiàn)原子性的庫存扣減。這里我們使用Python語言,通過redis-py庫(PyPI官方包)來操作。
為什么用Lua腳本?因?yàn)镽edis是單線程模型,執(zhí)行Lua腳本期間不會插入其他命令,保證了原子性。如果分兩步做(先GET判斷,再DECR),中間可能有其他請求插入,導(dǎo)致超賣。
import redis
import timeclass GroupBuyService:def __init__(self, host='localhost', port=6379, db=0):self.r = redis.Redis(host=host, port=port, db=db, decode_responses=True)# 定義Lua腳本,保證原子性self.lua_script = local stock_key = KEYS[1]local order_key = KEYS[2]local user_id = ARGV[1]local max_stock = tonumber(ARGV[2])-- 1. 檢查用戶是否已經(jīng)購買過(防刷單)if redis.call('SISMEMBER', order_key, user_id) == 1 thenreturn -1end-- 2. 獲取當(dāng)前庫存local stock = tonumber(redis.call('GET', stock_key))if stock == nil thenreturn -2end-- 3. 判斷庫存是否足夠if stock 0 then-- 扣減庫存redis.call('DECR', stock_key)-- 將用戶加入已購買集合redis.call('SADD', order_key, user_id)return 1elsereturn 0end# 注冊腳本,返回sha1值,減少網(wǎng)絡(luò)傳輸self.sha = self.r.script_load(self.lua_script)def pre_deduct_stock(self, product_id, user_id, initial_stock):stock_key = fstock:{product_id}order_key = forder:{product_id}# 初始化庫存(僅首次或重置時調(diào)用,生產(chǎn)環(huán)境需加鎖或冪等控制)if not self.r.exists(stock_key):self.r.set(stock_key, initial_stock)# 執(zhí)行Lua腳本result = self.r.evalsha(self.sha, 2, stock_key, order_key, user_id, initial_stock)if result == 1:return Trueelif result == -1:return False, 用戶已購買elif result == -2:return False, 商品不存在else:return False, 庫存不足# 測試用例
if __name__ == '__main__':service = GroupBuyService()# 模擬1000個用戶并發(fā)搶購100件商品import threadingsuccess_count = 0lock = threading.Lock()def buy(user_id):result = service.pre_deduct_stock(item_001, user_id, 100)if result is True:with lock:global success_countsuccess_count += 1print(fUser {user_id} Buy Success)else:# 處理失敗邏輯passthreads = []for i in range(1000):t = threading.Thread(target=buy, args=(fuser_{i},))threads.append(t)t.start()for t in threads:t.join()print(fTotal Success: {success_count}, Expected: 100)代碼解析:SISMEMBER 防重:通過Set結(jié)構(gòu)記錄已購買用戶,避免同一用戶多次搶購。
DECR 原子操作:直接扣減,避免先查后減的競態(tài)條件。
evalsha 優(yōu)化:將腳本預(yù)加載到Redis服務(wù)器,后續(xù)只需傳遞SHA1值,減少網(wǎng)絡(luò)開銷。
Python并發(fā)模擬:雖然Python有GIL,但在IO密集型任務(wù)(如網(wǎng)絡(luò)請求)中,多線程依然能體現(xiàn)并發(fā)效果。這里模擬了高并發(fā)場景,驗(yàn)證了邏輯的正確性。追問與延伸:那些刁鉆的問題
面試官不會讓你就這么結(jié)束,通常會追問幾個細(xì)節(jié):
Q1: Redis扣減成功了,但消息隊列發(fā)送失敗怎么辦?
A: 這是一個典型的分布式事務(wù)問題。
方案一:本地消息表。在數(shù)據(jù)庫事務(wù)中插入一條消息記錄,通過定時任務(wù)掃描并發(fā)送。
方案二:事務(wù)消息(如RocketMQ)。先發(fā)送Half Message,業(yè)務(wù)邏輯執(zhí)行成功后再Commit。
方案三:如果允許短暫不一致,可以先扣減Redis,再異步發(fā)消息。如果失敗,通過定時任務(wù)比對Redis庫存和DB庫存,進(jìn)行回滾或補(bǔ)償。
Q2: 如果Redis掛了,系統(tǒng)會崩嗎?
A: 不會。Redis可以做主從復(fù)制或哨兵模式。即使Redis不可用,可以降級策略:直接查數(shù)據(jù)庫,但此時要開啟限流,保護(hù)數(shù)據(jù)庫?;蛘?,如果業(yè)務(wù)允許,直接返回“系統(tǒng)繁忙,請稍后再試”。
Q3: 樂觀鎖在極端高并發(fā)下會有問題嗎?
A: 會。如果并發(fā)極高,樂觀鎖會大量重試,導(dǎo)致CPU空轉(zhuǎn)。這時候可以考慮分段鎖或者隊列串行化。比如,將庫存分成100段,每段100件,請求隨機(jī)分配到某一段。這樣沖突概率降低,性能提升。
Q4: 怎么監(jiān)控系統(tǒng)的健康狀態(tài)?
A: 關(guān)注QPS(每秒查詢率)、RT(響應(yīng)時間)、錯誤率。特別是Redis的內(nèi)存使用率、MQ的堆積量。如果MQ堆積嚴(yán)重,說明消費(fèi)能力不足,需要擴(kuò)容消費(fèi)者或優(yōu)化消費(fèi)邏輯。
記憶口訣:秒殺四步走
為了方便記憶,我總結(jié)了一個口訣,面試前默念三遍:
前端驗(yàn),網(wǎng)關(guān)限,Redis扣,MQ發(fā),DB鎖,終一致。前端驗(yàn):驗(yàn)證碼、滑塊,防機(jī)器人。
網(wǎng)關(guān)限:令牌桶、漏桶,防DDoS。
Redis扣:Lua腳本,原子操作,預(yù)扣減。
MQ發(fā):異步解耦,削峰填谷,解耦業(yè)務(wù)。
DB鎖:樂觀鎖、版本號,兜底數(shù)據(jù)準(zhǔn)確。
終一致:補(bǔ)償機(jī)制、重試、監(jiān)控,保證最終狀態(tài)正確。這個口訣不僅適用于京東團(tuán)購,也適用于任何高并發(fā)搶購場景,比如秒殺、搶票、搶紅包。掌握了這套邏輯,你就掌握了高并發(fā)設(shè)計的核心精髓。
結(jié)尾互動
技術(shù)沒有銀彈,只有權(quán)衡。在京東團(tuán)購這樣的場景中,你更傾向于使用Redis + MQ的異步方案,還是數(shù)據(jù)庫樂觀鎖的同步方案?或者你有其他更高效的實(shí)踐?
評論區(qū)交流你的想法,看看誰的設(shè)計更優(yōu)雅。如果這篇文章對你有啟發(fā),別忘了點(diǎn)贊收藏,下次面試前拿出來復(fù)習(xí)一遍,保你從容應(yīng)對。