再見(jiàn),秒殺
前言
最近心血來(lái)潮,想起前段時(shí)間公司舉辦的線下秒殺活動(dòng)不理想,想研究一下秒殺系統(tǒng)的優(yōu)化。當(dāng)時(shí)活動(dòng)現(xiàn)場(chǎng)有 200+ 會(huì)員,由于我們先前沒(méi)有經(jīng)驗(yàn),各種原因?qū)е掠脩粼诿霘⒌臅r(shí)候 APP 頁(yè)面白屏、卡死。業(yè)務(wù)部門(mén)想把手機(jī)甩我們開(kāi)發(fā)臉上......當(dāng)時(shí)我剛畢業(yè)也剛?cè)肼毑痪茫桓野l(fā)表意見(jiàn)。現(xiàn)在逐漸膨脹,是時(shí)候重新設(shè)計(jì)一套秒殺系統(tǒng)了......
問(wèn)題分析
有經(jīng)驗(yàn)的同學(xué)看到 200+ 會(huì)員都出現(xiàn)白屏、卡死,可能會(huì)覺(jué)得公司技術(shù)太 low 。其實(shí)不然,公司系統(tǒng)架構(gòu)還是很好的,大佬搭建了一套 SpringCloud 組件,都是比較新的版本。這次秒殺活動(dòng)失利的確是之前沒(méi)有這樣的經(jīng)驗(yàn),很多代碼考慮沒(méi)到位,訪問(wèn)數(shù)據(jù)庫(kù)的次數(shù)太多。雖然會(huì)員數(shù)是 200 ,但是會(huì)員從進(jìn)入秒殺頁(yè)面,點(diǎn)擊秒殺商品,再到秒殺下單,這中間夸了幾個(gè)微服務(wù),對(duì)于數(shù)據(jù)庫(kù)的訪問(wèn)遠(yuǎn)遠(yuǎn)不止 200 。
對(duì)代碼分析之后,我們公司秒殺其實(shí)是和普通訂單是同一個(gè)流程,只是加了一個(gè)秒殺 ID 字段。下單流程在一個(gè)事務(wù)里面各種校驗(yàn)、跨服務(wù)調(diào)用、加鎖扣減庫(kù)存、插入訂單商品信息、物流配送信息......等。這樣跨服務(wù)和多次訪問(wèn)數(shù)據(jù)庫(kù)很明顯無(wú)法滿足秒殺業(yè)務(wù)瞬間流量巨大的特性。就像下面的圖

