搶購——常見于電商網(wǎng)站的商品限量低價銷售,限時秒殺等狞尔,特點是商品數(shù)量有限制,價低于往日正常售價巩掺,顧客在搶到庫存中的商品后會暫時占有這筆庫存沪么,支付完成后庫存中才會減1,如果在規(guī)定時間內(nèi)(例如15分鐘)沒有完成支付锌半,會將庫存放回可售庫存禽车。
搶購模塊因瞬時訪問量會比平時高十幾倍甚至幾十倍,不能讓搶購模塊的大并發(fā)量影響到其他模塊刊殉,造成整體系統(tǒng)響應(yīng)緩慢甚至整個系統(tǒng)的崩潰都有可能殉摔,所以將搶購模塊單獨拆分成一個搶購系統(tǒng)已經(jīng)是必然。
開發(fā)之初我們總結(jié)了搶購系統(tǒng)所需要解決的幾個問題
1.庫存的高實時性和一致性记焊,不可超賣或者未賣完即提示已售完
2.大并發(fā)下須保證系統(tǒng)在可承受范圍內(nèi)逸月,做好限流措施
3.整體異步化,與訂單系統(tǒng)遍膜,資產(chǎn)系統(tǒng)的交互中通過中間表碗硬、緩存、隊列實現(xiàn)數(shù)據(jù)的異步瓢颅,減緩對其他系統(tǒng)造成高并發(fā)沖擊恩尾。
任何系統(tǒng)都有壓力臨界點,為防止系統(tǒng)崩潰挽懦,讓系統(tǒng)保持在可接受的壓力環(huán)境下翰意,我們對搶購系統(tǒng)采取了限流措施 , 通過Guava RateLimiter實現(xiàn)平滑限流
限流偽代碼:
//每秒發(fā)放10個令牌
final RateLimiter limiter = RateLimiter create(10.0);
ThreadPoolExecutor excutor = new ThreadPoolExecutor(availableProcessors, availableProcessors * 5, 60L,
TimeUnit.MILLISECONDS, workQueue, new NamedThreadFactory("wallet", false));
void submitOrder(List tasks) {
for (Runnable task : tasks) {
limiter.acquire();
excutor.execute(task);
}
}
理財產(chǎn)品的搶購與電商類商品庫存大致概念一樣,在整體的庫存控制中(產(chǎn)品額度)信柿,產(chǎn)品額度分為總額度(total)冀偶,凍結(jié)額度(freeze),已售額度(sold)渔嚷,整個搶購流程中必然是 total >= freeze + sold的情況进鸠,如何有效的控制住高并發(fā)下的額度不超賣,額度更新的原子性也是系統(tǒng)關(guān)鍵點之一形病。實際系統(tǒng)中的可售額度為 total - freeze - sold客年,具體請看下文。
控制額度單從數(shù)據(jù)庫層面是控制不了窒朋,高并發(fā)情況下數(shù)據(jù)庫壓力增大并且按樂觀鎖控制額度不能有效的控制住額度搀罢,目前我們使用redis 存hash結(jié)構(gòu)來操作額度的增加和扣減蝗岖。整體流程如下:
- 情況1. 凍結(jié)額度增加
從用戶下單時做凍結(jié)額度的增加侥猩,當(dāng)發(fā)現(xiàn)凍結(jié)額度 + 已售額度 > 總額度,返回訂單創(chuàng)建失敗抵赢。
RedisCallback<Object> createCallback = connection -> {
connection.multi();
connection.hIncrBy((productIdKey,soldKey,0);
//增加凍結(jié)額度
connection.hIncrBy(productIdKey,freezeKey,orderAmount);
return connection.exec();
};
List<Object> list = (List)redisService.excute(createCallback);
if (ListUtil.isHave(list)){
long soldAmount = (long)list.get(0);
long withHoldAmount = (long)list.get(1);
if (withHoldAmount+soldAmount > sellAmount){
logger.error("額度控制 創(chuàng)建訂單 凍結(jié)金額+售賣金額大于總發(fā)售金額 withHoldAmount={},sellAmount={},productId={}",withHoldAmount,sellAmount,productId);
RedisCallback<Object> createFailureCallback = connection -> connection.hIncrBy(productIdKey,(freezeKey,-orderAmount);
//額度不足回滾數(shù)據(jù)欺劳。
redisService.excute(createFailureCallback);
//發(fā)布事件唧取,將額度同步到數(shù)據(jù)庫
eventBus.postEvent(new SyncAmountDataEvent(productId));
throw new ServiceException("額度不足");
}
}
- 情況2. 凍結(jié)額度釋放
定時任務(wù)定時跑批查詢訂單不是已支付且支付開始時間超過最近15分鐘的會調(diào)用關(guān)閉訂單方法,關(guān)閉訂單方法將訂單狀態(tài)修改為已失效划提,并將redis中的凍結(jié)額度-orderAmount釋放出可用額度枫弟。
connection.hIncrBy(productIdKey,freezeKey,-orderAmount); - 情況3. 已售額度增加并釋放凍結(jié)額度
與情況2一樣,在訂單支付完成后我們需要將sold + orderAmount 鹏往,freeze - orderAmount淡诗。
connection.multi();
connection.hIncrBy(productIdKey,sold,orderAmount);
connection.hIncrBy(productIdKey,freezeKey,-orderAmount);
return connection.exec();
在redis 額度更新的同時會啟動異步線程更新mysql的額度表數(shù)據(jù)。
以上三種情況可讓額度在訂單的各個狀態(tài)流轉(zhuǎn)時得到有效的控制伊履。
在高并發(fā)情況下韩容,如果將搶購系統(tǒng)的大量請求與其他核心系統(tǒng)同步處理顯然是不明智的,將搶購系統(tǒng)單獨抽離的一大原因就是為了減緩大請求量對整個系統(tǒng)的沖擊唐瀑,這種情況下我們考慮出了使用臨時表記錄訂單數(shù)據(jù)群凶,訂單創(chuàng)建,更新等都會先將訂單信息刷到redis用戶訂單緩存哄辣,保證用戶能第一時間看到自己的已搶成功的訂單请梢,隨后發(fā)送mq同步到訂單系統(tǒng)中,訂單系統(tǒng)在數(shù)據(jù)落地成功后會再次將庫里數(shù)據(jù)和redis做一次同步力穗。訂單緩沖表比較簡單毅弧,可以只存一個訂單json串來實現(xiàn)。
至此搶購系統(tǒng)的大概流程講解完畢当窗,其中還有一些細(xì)節(jié)問題需要去處理形真,比如redis高并發(fā)下性能問題,多次交互會浪費網(wǎng)絡(luò)資源超全,可以考慮將額度控制的內(nèi)容改為redis + lua的方式更為高效咆霜,同時考慮redis在掛掉時的系統(tǒng)如何運轉(zhuǎn)等情況。