實(shí)現(xiàn)一個(gè)秒殺系統(tǒng)

來(lái)源:https://www.cnblogs.com/huangqingshi/p/10325574.html


之前寫了如何實(shí)現(xiàn)分布式鎖和分布式限流,這次我們繼續(xù)在這塊功能上推進(jìn),實(shí)現(xiàn)一個(gè)秒殺系統(tǒng)晒衩,采用spring boot 2.x + mybatis+ redis + swagger2 + lombok實(shí)現(xiàn)伙窃。

先說(shuō)說(shuō)基本流程,就是提供一個(gè)秒殺接口患膛,然后針對(duì)秒殺接口進(jìn)行限流,限流的方式目前我實(shí)現(xiàn)了兩種,上次實(shí)現(xiàn)的是累計(jì)計(jì)數(shù)方式蜗侈,這次還有這個(gè)功能篷牌,并且我增加了令牌桶方式的lua腳本進(jìn)行限流。

然后不被限流的數(shù)據(jù)進(jìn)來(lái)之后踏幻,加一把分布式鎖枷颊,獲取分布式鎖之后就可以對(duì)數(shù)據(jù)庫(kù)進(jìn)行操作了。直接操作數(shù)據(jù)庫(kù)的方式可以该面,但是速度會(huì)比較慢夭苗,咱們直接通過(guò)一個(gè)初始化接口,將庫(kù)存數(shù)據(jù)放到緩存中隔缀,然后對(duì)緩存中的數(shù)據(jù)進(jìn)行操作题造。

寫庫(kù)的操作采用異步方式,實(shí)現(xiàn)的方式就是將操作好的數(shù)據(jù)放入到隊(duì)列中猾瘸,然后由另一個(gè)線程對(duì)隊(duì)列進(jìn)行消費(fèi)界赔。當(dāng)然,也可以將數(shù)據(jù)直接寫入mq中牵触,由另一個(gè)線程進(jìn)行消費(fèi)淮悼,這樣也更穩(wěn)妥。

好了揽思,看一下項(xiàng)目的基本結(jié)構(gòu):

看一下入口controller類袜腥,入口類有兩個(gè)方法,一個(gè)是初始化訂單的方法钉汗,即秒殺開(kāi)始的時(shí)候羹令,秒殺接口才會(huì)有效,這個(gè)方法可以采用定時(shí)任務(wù)自動(dòng)實(shí)現(xiàn)也可以损痰。

初始化后就可以調(diào)用placeOrder的方法了特恬。在placeOrder上面有個(gè)自定義的注解DistriLimitAnno,這個(gè)是我在上篇文章寫的徐钠,用作限流使用癌刽。

采用的方式目前有兩種,一種是使用計(jì)數(shù)方式限流尝丐,一種方式是令牌桶显拜,上次使用了計(jì)數(shù),咱們這次采用令牌桶方式實(shí)現(xiàn)爹袁。

packagecom.hqs.flashsales.controller;

importcom.hqs.flashsales.annotation.DistriLimitAnno;

importcom.hqs.flashsales.aspect.LimitAspect;

importcom.hqs.flashsales.lock.DistributedLock;

importcom.hqs.flashsales.limit.DistributedLimit;

importcom.hqs.flashsales.service.OrderService;

importlombok.extern.slf4j.Slf4j;

importorg.springframework.beans.factory.annotation.Autowired;

importorg.springframework.data.redis.core.RedisTemplate;

importorg.springframework.data.redis.core.script.RedisScript;

importorg.springframework.stereotype.Controller;

importorg.springframework.web.bind.annotation.GetMapping;

importorg.springframework.web.bind.annotation.PostMapping;

importorg.springframework.web.bind.annotation.ResponseBody;

importjavax.annotation.Resource;

importjava.util.Collections;

/**

*@authorhuangqingshi

*@Date2019-01-23

*/

@Slf4j

@Controller

