聲明:本人并未參與過真正的秒殺系統(tǒng)設(shè)計,以下是本人學(xué)習(xí)筆記,自測通過,但可能并不完善,僅供參考,若用于生產(chǎn)出現(xiàn)問題,本人概不負責(zé)。
本文內(nèi)容有:
本文主要講思路,沒有將所有代碼貼出來,需要代碼的文末有源碼地址。
一、設(shè)計思路
秒殺系統(tǒng)的特點就是并發(fā)量大,一秒鐘就可能幾千幾萬的請求進來了,如果不使點兒手段,系統(tǒng)分分鐘就垮了。下面就探討一下如何設(shè)計一個能打的秒殺系統(tǒng)。
1、限流:
首先不考慮業(yè)務(wù)邏輯,假如有如下一個最簡單的接口:
@GetMapping("/test")
public String test() {
return "success";
}
這是一個最簡單的沒有任何邏輯的接口,但是如果同時有成千上萬的請求去訪問這個接口,服務(wù)器一樣會崩掉。所以,高并發(fā)系統(tǒng)該做的第一件事就是限流。springcloud項目可以使用hystrix進行限流,springcloud alibaba可以使用sentinel進行限流,那么非springcloud項目呢?guava為我們提供了一個RateLimiter工具類,可以做限流。它主要有漏桶算法和令牌桶算法。
漏桶算法:一個有洞洞的桶子在水龍頭下裝水,裝一點兒就漏一點兒,但是如果水龍頭的水很大,桶里的水遲早會溢出的,溢出就限流。這種適合做限制上傳下載速率一類的。
令牌桶算法:以恒定的速率往桶中放入令牌,每次請求進來,要先從桶中拿令牌,如果沒有拿到令牌,請求就被擋掉。這種適合做限流,即限制QPS。
這里應(yīng)該使用令牌桶算法進行限流,如果沒拿到令牌,直接返回“人太多了,擠不進去”的提示。
2、檢查用戶是否登錄:
經(jīng)過第一步的限流,進來的請求應(yīng)該檢查用戶是否登錄,本項目使用JWT,即先請求登錄接口,登錄后返回token,請求其他所有接口都在請求頭中帶上token,然后通過token就可以拿到用戶信息。如果沒拿到用戶信息,就返回“無效的token,請重新登錄”的提示。
3、檢查商品是否賣完:
通過了前兩步的校驗,就應(yīng)該檢查一下商品是否賣完了,如果賣完了就返回“來遲了,商品已秒殺完”的提示。注意,檢查商品是否賣完不能查數(shù)據(jù)庫,否則會很慢。我們可以搞個map,商品id作為key,如果賣完,值就設(shè)置為true,否則就是false。
4、將參加秒殺的商品加到redis中:
首先搞個ISINREDIS
的key,表示商品是否已經(jīng)加到redis中了,避免每個請求進來都重復(fù)此操作。如果ISINREDIS
值為false,表示redis中還沒有秒殺商品。那么就查詢出所有參加秒殺的商品,商品id作為key,商品庫存作為value,存到redis中,同時將商品id作為key,false作為value,放到第三步的map中,表示該商品沒有售完。最后將ISINREDIS
的值設(shè)置為true,表示已經(jīng)將所有參加秒殺的商品加到redis中了。
5、預(yù)扣庫存:
利用redis的decr對商品進行自減,然后對自減后的結(jié)果進行判斷。如果自減后結(jié)果小于0,表示商品已經(jīng)賣完了,那么就將map中對應(yīng)的商品id的值設(shè)置為true,并且返回“來遲了,商品已秒殺完”的提示。
6、判斷是否重復(fù)秒殺:
如果用戶秒殺成功,在秒殺訂單入庫后,會將用戶id和商品id作為key,true作為value存入redis中,表示該用戶已經(jīng)秒殺過該商品了。所以在這里就根據(jù)用戶id和商品id去redis中判斷是否重復(fù)秒殺,如果是,就返回“請勿重復(fù)秒殺”的提示。
7、異步處理:
如果以上校驗都通過了,那么就可以處理秒殺了。但是,如果處理每個秒殺請求我們都在數(shù)據(jù)庫進行扣庫存、創(chuàng)建訂單的操作,也是非常慢的,還有可能壓垮數(shù)據(jù)庫。所以我們可以異步處理,即通過了以上校驗,就將用戶id和商品id作為message發(fā)送到MQ中,然后立即給用戶返回“排隊中”的提示。然后在MQ的消費者端對消息進行消費,拿到用戶id和商品id,可以根據(jù)商品id查詢庫存,再次確保庫存充足;然后也可以再次判斷是否重復(fù)秒殺。通過了判斷后,就操作數(shù)據(jù)庫,扣減庫存,創(chuàng)建秒殺訂單。注意扣減庫存和創(chuàng)建秒殺訂單需要在同一個事務(wù)中。
8、超賣問題:
超賣問題就是商品庫存出現(xiàn)負數(shù)的情況。比如庫存剩余1了,然后10個用戶同時秒殺,在判斷庫存的時候都是1,所以10個人都能下單成功,最后庫存為-9。如何解決?其實本系統(tǒng)中根本就不會出現(xiàn)這樣的問題,因為一開始用redis進行了庫存預(yù)減,而redis命令核心模塊是單線程的,所以可以保證不會超賣。如果沒有用到redis,也可以給該商品增加一個version字段,每次扣減庫存前先查其version,扣減庫存的sql加上一個條件,就是version要等于剛才查出來的version。
二、核心代碼
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private UserService userService;
@Autowired
private SeckillService seckillService;
@Autowired
private RabbitMqSender mqSender;
// 用來標記商品是否已經(jīng)加入到redis中的key
private static final String ISINREDIS = "isInRedis";
// 用goodsId作為key,標記該商品是否已經(jīng)賣完
private Map<Integer, Boolean> seckillOver = new HashMap<Integer, Boolean>();
// 用RateLimiter做限流,create(10),可以理解為QPS閾值為10
private RateLimiter rateLimiter = RateLimiter.create(10);
@PostMapping("/{sgId}")
public JsonResult<?> seckillGoods(@PathVariable("sgId") Integer sgId, HttpServletRequest httpServletRequest){
// 1. 如果QPS閾值超過10,即1秒鐘內(nèi)沒有拿到令牌,就返回“人太多了,擠不進去”的提示
if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
return new JsonResult<>(SeckillGoodsEnum.TRY_AGAIN.getCode(), SeckillGoodsEnum.TRY_AGAIN.getMessage());
}
// 2. 檢查用戶是否登錄(用戶登錄后,訪問每個接口都應(yīng)該在請求頭帶上token,根據(jù)token再去拿user)
String token = httpServletRequest.getHeader("token");
String userId = JWT.decode(token).getAudience().get(0);
User user = userService.findUserById(Integer.valueOf(userId));
if (user == null) {
return new JsonResult<>(SeckillGoodsEnum.INVALID_TOKEN.getCode(), SeckillGoodsEnum.INVALID_TOKEN.getMessage());
}
// 3. 如果商品已經(jīng)秒殺完了,就不執(zhí)行下面的邏輯,直接返回商品已秒殺完的提示
if (!seckillOver.isEmpty() && seckillOver.get(sgId)) {
return new JsonResult<>(SeckillGoodsEnum.SECKILL_OVER.getCode(), SeckillGoodsEnum.SECKILL_OVER.getMessage());
}
// 4. 將所有參加秒殺的商品信息加入到redis中
if (!RedisUtil.isExist(ISINREDIS)) {
List<SeckillGoods> goods = seckillService.getAllSeckillGoods();
for (SeckillGoods seckillGoods : goods) {
RedisUtil.set(String.valueOf(seckillGoods.getSgId()), seckillGoods.getSgSeckillNum());
seckillOver.put(seckillGoods.getSgId(), false);
}
RedisUtil.set(ISINREDIS, true);
}
// 5. 先自減,預(yù)扣庫存,判斷預(yù)扣后庫存是否小于0,如果是,表示秒殺完了
Long stock = RedisUtil.decr(String.valueOf(sgId));
if (stock < 0) {
// 標記該商品已經(jīng)秒殺完
seckillOver.put(sgId, true);
return new JsonResult<>(SeckillGoodsEnum.SECKILL_OVER.getCode(), SeckillGoodsEnum.SECKILL_OVER.getMessage());
}
// 6. 判斷是否重復(fù)秒殺(成功秒殺并創(chuàng)建訂單后,會將userId和goodsId作為key放到redis中)
if (RedisUtil.isExist(userId + sgId)) {
return new JsonResult<>(SeckillGoodsEnum.REPEAT_SECKILL.getCode(), SeckillGoodsEnum.REPEAT_SECKILL.getMessage());
}
// 7. 以上校驗都通過了,就將當(dāng)前請求加入到MQ中,然后返回“排隊中”的提示
String msg = userId + "," + sgId;
mqSender.send(msg);
return new JsonResult<>(SeckillGoodsEnum.LINE_UP.getCode(), SeckillGoodsEnum.LINE_UP.getMessage());
}
}
三、壓測
用jmeter模擬并發(fā)請求,測試高并發(fā)情況下系統(tǒng)能否扛得住。由于只有一個id為1的商品,所以商品id固定寫死1。但是每個用戶都要先請求登錄接口獲取到token才能進行秒殺請求,有點兒麻煩,所以可以先把jwt模塊注釋掉,把userId當(dāng)成參數(shù)傳進去。jmeter配置如下圖:
jmeter壓測配置
jmeter壓測配置四、總結(jié)
秒殺系統(tǒng)的核心就是限流,防止高流量沖垮系統(tǒng);redis預(yù)減庫存,解決超賣問題;然后是異步下單,及時返回提示給用戶,提升用戶體驗,同時也減輕數(shù)據(jù)庫的壓力。
在本公眾號后臺發(fā)送"秒殺" 獲取項目源碼。