上面就是我們下單的粗略流程,其實(shí)具體比這個(gè)要復(fù)雜,在每個(gè)微服務(wù)里面又調(diào)用了其他服務(wù)訪問(wèn)數(shù)據(jù)庫(kù)。。。因?yàn)橐粋€(gè)用戶至少就是一個(gè)線程,當(dāng)用戶量過(guò)大線程數(shù)就很多,服務(wù)資源是有限的。當(dāng)資源不夠用的時(shí)候,后來(lái)的用戶請(qǐng)求就會(huì)等待服務(wù)器釋放資源處理,用戶就會(huì)覺(jué)得卡。用戶一旦覺(jué)得卡,就很可能會(huì)回退頁(yè)面刷新,或者再次點(diǎn)擊提交訂單,然后請(qǐng)求又過(guò)來(lái),又訪問(wèn)數(shù)據(jù)庫(kù),就會(huì)更卡。一次請(qǐng)求每多訪問(wèn)一次數(shù)據(jù)庫(kù),就需要更多的時(shí)間來(lái)處理,所以請(qǐng)求太多最終服務(wù)器處理不過(guò)來(lái),前端得不到響應(yīng),用戶屏幕會(huì)卡在那白屏。
還有一個(gè)關(guān)鍵原因是秒殺商品的查詢,也是走的數(shù)據(jù)庫(kù),而且由于公司業(yè)務(wù)特殊性,不同地區(qū)的會(huì)員看到的商品不同等其他業(yè)務(wù),導(dǎo)致秒殺商品的查詢也比較慢,系統(tǒng)吞吐量低,最后可能導(dǎo)致用戶白屏,流程和上面下單的差不多。說(shuō)到底就是要提高系統(tǒng)吞吐量,讓服務(wù)器盡快釋放資源。
針對(duì)上述問(wèn)題:在應(yīng)對(duì)秒殺系統(tǒng)這樣一瞬間的巨大流量,現(xiàn)在系統(tǒng)架構(gòu)存在的核心問(wèn)題:
沒(méi)有遵循服務(wù)單一原則,把秒殺功能做在訂單服務(wù)里面,萬(wàn)一秒殺系統(tǒng)壓力過(guò)大還會(huì)影響到正常的訂單業(yè)務(wù)
和普通訂單一樣巨大流量蜂擁而至加鎖扣減庫(kù)存,導(dǎo)致很多無(wú)效請(qǐng)求占用資源
秒殺訂單鏈接沒(méi)有加密,給專業(yè)團(tuán)隊(duì)可趁之機(jī)
大量操作直接操作數(shù)據(jù)庫(kù),頻繁磁盤(pán) IO,系統(tǒng)吞吐量很低,甚至有可能數(shù)據(jù)庫(kù)掛掉
以上是幾大核心問(wèn)題,解決了這幾個(gè)問(wèn)題,基本上就可以實(shí)現(xiàn)較好的秒殺系統(tǒng)了。下面全面分析、解決秒殺系統(tǒng)問(wèn)題:
服務(wù)單一職責(zé)
我們都知道秒殺的特性,瞬間流量巨大。如果將秒殺功能做在訂單服務(wù)里面,萬(wàn)一秒殺占用的資源過(guò)多,或者秒殺功能直接把服務(wù)搞掛,正常訂單業(yè)務(wù)也會(huì)受影響。所以秒殺要單獨(dú)部署微服務(wù)
巨大流量處理
秒殺一瞬間的巨大流量不僅僅有廣大用戶正常請(qǐng)求,還有用戶不必要的頻繁點(diǎn)擊、惡意用戶、惡意攻擊等。如果不做好處理很有可能請(qǐng)求還沒(méi)到庫(kù)存扣減那里,微服務(wù)集群就頂不住了。對(duì)于巨大的流量,采取適當(dāng)?shù)南蘖鞔胧┦呛苡斜匾摹3S孟蘖鞣绞接邢旅鎺追N:
前端限流
Nginx 限流
limit_req_zone $binary_remote_addr zone=one:10m rate=20r/s; #限制同一IP 允許訪問(wèn)20次/S
網(wǎng)關(guān)限流
<dependency><groupId>org.springframework.boot</groupId><artifatId>spring-boot-starter-data-redis-reactive</artifactId></dependency>
配置:
server:port: 40000spring:cloud:gateway:routes:id: sec_kill_routeuri: lb://hosjoy-b2b-seckillpredicates:Path=/seckill/**filters:name: RequestRateLimiterargs:: '#{@apiKeyResolver}' #從Spring容器中獲取限流的Bean: 100 #令牌填充速率(每秒處理的請(qǐng)求): 3000 #令牌總?cè)萘浚?秒內(nèi)能允許的最大請(qǐng)求數(shù))application:name: hosjoy-b2b-gatewayredis:host: localhostport: 6379database: 0
代碼:
@Beanpublic KeyResolver apiKeyResolver() {return exchange -> Mono.just(exchange.getRequest().getPath().value());}
如果公司有使用阿里的 Sentinel 組件,這里也可以在網(wǎng)關(guān)層使用 Sentinel 做限流,功能強(qiáng)大,方便監(jiān)控、熔斷、降級(jí),使用非常方便!
集群實(shí)例擴(kuò)充
應(yīng)對(duì)惡意請(qǐng)求
相信大家都有所耳聞,有些 “專業(yè)團(tuán)隊(duì)” ,專門(mén)通過(guò)代別人搶茅臺(tái)等商品牟利。對(duì)于這種團(tuán)隊(duì),他們不僅有多 IP ,甚至還有可能有多賬戶!就是通過(guò)各種渠道低價(jià)購(gòu)買(mǎi)正常用戶的賬號(hào)來(lái)逃避風(fēng)控系統(tǒng)。對(duì)于這樣的 “專業(yè)團(tuán)隊(duì)”,單純的限流不能完全解決這個(gè)問(wèn)題,你想一下,有可能發(fā)生這種情況,這些惡意的請(qǐng)求被處理了,搶到了商品,但是廣大用戶沒(méi)有搶到,這個(gè)問(wèn)題就很嚴(yán)重。對(duì)于這種情況,我們可以采取兩種方案:
秒殺鏈接加密
public void secKill( Long secId, Long productId, String password){SecProductResponse secProduct = (SecProductResponse) redisTemplate.opsForValue().get("secId:productId");//場(chǎng)次商品信息if(secProduct.getPassword().equals(password)) { //校驗(yàn)秒殺商品密碼//...} else {stringRedisTemplate.opsForValue().increment("black:secId:productId:userId");}}
黑名單過(guò)濾
String s = stringRedisTemplate.opsForValue().get("secId:black:userId");if(s != null && Integer.parseInt(s) >= maxCount){return;}
防止用戶重復(fù)購(gòu)買(mǎi)
一般來(lái)說(shuō)秒殺活動(dòng)對(duì)于同一個(gè)用戶的購(gòu)買(mǎi)是有限制的,如果已經(jīng)購(gòu)買(mǎi)過(guò),那么這個(gè)用戶就不應(yīng)該繼續(xù)購(gòu)買(mǎi)。雖然前端已經(jīng)做了限制,但是為了防止專業(yè)人士,這里在后端也要進(jìn)行限制。我們可以使用 Redis 來(lái)實(shí)現(xiàn)
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("secId:productId:userId", 1, time, TimeUnit.SECONDS);if(flag){//...}
超賣(mài)控制、庫(kù)存扣減
秒殺商品發(fā)生超賣(mài)是個(gè)很可怕的事情,因?yàn)槊霘⑸唐繁旧砭秃軆?yōu)惠。為了吸引流量以極低的秒殺價(jià)格售賣(mài),甚至虧本。如果一旦超賣(mài),公司或者商家可能虧的血本無(wú)歸。為了控制超賣(mài),我們可以在扣除庫(kù)存的時(shí)候加鎖。但是這里必須要加分布式鎖,使用本地鎖不能控制住。使用分布式鎖避免并發(fā)造成庫(kù)存扣減超賣(mài)。但是如此一來(lái)系統(tǒng)吞吐量會(huì)有所下降。我們?cè)瓉?lái)就是跨服務(wù)扣減庫(kù)存,加分布式鎖,還訪問(wèn)的數(shù)據(jù)庫(kù),拿大腿都能想到這個(gè)對(duì)于秒殺的請(qǐng)求量肯定不合適。現(xiàn)在我們?cè)诿霘⒔涌诶锩骐m然可以優(yōu)化到不跨服務(wù)訪問(wèn)數(shù)據(jù)庫(kù)了,所以使用分布式鎖也能解決這個(gè)問(wèn)題。但是既然是鎖,就有資源消耗。有沒(méi)有不使用分布式鎖的方案呢?所以我們可以換個(gè)角度考慮這個(gè)問(wèn)題,秒殺商品庫(kù)存一般都是有一定數(shù)量限制的,并且秒殺庫(kù)存遠(yuǎn)小于商品可售賣(mài)庫(kù)存。我們可以把這個(gè)秒殺庫(kù)存的數(shù)量提前保存在 Redis 里面,然后用 Redis 來(lái)預(yù)先扣減庫(kù)存,庫(kù)存一旦扣減完,就返回秒殺結(jié)束,已搶完。如此一來(lái),我們?cè)谶@里有多少庫(kù)存就會(huì)放進(jìn)來(lái)多少請(qǐng)求,剩余的無(wú)效請(qǐng)求全部返回。不但防止了超賣(mài),還做了流量限制,相對(duì)于原來(lái)的蜂擁而至排隊(duì)扣減庫(kù)存模式,這樣吞吐量極高。我們可以采用 Redis 的分布式信號(hào)量實(shí)現(xiàn),這里可以使用 Redisson 來(lái)做具體代碼實(shí)現(xiàn)
RSemaphore semaphore = redissonClient.getSemaphore("secId:productId");boolean success=false;try {success = semaphore.tryAcquire(1,50,TimeUnit.MILLISECONDS);} catch (InterruptedException e) {log.error(e);return;}if(success){//生成訂單號(hào)//發(fā)送消息到MQ}
其實(shí)分布式信號(hào)量也可以算是一種分布式鎖,但是它的性能極高,獲取一次信號(hào)量幾乎是 0 - 1 ms,基本不會(huì)影響系統(tǒng)吞吐量。
流量削峰
經(jīng)過(guò)上面重重關(guān)卡,最后調(diào)用訂單服務(wù)的請(qǐng)求數(shù)和秒殺商品的庫(kù)存數(shù)量一樣。假設(shè) 100 萬(wàn)人搶 400 茅臺(tái),那么就有 400 請(qǐng)求要調(diào)用訂單服務(wù),400 并發(fā)下單的話,由于還有一系列業(yè)務(wù)處理,并發(fā)訪問(wèn)數(shù)據(jù)庫(kù),其實(shí)又回到了最初的模式。在秒殺接口里面訪問(wèn)數(shù)據(jù)庫(kù),這樣吞吐量是很低的,還有可能打掛數(shù)據(jù)庫(kù)。我們應(yīng)該讓秒殺接口的操作全部走 Redis 。這里我們可以使用消息隊(duì)列來(lái)做 為什么使用消息隊(duì)列?使用 MQ 來(lái)削峰,平緩消費(fèi)創(chuàng)建訂單,將峰值流量散開(kāi)。由于消息隊(duì)列在強(qiáng)大并發(fā)下可能會(huì)造成消息丟失等問(wèn)題,具體可參考 RabbitMQ 可靠性、重復(fù)消費(fèi)、順序性、消息積壓解決方案
數(shù)據(jù)庫(kù)分表分庫(kù)
一般來(lái)說(shuō)以上就能實(shí)現(xiàn)較好的秒殺系統(tǒng)效果了,如果公司數(shù)據(jù)量很大,業(yè)務(wù)很復(fù)雜。甚至 MQ 異步消費(fèi)訪問(wèn)數(shù)據(jù)庫(kù)也不能解決的話,那么就用讀寫(xiě)分離,讀庫(kù)和寫(xiě)庫(kù)分開(kāi),有效降低數(shù)據(jù)庫(kù)壓力。還可以去對(duì)數(shù)據(jù)庫(kù)分表、分庫(kù)來(lái)提升單表并發(fā)能力和磁盤(pán) IO 讀寫(xiě)性能。
解決以上問(wèn)題,秒殺流程基本就 OK 了,其實(shí)上面的偽代碼都很簡(jiǎn)單,真實(shí)實(shí)現(xiàn)的話,代碼也不復(fù)雜,只是要合理的設(shè)計(jì)方案,該屏蔽過(guò)濾的請(qǐng)求就屏蔽過(guò)濾,不該訪問(wèn)數(shù)據(jù)庫(kù)的不訪問(wèn)即可。下面具體看下這幾個(gè)環(huán)節(jié)的流程圖
商品上架/庫(kù)存回退
商品上架其實(shí)很簡(jiǎn)單,我們只需要把需要的信息存入 Redis 即可。不過(guò)不同公司有不同的業(yè)務(wù),比如我公司的業(yè)務(wù) B → b → c 的模式,秒殺商品、活動(dòng)是有區(qū)域的,就是說(shuō)一場(chǎng)活動(dòng)可能會(huì)發(fā)生,經(jīng)營(yíng)區(qū)域在 A、B、C 三個(gè)市的會(huì)員店可以參與,其他區(qū)域的會(huì)員店不可以參與。所以針對(duì)這種情況,我們需要把秒殺場(chǎng)次信息在所有可允許的區(qū)域都要存儲(chǔ)一份,就像下面這樣
//場(chǎng)次信息redisTemplate.opsForValue().set("province:cityId:secId","data");//商品信息redisTemplate.opsForValue().set("secId:productId","data");//庫(kù)存信息redisTemplate.opsForValue().set("stock:secId:productId:password","data");
那么你可能會(huì)說(shuō),這得存多少 Redis 的 Key 啊......的確,如果場(chǎng)次多一點(diǎn),選擇的區(qū)域多一點(diǎn),是要存不少 key 。計(jì)算一下,據(jù) 2016 年統(tǒng)計(jì),中國(guó)總共好像是 293 個(gè)市,按 300 算。假設(shè)最近三天有 30 場(chǎng)秒殺活動(dòng), 每場(chǎng)活動(dòng)有 10 個(gè)商品 。那么總共需要的 key 數(shù)量的計(jì)算方法為 城市場(chǎng)次 + 場(chǎng)次商品 + 場(chǎng)次商品庫(kù)存 = 300 * 30 + 30 * 10 + 30 * 10 = 9600
再按照三天內(nèi)掃描前后三天再乘個(gè)三好了,也就 30000 不到的 key。你覺(jué)得這個(gè)數(shù)量多嗎?我們來(lái)看看官方對(duì)于 Redis 存儲(chǔ) key 數(shù)量給的描述:
Redis can handle up to 2^32 keys, and was tested in practice to handle at least 250 million >keys per instance. Every hash, list, set, and sorted set, can hold 2^32 elements. In other words your limit is likely the available memory in your system.
來(lái)源于 Redis 官網(wǎng)
官方說(shuō) Redis 理論上能存儲(chǔ) 2^32 個(gè) key ,實(shí)際測(cè)試中一個(gè)實(shí)例至少存儲(chǔ) 2.5 億的 key 。最后一句:你的限制其實(shí)是你系統(tǒng)的可用內(nèi)存而已......而且這還只是一個(gè) Redis 實(shí)例的數(shù)據(jù)。所以說(shuō)不要太小看 Redis ,人家官網(wǎng)聲稱性能極高,讀的速度是110000次/s,寫(xiě)的速度是81000次/s 。而且,如果一個(gè)互聯(lián)網(wǎng)公司在當(dāng)今緩存界對(duì)于 Redis 這么牛逼的緩存中間件的使用量很少,那么一般來(lái)說(shuō),業(yè)務(wù)用戶量是有限的。不過(guò)有一點(diǎn)需要注意,一旦業(yè)務(wù)大量使用 Redis 作為緩存中間件,必須至少要防止三件事 Redis 實(shí)戰(zhàn)應(yīng)用篇 — 緩存雪崩、緩存擊穿、緩存穿透和數(shù)據(jù)一致性
因?yàn)槊霘⒒顒?dòng)有一種業(yè)務(wù)場(chǎng)景是沒(méi)賣(mài)完,雖然這有些尷尬......但是不得不考慮,這里需要在場(chǎng)次結(jié)束之后,把沒(méi)有賣(mài)完的庫(kù)存從 Redis 回退到庫(kù)存表里面。