publicclassFlashSaleController{

@Autowired

OrderService?orderService;

@Autowired

DistributedLock?distributedLock;

@Autowired

LimitAspect?limitAspect;

//注意RedisTemplate用的String,String远荠,后續(xù)所有用到的key和value都是String的

@Autowired

RedisTemplate?redisTemplate;

privatestaticfinalString?LOCK_PRE?="LOCK_ORDER";

@PostMapping("/initCatalog")

@ResponseBody

publicString?initCatalog()??{

try{

orderService.initCatalog();

}catch(Exception?e)?{

log.error("error",?e);

}

return"init?is?ok";

}

@PostMapping("/placeOrder")

@ResponseBody

@DistriLimitAnno(limitKey?="limit",?limit?=?100,?seconds?="1")

publicLongplaceOrder(LongorderId)?{

LongsaleOrderId?=0L;

boolean?locked?=false;

String?key?=?LOCK_PRE?+?orderId;

String?uuid?=?String.valueOf(orderId);

try{

locked?=?distributedLock.distributedLock(key,?uuid,

"10");

if(locked)?{

//直接操作數(shù)據(jù)庫(kù)

//????????????????saleOrderId?=?orderService.placeOrder(orderId);

//操作緩存?異步操作數(shù)據(jù)庫(kù)

saleOrderId?=?orderService.placeOrderWithQueue(orderId);

}

log.info("saleOrderId:{}",?saleOrderId);

}catch(Exception?e)?{

log.error(e.getMessage());

}finally{

if(locked)?{

distributedLock.distributedUnlock(key,?uuid);

}

}

returnsaleOrderId;

}

}

令牌桶的方式比直接計(jì)數(shù)更加平滑,直接計(jì)數(shù)可能會(huì)瞬間達(dá)到最高值失息,令牌桶則把最高峰給削掉了譬淳,令牌桶的基本原理就是有一個(gè)桶裝著令牌档址,然后又一隊(duì)人排隊(duì)領(lǐng)取令牌,領(lǐng)到令牌的人就可以去做做自己想做的事情了邻梆,沒(méi)有領(lǐng)到令牌的人直接就走了(也可以重新排隊(duì))守伸。

發(fā)令牌是按照一定的速度發(fā)放的,所以這樣在多人等令牌的時(shí)候浦妄,很多人是拿不到的尼摹。當(dāng)桶里邊的令牌在一定時(shí)間內(nèi)領(lǐng)完后,則沒(méi)有令牌可領(lǐng)剂娄,都直接走了蠢涝。如果過(guò)了一定的時(shí)間之后可以再次把令牌桶裝滿供排隊(duì)的人領(lǐng)。

基本原理是這樣的阅懦,看一下腳本簡(jiǎn)單了解一下和二,里邊有一個(gè)key和四個(gè)參數(shù),第一個(gè)參數(shù)是獲取一個(gè)令牌桶的時(shí)間間隔耳胎,第二個(gè)參數(shù)是重新填裝令牌的時(shí)間(精確到毫秒)儿咱,第三個(gè)是令牌桶的數(shù)量限制,第四個(gè)是隔多長(zhǎng)時(shí)間重新填裝令牌桶场晶。

--?bucket?name

localkey?=?KEYS[1]

--?token?generate?interval

localintervalPerPermit?=tonumber(ARGV[1])

--?grant?timestamp

localrefillTime?=tonumber(ARGV[2])

--?limit?token?count

locallimit?=tonumber(ARGV[3])

--?ratelimit?time?period

localinterval?=tonumber(ARGV[4])

localcounter?=?redis.call('hgetall',?key)

iftable.getn(counter)?==0then

--?first?check?if?bucket?not?exists,?if?yes,?create?a?new?one?with?full?capacity,?then?grant?access

redis.call('hmset',?key,'lastRefillTime',?refillTime,'tokensRemaining',?limit?-1)

--?expire?will?save?memory

redis.call('expire',?key,?interval)

return1

elseiftable.getn(counter)?==4then

--?if?bucket?exists,?first?we?try?to?refill?the?token?bucket

locallastRefillTime,?tokensRemaining?=tonumber(counter[2]),tonumber(counter[4])

localcurrentTokens

ifrefillTime?>?lastRefillTimethen

--?check?if?refillTime?larger?than?lastRefillTime.

--?if?not,?it?means?some?other?operation?later?than?this?call?made?the?call?first.

--?there?is?no?need?to?refill?the?tokens.

localintervalSinceLast?=?refillTime?-?lastRefillTime

ifintervalSinceLast?>?intervalthen

currentTokens?=?limit

redis.call('hset',?key,'lastRefillTime',?refillTime)

else

localgrantedTokens?=math.floor(intervalSinceLast?/?intervalPerPermit)

ifgrantedTokens?>0then

--?ajust?lastRefillTime,?we?want?shift?left?the?refill?time.

localpadMillis?=math.fmod(intervalSinceLast,?intervalPerPermit)

