來(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ò)展閱讀
阿里淘寶雙十一秒殺系統(tǒng)設(shè)計(jì)詳解