如上圖,配置定時(shí)任務(wù)定期掃描近三天要秒殺的場(chǎng)次,然后上架,注意不要重復(fù)上架。上架主要是將上圖的信息保存到 Redis,然后對(duì)于每個(gè)場(chǎng)次結(jié)束的商品發(fā)送延遲消息,在消費(fèi)者里面判斷如果信號(hào)量不為 0,就說(shuō)明秒殺活動(dòng)沒(méi)有賣(mài)完,需要把庫(kù)存回退,然后刪除 Redis 中的信號(hào)量。
秒殺商品查詢
由于秒殺活動(dòng)查詢頻繁、巨大流量,千萬(wàn)不能去數(shù)據(jù)庫(kù)查詢商品信息。所有查詢操作走 Redis ,注意在活動(dòng)開(kāi)始之前不要返回商品密碼字段。
這里有一點(diǎn)需要注意的地方,因?yàn)轫?yè)面上活動(dòng)開(kāi)始之前購(gòu)買(mǎi)按鈕是置灰的,所以在秒殺開(kāi)始的前一秒,需要去請(qǐng)求一次服務(wù)器獲取商品密碼。假設(shè)有十萬(wàn)人準(zhǔn)備搶購(gòu),那就有十萬(wàn)次請(qǐng)求發(fā)到服務(wù)器。其實(shí)十萬(wàn)次請(qǐng)求到是沒(méi)什么問(wèn)題,因?yàn)槟慵热挥惺f(wàn)人準(zhǔn)備搶購(gòu),就得有十萬(wàn)請(qǐng)求要到服務(wù)器,如果你在這里覺(jué)得十萬(wàn)次請(qǐng)求到服務(wù)器不太好,那么你的秒殺接口不是一樣要放十萬(wàn)請(qǐng)求到服務(wù)器嗎?所以關(guān)鍵的問(wèn)題不是請(qǐng)求數(shù)量,而是請(qǐng)求的錯(cuò)峰。就是說(shuō)你前端不能讓十萬(wàn)客戶端在真正相同毫秒級(jí)別的時(shí)間把請(qǐng)求發(fā)過(guò)來(lái),比如 2021-05-01 00:00:00 有一場(chǎng)秒殺活動(dòng),那么前端在 2021-04-30 23:59:58 或者 59 的時(shí)候就可以發(fā)請(qǐng)求了,但是這里要精確到毫秒去發(fā),1 s = 1000 ms ,前端可以在這 1000-2000 毫秒內(nèi)錯(cuò)開(kāi)十萬(wàn)的請(qǐng)求量,這樣十萬(wàn)的請(qǐng)求量不在同一個(gè)毫秒級(jí)別的時(shí)間,服務(wù)器壓力會(huì)小一些,而且服務(wù)器是走 Redis 查詢的,響應(yīng)時(shí)間應(yīng)該 10 - 20 ms 就可以。拿到商品密碼之后判斷當(dāng)前時(shí)間是否到達(dá)秒殺開(kāi)始時(shí)間,如果到了就恢復(fù)按鈕狀態(tài),如果沒(méi)到就等時(shí)間到了再恢復(fù)按鈕就行了。