redis.call('hset',?key,'lastRefillTime',?refillTime?-?padMillis)

end

currentTokens?=math.min(grantedTokens?+?tokensRemaining,?limit)

end

else

--?if?not,?it?means?some?other?operation?later?than?this?call?made?the?call?first.

--?there?is?no?need?to?refill?the?tokens.

currentTokens?=?tokensRemaining

end

assert(currentTokens?>=0)

ifcurrentTokens?==0then

--?we?didn't?consume?any?keys

redis.call('hset',?key,'tokensRemaining',?currentTokens)

return0

else

--?we?take?1?token?from?the?bucket

redis.call('hset',?key,'tokensRemaining',?currentTokens?-1)

return1

end

else

error("Size?of?counter?is?"..table.getn(counter)?..",?Should?Be?0?or?4.")

end

看一下調(diào)用令牌桶l(fā)ua的JAVA代碼混埠,也比較簡(jiǎn)單:

publicBooleandistributedRateLimit(Stringkey,Stringlimit,Stringseconds)?{

Long?id?=0L;

long?intervalInMills?=?Long.valueOf(seconds)?*1000;

long?limitInLong?=?Long.valueOf(limit);

long?intervalPerPermit?=?intervalInMills?/?limitInLong;

//????????Long?refillTime?=?System.currentTimeMillis();

//????????log.info("調(diào)用redis執(zhí)行l(wèi)ua腳本,?{}?{}?{}?{}?{}",?"ratelimit",?intervalPerPermit,?refillTime,

//????????????????limit,?intervalInMills);

try{

id?=?redisTemplate.execute(rateLimitScript,?Collections.singletonList(key),

String.valueOf(intervalPerPermit),String.valueOf(System.currentTimeMillis()),

String.valueOf(limitInLong),String.valueOf(intervalInMills));

}catch(Exception?e)?{

log.error("error",?e);

}

if(id?==0L)?{

returnfalse;

}else{

returntrue;

}

}

創(chuàng)建兩張簡(jiǎn)單表,一個(gè)庫(kù)存表诗轻,一個(gè)是銷售訂單表:

CREATETABLE`catalog`(

`id`int(11)unsignedNOTNULLAUTO_INCREMENT,

`name`varchar(50)NOTNULLDEFAULT''COMMENT'名稱',

`total`int(11)NOTNULLCOMMENT'庫(kù)存',

`sold`int(11)NOTNULLCOMMENT'已售',

`version`int(11)NULLCOMMENT'樂(lè)觀鎖钳宪,版本號(hào)',

PRIMARYKEY(`id`)

)ENGINE=InnoDBDEFAULTCHARSET=utf8;

CREATETABLE`sales_order`(

`id`int(11)unsignedNOTNULLAUTO_INCREMENT,

`cid`int(11)NOTNULLCOMMENT'庫(kù)存ID',

`name`varchar(30)NOTNULLDEFAULT''COMMENT'商品名稱',

`create_time`timestampNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'創(chuàng)建時(shí)間',

PRIMARYKEY(`id`)

)ENGINE=InnoDBDEFAULTCHARSET=utf8;

