京東搶購(gòu)服務(wù)高并發(fā)實(shí)踐
服務(wù)介紹
限時(shí)搶購(gòu)又稱閃購(gòu),英文Flash sale,起源于法國(guó)網(wǎng)站Vente Privée。閃購(gòu)模式即是以互聯(lián)網(wǎng)為媒介的B2C電子零售交易活動(dòng),以限時(shí)特賣的形式,定期定時(shí)推出國(guó)際知名品牌的商品,一般以原價(jià)1-5折的價(jià)格供專屬會(huì)員限時(shí)搶購(gòu),每次特賣時(shí)間持續(xù)5-10天不等,先到先買,限時(shí)限量,售完即止。顧客在指定時(shí)間內(nèi)(一般為20分鐘)必須付款,否則商品會(huì)重新放到待銷售商品的行列里。
模式特征:
品牌豐富 —— 推出國(guó)內(nèi)外一二線名牌商品,供消費(fèi)者購(gòu)買選擇;
時(shí)間短暫 —— 每個(gè)品牌推出時(shí)間短暫,一般為5—10天,先到先買,限量售賣,售完即止;
折扣超低 —— 以商品原價(jià)1—5折的價(jià)格銷售,折扣力度大。
摘自【百度百科】,通過這段簡(jiǎn)介相信對(duì)限時(shí)搶購(gòu)有了一定的了解,我們內(nèi)部稱之為搶購(gòu)系統(tǒng)。
對(duì)于搶購(gòu)系統(tǒng)來說,首先要有可搶購(gòu)的活動(dòng),而且這些活動(dòng)具有促銷性質(zhì),比如直降500元。其次要求可搶購(gòu)的活動(dòng)類目豐富,用戶才有充分的選擇性。618(6.1-6.20)期間增量促銷活動(dòng)量非常多,可能某個(gè)活動(dòng)力度特別大,大多用戶都在搶,必然對(duì)系統(tǒng)是一個(gè)考驗(yàn)。這樣搶購(gòu)系統(tǒng)具有秒殺特性,并發(fā)訪問量高,同時(shí)用戶也可選購(gòu)多個(gè)限時(shí)搶商品,與普通商品一起進(jìn)購(gòu)物車結(jié)算。這種大型活動(dòng)的負(fù)載可能是平時(shí)的幾十倍,所以通過增加硬件、優(yōu)化瓶頸代碼等手段是很難達(dá)到目標(biāo)的,所以搶購(gòu)系統(tǒng)得專門設(shè)計(jì)。
服務(wù)主要功能
創(chuàng)建促銷服務(wù):采銷創(chuàng)建促銷后,促銷管理系統(tǒng)審核通過后,會(huì)調(diào)用搶購(gòu)系統(tǒng)創(chuàng)建促銷;
搶服務(wù):為符合條件的訂單操作剩余數(shù),主要是扣減剩余數(shù);
針對(duì)哪些SKU
目前主要為單品促銷,直降或者一口價(jià),比如:
主要渠道
移動(dòng)APP、微信、手Q和主站
限購(gòu)類型
限數(shù)量、限ip、限pin和限制ip與pin
系統(tǒng)設(shè)計(jì)要點(diǎn)
如何實(shí)現(xiàn)實(shí)時(shí)庫(kù)存?
這里說的庫(kù)存不是真正意義上的庫(kù)存,其實(shí)是該促銷可以搶購(gòu)的數(shù)量,真正的庫(kù)存在基礎(chǔ)庫(kù)存服務(wù)。用戶點(diǎn)擊『提交訂單』按鈕后,在搶購(gòu)系統(tǒng)中獲取了資格后才去基礎(chǔ)庫(kù)存服務(wù)中扣減真正的庫(kù)存;而搶購(gòu)系統(tǒng)控制的就是資格/剩余數(shù)。傳統(tǒng)方案利用數(shù)據(jù)庫(kù)行鎖,但是在促銷高峰數(shù)據(jù)庫(kù)壓力過大導(dǎo)致服務(wù)不可用,目前采用redis集群(16分片)緩存促銷信息,例如促銷id、促銷剩余數(shù)、搶次數(shù)等,搶的過程中按照促銷id散列到對(duì)應(yīng)分片,實(shí)時(shí)扣減剩余數(shù)。當(dāng)剩余數(shù)為0或促銷刪除,價(jià)格恢復(fù)原價(jià)。
如何設(shè)計(jì)搶購(gòu)redis數(shù)據(jù)結(jié)構(gòu)?
采銷人員發(fā)布促銷后,在搶購(gòu)redis中生成一筆記錄,給搶服務(wù)提供基本信息。每一個(gè)促銷對(duì)應(yīng)一個(gè)促銷id,促銷信息是Hashes結(jié)構(gòu)。
例如促銷A,對(duì)應(yīng)的類型為單品促銷,我們暫且認(rèn)為類型值為1,對(duì)應(yīng)redis中的key為 C_A_1,數(shù)據(jù)結(jié)構(gòu)內(nèi)容類似于如下:
- o: 100 // 原始數(shù)量
- b: 99 // 可搶購(gòu)數(shù)量,假如搶購(gòu)了一個(gè)剩下了99
- c: 1 // 搶購(gòu)次數(shù)記錄,用來限流,后面會(huì)介紹到
如何保證不超賣?
因?yàn)榭蹨p資格是一組操作,我們利用EVAL操作redis剩余數(shù)實(shí)現(xiàn)原子化操作,偽代碼如下:
- local key = KEYS[1]
- local tag = "b"
- local num = tonumber(ARGV[1]);
- local lastNum = redis.call('HINCRBY',key,tag,-num);
- if業(yè)務(wù)性判斷ortonumber(lastNum) == 0then
- return lastNum
- end
如上代碼會(huì)返回剩余數(shù),如果小于等于0了,則沒有庫(kù)存了。
如何提高吞吐量?
減少網(wǎng)絡(luò)交互(一次搶數(shù)據(jù)通過 EVALSHA 一次性提交給redis集群);數(shù)據(jù)庫(kù)操作異步化(使用JMQ異步記錄日志)。
如何保證可用性?
采用JSF(京東內(nèi)部SOA框架)對(duì)外開放服務(wù)(搶服務(wù)和發(fā)布促銷服務(wù)),可降級(jí)為系統(tǒng)自身webservice服務(wù);
搶購(gòu)系統(tǒng)主要依賴于redis集群,redis采用一主三從集群方案,部署在兩個(gè)機(jī)房,每個(gè)集群16個(gè)分片,每?jī)煞制灿靡慌_(tái)物理機(jī),可通過配置中心切換主從;
如果Redis掛掉了,如何恢復(fù)呢?通過匯總MySQL中的搶購(gòu)和取消流水日志,并恢復(fù)Redis的搶購(gòu)數(shù)量。
系統(tǒng)架構(gòu)
這里主要涉及搶服務(wù)架構(gòu)剖析,因?yàn)樗哂械湫偷母卟l(fā)特性,下面是基本架構(gòu)概圖:
注:此處的庫(kù)存是可搶購(gòu)數(shù)量設(shè)置,或者叫做資格/剩余數(shù),并非真正的實(shí)際庫(kù)存。
搶服務(wù)流程
Redis使用單個(gè)Lua解釋器去運(yùn)行所有腳本,并且Redis 也保證腳本會(huì)以原子性(atomic)的方式執(zhí)行:當(dāng)某個(gè)腳本正在運(yùn)行的時(shí)候,不會(huì)有其他腳本或Redis命令被執(zhí)行。這種特性很好的解決了搶服務(wù)流程中并發(fā)帶來的問題。
REDIS+LUA搶購(gòu)子流程:
此流程通過lua Script腳本實(shí)現(xiàn),我們暫時(shí)命名為q.lua(主要功能限流和扣減促銷活動(dòng)剩余數(shù))。這樣把搶購(gòu)流程與Script腳本結(jié)合,一次性提交給Redis減少網(wǎng)絡(luò)交互,使得性能大大提升。
q.lua偽代碼:
- --[[
- --!@brief 促銷Id下限流:可以防止某個(gè)促銷過熱導(dǎo)致服務(wù)不可以用
- --]]
- local function limited()
- -- todo: 實(shí)現(xiàn)
- end
- --[[
- --!@brief 限制邏輯(ip和pin):比如有的促銷是限制ip,這里校驗(yàn)ip是否存在,如果為限ip類型搶購(gòu)活動(dòng),存在拋出異常告知ip已經(jīng)存在不能搶購(gòu)
- --]]
- local function check_ip_pin()
- -- todo: 實(shí)現(xiàn)
- end
- --[[
- --!@brief 記錄訂單號(hào):主要目的實(shí)現(xiàn)搶方法冪等性,調(diào)用方網(wǎng)絡(luò)超時(shí)可以重復(fù)調(diào)用,存在訂單號(hào)直接返回?fù)屬?gòu)成功,不至于超賣
- --]]
- local function record_order_id()
- -- todo: 實(shí)現(xiàn)
- end
- --[[
- --!@brief 扣減剩余數(shù)
- --]]
- local function scalebuy()
- --
- local lastNum = redis.call('HINCRBY',key,tag,-num);
- --
- end
- -- 調(diào)用順序不可調(diào)整
- -- 1 限流
- local status,msg = limited()
- if status == 0then
- return msg
- end
- -- 2 校驗(yàn)
- status,msg = check_ip_pin()
- if status == 0 then
- return msg
- end
- -- 3 記錄訂單
- status,msg = record_order_id()
- if status == 0 then
- return msg
- end
- -- 4 扣減剩余數(shù)
- status,msg = scalebuy()
- if status == 0 then
- return msg
- end
- -- 5 返回成功標(biāo)示
- return 1
子流程具體如下:
1、解析請(qǐng)求參數(shù),根據(jù)促銷Id按照J(rèn)edis中MurmurHash算法獲取分片,然后按照分片包裝Pipeline批量發(fā)送請(qǐng)求參數(shù)argList;
2、獲取系統(tǒng)初始化時(shí)SCRIPT LOAD加載q.lua返回的串shaValue;
3、執(zhí)行EVALSHA,偽代碼如下:
- // 其他操作
- Pipeline p;
- // 初始化p
- p.evalsha(shaValue,keyList, argList);
- // 其他操作
4、處理返回結(jié)果,只要有一個(gè)分片失敗,本次搶購(gòu)就失敗。
補(bǔ)充:詳細(xì)Script操作可以參考Jedis中 ScriptingCommandsTest。
JMQ發(fā)送子流程:
執(zhí)行REDIS+LUA搶購(gòu)子流程成功僅僅代表著操作redis成功,發(fā)送jmq(京東mq基礎(chǔ)服務(wù))成功(后端異步將實(shí)時(shí)庫(kù)存更新到MySQL)才算一筆搶購(gòu)成功,否則算搶購(gòu)失敗。這么設(shè)計(jì)的原因主要是保證搶購(gòu)redis和mysql記錄最終一致,發(fā)送失敗需要回滾REDIS+LUA搶購(gòu)子流程(恢復(fù)Redis的庫(kù)存和搶購(gòu)資格)。當(dāng)然要考慮降級(jí),jmq不可用時(shí),直接切到j(luò)sf服務(wù)模擬jmq,也就是直接寫MySQL庫(kù),前提是限流次數(shù)調(diào)小,否則數(shù)據(jù)庫(kù)有壓力過大的風(fēng)險(xiǎn)。這樣雖然用戶體驗(yàn)下降了,但是服務(wù)依然可用。開關(guān)都在配置中心操作,一分鐘內(nèi)生效。
資格回滾子流程:
發(fā)送JMQ失敗必須回滾,否則就出現(xiàn)了超賣現(xiàn)象,具體流程同REDIS+LUA搶購(gòu)子流程類似,是它的逆向流程,只不過運(yùn)行腳本不同罷了。
限流處理
方法級(jí)限流,限流閾值通過配置中心配置,一分鐘生效,偽代碼如下:
- private static AtomicInteger atomic = new AtomicInteger(0);
- public void test() {
- try {
- // 限流
- int limitNum = XXX.getLimitNum();
- int nowConcurrent = atomic.incrementAndGet();
- if(nowConcurrent > limitNum) {
- // 異常處理
- }
- // 正常業(yè)務(wù)邏輯
- } catch(Exception e) {
- // 異常處理
- } finally {
- atomic.decrementAndGet();
- }
- }
q.lua中促銷級(jí)別的限流,主要利用C_A_1中c的搶次數(shù)和閾值比對(duì)。比如促銷A,60秒內(nèi)只能搶60000次,超過閾值60000該促銷就會(huì)搶購(gòu)失敗。
到此搶購(gòu)系統(tǒng)的核心邏輯就介紹完了,這里邊還有一些細(xì)節(jié)問題需要大家在設(shè)計(jì)時(shí)思考,如限購(gòu)(如每個(gè)人限購(gòu)2個(gè))、真實(shí)庫(kù)存不足取消、用戶取消訂單歸還資格、Redis掛了恢復(fù)數(shù)據(jù)、停促銷(時(shí)間過期停、庫(kù)存不足停)等等。
作者:張子良,京東高級(jí)開發(fā)工程師,在京東負(fù)責(zé)搶購(gòu)后端服務(wù)系統(tǒng)架構(gòu)和開發(fā)工作。
【本文來自51CTO專欄作者張開濤的微信公眾號(hào)(開濤的博客),公眾號(hào)id: kaitao-1234567】