秒殺流程
下面就是具體的秒殺流程詳細(xì)圖,按順序描述每一節(jié)點(diǎn)要考慮的問(wèn)題以及解決方案

秒殺流程的偽代碼:
private RedisTemplate<String,Object> redisTemplate;/*** 秒殺流程* */("/sec-kill")public void secKill(("secId") Long secId,("productId") Long productId,("password") String password){SecResponse sec = (SecResponse) redisTemplate.opsForValue().get("secId");//場(chǎng)次信息LocalDateTime now = LocalDateTime.now();if(now.isAfter(sec.getStartTime()) && now.isBefore(sec.getEndTime())){ //校驗(yàn)已開(kāi)始SecProductResponse secProduct = (SecProductResponse) redisTemplate.opsForValue().get("secId:productId");//場(chǎng)次商品信息if(secProduct.getPassword().equals(password)){ //校驗(yàn)秒殺商品密碼Duration duration = Duration.between(sec.getStartTime(), sec.getEndTime());int random = (int)(Math.random() * 100);long period = duration.getSeconds() + random;Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("secId:productId:userId", "1", period, TimeUnit.SECONDS);if(flag != null && flag){ //校驗(yàn)已購(gòu)買(mǎi)RSemaphore semaphore = redissonClient.getSemaphore("secId:productId:password" );try {//嘗試在50ms內(nèi)獲取信號(hào)量boolean acquire = semaphore.tryAcquire(num,50, TimeUnit.MILLISECONDS);if(acquire){ //搶到庫(kù)存String orderNo = generateOrderNo();//生成訂單號(hào)rabbitTemplate.convertAndSend("hosjoy-b2b-secKill","routingKey","data");//發(fā)送消息} else{//如果沒(méi)搶到,刪除已購(gòu)買(mǎi)的標(biāo)識(shí)(其實(shí)不刪也沒(méi)什么問(wèn)題)stringRedisTemplate.delete("secId:productId:userId");}} catch (InterruptedException e) {}}} else {stringRedisTemplate.opsForValue().increment("black:secId:productId:userId");}}}
以上就是大致的秒殺流程代碼,也是我覺(jué)得比較好的秒殺流程,設(shè)計(jì)完之后請(qǐng)同事大佬指點(diǎn)(咳咳,其實(shí)我是想裝個(gè) X,噓!)了一下。我與他的想法或者說(shuō)設(shè)計(jì)思路有主要兩點(diǎn)不同
實(shí)現(xiàn) Redis 庫(kù)存的數(shù)據(jù)結(jié)構(gòu)
什么時(shí)候算秒殺成功
他使用的是 Redis 的 List 數(shù)據(jù)結(jié)構(gòu)來(lái)存放庫(kù)存,比如有 100 個(gè)庫(kù)存就 leftPush 100 個(gè)商品 id。然后通過(guò) pop 的方式去扣減庫(kù)存。我對(duì)比了一下分布式信號(hào)量 Semaphore 和 List 結(jié)構(gòu),兩者都可以實(shí)現(xiàn),用起來(lái)也都很方便,還有個(gè) incr 和 decr 自增自減其實(shí)也可以,但是這都是默認(rèn)針對(duì)秒殺商品只能秒殺一件的 。如果說(shuō)業(yè)務(wù)允許秒殺可以購(gòu)買(mǎi)多件商品,那么 List 和 decr 就必須要加分布式鎖來(lái)控制了,如此一來(lái)會(huì)讓系統(tǒng)的吞吐量就相對(duì)被降低了。因?yàn)?List 一次只能彈出一個(gè)元素,decr 雖然可以傳參數(shù)扣減,但是可以減到負(fù)數(shù)的。假設(shè) A 用戶秒殺 5 件,庫(kù)存現(xiàn)在只有 4 件,B 用戶秒殺 2 件,理論上 A 是秒殺失敗,但是 B 應(yīng)該秒殺成功,如果不加分布式鎖,A 把庫(kù)存減到 -1 ,發(fā)現(xiàn)不對(duì),要把庫(kù)存加回去,此時(shí) B 秒殺 2 件,發(fā)現(xiàn)庫(kù)存已經(jīng)是 -1 了,也秒殺失敗,這就有問(wèn)題了。所以......分布式信號(hào)量牛逼啊!
還有個(gè)區(qū)別是他有個(gè)用戶購(gòu)買(mǎi)之后排隊(duì)的概念來(lái)校驗(yàn)重復(fù)購(gòu)買(mǎi),我這直接 setIfAbsent 來(lái)校驗(yàn),這個(gè)區(qū)別其實(shí)無(wú)關(guān)緊要,重要的是什么時(shí)候算秒殺成功。
我的設(shè)計(jì)思路
以我的設(shè)計(jì)方案,只要用戶嘗試獲取信號(hào)量成功,就算秒殺成功,但是這里其實(shí)可以不用立即返回告訴用戶,最好讓用戶手機(jī)繼續(xù)轉(zhuǎn)圈 1-2 秒之后告訴他秒殺成功,因?yàn)?MQ 發(fā)送消息到消費(fèi)成功有一定時(shí)間,如果立即告訴用戶秒殺成功,而訂單還在生成中,可能會(huì)給用戶帶來(lái)不好的用戶體驗(yàn)。等 1-2 秒之后 MQ 消息消費(fèi)完成訂單也生成成功,此時(shí)正好用戶收到秒殺成功,訂單也生成成功就很 NICE!
那么你可能會(huì)有疑問(wèn),如果消費(fèi)者生成訂單報(bào)錯(cuò)了怎么辦?不得不說(shuō),這是個(gè)必須考慮的問(wèn)題,畢竟 MQ 的消費(fèi)說(shuō)不準(zhǔn)。這里當(dāng)然我也考慮到了這種情況,如果消費(fèi)失敗首先采取重試,如果重試 3 次仍然失敗,那說(shuō)明這里產(chǎn)生了代碼問(wèn)題導(dǎo)致訂單生成失敗,記錄下來(lái)報(bào)錯(cuò)消息,然后人工查詢錯(cuò)誤,恢復(fù)用戶訂單即可。畢竟這是個(gè)小概率的事情,也不會(huì)有一堆訂單消費(fèi)失敗吧?更何況人家本來(lái)就是在秒殺服務(wù)搶到了庫(kù)存,既然搶到了我就算他秒殺成功了,訂單由于其他原因生成失敗,我給他手動(dòng)生成訂單,保證最終一致性即可,不然怎么跟用戶交代?
同事的設(shè)計(jì)思路
而同事他說(shuō)不應(yīng)該這么設(shè)計(jì),應(yīng)該設(shè)計(jì)為用戶搶到信號(hào)量只是有一個(gè)秒殺機(jī)會(huì),具體秒殺成功與否要看訂單服務(wù)消費(fèi)的結(jié)果。如果訂單服務(wù)消費(fèi)失敗,就回滾秒殺庫(kù)存到 Redis ,讓其他用戶來(lái)?yè)專驗(yàn)榭赡軙?huì)存在業(yè)務(wù)校驗(yàn)不通過(guò),用戶沒(méi)有購(gòu)買(mǎi)資格。不得不說(shuō),他考慮問(wèn)題一向很周到,我從跟他后面做項(xiàng)目開(kāi)始到現(xiàn)在也成長(zhǎng)很多,他真的是實(shí)力很強(qiáng)的大佬!
不過(guò)我的設(shè)計(jì)初衷是沒(méi)有考慮到有業(yè)務(wù)校驗(yàn)用戶沒(méi)有資格購(gòu)買(mǎi)商品,為什么會(huì)有用戶沒(méi)有資格購(gòu)買(mǎi)商品……這特么什么業(yè)務(wù)場(chǎng)景,既然沒(méi)資格買(mǎi)為什么要讓他看到秒殺活動(dòng)?。但是仔細(xì)一想,這樣根據(jù)訂單生成結(jié)果判定秒殺結(jié)果其實(shí)是有點(diǎn)問(wèn)題的。
存在的問(wèn)題
假設(shè) 100 萬(wàn)人搶 400 茅臺(tái),本來(lái)全部搶完之后你提醒沒(méi)搶到的用戶秒殺商品已搶完了。但是訂單服務(wù)那邊消費(fèi)到第 399 和 400 個(gè)消息的時(shí)候失敗了,回滾了訂單,回滾了庫(kù)存到 Redis 。如果是因?yàn)闃I(yè)務(wù)校驗(yàn)未通過(guò),那我認(rèn)為是否應(yīng)該不讓用戶看見(jiàn)這個(gè)活動(dòng),或者想辦法在搶到秒殺機(jī)會(huì)之前就提示用戶沒(méi)有參加資格會(huì)比較好
此時(shí)消費(fèi)到第 399 和 400 消息大約過(guò)了 3-5 秒,你把它回滾了。正常用戶剛開(kāi)始看沒(méi)搶到,可能都走了,這還有可能發(fā)生少賣(mài)。
如果不是因?yàn)闃I(yè)務(wù)校驗(yàn)的問(wèn)題,而是代碼問(wèn)題導(dǎo)致的報(bào)錯(cuò),這時(shí)回滾了訂單,感覺(jué)這個(gè)用戶有點(diǎn)慘啊,明明是系統(tǒng)問(wèn)題,卻讓用戶背鍋......
如果該用戶由于代碼問(wèn)題被回滾了訂單,然后去秒殺商品頁(yè)面又看到了庫(kù)存剩余再次秒殺,然后再次失敗,再次秒殺,再次失敗......如此循環(huán)下去,我覺(jué)得他的內(nèi)心是崩潰的......,不過(guò)這個(gè)概率很小
看到這里大伙可能會(huì)覺(jué)得,我靠這個(gè)博主太不要臉了,就挑別人的刺,不考慮自己的問(wèn)題