基本已經(jīng)準(zhǔn)備完畢,然后啟動(dòng)程序扳炬,打開(kāi)swagger(http://localhost:8080/swagger-ui.html#)吏颖,執(zhí)行初始化方法initCatalog:

日志里邊會(huì)輸出初始化的記錄內(nèi)容,初始化庫(kù)存為1000:

初始化執(zhí)行的方法恨樟,十分簡(jiǎn)單半醉,寫到緩存中。

@Override

publicvoidinitCatalog()

{

Catalog?catalog?=newCatalog();

catalog.setName("mac");

catalog.setTotal(1000L);

catalog.setSold(0L);

catalogMapper.insertCatalog(catalog);

log.info("catalog:{}",?catalog);

redisTemplate.opsForValue().set(CATALOG_TOTAL?+?catalog.getId(),?catalog.getTotal().toString());

redisTemplate.opsForValue().set(CATALOG_SOLD?+?catalog.getId(),?catalog.getSold().toString());

log.info("redis?value:{}",?redisTemplate.opsForValue().get(CATALOG_TOTAL?+?catalog.getId()));

handleCatalog();

}

我寫了一個(gè)測(cè)試類劝术,啟動(dòng)3000個(gè)線程缩多,然后去進(jìn)行下單請(qǐng)求:

packagecom.hqs.flashsales;

importlombok.extern.slf4j.Slf4j;

importorg.junit.Test;

importorg.junit.runner.RunWith;

importorg.springframework.beans.factory.annotation.Autowired;

importorg.springframework.boot.test.context.SpringBootTest;

importorg.springframework.boot.test.web.client.TestRestTemplate;

importorg.springframework.test.context.junit4.SpringRunner;

importorg.springframework.util.LinkedMultiValueMap;

importorg.springframework.util.MultiValueMap;

importjava.util.concurrent.TimeUnit;

@Slf4j

@RunWith(SpringRunner.class)

@SpringBootTest(classes?=?FlashsalesApplication.class,?webEnvironment?=?SpringBootTest.WebEnvironment.RANDOM_PORT)

publicclassFlashSalesApplicationTests{

@Autowired

privateTestRestTemplate?testRestTemplate;

@Test

publicvoidflashsaleTest(){

String?url?="http://localhost:8080/placeOrder";

for(inti?=0;?i?<3000;?i++)?{

try{

TimeUnit.MILLISECONDS.sleep(20);

newThread(()?->?{

MultiValueMap?params?=newLinkedMultiValueMap<>();

params.add("orderId","1");

Long?result?=?testRestTemplate.postForObject(url,?params,?Long.class);

if(result?!=0)?{

System.out.println("-------------"+?result);

}

}

).start();

}catch(Exception?e)?{

log.info("error:{}",?e.getMessage());

}

}

}

@Test

publicvoidcontextLoads(){

}

}

然后開(kāi)始運(yùn)行測(cè)試代碼,查看一下測(cè)試日志和程序日志养晋,均顯示賣了1000后直接顯示SOLD OUT了衬吆。分別看一下日志和數(shù)據(jù)庫(kù):

商品庫(kù)存catalog表和訂單明細(xì)表sales_order表,都是1000條绳泉,沒(méi)有問(wèn)題逊抡。

總結(jié):

通過(guò)采用分布式鎖和分布式限流,即可實(shí)現(xiàn)秒殺流程零酪,當(dāng)然分布式限流也可以用到很多地方冒嫡,比如限制某些IP在多久時(shí)間訪問(wèn)接口多少次拇勃,都可以的。

令牌桶的限流方式使得請(qǐng)求可以得到更加平滑的處理孝凌,不至于瞬間把系統(tǒng)達(dá)到最高負(fù)載方咆。在這其中其實(shí)還有一個(gè)小細(xì)節(jié),就是Redis的鎖胎许,單機(jī)情況下沒(méi)有任何問(wèn)題峻呛,如果是集群的話需要注意罗售,一個(gè)key被hash到同一個(gè)slot的時(shí)候沒(méi)有問(wèn)題辜窑,如果說(shuō)擴(kuò)容或者縮容的話,如果key被hash到不同的slot寨躁,程序可能會(huì)出問(wèn)題穆碎。

在寫代碼的過(guò)程中還出現(xiàn)了一個(gè)小問(wèn)題,就是寫controller的方法的時(shí)候职恳,方法一定要聲明成public的所禀,否則自定義的注解用不了,其他service的注解直接變?yōu)榭辗徘眨@個(gè)問(wèn)題也是找了很久才找到色徘。

代碼地址:

https://github.com/stonehqs/flashsales.git

擴(kuò)展閱讀

Redis實(shí)現(xiàn)的分布式鎖和分布式限流

阿里淘寶雙十一秒殺系統(tǒng)設(shè)計(jì)詳解

從構(gòu)建分布式秒殺系統(tǒng)聊聊限流特技

高并發(fā)系統(tǒng)的設(shè)計(jì)及秒殺實(shí)踐

細(xì)說(shuō)JDK動(dòng)態(tài)代理的實(shí)現(xiàn)原理

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市操禀,隨后出現(xiàn)的幾起案子褂策,更是在濱河造成了極大的恐慌,老刑警劉巖颓屑,帶你破解...
    沈念sama閱讀 211,948評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件斤寂,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡揪惦,警方通過(guò)查閱死者的電腦和手機(jī)遍搞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,371評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)器腋,“玉大人溪猿,你說(shuō)我怎么就攤上這事∪宜” “怎么了再愈?”我有些...
    開(kāi)封第一講書人閱讀 157,490評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)护戳。 經(jīng)常有香客問(wèn)我翎冲,道長(zhǎng),這世上最難降的妖魔是什么媳荒? 我笑而不...
    開(kāi)封第一講書人閱讀 56,521評(píng)論 1 284
  • 正文 為了忘掉前任抗悍,我火速辦了婚禮驹饺,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘缴渊。我一直安慰自己赏壹,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,627評(píng)論 6 386
  • 文/花漫 我一把揭開(kāi)白布衔沼。 她就那樣靜靜地躺著蝌借,像睡著了一般。 火紅的嫁衣襯著肌膚如雪指蚁。 梳的紋絲不亂的頭發(fā)上菩佑,一...
    開(kāi)封第一講書人閱讀 49,842評(píng)論 1 290
  • 那天,我揣著相機(jī)與錄音凝化,去河邊找鬼稍坯。 笑死,一個(gè)胖子當(dāng)著我的面吹牛搓劫,可吹牛的內(nèi)容都是我干的瞧哟。 我是一名探鬼主播,決...
    沈念sama閱讀 38,997評(píng)論 3 408
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼枪向,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼勤揩!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起秘蛔,我...
    開(kāi)封第一講書人閱讀 37,741評(píng)論 0 268
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤陨亡,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后缠犀,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體数苫,經(jīng)...
    沈念sama閱讀 44,203評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,534評(píng)論 2 327
  • 正文 我和宋清朗相戀三年辨液,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了虐急。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,673評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡滔迈,死狀恐怖止吁,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情燎悍,我是刑警寧澤敬惦,帶...
    沈念sama閱讀 34,339評(píng)論 4 330
  • 正文 年R本政府宣布,位于F島的核電站谈山,受9級(jí)特大地震影響俄删,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,955評(píng)論 3 313
  • 文/蒙蒙 一畴椰、第九天 我趴在偏房一處隱蔽的房頂上張望臊诊。 院中可真熱鬧,春花似錦斜脂、人聲如沸抓艳。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 30,770評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)玷或。三九已至,卻和暖如春片任,著一層夾襖步出監(jiān)牢的瞬間偏友,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 32,000評(píng)論 1 266
  • 我被黑心中介騙來(lái)泰國(guó)打工蚂踊, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留约谈,地道東北人笔宿。 一個(gè)月前我還...
    沈念sama閱讀 46,394評(píng)論 2 360
  • 正文 我出身青樓犁钟,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親泼橘。 傳聞我的和親對(duì)象是個(gè)殘疾皇子涝动,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,562評(píng)論 2 349

推薦閱讀更多精彩內(nèi)容

  • 最近使用Redis優(yōu)化項(xiàng)目功能,其中有一部分為模糊查詢炬灭,找了很多帖子醋粟,也沒(méi)有找到很好的解決方案和思路,最終皇天不負(fù)...
    Java面試指南閱讀 66,209評(píng)論 1 2
  • 摘要:在開(kāi)發(fā)高并發(fā)系統(tǒng)時(shí)有三把利器用來(lái)保護(hù)系統(tǒng):緩存重归、降級(jí)和限流米愿。而有些場(chǎng)景并不能用緩存和降級(jí)來(lái)解決,因此需有一種...
    落羽成霜丶閱讀 2,150評(píng)論 0 18
  • 在開(kāi)發(fā)高并發(fā)系統(tǒng)時(shí)有三把利器用來(lái)保護(hù)系統(tǒng):緩存鼻吮、降級(jí)和限流育苟。緩存的目的是提升系統(tǒng)訪問(wèn)速度和增大系統(tǒng)能處理的容量,可...
    高級(jí)java架構(gòu)師閱讀 692評(píng)論 0 5
  • 聊聊高并發(fā)系統(tǒng)限流特技-1來(lái)自開(kāi)濤的博客 在開(kāi)發(fā)高并發(fā)系統(tǒng)時(shí)有三把利器用來(lái)保護(hù)系統(tǒng):緩存椎木、降級(jí)和限流违柏。緩存的目的是...
    meng_philip123閱讀 6,623評(píng)論 1 20
  • 緩存 緩存比較好理解,在大型高并發(fā)系統(tǒng)中香椎,如果沒(méi)有緩存數(shù)據(jù)庫(kù)將分分鐘被爆漱竖,系統(tǒng)也會(huì)瞬間癱瘓。使用緩存不單單能夠提升...
    阿斯蒂芬2閱讀 12,136評(píng)論 1 28