我的方案存在的問(wèn)題
需要有人去關(guān)注秒殺活動(dòng),雖然出錯(cuò)的概率比較小,但是一旦訂單服務(wù)報(bào)錯(cuò),你得有人去盡快生成/恢復(fù)訂單,耗費(fèi)人力。如果恢復(fù)了訂單,用戶最后不支付的話,那這個(gè)人力資源相當(dāng)于白費(fèi)了呀。。。
未支付就在設(shè)計(jì)邏輯上算用戶秒殺成功,這樣可能領(lǐng)導(dǎo)聽(tīng)起來(lái)不太能接受,如果先讓用戶支付,支付完成才算秒殺成功,然后去生成訂單,這樣領(lǐng)導(dǎo)應(yīng)該會(huì)很贊同......這個(gè)看起來(lái)沒(méi)問(wèn)題,實(shí)際實(shí)現(xiàn)細(xì)節(jié)上有沒(méi)有問(wèn)題還沒(méi)有研究過(guò),畢竟天貓、淘寶也是先生成訂單才去支付的,等第二版更新。
個(gè)人覺(jué)得每個(gè)人的方案都可能存在一定的局限、問(wèn)題,畢竟沒(méi)有完美的方案,只能最后根據(jù)實(shí)際業(yè)務(wù)情況或者公司所有同事一起討論去選用一種更為符合的設(shè)計(jì)方案,或者在此基礎(chǔ)上再做優(yōu)化。
